refactor: migrate business workflows to @upulse/workflows package
CI / test (push) Has been cancelled
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:
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -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
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
+4
-4
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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', () => {
|
||||
+4
-4
@@ -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, {
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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
-4
@@ -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;
|
||||
@@ -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 },
|
||||
};
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
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 },
|
||||
};
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -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 {
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
+4
-4
@@ -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;
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -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;
|
||||
+1
-1
@@ -15,7 +15,7 @@ import {
|
||||
type Role,
|
||||
START,
|
||||
type WorkflowType,
|
||||
} from './workflow-type.js';
|
||||
} from '@uncaged/pulse';
|
||||
|
||||
// ── Role Meta types ────────────────────────────────────────────
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
+1
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -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({
|
||||
+1
-1
@@ -15,7 +15,7 @@ import {
|
||||
type Role,
|
||||
START,
|
||||
type WorkflowType,
|
||||
} from './workflow-type.js';
|
||||
} from '@uncaged/pulse';
|
||||
|
||||
// ── Role Meta types ────────────────────────────────────────────
|
||||
|
||||
@@ -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 };
|
||||
+1
-1
@@ -20,7 +20,7 @@ import {
|
||||
START,
|
||||
type WorkflowMessage,
|
||||
type WorkflowType,
|
||||
} from './workflow-type.js';
|
||||
} from '@uncaged/pulse';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
Vendored
+82
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
export { executeSurvivalEffect, type SurvivalEffect, type SurvivalExecDeps, } from './survival.js';
|
||||
@@ -0,0 +1 @@
|
||||
export { executeSurvivalEffect, } from './survival.js';
|
||||
+21
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+71
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+236
@@ -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';
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
Vendored
+51
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
+10
@@ -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>>;
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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
@@ -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 {};
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
Vendored
+30
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
+4
@@ -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';
|
||||
@@ -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
@@ -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 {};
|
||||
@@ -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,
|
||||
];
|
||||
Vendored
+97
@@ -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;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
+121
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+73
@@ -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;
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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
@@ -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';
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user