UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

806 lines 37.9 kB
import { join } from 'node:path'; import { hostname } from 'node:os'; import { nodeFs } from '../node-fs.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. Same * `.the-framework/` directory as the committed project log (#313): one dir holds * both the transient run state (events.jsonl / run.json / runs/) and the DB * (LOGS.md); a seeded `.the-framework/.gitignore` keeps the run state untracked. */ export const FRAMEWORK_DIR = '.the-framework'; /** * Per-run worktrees live under `<repo>/.the-framework/worktrees/` (#736). Kept out of git by * the install-time `.the-framework/.gitignore` (`*` rule, #313), so a worktree's checkout never * shows up as dirty in the parent. Declared here beside {@link FRAMEWORK_DIR} rather than in * `worktree.ts`, which imports from this module: {@link readLiveMetas} needs it to find the * runs living in those worktrees, and the other direction would be an import cycle. */ export const WORKTREES_DIR = 'worktrees'; /** 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'; /** * Where finished runs are archived, so the dashboard can list a project's run * history (#303). The live run stays at `events.jsonl`/`run.json` (the daemon * tails it); on {@link RunStore.close} a copy lands here as `<id>.jsonl` + * `<id>.json`, giving the history sidebar a per-run log to replay. */ export const RUNS_DIR = 'runs'; /** * Where a project's finished runs are archived now (#1179): `.the-framework/<user>/sessions/`, * which the install-time ignore un-ignores so the history is committed and survives a * `git clean -fdx`. {@link RUNS_DIR} stays the transient location — a run worktree still archives * into its own throwaway checkout there, and it is where every run archived before this shipped * still lives, so both are read. * * The name lives here beside its sibling rather than in `sessions.ts`, which owns the per-user * naming: that module reads the store, so the constant travelling the other way would be a cycle. */ export const SESSIONS_DIR = 'sessions'; /** Filesystem-safe, lexicographically-sortable run id from an ISO start time. */ export function runIdFromStartedAt(startedAt) { // ISO is fixed-width, so replacing the `:`/`.` separators keeps lexical order // in step with chronological order — the history list sorts by id alone. return startedAt.replace(/[:.]/g, '-'); } /** A run id is path-safe: no separators or traversal, only our own charset. */ export function isSafeRunId(id) { return /^[A-Za-z0-9_-]+$/.test(id); } /** * The inverse of {@link runIdFromStartedAt}, for a caller that has the id but not the meta * (#1251): the CLI's end-of-run handoff needs the start time to tell the run's own PR from a * predecessor's on the same branch name. Undefined for an id that is not one of ours. */ export function startedAtFromRunId(id) { const match = /^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(id); return match ? `${match[1]}:${match[2]}:${match[3]}.${match[4]}Z` : undefined; } /** Bumped when the on-disk shape changes, so a reader can detect an old file. */ export const RUN_META_VERSION = 2; /** * 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, event, at) { const next = { ...meta, updatedAt: at }; switch (event.kind) { case 'session': next.driver = event.driver; next.workspace = event.workspace; if (event.sessionLink) next.sessionLink = event.sessionLink; // Per-leg (#1438): a leg that recorded no model leaves it unknown rather than // inheriting the prior leg's — the agent may have resolved a different default. if (event.model) next.model = event.model; else delete next.model; break; case 'session-update': next.sessionId = event.sessionId; if (event.sessionLink) next.sessionLink = event.sessionLink; break; case 'session-name': next.sessionName = event.name; break; case 'ready-for-merge': next.readyForMerge = true; break; case 'choice': next.pendingChoice = { id: event.id, title: event.title }; break; case 'choice-resolved': if (next.pendingChoice?.id === event.id) delete next.pendingChoice; 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 'browser-stream': next.browserStreamPort = event.port; break; case 'handoff-armed': next.handoff = { push: event.push, pr: event.pr, ...(event.merge !== undefined ? { merge: event.merge } : {}) }; break; case 'handoff': next.handoffReport = event.outcome; if (event.outcome !== 'failed' && event.merge) next.mergeOutcome = event.merge.outcome; break; case 'ticket': next.ticket = event.path; break; case 'queue-entry': next.queueEntry = event.entry; break; case 'branch': next.branch = event.branch; break; case 'settled': next.settledAt = at; break; case 'bind': next.boundProjectId = event.projectId; break; case 'driver': // Any new turn means the agent is working again, so the run is no longer parked (#785). if (event.event.type === 'start') delete next.settledAt; break; case 'end': next.status = event.ok ? 'done' : event.stopped ? 'stopped' : 'failed'; delete next.pendingChoice; // a finished run is not awaiting anything delete next.settledAt; // nor is it waiting on you // The bridge dies with the run, so a kept port would send the pane at whatever else // the OS handed that number next. delete next.browserStreamPort; break; default: break; } return next; } /** The seed meta a run starts from, before any event is folded in. */ function freshMeta(startedAt, intent, owner, id, target, topic, kind) { return { version: RUN_META_VERSION, status: 'running', id: id && isSafeRunId(id) ? id : runIdFromStartedAt(startedAt), startedAt, updatedAt: startedAt, passes: 0, ...(owner ? { pid: owner.pid, host: owner.host } : {}), ...(intent ? { intent } : {}), // Only a non-local target travels; `local` is the default every reader already assumes. ...(target && target !== 'local' ? { target } : {}), // Only the topic flag travels; a project run is the default (absent). ...(topic ? { topic: true } : {}), ...(kind ? { kind } : {}), }; } /** * Parse a JSONL event log. A blank or malformed trailing line (e.g. a crash * mid-write) stops the read rather than throwing, so a partial run still replays * everything up to the cut. */ function parseEventLog(raw) { const events = []; for (const line of raw.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; try { events.push(JSON.parse(trimmed)); } catch { break; // a torn last line from an interrupted write; keep what we have } } return events; } /** Read + parse a persisted {@link RunMeta} file, or `undefined` if missing/unreadable. */ async function readMetaFile(fs, path) { if (!(await fs.exists(path))) return undefined; try { return JSON.parse(await fs.read(path)); } catch { return undefined; } } /** Write a {@link RunMeta} file. The one owner of the on-disk encoding, symmetric to * {@link readMetaFile} — every meta write in this module goes through it. */ function writeMetaFile(fs, path, meta) { return fs.write(path, JSON.stringify(meta, null, 2) + '\n'); } /** * The `end` event written on behalf of a run whose process died without reporting one (#1359): * a crash, a `kill -9`, or the empty-event-loop exit a parked gate used to cause. Every reader * of the stream — the dashboard's outcome pill, its choice rail, the meta fold — keys "over" * off a single `end` event, so a death that skipped it left the run's last question rendering * as answerable forever while its picks were read by nobody. */ function orphanEndEvent() { return { kind: 'end', ok: false, stopped: true, detail: 'its process died without reporting an end' }; } /** * Record a dead run's missing ending in place (#1359): append the surrogate `end` event to the * checkout's live log and fold it into the meta via {@link applyEventToMeta} — so the status * flips to `stopped` and a `pendingChoice` the run died holding expires exactly as a run-written * end would expire it. Best-effort on both writes: healing must never make a read throw. */ async function recordOrphanEnd(fs, dir, meta) { const event = orphanEndEvent(); await fs.append(join(dir, EVENTS_FILE), JSON.stringify(event) + '\n').catch(() => { }); const ended = applyEventToMeta(meta, event, new Date().toISOString()); await writeMetaFile(fs, join(dir, META_FILE), ended).catch(() => { }); return ended; } /** * Flip the live run at `dir` to `stopped` and archive it, returning the stopped meta. The * shared tail of every self-heal: a `running` meta whose process is gone must both stop * showing as live and keep its history. The flip goes through {@link recordOrphanEnd}, so * the log gains the `end` event the dead process never wrote (#1359) before the archive * copies it. Best-effort on both writes — healing must never make a read throw. */ async function stopAndArchiveLive(fs, dir, meta) { const stopped = await recordOrphanEnd(fs, dir, meta); await archivePriorRun(fs, dir).catch(() => { }); return stopped; } /** Rebuild {@link RunMeta} from a full event log (used when resuming). */ export function metaFromEvents(events, startedAt) { let meta = freshMeta(startedAt); 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 { fs; dir; clock; tail = Promise.resolve(); meta; /** * The intent a continuation must keep (#762/#1467): a reopened run keeps its original label, * but a build continuation re-runs the bootstrap, whose `scope` event carries the resume * message and would relabel the row through {@link applyEventToMeta}'s normal refinement. * Unset for a fresh run, where the scope event's refinement stands. */ pinnedIntent; constructor(fs, dir, clock, startMeta) { this.fs = fs; this.dir = dir; this.clock = clock; this.meta = startMeta; } /** The event log path. */ get eventsPath() { return join(this.dir, EVENTS_FILE); } /** The meta snapshot path. */ get metaPath() { return join(this.dir, META_FILE); } /** * Open (creating `.the-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, opts = {}) { const fs = opts.fs ?? nodeStoreFs(); const dir = join(cwd, FRAMEWORK_DIR); const now = opts.now ?? new Date().toISOString(); await fs.mkdir(dir); const owner = opts.owner ?? { pid: process.pid, host: hostname() }; const clock = opts.clock ?? (() => new Date().toISOString()); const store = new RunStore(fs, dir, clock, freshMeta(now, opts.intent, owner, opts.id, opts.target, opts.topic, opts.kind)); if (opts.continueRun) { // Reopen: the log stays, the row keeps its original intent, and this process takes ownership // so a liveness probe (#716) reads the run as alive rather than as an orphan. const prior = await readMetaFile(fs, store.metaPath); if (prior) { store.meta = { ...prior, status: 'running', pid: owner.pid, host: owner.host, updatedAt: now }; store.pinnedIntent = prior.intent; await store.writeMeta(); return store; } } if (opts.fresh) { // A new run truncates the live log. First rescue the prior run if it never // got archived (e.g. a crash exited before close), so no history is lost. await archivePriorRun(fs, dir).catch(() => { }); 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) { this.meta = applyEventToMeta(this.meta, event, this.clock()); // A continuation keeps the run's original label (#762) even through a re-entered // bootstrap's scope event, which carries the resume message rather than a name (#1467). if (this.pinnedIntent) this.meta = { ...this.meta, intent: this.pinnedIntent }; 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, then archive this run into `runs/` so it shows up in * the dashboard's history (#303). Both best-effort: persistence must never break * a run, so an archive failure is logged, not thrown. */ async close() { await this.tail; try { await archiveRun(this.fs, this.dir, this.meta, this.eventsPath); } catch (err) { console.error('[framework] failed to archive run history:', err); } } /** The current derived snapshot. */ snapshot() { 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() { if (!(await this.fs.exists(this.eventsPath))) return []; return parseEventLog(await this.fs.read(this.eventsPath)); } /** Read the persisted meta snapshot, or `undefined` if none/unreadable. */ readMeta() { return readMetaFile(this.fs, this.metaPath); } writeMeta() { return writeMetaFile(this.fs, this.metaPath, this.meta); } } /** * The directory a run's archive lives in: this user's committed `sessions/` when a caller named * one (#1179), else the transient `runs/`. Callers pass a user only where the archive is meant to * be kept — the project's copy — never for the copy a run leaves inside its own worktree. */ function archiveDir(dir, user) { return user ? join(dir, user, SESSIONS_DIR) : join(dir, RUNS_DIR); } /** Paths of a run's archived log + meta. */ function archivePaths(dir, id, user) { const runs = archiveDir(dir, user); return { events: join(runs, `${id}.jsonl`), meta: join(runs, `${id}.json`) }; } /** * Every directory a project's archived runs may sit in, newest scheme first: each user's * `<user>/sessions/`, then the legacy `runs/`. * * Both are read because both exist in the wild: `runs/` holds everything archived before #1179, * and a user directory is only created once that user has run something. Every user's sessions are * listed, not just the reader's — the history is a team-visible record of what the agent has done * to the repo, which is the point of committing it. * * A directory is recognized by having a readable `sessions/` child, so a stray file in * `.the-framework/` is simply not one (readdir yields `[]` for anything that is not a directory). */ /** * Where one run's archive actually sits, searched across {@link archiveDirs}, or `undefined` when * it is nowhere. A run id alone no longer names a path: which user archived it decides that, and a * reader (the #762 continue, a removal) only has the id. */ async function findArchive(fs, dir, runId) { for (const runsDir of await archiveDirs(fs, dir)) { const paths = { events: join(runsDir, `${runId}.jsonl`), meta: join(runsDir, `${runId}.json`) }; if (await fs.exists(paths.meta)) return paths; } return undefined; } async function archiveDirs(fs, dir) { const dirs = []; for (const name of await fs.readdir(dir)) { const candidate = join(dir, name, SESSIONS_DIR); if ((await fs.readdir(candidate)).length > 0) dirs.push(candidate); } dirs.push(join(dir, RUNS_DIR)); return dirs; } /** * Copy a run's live log + meta into its archive as `<id>.jsonl` / `<id>.json`. The live files stay * put (the daemon keeps tailing them until the next run); this is a durable snapshot for the * history list. Idempotent per id. `user` files it under that user's committed sessions (#1179). */ async function archiveRun(fs, dir, meta, eventsPath, user) { if (!isSafeRunId(meta.id)) return; await fs.mkdir(archiveDir(dir, user)); const out = archivePaths(dir, meta.id, user); const events = (await fs.exists(eventsPath)) ? await fs.read(eventsPath) : ''; await fs.write(out.events, events); await writeMetaFile(fs, out.meta, meta); } /** * Archive the run currently sitting in the live files, unless it is already in * `runs/`. Used at the start of a fresh run so a crash that skipped * {@link RunStore.close} still leaves its history behind. */ async function archivePriorRun(fs, dir) { const meta = await readMetaFile(fs, join(dir, META_FILE)); if (!meta?.id || !isSafeRunId(meta.id)) return; if (await fs.exists(archivePaths(dir, meta.id).meta)) return; await archiveRun(fs, dir, meta, join(dir, EVENTS_FILE)); } /** * Put an archived run's history back where a run reads it (#762), so a continued run picks up its * own log rather than starting empty. The inverse of {@link archiveWorktreeRun}: teardown moved the * history to the repo, and continuing needs it in the checkout again. * * A no-op when the worktree already holds a live run (nothing to restore, and its log is newer), * or when there is no archive. Never throws. */ export async function restoreArchivedRun(repo, worktree, runId, fs = nodeStoreFs()) { try { if (!isSafeRunId(runId)) return false; const dir = join(worktree, FRAMEWORK_DIR); if (await fs.exists(join(dir, META_FILE))) return false; const archive = await findArchive(fs, join(repo, FRAMEWORK_DIR), runId); if (!archive) return false; await fs.mkdir(dir); await fs.write(join(dir, EVENTS_FILE), (await fs.exists(archive.events)) ? await fs.read(archive.events) : ''); await fs.write(join(dir, META_FILE), await fs.read(archive.meta)); return true; } catch { return false; } } /** * The run ids that have a worktree directory under `.the-framework/worktrees/` (#737). Names * only, from the filesystem: a directory here IS a run's checkout, and its name is the run id. * Forgiving — a project that never ran concurrently has no such dir and yields `[]`. */ export async function listWorktreeDirs(cwd, fs = nodeStoreFs()) { const names = await fs.readdir(join(cwd, FRAMEWORK_DIR, WORKTREES_DIR)).catch(() => []); return names.filter(isSafeRunId); } /** * Archive a worktree run's history into the *main repo* (#737), returning the meta it archived. * * A run writes its `run.json` / `events.jsonl` inside its own worktree (#736), so deleting that * worktree would delete the run's history with it. This copies it into the repo, which is the one * place the dashboard's history reads from, so teardown becomes safe. * * `user` files the copy under that user's committed `sessions/` (#1179) instead of the transient * `runs/`. It is this copy, not the one the run left in its own worktree, that is meant to last: * every run in a git repo gets a worktree, so this is the only archive of it that outlives the * checkout, and committing it is what makes the history survive `git clean -fdx`. The worktree's * own copy deliberately stays untracked — it would otherwise be committed onto the run's branch as * well and collide with this one on merge. * * A meta still marked `running` is flipped to `stopped` first: this runs when the process is * already gone, so `running` means it died without closing (crash, kill -9), exactly the case * {@link reconcileOrphanedRuns} handles for the project path. Idempotent per id, and forgiving: * a worktree with no run, or an unreadable one, yields `undefined` rather than throwing. */ export async function archiveWorktreeRun(worktree, repo, fs = nodeStoreFs(), branch, user) { try { const worktreeDir = join(worktree, FRAMEWORK_DIR); const live = await readMetaFile(fs, join(worktreeDir, META_FILE)); if (!live?.id || !isSafeRunId(live.id)) return undefined; // The flip writes the worktree's own log + meta too (#1359): the death gains its `end` // event before the archive copies the log, so no reader — live tail or archived replay — // is left holding an open gate for a dead run. const stopped = live.status === 'running' ? await recordOrphanEnd(fs, worktreeDir, live) : live; // The branch is read from the checkout by the caller and stamped here, because this is the // last moment it can be observed: the worktree is about to go (#799). const meta = branch ? { ...stopped, branch } : stopped; await archiveRun(fs, join(repo, FRAMEWORK_DIR), meta, join(worktreeDir, EVENTS_FILE), user); return meta; } catch { return undefined; } } /** * The archived log + meta paths of one run, wherever it is filed, or `[]` when it is nowhere. * Exported so a caller that deletes a session (the dashboard's Remove) does not have to know which * user archived it — before #1179 the path was derivable from the id alone, and now it is not. */ export async function archivedRunPaths(cwd, runId, fs = nodeStoreFs()) { if (!isSafeRunId(runId)) return []; const archive = await findArchive(fs, join(cwd, FRAMEWORK_DIR), runId).catch(() => undefined); return archive ? [archive.meta, archive.events] : []; } /** Newest run first: an id sorts chronologically, so the id order IS the time order (no parse). */ const byIdDesc = (a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0); /** * Every `runs/*.json` archived meta with the path it was read from, torn/half-written entries * skipped. The one home of the archived-history read loop, shared by {@link listRuns} and the * boot reconcile. A missing/unreadable dir throws to the caller, as both callers always let it. */ async function readArchivedMetaEntries(fs, runsDir) { const entries = []; for (const name of await fs.readdir(runsDir)) { if (!name.endsWith('.json')) continue; const path = join(runsDir, name); try { entries.push({ path, meta: JSON.parse(await fs.read(path)) }); } catch { // torn/half-written meta — skip it } } return entries; } /** * A `running` meta whose owning process is provably gone (or unknowable on boot): the orphan * {@link reconcileOrphanedRuns} flips to `stopped`. A live pid on this host, or a non-running * meta, is left be. The narrowing lets a caller use the meta as present in the true branch. */ function isDeadRunning(meta, isAlive) { return meta?.status === 'running' && ownerLiveness(meta, isAlive) !== 'live'; } /** * Every archived meta a project has, across all of {@link archiveDirs}, with the path it came from. * De-duplicated by run id, first directory winning: a run archived before #1179 and re-archived * into its user's sessions afterwards exists in both places, and the history must show it once. * The user directories are searched before `runs/`, so the committed copy is the one that wins. */ async function readAllArchivedMetaEntries(fs, dir) { const seen = new Set(); const entries = []; for (const runsDir of await archiveDirs(fs, dir)) { for (const entry of await readArchivedMetaEntries(fs, runsDir).catch(() => [])) { if (seen.has(entry.meta.id)) continue; seen.add(entry.meta.id); entries.push(entry); } } return entries; } /** * List a project's archived runs, most-recent first: every user's committed `sessions/` plus the * legacy `runs/`. The id sorts chronologically so no timestamp parse is needed. Missing or * unreadable dir/entries are skipped, never thrown. */ export async function listRuns(cwd, fs = nodeStoreFs()) { const entries = await readAllArchivedMetaEntries(fs, join(cwd, FRAMEWORK_DIR)); return entries.map(entry => entry.meta).sort(byIdDesc); } /** * Whether a `running` meta's owning process is provably there, provably gone, or unknowable. * * `'unknown'` is the load-bearing third state (#716/#926): a meta with no `pid` (it predates the * field) or one owned by another host cannot be probed from here. The two callers treat it * differently on purpose — the boot reconcile flips an unknown to `stopped` (a fresh daemon * drives no in-flight run, and there is nothing better to go on), while the self-heal on read * leaves it alone (a routine read must not kill a run another machine may own). */ function ownerLiveness(meta, isAlive) { if (meta.status !== 'running' || meta.pid === undefined || meta.host !== hostname()) return 'unknown'; return isAlive(meta.pid) ? 'live' : 'dead'; } /** * Reconcile runs a dead process left marked `running` — the live `run.json`, an archived * `runs/*.json`, or a run inside a worktree. Such a run shows as active while nothing is left * to read its `control.jsonl`, so its Stop is a no-op. Each is flipped to `stopped`; the live * run is archived first (idempotent) so its history is kept. Returns how many were reconciled. * Best-effort: a read/write error skips that run, never throws. * * A run whose pid is alive on this host is left alone (#926). This used to flip every `running` * meta on the assumption that a fresh daemon drives no in-flight run, which holds only while * exactly one daemon ever boots: a second one (and before #922, every failed `framework --daemon` * spawned one) marked genuinely live runs as finished, giving them a no-op Stop in the dashboard. * A meta with no `pid` keeps the old behaviour, since there is nothing better to go on. */ export async function reconcileOrphanedRuns(cwd, fs = nodeStoreFs(), isAlive = isPidAlive) { const dir = join(cwd, FRAMEWORK_DIR); let fixed = 0; // Archived runs stuck at `running` (e.g. a prior live run the next run never rescued), wherever // they are archived. Done before the live run so its fresh archive isn't re-counted here. for (const { path, meta } of await readAllArchivedMetaEntries(fs, dir)) { if (!isDeadRunning(meta, isAlive)) continue; try { // The archived pair sits side by side (`<id>.json` + `<id>.jsonl`), so the surrogate end // (#1359) lands in both: the replayed log sees the run finish, and the meta fold drops // the pendingChoice the run died holding. const event = orphanEndEvent(); await fs.append(path.replace(/\.json$/, '.jsonl'), JSON.stringify(event) + '\n').catch(() => { }); await writeMetaFile(fs, path, applyEventToMeta(meta, event, new Date().toISOString())); fixed++; } catch { // write failed — best-effort, skip } } // The live run: flip it, then archive so a crash that skipped close() still // leaves the stopped run in the history list. const live = await readMetaFile(fs, join(dir, META_FILE)); if (isDeadRunning(live, isAlive)) { await stopAndArchiveLive(fs, dir, live); fixed++; } // Runs living in worktrees (#736/#737). A daemon that died mid-run never ran its teardown, so // each of those runs is orphaned the same way — except its history sits inside the worktree, // where nothing reads it. Flip it in place (so the dashboard stops showing it as live) and copy // it into the repo's history. The worktree itself is left on disk: a run that ended this way did // not end cleanly, and those are kept for inspection. Removing one is an explicit action. for (const name of await fs.readdir(join(dir, WORKTREES_DIR))) { if (!isSafeRunId(name)) continue; const worktreeDir = join(dir, WORKTREES_DIR, name, FRAMEWORK_DIR); const meta = await readMetaFile(fs, join(worktreeDir, META_FILE)); if (!isDeadRunning(meta, isAlive)) continue; // recordOrphanEnd rather than a bare status flip (#1359): the worktree's log gains the // `end` event first, so the archive below copies a stream that actually ends. await recordOrphanEnd(fs, worktreeDir, meta); await archiveWorktreeRun(join(dir, WORKTREES_DIR, name), cwd, fs).catch(() => undefined); fixed++; } return fixed; } /** * Whether `pid` is a live process on this host. `process.kill(pid, 0)` sends no signal but * throws `ESRCH` once the process is gone; `EPERM` means it exists under another user (still * alive). A pid on a *different* host is unknowable here, so callers guard on {@link RunMeta.host} * before trusting a result. A recycled pid (another process reusing a dead run's number) reads as * alive — an accepted, vanishingly rare miss on a single dev box. */ export function isPidAlive(pid) { try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; } } /** * The live (in-progress) run's meta snapshot from `.the-framework/run.json`, or * `undefined` when none/unreadable. Unlike {@link listRuns} (which reads the * archived `runs/` copies written on close), this is the run the daemon is * tailing right now — so the dashboard can list it with a `running` status * before it finishes. Missing or torn file yields `undefined`, never throws. * * Self-heals a stale run on read (#716): if the meta says `running` but its owning process died * without writing `end` (a crash, `kill -9`, or the machine sleeping), nothing is left to consume * `control.jsonl` — so Stop is a no-op and the row is stuck. When the owning pid is gone on this * host, flip it to `stopped` and archive it, so the dashboard clears the row on the next poll * instead of only after a daemon restart's boot-time {@link reconcileOrphanedRuns}. A run whose * meta predates this field (no `pid`) is left untouched — the boot reconcile still catches it. */ export async function readLiveMeta(cwd, fs = nodeStoreFs(), isAlive = isPidAlive) { const dir = join(cwd, FRAMEWORK_DIR); const meta = await readMetaFile(fs, join(dir, META_FILE)); if (!meta) return undefined; // Only a provably dead owner heals here — 'unknown' (no pid / another host) is left alone. if (ownerLiveness(meta, isAlive) === 'dead') return stopAndArchiveLive(fs, dir, meta); return meta; } /** * Every live run of a project (#738): the list variant of {@link readLiveMeta}. * * A run started from the dashboard gets its own worktree (#736) and writes its `run.json` * inside it, so the project path alone no longer sees any of them. This looks in both places: * each `.the-framework/worktrees/*` checkout, and the repo root itself, which is where a * project that cannot be given a worktree (not a git repo) still runs and where every run * from before #736 lives. * * Each candidate goes through {@link readLiveMeta}, so a stale run self-heals exactly as it * did. Newest first, by id. Never throws: an unreadable worktree is skipped. */ export async function readLiveMetas(cwd, fs = nodeStoreFs(), isAlive = isPidAlive) { const worktreesDir = join(cwd, FRAMEWORK_DIR, WORKTREES_DIR); const names = await fs.readdir(worktreesDir).catch(() => []); // isSafeRunId: the directory name is the run id, and anything else in there is not ours. const candidates = [cwd, ...names.filter(isSafeRunId).map(name => join(worktreesDir, name))]; const runs = []; for (const candidate of candidates) { const meta = await readLiveMeta(candidate, fs, isAlive).catch(() => undefined); if (meta) runs.push({ ...meta, cwd: candidate }); } return runs.sort(byIdDesc); } /** * Read one archived run's event log for replay. Returns `undefined` for an * unknown or unsafe id; a torn trailing line is dropped (same rule as the live * {@link RunStore.loadEvents}). */ export async function loadRunEvents(cwd, id, fs = nodeStoreFs()) { if (!isSafeRunId(id)) return undefined; const archive = await findArchive(fs, join(cwd, FRAMEWORK_DIR), id); if (!archive || !(await fs.exists(archive.events))) return undefined; return parseEventLog(await fs.read(archive.events)); } /** A {@link StoreFs} backed by `node:fs/promises`. See {@link nodeFs}. */ export function nodeStoreFs() { // Destructured rather than returned whole: the narrow interface is the contract, // so the object should not carry methods the store was never handed. const { read, write, append, exists, mkdir, readdir } = nodeFs(); return { read, write, append, exists, mkdir, readdir }; } /** * A project's runs: the live ones prepended to the archived history, newest-first. Forgiving — * a side that cannot be read simply contributes nothing. * * Live wins over archived (#768). The dedup used to drop the live copy, which was right while * "archived" meant "finished for good": a run was only ever copied into `runs/` on its way out. * Continuing a run (#762) breaks that — the run has an archived copy from its first leg AND is * live again — and keeping the archive showed a running run as finished. * * This composition, not its two halves, is what every caller actually wants; the store exporting * only the halves is why three separate modules each grew their own copy of it. */ export async function readAllRuns(cwd, fs = nodeStoreFs()) { const [archived, live] = await Promise.all([ listRuns(cwd, fs).catch(() => []), readLiveMetas(cwd, fs).catch(() => []), ]); return [...live, ...archived.filter(run => !live.some(l => l.id === run.id))]; } /** * One run's meta by id, live copy winning over archived — {@link readAllRuns}'s rule for a * single row. The find-by-id shape the RPCs kept privately rebuilding, for the same reason * the list shape did: the store exported only the halves. */ export async function findRun(cwd, runId, fs = nodeStoreFs()) { return (await readAllRuns(cwd, fs)).find(run => run.id === runId); } /** * Read a checkout's live event log (`.the-framework/events.jsonl`). Missing or unreadable * yields `[]`, and a torn trailing line is dropped — the same rule as * {@link RunStore.loadEvents}, exported so a reader outside the store (the Discord bot's gate * lookup) cannot keep a second parser with a drifted torn-line policy. */ export async function readEventLog(cwd, fs = nodeStoreFs()) { const path = join(cwd, FRAMEWORK_DIR, EVENTS_FILE); try { if (!(await fs.exists(path))) return []; return parseEventLog(await fs.read(path)); } catch { return []; } } //# sourceMappingURL=run-store.js.map