diff --git a/apps/docs/docs/concepts/inbox.md b/apps/docs/docs/concepts/inbox.md index 637ab5bb..3ab10e16 100644 --- a/apps/docs/docs/concepts/inbox.md +++ b/apps/docs/docs/concepts/inbox.md @@ -27,8 +27,11 @@ Facility implements a typed, auditable HITL model: cancelled / expired, then executed or execution-failed. Who decided, when, and why is never reconstructable-only — it's recorded. - **Plan acceptance** is executable: approving a platform-lane plan creates - and queues the builder run linked to the architect run. In the repo lane, - the human invokes `/builder` in GitHub, which is the recorded Gate 1 action. + and queues the builder run linked to the architect run. The current repo-lane + workflow executes outside this control-plane boundary and is therefore not + compatible with `builderPlanPolicy=required`; required projects must route + both Builder commands to the platform lane. A later signed repo-lane handshake + can extend the same invariant without treating a slash command as a proposal. ## Design intent diff --git a/apps/docs/docs/concepts/projects-and-governance.md b/apps/docs/docs/concepts/projects-and-governance.md index 08f03332..047dee4c 100644 --- a/apps/docs/docs/concepts/projects-and-governance.md +++ b/apps/docs/docs/concepts/projects-and-governance.md @@ -43,3 +43,94 @@ never silently overwritten. The fingerprint advances only when the PR merges. Every project records its system version. Org policy can pin projects, preview diffs between versions, and roll forward on your schedule — the whole method is data, not folklore. + +## Required Architect plans for Builder + +Projects can set `builderPlanPolicy` to `optional` or `required`. The default is +`optional`, including for projects created before this setting existed, so an +upgrade does not silently change their dispatch behavior. + +Within the Facility platform lane, `required` makes Gate 1 a runtime invariant. A Builder can only be created by +the internal executor for an approved `plan_acceptance` proposal with trusted +freshness evidence. Run-now, +Story Build, GitHub slash commands, MCP tools, resumes, schedules, and inbound +integrations cannot create an unlinked Builder run. There is no break-glass +route. + +Facility accepts `required` only when every connected repository has a recent +`ok` fingerprint from its default branch and routes both `builder` and +`codex-builder` to `platform`. Connecting another repository requires switching +back to `optional`, connecting and verifying its platform configuration, then +re-enabling the gate. The generated repo-lane workflow does not yet call this +policy and must not remain enabled for Builder on a required project. +If the default-branch manifest later removes either platform lane, Facility +marks the repository fingerprint drifted and rejects the synchronized config; +restore and verify the governed files before dispatching again. +Managed pushes mark the fingerprint pending before asynchronous verification, +and approval rechecks the cached lane plus a recent `ok` fingerprint. Approval +also reads the repository's live default-branch SHA and the live GitHub issue +revision; a cached mirror or caller-supplied value is never accepted as +freshness evidence. + +Policy activation, run admission, repository mutation, and Builder-relevant +agent mutations share a per-project transaction lock. Admission also persists +an immutable canonical `builder` or `codex-builder` run mode; later edits to an +agent name or command trigger cannot erase the role the run was admitted as. +Enabling `required` conservatively refuses to proceed while any older run is +non-terminal. This covers legacy rows created before immutable admission and +prevents a run that observed `optional` from crossing the activation boundary. + +Denials use stable API and audit codes: + +- `builder_plan_required` when no acceptance was supplied; +- `builder_plan_expired`, `builder_plan_rejected`, or + `builder_plan_already_consumed` for proposal lifecycle failures; +- `builder_plan_stale` when the recorded base or issue revision changed; +- `builder_plan_freshness_unavailable` when Facility cannot prove that those + revisions are still current; and +- `builder_plan_context_invalid` for malformed or non-canonical provenance. + +The acceptance records the exact plan SHA-256, approving human principal and time, +Architect receipt, repository, and issue. Base-commit provenance must come from +the workspace/base tracking contract, and issue freshness must come from a live, +canonical digest of the exact Architect issue scope rather than the mutable +mirror timestamp. If either trusted provider is unavailable, `required` denies +Builder with `builder_plan_freshness_unavailable`; it never falls back to caller +input. +For a required project, an API key or agent cannot supply the approval event; +the approving principal must be a Facility user or the authenticated GitHub +human who issued the canonical `/builder` command, and must be distinct from +the proposal opener. + +Architect records the checked-out default-branch SHA and a versioned canonical +digest of the issue title, body, state, author, URL, labels, and material +comments. Facility excludes its own progress/publication comments and exact +approval-only Builder comments from that digest. At approval, on worker receipt, +and once more after the worker atomically claims the run, Facility re-reads the +live branch and issue. A mismatch returns `builder_plan_stale`; an unavailable +read returns `builder_plan_freshness_unavailable`, before credentials or a +sandbox are released. The runner also clones with the approved SHA as its +expected head, so a later default-branch movement aborts before the model runs. + +Proposals created before this coherent deployment are not backfilled. Run +Architect again, approve the newly published proposal, and do not edit or reuse +an old plan. The approved plan body and its SHA-256 remain immutable even though +GitHub cannot atomically lock an issue: an issue edit after the worker's final +freshness read cannot be excluded atomically, but it cannot rewrite the scope +that Builder receives. + +Architect plan publication is a durable, retryable delivery. A scheduled +reconciler resumes terminal Architect runs whose plan comment was not recorded, +uses a stable publication marker to discover a comment created during an +ambiguous response, and closes or suppresses stale publications when their +proposal is no longer open. GitHub comment creation offers no idempotency key, +so delivery remains technically at-least-once; the stable marker, bounded retry, +and reconciler make duplicate publication observable and recoverable. + +Two operational follow-ups remain. A generic inbound event +denied by the gate stays unprocessed, and replaying the same delivery ID does +not automatically execute it again; an operator must create a fresh delivery +after correcting the source workflow. Also, the web UI disables Builder trigger +controls while `required` is active, including editing an existing Builder +schedule. Remove or change that schedule before activation (or temporarily +return the project to `optional` through the governed project settings flow). diff --git a/apps/web/app/(app)/projects/[projectId]/agents/[agentId]/page.tsx b/apps/web/app/(app)/projects/[projectId]/agents/[agentId]/page.tsx index eb899d67..c913df43 100644 --- a/apps/web/app/(app)/projects/[projectId]/agents/[agentId]/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/agents/[agentId]/page.tsx @@ -11,12 +11,13 @@ export default async function AgentDetailPage({ params: Promise<{ projectId: string; agentId: string }>; }) { const { projectId, agentId } = await params; - const [agents, status, catalog, me, profiles] = await Promise.all([ + const [agents, status, catalog, me, profiles, project] = await Promise.all([ api.projectAgents(projectId), api.agentsStatus(projectId), api.catalog(), api.me(), api.sandboxProfiles(), + api.project(projectId), ]); if (!agents.ok) return agents.offline ? : ; @@ -42,6 +43,7 @@ export default async function AgentDetailPage({ myPermissions={me.ok ? me.data.permissions : []} sandboxProfiles={profiles.ok ? profiles.data.map((p) => ({ id: p.id, name: p.name })) : []} recentRuns={runs.ok ? runs.data : []} + builderPlanPolicy={project.ok ? project.data.builderPlanPolicy : "required"} /> ); diff --git a/apps/web/app/(app)/projects/[projectId]/settings/page.tsx b/apps/web/app/(app)/projects/[projectId]/settings/page.tsx index abb91b34..55666999 100644 --- a/apps/web/app/(app)/projects/[projectId]/settings/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/settings/page.tsx @@ -91,7 +91,11 @@ export default async function ProjectSettingsPage({
gates - +
diff --git a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx index 43c432c9..f40c7cb6 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/[number]/page.tsx @@ -42,13 +42,14 @@ export default async function StoryPage({ ...(storyType ? { storyType } : {}), }; - const [detail, inbox, outcomes, activity, pipeline, me] = await Promise.all([ + const [detail, inbox, outcomes, activity, pipeline, me, project] = await Promise.all([ api.story(projectId, number, query), api.inboxAll(), api.outcomes(`?state=all&projectId=${projectId}&limit=200`), api.storyGithubActivity(projectId, number, query), api.pipeline(projectId), api.me(), + api.project(projectId), ]); if (!detail.ok) { @@ -139,6 +140,7 @@ export default async function StoryPage({ projectId={projectId} issueNumber={story.number} repoId={story.repoId} + builderPlanRequired={!project.ok || project.data.builderPlanPolicy === "required"} /> ) : null} diff --git a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx index 839a5b69..03e4c41d 100644 --- a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx @@ -38,7 +38,11 @@ export default async function ProjectStoriesPage({ searchParams: Promise<{ stage?: string; status?: string }>; }) { const [{ projectId }, { stage, status }] = await Promise.all([params, searchParams]); - const [pipelineResult, me] = await Promise.all([api.pipeline(projectId), api.me()]); + const [pipelineResult, me, project] = await Promise.all([ + api.pipeline(projectId), + api.me(), + api.project(projectId), + ]); if (!pipelineResult.ok && pipelineResult.offline) return ; @@ -189,6 +193,9 @@ export default async function ProjectStoriesPage({ projectId={projectId} story={story} canTrigger={canTrigger} + builderPlanRequired={ + !project.ok || project.data.builderPlanPolicy === "required" + } /> ))} diff --git a/apps/web/components/agents/agent-detail.tsx b/apps/web/components/agents/agent-detail.tsx index dc63aa7c..1c62de5c 100644 --- a/apps/web/components/agents/agent-detail.tsx +++ b/apps/web/components/agents/agent-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { isBuilderMode } from "@facility/run-objective"; +import { isBuilderAgent } from "@facility/run-objective"; import { Button, Divider, @@ -44,6 +44,7 @@ type Props = { myPermissions: string[]; sandboxProfiles: Array<{ id: string; name: string }>; recentRuns: Run[]; + builderPlanPolicy: "optional" | "required"; }; type ScheduleTriggerShape = { @@ -84,7 +85,7 @@ function runCost(run: Run): number | null { /** The flagship surface: one agent, fully understandable and operable. */ export function AgentDetail(props: Props) { - const { projectId, agent, status } = props; + const { projectId, agent, status, builderPlanPolicy } = props; const router = useRouter(); const [busy, setBusy] = useState(null); const [note, setNote] = useState<{ scope: string; text: string } | null>(null); @@ -108,7 +109,7 @@ export function AgentDetail(props: Props) { async function runNow() { const trigger: Record = { type: "manual", source: "agent-page" }; - if (isBuilderMode(agent.name)) { + if (isBuilderAgent(agent.name, agent.triggers)) { const objective = window.prompt(`Run ${agent.name} — what should it do?`); if (objective === null) return; const message = objective.trim(); @@ -132,6 +133,8 @@ export function AgentDetail(props: Props) { const health = status ? agentHealth(status) : null; const next = fmtIn(status?.nextRunAt ?? null); + const builderRequiresPlan = + builderPlanPolicy === "required" && isBuilderAgent(agent.name, agent.triggers); return (
@@ -163,11 +166,19 @@ export function AgentDetail(props: Props) { size="sm" variant="primary" tone="agent" - disabled={busy !== null || !agent.enabled} + disabled={busy !== null || !agent.enabled || builderRequiresPlan} + title={ + builderRequiresPlan + ? "Approve a current Architect plan from the story or GitHub issue" + : undefined + } onClick={() => void runNow()} > {busy === "run" ? "starting…" : "run now"} + {builderRequiresPlan ? ( + plan approval required + ) : null} ) : null} + {builderRequiresPlan ? ( + + disabled by required plan gate + + ) : null}
{editing ? (
diff --git a/apps/web/components/inbox/proposal-card.tsx b/apps/web/components/inbox/proposal-card.tsx index 16ffbeed..44c17dac 100644 --- a/apps/web/components/inbox/proposal-card.tsx +++ b/apps/web/components/inbox/proposal-card.tsx @@ -65,6 +65,15 @@ export function ProposalCard({ proposal, focused }: { proposal: Proposal; focuse headings, lists, and code spans) — render it, don't dump the source. */} + {proposal.executionError ? ( +

+ {proposal.actionType === "plan_acceptance" + ? "Builder dispatch blocked" + : "Execution failed"} + : {proposal.executionError} +

+ ) : null} +
payload diff --git a/apps/web/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx index c96b2375..a585cb11 100644 --- a/apps/web/components/issues/issue-row.tsx +++ b/apps/web/components/issues/issue-row.tsx @@ -21,10 +21,12 @@ export function IssueRow({ projectId, story, canTrigger, + builderPlanRequired, }: { projectId: string; story: PipelineStory; canTrigger: boolean; + builderPlanRequired: boolean; }) { const router = useRouter(); const [busy, setBusy] = useState(null); @@ -90,6 +92,13 @@ export function IssueRow({ ); } if (story.stageState === "ready_to_build" && canTrigger && story.storyType === "issue") { + if (builderPlanRequired) { + return ( + + Review Gate 1 + + ); + } return ( + {builderPlanRequired ? ( + approve the plan below + ) : null} {error ? {error} : null}
); diff --git a/packages/cli/templates/receipts/collect.mjs b/packages/cli/templates/receipts/collect.mjs index 93379e6a..70a84a78 100644 --- a/packages/cli/templates/receipts/collect.mjs +++ b/packages/cli/templates/receipts/collect.mjs @@ -28,7 +28,8 @@ export function collectReceipt(env = process.env, now = new Date()) { const engine = parseEngineEvidence(env.FACILITY_RECEIPT_ENGINE_JSONL); const checkEvidence = parseChecks(env.FACILITY_RECEIPT_CHECKS_FILE); const target = githubTarget(env.GITHUB_EVENT_PATH); - const git = gitActivity(env.FACILITY_RECEIPT_BASE_SHA, env.GITHUB_WORKSPACE); + const baseSha = gitCommitSha(env.FACILITY_RECEIPT_BASE_SHA); + const git = gitActivity(baseSha, env.GITHUB_WORKSPACE); const actor = env.GITHUB_ACTOR; const receipt = { schema: "facility.run.v1", @@ -59,6 +60,7 @@ export function collectReceipt(env = process.env, now = new Date()) { repo: env.GITHUB_REPOSITORY?.split("/")[1], issue: target.issue, pr: target.pr, + ...(baseSha ? { base_sha: baseSha } : {}), ...(actor ? { actor_sha256: sha256(actor) } : {}), }, timing: { @@ -250,6 +252,12 @@ function normalizeResult(value) { return "failed"; } +function gitCommitSha(value) { + return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value) + ? value.toLowerCase() + : undefined; +} + function requiredChoice(value, choices, name) { if (!value || !choices.has(value)) throw new Error(`FACILITY_RECEIPT_${name.toUpperCase()} is invalid`); diff --git a/packages/cli/test/receipts.test.mjs b/packages/cli/test/receipts.test.mjs index e3e7305a..f6383617 100644 --- a/packages/cli/test/receipts.test.mjs +++ b/packages/cli/test/receipts.test.mjs @@ -29,6 +29,7 @@ test("collects a privacy-preserving, tamper-evident agent receipt", async () => FACILITY_RECEIPT_RESULT: "success", FACILITY_RECEIPT_STARTED_AT: "2026-07-19T00:00:00.000Z", FACILITY_RECEIPT_ENGINE_JSONL: enginePath, + FACILITY_RECEIPT_BASE_SHA: "a".repeat(40), FACILITY_RECEIPT_OUTPUT: outputPath, GITHUB_EVENT_PATH: eventPath, GITHUB_REPOSITORY: "theam/mirror", @@ -40,6 +41,7 @@ test("collects a privacy-preserving, tamper-evident agent receipt", async () => const receipt = collectReceipt(env, new Date("2026-07-19T00:01:00.000Z")); assert.equal(receipt.schema, "facility.run.v1"); assert.equal(receipt.github.pr, 42); + assert.equal(receipt.github.base_sha, "a".repeat(40)); assert.equal(receipt.usage.input_tokens, 10); assert.equal(receipt.activity.turns, 1); assert.equal(receipt.activity.shell_commands, 1); @@ -85,5 +87,6 @@ test("reports the full check count when receipt details are bounded", async () = assert.equal(receipt.checks_truncated, true); assert.equal(receipt.checks[0].name, "check-1"); assert.equal(receipt.checks.at(-1).name, "check-200"); + assert.equal(receipt.github.base_sha, undefined); assert.equal(verifyReceipt(receipt), true); }); diff --git a/packages/core/src/receipts.ts b/packages/core/src/receipts.ts index b080fb38..3d087fe3 100644 --- a/packages/core/src/receipts.ts +++ b/packages/core/src/receipts.ts @@ -51,6 +51,10 @@ export const FacilityReceiptSchema = z.object({ repo: z.string().optional(), issue: z.number().int().optional(), pr: z.number().int().optional(), + base_sha: z + .string() + .regex(/^[0-9a-f]{40}$/i) + .optional(), actor_sha256: z.string().optional(), }) .optional(), @@ -155,6 +159,10 @@ const LegacyAgentReceiptSchema = z.object({ repo: z.string().optional(), issue: z.number().int().optional(), pr: z.number().int().optional(), + base_sha: z + .string() + .regex(/^[0-9a-f]{40}$/i) + .optional(), actor: z.string().optional(), actor_sha256: z.string().optional(), }) @@ -206,6 +214,7 @@ export function parseLegacyAgentReceipt(json: unknown): FacilityReceipt { repo: receipt.github.repo, issue: receipt.github.issue, pr: receipt.github.pr, + base_sha: receipt.github.base_sha, actor_sha256: receipt.github.actor_sha256 ?? (receipt.github.actor ? hashActor(receipt.github.actor) : undefined), diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 27d227d2..88f001bc 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -404,13 +404,14 @@ describe("receipts", () => { result: "succeeded", usage: { input_tokens: 100, output_tokens: 50, cost_usd: 1.235, cost_source: "provider" }, activity: { turns: 2, shell_commands: 1, file_changes: 3, mcp_tool_calls: 0, errors: 0 }, - github: { owner: "example", repo: "product", actor: "octo" }, + github: { owner: "example", repo: "product", base_sha: "b".repeat(40), actor: "octo" }, timing: { started_at: "2026-01-01T00:00:00Z", duration_ms: 1000 }, checks: [{ name: "pnpm test", status: "passed", source: "platform", exit_code: 0 }], }); expect(receipt.schema).toBe("facility.run.v1"); expect(receipt.usage.cost_cents).toBe(124); expect(receipt.github?.actor_sha256).toMatch(/^[0-9a-f]{64}$/); + expect(receipt.github?.base_sha).toBe("b".repeat(40)); expect(receipt.checks).toEqual([ { name: "pnpm test", status: "passed", source: "platform", exit_code: 0 }, ]); @@ -444,4 +445,27 @@ describe("receipts", () => { }), ).toBe(false); }); + + it("keeps the optional base commit inside receipt integrity", () => { + const baseSha = "c".repeat(40); + const receipt = parseLegacyAgentReceipt({ + schema: "example.agent_sdlc.run.v1", + provider: "codex_cli", + mode: "builder", + result: "succeeded", + usage: { input_tokens: 1, output_tokens: 1, cost_source: "provider" }, + activity: {}, + github: { base_sha: baseSha }, + timing: { started_at: "2026-08-16T00:00:00.000Z" }, + }); + const sealed = sealFacilityReceipt(receipt, null); + + expect(verifyFacilityReceipt(sealed)).toBe(true); + expect( + verifyFacilityReceipt({ + ...sealed, + github: { ...sealed.github, base_sha: "d".repeat(40) }, + }), + ).toBe(false); + }); }); diff --git a/packages/db/migrations/0042_run_base_sha_provenance.sql b/packages/db/migrations/0042_run_base_sha_provenance.sql new file mode 100644 index 00000000..4bb5db54 --- /dev/null +++ b/packages/db/migrations/0042_run_base_sha_provenance.sql @@ -0,0 +1,5 @@ +ALTER TABLE runs + ADD COLUMN workspace_base_sha text; + +ALTER TABLE run_deliveries + ADD COLUMN base_sha text; diff --git a/packages/db/migrations/0043_builder_plan_policy.sql b/packages/db/migrations/0043_builder_plan_policy.sql new file mode 100644 index 00000000..61c1d294 --- /dev/null +++ b/packages/db/migrations/0043_builder_plan_policy.sql @@ -0,0 +1,8 @@ +-- 0042 is reserved by the workspace/base provenance work in #166. Keeping +-- this policy migration at 0043 lets the two contributions compose cleanly. +ALTER TABLE projects + ADD COLUMN builder_plan_policy text NOT NULL DEFAULT 'optional'; + +ALTER TABLE projects + ADD CONSTRAINT projects_builder_plan_policy_check + CHECK (builder_plan_policy IN ('optional', 'required')); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index eba9b04f..4b8d04b8 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -106,6 +106,7 @@ export const projects = pgTable( slug: text("slug").notNull(), description: text("description"), systemVersion: text("system_version").notNull().default("v1"), + builderPlanPolicy: text("builder_plan_policy").notNull().default("optional"), settings: jsonb("settings").notNull().default(sql`'{}'::jsonb`), status: text("status").notNull().default("active"), ...timestamps, @@ -113,6 +114,10 @@ export const projects = pgTable( (table) => [ unique("projects_org_slug_uidx").on(table.orgId, table.slug), index("projects_org_idx").on(table.orgId), + check( + "projects_builder_plan_policy_check", + sql`${table.builderPlanPolicy} IN ('optional', 'required')`, + ), ], ); @@ -492,6 +497,7 @@ export const runs = pgTable( ciRepairKey: text("ci_repair_key"), transcriptUri: text("transcript_uri"), sessionStateUri: text("session_state_uri"), + workspaceBaseSha: text("workspace_base_sha"), error: text("error"), queuedAt: timestamp("queued_at", { withTimezone: true }).defaultNow().notNull(), startedAt: timestamp("started_at", { withTimezone: true }), @@ -550,6 +556,7 @@ export const runDeliveries = pgTable( repoName: text("repo_name").notNull(), headBranch: text("head_branch").notNull(), expectedHeadSha: text("expected_head_sha").notNull(), + baseSha: text("base_sha"), baseBranch: text("base_branch").notNull(), title: text("title").notNull(), body: text("body").notNull(), diff --git a/packages/db/test/db.test.ts b/packages/db/test/db.test.ts index f0aabec6..c58b9c7a 100644 --- a/packages/db/test/db.test.ts +++ b/packages/db/test/db.test.ts @@ -743,6 +743,9 @@ describe("db", async () => { OR (table_name = 'spend_counters' AND column_name = 'spent_cents') OR (table_name = 'analytics_daily' AND column_name IN ('cost_cents', 'outcomes_assessed', 'outcomes_accepted')) OR (table_name = 'provider_credentials' AND column_name = 'auth_mode') + OR (table_name = 'projects' AND column_name = 'builder_plan_policy') + OR (table_name = 'runs' AND column_name = 'workspace_base_sha') + OR (table_name = 'run_deliveries' AND column_name = 'base_sha') `, )) as Iterable<{ table_name: string; column_name: string; data_type: string }>; const columnTypes = new Map( @@ -757,6 +760,9 @@ describe("db", async () => { expect(columnTypes.get("analytics_daily.outcomes_assessed")).toBe("integer"); expect(columnTypes.get("analytics_daily.outcomes_accepted")).toBe("integer"); expect(columnTypes.get("provider_credentials.auth_mode")).toBe("text"); + expect(columnTypes.get("projects.builder_plan_policy")).toBe("text"); + expect(columnTypes.get("runs.workspace_base_sha")).toBe("text"); + expect(columnTypes.get("run_deliveries.base_sha")).toBe("text"); const indexes = (await db.execute( sql` SELECT indexname @@ -864,6 +870,17 @@ describe("db", async () => { expect(Array.from(ciEventChecks).map((row) => row.conname)).toEqual([ "gh_ci_events_state_check", ]); + const projectChecks = (await db.execute( + sql` + SELECT conname + FROM pg_constraint + WHERE conrelid = 'projects'::regclass + AND conname = 'projects_builder_plan_policy_check' + `, + )) as Iterable<{ conname: string }>; + expect(Array.from(projectChecks).map((row) => row.conname)).toEqual([ + "projects_builder_plan_policy_check", + ]); const applied = (await db.execute( sql` SELECT name @@ -874,7 +891,10 @@ describe("db", async () => { // A developer database can include later migrations from another worktree; // assert this checkout's latest migration was applied without assuming it // is the newest row in that shared database. - expect(Array.from(applied).map((row) => row.name)).toContain("0041_github_ci_story_events.sql"); + expect(Array.from(applied).map((row) => row.name)).toContain("0043_builder_plan_policy.sql"); + expect(Array.from(applied).map((row) => row.name)).toContain( + "0042_run_base_sha_provenance.sql", + ); const providerCredentialChecks = (await db.execute( sql` SELECT conname diff --git a/packages/run-objective/src/index.d.ts b/packages/run-objective/src/index.d.ts index 9d5be84a..64fe7d04 100644 --- a/packages/run-objective/src/index.d.ts +++ b/packages/run-objective/src/index.d.ts @@ -1,2 +1,4 @@ export declare function isBuilderMode(mode: string): boolean; +export declare function agentDefTriggersBuilder(value: unknown): boolean; +export declare function isBuilderAgent(name: string, triggers: unknown): boolean; export declare function runObjectiveText(value: unknown): string | null; diff --git a/packages/run-objective/src/index.js b/packages/run-objective/src/index.js index 6553f29f..6fbde10c 100644 --- a/packages/run-objective/src/index.js +++ b/packages/run-objective/src/index.js @@ -1,6 +1,22 @@ /** @param {string} mode */ export function isBuilderMode(mode) { - return mode === "builder" || mode.endsWith("-builder"); + const canonical = mode.replaceAll("_", "-"); + return canonical === "builder" || canonical.endsWith("-builder"); +} + +/** @param {unknown} value */ +export function agentDefTriggersBuilder(value) { + if (!Array.isArray(value)) return false; + return value.some((trigger) => { + const entry = objectValue(trigger); + const command = stringValue(entry.command) ?? stringValue(entry.handle); + return command ? isBuilderMode(command.replace(/^\//, "")) : false; + }); +} + +/** @param {string} name @param {unknown} triggers */ +export function isBuilderAgent(name, triggers) { + return isBuilderMode(name) || agentDefTriggersBuilder(triggers); } /** @@ -36,3 +52,8 @@ export function runObjectiveText(value) { function objectValue(value) { return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; } + +/** @param {unknown} value @returns {string | null} */ +function stringValue(value) { + return typeof value === "string" && value.length > 0 ? value : null; +} diff --git a/packages/run-objective/test/index.test.ts b/packages/run-objective/test/index.test.ts index 682bc7f4..2d475e2d 100644 --- a/packages/run-objective/test/index.test.ts +++ b/packages/run-objective/test/index.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { isBuilderMode, runObjectiveText } from "../src/index.js"; +import { + agentDefTriggersBuilder, + isBuilderAgent, + isBuilderMode, + runObjectiveText, +} from "../src/index.js"; describe("run objective policy", () => { it("recognizes every governed builder objective source", () => { @@ -34,6 +39,14 @@ describe("run objective policy", () => { it("recognizes canonical and prefixed builder modes", () => { expect(isBuilderMode("builder")).toBe(true); expect(isBuilderMode("codex-builder")).toBe(true); + expect(isBuilderMode("codex_builder")).toBe(true); expect(isBuilderMode("architect")).toBe(false); }); + + it("recognizes renamed Builder definitions from command triggers", () => { + expect(agentDefTriggersBuilder([{ type: "command", command: "builder" }])).toBe(true); + expect(agentDefTriggersBuilder([{ type: "command", handle: "/codex_builder" }])).toBe(true); + expect(isBuilderAgent("implementation-agent", [{ handle: "/codex-builder" }])).toBe(true); + expect(isBuilderAgent("architect", [{ handle: "/architect" }])).toBe(false); + }); }); diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 882c05d2..036685f0 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3603,6 +3603,13 @@ "systemVersion": { "type": "string" }, + "builderPlanPolicy": { + "type": "string", + "enum": [ + "optional", + "required" + ] + }, "settings": { "type": "object", "additionalProperties": {} @@ -3626,6 +3633,7 @@ "slug", "description", "systemVersion", + "builderPlanPolicy", "settings", "status", "createdAt", @@ -3750,6 +3758,13 @@ "description": { "type": "string" }, + "builderPlanPolicy": { + "type": "string", + "enum": [ + "optional", + "required" + ] + }, "settings": { "type": "object", "additionalProperties": {} @@ -3790,6 +3805,13 @@ "systemVersion": { "type": "string" }, + "builderPlanPolicy": { + "type": "string", + "enum": [ + "optional", + "required" + ] + }, "settings": { "type": "object", "additionalProperties": {} @@ -3813,6 +3835,7 @@ "slug", "description", "systemVersion", + "builderPlanPolicy", "settings", "status", "createdAt", @@ -3972,6 +3995,13 @@ "systemVersion": { "type": "string" }, + "builderPlanPolicy": { + "type": "string", + "enum": [ + "optional", + "required" + ] + }, "settings": { "type": "object", "additionalProperties": {} @@ -3995,6 +4025,7 @@ "slug", "description", "systemVersion", + "builderPlanPolicy", "settings", "status", "createdAt", @@ -4118,6 +4149,13 @@ "status": { "type": "string" }, + "builderPlanPolicy": { + "type": "string", + "enum": [ + "optional", + "required" + ] + }, "settings": { "type": "object", "additionalProperties": {} @@ -4164,6 +4202,13 @@ "systemVersion": { "type": "string" }, + "builderPlanPolicy": { + "type": "string", + "enum": [ + "optional", + "required" + ] + }, "settings": { "type": "object", "additionalProperties": {} @@ -4187,6 +4232,7 @@ "slug", "description", "systemVersion", + "builderPlanPolicy", "settings", "status", "createdAt", @@ -6639,6 +6685,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -6685,6 +6735,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -6968,6 +7019,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -7014,6 +7069,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -7463,6 +7519,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -7509,6 +7569,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -7923,6 +7984,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -7989,6 +8054,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -8234,6 +8300,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -8280,6 +8350,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -8534,6 +8605,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -8580,6 +8655,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -9809,6 +9885,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -9855,6 +9935,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -13420,6 +13501,10 @@ "nullable": true, "type": "string" }, + "workspaceBaseSha": { + "nullable": true, + "type": "string" + }, "error": { "nullable": true, "type": "string" @@ -13466,6 +13551,7 @@ "engineSessionId", "transcriptUri", "sessionStateUri", + "workspaceBaseSha", "error", "queuedAt", "startedAt", @@ -18258,6 +18344,10 @@ "state": { "type": "string" }, + "executionError": { + "nullable": true, + "type": "string" + }, "decidedBy": { "nullable": true, "type": "string" @@ -18334,6 +18424,10 @@ "state": { "type": "string" }, + "executionError": { + "nullable": true, + "type": "string" + }, "decidedBy": { "nullable": true, "type": "string" @@ -18607,6 +18701,10 @@ "state": { "type": "string" }, + "executionError": { + "nullable": true, + "type": "string" + }, "decidedBy": { "nullable": true, "type": "string" @@ -18870,6 +18968,10 @@ "state": { "type": "string" }, + "executionError": { + "nullable": true, + "type": "string" + }, "decidedBy": { "nullable": true, "type": "string" @@ -19101,6 +19203,10 @@ "state": { "type": "string" }, + "executionError": { + "nullable": true, + "type": "string" + }, "decidedBy": { "nullable": true, "type": "string" @@ -19340,6 +19446,10 @@ "state": { "type": "string" }, + "executionError": { + "nullable": true, + "type": "string" + }, "decidedBy": { "nullable": true, "type": "string" @@ -19541,6 +19651,10 @@ "state": { "type": "string" }, + "executionError": { + "nullable": true, + "type": "string" + }, "decidedBy": { "nullable": true, "type": "string" @@ -27394,6 +27508,10 @@ "state": { "type": "string" }, + "executionError": { + "nullable": true, + "type": "string" + }, "decidedBy": { "nullable": true, "type": "string" diff --git a/packages/sdk/src/schema.d.ts b/packages/sdk/src/schema.d.ts index b4aa3246..f5edd1fd 100644 --- a/packages/sdk/src/schema.d.ts +++ b/packages/sdk/src/schema.d.ts @@ -4374,6 +4374,8 @@ export interface operations { slug: string; description: string | null; systemVersion: string; + /** @enum {string} */ + builderPlanPolicy: "optional" | "required"; settings: { [key: string]: unknown; }; @@ -4475,6 +4477,8 @@ export interface operations { name: string; slug: string; description?: string; + /** @enum {string} */ + builderPlanPolicy?: "optional" | "required"; settings?: { [key: string]: unknown; }; @@ -4495,6 +4499,8 @@ export interface operations { slug: string; description: string | null; systemVersion: string; + /** @enum {string} */ + builderPlanPolicy: "optional" | "required"; settings: { [key: string]: unknown; }; @@ -4604,6 +4610,8 @@ export interface operations { slug: string; description: string | null; systemVersion: string; + /** @enum {string} */ + builderPlanPolicy: "optional" | "required"; settings: { [key: string]: unknown; }; @@ -4800,6 +4808,8 @@ export interface operations { name?: string; description?: string; status?: string; + /** @enum {string} */ + builderPlanPolicy?: "optional" | "required"; settings?: { [key: string]: unknown; }; @@ -4820,6 +4830,8 @@ export interface operations { slug: string; description: string | null; systemVersion: string; + /** @enum {string} */ + builderPlanPolicy: "optional" | "required"; settings: { [key: string]: unknown; }; @@ -6130,6 +6142,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -6295,6 +6308,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -6559,6 +6573,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -6809,6 +6824,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -6962,6 +6978,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -7113,6 +7130,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -7891,6 +7909,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -9663,6 +9682,7 @@ export interface operations { engineSessionId: string | null; transcriptUri: string | null; sessionStateUri: string | null; + workspaceBaseSha: string | null; error: string | null; /** Format: date-time */ queuedAt: string; @@ -12532,6 +12552,7 @@ export interface operations { }; contextMd: string; state: string; + executionError?: string | null; decidedBy: string | null; /** Format: date-time */ decidedAt: string | null; @@ -12554,6 +12575,7 @@ export interface operations { }; contextMd: string; state: string; + executionError?: string | null; decidedBy: string | null; /** Format: date-time */ decidedAt: string | null; @@ -12690,6 +12712,7 @@ export interface operations { }; contextMd: string; state: string; + executionError?: string | null; decidedBy: string | null; /** Format: date-time */ decidedAt: string | null; @@ -12833,6 +12856,7 @@ export interface operations { }; contextMd: string; state: string; + executionError?: string | null; decidedBy: string | null; /** Format: date-time */ decidedAt: string | null; @@ -12963,6 +12987,7 @@ export interface operations { }; contextMd: string; state: string; + executionError?: string | null; decidedBy: string | null; /** Format: date-time */ decidedAt: string | null; @@ -13089,6 +13114,7 @@ export interface operations { }; contextMd: string; state: string; + executionError?: string | null; decidedBy: string | null; /** Format: date-time */ decidedAt: string | null; @@ -13207,6 +13233,7 @@ export interface operations { }; contextMd: string; state: string; + executionError?: string | null; decidedBy: string | null; /** Format: date-time */ decidedAt: string | null; @@ -17661,6 +17688,7 @@ export interface operations { }; contextMd: string; state: string; + executionError?: string | null; decidedBy: string | null; /** Format: date-time */ decidedAt: string | null; diff --git a/runner/src/index.ts b/runner/src/index.ts index 1dd4617c..7bfc5a9e 100644 --- a/runner/src/index.ts +++ b/runner/src/index.ts @@ -137,6 +137,7 @@ async function main() { await prepareRunnerRuntime(); }); restoredSessionState = await restoreSessionState(activeBundle); + await recordWorkspaceProvenance(activeBundle); steerStop = startSteeringPoll(); const packageInstallCmd = activeBundle.packageInstallCmd; if (packageInstallCmd) { @@ -545,6 +546,22 @@ export async function prepareWorkspace( } } +export async function preparedWorkspaceBaseSha(bundle: RunBundle, root = workRoot) { + if (!bundle.repo.cloneUrl) return null; + const baseSha = (await gitOutput(cwdFor(bundle, root), ["rev-parse", "HEAD"])).trim(); + if (!/^[0-9a-f]{40}$/i.test(baseSha)) throw new Error("workspace_base_sha_invalid"); + return baseSha.toLowerCase(); +} + +export async function recordWorkspaceProvenance(bundle: RunBundle, root = workRoot) { + const baseSha = await preparedWorkspaceBaseSha(bundle, root); + if (!baseSha) return null; + return api<{ baseSha: string }>(`/internal/runs/${currentRunId()}/workspace`, { + method: "POST", + body: JSON.stringify({ baseSha }), + }); +} + async function pathExists(path: string) { try { await lstat(path); @@ -1922,6 +1939,7 @@ export async function shipGitChanges( return { branch: published.branch, headSha: published.headSha, + baseSha, changed: true, ...(pullRequest ? { diff --git a/runner/test/github-delivery.integration.test.ts b/runner/test/github-delivery.integration.test.ts index 9793641f..79653556 100644 --- a/runner/test/github-delivery.integration.test.ts +++ b/runner/test/github-delivery.integration.test.ts @@ -440,6 +440,7 @@ describe.sequential("signed GitHub delivery integration", () => { expect(result).toEqual({ branch: "feature/task", headSha: "signed_sha", + baseSha: fixture.baseSha, changed: true, pullRequestTitle: "fix!: deliver signed task", pullRequestBody: "## Summary\n\n- Deliver the signed task.", @@ -579,7 +580,12 @@ describe.sequential("signed GitHub delivery integration", () => { githubFetch: github.githubFetch, }); - expect(result).toEqual({ branch: "feature/task", headSha: "signed_sha", changed: true }); + expect(result).toEqual({ + branch: "feature/task", + headSha: "signed_sha", + baseSha: fixture.baseSha, + changed: true, + }); expect(facilityRequests(facility.requests, "/push-token")).toHaveLength(1); expect(github.originalUrls).toEqual(["https://api.github.com/graphql"]); expect(github.committed()).toBe(true); @@ -606,7 +612,12 @@ describe.sequential("signed GitHub delivery integration", () => { githubFetch: github.githubFetch, }); - expect(result).toEqual({ branch: "feature/task", headSha: "signed_sha", changed: true }); + expect(result).toEqual({ + branch: "feature/task", + headSha: "signed_sha", + baseSha: fixture.baseSha, + changed: true, + }); expect(facilityRequests(facility.requests, "/push-token")).toHaveLength(1); expect(github.originalUrls).toEqual(["https://api.github.com/graphql"]); expect(github.committed()).toBe(true); diff --git a/runner/test/workspace.test.ts b/runner/test/workspace.test.ts index 97268bfd..2ae6d9d0 100644 --- a/runner/test/workspace.test.ts +++ b/runner/test/workspace.test.ts @@ -33,6 +33,7 @@ import { gitOutput, handleControlMessage, parseGitNameStatus, + preparedWorkspaceBaseSha, prepareWorkspace, privateRegistryInstallCommand, privateRegistryNpmrc, @@ -1403,9 +1404,11 @@ describe("Claude resume controls", () => { it("replays a workspace checkpoint when the admitted base changed", async () => { const root = await mkdtemp(join(tmpdir(), "facility-runner-resume-stale-")); const source = join(root, "source"); - const target = join(root, "target"); + const workspace = join(root, "workspace"); + const target = join(workspace, "repo"); const checkpoint = join(root, "checkpoint"); await mkdir(source); + await mkdir(workspace); execFileSync("git", ["init", "--initial-branch=main"], { cwd: source }); execFileSync("git", ["config", "user.name", "Facility Test"], { cwd: source }); execFileSync("git", ["config", "user.email", "facility@example.test"], { cwd: source }); @@ -1413,6 +1416,10 @@ describe("Claude resume controls", () => { execFileSync("git", ["add", "task.txt"], { cwd: source }); execFileSync("git", ["commit", "-m", "chore: initialize stale fixture"], { cwd: source }); execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: source }); + const originalBaseSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: source, + encoding: "utf8", + }).trim(); await writeFile(join(source, "task.txt"), "resumable work\n"); await createWorkspaceCheckpoint(source, checkpoint, "main"); @@ -1423,10 +1430,32 @@ describe("Claude resume controls", () => { execFileSync("git", ["add", "new-base.txt"], { cwd: target }); execFileSync("git", ["commit", "-m", "chore: advance admitted base"], { cwd: target }); execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: target }); + const currentBaseSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: target, + encoding: "utf8", + }).trim(); await expect(restoreWorkspaceCheckpoint(target, checkpoint, "main")).resolves.toBe(true); - await expect(readFile(join(target, "task.txt"), "utf8")).resolves.toBe("resumable work\n"); - await expect(readFile(join(target, "new-base.txt"), "utf8")).resolves.toBe("new base\n"); + await expect( + preparedWorkspaceBaseSha( + bundle({ + repo: { + cloneUrl: "https://github.com/acme/widget.git", + branch: "main", + expectedHeadSha: null, + installationTokenRef: null, + }, + }), + workspace, + ), + ).resolves.toBe(currentBaseSha); + expect(currentBaseSha).not.toBe(originalBaseSha); + await expect( + readFile(join(target, "task.txt"), "utf8").then((body) => body.replace(/\r\n/g, "\n")), + ).resolves.toBe("resumable work\n"); + await expect( + readFile(join(target, "new-base.txt"), "utf8").then((body) => body.replace(/\r\n/g, "\n")), + ).resolves.toBe("new base\n"); expect( execFileSync("git", ["branch", "--show-current"], { cwd: target, encoding: "utf8" }).trim(), ).toBe("main"); diff --git a/services/api/src/builder-plan-freshness.ts b/services/api/src/builder-plan-freshness.ts new file mode 100644 index 00000000..bd3dba14 --- /dev/null +++ b/services/api/src/builder-plan-freshness.ts @@ -0,0 +1,156 @@ +import { type FacilityDb, proposals, repos } from "@facility/db"; +import { and, eq } from "drizzle-orm"; +import { ApiError } from "./errors.js"; +import { + createGithubClientFactory, + type FacilityGithubClient, + type GithubClientFactory, +} from "./github/client.js"; +import { githubIssueRevisionContext, githubIssueRevisionSha256 } from "./github/issue-revision.js"; +import { createGithubClientForRepo } from "./github/kickstart.js"; +import type { AppConfig } from "./types.js"; + +export type BuilderPlanFreshnessEvidence = { + baseSha: string; + issueRevisionSha256: string; + checkedAt: string; +}; + +type FreshnessClient = Pick< + FacilityGithubClient, + "getDefaultBranchSha" | "getIssue" | "listIssueComments" +>; + +export type BuilderPlanFreshnessOptions = { + config?: AppConfig; + githubFactory?: GithubClientFactory; + githubClient?: { + owner: string; + repo: string; + client: FreshnessClient; + }; +}; + +const ISSUE_CONTEXT_MAX_CHARS = 512 * 1024; + +export async function resolveBuilderPlanFreshnessForRun( + db: FacilityDb, + run: { orgId: string; projectId: string; trigger: unknown }, + options: BuilderPlanFreshnessOptions = {}, +): Promise { + const trigger = objectValue(run.trigger); + const proposalId = stringValue(trigger.proposalId); + const architectRunId = stringValue(trigger.architectRunId); + if (trigger.source !== "plan_acceptance" || !proposalId || !architectRunId) { + throw freshnessUnavailable("plan_acceptance_context_missing"); + } + const proposal = ( + await db + .select() + .from(proposals) + .where( + and( + eq(proposals.orgId, run.orgId), + eq(proposals.projectId, run.projectId), + eq(proposals.id, proposalId), + eq(proposals.runId, architectRunId), + ), + ) + .limit(1) + )[0]; + if (!proposal) throw freshnessUnavailable("proposal_not_found"); + return resolveBuilderPlanFreshnessForProposal(db, proposal, options); +} + +export async function resolveBuilderPlanFreshnessForProposal( + db: FacilityDb, + proposal: Pick, + options: BuilderPlanFreshnessOptions = {}, +): Promise { + if (!proposal.projectId) throw freshnessUnavailable("proposal_project_missing"); + const payload = objectValue(proposal.payload); + const repoId = stringValue(payload.repoId); + const issueNumber = positiveInteger(payload.issueNumber); + if (!repoId || !issueNumber) throw freshnessUnavailable("proposal_issue_context_missing"); + const repo = ( + await db + .select() + .from(repos) + .where( + and( + eq(repos.orgId, proposal.orgId), + eq(repos.projectId, proposal.projectId), + eq(repos.id, repoId), + ), + ) + .limit(1) + )[0]; + if (!repo) throw freshnessUnavailable("proposal_repo_missing"); + + let client: FreshnessClient; + try { + if (options.githubClient) { + if (options.githubClient.owner !== repo.owner || options.githubClient.repo !== repo.name) { + throw freshnessUnavailable("github_client_repo_mismatch"); + } + client = options.githubClient.client; + } else { + const factory = + options.githubFactory ?? + (options.config ? createGithubClientFactory(options.config) : null); + if (!factory) throw freshnessUnavailable("github_client_unavailable"); + client = await createGithubClientForRepo(db, factory, repo); + } + const [baseSha, issue, comments] = await Promise.all([ + client.getDefaultBranchSha(), + client.getIssue(issueNumber), + client.listIssueComments(issueNumber, ISSUE_CONTEXT_MAX_CHARS), + ]); + const normalizedBaseSha = gitSha(baseSha); + const issueRevisionSha256 = githubIssueRevisionSha256( + githubIssueRevisionContext(issue, comments), + ); + if (!normalizedBaseSha || !issueRevisionSha256) { + throw freshnessUnavailable("github_freshness_invalid"); + } + return { + baseSha: normalizedBaseSha, + issueRevisionSha256, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + if (error instanceof ApiError && error.code === "builder_plan_freshness_unavailable") { + throw error; + } + throw freshnessUnavailable( + error instanceof Error ? `github_freshness_error:${error.message}` : "github_freshness_error", + ); + } +} + +function freshnessUnavailable(reason: string) { + return new ApiError( + 409, + "builder_plan_freshness_unavailable", + "Facility could not verify the approved repository and issue revision", + { reason }, + ); +} + +function gitSha(value: unknown) { + return typeof value === "string" && /^[a-f0-9]{40}$/i.test(value) ? value.toLowerCase() : null; +} + +function stringValue(value: unknown) { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function positiveInteger(value: unknown) { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; +} + +function objectValue(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} diff --git a/services/api/src/builder-plan-policy.ts b/services/api/src/builder-plan-policy.ts new file mode 100644 index 00000000..e49325a2 --- /dev/null +++ b/services/api/src/builder-plan-policy.ts @@ -0,0 +1,692 @@ +import { createHash } from "node:crypto"; +import { FacilityReceiptSchema, verifyFacilityReceipt } from "@facility/core"; +import { + type AuditInsert, + actionTypes, + agentDefs, + type FacilityDb, + insertAuditEvent, + projects, + proposalEvents, + proposals, + repos, + runs, +} from "@facility/db"; +import { agentDefTriggersBuilder, isBuilderMode } from "@facility/run-objective"; +import { and, eq, sql } from "drizzle-orm"; +import { ApiError } from "./errors.js"; + +export type BuilderPlanPolicy = "optional" | "required"; +export type BuilderPlanDenialCode = + | "builder_plan_required" + | "builder_plan_context_invalid" + | "builder_plan_expired" + | "builder_plan_rejected" + | "builder_plan_already_consumed" + | "builder_plan_stale" + | "builder_plan_freshness_unavailable"; + +const BUILDER_PLAN_DENIAL_CODES = new Set([ + "builder_plan_required", + "builder_plan_context_invalid", + "builder_plan_expired", + "builder_plan_rejected", + "builder_plan_already_consumed", + "builder_plan_stale", + "builder_plan_freshness_unavailable", +]); + +export type BuilderPlanDispatchInput = { + orgId: string; + projectId: string; + mode: string; + agentDefId?: string | null; + agentName?: string | null; + trigger: unknown; + gh?: unknown; + runId?: string | null; + actor?: AuditInsert["actor"]; + source?: string; + /** Trusted, live evidence resolved by the canonical executor/worker, never request JSON. */ + freshnessEvidence?: { + baseSha: string; + issueRevisionSha256: string; + checkedAt: string; + }; +}; + +/** + * Immutable classification produced while the project/agent admission lock is + * held. Producers must persist `mode`; downstream policy must never have to + * infer a queued run's security role from a mutable agent definition. + */ +export type BuilderPlanAdmission = { + mode: string; + isBuilder: boolean; +}; + +type BuilderPlanDecisionInput = { + policy: BuilderPlanPolicy; + mode: string; + agentName?: string | null; + agentIsBuilder?: boolean; + trigger: unknown; + acceptanceValid: boolean; + denialCode?: BuilderPlanDenialCode; +}; + +export type BuilderPlanDecision = + | { allowed: true } + | { allowed: false; code: BuilderPlanDenialCode }; + +type AcceptanceValidation = + | { valid: true } + | { valid: false; code: BuilderPlanDenialCode; reason: string }; + +const FRESHNESS_EVIDENCE_MAX_AGE_MS = 5 * 60_000; +const FRESHNESS_EVIDENCE_FUTURE_SKEW_MS = 30_000; + +/** + * Pure policy seam shared by API preflight and the worker's final dispatch guard. + * `acceptanceValid` is deliberately supplied by the caller so tests cannot make + * a syntactically plausible trigger stand in for durable proposal provenance. + */ +export function builderPlanDecision(input: BuilderPlanDecisionInput): BuilderPlanDecision { + if ( + !(input.agentIsBuilder ?? builderIdentity(input.mode, input.agentName)) || + input.policy === "optional" + ) { + return { allowed: true }; + } + if (objectValue(input.trigger).source !== "plan_acceptance") { + return { allowed: false, code: "builder_plan_required" }; + } + return input.acceptanceValid + ? { allowed: true } + : { allowed: false, code: input.denialCode ?? "builder_plan_context_invalid" }; +} + +export function builderIdentity(mode: string, agentName?: string | null) { + return isBuilderMode(mode) || (agentName ? isBuilderMode(agentName) : false); +} + +export function isBuilderPlanDenialError(error: unknown): error is ApiError { + return error instanceof ApiError && builderPlanDenialCode(error.code) !== null; +} + +export function builderPlanDenialCode(value: unknown): BuilderPlanDenialCode | null { + return typeof value === "string" && BUILDER_PLAN_DENIAL_CODES.has(value as BuilderPlanDenialCode) + ? (value as BuilderPlanDenialCode) + : null; +} + +export async function builderPlanRequired( + db: FacilityDb, + orgId: string, + projectId: string, +): Promise { + const project = ( + await db + .select({ policy: projects.builderPlanPolicy }) + .from(projects) + .where(and(eq(projects.orgId, orgId), eq(projects.id, projectId))) + .limit(1) + )[0]; + if (!project) throw new ApiError(404, "project_not_found", "Project not found"); + return project.policy === "required"; +} + +/** + * Serialize Builder admission with project policy activation. Every run + * producer performs its policy check and insert inside this transaction; + * PATCHing a project to `required` takes the same lock. This closes the window + * where a producer could observe `optional`, an administrator could enable the + * gate, and the producer could then insert an ungoverned row. + */ +export async function withBuilderPlanPreflight( + db: FacilityDb, + input: BuilderPlanDispatchInput, + create: (tx: FacilityDb, admission: BuilderPlanAdmission) => Promise, +): Promise { + try { + return await db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, input.orgId, input.projectId); + const admission = await assertBuilderPlanDispatch(tx, input); + return create(tx, admission); + }); + } catch (error) { + // The denial audit written by assertBuilderPlanDispatch participates in the + // admission transaction and is rolled back with the denied insert. Re-emit + // it after rollback so the stable decision remains durable. + const apiError = error instanceof ApiError ? error : null; + const code = apiError ? builderPlanDenialCode(apiError.code) : null; + if (code) { + const details = objectValue(apiError?.details); + await recordBuilderPlanDenial( + db, + input, + code, + stringValue(details.reason) ?? "transactional_preflight_denied", + ); + } + throw error; + } +} + +/** Must be called from an open transaction. */ +export async function lockBuilderPlanPolicy( + db: FacilityDb, + orgId: string, + projectId: string, +): Promise { + await db.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`builder-plan:${orgId}:${projectId}`}, 0))`, + ); +} + +/** + * Fail closed immediately before a Builder row can be inserted or provisioned. + * A reserved trigger string is insufficient: required projects accept only a + * proposal Facility opened for a sealed Architect receipt and a distinct, + * durable approval event. + */ +export async function assertBuilderPlanDispatch( + db: FacilityDb, + input: BuilderPlanDispatchInput, +): Promise { + const agent = await resolvedAgentIdentity(db, input); + const admission: BuilderPlanAdmission = { + mode: agent.isBuilder ? canonicalBuilderRunMode(input, agent) : input.mode, + isBuilder: agent.isBuilder, + }; + const required = await builderPlanRequired(db, input.orgId, input.projectId); + if (required && input.agentDefId && !agent.found) { + await recordBuilderPlanDenial( + db, + input, + "builder_plan_context_invalid", + "agent_definition_scope_mismatch", + ); + throw new ApiError( + 409, + "builder_plan_context_invalid", + "Builder plan dispatch could not verify the agent definition in this project", + { reason: "agent_definition_scope_mismatch" }, + ); + } + if (!agent.isBuilder) return admission; + if (!required) return admission; + + const trigger = objectValue(input.trigger); + const validation = + trigger.source === "plan_acceptance" + ? await validatePlanAcceptance(db, input, trigger) + : ({ + valid: false, + code: "builder_plan_required", + reason: "plan_acceptance_missing", + } as const); + const decision = builderPlanDecision({ + policy: "required", + mode: input.mode, + agentName: agent.name, + agentIsBuilder: agent.isBuilder, + trigger, + acceptanceValid: validation.valid, + denialCode: validation.valid ? undefined : validation.code, + }); + if (decision.allowed) return admission; + const denialReason = validation.valid ? "plan_acceptance_invalid" : validation.reason; + + await recordBuilderPlanDenial(db, input, decision.code, denialReason); + + if (decision.code === "builder_plan_required") { + throw new ApiError( + 409, + decision.code, + "This project requires an approved Architect plan before Builder can run", + { reason: denialReason }, + ); + } + throw new ApiError( + 409, + decision.code, + "Builder plan acceptance is missing valid Facility proposal provenance", + { reason: denialReason }, + ); +} + +export async function recordBuilderPlanDenial( + db: FacilityDb, + input: BuilderPlanDispatchInput, + code: BuilderPlanDenialCode, + reason: string, +): Promise { + const agentName = (await resolvedAgentIdentity(db, input)).name; + const trigger = objectValue(input.trigger); + const planProvenance = objectValue(trigger.planProvenance); + const expectedBaseSha = gitShaValue(planProvenance.workspaceBaseSha); + const expectedIssueRevisionSha256 = sha256Value(planProvenance.issueRevisionSha256); + const observedBaseSha = gitShaValue(input.freshnessEvidence?.baseSha); + const observedIssueRevisionSha256 = sha256Value(input.freshnessEvidence?.issueRevisionSha256); + await insertAuditEvent(db, { + orgId: input.orgId, + projectId: input.projectId, + actor: input.actor ?? { type: "system", id: "builder-plan-policy" }, + action: "run.builder_plan_denied", + target: input.runId + ? { type: "run", id: input.runId } + : { type: "project", id: input.projectId }, + payload: { + code, + reason, + source: input.source ?? "unknown", + mode: input.mode, + agentDefId: input.agentDefId ?? null, + agentName: agentName ?? null, + proposalId: stringValue(trigger.proposalId), + architectRunId: stringValue(trigger.architectRunId), + expectedPlanInputs: { + baseSha: expectedBaseSha, + issueRevisionSha256: expectedIssueRevisionSha256, + }, + observedPlanInputs: { + baseSha: observedBaseSha, + issueRevisionSha256: observedIssueRevisionSha256, + checkedAt: stringValue(input.freshnessEvidence?.checkedAt), + }, + }, + }); +} + +async function resolvedAgentIdentity(db: FacilityDb, input: BuilderPlanDispatchInput) { + if (!input.agentDefId) { + const name = input.agentName ?? null; + return { name, triggers: null, isBuilder: builderIdentity(input.mode, name), found: true }; + } + const agent = ( + await db + .select({ name: agentDefs.name, triggers: agentDefs.triggers }) + .from(agentDefs) + .where( + and( + eq(agentDefs.orgId, input.orgId), + eq(agentDefs.projectId, input.projectId), + eq(agentDefs.id, input.agentDefId), + ), + ) + .limit(1) + )[0]; + const name = agent?.name ?? input.agentName ?? null; + return { + name, + triggers: agent?.triggers ?? null, + found: Boolean(agent), + isBuilder: + builderIdentity(input.mode, name) || + (agent ? agentDefTriggersBuilder(agent.triggers) : false), + }; +} + +function canonicalBuilderRunMode( + input: BuilderPlanDispatchInput, + agent: { name: string | null; triggers: unknown }, +): "builder" | "codex-builder" { + const candidates = [ + input.mode, + input.agentName, + agent.name, + ...agentTriggerCommands(agent.triggers), + ]; + return candidates.some((candidate) => canonicalAgentToken(candidate) === "codex-builder") + ? "codex-builder" + : "builder"; +} + +function agentTriggerCommands(value: unknown): unknown[] { + if (!Array.isArray(value)) return []; + return value.flatMap((trigger) => { + const entry = objectValue(trigger); + return [entry.command, entry.handle]; + }); +} + +function canonicalAgentToken(value: unknown): string | null { + return typeof value === "string" ? value.replace(/^\//, "").replaceAll("_", "-") : null; +} + +async function validatePlanAcceptance( + db: FacilityDb, + input: BuilderPlanDispatchInput, + trigger: Record, +): Promise { + const proposalId = stringValue(trigger.proposalId); + const architectRunId = stringValue(trigger.architectRunId); + const approvedPlan = stringValue(trigger.approvedPlan); + if (!proposalId || !architectRunId || !approvedPlan) { + return invalid("builder_plan_context_invalid", "trigger_context_missing"); + } + + const proposal = ( + await db + .select() + .from(proposals) + .where( + and( + eq(proposals.orgId, input.orgId), + eq(proposals.projectId, input.projectId), + eq(proposals.id, proposalId), + ), + ) + .limit(1) + )[0]; + if (!proposal) return invalid("builder_plan_context_invalid", "proposal_not_found"); + if (proposal.state === "expired") { + return invalid("builder_plan_expired", "proposal_expired"); + } + if (proposal.state === "rejected") { + return invalid("builder_plan_rejected", "proposal_rejected"); + } + if ( + (proposal.state === "open" && proposal.expiresAt.getTime() <= Date.now()) || + (proposal.decidedAt && proposal.decidedAt.getTime() > proposal.expiresAt.getTime()) + ) { + return invalid("builder_plan_expired", "proposal_expired"); + } + const linkedRuns = await db + .select({ id: runs.id }) + .from(runs) + .where( + and( + eq(runs.orgId, input.orgId), + eq(runs.projectId, input.projectId), + sql`${runs.trigger}->>'source' = 'plan_acceptance'`, + sql`${runs.trigger}->>'proposalId' = ${proposal.id}`, + ), + ) + .limit(2); + if (linkedRuns.some((run) => run.id !== input.runId)) { + return invalid("builder_plan_already_consumed", "proposal_linked_to_another_run"); + } + const architectLinkedRuns = await db + .select({ id: runs.id }) + .from(runs) + .where( + and( + eq(runs.orgId, input.orgId), + eq(runs.projectId, input.projectId), + sql`${runs.trigger}->>'source' = 'plan_acceptance'`, + sql`${runs.trigger}->>'architectRunId' = ${architectRunId}`, + ), + ) + .limit(2); + if (architectLinkedRuns.some((run) => run.id !== input.runId)) { + return invalid("builder_plan_already_consumed", "architect_plan_linked_to_another_run"); + } + const dispatchingLinkedRun = Boolean( + input.runId && + linkedRuns.some((run) => run.id === input.runId) && + architectLinkedRuns.some((run) => run.id === input.runId), + ); + if (proposal.state === "executed" && !dispatchingLinkedRun) { + return invalid("builder_plan_context_invalid", "executed_proposal_missing_linked_run"); + } + if ( + proposal.state !== "executing" && + proposal.state !== "executed" && + !(proposal.state === "execution_failed" && dispatchingLinkedRun) + ) { + return invalid("builder_plan_context_invalid", "proposal_not_executing"); + } + if (!proposal.decidedBy || !proposal.decidedAt) { + return invalid("builder_plan_context_invalid", "approval_decision_missing"); + } + if (proposal.runId !== architectRunId) { + return invalid("builder_plan_context_invalid", "architect_run_link_mismatch"); + } + const actionType = ( + await db + .select() + .from(actionTypes) + .where(and(eq(actionTypes.orgId, input.orgId), eq(actionTypes.id, proposal.actionTypeId))) + .limit(1) + )[0]; + if (!actionType) return invalid("builder_plan_context_invalid", "action_type_missing"); + if (actionType.name !== "plan_acceptance") { + return invalid("builder_plan_context_invalid", "action_type_invalid"); + } + if (objectValue(actionType.executor).type !== "internal") { + return invalid("builder_plan_context_invalid", "action_executor_invalid"); + } + if (proposal.contextMd.trim().length === 0 || proposal.contextMd !== approvedPlan) { + return invalid("builder_plan_context_invalid", "approved_plan_mismatch"); + } + const planSha256 = createHash("sha256").update(proposal.contextMd).digest("hex"); + const payload = objectValue(proposal.payload); + const approvalContext = objectValue(trigger.approval); + const recordedPlanSha256 = payload.planSha256; + if ( + (recordedPlanSha256 !== undefined && sha256Value(recordedPlanSha256) !== planSha256) || + sha256Value(trigger.planSha256) !== planSha256 || + stringValue(approvalContext.principal) !== proposal.decidedBy || + stringValue(approvalContext.at) !== proposal.decidedAt.toISOString() + ) { + return invalid("builder_plan_context_invalid", "plan_or_approval_identity_mismatch"); + } + + const architectRun = ( + await db + .select() + .from(runs) + .where( + and( + eq(runs.orgId, input.orgId), + eq(runs.projectId, input.projectId), + eq(runs.id, architectRunId), + ), + ) + .limit(1) + )[0]; + if (!architectRun) { + return invalid("builder_plan_context_invalid", "architect_run_not_found"); + } + if (architectRun.status !== "succeeded") { + return invalid("builder_plan_context_invalid", "architect_run_not_succeeded"); + } + if (!(await architectRunIdentityValid(db, architectRun))) { + return invalid("builder_plan_context_invalid", "architect_identity_invalid"); + } + + const events = await db + .select() + .from(proposalEvents) + .where(and(eq(proposalEvents.orgId, input.orgId), eq(proposalEvents.proposalId, proposal.id))); + const opener = events.find((event) => event.seq === 1); + const openerActor = objectValue(opener?.actor); + if ( + opener?.type !== "open" || + openerActor.type !== "agent" || + openerActor.id !== architectRun.id || + objectValue(opener.data).source !== "architect_run" + ) { + return invalid("builder_plan_context_invalid", "proposal_origin_invalid"); + } + const approval = events.find((event) => { + if (event.type !== "approved") return false; + const actor = objectValue(event.actor); + return actor.id === proposal.decidedBy && actor.type === "user"; + }); + if (!approval) return invalid("builder_plan_context_invalid", "approval_event_invalid"); + + const repoId = stringValue(payload.repoId); + const issueNumber = positiveInteger(payload.issueNumber); + const receiptSha256 = sha256Value(payload.receiptSha256); + const expectedBaseSha = gitShaValue(payload.workspaceBaseSha); + const expectedIssueRevision = sha256Value(payload.issueRevisionSha256); + const planProvenance = objectValue(trigger.planProvenance); + if ( + stringValue(payload.architectRunId) !== architectRun.id || + !repoId || + !issueNumber || + !receiptSha256 + ) { + return invalid("builder_plan_context_invalid", "proposal_context_invalid"); + } + if ( + expectedBaseSha && + expectedIssueRevision && + (gitShaValue(planProvenance.workspaceBaseSha) !== expectedBaseSha || + sha256Value(planProvenance.issueRevisionSha256) !== expectedIssueRevision) + ) { + return invalid("builder_plan_context_invalid", "plan_provenance_mismatch"); + } + const repo = ( + await db + .select() + .from(repos) + .where( + and( + eq(repos.orgId, input.orgId), + eq(repos.projectId, input.projectId), + eq(repos.id, repoId), + ), + ) + .limit(1) + )[0]; + if (!repo) return invalid("builder_plan_context_invalid", "proposal_repo_invalid"); + + const architectGh = objectValue(architectRun.gh); + const architectTrigger = objectValue(architectRun.trigger); + const triggerRepo = objectValue(architectTrigger.repo); + const triggerIssue = objectValue(architectTrigger.issue); + if ( + architectGh.owner !== repo.owner || + architectGh.repo !== repo.name || + positiveInteger(architectGh.issueNumber) !== issueNumber || + triggerRepo.id !== repo.id || + triggerRepo.owner !== repo.owner || + triggerRepo.name !== repo.name || + positiveInteger(triggerIssue.number) !== issueNumber + ) { + return invalid("builder_plan_context_invalid", "architect_issue_context_invalid"); + } + + const builderGh = objectValue(input.gh); + if ( + builderGh.owner !== repo.owner || + builderGh.repo !== repo.name || + positiveInteger(builderGh.issueNumber) !== issueNumber + ) { + return invalid("builder_plan_context_invalid", "builder_issue_context_invalid"); + } + + const receipt = FacilityReceiptSchema.safeParse(architectRun.receipt); + if (!receipt.success || !verifyFacilityReceipt(receipt.data)) { + return invalid("builder_plan_context_invalid", "architect_receipt_invalid"); + } + const expectedReceiptMode = architectReceiptMode(architectRun.mode); + if ( + !expectedReceiptMode || + receipt.data.integrity?.payload_sha256 !== receiptSha256 || + receipt.data.run_id !== architectRun.id || + receipt.data.project_id !== input.projectId || + receipt.data.mode !== expectedReceiptMode || + receipt.data.result !== "succeeded" || + receipt.data.github?.owner !== repo.owner || + receipt.data.github?.repo !== repo.name || + receipt.data.github?.issue !== issueNumber || + (expectedBaseSha && receipt.data.github?.base_sha !== expectedBaseSha) || + (expectedBaseSha && gitShaValue(architectRun.workspaceBaseSha) !== expectedBaseSha) || + (architectRun.agentDefId && receipt.data.agent_id !== architectRun.agentDefId) + ) { + return invalid("builder_plan_context_invalid", "architect_receipt_context_invalid"); + } + + const currentBaseSha = gitShaValue(input.freshnessEvidence?.baseSha); + const currentIssueRevision = sha256Value(input.freshnessEvidence?.issueRevisionSha256); + const checkedAt = stringValue(input.freshnessEvidence?.checkedAt); + const checkedAtMs = checkedAt ? Date.parse(checkedAt) : Number.NaN; + const now = Date.now(); + if ( + !expectedBaseSha || + !expectedIssueRevision || + !currentBaseSha || + !currentIssueRevision || + !checkedAt || + !Number.isFinite(checkedAtMs) || + checkedAtMs < now - FRESHNESS_EVIDENCE_MAX_AGE_MS || + checkedAtMs > now + FRESHNESS_EVIDENCE_FUTURE_SKEW_MS + ) { + return invalid("builder_plan_freshness_unavailable", "freshness_evidence_missing"); + } + // Legacy Architect proposals did not persist a plan digest. They remain + // distinguishable as freshness-unavailable when they also predate the + // trusted base/issue envelope, but no otherwise-complete envelope may omit it. + if (recordedPlanSha256 === undefined) { + return invalid("builder_plan_context_invalid", "plan_hash_missing"); + } + if (currentBaseSha !== expectedBaseSha || currentIssueRevision !== expectedIssueRevision) { + return invalid("builder_plan_stale", "base_or_issue_revision_changed"); + } + return { valid: true }; +} + +function architectReceiptMode(mode: string): "architect" | null { + const canonical = mode.replaceAll("_", "-"); + return canonical === "architect" || canonical.endsWith("-architect") ? "architect" : null; +} + +export async function architectRunIdentityValid( + db: FacilityDb, + architectRun: typeof runs.$inferSelect, +): Promise { + const mode = architectRun.mode.replaceAll("_", "-"); + if (mode !== "architect" && !mode.endsWith("-architect")) return false; + if (!architectRun.agentDefId) return true; + const agent = ( + await db + .select({ name: agentDefs.name }) + .from(agentDefs) + .where( + and( + eq(agentDefs.orgId, architectRun.orgId), + eq(agentDefs.projectId, architectRun.projectId), + eq(agentDefs.id, architectRun.agentDefId), + ), + ) + .limit(1) + )[0]; + const name = agent?.name.replaceAll("_", "-"); + return Boolean(name && (name === "architect" || name.endsWith("-architect"))); +} + +function invalid(code: BuilderPlanDenialCode, reason?: string): AcceptanceValidation { + return { valid: false, code, reason: reason ?? code }; +} + +function objectValue(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function sha256Value(value: unknown): string | null { + const candidate = stringValue(value); + return candidate && /^[a-f0-9]{64}$/i.test(candidate) ? candidate.toLowerCase() : null; +} + +function gitShaValue(value: unknown): string | null { + const candidate = stringValue(value); + return candidate && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(candidate) + ? candidate.toLowerCase() + : null; +} + +function positiveInteger(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; +} diff --git a/services/api/src/executors.ts b/services/api/src/executors.ts index 616b4343..3a9e8555 100644 --- a/services/api/src/executors.ts +++ b/services/api/src/executors.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { can, newId } from "@facility/core"; -import type { createDb } from "@facility/db"; +import type { createDb, FacilityDb } from "@facility/db"; import { actionTypes, agentDefs, @@ -35,6 +35,20 @@ import { import { artifactIdFor, validate } from "@facility/harness"; import { and, desc, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; import { assertBudgetAgentInProject, resolveBudgetScope } from "./budget-scope.js"; +import { + type BuilderPlanFreshnessOptions, + resolveBuilderPlanFreshnessForProposal, +} from "./builder-plan-freshness.js"; +import { + architectRunIdentityValid, + builderPlanDenialCode, + builderPlanRequired, + lockBuilderPlanPolicy, + recordBuilderPlanDenial, + withBuilderPlanPreflight, +} from "./builder-plan-policy.js"; +import { ApiError } from "./errors.js"; +import { findAgentDef, laneFor } from "./github/agent-routing.js"; import { createGithubClientFactory, FacilityGithubClient, @@ -46,7 +60,6 @@ import { kickstartRepo, upgradeRepo, } from "./github/kickstart.js"; -import { findAgentDef, laneFor } from "./github/router.js"; import { renderGithubRunProgress } from "./github/run-progress.js"; import { ensureTrackedIssue } from "./github/tracked-issues.js"; import { @@ -85,6 +98,7 @@ type ExecuteApprovedProposalOptions = { config?: AppConfig; github?: GitHubIssueClient; githubFactory?: GithubClientFactory; + githubClient?: BuilderPlanFreshnessOptions["githubClient"]; enqueue?: (queue: string, data: Record) => Promise; }; @@ -95,30 +109,57 @@ export async function executeApprovedProposal( options: ExecuteApprovedProposalOptions | GitHubIssueClient = {}, ) { const executionOptions = isGitHubIssueClient(options) ? { github: options } : options; - if (candidate.state !== "approved" && candidate.state !== "execution_failed") return false; + if ( + candidate.state !== "approved" && + candidate.state !== "execution_failed" && + candidate.state !== "executing" + ) { + return false; + } const actionType = ( await db.select().from(actionTypes).where(eq(actionTypes.id, candidate.actionTypeId)).limit(1) )[0]; if (!actionType) return false; + // Plan acceptance is the only executor whose side effects are deliberately + // idempotent at the database boundary: its Builder row is protected by the + // proposal and Architect-run unique indexes. Allow a later request to finish + // an `executing` proposal after a process dies between the claim and insert. + // Other action types retain the original single-owner behavior. + if (candidate.state === "executing" && actionType.name !== "plan_acceptance") return false; // `executor.type = none` is a deliberate externally-consumed approval gate. // Approval is terminal for Facility itself, so leave the proposal approved. if (objectOrEmpty(actionType.executor).type === "none") return true; // Claim before performing any database or external side effect. The compare-and-set // makes concurrent /execute calls single-writer even when both loaded the same // approved proposal. Failed executions remain explicitly retryable. - const proposal = ( - await db - .update(proposals) - .set({ state: "executing", updatedAt: new Date() }) - .where( - and( - eq(proposals.orgId, candidate.orgId), - eq(proposals.id, candidate.id), - inArray(proposals.state, ["approved", "execution_failed"]), - ), - ) - .returning() - )[0]; + const proposal = + candidate.state === "executing" + ? ( + await db + .select() + .from(proposals) + .where( + and( + eq(proposals.orgId, candidate.orgId), + eq(proposals.id, candidate.id), + eq(proposals.state, "executing"), + ), + ) + .limit(1) + )[0] + : ( + await db + .update(proposals) + .set({ state: "executing", updatedAt: new Date() }) + .where( + and( + eq(proposals.orgId, candidate.orgId), + eq(proposals.id, candidate.id), + inArray(proposals.state, ["approved", "execution_failed"]), + ), + ) + .returning() + )[0]; if (!proposal) return false; let actionTypeName = "unknown"; try { @@ -153,23 +194,56 @@ export async function executeApprovedProposal( } else { throw new Error(`unsupported_action_type:${actionType.name}`); } - await db - .update(proposals) - .set({ state: "executed", updatedAt: new Date() }) - .where(and(eq(proposals.orgId, proposal.orgId), eq(proposals.id, proposal.id))); - await appendProposalEvent(db, proposal, "executed", actor, { - actionType: actionType.name, - ...(recurrence ? { recurrence } : {}), + await db.transaction(async (transaction) => { + const tx = transaction as unknown as Db; + const completed = ( + await tx + .update(proposals) + .set({ state: "executed", updatedAt: new Date() }) + .where( + and( + eq(proposals.orgId, proposal.orgId), + eq(proposals.id, proposal.id), + inArray(proposals.state, ["executing", "execution_failed"]), + ), + ) + .returning() + )[0]; + if (completed) { + await appendProposalEvent(tx, completed, "executed", actor, { + actionType: actionType.name, + ...(recurrence ? { recurrence } : {}), + }); + } }); return true; } catch (error) { - await db - .update(proposals) - .set({ state: "execution_failed", updatedAt: new Date() }) - .where(and(eq(proposals.orgId, proposal.orgId), eq(proposals.id, proposal.id))); - await appendProposalEvent(db, proposal, "execution_failed", actor, { - actionType: actionTypeName, - error: error instanceof Error ? error.message : String(error), + await db.transaction(async (transaction) => { + const tx = transaction as unknown as Db; + const failed = ( + await tx + .update(proposals) + .set({ state: "execution_failed", updatedAt: new Date() }) + .where( + and( + eq(proposals.orgId, proposal.orgId), + eq(proposals.id, proposal.id), + eq(proposals.state, "executing"), + ), + ) + .returning() + )[0]; + if (failed) { + await appendProposalEvent(tx, failed, "execution_failed", actor, { + actionType: actionTypeName, + error: + error instanceof ApiError + ? error.code + : error instanceof Error + ? error.message + : String(error), + }); + } }); return true; } @@ -183,6 +257,7 @@ async function executePlanAcceptance( ) { if (!proposal.projectId) throw new Error("plan_acceptance_missing_project"); if (!proposal.runId) throw new Error("plan_acceptance_missing_architect_run"); + const proposalProjectId = proposal.projectId; const architectRun = ( await db @@ -198,20 +273,120 @@ async function executePlanAcceptance( .limit(1) )[0]; if (!architectRun) throw new Error("plan_acceptance_architect_run_not_found"); - if (!["architect", "codex-architect"].includes(architectRun.mode)) { + if (!(await architectRunIdentityValid(db, architectRun))) { throw new Error("plan_acceptance_source_not_architect"); } if (!["succeeded", "awaiting_human"].includes(architectRun.status)) { throw new Error("plan_acceptance_architect_run_not_ready"); } - await assertPlatformBuilderLane(db, proposal, architectRun); + const builderCommand = architectRun.engine === "codex" ? "codex-builder" : "builder"; + await assertPlatformBuilderLane(db, proposal, architectRun, builderCommand); + const requiredPlan = await builderPlanRequired(db, proposal.orgId, proposal.projectId); + let freshnessEvidence: + | Awaited> + | undefined; + if (requiredPlan) { + try { + freshnessEvidence = await resolveBuilderPlanFreshnessForProposal(db, proposal, options); + } catch (error) { + const apiError = error instanceof ApiError ? error : null; + const code = apiError ? builderPlanDenialCode(apiError.code) : null; + if (code) { + const payload = objectOrEmpty(proposal.payload); + await recordBuilderPlanDenial( + db, + { + orgId: proposal.orgId, + projectId: proposal.projectId, + mode: builderCommand, + agentName: builderCommand, + trigger: { + source: "plan_acceptance", + proposalId: proposal.id, + architectRunId: architectRun.id, + planProvenance: { + workspaceBaseSha: payload.workspaceBaseSha, + issueRevisionSha256: payload.issueRevisionSha256, + }, + }, + gh: architectRun.gh, + actor: { type: auditActorType(actor.type), id: actor.id }, + source: "plan_acceptance_freshness", + }, + code, + stringField(objectOrEmpty(apiError?.details).reason) ?? "freshness_resolution_failed", + ); + } + throw error; + } + } + const builderGh = { ...objectOrEmpty(architectRun.gh) }; + delete builderGh.progressComment; + const planSha256 = createHash("sha256").update(proposal.contextMd).digest("hex"); + const approval = { + principal: proposal.decidedBy, + at: proposal.decidedAt?.toISOString(), + }; + const approvalEvent = ( + await db + .select({ actor: proposalEvents.actor }) + .from(proposalEvents) + .where( + and( + eq(proposalEvents.orgId, proposal.orgId), + eq(proposalEvents.proposalId, proposal.id), + eq(proposalEvents.type, "approved"), + ), + ) + .orderBy(desc(proposalEvents.seq)) + .limit(1) + )[0]; + const approvalActor = objectOrEmpty(approvalEvent?.actor); + const approvalCreator = { + type: stringField(approvalActor.type) ?? actor.type, + id: proposal.decidedBy ?? actor.id, + proposalId: proposal.id, + }; // The proposal link, not the currently configured builder definition, is // the durable dispatch identity. Reuse the original run even if an admin // replaces or disables the builder before an execution retry. const existingRun = await loadPlanBuilderRun(db, proposal); if (existingRun) { + await withBuilderPlanPreflight( + db, + { + orgId: existingRun.orgId, + projectId: existingRun.projectId, + mode: existingRun.mode, + agentDefId: existingRun.agentDefId, + trigger: existingRun.trigger, + gh: existingRun.gh, + runId: existingRun.id, + actor: { type: auditActorType(actor.type), id: actor.id }, + source: "plan_acceptance_retry", + freshnessEvidence, + }, + async (tx) => { + await assertPlatformBuilderLane(tx, proposal, architectRun, builderCommand); + await tx + .insert(runEvents) + .values({ + orgId: existingRun.orgId, + runId: existingRun.id, + seq: 1, + type: "queued", + data: { + queue: "runs.dispatch", + source: "plan_acceptance", + proposalId: proposal.id, + architectRunId: architectRun.id, + }, + }) + .onConflictDoNothing(); + }, + ); await options.enqueue?.("runs.dispatch", { runId: existingRun.id, orgId: proposal.orgId, @@ -219,59 +394,122 @@ async function executePlanAcceptance( return; } - const builderCommand = architectRun.engine === "codex" ? "codex-builder" : "builder"; const builder = await findAgentDef(db, proposal.orgId, proposal.projectId, builderCommand); if (!builder) throw new Error("plan_acceptance_builder_not_configured"); - const builderGh = { ...objectOrEmpty(architectRun.gh) }; - delete builderGh.progressComment; + const architectTrigger = objectOrEmpty(architectRun.trigger); + const proposalPayload = objectOrEmpty(proposal.payload); const architectCreator = objectOrEmpty(architectRun.createdBy); const githubLogin = stringField(architectTrigger.githubLogin) ?? (architectCreator.type === "github" ? (stringField(architectCreator.id) ?? stringField(architectCreator.login)) : null); + const builderTrigger = { + source: "plan_acceptance", + ...(githubLogin ? { githubLogin } : {}), + proposalId: proposal.id, + architectRunId: architectRun.id, + architectTrigger: architectRun.trigger, + approvedPlan: proposal.contextMd, + planSha256, + approval, + planProvenance: { + workspaceBaseSha: proposalPayload.workspaceBaseSha, + issueRevisionSha256: proposalPayload.issueRevisionSha256, + }, + ...(freshnessEvidence ? { admissionFreshness: freshnessEvidence } : {}), + }; + const builderPolicyInput = { + orgId: proposal.orgId, + projectId: proposal.projectId, + mode: "builder", + agentDefId: builder.id, + trigger: builderTrigger, + gh: builderGh, + actor: { type: auditActorType(actor.type), id: actor.id } as const, + source: "plan_acceptance_executor", + freshnessEvidence, + }; - const createdRun = ( - await db - .insert(runs) - .values({ - id: newId("run"), - orgId: proposal.orgId, - projectId: proposal.projectId, - agentDefId: builder.id, - mode: "builder", - engine: builder.engine, - trigger: { - source: "plan_acceptance", - ...(githubLogin ? { githubLogin } : {}), - proposalId: proposal.id, - architectRunId: architectRun.id, - architectTrigger: architectRun.trigger, - approvedPlan: proposal.contextMd, + const createdRun = await withBuilderPlanPreflight( + db, + builderPolicyInput, + async (tx, admission) => { + await assertPlatformBuilderLane(tx, proposal, architectRun, builderCommand); + const run = ( + await tx + // builder-plan-preflight: plan_acceptance_executor + .insert(runs) + .values({ + id: newId("run"), + orgId: proposal.orgId, + projectId: proposalProjectId, + agentDefId: builder.id, + mode: admission.mode, + engine: builder.engine, + trigger: builderTrigger, + gh: builderGh, + // Attribute the durable Builder row to the principal that approved the + // proposal, never to a later crash-recovery caller that happened to win + // the unique insert race. + createdBy: approvalCreator, + }) + .onConflictDoNothing() + .returning() + )[0]; + if (run) { + await tx.insert(runEvents).values({ + orgId: proposal.orgId, + runId: run.id, + seq: 1, + type: "queued", + data: { + queue: "runs.dispatch", + source: "plan_acceptance", + proposalId: proposal.id, + architectRunId: architectRun.id, + }, + }); + } + return run; + }, + ); + let run = createdRun ?? (await loadPlanBuilderRun(db, proposal)); + if (!run) { + const architectLinkedRun = await loadArchitectBuilderRun(db, proposal); + if (architectLinkedRun && (await builderPlanRequired(db, proposal.orgId, proposal.projectId))) { + await recordBuilderPlanDenial( + db, + { + orgId: proposal.orgId, + projectId: proposal.projectId, + mode: builderCommand, + agentDefId: builder.id, + trigger: { + source: "plan_acceptance", + proposalId: proposal.id, + architectRunId: architectRun.id, + }, + gh: builderGh, + actor: { type: auditActorType(actor.type), id: actor.id }, + source: "plan_acceptance_unique_conflict", }, - gh: builderGh, - createdBy: { type: actor.type, id: actor.id, proposalId: proposal.id }, - }) - .onConflictDoNothing() - .returning() - )[0]; - const run = createdRun ?? (await loadPlanBuilderRun(db, proposal)); + "builder_plan_already_consumed", + "architect_plan_linked_to_another_run", + ); + throw new ApiError( + 409, + "builder_plan_already_consumed", + "This Architect plan is already linked to another Builder run", + ); + } + // Preserve legacy Architect-level de-duplication for optional projects. + run = architectLinkedRun; + } if (!run) throw new Error("plan_acceptance_builder_run_not_created"); if (createdRun) { - await db.insert(runEvents).values({ - orgId: proposal.orgId, - runId: run.id, - seq: 1, - type: "queued", - data: { - queue: "runs.dispatch", - source: "plan_acceptance", - proposalId: proposal.id, - architectRunId: architectRun.id, - }, - }); await createBuilderProgressComment(db, createdRun, architectRun, builderCommand, options); } await options.enqueue?.("runs.dispatch", { runId: run.id, orgId: proposal.orgId }); @@ -375,7 +613,7 @@ async function createBuilderProgressComment( } } -async function loadPlanBuilderRun(db: Db, proposal: typeof proposals.$inferSelect) { +export async function loadPlanBuilderRun(db: Db, proposal: typeof proposals.$inferSelect) { return ( await db .select() @@ -384,7 +622,28 @@ async function loadPlanBuilderRun(db: Db, proposal: typeof proposals.$inferSelec and( eq(runs.orgId, proposal.orgId), eq(runs.projectId, proposal.projectId ?? ""), - eq(runs.mode, "builder"), + inArray(runs.mode, ["builder", "codex-builder"]), + sql`${runs.trigger} @> ${JSON.stringify({ + source: "plan_acceptance", + proposalId: proposal.id, + architectRunId: proposal.runId, + })}::jsonb`, + ), + ) + .limit(1) + )[0]; +} + +async function loadArchitectBuilderRun(db: Db, proposal: typeof proposals.$inferSelect) { + return ( + await db + .select() + .from(runs) + .where( + and( + eq(runs.orgId, proposal.orgId), + eq(runs.projectId, proposal.projectId ?? ""), + inArray(runs.mode, ["builder", "codex-builder"]), sql`${runs.trigger} @> ${JSON.stringify({ source: "plan_acceptance", architectRunId: proposal.runId, @@ -399,6 +658,7 @@ async function assertPlatformBuilderLane( db: Db, proposal: typeof proposals.$inferSelect, architectRun: typeof runs.$inferSelect, + builderCommand: string, ) { const projectRepos = await db .select() @@ -418,9 +678,23 @@ async function assertPlatformBuilderLane( const repo = matchedRepo ?? (!hasRepoIdentity && projectRepos.length === 1 ? projectRepos[0] : undefined); if (!repo) throw new Error("plan_acceptance_repo_context_ambiguous"); - if (laneFor(repo, "builder") !== "platform") { + if (laneFor(repo, builderCommand) !== "platform") { throw new Error("plan_acceptance_builder_uses_repo_lane"); } + if ( + (await builderPlanRequired(db, proposal.orgId, proposal.projectId ?? "")) && + (!repo.fingerprint || + repo.fingerprintStatus !== "ok" || + !repo.fingerprintVerifiedAt || + Date.now() - repo.fingerprintVerifiedAt.getTime() > 5 * 60_000) + ) { + throw new ApiError( + 409, + "builder_plan_context_invalid", + "The required Builder plan repository no longer has a verified Facility fingerprint", + { reason: "repository_fingerprint_unverified", repo: `${repo.owner}/${repo.name}` }, + ); + } } async function executeMcpToolCall( @@ -584,20 +858,35 @@ async function executeKnownMcpTool( const projectId = requiredString(args.projectId, "projectId"); const agentName = requiredString(args.agentName, "agentName"); const agent = await resolveAgentForMcpRun(db, orgId, projectId, agentName); + const trigger = { source: "mcp", agentName, input: args.input }; const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), + await withBuilderPlanPreflight( + db, + { orgId, projectId, - agentDefId: agent.id, mode: agent.name, - engine: agent.engine, - trigger: { source: "mcp", agentName, input: args.input }, - createdBy: actor, - }) - .returning() + agentDefId: agent.id, + trigger, + actor: { type: auditActorType(actor.type), id: actor.id }, + source: "mcp_trigger_run", + }, + (tx, admission) => + tx + // builder-plan-preflight: mcp_trigger_run + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId, + agentDefId: agent.id, + mode: admission.mode, + engine: agent.engine, + trigger, + createdBy: actor, + }) + .returning(), + ) )[0]; if (run) { await db.insert(runEvents).values({ @@ -731,25 +1020,42 @@ async function executeKnownMcpTool( throw new Error("run_not_resumable"); } const message = optionalString(args.message); + const trigger = { + type: "resume", + resumeOf: parent.id, + ...(message ? { message } : {}), + }; + const gh = resumableGithubContext(parent.gh); const resumed = ( - await db - .insert(runs) - .values({ - id: newId("run"), + await withBuilderPlanPreflight( + db, + { orgId, projectId: parent.projectId, - agentDefId: parent.agentDefId, mode: parent.mode, - engine: parent.engine, - trigger: { - type: "resume", - resumeOf: parent.id, - ...(message ? { message } : {}), - }, - gh: resumableGithubContext(parent.gh), - createdBy: actor, - }) - .returning({ id: runs.id }) + agentDefId: parent.agentDefId, + trigger, + gh, + actor: { type: auditActorType(actor.type), id: actor.id }, + source: "mcp_resume_run", + }, + (tx, admission) => + tx + // builder-plan-preflight: mcp_resume_run + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId: parent.projectId, + agentDefId: parent.agentDefId, + mode: admission.mode, + engine: parent.engine, + trigger, + gh, + createdBy: actor, + }) + .returning({ id: runs.id }), + ) )[0]; if (!resumed) throw new Error("run_resume_failed"); await db.insert(runEvents).values({ @@ -802,75 +1108,110 @@ async function executeKnownMcpTool( if (toolName === "facility_send_conversation_message") { const conversationId = requiredString(args.conversationId, "conversationId"); const body = requiredString(args.body, "body"); - const result = await db.transaction(async (tx) => { - await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`); - const conversation = ( + const conversationForPolicy = ( + await db + .select() + .from(conversations) + .where(and(eq(conversations.orgId, orgId), eq(conversations.id, conversationId))) + .limit(1) + )[0]; + if (!conversationForPolicy) throw new Error("conversation_not_found"); + const agentForPolicy = ( + await db + .select() + .from(agentDefs) + .where( + and( + eq(agentDefs.orgId, orgId), + eq(agentDefs.projectId, conversationForPolicy.projectId), + eq(agentDefs.id, conversationForPolicy.agentDefId), + ), + ) + .limit(1) + )[0]; + if (!agentForPolicy) throw new Error("conversation_agent_not_found"); + const result = await withBuilderPlanPreflight( + db, + { + orgId, + projectId: conversationForPolicy.projectId, + mode: "conversation", + agentDefId: agentForPolicy.id, + trigger: { type: "conversation", conversationId, message: body }, + actor: { type: auditActorType(actor.type), id: actor.id }, + source: "mcp_conversation_message", + }, + async (tx, admission) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`); + const conversation = ( + await tx + .update(conversations) + .set({ status: "running", updatedAt: new Date() }) + .where( + and( + eq(conversations.orgId, orgId), + eq(conversations.id, conversationId), + eq(conversations.status, "idle"), + ), + ) + .returning() + )[0]; + if (!conversation) throw new Error("conversation_turn_in_flight"); + const rows = await tx + .select({ max: sql`coalesce(max(${conversationMessages.seq}), 0)` }) + .from(conversationMessages) + .where(eq(conversationMessages.conversationId, conversationId)); + const message = ( + await tx + .insert(conversationMessages) + .values({ + id: newId("evt"), + orgId, + conversationId, + seq: Number(rows[0]?.max ?? 0) + 1, + role: "user", + body, + }) + .returning({ id: conversationMessages.id }) + )[0]; + const run = ( + await tx + // builder-plan-preflight: mcp_conversation_message + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId: conversation.projectId, + agentDefId: conversation.agentDefId, + mode: admission.mode, + engine: "claude_code", + trigger: { + type: "conversation", + conversationId, + message: body, + ...(conversation.engineSessionId && conversation.lastRunId + ? { resumeOf: conversation.lastRunId } + : {}), + }, + createdBy: actor, + }) + .returning({ id: runs.id }) + )[0]; + if (!message || !run) throw new Error("conversation_turn_create_failed"); await tx .update(conversations) - .set({ status: "running", updatedAt: new Date() }) - .where( - and( - eq(conversations.orgId, orgId), - eq(conversations.id, conversationId), - eq(conversations.status, "idle"), - ), - ) - .returning() - )[0]; - if (!conversation) throw new Error("conversation_turn_in_flight"); - const rows = await tx - .select({ max: sql`coalesce(max(${conversationMessages.seq}), 0)` }) - .from(conversationMessages) - .where(eq(conversationMessages.conversationId, conversationId)); - const message = ( - await tx - .insert(conversationMessages) - .values({ - id: newId("evt"), - orgId, - conversationId, - seq: Number(rows[0]?.max ?? 0) + 1, - role: "user", - body, - }) - .returning({ id: conversationMessages.id }) - )[0]; - const run = ( - await tx - .insert(runs) - .values({ - id: newId("run"), - orgId, - projectId: conversation.projectId, - agentDefId: conversation.agentDefId, - mode: "conversation", - engine: "claude_code", - trigger: { - type: "conversation", - conversationId, - message: body, - ...(conversation.engineSessionId && conversation.lastRunId - ? { resumeOf: conversation.lastRunId } - : {}), - }, - createdBy: actor, - }) - .returning({ id: runs.id }) - )[0]; - if (!message || !run) throw new Error("conversation_turn_create_failed"); - await tx - .update(conversations) - .set({ lastRunId: run.id, updatedAt: new Date() }) - .where(and(eq(conversations.orgId, orgId), eq(conversations.id, conversationId))); - await tx.insert(runEvents).values({ - orgId, - runId: run.id, - seq: 1, - type: "queued", - data: { queue: "runs.dispatch" }, - }); - return { messageId: message.id, runId: run.id }; - }); + .set({ lastRunId: run.id, updatedAt: new Date() }) + .where(and(eq(conversations.orgId, orgId), eq(conversations.id, conversationId))); + await tx.insert(runEvents).values({ + orgId, + runId: run.id, + seq: 1, + type: "queued", + data: { queue: "runs.dispatch" }, + }); + return { messageId: message.id, runId: run.id }; + }, + ); await options.enqueue?.("runs.dispatch", { runId: result.runId, orgId }); return { conversationId, ...result }; } @@ -918,25 +1259,42 @@ async function executeKnownMcpTool( )[0]; if (!repo) throw new Error("repo_not_found"); const agent = await resolveAgentForMcpRun(db, orgId, projectId, agentName); + const trigger = { + type: "mcp_issue", + repo: { id: repo.id, owner: repo.owner, name: repo.name }, + issue: { number }, + }; + const gh = { owner: repo.owner, repo: repo.name, issueNumber: number }; const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), + await withBuilderPlanPreflight( + db, + { orgId, projectId, + mode: agent.name, agentDefId: agent.id, - mode: agentName, - engine: agent.engine, - trigger: { - type: "mcp_issue", - repo: { id: repo.id, owner: repo.owner, name: repo.name }, - issue: { number }, - }, - gh: { owner: repo.owner, repo: repo.name, issueNumber: number }, - createdBy: actor, - }) - .returning({ id: runs.id }) + trigger, + gh, + actor: { type: auditActorType(actor.type), id: actor.id }, + source: "mcp_trigger_github_issue", + }, + (tx, admission) => + tx + // builder-plan-preflight: mcp_trigger_github_issue + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId, + agentDefId: agent.id, + mode: admission.mode, + engine: agent.engine, + trigger, + gh, + createdBy: actor, + }) + .returning({ id: runs.id }), + ) )[0]; if (!run) throw new Error("run_create_failed"); await db.insert(runEvents).values({ @@ -1040,24 +1398,28 @@ async function executeKnownMcpTool( const contractItemId = await resolveMcpAgentContract(db, orgId, projectId, args, actor.id); await assertMcpRegistryReference(db, orgId, projectId, optionalString(args.harnessItemId)); await assertMcpSandboxReference(db, orgId, projectId, optionalString(args.sandboxProfileId)); - const agent = ( - await db - .insert(agentDefs) - .values({ - id: newId("agent"), - orgId, - projectId, - name: requiredString(args.name, "name"), - engine: requiredString(args.engine, "engine"), - model: args.model ?? {}, - contractItemId, - harnessItemId: optionalString(args.harnessItemId), - triggers: Array.isArray(args.triggers) ? args.triggers : [], - sandboxProfileId: optionalString(args.sandboxProfileId), - enabled: true, - }) - .returning() - )[0]; + const agent = await db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, orgId, projectId); + return ( + await tx + .insert(agentDefs) + .values({ + id: newId("agent"), + orgId, + projectId, + name: requiredString(args.name, "name"), + engine: requiredString(args.engine, "engine"), + model: args.model ?? {}, + contractItemId, + harnessItemId: optionalString(args.harnessItemId), + triggers: Array.isArray(args.triggers) ? args.triggers : [], + sandboxProfileId: optionalString(args.sandboxProfileId), + enabled: true, + }) + .returning() + )[0]; + }); return { agentId: agent?.id, contractItemId }; } @@ -1100,19 +1462,23 @@ async function executeKnownMcpTool( updatedAt: new Date(), }; if (Object.keys(values).length === 1) throw new Error("mcp_tool_no_changes"); - const updated = ( - await db - .update(agentDefs) - .set(values) - .where( - and( - eq(agentDefs.orgId, orgId), - eq(agentDefs.projectId, projectId), - eq(agentDefs.id, agentId), - ), - ) - .returning({ id: agentDefs.id }) - )[0]; + const updated = await db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, orgId, projectId); + return ( + await tx + .update(agentDefs) + .set(values) + .where( + and( + eq(agentDefs.orgId, orgId), + eq(agentDefs.projectId, projectId), + eq(agentDefs.id, agentId), + ), + ) + .returning({ id: agentDefs.id }) + )[0]; + }); if (!updated) throw new Error("agent_not_found"); return { agentId: updated.id }; } @@ -1120,18 +1486,22 @@ async function executeKnownMcpTool( if (toolName === "facility_retire_agent") { const projectId = requiredString(args.projectId, "projectId"); const agentId = requiredString(args.agentId, "agentId"); - const retired = ( - await db - .delete(agentDefs) - .where( - and( - eq(agentDefs.orgId, orgId), - eq(agentDefs.projectId, projectId), - eq(agentDefs.id, agentId), - ), - ) - .returning({ id: agentDefs.id }) - )[0]; + const retired = await db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, orgId, projectId); + return ( + await tx + .delete(agentDefs) + .where( + and( + eq(agentDefs.orgId, orgId), + eq(agentDefs.projectId, projectId), + eq(agentDefs.id, agentId), + ), + ) + .returning({ id: agentDefs.id }) + )[0]; + }); if (!retired) throw new Error("agent_not_found"); return { agentId: retired.id, retired: true }; } @@ -1449,6 +1819,7 @@ function resumableGithubContext(value: unknown) { ...(typeof source.owner === "string" ? { owner: source.owner } : {}), ...(typeof source.repo === "string" ? { repo: source.repo } : {}), ...(typeof source.branch === "string" ? { branch: source.branch } : {}), + ...(typeof source.issueNumber === "number" ? { issueNumber: source.issueNumber } : {}), }; } @@ -1778,53 +2149,73 @@ async function connectMcpRepo( ) { const owner = requiredString(args.owner, "owner"); const name = requiredString(args.name, "name"); - const installation = ( - await db - .select() - .from(githubInstallations) - .where( - and( - eq(githubInstallations.orgId, orgId), - eq(githubInstallations.accountLogin, owner), - isNull(githubInstallations.suspendedAt), - ), - ) - .limit(1) - )[0]; - if (!installation) throw new Error("github_installation_required"); - const factory = options.githubFactory ?? createGithubClientFactory(requireConfig(options)); - const octokit = await factory(installation.installationId); const shouldCreate = args.create === true || args.mode === "create"; - const createRepository = octokit.rest.repos.createInOrg; - const getRepository = octokit.rest.repos.get; - if (shouldCreate && !createRepository) throw new Error("github_create_unavailable"); - if (!shouldCreate && !getRepository) throw new Error("github_lookup_unavailable"); - const response = shouldCreate - ? await createRepository?.({ - org: owner, - name, - private: args.private !== false, - description: optionalString(args.description), - auto_init: args.autoInit !== false, - }) - : await getRepository?.({ owner, repo: name }); - if (!response) throw new Error("github_repo_unavailable"); - const repo = ( - await db - .insert(repos) - .values({ - id: newId("repo"), - orgId, - projectId, - installationId: installation.id, - owner: response.data.owner?.login ?? owner, - name: response.data.name, - defaultBranch: response.data.default_branch ?? optionalString(args.defaultBranch) ?? "main", - }) - .returning() - )[0]; - if (!repo) throw new Error("repo_connect_failed"); - return { repoId: repo.id }; + return db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, orgId, projectId); + const project = ( + await tx + .select({ policy: projects.builderPlanPolicy }) + .from(projects) + .where(and(eq(projects.orgId, orgId), eq(projects.id, projectId))) + .limit(1) + )[0]; + if (!project) throw new Error("project_not_found"); + if (project.policy === "required") { + throw new ApiError( + 409, + "builder_plan_platform_lane_required", + "Set Builder plan policy to optional before connecting a repository; configure its Builder lane as platform, then re-enable required", + ); + } + const installation = ( + await tx + .select() + .from(githubInstallations) + .where( + and( + eq(githubInstallations.orgId, orgId), + eq(githubInstallations.accountLogin, owner), + isNull(githubInstallations.suspendedAt), + ), + ) + .limit(1) + )[0]; + if (!installation) throw new Error("github_installation_required"); + const factory = options.githubFactory ?? createGithubClientFactory(requireConfig(options)); + const octokit = await factory(installation.installationId); + const createRepository = octokit.rest.repos.createInOrg; + const getRepository = octokit.rest.repos.get; + if (shouldCreate && !createRepository) throw new Error("github_create_unavailable"); + if (!shouldCreate && !getRepository) throw new Error("github_lookup_unavailable"); + const response = shouldCreate + ? await createRepository?.({ + org: owner, + name, + private: args.private !== false, + description: optionalString(args.description), + auto_init: args.autoInit !== false, + }) + : await getRepository?.({ owner, repo: name }); + if (!response) throw new Error("github_repo_unavailable"); + const repo = ( + await tx + .insert(repos) + .values({ + id: newId("repo"), + orgId, + projectId, + installationId: installation.id, + owner: response.data.owner?.login ?? owner, + name: response.data.name, + defaultBranch: + response.data.default_branch ?? optionalString(args.defaultBranch) ?? "main", + }) + .returning() + )[0]; + if (!repo) throw new Error("repo_connect_failed"); + return { repoId: repo.id }; + }); } async function executeTaskCreation( diff --git a/services/api/src/github/agent-routing.ts b/services/api/src/github/agent-routing.ts new file mode 100644 index 00000000..2878f9d4 --- /dev/null +++ b/services/api/src/github/agent-routing.ts @@ -0,0 +1,37 @@ +import { agentDefs, type FacilityDb, type repos } from "@facility/db"; +import { and, eq } from "drizzle-orm"; + +export function laneFor(repo: typeof repos.$inferSelect, command: string): "repo" | "platform" { + const answers = repo.renderAnswers as { execution_lane?: Record } | null; + const lane = answers?.execution_lane?.[command] ?? answers?.execution_lane?.[`/${command}`]; + return lane === "platform" ? "platform" : "repo"; +} + +export async function findAgentDef( + db: FacilityDb, + orgId: string, + projectId: string, + command: string, +) { + const rows = await db + .select() + .from(agentDefs) + .where( + and( + eq(agentDefs.orgId, orgId), + eq(agentDefs.projectId, projectId), + eq(agentDefs.enabled, true), + ), + ); + return rows.find((row) => { + const triggers = row.triggers as unknown; + if (!Array.isArray(triggers)) return row.name === command; + return triggers.some((trigger) => { + if (!trigger || typeof trigger !== "object") return false; + const value = + (trigger as { command?: unknown; handle?: unknown }).command ?? + (trigger as { handle?: unknown }).handle; + return value === command || value === `/${command}`; + }); + }); +} diff --git a/services/api/src/github/architect-plan-publication.ts b/services/api/src/github/architect-plan-publication.ts new file mode 100644 index 00000000..e331e19f --- /dev/null +++ b/services/api/src/github/architect-plan-publication.ts @@ -0,0 +1,81 @@ +export function architectPlanPublicationKey(runId: string, proposalId: string) { + return `architect-plan:${runId}:${proposalId}`; +} + +export function architectPlanPublicationMarker(runId: string, proposalId: string) { + return ``; +} + +export function legacyRunProgressMarker(runId: string) { + return ``; +} + +export function isGithubNotFound(error: unknown) { + if (!error || typeof error !== "object") return false; + const candidate = error as { + status?: unknown; + statusCode?: unknown; + response?: { status?: unknown }; + }; + return ( + candidate.status === 404 || candidate.statusCode === 404 || candidate.response?.status === 404 + ); +} + +export function findArchitectPlanPublicationComment( + comments: T[], + input: { runId: string; publicationMarker: string; allowLegacy: boolean }, +) { + const bots = comments.filter((comment) => comment.authorType.toLowerCase() === "bot"); + return ( + bots.find((comment) => comment.body.includes(input.publicationMarker)) ?? + (input.allowLegacy + ? bots.find((comment) => comment.body.includes(legacyRunProgressMarker(input.runId))) + : undefined) + ); +} + +export function rotateArchitectPlanPublicationOrgIds(orgIds: string[], now: Date, limit: number) { + if (limit <= 0 || orgIds.length === 0) return []; + if (orgIds.length <= limit) return [...orgIds]; + const minuteBucket = Math.floor(now.getTime() / 60_000); + const offset = (minuteBucket * limit) % orgIds.length; + return Array.from( + { length: limit }, + (_, index) => orgIds[(offset + index) % orgIds.length] ?? "", + ); +} + +export function effectiveArchitectPlanProposalState( + state: string, + expiresAt: Date, + now = new Date(), +) { + const open = state === "open" && expiresAt.getTime() > now.getTime(); + return { open, state: state === "open" && !open ? "expired" : state }; +} + +export function renderClosedArchitectPlanPublication(input: { + runId: string; + plan: string; + proposalState: string; + publicationMarker: string; + updatedAt?: Date; +}) { + return [ + ``, + input.publicationMarker, + "### ✅ Facility Architect plan", + "", + `**Human Gate 1:** no longer open (\`${input.proposalState}\`)`, + `**Run:** \`${input.runId}\``, + "", + "This is the plan snapshot produced by the completed Architect run. Facility will not accept a new Builder approval from this closed proposal.", + "", + "## Plan snapshot", + "", + input.plan.slice(0, 48_000), + "", + `_Last updated: ${(input.updatedAt ?? new Date()).toISOString()}_`, + ].join("\n"); +} diff --git a/services/api/src/github/client.ts b/services/api/src/github/client.ts index a5e0a77b..269c89f2 100644 --- a/services/api/src/github/client.ts +++ b/services/api/src/github/client.ts @@ -132,6 +132,7 @@ export type Octokit = { number: number; title: string; body?: string | null; + state?: string; html_url: string; user?: { login?: string } | null; labels?: Array; diff --git a/services/api/src/github/issue-revision.ts b/services/api/src/github/issue-revision.ts new file mode 100644 index 00000000..96596628 --- /dev/null +++ b/services/api/src/github/issue-revision.ts @@ -0,0 +1,125 @@ +import { createHash } from "node:crypto"; + +export type GithubIssueRevisionComment = { + id: number; + author: string; + authorType: string; + body: string; + createdAt: string; + url: string; +}; + +type GithubIssueLike = { + title?: unknown; + body?: unknown; + state?: unknown; + user?: { login?: unknown } | null; + labels?: unknown; + html_url?: unknown; +}; + +const FACILITY_COMMENT_MARKERS = ["", + "### 🛑 Facility `/builder` blocked", + "", + "No Builder run was created because Human Gate 1 did not have valid plan evidence.", + "", + guidance[code] ?? guidance.builder_plan_context_invalid, + "", + `**Policy reason:** \`${code}\``, + ].join("\n"); +} + export function progressCommentId(gh: unknown) { const root = objectOrEmpty(gh); const progress = objectOrEmpty(root.progressComment); diff --git a/services/api/src/integrations/inbound.ts b/services/api/src/integrations/inbound.ts index e2945b90..295bc3aa 100644 --- a/services/api/src/integrations/inbound.ts +++ b/services/api/src/integrations/inbound.ts @@ -10,6 +10,7 @@ import { runs, } from "@facility/db"; import { and, eq } from "drizzle-orm"; +import { withBuilderPlanPreflight } from "../builder-plan-policy.js"; import { ApiError } from "../errors.js"; import { type IssueSeverity, @@ -185,25 +186,42 @@ async function maybeEnqueueRun( const projectId = await resolveProjectId(db, event.orgId, integration.projectId, payload, config); if (!projectId) throw new Error("generic_inbound_run_project_required"); const agent = await resolveAgent(db, event.orgId, projectId, runConfig, config); + const mode = stringField(runConfig, "mode") ?? stringField(config, "mode") ?? agent.name; + const engine = stringField(runConfig, "engine") ?? stringField(config, "engine") ?? agent.engine; + const trigger = { + type: "generic_inbound", + integrationId: integration.id, + inboundEventId: event.id, + eventType: event.eventType, + }; const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), + await withBuilderPlanPreflight( + db, + { orgId: event.orgId, projectId, + mode, agentDefId: agent.id, - mode: stringField(runConfig, "mode") ?? stringField(config, "mode") ?? agent.name, - engine: stringField(runConfig, "engine") ?? stringField(config, "engine") ?? agent.engine, - trigger: { - type: "generic_inbound", - integrationId: integration.id, - inboundEventId: event.id, - eventType: event.eventType, - }, - createdBy: { type: "system", id: `integration:${integration.id}` }, - }) - .returning() + trigger, + actor: { type: "system", id: `integration:${integration.id}` }, + source: "generic_inbound", + }, + (tx, admission) => + tx + // builder-plan-preflight: inbound_dispatch + .insert(runs) + .values({ + id: newId("run"), + orgId: event.orgId, + projectId, + agentDefId: agent.id, + mode: admission.mode, + engine, + trigger, + createdBy: { type: "system", id: `integration:${integration.id}` }, + }) + .returning(), + ) )[0]; if (!run) throw new Error("generic_inbound_run_insert_failed"); await db.insert(runEvents).values({ diff --git a/services/api/src/learning.ts b/services/api/src/learning.ts index 79cb9dc0..3d73709e 100644 --- a/services/api/src/learning.ts +++ b/services/api/src/learning.ts @@ -15,6 +15,7 @@ import { spendCounters, } from "@facility/db"; import { and, desc, eq, gte, inArray, lte } from "drizzle-orm"; +import { isBuilderPlanDenialError, withBuilderPlanPreflight } from "./builder-plan-policy.js"; import { createGithubClientFactory, FacilityGithubClient, @@ -473,21 +474,42 @@ export async function runLearningNightly( await attachGithubReviewEvidence(db, basePacket, githubFactory), ); const packetUrl = `facility://learning-packets/${agent.projectId}/${packet.date}`; - const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), - orgId: agent.orgId, - projectId: agent.projectId, - agentDefId: agent.id, - mode: "learning", - engine: agent.engine, - trigger: { type: "schedule", packetUrl, packet }, - createdBy: { type: "system", id: "learning.nightly" }, - }) - .returning() - )[0]; + const trigger = { type: "schedule", packetUrl, packet }; + let run: typeof runs.$inferSelect | undefined; + try { + run = ( + await withBuilderPlanPreflight( + db, + { + orgId: agent.orgId, + projectId: agent.projectId, + mode: "learning", + agentDefId: agent.id, + trigger, + actor: { type: "system", id: "learning.nightly" }, + source: "learning_nightly", + }, + (tx, admission) => + tx + // builder-plan-preflight: learning_nightly + .insert(runs) + .values({ + id: newId("run"), + orgId: agent.orgId, + projectId: agent.projectId, + agentDefId: agent.id, + mode: admission.mode, + engine: agent.engine, + trigger, + createdBy: { type: "system", id: "learning.nightly" }, + }) + .returning(), + ) + )[0]; + } catch (error) { + if (isBuilderPlanDenialError(error)) continue; + throw error; + } if (run) { createdRuns.push(run.id); await enqueue("runs.dispatch", { runId: run.id, orgId: run.orgId }); diff --git a/services/api/src/routes/internal.ts b/services/api/src/routes/internal.ts index b4ba4c24..69ad884d 100644 --- a/services/api/src/routes/internal.ts +++ b/services/api/src/routes/internal.ts @@ -24,6 +24,7 @@ import { import type { AppConfig } from "../types.js"; const Params = z.object({ runId: z.string() }); +const GitCommitSha = z.string().regex(/^[0-9a-f]{40}$/i); const TRANSCRIPT_MAX_BYTES = 50 * 1024 * 1024; const SESSION_STATE_MAX_BYTES = 200 * 1024 * 1024; const EventBatch = z.array( @@ -168,6 +169,45 @@ export async function registerInternalRoutes(app: FastifyInstance, config: AppCo }, ); + app.post( + "/internal/runs/:runId/workspace", + { + config: { public: true }, + preHandler: authenticate, + schema: { + params: Params, + body: z.object({ baseSha: GitCommitSha }), + response: { 200: z.object({ baseSha: GitCommitSha }) }, + }, + }, + async (request) => { + const run = (request as RunnerRequest).runnerRun; + if (!run) throw notFound("Run not found"); + const baseSha = (request.body as { baseSha: string }).baseSha.toLowerCase(); + const [recorded] = await db + .update(runs) + .set({ workspaceBaseSha: baseSha, updatedAt: new Date() }) + .where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id), isNull(runs.workspaceBaseSha))) + .returning({ baseSha: runs.workspaceBaseSha }); + if (recorded?.baseSha) return { baseSha: recorded.baseSha }; + + // Runner lifecycle requests may be replayed after a lost response. The + // checkpoint is immutable: an exact replay succeeds, while a different + // SHA cannot rewrite the provenance already bound to this run. + const [current] = await db + .select({ baseSha: runs.workspaceBaseSha }) + .from(runs) + .where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id))) + .limit(1); + if (current?.baseSha === baseSha) return { baseSha }; + throw new ApiError( + 409, + "workspace_base_mismatch", + "Run workspace base commit was already recorded", + ); + }, + ); + app.post( "/internal/runs/:runId/events", { @@ -426,6 +466,7 @@ export async function registerInternalRoutes(app: FastifyInstance, config: AppCo .object({ branch: z.string().optional(), headSha: z.string().optional(), + baseSha: GitCommitSha.optional(), changed: z.boolean(), pushError: z.string().optional(), pullRequestTitle: z.string().optional(), @@ -448,6 +489,7 @@ export async function registerInternalRoutes(app: FastifyInstance, config: AppCo git?: { branch?: string; headSha?: string; + baseSha?: string; changed: boolean; pushError?: string; pullRequestTitle?: string; diff --git a/services/api/src/routes/v1/agents-sandboxes.ts b/services/api/src/routes/v1/agents-sandboxes.ts index b3a5fd99..7c2a607c 100644 --- a/services/api/src/routes/v1/agents-sandboxes.ts +++ b/services/api/src/routes/v1/agents-sandboxes.ts @@ -1,8 +1,9 @@ import { newId } from "@facility/core"; -import { agentDefs, projects, registryItems, sandboxProfiles } from "@facility/db"; +import { agentDefs, type FacilityDb, projects, registryItems, sandboxProfiles } from "@facility/db"; import { and, asc, eq } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; +import { lockBuilderPlanPolicy } from "../../builder-plan-policy.js"; import { ApiError } from "../../errors.js"; import { nestedDockerSettingIsValid, @@ -258,25 +259,30 @@ function registerCrud( validateAgentSchedules(body.triggers); await assertAgentReferences(p, params.projectId, body); if (body.permissions) assertPermissionsGrantable(p, body.permissions); - return ( - await app.facilityDb - .insert(table) - .values({ - id: newId(prefix), - orgId: p.orgId, - projectId: rowProjectId, - name: body.name, - engine: body.engine, - model: body.model, - contractItemId: body.contractItemId, - harnessItemId: body.harnessItemId, - triggers: body.triggers, - sandboxProfileId: body.sandboxProfileId, - permissions: body.permissions ?? [], - enabled: body.enabled, - }) - .returning() - )[0]; + if (!rowProjectId) throw new ApiError(400, "project_required", "Agent project is required"); + return app.facilityDb.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, p.orgId, rowProjectId); + return ( + await tx + .insert(table) + .values({ + id: newId(prefix), + orgId: p.orgId, + projectId: rowProjectId, + name: body.name, + engine: body.engine, + model: body.model, + contractItemId: body.contractItemId, + harnessItemId: body.harnessItemId, + triggers: body.triggers, + sandboxProfileId: body.sandboxProfileId, + permissions: body.permissions ?? [], + enabled: body.enabled, + }) + .returning() + )[0]; + }); } const body = request.body as { name: string; @@ -366,13 +372,20 @@ function registerCrud( network: (request.body as { network?: Record }).network, updatedAt: new Date(), }); - return ( - await app.facilityDb + const update = (database: FacilityDb) => + database .update(table) .set(set) .where(and(...clauses)) - .returning() - )[0]; + .returning(); + if (prefix === "agent") { + return app.facilityDb.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, p.orgId, existing.projectId); + return (await update(tx))[0]; + }); + } + return (await update(app.facilityDb))[0]; }, ); app.delete( @@ -397,7 +410,15 @@ function registerCrud( } const clauses = [eq(table.orgId, p.orgId), eq(table.id, id)]; if (projectId && table.projectId) clauses.push(eq(table.projectId, projectId)); - await app.facilityDb.delete(table).where(and(...clauses)); + if (prefix === "agent") { + await app.facilityDb.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, p.orgId, existing.projectId); + await tx.delete(table).where(and(...clauses)); + }); + } else { + await app.facilityDb.delete(table).where(and(...clauses)); + } return { ok: true }; }, ); diff --git a/services/api/src/routes/v1/assistant.ts b/services/api/src/routes/v1/assistant.ts index 7e4d5729..4bb9ca04 100644 --- a/services/api/src/routes/v1/assistant.ts +++ b/services/api/src/routes/v1/assistant.ts @@ -11,6 +11,7 @@ import { and, eq, sql } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { type AssistantModelDriver, runAssistantTurn } from "../../assistant/loop.js"; +import { withBuilderPlanPreflight } from "../../builder-plan-policy.js"; import { ApiError, notFound } from "../../errors.js"; import { terminalStatus } from "../../sandbox/state.js"; import { assertProjectScope, IdParams, principal, type V1RouteContext } from "./shared.js"; @@ -75,6 +76,11 @@ export async function registerAssistantRoutes(app: FastifyInstance, context: V1R if (!owner) { throw new ApiError(400, "no_owner_agent", "Project has no project-owner agent"); } + const policyTrigger = { + type: "conversation", + ...(body.conversationId ? { conversationId: body.conversationId } : {}), + message: body.body, + }; const ownerModel = modelFrom(owner.model) ?? "claude-sonnet-5"; let conversationId = body.conversationId; @@ -122,86 +128,100 @@ export async function registerAssistantRoutes(app: FastifyInstance, context: V1R conversationId = created.id; } - const result = await db.transaction(async (tx) => { - await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`); - const thread = ( - await tx - .select() - .from(conversations) - .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId))) - .limit(1) - )[0]; - if (!thread) throw notFound("Conversation not found"); - if (thread.status === "running") { - // Self-heal: a turn whose pinned run already ended (or died without a - // trace — API crash before the reconcile sweep) must not deadlock the - // thread. Anything genuinely in flight stays locked. - const pinned = thread.lastRunId - ? ( - await tx - .select({ status: runs.status, startedAt: runs.startedAt }) - .from(runs) - .where(and(eq(runs.orgId, p.orgId), eq(runs.id, thread.lastRunId))) - .limit(1) - )[0] - : undefined; - const stale = - !pinned || - terminalStatus(pinned.status) || - (pinned.startedAt !== null && Date.now() - pinned.startedAt.getTime() > STALE_TURN_MS); - if (!stale) { - throw new ApiError(409, "turn_in_flight", "The thread already has a turn in flight"); + const result = await withBuilderPlanPreflight( + db, + { + orgId: p.orgId, + projectId, + mode: "assistant", + agentDefId: owner.id, + trigger: policyTrigger, + actor: { type: p.type, id: p.id }, + source: "assistant_conversation", + }, + async (tx, admission) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`); + const thread = ( + await tx + .select() + .from(conversations) + .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId))) + .limit(1) + )[0]; + if (!thread) throw notFound("Conversation not found"); + if (thread.status === "running") { + // Self-heal: a turn whose pinned run already ended (or died without a + // trace — API crash before the reconcile sweep) must not deadlock the + // thread. Anything genuinely in flight stays locked. + const pinned = thread.lastRunId + ? ( + await tx + .select({ status: runs.status, startedAt: runs.startedAt }) + .from(runs) + .where(and(eq(runs.orgId, p.orgId), eq(runs.id, thread.lastRunId))) + .limit(1) + )[0] + : undefined; + const stale = + !pinned || + terminalStatus(pinned.status) || + (pinned.startedAt !== null && + Date.now() - pinned.startedAt.getTime() > STALE_TURN_MS); + if (!stale) { + throw new ApiError(409, "turn_in_flight", "The thread already has a turn in flight"); + } } - } - await tx - .update(conversations) - .set({ status: "running", updatedAt: new Date() }) - .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId))); - const seqRows = await tx - .select({ max: sql`coalesce(max(seq), 0)` }) - .from(conversationMessages) - .where(eq(conversationMessages.conversationId, conversationId)); - const seq = Number(seqRows[0]?.max ?? 0) + 1; - const message = ( await tx - .insert(conversationMessages) - .values({ - id: newId("evt"), - orgId: p.orgId, - conversationId, - seq, - role: "user", - body: body.body, - }) - .returning() - )[0]; - // status starts at "running" — never "queued": the reconcile backstop - // re-enqueues stale queued runs into runs.dispatch, which would launch - // a sandbox for what is an in-process turn. - const run = ( + .update(conversations) + .set({ status: "running", updatedAt: new Date() }) + .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId))); + const seqRows = await tx + .select({ max: sql`coalesce(max(seq), 0)` }) + .from(conversationMessages) + .where(eq(conversationMessages.conversationId, conversationId)); + const seq = Number(seqRows[0]?.max ?? 0) + 1; + const message = ( + await tx + .insert(conversationMessages) + .values({ + id: newId("evt"), + orgId: p.orgId, + conversationId, + seq, + role: "user", + body: body.body, + }) + .returning() + )[0]; + // status starts at "running" — never "queued": the reconcile backstop + // re-enqueues stale queued runs into runs.dispatch, which would launch + // a sandbox for what is an in-process turn. + const run = ( + await tx + // builder-plan-preflight: assistant_conversation + .insert(runs) + .values({ + id: newId("run"), + orgId: p.orgId, + projectId, + agentDefId: owner.id, + mode: admission.mode, + engine: "inline", + status: "running", + startedAt: new Date(), + trigger: { type: "conversation", conversationId, message: body.body }, + createdBy: { type: p.type, id: p.id }, + }) + .returning() + )[0]; + if (!message || !run) throw new Error("assistant_turn_create_failed"); await tx - .insert(runs) - .values({ - id: newId("run"), - orgId: p.orgId, - projectId, - agentDefId: owner.id, - mode: "assistant", - engine: "inline", - status: "running", - startedAt: new Date(), - trigger: { type: "conversation", conversationId, message: body.body }, - createdBy: { type: p.type, id: p.id }, - }) - .returning() - )[0]; - if (!message || !run) throw new Error("assistant_turn_create_failed"); - await tx - .update(conversations) - .set({ lastRunId: run.id, updatedAt: new Date() }) - .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId))); - return { message, run }; - }); + .update(conversations) + .set({ lastRunId: run.id, updatedAt: new Date() }) + .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId))); + return { message, run }; + }, + ); await insertAuditEvent(db, { orgId: p.orgId, diff --git a/services/api/src/routes/v1/conversations.ts b/services/api/src/routes/v1/conversations.ts index 8463dc8a..840ef685 100644 --- a/services/api/src/routes/v1/conversations.ts +++ b/services/api/src/routes/v1/conversations.ts @@ -10,6 +10,7 @@ import { import { and, desc, eq, sql } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; +import { withBuilderPlanPreflight } from "../../builder-plan-policy.js"; import { ApiError, notFound } from "../../errors.js"; import { assertBareRowProjectScope, @@ -191,79 +192,94 @@ export async function registerConversationsRoutes(app: FastifyInstance, context: if (conversation.status === "running") { throw new ApiError(409, "turn_in_flight", "Conversation already has a turn in flight"); } - const result = await db.transaction(async (tx) => { - await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`); - const claimed = ( + const agent = await loadAgent(p.orgId, conversation.projectId, conversation.agentDefId); + if (!agent) throw new ApiError(409, "conversation_agent_not_found", "Agent not found"); + const result = await withBuilderPlanPreflight( + db, + { + orgId: p.orgId, + projectId: conversation.projectId, + mode: "conversation", + agentDefId: agent.id, + trigger: { type: "conversation", conversationId, message: body.body }, + actor: { type: p.type, id: p.id }, + source: "rest_conversation_message", + }, + async (tx, admission) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`); + const claimed = ( + await tx + .update(conversations) + .set({ status: "running", updatedAt: new Date() }) + .where( + and( + eq(conversations.orgId, p.orgId), + eq(conversations.id, conversationId), + eq(conversations.status, "idle"), + ), + ) + .returning() + )[0]; + if (!claimed) return null; + const rows = await tx + .select({ max: sql`coalesce(max(seq), 0)` }) + .from(conversationMessages) + .where(eq(conversationMessages.conversationId, conversationId)); + const seq = Number(rows[0]?.max ?? 0) + 1; + const message = ( + await tx + .insert(conversationMessages) + .values({ + id: newId("evt"), + orgId: p.orgId, + conversationId, + seq, + role: "user", + body: body.body, + }) + .returning() + )[0]; + const trigger: Record = { + type: "conversation", + conversationId, + message: body.body, + }; + if (claimed.engineSessionId && claimed.lastRunId) trigger.resumeOf = claimed.lastRunId; + const run = ( + await tx + // builder-plan-preflight: rest_conversation_message + .insert(runs) + .values({ + id: newId("run"), + orgId: p.orgId, + projectId: claimed.projectId, + agentDefId: claimed.agentDefId, + mode: admission.mode, + engine: "claude_code", + trigger, + createdBy: { type: p.type, id: p.id }, + }) + .returning() + )[0]; + if (!message || !run) throw new Error("conversation_turn_create_failed"); + // Pin the run that OWNS this running turn. Both finalize paths + // (finishConversationTurn / releaseConversationOnFailure) require the + // finishing run to be this one — so a forged run carrying another + // conversation's id can't release or append to a thread it doesn't own. await tx .update(conversations) - .set({ status: "running", updatedAt: new Date() }) - .where( - and( - eq(conversations.orgId, p.orgId), - eq(conversations.id, conversationId), - eq(conversations.status, "idle"), - ), - ) - .returning() - )[0]; - if (!claimed) return null; - const rows = await tx - .select({ max: sql`coalesce(max(seq), 0)` }) - .from(conversationMessages) - .where(eq(conversationMessages.conversationId, conversationId)); - const seq = Number(rows[0]?.max ?? 0) + 1; - const message = ( - await tx - .insert(conversationMessages) - .values({ - id: newId("evt"), - orgId: p.orgId, - conversationId, - seq, - role: "user", - body: body.body, - }) - .returning() - )[0]; - const trigger: Record = { - type: "conversation", - conversationId, - message: body.body, - }; - if (claimed.engineSessionId && claimed.lastRunId) trigger.resumeOf = claimed.lastRunId; - const run = ( - await tx - .insert(runs) - .values({ - id: newId("run"), - orgId: p.orgId, - projectId: claimed.projectId, - agentDefId: claimed.agentDefId, - mode: "conversation", - engine: "claude_code", - trigger, - createdBy: { type: p.type, id: p.id }, - }) - .returning() - )[0]; - if (!message || !run) throw new Error("conversation_turn_create_failed"); - // Pin the run that OWNS this running turn. Both finalize paths - // (finishConversationTurn / releaseConversationOnFailure) require the - // finishing run to be this one — so a forged run carrying another - // conversation's id can't release or append to a thread it doesn't own. - await tx - .update(conversations) - .set({ lastRunId: run.id, updatedAt: new Date() }) - .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId))); - await tx.insert(runEvents).values({ - orgId: p.orgId, - runId: run.id, - seq: 1, - type: "queued", - data: { queue: "runs.dispatch" }, - }); - return { message, run }; - }); + .set({ lastRunId: run.id, updatedAt: new Date() }) + .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId))); + await tx.insert(runEvents).values({ + orgId: p.orgId, + runId: run.id, + seq: 1, + type: "queued", + data: { queue: "runs.dispatch" }, + }); + return { message, run }; + }, + ); if (!result) { throw new ApiError(409, "turn_in_flight", "Conversation already has a turn in flight"); } diff --git a/services/api/src/routes/v1/github.ts b/services/api/src/routes/v1/github.ts index fa1ec2fc..1eee38f1 100644 --- a/services/api/src/routes/v1/github.ts +++ b/services/api/src/routes/v1/github.ts @@ -15,6 +15,7 @@ import { import { and, desc, eq, gte, inArray, isNull, lt, or, type SQL, sql } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; +import { assertBuilderPlanDispatch, withBuilderPlanPreflight } from "../../builder-plan-policy.js"; import { ApiError, notFound } from "../../errors.js"; import { createGithubClientFactory, type GithubClientFactory } from "../../github/client.js"; import { createGithubClientForRepo, syncRepoFacilityConfig } from "../../github/kickstart.js"; @@ -938,6 +939,15 @@ export async function registerGithubV1Routes(app: FastifyInstance, context: V1Ro // context that cannot be dispatched. const agent = await findAgentDef(db, p.orgId, projectId, body.agent); if (!agent) throw new ApiError(400, "agent_not_found", "Agent definition not found"); + await assertBuilderPlanDispatch(db, { + orgId: p.orgId, + projectId, + mode: agent.name, + agentDefId: agent.id, + trigger: { type: "web_issue" }, + actor: { type: p.type, id: p.id }, + source: "web_issue_preflight", + }); const githubFactory = app.githubClientFactory ?? (config.githubAppId && config.githubAppPrivateKey @@ -966,6 +976,7 @@ export async function registerGithubV1Routes(app: FastifyInstance, context: V1Ro number: githubIssue.number, title: githubIssue.title, body: githubIssue.body, + state: githubIssue.state, user: { login: githubIssue.user?.login }, labels: githubIssue.labels ?? [], html_url: githubIssue.html_url, @@ -974,30 +985,47 @@ export async function registerGithubV1Routes(app: FastifyInstance, context: V1Ro issueComments, ); assertGithubRequestContextSize(issueRequest); + const trigger = { + type: "web_issue", + ...(p.githubLogin ? { githubLogin: p.githubLogin } : {}), + repo: { id: repo.id, owner: repo.owner, name: repo.name }, + issue: { number }, + request: issueRequest, + }; + const gh = { owner: repo.owner, repo: repo.name, issueNumber: number }; // No GitHub userCanWrite check: platform RBAC `runs:trigger` is the authority // for control-plane-originated dispatch. // No execution_lane gate: an explicit control-plane trigger is platform-lane intent. const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), + await withBuilderPlanPreflight( + db, + { orgId: p.orgId, projectId, - agentDefId: agent.id, mode: body.agent, - engine: agent.engine, - trigger: { - type: "web_issue", - ...(p.githubLogin ? { githubLogin: p.githubLogin } : {}), - repo: { id: repo.id, owner: repo.owner, name: repo.name }, - issue: { number }, - request: issueRequest, - }, - gh: { owner: repo.owner, repo: repo.name, issueNumber: number }, - createdBy: { type: p.type, id: p.id }, - }) - .returning() + agentDefId: agent.id, + trigger, + gh, + actor: { type: p.type, id: p.id }, + source: "web_issue", + }, + (tx, admission) => + tx + // builder-plan-preflight: rest_github_issue + .insert(runs) + .values({ + id: newId("run"), + orgId: p.orgId, + projectId, + agentDefId: agent.id, + mode: admission.mode, + engine: agent.engine, + trigger, + gh, + createdBy: { type: p.type, id: p.id }, + }) + .returning(), + ) )[0]; if (!run) throw new ApiError(500, "insert_failed", "Could not create run"); await db.insert(runEvents).values({ diff --git a/services/api/src/routes/v1/hitl.ts b/services/api/src/routes/v1/hitl.ts index 207cd7bd..df3554ec 100644 --- a/services/api/src/routes/v1/hitl.ts +++ b/services/api/src/routes/v1/hitl.ts @@ -1,5 +1,12 @@ import { can, newId } from "@facility/core"; -import { actionTypes, platformIssues, proposalEvents, proposals, runs } from "@facility/db"; +import { + actionTypes, + platformIssues, + projects, + proposalEvents, + proposals, + runs, +} from "@facility/db"; import { and, asc, desc, eq, gt, inArray, isNull, or } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; @@ -116,9 +123,32 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC .orderBy(desc(proposals.createdAt), desc(proposals.id)) .limit(query.limit) .offset(query.offset); + const failedProposalIds = proposalRows + .filter(({ proposal }) => proposal.state === "execution_failed") + .map(({ proposal }) => proposal.id); + const failureEvents = failedProposalIds.length + ? await db + .select() + .from(proposalEvents) + .where( + and( + eq(proposalEvents.orgId, p.orgId), + eq(proposalEvents.type, "execution_failed"), + inArray(proposalEvents.proposalId, failedProposalIds), + ), + ) + .orderBy(desc(proposalEvents.seq)) + : []; + const executionErrors = new Map(); + for (const event of failureEvents) { + if (!executionErrors.has(event.proposalId)) { + executionErrors.set(event.proposalId, executionErrorFromEvents([event])); + } + } const proposalResponses = proposalRows.map(({ proposal, actionType }) => ({ ...proposal, actionType, + executionError: executionErrors.get(proposal.id) ?? null, })); // Issues are watchtower alerts, not proposals: the `state` query param // scopes proposals only. Always surface actionable (error/critical) issues @@ -173,7 +203,7 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC .from(proposalEvents) .where(and(eq(proposalEvents.orgId, p.orgId), eq(proposalEvents.proposalId, proposalId))) .orderBy(asc(proposalEvents.seq)); - return { ...proposal, events }; + return { ...proposal, executionError: executionErrorFromEvents(events), events }; }, ); @@ -369,6 +399,13 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC actionType = await ensureIssueUpdateActionType(p.orgId); } if (!actionType) throw notFound("Action type not found"); + if (actionType.name === "plan_acceptance") { + throw new ApiError( + 403, + "reserved_action_type", + "plan_acceptance proposals are created only from a completed Facility Architect run", + ); + } // mcp_tool_call dispatches to privileged operations (create agent, set // budget, publish registry version, …) whose per-tool permission is checked // ONLY on the dedicated /v1/mcp/tool-proposals route. The generic route @@ -446,6 +483,27 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC .limit(1) )[0]; if (!proposal) throw new ApiError(409, "not_open", "Proposal is not open"); + if ( + body.decision === "approve" && + proposal.actionType.name === "plan_acceptance" && + proposal.proposal.projectId && + p.type !== "user" + ) { + const project = ( + await tx + .select({ builderPlanPolicy: projects.builderPlanPolicy }) + .from(projects) + .where(and(eq(projects.orgId, p.orgId), eq(projects.id, proposal.proposal.projectId))) + .limit(1) + )[0]; + if (project?.builderPlanPolicy === "required") { + throw new ApiError( + 403, + "builder_plan_approval_principal_required", + "Required Builder plans must be approved by a human user principal", + ); + } + } const opener = ( await tx .select() @@ -537,7 +595,11 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC )[0]; if (!proposal) throw notFound("Proposal not found"); assertBareRowProjectScope(p, proposal.projectId, "Proposal not found"); - if (proposal.state !== "execution_failed" && proposal.state !== "approved") { + if ( + proposal.state !== "execution_failed" && + proposal.state !== "approved" && + proposal.state !== "executing" + ) { throw new ApiError(409, "not_executable", "Proposal is not pending execution"); } const claimed = await executeApprovedProposal( @@ -644,7 +706,28 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC if (!actionType) { throw new ApiError(500, "invalid_proposal", "Proposal action type is missing"); } - return { ...proposal, actionType: actionType.name }; + return { + ...proposal, + actionType: actionType.name, + executionError: await proposalExecutionError(proposal), + }; + } + + async function proposalExecutionError(proposal: typeof proposals.$inferSelect) { + if (proposal.state !== "execution_failed") return null; + const events = await db + .select() + .from(proposalEvents) + .where( + and( + eq(proposalEvents.orgId, proposal.orgId), + eq(proposalEvents.proposalId, proposal.id), + eq(proposalEvents.type, "execution_failed"), + ), + ) + .orderBy(desc(proposalEvents.seq)) + .limit(1); + return executionErrorFromEvents(events); } async function proposalRunProject(p: Principal, runId: string | undefined) { @@ -661,6 +744,15 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC } } +function executionErrorFromEvents(events: Array) { + const failed = [...events].reverse().find((event) => event.type === "execution_failed"); + const data = failed?.data; + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + const error = (data as { error?: unknown }).error; + if (typeof error !== "string") return null; + return /^[a-z0-9_:-]{1,160}$/i.test(error) ? error : "execution_failed"; +} + function sameActor(left: unknown, right: { type: string; id: string }) { return ( typeof left === "object" && diff --git a/services/api/src/routes/v1/kb-tasks.ts b/services/api/src/routes/v1/kb-tasks.ts index dcaabf83..97a83a8b 100644 --- a/services/api/src/routes/v1/kb-tasks.ts +++ b/services/api/src/routes/v1/kb-tasks.ts @@ -19,6 +19,7 @@ import { artifactIdFor, validate } from "@facility/harness"; import { and, asc, desc, eq, ilike, or } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; +import { withBuilderPlanPreflight } from "../../builder-plan-policy.js"; import { ApiError, notFound } from "../../errors.js"; import { ensureActive, @@ -1137,31 +1138,46 @@ export async function registerKbTasksRoutes(app: FastifyInstance, context: V1Rou if (!owner) { throw new ApiError(400, "no_owner_agent", "Project has no enabled project-owner agent"); } + const trigger = { + type: "kb_intake", + entryId: entry.id, + artifactId, + source: body.source, + title: body.title, + instruction: + "A new capture landed in the KB. Read it, read the pipeline and the active decisions, then propose the backlog and decision changes it implies — one proposal per change, citing " + + artifactId + + " as evidence.", + }; const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), + await withBuilderPlanPreflight( + db, + { orgId: p.orgId, projectId, - agentDefId: owner.id, mode: owner.name, - engine: owner.engine, - trigger: { - type: "kb_intake", - entryId: entry.id, - artifactId, - source: body.source, - title: body.title, - instruction: - "A new capture landed in the KB. Read it, read the pipeline and the active decisions, then propose the backlog and decision changes it implies — one proposal per change, citing " + - artifactId + - " as evidence.", - }, - gh: {}, - createdBy: { type: p.type, id: p.id }, - }) - .returning() + agentDefId: owner.id, + trigger, + actor: { type: p.type, id: p.id }, + source: "kb_intake_dispatch", + }, + (tx, admission) => + tx + // builder-plan-preflight: kb_intake_dispatch + .insert(runs) + .values({ + id: newId("run"), + orgId: p.orgId, + projectId, + agentDefId: owner.id, + mode: admission.mode, + engine: owner.engine, + trigger, + gh: {}, + createdBy: { type: p.type, id: p.id }, + }) + .returning(), + ) )[0]; if (!run) throw new ApiError(500, "insert_failed", "Could not dispatch the review run"); await db.insert(runEvents).values({ diff --git a/services/api/src/routes/v1/projects-repos.ts b/services/api/src/routes/v1/projects-repos.ts index 53c0d3df..8ff90ffb 100644 --- a/services/api/src/routes/v1/projects-repos.ts +++ b/services/api/src/routes/v1/projects-repos.ts @@ -4,17 +4,21 @@ import { analysisSandboxProfileId, builderSandboxProfileId, defaultSandboxProfileId, + type FacilityDb, githubInstallations, projects, registryItems, repos, + runs, sandboxProfiles, withOrg, } from "@facility/db"; -import { and, asc, eq, isNull } from "drizzle-orm"; +import { and, asc, eq, isNull, notInArray } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; +import { lockBuilderPlanPolicy } from "../../builder-plan-policy.js"; import { ApiError, notFound } from "../../errors.js"; +import { laneFor } from "../../github/agent-routing.js"; import { createGithubClientFactory } from "../../github/client.js"; import { ensureProjectKbSpace } from "../../harness.js"; import { projectHealth } from "../../watchtower/health.js"; @@ -72,6 +76,7 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context: name: z.string(), slug: z.string(), description: z.string().optional(), + builderPlanPolicy: z.enum(["optional", "required"]).optional(), settings: AnyObject.optional(), }), response: { 200: ProjectSchema }, @@ -83,6 +88,7 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context: name: string; slug: string; description?: string; + builderPlanPolicy?: "optional" | "required"; settings?: Record; }; const project = ( @@ -94,6 +100,7 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context: name: body.name, slug: body.slug, description: body.description, + builderPlanPolicy: body.builderPlanPolicy ?? "optional", settings: body.settings ?? { default_branch: "main", check_cmds: [] }, }) .returning() @@ -151,6 +158,7 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context: name: z.string().optional(), description: z.string().optional(), status: z.string().optional(), + builderPlanPolicy: z.enum(["optional", "required"]).optional(), settings: AnyObject.optional(), }), response: { 200: ProjectSchema }, @@ -160,21 +168,41 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context: const p = principal(request); const { projectId } = request.params as { projectId: string }; assertProjectScope(p, projectId); - return ( - await db - .update(projects) - .set( - definedFields({ - name: (request.body as { name?: string }).name, - description: (request.body as { description?: string }).description, - status: (request.body as { status?: string }).status, - settings: (request.body as { settings?: Record }).settings, - updatedAt: new Date(), - }), - ) - .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId))) - .returning() - )[0]; + const requestedPolicy = (request.body as { builderPlanPolicy?: "optional" | "required" }) + .builderPlanPolicy; + return db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, p.orgId, projectId); + const currentProject = ( + await tx + .select({ policy: projects.builderPlanPolicy }) + .from(projects) + .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId))) + .limit(1) + )[0]; + if (!currentProject) throw notFound("Project not found"); + if (requestedPolicy === "required" && currentProject.policy !== "required") { + await assertPlatformBuilderLanes(tx, p.orgId, projectId); + await assertNoActiveRuns(tx, p.orgId, projectId); + } + return ( + await tx + .update(projects) + .set( + definedFields({ + name: (request.body as { name?: string }).name, + description: (request.body as { description?: string }).description, + status: (request.body as { status?: string }).status, + builderPlanPolicy: (request.body as { builderPlanPolicy?: "optional" | "required" }) + .builderPlanPolicy, + settings: (request.body as { settings?: Record }).settings, + updatedAt: new Date(), + }), + ) + .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId))) + .returning() + )[0]; + }); }, ); @@ -251,36 +279,73 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context: description?: string; autoInit: boolean; }; - const creation = body.create === true || body.mode === "create"; - const installation = await loadGithubInstallation(p.orgId, body.owner); - const githubRepo = creation - ? await createGithubRepository({ - installationId: installation.installationId, - owner: body.owner, - name: body.name, - description: body.description, - private: body.private, - autoInit: body.autoInit, - }) - : await loadGithubRepository({ - installationId: installation.installationId, - owner: body.owner, - name: body.name, - }); - const row = ( + const project = ( await db - .insert(repos) - .values({ - id: newId("repo"), - orgId: p.orgId, - projectId, - installationId: installation.id, - owner: githubRepo.owner, - name: githubRepo.name, - defaultBranch: githubRepo.defaultBranch ?? body.defaultBranch, - }) - .returning() + .select({ policy: projects.builderPlanPolicy }) + .from(projects) + .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId))) + .limit(1) )[0]; + if (!project) throw notFound("Project not found"); + if (project.policy === "required") { + throw new ApiError( + 409, + "builder_plan_platform_lane_required", + "Set Builder plan policy to optional before connecting a repository; configure its Builder lane as platform, then re-enable required", + ); + } + const creation = body.create === true || body.mode === "create"; + const installation = await loadGithubInstallation(p.orgId, body.owner); + const row = await db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, p.orgId, projectId); + const currentProject = ( + await tx + .select({ policy: projects.builderPlanPolicy }) + .from(projects) + .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId))) + .limit(1) + )[0]; + if (!currentProject) throw notFound("Project not found"); + if (currentProject.policy === "required") { + throw new ApiError( + 409, + "builder_plan_platform_lane_required", + "Set Builder plan policy to optional before connecting a repository; configure its Builder lane as platform, then re-enable required", + ); + } + // Keep the project lock through the remote create/load and local insert. + // Activation cannot slip between the final policy read and the new, + // initially-unverified repository row becoming visible. + const githubRepo = creation + ? await createGithubRepository({ + installationId: installation.installationId, + owner: body.owner, + name: body.name, + description: body.description, + private: body.private, + autoInit: body.autoInit, + }) + : await loadGithubRepository({ + installationId: installation.installationId, + owner: body.owner, + name: body.name, + }); + return ( + await tx + .insert(repos) + .values({ + id: newId("repo"), + orgId: p.orgId, + projectId, + installationId: installation.id, + owner: githubRepo.owner, + name: githubRepo.name, + defaultBranch: githubRepo.defaultBranch ?? body.defaultBranch, + }) + .returning() + )[0]; + }); if (row) { await app.enqueue("github.issues-sync", { repoId: row.id, orgId: p.orgId }); } @@ -288,6 +353,64 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context: }, ); + async function assertPlatformBuilderLanes( + database: FacilityDb, + orgId: string, + projectId: string, + ) { + const projectRepos = await database + .select() + .from(repos) + .where(and(eq(repos.orgId, orgId), eq(repos.projectId, projectId))); + const now = Date.now(); + const incompatible = projectRepos.filter( + (repo) => + laneFor(repo, "builder") !== "platform" || + laneFor(repo, "codex-builder") !== "platform" || + !repo.fingerprint || + repo.fingerprintStatus !== "ok" || + !repo.fingerprintVerifiedAt || + now - repo.fingerprintVerifiedAt.getTime() > 5 * 60_000, + ); + if (incompatible.length > 0) { + throw new ApiError( + 409, + "builder_plan_platform_lane_required", + "Builder plan policy required needs a recent verified default-branch Facility fingerprint and platform lanes for /builder and /codex-builder in every connected repository", + { + repos: incompatible.map((repo) => ({ + repo: `${repo.owner}/${repo.name}`, + fingerprintStatus: repo.fingerprintStatus, + fingerprintVerifiedAt: repo.fingerprintVerifiedAt?.toISOString() ?? null, + })), + }, + ); + } + } + + async function assertNoActiveRuns(database: FacilityDb, orgId: string, projectId: string) { + const active = await database + .select({ + id: runs.id, + }) + .from(runs) + .where( + and( + eq(runs.orgId, orgId), + eq(runs.projectId, projectId), + notInArray(runs.status, ["succeeded", "failed", "canceled"]), + ), + ); + if (active.length > 0) { + throw new ApiError( + 409, + "builder_plan_active_runs_present", + "Wait for every existing run to finish before enabling the required plan policy", + { runIds: active.map((run) => run.id) }, + ); + } + } + async function loadGithubInstallation(orgId: string, owner: string) { const installation = await findGithubInstallation(orgId, owner); if (!installation) { @@ -393,9 +516,15 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context: async (request) => { const p = principal(request); const { projectId, repoId } = request.params as { projectId: string; repoId: string }; - await db - .delete(repos) - .where(and(eq(repos.orgId, p.orgId), eq(repos.projectId, projectId), eq(repos.id, repoId))); + await db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, p.orgId, projectId); + await tx + .delete(repos) + .where( + and(eq(repos.orgId, p.orgId), eq(repos.projectId, projectId), eq(repos.id, repoId)), + ); + }); return { ok: true }; }, ); diff --git a/services/api/src/routes/v1/runs.ts b/services/api/src/routes/v1/runs.ts index 54e5c5b1..ac1dec28 100644 --- a/services/api/src/routes/v1/runs.ts +++ b/services/api/src/routes/v1/runs.ts @@ -14,6 +14,7 @@ import { and, desc, eq, notInArray, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply } from "fastify"; import postgres from "postgres"; import { z } from "zod"; +import { withBuilderPlanPreflight } from "../../builder-plan-policy.js"; import { readTranscriptObject } from "../../envelopes.js"; import { ApiError, notFound } from "../../errors.js"; import { cancelRun } from "../../sandbox/orchestrator.js"; @@ -216,16 +217,16 @@ export async function registerRunsRoutes(app: FastifyInstance, context: V1RouteC ); } const agent = await resolveRunAgentDef(p.orgId, projectId, body); - const trigger = validatedRunTrigger(agent.name, body.trigger); - if ("githubLogin" in trigger) { - delete trigger.githubLogin; - if (p.githubLogin) trigger.githubLogin = p.githubLogin; + const policyTrigger = { ...(body.trigger ?? {}) }; + if ("githubLogin" in policyTrigger) { + delete policyTrigger.githubLogin; + if (p.githubLogin) policyTrigger.githubLogin = p.githubLogin; } // Preserve issue provenance across generic dispatch (retries pass the // source run's trigger): without this, a retried issue-run loses its // gh linkage and disappears from the issue's history and the pipeline. - const triggerRepo = trigger.repo as { owner?: unknown; name?: unknown } | undefined; - const triggerIssue = trigger.issue as { number?: unknown } | undefined; + const triggerRepo = policyTrigger.repo as { owner?: unknown; name?: unknown } | undefined; + const triggerIssue = policyTrigger.issue as { number?: unknown } | undefined; const gh = typeof triggerRepo?.owner === "string" && typeof triggerRepo?.name === "string" && @@ -233,26 +234,48 @@ export async function registerRunsRoutes(app: FastifyInstance, context: V1RouteC ? { owner: triggerRepo.owner, repo: triggerRepo.name, issueNumber: triggerIssue.number } : {}; const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), + await withBuilderPlanPreflight( + db, + { orgId: p.orgId, projectId, - agentDefId: agent.id, - // The selected agent owns its governed role. Caller labels such as - // "manual" must not bypass role-specific progress, delivery, receipt, - // or read-only invariants in the runner. mode: agent.name, - // The agent definition owns execution engine selection. Persisting a - // caller-supplied default made CLI/MCP-triggered Claude/BYO runs look - // like Codex runs even though orchestration correctly used the agent. - engine: agent.engine, - trigger, - gh, - createdBy: { type: p.type, id: p.id }, - }) - .returning() + agentDefId: agent.id, + trigger: policyTrigger, + actor: auditActor(p), + source: "rest_run", + }, + (tx, admission) => { + // Required Builder governance is evaluated before the legacy + // objective validation. This keeps the stable Gate 1 denial (and + // no-row invariant) authoritative for every Builder request while + // optional projects retain the existing objective error. + const trigger = validatedRunTrigger(admission.mode, policyTrigger); + return ( + tx + // builder-plan-preflight: rest_run + .insert(runs) + .values({ + id: newId("run"), + orgId: p.orgId, + projectId, + agentDefId: agent.id, + // Persist the role admitted under the project lock. Builder + // classification cannot later disappear when an agent's name or + // command triggers are edited. + mode: admission.mode, + // The agent definition owns execution engine selection. Persisting a + // caller-supplied default made CLI/MCP-triggered Claude/BYO runs look + // like Codex runs even though orchestration correctly used the agent. + engine: agent.engine, + trigger, + gh, + createdBy: { type: p.type, id: p.id }, + }) + .returning() + ); + }, + ) )[0]; if (!run) throw new ApiError(500, "insert_failed", "Could not create run"); await db.insert(runEvents).values({ @@ -296,25 +319,40 @@ export async function registerRunsRoutes(app: FastifyInstance, context: V1RouteC "Consult dispatches read-only architect agents only", ); } + const trigger = { + type: "consult", + question: body.question, + requestedBy: { type: p.type, id: p.id }, + }; const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), + await withBuilderPlanPreflight( + db, + { orgId: p.orgId, projectId, - agentDefId: agent.id, mode: agent.name, - engine: agent.engine, - trigger: { - type: "consult", - question: body.question, - requestedBy: { type: p.type, id: p.id }, - }, - gh: {}, - createdBy: { type: p.type, id: p.id }, - }) - .returning() + agentDefId: agent.id, + trigger, + actor: auditActor(p), + source: "rest_consult", + }, + (tx, admission) => + tx + // builder-plan-preflight: rest_consult + .insert(runs) + .values({ + id: newId("run"), + orgId: p.orgId, + projectId, + agentDefId: agent.id, + mode: admission.mode, + engine: agent.engine, + trigger, + gh: {}, + createdBy: { type: p.type, id: p.id }, + }) + .returning(), + ) )[0]; if (!run) throw new ApiError(500, "insert_failed", "Could not create run"); await db.insert(runEvents).values({ @@ -773,20 +811,35 @@ export async function registerRunsRoutes(app: FastifyInstance, context: V1RouteC const trigger: Record = { type: "resume", resumeOf: parent.id }; if (body.message !== undefined) trigger.message = body.message; const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), + await withBuilderPlanPreflight( + db, + { orgId: p.orgId, projectId: parent.projectId, - agentDefId: parent.agentDefId, mode: parent.mode, - engine: parent.engine, + agentDefId: parent.agentDefId, trigger, gh, - createdBy: { type: p.type, id: p.id }, - }) - .returning() + actor: auditActor(p), + source: "rest_resume", + }, + (tx, admission) => + tx + // builder-plan-preflight: rest_resume_run + .insert(runs) + .values({ + id: newId("run"), + orgId: p.orgId, + projectId: parent.projectId, + agentDefId: parent.agentDefId, + mode: admission.mode, + engine: parent.engine, + trigger, + gh, + createdBy: { type: p.type, id: p.id }, + }) + .returning(), + ) )[0]; if (!run) throw new ApiError(500, "run_create_failed", "Run could not be created"); await db.insert(runEvents).values({ @@ -823,6 +876,7 @@ function parentGhForResume(value: unknown) { if (typeof gh.owner === "string") out.owner = gh.owner; if (typeof gh.repo === "string") out.repo = gh.repo; if (typeof gh.branch === "string") out.branch = gh.branch; + if (typeof gh.issueNumber === "number") out.issueNumber = gh.issueNumber; return out; } diff --git a/services/api/src/routes/v1/shared.ts b/services/api/src/routes/v1/shared.ts index f1368cb9..656cc7a3 100644 --- a/services/api/src/routes/v1/shared.ts +++ b/services/api/src/routes/v1/shared.ts @@ -139,6 +139,7 @@ export const ProjectSchema = z.object({ slug: z.string(), description: z.string().nullable(), systemVersion: z.string(), + builderPlanPolicy: z.enum(["optional", "required"]), settings: AnyObject, status: z.string(), createdAt: DateValue, @@ -202,6 +203,7 @@ export const RunSchema = z.object({ engineSessionId: z.string().nullable(), transcriptUri: z.string().nullable(), sessionStateUri: z.string().nullable(), + workspaceBaseSha: z.string().nullable(), error: z.string().nullable(), queuedAt: DateValue, startedAt: DateValue.nullable(), @@ -261,6 +263,8 @@ export const ProposalSchema = z.object({ payload: AnyObject, contextMd: z.string(), state: z.string(), + /** Stable executor failure code when the latest execution attempt failed. */ + executionError: z.string().nullable().optional(), decidedBy: z.string().nullable(), decidedAt: DateValue.nullable(), expiresAt: DateValue, diff --git a/services/api/src/sandbox/orchestrator.ts b/services/api/src/sandbox/orchestrator.ts index f4b653da..8d93f2dc 100644 --- a/services/api/src/sandbox/orchestrator.ts +++ b/services/api/src/sandbox/orchestrator.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { allowedModelsForEngine, type FacilityReceipt, @@ -23,6 +24,7 @@ import { kbSpaces, llmRequests, outcomes, + platformIssues, projects, proposalEvents, proposals, @@ -37,13 +39,36 @@ import { sandboxProfiles, virtualKeys, } from "@facility/db"; -import { and, desc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; +import { isBuilderMode } from "@facility/run-objective"; +import { and, asc, desc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; +import { + type BuilderPlanFreshnessOptions, + resolveBuilderPlanFreshnessForRun, +} from "../builder-plan-freshness.js"; +import { + assertBuilderPlanDispatch, + builderPlanDenialCode, + builderPlanRequired, + recordBuilderPlanDenial, + withBuilderPlanPreflight, +} from "../builder-plan-policy.js"; +import { ApiError } from "../errors.js"; +import { + architectPlanPublicationKey, + architectPlanPublicationMarker, + effectiveArchitectPlanProposalState, + findArchitectPlanPublicationComment, + isGithubNotFound, + renderClosedArchitectPlanPublication, + rotateArchitectPlanPublicationOrgIds, +} from "../github/architect-plan-publication.js"; import { createGithubClientFactory, FacilityGithubClient, type GithubClientFactory, } from "../github/client.js"; import { pullRequestBodyForIssue } from "../github/closing-issues.js"; +import { githubIssueRevisionSha256 } from "../github/issue-revision.js"; import { type GithubRunProgressPhase, progressCommentId, @@ -84,12 +109,61 @@ type FinishRunDeps = { config?: AppConfig; githubClientFactory?: GithubClientFactory; enqueue?: (queue: string, data: Record) => Promise; + /** Test seam for proving terminal run + proposal commit atomicity. */ + afterArchitectPlanOutboxWrite?: () => Promise | void; }; type DispatchRunDeps = { sandboxDriver?: (name: SandboxDriverName) => Promise; + githubFactory?: GithubClientFactory; + githubClient?: BuilderPlanFreshnessOptions["githubClient"]; +}; + +type ArchitectPlanPublicationJob = { + proposalId?: string; + orgId?: string; }; const RESUME_FALLBACK_SCOPE_MAX_BYTES = 32 * 1024; +const ARCHITECT_PLAN_PUBLICATION_LIMIT = 25; +const ARCHITECT_PLAN_PUBLICATION_TIMEOUT_MS = 20_000; + +function architectPlanPublicationBaseEligibility(now: Date) { + return and( + eq(actionTypes.name, "plan_acceptance"), + eq(runs.status, "succeeded"), + sql`(${runs.mode} = 'architect' or ${runs.mode} like '%-architect')`, + sql`( + ${platformIssues.id} is null + or ${platformIssues.state} = 'resolved' + or ${platformIssues.lastSeen} <= ${now.toISOString()}::timestamptz + - (interval '1 minute' * (1 << least(greatest(${platformIssues.count} - 1, 0), 6))) + )`, + ); +} + +function globalArchitectPlanPublicationEligibility(now: Date) { + return and( + architectPlanPublicationBaseEligibility(now), + sql`not exists ( + select 1 + from proposal_events publication + where publication.org_id = ${proposals.orgId} + and publication.proposal_id = ${proposals.id} + and publication.type in ('publication_suppressed', 'publication_closed') + ) + and ( + not exists ( + select 1 + from proposal_events publication + where publication.org_id = ${proposals.orgId} + and publication.proposal_id = ${proposals.id} + and publication.type = 'published' + ) + or ${proposals.state} <> 'open' + or ${proposals.expiresAt} <= ${now.toISOString()}::timestamptz + )`, + ); +} export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: DispatchRunDeps = {}) { if (!job.runId || !job.orgId) throw new Error("runs.dispatch requires runId and orgId"); @@ -99,9 +173,36 @@ export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: Dis // (failRun revokes by persisted sandbox, which wouldn't yet carry these ids). const createdKeys: RunSandboxState = {}; let launchedSandbox: { driver: SandboxDriver; ref: string } | undefined; + let run: RunRow | undefined; + let freshnessFailureSource: "worker_initial_freshness" | "worker_claimed_freshness" | null = null; try { - const run = await loadRun(db, job.orgId, job.runId); + run = await loadRun(db, job.orgId, job.runId); if (run?.status !== "queued") return; + const requiredBuilderPlan = + isBuilderMode(run.mode) && (await builderPlanRequired(db, run.orgId, run.projectId)); + const requiredPlanFreshness = + requiredBuilderPlan && objectOrEmpty(run.trigger).source === "plan_acceptance"; + freshnessFailureSource = requiredPlanFreshness ? "worker_initial_freshness" : null; + const initialFreshness = requiredPlanFreshness + ? await resolveBuilderPlanFreshnessForRun(db, run, { + config, + githubFactory: deps.githubFactory, + githubClient: deps.githubClient, + }) + : undefined; + freshnessFailureSource = null; + await assertBuilderPlanDispatch(db, { + orgId: run.orgId, + projectId: run.projectId, + mode: run.mode, + agentDefId: run.agentDefId, + trigger: run.trigger, + gh: run.gh, + runId: run.id, + actor: { type: "system", id: "runs.dispatch" }, + source: "worker_dispatch", + freshnessEvidence: initialFreshness, + }); // Claim the run atomically. If a duplicate queue delivery raced us and // another worker already moved it out of "queued", the update touches no // rows and we must NOT launch a second sandbox for the same run. @@ -111,9 +212,72 @@ export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: Dis .where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id), eq(runs.status, "queued"))) .returning({ id: runs.id }); if (claimed.length === 0) return; + // Re-evaluate after the atomic claim. The project policy or mutable agent + // definition may change between the producer preflight and queue delivery; + // claiming first prevents another worker racing ahead while this final + // check fails. The outer failure boundary marks the row failed before any + // credential or sandbox side effect is created. + freshnessFailureSource = requiredPlanFreshness ? "worker_claimed_freshness" : null; + const claimedFreshness = requiredPlanFreshness + ? await resolveBuilderPlanFreshnessForRun(db, run, { + config, + githubFactory: deps.githubFactory, + githubClient: deps.githubClient, + }) + : undefined; + freshnessFailureSource = null; + const claimedRunScope = { orgId: run.orgId, runId: run.id }; + const dispatchSnapshot = await withBuilderPlanPreflight( + db, + { + orgId: run.orgId, + projectId: run.projectId, + mode: run.mode, + agentDefId: run.agentDefId, + trigger: run.trigger, + gh: run.gh, + runId: run.id, + actor: { type: "system", id: "runs.dispatch" }, + source: "worker_claimed_dispatch", + freshnessEvidence: claimedFreshness, + }, + async (tx, admission) => { + let claimedRun = await loadRun(tx, claimedRunScope.orgId, claimedRunScope.runId); + if (claimedRun?.status !== "provisioning") return null; + if (claimedRun.mode !== admission.mode) { + const sealed = ( + await tx + .update(runs) + .set({ mode: admission.mode, updatedAt: new Date() }) + .where(and(eq(runs.orgId, claimedRun.orgId), eq(runs.id, claimedRun.id))) + .returning() + )[0]; + if (!sealed) return null; + claimedRun = sealed; + } + // Agent definitions are mutable. Build the complete execution snapshot + // while holding the same lock used by name/trigger/contract mutations so + // the identity that passed Gate 1 is exactly the identity launched. + return buildRunBundle(tx, claimedRun, config); + }, + ); + if (!dispatchSnapshot) return; + if (claimedFreshness) { + await appendRunEvents(db, run.orgId, run.id, [ + { + type: "builder_plan_admitted", + data: { + proposalId: stringValue(objectOrEmpty(run.trigger).proposalId), + baseSha: claimedFreshness.baseSha, + issueRevisionSha256: claimedFreshness.issueRevisionSha256, + checkedAt: claimedFreshness.checkedAt, + }, + }, + ]); + } await appendRunEvents(db, run.orgId, run.id, [{ type: "provisioning", data: {} }]); - const { bundle, profile, agentPermissions } = await buildRunBundle(db, run, config); + const { bundle, profile, agentPermissions } = dispatchSnapshot; const virtualKey = await generateApiKey("fvk"); await db.insert(virtualKeys).values({ id: virtualKey.id, @@ -261,9 +425,33 @@ export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: Dis ]); await updateGithubRunProgress(db, run.id, "provisioning", { config }).catch(() => undefined); } catch (error) { - await failRun(db, job.orgId, job.runId, errorMessage(error), "provision_failed").catch( - () => undefined, - ); + const builderPlanCode = error instanceof ApiError ? builderPlanDenialCode(error.code) : null; + if (builderPlanCode && freshnessFailureSource && run) { + await recordBuilderPlanDenial( + db, + { + orgId: run.orgId, + projectId: run.projectId, + mode: run.mode, + agentDefId: run.agentDefId, + trigger: run.trigger, + gh: run.gh, + runId: run.id, + actor: { type: "system", id: "runs.dispatch" }, + source: freshnessFailureSource, + }, + builderPlanCode, + stringValue(objectOrEmpty(error instanceof ApiError ? error.details : null).reason) ?? + "freshness_resolution_failed", + ).catch(() => undefined); + } + await failRun( + db, + job.orgId, + job.runId, + builderPlanCode ?? errorMessage(error), + builderPlanCode ? "builder_plan_denied" : "provision_failed", + ).catch(() => undefined); await updateGithubRunProgress(db, job.runId, "failed", { config }).catch(() => undefined); // failRun revokes by the persisted sandbox, which on a pre-persist failure // wouldn't carry these — so revoke every key we minted, and destroy the @@ -288,6 +476,7 @@ export async function finishRun( git?: { branch?: string; headSha?: string; + baseSha?: string; changed: boolean; pushError?: string; pullRequestTitle?: string; @@ -298,7 +487,18 @@ export async function finishRun( }, deps?: FinishRunDeps, ) { - if (terminalStatus(run.status)) return run; + if (terminalStatus(run.status)) { + // Current writes commit Architect success and its Gate 1 proposal together. + // Keep this repair path for legacy/incomplete rows and missing canonical + // origin events; network delivery remains the cron reconciler's job. + if (run.status === "succeeded" && isArchitectMode(run.mode)) { + const receipt = FacilityReceiptSchema.safeParse(run.receipt); + if (receipt.success && verifyFacilityReceipt(receipt.data)) { + await openArchitectPlanAcceptance(db, run, receipt.data); + } + } + return run; + } // A harness run that succeeds must leave the KB valid. If the checkpoint // fails, the run is a FAILURE — but resources must still be reclaimed, so we // downgrade status here and fall through to cleanup rather than throwing and @@ -347,8 +547,19 @@ export async function finishRun( } const sandbox = readSandbox(run.sandbox); const aggregate = await gatewayAggregate(db, run.id); - let receipt = await canonicalRunReceipt(db, run, input.receipt, aggregate, status); - const claimed = await db.transaction(async (tx) => { + // A delivery receipt names the published range. Runs without a delivery + // still expose the prepared workspace base they actually inspected. + const receiptBaseSha = input.git?.baseSha ?? run.workspaceBaseSha; + let receipt = await canonicalRunReceipt( + db, + run, + input.receipt, + aggregate, + status, + receiptBaseSha, + ); + const claim = await db.transaction(async (transaction) => { + const tx = transaction as unknown as ReturnType["db"]; const terminal = ( await tx .update(runs) @@ -362,9 +573,10 @@ export async function finishRun( sandbox: { ...sandbox, finishedAt: new Date().toISOString() }, updatedAt: new Date(), }) - // The terminal status and durable delivery intent are one commit. A - // process crash can therefore never strand a successfully pushed - // branch without the metadata needed to publish its pull request. + // Terminal status, durable PR delivery intent, and the Architect Gate + // 1 proposal/open event commit together. A process crash can therefore + // strand neither a pushed branch nor a succeeded GitHub Architect run + // without the durable metadata its delivery reconciler needs. .where(and(eq(runs.id, run.id), notInArray(runs.status, [...TERMINAL_RUN_STATUSES]))) .returning() )[0]; @@ -372,9 +584,15 @@ export async function finishRun( if (status === "succeeded" && deliveryPlan) { await tx.insert(runDeliveries).values(deliveryPlan); } - return terminal; + const architectProposal = + status === "succeeded" && isArchitectMode(terminal.mode) + ? await ensureArchitectPlanAcceptance(tx, terminal, receipt) + : undefined; + if (architectProposal) await deps?.afterArchitectPlanOutboxWrite?.(); + return { run: terminal, architectProposalId: architectProposal?.id }; }); - if (!claimed) return run; + if (!claim) return run; + const claimed = claim.run; if (sandbox.driver && sandbox.ref) { const driver = await sandboxDriver(sandbox.driver); const destroyed = await driver @@ -411,29 +629,20 @@ export async function finishRun( ) { await recordRunPullRequestUpdate(db, claimed, input.git.branch, input.git.headSha); } - if (status === "succeeded" && isArchitectMode(run.mode)) { + if (status === "succeeded" && claim.architectProposalId) { try { - await openArchitectPlanAcceptance(db, claimed, receipt, deps); + await publishArchitectPlanAcceptance(db, run.orgId, claim.architectProposalId, deps); } catch (planError) { - const message = errorMessage(planError); - status = "failed"; - error = `plan_publication_failed:${message}`; - receipt = await canonicalRunReceipt(db, run, input.receipt, aggregate, status); - await db - .update(runs) - .set({ status, receipt, error, updatedAt: new Date() }) - .where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id))); - await appendRunEvents(db, run.orgId, run.id, [ - { type: "artifact_error", data: { kind: "plan_publication_failed", error: message } }, - ]); - await raisePlatformIssue(db, { + // The sealed Architect work and canonical proposal are already durable. + // A transient GitHub publication failure is an artifact-delivery error, + // not a reason to rewrite a succeeded receipt as failed. The scheduled + // reconciler and terminal finish retry re-enter the idempotent publisher. + await recordArchitectPlanPublicationFailure(db, { orgId: run.orgId, projectId: run.projectId, - kind: "plan_publication_failed", - severity: "error", - fingerprint: `plan_publication_failed:${run.id}`, - title: "Failed to publish architect plan", - bodyMd: `Architect run ${run.id} completed, but Facility could not publish its plan and human approval gate.\n\n${message}`, + runId: run.id, + proposalId: claim.architectProposalId, + error: planError, }); } } @@ -459,7 +668,14 @@ export async function finishRun( const message = errorMessage(syncError); status = "failed"; error = `security_issue_sync_failed:${message}`; - receipt = await canonicalRunReceipt(db, run, input.receipt, aggregate, status); + receipt = await canonicalRunReceipt( + db, + run, + input.receipt, + aggregate, + status, + receiptBaseSha, + ); await db .update(runs) .set({ status, receipt, error, updatedAt: new Date() }) @@ -479,7 +695,12 @@ export async function finishRun( } } await appendRunEvents(db, run.orgId, run.id, [{ type: "result", data: { status, error } }]); - await updateGithubRunProgress(db, run.id, status, deps).catch(() => undefined); + // Architect Gate 1 publication owns the succeeded progress comment. A + // second generic update could race a publication_closed reconciliation and + // resurrect an approval CTA after the proposal became terminal. + if (!(status === "succeeded" && claim.architectProposalId)) { + await updateGithubRunProgress(db, run.id, status, deps).catch(() => undefined); + } await insertAuditEvent(db, { orgId: run.orgId, projectId: run.projectId, @@ -636,15 +857,17 @@ export async function updateGithubRunProgress( const agentProgress = await lastAgentProgress(db, run); const commentId = progressCommentId(run.gh); if (!commentId) return false; - await client.updateIssueComment( - commentId, - renderProgressForRun(run, phase, { - finalText, - agentProgress, - proposalId: proposal?.id, - error: phase === "failed" ? run.error : null, - }), - ); + const rendered = renderProgressForRun(run, phase, { + finalText, + agentProgress, + proposalId: proposal?.id, + error: phase === "failed" ? run.error : null, + }); + const body = + phase === "succeeded" && proposal + ? `${rendered}\n\n${architectPlanPublicationMarker(run.id, proposal.id)}` + : rendered; + await client.updateIssueComment(commentId, body); return true; } @@ -687,7 +910,23 @@ async function openArchitectPlanAcceptance( db: ReturnType["db"], run: RunRow, receipt: FacilityReceipt, - deps?: FinishRunDeps, +) { + await db.transaction(async (transaction) => + ensureArchitectPlanAcceptance( + transaction as unknown as ReturnType["db"], + run, + receipt, + ), + ); + // Terminal retries repair only the durable DB outbox. Network publication is + // left to the scheduled reconciler so an ambiguous prior response observes + // durable backoff instead of immediately creating a second comment. +} + +async function ensureArchitectPlanAcceptance( + db: ReturnType["db"], + run: RunRow, + receipt: FacilityReceipt, ) { const gh = objectOrEmpty(run.gh); const issueNumber = numberOrUndefined(gh.issueNumber); @@ -704,96 +943,741 @@ async function openArchitectPlanAcceptance( .limit(1) )[0]; if (!actionType) throw new Error("plan_acceptance_action_missing"); - const existing = ( + const runTrigger = objectOrEmpty(run.trigger); + const workspaceBaseSha = gitCommitSha(run.workspaceBaseSha); + const issueRevisionSha256 = githubIssueRevisionSha256(runTrigger.request); + const canonicalPayload = { + architectRunId: run.id, + issueNumber, + repoId: repo.id, + receiptSha256: receipt.integrity?.payload_sha256, + planSha256: createHash("sha256").update(plan).digest("hex"), + ...(workspaceBaseSha ? { workspaceBaseSha } : {}), + ...(issueRevisionSha256 ? { issueRevisionSha256 } : {}), + }; + await db.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`architect-plan:${run.id}`}, 0))`, + ); + const candidates = await db + .select() + .from(proposals) + .where( + and( + eq(proposals.orgId, run.orgId), + eq(proposals.projectId, run.projectId), + eq(proposals.runId, run.id), + eq(proposals.actionTypeId, actionType.id), + ), + ); + for (const existing of candidates) { + const payload = objectOrEmpty(existing.payload); + if ( + existing.contextMd !== plan || + payload.architectRunId !== canonicalPayload.architectRunId || + payload.issueNumber !== canonicalPayload.issueNumber || + payload.repoId !== canonicalPayload.repoId || + payload.receiptSha256 !== canonicalPayload.receiptSha256 || + payload.planSha256 !== canonicalPayload.planSha256 || + (gitCommitSha(payload.workspaceBaseSha) ?? null) !== (workspaceBaseSha ?? null) || + (sha256Digest(payload.issueRevisionSha256) ?? null) !== (issueRevisionSha256 ?? null) + ) { + continue; + } + const origin = ( + await db + .select({ + seq: proposalEvents.seq, + type: proposalEvents.type, + actor: proposalEvents.actor, + data: proposalEvents.data, + }) + .from(proposalEvents) + .where( + and( + eq(proposalEvents.orgId, run.orgId), + eq(proposalEvents.proposalId, existing.id), + eq(proposalEvents.seq, 1), + ), + ) + .limit(1) + )[0]; + const originActor = objectOrEmpty(origin?.actor); + const originData = objectOrEmpty(origin?.data); + if ( + origin && + (origin.type !== "open" || + originActor.type !== "agent" || + originActor.id !== run.id || + originData.source !== "architect_run") + ) { + continue; + } + if (!origin) { + await db + .insert(proposalEvents) + .values({ + orgId: run.orgId, + proposalId: existing.id, + seq: 1, + type: "open", + actor: { type: "agent", id: run.id }, + data: { source: "architect_run" }, + }) + .onConflictDoNothing(); + } + return existing; + } + const created = ( await db - .select() + .insert(proposals) + .values({ + id: newId("prop"), + orgId: run.orgId, + projectId: run.projectId, + runId: run.id, + actionTypeId: actionType.id, + payload: canonicalPayload, + contextMd: plan, + expiresAt: new Date(Date.now() + actionType.defaultTtlHours * 3_600_000), + }) + .returning() + )[0]; + if (!created) throw new Error("plan_acceptance_create_failed"); + await db.insert(proposalEvents).values({ + orgId: run.orgId, + proposalId: created.id, + seq: 1, + type: "open", + actor: { type: "agent", id: run.id }, + data: { source: "architect_run" }, + }); + return created; +} + +export async function reconcileArchitectPlanPublications( + db: ReturnType["db"], + config: AppConfig, + job: ArchitectPlanPublicationJob = {}, + githubClientFactory?: GithubClientFactory, + now = new Date(), +) { + if ((job.proposalId && !job.orgId) || (!job.proposalId && job.orgId)) { + throw new Error("architect-plans.publish requires proposalId and orgId together"); + } + const globalEligibility = globalArchitectPlanPublicationEligibility(now); + let selectedOrgIds: string[] | undefined; + if (!job.proposalId) { + const eligibleOrgs = await db + .selectDistinct({ orgId: proposals.orgId }) .from(proposals) - .where( + .innerJoin( + actionTypes, + and(eq(actionTypes.id, proposals.actionTypeId), eq(actionTypes.orgId, proposals.orgId)), + ) + .innerJoin( + runs, and( - eq(proposals.orgId, run.orgId), - eq(proposals.projectId, run.projectId), - eq(proposals.runId, run.id), - eq(proposals.actionTypeId, actionType.id), + eq(runs.id, proposals.runId), + eq(runs.orgId, proposals.orgId), + eq(runs.projectId, proposals.projectId), ), ) - .limit(1) - )[0]; - const proposal = - existing ?? - ( - await db - .insert(proposals) - .values({ - id: newId("prop"), - orgId: run.orgId, - projectId: run.projectId, - runId: run.id, - actionTypeId: actionType.id, - payload: { - architectRunId: run.id, - issueNumber, - repoId: repo.id, - receiptSha256: receipt.integrity?.payload_sha256, - }, - contextMd: plan, - expiresAt: new Date(Date.now() + actionType.defaultTtlHours * 3_600_000), + .leftJoin( + platformIssues, + and( + eq(platformIssues.orgId, proposals.orgId), + eq(platformIssues.kind, "plan_publication_failed"), + sql`${platformIssues.fingerprint} = 'plan_publication_failed:' || ${runs.id}`, + ), + ) + .where(globalEligibility) + .orderBy(asc(proposals.orgId)); + selectedOrgIds = rotateArchitectPlanPublicationOrgIds( + eligibleOrgs.map((row) => row.orgId), + now, + ARCHITECT_PLAN_PUBLICATION_LIMIT, + ); + if (selectedOrgIds.length === 0) return []; + } + const pending = await db + .select({ + proposalId: proposals.id, + orgId: proposals.orgId, + projectId: proposals.projectId, + runId: proposals.runId, + }) + .from(proposals) + .innerJoin( + actionTypes, + and(eq(actionTypes.id, proposals.actionTypeId), eq(actionTypes.orgId, proposals.orgId)), + ) + .innerJoin( + runs, + and( + eq(runs.id, proposals.runId), + eq(runs.orgId, proposals.orgId), + eq(runs.projectId, proposals.projectId), + ), + ) + .leftJoin( + platformIssues, + and( + eq(platformIssues.orgId, proposals.orgId), + eq(platformIssues.kind, "plan_publication_failed"), + sql`${platformIssues.fingerprint} = 'plan_publication_failed:' || ${runs.id}`, + ), + ) + .where( + and( + job.proposalId ? architectPlanPublicationBaseEligibility(now) : globalEligibility, + job.proposalId ? eq(proposals.id, job.proposalId) : undefined, + job.orgId ? eq(proposals.orgId, job.orgId) : undefined, + selectedOrgIds ? inArray(proposals.orgId, selectedOrgIds) : undefined, + ), + ) + .orderBy( + job.proposalId + ? asc(proposals.createdAt) + : sql`row_number() over ( + partition by ${proposals.orgId} + order by + case when ${platformIssues.id} is null then 0 else 1 end, + ${platformIssues.lastSeen} asc nulls first, + ${proposals.createdAt}, + ${proposals.id} + )`, + sql`case when ${platformIssues.id} is null then 0 else 1 end`, + asc(platformIssues.lastSeen), + asc(proposals.createdAt), + asc(proposals.id), + ) + .limit(job.proposalId ? 1 : ARCHITECT_PLAN_PUBLICATION_LIMIT); + + const results: Array<{ + proposalId: string; + status: "published" | "closed" | "suppressed" | "pending"; + }> = []; + for (const candidate of pending) { + if (!candidate.projectId || !candidate.runId) continue; + try { + const status = await publishArchitectPlanAcceptance( + db, + candidate.orgId, + candidate.proposalId, + { config, githubClientFactory }, + ); + results.push({ proposalId: candidate.proposalId, status }); + } catch (error) { + await recordArchitectPlanPublicationFailure(db, { + orgId: candidate.orgId, + projectId: candidate.projectId, + runId: candidate.runId, + proposalId: candidate.proposalId, + error, + }); + results.push({ proposalId: candidate.proposalId, status: "pending" }); + } + } + return results; +} + +async function publishArchitectPlanAcceptance( + db: ReturnType["db"], + orgId: string, + proposalId: string, + deps?: Pick, +) { + return db.transaction(async (transaction) => { + const tx = transaction as unknown as ReturnType["db"]; + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`architect-plan-publication:${proposalId}`}, 0))`, + ); + const context = ( + await tx + .select({ proposal: proposals, run: runs, actionTypeName: actionTypes.name }) + .from(proposals) + .innerJoin( + actionTypes, + and(eq(actionTypes.id, proposals.actionTypeId), eq(actionTypes.orgId, proposals.orgId)), + ) + .innerJoin( + runs, + and( + eq(runs.id, proposals.runId), + eq(runs.orgId, proposals.orgId), + eq(runs.projectId, proposals.projectId), + ), + ) + .where(and(eq(proposals.orgId, orgId), eq(proposals.id, proposalId))) + .for("update", { of: proposals }) + .limit(1) + )[0]; + if (context?.actionTypeName !== "plan_acceptance") { + throw new Error("architect_plan_publication_missing"); + } + const { proposal, run } = context; + if (!proposal.projectId || run.status !== "succeeded" || !isArchitectMode(run.mode)) { + throw new Error("architect_plan_publication_invalid_run"); + } + const origin = ( + await tx + .select({ + type: proposalEvents.type, + actor: proposalEvents.actor, + data: proposalEvents.data, }) - .returning() + .from(proposalEvents) + .where( + and( + eq(proposalEvents.orgId, orgId), + eq(proposalEvents.proposalId, proposal.id), + eq(proposalEvents.seq, 1), + ), + ) + .limit(1) )[0]; - if (!proposal) throw new Error("plan_acceptance_create_failed"); - if (!existing) { - await db.insert(proposalEvents).values({ - orgId: run.orgId, + const originActor = objectOrEmpty(origin?.actor); + const originData = objectOrEmpty(origin?.data); + if ( + origin?.type !== "open" || + originActor.type !== "agent" || + originActor.id !== run.id || + originData.source !== "architect_run" + ) { + throw new Error("architect_plan_publication_invalid_origin"); + } + const payload = objectOrEmpty(proposal.payload); + const issueNumber = numberOrUndefined(payload.issueNumber); + const repoId = stringValue(payload.repoId); + const receipt = FacilityReceiptSchema.safeParse(run.receipt); + const ghIssueNumber = numberOrUndefined(objectOrEmpty(run.gh).issueNumber); + if ( + !issueNumber || + issueNumber !== ghIssueNumber || + !repoId || + payload.architectRunId !== run.id || + payload.planSha256 !== createHash("sha256").update(proposal.contextMd).digest("hex") || + (gitCommitSha(payload.workspaceBaseSha) ?? null) !== + (gitCommitSha(run.workspaceBaseSha) ?? null) || + (sha256Digest(payload.issueRevisionSha256) ?? null) !== + (githubIssueRevisionSha256(objectOrEmpty(run.trigger).request) ?? null) || + !receipt.success || + !verifyFacilityReceipt(receipt.data) || + payload.receiptSha256 !== receipt.data.integrity?.payload_sha256 + ) { + throw new Error("architect_plan_publication_invalid_context"); + } + const repo = await repoForGithubRun(tx, run); + if (!repo || repo.id !== repoId || repo.projectId !== proposal.projectId) { + throw new Error("architect_plan_publication_repo_mismatch"); + } + const publicationEvents = await tx + .select({ type: proposalEvents.type, data: proposalEvents.data }) + .from(proposalEvents) + .where( + and( + eq(proposalEvents.orgId, orgId), + eq(proposalEvents.proposalId, proposal.id), + inArray(proposalEvents.type, [ + "published", + "publication_suppressed", + "publication_closed", + ]), + ), + ) + .orderBy(desc(proposalEvents.seq)); + const published = publicationEvents.find((event) => event.type === "published"); + const suppressed = publicationEvents.find((event) => event.type === "publication_suppressed"); + const closed = publicationEvents.find((event) => event.type === "publication_closed"); + const publicationFingerprint = `plan_publication_failed:${run.id}`; + if (closed) { + await resolvePlatformIssue( + tx, + orgId, + publicationFingerprint, + "Architect plan publication closed", + { projectId: run.projectId }, + ); + return "closed" as const; + } + if (suppressed) { + await resolvePlatformIssue( + tx, + orgId, + publicationFingerprint, + "Architect plan publication suppressed", + { projectId: run.projectId }, + ); + return "suppressed" as const; + } + // Expiry is evaluated after taking the proposal row lock, not from the + // sweep's selection timestamp. A long batch can therefore never publish a + // CTA that expired while earlier candidates were using GitHub. + const effective = effectiveArchitectPlanProposalState( + proposal.state, + proposal.expiresAt, + new Date(), + ); + const effectiveOpen = effective.open; + const effectiveState = effective.state; + if (published && effectiveOpen) { + await resolvePlatformIssue( + tx, + orgId, + publicationFingerprint, + "Architect plan publication recovered", + { projectId: run.projectId }, + ); + return "published" as const; + } + if (!repo.installationId) throw new Error("run_repo_missing_installation"); + const installation = ( + await tx + .select() + .from(githubInstallations) + .where( + and( + eq(githubInstallations.orgId, orgId), + eq(githubInstallations.id, repo.installationId), + ), + ) + .limit(1) + )[0]; + if (!installation || installation.suspendedAt) throw new Error("run_installation_unavailable"); + const factory = + deps?.githubClientFactory ?? + (deps?.config?.githubAppId && deps.config.githubAppPrivateKey + ? createGithubClientFactory(deps.config) + : null); + if (!factory) throw new Error("github_app_unconfigured"); + const networkDeadline = Date.now() + ARCHITECT_PLAN_PUBLICATION_TIMEOUT_MS; + const remote = (operation: () => Promise) => + withArchitectPlanPublicationTimeout(operation, Math.max(1, networkDeadline - Date.now())); + const client = new FacilityGithubClient( + await remote(() => factory(installation.installationId)), + { + owner: repo.owner, + repo: repo.name, + defaultBranch: repo.defaultBranch, + }, + ); + const publicationKey = architectPlanPublicationKey(run.id, proposal.id); + const publicationMarker = architectPlanPublicationMarker(run.id, proposal.id); + const publishedData = objectOrEmpty(published?.data); + const publishedCommentId = numberOrUndefined(publishedData.commentId); + let listedComments: Awaited> | undefined; + const recoverComment = async (allowLegacy: boolean) => { + listedComments ??= await remote(() => client.listIssueComments(issueNumber)); + return findArchitectPlanPublicationComment(listedComments, { + runId: run.id, + publicationMarker, + allowLegacy, + }); + }; + const closedBody = renderClosedArchitectPlanPublication({ + runId: run.id, + plan: proposal.contextMd, + proposalState: effectiveState, + publicationMarker, + }); + const closePublished = async ( + remoteComment: { id: number; url?: string } | undefined, + reason?: "deleted_comment" | "legacy_comment_untracked", + ) => { + const latest = ( + await tx + .select({ seq: proposalEvents.seq }) + .from(proposalEvents) + .where(and(eq(proposalEvents.orgId, orgId), eq(proposalEvents.proposalId, proposal.id))) + .orderBy(desc(proposalEvents.seq)) + .limit(1) + )[0]; + await tx.insert(proposalEvents).values({ + orgId, + proposalId: proposal.id, + seq: (latest?.seq ?? 0) + 1, + type: "publication_closed", + actor: { type: "agent", id: run.id }, + data: { + source: "architect_run", + issue: issueNumber, + publicationKey, + proposalState: effectiveState, + commentId: remoteComment?.id ?? null, + commentUrl: remoteComment?.url ?? stringValue(publishedData.commentUrl) ?? null, + ...(reason ? { reason } : {}), + }, + }); + await resolvePlatformIssue( + tx, + orgId, + publicationFingerprint, + `Architect plan publication closed because proposal is ${effectiveState}`, + { projectId: run.projectId }, + ); + return "closed" as const; + }; + if (!effectiveOpen && published) { + const knownCommentId = publishedCommentId ?? progressCommentId(run.gh); + if (knownCommentId) { + try { + await remote(() => client.updateIssueComment(knownCommentId, closedBody)); + return closePublished({ id: knownCommentId }); + } catch (error) { + if (!isGithubNotFound(error)) throw error; + } + } + const recovered = await recoverComment(true); + if (!recovered) { + return closePublished( + undefined, + knownCommentId ? "deleted_comment" : "legacy_comment_untracked", + ); + } + await remote(() => client.updateIssueComment(recovered.id, closedBody)); + return closePublished({ id: recovered.id, url: recovered.url }); + } + const recovered = !effectiveOpen ? await recoverComment(false) : undefined; + if (!effectiveOpen && !recovered) { + const latest = ( + await tx + .select({ seq: proposalEvents.seq }) + .from(proposalEvents) + .where(and(eq(proposalEvents.orgId, orgId), eq(proposalEvents.proposalId, proposal.id))) + .orderBy(desc(proposalEvents.seq)) + .limit(1) + )[0]; + await tx.insert(proposalEvents).values({ + orgId, + proposalId: proposal.id, + seq: (latest?.seq ?? 0) + 1, + type: "publication_suppressed", + actor: { type: "agent", id: run.id }, + data: { + source: "architect_run", + issue: issueNumber, + publicationKey, + proposalState: effectiveState, + reason: "proposal_not_open", + }, + }); + await resolvePlatformIssue( + tx, + orgId, + publicationFingerprint, + `Architect plan publication suppressed because proposal is ${effectiveState}`, + { projectId: run.projectId }, + ); + return "suppressed" as const; + } + + let remoteComment: { id: number; url?: string }; + if (!effectiveOpen && recovered) { + // GitHub already accepted the stable marker before the proposal changed + // state. Replace the old CTA with a terminal snapshot, then close the + // ledger; never create a fresh approval surface after close/expiry. + await remote(() => client.updateIssueComment(recovered.id, closedBody)); + remoteComment = { id: recovered.id, url: recovered.url }; + } else { + const body = `${renderProgressForRun(run, "succeeded", { + finalText: proposal.contextMd, + agentProgress: await lastAgentProgress(tx, run), + proposalId: proposal.id, + })}\n\n${publicationMarker}`; + const progressId = progressCommentId(run.gh); + if (progressId) { + try { + await remote(() => client.updateIssueComment(progressId, body)); + remoteComment = { id: progressId }; + } catch (error) { + if (!isGithubNotFound(error)) throw error; + const fallback = await recoverComment(true); + if (fallback) { + await remote(() => client.updateIssueComment(fallback.id, body)); + remoteComment = { id: fallback.id, url: fallback.url }; + } else { + remoteComment = await remote(() => client.createIssueComment(issueNumber, body)); + } + } + } else { + const fallback = await recoverComment(false); + if (fallback) { + await remote(() => client.updateIssueComment(fallback.id, body)); + remoteComment = { id: fallback.id, url: fallback.url }; + } else { + remoteComment = await remote(() => client.createIssueComment(issueNumber, body)); + } + } + } + const latest = ( + await tx + .select({ seq: proposalEvents.seq }) + .from(proposalEvents) + .where(and(eq(proposalEvents.orgId, run.orgId), eq(proposalEvents.proposalId, proposal.id))) + .orderBy(desc(proposalEvents.seq)) + .limit(1) + )[0]; + await tx.insert(proposalEvents).values({ + orgId, proposalId: proposal.id, - seq: 1, - type: "open", + seq: (latest?.seq ?? 0) + 1, + type: "published", actor: { type: "agent", id: run.id }, - data: { source: "architect_run" }, + data: { + source: "architect_run", + issue: issueNumber, + publicationKey, + commentId: remoteComment.id, + commentUrl: remoteComment.url ?? null, + }, }); + if (!effectiveOpen) { + await tx.insert(proposalEvents).values({ + orgId, + proposalId: proposal.id, + seq: (latest?.seq ?? 0) + 2, + type: "publication_closed", + actor: { type: "agent", id: run.id }, + data: { + source: "architect_run", + issue: issueNumber, + publicationKey, + proposalState: effectiveState, + commentId: remoteComment.id, + commentUrl: remoteComment.url ?? null, + }, + }); + } + await insertAuditEvent(tx, { + orgId, + projectId: run.projectId, + actor: { type: "agent", id: run.id }, + action: "hitl.proposed", + target: { type: "proposal", id: proposal.id }, + payload: { + action_type: "plan_acceptance", + issue: issueNumber, + publication_key: publicationKey, + comment_id: remoteComment.id, + }, + }); + await resolvePlatformIssue( + tx, + orgId, + publicationFingerprint, + "Architect plan publication recovered", + { projectId: run.projectId }, + ); + return effectiveOpen ? ("published" as const) : ("closed" as const); + }); +} + +async function withArchitectPlanPublicationTimeout( + operation: () => Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + operation(), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("architect_plan_publication_timeout")), + timeoutMs, + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); } - if (!repo?.installationId) throw new Error("run_repo_missing_installation"); - const installation = ( - await db - .select() - .from(githubInstallations) +} + +async function recordArchitectPlanPublicationFailure( + db: ReturnType["db"], + input: { + orgId: string; + projectId: string; + runId: string; + proposalId: string; + error: unknown; + }, +) { + const message = errorMessage(input.error); + await db.transaction(async (transaction) => { + const tx = transaction as unknown as ReturnType["db"]; + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`architect-plan-publication:${input.proposalId}`}, 0))`, + ); + const proposal = ( + await tx + .select({ state: proposals.state, expiresAt: proposals.expiresAt }) + .from(proposals) + .where(and(eq(proposals.orgId, input.orgId), eq(proposals.id, input.proposalId))) + .for("update") + .limit(1) + )[0]; + const outcomes = await tx + .select({ type: proposalEvents.type }) + .from(proposalEvents) .where( and( - eq(githubInstallations.orgId, run.orgId), - eq(githubInstallations.id, repo.installationId), + eq(proposalEvents.orgId, input.orgId), + eq(proposalEvents.proposalId, input.proposalId), + inArray(proposalEvents.type, [ + "published", + "publication_suppressed", + "publication_closed", + ]), ), - ) - .limit(1) - )[0]; - if (!installation || installation.suspendedAt) throw new Error("run_installation_unavailable"); - const factory = - deps?.githubClientFactory ?? - (deps?.config?.githubAppId && deps.config.githubAppPrivateKey - ? createGithubClientFactory(deps.config) - : null); - if (!factory) throw new Error("github_app_unconfigured"); - const client = new FacilityGithubClient(await factory(installation.installationId), { - owner: repo.owner, - repo: repo.name, - defaultBranch: repo.defaultBranch, - }); - const body = renderProgressForRun(run, "succeeded", { - finalText: plan, - agentProgress: await lastAgentProgress(db, run), - proposalId: proposal.id, - }); - const commentId = progressCommentId(run.gh); - if (commentId) { - await client.updateIssueComment(commentId, body); - } else { - await client.createIssueComment(issueNumber, body); - } - await insertAuditEvent(db, { - orgId: run.orgId, - projectId: run.projectId, - actor: { type: "agent", id: run.id }, - action: "hitl.proposed", - target: { type: "proposal", id: proposal.id }, - payload: { action_type: "plan_acceptance", issue: issueNumber }, + ); + const effectiveOpen = proposal?.state === "open" && proposal.expiresAt.getTime() > Date.now(); + const completedOutcome = + outcomes.find((outcome) => + ["publication_suppressed", "publication_closed"].includes(outcome.type), + ) ?? (effectiveOpen ? outcomes.find((outcome) => outcome.type === "published") : undefined); + const fingerprint = `plan_publication_failed:${input.runId}`; + if (completedOutcome) { + await resolvePlatformIssue( + tx, + input.orgId, + fingerprint, + `Architect plan publication reached terminal outcome ${completedOutcome.type}`, + { projectId: input.projectId }, + ); + return; + } + const priorFailure = ( + await tx + .select({ seq: runEvents.seq }) + .from(runEvents) + .where( + and( + eq(runEvents.orgId, input.orgId), + eq(runEvents.runId, input.runId), + eq(runEvents.type, "artifact_error"), + sql`${runEvents.data}->>'kind' = 'plan_publication_failed'`, + ), + ) + .limit(1) + )[0]; + if (!priorFailure) { + await appendRunEvents(tx, input.orgId, input.runId, [ + { type: "artifact_error", data: { kind: "plan_publication_failed", error: message } }, + ]); + } + await raisePlatformIssue( + tx, + { + orgId: input.orgId, + projectId: input.projectId, + kind: "plan_publication_failed", + severity: "error", + fingerprint, + title: "Failed to publish architect plan", + bodyMd: `Architect run ${input.runId} completed, but Facility could not publish its plan and human approval gate. The durable publication reconciler will retry.\n\n${message}`, + }, + { projectId: input.projectId }, + ); }); } @@ -890,6 +1774,7 @@ async function prepareRunDelivery( git: { branch?: string; headSha?: string; + baseSha?: string; changed: boolean; pushError?: string; pullRequestTitle?: string; @@ -967,6 +1852,7 @@ async function prepareRunDelivery( repoName: repo.name, headBranch: git.branch, expectedHeadSha: git.headSha, + baseSha: git.baseSha, baseBranch: repo.defaultBranch, title: git.pullRequestTitle, body: pullRequestBody, @@ -1731,7 +2617,11 @@ async function buildRunBundle( const contract = renderRunContract(rawContract, provisionSummary, checkCmds); const githubBranch = typeof runGh.branch === "string" ? runGh.branch : null; const checkoutBranch = githubPullRequestMode(run.mode) && githubBranch ? githubBranch : null; - const expectedHeadSha = repairExpectedHeadSha(run.mode, run.trigger); + const planProvenance = objectOrEmpty(objectOrEmpty(run.trigger).planProvenance); + const acceptedPlanBaseSha = isBuilderMode(run.mode) + ? gitCommitSha(planProvenance.workspaceBaseSha) + : undefined; + const expectedHeadSha = repairExpectedHeadSha(run.mode, run.trigger) ?? acceptedPlanBaseSha; if (run.mode.replace(/^codex-/, "").replace(/-/g, "_") === "ci_doctor" && !expectedHeadSha) { throw new Error("ci_doctor_admitted_head_missing"); } @@ -1767,7 +2657,7 @@ async function buildRunBundle( ? { cloneUrl: `https://github.com/${repo.owner}/${repo.name}.git`, branch: checkoutBranch ?? repo.defaultBranch, - expectedHeadSha, + expectedHeadSha: expectedHeadSha ?? null, installationTokenRef: repo.installationId, } : { cloneUrl: null, branch: null, expectedHeadSha: null, installationTokenRef: null }, @@ -2211,6 +3101,7 @@ async function canonicalRunReceipt( runnerReceipt: Record | undefined, aggregate: Awaited>, status: "succeeded" | "failed" | "canceled", + baseSha: string | null | undefined, ): Promise { const runner = objectOrEmpty(runnerReceipt); const runnerTiming = objectOrEmpty(runner.timing); @@ -2264,6 +3155,7 @@ async function canonicalRunReceipt( repo: stringValue(gh.repo), issue: integerValue(gh.issueNumber), pr: integerValue(objectOrEmpty(gh.pr).number), + base_sha: stringValue(baseSha), }, timing: { started_at: stringValue(runnerTiming.started_at) ?? startedAt.toISOString(), @@ -2335,10 +3227,6 @@ export function platformDeliveryFailure( return null; } -function isBuilderMode(mode: string) { - return mode === "builder" || mode.endsWith("-builder"); -} - function isArchitectMode(mode: string) { return mode === "architect" || mode.endsWith("-architect"); } @@ -2403,6 +3291,18 @@ function stringValue(value: unknown) { return typeof value === "string" && value ? value : undefined; } +function gitCommitSha(value: unknown) { + return typeof value === "string" && /^[a-f0-9]{40}$/i.test(value) + ? value.toLowerCase() + : undefined; +} + +function sha256Digest(value: unknown) { + return typeof value === "string" && /^[a-f0-9]{64}$/i.test(value) + ? value.toLowerCase() + : undefined; +} + function integerValue(value: unknown) { return typeof value === "number" && Number.isInteger(value) ? value : undefined; } diff --git a/services/api/src/scheduler.ts b/services/api/src/scheduler.ts index 33f1f68d..67896b53 100644 --- a/services/api/src/scheduler.ts +++ b/services/api/src/scheduler.ts @@ -1,9 +1,21 @@ import { newId } from "@facility/core"; -import { agentDefs, createDb, insertAuditEvent, runEvents, runs } from "@facility/db"; +import { + agentDefs, + createDb, + type FacilityDb, + insertAuditEvent, + runEvents, + runs, +} from "@facility/db"; // Default-import: cron-parser is CJS and its named exports aren't statically // visible to Node's ESM loader (tsx runs the worker in real ESM mode). import cronParser from "cron-parser"; import { and, eq, not, notInArray, sql } from "drizzle-orm"; +import { + assertBuilderPlanDispatch, + isBuilderPlanDenialError, + lockBuilderPlanPolicy, +} from "./builder-plan-policy.js"; import { TERMINAL_RUN_STATUSES } from "./sandbox/state.js"; import type { AppConfig } from "./types.js"; @@ -66,17 +78,41 @@ export async function runScheduledAgents( .limit(1) )[0]; if (!liveRun) { + const runTrigger = { type: "schedule", cron: trigger.config.cron }; + let admittedMode = agent.name; + try { + const policyTx = tx as unknown as FacilityDb; + await lockBuilderPlanPolicy(policyTx, agent.orgId, agent.projectId); + const admission = await assertBuilderPlanDispatch(policyTx, { + orgId: agent.orgId, + projectId: agent.projectId, + mode: agent.name, + agentDefId: agent.id, + trigger: runTrigger, + actor: { type: "system", id: "scheduler" }, + source: "legacy_scheduler", + }); + admittedMode = admission.mode; + } catch (error) { + if (!isBuilderPlanDenialError(error)) throw error; + await tx + .update(agentDefs) + .set({ lastScheduledAt: now, updatedAt: now }) + .where(and(eq(agentDefs.orgId, agent.orgId), eq(agentDefs.id, agent.id))); + continue; + } const run = ( await tx + // builder-plan-preflight: scheduled_run .insert(runs) .values({ id: newId("run"), orgId: agent.orgId, projectId: agent.projectId, agentDefId: agent.id, - mode: agent.name, + mode: admittedMode, engine: agent.engine, - trigger: { type: "schedule", cron: trigger.config.cron }, + trigger: runTrigger, createdBy: { type: "system", id: "scheduler" }, }) .returning() diff --git a/services/api/src/schedules.ts b/services/api/src/schedules.ts index f2decd5a..4fe5d4cd 100644 --- a/services/api/src/schedules.ts +++ b/services/api/src/schedules.ts @@ -10,7 +10,8 @@ import { schedulerWatermarks, } from "@facility/db"; import { and, eq, not, sql } from "drizzle-orm"; -import { laneFor } from "./github/router.js"; +import { isBuilderPlanDenialError, withBuilderPlanPreflight } from "./builder-plan-policy.js"; +import { laneFor } from "./github/agent-routing.js"; import type { AppConfig } from "./types.js"; type Enqueue = ( @@ -77,39 +78,59 @@ export async function runAgentSchedules(config: AppConfig, enqueue: Enqueue, now if (!cronMatches(trigger.config.cron, instant, trigger.config.timezone ?? "UTC")) continue; const runId = scheduledRunId(agent.id, index, instant); - const inserted = await db.transaction(async (tx) => { - const run = ( - await tx - .insert(runs) - .values({ - id: runId, + const runTrigger = { + type: "schedule", + agentName: agent.name, + cron: trigger.config.cron, + timezone: trigger.config.timezone ?? "UTC", + scheduledFor: minuteIso(instant), + }; + let inserted = false; + try { + inserted = await withBuilderPlanPreflight( + db, + { + orgId: agent.orgId, + projectId: agent.projectId, + mode: agent.name, + agentDefId: agent.id, + trigger: runTrigger, + actor: { type: "system", id: "agent-scheduler" }, + source: "agent_scheduler", + }, + async (tx, admission) => { + const run = ( + await tx + // builder-plan-preflight: schedule_dispatch + .insert(runs) + .values({ + id: runId, + orgId: agent.orgId, + projectId: agent.projectId, + agentDefId: agent.id, + mode: admission.mode, + engine: agent.engine, + trigger: runTrigger, + createdBy: { type: "system", id: "agent-scheduler" }, + }) + .onConflictDoNothing() + .returning({ id: runs.id }) + )[0]; + if (!run) return false; + await tx.insert(runEvents).values({ orgId: agent.orgId, - projectId: agent.projectId, - agentDefId: agent.id, - mode: agent.name, - engine: agent.engine, - trigger: { - type: "schedule", - agentName: agent.name, - cron: trigger.config.cron, - timezone: trigger.config.timezone ?? "UTC", - scheduledFor: minuteIso(instant), - }, - createdBy: { type: "system", id: "agent-scheduler" }, - }) - .onConflictDoNothing() - .returning({ id: runs.id }) - )[0]; - if (!run) return false; - await tx.insert(runEvents).values({ - orgId: agent.orgId, - runId, - seq: 1, - type: "queued", - data: { queue: "runs.dispatch", source: "schedule" }, - }); - return true; - }); + runId, + seq: 1, + type: "queued", + data: { queue: "runs.dispatch", source: "schedule" }, + }); + return true; + }, + ); + } catch (error) { + if (isBuilderPlanDenialError(error)) continue; + throw error; + } if (inserted) { await insertAuditEvent(db, { orgId: agent.orgId, diff --git a/services/api/src/watchtower/canary.ts b/services/api/src/watchtower/canary.ts index af07480e..9ea2d0e2 100644 --- a/services/api/src/watchtower/canary.ts +++ b/services/api/src/watchtower/canary.ts @@ -10,6 +10,7 @@ import { runs, } from "@facility/db"; import { and, eq } from "drizzle-orm"; +import { isBuilderPlanDenialError, withBuilderPlanPreflight } from "../builder-plan-policy.js"; import type { AppConfig } from "../types.js"; import { createGitHubClient, type GitHubClient } from "./github.js"; import { raisePlatformIssue, resolvePlatformIssue } from "./issues.js"; @@ -107,25 +108,56 @@ async function verifyPlatformCanary( }); return; } - const run = ( - await db - .insert(runs) - .values({ - id: newId("run"), - orgId, - projectId, - agentDefId: agent.id, - mode: "architect", - engine: agent.engine, - trigger: { - kind: "watchtower.canary", - message: CANARY_MESSAGE, - messageHash: CANARY_MESSAGE_HASH, + const trigger = { + kind: "watchtower.canary", + message: CANARY_MESSAGE, + messageHash: CANARY_MESSAGE_HASH, + }; + let run: typeof runs.$inferSelect | undefined; + try { + run = ( + await withBuilderPlanPreflight( + db, + { + orgId, + projectId, + mode: "architect", + agentDefId: agent.id, + trigger, + actor: { type: "system", id: "watchtower" }, + source: "watchtower_canary", }, - createdBy: { type: "system", id: "watchtower" }, - }) - .returning() - )[0]; + (tx, admission) => + tx + // builder-plan-preflight: watchtower_canary + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId, + agentDefId: agent.id, + mode: admission.mode, + engine: agent.engine, + trigger, + createdBy: { type: "system", id: "watchtower" }, + }) + .returning(), + ) + )[0]; + } catch (error) { + if (!isBuilderPlanDenialError(error)) throw error; + await raisePlatformIssue(db, { + orgId, + projectId, + kind: "canary_failure", + severity: "error", + fingerprint, + title: "Canary agent blocked by Builder plan policy", + bodyMd: + "The configured canary agent also resolves as Builder and cannot run without canonical plan acceptance.", + }); + return; + } if (run) { await db.insert(runEvents).values({ orgId, diff --git a/services/api/src/watchtower/hitl.ts b/services/api/src/watchtower/hitl.ts index 6c8bbb76..1150da82 100644 --- a/services/api/src/watchtower/hitl.ts +++ b/services/api/src/watchtower/hitl.ts @@ -12,33 +12,54 @@ export async function runHitlExpire(config: AppConfig) { } export async function expireHitlProposals(db: FacilityDb) { + const now = new Date(); const overdue = await db .select() .from(proposals) - .where(and(eq(proposals.state, "open"), lt(proposals.expiresAt, new Date()))); + .where(and(eq(proposals.state, "open"), lt(proposals.expiresAt, now))); + let expired = 0; for (const proposal of overdue) { - await db - .update(proposals) - .set({ state: "expired", updatedAt: new Date() }) - .where(and(eq(proposals.orgId, proposal.orgId), eq(proposals.id, proposal.id))); - const last = ( - await db - .select() - .from(proposalEvents) - .where( - and(eq(proposalEvents.orgId, proposal.orgId), eq(proposalEvents.proposalId, proposal.id)), - ) - .orderBy(desc(proposalEvents.seq)) - .limit(1) - )[0]; - await db.insert(proposalEvents).values({ - orgId: proposal.orgId, - proposalId: proposal.id, - seq: (last?.seq ?? 0) + 1, - type: "expired", - actor: { type: "system", name: "hitl.expire" }, - data: {}, + const claimed = await db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + const updated = ( + await tx + .update(proposals) + .set({ state: "expired", updatedAt: now }) + .where( + and( + eq(proposals.orgId, proposal.orgId), + eq(proposals.id, proposal.id), + eq(proposals.state, "open"), + lt(proposals.expiresAt, now), + ), + ) + .returning() + )[0]; + if (!updated) return false; + const last = ( + await tx + .select() + .from(proposalEvents) + .where( + and( + eq(proposalEvents.orgId, proposal.orgId), + eq(proposalEvents.proposalId, proposal.id), + ), + ) + .orderBy(desc(proposalEvents.seq)) + .limit(1) + )[0]; + await tx.insert(proposalEvents).values({ + orgId: proposal.orgId, + proposalId: proposal.id, + seq: (last?.seq ?? 0) + 1, + type: "expired", + actor: { type: "system", name: "hitl.expire" }, + data: {}, + }); + return true; }); + if (claimed) expired += 1; } - return overdue.length; + return expired; } diff --git a/services/api/src/worker.ts b/services/api/src/worker.ts index ceffed7e..47d095ea 100644 --- a/services/api/src/worker.ts +++ b/services/api/src/worker.ts @@ -13,7 +13,11 @@ import { deliverPendingWebhooks } from "./integrations/outbound.js"; import { runLearningNightly } from "./learning.js"; import { destroyPreviewById, provisionPreview, reconcilePreviews } from "./previews.js"; import { deliverPendingRunDeliveries } from "./sandbox/delivery.js"; -import { dispatchRun, reconcileSandboxes } from "./sandbox/orchestrator.js"; +import { + dispatchRun, + reconcileArchitectPlanPublications, + reconcileSandboxes, +} from "./sandbox/orchestrator.js"; import { runAgentSchedules } from "./schedules.js"; import { runAnalyticsRollup } from "./watchtower/analytics.js"; import { runWatchtowerCanary } from "./watchtower/canary.js"; @@ -35,6 +39,7 @@ export async function startWorker() { const queues = [ "runs.dispatch", "deliveries.deliver", + "architect-plans.publish", "watchtower.outcomes", "watchtower.health", "watchtower.canary", @@ -105,6 +110,22 @@ export async function startWorker() { pending: deliveries.filter((delivery) => delivery.status === "pending").length, blocked: deliveries.filter((delivery) => delivery.status === "blocked").length, }; + } else if (queue === "architect-plans.publish") { + const publications = await reconcileArchitectPlanPublications( + db, + config, + data as { proposalId?: string; orgId?: string }, + githubFactory, + ); + result = { + selected: publications.length, + published: publications.filter((publication) => publication.status === "published") + .length, + closed: publications.filter((publication) => publication.status === "closed").length, + suppressed: publications.filter((publication) => publication.status === "suppressed") + .length, + pending: publications.filter((publication) => publication.status === "pending").length, + }; } else if (queue === "sandbox.reconcile") { await reconcileSandboxes(config, (name, payload) => boss.send(name, payload)); } else if (queue === "agent.schedules") { @@ -155,6 +176,7 @@ export async function startWorker() { } await boss.schedule("sandbox.reconcile", "*/2 * * * *", {}); await boss.schedule("deliveries.deliver", "* * * * *", {}); + await boss.schedule("architect-plans.publish", "* * * * *", {}); await boss.schedule("agent.schedules", "* * * * *", {}); await boss.schedule("webhooks.deliver", "* * * * *", {}); await boss.schedule("idempotency.expire", "15 2 * * *", {}); diff --git a/services/api/test/api.test.ts b/services/api/test/api.test.ts index c870066a..02caad40 100644 --- a/services/api/test/api.test.ts +++ b/services/api/test/api.test.ts @@ -46,7 +46,7 @@ import { verifyAuditChain, webhookDeliveries, } from "@facility/db"; -import { and, eq, ne, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import postgres from "postgres"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { buildApp, mintSessionCookie } from "../src/app.js"; @@ -400,7 +400,7 @@ describe("api", async () => { await db .select() .from(agentDefs) - .where(and(eq(agentDefs.projectId, project.json().id), ne(agentDefs.name, "learning"))) + .where(and(eq(agentDefs.projectId, project.json().id), eq(agentDefs.name, "architect"))) .limit(1) )[0]; expect(agent).toBeTruthy(); @@ -638,6 +638,7 @@ describe("api", async () => { payload: { name: "Project", slug }, }); expect(created.statusCode).toBe(200); + expect(created.json().builderPlanPolicy).toBe("optional"); projectId = created.json().id; const projectSpaces = await db .select() @@ -1769,7 +1770,16 @@ describe("api", async () => { owner: repoOwner, name: "plan-dispatch", defaultBranch: "main", - renderAnswers: { execution_lane: { architect: "platform", builder: "platform" } }, + renderAnswers: { + execution_lane: { + architect: "platform", + builder: "platform", + "codex-builder": "platform", + }, + }, + fingerprintStatus: "ok", + fingerprint: { files: [] }, + fingerprintVerifiedAt: new Date(), }) .returning() )[0]; @@ -1817,6 +1827,205 @@ describe("api", async () => { if (!planRepo || !builder || !architectRun || !planAcceptance) { throw new Error("plan fixtures missing"); } + const createInternalPlanProposal = async ( + runId: string, + contextMd: string, + payload: Record = {}, + ) => { + const id = newId("prop"); + await db.insert(proposals).values({ + id, + orgId, + projectId: planProjectId, + runId, + actionTypeId: planAcceptance.id, + payload, + contextMd, + expiresAt: new Date(Date.now() + 3_600_000), + }); + await db.insert(proposalEvents).values({ + orgId, + proposalId: id, + seq: 1, + type: "open", + actor: { type: "agent", id: runId }, + data: { source: "architect_run" }, + }); + return { statusCode: 200, json: () => ({ id }) }; + }; + await db + .update(repos) + .set({ fingerprintStatus: "pending_merge" }) + .where(eq(repos.id, planRepo.id)); + const unverifiedPolicy = await app.inject({ + method: "PATCH", + url: `/v1/projects/${planProjectId}`, + headers: { cookie }, + payload: { builderPlanPolicy: "required" }, + }); + expect(unverifiedPolicy.statusCode).toBe(409); + expect(unverifiedPolicy.json().error.code).toBe("builder_plan_platform_lane_required"); + await db + .update(repos) + .set({ fingerprintStatus: "ok", fingerprintVerifiedAt: new Date() }) + .where(eq(repos.id, planRepo.id)); + await db + .update(agentDefs) + .set({ + name: "delivery-specialist", + triggers: [{ type: "command", command: "/builder" }], + }) + .where(eq(agentDefs.id, builder.id)); + const activeLegacyBuilder = await app.inject({ + method: "POST", + url: `/v1/projects/${planProjectId}/runs`, + headers: { cookie }, + payload: { + agentDefId: builder.id, + trigger: { type: "manual", message: "Persist the admitted Builder identity" }, + }, + }); + expect(activeLegacyBuilder.statusCode).toBe(200); + expect(activeLegacyBuilder.json().mode).toBe("builder"); + await db.update(agentDefs).set({ triggers: [] }).where(eq(agentDefs.id, builder.id)); + const activeRunPolicy = await app.inject({ + method: "PATCH", + url: `/v1/projects/${planProjectId}`, + headers: { cookie }, + payload: { builderPlanPolicy: "required" }, + }); + expect(activeRunPolicy.statusCode).toBe(409); + expect(activeRunPolicy.json().error.code).toBe("builder_plan_active_runs_present"); + await db + .update(runs) + .set({ status: "failed", endedAt: new Date() }) + .where(eq(runs.id, activeLegacyBuilder.json().id)); + const opaqueLegacyRun = ( + await db + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId: planProjectId, + agentDefId: builder.id, + mode: "delivery-specialist", + engine: builder.engine, + trigger: { type: "manual", source: "pre-immutable-admission" }, + createdBy: { type: "user", id: "legacy-fixture" }, + }) + .returning({ id: runs.id }) + )[0]; + const opaqueLegacyPolicy = await app.inject({ + method: "PATCH", + url: `/v1/projects/${planProjectId}`, + headers: { cookie }, + payload: { builderPlanPolicy: "required" }, + }); + expect(opaqueLegacyPolicy.statusCode).toBe(409); + expect(opaqueLegacyPolicy.json().error.code).toBe("builder_plan_active_runs_present"); + await db + .update(runs) + .set({ status: "failed", endedAt: new Date() }) + .where(eq(runs.id, opaqueLegacyRun?.id ?? "")); + await db + .update(agentDefs) + .set({ triggers: [{ type: "command", command: "builder" }] }) + .where(eq(agentDefs.id, builder.id)); + const requiredPolicy = await app.inject({ + method: "PATCH", + url: `/v1/projects/${planProjectId}`, + headers: { cookie }, + payload: { builderPlanPolicy: "required" }, + }); + expect(requiredPolicy.statusCode).toBe(200); + expect(requiredPolicy.json().builderPlanPolicy).toBe("required"); + + await db + .update(repos) + .set({ fingerprintStatus: "pending", fingerprintVerifiedAt: null }) + .where(eq(repos.id, planRepo.id)); + const requiredSettingsDuringDrift = await app.inject({ + method: "PATCH", + url: `/v1/projects/${planProjectId}`, + headers: { cookie }, + payload: { + builderPlanPolicy: "required", + settings: { gateRegression: "lane-drift" }, + }, + }); + expect(requiredSettingsDuringDrift.statusCode).toBe(200); + expect(requiredSettingsDuringDrift.json().settings).toEqual({ gateRegression: "lane-drift" }); + await db + .update(repos) + .set({ fingerprintStatus: "ok", fingerprintVerifiedAt: new Date() }) + .where(eq(repos.id, planRepo.id)); + const activeRequiredRun = ( + await db + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId: planProjectId, + mode: "architect", + engine: "codex", + trigger: { type: "manual", message: "Keep required settings editable" }, + createdBy: { type: "user", id: "fixture" }, + }) + .returning({ id: runs.id }) + )[0]; + const requiredSettingsWithActiveRun = await app.inject({ + method: "PATCH", + url: `/v1/projects/${planProjectId}`, + headers: { cookie }, + payload: { + builderPlanPolicy: "required", + settings: { gateRegression: "active-run" }, + }, + }); + expect(requiredSettingsWithActiveRun.statusCode).toBe(200); + expect(requiredSettingsWithActiveRun.json().settings).toEqual({ gateRegression: "active-run" }); + await db + .update(runs) + .set({ status: "failed", endedAt: new Date() }) + .where(eq(runs.id, activeRequiredRun?.id ?? "")); + + const beforeDenied = await db + .select({ id: runs.id }) + .from(runs) + .where(and(eq(runs.orgId, orgId), eq(runs.projectId, planProjectId))); + const missingPlan = await app.inject({ + method: "POST", + url: `/v1/projects/${planProjectId}/runs`, + headers: { cookie }, + payload: { + agentDefId: builder.id, + trigger: { type: "manual", message: "Do not bypass Gate 1" }, + }, + }); + expect(missingPlan.statusCode).toBe(409); + expect(missingPlan.json().error.code).toBe("builder_plan_required"); + const missingPlanAndObjective = await app.inject({ + method: "POST", + url: `/v1/projects/${planProjectId}/runs`, + headers: { cookie }, + payload: { agentDefId: builder.id }, + }); + expect(missingPlanAndObjective.statusCode).toBe(409); + expect(missingPlanAndObjective.json().error.code).toBe("builder_plan_required"); + expect( + await db + .select({ id: runs.id }) + .from(runs) + .where(and(eq(runs.orgId, orgId), eq(runs.projectId, planProjectId))), + ).toHaveLength(beforeDenied.length); + const optionalPolicy = await app.inject({ + method: "PATCH", + url: `/v1/projects/${planProjectId}`, + headers: { cookie }, + payload: { builderPlanPolicy: "optional" }, + }); + expect(optionalPolicy.statusCode).toBe(200); + const forgedPlanRun = await app.inject({ method: "POST", url: `/v1/projects/${planProjectId}/runs`, @@ -1831,7 +2040,7 @@ describe("api", async () => { expect(forgedPlanRun.statusCode).toBe(400); expect(forgedPlanRun.json().error.code).toBe("reserved_trigger_source"); - const proposal = await app.inject({ + const reservedPlanProposal = await app.inject({ method: "POST", url: "/v1/proposals", headers: { cookie }, @@ -1843,6 +2052,26 @@ describe("api", async () => { contextMd: "Approve the implementation plan", }, }); + expect(reservedPlanProposal.statusCode).toBe(403); + expect(reservedPlanProposal.json().error.code).toBe("reserved_action_type"); + const reservedNamedPlan = await app.inject({ + method: "POST", + url: "/v1/proposals", + headers: { cookie }, + payload: { + projectId: planProjectId, + runId: architectRun.id, + actionType: "plan_acceptance", + payload: {}, + contextMd: "Attempt the reserved action by name", + }, + }); + expect(reservedNamedPlan.statusCode).toBe(403); + expect(reservedNamedPlan.json().error.code).toBe("reserved_action_type"); + const proposal = await createInternalPlanProposal( + architectRun.id, + "Approve the implementation plan", + ); expect(proposal.statusCode).toBe(200); const approved = await app.inject({ method: "POST", @@ -1860,7 +2089,7 @@ describe("api", async () => { and( eq(runs.orgId, orgId), eq(runs.projectId, planProjectId), - eq(runs.mode, "builder"), + eq(runs.mode, "codex-builder"), sql`${runs.trigger} @> ${JSON.stringify({ source: "plan_acceptance", proposalId: proposal.json().id, @@ -1868,6 +2097,7 @@ describe("api", async () => { ), ); expect(builderRuns).toHaveLength(1); + expect(builderRuns[0]?.mode).toBe("codex-builder"); const codexBuilder = ( await db .select() @@ -1901,18 +2131,31 @@ describe("api", async () => { data: { source: "plan_acceptance", architectRunId: architectRun.id }, }); - const duplicateProposal = await app.inject({ + // Optional projects preserve the legacy resume shape: provenance is used + // only for the policy preflight and is not copied into the new row, where + // the plan-acceptance uniqueness indexes would reject it. + await db + .update(runs) + .set({ status: "failed", engine: "claude_code", engineSessionId: "plan-resume-session" }) + .where(eq(runs.id, builderRuns[0]?.id ?? "")); + const resumedPlanRun = await app.inject({ method: "POST", - url: "/v1/proposals", + url: `/v1/runs/${builderRuns[0]?.id}/resume`, headers: { cookie }, - payload: { - projectId: planProjectId, - runId: architectRun.id, - actionTypeId: planAcceptance.id, - payload: {}, - contextMd: "A duplicate approval for the same architect plan", - }, + payload: { message: "Continue the optional legacy run" }, }); + expect(resumedPlanRun.statusCode).toBe(200); + expect(resumedPlanRun.json().trigger).toMatchObject({ + type: "resume", + resumeOf: builderRuns[0]?.id, + }); + expect(resumedPlanRun.json().trigger).not.toHaveProperty("source"); + expect(resumedPlanRun.json().trigger).not.toHaveProperty("proposalId"); + + const duplicateProposal = await createInternalPlanProposal( + architectRun.id, + "A duplicate approval for the same architect plan", + ); const duplicateApproval = await app.inject({ method: "POST", url: `/v1/proposals/${duplicateProposal.json().id}/decide`, @@ -1975,6 +2218,89 @@ describe("api", async () => { expect(retriedBuilderRuns).toHaveLength(1); expect(retriedBuilderRuns[0]?.agentDefId).toBe(codexBuilder.id); + const racedArchitect = ( + await db + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId: planProjectId, + mode: "codex-architect", + engine: "codex", + status: "succeeded", + trigger: { type: "github_comment", repo: { id: planRepo.id } }, + gh: { owner: repoOwner, repo: planRepo.name, issueNumber: 44 }, + createdBy: { type: "user", id: "architect-requester" }, + }) + .returning() + )[0]; + if (!racedArchitect) throw new Error("raced Architect fixture missing"); + const racedProposal = await createInternalPlanProposal( + racedArchitect.id, + "Reject repository lane drift inside the admission lock", + ); + const beforeRacedApproval = await db + .select({ id: runs.id }) + .from(runs) + .where( + sql`${runs.trigger} @> ${JSON.stringify({ + source: "plan_acceptance", + proposalId: racedProposal.json().id, + })}::jsonb`, + ); + let racedApproval: + | Promise<{ statusCode: number; body: string; json: () => unknown }> + | undefined; + await db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`builder-plan:${orgId}:${planProjectId}`}, 0))`, + ); + await tx + .update(repos) + .set({ renderAnswers: { execution_lane: { architect: "platform", builder: "repo" } } }) + .where(eq(repos.id, planRepo.id)); + racedApproval = app.inject({ + method: "POST", + url: `/v1/proposals/${racedProposal.json().id}/decide`, + headers: { cookie: approverCookie }, + payload: { decision: "approve" }, + }); + let executing = false; + for (let attempt = 0; attempt < 100; attempt += 1) { + const candidate = ( + await db + .select({ state: proposals.state }) + .from(proposals) + .where(eq(proposals.id, racedProposal.json().id)) + .limit(1) + )[0]; + if (candidate?.state === "executing") { + executing = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(executing).toBe(true); + }); + if (!racedApproval) throw new Error("raced approval did not start"); + const racedResult = await racedApproval; + expect(racedResult.statusCode, racedResult.body).toBe(200); + expect(racedResult.json()).toMatchObject({ + state: "execution_failed", + executionError: "plan_acceptance_builder_uses_repo_lane", + }); + expect( + await db + .select({ id: runs.id }) + .from(runs) + .where( + sql`${runs.trigger} @> ${JSON.stringify({ + source: "plan_acceptance", + proposalId: racedProposal.json().id, + })}::jsonb`, + ), + ).toHaveLength(beforeRacedApproval.length); + await db .update(repos) .set({ renderAnswers: { execution_lane: { architect: "platform", builder: "repo" } } }) @@ -1996,18 +2322,10 @@ describe("api", async () => { .returning() )[0]; if (!repoLaneArchitect) throw new Error("repo-lane architect fixture missing"); - const repoLaneProposal = await app.inject({ - method: "POST", - url: "/v1/proposals", - headers: { cookie }, - payload: { - projectId: planProjectId, - runId: repoLaneArchitect.id, - actionTypeId: planAcceptance.id, - payload: {}, - contextMd: "Repo lane must still require /builder", - }, - }); + const repoLaneProposal = await createInternalPlanProposal( + repoLaneArchitect.id, + "Repo lane must still require /builder", + ); const repoLaneApproval = await app.inject({ method: "POST", url: `/v1/proposals/${repoLaneProposal.json().id}/decide`, @@ -2015,6 +2333,7 @@ describe("api", async () => { payload: { decision: "approve" }, }); expect(repoLaneApproval.json().state).toBe("execution_failed"); + expect(repoLaneApproval.json().executionError).toBe("plan_acceptance_builder_uses_repo_lane"); const repoLaneEvents = await db .select() .from(proposalEvents) @@ -2627,6 +2946,239 @@ describe("api", async () => { expect(archived?.status).toBe("archived"); }); + it("denies MCP Builder trigger families without creating or enqueueing runs when plans are required", async () => { + const target = await createProjectWithAgent("MCP Governed Builder"); + await db + .update(agentDefs) + .set({ + name: "delivery-specialist", + engine: "claude_code", + triggers: [{ type: "command", handle: "/builder" }], + enabled: true, + }) + .where(eq(agentDefs.id, target.agent.id)); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(and(eq(projects.orgId, orgId), eq(projects.id, target.projectId))); + + const role = await app.inject({ + method: "POST", + url: "/v1/roles", + headers: { cookie }, + payload: { + name: `mcp-governed-builder-${Date.now()}`, + permissions: ["org:read", "runs:trigger", "repos:write"], + }, + }); + expect(role.statusCode, role.body).toBe(200); + const issued = await app.inject({ + method: "POST", + url: "/v1/keys", + headers: { cookie }, + payload: { + name: `mcp-governed-builder-${Date.now()}`, + roleId: role.json().id, + projectId: target.projectId, + }, + }); + expect(issued.statusCode, issued.body).toBe(200); + + const installation = ( + await db + .insert(githubInstallations) + .values({ + id: newId("int"), + orgId, + installationId: Date.now(), + accountLogin: `mcp-governed-${Date.now()}`, + targetType: "Organization", + }) + .returning() + )[0]; + const repo = ( + await db + .insert(repos) + .values({ + id: newId("repo"), + orgId, + projectId: target.projectId, + installationId: installation?.id, + owner: `mcp-governed-${Date.now()}`, + name: "facility", + defaultBranch: "main", + }) + .returning() + )[0]; + if (!installation || !repo) throw new Error("governed MCP repository fixture missing"); + await db.insert(ghIssues).values({ + id: newId("evt"), + orgId, + projectId: target.projectId, + repoId: repo.id, + number: 204, + title: "Do not bypass Gate 1 from MCP", + state: "open", + htmlUrl: `https://github.com/${repo.owner}/${repo.name}/issues/204`, + }); + const terminalBuilder = ( + await db + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId: target.projectId, + agentDefId: target.agent.id, + mode: "delivery-specialist", + engine: "claude_code", + engineSessionId: "governed-mcp-resume", + status: "succeeded", + createdBy: { type: "test", id: "governed-mcp" }, + }) + .returning() + )[0]; + if (!terminalBuilder) throw new Error("governed MCP resume fixture missing"); + const governedConversation = ( + await db + .insert(conversations) + .values({ + id: newId("evt"), + orgId, + projectId: target.projectId, + agentDefId: target.agent.id, + title: "Governed Builder conversation", + createdBy: { type: "test", id: "governed-mcp" }, + }) + .returning() + )[0]; + if (!governedConversation) throw new Error("governed MCP conversation fixture missing"); + + const originalEnqueue = app.enqueue; + const originalFactory = app.githubClientFactory; + const enqueued: Array<{ queue: string; data: Record }> = []; + let githubFactoryCalls = 0; + app.enqueue = async (queue, data) => { + enqueued.push({ queue, data }); + return null; + }; + app.githubClientFactory = (async () => { + githubFactoryCalls += 1; + throw new Error("MCP Builder denial reached GitHub"); + }) as unknown as GithubClientFactory; + + const executeDenied = async (toolName: string, args: Record) => { + const before = await db + .select({ id: runs.id }) + .from(runs) + .where(eq(runs.projectId, target.projectId)); + const proposed = await app.inject({ + method: "POST", + url: "/v1/mcp/tool-proposals", + headers: { authorization: `Bearer ${issued.json().secret}` }, + payload: { + toolName, + permission: "runs:trigger", + projectId: target.projectId, + summary: `Attempt governed ${toolName}`, + args, + }, + }); + expect(proposed.statusCode, proposed.body).toBe(200); + const approved = await app.inject({ + method: "POST", + url: `/v1/proposals/${proposed.json().id}/decide`, + headers: { cookie: approverCookie }, + payload: { decision: "approve" }, + }); + expect(approved.statusCode, approved.body).toBe(200); + expect(approved.json()).toMatchObject({ + state: "execution_failed", + executionError: "builder_plan_required", + }); + expect( + await db.select({ id: runs.id }).from(runs).where(eq(runs.projectId, target.projectId)), + ).toHaveLength(before.length); + }; + + try { + await executeDenied("facility_trigger_run", { + projectId: target.projectId, + agentName: "builder", + input: { objective: "Attempt direct Builder dispatch" }, + }); + await executeDenied("facility_trigger_github_issue", { + projectId: target.projectId, + repoId: repo.id, + number: 204, + agentName: "builder", + }); + await executeDenied("facility_resume_run", { + runId: terminalBuilder.id, + message: "Attempt governed MCP resume", + }); + await executeDenied("facility_send_conversation_message", { + conversationId: governedConversation.id, + body: "Attempt governed Builder conversation turn", + }); + const beforeRepos = await db + .select({ id: repos.id }) + .from(repos) + .where(eq(repos.projectId, target.projectId)); + const repoProposal = await app.inject({ + method: "POST", + url: "/v1/mcp/tool-proposals", + headers: { authorization: `Bearer ${issued.json().secret}` }, + payload: { + toolName: "facility_connect_repo", + permission: "repos:write", + projectId: target.projectId, + summary: "Attempt repo connection while Gate 1 is required", + args: { + projectId: target.projectId, + owner: installation.accountLogin, + name: "unverified-required-repo", + }, + }, + }); + expect(repoProposal.statusCode, repoProposal.body).toBe(200); + const repoApproval = await app.inject({ + method: "POST", + url: `/v1/proposals/${repoProposal.json().id}/decide`, + headers: { cookie: approverCookie }, + payload: { decision: "approve" }, + }); + expect(repoApproval.statusCode, repoApproval.body).toBe(200); + expect(repoApproval.json()).toMatchObject({ + state: "execution_failed", + executionError: "builder_plan_platform_lane_required", + }); + expect( + await db.select({ id: repos.id }).from(repos).where(eq(repos.projectId, target.projectId)), + ).toHaveLength(beforeRepos.length); + expect(enqueued).toEqual([]); + expect(githubFactoryCalls).toBe(0); + const sources = ( + await db + .select({ action: auditEvents.action, payload: auditEvents.payload }) + .from(auditEvents) + .where(eq(auditEvents.projectId, target.projectId)) + ) + .filter((event) => event.action === "run.builder_plan_denied") + .map((event) => (event.payload as { source?: unknown }).source); + expect(sources).toEqual( + expect.arrayContaining([ + "mcp_trigger_run", + "mcp_trigger_github_issue", + "mcp_resume_run", + "mcp_conversation_message", + ]), + ); + } finally { + app.enqueue = originalEnqueue; + app.githubClientFactory = originalFactory; + } + }); + it("executes approved MCP sessions, conversations, and GitHub issue workflows end to end", async () => { const target = await createProjectWithAgent("MCP Interactive Lifecycle"); await db @@ -2734,6 +3286,11 @@ describe("api", async () => { engine: "claude_code", engineSessionId: "session_mcp_resume", status: "succeeded", + trigger: { + source: "plan_acceptance", + proposalId: newId("prop"), + architectRunId: newId("run"), + }, createdBy: { type: "test", id: "mcp-interactive" }, }) .returning() @@ -2756,6 +3313,8 @@ describe("api", async () => { resumeOf: terminal?.id, message: "Continue from the verified checkpoint", }); + expect(resumed?.trigger).not.toHaveProperty("source"); + expect(resumed?.trigger).not.toHaveProperty("proposalId"); const title = `MCP conversation ${Date.now()}`; await execute("facility_start_conversation", "runs:trigger", { @@ -6200,6 +6759,31 @@ describe("api", async () => { expect(codex.statusCode).toBe(409); expect(codex.json().error.code).toBe("not_resumable"); + // A resume is a new Builder row, not a second consumption of the + // parent's plan acceptance. Required projects therefore deny it before + // insertion instead of copying provenance into a non-canonical trigger. + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(and(eq(projects.orgId, orgId), eq(projects.id, target.projectId))); + const beforeRequiredResume = await db + .select({ id: runs.id }) + .from(runs) + .where(eq(runs.projectId, target.projectId)); + const dispatchedBeforeRequiredResume = dispatched.length; + const governedResume = await app.inject({ + method: "POST", + url: `/v1/runs/${parent?.id}/resume`, + headers: { cookie }, + payload: { message: "attempt a governed resume" }, + }); + expect(governedResume.statusCode, governedResume.body).toBe(409); + expect(governedResume.json().error.code).toBe("builder_plan_required"); + expect( + await db.select({ id: runs.id }).from(runs).where(eq(runs.projectId, target.projectId)), + ).toHaveLength(beforeRequiredResume.length); + expect(dispatched).toHaveLength(dispatchedBeforeRequiredResume); + const other = ( await db .insert(projects) diff --git a/services/api/test/architect-plan-publication.test.ts b/services/api/test/architect-plan-publication.test.ts new file mode 100644 index 00000000..800c2e4c --- /dev/null +++ b/services/api/test/architect-plan-publication.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { + architectPlanPublicationKey, + architectPlanPublicationMarker, + effectiveArchitectPlanProposalState, + findArchitectPlanPublicationComment, + isGithubNotFound, + legacyRunProgressMarker, + renderClosedArchitectPlanPublication, + rotateArchitectPlanPublicationOrgIds, +} from "../src/github/architect-plan-publication.js"; + +describe("Architect plan publication", () => { + it("builds a stable tenant-independent publication identity", () => { + expect(architectPlanPublicationKey("run_123", "prop_456")).toBe( + "architect-plan:run_123:prop_456", + ); + }); + + it("wraps the stable identity in a recoverable GitHub marker", () => { + expect(architectPlanPublicationMarker("run_123", "prop_456")).toBe( + "", + ); + }); + + it("recognizes Octokit not-found errors without swallowing other failures", () => { + expect(isGithubNotFound({ status: 404 })).toBe(true); + expect(isGithubNotFound({ statusCode: 404 })).toBe(true); + expect(isGithubNotFound({ response: { status: 404 } })).toBe(true); + expect(isGithubNotFound({ status: 500 })).toBe(false); + }); + + it("prefers the new marker but can recover a bot-authored legacy progress comment", () => { + const comments = [ + { + id: 1, + authorType: "User", + body: legacyRunProgressMarker("run_123"), + }, + { + id: 2, + authorType: "Bot", + body: legacyRunProgressMarker("run_123"), + }, + { + id: 3, + authorType: "Bot", + body: architectPlanPublicationMarker("run_123", "prop_456"), + }, + ]; + expect( + findArchitectPlanPublicationComment(comments, { + runId: "run_123", + publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"), + allowLegacy: true, + })?.id, + ).toBe(3); + expect( + findArchitectPlanPublicationComment(comments.slice(0, 2), { + runId: "run_123", + publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"), + allowLegacy: true, + })?.id, + ).toBe(2); + }); + + it("does not treat a legacy progress marker as an unpublished outbox delivery", () => { + expect( + findArchitectPlanPublicationComment( + [{ id: 2, authorType: "Bot", body: legacyRunProgressMarker("run_123") }], + { + runId: "run_123", + publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"), + allowLegacy: false, + }, + ), + ).toBeUndefined(); + }); + + it("rotates a bounded organization window without starving the remainder", () => { + const orgIds = Array.from({ length: 30 }, (_, index) => `org_${index}`); + const first = rotateArchitectPlanPublicationOrgIds( + orgIds, + new Date("1970-01-01T00:00:00.000Z"), + 25, + ); + const second = rotateArchitectPlanPublicationOrgIds( + orgIds, + new Date("1970-01-01T00:01:00.000Z"), + 25, + ); + + expect(first).toHaveLength(25); + expect(second).toHaveLength(25); + expect(new Set([...first, ...second])).toEqual(new Set(orgIds)); + }); + + it("preserves all eligible organizations when they fit in one window", () => { + expect( + rotateArchitectPlanPublicationOrgIds( + ["org_a", "org_b"], + new Date("2026-08-26T12:34:00.000Z"), + 25, + ), + ).toEqual(["org_a", "org_b"]); + }); + + it("keeps an open proposal effective strictly before expiry", () => { + expect( + effectiveArchitectPlanProposalState( + "open", + new Date("2026-08-26T12:01:00.000Z"), + new Date("2026-08-26T12:00:00.000Z"), + ), + ).toEqual({ open: true, state: "open" }); + }); + + it("treats an open proposal as expired at the exact boundary", () => { + expect( + effectiveArchitectPlanProposalState( + "open", + new Date("2026-08-26T12:00:00.000Z"), + new Date("2026-08-26T12:00:00.000Z"), + ), + ).toEqual({ open: false, state: "expired" }); + }); + + it("preserves an explicit terminal proposal state", () => { + expect( + effectiveArchitectPlanProposalState( + "rejected", + new Date("2026-08-27T12:00:00.000Z"), + new Date("2026-08-26T12:00:00.000Z"), + ), + ).toEqual({ open: false, state: "rejected" }); + }); + + it("renders a terminal snapshot with its marker and without an approval CTA", () => { + const body = renderClosedArchitectPlanPublication({ + runId: "run_123", + plan: "1. Keep the gate closed.", + proposalState: "executed", + publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"), + updatedAt: new Date("2026-08-26T12:00:00.000Z"), + }); + + expect(body).toContain(""); + expect(body).toContain("Human Gate 1:** no longer open (`executed`)"); + expect(body).toContain("1. Keep the gate closed."); + expect(body).toContain("2026-08-26T12:00:00.000Z"); + expect(body).not.toContain("Approve this plan"); + expect(body).not.toContain("/builder"); + }); + + it("bounds the terminal plan snapshot", () => { + const body = renderClosedArchitectPlanPublication({ + runId: "run_123", + plan: `start${"x".repeat(60_000)}tail`, + proposalState: "expired", + publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"), + updatedAt: new Date("2026-08-26T12:00:00.000Z"), + }); + + expect(body).toContain("start"); + expect(body).not.toContain("tail"); + }); +}); diff --git a/services/api/test/assistant.test.ts b/services/api/test/assistant.test.ts index 2eb89570..bc6e520f 100644 --- a/services/api/test/assistant.test.ts +++ b/services/api/test/assistant.test.ts @@ -136,6 +136,33 @@ describe("assistant ask endpoint", async () => { expect(response.statusCode).toBe(404); }); + it("denies an inline project-owner that also exposes /builder before creating a run", async () => { + const project = await insertProject("Assistant Builder Alias Guard"); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, project.id)); + await insertOwnerAgent(project.id, [{ type: "command", handle: "/builder" }]); + const before = await db + .select({ id: runs.id }) + .from(runs) + .where(eq(runs.projectId, project.id)); + + const response = await app.inject({ + method: "POST", + url: `/v1/projects/${project.id}/ask`, + headers: { cookie }, + payload: { body: "attempt the inline bypass" }, + }); + + expect(response.statusCode).toBe(409); + expect((response.json() as { error: { code: string } }).error.code).toBe( + "builder_plan_required", + ); + const after = await db.select({ id: runs.id }).from(runs).where(eq(runs.projectId, project.id)); + expect(after).toHaveLength(before.length); + }); + it("runs a stubbed turn end to end: tool call, reply, revoked key, idle thread", async () => { const project = await insertProject("Assistant Happy Path"); await insertOwnerAgent(project.id); @@ -345,7 +372,7 @@ describe("assistant ask endpoint", async () => { return project; } - async function insertOwnerAgent(projectId: string) { + async function insertOwnerAgent(projectId: string, triggers: unknown[] = []) { const contract = ( await db .insert(registryItems) @@ -371,7 +398,7 @@ describe("assistant ask endpoint", async () => { engine: "claude_code", model: { model: "claude-sonnet-5" }, contractItemId: contract.id, - triggers: [], + triggers, permissions: ["kb:read", "kb:write", "tasks:read", "tasks:write", "runs:read"], enabled: true, }) diff --git a/services/api/test/builder-plan-policy.integration.test.ts b/services/api/test/builder-plan-policy.integration.test.ts new file mode 100644 index 00000000..223f3ea6 --- /dev/null +++ b/services/api/test/builder-plan-policy.integration.test.ts @@ -0,0 +1,1275 @@ +import { createHash } from "node:crypto"; +import { newId, sealFacilityReceipt } from "@facility/core"; +import { + actionTypes, + agentDefs, + auditEvents, + createDb, + type FacilityDb, + ghIssues, + migrate, + orgs, + projects, + proposalEvents, + proposals, + registryItems, + repos, + runs, +} from "@facility/db"; +import { and, desc, eq } from "drizzle-orm"; +import postgres from "postgres"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + assertBuilderPlanDispatch, + lockBuilderPlanPolicy, + withBuilderPlanPreflight, +} from "../src/builder-plan-policy.js"; +import { ApiError } from "../src/errors.js"; +import { githubIssueRevisionSha256 } from "../src/github/issue-revision.js"; +import { syncRepoFacilityConfig } from "../src/github/kickstart.js"; +import { routeTrigger, type TriggerPayload } from "../src/github/router.js"; +import { dispatchRun } from "../src/sandbox/orchestrator.js"; +import type { AppConfig } from "../src/types.js"; + +const databaseUrl = + process.env.DATABASE_URL ?? "postgres://facility:facility@127.0.0.1:5461/facility_test"; + +async function canConnect() { + const sqlClient = postgres(databaseUrl, { max: 1, connect_timeout: 2 }); + try { + await sqlClient`select 1`; + return true; + } catch { + return false; + } finally { + await sqlClient.end().catch(() => undefined); + } +} + +describe("builder plan policy integration", async () => { + const reachable = await canConnect(); + if (!reachable) { + it.skip("Postgres is unreachable at DATABASE_URL; Builder plan tests skipped", () => undefined); + return; + } + + const { db, client } = createDb(databaseUrl); + + beforeAll(async () => { + await migrate(databaseUrl); + }); + + afterAll(async () => { + await client.end(); + }); + + it("allows one canonical, fresh plan acceptance and rejects a second consumption", async () => { + const fixture = await canonicalFixture(); + await expect(assertBuilderPlanDispatch(db, fixture.dispatch)).resolves.toEqual({ + mode: "builder", + isBuilder: true, + }); + + const linkedRunId = newId("run"); + await db.insert(runs).values({ + id: linkedRunId, + orgId: fixture.orgId, + projectId: fixture.projectId, + mode: "builder", + engine: "codex", + trigger: fixture.dispatch.trigger, + gh: fixture.dispatch.gh, + createdBy: { type: "user", id: "approver" }, + }); + await db + .update(proposals) + .set({ state: "executed" }) + .where(eq(proposals.id, fixture.proposalId)); + + await expect( + assertBuilderPlanDispatch(db, { ...fixture.dispatch, runId: linkedRunId }), + ).resolves.toEqual({ mode: "builder", isBuilder: true }); + const before = await projectRuns(fixture.orgId, fixture.projectId); + await expect(assertBuilderPlanDispatch(db, fixture.dispatch)).rejects.toMatchObject({ + code: "builder_plan_already_consumed", + }); + expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length); + await expect(lastDenialCode(fixture.orgId)).resolves.toBe("builder_plan_already_consumed"); + + const original = ( + await db.select().from(proposals).where(eq(proposals.id, fixture.proposalId)).limit(1) + )[0]; + if (!original) throw new Error("original proposal fixture missing"); + const duplicateProposalId = newId("prop"); + await db.insert(proposals).values({ + ...original, + id: duplicateProposalId, + state: "executing", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(proposalEvents).values([ + { + orgId: fixture.orgId, + proposalId: duplicateProposalId, + seq: 1, + type: "open", + actor: { type: "agent", id: original.runId }, + data: { source: "architect_run" }, + }, + { + orgId: fixture.orgId, + proposalId: duplicateProposalId, + seq: 2, + type: "approved", + actor: { type: "user", id: "approver" }, + data: {}, + }, + ]); + await expect( + assertBuilderPlanDispatch(db, { + ...fixture.dispatch, + trigger: { ...fixture.dispatch.trigger, proposalId: duplicateProposalId }, + }), + ).rejects.toMatchObject({ code: "builder_plan_already_consumed" }); + }); + + it("recognizes a Builder by agentDefId when the run mode is a surface alias", async () => { + const fixture = await canonicalFixture(); + const contractId = newId("item"); + const agentDefId = newId("agent"); + await db.insert(registryItems).values({ + id: contractId, + orgId: fixture.orgId, + scope: "project", + projectId: fixture.projectId, + kind: "contract", + name: "builder-contract", + }); + await db.insert(agentDefs).values({ + id: agentDefId, + orgId: fixture.orgId, + projectId: fixture.projectId, + name: "codex-builder", + engine: "codex", + model: { primary: "gpt-5" }, + contractItemId: contractId, + }); + const before = await projectRuns(fixture.orgId, fixture.projectId); + await expect( + assertBuilderPlanDispatch(db, { + orgId: fixture.orgId, + projectId: fixture.projectId, + mode: "conversation", + agentDefId, + trigger: { type: "conversation", message: "try to hide the role" }, + actor: { type: "system", id: "integration-test" }, + source: "integration_test_alias", + }), + ).rejects.toMatchObject({ code: "builder_plan_required" }); + expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length); + }); + + it("recognizes a renamed Builder from its governed command trigger", async () => { + const fixture = await canonicalFixture(); + const contractId = newId("item"); + const agentDefId = newId("agent"); + await db.insert(registryItems).values({ + id: contractId, + orgId: fixture.orgId, + scope: "project", + projectId: fixture.projectId, + kind: "contract", + name: "renamed-builder-contract", + }); + await db.insert(agentDefs).values({ + id: agentDefId, + orgId: fixture.orgId, + projectId: fixture.projectId, + name: "implementation-agent", + engine: "codex", + model: { primary: "gpt-5" }, + contractItemId: contractId, + triggers: [{ type: "command", handle: "/codex-builder" }], + }); + await expect( + assertBuilderPlanDispatch(db, { + orgId: fixture.orgId, + projectId: fixture.projectId, + mode: "conversation", + agentDefId, + trigger: { type: "conversation", message: "renamed role bypass" }, + source: "integration_test_trigger_alias", + }), + ).rejects.toMatchObject({ code: "builder_plan_required" }); + }); + + it("serializes policy activation ahead of preflight and leaves no denied run row", async () => { + const fixture = await githubRouteFixture("open"); + let releaseActivation!: () => void; + const holdActivation = new Promise((resolve) => { + releaseActivation = resolve; + }); + let activationLocked!: () => void; + const locked = new Promise((resolve) => { + activationLocked = resolve; + }); + const activation = db.transaction(async (transaction) => { + const tx = transaction as unknown as FacilityDb; + await lockBuilderPlanPolicy(tx, fixture.orgId, fixture.projectId); + await tx + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, fixture.projectId)); + activationLocked(); + await holdActivation; + }); + await locked; + const before = await projectRuns(fixture.orgId, fixture.projectId); + const denied = withBuilderPlanPreflight( + db, + { + orgId: fixture.orgId, + projectId: fixture.projectId, + mode: "builder", + agentDefId: fixture.builderAgentId, + trigger: { type: "manual" }, + source: "activation_race_test", + }, + (tx) => + tx + .insert(runs) + .values({ + id: newId("run"), + orgId: fixture.orgId, + projectId: fixture.projectId, + agentDefId: fixture.builderAgentId, + mode: "builder", + engine: "codex", + trigger: { type: "manual" }, + createdBy: { type: "system", id: "activation-race-test" }, + }) + .returning(), + ); + const denialAssertion = expect(denied).rejects.toMatchObject({ + code: "builder_plan_required", + }); + await new Promise((resolve) => setImmediate(resolve)); + releaseActivation(); + await activation; + + await denialAssertion; + expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length); + }); + + it("fails closed when agentDefId belongs to another project", async () => { + const fixture = await canonicalFixture(); + const otherProjectId = newId("proj"); + await db.insert(projects).values({ + id: otherProjectId, + orgId: fixture.orgId, + name: "Other project", + slug: `other-${crypto.randomUUID()}`, + }); + const contractId = newId("item"); + const crossProjectAgentId = newId("agent"); + await db.insert(registryItems).values({ + id: contractId, + orgId: fixture.orgId, + scope: "project", + projectId: otherProjectId, + kind: "contract", + name: `cross-project-${crypto.randomUUID()}`, + }); + await db.insert(agentDefs).values({ + id: crossProjectAgentId, + orgId: fixture.orgId, + projectId: otherProjectId, + name: "implementation-agent", + engine: "codex", + model: { primary: "gpt-5" }, + contractItemId: contractId, + triggers: [{ type: "command", handle: "/builder" }], + }); + + await expect( + assertBuilderPlanDispatch(db, { + orgId: fixture.orgId, + projectId: fixture.projectId, + mode: "conversation", + agentDefId: crossProjectAgentId, + trigger: { type: "conversation" }, + source: "integration_test_cross_project_agent", + }), + ).rejects.toMatchObject({ code: "builder_plan_context_invalid" }); + await expect(lastDenialCode(fixture.orgId)).resolves.toBe("builder_plan_context_invalid"); + }); + + it("does not accept a canonical proposal from another tenant or project", async () => { + const source = await canonicalFixture(); + const target = await canonicalFixture(); + const before = await projectRuns(target.orgId, target.projectId); + await expect( + assertBuilderPlanDispatch(db, { + ...target.dispatch, + trigger: source.dispatch.trigger, + }), + ).rejects.toMatchObject({ code: "builder_plan_context_invalid" }); + expect(await projectRuns(target.orgId, target.projectId)).toHaveLength(before.length); + }); + + it("marks a required repository drifted before rejecting a malformed manifest", async () => { + const fixture = await canonicalFixture(); + const repo = ( + await db.select().from(repos).where(eq(repos.projectId, fixture.projectId)).limit(1) + )[0]; + if (!repo) throw new Error("manifest repo fixture missing"); + await db + .update(repos) + .set({ + fingerprint: { files: [] }, + fingerprintStatus: "ok", + fingerprintVerifiedAt: new Date(), + renderAnswers: { + execution_lane: { builder: "platform", "codex-builder": "platform" }, + }, + }) + .where(eq(repos.id, repo.id)); + const client = { + getContent: async () => ({ + type: "file", + encoding: "base64", + content: Buffer.from("{invalid").toString("base64"), + }), + } as never; + + await expect(syncRepoFacilityConfig({ db, client, repo })).rejects.toMatchObject({ + code: "builder_plan_platform_lane_required", + }); + const updated = ( + await db + .select({ status: repos.fingerprintStatus }) + .from(repos) + .where(eq(repos.id, repo.id)) + .limit(1) + )[0]; + expect(updated?.status).toBe("drifted"); + }); + + it("routes GitHub /builder through the canonical executor and ignores a poisoned proposalId", async () => { + const fixture = await githubRouteFixture("open"); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, fixture.projectId)); + await db + .update(repos) + .set({ + fingerprint: { files: [] }, + fingerprintStatus: "ok", + fingerprintVerifiedAt: new Date(), + }) + .where(eq(repos.projectId, fixture.projectId)); + const poisonId = newId("run"); + await db.insert(runs).values({ + id: poisonId, + orgId: fixture.orgId, + projectId: fixture.projectId, + mode: "project-owner", + engine: "codex", + trigger: { source: "manual", proposalId: fixture.proposalId }, + createdBy: { type: "user", id: "poison" }, + }); + const enqueued: Array<{ queue: string; data: Record }> = []; + const deliveryId = `delivery_${crypto.randomUUID()}`; + + const result = await routeTrigger( + db, + fixture.orgId, + fixture.client, + fixture.payload, + async (queue, data) => { + enqueued.push({ queue, data }); + return null; + }, + deliveryId, + ); + + const denial = ( + await db + .select({ payload: auditEvents.payload }) + .from(auditEvents) + .where( + and( + eq(auditEvents.orgId, fixture.orgId), + eq(auditEvents.action, "run.builder_plan_denied"), + ), + ) + .orderBy(desc(auditEvents.seq)) + .limit(1) + )[0]; + const execution = ( + await db + .select({ data: proposalEvents.data }) + .from(proposalEvents) + .where(eq(proposalEvents.proposalId, fixture.proposalId)) + .orderBy(desc(proposalEvents.seq)) + .limit(1) + )[0]; + expect( + result.routed, + JSON.stringify({ result, denial: denial?.payload, execution: execution?.data }), + ).toBe(true); + expect(result.runId).not.toBe(poisonId); + const canonical = ( + await db + .select() + .from(runs) + .where(eq(runs.id, result.runId ?? "")) + .limit(1) + )[0]; + expect(canonical?.trigger).toMatchObject({ + source: "plan_acceptance", + proposalId: fixture.proposalId, + architectRunId: fixture.architectRunId, + }); + expect(enqueued).toEqual([ + { queue: "runs.dispatch", data: { runId: canonical?.id, orgId: fixture.orgId } }, + ]); + const storedProposal = ( + await db.select().from(proposals).where(eq(proposals.id, fixture.proposalId)).limit(1) + )[0]; + expect(storedProposal?.state).toBe("executed"); + const deliveryReplay = await routeTrigger( + db, + fixture.orgId, + fixture.client, + fixture.payload, + async () => null, + deliveryId, + ); + expect(deliveryReplay).toMatchObject({ + routed: true, + reason: "delivery_replayed", + runId: canonical?.id, + }); + const differentComment = await routeTrigger(db, fixture.orgId, fixture.client, { + ...fixture.payload, + comment: { id: 205, body: "/builder" }, + }); + expect(differentComment).toMatchObject({ + routed: false, + reason: "builder_plan_already_consumed", + runId: canonical?.id, + }); + const decisions = ( + await db + .select() + .from(auditEvents) + .where(and(eq(auditEvents.orgId, fixture.orgId), eq(auditEvents.action, "hitl.decided"))) + ).filter((event) => (event.target as { id?: unknown }).id === fixture.proposalId); + expect(decisions).toHaveLength(1); + }); + + it.each([ + { + name: "default branch", + drift: (fixture: Awaited>) => { + fixture.live.baseSha = "b".repeat(40); + }, + }, + { + name: "issue scope", + drift: (fixture: Awaited>) => { + fixture.live.issueBody = "Implement it, plus a newly-added requirement."; + }, + }, + ])("rejects a required GitHub approval when the live $name changed", async ({ drift }) => { + const fixture = await githubRouteFixture("open"); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, fixture.projectId)); + await db + .update(repos) + .set({ + fingerprint: { files: [] }, + fingerprintStatus: "ok", + fingerprintVerifiedAt: new Date(), + }) + .where(eq(repos.projectId, fixture.projectId)); + drift(fixture); + const before = await projectRuns(fixture.orgId, fixture.projectId); + + const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload); + + expect(result).toMatchObject({ routed: false, reason: "builder_plan_stale" }); + expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length); + await expect(lastDenialCode(fixture.orgId)).resolves.toBe("builder_plan_stale"); + const denial = await lastDenialPayload(fixture.orgId); + const provenance = fixture.dispatch.trigger.planProvenance as Record; + const expected = { + baseSha: provenance.workspaceBaseSha, + issueRevisionSha256: provenance.issueRevisionSha256, + }; + const observed = denial.observedPlanInputs as Record; + expect(denial).toMatchObject({ + code: "builder_plan_stale", + expectedPlanInputs: expected, + observedPlanInputs: { checkedAt: expect.any(String) }, + }); + expect({ + baseSha: observed.baseSha, + issueRevisionSha256: observed.issueRevisionSha256, + }).not.toEqual(expected); + }); + + it("fails closed and audits a required approval when GitHub freshness is unavailable", async () => { + const fixture = await githubRouteFixture("open"); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, fixture.projectId)); + await db + .update(repos) + .set({ + fingerprint: { files: [] }, + fingerprintStatus: "ok", + fingerprintVerifiedAt: new Date(), + }) + .where(eq(repos.projectId, fixture.projectId)); + fixture.live.issueError = new Error("GitHub fixture unavailable"); + const before = await projectRuns(fixture.orgId, fixture.projectId); + + const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload); + + expect(result).toMatchObject({ + routed: false, + reason: "builder_plan_freshness_unavailable", + }); + expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length); + await expect(lastDenialCode(fixture.orgId)).resolves.toBe("builder_plan_freshness_unavailable"); + }); + + it.each([ + { + name: "issue drift", + expected: "builder_plan_stale", + secondIssueBody: "Implement it, plus a requirement added during worker claim.", + secondIssueError: null, + }, + { + name: "GitHub freshness outage", + expected: "builder_plan_freshness_unavailable", + secondIssueBody: null, + secondIssueError: "GitHub fixture unavailable during worker claim", + }, + ])("revalidates a required plan after the worker claim and launches no sandbox on $name", async ({ + expected, + secondIssueBody, + secondIssueError, + }) => { + const fixture = await githubRouteFixture("open"); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, fixture.projectId)); + await db + .update(repos) + .set({ + fingerprint: { files: [] }, + fingerprintStatus: "ok", + fingerprintVerifiedAt: new Date(), + }) + .where(eq(repos.projectId, fixture.projectId)); + const routed = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload); + expect(routed).toMatchObject({ routed: true }); + if (!routed.runId) throw new Error("required Builder run was not created"); + + const repository = fixture.payload.repository; + const owner = repository?.owner?.login; + const repo = repository?.name; + if (!owner || !repo) throw new Error("worker freshness repository fixture missing"); + let issueReads = 0; + let launches = 0; + const workerClient = { + getDefaultBranchSha: async () => fixture.live.baseSha, + getIssue: async () => { + issueReads += 1; + if (issueReads === 2 && secondIssueError) throw new Error(secondIssueError); + return { + number: 204, + title: "Require a plan", + body: issueReads === 1 ? "Implement it" : secondIssueBody, + state: "open", + user: { login: "requester" }, + labels: [], + html_url: `https://github.test/${owner}/${repo}/issues/204`, + }; + }, + listIssueComments: async () => [ + { + id: 204, + author: "maintainer", + authorType: "User", + body: "/builder", + createdAt: "2026-08-26T10:00:00Z", + url: "https://github.test/comments/204", + }, + ], + }; + + await expect( + dispatchRun( + { databaseUrl } as AppConfig, + { runId: routed.runId, orgId: fixture.orgId }, + { + githubClient: { owner, repo, client: workerClient }, + sandboxDriver: async () => { + launches += 1; + return { + name: "docker", + launch: async () => ({ ref: "must-not-launch" }), + status: async () => "running", + async *logs() {}, + stop: async () => undefined, + destroy: async () => undefined, + } as never; + }, + }, + ), + ).rejects.toMatchObject({ code: expected }); + expect(issueReads).toBe(2); + expect(launches).toBe(0); + expect( + ( + await db + .select({ status: runs.status, error: runs.error }) + .from(runs) + .where(eq(runs.id, routed.runId)) + .limit(1) + )[0], + ).toEqual({ status: "failed", error: expected }); + await expect(lastDenialCode(fixture.orgId)).resolves.toBe(expected); + }); + + it("recovers an executing GitHub plan and a crash after the exact row was created", async () => { + const executing = await githubRouteFixture("executing"); + const recovered = await routeTrigger( + db, + executing.orgId, + executing.client, + executing.payload, + async () => null, + ); + expect(recovered).toMatchObject({ routed: true }); + const recoveredRun = ( + await db + .select() + .from(runs) + .where(eq(runs.id, recovered.runId ?? "")) + .limit(1) + )[0]; + expect(recoveredRun?.createdBy).toMatchObject({ + type: "user", + id: "github:maintainer", + proposalId: executing.proposalId, + }); + expect( + (await db.select().from(proposals).where(eq(proposals.id, executing.proposalId)).limit(1))[0] + ?.state, + ).toBe("executed"); + + const crashed = await githubRouteFixture("executed"); + const exactId = newId("run"); + await db.insert(runs).values({ + id: exactId, + orgId: crashed.orgId, + projectId: crashed.projectId, + agentDefId: crashed.builderAgentId, + mode: "codex-builder", + engine: "codex", + status: "queued", + trigger: crashed.dispatch.trigger, + gh: crashed.dispatch.gh, + createdBy: { type: "user", id: "approver" }, + }); + const jobs: Record[] = []; + const crashRecovery = await routeTrigger( + db, + crashed.orgId, + crashed.client, + crashed.payload, + async (_queue, data) => { + jobs.push(data); + return null; + }, + `delivery_${crypto.randomUUID()}`, + ); + expect(crashRecovery).toMatchObject({ routed: true, runId: exactId }); + expect(jobs).toEqual([{ runId: exactId, orgId: crashed.orgId }]); + }); + + it("does not enqueue an exact queued row when proposal recovery fails the required gate", async () => { + const fixture = await githubRouteFixture("execution_failed"); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, fixture.projectId)); + const exactId = newId("run"); + await db.insert(runs).values({ + id: exactId, + orgId: fixture.orgId, + projectId: fixture.projectId, + agentDefId: fixture.builderAgentId, + mode: "builder", + engine: "codex", + status: "queued", + trigger: fixture.dispatch.trigger, + gh: fixture.dispatch.gh, + createdBy: { type: "user", id: "github:maintainer" }, + }); + const jobs: Record[] = []; + const result = await routeTrigger( + db, + fixture.orgId, + fixture.client, + fixture.payload, + async (_queue, data) => { + jobs.push(data); + return null; + }, + `delivery_${crypto.randomUUID()}`, + ); + + expect(result).toMatchObject({ + routed: false, + reason: "builder_plan_context_invalid", + }); + expect(jobs).toHaveLength(0); + expect( + (await db.select({ status: runs.status }).from(runs).where(eq(runs.id, exactId)).limit(1))[0] + ?.status, + ).toBe("queued"); + }); + + it("returns the stable expired code for a persisted expired GitHub proposal", async () => { + const fixture = await githubRouteFixture("expired"); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, fixture.projectId)); + const before = await projectRuns(fixture.orgId, fixture.projectId); + const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload); + expect(result).toMatchObject({ routed: false, reason: "builder_plan_expired" }); + expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length); + }); + + it("returns the stable expired code when an open GitHub proposal has passed expiresAt", async () => { + const fixture = await githubRouteFixture("open"); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, fixture.projectId)); + await db + .update(proposals) + .set({ expiresAt: new Date(Date.now() - 1_000) }) + .where(eq(proposals.id, fixture.proposalId)); + const before = await projectRuns(fixture.orgId, fixture.projectId); + const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload); + expect(result).toMatchObject({ routed: false, reason: "builder_plan_expired" }); + expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length); + }); + + it.each([ + "expired", + "rejected", + "canceled", + "execution_failed", + ] as const)("preserves optional-policy /builder compatibility after a %s proposal", async (state) => { + const fixture = await githubRouteFixture(state); + const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload); + expect(result).toMatchObject({ routed: true }); + const run = ( + await db + .select() + .from(runs) + .where(eq(runs.id, result.runId ?? "")) + .limit(1) + )[0]; + expect(run?.trigger).toMatchObject({ type: "github_comment" }); + expect(run?.trigger).not.toMatchObject({ source: "plan_acceptance" }); + }); + + it("persists the canonical Builder mode for a renamed optional GitHub agent", async () => { + const fixture = await githubRouteFixture("rejected"); + await db + .update(agentDefs) + .set({ + name: "delivery-specialist", + triggers: [{ type: "command", handle: "/builder" }], + }) + .where(eq(agentDefs.id, fixture.builderAgentId)); + const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload); + expect(result).toMatchObject({ routed: true }); + const run = ( + await db + .select() + .from(runs) + .where(eq(runs.id, result.runId ?? "")) + .limit(1) + )[0]; + expect(run).toMatchObject({ mode: "builder", agentDefId: fixture.builderAgentId }); + }); + + it.each([ + { + name: "missing", + expected: "builder_plan_required", + arrange: async () => { + const fixture = await canonicalFixture(); + return { fixture, dispatch: { ...fixture.dispatch, trigger: { type: "manual" } } }; + }, + }, + { + name: "expired", + expected: "builder_plan_expired", + arrange: async () => { + const fixture = await canonicalFixture({ expiresAt: new Date(Date.now() - 60_000) }); + return { fixture, dispatch: fixture.dispatch }; + }, + }, + { + name: "persisted expired state", + expected: "builder_plan_expired", + arrange: async () => { + const fixture = await canonicalFixture({ state: "expired" }); + return { fixture, dispatch: fixture.dispatch }; + }, + }, + { + name: "rejected", + expected: "builder_plan_rejected", + arrange: async () => { + const fixture = await canonicalFixture({ state: "rejected" }); + return { fixture, dispatch: fixture.dispatch }; + }, + }, + { + name: "stale", + expected: "builder_plan_stale", + arrange: async () => { + const fixture = await canonicalFixture(); + return { + fixture, + dispatch: { + ...fixture.dispatch, + freshnessEvidence: { + ...fixture.dispatch.freshnessEvidence, + baseSha: "b".repeat(40), + }, + }, + }; + }, + }, + { + name: "invalid canonical context", + expected: "builder_plan_context_invalid", + arrange: async () => { + const fixture = await canonicalFixture(); + return { + fixture, + dispatch: { + ...fixture.dispatch, + trigger: { ...fixture.dispatch.trigger, planSha256: "f".repeat(64) }, + }, + }; + }, + }, + { + name: "non-human approval principal", + expected: "builder_plan_context_invalid", + arrange: async () => { + const fixture = await canonicalFixture(); + await db + .update(proposalEvents) + .set({ actor: { type: "key", id: "approver" } }) + .where( + and( + eq(proposalEvents.proposalId, fixture.proposalId), + eq(proposalEvents.type, "approved"), + ), + ); + return { fixture, dispatch: fixture.dispatch }; + }, + }, + { + name: "freshness unavailable", + expected: "builder_plan_freshness_unavailable", + arrange: async () => { + const fixture = await canonicalFixture(); + const { freshnessEvidence: _freshnessEvidence, ...dispatch } = fixture.dispatch; + return { fixture, dispatch }; + }, + }, + ])("denies $name with a stable code and no new run row", async ({ expected, arrange }) => { + const { fixture, dispatch } = await arrange(); + const before = await projectRuns(fixture.orgId, fixture.projectId); + await expect(assertBuilderPlanDispatch(db, dispatch)).rejects.toSatisfy( + (error: unknown) => error instanceof ApiError && error.code === expected, + ); + expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length); + await expect(lastDenialCode(fixture.orgId)).resolves.toBe(expected); + }); + + async function canonicalFixture(options: { state?: string; expiresAt?: Date } = {}): Promise<{ + orgId: string; + projectId: string; + proposalId: string; + dispatch: Parameters[1] & { + trigger: Record; + gh: Record; + freshnessEvidence: { baseSha: string; issueRevisionSha256: string; checkedAt: string }; + }; + }> { + const suffix = crypto.randomUUID().replaceAll("-", ""); + const orgId = `org_plan_${suffix}`; + const projectId = `proj_plan_${suffix}`; + const repoId = `repo_plan_${suffix}`; + const architectRunId = `run_arch_${suffix}`; + const proposalId = `prop_plan_${suffix}`; + const actionTypeId = `act_plan_${suffix}`; + const baseSha = "a".repeat(40); + const issueUrl = `https://github.test/facility-test/plan-${suffix}/issues/204`; + const issueRequest = { + title: "Require a plan", + body: "Implement it", + state: "open", + author: "requester", + url: issueUrl, + labels: [], + comments: [], + }; + const issueRevisionSha256 = githubIssueRevisionSha256(issueRequest); + if (!issueRevisionSha256) throw new Error("issue revision fixture missing"); + const plan = "Implement the reviewed change and run the named checks."; + const planSha256 = createHash("sha256").update(plan).digest("hex"); + const decidedAt = new Date(); + + await db.insert(orgs).values({ id: orgId, name: `Plan ${suffix}`, slug: `plan-${suffix}` }); + await db.insert(projects).values({ + id: projectId, + orgId, + name: "Plan policy", + slug: `plan-policy-${suffix}`, + builderPlanPolicy: "required", + }); + await db.insert(repos).values({ + id: repoId, + orgId, + projectId, + owner: "facility-test", + name: `plan-${suffix}`, + defaultBranch: "main", + }); + await db.insert(ghIssues).values({ + id: `ghi_${suffix}`, + orgId, + projectId, + repoId, + number: 204, + title: "Require a plan", + state: "open", + htmlUrl: issueUrl, + ghUpdatedAt: new Date(), + }); + const receipt = sealFacilityReceipt( + { + schema: "facility.run.v1", + run_id: architectRunId, + project_id: projectId, + provider: "codex_cli", + mode: "architect", + result: "succeeded", + usage: { + input_tokens: 10, + output_tokens: 20, + cost_cents: 1, + cost_source: "test", + }, + activity: { + turns: 1, + shell_commands: 0, + file_changes: 0, + mcp_tool_calls: 0, + web_searches: 0, + tool_calls: 1, + errors: 0, + }, + github: { + owner: "facility-test", + repo: `plan-${suffix}`, + issue: 204, + base_sha: baseSha, + }, + timing: { started_at: decidedAt.toISOString(), ended_at: decidedAt.toISOString() }, + }, + null, + ); + await db.insert(runs).values({ + id: architectRunId, + orgId, + projectId, + mode: "architect", + engine: "codex", + status: "succeeded", + trigger: { + type: "github_comment", + repo: { + id: repoId, + owner: "facility-test", + name: `plan-${suffix}`, + baseSha, + }, + issue: { number: 204 }, + request: issueRequest, + }, + workspaceBaseSha: baseSha, + receipt, + gh: { owner: "facility-test", repo: `plan-${suffix}`, issueNumber: 204 }, + createdBy: { type: "user", id: "requester" }, + }); + await db.insert(actionTypes).values({ + id: actionTypeId, + orgId, + name: "plan_acceptance", + payloadSchema: { type: "object" }, + resolver: { type: "permission", config: { permission: "hitl:decide" } }, + executor: { type: "internal", config: {} }, + defaultTtlHours: 72, + }); + await db.insert(proposals).values({ + id: proposalId, + orgId, + projectId, + runId: architectRunId, + actionTypeId, + payload: { + architectRunId, + issueNumber: 204, + repoId, + receiptSha256: receipt.integrity?.payload_sha256, + planSha256, + workspaceBaseSha: baseSha, + issueRevisionSha256, + }, + contextMd: plan, + state: options.state ?? "executing", + decidedBy: "approver", + decidedAt, + expiresAt: options.expiresAt ?? new Date(Date.now() + 3_600_000), + }); + await db.insert(proposalEvents).values([ + { + orgId, + proposalId, + seq: 1, + type: "open", + actor: { type: "agent", id: architectRunId }, + data: { source: "architect_run" }, + }, + { + orgId, + proposalId, + seq: 2, + type: "approved", + actor: { type: "user", id: "approver" }, + data: {}, + }, + ]); + return { + orgId, + projectId, + proposalId, + dispatch: { + orgId, + projectId, + mode: "builder", + trigger: { + source: "plan_acceptance", + proposalId, + architectRunId, + approvedPlan: plan, + planSha256, + approval: { principal: "approver", at: decidedAt.toISOString() }, + planProvenance: { workspaceBaseSha: baseSha, issueRevisionSha256 }, + }, + gh: { owner: "facility-test", repo: `plan-${suffix}`, issueNumber: 204 }, + actor: { type: "user", id: "approver" }, + source: "integration_test", + freshnessEvidence: { + baseSha, + issueRevisionSha256, + checkedAt: new Date().toISOString(), + }, + }, + }; + } + + async function githubRouteFixture( + state: + | "open" + | "executing" + | "executed" + | "expired" + | "rejected" + | "canceled" + | "execution_failed", + ) { + const fixture = await canonicalFixture({ state }); + await db + .update(projects) + .set({ builderPlanPolicy: "optional" }) + .where(eq(projects.id, fixture.projectId)); + if (state === "open") { + await db + .update(proposals) + .set({ decidedBy: null, decidedAt: null }) + .where(eq(proposals.id, fixture.proposalId)); + await db + .delete(proposalEvents) + .where(and(eq(proposalEvents.proposalId, fixture.proposalId), eq(proposalEvents.seq, 2))); + } else { + await db + .update(proposalEvents) + .set({ + actor: { type: "user", id: "github:maintainer", name: "maintainer" }, + data: { source: "github_command", commentId: 204 }, + }) + .where( + and( + eq(proposalEvents.proposalId, fixture.proposalId), + eq(proposalEvents.type, "approved"), + ), + ); + await db + .update(proposals) + .set({ decidedBy: "github:maintainer" }) + .where(eq(proposals.id, fixture.proposalId)); + } + const repo = ( + await db.select().from(repos).where(eq(repos.projectId, fixture.projectId)).limit(1) + )[0]; + if (!repo) throw new Error("route repo fixture missing"); + const contractId = newId("item"); + const builderAgentId = newId("agent"); + await db.insert(registryItems).values({ + id: contractId, + orgId: fixture.orgId, + scope: "project", + projectId: fixture.projectId, + kind: "contract", + name: `route-builder-${crypto.randomUUID()}`, + }); + await db.insert(agentDefs).values({ + id: builderAgentId, + orgId: fixture.orgId, + projectId: fixture.projectId, + name: "builder", + engine: "codex", + model: { primary: "gpt-5" }, + contractItemId: contractId, + triggers: [ + { type: "command", handle: "/builder" }, + { type: "command", handle: "/codex-builder" }, + ], + }); + let nextCommentId = 1; + const live: { baseSha: string; issueBody: string; issueError?: Error } = { + baseSha: fixture.dispatch.freshnessEvidence.baseSha, + issueBody: "Implement it", + }; + const client = { + userCanWrite: async () => true, + getContent: async () => ({ + type: "file", + encoding: "base64", + content: Buffer.from( + JSON.stringify({ + executionLane: { builder: "platform", "codex-builder": "platform" }, + }), + ).toString("base64"), + }), + listIssueComments: async () => [ + { + id: 204, + author: "maintainer", + authorType: "User", + body: "/builder", + createdAt: "2026-08-26T10:00:00Z", + url: "https://github.test/comments/204", + }, + ], + getDefaultBranchSha: async () => live.baseSha, + getIssue: async () => { + if (live.issueError) throw live.issueError; + return { + number: 204, + title: "Require a plan", + body: live.issueBody, + state: "open", + user: { login: "requester" }, + labels: [], + html_url: `https://github.test/${repo.owner}/${repo.name}/issues/204`, + }; + }, + assignIssue: async () => true, + createIssueComment: async () => ({ id: nextCommentId++ }), + } as never; + const payload: TriggerPayload = { + action: "created", + comment: { id: 204, body: "/builder" }, + issue: { + number: 204, + title: "Require a plan", + body: "Implement it", + user: { login: "requester" }, + labels: [], + html_url: `https://github.test/${repo.owner}/${repo.name}/issues/204`, + }, + repository: { owner: { login: repo.owner }, name: repo.name }, + sender: { login: "maintainer", type: "User" }, + }; + return { + ...fixture, + architectRunId: String(fixture.dispatch.trigger.architectRunId), + builderAgentId, + client, + live, + payload, + }; + } + + async function projectRuns(orgId: string, projectId: string) { + return db + .select({ id: runs.id }) + .from(runs) + .where(and(eq(runs.orgId, orgId), eq(runs.projectId, projectId))); + } + + async function lastDenialCode(orgId: string) { + return (await lastDenialPayload(orgId)).code; + } + + async function lastDenialPayload(orgId: string): Promise> { + const row = ( + await db + .select({ payload: auditEvents.payload }) + .from(auditEvents) + .where(and(eq(auditEvents.orgId, orgId), eq(auditEvents.action, "run.builder_plan_denied"))) + .orderBy(desc(auditEvents.seq)) + .limit(1) + )[0]; + return (row?.payload as Record | undefined) ?? {}; + } +}); diff --git a/services/api/test/builder-plan-policy.test.ts b/services/api/test/builder-plan-policy.test.ts new file mode 100644 index 00000000..c7f39992 --- /dev/null +++ b/services/api/test/builder-plan-policy.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { builderPlanDecision } from "../src/builder-plan-policy.js"; + +describe("builder plan policy", () => { + it("preserves direct Builder dispatch for optional projects", () => { + expect( + builderPlanDecision({ + policy: "optional", + mode: "builder", + trigger: { type: "manual" }, + acceptanceValid: false, + }), + ).toEqual({ allowed: true }); + }); + + it("denies Builder without a plan when the project requires one", () => { + expect( + builderPlanDecision({ + policy: "required", + mode: "codex-builder", + trigger: { type: "web_issue" }, + acceptanceValid: false, + }), + ).toEqual({ allowed: false, code: "builder_plan_required" }); + }); + + it("accepts only a durably validated plan_acceptance in required mode", () => { + const trigger = { + source: "plan_acceptance", + proposalId: "prop_test", + architectRunId: "run_test", + }; + expect( + builderPlanDecision({ + policy: "required", + mode: "builder", + trigger, + acceptanceValid: false, + }), + ).toEqual({ allowed: false, code: "builder_plan_context_invalid" }); + expect( + builderPlanDecision({ + policy: "required", + mode: "builder", + trigger, + acceptanceValid: true, + }), + ).toEqual({ allowed: true }); + }); + + it("does not gate read-only Architect runs", () => { + expect( + builderPlanDecision({ + policy: "required", + mode: "codex-architect", + trigger: { type: "github_comment" }, + acceptanceValid: false, + }), + ).toEqual({ allowed: true }); + }); + + it("derives Builder identity from the canonical agent when mode describes a surface", () => { + expect( + builderPlanDecision({ + policy: "required", + mode: "conversation", + agentName: "codex-builder", + trigger: { type: "conversation" }, + acceptanceValid: false, + }), + ).toEqual({ allowed: false, code: "builder_plan_required" }); + }); + + it.each([ + "builder_plan_expired", + "builder_plan_rejected", + "builder_plan_already_consumed", + "builder_plan_stale", + "builder_plan_freshness_unavailable", + "builder_plan_context_invalid", + ] as const)("preserves the stable %s denial code", (denialCode) => { + expect( + builderPlanDecision({ + policy: "required", + mode: "codex_builder", + trigger: { source: "plan_acceptance" }, + acceptanceValid: false, + denialCode, + }), + ).toEqual({ allowed: false, code: denialCode }); + }); +}); diff --git a/services/api/test/builder-plan-producer-inventory.test.ts b/services/api/test/builder-plan-producer-inventory.test.ts new file mode 100644 index 00000000..55af3246 --- /dev/null +++ b/services/api/test/builder-plan-producer-inventory.test.ts @@ -0,0 +1,159 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const sourceRoot = fileURLToPath(new URL("../src", import.meta.url)); +const expectedProducerCounts = new Map([ + ["executors.ts", 5], + ["github/processor.ts", 1], + ["github/router.ts", 1], + ["integrations/inbound.ts", 1], + ["learning.ts", 1], + ["routes/v1/assistant.ts", 1], + ["routes/v1/conversations.ts", 1], + ["routes/v1/github.ts", 1], + ["routes/v1/kb-tasks.ts", 1], + ["routes/v1/runs.ts", 3], + ["scheduler.ts", 1], + ["schedules.ts", 1], + ["watchtower/canary.ts", 1], +]); + +describe("Builder plan producer inventory", () => { + it("requires every production run insert to declare a reviewed preflight", () => { + const inserts: Array<{ + file: string; + relativeFile: string; + offset: number; + context: string; + guarded: boolean; + admissionModePersisted: boolean; + }> = []; + for (const file of typescriptFiles(sourceRoot)) { + const source = readFileSync(file, "utf8"); + const relativeFile = file.slice(sourceRoot.length + 1); + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + visit(sourceFile, (node) => { + if (!isRunsInsert(node)) return; + const offset = node.getStart(sourceFile); + inserts.push({ + file, + relativeFile, + offset, + context: source.slice(Math.max(0, offset - 220), offset + 220), + guarded: hasTransactionalAdmissionAncestor(node, relativeFile, sourceFile), + admissionModePersisted: persistsAdmissionMode(node, relativeFile, sourceFile), + }); + }); + } + + expect(inserts).toHaveLength(19); + expect( + new Map( + [...new Set(inserts.map((insert) => insert.relativeFile))].map((file) => [ + file, + inserts.filter((insert) => insert.relativeFile === file).length, + ]), + ), + ).toEqual(expectedProducerCounts); + for (const insert of inserts) { + expect( + insert.context, + `${insert.file}:${insert.offset} inserts a run without the Builder plan producer review marker`, + ).toMatch(/builder-plan-preflight:\s*[a-z0-9_:-]+\s*(?:\n|\r\n)/); + expect( + insert.guarded, + `${insert.file}:${insert.offset} does not couple Builder preflight and run insertion under the shared project lock`, + ).toBe(true); + expect( + insert.admissionModePersisted, + `${insert.file}:${insert.offset} does not persist the immutable Builder admission mode`, + ).toBe(true); + } + }); +}); + +function isRunsInsert(node: ts.Node): node is ts.CallExpression { + const target = ts.isCallExpression(node) ? node.arguments.at(0) : undefined; + return ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "insert" && + node.arguments.length === 1 && + target !== undefined && + ts.isIdentifier(target) && + target.text === "runs" + ); +} + +function persistsAdmissionMode( + insert: ts.CallExpression, + relativeFile: string, + sourceFile: ts.SourceFile, +) { + for (let current: ts.Node | undefined = insert.parent; current; current = current.parent) { + if (!ts.isArrowFunction(current) && !ts.isFunctionExpression(current)) continue; + const call: ts.Node = current.parent; + if (!ts.isCallExpression(call) || !call.arguments.includes(current)) continue; + const body = current.getText(sourceFile); + if (ts.isIdentifier(call.expression) && call.expression.text === "withBuilderPlanPreflight") { + const parameter = current.parameters[1]?.name; + if (!parameter || !ts.isIdentifier(parameter)) return false; + return new RegExp(`mode\\s*:\\s*${parameter.text}\\.mode\\b`).test(body); + } + if ( + relativeFile === "scheduler.ts" && + ts.isPropertyAccessExpression(call.expression) && + call.expression.name.text === "transaction" + ) { + return ( + /admittedMode\s*=\s*admission\.mode\b/.test(body) && /mode\s*:\s*admittedMode\b/.test(body) + ); + } + } + return false; +} + +function hasTransactionalAdmissionAncestor( + insert: ts.CallExpression, + relativeFile: string, + sourceFile: ts.SourceFile, +) { + for (let current: ts.Node | undefined = insert.parent; current; current = current.parent) { + if (!ts.isArrowFunction(current) && !ts.isFunctionExpression(current)) continue; + const call: ts.Node = current.parent; + if (!ts.isCallExpression(call) || !call.arguments.includes(current)) continue; + if (ts.isIdentifier(call.expression) && call.expression.text === "withBuilderPlanPreflight") { + return true; + } + if ( + relativeFile === "scheduler.ts" && + ts.isPropertyAccessExpression(call.expression) && + call.expression.name.text === "transaction" + ) { + const body = current.getText(sourceFile); + return body.includes("lockBuilderPlanPolicy(") && body.includes("assertBuilderPlanDispatch("); + } + } + return false; +} + +function visit(node: ts.Node, callback: (node: ts.Node) => void) { + callback(node); + node.forEachChild((child) => visit(child, callback)); +} + +function typescriptFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) return typescriptFiles(path); + return entry.isFile() && entry.name.endsWith(".ts") ? [path] : []; + }); +} diff --git a/services/api/test/github-issue-revision.test.ts b/services/api/test/github-issue-revision.test.ts new file mode 100644 index 00000000..4d3597d9 --- /dev/null +++ b/services/api/test/github-issue-revision.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + githubIssueRevisionContext, + githubIssueRevisionSha256, +} from "../src/github/issue-revision.js"; + +describe("GitHub issue revision", () => { + const issue = { + title: "Keep sources consistent", + body: "Apply the same rule on every surface.", + state: "open", + user: { login: "requester" }, + labels: [{ name: "frontend" }, "delivery"], + html_url: "https://github.test/theam/aifindr-ui/issues/116", + }; + const comments = [ + { + id: 1, + author: "requester", + authorType: "User", + body: "/architect\nInclude the permission fix.", + createdAt: "2026-08-26T10:00:00Z", + url: "https://github.test/comments/1", + }, + { + id: 2, + author: "facility-agent[bot]", + authorType: "Bot", + body: "\n_Last updated: now_", + createdAt: "2026-08-26T10:01:00Z", + url: "https://github.test/comments/2", + }, + { + id: 3, + author: "maintainer", + authorType: "User", + body: "/builder", + createdAt: "2026-08-26T10:02:00Z", + url: "https://github.test/comments/3", + }, + { + id: 4, + author: "facility-agent[bot]", + authorType: "Bot", + body: "\nPublished plan", + createdAt: "2026-08-26T10:03:00Z", + url: "https://github.test/comments/4", + }, + ]; + + it("is stable across label ordering and Facility or approval-only comments", () => { + const [requestComment, progressComment, approvalComment] = comments; + if (!requestComment || !progressComment || !approvalComment) { + throw new Error("issue revision comments fixture is incomplete"); + } + const baseline = githubIssueRevisionSha256(githubIssueRevisionContext(issue, comments)); + const updated = githubIssueRevisionSha256( + githubIssueRevisionContext({ ...issue, labels: ["delivery", { name: "frontend" }] }, [ + requestComment, + { ...progressComment, body: "\nchanged" }, + { ...approvalComment, body: " /codex-builder! " }, + ]), + ); + expect(updated).toBe(baseline); + }); + + it("changes for material issue, state, or comment edits", () => { + const baseline = githubIssueRevisionSha256(githubIssueRevisionContext(issue, comments)); + expect( + githubIssueRevisionSha256( + githubIssueRevisionContext(issue, [ + ...comments, + { + id: 5, + author: "maintainer", + authorType: "User", + body: "/builder and also change the API contract", + createdAt: "2026-08-26T10:04:00Z", + url: "https://github.test/comments/5", + }, + ]), + ), + ).not.toBe(baseline); + expect( + githubIssueRevisionSha256( + githubIssueRevisionContext({ ...issue, state: "closed" }, comments), + ), + ).not.toBe(baseline); + expect( + githubIssueRevisionSha256( + githubIssueRevisionContext({ ...issue, body: "A changed scope." }, comments), + ), + ).not.toBe(baseline); + }); +}); diff --git a/services/api/test/github-platform-lane.test.ts b/services/api/test/github-platform-lane.test.ts index 6e8b7adf..bc2c494d 100644 --- a/services/api/test/github-platform-lane.test.ts +++ b/services/api/test/github-platform-lane.test.ts @@ -1,5 +1,6 @@ import { generateApiKey, hashKey, newId } from "@facility/core"; import { + actionTypes, agentDefs, apiKeys, auditEvents, @@ -16,6 +17,7 @@ import { platformIssues, previewSandboxes, projects, + proposalEvents, proposals, registryItems, repos, @@ -33,6 +35,7 @@ import { buildApp } from "../src/app.js"; import { executeApprovedProposal } from "../src/executors.js"; import type { GithubClientFactory, Octokit } from "../src/github/client.js"; import { pullRequestBodyForIssue } from "../src/github/closing-issues.js"; +import { githubIssueRevisionSha256 } from "../src/github/issue-revision.js"; import { syncRepoIssues, upsertGhIssueFromWebhook } from "../src/github/issues-sync.js"; import { enqueueGithubIssuesSync, @@ -49,8 +52,10 @@ import { finishRun, publishRunDelivery, RunDeliveryLeaseLostError, + reconcileArchitectPlanPublications, } from "../src/sandbox/orchestrator.js"; import type { AppConfig } from "../src/types.js"; +import { expireHitlProposals } from "../src/watchtower/hitl.js"; const databaseUrl = process.env.DATABASE_URL ?? "postgres://facility:facility@127.0.0.1:5461/facility_test"; @@ -2303,18 +2308,43 @@ describe("github platform lane", async () => { observedAt: new Date("2026-08-01T01:02:00Z"), }, ]); - const legacyProposal = await app.inject({ - method: "POST", - url: "/v1/proposals", - headers: { cookie }, - payload: { - projectId, - actionType: "plan_acceptance", - payload: { issueNumber: number }, - contextMd: "Legacy proposal without a repository id", - }, + const planAcceptance = ( + await db + .select({ id: actionTypes.id }) + .from(actionTypes) + .where(and(eq(actionTypes.orgId, orgId), eq(actionTypes.name, "plan_acceptance"))) + .limit(1) + )[0]; + if (!planAcceptance) throw new Error("plan_acceptance action fixture missing"); + const legacyArchitectRun = await insertRun({ + mode: "architect", + status: "succeeded", + gh: { issueNumber: number }, + }); + const legacyProposal = ( + await db + .insert(proposals) + .values({ + id: newId("prop"), + orgId, + projectId, + runId: legacyArchitectRun.id, + actionTypeId: planAcceptance.id, + payload: { architectRunId: legacyArchitectRun.id, issueNumber: number }, + contextMd: "Legacy proposal without a repository id", + expiresAt: new Date(Date.now() + 3_600_000), + }) + .returning() + )[0]; + if (!legacyProposal) throw new Error("legacy proposal fixture missing"); + await db.insert(proposalEvents).values({ + orgId, + proposalId: legacyProposal.id, + seq: 1, + type: "open", + actor: { type: "agent", id: legacyArchitectRun.id }, + data: { source: "architect_run" }, }); - expect(legacyProposal.statusCode, legacyProposal.body).toBe(200); const pipeline = await app.inject({ method: "GET", @@ -3109,7 +3139,14 @@ describe("github platform lane", async () => { }) as never; const repo = await insertRepoWithInstallation(`trigger-${Date.now()}`); await insertIssue(repo.id, 44, "open", "2026-03-01T00:00:00Z"); - await insertAgent("builder"); + const storyBuilder = await insertAgent("builder"); + await db + .update(agentDefs) + .set({ + name: "delivery-specialist", + triggers: [{ type: "command", handle: "/builder" }], + }) + .where(eq(agentDefs.id, storyBuilder.id)); const forged = await app.inject({ method: "POST", @@ -3162,6 +3199,7 @@ describe("github platform lane", async () => { payload: { agent: "builder" }, }); expect(response.statusCode).toBe(200); + expect(response.json().mode).toBe("builder"); expect(response.json().gh.issueNumber).toBe(44); const [manifestSyncedRepo] = await db.select().from(repos).where(eq(repos.id, repo.id)); expect(manifestSyncedRepo?.renderAnswers).toMatchObject({ @@ -3404,6 +3442,123 @@ describe("github platform lane", async () => { app.githubClientFactory = undefined; }); + it("denies Story Build before GitHub access, row creation, or enqueue when plans are required", async () => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const governedProject = ( + await db + .insert(projects) + .values({ + id: newId("proj"), + orgId, + name: `Governed Story Build ${suffix}`, + slug: `governed-story-build-${suffix}`, + builderPlanPolicy: "required", + settings: {}, + }) + .returning() + )[0]; + if (!governedProject) throw new Error("governed project insert failed"); + const contract = ( + await db + .insert(registryItems) + .values({ + id: newId("item"), + orgId, + scope: "project", + projectId: governedProject.id, + kind: "agent_contract", + name: `governed-story-build-${suffix}`, + latestVersion: 1, + }) + .returning() + )[0]; + if (!contract) throw new Error("governed contract insert failed"); + const governedBuilder = ( + await db + .insert(agentDefs) + .values({ + id: newId("agent"), + orgId, + projectId: governedProject.id, + name: "delivery-specialist", + engine: "codex", + model: { primary: "gpt-5.5" }, + contractItemId: contract.id, + triggers: [{ type: "command", handle: "/builder" }], + enabled: true, + }) + .returning() + )[0]; + if (!governedBuilder) throw new Error("governed Builder insert failed"); + const governedRepo = ( + await db + .insert(repos) + .values({ + id: newId("repo"), + orgId, + projectId: governedProject.id, + owner: `governed-story-${suffix}`, + name: "facility", + defaultBranch: "main", + }) + .returning() + )[0]; + if (!governedRepo) throw new Error("governed repository insert failed"); + await db.insert(ghIssues).values({ + id: newId("ghi"), + orgId, + projectId: governedProject.id, + repoId: governedRepo.id, + number: 204, + title: "Build only after Gate 1", + state: "open", + labels: [], + assignees: [], + htmlUrl: `https://github.test/${governedRepo.owner}/${governedRepo.name}/issues/204`, + }); + + const originalFactory = app.githubClientFactory; + const originalEnqueue = app.enqueue; + let githubFactoryCalls = 0; + const enqueued: Array<{ queue: string; data: Record }> = []; + app.githubClientFactory = async () => { + githubFactoryCalls += 1; + throw new Error("Story Build denial reached GitHub"); + }; + app.enqueue = async (queue, data) => { + enqueued.push({ queue, data }); + return null; + }; + const before = await db + .select({ id: runs.id }) + .from(runs) + .where(eq(runs.projectId, governedProject.id)); + try { + const denied = await app.inject({ + method: "POST", + url: `/v1/projects/${governedProject.id}/issues/204/trigger?repoId=${governedRepo.id}`, + headers: { cookie }, + payload: { agent: "builder" }, + }); + expect(denied.statusCode, denied.body).toBe(409); + expect(denied.json().error.code).toBe("builder_plan_required"); + expect( + await db.select({ id: runs.id }).from(runs).where(eq(runs.projectId, governedProject.id)), + ).toHaveLength(before.length); + expect(githubFactoryCalls).toBe(0); + expect(enqueued).toEqual([]); + const denial = ( + await db.select().from(auditEvents).where(eq(auditEvents.projectId, governedProject.id)) + ).find((event) => event.action === "run.builder_plan_denied"); + expect(denial).toMatchObject({ + payload: { code: "builder_plan_required", source: "web_issue_preflight" }, + }); + } finally { + app.githubClientFactory = originalFactory; + app.enqueue = originalEnqueue; + } + }); + it("pins project-scoped keys to their project across the issue mirror (404 elsewhere)", async () => { // A key pinned to ANOTHER project — with full engineer permissions — must // not read this project's issues nor trigger runs in it. @@ -3677,6 +3832,7 @@ describe("github platform lane", async () => { const run = await insertRun({ status: "running", gh: { owner: repo.owner, repo: repo.name, issueNumber: 91 }, + workspaceBaseSha: "b".repeat(40), }); const queued: Array<{ queue: string; data: Record }> = []; await finishRun( @@ -3688,6 +3844,7 @@ describe("github platform lane", async () => { changed: true, branch: "feature/exact-delivery", headSha: "expected-sha", + baseSha: "a".repeat(40), pullRequestTitle: "fix: bind delivery to the pushed commit", pullRequestBody: "Exact delivery", }, @@ -3707,7 +3864,12 @@ describe("github platform lane", async () => { owner: repo.owner, repoName: repo.name, expectedHeadSha: "expected-sha", + baseSha: "a".repeat(40), }); + const [finishedRun] = await db.select().from(runs).where(eq(runs.id, run.id)); + expect((finishedRun?.receipt as { github?: { base_sha?: string } })?.github?.base_sha).toBe( + "a".repeat(40), + ); let createCalls = 0; const blocked = await deliverPendingRunDeliveries(db, config, { @@ -4552,10 +4714,37 @@ describe("github platform lane", async () => { it("finishRun publishes an architect plan and opens the human Gate 1 proposal", async () => { const repo = await insertRepoWithInstallation(`plan-${Date.now()}`); + const workspaceBaseSha = "d".repeat(40); + const issueRequest = { + title: "Preserve Architect publication marker", + body: "Publish one immutable plan.", + state: "open", + author: "maintainer", + url: `https://github.test/${repo.owner}/${repo.name}/issues/71`, + labels: ["governance"], + comments: [], + }; const run = await insertRun({ mode: "architect", status: "running", - gh: { owner: repo.owner, repo: repo.name, issueNumber: 71 }, + workspaceBaseSha, + trigger: { + type: "github_comment", + repo: { id: repo.id, owner: repo.owner, name: repo.name }, + issue: { number: 71 }, + request: issueRequest, + }, + gh: { + owner: repo.owner, + repo: repo.name, + issueNumber: 71, + progressComment: { + id: 7101, + command: "architect", + issueTitle: "Preserve Architect publication marker", + sender: "maintainer", + }, + }, }); await db.insert(runEvents).values({ orgId, @@ -4565,7 +4754,255 @@ describe("github platform lane", async () => { data: { text: "1. Add the behavior.\n2. Prove it with the mirror test." }, }); const comments: string[] = []; + let missingProgressUpdates = 0; + const publicationFactory = async () => + ({ + rest: { + issues: { + listComments: async () => ({ data: [] }), + createComment: async ({ body }: { body: string }) => { + comments.push(body); + return { data: { id: 1 } }; + }, + updateComment: async () => { + missingProgressUpdates += 1; + throw Object.assign(new Error("progress comment deleted"), { status: 404 }); + }, + }, + repos: {}, + pulls: {}, + git: {}, + }, + }) as never; + await expect( + finishRun( + db, + run, + { status: "succeeded" }, + { + config, + githubClientFactory: publicationFactory, + afterArchitectPlanOutboxWrite: () => { + throw new Error("injected_outbox_commit_failure"); + }, + }, + ), + ).rejects.toThrow("injected_outbox_commit_failure"); + expect(comments).toHaveLength(0); + expect((await db.select().from(runs).where(eq(runs.id, run.id)).limit(1))[0]?.status).toBe( + "running", + ); + expect(await db.select().from(proposals).where(eq(proposals.runId, run.id))).toHaveLength(0); + const finished = await finishRun( + db, + run, + { status: "succeeded" }, + { + config, + githubClientFactory: publicationFactory, + }, + ); + expect(finished.status).toBe("succeeded"); + expect(missingProgressUpdates).toBe(1); + expect(comments).toHaveLength(1); + expect(comments.at(-1)).toContain("Human Gate 1"); + expect(comments.at(-1)).toContain("Prove it with the mirror test"); + expect(comments.at(-1)).toContain(""); + expect(body).toContain("No Builder run was created"); + expect(body).toContain("approved base and live issue revision"); + expect(body).toContain("`builder_plan_freshness_unavailable`"); + }); }); diff --git a/services/api/test/sandbox.test.ts b/services/api/test/sandbox.test.ts index 81d8f38a..28bf3d90 100644 --- a/services/api/test/sandbox.test.ts +++ b/services/api/test/sandbox.test.ts @@ -28,7 +28,7 @@ import { seed, virtualKeys, } from "@facility/db"; -import { eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import postgres from "postgres"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildApp } from "../src/app.js"; @@ -182,6 +182,226 @@ describe("sandbox api", async () => { await client.end(); }); + it("fails a queued required-plan Builder before any sandbox or credential is created", async () => { + const suffix = crypto.randomUUID().replaceAll("-", ""); + const guardedProject = ( + await db + .insert(projects) + .values({ + id: newId("proj"), + orgId, + name: "Required Builder Gate", + slug: `required-builder-${suffix}`, + builderPlanPolicy: "required", + settings: {}, + }) + .returning() + )[0]; + if (!guardedProject) throw new Error("guarded project fixture missing"); + const contract = ( + await db + .insert(registryItems) + .values({ + id: newId("item"), + orgId, + projectId: guardedProject.id, + scope: "project", + kind: "agent_contract", + name: `required-builder-contract-${suffix}`, + }) + .returning() + )[0]; + if (!contract) throw new Error("guarded contract fixture missing"); + const agent = ( + await db + .insert(agentDefs) + .values({ + id: newId("agent"), + orgId, + projectId: guardedProject.id, + name: "codex_builder", + engine: "codex", + model: { primary: "gpt-5" }, + contractItemId: contract.id, + }) + .returning() + )[0]; + if (!agent) throw new Error("guarded Builder fixture missing"); + const run = ( + await db + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId: guardedProject.id, + agentDefId: agent.id, + mode: "builder", + engine: agent.engine, + trigger: { type: "conversation", message: "bypass attempt" }, + createdBy: { type: "system", id: "worker-policy-test" }, + }) + .returning() + )[0]; + if (!run) throw new Error("guarded run fixture missing"); + let launches = 0; + const driver: SandboxDriver = { + name: "docker", + launch: async () => { + launches += 1; + return { ref: "must-not-launch" }; + }, + status: async () => "running", + async *logs() {}, + stop: async () => undefined, + destroy: async () => undefined, + }; + + await expect( + dispatchRun(config, { runId: run.id, orgId }, { sandboxDriver: async () => driver }), + ).rejects.toMatchObject({ code: "builder_plan_required" }); + expect(launches).toBe(0); + await expect( + db.select({ id: virtualKeys.id }).from(virtualKeys).where(eq(virtualKeys.runId, run.id)), + ).resolves.toHaveLength(0); + await expect( + db.select({ id: apiKeys.id }).from(apiKeys).where(eq(apiKeys.runId, run.id)), + ).resolves.toHaveLength(0); + const failed = ( + await db + .select({ status: runs.status, error: runs.error }) + .from(runs) + .where(eq(runs.id, run.id)) + .limit(1) + )[0]; + expect(failed).toEqual({ status: "failed", error: "builder_plan_required" }); + const result = ( + await db + .select({ data: runEvents.data }) + .from(runEvents) + .where(and(eq(runEvents.runId, run.id), eq(runEvents.type, "result"))) + .limit(1) + )[0]; + expect(result?.data).toMatchObject({ + status: "failed", + kind: "builder_plan_denied", + error: "builder_plan_required", + }); + }); + + it("denies after a concurrent agent mutation wins before the claimed worker guard", async () => { + const suffix = crypto.randomUUID().replaceAll("-", ""); + const guardedProject = ( + await db + .insert(projects) + .values({ + id: newId("proj"), + orgId, + name: "Worker Agent Snapshot Gate", + slug: `worker-agent-snapshot-${suffix}`, + builderPlanPolicy: "required", + settings: {}, + }) + .returning() + )[0]; + if (!guardedProject) throw new Error("worker snapshot project fixture missing"); + const contract = ( + await db + .insert(registryItems) + .values({ + id: newId("item"), + orgId, + projectId: guardedProject.id, + scope: "project", + kind: "agent_contract", + name: `worker-agent-snapshot-${suffix}`, + }) + .returning() + )[0]; + if (!contract) throw new Error("worker snapshot contract fixture missing"); + const agent = ( + await db + .insert(agentDefs) + .values({ + id: newId("agent"), + orgId, + projectId: guardedProject.id, + name: "worker-canary-specialist", + engine: "codex", + model: { primary: "gpt-5" }, + contractItemId: contract.id, + triggers: [{ type: "manual" }], + }) + .returning() + )[0]; + if (!agent) throw new Error("worker snapshot agent fixture missing"); + const run = ( + await db + .insert(runs) + .values({ + id: newId("run"), + orgId, + projectId: guardedProject.id, + agentDefId: agent.id, + mode: "conversation", + engine: agent.engine, + trigger: { type: "conversation", message: "snapshot race" }, + createdBy: { type: "system", id: "worker-snapshot-test" }, + }) + .returning() + )[0]; + if (!run) throw new Error("worker snapshot run fixture missing"); + let launches = 0; + const driver: SandboxDriver = { + name: "docker", + launch: async () => { + launches += 1; + return { ref: "must-not-launch-after-agent-race" }; + }, + status: async () => "running", + async *logs() {}, + stop: async () => undefined, + destroy: async () => undefined, + }; + let dispatch: Promise | undefined; + await db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`builder-plan:${orgId}:${guardedProject.id}`}, 0))`, + ); + await tx + .update(agentDefs) + .set({ triggers: [{ type: "command", handle: "/builder" }] }) + .where(eq(agentDefs.id, agent.id)); + dispatch = dispatchRun( + config, + { runId: run.id, orgId }, + { sandboxDriver: async () => driver }, + ); + let provisioning = false; + for (let attempt = 0; attempt < 100; attempt += 1) { + const current = ( + await db.select({ status: runs.status }).from(runs).where(eq(runs.id, run.id)).limit(1) + )[0]; + if (current?.status === "provisioning") { + provisioning = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(provisioning).toBe(true); + }); + if (!dispatch) throw new Error("worker snapshot dispatch did not start"); + await expect(dispatch).rejects.toMatchObject({ code: "builder_plan_required" }); + expect(launches).toBe(0); + expect( + ( + await db + .select({ status: runs.status, error: runs.error }) + .from(runs) + .where(eq(runs.id, run.id)) + )[0], + ).toEqual({ status: "failed", error: "builder_plan_required" }); + }); + it("imageExists reports daemon image presence without pulling", async () => { const withInspect = (inspect: () => Promise) => new DockerSandboxDriver({ getImage: () => ({ inspect }) } as unknown as ConstructorParameters< @@ -940,6 +1160,62 @@ describe("sandbox api", async () => { expect(stored).toEqual({ engineSessionId: "sess_early_123", status: "running" }); }); + it("records an authenticated runner workspace base once and accepts exact replays", async () => { + const token = "frt_workspace_base"; + const run = await insertRunnerRun(token, "running"); + const otherToken = "frt_workspace_other_run"; + await insertRunnerRun(otherToken, "running"); + const baseSha = "A".repeat(40); + + const invalid = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${token}` }, + payload: { baseSha: "not-a-commit" }, + }); + expect(invalid.statusCode).toBe(400); + + const crossRun = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${otherToken}` }, + payload: { baseSha }, + }); + expect(crossRun.statusCode).toBe(401); + + const first = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${token}` }, + payload: { baseSha }, + }); + expect(first.statusCode, first.body).toBe(200); + expect(first.json()).toEqual({ baseSha: "a".repeat(40) }); + + const replay = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${token}` }, + payload: { baseSha: "a".repeat(40) }, + }); + expect(replay.statusCode, replay.body).toBe(200); + + const mismatch = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/workspace`, + headers: { authorization: `Bearer ${token}` }, + payload: { baseSha: "b".repeat(40) }, + }); + expect(mismatch.statusCode).toBe(409); + expect(mismatch.json()).toMatchObject({ error: { code: "workspace_base_mismatch" } }); + + const [stored] = await db + .select({ workspaceBaseSha: runs.workspaceBaseSha }) + .from(runs) + .where(eq(runs.id, run.id)); + expect(stored?.workspaceBaseSha).toBe("a".repeat(40)); + }); + it("finishRun synchronizes only trusted qualifying security findings", async () => { const suffix = Date.now(); const installation = ( @@ -1238,6 +1514,8 @@ describe("sandbox api", async () => { it("stores actual check outcomes and provenance in the run receipt", async () => { const token = "frt_receipt_checks"; const run = await insertRunnerRun(token, "running"); + const workspaceBaseSha = "c".repeat(40); + await db.update(runs).set({ workspaceBaseSha }).where(eq(runs.id, run.id)); await appendRunEvents(db, orgId, run.id, [ { type: "check", @@ -1266,6 +1544,9 @@ describe("sandbox api", async () => { { name: "pnpm test", status: "passed", source: "platform", exit_code: 0 }, { name: "agent smoke", status: "skipped", source: "agent" }, ]); + expect((finished?.receipt as { github?: { base_sha?: string } })?.github?.base_sha).toBe( + workspaceBaseSha, + ); expect((finished?.receipt as { checks_truncated?: boolean })?.checks_truncated).toBe(false); }); diff --git a/services/api/test/scheduler.test.ts b/services/api/test/scheduler.test.ts index 95e5e973..8fd2b45e 100644 --- a/services/api/test/scheduler.test.ts +++ b/services/api/test/scheduler.test.ts @@ -9,11 +9,13 @@ import { registryItems, runEvents, runs, + schedulerWatermarks, } from "@facility/db"; import { eq } from "drizzle-orm"; import postgres from "postgres"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { runScheduledAgents } from "../src/scheduler.js"; +import { runAgentSchedules } from "../src/schedules.js"; import type { AppConfig } from "../src/types.js"; const databaseUrl = @@ -203,9 +205,98 @@ describe("scheduled agents", async () => { expect(stored?.lastScheduledAt?.toISOString()).toBe(now.toISOString()); }); + it("does not create or enqueue a legacy scheduled Builder run when plans are required", async () => { + const now = new Date("2032-09-14T11:08:30.000Z"); + const agent = await insertAgent("governed-legacy-schedule", { + triggers: [ + { type: "schedule", config: { cron: "* * * * *", timezone: "UTC" } }, + { type: "command", command: "builder" }, + ], + lastScheduledAt: new Date("2032-09-14T11:07:00.000Z"), + builderPlanPolicy: "required", + }); + const enqueued: Array<{ queue: string; data: Record }> = []; + + await runScheduledAgents( + config, + async (queue, data) => { + enqueued.push({ queue, data }); + }, + { now }, + ); + + expect(await db.select().from(runs).where(eq(runs.agentDefId, agent.id))).toEqual([]); + expect(enqueued.filter((job) => job.data.orgId === agent.orgId)).toEqual([]); + const denial = ( + await db.select().from(auditEvents).where(eq(auditEvents.orgId, agent.orgId)) + ).find( + (event) => + event.action === "run.builder_plan_denied" && + (event.payload as { source?: unknown }).source === "legacy_scheduler", + ); + expect(denial).toMatchObject({ + projectId: agent.projectId, + payload: { code: "builder_plan_required", source: "legacy_scheduler" }, + }); + const stored = ( + await db.select().from(agentDefs).where(eq(agentDefs.id, agent.id)).limit(1) + )[0]; + expect(stored?.lastScheduledAt?.toISOString()).toBe(now.toISOString()); + }); + + it("does not create or enqueue a canonical scheduled Builder run when plans are required", async () => { + const now = new Date("2032-09-14T12:08:30.000Z"); + const agent = await insertAgent("governed-canonical-schedule", { + triggers: [ + { type: "schedule", config: { cron: "* * * * *", timezone: "UTC" } }, + { type: "command", command: "builder" }, + ], + lastScheduledAt: new Date("2032-09-14T12:07:00.000Z"), + builderPlanPolicy: "required", + }); + await db + .insert(schedulerWatermarks) + .values({ + name: "agent.schedules", + lastTick: new Date("2032-09-14T12:07:00.000Z"), + }) + .onConflictDoUpdate({ + target: schedulerWatermarks.name, + set: { lastTick: new Date("2032-09-14T12:07:00.000Z"), updatedAt: new Date() }, + }); + const enqueued: Array<{ queue: string; data: Record }> = []; + + await runAgentSchedules( + config, + async (queue, data) => { + enqueued.push({ queue, data }); + return null; + }, + now, + ); + + expect(await db.select().from(runs).where(eq(runs.agentDefId, agent.id))).toEqual([]); + expect(enqueued.filter((job) => job.data.orgId === agent.orgId)).toEqual([]); + const denial = ( + await db.select().from(auditEvents).where(eq(auditEvents.orgId, agent.orgId)) + ).find( + (event) => + event.action === "run.builder_plan_denied" && + (event.payload as { source?: unknown }).source === "agent_scheduler", + ); + expect(denial).toMatchObject({ + projectId: agent.projectId, + payload: { code: "builder_plan_required", source: "agent_scheduler" }, + }); + }); + async function insertAgent( name: string, - input: { triggers: unknown[]; lastScheduledAt: Date | null }, + input: { + triggers: unknown[]; + lastScheduledAt: Date | null; + builderPlanPolicy?: "optional" | "required"; + }, ) { const suffix = `${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`; const orgId = newId("org"); @@ -216,6 +307,7 @@ describe("scheduled agents", async () => { orgId, name: `Project ${suffix}`, slug: `project-${suffix}`, + builderPlanPolicy: input.builderPlanPolicy ?? "optional", settings: {}, }); const item = ( diff --git a/services/api/test/signals.test.ts b/services/api/test/signals.test.ts index 1422a313..cdceae51 100644 --- a/services/api/test/signals.test.ts +++ b/services/api/test/signals.test.ts @@ -1,5 +1,7 @@ import { newId } from "@facility/core"; import { + agentDefs, + auditEvents, createDb, inboundEvents, integrations, @@ -7,6 +9,8 @@ import { orgs, platformIssues, projects, + registryItems, + runs, } from "@facility/db"; import { eq } from "drizzle-orm"; import postgres from "postgres"; @@ -94,4 +98,98 @@ describe("typed operational signals", async () => { )[0]; expect(issue).toMatchObject({ projectId, kind: "deployment_failure", state: "resolved" }); }); + + it("does not create or enqueue an inbound Builder run when plans are required", async () => { + const suffix = newId("evt"); + const orgId = newId("org"); + const projectId = newId("proj"); + const integrationId = newId("int"); + const inboundEventId = newId("evt"); + await db.insert(orgs).values({ id: orgId, name: suffix, slug: suffix }); + await db.insert(projects).values({ + id: projectId, + orgId, + name: "Governed inbound", + slug: `governed-inbound-${suffix}`, + builderPlanPolicy: "required", + settings: {}, + }); + const contract = ( + await db + .insert(registryItems) + .values({ + id: newId("item"), + orgId, + scope: "project", + projectId, + kind: "agent_contract", + name: `governed-inbound-${suffix}`, + }) + .returning() + )[0]; + const agent = ( + await db + .insert(agentDefs) + .values({ + id: newId("agent"), + orgId, + projectId, + name: "inbound-delivery", + engine: "codex", + model: {}, + contractItemId: contract?.id ?? "", + triggers: [{ type: "command", handle: "/builder" }], + enabled: true, + }) + .returning() + )[0]; + if (!agent) throw new Error("failed to insert inbound agent"); + await db.insert(integrations).values({ + id: integrationId, + orgId, + projectId, + kind: "generic_inbound", + name: "Governed inbound adapter", + config: { projectId, enqueueRun: true, agent: agent.name }, + }); + await db.insert(inboundEvents).values({ + id: inboundEventId, + orgId, + integrationId, + verified: true, + eventType: "delivery.requested", + payload: { + projectId, + issue: { + fingerprint: `governed-inbound:${suffix}`, + title: "Attempt governed delivery", + }, + }, + }); + const enqueued: Array<{ queue: string; data: Record }> = []; + + await expect( + processGenericInboundEvent(db, inboundEventId, async (queue, data) => { + enqueued.push({ queue, data }); + return null; + }), + ).rejects.toMatchObject({ code: "builder_plan_required" }); + + expect(await db.select().from(runs).where(eq(runs.agentDefId, agent.id))).toEqual([]); + expect(enqueued).toEqual([]); + const storedEvent = ( + await db.select().from(inboundEvents).where(eq(inboundEvents.id, inboundEventId)).limit(1) + )[0]; + expect(storedEvent).toMatchObject({ processedAt: null }); + expect(storedEvent?.error).toContain("approved Architect plan"); + const denial = (await db.select().from(auditEvents).where(eq(auditEvents.orgId, orgId))).find( + (event) => + event.action === "run.builder_plan_denied" && + (event.payload as { source?: unknown }).source === "generic_inbound", + ); + expect(denial).toMatchObject({ + projectId, + payload: { code: "builder_plan_required", source: "generic_inbound" }, + }); + }); }); diff --git a/services/api/test/watchtower.test.ts b/services/api/test/watchtower.test.ts index 7bff43df..3f024528 100644 --- a/services/api/test/watchtower.test.ts +++ b/services/api/test/watchtower.test.ts @@ -503,6 +503,69 @@ describe("watchtower", async () => { expect(repoIssue?.state).toBe("open"); }); + it("preserves optional canary agent selection and gates a required dual-role fallback", async () => { + const configuredProject = await insertProject({ + watchtower: { canary: { enabled: true, lane: "platform" } }, + }); + const configuredAgent = await insertAgent(configuredProject.id, "Configured Probe"); + await db + .update(projects) + .set({ + settings: { + watchtower: { + canary: { enabled: true, lane: "platform", agentDefId: configuredAgent.id }, + }, + }, + }) + .where(eq(projects.id, configuredProject.id)); + await collectCanaries(db, new FakeGitHub(), async () => undefined); + expect( + (await db.select().from(runs).where(eq(runs.projectId, configuredProject.id)).limit(1))[0], + ).toMatchObject({ agentDefId: configuredAgent.id, mode: "architect", status: "queued" }); + + const fallbackProject = await insertProject({ + watchtower: { canary: { enabled: true, lane: "platform" } }, + }); + const fallbackAgent = await insertAgent(fallbackProject.id, "Legacy Probe"); + await collectCanaries(db, new FakeGitHub(), async () => undefined); + expect( + (await db.select().from(runs).where(eq(runs.projectId, fallbackProject.id)).limit(1))[0], + ).toMatchObject({ agentDefId: fallbackAgent.id, mode: "architect", status: "queued" }); + + const requiredProject = await insertProject({ + watchtower: { canary: { enabled: true, lane: "platform" } }, + }); + const dualRoleAgent = await insertAgent(requiredProject.id, "Legacy Dual Role Probe"); + await db + .update(agentDefs) + .set({ triggers: [{ type: "command", handle: "/builder" }] }) + .where(eq(agentDefs.id, dualRoleAgent.id)); + await db + .update(projects) + .set({ builderPlanPolicy: "required" }) + .where(eq(projects.id, requiredProject.id)); + const dispatched: Record[] = []; + await collectCanaries(db, new FakeGitHub(), async (queue, data) => { + dispatched.push({ queue, ...data }); + }); + expect(await db.select().from(runs).where(eq(runs.projectId, requiredProject.id))).toEqual([]); + expect(dispatched).toEqual([]); + expect( + ( + await db + .select() + .from(platformIssues) + .where( + and( + eq(platformIssues.projectId, requiredProject.id), + eq(platformIssues.kind, "canary_failure"), + ), + ) + .limit(1) + )[0], + ).toMatchObject({ state: "open", title: "Canary agent blocked by Builder plan policy" }); + }); + it("rolls up analytics idempotently and serves rollup-backed endpoints", async () => { const project = await insertProject(); const agent = await insertAgent(project.id, "Builder");