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/app.ts b/apps/server/src/app.ts index a958a53..6b20b71 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,6 @@ +import { listBudgets, listPendingApprovals, resolveApproval } from "@eng/db" import { Hono } from "hono" +import { type AppEnv, authMiddleware } from "./auth" import { persistenceFromEnv } from "./persistence" import { artifactPath, SHAPING_STAGES } from "./shaping" import { @@ -16,35 +18,22 @@ 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 -// 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. @@ -105,7 +94,16 @@ 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") + // 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", + 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 +142,18 @@ 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") + // 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", + 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..2e5a422 100644 --- a/apps/server/src/temporal/activities.ts +++ b/apps/server/src/temporal/activities.ts @@ -1,4 +1,11 @@ -import { assess, draft, 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,18 +255,68 @@ export async function implementTicket(ticketId: string): Promise> | null = null try { proposed = await proposeFileChanges({ role: role.id, systemPrompt: role.systemPrompt, goalContext, - task: ticket - ? `Implement ticket "${ticket.title}": ${ticket.description || "(no description provided)"}.` - : `Implement ticket ${ticketId}.`, + task: `${ + ticket + ? `Implement ticket "${ticket.title}": ${ticket.description || "(no description provided)"}.` + : `Implement ticket ${ticketId}.` + }${ + feedback + ? `\n\nThis is a rework attempt. Address the QA feedback from the previous attempt: ${feedback}` + : "" + }`, budgetCentsRemaining: remaining, + workdir, }) - await addSpend(role.id, proposed.costCents) + await reconcileSpend(role.id, held, proposed.costCents) await persistence.audit.append({ actor: "staff_engineer", kind: "agent_step", @@ -238,6 +330,7 @@ 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), @@ -335,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)}` + : ""} +