mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # lib/crates/fabro-cli/src/commands/run/rewind.rs # lib/crates/fabro-cli/src/logging.rs
This commit is contained in:
commit
a2b77b1a30
98 changed files with 7424 additions and 3278 deletions
|
|
@ -53,24 +53,30 @@
|
|||
--color-line: rgba(255, 255, 255, 0.06);
|
||||
--color-line-strong: rgba(255, 255, 255, 0.12);
|
||||
--color-focus: rgba(103, 178, 215, 0.4);
|
||||
/* Text color that sits on --color-teal-500 primary fills.
|
||||
Dark mode: teal-500 is light (#67B2D7) so dark text reads best.
|
||||
Light mode: teal-500 is dark (#0e7490) so white reads best. */
|
||||
--color-on-primary: #0F1729;
|
||||
}
|
||||
|
||||
/* ── Light theme overrides ── */
|
||||
|
||||
.light {
|
||||
/* Semantic tokens */
|
||||
--color-page: #ffffff;
|
||||
--color-panel: #f8fafc;
|
||||
--color-panel-alt: #f1f5f9;
|
||||
--color-overlay: rgba(0, 0, 0, 0.04);
|
||||
--color-overlay-strong: rgba(0, 0, 0, 0.10);
|
||||
/* Semantic tokens — page is softly tinted so panels/cards (white)
|
||||
read as the lighter surface, mirroring the dark-theme hierarchy. */
|
||||
--color-page: #eef2f7;
|
||||
--color-panel: #ffffff;
|
||||
--color-panel-alt: #e4eaf1;
|
||||
--color-overlay: rgba(15, 23, 41, 0.05);
|
||||
--color-overlay-strong: rgba(15, 23, 41, 0.10);
|
||||
--color-fg: #0f172a;
|
||||
--color-fg-2: #1e293b;
|
||||
--color-fg-3: #475569;
|
||||
--color-fg-muted: #94a3b8;
|
||||
--color-line: rgba(0, 0, 0, 0.08);
|
||||
--color-line-strong: rgba(0, 0, 0, 0.15);
|
||||
--color-fg-muted: #64748b;
|
||||
--color-line: rgba(15, 23, 41, 0.10);
|
||||
--color-line-strong: rgba(15, 23, 41, 0.18);
|
||||
--color-focus: rgba(14, 116, 144, 0.5);
|
||||
--color-on-primary: #ffffff;
|
||||
|
||||
/* Accent — darkened for WCAG AA on white */
|
||||
--color-mint: #0d9488;
|
||||
|
|
|
|||
84
apps/fabro-web/app/components/graph-toolbar.tsx
Normal file
84
apps/fabro-web/app/components/graph-toolbar.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
export const GRAPH_ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
export const GRAPH_DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
export function GraphToolbar({
|
||||
direction,
|
||||
setDirection,
|
||||
fitToWindow,
|
||||
zoomIndex,
|
||||
setZoomIndex,
|
||||
}: {
|
||||
direction: Direction;
|
||||
setDirection: (d: Direction) => void;
|
||||
fitToWindow: () => void;
|
||||
zoomIndex: number;
|
||||
setZoomIndex: (updater: (i: number) => number) => void;
|
||||
}) {
|
||||
const group =
|
||||
"flex items-center gap-0.5 px-0.5 [&:not(:first-child)]:border-l [&:not(:first-child)]:border-line-strong [&:not(:first-child)]:pl-1 [&:not(:first-child)]:ml-1";
|
||||
const btn =
|
||||
"flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500";
|
||||
const btnActive = "bg-overlay-strong text-fg-3";
|
||||
const btnDisabled =
|
||||
"disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Graph controls"
|
||||
className="absolute right-3 top-3 z-10 flex items-center rounded-md border border-line bg-panel p-0.5"
|
||||
>
|
||||
<div className={group}>
|
||||
<button
|
||||
type="button"
|
||||
title="Left to right"
|
||||
onClick={() => setDirection("LR")}
|
||||
aria-pressed={direction === "LR"}
|
||||
className={`${btn} ${direction === "LR" ? btnActive : ""}`}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Top to bottom"
|
||||
onClick={() => setDirection("TB")}
|
||||
aria-pressed={direction === "TB"}
|
||||
className={`${btn} ${direction === "TB" ? btnActive : ""}`}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={group}>
|
||||
<button type="button" title="Fit to window" onClick={fitToWindow} className={btn}>
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className={group}>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={zoomIndex === 0}
|
||||
className={`${btn} ${btnDisabled}`}
|
||||
>
|
||||
<MinusIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
onClick={() => setZoomIndex((i) => Math.min(GRAPH_ZOOM_STEPS.length - 1, i + 1))}
|
||||
disabled={zoomIndex === GRAPH_ZOOM_STEPS.length - 1}
|
||||
className={`${btn} ${btnDisabled}`}
|
||||
>
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link, useRevalidator } from "react-router";
|
||||
import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
NoSymbolIcon,
|
||||
PauseCircleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
|
||||
|
|
@ -19,7 +25,7 @@ export const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; c
|
|||
running: { icon: ArrowPathIcon, color: "text-teal-500" },
|
||||
pending: { icon: PauseCircleIcon, color: "text-fg-muted" },
|
||||
failed: { icon: XCircleIcon, color: "text-coral" },
|
||||
cancelled: { icon: XCircleIcon, color: "text-fg-muted" },
|
||||
cancelled: { icon: NoSymbolIcon, color: "text-fg-muted" },
|
||||
};
|
||||
|
||||
interface StageSidebarProps {
|
||||
|
|
|
|||
102
apps/fabro-web/app/components/state.tsx
Normal file
102
apps/fabro-web/app/components/state.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { ComponentType, ReactNode, SVGProps } from "react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import { SECONDARY_BUTTON_CLASS } from "./ui";
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
|
||||
// Shared chrome for non-critical "the content isn't there" states. Full-page
|
||||
// crashes are handled by the root ErrorBoundary; these render inside the
|
||||
// current content column.
|
||||
function StatePanel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center rounded-md border border-line bg-panel p-8 text-center">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<StatePanel>
|
||||
{Icon ? (
|
||||
<Icon className="mb-3 size-6 text-fg-muted" aria-hidden="true" />
|
||||
) : null}
|
||||
<p className="text-sm font-medium text-fg">{title}</p>
|
||||
{description ? (
|
||||
<p className="mt-1 max-w-[48ch] text-sm/6 text-fg-3">{description}</p>
|
||||
) : null}
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
title = "Something went wrong",
|
||||
description,
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<StatePanel>
|
||||
<ExclamationTriangleIcon
|
||||
className="mb-3 size-6 text-coral"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium text-fg">{title}</p>
|
||||
{description ? (
|
||||
<p className="mt-1 max-w-[48ch] text-sm/6 text-fg-3">{description}</p>
|
||||
) : null}
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className={`${SECONDARY_BUTTON_CLASS} mt-4`}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
) : null}
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label }: { label?: string }) {
|
||||
return (
|
||||
<StatePanel>
|
||||
<Spinner className="mb-3 size-5 text-teal-500" />
|
||||
<p className="text-sm text-fg-3">{label ?? "Loading…"}</p>
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={`shrink-0 animate-spin ${className}`}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
|
||||
<path
|
||||
d="M14 8a6 6 0 0 0-6-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,11 +30,11 @@ export function ToolRow({ tool }: { tool: ToolUse }) {
|
|||
{open && (
|
||||
<div className="space-y-px bg-overlay px-2.5 pb-2 pt-1">
|
||||
<div className="rounded bg-overlay px-2.5 py-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Input</div>
|
||||
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-fg-muted">Input</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{tool.input}</pre>
|
||||
</div>
|
||||
<div className="rounded bg-overlay px-2.5 py-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Result</div>
|
||||
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-fg-muted">Result</div>
|
||||
<pre className={`whitespace-pre-wrap font-mono text-xs leading-relaxed ${tool.isError ? "text-coral" : "text-fg-3"}`}>{tool.result}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
64
apps/fabro-web/app/components/ui.tsx
Normal file
64
apps/fabro-web/app/components/ui.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Shared UI primitives. The install wizard set the visual baseline; this file
|
||||
// exposes the primary button, secondary button, input, error message, and
|
||||
// copy button so the auth and in-app surfaces can match.
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
ClipboardIcon,
|
||||
} from "@heroicons/react/16/solid";
|
||||
|
||||
export const INPUT_CLASS =
|
||||
"block w-full rounded-lg bg-panel-alt px-3.5 py-2.5 text-base text-fg outline-1 -outline-offset-1 outline-white/10 placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 sm:text-sm";
|
||||
|
||||
export const PRIMARY_BUTTON_CLASS =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2 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";
|
||||
|
||||
export const SECONDARY_BUTTON_CLASS =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
|
||||
|
||||
export function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md bg-coral/10 px-3 py-2 text-sm/6 text-fg-2 outline-1 -outline-offset-1 outline-coral/40"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
label,
|
||||
className = "",
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard may be blocked; leave state unchanged.
|
||||
}
|
||||
}}
|
||||
className={`inline-flex size-6 shrink-0 items-center justify-center rounded text-fg-muted outline-teal-500 hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 ${className}`}
|
||||
aria-label={copied ? "Copied" : label}
|
||||
title={copied ? "Copied" : label}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardDocumentCheckIcon className="size-4 text-mint" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -126,6 +126,11 @@ describe("mapRunSummaryToRunItem", () => {
|
|||
expect(runStatusDisplay).toHaveProperty("blocked");
|
||||
});
|
||||
|
||||
test("recognizes archived as a terminal run status", () => {
|
||||
expect(isRunStatus("archived")).toBe(true);
|
||||
expect(runStatusDisplay).toHaveProperty("archived");
|
||||
});
|
||||
|
||||
test("uses blocked board column instead of waiting", () => {
|
||||
expect(columnStatusDisplay).toHaveProperty("blocked");
|
||||
expect(columnStatusDisplay).not.toHaveProperty("waiting");
|
||||
|
|
|
|||
|
|
@ -132,7 +132,8 @@ export type RunStatus =
|
|||
| "removing"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "dead";
|
||||
| "dead"
|
||||
| "archived";
|
||||
|
||||
export const runStatusDisplay: Record<RunStatus, { label: string; dot: string; text: string }> = {
|
||||
submitted: { label: "Submitted", dot: "bg-fg-muted", text: "text-fg-muted" },
|
||||
|
|
@ -145,6 +146,7 @@ export const runStatusDisplay: Record<RunStatus, { label: string; dot: string; t
|
|||
succeeded: { label: "Succeeded", dot: "bg-mint", text: "text-mint" },
|
||||
failed: { label: "Failed", dot: "bg-coral", text: "text-coral" },
|
||||
dead: { label: "Dead", dot: "bg-coral", text: "text-coral" },
|
||||
archived: { label: "Archived", dot: "bg-fg-muted", text: "text-fg-muted" },
|
||||
};
|
||||
|
||||
const knownRunStatuses = new Set<string>(Object.keys(runStatusDisplay));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,16 +2,28 @@ export const INSTALL_PROVIDERS = [
|
|||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "Claude API key.",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
keyHelp: {
|
||||
url: "https://console.anthropic.com/settings/keys",
|
||||
text: "Create one in the Anthropic Console under Settings → API keys.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
hint: "Responses API key.",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
keyHelp: {
|
||||
url: "https://platform.openai.com/api-keys",
|
||||
text: "Create one in the OpenAI platform under API keys.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
label: "Gemini",
|
||||
hint: "Google AI Studio API key.",
|
||||
envVar: "GEMINI_API_KEY",
|
||||
keyHelp: {
|
||||
url: "https://aistudio.google.com/apikey",
|
||||
text: "Create one in Google AI Studio.",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ describe("getVisibleNavigation", () => {
|
|||
expect(names).toContain("Workflows");
|
||||
expect(names).toContain("Runs");
|
||||
expect(names).toContain("Insights");
|
||||
expect(names).toContain("Settings");
|
||||
});
|
||||
|
||||
test("hides Workflows and Insights in production mode", () => {
|
||||
|
|
@ -16,5 +17,6 @@ describe("getVisibleNavigation", () => {
|
|||
expect(names).not.toContain("Workflows");
|
||||
expect(names).not.toContain("Insights");
|
||||
expect(names).toContain("Runs");
|
||||
expect(names).toContain("Settings");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Bars3Icon,
|
||||
BeakerIcon,
|
||||
ChartBarIcon,
|
||||
Cog6ToothIcon,
|
||||
MoonIcon,
|
||||
PlayIcon,
|
||||
RectangleStackIcon,
|
||||
|
|
@ -30,6 +31,7 @@ const allNavigation = [
|
|||
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon, demoOnly: true },
|
||||
{ name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false },
|
||||
{ name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true },
|
||||
{ name: "Settings", href: "/settings", icon: Cog6ToothIcon, demoOnly: false },
|
||||
];
|
||||
|
||||
export function getVisibleNavigation(demoMode: boolean) {
|
||||
|
|
@ -70,8 +72,8 @@ export default function AppShell({ loaderData }: any) {
|
|||
|
||||
return (
|
||||
<DemoModeProvider value={demoMode}>
|
||||
<div className="min-h-full">
|
||||
<Disclosure as="nav" className="bg-panel/50">
|
||||
<div className="isolate min-h-full">
|
||||
<Disclosure as="nav" className="bg-panel">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
|
|
@ -255,10 +257,12 @@ export default function AppShell({ loaderData }: any) {
|
|||
</Disclosure>
|
||||
|
||||
{!hideHeader && (
|
||||
<header className="relative bg-panel after:pointer-events-none after:absolute after:inset-x-0 after:inset-y-0 after:bottom-0 after:border-y after:border-line-strong">
|
||||
<header className="relative bg-panel after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:border-b after:border-line-strong">
|
||||
<div className={`mx-auto ${maxWidth} px-4 py-4 sm:px-6 lg:px-8`}>
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-lg/6 font-semibold text-fg">{title}</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-fg">
|
||||
{title}
|
||||
</h1>
|
||||
{headerExtra && <div className="ml-auto">{headerExtra}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { AuthLayout } from "../components/auth-layout";
|
||||
import {
|
||||
ErrorMessage,
|
||||
INPUT_CLASS,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
} from "../components/ui";
|
||||
import { getAuthConfig, loginDevToken } from "../api";
|
||||
|
||||
export async function loader() {
|
||||
|
|
@ -23,16 +28,16 @@ export default function AuthLogin({ loaderData }: any) {
|
|||
await loginDevToken(token);
|
||||
navigate("/runs");
|
||||
} catch {
|
||||
setError("Invalid dev token");
|
||||
setError("Invalid dev token.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
<h1 className="text-center text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
|
||||
Sign in to Fabro
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
<p className="mt-3 text-center text-sm/6 text-fg-3 text-pretty">
|
||||
{hasGitHub
|
||||
? "Authenticate with your GitHub account to continue."
|
||||
: "Paste your dev token to continue."}
|
||||
|
|
@ -41,7 +46,7 @@ export default function AuthLogin({ loaderData }: any) {
|
|||
{hasGitHub ? (
|
||||
<a
|
||||
href="/auth/login/github"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
className={`${PRIMARY_BUTTON_CLASS} w-full`}
|
||||
>
|
||||
<GitHubMark />
|
||||
Sign in with GitHub
|
||||
|
|
@ -55,34 +60,65 @@ export default function AuthLogin({ loaderData }: any) {
|
|||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : hasDevToken ? (
|
||||
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="fabro_dev_..."
|
||||
className="w-full rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm text-fg outline-none focus:border-teal-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
>
|
||||
Sign in with Dev Token
|
||||
</button>
|
||||
<p className="text-center text-xs text-fg-muted">
|
||||
Paste the dev token from your terminal or{" "}
|
||||
<code>cat ~/.fabro/dev-token</code>
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="text-center text-sm text-red-500">{error}</p>
|
||||
) : null}
|
||||
</form>
|
||||
<DevTokenForm
|
||||
token={token}
|
||||
setToken={setToken}
|
||||
error={error}
|
||||
onSubmit={handleSubmit}
|
||||
showLocation
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DevTokenForm({
|
||||
token,
|
||||
setToken,
|
||||
error,
|
||||
onSubmit,
|
||||
showLocation = false,
|
||||
}: {
|
||||
token: string;
|
||||
setToken: (v: string) => void;
|
||||
error: string | null;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
showLocation?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="dev-token" className="sr-only">
|
||||
Dev token
|
||||
</label>
|
||||
<input
|
||||
id="dev-token"
|
||||
type="password"
|
||||
name="dev_token"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="fabro_dev_…"
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
<button type="submit" className={`${PRIMARY_BUTTON_CLASS} w-full`}>
|
||||
Sign in with dev token
|
||||
</button>
|
||||
{showLocation ? (
|
||||
<p className="text-center text-xs text-fg-muted">
|
||||
Paste the dev token from your terminal or{" "}
|
||||
<code className="font-mono text-fg-3">cat ~/.fabro/dev-token</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DevTokenCollapsible({
|
||||
token,
|
||||
setToken,
|
||||
|
|
@ -101,7 +137,7 @@ function DevTokenCollapsible({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="mx-auto flex items-center gap-1 text-xs text-fg-3 hover:text-fg-2 transition-colors"
|
||||
className="mx-auto flex items-center gap-1 rounded text-xs text-fg-3 outline-teal-500 hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
>
|
||||
Use a dev token instead
|
||||
<svg
|
||||
|
|
@ -110,6 +146,7 @@ function DevTokenCollapsible({
|
|||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
|
|
@ -121,24 +158,14 @@ function DevTokenCollapsible({
|
|||
</svg>
|
||||
</button>
|
||||
{open ? (
|
||||
<form className="mt-3 space-y-3" onSubmit={onSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="fabro_dev_..."
|
||||
className="w-full rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm text-fg outline-none focus:border-teal-500"
|
||||
<div className="mt-4">
|
||||
<DevTokenForm
|
||||
token={token}
|
||||
setToken={setToken}
|
||||
error={error}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm font-medium text-fg transition-colors hover:bg-panel-2"
|
||||
>
|
||||
Sign in with Dev Token
|
||||
</button>
|
||||
{error ? (
|
||||
<p className="text-center text-sm text-red-500">{error}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -146,7 +173,7 @@ function DevTokenCollapsible({
|
|||
|
||||
function GitHubMark() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-4 shrink-0" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { apiJson } from "../api";
|
||||
import { EmptyState } from "../components/state";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { RunBilling } from "@qltysh/fabro-api-client";
|
||||
|
||||
|
|
@ -39,12 +40,24 @@ export async function loader({ request, params }: any) {
|
|||
export default function RunBilling({ loaderData }: any) {
|
||||
const { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown } =
|
||||
loaderData;
|
||||
|
||||
if (!stages.length) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
title="No billing yet"
|
||||
description="Token usage and cost will appear here once stages complete."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-hidden rounded-md border border-line">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs font-medium text-fg-3">
|
||||
<th className="px-4 py-2.5 font-medium">Stage</th>
|
||||
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||
|
|
@ -69,7 +82,7 @@ export default function RunBilling({ loaderData }: any) {
|
|||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-line-strong bg-panel/40">
|
||||
<tr className="border-t border-line-strong bg-overlay">
|
||||
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
||||
<td />
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||
|
|
@ -88,13 +101,11 @@ export default function RunBilling({ loaderData }: any) {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">
|
||||
By Model
|
||||
</h3>
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg">By model</h3>
|
||||
<div className="overflow-hidden rounded-md border border-line">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs font-medium text-fg-3">
|
||||
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Stages</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||
|
|
@ -119,7 +130,7 @@ export default function RunBilling({ loaderData }: any) {
|
|||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-line-strong bg-panel/40">
|
||||
<tr className="border-t border-line-strong bg-overlay">
|
||||
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||
{stages.length}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import { useEffect } from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { 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 { apiJson } from "../api";
|
||||
import { ErrorState } from "../components/state";
|
||||
import { useDemoMode } from "../lib/demo-mode";
|
||||
import type { PreviewUrlResponse } from "@qltysh/fabro-api-client";
|
||||
|
||||
const allTabs = [
|
||||
{ name: "Overview", path: "", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Stages", path: "/stages", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Files Changed", path: "/files", count: null, demoOnly: false, broken: true },
|
||||
{ name: "Graph", path: "/graph", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Billing", path: "/billing", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Overview", path: "", count: null, demoOnly: false },
|
||||
{ name: "Stages", path: "/stages", count: null, demoOnly: false },
|
||||
{ name: "Graph", path: "/graph", count: null, demoOnly: false },
|
||||
{ name: "Billing", path: "/billing", count: null, demoOnly: false },
|
||||
];
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
|
@ -65,7 +64,7 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
const basePath = `/runs/${params.id}`;
|
||||
const previewFetcher = useFetcher<PreviewUrlResponse>();
|
||||
const demoMode = useDemoMode();
|
||||
const tabs = allTabs.filter((t) => !t.broken && (!t.demoOnly || demoMode));
|
||||
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewFetcher.data?.url) {
|
||||
|
|
@ -74,7 +73,14 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
}, [previewFetcher.data]);
|
||||
|
||||
if (!run) {
|
||||
return <p className="py-8 text-center text-sm text-fg-muted">Run not found.</p>;
|
||||
return (
|
||||
<div className="py-12">
|
||||
<ErrorState
|
||||
title="Run not found"
|
||||
description="The run you're looking for doesn't exist or was deleted."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -107,16 +113,7 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Open pull request"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
|
||||
</svg>
|
||||
Open PR
|
||||
</button>
|
||||
{/* 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" />
|
||||
|
|
@ -124,50 +121,16 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={previewFetcher.state !== "idle"}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-teal-500/20 px-3 py-1.5 text-sm font-medium text-teal-500 transition-colors hover:border-teal-500/50 hover:bg-teal-500/10 hover:text-fg disabled:opacity-50"
|
||||
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" aria-hidden="true">
|
||||
<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"}
|
||||
{previewFetcher.state !== "idle" ? "Opening…" : "Preview"}
|
||||
</button>
|
||||
</previewFetcher.Form>
|
||||
)}
|
||||
{run.sandboxId && (
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="flex shrink-0 items-center gap-1.5 rounded-md border border-teal-500/20 px-3 py-1.5 text-sm font-medium text-teal-500 transition-colors hover:border-teal-500/50 hover:bg-teal-500/10 hover:text-fg">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 1 1-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z" />
|
||||
</svg>
|
||||
Terminal
|
||||
<ChevronDownIcon className="size-4" aria-hidden="true" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<a
|
||||
href="https://22222-rjyrtjg8gelfyo1p.daytonaproxy01.net/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-4 py-2 text-sm text-fg-3 data-focus:bg-overlay data-focus:text-fg"
|
||||
>
|
||||
Web Terminal
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:text-fg"
|
||||
>
|
||||
Connect with SSH
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-b border-line">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { apiFetch, apiJsonOrNull } from "../api";
|
||||
|
|
@ -8,6 +7,11 @@ import { isVisibleStage } from "../data/runs";
|
|||
import { formatDurationSecs } from "../lib/format";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import {
|
||||
GRAPH_DEFAULT_ZOOM_INDEX,
|
||||
GRAPH_ZOOM_STEPS,
|
||||
GraphToolbar,
|
||||
} from "../components/graph-toolbar";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
|
@ -169,9 +173,6 @@ function annotateRunningNodes(svg: SVGSVGElement, gt: ReturnType<typeof getGraph
|
|||
|
||||
}
|
||||
|
||||
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
const DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
export default function RunGraph({ loaderData }: any) {
|
||||
const { id } = useParams();
|
||||
const { stages, graphSvg } = loaderData;
|
||||
|
|
@ -179,11 +180,11 @@ export default function RunGraph({ loaderData }: any) {
|
|||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
|
||||
const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
|
||||
const [direction, setDirection] = useState<Direction>("LR");
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const zoom = ZOOM_STEPS[zoomIndex];
|
||||
const zoom = GRAPH_ZOOM_STEPS[zoomIndex];
|
||||
const { theme } = useTheme();
|
||||
const graphTheme = getGraphTheme(theme);
|
||||
|
||||
|
|
@ -264,8 +265,8 @@ export default function RunGraph({ loaderData }: any) {
|
|||
|
||||
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
|
||||
let best = 0;
|
||||
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
for (let i = GRAPH_ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (GRAPH_ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
}
|
||||
setZoomIndex(best);
|
||||
setPan({ x: 0, y: 0 });
|
||||
|
|
@ -280,61 +281,14 @@ export default function RunGraph({ loaderData }: any) {
|
|||
<StageSidebar stages={stages} runId={id!} activeLink="graph" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="graph-svg relative rounded-md border border-line bg-panel-alt/40">
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Left to right"
|
||||
onClick={() => setDirection("LR")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "LR" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Top to bottom"
|
||||
onClick={() => setDirection("TB")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "TB" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Fit to window"
|
||||
onClick={fitToWindow}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
|
||||
>
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={zoomIndex === 0}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<MinusIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
onClick={() => setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))}
|
||||
disabled={zoomIndex === ZOOM_STEPS.length - 1}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
|
||||
<GraphToolbar
|
||||
direction={direction}
|
||||
setDirection={setDirection}
|
||||
fitToWindow={fitToWindow}
|
||||
zoomIndex={zoomIndex}
|
||||
setZoomIndex={setZoomIndex}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { apiFetch, apiJsonOrNull } from "../api";
|
||||
import { isVisibleStage } from "../data/runs";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
|
|
@ -8,6 +7,12 @@ import { useTheme } from "../lib/theme";
|
|||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import {
|
||||
GRAPH_DEFAULT_ZOOM_INDEX,
|
||||
GRAPH_ZOOM_STEPS,
|
||||
GraphToolbar,
|
||||
} from "../components/graph-toolbar";
|
||||
import { EmptyState } from "../components/state";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
|
@ -33,9 +38,6 @@ export async function loader({ request, params }: any) {
|
|||
return { stages, graphSvg, runStatus };
|
||||
}
|
||||
|
||||
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
const DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
export default function RunOverview({ loaderData }: any) {
|
||||
|
|
@ -46,11 +48,11 @@ export default function RunOverview({ loaderData }: any) {
|
|||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
|
||||
const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
|
||||
const [direction, setDirection] = useState<Direction>("LR");
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const zoom = ZOOM_STEPS[zoomIndex];
|
||||
const zoom = GRAPH_ZOOM_STEPS[zoomIndex];
|
||||
|
||||
// Render SVG with stage annotations
|
||||
useEffect(() => {
|
||||
|
|
@ -190,8 +192,8 @@ export default function RunOverview({ loaderData }: any) {
|
|||
|
||||
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
|
||||
let best = 0;
|
||||
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
for (let i = GRAPH_ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (GRAPH_ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
}
|
||||
setZoomIndex(best);
|
||||
setPan({ x: 0, y: 0 });
|
||||
|
|
@ -203,61 +205,14 @@ export default function RunOverview({ loaderData }: any) {
|
|||
|
||||
<div className="min-w-0 flex-1">
|
||||
{graphSvg ? (
|
||||
<div className="graph-svg relative rounded-md border border-line bg-panel-alt/40">
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Left to right"
|
||||
onClick={() => setDirection("LR")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "LR" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Top to bottom"
|
||||
onClick={() => setDirection("TB")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "TB" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Fit to window"
|
||||
onClick={fitToWindow}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
|
||||
>
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={zoomIndex === 0}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<MinusIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
onClick={() => setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))}
|
||||
disabled={zoomIndex === ZOOM_STEPS.length - 1}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
|
||||
<GraphToolbar
|
||||
direction={direction}
|
||||
setDirection={setDirection}
|
||||
fitToWindow={fitToWindow}
|
||||
zoomIndex={zoomIndex}
|
||||
setZoomIndex={setZoomIndex}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -276,7 +231,10 @@ export default function RunOverview({ loaderData }: any) {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">No workflow graph available.</p>
|
||||
<EmptyState
|
||||
title="No workflow graph"
|
||||
description="This run doesn't have a renderable graph yet."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ export default function RunSettingsPage({ loaderData }: any) {
|
|||
<StageSidebar stages={stages} runId={id!} activeLink="settings" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="mb-4">
|
||||
<h2 className="text-base font-semibold text-fg">Run settings</h2>
|
||||
<p className="mt-1 text-sm/6 text-fg-3">
|
||||
Frozen settings snapshot used by this run.
|
||||
</p>
|
||||
</header>
|
||||
<CollapsibleFile
|
||||
file={{ name: "settings.json", contents: JSON.stringify(settings, null, 2), lang: "json" }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { ToolBlock } from "../components/tool-use";
|
|||
import type { ToolUse } from "../components/tool-use";
|
||||
import { StageSidebar, statusConfig } from "../components/stage-sidebar";
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import { EmptyState } from "../components/state";
|
||||
import { apiJson, apiJsonOrNull } from "../api";
|
||||
import { CopyButton } from "../components/ui";
|
||||
import { isVisibleStage } from "../data/runs";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList, PaginatedEventList } from "@qltysh/fabro-api-client";
|
||||
|
|
@ -176,28 +178,105 @@ function Markdown({ content }: { content: string }) {
|
|||
|
||||
function SystemBlock({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber/10 bg-amber/5 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<section className="group relative border-l-2 border-amber/50 pl-4">
|
||||
<header className="mb-1.5 flex items-center gap-2">
|
||||
<CommandLineIcon className="size-4 shrink-0 text-amber" />
|
||||
<span className="text-xs font-medium text-fg-3">System Prompt</span>
|
||||
</div>
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-fg-3">System prompt</span>
|
||||
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<CopyButton value={content} label="Copy system prompt" />
|
||||
</div>
|
||||
</header>
|
||||
<Markdown content={content} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantBlock({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-teal-500/10 bg-teal-500/5 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<section className="group relative border-l-2 border-teal-500/50 pl-4">
|
||||
<header className="mb-1.5 flex items-center gap-2">
|
||||
<ChatBubbleLeftIcon className="size-4 shrink-0 text-teal-500" />
|
||||
<span className="text-xs font-medium text-fg-3">Assistant</span>
|
||||
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<CopyButton value={content} label="Copy assistant message" />
|
||||
</div>
|
||||
</header>
|
||||
<Markdown content={content} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: "running" | "failed" | "success" | "neutral";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const toneClass = {
|
||||
running: "bg-teal-500/15 text-teal-500",
|
||||
failed: "bg-coral/15 text-coral",
|
||||
success: "bg-mint/15 text-mint",
|
||||
neutral: "bg-overlay text-fg-3",
|
||||
}[tone];
|
||||
return (
|
||||
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${toneClass}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const COLLAPSE_AFTER_LINES = 20;
|
||||
|
||||
function StreamLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="font-mono text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutputStream({
|
||||
label,
|
||||
content,
|
||||
tone = "normal",
|
||||
}: {
|
||||
label: string;
|
||||
content: string;
|
||||
tone?: "normal" | "error";
|
||||
}) {
|
||||
const lines = content.split("\n");
|
||||
const isLong = lines.length > COLLAPSE_AFTER_LINES;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visible = isLong && !expanded
|
||||
? lines.slice(-COLLAPSE_AFTER_LINES).join("\n")
|
||||
: content;
|
||||
const hiddenLines = isLong && !expanded ? lines.length - COLLAPSE_AFTER_LINES : 0;
|
||||
const preClass =
|
||||
tone === "error"
|
||||
? "whitespace-pre-wrap font-mono text-sm leading-relaxed text-coral sm:text-xs"
|
||||
: "whitespace-pre-wrap font-mono text-sm leading-relaxed text-fg-3 sm:text-xs";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<StreamLabel label={label} />
|
||||
<CopyButton
|
||||
value={content}
|
||||
label={`Copy ${label}`}
|
||||
className="-my-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
{isLong && !expanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="mb-2 text-[11px] font-medium text-teal-500 hover:text-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 rounded"
|
||||
>
|
||||
Show {hiddenLines} earlier lines
|
||||
</button>
|
||||
) : null}
|
||||
<pre className={preClass}>{visible}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -208,53 +287,58 @@ function CommandBlock({ turn }: { turn: Extract<TurnType, { kind: "command" }> }
|
|||
const bgColor = turn.running ? "bg-teal-500/5" : failed ? "bg-coral/5" : "bg-mint/5";
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border ${borderColor} ${bgColor} overflow-hidden`}>
|
||||
<div className={`group rounded-md border ${borderColor} ${bgColor} overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<PlayIcon className={`size-4 shrink-0 ${turn.running ? "text-teal-500 animate-pulse" : failed ? "text-coral" : "text-mint"}`} />
|
||||
<span className="text-xs font-medium text-fg-3">
|
||||
{turn.language === "python" ? "Python" : "Shell"}
|
||||
</span>
|
||||
{turn.running && (
|
||||
<span className="ml-auto text-[11px] font-medium text-teal-500 animate-pulse">Running...</span>
|
||||
)}
|
||||
{!turn.running && turn.timedOut && (
|
||||
<span className="ml-auto rounded bg-coral/15 px-1.5 py-0.5 text-[11px] font-medium text-coral">Timed out</span>
|
||||
)}
|
||||
{!turn.running && !turn.timedOut && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${failed ? "bg-coral/15 text-coral" : "bg-mint/15 text-mint"}`}>
|
||||
exit {turn.exitCode ?? "?"}
|
||||
</span>
|
||||
{turn.durationMs != null && (
|
||||
<span className="text-[11px] tabular-nums text-fg-muted">
|
||||
{turn.durationMs < 1000 ? `${turn.durationMs}ms` : `${(turn.durationMs / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{turn.running ? (
|
||||
<StatusPill tone="running">Running…</StatusPill>
|
||||
) : turn.timedOut ? (
|
||||
<StatusPill tone="failed">Timed out</StatusPill>
|
||||
) : (
|
||||
<>
|
||||
<StatusPill tone={failed ? "failed" : "success"}>
|
||||
exit {turn.exitCode ?? "?"}
|
||||
</StatusPill>
|
||||
{turn.durationMs != null && (
|
||||
<StatusPill tone="neutral">
|
||||
{turn.durationMs < 1000
|
||||
? `${turn.durationMs}ms`
|
||||
: `${(turn.durationMs / 1000).toFixed(1)}s`}
|
||||
</StatusPill>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{turn.script ? (
|
||||
<div className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<CopyButton value={turn.script} label="Copy script" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Script */}
|
||||
{turn.script && (
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{turn.script}</pre>
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-fg-3 sm:text-xs">{turn.script}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* stdout */}
|
||||
{turn.stdout && (
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">stdout</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{turn.stdout}</pre>
|
||||
<OutputStream label="stdout" content={turn.stdout} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* stderr */}
|
||||
{turn.stderr && (
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">stderr</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-coral">{turn.stderr}</pre>
|
||||
<OutputStream label="stderr" content={turn.stderr} tone="error" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -287,7 +371,14 @@ export default function RunStages({ loaderData }: any) {
|
|||
}, [isRunning]);
|
||||
|
||||
if (!stages.length) {
|
||||
return <p className="py-8 text-center text-sm text-fg-muted">No stages available for this run.</p>;
|
||||
return (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
title="No stages yet"
|
||||
description="Stages will appear here once the run begins executing."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedConfig = statusConfig[selectedStage.status];
|
||||
|
|
@ -301,10 +392,10 @@ export default function RunStages({ loaderData }: any) {
|
|||
<StageSidebar stages={stages} runId={id!} selectedStageId={selectedStage.id} />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex items-center gap-2 bg-page/85 px-2 py-2 backdrop-blur">
|
||||
<SelectedIcon className={`size-5 ${selectedConfig.color} ${isRunning ? "animate-spin" : ""}`} />
|
||||
<h3 className="text-sm font-medium text-fg">{selectedStage.name}</h3>
|
||||
<span className="font-mono text-xs text-fg-muted">{headerDuration}</span>
|
||||
<h3 className="text-base font-semibold text-fg">{selectedStage.name}</h3>
|
||||
<span className="font-mono text-xs tabular-nums text-fg-muted">{headerDuration}</span>
|
||||
</div>
|
||||
|
||||
{turns.map((turn: TurnType, i: number) => {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { CSS } from "@dnd-kit/utilities";
|
|||
import { ciConfig, columnStatusDisplay, deriveCiStatus, mapRunListItem } from "../data/runs";
|
||||
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
|
||||
import { apiPaginatedJson } from "../api";
|
||||
import { EmptyState } from "../components/state";
|
||||
import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client";
|
||||
|
||||
export function meta({}: any) {
|
||||
|
|
@ -309,7 +310,7 @@ function PrCard({
|
|||
const lifecycleLabel = boardLifecycleStatusLabel(pr);
|
||||
|
||||
return (
|
||||
<Link to={`/runs/${pr.id}`} className="group block rounded-md border border-line bg-panel/80 p-4 transition-all duration-200 hover:border-line-strong hover:bg-panel hover:shadow-lg hover:shadow-black/20">
|
||||
<Link to={`/runs/${pr.id}`} className="group block rounded-md border border-line bg-panel p-4 transition-all duration-200 hover:border-line-strong hover:shadow-lg hover:shadow-black/20">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Icon className={`size-3.5 shrink-0 ${iconColor}`} />
|
||||
<span className="font-mono text-xs font-medium text-teal-500">
|
||||
|
|
@ -321,7 +322,7 @@ function PrCard({
|
|||
</span>
|
||||
)}
|
||||
{lifecycleLabel != null && (
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[11px] uppercase tracking-wide text-fg-muted">
|
||||
{lifecycleLabel}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -336,10 +337,10 @@ function PrCard({
|
|||
)}
|
||||
{pr.additions != null && pr.deletions != null && (
|
||||
<>
|
||||
<span className="text-mint">
|
||||
<span className="tabular-nums text-mint">
|
||||
+{pr.additions.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-coral">
|
||||
<span className="tabular-nums text-coral">
|
||||
-{pr.deletions.toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
|
|
@ -459,7 +460,7 @@ function BoardColumn({ column }: { column: Column }) {
|
|||
const Icon = iconMap[column.iconType];
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full ${column.dot}`} />
|
||||
<h3 className="text-sm font-semibold tracking-wide text-fg-2">
|
||||
{column.name}
|
||||
|
|
@ -501,7 +502,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
<span className="font-mono text-xs font-medium text-teal-500">{run.repo}</span>
|
||||
<span className="truncate text-sm text-fg-2">{run.title}</span>
|
||||
{lifecycleLabel != null && (
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[11px] uppercase tracking-wide text-fg-muted">
|
||||
{lifecycleLabel}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -607,12 +608,12 @@ function CopyButton({ text }: { text: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
function RunsLandingEmpty() {
|
||||
return (
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<div className="w-full max-w-lg space-y-5">
|
||||
<p className="text-center text-sm text-fg-muted">
|
||||
Workflow runs will appear here as you run them.
|
||||
Your runs will appear here.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-line bg-panel/60 p-5">
|
||||
|
|
@ -741,6 +742,10 @@ export default function Runs({ loaderData }: any) {
|
|||
(item.number != null && `#${item.number}`.includes(lowerQuery))),
|
||||
),
|
||||
}));
|
||||
const filteredRuns = filteredColumns.reduce(
|
||||
(sum, col) => sum + col.items.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
|
|
@ -750,7 +755,9 @@ export default function Runs({ loaderData }: any) {
|
|||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search runs..."
|
||||
name="search"
|
||||
aria-label="Search runs"
|
||||
placeholder="Search runs…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
|
|
@ -758,6 +765,8 @@ export default function Runs({ loaderData }: any) {
|
|||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
name="repo"
|
||||
aria-label="Filter by repository"
|
||||
value={repoFilter}
|
||||
onChange={(e) => setRepoFilter(e.target.value)}
|
||||
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
|
|
@ -769,11 +778,12 @@ export default function Runs({ loaderData }: any) {
|
|||
</select>
|
||||
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
</div>
|
||||
<div className="flex rounded-md border border-line bg-panel/80">
|
||||
<div role="group" aria-label="Run list view" className="flex rounded-md border border-line bg-panel/80 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("columns")}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "columns" ? "text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
aria-pressed={view === "columns"}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors ${view === "columns" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
aria-label="Columns view"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
|
||||
|
|
@ -783,7 +793,8 @@ export default function Runs({ loaderData }: any) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setView("list")}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "list" ? "text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
aria-pressed={view === "list"}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors ${view === "list" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
aria-label="List view"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
|
||||
|
|
@ -802,9 +813,16 @@ export default function Runs({ loaderData }: any) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{totalRuns === 0 && (
|
||||
<EmptyState />
|
||||
)}
|
||||
{totalRuns === 0 ? (
|
||||
<RunsLandingEmpty />
|
||||
) : filteredRuns === 0 ? (
|
||||
<div className="py-8">
|
||||
<EmptyState
|
||||
title="No matching runs"
|
||||
description="Try clearing the search or repo filter."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -847,9 +865,16 @@ export default function Runs({ loaderData }: any) {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
{totalRuns === 0 && (
|
||||
<EmptyState />
|
||||
)}
|
||||
{totalRuns === 0 ? (
|
||||
<RunsLandingEmpty />
|
||||
) : filteredRuns === 0 ? (
|
||||
<div className="py-8">
|
||||
<EmptyState
|
||||
title="No matching runs"
|
||||
description="Try clearing the search or repo filter."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ export function meta({}: any) {
|
|||
return [{ title: "Settings — Fabro" }];
|
||||
}
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request }: any) {
|
||||
const settings = await apiJson<ServerSettings>("/settings", { request });
|
||||
return { settings };
|
||||
|
|
@ -23,10 +21,15 @@ export default function Settings({ loaderData }: any) {
|
|||
const { settings } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<>
|
||||
<p className="mb-6 max-w-[60ch] text-sm/6 text-fg-3 text-pretty">
|
||||
Snapshot of the server configuration. Edit via{" "}
|
||||
<code className="font-mono text-fg-2">settings.toml</code>; changes
|
||||
take effect on the next server restart.
|
||||
</p>
|
||||
<CollapsibleFile
|
||||
file={{ name: "server.json", contents: JSON.stringify(settings, null, 2), lang: "json" }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,68 @@
|
|||
import { AuthLayout } from "../components/auth-layout";
|
||||
import { PRIMARY_BUTTON_CLASS } from "../components/ui";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "Open a terminal on the server host",
|
||||
body: (
|
||||
<p className="text-sm/6 text-fg-3">
|
||||
Run{" "}
|
||||
<code className="font-mono text-fg-2">fabro install</code> on the same
|
||||
host that runs the Fabro server.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Choose GitHub App setup",
|
||||
body: (
|
||||
<p className="text-sm/6 text-fg-3">
|
||||
The CLI opens GitHub, exchanges the manifest code, and writes the
|
||||
required settings and secrets locally.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Restart the server, then return to sign in",
|
||||
body: (
|
||||
<p className="text-sm/6 text-fg-3">
|
||||
Once the server comes back up, you can authenticate from the browser.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Setup() {
|
||||
return (
|
||||
<AuthLayout footer="GitHub App setup is managed from the terminal, not the browser.">
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
<h1 className="text-center text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
|
||||
Set up Fabro
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
Run the installer on the same host that runs the Fabro server to
|
||||
register a GitHub App and write local configuration.
|
||||
<p className="mt-3 text-center text-sm/6 text-fg-3 text-pretty">
|
||||
Run the installer on the server host to register a GitHub App and write
|
||||
local configuration.
|
||||
</p>
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="rounded-lg border border-line-strong bg-overlay px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
1. Open a terminal on the server host
|
||||
</p>
|
||||
<pre className="mt-2 overflow-x-auto text-sm text-fg-2">
|
||||
<code>fabro install</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-lg border border-line-strong bg-overlay px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
2. Choose GitHub App setup
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-fg-3">
|
||||
The CLI opens GitHub, exchanges the manifest code, and writes the
|
||||
required settings and secrets locally.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-line-strong bg-overlay px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
3. Restart the server, then return to sign in
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
className="mt-3 flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
>
|
||||
Continue to sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ol
|
||||
role="list"
|
||||
className="mt-8 divide-y divide-line border-y border-line"
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<li key={step.title} className="flex items-start gap-4 py-4">
|
||||
<span
|
||||
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-overlay text-xs font-semibold tabular-nums text-fg-2 outline-1 -outline-offset-1 outline-white/10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-fg">{step.title}</p>
|
||||
<div className="mt-1">{step.body}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<a href="/login" className={`${PRIMARY_BUTTON_CLASS} mt-8 w-full`}>
|
||||
Continue to sign in
|
||||
</a>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export default function Start({ loaderData }: any) {
|
|||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!prompt.trim()}
|
||||
className="flex items-center justify-center size-8 rounded-lg bg-teal-500 text-navy-950 transition-all duration-200 hover:bg-teal-300 disabled:opacity-30 disabled:hover:bg-teal-500 disabled:cursor-default"
|
||||
className="flex items-center justify-center size-8 rounded-lg bg-teal-500 text-on-primary transition-all duration-200 hover:bg-teal-300 disabled:opacity-30 disabled:hover:bg-teal-500 disabled:cursor-default"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ description: "Server-owned settings.toml sections, CLI overrides, and environmen
|
|||
On a same-machine setup, the CLI and server share one `settings.toml`. On a remote deployment, the server machine has its own `settings.toml`, and the client machine keeps a separate local `settings.toml` for CLI-only values such as `[cli.target]`.
|
||||
|
||||
<Note>
|
||||
Legacy `server.toml`, `user.toml`, and `cli.toml` are ignored with a warning. Rename them to `settings.toml`.
|
||||
Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.toml` filenames are no longer part of the supported config surface.
|
||||
</Note>
|
||||
|
||||
### Which sections are server-owned
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ fabro doctor --server https://fabro.example.com/api/v1
|
|||
```
|
||||
|
||||
It checks:
|
||||
- Local user config and legacy `~/.fabro/.env` warnings
|
||||
- Local user config and storage directory health
|
||||
- Server-reported LLM provider connectivity
|
||||
- GitHub App, sandbox, and Brave Search credentials
|
||||
- Server authentication and crypto configuration
|
||||
|
|
|
|||
|
|
@ -401,10 +401,11 @@ paths:
|
|||
operationId: listRuns
|
||||
tags: [Runs]
|
||||
summary: List Runs
|
||||
description: Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
description: Returns durable run summaries from the backing store, including runs persisted before the current server boot. Archived runs are hidden by default; pass `include_archived=true` to include them in the response.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PageLimit"
|
||||
- $ref: "#/components/parameters/PageOffset"
|
||||
- $ref: "#/components/parameters/IncludeArchived"
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated durable run summaries
|
||||
|
|
@ -674,6 +675,69 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/archive:
|
||||
post:
|
||||
operationId: archiveRun
|
||||
tags: [Runs]
|
||||
summary: Archive Run
|
||||
description: >
|
||||
Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`.
|
||||
Archived runs are hidden from default listings and are read-only until
|
||||
unarchived. Idempotent on already-archived runs. Returns 409 if the run
|
||||
is not terminal.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
responses:
|
||||
"200":
|
||||
description: Run archived (or already archived)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RunStatusResponse"
|
||||
"404":
|
||||
description: Run not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"409":
|
||||
description: Run is not terminal and cannot be archived
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/unarchive:
|
||||
post:
|
||||
operationId: unarchiveRun
|
||||
tags: [Runs]
|
||||
summary: Unarchive Run
|
||||
description: >
|
||||
Restores an archived run to its prior terminal status. Idempotent on
|
||||
runs that are terminal but not archived (returns the current status
|
||||
without emitting an event). Returns 409 if the run is active.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
responses:
|
||||
"200":
|
||||
description: Run unarchived (or already not archived)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RunStatusResponse"
|
||||
"404":
|
||||
description: Run not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"409":
|
||||
description: Run is active and cannot be unarchived
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/graph:
|
||||
get:
|
||||
operationId: retrieveRunGraph
|
||||
|
|
@ -1970,6 +2034,16 @@ components:
|
|||
default: 0
|
||||
example: 0
|
||||
|
||||
IncludeArchived:
|
||||
name: include_archived
|
||||
in: query
|
||||
required: false
|
||||
description: Whether to include archived runs in the response. Defaults to `false`.
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
example: false
|
||||
|
||||
ModelProviderFilter:
|
||||
name: provider
|
||||
in: query
|
||||
|
|
@ -2207,7 +2281,6 @@ components:
|
|||
required:
|
||||
- status
|
||||
- restart_url
|
||||
- dev_token
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
|
|
@ -2217,6 +2290,10 @@ components:
|
|||
format: uri
|
||||
dev_token:
|
||||
type: string
|
||||
description: |
|
||||
Dev token used to bootstrap login. Only included when the operator
|
||||
chose the personal access token flow; GitHub App installs rely on
|
||||
OAuth and do not receive a dev token.
|
||||
|
||||
# ── Pagination ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -2630,7 +2707,11 @@ components:
|
|||
# ── Run Schemas ──────────────────────────────────────────────────────
|
||||
|
||||
RunStatus:
|
||||
description: Lifecycle status of a run.
|
||||
description: >
|
||||
Lifecycle status of a run. `archived` is a terminal status reached by
|
||||
an explicit user action on a previously terminal (`succeeded`, `failed`,
|
||||
or `dead`) run; archived runs are hidden from default listings and are
|
||||
read-only until unarchived.
|
||||
type: string
|
||||
enum:
|
||||
- submitted
|
||||
|
|
@ -2643,6 +2724,7 @@ components:
|
|||
- succeeded
|
||||
- failed
|
||||
- dead
|
||||
- archived
|
||||
|
||||
RunManifest:
|
||||
description: Self-contained workflow run manifest.
|
||||
|
|
|
|||
24
docs/changelog/2026-04-19.mdx
Normal file
24
docs/changelog/2026-04-19.mdx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
title: "Archive terminal runs"
|
||||
date: "2026-04-19"
|
||||
---
|
||||
|
||||
## Archive terminal runs
|
||||
|
||||
Terminal runs — `succeeded`, `failed`, and `dead` — can now be explicitly archived to signal "reviewed, no further action needed." Archived runs are hidden from default listings (`fabro ps`, `GET /api/v1/runs`) and are read-only: any mutation on an archived run returns an actionable error asking you to unarchive first.
|
||||
|
||||
Two new CLI commands mirror `fabro rm`'s bulk-by-ID shape:
|
||||
|
||||
```
|
||||
fabro archive <run-id> [<run-id> ...]
|
||||
fabro unarchive <run-id> [<run-id> ...]
|
||||
```
|
||||
|
||||
Both are idempotent in their respective directions — archiving an already-archived run is a no-op, and unarchiving a non-archived terminal run is a no-op — so retries and mixed-id batches are safe. Unarchive restores the run's exact prior terminal status.
|
||||
|
||||
On the HTTP surface, two new endpoints sit alongside the existing control family:
|
||||
|
||||
- `POST /api/v1/runs/{id}/archive`
|
||||
- `POST /api/v1/runs/{id}/unarchive`
|
||||
|
||||
`GET /api/v1/runs` gained an `include_archived=true` query parameter for opting in. `fabro ps` continues to show active runs only by default; `fabro ps -a` now shows active, terminal, and archived runs together.
|
||||
|
|
@ -252,6 +252,7 @@
|
|||
"group": "April 2026",
|
||||
"icon": "clock-rotate-left",
|
||||
"pages": [
|
||||
"changelog/2026-04-19",
|
||||
"changelog/2026-04-18",
|
||||
"changelog/2026-04-17",
|
||||
"changelog/2026-04-16",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: "feat: Add terminal `archived` status to workflow runs"
|
||||
type: feat
|
||||
status: active
|
||||
status: completed
|
||||
date: 2026-04-19
|
||||
origin: docs/brainstorms/2026-04-19-run-archived-status-requirements.md
|
||||
---
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Fabro loads machine defaults from `~/.fabro/settings.toml`. The file is optional
|
|||
On a same-machine setup, the CLI and server both read this file. On a remote setup, each machine has its own `settings.toml` and reads the sections relevant to that process.
|
||||
|
||||
<Note>
|
||||
Legacy `cli.toml`, `user.toml`, and `server.toml` are ignored with a warning. Rename them to `settings.toml`.
|
||||
Fabro only reads `settings.toml`. Older `cli.toml`, `user.toml`, and `server.toml` filenames are no longer part of the supported config surface.
|
||||
</Note>
|
||||
|
||||
## File location
|
||||
|
|
|
|||
|
|
@ -292,6 +292,26 @@ pub(crate) struct RunsRemoveArgs {
|
|||
pub(crate) force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunsArchiveArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
/// Run IDs or workflow names to archive
|
||||
#[arg(required = true)]
|
||||
pub(crate) runs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunsUnarchiveArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) server: ServerTargetArgs,
|
||||
|
||||
/// Run IDs or workflow names to unarchive
|
||||
#[arg(required = true)]
|
||||
pub(crate) runs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct LogsArgs {
|
||||
#[command(flatten)]
|
||||
|
|
@ -942,6 +962,11 @@ pub(crate) enum RunsCommands {
|
|||
Rm(RunsRemoveArgs),
|
||||
/// Show detailed information about a workflow run
|
||||
Inspect(InspectArgs),
|
||||
/// Mark terminal runs as archived (reviewed, no further action needed).
|
||||
/// Archived runs are hidden from default listings.
|
||||
Archive(RunsArchiveArgs),
|
||||
/// Restore archived runs to their prior terminal status.
|
||||
Unarchive(RunsUnarchiveArgs),
|
||||
}
|
||||
|
||||
impl RunsCommands {
|
||||
|
|
@ -950,6 +975,8 @@ impl RunsCommands {
|
|||
Self::Ps(_) => "ps",
|
||||
Self::Rm(_) => "rm",
|
||||
Self::Inspect(_) => "inspect",
|
||||
Self::Archive(_) => "archive",
|
||||
Self::Unarchive(_) => "unarchive",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,8 @@ fn config_layers(
|
|||
Some(path) => workflow_and_project_layers(path, cwd)?,
|
||||
None => (SettingsLayer::default(), load_settings_project(cwd)?),
|
||||
};
|
||||
let user_layer = user_config::settings_layer_with_config_and_storage_dir(
|
||||
Some(ctx.base_config_path()),
|
||||
None,
|
||||
)?;
|
||||
let user_layer =
|
||||
user_config::load_settings_with_config_and_storage_dir(Some(ctx.base_config_path()), None)?;
|
||||
Ok(EffectiveSettingsLayers::new(
|
||||
SettingsLayer::default(),
|
||||
workflow_layer,
|
||||
|
|
|
|||
|
|
@ -7,11 +7,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use anyhow::Result;
|
||||
use fabro_api::types as api_types;
|
||||
use fabro_config::legacy_env;
|
||||
use fabro_config::user::{
|
||||
active_settings_path, legacy_old_user_config_path, legacy_server_config_path,
|
||||
legacy_user_config_path,
|
||||
};
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_types::settings::CliSettings;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
pub(crate) use fabro_util::check_report::{
|
||||
|
|
@ -27,12 +23,9 @@ use crate::command_context::CommandContext;
|
|||
use crate::shared::print_json_pretty;
|
||||
use crate::user_config;
|
||||
|
||||
pub(crate) fn check_config(
|
||||
settings_path: Option<PathBuf>,
|
||||
legacy_paths: &[PathBuf],
|
||||
) -> CheckResult {
|
||||
match (settings_path, legacy_paths.is_empty()) {
|
||||
(Some(path), true) => {
|
||||
pub(crate) fn check_config(settings_path: Option<PathBuf>) -> CheckResult {
|
||||
match settings_path {
|
||||
Some(path) => {
|
||||
let display = contract_tilde(&path);
|
||||
CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
|
|
@ -45,44 +38,7 @@ pub(crate) fn check_config(
|
|||
remediation: None,
|
||||
}
|
||||
}
|
||||
(Some(path), false) => {
|
||||
let display = contract_tilde(&path);
|
||||
CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: display.display().to_string(),
|
||||
details: std::iter::once(CheckDetail::new(format!(
|
||||
"Loaded from {}",
|
||||
display.display()
|
||||
)))
|
||||
.chain(legacy_paths.iter().map(|legacy| {
|
||||
let legacy_display = contract_tilde(legacy);
|
||||
CheckDetail::new(format!(
|
||||
"Ignoring legacy config file {}",
|
||||
legacy_display.display()
|
||||
))
|
||||
}))
|
||||
.collect(),
|
||||
remediation: Some("Delete or rename legacy config files".to_string()),
|
||||
}
|
||||
}
|
||||
(None, false) => CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "legacy config files ignored".to_string(),
|
||||
details: legacy_paths
|
||||
.iter()
|
||||
.map(|legacy| {
|
||||
CheckDetail::new(format!("Found legacy config file {}", legacy.display()))
|
||||
})
|
||||
.chain(std::iter::once(CheckDetail::new(
|
||||
"Rename one to ~/.fabro/settings.toml or create a new settings.toml"
|
||||
.to_string(),
|
||||
)))
|
||||
.collect(),
|
||||
remediation: Some("Create ~/.fabro/settings.toml".to_string()),
|
||||
},
|
||||
(None, true) => CheckResult {
|
||||
None => CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "no settings config file found".to_string(),
|
||||
|
|
@ -94,22 +50,6 @@ pub(crate) fn check_config(
|
|||
}
|
||||
}
|
||||
|
||||
fn check_legacy_env(path: Option<PathBuf>) -> Option<CheckResult> {
|
||||
path.map(|path| {
|
||||
let display = contract_tilde(&path);
|
||||
CheckResult {
|
||||
name: "Legacy .env".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "legacy secrets file detected".to_string(),
|
||||
details: vec![CheckDetail::new(format!(
|
||||
"{} is no longer read by fabro",
|
||||
display.display()
|
||||
))],
|
||||
remediation: Some("Re-enter credentials with `fabro provider login`.".to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct StorageDirStatus {
|
||||
path: PathBuf,
|
||||
|
|
@ -289,37 +229,20 @@ pub(crate) async fn run_doctor(
|
|||
};
|
||||
|
||||
let settings_config_path = active_settings_path(None);
|
||||
let legacy_config_paths = [
|
||||
legacy_user_config_path(),
|
||||
legacy_old_user_config_path(),
|
||||
legacy_server_config_path(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|path| path.exists())
|
||||
.collect::<Vec<_>>();
|
||||
let legacy_env_path = {
|
||||
let p = legacy_env::legacy_env_file_path();
|
||||
p.exists().then_some(p)
|
||||
};
|
||||
|
||||
let settings = user_config::load_settings().unwrap_or_default();
|
||||
let storage_dir_path = user_config::storage_dir(&settings)
|
||||
.unwrap_or_else(|_| fabro_util::Home::from_env().storage_dir());
|
||||
let storage_dir_path =
|
||||
user_config::storage_dir(&settings).unwrap_or_else(|_| user_config::default_storage_dir());
|
||||
let storage_dir = probe_storage_dir(&storage_dir_path);
|
||||
|
||||
let mut local_checks = vec![
|
||||
let local_checks = vec![
|
||||
check_config(
|
||||
settings_config_path
|
||||
.exists()
|
||||
.then_some(settings_config_path),
|
||||
&legacy_config_paths,
|
||||
),
|
||||
check_storage_dir(&storage_dir),
|
||||
];
|
||||
if let Some(legacy_env_check) = check_legacy_env(legacy_env_path) {
|
||||
local_checks.push(legacy_env_check);
|
||||
}
|
||||
|
||||
let mut report = CheckReport {
|
||||
title: "Fabro Doctor".to_string(),
|
||||
|
|
@ -475,45 +398,18 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn check_config_pass_with_path() {
|
||||
let result = check_config(Some(PathBuf::from("/home/user/.fabro/settings.toml")), &[]);
|
||||
let result = check_config(Some(PathBuf::from("/home/user/.fabro/settings.toml")));
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.summary.contains(".fabro/settings.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_config_warning_without_path() {
|
||||
let result = check_config(None, &[]);
|
||||
let result = check_config(None);
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert!(result.remediation.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_config_warning_for_legacy_only_path() {
|
||||
let result = check_config(None, &[PathBuf::from("/home/user/.fabro/cli.toml")]);
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert!(result.summary.contains("legacy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_legacy_env_warning_when_present() {
|
||||
let result = check_legacy_env(Some(PathBuf::from("/home/user/.fabro/.env")));
|
||||
assert_eq!(
|
||||
result.as_ref().map(|check| check.status),
|
||||
Some(CheckStatus::Warning)
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.as_ref()
|
||||
.is_some_and(|check| check.summary.contains("legacy secrets file"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_legacy_env_is_omitted_when_absent() {
|
||||
let result = check_legacy_env(None);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// -- check_storage_dir --
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ use dialoguer::theme::ColorfulTheme;
|
|||
use dialoguer::{MultiSelect, Select};
|
||||
use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType};
|
||||
use fabro_auth::{AuthCredential, AuthMethod, codex_oauth_config, credential_id_for};
|
||||
use fabro_config::user::{SETTINGS_CONFIG_FILENAME, legacy_default_storage_root};
|
||||
use fabro_config::{ResolveError, Storage, envfile, legacy_env};
|
||||
use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir};
|
||||
use fabro_config::{ResolveError, Storage, envfile};
|
||||
use fabro_install::{
|
||||
InstallListenConfig, generate_jwt_keypair, merge_server_settings as merge_server_settings_impl,
|
||||
write_github_app_settings, write_token_settings,
|
||||
|
|
@ -1171,7 +1171,9 @@ async fn persist_install_outputs(
|
|||
settings_write,
|
||||
server_was_running,
|
||||
|path| Box::pin(server_client::connect_api_client(path)),
|
||||
|path, timeout| Box::pin(stop::stop_server(path, timeout)),
|
||||
|path, timeout| {
|
||||
Box::pin(async move { stop::stop_server(path, timeout).await.unwrap_or(false) })
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -1360,7 +1362,9 @@ async fn restart_server_after_install(
|
|||
restart_server_after_install_with(
|
||||
storage_dir,
|
||||
config_path,
|
||||
|path, timeout| Box::pin(stop::stop_server(path, timeout)),
|
||||
|path, timeout| {
|
||||
Box::pin(async move { stop::stop_server(path, timeout).await.unwrap_or(false) })
|
||||
},
|
||||
|storage_dir, config_path| {
|
||||
Box::pin(start::ensure_server_running_for_storage(
|
||||
storage_dir,
|
||||
|
|
@ -1483,9 +1487,9 @@ async fn run_install_github_inner(
|
|||
let storage_dir = user_config::storage_dir(&parsed_settings).unwrap_or_else(|_| {
|
||||
args.storage_dir
|
||||
.clone_path()
|
||||
.unwrap_or_else(|| legacy_default_storage_root().join("storage"))
|
||||
.unwrap_or_else(default_storage_dir)
|
||||
});
|
||||
let server_was_running = record::active_server_record(&storage_dir).is_some();
|
||||
let server_was_running = record::active_server_record(&storage_dir)?.is_some();
|
||||
let mut doc: toml::Value = toml::from_str(&existing_config_contents)
|
||||
.context("failed to parse existing settings.toml")?;
|
||||
|
||||
|
|
@ -1628,7 +1632,7 @@ async fn run_install_inner(
|
|||
let emoji = console::Emoji("⚒️ ", "");
|
||||
let cli_settings = user_config::load_settings_with_storage_dir(args.storage_dir.as_deref())?;
|
||||
let storage_dir = user_config::storage_dir(&cli_settings)?;
|
||||
let server_was_running = record::active_server_record(&storage_dir).is_some();
|
||||
let server_was_running = record::active_server_record(&storage_dir)?.is_some();
|
||||
let fabro_dir = fabro_util::Home::from_env().root().to_path_buf();
|
||||
let config_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME);
|
||||
let existing_config_contents = std::fs::read_to_string(&config_path).ok();
|
||||
|
|
@ -1661,18 +1665,6 @@ async fn run_install_inner(
|
|||
std::fs::create_dir_all(&fabro_dir)
|
||||
.with_context(|| format!("creating fabro home directory {}", fabro_dir.display()))?;
|
||||
|
||||
{
|
||||
let env_path = legacy_env::legacy_env_file_path();
|
||||
if env_path.exists() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" Warning: {} is no longer read by fabro server. This install will persist runtime secrets in server.env and workflow-visible credentials in the vault instead.",
|
||||
env_path.display()
|
||||
);
|
||||
fabro_util::printerr!(printer, "");
|
||||
}
|
||||
}
|
||||
|
||||
let facts = InstallFacts {
|
||||
codex_detected: detect_binary_on_path("codex").await,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use anyhow::Result;
|
||||
use fabro_api::types;
|
||||
use fabro_auth::credential_id_for;
|
||||
use fabro_config::legacy_env;
|
||||
use fabro_types::settings::CliSettings;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
|
|
@ -36,17 +35,6 @@ pub(super) async fn login_command(
|
|||
let credential_id = credential_id_for(&credential).map_err(anyhow::Error::msg)?;
|
||||
let value = serde_json::to_string(&credential)?;
|
||||
|
||||
{
|
||||
let path = legacy_env::legacy_env_file_path();
|
||||
if path.exists() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" Warning: {} is no longer read by fabro server. Re-enter credentials with `fabro provider login`.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
server
|
||||
.api()
|
||||
.create_secret()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use fabro_util::terminal::Styles;
|
|||
use crate::args::RunArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::user_config::settings_layer_with_storage_dir;
|
||||
use crate::user_config::load_settings_with_storage_dir;
|
||||
|
||||
pub(crate) async fn execute(
|
||||
mut args: RunArgs,
|
||||
|
|
@ -17,7 +17,7 @@ pub(crate) async fn execute(
|
|||
) -> Result<()> {
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?;
|
||||
let cli_defaults = settings_layer_with_storage_dir(None)?;
|
||||
let cli_defaults = load_settings_with_storage_dir(None)?;
|
||||
args.verbose = args.verbose || cli.output.verbosity == OutputVerbosity::Verbose;
|
||||
|
||||
let quiet = args.detach;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use fabro_util::terminal::Styles;
|
|||
use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::user_config::settings_layer_with_storage_dir;
|
||||
use crate::user_config::load_settings_with_storage_dir;
|
||||
|
||||
pub(crate) mod attach;
|
||||
pub(crate) mod command;
|
||||
|
|
@ -38,7 +38,7 @@ pub(crate) async fn dispatch(
|
|||
RunCommands::Run(args) => Box::pin(command::execute(args, cli, cli_layer, printer)).await,
|
||||
RunCommands::Create(args) => {
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
let cli_defaults = settings_layer_with_storage_dir(None)?;
|
||||
let cli_defaults = load_settings_with_storage_dir(None)?;
|
||||
let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?;
|
||||
let created_run = Box::pin(create::create_run(
|
||||
&ctx,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ pub(crate) async fn run(
|
|||
let client = ctx.server().await?;
|
||||
let run_id = client.resolve_run(&args.run_id).await?.run_id;
|
||||
let state = client.get_run_state(&run_id).await?;
|
||||
let current_status = state
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|record| record.status)
|
||||
.context("run has no recorded status — cannot rewind")?;
|
||||
let record = state.run.context("Failed to load run record from store")?;
|
||||
ensure_matching_repo_origin(record.repo_origin_url.as_deref(), "rewind")?;
|
||||
let store = Store::new(repo);
|
||||
|
|
@ -65,6 +70,7 @@ pub(crate) async fn run(
|
|||
run_id,
|
||||
target: target.clone(),
|
||||
push: !args.no_push,
|
||||
current_status,
|
||||
})?;
|
||||
let entry = timeline.resolve(&target)?;
|
||||
reset_rewound_run_state(client.as_ref(), &store, &run_id, entry).await?;
|
||||
|
|
|
|||
|
|
@ -113,7 +113,8 @@ fn print_human_output(
|
|||
RunStatus::Succeeded => (&styles.bold_green, "Succeeded"),
|
||||
RunStatus::Failed => (&styles.bold_red, "Failed"),
|
||||
RunStatus::Dead => (&styles.bold_red, "Dead"),
|
||||
// Poll loop only breaks on is_terminal() which is Succeeded | Failed | Dead
|
||||
RunStatus::Archived => (&styles.dim, "Archived"),
|
||||
// Poll loop only breaks on is_terminal() which is the four arms above.
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let status_display = style.apply_to(label);
|
||||
|
|
|
|||
138
lib/crates/fabro-cli/src/commands/runs/archive.rs
Normal file
138
lib/crates/fabro-cli/src/commands/runs/archive.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
use anyhow::{Result, bail};
|
||||
use fabro_types::settings::CliSettings;
|
||||
use fabro_types::settings::cli::{CliLayer, OutputFormat};
|
||||
use fabro_util::printer::Printer;
|
||||
|
||||
use super::short_run_id;
|
||||
use crate::args::{RunsArchiveArgs, RunsUnarchiveArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::server_client;
|
||||
use crate::server_runs::{
|
||||
ServerRunSummaryInfo, ServerSummaryLookup, resolve_server_run_from_summaries,
|
||||
};
|
||||
use crate::shared::print_json_pretty;
|
||||
|
||||
pub(crate) async fn archive_command(
|
||||
args: &RunsArchiveArgs,
|
||||
cli: &CliSettings,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?;
|
||||
let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?;
|
||||
run_bulk(
|
||||
Action::Archive,
|
||||
&args.runs,
|
||||
lookup.client(),
|
||||
lookup.runs(),
|
||||
cli,
|
||||
printer,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn unarchive_command(
|
||||
args: &RunsUnarchiveArgs,
|
||||
cli: &CliSettings,
|
||||
cli_layer: &CliLayer,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?;
|
||||
let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?;
|
||||
run_bulk(
|
||||
Action::Unarchive,
|
||||
&args.runs,
|
||||
lookup.client(),
|
||||
lookup.runs(),
|
||||
cli,
|
||||
printer,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Action {
|
||||
Archive,
|
||||
Unarchive,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn past(self) -> &'static str {
|
||||
match self {
|
||||
Self::Archive => "archived",
|
||||
Self::Unarchive => "unarchived",
|
||||
}
|
||||
}
|
||||
|
||||
fn json_key(self) -> &'static str {
|
||||
self.past()
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_bulk(
|
||||
action: Action,
|
||||
identifiers: &[String],
|
||||
client: &server_client::ServerStoreClient,
|
||||
runs: &[ServerRunSummaryInfo],
|
||||
cli: &CliSettings,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let json = cli.output.format == OutputFormat::Json;
|
||||
let mut had_errors = false;
|
||||
let mut changed = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for identifier in identifiers {
|
||||
let run = match resolve_server_run_from_summaries(runs, identifier) {
|
||||
Ok(run) => run,
|
||||
Err(err) => {
|
||||
if !json {
|
||||
fabro_util::printerr!(printer, "error: {identifier}: {err}");
|
||||
}
|
||||
errors.push(serde_json::json!({
|
||||
"identifier": identifier,
|
||||
"error": err.to_string(),
|
||||
}));
|
||||
had_errors = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let run_id = run.run_id();
|
||||
let result = match action {
|
||||
Action::Archive => client.archive_run(&run_id).await,
|
||||
Action::Unarchive => client.unarchive_run(&run_id).await,
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let run_id_string = run_id.to_string();
|
||||
changed.push(run_id_string.clone());
|
||||
if !json {
|
||||
fabro_util::printerr!(printer, "{}", short_run_id(&run_id_string));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if !json {
|
||||
fabro_util::printerr!(printer, "error: {identifier}: {err}");
|
||||
}
|
||||
errors.push(serde_json::json!({
|
||||
"identifier": identifier,
|
||||
"error": err.to_string(),
|
||||
}));
|
||||
had_errors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if json {
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert(action.json_key().to_string(), serde_json::json!(changed));
|
||||
body.insert("errors".to_string(), serde_json::json!(errors));
|
||||
print_json_pretty(&serde_json::Value::Object(body))?;
|
||||
}
|
||||
|
||||
if had_errors {
|
||||
bail!("some runs could not be {}", action.past());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ pub(crate) async fn list_command(
|
|||
} else {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
"No running processes found. Use -a to show all runs."
|
||||
"No running processes found. Use -a to show all runs (including archived)."
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
|
|
@ -149,7 +149,7 @@ fn status_cell(status: RunStatus, use_color: bool) -> CellStruct {
|
|||
RunStatus::Succeeded => Some(Color::Green),
|
||||
RunStatus::Failed => Some(Color::Red),
|
||||
RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan),
|
||||
RunStatus::Queued | RunStatus::Dead => Some(Color::Ansi256(8)),
|
||||
RunStatus::Queued | RunStatus::Dead | RunStatus::Archived => Some(Color::Ansi256(8)),
|
||||
RunStatus::Blocked | RunStatus::Removing => Some(Color::Yellow),
|
||||
RunStatus::Paused => Some(Color::Magenta),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use fabro_util::terminal::Styles;
|
|||
|
||||
use crate::args::RunsCommands;
|
||||
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod inspect;
|
||||
pub(crate) mod list;
|
||||
pub(crate) mod rm;
|
||||
|
|
@ -23,6 +24,12 @@ pub(crate) async fn dispatch(
|
|||
}
|
||||
RunsCommands::Rm(args) => rm::remove_command(&args, cli, cli_layer, printer).await,
|
||||
RunsCommands::Inspect(args) => inspect::run(&args, cli, cli_layer, printer).await,
|
||||
RunsCommands::Archive(args) => {
|
||||
archive::archive_command(&args, cli, cli_layer, printer).await
|
||||
}
|
||||
RunsCommands::Unarchive(args) => {
|
||||
archive::unarchive_command(&args, cli, cli_layer, printer).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub(crate) async fn execute(
|
|||
record_path: PathBuf,
|
||||
mut serve_args: ServeArgs,
|
||||
bind: BindRequest,
|
||||
storage_dir: Option<PathBuf>,
|
||||
storage_dir: PathBuf,
|
||||
styles: &'static Styles,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
|
|
@ -35,22 +35,14 @@ pub(crate) async fn execute(
|
|||
None
|
||||
};
|
||||
|
||||
let log_path = storage_dir.as_ref().map_or_else(
|
||||
|| {
|
||||
record_path.parent().map_or_else(
|
||||
|| PathBuf::from("server.log"),
|
||||
|parent| parent.join("server.log"),
|
||||
)
|
||||
},
|
||||
|dir| Storage::new(dir).server_state().log_path(),
|
||||
);
|
||||
let log_path = Storage::new(&storage_dir).server_state().log_path();
|
||||
let dev_token_path = std::env::var_os("FABRO_DEV_TOKEN_PATH").map(PathBuf::from);
|
||||
let pid = std::process::id();
|
||||
|
||||
Box::pin(serve::serve_command(
|
||||
serve_args,
|
||||
styles,
|
||||
storage_dir,
|
||||
Some(storage_dir),
|
||||
move |resolved_bind| {
|
||||
record::write_server_record(&record_path, &record::ServerRecord {
|
||||
pid,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use std::time::Duration;
|
|||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use fabro_config::user::{FABRO_CONFIG_ENV, active_settings_path, legacy_default_storage_root};
|
||||
use fabro_config::user::{FABRO_CONFIG_ENV, active_settings_path, default_storage_dir};
|
||||
use fabro_server::bind::{self, Bind, BindRequest};
|
||||
use fabro_server::install::{self, InstallAppState};
|
||||
use fabro_server::serve::{self, ServeArgs};
|
||||
|
|
@ -27,6 +27,7 @@ use crate::user_config;
|
|||
pub(crate) async fn dispatch(
|
||||
command: ServerCommand,
|
||||
_globals: &GlobalArgs,
|
||||
foreground_log_bootstrap: Option<start::ForegroundServerLogBootstrap>,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
|
|
@ -62,6 +63,7 @@ pub(crate) async fn dispatch(
|
|||
foreground,
|
||||
serve_args,
|
||||
storage_dir,
|
||||
foreground_log_bootstrap,
|
||||
styles,
|
||||
printer,
|
||||
))
|
||||
|
|
@ -73,8 +75,7 @@ pub(crate) async fn dispatch(
|
|||
}) => {
|
||||
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
|
||||
let storage_dir = user_config::storage_dir(&settings)?;
|
||||
stop::execute(&storage_dir, Duration::from_secs(timeout), printer).await;
|
||||
Ok(())
|
||||
stop::execute(&storage_dir, Duration::from_secs(timeout), printer).await
|
||||
}
|
||||
ServerCommand::Restart(ServerRestartArgs {
|
||||
storage_dir,
|
||||
|
|
@ -87,7 +88,7 @@ pub(crate) async fn dispatch(
|
|||
storage_dir.as_deref(),
|
||||
&serve_args,
|
||||
)? {
|
||||
stop::stop_server(&bootstrap.storage_dir, Duration::from_secs(timeout)).await;
|
||||
stop::stop_server(&bootstrap.storage_dir, Duration::from_secs(timeout)).await?;
|
||||
if serve_args.no_web {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
|
|
@ -102,7 +103,7 @@ pub(crate) async fn dispatch(
|
|||
storage_dir.as_deref(),
|
||||
)?;
|
||||
let storage_dir = user_config::storage_dir(&settings)?;
|
||||
stop::stop_server(&storage_dir, Duration::from_secs(timeout)).await;
|
||||
stop::stop_server(&storage_dir, Duration::from_secs(timeout)).await?;
|
||||
let bind_addr =
|
||||
serve::resolve_bind_request_from_settings(&settings, serve_args.bind.as_deref())?;
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
|
|
@ -111,6 +112,7 @@ pub(crate) async fn dispatch(
|
|||
foreground,
|
||||
serve_args,
|
||||
storage_dir,
|
||||
foreground_log_bootstrap,
|
||||
styles,
|
||||
printer,
|
||||
))
|
||||
|
|
@ -136,6 +138,7 @@ pub(crate) async fn dispatch(
|
|||
.clone()
|
||||
.unwrap_or_else(|| user_config::active_settings_path(None)),
|
||||
);
|
||||
let storage_dir = user_config::storage_dir(&settings)?;
|
||||
let bind_addr =
|
||||
serve::resolve_bind_request_from_settings(&settings, serve_args.bind.as_deref())?;
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
|
|
@ -146,7 +149,7 @@ pub(crate) async fn dispatch(
|
|||
..serve_args
|
||||
},
|
||||
bind_addr,
|
||||
storage_dir.clone_path(),
|
||||
storage_dir,
|
||||
styles,
|
||||
printer,
|
||||
))
|
||||
|
|
@ -181,10 +184,7 @@ fn maybe_install_bootstrap(
|
|||
None => default_install_bind_request(),
|
||||
};
|
||||
|
||||
let storage_dir = storage_dir.map_or_else(
|
||||
|| legacy_default_storage_root().join("storage"),
|
||||
std::path::Path::to_path_buf,
|
||||
);
|
||||
let storage_dir = storage_dir.map_or_else(default_storage_dir, std::path::Path::to_path_buf);
|
||||
|
||||
Ok(Some(InstallBootstrap {
|
||||
bind_request,
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@
|
|||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_config::Storage;
|
||||
use fabro_config::user::legacy_default_storage_root;
|
||||
use fabro_config::user::default_storage_dir;
|
||||
use fabro_server::bind::Bind;
|
||||
use fabro_util::Home;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -55,9 +56,8 @@ fn server_record_path(storage_dir: &Path) -> PathBuf {
|
|||
}
|
||||
|
||||
fn legacy_record_path(storage_dir: &Path) -> Option<PathBuf> {
|
||||
let default_storage_dir = legacy_default_storage_root().join("storage");
|
||||
if storage_dir == default_storage_dir {
|
||||
Some(server_record_path(&legacy_default_storage_root()))
|
||||
if storage_dir == default_storage_dir() {
|
||||
Some(Home::from_env().root().join("server.json"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
@ -76,14 +76,29 @@ fn active_server_record_at_path(path: PathBuf) -> Option<ActiveServerRecord> {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn active_server_record_details(storage_dir: &Path) -> Option<ActiveServerRecord> {
|
||||
pub(crate) fn active_server_record_details(
|
||||
storage_dir: &Path,
|
||||
) -> Result<Option<ActiveServerRecord>> {
|
||||
let primary_path = server_record_path(storage_dir);
|
||||
active_server_record_at_path(primary_path)
|
||||
.or_else(|| legacy_record_path(storage_dir).and_then(active_server_record_at_path))
|
||||
if let Some(active) = active_server_record_at_path(primary_path.clone()) {
|
||||
return Ok(Some(active));
|
||||
}
|
||||
|
||||
if let Some(legacy_path) = legacy_record_path(storage_dir) {
|
||||
if active_server_record_at_path(legacy_path.clone()).is_some() {
|
||||
bail!(
|
||||
"Legacy server record {} is still active while current storage record {} is missing.\nStop the old daemon with a legacy Fabro CLI or manually clear the stale daemon before retrying.",
|
||||
legacy_path.display(),
|
||||
primary_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn active_server_record(storage_dir: &Path) -> Option<ServerRecord> {
|
||||
active_server_record_details(storage_dir).map(|active| active.record)
|
||||
pub(crate) fn active_server_record(storage_dir: &Path) -> Result<Option<ServerRecord>> {
|
||||
Ok(active_server_record_details(storage_dir)?.map(|active| active.record))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
|
@ -116,7 +131,7 @@ mod tests {
|
|||
ServerRecord {
|
||||
pid: std::process::id(),
|
||||
bind,
|
||||
log_path: PathBuf::from("/tmp/server.log"),
|
||||
log_path: PathBuf::from("/tmp/storage/logs/server.log"),
|
||||
dev_token_path: None,
|
||||
started_at: Utc::now(),
|
||||
}
|
||||
|
|
@ -137,7 +152,7 @@ mod tests {
|
|||
#[test]
|
||||
fn active_server_record_returns_none_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(active_server_record(dir.path()).is_none());
|
||||
assert!(active_server_record(dir.path()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -148,7 +163,7 @@ mod tests {
|
|||
record.pid = u32::MAX; // definitely not alive
|
||||
write_server_record(&path, &record).unwrap();
|
||||
|
||||
assert!(active_server_record(dir.path()).is_none());
|
||||
assert!(!path.exists()); // lazy cleanup removed file
|
||||
assert!(active_server_record(dir.path()).unwrap().is_none());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,11 +24,17 @@ use tokio::time;
|
|||
|
||||
use super::record;
|
||||
|
||||
pub(crate) struct ForegroundServerLogBootstrap {
|
||||
#[expect(dead_code, reason = "held for its Drop to release the server lock")]
|
||||
lock_file: std::fs::File,
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(
|
||||
bind: BindRequest,
|
||||
foreground: bool,
|
||||
mut serve_args: ServeArgs,
|
||||
storage_dir: PathBuf,
|
||||
foreground_log_bootstrap: Option<ForegroundServerLogBootstrap>,
|
||||
styles: &'static Styles,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
|
|
@ -39,6 +45,8 @@ pub(crate) async fn execute(
|
|||
bind,
|
||||
serve_args,
|
||||
storage_dir,
|
||||
foreground_log_bootstrap
|
||||
.context("internal error: missing foreground server log bootstrap")?,
|
||||
styles,
|
||||
printer,
|
||||
))
|
||||
|
|
@ -56,6 +64,29 @@ pub(crate) async fn execute(
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_foreground_server_log(
|
||||
storage_dir: &Path,
|
||||
) -> Result<ForegroundServerLogBootstrap> {
|
||||
let lock_file = acquire_lock(storage_dir).await?;
|
||||
if let Some(existing) = record::active_server_record(storage_dir)? {
|
||||
bail!(
|
||||
"Server already running (pid {}) on {}",
|
||||
existing.pid,
|
||||
existing.bind
|
||||
);
|
||||
}
|
||||
|
||||
let log_path = Storage::new(storage_dir).server_state().log_path();
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating log directory {}", parent.display()))?;
|
||||
}
|
||||
std::fs::File::create(&log_path)
|
||||
.with_context(|| format!("creating server log file {}", log_path.display()))?;
|
||||
|
||||
Ok(ForegroundServerLogBootstrap { lock_file })
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_server_running_for_storage(
|
||||
storage_dir: &Path,
|
||||
config_path: &Path,
|
||||
|
|
@ -87,7 +118,7 @@ async fn ensure_server_running_with_bind(
|
|||
config_path: &Path,
|
||||
storage_dir: &Path,
|
||||
) -> Result<Bind> {
|
||||
if let Some(existing) = record::active_server_record(storage_dir) {
|
||||
if let Some(existing) = record::active_server_record(storage_dir)? {
|
||||
if bind_request
|
||||
.as_ref()
|
||||
.is_none_or(|requested| bind_matches_request(&existing.bind, requested))
|
||||
|
|
@ -131,7 +162,7 @@ async fn ensure_server_running_with_bind(
|
|||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => record::active_server_record(storage_dir)
|
||||
Ok(()) => record::active_server_record(storage_dir)?
|
||||
.map(|server| server.bind)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
|
|
@ -140,7 +171,7 @@ async fn ensure_server_running_with_bind(
|
|||
)
|
||||
}),
|
||||
Err(err) => {
|
||||
if let Some(existing) = record::active_server_record(storage_dir) {
|
||||
if let Some(existing) = record::active_server_record(storage_dir)? {
|
||||
Ok(existing.bind)
|
||||
} else {
|
||||
Err(err)
|
||||
|
|
@ -265,6 +296,7 @@ async fn execute_foreground(
|
|||
bind: BindRequest,
|
||||
serve_args: ServeArgs,
|
||||
storage_dir: PathBuf,
|
||||
_log_bootstrap: ForegroundServerLogBootstrap,
|
||||
styles: &'static Styles,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
|
|
@ -289,17 +321,6 @@ async fn execute_foreground(
|
|||
},
|
||||
);
|
||||
|
||||
let lock_file = acquire_lock(&storage_dir).await?;
|
||||
let _lock_file = lock_file; // keep alive for the duration
|
||||
|
||||
if let Some(existing) = record::active_server_record(&storage_dir) {
|
||||
bail!(
|
||||
"Server already running (pid {}) on {}",
|
||||
existing.pid,
|
||||
existing.bind
|
||||
);
|
||||
}
|
||||
|
||||
let server_state = Storage::new(&storage_dir).server_state();
|
||||
let record_path = server_state.record_path();
|
||||
let log_path = server_state.log_path();
|
||||
|
|
@ -349,9 +370,9 @@ async fn execute_daemon(
|
|||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
let lock_file = acquire_lock(storage_dir).await?;
|
||||
let _lock_file = lock_file; // keep alive until function returns
|
||||
let _lock_file = lock_file;
|
||||
|
||||
if let Some(existing) = record::active_server_record(storage_dir) {
|
||||
if let Some(existing) = record::active_server_record(storage_dir)? {
|
||||
if announce {
|
||||
bail!(
|
||||
"Server already running (pid {}) on {}",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use fabro_util::printer::Printer;
|
|||
use super::record;
|
||||
|
||||
pub(crate) fn execute(storage_dir: &Path, json: bool, printer: Printer) -> Result<()> {
|
||||
let Some(record) = record::active_server_record(storage_dir) else {
|
||||
let Some(record) = record::active_server_record(storage_dir)? else {
|
||||
if json {
|
||||
fabro_util::printout!(printer, r#"{{"status":"stopped"}}"#);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_server::bind::Bind;
|
||||
use fabro_util::printer::Printer;
|
||||
use tokio::time;
|
||||
|
||||
use super::record;
|
||||
|
||||
pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> bool {
|
||||
let Some(active) = record::active_server_record_details(storage_dir) else {
|
||||
return false;
|
||||
pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> Result<bool> {
|
||||
let Some(active) = record::active_server_record_details(storage_dir)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let record = active.record;
|
||||
|
||||
|
|
@ -36,14 +37,15 @@ pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> bool {
|
|||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
true
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(storage_dir: &Path, timeout: Duration, printer: Printer) {
|
||||
if !stop_server(storage_dir, timeout).await {
|
||||
pub(crate) async fn execute(storage_dir: &Path, timeout: Duration, printer: Printer) -> Result<()> {
|
||||
if !stop_server(storage_dir, timeout).await? {
|
||||
fabro_util::printerr!(printer, "Server is not running");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fabro_util::printerr!(printer, "Server stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,11 +57,14 @@ pub(crate) async fn run_uninstall(
|
|||
}
|
||||
|
||||
let storage_dir = user_config::load_settings().map_or_else(
|
||||
|_| home.storage_dir(),
|
||||
|settings| user_config::storage_dir(&settings).unwrap_or_else(|_| home.storage_dir()),
|
||||
|_| user_config::default_storage_dir(),
|
||||
|settings| {
|
||||
user_config::storage_dir(&settings)
|
||||
.unwrap_or_else(|_| user_config::default_storage_dir())
|
||||
},
|
||||
);
|
||||
|
||||
let inventory = build_inventory(&home_root, &storage_dir);
|
||||
let inventory = build_inventory(&home_root, &storage_dir)?;
|
||||
|
||||
if !args.yes {
|
||||
if json {
|
||||
|
|
@ -82,13 +85,13 @@ pub(crate) async fn run_uninstall(
|
|||
execute_uninstall(&inventory, json, printer).await
|
||||
}
|
||||
|
||||
fn build_inventory(home_root: &Path, storage_dir: &Path) -> Inventory {
|
||||
fn build_inventory(home_root: &Path, storage_dir: &Path) -> Result<Inventory> {
|
||||
let home_size = dir_size(home_root);
|
||||
let server_running = server::record::active_server_record_details(storage_dir).is_some();
|
||||
let server_running = server::record::active_server_record_details(storage_dir)?.is_some();
|
||||
let shell_configs = find_shell_configs_with_sentinel();
|
||||
let (binary_path, binary_is_managed) = resolve_binary(home_root);
|
||||
|
||||
Inventory {
|
||||
Ok(Inventory {
|
||||
home_root: home_root.to_path_buf(),
|
||||
storage_dir: storage_dir.to_path_buf(),
|
||||
home_exists: true,
|
||||
|
|
@ -97,7 +100,7 @@ fn build_inventory(home_root: &Path, storage_dir: &Path) -> Inventory {
|
|||
shell_configs,
|
||||
binary_path,
|
||||
binary_is_managed,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
|
|
@ -261,7 +264,7 @@ async fn execute_uninstall(inventory: &Inventory, json: bool, printer: Printer)
|
|||
|
||||
// Unit 3a: Server stop
|
||||
if inventory.server_running {
|
||||
server::stop::execute(&inventory.storage_dir, Duration::from_secs(5), printer).await;
|
||||
server::stop::execute(&inventory.storage_dir, Duration::from_secs(5), printer).await?;
|
||||
result.server_stopped = true;
|
||||
}
|
||||
|
||||
|
|
@ -739,7 +742,7 @@ mod tests {
|
|||
create_fabro_home(&home_root);
|
||||
|
||||
let storage_dir = home_root.join("storage");
|
||||
let inv = build_inventory(&home_root, &storage_dir);
|
||||
let inv = build_inventory(&home_root, &storage_dir).unwrap();
|
||||
|
||||
assert!(inv.home_exists);
|
||||
assert!(inv.home_size > 0);
|
||||
|
|
@ -754,7 +757,7 @@ mod tests {
|
|||
let home_root = tmp.path().join("fake-fabro-home");
|
||||
create_fabro_home(&home_root);
|
||||
|
||||
let inv = build_inventory(&home_root, &home_root.join("storage"));
|
||||
let inv = build_inventory(&home_root, &home_root.join("storage")).unwrap();
|
||||
// shell_configs depends on the actual user's shell config files,
|
||||
// but we verify the field is populated (even if empty in CI/test)
|
||||
assert!(inv.shell_configs.is_empty() || !inv.shell_configs.is_empty());
|
||||
|
|
|
|||
|
|
@ -2,22 +2,29 @@
|
|||
clippy::disallowed_methods,
|
||||
reason = "CLI logging setup: sync directory scan during startup"
|
||||
)]
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_util::run_log;
|
||||
use tracing_appender::rolling;
|
||||
use tracing_subscriber::fmt::writer::MakeWriter;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
const LOG_RETENTION_DAYS: u32 = 7;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum InternalLogSink {
|
||||
Cli,
|
||||
Server { path: std::path::PathBuf },
|
||||
}
|
||||
|
||||
pub(crate) fn init_tracing(
|
||||
debug: bool,
|
||||
config_log_level: Option<&str>,
|
||||
log_prefix: &str,
|
||||
sink: &InternalLogSink,
|
||||
) -> Result<()> {
|
||||
let default_level = if debug {
|
||||
"debug"
|
||||
|
|
@ -27,37 +34,28 @@ pub(crate) fn init_tracing(
|
|||
let filter =
|
||||
EnvFilter::try_from_env("FABRO_LOG").unwrap_or_else(|_| EnvFilter::new(default_level));
|
||||
|
||||
let log_dir = fabro_util::Home::from_env().logs_dir();
|
||||
match sink {
|
||||
InternalLogSink::Cli => {
|
||||
let log_dir = fabro_util::Home::from_env().logs_dir();
|
||||
|
||||
std::fs::create_dir_all(&log_dir)
|
||||
.with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?;
|
||||
std::fs::create_dir_all(&log_dir).with_context(|| {
|
||||
format!("Failed to create log directory: {}", log_dir.display())
|
||||
})?;
|
||||
|
||||
let file_appender = rolling::RollingFileAppender::builder()
|
||||
.rotation(rolling::Rotation::DAILY)
|
||||
.filename_prefix(log_prefix)
|
||||
.filename_suffix("log")
|
||||
.build(&log_dir)
|
||||
.with_context(|| "Failed to create log file appender")?;
|
||||
let file_appender = rolling::RollingFileAppender::builder()
|
||||
.rotation(rolling::Rotation::DAILY)
|
||||
.filename_prefix("cli")
|
||||
.filename_suffix("log")
|
||||
.build(&log_dir)
|
||||
.with_context(|| "Failed to create log file appender")?;
|
||||
|
||||
cleanup_old_logs(&log_dir, log_prefix, LOG_RETENTION_DAYS);
|
||||
|
||||
let run_log_writer = run_log::init();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(file_appender)
|
||||
.with_target(true)
|
||||
.with_ansi(false),
|
||||
)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(run_log_writer)
|
||||
.with_target(true)
|
||||
.with_ansi(false),
|
||||
)
|
||||
.init();
|
||||
cleanup_old_logs(&log_dir, "cli", LOG_RETENTION_DAYS);
|
||||
init_subscriber(filter, file_appender);
|
||||
}
|
||||
InternalLogSink::Server { path } => {
|
||||
init_subscriber(filter, FixedFileAppender::open(path)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -93,3 +91,50 @@ fn cleanup_old_logs(log_dir: &Path, prefix: &str, max_age_days: u32) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_subscriber<W>(filter: EnvFilter, file_writer: W)
|
||||
where
|
||||
W: for<'writer> MakeWriter<'writer> + Send + Sync + 'static,
|
||||
{
|
||||
let run_log_writer = run_log::init();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(file_writer)
|
||||
.with_target(true)
|
||||
.with_ansi(false),
|
||||
)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(run_log_writer)
|
||||
.with_target(true)
|
||||
.with_ansi(false),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
struct FixedFileAppender {
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl FixedFileAppender {
|
||||
fn open(path: &Path) -> Result<Self> {
|
||||
let file = OpenOptions::new()
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open server log file: {}", path.display()))?;
|
||||
Ok(Self { file })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'writer> MakeWriter<'writer> for FixedFileAppender {
|
||||
type Writer = File;
|
||||
|
||||
fn make_writer(&'writer self) -> Self::Writer {
|
||||
self.file
|
||||
.try_clone()
|
||||
.expect("fixed log file handle should be cloneable")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ use args::{
|
|||
};
|
||||
use clap::{CommandFactory, Parser};
|
||||
use fabro_config::merge::combine_files;
|
||||
use fabro_config::user::load_settings_config;
|
||||
use fabro_telemetry::{git, panic as tel_panic, sanitize, sender};
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::settings::cli::OutputVerbosity;
|
||||
|
|
@ -144,6 +143,10 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
let cli_layer = global_args_cli_layer(&globals);
|
||||
let process_local_json = globals.json;
|
||||
let command_name = command.name().to_string();
|
||||
let pre_tracing_bootstrap = match pre_tracing_bootstrap(command.as_ref()).await {
|
||||
Ok(bootstrap) => bootstrap,
|
||||
Err(err) => return (command_name, Err(err)),
|
||||
};
|
||||
|
||||
let user_settings = match user_config::load_settings() {
|
||||
Ok(settings) => settings,
|
||||
|
|
@ -159,35 +162,15 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
};
|
||||
let printer = printer_from_verbosity(cli_settings.output.verbosity);
|
||||
|
||||
let config_log_level = if let Commands::Server(ServerNamespace {
|
||||
command:
|
||||
ServerCommand::Start(args::ServerStartArgs {
|
||||
serve_args: args, ..
|
||||
})
|
||||
| ServerCommand::Serve(args::ServerServeArgs {
|
||||
serve_args: args, ..
|
||||
}),
|
||||
}) = command.as_ref()
|
||||
{
|
||||
match load_settings_config(args.config.as_deref()) {
|
||||
Ok(layer) => layer
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|server| server.logging.as_ref())
|
||||
.and_then(|logging| logging.level.clone()),
|
||||
Err(err) => return (command_name, Err(err.into())),
|
||||
}
|
||||
} else {
|
||||
cli_settings.logging.level.clone()
|
||||
let config_log_level = match &pre_tracing_bootstrap.sink {
|
||||
logging::InternalLogSink::Cli => cli_settings.logging.level.clone(),
|
||||
logging::InternalLogSink::Server { .. } => pre_tracing_bootstrap.config_log_level.clone(),
|
||||
};
|
||||
|
||||
let log_prefix = if command_name == "server start" || command_name == "server __serve" {
|
||||
"server"
|
||||
} else {
|
||||
"cli"
|
||||
};
|
||||
if let Err(err) = logging::init_tracing(globals.debug, config_log_level.as_deref(), log_prefix)
|
||||
{
|
||||
if let Err(err) = logging::init_tracing(
|
||||
globals.debug,
|
||||
config_log_level.as_deref(),
|
||||
&pre_tracing_bootstrap.sink,
|
||||
) {
|
||||
fabro_util::printerr!(
|
||||
bootstrap_printer,
|
||||
"Warning: failed to initialize logging: {err:#}"
|
||||
|
|
@ -195,6 +178,7 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
}
|
||||
|
||||
debug!(command = %command_name, "CLI command started");
|
||||
let foreground_server_log_bootstrap = pre_tracing_bootstrap.foreground_server_log_bootstrap;
|
||||
|
||||
let upgrade_handle = if matches!(
|
||||
command.as_ref(),
|
||||
|
|
@ -256,7 +240,13 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
commands::model::execute(command, &cli_settings, &cli_layer, printer).await?;
|
||||
}
|
||||
Commands::Server(ns) => {
|
||||
Box::pin(commands::server::dispatch(ns.command, &globals, printer)).await?;
|
||||
Box::pin(commands::server::dispatch(
|
||||
ns.command,
|
||||
&globals,
|
||||
foreground_server_log_bootstrap,
|
||||
printer,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
Commands::Doctor(args) => {
|
||||
let verbose =
|
||||
|
|
@ -418,15 +408,131 @@ async fn main_inner() -> (String, Result<()>) {
|
|||
(command_name, result)
|
||||
}
|
||||
|
||||
struct PreTracingBootstrap {
|
||||
sink: logging::InternalLogSink,
|
||||
config_log_level: Option<String>,
|
||||
foreground_server_log_bootstrap: Option<commands::server::start::ForegroundServerLogBootstrap>,
|
||||
}
|
||||
|
||||
impl PreTracingBootstrap {
|
||||
fn cli() -> Self {
|
||||
Self {
|
||||
sink: logging::InternalLogSink::Cli,
|
||||
config_log_level: None,
|
||||
foreground_server_log_bootstrap: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn pre_tracing_bootstrap(command: &Commands) -> Result<PreTracingBootstrap> {
|
||||
match command {
|
||||
Commands::Server(ServerNamespace {
|
||||
command: ServerCommand::Start(args),
|
||||
}) if args.foreground => {
|
||||
prepare_server_bootstrap(
|
||||
args.serve_args.config.as_deref(),
|
||||
args.storage_dir.as_deref(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Server(ServerNamespace {
|
||||
command: ServerCommand::Restart(args),
|
||||
}) if args.foreground => {
|
||||
prepare_server_bootstrap(
|
||||
args.serve_args.config.as_deref(),
|
||||
args.storage_dir.as_deref(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Server(ServerNamespace {
|
||||
command: ServerCommand::Serve(args),
|
||||
}) => {
|
||||
prepare_server_bootstrap(
|
||||
args.serve_args.config.as_deref(),
|
||||
args.storage_dir.as_deref(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => Ok(PreTracingBootstrap::cli()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_server_bootstrap(
|
||||
config_path: Option<&std::path::Path>,
|
||||
storage_dir: Option<&std::path::Path>,
|
||||
foreground: bool,
|
||||
) -> Result<PreTracingBootstrap> {
|
||||
let settings =
|
||||
user_config::load_settings_with_config_and_storage_dir(config_path, storage_dir)?;
|
||||
let storage_dir = user_config::storage_dir(&settings)?;
|
||||
let storage = fabro_config::Storage::new(&storage_dir);
|
||||
let foreground_server_log_bootstrap = if foreground {
|
||||
Some(commands::server::start::prepare_foreground_server_log(&storage_dir).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(PreTracingBootstrap {
|
||||
sink: logging::InternalLogSink::Server {
|
||||
path: storage.server_state().log_path(),
|
||||
},
|
||||
config_log_level: server_config_log_level(&settings),
|
||||
foreground_server_log_bootstrap,
|
||||
})
|
||||
}
|
||||
|
||||
fn server_config_log_level(settings: &SettingsLayer) -> Option<String> {
|
||||
settings
|
||||
.server
|
||||
.as_ref()
|
||||
.and_then(|server| server.logging.as_ref())
|
||||
.and_then(|logging| logging.level.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use args::{
|
||||
Commands, InstallGitHubStrategyArg, ModelsCommand, ProviderCommand, ProviderNamespace,
|
||||
StoreCommand, StoreNamespace,
|
||||
};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new().expect("runtime should build")
|
||||
}
|
||||
|
||||
fn write_test_settings(path: &std::path::Path) {
|
||||
std::fs::write(
|
||||
path,
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.logging]
|
||||
level = "warn"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tracing_bootstrap_uses_cli_sink_for_normal_cli_command() {
|
||||
let cli = Cli::try_parse_from(["fabro", "uninstall"]).expect("should parse");
|
||||
let command = cli.command.as_deref().unwrap();
|
||||
|
||||
let bootstrap = runtime()
|
||||
.block_on(pre_tracing_bootstrap(command))
|
||||
.expect("bootstrap should resolve");
|
||||
|
||||
assert_eq!(bootstrap.sink, logging::InternalLogSink::Cli);
|
||||
assert!(bootstrap.config_log_level.is_none());
|
||||
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_login_openai() {
|
||||
let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "openai"])
|
||||
|
|
@ -532,6 +638,157 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tracing_bootstrap_uses_server_sink_for_server_start_foreground() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
write_test_settings(&config_path);
|
||||
|
||||
let cli = Cli::try_parse_from([
|
||||
"fabro",
|
||||
"server",
|
||||
"start",
|
||||
"--foreground",
|
||||
"--storage-dir",
|
||||
storage_dir.path().to_str().unwrap(),
|
||||
"--config",
|
||||
config_path.to_str().unwrap(),
|
||||
])
|
||||
.expect("should parse");
|
||||
let command = cli.command.as_deref().unwrap();
|
||||
|
||||
let bootstrap = runtime()
|
||||
.block_on(pre_tracing_bootstrap(command))
|
||||
.expect("bootstrap should resolve");
|
||||
|
||||
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
|
||||
path: storage_dir.path().join("logs").join("server.log"),
|
||||
});
|
||||
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
|
||||
assert!(bootstrap.foreground_server_log_bootstrap.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tracing_bootstrap_uses_server_sink_for_server_restart_foreground() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
write_test_settings(&config_path);
|
||||
|
||||
let cli = Cli::try_parse_from([
|
||||
"fabro",
|
||||
"server",
|
||||
"restart",
|
||||
"--foreground",
|
||||
"--storage-dir",
|
||||
storage_dir.path().to_str().unwrap(),
|
||||
"--config",
|
||||
config_path.to_str().unwrap(),
|
||||
])
|
||||
.expect("should parse");
|
||||
let command = cli.command.as_deref().unwrap();
|
||||
|
||||
let bootstrap = runtime()
|
||||
.block_on(pre_tracing_bootstrap(command))
|
||||
.expect("bootstrap should resolve");
|
||||
|
||||
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
|
||||
path: storage_dir.path().join("logs").join("server.log"),
|
||||
});
|
||||
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
|
||||
assert!(bootstrap.foreground_server_log_bootstrap.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tracing_bootstrap_uses_server_sink_for_server_serve() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
write_test_settings(&config_path);
|
||||
let record_path = storage_dir.path().join("server.json");
|
||||
|
||||
let cli = Cli::try_parse_from([
|
||||
"fabro",
|
||||
"server",
|
||||
"__serve",
|
||||
"--storage-dir",
|
||||
storage_dir.path().to_str().unwrap(),
|
||||
"--record-path",
|
||||
record_path.to_str().unwrap(),
|
||||
"--config",
|
||||
config_path.to_str().unwrap(),
|
||||
])
|
||||
.expect("should parse");
|
||||
let command = cli.command.as_deref().unwrap();
|
||||
|
||||
let bootstrap = runtime()
|
||||
.block_on(pre_tracing_bootstrap(command))
|
||||
.expect("bootstrap should resolve");
|
||||
|
||||
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
|
||||
path: storage_dir.path().join("logs").join("server.log"),
|
||||
});
|
||||
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
|
||||
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tracing_bootstrap_uses_cli_sink_for_server_start_daemon_wrapper() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
write_test_settings(&config_path);
|
||||
|
||||
let cli = Cli::try_parse_from([
|
||||
"fabro",
|
||||
"server",
|
||||
"start",
|
||||
"--storage-dir",
|
||||
storage_dir.path().to_str().unwrap(),
|
||||
"--config",
|
||||
config_path.to_str().unwrap(),
|
||||
])
|
||||
.expect("should parse");
|
||||
let command = cli.command.as_deref().unwrap();
|
||||
|
||||
let bootstrap = runtime()
|
||||
.block_on(pre_tracing_bootstrap(command))
|
||||
.expect("bootstrap should resolve");
|
||||
|
||||
assert_eq!(bootstrap.sink, logging::InternalLogSink::Cli);
|
||||
assert!(bootstrap.config_log_level.is_none());
|
||||
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tracing_bootstrap_uses_cli_sink_for_server_restart_daemon_wrapper() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
write_test_settings(&config_path);
|
||||
|
||||
let cli = Cli::try_parse_from([
|
||||
"fabro",
|
||||
"server",
|
||||
"restart",
|
||||
"--storage-dir",
|
||||
storage_dir.path().to_str().unwrap(),
|
||||
"--config",
|
||||
config_path.to_str().unwrap(),
|
||||
])
|
||||
.expect("should parse");
|
||||
let command = cli.command.as_deref().unwrap();
|
||||
|
||||
let bootstrap = runtime()
|
||||
.block_on(pre_tracing_bootstrap(command))
|
||||
.expect("bootstrap should resolve");
|
||||
|
||||
assert_eq!(bootstrap.sink, logging::InternalLogSink::Cli);
|
||||
assert!(bootstrap.config_log_level.is_none());
|
||||
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_login_missing_provider_flag() {
|
||||
let result = Cli::try_parse_from(["fabro", "provider", "login"]);
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ fn remote_dev_token_auth_for_target<'a>(
|
|||
}
|
||||
|
||||
fn remote_url_matches_active_local_tcp_server(api_url: &str, storage_dir: &Path) -> bool {
|
||||
let Some(record) = record::active_server_record(storage_dir) else {
|
||||
let Ok(Some(record)) = record::active_server_record(storage_dir) else {
|
||||
return false;
|
||||
};
|
||||
let Bind::Tcp(bind_addr) = record.bind else {
|
||||
|
|
@ -595,6 +595,26 @@ impl ServerStoreClient {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn archive_run(&self, run_id: &RunId) -> Result<()> {
|
||||
self.client
|
||||
.archive_run()
|
||||
.id(run_id.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn unarchive_run(&self, run_id: &RunId) -> Result<()> {
|
||||
self.client
|
||||
.unarchive_run()
|
||||
.id(run_id.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_store_runs(&self) -> Result<Vec<RunSummary>> {
|
||||
let mut all_runs = Vec::new();
|
||||
let mut offset = 0_u64;
|
||||
|
|
@ -606,6 +626,7 @@ impl ServerStoreClient {
|
|||
.list_runs()
|
||||
.page_limit(limit)
|
||||
.page_offset(offset)
|
||||
.include_archived(true)
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
|
|
@ -1206,7 +1227,9 @@ mod tests {
|
|||
record::write_server_record(&record_path, &record::ServerRecord {
|
||||
pid: std::process::id(),
|
||||
bind: Bind::Unix(temp_home.path().join("fabro.sock")),
|
||||
log_path: storage.path().join("server.log"),
|
||||
log_path: fabro_config::Storage::new(storage.path())
|
||||
.server_state()
|
||||
.log_path(),
|
||||
dev_token_path: Some(token_path),
|
||||
started_at: chrono::Utc::now(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,31 +27,18 @@ pub(crate) fn load_settings() -> anyhow::Result<SettingsLayer> {
|
|||
load_settings_with_config_and_storage_dir(None, None)
|
||||
}
|
||||
|
||||
pub(crate) fn settings_layer_with_config_and_storage_dir(
|
||||
config_path: Option<&Path>,
|
||||
storage_dir: Option<&Path>,
|
||||
) -> anyhow::Result<SettingsLayer> {
|
||||
let layer = load_settings_config(config_path)?;
|
||||
Ok(apply_storage_dir_override(layer, storage_dir))
|
||||
}
|
||||
|
||||
pub(crate) fn settings_layer_with_storage_dir(
|
||||
storage_dir: Option<&Path>,
|
||||
) -> anyhow::Result<SettingsLayer> {
|
||||
settings_layer_with_config_and_storage_dir(None, storage_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn load_settings_with_storage_dir(
|
||||
storage_dir: Option<&Path>,
|
||||
) -> anyhow::Result<SettingsLayer> {
|
||||
settings_layer_with_storage_dir(storage_dir)
|
||||
load_settings_with_config_and_storage_dir(None, storage_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn load_settings_with_config_and_storage_dir(
|
||||
config_path: Option<&Path>,
|
||||
storage_dir: Option<&Path>,
|
||||
) -> anyhow::Result<SettingsLayer> {
|
||||
settings_layer_with_config_and_storage_dir(config_path, storage_dir)
|
||||
let layer = load_settings_config(config_path)?;
|
||||
Ok(apply_storage_dir_override(layer, storage_dir))
|
||||
}
|
||||
|
||||
fn render_resolve_errors(errors: Vec<fabro_config::ResolveError>) -> anyhow::Error {
|
||||
|
|
|
|||
200
lib/crates/fabro-cli/tests/it/cmd/archive.rs
Normal file
200
lib/crates/fabro-cli/tests/it/cmd/archive.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run};
|
||||
use crate::support::unique_run_id;
|
||||
|
||||
fn ulid_filter() -> (String, String) {
|
||||
(
|
||||
r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(),
|
||||
"[ULID]".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["archive", "--help"]);
|
||||
fabro_snapshot!(context.filters(), cmd, @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings
|
||||
|
||||
Usage: fabro archive [OPTIONS] <RUNS>...
|
||||
|
||||
Arguments:
|
||||
<RUNS>... Run IDs or workflow names to archive
|
||||
|
||||
Options:
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||||
-h, --help Print help
|
||||
----- stderr -----
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_requires_at_least_one_id() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["archive"]);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
error: the following required arguments were not provided:
|
||||
<RUNS>...
|
||||
|
||||
Usage: fabro archive --no-upgrade-check <RUNS>...
|
||||
|
||||
For more information, try '--help'.
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_succeeded_run_hides_it_from_default_ps() {
|
||||
let context = test_context!();
|
||||
let run = setup_completed_fast_dry_run(&context);
|
||||
let mut filters = context.filters();
|
||||
filters.push(ulid_filter());
|
||||
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["archive", &run.run_id]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
[ULID]
|
||||
");
|
||||
|
||||
// Default `ps` filters out archived runs.
|
||||
let mut ps = context.ps();
|
||||
ps.args(["--json", "--label", &context.test_case_label()]);
|
||||
fabro_snapshot!(context.filters(), ps, @r#"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
[]
|
||||
----- stderr -----
|
||||
"#);
|
||||
|
||||
// `ps -a` surfaces it with status `archived`.
|
||||
let output = context
|
||||
.ps()
|
||||
.args(["-a", "--json", "--label", &context.test_case_label()])
|
||||
.output()
|
||||
.expect("ps -a should execute");
|
||||
assert!(output.status.success());
|
||||
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
||||
assert_eq!(runs.len(), 1, "ps -a should show the archived run");
|
||||
assert_eq!(runs[0]["status"], "archived");
|
||||
assert_eq!(runs[0]["run_id"], run.run_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_running_run_rejects_with_must_be_terminal_message() {
|
||||
// A `create`d run is in `submitted` — not yet terminal.
|
||||
let context = test_context!();
|
||||
let run = setup_created_fast_dry_run(&context);
|
||||
let output = context
|
||||
.command()
|
||||
.args(["archive", &run.run_id])
|
||||
.output()
|
||||
.expect("archive should execute");
|
||||
assert!(!output.status.success(), "archive on submitted must fail");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("must be terminal"),
|
||||
"expected 'must be terminal' in stderr, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_already_archived_is_idempotent() {
|
||||
let context = test_context!();
|
||||
let run = setup_completed_fast_dry_run(&context);
|
||||
|
||||
let first = context
|
||||
.command()
|
||||
.args(["archive", &run.run_id])
|
||||
.output()
|
||||
.expect("archive should execute");
|
||||
assert!(first.status.success(), "first archive should succeed");
|
||||
|
||||
let second = context
|
||||
.command()
|
||||
.args(["archive", &run.run_id])
|
||||
.output()
|
||||
.expect("archive should execute");
|
||||
assert!(
|
||||
second.status.success(),
|
||||
"second archive on already-archived should succeed\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&second.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_unknown_id_renders_clean_error() {
|
||||
let context = test_context!();
|
||||
let fake_id = unique_run_id();
|
||||
let output = context
|
||||
.command()
|
||||
.args(["archive", &fake_id])
|
||||
.output()
|
||||
.expect("archive should execute");
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains(&fake_id) || stderr.contains("No run found"),
|
||||
"expected unknown-id error in stderr, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_json_output_shape() {
|
||||
let context = test_context!();
|
||||
let run = setup_completed_fast_dry_run(&context);
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["--json", "archive", &run.run_id])
|
||||
.output()
|
||||
.expect("archive --json should execute");
|
||||
assert!(output.status.success());
|
||||
let value: Value = serde_json::from_slice(&output.stdout).expect("archive JSON should parse");
|
||||
assert_eq!(
|
||||
value["archived"],
|
||||
Value::Array(vec![Value::String(run.run_id.clone())])
|
||||
);
|
||||
assert_eq!(value["errors"], Value::Array(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_mixed_batch_aggregates_errors() {
|
||||
let context = test_context!();
|
||||
let good = setup_completed_fast_dry_run(&context);
|
||||
let bad = unique_run_id();
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["--json", "archive", &good.run_id, &bad])
|
||||
.output()
|
||||
.expect("archive should execute");
|
||||
assert!(!output.status.success(), "mixed batch should exit non-zero");
|
||||
let value: Value = serde_json::from_slice(&output.stdout).expect("archive JSON should parse");
|
||||
assert_eq!(
|
||||
value["archived"],
|
||||
Value::Array(vec![Value::String(good.run_id.clone())])
|
||||
);
|
||||
let errors = value["errors"].as_array().expect("errors should be array");
|
||||
assert_eq!(errors.len(), 1);
|
||||
assert_eq!(errors[0]["identifier"], bad);
|
||||
}
|
||||
|
|
@ -646,7 +646,7 @@ fn settings_missing_run_config_errors() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn settings_legacy_cli_config_warns_and_ignores_it() {
|
||||
fn settings_legacy_cli_config_is_silently_ignored() {
|
||||
let context = test_context!();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
|
||||
|
|
@ -668,9 +668,91 @@ name = "legacy-model"
|
|||
.arg("--local")
|
||||
.current_dir(project.path())
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(predicate::str::contains("ignoring legacy config file"))
|
||||
.stderr(predicate::str::contains("Rename it to"));
|
||||
.success();
|
||||
|
||||
assert!(
|
||||
assert.get_output().stderr.is_empty(),
|
||||
"settings should not warn about legacy config files: {}",
|
||||
String::from_utf8_lossy(&assert.get_output().stderr)
|
||||
);
|
||||
|
||||
let cfg = parse_settings(&assert.get_output().stdout);
|
||||
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal"));
|
||||
assert!(
|
||||
cfg["run"]["model"].get("name").is_none(),
|
||||
"resolved dense settings should omit an unset run.model.name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_legacy_user_config_is_silently_ignored() {
|
||||
let context = test_context!();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
|
||||
context.write_home(
|
||||
".fabro/user.toml",
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[cli.output]
|
||||
verbosity = "verbose"
|
||||
|
||||
[run.model]
|
||||
name = "legacy-model"
|
||||
"#,
|
||||
);
|
||||
|
||||
let assert = context
|
||||
.settings()
|
||||
.arg("--local")
|
||||
.current_dir(project.path())
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
assert!(
|
||||
assert.get_output().stderr.is_empty(),
|
||||
"settings should not warn about legacy config files: {}",
|
||||
String::from_utf8_lossy(&assert.get_output().stderr)
|
||||
);
|
||||
|
||||
let cfg = parse_settings(&assert.get_output().stdout);
|
||||
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal"));
|
||||
assert!(
|
||||
cfg["run"]["model"].get("name").is_none(),
|
||||
"resolved dense settings should omit an unset run.model.name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_legacy_server_config_is_silently_ignored() {
|
||||
let context = test_context!();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
|
||||
context.write_home(
|
||||
".fabro/server.toml",
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[cli.output]
|
||||
verbosity = "verbose"
|
||||
|
||||
[run.model]
|
||||
name = "legacy-model"
|
||||
"#,
|
||||
);
|
||||
|
||||
let assert = context
|
||||
.settings()
|
||||
.arg("--local")
|
||||
.current_dir(project.path())
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
assert!(
|
||||
assert.get_output().stderr.is_empty(),
|
||||
"settings should not warn about legacy config files: {}",
|
||||
String::from_utf8_lossy(&assert.get_output().stderr)
|
||||
);
|
||||
|
||||
let cfg = parse_settings(&assert.get_output().stdout);
|
||||
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal"));
|
||||
|
|
@ -702,8 +784,13 @@ shared = "legacy"
|
|||
.arg("--local")
|
||||
.current_dir(project.path())
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(predicate::str::contains("ignoring legacy config file"));
|
||||
.success();
|
||||
|
||||
assert!(
|
||||
assert.get_output().stderr.is_empty(),
|
||||
"settings should not warn about legacy config files: {}",
|
||||
String::from_utf8_lossy(&assert.get_output().stderr)
|
||||
);
|
||||
|
||||
let cfg = parse_settings(&assert.get_output().stdout);
|
||||
assert_eq!(run_model_name(&cfg), Some("project-model"));
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ fn help() {
|
|||
store Export store-backed run state for debugging
|
||||
rm Remove one or more workflow runs
|
||||
inspect Show detailed information about a workflow run
|
||||
archive Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings
|
||||
unarchive Restore archived runs to their prior terminal status
|
||||
model List and test LLM models
|
||||
server Server operations
|
||||
doctor Check environment and integration health
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
mod archive;
|
||||
mod artifact_cp;
|
||||
mod artifact_list;
|
||||
mod attach;
|
||||
|
|
@ -62,6 +63,7 @@ mod system_prune;
|
|||
#[cfg(debug_assertions)]
|
||||
mod test_panic;
|
||||
mod top_level;
|
||||
mod unarchive;
|
||||
mod uninstall;
|
||||
mod upgrade;
|
||||
mod validate;
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ fn ps_default_excludes_non_running_runs() {
|
|||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
No running processes found. Use -a to show all runs.
|
||||
No running processes found. Use -a to show all runs (including archived).
|
||||
");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,10 @@ use std::process::Stdio;
|
|||
use std::sync::{Arc, Barrier};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fabro_test::{apply_test_isolation, fabro_snapshot, test_context};
|
||||
|
||||
fn isolated_storage_dir() -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir_in("/tmp").unwrap();
|
||||
std::fs::create_dir_all(root.path().join("storage")).unwrap();
|
||||
root
|
||||
}
|
||||
use fabro_test::{
|
||||
apply_test_isolation, fabro_snapshot, isolated_storage_dir, server_log_files, stop_pid,
|
||||
test_context, wait_for_log_line, wait_for_path,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
@ -161,6 +158,372 @@ fn start_without_default_settings_enters_install_mode_in_foreground() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This sync integration test spawns the real foreground server process to verify log ownership."
|
||||
)]
|
||||
fn foreground_start_writes_tracing_to_storage_server_log() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
let socket_path = storage_root.path().join("foreground.sock");
|
||||
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n").unwrap();
|
||||
let storage_log_path = storage_dir.join("logs").join("server.log");
|
||||
std::fs::create_dir_all(storage_log_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&storage_log_path, "stale pre-start log entry\n").unwrap();
|
||||
|
||||
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut cmd, home_dir.path());
|
||||
cmd.args(["server", "start", "--foreground"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.arg("--bind")
|
||||
.arg(&socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().expect("server start should spawn");
|
||||
let record_path = storage_dir.join("server.json");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
|
||||
while Instant::now() < deadline {
|
||||
if record_path.exists() {
|
||||
break;
|
||||
}
|
||||
if let Some(status) = child.try_wait().expect("server start should poll") {
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.expect("server start output should be readable");
|
||||
panic!(
|
||||
"foreground server exited before writing server.json with status {status}:\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
assert!(
|
||||
record_path.exists(),
|
||||
"expected foreground start to create server.json"
|
||||
);
|
||||
|
||||
wait_for_log_line(&storage_log_path, "API server started");
|
||||
|
||||
let stop_output = {
|
||||
let mut stop = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut stop, home_dir.path());
|
||||
stop.args(["server", "stop"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.output()
|
||||
.expect("server stop should run")
|
||||
};
|
||||
assert!(
|
||||
stop_output.status.success(),
|
||||
"server stop should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&stop_output.stdout),
|
||||
String::from_utf8_lossy(&stop_output.stderr)
|
||||
);
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.expect("server start output should be readable");
|
||||
wait_for_log_line(
|
||||
&storage_log_path,
|
||||
"Shutdown signal received, stopping server",
|
||||
);
|
||||
let storage_log = std::fs::read_to_string(&storage_log_path).unwrap_or_default();
|
||||
assert!(
|
||||
storage_log.contains("API server started"),
|
||||
"expected {} to contain server tracing, got:\n{}\nforeground stderr:\n{}",
|
||||
storage_log_path.display(),
|
||||
storage_log,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
storage_log.contains("Shutdown signal received, stopping server"),
|
||||
"expected {} to contain shutdown tracing, got:\n{}",
|
||||
storage_log_path.display(),
|
||||
storage_log
|
||||
);
|
||||
assert!(
|
||||
!storage_log.contains("stale pre-start log entry"),
|
||||
"expected startup to truncate stale log contents, got:\n{storage_log}",
|
||||
);
|
||||
assert!(
|
||||
storage_log.find("API server started")
|
||||
< storage_log.find("Shutdown signal received, stopping server"),
|
||||
"expected shutdown trace to append after startup trace, got:\n{storage_log}",
|
||||
);
|
||||
|
||||
let home_server_logs = server_log_files(&home_dir.path().join(".fabro").join("logs"));
|
||||
assert!(
|
||||
home_server_logs.is_empty(),
|
||||
"expected foreground server start to avoid home server logs, found: {home_server_logs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_start_writes_tracing_to_storage_server_log() {
|
||||
let context = test_context!();
|
||||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
let socket_path = storage_root.path().join("daemon.sock");
|
||||
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n").unwrap();
|
||||
let storage_log_path = storage_dir.join("logs").join("server.log");
|
||||
std::fs::create_dir_all(storage_log_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&storage_log_path, "stale pre-start log entry\n").unwrap();
|
||||
|
||||
context
|
||||
.command()
|
||||
.args(["server", "start"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.arg("--bind")
|
||||
.arg(&socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
wait_for_log_line(&storage_log_path, "API server started");
|
||||
|
||||
context
|
||||
.command()
|
||||
.args(["server", "stop"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
wait_for_log_line(
|
||||
&storage_log_path,
|
||||
"Shutdown signal received, stopping server",
|
||||
);
|
||||
|
||||
let storage_log = std::fs::read_to_string(&storage_log_path).unwrap_or_default();
|
||||
assert!(
|
||||
storage_log.contains("API server started"),
|
||||
"expected {} to contain startup tracing, got:\n{}",
|
||||
storage_log_path.display(),
|
||||
storage_log
|
||||
);
|
||||
assert!(
|
||||
storage_log.contains("Shutdown signal received, stopping server"),
|
||||
"expected {} to contain shutdown tracing, got:\n{}",
|
||||
storage_log_path.display(),
|
||||
storage_log
|
||||
);
|
||||
assert!(
|
||||
!storage_log.contains("stale pre-start log entry"),
|
||||
"expected startup to truncate stale log contents, got:\n{storage_log}",
|
||||
);
|
||||
assert!(
|
||||
storage_log.find("API server started")
|
||||
< storage_log.find("Shutdown signal received, stopping server"),
|
||||
"expected shutdown trace to append after startup trace, got:\n{storage_log}",
|
||||
);
|
||||
|
||||
let home_server_logs = server_log_files(&context.home_dir.join(".fabro").join("logs"));
|
||||
assert!(
|
||||
home_server_logs.is_empty(),
|
||||
"expected daemonized server start to avoid home server logs, found: {home_server_logs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This integration test moves a live daemon record on disk to simulate an unsupported legacy daemon upgrade."
|
||||
)]
|
||||
fn start_errors_when_only_a_legacy_running_server_record_exists() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let fabro_home = home_dir.path().join(".fabro");
|
||||
let storage_dir = fabro_home.join("storage");
|
||||
let socket_path = home_dir.path().join("legacy.sock");
|
||||
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n").unwrap();
|
||||
|
||||
let start_output = {
|
||||
let mut start = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut start, home_dir.path());
|
||||
start
|
||||
.args(["server", "start", "--bind"])
|
||||
.arg(&socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.expect("server start should run")
|
||||
};
|
||||
assert!(
|
||||
start_output.status.success(),
|
||||
"server start should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&start_output.stdout),
|
||||
String::from_utf8_lossy(&start_output.stderr)
|
||||
);
|
||||
|
||||
let current_record = storage_dir.join("server.json");
|
||||
wait_for_path(¤t_record);
|
||||
let legacy_record = fabro_home.join("server.json");
|
||||
std::fs::rename(¤t_record, &legacy_record).unwrap();
|
||||
|
||||
let retry_output = {
|
||||
let mut retry = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut retry, home_dir.path());
|
||||
retry
|
||||
.args(["server", "start", "--bind"])
|
||||
.arg(home_dir.path().join("new.sock"))
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.expect("server start retry should run")
|
||||
};
|
||||
|
||||
let pid_u64 = serde_json::from_str::<serde_json::Value>(
|
||||
&std::fs::read_to_string(&legacy_record).unwrap(),
|
||||
)
|
||||
.unwrap()["pid"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let pid = u32::try_from(pid_u64).expect("pid fits in u32");
|
||||
stop_pid(pid);
|
||||
let _ = std::fs::remove_file(&legacy_record);
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
|
||||
assert!(
|
||||
!retry_output.status.success(),
|
||||
"server start should fail when only the legacy record exists"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&retry_output.stderr);
|
||||
assert!(
|
||||
stderr.contains(&legacy_record.display().to_string()),
|
||||
"expected stderr to mention the legacy record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(¤t_record.display().to_string()),
|
||||
"expected stderr to mention the current record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("legacy Fabro CLI"),
|
||||
"expected stderr to instruct manual cleanup, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This sync integration test starts two real foreground server processes to verify lock ownership protects log truncation."
|
||||
)]
|
||||
fn concurrent_foreground_start_does_not_retruncate_storage_server_log() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
let first_socket_path = storage_root.path().join("foreground-first.sock");
|
||||
let second_socket_path = storage_root.path().join("foreground-second.sock");
|
||||
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n").unwrap();
|
||||
|
||||
let storage_log_path = storage_dir.join("logs").join("server.log");
|
||||
std::fs::create_dir_all(storage_log_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&storage_log_path, "stale pre-start log entry\n").unwrap();
|
||||
|
||||
let mut first = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut first, home_dir.path());
|
||||
first
|
||||
.args(["server", "start", "--foreground"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.arg("--bind")
|
||||
.arg(&first_socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let first_child = first.spawn().expect("first foreground start should spawn");
|
||||
wait_for_path(&storage_dir.join("server.json"));
|
||||
wait_for_log_line(&storage_log_path, "API server started");
|
||||
|
||||
let marker = "marker-after-first-start\n";
|
||||
{
|
||||
use std::io::Write as _;
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&storage_log_path)
|
||||
.unwrap();
|
||||
file.write_all(marker.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
let second_output = {
|
||||
let mut second = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut second, home_dir.path());
|
||||
second
|
||||
.args(["server", "start", "--foreground"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.arg("--bind")
|
||||
.arg(&second_socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.expect("second foreground start should run")
|
||||
};
|
||||
|
||||
assert!(
|
||||
!second_output.status.success(),
|
||||
"second foreground start should fail:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&second_output.stdout),
|
||||
String::from_utf8_lossy(&second_output.stderr)
|
||||
);
|
||||
let second_stderr = String::from_utf8_lossy(&second_output.stderr);
|
||||
assert!(
|
||||
second_stderr.contains("timed out waiting for server lock"),
|
||||
"expected lock timeout failure, got:\n{second_stderr}"
|
||||
);
|
||||
|
||||
let storage_log = std::fs::read_to_string(&storage_log_path).unwrap_or_default();
|
||||
assert!(
|
||||
storage_log.contains(marker.trim_end()),
|
||||
"expected second start to avoid retruncating the log, got:\n{storage_log}",
|
||||
);
|
||||
assert!(
|
||||
!storage_log.contains("stale pre-start log entry"),
|
||||
"expected the first start to truncate stale log contents, got:\n{storage_log}",
|
||||
);
|
||||
|
||||
let stop_output = {
|
||||
let mut stop = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut stop, home_dir.path());
|
||||
stop.args(["server", "stop", "--timeout", "0"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.output()
|
||||
.expect("server stop should run")
|
||||
};
|
||||
assert!(
|
||||
stop_output.status.success(),
|
||||
"server stop should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&stop_output.stdout),
|
||||
String::from_utf8_lossy(&stop_output.stderr)
|
||||
);
|
||||
|
||||
let _ = first_child
|
||||
.wait_with_output()
|
||||
.expect("first child should exit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
|
||||
fn isolated_storage_dir() -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir_in("/tmp").unwrap();
|
||||
std::fs::create_dir_all(root.path().join("storage")).unwrap();
|
||||
root
|
||||
}
|
||||
use fabro_test::{fabro_snapshot, isolated_storage_dir, stop_pid, test_context, wait_for_path};
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
@ -47,3 +41,79 @@ fn status_when_not_running() {
|
|||
Server is not running
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This integration test moves a live daemon record on disk to simulate an unsupported legacy daemon upgrade."
|
||||
)]
|
||||
fn status_errors_when_only_a_legacy_running_server_record_exists() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let fabro_home = home_dir.path().join(".fabro");
|
||||
let storage_dir = fabro_home.join("storage");
|
||||
let socket_path = home_dir.path().join("legacy.sock");
|
||||
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n").unwrap();
|
||||
|
||||
let start_output = {
|
||||
let mut start = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
fabro_test::apply_test_isolation(&mut start, home_dir.path());
|
||||
start
|
||||
.args(["server", "start", "--bind"])
|
||||
.arg(&socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.expect("server start should run")
|
||||
};
|
||||
assert!(
|
||||
start_output.status.success(),
|
||||
"server start should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&start_output.stdout),
|
||||
String::from_utf8_lossy(&start_output.stderr)
|
||||
);
|
||||
|
||||
let current_record = storage_dir.join("server.json");
|
||||
wait_for_path(¤t_record);
|
||||
let legacy_record = fabro_home.join("server.json");
|
||||
std::fs::rename(¤t_record, &legacy_record).unwrap();
|
||||
|
||||
let status_output = {
|
||||
let mut status = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
fabro_test::apply_test_isolation(&mut status, home_dir.path());
|
||||
status
|
||||
.args(["server", "status"])
|
||||
.output()
|
||||
.expect("server status should run")
|
||||
};
|
||||
|
||||
let pid_u64 = serde_json::from_str::<serde_json::Value>(
|
||||
&std::fs::read_to_string(&legacy_record).unwrap(),
|
||||
)
|
||||
.unwrap()["pid"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let pid = u32::try_from(pid_u64).expect("pid fits in u32");
|
||||
stop_pid(pid);
|
||||
let _ = std::fs::remove_file(&legacy_record);
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
|
||||
assert!(
|
||||
!status_output.status.success(),
|
||||
"server status should fail when only the legacy record exists"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&status_output.stderr);
|
||||
assert!(
|
||||
stderr.contains(&legacy_record.display().to_string()),
|
||||
"expected stderr to mention the legacy record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(¤t_record.display().to_string()),
|
||||
"expected stderr to mention the current record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("legacy Fabro CLI"),
|
||||
"expected stderr to instruct manual cleanup, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
|
||||
fn isolated_storage_dir() -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir_in("/tmp").unwrap();
|
||||
std::fs::create_dir_all(root.path().join("storage")).unwrap();
|
||||
root
|
||||
}
|
||||
use fabro_test::{fabro_snapshot, isolated_storage_dir, test_context};
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
|
|||
206
lib/crates/fabro-cli/tests/it/cmd/unarchive.rs
Normal file
206
lib/crates/fabro-cli/tests/it/cmd/unarchive.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run};
|
||||
use crate::support::unique_run_id;
|
||||
|
||||
fn ulid_filter() -> (String, String) {
|
||||
(
|
||||
r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(),
|
||||
"[ULID]".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["unarchive", "--help"]);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Restore archived runs to their prior terminal status
|
||||
|
||||
Usage: fabro unarchive [OPTIONS] <RUNS>...
|
||||
|
||||
Arguments:
|
||||
<RUNS>... Run IDs or workflow names to unarchive
|
||||
|
||||
Options:
|
||||
--json Output as JSON [env: FABRO_JSON=]
|
||||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||||
-h, --help Print help
|
||||
----- stderr -----
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unarchive_requires_at_least_one_id() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["unarchive"]);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
error: the following required arguments were not provided:
|
||||
<RUNS>...
|
||||
|
||||
Usage: fabro unarchive --no-upgrade-check <RUNS>...
|
||||
|
||||
For more information, try '--help'.
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unarchive_archived_run_restores_prior_terminal_status() {
|
||||
let context = test_context!();
|
||||
let run = setup_completed_fast_dry_run(&context);
|
||||
|
||||
// Archive first.
|
||||
let archive = context
|
||||
.command()
|
||||
.args(["archive", &run.run_id])
|
||||
.output()
|
||||
.expect("archive should execute");
|
||||
assert!(archive.status.success());
|
||||
|
||||
// Unarchive.
|
||||
let mut filters = context.filters();
|
||||
filters.push(ulid_filter());
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["unarchive", &run.run_id]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
[ULID]
|
||||
");
|
||||
|
||||
// `ps -a` now shows it as succeeded.
|
||||
let output = context
|
||||
.ps()
|
||||
.args(["-a", "--json", "--label", &context.test_case_label()])
|
||||
.output()
|
||||
.expect("ps -a should execute");
|
||||
assert!(output.status.success());
|
||||
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
||||
assert_eq!(runs.len(), 1, "ps -a should show the unarchived run");
|
||||
assert_eq!(runs[0]["status"], "succeeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unarchive_on_non_archived_terminal_is_idempotent() {
|
||||
// Unarchiving a succeeded (not-archived) run returns success with no event.
|
||||
let context = test_context!();
|
||||
let run = setup_completed_fast_dry_run(&context);
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["unarchive", &run.run_id])
|
||||
.output()
|
||||
.expect("unarchive should execute");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"unarchive on already-succeeded should succeed\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unarchive_on_active_run_rejects_with_not_archived_message() {
|
||||
let context = test_context!();
|
||||
let run = setup_created_fast_dry_run(&context);
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["unarchive", &run.run_id])
|
||||
.output()
|
||||
.expect("unarchive should execute");
|
||||
assert!(!output.status.success(), "unarchive on submitted must fail");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("is not archived"),
|
||||
"expected 'is not archived' in stderr, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unarchive_unknown_id_renders_clean_error() {
|
||||
let context = test_context!();
|
||||
let fake_id = unique_run_id();
|
||||
let output = context
|
||||
.command()
|
||||
.args(["unarchive", &fake_id])
|
||||
.output()
|
||||
.expect("unarchive should execute");
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains(&fake_id) || stderr.contains("No run found"),
|
||||
"expected unknown-id error in stderr, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unarchive_json_output_shape() {
|
||||
let context = test_context!();
|
||||
let run = setup_completed_fast_dry_run(&context);
|
||||
context
|
||||
.command()
|
||||
.args(["archive", &run.run_id])
|
||||
.output()
|
||||
.expect("archive should execute");
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["--json", "unarchive", &run.run_id])
|
||||
.output()
|
||||
.expect("unarchive --json should execute");
|
||||
assert!(output.status.success());
|
||||
let value: Value = serde_json::from_slice(&output.stdout).expect("unarchive JSON should parse");
|
||||
assert_eq!(
|
||||
value["unarchived"],
|
||||
Value::Array(vec![Value::String(run.run_id.clone())])
|
||||
);
|
||||
assert_eq!(value["errors"], Value::Array(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unarchive_mixed_batch_aggregates_errors() {
|
||||
let context = test_context!();
|
||||
let archived_run = setup_completed_fast_dry_run(&context);
|
||||
context
|
||||
.command()
|
||||
.args(["archive", &archived_run.run_id])
|
||||
.output()
|
||||
.expect("archive should execute");
|
||||
let active_run = setup_created_fast_dry_run(&context);
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args([
|
||||
"--json",
|
||||
"unarchive",
|
||||
&archived_run.run_id,
|
||||
&active_run.run_id,
|
||||
])
|
||||
.output()
|
||||
.expect("unarchive should execute");
|
||||
assert!(!output.status.success(), "mixed batch should exit non-zero");
|
||||
let value: Value = serde_json::from_slice(&output.stdout).expect("unarchive JSON should parse");
|
||||
assert_eq!(
|
||||
value["unarchived"],
|
||||
Value::Array(vec![Value::String(archived_run.run_id.clone())])
|
||||
);
|
||||
let errors = value["errors"].as_array().expect("errors should be array");
|
||||
assert_eq!(errors.len(), 1);
|
||||
assert_eq!(errors[0]["identifier"], active_run.run_id);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
use std::fs;
|
||||
|
||||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use fabro_test::{fabro_snapshot, stop_pid, test_context, wait_for_path};
|
||||
use serde_json::Value;
|
||||
|
||||
#[test]
|
||||
|
|
@ -169,3 +169,85 @@ fn not_installed_json() {
|
|||
|
||||
assert_eq!(value["status"].as_str(), Some("not_installed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This integration test moves a live daemon record on disk to simulate an unsupported legacy daemon upgrade."
|
||||
)]
|
||||
fn uninstall_yes_fails_when_only_a_legacy_running_server_record_exists() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let fabro_home = home_dir.path().join(".fabro");
|
||||
let storage_dir = fabro_home.join("storage");
|
||||
let socket_path = home_dir.path().join("legacy.sock");
|
||||
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n").unwrap();
|
||||
std::fs::create_dir_all(&fabro_home).unwrap();
|
||||
std::fs::write(fabro_home.join("settings.toml"), "_version = 1\n").unwrap();
|
||||
|
||||
let start_output = {
|
||||
let mut start = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
fabro_test::apply_test_isolation(&mut start, home_dir.path());
|
||||
start
|
||||
.args(["server", "start", "--bind"])
|
||||
.arg(&socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.expect("server start should run")
|
||||
};
|
||||
assert!(
|
||||
start_output.status.success(),
|
||||
"server start should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&start_output.stdout),
|
||||
String::from_utf8_lossy(&start_output.stderr)
|
||||
);
|
||||
|
||||
let current_record = storage_dir.join("server.json");
|
||||
wait_for_path(¤t_record);
|
||||
let legacy_record = fabro_home.join("server.json");
|
||||
std::fs::rename(¤t_record, &legacy_record).unwrap();
|
||||
let pid_u64 = serde_json::from_str::<serde_json::Value>(
|
||||
&std::fs::read_to_string(&legacy_record).unwrap(),
|
||||
)
|
||||
.unwrap()["pid"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let pid = u32::try_from(pid_u64).expect("pid fits in u32");
|
||||
|
||||
let uninstall_output = {
|
||||
let mut uninstall = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
fabro_test::apply_test_isolation(&mut uninstall, home_dir.path());
|
||||
uninstall
|
||||
.args(["uninstall", "--yes"])
|
||||
.output()
|
||||
.expect("uninstall should run")
|
||||
};
|
||||
|
||||
stop_pid(pid);
|
||||
let _ = std::fs::remove_file(&legacy_record);
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
|
||||
assert!(
|
||||
!uninstall_output.status.success(),
|
||||
"uninstall --yes should fail when only the legacy record exists"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&uninstall_output.stderr);
|
||||
assert!(
|
||||
stderr.contains(&legacy_record.display().to_string()),
|
||||
"expected stderr to mention the legacy record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(¤t_record.display().to_string()),
|
||||
"expected stderr to mention the current record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("legacy Fabro CLI"),
|
||||
"expected stderr to instruct manual cleanup, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
fabro_home.exists(),
|
||||
"uninstall should not remove ~/.fabro when the legacy daemon detector fires"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
91
lib/crates/fabro-cli/tests/it/scenario/archive.rs
Normal file
91
lib/crates/fabro-cli/tests/it/scenario/archive.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
use fabro_test::test_context;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cmd::support::setup_completed_fast_dry_run;
|
||||
|
||||
fn ps_runs(context: &fabro_test::TestContext, include_archived: bool) -> Vec<Value> {
|
||||
let label = context.test_case_label();
|
||||
let mut cmd = context.ps();
|
||||
if include_archived {
|
||||
cmd.arg("-a");
|
||||
}
|
||||
cmd.args(["--json", "--label", &label]);
|
||||
let output = cmd.output().expect("ps should execute");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ps failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("ps JSON should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_lifecycle_end_to_end() {
|
||||
let context = test_context!();
|
||||
let run = setup_completed_fast_dry_run(&context);
|
||||
|
||||
let visible = ps_runs(&context, true);
|
||||
assert_eq!(visible.len(), 1);
|
||||
assert_eq!(visible[0]["run_id"], run.run_id);
|
||||
assert_eq!(visible[0]["status"], "succeeded");
|
||||
|
||||
let archive = context
|
||||
.command()
|
||||
.args(["archive", &run.run_id])
|
||||
.output()
|
||||
.expect("archive should execute");
|
||||
assert!(
|
||||
archive.status.success(),
|
||||
"archive failed\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&archive.stderr)
|
||||
);
|
||||
|
||||
let default_visible = ps_runs(&context, false);
|
||||
assert!(
|
||||
default_visible.is_empty(),
|
||||
"default ps should hide archived, got {default_visible:?}"
|
||||
);
|
||||
|
||||
let with_archived = ps_runs(&context, true);
|
||||
assert_eq!(with_archived.len(), 1);
|
||||
assert_eq!(with_archived[0]["run_id"], run.run_id);
|
||||
assert_eq!(with_archived[0]["status"], "archived");
|
||||
|
||||
let unarchive = context
|
||||
.command()
|
||||
.args(["unarchive", &run.run_id])
|
||||
.output()
|
||||
.expect("unarchive should execute");
|
||||
assert!(
|
||||
unarchive.status.success(),
|
||||
"unarchive failed\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&unarchive.stderr)
|
||||
);
|
||||
let restored = ps_runs(&context, true);
|
||||
assert_eq!(restored[0]["status"], "succeeded");
|
||||
|
||||
// `rm` must remain available on archived runs — archive and delete are
|
||||
// orthogonal per the plan's Scope Boundaries.
|
||||
context
|
||||
.command()
|
||||
.args(["archive", &run.run_id])
|
||||
.output()
|
||||
.expect("re-archive should execute");
|
||||
let rm = context
|
||||
.command()
|
||||
.args(["rm", &run.run_id])
|
||||
.output()
|
||||
.expect("rm should execute");
|
||||
assert!(
|
||||
rm.status.success(),
|
||||
"rm on archived run should succeed\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&rm.stderr)
|
||||
);
|
||||
assert!(!run.run_dir.exists(), "run directory should be deleted");
|
||||
let after_delete = ps_runs(&context, true);
|
||||
assert!(
|
||||
after_delete.is_empty(),
|
||||
"run should be gone after rm, got {after_delete:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
#![allow(clippy::absolute_paths)]
|
||||
|
||||
mod archive;
|
||||
mod artifacts;
|
||||
mod exec;
|
||||
mod lifecycle;
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
//! Helpers for the deprecated `~/.fabro/.env` file.
|
||||
//!
|
||||
//! Fabro no longer reads `.env` automatically. The only remaining use for this
|
||||
//! module is detecting the old file so CLI commands can print migration
|
||||
//! warnings.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Return the path to the legacy `~/.fabro/.env` file.
|
||||
pub fn legacy_env_file_path() -> PathBuf {
|
||||
crate::Home::from_env().root().join(".env")
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ pub mod effective_settings;
|
|||
pub mod envfile;
|
||||
pub mod error;
|
||||
pub mod home;
|
||||
pub mod legacy_env;
|
||||
pub mod load;
|
||||
pub mod merge;
|
||||
pub mod parse;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use fabro_types::settings::server::{
|
|||
use fabro_util::Home;
|
||||
|
||||
use super::{ResolveError, default_interp, parse_socket_addr, require_interp};
|
||||
use crate::user::default_storage_dir;
|
||||
|
||||
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerSettings {
|
||||
let storage = resolve_storage(layer.storage.as_ref());
|
||||
|
|
@ -57,7 +58,7 @@ fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings
|
|||
ServerStorageSettings {
|
||||
root: layer
|
||||
.and_then(|storage| storage.root.clone())
|
||||
.unwrap_or_else(|| default_interp(Home::from_env().storage_dir())),
|
||||
.unwrap_or_else(|| default_interp(default_storage_dir())),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ impl Storage {
|
|||
self.root.join("cache")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn slatedb_cache_dir(&self) -> PathBuf {
|
||||
self.cache_dir().join("slatedb")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn secrets_path(&self) -> PathBuf {
|
||||
self.root
|
||||
|
|
@ -173,6 +178,10 @@ mod tests {
|
|||
storage.cache_dir(),
|
||||
std::path::Path::new("/tmp/fabro-data/cache")
|
||||
);
|
||||
assert_eq!(
|
||||
storage.slatedb_cache_dir(),
|
||||
std::path::Path::new("/tmp/fabro-data/cache/slatedb")
|
||||
);
|
||||
assert_eq!(
|
||||
storage.secrets_path(),
|
||||
std::path::Path::new("/tmp/fabro-data/vaults/default/secrets.json")
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@
|
|||
//! `~/.fabro/settings.toml` file. Runtime types that used to be
|
||||
//! re-exported from here live in `fabro_types::settings::user` now.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
|
||||
|
|
@ -15,23 +13,18 @@ use crate::home::Home;
|
|||
use crate::load::load_settings_path;
|
||||
|
||||
pub const SETTINGS_CONFIG_FILENAME: &str = "settings.toml";
|
||||
pub const LEGACY_USER_CONFIG_FILENAME: &str = "cli.toml";
|
||||
pub const LEGACY_OLD_USER_CONFIG_FILENAME: &str = "user.toml";
|
||||
pub const LEGACY_SERVER_CONFIG_FILENAME: &str = "server.toml";
|
||||
pub const FABRO_CONFIG_ENV: &str = "FABRO_CONFIG";
|
||||
|
||||
static WARNED_LEGACY_USER_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
||||
|
||||
pub fn default_settings_path() -> PathBuf {
|
||||
Home::from_env().user_config()
|
||||
}
|
||||
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
Home::from_env().root().join("fabro.sock")
|
||||
pub fn default_storage_dir() -> PathBuf {
|
||||
Home::from_env().root().join("storage")
|
||||
}
|
||||
|
||||
pub fn legacy_default_storage_root() -> PathBuf {
|
||||
Home::from_env().root().to_path_buf()
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
Home::from_env().root().join("fabro.sock")
|
||||
}
|
||||
|
||||
pub fn active_settings_path(path: Option<&Path>) -> PathBuf {
|
||||
|
|
@ -47,37 +40,9 @@ fn active_settings_path_with_lookup(
|
|||
.unwrap_or_else(default_settings_path)
|
||||
}
|
||||
|
||||
pub fn legacy_user_config_path() -> Option<PathBuf> {
|
||||
Some(Home::from_env().root().join(LEGACY_USER_CONFIG_FILENAME))
|
||||
}
|
||||
|
||||
pub fn legacy_old_user_config_path() -> Option<PathBuf> {
|
||||
Some(
|
||||
Home::from_env()
|
||||
.root()
|
||||
.join(LEGACY_OLD_USER_CONFIG_FILENAME),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn legacy_server_config_path() -> Option<PathBuf> {
|
||||
Some(Home::from_env().root().join(LEGACY_SERVER_CONFIG_FILENAME))
|
||||
}
|
||||
|
||||
fn warned_legacy_user_configs() -> &'static Mutex<HashSet<PathBuf>> {
|
||||
WARNED_LEGACY_USER_CONFIGS.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn should_warn_about_legacy_user_config(path: &Path) -> bool {
|
||||
warned_legacy_user_configs()
|
||||
.lock()
|
||||
.expect("legacy user config warning lock poisoned")
|
||||
.insert(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Load settings config from an explicit path or `~/.fabro/settings.toml`,
|
||||
/// returning defaults if the default file doesn't exist. An explicit path that
|
||||
/// doesn't exist is an error.
|
||||
#[allow(clippy::print_stderr)]
|
||||
pub fn load_settings_config(path: Option<&Path>) -> Result<SettingsLayer> {
|
||||
if let Some(explicit) = path
|
||||
.map(Path::to_path_buf)
|
||||
|
|
@ -86,25 +51,7 @@ pub fn load_settings_config(path: Option<&Path>) -> Result<SettingsLayer> {
|
|||
return load_v2_layer_from_path(&explicit);
|
||||
}
|
||||
|
||||
for legacy_path in [
|
||||
legacy_user_config_path(),
|
||||
legacy_old_user_config_path(),
|
||||
legacy_server_config_path(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if legacy_path.is_file() && should_warn_about_legacy_user_config(&legacy_path) {
|
||||
let target = default_settings_path();
|
||||
eprintln!(
|
||||
"Warning: ignoring legacy config file {}. Rename it to {}.",
|
||||
legacy_path.display(),
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let default = Home::from_env().root().join(SETTINGS_CONFIG_FILENAME);
|
||||
let default = default_settings_path();
|
||||
if default.is_file() {
|
||||
load_v2_layer_from_path(&default)
|
||||
} else {
|
||||
|
|
@ -119,23 +66,10 @@ fn load_v2_layer_from_path(path: &Path) -> Result<SettingsLayer> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
LEGACY_OLD_USER_CONFIG_FILENAME, LEGACY_SERVER_CONFIG_FILENAME,
|
||||
LEGACY_USER_CONFIG_FILENAME, SETTINGS_CONFIG_FILENAME, active_settings_path_with_lookup,
|
||||
default_settings_path, default_socket_path, legacy_old_user_config_path,
|
||||
legacy_server_config_path, legacy_user_config_path, should_warn_about_legacy_user_config,
|
||||
SETTINGS_CONFIG_FILENAME, active_settings_path_with_lookup, default_settings_path,
|
||||
default_socket_path, default_storage_dir,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn should_warn_about_legacy_user_config_once_per_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first = dir.path().join("cli.toml");
|
||||
let second = dir.path().join("other-cli.toml");
|
||||
|
||||
assert!(should_warn_about_legacy_user_config(&first));
|
||||
assert!(!should_warn_about_legacy_user_config(&first));
|
||||
assert!(should_warn_about_legacy_user_config(&second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_paths_use_expected_filenames() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
|
|
@ -144,33 +78,8 @@ mod tests {
|
|||
default_settings_path(),
|
||||
home.join(".fabro").join(SETTINGS_CONFIG_FILENAME)
|
||||
);
|
||||
assert_eq!(default_storage_dir(), home.join(".fabro/storage"));
|
||||
assert_eq!(default_socket_path(), home.join(".fabro/fabro.sock"));
|
||||
assert_eq!(
|
||||
legacy_user_config_path(),
|
||||
Some(home.join(".fabro").join(LEGACY_USER_CONFIG_FILENAME))
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_old_user_config_path(),
|
||||
Some(home.join(".fabro").join(LEGACY_OLD_USER_CONFIG_FILENAME))
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_server_config_path(),
|
||||
Some(home.join(".fabro").join(LEGACY_SERVER_CONFIG_FILENAME))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_warn_once_per_legacy_path_even_with_multiple_filenames() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let user = dir.path().join("user.toml");
|
||||
let server = dir.path().join("server.toml");
|
||||
let cli = dir.path().join("cli.toml");
|
||||
|
||||
assert!(should_warn_about_legacy_user_config(&user));
|
||||
assert!(!should_warn_about_legacy_user_config(&user));
|
||||
assert!(should_warn_about_legacy_user_config(&server));
|
||||
assert!(!should_warn_about_legacy_user_config(&server));
|
||||
assert!(should_warn_about_legacy_user_config(&cli));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use fabro_config::parse_settings_layer;
|
||||
use fabro_config::user::default_storage_dir;
|
||||
use fabro_types::settings::server::{
|
||||
GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerListenSettings,
|
||||
};
|
||||
|
|
@ -16,7 +17,7 @@ fn resolves_server_defaults_from_empty_settings() {
|
|||
|
||||
assert_eq!(
|
||||
settings.storage.root.as_source(),
|
||||
Home::from_env().storage_dir().to_string_lossy()
|
||||
default_storage_dir().to_string_lossy()
|
||||
);
|
||||
assert!(settings.web.enabled);
|
||||
assert_eq!(settings.web.url.as_source(), "http://localhost:3000");
|
||||
|
|
@ -36,8 +37,7 @@ fn resolves_server_defaults_from_empty_settings() {
|
|||
ObjectStoreSettings::Local { root } => {
|
||||
assert_eq!(
|
||||
root.as_source(),
|
||||
Home::from_env()
|
||||
.storage_dir()
|
||||
default_storage_dir()
|
||||
.join("objects")
|
||||
.join("artifacts")
|
||||
.to_string_lossy()
|
||||
|
|
@ -51,8 +51,7 @@ fn resolves_server_defaults_from_empty_settings() {
|
|||
ObjectStoreSettings::Local { root } => {
|
||||
assert_eq!(
|
||||
root.as_source(),
|
||||
Home::from_env()
|
||||
.storage_dir()
|
||||
default_storage_dir()
|
||||
.join("objects")
|
||||
.join("slatedb")
|
||||
.to_string_lossy()
|
||||
|
|
|
|||
|
|
@ -815,6 +815,7 @@ async fn post_install_finish(
|
|||
}
|
||||
|
||||
let mut server_env_secrets = Vec::new();
|
||||
let mut dev_token: Option<String> = None;
|
||||
match github {
|
||||
GithubInstallState::Token(github) => {
|
||||
if let Err(err) = write_token_settings(&mut settings_doc) {
|
||||
|
|
@ -826,6 +827,25 @@ async fn post_install_finish(
|
|||
secret_type: VaultSecretType::Environment,
|
||||
description: None,
|
||||
});
|
||||
let home = state.home.clone().unwrap_or_else(Home::from_env);
|
||||
let token = match dev_token::load_or_create_dev_token(&home.dev_token_path()) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return install_error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Err(err) = dev_token::write_dev_token(
|
||||
&Storage::new(state.storage_dir.as_ref())
|
||||
.server_state()
|
||||
.dev_token_path(),
|
||||
&token,
|
||||
) {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
dev_token = Some(token);
|
||||
}
|
||||
GithubInstallState::App(github) => {
|
||||
if let Err(err) = write_github_app_settings(
|
||||
|
|
@ -862,22 +882,6 @@ async fn post_install_finish(
|
|||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
};
|
||||
let home = state.home.clone().unwrap_or_else(Home::from_env);
|
||||
let dev_token = match dev_token::load_or_create_dev_token(&home.dev_token_path()) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
};
|
||||
if let Err(err) = dev_token::write_dev_token(
|
||||
&Storage::new(state.storage_dir.as_ref())
|
||||
.server_state()
|
||||
.dev_token_path(),
|
||||
&dev_token,
|
||||
) {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
|
||||
server_env_secrets.extend([
|
||||
(
|
||||
"FABRO_JWT_PRIVATE_KEY".to_string(),
|
||||
|
|
@ -888,8 +892,10 @@ async fn post_install_finish(
|
|||
BASE64_STANDARD.encode(jwt_public_pem.as_bytes()),
|
||||
),
|
||||
("SESSION_SECRET".to_string(), session_secret),
|
||||
("FABRO_DEV_TOKEN".to_string(), dev_token.clone()),
|
||||
]);
|
||||
if let Some(token) = dev_token.as_ref() {
|
||||
server_env_secrets.push(("FABRO_DEV_TOKEN".to_string(), token.clone()));
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
|
|
@ -949,15 +955,14 @@ async fn post_install_finish(
|
|||
}
|
||||
finish_guard.disarm();
|
||||
|
||||
(
|
||||
StatusCode::ACCEPTED,
|
||||
Json(serde_json::json!({
|
||||
"status": "completing",
|
||||
"restart_url": server.canonical_url,
|
||||
"dev_token": dev_token,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
let mut body = serde_json::json!({
|
||||
"status": "completing",
|
||||
"restart_url": server.canonical_url,
|
||||
});
|
||||
if let Some(token) = dev_token {
|
||||
body["dev_token"] = serde_json::Value::String(token);
|
||||
}
|
||||
(StatusCode::ACCEPTED, Json(body)).into_response()
|
||||
}
|
||||
|
||||
async fn render_install_shell(headers: HeaderMap, uri: OriginalUri) -> Response {
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ where
|
|||
let (object_store, slatedb_prefix, flush_interval, disk_cache) =
|
||||
build_slatedb_store(&resolved_server_settings)?;
|
||||
let cache_path = if disk_cache {
|
||||
Some(data_dir.join("cache").join("slatedb"))
|
||||
Some(storage.slatedb_cache_dir())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
|
|||
|
|
@ -137,6 +137,25 @@ pub struct PaginationParams {
|
|||
pub offset: u32,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ListRunsParams {
|
||||
#[serde(rename = "page[limit]", default = "default_page_limit")]
|
||||
limit: u32,
|
||||
#[serde(rename = "page[offset]", default)]
|
||||
offset: u32,
|
||||
#[serde(default)]
|
||||
include_archived: bool,
|
||||
}
|
||||
|
||||
impl ListRunsParams {
|
||||
fn pagination(&self) -> PaginationParams {
|
||||
PaginationParams {
|
||||
limit: self.limit,
|
||||
offset: self.offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModelListParams {
|
||||
#[serde(rename = "page[limit]", default = "default_page_limit")]
|
||||
|
|
@ -1110,6 +1129,8 @@ fn real_routes() -> Router<Arc<AppState>> {
|
|||
.route("/runs/{id}/start", post(start_run))
|
||||
.route("/runs/{id}/pause", post(pause_run))
|
||||
.route("/runs/{id}/unpause", post(unpause_run))
|
||||
.route("/runs/{id}/archive", post(archive_run))
|
||||
.route("/runs/{id}/unarchive", post(unarchive_run))
|
||||
.route("/runs/{id}/graph", get(get_graph))
|
||||
.route("/runs/{id}/stages", get(list_run_stages))
|
||||
.route("/runs/{id}/artifacts", get(list_run_artifacts))
|
||||
|
|
@ -2604,7 +2625,7 @@ fn board_column(status: WorkflowRunStatus) -> Option<&'static str> {
|
|||
WorkflowRunStatus::Blocked => Some("blocked"),
|
||||
WorkflowRunStatus::Succeeded => Some("succeeded"),
|
||||
WorkflowRunStatus::Failed | WorkflowRunStatus::Dead => Some("failed"),
|
||||
WorkflowRunStatus::Removing => None,
|
||||
WorkflowRunStatus::Removing | WorkflowRunStatus::Archived => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2782,7 +2803,7 @@ async fn list_board_runs(
|
|||
async fn list_runs(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
Query(params): Query<ListRunsParams>,
|
||||
) -> Response {
|
||||
match state
|
||||
.store
|
||||
|
|
@ -2790,11 +2811,13 @@ async fn list_runs(
|
|||
.await
|
||||
{
|
||||
Ok(runs) => {
|
||||
let include_archived = params.include_archived;
|
||||
let items = runs
|
||||
.into_iter()
|
||||
.filter(|summary| include_archived || summary.status != WorkflowRunStatus::Archived)
|
||||
.map(summary_to_api_run_summary)
|
||||
.collect::<Vec<_>>();
|
||||
let (data, has_more) = paginate_items(items, &pagination);
|
||||
let (data, has_more) = paginate_items(items, ¶ms.pagination());
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
|
|
@ -3404,6 +3427,7 @@ fn api_status_from_workflow(status: WorkflowRunStatus) -> RunStatus {
|
|||
WorkflowRunStatus::Succeeded => RunStatus::Succeeded,
|
||||
WorkflowRunStatus::Failed => RunStatus::Failed,
|
||||
WorkflowRunStatus::Dead => RunStatus::Dead,
|
||||
WorkflowRunStatus::Archived => RunStatus::Archived,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4071,6 +4095,9 @@ async fn start_run(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
let resume = body.is_some_and(|Json(req)| req.resume);
|
||||
|
||||
{
|
||||
|
|
@ -4907,6 +4934,9 @@ async fn submit_answer(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
let pending = match load_pending_interview(state.as_ref(), id, &qid).await {
|
||||
Ok(pending) => pending,
|
||||
Err(response) => return response,
|
||||
|
|
@ -4951,6 +4981,9 @@ async fn append_run_event(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
let event = match RunEvent::from_value(value.clone()) {
|
||||
Ok(event) => event,
|
||||
Err(err) => {
|
||||
|
|
@ -4960,6 +4993,12 @@ async fn append_run_event(
|
|||
if event.run_id != id {
|
||||
return ApiError::bad_request("Event run_id does not match path run ID.").into_response();
|
||||
}
|
||||
if let Some(denied) = denied_lifecycle_event_name(&event.body) {
|
||||
return ApiError::bad_request(format!(
|
||||
"{denied} is a lifecycle event; clients must call the corresponding operation endpoint instead of injecting it via append_run_event"
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let payload = match EventPayload::new(value, &id) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
|
||||
|
|
@ -5198,6 +5237,9 @@ async fn write_run_blob(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
match state.store.open_run(&id).await {
|
||||
Ok(run_store) => match run_store.write_blob(&body).await {
|
||||
Ok(blob_id) => Json(WriteBlobResponse {
|
||||
|
|
@ -5681,6 +5723,9 @@ async fn put_stage_artifact(
|
|||
if let Err(err) = authorize_artifact_upload(&parts, state.as_ref(), &id) {
|
||||
return err.into_response();
|
||||
}
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
if let Err(response) = load_run_record(state.as_ref(), &id).await.map(|_| ()) {
|
||||
return response;
|
||||
}
|
||||
|
|
@ -5902,6 +5947,9 @@ async fn put_sandbox_file(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
let sandbox = match reconnect_run_sandbox(&state, &id).await {
|
||||
Ok(sandbox) => sandbox,
|
||||
Err(response) => return response,
|
||||
|
|
@ -5997,6 +6045,40 @@ fn actor_from_subject(subject: &AuthenticatedSubject) -> Option<ActorRef> {
|
|||
subject.login.clone().map(ActorRef::user)
|
||||
}
|
||||
|
||||
/// Returns the wire event name if the given body has a dedicated operation
|
||||
/// endpoint that clients must use instead of injecting via `append_run_event`.
|
||||
/// These endpoints enforce authorization and status-transition preconditions
|
||||
/// (e.g. "archive only from terminal") that a direct event append would
|
||||
/// bypass. Other run-lifecycle events flow through this endpoint legitimately:
|
||||
/// the worker subprocess emits state transitions during execution, and the
|
||||
/// rewind CLI relays `RunRewound` / `RunSubmitted` here.
|
||||
fn denied_lifecycle_event_name(body: &EventBody) -> Option<&'static str> {
|
||||
match body {
|
||||
EventBody::RunArchived(_) => Some("run.archived"),
|
||||
EventBody::RunUnarchived(_) => Some("run.unarchived"),
|
||||
EventBody::RunCancelRequested(_) => Some("run.cancel.requested"),
|
||||
EventBody::RunPauseRequested(_) => Some("run.pause.requested"),
|
||||
EventBody::RunUnpauseRequested(_) => Some("run.unpause.requested"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a 409 response with an actionable "unarchive first" message if the
|
||||
/// run is currently archived. Returns `None` otherwise (including when the run
|
||||
/// doesn't exist — the caller's own not-found handling will surface that).
|
||||
async fn reject_if_archived(state: &AppState, run_id: &RunId) -> Option<Response> {
|
||||
let run_store = state.store.open_run_reader(run_id).await.ok()?;
|
||||
let projection = run_store.state().await.ok()?;
|
||||
let status = projection.status.as_ref()?.status;
|
||||
(status == WorkflowRunStatus::Archived).then(|| {
|
||||
ApiError::new(
|
||||
StatusCode::CONFLICT,
|
||||
operations::archived_rejection_message(run_id),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
}
|
||||
|
||||
fn schedule_worker_kill(state: Arc<AppState>, run_id: RunId, worker_pid: u32) {
|
||||
tokio::spawn(async move {
|
||||
sleep(WORKER_CANCEL_GRACE).await;
|
||||
|
|
@ -6020,6 +6102,9 @@ async fn cancel_run(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
let pending_control = match load_pending_control(state.as_ref(), id).await {
|
||||
Ok(pending_control) => pending_control,
|
||||
Err(err) => {
|
||||
|
|
@ -6167,6 +6252,9 @@ async fn pause_run(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
let pending_control = match load_pending_control(state.as_ref(), id).await {
|
||||
Ok(pending_control) => pending_control,
|
||||
Err(err) => {
|
||||
|
|
@ -6259,6 +6347,9 @@ async fn unpause_run(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
let pending_control = match load_pending_control(state.as_ref(), id).await {
|
||||
Ok(pending_control) => pending_control,
|
||||
Err(err) => {
|
||||
|
|
@ -6353,6 +6444,99 @@ async fn unpause_run(
|
|||
.into_response()
|
||||
}
|
||||
|
||||
async fn archive_run(
|
||||
subject: AuthenticatedSubject,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
run_archive_action(state, subject, id, ArchiveAction::Archive).await
|
||||
}
|
||||
|
||||
async fn unarchive_run(
|
||||
subject: AuthenticatedSubject,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
run_archive_action(state, subject, id, ArchiveAction::Unarchive).await
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ArchiveAction {
|
||||
Archive,
|
||||
Unarchive,
|
||||
}
|
||||
|
||||
async fn run_archive_action(
|
||||
state: Arc<AppState>,
|
||||
subject: AuthenticatedSubject,
|
||||
id: String,
|
||||
action: ArchiveAction,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let actor = actor_from_subject(&subject);
|
||||
let result = match action {
|
||||
ArchiveAction::Archive => operations::archive(&state.store, &id, actor)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
ArchiveAction::Unarchive => operations::unarchive(&state.store, &id, actor)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => archive_status_response(state.as_ref(), id).await,
|
||||
Err(WorkflowError::Precondition(message)) => {
|
||||
ApiError::new(StatusCode::CONFLICT, message).into_response()
|
||||
}
|
||||
Err(WorkflowError::RunNotFound(_)) => ApiError::not_found("Run not found.").into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `RunStatusResponse` reflecting the durable projection after an
|
||||
/// archive/unarchive transition. The run is terminal in both directions, so no
|
||||
/// live queue position or worker-only fields apply.
|
||||
async fn archive_status_response(state: &AppState, id: RunId) -> Response {
|
||||
let Ok(run_store) = state.store.open_run_reader(&id).await else {
|
||||
return ApiError::not_found("Run not found.").into_response();
|
||||
};
|
||||
let projection = match run_store.state().await {
|
||||
Ok(projection) => projection,
|
||||
Err(err) => {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let Some(record) = projection.status.as_ref() else {
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"run has no status record after archive/unarchive",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let status = api_status_from_workflow(record.status);
|
||||
let status_reason = record.status_reason.map(api_status_reason);
|
||||
let blocked_reason = record.blocked_reason.map(api_blocked_reason);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(RunStatusResponse {
|
||||
id: id.to_string(),
|
||||
blocked_reason,
|
||||
status,
|
||||
error: None,
|
||||
queue_position: None,
|
||||
status_reason,
|
||||
pending_control: None,
|
||||
created_at: id.created_at(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Persist a synchronous pause/unpause transition: append the caller-supplied
|
||||
/// events to the run store and mirror the new status in the in-memory run map.
|
||||
/// Returns `Some(Response)` on error, `None` on success.
|
||||
|
|
@ -8331,6 +8515,41 @@ slug = "fabro"
|
|||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_run_event_rejects_reserved_archive_event() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
let run_id = create_run(&app, MINIMAL_DOT).await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/events")))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"id": "evt-run-archived",
|
||||
"ts": "2026-04-19T12:00:00Z",
|
||||
"run_id": run_id,
|
||||
"event": "run.archived",
|
||||
"properties": {
|
||||
"actor": null
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = body_json(response.into_body()).await;
|
||||
assert!(
|
||||
body["errors"][0]["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|message| message.contains("run.archived is a lifecycle event")),
|
||||
"expected lifecycle rejection, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_checkpoint_returns_null_initially() {
|
||||
let state = create_app_state();
|
||||
|
|
@ -8966,6 +9185,145 @@ slug = "fabro"
|
|||
assert!(items[0]["total_usd_micros"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_and_unarchive_updates_listing_visibility() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
let run_id = fixtures::RUN_1;
|
||||
|
||||
create_durable_run_with_events(&state, run_id, &[
|
||||
workflow_event::Event::RunSubmitted {
|
||||
reason: None,
|
||||
definition_blob: None,
|
||||
},
|
||||
workflow_event::Event::RunStarting { reason: None },
|
||||
workflow_event::Event::RunRunning { reason: None },
|
||||
workflow_event::Event::WorkflowRunCompleted {
|
||||
duration_ms: 1000,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
},
|
||||
])
|
||||
.await;
|
||||
|
||||
let archive_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/archive")))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(archive_response.status(), StatusCode::OK);
|
||||
let archive_body = body_json(archive_response.into_body()).await;
|
||||
assert_eq!(archive_body["status"].as_str(), Some("archived"));
|
||||
|
||||
let hidden_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hidden_response.status(), StatusCode::OK);
|
||||
let hidden_body = body_json(hidden_response.into_body()).await;
|
||||
assert!(
|
||||
!hidden_body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["run_id"].as_str() == Some(&run_id.to_string())),
|
||||
"archived run should be hidden from default listing"
|
||||
);
|
||||
|
||||
let visible_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs?include_archived=true"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(visible_response.status(), StatusCode::OK);
|
||||
let visible_body = body_json(visible_response.into_body()).await;
|
||||
let archived_item = visible_body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["run_id"].as_str() == Some(&run_id.to_string()))
|
||||
.expect("archived run should appear when include_archived=true");
|
||||
assert_eq!(archived_item["status"].as_str(), Some("archived"));
|
||||
|
||||
let unarchive_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/unarchive")))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unarchive_response.status(), StatusCode::OK);
|
||||
let unarchive_body = body_json(unarchive_response.into_body()).await;
|
||||
assert_eq!(unarchive_body["status"].as_str(), Some("succeeded"));
|
||||
|
||||
let restored_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(restored_response.status(), StatusCode::OK);
|
||||
let restored_body = body_json(restored_response.into_body()).await;
|
||||
let restored_item = restored_body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["run_id"].as_str() == Some(&run_id.to_string()))
|
||||
.expect("unarchived run should reappear in default listing");
|
||||
assert_eq!(restored_item["status"].as_str(), Some("succeeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_unknown_run_returns_not_found() {
|
||||
let app = test_app_with();
|
||||
let run_id = fixtures::RUN_64;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/archive")))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_run_removes_durable_run() {
|
||||
let state = create_app_state();
|
||||
|
|
|
|||
|
|
@ -387,6 +387,150 @@ async fn token_install_finish_persists_settings_env_and_vault() {
|
|||
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let home_root = tempfile::tempdir().unwrap();
|
||||
let home = Home::new(home_root.path().join(".fabro"));
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
let github_mock = MockServer::start_async().await;
|
||||
github_mock
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST")
|
||||
.path("/app-manifests/stub-code/conversions");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
r#"{
|
||||
"id": 42,
|
||||
"slug": "fabro-test-app",
|
||||
"client_id": "Iv1.test-client-id",
|
||||
"client_secret": "test-client-secret",
|
||||
"webhook_secret": "test-webhook-secret",
|
||||
"pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
|
||||
}"#,
|
||||
);
|
||||
})
|
||||
.await;
|
||||
let app = build_install_router(
|
||||
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
|
||||
.with_home(home.clone())
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
);
|
||||
|
||||
let llm_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/llm")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(llm_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let server_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/server")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let manifest_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/github/app/manifest")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(manifest_response.status(), StatusCode::OK);
|
||||
let manifest_body = body_json(manifest_response.into_body()).await;
|
||||
let redirect_url = manifest_body["manifest"]["redirect_url"]
|
||||
.as_str()
|
||||
.expect("redirect_url should be present");
|
||||
let redirect_uri = fabro_http::Url::parse(redirect_url).unwrap();
|
||||
let state = redirect_uri
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "state")
|
||||
.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 finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/finish")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(finish_response.status(), StatusCode::ACCEPTED);
|
||||
let finish_body = body_json(finish_response.into_body()).await;
|
||||
assert_eq!(finish_body["status"], "completing");
|
||||
assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
|
||||
assert!(
|
||||
finish_body.get("dev_token").is_none(),
|
||||
"App installs must not expose a dev token"
|
||||
);
|
||||
|
||||
let server_env =
|
||||
std::fs::read_to_string(Storage::new(temp_dir.path()).server_state().env_path()).unwrap();
|
||||
assert!(!server_env.contains("FABRO_DEV_TOKEN="));
|
||||
|
||||
assert!(
|
||||
!home.dev_token_path().exists(),
|
||||
"home dev token file should not be created for App installs"
|
||||
);
|
||||
assert!(
|
||||
!Storage::new(temp_dir.path())
|
||||
.server_state()
|
||||
.dev_token_path()
|
||||
.exists(),
|
||||
"storage dev token file should not be created for App installs"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
249
lib/crates/fabro-server/tests/it/scenario/archive.rs
Normal file
249
lib/crates/fabro-server/tests/it/scenario/archive.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
use axum::body::Body;
|
||||
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,
|
||||
test_settings, wait_for_run_status,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn archived_runs_reject_mutations_with_actionable_body() {
|
||||
let state = test_app_state_with_options(test_settings(), 5);
|
||||
let app = test_app_with_scheduler(state);
|
||||
|
||||
let run_id =
|
||||
create_and_start_run_from_manifest(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT))
|
||||
.await;
|
||||
let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
||||
assert_eq!(status, "succeeded");
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/archive")))
|
||||
.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;
|
||||
assert_eq!(body["status"], "archived");
|
||||
|
||||
for path in &["/cancel", "/pause", "/unpause", "/start"] {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}{path}")))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::CONFLICT,
|
||||
"expected 409 for POST /runs/{{id}}{path} on archived run"
|
||||
);
|
||||
let body = body_json(response.into_body()).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 {path}, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/events")))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
"ts": "2026-04-19T12:00:00.000Z",
|
||||
"run_id": run_id,
|
||||
"event": "agent.message",
|
||||
"properties": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::CONFLICT,
|
||||
"expected 409 for POST /runs/{{id}}/events on archived run"
|
||||
);
|
||||
|
||||
// The archive guard runs before each endpoint's state-specific lookups, so
|
||||
// synthetic stage/question/filename values are enough to drive these
|
||||
// write surfaces into the guard.
|
||||
for (method, path, body, content_type) in [
|
||||
(
|
||||
"POST",
|
||||
format!("/runs/{run_id}/questions/q-fake/answer"),
|
||||
r#"{"value":"x"}"#,
|
||||
"application/json",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
format!("/runs/{run_id}/stages/fake@1/artifacts?filename=smoke.txt"),
|
||||
"payload",
|
||||
"application/octet-stream",
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
format!("/runs/{run_id}/sandbox/file?path=smoke.txt"),
|
||||
"payload",
|
||||
"application/octet-stream",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
format!("/runs/{run_id}/blobs"),
|
||||
"payload",
|
||||
"application/octet-stream",
|
||||
),
|
||||
] {
|
||||
let req = Request::builder()
|
||||
.method(method)
|
||||
.uri(api(&path))
|
||||
.header("content-type", content_type)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::CONFLICT,
|
||||
"expected 409 for {method} {path} on archived run"
|
||||
);
|
||||
let body = body_json(response.into_body()).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 {method} {path}, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// Unarchive restores the prior terminal status.
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/unarchive")))
|
||||
.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;
|
||||
assert_eq!(body["status"], "succeeded");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn appending_run_archived_event_directly_is_rejected() {
|
||||
// Regression: archive/unarchive events must not be injectable via
|
||||
// `append_run_event` — clients must use the operation endpoints.
|
||||
let state = test_app_state_with_options(test_settings(), 5);
|
||||
let app = test_app_with_scheduler(state);
|
||||
|
||||
let run_id =
|
||||
create_and_start_run_from_manifest(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT))
|
||||
.await;
|
||||
wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/events")))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
"ts": "2026-04-19T12:00:00.000Z",
|
||||
"run_id": run_id,
|
||||
"event": "run.archived",
|
||||
"properties": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.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 detail = body["errors"][0]["detail"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
detail.contains("lifecycle event"),
|
||||
"expected lifecycle rejection, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn archive_returns_404_for_unknown_run() {
|
||||
let state = test_app_state_with_options(test_settings(), 5);
|
||||
let app = test_app_with_scheduler(state);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/archive"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/unarchive"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn list_runs_respects_include_archived_flag() {
|
||||
let state = test_app_state_with_options(test_settings(), 5);
|
||||
let app = test_app_with_scheduler(state);
|
||||
|
||||
let run_id =
|
||||
create_and_start_run_from_manifest(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT))
|
||||
.await;
|
||||
wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
||||
|
||||
// Archive it.
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/archive")))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// Default listing hides archived.
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = body_json(response.into_body()).await;
|
||||
let ids_visible: Vec<String> = body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["run_id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
!ids_visible.contains(&run_id),
|
||||
"archived run should be hidden, got {ids_visible:?}"
|
||||
);
|
||||
|
||||
// `include_archived=true` surfaces it.
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs?include_archived=true"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = body_json(response.into_body()).await;
|
||||
let ids_all: Vec<String> = body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["run_id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
ids_all.contains(&run_id),
|
||||
"include_archived=true should surface the run, got {ids_all:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod archive;
|
||||
mod dry_run;
|
||||
mod lifecycle;
|
||||
mod run_completion;
|
||||
|
|
|
|||
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
1969
lib/crates/fabro-spa/assets/assets/entry-70cq40fr.js
generated
1969
lib/crates/fabro-spa/assets/assets/entry-70cq40fr.js
generated
File diff suppressed because one or more lines are too long
1971
lib/crates/fabro-spa/assets/assets/entry-g6egnqzk.js
generated
Normal file
1971
lib/crates/fabro-spa/assets/assets/entry-g6egnqzk.js
generated
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
lib/crates/fabro-spa/assets/index.html
generated
4
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -17,7 +17,7 @@
|
|||
(function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t="dark";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()
|
||||
</script>
|
||||
<link rel="stylesheet" href="/assets/app.css" />
|
||||
<link rel="stylesheet" href="/assets/entry-v1ec8kqg.css" />
|
||||
<link rel="stylesheet" href="/assets/entry-g7fj8z7g.css" />
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
|
|
@ -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-70cq40fr.js"></script>
|
||||
<script type="module" src="/assets/entry-g6egnqzk.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>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ pub struct RunProjection {
|
|||
pub graph_source: Option<String>,
|
||||
pub start: Option<StartRecord>,
|
||||
pub status: Option<RunStatusRecord>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prior_status: Option<RunStatus>,
|
||||
pub pending_control: Option<RunControlAction>,
|
||||
pub checkpoint: Option<Checkpoint>,
|
||||
pub checkpoints: Vec<(u32, Checkpoint)>,
|
||||
|
|
@ -212,6 +214,42 @@ impl RunProjection {
|
|||
EventBody::RunRewound(_) => {
|
||||
self.reset_for_rewind();
|
||||
}
|
||||
EventBody::RunArchived(_props) => {
|
||||
// Idempotent on replay: a second RunArchived (from a concurrent
|
||||
// archive race or a tampered log) must not overwrite
|
||||
// prior_status — doing so would permanently corrupt the run
|
||||
// (unarchive would then restore status=Archived and never
|
||||
// recover). We also ignore the archive attempt when current
|
||||
// status is not a terminal non-archived — the operations
|
||||
// layer is the only legitimate emitter and it validates
|
||||
// there, but replay must be defensive.
|
||||
let current = self.status.as_ref().map(|record| record.status);
|
||||
if matches!(
|
||||
current,
|
||||
Some(RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead)
|
||||
) {
|
||||
self.prior_status = current;
|
||||
self.status = Some(run_status_record(RunStatus::Archived, None, ts));
|
||||
}
|
||||
}
|
||||
EventBody::RunUnarchived(props)
|
||||
if matches!(
|
||||
self.status.as_ref().map(|record| record.status),
|
||||
Some(RunStatus::Archived)
|
||||
) && matches!(
|
||||
props.restored_status,
|
||||
RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead
|
||||
) =>
|
||||
{
|
||||
// Defensive: restored_status must be a real terminal status.
|
||||
// Accepting e.g. Running here would produce a projection
|
||||
// whose status is Running without any RunRunning event in
|
||||
// the log, breaking the is_active / is_terminal invariants.
|
||||
// The operations layer only ever writes validated terminals,
|
||||
// but replay must enforce it.
|
||||
self.status = Some(run_status_record(props.restored_status, None, ts));
|
||||
self.prior_status = None;
|
||||
}
|
||||
EventBody::CheckpointCompleted(props) => {
|
||||
let checkpoint = checkpoint_from_props(props, ts);
|
||||
if let Some(node_id) = stored.node_id.as_deref() {
|
||||
|
|
@ -1097,4 +1135,273 @@ mod tests {
|
|||
events[1].payload.as_value()["properties"]["definition_blob"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_archived_captures_prior_status_and_sets_archived() {
|
||||
use fabro_types::RunStatus;
|
||||
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps};
|
||||
|
||||
let mut state = RunProjection::default();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
1,
|
||||
EventBody::RunCompleted(RunCompletedProps {
|
||||
duration_ms: 10,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state.status.as_ref().map(|record| record.status),
|
||||
Some(RunStatus::Succeeded)
|
||||
);
|
||||
assert_eq!(state.prior_status, None);
|
||||
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
2,
|
||||
EventBody::RunArchived(RunArchivedProps { actor: None }),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.status.as_ref().map(|record| record.status),
|
||||
Some(RunStatus::Archived)
|
||||
);
|
||||
assert_eq!(state.prior_status, Some(RunStatus::Succeeded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_unarchived_restores_status_and_clears_prior_status() {
|
||||
use fabro_types::RunStatus;
|
||||
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps};
|
||||
|
||||
let mut state = RunProjection::default();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
1,
|
||||
EventBody::RunCompleted(RunCompletedProps {
|
||||
duration_ms: 10,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
2,
|
||||
EventBody::RunArchived(RunArchivedProps { actor: None }),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
3,
|
||||
EventBody::RunUnarchived(RunUnarchivedProps {
|
||||
actor: None,
|
||||
restored_status: RunStatus::Succeeded,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.status.as_ref().map(|record| record.status),
|
||||
Some(RunStatus::Succeeded)
|
||||
);
|
||||
assert_eq!(state.prior_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_unarchived_uses_event_payload_even_when_prior_status_differs() {
|
||||
// The event payload is authoritative: the unarchive apply arm sets status
|
||||
// from `restored_status`, ignoring whatever `prior_status` was captured.
|
||||
use fabro_types::RunStatus;
|
||||
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps};
|
||||
|
||||
let mut state = RunProjection::default();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
1,
|
||||
EventBody::RunCompleted(RunCompletedProps {
|
||||
duration_ms: 10,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
2,
|
||||
EventBody::RunArchived(RunArchivedProps { actor: None }),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
3,
|
||||
EventBody::RunUnarchived(RunUnarchivedProps {
|
||||
actor: None,
|
||||
restored_status: RunStatus::Failed,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.status.as_ref().map(|record| record.status),
|
||||
Some(RunStatus::Failed)
|
||||
);
|
||||
assert_eq!(state.prior_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_archive_preserves_prior_status() {
|
||||
// Two RunArchived events in sequence — possible under a concurrent
|
||||
// archive race or a replayed/retried write — must not overwrite
|
||||
// prior_status with Archived. Otherwise unarchive would emit
|
||||
// restored_status=Archived, the apply arm would set status=Archived +
|
||||
// prior_status=None, and the run would be permanently unrecoverable.
|
||||
use fabro_types::RunStatus;
|
||||
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps};
|
||||
|
||||
let mut state = RunProjection::default();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
1,
|
||||
EventBody::RunCompleted(RunCompletedProps {
|
||||
duration_ms: 10,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
2,
|
||||
EventBody::RunArchived(RunArchivedProps { actor: None }),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
3,
|
||||
EventBody::RunArchived(RunArchivedProps { actor: None }),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.status.as_ref().map(|record| record.status),
|
||||
Some(RunStatus::Archived)
|
||||
);
|
||||
assert_eq!(state.prior_status, Some(RunStatus::Succeeded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_unarchived_with_non_terminal_restored_status_is_ignored() {
|
||||
// The operations layer never emits this, but a corrupt log or an
|
||||
// imported event must not drag the projection into a nonsense state
|
||||
// (status=Running without any RunRunning event preceding it).
|
||||
use fabro_types::RunStatus;
|
||||
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps};
|
||||
|
||||
let mut state = RunProjection::default();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
1,
|
||||
EventBody::RunCompleted(RunCompletedProps {
|
||||
duration_ms: 10,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
2,
|
||||
EventBody::RunArchived(RunArchivedProps { actor: None }),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
3,
|
||||
EventBody::RunUnarchived(RunUnarchivedProps {
|
||||
actor: None,
|
||||
restored_status: RunStatus::Running,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.status.as_ref().map(|record| record.status),
|
||||
Some(RunStatus::Archived)
|
||||
);
|
||||
assert_eq!(state.prior_status, Some(RunStatus::Succeeded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_archived_on_non_terminal_projection_is_ignored() {
|
||||
// Same defensive posture on the other side: an event log that
|
||||
// somehow contains RunArchived on a Running run must not produce
|
||||
// a projection with status=Archived and prior_status=Some(Running).
|
||||
use fabro_types::RunStatus;
|
||||
use fabro_types::run_event::{RunArchivedProps, RunStatusTransitionProps};
|
||||
|
||||
let mut state = RunProjection::default();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
1,
|
||||
EventBody::RunRunning(RunStatusTransitionProps { reason: None }),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
state
|
||||
.apply_event(&test_event(
|
||||
2,
|
||||
EventBody::RunArchived(RunArchivedProps { actor: None }),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state.status.as_ref().map(|record| record.status),
|
||||
Some(RunStatus::Running)
|
||||
);
|
||||
assert_eq!(state.prior_status, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,91 @@ fn apply_test_isolation_with_lookup(
|
|||
cmd.env(TEST_IN_MEMORY_STORE_ENV, "1");
|
||||
}
|
||||
|
||||
/// Create a fresh tempdir containing an empty `storage/` subdirectory, for
|
||||
/// isolating server lifecycle tests from the shared nextest session storage.
|
||||
#[must_use]
|
||||
pub fn isolated_storage_dir() -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir_in("/tmp").expect("tempdir under /tmp");
|
||||
std::fs::create_dir_all(root.path().join("storage")).expect("create storage dir");
|
||||
root
|
||||
}
|
||||
|
||||
/// Sleep tick for the test polling helpers below. These are synchronous
|
||||
/// helpers called from blocking integration tests — there's no runtime to
|
||||
/// hand off to, so `std::thread::sleep` is the right primitive.
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "sync polling helper for blocking integration tests"
|
||||
)]
|
||||
fn poll_sleep() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
|
||||
/// Poll up to 5s for a path to appear; panic on timeout.
|
||||
pub fn wait_for_path(path: &Path) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if path.exists() {
|
||||
return;
|
||||
}
|
||||
poll_sleep();
|
||||
}
|
||||
panic!("timed out waiting for {}", path.display());
|
||||
}
|
||||
|
||||
/// Poll up to 5s for `needle` to appear in the contents of `path`; panic on
|
||||
/// timeout.
|
||||
pub fn wait_for_log_line(path: &Path, needle: &str) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.is_some_and(|contents| contents.contains(needle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
poll_sleep();
|
||||
}
|
||||
panic!("timed out waiting for {needle:?} in {}", path.display());
|
||||
}
|
||||
|
||||
/// SIGTERM the pid, wait up to 5s for it to exit, then SIGKILL if still alive.
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
poll_sleep();
|
||||
}
|
||||
fabro_proc::sigkill(pid);
|
||||
}
|
||||
|
||||
/// List any `server.*.log` files under `logs_dir`. Used by server-lifecycle
|
||||
/// tests to assert that no server logs leak into the home logs directory.
|
||||
#[must_use]
|
||||
pub fn server_log_files(logs_dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(logs_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
let has_log_ext = path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("log"));
|
||||
let has_server_prefix = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("server."));
|
||||
has_log_ext && has_server_prefix
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A test context for running fabro CLI commands.
|
||||
///
|
||||
/// Each context gets isolated home/temp directories. The storage directory is
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ pub enum EventBody {
|
|||
RunUnpaused(RunControlEffectProps),
|
||||
#[serde(rename = "run.rewound")]
|
||||
RunRewound(RunRewoundProps),
|
||||
#[serde(rename = "run.archived")]
|
||||
RunArchived(RunArchivedProps),
|
||||
#[serde(rename = "run.unarchived")]
|
||||
RunUnarchived(RunUnarchivedProps),
|
||||
#[serde(rename = "run.completed")]
|
||||
RunCompleted(RunCompletedProps),
|
||||
#[serde(rename = "run.failed")]
|
||||
|
|
@ -375,6 +379,8 @@ impl EventBody {
|
|||
Self::RunPaused(_) => "run.paused",
|
||||
Self::RunUnpaused(_) => "run.unpaused",
|
||||
Self::RunRewound(_) => "run.rewound",
|
||||
Self::RunArchived(_) => "run.archived",
|
||||
Self::RunUnarchived(_) => "run.unarchived",
|
||||
Self::RunCompleted(_) => "run.completed",
|
||||
Self::RunFailed(_) => "run.failed",
|
||||
Self::RunNotice(_) => "run.notice",
|
||||
|
|
@ -504,6 +510,8 @@ fn is_known_event_name(event: &str) -> bool {
|
|||
| "run.unblocked"
|
||||
| "run.removing"
|
||||
| "run.rewound"
|
||||
| "run.archived"
|
||||
| "run.unarchived"
|
||||
| "run.completed"
|
||||
| "run.failed"
|
||||
| "run.notice"
|
||||
|
|
@ -1106,6 +1114,76 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_archived_serializes_with_dotted_event_name_and_actor_property() {
|
||||
let body = EventBody::RunArchived(RunArchivedProps {
|
||||
actor: Some(ActorRef::user("alice".to_string())),
|
||||
});
|
||||
let value = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(value["event"], "run.archived");
|
||||
assert_eq!(value["properties"]["actor"]["kind"], "user");
|
||||
assert_eq!(value["properties"]["actor"]["id"], "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_unarchived_serializes_with_restored_status() {
|
||||
let body = EventBody::RunUnarchived(RunUnarchivedProps {
|
||||
actor: None,
|
||||
restored_status: crate::RunStatus::Failed,
|
||||
});
|
||||
let value = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(value["event"], "run.unarchived");
|
||||
assert_eq!(value["properties"]["restored_status"], "failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_archived_round_trips_through_from_value() {
|
||||
let value = json!({
|
||||
"id": "evt_archived",
|
||||
"ts": "2026-04-19T12:00:00.000Z",
|
||||
"run_id": fixtures::RUN_1,
|
||||
"event": "run.archived",
|
||||
"properties": {
|
||||
"actor": {
|
||||
"kind": "user",
|
||||
"id": "alice",
|
||||
"display": "alice"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = RunEvent::from_value(value.clone()).unwrap();
|
||||
assert!(matches!(parsed.body, EventBody::RunArchived(_)));
|
||||
let serialized = parsed.to_value().unwrap();
|
||||
assert_eq!(serialized["event"], "run.archived");
|
||||
assert_eq!(
|
||||
serialized["properties"]["actor"],
|
||||
value["properties"]["actor"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_unarchived_round_trips_through_from_value() {
|
||||
let value = json!({
|
||||
"id": "evt_unarchived",
|
||||
"ts": "2026-04-19T12:00:00.000Z",
|
||||
"run_id": fixtures::RUN_1,
|
||||
"event": "run.unarchived",
|
||||
"properties": {
|
||||
"restored_status": "succeeded"
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = RunEvent::from_value(value.clone()).unwrap();
|
||||
match &parsed.body {
|
||||
EventBody::RunUnarchived(props) => {
|
||||
assert_eq!(props.restored_status, crate::RunStatus::Succeeded);
|
||||
assert!(props.actor.is_none());
|
||||
}
|
||||
other => panic!("expected RunUnarchived body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_queued_and_unblocked_round_trip_as_typed_events() {
|
||||
for value in [
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ use std::collections::BTreeMap;
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{BilledTokenCounts, RunNoticeLevel};
|
||||
use super::{ActorRef, BilledTokenCounts, RunNoticeLevel};
|
||||
use crate::settings::SettingsLayer;
|
||||
use crate::status::BlockedReason;
|
||||
use crate::status::{BlockedReason, RunStatus};
|
||||
use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, StatusReason};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -93,6 +93,19 @@ pub struct RunRewoundProps {
|
|||
pub run_commit_sha: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunArchivedProps {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub actor: Option<ActorRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunUnarchivedProps {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub actor: Option<ActorRef>,
|
||||
pub restored_status: RunStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunCompletedProps {
|
||||
pub duration_ms: u64,
|
||||
|
|
|
|||
|
|
@ -17,10 +17,25 @@ pub enum RunStatus {
|
|||
Succeeded,
|
||||
Failed,
|
||||
Dead,
|
||||
Archived,
|
||||
}
|
||||
|
||||
impl RunStatus {
|
||||
/// Whether the run has reached a terminal outcome and stops poll loops,
|
||||
/// finalization, and similar "done" handling. `Archived` is terminal
|
||||
/// because it is only reachable from another terminal status.
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Succeeded | Self::Failed | Self::Dead | Self::Archived
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the run's status is frozen and cannot transition outbound
|
||||
/// (except via the `* -> Dead` escape hatch). `Archived` is intentionally
|
||||
/// NOT immutable — it can transition back to its prior terminal status
|
||||
/// via `unarchive`.
|
||||
pub fn is_immutable(self) -> bool {
|
||||
matches!(self, Self::Succeeded | Self::Failed | Self::Dead)
|
||||
}
|
||||
|
||||
|
|
@ -41,8 +56,13 @@ impl RunStatus {
|
|||
if to == Self::Dead {
|
||||
return true;
|
||||
}
|
||||
if self.is_terminal() {
|
||||
return false;
|
||||
if self.is_immutable() {
|
||||
// Allow immutable terminal statuses to archive.
|
||||
return matches!(to, Self::Archived);
|
||||
}
|
||||
if self == Self::Archived {
|
||||
// Unarchive: restore to any prior terminal status.
|
||||
return matches!(to, Self::Succeeded | Self::Failed);
|
||||
}
|
||||
matches!(
|
||||
(self, to),
|
||||
|
|
@ -84,6 +104,7 @@ impl fmt::Display for RunStatus {
|
|||
Self::Succeeded => "succeeded",
|
||||
Self::Failed => "failed",
|
||||
Self::Dead => "dead",
|
||||
Self::Archived => "archived",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
|
|
@ -104,6 +125,7 @@ impl FromStr for RunStatus {
|
|||
"succeeded" => Ok(Self::Succeeded),
|
||||
"failed" => Ok(Self::Failed),
|
||||
"dead" => Ok(Self::Dead),
|
||||
"archived" => Ok(Self::Archived),
|
||||
_ => Err(ParseRunStatusError(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
|
@ -189,7 +211,7 @@ impl RunStatusRecord {
|
|||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::RunStatus;
|
||||
use super::{InvalidTransition, RunStatus};
|
||||
|
||||
#[test]
|
||||
fn queued_and_blocked_parse_and_format() {
|
||||
|
|
@ -219,4 +241,63 @@ mod tests {
|
|||
assert!(blocked.can_transition_to(paused));
|
||||
assert!(blocked.can_transition_to(RunStatus::from_str("failed").unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_parses_and_round_trips() {
|
||||
let parsed = RunStatus::from_str("archived").expect("archived should parse");
|
||||
assert_eq!(parsed, RunStatus::Archived);
|
||||
assert_eq!(parsed.to_string(), "archived");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_statuses_can_transition_to_archived() {
|
||||
assert!(RunStatus::Succeeded.can_transition_to(RunStatus::Archived));
|
||||
assert!(RunStatus::Failed.can_transition_to(RunStatus::Archived));
|
||||
assert!(RunStatus::Dead.can_transition_to(RunStatus::Archived));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_can_transition_back_to_terminal() {
|
||||
assert!(RunStatus::Archived.can_transition_to(RunStatus::Succeeded));
|
||||
assert!(RunStatus::Archived.can_transition_to(RunStatus::Failed));
|
||||
// Dead is always reachable via the escape hatch.
|
||||
assert!(RunStatus::Archived.can_transition_to(RunStatus::Dead));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_cannot_transition_to_archived() {
|
||||
assert!(!RunStatus::Running.can_transition_to(RunStatus::Archived));
|
||||
assert!(!RunStatus::Queued.can_transition_to(RunStatus::Archived));
|
||||
assert!(!RunStatus::Submitted.can_transition_to(RunStatus::Archived));
|
||||
assert!(!RunStatus::Paused.can_transition_to(RunStatus::Archived));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_to_archived_is_rejected() {
|
||||
// Idempotency of archive is handled at the operation layer, not the guard.
|
||||
assert!(!RunStatus::Archived.can_transition_to(RunStatus::Archived));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_is_terminal_but_not_immutable() {
|
||||
assert!(RunStatus::Archived.is_terminal());
|
||||
assert!(!RunStatus::Archived.is_immutable());
|
||||
assert!(!RunStatus::Archived.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immutable_terminal_statuses_are_also_terminal() {
|
||||
for status in [RunStatus::Succeeded, RunStatus::Failed, RunStatus::Dead] {
|
||||
assert!(status.is_terminal(), "{status} should be terminal");
|
||||
assert!(status.is_immutable(), "{status} should be immutable");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_transition_carries_from_and_to() {
|
||||
let from = RunStatus::Running;
|
||||
let to = RunStatus::Archived;
|
||||
let err = from.transition_to(to).expect_err("should reject");
|
||||
assert_eq!(err, InvalidTransition { from, to });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,26 +44,11 @@ impl Home {
|
|||
self.root.join("settings.toml")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn server_config(&self) -> PathBuf {
|
||||
self.root.join("settings.toml")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn certs_dir(&self) -> PathBuf {
|
||||
self.root.join("certs")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn skills_dir(&self) -> PathBuf {
|
||||
self.root.join("skills")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn storage_dir(&self) -> PathBuf {
|
||||
self.root.join("storage")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn socket_path(&self) -> PathBuf {
|
||||
self.root.join("fabro.sock")
|
||||
|
|
@ -103,22 +88,10 @@ mod tests {
|
|||
home.user_config(),
|
||||
std::path::Path::new("/tmp/fabro-home/settings.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
home.server_config(),
|
||||
std::path::Path::new("/tmp/fabro-home/settings.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
home.certs_dir(),
|
||||
std::path::Path::new("/tmp/fabro-home/certs")
|
||||
);
|
||||
assert_eq!(
|
||||
home.skills_dir(),
|
||||
std::path::Path::new("/tmp/fabro-home/skills")
|
||||
);
|
||||
assert_eq!(
|
||||
home.storage_dir(),
|
||||
std::path::Path::new("/tmp/fabro-home/storage")
|
||||
);
|
||||
assert_eq!(
|
||||
home.socket_path(),
|
||||
std::path::Path::new("/tmp/fabro-home/fabro.sock")
|
||||
|
|
|
|||
|
|
@ -227,6 +227,9 @@ pub enum Error {
|
|||
#[error("Precondition failed: {0}")]
|
||||
Precondition(String),
|
||||
|
||||
#[error("Run not found: {0}")]
|
||||
RunNotFound(String),
|
||||
|
||||
#[error("Pipeline cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
|
@ -272,6 +275,7 @@ impl Error {
|
|||
| Self::Stylesheet(_)
|
||||
| Self::Checkpoint(_)
|
||||
| Self::Precondition(_)
|
||||
| Self::RunNotFound(_)
|
||||
| Self::Cancelled => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -288,7 +292,7 @@ impl Error {
|
|||
| Self::ValidationFailed { .. }
|
||||
| Self::Stylesheet(_)
|
||||
| Self::Checkpoint(_) => FailureCategory::Deterministic,
|
||||
Self::Precondition(_) => FailureCategory::Structural,
|
||||
Self::Precondition(_) | Self::RunNotFound(_) => FailureCategory::Structural,
|
||||
Self::Handler { failure_class, .. } | Self::Engine { failure_class, .. } => {
|
||||
*failure_class
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicI64, Ordering};
|
|||
|
||||
use ::fabro_types::{
|
||||
ActorRef, BilledTokenCounts, BlockedReason, ParallelBranchId, RunBlobId, RunControlAction,
|
||||
RunEvent, RunId, RunProvenance, StageId, StageStatus, StatusReason, run_event as fabro_types,
|
||||
RunEvent, RunId, RunProvenance, RunStatus, StageId, StageStatus, StatusReason,
|
||||
run_event as fabro_types,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
|
|
@ -118,6 +119,15 @@ pub enum Event {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
run_commit_sha: Option<String>,
|
||||
},
|
||||
RunArchived {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
actor: Option<ActorRef>,
|
||||
},
|
||||
RunUnarchived {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
actor: Option<ActorRef>,
|
||||
restored_status: RunStatus,
|
||||
},
|
||||
WorkflowRunCompleted {
|
||||
duration_ms: u64,
|
||||
artifact_count: usize,
|
||||
|
|
@ -628,6 +638,15 @@ impl Event {
|
|||
"Run rewound"
|
||||
);
|
||||
}
|
||||
Self::RunArchived { actor } => {
|
||||
info!(?actor, "Run archived");
|
||||
}
|
||||
Self::RunUnarchived {
|
||||
actor,
|
||||
restored_status,
|
||||
} => {
|
||||
info!(?actor, ?restored_status, "Run unarchived");
|
||||
}
|
||||
Self::WorkflowRunCompleted {
|
||||
duration_ms,
|
||||
artifact_count,
|
||||
|
|
@ -1172,6 +1191,8 @@ pub fn event_name(event: &Event) -> &'static str {
|
|||
Event::RunPaused => "run.paused",
|
||||
Event::RunUnpaused => "run.unpaused",
|
||||
Event::RunRewound { .. } => "run.rewound",
|
||||
Event::RunArchived { .. } => "run.archived",
|
||||
Event::RunUnarchived { .. } => "run.unarchived",
|
||||
Event::WorkflowRunCompleted { .. } => "run.completed",
|
||||
Event::WorkflowRunFailed { .. } => "run.failed",
|
||||
Event::RunNotice { .. } => "run.notice",
|
||||
|
|
@ -1357,7 +1378,9 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
|
|||
},
|
||||
Event::RunCancelRequested { actor }
|
||||
| Event::RunPauseRequested { actor }
|
||||
| Event::RunUnpauseRequested { actor } => StoredEventFields {
|
||||
| Event::RunUnpauseRequested { actor }
|
||||
| Event::RunArchived { actor }
|
||||
| Event::RunUnarchived { actor, .. } => StoredEventFields {
|
||||
actor: actor.clone(),
|
||||
..StoredEventFields::default()
|
||||
},
|
||||
|
|
@ -1584,6 +1607,16 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
previous_status: previous_status.clone(),
|
||||
run_commit_sha: run_commit_sha.clone(),
|
||||
}),
|
||||
Event::RunArchived { actor } => EventBody::RunArchived(fabro_types::RunArchivedProps {
|
||||
actor: actor.clone(),
|
||||
}),
|
||||
Event::RunUnarchived {
|
||||
actor,
|
||||
restored_status,
|
||||
} => EventBody::RunUnarchived(fabro_types::RunUnarchivedProps {
|
||||
actor: actor.clone(),
|
||||
restored_status: *restored_status,
|
||||
}),
|
||||
Event::WorkflowRunCompleted {
|
||||
duration_ms,
|
||||
artifact_count,
|
||||
|
|
@ -3395,6 +3428,60 @@ mod tests {
|
|||
assert!(unpause.actor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_archived_event_name_matches_dot_notation() {
|
||||
assert_eq!(
|
||||
event_name(&Event::RunArchived { actor: None }),
|
||||
"run.archived"
|
||||
);
|
||||
assert_eq!(
|
||||
event_name(&Event::RunUnarchived {
|
||||
actor: None,
|
||||
restored_status: RunStatus::Succeeded,
|
||||
}),
|
||||
"run.unarchived"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_archived_round_trips_actor_in_envelope() {
|
||||
let actor = ActorRef {
|
||||
kind: ActorKind::User,
|
||||
id: Some("alice".to_string()),
|
||||
display: Some("alice".to_string()),
|
||||
};
|
||||
|
||||
let archived = to_run_event(&fixtures::RUN_1, &Event::RunArchived {
|
||||
actor: Some(actor.clone()),
|
||||
});
|
||||
assert_eq!(archived.event_name(), "run.archived");
|
||||
assert_eq!(archived.actor.as_ref().expect("actor set"), &actor);
|
||||
assert!(matches!(archived.body, EventBody::RunArchived(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_unarchived_round_trips_actor_and_restored_status() {
|
||||
let actor = ActorRef {
|
||||
kind: ActorKind::User,
|
||||
id: Some("bob".to_string()),
|
||||
display: Some("bob".to_string()),
|
||||
};
|
||||
|
||||
let unarchived = to_run_event(&fixtures::RUN_1, &Event::RunUnarchived {
|
||||
actor: Some(actor.clone()),
|
||||
restored_status: RunStatus::Failed,
|
||||
});
|
||||
assert_eq!(unarchived.event_name(), "run.unarchived");
|
||||
assert_eq!(unarchived.actor.as_ref().expect("actor set"), &actor);
|
||||
match &unarchived.body {
|
||||
EventBody::RunUnarchived(props) => {
|
||||
assert_eq!(props.restored_status, RunStatus::Failed);
|
||||
assert_eq!(props.actor.as_ref().expect("actor set"), &actor);
|
||||
}
|
||||
other => panic!("expected RunUnarchived body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_assistant_message_populates_agent_actor() {
|
||||
let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {
|
||||
|
|
|
|||
391
lib/crates/fabro-workflow/src/operations/archive.rs
Normal file
391
lib/crates/fabro-workflow/src/operations/archive.rs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
use fabro_store::{Database, Error as StoreError};
|
||||
use fabro_types::{ActorRef, RunId, RunStatus};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::event::{self, Event};
|
||||
|
||||
fn map_open_run_error(run_id: &RunId, err: StoreError) -> Error {
|
||||
match err {
|
||||
StoreError::RunNotFound(id) => Error::RunNotFound(id),
|
||||
other => Error::engine(format!("failed to open run {run_id}: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical "run is archived — mutation rejected" error message. Shared
|
||||
/// by the operations layer, the CLI rewind precheck, and the server HTTP
|
||||
/// guards so the user sees the same actionable guidance everywhere.
|
||||
#[must_use]
|
||||
pub fn archived_rejection_message(run_id: &RunId) -> String {
|
||||
format!("run {run_id} is archived; run `fabro unarchive {run_id}` to restore it and try again")
|
||||
}
|
||||
|
||||
/// Returns `Err(Error::Precondition)` when the given status represents an
|
||||
/// archived run. Use this at any mutation entry point that would otherwise
|
||||
/// transition or emit events against the run (rewind, resume, etc.).
|
||||
pub fn ensure_not_archived(status: Option<RunStatus>, run_id: &RunId) -> Result<(), Error> {
|
||||
if status == Some(RunStatus::Archived) {
|
||||
Err(Error::Precondition(archived_rejection_message(run_id)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of an `archive` call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ArchiveOutcome {
|
||||
/// Event was appended; projection transitions to `Archived`.
|
||||
Archived { prior_status: RunStatus },
|
||||
/// Run was already archived; no event emitted.
|
||||
AlreadyArchived,
|
||||
}
|
||||
|
||||
/// Outcome of an `unarchive` call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UnarchiveOutcome {
|
||||
/// Event was appended; projection transitions back to `restored_status`.
|
||||
Unarchived { restored_status: RunStatus },
|
||||
/// Run was terminal but not archived; no event emitted. Symmetric with
|
||||
/// `ArchiveOutcome::AlreadyArchived`.
|
||||
NotArchived { status: RunStatus },
|
||||
}
|
||||
|
||||
/// Archive a terminal run. Idempotent if already archived.
|
||||
pub async fn archive(
|
||||
store: &Database,
|
||||
run_id: &RunId,
|
||||
actor: Option<ActorRef>,
|
||||
) -> Result<ArchiveOutcome, Error> {
|
||||
let run_store = store
|
||||
.open_run(run_id)
|
||||
.await
|
||||
.map_err(|err| map_open_run_error(run_id, err))?;
|
||||
let projection = run_store
|
||||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let current = projection
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|record| record.status)
|
||||
.ok_or_else(|| {
|
||||
Error::Precondition(format!("run {run_id} has no status; cannot archive"))
|
||||
})?;
|
||||
|
||||
if current == RunStatus::Archived {
|
||||
return Ok(ArchiveOutcome::AlreadyArchived);
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
current,
|
||||
RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead
|
||||
) {
|
||||
return Err(Error::Precondition(format!(
|
||||
"run {run_id} must be terminal (succeeded, failed, or dead) to archive; \
|
||||
current status is {current}"
|
||||
)));
|
||||
}
|
||||
|
||||
event::append_event(&run_store, run_id, &Event::RunArchived { actor })
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
|
||||
Ok(ArchiveOutcome::Archived {
|
||||
prior_status: current,
|
||||
})
|
||||
}
|
||||
|
||||
/// Unarchive a previously archived run, restoring its prior terminal status.
|
||||
/// Idempotent on terminal-but-not-archived runs (returns `NotArchived` without
|
||||
/// emitting an event).
|
||||
pub async fn unarchive(
|
||||
store: &Database,
|
||||
run_id: &RunId,
|
||||
actor: Option<ActorRef>,
|
||||
) -> Result<UnarchiveOutcome, Error> {
|
||||
let run_store = store
|
||||
.open_run(run_id)
|
||||
.await
|
||||
.map_err(|err| map_open_run_error(run_id, err))?;
|
||||
let projection = run_store
|
||||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let current = projection
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|record| record.status)
|
||||
.ok_or_else(|| {
|
||||
Error::Precondition(format!("run {run_id} has no status; cannot unarchive"))
|
||||
})?;
|
||||
|
||||
if current == RunStatus::Archived {
|
||||
let restored = projection.prior_status.ok_or_else(|| {
|
||||
Error::engine(format!(
|
||||
"run {run_id} is archived but prior_status is missing; projection is corrupt"
|
||||
))
|
||||
})?;
|
||||
event::append_event(&run_store, run_id, &Event::RunUnarchived {
|
||||
actor,
|
||||
restored_status: restored,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
return Ok(UnarchiveOutcome::Unarchived {
|
||||
restored_status: restored,
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(
|
||||
current,
|
||||
RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead
|
||||
) {
|
||||
return Ok(UnarchiveOutcome::NotArchived { status: current });
|
||||
}
|
||||
|
||||
Err(Error::Precondition(format!(
|
||||
"run {run_id} is not archived (status: {current}); nothing to unarchive"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_store::Database;
|
||||
use fabro_types::{RunId, fixtures};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn memory_store() -> Arc<Database> {
|
||||
Arc::new(Database::new(
|
||||
Arc::new(InMemory::new()),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
async fn seed_succeeded(store: &Database, run_id: &RunId) {
|
||||
let run_store = store.create_run(run_id).await.unwrap();
|
||||
event::append_event(&run_store, run_id, &Event::WorkflowRunCompleted {
|
||||
duration_ms: 10,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn seed_failed(store: &Database, run_id: &RunId) {
|
||||
let run_store = store.create_run(run_id).await.unwrap();
|
||||
event::append_event(&run_store, run_id, &Event::WorkflowRunFailed {
|
||||
error: crate::error::Error::engine("boom"),
|
||||
duration_ms: 10,
|
||||
reason: None,
|
||||
git_commit_sha: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn seed_running(store: &Database, run_id: &RunId) {
|
||||
let run_store = store.create_run(run_id).await.unwrap();
|
||||
event::append_event(&run_store, run_id, &Event::RunRunning { reason: None })
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn current_status(store: &Database, run_id: &RunId) -> RunStatus {
|
||||
let run_store = store.open_run_reader(run_id).await.unwrap();
|
||||
run_store.state().await.unwrap().status.unwrap().status
|
||||
}
|
||||
|
||||
async fn event_count(store: &Database, run_id: &RunId) -> usize {
|
||||
let run_store = store.open_run_reader(run_id).await.unwrap();
|
||||
run_store.list_events().await.unwrap().len()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_on_succeeded_emits_event_and_transitions_to_archived() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_1;
|
||||
seed_succeeded(&store, &run_id).await;
|
||||
|
||||
let outcome = archive(&store, &run_id, None).await.unwrap();
|
||||
assert_eq!(outcome, ArchiveOutcome::Archived {
|
||||
prior_status: RunStatus::Succeeded,
|
||||
});
|
||||
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived);
|
||||
|
||||
let projection = store
|
||||
.open_run_reader(&run_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.state()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(projection.prior_status, Some(RunStatus::Succeeded));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_on_failed_captures_failed_as_prior_status() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_2;
|
||||
seed_failed(&store, &run_id).await;
|
||||
|
||||
let outcome = archive(&store, &run_id, None).await.unwrap();
|
||||
assert_eq!(outcome, ArchiveOutcome::Archived {
|
||||
prior_status: RunStatus::Failed,
|
||||
});
|
||||
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_on_already_archived_is_idempotent_and_emits_no_event() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_1;
|
||||
seed_succeeded(&store, &run_id).await;
|
||||
archive(&store, &run_id, None).await.unwrap();
|
||||
|
||||
let events_before = event_count(&store, &run_id).await;
|
||||
let outcome = archive(&store, &run_id, None).await.unwrap();
|
||||
let events_after = event_count(&store, &run_id).await;
|
||||
|
||||
assert_eq!(outcome, ArchiveOutcome::AlreadyArchived);
|
||||
assert_eq!(events_before, events_after);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_on_running_rejects_with_precondition_error() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_1;
|
||||
seed_running(&store, &run_id).await;
|
||||
|
||||
let err = archive(&store, &run_id, None).await.unwrap_err();
|
||||
let Error::Precondition(message) = err else {
|
||||
panic!("expected Precondition, got {err:?}");
|
||||
};
|
||||
assert!(
|
||||
message.contains("must be terminal"),
|
||||
"message should explain terminal requirement, got: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unarchive_restores_succeeded_and_clears_prior_status() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_1;
|
||||
seed_succeeded(&store, &run_id).await;
|
||||
archive(&store, &run_id, None).await.unwrap();
|
||||
|
||||
let outcome = unarchive(&store, &run_id, None).await.unwrap();
|
||||
assert_eq!(outcome, UnarchiveOutcome::Unarchived {
|
||||
restored_status: RunStatus::Succeeded,
|
||||
});
|
||||
let projection = store
|
||||
.open_run_reader(&run_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.state()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
projection.status.as_ref().map(|r| r.status),
|
||||
Some(RunStatus::Succeeded)
|
||||
);
|
||||
assert_eq!(projection.prior_status, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unarchive_restores_failed_when_prior_was_failed() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_2;
|
||||
seed_failed(&store, &run_id).await;
|
||||
archive(&store, &run_id, None).await.unwrap();
|
||||
|
||||
let outcome = unarchive(&store, &run_id, None).await.unwrap();
|
||||
assert_eq!(outcome, UnarchiveOutcome::Unarchived {
|
||||
restored_status: RunStatus::Failed,
|
||||
});
|
||||
assert_eq!(current_status(&store, &run_id).await, RunStatus::Failed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unarchive_on_terminal_non_archived_run_is_idempotent_no_op() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_1;
|
||||
seed_succeeded(&store, &run_id).await;
|
||||
|
||||
let events_before = event_count(&store, &run_id).await;
|
||||
let outcome = unarchive(&store, &run_id, None).await.unwrap();
|
||||
let events_after = event_count(&store, &run_id).await;
|
||||
|
||||
assert_eq!(outcome, UnarchiveOutcome::NotArchived {
|
||||
status: RunStatus::Succeeded,
|
||||
});
|
||||
assert_eq!(events_before, events_after);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_on_unknown_run_returns_run_not_found() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_3;
|
||||
|
||||
let err = archive(&store, &run_id, None).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::RunNotFound(_)),
|
||||
"expected RunNotFound, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unarchive_on_unknown_run_returns_run_not_found() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_3;
|
||||
|
||||
let err = unarchive(&store, &run_id, None).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::RunNotFound(_)),
|
||||
"expected RunNotFound, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unarchive_on_running_rejects_with_precondition_error() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_1;
|
||||
seed_running(&store, &run_id).await;
|
||||
|
||||
let err = unarchive(&store, &run_id, None).await.unwrap_err();
|
||||
let Error::Precondition(message) = err else {
|
||||
panic!("expected Precondition, got {err:?}");
|
||||
};
|
||||
assert!(
|
||||
message.contains("not archived"),
|
||||
"message should explain run is not archived, got: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_unarchive_archive_cycle_produces_three_events() {
|
||||
let store = memory_store();
|
||||
let run_id = fixtures::RUN_1;
|
||||
seed_succeeded(&store, &run_id).await;
|
||||
|
||||
let events_before = event_count(&store, &run_id).await;
|
||||
archive(&store, &run_id, None).await.unwrap();
|
||||
unarchive(&store, &run_id, None).await.unwrap();
|
||||
archive(&store, &run_id, None).await.unwrap();
|
||||
let events_after = event_count(&store, &run_id).await;
|
||||
|
||||
assert_eq!(events_after - events_before, 3);
|
||||
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod archive;
|
||||
mod create;
|
||||
mod fork;
|
||||
mod rebuild_meta;
|
||||
|
|
@ -9,6 +10,10 @@ mod start;
|
|||
mod test_support;
|
||||
mod validate;
|
||||
|
||||
pub use archive::{
|
||||
ArchiveOutcome, UnarchiveOutcome, archive, archived_rejection_message, ensure_not_archived,
|
||||
unarchive,
|
||||
};
|
||||
pub use create::{CreateRunInput, CreatedRun, create, make_run_dir};
|
||||
pub use fork::{ForkRunInput, fork};
|
||||
pub use rebuild_meta::{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result<Started,
|
|||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
|
||||
if let Some(record) = state.status {
|
||||
super::archive::ensure_not_archived(Some(record.status), &services.run_id)?;
|
||||
if record.status == RunStatus::Succeeded {
|
||||
return Err(Error::Precondition(
|
||||
"run already finished successfully — nothing to resume".to_string(),
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ use fabro_checkpoint::branch::{BranchStore, CommitInfo};
|
|||
use fabro_checkpoint::git::Store;
|
||||
use fabro_graphviz::graph::Graph;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::{RunId, RunStatus};
|
||||
use git2::{Oid, Repository, Signature};
|
||||
|
||||
use super::archive::ensure_not_archived;
|
||||
use crate::git::{MetadataStore, RUN_BRANCH_PREFIX, push_run_branches};
|
||||
use crate::records::{Checkpoint, RunRecord};
|
||||
|
||||
|
|
@ -114,9 +115,13 @@ impl RunTimeline {
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RewindInput {
|
||||
pub run_id: RunId,
|
||||
pub target: RewindTarget,
|
||||
pub push: bool,
|
||||
pub run_id: RunId,
|
||||
pub target: RewindTarget,
|
||||
pub push: bool,
|
||||
/// Current durable run status. Callers must load this from the projection
|
||||
/// store before calling rewind so the archived-run precondition can be
|
||||
/// enforced here rather than by an upstream check that can drift.
|
||||
pub current_status: RunStatus,
|
||||
}
|
||||
|
||||
pub fn build_timeline(store: &Store, run_id: &str) -> Result<RunTimeline> {
|
||||
|
|
@ -249,6 +254,8 @@ fn detect_parallel_interior(graph: &Graph) -> HashMap<String, String> {
|
|||
}
|
||||
|
||||
pub fn rewind(store: &Store, input: &RewindInput) -> Result<()> {
|
||||
ensure_not_archived(Some(input.current_status), &input.run_id)
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
let timeline = build_timeline(store, &input.run_id.to_string())?;
|
||||
let entry = timeline.resolve(&input.target)?;
|
||||
rewind_to_entry(store, &input.run_id, entry, input.push)
|
||||
|
|
@ -509,9 +516,10 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
rewind(&store, &RewindInput {
|
||||
run_id: fixtures::RUN_1,
|
||||
target: RewindTarget::Ordinal(1),
|
||||
push: false,
|
||||
run_id: fixtures::RUN_1,
|
||||
target: RewindTarget::Ordinal(1),
|
||||
push: false,
|
||||
current_status: RunStatus::Succeeded,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -519,6 +527,24 @@ mod tests {
|
|||
assert_eq!(resolved, oid1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_rejects_archived_runs() {
|
||||
let (_dir, store) = temp_repo();
|
||||
|
||||
let err = rewind(&store, &RewindInput {
|
||||
run_id: fixtures::RUN_1,
|
||||
target: RewindTarget::Ordinal(1),
|
||||
push: false,
|
||||
current_status: RunStatus::Archived,
|
||||
})
|
||||
.unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("is archived") && message.contains("fabro unarchive"),
|
||||
"expected archived-rejection message, got: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_run_id_prefix_match() {
|
||||
let (_dir, store) = temp_repo();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use std::path::{Path, PathBuf};
|
|||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_config::Storage;
|
||||
use fabro_config::user::default_storage_dir;
|
||||
use fabro_store::{Database, RunSummary};
|
||||
use fabro_types::RunId;
|
||||
use serde::Serialize;
|
||||
|
|
@ -146,7 +147,7 @@ pub fn scratch_base(storage_dir: &Path) -> PathBuf {
|
|||
}
|
||||
|
||||
pub fn default_scratch_base() -> PathBuf {
|
||||
scratch_base(&fabro_util::Home::from_env().storage_dir())
|
||||
scratch_base(&default_storage_dir())
|
||||
}
|
||||
|
||||
fn scan_orphan_runs(base: &Path) -> Result<Vec<RunInfo>> {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,46 @@ import type { StoreRunSummary } from '../models';
|
|||
*/
|
||||
export const RunsApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
|
||||
* @summary Archive Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
archiveRun: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('archiveRun', 'id', id)
|
||||
const localVarPath = `/api/v1/runs/{id}/archive`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// 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;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Cancels a running or queued run. Returns 409 if the run has already completed or been cancelled.
|
||||
* @summary Cancel Run
|
||||
|
|
@ -212,14 +252,15 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot. Archived runs are hidden by default; pass `include_archived=true` to include them in the response.
|
||||
* @summary List Runs
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to `false`.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listRuns: async (pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
listRuns: async (pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/api/v1/runs`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
|
|
@ -246,6 +287,10 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
localVarQueryParameter['page[offset]'] = pageOffset;
|
||||
}
|
||||
|
||||
if (includeArchived !== undefined) {
|
||||
localVarQueryParameter['include_archived'] = includeArchived;
|
||||
}
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
|
|
@ -545,6 +590,46 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
|
||||
* @summary Unarchive Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
unarchiveRun: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('unarchiveRun', 'id', id)
|
||||
const localVarPath = `/api/v1/runs/{id}/unarchive`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// 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;
|
||||
|
||||
// authentication SessionCookie required
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Resumes a paused run. Returns 409 if the run is not paused.
|
||||
* @summary Unpause Run
|
||||
|
|
@ -594,6 +679,19 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
export const RunsApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosParamCreator = RunsApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
|
||||
* @summary Archive Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async archiveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.archiveRun(id, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['RunsApi.archiveRun']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Cancels a running or queued run. Returns 409 if the run has already completed or been cancelled.
|
||||
* @summary Cancel Run
|
||||
|
|
@ -648,15 +746,16 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot. Archived runs are hidden by default; pass `include_archived=true` to include them in the response.
|
||||
* @summary List Runs
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to `false`.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, options);
|
||||
async listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, includeArchived, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['RunsApi.listRuns']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
|
|
@ -753,6 +852,19 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
const localVarOperationServerBasePath = operationServerMap['RunsApi.startRun']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
|
||||
* @summary Unarchive Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async unarchiveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.unarchiveRun(id, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['RunsApi.unarchiveRun']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Resumes a paused run. Returns 409 if the run is not paused.
|
||||
* @summary Unpause Run
|
||||
|
|
@ -775,6 +887,16 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
export const RunsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||
const localVarFp = RunsApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
|
||||
* @summary Archive Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
archiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
|
||||
return localVarFp.archiveRun(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Cancels a running or queued run. Returns 409 if the run has already completed or been cancelled.
|
||||
* @summary Cancel Run
|
||||
|
|
@ -817,15 +939,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
return localVarFp.listBoardRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot. Archived runs are hidden by default; pass `include_archived=true` to include them in the response.
|
||||
* @summary List Runs
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to `false`.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
|
||||
return localVarFp.listRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath));
|
||||
listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
|
||||
return localVarFp.listRuns(pageLimit, pageOffset, includeArchived, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Pauses a running run. Returns 409 if the run is not running.
|
||||
|
|
@ -898,6 +1021,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
|
||||
return localVarFp.startRun(id, startRunRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
|
||||
* @summary Unarchive Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
unarchiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
|
||||
return localVarFp.unarchiveRun(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Resumes a paused run. Returns 409 if the run is not paused.
|
||||
* @summary Unpause Run
|
||||
|
|
@ -915,6 +1048,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
* RunsApi - object-oriented interface
|
||||
*/
|
||||
export class RunsApi extends BaseAPI {
|
||||
/**
|
||||
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
|
||||
* @summary Archive Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public archiveRun(id: string, options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).archiveRun(id, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a running or queued run. Returns 409 if the run has already completed or been cancelled.
|
||||
* @summary Cancel Run
|
||||
|
|
@ -961,15 +1105,16 @@ export class RunsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot. Archived runs are hidden by default; pass `include_archived=true` to include them in the response.
|
||||
* @summary List Runs
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to `false`.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath));
|
||||
public listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, includeArchived, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1050,6 +1195,17 @@ export class RunsApi extends BaseAPI {
|
|||
return RunsApiFp(this.configuration).startRun(id, startRunRequest, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
|
||||
* @summary Unarchive Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public unarchiveRun(id: string, options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).unarchiveRun(id, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a paused run. Returns 409 if the run is not paused.
|
||||
* @summary Unpause Run
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@
|
|||
export interface InstallFinishResponse {
|
||||
'status': InstallFinishResponseStatusEnum;
|
||||
'restart_url': string;
|
||||
'dev_token': string;
|
||||
/**
|
||||
* Dev token used to bootstrap login. Only included when the operator chose the personal access token flow; GitHub App installs rely on OAuth and do not receive a dev token.
|
||||
*/
|
||||
'dev_token'?: string;
|
||||
}
|
||||
|
||||
export const InstallFinishResponseStatusEnum = {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
|
||||
/**
|
||||
* Lifecycle status of a run.
|
||||
* Lifecycle status of a run. `archived` is a terminal status reached by an explicit user action on a previously terminal (`succeeded`, `failed`, or `dead`) run; archived runs are hidden from default listings and are read-only until unarchived.
|
||||
*/
|
||||
|
||||
export const RunStatus = {
|
||||
|
|
@ -28,7 +28,8 @@ export const RunStatus = {
|
|||
REMOVING: 'removing',
|
||||
SUCCEEDED: 'succeeded',
|
||||
FAILED: 'failed',
|
||||
DEAD: 'dead'
|
||||
DEAD: 'dead',
|
||||
ARCHIVED: 'archived'
|
||||
} as const;
|
||||
|
||||
export type RunStatus = typeof RunStatus[keyof typeof RunStatus];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue