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.

275 lines 15.3 kB
import { readFile } from 'node:fs/promises'; import { join, sep } from 'node:path'; import { errorMessage } from './error-message.js'; import { listWorktreeDirs, listAgents, readLiveMetas, branchPushed, commitPendingWork, worktreeClean, currentBranch, removeWorktree, pruneWorktrees, worktreePath, worktreeSize, isSafeAgentId, archivedAgentPaths, FRAMEWORK_DIR, META_FILE, } from './store/index.js'; import { pushAgentBranch } from './dashboard/agent-handoff.js'; import { dataWorktreePath, withDataBranch } from './data-branch.js'; import { nodeGitRunner } from './project.js'; /** * The worktrees a project still has on disk (#752), newest first — the same view the dashboard's * retained-worktrees list is built from, through the same store reads, so the CLI is a second * surface rather than a second behaviour. * * A live agent's checkout is included and flagged rather than hidden: "what is this directory and * why can I not remove it" is exactly the question the list has to answer. */ export async function listProjectWorktrees(cwd, opts = {}) { const [names, live, archived] = await Promise.all([ listWorktreeDirs(cwd).catch(() => []), readLiveMetas(cwd).catch(() => []), listAgents(cwd).catch(() => []), ]); const rows = []; for (const agentId of names) { const meta = live.find(agent => agent.id === agentId) ?? archived.find(agent => agent.id === agentId); const isLive = meta?.status === 'running'; rows.push({ agentId, live: isLive, ...(meta?.branch ? { branch: meta.branch } : {}), ...(meta?.status ? { status: meta.status } : {}), // Sizing a tree an agent is writing to gives a number that is wrong by the time it prints; // a caller that only wants the rows (the dashboard's retained list) skips the du entirely. ...(isLive || opts.sizes === false ? {} : await sizeOf(cwd, agentId)), }); } return rows.sort((a, b) => (a.agentId < b.agentId ? 1 : a.agentId > b.agentId ? -1 : 0)); } async function sizeOf(cwd, agentId) { const bytes = await worktreeSize(worktreePath(cwd, agentId)).catch(() => undefined); return bytes === undefined ? {} : { sizeBytes: bytes }; } /** * Remove one retained worktree (#752/#737/E5): the one implementation behind every surface that * removes one — the sweep, teardown, and the dashboard's Remove button (#982). * * **One rule: only what is on the remote may go.** The work is committed to the session's branch, * the branch is pushed, and the checkout is removed only once the remote has it. Every deletion is * therefore recoverable, and nothing local is ever the last copy of anything. It replaced three * interacting rules that each asked *what state did this session end in* — a clean finish removes * the checkout, a failure or stop keeps it, a merged branch reclaims it later via two different * "landed" signals — where the question that actually matters is *is this recoverable yet*. There * is one failure mode now, and it is legible: the push did not land, so the checkout stays and the * reason says why. * * The push serves the rule; it is not a licence to publish. A session armed to publish nothing * (`handoff: local`, B5/#1379) said its branch must not reach the remote, so its unpushed checkout * is kept — the same outcome as a repo with no remote — rather than pushed to make it removable. * Deciding otherwise would have teardown publish the very branch the agent's own handoff just * declined to. Nothing is committed on the way to that refusal either — a kept checkout is a * place someone works, and the sweep re-offers it every pass — so it goes only from a clean tree * on a pushed tip. And a record that cannot be read keeps the checkout too: unreadable is not * "publish freely". * * Refuses while the agent is still going — an agent's checkout is where its agent is working, and Stop * is how you end an agent, not pulling the floor out from under it. */ export async function removeProjectWorktree(cwd, agentId, opts = {}) { if (!isSafeAgentId(agentId)) return { ok: false, error: `invalid session id: ${agentId}` }; const names = await listWorktreeDirs(cwd).catch(() => []); if (!names.includes(agentId)) return { ok: false, error: `no worktree for session ${agentId}` }; const live = await readLiveMetas(cwd).catch(() => []); if (live.some(agent => agent.id === agentId && agent.status === 'running')) { return { ok: false, error: 'that session is still going; stop it before removing its worktree' }; } const path = worktreePath(cwd, agentId); try { const branch = await currentBranch(path); if (!branch) return { ok: false, error: `session ${agentId} is on no branch; its worktree was kept` }; // The armed handoff decides before anything commits or pushes: a session armed to publish // nothing keeps its checkout instead of having its branch pushed to make removal possible — // the push here serves recoverability, and pushing a `handoff: local` session's branch is // the publish that rung exists to refuse. `handoff` is on the meta from the agent's first // event, so a meta that exists without one (a boot death) keeps the recoverable default; // a meta that cannot be read keeps the checkout instead, because it cannot tell a // publish-nothing session from any other (fail closed, retried by a later pass). let meta; try { meta = await readMetaFor(cwd, path, agentId); } catch (err) { return { ok: false, error: `session ${agentId}'s record could not be read (${errorMessage(err)}); its worktree was kept`, }; } if (meta?.target === 'web' && meta.cloudAnchor && (await webCheckoutCovered(path, branch, meta.cloudAnchor))) { // A web run's checkout never holds the work (#1601): the hand-off pushed everything the // cloud session clones at, and the work itself lands on the session's own remote branch. // Pushing the local run branch just to satisfy the remote rule is what accreted one empty // `tf-agent-*` ref on origin per web run — so the checkout goes without a push, once it is // provably holding nothing: a clean tree whose tip is inside what the hand-off pushed (the // recorded anchor). Anything short of that proof — no anchor recorded, the anchor's object // gone, a tree or tip that moved — falls through to the ordinary rule below, which is never // worse than what every web run got before. } else if (meta?.handoff && !meta.handoff.push) { // A publish-nothing session's checkout goes only once everything it holds is already on // the remote by someone's explicit act: a clean tree on a pushed tip — then removing it // publishes nothing. Anything short of that would take a commit or a push of removal's // own, and a kept checkout is a place someone works, re-offered by the sweep every pass: // grabbing half-typed edits as "[The Framework] uncommitted changes" on the way to a // refusal is not cleanup. if (!(await worktreeClean(path)) || !(await branchPushed(cwd, branch))) { return { ok: false, error: `session ${agentId} was set to publish nothing (handoff: local); its worktree was kept`, }; } } else { // `removeWorktree` forces past a dirty tree, so an uncommitted edit has to be on the branch // before the checkout can go — otherwise the very diff the checkout held is what is deleted. if (!(await commitPendingWork(path))) { return { ok: false, error: `session ${agentId} has uncommitted work that could not be committed; its worktree was kept`, }; } if (!(await branchPushed(cwd, branch))) { // Pushing is what makes the removal recoverable, so it is attempted here rather than // required of the caller. A repo with no remote never gets past this, which is the honest // answer: there is nowhere for the work to be recoverable from. const pushed = await pushAgentBranch(cwd, branch); if (!pushed.ok) { return { ok: false, error: `${branch} is not on the remote (${pushed.error}); its worktree was kept` }; } } } await opts.beforeRemove?.(agentId); await removeWorktree(cwd, path); await pruneWorktrees(cwd); return { ok: true }; } catch (err) { return { ok: false, error: errorMessage(err) }; } } /** * Whether a web run's checkout provably holds nothing its hand-off did not carry (#1601): the * tree is clean and the branch tip is an ancestor of the pushed anchor. False on any doubt — * the caller then treats the checkout like every other run's. */ async function webCheckoutCovered(path, branch, anchor) { if (!(await worktreeClean(path))) return false; const git = nodeGitRunner(); return git(['merge-base', '--is-ancestor', branch, anchor], path).then(() => true, () => false); } /** * The meta the keep decision reads: the live copy in the checkout, else the archived one. * * Unlike the store's forgiving list reads — where anything unreadable contributes nothing — this * read tells absence from failure, because here they mean opposite things: `undefined` is "no * record was ever written" (a boot death, safe to treat as the default), while a record that * exists but cannot be read or parsed throws, so the caller refuses rather than guesses. */ async function readMetaFor(cwd, path, agentId) { const live = await readMetaStrict(join(path, FRAMEWORK_DIR, META_FILE)); if (live) return live; const archived = (await archivedAgentPaths(cwd, agentId)).find(p => p.endsWith('.json')); return archived ? readMetaStrict(archived) : undefined; } /** One meta file, strictly: `undefined` when absent, a throw when present but unreadable. */ async function readMetaStrict(path) { let raw; try { raw = await readFile(path, 'utf8'); } catch (err) { if (err.code === 'ENOENT') return undefined; throw err; } return JSON.parse(raw); } async function rmFile(path) { const { rm } = await import('node:fs/promises'); await rm(path, { force: true }); } /** * Delete a session (#1032): take it out of the dashboard, records and all. * * This is the sibling of {@link removeProjectWorktree}, and the difference is the whole point. * Remove-worktree reclaims the checkout on disk and keeps the session — its row, its replayable * log — because the history was already archived. Delete removes that archive too: the agent meta * (`<id>.json`, what the rail lists) and its event log (`<id>.jsonl`, what replays), wherever they * are filed, so the row is gone for good. It is the one destructive-of-history action, which is * why the surfaces that call it confirm first. Since #1179 that archive is committed, so the files * go but the deletion is itself a change git will record. * * What it deliberately leaves is git's, not the dashboard's: the branch `tf-agent-<id>` * (or the name the agent gave it) and its commits. Deleting a branch that may carry merged work * or an open PR is not a thing a * dashboard action should do silently, so the branch stays and delete means "remove from the * dashboard", not "erase every trace". * * Refuses while the agent is still going — Stop is how an agent ends. Any uncommitted work in the * worktree is discarded with it, which is the intent here (the session is being thrown away), * unlike remove-worktree, which commits that work to the kept branch first. */ export async function deleteProjectAgent(cwd, agentId, opts = {}) { if (!isSafeAgentId(agentId)) return { ok: false, error: `invalid session id: ${agentId}` }; const live = await readLiveMetas(cwd).catch(() => []); if (live.some(agent => agent.id === agentId && agent.status === 'running')) { return { ok: false, error: 'that session is still going; stop it before deleting it' }; } const removeFile = opts.removeFile ?? rmFile; try { // The worktree first, if one is on disk: force-removed (its uncommitted work goes with the // session), where remove-worktree would have committed it to the kept branch. const names = await listWorktreeDirs(cwd).catch(() => []); if (names.includes(agentId)) { await opts.beforeRemove?.(agentId); await removeWorktree(cwd, worktreePath(cwd, agentId)); await pruneWorktrees(cwd); } // Then the records that put the row in the list. Looked up rather than derived from the id: a // session is archived under whichever user ran it (#1179), so the id alone no longer names its // path. Tolerant of an absent file, so a half-deleted session (its worktree already gone) // still finishes cleanly. A record on the data branch is removed inside its write funnel // (#1582) — the deletion is a committed, pushed change — while a transient copy is an unlink. const paths = await archivedAgentPaths(cwd, agentId); const dataRoot = dataWorktreePath(cwd) + sep; for (const path of paths.filter(p => !p.startsWith(dataRoot))) await removeFile(path); if (paths.some(p => p.startsWith(dataRoot))) { const removed = await withDataBranch(cwd, `[The Framework] delete session ${agentId}`, async () => { for (const path of paths.filter(p => p.startsWith(dataRoot))) await removeFile(path); }); if (!removed.ok && !removed.committed) return { ok: false, error: removed.error }; } return { ok: true }; } catch (err) { return { ok: false, error: errorMessage(err) }; } } /** * Remove every retained worktree whose run is not live (#752): the "clean all of this up" case. * A live agent keeps its checkout and is reported as skipped, so the count always adds up to what * the list showed — and so does one whose branch could not reach the remote (E5). */ export async function pruneProjectWorktrees(cwd) { const result = { removed: [], skipped: [] }; for (const row of await listProjectWorktrees(cwd)) { if (row.live) { result.skipped.push({ agentId: row.agentId, reason: 'still running' }); continue; } const outcome = await removeProjectWorktree(cwd, row.agentId); if (outcome.ok) result.removed.push(row.agentId); else result.skipped.push({ agentId: row.agentId, reason: outcome.error }); } return result; } //# sourceMappingURL=worktrees.js.map