diff --git a/packages/pulse/src/gc.ts b/packages/pulse/src/gc.ts index 43bccee..2bac431 100644 --- a/packages/pulse/src/gc.ts +++ b/packages/pulse/src/gc.ts @@ -128,16 +128,6 @@ export function gcOrphanObjects( 'migrate', 'init', 'gc', - 'task-created', - 'task-assigned', - 'task-acked', - 'task-closed', - 'task-given-up', - 'project-created', - 'project-paused', - 'project-archived', - 'llm-call-completed', - 'tool-response', ]) { const events = store.queryByKind(kind, {}); for (const event of events) { diff --git a/packages/pulse/src/index.test.ts b/packages/pulse/src/index.test.ts index 3cb618b..4706309 100644 --- a/packages/pulse/src/index.test.ts +++ b/packages/pulse/src/index.test.ts @@ -6,14 +6,11 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { - type ActiveProjectsData, - type AgentLoopTraceData, composeRules, createRule, createScopedStore, createStore, findEffectiveEpoch, - type PendingTasksData, type PulseStore, type Rule, rebuildSnapshot, @@ -1140,271 +1137,3 @@ describe('runPulse with ScopedStore', () => { store.close(); }); }); - -// ── rebuildSnapshot with task projection ─────────────────────── - -describe('rebuildSnapshot with task projection', () => { - let tmpDir: string; - let store: PulseStore; - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'pulse-task-proj-')); - store = createStore({ - eventsDbPath: join(tmpDir, 'events.db'), - objectsDir: join(tmpDir, 'objects'), - }); - }); - - afterEach(async () => { - await new Promise((r) => setTimeout(r, 200)); - store.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('should include pending-tasks in snapshot when systemStore provided', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: JSON.stringify({ - taskId: 't1', - projectId: 'proj1', - title: 'Fix bug', - prompt: 'Fix the login bug', - }), - }); - - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'pending-tasks': Sensed; - }>(store, ['pending-tasks'], null, { systemStore: store }); - - expect(snapshot['pending-tasks']).toBeTruthy(); - expect(snapshot['pending-tasks'].data.pendingCount).toBe(1); - expect(snapshot['pending-tasks'].data.tasks[0]?.taskId).toBe('t1'); - expect(snapshot['pending-tasks'].refreshedAt).toBeGreaterThan(0); - }); - - it('should not include pending-tasks when systemStore not provided', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: JSON.stringify({ - taskId: 't1', - projectId: 'proj1', - title: 'Fix bug', - prompt: 'Fix it', - }), - }); - - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'pending-tasks'?: Sensed; - }>(store, ['pending-tasks']); - - expect(snapshot['pending-tasks']).toBeUndefined(); - }); - - it('should not include pending-tasks when senseKeys does not contain pending-tasks', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: JSON.stringify({ - taskId: 't1', - projectId: 'proj1', - title: 'Fix bug', - prompt: 'Fix it', - }), - }); - - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'pending-tasks'?: Sensed; - }>(store, ['other-key'], null, { systemStore: store }); - - expect(snapshot['pending-tasks']).toBeUndefined(); - }); - - it('should prefer workflowStore over systemStore for task projections', () => { - // Write task to a separate "routine" store - const routineDir = mkdtempSync(join(tmpdir(), 'pulse-routine-')); - const workflowStore = createStore({ - eventsDbPath: join(routineDir, 'events.db'), - objectsDir: join(routineDir, 'objects'), - }); - - workflowStore.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: JSON.stringify({ - taskId: 'r1', - projectId: 'proj-routine', - title: 'Routine task', - prompt: 'from routine scope', - }), - }); - - // systemStore (store) has no task events - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'pending-tasks': Sensed; - }>(store, ['pending-tasks'], null, { systemStore: store, workflowStore }); - - expect(snapshot['pending-tasks']).toBeTruthy(); - expect(snapshot['pending-tasks'].data.pendingCount).toBe(1); - expect(snapshot['pending-tasks'].data.tasks[0]?.taskId).toBe('r1'); - - workflowStore.close(); - rmSync(routineDir, { recursive: true, force: true }); - }); -}); - -// ── rebuildSnapshot with active-projects + agent-loop-trace ───────── - -describe('rebuildSnapshot with active-projects and agent-loop-trace', () => { - let tmpDir: string; - let store: PulseStore; - let workflowStore: PulseStore; - let routineDir: string; - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'pulse-proj-trace-')); - store = createStore({ - eventsDbPath: join(tmpDir, 'events.db'), - objectsDir: join(tmpDir, 'objects'), - }); - routineDir = mkdtempSync(join(tmpdir(), 'pulse-routine-trace-')); - workflowStore = createStore({ - eventsDbPath: join(routineDir, 'events.db'), - objectsDir: join(routineDir, 'objects'), - }); - }); - - afterEach(async () => { - await new Promise((r) => setTimeout(r, 200)); - store.close(); - workflowStore.close(); - rmSync(tmpDir, { recursive: true, force: true }); - rmSync(routineDir, { recursive: true, force: true }); - }); - - it('should include active-projects in snapshot when workflowStore provided', () => { - workflowStore.appendEvent({ - occurredAt: 1000, - kind: 'project-created', - meta: JSON.stringify({ projectId: 'p1', name: 'Alpha' }), - }); - workflowStore.appendEvent({ - occurredAt: 2000, - kind: 'project-created', - meta: JSON.stringify({ projectId: 'p2', name: 'Beta' }), - }); - - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'active-projects': Sensed; - }>(store, ['active-projects'], null, { workflowStore }); - - expect(snapshot['active-projects']).toBeTruthy(); - expect(snapshot['active-projects'].data.projectIds).toContain('p1'); - expect(snapshot['active-projects'].data.projectIds).toContain('p2'); - expect(snapshot['active-projects'].refreshedAt).toBeGreaterThan(0); - }); - - it('should not include active-projects without workflowStore', () => { - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'active-projects'?: Sensed; - }>(store, ['active-projects']); - - expect(snapshot['active-projects']).toBeUndefined(); - }); - - it('should include agent-loop-trace:{projectId} in snapshot', () => { - workflowStore.appendEvent({ - occurredAt: 1000, - kind: 'llm-call-completed', - meta: JSON.stringify({ - projectId: 'proj-x', - model: 'gpt-4', - durationMs: 300, - toolCalls: [{ name: 'read_file', arguments: { path: '/a' } }], - }), - }); - workflowStore.appendEvent({ - occurredAt: 2000, - kind: 'tool-response', - meta: JSON.stringify({ - projectId: 'proj-x', - toolCallIndex: 0, - toolName: 'read_file', - result: 'content', - }), - }); - - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'agent-loop-trace:proj-x': Sensed; - }>(store, ['agent-loop-trace:proj-x'], null, { workflowStore }); - - expect(snapshot['agent-loop-trace:proj-x']).toBeTruthy(); - expect(snapshot['agent-loop-trace:proj-x'].data.messages).toHaveLength(2); - expect(snapshot['agent-loop-trace:proj-x'].data.messages[0].role).toBe( - 'assistant', - ); - expect(snapshot['agent-loop-trace:proj-x'].data.messages[1].role).toBe( - 'tool', - ); - }); - - it('should not include agent-loop-trace without workflowStore', () => { - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'agent-loop-trace:proj-x'?: Sensed; - }>(store, ['agent-loop-trace:proj-x']); - - expect(snapshot['agent-loop-trace:proj-x']).toBeUndefined(); - }); - - it('agent-loop-trace nextCheckAt from set_next_check', () => { - workflowStore.appendEvent({ - occurredAt: 5000, - kind: 'llm-call-completed', - meta: JSON.stringify({ - projectId: 'proj-y', - durationMs: 100, - toolCalls: [{ name: 'set_next_check', arguments: { tickMs: 30000 } }], - }), - }); - - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'agent-loop-trace:proj-y': Sensed; - }>(store, ['agent-loop-trace:proj-y'], null, { workflowStore }); - - expect(snapshot['agent-loop-trace:proj-y'].data.nextCheckAt).toBe(35000); - }); - - it('multiple agent-loop-trace keys for different projects', () => { - workflowStore.appendEvent({ - occurredAt: 1000, - kind: 'llm-call-completed', - meta: JSON.stringify({ projectId: 'p1', durationMs: 100 }), - }); - workflowStore.appendEvent({ - occurredAt: 2000, - kind: 'llm-call-completed', - meta: JSON.stringify({ projectId: 'p2', durationMs: 200 }), - }); - - const snapshot = rebuildSnapshot<{ - timestamp: number; - 'agent-loop-trace:p1': Sensed; - 'agent-loop-trace:p2': Sensed; - }>(store, ['agent-loop-trace:p1', 'agent-loop-trace:p2'], null, { - workflowStore, - }); - - expect(snapshot['agent-loop-trace:p1'].data.messages).toHaveLength(1); - expect(snapshot['agent-loop-trace:p2'].data.messages).toHaveLength(1); - }); -}); diff --git a/packages/pulse/src/index.ts b/packages/pulse/src/index.ts index 323774f..a9179a8 100644 --- a/packages/pulse/src/index.ts +++ b/packages/pulse/src/index.ts @@ -16,11 +16,7 @@ import { createGcTrigger, DEFAULT_GC_CONFIG, type GcConfig } from './gc.js'; import { foldAllProjections, getProjectionState } from './projection-engine.js'; import type { EventRecord, PulseStore, ScopedStore } from './store.js'; import { startWatcher, type WatcherDef } from './watcher.js'; -import { - buildActiveProjectsFromEvents, - buildAgentLoopTraceFromEvents, - buildPendingTasksFromEvents, -} from './watchers/pending-tasks-projection.js'; + // ── Sensed Types ─────────────────────────────────────────────── @@ -313,38 +309,6 @@ export function rebuildSnapshot( snapshot['_error:cas_miss'] = { keys: casMisses, count: casMisses.length }; } - // Task projections: inject pending-tasks and agent-capability-stats from event fold. - // Prefer workflowStore (task events live in the 'workflows' scope); fall back to systemStore for backward compat. - const taskStore = options?.workflowStore ?? options?.systemStore; - if (taskStore && senseKeys.includes('pending-tasks')) { - snapshot['pending-tasks'] = { - data: buildPendingTasksFromEvents(taskStore), - refreshedAt: Date.now(), - } as Sensed; - } - - // Active-projects projection from project-created/paused/archived events - const workflowStore = options?.workflowStore; - if (workflowStore && senseKeys.includes('active-projects')) { - snapshot['active-projects'] = { - data: buildActiveProjectsFromEvents(workflowStore), - refreshedAt: Date.now(), - } as Sensed; - } - - // Agent-loop-trace projections (per projectId, dynamic sense keys) - for (const key of senseKeys) { - if (key.startsWith('agent-loop-trace:')) { - const projectId = key.slice('agent-loop-trace:'.length); - if (workflowStore) { - snapshot[key] = { - data: buildAgentLoopTraceFromEvents(workflowStore, projectId), - refreshedAt: Date.now(), - } as Sensed; - } - } - } - return snapshot as S; } @@ -1034,6 +998,8 @@ export { } from './gc.js'; // ── Projection Engine ─────────────────────────────────────────── export * from './projection-engine.js'; + +// ── Task Event Types ──────────────────────────────────────────── export type { ActiveProjectsData, AgentLoopTraceData, @@ -1059,11 +1025,4 @@ export type { ToolResponseMeta, TraceMessage, } from './task-events.js'; -// ── Task Events ───────────────────────────────────────────────── -export { - buildActiveProjectsFromEvents, - buildAgentLoopTraceFromEvents, - buildInflightBrokerFromEvents, - buildPendingTasksFromEvents, - buildProjectsFromEvents, -} from './watchers/pending-tasks-projection.js'; + diff --git a/packages/pulse/src/watchers/pending-tasks-projection.test.ts b/packages/pulse/src/watchers/pending-tasks-projection.test.ts deleted file mode 100644 index e38df8a..0000000 --- a/packages/pulse/src/watchers/pending-tasks-projection.test.ts +++ /dev/null @@ -1,840 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { - createScopedStore, - createStore, - type PulseStore, - type ScopedStore, -} from '../store.js'; -import { - buildActiveProjectsFromEvents, - buildAgentLoopTraceFromEvents, - buildInflightBrokerFromEvents, - buildPendingTasksFromEvents, - buildProjectsFromEvents, -} from './pending-tasks-projection.js'; - -function makeTaskMeta(overrides: Record) { - return JSON.stringify(overrides); -} - -describe('buildPendingTasksFromEvents', () => { - let tmpDir: string; - let store: PulseStore; - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'pulse-test-')); - store = createStore({ - eventsDbPath: join(tmpDir, 'events.db'), - objectsDir: join(tmpDir, 'objects'), - }); - }); - - afterEach(() => { - store.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('empty store → pendingCount = 0', () => { - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(0); - expect(result.tasks).toEqual([]); - expect(result.byProject).toEqual({}); - }); - - it('task-created → pendingCount = 1, status = pending', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't1', - projectId: 'proj-a', - title: 'Fix bug', - description: 'fix the login bug', - type: 'bug', - priority: 5, - creatorId: 'user-1', - }), - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(1); - expect(result.tasks[0].taskId).toBe('t1'); - expect(result.tasks[0].status).toBe('pending'); - expect(result.tasks[0].projectId).toBe('proj-a'); - expect(result.tasks[0].priority).toBe(5); - expect(result.tasks[0].type).toBe('bug'); - expect(result.tasks[0].creatorId).toBe('user-1'); - expect(result.tasks[0].description).toBe('fix the login bug'); - expect(result.byProject['proj-a']).toHaveLength(1); - }); - - it('task-routing → status = routing', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't1', - projectId: 'proj-a', - title: 'Fix bug', - description: 'fix it', - type: 'bug', - priority: 0, - creatorId: 'user-1', - }), - }); - store.appendEvent({ - occurredAt: 1500, - kind: 'task-routing', - meta: makeTaskMeta({ - taskId: 't1', - brokerSessionId: 'broker-123', - }), - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(1); - expect(result.tasks[0].status).toBe('routing'); - }); - - it('task-assigned → status = assigned, has assigneeId', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't1', - projectId: 'proj-a', - title: 'Fix bug', - description: 'fix it', - type: 'bug', - priority: 0, - creatorId: 'user-1', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'task-assigned', - meta: makeTaskMeta({ - taskId: 't1', - assigneeId: 'cursor', - assignedBy: 'broker', - }), - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(1); - expect(result.tasks[0].status).toBe('assigned'); - expect(result.tasks[0].assigneeId).toBe('cursor'); - }); - - it('task-responded → status = pending, has lastRespondedResult', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't1', - projectId: 'proj-a', - title: 'Fix bug', - description: 'fix it', - type: 'bug', - priority: 0, - creatorId: 'user-1', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'task-assigned', - meta: makeTaskMeta({ - taskId: 't1', - assigneeId: 'cursor', - assignedBy: 'broker', - }), - }); - store.appendEvent({ - occurredAt: 3000, - kind: 'task-responded', - meta: makeTaskMeta({ - taskId: 't1', - assigneeId: 'cursor', - result: 'Fixed the login issue', - }), - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(1); - expect(result.tasks[0].status).toBe('pending'); - expect(result.tasks[0].lastRespondedResult).toBe('Fixed the login issue'); - }); - - it('task-closed by matching creatorId → removed from map', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't1', - projectId: 'proj-a', - title: 'Fix bug', - description: 'fix it', - type: 'bug', - priority: 0, - creatorId: 'user-1', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'task-closed', - meta: makeTaskMeta({ taskId: 't1', creatorId: 'user-1' }), - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(0); - expect(result.tasks).toEqual([]); - }); - - it('task-closed by non-matching creatorId → ignored', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't1', - projectId: 'proj-a', - title: 'Fix bug', - description: 'fix it', - type: 'bug', - priority: 0, - creatorId: 'user-1', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'task-closed', - meta: makeTaskMeta({ taskId: 't1', creatorId: 'intruder' }), - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(1); - expect(result.tasks[0].status).toBe('pending'); - }); - - it('full lifecycle: created → assigned → responded → closed', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't1', - projectId: 'proj-a', - title: 'Task', - description: 'do it', - type: 'action', - priority: 0, - creatorId: 'user-1', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'task-assigned', - meta: makeTaskMeta({ - taskId: 't1', - assigneeId: 'cursor', - assignedBy: 'broker', - }), - }); - store.appendEvent({ - occurredAt: 3000, - kind: 'task-responded', - meta: makeTaskMeta({ - taskId: 't1', - assigneeId: 'cursor', - result: 'done', - }), - }); - store.appendEvent({ - occurredAt: 4000, - kind: 'task-closed', - meta: makeTaskMeta({ taskId: 't1', creatorId: 'user-1' }), - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(0); - expect(result.tasks).toEqual([]); - }); - - it('multiple tasks across multiple projects', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't1', - projectId: 'proj-a', - title: 'Task A1', - description: 'do a1', - type: 'bug', - priority: 0, - creatorId: 'user-1', - }), - }); - store.appendEvent({ - occurredAt: 1001, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't2', - projectId: 'proj-b', - title: 'Task B1', - description: 'do b1', - type: 'rfc', - priority: 0, - creatorId: 'user-1', - }), - }); - store.appendEvent({ - occurredAt: 1002, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't3', - projectId: 'proj-a', - title: 'Task A2', - description: 'do a2', - type: 'action', - priority: 0, - creatorId: 'user-1', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'task-closed', - meta: makeTaskMeta({ taskId: 't1', creatorId: 'user-1' }), - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(2); - expect(result.byProject['proj-a']).toHaveLength(1); - expect(result.byProject['proj-b']).toHaveLength(1); - expect(result.byProject['proj-a'][0].taskId).toBe('t3'); - expect(result.byProject['proj-b'][0].taskId).toBe('t2'); - }); - - it('default priority is 0 when not specified', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 't1', - projectId: 'proj-a', - title: 'No priority', - description: 'do it', - type: 'action', - creatorId: 'user-1', - }), - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.tasks[0].priority).toBe(0); - }); - - it('events without meta are skipped gracefully', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - }); - - const result = buildPendingTasksFromEvents(store); - expect(result.pendingCount).toBe(0); - expect(result.tasks).toEqual([]); - }); -}); - -// ── buildProjectsFromEvents ─────────────────────────────────── - -describe('buildProjectsFromEvents', () => { - let tmpDir: string; - let store: PulseStore; - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'pulse-proj-')); - store = createStore({ - eventsDbPath: join(tmpDir, 'events.db'), - objectsDir: join(tmpDir, 'objects'), - }); - }); - - afterEach(() => { - store.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('empty store → empty record', () => { - const result = buildProjectsFromEvents(store); - expect(result).toEqual({}); - }); - - it('project-created → record with project state', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'project-created', - meta: makeTaskMeta({ - projectId: 'p1', - name: 'Alpha', - repoDir: '/home/user/alpha', - }), - }); - - const result = buildProjectsFromEvents(store); - expect(result.p1).toEqual({ - projectId: 'p1', - name: 'Alpha', - repoDir: '/home/user/alpha', - }); - }); - - it('multiple projects', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'project-created', - meta: makeTaskMeta({ - projectId: 'p1', - name: 'Alpha', - repoDir: '/repo/a', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'project-created', - meta: makeTaskMeta({ - projectId: 'p2', - name: 'Beta', - repoDir: '/repo/b', - }), - }); - - const result = buildProjectsFromEvents(store); - expect(Object.keys(result)).toHaveLength(2); - expect(result.p1.name).toBe('Alpha'); - expect(result.p2.name).toBe('Beta'); - }); -}); - -// ── buildInflightBrokerFromEvents ───────────────────────────── - -describe('buildInflightBrokerFromEvents', () => { - let tmpDir: string; - let store: PulseStore; - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'pulse-broker-')); - store = createStore({ - eventsDbPath: join(tmpDir, 'events.db'), - objectsDir: join(tmpDir, 'objects'), - }); - }); - - afterEach(() => { - store.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('no executing effects → active = false', () => { - const result = buildInflightBrokerFromEvents(store); - expect(result.active).toBe(false); - }); - - it('executing broker effect without ack → active = true', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'effect-executing', - key: 'effect-1', - meta: JSON.stringify({ type: 'broker' }), - }); - - const result = buildInflightBrokerFromEvents(store); - expect(result.active).toBe(true); - }); - - it('executing broker effect with ack → active = false', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'effect-executing', - key: 'effect-1', - meta: JSON.stringify({ type: 'broker' }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'effect-acked', - key: 'effect-1', - }); - - const result = buildInflightBrokerFromEvents(store); - expect(result.active).toBe(false); - }); - - it('executing broker effect with failure → active = false', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'effect-executing', - key: 'effect-1', - meta: JSON.stringify({ type: 'broker' }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'effect-failed', - key: 'effect-1', - }); - - const result = buildInflightBrokerFromEvents(store); - expect(result.active).toBe(false); - }); - - it('non-broker executing effect → active = false', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'effect-executing', - key: 'effect-1', - meta: JSON.stringify({ type: 'cursor' }), - }); - - const result = buildInflightBrokerFromEvents(store); - expect(result.active).toBe(false); - }); -}); - -// ── routine scope integration ───────────────────────────────────── - -describe('buildPendingTasksFromEvents with routine scope', () => { - let tmpDir: string; - let scopedStore: ScopedStore; - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'pulse-routine-test-')); - scopedStore = createScopedStore({ - basePath: join(tmpDir, 'scopes'), - objectsDir: join(tmpDir, 'objects'), - }); - }); - - afterEach(() => { - scopedStore.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('task-created written to routine scope is correctly folded', () => { - const workflowStore = scopedStore.scope('workflows'); - - workflowStore.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 'rt-1', - projectId: 'proj-x', - title: 'Routine task', - description: 'do routine work', - type: 'action', - priority: 3, - creatorId: 'user-1', - }), - }); - - const result = buildPendingTasksFromEvents(workflowStore); - expect(result.pendingCount).toBe(1); - expect(result.tasks[0].taskId).toBe('rt-1'); - expect(result.tasks[0].projectId).toBe('proj-x'); - expect(result.tasks[0].status).toBe('pending'); - expect(result.tasks[0].priority).toBe(3); - }); - - it('full lifecycle: created → assigned → responded → closed', () => { - const workflowStore = scopedStore.scope('workflows'); - - workflowStore.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 'rt-2', - projectId: 'proj-y', - title: 'Lifecycle task', - description: 'full cycle', - type: 'bug', - priority: 0, - creatorId: 'user-1', - }), - }); - workflowStore.appendEvent({ - occurredAt: 2000, - kind: 'task-assigned', - meta: makeTaskMeta({ - taskId: 'rt-2', - assigneeId: 'cursor', - assignedBy: 'broker', - }), - }); - workflowStore.appendEvent({ - occurredAt: 3000, - kind: 'task-responded', - meta: makeTaskMeta({ - taskId: 'rt-2', - assigneeId: 'cursor', - result: 'completed', - }), - }); - workflowStore.appendEvent({ - occurredAt: 4000, - kind: 'task-closed', - meta: makeTaskMeta({ taskId: 'rt-2', creatorId: 'user-1' }), - }); - - const result = buildPendingTasksFromEvents(workflowStore); - expect(result.pendingCount).toBe(0); - expect(result.tasks).toEqual([]); - }); - - it('routine scope is isolated from _system scope', () => { - const workflowStore = scopedStore.scope('workflows'); - const systemStore = scopedStore.scope('_system'); - - workflowStore.appendEvent({ - occurredAt: 1000, - kind: 'task-created', - meta: makeTaskMeta({ - taskId: 'rt-3', - projectId: 'proj-z', - title: 'Routine only', - description: 'routine scope', - type: 'action', - priority: 0, - creatorId: 'user-1', - }), - }); - - const systemResult = buildPendingTasksFromEvents(systemStore); - expect(systemResult.pendingCount).toBe(0); - - const routineResult = buildPendingTasksFromEvents(workflowStore); - expect(routineResult.pendingCount).toBe(1); - expect(routineResult.tasks[0].taskId).toBe('rt-3'); - }); -}); - -// ── buildActiveProjectsFromEvents ───────────────────────────────── - -describe('buildActiveProjectsFromEvents', () => { - let tmpDir: string; - let store: PulseStore; - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'pulse-active-proj-')); - store = createStore({ - eventsDbPath: join(tmpDir, 'events.db'), - objectsDir: join(tmpDir, 'objects'), - }); - }); - - afterEach(() => { - store.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('empty store → no active projects', () => { - const result = buildActiveProjectsFromEvents(store); - expect(result.projectIds).toEqual([]); - }); - - it('project-created adds to active list', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'project-created', - meta: makeTaskMeta({ - projectId: 'p1', - name: 'Alpha', - repoDir: '/repo/a', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'project-created', - meta: makeTaskMeta({ - projectId: 'p2', - name: 'Beta', - repoDir: '/repo/b', - }), - }); - - const result = buildActiveProjectsFromEvents(store); - expect(result.projectIds).toContain('p1'); - expect(result.projectIds).toContain('p2'); - expect(result.projectIds).toHaveLength(2); - }); - - it('project-paused removes from active list', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'project-created', - meta: makeTaskMeta({ - projectId: 'p1', - name: 'Alpha', - repoDir: '/repo/a', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'project-paused', - meta: makeTaskMeta({ projectId: 'p1', reason: 'maintenance' }), - }); - - const result = buildActiveProjectsFromEvents(store); - expect(result.projectIds).toEqual([]); - }); - - it('project-archived removes from active list', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'project-created', - meta: makeTaskMeta({ - projectId: 'p1', - name: 'Alpha', - repoDir: '/repo/a', - }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'project-archived', - meta: makeTaskMeta({ projectId: 'p1', reason: 'done' }), - }); - - const result = buildActiveProjectsFromEvents(store); - expect(result.projectIds).toEqual([]); - }); - - it('events without meta are skipped gracefully', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'project-created', - }); - - const result = buildActiveProjectsFromEvents(store); - expect(result.projectIds).toEqual([]); - }); -}); - -// ── buildAgentLoopTraceFromEvents ───────────────────────────────── - -describe('buildAgentLoopTraceFromEvents', () => { - let tmpDir: string; - let store: PulseStore; - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'pulse-trace-')); - store = createStore({ - eventsDbPath: join(tmpDir, 'events.db'), - objectsDir: join(tmpDir, 'objects'), - }); - }); - - afterEach(() => { - store.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('empty store → empty messages, nextCheckAt = 0', () => { - const result = buildAgentLoopTraceFromEvents(store, 'proj-1'); - expect(result.messages).toEqual([]); - expect(result.nextCheckAt).toBe(0); - }); - - it('llm-call-completed creates assistant message with toolCalls', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'llm-call-completed', - meta: JSON.stringify({ - projectId: 'proj-1', - model: 'gpt-4', - toolCalls: [{ name: 'read_file', arguments: { path: '/tmp/a' } }], - durationMs: 500, - }), - }); - - const result = buildAgentLoopTraceFromEvents(store, 'proj-1'); - expect(result.messages).toHaveLength(1); - expect(result.messages[0].role).toBe('assistant'); - expect(result.messages[0].toolCalls).toHaveLength(1); - expect(result.messages[0].toolCalls![0].name).toBe('read_file'); - expect(result.messages[0].ts).toBe(1000); - }); - - it('tool-response creates tool message', () => { - store.appendEvent({ - occurredAt: 2000, - kind: 'tool-response', - meta: JSON.stringify({ - projectId: 'proj-1', - toolCallIndex: 0, - toolName: 'read_file', - result: 'file content here', - }), - }); - - const result = buildAgentLoopTraceFromEvents(store, 'proj-1'); - expect(result.messages).toHaveLength(1); - expect(result.messages[0].role).toBe('tool'); - expect(result.messages[0].toolName).toBe('read_file'); - expect(result.messages[0].result).toBe('file content here'); - }); - - it('filters by projectId', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'llm-call-completed', - meta: JSON.stringify({ projectId: 'proj-1', durationMs: 100 }), - }); - store.appendEvent({ - occurredAt: 2000, - kind: 'llm-call-completed', - meta: JSON.stringify({ projectId: 'proj-2', durationMs: 200 }), - }); - - const result1 = buildAgentLoopTraceFromEvents(store, 'proj-1'); - expect(result1.messages).toHaveLength(1); - - const result2 = buildAgentLoopTraceFromEvents(store, 'proj-2'); - expect(result2.messages).toHaveLength(1); - }); - - it('ring buffer truncates to 20 messages', () => { - for (let i = 0; i < 25; i++) { - store.appendEvent({ - occurredAt: 1000 + i, - kind: 'llm-call-completed', - meta: JSON.stringify({ projectId: 'proj-1', durationMs: 100 }), - }); - } - - const result = buildAgentLoopTraceFromEvents(store, 'proj-1'); - expect(result.messages).toHaveLength(20); - expect(result.messages[0].ts).toBe(1005); - expect(result.messages[19].ts).toBe(1024); - }); - - it('nextCheckAt from set_next_check tool call', () => { - store.appendEvent({ - occurredAt: 5000, - kind: 'llm-call-completed', - meta: JSON.stringify({ - projectId: 'proj-1', - durationMs: 100, - toolCalls: [{ name: 'set_next_check', arguments: { tickMs: 30000 } }], - }), - }); - - const result = buildAgentLoopTraceFromEvents(store, 'proj-1'); - expect(result.nextCheckAt).toBe(35000); - }); - - it('events without meta are skipped', () => { - store.appendEvent({ - occurredAt: 1000, - kind: 'llm-call-completed', - }); - - const result = buildAgentLoopTraceFromEvents(store, 'proj-1'); - expect(result.messages).toEqual([]); - }); -}); diff --git a/packages/pulse/src/watchers/pending-tasks-projection.ts b/packages/pulse/src/watchers/pending-tasks-projection.ts deleted file mode 100644 index 17e3280..0000000 --- a/packages/pulse/src/watchers/pending-tasks-projection.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { PulseStore } from '../store.js'; -import type { - ActiveProjectsData, - AgentLoopTraceData, - InflightBrokerData, - LlmCallCompletedMeta, - PendingTasksData, - ProjectState, - TaskRoutingMeta, - TaskState, - ToolResponseMeta, - TraceMessage, -} from '../task-events.js'; - -/** - * Fold task-* events into current task state. - * - * State machine: - * task-created → status: 'pending' - * task-routing → status: 'routing' - * task-assigned → status: 'assigned', store assigneeId + assignedBy - * task-responded → status: 'pending', store lastRespondedResult (cycle back for re-broker) - * task-closed → remove from map (only if creatorId matches) - */ -export function buildPendingTasksFromEvents( - store: PulseStore, -): PendingTasksData { - const kinds = [ - 'task-created', - 'task-routing', - 'task-assigned', - 'task-responded', - 'task-closed', - ]; - const allEvents = kinds.flatMap((kind) => store.queryByKind(kind)); - allEvents.sort((a, b) => a.occurredAt - b.occurredAt); - - const taskMap = new Map(); - - for (const event of allEvents) { - if (!event.meta) continue; - const meta = JSON.parse(event.meta); - - switch (event.kind) { - case 'task-created': { - taskMap.set(meta.taskId, { - taskId: meta.taskId, - projectId: meta.projectId, - title: meta.title, - description: meta.description ?? meta.prompt ?? '', - type: meta.type ?? 'action', - priority: meta.priority ?? 0, - creatorId: meta.creatorId ?? '', - status: 'pending', - createdAt: event.occurredAt, - updatedAt: event.occurredAt, - }); - break; - } - case 'task-routing': { - const m = meta as TaskRoutingMeta; - const task = taskMap.get(m.taskId); - if (task) { - task.status = 'routing'; - task.updatedAt = event.occurredAt; - } - break; - } - case 'task-assigned': { - const task = taskMap.get(meta.taskId); - if (task) { - task.status = 'assigned'; - task.assigneeId = meta.assigneeId; - task.updatedAt = event.occurredAt; - } - break; - } - case 'task-responded': { - const task = taskMap.get(meta.taskId); - if (task) { - task.status = 'pending'; - task.lastRespondedResult = meta.result; - task.updatedAt = event.occurredAt; - } - break; - } - case 'task-closed': { - const task = taskMap.get(meta.taskId); - if (task && task.creatorId === meta.creatorId) { - taskMap.delete(meta.taskId); - } - break; - } - } - } - - const activeTasks = [...taskMap.values()]; - - const byProject: Record = {}; - for (const task of activeTasks) { - if (!byProject[task.projectId]) byProject[task.projectId] = []; - byProject[task.projectId].push(task); - } - - return { - pendingCount: activeTasks.length, - tasks: activeTasks, - byProject, - checkedAt: Date.now(), - }; -} - -/** - * Fold project-created events → Record. - */ -export function buildProjectsFromEvents( - store: PulseStore, -): Record { - const allEvents = store.queryByKind('project-created'); - allEvents.sort((a, b) => a.occurredAt - b.occurredAt); - - const projects: Record = {}; - - for (const event of allEvents) { - if (!event.meta) continue; - const meta = JSON.parse(event.meta); - projects[meta.projectId] = { - projectId: meta.projectId, - name: meta.name, - repoDir: meta.repoDir ?? '', - }; - } - - return projects; -} - -/** - * Check whether there is an inflight broker effect in the system scope. - * - * Looks for effect-executing events whose meta contains `"broker"` kind - * that do NOT have a corresponding effect-acked or effect-failed event. - */ -export function buildInflightBrokerFromEvents( - systemStore: PulseStore, -): InflightBrokerData { - const executingEvents = systemStore.queryByKind('effect-executing'); - - for (const ev of executingEvents) { - if (!ev.meta) continue; - try { - const meta = JSON.parse(ev.meta); - if (meta.type !== 'broker') continue; - } catch { - continue; - } - - const key = ev.key ?? String(ev.id); - const acked = systemStore.getLatest('effect-acked', key); - if (acked) continue; - const failed = systemStore.getLatest('effect-failed', key); - if (failed) continue; - - return { active: true }; - } - - return { active: false }; -} - -/** - * Fold project-created/paused/archived events → active project list. - */ -export function buildActiveProjectsFromEvents( - store: PulseStore, -): ActiveProjectsData { - const kinds = ['project-created', 'project-paused', 'project-archived']; - const allEvents = kinds.flatMap((kind) => store.queryByKind(kind)); - allEvents.sort((a, b) => a.occurredAt - b.occurredAt); - - const active = new Set(); - - for (const event of allEvents) { - if (!event.meta) continue; - const meta = JSON.parse(event.meta); - - switch (event.kind) { - case 'project-created': - active.add(meta.projectId); - break; - case 'project-paused': - case 'project-archived': - active.delete(meta.projectId); - break; - } - } - - return { projectIds: [...active] }; -} - -const TRACE_RING_BUFFER_MAX = 20; - -/** - * Fold llm-call-completed + tool-response events for a given projectId - * → ring buffer of last 20 messages + nextCheckAt. - */ -export function buildAgentLoopTraceFromEvents( - store: PulseStore, - projectId: string, -): AgentLoopTraceData { - const kinds = ['llm-call-completed', 'tool-response']; - const allEvents = kinds.flatMap((kind) => store.queryByKind(kind)); - allEvents.sort((a, b) => a.occurredAt - b.occurredAt); - - const messages: TraceMessage[] = []; - let nextCheckAt = 0; - - for (const event of allEvents) { - if (!event.meta) continue; - const meta = JSON.parse(event.meta); - if (meta.projectId !== projectId) continue; - - switch (event.kind) { - case 'llm-call-completed': { - const m = meta as LlmCallCompletedMeta; - messages.push({ - role: 'assistant', - toolCalls: m.toolCalls, - ts: event.occurredAt, - }); - - if (m.toolCalls) { - const setCheck = m.toolCalls.find( - (tc) => tc.name === 'set_next_check', - ); - if (setCheck) { - const tickMs = (setCheck.arguments as { tickMs?: number }).tickMs; - if (typeof tickMs === 'number') { - nextCheckAt = event.occurredAt + tickMs; - } - } - } - break; - } - case 'tool-response': { - const m = meta as ToolResponseMeta; - messages.push({ - role: 'tool', - toolName: m.toolName, - result: m.result, - ts: event.occurredAt, - }); - break; - } - } - } - - while (messages.length > TRACE_RING_BUFFER_MAX) { - messages.shift(); - } - - return { messages, nextCheckAt }; -}