feat(sdk): add durable planning runtime (#2898)
This commit is contained in:
4
sdk/package-lock.json
generated
4
sdk/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
70
sdk/src/planning-journal.test.ts
Normal file
70
sdk/src/planning-journal.test.ts
Normal 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
153
sdk/src/planning-journal.ts
Normal 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');
|
||||
}
|
||||
29
sdk/src/planning-runtime.test.ts
Normal file
29
sdk/src/planning-runtime.test.ts
Normal 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
100
sdk/src/planning-runtime.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user