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
JavaScript
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