mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
e12c3632e2
34 changed files with 3647 additions and 2580 deletions
56
apps/fabro-web/app/components/blocked-run-notice.test.tsx
Normal file
56
apps/fabro-web/app/components/blocked-run-notice.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
34
apps/fabro-web/app/components/blocked-run-notice.tsx
Normal file
34
apps/fabro-web/app/components/blocked-run-notice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
196
apps/fabro-web/app/components/toast.test.tsx
Normal file
196
apps/fabro-web/app/components/toast.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
165
apps/fabro-web/app/components/toast.tsx
Normal file
165
apps/fabro-web/app/components/toast.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -20,6 +20,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";
|
||||
import { useTheme } from "../lib/theme";
|
||||
|
||||
|
|
@ -72,6 +73,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">
|
||||
|
|
@ -274,6 +276,7 @@ export default function AppShell({ loaderData }: any) {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</DemoModeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
179
apps/fabro-web/app/lib/run-actions.test.ts
Normal file
179
apps/fabro-web/app/lib/run-actions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
145
apps/fabro-web/app/lib/run-actions.ts
Normal file
145
apps/fabro-web/app/lib/run-actions.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
101
apps/fabro-web/app/lib/sse.test.ts
Normal file
101
apps/fabro-web/app/lib/sse.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
apps/fabro-web/app/lib/sse.ts
Normal file
81
apps/fabro-web/app/lib/sse.ts
Normal 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]);
|
||||
}
|
||||
198
apps/fabro-web/app/routes/run-detail.test.ts
Normal file
198
apps/fabro-web/app/routes/run-detail.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { useTheme } from "../lib/theme";
|
||||
import type {
|
||||
FileDiff as ApiFileDiff,
|
||||
|
|
@ -28,10 +30,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 };
|
||||
|
||||
|
|
@ -157,28 +167,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(
|
||||
|
|
@ -261,6 +253,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
|
||||
|
|
@ -290,6 +323,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);
|
||||
|
|
@ -298,23 +332,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;
|
||||
|
|
@ -357,6 +387,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.
|
||||
|
|
@ -389,28 +420,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[] =>
|
||||
|
|
@ -531,8 +556,6 @@ export default function RunFiles({ loaderData }: any) {
|
|||
theme: pierreTheme,
|
||||
}}
|
||||
/>
|
||||
{emptyToast && <Toast>{emptyToast}</Toast>}
|
||||
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -548,8 +571,6 @@ export default function RunFiles({ loaderData }: any) {
|
|||
degraded: meta.degraded ?? false,
|
||||
})}
|
||||
/>
|
||||
{emptyToast && <Toast>{emptyToast}</Toast>}
|
||||
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -573,8 +594,6 @@ export default function RunFiles({ loaderData }: any) {
|
|||
/>
|
||||
) : null}
|
||||
{body}
|
||||
{emptyToast && <Toast>{emptyToast}</Toast>}
|
||||
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: "feat: Expose CLI lifecycle actions (cancel, archive, unarchive) in the web UI"
|
||||
type: feat
|
||||
status: active
|
||||
status: completed
|
||||
date: 2026-04-19
|
||||
updated: 2026-04-20
|
||||
origin: docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md
|
||||
|
|
@ -224,7 +224,7 @@ graph TB
|
|||
|
||||
---
|
||||
|
||||
- [ ] **Unit 1: Extend `BOARD_STATUS_EVENTS` with `run.archived` and `run.unarchived`**
|
||||
- [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.
|
||||
|
||||
|
|
@ -255,7 +255,7 @@ graph TB
|
|||
|
||||
---
|
||||
|
||||
- [ ] **Unit 2: Shared toast infrastructure (`ToastProvider`, `useToast`)**
|
||||
- [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.
|
||||
|
||||
|
|
@ -296,7 +296,7 @@ graph TB
|
|||
|
||||
---
|
||||
|
||||
- [ ] **Unit 3: Shared SSE hook (`useRunEventSource`) and migration of existing call sites**
|
||||
- [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.
|
||||
|
||||
|
|
@ -339,7 +339,7 @@ graph TB
|
|||
|
||||
---
|
||||
|
||||
- [ ] **Unit 4: Lifecycle action helpers and route action dispatch**
|
||||
- [x] **Unit 4: Lifecycle action helpers and route action dispatch**
|
||||
|
||||
**Goal:** Centralize request handling, error parsing, and status predicates for cancel/archive/unarchive.
|
||||
|
||||
|
|
@ -383,7 +383,7 @@ graph TB
|
|||
|
||||
---
|
||||
|
||||
- [ ] **Unit 5: Immediate cancel UI**
|
||||
- [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.
|
||||
|
||||
|
|
@ -423,7 +423,7 @@ graph TB
|
|||
|
||||
---
|
||||
|
||||
- [ ] **Unit 6: Archive / unarchive UI**
|
||||
- [x] **Unit 6: Archive / unarchive UI**
|
||||
|
||||
**Goal:** Add archive and unarchive controls plus the inverse-action toast flow.
|
||||
|
||||
|
|
@ -463,7 +463,7 @@ graph TB
|
|||
|
||||
---
|
||||
|
||||
- [ ] **Unit 7: Blocked-run notice + secondary cancel affordance**
|
||||
- [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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3148,15 +3148,15 @@ async fn terminate_worker_for_deletion(
|
|||
fabro_proc::sigterm(worker_pid);
|
||||
|
||||
let deadline = Instant::now() + grace;
|
||||
while Instant::now() < deadline && fabro_proc::process_alive(worker_pid) {
|
||||
while Instant::now() < deadline && fabro_proc::process_running(worker_pid) {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
if fabro_proc::process_alive(worker_pid) {
|
||||
if fabro_proc::process_running(worker_pid) {
|
||||
fabro_proc::sigkill(worker_pid);
|
||||
|
||||
let kill_deadline = Instant::now() + Duration::from_secs(1);
|
||||
while Instant::now() < kill_deadline && fabro_proc::process_alive(worker_pid) {
|
||||
while Instant::now() < kill_deadline && fabro_proc::process_running(worker_pid) {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
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
1
lib/crates/fabro-spa/assets/assets/chunk-q2qv5qr4.js
generated
Normal file
1
lib/crates/fabro-spa/assets/assets/chunk-q2qv5qr4.js
generated
Normal file
File diff suppressed because one or more lines are too long
1
lib/crates/fabro-spa/assets/assets/chunk-tqzz87j8.js
generated
Normal file
1
lib/crates/fabro-spa/assets/assets/chunk-tqzz87j8.js
generated
Normal file
File diff suppressed because one or more lines are too long
2399
lib/crates/fabro-spa/assets/assets/entry-pmqz8d3n.js
generated
2399
lib/crates/fabro-spa/assets/assets/entry-pmqz8d3n.js
generated
File diff suppressed because one or more lines are too long
1805
lib/crates/fabro-spa/assets/assets/entry-qp59a2b0.js
generated
Normal file
1805
lib/crates/fabro-spa/assets/assets/entry-qp59a2b0.js
generated
Normal file
File diff suppressed because one or more lines are too long
6
lib/crates/fabro-spa/assets/index.html
generated
6
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -61,7 +61,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-pmqz8d3n.js"></script>
|
||||
<script type="module" src="/assets/entry-ab7g8fgj.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>
|
||||
|
|
@ -75,8 +75,8 @@
|
|||
<script type="module" src="/assets/chunk-6ma2q84r.js"></script>
|
||||
<script type="module" src="/assets/chunk-0tq24xt3.js"></script>
|
||||
<script type="module" src="/assets/chunk-z512569f.js"></script>
|
||||
<script type="module" src="/assets/chunk-7fpy4pmb.js"></script>
|
||||
<script type="module" src="/assets/chunk-nghb2mxb.js"></script>
|
||||
<script type="module" src="/assets/chunk-q2qv5qr4.js"></script>
|
||||
<script type="module" src="/assets/chunk-tqzz87j8.js"></script>
|
||||
<script type="module" src="/assets/chunk-w4txx8sc.js"></script>
|
||||
<script type="module" src="/assets/chunk-eaexpy25.js"></script>
|
||||
<script type="module" src="/assets/chunk-zb6gezq1.js"></script>
|
||||
|
|
|
|||
|
|
@ -222,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();
|
||||
|
|
@ -483,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);
|
||||
|
|
@ -749,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(
|
||||
|
|
@ -832,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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue