Merge origin/main into archived-run-status feature branch

Origin brought 21 commits of UI/install/test-helpers work that lived in
parallel with the archive feature. Only the SPA build outputs conflicted
(old bundle hashes on both sides). Resolution: accept origin's
resolution on the deleted files, then re-run scripts/refresh-fabro-spa.sh
from the merged source so the embedded bundle reflects both sides —
origin's Settings-nav/theme/stage-sidebar work plus this branch's
archived-status TypeScript changes in apps/fabro-web/app/data/runs.ts.

Verification:
- cargo build --workspace: clean
- cargo nextest run --workspace: 4198 passed, 182 skipped
- cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings: clean
- cargo +nightly-2026-04-14 fmt --check --all: clean
- apps/fabro-web bun run typecheck: clean
- apps/fabro-web bun test app/data/runs.test.ts: 8 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 18:57:07 -04:00
commit 4959778ea6
No known key found for this signature in database
65 changed files with 4814 additions and 3238 deletions

View file

@ -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;

View 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>
);
}

View file

@ -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 {

View 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>
);
}

View file

@ -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>

View 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>
);
}

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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");
});
});

View file

@ -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>

View file

@ -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>
);

View file

@ -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}

View file

@ -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">

View file

@ -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}

View file

@ -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>

View file

@ -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" }}
/>

View file

@ -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) => {

View file

@ -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>

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -2244,7 +2244,6 @@ components:
required:
- status
- restart_url
- dev_token
properties:
status:
type: string
@ -2254,6 +2253,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 ───────────────────────────────────────────────────────

View file

@ -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

View file

@ -21,10 +21,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,

View file

@ -2,11 +2,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::{
@ -22,12 +18,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(),
@ -40,44 +33,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(),
@ -89,22 +45,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,
@ -284,37 +224,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(),
@ -470,45 +393,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]

View file

@ -16,8 +16,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,
@ -1166,7 +1166,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
}
@ -1355,7 +1357,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,
@ -1478,9 +1482,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")?;
@ -1623,7 +1627,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();
@ -1656,18 +1660,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,
};

View file

@ -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()

View file

@ -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;

View file

@ -8,7 +8,7 @@ use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs};
use crate::command_context::CommandContext;
use crate::server_runs::ServerSummaryLookup;
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;
@ -39,7 +39,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,

View file

@ -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,

View file

@ -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,

View file

@ -1,10 +1,11 @@
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)]
@ -50,9 +51,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
}
@ -71,14 +71,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)]
@ -111,7 +126,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(),
}
@ -132,7 +147,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]
@ -143,7 +158,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());
}
}

View file

@ -17,11 +17,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<()> {
@ -32,6 +38,8 @@ pub(crate) async fn execute(
bind,
serve_args,
storage_dir,
foreground_log_bootstrap
.context("internal error: missing foreground server log bootstrap")?,
styles,
printer,
))
@ -49,6 +57,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,
@ -80,7 +111,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))
@ -124,7 +155,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!(
@ -133,7 +164,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)
@ -258,6 +289,7 @@ async fn execute_foreground(
bind: BindRequest,
serve_args: ServeArgs,
storage_dir: PathBuf,
_log_bootstrap: ForegroundServerLogBootstrap,
styles: &'static Styles,
printer: Printer,
) -> Result<()> {
@ -282,17 +314,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();
@ -342,9 +363,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 {}",

View file

@ -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 {

View file

@ -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(())
}

View file

@ -48,11 +48,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 {
@ -73,13 +76,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,
@ -88,7 +91,7 @@ fn build_inventory(home_root: &Path, storage_dir: &Path) -> Inventory {
shell_configs,
binary_path,
binary_is_managed,
}
})
}
fn dir_size(path: &Path) -> u64 {
@ -252,7 +255,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;
}
@ -730,7 +733,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);
@ -745,7 +748,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());

View file

@ -1,18 +1,26 @@
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"
@ -22,37 +30,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(())
}
@ -88,3 +87,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")
}
}

View file

@ -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(),
@ -254,7 +238,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 =
@ -409,15 +399,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"])
@ -523,6 +629,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"]);

View file

@ -231,7 +231,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 {
@ -1211,7 +1211,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(),
})

View file

@ -22,31 +22,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 {

View file

@ -641,7 +641,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();
@ -663,9 +663,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"));
@ -697,8 +779,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"));

View file

@ -2,13 +2,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() {
@ -151,6 +148,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(&current_record);
let legacy_record = fabro_home.join("server.json");
std::fs::rename(&current_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(&current_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,

View file

@ -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(&current_record);
let legacy_record = fabro_home.join("server.json");
std::fs::rename(&current_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(&current_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}"
);
}

View file

@ -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() {

View file

@ -1,6 +1,6 @@
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]
@ -164,3 +164,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(&current_record);
let legacy_record = fabro_home.join("server.json");
std::fs::rename(&current_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(&current_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"
);
}

View file

@ -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")
}

View file

@ -6,7 +6,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;

View file

@ -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())),
}
}

View file

@ -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")

View file

@ -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]

View file

@ -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()

View file

@ -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()));
}
let previous_settings = std::fs::read_to_string(state.config_path.as_ref()).ok();
@ -944,15 +950,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 {

View file

@ -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
};

View file

@ -381,6 +381,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();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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-h3nj59g0.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>

View file

@ -150,6 +150,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

View file

@ -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")

View file

@ -4,6 +4,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;
@ -140,7 +141,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>> {

View file

@ -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 = {