feat(sdk): add durable planning runtime (#2898)

This commit is contained in:
TÂCHES
2026-04-30 09:03:06 -06:00
committed by GitHub
parent 951d5bf7c0
commit 8cbdbdd2de
6 changed files with 358 additions and 2 deletions

4
sdk/package-lock.json generated
View File

@@ -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",

View File

@@ -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 {

View File

@@ -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);
});
});

153
sdk/src/planning-journal.ts Normal file
View File

@@ -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<string, unknown>;
evidenceIds: string[];
parentEventIds: string[];
trace: Record<string, unknown>;
requestHash: string;
};
export type PlanningJournalAppendInput = {
projectId: string;
type: string;
actor: PlanningEventActor;
payload: Record<string, unknown>;
idempotencyKey: string;
planId?: string;
itemId?: string;
workstreamId?: string;
evidenceIds?: string[];
parentEventIds?: string[];
causationId?: string;
trace?: Record<string, unknown>;
};
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<PlanningEvent> {
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<PlanningEvent[]> {
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<void> {
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<PlanningEvent | null> {
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');
}

View File

@@ -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',
]);
});
});

100
sdk/src/planning-runtime.ts Normal file
View File

@@ -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<string, unknown>) {
return this.journal.append({
projectId: this.options.projectId,
type,
actor: this.options.actor,
planId: input.planId,
itemId: input.itemId,
idempotencyKey: input.idempotencyKey,
payload,
});
}
}