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
JavaScript
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