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.

261 lines 13.3 kB
import { randomUUID } from 'node:crypto'; import { StreamJsonParser } from './claude-code.js'; import { readZip } from './actions-zip.js'; import { combineFraming, makeEmit } from './session-support.js'; /** * A {@link Driver} that runs the agent on **GitHub Actions** instead of on this * machine (#610): dispatch a workflow, poll it, read the transcript it uploads. * * This is the answer to "drive Claude Code on the web". The routines fire API was * the obvious candidate and turned out unusable — the prompt arrives wrapped as * untrusted data, and there is no read-back of any kind. The official * `anthropics/claude-code-action@v1` has neither problem: the prompt is passed * verbatim, and the run publishes its full transcript. Auth is the same * subscription posture as everywhere else (#495): a `claude setup-token` OAuth * token held by the repo, never an API key of ours. * * It fits `Driver`/`DriverSession` as written, with no new methods. What changes is * not the shape but the tempo, and those costs are real: * * - **Minutes, not seconds.** Every `prompt` is a fresh runner and a fresh * checkout. Continuity comes from the branch the previous turn pushed, which the * session tracks and dispatches onto next time. * - **No live stream.** The transcript arrives once, at the end, so the dashboard's * {@link DriverStartOptions.onEvent} feed replays in a burst rather than trickling. * - **Quota is the account's, not the runner's.** Free minutes on a public repo * change nothing about the subscription window every run draws down. * * The workspace lives on a runner that is gone by the time we read it, so * {@link ActionsSession.readCode} reads from the pushed branch over the contents * API rather than from disk. */ export class ActionsDriver { opts; id = 'github-actions'; constructor(opts) { this.opts = opts; } start(opts) { return Promise.resolve(new ActionsSession(this.opts, opts)); } } let sessionCounter = 0; /** A short random tag so correlation ids stay unique across driver processes. */ const randomRunTag = () => randomUUID().slice(0, 8); /** One Actions-backed session. Each `prompt` is one workflow run. */ export class ActionsSession { config; startOpts; id; cwd; /** The branch the last run pushed; set once the run reports it, and the next turn builds on it. */ branch; /** The branch this session asks each run to push to. Stable across turns, so they chain. */ runBranch; /** The agent's own session id, carried across turns so `resume` can continue it. */ lastSessionId; turnCounter = 0; constructor(config, startOpts) { this.config = config; this.startOpts = startOpts; this.cwd = startOpts.cwd; // The counter reads well in logs within one process; the random tag is what keeps the // correlation id unique across processes, since the daemon spawns a fresh one per run. this.id = `actions-${++sessionCounter}-${(config.runTag ?? randomRunTag)()}`; this.runBranch = `${config.branchPrefix ?? 'claude/'}framework-${this.id}`; this.lastSessionId = startOpts.resumeSessionId; } async prompt(text, opts = {}) { const emit = makeEmit(this.startOpts.onEvent, 'github-actions'); // The action takes `prompt` as an action input, not through a shell, so a // multi-line prompt is safe. The framing rides in front of it (as with Codex): // `--append-system-prompt` would have to survive shell-quoting inside the // workflow, and a system prompt is not worth an injection seam. const framing = combineFraming(this.startOpts.system, opts.system); const prompt = framing ? `${framing}\n\n${text}` : text; emit({ type: 'start', prompt }); // How we find our run: the dispatch API returns no run id, so the workflow echoes this // into its run-name and artifact name and we match on it. It must be unique per run (the // session's random tag) so a fresh process never latches onto a stale same-named run. const correlationId = `${this.id}-turn-${++this.turnCounter}`; const resume = opts.resume ? this.lastSessionId : undefined; await this.dispatch(prompt, correlationId, resume); emit({ type: 'notice', message: `Dispatched ${correlationId} to ${this.config.owner}/${this.config.repo}; waiting for the runner.` }); const run = await this.awaitWorkflowRun(correlationId, emit, opts.signal); const artifact = await this.readRunArtifact(run.id, correlationId); if (artifact.branch) this.branch = artifact.branch; const turn = replayTranscript(artifact.execution, emit); if (turn.sessionId) this.lastSessionId = turn.sessionId; emit({ type: 'result', text: turn.text, ...(turn.sessionId ? { sessionId: turn.sessionId } : {}), ...(turn.usage ? { usage: turn.usage } : {}) }); return turn; } /** * Read a file the agent produced. The runner is gone, so this reads the branch the * run pushed rather than the local workspace — the seam is still the code, just * fetched over the contents API. */ async readCode(path) { if (!this.branch) throw new Error('No branch yet: readCode is only available after a run has pushed one.'); const encoded = path.split('/').map(encodeURIComponent).join('/'); const body = await this.api(`/repos/${this.owner}/contents/${encoded}?ref=${encodeURIComponent(this.branch)}`); if (typeof body.content !== 'string') throw new Error(`${path} is not a file on ${this.branch}`); return Buffer.from(body.content, body.encoding === 'base64' ? 'base64' : 'utf8').toString('utf8'); } dispose() { // Every run reaps itself on the runner; there is nothing here to free. return Promise.resolve(); } /** Fire the workflow. Returns nothing useful: dispatch is 204 with no body, hence the correlation id. */ async dispatch(prompt, correlationId, resume) { const workflow = this.config.workflow ?? 'framework-agent.yml'; const inputs = { prompt, correlation_id: correlationId, branch: this.runBranch }; // These reach a shell on the runner as environment variables. They are ids and // model names, so anything outside that alphabet is a bug or an attack. if (this.startOpts.model) inputs['model'] = assertToken(this.startOpts.model, 'model'); if (resume) inputs['resume_session_id'] = assertToken(resume, 'resume session id'); await this.api(`/repos/${this.owner}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { method: 'POST', body: JSON.stringify({ ref: this.branch ?? this.config.ref ?? 'main', inputs }), }); } /** Poll until our run appears and finishes. Identified by the correlation id in its `run-name`. */ async awaitWorkflowRun(correlationId, emit, promptSignal) { const now = this.config.now ?? Date.now; const sleep = this.config.sleep ?? (ms => new Promise(r => setTimeout(r, ms))); const interval = this.config.pollIntervalMs ?? 5000; const deadline = now() + (this.config.timeoutMs ?? 60 * 60 * 1000); let announced = false; for (;;) { const found = await this.findWorkflowRun(correlationId); if (found) { if (!announced) { announced = true; emit({ type: 'action', label: `run ${found.html_url}` }); } if (found.status === 'completed') { if (found.conclusion !== 'success') throw new Error(`GitHub Actions run concluded "${found.conclusion}": ${found.html_url}`); return found; } } if (now() >= deadline) throw new Error(`Timed out waiting for the GitHub Actions run (${correlationId}).`); this.throwIfAborted(promptSignal); await sleep(interval); this.throwIfAborted(promptSignal); } } /** Our run among the workflow's recent ones, or undefined while GitHub is still creating it. */ async findWorkflowRun(correlationId) { const body = await this.api(`/repos/${this.owner}/actions/runs?event=workflow_dispatch&per_page=50`); return (body.workflow_runs ?? []).find(run => typeof run.name === 'string' && run.name.includes(correlationId)); } /** Download the run's artifact and pull the transcript and the pushed branch out of it. */ async readRunArtifact(runId, correlationId) { const list = await this.api(`/repos/${this.owner}/actions/runs/${runId}/artifacts`); const artifact = (list.artifacts ?? []).find(a => a.name.includes(correlationId)) ?? list.artifacts?.[0]; if (!artifact) throw new Error(`Run ${runId} uploaded no artifact; the workflow's collect step did not run.`); const res = await this.request(`/repos/${this.owner}/actions/artifacts/${artifact.id}/zip`); const entries = readZip(Buffer.from(await res.arrayBuffer())); const execution = entries.find(e => e.name.endsWith('execution.json')); if (!execution) throw new Error(`Artifact ${artifact.name} has no execution.json (entries: ${entries.map(e => e.name).join(', ') || 'none'})`); const meta = entries.find(e => e.name.endsWith('meta.json')); const branch = meta ? readBranch(meta.data.toString('utf8')) : undefined; return { execution: execution.data.toString('utf8'), ...(branch ? { branch } : {}) }; } get owner() { return `${this.config.owner}/${this.config.repo}`; } /** The session-wide signal or the per-prompt one (`DriverPromptOptions.signal`) both stop the poll. */ throwIfAborted(promptSignal) { if (this.startOpts.signal?.aborted || promptSignal?.aborted) throw new Error('Session aborted while waiting for the GitHub Actions run.'); } /** A REST call that expects JSON back. */ async api(path, init) { const res = await this.request(path, init); if (res.status === 204) return undefined; return (await res.json()); } /** A REST call, with auth and error handling. */ async request(path, init = {}) { const doFetch = this.config.fetch ?? globalThis.fetch; const res = await doFetch(`${this.config.apiBase ?? 'https://api.github.com'}${path}`, { ...init, headers: { accept: 'application/vnd.github+json', authorization: `Bearer ${this.config.token}`, 'x-github-api-version': '2022-11-28', ...(init.body ? { 'content-type': 'application/json' } : {}), ...init.headers, }, }); if (!res.ok) throw new Error(`GitHub API ${init.method ?? 'GET'} ${path} failed (${res.status} ${res.statusText}): ${await safeText(res)}`); return res; } } /** * Turn the action's `execution_file` into a turn, replaying its events on the way. * * The adapter is thin on purpose: the file is a JSON **array** of exactly the * SDKMessage objects the CLI emits one-per-line, so the existing * {@link StreamJsonParser} reads it verbatim once the array is unwrapped. The whole * difference between running locally and running on a runner is array-vs-JSONL. * * Events replay in a burst at the end rather than live — that is the honest cost of * this driver, and the dashboard sees the same event stream either way. */ export function replayTranscript(json, emit = () => { }) { let messages; try { messages = JSON.parse(json); } catch (err) { throw new Error(`Could not parse the run transcript as JSON: ${err.message}`); } if (!Array.isArray(messages)) throw new Error('The run transcript is not a JSON array of messages.'); const parser = new StreamJsonParser(); for (const message of messages) { for (const event of parser.push(JSON.stringify(message))) emit(event); } return parser.result(); } /** The branch the run pushed, from the workflow's `meta.json`. Absent when the agent pushed nothing. */ function readBranch(json) { try { const meta = JSON.parse(json); const branch = meta['branch']; return typeof branch === 'string' && branch ? branch : undefined; } catch { return undefined; // A malformed meta file costs us `readCode`, not the turn. } } /** Reject anything that is not an opaque id, since these reach a shell on the runner. */ function assertToken(value, what) { if (!/^[A-Za-z0-9._:-]+$/.test(value)) throw new Error(`Refusing to pass an unsafe ${what} to the workflow: ${value}`); return value; } /** An error body, best-effort — a failure to read one must not replace the real error. */ async function safeText(res) { try { return (await res.text()).slice(0, 500); } catch { return '<no body>'; } } //# sourceMappingURL=actions.js.map