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.

164 lines 8.92 kB
import { listAgents, readLiveMetas } from '../store/index.js'; import { isAgentBranch, readAgentHandoff, agentBranchFor } from './agent-handoff.js'; import { ghPrList } from './gh.js'; import { interventionKey } from './keys.js'; import { postDiscordWebhook } from './discord-webhook.js'; // Pure identity + diff, in the leaf `keys.ts` so the dashboard shares them rather than copying. export { interventionKey } from './keys.js'; /** How many recent finished agents are inspected per project by default. */ const HANDOFF_LIMIT = 5; /** * Build the cross-project interventions queue: every registered project's open PRs, plus any run * currently paused on a choice gate (#636), newest first. Forgiving — a project with no remote * (or an unreadable one) simply contributes nothing. Hand-opened draft PRs are excluded: they are * not yet asking for review. A session's own draft is not (#1102), because that is how * auto-handoff hands work back. * * Which projects were read whole comes back alongside the items (#1623): forgiveness here is what * lets one unreachable project keep the queue useful, and it is also what would let that project's * whole backlog announce itself as new the moment it came back. The queue's panels ignore this; * the notification watcher is the caller that cannot. */ export async function buildInterventions(projects, deps = {}) { const prs = deps.prs ?? ghPrList; const liveAgents = deps.liveAgents ?? readLiveMetas; const items = []; const whole = []; for (const project of projects) { // A project is read whole only when every one of its sources answered. Each `unread()` below is // a source that did not, and contributed nothing for a reason other than nothing being there. let sawEverything = true; const unread = () => { sawEverything = false; return []; }; const open = await prs(project.path).catch(unread); for (const pr of open) { // A draft opened by hand is not asking for review, so it stays off the queue. A draft the // framework opened for a session is the opposite (#1102): auto-handoff opens it as a draft // precisely so it does not ping reviewers, and if the queue then dropped it too, nothing // would tell anyone the work exists — which is the whole of #860 again. if (pr.isDraft && !isAgentBranch(pr.headRefName)) continue; items.push({ projectId: project.id, projectName: project.name, kind: 'pr', number: pr.number, title: pr.title, url: pr.url, ...(pr.createdAt ? { createdAt: pr.createdAt } : {}), }); } // An agent paused mid-flight to ask the user is a "needs you" too (#636): a live agent that is // still `running` and has an unresolved choice gate. An agent parks on one gate at a time, but // a project now has several concurrent agents (#736), so each parked agent contributes its own // item — keyed on the gate id, plus the agent id so two agents are told apart. for (const meta of await liveAgents(project.path).catch(unread)) { if (meta.status !== 'running' || !meta.pendingChoice) continue; items.push({ projectId: project.id, projectName: project.name, kind: 'awaiting', title: meta.pendingChoice.title, url: deps.dashboardUrl ?? '', awaitId: meta.pendingChoice.id, agentId: meta.id, ...(meta.updatedAt ? { createdAt: meta.updatedAt } : {}), }); } // A finished agent whose work never left the machine is a "needs you" too (#860). Until now the // queue only knew about a PR that is *already on GitHub* and an agent parked on a gate, so an agent // that committed real code and stopped produced neither, and nothing told anyone: the overview // drops it (it filters on `running`) and the handoff panel is behind clicking into that agent. // // Surfacing only: this says there is a decision waiting, it does not take it. Since #1102 a // session usually pushes itself, so what reaches here is the remainder — auto-handoff turned // off for the project, or turned off for that session, or tried and failed. for (const item of await unpushedFor(project, deps, unread).catch(unread)) items.push(item); if (sawEverything) whole.push(project.id); } items.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); // The same repo can be registered under two projects (e.g. a monorepo root + a subdir), so a // PR would otherwise appear once per entry. Collapse by identity, keeping the first (newest-sorted). const seen = new Set(); const unique = items.filter(item => (seen.has(interventionKey(item)) ? false : (seen.add(interventionKey(item)), true))); return { items: unique, whole }; } /** * The finished agents of a project whose branch still holds unpushed, unmerged commits (#860). * * Only the most recent {@link InterventionsDeps.handoffLimit} finished agents are inspected: each * costs several git reads and this runs on a poll. */ async function unpushedFor(project, deps, unread) { const agents = deps.agents ?? listAgents; const handoff = deps.handoff ?? // The default skips the `gh` PR lookup `readAgentHandoff` would otherwise do per branch: an open // PR means the branch was pushed, so `pushed` already excludes it, and the `pr` kind above is // what surfaces it. Paying an 8s-timeout network call per agent on every poll to learn that // would be the most expensive part of this whole queue. ((cwd, branch) => readAgentHandoff(cwd, branch, { pr: async () => undefined })); const finished = (await agents(project.path)) .filter(agent => agent.status !== 'running') .sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? '')) .slice(0, deps.handoffLimit ?? HANDOFF_LIMIT); const items = []; for (const agent of finished) { const branch = agentBranchFor(agent); const state = await handoff(project.path, branch).catch(() => { unread(); return undefined; }); // Every condition is a reason this is *not* waiting on anyone: the branch is gone, the session // wrote nothing, it already landed, it is already on the remote, or there is nowhere to push. if (!state || !state.exists || state.empty || state.merged || state.pushed || !state.hasRemote) continue; items.push({ projectId: project.id, projectName: project.name, kind: 'unpushed', title: agent.intent?.trim() || branch, url: deps.dashboardUrl ?? '', agentId: agent.id, branch, commits: state.commits.length, ...(agent.updatedAt ? { createdAt: agent.updatedAt } : {}), }); } return items; } /** * How one intervention reads on Discord. Beside {@link Intervention} rather than inside the * watcher that posts it: it switches on every `kind`, so adding a kind is a change here, not in * a transport module that has no other opinion about what an intervention is. * * A PR reads `#123 Title — url`; a paused agent (#636) has no number and only the dashboard url, * so it reads `Title — awaiting your answer` with the link appended when the daemon knows it. * Unpushed work (#860) names the branch, since that is the actionable part. */ export function interventionLine(item) { if (item.kind === 'awaiting') return `${item.title} — awaiting your answer${item.url ? ` — ${item.url}` : ''}`; if (item.kind === 'unpushed') { const count = item.commits === 1 ? '1 commit' : `${item.commits ?? 0} commits`; return `${item.title}${count} on ${item.branch ?? ''}, never pushed${item.url ? ` — ${item.url}` : ''}`; } return `#${item.number} ${item.title}${item.url}`; } /** * Post the given interventions to a Discord webhook as one message, resolving whether Discord * accepted it (#940). `fetch` is injectable for tests. */ export async function postInterventionsDiscord(webhook, items, fetchImpl = fetch) { if (items.length === 0) return true; const content = items.length === 1 ? `🔔 Needs you (${items[0].projectName}): ${interventionLine(items[0])}` : `🔔 ${items.length} items need you:\n${items.map(i => `• ${interventionLine(i)}`).join('\n')}`; return postDiscordWebhook(webhook, content, fetchImpl); } //# sourceMappingURL=interventions.js.map