From 8cbdbdd2de33cdce812ea9875aab53e390b78b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Thu, 30 Apr 2026 09:03:06 -0600 Subject: [PATCH] feat(sdk): add durable planning runtime (#2898) --- sdk/package-lock.json | 4 +- sdk/src/index.ts | 4 + sdk/src/planning-journal.test.ts | 70 ++++++++++++++ sdk/src/planning-journal.ts | 153 +++++++++++++++++++++++++++++++ sdk/src/planning-runtime.test.ts | 29 ++++++ sdk/src/planning-runtime.ts | 100 ++++++++++++++++++++ 6 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 sdk/src/planning-journal.test.ts create mode 100644 sdk/src/planning-journal.ts create mode 100644 sdk/src/planning-runtime.test.ts create mode 100644 sdk/src/planning-runtime.ts diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 2c5feb86b..c9a586f78 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gsd-build/sdk", - "version": "0.1.0", + "version": "1.39.0-rc.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gsd-build/sdk", - "version": "0.1.0", + "version": "1.39.0-rc.4", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.84", diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 898bbc31a..d9e8f811a 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -35,6 +35,10 @@ import { PhaseRunner } from './phase-runner.js'; import { ContextEngine } from './context-engine.js'; import { PromptFactory } from './phase-prompt.js'; +export { PlanningJournal } from './planning-journal.js'; +export type { PlanningEvent, PlanningEventActor, PlanningJournalAppendInput } from './planning-journal.js'; +export { PlanningRuntime } from './planning-runtime.js'; + // ─── GSD class ─────────────────────────────────────────────────────────────── export class GSD { diff --git a/sdk/src/planning-journal.test.ts b/sdk/src/planning-journal.test.ts new file mode 100644 index 000000000..0a5cf66e6 --- /dev/null +++ b/sdk/src/planning-journal.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { PlanningJournal } from './planning-journal.js'; + +describe('PlanningJournal', () => { + it('appends events with monotonic source sequence numbers', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gsd-journal-')); + const journal = new PlanningJournal({ projectDir: dir, sourceId: 'daemon-1', runId: 'run-1' }); + + const first = await journal.append({ + projectId: 'project-1', + type: 'plan.next', + actor: { type: 'agent', id: 'agent-1' }, + payload: { itemId: 'item-1' }, + idempotencyKey: 'next-1', + }); + const second = await journal.append({ + projectId: 'project-1', + type: 'plan.done', + actor: { type: 'agent', id: 'agent-1' }, + payload: { itemId: 'item-1' }, + idempotencyKey: 'done-1', + }); + + expect(first.source.seq).toBe(1); + expect(second.source.seq).toBe(2); + expect(await journal.readAll()).toHaveLength(2); + }); + + it('replays an existing event for duplicate idempotency keys', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gsd-journal-')); + const journal = new PlanningJournal({ projectDir: dir, sourceId: 'sdk-1', runId: 'run-1' }); + + const first = await journal.append({ + projectId: 'project-1', + type: 'plan.checkpoint', + actor: { type: 'agent', id: 'agent-1' }, + payload: { summary: 'Progress' }, + idempotencyKey: 'checkpoint-1', + }); + const replay = await journal.append({ + projectId: 'project-1', + type: 'plan.checkpoint', + actor: { type: 'agent', id: 'agent-1' }, + payload: { summary: 'Progress' }, + idempotencyKey: 'checkpoint-1', + }); + + expect(replay.id).toBe(first.id); + expect(await journal.readAll()).toHaveLength(1); + }); + + it('writes jsonl under .gsd/journal.jsonl', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gsd-journal-')); + const journal = new PlanningJournal({ projectDir: dir, sourceId: 'sdk-1', runId: 'run-1' }); + await journal.append({ + projectId: 'project-1', + type: 'plan.status', + actor: { type: 'agent', id: 'agent-1' }, + payload: {}, + idempotencyKey: 'status-1', + }); + + const raw = await readFile(join(dir, '.gsd', 'journal.jsonl'), 'utf8'); + expect(raw.trim().split('\n')).toHaveLength(1); + expect(JSON.parse(raw).schemaVersion).toBe(1); + }); +}); diff --git a/sdk/src/planning-journal.ts b/sdk/src/planning-journal.ts new file mode 100644 index 000000000..3afeee14d --- /dev/null +++ b/sdk/src/planning-journal.ts @@ -0,0 +1,153 @@ +import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +export type PlanningEventActor = { + type: 'human' | 'agent' | 'runtime' | 'verifier' | 'system'; + id: string; + role?: string; + sessionId?: string; + taskId?: string; +}; + +export type PlanningEvent = { + id: string; + schemaVersion: 1; + projectionVersion: number; + projectId: string; + source: { id: string; kind: 'sdk' | 'daemon' | 'cloud' | 'import'; seq: number; cursor?: string }; + runId: string; + workstreamId?: string; + planId?: string; + itemId?: string; + actor: PlanningEventActor; + authority: 'local' | 'cloud' | 'human_approved' | 'system'; + type: string; + idempotencyKey: string; + causationId?: string; + occurredAt: string; + payload: Record; + evidenceIds: string[]; + parentEventIds: string[]; + trace: Record; + requestHash: string; +}; + +export type PlanningJournalAppendInput = { + projectId: string; + type: string; + actor: PlanningEventActor; + payload: Record; + idempotencyKey: string; + planId?: string; + itemId?: string; + workstreamId?: string; + evidenceIds?: string[]; + parentEventIds?: string[]; + causationId?: string; + trace?: Record; +}; + +export class PlanningJournal { + private readonly path: string; + + constructor( + private readonly options: { + projectDir: string; + sourceId: string; + runId: string; + sourceKind?: 'sdk' | 'daemon' | 'cloud' | 'import'; + projectionVersion?: number; + }, + ) { + this.path = join(options.projectDir, '.gsd', 'journal.jsonl'); + } + + async append(input: PlanningJournalAppendInput): Promise { + const existing = await this.findByIdempotency(input.idempotencyKey); + const requestHash = hashRequest(input); + if (existing) { + if (existing.requestHash !== requestHash) { + throw new Error(`conflicting idempotency key: ${input.idempotencyKey}`); + } + return existing; + } + + const events = await this.readAll(); + const event: PlanningEvent = { + id: randomUUID(), + schemaVersion: 1, + projectionVersion: this.options.projectionVersion ?? 1, + projectId: input.projectId, + source: { + id: this.options.sourceId, + kind: this.options.sourceKind ?? 'sdk', + seq: events.filter((candidate) => candidate.source.id === this.options.sourceId).length + 1, + }, + runId: this.options.runId, + workstreamId: input.workstreamId, + planId: input.planId, + itemId: input.itemId, + actor: input.actor, + authority: 'local', + type: input.type, + idempotencyKey: input.idempotencyKey, + causationId: input.causationId, + occurredAt: new Date().toISOString(), + payload: input.payload, + evidenceIds: input.evidenceIds ?? [], + parentEventIds: input.parentEventIds ?? [], + trace: input.trace ?? {}, + requestHash, + }; + + await mkdir(join(this.options.projectDir, '.gsd'), { recursive: true }); + await appendFile(this.path, `${JSON.stringify(event)}\n`, 'utf8'); + return event; + } + + async readAll(): Promise { + let raw = ''; + try { + raw = await readFile(this.path, 'utf8'); + } catch { + return []; + } + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line) as PlanningEvent); + } + + async compact(events: PlanningEvent[]): Promise { + await mkdir(join(this.options.projectDir, '.gsd'), { recursive: true }); + const tmp = `${this.path}.tmp`; + await writeFile( + tmp, + events.map((event) => JSON.stringify(event)).join('\n') + (events.length ? '\n' : ''), + 'utf8', + ); + await rename(tmp, this.path); + } + + private async findByIdempotency(idempotencyKey: string): Promise { + const events = await this.readAll(); + return events.find((event) => event.idempotencyKey === idempotencyKey) ?? null; + } +} + +function hashRequest(input: PlanningJournalAppendInput): string { + return createHash('sha256') + .update( + JSON.stringify({ + projectId: input.projectId, + type: input.type, + payload: input.payload, + planId: input.planId, + itemId: input.itemId, + actor: input.actor, + }), + ) + .digest('hex'); +} diff --git a/sdk/src/planning-runtime.test.ts b/sdk/src/planning-runtime.test.ts new file mode 100644 index 000000000..bc230f621 --- /dev/null +++ b/sdk/src/planning-runtime.test.ts @@ -0,0 +1,29 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { PlanningRuntime } from './planning-runtime.js'; + +describe('PlanningRuntime', () => { + it('records intent events through the durable journal', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gsd-runtime-')); + const runtime = new PlanningRuntime({ + projectDir: dir, + projectId: 'project-1', + runId: 'run-1', + sourceId: 'sdk-1', + actor: { type: 'agent', id: 'agent-1', role: 'executor' }, + }); + + await runtime.status({ idempotencyKey: 'status-1' }); + await runtime.next({ idempotencyKey: 'next-1', createPlan: { title: 'Plan', items: [{ title: 'Item' }] } }); + await runtime.checkpoint({ idempotencyKey: 'checkpoint-1', summary: 'Progress' }); + + const events = await runtime.journal.readAll(); + expect(events.map((event) => event.type)).toEqual([ + 'plan.status', + 'plan.next', + 'plan.checkpoint', + ]); + }); +}); diff --git a/sdk/src/planning-runtime.ts b/sdk/src/planning-runtime.ts new file mode 100644 index 000000000..4ead59355 --- /dev/null +++ b/sdk/src/planning-runtime.ts @@ -0,0 +1,100 @@ +import { PlanningJournal, type PlanningEventActor } from './planning-journal.js'; + +type RuntimeOptions = { + projectDir: string; + projectId: string; + runId: string; + sourceId: string; + actor: PlanningEventActor; +}; + +type RuntimeMeta = { + idempotencyKey: string; + planId?: string; + itemId?: string; +}; + +type NextInput = RuntimeMeta & { + selector?: { itemId?: string; titleIncludes?: string }; + createPlan?: { title: string; items: Array<{ title: string; description?: string; dependsOn?: string[] }> }; +}; + +type CheckpointInput = RuntimeMeta & { + summary?: string; + subTasks?: Array<{ id?: string; text: string }>; + agentCriteria?: Array<{ id?: string; text: string }>; + criteriaMet?: string[]; + blocked?: { reason: string; nextAction?: string }; +}; + +type DoneInput = RuntimeMeta & { + summary: string; + blockers?: string[]; + criteriaMet?: string[]; + evidenceRefs?: string[]; + evidencePolicy?: 'auto' | 'explicit' | 'waive'; + evidenceWaiverReason?: string; + advance?: boolean; +}; + +export class PlanningRuntime { + readonly journal: PlanningJournal; + + constructor(private readonly options: RuntimeOptions) { + this.journal = new PlanningJournal({ + projectDir: options.projectDir, + sourceId: options.sourceId, + runId: options.runId, + sourceKind: 'sdk', + }); + } + + status(input: RuntimeMeta) { + return this.record('plan.status', input, {}); + } + + next(input: NextInput) { + return this.record('plan.next', input, { + selector: input.selector, + createPlan: input.createPlan, + }); + } + + checkpoint(input: CheckpointInput) { + return this.record('plan.checkpoint', input, { + summary: input.summary, + subTasks: input.subTasks, + agentCriteria: input.agentCriteria, + criteriaMet: input.criteriaMet, + blocked: input.blocked, + }); + } + + sync(input: RuntimeMeta & { cursor?: string }) { + return this.record('plan.sync', input, { cursor: input.cursor }); + } + + done(input: DoneInput) { + return this.record('plan.done', input, { + summary: input.summary, + blockers: input.blockers, + criteriaMet: input.criteriaMet, + evidenceRefs: input.evidenceRefs, + evidencePolicy: input.evidencePolicy ?? 'auto', + evidenceWaiverReason: input.evidenceWaiverReason, + advance: input.advance ?? true, + }); + } + + private record(type: string, input: RuntimeMeta, payload: Record) { + return this.journal.append({ + projectId: this.options.projectId, + type, + actor: this.options.actor, + planId: input.planId, + itemId: input.itemId, + idempotencyKey: input.idempotencyKey, + payload, + }); + } +}