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