From b7db6686bc4f0a54b29684dfce1b24205e6a1356 Mon Sep 17 00:00:00 2001 From: Toby V Date: Sat, 27 Jun 2026 12:34:58 +1000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Wave=201=20=E2=80=94=20API=20auth,?= =?UTF-8?q?=20structured=20outputs,=20sandbox=20env=20scrub=20(ENG-004,=20?= =?UTF-8?q?ENG-009,=20ENG-005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ENG-004: bearer-token auth middleware on /api/* (apps/server/src/auth.ts) — mutating requests require Authorization: Bearer $API_AUTH_TOKEN (timing-safe), GET read views open; principal from X-Actor; approval endpoints append an approval_decided audit event stamped with the principal; open dev-mode + startup warning when unset. ENG-009: WorkerInput.outputSchema; API backend emits output_config.format (json_schema); hand-written schemas for proposal/tickets/verdict; CLI keeps prose fallback; ProposedChanges.parsed distinguishes parse failure from empty change set. ENG-005: sandboxEnv() forwards only an allow-list to the claude -p child, withholding host secrets. typecheck + lint + 73 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/app.ts | 27 ++++++- apps/server/src/auth.test.ts | 70 +++++++++++++++++ apps/server/src/auth.ts | 78 +++++++++++++++++++ apps/server/src/index.ts | 2 + apps/server/src/temporal/activities.ts | 8 +- packages/agents/src/assess.ts | 2 + packages/agents/src/backends/api.ts | 9 +++ packages/agents/src/backends/cli.test.ts | 37 ++++++++- packages/agents/src/backends/cli.ts | 49 +++++++++++- packages/agents/src/decompose.ts | 2 + packages/agents/src/index.ts | 2 + packages/agents/src/propose.ts | 29 ++++++- packages/agents/src/schemas.test.ts | 25 ++++++ packages/agents/src/schemas.ts | 64 +++++++++++++++ packages/core/src/worker.ts | 6 ++ .../ENG-004-api-auth-and-approval-identity.md | 15 +++- project/ENG-005-scrub-cli-sandbox-env.md | 9 ++- .../ENG-009-structured-outputs-for-agents.md | 11 ++- project/ROADMAP.md | 10 ++- 19 files changed, 441 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/auth.test.ts create mode 100644 apps/server/src/auth.ts create mode 100644 packages/agents/src/schemas.test.ts create mode 100644 packages/agents/src/schemas.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index a958a53..40848f5 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import { type AppEnv, authMiddleware } from "./auth" import { persistenceFromEnv } from "./persistence" import { artifactPath, SHAPING_STAGES } from "./shaping" import { @@ -16,12 +17,16 @@ import { */ const persistence = persistenceFromEnv() -export const app = new Hono() +export const app = new Hono() app.onError((err, c) => c.json({ error: String(err) }, 500)) app.get("/health", (c) => c.json({ ok: true })) +// Auth gate (ENG-004): GET read views stay open; mutating /api requests require a Bearer token when +// API_AUTH_TOKEN is set. The resolved principal is recorded on approval events below. +app.use("/api/*", authMiddleware()) + app.get("/api/tickets", async (c) => c.json({ tickets: await persistence.tracker.list() })) app.get("/api/audit", async (c) => c.json({ events: await persistence.audit.query() })) // Pending human gates, derived from the audit log. A roadmap sign-off is pending when the latest @@ -105,7 +110,15 @@ app.post("/api/epics/:id/decompose", async (c) => { app.post("/api/epics/:id/approve-roadmap", async (c) => { const id = c.req.param("id") const signaled = await approveRoadmap(id) - return c.json({ epicId: id, gate: "roadmap", signaled }) + const by = c.get("actor") + // Record who released the gate (ENG-004) — append-only, so the audit log carries the approver. + await persistence.audit.append({ + actor: "human", + kind: "approval_decided", + ticketId: null, + payload: { epicId: id, gate: "roadmap", by, signaled }, + }) + return c.json({ epicId: id, gate: "roadmap", signaled, by }) }) // Create a ticket through the tracker under a chosen epic (or seed the default Mission→Goal→Epic @@ -144,5 +157,13 @@ app.post("/api/tickets/:id/approve", async (c) => { const body = (await c.req.json().catch(() => ({}))) as { gate?: "merge" | "deploy" } const gate = body.gate === "deploy" ? "deploy" : "merge" await approveTicket(id, gate) - return c.json({ ticketId: id, gate, signaled: true }) + const by = c.get("actor") + // Record who released the gate (ENG-004). + await persistence.audit.append({ + actor: "human", + kind: "approval_decided", + ticketId: id, + payload: { gate, by }, + }) + return c.json({ ticketId: id, gate, signaled: true, by }) }) diff --git a/apps/server/src/auth.test.ts b/apps/server/src/auth.test.ts new file mode 100644 index 0000000..f44bcbd --- /dev/null +++ b/apps/server/src/auth.test.ts @@ -0,0 +1,70 @@ +import { Hono } from "hono" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { type AppEnv, authMiddleware, checkAuth } from "./auth" + +describe("checkAuth", () => { + it("allows dev mode (principal 'dev') when no token is configured", () => { + expect(checkAuth({}, undefined)).toEqual({ ok: true, principal: "dev" }) + }) + + it("accepts a correct Bearer token and defaults the principal to 'operator'", () => { + const r = checkAuth({ authorization: "Bearer s3cret" }, "s3cret") + expect(r).toEqual({ ok: true, principal: "operator" }) + }) + + it("honors the X-Actor header as the principal", () => { + const r = checkAuth({ authorization: "Bearer s3cret", actor: "toby" }, "s3cret") + expect(r).toEqual({ ok: true, principal: "toby" }) + }) + + it("rejects a missing or wrong token", () => { + expect(checkAuth({}, "s3cret").ok).toBe(false) + expect(checkAuth({ authorization: "Bearer nope" }, "s3cret").ok).toBe(false) + expect(checkAuth({ authorization: "s3cret" }, "s3cret").ok).toBe(false) // missing "Bearer " + }) +}) + +describe("authMiddleware", () => { + const prev = process.env.API_AUTH_TOKEN + let app: Hono + + beforeEach(() => { + app = new Hono() + app.use("/api/*", authMiddleware()) + app.get("/api/thing", (c) => c.json({ actor: c.get("actor") })) + app.post("/api/thing", (c) => c.json({ actor: c.get("actor") })) + }) + afterEach(() => { + if (prev === undefined) delete process.env.API_AUTH_TOKEN + else process.env.API_AUTH_TOKEN = prev + }) + + it("leaves GET read views open", async () => { + process.env.API_AUTH_TOKEN = "s3cret" + const res = await app.request("/api/thing") + expect(res.status).toBe(200) + }) + + it("401s an unauthenticated mutation when a token is configured", async () => { + process.env.API_AUTH_TOKEN = "s3cret" + const res = await app.request("/api/thing", { method: "POST" }) + expect(res.status).toBe(401) + }) + + it("allows an authenticated mutation and exposes the principal", async () => { + process.env.API_AUTH_TOKEN = "s3cret" + const res = await app.request("/api/thing", { + method: "POST", + headers: { authorization: "Bearer s3cret", "x-actor": "toby" }, + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ actor: "toby" }) + }) + + it("allows mutations in dev mode when no token is configured", async () => { + delete process.env.API_AUTH_TOKEN + const res = await app.request("/api/thing", { method: "POST" }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ actor: "dev" }) + }) +}) diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts new file mode 100644 index 0000000..c1a22a3 --- /dev/null +++ b/apps/server/src/auth.ts @@ -0,0 +1,78 @@ +import { timingSafeEqual } from "node:crypto" +import type { MiddlewareHandler } from "hono" + +/** + * API authentication (ENG-004). The approval gates are the product's core value (invariant #4), so + * mutating requests must be authenticated and the deciding principal recorded. Auth activates when + * `API_AUTH_TOKEN` is set; when it isn't, the server runs in an open "dev mode" (and warns loudly at + * startup) so the local `docker compose up` quickstart keeps working — set the token to enforce. + */ + +/** Hono environment: the authenticated principal attributed to writes (audit identity). */ +export type AppEnv = { Variables: { actor: string } } + +const ACTOR_HEADER = "x-actor" + +export interface AuthResult { + ok: boolean + /** Who to attribute the write to in the audit log. */ + principal: string +} + +function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + if (ab.length !== bb.length) return false + return timingSafeEqual(ab, bb) +} + +/** + * Decide whether a request is authorized and who the principal is. Pure + header-driven for testing. + * - No configured token → dev mode: allowed, principal `dev`. + * - Configured token → requires `Authorization: Bearer `; principal from `X-Actor` + * (default `operator`). A bad/missing token → not ok. + */ +export function checkAuth( + headers: { authorization?: string | null; actor?: string | null }, + configuredToken: string | undefined, +): AuthResult { + if (!configuredToken) return { ok: true, principal: "dev" } + const match = /^Bearer\s+(.+)$/i.exec((headers.authorization ?? "").trim()) + const token = match?.[1] + if (!token || !safeEqual(token, configuredToken)) { + return { ok: false, principal: "anonymous" } + } + const actor = (headers.actor ?? "").trim() + return { ok: true, principal: actor || "operator" } +} + +/** + * Hono middleware enforcing auth on mutating requests. GET (read views) and `/health` stay open; any + * non-GET request requires auth when a token is configured. The resolved principal is stored on the + * context for handlers to attribute approvals/writes to. + */ +export function authMiddleware(): MiddlewareHandler { + return async (c, next) => { + if (c.req.method === "GET") { + c.set("actor", "anonymous") + return next() + } + const result = checkAuth( + { authorization: c.req.header("authorization"), actor: c.req.header(ACTOR_HEADER) }, + process.env.API_AUTH_TOKEN, + ) + if (!result.ok) return c.json({ error: "unauthorized" }, 401) + c.set("actor", result.principal) + return next() + } +} + +/** Log a one-time warning if auth is disabled (no token configured). Call at server startup. */ +export function warnIfAuthDisabled(): void { + if (!process.env.API_AUTH_TOKEN) { + console.warn( + "[auth] API_AUTH_TOKEN is not set — mutating endpoints (incl. approval gates) are UNAUTHENTICATED. " + + "Set API_AUTH_TOKEN to require a Bearer token before any shared/live deployment.", + ) + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 49623f3..a89b164 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,9 +1,11 @@ import { serve } from "@hono/node-server" import { app } from "./app" +import { warnIfAuthDisabled } from "./auth" import { startHeartbeat } from "./scheduler" const port = Number(process.env.PORT ?? 3000) +warnIfAuthDisabled() serve({ fetch: app.fetch, port }) console.log(`[server] listening on http://localhost:${port}`) diff --git a/apps/server/src/temporal/activities.ts b/apps/server/src/temporal/activities.ts index e378c59..b9b42b8 100644 --- a/apps/server/src/temporal/activities.ts +++ b/apps/server/src/temporal/activities.ts @@ -276,11 +276,17 @@ export async function implementTicket(ticketId: string): Promise { goalContext: input.goalContext, task: `${input.task}\n\n${CONTRACT}`, budgetCentsRemaining: input.budgetCentsRemaining, + outputSchema: VERDICT_SCHEMA, }) const { passed, summary } = parseVerdict(result.summary) return { passed, summary, costCents: result.costCents, stoppedReason: result.stoppedReason } diff --git a/packages/agents/src/backends/api.ts b/packages/agents/src/backends/api.ts index b9d201d..c11c5c7 100644 --- a/packages/agents/src/backends/api.ts +++ b/packages/agents/src/backends/api.ts @@ -21,6 +21,15 @@ export class ApiBackend implements WorkerBackend { thinking: { type: "adaptive" }, system: buildSystemPrompt(input), messages: [{ role: "user", content: input.task }], + // Structured outputs (ENG-009): constrain the model to schema-conforming JSON when a schema + // is supplied, so callers parse guaranteed-valid JSON instead of best-effort extraction. + ...(input.outputSchema + ? { + output_config: { + format: { type: "json_schema" as const, schema: input.outputSchema }, + }, + } + : {}), }, { signal }, ) diff --git a/packages/agents/src/backends/cli.test.ts b/packages/agents/src/backends/cli.test.ts index be8c142..eb61865 100644 --- a/packages/agents/src/backends/cli.test.ts +++ b/packages/agents/src/backends/cli.test.ts @@ -3,7 +3,7 @@ import { chmod, mkdir, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, beforeEach, describe, expect, it } from "vitest" -import { CliBackend, createSandbox, resolveWorkspaceRoot } from "./cli" +import { CliBackend, createSandbox, resolveWorkspaceRoot, sandboxEnv } from "./cli" describe("resolveWorkspaceRoot", () => { const prev = process.env.AGENT_WORKSPACE_DIR @@ -42,6 +42,41 @@ describe("createSandbox", () => { }) }) +describe("sandboxEnv", () => { + it("forwards benign vars but withholds host secrets", () => { + const env = sandboxEnv({ + PATH: "/usr/bin", + HOME: "/home/agent", + ANTHROPIC_API_KEY: "sk-secret", + GITHUB_TOKEN: "ghp-secret", + DATABASE_URL: "postgres://secret", + }) + expect(env.PATH).toBe("/usr/bin") + expect(env.HOME).toBe("/home/agent") + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect(env.GITHUB_TOKEN).toBeUndefined() + expect(env.DATABASE_URL).toBeUndefined() + }) + + it("supports opt-in passthrough but never leaks unlisted vars", () => { + const env = sandboxEnv({ + PATH: "/usr/bin", + AGENT_SANDBOX_ENV_PASSTHROUGH: "FOO, BAR", + FOO: "1", + BAR: "2", + ANTHROPIC_API_KEY: "sk-secret", + }) + expect(env.FOO).toBe("1") + expect(env.BAR).toBe("2") + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + }) + + it("omits allow-listed vars that are absent from the source", () => { + const env = sandboxEnv({ PATH: "/usr/bin" }) + expect("HOME" in env).toBe(false) + }) +}) + describe("CliBackend sandbox enforcement", () => { let root: string let bin: string diff --git a/packages/agents/src/backends/cli.ts b/packages/agents/src/backends/cli.ts index e9c22f9..398b142 100644 --- a/packages/agents/src/backends/cli.ts +++ b/packages/agents/src/backends/cli.ts @@ -40,6 +40,52 @@ export async function createSandbox(root: string, role: string): Promise return mkdtemp(join(root, `${role}-`)) } +/** + * Env vars forwarded into the agent sandbox (ENG-005). `claude -p` is the full Claude Code agent with + * bash + filesystem tools, so the child must NOT inherit the host environment — secrets like + * `ANTHROPIC_API_KEY`, `GITHUB_TOKEN`, and `DATABASE_URL` would otherwise be readable from the agent's + * shell. Only this benign allow-list is passed; the CLI authenticates via its own login under `HOME`. + */ +const SANDBOX_ENV_ALLOWLIST = [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TMPDIR", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", +] as const + +/** + * Build the scrubbed environment for a sandboxed agent run: the allow-list above, plus any names + * explicitly opted in via `AGENT_SANDBOX_ENV_PASSTHROUGH` (comma-separated). Everything else — notably + * credentials — is withheld. Pure and side-effect-free for testability. + */ +export function sandboxEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + for (const key of SANDBOX_ENV_ALLOWLIST) { + const value = source[key] + if (value !== undefined) env[key] = value + } + const extra = source.AGENT_SANDBOX_ENV_PASSTHROUGH + if (extra) { + for (const key of extra + .split(",") + .map((s) => s.trim()) + .filter(Boolean)) { + const value = source[key] + if (value !== undefined) env[key] = value + } + } + return env +} + export interface CliBackendOptions { bin?: string workspaceRoot?: string @@ -110,7 +156,8 @@ export class CliBackend implements WorkerBackend { private exec(args: string[], stdin: string, cwd: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { - const child = spawn(this.bin, args, { cwd, signal }) + // Scrubbed env (ENG-005): the agent's tools never see host secrets. + const child = spawn(this.bin, args, { cwd, signal, env: sandboxEnv() }) let stdout = "" let stderr = "" child.stdout.on("data", (chunk) => { diff --git a/packages/agents/src/decompose.ts b/packages/agents/src/decompose.ts index 88dcbb9..f23dfab 100644 --- a/packages/agents/src/decompose.ts +++ b/packages/agents/src/decompose.ts @@ -1,6 +1,7 @@ import type { RoleId, StoppedReason } from "@eng/core" import { ClaudeWorker } from "./claude-worker" import { extractJson } from "./propose" +import { TICKETS_SCHEMA } from "./schemas" /** A ticket proposed by the Lead Engineer when decomposing an epic. */ export interface ProposedTicket { @@ -51,6 +52,7 @@ export async function proposeTickets(input: ProposeTicketsInput): Promise { + it("PROPOSAL_SCHEMA requires summary + files and forbids extra props", () => { + expect(PROPOSAL_SCHEMA.type).toBe("object") + expect(PROPOSAL_SCHEMA.additionalProperties).toBe(false) + expect(PROPOSAL_SCHEMA.required).toEqual(["summary", "files"]) + }) + + it("TICKETS_SCHEMA constrains assigneeRole to the known roles", () => { + const props = TICKETS_SCHEMA.properties as { + tickets: { items: { properties: { assigneeRole: { enum: string[] } } } } + } + const role = props.tickets.items.properties.assigneeRole + expect(role.enum).toContain("staff_engineer") + expect(role.enum).toContain("pm") + expect(role.enum).toContain("qa_test") + }) + + it("VERDICT_SCHEMA requires passed + summary", () => { + expect(VERDICT_SCHEMA.type).toBe("object") + expect(VERDICT_SCHEMA.required).toEqual(["passed", "summary"]) + }) +}) diff --git a/packages/agents/src/schemas.ts b/packages/agents/src/schemas.ts new file mode 100644 index 0000000..f61300f --- /dev/null +++ b/packages/agents/src/schemas.ts @@ -0,0 +1,64 @@ +import { ROLE_IDS } from "@eng/core" + +/** + * JSON Schemas for agent structured outputs (ENG-009). Hand-written (rather than zod-derived) to stay + * within the API's structured-output constraints — every object sets `additionalProperties: false` + * and lists `required`, and no unsupported keywords (min/max length, etc.) are used. Passed to the + * Worker as `outputSchema`; the API backend enforces them, the CLI backend falls back to parsing. + */ + +/** `{ summary, files: [{ path, content }] }` — the coding agent's file changes. */ +export const PROPOSAL_SCHEMA: Record = { + type: "object", + additionalProperties: false, + required: ["summary", "files"], + properties: { + summary: { type: "string" }, + files: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["path", "content"], + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + }, + }, + }, +} + +/** `{ tickets: [{ title, description, acceptanceCriteria, assigneeRole }] }` — epic decomposition. */ +export const TICKETS_SCHEMA: Record = { + type: "object", + additionalProperties: false, + required: ["tickets"], + properties: { + tickets: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["title", "description", "acceptanceCriteria", "assigneeRole"], + properties: { + title: { type: "string" }, + description: { type: "string" }, + acceptanceCriteria: { type: "array", items: { type: "string" } }, + assigneeRole: { type: "string", enum: [...ROLE_IDS] }, + }, + }, + }, + }, +} + +/** `{ passed, summary }` — the QA verdict. */ +export const VERDICT_SCHEMA: Record = { + type: "object", + additionalProperties: false, + required: ["passed", "summary"], + properties: { + passed: { type: "boolean" }, + summary: { type: "string" }, + }, +} diff --git a/packages/core/src/worker.ts b/packages/core/src/worker.ts index 70a7ca3..8491176 100644 --- a/packages/core/src/worker.ts +++ b/packages/core/src/worker.ts @@ -16,6 +16,12 @@ export interface WorkerInput { task: string /** Remaining budget; the orchestrator stops the session if this is exhausted (invariant #3). */ budgetCentsRemaining: number + /** + * Optional JSON Schema for the response (ENG-009). Runtimes that support structured outputs (the + * API backend) constrain the model to emit schema-conforming JSON; runtimes that don't (the CLI + * backend) ignore it and fall back to prompt-contract parsing. + */ + outputSchema?: Record } export interface ToolCallRecord { diff --git a/project/ENG-004-api-auth-and-approval-identity.md b/project/ENG-004-api-auth-and-approval-identity.md index a5d7104..a09b53e 100644 --- a/project/ENG-004-api-auth-and-approval-identity.md +++ b/project/ENG-004-api-auth-and-approval-identity.md @@ -1,11 +1,24 @@ # ENG-004 — API authentication + approval identity -- **Status:** backlog +- **Status:** done - **Priority:** P0 (Critical — do before any live/shared deployment) - **Stage:** architecture - **Assignee role:** lead_system_design (with lead_architect sign-off) - **Area:** apps/server +> **Outcome (Wave 1):** `apps/server/src/auth.ts` adds bearer-token auth — `authMiddleware` on +> `/api/*` requires `Authorization: Bearer ` on mutating (non-GET) requests +> (timing-safe compare; GET read views stay open), resolving the principal from `X-Actor` (default +> `operator`). The approval endpoints now append an `approval_decided` audit event stamped with the +> authenticated principal (`payload.by`), so the append-only log records *who* released each gate. +> When `API_AUTH_TOKEN` is unset the server runs open "dev mode" and warns loudly at startup +> (`warnIfAuthDisabled`) — set the token to enforce. 9 auth unit tests (pure `checkAuth` + middleware +> via `app.request`). Typecheck + lint + 73 tests green. +> +> *Follow-up (noted):* require-auth-by-default and protecting read views are a hardening decision for +> the operator; first-class persisted approval records land in ENG-006 (where `decidedBy` moves onto +> the approval entity). + ## Problem The HTTP API has no authentication or authorization. Anyone who can reach the server can approve diff --git a/project/ENG-005-scrub-cli-sandbox-env.md b/project/ENG-005-scrub-cli-sandbox-env.md index 4c46c34..8d7538c 100644 --- a/project/ENG-005-scrub-cli-sandbox-env.md +++ b/project/ENG-005-scrub-cli-sandbox-env.md @@ -1,11 +1,18 @@ # ENG-005 — Scrub the environment for CLI agent sandboxes -- **Status:** backlog +- **Status:** done - **Priority:** P1 (High — security hardening) - **Stage:** implementation - **Assignee role:** staff_engineer (security review by lead_system_design) - **Area:** packages/agents +> **Outcome (Wave 1):** `CliBackend` now spawns `claude -p` with a scrubbed, allow-listed env +> (`sandboxEnv()` in `backends/cli.ts`) instead of inheriting the host environment — `ANTHROPIC_API_KEY`, +> `GITHUB_TOKEN`, `DATABASE_URL` and everything else not on the benign allow-list (PATH/HOME/XDG/locale/…) +> are withheld; the CLI still authenticates via its own login under `HOME`. Opt-in widening via +> `AGENT_SANDBOX_ENV_PASSTHROUGH`. Unit tests assert secrets are absent and passthrough works. +> *Deferred to ENG-013:* the PM-agent scoped-token exception. + ## Problem The CLI backend confines an agent's *cwd* to a throwaway sandbox, but it does not scrub the *process diff --git a/project/ENG-009-structured-outputs-for-agents.md b/project/ENG-009-structured-outputs-for-agents.md index ae132cb..1cf4eaa 100644 --- a/project/ENG-009-structured-outputs-for-agents.md +++ b/project/ENG-009-structured-outputs-for-agents.md @@ -1,11 +1,20 @@ # ENG-009 — Use structured outputs for agent response parsing -- **Status:** backlog +- **Status:** done - **Priority:** P1 (High) - **Stage:** implementation - **Assignee role:** staff_engineer - **Area:** packages/agents +> **Outcome (Wave 1):** `WorkerInput` gained an optional `outputSchema`; the **API backend** now emits +> `output_config: { format: { type: "json_schema", schema } }` (SDK 0.105) so responses are +> schema-constrained JSON. Hand-written JSON Schemas (`schemas.ts`: `PROPOSAL_SCHEMA` / `TICKETS_SCHEMA` +> / `VERDICT_SCHEMA`, within the API's structured-output constraints) are passed by +> `proposeFileChanges` / `proposeTickets` / `assess`. The **CLI backend** ignores the schema and keeps +> the prose-parse fallback. `ProposedChanges.parsed` distinguishes a parse failure from an empty change +> set, and `implementTicket` now audits "agent output could not be parsed" vs "no file changes +> proposed". Typecheck green; schema unit tests added; 35 agents+server tests pass. + ## Problem The agent helpers (`proposeFileChanges`, `proposeTickets`, `assess`) rely on prompting the model to diff --git a/project/ROADMAP.md b/project/ROADMAP.md index 553ad5e..6a0d4d3 100644 --- a/project/ROADMAP.md +++ b/project/ROADMAP.md @@ -26,10 +26,12 @@ high-leverage enablers forward even when labelled P1/P2. (SurrealDB — no-go on wholesale replacement; one item awaits the lead: relax the OSI-only license bar? recommended *no* for now). -### Wave 1 — Safety + enablers (independent, high-leverage) -- **ENG-004** — API auth + approval identity (P0 security gate before any live/shared run). -- **ENG-009** — Structured outputs (enabler for ENG-001 quality, ENG-014, ENG-016). -- **ENG-005** — Scrub the CLI sandbox env (security; prerequisite for the repo-context chain). +### Wave 1 — Safety + enablers (independent, high-leverage) — ✅ complete (2026-06-27) +- **ENG-004** ✅ — API auth + approval identity (bearer token on mutating `/api/*`; principal stamped + on `approval_decided` audit events; dev-mode warn when unset). +- **ENG-009** ✅ — Structured outputs (API backend emits `output_config.format`; hand-written schemas; + parse-failure vs empty-set audited distinctly). +- **ENG-005** ✅ — Scrub the CLI sandbox env (allow-listed `sandboxEnv()`; host secrets withheld). ### Wave 2 — Repo-context critical path - **ENG-013** — Repo provisioning (PM `git`/`gh`) + two-root workspace. Needs ENG-005. From 543d1b81adcac5b2cfc69ec75d81dafe850cd658 Mon Sep 17 00:00:00 2001 From: Toby V Date: Sat, 27 Jun 2026 12:49:30 +1000 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20Wave=202=20=E2=80=94=20repo=20provi?= =?UTF-8?q?sioning=20+=20repo-aware=20coding=20agents=20(ENG-013,=20ENG-00?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ENG-013: two-root workspace (workspace.ts) — persistent working-code root for cloned target repos (AGENT_CODE_WORKSPACE), distinct from throwaway agent-state sandboxes; PM granted git/gh (ROLES.pm.tools + prompt); sandboxEnv(source, role) forwards a PM-scoped AGENT_PM_GITHUB_TOKEN only to the PM sandbox (host token never forwarded); idempotent ensureRepoCloned (provision.ts) shallow clone/ff-pull with token via http.extraheader, no-op when unconfigured, wired+audited in implementTicket. ENG-001: WorkerInput.workdir — CLI backend runs the agent in the cloned repo cwd (persistent, not cleaned up) so it reads/edits real source; implementTicket passes the clone path; product tree never touched. typecheck + lint + 85 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + apps/server/src/temporal/activities.ts | 33 ++++++++- packages/agents/src/backends/cli.test.ts | 45 +++++++++++ packages/agents/src/backends/cli.ts | 40 ++++++++-- packages/agents/src/index.ts | 7 ++ packages/agents/src/propose.ts | 3 + packages/agents/src/provision.test.ts | 56 ++++++++++++++ packages/agents/src/provision.ts | 74 +++++++++++++++++++ packages/agents/src/workspace.test.ts | 28 +++++++ packages/agents/src/workspace.ts | 45 +++++++++++ packages/core/src/roles.ts | 4 +- packages/core/src/worker.ts | 6 ++ project/ENG-001-repo-aware-coding-agents.md | 14 +++- ...-repo-provisioning-and-shared-workspace.md | 23 +++++- project/ROADMAP.md | 9 ++- 15 files changed, 374 insertions(+), 16 deletions(-) create mode 100644 packages/agents/src/provision.test.ts create mode 100644 packages/agents/src/provision.ts create mode 100644 packages/agents/src/workspace.test.ts create mode 100644 packages/agents/src/workspace.ts diff --git a/.gitignore b/.gitignore index 9ded2ea..eaae021 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ coverage/ # cli-backend agent sandboxes — runtime scratch only (keep the dir + its README) /workspaces/* !/workspaces/README.md + +# working-code workspace — cloned target repos agents inspect/modify (ENG-013); never committed +/workspace/ diff --git a/apps/server/src/temporal/activities.ts b/apps/server/src/temporal/activities.ts index b9b42b8..e053a2a 100644 --- a/apps/server/src/temporal/activities.ts +++ b/apps/server/src/temporal/activities.ts @@ -1,4 +1,4 @@ -import { assess, draft, proposeFileChanges, proposeTickets } from "@eng/agents" +import { assess, draft, ensureRepoCloned, proposeFileChanges, proposeTickets } from "@eng/agents" import { type DeliveryAdapter, type DeployState, @@ -213,6 +213,36 @@ export async function implementTicket(ticketId: string): Promise> | null = null try { proposed = await proposeFileChanges({ @@ -223,6 +253,7 @@ export async function implementTicket(ticketId: string): Promise { const env = sandboxEnv({ PATH: "/usr/bin" }) expect("HOME" in env).toBe(false) }) + + it("forwards a PM-scoped GitHub token only for the PM role (ENG-013)", () => { + const src = { PATH: "/usr/bin", AGENT_PM_GITHUB_TOKEN: "pmtok" } + expect(sandboxEnv(src, "pm").GITHUB_TOKEN).toBe("pmtok") + expect(sandboxEnv(src, "pm").GH_TOKEN).toBe("pmtok") + expect(sandboxEnv(src, "staff_engineer").GITHUB_TOKEN).toBeUndefined() + expect(sandboxEnv(src).GITHUB_TOKEN).toBeUndefined() + }) + + it("never forwards the host GITHUB_TOKEN, even for the PM", () => { + const env = sandboxEnv({ PATH: "/usr/bin", GITHUB_TOKEN: "host" }, "pm") + expect(env.GITHUB_TOKEN).toBeUndefined() + }) }) describe("CliBackend sandbox enforcement", () => { @@ -107,3 +120,35 @@ describe("CliBackend sandbox enforcement", () => { expect(result.summary).not.toBe(process.cwd()) }) }) + +describe("CliBackend repo-aware workdir (ENG-001)", () => { + let dir: string + let bin: string + beforeEach(async () => { + dir = join(tmpdir(), `eng-code-${process.pid}-${Date.now()}`) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "TARGET.txt"), "seeded-content") + bin = join(dir, "fakebin.sh") + await writeFile(bin, "#!/bin/sh\ncat TARGET.txt\n") // reads a file from its cwd (the clone) + await chmod(bin, 0o755) + }) + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it("runs the agent in the provided workdir so it reads real source, and leaves it intact", async () => { + const backend = new CliBackend("claude-opus-4-8", { bin }) + const result = await backend.run({ + role: "staff_engineer", + systemPrompt: "", + tools: [], + goalContext: "", + task: "x", + budgetCentsRemaining: 100, + workdir: dir, + }) + expect(result.summary).toContain("seeded-content") // the agent saw the seeded file + expect(existsSync(dir)).toBe(true) // a provided workdir is persistent — not cleaned up + expect(existsSync(join(dir, "TARGET.txt"))).toBe(true) + }) +}) diff --git a/packages/agents/src/backends/cli.ts b/packages/agents/src/backends/cli.ts index 398b142..9e3a006 100644 --- a/packages/agents/src/backends/cli.ts +++ b/packages/agents/src/backends/cli.ts @@ -66,8 +66,16 @@ const SANDBOX_ENV_ALLOWLIST = [ * Build the scrubbed environment for a sandboxed agent run: the allow-list above, plus any names * explicitly opted in via `AGENT_SANDBOX_ENV_PASSTHROUGH` (comma-separated). Everything else — notably * credentials — is withheld. Pure and side-effect-free for testability. + * + * Scoped-token exception (ENG-013): the PM role provisions repos with `git`/`gh`, so — and only for + * the PM — a dedicated `AGENT_PM_GITHUB_TOKEN` is forwarded as `GITHUB_TOKEN`/`GH_TOKEN`. This is a + * deliberately narrow widening of the ENG-005 boundary: no other role's sandbox receives git/gh + * credentials, and the host's full `GITHUB_TOKEN` is never forwarded. */ -export function sandboxEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { +export function sandboxEnv( + source: NodeJS.ProcessEnv = process.env, + role?: string, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} for (const key of SANDBOX_ENV_ALLOWLIST) { const value = source[key] @@ -83,6 +91,10 @@ export function sandboxEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proc if (value !== undefined) env[key] = value } } + if (role === "pm" && source.AGENT_PM_GITHUB_TOKEN) { + env.GITHUB_TOKEN = source.AGENT_PM_GITHUB_TOKEN + env.GH_TOKEN = source.AGENT_PM_GITHUB_TOKEN + } return env } @@ -120,13 +132,19 @@ export class CliBackend implements WorkerBackend { buildSystemPrompt(input), ] - // Confine the agent's file/bash tools to a throwaway sandbox — never the repo source. - const sandbox = await createSandbox(this.workspaceRoot, input.role) + // ENG-001: when a workdir is provided (a cloned target repo in the working-code workspace) run + // there so the agent reads/edits real source; otherwise confine it to a throwaway agent-state + // sandbox — never this product's own source. Provided workdirs are persistent — don't clean up. + const useProvided = Boolean(input.workdir) + const cwd = input.workdir ?? (await createSandbox(this.workspaceRoot, input.role)) + const env = sandboxEnv(process.env, input.role) let stdout: string try { - stdout = await this.exec(args, input.task, sandbox, signal) + stdout = await this.exec(args, input.task, cwd, env, signal) } finally { - if (!process.env.AGENT_KEEP_WORKSPACE) await rm(sandbox, { recursive: true, force: true }) + if (!useProvided && !process.env.AGENT_KEEP_WORKSPACE) { + await rm(cwd, { recursive: true, force: true }) + } } let parsed: CliResult | undefined @@ -154,10 +172,16 @@ export class CliBackend implements WorkerBackend { } } - private exec(args: string[], stdin: string, cwd: string, signal?: AbortSignal): Promise { + private exec( + args: string[], + stdin: string, + cwd: string, + env: NodeJS.ProcessEnv, + signal?: AbortSignal, + ): Promise { return new Promise((resolve, reject) => { - // Scrubbed env (ENG-005): the agent's tools never see host secrets. - const child = spawn(this.bin, args, { cwd, signal, env: sandboxEnv() }) + // Scrubbed env (ENG-005); the PM also gets a scoped git/gh token (ENG-013). See sandboxEnv. + const child = spawn(this.bin, args, { cwd, signal, env }) let stdout = "" let stderr = "" child.stdout.on("data", (chunk) => { diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 1796cb3..9da07e0 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -20,7 +20,14 @@ export { parseProposal, proposeFileChanges, } from "./propose" +export { + type CloneResult, + ensureRepoCloned, + type RepoTarget, + repoTargetFromEnv, +} from "./provision" export { PROPOSAL_SCHEMA, TICKETS_SCHEMA, VERDICT_SCHEMA } from "./schemas" +export { repoWorkspacePath, resolveCodeWorkspaceRoot } from "./workspace" /** Factory for the default unit Worker runtime (mode from options or the WORKER_MODE env var). */ export function createWorker(options?: ClaudeWorkerOptions): Worker { diff --git a/packages/agents/src/propose.ts b/packages/agents/src/propose.ts index fe7086f..5738bd7 100644 --- a/packages/agents/src/propose.ts +++ b/packages/agents/src/propose.ts @@ -9,6 +9,8 @@ export interface ProposeChangesInput { /** What to build. */ task: string budgetCentsRemaining: number + /** Cloned target repo to work in (ENG-001) — the agent reads/edits real source there. */ + workdir?: string } export interface ProposedChanges { @@ -39,6 +41,7 @@ export async function proposeFileChanges(input: ProposeChangesInput): Promise { + it("is null when GitHub isn't configured", () => { + expect(repoTargetFromEnv({})).toBeNull() + expect(repoTargetFromEnv({ GITHUB_OWNER: "acme" })).toBeNull() + }) + + it("reads owner/repo and prefers the PM-scoped token over the host token", () => { + expect( + repoTargetFromEnv({ + GITHUB_OWNER: "acme", + GITHUB_REPO: "widget", + GITHUB_TOKEN: "host", + AGENT_PM_GITHUB_TOKEN: "scoped", + }), + ).toEqual({ owner: "acme", repo: "widget", token: "scoped" }) + }) +}) + +describe("provisionAction", () => { + it("clones a fresh dir and pulls an existing checkout", () => { + expect(provisionAction("/no/such/dir-xyz")).toBe("clone") + // The repo root itself has a .git → would be a pull (proves the existsSync branch). + expect(provisionAction(process.cwd())).toBe("pull") + }) +}) + +describe("gitArgs", () => { + it("builds a shallow clone with the auth header (token kept out of config)", () => { + const args = gitArgs("clone", "/code/acme/widget", { + owner: "acme", + repo: "widget", + token: "t0ken", + }) + expect(args).toContain("clone") + expect(args).toContain("--depth") + expect(args.join(" ")).toContain("https://github.com/acme/widget.git") + expect(args.join(" ")).toContain("http.extraheader=AUTHORIZATION: bearer t0ken") + }) + + it("builds a ff-only pull for an existing checkout, no auth header when tokenless", () => { + const args = gitArgs("pull", "/code/acme/widget", { owner: "acme", repo: "widget" }) + expect(args.slice(0, 2)).toEqual(["-C", "/code/acme/widget"]) + expect(args).toContain("pull") + expect(args).toContain("--ff-only") + expect(args.join(" ")).not.toContain("extraheader") + }) +}) + +describe("ensureRepoCloned", () => { + it("skips (returns null) when no target is configured", async () => { + await expect(ensureRepoCloned(null)).resolves.toBeNull() + }) +}) diff --git a/packages/agents/src/provision.ts b/packages/agents/src/provision.ts new file mode 100644 index 0000000..600fba1 --- /dev/null +++ b/packages/agents/src/provision.ts @@ -0,0 +1,74 @@ +import { spawn } from "node:child_process" +import { existsSync } from "node:fs" +import { mkdir } from "node:fs/promises" +import { dirname, join } from "node:path" +import { repoWorkspacePath } from "./workspace" + +/** + * Repository provisioning (ENG-013): clone the target repo into the working-code workspace so coding + * agents (ENG-001) have real source to read and edit. The PM agent owns this via its granted `git`/`gh` + * tools; this module is the *deterministic backbone* the lifecycle calls to guarantee (and verify) the + * clone exists regardless of the agent turn — idempotent, and a no-op when GitHub isn't configured. + */ + +export interface RepoTarget { + owner: string + repo: string + /** Scoped token for private clones; the PM-scoped token is preferred over the host delivery token. */ + token?: string +} + +/** Resolve the target repo from env (reuses the delivery config). Null when unconfigured (skip). */ +export function repoTargetFromEnv(env: NodeJS.ProcessEnv = process.env): RepoTarget | null { + const owner = env.GITHUB_OWNER + const repo = env.GITHUB_REPO + if (!owner || !repo) return null + return { owner, repo, token: env.AGENT_PM_GITHUB_TOKEN ?? env.GITHUB_TOKEN } +} + +export type ProvisionAction = "clone" | "pull" + +/** Pure idempotency decision: an existing checkout is pulled, otherwise cloned fresh. */ +export function provisionAction(dir: string): ProvisionAction { + return existsSync(join(dir, ".git")) ? "pull" : "clone" +} + +export interface CloneResult { + path: string + action: ProvisionAction +} + +/** `git` args for the action — token (if any) injected via http.extraheader so it never lands in + * `.git/config`. Pure, for testing. */ +export function gitArgs(action: ProvisionAction, dir: string, target: RepoTarget): string[] { + const auth = target.token ? ["-c", `http.extraheader=AUTHORIZATION: bearer ${target.token}`] : [] + if (action === "pull") return ["-C", dir, ...auth, "pull", "--ff-only"] + const url = `https://github.com/${target.owner}/${target.repo}.git` + return [...auth, "clone", "--depth", "1", url, dir] +} + +function runGit(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { stdio: "ignore" }) + child.on("error", reject) + child.on("close", (code) => + code === 0 ? resolve() : reject(new Error(`git exited with code ${code}`)), + ) + }) +} + +/** + * Ensure the target repo is cloned into the working-code workspace, idempotently. Returns the clone + * path + the action taken, or `null` when GitHub isn't configured (caller audits the skip). Operates + * only under the working-code workspace — never this product's own tree. + */ +export async function ensureRepoCloned( + target: RepoTarget | null = repoTargetFromEnv(), +): Promise { + if (!target) return null + const dir = repoWorkspacePath(target.owner, target.repo) + const action = provisionAction(dir) + if (action === "clone") await mkdir(dirname(dir), { recursive: true }) + await runGit(gitArgs(action, dir, target)) + return { path: dir, action } +} diff --git a/packages/agents/src/workspace.test.ts b/packages/agents/src/workspace.test.ts new file mode 100644 index 0000000..6f2d094 --- /dev/null +++ b/packages/agents/src/workspace.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from "vitest" +import { repoWorkspacePath, resolveCodeWorkspaceRoot } from "./workspace" + +describe("resolveCodeWorkspaceRoot", () => { + const prev = process.env.AGENT_CODE_WORKSPACE + afterEach(() => { + if (prev === undefined) delete process.env.AGENT_CODE_WORKSPACE + else process.env.AGENT_CODE_WORKSPACE = prev + }) + + it("honors AGENT_CODE_WORKSPACE", () => { + expect(resolveCodeWorkspaceRoot({ AGENT_CODE_WORKSPACE: "/tmp/code-ws" })).toBe("/tmp/code-ws") + }) + + it("defaults to a top-level workspace/ (singular, distinct from throwaway workspaces/)", () => { + const root = resolveCodeWorkspaceRoot({}) + expect(root.endsWith("/workspace")).toBe(true) + expect(root.endsWith("/workspaces")).toBe(false) + }) +}) + +describe("repoWorkspacePath", () => { + it("lays out clones as //", () => { + expect(repoWorkspacePath("acme", "widget", { AGENT_CODE_WORKSPACE: "/code" })).toBe( + "/code/acme/widget", + ) + }) +}) diff --git a/packages/agents/src/workspace.ts b/packages/agents/src/workspace.ts new file mode 100644 index 0000000..75d7207 --- /dev/null +++ b/packages/agents/src/workspace.ts @@ -0,0 +1,45 @@ +import { existsSync } from "node:fs" +import { dirname, join, resolve as pathResolve } from "node:path" + +/** + * The two sandboxed roots (ENG-013). Agents have two distinct filesystem areas, never conflated: + * + * 1. **Agent-state sandbox** — throwaway, per-run scratch under `workspaces/-xxxx` + * (`resolveWorkspaceRoot` in `backends/cli.ts`, env `AGENT_WORKSPACE_DIR`). Ephemeral; no product + * code lives here. + * 2. **Working-code workspace** — *persistent* shared root holding cloned target repos that agents + * inspect and modify (this module, env `AGENT_CODE_WORKSPACE`). + * + * This module owns the working-code root and the per-repo path layout. + */ + +/** Walk up to the monorepo root (the dir containing `pnpm-workspace.yaml`); falls back to `start`. */ +export function findRepoRoot(start: string = process.cwd()): string { + let dir = start + for (let i = 0; i < 8; i++) { + if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + return start +} + +/** + * Root of the persistent working-code workspace. Override with `AGENT_CODE_WORKSPACE`; otherwise a + * top-level `workspace/` (singular — deliberately distinct from the throwaway `workspaces/` plural). + */ +export function resolveCodeWorkspaceRoot(env: NodeJS.ProcessEnv = process.env): string { + const override = env.AGENT_CODE_WORKSPACE + if (override) return pathResolve(override) + return join(findRepoRoot(), "workspace") +} + +/** Path of a cloned target repo within the working-code workspace: `//`. */ +export function repoWorkspacePath( + owner: string, + repo: string, + env: NodeJS.ProcessEnv = process.env, +): string { + return join(resolveCodeWorkspaceRoot(env), owner, repo) +} diff --git a/packages/core/src/roles.ts b/packages/core/src/roles.ts index 9eb5491..632efc8 100644 --- a/packages/core/src/roles.ts +++ b/packages/core/src/roles.ts @@ -22,8 +22,8 @@ export const ROLES: Record = { id: "pm", title: "Product Manager", systemPrompt: - "You are the PM for the unit. Own discovery, requirements, prioritization, the roadmap, and acceptance criteria. Trace every item to a goal and the unit mission.", - tools: ["tracker", "docs"], + "You are the PM for the unit. Own discovery, requirements, prioritization, the roadmap, and acceptance criteria. Trace every item to a goal and the unit mission. You also provision the target repository: use `gh` to list existing repos and prefer reusing one; create it with `gh` only if it does not exist; and clone it into the working-code workspace so the engineers can read and modify real source. Operations must be idempotent — never clobber an existing repo or checkout.", + tools: ["tracker", "docs", "git", "gh"], monthlyBudgetCents: 50_00, ownsStages: ["discovery"], }, diff --git a/packages/core/src/worker.ts b/packages/core/src/worker.ts index 8491176..bc30726 100644 --- a/packages/core/src/worker.ts +++ b/packages/core/src/worker.ts @@ -22,6 +22,12 @@ export interface WorkerInput { * backend) ignore it and fall back to prompt-contract parsing. */ outputSchema?: Record + /** + * Optional working directory for the run (ENG-001). When set (e.g. a cloned target repo in the + * working-code workspace), the CLI backend runs the agent there — so it reads and edits real source + * — and does NOT clean it up. When unset, the backend uses a throwaway agent-state sandbox. + */ + workdir?: string } export interface ToolCallRecord { diff --git a/project/ENG-001-repo-aware-coding-agents.md b/project/ENG-001-repo-aware-coding-agents.md index 7f69b71..1f73b14 100644 --- a/project/ENG-001-repo-aware-coding-agents.md +++ b/project/ENG-001-repo-aware-coding-agents.md @@ -1,11 +1,23 @@ # ENG-001 — Give coding agents real repository context -- **Status:** backlog +- **Status:** done - **Priority:** P0 (Critical — blocks a usable autonomous run) - **Stage:** implementation - **Assignee role:** staff_engineer (with lead_system_design input) - **Area:** packages/agents +> **Outcome (Wave 2):** `WorkerInput.workdir` added; the **CLI backend** runs `claude -p` with its cwd +> set to the provided workdir (the cloned target repo from ENG-013) so the agent reads and edits real +> source — and a provided workdir is *persistent* (not cleaned up like the throwaway sandbox). +> `proposeFileChanges` forwards `workdir`; `implementTicket` clones the repo (ENG-013) and passes the +> path so the Staff Engineer codes against real files, auditing `repo_context_ready` / +> `repo_context_skipped`. The product's own tree is never touched (the agent works under the separate +> working-code workspace). Test: the agent reads a seeded file in the workdir and the workdir is left +> intact. +> +> *Deferred (noted):* the **API backend** is tool-less, so file-context injection for it (a directory +> tree / key files in the prompt) is a follow-up — the CLI backend is the real repo-aware path. + ## Problem Neither `Worker` backend can see the repository it is supposed to modify, so the coding agent writes diff --git a/project/ENG-013-repo-provisioning-and-shared-workspace.md b/project/ENG-013-repo-provisioning-and-shared-workspace.md index 06146f6..6400331 100644 --- a/project/ENG-013-repo-provisioning-and-shared-workspace.md +++ b/project/ENG-013-repo-provisioning-and-shared-workspace.md @@ -1,6 +1,6 @@ # ENG-013 — Repository provisioning via the PM agent (git + gh) & shared workspace -- **Status:** backlog +- **Status:** done - **Priority:** P0 (Critical — prerequisite for ENG-001) - **Stage:** discovery → implementation - **Assignee role:** pm (owns repo create / list / clone); supporting backend work (per-role tool @@ -8,6 +8,27 @@ - **Area:** packages/core (role config) · packages/agents (CLI backend tool gating + auth) · apps/server (shared workspace + audit) +> **Outcome (Wave 2):** +> - **Two-root workspace** — `packages/agents/src/workspace.ts` (`resolveCodeWorkspaceRoot` / +> `repoWorkspacePath`): a *persistent* working-code root (`workspace/` singular, env +> `AGENT_CODE_WORKSPACE`) for cloned target repos, distinct from the throwaway `workspaces/` +> agent-state sandboxes. `/workspace/` gitignored. +> - **PM tool grant** — `ROLES.pm.tools` now includes `git` + `gh`, and the PM prompt covers +> list/reuse/create/clone (idempotent). +> - **Scoped-token enforcement** — `sandboxEnv(source, role)` forwards `AGENT_PM_GITHUB_TOKEN` as +> `GITHUB_TOKEN`/`GH_TOKEN` **only** to the PM sandbox; the host `GITHUB_TOKEN` is never forwarded to +> any agent. This is the real security boundary (only the PM can authenticate git/gh). +> - **Idempotent provisioning** — `provision.ts` (`repoTargetFromEnv` / `provisionAction` / `gitArgs` / +> `ensureRepoCloned`): shallow clone-or-ff-pull into the working-code workspace, token passed via +> `http.extraheader` (never written to `.git/config`); no-op when GitHub unconfigured. Wired into +> `implementTicket` with `repo_context_ready` / `repo_context_skipped` audit events. +> - Tests: workspace layout, provisioning (skip/action/args), PM-scoped token. +> +> *Deferred (noted):* (1) literal `claude --allowedTools` tool-name gating — the security boundary is +> enforced via the scoped token, so the abstract→Claude-Code tool-name mapping is a follow-up; (2) +> per-epic repo *association* — the target repo is the configured `GITHUB_OWNER`/`GITHUB_REPO` (same as +> delivery) for now; moving repo selection onto the Goal/Epic is a follow-up. + ## Decision Per the product owner's direction, the **PM agent owns repository provisioning** and is granted two diff --git a/project/ROADMAP.md b/project/ROADMAP.md index 6a0d4d3..749fdde 100644 --- a/project/ROADMAP.md +++ b/project/ROADMAP.md @@ -33,9 +33,12 @@ high-leverage enablers forward even when labelled P1/P2. parse-failure vs empty-set audited distinctly). - **ENG-005** ✅ — Scrub the CLI sandbox env (allow-listed `sandboxEnv()`; host secrets withheld). -### Wave 2 — Repo-context critical path -- **ENG-013** — Repo provisioning (PM `git`/`gh`) + two-root workspace. Needs ENG-005. -- **ENG-001** — Repo-aware coding agents. Needs ENG-013; benefits from ENG-009. +### Wave 2 — Repo-context critical path — ✅ complete (2026-06-27) +- **ENG-013** ✅ — Two-root workspace + PM `git`/`gh` grant + PM-scoped token + idempotent + `ensureRepoCloned`, wired+audited in `implementTicket`. (Deferred: literal `--allowedTools` gating; + per-epic repo association.) +- **ENG-001** ✅ — `WorkerInput.workdir`; CLI backend runs in the cloned repo cwd so agents read/edit + real source. (Deferred: API-backend file-context injection.) ### Wave 3 — Governance / observability - **ENG-007** — Budget model (reset + reservation). Do first in this wave — gates 010 and 008. From 624d363ec663aa0192bc8ce30f84f7000e3a722f Mon Sep 17 00:00:00 2001 From: Toby V Date: Sat, 27 Jun 2026 13:36:21 +1000 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20Wave=203=20=E2=80=94=20budgets,=20c?= =?UTF-8?q?yclic=20rework,=20first-class=20approvals,=20budget=20dashboard?= =?UTF-8?q?=20(ENG-007/008/006/010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ENG-007: monthly budget reset (period_start, migration 0003, lazy rollover) + atomic reserveBudget/reconcileSpend wired across all agent activities; estimateRunCostCents sizes the hold; pure periodExpired/canReserve tested. ENG-008: bounded implement->review->QA rework loop in ticketLifecycle with QA feedback threaded into re-implementation, then blocked; verifyTicket returns {passed,feedback}; harness tests. ENG-006: first-class persisted approvals (added epic_id, migration 0004); createApproval/resolveApproval/listPendingApprovals; pending records per gate resolved with decidedBy; /api/approvals covers roadmap/merge/deploy; web Approvals handles all kinds. ENG-010: /api/budgets + web Budgets page. typecheck + lint + 94 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/app.ts | 36 +- apps/server/src/temporal/activities.ts | 103 ++- apps/server/src/temporal/workflows.test.ts | 35 +- apps/server/src/temporal/workflows.ts | 31 +- apps/web/src/api.ts | 17 +- apps/web/src/pages/Approvals.tsx | 34 +- apps/web/src/pages/Budgets.tsx | 60 ++ apps/web/src/router.tsx | 17 +- packages/agents/src/index.ts | 1 + packages/agents/src/pricing.test.ts | 13 + packages/agents/src/pricing.ts | 16 + packages/core/src/budget.test.ts | 37 ++ packages/core/src/budget.ts | 20 + packages/core/src/schema.ts | 1 + packages/db/drizzle/0003_narrow_surge.sql | 1 + .../db/drizzle/0004_tidy_next_avengers.sql | 2 + packages/db/drizzle/meta/0003_snapshot.json | 579 +++++++++++++++++ packages/db/drizzle/meta/0004_snapshot.json | 594 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 14 + packages/db/src/repo.ts | 150 ++++- packages/db/src/schema.ts | 3 + project/ENG-006-first-class-approvals.md | 10 +- ...-007-budget-model-reset-and-reservation.md | 11 +- project/ENG-008-cyclic-rework-routing.md | 10 +- project/ENG-010-dashboard-budgets-and-cost.md | 7 +- project/ROADMAP.md | 12 +- 26 files changed, 1738 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/pages/Budgets.tsx create mode 100644 packages/agents/src/pricing.test.ts create mode 100644 packages/core/src/budget.test.ts create mode 100644 packages/db/drizzle/0003_narrow_surge.sql create mode 100644 packages/db/drizzle/0004_tidy_next_avengers.sql create mode 100644 packages/db/drizzle/meta/0003_snapshot.json create mode 100644 packages/db/drizzle/meta/0004_snapshot.json diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 40848f5..6b20b71 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,3 +1,4 @@ +import { listBudgets, listPendingApprovals, resolveApproval } from "@eng/db" import { Hono } from "hono" import { type AppEnv, authMiddleware } from "./auth" import { persistenceFromEnv } from "./persistence" @@ -29,27 +30,10 @@ app.use("/api/*", authMiddleware()) app.get("/api/tickets", async (c) => c.json({ tickets: await persistence.tracker.list() })) app.get("/api/audit", async (c) => c.json({ events: await persistence.audit.query() })) -// Pending human gates, derived from the audit log. A roadmap sign-off is pending when the latest -// roadmap-lifecycle event for an epic is `roadmap_requested` (not yet approved/decomposed). -app.get("/api/approvals", async (c) => { - const events = await persistence.audit.query() - const seen = new Set() - const pending: { epicId: string; kind: string; at: string }[] = [] - for (const e of events) { - const epicId = typeof e.payload?.epicId === "string" ? (e.payload.epicId as string) : null - if (!epicId || seen.has(epicId)) continue - if ( - e.kind === "roadmap_requested" || - e.kind === "roadmap_approved" || - e.kind === "epic_decomposed" || - e.kind === "decomposition_skipped" - ) { - seen.add(epicId) - if (e.kind === "roadmap_requested") pending.push({ epicId, kind: "roadmap", at: e.at }) - } - } - return c.json({ approvals: pending }) -}) +// Pending human gates as first-class records (ENG-006): roadmap · pr_merge · deploy. +app.get("/api/approvals", async (c) => c.json({ approvals: await listPendingApprovals() })) +// Per-role budget/cost view for the dashboard (ENG-010). +app.get("/api/budgets", async (c) => c.json({ budgets: await listBudgets() })) // Goal hierarchy authoring (through the persistence port, so the backend is swappable). Lets the // human/PM decompose work under multiple goals + epics; tickets then target a chosen epic. @@ -111,7 +95,8 @@ app.post("/api/epics/:id/approve-roadmap", async (c) => { const id = c.req.param("id") const signaled = await approveRoadmap(id) const by = c.get("actor") - // Record who released the gate (ENG-004) — append-only, so the audit log carries the approver. + // Resolve the first-class approval record (ENG-006) + audit who released the gate (ENG-004). + await resolveApproval({ kind: "roadmap", epicId: id, decidedBy: by }) await persistence.audit.append({ actor: "human", kind: "approval_decided", @@ -158,7 +143,12 @@ app.post("/api/tickets/:id/approve", async (c) => { const gate = body.gate === "deploy" ? "deploy" : "merge" await approveTicket(id, gate) const by = c.get("actor") - // Record who released the gate (ENG-004). + // Resolve the first-class approval record (ENG-006) + audit who released the gate (ENG-004). + await resolveApproval({ + kind: gate === "deploy" ? "deploy" : "pr_merge", + ticketId: id, + decidedBy: by, + }) await persistence.audit.append({ actor: "human", kind: "approval_decided", diff --git a/apps/server/src/temporal/activities.ts b/apps/server/src/temporal/activities.ts index e053a2a..2e5a422 100644 --- a/apps/server/src/temporal/activities.ts +++ b/apps/server/src/temporal/activities.ts @@ -1,4 +1,11 @@ -import { assess, draft, ensureRepoCloned, proposeFileChanges, proposeTickets } from "@eng/agents" +import { + assess, + draft, + ensureRepoCloned, + estimateRunCostCents, + proposeFileChanges, + proposeTickets, +} from "@eng/agents" import { type DeliveryAdapter, type DeployState, @@ -6,7 +13,7 @@ import { ROLES, type TicketStatus, } from "@eng/core" -import { addSpend, getBudgetRemaining } from "@eng/db" +import { createApproval, getBudgetRemaining, reconcileSpend, reserveBudget } from "@eng/db" import { createGitHubDelivery } from "@eng/integrations" import { persistenceFromEnv } from "../persistence" import { artifactPath, SHAPING_STAGES } from "../shaping" @@ -51,6 +58,16 @@ export async function runShapingStage(epicId: string, stageKey: string): Promise if (!stage) return const role = ROLES[stage.role] const remaining = (await getBudgetRemaining(role.id)) ?? role.monthlyBudgetCents + const held = await reserveBudget(role.id, estimateRunCostCents(remaining)) + if (held === null) { + await persistence.audit.append({ + actor: "system", + kind: "artifact_skipped", + ticketId: null, + payload: { epicId, stage: stageKey, reason: "budget_exhausted" }, + }) + return + } const epicCtx = await persistence.hierarchy.epicContext(epicId) const prior: string[] = [] @@ -69,7 +86,7 @@ export async function runShapingStage(epicId: string, stageKey: string): Promise task: stage.task, budgetCentsRemaining: remaining, }) - await addSpend(role.id, result.costCents) + await reconcileSpend(role.id, held, result.costCents) await persistence.knowledge.write( artifactPath(epicId, stageKey), `# ${stage.title}\n\n${result.content}\n`, @@ -81,6 +98,7 @@ export async function runShapingStage(epicId: string, stageKey: string): Promise payload: { epicId, stage: stageKey, costCents: result.costCents }, }) } catch (err) { + await reconcileSpend(role.id, held, 0) // release the hold await persistence.audit.append({ actor: "system", kind: "artifact_skipped", @@ -90,8 +108,10 @@ export async function runShapingStage(epicId: string, stageKey: string): Promise } } -/** Record that an epic's plan is awaiting the human roadmap sign-off (the gate is pending). */ +/** Record that an epic's plan is awaiting the human roadmap sign-off (the gate is pending). Creates a + * first-class pending approval (ENG-006) plus the audit event. */ export async function requestRoadmapSignoff(epicId: string): Promise { + await createApproval({ kind: "roadmap", epicId, requestedByRole: "lead_engineer" }) await persistence.audit.append({ actor: "lead_engineer", kind: "roadmap_requested", @@ -100,6 +120,14 @@ export async function requestRoadmapSignoff(epicId: string): Promise { }) } +/** Create a pending approval record for a ticket gate (merge/deploy) — ENG-006. */ +export async function requestApproval( + kind: "pr_merge" | "deploy", + ticketId: string, +): Promise { + await createApproval({ kind, ticketId, requestedByRole: "lead_engineer" }) +} + /** Record the human roadmap sign-off (the gate released decomposition). */ export async function recordRoadmapApproval(epicId: string): Promise { await persistence.audit.append({ @@ -125,6 +153,16 @@ export async function decomposeEpic(epicId: string): Promise { ]) const goalContext = [epicCtx, ...artifacts].join("\n\n") const remaining = (await getBudgetRemaining(role.id)) ?? role.monthlyBudgetCents + const held = await reserveBudget(role.id, estimateRunCostCents(remaining)) + if (held === null) { + await persistence.audit.append({ + actor: "system", + kind: "decomposition_skipped", + ticketId: null, + payload: { epicId, reason: "budget_exhausted" }, + }) + return [] + } let proposed: Awaited> try { @@ -135,8 +173,9 @@ export async function decomposeEpic(epicId: string): Promise { task: "Decompose this epic into implementable tickets.", budgetCentsRemaining: remaining, }) - await addSpend(role.id, proposed.costCents) + await reconcileSpend(role.id, held, proposed.costCents) } catch (err) { + await reconcileSpend(role.id, held, 0) // release the hold await persistence.audit.append({ actor: "system", kind: "decomposition_skipped", @@ -202,7 +241,10 @@ const DEPLOY_REF = process.env.GITHUB_DEPLOY_REF ?? BASE_BRANCH * — if GitHub is configured — the changes are committed to a ticket branch and a PR is opened. * Returns the PR (for the later merge) or null. */ -export async function implementTicket(ticketId: string): Promise { +export async function implementTicket( + ticketId: string, + feedback?: string, +): Promise { const role = ROLES.staff_engineer const [ticket, goalContext] = await Promise.all([ persistence.tracker.get(ticketId), @@ -213,6 +255,19 @@ export async function implementTicket(ticketId: string): Promise { +export async function verifyTicket( + ticketId: string, +): Promise<{ passed: boolean; feedback: string }> { const role = ROLES.qa_test const remaining = (await getBudgetRemaining(role.id)) ?? role.monthlyBudgetCents + const held = await reserveBudget(role.id, estimateRunCostCents(remaining)) + if (held === null) { + await persistence.audit.append({ + actor: "system", + kind: "qa_skipped", + ticketId, + payload: { reason: "budget_exhausted" }, + }) + return { passed: true, feedback: "" } + } const [ticket, goalContext] = await Promise.all([ persistence.tracker.get(ticketId), persistence.hierarchy.traceContext(ticketId), @@ -372,22 +446,23 @@ export async function verifyTicket(ticketId: string): Promise { : `Verify ticket ${ticketId}.`, budgetCentsRemaining: remaining, }) - await addSpend(role.id, verdict.costCents) + await reconcileSpend(role.id, held, verdict.costCents) await persistence.audit.append({ actor: "qa_test", kind: verdict.passed ? "qa_passed" : "qa_failed", ticketId, payload: { summary: verdict.summary, costCents: verdict.costCents }, }) - return verdict.passed + return { passed: verdict.passed, feedback: verdict.summary } } catch (err) { + await reconcileSpend(role.id, held, 0) // release the hold await persistence.audit.append({ actor: "system", kind: "qa_skipped", ticketId, payload: { error: String(err) }, }) - return true + return { passed: true, feedback: "" } } } diff --git a/apps/server/src/temporal/workflows.test.ts b/apps/server/src/temporal/workflows.test.ts index 9ea58c0..ff27917 100644 --- a/apps/server/src/temporal/workflows.test.ts +++ b/apps/server/src/temporal/workflows.test.ts @@ -44,7 +44,7 @@ function makeActivities(overrides: Record = {}) { url: "http://pr/1", branch: "ticket/t", })), - verifyTicket: vi.fn(async (_id: string) => true), + verifyTicket: vi.fn(async (_id: string) => ({ passed: true, feedback: "" })), checkDeliveryStatus: vi.fn(async (_pr: unknown) => "success"), mergeDelivery: vi.fn(async (_id: string, _pr: unknown) => true), startDeploy: vi.fn(async (_id: string) => 42), @@ -55,6 +55,7 @@ function makeActivities(overrides: Record = {}) { runShapingStage: vi.fn(async (_epicId: string, _stageKey: string) => {}), requestRoadmapSignoff: vi.fn(async (_epicId: string) => {}), recordRoadmapApproval: vi.fn(async (_epicId: string) => {}), + requestApproval: vi.fn(async (_kind: string, _ticketId: string) => {}), ...overrides, } } @@ -113,22 +114,48 @@ describe("ticketLifecycle", () => { expect(acts.transitionTicket).toHaveBeenCalledWith("t1", "done") }, 30_000) - it("blocks the ticket when QA fails (before any gate)", async () => { - const acts = makeActivities({ verifyTicket: vi.fn(async (_id: string) => false) }) + it("reworks up to the attempt limit on persistent QA failure, then blocks", async () => { + const acts = makeActivities({ + verifyTicket: vi.fn(async (_id: string) => ({ passed: false, feedback: "still broken" })), + }) await withWorker(acts, async () => { const handle = await env.client.workflow.start(ticketLifecycle, { taskQueue: TQ, workflowId: nextId("ticket"), args: ["t1"], }) - await handle.result() // returns without any signal — QA fail short-circuits + await handle.result() // no signal needed — exhausts rework attempts then blocks }) + expect(acts.implementTicket).toHaveBeenCalledTimes(2) // bounced back and retried (ENG-008) expect(acts.transitionTicket).toHaveBeenCalledWith("t1", "blocked") expect(acts.mergeDelivery).not.toHaveBeenCalled() expect(acts.transitionTicket).not.toHaveBeenCalledWith("t1", "done") }, 30_000) + it("reworks once with the QA feedback, then reaches done on a pass", async () => { + const verifyTicket = vi + .fn() + .mockResolvedValueOnce({ passed: false, feedback: "add a test" }) + .mockResolvedValue({ passed: true, feedback: "" }) + const acts = makeActivities({ verifyTicket }) + await withWorker(acts, async () => { + const handle = await env.client.workflow.start(ticketLifecycle, { + taskQueue: TQ, + workflowId: nextId("ticket"), + args: ["t1"], + }) + await handle.signal(approveSignal, "merge") + await handle.signal(approveSignal, "deploy") + await handle.result() + }) + + expect(acts.implementTicket).toHaveBeenCalledTimes(2) // reworked once + expect(acts.implementTicket).toHaveBeenLastCalledWith("t1", "add a test") // fed the feedback + expect(acts.transitionTicket).toHaveBeenCalledWith("t1", "done") + expect(acts.transitionTicket).not.toHaveBeenCalledWith("t1", "blocked") + }, 30_000) + it("blocks the ticket when the merge fails", async () => { const acts = makeActivities({ mergeDelivery: vi.fn(async (_id: string, _pr: unknown) => false), diff --git a/apps/server/src/temporal/workflows.ts b/apps/server/src/temporal/workflows.ts index 78cc24d..440afac 100644 --- a/apps/server/src/temporal/workflows.ts +++ b/apps/server/src/temporal/workflows.ts @@ -16,6 +16,7 @@ const { runShapingStage, requestRoadmapSignoff, recordRoadmapApproval, + requestApproval, } = proxyActivities({ startToCloseTimeout: "10 minutes", retry: { maximumAttempts: 3 }, @@ -68,6 +69,8 @@ const CI_POLL_INTERVAL = "30 seconds" const CI_MAX_POLLS = 20 const DEPLOY_POLL_INTERVAL = "30 seconds" const DEPLOY_MAX_POLLS = 20 +/** Bounded implement→review→QA rework attempts before a ticket is blocked for a human (ENG-008). */ +const MAX_IMPLEMENT_ATTEMPTS = 2 /** * The ticket lifecycle as a durable state machine, with the full delivery + deploy loop: @@ -89,15 +92,21 @@ export async function ticketLifecycle(ticketId: string): Promise { await transitionTicket(ticketId, "planned") await sleep("2 seconds") - await transitionTicket(ticketId, "in_progress") - // Agent writes the code and (when GitHub is configured) pushes a branch + opens a PR. - const pr = await implementTicket(ticketId) - await transitionTicket(ticketId, "in_review") - - // QA quality gate: the QA agent verifies the acceptance criteria before review/merge. - const qaOk = await verifyTicket(ticketId) - if (!qaOk) { + // Cyclic rework (ENG-008): implement → review → QA, bouncing back to in_progress with the QA + // feedback on a fail, up to MAX_IMPLEMENT_ATTEMPTS, then block for human attention. + let pr: Awaited> = null + let passed = false + let feedback: string | undefined + for (let attempt = 1; attempt <= MAX_IMPLEMENT_ATTEMPTS && !passed; attempt++) { + await transitionTicket(ticketId, "in_progress") + pr = await implementTicket(ticketId, feedback) + await transitionTicket(ticketId, "in_review") + const qa = await verifyTicket(ticketId) + passed = qa.passed + feedback = qa.feedback + } + if (!passed) { await transitionTicket(ticketId, "blocked") return } @@ -111,7 +120,8 @@ export async function ticketLifecycle(ticketId: string): Promise { } } - // Merge gate: block until the human lead signs off on the merge. + // Merge gate: record a pending approval (ENG-006), then block until the human lead signs off. + await requestApproval("pr_merge", ticketId) await condition(() => approved.merge) const merged = pr ? await mergeDelivery(ticketId, pr) : true if (!merged) { @@ -119,8 +129,9 @@ export async function ticketLifecycle(ticketId: string): Promise { return } - // Ship: human-gated deploy. + // Ship: human-gated deploy (record a pending approval first, ENG-006). await transitionTicket(ticketId, "deploying") + await requestApproval("deploy", ticketId) await condition(() => approved.deploy) const afterRunId = await startDeploy(ticketId) if (afterRunId !== null) { diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index f586c2b..ecd0c2b 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,10 +1,11 @@ -import type { AuditEvent, HierarchyNode, Ticket } from "@eng/core" +import type { Approval, AuditEvent, HierarchyNode, Ticket } from "@eng/core" -/** A human gate awaiting sign-off (derived server-side from the audit log). */ -export interface PendingApproval { - epicId: string - kind: string - at: string +/** Per-scope budget summary for the dashboard (ENG-010). */ +export interface BudgetSummary { + scope: string + limitCents: number + spentCents: number + remainingCents: number } async function getJson(path: string): Promise { @@ -27,8 +28,8 @@ async function postJson(path: string, body?: unknown): Promise { export const api = { health: () => getJson<{ ok: boolean }>("/health"), tickets: () => getJson<{ tickets: Ticket[] }>("/api/tickets").then((r) => r.tickets), - approvals: () => - getJson<{ approvals: PendingApproval[] }>("/api/approvals").then((r) => r.approvals), + approvals: () => getJson<{ approvals: Approval[] }>("/api/approvals").then((r) => r.approvals), + budgets: () => getJson<{ budgets: BudgetSummary[] }>("/api/budgets").then((r) => r.budgets), audit: () => getJson<{ events: AuditEvent[] }>("/api/audit").then((r) => r.events), goals: () => getJson<{ goals: HierarchyNode[] }>("/api/goals").then((r) => r.goals), epics: (goalId?: string) => diff --git a/apps/web/src/pages/Approvals.tsx b/apps/web/src/pages/Approvals.tsx index a8f09c0..64cf32e 100644 --- a/apps/web/src/pages/Approvals.tsx +++ b/apps/web/src/pages/Approvals.tsx @@ -1,6 +1,23 @@ +import type { Approval } from "@eng/core" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { api } from "../api" +const LABELS: Record = { + roadmap: "Roadmap sign-off", + pr_merge: "Merge", + deploy: "Deploy", + design_signoff: "Design sign-off", + architecture_decision: "Architecture decision", +} + +/** Dispatch the right approve call for a pending gate (ENG-006: roadmap · merge · deploy). */ +function decide(a: Approval): Promise { + if (a.kind === "roadmap" && a.epicId) return api.approveRoadmap(a.epicId) + if (a.kind === "pr_merge" && a.ticketId) return api.approve(a.ticketId, "merge") + if (a.kind === "deploy" && a.ticketId) return api.approve(a.ticketId, "deploy") + return Promise.resolve() +} + export function Approvals() { const qc = useQueryClient() const { data, isLoading, error } = useQuery({ @@ -9,7 +26,7 @@ export function Approvals() { refetchInterval: 1500, }) const approve = useMutation({ - mutationFn: (epicId: string) => api.approveRoadmap(epicId), + mutationFn: decide, onSuccess: () => { qc.invalidateQueries({ queryKey: ["approvals"] }) qc.invalidateQueries({ queryKey: ["tickets"] }) @@ -28,16 +45,23 @@ export function Approvals() { ) : (
    {approvals.map((a) => ( -
  • +
  • - Roadmap sign-off{" "} - · epic {a.epicId.slice(0, 8)} + {LABELS[a.kind] ?? a.kind}{" "} + + ·{" "} + {a.ticketId + ? `ticket ${a.ticketId.slice(0, 8)}` + : a.epicId + ? `epic ${a.epicId.slice(0, 8)}` + : ""} +