From 233070e1399402968201918f315d133f015f2d0c Mon Sep 17 00:00:00 2001 From: Jacob Prall Date: Sat, 24 Oct 2026 15:27:32 -0700 Subject: [PATCH 1/4] Add a run viewer to the gateway The gateway serves a read-only viewer at /runs when FACTORY_VIEWER_PASSWORD is set: a plain HTML/CSS/JS page and two JSON routes, /api/runs and /api/runs/:traceId/events, behind HTTP Basic auth (user "factory"). - No dependencies or build step. Runs are listed newest first with keyset pagination on events.id; a run's events load when it is expanded. Every value is set with textContent, and the page is served with a strict CSP. - Styled to Render's brand foundations: semantic tokens in light and dark, square corners and hairlines, mono for metadata, purple only for links and the primary action, and green/red only for done/failed. - Migration 0002 adds the two indexes the viewer reads through and drops the unused run-claim index. - Takeover and /factory cancel record run.finished for the run they stop, since Render kills a cancelled run before it can record how it ended. - render.yaml generates FACTORY_VIEWER_PASSWORD on the gateway. --- .env.example | 2 + AGENTS.md | 2 +- app/claims/claim.ts | 2 +- app/claims/index.ts | 1 + app/claims/status.ts | 32 ++- app/entrypoints/gateway.ts | 13 +- app/events/index.ts | 87 ++++++++ app/gateway/index.ts | 6 + app/gateway/viewer/app.css | 372 ++++++++++++++++++++++++++++++++ app/gateway/viewer/app.js | 185 ++++++++++++++++ app/gateway/viewer/index.ts | 87 ++++++++ app/gateway/viewer/page.html | 42 ++++ app/sql/0002_viewer_indexes.sql | 6 + app/workflows/cancel.ts | 3 +- docs/architecture.md | 4 +- docs/configuration.md | 1 + docs/operations.md | 17 +- render.yaml | 3 + scripts/lib/requirements.ts | 8 +- tests/cancel.test.ts | 7 +- tests/claims.test.ts | 12 +- tests/gateway.test.ts | 56 +++++ 22 files changed, 931 insertions(+), 17 deletions(-) create mode 100644 app/gateway/viewer/app.css create mode 100644 app/gateway/viewer/app.js create mode 100644 app/gateway/viewer/index.ts create mode 100644 app/gateway/viewer/page.html create mode 100644 app/sql/0002_viewer_indexes.sql diff --git a/.env.example b/.env.example index ba95476..8ffcc52 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ PORT=3000 GITHUB_WEBHOOK_SECRET= # Slug of the factory-workflows service; tasks start as /. RENDER_WORKFLOW_SLUG=factory-workflows +# Optional: turns on the run viewer at /runs (user "factory"). At least 16 characters. +FACTORY_VIEWER_PASSWORD= # Workflows. RENDER_WORKSPACE_ID= diff --git a/AGENTS.md b/AGENTS.md index b94525e..a0f055d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ app/ entrypoints/ Gateway and Workflows process entry points config/ Loads factory.config.ts; RepoContext and resolveRepo; TASKS names; modelFor; requireEnv schemas/ Zod schemas: task inputs (tasks.ts) and agent outputs (triage, spec, implementation, review) - gateway/ Hono app: signature check, body cap, the routing table (routes.ts), which validates task inputs + gateway/ Hono app: signature check, body cap, the routing table (routes.ts), which validates task inputs; viewer/ is the read-only run viewer workflows/ The tasks: triage/, spec-to-pr/, pr-review/, cancel.ts, refresh-snapshot.ts; claiming-task.ts and run-verify.ts are shared claims/ withClaim lifecycle: permission, the status-comment claim, takeover and sandbox cleanup, checkpoints, expected failures agents/ One directory with index.ts and prompt.md per agent; shared defineAgent/runAgent runtime diff --git a/app/claims/claim.ts b/app/claims/claim.ts index 3a1cd73..37feed5 100644 --- a/app/claims/claim.ts +++ b/app/claims/claim.ts @@ -167,7 +167,7 @@ class ClaimedRun implements Claim { /** Stops the previous run and rewrites the status comment to name this one, then confirms no other run's write landed after it. */ async acquire(): Promise { const { task, title, runs } = this.#opts; - await takeOver({ gh: this.gh, subject: this.subject, task, title, runId: this.runId, runs }); + await takeOver({ gh: this.gh, repo: this.repo.fullName, subject: this.subject, task, title, runId: this.runId, runs }); await this.checkpoint(); } diff --git a/app/claims/index.ts b/app/claims/index.ts index 4f1b34a..996368b 100644 --- a/app/claims/index.ts +++ b/app/claims/index.ts @@ -7,6 +7,7 @@ export { type StatusGitHub, type StatusLink, type StatusUpdate, + type StopContext, type Subject, stopRun, subjectName, diff --git a/app/claims/status.ts b/app/claims/status.ts index 9afb779..8e9e9b8 100644 --- a/app/claims/status.ts +++ b/app/claims/status.ts @@ -1,4 +1,5 @@ import { ClientError, Render } from "@renderinc/sdk"; +import { emit } from "../events/index.js"; import { findMarkedComment, type GitHubClient, type IssueComment, type RunState, renderMarker, type StatusMarker } from "../github/index.js"; import { sandboxClient } from "../sandbox/index.js"; @@ -52,14 +53,32 @@ export async function readStatus(gh: StatusGitHub, number: number): Promise<{ co return findMarkedComment(await gh.listIssueComments(number), "status", await gh.botLogin()); } +/** Who stops a run and why, for the `run.finished` event recorded on the stopped run's behalf. */ +export interface StopContext { + /** "owner/name" */ + repo: string; + /** "issue#12" or "pr#34" */ + subject: string; + /** The task run doing the stopping. */ + by: string; + outcome: "superseded" | "cancelled"; +} + /** * Stops the run a status marker records: cancels it if it's still active, then terminates the sandbox it recorded. * Render kills a cancelled task without running its `finally`, so the sandbox would otherwise live until its - * timeout. Returns whether the run was still active. + * timeout, and the run never records how it ended; this records `run.finished` for it. Returns whether the run + * was still active. */ -export async function stopRun(runs: RunControl, status: StatusMarker): Promise { +export async function stopRun(runs: RunControl, status: StatusMarker, context: StopContext): Promise { const active = await isRunActive(runs, status.runId); - if (active) await cancelActiveRun(runs, status.runId); + if (active) { + await cancelActiveRun(runs, status.runId); + const { repo, subject, by, outcome } = context; + // Claiming tasks are started by the gateway, so each is its own root task run and its run ID is its trace ID. + const payload = { stage: status.stage, state: "cancelled", outcome, stoppedBy: by }; + await emit({ traceId: status.runId, runId: status.runId, repo, subject, type: "run.finished", payload }); + } if (status.sandboxId) { const { sandboxId } = status; await runs.terminateSandbox(sandboxId).catch((error) => warn("could not terminate the stopped run's sandbox", error, { sandboxId })); @@ -94,16 +113,19 @@ async function cancelActiveRun(runs: RunControl, taskRunId: string): Promise { - const { gh, subject, task, title, runId } = opts; + const { gh, repo, subject, task, title, runId } = opts; const current = await readStatus(gh, subject.number); const previous = current && current.data.runId !== runId ? current.data : null; - const cancelledPrevious = previous ? await stopRun(opts.runs ?? workflowRuns(), previous) : false; + const stop = { repo, subject: subjectName(subject), by: runId, outcome: "superseded" } as const; + const cancelledPrevious = previous ? await stopRun(opts.runs ?? workflowRuns(), previous, stop) : false; const body = statusBody({ task, title, runId, state: "running" }); const comment = current ? await gh.updateComment(current.comment.id, body) : await gh.createComment(subject.number, body); await addWorkingLabel(gh, subject.number); diff --git a/app/entrypoints/gateway.ts b/app/entrypoints/gateway.ts index e168516..9d41d46 100644 --- a/app/entrypoints/gateway.ts +++ b/app/entrypoints/gateway.ts @@ -1,8 +1,8 @@ import { serve } from "@hono/node-server"; import { Render } from "@renderinc/sdk"; import { requireEnv } from "../config/index.js"; -import { closeEvents } from "../events/index.js"; -import { createGateway } from "../gateway/index.js"; +import { closeEvents, listRuns, listTraceEvents } from "../events/index.js"; +import { createGateway, MIN_VIEWER_PASSWORD_CHARS } from "../gateway/index.js"; const port = Number(process.env.PORT ?? 3000); const webhookSecret = requireEnv("GITHUB_WEBHOOK_SECRET"); @@ -10,8 +10,15 @@ const workflowSlug = requireEnv("RENDER_WORKFLOW_SLUG"); // Reads RENDER_API_KEY and throws without it. const render = new Render(); +const viewerPassword = process.env.FACTORY_VIEWER_PASSWORD?.trim() ?? ""; +if (viewerPassword && viewerPassword.length < MIN_VIEWER_PASSWORD_CHARS) { + console.warn(`FACTORY_VIEWER_PASSWORD is shorter than ${MIN_VIEWER_PASSWORD_CHARS} characters, so the run viewer is off`); +} +const viewer = viewerPassword.length >= MIN_VIEWER_PASSWORD_CHARS ? { password: viewerPassword, listRuns, listTraceEvents } : undefined; + const app = createGateway({ webhookSecret, + viewer, async startTask(task, input, idempotencyKey) { const run = await render.workflows.startTask(`${workflowSlug}/${task}`, [input], { idempotencyKey }); return { id: run.taskRunId }; @@ -19,7 +26,7 @@ const app = createGateway({ }); const server = serve({ fetch: app.fetch, hostname: "0.0.0.0", port }); -console.log(`gateway listening on 0.0.0.0:${port}`); +console.log(`gateway listening on 0.0.0.0:${port}${viewer ? "; run viewer at /runs" : ""}`); for (const signal of ["SIGINT", "SIGTERM"] as const) { process.once(signal, () => { diff --git a/app/events/index.ts b/app/events/index.ts index 4c87511..2ede6a5 100644 --- a/app/events/index.ts +++ b/app/events/index.ts @@ -58,6 +58,93 @@ export async function emit(event: FactoryEvent): Promise { } } +/** One task run as the viewer lists it: its `run.started` event, how it finished, and what its agents cost. */ +export interface RunSummary { + /** The `run.started` event's id: the pagination cursor. */ + id: number; + traceId: string; + runId: string | null; + repo: string | null; + subject: string | null; + task: string | null; + actor: string | null; + startedAt: string; + /** Null while running, or when the run was killed before anything recorded how it ended. */ + finishedAt: string | null; + state: string | null; + outcome: string | null; + costUsd: number | null; + agentRuns: number; +} + +export interface TraceEvent { + id: number; + at: string; + type: string; + runId: string | null; + subject: string | null; + payload: Record; +} + +/** Task runs, newest first: up to `limit` whose `run.started` id is below `before`. Null without DATABASE_URL. */ +export async function listRuns(opts: { before?: number; limit: number }): Promise { + const p = db(); + if (!p) return null; + const { rows } = await p.query( + `select s.id::text, s.trace_id, s.run_id, s.repo, s.subject, s.created_at as started_at, + s.payload->>'stage' as task, s.payload->>'actor' as actor, + f.created_at as finished_at, f.payload->>'state' as state, f.payload->>'outcome' as outcome, + a.cost_usd::text, a.agent_runs::int + from events s + left join lateral ( + select created_at, payload from events + where trace_id = s.trace_id and run_id is not distinct from s.run_id and type = 'run.finished' + order by id desc limit 1 + ) f on true + left join lateral ( + select sum((payload->>'costUsd')::numeric) as cost_usd, count(*) as agent_runs from events + where trace_id = s.trace_id and type = 'agent.finished' + ) a on true + where s.type = 'run.started' and ($1::bigint is null or s.id < $1) + order by s.id desc + limit $2`, + [opts.before ?? null, opts.limit], + ); + return rows.map((r) => ({ + id: Number(r.id), + traceId: r.trace_id, + runId: r.run_id, + repo: r.repo, + subject: r.subject, + task: r.task, + actor: r.actor, + startedAt: r.started_at.toISOString(), + finishedAt: r.finished_at?.toISOString() ?? null, + state: r.state, + outcome: r.outcome, + costUsd: r.cost_usd === null ? null : Number(r.cost_usd), + agentRuns: r.agent_runs, + })); +} + +/** Every event of one trace, oldest first, up to `limit`. Null without DATABASE_URL. */ +export async function listTraceEvents(traceId: string, limit = 1000): Promise { + const p = db(); + if (!p) return null; + const { rows } = await p.query("select id::text, created_at, type, run_id, subject, payload from events where trace_id = $1 order by id limit $2", [ + traceId, + limit, + ]); + return rows.map((r) => ({ + id: Number(r.id), + at: r.created_at.toISOString(), + type: r.type, + runId: r.run_id, + subject: r.subject, + payload: r.payload, + })); +} + /** Ends the pool so scripts can exit. */ export async function closeEvents(): Promise { const p = pool; diff --git a/app/gateway/index.ts b/app/gateway/index.ts index 073ba31..55bab0e 100644 --- a/app/gateway/index.ts +++ b/app/gateway/index.ts @@ -4,11 +4,16 @@ import { emit as defaultEmit } from "../events/index.js"; import { readBody } from "./body.js"; import { route } from "./routes.js"; import { verifySignature } from "./signature.js"; +import { mountViewer, type ViewerDeps } from "./viewer/index.js"; + +export { MIN_VIEWER_PASSWORD_CHARS, type ViewerDeps } from "./viewer/index.js"; export interface GatewayDeps { startTask(task: TaskName, input: object, idempotencyKey: string): Promise<{ id: string }>; emit?: typeof defaultEmit; webhookSecret: string; + /** Serves the run viewer at /runs when set. */ + viewer?: ViewerDeps; } export function createGateway(deps: GatewayDeps): Hono { @@ -17,6 +22,7 @@ export function createGateway(deps: GatewayDeps): Hono { app.get("/health", (c) => c.json({ status: "ok" })); app.get("/ready", (c) => c.json({ status: "ready" })); + if (deps.viewer) mountViewer(app, deps.viewer); app.post("/webhooks/github", async (c) => { const event = c.req.header("x-github-event") ?? ""; diff --git a/app/gateway/viewer/app.css b/app/gateway/viewer/app.css new file mode 100644 index 0000000..f8de986 --- /dev/null +++ b/app/gateway/viewer/app.css @@ -0,0 +1,372 @@ +/* Render brand foundations: semantic tokens, square corners, hairlines, mono for metadata, purple only for links and the primary action. */ + +:root { + color-scheme: light dark; + --bg: #ffffff; + --bg-secondary: #e3e3e3; + --border: #e3e3e3; + --text: #0d0d0d; + --text-secondary: #4d4d4d; + --text-faint: #6b6b6b; + --text-overline: #6b6b6b; + --link: #8a05ff; + --link-hover: #48008c; + --accent: #8a05ff; + --accent-strong: #48008c; + --row-hover: rgba(0, 0, 0, 0.02); + --success: #006d4c; + --error: #e23642; + --code-bg: #272727; + --code-text: #f0f0f0; + --font-brand: "Roobert", "Manrope", ui-sans-serif, system-ui, sans-serif; + --font-default: "PP Neue Montreal", "Manrope", ui-sans-serif, system-ui, sans-serif; + --font-mono: "PP Neue Montreal Mono", "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --ease: cubic-bezier(0.9, 0.1, 0.1, 0.9); + --margin: 32px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d0d0d; + --bg-secondary: #141414; + --border: #272727; + --text: #ffffff; + --text-secondary: #c7c7c7; + --text-faint: #b3b3b3; + --text-overline: #b3b3b3; + --link: #d1b8ff; + --link-hover: #e7dbff; + --accent: #8a05ff; + --accent-strong: #c29eff; + --row-hover: rgba(255, 255, 255, 0.03); + --success: #00db7c; + --error: #f0989e; + --code-bg: #141414; + } +} + +@media (max-width: 1023px) { + :root { + --margin: 24px; + } +} + +@media (max-width: 767px) { + :root { + --margin: 16px; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 400 14px / 20px var(--font-default); + letter-spacing: 0.01em; +} + +/* Header */ + +header { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg); + border-bottom: 1px solid var(--border); +} + +.bar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + max-width: 1440px; + margin: 0 auto; + padding: 16px var(--margin); +} + +.title { + display: flex; + flex-direction: column; + gap: 4px; +} + +.overline { + margin: 0; + color: var(--text-overline); + font: 500 11px / 14px var(--font-mono); + letter-spacing: 0.025em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font: 400 24px / 28px var(--font-brand); + letter-spacing: -0.01em; +} + +main { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + max-width: 1440px; + margin: 0 auto; + padding: 24px var(--margin) 48px; +} + +.table-scroll { + align-self: stretch; + overflow-x: auto; +} + +#notice { + margin: 0; + color: var(--text-faint); +} + +/* Buttons: primary is neutral at rest and purple on hover; secondary is an outline. Hover swaps instantly. */ + +button { + font: 400 14px / 20px var(--font-default); + letter-spacing: 0.01em; + cursor: pointer; +} + +.primary, +.secondary { + min-height: 36px; + padding: 8px 16px; + border-radius: 0; +} + +.primary { + color: var(--bg); + background: var(--text); + border: 1px solid var(--text); +} + +.primary:hover { + color: #ffffff; + background: var(--accent); + border-color: var(--accent); +} + +.secondary { + color: var(--text); + background: transparent; + border: 1px solid var(--text); +} + +.secondary:hover { + color: var(--accent-strong); + border-color: var(--accent-strong); +} + +button:focus-visible, +a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +@media (max-width: 767px) { + .primary, + .secondary { + min-height: 44px; + } +} + +/* Links: the signature underline wipes in left to right. */ + +a { + color: var(--link); + text-decoration: none; + background: linear-gradient(currentColor, currentColor) left bottom / 0 1px no-repeat; + transition: background-size 200ms var(--ease); +} + +a:hover { + color: var(--link-hover); + background-size: 100% 1px; +} + +@media (prefers-reduced-motion: reduce) { + a { + transition: none; + } + + a:hover { + background: none; + text-decoration: underline; + } +} + +/* Tables */ + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 10px 12px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--border); +} + +th { + color: var(--text-faint); + font: 500 11px / 14px var(--font-mono); + letter-spacing: 0.025em; + text-transform: uppercase; + white-space: nowrap; +} + +.num { + text-align: right; + white-space: nowrap; +} + +.mono, +.num, +td.started { + font-family: var(--font-mono); + font-size: 12px; + font-variant-numeric: tabular-nums; + letter-spacing: 0; +} + +.faint { + color: var(--text-faint); +} + +tr.run { + cursor: pointer; +} + +tr.run > td { + white-space: nowrap; +} + +tr.run:hover, +tr.run.open { + background: var(--row-hover); +} + +/* The start time doubles as the expand control; the chevron is a single thin stroke. */ + +button.expand { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0; + color: inherit; + font: inherit; + letter-spacing: inherit; + background: none; + border: 0; + border-radius: 0; + white-space: nowrap; +} + +button.expand::before, +.payload summary::before { + content: ""; + width: 6px; + height: 6px; + border-right: 1.5px solid var(--text-faint); + border-bottom: 1.5px solid var(--text-faint); + transform: rotate(-45deg); +} + +button.expand[aria-expanded="true"]::before, +.payload details[open] > summary::before { + transform: rotate(45deg) translate(-2px, -2px); +} + +/* Status: green for success, red for failure, neutral for everything still open to interpretation. */ + +.status { + display: inline-block; + padding: 1px 8px; + color: var(--text-faint); + font: 500 11px / 16px var(--font-mono); + letter-spacing: 0.025em; + text-transform: uppercase; + border: 1px solid var(--border); + border-radius: 0; +} + +.status.done { + color: var(--success); + border-color: currentColor; +} + +.status.failed { + color: var(--error); + border-color: currentColor; +} + +.status.running { + color: var(--text); +} + +/* Event detail */ + +tr.detail > td { + padding: 0 0 16px 32px; + background: var(--row-hover); +} + +table.events th, +table.events td { + padding: 8px 12px; +} + +table.events tr:last-child > td { + border-bottom: 0; +} + +.payload { + width: 100%; + color: var(--text-secondary); + font: 400 12px / 18px var(--font-mono); + word-break: break-word; +} + +.payload summary { + display: flex; + align-items: baseline; + gap: 8px; + list-style: none; + cursor: pointer; +} + +.payload summary::-webkit-details-marker { + display: none; +} + +.payload summary::before { + flex: none; + transform: rotate(-45deg) translateY(-2px); +} + +table.events td.mono { + white-space: nowrap; +} + +.payload pre { + margin: 8px 0 0; + padding: 12px 16px; + overflow-x: auto; + color: var(--code-text); + background: var(--code-bg); + font: 400 12px / 18px var(--font-mono); + white-space: pre-wrap; +} diff --git a/app/gateway/viewer/app.js b/app/gateway/viewer/app.js new file mode 100644 index 0000000..e0a97ba --- /dev/null +++ b/app/gateway/viewer/app.js @@ -0,0 +1,185 @@ +// The run viewer: lists task runs from /api/runs and expands a run into its events. No dependencies, no build step. +// Every value from the API is set with textContent, never parsed as HTML. + +const PAGE_SIZE = 50; +/** A run with no recorded finish this long after it started is past every task's timeout (the longest is 4 hours). */ +const STALE_MS = 5 * 60 * 60 * 1000; +const COLUMNS = 8; +const PAYLOAD_PREVIEW_CHARS = 160; + +const tbody = document.querySelector("#runs tbody"); +const older = document.getElementById("older"); +const notice = document.getElementById("notice"); +const timeFormat = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" }); + +let cursor = null; +let loading = false; + +/** Resolves `path` against the origin, not the page URL: fetch refuses URLs carrying user:password@ credentials. */ +async function getJson(path) { + const response = await fetch(new URL(path, window.location.origin), { headers: { accept: "application/json" } }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || `${response.status} ${response.statusText}`); + return body; +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined && text !== null) node.textContent = String(text); + if (className) node.className = className; + return node; +} + +function showNotice(text) { + notice.textContent = text; + notice.hidden = !text; +} + +function formatDuration(ms) { + if (!Number.isFinite(ms) || ms < 0) return ""; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +function stateOf(run) { + if (run.state) return run.state; + return Date.now() - Date.parse(run.startedAt) > STALE_MS ? "unknown" : "running"; +} + +/** A link to the issue or PR on GitHub when the repo and subject look like "owner/name" and "issue#12" or "pr#34". */ +function subjectCell(run) { + const td = el("td"); + const match = /^(issue|pr)#(\d+)$/.exec(run.subject ?? ""); + if (!match || !/^[\w.-]+\/[\w.-]+$/.test(run.repo ?? "")) { + td.textContent = [run.repo, run.subject].filter(Boolean).join(" "); + return td; + } + const link = el("a", `${run.repo.split("/")[1]} ${run.subject}`); + link.title = `${run.repo} ${run.subject}`; + link.href = `https://github.com/${run.repo}/${match[1] === "pr" ? "pull" : "issues"}/${match[2]}`; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.addEventListener("click", (event) => event.stopPropagation()); + td.append(link); + return td; +} + +function statusCell(state) { + const td = el("td"); + td.append(el("span", state, `status ${state}`)); + return td; +} + +function runRow(run) { + const tr = el("tr", undefined, "run"); + const expand = el("button", timeFormat.format(new Date(run.startedAt)), "expand"); + expand.type = "button"; + expand.title = `${run.startedAt}: show this run's events`; + expand.setAttribute("aria-expanded", "false"); + const started = el("td", undefined, "started"); + started.append(expand); + const state = stateOf(run); + const end = run.finishedAt ? Date.parse(run.finishedAt) : state === "running" ? Date.now() : Number.NaN; + tr.append( + started, + el("td", run.task), + subjectCell(run), + el("td", run.actor), + statusCell(state), + el("td", run.outcome, "mono"), + el("td", formatDuration(end - Date.parse(run.startedAt)), "num"), + el("td", run.costUsd === null ? "" : `$${run.costUsd.toFixed(4)}`, "num"), + ); + expand.addEventListener("click", (event) => { + event.stopPropagation(); + toggleEvents(tr, expand, run); + }); + tr.addEventListener("click", () => toggleEvents(tr, expand, run)); + return tr; +} + +async function toggleEvents(tr, expand, run) { + const next = tr.nextElementSibling; + if (next?.classList.contains("detail")) { + next.remove(); + tr.classList.remove("open"); + expand.setAttribute("aria-expanded", "false"); + return; + } + tr.classList.add("open"); + expand.setAttribute("aria-expanded", "true"); + const detail = el("tr", undefined, "detail"); + const td = el("td", "Loading events…"); + td.colSpan = COLUMNS; + detail.append(td); + tr.after(detail); + try { + const { events } = await getJson(`/api/runs/${encodeURIComponent(run.traceId)}/events`); + td.replaceChildren(eventsTable(events)); + } catch (error) { + td.textContent = `Could not load events: ${error.message}`; + } +} + +function eventsTable(events) { + if (events.length === 0) return el("p", "No events recorded for this run."); + const table = el("table", undefined, "events"); + const head = el("tr"); + for (const name of ["Elapsed", "Type", "Run", "Payload"]) head.append(el("th", name)); + const thead = el("thead"); + thead.append(head); + const body = el("tbody"); + table.append(thead); + const start = Date.parse(events[0].at); + for (const event of events) { + const row = el("tr"); + const offset = el("td", `+${formatDuration(Date.parse(event.at) - start) || "0s"}`, "num"); + offset.title = event.at; + row.append(offset, el("td", event.type, "mono"), el("td", event.runId, "mono"), payloadCell(event.payload)); + body.append(row); + } + table.append(body); + return table; +} + +/** A one-line preview; the full payload opens in place. */ +function payloadCell(payload) { + const td = el("td", undefined, "payload"); + const json = JSON.stringify(payload ?? {}); + if (json.length <= PAYLOAD_PREVIEW_CHARS) { + td.append(el("code", json)); + return td; + } + const details = el("details"); + details.append(el("summary", `${json.slice(0, PAYLOAD_PREVIEW_CHARS)}…`), el("pre", JSON.stringify(payload, null, 2))); + td.append(details); + return td; +} + +async function loadRuns({ reset }) { + if (loading) return; + loading = true; + try { + const params = new URLSearchParams({ limit: String(PAGE_SIZE) }); + if (!reset && cursor !== null) params.set("before", String(cursor)); + const { runs, next } = await getJson(`/api/runs?${params}`); + const rows = document.createDocumentFragment(); + for (const run of runs) rows.append(runRow(run)); + if (reset) tbody.replaceChildren(rows); + else tbody.append(rows); + cursor = next; + older.hidden = next === null; + showNotice(tbody.children.length === 0 ? "No runs recorded yet." : ""); + } catch (error) { + showNotice(error.message); + } finally { + loading = false; + } +} + +document.getElementById("refresh").addEventListener("click", () => loadRuns({ reset: true })); +older.addEventListener("click", () => loadRuns({ reset: false })); +loadRuns({ reset: true }); diff --git a/app/gateway/viewer/index.ts b/app/gateway/viewer/index.ts new file mode 100644 index 0000000..701b619 --- /dev/null +++ b/app/gateway/viewer/index.ts @@ -0,0 +1,87 @@ +import { readFileSync } from "node:fs"; +import type { Context, Hono } from "hono"; +import { basicAuth } from "hono/basic-auth"; +import type { RunSummary, TraceEvent } from "../../events/index.js"; + +export interface ViewerDeps { + /** HTTP Basic auth password for the user `factory`. */ + password: string; + listRuns(opts: { before?: number; limit: number }): Promise; + listTraceEvents(traceId: string): Promise; +} + +/** The viewer stays off unless its password is at least this long; render.yaml generates one. */ +export const MIN_VIEWER_PASSWORD_CHARS = 16; + +const asset = (name: string) => readFileSync(new URL(name, import.meta.url), "utf8"); +const PAGE = asset("./page.html"); +const SCRIPT = asset("./app.js"); +const STYLE = asset("./app.css"); + +/** Telemetry includes repository names, issue subjects, and error snippets: never cache it, frame it, or leak it. */ +const HEADERS: Record = { + "cache-control": "no-store", + "content-security-policy": + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", +}; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; +const TRACE_ID = /^[\w.-]{1,128}$/; +const TELEMETRY_OFF = { error: "telemetry is off: set DATABASE_URL on the gateway" }; + +/** The run viewer: a page at /runs and its JSON API under /api/runs, behind HTTP Basic auth. */ +export function mountViewer(app: Hono, deps: ViewerDeps): void { + const auth = basicAuth({ username: "factory", password: deps.password, realm: "software-factory" }); + for (const path of ["/runs", "/runs/*", "/api/runs", "/api/runs/*"]) { + app.use(path, auth, async (c, next) => { + await next(); + for (const [name, value] of Object.entries(HEADERS)) c.header(name, value); + }); + } + + app.get("/runs", (c) => c.html(PAGE)); + app.get("/runs/app.js", (c) => c.body(SCRIPT, 200, { "content-type": "text/javascript; charset=utf-8" })); + app.get("/runs/app.css", (c) => c.body(STYLE, 200, { "content-type": "text/css; charset=utf-8" })); + + app.get("/api/runs", async (c) => { + const { limit: rawLimit, before } = c.req.query(); + const limit = rawLimit === undefined ? DEFAULT_LIMIT : positiveInt(rawLimit); + const cursor = before === undefined ? undefined : positiveInt(before); + if (limit === null || limit > MAX_LIMIT || cursor === null) { + return c.json({ error: `limit must be 1-${MAX_LIMIT} and before a positive integer` }, 400); + } + return read( + c, + () => deps.listRuns({ before: cursor, limit }), + (runs) => ({ runs, next: runs.length === limit ? (runs.at(-1)?.id ?? null) : null }), + ); + }); + + app.get("/api/runs/:traceId/events", async (c) => { + const traceId = c.req.param("traceId"); + if (!TRACE_ID.test(traceId)) return c.json({ error: "invalid trace id" }, 400); + return read( + c, + () => deps.listTraceEvents(traceId), + (events) => ({ events }), + ); + }); +} + +async function read(c: Context, query: () => Promise, shape: (rows: T) => object): Promise { + try { + const rows = await query(); + return rows === null ? c.json(TELEMETRY_OFF, 503) : c.json(shape(rows)); + } catch (error) { + console.error(JSON.stringify({ level: "warn", msg: "viewer query failed", error: error instanceof Error ? error.message : String(error) })); + return c.json({ error: "could not read telemetry" }, 502); + } +} + +function positiveInt(value: string | undefined): number | null { + if (value === undefined || !/^\d{1,15}$/.test(value)) return null; + const n = Number(value); + return n > 0 ? n : null; +} diff --git a/app/gateway/viewer/page.html b/app/gateway/viewer/page.html new file mode 100644 index 0000000..747a6ff --- /dev/null +++ b/app/gateway/viewer/page.html @@ -0,0 +1,42 @@ + + + + + + Runs · Software Factory + + + + +
+
+
+

Software Factory

+

Runs

+
+ +
+
+
+ +
+ + + + + + + + + + + + + + +
StartedTaskIssue or PRActorStateOutcomeDurationAgent cost
+
+ +
+ + diff --git a/app/sql/0002_viewer_indexes.sql b/app/sql/0002_viewer_indexes.sql new file mode 100644 index 0000000..c921640 --- /dev/null +++ b/app/sql/0002_viewer_indexes.sql @@ -0,0 +1,6 @@ +-- The run viewer lists runs newest first and loads one trace's events. +create index if not exists events_type_id on events (type, id desc); +create index if not exists events_trace_id on events (trace_id, id); + +-- Claims moved to the status comment; nothing queries run.claimed rows any more. +drop index if exists events_current_run_claim; diff --git a/app/workflows/cancel.ts b/app/workflows/cancel.ts index b9c0104..e606f0d 100644 --- a/app/workflows/cancel.ts +++ b/app/workflows/cancel.ts @@ -36,7 +36,8 @@ export async function cancelRun( return { cancelled: false, reason: "permission_denied" }; } const current = await readStatus(gh, input.number); - if (!current || !(await stopRun(deps.runs ?? workflowRuns(), current.data))) { + const stop = { repo: repo.fullName, subject: event.subject, by: runId, outcome: "cancelled" } as const; + if (!current || !(await stopRun(deps.runs ?? workflowRuns(), current.data, stop))) { await gh.createComment(input.number, `@${input.actor} no factory run is in progress here.`); return { cancelled: false, reason: "nothing_running" }; } diff --git a/docs/architecture.md b/docs/architecture.md index 65064b6..a44b89c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -144,7 +144,9 @@ and pull request diffs as untrusted. ### Gateway The gateway verifies the webhook signature before parsing JSON. It holds no -GitHub credential and runs no model or repository code. +GitHub credential and runs no model or repository code. When +`FACTORY_VIEWER_PASSWORD` is set, it also serves a read-only run viewer over +the telemetry, behind HTTP Basic auth. ### Workflows host diff --git a/docs/configuration.md b/docs/configuration.md index 0ac4585..59313d4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -119,6 +119,7 @@ database and workflow references are wired by the Blueprint. | `GITHUB_WEBHOOK_SECRET` | Verify GitHub webhook signatures | | `RENDER_API_KEY` | Start Workflows tasks | | `RENDER_WORKFLOW_SLUG` | Address the deployed Workflows service | +| `FACTORY_VIEWER_PASSWORD` | Optional: turns on the run viewer at `/runs` (user `factory`, at least 16 characters) | | `PORT` | HTTP port; defaults to `3000` | ### Workflows diff --git a/docs/operations.md b/docs/operations.md index efd3827..abf18b9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -10,8 +10,21 @@ factory's status comment shows progress, the current run ID, and final links. Find that run under `factory-workflows` in the Render Dashboard to inspect logs, inputs, and its result. -When `DATABASE_URL` is set, events are also appended to Postgres. To inspect -one run: +When `DATABASE_URL` is set, events are also appended to Postgres. + +### Run viewer + +The gateway serves a run viewer at `https:///runs`. Sign in as +`factory` with the gateway's `FACTORY_VIEWER_PASSWORD` (the Blueprint +generates it; find it under the gateway's Environment settings). It lists +runs newest first; click one to see its events. Remove the variable to turn +the viewer off. + +### Query Postgres directly + +```bash +render psql +``` ```sql select created_at, type, subject, payload diff --git a/render.yaml b/render.yaml index 6a33411..2a1b8e4 100644 --- a/render.yaml +++ b/render.yaml @@ -33,6 +33,9 @@ services: type: workflow name: factory-workflows property: slug + # Password for the run viewer at /runs (user "factory"). Delete it to turn the viewer off. + - key: FACTORY_VIEWER_PASSWORD + generateValue: true - type: workflow name: factory-workflows diff --git a/scripts/lib/requirements.ts b/scripts/lib/requirements.ts index 96b4a73..42dc1ae 100644 --- a/scripts/lib/requirements.ts +++ b/scripts/lib/requirements.ts @@ -61,7 +61,13 @@ export const SERVICE_ENV: Readonly> = { }; /** Values doctor must never print. */ -export const SECRET_ENV: readonly string[] = ["DATABASE_URL", "GITHUB_WEBHOOK_SECRET", "RENDER_API_KEY", "GITHUB_APP_PRIVATE_KEY"]; +export const SECRET_ENV: readonly string[] = [ + "DATABASE_URL", + "GITHUB_WEBHOOK_SECRET", + "RENDER_API_KEY", + "GITHUB_APP_PRIVATE_KEY", + "FACTORY_VIEWER_PASSWORD", +]; const LEVELS = ["none", "read", "write", "admin"]; diff --git a/tests/cancel.test.ts b/tests/cancel.test.ts index 29f2617..aaacac0 100644 --- a/tests/cancel.test.ts +++ b/tests/cancel.test.ts @@ -83,7 +83,12 @@ describe("cancel task", () => { expect(comments[0]?.body).toContain("**Spec-to-PR** was cancelled: cancelled by @alice."); expect(parseMarker(comments[0]?.body ?? "", "status")).toMatchObject({ runId: "trn-1", state: "cancelled" }); expect(labels.has(WORKING_LABEL)).toBe(false); - expect(events.map((e) => e.type)).toEqual(["run.started", "run.finished"]); + expect(events.map((e) => [e.type, e.runId])).toEqual([ + ["run.started", "trn-cancel"], + ["run.finished", "trn-1"], + ["run.finished", "trn-cancel"], + ]); + expect(events[1]?.payload).toMatchObject({ state: "cancelled", outcome: "cancelled", stoppedBy: "trn-cancel" }); expect(events[0]).toMatchObject({ traceId: "trn-cancel", runId: "trn-cancel", repo: REPO }); }); diff --git a/tests/claims.test.ts b/tests/claims.test.ts index 24c8cb2..634fdf1 100644 --- a/tests/claims.test.ts +++ b/tests/claims.test.ts @@ -96,9 +96,18 @@ describe("takeOver", () => { const gh = new FakeGitHub(); const existing = gh.addStatus("trn-old", "running", "sbx-old"); const runs = new FakeRuns({ "trn-old": "running" }); - const result = await takeOver({ gh, subject, task: "triage", title: "Triage", runId: "trn-new", runs }); + const result = await takeOver({ gh, repo: REPO.fullName, subject, task: "triage", title: "Triage", runId: "trn-new", runs }); expect(runs.cancelled).toEqual(["trn-old"]); expect(runs.terminated).toEqual(["sbx-old"]); + // The stopped run is killed before it can record how it ended, so the takeover records it. + expect(events).toContainEqual({ + traceId: "trn-old", + runId: "trn-old", + repo: REPO.fullName, + subject: "issue#12", + type: "run.finished", + payload: { stage: "triage", state: "cancelled", outcome: "superseded", stoppedBy: "trn-new" }, + }); expect(result).toEqual({ commentId: existing.id, previousRunId: "trn-old", cancelledPrevious: true }); expect(gh.comments).toHaveLength(1); expect(parseMarker(gh.comments[0]?.body ?? "", "status")).toMatchObject({ runId: "trn-new", state: "running", stage: "triage" }); @@ -167,6 +176,7 @@ describe("withClaim", () => { subject: "issue#12", payload: { stage: "triage", actor: "maintainer" }, }, + { type: "run.finished", traceId: "trn-prev", payload: { state: "cancelled", outcome: "superseded", stoppedBy: "trn-run" } }, { type: "run.finished", traceId: "trn-root", payload: { state: "done", outcome: "asked-questions" } }, ]); }); diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index 12e46c0..e4f6441 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -100,3 +100,59 @@ describe("gateway", () => { expect(event.payload).toMatchObject({ outcome: "start failed", reason: "render down" }); }); }); + +describe("run viewer", () => { + const PASSWORD = "viewer-password-0123456789"; + const auth = (password = PASSWORD) => ({ authorization: `Basic ${Buffer.from(`factory:${password}`).toString("base64")}` }); + const run = (id: number) => ({ + id, + traceId: `trn-${id}`, + runId: `trn-${id}`, + repo: REPO, + subject: "issue#12", + task: "triage", + actor: "maintainer", + startedAt: "2026-10-24T20:18:54.000Z", + finishedAt: null, + state: null, + outcome: null, + costUsd: null, + agentRuns: 0, + }); + const viewerApp = (viewer: Partial> = {}) => + createGateway({ + startTask, + emit, + webhookSecret: SECRET, + viewer: { password: PASSWORD, listRuns: vi.fn(async () => []), listTraceEvents: vi.fn(async () => []), ...viewer }, + }); + + it("is off unless configured, and requires the password for the page and the API", async () => { + expect((await app.request("/runs")).status).toBe(404); + const viewer = viewerApp(); + for (const path of ["/runs", "/runs/app.js", "/api/runs", "/api/runs/trn-1/events"]) { + expect((await viewer.request(path)).status).toBe(401); + expect((await viewer.request(path, { headers: auth("wrong-password-0123456789") })).status).toBe(401); + } + const page = await viewer.request("/runs", { headers: auth() }); + expect(page.status).toBe(200); + expect(await page.text()).toContain(' - - -
-
-
-

Software Factory

-

Runs

-
- -
-
-
- -
- - - - - - - - - - - - - - -
StartedTaskIssue or PRActorStateOutcomeDurationAgent cost
-
- -
- - diff --git a/app/sql/0001_events.sql b/app/sql/0001_events.sql deleted file mode 100644 index 39b3a3a..0000000 --- a/app/sql/0001_events.sql +++ /dev/null @@ -1,13 +0,0 @@ -create table events ( - id bigserial primary key, - trace_id text not null, - run_id text, - repo text, - subject text, - type text not null, - payload jsonb not null default '{}', - created_at timestamptz not null default now() -); -create index events_current_run_claim - on events (repo, subject, id desc) - where type = 'run.claimed'; diff --git a/app/sql/0002_viewer_indexes.sql b/app/sql/0002_viewer_indexes.sql deleted file mode 100644 index c921640..0000000 --- a/app/sql/0002_viewer_indexes.sql +++ /dev/null @@ -1,6 +0,0 @@ --- The run viewer lists runs newest first and loads one trace's events. -create index if not exists events_type_id on events (type, id desc); -create index if not exists events_trace_id on events (trace_id, id); - --- Claims moved to the status comment; nothing queries run.claimed rows any more. -drop index if exists events_current_run_claim; diff --git a/app/workflows/cancel.ts b/app/workflows/cancel.ts index e606f0d..b5cd8e1 100644 --- a/app/workflows/cancel.ts +++ b/app/workflows/cancel.ts @@ -25,7 +25,6 @@ export async function cancelRun( const gh = deps.gh ?? githubFor(repo); const { traceId, runId } = traceIds(ctx); const event = { traceId, runId, repo: repo.fullName, subject: `${input.kind}#${input.number}` }; - await emit({ ...event, type: "run.started", payload: { stage: TASKS.cancel, actor: input.actor } }); const result = await (async (): Promise => { if (!(await checkPermission(gh, input.actor))) { @@ -47,7 +46,7 @@ export async function cancelRun( return { cancelled: true, runId: cancelled }; })(); - await emit({ ...event, type: "run.finished", payload: { stage: TASKS.cancel, state: "done", ...result } }); + emit({ ...event, type: "run.finished", payload: { stage: TASKS.cancel, state: "done", ...result } }); return result; } diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index af1565f..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_DB: software_factory - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "55432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d software_factory"] - interval: 5s - timeout: 3s - retries: 10 diff --git a/docs/architecture.md b/docs/architecture.md index a44b89c..1a0d3fc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,8 +10,6 @@ decides what may be written to GitHub. flowchart LR GitHub -->|signed webhook| Gateway Gateway -->|start task| Workflows - Gateway -.->|delivery telemetry| Postgres - Workflows -.->|run telemetry| Postgres Workflows -->|restore and execute| Sandbox Sandbox -->|proposed result| Workflows Workflows -->|validated writes| GitHub @@ -19,13 +17,11 @@ flowchart LR Snapshot -.->|restore| Sandbox ``` -The Blueprint creates three persistent resources: +The Blueprint creates two services: - **Gateway** verifies GitHub webhooks and maps each delivery to one task. - **Workflows** runs the claiming tasks, agents, cancellation, snapshot refresh, and GitHub publishing. -- **Postgres** stores best-effort telemetry. It's optional: without - `DATABASE_URL`, events are dropped and nothing else changes. Each claiming task receives a fresh Sandbox restored from a repository snapshot. Render Workflows owns task state and timeouts. GitHub owns the human @@ -71,9 +67,9 @@ tests. | Current run for an issue or PR, and its Sandbox | App-authored status comment marker | | User-visible run status | The same status comment, and the working label | | Prepared repository filesystem | Render Sandbox snapshot | -| Delivery, run, agent, and cost history | Postgres events (optional) | +| Run and agent outcomes, usage, and cost | JSON lines in the service logs | -Nothing reads Postgres back to decide what a task does. +Telemetry is write-only: nothing reads it back to decide what a task does. ## Idempotency and competing runs @@ -144,9 +140,7 @@ and pull request diffs as untrusted. ### Gateway The gateway verifies the webhook signature before parsing JSON. It holds no -GitHub credential and runs no model or repository code. When -`FACTORY_VIEWER_PASSWORD` is set, it also serves a read-only run viewer over -the telemetry, behind HTTP Basic auth. +GitHub credential and runs no model or repository code. ### Workflows host @@ -178,7 +172,6 @@ Changes must preserve these properties: 7. End automation at a draft PR and COMMENT review. 8. Implement the exact specification that was approved. 9. Put a timeout and `finally` cleanup around every Sandbox. -10. Keep Postgres telemetry-only: no task behavior may depend on it. Tests pin these invariants and each built-in agent's tool authority. diff --git a/docs/configuration.md b/docs/configuration.md index 59313d4..2046b12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,25 +108,22 @@ budget. ## Environment variables -Every local variable appears in [`.env.example`](../.env.example). Deployed -database and workflow references are wired by the Blueprint. +Every local variable appears in [`.env.example`](../.env.example). The +Blueprint wires the gateway's reference to the Workflows service. ### Gateway | Variable | Purpose | |---|---| -| `DATABASE_URL` | Optional: migrations and delivery telemetry | | `GITHUB_WEBHOOK_SECRET` | Verify GitHub webhook signatures | | `RENDER_API_KEY` | Start Workflows tasks | | `RENDER_WORKFLOW_SLUG` | Address the deployed Workflows service | -| `FACTORY_VIEWER_PASSWORD` | Optional: turns on the run viewer at `/runs` (user `factory`, at least 16 characters) | | `PORT` | HTTP port; defaults to `3000` | ### Workflows | Variable | Purpose | |---|---| -| `DATABASE_URL` | Optional: run and agent telemetry | | `RENDER_API_KEY` | Manage Sandboxes, snapshots, and task cancellation | | `RENDER_WORKSPACE_ID` | Workspace that owns Sandboxes and snapshots | | `GITHUB_APP_ID` | Authenticate the GitHub App | diff --git a/docs/design-notes.md b/docs/design-notes.md index 918c6d8..10717a1 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -7,8 +7,8 @@ not required for deployment or operation. Render Workflows owns task execution. GitHub owns collaboration artifacts, including the status comment that says which run currently works on an issue -or pull request. Postgres only records telemetry, so the factory runs without -it. Every additional source of truth creates reconciliation work. +or pull request. There is no database; telemetry goes to logs. Every +additional source of truth creates reconciliation work. ## Make approval an explicit transition diff --git a/docs/operations.md b/docs/operations.md index abf18b9..3ffbd33 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -10,31 +10,19 @@ factory's status comment shows progress, the current run ID, and final links. Find that run under `factory-workflows` in the Render Dashboard to inspect logs, inputs, and its result. -When `DATABASE_URL` is set, events are also appended to Postgres. - -### Run viewer - -The gateway serves a run viewer at `https:///runs`. Sign in as -`factory` with the gateway's `FACTORY_VIEWER_PASSWORD` (the Blueprint -generates it; find it under the gateway's Environment settings). It lists -runs newest first; click one to see its events. Remove the variable to turn -the viewer off. - -### Query Postgres directly +Every task run ends with a `run.finished` JSON line, and every agent run with +an `agent.finished` line, in the `factory-workflows` logs. Search for them, or +send them to a log stream: ```bash -render psql -``` - -```sql -select created_at, type, subject, payload -from events -where trace_id = '' -order by id; +render logs -r --text agent.finished ``` -Telemetry includes task outcomes and agent model, turns, token usage, and -reported cost. Task state remains in Render Workflows; GitHub remains the +`run.finished` carries the task, state, outcome, and duration. `agent.finished` +carries the agent, model, turns, token usage, reported cost, and, when an agent +is stopped, why. A run stopped by a newer run or by `/factory cancel` gets its +`run.finished` line from whatever stopped it, because Render kills a cancelled +run before it can write one. Task state remains in Render Workflows; GitHub remains the source of truth for issues, specifications, pull requests, and reviews. ## Cancel or restart @@ -146,7 +134,7 @@ mismatch. - PR Reviewer executes the labeled PR's configured verification commands. - Sandboxes use allow-all egress. - Local filesystem writes on Render services are ephemeral; durable state - belongs in GitHub, Workflows, Postgres, or Sandbox snapshots. + belongs in GitHub, Workflows, or Sandbox snapshots. Review [Architecture](architecture.md#residual-risks) before using private code or untrusted pull requests. diff --git a/docs/quickstart.md b/docs/quickstart.md index ecd4718..16dc83f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -14,9 +14,8 @@ only you should take. - admin access to each target GitHub repository; and - the Node.js version in [`.node-version`](../.node-version). -The Blueprint creates paid Render resources: a Starter web service, a -Workflows service, and a Basic-256mb Postgres database. Sandbox and model -usage are charged separately. +The Blueprint creates paid Render resources: a Starter web service and a +Workflows service. Sandbox and model usage are charged separately. ## What the setup agent will do diff --git a/docs/runbooks/setup.md b/docs/runbooks/setup.md index 0a87883..27634f7 100644 --- a/docs/runbooks/setup.md +++ b/docs/runbooks/setup.md @@ -13,7 +13,7 @@ repository, and complete one Triage run. Setup is complete only when: -- `factory-gateway`, `factory-workflows`, and `factory-db` are live; +- `factory-gateway` and `factory-workflows` are live; - the GitHub App is installed on every configured repository; - the App webhook is active and points to the gateway; - the factory labels exist; @@ -239,9 +239,8 @@ The user confirms installation on every exact configured repository. Explain that [`render.yaml`](../../render.yaml) creates paid resources in Oregon: -- `factory-gateway`, a Starter web service; -- `factory-workflows`, a Workflows service; and -- `factory-db`, a Basic-256mb Postgres database. +- `factory-gateway`, a Starter web service; and +- `factory-workflows`, a Workflows service. The user should create the Blueprint from the configured fork and deployment branch. @@ -269,7 +268,7 @@ into chat. The Render workspace ID starts with `tea-`. ### Pass condition -All three resources are created, the gateway reports live, and the Workflows +Both services are created, the gateway reports live, and the Workflows service has deployed successfully. ### Recovery @@ -277,8 +276,6 @@ service has deployed successfully. - Blank `sync: false` field: set the missing value and redeploy. - Build failure: inspect the failing service's build log before changing configuration. -- Pre-deploy migration failure: verify the Blueprint's `DATABASE_URL` wiring - and database health. - Port or health failure: confirm the gateway binds to `0.0.0.0:$PORT` and serves `/ready`. @@ -362,10 +359,9 @@ Both commands exit successfully with every required check passing. ### Recovery -- Gateway failure: check webhook secret, Render API key, workflow slug, and - database connection. +- Gateway failure: check webhook secret, Render API key, and workflow slug. - Workflows failure: check Render workspace ID, Render API key, GitHub App - credentials, provider key, database, and registered tasks. + credentials, provider key, and registered tasks. - App failure: compare required permissions and events with Phase 4. - Label or installation failure: rerun `npm run setup:github` after fixing App access. diff --git a/package-lock.json b/package-lock.json index 4204768..0466971 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@renderinc/sdk": "^1.3.0", "esbuild": "^0.28.2", "hono": "^4.0.0", - "pg": "^8.23.0", "tar": "^7.5.22", "tsx": "^4.19.0", "typebox": "1.3.7", @@ -24,7 +23,6 @@ "devDependencies": { "@biomejs/biome": "^2.5.11", "@types/node": "^22.0.0", - "@types/pg": "^8.23.1", "typescript": "^5.5.0", "vitest": "^4.1.11" }, @@ -1805,18 +1803,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/pg": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", - "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -2812,95 +2798,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pg": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", - "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.14.0", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2950,45 +2847,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/protobufjs": { "version": "7.6.6", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", @@ -3104,15 +2962,6 @@ "node": ">=0.10.0" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/stackback": { "version": "0.0.2", "dev": true, @@ -3446,15 +3295,6 @@ } } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 0a62257..4755ba1 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,6 @@ "start:workflows": "tsx app/entrypoints/workflows.ts", "dev:gateway": "node --env-file=.env node_modules/.bin/tsx watch app/entrypoints/gateway.ts", "dev:workflows": "render workflows dev -- node --env-file=.env node_modules/.bin/tsx app/entrypoints/workflows.ts", - "dev:postgres": "docker compose up postgres", - "db:migrate": "node --env-file-if-exists=.env --import tsx scripts/migrate.ts", "setup:app": "node --env-file-if-exists=.env --import tsx scripts/setup-app.ts", "setup:github": "node --env-file-if-exists=.env --import tsx scripts/setup-github.ts", "doctor": "node --env-file-if-exists=.env --import tsx scripts/doctor.ts", @@ -33,7 +31,6 @@ "@renderinc/sdk": "^1.3.0", "esbuild": "^0.28.2", "hono": "^4.0.0", - "pg": "^8.23.0", "tar": "^7.5.22", "tsx": "^4.19.0", "typebox": "1.3.7", @@ -42,7 +39,6 @@ "devDependencies": { "@biomejs/biome": "^2.5.11", "@types/node": "^22.0.0", - "@types/pg": "^8.23.1", "typescript": "^5.5.0", "vitest": "^4.1.11" } diff --git a/render.yaml b/render.yaml index 2a1b8e4..5e2cd09 100644 --- a/render.yaml +++ b/render.yaml @@ -1,13 +1,8 @@ -# Software Factory: three resources. -# - factory-db: optional Postgres holding the append-only events table, used only for telemetry. -# - factory-gateway: verifies GitHub webhooks and starts the matching Workflows task. Migrations run before each deploy. -# - factory-workflows: runs every task (stages, agents, snapshot refresh, cancel). It has no `repo` field, so it +# Software Factory: two services. +# - factory-gateway: verifies GitHub webhooks and starts the matching Workflows task. +# - factory-workflows: runs every task (agents, snapshot refresh, cancel). It has no `repo` field, so it # deploys from the same repository as this Blueprint, including forks. - -databases: - - name: factory-db - region: oregon - plan: basic-256mb +# Run and agent telemetry is logged as JSON lines; find it in each service's logs or a log stream. services: - type: web @@ -17,13 +12,8 @@ services: plan: starter buildCommand: npm ci startCommand: npm run start:gateway - preDeployCommand: npm run db:migrate healthCheckPath: /ready envVars: - - key: DATABASE_URL - fromDatabase: - name: factory-db - property: connectionString - key: GITHUB_WEBHOOK_SECRET sync: false - key: RENDER_API_KEY @@ -33,9 +23,6 @@ services: type: workflow name: factory-workflows property: slug - # Password for the run viewer at /runs (user "factory"). Delete it to turn the viewer off. - - key: FACTORY_VIEWER_PASSWORD - generateValue: true - type: workflow name: factory-workflows @@ -44,10 +31,6 @@ services: buildCommand: npm ci startCommand: npm run start:workflows envVars: - - key: DATABASE_URL - fromDatabase: - name: factory-db - property: connectionString - key: RENDER_API_KEY sync: false - key: RENDER_WORKSPACE_ID diff --git a/scripts/doctor.ts b/scripts/doctor.ts index 4bec9da..ac51ef8 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -5,9 +5,6 @@ * * Checks the environment it runs in: locally that's .env; in a service's Render Shell it's that service's env. */ -import { readdir } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { config } from "../app/config/index.js"; import { runDoctor } from "./lib/doctor.js"; @@ -27,12 +24,9 @@ if (!services) { process.exit(2); } -const sqlDir = join(dirname(fileURLToPath(import.meta.url)), "..", "app", "sql"); -const migrations = (await readdir(sqlDir)).filter((name) => name.endsWith(".sql")).sort(); - const out = new Reporter(); try { - process.exitCode = await runDoctor({ env: process.env, services, repos: Object.keys(config.repos), models: config.models, migrations }, out); + process.exitCode = await runDoctor({ env: process.env, services, repos: Object.keys(config.repos), models: config.models }, out); } catch (error) { out.fail(`doctor crashed: ${errorMessage(error)}`); process.exitCode = 1; diff --git a/scripts/lib/doctor.ts b/scripts/lib/doctor.ts index ffed633..c449387 100644 --- a/scripts/lib/doctor.ts +++ b/scripts/lib/doctor.ts @@ -1,5 +1,4 @@ -/** doctor: read-only checks of one or both services' environment, the GitHub App, Postgres, and Workflows tasks. */ -import pg from "pg"; +/** doctor: read-only checks of one or both services' environment, the GitHub App, and Workflows tasks. */ import { TASKS } from "../../app/config/index.js"; import { PROVIDER_KEY_ENV } from "../../app/runner/keys.js"; import { appCredentialsFrom, type Env, GitHubApp, type ScriptDeps, splitRepo } from "./github.js"; @@ -8,31 +7,21 @@ import { errorMessage, type Reporter } from "./output.js"; import { LABELS, missingEvents, missingPermissions, SECRET_ENV, SERVICE_ENV, type Service, WEBHOOK_PATH } from "./requirements.js"; import { appSettingsUrl } from "./setup-github.js"; -export interface DbClient { - query(sql: string): Promise<{ rows: T[] }>; - end(): Promise; -} - export interface DoctorOptions extends ScriptDeps { services: readonly Service[]; repos: readonly string[]; /** config.models: "provider/model" per tier. */ models: Readonly>; - /** Migration filenames in app/sql. */ - migrations: readonly string[]; - connectDb?: (connectionString: string) => Promise; renderApiUrl?: string; } export async function runDoctor(opts: DoctorOptions, out: Reporter): Promise { const { env } = opts; out.addSecrets(...SECRET_ENV.map((name) => env[name]), ...modelKeys(opts.models).map((name) => env[name])); - out.addSecrets(databasePassword(env.DATABASE_URL)); checkEnv(opts, out); if (opts.services.includes("workflows")) checkModels(opts, out); await checkGitHub(opts, out); - await checkDatabase(opts, out); await checkTasks(opts, out); return out.summary(); } @@ -204,54 +193,6 @@ async function checkRepo(app: GitHubApp, info: { slug: string; html_url: string } } -async function checkDatabase(opts: DoctorOptions, out: Reporter): Promise { - out.heading("Postgres (optional telemetry)"); - const url = opts.env.DATABASE_URL?.trim(); - if (!url) { - out.skip("database checks: DATABASE_URL is not set, so telemetry is off"); - return; - } - let client: DbClient; - try { - client = await (opts.connectDb ?? connectPg)(url); - } catch (error) { - out.fail(`DATABASE_URL is unreachable: ${errorMessage(error)}`, "Use the internal URL on Render, the external one locally"); - return; - } - try { - out.pass("DATABASE_URL is reachable"); - const { rows } = await client.query<{ events: string | null; migrations: string | null }>( - "select to_regclass('public.events')::text as events, to_regclass('public.schema_migrations')::text as migrations", - ); - const tables = rows[0]; - if (tables?.events) out.pass("events table exists"); - else out.fail("events table is missing", "Run `npm run db:migrate` (the gateway's preDeployCommand)"); - if (!tables?.migrations) { - out.fail("schema_migrations table is missing", "Run `npm run db:migrate`"); - return; - } - const applied = new Set((await client.query<{ filename: string }>("select filename from schema_migrations")).rows.map((r) => r.filename)); - const pending = opts.migrations.filter((file) => !applied.has(file)); - if (pending.length) out.fail(`migrations not applied: ${pending.join(", ")}`, "Run `npm run db:migrate`"); - else out.pass(`all ${opts.migrations.length} migration(s) applied`); - } catch (error) { - out.fail(`database query failed: ${errorMessage(error)}`); - } finally { - await client.end().catch(() => {}); - } -} - -async function connectPg(connectionString: string): Promise { - const client = new pg.Client({ - connectionString, - application_name: "software-factory-doctor", - connectionTimeoutMillis: 5_000, - statement_timeout: 5_000, - }); - await client.connect(); - return client; -} - /** * The Render API's GET /v1/tasks, filtered by slug, for each task the gateway starts. The SDK's workflows client * has no list-tasks method, so this is a plain request. A non-auth API error skips rather than fails. @@ -305,8 +246,3 @@ function safeUrl(value: string): URL | null { return null; } } - -function databasePassword(url: string | undefined): string | undefined { - const parsed = url ? safeUrl(url.trim()) : null; - return parsed?.password ? decodeURIComponent(parsed.password) : undefined; -} diff --git a/scripts/lib/requirements.ts b/scripts/lib/requirements.ts index 42dc1ae..ea2607d 100644 --- a/scripts/lib/requirements.ts +++ b/scripts/lib/requirements.ts @@ -54,20 +54,14 @@ export const LABELS: readonly LabelSpec[] = [ export type Service = "gateway" | "workflows"; -/** The env vars each service requires, as render.yaml declares them. DATABASE_URL is optional: it only enables telemetry. */ +/** Each service's env vars, exactly as render.yaml declares them. */ export const SERVICE_ENV: Readonly> = { gateway: ["GITHUB_WEBHOOK_SECRET", "RENDER_API_KEY", "RENDER_WORKFLOW_SLUG"], workflows: ["RENDER_API_KEY", "RENDER_WORKSPACE_ID", "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY", "ANTHROPIC_API_KEY"], }; /** Values doctor must never print. */ -export const SECRET_ENV: readonly string[] = [ - "DATABASE_URL", - "GITHUB_WEBHOOK_SECRET", - "RENDER_API_KEY", - "GITHUB_APP_PRIVATE_KEY", - "FACTORY_VIEWER_PASSWORD", -]; +export const SECRET_ENV: readonly string[] = ["GITHUB_WEBHOOK_SECRET", "RENDER_API_KEY", "GITHUB_APP_PRIVATE_KEY"]; const LEVELS = ["none", "read", "write", "admin"]; diff --git a/scripts/migrate.ts b/scripts/migrate.ts deleted file mode 100644 index 5d08130..0000000 --- a/scripts/migrate.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { readdir, readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import pg from "pg"; - -const LOCK_ID = 0x736f66747761; // "softwa": stable advisory lock key for migrations - -const sqlDir = join(dirname(fileURLToPath(import.meta.url)), "..", "app", "sql"); -const files = (await readdir(sqlDir)).filter((name) => name.endsWith(".sql")).sort(); - -const connectionString = process.env.DATABASE_URL?.trim(); -if (!connectionString) { - console.log("DATABASE_URL is not set: telemetry is off and there is nothing to migrate"); - process.exit(0); -} - -// A dedicated client with no statement timeout: migrations may legitimately run long. -const client = new pg.Client({ - connectionString, - application_name: "software-factory-migrate", - connectionTimeoutMillis: 10_000, -}); -await client.connect(); - -try { - await client.query("select pg_advisory_lock($1)", [LOCK_ID]); - try { - await client.query(` - create table if not exists schema_migrations ( - filename text primary key, - applied_at timestamptz not null default now() - ) - `); - - const { rows } = await client.query<{ filename: string }>("select filename from schema_migrations"); - const applied = new Set(rows.map((row) => row.filename)); - - for (const file of files) { - if (applied.has(file)) continue; - console.log(`applying ${file}`); - const sql = await readFile(join(sqlDir, file), "utf8"); - await client.query("begin"); - try { - await client.query(sql); - await client.query("insert into schema_migrations (filename) values ($1)", [file]); - await client.query("commit"); - } catch (error) { - await client.query("rollback"); - throw error; - } - } - - console.log(`${files.length} migration file(s) checked, up to date`); - } finally { - await client.query("select pg_advisory_unlock($1)", [LOCK_ID]); - } -} finally { - await client.end(); -} diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 962c594..08bbf82 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -85,8 +85,8 @@ describe("runAgent in a sandbox", () => { for (const { command } of sandbox.execs) expect(command).not.toContain(HOST_KEY); expect(sandbox.uploads.get(`${runDir}/input.json`)).not.toContain(HOST_KEY); - expect(emitted()[0]).toMatchObject({ type: "agent.started", payload: { agentId: "reviewer" } }); - expect(emitted()[1]).toMatchObject({ + expect(emitted()).toHaveLength(1); + expect(emitted()[0]).toMatchObject({ type: "agent.finished", traceId: "trace-2", payload: { outcome: "ok", turns: 2, model: "anthropic/claude-opus-5", costUsd: run.costUsd, usage: run.usage }, diff --git a/tests/cancel.test.ts b/tests/cancel.test.ts index aaacac0..9286c5b 100644 --- a/tests/cancel.test.ts +++ b/tests/cancel.test.ts @@ -3,7 +3,7 @@ import type { FactoryEvent } from "../app/events/index.js"; const events: FactoryEvent[] = []; vi.mock("../app/events/index.js", () => ({ - emit: async (event: FactoryEvent) => { + emit: (event: FactoryEvent) => { events.push(event); }, })); @@ -84,12 +84,11 @@ describe("cancel task", () => { expect(parseMarker(comments[0]?.body ?? "", "status")).toMatchObject({ runId: "trn-1", state: "cancelled" }); expect(labels.has(WORKING_LABEL)).toBe(false); expect(events.map((e) => [e.type, e.runId])).toEqual([ - ["run.started", "trn-cancel"], ["run.finished", "trn-1"], ["run.finished", "trn-cancel"], ]); - expect(events[1]?.payload).toMatchObject({ state: "cancelled", outcome: "cancelled", stoppedBy: "trn-cancel" }); - expect(events[0]).toMatchObject({ traceId: "trn-cancel", runId: "trn-cancel", repo: REPO }); + expect(events[0]?.payload).toMatchObject({ state: "cancelled", outcome: "cancelled", stoppedBy: "trn-cancel" }); + expect(events[1]).toMatchObject({ traceId: "trn-cancel", runId: "trn-cancel", repo: REPO }); }); it("refuses an actor without permission and leaves the run alone", async () => { diff --git a/tests/claims.test.ts b/tests/claims.test.ts index 634fdf1..86296b7 100644 --- a/tests/claims.test.ts +++ b/tests/claims.test.ts @@ -3,7 +3,7 @@ import type { FactoryEvent } from "../app/events/index.js"; const events: FactoryEvent[] = []; vi.mock("../app/events/index.js", () => ({ - emit: async (event: FactoryEvent) => { + emit: (event: FactoryEvent) => { events.push(event); }, })); @@ -140,8 +140,7 @@ describe("withClaim", () => { expect(gh.comments[0]?.body).toContain("did not run: @reader needs `write` permission"); expect(parseMarker(gh.comments[0]?.body ?? "", "status")?.state).toBe("rejected"); expect(gh.labels.has(WORKING_LABEL)).toBe(false); - expect(events.map((e) => e.type)).toEqual(["run.started", "run.finished"]); - expect(events.at(-1)?.payload).toMatchObject({ state: "rejected" }); + expect(events).toMatchObject([{ type: "run.finished", traceId: "trn-root", runId: "trn-run", payload: { state: "rejected" } }]); }); it("runs the work and records events with the trace id and outcome", async () => { @@ -168,16 +167,15 @@ describe("withClaim", () => { expect(gh.comments[0]?.body).toContain("**Triage** finished: asked 2 questions"); expect(gh.labels.has(WORKING_LABEL)).toBe(false); expect(events).toMatchObject([ + { type: "run.finished", traceId: "trn-prev", payload: { state: "cancelled", outcome: "superseded", stoppedBy: "trn-run" } }, { - type: "run.started", + type: "run.finished", traceId: "trn-root", runId: "trn-run", repo: REPO.fullName, subject: "issue#12", - payload: { stage: "triage", actor: "maintainer" }, + payload: { stage: "triage", state: "done", outcome: "asked-questions" }, }, - { type: "run.finished", traceId: "trn-prev", payload: { state: "cancelled", outcome: "superseded", stoppedBy: "trn-run" } }, - { type: "run.finished", traceId: "trn-root", payload: { state: "done", outcome: "asked-questions" } }, ]); }); diff --git a/tests/events.test.ts b/tests/events.test.ts index 2fd270c..987e44a 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -1,52 +1,39 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { emit } from "../app/events/index.js"; -const pg = vi.hoisted(() => ({ - query: vi.fn(), - end: vi.fn(async () => {}), - constructed: 0, -})); - -vi.mock("pg", () => { - class Pool { - constructor() { - pg.constructed++; - } - query = pg.query; - end = pg.end; - on() { - return this; - } - } - return { default: { Pool } }; -}); - -const { closeEvents, emit } = await import("../app/events/index.js"); - -const event = { traceId: "trn-1", type: "run.started", repo: "acme/web", payload: { a: 1 } }; - -beforeEach(() => { - pg.query.mockReset(); - pg.constructed = 0; -}); - -afterEach(async () => { - await closeEvents(); - vi.unstubAllEnvs(); +afterEach(() => { vi.restoreAllMocks(); }); describe("emit", () => { - it("swallows database errors and logs one JSON warning", async () => { - vi.stubEnv("DATABASE_URL", "postgres://fake/db"); - pg.query.mockRejectedValueOnce(new Error("connection refused")); - const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); - await expect(emit(event)).resolves.toBeUndefined(); - expect(stderr).toHaveBeenCalledTimes(1); - expect(JSON.parse(stderr.mock.calls[0]?.[0] as string)).toMatchObject({ - level: "warn", - msg: "event dropped", - type: "run.started", - error: "connection refused", + it("writes one JSON line to stdout with the event name, its ids, and its payload", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + emit({ + traceId: "trn-root", + runId: "trn-2", + repo: "acme/web", + subject: "issue#12", + type: "run.finished", + payload: { stage: "triage", state: "done", outcome: "drafted" }, + }); + expect(log).toHaveBeenCalledTimes(1); + expect(JSON.parse(log.mock.calls[0]?.[0] as string)).toEqual({ + level: "info", + event: "run.finished", + traceId: "trn-root", + runId: "trn-2", + repo: "acme/web", + subject: "issue#12", + stage: "triage", + state: "done", + outcome: "drafted", }); }); + + it("never throws, even when the payload can't be serialized", () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const warn = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => emit({ traceId: "trn-1", type: "agent.finished", payload: { cost: 1n } })).not.toThrow(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("event dropped")); + }); }); diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index e4f6441..2ab2390 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -2,7 +2,6 @@ import { createHmac } from "node:crypto"; import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; import { config, type TaskName } from "../app/config/index.js"; import { TriageInput } from "../app/schemas/tasks.js"; -import type { FactoryEvent } from "../app/events/index.js"; import { createGateway, type GatewayDeps } from "../app/gateway/index.js"; const SECRET = "webhook-secret"; @@ -30,38 +29,28 @@ function webhook(body: string, { event = "issues", delivery = "delivery-1" as st } let startTask: Mock; -let emit: Mock<(event: FactoryEvent) => Promise>; let app: ReturnType; beforeEach(() => { startTask = vi.fn(async (_task: TaskName, _input: object, _key: string) => ({ id: "trn-123" })); - emit = vi.fn(async (_event: FactoryEvent) => {}); - app = createGateway({ startTask, emit, webhookSecret: SECRET }); + app = createGateway({ startTask, webhookSecret: SECRET }); }); -const deliveryEvent = () => { - expect(emit).toHaveBeenCalledTimes(1); - const event = emit.mock.calls[0]?.[0] as FactoryEvent; - expect(event.type).toBe("delivery.received"); - return event; -}; - describe("gateway", () => { - it("serves /health and /ready without the database", async () => { + it("serves /health and /ready", async () => { expect((await app.request("/health")).status).toBe(200); const ready = await app.request("/ready"); expect(ready.status).toBe(200); expect(await ready.json()).toEqual({ status: "ready" }); }); - it("rejects a bad signature before parsing the body, without an event", async () => { + it("rejects a bad signature before parsing the body", async () => { const parse = vi.spyOn(JSON, "parse"); const res = await app.fetch(webhook("{not json", { signature: sign("{not json", "wrong") })); expect(res.status).toBe(401); expect(parse).not.toHaveBeenCalled(); parse.mockRestore(); expect(startTask).not.toHaveBeenCalled(); - expect(emit).not.toHaveBeenCalled(); }); it("rejects webhook bodies over 256 KB before starting work", async () => { @@ -80,79 +69,18 @@ describe("gateway", () => { expect(task).toBe("triage"); expect(key).toBe("delivery-1"); expect(TriageInput.safeParse(input).success).toBe(true); - - const event = deliveryEvent(); - expect(event).toMatchObject({ traceId: "trn-123", runId: "trn-123", repo: REPO, subject: "issue#12" }); - expect(event.payload).toMatchObject({ - event: "issues", - action: "labeled", - outcome: "started", - deliveryId: "delivery-1", - }); }); - it("returns 502 when startTask fails so GitHub records a failed delivery", async () => { + it("returns 502 and logs why when startTask fails, so GitHub records a failed delivery", async () => { + const warn = vi.spyOn(console, "error").mockImplementation(() => {}); startTask.mockRejectedValueOnce(new Error("render down")); const res = await app.fetch(webhook(JSON.stringify(triagePayload))); expect(res.status).toBe(502); - const event = deliveryEvent(); - expect(event.traceId).toBe("delivery-1"); - expect(event.payload).toMatchObject({ outcome: "start failed", reason: "render down" }); - }); -}); - -describe("run viewer", () => { - const PASSWORD = "viewer-password-0123456789"; - const auth = (password = PASSWORD) => ({ authorization: `Basic ${Buffer.from(`factory:${password}`).toString("base64")}` }); - const run = (id: number) => ({ - id, - traceId: `trn-${id}`, - runId: `trn-${id}`, - repo: REPO, - subject: "issue#12", - task: "triage", - actor: "maintainer", - startedAt: "2026-10-24T20:18:54.000Z", - finishedAt: null, - state: null, - outcome: null, - costUsd: null, - agentRuns: 0, - }); - const viewerApp = (viewer: Partial> = {}) => - createGateway({ - startTask, - emit, - webhookSecret: SECRET, - viewer: { password: PASSWORD, listRuns: vi.fn(async () => []), listTraceEvents: vi.fn(async () => []), ...viewer }, + expect(JSON.parse(warn.mock.calls[0]?.[0] as string)).toMatchObject({ + msg: "could not start task", + deliveryId: "delivery-1", + error: "render down", }); - - it("is off unless configured, and requires the password for the page and the API", async () => { - expect((await app.request("/runs")).status).toBe(404); - const viewer = viewerApp(); - for (const path of ["/runs", "/runs/app.js", "/api/runs", "/api/runs/trn-1/events"]) { - expect((await viewer.request(path)).status).toBe(401); - expect((await viewer.request(path, { headers: auth("wrong-password-0123456789") })).status).toBe(401); - } - const page = await viewer.request("/runs", { headers: auth() }); - expect(page.status).toBe(200); - expect(await page.text()).toContain('