import { env } from "cloudflare:workers"; import { z } from "zod"; import { placeholders } from "../../db"; import { repoDb } from "../../scoped-db"; import { badRequest, notFoundError } from "../../errors"; import { runnerStub } from "../../ci/jobs"; import type { WorkflowJobRow, WorkflowRunRow } from "../../ci/jobs"; import type { LogRow } from "../../ci/runner-meta"; import { runUrl, scheduleWorkflows } from "../../ci/scheduler"; import { WORKFLOW_DIR } from "../../ci/spec"; import { limitHeavy, limitWrite } from "../../limits"; import { defineTool, repoArgs, scopedRepo } from "./base"; import { resolveRef } from "./content"; const LIST_LIMIT = 25; const MAX_LISTED_RUNS = 20; const MAX_LOG_ROWS = 2000; const MAX_LOG_CHARS = 64 * 1024; const MAX_FOLLOW_SECONDS = 25; const POLL_MS = 2000; const runCiTool = defineTool({ name: "run_ci", title: "Run CI", description: `Dispatch a workflow (a file under ${WORKFLOW_DIR} with workflow_dispatch) at a ref.`, input: z.object({ ...repoArgs, workflow: z.string().min(1).max(500).describe(`Workflow path, e.g. ${WORKFLOW_DIR}/ci.yml`), ref: z.string().max(250).optional(), inputs: z.record(z.string(), z.string().max(1000)).default({}), }), write: true, async run({ caller }, args) { await limitWrite(caller.viewer); const { repo } = await scopedRepo(caller, args.owner, args.repo, "actions", "write"); const revision = await resolveRef(repo, args.ref); const ids = await scheduleWorkflows(repo, { event: "workflow_dispatch", action: null, ref: revision.kind === "tag" ? `refs/tags/${revision.name}` : `refs/heads/${revision.name}`, sha: revision.sha, actorId: caller.viewer.id, actorName: caller.viewer.name, title: `Run by agent ${caller.agent.name} on ${revision.name}`, prNumber: null, headRef: null, baseRef: null, changedPaths: null, inputs: args.inputs, workflowPath: args.workflow, sourceRepo: repo, }); if (ids.length === 0) throw badRequest("That workflow does not accept workflow_dispatch at this ref"); const shown = ids.slice(0, MAX_LISTED_RUNS); const runs = await repoDb(repo.id).all( `SELECT * FROM workflow_runs WHERE id IN (${placeholders(shown.length)})`, ...shown, ); return { structured: { runs: runs.map((row) => ({ number: row.run_number, status: row.status, url: runUrl(repo, row.run_number) })), }, repoId: repo.id, }; }, }); function runJson(row: WorkflowRunRow, repo: { owner_name: string; name: string }) { return { number: row.run_number, workflow: row.workflow_path, name: row.workflow_name, event: row.event, ref: row.ref, sha: row.sha, status: row.status, conclusion: row.conclusion, created_at: row.created_at, url: runUrl(repo, row.run_number), }; } const listRunsTool = defineTool({ name: "list_ci_runs", title: "List CI runs", description: "Recent workflow runs, optionally filtered by commit SHA or branch.", input: z.object({ ...repoArgs, sha: z.string().length(40).optional(), branch: z.string().max(250).optional() }), write: false, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "actions", "read"); const rows = await repoDb(repo.id).all( `SELECT * FROM workflow_runs WHERE repo_id = ?1 AND (?2 IS NULL OR sha = ?2) AND (?3 IS NULL OR ref = ?3) ORDER BY run_number DESC LIMIT ${LIST_LIMIT}`, repo.id, args.sha ?? null, args.branch ? `refs/heads/${args.branch}` : null, ); return { structured: { runs: rows.map((row) => runJson(row, repo)) }, repoId: repo.id }; }, }); async function loadRun(repoId: string, runNumber: number): Promise { const row = await repoDb(repoId).first( "SELECT * FROM workflow_runs WHERE repo_id = ? AND run_number = ?", repoId, runNumber, ); if (!row) throw notFoundError("Run not found"); return row; } const getRunTool = defineTool({ name: "get_ci_run", title: "Get CI run status", description: "Status and conclusion of a run and each of its jobs. Use job ids with get_ci_logs.", input: z.object({ ...repoArgs, run: z.number().int().positive() }), write: false, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "actions", "read"); const row = await loadRun(repo.id, args.run); const jobs = await repoDb(repo.id).all( "SELECT * FROM workflow_jobs WHERE run_id = ? ORDER BY created_at, job_key", row.id, ); return { structured: { ...runJson(row, repo), jobs: jobs.slice(0, 100).map((job) => ({ id: job.id, key: job.job_key, name: job.name, status: job.status, conclusion: job.conclusion, })), }, repoId: repo.id, }; }, }); async function readLogs( job: Pick, after: number, ): Promise<{ rows: LogRow[]; done: boolean }> { const live = await runnerStub(job.id, job.runs_on).logs(after); if (live.rows.length > 0 || !live.done) return live; const archived = await env.BUCKET.get(`logs/${job.id}.json`); const rows: LogRow[] = archived ? await archived.json() : []; return { rows: rows.filter((row) => row.seq > after), done: true }; } const logsTool = defineTool({ name: "get_ci_logs", title: "Stream CI logs", description: `Log lines of a job after a cursor. follow_seconds (max ${MAX_FOLLOW_SECONDS}) waits for new lines and streams them as progress notifications when the request carries a progressToken.`, input: z.object({ ...repoArgs, job_id: z.string().min(1).max(64), after: z.number().int().min(0).default(0), follow_seconds: z.number().int().min(0).max(MAX_FOLLOW_SECONDS).default(0), }), write: false, async run({ caller, progress, signal }, args) { await limitHeavy(caller.viewer); const { repo } = await scopedRepo(caller, args.owner, args.repo, "actions", "read"); const job = await repoDb(repo.id).first( "SELECT * FROM workflow_jobs WHERE id = ? AND repo_id = ?", args.job_id, repo.id, ); if (!job) throw notFoundError("Job not found"); const deadline = Date.now() + args.follow_seconds * 1000; const collected: LogRow[] = []; let cursor = args.after; let done = false; let chars = 0; while (!signal.aborted) { const batch = await readLogs(job, cursor); for (const row of batch.rows) { if (collected.length >= MAX_LOG_ROWS || chars > MAX_LOG_CHARS) break; collected.push(row); chars += row.text.length; cursor = row.seq; } if (batch.rows.length > 0) progress( batch.rows .map((row) => row.text) .join("\n") .slice(0, 4000), cursor, ); done = batch.done; if (done || Date.now() >= deadline || collected.length >= MAX_LOG_ROWS || chars > MAX_LOG_CHARS) break; await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } return { structured: { job_id: job.id, status: job.status, done, cursor, lines: collected }, text: collected.map((row) => row.text).join("\n"), repoId: repo.id, }; }, }); export const ciTools = [runCiTool, listRunsTool, getRunTool, logsTool];