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",
|
"name": "@gsd-build/sdk",
|
||||||
"version": "0.1.0",
|
"version": "1.39.0-rc.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@gsd-build/sdk",
|
"name": "@gsd-build/sdk",
|
||||||
"version": "0.1.0",
|
"version": "1.39.0-rc.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.84",
|
"@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 { ContextEngine } from './context-engine.js';
|
||||||
import { PromptFactory } from './phase-prompt.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 ───────────────────────────────────────────────────────────────
|
// ─── GSD class ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class GSD {
|
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