feat(council-v2): role execution logging + SQLite busy_timeout
- store.ts: add PRAGMA busy_timeout = 5000 after WAL mode (both paths)
- topic-rule-adapter: accept optional logStore for role execution logs
- role-started/completed/failed events written to logStore (not business store)
- role errors caught gracefully, other actions continue
- meta includes scope field for source identification
- no logStore = no logging (graceful degradation)
- coding-task.ts: projection unchanged (role logs never enter business store)
- tests: 8 passing (echo+logStore, role-failed, no-logStore, Moore diff,
baseline, coding lifecycle, rejection, log isolation)
小橘 🍊 (NEKO Team)
This commit is contained in:
@@ -996,16 +996,16 @@ export { buildTopicsFromEvents } from './topic.js';
|
||||
|
||||
export type { CodingTaskContext } from './topics/coding-task.js';
|
||||
export { createCodingTaskType } from './topics/coding-task.js';
|
||||
export { createTopicTicker } from './topics/index.js';
|
||||
export { createArchitectRole } from './topics/roles/architect-llm.js';
|
||||
export { createCoderRole } from './topics/roles/coder-cursor.js';
|
||||
export { createReviewerRole } from './topics/roles/reviewer-cursor.js';
|
||||
export type {
|
||||
TopicRule,
|
||||
TopicTickResult,
|
||||
} from './topics/topic-rule-adapter.js';
|
||||
export { createTopicRule } from './topics/topic-rule-adapter.js';
|
||||
export type { TopicAction, TopicType } from './topics/topic-type.js';
|
||||
export { createTopicTicker } from './topics/index.js';
|
||||
export { createArchitectRole } from './topics/roles/architect-llm.js';
|
||||
export { createCoderRole } from './topics/roles/coder-cursor.js';
|
||||
export { createReviewerRole } from './topics/roles/reviewer-cursor.js';
|
||||
|
||||
// ── Executors ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ export function createStore(options: CreateStoreOptions): PulseStore {
|
||||
|
||||
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);
|
||||
@@ -524,6 +525,7 @@ function openScopeDb(path: string): Database {
|
||||
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);
|
||||
|
||||
@@ -138,4 +138,47 @@ describe('CodingTask TopicType', () => {
|
||||
const r7 = await rule.tick();
|
||||
expect(r7.executed).toEqual([]);
|
||||
});
|
||||
|
||||
it('role execution logs go to logStore, not business store', async () => {
|
||||
setup();
|
||||
const logTmpDir = mkdtempSync(join(tmpdir(), 'coding-log-'));
|
||||
const logStore = createStore({
|
||||
eventsDbPath: join(logTmpDir, 'log.db'),
|
||||
objectsDir: join(logTmpDir, 'log-objects'),
|
||||
});
|
||||
|
||||
try {
|
||||
const codingTask = createCodingTaskType();
|
||||
const rule = createTopicRule(codingTask, store, logStore);
|
||||
|
||||
store.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
kind: 'coding.created',
|
||||
key: 'task-3',
|
||||
meta: JSON.stringify({
|
||||
topicId: 'task-3',
|
||||
title: 'Test log isolation',
|
||||
description: 'Role logs go to logStore',
|
||||
repoDir: '/tmp/repo',
|
||||
}),
|
||||
});
|
||||
|
||||
// Tick: architect runs
|
||||
await rule.tick();
|
||||
|
||||
// Role logs in logStore
|
||||
expect(logStore.queryByKind('coding.role-started').length).toBe(1);
|
||||
expect(logStore.queryByKind('coding.role-completed').length).toBe(1);
|
||||
|
||||
// No role logs in business store
|
||||
expect(store.queryByKind('coding.role-started').length).toBe(0);
|
||||
expect(store.queryByKind('coding.role-completed').length).toBe(0);
|
||||
|
||||
// Business event still written to store
|
||||
expect(store.queryByKind('coding.analyzed').length).toBe(1);
|
||||
} finally {
|
||||
logStore.close();
|
||||
rmSync(logTmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { TopicAction, TopicType } from './topic-type.js';
|
||||
|
||||
describe('createTopicRule', () => {
|
||||
let store: PulseStore;
|
||||
let logStore: PulseStore;
|
||||
let tmpDir: string;
|
||||
|
||||
function setup() {
|
||||
@@ -22,12 +23,19 @@ describe('createTopicRule', () => {
|
||||
eventsDbPath: join(tmpDir, 'test.db'),
|
||||
objectsDir: join(tmpDir, 'objects'),
|
||||
});
|
||||
logStore = createStore({
|
||||
eventsDbPath: join(tmpDir, 'log.db'),
|
||||
objectsDir: join(tmpDir, 'log-objects'),
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
store?.close();
|
||||
} catch {}
|
||||
try {
|
||||
logStore?.close();
|
||||
} catch {}
|
||||
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -82,7 +90,7 @@ describe('createTopicRule', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const rule = createTopicRule(echoType, store);
|
||||
const rule = createTopicRule(echoType, store, logStore);
|
||||
|
||||
// Create a topic
|
||||
store.appendEvent({
|
||||
@@ -99,6 +107,167 @@ describe('createTopicRule', () => {
|
||||
// Second tick: echo already done, no new actions
|
||||
const r2 = await rule.tick();
|
||||
expect(r2.executed).toEqual([]);
|
||||
|
||||
// Verify role execution log events were written to logStore (not store)
|
||||
const started = logStore.queryByKind('echo.role-started');
|
||||
expect(started.length).toBe(1);
|
||||
const startedMeta = JSON.parse(started[0].meta!);
|
||||
expect(startedMeta).toEqual({ topicId: 't1', role: 'echo', scope: 'echo' });
|
||||
|
||||
const completed = logStore.queryByKind('echo.role-completed');
|
||||
expect(completed.length).toBe(1);
|
||||
const completedMeta = JSON.parse(completed[0].meta!);
|
||||
expect(completedMeta.topicId).toBe('t1');
|
||||
expect(completedMeta.role).toBe('echo');
|
||||
expect(completedMeta.scope).toBe('echo');
|
||||
expect(typeof completedMeta.durationMs).toBe('number');
|
||||
|
||||
// Verify no role log events in business store
|
||||
expect(store.queryByKind('echo.role-started').length).toBe(0);
|
||||
expect(store.queryByKind('echo.role-completed').length).toBe(0);
|
||||
});
|
||||
|
||||
it('writes role-failed event when role throws, continues other actions', async () => {
|
||||
setup();
|
||||
|
||||
interface Ctx {
|
||||
topicId: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const failType: TopicType<
|
||||
Ctx,
|
||||
{
|
||||
bomb: (ctx: Ctx, store: PulseStore) => Promise<void>;
|
||||
safe: (ctx: Ctx, store: PulseStore) => Promise<void>;
|
||||
}
|
||||
> = {
|
||||
name: 'failtest',
|
||||
projection: `
|
||||
(
|
||||
$created := $[kind = 'failtest.created'];
|
||||
$merge($created.(
|
||||
$m := $parse(meta);
|
||||
{ $m.topicId: { "topicId": $m.topicId, "done": false } }
|
||||
))
|
||||
)
|
||||
`,
|
||||
roles: {
|
||||
bomb: async () => {
|
||||
throw new Error('kaboom');
|
||||
},
|
||||
safe: async (ctx, s) => {
|
||||
s.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
kind: 'failtest.done',
|
||||
key: ctx.topicId,
|
||||
meta: JSON.stringify({ topicId: ctx.topicId }),
|
||||
});
|
||||
},
|
||||
},
|
||||
moderator: (_roles, topics) => {
|
||||
const actions: TopicAction[] = [];
|
||||
for (const [topicId] of Object.entries(topics)) {
|
||||
actions.push({ topicId, role: 'bomb' });
|
||||
actions.push({ topicId, role: 'safe' });
|
||||
}
|
||||
return actions;
|
||||
},
|
||||
};
|
||||
|
||||
const rule = createTopicRule(failType, store, logStore);
|
||||
|
||||
store.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
kind: 'failtest.created',
|
||||
key: 't1',
|
||||
meta: JSON.stringify({ topicId: 't1' }),
|
||||
});
|
||||
|
||||
const r1 = await rule.tick();
|
||||
// bomb fails so not in executed; safe succeeds
|
||||
expect(r1.executed).toEqual([{ topicId: 't1', role: 'safe' }]);
|
||||
|
||||
// Verify role-failed event in logStore
|
||||
const failed = logStore.queryByKind('failtest.role-failed');
|
||||
expect(failed.length).toBe(1);
|
||||
const failedMeta = JSON.parse(failed[0].meta!);
|
||||
expect(failedMeta.role).toBe('bomb');
|
||||
expect(failedMeta.error).toBe('kaboom');
|
||||
expect(failedMeta.scope).toBe('failtest');
|
||||
|
||||
// Verify safe role completed in logStore
|
||||
const safeCompleted = logStore.queryByKind('failtest.role-completed');
|
||||
expect(safeCompleted.length).toBe(1);
|
||||
});
|
||||
|
||||
it('skips logging when logStore is not provided', async () => {
|
||||
setup();
|
||||
|
||||
interface EchoCtx {
|
||||
topicId: string;
|
||||
message: string;
|
||||
echoed?: boolean;
|
||||
}
|
||||
|
||||
const echoType: TopicType<
|
||||
EchoCtx,
|
||||
{ echo: (ctx: EchoCtx, store: PulseStore) => Promise<void> }
|
||||
> = {
|
||||
name: 'echo',
|
||||
projection: `
|
||||
(
|
||||
$created := $[kind = 'echo.created'];
|
||||
$echoed := $[kind = 'echo.echoed'];
|
||||
$echoedIds := $distinct($echoed.($parse(meta).topicId));
|
||||
$merge($created.(
|
||||
$m := $parse(meta);
|
||||
{ $m.topicId: {
|
||||
"topicId": $m.topicId,
|
||||
"message": $m.message,
|
||||
"echoed": $m.topicId in $echoedIds
|
||||
}}
|
||||
))
|
||||
)
|
||||
`,
|
||||
roles: {
|
||||
echo: async (ctx, s) => {
|
||||
s.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
kind: 'echo.echoed',
|
||||
key: ctx.topicId,
|
||||
meta: JSON.stringify({
|
||||
topicId: ctx.topicId,
|
||||
reply: `Echo: ${ctx.message}`,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
moderator: (_roles, topics) => {
|
||||
const actions: TopicAction<'echo'>[] = [];
|
||||
for (const [topicId, ctx] of Object.entries(topics)) {
|
||||
if (!ctx.echoed) actions.push({ topicId, role: 'echo' });
|
||||
}
|
||||
return actions;
|
||||
},
|
||||
};
|
||||
|
||||
// No logStore — should work without logging
|
||||
const rule = createTopicRule(echoType, store);
|
||||
|
||||
store.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
kind: 'echo.created',
|
||||
key: 't1',
|
||||
meta: JSON.stringify({ topicId: 't1', message: 'hello' }),
|
||||
});
|
||||
|
||||
const r1 = await rule.tick();
|
||||
expect(r1.executed).toEqual([{ topicId: 't1', role: 'echo' }]);
|
||||
|
||||
// No role log events in store
|
||||
expect(store.queryByKind('echo.role-started').length).toBe(0);
|
||||
expect(store.queryByKind('echo.role-completed').length).toBe(0);
|
||||
});
|
||||
|
||||
it('Moore diff prevents re-execution of same action', async () => {
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface TopicRule {
|
||||
export function createTopicRule<TContext>(
|
||||
topicType: TopicType<TContext, any>,
|
||||
store: PulseStore,
|
||||
logStore?: PulseStore,
|
||||
): TopicRule {
|
||||
// Moore machine state: snapshot baseline from last tick
|
||||
let prevSnapshotJson = '';
|
||||
@@ -112,8 +113,55 @@ export function createTopicRule<TContext>(
|
||||
if (roleFn) {
|
||||
const ctx = topics[action.topicId];
|
||||
if (ctx !== undefined) {
|
||||
await roleFn(ctx, store);
|
||||
executed.push(action);
|
||||
// Write role-started event to logStore if available
|
||||
if (logStore) {
|
||||
logStore.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
kind: `${topicType.name}.role-started`,
|
||||
key: action.topicId,
|
||||
meta: JSON.stringify({
|
||||
topicId: action.topicId,
|
||||
role: action.role,
|
||||
scope: topicType.name,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
await roleFn(ctx, store);
|
||||
// Write role-completed event to logStore if available
|
||||
if (logStore) {
|
||||
logStore.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
kind: `${topicType.name}.role-completed`,
|
||||
key: action.topicId,
|
||||
meta: JSON.stringify({
|
||||
topicId: action.topicId,
|
||||
role: action.role,
|
||||
scope: topicType.name,
|
||||
durationMs: Date.now() - start,
|
||||
}),
|
||||
});
|
||||
}
|
||||
executed.push(action);
|
||||
} catch (err) {
|
||||
// Write role-failed event to logStore if available (don't rethrow — let other actions continue)
|
||||
if (logStore) {
|
||||
logStore.appendEvent({
|
||||
occurredAt: Date.now(),
|
||||
kind: `${topicType.name}.role-failed`,
|
||||
key: action.topicId,
|
||||
meta: JSON.stringify({
|
||||
topicId: action.topicId,
|
||||
role: action.role,
|
||||
scope: topicType.name,
|
||||
durationMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user