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.

173 lines 8.22 kB
import { join } from 'node:path'; import { nodeGitRunner } from './project.js'; import { nodeFs } from './node-fs.js'; import { FRAMEWORK_DIR } from './store/index.js'; /** * The maintenance sweep (#298): a background job that walks the registered repos, * finds the commits each repo has grown since its last maintenance review, and runs * the maintainability loop on them. Per-repo review state is a small local file * (`.the-framework/maintenance.json`, gitignored) recording the last-reviewed commit, * so a sweep only ever acts on new work. The capacity gate is the existing budget cap * (`--max-cost`). #298's "check the limit" half is reachable after all — the agent * reports the account's quota per turn (#517) and on demand (#521) — but this sweep * does not gate on it yet; that is #519's consumption limits. */ /** The per-repo review-state filename under `.the-framework/`. */ export const MAINTENANCE_FILE = 'maintenance.json'; /** * How long a repo is left alone between automatic codebase-wide sweeps (#882). * * A week, and deliberately not configurable: per #879 the answer to "should this have a * setting?" is no unless a setting earns itself. The sweep only queues follow-up entries and * only runs on an idle machine under its quota boundary, so the cost of it being a little too * eager is a backlog entry, not a bill. */ export const DEFAULT_MAINTENANCE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; /** * Whether a repo is due an automatic sweep: never swept, or swept longer ago than the interval. * * A never-swept repo is due immediately, which is the case #882 exists for. The commit-delta * sweep (#298) treats a first-seen repo the opposite way, baselining it at HEAD so its whole * pre-existing history is never reviewed, and that is precisely the gap this closes. * * An unparseable timestamp counts as due: a repo whose state file was hand-edited into nonsense * should get swept, not fall silently out of the schedule forever. */ export function maintenanceDue(state, now, intervalMs = DEFAULT_MAINTENANCE_INTERVAL_MS) { if (!state.sweptAt) return true; const last = Date.parse(state.sweptAt); if (!Number.isFinite(last)) return true; return now - last >= intervalMs; } /** A {@link MaintenanceFs} backed by `node:fs/promises`. See {@link nodeFs}. */ function nodeMaintenanceFs() { const { read, write, mkdir } = nodeFs(); return { read, write, mkdir }; } /** The review-state file path for a repo. */ export function maintenanceStatePath(cwd) { return join(cwd, FRAMEWORK_DIR, MAINTENANCE_FILE); } /** Read a repo's review state. Forgiving: a missing/unreadable/malformed file yields `{}`. */ export async function readMaintenanceState(cwd, fs = nodeMaintenanceFs()) { try { const parsed = JSON.parse(await fs.read(maintenanceStatePath(cwd))); if (parsed && typeof parsed === 'object') { const record = parsed; const state = {}; if (typeof record['reviewedSha'] === 'string') state.reviewedSha = record['reviewedSha']; if (typeof record['reviewedAt'] === 'string') state.reviewedAt = record['reviewedAt']; if (typeof record['sweptAt'] === 'string') state.sweptAt = record['sweptAt']; return state; } } catch { // absent / unreadable / malformed -> no prior review } return {}; } /** Record a repo's review state, creating `.the-framework/` as needed. */ export async function writeMaintenanceState(cwd, state, fs = nodeMaintenanceFs()) { await fs.mkdir(join(cwd, FRAMEWORK_DIR)); await fs.write(maintenanceStatePath(cwd), JSON.stringify(state, null, 2)); } /** * Record part of a repo's state, leaving the keys not mentioned alone. * * Load-bearing since #882: {@link writeMaintenanceState} replaces the file wholesale, and two * features now write it. The commit-delta sweep (#298) writes `reviewedSha`/`reviewedAt` and the * automatic sweep writes `sweptAt`, so a wholesale write from either one would silently reset the * other's schedule. */ export async function mergeMaintenanceState(cwd, patch, fs = nodeMaintenanceFs()) { await writeMaintenanceState(cwd, { ...(await readMaintenanceState(cwd, fs)), ...patch }, fs); } /** * Assess one repo: resolve HEAD, read its review state, and count the commits since * the last review. A never-reviewed repo is `baseline` (we record HEAD without * reviewing history retroactively); an unchanged repo is `skip`; new commits are * `review`. A non-repo / missing git is `error` (skipped, never throws). A reviewed * SHA git no longer knows (rebased away) falls back to `review`. */ export async function assessRepo(path, git = nodeGitRunner(), fs = nodeMaintenanceFs()) { let headSha; try { headSha = (await git(['rev-parse', 'HEAD'], path)).trim(); } catch { return { path, newCommits: 0, action: 'error', note: 'not a git repo, or it has no commits' }; } const { reviewedSha } = await readMaintenanceState(path, fs); if (!reviewedSha) return { path, headSha, newCommits: 0, action: 'baseline' }; if (reviewedSha === headSha) return { path, headSha, reviewedSha, newCommits: 0, action: 'skip' }; try { const count = Number.parseInt((await git(['rev-list', '--count', `${reviewedSha}..HEAD`], path)).trim(), 10); const newCommits = Number.isFinite(count) ? count : 0; return { path, headSha, reviewedSha, newCommits, action: newCommits > 0 ? 'review' : 'skip' }; } catch { // The reviewed commit is unknown to git (history was rewritten): re-review to be safe. return { path, headSha, reviewedSha, newCommits: 0, action: 'review', note: 'reviewed commit not found (history changed); re-reviewing' }; } } /** Assess every registered repo, tagging each review with its registry id. */ export async function planMaintenanceSweep(repos, git = nodeGitRunner(), fs = nodeMaintenanceFs()) { return Promise.all(repos.map(async (repo) => ({ ...(await assessRepo(repo.path, git, fs)), ...(repo.id ? { id: repo.id } : {}) }))); } /** * Orchestrate a sweep over pre-assessed reviews: baseline first-seen repos (record * HEAD, no run), skip up-to-date ones, and run the maintenance loop on the rest — * recording the reviewed SHA only when the agent succeeds, so a failure is retried next * sweep. Honors `maxRepos`; the remainder is reported as `pending`. */ export async function maintainSweep(reviews, deps) { const summary = { reviewed: 0, baselined: 0, skipped: 0, failed: 0, pending: 0 }; const limit = deps.maxRepos ?? Infinity; for (const review of reviews) { if (review.action === 'skip') { summary.skipped++; continue; } if (review.action === 'error') { summary.failed++; deps.log(`✗ ${review.path}: ${review.note ?? 'could not assess'}`); continue; } if (review.action === 'baseline') { if (review.headSha) await deps.record(review.path, { reviewedSha: review.headSha, reviewedAt: deps.now() }); summary.baselined++; deps.log(`◆ baselined ${review.path} at ${short(review.headSha)}`); continue; } // action === 'review' if (summary.reviewed >= limit) { summary.pending++; continue; } deps.log(`▶ maintaining ${review.path} (${review.newCommits} new commit${review.newCommits === 1 ? '' : 's'})…`); const ok = await deps.agent(review); if (ok && review.headSha) { await deps.record(review.path, { reviewedSha: review.headSha, reviewedAt: deps.now() }); summary.reviewed++; deps.log(`✓ ${review.path} reviewed`); } else { summary.failed++; deps.log(`✗ ${review.path} maintenance session failed; will retry next sweep`); } } return summary; } /** Short SHA for logs. */ export function short(sha) { return sha ? sha.slice(0, 7) : '(unknown)'; } //# sourceMappingURL=maintenance.js.map