Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
51 changes: 51 additions & 0 deletions apps/web/components/run/run-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export const CHECKS_NOT_CONFIGURED = "checks_not_configured";
export const DELIVERY_REPO_NOT_CONFIGURED = "delivery_repo_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 === DELIVERY_REPO_NOT_CONFIGURED) {
return {
code,
message:
"This builder run has no repository configured, so it cannot create a branch or pull request. Connect a repository in Settings and retry.",
href: projectId ? `/projects/${projectId}/settings` : null,
};
}
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,
};
}
56 changes: 56 additions & 0 deletions apps/web/test/run-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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 delivery_repo_not_configured to a human explanation with a settings link", () => {
expect(runErrorPresentation('{"code":"delivery_repo_not_configured"}', "project_1")).toEqual({
code: "delivery_repo_not_configured",
message:
"This builder run has no repository configured, so it cannot create a branch or pull request. Connect a repository in Settings and retry.",
href: "/projects/project_1/settings",
});
});

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();
});
});
3 changes: 3 additions & 0 deletions services/api/src/github/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ export async function routeTrigger(
if (accepted?.blockedRunId) {
return { routed: false, reason: "plan_already_accepted", runId: accepted.blockedRunId };
}
// The connected repository is already resolved here. GitHub CI owns
// acceptance for this lane; dispatchRun remains the universal preflight for
// repo-less delivery runs from other entry points.
const githubTrigger = {
type: "github_comment",
githubLogin: sender,
Expand Down
17 changes: 17 additions & 0 deletions services/api/src/sandbox/delivery-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { RunBundle } from "./state.js";

type DeliveryBundle = { mode: string; repo: Pick<RunBundle["repo"], "cloneUrl"> };

export const DELIVERY_REPO_NOT_CONFIGURED = "delivery_repo_not_configured";

// Keep delivery-mode detection aligned with the runner so only runs that need a
// branch and pull request are subject to the repository preflight.
export function requiresDelivery(mode: string) {
return mode === "builder" || mode.endsWith("-builder");
}

// A delivery-mode run without a repository cannot create its branch or pull
// request, so fail it before provisioning any spend-capable resources.
export function deliveryRepoConfigured(bundle: DeliveryBundle) {
return !requiresDelivery(bundle.mode) || Boolean(bundle.repo.cloneUrl);
}
71 changes: 47 additions & 24 deletions services/api/src/sandbox/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import type { AppConfig } from "../types.js";
import { raisePlatformIssue, resolvePlatformIssue } from "../watchtower/issues.js";
import { sandboxCachePartition, sandboxNamespace } from "./cache.js";
import { nestedDockerEnabled, provisioningDepth } from "./capabilities.js";
import { DELIVERY_REPO_NOT_CONFIGURED, deliveryRepoConfigured } from "./delivery-gate.js";
import { DockerSandboxDriver } from "./docker.js";
import type { LaunchSpec, SandboxDriver, SandboxDriverName } from "./driver.js";
import { sandboxDriver } from "./driver.js";
Expand Down Expand Up @@ -102,6 +103,21 @@ export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: Dis
try {
const run = await loadRun(db, job.orgId, job.runId);
if (run?.status !== "queued") return;
const { bundle, profile, agentPermissions } = await buildRunBundle(db, run, config);
// Refuse before any sandbox or spend-capable key exists: a delivery-mode
// agent without a repository cannot create a branch or pull request. The
// atomic failRun transition also closes the race where a concurrent worker
// already claimed and launched.
if (!deliveryRepoConfigured(bundle)) {
await failRun(
db,
job.orgId,
job.runId,
`{"code":"${DELIVERY_REPO_NOT_CONFIGURED}"}`,
DELIVERY_REPO_NOT_CONFIGURED,
);
return;
}
// 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.
Expand All @@ -113,7 +129,6 @@ export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: Dis
if (claimed.length === 0) return;
await appendRunEvents(db, run.orgId, run.id, [{ type: "provisioning", data: {} }]);

const { bundle, profile, agentPermissions } = await buildRunBundle(db, run, config);
const virtualKey = await generateApiKey("fvk");
await db.insert(virtualKeys).values({
id: virtualKey.id,
Expand Down Expand Up @@ -1659,27 +1674,7 @@ async function buildRunBundle(
if (agent.orgId !== run.orgId || agent.projectId !== run.projectId) {
throw new Error("agent_not_in_project");
}
const profile = (
await db
.select()
.from(sandboxProfiles)
.where(
and(
eq(sandboxProfiles.orgId, run.orgId),
agent.sandboxProfileId
? eq(sandboxProfiles.id, agent.sandboxProfileId)
: or(eq(sandboxProfiles.id, "sbx_dev_default"), isNull(sandboxProfiles.projectId)),
),
)
// Deterministic fallback when an org has several global profiles: the
// canonical seeded default wins, otherwise the oldest global profile — never
// an arbitrary row (limit(1) with no order returned a nondeterministic pick).
.orderBy(
sql`case when ${sandboxProfiles.id} = 'sbx_dev_default' then 0 else 1 end`,
sandboxProfiles.createdAt,
)
.limit(1)
)[0];
const profile = await agentSandboxProfile(db, run.orgId, agent);
if (!profile) throw new Error("run_missing_sandbox_profile");
// Clone the repo the run is ACTUALLY about: an issue/resume trigger records it
// in run.gh (owner/repo). Only fall back to the project's oldest repo when the
Expand Down Expand Up @@ -2455,11 +2450,11 @@ function arrayField(value: unknown, key: string) {
: [];
}

// Resolve the run's acceptance-gate commands: a sandbox profile's explicit
// Resolve the run's acceptance commands: a sandbox profile's explicit
// setup.check_cmds override wins; otherwise the project's own configured checks
// (settings.check_cmds). Empty when neither is set.
export function resolveCheckCmds(
profile: { setup: unknown },
profile: { setup?: unknown },
renderAnswers: unknown,
projectSettings: unknown,
): string[] {
Expand All @@ -2471,6 +2466,34 @@ export function resolveCheckCmds(
return arrayField(projectSettings, "check_cmds");
}

// The sandbox profile a run would use: the agent's explicit pick, else a
// deterministic global fallback (the canonical seeded default, then the oldest
// global profile — never an arbitrary row).
async function agentSandboxProfile(
db: ReturnType<typeof createDb>["db"],
orgId: string,
agent: { sandboxProfileId: string | null },
) {
return (
await db
.select()
.from(sandboxProfiles)
.where(
and(
eq(sandboxProfiles.orgId, orgId),
agent.sandboxProfileId
? eq(sandboxProfiles.id, agent.sandboxProfileId)
: or(eq(sandboxProfiles.id, "sbx_dev_default"), isNull(sandboxProfiles.projectId)),
),
)
.orderBy(
sql`case when ${sandboxProfiles.id} = 'sbx_dev_default' then 0 else 1 end`,
sandboxProfiles.createdAt,
)
.limit(1)
)[0];
}

export function resolveRepoEngineConfig(
agentName: string,
base: unknown,
Expand Down
51 changes: 51 additions & 0 deletions services/api/test/github-platform-lane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3404,6 +3404,57 @@ describe("github platform lane", async () => {
app.githubClientFactory = undefined;
});

it("accepts and enqueues a no-checks GitHub builder because GitHub CI owns acceptance", async () => {
const enqueued: { queue: string; data: Record<string, unknown> }[] = [];
app.enqueue = async (queue, data) => {
enqueued.push({ queue, data });
return null;
};
// No `.facility.json` checks and no project-level check commands. A
// connected GitHub repo is still accepted and enqueued because GitHub CI
// owns acceptance; this is not the repo-less dispatch safety net.
app.githubClientFactory = async () =>
({
rest: {
issues: {
get: async (input: { issue_number: number }) => ({
data: {
number: input.issue_number,
title: `Issue ${input.issue_number}`,
body: "Body",
user: { login: "author" },
labels: [],
html_url: `https://github.test/issues/${input.issue_number}`,
},
}),
listComments: async () => ({ data: [] }),
createComment: async () => ({ data: { id: 1, html_url: "https://example.test/c" } }),
addAssignees: async () => ({ data: { assignees: [] } }),
},
repos: repositoryApiWithoutFacilityManifest(),
git: {},
pulls: {},
},
}) as never;
const repo = await insertRepoWithInstallation(`nochecks-${Date.now()}`);
await insertIssue(repo.id, 51, "open", "2026-03-01T00:00:06Z");
await insertAgent("builder");

const response = await app.inject({
method: "POST",
url: `/v1/projects/${projectId}/issues/51/trigger`,
headers: { cookie },
payload: { agent: "builder" },
});
expect(response.statusCode, response.body).toBe(200);
expect(response.json().gh.issueNumber).toBe(51);
expect(enqueued).toContainEqual({
queue: "runs.dispatch",
data: { runId: response.json().id, orgId },
});
app.githubClientFactory = undefined;
});

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.
Expand Down
31 changes: 30 additions & 1 deletion services/api/test/orchestrator-checks.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { harnessFragmentForBundle } from "../src/harness.js";
import { deliveryRepoConfigured, requiresDelivery } from "../src/sandbox/delivery-gate.js";
import {
boundedResumeFallbackScope,
platformDeliveryFailure,
Expand All @@ -22,7 +23,35 @@ describe("platform delivery boundaries", () => {
});
});

describe("resolveCheckCmds — acceptance-gate source of truth", () => {
describe("dispatch delivery repository preflight", () => {
const githubBuilderBundle = {
mode: "builder",
checkCmds: [],
repo: { cloneUrl: "https://github.com/acme/app.git" },
};
const noRepoBuilderBundle = { mode: "builder", checkCmds: [], repo: { cloneUrl: null } };

it("refuses delivery-mode agents without a repository", () => {
expect(requiresDelivery("builder")).toBe(true);
expect(requiresDelivery("codex-builder")).toBe(true);
expect(deliveryRepoConfigured(noRepoBuilderBundle)).toBe(false);
expect(deliveryRepoConfigured({ ...noRepoBuilderBundle, mode: "codex-builder" })).toBe(false);
const noRepoBuilderWithChecks = { ...noRepoBuilderBundle, checkCmds: ["pnpm verify"] };
expect(deliveryRepoConfigured(noRepoBuilderWithChecks)).toBe(false);
});

it("allows a connected GitHub repository when check commands are empty", () => {
expect(deliveryRepoConfigured(githubBuilderBundle)).toBe(true);
});

it("allows non-delivery modes without a repository", () => {
expect(requiresDelivery("architect")).toBe(false);
expect(deliveryRepoConfigured({ mode: "architect", repo: { cloneUrl: null } })).toBe(true);
expect(deliveryRepoConfigured({ mode: "custom", repo: { cloneUrl: null } })).toBe(true);
});
});

describe("resolveCheckCmds — runner acceptance command source of truth", () => {
it("uses the project's configured checks when the sandbox profile has none", () => {
expect(resolveCheckCmds({ setup: {} }, {}, { check_cmds: ["pnpm test", "pnpm lint"] })).toEqual(
["pnpm test", "pnpm lint"],
Expand Down
Loading