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
2 changes: 1 addition & 1 deletion apps/docs/docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Scoping chain: **org → project → resource**. Every table carries `org_id` (a
- **Harness** (stored in the `registry_*` tables for API compatibility): `registry_items` (kind: skill|rule|agent_contract|harness|guard|module|template_set; scope org or project or bundled), `registry_versions` (content, content_hash, status draft|active|deprecated, created_by), `bundles` (recommended sets). Bundled v1 = v0.2 crew + modules + prompts.
- **Agents & sessions**: `agent_defs` (name, engine claude-code|codex|byo, model tier, contract → Harness, harness → Harness, triggers jsonb, sandbox_profile_id, permission set), `sandbox_profiles` (image, deps, provision_cmd, env refs, resources, driver), `runs` (the compatibility storage name for session execution records: agent_def, trigger, status queued→provisioning→running→awaiting_human→succeeded|failed|canceled, receipt, GitHub issue/PR refs), `run_deliveries` (one exact-SHA-bound pull-request intent per successful builder run, retried by the existing worker), `run_events` (structured session stream: tool calls, checkpoints, logs → object storage for bulk), `steer_messages` (session steering log).
- **Money**: `provider_credentials` (org, provider, sealed secret), `virtual_keys` (project/run scope, hash, budget refs, allowed models), `llm_requests` (key, run, provider, model, tokens, cost_cents, latency, status, envelope URIs; a single table with time/attribution indexes — partitioning is a future scale step, not yet implemented), `budgets` (scope org|project|agent_def, period, limit_cents, mode soft|hard), `spend_counters` (fast-path cache for gateway enforcement).
- **HITL**: `action_types` (name, payload JSON schema, resolver_type emails|permission|team|dynamic, executor internal|webhook|none), `proposals` (action_type, payload, context_md, project, run, current_state), `proposal_events` (append-only: draft→open→approved|rejected|cancelled|expired, executed|execution_failed). The `plan_acceptance` internal executor links an approved architect run to a newly queued builder run; Gate 2 remains GitHub review and merge.
- **HITL**: `action_types` (name, payload JSON schema, resolver_type emails|permission|team|dynamic, executor internal|webhook|none), `proposals` (action_type, payload, context_md, project, run, current_state), `proposal_events` (append-only: draft→open→review_presented→approved|rejected|cancelled|expired, executed|execution_failed). A `plan_acceptance` review context distinguishes the commit used to produce the plan (`planBaseSha`), the commit shown beside the decision (`presentedBaseSha`), and the commit admitted by the Builder freshness check. Web decisions reference a short-lived, server-created `review_presented` event; GitHub `/builder` decisions reference the immutable context stored with the published plan comment. The internal executor links an approved architect run to a newly queued builder run; Gate 2 remains GitHub review and merge.
- **Knowledge**: `kb_spaces` (project), `kb_entries` (type H|E|F|L|CR|SR|custom, number, slug, frontmatter jsonb, body md, status), `kb_links` (typed edges, bidirectionality enforced), `po_tasks` (kb trace, gh issue ref, wsjf value/time/risk/effort, board status mirror).
- **Observation**: `audit_events` (append-only, hash-chained, actor human|agent|system, action, target, payload), `platform_issues` (kind drift|budget_breach|run_failure|stuck_session|guard_failure|canary_failure|integration_error, fingerprint dedupe, state open|acked|resolved), `outcomes` (raw PR fate plus nullable evidence-backed acceptance, linked issue, human merger, verified merge method, review/fixup counts, issue-to-merge lead time), `analytics_daily` (per project × agent × model rollups with assessed and accepted counts).
- **Integrations**: `integrations` (kind github|webhook|slack|…, config, sealed secret), `inbound_events` (raw, verified, routed).
Expand Down
162 changes: 131 additions & 31 deletions apps/web/components/inbox/proposal-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ import { Button, cx, Eyebrow } from "@facility/ui";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Markdown } from "@/components/markdown";
import type { Proposal } from "@/lib/api";
import type { Proposal, ProposalReviewContext } from "@/lib/api";

type ReviewContext = ProposalReviewContext["reviewContext"];

export function requestProposalReviewContext(proposalId: string, fetchImpl: typeof fetch = fetch) {
return fetchImpl(`/api/v1/proposals/${proposalId}/review-context`, {
method: "POST",
});
}

/**
* A gate in card form: the evidence inline, the decision one tap.
Expand All @@ -16,9 +24,40 @@ export function ProposalCard({ proposal, focused }: { proposal: Proposal; focuse
// A human gate must be deliberate: the first click arms a decision, a second
// confirms it, so one stray tap cannot dispatch or deny.
const [pending, setPending] = useState<"approve" | "reject" | null>(null);
const [reviewContext, setReviewContext] = useState<ReviewContext | null>(null);
const [reviewContextSeq, setReviewContextSeq] = useState<number | null>(null);
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);

async function arm(decision: "approve" | "reject") {
setError(null);
if (proposal.actionType !== "plan_acceptance") {
setPending(decision);
return;
}
setBusy(decision);
try {
const res = await requestProposalReviewContext(proposal.id);
if (!res.ok) {
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
throw new Error(body?.error?.message ?? `review context failed (${res.status})`);
}
const body = (await res.json()) as {
reviewContextSeq: number;
reviewContext: ReviewContext;
};
setReviewContextSeq(body.reviewContextSeq);
setReviewContext(body.reviewContext);
setPending(decision);
} catch (err) {
setError(err instanceof Error ? err.message : "review context failed");
} finally {
setBusy(null);
}
}

async function decide(decision: "approve" | "reject") {
setBusy(decision);
setPending(null);
Expand All @@ -27,7 +66,11 @@ export function ProposalCard({ proposal, focused }: { proposal: Proposal; focuse
const res = await fetch(`/api/v1/proposals/${proposal.id}/decide`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ decision, note: note || undefined }),
body: JSON.stringify({
decision,
note: note || undefined,
...(reviewContextSeq ? { reviewContextSeq } : {}),
}),
});
if (!res.ok) {
const body = (await res.json().catch(() => null)) as {
Expand All @@ -38,6 +81,10 @@ export function ProposalCard({ proposal, focused }: { proposal: Proposal; focuse
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "decision failed");
if (proposal.actionType === "plan_acceptance") {
setReviewContext(null);
setReviewContextSeq(null);
}
} finally {
setBusy(null);
}
Expand Down Expand Up @@ -84,44 +131,48 @@ export function ProposalCard({ proposal, focused }: { proposal: Proposal; focuse
</details>

{pending ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-[13px] text-(--ink)">
Confirm <span className="font-mono">{pending}</span>? Recorded in the HITL ledger.
</span>
<Button
size="sm"
variant={pending === "approve" ? "primary" : "danger"}
disabled={busy !== null}
onClick={() => decide(pending)}
>
{busy ? `${pending}…` : `confirm ${pending}`}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy !== null}
onClick={() => setPending(null)}
>
cancel
</Button>
<div className="flex flex-col gap-3">
{reviewContext ? <ReviewContextPanel context={reviewContext} /> : null}
<div className="flex flex-wrap items-center gap-3">
<span className="text-[13px] text-(--ink)">
Confirm <span className="font-mono">{pending}</span>? Recorded in the HITL ledger.
</span>
<Button
size="sm"
variant={pending === "approve" ? "primary" : "danger"}
disabled={
busy !== null || (pending === "approve" && reviewContext?.status === "unavailable")
}
onClick={() => decide(pending)}
>
{busy ? `${pending}…` : `confirm ${pending}`}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy !== null}
onClick={() => {
setPending(null);
setReviewContext(null);
setReviewContextSeq(null);
}}
>
cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<Button
size="sm"
variant="primary"
disabled={busy !== null}
onClick={() => setPending("approve")}
onClick={() => arm("approve")}
>
approve
{busy === "approve" ? "loading context…" : "approve"}
</Button>
<Button
size="sm"
variant="danger"
disabled={busy !== null}
onClick={() => setPending("reject")}
>
reject
<Button size="sm" variant="danger" disabled={busy !== null} onClick={() => arm("reject")}>
{busy === "reject" ? "loading context…" : "reject"}
</Button>
<input
name="note"
Expand All @@ -137,3 +188,52 @@ export function ProposalCard({ proposal, focused }: { proposal: Proposal; focuse
</article>
);
}

export function ReviewContextPanel({ context }: { context: ReviewContext }) {
const repository = context.repository
? `${context.repository.owner}/${context.repository.name}`
: "unavailable";
const comparison = context.status === "available" ? context.comparison : null;
const unavailableReason = context.status === "unavailable" ? context.reason : null;
const changed = comparison?.changedPaths.length ?? 0;
return (
<div
className={cx(
"border p-4 font-mono text-[11px] leading-relaxed",
context.status === "available"
? "border-(--line) bg-(--bg-subtle) text-(--code)"
: "border-(--bad) bg-(--bad-subtle) text-(--bad)",
)}
>
<p>Repository: {repository}</p>
<p>Branch: {context.branch ?? "unavailable"}</p>
<p>Plan based on: {shortSha(context.planBaseSha)}</p>
<p>
Currently presented:{" "}
{shortSha(context.status === "available" ? context.presentedBaseSha : null)}
</p>
{comparison ? (
<p>
Drift: {comparison.aheadBy} commits ahead, {comparison.behindBy} behind, {changed}
{comparison.changedPathsTruncated ? "+" : ""} changed paths
</p>
) : (
<p>Evidence unavailable: {unavailableReason ?? "unknown"}</p>
)}
{comparison && changed > 0 ? (
<details className="mt-2">
<summary className="cursor-pointer">changed paths</summary>
<ul className="mt-1 list-inside list-disc">
{comparison.changedPaths.map((path) => (
<li key={path}>{path}</li>
))}
</ul>
</details>
) : null}
</div>
);
}

function shortSha(value: string | null) {
return value ? value.slice(0, 12) : "unavailable";
}
1 change: 1 addition & 0 deletions apps/web/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type {
Project,
ProjectRepo,
Proposal,
ProposalReviewContext,
Provider,
RegistryItem,
RegistryItemWithVersions,
Expand Down
70 changes: 70 additions & 0 deletions apps/web/test/proposal-review-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { ReviewContextPanel, requestProposalReviewContext } from "@/components/inbox/proposal-card";
import type { ProposalReviewContext } from "@/lib/api";

type ReviewContext = ProposalReviewContext["reviewContext"];

const common = {
version: 1 as const,
source: "facility_web" as const,
repository: { id: "repo_1", owner: "theam", name: "facility" },
branch: "main",
planBaseSha: "a".repeat(40),
planSha256: "b".repeat(64),
presentedAt: "2026-08-31T08:00:00.000Z",
};

function render(context: ReviewContext) {
return renderToStaticMarkup(createElement(ReviewContextPanel, { context }));
}

describe("proposal review context", () => {
it("does not advertise an empty JSON body when requesting review evidence", async () => {
const fetchImpl = vi.fn(async () =>
Response.json({ reviewContextSeq: 2, reviewContext: {} }),
) as typeof fetch;

await requestProposalReviewContext("prop_1", fetchImpl);

expect(fetchImpl).toHaveBeenCalledWith("/api/v1/proposals/prop_1/review-context", {
method: "POST",
});
});

it("renders the exact repository state shown for an available review", () => {
const html = render({
...common,
status: "available",
presentedBaseSha: "c".repeat(40),
issueRevisionSha256: "d".repeat(64),
comparison: {
status: "ahead",
aheadBy: 14,
behindBy: 0,
changedPaths: ["services/api/src/app.ts", "apps/web/app/page.tsx"],
changedPathsTruncated: false,
},
});

expect(html).toContain("Repository: theam/facility");
expect(html).toContain("Branch: main");
expect(html).toContain(`Plan based on: ${"a".repeat(12)}`);
expect(html).toContain(`Currently presented: ${"c".repeat(12)}`);
expect(html).toContain("Drift: 14 commits ahead, 0 behind, 2 changed paths");
expect(html).toContain("services/api/src/app.ts");
});

it("renders a stable reason and no invented SHA when evidence is unavailable", () => {
const html = render({
...common,
status: "unavailable",
reason: "github_evidence_unavailable",
});

expect(html).toContain("Currently presented: unavailable");
expect(html).toContain("Evidence unavailable: github_evidence_unavailable");
expect(html).not.toContain(`Currently presented: ${"a".repeat(12)}`);
});
});
Loading