Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/web/components/run/cockpit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import { isBuilderMode, runObjectiveText } from "@facility/run-objective";
import { Button, Cell, cx, Eyebrow, HairlineGrid, Metric, StatusDot, toneFor } from "@facility/ui";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AiIdentity } from "@/components/ai-identity";
import { runErrorPresentation } from "@/components/run/run-error";
import { RunTranscript } from "@/components/run/transcript";
import { engineIdentity } from "@/lib/ai-identity";
import type { Project, Run, RunEvent } from "@/lib/api";
Expand Down Expand Up @@ -272,6 +274,24 @@ function githubArtifacts(run: Run) {
].filter((item): item is { label: string; text: string; href: string | null } => Boolean(item));
}

function RunErrorLine({ error, projectId }: { error: string; projectId: string | null }) {
const presentation = runErrorPresentation(error, projectId);
if (!presentation) return <p className="text-sm text-(--bad)">{error}</p>;
return (
<div className="flex flex-col gap-1">
<p className="text-sm text-(--bad)">{presentation.message}</p>
{presentation.href ? (
<Link
href={presentation.href}
className="text-[12px] text-(--info) underline-offset-4 hover:underline"
>
configure acceptance checks
</Link>
) : null}
</div>
);
}

function checkItems(events: RunEvent[]) {
return events
.filter((event) => event.type === "check")
Expand Down Expand Up @@ -613,7 +633,7 @@ export function RunCockpit({
</div>
</div>

{run.error ? <p className="text-sm text-(--bad)">{run.error}</p> : null}
{run.error ? <RunErrorLine error={run.error} projectId={project?.id ?? null} /> : null}
</div>

<HairlineGrid cols="grid-cols-2 lg:grid-cols-5" className="border-0 border-b">
Expand Down
42 changes: 42 additions & 0 deletions apps/web/components/run/run-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export const CHECKS_NOT_CONFIGURED = "checks_not_configured";

// Extract the machine code from a run's stored error: the runner posts JSON
// fault payloads (possibly followed by stderr tail), and the platform marks
// early failures with the same JSON shape. Anything else is not a coded error.
export function runErrorCode(error: string | null | undefined): string | null {
if (!error) return null;
const trimmed = error.trim();
if (!trimmed) return null;
if (trimmed.startsWith("{")) {
const end = trimmed.indexOf("}");
if (end > 1) {
try {
const parsed = JSON.parse(trimmed.slice(0, end + 1)) as { code?: unknown };
if (typeof parsed.code === "string") return parsed.code;
} catch {
// Not a JSON error payload — render the raw string below.
}
}
}
return /^[a-z][a-z0-9_]*$/.test(trimmed) ? trimmed : null;
}

export type RunErrorPresentation = {
code: string;
message: string;
href: string | null;
};

export function runErrorPresentation(
error: string | null | undefined,
projectId: string | null,
): RunErrorPresentation | null {
const code = runErrorCode(error);
if (code !== CHECKS_NOT_CONFIGURED) return null;
return {
code,
message:
"No acceptance checks are configured for this project, so the builder run could not deliver.",
href: projectId ? `/projects/${projectId}/settings` : null,
};
}
47 changes: 47 additions & 0 deletions apps/web/test/run-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { runErrorCode, runErrorPresentation } from "@/components/run/run-error";

describe("runErrorCode", () => {
it("extracts the runner's JSON fault code", () => {
expect(runErrorCode('{"code":"checks_not_configured"}')).toBe("checks_not_configured");
});

it("still reads the code when the runner appends stderr tail after the JSON", () => {
expect(
runErrorCode('{"code":"checks_not_configured"} npm error something\nnpm ERR! exit 1'),
).toBe("checks_not_configured");
});

it("accepts a bare machine code", () => {
expect(runErrorCode("checks_not_configured")).toBe("checks_not_configured");
});

it("returns null for unknown codes, plain text, and empty errors", () => {
expect(runErrorCode('{"code":"provision_failed"}')).toBe("provision_failed");
expect(runErrorCode("the database exploded")).toBeNull();
expect(runErrorCode('{"message":"not a code"}')).toBeNull();
expect(runErrorCode("")).toBeNull();
expect(runErrorCode(null)).toBeNull();
expect(runErrorCode(undefined)).toBeNull();
});
});

describe("runErrorPresentation", () => {
it("maps checks_not_configured to a human explanation with a settings link", () => {
expect(runErrorPresentation('{"code":"checks_not_configured"}', "project_1")).toEqual({
code: "checks_not_configured",
message:
"No acceptance checks are configured for this project, so the builder run could not deliver.",
href: "/projects/project_1/settings",
});
});

it("drops the settings link when no project is known", () => {
expect(runErrorPresentation("checks_not_configured", null)).toMatchObject({ href: null });
});

it("leaves unknown errors to the raw renderer", () => {
expect(runErrorPresentation("error: something broke", "project_1")).toBeNull();
expect(runErrorPresentation(null, "project_1")).toBeNull();
});
});
20 changes: 20 additions & 0 deletions services/api/src/github/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
} from "@facility/db";
import { and, desc, eq, gt, sql } from "drizzle-orm";
import { ApiError } from "../errors.js";
import { checksConfiguredForRepo } from "../sandbox/acceptance-gate.js";
import { resolveDispatchCheckCmds } from "../sandbox/orchestrator.js";
import { type FacilityGithubClient, GithubIssueContextTooLargeError } from "./client.js";
import { syncRepoFacilityConfig } from "./kickstart.js";
import { renderGithubRunProgress } from "./run-progress.js";
Expand Down Expand Up @@ -150,6 +152,24 @@ export async function routeTrigger(
if (accepted?.blockedRunId) {
return { routed: false, reason: "plan_already_accepted", runId: accepted.blockedRunId };
}
// Refuse before inserting: a delivery-mode agent without acceptance checks
// can never deliver (its run would burn the sandbox and then fail at the
// runner's delivery gate). GitHub-backed repos are exempt — their CI owns
// acceptance of the draft pull request.
if (
!checksConfiguredForRepo({
mode: command,
checkCmds: await resolveDispatchCheckCmds(db, {
orgId: repo.orgId,
projectId: repo.projectId,
agent,
repo,
}),
repo,
})
) {
return { routed: false, reason: "checks_not_configured" };
}
const githubTrigger = {
type: "github_comment",
githubLogin: sender,
Expand Down
24 changes: 24 additions & 0 deletions services/api/src/routes/v1/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ import {
type PipelineAssembly,
type PipelinePullRequest,
} from "../../pipeline.js";
import {
CHECKS_NOT_CONFIGURED,
CHECKS_NOT_CONFIGURED_MESSAGE,
checksConfiguredForRepo,
} from "../../sandbox/acceptance-gate.js";
import { resolveDispatchCheckCmds } from "../../sandbox/orchestrator.js";
import {
assertProjectScope,
DateValue,
Expand Down Expand Up @@ -977,6 +983,24 @@ export async function registerGithubV1Routes(app: FastifyInstance, context: V1Ro
// 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.
// Refuse before queueing: a delivery-mode agent without acceptance checks
// can never deliver (its run would burn the sandbox and then fail at the
// runner's delivery gate). GitHub-backed repos are exempt — their CI owns
// acceptance of the draft pull request.
if (
!checksConfiguredForRepo({
mode: body.agent,
checkCmds: await resolveDispatchCheckCmds(db, {
orgId: p.orgId,
projectId,
agent,
repo,
}),
repo,
})
) {
throw new ApiError(409, CHECKS_NOT_CONFIGURED, CHECKS_NOT_CONFIGURED_MESSAGE);
}
const run = (
await db
.insert(runs)
Expand Down
60 changes: 60 additions & 0 deletions services/api/src/sandbox/acceptance-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { RunBundle } from "./state.js";

type AcceptanceRepo = Pick<RunBundle["repo"], "cloneUrl">;
type AcceptanceBundle = { mode: string; repo: AcceptanceRepo } & Pick<RunBundle, "checkCmds">;

export const CHECKS_NOT_CONFIGURED = "checks_not_configured";

export const CHECKS_NOT_CONFIGURED_MESSAGE =
"This project has no acceptance checks configured — a builder run cannot deliver. " +
"Configure them in Settings.";

// Delivery-mode agents ship repository changes, so the platform requires
// acceptance evidence. Mirrors the runner's requiresDelivery so the
// dispatch-time refusal and the runner's delivery gate agree.
export function requiresDelivery(mode: string) {
return mode === "builder" || mode.endsWith("-builder");
}

function normalizedMode(mode: string) {
return mode.replace(/^codex-/, "").replace(/-/g, "_");
}

function repairRepositoryMode(mode: string) {
return normalizedMode(mode) === "address_review" || normalizedMode(mode) === "ci_doctor";
}

// GitHub-backed repositories let their own CI accept the signed draft pull
// request, so acceptance is owned there even when no sandbox checks are
// configured. Mirrors the runner's githubCiOwnsAcceptance: refusing these
// would block deliveries that currently succeed.
export function githubCiOwnsAcceptance(bundle: AcceptanceBundle): boolean {
if (!bundle.repo.cloneUrl?.startsWith("https://github.com/")) return false;
return requiresDelivery(bundle.mode) || repairRepositoryMode(bundle.mode);
}

// The runner's acceptance gate, evaluated at dispatch time: a delivery-mode
// agent may only run when acceptance is configured or the repository's own CI
// owns acceptance.
export function checksConfiguredForDispatch(bundle: AcceptanceBundle): boolean {
return (
githubCiOwnsAcceptance(bundle) || !requiresDelivery(bundle.mode) || bundle.checkCmds.length > 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn’t seem to catch the case from #28 anymore. checksConfiguredForRepo() gives every connected repo a GitHub URL, so this always passes for a builder even when checkCmds is empty. The only builder it blocks has no repo; asking that user to configure checks is misleading, because the run will then start and fail with delivery_repo_not_configured. Could we either preflight the missing repo too, or re-scope this now that GitHub CI owns acceptance?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm, yeah, you're right. Looking at the current flow, I think the right thing to do is:

  • replace the current API-side checks preflight with an early delivery_repo_not_configured guard for builder runs without a repository, before creating keys or provisioning a sandbox;
  • remove the checksConfiguredForRepo() preflight from connected GitHub repository triggers;
  • add human-readable UI copy and regression tests for that failure mode;
  • update the PR description to make it clear that GitHub CI owns acceptance for connected repositories.

That way, connected repositories follow the current GitHub CI acceptance model, while builder runs without a repository fail early instead of spending resources on a run that cannot deliver.

@elirethDev elirethDev Aug 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 19d5d88.

Connected GitHub repositories no longer run the check-command preflight. Builder runs without a repository now fail before key creation or sandbox provisioning with delivery_repo_not_configured, with matching UI copy and regression coverage. I also updated the PR title and description to reflect the current GitHub CI acceptance model.

Focused checks pass: 32 API tests, 8 web tests, both typechecks, Biome, and diff whitespace checks.

@elirethDev elirethDev Aug 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reran the database-backed suites with Docker and Postgres healthy. The result was 90 passed, 1 timing-based timeout in coalesces a CI notification burst, and no skipped tests.

I also confirmed that pnpm verify still stops at its first Lint step on Windows with scripts/verify.mjs calling spawnSync("pnpm") and receiving ENOENT.

);
}

// Issue-trigger variant: buildRunBundle synthesizes a repo row's clone URL the
// same way, so a trigger-time refusal uses exactly the bundle the runner would
// see — including the GitHub-backed exemption.
export function checksConfiguredForRepo(input: {
mode: string;
checkCmds: string[];
repo: { owner: string; name: string } | null;
}): boolean {
return checksConfiguredForDispatch({
mode: input.mode,
checkCmds: input.checkCmds,
repo: input.repo
? { cloneUrl: `https://github.com/${input.repo.owner}/${input.repo.name}.git` }
: { cloneUrl: null },
});
}
Loading