refactor: extract guard-core pure logic + fix check-before-transition (refs #9)
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
This commit is contained in:
@@ -6,7 +6,6 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
composeRules,
|
|
||||||
createStore,
|
createStore,
|
||||||
type PulseStore,
|
type PulseStore,
|
||||||
type Rule,
|
type Rule,
|
||||||
|
|||||||
@@ -12,14 +12,14 @@
|
|||||||
import { existsSync, mkdirSync } from 'node:fs';
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
createAnalystRole,
|
||||||
|
createCodingWorkflow,
|
||||||
|
createRendererRole,
|
||||||
|
createReportWorkflow,
|
||||||
|
} from '@upulse/workflows';
|
||||||
import { createOpenAiLlmClient } from '../llm-client.js';
|
import { createOpenAiLlmClient } from '../llm-client.js';
|
||||||
import { createStore } from '../store.js';
|
import { createStore } from '../store.js';
|
||||||
import {
|
|
||||||
createCodingWorkflow,
|
|
||||||
createReportWorkflow,
|
|
||||||
createAnalystRole,
|
|
||||||
createRendererRole,
|
|
||||||
} from '@upulse/workflows';
|
|
||||||
import { createWorkflowTicker } from '../workflows/index.js';
|
import { createWorkflowTicker } from '../workflows/index.js';
|
||||||
import { createCursorRunner } from '../workflows/roles/agent-executor.js';
|
import { createCursorRunner } from '../workflows/roles/agent-executor.js';
|
||||||
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
||||||
@@ -79,19 +79,19 @@ const codingRule = createWorkflowRule(codingWf, store, logStore);
|
|||||||
// 2. Meta workflow — engine override > core fallback
|
// 2. Meta workflow — engine override > core fallback
|
||||||
const metaMod = await tryLoadFromEngine(
|
const metaMod = await tryLoadFromEngine(
|
||||||
'src/workflows/meta.ts',
|
'src/workflows/meta.ts',
|
||||||
() => import('../workflows/meta.js')
|
() => import('../workflows/meta.js'),
|
||||||
);
|
);
|
||||||
const metaCoderMod = await tryLoadFromEngine(
|
const metaCoderMod = await tryLoadFromEngine(
|
||||||
'src/workflows/roles/meta-coder-cursor.ts',
|
'src/workflows/roles/meta-coder-cursor.ts',
|
||||||
() => import('../workflows/roles/meta-coder-cursor.js')
|
() => import('../workflows/roles/meta-coder-cursor.js'),
|
||||||
);
|
);
|
||||||
const metaCheckerMod = await tryLoadFromEngine(
|
const metaCheckerMod = await tryLoadFromEngine(
|
||||||
'src/workflows/roles/meta-checker.ts',
|
'src/workflows/roles/meta-checker.ts',
|
||||||
() => import('../workflows/roles/meta-checker.js')
|
() => import('../workflows/roles/meta-checker.js'),
|
||||||
);
|
);
|
||||||
const metaTesterMod = await tryLoadFromEngine(
|
const metaTesterMod = await tryLoadFromEngine(
|
||||||
'src/workflows/roles/meta-tester.ts',
|
'src/workflows/roles/meta-tester.ts',
|
||||||
() => import('../workflows/roles/meta-tester.js')
|
() => import('../workflows/roles/meta-tester.js'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load gate role if available from engine
|
// Load gate role if available from engine
|
||||||
@@ -129,7 +129,9 @@ console.log(` Store: ${DATA_DIR}/workflows.db`);
|
|||||||
console.log(
|
console.log(
|
||||||
` Tick: adaptive ${BASE_TICK_MS / 1000}s → ${MAX_TICK_MS / 1000}s (×${BACKOFF_FACTOR})`,
|
` Tick: adaptive ${BASE_TICK_MS / 1000}s → ${MAX_TICK_MS / 1000}s (×${BACKOFF_FACTOR})`,
|
||||||
);
|
);
|
||||||
console.log(` Engine override: ${existsSync(join(ENGINE_DIR, 'src/workflows/meta.ts')) ? 'YES' : 'no'}`);
|
console.log(
|
||||||
|
` Engine override: ${existsSync(join(ENGINE_DIR, 'src/workflows/meta.ts')) ? 'YES' : 'no'}`,
|
||||||
|
);
|
||||||
console.log(` Workflows: coding, meta, report`);
|
console.log(` Workflows: coding, meta, report`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
@@ -180,7 +182,10 @@ scheduleNext();
|
|||||||
/**
|
/**
|
||||||
* Try loading a workflow module from ENGINE_DIR, fallback to core package.
|
* Try loading a workflow module from ENGINE_DIR, fallback to core package.
|
||||||
*/
|
*/
|
||||||
async function tryLoadFromEngine<T>(engineRelPath: string, fallbackImport: () => Promise<T>): Promise<T> {
|
async function tryLoadFromEngine<T>(
|
||||||
|
engineRelPath: string,
|
||||||
|
fallbackImport: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
const enginePath = join(ENGINE_DIR, engineRelPath);
|
const enginePath = join(ENGINE_DIR, engineRelPath);
|
||||||
if (existsSync(enginePath)) {
|
if (existsSync(enginePath)) {
|
||||||
console.log(` 📦 Loading from engine: ${engineRelPath}`);
|
console.log(` 📦 Loading from engine: ${engineRelPath}`);
|
||||||
|
|||||||
Vendored
+71
-44
@@ -6,77 +6,104 @@
|
|||||||
*/
|
*/
|
||||||
import type { Database } from 'bun:sqlite';
|
import type { Database } from 'bun:sqlite';
|
||||||
export interface ObjectDef {
|
export interface ObjectDef {
|
||||||
name: string;
|
name: string;
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
export interface EventDef {
|
export interface EventDef {
|
||||||
hash: string;
|
hash: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentHash?: string;
|
parentHash?: string;
|
||||||
schema?: any;
|
schema?: any;
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
export interface ProjectionDef {
|
export interface ProjectionDef {
|
||||||
hash: string;
|
hash: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentHash?: string;
|
parentHash?: string;
|
||||||
params?: any;
|
params?: any;
|
||||||
valueSchema?: any;
|
valueSchema?: any;
|
||||||
initialValue: any;
|
initialValue: any;
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
sources: Array<{
|
sources: Array<{
|
||||||
eventKind: string;
|
eventKind: string;
|
||||||
eventKey?: string;
|
eventKey?: string;
|
||||||
expression: string;
|
expression: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
result?: any;
|
result?: any;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Initialize the definition schema on an existing database connection.
|
* Initialize the definition schema on an existing database connection.
|
||||||
* Use this when you manage the database lifecycle externally.
|
* Use this when you manage the database lifecycle externally.
|
||||||
*/
|
*/
|
||||||
export declare function initDefsSchema(db: Database): Promise<void>;
|
export declare function initDefsSchema(db: Database): Promise<void>;
|
||||||
export declare function registerObjectDef(db: Database, opts: {
|
export declare function registerObjectDef(
|
||||||
|
db: Database,
|
||||||
|
opts: {
|
||||||
name: string;
|
name: string;
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
}): Promise<ObjectDef>;
|
},
|
||||||
export declare function getObjectDef(db: Database, name: string, codeRev: string): Promise<ObjectDef | null>;
|
): Promise<ObjectDef>;
|
||||||
export declare function registerEventDef(db: Database, opts: {
|
export declare function getObjectDef(
|
||||||
|
db: Database,
|
||||||
|
name: string,
|
||||||
|
codeRev: string,
|
||||||
|
): Promise<ObjectDef | null>;
|
||||||
|
export declare function registerEventDef(
|
||||||
|
db: Database,
|
||||||
|
opts: {
|
||||||
name: string;
|
name: string;
|
||||||
schema?: any;
|
schema?: any;
|
||||||
parentHash?: string;
|
parentHash?: string;
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
}): Promise<EventDef>;
|
},
|
||||||
export declare function getEventDef(db: Database, name: string, codeRev: string): Promise<EventDef | null>;
|
): Promise<EventDef>;
|
||||||
export declare function listEventDefs(db: Database, opts: {
|
export declare function getEventDef(
|
||||||
|
db: Database,
|
||||||
|
name: string,
|
||||||
|
codeRev: string,
|
||||||
|
): Promise<EventDef | null>;
|
||||||
|
export declare function listEventDefs(
|
||||||
|
db: Database,
|
||||||
|
opts: {
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
}): Promise<EventDef[]>;
|
},
|
||||||
export declare function registerProjectionDef(db: Database, opts: {
|
): Promise<EventDef[]>;
|
||||||
|
export declare function registerProjectionDef(
|
||||||
|
db: Database,
|
||||||
|
opts: {
|
||||||
name: string;
|
name: string;
|
||||||
params?: any;
|
params?: any;
|
||||||
valueSchema?: any;
|
valueSchema?: any;
|
||||||
initialValue: any;
|
initialValue: any;
|
||||||
sources: Array<{
|
sources: Array<{
|
||||||
eventKind: string;
|
eventKind: string;
|
||||||
eventKey?: string;
|
eventKey?: string;
|
||||||
expression: string;
|
expression: string;
|
||||||
}>;
|
}>;
|
||||||
parentHash?: string;
|
parentHash?: string;
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
}): Promise<ProjectionDef>;
|
},
|
||||||
export declare function getProjectionDef(db: Database, name: string, codeRev: string): Promise<ProjectionDef | null>;
|
): Promise<ProjectionDef>;
|
||||||
export declare function listProjectionDefs(db: Database, opts: {
|
export declare function getProjectionDef(
|
||||||
|
db: Database,
|
||||||
|
name: string,
|
||||||
|
codeRev: string,
|
||||||
|
): Promise<ProjectionDef | null>;
|
||||||
|
export declare function listProjectionDefs(
|
||||||
|
db: Database,
|
||||||
|
opts: {
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
}): Promise<ProjectionDef[]>;
|
},
|
||||||
|
): Promise<ProjectionDef[]>;
|
||||||
export declare function validateExpression(opts: {
|
export declare function validateExpression(opts: {
|
||||||
expression: string;
|
expression: string;
|
||||||
initialValue: any;
|
initialValue: any;
|
||||||
mockEvent: any;
|
mockEvent: any;
|
||||||
}): Promise<ValidationResult>;
|
}): Promise<ValidationResult>;
|
||||||
|
|||||||
@@ -1,322 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,10 +10,9 @@
|
|||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { createArchitectRole, createCodingWorkflow } from '@upulse/workflows';
|
||||||
import { createOpenAiLlmClient } from '../llm-client.js';
|
import { createOpenAiLlmClient } from '../llm-client.js';
|
||||||
import { createStore } from '../store.js';
|
import { createStore } from '../store.js';
|
||||||
import { createCodingWorkflow } from '@upulse/workflows';
|
|
||||||
import { createArchitectRole } from '@upulse/workflows';
|
|
||||||
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
||||||
|
|
||||||
const SEP = '─'.repeat(50);
|
const SEP = '─'.repeat(50);
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
import { mkdtempSync } from 'node:fs';
|
import { mkdtempSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
|
import {
|
||||||
|
createCoderRole,
|
||||||
|
createCodingWorkflow,
|
||||||
|
createReviewerRole,
|
||||||
|
} from '@upulse/workflows';
|
||||||
import { createStore } from '../index.js';
|
import { createStore } from '../index.js';
|
||||||
import { createOpenAiLlmClient } from '../llm-client.js';
|
import { createOpenAiLlmClient } from '../llm-client.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 { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
||||||
import type { WorkflowMessage } from '../workflows/workflow-type.js';
|
import type { WorkflowMessage } from '../workflows/workflow-type.js';
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,13 @@
|
|||||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
createAnalystRole,
|
||||||
|
createRendererRole,
|
||||||
|
createReportWorkflow,
|
||||||
|
} from '@upulse/workflows';
|
||||||
import { createStore } from '../index.js';
|
import { createStore } from '../index.js';
|
||||||
import { createOpenAiLlmClient } from '../llm-client.js';
|
import { createOpenAiLlmClient } from '../llm-client.js';
|
||||||
import { createReportWorkflow } from '@upulse/workflows';
|
|
||||||
import { createAnalystRole } from '@upulse/workflows';
|
|
||||||
import { createRendererRole } from '@upulse/workflows';
|
|
||||||
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
||||||
|
|
||||||
// ── Args ───────────────────────────────────────────────────────
|
// ── Args ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { afterEach, describe, expect, it } from 'bun:test';
|
|||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { createStore, type PulseStore } from '../store.js';
|
|
||||||
import { createCodingWorkflow } from '@upulse/workflows';
|
import { createCodingWorkflow } from '@upulse/workflows';
|
||||||
|
import { createStore, type PulseStore } from '../store.js';
|
||||||
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
||||||
|
|
||||||
describe('Council v2 E2E', () => {
|
describe('Council v2 E2E', () => {
|
||||||
|
|||||||
@@ -2,27 +2,25 @@
|
|||||||
* 狼人杀 LLM 对战 — 接真 LLM 跑一局
|
* 狼人杀 LLM 对战 — 接真 LLM 跑一局
|
||||||
*
|
*
|
||||||
* bun run packages/pulse/src/e2e/werewolf-live.ts
|
* bun run packages/pulse/src/e2e/werewolf-live.ts
|
||||||
*
|
*
|
||||||
* 小橘 🍊 (NEKO Team)
|
* 小橘 🍊 (NEKO Team)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createScopedStore, createWorkflowTicker } from '../index.js';
|
|
||||||
import {
|
import {
|
||||||
createWerewolfWorkflow,
|
|
||||||
createPlayers,
|
createPlayers,
|
||||||
parseGameState,
|
createWerewolfWorkflow,
|
||||||
filterChainForPlayer,
|
|
||||||
type WolfNightMeta,
|
|
||||||
type SeerCheckMeta,
|
|
||||||
type WitchActionMeta,
|
|
||||||
type DaySpeechMeta,
|
type DaySpeechMeta,
|
||||||
type VoteMeta,
|
filterChainForPlayer,
|
||||||
type HunterShotMeta,
|
|
||||||
type GameEndMeta,
|
type GameEndMeta,
|
||||||
|
type HunterShotMeta,
|
||||||
type Player,
|
type Player,
|
||||||
type Identity,
|
parseGameState,
|
||||||
type GameState,
|
type SeerCheckMeta,
|
||||||
|
type VoteMeta,
|
||||||
|
type WitchActionMeta,
|
||||||
|
type WolfNightMeta,
|
||||||
} from '@upulse/workflows';
|
} from '@upulse/workflows';
|
||||||
|
import { createWorkflowTicker } from '../index.js';
|
||||||
import type { Role, WorkflowMessage } from '../workflows/workflow-type.js';
|
import type { Role, WorkflowMessage } from '../workflows/workflow-type.js';
|
||||||
|
|
||||||
// ── LLM Client ─────────────────────────────────────────────────
|
// ── LLM Client ─────────────────────────────────────────────────
|
||||||
@@ -35,21 +33,27 @@ interface LlmMessage {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callLlm(messages: LlmMessage[], temperature = 0.8): Promise<string> {
|
async function callLlm(
|
||||||
const res = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', {
|
messages: LlmMessage[],
|
||||||
method: 'POST',
|
temperature = 0.8,
|
||||||
headers: {
|
): Promise<string> {
|
||||||
'Authorization': `Bearer ${DASHSCOPE_API_KEY}`,
|
const res = await fetch(
|
||||||
'Content-Type': 'application/json',
|
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'qwen-plus',
|
||||||
|
messages,
|
||||||
|
temperature,
|
||||||
|
max_tokens: 800,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
);
|
||||||
model: 'qwen-plus',
|
const data = (await res.json()) as any;
|
||||||
messages,
|
|
||||||
temperature,
|
|
||||||
max_tokens: 800,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json() as any;
|
|
||||||
return data.choices?.[0]?.message?.content ?? '';
|
return data.choices?.[0]?.message?.content ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,9 +72,10 @@ const PERSONALITIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function buildSystemPrompt(player: Player, personality: string): string {
|
function buildSystemPrompt(player: Player, personality: string): string {
|
||||||
const identityInfo = player.identity.team === 'wolf'
|
const identityInfo =
|
||||||
? `你的身份是【狼人】。你的同伴是其他狼人(玩家1、玩家2、玩家3)。\n胜利条件:淘汰所有好人。\n夜晚你和狼人同伴商量杀谁。白天你需要伪装成好人,不被投票出局。`
|
player.identity.team === 'wolf'
|
||||||
: `你的身份是【${player.identity.name}】。\n胜利条件:找出并淘汰所有狼人。\n${player.identity.abilities ? `特殊能力:${player.identity.abilities}` : '你没有特殊能力,靠发言和投票帮助好人阵营。'}`;
|
? `你的身份是【狼人】。你的同伴是其他狼人(玩家1、玩家2、玩家3)。\n胜利条件:淘汰所有好人。\n夜晚你和狼人同伴商量杀谁。白天你需要伪装成好人,不被投票出局。`
|
||||||
|
: `你的身份是【${player.identity.name}】。\n胜利条件:找出并淘汰所有狼人。\n${player.identity.abilities ? `特殊能力:${player.identity.abilities}` : '你没有特殊能力,靠发言和投票帮助好人阵营。'}`;
|
||||||
|
|
||||||
return `你正在玩一局9人狼人杀游戏。你是 ${player.name}。
|
return `你正在玩一局9人狼人杀游戏。你是 ${player.name}。
|
||||||
性格特征:${personality}
|
性格特征:${personality}
|
||||||
@@ -87,37 +92,49 @@ ${identityInfo}
|
|||||||
|
|
||||||
// ── LLM Roles ──────────────────────────────────────────────────
|
// ── LLM Roles ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const players = createPlayers();
|
const _players = createPlayers();
|
||||||
|
|
||||||
function getVisibleHistory(chain: WorkflowMessage[], player: Player): string {
|
function getVisibleHistory(chain: WorkflowMessage[], player: Player): string {
|
||||||
const visible = filterChainForPlayer(chain, player.id, player.identity);
|
const visible = filterChainForPlayer(chain, player.id, player.identity);
|
||||||
if (visible.length === 0) return '(暂无历史信息)';
|
if (visible.length === 0) return '(暂无历史信息)';
|
||||||
return visible.map(m => m.content).join('\n');
|
return visible.map((m) => m.content).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const llmWolfNight: Role<WolfNightMeta> = async (chain) => {
|
const llmWolfNight: Role<WolfNightMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const wolves = state.alive.filter(p => p.identity.team === 'wolf');
|
const wolves = state.alive.filter((p) => p.identity.team === 'wolf');
|
||||||
const goodAlive = state.alive.filter(p => p.identity.team === 'good');
|
const goodAlive = state.alive.filter((p) => p.identity.team === 'good');
|
||||||
|
|
||||||
if (goodAlive.length === 0) {
|
if (goodAlive.length === 0) {
|
||||||
return { content: '[狼人夜晚] 无好人可杀', meta: { phase: 'wolf-night', targetId: '' } };
|
return {
|
||||||
|
content: '[狼人夜晚] 无好人可杀',
|
||||||
|
meta: { phase: 'wolf-night', targetId: '' },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 让第一个存活狼人代表决策
|
// 让第一个存活狼人代表决策
|
||||||
const leadWolf = wolves[0];
|
const leadWolf = wolves[0];
|
||||||
const history = getVisibleHistory(chain, leadWolf);
|
const history = getVisibleHistory(chain, leadWolf);
|
||||||
|
|
||||||
const targetList = goodAlive.map(p => `${p.id}(${p.name})`).join('、');
|
const targetList = goodAlive.map((p) => `${p.id}(${p.name})`).join('、');
|
||||||
const response = await callLlm([
|
const response = await callLlm(
|
||||||
{ role: 'system', content: buildSystemPrompt(leadWolf, PERSONALITIES[0]) },
|
[
|
||||||
{ role: 'user', content: `${history}\n\n现在是夜晚,你们狼人要商量杀谁。\n存活的非狼人玩家:${targetList}\n\n请直接回复你要杀的玩家ID(如 p4),一个字都不要多说。` },
|
{
|
||||||
], 0.3);
|
role: 'system',
|
||||||
|
content: buildSystemPrompt(leadWolf, PERSONALITIES[0]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `${history}\n\n现在是夜晚,你们狼人要商量杀谁。\n存活的非狼人玩家:${targetList}\n\n请直接回复你要杀的玩家ID(如 p4),一个字都不要多说。`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0.3,
|
||||||
|
);
|
||||||
|
|
||||||
// 解析目标
|
// 解析目标
|
||||||
const match = response.match(/p\d+/);
|
const match = response.match(/p\d+/);
|
||||||
const targetId = match ? match[0] : goodAlive[0].id;
|
const targetId = match ? match[0] : goodAlive[0].id;
|
||||||
const target = goodAlive.find(p => p.id === targetId) ?? goodAlive[0];
|
const target = goodAlive.find((p) => p.id === targetId) ?? goodAlive[0];
|
||||||
|
|
||||||
console.log(` 🐺 狼人决定击杀 ${target.name}`);
|
console.log(` 🐺 狼人决定击杀 ${target.name}`);
|
||||||
|
|
||||||
@@ -129,59 +146,92 @@ const llmWolfNight: Role<WolfNightMeta> = async (chain) => {
|
|||||||
|
|
||||||
const llmSeerCheck: Role<SeerCheckMeta> = async (chain) => {
|
const llmSeerCheck: Role<SeerCheckMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const seer = state.alive.find(p => p.identity.name === '预言家');
|
const seer = state.alive.find((p) => p.identity.name === '预言家');
|
||||||
if (!seer) {
|
if (!seer) {
|
||||||
return { content: '[预言家已死,跳过]', meta: { phase: 'seer-check', targetId: '', isWolf: false, visibleTo: [] } };
|
return {
|
||||||
|
content: '[预言家已死,跳过]',
|
||||||
|
meta: { phase: 'seer-check', targetId: '', isWolf: false, visibleTo: [] },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const others = state.alive.filter(p => p.id !== seer.id);
|
const others = state.alive.filter((p) => p.id !== seer.id);
|
||||||
const history = getVisibleHistory(chain, seer);
|
const history = getVisibleHistory(chain, seer);
|
||||||
const targetList = others.map(p => `${p.id}(${p.name})`).join('、');
|
const targetList = others.map((p) => `${p.id}(${p.name})`).join('、');
|
||||||
|
|
||||||
const response = await callLlm([
|
const response = await callLlm(
|
||||||
{ role: 'system', content: buildSystemPrompt(seer, PERSONALITIES[3]) },
|
[
|
||||||
{ role: 'user', content: `${history}\n\n现在轮到你查验身份。可查验的玩家:${targetList}\n\n请直接回复你要查验的玩家ID(如 p1),一个字都不要多说。` },
|
{ role: 'system', content: buildSystemPrompt(seer, PERSONALITIES[3]) },
|
||||||
], 0.3);
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `${history}\n\n现在轮到你查验身份。可查验的玩家:${targetList}\n\n请直接回复你要查验的玩家ID(如 p1),一个字都不要多说。`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0.3,
|
||||||
|
);
|
||||||
|
|
||||||
const match = response.match(/p\d+/);
|
const match = response.match(/p\d+/);
|
||||||
const targetId = match ? match[0] : others[0].id;
|
const targetId = match ? match[0] : others[0].id;
|
||||||
const target = others.find(p => p.id === targetId) ?? others[0];
|
const target = others.find((p) => p.id === targetId) ?? others[0];
|
||||||
const isWolf = target.identity.team === 'wolf';
|
const isWolf = target.identity.team === 'wolf';
|
||||||
|
|
||||||
console.log(` 🔮 预言家查验 ${target.name} → ${isWolf ? '🐺狼人' : '✅好人'}`);
|
console.log(
|
||||||
|
` 🔮 预言家查验 ${target.name} → ${isWolf ? '🐺狼人' : '✅好人'}`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: `[预言家查验] ${target.name} 的身份是${isWolf ? '狼人' : '好人'}`,
|
content: `[预言家查验] ${target.name} 的身份是${isWolf ? '狼人' : '好人'}`,
|
||||||
meta: { phase: 'seer-check', targetId: target.id, isWolf, visibleTo: [seer.id] },
|
meta: {
|
||||||
|
phase: 'seer-check',
|
||||||
|
targetId: target.id,
|
||||||
|
isWolf,
|
||||||
|
visibleTo: [seer.id],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const llmWitchAction: Role<WitchActionMeta & { gameOver?: boolean }> = async (chain) => {
|
const llmWitchAction: Role<WitchActionMeta & { gameOver?: boolean }> = async (
|
||||||
|
chain,
|
||||||
|
) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const witch = state.alive.find(p => p.identity.name === '女巫');
|
const witch = state.alive.find((p) => p.identity.name === '女巫');
|
||||||
|
|
||||||
if (!witch) {
|
if (!witch) {
|
||||||
const nightDead: string[] = [];
|
const nightDead: string[] = [];
|
||||||
if (state.lastKill) nightDead.push(state.lastKill);
|
if (state.lastKill) nightDead.push(state.lastKill);
|
||||||
const deadIds = new Set([...state.dead.map(d => d.id), ...nightDead]);
|
const deadIds = new Set([...state.dead.map((d) => d.id), ...nightDead]);
|
||||||
const aliveAfter = state.players.filter(p => !deadIds.has(p.id));
|
const aliveAfter = state.players.filter((p) => !deadIds.has(p.id));
|
||||||
const wolves = aliveAfter.filter(p => p.identity.team === 'wolf');
|
const wolves = aliveAfter.filter((p) => p.identity.team === 'wolf');
|
||||||
const gameOver = wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length;
|
const gameOver =
|
||||||
|
wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length;
|
||||||
return {
|
return {
|
||||||
content: '[女巫已死,跳过]',
|
content: '[女巫已死,跳过]',
|
||||||
meta: { phase: 'witch-action', saved: false, poisonTarget: null, visibleTo: [], witchPotion: state.witchPotion, witchPoison: state.witchPoison, gameOver } as any,
|
meta: {
|
||||||
|
phase: 'witch-action',
|
||||||
|
saved: false,
|
||||||
|
poisonTarget: null,
|
||||||
|
visibleTo: [],
|
||||||
|
witchPotion: state.witchPotion,
|
||||||
|
witchPoison: state.witchPoison,
|
||||||
|
gameOver,
|
||||||
|
} as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let saved = false;
|
let saved = false;
|
||||||
let poisonTarget: string | null = null;
|
const poisonTarget: string | null = null;
|
||||||
|
|
||||||
if (state.lastKill && state.witchPotion) {
|
if (state.lastKill && state.witchPotion) {
|
||||||
const killed = state.players.find(p => p.id === state.lastKill);
|
const killed = state.players.find((p) => p.id === state.lastKill);
|
||||||
const response = await callLlm([
|
const response = await callLlm(
|
||||||
{ role: 'system', content: buildSystemPrompt(witch, PERSONALITIES[7]) },
|
[
|
||||||
{ role: 'user', content: `今晚 ${killed?.name ?? '某人'} 被狼人杀害。你有解药,要救吗?回复"救"或"不救"。` },
|
{ role: 'system', content: buildSystemPrompt(witch, PERSONALITIES[7]) },
|
||||||
], 0.5);
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `今晚 ${killed?.name ?? '某人'} 被狼人杀害。你有解药,要救吗?回复"救"或"不救"。`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0.5,
|
||||||
|
);
|
||||||
saved = response.includes('救') && !response.includes('不救');
|
saved = response.includes('救') && !response.includes('不救');
|
||||||
if (saved) console.log(` 🧪 女巫救了 ${killed?.name}`);
|
if (saved) console.log(` 🧪 女巫救了 ${killed?.name}`);
|
||||||
}
|
}
|
||||||
@@ -189,17 +239,21 @@ const llmWitchAction: Role<WitchActionMeta & { gameOver?: boolean }> = async (ch
|
|||||||
// 简化:mock 不用毒药
|
// 简化:mock 不用毒药
|
||||||
const nightDead: string[] = [];
|
const nightDead: string[] = [];
|
||||||
if (state.lastKill && !saved) nightDead.push(state.lastKill);
|
if (state.lastKill && !saved) nightDead.push(state.lastKill);
|
||||||
const deadIds = new Set([...state.dead.map(d => d.id), ...nightDead]);
|
const deadIds = new Set([...state.dead.map((d) => d.id), ...nightDead]);
|
||||||
const aliveAfter = state.players.filter(p => !deadIds.has(p.id));
|
const aliveAfter = state.players.filter((p) => !deadIds.has(p.id));
|
||||||
const wolves = aliveAfter.filter(p => p.identity.team === 'wolf');
|
const wolves = aliveAfter.filter((p) => p.identity.team === 'wolf');
|
||||||
const gameOver = wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length;
|
const gameOver =
|
||||||
|
wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length;
|
||||||
|
|
||||||
console.log(` 🧙 女巫${saved ? '使用了解药' : '未使用解药'}`);
|
console.log(` 🧙 女巫${saved ? '使用了解药' : '未使用解药'}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}`,
|
content: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}`,
|
||||||
meta: {
|
meta: {
|
||||||
phase: 'witch-action', saved, poisonTarget, visibleTo: [witch.id],
|
phase: 'witch-action',
|
||||||
|
saved,
|
||||||
|
poisonTarget,
|
||||||
|
visibleTo: [witch.id],
|
||||||
witchPotion: saved ? false : state.witchPotion,
|
witchPotion: saved ? false : state.witchPotion,
|
||||||
witchPoison: poisonTarget ? false : state.witchPoison,
|
witchPoison: poisonTarget ? false : state.witchPoison,
|
||||||
gameOver,
|
gameOver,
|
||||||
@@ -216,14 +270,22 @@ const llmDaySpeech: Role<DaySpeechMeta> = async (chain) => {
|
|||||||
for (let i = 0; i < state.alive.length; i++) {
|
for (let i = 0; i < state.alive.length; i++) {
|
||||||
const p = state.alive[i];
|
const p = state.alive[i];
|
||||||
const history = getVisibleHistory(chain, p);
|
const history = getVisibleHistory(chain, p);
|
||||||
const prevSpeeches = speeches.map(s => {
|
const prevSpeeches = speeches
|
||||||
const sp = state.alive.find(pp => pp.id === s.playerId);
|
.map((s) => {
|
||||||
return `${sp?.name ?? s.playerId}:${s.speech}`;
|
const sp = state.alive.find((pp) => pp.id === s.playerId);
|
||||||
}).join('\n');
|
return `${sp?.name ?? s.playerId}:${s.speech}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
const response = await callLlm([
|
const response = await callLlm([
|
||||||
{ role: 'system', content: buildSystemPrompt(p, PERSONALITIES[i % PERSONALITIES.length]) },
|
{
|
||||||
{ role: 'user', content: `${history}\n\n${prevSpeeches ? `前面玩家的发言:\n${prevSpeeches}\n\n` : ''}现在轮到你发言(50-150字),分析局势表达观点:` },
|
role: 'system',
|
||||||
|
content: buildSystemPrompt(p, PERSONALITIES[i % PERSONALITIES.length]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `${history}\n\n${prevSpeeches ? `前面玩家的发言:\n${prevSpeeches}\n\n` : ''}现在轮到你发言(50-150字),分析局势表达观点:`,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
speeches.push({ playerId: p.id, speech: response.trim() });
|
speeches.push({ playerId: p.id, speech: response.trim() });
|
||||||
@@ -231,35 +293,51 @@ const llmDaySpeech: Role<DaySpeechMeta> = async (chain) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: speeches.map(s => `【${s.playerId}】${s.speech}`).join('\n\n'),
|
content: speeches.map((s) => `【${s.playerId}】${s.speech}`).join('\n\n'),
|
||||||
meta: { phase: 'day-speech', speeches },
|
meta: { phase: 'day-speech', speeches },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const llmVote: Role<VoteMeta & { hunterTriggered?: boolean; gameOver?: boolean }> = async (chain) => {
|
const llmVote: Role<
|
||||||
|
VoteMeta & { hunterTriggered?: boolean; gameOver?: boolean }
|
||||||
|
> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
|
|
||||||
// Resolve night deaths for effective alive
|
// Resolve night deaths for effective alive
|
||||||
const nightDead: string[] = [];
|
const nightDead: string[] = [];
|
||||||
if (state.lastKill) {
|
if (state.lastKill) {
|
||||||
const witchMsg = [...chain].reverse().find(m => (m.meta as any)?.phase === 'witch-action');
|
const witchMsg = [...chain]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => (m.meta as any)?.phase === 'witch-action');
|
||||||
if (!(witchMsg?.meta as any)?.saved) nightDead.push(state.lastKill);
|
if (!(witchMsg?.meta as any)?.saved) nightDead.push(state.lastKill);
|
||||||
}
|
}
|
||||||
const deadSet = new Set([...state.dead.map(d => d.id), ...nightDead]);
|
const deadSet = new Set([...state.dead.map((d) => d.id), ...nightDead]);
|
||||||
const effectiveAlive = state.players.filter(p => !deadSet.has(p.id));
|
const effectiveAlive = state.players.filter((p) => !deadSet.has(p.id));
|
||||||
|
|
||||||
console.log(` 🗳️ === 投票阶段 ===`);
|
console.log(` 🗳️ === 投票阶段 ===`);
|
||||||
|
|
||||||
const votes: Record<string, string> = {};
|
const votes: Record<string, string> = {};
|
||||||
for (const p of effectiveAlive) {
|
for (const p of effectiveAlive) {
|
||||||
const others = effectiveAlive.filter(o => o.id !== p.id);
|
const others = effectiveAlive.filter((o) => o.id !== p.id);
|
||||||
const history = getVisibleHistory(chain, p);
|
const history = getVisibleHistory(chain, p);
|
||||||
const targetList = others.map(o => `${o.id}(${o.name})`).join('、');
|
const targetList = others.map((o) => `${o.id}(${o.name})`).join('、');
|
||||||
|
|
||||||
const response = await callLlm([
|
const response = await callLlm(
|
||||||
{ role: 'system', content: buildSystemPrompt(p, PERSONALITIES[effectiveAlive.indexOf(p) % PERSONALITIES.length]) },
|
[
|
||||||
{ role: 'user', content: `${history}\n\n现在投票环节。请投出你认为最可疑的玩家。可投:${targetList}\n\n直接回复玩家ID(如 p5),一个字都不要多说。` },
|
{
|
||||||
], 0.3);
|
role: 'system',
|
||||||
|
content: buildSystemPrompt(
|
||||||
|
p,
|
||||||
|
PERSONALITIES[effectiveAlive.indexOf(p) % PERSONALITIES.length],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `${history}\n\n现在投票环节。请投出你认为最可疑的玩家。可投:${targetList}\n\n直接回复玩家ID(如 p5),一个字都不要多说。`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0.3,
|
||||||
|
);
|
||||||
|
|
||||||
const match = response.match(/p\d+/);
|
const match = response.match(/p\d+/);
|
||||||
votes[p.id] = match ? match[0] : others[0].id;
|
votes[p.id] = match ? match[0] : others[0].id;
|
||||||
@@ -271,48 +349,71 @@ const llmVote: Role<VoteMeta & { hunterTriggered?: boolean; gameOver?: boolean }
|
|||||||
tally[target] = (tally[target] || 0) + 1;
|
tally[target] = (tally[target] || 0) + 1;
|
||||||
}
|
}
|
||||||
const maxVotes = Math.max(...Object.values(tally));
|
const maxVotes = Math.max(...Object.values(tally));
|
||||||
const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k);
|
const topIds = Object.entries(tally)
|
||||||
|
.filter(([, v]) => v === maxVotes)
|
||||||
|
.map(([k]) => k);
|
||||||
const eliminatedId = topIds[0]; // 平票取第一个
|
const eliminatedId = topIds[0]; // 平票取第一个
|
||||||
|
|
||||||
const eliminated = effectiveAlive.find(p => p.id === eliminatedId);
|
const eliminated = effectiveAlive.find((p) => p.id === eliminatedId);
|
||||||
const aliveAfterVote = effectiveAlive.filter(p => p.id !== eliminatedId);
|
const aliveAfterVote = effectiveAlive.filter((p) => p.id !== eliminatedId);
|
||||||
const wolves = aliveAfterVote.filter(p => p.identity.team === 'wolf');
|
const wolves = aliveAfterVote.filter((p) => p.identity.team === 'wolf');
|
||||||
const gameOver = wolves.length === 0 || wolves.length >= aliveAfterVote.length - wolves.length;
|
const gameOver =
|
||||||
|
wolves.length === 0 ||
|
||||||
|
wolves.length >= aliveAfterVote.length - wolves.length;
|
||||||
const hunterTriggered = eliminated?.identity.name === '猎人';
|
const hunterTriggered = eliminated?.identity.name === '猎人';
|
||||||
|
|
||||||
// Print votes
|
// Print votes
|
||||||
for (const [voter, target] of Object.entries(votes)) {
|
for (const [voter, target] of Object.entries(votes)) {
|
||||||
const vName = effectiveAlive.find(p => p.id === voter)?.name ?? voter;
|
const vName = effectiveAlive.find((p) => p.id === voter)?.name ?? voter;
|
||||||
const tName = effectiveAlive.find(p => p.id === target)?.name ?? target;
|
const tName = effectiveAlive.find((p) => p.id === target)?.name ?? target;
|
||||||
console.log(` 🗳️ ${vName} → ${tName}`);
|
console.log(` 🗳️ ${vName} → ${tName}`);
|
||||||
}
|
}
|
||||||
console.log(` ❌ ${eliminated?.name}(${eliminated?.identity.name})被放逐出局!\n`);
|
console.log(
|
||||||
|
` ❌ ${eliminated?.name}(${eliminated?.identity.name})被放逐出局!\n`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: `[投票结果] ${eliminated?.name ?? eliminatedId} 被放逐出局`,
|
content: `[投票结果] ${eliminated?.name ?? eliminatedId} 被放逐出局`,
|
||||||
meta: { phase: 'vote', votes, eliminatedId, hunterTriggered, gameOver } as any,
|
meta: {
|
||||||
|
phase: 'vote',
|
||||||
|
votes,
|
||||||
|
eliminatedId,
|
||||||
|
hunterTriggered,
|
||||||
|
gameOver,
|
||||||
|
} as any,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const llmHunterShot: Role<HunterShotMeta & { gameOver?: boolean }> = async (chain) => {
|
const llmHunterShot: Role<HunterShotMeta & { gameOver?: boolean }> = async (
|
||||||
|
chain,
|
||||||
|
) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
// 猎人已死,从最后的 chain 找到猎人
|
// 猎人已死,从最后的 chain 找到猎人
|
||||||
const hunter = state.players.find(p => p.identity.name === '猎人')!;
|
const hunter = state.players.find((p) => p.identity.name === '猎人')!;
|
||||||
const targetList = state.alive.map(p => `${p.id}(${p.name})`).join('、');
|
const targetList = state.alive.map((p) => `${p.id}(${p.name})`).join('、');
|
||||||
|
|
||||||
const response = await callLlm([
|
const response = await callLlm(
|
||||||
{ role: 'system', content: buildSystemPrompt(hunter, PERSONALITIES[2]) },
|
[
|
||||||
{ role: 'user', content: `你被投票出局了!作为猎人你可以开枪带走一人。存活玩家:${targetList}\n\n直接回复玩家ID(如 p1):` },
|
{ role: 'system', content: buildSystemPrompt(hunter, PERSONALITIES[2]) },
|
||||||
], 0.3);
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `你被投票出局了!作为猎人你可以开枪带走一人。存活玩家:${targetList}\n\n直接回复玩家ID(如 p1):`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0.3,
|
||||||
|
);
|
||||||
|
|
||||||
const match = response.match(/p\d+/);
|
const match = response.match(/p\d+/);
|
||||||
const targetId = match ? match[0] : state.alive[0].id;
|
const targetId = match ? match[0] : state.alive[0].id;
|
||||||
const target = state.alive.find(p => p.id === targetId) ?? state.alive[0];
|
const target = state.alive.find((p) => p.id === targetId) ?? state.alive[0];
|
||||||
const aliveAfter = state.alive.filter(p => p.id !== target.id);
|
const aliveAfter = state.alive.filter((p) => p.id !== target.id);
|
||||||
const wolves = aliveAfter.filter(p => p.identity.team === 'wolf');
|
const wolves = aliveAfter.filter((p) => p.identity.team === 'wolf');
|
||||||
const gameOver = wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length;
|
const gameOver =
|
||||||
|
wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length;
|
||||||
|
|
||||||
console.log(` 🔫 猎人开枪带走了 ${target.name}(${target.identity.name})!\n`);
|
console.log(
|
||||||
|
` 🔫 猎人开枪带走了 ${target.name}(${target.identity.name})!\n`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: `[猎人开枪] 猎人带走了 ${target.name}`,
|
content: `[猎人开枪] 猎人带走了 ${target.name}`,
|
||||||
@@ -322,17 +423,17 @@ const llmHunterShot: Role<HunterShotMeta & { gameOver?: boolean }> = async (chai
|
|||||||
|
|
||||||
const llmGameEnd: Role<GameEndMeta> = async (chain) => {
|
const llmGameEnd: Role<GameEndMeta> = async (chain) => {
|
||||||
const state = parseGameState(chain);
|
const state = parseGameState(chain);
|
||||||
const wolves = state.alive.filter(p => p.identity.team === 'wolf');
|
const wolves = state.alive.filter((p) => p.identity.team === 'wolf');
|
||||||
const winner = wolves.length === 0 ? 'good' : 'wolf';
|
const winner = wolves.length === 0 ? 'good' : 'wolf';
|
||||||
|
|
||||||
const summary = `
|
const summary = `
|
||||||
🏆 ${winner === 'good' ? '好人阵营' : '狼人阵营'}获胜!
|
🏆 ${winner === 'good' ? '好人阵营' : '狼人阵营'}获胜!
|
||||||
|
|
||||||
存活玩家:${state.alive.map(p => `${p.name}(${p.identity.name})`).join('、') || '无'}
|
存活玩家:${state.alive.map((p) => `${p.name}(${p.identity.name})`).join('、') || '无'}
|
||||||
死亡玩家:${state.dead.map(d => `${d.name}(${d.identity.name},${d.cause})`).join('、')}
|
死亡玩家:${state.dead.map((d) => `${d.name}(${d.identity.name},${d.cause})`).join('、')}
|
||||||
|
|
||||||
玩家身份揭晓:
|
玩家身份揭晓:
|
||||||
${state.players.map(p => ` ${p.name} → ${p.identity.name}(${p.identity.team === 'wolf' ? '🐺' : '✅'})`).join('\n')}
|
${state.players.map((p) => ` ${p.name} → ${p.identity.name}(${p.identity.team === 'wolf' ? '🐺' : '✅'})`).join('\n')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log(summary);
|
console.log(summary);
|
||||||
@@ -349,7 +450,11 @@ async function main() {
|
|||||||
console.log('🐺🌙 ============ 狼人杀 LLM 对战 ============\n');
|
console.log('🐺🌙 ============ 狼人杀 LLM 对战 ============\n');
|
||||||
console.log('玩家身份(上帝视角):');
|
console.log('玩家身份(上帝视角):');
|
||||||
const allPlayers = createPlayers();
|
const allPlayers = createPlayers();
|
||||||
allPlayers.forEach(p => console.log(` ${p.name} → ${p.identity.name}(${p.identity.team === 'wolf' ? '🐺' : '✅'})`));
|
allPlayers.forEach((p) =>
|
||||||
|
console.log(
|
||||||
|
` ${p.name} → ${p.identity.name}(${p.identity.team === 'wolf' ? '🐺' : '✅'})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
console.log('\n游戏开始!\n');
|
console.log('\n游戏开始!\n');
|
||||||
|
|
||||||
const wf = createWerewolfWorkflow({
|
const wf = createWerewolfWorkflow({
|
||||||
@@ -369,15 +474,23 @@ async function main() {
|
|||||||
mkdirSync(`${tmpDir}/scopes`, { recursive: true });
|
mkdirSync(`${tmpDir}/scopes`, { recursive: true });
|
||||||
mkdirSync(`${tmpDir}/objects`, { recursive: true });
|
mkdirSync(`${tmpDir}/objects`, { recursive: true });
|
||||||
|
|
||||||
const ss = css({ basePath: `${tmpDir}/scopes`, objectsDir: `${tmpDir}/objects` });
|
const ss = css({
|
||||||
|
basePath: `${tmpDir}/scopes`,
|
||||||
|
objectsDir: `${tmpDir}/objects`,
|
||||||
|
});
|
||||||
const store = ss.scope('werewolf-game');
|
const store = ss.scope('werewolf-game');
|
||||||
|
|
||||||
const { createWorkflowRule } = await import('../workflows/workflow-rule-adapter.js');
|
const { createWorkflowRule } = await import(
|
||||||
|
'../workflows/workflow-rule-adapter.js'
|
||||||
|
);
|
||||||
const rule = createWorkflowRule(wf, store);
|
const rule = createWorkflowRule(wf, store);
|
||||||
const ticker = createWorkflowTicker([rule]);
|
const ticker = createWorkflowTicker([rule]);
|
||||||
|
|
||||||
// Submit start event
|
// Submit start event
|
||||||
const startPayload = JSON.stringify({ title: 'AI 狼人杀', players: allPlayers.map(p => p.name) });
|
const startPayload = JSON.stringify({
|
||||||
|
title: 'AI 狼人杀',
|
||||||
|
players: allPlayers.map((p) => p.name),
|
||||||
|
});
|
||||||
const hash = await store.putObject(startPayload);
|
const hash = await store.putObject(startPayload);
|
||||||
await store.appendEvent({
|
await store.appendEvent({
|
||||||
occurredAt: Date.now(),
|
occurredAt: Date.now(),
|
||||||
@@ -392,7 +505,9 @@ async function main() {
|
|||||||
while (maxTicks-- > 0) {
|
while (maxTicks-- > 0) {
|
||||||
await ticker();
|
await ticker();
|
||||||
// Check if game ended
|
// Check if game ended
|
||||||
const endEvents = await store.queryByKind('werewolf.game-end', { limit: 1 });
|
const endEvents = await store.queryByKind('werewolf.game-end', {
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
if (endEvents.length > 0) {
|
if (endEvents.length > 0) {
|
||||||
console.log('\n🎮 游戏结束!');
|
console.log('\n🎮 游戏结束!');
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,29 +1,34 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* 狼人杀战报生成 — 用 Report Workflow (analyst → renderer)
|
* 狼人杀战报生成 — 用 Report Workflow (analyst → renderer)
|
||||||
* 读取狼人杀 game 数据,生成 HTML 战报
|
* 读取狼人杀 game 数据,生成 HTML 战报
|
||||||
*
|
*
|
||||||
* bun packages/pulse/src/e2e/werewolf-report.ts [--db /tmp/werewolf-xxx]
|
* bun packages/pulse/src/e2e/werewolf-report.ts [--db /tmp/werewolf-xxx]
|
||||||
*
|
*
|
||||||
* 小橘 🍊 (NEKO Team)
|
* 小橘 🍊 (NEKO Team)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mkdtempSync, writeFileSync, readdirSync } from 'node:fs';
|
import { mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
createAnalystRole,
|
||||||
|
createRendererRole,
|
||||||
|
createReportWorkflow,
|
||||||
|
} from '@upulse/workflows';
|
||||||
import { createScopedStore, createStore } from '../index.js';
|
import { createScopedStore, createStore } from '../index.js';
|
||||||
import { createOpenAiLlmClient } from '../llm-client.js';
|
import { createOpenAiLlmClient } from '../llm-client.js';
|
||||||
import { createReportWorkflow } from '@upulse/workflows';
|
|
||||||
import { createAnalystRole } from '@upulse/workflows';
|
|
||||||
import { createRendererRole } from '@upulse/workflows';
|
|
||||||
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
|
||||||
|
|
||||||
// ── Args ───────────────────────────────────────────────────────
|
// ── Args ───────────────────────────────────────────────────────
|
||||||
let dbDir = process.argv.find(a => a.startsWith('--db='))?.slice(5);
|
let dbDir = process.argv.find((a) => a.startsWith('--db='))?.slice(5);
|
||||||
|
|
||||||
if (!dbDir) {
|
if (!dbDir) {
|
||||||
// Auto-detect latest /tmp/werewolf-*
|
// Auto-detect latest /tmp/werewolf-*
|
||||||
const dirs = readdirSync('/tmp').filter(d => d.startsWith('werewolf-')).sort().reverse();
|
const dirs = readdirSync('/tmp')
|
||||||
|
.filter((d) => d.startsWith('werewolf-'))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
if (dirs.length > 0) {
|
if (dirs.length > 0) {
|
||||||
dbDir = join('/tmp', dirs[0]);
|
dbDir = join('/tmp', dirs[0]);
|
||||||
console.log(`🔍 Auto-detected: ${dbDir}`);
|
console.log(`🔍 Auto-detected: ${dbDir}`);
|
||||||
@@ -33,7 +38,9 @@ if (!dbDir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.PULSE_LLM_BASE_URL ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
const baseUrl =
|
||||||
|
process.env.PULSE_LLM_BASE_URL ??
|
||||||
|
'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||||
const apiKey = process.env.PULSE_LLM_API_KEY ?? process.env.DASHSCOPE_API_KEY;
|
const apiKey = process.env.PULSE_LLM_API_KEY ?? process.env.DASHSCOPE_API_KEY;
|
||||||
const model = process.env.PULSE_LLM_MODEL ?? 'qwen-plus';
|
const model = process.env.PULSE_LLM_MODEL ?? 'qwen-plus';
|
||||||
|
|
||||||
@@ -46,7 +53,10 @@ const t0 = Date.now();
|
|||||||
const ts = () => `[+${((Date.now() - t0) / 1000).toFixed(1)}s]`;
|
const ts = () => `[+${((Date.now() - t0) / 1000).toFixed(1)}s]`;
|
||||||
|
|
||||||
// ── Read werewolf game events ──────────────────────────────────
|
// ── Read werewolf game events ──────────────────────────────────
|
||||||
const ss = createScopedStore({ basePath: join(dbDir, 'scopes'), objectsDir: join(dbDir, 'objects') });
|
const ss = createScopedStore({
|
||||||
|
basePath: join(dbDir, 'scopes'),
|
||||||
|
objectsDir: join(dbDir, 'objects'),
|
||||||
|
});
|
||||||
const store = ss.scope('werewolf-game');
|
const store = ss.scope('werewolf-game');
|
||||||
const allEvents = await store.getAfter(0);
|
const allEvents = await store.getAfter(0);
|
||||||
|
|
||||||
@@ -60,34 +70,43 @@ if (allEvents.length === 0) {
|
|||||||
|
|
||||||
// Build timeline JSON (adapted for werewolf)
|
// Build timeline JSON (adapted for werewolf)
|
||||||
const tStart = allEvents[0].occurredAt;
|
const tStart = allEvents[0].occurredAt;
|
||||||
const eventItems = await Promise.all(allEvents.map(async (e, i) => {
|
const eventItems = await Promise.all(
|
||||||
const role = e.kind.replace('werewolf.', '');
|
allEvents.map(async (e, i) => {
|
||||||
const meta = e.meta ? JSON.parse(e.meta) : null;
|
const role = e.kind.replace('werewolf.', '');
|
||||||
let content: string | null = null;
|
const meta = e.meta ? JSON.parse(e.meta) : null;
|
||||||
if (e.hash) {
|
let content: string | null = null;
|
||||||
try {
|
if (e.hash) {
|
||||||
const obj = await store.getObject(e.hash);
|
try {
|
||||||
content = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
const obj = await store.getObject(e.hash);
|
||||||
} catch {}
|
content = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
||||||
}
|
} catch {}
|
||||||
return {
|
}
|
||||||
id: e.id,
|
return {
|
||||||
role,
|
id: e.id,
|
||||||
offsetMs: e.occurredAt - tStart,
|
role,
|
||||||
durationMs: i > 0 ? e.occurredAt - allEvents[i - 1].occurredAt : 0,
|
offsetMs: e.occurredAt - tStart,
|
||||||
meta,
|
durationMs: i > 0 ? e.occurredAt - allEvents[i - 1].occurredAt : 0,
|
||||||
content,
|
meta,
|
||||||
};
|
content,
|
||||||
}));
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const timelineJson = JSON.stringify({
|
const timelineJson = JSON.stringify(
|
||||||
key: 'werewolf-game-1',
|
{
|
||||||
totalMs: allEvents[allEvents.length - 1].occurredAt - tStart,
|
key: 'werewolf-game-1',
|
||||||
events: eventItems,
|
totalMs: allEvents[allEvents.length - 1].occurredAt - tStart,
|
||||||
}, null, 2);
|
events: eventItems,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
ss.close();
|
ss.close();
|
||||||
console.log(ts(), `Timeline: ${eventItems.length} phases, ${((JSON.parse(timelineJson).totalMs) / 1000).toFixed(1)}s total`);
|
console.log(
|
||||||
|
ts(),
|
||||||
|
`Timeline: ${eventItems.length} phases, ${(JSON.parse(timelineJson).totalMs / 1000).toFixed(1)}s total`,
|
||||||
|
);
|
||||||
|
|
||||||
// ── Run report workflow ────────────────────────────────────────
|
// ── Run report workflow ────────────────────────────────────────
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-report-'));
|
const tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-report-'));
|
||||||
@@ -139,12 +158,12 @@ while (tickNum < 5) {
|
|||||||
|
|
||||||
// Extract HTML report
|
// Extract HTML report
|
||||||
const reportEvents = await reportStore.getAfter(0);
|
const reportEvents = await reportStore.getAfter(0);
|
||||||
const rendererEvt = reportEvents.find(e => e.kind === 'report.renderer');
|
const rendererEvt = reportEvents.find((e) => e.kind === 'report.renderer');
|
||||||
if (rendererEvt?.hash) {
|
if (rendererEvt?.hash) {
|
||||||
const html = await reportStore.getObject(rendererEvt.hash) as string;
|
const html = (await reportStore.getObject(rendererEvt.hash)) as string;
|
||||||
const outPath = join(tmpDir, 'werewolf-report.html');
|
const outPath = join(tmpDir, 'werewolf-report.html');
|
||||||
writeFileSync(outPath, html, 'utf-8');
|
writeFileSync(outPath, html, 'utf-8');
|
||||||
|
|
||||||
// Also copy to workspace
|
// Also copy to workspace
|
||||||
const wsPath = '/home/azureuser/.openclaw/workspace/werewolf-report-v2.html';
|
const wsPath = '/home/azureuser/.openclaw/workspace/werewolf-report-v2.html';
|
||||||
writeFileSync(wsPath, html, 'utf-8');
|
writeFileSync(wsPath, html, 'utf-8');
|
||||||
@@ -152,12 +171,14 @@ if (rendererEvt?.hash) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Print analyst findings
|
// Print analyst findings
|
||||||
const analystEvt = reportEvents.find(e => e.kind === 'report.analyst');
|
const analystEvt = reportEvents.find((e) => e.kind === 'report.analyst');
|
||||||
if (analystEvt?.meta) {
|
if (analystEvt?.meta) {
|
||||||
const meta = JSON.parse(analystEvt.meta);
|
const meta = JSON.parse(analystEvt.meta);
|
||||||
console.log(`\n📊 Score: ${meta.score}/10`);
|
console.log(`\n📊 Score: ${meta.score}/10`);
|
||||||
console.log(`✅ Highlights: ${meta.highlights?.join(', ')}`);
|
console.log(`✅ Highlights: ${meta.highlights?.join(', ')}`);
|
||||||
console.log(`⚠️ Issues: ${meta.issues?.length ? meta.issues.join(', ') : 'None'}`);
|
console.log(
|
||||||
|
`⚠️ Issues: ${meta.issues?.length ? meta.issues.join(', ') : 'None'}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await reportStore.close();
|
await reportStore.close();
|
||||||
|
|||||||
+5
-1
@@ -1 +1,5 @@
|
|||||||
export { executeSurvivalEffect, type SurvivalEffect, type SurvivalExecDeps, } from './survival.js';
|
export {
|
||||||
|
executeSurvivalEffect,
|
||||||
|
type SurvivalEffect,
|
||||||
|
type SurvivalExecDeps,
|
||||||
|
} from './survival.js';
|
||||||
|
|||||||
+13
-7
@@ -3,19 +3,25 @@
|
|||||||
*
|
*
|
||||||
* Execute survival effects - all deterministic local commands.
|
* Execute survival effects - all deterministic local commands.
|
||||||
*/
|
*/
|
||||||
import { execFileSync as defaultExecFileSync, type execSync } from 'node:child_process';
|
import {
|
||||||
|
execFileSync as defaultExecFileSync,
|
||||||
|
type execSync,
|
||||||
|
} from 'node:child_process';
|
||||||
import type * as fs from 'node:fs';
|
import type * as fs from 'node:fs';
|
||||||
export interface SurvivalEffect {
|
export interface SurvivalEffect {
|
||||||
type: string;
|
type: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
/** Dependencies that can be injected for testing */
|
/** Dependencies that can be injected for testing */
|
||||||
export interface SurvivalExecDeps {
|
export interface SurvivalExecDeps {
|
||||||
fs?: typeof fs;
|
fs?: typeof fs;
|
||||||
execSyncFn?: typeof execSync;
|
execSyncFn?: typeof execSync;
|
||||||
execFileSyncFn?: typeof defaultExecFileSync;
|
execFileSyncFn?: typeof defaultExecFileSync;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Execute survival effects — all deterministic local commands
|
* Execute survival effects — all deterministic local commands
|
||||||
*/
|
*/
|
||||||
export declare function executeSurvivalEffect(effect: SurvivalEffect, deps?: SurvivalExecDeps): Promise<void>;
|
export declare function executeSurvivalEffect(
|
||||||
|
effect: SurvivalEffect,
|
||||||
|
deps?: SurvivalExecDeps,
|
||||||
|
): Promise<void>;
|
||||||
|
|||||||
Vendored
+34
-28
@@ -12,33 +12,36 @@
|
|||||||
*/
|
*/
|
||||||
import type { PulseStore } from './store.js';
|
import type { PulseStore } from './store.js';
|
||||||
export interface GcTier {
|
export interface GcTier {
|
||||||
/** Events older than this (ms) are candidates for this tier. */
|
/** Events older than this (ms) are candidates for this tier. */
|
||||||
olderThanMs: number;
|
olderThanMs: number;
|
||||||
/** Keep one event per this interval (ms). null = hard delete. */
|
/** Keep one event per this interval (ms). null = hard delete. */
|
||||||
intervalMs: number | null;
|
intervalMs: number | null;
|
||||||
}
|
}
|
||||||
export interface GcConfig {
|
export interface GcConfig {
|
||||||
/** Enable automatic GC. Default: true. */
|
/** Enable automatic GC. Default: true. */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Run GC every N ticks. Default: 240 (~1h at 15s tick). */
|
/** Run GC every N ticks. Default: 240 (~1h at 15s tick). */
|
||||||
tickInterval: number;
|
tickInterval: number;
|
||||||
/** Retention tiers, ordered by olderThanMs ascending. */
|
/** Retention tiers, ordered by olderThanMs ascending. */
|
||||||
tiers: GcTier[];
|
tiers: GcTier[];
|
||||||
}
|
}
|
||||||
export declare const DEFAULT_GC_CONFIG: GcConfig;
|
export declare const DEFAULT_GC_CONFIG: GcConfig;
|
||||||
export interface GcResult {
|
export interface GcResult {
|
||||||
downsampledCount: number;
|
downsampledCount: number;
|
||||||
archivedCount: number;
|
archivedCount: number;
|
||||||
orphanObjectsCount: number;
|
orphanObjectsCount: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Run GC on vitals store: downsample + archive.
|
* Run GC on vitals store: downsample + archive.
|
||||||
* Returns stats about what was cleaned up.
|
* Returns stats about what was cleaned up.
|
||||||
*/
|
*/
|
||||||
export declare function gcVitals(vitalsStore: PulseStore, config?: GcConfig): Promise<{
|
export declare function gcVitals(
|
||||||
downsampledCount: number;
|
vitalsStore: PulseStore,
|
||||||
archivedCount: number;
|
config?: GcConfig,
|
||||||
|
): Promise<{
|
||||||
|
downsampledCount: number;
|
||||||
|
archivedCount: number;
|
||||||
}>;
|
}>;
|
||||||
/**
|
/**
|
||||||
* CAS mark-and-sweep: delete orphaned objects not referenced by any event.
|
* CAS mark-and-sweep: delete orphaned objects not referenced by any event.
|
||||||
@@ -46,26 +49,29 @@ export declare function gcVitals(vitalsStore: PulseStore, config?: GcConfig): Pr
|
|||||||
* Scans all events in the given stores for hash references,
|
* Scans all events in the given stores for hash references,
|
||||||
* then compares against files in objectsDir.
|
* then compares against files in objectsDir.
|
||||||
*/
|
*/
|
||||||
export declare function gcOrphanObjects(stores: PulseStore[], objectsDir: string): Promise<number>;
|
export declare function gcOrphanObjects(
|
||||||
|
stores: PulseStore[],
|
||||||
|
objectsDir: string,
|
||||||
|
): Promise<number>;
|
||||||
/**
|
/**
|
||||||
* Full GC cycle: vitals downsample/archive + CAS orphan sweep.
|
* Full GC cycle: vitals downsample/archive + CAS orphan sweep.
|
||||||
* Writes a gc event to systemStore for observability.
|
* Writes a gc event to systemStore for observability.
|
||||||
*/
|
*/
|
||||||
export declare function runGc(options: {
|
export declare function runGc(options: {
|
||||||
vitalsStore: PulseStore;
|
vitalsStore: PulseStore;
|
||||||
systemStore: PulseStore;
|
systemStore: PulseStore;
|
||||||
allStores: PulseStore[];
|
allStores: PulseStore[];
|
||||||
objectsDir: string;
|
objectsDir: string;
|
||||||
config?: GcConfig;
|
config?: GcConfig;
|
||||||
}): Promise<GcResult>;
|
}): Promise<GcResult>;
|
||||||
/**
|
/**
|
||||||
* Create a GC trigger that fires every N ticks.
|
* Create a GC trigger that fires every N ticks.
|
||||||
* Returns a function to call after each tick.
|
* Returns a function to call after each tick.
|
||||||
*/
|
*/
|
||||||
export declare function createGcTrigger(options: {
|
export declare function createGcTrigger(options: {
|
||||||
vitalsStore: PulseStore;
|
vitalsStore: PulseStore;
|
||||||
systemStore: PulseStore;
|
systemStore: PulseStore;
|
||||||
allStores: PulseStore[];
|
allStores: PulseStore[];
|
||||||
objectsDir: string;
|
objectsDir: string;
|
||||||
config?: GcConfig;
|
config?: GcConfig;
|
||||||
}): () => void;
|
}): () => void;
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* Guard core logic — pure functions without database dependencies.
|
||||||
|
* Can be used in both bun:sqlite (Pulse) and D1 (Pulseflare) environments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import jsonata, { type Expression } from 'jsonata';
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GuardSource {
|
||||||
|
kind: string;
|
||||||
|
key_prefix?: string;
|
||||||
|
check: string;
|
||||||
|
transition: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuardProjectionDef {
|
||||||
|
name: string;
|
||||||
|
initial_value: any;
|
||||||
|
sources: GuardSource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuardContext {
|
||||||
|
currentState: any;
|
||||||
|
event: { kind: string; key?: string; meta?: string; occurredAt: number };
|
||||||
|
sources: GuardSource[];
|
||||||
|
initialValue: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuardEvalResult {
|
||||||
|
ok: boolean;
|
||||||
|
newState: any;
|
||||||
|
matchedAny: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GuardViolationError extends Error {
|
||||||
|
constructor(
|
||||||
|
public guardName: string,
|
||||||
|
public eventKind: string,
|
||||||
|
public eventKey: string | undefined,
|
||||||
|
public reason: string,
|
||||||
|
) {
|
||||||
|
super(`Guard "${guardName}" rejected event ${eventKind}: ${reason}`);
|
||||||
|
this.name = 'GuardViolationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuardUpdate {
|
||||||
|
guardName: string;
|
||||||
|
key: string;
|
||||||
|
newState: any;
|
||||||
|
lastEventId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expression cache ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const exprCache = new Map<string, Expression>();
|
||||||
|
|
||||||
|
function getExpr(expressionStr: string): Expression {
|
||||||
|
let e = exprCache.get(expressionStr);
|
||||||
|
if (!e) {
|
||||||
|
e = jsonata(expressionStr);
|
||||||
|
exprCache.set(expressionStr, e);
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGuardExpressionCache(): void {
|
||||||
|
exprCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kind pattern matching (* = one dot segment) ────────────────
|
||||||
|
|
||||||
|
export function matchEventKindPattern(pattern: string, kind: string): boolean {
|
||||||
|
const ps = pattern.split('.');
|
||||||
|
const ks = kind.split('.');
|
||||||
|
if (ps.length !== ks.length) return false;
|
||||||
|
for (let i = 0; i < ps.length; i++) {
|
||||||
|
if (ps[i] === '*') continue;
|
||||||
|
if (ps[i] !== ks[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event bindings helper ──────────────────────────────────────
|
||||||
|
|
||||||
|
function eventBindings(
|
||||||
|
event: { kind: string; key?: string; meta?: string; occurredAt: number },
|
||||||
|
eventId: number,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: eventId,
|
||||||
|
occurred_at: event.occurredAt,
|
||||||
|
kind: event.kind,
|
||||||
|
key: event.key,
|
||||||
|
payload: event.meta ? JSON.parse(event.meta) : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evalBool(
|
||||||
|
expressionStr: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const e = getExpr(expressionStr);
|
||||||
|
const out = await e.evaluate(context, {});
|
||||||
|
if (typeof out === 'boolean') return out;
|
||||||
|
if (out === null || out === undefined) return false;
|
||||||
|
throw new Error(`check must return boolean, got ${typeof out}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core evaluation logic (pure, no DB) ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a single guard against an event.
|
||||||
|
* Returns ok=true + newState if all checks pass, ok=false + error if any fail.
|
||||||
|
* Check runs BEFORE transition (fail fast).
|
||||||
|
*/
|
||||||
|
export async function evaluateGuard(
|
||||||
|
ctx: GuardContext,
|
||||||
|
): Promise<GuardEvalResult> {
|
||||||
|
const { currentState, event, sources, initialValue } = ctx;
|
||||||
|
const baseState = currentState !== undefined ? currentState : initialValue;
|
||||||
|
|
||||||
|
let working = baseState;
|
||||||
|
let matchedAny = false;
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!matchEventKindPattern(source.kind, event.kind)) continue;
|
||||||
|
|
||||||
|
if (source.key_prefix !== undefined) {
|
||||||
|
const k = event.key;
|
||||||
|
if (k === undefined || !k.startsWith(source.key_prefix)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedAny = true;
|
||||||
|
|
||||||
|
const ev = eventBindings(event, 0);
|
||||||
|
const evalCtx = { state: working, event: ev };
|
||||||
|
|
||||||
|
// Check FIRST (fail fast)
|
||||||
|
let ok: boolean;
|
||||||
|
try {
|
||||||
|
ok = await evalBool(source.check, evalCtx);
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
newState: working,
|
||||||
|
matchedAny,
|
||||||
|
errorMessage: `check failed: ${err?.message ?? String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
newState: working,
|
||||||
|
matchedAny,
|
||||||
|
errorMessage: 'check returned false',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition AFTER check passes
|
||||||
|
let newState: any;
|
||||||
|
try {
|
||||||
|
newState = await getExpr(source.transition).evaluate(evalCtx, {});
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
newState: working,
|
||||||
|
matchedAny,
|
||||||
|
errorMessage: `transition failed: ${err?.message ?? String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
working = newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, newState: working, matchedAny };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate all matching guards for an event.
|
||||||
|
* Returns updates to apply, or throws GuardViolationError on first failure.
|
||||||
|
*/
|
||||||
|
export async function checkGuardsCore(
|
||||||
|
defs: GuardProjectionDef[],
|
||||||
|
event: { kind: string; key?: string; meta?: string; occurredAt: number },
|
||||||
|
loadState: (
|
||||||
|
guardName: string,
|
||||||
|
key: string,
|
||||||
|
) => { value: any; lastEventId: number } | null,
|
||||||
|
): Promise<{ updates: GuardUpdate[] }> {
|
||||||
|
const updates: GuardUpdate[] = [];
|
||||||
|
|
||||||
|
for (const def of defs) {
|
||||||
|
const stateKey = event.key ?? '';
|
||||||
|
const row = loadState(def.name, stateKey);
|
||||||
|
const currentState = row ? row.value : undefined;
|
||||||
|
|
||||||
|
const result = await evaluateGuard({
|
||||||
|
currentState,
|
||||||
|
event,
|
||||||
|
sources: def.sources,
|
||||||
|
initialValue: def.initial_value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new GuardViolationError(
|
||||||
|
def.name,
|
||||||
|
event.kind,
|
||||||
|
event.key,
|
||||||
|
result.errorMessage || 'unknown error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.matchedAny) {
|
||||||
|
updates.push({
|
||||||
|
guardName: def.name,
|
||||||
|
key: stateKey,
|
||||||
|
newState: result.newState,
|
||||||
|
lastEventId: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updates };
|
||||||
|
}
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Guard projections: synchronous fold + check on append (JSONata).
|
* Guard projections: bun:sqlite storage layer.
|
||||||
|
* Core logic delegated to guard-core.ts (pure, portable).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Database } from 'bun:sqlite';
|
import type { Database } from 'bun:sqlite';
|
||||||
import jsonata, { type Expression } from 'jsonata';
|
import {
|
||||||
|
checkGuardsCore,
|
||||||
|
clearGuardExpressionCache as clearCoreCache,
|
||||||
|
type GuardProjectionDef,
|
||||||
|
type GuardSource,
|
||||||
|
type GuardUpdate,
|
||||||
|
} from './guard-core.js';
|
||||||
|
|
||||||
|
// Re-export for backward compat
|
||||||
|
export {
|
||||||
|
type GuardProjectionDef,
|
||||||
|
type GuardSource,
|
||||||
|
type GuardUpdate,
|
||||||
|
GuardViolationError,
|
||||||
|
matchEventKindPattern,
|
||||||
|
} from './guard-core.js';
|
||||||
|
|
||||||
// ── Schema ────────────────────────────────────────────────────
|
// ── Schema ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -29,72 +45,16 @@ export function initGuardSchema(db: Database): void {
|
|||||||
db.exec(GUARD_SCHEMA);
|
db.exec(GUARD_SCHEMA);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────
|
// ── Memory cache ───────────────────────────────────────────────
|
||||||
|
|
||||||
export interface GuardSource {
|
const guardDefMemory = new Map<string, GuardProjectionDef>();
|
||||||
kind: string;
|
|
||||||
key_prefix?: string;
|
|
||||||
check: string;
|
|
||||||
transition: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GuardProjectionDef {
|
/** @internal Clear expression cache and memory cache (for testing) */
|
||||||
name: string;
|
|
||||||
initial_value: any;
|
|
||||||
sources: GuardSource[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GuardViolationError extends Error {
|
|
||||||
constructor(
|
|
||||||
public guardName: string,
|
|
||||||
public eventKind: string,
|
|
||||||
public eventKey: string | undefined,
|
|
||||||
public reason: string,
|
|
||||||
) {
|
|
||||||
super(`Guard "${guardName}" rejected event ${eventKind}: ${reason}`);
|
|
||||||
this.name = 'GuardViolationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GuardUpdate {
|
|
||||||
guardName: string;
|
|
||||||
key: string;
|
|
||||||
newState: any;
|
|
||||||
lastEventId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Expression cache ───────────────────────────────────────────
|
|
||||||
|
|
||||||
const exprCache = new Map<string, Expression>();
|
|
||||||
|
|
||||||
function expr(expressionStr: string): Expression {
|
|
||||||
let e = exprCache.get(expressionStr);
|
|
||||||
if (!e) {
|
|
||||||
e = jsonata(expressionStr);
|
|
||||||
exprCache.set(expressionStr, e);
|
|
||||||
}
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
export function clearGuardExpressionCache(): void {
|
export function clearGuardExpressionCache(): void {
|
||||||
exprCache.clear();
|
clearCoreCache();
|
||||||
guardDefMemory.clear();
|
guardDefMemory.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Kind pattern (* = one dot segment) ─────────────────────────
|
|
||||||
|
|
||||||
export function matchEventKindPattern(pattern: string, kind: string): boolean {
|
|
||||||
const ps = pattern.split('.');
|
|
||||||
const ks = kind.split('.');
|
|
||||||
if (ps.length !== ks.length) return false;
|
|
||||||
for (let i = 0; i < ps.length; i++) {
|
|
||||||
if (ps[i] === '*') continue;
|
|
||||||
if (ps[i] !== ks[i]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DB helpers ─────────────────────────────────────────────────
|
// ── DB helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const insertGuardDefStmt = (db: Database) =>
|
const insertGuardDefStmt = (db: Database) =>
|
||||||
@@ -158,41 +118,11 @@ function loadGuardRow(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventBindings(
|
// ── Public API ─────────────────────────────────────────────────
|
||||||
event: {
|
|
||||||
kind: string;
|
|
||||||
key?: string;
|
|
||||||
meta?: string;
|
|
||||||
occurredAt: number;
|
|
||||||
},
|
|
||||||
eventId: number,
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
id: eventId,
|
|
||||||
occurred_at: event.occurredAt,
|
|
||||||
kind: event.kind,
|
|
||||||
key: event.key,
|
|
||||||
payload: event.meta ? JSON.parse(event.meta) : {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function evalBool(
|
|
||||||
label: string,
|
|
||||||
expressionStr: string,
|
|
||||||
context: Record<string, unknown>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const e = expr(expressionStr);
|
|
||||||
const out = await e.evaluate(context, {});
|
|
||||||
if (typeof out === 'boolean') return out;
|
|
||||||
if (out === null || out === undefined) return false;
|
|
||||||
throw new Error(`${label} must return boolean, got ${typeof out}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register guard (persist + memory cache of compiled defs for this process).
|
* Register guard (persist + memory cache).
|
||||||
*/
|
*/
|
||||||
const guardDefMemory = new Map<string, GuardProjectionDef>();
|
|
||||||
|
|
||||||
export function registerGuard(db: Database, def: GuardProjectionDef): void {
|
export function registerGuard(db: Database, def: GuardProjectionDef): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
insertGuardDefStmt(db).run(
|
insertGuardDefStmt(db).run(
|
||||||
@@ -205,9 +135,13 @@ export function registerGuard(db: Database, def: GuardProjectionDef): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read guard's current state (same idea as lazy projection read).
|
* Read guard's current state.
|
||||||
*/
|
*/
|
||||||
export function getGuardState(db: Database, guardName: string, key: string): any {
|
export function getGuardState(
|
||||||
|
db: Database,
|
||||||
|
guardName: string,
|
||||||
|
key: string,
|
||||||
|
): any {
|
||||||
const row = loadGuardRow(db, guardName, key);
|
const row = loadGuardRow(db, guardName, key);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
const defs = listGuardDefs(db);
|
const defs = listGuardDefs(db);
|
||||||
@@ -218,87 +152,17 @@ export function getGuardState(db: Database, guardName: string, key: string): any
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Before append: match guards, fold, check. Returns pending state updates
|
* Before append: match guards, fold, check. Returns pending state updates.
|
||||||
* (lastEventId is a placeholder; caller sets to written event id).
|
* Delegates core logic to guard-core (pure, portable).
|
||||||
*/
|
*/
|
||||||
export async function checkGuards(
|
export async function checkGuards(
|
||||||
db: Database,
|
db: Database,
|
||||||
event: { kind: string; key?: string; meta?: string; occurredAt: number },
|
event: { kind: string; key?: string; meta?: string; occurredAt: number },
|
||||||
): Promise<{ updates: GuardUpdate[] }> {
|
): Promise<{ updates: GuardUpdate[] }> {
|
||||||
const defs = listGuardDefs(db);
|
const defs = listGuardDefs(db);
|
||||||
const updates: GuardUpdate[] = [];
|
return checkGuardsCore(defs, event, (guardName, key) =>
|
||||||
const pendingEventId = 0;
|
loadGuardRow(db, guardName, key),
|
||||||
|
);
|
||||||
for (const def of defs) {
|
|
||||||
const stateKey = event.key ?? '';
|
|
||||||
|
|
||||||
const row = loadGuardRow(db, def.name, stateKey);
|
|
||||||
const baseState = row ? row.value : def.initial_value;
|
|
||||||
|
|
||||||
let working = baseState;
|
|
||||||
let matchedAny = false;
|
|
||||||
|
|
||||||
for (const source of def.sources) {
|
|
||||||
if (!matchEventKindPattern(source.kind, event.kind)) continue;
|
|
||||||
|
|
||||||
if (source.key_prefix !== undefined) {
|
|
||||||
const k = event.key;
|
|
||||||
if (k === undefined || !k.startsWith(source.key_prefix)) continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
matchedAny = true;
|
|
||||||
|
|
||||||
const ev = eventBindings(event, pendingEventId);
|
|
||||||
|
|
||||||
const ctx = { state: working, event: ev };
|
|
||||||
|
|
||||||
let newState: any;
|
|
||||||
try {
|
|
||||||
newState = await expr(source.transition).evaluate(ctx, {});
|
|
||||||
} catch (err: any) {
|
|
||||||
throw new GuardViolationError(
|
|
||||||
def.name,
|
|
||||||
event.kind,
|
|
||||||
event.key,
|
|
||||||
`transition failed: ${err?.message ?? String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ok: boolean;
|
|
||||||
try {
|
|
||||||
ok = await evalBool('check', source.check, ctx);
|
|
||||||
} catch (err: any) {
|
|
||||||
throw new GuardViolationError(
|
|
||||||
def.name,
|
|
||||||
event.kind,
|
|
||||||
event.key,
|
|
||||||
`check failed: ${err?.message ?? String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ok) {
|
|
||||||
throw new GuardViolationError(
|
|
||||||
def.name,
|
|
||||||
event.kind,
|
|
||||||
event.key,
|
|
||||||
'check returned false',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
working = newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedAny) {
|
|
||||||
updates.push({
|
|
||||||
guardName: def.name,
|
|
||||||
key: stateKey,
|
|
||||||
newState: working,
|
|
||||||
lastEventId: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { updates };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Vendored
+200
-77
@@ -19,8 +19,8 @@ import { type WatcherDef } from './watcher.js';
|
|||||||
* refreshedAt: when this data was last collected (= collect event's occurred_at)
|
* refreshedAt: when this data was last collected (= collect event's occurred_at)
|
||||||
*/
|
*/
|
||||||
export interface Sensed<T> {
|
export interface Sensed<T> {
|
||||||
data: T;
|
data: T;
|
||||||
refreshedAt: number;
|
refreshedAt: number;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Rule: the universal composition primitive.
|
* Rule: the universal composition primitive.
|
||||||
@@ -38,7 +38,11 @@ export interface Sensed<T> {
|
|||||||
* - Short-circuit by not calling inner (bypass inner layers)
|
* - Short-circuit by not calling inner (bypass inner layers)
|
||||||
* - Pass through by returning inner's result unchanged
|
* - 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]>;
|
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.
|
* Rule definition with projection dependencies.
|
||||||
*
|
*
|
||||||
@@ -46,10 +50,10 @@ export type Rule<S, E> = (prev: S, curr: S, inner: (prev: S, curr: S) => Promise
|
|||||||
* The engine will automatically read these projections and build the snapshot.
|
* The engine will automatically read these projections and build the snapshot.
|
||||||
*/
|
*/
|
||||||
export interface RuleDef<S, E> {
|
export interface RuleDef<S, E> {
|
||||||
name: string;
|
name: string;
|
||||||
/** Projection dependencies, format "scope/name" like ["_vitals/cpu_usage", "neko/session_count"] */
|
/** Projection dependencies, format "scope/name" like ["_vitals/cpu_usage", "neko/session_count"] */
|
||||||
projections: string[];
|
projections: string[];
|
||||||
rule: Rule<S, E>;
|
rule: Rule<S, E>;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Executor: executes a batch of effects.
|
* Executor: executes a batch of effects.
|
||||||
@@ -79,7 +83,9 @@ export type ChainableExecutor<E> = (effects: E[]) => Promise<E[]>;
|
|||||||
* a console.warn is emitted. If executors array is empty and effects is
|
* a console.warn is emitted. If executors array is empty and effects is
|
||||||
* non-empty, a warning is emitted immediately.
|
* non-empty, a warning is emitted immediately.
|
||||||
*/
|
*/
|
||||||
export declare function chainExecutors<E>(executors: ChainableExecutor<E>[]): Executor<E>;
|
export declare function chainExecutors<E>(
|
||||||
|
executors: ChainableExecutor<E>[],
|
||||||
|
): Executor<E>;
|
||||||
/**
|
/**
|
||||||
* Compose rules via onion middleware (reduceRight).
|
* Compose rules via onion middleware (reduceRight).
|
||||||
*
|
*
|
||||||
@@ -89,14 +95,19 @@ export declare function chainExecutors<E>(executors: ChainableExecutor<E>[]): Ex
|
|||||||
* r1 is outermost (executes first), r3 is innermost (closest to base).
|
* r1 is outermost (executes first), r3 is innermost (closest to base).
|
||||||
* Each rule calls inner(prev, curr) to delegate to the next layer.
|
* 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]>;
|
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.
|
* 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 there's a rollback, use the promote event of the rolled-back-to version.
|
||||||
* If no rollback, use the latest promote event.
|
* If no rollback, use the latest promote event.
|
||||||
* If no promote at all, return null (cold start).
|
* If no promote at all, return null (cold start).
|
||||||
*/
|
*/
|
||||||
export declare function findEffectiveEpoch(store: PulseStore): Promise<EventRecord | null>;
|
export declare function findEffectiveEpoch(
|
||||||
|
store: PulseStore,
|
||||||
|
): Promise<EventRecord | null>;
|
||||||
/**
|
/**
|
||||||
* Rebuild a Snapshot from the events table, respecting version epoch.
|
* 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 provided, only reads events after the epoch with matching code_rev.
|
||||||
@@ -110,23 +121,34 @@ export declare function findEffectiveEpoch(store: PulseStore): Promise<EventReco
|
|||||||
* task projections (pending-tasks, agent-capability-stats) are folded from workflowStore.
|
* task projections (pending-tasks, agent-capability-stats) are folded from workflowStore.
|
||||||
* Falls back to options.systemStore for backward compatibility.
|
* Falls back to options.systemStore for backward compatibility.
|
||||||
*/
|
*/
|
||||||
export declare function rebuildSnapshot<S extends {
|
export declare function rebuildSnapshot<
|
||||||
|
S extends {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}>(storeOrStores: PulseStore | {
|
},
|
||||||
system: PulseStore;
|
>(
|
||||||
vitals: PulseStore;
|
storeOrStores:
|
||||||
}, senseKeys: string[], epoch?: EventRecord | null, options?: {
|
| PulseStore
|
||||||
|
| {
|
||||||
|
system: PulseStore;
|
||||||
|
vitals: PulseStore;
|
||||||
|
},
|
||||||
|
senseKeys: string[],
|
||||||
|
epoch?: EventRecord | null,
|
||||||
|
options?: {
|
||||||
systemStore?: PulseStore;
|
systemStore?: PulseStore;
|
||||||
workflowStore?: PulseStore;
|
workflowStore?: PulseStore;
|
||||||
}): Promise<S>;
|
},
|
||||||
|
): Promise<S>;
|
||||||
/**
|
/**
|
||||||
* Build snapshot from projections.
|
* Build snapshot from projections.
|
||||||
* Read each declared projection's current value, using "scope/name" as key.
|
* Read each declared projection's current value, using "scope/name" as key.
|
||||||
* If projection doesn't exist, value is null (graceful degradation).
|
* If projection doesn't exist, value is null (graceful degradation).
|
||||||
*/
|
*/
|
||||||
export declare function buildSnapshotFromProjections<S extends {
|
export declare function buildSnapshotFromProjections<
|
||||||
|
S extends {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}>(scopedStore: ScopedStore, projectionPaths: string[]): Promise<S>;
|
},
|
||||||
|
>(scopedStore: ScopedStore, projectionPaths: string[]): Promise<S>;
|
||||||
/**
|
/**
|
||||||
* Run the Pulse loop.
|
* Run the Pulse loop.
|
||||||
*
|
*
|
||||||
@@ -137,35 +159,38 @@ export declare function buildSnapshotFromProjections<S extends {
|
|||||||
* Cold-start is handled by rules: they see undefined senses and
|
* Cold-start is handled by rules: they see undefined senses and
|
||||||
* produce collect effects on the first tick.
|
* produce collect effects on the first tick.
|
||||||
*/
|
*/
|
||||||
export declare function runPulse<S extends {
|
export declare function runPulse<
|
||||||
|
S extends {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}, E>(options: {
|
},
|
||||||
scopedStore?: ScopedStore;
|
E,
|
||||||
/** @deprecated Use {@link scopedStore} instead. */
|
>(options: {
|
||||||
store?: PulseStore;
|
scopedStore?: ScopedStore;
|
||||||
execute: Executor<E>;
|
/** @deprecated Use {@link scopedStore} instead. */
|
||||||
rules: Rule<S, E>[];
|
store?: PulseStore;
|
||||||
senseKeys: string[];
|
execute: Executor<E>;
|
||||||
defaultTickMs?: number;
|
rules: Rule<S, E>[];
|
||||||
codeRev?: string;
|
senseKeys: string[];
|
||||||
watchers?: WatcherDef[];
|
defaultTickMs?: number;
|
||||||
/** Scan interval for the background executor loop (ms). Default 1000. */
|
codeRev?: string;
|
||||||
executorScanIntervalMs?: number;
|
watchers?: WatcherDef[];
|
||||||
/** GC configuration. Set `{ enabled: false }` to disable. Uses DEFAULT_GC_CONFIG if omitted. */
|
/** Scan interval for the background executor loop (ms). Default 1000. */
|
||||||
gc?: Partial<GcConfig>;
|
executorScanIntervalMs?: number;
|
||||||
/** Objects directory path for CAS orphan cleanup. Required for CAS GC. */
|
/** GC configuration. Set `{ enabled: false }` to disable. Uses DEFAULT_GC_CONFIG if omitted. */
|
||||||
objectsDir?: string;
|
gc?: Partial<GcConfig>;
|
||||||
/** Adaptive tick frequency configuration */
|
/** Objects directory path for CAS orphan cleanup. Required for CAS GC. */
|
||||||
adaptiveTick?: {
|
objectsDir?: string;
|
||||||
/** Base tick interval in ms when active (default: 5000) */
|
/** Adaptive tick frequency configuration */
|
||||||
baseTickMs?: number;
|
adaptiveTick?: {
|
||||||
/** Maximum tick interval in ms when idle (default: 300000) */
|
/** Base tick interval in ms when active (default: 5000) */
|
||||||
maxTickMs?: number;
|
baseTickMs?: number;
|
||||||
/** Backoff factor when idle (default: 2) */
|
/** Maximum tick interval in ms when idle (default: 300000) */
|
||||||
backoffFactor?: number;
|
maxTickMs?: number;
|
||||||
/** Function to determine if there are active topics/work (optional) */
|
/** Backoff factor when idle (default: 2) */
|
||||||
hasActiveWork?: (snapshot: S) => boolean;
|
backoffFactor?: number;
|
||||||
};
|
/** Function to determine if there are active topics/work (optional) */
|
||||||
|
hasActiveWork?: (snapshot: S) => boolean;
|
||||||
|
};
|
||||||
}): Promise<never>;
|
}): Promise<never>;
|
||||||
/**
|
/**
|
||||||
* Run the Pulse loop with RuleDefs and projection-based snapshots (V2).
|
* Run the Pulse loop with RuleDefs and projection-based snapshots (V2).
|
||||||
@@ -173,17 +198,20 @@ export declare function runPulse<S extends {
|
|||||||
* Uses RuleDef + projections to drive the loop instead of senseKeys.
|
* Uses RuleDef + projections to drive the loop instead of senseKeys.
|
||||||
* Automatically folds projections and builds snapshots from declared dependencies.
|
* Automatically folds projections and builds snapshots from declared dependencies.
|
||||||
*/
|
*/
|
||||||
export declare function runPulseV2<S extends {
|
export declare function runPulseV2<
|
||||||
|
S extends {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}, E>(options: {
|
},
|
||||||
scopedStore: ScopedStore;
|
E,
|
||||||
execute: Executor<E>;
|
>(options: {
|
||||||
ruleDefs: RuleDef<S, E>[];
|
scopedStore: ScopedStore;
|
||||||
defaultTickMs?: number;
|
execute: Executor<E>;
|
||||||
codeRev: string;
|
ruleDefs: RuleDef<S, E>[];
|
||||||
watchers?: WatcherDef[];
|
defaultTickMs?: number;
|
||||||
/** Scan interval for the background executor loop (ms). Default 1000. */
|
codeRev: string;
|
||||||
executorScanIntervalMs?: number;
|
watchers?: WatcherDef[];
|
||||||
|
/** Scan interval for the background executor loop (ms). Default 1000. */
|
||||||
|
executorScanIntervalMs?: number;
|
||||||
}): Promise<never>;
|
}): Promise<never>;
|
||||||
/**
|
/**
|
||||||
* Independent executor loop that scans for pending effect events
|
* Independent executor loop that scans for pending effect events
|
||||||
@@ -193,44 +221,139 @@ export declare function runPulseV2<S extends {
|
|||||||
* ticks write effect events, executorLoop picks them up and runs them.
|
* ticks write effect events, executorLoop picks them up and runs them.
|
||||||
*/
|
*/
|
||||||
export declare function executorLoop<E>(options: {
|
export declare function executorLoop<E>(options: {
|
||||||
store: PulseStore;
|
store: PulseStore;
|
||||||
execute: Executor<E>;
|
execute: Executor<E>;
|
||||||
scanIntervalMs?: number;
|
scanIntervalMs?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Start the executor loop in the background.
|
* Start the executor loop in the background.
|
||||||
* Returns the AbortController so the caller can stop it.
|
* Returns the AbortController so the caller can stop it.
|
||||||
*/
|
*/
|
||||||
export declare function startExecutorLoop<E>(options: {
|
export declare function startExecutorLoop<E>(options: {
|
||||||
store: PulseStore;
|
store: PulseStore;
|
||||||
execute: Executor<E>;
|
execute: Executor<E>;
|
||||||
scanIntervalMs?: number;
|
scanIntervalMs?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): void;
|
}): void;
|
||||||
/**
|
/**
|
||||||
* Create a rule from an accessor + pure logic.
|
* Create a rule from an accessor + pure logic.
|
||||||
* Adaptation happens at construction time — no need for contramap.
|
* 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 declare function createRule<S, E, T>(
|
||||||
export { type CreateScopedStoreOptions, type CreateStoreOptions, createScopedStore, createStore, type EventRecord, type ObjectInstance, type PulseStore, type ScopedStore, } from './store.js';
|
accessor: (s: S) => T,
|
||||||
export { adaptiveInterval, clampTick, dedup, errorBackoff, } from './rules/builtin.js';
|
logic: (
|
||||||
export { startWatcher, type VitalWithData, type WakeCondition, type WatcherDef, type WatcherHandle, } from './watcher.js';
|
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 './watchers/index.js';
|
||||||
export * from './rules/index.js';
|
export * from './rules/index.js';
|
||||||
export { createOpenAiLlmClient, type LlmClient, type LlmMessage, type LlmResponse, type LlmTool, } from './llm-client.js';
|
export {
|
||||||
export { type AgentLoopRuleOptions, createAgentLoopRule, } from './rules/agent-loop.js';
|
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 { buildPersonasFromEvents } from './persona.js';
|
||||||
export { createWorkflowTicker } from './workflows/index.js';
|
export { createWorkflowTicker } from './workflows/index.js';
|
||||||
export type { WorkflowRule, WorkflowTickResult, } from './workflows/workflow-rule-adapter.js';
|
export type {
|
||||||
|
WorkflowRule,
|
||||||
|
WorkflowTickResult,
|
||||||
|
} from './workflows/workflow-rule-adapter.js';
|
||||||
export { createWorkflowRule } 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 {
|
||||||
export { type AgentExecutorConfig, type AgentResult, type AgentRunner, createAgentExecutorRole, createCursorRunner, } from './workflows/roles/agent-executor.js';
|
END,
|
||||||
export { type LlmRoleConfig, type ToolRoleConfig, createLlmRole, createToolRole, } from './workflows/roles/llm-role-factory.js';
|
type MetaOf,
|
||||||
export { type ScaffoldOptions, scaffoldWorkflow } from './workflows/scaffold.js';
|
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 './defs.js';
|
||||||
export * from './executors/index.js';
|
export * from './executors/index.js';
|
||||||
export type { GcConfig, GcResult, GcTier } from './gc.js';
|
export type { GcConfig, GcResult, GcTier } from './gc.js';
|
||||||
export { createGcTrigger, DEFAULT_GC_CONFIG, gcOrphanObjects, gcVitals, runGc, } from './gc.js';
|
export {
|
||||||
|
createGcTrigger,
|
||||||
|
DEFAULT_GC_CONFIG,
|
||||||
|
gcOrphanObjects,
|
||||||
|
gcVitals,
|
||||||
|
runGc,
|
||||||
|
} from './gc.js';
|
||||||
export * from './projection-engine.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';
|
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';
|
||||||
|
|||||||
@@ -1,672 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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';
|
|
||||||
+40
-24
@@ -204,7 +204,7 @@ export async function rebuildSnapshot<S extends { timestamp: number }>(
|
|||||||
storeOrStores: PulseStore | { system: PulseStore; vitals: PulseStore },
|
storeOrStores: PulseStore | { system: PulseStore; vitals: PulseStore },
|
||||||
senseKeys: string[],
|
senseKeys: string[],
|
||||||
epoch?: EventRecord | null,
|
epoch?: EventRecord | null,
|
||||||
options?: { systemStore?: PulseStore; workflowStore?: PulseStore },
|
_options?: { systemStore?: PulseStore; workflowStore?: PulseStore },
|
||||||
): Promise<S> {
|
): Promise<S> {
|
||||||
const isMultiStore =
|
const isMultiStore =
|
||||||
typeof storeOrStores === 'object' &&
|
typeof storeOrStores === 'object' &&
|
||||||
@@ -552,8 +552,7 @@ export async function runPulse<S extends { timestamp: number }, E>(options: {
|
|||||||
tickMs = 0;
|
tickMs = 0;
|
||||||
} else {
|
} else {
|
||||||
// Determine if there's active work
|
// Determine if there's active work
|
||||||
const hasActivity =
|
const hasActivity = effects.length > 0 || hasActiveWork?.(curr);
|
||||||
effects.length > 0 || (hasActiveWork && hasActiveWork(curr));
|
|
||||||
|
|
||||||
if (hasActivity) {
|
if (hasActivity) {
|
||||||
// Active work detected → reset to base frequency
|
// Active work detected → reset to base frequency
|
||||||
@@ -964,15 +963,32 @@ export { buildPersonasFromEvents } from './persona.js';
|
|||||||
// ── Council v2: WorkflowType ────────────────────────────────────────
|
// ── Council v2: WorkflowType ────────────────────────────────────────
|
||||||
|
|
||||||
export { createWorkflowTicker } from './workflows/index.js';
|
export { createWorkflowTicker } from './workflows/index.js';
|
||||||
|
export {
|
||||||
|
type AgentExecutorConfig,
|
||||||
|
type AgentResult,
|
||||||
|
type AgentRunner,
|
||||||
|
createAgentExecutorRole,
|
||||||
|
createCursorRunner,
|
||||||
|
} from './workflows/roles/agent-executor.js';
|
||||||
|
export {
|
||||||
|
createLlmRole,
|
||||||
|
createToolRole,
|
||||||
|
type LlmRoleConfig,
|
||||||
|
type ToolRoleConfig,
|
||||||
|
} from './workflows/roles/llm-role-factory.js';
|
||||||
|
export {
|
||||||
|
type ScaffoldOptions,
|
||||||
|
scaffoldWorkflow,
|
||||||
|
} from './workflows/scaffold.js';
|
||||||
|
export {
|
||||||
|
createSubprocessRole,
|
||||||
|
type SubprocessRoleConfig,
|
||||||
|
} from './workflows/subprocess-role.js';
|
||||||
export type {
|
export type {
|
||||||
WorkflowRule,
|
WorkflowRule,
|
||||||
WorkflowTickResult,
|
WorkflowTickResult,
|
||||||
} from './workflows/workflow-rule-adapter.js';
|
} from './workflows/workflow-rule-adapter.js';
|
||||||
export { createWorkflowRule } from './workflows/workflow-rule-adapter.js';
|
export { createWorkflowRule } from './workflows/workflow-rule-adapter.js';
|
||||||
export {
|
|
||||||
type SubprocessRoleConfig,
|
|
||||||
createSubprocessRole,
|
|
||||||
} from './workflows/subprocess-role.js';
|
|
||||||
export {
|
export {
|
||||||
END,
|
END,
|
||||||
type MetaOf,
|
type MetaOf,
|
||||||
@@ -986,20 +1002,6 @@ export {
|
|||||||
type WorkflowMessage,
|
type WorkflowMessage,
|
||||||
type WorkflowType,
|
type WorkflowType,
|
||||||
} from './workflows/workflow-type.js';
|
} 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 ─────────────────────────────────────────────────
|
// ── Executors ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1015,9 +1017,21 @@ export {
|
|||||||
gcVitals,
|
gcVitals,
|
||||||
runGc,
|
runGc,
|
||||||
} from './gc.js';
|
} from './gc.js';
|
||||||
// ── Projection Engine ───────────────────────────────────────────
|
// ── Guard core (pure logic, no DB dependency — for Pulseflare) ──
|
||||||
export * from './projection-engine.js';
|
// ── Guard core (pure logic, portable — for Pulseflare / D1) ──────
|
||||||
|
export {
|
||||||
|
checkGuardsCore,
|
||||||
|
checkGuardsCore,
|
||||||
|
evaluateGuard,
|
||||||
|
evaluateGuard,
|
||||||
|
type GuardContext,
|
||||||
|
type GuardContext,
|
||||||
|
type GuardEvalResult,
|
||||||
|
type GuardEvalResult,
|
||||||
|
type GuardUpdate,
|
||||||
|
matchEventKindPattern as matchEventKindPatternCore,
|
||||||
|
matchEventKindPattern,
|
||||||
|
} from './guard-core.js';
|
||||||
// ── Guard projections ───────────────────────────────────────────
|
// ── Guard projections ───────────────────────────────────────────
|
||||||
export {
|
export {
|
||||||
type GuardProjectionDef,
|
type GuardProjectionDef,
|
||||||
@@ -1026,6 +1040,8 @@ export {
|
|||||||
getGuardState,
|
getGuardState,
|
||||||
registerGuard,
|
registerGuard,
|
||||||
} from './guard-projection.js';
|
} from './guard-projection.js';
|
||||||
|
// ── Projection Engine ───────────────────────────────────────────
|
||||||
|
export * from './projection-engine.js';
|
||||||
|
|
||||||
// ── Task Event Types ────────────────────────────────────────────
|
// ── Task Event Types ────────────────────────────────────────────
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Vendored
+36
-36
@@ -1,51 +1,51 @@
|
|||||||
export interface LlmMessage {
|
export interface LlmMessage {
|
||||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||||
content?: string;
|
content?: string;
|
||||||
tool_calls?: Array<{
|
tool_calls?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
function: {
|
function: {
|
||||||
name: string;
|
name: string;
|
||||||
arguments: string;
|
arguments: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
tool_call_id?: string;
|
tool_call_id?: string;
|
||||||
}
|
}
|
||||||
export interface LlmTool {
|
export interface LlmTool {
|
||||||
type: 'function';
|
type: 'function';
|
||||||
function: {
|
function: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export interface LlmResponse {
|
export interface LlmResponse {
|
||||||
content?: string;
|
content?: string;
|
||||||
tool_calls?: Array<{
|
tool_calls?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
function: {
|
function: {
|
||||||
name: string;
|
name: string;
|
||||||
arguments: string;
|
arguments: string;
|
||||||
};
|
|
||||||
}>;
|
|
||||||
usage?: {
|
|
||||||
prompt_tokens: number;
|
|
||||||
completion_tokens: number;
|
|
||||||
};
|
};
|
||||||
|
}>;
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export interface LlmClient {
|
export interface LlmClient {
|
||||||
chat(opts: {
|
chat(opts: {
|
||||||
messages: LlmMessage[];
|
messages: LlmMessage[];
|
||||||
tools?: LlmTool[];
|
tools?: LlmTool[];
|
||||||
tool_choice?: 'auto' | 'required';
|
tool_choice?: 'auto' | 'required';
|
||||||
}): Promise<LlmResponse>;
|
}): Promise<LlmResponse>;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Create an OpenAI-compatible LLM client.
|
* Create an OpenAI-compatible LLM client.
|
||||||
* Works with DashScope, LiteLLM proxy, OpenAI, etc.
|
* Works with DashScope, LiteLLM proxy, OpenAI, etc.
|
||||||
*/
|
*/
|
||||||
export declare function createOpenAiLlmClient(opts: {
|
export declare function createOpenAiLlmClient(opts: {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
model: string;
|
model: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): LlmClient;
|
}): LlmClient;
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
+3
-1
@@ -7,4 +7,6 @@ import type { PersonaState } from './task-events.js';
|
|||||||
* - persona-updated only patches provided fields.
|
* - persona-updated only patches provided fields.
|
||||||
* - Events are merged in occurredAt order.
|
* - Events are merged in occurredAt order.
|
||||||
*/
|
*/
|
||||||
export declare function buildPersonasFromEvents(store: PulseStore): Promise<Map<string, PersonaState>>;
|
export declare function buildPersonasFromEvents(
|
||||||
|
store: PulseStore,
|
||||||
|
): Promise<Map<string, PersonaState>>;
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
+26
-10
@@ -8,28 +8,44 @@ import type { Database } from 'bun:sqlite';
|
|||||||
/** Clear the compiled expression cache (useful for testing). */
|
/** Clear the compiled expression cache (useful for testing). */
|
||||||
export declare function clearExpressionCache(): void;
|
export declare function clearExpressionCache(): void;
|
||||||
export interface ProjectionState {
|
export interface ProjectionState {
|
||||||
name: string;
|
name: string;
|
||||||
value: any;
|
value: any;
|
||||||
lastEventId: number;
|
lastEventId: number;
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
updatedAt: number;
|
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";
|
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.
|
* Get current projection state from the database.
|
||||||
*/
|
*/
|
||||||
export declare function getProjectionState(scopeDb: Database, projectionName: string): Promise<ProjectionState | null>;
|
export declare function getProjectionState(
|
||||||
|
scopeDb: Database,
|
||||||
|
projectionName: string,
|
||||||
|
): Promise<ProjectionState | null>;
|
||||||
/**
|
/**
|
||||||
* Incremental fold for a single projection.
|
* Incremental fold for a single projection.
|
||||||
* Reads definition, current state, processes new events since last_event_id.
|
* 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>;
|
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.
|
* 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>>;
|
export declare function foldAllProjections(
|
||||||
|
scopeDb: Database,
|
||||||
|
scopeName: string,
|
||||||
|
codeRev: string,
|
||||||
|
): Promise<Map<string, ProjectionState>>;
|
||||||
/**
|
/**
|
||||||
* Reset all projections and replay with new code revision.
|
* Reset all projections and replay with new code revision.
|
||||||
* This is the only function that DELETES from projections table.
|
* This is the only function that DELETES from projections table.
|
||||||
*/
|
*/
|
||||||
export declare function resetProjections(scopeDb: Database, codeRev: string): Promise<void>;
|
export declare function resetProjections(
|
||||||
|
scopeDb: Database,
|
||||||
|
codeRev: string,
|
||||||
|
): Promise<void>;
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-7
@@ -2,15 +2,17 @@ import type { Rule } from '../index.js';
|
|||||||
import type { LlmClient, LlmTool } from '../llm-client.js';
|
import type { LlmClient, LlmTool } from '../llm-client.js';
|
||||||
import type { PulseStore } from '../store.js';
|
import type { PulseStore } from '../store.js';
|
||||||
export interface AgentLoopRuleOptions {
|
export interface AgentLoopRuleOptions {
|
||||||
llmClient: LlmClient;
|
llmClient: LlmClient;
|
||||||
workflowStore: PulseStore;
|
workflowStore: PulseStore;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
llmTimeoutMs?: number;
|
llmTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
type Snapshot = Record<string, unknown> & {
|
type Snapshot = Record<string, unknown> & {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
type Effect = Record<string, unknown>;
|
type Effect = Record<string, unknown>;
|
||||||
export declare const EFFECT_TOOLS: LlmTool[];
|
export declare const EFFECT_TOOLS: LlmTool[];
|
||||||
export declare function createAgentLoopRule<S extends Snapshot, E extends Effect>(opts: AgentLoopRuleOptions): Rule<S, E>;
|
export declare function createAgentLoopRule<
|
||||||
export {};
|
S extends Snapshot,
|
||||||
|
E extends Effect,
|
||||||
|
>(opts: AgentLoopRuleOptions): Rule<S, E>;
|
||||||
|
|||||||
+15
-4
@@ -17,20 +17,31 @@ export declare function clampTick<S, E>(min?: number, max?: number): Rule<S, E>;
|
|||||||
* tickMs *= 2^errors, capped at maxMs.
|
* tickMs *= 2^errors, capped at maxMs.
|
||||||
* Zero errors → pass through unchanged.
|
* Zero errors → pass through unchanged.
|
||||||
*/
|
*/
|
||||||
export declare function errorBackoff<S, E>(getErrors: (s: S) => number, maxMs?: number): Rule<S, E>;
|
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.
|
* Adaptive interval: speed up when state changes, slow down when idle.
|
||||||
*
|
*
|
||||||
* - Change detected → fastMs
|
* - Change detected → fastMs
|
||||||
* - No change → tickMs * slowFactor, capped at slowMs
|
* - 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>;
|
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).
|
* Deduplicate effects by kind (or custom key).
|
||||||
*
|
*
|
||||||
* Keeps only the last occurrence of each key.
|
* Keeps only the last occurrence of each key.
|
||||||
* Requires E extends { kind: string }.
|
* Requires E extends { kind: string }.
|
||||||
*/
|
*/
|
||||||
export declare function dedup<S, E extends {
|
export declare function dedup<
|
||||||
|
S,
|
||||||
|
E extends {
|
||||||
kind: string;
|
kind: string;
|
||||||
}>(key?: (e: E) => string): Rule<S, E>;
|
},
|
||||||
|
>(key?: (e: E) => string): Rule<S, E>;
|
||||||
|
|||||||
Vendored
+23
-18
@@ -5,26 +5,31 @@
|
|||||||
*/
|
*/
|
||||||
import type { PulseStore } from '../store.js';
|
import type { PulseStore } from '../store.js';
|
||||||
export interface HealthSnapshot {
|
export interface HealthSnapshot {
|
||||||
lastRestart: Record<string, {
|
lastRestart: Record<
|
||||||
ts: number;
|
string,
|
||||||
count: number;
|
{
|
||||||
}>;
|
ts: number;
|
||||||
lastGc: {
|
count: number;
|
||||||
ts: number;
|
}
|
||||||
} | null;
|
>;
|
||||||
lastNotify: {
|
lastGc: {
|
||||||
ts: number;
|
ts: number;
|
||||||
} | null;
|
} | null;
|
||||||
panicCount: number;
|
lastNotify: {
|
||||||
lastPromote?: {
|
ts: number;
|
||||||
ts: number;
|
} | null;
|
||||||
codeRev: string;
|
panicCount: number;
|
||||||
prevCodeRev: string;
|
lastPromote?: {
|
||||||
};
|
ts: number;
|
||||||
recentErrorCount: number;
|
codeRev: string;
|
||||||
|
prevCodeRev: string;
|
||||||
|
};
|
||||||
|
recentErrorCount: number;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Rebuild health field from events table.
|
* Rebuild health field from events table.
|
||||||
* This function is in core package, agent cannot change.
|
* This function is in core package, agent cannot change.
|
||||||
*/
|
*/
|
||||||
export declare function rebuildHealth(store: PulseStore): Promise<HealthSnapshot>;
|
export declare function rebuildHealth(
|
||||||
|
store: PulseStore,
|
||||||
|
): Promise<HealthSnapshot>;
|
||||||
|
|||||||
Vendored
+7
-1
@@ -1,4 +1,10 @@
|
|||||||
export { adaptiveInterval, clampTick, dedup, errorBackoff } from './builtin.js';
|
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 {
|
||||||
|
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 HealthSnapshot, rebuildHealth } from './health.js';
|
||||||
export { type SurvivalSnapshot, survivalRules } from './survival.js';
|
export { type SurvivalSnapshot, survivalRules } from './survival.js';
|
||||||
|
|||||||
+9
-10
@@ -13,17 +13,17 @@ import type { SystemResourceData } from '../watchers/system-resource.js';
|
|||||||
import type { HealthSnapshot } from './health.js';
|
import type { HealthSnapshot } from './health.js';
|
||||||
/** Minimal LLM health shape used by llmWatchdog (full type lives in @uncaged/pulse-openclaw) */
|
/** Minimal LLM health shape used by llmWatchdog (full type lives in @uncaged/pulse-openclaw) */
|
||||||
interface LlmHealthLike {
|
interface LlmHealthLike {
|
||||||
processOk: boolean;
|
processOk: boolean;
|
||||||
completionOk?: boolean;
|
completionOk?: boolean;
|
||||||
}
|
}
|
||||||
export interface SurvivalSnapshot {
|
export interface SurvivalSnapshot {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
system?: Sensed<SystemResourceData>;
|
system?: Sensed<SystemResourceData>;
|
||||||
processes?: Sensed<ProcessAliveData>;
|
processes?: Sensed<ProcessAliveData>;
|
||||||
network?: Sensed<NetworkData>;
|
network?: Sensed<NetworkData>;
|
||||||
errorLog?: Sensed<ErrorLogData>;
|
errorLog?: Sensed<ErrorLogData>;
|
||||||
llm?: Sensed<LlmHealthLike>;
|
llm?: Sensed<LlmHealthLike>;
|
||||||
health?: HealthSnapshot;
|
health?: HealthSnapshot;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Panic rollback - outermost fallback
|
* Panic rollback - outermost fallback
|
||||||
@@ -58,4 +58,3 @@ export declare const errorEscalate: Rule<SurvivalSnapshot, SurvivalEffect>;
|
|||||||
* Survival rules in onion order (first element is outermost layer)
|
* Survival rules in onion order (first element is outermost layer)
|
||||||
*/
|
*/
|
||||||
export declare const survivalRules: Rule<SurvivalSnapshot, SurvivalEffect>[];
|
export declare const survivalRules: Rule<SurvivalSnapshot, SurvivalEffect>[];
|
||||||
export {};
|
|
||||||
|
|||||||
Vendored
+89
-76
@@ -6,92 +6,105 @@
|
|||||||
*/
|
*/
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
export interface EventRecord {
|
export interface EventRecord {
|
||||||
id: number;
|
id: number;
|
||||||
occurredAt: number;
|
occurredAt: number;
|
||||||
kind: string;
|
kind: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
hash?: string;
|
hash?: string;
|
||||||
codeRev?: string;
|
codeRev?: string;
|
||||||
meta?: string;
|
meta?: string;
|
||||||
objectId?: number;
|
objectId?: number;
|
||||||
}
|
}
|
||||||
/** An immutable entity instance tracked in the objects table. */
|
/** An immutable entity instance tracked in the objects table. */
|
||||||
export interface ObjectInstance {
|
export interface ObjectInstance {
|
||||||
id: number;
|
id: number;
|
||||||
objectType: string;
|
objectType: string;
|
||||||
externalId: string | null;
|
externalId: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
}
|
}
|
||||||
export interface PulseStore {
|
export interface PulseStore {
|
||||||
/** Append one event (id is auto-incremented) */
|
/** Append one event (id is auto-incremented) */
|
||||||
appendEvent(event: Omit<EventRecord, 'id'>): Promise<EventRecord>;
|
appendEvent(event: Omit<EventRecord, 'id'>): Promise<EventRecord>;
|
||||||
/** Append multiple events in a transaction */
|
/** Append multiple events in a transaction */
|
||||||
appendEvents(events: Omit<EventRecord, 'id'>[]): Promise<EventRecord[]>;
|
appendEvents(events: Omit<EventRecord, 'id'>[]): Promise<EventRecord[]>;
|
||||||
/** Create an immutable object instance. Returns the integer id. Idempotent on (objectType, externalId). */
|
/** Create an immutable object instance. Returns the integer id. Idempotent on (objectType, externalId). */
|
||||||
createObject(opts: {
|
createObject(opts: {
|
||||||
objectType: string;
|
objectType: string;
|
||||||
externalId?: string;
|
externalId?: string;
|
||||||
codeRev: string;
|
codeRev: string;
|
||||||
}): Promise<number>;
|
}): Promise<number>;
|
||||||
/** Get an object instance by id. Returns null if not found. */
|
/** Get an object instance by id. Returns null if not found. */
|
||||||
getObjectInstance(id: number): Promise<ObjectInstance | null>;
|
getObjectInstance(id: number): Promise<ObjectInstance | null>;
|
||||||
/** Query object instances by type. */
|
/** Query object instances by type. */
|
||||||
queryObjectsByType(objectType: string): Promise<ObjectInstance[]>;
|
queryObjectsByType(objectType: string): Promise<ObjectInstance[]>;
|
||||||
/** Get the latest event by kind + optional key */
|
/** Get the latest event by kind + optional key */
|
||||||
getLatest(kind: string, key?: string): Promise<EventRecord | null>;
|
getLatest(kind: string, key?: string): Promise<EventRecord | null>;
|
||||||
/** Get latest event with additional filters */
|
/** Get latest event with additional filters */
|
||||||
getLatestWhere(opts: {
|
getLatestWhere(opts: {
|
||||||
kind: string;
|
kind: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
codeRev?: string;
|
codeRev?: string;
|
||||||
}): Promise<EventRecord | null>;
|
}): Promise<EventRecord | null>;
|
||||||
/** Get recent events (newest first) */
|
/** Get recent events (newest first) */
|
||||||
getRecent(limit?: number): Promise<EventRecord[]>;
|
getRecent(limit?: number): Promise<EventRecord[]>;
|
||||||
/** Query events by kind with optional filters */
|
/** Query events by kind with optional filters */
|
||||||
queryByKind(kind: string, opts?: {
|
queryByKind(
|
||||||
key?: string;
|
kind: string,
|
||||||
since?: number;
|
opts?: {
|
||||||
codeRev?: string;
|
key?: string;
|
||||||
limit?: number;
|
since?: number;
|
||||||
}): Promise<EventRecord[]>;
|
codeRev?: string;
|
||||||
/** Get all events after a specific event id */
|
limit?: number;
|
||||||
getAfter(afterId: number, opts?: {
|
},
|
||||||
kind?: string;
|
): Promise<EventRecord[]>;
|
||||||
key?: string;
|
/** Get all events after a specific event id */
|
||||||
codeRev?: string;
|
getAfter(
|
||||||
}): Promise<EventRecord[]>;
|
afterId: number,
|
||||||
/** Check if any events exist */
|
opts?: {
|
||||||
hasEvents(): Promise<boolean>;
|
kind?: string;
|
||||||
/** Write data to CAS store. Returns hash. No-op if already exists. */
|
key?: string;
|
||||||
putObject(data: unknown): Promise<string>;
|
codeRev?: string;
|
||||||
/** Read data from CAS store by hash. Returns null if not found. */
|
},
|
||||||
getObject(hash: string): Promise<unknown | null>;
|
): Promise<EventRecord[]>;
|
||||||
/** Close the database */
|
/** Check if any events exist */
|
||||||
close(): Promise<void>;
|
hasEvents(): Promise<boolean>;
|
||||||
/** Delete events older than the given timestamp. Returns count of deleted rows. */
|
/** Write data to CAS store. Returns hash. No-op if already exists. */
|
||||||
archiveEvents(olderThan: number): Promise<number>;
|
putObject(data: unknown): Promise<string>;
|
||||||
/** Downsample events of a specific kind+key: keep one per interval window. Returns count of deleted rows. */
|
/** Read data from CAS store by hash. Returns null if not found. */
|
||||||
downsampleEvents(kind: string, key: string, intervalMs: number, olderThan: number): Promise<number>;
|
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 {
|
export interface CreateStoreOptions {
|
||||||
eventsDbPath: string;
|
eventsDbPath: string;
|
||||||
/** @deprecated Vitals now use events table via scoped store. This field is accepted but ignored. */
|
/** @deprecated Vitals now use events table via scoped store. This field is accepted but ignored. */
|
||||||
vitalsDbPath?: string;
|
vitalsDbPath?: string;
|
||||||
objectsDir: string;
|
objectsDir: string;
|
||||||
}
|
}
|
||||||
export declare function createStore(options: CreateStoreOptions): PulseStore;
|
export declare function createStore(options: CreateStoreOptions): PulseStore;
|
||||||
export interface CreateScopedStoreOptions {
|
export interface CreateScopedStoreOptions {
|
||||||
basePath: string;
|
basePath: string;
|
||||||
objectsDir: string;
|
objectsDir: string;
|
||||||
}
|
}
|
||||||
export interface ScopedStore {
|
export interface ScopedStore {
|
||||||
scope(name: string): PulseStore;
|
scope(name: string): PulseStore;
|
||||||
listScopes(): string[];
|
listScopes(): string[];
|
||||||
/** Get underlying Database for scope (used by projection engine) */
|
/** Get underlying Database for scope (used by projection engine) */
|
||||||
scopeDatabase(name: string): Database;
|
scopeDatabase(name: string): Database;
|
||||||
putObject(data: unknown): Promise<string>;
|
putObject(data: unknown): Promise<string>;
|
||||||
getObject(hash: string): Promise<unknown | null>;
|
getObject(hash: string): Promise<unknown | null>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
export declare function createScopedStore(options: CreateScopedStoreOptions): ScopedStore;
|
export declare function createScopedStore(
|
||||||
|
options: CreateScopedStoreOptions,
|
||||||
|
): ScopedStore;
|
||||||
|
|||||||
@@ -1,525 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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
+79
-79
@@ -1,121 +1,121 @@
|
|||||||
export type TaskStatus = 'pending' | 'routing' | 'assigned' | 'closed';
|
export type TaskStatus = 'pending' | 'routing' | 'assigned' | 'closed';
|
||||||
export type TaskType = 'bug' | 'rfc' | 'action' | 'review';
|
export type TaskType = 'bug' | 'rfc' | 'action' | 'review';
|
||||||
export interface TaskCreatedMeta {
|
export interface TaskCreatedMeta {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: TaskType;
|
type: TaskType;
|
||||||
priority: number;
|
priority: number;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
}
|
}
|
||||||
export interface TaskRoutingMeta {
|
export interface TaskRoutingMeta {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
brokerSessionId: string;
|
brokerSessionId: string;
|
||||||
}
|
}
|
||||||
export interface TaskAssignedMeta {
|
export interface TaskAssignedMeta {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
assigneeId: string;
|
assigneeId: string;
|
||||||
assignedBy: string;
|
assignedBy: string;
|
||||||
}
|
}
|
||||||
export interface TaskRespondedMeta {
|
export interface TaskRespondedMeta {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
assigneeId: string;
|
assigneeId: string;
|
||||||
result: string;
|
result: string;
|
||||||
}
|
}
|
||||||
export interface TaskClosedMeta {
|
export interface TaskClosedMeta {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
}
|
}
|
||||||
export interface ProjectCreatedMeta {
|
export interface ProjectCreatedMeta {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
}
|
}
|
||||||
export interface TaskState {
|
export interface TaskState {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: TaskType;
|
type: TaskType;
|
||||||
priority: number;
|
priority: number;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
lastRespondedResult?: string;
|
lastRespondedResult?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
export interface PendingTasksData {
|
export interface PendingTasksData {
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
tasks: TaskState[];
|
tasks: TaskState[];
|
||||||
byProject: Record<string, TaskState[]>;
|
byProject: Record<string, TaskState[]>;
|
||||||
checkedAt: number;
|
checkedAt: number;
|
||||||
}
|
}
|
||||||
export interface ProjectState {
|
export interface ProjectState {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
}
|
}
|
||||||
export interface InflightBrokerData {
|
export interface InflightBrokerData {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
export type ContainerType = 'openclaw' | 'cursor' | 'claude-code' | 'hermes';
|
export type ContainerType = 'openclaw' | 'cursor' | 'claude-code' | 'hermes';
|
||||||
export type ContainerStatus = 'online' | 'offline' | 'busy';
|
export type ContainerStatus = 'online' | 'offline' | 'busy';
|
||||||
export interface PersonaRegisteredMeta {
|
export interface PersonaRegisteredMeta {
|
||||||
personaId: string;
|
personaId: string;
|
||||||
name: string;
|
name: string;
|
||||||
container: ContainerType;
|
container: ContainerType;
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
}
|
}
|
||||||
export interface PersonaUpdatedMeta {
|
export interface PersonaUpdatedMeta {
|
||||||
personaId: string;
|
personaId: string;
|
||||||
container?: ContainerType;
|
container?: ContainerType;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
}
|
}
|
||||||
export interface PersonaState {
|
export interface PersonaState {
|
||||||
personaId: string;
|
personaId: string;
|
||||||
name: string;
|
name: string;
|
||||||
container: ContainerType;
|
container: ContainerType;
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
registeredAt: number;
|
registeredAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
export interface LlmCallStartedMeta {
|
export interface LlmCallStartedMeta {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
}
|
}
|
||||||
export interface LlmCallCompletedMeta {
|
export interface LlmCallCompletedMeta {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
usage?: {
|
usage?: {
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
};
|
};
|
||||||
toolCalls?: Array<{
|
toolCalls?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
arguments: Record<string, unknown>;
|
arguments: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
}
|
}
|
||||||
export interface ToolResponseMeta {
|
export interface ToolResponseMeta {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
toolCallIndex: number;
|
toolCallIndex: number;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
result: string;
|
result: string;
|
||||||
}
|
}
|
||||||
export interface TraceMessage {
|
export interface TraceMessage {
|
||||||
role: 'assistant' | 'tool';
|
role: 'assistant' | 'tool';
|
||||||
content?: string;
|
content?: string;
|
||||||
toolCalls?: LlmCallCompletedMeta['toolCalls'];
|
toolCalls?: LlmCallCompletedMeta['toolCalls'];
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
result?: string;
|
result?: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
}
|
}
|
||||||
export interface AgentLoopTraceData {
|
export interface AgentLoopTraceData {
|
||||||
messages: TraceMessage[];
|
messages: TraceMessage[];
|
||||||
nextCheckAt: number;
|
nextCheckAt: number;
|
||||||
}
|
}
|
||||||
export interface ActiveProjectsData {
|
export interface ActiveProjectsData {
|
||||||
projectIds: string[];
|
projectIds: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
Vendored
+33
-27
@@ -11,47 +11,49 @@ import type { EventRecord, PulseStore } from './store.js';
|
|||||||
* without re-fetching from the object store.
|
* without re-fetching from the object store.
|
||||||
*/
|
*/
|
||||||
export interface VitalWithData<T = unknown> extends EventRecord {
|
export interface VitalWithData<T = unknown> extends EventRecord {
|
||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Predicate evaluated against a recent window of vital records
|
* Predicate evaluated against a recent window of vital records
|
||||||
* with their resolved data payloads.
|
* with their resolved data payloads.
|
||||||
* Return `true` to fire the wake callback.
|
* Return `true` to fire the wake callback.
|
||||||
*/
|
*/
|
||||||
export type WakeCondition<T = unknown> = (window: VitalWithData<T>[]) => boolean;
|
export type WakeCondition<T = unknown> = (
|
||||||
|
window: VitalWithData<T>[],
|
||||||
|
) => boolean;
|
||||||
/**
|
/**
|
||||||
* Definition of a watcher: what to collect, how often, and when to wake.
|
* Definition of a watcher: what to collect, how often, and when to wake.
|
||||||
*/
|
*/
|
||||||
export interface WatcherDef<T = unknown> {
|
export interface WatcherDef<T = unknown> {
|
||||||
/** Human-readable name, used in logs and the returned handle. */
|
/** Human-readable name, used in logs and the returned handle. */
|
||||||
name: string;
|
name: string;
|
||||||
/** Vital key under which collected data is stored. */
|
/** Vital key under which collected data is stored. */
|
||||||
key: string;
|
key: string;
|
||||||
/**
|
/**
|
||||||
* Async function that collects a snapshot of data.
|
* Async function that collects a snapshot of data.
|
||||||
* The returned value is persisted via CAS and referenced in the vital record.
|
* The returned value is persisted via CAS and referenced in the vital record.
|
||||||
*/
|
*/
|
||||||
collect: () => Promise<T>;
|
collect: () => Promise<T>;
|
||||||
/**
|
/**
|
||||||
* Evaluated after each collection against the last 12 vital records
|
* Evaluated after each collection against the last 12 vital records
|
||||||
* with their resolved data payloads.
|
* with their resolved data payloads.
|
||||||
* When it returns `true`, `wakeTick` is invoked.
|
* When it returns `true`, `wakeTick` is invoked.
|
||||||
*/
|
*/
|
||||||
shouldWake: WakeCondition<T>;
|
shouldWake: WakeCondition<T>;
|
||||||
/**
|
/**
|
||||||
* Collection interval in milliseconds.
|
* Collection interval in milliseconds.
|
||||||
* @default 5000
|
* @default 5000
|
||||||
*/
|
*/
|
||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Returned by `startWatcher`. Allows the caller to identify and stop the loop.
|
* Returned by `startWatcher`. Allows the caller to identify and stop the loop.
|
||||||
*/
|
*/
|
||||||
export interface WatcherHandle {
|
export interface WatcherHandle {
|
||||||
/** The watcher's name, as provided in {@link WatcherDef}. */
|
/** The watcher's name, as provided in {@link WatcherDef}. */
|
||||||
name: string;
|
name: string;
|
||||||
/** Stop the collection loop. The current in-flight tick completes first. */
|
/** Stop the collection loop. The current in-flight tick completes first. */
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Start a periodic collection loop for the given watcher definition.
|
* Start a periodic collection loop for the given watcher definition.
|
||||||
@@ -70,4 +72,8 @@ export interface WatcherHandle {
|
|||||||
* @param wakeTick Callback invoked when `def.shouldWake` returns `true`.
|
* @param wakeTick Callback invoked when `def.shouldWake` returns `true`.
|
||||||
* @returns A handle that exposes the watcher name and a `stop` function.
|
* @returns A handle that exposes the watcher name and a `stop` function.
|
||||||
*/
|
*/
|
||||||
export declare function startWatcher(def: WatcherDef, store: PulseStore, wakeTick: () => void): WatcherHandle;
|
export declare function startWatcher(
|
||||||
|
def: WatcherDef,
|
||||||
|
store: PulseStore,
|
||||||
|
wakeTick: () => void,
|
||||||
|
): WatcherHandle;
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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));
|
|
||||||
}
|
|
||||||
+9
-7
@@ -1,12 +1,14 @@
|
|||||||
import type { WatcherDef } from '../watcher.js';
|
import type { WatcherDef } from '../watcher.js';
|
||||||
export interface ErrorLogData {
|
export interface ErrorLogData {
|
||||||
matches: string[];
|
matches: string[];
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
export interface ErrorLogOptions {
|
export interface ErrorLogOptions {
|
||||||
/** 要监控的日志文件路径列表 */
|
/** 要监控的日志文件路径列表 */
|
||||||
logFiles: string[];
|
logFiles: string[];
|
||||||
/** 触发唤醒的关键词 */
|
/** 触发唤醒的关键词 */
|
||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
}
|
}
|
||||||
export declare function errorLogWatcher(opts: ErrorLogOptions): WatcherDef<ErrorLogData>;
|
export declare function errorLogWatcher(
|
||||||
|
opts: ErrorLogOptions,
|
||||||
|
): WatcherDef<ErrorLogData>;
|
||||||
|
|||||||
+20
-4
@@ -1,4 +1,20 @@
|
|||||||
export { type ErrorLogData, type ErrorLogOptions, errorLogWatcher, } from './error-log.js';
|
export {
|
||||||
export { type NetworkData, type NetworkOptions, networkWatcher, } from './network.js';
|
type ErrorLogData,
|
||||||
export { type ProcessAliveData, type ProcessAliveOptions, processAliveWatcher, } from './process-alive.js';
|
type ErrorLogOptions,
|
||||||
export { type SystemResourceData, type SystemResourceOptions, systemResourceWatcher, } from './system-resource.js';
|
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';
|
||||||
|
|||||||
+11
-9
@@ -1,15 +1,17 @@
|
|||||||
import * as dns from 'node:dns';
|
import * as dns from 'node:dns';
|
||||||
import type { WatcherDef } from '../watcher.js';
|
import type { WatcherDef } from '../watcher.js';
|
||||||
export interface NetworkData {
|
export interface NetworkData {
|
||||||
dnsOk: boolean;
|
dnsOk: boolean;
|
||||||
httpOk: boolean;
|
httpOk: boolean;
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
}
|
}
|
||||||
export interface NetworkOptions {
|
export interface NetworkOptions {
|
||||||
dnsHost?: string;
|
dnsHost?: string;
|
||||||
httpUrl?: string;
|
httpUrl?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
/** Inject DNS resolve function for testing */
|
/** Inject DNS resolve function for testing */
|
||||||
dnsResolveFn?: typeof dns.promises.resolve;
|
dnsResolveFn?: typeof dns.promises.resolve;
|
||||||
}
|
}
|
||||||
export declare function networkWatcher(opts?: NetworkOptions): WatcherDef<NetworkData>;
|
export declare function networkWatcher(
|
||||||
|
opts?: NetworkOptions,
|
||||||
|
): WatcherDef<NetworkData>;
|
||||||
|
|||||||
+8
-6
@@ -1,12 +1,14 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import type { WatcherDef } from '../watcher.js';
|
import type { WatcherDef } from '../watcher.js';
|
||||||
export interface ProcessAliveData {
|
export interface ProcessAliveData {
|
||||||
processes: Record<string, boolean>;
|
processes: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
export interface ProcessAliveOptions {
|
export interface ProcessAliveOptions {
|
||||||
/** 要监控的进程列表:name → 匹配命令行的关键词 */
|
/** 要监控的进程列表:name → 匹配命令行的关键词 */
|
||||||
processes: Record<string, string>;
|
processes: Record<string, string>;
|
||||||
/** Inject execSync for testing */
|
/** Inject execSync for testing */
|
||||||
execSyncFn?: typeof execSync;
|
execSyncFn?: typeof execSync;
|
||||||
}
|
}
|
||||||
export declare function processAliveWatcher(opts: ProcessAliveOptions): WatcherDef<ProcessAliveData>;
|
export declare function processAliveWatcher(
|
||||||
|
opts: ProcessAliveOptions,
|
||||||
|
): WatcherDef<ProcessAliveData>;
|
||||||
|
|||||||
+16
-14
@@ -3,20 +3,22 @@ import * as fs from 'node:fs';
|
|||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import type { WatcherDef } from '../watcher.js';
|
import type { WatcherDef } from '../watcher.js';
|
||||||
export interface SystemResourceData {
|
export interface SystemResourceData {
|
||||||
cpuPct: number;
|
cpuPct: number;
|
||||||
memoryPct: number;
|
memoryPct: number;
|
||||||
diskPct: number;
|
diskPct: number;
|
||||||
swapPct: number;
|
swapPct: number;
|
||||||
}
|
}
|
||||||
export interface SystemResourceOptions {
|
export interface SystemResourceOptions {
|
||||||
memoryThreshold?: number;
|
memoryThreshold?: number;
|
||||||
diskThreshold?: number;
|
diskThreshold?: number;
|
||||||
sustainedSeconds?: number;
|
sustainedSeconds?: number;
|
||||||
/** Inject execSync for testing */
|
/** Inject execSync for testing */
|
||||||
execSyncFn?: typeof execSync;
|
execSyncFn?: typeof execSync;
|
||||||
/** Inject fs module for testing */
|
/** Inject fs module for testing */
|
||||||
fsFn?: typeof fs;
|
fsFn?: typeof fs;
|
||||||
/** Inject os module for testing */
|
/** Inject os module for testing */
|
||||||
osFn?: typeof os;
|
osFn?: typeof os;
|
||||||
}
|
}
|
||||||
export declare function systemResourceWatcher(opts?: SystemResourceOptions): WatcherDef<SystemResourceData>;
|
export declare function systemResourceWatcher(
|
||||||
|
opts?: SystemResourceOptions,
|
||||||
|
): WatcherDef<SystemResourceData>;
|
||||||
|
|||||||
+33
-6
@@ -3,20 +3,47 @@
|
|||||||
*
|
*
|
||||||
* 小橘 🍊 (NEKO Team)
|
* 小橘 🍊 (NEKO Team)
|
||||||
*/
|
*/
|
||||||
export type { MetaCoderMeta, MetaTesterMeta, } from './meta.js';
|
export type { MetaCoderMeta, MetaTesterMeta } from './meta.js';
|
||||||
export { createMetaWorkflow } from './meta.js';
|
export { createMetaWorkflow } from './meta.js';
|
||||||
export { type AgentExecutorConfig, type AgentResult, type AgentRunner, createAgentExecutorRole, createCursorRunner, } from './roles/agent-executor.js';
|
export {
|
||||||
export type { LlmRoleConfig, ToolRoleConfig, } from './roles/llm-role-factory.js';
|
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 { createLlmRole, createToolRole } from './roles/llm-role-factory.js';
|
||||||
export { createMetaCoderRole } from './roles/meta-coder-cursor.js';
|
export { createMetaCoderRole } from './roles/meta-coder-cursor.js';
|
||||||
export { createMetaTesterRole } from './roles/meta-tester.js';
|
export { createMetaTesterRole } from './roles/meta-tester.js';
|
||||||
export { createMetaCheckerRole } from './roles/meta-checker.js';
|
export { createMetaCheckerRole } from './roles/meta-checker.js';
|
||||||
export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js';
|
export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js';
|
||||||
export { createWorkflowRule, type WorkflowRule, type WorkflowTickResult, } from './workflow-rule-adapter.js';
|
export {
|
||||||
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';
|
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';
|
import type { WorkflowRule } from './workflow-rule-adapter.js';
|
||||||
/**
|
/**
|
||||||
* createWorkflowTicker — wraps multiple WorkflowRules into a single async function
|
* createWorkflowTicker — wraps multiple WorkflowRules into a single async function
|
||||||
* suitable for calling at the end of a runPulse tick cycle.
|
* suitable for calling at the end of a runPulse tick cycle.
|
||||||
*/
|
*/
|
||||||
export declare function createWorkflowTicker(rules: WorkflowRule[]): () => Promise<void>;
|
export declare function createWorkflowTicker(
|
||||||
|
rules: WorkflowRule[],
|
||||||
|
): () => Promise<void>;
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ export type {
|
|||||||
ToolRoleConfig,
|
ToolRoleConfig,
|
||||||
} from './roles/llm-role-factory.js';
|
} from './roles/llm-role-factory.js';
|
||||||
export { createLlmRole, createToolRole } from './roles/llm-role-factory.js';
|
export { createLlmRole, createToolRole } from './roles/llm-role-factory.js';
|
||||||
|
export { createMetaCheckerRole } from './roles/meta-checker.js';
|
||||||
export { createMetaCoderRole } from './roles/meta-coder-cursor.js';
|
export { createMetaCoderRole } from './roles/meta-coder-cursor.js';
|
||||||
export { createMetaTesterRole } from './roles/meta-tester.js';
|
export { createMetaTesterRole } from './roles/meta-tester.js';
|
||||||
export { createMetaCheckerRole } from './roles/meta-checker.js';
|
|
||||||
export {
|
|
||||||
type SubprocessRoleConfig,
|
|
||||||
createSubprocessRole,
|
|
||||||
} from './subprocess-role.js';
|
|
||||||
export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js';
|
export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js';
|
||||||
|
export {
|
||||||
|
createSubprocessRole,
|
||||||
|
type SubprocessRoleConfig,
|
||||||
|
} from './subprocess-role.js';
|
||||||
export {
|
export {
|
||||||
createWorkflowRule,
|
createWorkflowRule,
|
||||||
type WorkflowRule,
|
type WorkflowRule,
|
||||||
|
|||||||
+19
-17
@@ -17,27 +17,29 @@
|
|||||||
*/
|
*/
|
||||||
import { type Role, type WorkflowType } from './workflow-type.js';
|
import { type Role, type WorkflowType } from './workflow-type.js';
|
||||||
export interface MetaCoderMeta {
|
export interface MetaCoderMeta {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
filesChanged: string[];
|
filesChanged: string[];
|
||||||
testsPassed: boolean;
|
testsPassed: boolean;
|
||||||
}
|
}
|
||||||
export interface MetaCheckerMeta {
|
export interface MetaCheckerMeta {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
pass: boolean;
|
pass: boolean;
|
||||||
reason: string;
|
reason: string;
|
||||||
violations?: string[];
|
violations?: string[];
|
||||||
}
|
}
|
||||||
export interface MetaTesterMeta {
|
export interface MetaTesterMeta {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
pass: boolean;
|
pass: boolean;
|
||||||
reason: string;
|
reason: string;
|
||||||
/** Only present when pass=true */
|
/** Only present when pass=true */
|
||||||
commitHash?: string;
|
commitHash?: string;
|
||||||
pushed?: boolean;
|
pushed?: boolean;
|
||||||
}
|
}
|
||||||
export type MetaWorkflowRoles = {
|
export type MetaWorkflowRoles = {
|
||||||
coder: Role<MetaCoderMeta>;
|
coder: Role<MetaCoderMeta>;
|
||||||
checker: Role<MetaCheckerMeta>;
|
checker: Role<MetaCheckerMeta>;
|
||||||
tester: Role<MetaTesterMeta>;
|
tester: Role<MetaTesterMeta>;
|
||||||
};
|
};
|
||||||
export declare function createMetaWorkflow(roles: MetaWorkflowRoles): WorkflowType<MetaWorkflowRoles>;
|
export declare function createMetaWorkflow(
|
||||||
|
roles: MetaWorkflowRoles,
|
||||||
|
): WorkflowType<MetaWorkflowRoles>;
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ import { mkdtempSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { createStore } from '../store.js';
|
import { createStore } from '../store.js';
|
||||||
import {
|
import { createMetaWorkflow } from './meta.js';
|
||||||
createMetaWorkflow,
|
|
||||||
type MetaCheckerMeta,
|
|
||||||
type MetaCoderMeta,
|
|
||||||
type MetaTesterMeta,
|
|
||||||
} from './meta.js';
|
|
||||||
import { createWorkflowRule } from './workflow-rule-adapter.js';
|
import { createWorkflowRule } from './workflow-rule-adapter.js';
|
||||||
import { END, START } from './workflow-type.js';
|
import { END, START } from './workflow-type.js';
|
||||||
|
|
||||||
@@ -37,7 +32,11 @@ const mockCheckerPass = async () => ({
|
|||||||
|
|
||||||
const mockCheckerFail = async () => ({
|
const mockCheckerFail = async () => ({
|
||||||
content: 'fail',
|
content: 'fail',
|
||||||
meta: { pass: false, reason: '文件范围越界', violations: ['越界文件: package.json'] },
|
meta: {
|
||||||
|
pass: false,
|
||||||
|
reason: '文件范围越界',
|
||||||
|
violations: ['越界文件: package.json'],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockTesterPass = async () => ({
|
const mockTesterPass = async () => ({
|
||||||
@@ -72,10 +71,7 @@ describe('Meta Workflow', () => {
|
|||||||
),
|
),
|
||||||
).toBe('tester');
|
).toBe('tester');
|
||||||
expect(
|
expect(
|
||||||
wf.moderator(
|
wf.moderator({ role: 'tester', meta: { pass: true, reason: 'ok' } }, 'x'),
|
||||||
{ role: 'tester', meta: { pass: true, reason: 'ok' } },
|
|
||||||
'x',
|
|
||||||
),
|
|
||||||
).toBe(END);
|
).toBe(END);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,7 +196,14 @@ describe('Meta Workflow', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// coder→checker→tester(fail)→coder→checker→tester(pass)
|
// coder→checker→tester(fail)→coder→checker→tester(pass)
|
||||||
expect(roles).toEqual(['coder', 'checker', 'tester', 'coder', 'checker', 'tester']);
|
expect(roles).toEqual([
|
||||||
|
'coder',
|
||||||
|
'checker',
|
||||||
|
'tester',
|
||||||
|
'coder',
|
||||||
|
'checker',
|
||||||
|
'tester',
|
||||||
|
]);
|
||||||
await store.close();
|
await store.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+30
-23
@@ -10,40 +10,47 @@
|
|||||||
import type { LlmClient, LlmTool } from '../../llm-client.js';
|
import type { LlmClient, LlmTool } from '../../llm-client.js';
|
||||||
import type { Role, WorkflowMessage } from '../workflow-type.js';
|
import type { Role, WorkflowMessage } from '../workflow-type.js';
|
||||||
export interface AgentResult {
|
export interface AgentResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
output: string;
|
output: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
}
|
}
|
||||||
export interface AgentRunner {
|
export interface AgentRunner {
|
||||||
run(prompt: string, cwd: string): Promise<AgentResult>;
|
run(prompt: string, cwd: string): Promise<AgentResult>;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Default agent runner — Cursor CLI.
|
* Default agent runner — Cursor CLI.
|
||||||
*/
|
*/
|
||||||
export declare function createCursorRunner(opts: {
|
export declare function createCursorRunner(opts: {
|
||||||
agentBin: string;
|
agentBin: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): AgentRunner;
|
}): AgentRunner;
|
||||||
export interface AgentExecutorConfig<Meta> {
|
export interface AgentExecutorConfig<Meta> {
|
||||||
/** Build prompt + cwd for the agent. */
|
/** Build prompt + cwd for the agent. */
|
||||||
prepPrompt: (chain: WorkflowMessage[], topicId: string) => {
|
prepPrompt: (
|
||||||
prompt: string;
|
chain: WorkflowMessage[],
|
||||||
cwd: string;
|
topicId: string,
|
||||||
};
|
) => {
|
||||||
/** LLM₂ structured output: tool definition for meta extraction. */
|
prompt: string;
|
||||||
parseMeta: {
|
cwd: string;
|
||||||
/** System prompt for the meta-extraction LLM call. */
|
};
|
||||||
system: string;
|
/** LLM₂ structured output: tool definition for meta extraction. */
|
||||||
/** Tool definition — parameters schema defines Meta shape. */
|
parseMeta: {
|
||||||
tool: LlmTool;
|
/** System prompt for the meta-extraction LLM call. */
|
||||||
/** Parse tool_call arguments into Meta. Falls back to defaultMeta on failure. */
|
system: string;
|
||||||
parse: (args: string) => Meta;
|
/** Tool definition — parameters schema defines Meta shape. */
|
||||||
/** Fallback when LLM₂ fails or returns no tool_call. */
|
tool: LlmTool;
|
||||||
defaultMeta: (output: string) => Meta;
|
/** Parse tool_call arguments into Meta. Falls back to defaultMeta on failure. */
|
||||||
};
|
parse: (args: string) => Meta;
|
||||||
|
/** Fallback when LLM₂ fails or returns no tool_call. */
|
||||||
|
defaultMeta: (output: string) => Meta;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Create a pure Role from an agent executor config.
|
* Create a pure Role from an agent executor config.
|
||||||
* The Role runs: prepPrompt → agent → LLM₂ parse → { content, meta }.
|
* The Role runs: prepPrompt → agent → LLM₂ parse → { content, meta }.
|
||||||
*/
|
*/
|
||||||
export declare function createAgentExecutorRole<Meta>(agent: AgentRunner, llm: LlmClient, config: AgentExecutorConfig<Meta>): Role<Meta>;
|
export declare function createAgentExecutorRole<Meta>(
|
||||||
|
agent: AgentRunner,
|
||||||
|
llm: LlmClient,
|
||||||
|
config: AgentExecutorConfig<Meta>,
|
||||||
|
): Role<Meta>;
|
||||||
|
|||||||
+48
-33
@@ -17,47 +17,62 @@
|
|||||||
import type { LlmClient, LlmResponse } from '../../llm-client.js';
|
import type { LlmClient, LlmResponse } from '../../llm-client.js';
|
||||||
import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js';
|
import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js';
|
||||||
export interface LlmRoleConfig<TMeta extends Record<string, unknown>> {
|
export interface LlmRoleConfig<TMeta extends Record<string, unknown>> {
|
||||||
/** System prompt */
|
/** System prompt */
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
/** Build user messages from the workflow chain. Default: last message content. */
|
/** Build user messages from the workflow chain. Default: last message content. */
|
||||||
buildUserMessage?: (chain: WorkflowMessage[]) => string;
|
buildUserMessage?: (chain: WorkflowMessage[]) => string;
|
||||||
/** Tool definitions for structured output */
|
/** Tool definitions for structured output */
|
||||||
tools?: Array<{
|
tools?: Array<{
|
||||||
type: 'function';
|
type: 'function';
|
||||||
function: {
|
function: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
/** Tool choice strategy */
|
/** Tool choice strategy */
|
||||||
toolChoice?: 'auto' | 'required';
|
toolChoice?: 'auto' | 'required';
|
||||||
/** Parse the LLM response into role result. */
|
/** Parse the LLM response into role result. */
|
||||||
parseResponse: (resp: LlmResponse, chain: WorkflowMessage[]) => RoleResult<TMeta>;
|
parseResponse: (
|
||||||
|
resp: LlmResponse,
|
||||||
|
chain: WorkflowMessage[],
|
||||||
|
) => RoleResult<TMeta>;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Create a reusable LLM role from config.
|
* Create a reusable LLM role from config.
|
||||||
* All LLM roles share the same call skeleton — only the filling differs.
|
* All LLM roles share the same call skeleton — only the filling differs.
|
||||||
*/
|
*/
|
||||||
export declare function createLlmRole<TMeta extends Record<string, unknown>>(llm: LlmClient, config: LlmRoleConfig<TMeta>): Role<TMeta>;
|
export declare function createLlmRole<TMeta extends Record<string, unknown>>(
|
||||||
export interface ToolRoleConfig<TMeta extends Record<string, unknown>, TToolResult = unknown> {
|
llm: LlmClient,
|
||||||
systemPrompt: string;
|
config: LlmRoleConfig<TMeta>,
|
||||||
buildUserMessage?: (chain: WorkflowMessage[]) => string;
|
): Role<TMeta>;
|
||||||
tool: {
|
export interface ToolRoleConfig<
|
||||||
type: 'function';
|
TMeta extends Record<string, unknown>,
|
||||||
function: {
|
TToolResult = unknown,
|
||||||
name: string;
|
> {
|
||||||
description: string;
|
systemPrompt: string;
|
||||||
parameters: Record<string, unknown>;
|
buildUserMessage?: (chain: WorkflowMessage[]) => string;
|
||||||
};
|
tool: {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
/** Default result if tool call parsing fails */
|
};
|
||||||
defaultResult: TToolResult;
|
/** Default result if tool call parsing fails */
|
||||||
/** Convert parsed tool result to role result */
|
defaultResult: TToolResult;
|
||||||
toRoleResult: (parsed: TToolResult, chain: WorkflowMessage[]) => RoleResult<TMeta>;
|
/** Convert parsed tool result to role result */
|
||||||
|
toRoleResult: (
|
||||||
|
parsed: TToolResult,
|
||||||
|
chain: WorkflowMessage[],
|
||||||
|
) => RoleResult<TMeta>;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Create an LLM role that uses tool_choice: required for structured output.
|
* Create an LLM role that uses tool_choice: required for structured output.
|
||||||
* Handles tool_call parsing and fallback automatically.
|
* Handles tool_call parsing and fallback automatically.
|
||||||
*/
|
*/
|
||||||
export declare function createToolRole<TMeta extends Record<string, unknown>, TToolResult = unknown>(llm: LlmClient, config: ToolRoleConfig<TMeta, TToolResult>): Role<TMeta>;
|
export declare function createToolRole<
|
||||||
|
TMeta extends Record<string, unknown>,
|
||||||
|
TToolResult = unknown,
|
||||||
|
>(llm: LlmClient, config: ToolRoleConfig<TMeta, TToolResult>): Role<TMeta>;
|
||||||
|
|||||||
+8
-8
@@ -13,14 +13,14 @@
|
|||||||
*/
|
*/
|
||||||
import type { Role } from '../workflow-type.js';
|
import type { Role } from '../workflow-type.js';
|
||||||
export interface MetaCheckerMeta {
|
export interface MetaCheckerMeta {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
pass: boolean;
|
pass: boolean;
|
||||||
reason: string;
|
reason: string;
|
||||||
violations?: string[];
|
violations?: string[];
|
||||||
}
|
}
|
||||||
export declare function createMetaCheckerRole(opts: {
|
export declare function createMetaCheckerRole(opts: {
|
||||||
/** Engine repo directory (allowed file scope) */
|
/** Engine repo directory (allowed file scope) */
|
||||||
engineDir: string;
|
engineDir: string;
|
||||||
/** Extra allowed path prefixes (optional) */
|
/** Extra allowed path prefixes (optional) */
|
||||||
allowedPrefixes?: string[];
|
allowedPrefixes?: string[];
|
||||||
}): Role<MetaCheckerMeta>;
|
}): Role<MetaCheckerMeta>;
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export function createMetaCheckerRole(opts: {
|
|||||||
let changedFiles: string[] = [];
|
let changedFiles: string[] = [];
|
||||||
try {
|
try {
|
||||||
// Get all uncommitted changes + last commit changes
|
// Get all uncommitted changes + last commit changes
|
||||||
const diffOutput = exec('git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only');
|
const diffOutput = exec(
|
||||||
|
'git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only',
|
||||||
|
);
|
||||||
changedFiles = diffOutput.split('\n').filter(Boolean);
|
changedFiles = diffOutput.split('\n').filter(Boolean);
|
||||||
} catch {
|
} catch {
|
||||||
// No git history — check working tree
|
// No git history — check working tree
|
||||||
@@ -68,7 +70,9 @@ export function createMetaCheckerRole(opts: {
|
|||||||
file.startsWith(prefix),
|
file.startsWith(prefix),
|
||||||
);
|
);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
violations.push(`越界文件: ${file}(只允许修改 ${allowedPrefixes.join(', ')} 下的文件)`);
|
violations.push(
|
||||||
|
`越界文件: ${file}(只允许修改 ${allowedPrefixes.join(', ')} 下的文件)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,8 @@ import type { LlmClient } from '../../llm-client.js';
|
|||||||
import type { MetaCoderMeta } from '../meta.js';
|
import type { MetaCoderMeta } from '../meta.js';
|
||||||
import type { Role } from '../workflow-type.js';
|
import type { Role } from '../workflow-type.js';
|
||||||
import { type AgentRunner } from './agent-executor.js';
|
import { type AgentRunner } from './agent-executor.js';
|
||||||
export declare function createMetaCoderRole(runner: AgentRunner, llm: LlmClient, repoDir: string): Role<MetaCoderMeta>;
|
export declare function createMetaCoderRole(
|
||||||
|
runner: AgentRunner,
|
||||||
|
llm: LlmClient,
|
||||||
|
repoDir: string,
|
||||||
|
): Role<MetaCoderMeta>;
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ export function createMetaCoderRole(
|
|||||||
prepPrompt: (chain, _topicId) => {
|
prepPrompt: (chain, _topicId) => {
|
||||||
const startMsg = chain.find((m) => m.role === '__start__');
|
const startMsg = chain.find((m) => m.role === '__start__');
|
||||||
const taskDescription = startMsg?.content ?? '';
|
const taskDescription = startMsg?.content ?? '';
|
||||||
|
|
||||||
// 如果有 tester 失败反馈,附加
|
// 如果有 tester 失败反馈,附加
|
||||||
const testerMsg = [...chain].reverse().find((m) => m.role === 'tester');
|
const testerMsg = [...chain].reverse().find((m) => m.role === 'tester');
|
||||||
const testerFeedback = testerMsg ? `\n\n## 上次验证失败\n${testerMsg.content}` : '';
|
const testerFeedback = testerMsg
|
||||||
|
? `\n\n## 上次验证失败\n${testerMsg.content}`
|
||||||
|
: '';
|
||||||
|
|
||||||
const prompt = `# 任务
|
const prompt = `# 任务
|
||||||
${taskDescription}
|
${taskDescription}
|
||||||
${testerFeedback}
|
${testerFeedback}
|
||||||
|
|||||||
+7
-7
@@ -10,11 +10,11 @@
|
|||||||
import type { MetaTesterMeta } from '../meta.js';
|
import type { MetaTesterMeta } from '../meta.js';
|
||||||
import type { Role } from '../workflow-type.js';
|
import type { Role } from '../workflow-type.js';
|
||||||
export declare function createMetaTesterRole(opts: {
|
export declare function createMetaTesterRole(opts: {
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
/** git remote (auto-detect if omitted) */
|
/** git remote (auto-detect if omitted) */
|
||||||
remote?: string;
|
remote?: string;
|
||||||
/** git branch (default: main) */
|
/** git branch (default: main) */
|
||||||
branch?: string;
|
branch?: string;
|
||||||
/** max ticks for e2e run (default: 100;下限 20 以覆盖 ping-pong 静默 + 较长 workflow) */
|
/** max ticks for e2e run (default: 100;下限 20 以覆盖 ping-pong 静默 + 较长 workflow) */
|
||||||
maxTicks?: number;
|
maxTicks?: number;
|
||||||
}): Role<MetaTesterMeta>;
|
}): Role<MetaTesterMeta>;
|
||||||
|
|||||||
@@ -12,10 +12,15 @@ import { execSync } from 'node:child_process';
|
|||||||
import { existsSync, mkdtempSync, readdirSync } from 'node:fs';
|
import { existsSync, mkdtempSync, readdirSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { MetaTesterMeta } from '../meta.js';
|
|
||||||
import type { Role, RoleResult, WorkflowMessage, WorkflowType } from '../workflow-type.js';
|
|
||||||
import { createStore } from '../../store.js';
|
import { createStore } from '../../store.js';
|
||||||
|
import type { MetaTesterMeta } from '../meta.js';
|
||||||
import { createWorkflowRule } from '../workflow-rule-adapter.js';
|
import { createWorkflowRule } from '../workflow-rule-adapter.js';
|
||||||
|
import type {
|
||||||
|
Role,
|
||||||
|
RoleResult,
|
||||||
|
WorkflowMessage,
|
||||||
|
WorkflowType,
|
||||||
|
} from '../workflow-type.js';
|
||||||
|
|
||||||
export function createMetaTesterRole(opts: {
|
export function createMetaTesterRole(opts: {
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
@@ -60,15 +65,31 @@ export function createMetaTesterRole(opts: {
|
|||||||
const mod = await import(fullPath);
|
const mod = await import(fullPath);
|
||||||
for (const [key, val] of Object.entries(mod)) {
|
for (const [key, val] of Object.entries(mod)) {
|
||||||
// Direct WorkflowType export (e.g. `export const pingPong: WorkflowType`)
|
// Direct WorkflowType export (e.g. `export const pingPong: WorkflowType`)
|
||||||
if (val && typeof val === 'object' && 'name' in (val as any) && 'roles' in (val as any) && 'moderator' in (val as any)) {
|
if (
|
||||||
|
val &&
|
||||||
|
typeof val === 'object' &&
|
||||||
|
'name' in (val as any) &&
|
||||||
|
'roles' in (val as any) &&
|
||||||
|
'moderator' in (val as any)
|
||||||
|
) {
|
||||||
workflows.push(val as WorkflowType<any>);
|
workflows.push(val as WorkflowType<any>);
|
||||||
}
|
}
|
||||||
// Factory function (e.g. `export function createWerewolfWorkflow()`)
|
// Factory function (e.g. `export function createWerewolfWorkflow()`)
|
||||||
if (typeof val === 'function' && key.startsWith('create') && key.endsWith('Workflow')) {
|
if (
|
||||||
|
typeof val === 'function' &&
|
||||||
|
key.startsWith('create') &&
|
||||||
|
key.endsWith('Workflow')
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// Call with no args first (mock mode)
|
// Call with no args first (mock mode)
|
||||||
const wf = val();
|
const wf = val();
|
||||||
if (wf && typeof wf === 'object' && 'name' in wf && 'roles' in wf && 'moderator' in wf) {
|
if (
|
||||||
|
wf &&
|
||||||
|
typeof wf === 'object' &&
|
||||||
|
'name' in wf &&
|
||||||
|
'roles' in wf &&
|
||||||
|
'moderator' in wf
|
||||||
|
) {
|
||||||
workflows.push(wf as WorkflowType<any>);
|
workflows.push(wf as WorkflowType<any>);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -140,9 +161,13 @@ export function createMetaTesterRole(opts: {
|
|||||||
if (!completed) {
|
if (!completed) {
|
||||||
try {
|
try {
|
||||||
const allEvents = await testStore.getAfter(0);
|
const allEvents = await testStore.getAfter(0);
|
||||||
const wfEvents = allEvents.filter((ev: any) => ev.kind.startsWith(`${wf.name}.`));
|
const wfEvents = allEvents.filter((ev: any) =>
|
||||||
|
ev.kind.startsWith(`${wf.name}.`),
|
||||||
|
);
|
||||||
const lastEvent = wfEvents[wfEvents.length - 1];
|
const lastEvent = wfEvents[wfEvents.length - 1];
|
||||||
const roles = wfEvents.map((ev: any) => ev.kind.replace(`${wf.name}.`, '')).join(' → ');
|
const roles = wfEvents
|
||||||
|
.map((ev: any) => ev.kind.replace(`${wf.name}.`, ''))
|
||||||
|
.join(' → ');
|
||||||
diagnostic = `\n 事件链: ${roles}`;
|
diagnostic = `\n 事件链: ${roles}`;
|
||||||
if (lastEvent) {
|
if (lastEvent) {
|
||||||
diagnostic += `\n 最后事件: ${lastEvent.kind} (id=${lastEvent.id})`;
|
diagnostic += `\n 最后事件: ${lastEvent.kind} (id=${lastEvent.id})`;
|
||||||
@@ -158,11 +183,23 @@ export function createMetaTesterRole(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (completed) {
|
if (completed) {
|
||||||
results.push({ name: wf.name, pass: true, detail: `completed in ${tickCount} ticks` });
|
results.push({
|
||||||
|
name: wf.name,
|
||||||
|
pass: true,
|
||||||
|
detail: `completed in ${tickCount} ticks`,
|
||||||
|
});
|
||||||
} else if (lastError) {
|
} else if (lastError) {
|
||||||
results.push({ name: wf.name, pass: false, detail: `error: ${lastError}${diagnostic}` });
|
results.push({
|
||||||
|
name: wf.name,
|
||||||
|
pass: false,
|
||||||
|
detail: `error: ${lastError}${diagnostic}`,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
results.push({ name: wf.name, pass: false, detail: `did not complete in ${tickCount} ticks${diagnostic}` });
|
results.push({
|
||||||
|
name: wf.name,
|
||||||
|
pass: false,
|
||||||
|
detail: `did not complete in ${tickCount} ticks${diagnostic}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await testStore.close();
|
await testStore.close();
|
||||||
@@ -209,12 +246,16 @@ export function createMetaTesterRole(opts: {
|
|||||||
commitHash = exec('git rev-parse --short HEAD');
|
commitHash = exec('git rev-parse --short HEAD');
|
||||||
|
|
||||||
// Auto-detect remote
|
// Auto-detect remote
|
||||||
const remote = opts.remote ?? (() => {
|
const remote =
|
||||||
try {
|
opts.remote ??
|
||||||
const remotes = exec('git remote').split('\n').filter(Boolean);
|
(() => {
|
||||||
return remotes[0] || null;
|
try {
|
||||||
} catch { return null; }
|
const remotes = exec('git remote').split('\n').filter(Boolean);
|
||||||
})();
|
return remotes[0] || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
if (remote) {
|
if (remote) {
|
||||||
try {
|
try {
|
||||||
@@ -227,14 +268,19 @@ export function createMetaTesterRole(opts: {
|
|||||||
pushed = false;
|
pushed = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (_err: any) {
|
||||||
commitHash = undefined;
|
commitHash = undefined;
|
||||||
pushed = false;
|
pushed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: `e2e 验证通过\n\n${summary}\n\nCommit: ${commitHash ?? 'none'}\nPushed: ${pushed ? 'yes' : 'no'}`,
|
content: `e2e 验证通过\n\n${summary}\n\nCommit: ${commitHash ?? 'none'}\nPushed: ${pushed ? 'yes' : 'no'}`,
|
||||||
meta: { pass: true, reason: 'e2e verification passed', commitHash, pushed },
|
meta: {
|
||||||
|
pass: true,
|
||||||
|
reason: 'e2e verification passed',
|
||||||
|
commitHash,
|
||||||
|
pushed,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -11,8 +11,8 @@
|
|||||||
* 小橘 🍊 (NEKO Team)
|
* 小橘 🍊 (NEKO Team)
|
||||||
*/
|
*/
|
||||||
export interface ScaffoldOptions {
|
export interface ScaffoldOptions {
|
||||||
name: string;
|
name: string;
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
workflowsDir: string;
|
workflowsDir: string;
|
||||||
}
|
}
|
||||||
export declare function scaffoldWorkflow(opts: ScaffoldOptions): string[];
|
export declare function scaffoldWorkflow(opts: ScaffoldOptions): string[];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* 小橘 🍊 (NEKO Team)
|
* 小橘 🍊 (NEKO Team)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
export interface ScaffoldOptions {
|
export interface ScaffoldOptions {
|
||||||
@@ -49,7 +49,7 @@ export function scaffoldWorkflow(opts: ScaffoldOptions): string[] {
|
|||||||
|
|
||||||
const moderatorCases = roles
|
const moderatorCases = roles
|
||||||
.map((r, i) => {
|
.map((r, i) => {
|
||||||
const next = i < roles.length - 1 ? `'${roles[i + 1]}'` : 'END';
|
const _next = i < roles.length - 1 ? `'${roles[i + 1]}'` : 'END';
|
||||||
if (i === 0) return ` case START:\n return '${r}';`;
|
if (i === 0) return ` case START:\n return '${r}';`;
|
||||||
return ` case '${roles[i - 1]}':\n return '${r}';`;
|
return ` case '${roles[i - 1]}':\n return '${r}';`;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ describe('createSubprocessRole', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const chain = [
|
const chain = [
|
||||||
{ role: '__start__', content: 'hello', meta: null, timestamp: Date.now() },
|
{
|
||||||
|
role: '__start__',
|
||||||
|
content: 'hello',
|
||||||
|
meta: null,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const result = await role(chain, 'topic-1', null as any);
|
const result = await role(chain, 'topic-1', null as any);
|
||||||
|
|
||||||
|
|||||||
@@ -32,13 +32,10 @@ const RUNNER_PATH = join(import.meta.dir, 'subprocess-runner.ts');
|
|||||||
* Wrap a Role as a subprocess execution.
|
* Wrap a Role as a subprocess execution.
|
||||||
* Returns a function matching the Role<unknown> signature.
|
* Returns a function matching the Role<unknown> signature.
|
||||||
*/
|
*/
|
||||||
export function createSubprocessRole(config: SubprocessRoleConfig): Role<unknown> {
|
export function createSubprocessRole(
|
||||||
const {
|
config: SubprocessRoleConfig,
|
||||||
rolePath,
|
): Role<unknown> {
|
||||||
roleExport,
|
const { rolePath, roleExport, timeoutMs = 300_000, storeConfig } = config;
|
||||||
timeoutMs = 300_000,
|
|
||||||
storeConfig,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
return async (
|
return async (
|
||||||
chain: WorkflowMessage[],
|
chain: WorkflowMessage[],
|
||||||
|
|||||||
Reference in New Issue
Block a user