Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/persist-orchestration-state.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions packages/framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,26 @@ framework --fake Offline demo (no CLI, no model, deterministic).
--deploy <target> Narrate a deploy decision (e.g. cloudflare, dokploy).
--port <n> 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 <url> 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 <dir>` 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
Expand Down
9 changes: 9 additions & 0 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
109 changes: 106 additions & 3 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -75,6 +82,9 @@ Options:
--dokploy-app <id> Dokploy application id (required for --deploy dokploy).
--port <n> 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 <url> Link to the live agent session (shown on the dashboard).
Defaults to https://claude.ai/code for a live run, where
Expand Down Expand Up @@ -117,6 +127,8 @@ export interface CliOptions {
sessionLink?: string | undefined
permissionMode?: PermissionMode | undefined
skipPermissions: boolean
resume: boolean
persist: boolean
error?: string
}

Expand All @@ -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[] = []
Expand All @@ -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
Expand Down Expand Up @@ -269,6 +289,11 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
return result.ok ? 0 : 1
}

// Resume a previous run's dashboard from its persisted log — the reload half of
// #211. No agent runs; we just replay the saved events into a fresh stream so
// the dashboard rehydrates exactly as it looked, then leave it up read-only.
if (opts.resume) return resumeRun(opts, io)

const fake = opts.fake
const intent = opts.intent || (fake ? FAKE_INTENT : '')
if (!intent) {
Expand Down Expand Up @@ -337,9 +362,23 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
}
}

// Persist the orchestration state so a restart can --resume it (#211). The log
// is the dashboard's own event stream, appended to .framework/ in the workspace.
// Best-effort: a store that fails to open just means no persistence, never a
// failed run. --no-persist opts out entirely.
let store: RunStore | undefined
if (opts.persist) {
try {
store = await RunStore.open(cwd, { fresh: true })
} catch (err) {
io.err(`could not persist run state (${err instanceof Error ? err.message : String(err)}); continuing without it`)
}
}

const onEvent = (event: FrameworkEvent) => {
io.out(formatFrameworkEvent(event))
dashboard?.push(event)
void store?.append(event)
}

// Detection signals: fixed for the fake demo, read from the project otherwise so
Expand Down Expand Up @@ -375,13 +414,17 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
}

try {
const { result, preview } = await runFramework(runOpts)
const { result, preview, ledger } = await runFramework(runOpts)
io.out(
result.productionGrade
? `\n✓ production-grade in ${result.passes} pass(es).`
: `\n• prototype ready${result.stoppedEarly ? ` (stopped with ${result.blockers.length} blocker(s))` : ''}.`,
)
if (preview) io.out(`\n▶ Your app is running at ${preview.url} — open it in a browser.`)
// Flush the event log, and drop a human-readable DECISIONS.md beside it so the
// ledger is legible without the dashboard. Both best-effort.
await store?.close()
await writeDecisions(cwd, ledger, io)
// Stay up while the dashboard and/or the app are live, then tear both down.
if (dashboard || preview) {
if (dashboard) io.out(`\nDashboard still live at ${dashboard.url}. Press Ctrl+C to exit.`)
Expand All @@ -393,11 +436,71 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
return 0
} catch (err) {
io.err(`\n✗ run failed: ${err instanceof Error ? err.message : String(err)}`)
await store?.close()
await dashboard?.close()
return 1
}
}

/**
* Reopen the last run from its persisted `.framework/` log and replay it into a
* fresh dashboard (#211). No agent runs; the dashboard rehydrates from the saved
* event stream and stays up read-only until Ctrl+C.
*/
async function resumeRun(opts: CliOptions, io: CliIO): Promise<number> {
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<void> {
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
Expand Down
14 changes: 14 additions & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/framework/src/store/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading