diff --git a/.changeset/persist-orchestration-state.md b/.changeset/persist-orchestration-state.md new file mode 100644 index 0000000..b48b0d4 --- /dev/null +++ b/.changeset/persist-orchestration-state.md @@ -0,0 +1,11 @@ +--- +'@gemstack/framework': minor +--- + +Persist the orchestration state so a restarted dashboard can resume it + +A run now saves its orchestration state (the stack rationale, loop status, and decisions ledger) so it survives a restart. Because the dashboard is a pure projection of the run's `FrameworkEvent` stream, persisting is durably logging that stream: each run appends to `.framework/events.jsonl` in the workspace, keeps a small derived `run.json` snapshot beside it, and writes a human-readable `DECISIONS.md` at the root. `framework --resume` reopens the last run's dashboard read-only by replaying the log into a fresh stream, exactly as it looked, without running the agent again. `--no-persist` opts out of writing state. + +Per the design sync we do not persist the agent's chat transcript (Claude Code owns that); only our own orchestration events. New `RunStore` module and exports (`RunStore`, `nodeStoreFs`, `metaFromEvents`, `applyEventToMeta`, `RunMeta`, `StoreFs`, ...); new `--resume` / `--no-persist` CLI flags. + +Part of #209. Closes #211. diff --git a/packages/framework/README.md b/packages/framework/README.md index 2bd84db..2a52b08 100644 --- a/packages/framework/README.md +++ b/packages/framework/README.md @@ -70,12 +70,26 @@ framework --fake Offline demo (no CLI, no model, deterministic). --deploy Narrate a deploy decision (e.g. cloudflare, dokploy). --port Dashboard port (default: 4477). --no-dashboard Run headless. + --resume Reopen the last run's dashboard from .framework/ (see below). + --no-persist Do not write the orchestration state to .framework/. --session-link Link to the live agent session (shown on the dashboard). ``` The live path needs the Claude Code CLI installed (`claude` on `PATH`). The `--fake` path needs neither a CLI nor a model, so it is what CI runs. +### Persistence + resume + +The orchestration state (the stack rationale, loop status, and decisions ledger) +is our part of the run, not the agent's chat transcript, so we persist it. Each +run appends its event stream to `.framework/events.jsonl` in the workspace, with a +small `run.json` snapshot beside it and a human-readable `DECISIONS.md` at the +root. Because the dashboard is a pure projection of that stream, a restart can +replay it: `framework --resume` reopens the last run's dashboard read-only, exactly +as it looked, without running the agent again. Add `--cwd ` to resume a run +from another workspace. Pass `--no-persist` to skip writing state. We do not +persist the agent's transcript; Claude Code owns that. + ### Watching the live session Every run surfaces the current Claude Code **session id** on the dashboard (and in diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index 194af72..36c66fb 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -35,6 +35,15 @@ test('parseArgs reads permission-mode and skip-permissions', () => { assert.equal(opts.skipPermissions, true) }) +test('parseArgs persists by default and reads --resume / --no-persist (#211)', () => { + const dflt = parseArgs(['x']) + assert.equal(dflt.persist, true) + assert.equal(dflt.resume, false) + const opts = parseArgs(['--resume', '--no-persist']) + assert.equal(opts.resume, true) + assert.equal(opts.persist, false) +}) + test('chooseSessionLink defaults a live run to the claude.ai/code session list (#212)', () => { assert.equal(chooseSessionLink({ sessionLink: undefined }, false), CLAUDE_CODE_SESSION_LIST) assert.equal(CLAUDE_CODE_SESSION_LIST, 'https://claude.ai/code') diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index b3912ba..c1705e6 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -1,14 +1,21 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' -import { cloudflareTarget, dokployTarget, type DeployTarget } from '@gemstack/ai-autopilot' +import { cloudflareTarget, dokployTarget, nodeLedgerFs, saveLedger, type DeployTarget } from '@gemstack/ai-autopilot' import { ClaudeCodeDriver, type ClaudeCodeDriverOptions, type Driver, type PermissionMode } from './driver/index.js' import { hostExecutor } from './host-exec.js' import { startDashboard, type Dashboard } from './dashboard/index.js' import { formatFrameworkEvent, type FrameworkEvent } from './events.js' -import { runFramework, type DeployDecision, type RunFrameworkOptions, type ServeConfig } from './run.js' +import { + runFramework, + type DeployDecision, + type RunFrameworkOptions, + type RunFrameworkResult, + type ServeConfig, +} from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' import { discoverExtensions, readProjectSignals } from './extensions.js' import { preflight } from './preflight.js' +import { RunStore } from './store/index.js' /** * The claude.ai/code session list — the default link shown for a live run. It is @@ -75,6 +82,9 @@ Options: --dokploy-app Dokploy application id (required for --deploy dokploy). --port Dashboard port (default: 4477). --no-dashboard Do not start the localhost dashboard. + --resume Reopen the last run's dashboard from .framework/ in --cwd + (read-only replay; no new agent run). Survives a restart. + --no-persist Do not write the orchestration state to .framework/. --skip-preflight Skip the prerequisite checks before a live run. --session-link Link to the live agent session (shown on the dashboard). Defaults to https://claude.ai/code for a live run, where @@ -117,6 +127,8 @@ export interface CliOptions { sessionLink?: string | undefined permissionMode?: PermissionMode | undefined skipPermissions: boolean + resume: boolean + persist: boolean error?: string } @@ -133,6 +145,8 @@ export function parseArgs(argv: string[]): CliOptions { dashboard: true, composeExtensions: false, skipPermissions: false, + resume: false, + persist: true, } const PERMISSION_MODES: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions', 'plan'] const words: string[] = [] @@ -156,6 +170,12 @@ export function parseArgs(argv: string[]): CliOptions { case '--compose-extensions': opts.composeExtensions = true break + case '--resume': + opts.resume = true + break + case '--no-persist': + opts.persist = false + break case '--skip-preflight': opts.skipPreflight = true break @@ -269,6 +289,11 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { io.out(formatFrameworkEvent(event)) dashboard?.push(event) + void store?.append(event) } // Detection signals: fixed for the fake demo, read from the project otherwise so @@ -375,13 +414,17 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { + const cwd = opts.cwd ?? process.cwd() + let store: RunStore + try { + store = await RunStore.open(cwd, { fresh: false }) + } catch (err) { + io.err(`could not open .framework/ in ${cwd} (${err instanceof Error ? err.message : String(err)})`) + return 1 + } + const events = await store.loadEvents() + if (events.length === 0) { + io.err(`Nothing to resume: no saved run found in ${store.dir}. Run \`framework "..."\` first.`) + return 1 + } + const meta = (await store.readMeta()) ?? store.snapshot() + + let dashboard: Dashboard | undefined + if (opts.dashboard) { + try { + dashboard = await startDashboard(opts.port !== undefined ? { port: opts.port } : {}) + io.out(`◆ dashboard (resumed): ${dashboard.url}`) + } catch (err) { + io.err(`could not start dashboard (${err instanceof Error ? err.message : String(err)}); replaying to terminal only`) + } + } + + for (const event of events) { + io.out(formatFrameworkEvent(event)) + dashboard?.push(event) + } + io.out(`\n• resumed ${meta.status} run of "${meta.intent ?? 'unknown intent'}" (${events.length} event(s), ${meta.passes} pass(es)).`) + + if (dashboard) { + io.out(`\nDashboard live at ${dashboard.url}. Press Ctrl+C to exit.`) + await waitForInterrupt() + await dashboard.close() + } + return 0 +} + +/** + * Persist the decisions ledger as a human-readable `DECISIONS.md` at the + * workspace root, reusing ai-autopilot's markdown store. Best-effort: a write + * failure is reported but never fails the run. + */ +async function writeDecisions(cwd: string, ledger: RunFrameworkResult['ledger'], io: CliIO): Promise { + if (ledger.all().length === 0) return + try { + await saveLedger(nodeLedgerFs(), ledger, join(cwd, 'DECISIONS.md')) + } catch (err) { + io.err(`could not write DECISIONS.md (${err instanceof Error ? err.message : String(err)})`) + } +} + /** * Build a real {@link DeployTarget} for a known target name, or return an error / * nothing. `cloudflare` runs `wrangler` via a host executor bound to the build's diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 0cef10f..5559ae2 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -69,6 +69,20 @@ export { SESSION_ID_PLACEHOLDER, } from './events.js' export { startDashboard, dashboardHtml, type Dashboard, type DashboardOptions } from './dashboard/index.js' +export { + RunStore, + nodeStoreFs, + applyEventToMeta, + metaFromEvents, + FRAMEWORK_DIR, + EVENTS_FILE, + META_FILE, + RUN_META_VERSION, + type StoreFs, + type RunMeta, + type RunStatus, + type OpenStoreOptions, +} from './store/index.js' export { runCli, parseArgs, buildDeployTarget, type CliIO, type CliOptions } from './cli.js' export { preflight, diff --git a/packages/framework/src/store/index.ts b/packages/framework/src/store/index.ts new file mode 100644 index 0000000..ad0654b --- /dev/null +++ b/packages/framework/src/store/index.ts @@ -0,0 +1,14 @@ +export { + RunStore, + nodeStoreFs, + applyEventToMeta, + metaFromEvents, + FRAMEWORK_DIR, + EVENTS_FILE, + META_FILE, + RUN_META_VERSION, + type StoreFs, + type RunMeta, + type RunStatus, + type OpenStoreOptions, +} from './run-store.js' diff --git a/packages/framework/src/store/run-store.test.ts b/packages/framework/src/store/run-store.test.ts new file mode 100644 index 0000000..287252b --- /dev/null +++ b/packages/framework/src/store/run-store.test.ts @@ -0,0 +1,118 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { join } from 'node:path' +import { RunStore, applyEventToMeta, metaFromEvents, RUN_META_VERSION, type StoreFs, type RunMeta } from './run-store.js' +import type { FrameworkEvent } from '../events.js' + +/** An in-memory {@link StoreFs} so the store logic is tested without touching disk. */ +function memFs(seed: Record = {}): StoreFs & { files: Map } { + const files = new Map(Object.entries(seed)) + return { + files, + async read(path) { + const v = files.get(path) + if (v === undefined) throw new Error(`ENOENT: ${path}`) + return v + }, + async write(path, contents) { + files.set(path, contents) + }, + async append(path, contents) { + files.set(path, (files.get(path) ?? '') + contents) + }, + async exists(path) { + return files.has(path) + }, + async mkdir() { + // no-op: the memory fs has no directories + }, + } +} + +const AT = '2026-07-04T00:00:00.000Z' +const CWD = '/ws' +const EVENTS = join(CWD, '.framework', 'events.jsonl') +const META = join(CWD, '.framework', 'run.json') + +const RUN: FrameworkEvent[] = [ + { kind: 'session', driver: 'fake', workspace: CWD, fake: true, sessionLink: 'https://claude.ai/code' }, + { kind: 'bootstrap', event: { type: 'scope', scope: 'full', intent: 'a blog with comments' } }, + { kind: 'session-update', sessionId: 'sess-123', sessionLink: 'https://ex.com/s/sess-123' }, + { kind: 'bootstrap', event: { type: 'checklist', pass: 1, blockers: ['no tests'], passing: false } }, + { kind: 'bootstrap', event: { type: 'checklist', pass: 2, blockers: [], passing: true } }, + { kind: 'end', ok: true }, +] + +test('fresh open truncates the log and writes an initial meta snapshot', async () => { + const fs = memFs({ [EVENTS]: 'stale\n' }) + const store = await RunStore.open(CWD, { fs, fresh: true, now: AT }) + assert.equal(fs.files.get(EVENTS), '') + const meta = await store.readMeta() + assert.equal(meta?.version, RUN_META_VERSION) + assert.equal(meta?.status, 'running') + assert.equal(meta?.startedAt, AT) +}) + +test('append writes one JSONL line per event and derives meta', async () => { + const fs = memFs() + const store = await RunStore.open(CWD, { fs, fresh: true, now: AT }) + for (const event of RUN) await store.append(event) + + const lines = (fs.files.get(EVENTS) ?? '').trim().split('\n') + assert.equal(lines.length, RUN.length) + assert.deepEqual(JSON.parse(lines[0]!), RUN[0]) + + const meta = JSON.parse(fs.files.get(META)!) as RunMeta + assert.equal(meta.intent, 'a blog with comments') + assert.equal(meta.scope, 'full') + assert.equal(meta.driver, 'fake') + assert.equal(meta.workspace, CWD) + assert.equal(meta.sessionId, 'sess-123') + assert.equal(meta.sessionLink, 'https://ex.com/s/sess-123') + assert.equal(meta.passes, 2) + assert.equal(meta.status, 'done') +}) + +test('loadEvents round-trips the persisted log', async () => { + const fs = memFs() + const store = await RunStore.open(CWD, { fs, fresh: true, now: AT }) + for (const event of RUN) await store.append(event) + const loaded = await store.loadEvents() + assert.deepEqual(loaded, RUN) +}) + +test('loadEvents skips a torn trailing line from an interrupted write', async () => { + const good = RUN.slice(0, 2).map(e => JSON.stringify(e)).join('\n') + const fs = memFs({ [EVENTS]: good + '\n{"kind":"log","mess' }) + const store = await RunStore.open(CWD, { fs, fresh: false, now: AT }) + const loaded = await store.loadEvents() + assert.equal(loaded.length, 2) + assert.equal(loaded[1]!.kind, 'bootstrap') +}) + +test('a non-fresh open preserves the existing log (resume)', async () => { + const existing = RUN.map(e => JSON.stringify(e)).join('\n') + '\n' + const fs = memFs({ [EVENTS]: existing }) + const store = await RunStore.open(CWD, { fs, fresh: false, now: AT }) + assert.equal(fs.files.get(EVENTS), existing) + assert.equal((await store.loadEvents()).length, RUN.length) +}) + +test('loadEvents on a never-run workspace yields an empty array', async () => { + const store = await RunStore.open(CWD, { fs: memFs(), fresh: false, now: AT }) + assert.deepEqual(await store.loadEvents(), []) +}) + +test('metaFromEvents reconstructs the same snapshot as live appends', async () => { + const meta = metaFromEvents(RUN, AT) + assert.equal(meta.intent, 'a blog with comments') + assert.equal(meta.passes, 2) + assert.equal(meta.status, 'done') + assert.equal(meta.startedAt, AT) +}) + +test('applyEventToMeta marks a thrown run as failed', () => { + const base = metaFromEvents(RUN.slice(0, 4), AT) + const failed = applyEventToMeta(base, { kind: 'end', ok: false, detail: 'boom' }, AT) + assert.equal(failed.status, 'failed') +}) diff --git a/packages/framework/src/store/run-store.ts b/packages/framework/src/store/run-store.ts new file mode 100644 index 0000000..4a9211a --- /dev/null +++ b/packages/framework/src/store/run-store.ts @@ -0,0 +1,274 @@ +import { join } from 'node:path' +import type { FrameworkEvent } from '../events.js' + +/** + * Persisted orchestration state (#211). The dashboard is a pure projection of the + * {@link FrameworkEvent} stream, so persisting *is* durably logging that stream: + * the stack rationale, the loop status, and the decisions ledger are all events + * that already flow through it. We store the log append-only and rehydrate a + * restarted dashboard by replaying it into a fresh stream — no separate state + * model to keep in sync. Per the sync, we do **not** persist the agent's chat + * transcript (Claude Code owns that); only our own orchestration events. + */ + +/** The directory, under the workspace root, that holds the persisted run. */ +export const FRAMEWORK_DIR = '.framework' + +/** The append-only event log: one {@link FrameworkEvent} per line (JSONL). */ +export const EVENTS_FILE = 'events.jsonl' + +/** A small snapshot for cheap status reads without replaying the whole log. */ +export const META_FILE = 'run.json' + +/** Bumped when the on-disk shape changes, so a reader can detect an old file. */ +export const RUN_META_VERSION = 1 + +/** How a run ended (or that it is still going). */ +export type RunStatus = 'running' | 'done' | 'failed' + +/** + * A queryable snapshot of the run, derived entirely from the event log. Lets the + * dashboard render a header (and a future run list) without parsing every line. + */ +export interface RunMeta { + version: number + status: RunStatus + /** ISO timestamp the store was opened (run start). */ + startedAt: string + /** ISO timestamp of the last event written. */ + updatedAt: string + /** Full-fledged loop passes performed so far. */ + passes: number + /** What the user asked to build (from the `scope` event). */ + intent?: string + scope?: string + /** The wrapped agent (from the `session` event). */ + driver?: string + /** The workspace the agent builds in (from the `session` event). */ + workspace?: string + /** The wrapped agent's real session id, once it reports one. */ + sessionId?: string + /** The link shown to jump into the live agent session. */ + sessionLink?: string +} + +/** + * The slice of a filesystem {@link RunStore} needs. Mirrors the `LedgerFs` seam + * in ai-autopilot's decisions store: the store logic is pure and testable with an + * in-memory fs, and only {@link nodeStoreFs} touches disk. + */ +export interface StoreFs { + read(path: string): Promise + write(path: string, contents: string): Promise + append(path: string, contents: string): Promise + exists(path: string): Promise + mkdir(path: string): Promise +} + +/** Options for {@link RunStore.open}. */ +export interface OpenStoreOptions { + /** The filesystem adapter. Default {@link nodeStoreFs}. */ + fs?: StoreFs + /** + * Truncate any prior log so this is a clean run (MVP: one run per workspace). + * `false` (the default) opens read-only-ish for {@link RunStore.loadEvents} — + * the `--resume` path — and does not clear the log. + */ + fresh?: boolean + /** The wall-clock start, ISO. Injectable so tests are deterministic. */ + now?: string +} + +/** + * Fold one event into the running {@link RunMeta}. Pure, so the same derivation + * drives both a live append and reconstructing meta from a replayed log. + */ +export function applyEventToMeta(meta: RunMeta, event: FrameworkEvent, at: string): RunMeta { + const next: RunMeta = { ...meta, updatedAt: at } + switch (event.kind) { + case 'session': + next.driver = event.driver + next.workspace = event.workspace + if (event.sessionLink) next.sessionLink = event.sessionLink + break + case 'session-update': + next.sessionId = event.sessionId + if (event.sessionLink) next.sessionLink = event.sessionLink + break + case 'bootstrap': { + const b = event.event + if (b.type === 'scope') { + next.intent = b.intent + next.scope = b.scope + } else if (b.type === 'checklist') { + next.passes = b.pass + } else if (b.type === 'done') { + next.passes = b.result.passes + } + break + } + case 'end': + next.status = event.ok ? 'done' : 'failed' + break + default: + break + } + return next +} + +/** Rebuild {@link RunMeta} from a full event log (used when resuming). */ +export function metaFromEvents(events: readonly FrameworkEvent[], startedAt: string): RunMeta { + let meta: RunMeta = { version: RUN_META_VERSION, status: 'running', startedAt, updatedAt: startedAt, passes: 0 } + for (const event of events) meta = applyEventToMeta(meta, event, startedAt) + return meta +} + +/** + * Durable, append-only store for a single run's orchestration events, plus a + * derived {@link RunMeta} snapshot. Writes are serialized through one tail + * promise so an append and its meta rewrite never interleave; {@link close} + * flushes that queue before the process exits. + */ +export class RunStore { + private tail: Promise = Promise.resolve() + private meta: RunMeta + + private constructor( + private readonly fs: StoreFs, + readonly dir: string, + private readonly now: string, + startMeta: RunMeta, + ) { + this.meta = startMeta + } + + /** The event log path. */ + get eventsPath(): string { + return join(this.dir, EVENTS_FILE) + } + + /** The meta snapshot path. */ + get metaPath(): string { + return join(this.dir, META_FILE) + } + + /** + * Open (creating `.framework/` if needed) under the workspace `cwd`. `fresh` + * truncates any prior log for a new run; the default preserves it so a resume + * can {@link loadEvents}. + */ + static async open(cwd: string, opts: OpenStoreOptions = {}): Promise { + const fs = opts.fs ?? nodeStoreFs() + const dir = join(cwd, FRAMEWORK_DIR) + const now = opts.now ?? new Date().toISOString() + await fs.mkdir(dir) + const store = new RunStore(fs, dir, now, { + version: RUN_META_VERSION, + status: 'running', + startedAt: now, + updatedAt: now, + passes: 0, + }) + if (opts.fresh) { + await fs.write(store.eventsPath, '') + await store.writeMeta() + } + return store + } + + /** + * Append one event to the log and refresh the meta snapshot. Fire-and-forget at + * the call site: internally chained so writes stay ordered. A failed write is + * swallowed (persistence is best-effort — it must never break a live run). + */ + append(event: FrameworkEvent): Promise { + this.meta = applyEventToMeta(this.meta, event, this.now) + this.tail = this.tail + .then(() => this.fs.append(this.eventsPath, JSON.stringify(event) + '\n')) + .then(() => this.writeMeta()) + .catch(err => { + console.error('[framework] failed to persist orchestration state:', err) + }) + return this.tail + } + + /** Flush any queued writes. Call before exit so the log is complete on disk. */ + async close(): Promise { + await this.tail + } + + /** The current derived snapshot. */ + snapshot(): RunMeta { + return { ...this.meta } + } + + /** + * Read and parse the persisted event log. A blank or malformed trailing line + * (e.g. a crash mid-write) is skipped rather than throwing, so a partial run + * still replays everything up to the cut. Missing file yields `[]`. + */ + async loadEvents(): Promise { + if (!(await this.fs.exists(this.eventsPath))) return [] + const raw = await this.fs.read(this.eventsPath) + const events: FrameworkEvent[] = [] + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + try { + events.push(JSON.parse(trimmed) as FrameworkEvent) + } catch { + // A torn last line from an interrupted write; stop, keep what we have. + break + } + } + return events + } + + /** Read the persisted meta snapshot, or `undefined` if none/unreadable. */ + async readMeta(): Promise { + if (!(await this.fs.exists(this.metaPath))) return undefined + try { + return JSON.parse(await this.fs.read(this.metaPath)) as RunMeta + } catch { + return undefined + } + } + + private writeMeta(): Promise { + return this.fs.write(this.metaPath, JSON.stringify(this.meta, null, 2) + '\n') + } +} + +/** + * A {@link StoreFs} backed by `node:fs/promises`. The import is dynamic so the + * store core stays free of a hard `node:fs` dependency — same convention as + * ai-autopilot's `nodeLedgerFs`. + */ +export function nodeStoreFs(): StoreFs { + return { + async read(path) { + const { readFile } = await import('node:fs/promises') + return readFile(path, 'utf8') + }, + async write(path, contents) { + const { writeFile } = await import('node:fs/promises') + await writeFile(path, contents, 'utf8') + }, + async append(path, contents) { + const { appendFile } = await import('node:fs/promises') + await appendFile(path, contents, 'utf8') + }, + async exists(path) { + const { stat } = await import('node:fs/promises') + try { + return (await stat(path)).isFile() + } catch { + return false + } + }, + async mkdir(path) { + const { mkdir } = await import('node:fs/promises') + await mkdir(path, { recursive: true }) + }, + } +}