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.
311 lines • 15.1 kB
TypeScript
import { type GitRunner } from '../project.js';
import { type GhRunner, type LinkedPr, type BranchPrLookup } from './gh.js';
import type { Cached } from './cache.js';
import type { AutoHandoffSkip, AutoMergeOutcome, MergeWithheldReason } from '../events.js';
import { type RunMeta } from '../store/index.js';
/** One commit a session put on its branch. */
export interface HandoffCommit {
sha: string;
/** Short sha, for display. */
short: string;
subject: string;
}
/** One file the session changed, against the branch point. */
export interface HandoffFile {
path: string;
insertions: number;
deletions: number;
/** True for a binary file, where line counts are meaningless. */
binary: boolean;
}
/** What a finished session produced and what can still be done with it. */
export interface RunHandoff {
/** The branch the work is on. */
branch: string;
/** The branch still exists in the repo (a deleted or never-created one does not). */
exists: boolean;
/** What the branch is measured against (the repo's default branch), when one was found. */
base?: string;
commits: HandoffCommit[];
files: HandoffFile[];
insertions: number;
deletions: number;
/**
* The session produced nothing to hand off: the branch exists but carries no commit the base
* does not already have — or nothing beyond the framework's own bookkeeping (#1291), which is
* committed for provenance, never as publishable work. Said out loud, rather than shown as an
* empty branch.
*/
empty: boolean;
/** The repo has a remote to push to at all. */
hasRemote: boolean;
/** The branch is on the remote and the remote is at the same commit. */
pushed: boolean;
/** The branch is already merged into the base. */
merged: boolean;
/** The PR opened for this branch, when there is one. */
pr?: LinkedPr;
/** The PR is not known yet, rather than absent (#1028): the lookup is still running. */
prPending?: boolean;
/**
* The files the session changed and never committed, read from its own checkout (#1173).
*
* The agent is instructed to commit what it *found*, never what it *wrote*, so a settled session
* can hold its whole output in an uncommitted tree. That work is not on the branch yet, so it is
* not in {@link commits} and it does not make {@link empty} false. Paths rather than a count,
* because a no-diff branch must *name* what is waiting instead of offering an Open PR that
* GitHub can only refuse. Absent when the caller did not say which checkout the session worked
* in — "nobody asked" and "asked, tree clean" are different answers.
*/
pendingFiles?: string[];
}
/** Injectable seams so the reader is unit-testable off disk, plus the checkout the session worked in. */
export interface RunHandoffDeps {
git?: GitRunner;
pr?: BranchPrLookup;
/**
* The session's own checkout (#453), when it has one. The branch lives in the project repo and is
* read from there; uncommitted work does not, it sits in the tree the agent actually edited.
*/
checkout?: string;
/**
* When the run started (ISO), so the PR lookup can tell the run's own PR from an earlier run's
* on the same branch name (#1251). Without it, only an open PR is trusted.
*/
since?: string;
}
/**
* 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 declare function runBranchFor(run: {
id: string;
branch?: string;
sessionName?: string;
}): string;
/** What {@link resolveRunPr} needs to know about a run: structurally satisfied by {@link RunMeta}. */
export interface RunPrRun {
id: string;
startedAt?: string;
branch?: string;
sessionName?: string;
}
/** The injectable lookup seam for {@link resolveRunPr}: a branch's cached PR history. */
export type BranchPrsLookup = (cwd: string, branch: string) => Promise<Cached<LinkedPr[]>>;
/**
* 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 declare function resolveRunPr(cwd: string, run: RunPrRun, prs?: BranchPrsLookup): Promise<Cached<LinkedPr | undefined>>;
/**
* 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 declare function mergeSessionPr(cwd: string, run: RunPrRun, deps?: {
gh?: GhRunner;
prs?: BranchPrsLookup;
}): Promise<HandoffResult>;
/**
* 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 declare function isSessionBranch(branch: string | undefined): boolean;
/**
* 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 declare function readRunHandoff(cwd: string, branch: string, deps?: RunHandoffDeps): Promise<RunHandoff | undefined>;
/**
* 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 declare function commitSessionWork(checkout: string, projectCwd: string, branch: string, git?: GitRunner): Promise<boolean>;
/** The outcome of a handoff action, in the `{ ok }` shape the dashboard's `useAction` understands. */
export type HandoffResult = {
ok: true;
url?: string;
} | {
ok: false;
error: string;
};
/**
* 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 declare function pushRunBranch(cwd: string, branch: string, git?: GitRunner): Promise<HandoffResult>;
/**
* {@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 declare function prBaseName(base: string): string;
/**
* 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 declare function gitReason(err: unknown): string;
/** What to put on the PR. */
export interface PullRequestDraft {
title: string;
body: string;
base?: string;
/**
* Open it as a GitHub draft (#1102). What auto-handoff uses: opening a PR by itself at the end
* of every session should not put a review request in anyone's inbox.
*
* Safe to do only because the interventions queue was taught to keep listing a draft on a
* session branch. Left off, a draft would be invisible in both places at once.
*/
draft?: boolean;
}
/**
* 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 declare function openRunPullRequest(cwd: string, branch: string, draft: PullRequestDraft, deps?: {
git?: GitRunner;
gh?: GhRunner;
}): Promise<HandoffResult>;
/**
* 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 declare function openSessionPullRequest(cwd: string, run: RunMeta, options?: {
draft?: boolean;
}): Promise<HandoffResult>;
/**
* What a session was left armed to do when it ends (#1102).
*
* Both start true. The point of the feature is that the common case costs nothing: a session that
* is simply left alone puts its branch on the remote and opens a PR for it.
*/
export interface HandoffIntent {
push: boolean;
pr: boolean;
/**
* Merge the PR once it is opened (#1216). Absent = off, unlike the pair above: landing work on
* the default branch is not something to arm by default. No action-bar checkbox mutates it —
* it comes settled off the run's config.
*/
merge?: boolean;
}
/**
* 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 declare function withheldMerge(deps: {
readyForMerge: boolean;
sessionTodoOpen: boolean;
}): MergeWithheldReason | undefined;
/**
* What auto-handoff did, so the run can say it as an event (#835).
*
* A dashboard-started run is spawned with `stdio: 'ignore'`, so anything printed here reaches
* nobody: the outcome has to travel as an event or it does not travel at all. Skips are reported
* for the same reason a skipped on-before-mergeable is — silence reads as "it ran and did nothing".
*/
export type AutoHandoffOutcome = {
outcome: 'skipped';
reason: AutoHandoffSkip;
merge?: AutoMergeOutcome;
} | {
outcome: 'done';
pushed: boolean;
url?: string;
merge?: AutoMergeOutcome;
} | {
outcome: 'failed';
step: 'push' | 'pr';
error: string;
};
/**
* 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 declare function runAutoHandoff(cwd: string, run: HandoffRun, intent: HandoffIntent, deps?: RunHandoffDeps & {
gh?: GhRunner;
}): Promise<AutoHandoffOutcome>;
/**
* The little a handoff needs to know about the run it is for: which branch, and what to say on
* the PR. Narrower than {@link RunMeta} so the run process can call this before its meta is
* final, and so a caller cannot quietly start depending on the rest of the run's state.
*/
export type HandoffRun = Pick<RunMeta, 'id' | 'branch' | 'sessionName' | 'intent'> & Partial<Pick<RunMeta, 'startedAt'>> & {
/**
* The GitHub issue the run's ticket tracks (`#42`), when it implements one (#1334). Carried
* into the PR title as `(fix #42)` so the squash-merge commit — which inherits the title —
* closes the issue; without it an auto-merged quick-win leaves its ticket open.
*/
fixes?: string;
};
//# sourceMappingURL=run-handoff.d.ts.map