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.

485 lines 25.5 kB
import { nodeGitRunner } from '../project.js'; import { cachedPrsForBranch, forgetBranchPrs, forgetPr, ghMergePr, ghPrsForBranch, nodeGhRunner, pickRunPr, } from './gh.js'; import { parseNumstat } from './file-diff.js'; import { parsePorcelain } from './file-status.js'; import { errorMessage } from '../error-message.js'; import { commitPendingWork, currentBranch, startedAtFromRunId, FRAMEWORK_DIR } from '../store/index.js'; /** * The branch a run's work is on. * * Prefers what was recorded while the worktree existed (#799), because the #326 prompt lets the * agent name its own branch, which makes both derivations below a guess. They stay as a fallback * for runs archived before the branch was recorded. */ export function runBranchFor(run) { if (run.branch) return run.branch; return run.sessionName ? `${SESSION_BRANCH_PREFIX}${run.sessionName}` : `${SESSION_BRANCH_PREFIX}run-${run.id}`; } /** What every branch a session creates for itself is named under. */ const SESSION_BRANCH_PREFIX = 'the-framework/'; /** * The branch's PR as it applies to *this* run: the injected seam when the caller gave one, else * the cached history filtered through {@link pickRunPr} with the run's start time (#1251). */ async function lookupRunPr(cwd, branch, deps) { if (deps.pr) return { value: await deps.pr(cwd, branch).catch(() => undefined), pending: false }; const prs = await cachedPrsForBranch(cwd, branch).catch(() => ({ value: undefined, pending: false })); return { value: prs.value ? pickRunPr(prs.value, deps.since) : undefined, pending: prs.pending }; } /** * The PR that belongs to a run, tried across every branch name the run may have worked under * (#1251/#1255): the recorded branch, the session-name branch, then the run-id branch. * * The ladder is what makes a hands-off web run resolvable: its local worktree is torn down (or * never existed), its meta may carry only a session name whose branch is a reused pin, but the * cloud session pushed the run-id branch, which no other run can ever have. Each candidate is * filtered through {@link pickRunPr} with the run's start time, so a predecessor's PR on a shared * branch name is never the answer. `pending` only when nothing was found and a lookup is still * running, so the caller can ask again rather than render "no PR". */ export async function resolveRunPr(cwd, run, prs = cachedPrsForBranch) { const since = run.startedAt ?? startedAtFromRunId(run.id); const candidates = runBranchCandidates(run); let pending = false; for (const branch of candidates) { const read = await prs(cwd, branch).catch(() => ({ value: undefined, pending: false })); if (read.pending) pending = true; const pr = read.value ? pickRunPr(read.value, since) : undefined; if (pr) return { value: pr, pending: false }; } return { value: undefined, pending }; } /** Every branch name a run may have worked under, in trust order — {@link resolveRunPr}'s ladder. */ function runBranchCandidates(run) { return [ ...new Set([ ...(run.branch ? [run.branch] : []), ...(run.sessionName ? [`${SESSION_BRANCH_PREFIX}${run.sessionName}`] : []), `${SESSION_BRANCH_PREFIX}run-${run.id}`, ]), ]; } /** * Merge a finished session's open PR (#1391): the Merge action, pressed by a human. * * The direct answer to the withheld-merge ending (#1363): a session whose agent never signalled * ready-for-merge leaves a draft PR behind, and this is the human saying "it's good, land it". * `ghMergePr` marks a draft ready on the way, for exactly that case. Refuses when the run has no * PR or it is no longer open — "already merged" is an answer, not an action. */ export async function mergeSessionPr(cwd, run, deps = {}) { const pr = (await resolveRunPr(cwd, run, deps.prs)).value; if (!pr) return { ok: false, error: 'this session has no pull request to merge' }; if (pr.state !== 'OPEN') return { ok: false, error: `this session's PR is already ${pr.state.toLowerCase()}` }; const merged = await ghMergePr(cwd, pr.number, deps.gh); if (merged.outcome === 'failed') return { ok: false, error: merged.error }; // The PR's cached state just changed under every branch name the lookup tries: forget them all, // or the bar keeps offering a merge for a PR that landed (#1028). for (const branch of runBranchCandidates(run)) { forgetPr(cwd, branch); forgetBranchPrs(cwd, branch); } return { ok: true, url: pr.url }; } /** * Whether a branch is one a session made, rather than one the user did. * * Only a naming convention, so it is a guess for the case #326 allows — the agent picking its own * branch name. Every caller uses it to decide how loudly to surface something, never to act. */ export function isSessionBranch(branch) { return Boolean(branch?.startsWith(SESSION_BRANCH_PREFIX)); } /** `git` that resolves to '' instead of rejecting, for reads where "no answer" is a fine answer. */ function soft(git, cwd) { return args => git(args, cwd).catch(() => ''); } /** The repo's default branch: what the remote points HEAD at, else the first local conventional one. */ async function detectBase(run) { const head = (await run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).trim(); if (head) return head; for (const name of ['main', 'master']) { if ((await run(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`])).trim()) return name; } return undefined; } /** A subject can hold anything, so the fields are unit-separated rather than space-split. */ const SEP = String.fromCharCode(31); /** Parse `git log --format=%H%x1f%s`. */ function parseCommits(out) { return out .split('\n') .filter(line => line.includes(SEP)) .map(line => { const [sha = '', subject = ''] = line.split(SEP); return { sha, short: sha.slice(0, 7), subject }; }); } /** `git diff --numstat` as {@link HandoffFile}s, via the shared parser in file-diff.ts. */ function parseHandoffFiles(out) { return parseNumstat(out).map(({ path, added, removed, binary }) => ({ path, insertions: added, deletions: removed, binary })); } /** The framework's own paper trail (#1291): conversation records, LOGS, session archives. */ function isBookkeepingPath(path) { return path === FRAMEWORK_DIR || path.startsWith(`${FRAMEWORK_DIR}/`); } /** * Read what a finished session left behind, from the project repo, for `branch`. * * Returns undefined only when `cwd` is not a git repo at all. A branch that no longer exists * still returns a handoff (with `exists: false`), because "that branch is gone" is itself the * answer the dashboard needs to show. */ export async function readRunHandoff(cwd, branch, deps = {}) { const git = deps.git ?? nodeGitRunner(); const run = soft(git, cwd); // Not a repo (or git is unusable): nothing here is answerable. if (!(await git(['rev-parse', '--git-dir'], cwd).then(() => true).catch(() => false))) return undefined; const tip = (await run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).trim(); const hasRemote = (await run(['remote'])).trim().length > 0; const pending = await countPendingWork(git, deps.checkout); if (!tip) { // The branch being gone locally does not mean the work is: a hands-off web run pushes its // branch and opens its PR remotely, and a merged branch gets deleted. The PR is a remote // question, so it is still answerable — and it is the one thing left worth showing (#1255). const pr = await lookupRunPr(cwd, branch, deps); return { branch, exists: false, commits: [], files: [], insertions: 0, deletions: 0, empty: true, hasRemote, pushed: false, merged: false, ...(pr.value ? { pr: pr.value } : {}), ...(pr.pending ? { prPending: true } : {}), ...pending, }; } const base = await detectBase(run); // Two ranges, because git's two spellings mean opposite things here and only one is right for // each question (#1164/#1173). // // `base..branch` is the branch's OWN commits, which is what "what did this session produce" // asks. `base...branch` in `git log` is the SYMMETRIC difference, so it also lists commits that // are only on the base — exactly the thing the comment below says not to count. A session whose // work is already merged then reported commits it did not make, `empty` stayed false, and the // dashboard offered an Open PR that GitHub refuses with "No commits between main and <branch>". // // For the diff the three-dot form IS the right one: it is the change since the branch point, // rather than a comparison against a base that has moved on since. const logRange = base ? `${base}..${branch}` : undefined; const diffRange = base ? `${base}...${branch}` : undefined; const [commitsOut, numstatOut, remoteTip, mergedOut] = await Promise.all([ logRange ? run(['log', '--format=%H%x1f%s', logRange]) : Promise.resolve(''), diffRange ? run(['diff', '--numstat', diffRange]) : Promise.resolve(''), hasRemote ? run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`]) : Promise.resolve(''), base ? run(['branch', '--list', '--merged', base, branch]) : Promise.resolve(''), ]); const commits = parseCommits(commitsOut); const files = parseHandoffFiles(numstatOut); // Read through the cache and allowed to arrive late (#1028): the commits, the files and // whether the branch is pushed are all local git, and none of them should wait on `gh`. const pr = await lookupRunPr(cwd, branch, deps); return { branch, exists: true, ...(base ? { base } : {}), commits, files, insertions: files.reduce((sum, f) => sum + f.insertions, 0), deletions: files.reduce((sum, f) => sum + f.deletions, 0), // A session that changed nothing is a real outcome, not an error: it gets said, not shown as // an empty branch with buttons that would push nothing. Bookkeeping-only counts as nothing // (#1291): every run's branch carries the framework's own records — the #326 pre-work commit // sweeps in the conversation file the daemon just wrote — and publishing those alone produced // junk PRs of pure paper trail. The files decide, not the commits: a branch of bookkeeping // sweeps has commits and still nothing to hand off. empty: commits.length === 0 || files.every(file => isBookkeepingPath(file.path)), hasRemote, pushed: remoteTip.trim() === tip, merged: mergedOut.trim().length > 0, ...(pr.value ? { pr: pr.value } : {}), ...(pr.pending ? { prPending: true } : {}), ...pending, }; } /** * The files the session left uncommitted in its own checkout, as a spreadable field. * * Absent rather than `[]` when no checkout was given (or git could not answer): "nobody asked" and * "asked, nothing pending" are different answers, and only the second one may be shown as a clean * tree. */ async function countPendingWork(git, checkout) { if (!checkout) return {}; const status = await git(['status', '--porcelain'], checkout).catch(() => undefined); if (status === undefined) return {}; return { pendingFiles: parsePorcelain(status).map(entry => entry.path) }; } /** * Commit what a session left uncommitted, so what it did is what gets handed off (#1173). * * The automatic handoff commits the session's leftovers on the run's way out, but that happens * when the run process exits, and the finishing step is offered as soon as the agent settles * (#1178), which for a session left open for another turn is much earlier. Pressing the button is * the same instruction given by hand, so it sweeps the same leftovers into what it publishes. The * button only shows for a branch that already carries commits (#1173): a no-diff branch names its * uncommitted work instead of offering a step, so this never turns "nothing committed" into a PR * by itself. * * Two guards, because both failure modes end with the user's own work committed for them: the * checkout has to be the session's own (#453) rather than the project root that `resolveRunCheckout` * falls back to once a worktree is gone, and it has to be sitting on the session's branch. * * Returns whether the handoff may go ahead: true when there was nothing to do, when the guards say * this is not ours to commit, or when the commit succeeded. */ export async function commitSessionWork(checkout, projectCwd, branch, git = nodeGitRunner()) { if (checkout === projectCwd) return true; if ((await currentBranch(checkout, git)) !== branch) return true; return commitPendingWork(checkout, git); } /** * Push a finished session's branch to `origin`. * * Publishing the agent's work under the user's name is the user's call, but since #1102 that call * is made once, up front, by a checkbox that is armed by default, rather than re-taken by hand at * the end of every session. The click is still here for a session that opted out, and it is what * a failed auto-push falls back to. */ export async function pushRunBranch(cwd, branch, git = nodeGitRunner()) { try { await git(['push', '--set-upstream', 'origin', branch], cwd); return { ok: true }; } catch (err) { return { ok: false, error: gitReason(err) }; } } /** * {@link RunHandoff.base} as a base a PR can actually be opened against. * * The field holds a git ref, because that is what every other use of it needs: `detectBase` reads * `refs/remotes/origin/HEAD`, so it is `origin/main`, and the log range and merged check are both * asking git a question about a remote-tracking ref. `gh pr create --base` is asking GitHub for a * *branch on the remote*, and rejects `origin/main` with "Base ref must be a branch". * * So the conversion belongs at the `gh` boundary rather than in the field. Stripping `origin/` * matches what the rest of this module already assumes: the remote is `origin` (`pushRunBranch` * pushes there, `detectBase` reads its HEAD). */ export function prBaseName(base) { return base.startsWith('origin/') ? base.slice('origin/'.length) : base; } /** * The line of a failed git invocation worth showing. * * `execFile` rejects with "Command failed: git push ..." and buries git's own `fatal:` line * further down, which in a one-line panel means the user reads the command back instead of the * reason it failed. */ export function gitReason(err) { const message = errorMessage(err); const lines = message.split('\n').map(line => line.trim()).filter(Boolean); return lines.find(line => /^(fatal|error|remote):/i.test(line)) ?? lines[0] ?? 'git failed'; } /** * Open a PR for a finished session's branch, pushing it first when the remote does not have it. * * The button opens it ready for review, because a PR a human asked for by name is asking for * review. {@link PullRequestDraft.draft} is the auto-handoff case, which is not. */ export async function openRunPullRequest(cwd, branch, draft, deps = {}) { const git = deps.git ?? nodeGitRunner(); const gh = deps.gh ?? nodeGhRunner(); // gh refuses to open a PR for a branch the remote has never seen, so the push is part of the // action rather than a thing the user has to remember to do first. const pushed = await pushRunBranch(cwd, branch, git); if (!pushed.ok) return pushed; try { const args = ['pr', 'create', '--head', branch, '--title', draft.title, '--body', draft.body]; if (draft.base) args.push('--base', prBaseName(draft.base)); if (draft.draft) args.push('--draft'); const out = (await gh(args, cwd)).trim(); // The branch has a PR now, so the cached "no PR" must go or the bar would keep offering to // open one for the next minute (#1028). Both caches: the single-PR view and the history. forgetPr(cwd, branch); forgetBranchPrs(cwd, branch); // gh prints the new PR's URL as its last line. const url = out.split('\n').filter(Boolean).at(-1); return url ? { ok: true, url } : { ok: true }; } catch (err) { return { ok: false, error: errorMessage(err) }; } } /** * Open a PR for a finished session, deciding from what the run recorded which cases should not * open one. Reads the branch's handoff first: a branch that no longer exists, or a session that * changed nothing, is a clear error rather than an empty PR, and a branch that already has a PR * returns that one. Title is the session name (else the intent's first line, else the id); body * is the intent plus which session did it. This is the handoff decision the dashboard's * open-PR button offers; the RPC layer only resolves which run it is about. */ export async function openSessionPullRequest(cwd, run, options = {}) { const branch = runBranchFor(run); const handoff = await readRunHandoff(cwd, branch, { since: run.startedAt }).catch(() => undefined); // The run's PR first, even when its branch is gone locally: a hands-off web run's branch only // ever existed on the remote, and its PR is the answer the button exists to give (#1255). if (handoff?.pr) return { ok: true, url: handoff.pr.url }; if (handoff && !handoff.exists) return { ok: false, error: `branch ${branch} no longer exists` }; // Refuse rather than open an empty PR: a session that changed nothing has nothing to hand off. if (handoff?.empty) return { ok: false, error: 'this session produced no commits to open a PR for' }; return openRunPullRequest(cwd, branch, { title: sessionPrTitle(run), body: sessionPrBody(run), ...(handoff?.base ? { base: handoff.base } : {}), ...(options.draft ? { draft: true } : {}), }); } /** Both halves armed — the default a session starts from. */ const ARMED_HANDOFF = { push: true, pr: true }; /** * Whether an armed merge may actually run (#1363), and if not, why. * * The rule settled on #1390: config *arms* the merge, the agent *authorizes* it. Landing on the * default branch unattended takes (a) the agent having declared the session done via * setReadyForMerge() — the same signal the on-before-mergeable step requires — and (b) the * framework not already knowing of work pending in this session (its own TODO file; never the * global queue, which is decoupled from sessions). A withheld merge is not a failed handoff: * push and PR go ahead, the PR just opens as a draft for a human. * * (b) is a temporary safety belt: the agent's word should ultimately be enough. Deleting it means * deleting `sessionTodoOpen` here and `sessionTodoPending` in todo-loop.ts. */ export function withheldMerge(deps) { if (!deps.readyForMerge) return 'not-ready-for-merge'; if (deps.sessionTodoOpen) return 'session-todo-open'; return undefined; } /** * Do the end-of-session handoff a session was left armed for (#1102): push the branch, open a * draft PR for it, or both. * * Reads the branch first and refuses on everything that is not a clean hand-off — a branch that is * gone, a session that committed nothing, a repo with no remote, a branch that already has a PR. * Those are the cases where doing it anyway would produce a confusing artefact rather than help. * * The PR is a draft on purpose. Opening one by itself at the end of every session must not put a * review request in anyone's inbox, and the interventions queue keeps listing a session's draft * so the work still comes back to the human. */ export async function runAutoHandoff(cwd, run, intent, deps = {}) { if (!intent.push && !intent.pr) return { outcome: 'skipped', reason: 'not-armed' }; const branch = runBranchFor(run); const since = run.startedAt ?? startedAtFromRunId(run.id); const { gh, ...readDeps } = deps; // The UNcached PR lookup, deliberately. The dashboard's cache answers `prPending` rather than // yes-or-no (#1028), which is right for a panel repainting every 15s and wrong here: "not known // yet" would read as "no PR" and this would open a second one. Proved against a real remote — // only `gh` refusing the duplicate stopped it. This runs once, at the end of a session, so it // can afford to wait for a real answer. Filtered by the run's start time (#1251): a merged PR // from an earlier run on the same branch name must not stop this run from opening its own. const runPr = async (c, b) => pickRunPr(await ghPrsForBranch(c, b), since); const state = await readRunHandoff(cwd, branch, { pr: runPr, ...readDeps }).catch(() => undefined); if (!state || !state.exists) return { outcome: 'skipped', reason: 'branch-gone' }; if (state.empty) return { outcome: 'skipped', reason: 'no-commits' }; if (!state.hasRemote) return { outcome: 'skipped', reason: 'no-remote' }; // A PR already covers both halves: it means the branch is published and the human has a place // to answer. Opening a second one is the one mistake this must never make. An armed merge // (#1216) still applies to the open PR — this is a rerun or a restart finding the PR its // predecessor opened, and the merge is the half that has not happened yet. if (state.pr) { if (intent.merge && state.pr.state === 'OPEN') { // `watch` mode (#1418), like the freshly-opened path below: an auto merge must wait for the // PR's checks rather than land on the direct fallback before they run (#1406). return { outcome: 'skipped', reason: 'already-open', merge: await ghMergePr(cwd, state.pr.number, gh, { whenUnarmed: 'watch' }) }; } return { outcome: 'skipped', reason: 'already-open' }; } if (intent.pr) { // `openRunPullRequest` pushes first, so the PR half subsumes the push half. const opened = await openRunPullRequest(cwd, branch, { title: sessionPrTitle(run), body: sessionPrBody(run), // GitHub refuses to merge or auto-merge a draft, so an armed merge (#1216) opens the PR // ready: its review happened on the queue before the run, which is the same reason the // merge is armed at all. Draft stays the default for PRs a human is meant to look at. draft: !intent.merge, ...(state.base ? { base: state.base } : {}), }, { ...(readDeps.git ? { git: readDeps.git } : {}), ...(gh ? { gh } : {}) }); if (!opened.ok) return { outcome: 'failed', step: 'pr', error: opened.error }; // The merge half (#1216), only after the PR half succeeded. The number comes off the URL gh // just printed; the lookup is the fallback for a gh that answered without one. Failing to // resolve a number is a reported merge failure, never a failed handoff — the PR is there. const merge = intent.merge ? await (async () => { const lookup = readDeps.pr ?? runPr; const number = prNumberFromUrl(opened.url) ?? (await lookup(cwd, branch).catch(() => undefined))?.number; // `watch` mode (#1418): where GitHub cannot arm the merge, a just-opened PR defers to // the daemon's CI watch instead of the direct fallback that landed it before its first // check ran (#1406). return number !== undefined ? ghMergePr(cwd, number, gh, { whenUnarmed: 'watch' }) : { outcome: 'failed', error: 'could not resolve the PR number to merge' }; })() : undefined; return { outcome: 'done', pushed: true, ...(opened.url ? { url: opened.url } : {}), ...(merge ? { merge } : {}) }; } if (state.pushed) return { outcome: 'skipped', reason: 'already-pushed' }; const pushed = await pushRunBranch(cwd, branch, readDeps.git); if (!pushed.ok) return { outcome: 'failed', step: 'push', error: pushed.error }; return { outcome: 'done', pushed: true }; } /** The PR title for a session (#1102), with the ticket's issue reference riding along (#1334). */ function sessionPrTitle(run) { const title = run.sessionName ?? run.intent?.split('\n')[0]?.slice(0, 72) ?? `Session ${run.id}`; return run.fixes ? `${title} (fix ${run.fixes})` : title; } /** The PR number out of the URL `gh pr create` prints, e.g. `…/pull/123` (#1216). */ function prNumberFromUrl(url) { const match = url?.match(/\/pull\/(\d+)(?:$|[/?#])/); return match ? Number(match[1]) : undefined; } /** The PR body: what was asked for, and which session did it. */ function sessionPrBody(run) { const lines = []; if (run.intent) lines.push(run.intent.trim(), ''); lines.push(`Opened from The Framework session \`${run.sessionName ?? run.id}\`.`); return lines.join('\n'); } //# sourceMappingURL=run-handoff.js.map