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.
297 lines • 15.5 kB
JavaScript
import { dirname, join } from 'node:path';
import { nodeGitRunner } from './project.js';
import { FRAMEWORK_DIR, BRANCHES_DIR } from './store/index.js';
import { DATA_BRANCH } from './branch-names.js';
import { TICKETS_DIR, FLAT_TODO_FILE } from './tickets.js';
import { excludeFromGit } from './git-exclude.js';
import { errorMessage } from './error-message.js';
export { DATA_BRANCH };
// The `tf-data` branch (#1582): every file The Framework itself writes — the tickets,
// the queue, the session archives — lives on one branch of the project repo, the way `gh-pages`
// holds a site's data. Main is 100% code, 0% framework data.
//
// What this buys, structurally: the branch is safe to push and pull *eagerly*, because nothing on
// it is anyone's working tree. That closes #1577 (an agent's stale queue copy clobbering the
// freshly-merged one — there is one queue, on one branch, with one local writer) and #1397 (run
// bookkeeping stranded on local main — data commits land on a branch whose whole job is to be
// pushed), and it gives every machine and cloud session the same view: fetch the branch, read the
// files, commit onto it, push.
//
// The daemon is the single LOCAL committer. Every local write funnels through
// {@link withDataBranch}, which serializes per project and runs the whole cycle — sync, apply,
// commit, push — so two ticks can never interleave half-written queue states. A data-writing
// *session* (triage, sync) is a remote writer like another machine: it commits onto this branch in
// its own checkout and pushes, and the race is settled by the push itself — whoever loses re-syncs
// and re-applies.
//
// The checkout lives at `.the-framework/branches/tf-data`, a plain git worktree in the
// #1580 branches dir, named as its branch like every checkout there. The repo root keeps a
// `tickets` symlink into it so the roadmap stays one `ls` away for humans.
/** The data branch's checkout under a project: `<repo>/.the-framework/branches/tf-data`. */
export function dataWorktreePath(cwd) {
return join(cwd, FRAMEWORK_DIR, BRANCHES_DIR, DATA_BRANCH);
}
/**
* git's well-known empty tree, present in every repository without being written first. Committing
* it is how the branch is born parentless with no working-tree gymnastics: `git commit-tree` +
* `git branch` never touches any checkout, and no code commit is ever an ancestor of the data
* history.
*/
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
function resolveDeps(deps) {
const fs = () => import('node:fs/promises');
return {
git: deps.git ?? nodeGitRunner(),
write: deps.write ??
(async (path, content) => {
const f = await fs();
await f.mkdir(dirname(path), { recursive: true });
await f.writeFile(path, content, 'utf8');
}),
lexists: deps.lexists ?? (path => fs().then(f => f.lstat(path)).then(() => true, () => false)),
symlink: deps.symlink ?? ((target, path) => fs().then(f => f.symlink(target, path))),
log: deps.log ?? (() => { }),
};
}
/** Whether the repo has any remote to sync the branch with. No remote is a local-only project, fine. */
async function hasRemote(cwd, git) {
try {
return (await git(['remote'], cwd)).trim().length > 0;
}
catch {
return false;
}
}
/** Whether a ref exists locally. */
async function refExists(cwd, ref, git) {
return git(['rev-parse', '--verify', '--quiet', ref], cwd).then(() => true, () => false);
}
/**
* One serialized cycle per project, so a writer and the eager pull can never interleave: the
* promise chain is the lock, and every public entry point below joins it.
*/
const chains = new Map();
function serialize(cwd, task) {
const next = (chains.get(cwd) ?? Promise.resolve()).then(task, task);
chains.set(cwd, next.catch(() => { }));
return next;
}
/**
* The unserialized ensure: branch, checkout, seed, symlink. Split from {@link ensureDataWorktree}
* so the writer and the pull can run it inside the cycle they already hold the chain for.
*/
async function ensureCore(cwd, r) {
const path = dataWorktreePath(cwd);
// Already checked out on the right branch: done. The common case, taken on every tick.
const onBranch = await r.git(['rev-parse', '--abbrev-ref', 'HEAD'], path).then(out => out.trim() === DATA_BRANCH, () => false);
if (!onBranch) {
if (!(await refExists(cwd, `refs/heads/${DATA_BRANCH}`, r.git))) {
// Take origin's branch when there is one (the every-other-machine case); otherwise the
// branch is born here, parentless.
if (await hasRemote(cwd, r.git))
await r.git(['fetch', 'origin', DATA_BRANCH], cwd).catch(() => { });
if (await refExists(cwd, `refs/remotes/origin/${DATA_BRANCH}`, r.git)) {
await r.git(['branch', DATA_BRANCH, `origin/${DATA_BRANCH}`], cwd);
}
else {
const commit = (await r.git(['commit-tree', EMPTY_TREE, '-m', '[The Framework] tf-data'], cwd)).trim();
await r.git(['branch', DATA_BRANCH, commit], cwd);
}
}
// A stale registration at this path (the dir was deleted by hand) blocks the add.
await r.git(['worktree', 'prune'], cwd).catch(() => { });
await r.git(['worktree', 'add', path, DATA_BRANCH], cwd);
}
// Seed the queue on a branch born empty, so readers and humans find the file, not a mystery.
// Committed here, so the checkout is clean between cycles and no later write's message lies
// about carrying the seed; the push is owed like any local commit and rides the next cycle.
if (!(await r.lexists(join(path, FLAT_TODO_FILE)))) {
await r.write(join(path, FLAT_TODO_FILE), '');
await r.git(['add', '-A'], path);
await r.git(['commit', '-m', '[The Framework] seed the queue'], path);
}
// The roadmap stays one `ls` away: `<repo>/tickets` links into the data checkout. Created only
// over nothing — a real `tickets/` dir (pre-migration) or a user's own file is theirs. The link
// is framework state, so it is hidden from git the moment it is made: uncommitted at the root,
// it would ride any sweeping `git add -A` onto a code branch. The exclude is a pair because the
// repo-level exclude speaks for every worktree at once — this checkout included, whose root
// holds the real `tickets/` the branch exists to carry: `/tickets` hides root entries of that
// name, and `!/tickets/` re-includes directories (a trailing slash never matches a symlink), so
// the link stays hidden while the data checkout's own directory keeps committing.
const rootLink = join(cwd, TICKETS_DIR);
if (!(await r.lexists(rootLink))) {
await r.symlink(join(FRAMEWORK_DIR, BRANCHES_DIR, DATA_BRANCH, TICKETS_DIR), rootLink).catch(() => { });
await excludeFromGit(cwd, '/' + TICKETS_DIR, undefined, r.git).catch(() => { });
await excludeFromGit(cwd, '!/' + TICKETS_DIR + '/', undefined, r.git).catch(() => { });
}
}
/**
* Bring the checkout up to date with origin: fetch, then rebase whatever local commits exist (a
* push that could not land earlier) onto origin's tip. A conflict resolves toward origin — the
* writer re-applies the local intent afterwards, which is the "re-run on conflict" rule the whole
* cycle is built on.
*/
async function syncCore(cwd, r) {
if (!(await hasRemote(cwd, r.git)))
return;
const path = dataWorktreePath(cwd);
await r.git(['fetch', 'origin', DATA_BRANCH], cwd).catch(() => { });
if (!(await refExists(cwd, `refs/remotes/origin/${DATA_BRANCH}`, r.git)))
return;
try {
await r.git(['rebase', `origin/${DATA_BRANCH}`], path);
}
catch {
await r.git(['rebase', '--abort'], path).catch(() => { });
await r.git(['reset', '--hard', `origin/${DATA_BRANCH}`], path);
}
}
/**
* Make sure the data branch and its checkout exist, the queue file is seeded, and the root
* `tickets` symlink points into the checkout. Idempotent and cheap when everything is in place
* (one `git rev-parse` against the checkout); never throws — a project this cannot be set up in
* reports why and is left alone.
*/
export async function ensureDataWorktree(cwd, deps = {}) {
const r = resolveDeps(deps);
return serialize(cwd, async () => {
try {
await ensureCore(cwd, r);
return { ok: true };
}
catch (err) {
return { ok: false, error: errorMessage(err) };
}
});
}
/**
* Apply one change to the data branch: sync with origin, run `op` against the checkout, commit
* whatever it changed, push. The single funnel every local data write goes through (#1582).
*
* `op` must be re-runnable: when the push loses a race with another machine, the cycle re-syncs
* and runs it again against the fresher state rather than force-fitting a stale commit — the op
* *is* the intent, the commit is just its serialization. Two attempts; a push that still fails
* (the network, most likely) keeps the commit local and reports the error — the next cycle's sync
* rebases it onto whatever origin has by then, and the next push carries it.
*
* Never throws: callers run on background ticks with nothing to catch it.
*/
export async function withDataBranch(cwd, message, op, deps = {}) {
const r = resolveDeps(deps);
const path = dataWorktreePath(cwd);
return serialize(cwd, async () => {
try {
await ensureCore(cwd, r);
const remote = await hasRemote(cwd, r.git);
for (let attempt = 0;; attempt++) {
await syncCore(cwd, r);
await op(path);
await r.git(['add', '-A'], path);
const staged = (await r.git(['status', '--porcelain'], path)).trim();
// The message is resolved after the op ran: a batch write only knows what it did then.
if (staged)
await r.git(['commit', '-m', typeof message === 'function' ? message() : message], path);
if (!remote)
return { ok: true, changed: Boolean(staged), pushed: false };
// Unpushed commits — this cycle's, or an earlier cycle's that the sync just rebased. The
// push is owed whenever any exist, even when this op itself wrote nothing new.
const ahead = (await refExists(cwd, `refs/remotes/origin/${DATA_BRANCH}`, r.git))
? (await r.git(['rev-list', '--count', `origin/${DATA_BRANCH}..${DATA_BRANCH}`], cwd)).trim() !== '0'
: true;
if (!ahead)
return { ok: true, changed: false, pushed: false };
try {
await r.git(['push', 'origin', `${DATA_BRANCH}:${DATA_BRANCH}`], path);
return { ok: true, changed: Boolean(staged), pushed: true };
}
catch (err) {
if (attempt >= 1)
return { ok: false, committed: true, error: `the data branch could not be pushed: ${errorMessage(err)}` };
}
}
}
catch (err) {
// The op's half-written files must not ride a later, unrelated commit: put the checkout
// back to its committed state before reporting.
await r.git(['reset', '--hard'], path).catch(() => { });
await r.git(['clean', '-fd'], path).catch(() => { });
return { ok: false, committed: false, error: errorMessage(err) };
}
});
}
/**
* The eager pull (#1582): sync the checkout with origin so this machine reads what other machines
* and cloud sessions committed, without waiting for the next local write — and push anything a
* failed cycle left stranded locally, via the same owed-push rule as the writer. Ensures the
* checkout exists, so a fresh clone converges on its first tick. Never throws.
*
* Reports why it could not converge (#1599): a push origin rejects, or no origin to converge with
* at all. The writer treats a remote-less repo as fine — the commit is safe locally — but a sync's
* whole job is to meet the other machines, and a repo nothing can reach is an error state the user
* has to fix, not a mode the framework supports (#1595). The daemon records the answer as the
* project's error state, so it reaches the dashboard rather than only the daemon's stdout.
*/
export async function pullDataBranch(cwd, deps = {}) {
const r = resolveDeps(deps);
const result = await withDataBranch(cwd, '[The Framework] data sync', async () => { }, deps);
const outcome = !result.ok
? { ok: false, error: result.error }
: (await hasRemote(cwd, r.git))
? { ok: true }
: { ok: false, error: 'the repository has no remote, so the data branch cannot be shared with other machines' };
if (!outcome.ok)
r.log(`[framework] data branch sync: ${outcome.error}`);
return outcome;
}
/**
* The project root `cwd` belongs to: the directory holding the repo's real `.git`. From the main
* checkout that is `cwd` itself; from an agent's worktree it is the repo the worktree was made
* from — where the data checkout lives, and the address every data write funnels to. `undefined`
* outside any repo.
*/
export async function dataProjectRoot(cwd, git = nodeGitRunner()) {
try {
const gitDir = (await git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd)).trim();
return gitDir ? dirname(gitDir) : undefined;
}
catch {
return undefined;
}
}
/**
* Read one file off the data branch, from anywhere in the repo: the checkout when this `cwd` has
* one, else `git show` against the local branch (worktrees share the repo's refs, so an agent's
* checkout reads the same data without holding any of it), else against `origin/…` (a fresh
* clone that fetched but never branched — the cloud case). `undefined` when the file exists in
* none of them. Never throws.
*
* `fresh: true` fetches first and prefers origin's copy — for a reader about to act on the queue
* from a long-lived agent process, where the local ref may trail what other writers pushed.
*/
export async function readDataFile(cwd, rel, opts = {}, deps = {}) {
const r = resolveDeps(deps);
const remote = opts.fresh && (await hasRemote(cwd, r.git));
if (remote)
await r.git(['fetch', 'origin', DATA_BRANCH], cwd).catch(() => { });
const sources = [
...(remote ? [`origin/${DATA_BRANCH}`] : []),
DATA_BRANCH,
...(remote ? [] : [`origin/${DATA_BRANCH}`]),
];
if (!opts.fresh) {
const fromCheckout = await r.git(['rev-parse', '--abbrev-ref', 'HEAD'], dataWorktreePath(cwd)).then(out => out.trim() === DATA_BRANCH, () => false);
if (fromCheckout) {
const { readFile } = await import('node:fs/promises');
const md = await readFile(join(dataWorktreePath(cwd), rel), 'utf8').catch(() => undefined);
if (md !== undefined)
return md;
}
}
for (const ref of sources) {
const md = await r.git(['show', `${ref}:${rel}`], cwd).catch(() => undefined);
if (md !== undefined)
return md;
}
return undefined;
}
//# sourceMappingURL=data-branch.js.map