Merge remote-tracking branch 'origin/main'

# Conflicts:
#	apps/fabro-web/app/routes/run-files.tsx
#	lib/crates/fabro-spa/assets/assets/app.css
#	lib/crates/fabro-spa/assets/index.html
This commit is contained in:
Bryan Helmkamp 2026-04-20 09:28:27 -04:00
commit c769b2a30c
No known key found for this signature in database
79 changed files with 7492 additions and 3627 deletions

View file

@ -49,7 +49,7 @@ jobs:
- name: Install bun deps (for SPA verify)
if: steps.skip.outputs.skip != 'true'
run: bun install
run: bun install --frozen-lockfile
- name: Set up Rust
if: steps.skip.outputs.skip != 'true'

View file

@ -12,6 +12,7 @@ on:
- ".gitattributes"
- "scripts/**"
- ".github/workflows/typescript.yml"
- ".github/workflows/nightly.yml"
pull_request:
branches: [main]
paths:
@ -23,6 +24,7 @@ on:
- ".gitattributes"
- "scripts/**"
- ".github/workflows/typescript.yml"
- ".github/workflows/nightly.yml"
workflow_dispatch:
concurrency:
@ -42,7 +44,7 @@ jobs:
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install
- run: bun install --frozen-lockfile
- run: cd apps/fabro-web && bun run typecheck
test:
@ -55,7 +57,7 @@ jobs:
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install
- run: bun install --frozen-lockfile
- run: cd apps/fabro-web && bun test
build:
@ -68,7 +70,7 @@ jobs:
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install
- run: bun install --frozen-lockfile
- run: scripts/refresh-fabro-spa.sh
- run: git diff --exit-code -- lib/crates/fabro-spa/assets
- run: scripts/check-fabro-spa-budgets.sh

2
Cargo.lock generated
View file

@ -1857,6 +1857,7 @@ dependencies = [
"axum",
"base64",
"fabro-http",
"fabro-test",
"hex",
"httpmock",
"open",
@ -1958,6 +1959,7 @@ dependencies = [
"fabro-slack",
"fabro-spa",
"fabro-store",
"fabro-test",
"fabro-types",
"fabro-util",
"fabro-validate",

View file

@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { BlockedRunNotice } from "./blocked-run-notice";
function textFromNode(node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>): string {
if (!node) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(textFromNode).join("");
return (node.children ?? []).map(textFromNode).join("");
}
describe("BlockedRunNotice", () => {
test("renders the question text when provided", () => {
let tree: TestRenderer.ReactTestRenderer | undefined;
act(() => {
tree = TestRenderer.create(
<BlockedRunNotice
questionText="Approve the deployment target?"
onCancel={() => {}}
/>,
);
});
expect(textFromNode(tree!.toJSON())).toContain("Approve the deployment target?");
});
test("renders fallback copy when no question is available", () => {
let tree: TestRenderer.ReactTestRenderer | undefined;
act(() => {
tree = TestRenderer.create(<BlockedRunNotice onCancel={() => {}} />);
});
expect(textFromNode(tree!.toJSON())).toContain("Fabro is blocked on a human-in-the-loop question.");
});
test("fires the secondary cancel action", () => {
let cancelled = 0;
let tree: TestRenderer.ReactTestRenderer | undefined;
act(() => {
tree = TestRenderer.create(
<BlockedRunNotice onCancel={() => {
cancelled += 1;
}}
/>,
);
});
const button = tree!.root.findByType("button");
act(() => {
button.props.onClick();
});
expect(cancelled).toBe(1);
});
});

View file

@ -0,0 +1,34 @@
export function BlockedRunNotice({
questionText,
cancelling = false,
onCancel,
}: {
questionText?: string | null;
cancelling?: boolean;
onCancel: () => void;
}) {
return (
<div
role="status"
className="mb-6 rounded-lg border border-amber/30 bg-amber/10 px-4 py-4 text-sm text-fg-2"
>
<p className="font-medium text-fg">This run is waiting for input.</p>
<p className="mt-2 leading-6">
{questionText?.trim()
? questionText
: "Fabro is blocked on a human-in-the-loop question. Answer it from the CLI to continue the run."}
</p>
<p className="mt-2 text-fg-muted">
If you don't want to continue in the CLI, you can cancel the run here instead.
</p>
<button
type="button"
onClick={onCancel}
disabled={cancelling}
className="mt-3 inline-flex min-h-12 items-center rounded-md px-3 text-sm font-medium text-fg-muted transition-colors hover:bg-amber/10 hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelling ? "Cancelling…" : "Cancel run anyway."}
</button>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { Link, useRevalidator } from "react-router";
import { Link } from "react-router";
import {
ArrowPathIcon,
CheckCircleIcon,
@ -9,6 +9,7 @@ import {
} from "@heroicons/react/24/solid";
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { formatDurationSecs } from "../lib/format";
import { useRunEventSource } from "../lib/sse";
export type StageStatus = "completed" | "running" | "pending" | "failed" | "cancelled";
@ -42,34 +43,15 @@ const STAGE_EVENTS = new Set([
]);
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
const revalidator = useRevalidator();
// Track when we first observed each running stage (for ticking timer)
const runningStartRef = useRef<Map<string, number>>(new Map());
const [, setTick] = useState(0);
// Subscribe to run-specific SSE for live stage updates
useEffect(() => {
const source = new EventSource(`/api/v1/runs/${runId}/attach?since_seq=1`);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
source.onmessage = (msg) => {
try {
const payload = JSON.parse(msg.data);
if (STAGE_EVENTS.has(payload.event)) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => revalidator.revalidate(), 300);
}
} catch {
// ignore malformed events
}
};
return () => {
clearTimeout(debounceTimer);
source.close();
};
}, [runId]);
useRunEventSource(runId, {
allowlist: STAGE_EVENTS,
debounceMs: 300,
});
// Track start times for running stages
useEffect(() => {

View file

@ -0,0 +1,196 @@
import { afterEach, describe, expect, test } from "bun:test";
import { useEffect } from "react";
import TestRenderer, { act } from "react-test-renderer";
import { ToastProvider, useToast } from "./toast";
function textFromNode(node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>): string {
if (!node) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(textFromNode).join("");
return (node.children ?? []).map(textFromNode).join("");
}
function textFromInstance(node: TestRenderer.ReactTestInstance): string {
return node.children
.map((child) => (typeof child === "string" ? child : textFromInstance(child)))
.join("");
}
function PushOnMount({
toast,
onReady,
}: {
toast: Parameters<ReturnType<typeof useToast>["push"]>[0];
onReady?: (api: ReturnType<typeof useToast>) => void;
}) {
const api = useToast();
useEffect(() => {
onReady?.(api);
api.push(toast);
}, [api, onReady, toast]);
return null;
}
describe("ToastProvider", () => {
afterEach(() => {
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
});
test("push renders a toast with the message", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<ToastProvider autoDismissMs={1000}>
<PushOnMount toast={{ message: "Run archived." }} />
</ToastProvider>,
);
});
expect(textFromNode(renderer!.toJSON())).toContain("Run archived.");
const liveRegions = renderer!.root.findAll(
(node) => node.props?.role === "status" && node.props?.["aria-live"] === "polite",
);
expect(liveRegions.length).toBeGreaterThan(0);
await act(async () => {
renderer?.unmount();
});
});
test("action toasts render a button and fire onClick", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let clicked = 0;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<ToastProvider autoDismissMs={1000}>
<PushOnMount
toast={{
message: "Run archived.",
action: {
label: "Unarchive",
onClick: () => {
clicked += 1;
},
},
}}
/>
</ToastProvider>,
);
});
const button = renderer!.root.findByType("button");
expect(textFromNode(renderer!.toJSON())).toContain("Unarchive");
await act(async () => {
button.props.onClick();
});
expect(clicked).toBe(1);
await act(async () => {
renderer?.unmount();
});
});
test("error toasts do not auto-dismiss", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<ToastProvider autoDismissMs={5}>
<PushOnMount toast={{ message: "Conflict", tone: "error" }} />
</ToastProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
expect(textFromNode(renderer!.toJSON())).toContain("Conflict");
await act(async () => {
renderer?.unmount();
});
});
test("multiple toasts stack in insertion order", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let api: ReturnType<typeof useToast> | null = null;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<ToastProvider autoDismissMs={1000}>
<PushOnMount
toast={{ message: "First" }}
onReady={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
});
await act(async () => {
api!.push({ message: "Second" });
});
const toasts = renderer!.root.findAll(
(node) => node.props?.["data-toast-id"] != null,
);
expect(toasts).toHaveLength(2);
expect(textFromInstance(toasts[0]!)).toContain("First");
expect(textFromInstance(toasts[1]!)).toContain("Second");
await act(async () => {
renderer?.unmount();
});
});
test("dismiss removes a toast and leaves the rest reflowed", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let api: ReturnType<typeof useToast> | null = null;
let firstId = "";
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<ToastProvider autoDismissMs={1000}>
<PushOnMount
toast={{ message: "First" }}
onReady={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
});
await act(async () => {
firstId = api!.push({ message: "Second" });
});
await act(async () => {
api!.dismiss(firstId);
});
const text = textFromNode(renderer!.toJSON());
expect(text).toContain("First");
expect(text).not.toContain("Second");
await act(async () => {
renderer?.unmount();
});
});
});

View file

@ -0,0 +1,165 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { XMarkIcon } from "@heroicons/react/20/solid";
export type ToastTone = "info" | "error";
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface ToastInput {
message: string;
tone?: ToastTone;
action?: ToastAction;
autoDismissMs?: number;
}
interface ToastRecord extends ToastInput {
id: string;
tone: ToastTone;
}
interface ToastContextValue {
push: (toast: ToastInput) => string;
dismiss: (id: string) => void;
clear: () => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
function toastClassName(tone: ToastTone): string {
return tone === "error"
? "border-coral/40 bg-rose-950/90 text-rose-50"
: "border-line bg-panel/95 text-fg-2";
}
export function ToastRoot({
toasts,
onDismiss,
}: {
toasts: ToastRecord[];
onDismiss: (id: string) => void;
}) {
if (toasts.length === 0) return null;
return (
<div
role="status"
aria-live="polite"
aria-atomic="false"
className="pointer-events-none fixed right-4 bottom-6 z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2 sm:right-6"
>
{toasts.map((toast) => (
<div
key={toast.id}
data-toast-id={toast.id}
className={`pointer-events-auto rounded-lg border px-4 py-3 shadow-lg ${toastClassName(toast.tone)}`}
>
<div className="flex items-start gap-3">
<p className="min-w-0 flex-1 text-sm leading-5">{toast.message}</p>
{toast.tone === "error" && (
<button
type="button"
onClick={() => onDismiss(toast.id)}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-current/70 transition-colors hover:bg-white/10 hover:text-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
aria-label="Dismiss notification"
>
<XMarkIcon className="size-4" aria-hidden="true" />
</button>
)}
</div>
{toast.action && (
<div className="mt-3">
<button
type="button"
onClick={() => {
toast.action?.onClick();
onDismiss(toast.id);
}}
className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-md bg-white/10 px-3 text-sm font-medium text-current transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
{toast.action.label}
</button>
</div>
)}
</div>
))}
</div>
);
}
export function ToastProvider({
children,
autoDismissMs = 3500,
}: {
children: ReactNode;
autoDismissMs?: number;
}) {
const [toasts, setToasts] = useState<ToastRecord[]>([]);
const nextIdRef = useRef(0);
const timeoutIdsRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const dismiss = useCallback((id: string) => {
const timeoutId = timeoutIdsRef.current.get(id);
if (timeoutId) {
clearTimeout(timeoutId);
timeoutIdsRef.current.delete(id);
}
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const clear = useCallback(() => {
for (const timeoutId of timeoutIdsRef.current.values()) {
clearTimeout(timeoutId);
}
timeoutIdsRef.current.clear();
setToasts([]);
}, []);
const push = useCallback((toast: ToastInput) => {
const id = `toast-${nextIdRef.current++}`;
const record: ToastRecord = {
...toast,
id,
tone: toast.tone ?? "info",
};
setToasts((current) => [...current, record]);
if (record.tone !== "error") {
const timeoutId = setTimeout(() => dismiss(id), toast.autoDismissMs ?? autoDismissMs);
timeoutIdsRef.current.set(id, timeoutId);
}
return id;
}, [autoDismissMs, dismiss]);
useEffect(() => clear, [clear]);
const value = useMemo(() => ({ push, dismiss, clear }), [push, dismiss, clear]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastRoot toasts={toasts} onDismiss={dismiss} />
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const value = useContext(ToastContext);
if (!value) {
throw new Error("useToast must be used within a ToastProvider");
}
return value;
}

View file

@ -18,6 +18,7 @@ import {
} from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation, useMatches, useRevalidator } from "react-router";
import { getAuthMe } from "../api";
import { ToastProvider } from "../components/toast";
import { DemoModeProvider } from "../lib/demo-mode";
export async function loader() {
@ -66,6 +67,7 @@ export default function AppShell({ loaderData }: any) {
return (
<DemoModeProvider value={demoMode}>
<ToastProvider>
<div className="isolate min-h-full">
<Disclosure as="nav" className="bg-panel">
<div className="px-4 sm:px-6 lg:px-8">
@ -250,6 +252,7 @@ export default function AppShell({ loaderData }: any) {
</div>
</main>
</div>
</ToastProvider>
</DemoModeProvider>
);
}

View file

@ -0,0 +1,179 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
archiveRun,
canArchive,
canCancel,
canUnarchive,
cancelRun,
isTerminalCancelledRun,
mapError,
unarchiveRun,
} from "./run-actions";
type StubResponseInit = {
status: number;
body?: string;
statusText?: string;
};
function stubFetchOnce(init: StubResponseInit) {
const originalFetch = globalThis.fetch;
globalThis.fetch = (() =>
Promise.resolve(
new Response(init.body ?? "", {
status: init.status,
statusText: init.statusText ?? "",
headers: { "Content-Type": "application/json" },
}),
)) as typeof fetch;
return () => {
globalThis.fetch = originalFetch;
};
}
async function expectLifecycleError(
input: Promise<unknown>,
): Promise<{ status: number; errors: Array<{ status: string; title: string; detail: string }> }> {
try {
await input;
throw new Error("expected promise to reject");
} catch (error) {
return error as { status: number; errors: Array<{ status: string; title: string; detail: string }> };
}
}
describe("run lifecycle actions", () => {
let restoreFetch: (() => void) | undefined;
afterEach(() => {
restoreFetch?.();
restoreFetch = undefined;
delete (globalThis as { window?: unknown }).window;
});
test("cancelRun parses a 200 response", async () => {
restoreFetch = stubFetchOnce({
status: 200,
body: JSON.stringify({
id: "run-1",
status: "failed",
status_reason: "cancelled",
created_at: "2026-04-20T12:00:00Z",
}),
});
const result = await cancelRun("run-1");
expect(result.status).toBe("failed");
expect(result.status_reason).toBe("cancelled");
});
test("archiveRun parses a 200 response", async () => {
restoreFetch = stubFetchOnce({
status: 200,
body: JSON.stringify({
id: "run-1",
status: "archived",
created_at: "2026-04-20T12:00:00Z",
}),
});
const result = await archiveRun("run-1");
expect(result.status).toBe("archived");
});
test("unarchiveRun parses a 200 response", async () => {
restoreFetch = stubFetchOnce({
status: 200,
body: JSON.stringify({
id: "run-1",
status: "succeeded",
created_at: "2026-04-20T12:00:00Z",
}),
});
const result = await unarchiveRun("run-1");
expect(result.status).toBe("succeeded");
});
test("404 and 409 preserve the parsed error envelope", async () => {
restoreFetch = stubFetchOnce({
status: 404,
body: JSON.stringify({
errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
}),
});
const notFound = await expectLifecycleError(cancelRun("missing-run"));
expect(notFound).toEqual({
status: 404,
errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
});
restoreFetch = stubFetchOnce({
status: 409,
body: JSON.stringify({
errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }],
}),
});
const conflict = await expectLifecycleError(archiveRun("run-1"));
expect(conflict).toEqual({
status: 409,
errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }],
});
});
test("non-JSON error bodies fall back to an empty error list", async () => {
restoreFetch = stubFetchOnce({
status: 409,
body: "<html>conflict</html>",
statusText: "Conflict",
});
const error = await expectLifecycleError(unarchiveRun("run-1"));
expect(error).toEqual({ status: 409, errors: [] });
});
test("mapError returns user-facing copy for lifecycle conflicts", () => {
expect(mapError({ status: 409, errors: [] }, "cancel")).toBe("This run can no longer be cancelled.");
expect(mapError({ status: 409, errors: [] }, "archive")).toBe("Only terminal runs can be archived.");
expect(mapError({ status: 409, errors: [] }, "unarchive")).toBe("Active runs can't be unarchived.");
});
test("status predicates align with the documented run statuses", () => {
expect(canCancel("submitted")).toBe(true);
expect(canCancel("queued")).toBe(true);
expect(canCancel("starting")).toBe(true);
expect(canCancel("running")).toBe(true);
expect(canCancel("paused")).toBe(true);
expect(canCancel("blocked")).toBe(false);
expect(canCancel("archived")).toBe(false);
expect(canArchive("succeeded")).toBe(true);
expect(canArchive("failed")).toBe(true);
expect(canArchive("dead")).toBe(true);
expect(canArchive("archived")).toBe(false);
expect(canUnarchive("archived")).toBe(true);
expect(canUnarchive("failed")).toBe(false);
});
test("isTerminalCancelledRun distinguishes immediate cancel success from in-flight cancellation", () => {
expect(
isTerminalCancelledRun({
id: "run-1",
status: "failed",
status_reason: "cancelled",
created_at: "2026-04-20T12:00:00Z",
}),
).toBe(true);
expect(
isTerminalCancelledRun({
id: "run-1",
status: "running",
pending_control: "cancel",
created_at: "2026-04-20T12:00:00Z",
}),
).toBe(false);
});
});

View file

@ -0,0 +1,145 @@
import type { ErrorResponseEntry, RunStatusResponse } from "@qltysh/fabro-api-client";
import { apiFetch } from "../api";
import type { RunStatus } from "../data/runs";
export type LifecycleAction = "cancel" | "archive" | "unarchive";
export interface LifecycleActionError {
status: number;
errors: ErrorResponseEntry[];
}
const CANCELABLE_STATUSES = new Set<RunStatus>([
"submitted",
"queued",
"starting",
"running",
"paused",
]);
const ARCHIVABLE_STATUSES = new Set<RunStatus>([
"succeeded",
"failed",
"dead",
]);
export async function cancelRun(id: string, request?: Request): Promise<RunStatusResponse> {
return runLifecycleAction(id, "cancel", request);
}
export async function archiveRun(id: string, request?: Request): Promise<RunStatusResponse> {
return runLifecycleAction(id, "archive", request);
}
export async function unarchiveRun(id: string, request?: Request): Promise<RunStatusResponse> {
return runLifecycleAction(id, "unarchive", request);
}
export function canCancel(status: string | null | undefined): boolean {
return !!status && CANCELABLE_STATUSES.has(status as RunStatus);
}
export function canArchive(status: string | null | undefined): boolean {
return !!status && ARCHIVABLE_STATUSES.has(status as RunStatus);
}
export function canUnarchive(status: string | null | undefined): boolean {
return status === "archived";
}
export function isTerminalCancelledRun(run: RunStatusResponse): boolean {
return (run.status === "failed" || run.status === "dead") && run.status_reason === "cancelled";
}
export function mapError(error: unknown, action: LifecycleAction): string {
if (isLifecycleActionError(error)) {
if (error.status === 404) {
return "This run no longer exists.";
}
if (error.status === 409) {
switch (action) {
case "cancel":
return "This run can no longer be cancelled.";
case "archive":
return "Only terminal runs can be archived.";
case "unarchive":
return "Active runs can't be unarchived.";
}
}
const detail = error.errors[0]?.detail?.trim();
if (detail) {
return detail;
}
}
switch (action) {
case "cancel":
return "Couldn't cancel the run right now. Try again.";
case "archive":
return "Couldn't archive the run right now. Try again.";
case "unarchive":
return "Couldn't unarchive the run right now. Try again.";
}
}
async function runLifecycleAction(
id: string,
action: LifecycleAction,
request?: Request,
): Promise<RunStatusResponse> {
const response = await apiFetch(`/runs/${id}/${action}`, {
init: {
method: "POST",
...(request?.signal ? { signal: request.signal } : {}),
},
});
if (!response.ok) {
throw await parseLifecycleActionError(response);
}
return response.json() as Promise<RunStatusResponse>;
}
async function parseLifecycleActionError(response: Response): Promise<LifecycleActionError> {
let bodyText = "";
try {
bodyText = await response.text();
} catch {
// Ignore body read failures and fall back to the status only.
}
if (!bodyText) {
return { status: response.status, errors: [] };
}
try {
const body = JSON.parse(bodyText) as { errors?: unknown };
if (!Array.isArray(body.errors)) {
return { status: response.status, errors: [] };
}
const errors = body.errors.filter(isErrorResponseEntry);
return { status: response.status, errors };
} catch {
return { status: response.status, errors: [] };
}
}
function isLifecycleActionError(value: unknown): value is LifecycleActionError {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return typeof record.status === "number" && Array.isArray(record.errors);
}
function isErrorResponseEntry(value: unknown): value is ErrorResponseEntry {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return (
typeof record.status === "string"
&& typeof record.title === "string"
&& typeof record.detail === "string"
);
}

View file

@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import { subscribeToRunEventSource } from "./sse";
type MessageHandler = ((event: { data: string }) => void) | null;
class FakeEventSource {
onmessage: MessageHandler = null;
closed = false;
emit(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitRaw(data: string) {
this.onmessage?.({ data });
}
close() {
this.closed = true;
}
}
describe("subscribeToRunEventSource", () => {
test("allowlisted events trigger debounced revalidation and onEvent", async () => {
const source = new FakeEventSource();
let revalidations = 0;
const events: Array<{ event?: string }> = [];
const cleanup = subscribeToRunEventSource("run-1", {
allowlist: new Set(["run.completed"]),
debounceMs: 5,
revalidate: () => {
revalidations += 1;
},
onEvent: (payload) => {
events.push(payload);
},
eventSourceFactory: () => source,
});
source.emit({ event: "run.completed", seq: 42 });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(revalidations).toBe(1);
expect(events).toEqual([{ event: "run.completed", seq: 42 }]);
cleanup();
});
test("non-allowlisted and malformed events are ignored", async () => {
const source = new FakeEventSource();
let revalidations = 0;
let calls = 0;
const cleanup = subscribeToRunEventSource("run-1", {
allowlist: new Set(["checkpoint.completed"]),
debounceMs: 5,
revalidate: () => {
revalidations += 1;
},
onEvent: () => {
calls += 1;
},
eventSourceFactory: () => source,
});
source.emit({ event: "run.completed" });
source.emitRaw("{broken");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(revalidations).toBe(0);
expect(calls).toBe(0);
cleanup();
});
test("cleanup closes the source and clears a pending debounce", async () => {
const source = new FakeEventSource();
let revalidations = 0;
const cleanup = subscribeToRunEventSource("run-1", {
allowlist: new Set(["run.completed"]),
debounceMs: 20,
revalidate: () => {
revalidations += 1;
},
eventSourceFactory: () => source,
});
source.emit({ event: "run.completed" });
cleanup();
await new Promise((resolve) => setTimeout(resolve, 40));
expect(source.closed).toBe(true);
expect(revalidations).toBe(0);
});
});

View file

@ -0,0 +1,81 @@
import { useEffect } from "react";
import { useRevalidator } from "react-router";
export interface RunEventPayload {
event?: string;
[key: string]: unknown;
}
export interface RunEventSourceLike {
onmessage: ((event: { data: string }) => void) | null;
close: () => void;
}
interface SubscribeOptions {
allowlist: ReadonlySet<string>;
debounceMs?: number;
onEvent?: (payload: RunEventPayload) => void;
revalidate: () => void;
eventSourceFactory?: (url: string) => RunEventSourceLike;
}
function createBrowserEventSource(url: string): RunEventSourceLike {
return new EventSource(url);
}
export function subscribeToRunEventSource(runId: string, options: SubscribeOptions): () => void {
const {
allowlist,
debounceMs = 300,
onEvent,
revalidate,
eventSourceFactory = createBrowserEventSource,
} = options;
const source = eventSourceFactory(`/api/v1/runs/${runId}/attach?since_seq=1`);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
source.onmessage = (message) => {
try {
const payload = JSON.parse(message.data) as RunEventPayload;
if (!payload.event || !allowlist.has(payload.event)) {
return;
}
onEvent?.(payload);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => revalidate(), debounceMs);
} catch {
// ignore malformed events
}
};
return () => {
clearTimeout(debounceTimer);
source.close();
};
}
export function useRunEventSource(
runId: string | undefined,
{
allowlist,
debounceMs = 300,
onEvent,
}: {
allowlist: ReadonlySet<string>;
debounceMs?: number;
onEvent?: (payload: RunEventPayload) => void;
},
) {
const revalidator = useRevalidator();
useEffect(() => {
if (!runId) return;
return subscribeToRunEventSource(runId, {
allowlist,
debounceMs,
onEvent,
revalidate: () => revalidator.revalidate(),
});
}, [allowlist, debounceMs, onEvent, revalidator, runId]);
}

View file

@ -0,0 +1,198 @@
import { afterEach, describe, expect, test } from "bun:test";
import { action, lifecycleActionVisibility, loader } from "./run-detail";
type StubFetchEntry = {
status: number;
body?: unknown;
};
function stubFetchSequence(entries: StubFetchEntry[]) {
const originalFetch = globalThis.fetch;
let index = 0;
globalThis.fetch = ((input: RequestInfo | URL) => {
const next = entries[index++];
if (!next) {
throw new Error(`unexpected fetch for ${String(input)}`);
}
return Promise.resolve(
new Response(next.body == null ? "" : JSON.stringify(next.body), {
status: next.status,
headers: { "Content-Type": "application/json" },
}),
);
}) as typeof fetch;
return () => {
globalThis.fetch = originalFetch;
};
}
function buildActionRequest(data: Record<string, string>) {
const formData = new FormData();
for (const [key, value] of Object.entries(data)) {
formData.set(key, value);
}
return new Request("http://fabro.test/runs/run-1", {
method: "POST",
body: formData,
});
}
describe("run-detail loader", () => {
let restoreFetch: (() => void) | undefined;
afterEach(() => {
restoreFetch?.();
restoreFetch = undefined;
delete (globalThis as { window?: unknown }).window;
});
test("loads the first blocked question when the run is blocked", async () => {
restoreFetch = stubFetchSequence([
{
status: 200,
body: {
run_id: "run-1",
title: "Blocked run",
repository: { name: "repo" },
status: "blocked",
workflow_name: "review",
},
},
{
status: 200,
body: {
data: [{ id: "q-1", text: "Ship this change?", stage: "review", question_type: "single_select", options: [], allow_freeform: false }],
meta: { has_more: false },
},
},
]);
const result = await loader({
request: new Request("http://fabro.test/runs/run-1"),
params: { id: "run-1" },
});
expect(result.blockedQuestionText).toBe("Ship this change?");
expect(result.run?.lifecycleStatus).toBe("blocked");
});
test("falls back to null blockedQuestionText when no question is available", async () => {
restoreFetch = stubFetchSequence([
{
status: 200,
body: {
run_id: "run-1",
title: "Blocked run",
repository: { name: "repo" },
status: "blocked",
workflow_name: "review",
},
},
{
status: 200,
body: {
data: [],
meta: { has_more: false },
},
},
]);
const result = await loader({
request: new Request("http://fabro.test/runs/run-1"),
params: { id: "run-1" },
});
expect(result.blockedQuestionText).toBeNull();
});
});
describe("run-detail action", () => {
let restoreFetch: (() => void) | undefined;
afterEach(() => {
restoreFetch?.();
restoreFetch = undefined;
delete (globalThis as { window?: unknown }).window;
});
test("preview still dispatches through intent=preview", async () => {
restoreFetch = stubFetchSequence([
{
status: 200,
body: { url: "https://preview.example.com" },
},
]);
const result = await action({
params: { id: "run-1" },
request: buildActionRequest({
intent: "preview",
port: "3000",
expires_in_secs: "3600",
}),
});
expect(result).toEqual({
intent: "preview",
url: "https://preview.example.com",
});
});
test("cancel dispatches through the lifecycle helper path", async () => {
restoreFetch = stubFetchSequence([
{
status: 200,
body: {
id: "run-1",
status: "failed",
status_reason: "cancelled",
created_at: "2026-04-20T12:00:00Z",
},
},
]);
const result = await action({
params: { id: "run-1" },
request: buildActionRequest({ intent: "cancel" }),
});
expect(result).toEqual({
intent: "cancel",
ok: true,
run: {
id: "run-1",
status: "failed",
status_reason: "cancelled",
created_at: "2026-04-20T12:00:00Z",
},
});
});
});
describe("lifecycleActionVisibility", () => {
test("shows cancel for active cancellable states and hides it elsewhere", () => {
expect(lifecycleActionVisibility("submitted").showPrimaryCancel).toBe(true);
expect(lifecycleActionVisibility("queued").showPrimaryCancel).toBe(true);
expect(lifecycleActionVisibility("starting").showPrimaryCancel).toBe(true);
expect(lifecycleActionVisibility("running").showPrimaryCancel).toBe(true);
expect(lifecycleActionVisibility("paused").showPrimaryCancel).toBe(true);
expect(lifecycleActionVisibility("blocked").showPrimaryCancel).toBe(false);
expect(lifecycleActionVisibility("succeeded").showPrimaryCancel).toBe(false);
expect(lifecycleActionVisibility("failed").showPrimaryCancel).toBe(false);
expect(lifecycleActionVisibility("dead").showPrimaryCancel).toBe(false);
expect(lifecycleActionVisibility("archived").showPrimaryCancel).toBe(false);
});
test("shows archive and unarchive in the expected terminal states", () => {
expect(lifecycleActionVisibility("succeeded").showArchive).toBe(true);
expect(lifecycleActionVisibility("failed").showArchive).toBe(true);
expect(lifecycleActionVisibility("dead").showArchive).toBe(true);
expect(lifecycleActionVisibility("archived").showArchive).toBe(false);
expect(lifecycleActionVisibility("archived").showUnarchive).toBe(true);
expect(lifecycleActionVisibility("running").showUnarchive).toBe(false);
expect(lifecycleActionVisibility("blocked").showBlockedNotice).toBe(true);
});
});

View file

@ -1,12 +1,38 @@
import { useEffect } from "react";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { ArrowPathIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useFetcher, useLocation } from "react-router";
import { mapRunSummaryToRunItem, runStatusDisplay, isRunStatus } from "../data/runs";
import type { RunSummaryResponse } from "../data/runs";
import type {
ErrorResponseEntry,
PaginatedApiQuestionList,
PreviewUrlResponse,
RunStatusResponse,
} from "@qltysh/fabro-api-client";
import { apiJson } from "../api";
import { BlockedRunNotice } from "../components/blocked-run-notice";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
import { PRIMARY_BUTTON_CLASS, SECONDARY_BUTTON_CLASS } from "../components/ui";
import {
isRunStatus,
mapRunSummaryToRunItem,
runStatusDisplay,
type RunSummaryResponse,
} from "../data/runs";
import { useDemoMode } from "../lib/demo-mode";
import type { PreviewUrlResponse } from "@qltysh/fabro-api-client";
import { useRunEventSource } from "../lib/sse";
import {
archiveRun,
canArchive,
canCancel,
canUnarchive,
cancelRun,
isTerminalCancelledRun,
mapError,
type LifecycleAction,
type LifecycleActionError,
unarchiveRun,
} from "../lib/run-actions";
const allTabs = [
{ name: "Overview", path: "", count: null, demoOnly: false },
@ -18,17 +44,82 @@ const allTabs = [
export const handle = { hideHeader: true };
export async function loader({ request, params }: any) {
const RUN_DETAIL_EVENTS = new Set([
"run.submitted",
"run.queued",
"run.starting",
"run.running",
"run.paused",
"run.unpaused",
"run.blocked",
"run.unblocked",
"run.completed",
"run.failed",
"run.archived",
"run.unarchived",
]);
const CANCEL_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg border border-coral/30 bg-coral/10 px-4 py-2 text-sm font-medium text-coral transition-colors hover:bg-coral/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-coral/10";
const MUTATION_BUTTON_CLASS =
`${SECONDARY_BUTTON_CLASS} disabled:cursor-not-allowed disabled:opacity-60`;
type RunDetailRun = ReturnType<typeof mapRunSummaryToRunItem> & {
statusLabel: string;
statusDot: string;
statusText: string;
};
export interface RunDetailLoaderData {
run: RunDetailRun | null;
blockedQuestionText: string | null;
}
type PreviewActionResult = {
intent: "preview";
url: string;
};
type LifecycleActionResult =
| {
intent: LifecycleAction;
ok: true;
run: RunStatusResponse;
}
| {
intent: LifecycleAction;
ok: false;
error: LifecycleActionError | null;
};
export type RunDetailActionResult = PreviewActionResult | LifecycleActionResult;
export function lifecycleActionVisibility(status: string | null | undefined) {
return {
showPrimaryCancel: canCancel(status),
showArchive: canArchive(status),
showUnarchive: canUnarchive(status),
showBlockedNotice: status === "blocked",
};
}
export async function loader({ request, params }: any): Promise<RunDetailLoaderData> {
const response = await fetch(`/api/v1/runs/${params.id}`, {
credentials: "include",
...(request?.signal ? { signal: request.signal } : {}),
});
if (!response.ok) return { run: null };
if (!response.ok) {
return { run: null, blockedQuestionText: null };
}
const summary: RunSummaryResponse = await response.json();
const item = mapRunSummaryToRunItem(summary);
const rawStatus = summary.status;
const display = isRunStatus(rawStatus)
? runStatusDisplay[rawStatus]
: { label: rawStatus, dot: "bg-fg-muted", text: "text-fg-muted" };
return {
run: {
...item,
@ -36,22 +127,39 @@ export async function loader({ request, params }: any) {
statusDot: display.dot,
statusText: display.text,
},
blockedQuestionText:
rawStatus === "blocked"
? await loadBlockedQuestionText(params.id, request?.signal)
: null,
};
}
export async function action({ params, request }: any) {
export async function action({ params, request }: any): Promise<RunDetailActionResult> {
const formData = await request.formData();
const port = formData.get("port");
const expiresInSecs = formData.get("expires_in_secs");
const result = await apiJson<PreviewUrlResponse>(`/runs/${params.id}/preview`, {
request,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ port: Number(port), expires_in_secs: Number(expiresInSecs) }),
},
});
return result;
const intent = String(formData.get("intent") ?? "preview");
if (intent === "preview") {
const port = formData.get("port");
const expiresInSecs = formData.get("expires_in_secs");
const result = await apiJson<PreviewUrlResponse>(`/runs/${params.id}/preview`, {
request,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ port: Number(port), expires_in_secs: Number(expiresInSecs) }),
},
});
return {
intent: "preview",
url: result.url,
};
}
if (intent === "cancel" || intent === "archive" || intent === "unarchive") {
return runLifecycleIntent(params.id, intent, request);
}
throw new Response(null, { status: 400, statusText: `Unsupported intent: ${intent}` });
}
export function meta({ data }: any) {
@ -59,20 +167,69 @@ export function meta({ data }: any) {
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
}
export default function RunDetail({ loaderData, params }: any) {
const { run } = loaderData;
export default function RunDetail({ loaderData, params }: { loaderData: RunDetailLoaderData; params: { id: string } }) {
const { run, blockedQuestionText } = loaderData;
const { pathname } = useLocation();
const basePath = `/runs/${params.id}`;
const previewFetcher = useFetcher<PreviewUrlResponse>();
const previewFetcher = useFetcher<RunDetailActionResult>();
const cancelFetcher = useFetcher<RunDetailActionResult>();
const archiveFetcher = useFetcher<RunDetailActionResult>();
const unarchiveFetcher = useFetcher<RunDetailActionResult>();
const { push } = useToast();
const demoMode = useDemoMode();
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
useRunEventSource(run?.id ?? undefined, {
allowlist: RUN_DETAIL_EVENTS,
debounceMs: 300,
});
useEffect(() => {
if (previewFetcher.data?.url) {
if (previewFetcher.data?.intent === "preview") {
window.open(previewFetcher.data.url, "_blank");
}
}, [previewFetcher.data]);
useEffect(() => {
const result = cancelFetcher.data;
if (!result || result.intent !== "cancel") return;
if (isLifecycleActionFailure(result)) {
push({ message: mapError(result.error, "cancel"), tone: "error" });
return;
}
push({
message: isTerminalCancelledRun(result.run)
? "Run cancelled."
: "Cancellation requested.",
});
}, [cancelFetcher.data, push]);
useEffect(() => {
const result = archiveFetcher.data;
if (!result || result.intent !== "archive") return;
if (isLifecycleActionFailure(result)) {
push({ message: mapError(result.error, "archive"), tone: "error" });
return;
}
push({
message: "Run archived.",
action: {
label: "Unarchive",
onClick: () => submitIntent(unarchiveFetcher, "unarchive"),
},
});
}, [archiveFetcher, archiveFetcher.data, push, unarchiveFetcher]);
useEffect(() => {
const result = unarchiveFetcher.data;
if (!result || result.intent !== "unarchive") return;
if (isLifecycleActionFailure(result)) {
push({ message: mapError(result.error, "unarchive"), tone: "error" });
return;
}
push({ message: "Run restored." });
}, [push, unarchiveFetcher.data]);
if (!run) {
return (
<div className="py-12">
@ -84,6 +241,12 @@ export default function RunDetail({ loaderData, params }: any) {
);
}
const visibility = lifecycleActionVisibility(run.lifecycleStatus);
const previewPending = previewFetcher.state !== "idle";
const cancelPending = cancelFetcher.state !== "idle";
const archivePending = archiveFetcher.state !== "idle";
const unarchivePending = unarchiveFetcher.state !== "idle";
return (
<div>
<nav className="mb-4 flex items-center gap-1 text-sm text-fg-muted">
@ -100,7 +263,7 @@ export default function RunDetail({ loaderData, params }: any) {
<span>{run.title}</span>
</nav>
<div className="mb-6 flex items-center gap-4">
<div className="mb-6 flex flex-wrap items-start gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-xl font-semibold text-fg">{run.title}</h2>
<div className="mt-2 flex items-center gap-3 text-sm">
@ -114,26 +277,76 @@ export default function RunDetail({ loaderData, params }: any) {
)}
</div>
</div>
{/* TODO: restore an Open PR button when RunPullRequest gains a url field */}
{run.sandboxId && (
<previewFetcher.Form method="post">
<input type="hidden" name="port" value="3000" />
<input type="hidden" name="expires_in_secs" value="3600" />
<button
type="submit"
disabled={previewFetcher.state !== "idle"}
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-teal-500 px-3.5 py-1.5 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="size-3.5 shrink-0" aria-hidden="true">
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
<path fillRule="evenodd" d="M.664 10.59a1.651 1.651 0 0 1 0-1.186A10.004 10.004 0 0 1 10 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0 1 10 17c-4.257 0-7.893-2.66-9.336-6.41ZM14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" clipRule="evenodd" />
</svg>
{previewFetcher.state !== "idle" ? "Opening…" : "Preview"}
</button>
</previewFetcher.Form>
)}
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{visibility.showPrimaryCancel && (
<cancelFetcher.Form method="post">
<input type="hidden" name="intent" value="cancel" />
<button
type="submit"
disabled={cancelPending}
className={CANCEL_BUTTON_CLASS}
>
{cancelPending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{cancelPending ? "Cancelling…" : "Cancel"}
</button>
</cancelFetcher.Form>
)}
{visibility.showArchive && (
<archiveFetcher.Form method="post">
<input type="hidden" name="intent" value="archive" />
<button
type="submit"
disabled={archivePending}
className={MUTATION_BUTTON_CLASS}
>
{archivePending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{archivePending ? "Archiving…" : "Archive"}
</button>
</archiveFetcher.Form>
)}
{visibility.showUnarchive && (
<unarchiveFetcher.Form method="post">
<input type="hidden" name="intent" value="unarchive" />
<button
type="submit"
disabled={unarchivePending}
className={MUTATION_BUTTON_CLASS}
>
{unarchivePending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{unarchivePending ? "Restoring…" : "Unarchive"}
</button>
</unarchiveFetcher.Form>
)}
{run.sandboxId && (
<previewFetcher.Form method="post">
<input type="hidden" name="intent" value="preview" />
<input type="hidden" name="port" value="3000" />
<input type="hidden" name="expires_in_secs" value="3600" />
<button
type="submit"
disabled={previewPending}
className={PRIMARY_BUTTON_CLASS}
>
{previewPending && <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />}
{previewPending ? "Opening…" : "Preview"}
</button>
</previewFetcher.Form>
)}
</div>
</div>
{visibility.showBlockedNotice && (
<BlockedRunNotice
questionText={blockedQuestionText}
cancelling={cancelPending}
onCancel={() => submitIntent(cancelFetcher, "cancel")}
/>
)}
<div className="border-b border-line">
<nav className="-mb-px flex gap-6">
{tabs.map((tab) => {
@ -171,3 +384,85 @@ export default function RunDetail({ loaderData, params }: any) {
</div>
);
}
async function loadBlockedQuestionText(id: string, signal?: AbortSignal): Promise<string | null> {
try {
const url = new URL(`/api/v1/runs/${id}/questions`, "http://fabro.local");
url.searchParams.set("page[limit]", "1");
url.searchParams.set("page[offset]", "0");
const response = await fetch(`${url.pathname}${url.search}`, {
credentials: "include",
...(signal ? { signal } : {}),
});
if (!response.ok) {
return null;
}
const payload = await response.json() as PaginatedApiQuestionList;
return payload.data[0]?.text ?? null;
} catch {
return null;
}
}
async function runLifecycleIntent(
id: string,
intent: LifecycleAction,
request: Request,
): Promise<LifecycleActionResult> {
try {
switch (intent) {
case "cancel":
return { intent, ok: true, run: await cancelRun(id, request) };
case "archive":
return { intent, ok: true, run: await archiveRun(id, request) };
case "unarchive":
return { intent, ok: true, run: await unarchiveRun(id, request) };
}
} catch (error) {
return {
intent,
ok: false,
error: serializeLifecycleActionError(error),
};
}
}
function serializeLifecycleActionError(error: unknown): LifecycleActionError | null {
if (!error || typeof error !== "object") return null;
const record = error as Record<string, unknown>;
if (typeof record.status !== "number" || !Array.isArray(record.errors)) {
return null;
}
return {
status: record.status,
errors: record.errors.filter(isErrorResponseEntry),
};
}
function isErrorResponseEntry(value: unknown): value is ErrorResponseEntry {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return (
typeof record.status === "string"
&& typeof record.title === "string"
&& typeof record.detail === "string"
);
}
function isLifecycleActionFailure(
value: LifecycleActionResult,
): value is Extract<LifecycleActionResult, { ok: false }> {
return value.ok === false;
}
function submitIntent(
fetcher: { submit: (target: FormData, options: { method: "post" }) => void },
intent: LifecycleAction,
) {
const formData = new FormData();
formData.set("intent", intent);
fetcher.submit(formData, { method: "post" });
}

View file

@ -1,6 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test";
import { extractRequestId, loader } from "./run-files";
import {
deepLinkToastMessage,
emptyTransitionToastMessage,
extractRequestId,
loader,
} from "./run-files";
type StubResponseInit = {
status: number;
@ -8,6 +13,30 @@ type StubResponseInit = {
headers?: Record<string, string>;
};
function buildRunFilesPayload({
files = [],
degraded = false,
patch = null,
}: {
files?: string[];
degraded?: boolean;
patch?: string | null;
}) {
return {
data: files.map((name) => ({
change_kind: "modified",
old_file: { name },
new_file: { name },
})),
meta: {
degraded,
patch,
total_changed: files.length,
truncated: false,
},
} as any;
}
function stubFetchOnce(init: StubResponseInit) {
const original = globalThis.fetch;
globalThis.fetch = (() => {
@ -76,6 +105,50 @@ describe("extractRequestId", () => {
});
});
describe("emptyTransitionToastMessage", () => {
test("returns the no-changes toast when a populated diff becomes empty", () => {
expect(emptyTransitionToastMessage(3, 0)).toBe("No changes in this run.");
});
test("returns null when the diff was already empty", () => {
expect(emptyTransitionToastMessage(0, 0)).toBeNull();
expect(emptyTransitionToastMessage(null, 0)).toBeNull();
expect(emptyTransitionToastMessage(2, 1)).toBeNull();
});
});
describe("deepLinkToastMessage", () => {
test("returns the patch-only message when file navigation is unavailable", () => {
expect(
deepLinkToastMessage(
"src/app.tsx",
buildRunFilesPayload({
degraded: true,
patch: "@@ -1 +1 @@",
}),
),
).toBe("File-level navigation isn't available in the patch-only view.");
});
test("returns the missing-file message when the requested file is absent", () => {
expect(
deepLinkToastMessage(
"src/missing.ts",
buildRunFilesPayload({ files: ["src/present.ts"] }),
),
).toBe("File src/missing.ts is not in this run.");
});
test("returns null when the deep-linked file exists", () => {
expect(
deepLinkToastMessage(
"src/present.ts",
buildRunFilesPayload({ files: ["src/present.ts"] }),
),
).toBeNull();
});
});
describe("loader", () => {
let restoreFetch: (() => void) | undefined;

View file

@ -4,6 +4,7 @@ import {
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import {
useMatches,
@ -11,7 +12,8 @@ import {
useParams,
useRevalidator,
} from "react-router";
import { MultiFileDiff, PatchDiff, Virtualizer } from "@pierre/diffs/react";
import * as PierreDiffs from "@pierre/diffs/react";
import { useToast } from "../components/toast";
import type {
FileDiff as ApiFileDiff,
PaginatedRunFileList,
@ -27,10 +29,18 @@ import {
LoadingSkeleton,
renderStatusError,
RunFilesErrorBoundary,
Toast,
} from "./run-files/states";
import { useFileKeyboardNav } from "./run-files/keyboard";
import { Toolbar, type DiffStyle } from "./run-files/toolbar";
import { useRunEventSource } from "../lib/sse";
const { MultiFileDiff, PatchDiff } = PierreDiffs;
const maybeVirtualizer = (PierreDiffs as Record<string, unknown>).Virtualizer;
const Virtualizer = typeof maybeVirtualizer === "function"
? maybeVirtualizer as ({ children }: { children: ReactNode }) => ReactElement
: function VirtualizerFallback({ children }: { children: ReactNode }) {
return <>{children}</>;
};
export const handle = { wide: true };
@ -156,28 +166,10 @@ function useNarrowViewport(): boolean {
}
function useSseRevalidation(runId: string | undefined) {
const revalidator = useRevalidator();
useEffect(() => {
if (!runId) return;
const source = new EventSource(`/api/v1/runs/${runId}/attach?since_seq=1`);
let debounce: ReturnType<typeof setTimeout> | undefined;
source.onmessage = (msg) => {
try {
const payload = JSON.parse(msg.data);
if (REFRESH_EVENTS.has(payload.event)) {
clearTimeout(debounce);
debounce = setTimeout(() => revalidator.revalidate(), 500);
}
} catch {
// ignore malformed payloads
}
};
return () => {
clearTimeout(debounce);
source.close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId]);
useRunEventSource(runId, {
allowlist: REFRESH_EVENTS,
debounceMs: 500,
});
}
function useFreshness(
@ -260,6 +252,47 @@ function decodeDeepLinkFile(hash: string): string | null {
}
}
export function emptyTransitionToastMessage(
previousFileCount: number | null,
nextFileCount: number,
): string | null {
return previousFileCount !== null && previousFileCount > 0 && nextFileCount === 0
? "No changes in this run."
: null;
}
function resolveDeepLinkToast(
hashFile: string | null,
data: PaginatedRunFileList | null,
): { key: string; message: string } | null {
if (!hashFile || !data) return null;
if (data.meta.degraded && data.meta.patch) {
return {
key: `patch-only:${hashFile}`,
message: "File-level navigation isn't available in the patch-only view.",
};
}
const exists = data.data.some(
(file) => file.new_file.name === hashFile || file.old_file.name === hashFile,
);
if (!exists) {
return {
key: `missing:${hashFile}`,
message: `File ${hashFile} is not in this run.`,
};
}
return null;
}
export function deepLinkToastMessage(
hashFile: string | null,
data: PaginatedRunFileList | null,
): string | null {
return resolveDeepLinkToast(hashFile, data)?.message ?? null;
}
/**
* Extract the lifecycle status from whichever ancestor match carries it.
* The Run Detail loader (apps/fabro-web/app/routes/run-detail.tsx) returns
@ -288,6 +321,7 @@ export default function RunFiles({ loaderData }: any) {
const navigation = useNavigation();
const revalidator = useRevalidator();
const matches = useMatches();
const { push } = useToast();
const result = loaderData as RunFilesLoaderResult | null;
const narrow = useNarrowViewport();
const runStatus = resolveRunStatus(matches);
@ -296,23 +330,19 @@ export default function RunFiles({ loaderData }: any) {
// rendering the previous files while surfacing an inline banner.
const lastGoodDataRef = useRef<PaginatedRunFileList | null>(null);
const lastFetchedAtRef = useRef<number | null>(null);
const [emptyToast, setEmptyToast] = useState<string | null>(null);
const [deepLinkToast, setDeepLinkToast] = useState<string | null>(null);
useEffect(() => {
if (!result?.data) return;
const prev = lastGoodDataRef.current;
if (prev && prev.data.length > 0 && result.data.data.length === 0) {
setEmptyToast("No changes in this run.");
const id = setTimeout(() => setEmptyToast(null), 3500);
lastGoodDataRef.current = result.data;
lastFetchedAtRef.current = Date.now();
return () => clearTimeout(id);
const message = emptyTransitionToastMessage(
lastGoodDataRef.current?.data.length ?? null,
result.data.data.length,
);
if (message) {
push({ message });
}
lastGoodDataRef.current = result.data;
lastFetchedAtRef.current = Date.now();
return undefined;
}, [result?.data]);
}, [push, result?.data]);
const data: PaginatedRunFileList | null =
result?.data ?? lastGoodDataRef.current;
@ -352,6 +382,7 @@ export default function RunFiles({ loaderData }: any) {
const refreshButtonRef = useRef<HTMLButtonElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const lastDeepLinkToastRef = useRef<string | null>(null);
// Return focus to the Refresh button after a revalidation completes so
// keyboard-first users stay oriented.
@ -384,28 +415,22 @@ export default function RunFiles({ loaderData }: any) {
}, []);
useEffect(() => {
const toast = resolveDeepLinkToast(hashFile, data);
if (toast) {
if (lastDeepLinkToastRef.current !== toast.key) {
push({ message: toast.message, autoDismissMs: 5000 });
lastDeepLinkToastRef.current = toast.key;
}
return;
}
lastDeepLinkToastRef.current = null;
if (!hashFile || !data) return;
if (data.meta.degraded && data.meta.patch) {
setDeepLinkToast(
"File-level navigation isn't available in the patch-only view.",
);
const id = setTimeout(() => setDeepLinkToast(null), 5000);
return () => clearTimeout(id);
}
const exists = data.data.some(
(f) => f.new_file.name === hashFile || f.old_file.name === hashFile,
);
if (!exists) {
setDeepLinkToast(`File ${hashFile} is not in this run.`);
const id = setTimeout(() => setDeepLinkToast(null), 5000);
return () => clearTimeout(id);
}
const el = document.getElementById(fileRowId(hashFile));
if (el) {
el.scrollIntoView({ block: "start", behavior: "smooth" });
el.focus({ preventScroll: true });
}
}, [hashFile, data]);
}, [data, hashFile, push]);
const renderFiles = useCallback(
(files: ApiFileDiff[]): ReactElement[] =>
@ -526,8 +551,6 @@ export default function RunFiles({ loaderData }: any) {
theme: "pierre-dark",
}}
/>
{emptyToast && <Toast>{emptyToast}</Toast>}
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
</div>
);
}
@ -543,8 +566,6 @@ export default function RunFiles({ loaderData }: any) {
degraded: meta.degraded ?? false,
})}
/>
{emptyToast && <Toast>{emptyToast}</Toast>}
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
</div>
);
}
@ -568,8 +589,6 @@ export default function RunFiles({ loaderData }: any) {
/>
) : null}
{body}
{emptyToast && <Toast>{emptyToast}</Toast>}
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
</div>
);
}

View file

@ -1,12 +1,20 @@
import { describe, expect, test } from "bun:test";
import { MultiFileDiff, PatchDiff, Virtualizer } from "@pierre/diffs/react";
import type { ReactNode } from "react";
import * as PierreDiffs from "@pierre/diffs/react";
const { MultiFileDiff, PatchDiff } = PierreDiffs;
const maybeVirtualizer = (PierreDiffs as Record<string, unknown>).Virtualizer;
const Virtualizer = typeof maybeVirtualizer === "function"
? maybeVirtualizer
: function VirtualizerFallback({ children }: { children: ReactNode }) {
return <>{children}</>;
};
// Regression coverage for the @pierre/diffs 1.0 -> 1.1 upgrade. We assert
// only that the public React components the Run Files route uses remain
// exported as callable function components — a full mount-under-test hits
// pierre's useLayoutEffect teardown path, which is incompatible with
// react-test-renderer under React 19. Functional mount coverage lives in
// the dev-server smoke check.
// only that the React components the Run Files route consumes remain
// callable. `Virtualizer` is optional across installed pierre versions, so
// the route carries a no-op fallback and the smoke test mirrors that
// compatibility layer rather than assuming a specific package export set.
describe("@pierre/diffs public API", () => {
test("MultiFileDiff is a callable component export", () => {

View file

@ -8,7 +8,6 @@ import {
EmptyState,
InlineErrorBanner,
LoadingSkeleton,
Toast,
} from "./states";
function renderToJson(element: React.ReactElement): any {
@ -178,15 +177,4 @@ describe("component rendering", () => {
});
expect(clicked).toBe(1);
});
test("Toast renders its children in an aria-live region", () => {
let tree: TestRenderer.ReactTestRenderer | undefined;
TestRenderer.act(() => {
tree = TestRenderer.create(<Toast>hello</Toast>);
});
const live = tree!.root.findAll(
(node) => node.props?.["aria-live"] === "polite",
);
expect(live.length).toBeGreaterThan(0);
});
});

View file

@ -128,18 +128,6 @@ export function InlineErrorBanner({
);
}
export function Toast({ children }: { children: React.ReactNode }) {
return (
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed bottom-6 right-6 z-50 rounded-md border border-line bg-panel/95 px-3 py-2 text-xs text-fg-2 shadow-lg"
>
{children}
</div>
);
}
/**
* Shared helper for rendering the documented status-code taxonomy. Consumed
* by both the inline `initialError` branch in run-files.tsx and the

View file

@ -43,6 +43,8 @@ describe("runs route board mapping", () => {
expect(shouldRefreshBoardForEvent("run.queued")).toBe(true);
expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true);
expect(shouldRefreshBoardForEvent("run.unblocked")).toBe(true);
expect(shouldRefreshBoardForEvent("run.archived")).toBe(true);
expect(shouldRefreshBoardForEvent("run.unarchived")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.started")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true);
expect(shouldRefreshBoardForEvent("run.created")).toBe(false);

View file

@ -72,6 +72,8 @@ const BOARD_STATUS_EVENTS = new Set([
"run.unblocked",
"run.completed",
"run.failed",
"run.archived",
"run.unarchived",
"interview.started",
"interview.completed",
"interview.timeout",

View file

@ -211,7 +211,7 @@ Customize the git author identity used for checkpoint commits. When not set, def
### `[server.integrations.github]` section
Configure GitHub integration auth. `strategy = "token"` is the default and uses a stored `GITHUB_TOKEN` from the vault (with `GH_TOKEN` as a fallback). `strategy = "app"` enables the GitHub App flow, browser OAuth, and webhooks.
Configure GitHub integration auth. `strategy = "token"` is the default and uses a stored `GITHUB_TOKEN` from the vault (with `GH_TOKEN` as a fallback). `strategy = "app"` enables the GitHub App flow and browser OAuth; webhook delivery is configured separately under `[server.integrations.github.webhooks]`.
```toml title="settings.toml"
[server.integrations.github]
@ -227,11 +227,20 @@ app_id = "123456"
client_id = "Iv1.abc123"
slug = "fabro-app"
[server.api]
url = "https://fabro-api.example.com"
[server.integrations.github.webhooks]
strategy = "tailscale_funnel"
strategy = "server_url"
```
When `webhooks.strategy = "tailscale_funnel"` is configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. Requires the `GITHUB_APP_WEBHOOK_SECRET` environment variable.
Fabro always serves the GitHub webhook handler at `POST /api/v1/webhooks/github` when `GITHUB_APP_WEBHOOK_SECRET` is configured. The `strategy` field controls how Fabro exposes that route and whether it mutates the GitHub App webhook URL on startup:
- `strategy = "server_url"`: recommended for production or any deployment with a stable public API URL. Fabro sets the GitHub App webhook URL to `<server.api.url>/api/v1/webhooks/github` on startup. Requires `server.api.url` and `GITHUB_APP_WEBHOOK_SECRET`.
- `strategy = "tailscale_funnel"`: opt-in for Tailscale-hosted machines without a stable public URL. Fabro runs `tailscale funnel <server-port>`, exposes the main server on that Funnel URL, and best-effort updates the GitHub App webhook URL to `<funnel-url>/api/v1/webhooks/github`. Requires a TCP listener and `GITHUB_APP_WEBHOOK_SECRET`.
- `strategy` unset: Fabro still accepts signed webhook deliveries on `/api/v1/webhooks/github` when the secret is present, but it does not run `tailscale funnel` and does not update the GitHub App webhook URL.
Incoming webhooks are authenticated only by GitHub's `X-Hub-Signature-256` HMAC signature, not by Fabro's bearer/session auth.
### `[run.checkpoint]` section

View file

@ -9,6 +9,8 @@ tags:
description: API discovery and health
- name: Install
description: First-run browser install workflow
- name: Integrations
description: External provider callbacks and integration endpoints
- name: Runs
description: Run management operations
- name: Human-in-the-Loop
@ -374,6 +376,26 @@ paths:
schema:
type: object
/api/v1/webhooks/github:
post:
operationId: receiveGithubWebhook
tags: [Integrations]
summary: Receive GitHub Webhook
description: Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
"200":
description: Webhook accepted
"401":
description: Missing or invalid webhook signature
/api/v1/user:
get:
operationId: getUser

View file

@ -0,0 +1,126 @@
---
date: 2026-04-19
topic: web-ui-lifecycle-actions
---
# Expose CLI Lifecycle Actions in the Web UI
## Problem Frame
The Fabro web UI today is essentially read-only for run management. The only mutating action exposed is **Preview** (opens a sandbox port URL). Every other run lifecycle action — cancelling a stuck run, cleaning up the board, revisiting archived runs — requires dropping to the CLI.
Two concrete user problems drive this work:
1. **Daily friction for CLI users.** People live in the board view and detail pages but have to context-switch to a terminal to manage state.
2. **Excludes non-CLI teammates.** PMs, reviewers, and stakeholders can watch runs but cannot participate in managing them, cutting the UI off as a collaboration surface.
The web UI should own the **everyday lifecycle operations** these users hit. Rarer or more dangerous CLI verbs (force-delete of active runs, checkpoint ops, etc.) can remain CLI-only by design; the goal is user value per surface, not parity for parity's sake.
Most server infrastructure already exists — `POST /runs/{id}/{cancel,archive,unarchive}` are live, wired to the workflow engine's operations, and emit SSE events. The remaining work is UI surface + a handful of concrete SSE reconciliation gaps (see Dependencies).
## Requirements
**Action set**
- R1. Expose three lifecycle actions on the run detail page (`/runs/{id}`): **cancel**, **archive**, **unarchive**.
- R2. Do not expose `pause`, `unpause`, or `delete` in this first pass. Pause/unpause has no evidenced daily-user need; delete's tab-close-mid-undo failure mode is unacceptable for a non-CLI teammate because observability is destroyed (there's no run to revisit to self-verify).
- R3. Do not expose checkpoint operations (resume, rewind, fork) or HITL question answering in this first pass.
- R4. Do not expose these actions on the board kanban cards or as bulk selection in this first pass.
**State-aware visibility**
- R5. Only show an action when the run's current status makes it valid:
- **cancel (primary)**: visible as a primary affordance when status is `submitted`, `queued`, `starting`, `running`, or `paused`. Not shown as primary when `blocked` — see R6. The server also accepts cancel on `blocked` runs; that path is only reachable via the secondary surface from R6.
- **archive**: visible only when status is terminal (`succeeded`, `failed`, `dead`) AND not already archived.
- **unarchive**: visible only when `archived`.
- R6. When a run is `blocked` (waiting on an HITL question), do not show cancel as a primary action. Instead, show an inline notice with the pending question text (from `GET /api/v1/runs/{id}/questions`, whose `ApiQuestion.text` field is already human-readable) and the instruction: "Answer this question via `fabro` CLI to continue." Cancel remains reachable via an overflow/secondary affordance (e.g., a "…" menu) for users who truly want to abandon the run. This protects the common case (non-CLI teammate accidentally cancelling work that was waiting for them) without fully hiding the escape hatch.
- R7. Visibility updates live when the run status changes. The detail page must subscribe to the run's SSE event stream (`GET /runs/{id}/attach`) so action affordances appear/disappear without a manual refresh.
**Interaction & feedback**
Two toast patterns, matched to the reversibility of each action:
- R8. **Cancel uses a client-side deferred toast with a fixed 5-second countdown.** Cancel has partially-irreversible side effects (the agent stops mid-stage) so the undo window guards against misclicks.
- Clicking cancel shows a toast with the action description, a 5-second countdown, and an **Undo** button. The cancel affordance enters a disabled/pending state during the window (not hidden — hiding mid-window misrepresents actual run state).
- If the user clicks **Undo** before the countdown expires, the pending client-side timer is cancelled and no API call is made.
- If the countdown expires, the client fires the API call. **Before firing**, the UI performs a `GET /api/v1/runs/{id}` refetch: if the run's status is no longer one where cancel is valid (e.g., the run completed or was cancelled elsewhere), the pending action is aborted and a brief "Run transitioned — cancel aborted" notice is shown instead. This covers the SSE-channel-unreachable case where R9's event-driven abort cannot fire.
- If the tab is closed or navigated away (including SPA navigation to another route) mid-window, the pending action is silently cancelled. Accepted tradeoff of the client-only approach.
- R9. **Archive and unarchive fire immediately with an inverse-action toast.** Both are fully reversible by the opposite API call, so the 5-second countdown is overhead with no safety benefit. Gmail-archive shape:
- Clicking archive fires `POST /runs/{id}/archive` immediately (optimistic UI: the run disappears from terminal views / moves to archived views right away).
- On success, show a toast: "Run archived. **Unarchive**" with a visible action button. The toast remains dismissable for ~8 seconds.
- Clicking **Unarchive** in the toast fires `POST /runs/{id}/unarchive`. The toast updates to "Run restored" briefly, then dismisses.
- Unarchive-triggered-from-the-primary-affordance works identically, with the inverse verb.
- R10. **Undo-window collisions (cancel only).** While the cancel timer is pending for a run, other primary affordances for that run are disabled (not hidden). If an SSE event arrives for that run that would change its status (another tab, a CLI user, the run finishing naturally), the pending client-side timer is cancelled, the toast is dismissed with a brief notice ("Run transitioned — action cancelled"), and the UI reconciles to the new status. If a newly-valid action (e.g., archive becomes valid because the run just completed) results from the transition, its affordance lights up immediately rather than waiting for the toast to fully dismiss.
- R11. On a successful async cancel (status unchanged in the response body), the UI relies on SSE for the final status flip. No additional success toast beyond the deferred-toast already shown.
- R12. On API failure (including 409 precondition failures — e.g., the run transitioned out of a valid state between toast-expiry and API call), show an error toast that includes the server's error message, and refetch the run so the UI reconciles to actual state. For the archive/unarchive fire-immediately path, additionally roll back the optimistic UI change on failure.
- R13. **Multi-tab behavior (single-client rules apply per tab).** R8/R10 are scoped per-client: each tab runs its own timer. An SSE-delivered status transition in tab B (caused by tab A firing cancel) will cancel tab B's own pending timer for the same run per R10 and surface the transition notice. Cross-tab action coordination beyond what SSE already provides is not a requirement for this pass.
**Accessibility**
- R14. Both toast patterns (deferred cancel toast; immediate archive/unarchive toast) must meet baseline a11y expectations:
- Toast container uses `role="status"` with `aria-live="polite"` (assertive interrupts screen-reader output and is wrong here). Announcement names the action, e.g., "Cancel run requested, undoing in 5 seconds" or "Run archived. Press Unarchive to undo."
- Toast does **not** steal focus, but is reachable via keyboard (tab order places the action button — Undo or Unarchive — immediately after the triggering affordance).
- For the cancel deferred toast, while keyboard focus is on the **Undo** button, the 5-second countdown **pauses** and resumes counting down from the paused value when focus leaves. Focus leaving the Undo button after the countdown would have expired does not auto-fire the action — the user must still explicitly close the toast or navigate away for the countdown to resume its final tick.
- Archive/unarchive toasts do not count down (they fire on click); they remain dismissable and focusable for ~8 seconds.
- Touch targets (Undo / Unarchive buttons) meet 44×44 CSS px minimum. The detail page is expected to work on tablet and larger; phone-size support is not a requirement for this pass.
- The action cluster is keyboard-operable end to end (no mouse-only affordances). Keyboard shortcuts for individual actions are out of scope for this pass.
## Success Criteria
- A user managing runs day-to-day can complete a full session (cancelling a stuck run, archiving finished ones, revisiting an archived one) without touching the CLI.
- A non-CLI teammate can cancel or archive a run in the web UI without onboarding documentation beyond "click the button."
- A non-CLI teammate who lands on a `blocked` run understands the run is waiting on a human answer, sees the question text, and does not accidentally cancel work in progress. **Note:** unblocking the teammate so they can actually *answer* the question requires HITL-answering in the web UI, which is deferred — this first pass prevents destruction, not participation. If blocked-teammate-can't-proceed proves to be a real painful pattern in usage, HITL answering should be the next scope to pick up.
- When a run transitions state while the detail page is open (e.g., finishes, gets archived from the CLI, gets cancelled by someone else), the available actions update live without a page refresh.
- The cancel deferred-toast component (toast container + Undo + pause-on-focus countdown + polite aria-live) is shaped so it can host future destructive actions (delete if we solve the observability problem; force-cancel if ever added) without redesign. The archive/unarchive immediate-inverse toast is a simpler shape that other reversible fire-and-forget actions can reuse.
## Scope Boundaries
**Out of scope for this first pass:**
- `pause` and `unpause` (no evidenced daily-user pain for the target personas; revisit if a real workflow surfaces).
- `delete` (tab-close-silently-cancels destroys observability — there's no run to revisit to self-verify — which is unacceptable for the non-CLI teammate persona; revisit with server-side soft-delete or a different UX).
- Force-delete of active runs (`rm --force`).
- Checkpoint operations: `resume`, `rewind`, `fork`. These need parameter input (which checkpoint? which branch?) that doesn't fit the uniform button+toast pattern.
- Answering HITL questions from the web UI. R6 surfaces the question read-only and points to the CLI.
- Board-card actions and bulk multi-select on the board.
- Dense table/list view of runs.
- Server-side deferred/pending states.
- Keyboard shortcuts for individual actions.
- Role-based authorization (assumed unchanged from today; all authenticated users can take all actions).
- Phone-size responsive layout.
## Key Decisions
- **Cancel + archive + unarchive only.** Cancel addresses the clearest daily pain (stuck runs). Archive/unarchive addresses board clutter and is fully reversible by the opposite action — lowest-risk place to validate the interaction pattern. Pause/unpause are deferred for lack of evidenced need; delete is deferred because its client-side-undo failure mode destroys observability for the non-CLI teammate persona.
- **Run detail page only, not the board.** Keep the first pass tight.
- **Two toast patterns matched to reversibility, not one uniform pattern.** Cancel is partially irreversible → deferred-toast with a bound 5-second countdown. Archive/unarchive are fully reversible via the opposite API call → fire-immediately with an inverse-action toast (Gmail-archive shape). This is more spec than "one pattern for everything" but maps to a real property of the actions and eliminates a pointless friction tax on the reversible ones. The shared toast infrastructure (container, action-button slot, aria-live, keyboard reachability) is reused across both; only the cancel path carries the countdown + focus-pause machinery.
- **Pre-fire status recheck on the deferred cancel path.** At countdown expiry, the client refetches `GET /runs/{id}` before firing the cancel API call. This covers the SSE-unreachable failure mode where R10's event-driven abort cannot fire: without the recheck, a dead SSE channel would silently degrade to "timer fires, 409 error, user sees error toast for a race they didn't create." One extra GET per cancel is a cheap premium for reliable UX.
- **Blocked runs suppress cancel as a primary affordance and show the pending question.** Protects non-CLI teammates from the worst failure mode (cancelling work that was waiting for them). Cancel remains reachable via a secondary/overflow affordance for users who genuinely want to abandon. This solves the destruction problem but leaves the participation problem for HITL-in-the-web to solve later.
- **Client-side deferral, not server-side.** Chosen on shipping speed; does not add new lifecycle states. Accepted tradeoff: the "undo" promise is soft — tab close or SPA nav silently cancels. This is tolerable precisely because the action with the worst silent-cancellation consequence (delete) was scoped out.
- **Accessibility is in the requirements, not deferred to implementation.** ARIA semantics, keyboard reachability, focus-pauses-countdown, touch-target sizing, and aria-live politeness are specified rather than left as "standard best practices."
- **Pattern reuse is claimed only within this action family.** We do not claim "adding resume/rewind/fork/HITL later is same shape, new button." Those need parameter input (checkpoint choice, answer text) that the button+toast pattern doesn't host. That's fine — these patterns are for fire-and-forget lifecycle mutations, and the rest of the verbs will need their own UX.
## Dependencies / Assumptions
- The existing API endpoints (`POST /runs/{id}/{cancel,archive,unarchive}`) are stable and will not require spec changes. Verified against `docs/api-reference/fabro-api.yaml`.
- The generated TypeScript client in `lib/packages/fabro-api-client` exposes (or will trivially expose after regeneration) methods for these endpoints.
- **SSE coverage is not uniform.** The server emits `run.*` events for status transitions including `run.archived` / `run.unarchived` (`fabro-workflow/src/event.rs`, `fabro-server/src/server.rs`). Known gaps the plan must address:
- The board's event allowlist (`apps/fabro-web/app/routes/runs.tsx` `BOARD_STATUS_EVENTS`) does not currently include `run.archived` / `run.unarchived`. Adding them is in scope.
- The per-run `/attach` SSE stream terminates on `RunCompleted` / `RunFailed`. Archive/unarchive events fire on already-terminal runs, so the detail page must either reconnect to a non-terminating channel, refetch on the successful archive/unarchive response, or listen at a layer above `/attach`. Planning should pick an approach.
- **Run detail page is not yet SSE-subscribed.** `apps/fabro-web/app/routes/run-detail.tsx` currently fetches the run once via the React Router loader and does not subscribe to `/api/v1/runs/{id}/attach`. Wiring this subscription at the detail-page level (the owner of `run.status` that drives R5 visibility) is net-new work for R7. Individual tab components (stage-sidebar, run-files) already have per-run SSE subscriptions that can be used as a pattern.
- **No undo-capable toast system exists yet.** The only Toast in `apps/fabro-web` is a read-only live-region banner (`apps/fabro-web/app/routes/run-files/states.tsx`) with local `useState`/`setTimeout`. R8 + R9 + R12 together require a shared toast component with: a countdown, action-button slot, programmatic dismiss, multi-toast coexistence, polite aria-live, and focus-pauses-countdown behavior. This is net-new UI infrastructure.
- **Cancel semantics.** For `submitted` and `queued` runs, cancel synchronously flips lifecycle `status` to `failed` with `status_reason: cancelled` and returns that on the response. For `starting`/`running`/`blocked`/`paused` runs, cancel returns 200 with unchanged status and the transition lands asynchronously via the workflow engine. The UI should treat cancel as "request accepted" and rely on SSE for the final status flip — R10 covers this implicitly, but the plan should make the optimistic-UI behavior explicit (e.g., the cancel affordance stays in its disabled/pending state until either the response body carries the synchronous `failed`/`cancelled` result or the SSE-driven reconciliation arrives).
- **Per-run `/attach` stream has silent termination paths beyond the terminal-event case.** `attach_event_is_terminal` only matches `RunCompleted | RunFailed`, but the task that drives the stream can also exit without a terminal marker if the store read errors, if the run projection becomes non-active mid-replay, or if cancel lands on a queued run that never transitioned to running (covered by the `cancel_before_run_transitions_to_running_returns_empty_attach_stream` test in `fabro-server/src/server.rs`). R7 and R10 must therefore not assume a terminal event will always land: the plan needs a fallback refetch path for "SSE stream ended without a terminal marker" and for "SSE channel unreachable during an undo window."
- Authorization is a non-issue today (single-user / trusted deployment assumption). If multi-tenant auth lands, the action affordances will need to respect it, but that's a separate workstream.
## Outstanding Questions
### Deferred to Planning
- [Affects R8][Design] Exact visual placement of the action cluster in the detail page header: single "Actions" dropdown vs. inline buttons vs. split primary+overflow. Behavior is specified here; visual placement resolves in design/implementation. Consider how cancel-on-blocked lives in the overflow while the primary slot is occupied by the R6 inline notice.
- [Affects R11][Technical] Confirm the 409 error-body shape from the server so error-toast copy can use the server-provided message verbatim.
- [Affects R7, R10][Technical] Decide the post-terminal reconciliation mechanism for archive/unarchive: reconnect SSE after terminal close, refetch on 2xx archive/unarchive response, or subscribe on a non-terminating channel. Either the per-run `/attach` contract extends or the UI uses response-driven reconciliation.
## Next Steps
→ `/ce:plan` for structured implementation planning

View file

@ -19,6 +19,14 @@ The published Docker image switched to [Docker Hardened Images](https://www.dock
A new `docker-compose.prod.yaml` stands up a Caddy 2 sidecar that handles auto-HTTPS on ports 80/443 and proxies to the Fabro service. Set `FABRO_DOMAIN` to your domain and Caddy provisions and renews the certificate; certs persist in a named volume. The base `docker-compose.yaml` has moved to the repo root.
## GitHub webhook exposure is now explicit
GitHub App webhook exposure no longer auto-enables just because `[server.integrations.github.webhooks]` exists. The webhook handler now lives on the main API router at `POST /api/v1/webhooks/github`, and operators must choose an explicit strategy if they want Fabro to mutate any external state on startup.
- Breaking: if you previously relied on Fabro silently starting `tailscale funnel` and rewriting the GitHub App webhook URL, add `strategy = "tailscale_funnel"` under `[server.integrations.github.webhooks]` to restore that behavior.
- New: `strategy = "server_url"` tells Fabro to use `server.api.url` and set the GitHub App webhook URL to `<server.api.url>/api/v1/webhooks/github` on startup. This is the recommended choice for stable production deployments behind HTTPS.
- Unset `strategy`: Fabro still verifies and serves signed GitHub webhooks when `GITHUB_APP_WEBHOOK_SECRET` is present, but it will not run `tailscale funnel` and it will not rewrite the GitHub App webhook URL.
## More
<Accordion title="Fixes">

View file

@ -6,7 +6,7 @@ description: "Integrate Fabro with GitHub for repository access and OAuth login"
Fabro supports two GitHub integration strategies:
- `token` — the default for local and individual use. Fabro captures `gh auth token` during `fabro install`, stores it as `GITHUB_TOKEN`, and uses that token directly for repo access, pull requests, and sandbox `GITHUB_TOKEN` injection.
- `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, and enables browser OAuth and webhooks.
- `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, enables browser OAuth, and supports webhook delivery when you configure a [webhook strategy](#webhook-delivery-strategies).
`token` changes GitHub integration auth only. It does not provide browser sign-in, so the embedded web UI is disabled when `strategy = "token"`.
@ -19,11 +19,11 @@ Fabro supports two GitHub integration strategies:
| Sandbox `GITHUB_TOKEN` | Direct token | Scoped installation token |
| Browser sign-in | No | Yes |
| Web UI routes | Disabled | Enabled |
| Webhooks | No | Yes |
| Webhooks | No | Strategy-dependent |
## GitHub App mode
The rest of this page describes the `app` strategy, which is required for browser auth and webhooks.
The rest of this page describes the `app` strategy, which is required for browser auth and for any webhook delivery strategy.
| Feature | How it's used |
|---|---|
@ -125,6 +125,30 @@ Fabro stores the GitHub App secrets in `<data_dir>/server.env` under these keys:
The private key is stored as base64-encoded PEM. Fabro also accepts raw PEM format (starting with `-----BEGIN`).
### Webhook delivery strategies
Fabro receives GitHub webhooks on `POST /api/v1/webhooks/github` whenever `GITHUB_APP_WEBHOOK_SECRET` is configured. The webhook `strategy` controls how that route becomes reachable from GitHub:
```toml title="settings.toml"
[server.integrations.github]
strategy = "app"
app_id = "123456"
client_id = "Iv1.abc123def"
slug = "fabro-a3f2"
[server.api]
url = "https://fabro-api.example.com"
[server.integrations.github.webhooks]
strategy = "server_url"
```
- `server_url`: recommended for production. Fabro assumes `server.api.url` is already publicly reachable and best-effort updates the GitHub App webhook URL to `<server.api.url>/api/v1/webhooks/github` each time `fabro server start` runs.
- `tailscale_funnel`: opt-in for machines reachable through Tailscale but not through a stable public URL. Fabro runs `tailscale funnel` against the main server port and best-effort updates the GitHub App webhook URL to the resulting Funnel origin.
- Unset `strategy`: Fabro still serves the webhook route if the secret is present, but it does not expose the route for you and does not mutate the GitHub App webhook URL.
`tailscale_funnel` has host-wide side effects: it changes Tailscale Funnel state on the server and rewrites the GitHub App webhook URL on startup. Use `server_url` when you already have HTTPS and a stable hostname.
### Reconfigure GitHub after install
To switch GitHub strategies or re-register the app without re-running the full install wizard, use `fabro install github`:

View file

@ -0,0 +1,548 @@
---
title: "feat: Expose CLI lifecycle actions (cancel, archive, unarchive) in the web UI"
type: feat
status: completed
date: 2026-04-19
updated: 2026-04-20
origin: docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md
---
# feat: Expose CLI lifecycle actions (cancel, archive, unarchive) in the web UI
## Overview
Today the Fabro web UI at `apps/fabro-web` is essentially read-only for run management: only Preview mutates state. This plan adds three lifecycle actions to the run detail page (`/runs/{id}`): **cancel**, **archive**, and **unarchive**.
The plan also closes three supporting gaps the requirements and review surfaced:
- a shared action-capable toast system
- a run-detail SSE subscription so action visibility updates live
- two missing event strings in the board revalidation allowlist
As of **April 20, 2026**, product explicitly chose to remove the deferred cancel timer and undo window from the earlier requirements doc. In this plan, cancel fires immediately.
Pause/unpause and delete remain out of scope (see origin: `docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md`).
## Problem Frame
Two user pains from the origin document drive this work:
1. **Daily friction for CLI users** who live in the web UI but must context-switch to a terminal to cancel, archive, or unarchive runs.
2. **Exclusion of non-CLI teammates** (PMs, reviewers, stakeholders) who can observe runs but cannot participate in managing them.
The server endpoints already exist. The remaining work is UI surface, route wiring, SSE reconciliation, and user-facing error handling.
## Requirements Trace
All IDs reference the origin document, but this plan is the source of truth for implementation. The origin document's deferred-cancel requirements were superseded on **April 20, 2026** when product chose immediate cancel with no undo window.
- **R1** Expose cancel, archive, and unarchive on the run detail page.
- **R2** Do not expose pause, unpause, or delete in this pass.
- **R3** Do not expose checkpoint operations (resume, rewind, fork) or HITL question answering in this pass.
- **R4** Do not expose these actions on board kanban cards or as bulk selection in this pass.
- **R5** State-aware visibility:
cancel visible for `submitted|queued|starting|running|paused` as the primary affordance; archive for terminal non-archived runs; unarchive for archived runs.
- **R6** Blocked runs hide the primary cancel button and instead show an inline notice with question text plus CLI guidance. Cancel remains reachable through a de-emphasized secondary affordance inside that notice.
- **R7** The detail page subscribes to the run's SSE stream so affordances update live without a manual refresh.
- **R8** Cancel fires immediately. There is no client-side pending timer, no undo window, and no pre-fire `GET /runs/{id}` recheck in this plan.
- **R9** Archive and unarchive fire immediately and surface an inverse-action toast ("Run archived. Unarchive").
- **R10** While a lifecycle action request is in flight, only the submitting control disables/spins. There is no cross-action disable window because there is no pending cancel timer.
- **R11** There is no optimistic local archived/unarchived flip. All three actions reconcile through the action response, normal route revalidation, and SSE for later lifecycle transitions.
- **R12** On 404/409/network failures, show a user-facing mapped error toast and revalidate so the page reconciles to actual server state. Because there is no optimistic local state, there is no rollback layer.
- **R13** Multi-tab behavior has no per-client timers. SSE still reconciles non-terminal state transitions across tabs. **Known limitation:** if tab A is showing an already-terminal run and tab B archives/unarchives it, tab A may stay stale because the per-run `/attach` stream has already closed on `RunCompleted`/`RunFailed`. Users recover on navigation/refresh or on a failed action attempt surfaced through the 409/error toast path.
- **R14** Accessibility:
toasts use `role="status"` and `aria-live="polite"`, do not steal focus, action buttons are keyboard-operable and touch-friendly, and the blocked-run secondary cancel affordance expands to a 48x48 CSS px touch target on coarse pointers. Countdown-specific pause/resume behavior is no longer part of scope.
## Scope Boundaries
- Actions exposed: cancel, archive, unarchive. Nothing else.
- Surface: run detail page (`/runs/{id}`) only. Not the board, not bulk.
- Not exposed: pause, unpause, delete, force-delete, resume/rewind/fork, HITL answering.
- No OpenAPI changes.
- No new server event types.
- No changes to authorization (single-tenant trusted deployment assumption).
- No phone-size responsive layout work; tablet and up only.
- No keyboard shortcuts for individual actions.
## Context & Research
### Relevant Code and Patterns
- **Board SSE + revalidation allowlist:** `apps/fabro-web/app/routes/runs.tsx` (`BOARD_STATUS_EVENTS` at lines 63-79). `run.archived` and `run.unarchived` are missing. Test pattern in `apps/fabro-web/app/routes/runs.test.tsx`.
- **Existing per-run SSE subscriptions:** `apps/fabro-web/app/routes/run-files.tsx` and `apps/fabro-web/app/components/stage-sidebar.tsx`. Both parse `msg.data` JSON and gate on `payload.event`; they do **not** use `EventSource` message type names.
- **Run detail page header and Preview button:** `apps/fabro-web/app/routes/run-detail.tsx`. The route already uses React Router `useFetcher` plus a route `action` for Preview, so lifecycle actions can follow the same pattern instead of introducing a second mutation model.
- **Run detail loader shape:** the loader receives raw API `summary.status` and maps it onto `run.lifecycleStatus` in loader data. UI visibility logic in this plan keys off `run.lifecycleStatus`; loader-only branching is described explicitly as checking `summary.status` before mapping.
- **Existing toast primitive:** `apps/fabro-web/app/routes/run-files/states.tsx` has a simple read-only live-region toast. It is a useful visual/a11y baseline but is not reusable for stacked toasts with action buttons.
- **UI primitives:** `apps/fabro-web/app/components/ui.tsx` exports `PRIMARY_BUTTON_CLASS` and `SECONDARY_BUTTON_CLASS`. No generic `<Button>` or app-level toast system exists today.
- **API helpers:** `apps/fabro-web/app/api.ts` exports both `apiJson` and `apiFetch`. `apiJson` discards the response body on non-2xx. That is incompatible with lifecycle actions because these flows need the server error envelope for 404/409 handling. Lifecycle mutation helpers in this plan therefore use `apiFetch` and parse the body manually.
- **Status taxonomy:** `apps/fabro-web/app/data/runs.ts` exports `RunStatus`, `runStatusDisplay`, and `mapRunSummaryToRunItem`. Use those status strings rather than open-coding new ones.
- **Blocked question data:** `GET /api/v1/runs/{id}/questions` returns a `PaginatedApiQuestionList` in `docs/api-reference/fabro-api.yaml`. This plan intentionally shows the **first** pending question's `text` when the run is blocked; the blocked notice is informational only and does not attempt multi-question navigation or answering.
- **Server cancel semantics:** for `submitted`/`queued`, cancel may synchronously return a terminal failed/cancelled state; for `starting`/`running`/`blocked`/`paused`, the response may keep the same lifecycle status and the eventual transition lands later via SSE.
- **Per-run `/attach` stream limitations:** the stream closes on terminal run events and has a few other silent-termination paths. This plan accepts the existing already-terminal stale-tab limitation for archive/unarchive instead of changing the server subscription contract.
### Institutional Learnings
None. `docs/solutions/` is not seeded in this repo. Capture learnings from this work with `/ce:compound` after implementation.
### External References
Not needed. Local patterns cover React Router actions, SSE subscriptions, and route-level data loading.
## Key Technical Decisions
- **Immediate cancel, no undo.** Product explicitly removed the deferred timer on April 20, 2026. Cancel now behaves like a normal immediate mutation with passive feedback.
- **One mutation pattern for all three lifecycle actions.** Cancel, archive, and unarchive all use React Router `useFetcher` plus the `run-detail.tsx` route `action`, extending the existing Preview route-action pattern with an `intent` dispatch instead of mixing `fetcher` and direct click-handler fetches.
- **Lifecycle mutation helpers use `apiFetch`, not `apiJson`.** These flows need access to the error response body for 404/409 mapping. `run-actions.ts` owns that parsing and returns or throws typed shapes the route action can consume.
- **Archive/unarchive are not optimistic.** The UI shows normal in-flight disabled state during submission, then relies on action completion + route revalidation to reflect the archived state. This is simpler and matches current app patterns.
- **Shared app-shell toast provider is still the right shape.** With the cancel undo window removed, the earlier focus-order and route-lifetime problems disappear. The provider only needs to support passive toasts and inverse-action toasts.
- **The new shared SSE hook is for code reuse, not socket deduplication.** `useRunEventSource` standardizes payload parsing, allowlist gating, debounce behavior, and cleanup across call sites. It still opens one `EventSource` per caller; the plan no longer claims otherwise.
- **Blocked-run question text is loaded by the run-detail route, not by a component-local fetch.** When the loader sees raw API `summary.status === "blocked"` before mapping that value to `run.lifecycleStatus`, it fetches the first pending question and returns `blockedQuestionText` alongside the run summary. `blocked-run-notice.tsx` stays presentational.
- **Blocked-run secondary cancel is an inline text affordance, not an overflow menu.** This satisfies the product intent of a de-emphasized escape hatch while avoiding unnecessary menu infrastructure.
- **Post-terminal archive/unarchive reconciliation stays response-driven.** The plan does not reconnect `/attach` after terminal closure. Success responses and normal route revalidation handle the local tab; the known already-terminal stale-tab limitation is explicitly accepted.
## Open Questions
### Resolved During Planning
- **Cancel interaction model:** immediate-fire, no undo, per April 20, 2026 product decision.
- **Mutation transport:** all lifecycle actions use `useFetcher` plus route `action` dispatch in `run-detail.tsx`.
- **Error-body parsing:** use `apiFetch` inside `run-actions.ts`; do not use `apiJson` for lifecycle mutations.
- **Blocked question source:** fetch the first pending question in the run-detail loader when raw API `summary.status` is `blocked` before it is mapped to `run.lifecycleStatus`.
- **Blocked cancel affordance shape:** inline muted text link inside the blocked notice rather than a menu.
### Deferred to Implementation
- Exact toast z-index and portal strategy if stacking interacts with the legacy toast in `run-files/states.tsx`.
- Exact user-facing copy for each mapped 4xx/409 condition. Minimum set:
cancel-409, archive-409, unarchive-409, 404, and generic network failure.
- Exact visual treatment of the archive/unarchive inverse-action toast when the user clicks the toast action immediately after the first mutation settles.
- Whether unarchive success uses a symmetric inverse-action toast (`Archive`) or a short passive "Run restored." toast.
## High-Level Technical Design
> This section is directional guidance for implementation and review, not code to copy verbatim.
### Module layout (new + modified, repo-relative)
```text
apps/fabro-web/app/
├── components/
│ ├── toast.tsx NEW Toast, ToastProvider, useToast, ToastRoot
│ └── blocked-run-notice.tsx NEW Presentational blocked notice
├── lib/
│ ├── sse.ts NEW useRunEventSource(runId, { allowlist, debounceMs, onEvent? })
│ └── run-actions.ts NEW cancel/archive/unarchive request helpers, status predicates, error mapping
├── layouts/
│ └── app-shell.tsx MOD Mount ToastProvider once
└── routes/
├── run-detail.tsx MOD Loader adds blockedQuestionText; action dispatches lifecycle intents; UI renders actions and toast integration
├── run-detail.test.tsx NEW or extend if created during implementation
├── runs.tsx MOD Extend BOARD_STATUS_EVENTS
└── runs.test.tsx MOD Add allowlist cases
```
### Immediate cancel flow
```mermaid
sequenceDiagram
participant User
participant UI as Cancel Button
participant Action as Route action
participant API as POST /cancel
participant Toast
participant SSE as useRunEventSource
User->>UI: click Cancel
UI->>Action: submit intent=cancel
Action->>API: POST /runs/{id}/cancel
alt 200 with terminal failed/cancelled state
API-->>Action: RunStatusResponse
Action-->>UI: { ok: true, run }
UI->>Toast: "Run cancelled."
else 200 with unchanged in-progress state
API-->>Action: RunStatusResponse
Action-->>UI: { ok: true, run }
UI->>Toast: "Cancellation requested."
SSE-->>UI: later run.failed / equivalent transition
else 404 / 409 / network failure
API-->>Action: ErrorResponse
Action-->>UI: { ok: false, error }
UI->>Toast: mapped error copy
end
```
### Archive / unarchive inverse-action flow
```mermaid
sequenceDiagram
participant User
participant UI as Archive Button
participant Action as Route action
participant API as POST /archive
participant Toast
User->>UI: click Archive
UI->>Action: submit intent=archive
Action->>API: POST /runs/{id}/archive
alt 200
API-->>Action: RunStatusResponse
Action-->>UI: { ok: true, run }
UI->>Toast: "Run archived." + Unarchive action
opt user clicks Unarchive in toast
User->>UI: click Unarchive toast action
UI->>Action: submit intent=unarchive
end
else 404 / 409 / network failure
API-->>Action: ErrorResponse
Action-->>UI: { ok: false, error }
UI->>Toast: mapped error copy
end
```
## Implementation Units
```mermaid
graph TB
U1[Unit 1: Board allowlist additions]
U2[Unit 2: Shared toast infrastructure]
U3[Unit 3: Shared SSE hook and migration]
U4[Unit 4: Lifecycle action helpers and route action dispatch]
U5[Unit 5: Immediate cancel UI]
U6[Unit 6: Archive/unarchive UI]
U7[Unit 7: Blocked-run notice]
U2 --> U5
U2 --> U6
U3 --> U5
U3 --> U6
U3 --> U7
U4 --> U5
U4 --> U6
U4 --> U7
U5 --> U7
```
---
- [x] **Unit 1: Extend `BOARD_STATUS_EVENTS` with `run.archived` and `run.unarchived`**
**Goal:** Ensure the board revalidates when a run is archived or unarchived from any source.
**Requirements:** R7, R13.
**Dependencies:** None.
**Files:**
- Modify: `apps/fabro-web/app/routes/runs.tsx`
- Modify: `apps/fabro-web/app/routes/runs.test.tsx`
**Approach:**
- Add `run.archived` and `run.unarchived` to `BOARD_STATUS_EVENTS`.
- Leave debounce/revalidator wiring unchanged.
**Patterns to follow:**
- Existing allowlist structure in `apps/fabro-web/app/routes/runs.tsx`
- Existing allowlist tests in `apps/fabro-web/app/routes/runs.test.tsx`
**Test scenarios:**
- `shouldRefreshBoardForEvent("run.archived")` returns `true`.
- `shouldRefreshBoardForEvent("run.unarchived")` returns `true`.
- `shouldRefreshBoardForEvent("run.created")` remains `false`.
**Verification:**
- `runs.test.tsx` passes.
- Manual: archive a terminal run from outside the board and confirm `/runs` updates without a manual refresh.
---
- [x] **Unit 2: Shared toast infrastructure (`ToastProvider`, `useToast`)**
**Goal:** Introduce a stackable app-level toast system for passive toasts, error toasts, and inverse-action toasts.
**Requirements:** R9, R12, R14.
**Dependencies:** None.
**Files:**
- Create: `apps/fabro-web/app/components/toast.tsx`
- Create: `apps/fabro-web/app/components/toast.test.tsx`
- Modify: `apps/fabro-web/app/layouts/app-shell.tsx`
**Approach:**
- Export `ToastProvider`, `useToast()`, and a provider-owned root container.
- `useToast()` exposes `push`, `dismiss`, and `clear`.
- Supported toast shapes:
passive info toast, error toast, and action toast with a single button.
- Root renders a stacked bottom-right container with `role="status"` and `aria-live="polite"`.
- Do not steal focus on mount.
- Error toasts are sticky until dismissed.
- Non-error toasts may auto-dismiss with a short TTL.
- Action buttons meet the minimum touch target via padding or explicit size classes.
**Patterns to follow:**
- Existing `Toast` live-region semantics in `apps/fabro-web/app/routes/run-files/states.tsx`
**Test scenarios:**
- `push({ message })` renders a toast with the message.
- `push({ action: { label, onClick } })` renders an actionable button and fires `onClick`.
- Error toasts do not auto-dismiss.
- Multiple toasts stack in insertion order.
- `dismiss()` removes a toast and leaves the rest reflowed.
- The provider mounted in `app-shell.tsx` is accessible from descendant route components.
**Verification:**
- `toast.test.tsx` passes.
- `bun run typecheck` passes.
---
- [x] **Unit 3: Shared SSE hook (`useRunEventSource`) and migration of existing call sites**
**Goal:** Standardize run-scoped SSE parsing and revalidation behavior across the app.
**Requirements:** R7, R13.
**Dependencies:** None runtime; Units 5 and 6 consume this.
**Files:**
- Create: `apps/fabro-web/app/lib/sse.ts`
- Create: `apps/fabro-web/app/lib/sse.test.ts`
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
- Modify: `apps/fabro-web/app/routes/run-files.tsx`
- Modify: `apps/fabro-web/app/components/stage-sidebar.tsx`
**Approach:**
- Export `useRunEventSource(runId, { allowlist, debounceMs = 300, onEvent? })`.
- Internally:
open `/api/v1/runs/${runId}/attach?since_seq=1`, parse `msg.data`, read `payload.event`, and gate both `revalidator.revalidate()` and `onEvent(payload)` on the allowlist.
- Cleanup closes the `EventSource` and pending debounce timer.
- The plan intentionally describes this as code reuse and behavior standardization, not socket deduplication.
- `run-detail.tsx` should subscribe to the lifecycle events that can change button visibility for the current run.
- `run-files.tsx` and `stage-sidebar.tsx` keep their current event sets and debounce timing.
- `run-detail.tsx` is the subscriber that makes the blocked notice in Unit 7 disappear when the run leaves `blocked`.
**Patterns to follow:**
- Existing SSE shapes in `apps/fabro-web/app/routes/run-files.tsx`
- Existing SSE shapes in `apps/fabro-web/app/components/stage-sidebar.tsx`
**Test scenarios:**
- Allowlisted `payload.event` triggers debounced revalidation.
- Non-allowlisted events are ignored.
- `onEvent` receives the parsed payload for allowlisted events.
- Unmount closes the source and clears any timer.
- The migrated `run-files.tsx` still refreshes for `checkpoint.completed`.
- The migrated `stage-sidebar.tsx` still refreshes for stage events.
**Verification:**
- `sse.test.ts` passes.
- Existing `run-files` and `stage-sidebar` tests continue to pass.
---
- [x] **Unit 4: Lifecycle action helpers and route action dispatch**
**Goal:** Centralize request handling, error parsing, and status predicates for cancel/archive/unarchive.
**Requirements:** R5, R8, R9, R11, R12.
**Dependencies:** None.
**Files:**
- Create: `apps/fabro-web/app/lib/run-actions.ts`
- Create: `apps/fabro-web/app/lib/run-actions.test.ts`
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
**Approach:**
- `run-actions.ts` owns:
`cancelRun(id, request)`, `archiveRun(id, request)`, `unarchiveRun(id, request)`,
`mapError(error, action)`,
and `canCancel`, `canArchive`, `canUnarchive`.
- These helpers use `apiFetch`, not `apiJson`, so the error body is still available on 404/409.
- Introduce a small typed error shape such as:
`{ status: number; errors: ErrorResponseEntry[] }`.
- If the error body is absent or non-JSON (for example, a proxy HTML error page), parse fallback should return `{ status, errors: [] }` so `mapError` can fall back to generic copy instead of throwing.
- Extend the route `action` in `run-detail.tsx` to dispatch on `intent=preview|cancel|archive|unarchive`.
- Because Preview is no longer the implicit default path, add a hidden `intent=preview` input to the existing Preview form so the dispatch routes it explicitly.
- Return a typed action result payload for lifecycle submissions so the component can toast on settle.
**Patterns to follow:**
- Existing `useFetcher` + route `action` pattern already used for Preview in `run-detail.tsx`
**Test scenarios:**
- `cancelRun`, `archiveRun`, and `unarchiveRun` parse 200 responses correctly.
- 404 and 409 responses preserve the parsed error envelope.
- Non-JSON error bodies fall back to `{ status, errors: [] }` rather than throwing during parsing.
- `mapError` returns user-facing copy for cancel/archive/unarchive conflict states.
- Status predicates use lifecycle status strings from `data/runs.ts`, not a new local taxonomy.
- The route `action` dispatches correctly for each lifecycle `intent`.
- Preview still submits through the same route action via `intent=preview`; cover that regression in `run-detail.test.tsx`.
**Verification:**
- `run-actions.test.ts` passes.
- `bun run typecheck` passes.
---
- [x] **Unit 5: Immediate cancel UI**
**Goal:** Add the cancel button to the run detail header using the same route-action/fetcher pattern as the other lifecycle actions.
**Requirements:** R5, R8, R10, R11, R12.
**Dependencies:** Units 2, 3, and 4.
**Files:**
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
- Modify/Create: `apps/fabro-web/app/routes/run-detail.test.tsx`
**Approach:**
- Render cancel when `canCancel(run.lifecycleStatus)` is true and the run is not blocked.
- Use `cancelFetcher.Form` with `intent=cancel`.
- Disable only the cancel control while its submission is in flight.
- On successful settle:
if the returned run is already terminal failed/cancelled, show a passive "Run cancelled." toast;
otherwise show "Cancellation requested." and rely on SSE + normal revalidation for the final flip.
- On 404/409/network failure, show `mapError(error, "cancel")` in an error toast.
**Patterns to follow:**
- Preview button layout and fetcher pattern in `apps/fabro-web/app/routes/run-detail.tsx`
- `SECONDARY_BUTTON_CLASS` from `apps/fabro-web/app/components/ui.tsx`
**Test scenarios:**
- Cancel renders for `submitted`, `queued`, `starting`, `running`, and `paused`.
- Cancel is hidden for `blocked`, `succeeded`, `failed`, `dead`, and `archived`.
- Submitting cancel sends `intent=cancel` through the route action.
- While the cancel submission is pending, only that button disables.
- A terminal success response shows "Run cancelled."
- An async success response shows "Cancellation requested."
- 404/409 responses show the mapped error toast.
**Verification:**
- `run-detail.test.tsx` passes with cancel cases.
- Manual: cancel a long-running run from `/runs/{id}` and confirm the toast and later lifecycle update behavior.
---
- [x] **Unit 6: Archive / unarchive UI**
**Goal:** Add archive and unarchive controls plus the inverse-action toast flow.
**Requirements:** R5, R9, R10, R11, R12.
**Dependencies:** Units 2, 3, and 4.
**Files:**
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
- Modify: `apps/fabro-web/app/routes/run-detail.test.tsx`
**Approach:**
- Render archive when `canArchive(run.lifecycleStatus)` is true.
- Render unarchive when `canUnarchive(run.lifecycleStatus)` is true.
- Use dedicated fetchers or one intent-aware fetcher for both actions.
- No optimistic local state; rely on standard submission state and route revalidation.
- On archive success, push an action toast with `Unarchive`.
- The toast action should capture the route-scoped fetcher submission at push time, e.g. `onClick: () => unarchiveFetcher.submit(...)`, rather than trying to host a route `<Form>` inside the app-shell toast tree.
- On unarchive success, show the success-toast variant chosen in Deferred to Implementation below.
- On 404/409/network failure, show the mapped error toast.
**Patterns to follow:**
- Same route-action/fetcher pattern as Unit 5
**Test scenarios:**
- Archive renders only for terminal non-archived runs.
- Unarchive renders only for archived runs.
- Submitting archive/unarchive dispatches the correct `intent`.
- Buttons disable while their own submission is pending.
- Archive success shows "Run archived." with an Unarchive action.
- Clicking the toast's Unarchive action submits the unarchive flow.
- 404/409 failures show mapped error toasts.
**Verification:**
- `run-detail.test.tsx` passes with archive/unarchive cases.
- Manual: archive a terminal run from the detail page, then restore it from the toast action.
---
- [x] **Unit 7: Blocked-run notice + secondary cancel affordance**
**Goal:** Show non-CLI users why the run is blocked without presenting cancel as the main action.
**Requirements:** R6, R14.
**Dependencies:** Units 3, 4, and 5.
**Files:**
- Create: `apps/fabro-web/app/components/blocked-run-notice.tsx`
- Create: `apps/fabro-web/app/components/blocked-run-notice.test.tsx`
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
- Modify: `apps/fabro-web/app/routes/run-detail.test.tsx`
**Approach:**
- In the run-detail loader:
when raw API `summary.status === "blocked"` before it is mapped to `run.lifecycleStatus`, fetch the first pending question from `/api/v1/runs/{id}/questions?page[limit]=1&page[offset]=0` and return `blockedQuestionText`.
- `blocked-run-notice.tsx` is presentational: it renders the question text when available, otherwise fallback copy, plus the muted "Cancel run anyway." secondary control.
- The secondary control reuses the same cancel submission path as Unit 5.
- The control uses the coarse-pointer hit-area expansion pattern from R14.
- Loader-branch tests for `blockedQuestionText` live in `run-detail.test.tsx`; `blocked-run-notice.test.tsx` stays focused on presentational behavior.
**Patterns to follow:**
- Visual banner treatment inspired by `InlineErrorBanner` in `apps/fabro-web/app/routes/run-files/states.tsx`
**Test scenarios:**
- Blocked runs render the question text when the loader returns it.
- Blocked runs render fallback copy when no question is available.
- Blocked runs do not show the primary cancel button.
- Clicking "Cancel run anyway." submits the same cancel path as Unit 5.
- The notice disappears when the lifecycle status is no longer blocked after revalidation/SSE.
**Verification:**
- `blocked-run-notice.test.tsx` passes.
- Manual: open a blocked run, verify the question text and secondary cancel affordance.
## System-Wide Impact
- **Interaction model:** lifecycle actions now share one route-action/fetcher model instead of mixing fetcher submissions and direct handler fetches.
- **Error propagation:** lifecycle mutation helpers preserve and parse server error bodies via `apiFetch`.
- **State reconciliation:** immediate action responses, normal route revalidation, and live SSE updates remain the only state sources. There is no local optimistic layer and no client-side pending cancel timer.
- **Known limitation retained:** archive/unarchive on already-terminal runs can leave another open tab stale until refresh because `/attach` has already closed. This plan documents the limitation rather than changing the server contract.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| Toast provider overlaps the legacy fixed-position toast in `run-files/states.tsx`. | Start with simple stacking and adjust z-index or migrate the older toast later if visual overlap proves real. |
| The shared SSE refactor is over-read as socket deduplication work. | The plan states explicitly that Unit 3 is code reuse and behavior standardization only. |
| Error mapping drifts from server copy. | Keep the mapped strings in one place in `run-actions.ts`; only use server detail text as a fallback for unexpected cases. |
| Blocked runs may have multiple pending questions. | The plan intentionally shows only the first question because this surface is informational, not an answering workflow. |
| `scripts/refresh-fabro-spa.sh` is forgotten before commit. | Call it out in implementation and PR notes whenever `apps/fabro-web` changes ship. |
## Documentation / Operational Notes
- **PR description** should call out:
scope = cancel/archive/unarchive only;
immediate cancel with no undo;
new `ToastProvider`;
new run-detail SSE subscription;
`scripts/refresh-fabro-spa.sh` was run.
- **Accessibility audit before merge:**
1. Tab through the detail-page action row and confirm cancel/archive/unarchive are keyboard-operable.
2. Trigger cancel and confirm the passive toast appears without stealing focus.
3. Trigger archive and confirm the inverse-action toast button is reachable by keyboard and touch-friendly.
4. Open a blocked run and confirm the secondary "Cancel run anyway." affordance meets the coarse-pointer hit-area requirement.
## Sources & References
- **Origin document:** [docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md](docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md)
- Key repo files:
`apps/fabro-web/app/routes/run-detail.tsx`,
`apps/fabro-web/app/routes/runs.tsx`,
`apps/fabro-web/app/routes/run-files.tsx`,
`apps/fabro-web/app/components/stage-sidebar.tsx`,
`apps/fabro-web/app/routes/run-files/states.tsx`,
`apps/fabro-web/app/api.ts`,
`apps/fabro-web/app/components/ui.tsx`,
`apps/fabro-web/app/layouts/app-shell.tsx`
- API spec:
`docs/api-reference/fabro-api.yaml`
- Related plan:
`docs/plans/2026-04-19-001-feat-archived-run-status-plan.md`

View file

@ -190,6 +190,24 @@ Examples:
- exact SHA lineage across rewind/fork
- exact file existence semantics
### Use shared helpers for HTTP status assertions
When a Rust test asserts on an HTTP response status, use the shared helpers in
`fabro-test` instead of `assert_eq!(response.status(), ...)`.
- Use the helper that matches the transport:
- `expect_axum_*` / `assert_axum_*` for Axum `oneshot` responses
- `expect_reqwest_*` / `assert_reqwest_*` for `fabro_http` / reqwest responses
- Use the helper that matches what the test needs next:
- `*_status` when you only care about the status
- `*_json` when a successful response should parse as JSON
- `*_text` / `*_bytes` when the test inspects the raw body
- Pass a context string that names the request shape, for example
`GET /api/v1/runs/{id}/graph`.
These helpers preserve successful responses but, on failure, include the
status, relevant headers, URL when available, and a readable body preview.
## Snapshot rules
- Prefer inline snapshots unless the payload is too large to read comfortably.

View file

@ -48,7 +48,7 @@ pub(crate) fn remove_server_record(path: &Path) {
}
pub(crate) fn server_record_is_running(record: &ServerRecord) -> bool {
fabro_proc::process_alive(record.pid) && server_process_matches(record)
fabro_proc::process_running(record.pid) && server_process_matches(record)
}
fn server_record_path(storage_dir: &Path) -> PathBuf {

View file

@ -19,14 +19,14 @@ pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> Result
let poll_interval = Duration::from_millis(100);
let mut elapsed = Duration::ZERO;
while elapsed < timeout {
if !fabro_proc::process_alive(record.pid) {
if !fabro_proc::process_running(record.pid) {
break;
}
time::sleep(poll_interval).await;
elapsed += poll_interval;
}
if fabro_proc::process_alive(record.pid) {
if fabro_proc::process_running(record.pid) {
fabro_proc::sigkill(record.pid);
time::sleep(Duration::from_millis(100)).await;
}

View file

@ -8,7 +8,9 @@ use std::process::{Output, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use fabro_test::{apply_filters, fabro_snapshot, test_context};
use fabro_test::{
apply_filters, assert_reqwest_status, expect_reqwest_json, fabro_snapshot, test_context,
};
use serde_json::Value;
use super::support::{
@ -31,15 +33,12 @@ async fn wait_for_server_question(
.send()
.await
.expect("question request should succeed");
assert!(
response.status().is_success(),
"question request failed: {}",
response.status()
);
let body: Value = response
.json()
.await
.expect("question response should parse");
let body: Value = expect_reqwest_json(
response,
fabro_http::StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions?page[limit]=100&page[offset]=0"),
)
.await;
if let Some(question) = body["data"].as_array().and_then(|items| items.first()) {
return question.clone();
}
@ -860,7 +859,12 @@ fn attach_json_errors_without_prompting_for_human_input() {
.send()
.await
.expect("answer submission should succeed");
assert_eq!(response.status(), fabro_http::StatusCode::NO_CONTENT);
assert_reqwest_status(
response,
fabro_http::StatusCode::NO_CONTENT,
format!("POST /api/v1/runs/{run_id}/questions/{question_id}/answer"),
)
.await;
});
wait_for_status(&run.run_dir, &["succeeded"]);
}

View file

@ -12,7 +12,7 @@ use std::process::{Child, ExitStatus, Output, Stdio};
use std::time::{Duration, Instant};
use fabro_store::EventEnvelope;
use fabro_test::{fabro_snapshot, test_context};
use fabro_test::{assert_reqwest_status, expect_reqwest_json, fabro_snapshot, test_context};
use fabro_types::{EventBody, RunEvent, StatusReason};
use httpmock::MockServer;
@ -133,15 +133,12 @@ async fn wait_for_server_question(
.send()
.await
.expect("question request should succeed");
assert!(
response.status().is_success(),
"question request failed: {}",
response.status()
);
let body: serde_json::Value = response
.json()
.await
.expect("question response should parse");
let body: serde_json::Value = expect_reqwest_json(
response,
fabro_http::StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions?page[limit]=100&page[offset]=0"),
)
.await;
if let Some(question) = body["data"].as_array().and_then(|items| items.first()) {
return question.clone();
}
@ -607,7 +604,12 @@ fn detached_run_answers_pending_question_without_interview_scratch_files() {
.send()
.await
.expect("answer submission should succeed");
assert_eq!(response.status(), fabro_http::StatusCode::NO_CONTENT);
assert_reqwest_status(
response,
fabro_http::StatusCode::NO_CONTENT,
format!("POST /api/v1/runs/{run_id}/questions/{question_id}/answer"),
)
.await;
question_id
});

View file

@ -17,7 +17,7 @@ use std::time::{Duration, Instant};
use fabro_config::Storage;
use fabro_server::bind::Bind;
use fabro_store::EventEnvelope;
use fabro_test::TestContext;
use fabro_test::{TestContext, expect_reqwest_status};
use fabro_types::RunId;
use serde_json::Value;
use shlex::try_quote;
@ -737,7 +737,8 @@ async fn try_get_server_json_for_storage<T: serde::de::DeserializeOwned>(
) -> Option<T> {
let (client, base_url) = server_endpoint(storage_dir)?;
let response = client.get(format!("{base_url}{path}")).send().await.ok()?;
if !response.status().is_success() {
let status = response.status();
if status != fabro_http::StatusCode::OK {
return None;
}
response.json::<T>().await.ok()
@ -753,11 +754,8 @@ async fn get_server_json_for_storage<T: serde::de::DeserializeOwned>(
.send()
.await
.expect("server request should succeed");
assert!(
response.status().is_success(),
"server request failed for {path}: {}",
response.status()
);
let response =
expect_reqwest_status(response, fabro_http::StatusCode::OK, format!("GET {path}")).await;
response
.json::<T>()
.await

View file

@ -14,6 +14,8 @@ mod smoke;
use std::path::{Path, PathBuf};
use std::time::Duration;
use fabro_test::expect_reqwest_status;
use crate::cmd::support::{RunProjection, server_endpoint};
pub(super) fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
@ -47,11 +49,8 @@ async fn get_server_json_for_storage<T: serde::de::DeserializeOwned>(
.send()
.await
.expect("server request should succeed");
assert!(
response.status().is_success(),
"server request failed for {path}: {}",
response.status()
);
let response =
expect_reqwest_status(response, fabro_http::StatusCode::OK, format!("GET {path}")).await;
response
.json::<T>()
.await

View file

@ -17,7 +17,7 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use fabro_store::EventEnvelope;
use fabro_test::TestContext;
use fabro_test::{TestContext, expect_reqwest_status};
use serde_json::Value;
use crate::cmd::support::{RunProjection, server_endpoint};
@ -118,11 +118,8 @@ async fn get_server_json_for_storage<T: serde::de::DeserializeOwned>(
.send()
.await
.expect("server request should succeed");
assert!(
response.status().is_success(),
"server request failed for {path}: {}",
response.status()
);
let response =
expect_reqwest_status(response, fabro_http::StatusCode::OK, format!("GET {path}")).await;
response
.json::<T>()
.await

View file

@ -1,15 +1,16 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
DiscordIntegrationSettings, GithubIntegrationSettings, IntegrationWebhooksLayer,
IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer, ObjectStoreProvider,
ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer, ServerApiSettings,
ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthLayer,
ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer, ServerIntegrationsSettings,
ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerIpAllowlistOverrideSettings,
ServerIpAllowlistSettings, ServerLayer, ServerListenLayer, ServerListenSettings,
ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, ServerSlateDbLayer,
ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, ServerWebLayer,
ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings,
DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy,
IntegrationWebhooksLayer, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer,
ObjectStoreProvider, ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer,
ServerApiSettings, ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings,
ServerAuthLayer, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer,
ServerIntegrationsSettings, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer,
ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerLayer, ServerListenLayer,
ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings, ServerSettings,
ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings,
ServerWebLayer, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings,
WebhookStrategy,
};
use fabro_util::Home;
@ -25,6 +26,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
let integrations = resolve_integrations(layer.integrations.as_ref(), errors);
validate_ip_allowlist_for_listen(&listen, &ip_allowlist, errors);
validate_github_webhook_ip_allowlist_for_listen(&listen, &ip_allowlist, &integrations, errors);
validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors);
ServerSettings {
listen,
@ -294,6 +296,40 @@ fn validate_github_webhook_ip_allowlist_for_listen(
}
}
fn validate_github_webhook_strategy(
integrations: &ServerIntegrationsSettings,
api_layer: Option<&ServerApiLayer>,
errors: &mut Vec<ResolveError>,
) {
let github = &integrations.github;
let strategy = github
.webhooks
.as_ref()
.and_then(|webhooks| webhooks.strategy);
if strategy.is_some()
&& github.strategy == GithubIntegrationStrategy::App
&& github.app_id.is_none()
{
errors.push(ResolveError::Invalid {
path: "server.integrations.github.app_id".to_string(),
reason: "must be set when server.integrations.github.webhooks.strategy is configured"
.to_string(),
});
}
if matches!(strategy, Some(WebhookStrategy::ServerUrl))
&& api_layer.and_then(|api| api.url.as_ref()).is_none()
{
errors.push(ResolveError::Invalid {
path: "server.api.url".to_string(),
reason:
"must be set when server.integrations.github.webhooks.strategy = \"server_url\""
.to_string(),
});
}
}
fn resolve_artifacts(
layer: Option<&ServerArtifactsLayer>,
storage_root: &InterpString,

View file

@ -298,6 +298,56 @@ trusted_proxy_count = 3
assert_eq!(webhook_allowlist.trusted_proxy_count, Some(3));
}
#[test]
fn rejects_server_url_webhook_strategy_without_server_api_url() {
let file = parse(
r#"
_version = 1
[server.integrations.github]
strategy = "app"
[server.integrations.github.webhooks]
strategy = "server_url"
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("server_url webhook strategy should require server.api.url");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("server.api.url"));
}
#[test]
fn rejects_configured_webhook_strategy_without_github_app_id() {
let file = parse(
r#"
_version = 1
[server.integrations.github]
strategy = "app"
[server.integrations.github.webhooks]
strategy = "tailscale_funnel"
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("configured webhook strategy should require server.integrations.github.app_id");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("server.integrations.github.app_id"));
}
#[test]
fn rejects_invalid_ip_allowlist_entry() {
let file = parse(

View file

@ -810,6 +810,44 @@ pub async fn get_authenticated_app(
.map_err(|e| format!("Failed to parse GitHub App info: {e}"))
}
/// Update a GitHub App's webhook URL via `PATCH /app/hook/config`.
///
/// Signs an App JWT and sets the webhook endpoint and content type.
pub async fn update_app_webhook_config(
app_id: &str,
private_key_pem: &str,
webhook_url: &str,
) -> Result<(), String> {
let jwt = sign_app_jwt(app_id, private_key_pem)?;
let client = http_client()?;
let url = format!("{}/app/hook/config", github_api_base_url());
let auth = format!("Bearer {jwt}");
let body = serde_json::json!({
"url": webhook_url,
"content_type": "json",
});
let resp = HttpClient::request(
&client,
HttpMethod::Patch,
&url,
&github_headers(&auth),
Some(&body),
)
.await
.map_err(|e| format!("Failed to update GitHub App webhook: {e}"))?;
if !(200..300).contains(&resp.status) {
return Err(format!(
"GitHub API returned {}: {}",
resp.status,
resp.text()
));
}
Ok(())
}
/// Check whether a GitHub App is publicly visible.
///
/// Calls `GET /apps/{slug}` **without** authentication. Public apps return 200,

View file

@ -28,3 +28,4 @@ open = "5"
[dev-dependencies]
httpmock = "0.8"
tokio = { workspace = true, features = ["test-util", "macros"] }
fabro-test = { workspace = true }

View file

@ -422,6 +422,8 @@ pub async fn run_browser_flow(
#[cfg(test)]
mod tests {
use fabro_test::assert_reqwest_status;
use super::*;
fn test_http_client() -> fabro_http::HttpClient {
@ -843,7 +845,7 @@ mod tests {
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_reqwest_status(resp, StatusCode::OK, "GET /oauth/done").await;
}
#[tokio::test]
@ -862,7 +864,7 @@ mod tests {
.await
.unwrap();
assert_eq!(resp.status(), 400);
assert_reqwest_status(resp, StatusCode::BAD_REQUEST, "GET /oauth/done").await;
}
#[tokio::test]

View file

@ -18,7 +18,7 @@ pub use pre_exec::pre_exec_pdeathsig;
pub use pre_exec::pre_exec_setpgid;
#[cfg(unix)]
pub use pre_exec::pre_exec_setsid;
pub use signal::{process_alive, process_group_alive};
pub use signal::{process_exists, process_group_alive, process_running};
#[cfg(unix)]
pub use signal::{
sigkill, sigkill_process_group, sigterm, sigterm_process_group, sigusr1, sigusr2,

View file

@ -1,8 +1,8 @@
/// Check whether a process with the given PID is alive.
/// Check whether a process with the given PID currently exists.
///
/// On Unix, sends signal 0 via `kill(2)`. Returns `false` if the pid does not
/// fit in `i32`. On non-Unix platforms, conservatively returns `true`.
pub fn process_alive(pid: u32) -> bool {
pub fn process_exists(pid: u32) -> bool {
#[cfg(unix)]
{
let Ok(pid) = i32::try_from(pid) else {
@ -18,6 +18,46 @@ pub fn process_alive(pid: u32) -> bool {
}
}
/// Check whether a process with the given PID is still running.
///
/// On Unix, this treats zombie / defunct processes as not running even though
/// they still have a visible PID until their parent reaps them. If the
/// follow-up `ps` probe fails, this falls back to `process_exists(pid)` to
/// preserve the old conservative behavior.
pub fn process_running(pid: u32) -> bool {
#[cfg(unix)]
{
if !process_exists(pid) {
return false;
}
unix_process_state(pid).is_none_or(|state| !matches!(state, 'Z' | 'z'))
}
#[cfg(not(unix))]
{
process_exists(pid)
}
}
#[cfg(unix)]
#[expect(
clippy::disallowed_methods,
reason = "Unix process-state detection shells out to ps to distinguish running processes from zombies"
)]
fn unix_process_state(pid: u32) -> Option<char> {
let output = std::process::Command::new("ps")
.args(["-ww", "-o", "stat=", "-p", &pid.to_string()])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.chars()
.find(|ch| !ch.is_whitespace())
}
/// Check whether any process in the given process group is alive.
///
/// On Unix, sends signal 0 to `-pgid` via `kill(2)`. Returns `false` if the
@ -104,3 +144,109 @@ pub fn sigusr2(pid: u32) {
}
}
}
#[cfg(test)]
mod tests {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::time::Duration;
use super::{process_exists, process_group_alive, process_running};
#[test]
fn process_running_returns_true_for_current_process() {
assert!(process_exists(std::process::id()));
assert!(process_running(std::process::id()));
}
#[cfg(unix)]
#[test]
#[expect(
clippy::disallowed_methods,
reason = "process-state test needs to spawn a short-lived child and intentionally leave it unreaped"
)]
fn process_running_returns_false_for_unreaped_zombie_child() {
let mut child = Command::new("sh")
.args(["-c", "exit 0"])
.spawn()
.expect("short-lived child should spawn");
let pid = child.id();
std::thread::sleep(Duration::from_millis(100));
assert!(
process_exists(pid),
"unreaped zombie should still have a visible pid"
);
assert!(
!process_running(pid),
"unreaped zombie should not count as a running process"
);
let _status = child.wait().expect("child should remain waitable");
}
#[cfg(unix)]
#[test]
#[expect(
clippy::disallowed_methods,
reason = "process-group test spawns a child in its own process group and observes the group probe"
)]
fn process_group_alive_returns_true_for_running_process_group() {
let mut child = Command::new("sh");
child
.args(["-c", "sleep 5"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::pre_exec::pre_exec_setpgid(&mut child);
let mut child = child.spawn().expect("group leader should spawn");
let pgid = child.id();
assert!(
process_group_alive(pgid),
"running process group should count as alive"
);
let _ = child.kill();
let _ = child.wait();
}
#[cfg(unix)]
#[test]
#[expect(
clippy::disallowed_methods,
reason = "process-group zombie test uses a short perl helper that forks without reaping its child"
)]
fn process_group_alive_returns_false_for_zombie_only_process_group() {
let mut parent = Command::new("perl");
parent
.args([
"-MPOSIX",
"-e",
r#"$|=1; $pid=fork(); die $! unless defined $pid; if(!$pid){ POSIX::setpgid(0,0) or die $!; exit 0 } print "$pid\n"; sleep 5;"#,
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let mut parent = parent.spawn().expect("zombie parent helper should spawn");
let stdout = parent.stdout.take().expect("helper stdout should be piped");
let mut lines = BufReader::new(stdout).lines();
let child_pid = lines
.next()
.expect("helper should print child pid")
.expect("helper child pid should read")
.parse::<u32>()
.expect("helper child pid should parse");
std::thread::sleep(Duration::from_millis(100));
assert!(
!process_group_alive(child_pid),
"zombie-only process group should not count as alive"
);
let _ = parent.kill();
let _ = parent.wait();
}
}

View file

@ -87,3 +87,4 @@ tracing-subscriber.workspace = true
async-trait.workspace = true
tokio-util.workspace = true
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
fabro-test = { workspace = true }

View file

@ -18,6 +18,16 @@ pub enum BindRequest {
TcpHost(IpAddr),
}
impl Bind {
/// Port of a TCP bind, or `None` for a Unix socket.
pub fn tcp_port(&self) -> Option<u16> {
match self {
Self::Tcp(addr) => Some(addr.port()),
Self::Unix(_) => None,
}
}
}
impl fmt::Display for Bind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {

View file

@ -1,27 +1,21 @@
use std::net::SocketAddr;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::{Router, middleware};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tokio::net::TcpListener;
use tokio::process::Command;
use tokio::sync::oneshot;
use tracing::{debug, error, info, warn};
use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware};
use tracing::{info, warn};
type HmacSha256 = Hmac<Sha256>;
/// Name of the server secret holding the GitHub App webhook HMAC key.
pub(crate) const WEBHOOK_SECRET_ENV: &str = "GITHUB_APP_WEBHOOK_SECRET";
/// Route path where Fabro receives GitHub App webhook deliveries.
pub(crate) const WEBHOOK_ROUTE: &str = "/api/v1/webhooks/github";
/// Verify a GitHub webhook HMAC-SHA256 signature.
///
/// `signature_header` is the value of the `X-Hub-Signature-256` header,
/// expected in the form `sha256=<hex-digest>`.
pub fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> bool {
pub(crate) fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> bool {
let Some(hex_digest) = signature_header.strip_prefix("sha256=") else {
return false;
};
@ -37,60 +31,7 @@ pub fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> b
mac.verify_slice(&expected).is_ok()
}
#[derive(Clone)]
struct WebhookState {
secret: Vec<u8>,
}
async fn webhook_handler(
State(state): State<WebhookState>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
let delivery_id = headers
.get("x-github-delivery")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
let Some(signature) = headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
else {
warn!(delivery = %delivery_id, "Webhook signature verification failed");
return StatusCode::UNAUTHORIZED;
};
if !verify_signature(&state.secret, &body, signature) {
warn!(delivery = %delivery_id, "Webhook signature verification failed");
return StatusCode::UNAUTHORIZED;
}
let event_type = headers
.get("x-github-event")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
if tracing::enabled!(tracing::Level::DEBUG) {
let (repo, action) = parse_event_metadata(&body);
debug!(
event = %event_type,
delivery = %delivery_id,
repo = %repo,
action = %action,
"Webhook received"
);
} else {
info!(
event = %event_type,
delivery = %delivery_id,
"Webhook received"
);
}
StatusCode::OK
}
fn parse_event_metadata(body: &[u8]) -> (String, String) {
pub(crate) fn parse_event_metadata(body: &[u8]) -> (String, String) {
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap_or_default();
let repo = parsed
.get("repository")
@ -106,106 +47,41 @@ fn parse_event_metadata(body: &[u8]) -> (String, String) {
(repo, action)
}
/// A running webhook listener that can be shut down.
pub struct WebhookListener {
port: u16,
shutdown_tx: oneshot::Sender<()>,
/// Manages `tailscale funnel` lifecycle for the main Fabro server port.
pub struct TailscaleFunnelManager {
port: u16,
}
impl WebhookListener {
pub fn port(&self) -> u16 {
self.port
}
pub fn shutdown(self) {
let _ = self.shutdown_tx.send(());
info!("Webhook listener stopped");
}
}
/// Spawn the webhook HTTP listener on a random port (127.0.0.1 only).
pub async fn spawn_webhook_listener(
secret: Vec<u8>,
ip_allowlist: Arc<IpAllowlistConfig>,
) -> anyhow::Result<WebhookListener> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
let state = WebhookState { secret };
let router = Router::new()
.route("/webhooks/github", post(webhook_handler))
.with_state(state)
.layer(middleware::from_fn_with_state(
ip_allowlist,
ip_allowlist_middleware,
));
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await
.ok();
});
info!(port = port, "Webhook listener started");
Ok(WebhookListener { port, shutdown_tx })
}
/// Manage the full webhook lifecycle: listener + tailscale funnel + GitHub API.
pub struct WebhookManager {
listener: WebhookListener,
}
impl WebhookManager {
/// Start the webhook system: spawn listener, enable Tailscale funnel,
/// and update the GitHub App webhook URL.
impl TailscaleFunnelManager {
pub async fn start(
secret: Vec<u8>,
main_server_port: u16,
app_id: &str,
private_key_pem: &str,
ip_allowlist: Arc<IpAllowlistConfig>,
) -> anyhow::Result<Self> {
let listener = spawn_webhook_listener(secret, ip_allowlist).await?;
let port = listener.port();
let funnel_url = enable_tailscale_funnel(main_server_port).await?;
info!(port = main_server_port, url = %funnel_url, "Tailscale funnel enabled");
// Enable Tailscale funnel
let funnel_url = match enable_tailscale_funnel(port).await {
Ok(url) => url,
Err(err) => {
error!(error = %err, "Failed to enable Tailscale funnel");
listener.shutdown();
return Err(err);
let webhook_url = format!("{funnel_url}{WEBHOOK_ROUTE}");
match fabro_github::update_app_webhook_config(app_id, private_key_pem, &webhook_url).await {
Ok(()) => {
info!(url = %webhook_url, "GitHub App webhook URL updated");
}
Err(err) => {
warn!(
error = %err,
url = %webhook_url,
"Failed to update GitHub App webhook URL"
);
}
};
info!(url = %funnel_url, "Tailscale funnel enabled");
// Update GitHub App webhook URL
let webhook_url = format!("{funnel_url}/webhooks/github");
if let Err(err) = update_github_app_webhook(app_id, private_key_pem, &webhook_url).await {
error!(error = %err, "Failed to update GitHub App webhook URL");
disable_tailscale_funnel(port).await;
listener.shutdown();
return Err(err);
}
info!(url = %webhook_url, "GitHub App webhook URL updated");
Ok(Self { listener })
Ok(Self {
port: main_server_port,
})
}
/// Shut down: disable funnel, stop listener.
pub async fn shutdown(self) {
disable_tailscale_funnel(self.listener.port()).await;
self.listener.shutdown();
disable_tailscale_funnel(self.port).await;
}
}
@ -220,28 +96,26 @@ async fn enable_tailscale_funnel(port: u16) -> anyhow::Result<String> {
anyhow::bail!("tailscale funnel failed: {stderr}");
}
// Get the funnel URL from `tailscale funnel status`
let status_output = Command::new("tailscale")
.args(["funnel", "status"])
.output()
.await?;
if !status_output.status.success() {
let stderr = String::from_utf8_lossy(&status_output.stderr);
anyhow::bail!("tailscale funnel status failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&status_output.stdout);
// Parse the HTTPS URL from status output — first line typically contains it
let url = stdout
.lines()
.find_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("https://") {
// Strip trailing path/colon info
Some(trimmed.trim_end_matches('/').to_string())
} else {
None
}
})
.ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}"))?;
parse_tailscale_funnel_url(&stdout)
.ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}"))
}
Ok(url)
fn parse_tailscale_funnel_url(status_output: &str) -> Option<String> {
status_output.lines().find_map(|line| {
line.split_whitespace()
.find(|part| part.starts_with("https://"))
.map(|url| url.trim_end_matches('/').to_string())
})
}
async fn disable_tailscale_funnel(port: u16) {
@ -251,80 +125,28 @@ async fn disable_tailscale_funnel(port: u16) {
.await
{
Ok(output) if output.status.success() => {
info!("Tailscale funnel disabled");
info!(port, "Tailscale funnel disabled");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(error = %stderr, "Failed to disable Tailscale funnel");
warn!(port, error = %stderr, "Failed to disable Tailscale funnel");
}
Err(err) => {
warn!(error = %err, "Failed to disable Tailscale funnel");
warn!(port, error = %err, "Failed to disable Tailscale funnel");
}
}
}
async fn update_github_app_webhook(
app_id: &str,
private_key_pem: &str,
webhook_url: &str,
) -> anyhow::Result<()> {
let jwt =
fabro_github::sign_app_jwt(app_id, private_key_pem).map_err(|e| anyhow::anyhow!(e))?;
let client = fabro_http::http_client()?;
let body = serde_json::json!({
"url": webhook_url,
"content_type": "json",
});
let resp = client
.patch("https://api.github.com/app/hook/config")
.header("Authorization", format!("Bearer {jwt}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "fabro")
.json(&body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("GitHub API returned {status}: {text}");
}
Ok(())
#[cfg(test)]
pub(crate) fn compute_signature(secret: &[u8], body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
mac.update(body);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
use super::*;
use crate::ip_allowlist::{IpAllowlist, IpAllowlistConfig};
fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
fn empty_allowlist_config() -> Arc<IpAllowlistConfig> {
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::default(),
trusted_proxy_count: 0,
})
}
// -----------------------------------------------------------------------
// verify_signature
// -----------------------------------------------------------------------
fn compute_signature(secret: &[u8], body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
mac.update(body);
let result = mac.finalize();
format!("sha256={}", hex::encode(result.into_bytes()))
}
#[test]
fn valid_signature() {
@ -359,138 +181,21 @@ mod tests {
assert!(verify_signature(secret, body, &sig));
}
// -----------------------------------------------------------------------
// webhook_handler
// -----------------------------------------------------------------------
fn build_test_router(secret: &[u8]) -> Router {
let state = WebhookState {
secret: secret.to_vec(),
};
Router::new()
.route("/webhooks/github", post(webhook_handler))
.with_state(state)
.layer(middleware::from_fn_with_state(
empty_allowlist_config(),
ip_allowlist_middleware,
))
#[test]
fn parse_event_metadata_defaults_missing_fields() {
assert_eq!(
parse_event_metadata(br#"{"repository":{}}"#),
("unknown".to_string(), "none".to_string(),)
);
}
#[tokio::test]
async fn rejects_missing_signature() {
let app = build_test_router(b"secret");
let req = Request::builder()
.method("POST")
.uri("/webhooks/github")
.body(Body::from("{}"))
.unwrap();
#[test]
fn parse_tailscale_funnel_url_extracts_https_origin() {
let status = "https://fabro.example.ts.net proxy http://127.0.0.1:32276";
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_bad_signature() {
let app = build_test_router(b"secret");
let body = b"{}";
let bad_sig = compute_signature(b"wrong", body);
let req = Request::builder()
.method("POST")
.uri("/webhooks/github")
.header("x-hub-signature-256", bad_sig)
.body(Body::from(body.to_vec()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn accepts_valid_webhook() {
let secret = b"my-secret";
let app = build_test_router(secret);
let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#;
let sig = compute_signature(secret, body);
let req = Request::builder()
.method("POST")
.uri("/webhooks/github")
.header("x-hub-signature-256", sig)
.header("x-github-event", "pull_request")
.header("x-github-delivery", "abc-123")
.body(Body::from(body.to_vec()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// -----------------------------------------------------------------------
// spawn_webhook_listener
// -----------------------------------------------------------------------
#[tokio::test]
async fn spawn_listener_serves_route() {
let secret = b"integration-secret";
let listener = spawn_webhook_listener(secret.to_vec(), empty_allowlist_config())
.await
.unwrap();
let port = listener.port();
// Valid request should return 200
let body = b"{}";
let sig = compute_signature(secret, body);
let client = test_http_client();
let resp = client
.post(format!("http://127.0.0.1:{port}/webhooks/github"))
.header("x-hub-signature-256", sig)
.body(body.to_vec())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// Missing signature should return 401
let resp = client
.post(format!("http://127.0.0.1:{port}/webhooks/github"))
.body("{}")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
listener.shutdown();
}
#[tokio::test]
async fn spawn_listener_blocks_non_allowlisted_ip() {
let secret = b"integration-secret";
let listener = spawn_webhook_listener(
secret.to_vec(),
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,
}),
)
.await
.unwrap();
let port = listener.port();
let body = b"{}";
let sig = compute_signature(secret, body);
let client = test_http_client();
let resp = client
.post(format!("http://127.0.0.1:{port}/webhooks/github"))
.header("x-hub-signature-256", sig)
.body(body.to_vec())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 403);
listener.shutdown();
assert_eq!(
parse_tailscale_funnel_url(status),
Some("https://fabro.example.ts.net".to_string())
);
}
}

View file

@ -343,6 +343,12 @@ mod tests {
IpAllowEntry::parse_literal(value).unwrap()
}
macro_rules! assert_status {
($response:expr, $expected:expr) => {
fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
#[test]
fn effective_scope_inherits_global_fields_and_prefers_override_values() {
let global = ServerIpAllowlistSettings {
@ -547,7 +553,7 @@ mod tests {
.body(Body::empty())
.unwrap();
let allowed_response = app.clone().oneshot(allowed_request).await.unwrap();
assert_eq!(allowed_response.status(), StatusCode::OK);
assert_status!(allowed_response, StatusCode::OK).await;
let blocked_request = Request::builder()
.uri("/api/v1/runs")
@ -555,7 +561,7 @@ mod tests {
.body(Body::empty())
.unwrap();
let blocked_response = app.oneshot(blocked_request).await.unwrap();
assert_eq!(blocked_response.status(), StatusCode::FORBIDDEN);
assert_status!(blocked_response, StatusCode::FORBIDDEN).await;
}
#[tokio::test]
@ -580,7 +586,7 @@ mod tests {
))
.await
.unwrap();
assert_eq!(health_response.status(), StatusCode::OK);
assert_status!(health_response, StatusCode::OK).await;
let blocked_response = app
.oneshot(request_with_connect_info(
@ -589,7 +595,7 @@ mod tests {
))
.await
.unwrap();
assert_eq!(blocked_response.status(), StatusCode::FORBIDDEN);
assert_status!(blocked_response, StatusCode::FORBIDDEN).await;
}
fn request_with_connect_info(path: &str, ip: IpAddr) -> Request<Body> {

View file

@ -237,7 +237,7 @@ pub fn auth_method_name(method: ServerAuthMethod) -> &'static str {
#[cfg(test)]
mod tests {
use axum::body::{Body, to_bytes};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
@ -297,9 +297,16 @@ mod tests {
.layer(axum::Extension(mode))
}
async fn response_json(response: axum::response::Response) -> serde_json::Value {
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
macro_rules! response_json {
($response:expr) => {
fabro_test::expect_axum_json($response, StatusCode::OK, concat!(file!(), ":", line!()))
};
}
macro_rules! assert_status {
($response:expr, $expected:expr) => {
fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
fn dev_token_mode() -> AuthMode {
@ -392,7 +399,7 @@ client_id = "Iv1.test"
.oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_status!(response, StatusCode::OK).await;
}
#[tokio::test]
@ -402,7 +409,7 @@ client_id = "Iv1.test"
.oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_status!(response, StatusCode::UNAUTHORIZED).await;
}
#[tokio::test]
@ -421,8 +428,7 @@ client_id = "Iv1.test"
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json = response_json(response).await;
let json = response_json!(response).await;
assert_eq!(json["login"], "dev");
assert_eq!(json["auth_method"], "dev_token");
}
@ -457,7 +463,7 @@ client_id = "Iv1.test"
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_status!(response, StatusCode::UNAUTHORIZED).await;
}
#[tokio::test]
@ -476,8 +482,7 @@ client_id = "Iv1.test"
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json = response_json(response).await;
let json = response_json!(response).await;
assert_eq!(json["login"], "alice");
assert_eq!(json["auth_method"], "github");
}

View file

@ -9,9 +9,11 @@ use fabro_config::merge::combine_files;
use fabro_config::user::load_settings_config;
use fabro_config::{Storage, resolve_server_from_file};
use fabro_sandbox::SandboxProvider;
use fabro_types::settings::server::{GithubIntegrationStrategy, ServerLayer, ServerListenLayer};
use fabro_types::settings::server::{
GithubIntegrationStrategy, ServerLayer, ServerListenLayer, WebhookStrategy,
};
use fabro_types::settings::{
InterpString, ObjectStoreSettings, ServerListenSettings,
GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings,
ServerSettings as ResolvedServerSettings, SettingsLayer,
};
use fabro_util::terminal::Styles;
@ -25,11 +27,11 @@ use tokio::time::interval;
use tracing::{error, info, warn};
use crate::bind::{self, Bind, BindRequest};
use crate::github_webhooks::WebhookManager;
use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV};
use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config};
use crate::jwt_auth::resolve_auth_mode_with_lookup;
use crate::server::{
AppStateConfig, RouterOptions, build_app_state, build_router_with_options,
AppState, AppStateConfig, RouterOptions, build_app_state, build_router_with_options,
reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler,
};
use crate::server_secrets::ServerSecrets;
@ -163,6 +165,140 @@ async fn resolve_github_webhook_ip_allowlist(
Ok(Arc::new(config))
}
async fn resolve_startup_github_webhook_ip_allowlist(
resolved_server_settings: &ResolvedServerSettings,
github_meta_resolver: &GitHubMetaResolver,
webhook_secret_present: bool,
) -> anyhow::Result<Option<Arc<IpAllowlistConfig>>> {
if !webhook_secret_present {
return Ok(None);
}
resolve_github_webhook_ip_allowlist(resolved_server_settings, github_meta_resolver)
.await
.map(Some)
}
enum WebhookPreconditions {
Ready {
app_id: String,
private_key_pem: String,
},
Skip(String),
}
fn resolve_webhook_preconditions(
github: &GithubIntegrationSettings,
state: &Arc<AppState>,
webhook_secret_present: bool,
) -> anyhow::Result<WebhookPreconditions> {
if github.strategy != GithubIntegrationStrategy::App {
return Ok(WebhookPreconditions::Skip(
"GitHub integration auth is not set to app".to_string(),
));
}
if !webhook_secret_present {
return Ok(WebhookPreconditions::Skip(format!(
"{WEBHOOK_SECRET_ENV} is not set"
)));
}
let Some(app_id) = github.app_id.as_ref().map(resolve_interp).transpose()? else {
return Ok(WebhookPreconditions::Skip(
"server.integrations.github.app_id is not set".to_string(),
));
};
let github_app = match state.github_credentials(github) {
Ok(creds) => creds,
Err(err) => {
return Ok(WebhookPreconditions::Skip(format!(
"GitHub credentials are invalid: {err}"
)));
}
};
let Some(fabro_github::GitHubCredentials::App(github_app)) = github_app else {
return Ok(WebhookPreconditions::Skip(
"GITHUB_APP_PRIVATE_KEY is not available".to_string(),
));
};
Ok(WebhookPreconditions::Ready {
app_id,
private_key_pem: github_app.private_key_pem,
})
}
async fn start_webhook_strategy(
resolved_server_settings: &ResolvedServerSettings,
state: &Arc<AppState>,
bind_addr: &Bind,
webhook_secret_present: bool,
) -> anyhow::Result<Option<TailscaleFunnelManager>> {
let github = &resolved_server_settings.integrations.github;
let Some(strategy) = github.webhooks.as_ref().and_then(|w| w.strategy) else {
return Ok(None);
};
let (app_id, private_key_pem) =
match resolve_webhook_preconditions(github, state, webhook_secret_present)? {
WebhookPreconditions::Ready {
app_id,
private_key_pem,
} => (app_id, private_key_pem),
WebhookPreconditions::Skip(reason) => {
warn!(
%reason,
"Webhook strategy is configured but skipping webhook startup"
);
return Ok(None);
}
};
match strategy {
WebhookStrategy::TailscaleFunnel => {
let Some(port) = bind_addr.tcp_port() else {
warn!(
"GitHub webhook strategy tailscale_funnel requires a TCP server listen address; skipping webhook startup"
);
return Ok(None);
};
match TailscaleFunnelManager::start(port, &app_id, &private_key_pem).await {
Ok(manager) => Ok(Some(manager)),
Err(err) => {
error!(
error = %err,
"Failed to start Tailscale funnel for GitHub webhooks"
);
Ok(None)
}
}
}
WebhookStrategy::ServerUrl => {
let server_api_url = resolved_server_settings
.api
.url
.as_ref()
.map(resolve_interp)
.transpose()?
.ok_or_else(|| {
anyhow::anyhow!(
"server.api.url must be set when webhook strategy = \"server_url\" (resolver invariant)"
)
})?;
let webhook_url = format!("{}{WEBHOOK_ROUTE}", server_api_url.trim_end_matches('/'));
match fabro_github::update_app_webhook_config(&app_id, &private_key_pem, &webhook_url)
.await
{
Ok(()) => info!(url = %webhook_url, "GitHub App webhook URL updated"),
Err(err) => warn!(
error = %err,
url = %webhook_url,
"Failed to update GitHub App webhook URL"
),
}
Ok(None)
}
}
}
fn use_in_memory_store() -> bool {
!matches!(
std::env::var(TEST_IN_MEMORY_STORE_ENV).ok().as_deref(),
@ -335,6 +471,7 @@ where
let vault_path = storage.secrets_path();
let server_env_path = storage.server_state().env_path();
let server_secrets = ServerSecrets::load(server_env_path.clone())?;
let webhook_secret_present = server_secrets.get(WEBHOOK_SECRET_ENV).is_some();
// Shared config for live reloading
let effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir);
@ -400,70 +537,31 @@ where
.await
.context("resolving server IP allowlist")?,
);
let github_webhook_ip_allowlist = resolve_startup_github_webhook_ip_allowlist(
&resolved_server_settings,
&github_meta_resolver,
webhook_secret_present,
)
.await?;
let router = build_router_with_options(
Arc::clone(&state),
auth_mode,
Arc::clone(&default_ip_allowlist),
RouterOptions { web_enabled },
RouterOptions {
web_enabled,
github_webhook_ip_allowlist,
},
);
let bound_listener = bind_listener(&bind_request).await?;
let bind_addr = bound_listener.bind.clone();
// Optionally start webhook listener
let webhook_manager = match resolved_server_settings.integrations.github.strategy {
GithubIntegrationStrategy::Token => None,
GithubIntegrationStrategy::App => {
let webhook_app_id = resolved_server_settings
.integrations
.github
.webhooks
.as_ref()
.and(resolved_server_settings.integrations.github.app_id.as_ref())
.map(resolve_interp)
.transpose()?;
match webhook_app_id {
Some(app_id) => {
let secret = server_secrets.get("GITHUB_APP_WEBHOOK_SECRET");
let github_app = state
.github_credentials(&resolved_server_settings.integrations.github)
.unwrap_or_else(|err| {
warn!(
error = %err,
"Webhook config present but GitHub credentials are invalid; skipping webhook listener"
);
None
});
if let (Some(secret), Some(fabro_github::GitHubCredentials::App(github_app))) =
(secret, github_app)
{
let webhook_ip_allowlist = resolve_github_webhook_ip_allowlist(
&resolved_server_settings,
&github_meta_resolver,
)
.await?;
match WebhookManager::start(
secret.into_bytes(),
&app_id,
&github_app.private_key_pem,
webhook_ip_allowlist,
)
.await
{
Ok(manager) => Some(manager),
Err(err) => {
error!(error = %err, "Failed to start webhook listener");
None
}
}
} else {
warn!(
"Webhook config present but GITHUB_APP_WEBHOOK_SECRET or GITHUB_APP_PRIVATE_KEY not set; skipping webhook listener"
);
None
}
}
None => None,
}
}
};
let webhook_manager = start_webhook_strategy(
&resolved_server_settings,
&state,
&bind_addr,
webhook_secret_present,
)
.await?;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let shutdown_state = Arc::clone(&state);
@ -517,8 +615,6 @@ where
}
});
let bound_listener = bind_listener(&bind_request).await?;
let bind_addr = bound_listener.bind.clone();
if bound_listener.used_random_port_fallback {
if let BindRequest::TcpHost(host) = bind_request {
warn!(
@ -581,7 +677,6 @@ where
let _ = child.wait();
}
// Clean up webhook listener on shutdown
if let Some(manager) = webhook_manager {
manager.shutdown().await;
}
@ -748,7 +843,8 @@ mod tests {
GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_runtime_settings,
bind_tcp_host_with_fallback, build_local_object_store_with_preference, build_slatedb_store,
resolve_bind_request_from_settings, resolve_github_webhook_ip_allowlist,
resolve_server_settings, router_web_enabled, server_bind_title, server_title,
resolve_server_settings, resolve_startup_github_webhook_ip_allowlist, router_web_enabled,
server_bind_title, server_title,
};
use crate::bind::{Bind, BindRequest};
@ -1080,4 +1176,41 @@ entries = ["github_meta_hooks"]
assert!(error.to_string().contains("GitHub webhook IP allowlist"));
}
#[tokio::test]
async fn resolve_startup_github_webhook_ip_allowlist_skips_resolution_without_webhook_secret() {
let settings = resolve_server_settings(&parse_settings(
r#"
_version = 1
[server.listen]
type = "tcp"
address = "127.0.0.1:0"
[server.integrations.github]
strategy = "app"
app_id = "123"
[server.integrations.github.webhooks.ip_allowlist]
entries = ["github_meta_hooks"]
"#,
))
.expect("settings should resolve");
let cache_dir = tempfile::tempdir().unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let resolver = GitHubMetaResolver::new(
fabro_http::test_http_client().unwrap(),
format!("http://127.0.0.1:{port}/meta"),
cache_dir.path().join("github-meta.json"),
);
let allowlist = resolve_startup_github_webhook_ip_allowlist(&settings, &resolver, false)
.await
.expect("inactive webhook route should skip GitHub meta resolution");
assert!(allowlist.is_none());
}
}

File diff suppressed because it is too large Load diff

View file

@ -644,7 +644,7 @@ mod tests {
use std::sync::Arc;
use axum::Extension;
use axum::body::{Body, to_bytes};
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode, header};
use axum_extra::extract::cookie::Key;
@ -654,7 +654,7 @@ mod tests {
GithubIntegrationLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerAuthMethod,
ServerIntegrationsLayer, ServerLayer, ServerWebLayer,
};
use serde_json::{Value, json};
use serde_json::json;
use tower::ServiceExt;
use super::{api_routes, read_private_session, routes};
@ -738,8 +738,22 @@ mod tests {
test_auth_router_with_settings(SettingsLayer::default(), auth_mode)
}
async fn response_json(response: axum::response::Response) -> Value {
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
macro_rules! response_json {
($response:expr) => {
fabro_test::expect_axum_json($response, StatusCode::OK, concat!(file!(), ":", line!()))
};
}
macro_rules! assert_status {
($response:expr, $expected:expr) => {
fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
macro_rules! checked_response {
($response:expr, $expected:expr) => {
fabro_test::expect_axum_status($response, $expected, concat!(file!(), ":", line!()))
};
}
#[tokio::test]
@ -759,7 +773,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = checked_response!(response, StatusCode::OK).await;
let session_cookie = response
.headers()
@ -788,8 +802,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let body = response_json!(response).await;
assert_eq!(body["provider"], "dev-token");
assert_eq!(body["user"]["login"], "dev");
}
@ -813,7 +826,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_status!(response, StatusCode::UNAUTHORIZED).await;
}
#[tokio::test]
@ -830,8 +843,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let body = response_json!(response).await;
assert_eq!(body, json!({ "methods": ["dev-token"] }));
}
@ -851,8 +863,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let response = checked_response!(response, StatusCode::SEE_OTHER).await;
let set_cookie = response
.headers()
.get(header::SET_COOKIE)
@ -881,8 +892,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let response = checked_response!(response, StatusCode::SEE_OTHER).await;
assert_eq!(
response
.headers()

View file

@ -1,17 +1,4 @@
#![expect(
clippy::disallowed_methods,
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use std::path::PathBuf;
fn read_doc(relative_path: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../")
.join(relative_path);
std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()))
}
use crate::helpers::read_repo_file as read_doc;
#[test]
fn active_server_docs_describe_the_unix_socket_default() {
@ -58,3 +45,22 @@ fn changelog_marks_removed_mutual_tls_as_historical() {
"historical changelog should clarify that inbound mutual TLS is no longer supported"
);
}
#[test]
fn github_docs_describe_webhooks_as_strategy_dependent() {
let github = read_doc("docs/integrations/github.mdx");
assert!(
!github.contains("enables browser OAuth and webhooks"),
"GitHub integration docs should not imply app auth alone enables webhook delivery"
);
assert!(
!github.contains("| Webhooks | No | Yes |"),
"GitHub strategy matrix should describe webhook delivery as strategy-dependent"
);
let server_configuration = read_doc("docs/administration/server-configuration.mdx");
assert!(
!server_configuration.contains("enables the GitHub App flow, browser OAuth, and webhooks"),
"server configuration docs should not imply app auth alone enables webhook delivery"
);
}

View file

@ -18,7 +18,7 @@ use httpmock::MockServer;
use tokio::time::sleep;
use tower::ServiceExt;
use crate::helpers::body_json;
use crate::helpers::{checked_response, response_json, response_status, response_text};
async fn configure_token_install(app: &axum::Router, token: &str) {
let llm_response = app
@ -36,7 +36,7 @@ async fn configure_token_install(app: &axum::Router, token: &str) {
)
.await
.unwrap();
assert_eq!(llm_response.status(), StatusCode::NO_CONTENT);
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
let server_response = app
.clone()
@ -53,7 +53,12 @@ async fn configure_token_install(app: &axum::Router, token: &str) {
)
.await
.unwrap();
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let github_response = app
.clone()
@ -70,7 +75,12 @@ async fn configure_token_install(app: &axum::Router, token: &str) {
)
.await
.unwrap();
assert_eq!(github_response.status(), StatusCode::NO_CONTENT);
response_status(
github_response,
StatusCode::NO_CONTENT,
"PUT /install/github/token",
)
.await;
}
#[tokio::test]
@ -88,8 +98,7 @@ async fn install_router_isolated_from_normal_api_surface() {
)
.await
.unwrap();
assert_eq!(health_response.status(), StatusCode::OK);
let health_body = body_json(health_response.into_body()).await;
let health_body = response_json(health_response, StatusCode::OK, "GET /health").await;
assert_eq!(health_body["status"], "ok");
assert_eq!(health_body["mode"], "install");
@ -105,14 +114,7 @@ async fn install_router_isolated_from_normal_api_surface() {
)
.await
.unwrap();
assert_eq!(root_response.status(), StatusCode::OK);
let root_html = String::from_utf8(
axum::body::to_bytes(root_response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
let root_html = response_text(root_response, StatusCode::OK, "GET /").await;
assert!(
root_html.contains("__FABRO_MODE__ = \"install\""),
"install shell should mark the SPA boot mode"
@ -128,7 +130,7 @@ async fn install_router_isolated_from_normal_api_surface() {
)
.await
.unwrap();
assert_eq!(api_response.status(), StatusCode::NOT_FOUND);
response_status(api_response, StatusCode::NOT_FOUND, "GET /api/v1/auth/me").await;
}
#[tokio::test]
@ -146,7 +148,12 @@ async fn install_session_requires_valid_install_token() {
)
.await
.unwrap();
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
response_status(
unauthorized,
StatusCode::UNAUTHORIZED,
"GET /install/session",
)
.await;
let authorized = app
.oneshot(
@ -161,8 +168,7 @@ async fn install_session_requires_valid_install_token() {
)
.await
.unwrap();
assert_eq!(authorized.status(), StatusCode::OK);
let body = body_json(authorized.into_body()).await;
let body = response_json(authorized, StatusCode::OK, "GET /install/session").await;
assert_eq!(
body["prefill"]["canonical_url"],
"https://fabro.example.com"
@ -229,11 +235,12 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() {
)
.await
.unwrap();
assert_eq!(
missing_token_response.status(),
response_status(
missing_token_response,
StatusCode::UNAUTHORIZED,
"missing token should be rejected for {method} {path}"
);
format!("{method} {path} without install token"),
)
.await;
let wrong_token_response = app
.clone()
@ -244,11 +251,12 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() {
)
.await
.unwrap();
assert_eq!(
wrong_token_response.status(),
response_status(
wrong_token_response,
StatusCode::UNAUTHORIZED,
"wrong token should be rejected for {method} {path}"
);
format!("{method} {path} with wrong install token"),
)
.await;
}
}
@ -268,7 +276,7 @@ async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response_status(response, StatusCode::OK, "GET /install/session?token=...").await;
}
#[tokio::test]
@ -297,7 +305,7 @@ async fn token_install_finish_persists_settings_env_and_vault() {
)
.await
.unwrap();
assert_eq!(llm_response.status(), StatusCode::NO_CONTENT);
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
let server_response = app
.clone()
@ -314,7 +322,12 @@ async fn token_install_finish_persists_settings_env_and_vault() {
)
.await
.unwrap();
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let github_response = app
.clone()
@ -331,7 +344,12 @@ async fn token_install_finish_persists_settings_env_and_vault() {
)
.await
.unwrap();
assert_eq!(github_response.status(), StatusCode::NO_CONTENT);
response_status(
github_response,
StatusCode::NO_CONTENT,
"PUT /install/github/token",
)
.await;
let finish_response = app
.oneshot(
@ -344,8 +362,12 @@ async fn token_install_finish_persists_settings_env_and_vault() {
)
.await
.unwrap();
assert_eq!(finish_response.status(), StatusCode::ACCEPTED);
let finish_body = body_json(finish_response.into_body()).await;
let finish_body = response_json(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
assert_eq!(finish_body["status"], "completing");
assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
assert!(
@ -434,7 +456,7 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
)
.await
.unwrap();
assert_eq!(llm_response.status(), StatusCode::NO_CONTENT);
response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
let server_response = app
.clone()
@ -451,7 +473,12 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
)
.await
.unwrap();
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let manifest_response = app
.clone()
@ -468,8 +495,12 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
)
.await
.unwrap();
assert_eq!(manifest_response.status(), StatusCode::OK);
let manifest_body = body_json(manifest_response.into_body()).await;
let manifest_body = response_json(
manifest_response,
StatusCode::OK,
"POST /install/github/app/manifest",
)
.await;
let redirect_url = manifest_body["manifest"]["redirect_url"]
.as_str()
.expect("redirect_url should be present");
@ -493,7 +524,12 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
)
.await
.unwrap();
assert_eq!(callback_response.status(), StatusCode::FOUND);
response_status(
callback_response,
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
let finish_response = app
.oneshot(
@ -506,8 +542,12 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
)
.await
.unwrap();
assert_eq!(finish_response.status(), StatusCode::ACCEPTED);
let finish_body = body_json(finish_response.into_body()).await;
let finish_body = response_json(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
assert_eq!(finish_body["status"], "completing");
assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
assert!(
@ -561,7 +601,12 @@ async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
)
.await
.unwrap();
assert_eq!(finish_response.status(), StatusCode::ACCEPTED);
response_status(
finish_response,
StatusCode::ACCEPTED,
"POST /install/finish",
)
.await;
assert!(!callback_invoked.load(Ordering::Acquire));
sleep(Duration::from_millis(650)).await;
@ -621,8 +666,7 @@ async fn install_validation_endpoints_validate_credentials_and_github_token() {
)
.await
.unwrap();
assert_eq!(llm_response.status(), StatusCode::OK);
let llm_body = body_json(llm_response.into_body()).await;
let llm_body = response_json(llm_response, StatusCode::OK, "POST /install/llm/test").await;
assert_eq!(llm_body["ok"], true);
let github_response = app
@ -637,8 +681,12 @@ async fn install_validation_endpoints_validate_credentials_and_github_token() {
)
.await
.unwrap();
assert_eq!(github_response.status(), StatusCode::OK);
let github_body = body_json(github_response.into_body()).await;
let github_body = response_json(
github_response,
StatusCode::OK,
"POST /install/github/token/test",
)
.await;
assert_eq!(github_body["username"], "octocat");
}
@ -684,7 +732,12 @@ async fn github_app_manifest_round_trip_updates_install_session() {
)
.await
.unwrap();
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let manifest_response = app
.clone()
@ -701,8 +754,12 @@ async fn github_app_manifest_round_trip_updates_install_session() {
)
.await
.unwrap();
assert_eq!(manifest_response.status(), StatusCode::OK);
let manifest_body = body_json(manifest_response.into_body()).await;
let manifest_body = response_json(
manifest_response,
StatusCode::OK,
"POST /install/github/app/manifest",
)
.await;
assert_eq!(
manifest_body["github_form_action"],
"https://github.com/settings/apps/new"
@ -722,20 +779,23 @@ async fn github_app_manifest_round_trip_updates_install_session() {
.map(|(_, value)| value.into_owned())
.expect("state should be embedded in redirect_url");
let callback_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(callback_response.status(), StatusCode::FOUND);
let callback_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
assert_eq!(
callback_response
.headers()
@ -756,8 +816,8 @@ async fn github_app_manifest_round_trip_updates_install_session() {
)
.await
.unwrap();
assert_eq!(session_response.status(), StatusCode::OK);
let session_body = body_json(session_response.into_body()).await;
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["github"]["strategy"], "app");
assert_eq!(session_body["github"]["slug"], "fabro-test-app");
assert_eq!(session_body["github"]["allowed_username"], "octocat");
@ -789,7 +849,12 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok
)
.await
.unwrap();
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let github_response = app
.clone()
@ -806,7 +871,12 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok
)
.await
.unwrap();
assert_eq!(github_response.status(), StatusCode::NO_CONTENT);
response_status(
github_response,
StatusCode::NO_CONTENT,
"PUT /install/github/token",
)
.await;
let manifest_response = app
.clone()
@ -823,7 +893,12 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok
)
.await
.unwrap();
assert_eq!(manifest_response.status(), StatusCode::OK);
response_status(
manifest_response,
StatusCode::OK,
"POST /install/github/app/manifest",
)
.await;
let session_response = app
.clone()
@ -837,8 +912,8 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok
)
.await
.unwrap();
assert_eq!(session_response.status(), StatusCode::OK);
let session_body = body_json(session_response.into_body()).await;
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert_eq!(session_body["github"]["strategy"], "token");
assert_eq!(session_body["github"]["username"], "brynary");
assert!(
@ -863,8 +938,12 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok
)
.await
.unwrap();
assert_eq!(retry_response.status(), StatusCode::CONFLICT);
let retry_body = body_json(retry_response.into_body()).await;
let retry_body = response_json(
retry_response,
StatusCode::CONFLICT,
"POST /install/github/app/manifest",
)
.await;
assert_eq!(
retry_body["errors"][0]["detail"],
"GitHub App setup is already pending; finish it or wait for it to expire."
@ -913,7 +992,12 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s
)
.await
.unwrap();
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let manifest_response = app
.clone()
@ -930,8 +1014,12 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s
)
.await
.unwrap();
assert_eq!(manifest_response.status(), StatusCode::OK);
let manifest_body = body_json(manifest_response.into_body()).await;
let manifest_body = response_json(
manifest_response,
StatusCode::OK,
"POST /install/github/app/manifest",
)
.await;
let redirect_url = manifest_body["manifest"]["redirect_url"]
.as_str()
.expect("redirect_url should be present");
@ -942,18 +1030,21 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s
.map(|(_, value)| value.into_owned())
.expect("state should be embedded in redirect_url");
let wrong_state_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/github/app/redirect?code=stub-code&state=wrong-state")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(wrong_state_response.status(), StatusCode::FOUND);
let wrong_state_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/github/app/redirect?code=stub-code&state=wrong-state")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=wrong-state",
)
.await;
assert_eq!(
wrong_state_response
.headers()
@ -975,8 +1066,8 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s
)
.await
.unwrap();
assert_eq!(session_response.status(), StatusCode::OK);
let session_body = body_json(session_response.into_body()).await;
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert!(session_body["github"].is_null());
assert!(
!session_body["completed_steps"]
@ -986,18 +1077,21 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s
.any(|value| value == "github")
);
let missing_state_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/github/app/redirect?code=stub-code")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing_state_response.status(), StatusCode::FOUND);
let missing_state_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/install/github/app/redirect?code=stub-code")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code",
)
.await;
assert_eq!(
missing_state_response
.headers()
@ -1019,7 +1113,12 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s
)
.await
.unwrap();
assert_eq!(valid_state_response.status(), StatusCode::FOUND);
response_status(
valid_state_response,
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
conversion_mock.assert_calls_async(1).await;
}
@ -1054,7 +1153,12 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin
)
.await
.unwrap();
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
response_status(
server_response,
StatusCode::NO_CONTENT,
"PUT /install/server",
)
.await;
let manifest_response = app
.clone()
@ -1071,8 +1175,12 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin
)
.await
.unwrap();
assert_eq!(manifest_response.status(), StatusCode::OK);
let manifest_body = body_json(manifest_response.into_body()).await;
let manifest_body = response_json(
manifest_response,
StatusCode::OK,
"POST /install/github/app/manifest",
)
.await;
let redirect_url = manifest_body["manifest"]["redirect_url"]
.as_str()
.expect("redirect_url should be present");
@ -1083,20 +1191,23 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin
.map(|(_, value)| value.into_owned())
.expect("state should be embedded in redirect_url");
let callback_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(callback_response.status(), StatusCode::FOUND);
let callback_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!(
"/install/github/app/redirect?code=stub-code&state={state}"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
assert_eq!(
callback_response
.headers()
@ -1120,7 +1231,12 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin
)
.await
.unwrap();
assert_eq!(retry_response.status(), StatusCode::FOUND);
response_status(
retry_response,
StatusCode::FOUND,
"GET /install/github/app/redirect?code=stub-code&state=...",
)
.await;
conversion_mock.assert_calls_async(2).await;
}
@ -1143,8 +1259,12 @@ async fn install_server_rejects_trailing_slash_canonical_urls() {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"PUT /install/server",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"canonical_url must not end with a trailing slash"
@ -1188,8 +1308,12 @@ async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys(
)
.await
.unwrap();
assert_eq!(finish_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let finish_body = body_json(finish_response.into_body()).await;
let finish_body = response_json(
finish_response,
StatusCode::INTERNAL_SERVER_ERROR,
"POST /install/finish",
)
.await;
assert!(
finish_body["errors"][0]["detail"]
.as_str()
@ -1234,8 +1358,8 @@ async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys(
)
.await
.unwrap();
assert_eq!(session_response.status(), StatusCode::OK);
let session_body = body_json(session_response.into_body()).await;
let session_body =
response_json(session_response, StatusCode::OK, "GET /install/session").await;
assert!(
session_body["completed_steps"]
.as_array()
@ -1277,7 +1401,12 @@ async fn install_finish_failure_leaves_home_dev_token_mirror_written() {
)
.await
.unwrap();
assert_eq!(finish_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
response_status(
finish_response,
StatusCode::INTERNAL_SERVER_ERROR,
"POST /install/finish",
)
.await;
let home_dev_token = dev_token::read_dev_token_file(&home.dev_token_path())
.expect("home dev token should exist");

View file

@ -3,7 +3,7 @@ use axum::http::{Request, StatusCode};
use fabro_server::install::{InstallAppState, build_install_router};
use tower::ServiceExt;
use crate::helpers::body_json;
use crate::helpers::response_json;
#[tokio::test]
async fn install_llm_endpoints_reject_openai_compatible_in_v1() {
@ -24,8 +24,12 @@ async fn install_llm_endpoints_reject_openai_compatible_in_v1() {
)
.await
.unwrap();
assert_eq!(test_response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let test_body = body_json(test_response.into_body()).await;
let test_body = response_json(
test_response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/llm/test",
)
.await;
assert_eq!(
test_body["errors"][0]["detail"],
"openai_compatible is not supported by install in v1"
@ -45,8 +49,12 @@ async fn install_llm_endpoints_reject_openai_compatible_in_v1() {
)
.await
.unwrap();
assert_eq!(put_response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let put_body = body_json(put_response.into_body()).await;
let put_body = response_json(
put_response,
StatusCode::UNPROCESSABLE_ENTITY,
"PUT /install/llm",
)
.await;
assert_eq!(
put_body["errors"][0]["detail"],
"openai_compatible is not supported by install in v1"

View file

@ -1,7 +1,7 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use axum::body::{Body, to_bytes};
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Method, Request, StatusCode};
use fabro_config::parse_settings_layer;
@ -14,7 +14,7 @@ use fabro_server::server::{
use fabro_types::settings::SettingsLayer;
use tower::ServiceExt;
use crate::helpers::body_json;
use crate::helpers::{checked_response, response_json, response_status, response_text};
#[tokio::test]
async fn old_unversioned_routes_return_404() {
@ -29,7 +29,7 @@ async fn old_unversioned_routes_return_404() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {path}");
response_status(response, StatusCode::NOT_FOUND, format!("{method} {path}")).await;
}
}
@ -43,11 +43,7 @@ async fn root_and_health_stay_at_root() {
.body(Body::empty())
.unwrap();
let root_response = app.clone().oneshot(root_req).await.unwrap();
assert_eq!(root_response.status(), StatusCode::OK);
let root_body = to_bytes(root_response.into_body(), usize::MAX)
.await
.unwrap();
let root_html = String::from_utf8(root_body.to_vec()).unwrap();
let root_html = response_text(root_response, StatusCode::OK, "GET /").await;
assert!(root_html.contains("<div id=\"root\"></div>"));
let health_req = Request::builder()
@ -56,8 +52,7 @@ async fn root_and_health_stay_at_root() {
.body(Body::empty())
.unwrap();
let health_response = app.oneshot(health_req).await.unwrap();
assert_eq!(health_response.status(), StatusCode::OK);
let health_body = body_json(health_response.into_body()).await;
let health_body = response_json(health_response, StatusCode::OK, "GET /health").await;
assert_eq!(health_body["status"], "ok");
assert!(
health_body.get("version").is_none(),
@ -80,7 +75,7 @@ async fn install_routes_are_absent_in_normal_mode() {
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
response_status(response, StatusCode::NOT_FOUND, "GET /install").await;
}
#[tokio::test]
@ -94,7 +89,7 @@ async fn moved_routes_not_at_root_of_api_prefix() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}");
response_status(response, StatusCode::NOT_FOUND, format!("GET {path}")).await;
}
}
@ -109,7 +104,12 @@ async fn source_maps_are_not_served() {
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
response_status(
response,
StatusCode::NOT_FOUND,
"GET /assets/entry-abc123.js.map",
)
.await;
}
#[tokio::test]
@ -122,7 +122,12 @@ async fn web_enabled_serves_web_only_routes() {
.body(Body::empty())
.unwrap();
let auth_me_response = app.clone().oneshot(auth_me_request).await.unwrap();
assert_eq!(auth_me_response.status(), StatusCode::UNAUTHORIZED);
response_status(
auth_me_response,
StatusCode::UNAUTHORIZED,
"GET /api/v1/auth/me",
)
.await;
// Browser-style navigation to an SPA route falls back to index.html.
let setup_request = Request::builder()
@ -132,7 +137,7 @@ async fn web_enabled_serves_web_only_routes() {
.body(Body::empty())
.unwrap();
let setup_response = app.clone().oneshot(setup_request).await.unwrap();
assert_eq!(setup_response.status(), StatusCode::OK);
response_status(setup_response, StatusCode::OK, "GET /setup").await;
// Same path without `Accept: text/html` (e.g. curl, fetch default) is
// not a browser navigation and must not get the SPA HTML fallback.
@ -142,7 +147,12 @@ async fn web_enabled_serves_web_only_routes() {
.body(Body::empty())
.unwrap();
let setup_no_accept_response = app.clone().oneshot(setup_no_accept).await.unwrap();
assert_eq!(setup_no_accept_response.status(), StatusCode::NOT_FOUND);
response_status(
setup_no_accept_response,
StatusCode::NOT_FOUND,
"GET /setup",
)
.await;
let setup_status_request = Request::builder()
.method("GET")
@ -150,7 +160,12 @@ async fn web_enabled_serves_web_only_routes() {
.body(Body::empty())
.unwrap();
let setup_status_response = app.clone().oneshot(setup_status_request).await.unwrap();
assert_eq!(setup_status_response.status(), StatusCode::NOT_FOUND);
response_status(
setup_status_response,
StatusCode::NOT_FOUND,
"GET /api/v1/setup/status",
)
.await;
let setup_complete_request = Request::builder()
.method("GET")
@ -158,7 +173,12 @@ async fn web_enabled_serves_web_only_routes() {
.body(Body::empty())
.unwrap();
let setup_complete_response = app.clone().oneshot(setup_complete_request).await.unwrap();
assert_eq!(setup_complete_response.status(), StatusCode::NOT_FOUND);
response_status(
setup_complete_response,
StatusCode::NOT_FOUND,
"GET /setup/complete",
)
.await;
let demo_toggle_request = Request::builder()
.method("POST")
@ -166,8 +186,12 @@ async fn web_enabled_serves_web_only_routes() {
.header("content-type", "application/json")
.body(Body::from(r#"{"enabled":true}"#))
.unwrap();
let demo_toggle_response = app.clone().oneshot(demo_toggle_request).await.unwrap();
assert_eq!(demo_toggle_response.status(), StatusCode::OK);
let demo_toggle_response = checked_response(
app.clone().oneshot(demo_toggle_request).await.unwrap(),
StatusCode::OK,
"POST /api/v1/demo/toggle",
)
.await;
assert!(
demo_toggle_response.headers().contains_key("set-cookie"),
"demo toggle should set a cookie"
@ -183,7 +207,12 @@ async fn web_enabled_serves_web_only_routes() {
.body(Body::empty())
.unwrap();
let api_miss_response = app.oneshot(api_miss).await.unwrap();
assert_eq!(api_miss_response.status(), StatusCode::NOT_FOUND);
response_status(
api_miss_response,
StatusCode::NOT_FOUND,
"GET /api/v2/nonexistent",
)
.await;
}
#[tokio::test]
@ -191,17 +220,21 @@ async fn security_headers_are_applied_to_all_responses() {
let app = build_router(create_app_state(), AuthMode::Disabled);
// Plain HTTP: HSTS must NOT be present.
let api_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let api_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::OK,
"GET /health",
)
.await;
let headers = api_response.headers();
assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff");
assert_eq!(headers.get("x-frame-options").unwrap(), "DENY");
@ -222,18 +255,22 @@ async fn security_headers_are_applied_to_all_responses() {
);
// X-Forwarded-Proto: https signals the request reached an HTTPS edge.
let https_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/health")
.header("x-forwarded-proto", "https")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let https_response = checked_response(
app.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/health")
.header("x-forwarded-proto", "https")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::OK,
"GET /health with x-forwarded-proto=https",
)
.await;
assert_eq!(
https_response
.headers()
@ -243,8 +280,8 @@ async fn security_headers_are_applied_to_all_responses() {
);
// SPA fallback path must also get the headers.
let spa_response = app
.oneshot(
let spa_response = checked_response(
app.oneshot(
Request::builder()
.method("GET")
.uri("/runs/abc123")
@ -253,8 +290,11 @@ async fn security_headers_are_applied_to_all_responses() {
.unwrap(),
)
.await
.unwrap();
assert_eq!(spa_response.status(), StatusCode::OK);
.unwrap(),
StatusCode::OK,
"GET /runs/abc123",
)
.await;
assert_eq!(
spa_response.headers().get("x-frame-options").unwrap(),
"DENY"
@ -310,7 +350,10 @@ enabled = false
create_app_state_with_options(settings, 5),
AuthMode::Disabled,
Arc::new(IpAllowlistConfig::default()),
RouterOptions { web_enabled: false },
RouterOptions {
web_enabled: false,
..RouterOptions::default()
},
);
for (method, path, body) in [
@ -334,7 +377,7 @@ enabled = false
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {path}");
response_status(response, StatusCode::NOT_FOUND, format!("{method} {path}")).await;
}
let settings_request = Request::builder()
@ -343,7 +386,7 @@ enabled = false
.body(Body::empty())
.unwrap();
let settings_response = app.clone().oneshot(settings_request).await.unwrap();
assert_eq!(settings_response.status(), StatusCode::OK);
response_status(settings_response, StatusCode::OK, "GET /api/v1/settings").await;
let health_request = Request::builder()
.method("GET")
@ -351,7 +394,7 @@ enabled = false
.body(Body::empty())
.unwrap();
let health_response = app.oneshot(health_request).await.unwrap();
assert_eq!(health_response.status(), StatusCode::OK);
response_status(health_response, StatusCode::OK, "GET /health").await;
}
#[tokio::test]
@ -369,7 +412,10 @@ enabled = false
create_app_state_with_options(settings, 5),
AuthMode::Disabled,
Arc::new(IpAllowlistConfig::default()),
RouterOptions { web_enabled: false },
RouterOptions {
web_enabled: false,
..RouterOptions::default()
},
);
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
@ -381,7 +427,7 @@ enabled = false
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
response_status(response, StatusCode::NOT_FOUND, "GET /api/v1/runs/{id}").await;
}
#[tokio::test]
@ -404,7 +450,7 @@ async fn allowlist_blocks_non_allowlisted_api_requests() {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
response_status(response, StatusCode::FORBIDDEN, "GET /api/v1/runs").await;
}
#[tokio::test]
@ -427,7 +473,7 @@ async fn allowlist_exempts_health_checks() {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response_status(response, StatusCode::OK, "GET /health").await;
}
fn request_with_connect_info(path: &str, ip: IpAddr) -> Request<Body> {

View file

@ -13,7 +13,9 @@ use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::build_router;
use tower::ServiceExt;
use crate::helpers::{MINIMAL_DOT, api, body_json, minimal_manifest_json, test_app_state};
use crate::helpers::{
MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status, test_app_state,
};
fn files_url(run_id: &str) -> String {
api(&format!("/runs/{run_id}/files"))
@ -28,7 +30,12 @@ async fn invalid_run_id_returns_400() {
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
response_status(
resp,
StatusCode::BAD_REQUEST,
"GET /api/v1/runs/not-a-ulid/files",
)
.await;
}
#[tokio::test]
@ -42,7 +49,12 @@ async fn unknown_run_returns_404() {
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
response_status(
resp,
StatusCode::NOT_FOUND,
format!("GET /api/v1/runs/{fake}/files"),
)
.await;
}
#[tokio::test]
@ -55,8 +67,12 @@ async fn malformed_from_sha_query_returns_400() {
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = body_json(resp.into_body()).await;
let body = crate::helpers::response_json(
resp,
StatusCode::BAD_REQUEST,
format!("{}:{}", file!(), line!()),
)
.await;
assert!(
body["errors"][0]["detail"]
.as_str()
@ -80,7 +96,12 @@ async fn non_default_from_sha_returns_400_even_when_hex() {
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
response_status(
resp,
StatusCode::BAD_REQUEST,
format!("GET /api/v1/runs/{fake}/files?from_sha=<non-default>"),
)
.await;
}
#[tokio::test]
@ -93,7 +114,12 @@ async fn malformed_to_sha_returns_400() {
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
response_status(
resp,
StatusCode::BAD_REQUEST,
format!("GET /api/v1/runs/{fake}/files?to_sha=xyz"),
)
.await;
}
#[tokio::test]
@ -110,8 +136,7 @@ async fn submitted_run_without_sandbox_returns_empty_envelope() {
.body(Body::from(serde_json::to_string(&manifest).unwrap()))
.unwrap();
let create_resp = app.clone().oneshot(create_req).await.unwrap();
assert_eq!(create_resp.status(), StatusCode::CREATED);
let create_body = body_json(create_resp.into_body()).await;
let create_body = response_json(create_resp, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = create_body["id"].as_str().unwrap().to_string();
let req = Request::builder()
@ -120,8 +145,12 @@ async fn submitted_run_without_sandbox_returns_empty_envelope() {
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp.into_body()).await;
let body = response_json(
resp,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/files"),
)
.await;
assert!(
body["data"].as_array().is_some_and(Vec::is_empty),
"expected empty data: {body}"
@ -146,8 +175,7 @@ async fn demo_mode_returns_fixture_without_touching_store() {
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp.into_body()).await;
let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/files").await;
// Demo fixture ships three entries (modified + added + renamed).
let data = body["data"].as_array().expect("data array");
@ -175,8 +203,7 @@ async fn response_envelope_matches_openapi_paginated_run_file_list_shape() {
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp.into_body()).await;
let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/files").await;
assert!(body["data"].is_array());
assert!(body["meta"].is_object());

View file

@ -7,7 +7,7 @@ use serde_json::json;
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, body_json, minimal_manifest_json, test_app_state_with_options,
MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, test_app_state_with_options,
};
#[tokio::test]
@ -82,8 +82,12 @@ session_sandboxes = true
.unwrap();
let response = app.oneshot(get_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/settings"),
)
.await;
assert_eq!(body["_version"], 1);
assert_eq!(body["run"]["goal"], "Ship it");
assert_eq!(body["cli"]["output"]["verbosity"], "verbose");

View file

@ -7,7 +7,7 @@ use fabro_types::settings::SettingsLayer;
use serde_json::json;
use tower::ServiceExt;
use crate::helpers::body_json;
use crate::helpers::{body_json, checked_response, response_json};
#[tokio::test]
async fn retrieve_server_settings_default_view_returns_redacted_layer_settings() {
@ -54,8 +54,7 @@ server_only = "1"
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(response, StatusCode::OK, "GET /api/v1/settings").await;
assert_eq!(body["_version"], 1);
assert_eq!(body["server"]["storage"]["root"], "/srv/fabro");
assert_eq!(body["server"]["scheduler"]["max_concurrent_runs"], 9);
@ -119,7 +118,12 @@ server_only = "1"
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = checked_response(
response,
StatusCode::OK,
"GET /api/v1/settings?view=resolved",
)
.await;
assert_eq!(
response
.headers()

View file

@ -19,9 +19,9 @@ use tokio::time::timeout;
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, minimal_manifest_json,
minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_scheduler,
test_settings, wait_for_run_status,
MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, api, checked_response, minimal_manifest_json,
minimal_manifest_json_with_dry_run, response_json, response_status,
test_app_state_with_options, test_app_with_scheduler, test_settings, wait_for_run_status,
};
const HUMAN_GATE_DOT: &str = r#"digraph GateTest {
@ -61,7 +61,7 @@ async fn create_run(app: &axum::Router, manifest: serde_json::Value) -> String {
))
.expect("create-run request should build");
let response = app.clone().oneshot(request).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await;
body["id"]
.as_str()
.expect("create-run response should include an id")
@ -75,7 +75,12 @@ async fn start_run(app: &axum::Router, run_id: &str) {
.body(Body::empty())
.expect("start-run request should build");
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response_status(
response,
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/start"),
)
.await;
}
async fn wait_for_question(app: &axum::Router, run_id: &str) -> serde_json::Value {
@ -86,7 +91,12 @@ async fn wait_for_question(app: &axum::Router, run_id: &str) -> serde_json::Valu
.body(Body::empty())
.expect("questions request should build");
let response = app.clone().oneshot(request).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions"),
)
.await;
if let Some(question) = body["data"].as_array().and_then(|items| items.first()) {
return question.clone();
}
@ -102,8 +112,12 @@ async fn load_questions(app: &axum::Router, run_id: &str) -> serde_json::Value {
.body(Body::empty())
.expect("questions request should build");
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
body_json(response.into_body()).await
response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions"),
)
.await
}
#[tokio::test]
@ -121,8 +135,7 @@ async fn get_system_info_returns_runtime_fields() {
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(response, StatusCode::OK, "GET /api/v1/system/info").await;
assert!(body["version"].as_str().is_some());
assert_eq!(body["storage_engine"], "slatedb");
assert_eq!(
@ -181,8 +194,12 @@ async fn get_system_disk_usage_returns_summary_and_verbose_rows() {
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
"GET /api/v1/system/df?verbose=true",
)
.await;
assert!(body["summary"].is_array());
assert!(body["total_size_bytes"].as_i64().unwrap_or_default() > 0);
assert!(
@ -216,8 +233,12 @@ async fn prune_runs_supports_dry_run_and_deletion() {
.body(Body::from(r#"{"before":"9999"}"#))
.unwrap();
let dry_run_response = app.clone().oneshot(dry_run_request).await.unwrap();
assert_eq!(dry_run_response.status(), StatusCode::OK);
let dry_run_body = body_json(dry_run_response.into_body()).await;
let dry_run_body = response_json(
dry_run_response,
StatusCode::OK,
"POST /api/v1/system/prune/runs",
)
.await;
assert_eq!(dry_run_body["dry_run"], true);
assert_eq!(dry_run_body["total_count"], 1);
assert_eq!(dry_run_body["runs"][0]["run_id"], run_id);
@ -230,8 +251,12 @@ async fn prune_runs_supports_dry_run_and_deletion() {
.body(Body::from(r#"{"dry_run":false,"before":"9999"}"#))
.unwrap();
let delete_response = app.clone().oneshot(delete_request).await.unwrap();
assert_eq!(delete_response.status(), StatusCode::OK);
let delete_body = body_json(delete_response.into_body()).await;
let delete_body = response_json(
delete_response,
StatusCode::OK,
"POST /api/v1/system/prune/runs",
)
.await;
assert_eq!(delete_body["dry_run"], false);
assert_eq!(delete_body["deleted_count"], 1);
assert!(!run_dir.exists());
@ -250,8 +275,12 @@ async fn attach_events_streams_only_matching_run_ids() {
.uri(api(&format!("/attach?run_id={run_one}")))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = checked_response(
app.clone().oneshot(request).await.unwrap(),
StatusCode::OK,
format!("GET /api/v1/attach?run_id={run_one}"),
)
.await;
let content_type = response
.headers()
.get("content-type")

View file

@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use axum::http::StatusCode;
use fabro_config::ServerState;
use fabro_server::bind::Bind;
use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig};
@ -21,7 +22,7 @@ use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::sleep;
use crate::helpers::api;
use crate::helpers::{api, reqwest_status};
const TEST_DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
@ -111,7 +112,8 @@ async fn spawn_served_listener(
async fn wait_for_health(client: &fabro_http::HttpClient, url: &str) {
for _ in 0..50 {
if let Ok(response) = client.get(url).send().await {
if response.status() == 200 {
let status = response.status();
if status == 200 {
return;
}
}
@ -150,7 +152,7 @@ methods = ["dev-token"]
.await
.expect("plain HTTP request should succeed");
assert_eq!(response.status(), 200);
reqwest_status(response, StatusCode::OK, "GET /api/v1/runs").await;
handle.abort();
}
@ -165,7 +167,7 @@ async fn tcp_dev_token_auth_uses_bearer_auth() {
let url = format!("http://127.0.0.1:{}{}", addr.port(), api("/runs"));
let unauthorized = client.get(&url).send().await.unwrap();
assert_eq!(unauthorized.status(), 401);
reqwest_status(unauthorized, StatusCode::UNAUTHORIZED, "GET /api/v1/runs").await;
let authorized = client
.get(url)
@ -173,7 +175,7 @@ async fn tcp_dev_token_auth_uses_bearer_auth() {
.send()
.await
.unwrap();
assert_eq!(authorized.status(), 200);
reqwest_status(authorized, StatusCode::OK, "GET /api/v1/runs").await;
}
#[cfg(unix)]
@ -209,7 +211,7 @@ methods = ["dev-token"]
.await
.expect("Unix-socket HTTP request should succeed");
assert_eq!(response.status(), 200);
reqwest_status(response, StatusCode::OK, "GET /api/v1/runs").await;
handle.abort();
}
@ -231,5 +233,5 @@ async fn tcp_ip_allowlist_uses_connect_info() {
.await
.unwrap();
assert_eq!(response.status(), 403);
reqwest_status(response, StatusCode::FORBIDDEN, "GET /api/v1/runs").await;
}

View file

@ -1,3 +1,4 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@ -8,6 +9,10 @@ use fabro_server::server::{
AppState, build_router, create_app_state, create_app_state_with_env_lookup,
create_app_state_with_options_and_registry_factory, spawn_scheduler,
};
use fabro_test::{
assert_axum_status, assert_reqwest_status, expect_axum_json, expect_axum_status,
expect_axum_status_in, expect_axum_text,
};
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::run::{LocalSandboxLayer, RunLayer, RunSandboxLayer, WorktreeMode};
use tokio::time::sleep;
@ -77,6 +82,24 @@ pub(crate) fn api(path: &str) -> String {
format!("/api/v1{path}")
}
pub(crate) fn repo_root() -> PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.expect("fabro-server crate should be nested under lib/crates/fabro-server")
.to_path_buf()
}
#[expect(
clippy::disallowed_methods,
reason = "test fixture reads tracked files synchronously"
)]
pub(crate) fn read_repo_file(relative_path: &str) -> String {
let path = repo_root().join(relative_path);
std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()))
}
pub(crate) async fn body_json(body: Body) -> serde_json::Value {
let bytes = to_bytes(body, usize::MAX)
.await
@ -84,6 +107,54 @@ pub(crate) async fn body_json(body: Body) -> serde_json::Value {
serde_json::from_slice(&bytes).expect("response body should be valid JSON")
}
pub(crate) async fn response_status(
response: axum::response::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) {
assert_axum_status(response, expected, context).await;
}
pub(crate) async fn response_json(
response: axum::response::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) -> serde_json::Value {
expect_axum_json(response, expected, context).await
}
pub(crate) async fn response_text(
response: axum::response::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) -> String {
expect_axum_text(response, expected, context).await
}
pub(crate) async fn checked_response(
response: axum::response::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) -> axum::response::Response {
expect_axum_status(response, expected, context).await
}
pub(crate) async fn checked_response_in(
response: axum::response::Response,
expected: &[StatusCode],
context: impl std::fmt::Display,
) -> axum::response::Response {
expect_axum_status_in(response, expected, context).await
}
pub(crate) async fn reqwest_status(
response: fabro_http::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) {
assert_reqwest_status(response, expected, context).await;
}
pub(crate) async fn create_and_start_run_from_manifest(
app: &axum::Router,
manifest: serde_json::Value,
@ -97,7 +168,7 @@ pub(crate) async fn create_and_start_run_from_manifest(
))
.expect("create-run request should build");
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = body["id"]
.as_str()
.expect("create-run response should include an id")
@ -108,7 +179,12 @@ pub(crate) async fn create_and_start_run_from_manifest(
.uri(api(&format!("/runs/{run_id}/start")))
.body(Body::empty())
.expect("start-run request should build");
app.clone().oneshot(req).await.unwrap();
response_status(
app.clone().oneshot(req).await.unwrap(),
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/start"),
)
.await;
run_id
}
@ -143,8 +219,12 @@ pub(crate) async fn run_json(app: &axum::Router, run_id: &str) -> serde_json::Va
.body(Body::empty())
.expect("run lookup request should build");
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
body_json(response.into_body()).await
response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}"),
)
.await
}
pub(crate) async fn wait_for_run_status(

View file

@ -7,28 +7,19 @@
clippy::manual_let_else,
reason = "These spec/router conformance tests prefer direct assertions over pedantic style lints."
)]
#![expect(
clippy::disallowed_methods,
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use fabro_server::install::{InstallAppState, build_install_router};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::build_router;
use fabro_server::server::{build_router, create_app_state_with_env_lookup};
use serde_yaml::Value;
use tower::ServiceExt;
use super::helpers::test_app_state;
use super::helpers::{read_repo_file, test_app_state, test_settings};
fn load_spec() -> Value {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.expect("fabro-server crate should be nested under lib/crates/fabro-server");
let spec_path = repo_root.join("docs/api-reference/fabro-api.yaml");
let text = std::fs::read_to_string(&spec_path).expect("failed to read spec");
let text = read_repo_file("docs/api-reference/fabro-api.yaml");
serde_yaml::from_str(&text).expect("failed to parse spec")
}
@ -122,6 +113,57 @@ async fn all_spec_routes_are_routable() {
assert!(checked > 0, "No routes were checked — is the spec empty?");
}
#[test]
fn github_webhook_spec_and_sdk_describe_a_json_body() {
let spec = load_spec();
let webhook_schema = spec["paths"]["/api/v1/webhooks/github"]["post"]["requestBody"]["content"]
["application/json"]["schema"]
.clone();
assert_eq!(
webhook_schema.get("type").and_then(Value::as_str),
Some("object"),
"GitHub webhook request body should be modeled as JSON, not a binary file upload"
);
assert!(
webhook_schema.get("format").is_none(),
"GitHub webhook JSON schema should not declare a binary format"
);
let generated_client =
read_repo_file("lib/packages/fabro-api-client/src/api/integrations-api.ts");
assert!(
!generated_client.contains("@param {File} body"),
"generated TypeScript client should not expose the webhook body as File"
);
assert!(
!generated_client.contains("receiveGithubWebhook: async (body: File"),
"generated TypeScript client should not require File for a JSON webhook payload"
);
}
#[tokio::test]
async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() {
let secret = "test-webhook-secret".to_string();
let app = build_router(
create_app_state_with_env_lookup(test_settings(), 5, move |name| {
(name == "GITHUB_APP_WEBHOOK_SECRET").then(|| secret.clone())
}),
AuthMode::Disabled,
);
let response = app
.oneshot(request_for(&Method::POST, "/api/v1/webhooks/github"))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"Webhook spec route should be mounted when GITHUB_APP_WEBHOOK_SECRET is present"
);
}
#[tokio::test]
async fn install_and_normal_routes_stay_isolated() {
let spec = load_spec();

View file

@ -11,7 +11,7 @@ use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::build_router;
use tower::ServiceExt;
use super::helpers::test_app_state;
use super::helpers::{response_json, test_app_state};
async fn get_json(app: axum::Router, uri: &str) -> serde_json::Value {
let req = Request::builder()
@ -21,11 +21,7 @@ async fn get_json(app: axum::Router, uri: &str) -> serde_json::Value {
.body(Body::empty())
.expect("pagination request should build");
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK, "GET {uri} failed");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("pagination response body should fit in memory");
serde_json::from_slice(&body).expect("pagination response should be valid JSON")
response_json(response, StatusCode::OK, format!("GET {uri}")).await
}
/// Assert that a value has the paginated shape: `{ data: [...], meta: {

View file

@ -3,8 +3,8 @@ use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, body_json, create_and_start_run_from_manifest,
minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_scheduler,
MINIMAL_DOT, api, create_and_start_run_from_manifest, minimal_manifest_json_with_dry_run,
response_json, response_status, test_app_state_with_options, test_app_with_scheduler,
test_settings, wait_for_run_status,
};
@ -25,8 +25,12 @@ async fn archived_runs_reject_mutations_with_actionable_body() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/archive"),
)
.await;
assert_eq!(body["status"], "archived");
for path in &["/cancel", "/pause", "/unpause", "/start"] {
@ -36,12 +40,12 @@ async fn archived_runs_reject_mutations_with_actionable_body() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(
response.status(),
let body = response_json(
response,
StatusCode::CONFLICT,
"expected 409 for POST /runs/{{id}}{path} on archived run"
);
let body = body_json(response.into_body()).await;
format!("POST /api/v1/runs/{run_id}{path}"),
)
.await;
let detail = body["errors"][0]["detail"].as_str().unwrap_or_default();
assert!(
detail.contains("is archived") && detail.contains("fabro unarchive"),
@ -65,10 +69,16 @@ async fn archived_runs_reject_mutations_with_actionable_body() {
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(
response.status(),
let body = response_json(
response,
StatusCode::CONFLICT,
"expected 409 for POST /runs/{{id}}/events on archived run"
format!("POST /api/v1/runs/{run_id}/events"),
)
.await;
let detail = body["errors"][0]["detail"].as_str().unwrap_or_default();
assert!(
detail.contains("is archived") && detail.contains("fabro unarchive"),
"expected archived-rejection body on /events, got: {body}"
);
// The archive guard runs before each endpoint's state-specific lookups, so
@ -107,12 +117,12 @@ async fn archived_runs_reject_mutations_with_actionable_body() {
.body(Body::from(body))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(
response.status(),
let body = response_json(
response,
StatusCode::CONFLICT,
"expected 409 for {method} {path} on archived run"
);
let body = body_json(response.into_body()).await;
format!("{method} /api/v1{path}"),
)
.await;
let detail = body["errors"][0]["detail"].as_str().unwrap_or_default();
assert!(
detail.contains("is archived") && detail.contains("fabro unarchive"),
@ -127,8 +137,12 @@ async fn archived_runs_reject_mutations_with_actionable_body() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/unarchive"),
)
.await;
assert_eq!(body["status"], "succeeded");
}
@ -160,8 +174,12 @@ async fn appending_run_archived_event_directly_is_rejected() {
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_json(response.into_body()).await;
let body = crate::helpers::response_json(
response,
StatusCode::BAD_REQUEST,
format!("{}:{}", file!(), line!()),
)
.await;
let detail = body["errors"][0]["detail"].as_str().unwrap_or_default();
assert!(
detail.contains("lifecycle event"),
@ -180,7 +198,12 @@ async fn archive_returns_404_for_unknown_run() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
response_status(
response,
StatusCode::NOT_FOUND,
"POST /api/v1/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/archive",
)
.await;
let req = Request::builder()
.method("POST")
@ -188,7 +211,12 @@ async fn archive_returns_404_for_unknown_run() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
response_status(
response,
StatusCode::NOT_FOUND,
"POST /api/v1/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/unarchive",
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@ -207,7 +235,12 @@ async fn list_runs_respects_include_archived_flag() {
.uri(api(&format!("/runs/{run_id}/archive")))
.body(Body::empty())
.unwrap();
app.clone().oneshot(req).await.unwrap();
response_status(
app.clone().oneshot(req).await.unwrap(),
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/archive"),
)
.await;
// Default listing hides archived.
let req = Request::builder()
@ -216,7 +249,7 @@ async fn list_runs_respects_include_archived_flag() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(response, StatusCode::OK, "GET /api/v1/runs").await;
let ids_visible: Vec<String> = body["data"]
.as_array()
.unwrap()
@ -235,7 +268,12 @@ async fn list_runs_respects_include_archived_flag() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
"GET /api/v1/runs?include_archived=true",
)
.await;
let ids_all: Vec<String> = body["data"]
.as_array()
.unwrap()

View file

@ -4,9 +4,10 @@ use httpmock::MockServer;
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, body_json, create_and_start_run_from_manifest, minimal_manifest_json,
minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_mock_anthropic,
test_app_with_no_providers, test_app_with_scheduler, test_settings, wait_for_run_status,
MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest, minimal_manifest_json,
minimal_manifest_json_with_dry_run, response_json, response_status,
test_app_state_with_options, test_app_with_mock_anthropic, test_app_with_no_providers,
test_app_with_scheduler, test_settings, wait_for_run_status,
};
fn completion_request(stream: bool) -> Request<Body> {
@ -65,9 +66,12 @@ async fn test_model_skip_when_no_providers() {
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
"POST /api/v1/models/claude-opus-4-6/test",
)
.await;
assert_eq!(body["model_id"], "claude-opus-4-6");
assert_eq!(body["status"], "skip");
}
@ -84,7 +88,12 @@ async fn test_model_unknown_via_full_router() {
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
response_status(
response,
StatusCode::NOT_FOUND,
"POST /api/v1/models/nonexistent-model-xyz/test",
)
.await;
}
#[tokio::test]
@ -101,7 +110,7 @@ async fn dry_run_serve_rejects_invalid_dot() {
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
response_status(response, StatusCode::BAD_REQUEST, "POST /api/v1/runs").await;
}
#[tokio::test]
@ -109,7 +118,12 @@ async fn completion_no_provider_non_streaming_returns_502() {
let app = test_app_with_no_providers();
let response = app.oneshot(completion_request(false)).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
response_status(
response,
StatusCode::BAD_GATEWAY,
"POST /api/v1/completions",
)
.await;
}
#[tokio::test]
@ -117,7 +131,12 @@ async fn completion_no_provider_streaming_returns_502() {
let app = test_app_with_no_providers();
let response = app.oneshot(completion_request(true)).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
response_status(
response,
StatusCode::BAD_GATEWAY,
"POST /api/v1/completions?stream=true",
)
.await;
}
#[tokio::test]
@ -149,9 +168,7 @@ async fn completion_non_streaming_returns_valid_json() {
.oneshot(completion_request_with_model(false, "claude-sonnet-4-5"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(response, StatusCode::OK, "POST /api/v1/completions").await;
assert!(body["id"].is_string());
assert_eq!(body["model"], "claude-sonnet-4-5");
assert_eq!(body["stop_reason"], "end_turn");
@ -185,7 +202,7 @@ async fn completion_streaming_returns_sse() {
.oneshot(completion_request_with_model(true, "claude-sonnet-4-5"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = checked_response(response, StatusCode::OK, "POST /api/v1/completions").await;
let content_type = response
.headers()
.get("content-type")

View file

@ -16,8 +16,8 @@ use tokio::time::sleep;
use tower::ServiceExt;
use crate::helpers::{
POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, minimal_manifest_json, run_json, test_settings,
wait_for_run_status,
POLL_ATTEMPTS, POLL_INTERVAL, api, minimal_manifest_json, response_json, response_status,
run_json, test_settings, wait_for_run_status,
};
fn gate_registry(interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
@ -37,7 +37,12 @@ async fn wait_for_question_id(app: &axum::Router, run_id: &str) -> String {
.body(Body::empty())
.expect("questions request should build");
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions"),
)
.await;
let arr = body["data"]
.as_array()
.expect("questions response should include a data array");
@ -61,7 +66,12 @@ async fn wait_for_question(app: &axum::Router, run_id: &str) -> serde_json::Valu
.body(Body::empty())
.expect("questions request should build");
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions"),
)
.await;
let arr = body["data"]
.as_array()
.expect("questions response should include a data array");
@ -124,8 +134,7 @@ async fn full_http_lifecycle_approve_and_complete() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = body["id"].as_str().unwrap().to_string();
// 1b. Start the run
@ -135,7 +144,12 @@ async fn full_http_lifecycle_approve_and_complete() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response_status(
response,
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/start"),
)
.await;
// 2. Poll for question to appear (run goes start -> work -> gate, then blocks)
let question = wait_for_question(&app, &run_id).await;
@ -156,7 +170,12 @@ async fn full_http_lifecycle_approve_and_complete() {
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
response_status(
response,
StatusCode::NO_CONTENT,
format!("POST /api/v1/runs/{run_id}/questions/{question_id}/answer"),
)
.await;
// 4. Poll until the run reaches a terminal success or failure state.
let final_status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
@ -169,7 +188,12 @@ async fn full_http_lifecycle_approve_and_complete() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions"),
)
.await;
assert!(
body["data"].as_array().unwrap().is_empty(),
"no pending questions after completion"
@ -192,7 +216,7 @@ async fn full_http_lifecycle_cancel() {
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = body["id"].as_str().unwrap().to_string();
let req = Request::builder()
@ -200,7 +224,12 @@ async fn full_http_lifecycle_cancel() {
.uri(api(&format!("/runs/{run_id}/start")))
.body(Body::empty())
.unwrap();
app.clone().oneshot(req).await.unwrap();
response_status(
app.clone().oneshot(req).await.unwrap(),
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/start"),
)
.await;
// Wait until the worker has reached the human gate so cancel exercises the
// live-running path rather than racing the in-memory queue transition.
@ -213,8 +242,12 @@ async fn full_http_lifecycle_cancel() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/cancel"),
)
.await;
assert_eq!(body["status"], "running");
assert_eq!(body["pending_control"], "cancel");
@ -238,7 +271,7 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() {
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = body["id"].as_str().unwrap().to_string();
let req = Request::builder()
@ -246,7 +279,12 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() {
.uri(api(&format!("/runs/{run_id}/start")))
.body(Body::empty())
.unwrap();
app.clone().oneshot(req).await.unwrap();
response_status(
app.clone().oneshot(req).await.unwrap(),
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/start"),
)
.await;
let _question_id = wait_for_question_id(&app, &run_id).await;
@ -256,7 +294,12 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() {
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response_status(
response,
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/cancel"),
)
.await;
let status = wait_for_run_status(&app, &run_id, &["failed"]).await;
assert_eq!(status, "failed");
@ -267,8 +310,12 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() {
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/events"),
)
.await;
let failed_reasons = body["data"]
.as_array()
.unwrap()

View file

@ -1,11 +1,12 @@
use axum::body::{Body, to_bytes};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tokio::time::sleep;
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, create_and_start_run_from_manifest, minimal_manifest_json_with_dry_run,
test_app_state_with_options, test_app_with_scheduler, test_settings, wait_for_run_status,
MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest,
minimal_manifest_json_with_dry_run, response_text, test_app_state_with_options,
test_app_with_scheduler, test_settings, wait_for_run_status,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@ -39,8 +40,12 @@ async fn attach_run_events_returns_sse_stream() {
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = checked_response(
app.oneshot(req).await.unwrap(),
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/attach"),
)
.await;
let content_type = response
.headers()
.get("content-type")
@ -71,10 +76,12 @@ async fn attach_run_events_replays_terminal_event_after_completion() {
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
let body = response_text(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/attach?since_seq=1"),
)
.await;
let event_names = body
.lines()
.filter_map(|line| line.strip_prefix("data:"))

View file

@ -7,9 +7,10 @@ use tokio::time::{sleep, timeout};
use tower::ServiceExt;
use crate::helpers::{
POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, create_and_start_run_from_manifest,
minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_scheduler,
test_settings, wait_for_run_status_not_in,
POLL_ATTEMPTS, POLL_INTERVAL, api, checked_response, checked_response_in,
create_and_start_run_from_manifest, minimal_manifest_json_with_dry_run, response_json,
test_app_state_with_options, test_app_with_scheduler, test_settings,
wait_for_run_status_not_in,
};
const SIMPLE_DOT: &str = r#"digraph SSETest {
@ -28,9 +29,21 @@ async fn wait_for_checkpoint(app: &axum::Router, run_id: &str) -> serde_json::Va
.body(Body::empty())
.expect("checkpoint request should build");
let response = app.clone().oneshot(req).await.unwrap();
if response.status() == StatusCode::OK {
return body_json(response.into_body()).await;
let status = response.status();
if status == StatusCode::OK {
return response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/checkpoint"),
)
.await;
}
checked_response_in(
response,
&[StatusCode::OK, StatusCode::NOT_FOUND],
format!("GET /api/v1/runs/{run_id}/checkpoint"),
)
.await;
sleep(POLL_INTERVAL).await;
}
panic!("checkpoint did not become available for {run_id}");
@ -53,13 +66,12 @@ async fn sse_stream_contains_expected_event_types() {
.uri(api(&format!("/runs/{run_id}/attach")))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let sse_status = response.status();
assert_eq!(
sse_status,
let response = checked_response(
app.clone().oneshot(req).await.unwrap(),
StatusCode::OK,
"expected 200, got: {sse_status}"
);
format!("GET /api/v1/runs/{run_id}/attach"),
)
.await;
let content_type = response
.headers()

View file

@ -4,7 +4,7 @@ use tokio::time::sleep;
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, create_and_start_run_from_manifest,
MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, api, create_and_start_run_from_manifest,
minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_scheduler,
test_settings, wait_for_run_status,
};
@ -31,9 +31,12 @@ async fn aggregate_billing_increments_after_run_completes() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let body = crate::helpers::response_json(
response,
StatusCode::OK,
format!("{}:{}", file!(), line!()),
)
.await;
total_runs = body["totals"]["runs"].as_i64().unwrap();
if total_runs == 1 {
break;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -58,7 +58,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-78vt4q5f.js"></script>
<script type="module" src="/assets/entry-c13135br.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>

View file

@ -0,0 +1,672 @@
use std::fmt::{Display, Write};
use axum::body::to_bytes;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::Response;
const BODY_PREVIEW_LIMIT: usize = 4096;
const BINARY_HEX_PREVIEW_BYTES: usize = 64;
pub async fn expect_axum_status(
response: Response,
expected: StatusCode,
context: impl Display,
) -> Response {
let context = context.to_string();
let status = response.status();
if status == expected {
return response;
}
let headers = response.headers().clone();
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body should fit in memory");
panic!(
"{}",
format_status_mismatch(
&context,
&format_status_list(&[expected]),
status,
None,
&headers,
&bytes,
)
);
}
pub async fn assert_axum_status(response: Response, expected: StatusCode, context: impl Display) {
let _ = expect_axum_status(response, expected, context).await;
}
pub async fn expect_axum_status_in(
response: Response,
expected: &[StatusCode],
context: impl Display,
) -> Response {
let context = context.to_string();
let status = response.status();
if expected.contains(&status) {
return response;
}
let headers = response.headers().clone();
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body should fit in memory");
panic!(
"{}",
format_status_mismatch(
&context,
&format_status_list(expected),
status,
None,
&headers,
&bytes,
)
);
}
pub async fn assert_axum_status_in(
response: Response,
expected: &[StatusCode],
context: impl Display,
) {
let _ = expect_axum_status_in(response, expected, context).await;
}
pub async fn expect_axum_json(
response: Response,
expected: StatusCode,
context: impl Display,
) -> serde_json::Value {
let context = context.to_string();
let response = expect_axum_status(response, expected, &context).await;
let status = response.status();
let headers = response.headers().clone();
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body should fit in memory");
parse_json_or_panic(&context, status, None, &headers, &bytes)
}
pub async fn expect_axum_text(
response: Response,
expected: StatusCode,
context: impl Display,
) -> String {
let context = context.to_string();
let response = expect_axum_status(response, expected, &context).await;
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body should fit in memory");
String::from_utf8_lossy(&bytes).into_owned()
}
pub async fn expect_axum_bytes(
response: Response,
expected: StatusCode,
context: impl Display,
) -> Vec<u8> {
let context = context.to_string();
let response = expect_axum_status(response, expected, &context).await;
to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body should fit in memory")
.to_vec()
}
pub async fn expect_reqwest_status(
response: fabro_http::Response,
expected: StatusCode,
context: impl Display,
) -> fabro_http::Response {
let context = context.to_string();
let status = response.status();
if status == expected {
return response;
}
let url = response.url().clone();
let headers = response.headers().clone();
let bytes = response
.bytes()
.await
.expect("response body should fit in memory");
panic!(
"{}",
format_status_mismatch(
&context,
&format_status_list(&[expected]),
status,
Some(url.as_str()),
&headers,
bytes.as_ref(),
)
);
}
pub async fn assert_reqwest_status(
response: fabro_http::Response,
expected: StatusCode,
context: impl Display,
) {
let _ = expect_reqwest_status(response, expected, context).await;
}
pub async fn expect_reqwest_status_in(
response: fabro_http::Response,
expected: &[StatusCode],
context: impl Display,
) -> fabro_http::Response {
let context = context.to_string();
let status = response.status();
if expected.contains(&status) {
return response;
}
let url = response.url().clone();
let headers = response.headers().clone();
let bytes = response
.bytes()
.await
.expect("response body should fit in memory");
panic!(
"{}",
format_status_mismatch(
&context,
&format_status_list(expected),
status,
Some(url.as_str()),
&headers,
bytes.as_ref(),
)
);
}
pub async fn assert_reqwest_status_in(
response: fabro_http::Response,
expected: &[StatusCode],
context: impl Display,
) {
let _ = expect_reqwest_status_in(response, expected, context).await;
}
pub async fn expect_reqwest_json(
response: fabro_http::Response,
expected: StatusCode,
context: impl Display,
) -> serde_json::Value {
let context = context.to_string();
let response = expect_reqwest_status(response, expected, &context).await;
let status = response.status();
let url = response.url().clone();
let headers = response.headers().clone();
let bytes = response
.bytes()
.await
.expect("response body should fit in memory");
parse_json_or_panic(
&context,
status,
Some(url.as_str()),
&headers,
bytes.as_ref(),
)
}
pub async fn expect_reqwest_text(
response: fabro_http::Response,
expected: StatusCode,
context: impl Display,
) -> String {
let context = context.to_string();
let response = expect_reqwest_status(response, expected, &context).await;
response.text().await.expect("response text should read")
}
pub async fn expect_reqwest_bytes(
response: fabro_http::Response,
expected: StatusCode,
context: impl Display,
) -> Vec<u8> {
let context = context.to_string();
let response = expect_reqwest_status(response, expected, &context).await;
response
.bytes()
.await
.expect("response body should fit in memory")
.to_vec()
}
fn parse_json_or_panic(
context: &str,
status: StatusCode,
url: Option<&str>,
headers: &HeaderMap,
bytes: &[u8],
) -> serde_json::Value {
serde_json::from_slice(bytes).unwrap_or_else(|error| {
panic!(
"{}",
format_json_parse_failure(context, status, url, headers, bytes, &error)
)
})
}
fn format_json_parse_failure(
context: &str,
status: StatusCode,
url: Option<&str>,
headers: &HeaderMap,
bytes: &[u8],
error: &serde_json::Error,
) -> String {
let mut lines = vec![
context.to_string(),
"expected a JSON response body".to_string(),
format!("status: {status}"),
];
if let Some(url) = url {
lines.push(format!("url: {url}"));
}
if let Some(content_type) = header_value(headers, header::CONTENT_TYPE) {
lines.push(format!("content-type: {content_type}"));
}
lines.push(format!("parse error: {error}"));
lines.push("body:".to_string());
lines.push(format_body_preview(headers, bytes));
lines.join("\n")
}
fn format_status_mismatch(
context: &str,
expected: &str,
actual: StatusCode,
url: Option<&str>,
headers: &HeaderMap,
bytes: &[u8],
) -> String {
let mut lines = vec![
context.to_string(),
format!("expected status: {expected}"),
format!("actual status: {actual}"),
];
if let Some(url) = url {
lines.push(format!("url: {url}"));
}
if let Some(content_type) = header_value(headers, header::CONTENT_TYPE) {
lines.push(format!("content-type: {content_type}"));
}
if let Some(location) = header_value(headers, header::LOCATION) {
lines.push(format!("location: {location}"));
}
lines.push("body:".to_string());
lines.push(format_body_preview(headers, bytes));
lines.join("\n")
}
fn format_status_list(statuses: &[StatusCode]) -> String {
statuses
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" or ")
}
fn header_value(headers: &HeaderMap, name: header::HeaderName) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string)
}
fn format_body_preview(headers: &HeaderMap, bytes: &[u8]) -> String {
if bytes.is_empty() {
return "<empty body>".to_string();
}
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(bytes) {
let rendered = serde_json::to_string_pretty(&json).expect("JSON value should pretty print");
return truncate_text(&rendered, BODY_PREVIEW_LIMIT);
}
if std::str::from_utf8(bytes).is_ok() || content_type_is_textual(headers) {
let rendered = String::from_utf8_lossy(bytes);
return truncate_text(&rendered, BODY_PREVIEW_LIMIT);
}
let preview_len = bytes.len().min(BINARY_HEX_PREVIEW_BYTES);
let hex = bytes[..preview_len]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<Vec<_>>()
.join(" ");
let mut line = format!("binary body ({} bytes)", bytes.len());
if bytes.len() > preview_len {
let _ = write!(line, " [truncated to first {preview_len} bytes]");
}
line.push('\n');
line.push_str(&hex);
line
}
fn content_type_is_textual(headers: &HeaderMap) -> bool {
let Some(content_type) = header_value(headers, header::CONTENT_TYPE) else {
return false;
};
let content_type = content_type.to_ascii_lowercase();
content_type.starts_with("text/")
|| content_type.contains("json")
|| content_type.contains("xml")
|| content_type.contains("javascript")
|| content_type.contains("svg")
|| content_type.contains("x-www-form-urlencoded")
}
fn truncate_text(text: &str, byte_limit: usize) -> String {
if text.len() <= byte_limit {
return text.to_string();
}
let mut end = byte_limit;
while !text.is_char_boundary(end) {
end -= 1;
}
format!(
"{}\n[truncated from {} bytes to {} bytes]",
&text[..end],
text.len(),
byte_limit
)
}
#[cfg(test)]
mod tests {
use std::panic;
use axum::body::Body;
use axum::http::HeaderValue;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router, serve};
use serde_json::json;
use tokio::net::TcpListener;
use tokio::runtime::Runtime;
use super::*;
fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
match payload.downcast::<String>() {
Ok(message) => *message,
Err(payload) => match payload.downcast::<&'static str>() {
Ok(message) => (*message).to_string(),
Err(_) => "<non-string panic>".to_string(),
},
}
}
fn catch_async_panic(future: impl std::future::Future<Output = ()>) -> String {
let runtime = Runtime::new().expect("test runtime should build");
let payload = panic::catch_unwind(panic::AssertUnwindSafe(|| runtime.block_on(future)))
.expect_err("future should panic");
panic_message(payload)
}
async fn catch_task_panic(
future: impl std::future::Future<Output = ()> + Send + 'static,
) -> String {
let join_error = tokio::spawn(future).await.expect_err("future should panic");
panic_message(join_error.into_panic())
}
async fn start_test_server(router: Router) -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("test listener should bind");
let addr = listener
.local_addr()
.expect("test listener should expose local addr");
tokio::spawn(async move {
serve(listener, router)
.await
.expect("test server should stay alive");
});
format!("http://{addr}")
}
#[test]
fn axum_mismatch_pretty_prints_json_body() {
let response = (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "boom", "details": { "request_id": "req-123" } })),
)
.into_response();
let panic = catch_async_panic(async move {
let _ = expect_axum_status(response, StatusCode::OK, "GET /api/v1/runs/1/graph").await;
});
assert!(panic.contains("GET /api/v1/runs/1/graph"));
assert!(panic.contains("expected status: 200 OK"));
assert!(panic.contains("actual status: 500 Internal Server Error"));
assert!(panic.contains("\"error\": \"boom\""));
assert!(panic.contains("\"request_id\": \"req-123\""));
}
#[test]
fn axum_mismatch_pretty_prints_text_body() {
let response = (
StatusCode::BAD_REQUEST,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
)],
"bad request body",
)
.into_response();
let panic = catch_async_panic(async move {
let _ = expect_axum_status(response, StatusCode::OK, "GET /health").await;
});
assert!(panic.contains("bad request body"));
assert!(panic.contains("content-type: text/plain; charset=utf-8"));
}
#[test]
fn axum_mismatch_describes_binary_body() {
let response = (
StatusCode::BAD_GATEWAY,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
)],
vec![0, 159, 146, 150, 1, 2, 3, 4],
)
.into_response();
let panic = catch_async_panic(async move {
let _ = expect_axum_status(response, StatusCode::OK, "GET /blob").await;
});
assert!(panic.contains("binary body (8 bytes)"));
assert!(panic.contains("00 9f 92 96 01 02 03 04"));
}
#[test]
fn axum_status_preserves_successful_response() {
let response = (
StatusCode::OK,
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
"still here",
)
.into_response();
let runtime = Runtime::new().expect("test runtime should build");
runtime.block_on(async move {
let response = expect_axum_status(response, StatusCode::OK, "GET /ok").await;
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.expect("content-type should exist");
assert_eq!(content_type, "text/plain");
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should fit in memory");
assert_eq!(&body[..], b"still here");
});
}
#[test]
fn axum_status_in_accepts_multiple_statuses() {
let response = (StatusCode::NO_CONTENT, Body::empty()).into_response();
let runtime = Runtime::new().expect("test runtime should build");
runtime.block_on(async move {
let response = expect_axum_status_in(
response,
&[StatusCode::OK, StatusCode::NO_CONTENT],
"DELETE /api/v1/runs/1",
)
.await;
let status = response.status();
assert_eq!(status, StatusCode::NO_CONTENT);
});
}
#[test]
fn axum_json_parse_failure_includes_raw_body() {
let response = (
StatusCode::OK,
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
"not-json",
)
.into_response();
let panic = catch_async_panic(async move {
let _ = expect_axum_json(response, StatusCode::OK, "GET /api/v1/settings").await;
});
assert!(panic.contains("expected a JSON response body"));
assert!(panic.contains("not-json"));
}
#[tokio::test]
async fn reqwest_mismatch_includes_url_and_json_body() {
let base_url = start_test_server(Router::new().route(
"/fail",
get(|| async {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "boom", "request_id": "req-9" })),
)
}),
))
.await;
let client = fabro_http::test_http_client().expect("test client should build");
let response = client
.get(format!("{base_url}/fail"))
.send()
.await
.expect("request should complete");
let panic = catch_task_panic(async move {
let _ = expect_reqwest_status(response, StatusCode::OK, "GET /fail").await;
})
.await;
assert!(panic.contains("GET /fail"));
assert!(panic.contains("url:"));
assert!(panic.contains("/fail"));
assert!(panic.contains("\"error\": \"boom\""));
assert!(panic.contains("\"request_id\": \"req-9\""));
}
#[tokio::test]
async fn reqwest_status_preserves_successful_response() {
let base_url = start_test_server(Router::new().route("/ok", get(|| async { "ok" }))).await;
let client = fabro_http::test_http_client().expect("test client should build");
let response = client
.get(format!("{base_url}/ok"))
.send()
.await
.expect("request should complete");
let response = expect_reqwest_status(response, StatusCode::OK, "GET /ok").await;
let text = response.text().await.expect("text body should read");
assert_eq!(text, "ok");
}
#[tokio::test]
async fn reqwest_status_in_accepts_multiple_statuses() {
let base_url = start_test_server(Router::new().route(
"/delete",
get(|| async { (StatusCode::NO_CONTENT, Body::empty()) }),
))
.await;
let client = fabro_http::test_http_client().expect("test client should build");
let response = client
.get(format!("{base_url}/delete"))
.send()
.await
.expect("request should complete");
let response = expect_reqwest_status_in(
response,
&[StatusCode::OK, StatusCode::NO_CONTENT],
"DELETE /delete",
)
.await;
let status = response.status();
assert_eq!(status, StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn reqwest_json_parse_failure_includes_raw_body() {
let base_url = start_test_server(Router::new().route(
"/text",
get(|| async {
(
StatusCode::OK,
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
"not-json",
)
}),
))
.await;
let client = fabro_http::test_http_client().expect("test client should build");
let response = client
.get(format!("{base_url}/text"))
.send()
.await
.expect("request should complete");
let panic = catch_task_panic(async move {
let _ = expect_reqwest_json(response, StatusCode::OK, "GET /text").await;
})
.await;
assert!(panic.contains("expected a JSON response body"));
assert!(panic.contains("not-json"));
}
#[test]
fn truncated_bodies_say_they_are_truncated() {
let long_text = "x".repeat(5000);
let response = (
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
long_text,
)
.into_response();
let panic = catch_async_panic(async move {
let _ = expect_axum_status(response, StatusCode::OK, "GET /too-large").await;
});
assert!(panic.contains("[truncated"));
}
}

View file

@ -21,6 +21,15 @@ use serde_json::{Map, Value, json};
use toml::Value as TomlValue;
use toml::map::Map as TomlMap;
mod http_assert;
pub use http_assert::{
assert_axum_status, assert_axum_status_in, assert_reqwest_status, assert_reqwest_status_in,
expect_axum_bytes, expect_axum_json, expect_axum_status, expect_axum_status_in,
expect_axum_text, expect_reqwest_bytes, expect_reqwest_json, expect_reqwest_status,
expect_reqwest_status_in, expect_reqwest_text,
};
/// Re-export `LLVM_PROFILE_FILE` into a `Command` whose env was just cleared,
/// so subprocess coverage data lands in the profile path that
/// `cargo-llvm-cov` configured for the parent test process. Accepts both
@ -213,7 +222,7 @@ pub fn stop_pid(pid: u32) {
fabro_proc::sigterm(pid);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if !fabro_proc::process_alive(pid) {
if !fabro_proc::process_running(pid) {
return;
}
poll_sleep();
@ -474,7 +483,7 @@ fn live_marker_count(root: &Path) -> usize {
.map(|pid| (pid, entry.path()))
})
.filter(|(pid, path)| {
if fabro_proc::process_alive(*pid) {
if fabro_proc::process_running(*pid) {
true
} else {
let _ = std::fs::remove_file(path);
@ -740,7 +749,7 @@ fn server_record_pid(storage_dir: &Path) -> Option<u32> {
}
fn server_running(server: &ServerPaths) -> bool {
server_record_pid(&server.storage_dir).is_some_and(fabro_proc::process_alive)
server_record_pid(&server.storage_dir).is_some_and(fabro_proc::process_running)
}
#[expect(
@ -823,11 +832,11 @@ fn stop_test_server(server: &ServerPaths) {
let poll = std::time::Duration::from_millis(50);
let timeout = test_server_stop_timeout();
let mut elapsed = std::time::Duration::ZERO;
while elapsed < timeout && fabro_proc::process_alive(pid) {
while elapsed < timeout && fabro_proc::process_running(pid) {
std::thread::sleep(poll);
elapsed += poll;
}
if fabro_proc::process_alive(pid) {
if fabro_proc::process_running(pid) {
fabro_proc::sigkill(pid);
}
@ -1620,11 +1629,7 @@ impl TwinOpenAi {
.send()
.await
.expect("reset twin-openai namespace");
assert!(
response.status().is_success(),
"reset twin-openai namespace failed: {}",
response.status()
);
assert_reqwest_status(response, fabro_http::StatusCode::OK, "POST /__admin/reset").await;
}
}
@ -1661,11 +1666,12 @@ impl TwinScenarios {
.send()
.await
.expect("load twin-openai scenarios");
assert!(
response.status().is_success(),
"load twin-openai scenarios failed: {}",
response.status()
);
assert_reqwest_status(
response,
fabro_http::StatusCode::OK,
"POST /__admin/scenarios",
)
.await;
}
}
@ -1880,7 +1886,8 @@ pub async fn twin_openai() -> &'static TwinOpenAi {
let healthz_url = format!("http://127.0.0.1:{}/healthz", addr.port());
for _ in 0..50 {
if let Ok(resp) = client.get(&healthz_url).send().await {
if resp.status().is_success() {
let status = resp.status();
if status == fabro_http::StatusCode::OK {
return TwinOpenAi { base_url };
}
}

View file

@ -536,4 +536,5 @@ pub enum GithubIntegrationStrategy {
#[serde(rename_all = "snake_case")]
pub enum WebhookStrategy {
TailscaleFunnel,
ServerUrl,
}

View file

@ -5,6 +5,7 @@ api/discovery-api.ts
api/human-in-the-loop-api.ts
api/insights-api.ts
api/install-api.ts
api/integrations-api.ts
api/models-api.ts
api/repos-api.ts
api/run-internals-api.ts

View file

@ -20,6 +20,7 @@ export * from './api/discovery-api';
export * from './api/human-in-the-loop-api';
export * from './api/insights-api';
export * from './api/install-api';
export * from './api/integrations-api';
export * from './api/models-api';
export * from './api/repos-api';
export * from './api/run-internals-api';

View file

@ -0,0 +1,122 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from '../configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
/**
* IntegrationsApi - axios parameter creator
*/
export const IntegrationsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
* @summary Receive GitHub Webhook
* @param {{ [key: string]: any; }} requestBody
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
receiveGithubWebhook: async (requestBody: { [key: string]: any; }, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'requestBody' is not null or undefined
assertParamExists('receiveGithubWebhook', 'requestBody', requestBody)
const localVarPath = `/api/v1/webhooks/github`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(requestBody, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* IntegrationsApi - functional programming interface
*/
export const IntegrationsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = IntegrationsApiAxiosParamCreator(configuration)
return {
/**
* Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
* @summary Receive GitHub Webhook
* @param {{ [key: string]: any; }} requestBody
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async receiveGithubWebhook(requestBody: { [key: string]: any; }, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.receiveGithubWebhook(requestBody, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['IntegrationsApi.receiveGithubWebhook']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* IntegrationsApi - factory interface
*/
export const IntegrationsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = IntegrationsApiFp(configuration)
return {
/**
* Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
* @summary Receive GitHub Webhook
* @param {{ [key: string]: any; }} requestBody
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
receiveGithubWebhook(requestBody: { [key: string]: any; }, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.receiveGithubWebhook(requestBody, options).then((request) => request(axios, basePath));
},
};
};
/**
* IntegrationsApi - object-oriented interface
*/
export class IntegrationsApi extends BaseAPI {
/**
* Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
* @summary Receive GitHub Webhook
* @param {{ [key: string]: any; }} requestBody
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public receiveGithubWebhook(requestBody: { [key: string]: any; }, options?: RawAxiosRequestConfig) {
return IntegrationsApiFp(this.configuration).receiveGithubWebhook(requestBody, options).then((request) => request(this.axios, this.basePath));
}
}