refactor: migrate business workflows to @upulse/workflows package
CI / test (push) Has been cancelled

- Move coding, coding-tdd, report, werewolf, cursor-health workflows
  to packages/pulse-workflows/
- Move analyst, architect, coder, renderer, reviewer roles to
  packages/pulse-workflows/
- Update all imports in migrated files to use @uncaged/pulse
- Update daemon and e2e files to import from @upulse/workflows
- Clean up core workflows/index.ts re-exports
- Add AgentExecutor, LlmRoleFactory, Scaffold exports to core index.ts
- Core package: 25 tests pass, workflows package: 40 tests pass

小橘 🍊 (NEKO Team)
This commit is contained in:
2026-04-18 09:56:24 +00:00
parent a3db066808
commit 40a7804dfb
119 changed files with 7392 additions and 555 deletions
+1
View File
@@ -0,0 +1 @@
dist/
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@upulse/workflows",
"version": "0.1.0",
"description": "Built-in workflow definitions for Pulse (coding, report, etc.)",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "bun x tsc",
"test": "bun test"
},
"dependencies": {
"@uncaged/pulse": "workspace:*"
},
"devDependencies": {
"bun-types": "^1.3.12"
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* @upulse/workflows — Built-in workflow definitions for Pulse.
*
* 小橘 🍊 (NEKO Team)
*/
export { type ArchitectMeta, type CloserMeta, type CoderMeta, type CodingRoles, createCodingWorkflow, type ReviewerMeta, } from './workflows/coding.js';
export { type AutoTesterMeta, type CreateTddCodingWorkflowOpts, createTddCodingWorkflow, type ManualTesterMeta, type TddCloserMeta, type TddCoderMeta, type TddCodingRoles, type TddReviewerMeta, type TestCoderMeta, type TestPlannerMeta, type TestReviewerMeta, } from './workflows/coding-tdd.js';
export { type AnalystMeta, createReportWorkflow, type RendererMeta, type ReportRoles, } from './workflows/report.js';
export { checkCursorHealth, type CursorHealthOptions, type CursorHealthResult, } from './workflows/cursor-health.js';
export { createWerewolfWorkflow, type CreateWerewolfWorkflowOpts, type DaySpeechMeta, filterChainForPlayer, type GameEndMeta, type GameState, type HunterShotMeta, type Identity, parseGameState, type Player, type SeerCheckMeta, type VoteMeta, type WerewolfRoles, type WitchActionMeta, type WolfNightMeta, createPlayers, } from './workflows/werewolf.js';
export { type AnalysisResult, createAnalystRole } from './roles/analyst-llm.js';
export { createArchitectRole } from './roles/architect-llm.js';
export { createCoderRole } from './roles/coder-cursor.js';
export { createRendererRole } from './roles/renderer-template.js';
export { createRendererRole as createLlmRendererRole } from './roles/renderer-llm.js';
export { createReviewerRole } from './roles/reviewer-cursor.js';
+18
View File
@@ -0,0 +1,18 @@
/**
* @upulse/workflows — Built-in workflow definitions for Pulse.
*
* 小橘 🍊 (NEKO Team)
*/
// ── Workflows ───────────────────────────────────────────────────
export { createCodingWorkflow, } from './workflows/coding.js';
export { createTddCodingWorkflow, } from './workflows/coding-tdd.js';
export { createReportWorkflow, } from './workflows/report.js';
export { checkCursorHealth, } from './workflows/cursor-health.js';
export { createWerewolfWorkflow, filterChainForPlayer, parseGameState, createPlayers, } from './workflows/werewolf.js';
// ── Roles ───────────────────────────────────────────────────────
export { createAnalystRole } from './roles/analyst-llm.js';
export { createArchitectRole } from './roles/architect-llm.js';
export { createCoderRole } from './roles/coder-cursor.js';
export { createRendererRole } from './roles/renderer-template.js';
export { createRendererRole as createLlmRendererRole } from './roles/renderer-llm.js';
export { createReviewerRole } from './roles/reviewer-cursor.js';
+71
View File
@@ -0,0 +1,71 @@
/**
* @upulse/workflows — Built-in workflow definitions for Pulse.
*
* 小橘 🍊 (NEKO Team)
*/
// ── Workflows ───────────────────────────────────────────────────
export {
type ArchitectMeta,
type CloserMeta,
type CoderMeta,
type CodingRoles,
createCodingWorkflow,
type ReviewerMeta,
} from './workflows/coding.js';
export {
type AutoTesterMeta,
type CreateTddCodingWorkflowOpts,
createTddCodingWorkflow,
type ManualTesterMeta,
type TddCloserMeta,
type TddCoderMeta,
type TddCodingRoles,
type TddReviewerMeta,
type TestCoderMeta,
type TestPlannerMeta,
type TestReviewerMeta,
} from './workflows/coding-tdd.js';
export {
type AnalystMeta,
createReportWorkflow,
type RendererMeta,
type ReportRoles,
} from './workflows/report.js';
export {
checkCursorHealth,
type CursorHealthOptions,
type CursorHealthResult,
} from './workflows/cursor-health.js';
export {
createWerewolfWorkflow,
type CreateWerewolfWorkflowOpts,
type DaySpeechMeta,
filterChainForPlayer,
type GameEndMeta,
type GameState,
type HunterShotMeta,
type Identity,
parseGameState,
type Player,
type SeerCheckMeta,
type VoteMeta,
type WerewolfRoles,
type WitchActionMeta,
type WolfNightMeta,
createPlayers,
} from './workflows/werewolf.js';
// ── Roles ───────────────────────────────────────────────────────
export { type AnalysisResult, createAnalystRole } from './roles/analyst-llm.js';
export { createArchitectRole } from './roles/architect-llm.js';
export { createCoderRole } from './roles/coder-cursor.js';
export { createRendererRole } from './roles/renderer-template.js';
export { createRendererRole as createLlmRendererRole } from './roles/renderer-llm.js';
export { createReviewerRole } from './roles/reviewer-cursor.js';
+30
View File
@@ -0,0 +1,30 @@
/**
* Analyst role — LLM reads workflow timeline JSON and produces structured analysis.
* Built on createToolRole factory (shared sandwich pattern).
*
* 小橘 🍊 (NEKO Team)
*/
import type { LlmClient } from '@uncaged/pulse';
import type { AnalystMeta } from '../workflows/report.js';
import type { Role } from '@uncaged/pulse';
export interface AnalysisResult {
score: number;
summary: string;
highlights: Array<{
text: string;
refEventId?: number;
}>;
issues: Array<{
text: string;
refEventId?: number;
}>;
suggestions: string[];
roleAnalysis: Array<{
eventId: number;
role: string;
durationMs?: number;
verdict: 'good' | 'slow' | 'problematic';
comment: string;
}>;
}
export declare function createAnalystRole(llm: LlmClient): Role<AnalystMeta>;
@@ -0,0 +1,118 @@
/**
* Analyst role — LLM reads workflow timeline JSON and produces structured analysis.
* Built on createToolRole factory (shared sandwich pattern).
*
* 小橘 🍊 (NEKO Team)
*/
import { createToolRole } from '@uncaged/pulse';
const SYSTEM_PROMPT = `You are a workflow execution analyst. You receive a workflow timeline (JSON with events array) and produce a structured analysis.
Analyze:
1. Overall quality and efficiency
2. Each role's performance (duration, output quality)
3. Issues: retries, slow steps, failures, rejected reviews
4. Concrete improvement suggestions
Use the extract_analysis tool to output your findings. Reference specific eventId values from the timeline.
IMPORTANT: All text fields (summary, highlights, issues, suggestions, comments) MUST be in Chinese (中文).`;
const ANALYSIS_TOOL = {
type: 'function',
function: {
name: 'extract_analysis',
description: 'Extract structured workflow analysis',
parameters: {
type: 'object',
properties: {
score: {
type: 'number',
minimum: 1,
maximum: 10,
description: 'Overall score 1-10 (10=perfect)',
},
summary: {
type: 'string',
description: 'One-sentence summary of the workflow execution (in Chinese)',
},
highlights: {
type: 'array',
items: {
type: 'object',
properties: {
text: { type: 'string' },
refEventId: { type: 'number' },
},
required: ['text'],
},
},
issues: {
type: 'array',
items: {
type: 'object',
properties: {
text: { type: 'string' },
refEventId: { type: 'number' },
},
required: ['text'],
},
},
suggestions: {
type: 'array',
items: { type: 'string' },
},
roleAnalysis: {
type: 'array',
items: {
type: 'object',
properties: {
eventId: { type: 'number' },
role: { type: 'string' },
durationMs: { type: 'number' },
verdict: {
type: 'string',
enum: ['good', 'slow', 'problematic'],
},
comment: { type: 'string' },
},
required: ['eventId', 'role', 'verdict', 'comment'],
},
},
},
required: [
'score',
'summary',
'highlights',
'issues',
'suggestions',
'roleAnalysis',
],
},
},
};
const DEFAULT_ANALYSIS = {
score: 5,
summary: '分析解析失败',
highlights: [],
issues: [{ text: '分析输出无法解析' }],
suggestions: [],
roleAnalysis: [],
};
export function createAnalystRole(llm) {
return createToolRole(llm, {
systemPrompt: SYSTEM_PROMPT,
buildUserMessage: (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
return `Workflow timeline:\n\n${startMsg?.content ?? '{}'}`;
},
tool: ANALYSIS_TOOL,
defaultResult: DEFAULT_ANALYSIS,
toRoleResult: (analysis) => ({
content: JSON.stringify(analysis),
meta: {
score: analysis.score,
highlights: analysis.highlights.map((h) => h.text),
issues: analysis.issues.map((i) => i.text),
},
}),
});
}
@@ -5,10 +5,10 @@
* 🍊 (NEKO Team)
*/
import type { LlmClient } from '../../llm-client.js';
import type { AnalystMeta } from '../report.js';
import type { Role } from '../workflow-type.js';
import { createToolRole } from './llm-role-factory.js';
import type { LlmClient } from '@uncaged/pulse';
import type { AnalystMeta } from '../workflows/report.js';
import type { Role } from '@uncaged/pulse';
import { createToolRole } from '@uncaged/pulse';
const SYSTEM_PROMPT = `You are a workflow execution analyst. You receive a workflow timeline (JSON with events array) and produce a structured analysis.
+12
View File
@@ -0,0 +1,12 @@
/**
* Architect role — LLM analyzes coding task and outputs structured design.
* Built on createLlmRole factory (shared sandwich pattern).
*
* Outputs: targetFiles, changes (file → description), verification criteria.
*
* 小橘 🍊 (NEKO Team)
*/
import type { LlmClient } from '@uncaged/pulse';
import type { ArchitectMeta } from '../workflows/coding.js';
import type { Role } from '@uncaged/pulse';
export declare function createArchitectRole(llmClient: LlmClient): Role<ArchitectMeta>;
@@ -0,0 +1,51 @@
/**
* Architect role — LLM analyzes coding task and outputs structured design.
* Built on createLlmRole factory (shared sandwich pattern).
*
* Outputs: targetFiles, changes (file → description), verification criteria.
*
* 小橘 🍊 (NEKO Team)
*/
import { createLlmRole } from '@uncaged/pulse';
export function createArchitectRole(llmClient) {
return createLlmRole(llmClient, {
systemPrompt: `You are a software architect. Analyze the task and produce a structured design.
Reply in JSON with these exact fields:
{
"analysis": "high-level summary",
"targetFiles": ["file1.ts", "file2.ts"],
"changes": { "file1.ts": "description of what to change", "file2.ts": "..." },
"verification": "how to verify the changes are correct (e.g. test commands, manual checks)"
}`,
buildUserMessage: (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
const title = startMsg?.meta?.title ?? 'unknown';
const description = startMsg?.content ?? '';
const repoDir = startMsg?.meta?.repoDir ?? '';
return `Task: ${title}\nDescription: ${description}\nRepo: ${repoDir}`;
},
parseResponse: (resp) => {
let parsed;
try {
parsed = JSON.parse(resp.content ?? '{}');
}
catch {
parsed = { analysis: resp.content ?? 'No analysis' };
}
const targetFiles = parsed.targetFiles ?? [];
const changes = parsed.changes ?? {};
if (Object.keys(changes).length === 0) {
for (const f of targetFiles)
changes[f] = 'Update this file';
}
return {
content: parsed.analysis ?? 'No analysis',
meta: {
targetFiles,
changes,
verification: parsed.verification ?? 'Run tests and verify manually',
},
};
},
});
}
@@ -5,8 +5,8 @@
*/
import { describe, expect, it } from 'bun:test';
import type { LlmClient } from '../../llm-client.js';
import type { WorkflowMessage } from '../workflow-type.js';
import type { LlmClient } from '@uncaged/pulse';
import type { WorkflowMessage } from '@uncaged/pulse';
import { createArchitectRole } from './architect-llm.js';
describe('createArchitectRole', () => {
@@ -7,10 +7,10 @@
* 🍊 (NEKO Team)
*/
import type { LlmClient } from '../../llm-client.js';
import type { ArchitectMeta } from '../coding.js';
import type { Role } from '../workflow-type.js';
import { createLlmRole } from './llm-role-factory.js';
import type { LlmClient } from '@uncaged/pulse';
import type { ArchitectMeta } from '../workflows/coding.js';
import type { Role } from '@uncaged/pulse';
import { createLlmRole } from '@uncaged/pulse';
export function createArchitectRole(llmClient: LlmClient): Role<ArchitectMeta> {
return createLlmRole<ArchitectMeta>(llmClient, {
+14
View File
@@ -0,0 +1,14 @@
/**
* Coder role — LLM-Agent-LLM sandwich via agent executor.
*
* 小橘 🍊 (NEKO Team)
*/
import type { LlmClient } from '@uncaged/pulse';
import type { CoderMeta } from '../workflows/coding.js';
import type { Role } from '@uncaged/pulse';
import { type AgentRunner } from '@uncaged/pulse';
export declare function createCoderRole(opts: {
agentBin?: string;
agent?: AgentRunner;
llm: LlmClient;
}): Role<CoderMeta>;
@@ -0,0 +1,91 @@
/**
* Coder role — LLM-Agent-LLM sandwich via agent executor.
*
* 小橘 🍊 (NEKO Team)
*/
import { createAgentExecutorRole, createCursorRunner, } from '@uncaged/pulse';
export function createCoderRole(opts) {
const agent = opts.agent ??
createCursorRunner({
agentBin: opts.agentBin ?? `${process.env.HOME}/.local/bin/agent`,
});
return createAgentExecutorRole(agent, opts.llm, {
prepPrompt: (chain, topicId) => {
const startMsg = chain.find((m) => m.role === '__start__');
const title = startMsg?.meta?.title ?? topicId;
const description = startMsg?.content ?? '';
const repoDir = startMsg?.meta?.repoDir ?? '/tmp';
const architectMsg = chain.find((m) => m.role === 'architect');
const lastReviewerMsg = [...chain]
.reverse()
.find((m) => m.role === 'reviewer');
const changes = architectMsg?.meta?.changes ?? {};
const verification = architectMsg?.meta?.verification ?? '';
const targetFiles = architectMsg?.meta?.targetFiles ?? [];
const changesSection = Object.entries(changes)
.map(([file, desc]) => `- **${file}**: ${desc}`)
.join('\n');
let rejectionSection = '';
if (lastReviewerMsg?.meta?.verdict === 'rejected') {
const reasons = lastReviewerMsg.meta.rejectionReason ?? [];
const reasonText = reasons.length > 0
? reasons.map((r, i) => `${i + 1}. ${r}`).join('\n')
: 'No reason given';
rejectionSection = `\n## Previous Review Rejection\n${reasonText}\n\nFix the issues above before proceeding.\n`;
}
const prompt = `## Task: ${title}
${description}
## Architect Analysis
${architectMsg?.content ?? 'None'}
## Target Files
${targetFiles.join(', ') || 'Not specified'}
## Per-File Changes
${changesSection || 'Not specified'}
## Verification Criteria
${verification || 'Not specified'}
${rejectionSection}
## Instructions
Implement the changes as described above. Do NOT modify any existing test files.
Run the verification criteria to confirm correctness. Commit your changes.`;
return { prompt, cwd: repoDir };
},
parseMeta: {
system: 'Extract structured metadata from this coding agent report. Call the extract_coder_meta tool.',
tool: {
type: 'function',
function: {
name: 'extract_coder_meta',
description: 'Extract coder metadata from agent output',
parameters: {
type: 'object',
properties: {
filesChanged: {
type: 'array',
items: { type: 'string' },
description: 'List of files created or modified',
},
testsPassed: {
type: 'boolean',
description: 'Whether tests passed successfully',
},
},
required: ['filesChanged', 'testsPassed'],
},
},
},
parse: (args) => {
const parsed = JSON.parse(args);
return {
filesChanged: parsed.filesChanged ?? [],
testsPassed: parsed.testsPassed ?? false,
};
},
defaultMeta: () => ({ filesChanged: [], testsPassed: false }),
},
});
}
@@ -8,8 +8,8 @@ import { afterEach, describe, expect, it } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createStore, type PulseStore } from '../../store.js';
import type { WorkflowMessage } from '../workflow-type.js';
import { createStore, type PulseStore } from '@uncaged/pulse';
import type { WorkflowMessage } from '@uncaged/pulse';
describe('coder-cursor role', () => {
let store: PulseStore;
@@ -4,14 +4,14 @@
* 🍊 (NEKO Team)
*/
import type { LlmClient } from '../../llm-client.js';
import type { CoderMeta } from '../coding.js';
import type { Role } from '../workflow-type.js';
import type { LlmClient } from '@uncaged/pulse';
import type { CoderMeta } from '../workflows/coding.js';
import type { Role } from '@uncaged/pulse';
import {
type AgentRunner,
createAgentExecutorRole,
createCursorRunner,
} from './agent-executor.js';
} from '@uncaged/pulse';
export function createCoderRole(opts: {
agentBin?: string;
+9
View File
@@ -0,0 +1,9 @@
/**
* Renderer role — LLM turns analyst output into a beautiful single-file HTML report.
*
* 小橘 🍊 (NEKO Team)
*/
import type { LlmClient } from '@uncaged/pulse';
import type { RendererMeta } from '../workflows/report.js';
import type { Role } from '@uncaged/pulse';
export declare function createRendererRole(llm: LlmClient): Role<RendererMeta>;
@@ -0,0 +1,57 @@
/**
* Renderer role — LLM turns analyst output into a beautiful single-file HTML report.
*
* 小橘 🍊 (NEKO Team)
*/
const SYSTEM_PROMPT = `You are a talented web designer who creates beautiful single-file HTML reports.
You receive:
1. A workflow analysis (from the analyst)
2. The raw timeline data (from __start__)
Generate a COMPLETE, self-contained HTML file with:
- Inline CSS (no external dependencies)
- A clean, modern design with a tasteful color palette
- A horizontal Gantt-style timeline bar showing each role's duration proportionally
- Role cards with: name, duration, status icon, meta highlights
- A summary header: task name, total time, score badge, pass/fail
- The analyst's highlights (green) and issues (red) as styled callouts
- Responsive layout (looks good on mobile too)
- Subtle animations (fade-in on scroll, progress bar fill)
Design guidelines:
- Background: #0f172a (dark slate), cards: #1e293b, accent: #38bdf8 (sky blue)
- Font: system-ui, clean sans-serif
- Score badge: colored by score (green ≥8, yellow ≥5, red <5)
- Role timeline: horizontal bar chart, each segment a different color
- Keep it under 500 lines of HTML
Output ONLY the complete HTML file, starting with <!DOCTYPE html> and ending with </html>.
Do NOT wrap it in markdown code blocks. Just raw HTML.`;
export function createRendererRole(llm) {
return async (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
const analystMsg = chain.find((m) => m.role === 'analyst');
const timelineJson = startMsg?.content ?? '{}';
const analysis = analystMsg?.content ?? 'No analysis available.';
const analysisMeta = analystMsg?.meta
? JSON.stringify(analystMsg.meta)
: '{}';
const resp = await llm.chat({
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{
role: 'user',
content: `## Timeline Data\n\`\`\`json\n${timelineJson}\n\`\`\`\n\n## Analyst Report\n${analysis}\n\n## Analyst Meta\n${analysisMeta}`,
},
],
});
let html = resp.content ?? '<html><body>Report generation failed</body></html>';
// Strip markdown code fences if LLM wrapped it
html = html.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
return {
content: html,
meta: { format: 'html', bytes: html.length },
};
};
}
@@ -4,9 +4,9 @@
* 🍊 (NEKO Team)
*/
import type { LlmClient } from '../../llm-client.js';
import type { RendererMeta } from '../report.js';
import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js';
import type { LlmClient } from '@uncaged/pulse';
import type { RendererMeta } from '../workflows/report.js';
import type { Role, RoleResult, WorkflowMessage } from '@uncaged/pulse';
const SYSTEM_PROMPT = `You are a talented web designer who creates beautiful single-file HTML reports.
@@ -0,0 +1,9 @@
/**
* Renderer role — pure code, no LLM. Generates a single-file HTML report
* from timeline JSON + analyst JSON.
*
* 小橘 🍊 (NEKO Team)
*/
import type { RendererMeta } from '../workflows/report.js';
import type { Role } from '@uncaged/pulse';
export declare function createRendererRole(): Role<RendererMeta>;
@@ -0,0 +1,236 @@
/**
* Renderer role — pure code, no LLM. Generates a single-file HTML report
* from timeline JSON + analyst JSON.
*
* 小橘 🍊 (NEKO Team)
*/
const ROLE_COLORS = {
__start__: '#94a3b8',
architect: '#a78bfa',
coder: '#38bdf8',
reviewer: '#34d399',
closer: '#f472b6',
analyst: '#fbbf24',
renderer: '#fb923c',
};
const VERDICT_COLORS = {
good: '#34d399',
slow: '#fbbf24',
problematic: '#f87171',
};
function esc(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fmtDuration(ms) {
if (ms < 1000)
return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function renderHtml(timeline, analysis) {
const roleMap = new Map();
for (const ra of analysis.roleAnalysis)
roleMap.set(ra.eventId, ra);
// Build gantt bars
const ganttBars = timeline.events
.filter((e) => e.role !== '__start__')
.map((e) => {
const pct = timeline.totalMs > 0 ? (e.durationMs / timeline.totalMs) * 100 : 0;
const color = ROLE_COLORS[e.role] ?? '#64748b';
const ra = roleMap.get(e.id);
const border = ra
? `border: 2px solid ${VERDICT_COLORS[ra.verdict] ?? '#64748b'}`
: '';
return `<div class="bar" style="width:${Math.max(pct, 2)}%;background:${color};${border}" title="${esc(e.role)}: ${fmtDuration(e.durationMs)}">${pct > 8 ? esc(e.role) : ''}</div>`;
})
.join('\n');
// Build event cards
const eventCards = timeline.events
.map((e) => {
const color = ROLE_COLORS[e.role] ?? '#64748b';
const ra = roleMap.get(e.id);
const verdictBadge = ra
? `<span class="badge" style="background:${VERDICT_COLORS[ra.verdict]}">${ra.verdict}</span>`
: '';
const comment = ra ? `<p class="comment">${esc(ra.comment)}</p>` : '';
const metaStr = e.meta
? `<pre class="meta">${esc(JSON.stringify(e.meta, null, 1))}</pre>`
: '';
const contentPreview = e.content
? `<details><summary>内容预览</summary><pre class="content">${esc(e.content.slice(0, 500))}${e.content.length > 500 ? '...' : ''}</pre></details>`
: '';
return `
<div class="card" id="event-${e.id}">
<div class="card-header">
<span class="role-dot" style="background:${color}"></span>
<span class="role-name">${esc(e.role)}</span>
<span class="event-id">#${e.id}</span>
<span class="duration">${fmtDuration(e.durationMs)}</span>
${verdictBadge}
</div>
${comment}
${metaStr}
${contentPreview}
</div>`;
})
.join('\n');
// Highlights & Issues
const highlightItems = analysis.highlights
.map((h) => {
const ref = h.refEventId
? ` <a href="#event-${h.refEventId}">#${h.refEventId}</a>`
: '';
return `<li class="highlight-item">✅ ${esc(h.text)}${ref}</li>`;
})
.join('\n');
const issueItems = analysis.issues
.map((i) => {
const ref = i.refEventId
? ` <a href="#event-${i.refEventId}">#${i.refEventId}</a>`
: '';
return `<li class="issue-item">⚠️ ${esc(i.text)}${ref}</li>`;
})
.join('\n');
const suggestionItems = analysis.suggestions
.map((s) => `<li>💡 ${esc(s)}</li>`)
.join('\n');
const scoreBg = analysis.score >= 8
? '#34d399'
: analysis.score >= 5
? '#fbbf24'
: '#f87171';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>工作流报告: ${esc(timeline.key)}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;padding:24px;max-width:960px;margin:0 auto}
h1{font-size:1.5rem;margin-bottom:4px}
.subtitle{color:#94a3b8;font-size:.875rem;margin-bottom:24px}
.summary{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:24px}
.stat{background:#1e293b;border-radius:8px;padding:16px 20px;flex:1;min-width:120px}
.stat-label{color:#94a3b8;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
.stat-value{font-size:1.5rem;font-weight:700;margin-top:4px}
.score-badge{display:inline-block;width:48px;height:48px;border-radius:50%;line-height:48px;text-align:center;font-size:1.25rem;font-weight:700;color:#0f172a}
.gantt{background:#1e293b;border-radius:8px;padding:16px;margin-bottom:24px}
.gantt h2{font-size:1rem;margin-bottom:12px;color:#94a3b8}
.gantt-track{display:flex;height:36px;border-radius:6px;overflow:hidden;gap:2px}
.bar{display:flex;align-items:center;justify-content:center;font-size:.75rem;font-weight:600;color:#0f172a;border-radius:4px;transition:opacity .2s}
.bar:hover{opacity:.8}
.section{margin-bottom:24px}
.section h2{font-size:1.125rem;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #334155}
.card{background:#1e293b;border-radius:8px;padding:16px;margin-bottom:8px}
.card-header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.role-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.role-name{font-weight:600}
.event-id{color:#64748b;font-size:.75rem}
.duration{color:#94a3b8;font-size:.875rem;margin-left:auto}
.badge{font-size:.7rem;padding:2px 8px;border-radius:10px;color:#0f172a;font-weight:600;text-transform:uppercase}
.comment{color:#cbd5e1;margin-top:8px;font-size:.875rem}
.meta{background:#0f172a;border-radius:4px;padding:8px;margin-top:8px;font-size:.75rem;color:#94a3b8;overflow-x:auto}
.content{background:#0f172a;border-radius:4px;padding:8px;font-size:.75rem;color:#94a3b8;max-height:200px;overflow:auto;white-space:pre-wrap}
details{margin-top:8px}
summary{cursor:pointer;color:#64748b;font-size:.75rem}
ul{list-style:none;padding:0}
li{padding:6px 0;font-size:.875rem}
.highlight-item{color:#34d399}
.issue-item{color:#fbbf24}
a{color:#38bdf8;text-decoration:none}
a:hover{text-decoration:underline}
@media(max-width:640px){.summary{flex-direction:column}.gantt-track{height:24px}}
</style>
</head>
<body>
<h1>📊 ${esc(timeline.key)}</h1>
<p class="subtitle">工作流执行报告 · Pulse Council v2 生成</p>
<div class="summary">
<div class="stat">
<div class="stat-label">评分</div>
<div class="stat-value"><span class="score-badge" style="background:${scoreBg}">${analysis.score}</span></div>
</div>
<div class="stat">
<div class="stat-label">总耗时</div>
<div class="stat-value">${fmtDuration(timeline.totalMs)}</div>
</div>
<div class="stat">
<div class="stat-label">步骤</div>
<div class="stat-value">${timeline.events.length}</div>
</div>
<div class="stat">
<div class="stat-label">总结</div>
<div style="margin-top:4px;font-size:.875rem">${esc(analysis.summary)}</div>
</div>
</div>
<div class="gantt">
<h2>时间线</h2>
<div class="gantt-track">
${ganttBars}
</div>
</div>
<div class="section">
<h2>事件</h2>
${eventCards}
</div>
<div class="section">
<h2>亮点</h2>
<ul>${highlightItems || '<li style="color:#64748b">无</li>'}</ul>
</div>
<div class="section">
<h2>问题</h2>
<ul>${issueItems || '<li style="color:#64748b">无</li>'}</ul>
</div>
<div class="section">
<h2>改进建议</h2>
<ul>${suggestionItems || '<li style="color:#64748b">无</li>'}</ul>
</div>
<footer style="margin-top:32px;padding-top:16px;border-top:1px solid #334155;color:#475569;font-size:.75rem">
小橘 🍊 · Pulse Council v2 · ${new Date().toISOString().slice(0, 19)}Z
</footer>
</body>
</html>`;
}
export function createRendererRole() {
return async (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
const analystMsg = chain.find((m) => m.role === 'analyst');
let timeline;
try {
timeline = JSON.parse(startMsg?.content ?? '{}');
}
catch {
timeline = { key: 'unknown', totalMs: 0, events: [] };
}
let analysis;
try {
analysis = JSON.parse(analystMsg?.content ?? '{}');
}
catch {
analysis = {
score: 0,
summary: 'Failed to parse analysis',
highlights: [],
issues: [],
suggestions: [],
roleAnalysis: [],
};
}
const html = renderHtml(timeline, analysis);
return {
content: html,
meta: { format: 'html', bytes: html.length },
};
};
}
@@ -5,8 +5,8 @@
* 🍊 (NEKO Team)
*/
import type { RendererMeta } from '../report.js';
import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js';
import type { RendererMeta } from '../workflows/report.js';
import type { Role, RoleResult, WorkflowMessage } from '@uncaged/pulse';
import type { AnalysisResult } from './analyst-llm.js';
interface TimelineEvent {
+17
View File
@@ -0,0 +1,17 @@
/**
* Reviewer role — LLM-Agent-LLM sandwich via agent executor.
*
* Compares coder's actual changes against architect's design (changes + verification).
* Outputs verdict, rejectionReason (when rejected), and monotonic retryCount.
*
* 小橘 🍊 (NEKO Team)
*/
import type { LlmClient } from '@uncaged/pulse';
import type { ReviewerMeta } from '../workflows/coding.js';
import type { Role } from '@uncaged/pulse';
import { type AgentRunner } from '@uncaged/pulse';
export declare function createReviewerRole(opts: {
agentBin?: string;
agent?: AgentRunner;
llm: LlmClient;
}): Role<ReviewerMeta>;
@@ -0,0 +1,112 @@
/**
* Reviewer role — LLM-Agent-LLM sandwich via agent executor.
*
* Compares coder's actual changes against architect's design (changes + verification).
* Outputs verdict, rejectionReason (when rejected), and monotonic retryCount.
*
* 小橘 🍊 (NEKO Team)
*/
import { createAgentExecutorRole, createCursorRunner, } from '@uncaged/pulse';
export function createReviewerRole(opts) {
const agent = opts.agent ??
createCursorRunner({
agentBin: opts.agentBin ?? `${process.env.HOME}/.local/bin/agent`,
});
const innerRole = createAgentExecutorRole(agent, opts.llm, {
prepPrompt: (chain, topicId) => {
const startMsg = chain.find((m) => m.role === '__start__');
const title = startMsg?.meta?.title ?? topicId;
const repoDir = startMsg?.meta?.repoDir ?? '/tmp';
const architectMsg = chain.find((m) => m.role === 'architect');
const coderMsg = [...chain].reverse().find((m) => m.role === 'coder');
const reviewerMsgs = chain.filter((m) => m.role === 'reviewer');
const retryCount = reviewerMsgs.length;
const architectChanges = architectMsg?.meta?.changes ?? {};
const architectVerification = architectMsg?.meta?.verification ?? '';
const changesSpec = Object.entries(architectChanges)
.map(([file, desc]) => `- **${file}**: ${desc}`)
.join('\n');
const prompt = `## Code Review: ${title} (review round ${retryCount})
## Architect's Design Specification
### Expected Changes
${changesSpec || 'Not specified'}
### Verification Criteria
${architectVerification || 'Not specified'}
## What the Coder Actually Did
${coderMsg?.content ?? 'Unknown'}
## Files Changed by Coder
${(coderMsg?.meta?.filesChanged ?? []).join(', ')}
## Instructions
1. Compare the coder's actual changes against the architect's expected changes above.
2. Verify the coder addressed each file and change described in the spec.
3. Check if the verification criteria can be satisfied.
4. Review for correctness, security, and code quality.
Do NOT modify any files. Only output your review.
End with a clear verdict: APPROVED or REJECTED.
If REJECTED, explain specifically what is wrong or missing.`;
return { prompt, cwd: repoDir };
},
parseMeta: {
system: 'Extract the review verdict and rejection reason from this code review report. Call the extract_review_verdict tool.',
tool: {
type: 'function',
function: {
name: 'extract_review_verdict',
description: 'Extract review verdict from reviewer output',
parameters: {
type: 'object',
properties: {
verdict: {
type: 'string',
enum: ['approved', 'rejected'],
description: 'Final review verdict',
},
rejectionReason: {
type: 'array',
items: { type: 'string' },
description: 'List of rejection reasons (empty array if approved)',
},
},
required: ['verdict', 'rejectionReason'],
},
},
},
parse: (args) => {
const parsed = JSON.parse(args);
const rawReason = parsed.rejectionReason;
const rejectionReason = Array.isArray(rawReason)
? rawReason
: rawReason
? [rawReason]
: [];
return {
verdict: parsed.verdict === 'rejected' ? 'rejected' : 'approved',
rejectionReason,
retryCount: 0,
};
},
defaultMeta: (output) => {
const tail = output.toLowerCase().slice(-200);
const isRejected = tail.includes('reject');
return {
verdict: isRejected ? 'rejected' : 'approved',
rejectionReason: isRejected ? [output.slice(-500)] : [],
retryCount: 0,
};
},
},
});
return async (chain, topicId, store) => {
const result = await innerRole(chain, topicId, store);
const retryCount = chain.filter((m) => m.role === 'reviewer').length;
return {
...result,
meta: result.meta ? { ...result.meta, retryCount } : result.meta,
};
};
}
@@ -8,8 +8,8 @@ import { afterEach, describe, expect, it } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createStore, type PulseStore } from '../../store.js';
import type { WorkflowMessage } from '../workflow-type.js';
import { createStore, type PulseStore } from '@uncaged/pulse';
import type { WorkflowMessage } from '@uncaged/pulse';
describe('reviewer-cursor role', () => {
let store: PulseStore;
@@ -7,14 +7,14 @@
* 🍊 (NEKO Team)
*/
import type { LlmClient } from '../../llm-client.js';
import type { ReviewerMeta } from '../coding.js';
import type { Role } from '../workflow-type.js';
import type { LlmClient } from '@uncaged/pulse';
import type { ReviewerMeta } from '../workflows/coding.js';
import type { Role } from '@uncaged/pulse';
import {
type AgentRunner,
createAgentExecutorRole,
createCursorRunner,
} from './agent-executor.js';
} from '@uncaged/pulse';
export function createReviewerRole(opts: {
agentBin?: string;
+64
View File
@@ -0,0 +1,64 @@
/**
* TDD-driven coding workflow — test-planner → … → closer.
*
* Pure roles + START/END automaton. Trigger: coding-tdd.__start__
*
* 设计里的 role `type`(llm / code / agent)在实现层统一为 `Role<…>`;默认全部为可注入的
* mock,生产环境通过 `CreateTddCodingWorkflowOpts` 传入 `createLlmRole` / agent 工厂等。
*
* 小橘 🍊 (NEKO Team)
*/
import { type Role, type WorkflowType } from '@uncaged/pulse';
export type TestPlannerMeta = {
testPlan: string;
scenarios: string[];
};
export type TestReviewerMeta = {
verdict: 'approved' | 'rejected';
feedback: string;
};
export type TestCoderMeta = {
testFiles: string[];
testCount: number;
};
export type TddCoderMeta = {
filesChanged: string[];
deploymentGuide: string;
};
export type AutoTesterMeta = {
pass: boolean;
failedTests: string[];
output: string;
};
export type ManualTesterMeta = {
pass: boolean;
issues: string[];
};
export type TddReviewerMeta = {
verdict: 'approved' | 'rejected';
comments: string;
codeQuality: string;
testQuality: string;
};
export type TddCloserMeta = Record<string, never>;
export type TddCodingRoles = {
'test-planner': Role<TestPlannerMeta>;
'test-reviewer': Role<TestReviewerMeta>;
'test-coder': Role<TestCoderMeta>;
coder: Role<TddCoderMeta>;
'auto-tester': Role<AutoTesterMeta>;
'manual-tester': Role<ManualTesterMeta>;
reviewer: Role<TddReviewerMeta>;
closer: Role<TddCloserMeta>;
};
export type CreateTddCodingWorkflowOpts = {
testPlannerFn?: Role<TestPlannerMeta>;
testReviewerFn?: Role<TestReviewerMeta>;
testCoderFn?: Role<TestCoderMeta>;
coderFn?: Role<TddCoderMeta>;
autoTesterFn?: Role<AutoTesterMeta>;
manualTesterFn?: Role<ManualTesterMeta>;
reviewerFn?: Role<TddReviewerMeta>;
closerFn?: Role<TddCloserMeta>;
};
export declare function createTddCodingWorkflow(opts?: CreateTddCodingWorkflowOpts): WorkflowType<TddCodingRoles>;
@@ -0,0 +1,144 @@
/**
* TDD-driven coding workflow — test-planner → … → closer.
*
* Pure roles + START/END automaton. Trigger: coding-tdd.__start__
*
* 设计里的 role `type`(llm / code / agent)在实现层统一为 `Role<…>`;默认全部为可注入的
* mock,生产环境通过 `CreateTddCodingWorkflowOpts` 传入 `createLlmRole` / agent 工厂等。
*
* 小橘 🍊 (NEKO Team)
*/
import { END, START, } from '@uncaged/pulse';
// ── Default mock implementations ────────────────────────────────
const defaultTestPlanner = async (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
const title = startMsg?.meta?.title ?? 'task';
return {
content: `[mock] Test plan draft for "${title}"`,
meta: {
testPlan: `# Tests for ${title}\n- Case A\n- Case B`,
scenarios: ['happy path', 'edge case'],
},
};
};
const defaultTestReviewer = async (chain) => {
const planner = chain.find((m) => m.role === 'test-planner');
return {
content: `[mock] Review of plan: ${planner?.content?.slice(0, 40) ?? ''}`,
meta: { verdict: 'approved', feedback: 'LGTM' },
};
};
const defaultTestCoder = async () => ({
content: '[mock] Red tests written',
meta: {
testFiles: ['src/foo.test.ts'],
testCount: 2,
},
});
const defaultCoder = async (chain) => {
const tc = chain.find((m) => m.role === 'test-coder');
const n = tc?.meta?.testCount ?? 0;
return {
content: '[mock] Implementation',
meta: {
filesChanged: ['src/foo.ts'],
deploymentGuide: `Run tests (${n} cases); open http://localhost`,
},
};
};
const defaultAutoTester = async () => ({
content: '[mock] bun test output',
meta: {
pass: true,
failedTests: [],
output: 'all tests passed',
},
});
const defaultManualTester = async () => ({
content: '[mock] Manual validation notes',
meta: { pass: true, issues: [] },
});
const defaultTddReviewer = async () => ({
content: '[mock] Review complete',
meta: {
verdict: 'approved',
comments: 'ok',
codeQuality: 'good',
testQuality: 'good',
},
});
const defaultCloser = async (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
const title = startMsg?.meta?.title ?? 'task';
return {
content: `[mock] TDD workflow report: ${title}`,
meta: {},
};
};
function tddCodingModerator(output, _topicId, remainingRounds) {
const emergency = remainingRounds !== undefined && remainingRounds <= 1;
if (output.role === START)
return 'test-planner';
switch (output.role) {
case 'test-planner':
return 'test-reviewer';
case 'test-reviewer': {
const verdict = output.meta?.verdict;
if (verdict === 'approved')
return 'test-coder';
if (verdict === 'rejected') {
if (emergency)
return 'closer';
return 'test-planner';
}
return 'test-planner';
}
case 'test-coder':
return 'coder';
case 'coder':
return 'auto-tester';
case 'auto-tester': {
if (output.meta?.pass)
return 'manual-tester';
if (emergency)
return 'closer';
return 'coder';
}
case 'manual-tester': {
if (output.meta?.pass)
return 'reviewer';
if (emergency)
return 'closer';
return 'coder';
}
case 'reviewer': {
if (emergency)
return 'closer';
if (output.meta?.verdict === 'approved')
return 'closer';
return 'coder';
}
case 'closer':
return END;
default:
return END;
}
}
// ── Factory ────────────────────────────────────────────────────
export function createTddCodingWorkflow(opts) {
return {
name: 'coding-tdd',
roles: {
'test-planner': opts?.testPlannerFn ?? defaultTestPlanner,
'test-reviewer': opts?.testReviewerFn ?? defaultTestReviewer,
'test-coder': opts?.testCoderFn ?? defaultTestCoder,
coder: opts?.coderFn ?? defaultCoder,
'auto-tester': opts?.autoTesterFn ?? defaultAutoTester,
'manual-tester': opts?.manualTesterFn ?? defaultManualTester,
reviewer: opts?.reviewerFn ?? defaultTddReviewer,
closer: opts?.closerFn ?? defaultCloser,
},
moderator: tddCodingModerator,
limits: { maxRounds: 25 },
};
}
@@ -8,10 +8,10 @@ import { describe, expect, it } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createStore, type PulseStore } from '../store.js';
import { createStore, type PulseStore } from '@uncaged/pulse';
import { createTddCodingWorkflow } from './coding-tdd.js';
import { createWorkflowRule } from './workflow-rule-adapter.js';
import { END, START } from './workflow-type.js';
import { createWorkflowRule } from '@uncaged/pulse';
import { END, START } from '@uncaged/pulse';
describe('coding-tdd WorkflowType', () => {
let store: PulseStore;
@@ -15,7 +15,7 @@ import {
type Role,
START,
type WorkflowType,
} from './workflow-type.js';
} from '@uncaged/pulse';
// ── Role Meta types ────────────────────────────────────────────
+36
View File
@@ -0,0 +1,36 @@
/**
* CodingTask WorkflowType — pure roles + START/END automaton.
*
* Roles: architect → coder → reviewer → closer
* Trigger: coding.__start__ (external)
* Each role returns { content, meta } — adapter writes events.
*
* 小橘 🍊 (NEKO Team)
*/
import { type Role, type WorkflowType } from '@uncaged/pulse';
export type ArchitectMeta = {
targetFiles: string[];
changes: Record<string, string>;
verification: string;
};
export type CoderMeta = {
filesChanged: string[];
testsPassed: boolean;
};
export type ReviewerMeta = {
verdict: 'approved' | 'rejected';
rejectionReason: string[];
retryCount: number;
};
export type CloserMeta = null;
export type CodingRoles = {
architect: Role<ArchitectMeta>;
coder: Role<CoderMeta>;
reviewer: Role<ReviewerMeta>;
closer: Role<CloserMeta>;
};
export declare function createCodingWorkflow(opts?: {
architectFn?: Role<ArchitectMeta>;
coderFn?: Role<CoderMeta>;
reviewerFn?: Role<ReviewerMeta>;
}): WorkflowType<CodingRoles>;
@@ -0,0 +1,87 @@
/**
* CodingTask WorkflowType — pure roles + START/END automaton.
*
* Roles: architect → coder → reviewer → closer
* Trigger: coding.__start__ (external)
* Each role returns { content, meta } — adapter writes events.
*
* 小橘 🍊 (NEKO Team)
*/
import { END, START, } from '@uncaged/pulse';
// ── Default mock implementations ───────────────────────────────
const defaultArchitect = async (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
const title = startMsg?.meta?.title ?? 'unknown';
const targetFiles = ['src/main.ts', 'src/utils.ts'];
return {
content: `[mock] Analysis for "${title}": ${startMsg?.content ?? ''}`,
meta: {
targetFiles,
changes: {
'src/main.ts': 'Update main entry point',
'src/utils.ts': 'Add utility helpers',
},
verification: 'Run `bun test` and verify all tests pass',
},
};
};
const defaultCoder = async (chain) => {
const architectMsg = chain.find((m) => m.role === 'architect');
const filesChanged = architectMsg?.meta?.targetFiles ?? [
'src/main.ts',
];
return {
content: '[mock] Implemented changes',
meta: { filesChanged, testsPassed: true },
};
};
const defaultReviewer = async (chain) => {
const reviewerMsgs = chain.filter((m) => m.role === 'reviewer');
const retryCount = reviewerMsgs.length;
return {
content: '[mock] Code looks good',
meta: { verdict: 'approved', rejectionReason: [], retryCount },
};
};
const defaultCloser = async (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
const title = startMsg?.meta?.title ?? 'unknown';
return {
content: `Completed: ${title}`,
meta: null,
};
};
function codingModerator(output, _topicId, remainingRounds) {
if (output.role === START)
return 'architect';
switch (output.role) {
case 'architect':
return 'coder';
case 'coder':
return 'reviewer';
case 'reviewer': {
if (remainingRounds !== undefined && remainingRounds <= 1)
return 'closer';
const rejected = output.meta?.verdict === 'rejected';
const retryCount = output.meta?.retryCount ?? 0;
return rejected && retryCount < 3 ? 'coder' : 'closer';
}
case 'closer':
return END;
}
return END;
}
// ── Factory ────────────────────────────────────────────────────
export function createCodingWorkflow(opts) {
return {
name: 'coding',
roles: {
architect: opts?.architectFn ?? defaultArchitect,
coder: opts?.coderFn ?? defaultCoder,
reviewer: opts?.reviewerFn ?? defaultReviewer,
closer: defaultCloser,
},
moderator: codingModerator,
limits: { maxRounds: 15 },
};
}
@@ -8,9 +8,9 @@ import { afterEach, describe, expect, it } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createStore, type PulseStore } from '../store.js';
import { createStore, type PulseStore } from '@uncaged/pulse';
import { createCodingWorkflow } from './coding.js';
import { createWorkflowRule } from './workflow-rule-adapter.js';
import { createWorkflowRule } from '@uncaged/pulse';
describe('CodingTask WorkflowType', () => {
let store: PulseStore;
@@ -14,7 +14,7 @@ import {
type Role,
START,
type WorkflowType,
} from './workflow-type.js';
} from '@uncaged/pulse';
// ── Role Meta types ────────────────────────────────────────────
@@ -0,0 +1,24 @@
/**
* Cursor Health Rule — Cursor 用量哨兵
*
* 监控 Cursor AI 调用频率,防止 workflow 死循环烧额度。
* 读取 ~/.cursor/ai-tracking/ai-code-tracking.db 检查最近调用次数。
*
* 小橘 <xiaoju@shazhou.work> 🍊 (NEKO Team)
*/
export interface CursorHealthResult {
recentCount: number;
threshold: number;
isHealthy: boolean;
windowMs: number;
checkedAt: number;
}
export interface CursorHealthOptions {
dbPath?: string;
windowMs?: number;
threshold?: number;
}
/**
* 检查 Cursor AI 调用健康状态
*/
export declare function checkCursorHealth(opts?: CursorHealthOptions): CursorHealthResult;
@@ -0,0 +1,41 @@
/**
* Cursor Health Rule — Cursor 用量哨兵
*
* 监控 Cursor AI 调用频率,防止 workflow 死循环烧额度。
* 读取 ~/.cursor/ai-tracking/ai-code-tracking.db 检查最近调用次数。
*
* 小橘 <xiaoju@shazhou.work> 🍊 (NEKO Team)
*/
import { Database } from 'bun:sqlite';
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* 检查 Cursor AI 调用健康状态
*/
export function checkCursorHealth(opts = {}) {
const { dbPath = join(homedir(), '.cursor', 'ai-tracking', 'ai-code-tracking.db'), windowMs = 15 * 60 * 1000, // 15 分钟
threshold = 100, } = opts;
const checkedAt = Date.now();
const windowStart = checkedAt - windowMs;
let recentCount = 0;
try {
const db = new Database(dbPath, { readonly: true });
// 查询最近时间窗口内的记录数
const query = db.query('SELECT COUNT(*) as count FROM ai_code_hashes WHERE timestamp >= ?');
const result = query.get(windowStart);
recentCount = result?.count ?? 0;
db.close();
}
catch (error) {
// 数据库不存在或无法访问时,认为没有调用记录
console.warn('Failed to read Cursor tracking DB:', error);
recentCount = 0;
}
return {
recentCount,
threshold,
isHealthy: recentCount <= threshold,
windowMs,
checkedAt,
};
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Report WorkflowType — analyst → renderer.
*
* Pure LLM workflow: analyst reads timeline JSON and produces structured analysis,
* renderer turns it into a beautiful single-file HTML report.
*
* Trigger: report.__start__ with timeline JSON as content
*
* 小橘 🍊 (NEKO Team)
*/
import { type Role, type WorkflowType } from '@uncaged/pulse';
export type AnalystMeta = {
score: number;
highlights: string[];
issues: string[];
};
export type RendererMeta = {
format: 'html';
bytes: number;
};
export type ReportRoles = {
analyst: Role<AnalystMeta>;
renderer: Role<RendererMeta>;
};
export declare function createReportWorkflow(opts?: {
analystFn?: Role<AnalystMeta>;
rendererFn?: Role<RendererMeta>;
}): WorkflowType<ReportRoles>;
@@ -0,0 +1,49 @@
/**
* Report WorkflowType — analyst → renderer.
*
* Pure LLM workflow: analyst reads timeline JSON and produces structured analysis,
* renderer turns it into a beautiful single-file HTML report.
*
* Trigger: report.__start__ with timeline JSON as content
*
* 小橘 🍊 (NEKO Team)
*/
import { END, START, } from '@uncaged/pulse';
// ── Default mock implementations ───────────────────────────────
const defaultAnalyst = async (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
return {
content: `[mock] Analysis of workflow: ${startMsg?.content?.slice(0, 100) ?? 'unknown'}`,
meta: { score: 8, highlights: ['completed'], issues: [] },
};
};
const defaultRenderer = async (chain) => {
const analystMsg = chain.find((m) => m.role === 'analyst');
const html = `<!DOCTYPE html><html><body><h1>Report</h1><p>${analystMsg?.content ?? ''}</p></body></html>`;
return {
content: html,
meta: { format: 'html', bytes: html.length },
};
};
function reportModerator(output, _topicId) {
if (output.role === START)
return 'analyst';
switch (output.role) {
case 'analyst':
return 'renderer';
case 'renderer':
return END;
}
return END;
}
// ── Factory ────────────────────────────────────────────────────
export function createReportWorkflow(opts) {
return {
name: 'report',
roles: {
analyst: opts?.analystFn ?? defaultAnalyst,
renderer: opts?.rendererFn ?? defaultRenderer,
},
moderator: reportModerator,
};
}
@@ -5,11 +5,11 @@
*/
import { describe, expect, test } from 'bun:test';
import { createStore, type Store } from '../store.js';
import { createStore, type PulseStore } from '@uncaged/pulse';
import { createReportWorkflow } from './report.js';
import { createWorkflowRule } from './workflow-rule-adapter.js';
import { createWorkflowRule } from '@uncaged/pulse';
function tmpStore(): { store: Store; cleanup: () => void } {
function tmpStore(): { store: PulseStore; cleanup: () => void } {
const dir = `${require('node:os').tmpdir()}/report-wf-test-${Date.now()}`;
require('node:fs').mkdirSync(dir, { recursive: true });
const store = createStore({
@@ -15,7 +15,7 @@ import {
type Role,
START,
type WorkflowType,
} from './workflow-type.js';
} from '@uncaged/pulse';
// ── Role Meta types ────────────────────────────────────────────
+105
View File
@@ -0,0 +1,105 @@
/**
* Werewolf (狼人杀) workflow — 9-player game with information asymmetry.
*
* Pure roles + START/END automaton. Trigger: werewolf.__start__
*
* Roles are per-phase (not per-player). Each phase role iterates over
* the relevant players internally. Game state is rebuilt from chain
* (event sourcing via parseGameState).
*
* Default: all mock roles (no LLM). Inject real implementations via
* CreateWerewolfWorkflowOpts.
*
* 小橘 🍊 (NEKO Team)
*/
import { type Role, type WorkflowMessage, type WorkflowType } from '@uncaged/pulse';
export type Team = 'wolf' | 'good';
export interface Identity {
name: string;
team: Team;
abilities?: string;
}
export interface Player {
id: string;
name: string;
identity: Identity;
}
export interface DeadPlayer extends Player {
cause: 'wolf-kill' | 'vote' | 'poison' | 'hunter-shot';
day: number;
}
export interface GameState {
players: Player[];
alive: Player[];
dead: DeadPlayer[];
day: number;
phase: string;
witchPotion: boolean;
witchPoison: boolean;
lastKill: string | null;
lastDeath: DeadPlayer | null;
}
export declare function createPlayers(): Player[];
export declare function parseGameState(chain: WorkflowMessage[]): GameState;
export declare function filterChainForPlayer(chain: WorkflowMessage[], playerId: string, identity: Identity): WorkflowMessage[];
export type WolfNightMeta = {
phase: 'wolf-night';
targetId: string;
visibleTo?: undefined;
};
export type SeerCheckMeta = {
phase: 'seer-check';
targetId: string;
isWolf: boolean;
visibleTo: string[];
};
export type WitchActionMeta = {
phase: 'witch-action';
saved: boolean;
poisonTarget: string | null;
visibleTo: string[];
witchPotion: boolean;
witchPoison: boolean;
};
export type DaySpeechMeta = {
phase: 'day-speech';
speeches: Array<{
playerId: string;
speech: string;
}>;
};
export type VoteMeta = {
phase: 'vote';
votes: Record<string, string>;
eliminatedId: string | null;
};
export type HunterShotMeta = {
phase: 'hunter-shot';
shotTarget: string;
};
export type GameEndMeta = {
phase: 'game-end';
winner: Team;
summary: string;
};
export type WerewolfRoles = {
'wolf-night': Role<WolfNightMeta>;
'seer-check': Role<SeerCheckMeta>;
'witch-action': Role<WitchActionMeta>;
'day-speech': Role<DaySpeechMeta>;
'vote': Role<VoteMeta>;
'hunter-shot': Role<HunterShotMeta>;
'game-end': Role<GameEndMeta>;
};
declare function checkGameOver(alive: Player[]): boolean;
export type CreateWerewolfWorkflowOpts = {
wolfNightFn?: Role<WolfNightMeta>;
seerCheckFn?: Role<SeerCheckMeta>;
witchActionFn?: Role<WitchActionMeta>;
daySpeechFn?: Role<DaySpeechMeta>;
voteFn?: Role<VoteMeta>;
hunterShotFn?: Role<HunterShotMeta>;
gameEndFn?: Role<GameEndMeta>;
};
export declare function createWerewolfWorkflow(opts?: CreateWerewolfWorkflowOpts): WorkflowType<WerewolfRoles>;
export { checkGameOver };
@@ -0,0 +1,510 @@
/**
* Werewolf (狼人杀) workflow — 9-player game with information asymmetry.
*
* Pure roles + START/END automaton. Trigger: werewolf.__start__
*
* Roles are per-phase (not per-player). Each phase role iterates over
* the relevant players internally. Game state is rebuilt from chain
* (event sourcing via parseGameState).
*
* Default: all mock roles (no LLM). Inject real implementations via
* CreateWerewolfWorkflowOpts.
*
* 小橘 🍊 (NEKO Team)
*/
import { END, START, } from '@uncaged/pulse';
// ── Player setup (9 players) ───────────────────────────────────
const IDENTITIES = [
{ name: '狼人', team: 'wolf' },
{ name: '狼人', team: 'wolf' },
{ name: '狼人', team: 'wolf' },
{ name: '预言家', team: 'good', abilities: '每晚可查验一名玩家身份' },
{ name: '女巫', team: 'good', abilities: '有一瓶解药和一瓶毒药' },
{ name: '猎人', team: 'good', abilities: '死亡时可开枪带走一人' },
{ name: '村民', team: 'good' },
{ name: '村民', team: 'good' },
{ name: '村民', team: 'good' },
];
export function createPlayers() {
return IDENTITIES.map((id, i) => ({
id: `p${i + 1}`,
name: `玩家${i + 1}`,
identity: id,
}));
}
// ── Game state from chain (event sourcing) ─────────────────────
export function parseGameState(chain) {
const players = createPlayers();
const dead = [];
let day = 1;
let witchPotion = true;
let witchPoison = true;
let lastKill = null;
let lastDeath = null;
let phase = '';
for (const msg of chain) {
const meta = msg.meta;
if (!meta)
continue;
if (meta.phase === 'wolf-night') {
phase = 'wolf-night';
if (meta.targetId)
lastKill = meta.targetId;
}
if (meta.phase === 'witch-action') {
phase = 'witch-action';
if (meta.saved === true) {
lastKill = null;
witchPotion = false;
}
if (meta.poisonTarget) {
witchPoison = false;
const pid = meta.poisonTarget;
const p = players.find(pp => pp.id === pid);
if (p && !dead.some(d => d.id === pid)) {
const dp = { ...p, cause: 'poison', day };
dead.push(dp);
lastDeath = dp;
}
}
}
if (meta.phase === 'dawn') {
// Wolf kill resolves at dawn (after witch action)
if (lastKill) {
const p = players.find(pp => pp.id === lastKill);
if (p && !dead.some(d => d.id === lastKill)) {
const dp = { ...p, cause: 'wolf-kill', day };
dead.push(dp);
lastDeath = dp;
}
}
lastKill = null;
}
if (meta.phase === 'day-speech')
phase = 'day-speech';
if (meta.phase === 'vote') {
phase = 'vote';
if (meta.eliminatedId) {
const pid = meta.eliminatedId;
const p = players.find(pp => pp.id === pid);
if (p && !dead.some(d => d.id === pid)) {
const dp = { ...p, cause: 'vote', day };
dead.push(dp);
lastDeath = dp;
}
}
}
if (meta.phase === 'hunter-shot') {
phase = 'hunter-shot';
if (meta.shotTarget) {
const pid = meta.shotTarget;
const p = players.find(pp => pp.id === pid);
if (p && !dead.some(d => d.id === pid)) {
const dp = { ...p, cause: 'hunter-shot', day };
dead.push(dp);
lastDeath = dp;
}
}
}
if (meta.phase === 'new-night') {
day++;
lastKill = null;
}
if (meta.witchPotion === false)
witchPotion = false;
if (meta.witchPoison === false)
witchPoison = false;
}
const deadIds = new Set(dead.map(d => d.id));
const alive = players.filter(p => !deadIds.has(p.id));
return { players, alive, dead, day, phase, witchPotion, witchPoison, lastKill, lastDeath };
}
// ── Information visibility filter ──────────────────────────────
export function filterChainForPlayer(chain, playerId, identity) {
return chain.filter(msg => {
const meta = msg.meta;
if (!meta)
return true; // system messages without meta are public
const phase = meta.phase;
const visibleTo = meta.visibleTo;
// Public phases
if (phase === 'day-speech' || phase === 'vote' || phase === 'death' ||
phase === 'dawn' || phase === 'hunter-shot' || phase === 'game-end' ||
phase === 'new-night')
return true;
// Wolf night: only wolves see
if (phase === 'wolf-night')
return identity.team === 'wolf';
// Seer / witch: only visible to target player
if (phase === 'seer-check' || phase === 'witch-action') {
return visibleTo ? visibleTo.includes(playerId) : false;
}
// System messages with explicit visibility
if (phase === 'system') {
return visibleTo ? visibleTo.includes(playerId) : true;
}
return true;
});
}
// ── Helpers ────────────────────────────────────────────────────
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
// ── Default mock implementations ────────────────────────────────
const defaultWolfNight = async (chain) => {
const state = parseGameState(chain);
const goodAlive = state.alive.filter(p => p.identity.team === 'good');
const target = pick(goodAlive);
return {
content: `[狼人夜晚] 狼人决定击杀 ${target.name}`,
meta: { phase: 'wolf-night', targetId: target.id },
};
};
const defaultSeerCheck = async (chain) => {
const state = parseGameState(chain);
const seer = state.alive.find(p => p.identity.name === '预言家');
if (!seer) {
return {
content: '[预言家已死,跳过]',
meta: { phase: 'seer-check', targetId: '', isWolf: false, visibleTo: [] },
};
}
const others = state.alive.filter(p => p.id !== seer.id);
const target = pick(others);
return {
content: `[预言家查验] ${target.name} 的身份是${target.identity.team === 'wolf' ? '狼人' : '好人'}`,
meta: {
phase: 'seer-check',
targetId: target.id,
isWolf: target.identity.team === 'wolf',
visibleTo: [seer.id],
},
};
};
const defaultWitchAction = async (chain) => {
const state = parseGameState(chain);
const witch = state.alive.find(p => p.identity.name === '女巫');
if (!witch) {
// Witch dead — still emit dawn event with kill resolved
const died = [];
if (state.lastKill)
died.push(state.lastKill);
return {
content: `[女巫已死,跳过] 天亮了${died.length ? ',有人死亡' : ''}`,
meta: {
phase: 'witch-action',
saved: false,
poisonTarget: null,
visibleTo: [],
witchPotion: state.witchPotion,
witchPoison: state.witchPoison,
},
};
}
let saved = false;
let poisonTarget = null;
// 50% chance to save if someone was killed and potion available
if (state.lastKill && state.witchPotion && Math.random() < 0.5) {
saved = true;
}
return {
content: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}${poisonTarget ? `,毒杀了${poisonTarget}` : ''}`,
meta: {
phase: 'witch-action',
saved,
poisonTarget,
visibleTo: [witch.id],
witchPotion: saved ? false : state.witchPotion,
witchPoison: poisonTarget ? false : state.witchPoison,
},
};
};
const defaultDaySpeech = async (chain) => {
const state = parseGameState(chain);
const speeches = state.alive.map(p => ({
playerId: p.id,
speech: `我是${p.name},我觉得场上形势很复杂,大家要理性分析。`,
}));
return {
content: speeches.map(s => `${s.playerId}${s.speech}`).join('\n'),
meta: { phase: 'day-speech', speeches },
};
};
const defaultVote = async (chain) => {
const state = parseGameState(chain);
const votes = {};
for (const p of state.alive) {
const others = state.alive.filter(o => o.id !== p.id);
votes[p.id] = pick(others).id;
}
// Tally
const tally = {};
for (const target of Object.values(votes)) {
tally[target] = (tally[target] || 0) + 1;
}
const maxVotes = Math.max(...Object.values(tally));
const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k);
const eliminatedId = topIds.length === 1 ? topIds[0] : pick(topIds);
const eliminated = state.alive.find(p => p.id === eliminatedId);
return {
content: `[投票结果] ${eliminated?.name ?? eliminatedId} 被放逐出局`,
meta: { phase: 'vote', votes, eliminatedId },
};
};
const defaultHunterShot = async (chain) => {
const state = parseGameState(chain);
// Hunter shoots a random alive player
const target = pick(state.alive);
return {
content: `[猎人开枪] 猎人带走了 ${target.name}`,
meta: { phase: 'hunter-shot', shotTarget: target.id },
};
};
const defaultGameEnd = async (chain) => {
const state = parseGameState(chain);
const wolves = state.alive.filter(p => p.identity.team === 'wolf');
const winner = wolves.length === 0 ? 'good' : 'wolf';
return {
content: `[游戏结束] ${winner === 'good' ? '好人阵营' : '狼人阵营'}获胜!`,
meta: {
phase: 'game-end',
winner,
summary: `存活: ${state.alive.map(p => p.name).join(', ')}; 死亡: ${state.dead.map(d => `${d.name}(${d.cause})`).join(', ')}`,
},
};
};
function werewolfModerator(output, _topicId, remainingRounds) {
const emergency = remainingRounds !== undefined && remainingRounds <= 1;
if (output.role === START)
return 'wolf-night';
// Build state from meta for win-condition checks
const meta = output.meta;
switch (output.role) {
case 'wolf-night':
return 'seer-check';
case 'seer-check':
return 'witch-action';
case 'witch-action':
return 'day-speech';
case 'day-speech':
return 'vote';
case 'vote': {
// Check if game should end after vote
if (meta?.gameOver === true || emergency)
return 'game-end';
// Check if hunter was eliminated
if (meta?.hunterTriggered === true)
return 'hunter-shot';
return 'wolf-night';
}
case 'hunter-shot': {
if (meta?.gameOver === true || emergency)
return 'game-end';
return 'wolf-night';
}
case 'game-end':
return END;
default:
return END;
}
}
// Wait — the moderator doesn't have access to game state from the chain.
// It only gets meta from the last role output. So roles need to include
// win-condition info in their meta. Let me revise the approach:
// The vote role and hunter-shot role should check win conditions and set
// meta flags. Similarly wolf-night + witch-action combined produce deaths
// that day-speech should account for.
//
// Actually, looking at the adapter code, moderator only gets last role's meta.
// So each role that might end the game needs to put gameOver + hunterTriggered in meta.
// Let me redesign: make a "smart" moderator that rebuilds state from chain.
// But moderator only gets (output, topicId) — no chain access.
// The design doc shows moderator reading meta.gameState.
// Solution: roles embed necessary routing info in meta.
// Revised approach: vote/hunter roles add gameOver and hunterTriggered flags.
// wolf-night already doesn't end game. witch-action doesn't either (dawn deaths
// are checked when entering day-speech... actually no, we need to check after
// night phases complete).
//
// Simpler: day-speech role checks win condition before speeches (night deaths resolved).
// If game over, it signals in meta.
//
// Actually let me look at this more carefully. The moderator transition is:
// wolf-night → seer-check → witch-action → day-speech → vote → wolf-night (loop)
// Win conditions are checked: after vote (good might have eliminated all wolves),
// and at the start of day (wolf kill at night might tip the balance).
//
// The cleanest approach: each role that resolves deaths includes gameOver flag.
// Moderator checks it to route to game-end.
// Let me just rewrite moderator and tweak the roles to include routing metadata.
// ── Revised Moderator (final) ──────────────────────────────────
function werewolfModeratorFinal(output, _topicId, remainingRounds) {
const emergency = remainingRounds !== undefined && remainingRounds <= 1;
if (emergency)
return 'game-end';
if (output.role === START)
return 'wolf-night';
const meta = output.meta;
const gameOver = meta?.gameOver === true;
switch (output.role) {
case 'wolf-night':
return 'seer-check';
case 'seer-check':
return 'witch-action';
case 'witch-action':
// After witch action, check if night deaths end the game
if (gameOver)
return 'game-end';
return 'day-speech';
case 'day-speech':
return 'vote';
case 'vote': {
if (gameOver)
return 'game-end';
if (meta?.hunterTriggered === true)
return 'hunter-shot';
return 'wolf-night';
}
case 'hunter-shot': {
if (gameOver)
return 'game-end';
return 'wolf-night';
}
case 'game-end':
return END;
default:
return END;
}
}
// ── Revised mock roles with routing metadata ───────────────────
// We need roles to compute game-over and hunter-triggered flags.
// The witch-action role resolves night deaths, so it checks win condition.
// The vote role resolves day deaths, so it checks win condition + hunter trigger.
// The hunter-shot role resolves hunter death, so it checks win condition.
function checkGameOver(alive) {
const wolves = alive.filter(p => p.identity.team === 'wolf');
if (wolves.length === 0)
return true;
if (wolves.length >= alive.length - wolves.length)
return true;
return false;
}
// Re-implement witch-action to resolve dawn deaths and check win condition
const mockWitchAction = async (chain) => {
const state = parseGameState(chain);
const witch = state.alive.find(p => p.identity.name === '女巫');
let saved = false;
let poisonTarget = null;
const witchId = witch?.id ?? '';
if (witch) {
if (state.lastKill && state.witchPotion && Math.random() < 0.5) {
saved = true;
}
}
// Simulate dawn: compute who actually dies
const nightDead = [];
if (state.lastKill && !saved)
nightDead.push(state.lastKill);
if (poisonTarget)
nightDead.push(poisonTarget);
// Check win condition after night deaths
const deadIds = new Set([...state.dead.map(d => d.id), ...nightDead]);
const aliveAfter = state.players.filter(p => !deadIds.has(p.id));
const gameOver = checkGameOver(aliveAfter);
return {
content: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}`,
meta: {
phase: 'witch-action',
saved,
poisonTarget,
visibleTo: witchId ? [witchId] : [],
witchPotion: saved ? false : state.witchPotion,
witchPoison: poisonTarget ? false : state.witchPoison,
gameOver,
},
};
};
const mockVote = async (chain) => {
// First resolve dawn deaths from last night
const statePreDawn = parseGameState(chain);
// We need to account for dawn deaths. The chain should already have witch-action
// which has saved/not-saved info. parseGameState handles lastKill resolution
// at dawn phase, but we haven't emitted a dawn event.
// Actually, parseGameState only processes dawn phase if there's a message with phase:'dawn'.
// Since we don't emit that, we need to handle it differently.
// Let's resolve: after witch-action, the wolf kill is still pending in lastKill
// unless saved. We need to consider it as death for voting purposes.
// Compute effective alive list (after night)
const nightDead = [];
if (statePreDawn.lastKill) {
// Check if witch saved
const witchMsg = [...chain].reverse().find(m => m.meta?.phase === 'witch-action');
const witchSaved = witchMsg?.meta?.saved === true;
if (!witchSaved)
nightDead.push(statePreDawn.lastKill);
}
const deadSet = new Set([...statePreDawn.dead.map(d => d.id), ...nightDead]);
const effectiveAlive = statePreDawn.players.filter(p => !deadSet.has(p.id));
const votes = {};
for (const p of effectiveAlive) {
const others = effectiveAlive.filter(o => o.id !== p.id);
if (others.length > 0)
votes[p.id] = pick(others).id;
}
const tally = {};
for (const target of Object.values(votes)) {
tally[target] = (tally[target] || 0) + 1;
}
let eliminatedId = null;
if (Object.keys(tally).length > 0) {
const maxVotes = Math.max(...Object.values(tally));
const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k);
eliminatedId = topIds.length === 1 ? topIds[0] : pick(topIds);
}
const aliveAfterVote = effectiveAlive.filter(p => p.id !== eliminatedId);
const gameOver = checkGameOver(aliveAfterVote);
const eliminated = effectiveAlive.find(p => p.id === eliminatedId);
const hunterTriggered = eliminated?.identity.name === '猎人';
return {
content: `[投票结果] ${eliminated?.name ?? '无人'} 被放逐出局`,
meta: {
phase: 'vote',
votes,
eliminatedId,
hunterTriggered,
gameOver,
},
};
};
const mockHunterShot = async (chain) => {
const state = parseGameState(chain);
// Hunter can't shoot themselves; pick from alive (hunter is already dead from vote)
const target = pick(state.alive);
const aliveAfter = state.alive.filter(p => p.id !== target.id);
const gameOver = checkGameOver(aliveAfter);
return {
content: `[猎人开枪] 猎人带走了 ${target.name}`,
meta: {
phase: 'hunter-shot',
shotTarget: target.id,
gameOver,
},
};
};
export function createWerewolfWorkflow(opts) {
return {
name: 'werewolf',
roles: {
'wolf-night': opts?.wolfNightFn ?? defaultWolfNight,
'seer-check': opts?.seerCheckFn ?? defaultSeerCheck,
'witch-action': opts?.witchActionFn ?? mockWitchAction,
'day-speech': opts?.daySpeechFn ?? defaultDaySpeech,
'vote': opts?.voteFn ?? mockVote,
'hunter-shot': opts?.hunterShotFn ?? mockHunterShot,
'game-end': opts?.gameEndFn ?? defaultGameEnd,
},
moderator: werewolfModeratorFinal,
limits: { maxRounds: 50 },
};
}
export { checkGameOver };
@@ -20,7 +20,7 @@ import {
START,
type WorkflowMessage,
type WorkflowType,
} from './workflow-type.js';
} from '@uncaged/pulse';
// ── Types ──────────────────────────────────────────────────────
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["bun-types"]
},
"include": ["src"],
"exclude": ["**/*.test.ts"]
}
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@uncaged/pulse",
"version": "0.1.0",
"description": "Pulse core engine stateful reactive loop with S-combinator rule composition",
"description": "Pulse core engine \u2014 stateful reactive loop with S-combinator rule composition",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -29,7 +29,8 @@
"devDependencies": {
"@types/node": "^25.6.0",
"bun-types": "latest",
"typescript": "^6.0.2"
"typescript": "^6.0.2",
"@upulse/workflows": "workspace:*"
},
"dependencies": {
"jsonata": "^2.1.0",
+6 -4
View File
@@ -14,16 +14,18 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { createOpenAiLlmClient } from '../llm-client.js';
import { createStore } from '../store.js';
import { createCodingWorkflow } from '../workflows/coding.js';
import {
createCodingWorkflow,
createReportWorkflow,
createAnalystRole,
createRendererRole,
} from '@upulse/workflows';
import { createWorkflowTicker } from '../workflows/index.js';
import { createMetaWorkflow } from '../workflows/meta.js';
import { createCursorRunner } from '../workflows/roles/agent-executor.js';
import { createMetaCoderRole } from '../workflows/roles/meta-coder-cursor.js';
import { createMetaTesterRole } from '../workflows/roles/meta-tester.js';
import { createMetaCheckerRole } from '../workflows/roles/meta-checker.js';
import { createReportWorkflow } from '../workflows/report.js';
import { createAnalystRole } from '../workflows/roles/analyst-llm.js';
import { createRendererRole } from '../workflows/roles/renderer-template.js';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
// ── Config ─────────────────────────────────────────────────────
+82
View File
@@ -0,0 +1,82 @@
/**
* @uncaged/pulse — Definition Layer (Phase 1)
*
* Append-only definition tables for objects, events, and projections.
* Content-addressed versioning with code_rev binding.
*/
import type { Database } from 'bun:sqlite';
export interface ObjectDef {
name: string;
codeRev: string;
createdAt: number;
}
export interface EventDef {
hash: string;
name: string;
parentHash?: string;
schema?: any;
codeRev: string;
createdAt: number;
}
export interface ProjectionDef {
hash: string;
name: string;
parentHash?: string;
params?: any;
valueSchema?: any;
initialValue: any;
codeRev: string;
createdAt: number;
sources: Array<{
eventKind: string;
eventKey?: string;
expression: string;
}>;
}
export interface ValidationResult {
valid: boolean;
result?: any;
error?: string;
}
/**
* Initialize the definition schema on an existing database connection.
* Use this when you manage the database lifecycle externally.
*/
export declare function initDefsSchema(db: Database): Promise<void>;
export declare function registerObjectDef(db: Database, opts: {
name: string;
codeRev: string;
}): Promise<ObjectDef>;
export declare function getObjectDef(db: Database, name: string, codeRev: string): Promise<ObjectDef | null>;
export declare function registerEventDef(db: Database, opts: {
name: string;
schema?: any;
parentHash?: string;
codeRev: string;
}): Promise<EventDef>;
export declare function getEventDef(db: Database, name: string, codeRev: string): Promise<EventDef | null>;
export declare function listEventDefs(db: Database, opts: {
codeRev: string;
}): Promise<EventDef[]>;
export declare function registerProjectionDef(db: Database, opts: {
name: string;
params?: any;
valueSchema?: any;
initialValue: any;
sources: Array<{
eventKind: string;
eventKey?: string;
expression: string;
}>;
parentHash?: string;
codeRev: string;
}): Promise<ProjectionDef>;
export declare function getProjectionDef(db: Database, name: string, codeRev: string): Promise<ProjectionDef | null>;
export declare function listProjectionDefs(db: Database, opts: {
codeRev: string;
}): Promise<ProjectionDef[]>;
export declare function validateExpression(opts: {
expression: string;
initialValue: any;
mockEvent: any;
}): Promise<ValidationResult>;
+322
View File
@@ -0,0 +1,322 @@
/**
* @uncaged/pulse — Definition Layer (Phase 1)
*
* Append-only definition tables for objects, events, and projections.
* Content-addressed versioning with code_rev binding.
*/
import { createHash } from 'node:crypto';
import jsonata from 'jsonata';
// ── Database Schema ────────────────────────────────────────────
const OBJECT_DEFS_SCHEMA = `
CREATE TABLE IF NOT EXISTS object_defs (
name TEXT NOT NULL,
code_rev TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (name, code_rev)
)
`;
const EVENT_DEFS_SCHEMA = `
CREATE TABLE IF NOT EXISTS event_defs (
hash TEXT NOT NULL,
name TEXT NOT NULL,
parent_hash TEXT,
schema TEXT,
code_rev TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (name, code_rev)
);
CREATE INDEX IF NOT EXISTS idx_event_defs_hash ON event_defs(hash);
`;
const PROJECTION_DEFS_SCHEMA = `
CREATE TABLE IF NOT EXISTS projection_defs (
hash TEXT NOT NULL,
name TEXT NOT NULL,
parent_hash TEXT,
params TEXT,
value_schema TEXT,
initial_value TEXT NOT NULL,
code_rev TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (name, code_rev)
);
CREATE INDEX IF NOT EXISTS idx_projection_defs_hash ON projection_defs(hash);
`;
const PROJECTION_DEF_SOURCES_SCHEMA = `
CREATE TABLE IF NOT EXISTS projection_def_sources (
projection_hash TEXT NOT NULL,
event_kind TEXT NOT NULL,
event_key TEXT,
expression TEXT NOT NULL,
FOREIGN KEY (projection_hash) REFERENCES projection_defs(hash)
)
`;
// ── Schema Initialization ──────────────────────────────────────
/**
* Initialize the definition schema on an existing database connection.
* Use this when you manage the database lifecycle externally.
*/
export async function initDefsSchema(db) {
db.exec(OBJECT_DEFS_SCHEMA);
db.exec(EVENT_DEFS_SCHEMA);
db.exec(PROJECTION_DEFS_SCHEMA);
db.exec(PROJECTION_DEF_SOURCES_SCHEMA);
}
// ── Hash Calculation ───────────────────────────────────────────
function calculateEventHash(name, schema) {
const content = name + JSON.stringify(schema || null);
return createHash('sha256').update(content).digest('hex');
}
function calculateProjectionHash(name, params, valueSchema, initialValue, sources) {
const hashInput = JSON.stringify({
name,
initialValue,
params: params || null,
valueSchema: valueSchema || null,
sources: sources
? sources.map((s) => ({
eventKind: s.eventKind,
eventKey: s.eventKey,
expression: s.expression,
}))
: null,
});
return createHash('sha256').update(hashInput).digest('hex');
}
// ── Object Definitions ─────────────────────────────────────────
const insertObjectDef = (db) => db.prepare(`
INSERT INTO object_defs (name, code_rev, created_at)
VALUES (?, ?, ?)
`);
const selectObjectDef = (db) => db.prepare(`
SELECT name, code_rev, created_at
FROM object_defs
WHERE name = ? AND code_rev = ?
`);
export async function registerObjectDef(db, opts) {
const createdAt = Date.now();
try {
insertObjectDef(db).run(opts.name, opts.codeRev, createdAt);
}
catch (error) {
if (error.message.includes('UNIQUE constraint failed')) {
throw new Error(`Object definition already exists: ${opts.name}@${opts.codeRev}`);
}
throw error;
}
return {
name: opts.name,
codeRev: opts.codeRev,
createdAt,
};
}
export async function getObjectDef(db, name, codeRev) {
const row = selectObjectDef(db).get(name, codeRev);
if (!row)
return null;
return {
name: row.name,
codeRev: row.code_rev,
createdAt: row.created_at,
};
}
// ── Event Definitions ──────────────────────────────────────────
const insertEventDef = (db) => db.prepare(`
INSERT INTO event_defs (hash, name, parent_hash, schema, code_rev, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
const selectEventDefByNameCodeRev = (db) => db.prepare(`
SELECT hash, name, parent_hash, schema, code_rev, created_at
FROM event_defs
WHERE name = ? AND code_rev = ?
`);
const selectEventDefsByCodeRev = (db) => db.prepare(`
SELECT hash, name, parent_hash, schema, code_rev, created_at
FROM event_defs
WHERE code_rev = ?
ORDER BY name
`);
export async function registerEventDef(db, opts) {
const hash = calculateEventHash(opts.name, opts.schema);
const createdAt = Date.now();
try {
insertEventDef(db).run(hash, opts.name, opts.parentHash || null, opts.schema ? JSON.stringify(opts.schema) : null, opts.codeRev, createdAt);
}
catch (error) {
if (error.message.includes('UNIQUE constraint failed')) {
throw new Error(`Event definition already exists: ${opts.name}@${opts.codeRev}`);
}
throw error;
}
return {
hash,
name: opts.name,
parentHash: opts.parentHash,
schema: opts.schema,
codeRev: opts.codeRev,
createdAt,
};
}
export async function getEventDef(db, name, codeRev) {
const row = selectEventDefByNameCodeRev(db).get(name, codeRev);
if (!row)
return null;
return {
hash: row.hash,
name: row.name,
parentHash: row.parent_hash || undefined,
schema: row.schema ? JSON.parse(row.schema) : undefined,
codeRev: row.code_rev,
createdAt: row.created_at,
};
}
export async function listEventDefs(db, opts) {
const rows = selectEventDefsByCodeRev(db).all(opts.codeRev);
return rows.map((row) => ({
hash: row.hash,
name: row.name,
parentHash: row.parent_hash || undefined,
schema: row.schema ? JSON.parse(row.schema) : undefined,
codeRev: row.code_rev,
createdAt: row.created_at,
}));
}
// ── Projection Definitions ─────────────────────────────────────
const insertProjectionDef = (db) => db.prepare(`
INSERT INTO projection_defs (hash, name, parent_hash, params, value_schema, initial_value, code_rev, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertProjectionDefSource = (db) => db.prepare(`
INSERT INTO projection_def_sources (projection_hash, event_kind, event_key, expression)
VALUES (?, ?, ?, ?)
`);
const selectProjectionDefByNameCodeRev = (db) => db.prepare(`
SELECT hash, name, parent_hash, params, value_schema, initial_value, code_rev, created_at
FROM projection_defs
WHERE name = ? AND code_rev = ?
`);
const selectProjectionDefsByCodeRev = (db) => db.prepare(`
SELECT hash, name, parent_hash, params, value_schema, initial_value, code_rev, created_at
FROM projection_defs
WHERE code_rev = ?
ORDER BY name
`);
const selectProjectionDefSources = (db) => db.prepare(`
SELECT event_kind, event_key, expression
FROM projection_def_sources
WHERE projection_hash = ?
`);
export async function registerProjectionDef(db, opts) {
const hash = calculateProjectionHash(opts.name, opts.params, opts.valueSchema, opts.initialValue, opts.sources);
const createdAt = Date.now();
// Validate JSONata expressions with dry-run
for (const source of opts.sources) {
const mockEvent = {
kind: source.eventKind,
key: source.eventKey || 'test',
data: {},
};
const validation = await validateExpression({
expression: source.expression,
initialValue: opts.initialValue,
mockEvent,
});
if (!validation.valid) {
throw new Error(`Invalid JSONata expression for ${source.eventKind}: ${validation.error}`);
}
}
// Use transaction for atomic insertion
const transaction = db.transaction(() => {
try {
insertProjectionDef(db).run(hash, opts.name, opts.parentHash || null, opts.params ? JSON.stringify(opts.params) : null, opts.valueSchema ? JSON.stringify(opts.valueSchema) : null, JSON.stringify(opts.initialValue), opts.codeRev, createdAt);
// Insert sources
for (const source of opts.sources) {
insertProjectionDefSource(db).run(hash, source.eventKind, source.eventKey || null, source.expression);
}
}
catch (error) {
if (error.message.includes('UNIQUE constraint failed')) {
throw new Error(`Projection definition already exists: ${opts.name}@${opts.codeRev}`);
}
throw error;
}
});
transaction();
return {
hash,
name: opts.name,
parentHash: opts.parentHash,
params: opts.params,
valueSchema: opts.valueSchema,
initialValue: opts.initialValue,
sources: opts.sources,
codeRev: opts.codeRev,
createdAt,
};
}
export async function getProjectionDef(db, name, codeRev) {
const row = selectProjectionDefByNameCodeRev(db).get(name, codeRev);
if (!row)
return null;
// Get sources
const sources = selectProjectionDefSources(db).all(row.hash);
return {
hash: row.hash,
name: row.name,
parentHash: row.parent_hash || undefined,
params: row.params ? JSON.parse(row.params) : undefined,
valueSchema: row.value_schema ? JSON.parse(row.value_schema) : undefined,
initialValue: JSON.parse(row.initial_value),
sources: sources.map((s) => ({
eventKind: s.event_kind,
eventKey: s.event_key || undefined,
expression: s.expression,
})),
codeRev: row.code_rev,
createdAt: row.created_at,
};
}
export async function listProjectionDefs(db, opts) {
const rows = selectProjectionDefsByCodeRev(db).all(opts.codeRev);
return rows.map((row) => {
const sources = selectProjectionDefSources(db).all(row.hash);
return {
hash: row.hash,
name: row.name,
parentHash: row.parent_hash || undefined,
params: row.params ? JSON.parse(row.params) : undefined,
valueSchema: row.value_schema ? JSON.parse(row.value_schema) : undefined,
initialValue: JSON.parse(row.initial_value),
sources: sources.map((s) => ({
eventKind: s.event_kind,
eventKey: s.event_key || undefined,
expression: s.expression,
})),
codeRev: row.code_rev,
createdAt: row.created_at,
};
});
}
// ── JSONata Expression Validation ─────────────────────────────
export async function validateExpression(opts) {
try {
const expr = jsonata(opts.expression);
// Test bindings: { state: initialValue, event: mockEvent, params: {} }
const testData = {}; // empty data
const testBindings = {
state: opts.initialValue,
event: opts.mockEvent,
params: {},
};
const result = await expr.evaluate(testData, testBindings);
return {
valid: true,
result,
};
}
catch (error) {
return {
valid: false,
error: error.message,
};
}
}
+2 -2
View File
@@ -12,8 +12,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createOpenAiLlmClient } from '../llm-client.js';
import { createStore } from '../store.js';
import { createCodingWorkflow } from '../workflows/coding.js';
import { createArchitectRole } from '../workflows/roles/architect-llm.js';
import { createCodingWorkflow } from '@upulse/workflows';
import { createArchitectRole } from '@upulse/workflows';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
const SEP = '─'.repeat(50);
+3 -3
View File
@@ -14,9 +14,9 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createStore } from '../index.js';
import { createOpenAiLlmClient } from '../llm-client.js';
import { createCodingWorkflow } from '../workflows/coding.js';
import { createCoderRole } from '../workflows/roles/coder-cursor.js';
import { createReviewerRole } from '../workflows/roles/reviewer-cursor.js';
import { createCodingWorkflow } from '@upulse/workflows';
import { createCoderRole } from '@upulse/workflows';
import { createReviewerRole } from '@upulse/workflows';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
import type { WorkflowMessage } from '../workflows/workflow-type.js';
+3 -3
View File
@@ -15,9 +15,9 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createStore } from '../index.js';
import { createOpenAiLlmClient } from '../llm-client.js';
import { createReportWorkflow } from '../workflows/report.js';
import { createAnalystRole } from '../workflows/roles/analyst-llm.js';
import { createRendererRole } from '../workflows/roles/renderer-template.js';
import { createReportWorkflow } from '@upulse/workflows';
import { createAnalystRole } from '@upulse/workflows';
import { createRendererRole } from '@upulse/workflows';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
// ── Args ───────────────────────────────────────────────────────
@@ -9,7 +9,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createStore, type PulseStore } from '../store.js';
import { createCodingWorkflow } from '../workflows/coding.js';
import { createCodingWorkflow } from '@upulse/workflows';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
describe('Council v2 E2E', () => {
+1 -1
View File
@@ -22,7 +22,7 @@ import {
type Player,
type Identity,
type GameState,
} from '../workflows/werewolf.js';
} from '@upulse/workflows';
import type { Role, WorkflowMessage } from '../workflows/workflow-type.js';
// ── LLM Client ─────────────────────────────────────────────────
+3 -3
View File
@@ -13,9 +13,9 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createScopedStore, createStore } from '../index.js';
import { createOpenAiLlmClient } from '../llm-client.js';
import { createReportWorkflow } from '../workflows/report.js';
import { createAnalystRole } from '../workflows/roles/analyst-llm.js';
import { createRendererRole } from '../workflows/roles/renderer-template.js';
import { createReportWorkflow } from '@upulse/workflows';
import { createAnalystRole } from '@upulse/workflows';
import { createRendererRole } from '@upulse/workflows';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
// ── Args ───────────────────────────────────────────────────────
+1
View File
@@ -0,0 +1 @@
export { executeSurvivalEffect, type SurvivalEffect, type SurvivalExecDeps, } from './survival.js';
+1
View File
@@ -0,0 +1 @@
export { executeSurvivalEffect, } from './survival.js';
+21
View File
@@ -0,0 +1,21 @@
/**
* Survival effect executors
*
* Execute survival effects - all deterministic local commands.
*/
import { execFileSync as defaultExecFileSync, type execSync } from 'node:child_process';
import type * as fs from 'node:fs';
export interface SurvivalEffect {
type: string;
[key: string]: unknown;
}
/** Dependencies that can be injected for testing */
export interface SurvivalExecDeps {
fs?: typeof fs;
execSyncFn?: typeof execSync;
execFileSyncFn?: typeof defaultExecFileSync;
}
/**
* Execute survival effects — all deterministic local commands
*/
export declare function executeSurvivalEffect(effect: SurvivalEffect, deps?: SurvivalExecDeps): Promise<void>;
+109
View File
@@ -0,0 +1,109 @@
/**
* Survival effect executors
*
* Execute survival effects - all deterministic local commands.
*/
import { execFileSync as defaultExecFileSync, } from 'node:child_process';
import * as os from 'node:os';
import * as path from 'node:path';
/** Allowlist pattern for safe shell arguments (service names, git refs, etc.) */
const SAFE_ARG = /^[a-zA-Z0-9._/@:-]+$/;
/**
* Execute survival effects — all deterministic local commands
*/
export async function executeSurvivalEffect(effect, deps) {
const execFileSync = deps?.execFileSyncFn ?? defaultExecFileSync;
switch (effect.type) {
case 'restart-service': {
const service = effect.service;
if (!SAFE_ARG.test(service)) {
console.error(`[survival] Rejected unsafe service name: ${service}`);
break;
}
try {
execFileSync('systemctl', ['restart', service], { timeout: 30000 });
}
catch (err) {
console.error(`[survival] Failed to restart ${service}:`, err);
}
break;
}
case 'gc-vitals': {
// Handled at runPulse level with store access
// This effect is processed in runPulse layer, not here
break;
}
case 'clear-cache': {
const target = effect.target;
try {
if (target === 'journal') {
execFileSync('journalctl', ['--vacuum-size=100M'], {
timeout: 30000,
});
}
else if (target === 'tmp') {
execFileSync('find', ['/tmp', '-type', 'f', '-mtime', '+1', '-delete'], { timeout: 30000, stdio: 'ignore' });
}
}
catch (err) {
console.error(`[survival] Failed to clear ${target}:`, err);
}
break;
}
case 'rollback-code': {
const to = effect.to;
if (!SAFE_ARG.test(to)) {
console.error(`[survival] Rejected unsafe git ref: ${to}`);
break;
}
try {
execFileSync('git', ['checkout', to], {
cwd: path.join(os.homedir(), '.upulse', 'engine'),
timeout: 30000,
});
}
catch (err) {
console.error(`[survival] Failed to rollback to ${to}:`, err);
}
break;
}
case 'rollback-config': {
// Three-layer rollback: Pulse → LiteLLM → OC Gateway
const configs = [
['pulse', path.join(os.homedir(), '.upulse', 'config.json')],
['litellm', '/etc/litellm/config.yaml'],
['openclaw', path.join(os.homedir(), '.openclaw', 'openclaw.json')],
];
for (const [name, configPath] of configs) {
try {
execFileSync('cp', [`${configPath}.bak`, configPath], {
timeout: 10000,
stdio: 'ignore',
});
execFileSync('systemctl', ['restart', name], { timeout: 30000 });
}
catch { }
}
break;
}
case 'notify-owner': {
const message = effect.message;
// Dual channel: OC Gateway → direct Telegram fallback
try {
// Channel 1: through OC Gateway
await fetch('http://localhost:18789/api/notify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
signal: AbortSignal.timeout(3000),
});
return;
}
catch { }
// Channel 2: direct Telegram (TODO: needs BOT_TOKEN and CHAT_ID config)
// Leave as TODO for now, will be configured later
console.log(`[survival] notify-owner: ${message}`);
break;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* gc.ts — Automatic vitals GC: downsample + archive + CAS mark-and-sweep.
*
* Tiered retention (hardcoded defaults, configurable via GcConfig):
* < 1h: full resolution (keep all)
* 1h–24h: 1 per 5 min
* 24h–7d: 1 per 1 hour
* > 7d: hard delete (archive)
*
* Triggered by tick count, not setInterval.
* GC stats written to _system scope as kind="gc" events.
*/
import type { PulseStore } from './store.js';
export interface GcTier {
/** Events older than this (ms) are candidates for this tier. */
olderThanMs: number;
/** Keep one event per this interval (ms). null = hard delete. */
intervalMs: number | null;
}
export interface GcConfig {
/** Enable automatic GC. Default: true. */
enabled: boolean;
/** Run GC every N ticks. Default: 240 (~1h at 15s tick). */
tickInterval: number;
/** Retention tiers, ordered by olderThanMs ascending. */
tiers: GcTier[];
}
export declare const DEFAULT_GC_CONFIG: GcConfig;
export interface GcResult {
downsampledCount: number;
archivedCount: number;
orphanObjectsCount: number;
durationMs: number;
}
/**
* Run GC on vitals store: downsample + archive.
* Returns stats about what was cleaned up.
*/
export declare function gcVitals(vitalsStore: PulseStore, config?: GcConfig): Promise<{
downsampledCount: number;
archivedCount: number;
}>;
/**
* CAS mark-and-sweep: delete orphaned objects not referenced by any event.
*
* Scans all events in the given stores for hash references,
* then compares against files in objectsDir.
*/
export declare function gcOrphanObjects(stores: PulseStore[], objectsDir: string): Promise<number>;
/**
* Full GC cycle: vitals downsample/archive + CAS orphan sweep.
* Writes a gc event to systemStore for observability.
*/
export declare function runGc(options: {
vitalsStore: PulseStore;
systemStore: PulseStore;
allStores: PulseStore[];
objectsDir: string;
config?: GcConfig;
}): Promise<GcResult>;
/**
* Create a GC trigger that fires every N ticks.
* Returns a function to call after each tick.
*/
export declare function createGcTrigger(options: {
vitalsStore: PulseStore;
systemStore: PulseStore;
allStores: PulseStore[];
objectsDir: string;
config?: GcConfig;
}): () => void;
+181
View File
@@ -0,0 +1,181 @@
/**
* gc.ts — Automatic vitals GC: downsample + archive + CAS mark-and-sweep.
*
* Tiered retention (hardcoded defaults, configurable via GcConfig):
* < 1h: full resolution (keep all)
* 1h–24h: 1 per 5 min
* 24h–7d: 1 per 1 hour
* > 7d: hard delete (archive)
*
* Triggered by tick count, not setInterval.
* GC stats written to _system scope as kind="gc" events.
*/
import { readdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
const ONE_HOUR = 3_600_000;
const ONE_DAY = 86_400_000;
const SEVEN_DAYS = 7 * ONE_DAY;
const FIVE_MIN = 300_000;
export const DEFAULT_GC_CONFIG = {
enabled: true,
tickInterval: 240,
tiers: [
{ olderThanMs: ONE_HOUR, intervalMs: FIVE_MIN },
{ olderThanMs: ONE_DAY, intervalMs: ONE_HOUR },
{ olderThanMs: SEVEN_DAYS, intervalMs: null },
],
};
// ── Core GC logic ──────────────────────────────────────────────
/**
* Run GC on vitals store: downsample + archive.
* Returns stats about what was cleaned up.
*/
export async function gcVitals(vitalsStore, config = DEFAULT_GC_CONFIG) {
const now = Date.now();
let downsampledCount = 0;
let archivedCount = 0;
// Sort tiers by olderThanMs descending so we process the oldest tier first.
// This ensures archive runs before downsample (no point downsampling data we'll delete).
const sortedTiers = [...config.tiers].sort((a, b) => b.olderThanMs - a.olderThanMs);
for (const tier of sortedTiers) {
const olderThan = now - tier.olderThanMs;
if (tier.intervalMs === null) {
// Hard delete (archive)
archivedCount += await vitalsStore.archiveEvents(olderThan);
}
else {
// Downsample: query distinct kind+key combos from vitals, then downsample each.
// We downsample all kinds present in vitals.
const kinds = await getDistinctKindKeys(vitalsStore, olderThan);
for (const { kind, key } of kinds) {
downsampledCount += await vitalsStore.downsampleEvents(kind, key, tier.intervalMs, olderThan);
}
}
}
return { downsampledCount, archivedCount };
}
/**
* CAS mark-and-sweep: delete orphaned objects not referenced by any event.
*
* Scans all events in the given stores for hash references,
* then compares against files in objectsDir.
*/
export async function gcOrphanObjects(stores, objectsDir) {
// Mark: collect all hashes referenced by events
const referencedHashes = new Set();
for (const store of stores) {
// Scan all events that have a hash field
// queryByKind with no kind filter doesn't exist, so we scan known kinds
for (const kind of [
'collect',
'effect',
'vital',
'tick',
'error',
'promote',
'rollback',
'migrate',
'init',
'gc',
]) {
const events = await store.queryByKind(kind, {});
for (const event of events) {
if (event.hash) {
referencedHashes.add(event.hash);
}
}
}
}
// Sweep: compare against files in objectsDir
let orphanCount = 0;
let files;
try {
files = readdirSync(objectsDir);
}
catch {
return 0; // directory doesn't exist or not readable
}
for (const file of files) {
if (!file.endsWith('.json'))
continue;
const hash = file.slice(0, -5); // remove .json
if (!referencedHashes.has(hash)) {
try {
unlinkSync(join(objectsDir, file));
orphanCount++;
}
catch {
// best effort — file may have been removed concurrently
}
}
}
return orphanCount;
}
/**
* Full GC cycle: vitals downsample/archive + CAS orphan sweep.
* Writes a gc event to systemStore for observability.
*/
export async function runGc(options) {
const config = options.config ?? DEFAULT_GC_CONFIG;
const start = Date.now();
const { downsampledCount, archivedCount } = await gcVitals(options.vitalsStore, config);
const orphanObjectsCount = await gcOrphanObjects(options.allStores, options.objectsDir);
const durationMs = Date.now() - start;
const result = {
downsampledCount,
archivedCount,
orphanObjectsCount,
durationMs,
};
// Write GC stats event to _system scope
await options.systemStore.appendEvent({
occurredAt: Date.now(),
kind: 'gc',
key: 'vitals',
meta: JSON.stringify(result),
});
return result;
}
// ── Tick-based GC trigger ──────────────────────────────────────
/**
* Create a GC trigger that fires every N ticks.
* Returns a function to call after each tick.
*/
export function createGcTrigger(options) {
const config = options.config ?? DEFAULT_GC_CONFIG;
if (!config.enabled)
return () => { };
let tickCount = 0;
return () => {
tickCount++;
if (tickCount >= config.tickInterval) {
tickCount = 0;
runGc({ ...options, config }).catch((err) => {
console.error('[pulse gc]', err);
});
}
};
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Get distinct kind+key combos from a store for events older than a threshold.
* Used to know which series to downsample.
*/
async function getDistinctKindKeys(store, olderThan) {
// queryByKind returns events filtered by kind.
// For vitals, the main kind is 'vital' with key = watcher name.
// We also handle 'collect' kind in case vitals store has those.
const result = [];
const seen = new Set();
for (const kind of ['vital', 'collect']) {
const events = await store.queryByKind(kind, { limit: 1000 });
for (const event of events) {
const pair = `${kind}:${event.key ?? ''}`;
if (!seen.has(pair) && event.occurredAt < olderThan) {
seen.add(pair);
result.push({ kind, key: event.key ?? '' });
}
}
}
return result;
}
+236
View File
@@ -0,0 +1,236 @@
/**
* @uncaged/pulse — Core Engine
*
* A stateful reactive loop. All intelligence lives in the rules.
*
* Core atom:
* Rule = (prev, curr, inner) → Promise<[effects', tickMs']>
*
* Onion middleware model: each rule wraps the inner chain.
* The first rule in the array is the outermost layer.
*
* Composition: pulse = reduceRight rules base
*/
import { type GcConfig } from './gc.js';
import type { EventRecord, PulseStore, ScopedStore } from './store.js';
import { type WatcherDef } from './watcher.js';
/**
* A sensed value — latest known state of one sense dimension.
* refreshedAt: when this data was last collected (= collect event's occurred_at)
*/
export interface Sensed<T> {
data: T;
refreshedAt: number;
}
/**
* Rule: the universal composition primitive.
*
* Onion middleware model. Each rule receives two snapshots and an inner
* continuation. The rule calls inner(prev, curr) to get the result from
* all inner layers, then can modify it before returning.
*
* Rules compose via onion (reduceRight):
* pulse = reduceRight rules base
* base = async () => [[], defaultTickMs]
*
* A rule can:
* - Call inner and modify the result (append, filter, adjust tickMs)
* - Short-circuit by not calling inner (bypass inner layers)
* - Pass through by returning inner's result unchanged
*/
export type Rule<S, E> = (prev: S, curr: S, inner: (prev: S, curr: S) => Promise<[E[], number]>) => Promise<[E[], number]>;
/**
* Rule definition with projection dependencies.
*
* Declares which projections this rule depends on via scope/name paths.
* The engine will automatically read these projections and build the snapshot.
*/
export interface RuleDef<S, E> {
name: string;
/** Projection dependencies, format "scope/name" like ["_vitals/cpu_usage", "neko/session_count"] */
projections: string[];
rule: Rule<S, E>;
}
/**
* Executor: executes a batch of effects.
*/
export type Executor<E> = (effects: E[]) => Promise<void>;
/** @deprecated Use {@link Executor} instead. */
export type Effector<E> = Executor<E>;
/**
* ChainableExecutor: processes effects and returns unhandled ones.
*
* Each ChainableExecutor in a chain receives the effects array, handles
* what it knows about, and returns the remaining unhandled effects.
* The next executor in the chain receives only the unhandled effects.
*
* Use chainExecutors() to compose a chain of ChainableExecutors into
* a single Executor<E> compatible with runPulse.
*/
export type ChainableExecutor<E> = (effects: E[]) => Promise<E[]>;
/**
* Compose multiple ChainableExecutors into a single Executor<E>.
*
* Each executor in the chain:
* 1. Receives effects (or remaining unhandled effects from the previous executor)
* 2. Returns the unhandled effects for the next executor
*
* If the final remaining effects are non-empty and no executor handled them,
* a console.warn is emitted. If executors array is empty and effects is
* non-empty, a warning is emitted immediately.
*/
export declare function chainExecutors<E>(executors: ChainableExecutor<E>[]): Executor<E>;
/**
* Compose rules via onion middleware (reduceRight).
*
* Given rules [r1, r2, r3], produces:
* r1 wraps r2 wraps r3 wraps base
*
* r1 is outermost (executes first), r3 is innermost (closest to base).
* Each rule calls inner(prev, curr) to delegate to the next layer.
*/
export declare function composeRules<S, E>(rules: Rule<S, E>[], defaultTickMs?: number): (prev: S, curr: S) => Promise<[E[], number]>;
/**
* Find the effective version epoch — the promote event that current runtime should use.
* If there's a rollback, use the promote event of the rolled-back-to version.
* If no rollback, use the latest promote event.
* If no promote at all, return null (cold start).
*/
export declare function findEffectiveEpoch(store: PulseStore): Promise<EventRecord | null>;
/**
* Rebuild a Snapshot from the events table, respecting version epoch.
* When epoch is provided, only reads events after the epoch with matching code_rev.
* When epoch is null/undefined, falls back to latest collect per key.
*
* Overloads:
* - rebuildSnapshot(store, senseKeys, epoch?) — reads from a single store
* - rebuildSnapshot({ system, vitals }, senseKeys, epoch?) — reads vitals from separate store
*
* When options.workflowStore is provided and senseKeys includes 'pending-tasks',
* task projections (pending-tasks, agent-capability-stats) are folded from workflowStore.
* Falls back to options.systemStore for backward compatibility.
*/
export declare function rebuildSnapshot<S extends {
timestamp: number;
}>(storeOrStores: PulseStore | {
system: PulseStore;
vitals: PulseStore;
}, senseKeys: string[], epoch?: EventRecord | null, options?: {
systemStore?: PulseStore;
workflowStore?: PulseStore;
}): Promise<S>;
/**
* Build snapshot from projections.
* Read each declared projection's current value, using "scope/name" as key.
* If projection doesn't exist, value is null (graceful degradation).
*/
export declare function buildSnapshotFromProjections<S extends {
timestamp: number;
}>(scopedStore: ScopedStore, projectionPaths: string[]): Promise<S>;
/**
* Run the Pulse loop.
*
* rebuildSnapshot → pulse → execute → sleep → repeat
*
* All effects (including collect) go to execute. The runtime is
* completely unaware of collection logic — that lives in execute.
* Cold-start is handled by rules: they see undefined senses and
* produce collect effects on the first tick.
*/
export declare function runPulse<S extends {
timestamp: number;
}, E>(options: {
scopedStore?: ScopedStore;
/** @deprecated Use {@link scopedStore} instead. */
store?: PulseStore;
execute: Executor<E>;
rules: Rule<S, E>[];
senseKeys: string[];
defaultTickMs?: number;
codeRev?: string;
watchers?: WatcherDef[];
/** Scan interval for the background executor loop (ms). Default 1000. */
executorScanIntervalMs?: number;
/** GC configuration. Set `{ enabled: false }` to disable. Uses DEFAULT_GC_CONFIG if omitted. */
gc?: Partial<GcConfig>;
/** Objects directory path for CAS orphan cleanup. Required for CAS GC. */
objectsDir?: string;
/** Adaptive tick frequency configuration */
adaptiveTick?: {
/** Base tick interval in ms when active (default: 5000) */
baseTickMs?: number;
/** Maximum tick interval in ms when idle (default: 300000) */
maxTickMs?: number;
/** Backoff factor when idle (default: 2) */
backoffFactor?: number;
/** Function to determine if there are active topics/work (optional) */
hasActiveWork?: (snapshot: S) => boolean;
};
}): Promise<never>;
/**
* Run the Pulse loop with RuleDefs and projection-based snapshots (V2).
*
* Uses RuleDef + projections to drive the loop instead of senseKeys.
* Automatically folds projections and builds snapshots from declared dependencies.
*/
export declare function runPulseV2<S extends {
timestamp: number;
}, E>(options: {
scopedStore: ScopedStore;
execute: Executor<E>;
ruleDefs: RuleDef<S, E>[];
defaultTickMs?: number;
codeRev: string;
watchers?: WatcherDef[];
/** Scan interval for the background executor loop (ms). Default 1000. */
executorScanIntervalMs?: number;
}): Promise<never>;
/**
* Independent executor loop that scans for pending effect events
* and executes them asynchronously (fire-and-forget).
*
* Decouples effect execution from the sense·think tick loop:
* ticks write effect events, executorLoop picks them up and runs them.
*/
export declare function executorLoop<E>(options: {
store: PulseStore;
execute: Executor<E>;
scanIntervalMs?: number;
signal?: AbortSignal;
}): Promise<void>;
/**
* Start the executor loop in the background.
* Returns the AbortController so the caller can stop it.
*/
export declare function startExecutorLoop<E>(options: {
store: PulseStore;
execute: Executor<E>;
scanIntervalMs?: number;
signal?: AbortSignal;
}): void;
/**
* Create a rule from an accessor + pure logic.
* Adaptation happens at construction time — no need for contramap.
*/
export declare function createRule<S, E, T>(accessor: (s: S) => T, logic: (prev: T, curr: T, inner: (prev: S, curr: S) => Promise<[E[], number]>) => Promise<[E[], number]>): Rule<S, E>;
export { type CreateScopedStoreOptions, type CreateStoreOptions, createScopedStore, createStore, type EventRecord, type ObjectInstance, type PulseStore, type ScopedStore, } from './store.js';
export { adaptiveInterval, clampTick, dedup, errorBackoff, } from './rules/builtin.js';
export { startWatcher, type VitalWithData, type WakeCondition, type WatcherDef, type WatcherHandle, } from './watcher.js';
export * from './watchers/index.js';
export * from './rules/index.js';
export { createOpenAiLlmClient, type LlmClient, type LlmMessage, type LlmResponse, type LlmTool, } from './llm-client.js';
export { type AgentLoopRuleOptions, createAgentLoopRule, } from './rules/agent-loop.js';
export { buildPersonasFromEvents } from './persona.js';
export { createWorkflowTicker } from './workflows/index.js';
export type { WorkflowRule, WorkflowTickResult, } from './workflows/workflow-rule-adapter.js';
export { createWorkflowRule } from './workflows/workflow-rule-adapter.js';
export { END, type MetaOf, type ModeratorInput, type Role, type RoleOutput, type RoleResult, START, type StartSignal, type WorkflowAction, type WorkflowMessage, type WorkflowType, } from './workflows/workflow-type.js';
export { type AgentExecutorConfig, type AgentResult, type AgentRunner, createAgentExecutorRole, createCursorRunner, } from './workflows/roles/agent-executor.js';
export { type LlmRoleConfig, type ToolRoleConfig, createLlmRole, createToolRole, } from './workflows/roles/llm-role-factory.js';
export { type ScaffoldOptions, scaffoldWorkflow } from './workflows/scaffold.js';
export * from './defs.js';
export * from './executors/index.js';
export type { GcConfig, GcResult, GcTier } from './gc.js';
export { createGcTrigger, DEFAULT_GC_CONFIG, gcOrphanObjects, gcVitals, runGc, } from './gc.js';
export * from './projection-engine.js';
export type { ActiveProjectsData, AgentLoopTraceData, ContainerStatus, ContainerType, InflightBrokerData, LlmCallCompletedMeta, LlmCallStartedMeta, PendingTasksData, PersonaRegisteredMeta, PersonaState, PersonaUpdatedMeta, ProjectCreatedMeta, ProjectState, TaskAssignedMeta, TaskClosedMeta, TaskCreatedMeta, TaskRespondedMeta, TaskRoutingMeta, TaskState, TaskStatus, TaskType, ToolResponseMeta, TraceMessage, } from './task-events.js';
+672
View File
@@ -0,0 +1,672 @@
/**
* @uncaged/pulse — Core Engine
*
* A stateful reactive loop. All intelligence lives in the rules.
*
* Core atom:
* Rule = (prev, curr, inner) → Promise<[effects', tickMs']>
*
* Onion middleware model: each rule wraps the inner chain.
* The first rule in the array is the outermost layer.
*
* Composition: pulse = reduceRight rules base
*/
import { createGcTrigger, DEFAULT_GC_CONFIG } from './gc.js';
import { foldAllProjections, getProjectionState } from './projection-engine.js';
import { startWatcher } from './watcher.js';
/**
* Compose multiple ChainableExecutors into a single Executor<E>.
*
* Each executor in the chain:
* 1. Receives effects (or remaining unhandled effects from the previous executor)
* 2. Returns the unhandled effects for the next executor
*
* If the final remaining effects are non-empty and no executor handled them,
* a console.warn is emitted. If executors array is empty and effects is
* non-empty, a warning is emitted immediately.
*/
export function chainExecutors(executors) {
return async (effects) => {
if (executors.length === 0) {
if (effects.length > 0) {
console.warn(`[pulse] chainExecutors: ${effects.length} unhandled effects (no executors registered)`);
}
return;
}
let remaining = effects;
for (const executor of executors) {
remaining = await executor(remaining);
}
if (remaining.length > 0) {
console.warn(`[pulse] chainExecutors: ${remaining.length} unhandled effects after all executors`);
}
};
}
// ── Composition ────────────────────────────────────────────────
/**
* Compose rules via onion middleware (reduceRight).
*
* Given rules [r1, r2, r3], produces:
* r1 wraps r2 wraps r3 wraps base
*
* r1 is outermost (executes first), r3 is innermost (closest to base).
* Each rule calls inner(prev, curr) to delegate to the next layer.
*/
export function composeRules(rules, defaultTickMs = 15000) {
const chain = rules.reduceRight((next, rule) => (prev, curr, inner) => rule(prev, curr, (p, c) => next(p, c, inner)), (prev, curr, inner) => inner(prev, curr));
const base = async () => [
[],
defaultTickMs,
];
return (prev, curr) => chain(prev, curr, base);
}
// ── Version Epoch ──────────────────────────────────────────────
/**
* Find the effective version epoch — the promote event that current runtime should use.
* If there's a rollback, use the promote event of the rolled-back-to version.
* If no rollback, use the latest promote event.
* If no promote at all, return null (cold start).
*/
export async function findEffectiveEpoch(store) {
const rollback = await store.getLatest('rollback');
if (rollback) {
// rollback.meta should contain { to: 'v1' } — the code_rev to roll back to
let meta = {};
try {
meta = rollback.meta ? JSON.parse(rollback.meta) : {};
}
catch {
// Corrupted meta — skip this rollback event, fall through to latest promote
return await store.getLatest('promote');
}
const targetRev = meta.to || rollback.codeRev;
if (targetRev) {
return await store.getLatestWhere({
kind: 'promote',
codeRev: targetRev,
});
}
}
return await store.getLatest('promote');
}
// ── Snapshot Rebuild ───────────────────────────────────────────
/**
* Rebuild a Snapshot from the events table, respecting version epoch.
* When epoch is provided, only reads events after the epoch with matching code_rev.
* When epoch is null/undefined, falls back to latest collect per key.
*
* Overloads:
* - rebuildSnapshot(store, senseKeys, epoch?) — reads from a single store
* - rebuildSnapshot({ system, vitals }, senseKeys, epoch?) — reads vitals from separate store
*
* When options.workflowStore is provided and senseKeys includes 'pending-tasks',
* task projections (pending-tasks, agent-capability-stats) are folded from workflowStore.
* Falls back to options.systemStore for backward compatibility.
*/
export async function rebuildSnapshot(storeOrStores, senseKeys, epoch, options) {
const isMultiStore = typeof storeOrStores === 'object' &&
'system' in storeOrStores &&
'vitals' in storeOrStores;
const store = isMultiStore
? storeOrStores.system
: storeOrStores;
const vitalsStore = isMultiStore
? storeOrStores.vitals
: null;
const snapshot = { timestamp: Date.now() };
const casMisses = [];
for (const key of senseKeys) {
// Priority 1: read latest vital from vitals store (if provided)
if (vitalsStore) {
const latestVital = await vitalsStore.getLatest('vital', key);
if (latestVital?.hash) {
const data = await vitalsStore.getObject(latestVital.hash);
if (data !== null) {
snapshot[key] = {
data,
refreshedAt: latestVital.occurredAt,
};
continue;
}
casMisses.push(key);
}
}
// Priority 2: fallback to events table (migrate/init events)
if (epoch) {
const events = await store.getAfter(epoch.id, {
kind: 'collect',
key,
codeRev: epoch.codeRev ?? undefined,
});
const latestCollect = events.length > 0 ? events[events.length - 1] : null;
if (latestCollect?.hash) {
const data = await store.getObject(latestCollect.hash);
if (data !== null) {
snapshot[key] = {
data,
refreshedAt: latestCollect.occurredAt,
};
continue;
}
}
// Check migrate events
const migrateEvents = await store.getAfter(epoch.id, {
kind: 'migrate',
key,
codeRev: epoch.codeRev ?? undefined,
});
if (migrateEvents.length > 0) {
const latestMigrate = migrateEvents[migrateEvents.length - 1];
if (latestMigrate.hash) {
const data = await store.getObject(latestMigrate.hash);
if (data !== null) {
snapshot[key] = {
data,
refreshedAt: latestMigrate.occurredAt,
};
continue;
}
}
}
// Check init events
const initEvents = await store.getAfter(epoch.id, {
kind: 'init',
key,
codeRev: epoch.codeRev ?? undefined,
});
if (initEvents.length > 0) {
const latestInit = initEvents[initEvents.length - 1];
if (latestInit.hash) {
const data = await store.getObject(latestInit.hash);
if (data !== null) {
snapshot[key] = {
data,
refreshedAt: latestInit.occurredAt,
};
}
}
}
}
else {
// No epoch — try latest collect from events table directly
const latest = await store.getLatest('collect', key);
if (latest?.hash) {
const data = await store.getObject(latest.hash);
if (data !== null) {
snapshot[key] = {
data,
refreshedAt: latest.occurredAt,
};
}
}
}
}
// Surface CAS misses so rules can detect data integrity issues
if (casMisses.length > 0) {
snapshot['_error:cas_miss'] = { keys: casMisses, count: casMisses.length };
}
return snapshot;
}
// ── Projection-based Snapshot ─────────────────────────────────
/**
* Build snapshot from projections.
* Read each declared projection's current value, using "scope/name" as key.
* If projection doesn't exist, value is null (graceful degradation).
*/
export async function buildSnapshotFromProjections(scopedStore, projectionPaths) {
const snapshot = { timestamp: Date.now() };
for (const projectionPath of projectionPaths) {
const [scopeName, projectionName] = projectionPath.split('/');
if (!scopeName || !projectionName) {
console.warn(`[buildSnapshotFromProjections] Invalid projection path format: "${projectionPath}", expected "scope/name"`);
snapshot[projectionPath] = null;
continue;
}
try {
// Get scope database
const scopeDb = scopedStore.scopeDatabase(scopeName);
// Get projection state
const projectionState = await getProjectionState(scopeDb, projectionName);
if (projectionState) {
snapshot[projectionPath] = projectionState.value;
}
else {
console.warn(`[buildSnapshotFromProjections] Projection not found: "${projectionPath}"`);
snapshot[projectionPath] = null;
}
}
catch (error) {
console.warn(`[buildSnapshotFromProjections] Error reading projection "${projectionPath}":`, error);
snapshot[projectionPath] = null;
}
}
return snapshot;
}
// ── Runtime ────────────────────────────────────────────────────
/**
* Run the Pulse loop.
*
* rebuildSnapshot → pulse → execute → sleep → repeat
*
* All effects (including collect) go to execute. The runtime is
* completely unaware of collection logic — that lives in execute.
* Cold-start is handled by rules: they see undefined senses and
* produce collect effects on the first tick.
*/
export async function runPulse(options) {
const { execute, rules, senseKeys, defaultTickMs = 15000, codeRev } = options;
// ── Adaptive Tick Configuration ────────────────────────────────
const adaptiveConfig = {
baseTickMs: 5000,
maxTickMs: 300000,
backoffFactor: 2,
hasActiveWork: undefined,
...options.adaptiveTick,
};
const { baseTickMs, maxTickMs, backoffFactor, hasActiveWork } = adaptiveConfig;
const systemStore = options.scopedStore
? options.scopedStore.scope('_system')
: options.store;
const vitalsStore = options.scopedStore
? options.scopedStore.scope('_vitals')
: options.store;
// Workflow scope: stores task lifecycle events (task-created, task-assigned, etc.)
const workflowStore = options.scopedStore
? options.scopedStore.scope('workflows')
: undefined;
// ── GC trigger ───────────────────────────────────────────────
const gcConfig = { ...DEFAULT_GC_CONFIG, ...options.gc };
const allStores = [
systemStore,
vitalsStore,
...(workflowStore ? [workflowStore] : []),
];
const gcTick = createGcTrigger({
vitalsStore,
systemStore,
allStores,
objectsDir: options.objectsDir ?? '',
config: gcConfig,
});
const pulse = composeRules(rules, defaultTickMs);
// Determine version epoch (always from system store)
const epoch = await findEffectiveEpoch(systemStore);
let prev = await rebuildSnapshot({ system: systemStore, vitals: vitalsStore }, senseKeys, epoch, { systemStore, workflowStore });
let tickMs = defaultTickMs;
// ── Wake mechanism ─────────────────────────────────────────────
let wakeResolve = null;
let pendingWake = false;
let ticking = false;
function wakeTick() {
if (ticking) {
pendingWake = true;
}
else if (wakeResolve) {
const resolve = wakeResolve;
wakeResolve = null;
resolve();
}
}
function interruptibleSleep(ms) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
wakeResolve = null;
resolve();
}, ms);
wakeResolve = () => {
clearTimeout(timer);
resolve();
};
});
}
// Start watchers — they write to vitalsStore
for (const def of options.watchers ?? []) {
startWatcher(def, vitalsStore, wakeTick);
}
// Start executor loop — picks up effect events and executes them asynchronously
const executorAbort = new AbortController();
startExecutorLoop({
store: systemStore,
execute,
scanIntervalMs: options.executorScanIntervalMs ?? 1000,
signal: executorAbort.signal,
});
// Future: Rule scopes declaration
// Rules will be able to declare which scopes they need:
// createRule({ scopes: ['_system', '_vitals', 'neko'], accessor, decide })
// rebuildSnapshot will then pull from declared scopes only.
while (true) {
await interruptibleSleep(tickMs);
ticking = true;
pendingWake = false;
const curr = await rebuildSnapshot({ system: systemStore, vitals: vitalsStore }, senseKeys, epoch, { systemStore, workflowStore });
const tickStart = Date.now();
const [effects, nextTickMs] = await pulse(prev, curr);
// Write effect events to store (fire-and-forget — executorLoop picks them up)
if (effects.length > 0) {
for (const effect of effects) {
const effectHash = await systemStore.putObject(effect);
await systemStore.appendEvent({
occurredAt: Date.now(),
kind: 'effect',
key: effectHash,
hash: effectHash,
meta: JSON.stringify({
type: effect.type || effect.kind || 'unknown',
}),
codeRev,
});
}
}
ticking = false;
// GC tick — runs actual GC every N ticks
gcTick();
// Adaptive tick frequency logic
if (pendingWake) {
// Immediate next tick if wake was requested
tickMs = 0;
}
else {
// Determine if there's active work
const hasActivity = effects.length > 0 || (hasActiveWork && hasActiveWork(curr));
if (hasActivity) {
// Active work detected → reset to base frequency
tickMs = baseTickMs;
}
else {
// No activity → exponential backoff
tickMs = Math.min(tickMs * backoffFactor, maxTickMs);
}
// Apply rule-suggested tickMs if provided
if (nextTickMs !== defaultTickMs) {
tickMs = nextTickMs;
}
}
// Record tick event with the actual tickMs that will be used for next iteration
await systemStore.appendEvent({
occurredAt: Date.now(),
kind: 'tick',
meta: JSON.stringify({
tick_ms: tickMs,
duration_ms: Date.now() - tickStart,
effect_count: effects.length,
}),
codeRev,
});
prev = curr;
}
}
// ── Runtime V2 (Projection-based) ────────────────────────────
/**
* Run the Pulse loop with RuleDefs and projection-based snapshots (V2).
*
* Uses RuleDef + projections to drive the loop instead of senseKeys.
* Automatically folds projections and builds snapshots from declared dependencies.
*/
export async function runPulseV2(options) {
const { scopedStore, execute, ruleDefs, defaultTickMs = 15000, codeRev, watchers, } = options;
// Collect all projection paths from rule definitions (deduplicated)
const allProjectionPaths = new Set();
for (const ruleDef of ruleDefs) {
for (const projectionPath of ruleDef.projections) {
allProjectionPaths.add(projectionPath);
}
}
const projectionPaths = Array.from(allProjectionPaths);
// Extract unique scope names
const scopeNames = new Set();
for (const projectionPath of projectionPaths) {
const [scopeName] = projectionPath.split('/');
if (scopeName) {
scopeNames.add(scopeName);
}
}
// Compose rules from RuleDefs
const rules = ruleDefs.map((def) => def.rule);
const pulse = composeRules(rules, defaultTickMs);
// Build initial snapshot
let prev = await buildSnapshotFromProjections(scopedStore, projectionPaths);
let tickMs = defaultTickMs;
// ── Wake mechanism ─────────────────────────────────────────────
let wakeResolve = null;
let pendingWake = false;
let ticking = false;
function wakeTick() {
if (ticking) {
pendingWake = true;
}
else if (wakeResolve) {
const resolve = wakeResolve;
wakeResolve = null;
resolve();
}
}
function interruptibleSleep(ms) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
wakeResolve = null;
resolve();
}, ms);
wakeResolve = () => {
clearTimeout(timer);
resolve();
};
});
}
// Start watchers — they write to vitalsStore
if (watchers) {
const vitalsStore = scopedStore.scope('_vitals');
for (const def of watchers) {
startWatcher(def, vitalsStore, wakeTick);
}
}
// Start executor loop — picks up effect events and executes them asynchronously
const executorAbort = new AbortController();
startExecutorLoop({
store: scopedStore.scope('_system'),
execute,
scanIntervalMs: options.executorScanIntervalMs ?? 1000,
signal: executorAbort.signal,
});
// Main loop
while (true) {
await interruptibleSleep(tickMs);
ticking = true;
pendingWake = false;
// Fold all projections for involved scopes
for (const scopeName of scopeNames) {
try {
const scopeDb = scopedStore.scopeDatabase(scopeName);
await foldAllProjections(scopeDb, scopeName, codeRev);
}
catch (error) {
console.warn(`[runPulseV2] Failed to fold projections for scope "${scopeName}":`, error);
}
}
// Build current snapshot from projections
const curr = await buildSnapshotFromProjections(scopedStore, projectionPaths);
const tickStart = Date.now();
const [effects, nextTickMs] = await pulse(prev, curr);
// Write effect events to store (fire-and-forget — executorLoop picks them up)
if (effects.length > 0) {
const sysStore = scopedStore.scope('_system');
for (const effect of effects) {
const effectHash = await sysStore.putObject(effect);
await sysStore.appendEvent({
occurredAt: Date.now(),
kind: 'effect',
key: effectHash,
hash: effectHash,
meta: JSON.stringify({
type: effect.type || effect.kind || 'unknown',
}),
codeRev,
});
}
}
// Record tick event in system store
const systemStore = scopedStore.scope('_system');
await systemStore.appendEvent({
occurredAt: Date.now(),
kind: 'tick',
meta: JSON.stringify({
tick_ms: nextTickMs,
duration_ms: Date.now() - tickStart,
effect_count: effects.length,
}),
codeRev,
});
ticking = false;
tickMs = pendingWake ? 0 : nextTickMs;
prev = curr;
}
}
// ── Executor Loop (fire-and-forget) ────────────────────────────
/**
* Independent executor loop that scans for pending effect events
* and executes them asynchronously (fire-and-forget).
*
* Decouples effect execution from the sense·think tick loop:
* ticks write effect events, executorLoop picks them up and runs them.
*/
export async function executorLoop(options) {
const { store, execute, scanIntervalMs = 5000, signal } = options;
/** Set of effect event ids currently being executed (prevents double-exec). */
const inflight = new Set();
while (!signal?.aborted) {
try {
// Find all pending effect events
const effectEvents = await store.queryByKind('effect');
for (const effectEvent of effectEvents) {
if (signal?.aborted)
break;
if (inflight.has(effectEvent.id))
continue;
const idStr = String(effectEvent.id);
// Check if already acked or failed
const acked = await store.getLatest('effect-acked', idStr);
if (acked)
continue;
const failed = await store.getLatest('effect-failed', idStr);
if (failed)
continue;
const executing = await store.getLatest('effect-executing', idStr);
if (executing)
continue;
// Retrieve the effect object from CAS
if (!effectEvent.hash)
continue;
const effectObj = (await store.getObject(effectEvent.hash));
if (effectObj === null)
continue;
// Mark as inflight
inflight.add(effectEvent.id);
// Write effect-executing event
await store.appendEvent({
occurredAt: Date.now(),
kind: 'effect-executing',
key: idStr,
hash: effectEvent.hash,
meta: effectEvent.meta,
codeRev: effectEvent.codeRev,
});
// Fire-and-forget: execute asynchronously
execute([effectObj])
.then(() => {
// Write effect-acked event
return store.appendEvent({
occurredAt: Date.now(),
kind: 'effect-acked',
key: idStr,
hash: effectEvent.hash,
codeRev: effectEvent.codeRev,
});
})
.catch((err) => {
// Write effect-failed event
const errorMessage = err instanceof Error ? err.message : String(err);
return store.appendEvent({
occurredAt: Date.now(),
kind: 'effect-failed',
key: idStr,
hash: effectEvent.hash,
meta: JSON.stringify({ error: errorMessage }),
codeRev: effectEvent.codeRev,
});
})
.finally(() => {
inflight.delete(effectEvent.id);
});
}
}
catch (err) {
// Database may have been closed (e.g. during test teardown) — exit gracefully
if (err instanceof RangeError &&
String(err.message).includes('closed database')) {
return;
}
throw err;
}
// Sleep until next scan (or abort)
await new Promise((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
const timer = setTimeout(resolve, scanIntervalMs);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
resolve();
}, { once: true });
});
}
}
/**
* Start the executor loop in the background.
* Returns the AbortController so the caller can stop it.
*/
export function startExecutorLoop(options) {
// Fire-and-forget — the loop runs until signal is aborted
executorLoop(options).catch((err) => {
console.error('[pulse] executorLoop crashed:', err);
});
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Create a rule from an accessor + pure logic.
* Adaptation happens at construction time — no need for contramap.
*/
export function createRule(accessor, logic) {
return (prev, curr, inner) => logic(accessor(prev), accessor(curr), inner);
}
// ── Storage ────────────────────────────────────────────────────
export { createScopedStore, createStore, } from './store.js';
// ── Built-in Rules ─────────────────────────────────────────────
export { adaptiveInterval, clampTick, dedup, errorBackoff, } from './rules/builtin.js';
// ── Watcher ────────────────────────────────────────────────────
export { startWatcher, } from './watcher.js';
// ── P0 Watchers ─────────────────────────────────────────────────
export * from './watchers/index.js';
// ── Survival Layer ──────────────────────────────────────────────
export * from './rules/index.js';
// ── LLM Client ──────────────────────────────────────────────────
export { createOpenAiLlmClient, } from './llm-client.js';
// ── Agent Loop Rule ─────────────────────────────────────────────
export { createAgentLoopRule, } from './rules/agent-loop.js';
// ── Persona Registry ────────────────────────────────────────────
export { buildPersonasFromEvents } from './persona.js';
// ── Council v2: WorkflowType ────────────────────────────────────────
export { createWorkflowTicker } from './workflows/index.js';
export { createWorkflowRule } from './workflows/workflow-rule-adapter.js';
export { END, START, } from './workflows/workflow-type.js';
export { createAgentExecutorRole, createCursorRunner, } from './workflows/roles/agent-executor.js';
export { createLlmRole, createToolRole, } from './workflows/roles/llm-role-factory.js';
export { scaffoldWorkflow } from './workflows/scaffold.js';
// ── Executors ─────────────────────────────────────────────────
// ── Definition Layer ────────────────────────────────────────────
export * from './defs.js';
export * from './executors/index.js';
// ── GC ──────────────────────────────────────────────────────────
export { createGcTrigger, DEFAULT_GC_CONFIG, gcOrphanObjects, gcVitals, runGc, } from './gc.js';
// ── Projection Engine ───────────────────────────────────────────
export * from './projection-engine.js';
+14 -11
View File
@@ -963,18 +963,7 @@ export { buildPersonasFromEvents } from './persona.js';
// ── Council v2: WorkflowType ────────────────────────────────────────
export {
type ArchitectMeta,
type CloserMeta,
type CoderMeta,
type CodingRoles,
createCodingWorkflow,
type ReviewerMeta,
} from './workflows/coding.js';
export { createWorkflowTicker } from './workflows/index.js';
export { createArchitectRole } from './workflows/roles/architect-llm.js';
export { createCoderRole } from './workflows/roles/coder-cursor.js';
export { createReviewerRole } from './workflows/roles/reviewer-cursor.js';
export type {
WorkflowRule,
WorkflowTickResult,
@@ -993,6 +982,20 @@ export {
type WorkflowMessage,
type WorkflowType,
} from './workflows/workflow-type.js';
export {
type AgentExecutorConfig,
type AgentResult,
type AgentRunner,
createAgentExecutorRole,
createCursorRunner,
} from './workflows/roles/agent-executor.js';
export {
type LlmRoleConfig,
type ToolRoleConfig,
createLlmRole,
createToolRole,
} from './workflows/roles/llm-role-factory.js';
export { type ScaffoldOptions, scaffoldWorkflow } from './workflows/scaffold.js';
// ── Executors ─────────────────────────────────────────────────
+51
View File
@@ -0,0 +1,51 @@
export interface LlmMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content?: string;
tool_calls?: Array<{
id: string;
function: {
name: string;
arguments: string;
};
}>;
tool_call_id?: string;
}
export interface LlmTool {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
export interface LlmResponse {
content?: string;
tool_calls?: Array<{
id: string;
function: {
name: string;
arguments: string;
};
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
};
}
export interface LlmClient {
chat(opts: {
messages: LlmMessage[];
tools?: LlmTool[];
tool_choice?: 'auto' | 'required';
}): Promise<LlmResponse>;
}
/**
* Create an OpenAI-compatible LLM client.
* Works with DashScope, LiteLLM proxy, OpenAI, etc.
*/
export declare function createOpenAiLlmClient(opts: {
baseUrl: string;
apiKey: string;
model: string;
timeoutMs?: number;
}): LlmClient;
+50
View File
@@ -0,0 +1,50 @@
/**
* Create an OpenAI-compatible LLM client.
* Works with DashScope, LiteLLM proxy, OpenAI, etc.
*/
export function createOpenAiLlmClient(opts) {
const { baseUrl, apiKey, model, timeoutMs = 30_000 } = opts;
return {
async chat({ messages, tools, tool_choice }) {
const body = {
model,
messages,
};
if (tools && tools.length > 0)
body.tools = tools;
if (tool_choice)
body.tool_choice = tool_choice;
const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`LLM API error ${res.status}: ${text}`);
}
const json = (await res.json());
const choice = json.choices?.[0]?.message;
// copilot-api 등 일부 구현은 choices를 2개 반환하고
// tool_calls가 두 번째 choice에 있을 수 있음
const toolChoice = json.choices?.find((c) => c.message?.tool_calls)?.message;
return {
content: choice?.content ?? undefined,
tool_calls: toolChoice?.tool_calls ?? choice?.tool_calls,
usage: json.usage,
};
}
finally {
clearTimeout(timer);
}
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { PulseStore } from './store.js';
import type { PersonaState } from './task-events.js';
/**
* Build a Map of PersonaState from persona-registered and persona-updated events.
*
* - Same personaId registered multiple times → idempotent overwrite.
* - persona-updated only patches provided fields.
* - Events are merged in occurredAt order.
*/
export declare function buildPersonasFromEvents(store: PulseStore): Promise<Map<string, PersonaState>>;
+51
View File
@@ -0,0 +1,51 @@
/**
* Build a Map of PersonaState from persona-registered and persona-updated events.
*
* - Same personaId registered multiple times → idempotent overwrite.
* - persona-updated only patches provided fields.
* - Events are merged in occurredAt order.
*/
export async function buildPersonasFromEvents(store) {
const registered = await store.queryByKind('persona-registered');
const updated = await store.queryByKind('persona-updated');
const allEvents = [...registered, ...updated];
allEvents.sort((a, b) => a.occurredAt - b.occurredAt);
const personas = new Map();
for (const ev of allEvents) {
if (!ev.meta)
continue;
let meta;
try {
meta = JSON.parse(ev.meta);
}
catch {
continue;
}
const personaId = meta.personaId;
if (!personaId)
continue;
if (ev.kind === 'persona-registered') {
const m = meta;
personas.set(personaId, {
personaId: m.personaId,
name: m.name,
container: m.container,
capabilities: m.capabilities,
registeredAt: ev.occurredAt,
updatedAt: ev.occurredAt,
});
}
else if (ev.kind === 'persona-updated') {
const existing = personas.get(personaId);
if (!existing)
continue;
const m = meta;
if (m.container !== undefined)
existing.container = m.container;
if (m.capabilities !== undefined)
existing.capabilities = m.capabilities;
existing.updatedAt = ev.occurredAt;
}
}
return personas;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @uncaged/pulse — Projection Engine (Phase 2)
*
* Incremental fold engine for projections with JSONata expressions.
* Projections are first-class citizens with their own state table.
*/
import type { Database } from 'bun:sqlite';
/** Clear the compiled expression cache (useful for testing). */
export declare function clearExpressionCache(): void;
export interface ProjectionState {
name: string;
value: any;
lastEventId: number;
codeRev: string;
updatedAt: number;
}
export declare const PROJECTIONS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS projections (\n name TEXT PRIMARY KEY,\n value TEXT NOT NULL, -- JSON, current state after fold\n last_event_id INTEGER NOT NULL DEFAULT 0,\n code_rev TEXT NOT NULL,\n updated_at INTEGER NOT NULL\n);\n";
/**
* Get current projection state from the database.
*/
export declare function getProjectionState(scopeDb: Database, projectionName: string): Promise<ProjectionState | null>;
/**
* Incremental fold for a single projection.
* Reads definition, current state, processes new events since last_event_id.
*/
export declare function foldProjection(scopeDb: Database, _scopeName: string, projectionName: string, codeRev: string): Promise<ProjectionState>;
/**
* Fold all projections in a scope with the given code revision.
*/
export declare function foldAllProjections(scopeDb: Database, scopeName: string, codeRev: string): Promise<Map<string, ProjectionState>>;
/**
* Reset all projections and replay with new code revision.
* This is the only function that DELETES from projections table.
*/
export declare function resetProjections(scopeDb: Database, codeRev: string): Promise<void>;
+310
View File
@@ -0,0 +1,310 @@
/**
* @uncaged/pulse — Projection Engine (Phase 2)
*
* Incremental fold engine for projections with JSONata expressions.
* Projections are first-class citizens with their own state table.
*/
import jsonata from 'jsonata';
import { getProjectionDef } from './defs.js';
// ── Expression Cache ───────────────────────────────────────────
const expressionCache = new Map();
function getOrCompileExpression(expressionStr) {
let expr = expressionCache.get(expressionStr);
if (!expr) {
expr = jsonata(expressionStr);
expressionCache.set(expressionStr, expr);
}
return expr;
}
/** Clear the compiled expression cache (useful for testing). */
export function clearExpressionCache() {
expressionCache.clear();
}
// ── Database Schema ────────────────────────────────────────────
export const PROJECTIONS_SCHEMA = `
CREATE TABLE IF NOT EXISTS projections (
name TEXT PRIMARY KEY,
value TEXT NOT NULL, -- JSON, current state after fold
last_event_id INTEGER NOT NULL DEFAULT 0,
code_rev TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`;
// ── Prepared Statements ───────────────────────────────────────
function getProjectionStateStmt(db) {
return db.prepare(`
SELECT name, value, last_event_id, code_rev, updated_at
FROM projections
WHERE name = ?
`);
}
function upsertProjectionStateStmt(db) {
return db.prepare(`
INSERT OR REPLACE INTO projections (name, value, last_event_id, code_rev, updated_at)
VALUES (?, ?, ?, ?, ?)
`);
}
function selectEventsAfterStmt(db) {
return db.prepare(`
SELECT id, occurred_at, kind, key, hash, code_rev, meta
FROM events
WHERE id > ?
ORDER BY id ASC
`);
}
function selectEventsAfterWithKindFilterStmt(db) {
return db.prepare(`
SELECT id, occurred_at, kind, key, hash, code_rev, meta
FROM events
WHERE id > ? AND kind = ?
ORDER BY id ASC
`);
}
function insertEventStmt(db) {
return db.prepare(`
INSERT INTO events (occurred_at, kind, key, hash, code_rev, meta)
VALUES (?, ?, ?, ?, ?, ?)
`);
}
function deleteAllProjectionsStmt(db) {
return db.prepare(`DELETE FROM projections`);
}
function selectAllEventsStmt(db) {
return db.prepare(`
SELECT id, occurred_at, kind, key, hash, code_rev, meta
FROM events
ORDER BY id ASC
`);
}
// ── Helper Functions ───────────────────────────────────────────
function rowToEventRecord(row) {
const rec = {
id: Number(row.id),
occurredAt: row.occurred_at,
kind: row.kind,
};
if (row.key != null)
rec.key = row.key;
if (row.hash != null)
rec.hash = row.hash;
if (row.code_rev != null)
rec.codeRev = row.code_rev;
if (row.meta != null)
rec.meta = row.meta;
return rec;
}
function writeErrorEvent(db, projectionName, error, eventId) {
try {
const now = Date.now();
const payload = {
error: error.message || String(error),
eventId,
projectionName,
};
insertEventStmt(db).run(now, 'projection.fold.error', projectionName, null, // no hash
null, // no code_rev
JSON.stringify(payload));
}
catch (writeError) {
// If we can't even write the error event, log it but don't crash
console.error('Failed to write projection error event:', writeError);
}
}
// ── Core API ───────────────────────────────────────────────────
/**
* Get current projection state from the database.
*/
export async function getProjectionState(scopeDb, projectionName) {
const stmt = getProjectionStateStmt(scopeDb);
const row = stmt.get(projectionName);
if (!row)
return null;
return {
name: row.name,
value: JSON.parse(row.value),
lastEventId: Number(row.last_event_id),
codeRev: row.code_rev,
updatedAt: row.updated_at,
};
}
/**
* Incremental fold for a single projection.
* Reads definition, current state, processes new events since last_event_id.
*/
export async function foldProjection(scopeDb, _scopeName, projectionName, codeRev) {
// 1. Get projection definition
const def = await getProjectionDef(scopeDb, projectionName, codeRev);
if (!def) {
throw new Error(`Projection definition not found: ${projectionName}@${codeRev}`);
}
// 2. Get current state from projections table
const currentState = await getProjectionState(scopeDb, projectionName);
const currentValue = currentState?.value ?? def.initialValue;
const lastEventId = currentState?.lastEventId ?? 0;
// If code_rev changed, we need full replay (handled by resetProjections)
if (currentState && currentState.codeRev !== codeRev) {
throw new Error(`Projection ${projectionName} has different code_rev (${currentState.codeRev} vs ${codeRev}). Call resetProjections first.`);
}
// 3. Get events after last_event_id
const eventKinds = new Set(def.sources.map((s) => s.eventKind));
let newEvents = [];
if (eventKinds.size === 1) {
// Optimize for single event kind
const kind = Array.from(eventKinds)[0];
const stmt = selectEventsAfterWithKindFilterStmt(scopeDb);
const rows = stmt.all(lastEventId, kind);
newEvents = rows.map(rowToEventRecord);
}
else {
// Multiple event kinds - get all and filter
const stmt = selectEventsAfterStmt(scopeDb);
const rows = stmt.all(lastEventId);
newEvents = rows
.map(rowToEventRecord)
.filter((event) => eventKinds.has(event.kind));
}
// 4. Process each event through JSONata expressions
let updatedValue = currentValue;
let lastProcessedEventId = lastEventId;
for (const event of newEvents) {
// Find matching sources for this event
const matchingSources = def.sources.filter((source) => {
const kindMatch = source.eventKind === event.kind;
const keyMatch = !source.eventKey || source.eventKey === event.key;
return kindMatch && keyMatch;
});
// Process each matching source
for (const source of matchingSources) {
try {
const expr = getOrCompileExpression(source.expression);
// JSONata needs variables as bindings, not in data context
const data = {}; // Empty data object
const bindings = {
state: updatedValue,
event: {
id: event.id,
occurred_at: event.occurredAt,
kind: event.kind,
key: event.key,
payload: event.meta ? JSON.parse(event.meta) : {},
},
params: def.params || {},
};
const result = await expr.evaluate(data, bindings);
updatedValue = result;
lastProcessedEventId = event.id;
}
catch (error) {
// Write error event but continue processing
writeErrorEvent(scopeDb, projectionName, error, event.id);
console.warn(`Projection ${projectionName} fold error for event ${event.id}:`, error);
// Skip this event, don't update state
}
}
}
// 5. Update projections table
const now = Date.now();
// Ensure we have a valid value to store
if (updatedValue === undefined || updatedValue === null) {
console.warn(`Projection ${projectionName} ended up with undefined/null value, using initial value`);
updatedValue = def.initialValue;
}
const newState = {
name: projectionName,
value: updatedValue,
lastEventId: lastProcessedEventId,
codeRev,
updatedAt: now,
};
const stmt = upsertProjectionStateStmt(scopeDb);
const valueJson = JSON.stringify(updatedValue);
stmt.run(projectionName, valueJson, lastProcessedEventId, codeRev, now);
return newState;
}
/**
* Fold all projections in a scope with the given code revision.
*/
export async function foldAllProjections(scopeDb, scopeName, codeRev) {
const hasTable = scopeDb
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projection_defs'")
.get();
if (!hasTable) {
return new Map();
}
const { listProjectionDefs } = await import('./defs.js');
const projectionDefs = await listProjectionDefs(scopeDb, { codeRev });
const results = new Map();
for (const def of projectionDefs) {
try {
const state = await foldProjection(scopeDb, scopeName, def.name, codeRev);
results.set(def.name, state);
}
catch (error) {
console.error(`Failed to fold projection ${def.name}:`, error);
// Continue with other projections
}
}
return results;
}
/**
* Reset all projections and replay with new code revision.
* This is the only function that DELETES from projections table.
*/
export async function resetProjections(scopeDb, codeRev) {
// 1. Clear projections table
const deleteStmt = deleteAllProjectionsStmt(scopeDb);
deleteStmt.run();
// 2. Get all projection definitions for new code_rev
const { listProjectionDefs } = await import('./defs.js');
const projectionDefs = await listProjectionDefs(scopeDb, { codeRev });
// 3. Replay all events for each projection
const allEventsStmt = selectAllEventsStmt(scopeDb);
const allEvents = allEventsStmt.all().map(rowToEventRecord);
for (const def of projectionDefs) {
let currentValue = def.initialValue;
let lastEventId = 0;
// Process all events
for (const event of allEvents) {
// Find matching sources for this event
const matchingSources = def.sources.filter((source) => {
if (source.eventKind !== event.kind)
return false;
if (source.eventKey && source.eventKey !== event.key)
return false;
return true;
});
// Process each matching source
for (const source of matchingSources) {
try {
const expr = getOrCompileExpression(source.expression);
// JSONata needs variables as bindings, not in data context
const data = {}; // Empty data object
const bindings = {
state: currentValue,
event: {
id: event.id,
occurred_at: event.occurredAt,
kind: event.kind,
key: event.key,
payload: event.meta ? JSON.parse(event.meta) : {},
},
params: def.params || {},
};
const result = await expr.evaluate(data, bindings);
currentValue = result;
lastEventId = event.id;
}
catch (error) {
// Write error event but continue processing
writeErrorEvent(scopeDb, def.name, error, event.id);
console.warn(`Projection ${def.name} replay error for event ${event.id}:`, error);
// Skip this event, don't update state
}
}
}
// Insert final state
const now = Date.now();
const stmt = upsertProjectionStateStmt(scopeDb);
stmt.run(def.name, JSON.stringify(currentValue), lastEventId, codeRev, now);
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { Rule } from '../index.js';
import type { LlmClient, LlmTool } from '../llm-client.js';
import type { PulseStore } from '../store.js';
export interface AgentLoopRuleOptions {
llmClient: LlmClient;
workflowStore: PulseStore;
systemPrompt?: string;
llmTimeoutMs?: number;
}
type Snapshot = Record<string, unknown> & {
timestamp: number;
};
type Effect = Record<string, unknown>;
export declare const EFFECT_TOOLS: LlmTool[];
export declare function createAgentLoopRule<S extends Snapshot, E extends Effect>(opts: AgentLoopRuleOptions): Rule<S, E>;
export {};
+253
View File
@@ -0,0 +1,253 @@
// ── EFFECT_TOOLS (function calling schema) ─────────────────────
export const EFFECT_TOOLS = [
{
type: 'function',
function: {
name: 'assign_task',
description: '分配任务给 cursor agent 执行',
parameters: {
type: 'object',
properties: {
taskId: { type: 'string' },
prompt: { type: 'string' },
scenario: {
type: 'string',
enum: ['bug-fix', 'feature', 'refactor', 'test', 'docs', 'review'],
},
repoDir: { type: 'string' },
timeoutMs: { type: 'number' },
},
required: ['taskId', 'prompt', 'scenario', 'repoDir'],
},
},
},
{
type: 'function',
function: {
name: 'close_task',
description: '确认任务已完成,关闭任务',
parameters: {
type: 'object',
properties: {
taskId: { type: 'string' },
summary: { type: 'string' },
},
required: ['taskId'],
},
},
},
{
type: 'function',
function: {
name: 'give_up_task',
description: '放弃任务,记录无法完成的原因',
parameters: {
type: 'object',
properties: {
taskId: { type: 'string' },
reason: { type: 'string' },
},
required: ['taskId', 'reason'],
},
},
},
{
type: 'function',
function: {
name: 'set_next_check',
description: '设置下次检查的等待时间(毫秒)。没有其他 task 操作时必须调用。',
parameters: {
type: 'object',
properties: {
tickMs: {
type: 'number',
description: '等待毫秒数,例如 60000 = 1分钟',
},
reason: { type: 'string' },
},
required: ['tickMs'],
},
},
},
];
// ── Default System Prompt ──────────────────────────────────────
const DEFAULT_SYSTEM_PROMPT = `你是一个任务调度 Agent,负责管理项目的编码任务队列。
你的职责:
1. 查看待处理任务列表,决定哪些任务可以派给 cursor agent 执行
2. 查看已执行完成(acked)的任务,决定是否关闭或重试
3. 对无法完成的任务,给出明确原因并放弃
调度原则:
- 每次只派发一个任务(避免并发冲突)
- 任务 acked 后先检查执行结果,成功则 close,失败则考虑 retry 或 give_up
- 无任务可处理时,调用 set_next_check 设置等待时间(通常 60000-300000 ms)
你的每次决策都会被记录作为能力指标,请认真判断。`;
function buildTaskSummary(projectId, tasks) {
const lines = [`项目 ${projectId} 的任务列表 (${tasks.length} 个):\n`];
for (const task of tasks) {
lines.push(`- [${task.status}] ${task.taskId}: ${task.title} (priority=${task.priority})`);
if (task.lastRespondedResult) {
lines.push(` 最近执行结果: ${task.lastRespondedResult}`);
}
}
return lines.join('\n');
}
function traceMessageToLlmMessage(m) {
if (m.role === 'assistant') {
const parts = [];
if (m.content)
parts.push(m.content);
if (m.toolCalls) {
for (const tc of m.toolCalls) {
parts.push(`[tool_call] ${tc.name}(${JSON.stringify(tc.arguments)})`);
}
}
return { role: 'assistant', content: parts.join('\n') || undefined };
}
return {
role: 'tool',
content: `[${m.toolName}] ${m.result ?? 'ok'}`,
};
}
function parseToolCallsToEffects(toolCalls, projectId) {
const effects = [];
let tickMs = 60_000; // default 1min
for (const tc of toolCalls) {
let args;
try {
args = JSON.parse(tc.function.arguments);
}
catch {
continue;
}
switch (tc.function.name) {
case 'assign_task':
if (args.taskId && args.prompt && args.scenario && args.repoDir) {
effects.push({
kind: 'coding-task',
taskId: args.taskId,
prompt: args.prompt,
scenario: args.scenario,
repoDir: args.repoDir,
timeoutMs: args.timeoutMs,
projectId,
});
}
break;
case 'close_task':
if (args.taskId) {
effects.push({
kind: 'task-closed',
taskId: args.taskId,
agentId: 'agent-loop',
summary: args.summary,
projectId,
});
}
break;
case 'give_up_task':
if (args.taskId && args.reason) {
effects.push({
kind: 'task-given-up',
taskId: args.taskId,
agentId: 'agent-loop',
reason: args.reason,
projectId,
});
}
break;
case 'set_next_check':
if (typeof args.tickMs === 'number' && args.tickMs > 0) {
tickMs = args.tickMs;
}
break;
}
}
return { effects, tickMs };
}
// ── Per-project loop ───────────────────────────────────────────
async function runProjectLoop(projectId, curr, opts) {
// 1. cooldown check
const traceData = curr[`agent-loop-trace:${projectId}`]?.data;
if (traceData?.nextCheckAt && Date.now() < traceData.nextCheckAt) {
return { effects: [], tickMs: traceData.nextCheckAt - Date.now() };
}
// 2. pending tasks for this project
const pendingData = curr['pending-tasks']?.data;
const allTasks = pendingData?.byProject?.[projectId] ?? [];
if (allTasks.length === 0) {
return { effects: [], tickMs: 60_000 };
}
// 3. build user message
const taskSummary = buildTaskSummary(projectId, allTasks);
// 4. history from trace
const history = (traceData?.messages ?? []).map(traceMessageToLlmMessage);
// 5. LLM call with timeout
const systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
const llmCall = opts.llmClient.chat({
messages: [
{ role: 'system', content: systemPrompt },
...history,
{ role: 'user', content: taskSummary },
],
tools: EFFECT_TOOLS,
tool_choice: 'required',
});
await opts.workflowStore.appendEvent({
kind: 'llm-call-started',
meta: JSON.stringify({ projectId, taskCount: allTasks.length }),
occurredAt: Date.now(),
});
let response;
const startTime = Date.now();
try {
response = await Promise.race([
llmCall,
new Promise((_, reject) => setTimeout(() => reject(new Error('LLM timeout')), opts.llmTimeoutMs ?? 30_000)),
]);
}
catch (err) {
await opts.workflowStore.appendEvent({
kind: 'llm-call-failed',
meta: JSON.stringify({ projectId, error: String(err) }),
occurredAt: Date.now(),
});
return { effects: [], tickMs: 60_000 };
}
// 6. write trace event
const durationMs = Date.now() - startTime;
await opts.workflowStore.appendEvent({
kind: 'llm-call-completed',
meta: JSON.stringify({
projectId,
toolCalls: response.tool_calls?.map((tc) => ({
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments),
})),
usage: response.usage,
durationMs,
}),
occurredAt: Date.now(),
});
// 7. parse effects + tickMs from tool_calls
return parseToolCallsToEffects(response.tool_calls ?? [], projectId);
}
export function createAgentLoopRule(opts) {
return async (prev, curr, inner) => {
const [effects, tickMs] = await inner(prev, curr);
const activeProjects = curr['active-projects']?.data
?.projectIds ?? [];
if (activeProjects.length === 0)
return [effects, tickMs];
const results = await Promise.all(activeProjects.map((projectId) => runProjectLoop(projectId, curr, opts)));
const allEffects = [
...effects,
...results.flatMap((r) => r.effects),
];
const candidateTicks = results.map((r) => r.tickMs).filter((t) => t > 0);
const nextTick = Math.min(tickMs, ...candidateTicks);
return [allEffects, nextTick];
};
}
+36
View File
@@ -0,0 +1,36 @@
/**
* @uncaged/pulse — Built-in Rules
*
* Common rule primitives: tick clamping, error backoff,
* adaptive intervals, effect deduplication.
*/
import type { Rule } from '../index.js';
/**
* Clamp tickMs within [min, max].
* Pure frequency guard — doesn't inspect snapshots.
*/
export declare function clampTick<S, E>(min?: number, max?: number): Rule<S, E>;
/**
* Exponential backoff on errors.
*
* Uses an accessor to extract error count from snapshot.
* tickMs *= 2^errors, capped at maxMs.
* Zero errors → pass through unchanged.
*/
export declare function errorBackoff<S, E>(getErrors: (s: S) => number, maxMs?: number): Rule<S, E>;
/**
* Adaptive interval: speed up when state changes, slow down when idle.
*
* - Change detected → fastMs
* - No change → tickMs * slowFactor, capped at slowMs
*/
export declare function adaptiveInterval<S, E>(hasChanged: (prev: S, curr: S) => boolean, fastMs?: number, slowMs?: number, slowFactor?: number): Rule<S, E>;
/**
* Deduplicate effects by kind (or custom key).
*
* Keeps only the last occurrence of each key.
* Requires E extends { kind: string }.
*/
export declare function dedup<S, E extends {
kind: string;
}>(key?: (e: E) => string): Rule<S, E>;
+65
View File
@@ -0,0 +1,65 @@
/**
* @uncaged/pulse — Built-in Rules
*
* Common rule primitives: tick clamping, error backoff,
* adaptive intervals, effect deduplication.
*/
/**
* Clamp tickMs within [min, max].
* Pure frequency guard — doesn't inspect snapshots.
*/
export function clampTick(min = 1000, max = 300_000) {
return async (_prev, _curr, inner) => {
const [effects, tickMs] = await inner(_prev, _curr);
return [effects, Math.max(min, Math.min(max, tickMs))];
};
}
/**
* Exponential backoff on errors.
*
* Uses an accessor to extract error count from snapshot.
* tickMs *= 2^errors, capped at maxMs.
* Zero errors → pass through unchanged.
*/
export function errorBackoff(getErrors, maxMs = 300_000) {
return async (_prev, curr, inner) => {
const [effects, tickMs] = await inner(_prev, curr);
const errors = getErrors(curr);
if (errors <= 0)
return [effects, tickMs];
const backed = Math.min(tickMs * 2 ** errors, maxMs);
return [effects, backed];
};
}
/**
* Adaptive interval: speed up when state changes, slow down when idle.
*
* - Change detected → fastMs
* - No change → tickMs * slowFactor, capped at slowMs
*/
export function adaptiveInterval(hasChanged, fastMs = 5000, slowMs = 120_000, slowFactor = 1.5) {
return async (prev, curr, inner) => {
const [effects, tickMs] = await inner(prev, curr);
if (hasChanged(prev, curr)) {
return [effects, fastMs];
}
return [effects, Math.min(tickMs * slowFactor, slowMs)];
};
}
/**
* Deduplicate effects by kind (or custom key).
*
* Keeps only the last occurrence of each key.
* Requires E extends { kind: string }.
*/
export function dedup(key) {
const getKey = key ?? ((e) => e.kind);
return async (_prev, _curr, inner) => {
const [effects, tickMs] = await inner(_prev, _curr);
const seen = new Map();
for (const e of effects) {
seen.set(getKey(e), e);
}
return [Array.from(seen.values()), tickMs];
};
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Survival layer constants
*
* These values are hardcoded in the core package - agents cannot modify them.
*/
/** Critical process whitelist — hardcoded, agent cannot change */
export declare const ESSENTIAL_PROCESSES: Set<string>;
/** Idempotent window: don't repeat same action within 5 minutes */
export declare const IDEMPOTENT_WINDOW_MS: number;
/** Auto-rollback observation window after promote: 5 minutes */
export declare const ROLLBACK_WINDOW_MS: number;
/** Error count threshold to trigger rollback */
export declare const ROLLBACK_ERROR_THRESHOLD = 5;
/** Max consecutive restart count, escalate to panic if exceeded */
export declare const MAX_RESTART_COUNT = 3;
+19
View File
@@ -0,0 +1,19 @@
/**
* Survival layer constants
*
* These values are hardcoded in the core package - agents cannot modify them.
*/
/** Critical process whitelist — hardcoded, agent cannot change */
export const ESSENTIAL_PROCESSES = new Set([
'upulse', // Pulse itself
'sshd', // SSH rescue channel
'systemd', // System
]);
/** Idempotent window: don't repeat same action within 5 minutes */
export const IDEMPOTENT_WINDOW_MS = 5 * 60 * 1000;
/** Auto-rollback observation window after promote: 5 minutes */
export const ROLLBACK_WINDOW_MS = 5 * 60 * 1000;
/** Error count threshold to trigger rollback */
export const ROLLBACK_ERROR_THRESHOLD = 5;
/** Max consecutive restart count, escalate to panic if exceeded */
export const MAX_RESTART_COUNT = 3;
+30
View File
@@ -0,0 +1,30 @@
/**
* Health snapshot management
*
* Rebuilds health state from events table for use by survival rules.
*/
import type { PulseStore } from '../store.js';
export interface HealthSnapshot {
lastRestart: Record<string, {
ts: number;
count: number;
}>;
lastGc: {
ts: number;
} | null;
lastNotify: {
ts: number;
} | null;
panicCount: number;
lastPromote?: {
ts: number;
codeRev: string;
prevCodeRev: string;
};
recentErrorCount: number;
}
/**
* Rebuild health field from events table.
* This function is in core package, agent cannot change.
*/
export declare function rebuildHealth(store: PulseStore): Promise<HealthSnapshot>;
+71
View File
@@ -0,0 +1,71 @@
/**
* Health snapshot management
*
* Rebuilds health state from events table for use by survival rules.
*/
/**
* Rebuild health field from events table.
* This function is in core package, agent cannot change.
*/
export async function rebuildHealth(store) {
const now = Date.now();
const windowStart = now - 5 * 60 * 1000; // 5 minute window
// Query recent events from events table
const recentEvents = await store.queryByKind('effect', {
since: windowStart,
});
// Count restarts
const lastRestart = {};
for (const ev of recentEvents) {
if (ev.meta) {
try {
const meta = JSON.parse(ev.meta);
if (meta.type === 'restart-service' && meta.service) {
const existing = lastRestart[meta.service];
if (existing) {
existing.count++;
if (ev.occurredAt > existing.ts)
existing.ts = ev.occurredAt;
}
else {
lastRestart[meta.service] = { ts: ev.occurredAt, count: 1 };
}
}
}
catch { }
}
}
// Count error events
const errorEvents = await store.queryByKind('error', { since: windowStart });
// Find latest promote within window
const latestPromote = await store.getLatest('promote');
let lastPromote;
if (latestPromote && latestPromote.occurredAt > windowStart) {
const meta = latestPromote.meta ? JSON.parse(latestPromote.meta) : {};
lastPromote = {
ts: latestPromote.occurredAt,
codeRev: latestPromote.codeRev ?? 'unknown',
prevCodeRev: meta.prevCodeRev ?? 'unknown',
};
}
// Count rollback-config events as panic events
const panicCount = recentEvents.filter((ev) => {
if (!ev.meta)
return false;
try {
const meta = JSON.parse(ev.meta);
return meta.type === 'rollback-config';
}
catch {
return false;
}
}).length;
return {
lastRestart,
lastGc: null, // TODO: rebuild from events
lastNotify: null, // TODO: rebuild from events
panicCount,
lastPromote,
recentErrorCount: errorEvents.length,
};
}
+4
View File
@@ -0,0 +1,4 @@
export { adaptiveInterval, clampTick, dedup, errorBackoff } from './builtin.js';
export { ESSENTIAL_PROCESSES, IDEMPOTENT_WINDOW_MS, MAX_RESTART_COUNT, ROLLBACK_ERROR_THRESHOLD, ROLLBACK_WINDOW_MS, } from './constants.js';
export { type HealthSnapshot, rebuildHealth } from './health.js';
export { type SurvivalSnapshot, survivalRules } from './survival.js';
+4
View File
@@ -0,0 +1,4 @@
export { adaptiveInterval, clampTick, dedup, errorBackoff } from './builtin.js';
export { ESSENTIAL_PROCESSES, IDEMPOTENT_WINDOW_MS, MAX_RESTART_COUNT, ROLLBACK_ERROR_THRESHOLD, ROLLBACK_WINDOW_MS, } from './constants.js';
export { rebuildHealth } from './health.js';
export { survivalRules } from './survival.js';
+61
View File
@@ -0,0 +1,61 @@
/**
* Survival rules - P0 protection layer
*
* These rules are hardcoded in the outermost onion layer. Agents cannot modify them.
* Rules are applied in array order (first element is outermost layer).
*/
import type { SurvivalEffect } from '../executors/survival.js';
import type { Rule, Sensed } from '../index.js';
import type { ErrorLogData } from '../watchers/error-log.js';
import type { NetworkData } from '../watchers/network.js';
import type { ProcessAliveData } from '../watchers/process-alive.js';
import type { SystemResourceData } from '../watchers/system-resource.js';
import type { HealthSnapshot } from './health.js';
/** Minimal LLM health shape used by llmWatchdog (full type lives in @uncaged/pulse-openclaw) */
interface LlmHealthLike {
processOk: boolean;
completionOk?: boolean;
}
export interface SurvivalSnapshot {
timestamp: number;
system?: Sensed<SystemResourceData>;
processes?: Sensed<ProcessAliveData>;
network?: Sensed<NetworkData>;
errorLog?: Sensed<ErrorLogData>;
llm?: Sensed<LlmHealthLike>;
health?: HealthSnapshot;
}
/**
* Panic rollback - outermost fallback
* If panicCount >= 3, rollback all configs and bypass inner layers
*/
export declare const panicRollback: Rule<SurvivalSnapshot, SurvivalEffect>;
/**
* Auto rollback after recent promote if too many errors
*/
export declare const autoRollback: Rule<SurvivalSnapshot, SurvivalEffect>;
/**
* Process watchdog - restart dead essential processes
*/
export declare const processWatchdog: Rule<SurvivalSnapshot, SurvivalEffect>;
/**
* Resource guard - handle disk/memory pressure
*/
export declare const resourceGuard: Rule<SurvivalSnapshot, SurvivalEffect>;
/**
* LLM watchdog - monitor LLM service health
*/
export declare const llmWatchdog: Rule<SurvivalSnapshot, SurvivalEffect>;
/**
* Network watchdog - monitor connectivity (notify-only stub)
*/
export declare const networkWatchdog: Rule<SurvivalSnapshot, SurvivalEffect>;
/**
* Error escalation - accelerate patrol on errors (notify-only stub)
*/
export declare const errorEscalate: Rule<SurvivalSnapshot, SurvivalEffect>;
/**
* Survival rules in onion order (first element is outermost layer)
*/
export declare const survivalRules: Rule<SurvivalSnapshot, SurvivalEffect>[];
export {};
+190
View File
@@ -0,0 +1,190 @@
/**
* Survival rules - P0 protection layer
*
* These rules are hardcoded in the outermost onion layer. Agents cannot modify them.
* Rules are applied in array order (first element is outermost layer).
*/
import { ESSENTIAL_PROCESSES, MAX_RESTART_COUNT, ROLLBACK_ERROR_THRESHOLD, ROLLBACK_WINDOW_MS, } from './constants.js';
/**
* Panic rollback - outermost fallback
* If panicCount >= 3, rollback all configs and bypass inner layers
*/
export const panicRollback = async (prev, curr, inner) => {
const panicCount = curr.health?.panicCount ?? 0;
if (panicCount >= 3) {
return [
[
{
type: 'rollback-config',
},
],
30000,
]; // Emergency interval
}
// Pass through to inner layers
return await inner(prev, curr);
};
/**
* Auto rollback after recent promote if too many errors
*/
export const autoRollback = async (prev, curr, inner) => {
const now = Date.now();
const lastPromote = curr.health?.lastPromote;
if (lastPromote &&
now - lastPromote.ts <= ROLLBACK_WINDOW_MS &&
(curr.health?.recentErrorCount ?? 0) >= ROLLBACK_ERROR_THRESHOLD) {
return [
[
{
type: 'rollback-code',
to: lastPromote.prevCodeRev,
},
],
60000,
]; // Check every minute during rollback
}
// Pass through to inner layers
return await inner(prev, curr);
};
/**
* Process watchdog - restart dead essential processes
*/
export const processWatchdog = async (prev, curr, inner) => {
// processes.data.processes is Record<string, boolean> (name → alive)
const processes = curr.processes?.data?.processes;
if (!processes) {
return await inner(prev, curr);
}
const effects = [];
let hasDeadProcess = false;
// Check each essential process
for (const serviceName of ESSENTIAL_PROCESSES) {
// Check if Record contains matching key and value is false
const isAlive = Object.entries(processes).some(([name, alive]) => name.toLowerCase().includes(serviceName.toLowerCase()) && alive);
const isMonitored = Object.keys(processes).some((k) => k.toLowerCase().includes(serviceName.toLowerCase()));
if (isMonitored && !isAlive) {
// Process is being monitored but is dead
hasDeadProcess = true;
const restartCount = curr.health?.lastRestart?.[serviceName]?.count ?? 0;
if (restartCount < MAX_RESTART_COUNT) {
effects.push({
type: 'restart-service',
service: serviceName,
});
}
else {
effects.push({
type: 'notify-owner',
message: `[CRITICAL] Process ${serviceName} failed ${restartCount} times, needs manual intervention`,
});
}
}
}
if (hasDeadProcess) {
// Bypass inner layers if critical processes are down
return [effects, 10000]; // Fast retry for process recovery
}
// All processes healthy, pass through
return await inner(prev, curr);
};
/**
* Resource guard - handle disk/memory pressure
*/
export const resourceGuard = async (prev, curr, inner) => {
const system = curr.system?.data;
if (!system) {
return await inner(prev, curr);
}
// system-resource watcher returns flat { diskPct, memoryPct, cpuPct, swapPct }
const diskUsage = system.diskPct ?? 0;
const memUsage = system.memoryPct ?? 0;
const effects = [];
if (diskUsage > 95) {
effects.push({ type: 'gc-vitals' }, { type: 'clear-cache', target: 'journal' }, { type: 'clear-cache', target: 'tmp' });
// Bypass inner layers during disk crisis
return [effects, 30000];
}
if (memUsage > 90) {
effects.push({ type: 'archive-sessions' }, { type: 'restart-service', service: 'openclaw' });
// Bypass inner layers during memory crisis
return [effects, 30000];
}
// Resources healthy, pass through
return await inner(prev, curr);
};
/**
* LLM watchdog - monitor LLM service health
*/
export const llmWatchdog = async (prev, curr, inner) => {
const llm = curr.llm?.data;
if (!llm) {
return await inner(prev, curr);
}
const effects = [];
if (llm.processOk === false) {
effects.push({ type: 'restart-service', service: 'litellm' }, { type: 'notify-owner', message: '[WARN] LiteLLM process restarted' });
// Don't bypass - let inner layers see degraded state and adapt
}
if (llm.completionOk === false) {
effects.push({
type: 'notify-owner',
message: '[WARN] LLM upstream unavailable',
});
// Don't bypass - upstream issues we can't fix locally
}
// Continue to inner layers with any effects
const [innerEffects, tickMs] = await inner(prev, curr);
return [effects.concat(innerEffects), tickMs];
};
/**
* Network watchdog - monitor connectivity (notify-only stub)
*/
export const networkWatchdog = async (prev, curr, inner) => {
const network = curr.network?.data;
// network watcher returns { dnsOk, httpOk, latencyMs }; connectivity is lost when both fail
if (network && !network.dnsOk && !network.httpOk) {
const effects = [
{
type: 'notify-owner',
message: '[CRITICAL] Network connectivity lost',
},
];
// Continue to inner layers
const [innerEffects, tickMs] = await inner(prev, curr);
return [effects.concat(innerEffects), tickMs];
}
// Pass through to inner layers
return await inner(prev, curr);
};
/**
* Error escalation - accelerate patrol on errors (notify-only stub)
*/
export const errorEscalate = async (prev, curr, inner) => {
const recentErrors = curr.health?.recentErrorCount ?? 0;
if (recentErrors > 0) {
const effects = [
{
type: 'notify-owner',
message: `[WARN] ${recentErrors} recent errors detected, accelerating patrol`,
},
];
// Continue to inner layers with accelerated timing
const [innerEffects, tickMs] = await inner(prev, curr);
const acceleratedTick = Math.max(tickMs * 0.5, 5000); // 50% faster, min 5s
return [effects.concat(innerEffects), acceleratedTick];
}
// Pass through to inner layers
return await inner(prev, curr);
};
/**
* Survival rules in onion order (first element is outermost layer)
*/
export const survivalRules = [
panicRollback,
autoRollback,
processWatchdog,
resourceGuard,
llmWatchdog,
networkWatchdog,
errorEscalate,
];
+97
View File
@@ -0,0 +1,97 @@
/**
* @uncaged/pulse — Storage Layer (v4: events table + CAS)
*
* Single `events` table with INTEGER AUTOINCREMENT primary keys +
* content-addressed object store (CAS) on disk via SHA-256 hashes.
*/
import { Database } from 'bun:sqlite';
export interface EventRecord {
id: number;
occurredAt: number;
kind: string;
key?: string;
hash?: string;
codeRev?: string;
meta?: string;
objectId?: number;
}
/** An immutable entity instance tracked in the objects table. */
export interface ObjectInstance {
id: number;
objectType: string;
externalId: string | null;
createdAt: number;
codeRev: string;
}
export interface PulseStore {
/** Append one event (id is auto-incremented) */
appendEvent(event: Omit<EventRecord, 'id'>): Promise<EventRecord>;
/** Append multiple events in a transaction */
appendEvents(events: Omit<EventRecord, 'id'>[]): Promise<EventRecord[]>;
/** Create an immutable object instance. Returns the integer id. Idempotent on (objectType, externalId). */
createObject(opts: {
objectType: string;
externalId?: string;
codeRev: string;
}): Promise<number>;
/** Get an object instance by id. Returns null if not found. */
getObjectInstance(id: number): Promise<ObjectInstance | null>;
/** Query object instances by type. */
queryObjectsByType(objectType: string): Promise<ObjectInstance[]>;
/** Get the latest event by kind + optional key */
getLatest(kind: string, key?: string): Promise<EventRecord | null>;
/** Get latest event with additional filters */
getLatestWhere(opts: {
kind: string;
key?: string;
codeRev?: string;
}): Promise<EventRecord | null>;
/** Get recent events (newest first) */
getRecent(limit?: number): Promise<EventRecord[]>;
/** Query events by kind with optional filters */
queryByKind(kind: string, opts?: {
key?: string;
since?: number;
codeRev?: string;
limit?: number;
}): Promise<EventRecord[]>;
/** Get all events after a specific event id */
getAfter(afterId: number, opts?: {
kind?: string;
key?: string;
codeRev?: string;
}): Promise<EventRecord[]>;
/** Check if any events exist */
hasEvents(): Promise<boolean>;
/** Write data to CAS store. Returns hash. No-op if already exists. */
putObject(data: unknown): Promise<string>;
/** Read data from CAS store by hash. Returns null if not found. */
getObject(hash: string): Promise<unknown | null>;
/** Close the database */
close(): Promise<void>;
/** Delete events older than the given timestamp. Returns count of deleted rows. */
archiveEvents(olderThan: number): Promise<number>;
/** Downsample events of a specific kind+key: keep one per interval window. Returns count of deleted rows. */
downsampleEvents(kind: string, key: string, intervalMs: number, olderThan: number): Promise<number>;
}
export interface CreateStoreOptions {
eventsDbPath: string;
/** @deprecated Vitals now use events table via scoped store. This field is accepted but ignored. */
vitalsDbPath?: string;
objectsDir: string;
}
export declare function createStore(options: CreateStoreOptions): PulseStore;
export interface CreateScopedStoreOptions {
basePath: string;
objectsDir: string;
}
export interface ScopedStore {
scope(name: string): PulseStore;
listScopes(): string[];
/** Get underlying Database for scope (used by projection engine) */
scopeDatabase(name: string): Database;
putObject(data: unknown): Promise<string>;
getObject(hash: string): Promise<unknown | null>;
close(): Promise<void>;
}
export declare function createScopedStore(options: CreateScopedStoreOptions): ScopedStore;
+525
View File
@@ -0,0 +1,525 @@
/**
* @uncaged/pulse — Storage Layer (v4: events table + CAS)
*
* Single `events` table with INTEGER AUTOINCREMENT primary keys +
* content-addressed object store (CAS) on disk via SHA-256 hashes.
*/
import { Database } from 'bun:sqlite';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
import { dirname, join } from 'node:path';
import { initDefsSchema } from './defs.js';
import { PROJECTIONS_SCHEMA } from './projection-engine.js';
// ── CAS Hashing ────────────────────────────────────────────────
function hashObject(data) {
return createHash('sha256')
.update(JSON.stringify(data))
.digest('hex')
.slice(0, 32); // 32 hex chars = 128 bits, safe against birthday collisions
}
// ── Schema ─────────────────────────────────────────────────────
const EVENTS_SCHEMA = `
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
occurred_at INTEGER NOT NULL,
kind TEXT NOT NULL,
key TEXT,
hash TEXT,
code_rev TEXT,
meta TEXT
);
CREATE INDEX IF NOT EXISTS idx_occurred ON events(occurred_at);
CREATE INDEX IF NOT EXISTS idx_kind_key ON events(kind, key, occurred_at);
CREATE INDEX IF NOT EXISTS idx_code_rev ON events(code_rev, occurred_at);
`;
const OBJECTS_SCHEMA = `
CREATE TABLE IF NOT EXISTS objects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
object_type TEXT NOT NULL,
external_id TEXT,
created_at INTEGER NOT NULL,
code_rev TEXT NOT NULL,
UNIQUE(object_type, external_id)
);
`;
/** Safe migration: add object_id column to events if it doesn't exist yet. */
function migrateEventsObjectId(db) {
try {
db.run('ALTER TABLE events ADD COLUMN object_id INTEGER REFERENCES objects(id)');
}
catch (_e) {
// Column already exists — ignore
}
}
function rowToRecord(row) {
const rec = {
id: Number(row.id),
occurredAt: row.occurred_at,
kind: row.kind,
};
if (row.key != null)
rec.key = row.key;
if (row.hash != null)
rec.hash = row.hash;
if (row.code_rev != null)
rec.codeRev = row.code_rev;
if (row.meta != null)
rec.meta = row.meta;
if (row.object_id != null)
rec.objectId = row.object_id;
return rec;
}
function rowToObjectInstance(row) {
return {
id: row.id,
objectType: row.object_type,
externalId: row.external_id,
createdAt: row.created_at,
codeRev: row.code_rev,
};
}
export function createStore(options) {
const { eventsDbPath, objectsDir } = options;
mkdirSync(objectsDir, { recursive: true });
const eventsDb = new Database(eventsDbPath, { create: true });
eventsDb.exec('PRAGMA journal_mode = WAL');
eventsDb.exec('PRAGMA busy_timeout = 5000');
eventsDb.exec(EVENTS_SCHEMA);
eventsDb.exec(OBJECTS_SCHEMA);
migrateEventsObjectId(eventsDb);
const insertEvent = eventsDb.prepare(`
INSERT INTO events (occurred_at, kind, key, hash, code_rev, meta, object_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const selectLatest = eventsDb.prepare(`
SELECT * FROM events
WHERE kind = ? AND (key = ? OR ? IS NULL)
ORDER BY occurred_at DESC, id DESC
LIMIT 1
`);
const selectHasEvents = eventsDb.prepare(`
SELECT 1 FROM events LIMIT 1
`);
function doAppendEvent(event) {
const result = insertEvent.run(event.occurredAt, event.kind, event.key ?? null, event.hash ?? null, event.codeRev ?? null, event.meta ?? null, event.objectId ?? null);
const id = Number(result.lastInsertRowid);
return { id, ...event };
}
const appendManyTx = eventsDb.transaction((events) => {
const results = [];
for (const event of events) {
const result = insertEvent.run(event.occurredAt, event.kind, event.key ?? null, event.hash ?? null, event.codeRev ?? null, event.meta ?? null, event.objectId ?? null);
const id = Number(result.lastInsertRowid);
results.push({ id, ...event });
}
return results;
});
return {
async appendEvent(event) {
return doAppendEvent(event);
},
async appendEvents(events) {
return appendManyTx(events);
},
async getLatest(kind, key) {
const row = selectLatest.get(kind, key ?? null, key ?? null);
return row ? rowToRecord(row) : null;
},
async getLatestWhere(opts) {
const conditions = ['kind = ?'];
const params = [opts.kind];
if (opts.key !== undefined) {
conditions.push('key = ?');
params.push(opts.key);
}
if (opts.codeRev !== undefined) {
conditions.push('code_rev = ?');
params.push(opts.codeRev);
}
const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC LIMIT 1`;
const row = eventsDb.prepare(sql).get(...params);
return row ? rowToRecord(row) : null;
},
async getRecent(limit = 20) {
const sql = `SELECT * FROM events ORDER BY occurred_at DESC, id DESC LIMIT ?`;
return eventsDb.prepare(sql).all(limit).map(rowToRecord);
},
async queryByKind(kind, opts) {
const conditions = ['kind = ?'];
const params = [kind];
if (opts?.key !== undefined) {
conditions.push('key = ?');
params.push(opts.key);
}
if (opts?.since !== undefined) {
conditions.push('occurred_at >= ?');
params.push(opts.since);
}
if (opts?.codeRev !== undefined) {
conditions.push('code_rev = ?');
params.push(opts.codeRev);
}
let sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC`;
if (opts?.limit !== undefined) {
sql += ' LIMIT ?';
params.push(opts.limit);
}
return eventsDb.prepare(sql).all(...params).map(rowToRecord);
},
async getAfter(afterId, opts) {
const conditions = ['id > ?'];
const params = [afterId];
if (opts?.kind !== undefined) {
conditions.push('kind = ?');
params.push(opts.kind);
}
if (opts?.key !== undefined) {
conditions.push('key = ?');
params.push(opts.key);
}
if (opts?.codeRev !== undefined) {
conditions.push('code_rev = ?');
params.push(opts.codeRev);
}
const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC`;
return eventsDb.prepare(sql).all(...params).map(rowToRecord);
},
async hasEvents() {
return selectHasEvents.get() !== null;
},
async createObject(opts) {
const now = Date.now();
const extId = opts.externalId ?? null;
// Idempotent: if (objectType, externalId) already exists, return existing id
if (extId !== null) {
const existing = eventsDb
.prepare('SELECT id FROM objects WHERE object_type = ? AND external_id = ?')
.get(opts.objectType, extId);
if (existing)
return existing.id;
}
const result = eventsDb
.prepare('INSERT INTO objects (object_type, external_id, created_at, code_rev) VALUES (?, ?, ?, ?)')
.run(opts.objectType, extId, now, opts.codeRev);
return Number(result.lastInsertRowid);
},
async getObjectInstance(id) {
const row = eventsDb
.prepare('SELECT * FROM objects WHERE id = ?')
.get(id);
return row ? rowToObjectInstance(row) : null;
},
async queryObjectsByType(objectType) {
return eventsDb
.prepare('SELECT * FROM objects WHERE object_type = ?')
.all(objectType).map(rowToObjectInstance);
},
async putObject(data) {
const hash = hashObject(data);
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath)) {
mkdirSync(objectsDir, { recursive: true });
writeFileSync(filePath, JSON.stringify(data), 'utf-8');
}
return hash;
},
async getObject(hash) {
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath))
return null;
return JSON.parse(readFileSync(filePath, 'utf-8'));
},
async close() {
eventsDb.close();
},
async archiveEvents(olderThan) {
const result = eventsDb
.prepare('DELETE FROM events WHERE occurred_at < ?')
.run(olderThan);
return result.changes;
},
async downsampleEvents(kind, key, intervalMs, olderThan) {
const safeInterval = Math.floor(Math.abs(intervalMs));
if (safeInterval <= 0)
return 0;
const stmt = eventsDb.prepare(`
DELETE FROM events WHERE kind = ? AND key = ? AND occurred_at < ? AND id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY (occurred_at / ${safeInterval}) ORDER BY occurred_at DESC
) as rn FROM events WHERE kind = ? AND key = ? AND occurred_at < ?
) WHERE rn = 1
)
`);
const result = stmt.run(kind, key, olderThan, kind, key, olderThan);
return result.changes;
},
};
}
// ── Scoped Store ──────────────────────────────────────────────
const SCOPE_NAME_RE = /^[a-z0-9_-]{1,64}$/;
function validateScopeName(name) {
if (!SCOPE_NAME_RE.test(name)) {
throw new Error(`Invalid scope name "${name}": must match [a-z0-9_-] and be 1-64 chars`);
}
}
/**
* Open (or create) a scope database at the given path.
* Sets WAL mode and creates the events table and projections table.
* initDefsSchema is async in interface but synchronous in bun:sqlite — safe to call with void.
*/
function openScopeDb(path) {
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path, { create: true });
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA busy_timeout = 5000');
db.exec(EVENTS_SCHEMA);
db.exec(OBJECTS_SCHEMA);
migrateEventsObjectId(db);
// Use canonical PROJECTIONS_SCHEMA (INTEGER last_event_id)
db.exec(PROJECTIONS_SCHEMA);
// Each scope carries its own def tables (bun:sqlite is sync under async wrapper)
void initDefsSchema(db);
return db;
}
function createScopeStore(db, objectsDir) {
const insertEvent = db.prepare(`
INSERT INTO events (occurred_at, kind, key, hash, code_rev, meta, object_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const selectLatest = db.prepare(`
SELECT * FROM events
WHERE kind = ? AND (key = ? OR ? IS NULL)
ORDER BY occurred_at DESC, id DESC
LIMIT 1
`);
const selectHasEvents = db.prepare(`
SELECT 1 FROM events LIMIT 1
`);
function doAppendEvent(event) {
const result = insertEvent.run(event.occurredAt, event.kind, event.key ?? null, event.hash ?? null, event.codeRev ?? null, event.meta ?? null, event.objectId ?? null);
const id = Number(result.lastInsertRowid);
return { id, ...event };
}
const appendManyTx = db.transaction((events) => {
const results = [];
for (const event of events) {
const result = insertEvent.run(event.occurredAt, event.kind, event.key ?? null, event.hash ?? null, event.codeRev ?? null, event.meta ?? null, event.objectId ?? null);
const id = Number(result.lastInsertRowid);
results.push({ id, ...event });
}
return results;
});
return {
async appendEvent(event) {
return doAppendEvent(event);
},
async appendEvents(events) {
return appendManyTx(events);
},
async getLatest(kind, key) {
const row = selectLatest.get(kind, key ?? null, key ?? null);
return row ? rowToRecord(row) : null;
},
async getLatestWhere(opts) {
const conditions = ['kind = ?'];
const params = [opts.kind];
if (opts.key !== undefined) {
conditions.push('key = ?');
params.push(opts.key);
}
if (opts.codeRev !== undefined) {
conditions.push('code_rev = ?');
params.push(opts.codeRev);
}
const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC LIMIT 1`;
const row = db.prepare(sql).get(...params);
return row ? rowToRecord(row) : null;
},
async getRecent(limit = 20) {
const sql = `SELECT * FROM events ORDER BY occurred_at DESC, id DESC LIMIT ?`;
return db.prepare(sql).all(limit).map(rowToRecord);
},
async queryByKind(kind, opts) {
const conditions = ['kind = ?'];
const params = [kind];
if (opts?.key !== undefined) {
conditions.push('key = ?');
params.push(opts.key);
}
if (opts?.since !== undefined) {
conditions.push('occurred_at >= ?');
params.push(opts.since);
}
if (opts?.codeRev !== undefined) {
conditions.push('code_rev = ?');
params.push(opts.codeRev);
}
let sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC`;
if (opts?.limit !== undefined) {
sql += ' LIMIT ?';
params.push(opts.limit);
}
return db.prepare(sql).all(...params).map(rowToRecord);
},
async getAfter(afterId, opts) {
const conditions = ['id > ?'];
const params = [afterId];
if (opts?.kind !== undefined) {
conditions.push('kind = ?');
params.push(opts.kind);
}
if (opts?.key !== undefined) {
conditions.push('key = ?');
params.push(opts.key);
}
if (opts?.codeRev !== undefined) {
conditions.push('code_rev = ?');
params.push(opts.codeRev);
}
const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC`;
return db.prepare(sql).all(...params).map(rowToRecord);
},
async hasEvents() {
return selectHasEvents.get() !== null;
},
async createObject(opts) {
const now = Date.now();
const extId = opts.externalId ?? null;
if (extId !== null) {
const existing = db
.prepare('SELECT id FROM objects WHERE object_type = ? AND external_id = ?')
.get(opts.objectType, extId);
if (existing)
return existing.id;
}
const result = db
.prepare('INSERT INTO objects (object_type, external_id, created_at, code_rev) VALUES (?, ?, ?, ?)')
.run(opts.objectType, extId, now, opts.codeRev);
return Number(result.lastInsertRowid);
},
async getObjectInstance(id) {
const row = db
.prepare('SELECT * FROM objects WHERE id = ?')
.get(id);
return row ? rowToObjectInstance(row) : null;
},
async queryObjectsByType(objectType) {
return db
.prepare('SELECT * FROM objects WHERE object_type = ?')
.all(objectType).map(rowToObjectInstance);
},
async putObject(data) {
const hash = hashObject(data);
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath)) {
mkdirSync(objectsDir, { recursive: true });
writeFileSync(filePath, JSON.stringify(data), 'utf-8');
}
return hash;
},
async getObject(hash) {
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath))
return null;
return JSON.parse(readFileSync(filePath, 'utf-8'));
},
async close() {
db.close();
},
async archiveEvents(olderThan) {
const result = db
.prepare('DELETE FROM events WHERE occurred_at < ?')
.run(olderThan);
return result.changes;
},
async downsampleEvents(kind, key, intervalMs, olderThan) {
const safeInterval = Math.floor(Math.abs(intervalMs));
if (safeInterval <= 0)
return 0;
const stmt = db.prepare(`
DELETE FROM events WHERE kind = ? AND key = ? AND occurred_at < ? AND id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY (occurred_at / ${safeInterval}) ORDER BY occurred_at DESC
) as rn FROM events WHERE kind = ? AND key = ? AND occurred_at < ?
) WHERE rn = 1
)
`);
const result = stmt.run(kind, key, olderThan, kind, key, olderThan);
return result.changes;
},
};
}
export function createScopedStore(options) {
const { basePath, objectsDir } = options;
mkdirSync(basePath, { recursive: true });
mkdirSync(objectsDir, { recursive: true });
const openStores = new Map();
const openDatabases = new Map();
return {
scope(name) {
validateScopeName(name);
const existing = openStores.get(name);
if (existing)
return existing;
const dbPath = join(basePath, `${name}.db`);
const db = openScopeDb(dbPath);
const store = createScopeStore(db, objectsDir);
openStores.set(name, store);
openDatabases.set(name, db);
return store;
},
scopeDatabase(name) {
validateScopeName(name);
// Try to get existing database
const existing = openDatabases.get(name);
if (existing)
return existing;
// Open database if not already open
const dbPath = join(basePath, `${name}.db`);
const db = openScopeDb(dbPath);
openDatabases.set(name, db);
// Also create store if not already created
if (!openStores.has(name)) {
const store = createScopeStore(db, objectsDir);
openStores.set(name, store);
}
return db;
},
listScopes() {
if (!existsSync(basePath))
return [];
return readdirSync(basePath)
.filter((f) => f.endsWith('.db'))
.map((f) => f.slice(0, -3))
.sort();
},
async putObject(data) {
const hash = hashObject(data);
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath)) {
mkdirSync(objectsDir, { recursive: true });
writeFileSync(filePath, JSON.stringify(data), 'utf-8');
}
return hash;
},
async getObject(hash) {
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath))
return null;
return JSON.parse(readFileSync(filePath, 'utf-8'));
},
async close() {
for (const store of openStores.values()) {
await store.close();
}
for (const db of openDatabases.values()) {
db.close();
}
openStores.clear();
openDatabases.clear();
},
};
}
+121
View File
@@ -0,0 +1,121 @@
export type TaskStatus = 'pending' | 'routing' | 'assigned' | 'closed';
export type TaskType = 'bug' | 'rfc' | 'action' | 'review';
export interface TaskCreatedMeta {
taskId: string;
projectId: string;
title: string;
description: string;
type: TaskType;
priority: number;
creatorId: string;
}
export interface TaskRoutingMeta {
taskId: string;
brokerSessionId: string;
}
export interface TaskAssignedMeta {
taskId: string;
assigneeId: string;
assignedBy: string;
}
export interface TaskRespondedMeta {
taskId: string;
assigneeId: string;
result: string;
}
export interface TaskClosedMeta {
taskId: string;
creatorId: string;
}
export interface ProjectCreatedMeta {
projectId: string;
name: string;
repoDir: string;
}
export interface TaskState {
taskId: string;
projectId: string;
title: string;
description: string;
type: TaskType;
priority: number;
creatorId: string;
status: TaskStatus;
assigneeId?: string;
lastRespondedResult?: string;
createdAt: number;
updatedAt: number;
}
export interface PendingTasksData {
pendingCount: number;
tasks: TaskState[];
byProject: Record<string, TaskState[]>;
checkedAt: number;
}
export interface ProjectState {
projectId: string;
name: string;
repoDir: string;
}
export interface InflightBrokerData {
active: boolean;
}
export type ContainerType = 'openclaw' | 'cursor' | 'claude-code' | 'hermes';
export type ContainerStatus = 'online' | 'offline' | 'busy';
export interface PersonaRegisteredMeta {
personaId: string;
name: string;
container: ContainerType;
capabilities: string[];
}
export interface PersonaUpdatedMeta {
personaId: string;
container?: ContainerType;
capabilities?: string[];
}
export interface PersonaState {
personaId: string;
name: string;
container: ContainerType;
capabilities: string[];
registeredAt: number;
updatedAt: number;
}
export interface LlmCallStartedMeta {
projectId: string;
model?: string;
}
export interface LlmCallCompletedMeta {
projectId: string;
model?: string;
usage?: {
inputTokens: number;
outputTokens: number;
};
toolCalls?: Array<{
name: string;
arguments: Record<string, unknown>;
}>;
durationMs: number;
}
export interface ToolResponseMeta {
projectId: string;
toolCallIndex: number;
toolName: string;
result: string;
}
export interface TraceMessage {
role: 'assistant' | 'tool';
content?: string;
toolCalls?: LlmCallCompletedMeta['toolCalls'];
toolName?: string;
result?: string;
ts: number;
}
export interface AgentLoopTraceData {
messages: TraceMessage[];
nextCheckAt: number;
}
export interface ActiveProjectsData {
projectIds: string[];
}
+1
View File
@@ -0,0 +1 @@
export {};
+73
View File
@@ -0,0 +1,73 @@
/**
* @uncaged/pulse — Watcher Framework
*
* Periodic data collection loops that store events (kind='vital') and trigger
* wake callbacks when a configurable condition is met over a sliding window.
*/
import type { EventRecord, PulseStore } from './store.js';
/**
* An event record with the resolved data payload attached.
* Passed to `shouldWake` so conditions can operate on typed data
* without re-fetching from the object store.
*/
export interface VitalWithData<T = unknown> extends EventRecord {
data: T;
}
/**
* Predicate evaluated against a recent window of vital records
* with their resolved data payloads.
* Return `true` to fire the wake callback.
*/
export type WakeCondition<T = unknown> = (window: VitalWithData<T>[]) => boolean;
/**
* Definition of a watcher: what to collect, how often, and when to wake.
*/
export interface WatcherDef<T = unknown> {
/** Human-readable name, used in logs and the returned handle. */
name: string;
/** Vital key under which collected data is stored. */
key: string;
/**
* Async function that collects a snapshot of data.
* The returned value is persisted via CAS and referenced in the vital record.
*/
collect: () => Promise<T>;
/**
* Evaluated after each collection against the last 12 vital records
* with their resolved data payloads.
* When it returns `true`, `wakeTick` is invoked.
*/
shouldWake: WakeCondition<T>;
/**
* Collection interval in milliseconds.
* @default 5000
*/
intervalMs?: number;
}
/**
* Returned by `startWatcher`. Allows the caller to identify and stop the loop.
*/
export interface WatcherHandle {
/** The watcher's name, as provided in {@link WatcherDef}. */
name: string;
/** Stop the collection loop. The current in-flight tick completes first. */
stop: () => void;
}
/**
* Start a periodic collection loop for the given watcher definition.
*
* The loop runs in the background (fire-and-forget). Each tick:
* 1. Calls `def.collect()` to obtain a snapshot.
* 2. Persists the snapshot via `store.putObject()`.
* 3. Appends an event with kind='vital' via `store.appendEvent()`.
* 4. Fetches the last 12 vital events and evaluates `def.shouldWake()`.
* 5. If the condition is met, calls `wakeTick()`.
*
* Errors in any step are caught, logged, and do not stop the loop.
*
* @param def Watcher configuration.
* @param store Pulse store instance for persistence (typically the _vitals scope).
* @param wakeTick Callback invoked when `def.shouldWake` returns `true`.
* @returns A handle that exposes the watcher name and a `stop` function.
*/
export declare function startWatcher(def: WatcherDef, store: PulseStore, wakeTick: () => void): WatcherHandle;
+90
View File
@@ -0,0 +1,90 @@
/**
* @uncaged/pulse — Watcher Framework
*
* Periodic data collection loops that store events (kind='vital') and trigger
* wake callbacks when a configurable condition is met over a sliding window.
*/
// ── Implementation ─────────────────────────────────────────────
/**
* Start a periodic collection loop for the given watcher definition.
*
* The loop runs in the background (fire-and-forget). Each tick:
* 1. Calls `def.collect()` to obtain a snapshot.
* 2. Persists the snapshot via `store.putObject()`.
* 3. Appends an event with kind='vital' via `store.appendEvent()`.
* 4. Fetches the last 12 vital events and evaluates `def.shouldWake()`.
* 5. If the condition is met, calls `wakeTick()`.
*
* Errors in any step are caught, logged, and do not stop the loop.
*
* @param def Watcher configuration.
* @param store Pulse store instance for persistence (typically the _vitals scope).
* @param wakeTick Callback invoked when `def.shouldWake` returns `true`.
* @returns A handle that exposes the watcher name and a `stop` function.
*/
export function startWatcher(def, store, wakeTick) {
const intervalMs = def.intervalMs ?? 5000;
let running = true;
async function loop() {
while (running) {
await sleep(intervalMs);
if (!running)
break;
try {
const data = await def.collect();
const hash = await store.putObject(data);
await store.appendEvent({
occurredAt: Date.now(),
kind: 'vital',
key: def.key,
hash,
});
const window = await store.queryByKind('vital', {
key: def.key,
limit: 12,
});
const resolvedWindow = await Promise.all(window.map(async (v) => ({
...v,
data: v.hash ? await store.getObject(v.hash) : null,
})));
if (def.shouldWake(resolvedWindow)) {
wakeTick();
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('Database has closed')) {
running = false;
break;
}
console.error(`[watcher:${def.name}] error during tick:`, err);
try {
await store.appendEvent({
occurredAt: Date.now(),
kind: 'vital',
key: `_error:${def.key}`,
hash: await store.putObject({
error: msg,
watcher: def.name,
}),
});
}
catch {
// Best-effort: if even error recording fails, just log
}
}
}
}
// Start loop without awaiting so the caller is not blocked.
void loop();
return {
name: def.name,
stop() {
running = false;
},
};
}
// ── Helpers ────────────────────────────────────────────────────
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+12
View File
@@ -0,0 +1,12 @@
import type { WatcherDef } from '../watcher.js';
export interface ErrorLogData {
matches: string[];
source: string;
}
export interface ErrorLogOptions {
/** 要监控的日志文件路径列表 */
logFiles: string[];
/** 触发唤醒的关键词 */
keywords?: string[];
}
export declare function errorLogWatcher(opts: ErrorLogOptions): WatcherDef<ErrorLogData>;
+87
View File
@@ -0,0 +1,87 @@
import * as fs from 'node:fs';
export function errorLogWatcher(opts) {
const keywords = opts.keywords ?? [
'panic',
'fatal',
'OOM',
'SIGKILL',
'unhandled rejection',
];
// 为每个文件保存最后读取的位置
const filePositions = new Map();
return {
name: 'error-log',
key: 'errorlog',
intervalMs: 5000,
collect: async () => {
const allMatches = [];
let sourceFile = '';
for (const logFile of opts.logFiles) {
try {
// 检查文件是否存在
if (!fs.existsSync(logFile)) {
continue;
}
const stats = fs.statSync(logFile);
const currentSize = stats.size;
let lastPosition = filePositions.get(logFile) ?? 0;
// 如果文件被轮转或截断,重置位置
if (currentSize < lastPosition) {
lastPosition = 0;
filePositions.set(logFile, 0);
}
// 只读取新增的部分
if (currentSize > lastPosition) {
const fd = fs.openSync(logFile, 'r');
try {
const bufferSize = currentSize - lastPosition;
const buffer = Buffer.alloc(bufferSize);
const bytesRead = fs.readSync(fd, buffer, 0, bufferSize, lastPosition);
const newContent = buffer.subarray(0, bytesRead).toString('utf8');
const lines = newContent
.split('\n')
.filter((line) => line.trim());
// 在新行中搜索关键词
for (const line of lines) {
const lowerLine = line.toLowerCase();
for (const keyword of keywords) {
if (lowerLine.includes(keyword.toLowerCase())) {
allMatches.push(line);
sourceFile = logFile;
break; // 每行只匹配一次
}
}
}
// 更新文件位置
filePositions.set(logFile, currentSize);
}
finally {
fs.closeSync(fd);
}
}
}
catch (error) {
console.error(`[error-log] Failed to read ${logFile}:`, error);
// 读取失败时不更新位置,下次重试
}
}
return {
matches: allMatches,
source: sourceFile || opts.logFiles[0] || '',
};
},
shouldWake: (window) => {
if (window.length === 0)
return false;
const latest = window[0];
if (!latest?.data)
return false;
// 如果有任何匹配的错误关键词,立即唤醒
if (latest.data.matches.length > 0) {
console.warn(`[error-log] Found ${latest.data.matches.length} error patterns:`, latest.data.matches.slice(0, 3)); // 只显示前3个匹配
return true;
}
return false;
},
};
}
+4
View File
@@ -0,0 +1,4 @@
export { type ErrorLogData, type ErrorLogOptions, errorLogWatcher, } from './error-log.js';
export { type NetworkData, type NetworkOptions, networkWatcher, } from './network.js';
export { type ProcessAliveData, type ProcessAliveOptions, processAliveWatcher, } from './process-alive.js';
export { type SystemResourceData, type SystemResourceOptions, systemResourceWatcher, } from './system-resource.js';
+4
View File
@@ -0,0 +1,4 @@
export { errorLogWatcher, } from './error-log.js';
export { networkWatcher, } from './network.js';
export { processAliveWatcher, } from './process-alive.js';
export { systemResourceWatcher, } from './system-resource.js';
+15
View File
@@ -0,0 +1,15 @@
import * as dns from 'node:dns';
import type { WatcherDef } from '../watcher.js';
export interface NetworkData {
dnsOk: boolean;
httpOk: boolean;
latencyMs: number;
}
export interface NetworkOptions {
dnsHost?: string;
httpUrl?: string;
timeoutMs?: number;
/** Inject DNS resolve function for testing */
dnsResolveFn?: typeof dns.promises.resolve;
}
export declare function networkWatcher(opts?: NetworkOptions): WatcherDef<NetworkData>;
+55
View File
@@ -0,0 +1,55 @@
import * as dns from 'node:dns';
export function networkWatcher(opts) {
const dnsHost = opts?.dnsHost ?? 'cloudflare.com';
const httpUrl = opts?.httpUrl ?? 'https://1.1.1.1/cdn-cgi/trace';
const timeoutMs = opts?.timeoutMs ?? 5000;
const dnsResolveFn = opts?.dnsResolveFn ?? dns.promises.resolve;
return {
name: 'network-connectivity',
key: 'network',
intervalMs: 5000,
collect: async () => {
const startTime = Date.now();
let dnsOk = false;
let httpOk = false;
// DNS 解析测试
try {
await Promise.race([
dnsResolveFn(dnsHost),
new Promise((_, reject) => setTimeout(() => reject(new Error('DNS timeout')), timeoutMs)),
]);
dnsOk = true;
}
catch {
dnsOk = false;
}
// HTTP 连接测试
try {
const controller = new AbortController();
const tid = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(httpUrl, {
method: 'GET',
signal: controller.signal,
redirect: 'manual',
});
clearTimeout(tid);
httpOk = response.status >= 200 && response.status < 400;
}
catch {
httpOk = false;
}
return { dnsOk, httpOk, latencyMs: Date.now() - startTime };
},
shouldWake: (window) => {
if (window.length < 2)
return false;
// 连续 2 次 DNS 和 HTTP 全部失败 → 唤醒
const recentFailures = window.slice(0, 2).filter((v) => {
if (!v?.data)
return true;
return !v.data.dnsOk && !v.data.httpOk;
});
return recentFailures.length >= 2;
},
};
}
+12
View File
@@ -0,0 +1,12 @@
import { execSync } from 'node:child_process';
import type { WatcherDef } from '../watcher.js';
export interface ProcessAliveData {
processes: Record<string, boolean>;
}
export interface ProcessAliveOptions {
/** 要监控的进程列表:name → 匹配命令行的关键词 */
processes: Record<string, string>;
/** Inject execSync for testing */
execSyncFn?: typeof execSync;
}
export declare function processAliveWatcher(opts: ProcessAliveOptions): WatcherDef<ProcessAliveData>;
@@ -0,0 +1,64 @@
import { execSync } from 'node:child_process';
export function processAliveWatcher(opts) {
const execSyncFn = opts.execSyncFn ?? execSync;
return {
name: 'process-alive',
key: 'processes',
intervalMs: 5000,
collect: async () => {
const processes = {};
try {
// 获取所有进程信息
const psOutput = execSyncFn('ps aux --no-headers', {
encoding: 'utf8',
});
// 检查每个配置的进程
for (const [name, keyword] of Object.entries(opts.processes)) {
// 在进程列表中搜索包含关键词的进程
const isRunning = psOutput
.split('\n')
.some((line) => line.toLowerCase().includes(keyword.toLowerCase()));
processes[name] = isRunning;
}
}
catch (error) {
// ps 命令失败时,标记所有进程为未知状态(false)
console.error('[process-alive] Failed to get process list:', error);
for (const name of Object.keys(opts.processes)) {
processes[name] = false;
}
}
return { processes };
},
shouldWake: (window) => {
if (window.length === 0)
return false;
const latest = window[0];
if (!latest?.data)
return false;
const currentProcesses = latest.data.processes;
// 检查是否有任何进程消失
for (const [name, isAlive] of Object.entries(currentProcesses)) {
if (!isAlive) {
console.warn(`[process-alive] Process '${name}' is not running`);
return true;
}
}
// 如果有历史数据,检查是否有进程从运行变为停止
if (window.length > 1) {
const previous = window[1];
if (previous?.data) {
const prevProcesses = previous.data.processes;
for (const [name, isAlive] of Object.entries(currentProcesses)) {
const wasAlive = prevProcesses[name];
if (wasAlive && !isAlive) {
console.warn(`[process-alive] Process '${name}' stopped running`);
return true;
}
}
}
}
return false;
},
};
}
+22
View File
@@ -0,0 +1,22 @@
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import type { WatcherDef } from '../watcher.js';
export interface SystemResourceData {
cpuPct: number;
memoryPct: number;
diskPct: number;
swapPct: number;
}
export interface SystemResourceOptions {
memoryThreshold?: number;
diskThreshold?: number;
sustainedSeconds?: number;
/** Inject execSync for testing */
execSyncFn?: typeof execSync;
/** Inject fs module for testing */
fsFn?: typeof fs;
/** Inject os module for testing */
osFn?: typeof os;
}
export declare function systemResourceWatcher(opts?: SystemResourceOptions): WatcherDef<SystemResourceData>;
@@ -0,0 +1,124 @@
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
/**
* Get accurate memory usage percentage.
*
* On macOS, os.freemem() only returns truly "free" pages, excluding
* inactive and purgeable memory that macOS can reclaim instantly.
* This causes memoryPct to report ~95%+ on healthy machines.
*
* On Linux, /proc/meminfo MemAvailable is preferred over MemFree
* for the same reason (buffers/cache are reclaimable).
*/
function getMemoryPct(totalMem, osFn, execSyncFn, fsFn) {
const platform = osFn.platform();
if (platform === 'darwin') {
try {
const output = execSyncFn('vm_stat', { encoding: 'utf8' });
const pageSize = parseInt(output.match(/page size of (\d+) bytes/)?.[1] ?? '0', 10) ||
16384;
const parse = (label) => {
const match = output.match(new RegExp(`${label}:\\s+(\\d+)`));
return parseInt(match?.[1] ?? '0', 10) * pageSize;
};
const free = parse('Pages free');
const inactive = parse('Pages inactive');
const purgeable = parse('Pages purgeable');
const available = free + inactive + purgeable;
return ((totalMem - available) / totalMem) * 100;
}
catch {
// fallback to os.freemem()
}
}
if (platform === 'linux') {
try {
const meminfo = fsFn.readFileSync('/proc/meminfo', 'utf8');
const memAvailable = parseInt(meminfo.match(/MemAvailable:\s+(\d+)/)?.[1] ?? '0', 10);
if (memAvailable > 0) {
return ((totalMem - memAvailable * 1024) / totalMem) * 100;
}
}
catch {
// fallback to os.freemem()
}
}
// Fallback for other platforms
return ((totalMem - osFn.freemem()) / totalMem) * 100;
}
export function systemResourceWatcher(opts) {
const memThreshold = opts?.memoryThreshold ?? 90;
const diskThreshold = opts?.diskThreshold ?? 95;
const sustained = opts?.sustainedSeconds ?? 30;
const execSyncFn = opts?.execSyncFn ?? execSync;
const fsFn = opts?.fsFn ?? fs;
const osFn = opts?.osFn ?? os;
return {
name: 'system-resource',
key: 'system',
intervalMs: 5000,
collect: async () => {
const totalMem = osFn.totalmem();
const memoryPct = getMemoryPct(totalMem, osFn, execSyncFn, fsFn);
// CPU:用 os.cpus() 算 idle 百分比(简化版,使用瞬时值)
const cpus = osFn.cpus();
const totalIdle = cpus.reduce((sum, cpu) => sum + cpu.times.idle, 0);
const totalTick = cpus.reduce((sum, cpu) => sum + Object.values(cpu.times).reduce((a, b) => a + b, 0), 0);
const cpuPct = totalTick > 0 ? (1 - totalIdle / totalTick) * 100 : 0;
// 磁盘:使用 df 命令解析根分区使用率
let diskPct = 0;
try {
const output = execSyncFn('df / --output=pcent | tail -1', {
encoding: 'utf8',
});
diskPct = parseFloat(output.trim().replace('%', '')) || 0;
}
catch {
diskPct = 0;
}
// Swap:从 /proc/meminfo 读取
let swapPct = 0;
try {
const meminfo = fsFn.readFileSync('/proc/meminfo', 'utf8');
const swapTotal = parseInt(meminfo.match(/SwapTotal:\s+(\d+)/)?.[1] ?? '0', 10);
const swapFree = parseInt(meminfo.match(/SwapFree:\s+(\d+)/)?.[1] ?? '0', 10);
swapPct =
swapTotal > 0 ? ((swapTotal - swapFree) / swapTotal) * 100 : 0;
}
catch {
swapPct = 0;
}
return {
cpuPct: Math.round(cpuPct * 10) / 10,
memoryPct: Math.round(memoryPct * 10) / 10,
diskPct,
swapPct: Math.round(swapPct * 10) / 10,
};
},
shouldWake: (window) => {
if (window.length === 0)
return false;
const latest = window[0];
if (!latest?.data)
return false;
const data = latest.data;
// 磁盘 > 95% 立即唤醒(单点就够危险)
if (data.diskPct >= diskThreshold) {
return true;
}
// 内存持续超阈值检查
if (data.memoryPct >= memThreshold) {
// 检查持续时间:最近 N 条记录都超阈值
const sustainedCount = Math.ceil((sustained * 1000) / 5000); // 5s interval
const recentOverThreshold = window
.slice(0, sustainedCount)
.filter((v) => v.data && v.data.memoryPct >= memThreshold);
if (recentOverThreshold.length >= sustainedCount) {
return true;
}
}
return false;
},
};
}
@@ -1,176 +0,0 @@
/**
* Cursor Health Rule Tests
*
* 小橘 <xiaoju@shazhou.work> 🍊 (NEKO Team)
*/
import { Database } from 'bun:sqlite';
import { describe, expect, test } from 'bun:test';
import { unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { checkCursorHealth } from './cursor-health.js';
describe('cursor-health', () => {
function createTestDb(records: number[], baseTime = Date.now()): string {
const tempDbPath = join(
tmpdir(),
`cursor-health-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
);
const db = new Database(tempDbPath);
// 创建表结构(模仿 Cursor 的 schema)
db.exec(`
CREATE TABLE IF NOT EXISTS ai_code_hashes (
timestamp INTEGER
)
`);
// 插入测试数据
const stmt = db.prepare(
'INSERT INTO ai_code_hashes (timestamp) VALUES (?)',
);
for (const offset of records) {
stmt.run(baseTime + offset);
}
db.close();
return tempDbPath;
}
async function cleanupDb(dbPath: string) {
try {
await unlink(dbPath);
} catch {
// Ignore cleanup errors
}
}
test('健康状态 - 调用次数低于阈值', async () => {
const now = Date.now();
// 插入 50 条记录,都在最近 15 分钟内
const records = Array.from({ length: 50 }, (_, i) => -i * 10000); // 每 10 秒一条
const tempDbPath = createTestDb(records, now);
const result = checkCursorHealth({
dbPath: tempDbPath,
windowMs: 15 * 60 * 1000,
threshold: 100,
});
expect(result.recentCount).toBe(50);
expect(result.threshold).toBe(100);
expect(result.isHealthy).toBe(true);
expect(result.windowMs).toBe(15 * 60 * 1000);
expect(result.checkedAt).toBeGreaterThan(now - 1000);
await cleanupDb(tempDbPath);
});
test('不健康状态 - 调用次数超过阈值', async () => {
const now = Date.now();
// 插入 150 条记录,都在最近 15 分钟内
const records = Array.from({ length: 150 }, (_, i) => -i * 5000); // 每 5 秒一条
const tempDbPath = createTestDb(records, now);
const result = checkCursorHealth({
dbPath: tempDbPath,
windowMs: 15 * 60 * 1000,
threshold: 100,
});
expect(result.recentCount).toBe(150);
expect(result.threshold).toBe(100);
expect(result.isHealthy).toBe(false);
expect(result.windowMs).toBe(15 * 60 * 1000);
await cleanupDb(tempDbPath);
});
test('时间窗口过滤 - 只统计时间窗口内的记录', async () => {
const now = Date.now();
const windowMs = 10 * 60 * 1000; // 10 分钟窗口
const records = [
// 窗口内(最近 10 分钟)- 应该被统计
-5 * 60 * 1000, // 5 分钟前
-8 * 60 * 1000, // 8 分钟前
-9 * 60 * 1000, // 9 分钟前
// 窗口外(超过 10 分钟)- 不应该被统计
-12 * 60 * 1000, // 12 分钟前
-20 * 60 * 1000, // 20 分钟前
];
const tempDbPath = createTestDb(records, now);
const result = checkCursorHealth({
dbPath: tempDbPath,
windowMs,
threshold: 50,
});
expect(result.recentCount).toBe(3); // 只有窗口内的 3 条
expect(result.isHealthy).toBe(true);
await cleanupDb(tempDbPath);
});
test('自定义参数', async () => {
const now = Date.now();
const records = Array.from({ length: 30 }, (_, i) => -i * 1000);
const tempDbPath = createTestDb(records, now);
const result = checkCursorHealth({
dbPath: tempDbPath,
windowMs: 5 * 60 * 1000, // 5 分钟窗口
threshold: 20, // 阈值 20
});
expect(result.windowMs).toBe(5 * 60 * 1000);
expect(result.threshold).toBe(20);
// 5 分钟内的记录数量
expect(result.recentCount).toBeGreaterThan(0);
await cleanupDb(tempDbPath);
});
test('数据库不存在时返回健康状态', () => {
const result = checkCursorHealth({
dbPath: '/nonexistent/path/to/db.sqlite',
});
expect(result.recentCount).toBe(0);
expect(result.isHealthy).toBe(true);
expect(result.threshold).toBe(100);
expect(result.windowMs).toBe(15 * 60 * 1000);
});
test('空数据库返回健康状态', async () => {
// 创建空数据库
const tempDbPath = createTestDb([], Date.now());
const result = checkCursorHealth({
dbPath: tempDbPath,
});
expect(result.recentCount).toBe(0);
expect(result.isHealthy).toBe(true);
await cleanupDb(tempDbPath);
});
test('默认参数正确', async () => {
const tempDbPath = createTestDb([], Date.now());
const result = checkCursorHealth({
dbPath: tempDbPath,
});
expect(result.threshold).toBe(100);
expect(result.windowMs).toBe(15 * 60 * 1000);
expect(result.recentCount).toBe(0);
expect(result.isHealthy).toBe(true);
await cleanupDb(tempDbPath);
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Workflows module — re-exports + createWorkflowTicker utility.
*
* 小橘 🍊 (NEKO Team)
*/
export type { MetaCoderMeta, MetaTesterMeta, } from './meta.js';
export { createMetaWorkflow } from './meta.js';
export { type AgentExecutorConfig, type AgentResult, type AgentRunner, createAgentExecutorRole, createCursorRunner, } from './roles/agent-executor.js';
export type { LlmRoleConfig, ToolRoleConfig, } from './roles/llm-role-factory.js';
export { createLlmRole, createToolRole } from './roles/llm-role-factory.js';
export { createMetaCoderRole } from './roles/meta-coder-cursor.js';
export { createMetaTesterRole } from './roles/meta-tester.js';
export { createMetaCheckerRole } from './roles/meta-checker.js';
export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js';
export { createWorkflowRule, type WorkflowRule, type WorkflowTickResult, } from './workflow-rule-adapter.js';
export { END, type MetaOf, type ModeratorInput, type Role, type RoleOutput, type RoleResult, START, type StartSignal, type WorkflowAction, type WorkflowMessage, type WorkflowType, } from './workflow-type.js';
import type { WorkflowRule } from './workflow-rule-adapter.js';
/**
* createWorkflowTicker — wraps multiple WorkflowRules into a single async function
* suitable for calling at the end of a runPulse tick cycle.
*/
export declare function createWorkflowTicker(rules: WorkflowRule[]): () => Promise<void>;

Some files were not shown because too many files have changed in this diff Show More