mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
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:
commit
4959778ea6
65 changed files with 4814 additions and 3238 deletions
|
|
@ -53,24 +53,30 @@
|
|||
--color-line: rgba(255, 255, 255, 0.06);
|
||||
--color-line-strong: rgba(255, 255, 255, 0.12);
|
||||
--color-focus: rgba(103, 178, 215, 0.4);
|
||||
/* Text color that sits on --color-teal-500 primary fills.
|
||||
Dark mode: teal-500 is light (#67B2D7) so dark text reads best.
|
||||
Light mode: teal-500 is dark (#0e7490) so white reads best. */
|
||||
--color-on-primary: #0F1729;
|
||||
}
|
||||
|
||||
/* ── Light theme overrides ── */
|
||||
|
||||
.light {
|
||||
/* Semantic tokens */
|
||||
--color-page: #ffffff;
|
||||
--color-panel: #f8fafc;
|
||||
--color-panel-alt: #f1f5f9;
|
||||
--color-overlay: rgba(0, 0, 0, 0.04);
|
||||
--color-overlay-strong: rgba(0, 0, 0, 0.10);
|
||||
/* Semantic tokens — page is softly tinted so panels/cards (white)
|
||||
read as the lighter surface, mirroring the dark-theme hierarchy. */
|
||||
--color-page: #eef2f7;
|
||||
--color-panel: #ffffff;
|
||||
--color-panel-alt: #e4eaf1;
|
||||
--color-overlay: rgba(15, 23, 41, 0.05);
|
||||
--color-overlay-strong: rgba(15, 23, 41, 0.10);
|
||||
--color-fg: #0f172a;
|
||||
--color-fg-2: #1e293b;
|
||||
--color-fg-3: #475569;
|
||||
--color-fg-muted: #94a3b8;
|
||||
--color-line: rgba(0, 0, 0, 0.08);
|
||||
--color-line-strong: rgba(0, 0, 0, 0.15);
|
||||
--color-fg-muted: #64748b;
|
||||
--color-line: rgba(15, 23, 41, 0.10);
|
||||
--color-line-strong: rgba(15, 23, 41, 0.18);
|
||||
--color-focus: rgba(14, 116, 144, 0.5);
|
||||
--color-on-primary: #ffffff;
|
||||
|
||||
/* Accent — darkened for WCAG AA on white */
|
||||
--color-mint: #0d9488;
|
||||
|
|
|
|||
84
apps/fabro-web/app/components/graph-toolbar.tsx
Normal file
84
apps/fabro-web/app/components/graph-toolbar.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
export const GRAPH_ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
export const GRAPH_DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
export function GraphToolbar({
|
||||
direction,
|
||||
setDirection,
|
||||
fitToWindow,
|
||||
zoomIndex,
|
||||
setZoomIndex,
|
||||
}: {
|
||||
direction: Direction;
|
||||
setDirection: (d: Direction) => void;
|
||||
fitToWindow: () => void;
|
||||
zoomIndex: number;
|
||||
setZoomIndex: (updater: (i: number) => number) => void;
|
||||
}) {
|
||||
const group =
|
||||
"flex items-center gap-0.5 px-0.5 [&:not(:first-child)]:border-l [&:not(:first-child)]:border-line-strong [&:not(:first-child)]:pl-1 [&:not(:first-child)]:ml-1";
|
||||
const btn =
|
||||
"flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500";
|
||||
const btnActive = "bg-overlay-strong text-fg-3";
|
||||
const btnDisabled =
|
||||
"disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Graph controls"
|
||||
className="absolute right-3 top-3 z-10 flex items-center rounded-md border border-line bg-panel p-0.5"
|
||||
>
|
||||
<div className={group}>
|
||||
<button
|
||||
type="button"
|
||||
title="Left to right"
|
||||
onClick={() => setDirection("LR")}
|
||||
aria-pressed={direction === "LR"}
|
||||
className={`${btn} ${direction === "LR" ? btnActive : ""}`}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Top to bottom"
|
||||
onClick={() => setDirection("TB")}
|
||||
aria-pressed={direction === "TB"}
|
||||
className={`${btn} ${direction === "TB" ? btnActive : ""}`}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={group}>
|
||||
<button type="button" title="Fit to window" onClick={fitToWindow} className={btn}>
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className={group}>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={zoomIndex === 0}
|
||||
className={`${btn} ${btnDisabled}`}
|
||||
>
|
||||
<MinusIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
onClick={() => setZoomIndex((i) => Math.min(GRAPH_ZOOM_STEPS.length - 1, i + 1))}
|
||||
disabled={zoomIndex === GRAPH_ZOOM_STEPS.length - 1}
|
||||
className={`${btn} ${btnDisabled}`}
|
||||
>
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link, useRevalidator } from "react-router";
|
||||
import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
NoSymbolIcon,
|
||||
PauseCircleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
|
||||
|
|
@ -19,7 +25,7 @@ export const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; c
|
|||
running: { icon: ArrowPathIcon, color: "text-teal-500" },
|
||||
pending: { icon: PauseCircleIcon, color: "text-fg-muted" },
|
||||
failed: { icon: XCircleIcon, color: "text-coral" },
|
||||
cancelled: { icon: XCircleIcon, color: "text-fg-muted" },
|
||||
cancelled: { icon: NoSymbolIcon, color: "text-fg-muted" },
|
||||
};
|
||||
|
||||
interface StageSidebarProps {
|
||||
|
|
|
|||
102
apps/fabro-web/app/components/state.tsx
Normal file
102
apps/fabro-web/app/components/state.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { ComponentType, ReactNode, SVGProps } from "react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import { SECONDARY_BUTTON_CLASS } from "./ui";
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
|
||||
// Shared chrome for non-critical "the content isn't there" states. Full-page
|
||||
// crashes are handled by the root ErrorBoundary; these render inside the
|
||||
// current content column.
|
||||
function StatePanel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center rounded-md border border-line bg-panel p-8 text-center">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<StatePanel>
|
||||
{Icon ? (
|
||||
<Icon className="mb-3 size-6 text-fg-muted" aria-hidden="true" />
|
||||
) : null}
|
||||
<p className="text-sm font-medium text-fg">{title}</p>
|
||||
{description ? (
|
||||
<p className="mt-1 max-w-[48ch] text-sm/6 text-fg-3">{description}</p>
|
||||
) : null}
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
title = "Something went wrong",
|
||||
description,
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<StatePanel>
|
||||
<ExclamationTriangleIcon
|
||||
className="mb-3 size-6 text-coral"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium text-fg">{title}</p>
|
||||
{description ? (
|
||||
<p className="mt-1 max-w-[48ch] text-sm/6 text-fg-3">{description}</p>
|
||||
) : null}
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className={`${SECONDARY_BUTTON_CLASS} mt-4`}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
) : null}
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label }: { label?: string }) {
|
||||
return (
|
||||
<StatePanel>
|
||||
<Spinner className="mb-3 size-5 text-teal-500" />
|
||||
<p className="text-sm text-fg-3">{label ?? "Loading…"}</p>
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={`shrink-0 animate-spin ${className}`}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
|
||||
<path
|
||||
d="M14 8a6 6 0 0 0-6-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,11 +30,11 @@ export function ToolRow({ tool }: { tool: ToolUse }) {
|
|||
{open && (
|
||||
<div className="space-y-px bg-overlay px-2.5 pb-2 pt-1">
|
||||
<div className="rounded bg-overlay px-2.5 py-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Input</div>
|
||||
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-fg-muted">Input</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{tool.input}</pre>
|
||||
</div>
|
||||
<div className="rounded bg-overlay px-2.5 py-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Result</div>
|
||||
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-fg-muted">Result</div>
|
||||
<pre className={`whitespace-pre-wrap font-mono text-xs leading-relaxed ${tool.isError ? "text-coral" : "text-fg-3"}`}>{tool.result}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
64
apps/fabro-web/app/components/ui.tsx
Normal file
64
apps/fabro-web/app/components/ui.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Shared UI primitives. The install wizard set the visual baseline; this file
|
||||
// exposes the primary button, secondary button, input, error message, and
|
||||
// copy button so the auth and in-app surfaces can match.
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
ClipboardIcon,
|
||||
} from "@heroicons/react/16/solid";
|
||||
|
||||
export const INPUT_CLASS =
|
||||
"block w-full rounded-lg bg-panel-alt px-3.5 py-2.5 text-base text-fg outline-1 -outline-offset-1 outline-white/10 placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 sm:text-sm";
|
||||
|
||||
export const PRIMARY_BUTTON_CLASS =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500";
|
||||
|
||||
export const SECONDARY_BUTTON_CLASS =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
|
||||
|
||||
export function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md bg-coral/10 px-3 py-2 text-sm/6 text-fg-2 outline-1 -outline-offset-1 outline-coral/40"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
label,
|
||||
className = "",
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard may be blocked; leave state unchanged.
|
||||
}
|
||||
}}
|
||||
className={`inline-flex size-6 shrink-0 items-center justify-center rounded text-fg-muted outline-teal-500 hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 ${className}`}
|
||||
aria-label={copied ? "Copied" : label}
|
||||
title={copied ? "Copied" : label}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardDocumentCheckIcon className="size-4 text-mint" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,16 +2,28 @@ export const INSTALL_PROVIDERS = [
|
|||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "Claude API key.",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
keyHelp: {
|
||||
url: "https://console.anthropic.com/settings/keys",
|
||||
text: "Create one in the Anthropic Console under Settings → API keys.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
hint: "Responses API key.",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
keyHelp: {
|
||||
url: "https://platform.openai.com/api-keys",
|
||||
text: "Create one in the OpenAI platform under API keys.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
label: "Gemini",
|
||||
hint: "Google AI Studio API key.",
|
||||
envVar: "GEMINI_API_KEY",
|
||||
keyHelp: {
|
||||
url: "https://aistudio.google.com/apikey",
|
||||
text: "Create one in Google AI Studio.",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ describe("getVisibleNavigation", () => {
|
|||
expect(names).toContain("Workflows");
|
||||
expect(names).toContain("Runs");
|
||||
expect(names).toContain("Insights");
|
||||
expect(names).toContain("Settings");
|
||||
});
|
||||
|
||||
test("hides Workflows and Insights in production mode", () => {
|
||||
|
|
@ -16,5 +17,6 @@ describe("getVisibleNavigation", () => {
|
|||
expect(names).not.toContain("Workflows");
|
||||
expect(names).not.toContain("Insights");
|
||||
expect(names).toContain("Runs");
|
||||
expect(names).toContain("Settings");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Bars3Icon,
|
||||
BeakerIcon,
|
||||
ChartBarIcon,
|
||||
Cog6ToothIcon,
|
||||
MoonIcon,
|
||||
PlayIcon,
|
||||
RectangleStackIcon,
|
||||
|
|
@ -30,6 +31,7 @@ const allNavigation = [
|
|||
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon, demoOnly: true },
|
||||
{ name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false },
|
||||
{ name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true },
|
||||
{ name: "Settings", href: "/settings", icon: Cog6ToothIcon, demoOnly: false },
|
||||
];
|
||||
|
||||
export function getVisibleNavigation(demoMode: boolean) {
|
||||
|
|
@ -70,8 +72,8 @@ export default function AppShell({ loaderData }: any) {
|
|||
|
||||
return (
|
||||
<DemoModeProvider value={demoMode}>
|
||||
<div className="min-h-full">
|
||||
<Disclosure as="nav" className="bg-panel/50">
|
||||
<div className="isolate min-h-full">
|
||||
<Disclosure as="nav" className="bg-panel">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
|
|
@ -255,10 +257,12 @@ export default function AppShell({ loaderData }: any) {
|
|||
</Disclosure>
|
||||
|
||||
{!hideHeader && (
|
||||
<header className="relative bg-panel after:pointer-events-none after:absolute after:inset-x-0 after:inset-y-0 after:bottom-0 after:border-y after:border-line-strong">
|
||||
<header className="relative bg-panel after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:border-b after:border-line-strong">
|
||||
<div className={`mx-auto ${maxWidth} px-4 py-4 sm:px-6 lg:px-8`}>
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-lg/6 font-semibold text-fg">{title}</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-fg">
|
||||
{title}
|
||||
</h1>
|
||||
{headerExtra && <div className="ml-auto">{headerExtra}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { AuthLayout } from "../components/auth-layout";
|
||||
import {
|
||||
ErrorMessage,
|
||||
INPUT_CLASS,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
} from "../components/ui";
|
||||
import { getAuthConfig, loginDevToken } from "../api";
|
||||
|
||||
export async function loader() {
|
||||
|
|
@ -23,16 +28,16 @@ export default function AuthLogin({ loaderData }: any) {
|
|||
await loginDevToken(token);
|
||||
navigate("/runs");
|
||||
} catch {
|
||||
setError("Invalid dev token");
|
||||
setError("Invalid dev token.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
<h1 className="text-center text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
|
||||
Sign in to Fabro
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
<p className="mt-3 text-center text-sm/6 text-fg-3 text-pretty">
|
||||
{hasGitHub
|
||||
? "Authenticate with your GitHub account to continue."
|
||||
: "Paste your dev token to continue."}
|
||||
|
|
@ -41,7 +46,7 @@ export default function AuthLogin({ loaderData }: any) {
|
|||
{hasGitHub ? (
|
||||
<a
|
||||
href="/auth/login/github"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
className={`${PRIMARY_BUTTON_CLASS} w-full`}
|
||||
>
|
||||
<GitHubMark />
|
||||
Sign in with GitHub
|
||||
|
|
@ -55,34 +60,65 @@ export default function AuthLogin({ loaderData }: any) {
|
|||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : hasDevToken ? (
|
||||
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="fabro_dev_..."
|
||||
className="w-full rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm text-fg outline-none focus:border-teal-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
>
|
||||
Sign in with Dev Token
|
||||
</button>
|
||||
<p className="text-center text-xs text-fg-muted">
|
||||
Paste the dev token from your terminal or{" "}
|
||||
<code>cat ~/.fabro/dev-token</code>
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="text-center text-sm text-red-500">{error}</p>
|
||||
) : null}
|
||||
</form>
|
||||
<DevTokenForm
|
||||
token={token}
|
||||
setToken={setToken}
|
||||
error={error}
|
||||
onSubmit={handleSubmit}
|
||||
showLocation
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DevTokenForm({
|
||||
token,
|
||||
setToken,
|
||||
error,
|
||||
onSubmit,
|
||||
showLocation = false,
|
||||
}: {
|
||||
token: string;
|
||||
setToken: (v: string) => void;
|
||||
error: string | null;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
showLocation?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="dev-token" className="sr-only">
|
||||
Dev token
|
||||
</label>
|
||||
<input
|
||||
id="dev-token"
|
||||
type="password"
|
||||
name="dev_token"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="fabro_dev_…"
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
<button type="submit" className={`${PRIMARY_BUTTON_CLASS} w-full`}>
|
||||
Sign in with dev token
|
||||
</button>
|
||||
{showLocation ? (
|
||||
<p className="text-center text-xs text-fg-muted">
|
||||
Paste the dev token from your terminal or{" "}
|
||||
<code className="font-mono text-fg-3">cat ~/.fabro/dev-token</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DevTokenCollapsible({
|
||||
token,
|
||||
setToken,
|
||||
|
|
@ -101,7 +137,7 @@ function DevTokenCollapsible({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="mx-auto flex items-center gap-1 text-xs text-fg-3 hover:text-fg-2 transition-colors"
|
||||
className="mx-auto flex items-center gap-1 rounded text-xs text-fg-3 outline-teal-500 hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
>
|
||||
Use a dev token instead
|
||||
<svg
|
||||
|
|
@ -110,6 +146,7 @@ function DevTokenCollapsible({
|
|||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
|
|
@ -121,24 +158,14 @@ function DevTokenCollapsible({
|
|||
</svg>
|
||||
</button>
|
||||
{open ? (
|
||||
<form className="mt-3 space-y-3" onSubmit={onSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="fabro_dev_..."
|
||||
className="w-full rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm text-fg outline-none focus:border-teal-500"
|
||||
<div className="mt-4">
|
||||
<DevTokenForm
|
||||
token={token}
|
||||
setToken={setToken}
|
||||
error={error}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center rounded-lg border border-line-strong bg-panel px-4 py-2.5 text-sm font-medium text-fg transition-colors hover:bg-panel-2"
|
||||
>
|
||||
Sign in with Dev Token
|
||||
</button>
|
||||
{error ? (
|
||||
<p className="text-center text-sm text-red-500">{error}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -146,7 +173,7 @@ function DevTokenCollapsible({
|
|||
|
||||
function GitHubMark() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-4 shrink-0" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { apiJson } from "../api";
|
||||
import { EmptyState } from "../components/state";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { RunBilling } from "@qltysh/fabro-api-client";
|
||||
|
||||
|
|
@ -39,12 +40,24 @@ export async function loader({ request, params }: any) {
|
|||
export default function RunBilling({ loaderData }: any) {
|
||||
const { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown } =
|
||||
loaderData;
|
||||
|
||||
if (!stages.length) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
title="No billing yet"
|
||||
description="Token usage and cost will appear here once stages complete."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-hidden rounded-md border border-line">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs font-medium text-fg-3">
|
||||
<th className="px-4 py-2.5 font-medium">Stage</th>
|
||||
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||
|
|
@ -69,7 +82,7 @@ export default function RunBilling({ loaderData }: any) {
|
|||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-line-strong bg-panel/40">
|
||||
<tr className="border-t border-line-strong bg-overlay">
|
||||
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
||||
<td />
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||
|
|
@ -88,13 +101,11 @@ export default function RunBilling({ loaderData }: any) {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">
|
||||
By Model
|
||||
</h3>
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg">By model</h3>
|
||||
<div className="overflow-hidden rounded-md border border-line">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs font-medium text-fg-3">
|
||||
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Stages</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||
|
|
@ -119,7 +130,7 @@ export default function RunBilling({ loaderData }: any) {
|
|||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-line-strong bg-panel/40">
|
||||
<tr className="border-t border-line-strong bg-overlay">
|
||||
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||
{stages.length}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import { useEffect } from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Link, Outlet, useFetcher, useLocation } from "react-router";
|
||||
import { mapRunSummaryToRunItem, runStatusDisplay, isRunStatus } from "../data/runs";
|
||||
import type { RunSummaryResponse } from "../data/runs";
|
||||
import { apiJson } from "../api";
|
||||
import { ErrorState } from "../components/state";
|
||||
import { useDemoMode } from "../lib/demo-mode";
|
||||
import type { PreviewUrlResponse } from "@qltysh/fabro-api-client";
|
||||
|
||||
const allTabs = [
|
||||
{ name: "Overview", path: "", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Stages", path: "/stages", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Files Changed", path: "/files", count: null, demoOnly: false, broken: true },
|
||||
{ name: "Graph", path: "/graph", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Billing", path: "/billing", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Overview", path: "", count: null, demoOnly: false },
|
||||
{ name: "Stages", path: "/stages", count: null, demoOnly: false },
|
||||
{ name: "Graph", path: "/graph", count: null, demoOnly: false },
|
||||
{ name: "Billing", path: "/billing", count: null, demoOnly: false },
|
||||
];
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
|
@ -65,7 +64,7 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
const basePath = `/runs/${params.id}`;
|
||||
const previewFetcher = useFetcher<PreviewUrlResponse>();
|
||||
const demoMode = useDemoMode();
|
||||
const tabs = allTabs.filter((t) => !t.broken && (!t.demoOnly || demoMode));
|
||||
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewFetcher.data?.url) {
|
||||
|
|
@ -74,7 +73,14 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
}, [previewFetcher.data]);
|
||||
|
||||
if (!run) {
|
||||
return <p className="py-8 text-center text-sm text-fg-muted">Run not found.</p>;
|
||||
return (
|
||||
<div className="py-12">
|
||||
<ErrorState
|
||||
title="Run not found"
|
||||
description="The run you're looking for doesn't exist or was deleted."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -107,16 +113,7 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Open pull request"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
|
||||
</svg>
|
||||
Open PR
|
||||
</button>
|
||||
{/* TODO: restore an Open PR button when RunPullRequest gains a url field */}
|
||||
{run.sandboxId && (
|
||||
<previewFetcher.Form method="post">
|
||||
<input type="hidden" name="port" value="3000" />
|
||||
|
|
@ -124,50 +121,16 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={previewFetcher.state !== "idle"}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-teal-500/20 px-3 py-1.5 text-sm font-medium text-teal-500 transition-colors hover:border-teal-500/50 hover:bg-teal-500/10 hover:text-fg disabled:opacity-50"
|
||||
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-teal-500 px-3.5 py-1.5 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="size-3.5 shrink-0" aria-hidden="true">
|
||||
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
|
||||
<path fillRule="evenodd" d="M.664 10.59a1.651 1.651 0 0 1 0-1.186A10.004 10.004 0 0 1 10 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0 1 10 17c-4.257 0-7.893-2.66-9.336-6.41ZM14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{previewFetcher.state !== "idle" ? "Opening..." : "Preview"}
|
||||
{previewFetcher.state !== "idle" ? "Opening…" : "Preview"}
|
||||
</button>
|
||||
</previewFetcher.Form>
|
||||
)}
|
||||
{run.sandboxId && (
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="flex shrink-0 items-center gap-1.5 rounded-md border border-teal-500/20 px-3 py-1.5 text-sm font-medium text-teal-500 transition-colors hover:border-teal-500/50 hover:bg-teal-500/10 hover:text-fg">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 1 1-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z" />
|
||||
</svg>
|
||||
Terminal
|
||||
<ChevronDownIcon className="size-4" aria-hidden="true" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<a
|
||||
href="https://22222-rjyrtjg8gelfyo1p.daytonaproxy01.net/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-4 py-2 text-sm text-fg-3 data-focus:bg-overlay data-focus:text-fg"
|
||||
>
|
||||
Web Terminal
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:text-fg"
|
||||
>
|
||||
Connect with SSH
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-b border-line">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { apiFetch, apiJsonOrNull } from "../api";
|
||||
|
|
@ -8,6 +7,11 @@ import { isVisibleStage } from "../data/runs";
|
|||
import { formatDurationSecs } from "../lib/format";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import {
|
||||
GRAPH_DEFAULT_ZOOM_INDEX,
|
||||
GRAPH_ZOOM_STEPS,
|
||||
GraphToolbar,
|
||||
} from "../components/graph-toolbar";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
|
@ -169,9 +173,6 @@ function annotateRunningNodes(svg: SVGSVGElement, gt: ReturnType<typeof getGraph
|
|||
|
||||
}
|
||||
|
||||
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
const DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
export default function RunGraph({ loaderData }: any) {
|
||||
const { id } = useParams();
|
||||
const { stages, graphSvg } = loaderData;
|
||||
|
|
@ -179,11 +180,11 @@ export default function RunGraph({ loaderData }: any) {
|
|||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
|
||||
const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
|
||||
const [direction, setDirection] = useState<Direction>("LR");
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const zoom = ZOOM_STEPS[zoomIndex];
|
||||
const zoom = GRAPH_ZOOM_STEPS[zoomIndex];
|
||||
const { theme } = useTheme();
|
||||
const graphTheme = getGraphTheme(theme);
|
||||
|
||||
|
|
@ -264,8 +265,8 @@ export default function RunGraph({ loaderData }: any) {
|
|||
|
||||
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
|
||||
let best = 0;
|
||||
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
for (let i = GRAPH_ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (GRAPH_ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
}
|
||||
setZoomIndex(best);
|
||||
setPan({ x: 0, y: 0 });
|
||||
|
|
@ -280,61 +281,14 @@ export default function RunGraph({ loaderData }: any) {
|
|||
<StageSidebar stages={stages} runId={id!} activeLink="graph" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="graph-svg relative rounded-md border border-line bg-panel-alt/40">
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Left to right"
|
||||
onClick={() => setDirection("LR")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "LR" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Top to bottom"
|
||||
onClick={() => setDirection("TB")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "TB" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Fit to window"
|
||||
onClick={fitToWindow}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
|
||||
>
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={zoomIndex === 0}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<MinusIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
onClick={() => setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))}
|
||||
disabled={zoomIndex === ZOOM_STEPS.length - 1}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
|
||||
<GraphToolbar
|
||||
direction={direction}
|
||||
setDirection={setDirection}
|
||||
fitToWindow={fitToWindow}
|
||||
zoomIndex={zoomIndex}
|
||||
setZoomIndex={setZoomIndex}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { apiFetch, apiJsonOrNull } from "../api";
|
||||
import { isVisibleStage } from "../data/runs";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
|
|
@ -8,6 +7,12 @@ import { useTheme } from "../lib/theme";
|
|||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import {
|
||||
GRAPH_DEFAULT_ZOOM_INDEX,
|
||||
GRAPH_ZOOM_STEPS,
|
||||
GraphToolbar,
|
||||
} from "../components/graph-toolbar";
|
||||
import { EmptyState } from "../components/state";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
|
@ -33,9 +38,6 @@ export async function loader({ request, params }: any) {
|
|||
return { stages, graphSvg, runStatus };
|
||||
}
|
||||
|
||||
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
const DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
type Direction = "LR" | "TB";
|
||||
|
||||
export default function RunOverview({ loaderData }: any) {
|
||||
|
|
@ -46,11 +48,11 @@ export default function RunOverview({ loaderData }: any) {
|
|||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
|
||||
const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
|
||||
const [direction, setDirection] = useState<Direction>("LR");
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const zoom = ZOOM_STEPS[zoomIndex];
|
||||
const zoom = GRAPH_ZOOM_STEPS[zoomIndex];
|
||||
|
||||
// Render SVG with stage annotations
|
||||
useEffect(() => {
|
||||
|
|
@ -190,8 +192,8 @@ export default function RunOverview({ loaderData }: any) {
|
|||
|
||||
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
|
||||
let best = 0;
|
||||
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
for (let i = GRAPH_ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (GRAPH_ZOOM_STEPS[i] <= fitPct) { best = i; break; }
|
||||
}
|
||||
setZoomIndex(best);
|
||||
setPan({ x: 0, y: 0 });
|
||||
|
|
@ -203,61 +205,14 @@ export default function RunOverview({ loaderData }: any) {
|
|||
|
||||
<div className="min-w-0 flex-1">
|
||||
{graphSvg ? (
|
||||
<div className="graph-svg relative rounded-md border border-line bg-panel-alt/40">
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Left to right"
|
||||
onClick={() => setDirection("LR")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "LR" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Top to bottom"
|
||||
onClick={() => setDirection("TB")}
|
||||
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "TB" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Fit to window"
|
||||
onClick={fitToWindow}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
|
||||
>
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom out"
|
||||
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={zoomIndex === 0}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<MinusIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Zoom in"
|
||||
onClick={() => setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))}
|
||||
disabled={zoomIndex === ZOOM_STEPS.length - 1}
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
|
||||
<GraphToolbar
|
||||
direction={direction}
|
||||
setDirection={setDirection}
|
||||
fitToWindow={fitToWindow}
|
||||
zoomIndex={zoomIndex}
|
||||
setZoomIndex={setZoomIndex}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -276,7 +231,10 @@ export default function RunOverview({ loaderData }: any) {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">No workflow graph available.</p>
|
||||
<EmptyState
|
||||
title="No workflow graph"
|
||||
description="This run doesn't have a renderable graph yet."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ export default function RunSettingsPage({ loaderData }: any) {
|
|||
<StageSidebar stages={stages} runId={id!} activeLink="settings" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="mb-4">
|
||||
<h2 className="text-base font-semibold text-fg">Run settings</h2>
|
||||
<p className="mt-1 text-sm/6 text-fg-3">
|
||||
Frozen settings snapshot used by this run.
|
||||
</p>
|
||||
</header>
|
||||
<CollapsibleFile
|
||||
file={{ name: "settings.json", contents: JSON.stringify(settings, null, 2), lang: "json" }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { ToolBlock } from "../components/tool-use";
|
|||
import type { ToolUse } from "../components/tool-use";
|
||||
import { StageSidebar, statusConfig } from "../components/stage-sidebar";
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import { EmptyState } from "../components/state";
|
||||
import { apiJson, apiJsonOrNull } from "../api";
|
||||
import { CopyButton } from "../components/ui";
|
||||
import { isVisibleStage } from "../data/runs";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList, PaginatedEventList } from "@qltysh/fabro-api-client";
|
||||
|
|
@ -176,28 +178,105 @@ function Markdown({ content }: { content: string }) {
|
|||
|
||||
function SystemBlock({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber/10 bg-amber/5 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<section className="group relative border-l-2 border-amber/50 pl-4">
|
||||
<header className="mb-1.5 flex items-center gap-2">
|
||||
<CommandLineIcon className="size-4 shrink-0 text-amber" />
|
||||
<span className="text-xs font-medium text-fg-3">System Prompt</span>
|
||||
</div>
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-fg-3">System prompt</span>
|
||||
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<CopyButton value={content} label="Copy system prompt" />
|
||||
</div>
|
||||
</header>
|
||||
<Markdown content={content} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantBlock({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-teal-500/10 bg-teal-500/5 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<section className="group relative border-l-2 border-teal-500/50 pl-4">
|
||||
<header className="mb-1.5 flex items-center gap-2">
|
||||
<ChatBubbleLeftIcon className="size-4 shrink-0 text-teal-500" />
|
||||
<span className="text-xs font-medium text-fg-3">Assistant</span>
|
||||
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<CopyButton value={content} label="Copy assistant message" />
|
||||
</div>
|
||||
</header>
|
||||
<Markdown content={content} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: "running" | "failed" | "success" | "neutral";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const toneClass = {
|
||||
running: "bg-teal-500/15 text-teal-500",
|
||||
failed: "bg-coral/15 text-coral",
|
||||
success: "bg-mint/15 text-mint",
|
||||
neutral: "bg-overlay text-fg-3",
|
||||
}[tone];
|
||||
return (
|
||||
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${toneClass}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const COLLAPSE_AFTER_LINES = 20;
|
||||
|
||||
function StreamLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="font-mono text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutputStream({
|
||||
label,
|
||||
content,
|
||||
tone = "normal",
|
||||
}: {
|
||||
label: string;
|
||||
content: string;
|
||||
tone?: "normal" | "error";
|
||||
}) {
|
||||
const lines = content.split("\n");
|
||||
const isLong = lines.length > COLLAPSE_AFTER_LINES;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visible = isLong && !expanded
|
||||
? lines.slice(-COLLAPSE_AFTER_LINES).join("\n")
|
||||
: content;
|
||||
const hiddenLines = isLong && !expanded ? lines.length - COLLAPSE_AFTER_LINES : 0;
|
||||
const preClass =
|
||||
tone === "error"
|
||||
? "whitespace-pre-wrap font-mono text-sm leading-relaxed text-coral sm:text-xs"
|
||||
: "whitespace-pre-wrap font-mono text-sm leading-relaxed text-fg-3 sm:text-xs";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<StreamLabel label={label} />
|
||||
<CopyButton
|
||||
value={content}
|
||||
label={`Copy ${label}`}
|
||||
className="-my-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
{isLong && !expanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="mb-2 text-[11px] font-medium text-teal-500 hover:text-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 rounded"
|
||||
>
|
||||
Show {hiddenLines} earlier lines
|
||||
</button>
|
||||
) : null}
|
||||
<pre className={preClass}>{visible}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -208,53 +287,58 @@ function CommandBlock({ turn }: { turn: Extract<TurnType, { kind: "command" }> }
|
|||
const bgColor = turn.running ? "bg-teal-500/5" : failed ? "bg-coral/5" : "bg-mint/5";
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border ${borderColor} ${bgColor} overflow-hidden`}>
|
||||
<div className={`group rounded-md border ${borderColor} ${bgColor} overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<PlayIcon className={`size-4 shrink-0 ${turn.running ? "text-teal-500 animate-pulse" : failed ? "text-coral" : "text-mint"}`} />
|
||||
<span className="text-xs font-medium text-fg-3">
|
||||
{turn.language === "python" ? "Python" : "Shell"}
|
||||
</span>
|
||||
{turn.running && (
|
||||
<span className="ml-auto text-[11px] font-medium text-teal-500 animate-pulse">Running...</span>
|
||||
)}
|
||||
{!turn.running && turn.timedOut && (
|
||||
<span className="ml-auto rounded bg-coral/15 px-1.5 py-0.5 text-[11px] font-medium text-coral">Timed out</span>
|
||||
)}
|
||||
{!turn.running && !turn.timedOut && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${failed ? "bg-coral/15 text-coral" : "bg-mint/15 text-mint"}`}>
|
||||
exit {turn.exitCode ?? "?"}
|
||||
</span>
|
||||
{turn.durationMs != null && (
|
||||
<span className="text-[11px] tabular-nums text-fg-muted">
|
||||
{turn.durationMs < 1000 ? `${turn.durationMs}ms` : `${(turn.durationMs / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{turn.running ? (
|
||||
<StatusPill tone="running">Running…</StatusPill>
|
||||
) : turn.timedOut ? (
|
||||
<StatusPill tone="failed">Timed out</StatusPill>
|
||||
) : (
|
||||
<>
|
||||
<StatusPill tone={failed ? "failed" : "success"}>
|
||||
exit {turn.exitCode ?? "?"}
|
||||
</StatusPill>
|
||||
{turn.durationMs != null && (
|
||||
<StatusPill tone="neutral">
|
||||
{turn.durationMs < 1000
|
||||
? `${turn.durationMs}ms`
|
||||
: `${(turn.durationMs / 1000).toFixed(1)}s`}
|
||||
</StatusPill>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{turn.script ? (
|
||||
<div className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<CopyButton value={turn.script} label="Copy script" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Script */}
|
||||
{turn.script && (
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{turn.script}</pre>
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-fg-3 sm:text-xs">{turn.script}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* stdout */}
|
||||
{turn.stdout && (
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">stdout</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{turn.stdout}</pre>
|
||||
<OutputStream label="stdout" content={turn.stdout} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* stderr */}
|
||||
{turn.stderr && (
|
||||
<div className="border-t border-line px-3 py-2.5">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">stderr</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-coral">{turn.stderr}</pre>
|
||||
<OutputStream label="stderr" content={turn.stderr} tone="error" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -287,7 +371,14 @@ export default function RunStages({ loaderData }: any) {
|
|||
}, [isRunning]);
|
||||
|
||||
if (!stages.length) {
|
||||
return <p className="py-8 text-center text-sm text-fg-muted">No stages available for this run.</p>;
|
||||
return (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
title="No stages yet"
|
||||
description="Stages will appear here once the run begins executing."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedConfig = statusConfig[selectedStage.status];
|
||||
|
|
@ -301,10 +392,10 @@ export default function RunStages({ loaderData }: any) {
|
|||
<StageSidebar stages={stages} runId={id!} selectedStageId={selectedStage.id} />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex items-center gap-2 bg-page/85 px-2 py-2 backdrop-blur">
|
||||
<SelectedIcon className={`size-5 ${selectedConfig.color} ${isRunning ? "animate-spin" : ""}`} />
|
||||
<h3 className="text-sm font-medium text-fg">{selectedStage.name}</h3>
|
||||
<span className="font-mono text-xs text-fg-muted">{headerDuration}</span>
|
||||
<h3 className="text-base font-semibold text-fg">{selectedStage.name}</h3>
|
||||
<span className="font-mono text-xs tabular-nums text-fg-muted">{headerDuration}</span>
|
||||
</div>
|
||||
|
||||
{turns.map((turn: TurnType, i: number) => {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { CSS } from "@dnd-kit/utilities";
|
|||
import { ciConfig, columnStatusDisplay, deriveCiStatus, mapRunListItem } from "../data/runs";
|
||||
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
|
||||
import { apiPaginatedJson } from "../api";
|
||||
import { EmptyState } from "../components/state";
|
||||
import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client";
|
||||
|
||||
export function meta({}: any) {
|
||||
|
|
@ -309,7 +310,7 @@ function PrCard({
|
|||
const lifecycleLabel = boardLifecycleStatusLabel(pr);
|
||||
|
||||
return (
|
||||
<Link to={`/runs/${pr.id}`} className="group block rounded-md border border-line bg-panel/80 p-4 transition-all duration-200 hover:border-line-strong hover:bg-panel hover:shadow-lg hover:shadow-black/20">
|
||||
<Link to={`/runs/${pr.id}`} className="group block rounded-md border border-line bg-panel p-4 transition-all duration-200 hover:border-line-strong hover:shadow-lg hover:shadow-black/20">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Icon className={`size-3.5 shrink-0 ${iconColor}`} />
|
||||
<span className="font-mono text-xs font-medium text-teal-500">
|
||||
|
|
@ -321,7 +322,7 @@ function PrCard({
|
|||
</span>
|
||||
)}
|
||||
{lifecycleLabel != null && (
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[11px] uppercase tracking-wide text-fg-muted">
|
||||
{lifecycleLabel}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -336,10 +337,10 @@ function PrCard({
|
|||
)}
|
||||
{pr.additions != null && pr.deletions != null && (
|
||||
<>
|
||||
<span className="text-mint">
|
||||
<span className="tabular-nums text-mint">
|
||||
+{pr.additions.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-coral">
|
||||
<span className="tabular-nums text-coral">
|
||||
-{pr.deletions.toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
|
|
@ -459,7 +460,7 @@ function BoardColumn({ column }: { column: Column }) {
|
|||
const Icon = iconMap[column.iconType];
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full ${column.dot}`} />
|
||||
<h3 className="text-sm font-semibold tracking-wide text-fg-2">
|
||||
{column.name}
|
||||
|
|
@ -501,7 +502,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
<span className="font-mono text-xs font-medium text-teal-500">{run.repo}</span>
|
||||
<span className="truncate text-sm text-fg-2">{run.title}</span>
|
||||
{lifecycleLabel != null && (
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[11px] uppercase tracking-wide text-fg-muted">
|
||||
{lifecycleLabel}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -607,12 +608,12 @@ function CopyButton({ text }: { text: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
function RunsLandingEmpty() {
|
||||
return (
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<div className="w-full max-w-lg space-y-5">
|
||||
<p className="text-center text-sm text-fg-muted">
|
||||
Workflow runs will appear here as you run them.
|
||||
Your runs will appear here.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-line bg-panel/60 p-5">
|
||||
|
|
@ -741,6 +742,10 @@ export default function Runs({ loaderData }: any) {
|
|||
(item.number != null && `#${item.number}`.includes(lowerQuery))),
|
||||
),
|
||||
}));
|
||||
const filteredRuns = filteredColumns.reduce(
|
||||
(sum, col) => sum + col.items.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
|
|
@ -750,7 +755,9 @@ export default function Runs({ loaderData }: any) {
|
|||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search runs..."
|
||||
name="search"
|
||||
aria-label="Search runs"
|
||||
placeholder="Search runs…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
|
|
@ -758,6 +765,8 @@ export default function Runs({ loaderData }: any) {
|
|||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
name="repo"
|
||||
aria-label="Filter by repository"
|
||||
value={repoFilter}
|
||||
onChange={(e) => setRepoFilter(e.target.value)}
|
||||
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
|
|
@ -769,11 +778,12 @@ export default function Runs({ loaderData }: any) {
|
|||
</select>
|
||||
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
</div>
|
||||
<div className="flex rounded-md border border-line bg-panel/80">
|
||||
<div role="group" aria-label="Run list view" className="flex rounded-md border border-line bg-panel/80 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("columns")}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "columns" ? "text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
aria-pressed={view === "columns"}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors ${view === "columns" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
aria-label="Columns view"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
|
||||
|
|
@ -783,7 +793,8 @@ export default function Runs({ loaderData }: any) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setView("list")}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "list" ? "text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
aria-pressed={view === "list"}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors ${view === "list" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
aria-label="List view"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
|
||||
|
|
@ -802,9 +813,16 @@ export default function Runs({ loaderData }: any) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{totalRuns === 0 && (
|
||||
<EmptyState />
|
||||
)}
|
||||
{totalRuns === 0 ? (
|
||||
<RunsLandingEmpty />
|
||||
) : filteredRuns === 0 ? (
|
||||
<div className="py-8">
|
||||
<EmptyState
|
||||
title="No matching runs"
|
||||
description="Try clearing the search or repo filter."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -847,9 +865,16 @@ export default function Runs({ loaderData }: any) {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
{totalRuns === 0 && (
|
||||
<EmptyState />
|
||||
)}
|
||||
{totalRuns === 0 ? (
|
||||
<RunsLandingEmpty />
|
||||
) : filteredRuns === 0 ? (
|
||||
<div className="py-8">
|
||||
<EmptyState
|
||||
title="No matching runs"
|
||||
description="Try clearing the search or repo filter."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ export function meta({}: any) {
|
|||
return [{ title: "Settings — Fabro" }];
|
||||
}
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request }: any) {
|
||||
const settings = await apiJson<ServerSettings>("/settings", { request });
|
||||
return { settings };
|
||||
|
|
@ -23,10 +21,15 @@ export default function Settings({ loaderData }: any) {
|
|||
const { settings } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<>
|
||||
<p className="mb-6 max-w-[60ch] text-sm/6 text-fg-3 text-pretty">
|
||||
Snapshot of the server configuration. Edit via{" "}
|
||||
<code className="font-mono text-fg-2">settings.toml</code>; changes
|
||||
take effect on the next server restart.
|
||||
</p>
|
||||
<CollapsibleFile
|
||||
file={{ name: "server.json", contents: JSON.stringify(settings, null, 2), lang: "json" }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,68 @@
|
|||
import { AuthLayout } from "../components/auth-layout";
|
||||
import { PRIMARY_BUTTON_CLASS } from "../components/ui";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "Open a terminal on the server host",
|
||||
body: (
|
||||
<p className="text-sm/6 text-fg-3">
|
||||
Run{" "}
|
||||
<code className="font-mono text-fg-2">fabro install</code> on the same
|
||||
host that runs the Fabro server.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Choose GitHub App setup",
|
||||
body: (
|
||||
<p className="text-sm/6 text-fg-3">
|
||||
The CLI opens GitHub, exchanges the manifest code, and writes the
|
||||
required settings and secrets locally.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Restart the server, then return to sign in",
|
||||
body: (
|
||||
<p className="text-sm/6 text-fg-3">
|
||||
Once the server comes back up, you can authenticate from the browser.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Setup() {
|
||||
return (
|
||||
<AuthLayout footer="GitHub App setup is managed from the terminal, not the browser.">
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
<h1 className="text-center text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
|
||||
Set up Fabro
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
Run the installer on the same host that runs the Fabro server to
|
||||
register a GitHub App and write local configuration.
|
||||
<p className="mt-3 text-center text-sm/6 text-fg-3 text-pretty">
|
||||
Run the installer on the server host to register a GitHub App and write
|
||||
local configuration.
|
||||
</p>
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="rounded-lg border border-line-strong bg-overlay px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
1. Open a terminal on the server host
|
||||
</p>
|
||||
<pre className="mt-2 overflow-x-auto text-sm text-fg-2">
|
||||
<code>fabro install</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-lg border border-line-strong bg-overlay px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
2. Choose GitHub App setup
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-fg-3">
|
||||
The CLI opens GitHub, exchanges the manifest code, and writes the
|
||||
required settings and secrets locally.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-line-strong bg-overlay px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
3. Restart the server, then return to sign in
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
className="mt-3 flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
>
|
||||
Continue to sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ol
|
||||
role="list"
|
||||
className="mt-8 divide-y divide-line border-y border-line"
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<li key={step.title} className="flex items-start gap-4 py-4">
|
||||
<span
|
||||
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-overlay text-xs font-semibold tabular-nums text-fg-2 outline-1 -outline-offset-1 outline-white/10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-fg">{step.title}</p>
|
||||
<div className="mt-1">{step.body}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<a href="/login" className={`${PRIMARY_BUTTON_CLASS} mt-8 w-full`}>
|
||||
Continue to sign in
|
||||
</a>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export default function Start({ loaderData }: any) {
|
|||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!prompt.trim()}
|
||||
className="flex items-center justify-center size-8 rounded-lg bg-teal-500 text-navy-950 transition-all duration-200 hover:bg-teal-300 disabled:opacity-30 disabled:hover:bg-teal-500 disabled:cursor-default"
|
||||
className="flex items-center justify-center size-8 rounded-lg bg-teal-500 text-on-primary transition-all duration-200 hover:bg-teal-300 disabled:opacity-30 disabled:hover:bg-teal-500 disabled:cursor-default"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ description: "Server-owned settings.toml sections, CLI overrides, and environmen
|
|||
On a same-machine setup, the CLI and server share one `settings.toml`. On a remote deployment, the server machine has its own `settings.toml`, and the client machine keeps a separate local `settings.toml` for CLI-only values such as `[cli.target]`.
|
||||
|
||||
<Note>
|
||||
Legacy `server.toml`, `user.toml`, and `cli.toml` are ignored with a warning. Rename them to `settings.toml`.
|
||||
Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.toml` filenames are no longer part of the supported config surface.
|
||||
</Note>
|
||||
|
||||
### Which sections are server-owned
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ fabro doctor --server https://fabro.example.com/api/v1
|
|||
```
|
||||
|
||||
It checks:
|
||||
- Local user config and legacy `~/.fabro/.env` warnings
|
||||
- Local user config and storage directory health
|
||||
- Server-reported LLM provider connectivity
|
||||
- GitHub App, sandbox, and Brave Search credentials
|
||||
- Server authentication and crypto configuration
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use anyhow::Result;
|
||||
use fabro_api::types;
|
||||
use fabro_auth::credential_id_for;
|
||||
use fabro_config::legacy_env;
|
||||
use fabro_types::settings::CliSettings;
|
||||
use fabro_types::settings::cli::CliLayer;
|
||||
use fabro_util::printer::Printer;
|
||||
|
|
@ -36,17 +35,6 @@ pub(super) async fn login_command(
|
|||
let credential_id = credential_id_for(&credential).map_err(anyhow::Error::msg)?;
|
||||
let value = serde_json::to_string(&credential)?;
|
||||
|
||||
{
|
||||
let path = legacy_env::legacy_env_file_path();
|
||||
if path.exists() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" Warning: {} is no longer read by fabro server. Re-enter credentials with `fabro provider login`.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
server
|
||||
.api()
|
||||
.create_secret()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use fabro_util::terminal::Styles;
|
|||
use crate::args::RunArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::user_config::settings_layer_with_storage_dir;
|
||||
use crate::user_config::load_settings_with_storage_dir;
|
||||
|
||||
pub(crate) async fn execute(
|
||||
mut args: RunArgs,
|
||||
|
|
@ -17,7 +17,7 @@ pub(crate) async fn execute(
|
|||
) -> Result<()> {
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?;
|
||||
let cli_defaults = settings_layer_with_storage_dir(None)?;
|
||||
let cli_defaults = load_settings_with_storage_dir(None)?;
|
||||
args.verbose = args.verbose || cli.output.verbosity == OutputVerbosity::Verbose;
|
||||
|
||||
let quiet = args.detach;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub(crate) async fn execute(
|
|||
record_path: PathBuf,
|
||||
mut serve_args: ServeArgs,
|
||||
bind: BindRequest,
|
||||
storage_dir: Option<PathBuf>,
|
||||
storage_dir: PathBuf,
|
||||
styles: &'static Styles,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
|
|
@ -35,22 +35,14 @@ pub(crate) async fn execute(
|
|||
None
|
||||
};
|
||||
|
||||
let log_path = storage_dir.as_ref().map_or_else(
|
||||
|| {
|
||||
record_path.parent().map_or_else(
|
||||
|| PathBuf::from("server.log"),
|
||||
|parent| parent.join("server.log"),
|
||||
)
|
||||
},
|
||||
|dir| Storage::new(dir).server_state().log_path(),
|
||||
);
|
||||
let log_path = Storage::new(&storage_dir).server_state().log_path();
|
||||
let dev_token_path = std::env::var_os("FABRO_DEV_TOKEN_PATH").map(PathBuf::from);
|
||||
let pid = std::process::id();
|
||||
|
||||
Box::pin(serve::serve_command(
|
||||
serve_args,
|
||||
styles,
|
||||
storage_dir,
|
||||
Some(storage_dir),
|
||||
move |resolved_bind| {
|
||||
record::write_server_record(&record_path, &record::ServerRecord {
|
||||
pid,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use std::time::Duration;
|
|||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use fabro_config::user::{FABRO_CONFIG_ENV, active_settings_path, legacy_default_storage_root};
|
||||
use fabro_config::user::{FABRO_CONFIG_ENV, active_settings_path, default_storage_dir};
|
||||
use fabro_server::bind::{self, Bind, BindRequest};
|
||||
use fabro_server::install::{self, InstallAppState};
|
||||
use fabro_server::serve::{self, ServeArgs};
|
||||
|
|
@ -27,6 +27,7 @@ use crate::user_config;
|
|||
pub(crate) async fn dispatch(
|
||||
command: ServerCommand,
|
||||
_globals: &GlobalArgs,
|
||||
foreground_log_bootstrap: Option<start::ForegroundServerLogBootstrap>,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
|
|
@ -62,6 +63,7 @@ pub(crate) async fn dispatch(
|
|||
foreground,
|
||||
serve_args,
|
||||
storage_dir,
|
||||
foreground_log_bootstrap,
|
||||
styles,
|
||||
printer,
|
||||
))
|
||||
|
|
@ -73,8 +75,7 @@ pub(crate) async fn dispatch(
|
|||
}) => {
|
||||
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
|
||||
let storage_dir = user_config::storage_dir(&settings)?;
|
||||
stop::execute(&storage_dir, Duration::from_secs(timeout), printer).await;
|
||||
Ok(())
|
||||
stop::execute(&storage_dir, Duration::from_secs(timeout), printer).await
|
||||
}
|
||||
ServerCommand::Restart(ServerRestartArgs {
|
||||
storage_dir,
|
||||
|
|
@ -87,7 +88,7 @@ pub(crate) async fn dispatch(
|
|||
storage_dir.as_deref(),
|
||||
&serve_args,
|
||||
)? {
|
||||
stop::stop_server(&bootstrap.storage_dir, Duration::from_secs(timeout)).await;
|
||||
stop::stop_server(&bootstrap.storage_dir, Duration::from_secs(timeout)).await?;
|
||||
if serve_args.no_web {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
|
|
@ -102,7 +103,7 @@ pub(crate) async fn dispatch(
|
|||
storage_dir.as_deref(),
|
||||
)?;
|
||||
let storage_dir = user_config::storage_dir(&settings)?;
|
||||
stop::stop_server(&storage_dir, Duration::from_secs(timeout)).await;
|
||||
stop::stop_server(&storage_dir, Duration::from_secs(timeout)).await?;
|
||||
let bind_addr =
|
||||
serve::resolve_bind_request_from_settings(&settings, serve_args.bind.as_deref())?;
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
|
|
@ -111,6 +112,7 @@ pub(crate) async fn dispatch(
|
|||
foreground,
|
||||
serve_args,
|
||||
storage_dir,
|
||||
foreground_log_bootstrap,
|
||||
styles,
|
||||
printer,
|
||||
))
|
||||
|
|
@ -136,6 +138,7 @@ pub(crate) async fn dispatch(
|
|||
.clone()
|
||||
.unwrap_or_else(|| user_config::active_settings_path(None)),
|
||||
);
|
||||
let storage_dir = user_config::storage_dir(&settings)?;
|
||||
let bind_addr =
|
||||
serve::resolve_bind_request_from_settings(&settings, serve_args.bind.as_deref())?;
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
|
|
@ -146,7 +149,7 @@ pub(crate) async fn dispatch(
|
|||
..serve_args
|
||||
},
|
||||
bind_addr,
|
||||
storage_dir.clone_path(),
|
||||
storage_dir,
|
||||
styles,
|
||||
printer,
|
||||
))
|
||||
|
|
@ -181,10 +184,7 @@ fn maybe_install_bootstrap(
|
|||
None => default_install_bind_request(),
|
||||
};
|
||||
|
||||
let storage_dir = storage_dir.map_or_else(
|
||||
|| legacy_default_storage_root().join("storage"),
|
||||
std::path::Path::to_path_buf,
|
||||
);
|
||||
let storage_dir = storage_dir.map_or_else(default_storage_dir, std::path::Path::to_path_buf);
|
||||
|
||||
Ok(Some(InstallBootstrap {
|
||||
bind_request,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use fabro_util::printer::Printer;
|
|||
use super::record;
|
||||
|
||||
pub(crate) fn execute(storage_dir: &Path, json: bool, printer: Printer) -> Result<()> {
|
||||
let Some(record) = record::active_server_record(storage_dir) else {
|
||||
let Some(record) = record::active_server_record(storage_dir)? else {
|
||||
if json {
|
||||
fabro_util::printout!(printer, r#"{{"status":"stopped"}}"#);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_server::bind::Bind;
|
||||
use fabro_util::printer::Printer;
|
||||
use tokio::time;
|
||||
|
||||
use super::record;
|
||||
|
||||
pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> bool {
|
||||
let Some(active) = record::active_server_record_details(storage_dir) else {
|
||||
return false;
|
||||
pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> Result<bool> {
|
||||
let Some(active) = record::active_server_record_details(storage_dir)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let record = active.record;
|
||||
|
||||
|
|
@ -36,14 +37,15 @@ pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> bool {
|
|||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
true
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(storage_dir: &Path, timeout: Duration, printer: Printer) {
|
||||
if !stop_server(storage_dir, timeout).await {
|
||||
pub(crate) async fn execute(storage_dir: &Path, timeout: Duration, printer: Printer) -> Result<()> {
|
||||
if !stop_server(storage_dir, timeout).await? {
|
||||
fabro_util::printerr!(printer, "Server is not running");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fabro_util::printerr!(printer, "Server stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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(¤t_record);
|
||||
let legacy_record = fabro_home.join("server.json");
|
||||
std::fs::rename(¤t_record, &legacy_record).unwrap();
|
||||
|
||||
let retry_output = {
|
||||
let mut retry = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut retry, home_dir.path());
|
||||
retry
|
||||
.args(["server", "start", "--bind"])
|
||||
.arg(home_dir.path().join("new.sock"))
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.expect("server start retry should run")
|
||||
};
|
||||
|
||||
let pid_u64 = serde_json::from_str::<serde_json::Value>(
|
||||
&std::fs::read_to_string(&legacy_record).unwrap(),
|
||||
)
|
||||
.unwrap()["pid"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let pid = u32::try_from(pid_u64).expect("pid fits in u32");
|
||||
stop_pid(pid);
|
||||
let _ = std::fs::remove_file(&legacy_record);
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
|
||||
assert!(
|
||||
!retry_output.status.success(),
|
||||
"server start should fail when only the legacy record exists"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&retry_output.stderr);
|
||||
assert!(
|
||||
stderr.contains(&legacy_record.display().to_string()),
|
||||
"expected stderr to mention the legacy record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(¤t_record.display().to_string()),
|
||||
"expected stderr to mention the current record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("legacy Fabro CLI"),
|
||||
"expected stderr to instruct manual cleanup, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This sync integration test starts two real foreground server processes to verify lock ownership protects log truncation."
|
||||
)]
|
||||
fn concurrent_foreground_start_does_not_retruncate_storage_server_log() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let storage_root = isolated_storage_dir();
|
||||
let storage_dir = storage_root.path().join("storage");
|
||||
let first_socket_path = storage_root.path().join("foreground-first.sock");
|
||||
let second_socket_path = storage_root.path().join("foreground-second.sock");
|
||||
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n").unwrap();
|
||||
|
||||
let storage_log_path = storage_dir.join("logs").join("server.log");
|
||||
std::fs::create_dir_all(storage_log_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&storage_log_path, "stale pre-start log entry\n").unwrap();
|
||||
|
||||
let mut first = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut first, home_dir.path());
|
||||
first
|
||||
.args(["server", "start", "--foreground"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.arg("--bind")
|
||||
.arg(&first_socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let first_child = first.spawn().expect("first foreground start should spawn");
|
||||
wait_for_path(&storage_dir.join("server.json"));
|
||||
wait_for_log_line(&storage_log_path, "API server started");
|
||||
|
||||
let marker = "marker-after-first-start\n";
|
||||
{
|
||||
use std::io::Write as _;
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&storage_log_path)
|
||||
.unwrap();
|
||||
file.write_all(marker.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
let second_output = {
|
||||
let mut second = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut second, home_dir.path());
|
||||
second
|
||||
.args(["server", "start", "--foreground"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.arg("--bind")
|
||||
.arg(&second_socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.expect("second foreground start should run")
|
||||
};
|
||||
|
||||
assert!(
|
||||
!second_output.status.success(),
|
||||
"second foreground start should fail:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&second_output.stdout),
|
||||
String::from_utf8_lossy(&second_output.stderr)
|
||||
);
|
||||
let second_stderr = String::from_utf8_lossy(&second_output.stderr);
|
||||
assert!(
|
||||
second_stderr.contains("timed out waiting for server lock"),
|
||||
"expected lock timeout failure, got:\n{second_stderr}"
|
||||
);
|
||||
|
||||
let storage_log = std::fs::read_to_string(&storage_log_path).unwrap_or_default();
|
||||
assert!(
|
||||
storage_log.contains(marker.trim_end()),
|
||||
"expected second start to avoid retruncating the log, got:\n{storage_log}",
|
||||
);
|
||||
assert!(
|
||||
!storage_log.contains("stale pre-start log entry"),
|
||||
"expected the first start to truncate stale log contents, got:\n{storage_log}",
|
||||
);
|
||||
|
||||
let stop_output = {
|
||||
let mut stop = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
apply_test_isolation(&mut stop, home_dir.path());
|
||||
stop.args(["server", "stop", "--timeout", "0"])
|
||||
.arg("--storage-dir")
|
||||
.arg(&storage_dir)
|
||||
.output()
|
||||
.expect("server stop should run")
|
||||
};
|
||||
assert!(
|
||||
stop_output.status.success(),
|
||||
"server stop should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&stop_output.stdout),
|
||||
String::from_utf8_lossy(&stop_output.stderr)
|
||||
);
|
||||
|
||||
let _ = first_child
|
||||
.wait_with_output()
|
||||
.expect("first child should exit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
|
||||
fn isolated_storage_dir() -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir_in("/tmp").unwrap();
|
||||
std::fs::create_dir_all(root.path().join("storage")).unwrap();
|
||||
root
|
||||
}
|
||||
use fabro_test::{fabro_snapshot, isolated_storage_dir, stop_pid, test_context, wait_for_path};
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
@ -47,3 +41,79 @@ fn status_when_not_running() {
|
|||
Server is not running
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This integration test moves a live daemon record on disk to simulate an unsupported legacy daemon upgrade."
|
||||
)]
|
||||
fn status_errors_when_only_a_legacy_running_server_record_exists() {
|
||||
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let fabro_home = home_dir.path().join(".fabro");
|
||||
let storage_dir = fabro_home.join("storage");
|
||||
let socket_path = home_dir.path().join("legacy.sock");
|
||||
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
|
||||
let config_path = config_dir.path().join("settings.toml");
|
||||
std::fs::write(&config_path, "_version = 1\n").unwrap();
|
||||
|
||||
let start_output = {
|
||||
let mut start = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
fabro_test::apply_test_isolation(&mut start, home_dir.path());
|
||||
start
|
||||
.args(["server", "start", "--bind"])
|
||||
.arg(&socket_path)
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.expect("server start should run")
|
||||
};
|
||||
assert!(
|
||||
start_output.status.success(),
|
||||
"server start should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&start_output.stdout),
|
||||
String::from_utf8_lossy(&start_output.stderr)
|
||||
);
|
||||
|
||||
let current_record = storage_dir.join("server.json");
|
||||
wait_for_path(¤t_record);
|
||||
let legacy_record = fabro_home.join("server.json");
|
||||
std::fs::rename(¤t_record, &legacy_record).unwrap();
|
||||
|
||||
let status_output = {
|
||||
let mut status = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
fabro_test::apply_test_isolation(&mut status, home_dir.path());
|
||||
status
|
||||
.args(["server", "status"])
|
||||
.output()
|
||||
.expect("server status should run")
|
||||
};
|
||||
|
||||
let pid_u64 = serde_json::from_str::<serde_json::Value>(
|
||||
&std::fs::read_to_string(&legacy_record).unwrap(),
|
||||
)
|
||||
.unwrap()["pid"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let pid = u32::try_from(pid_u64).expect("pid fits in u32");
|
||||
stop_pid(pid);
|
||||
let _ = std::fs::remove_file(&legacy_record);
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
|
||||
assert!(
|
||||
!status_output.status.success(),
|
||||
"server status should fail when only the legacy record exists"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&status_output.stderr);
|
||||
assert!(
|
||||
stderr.contains(&legacy_record.display().to_string()),
|
||||
"expected stderr to mention the legacy record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(¤t_record.display().to_string()),
|
||||
"expected stderr to mention the current record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("legacy Fabro CLI"),
|
||||
"expected stderr to instruct manual cleanup, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
|
||||
fn isolated_storage_dir() -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir_in("/tmp").unwrap();
|
||||
std::fs::create_dir_all(root.path().join("storage")).unwrap();
|
||||
root
|
||||
}
|
||||
use fabro_test::{fabro_snapshot, isolated_storage_dir, test_context};
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
|
|||
|
|
@ -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(¤t_record);
|
||||
let legacy_record = fabro_home.join("server.json");
|
||||
std::fs::rename(¤t_record, &legacy_record).unwrap();
|
||||
let pid_u64 = serde_json::from_str::<serde_json::Value>(
|
||||
&std::fs::read_to_string(&legacy_record).unwrap(),
|
||||
)
|
||||
.unwrap()["pid"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let pid = u32::try_from(pid_u64).expect("pid fits in u32");
|
||||
|
||||
let uninstall_output = {
|
||||
let mut uninstall = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
|
||||
fabro_test::apply_test_isolation(&mut uninstall, home_dir.path());
|
||||
uninstall
|
||||
.args(["uninstall", "--yes"])
|
||||
.output()
|
||||
.expect("uninstall should run")
|
||||
};
|
||||
|
||||
stop_pid(pid);
|
||||
let _ = std::fs::remove_file(&legacy_record);
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
|
||||
assert!(
|
||||
!uninstall_output.status.success(),
|
||||
"uninstall --yes should fail when only the legacy record exists"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&uninstall_output.stderr);
|
||||
assert!(
|
||||
stderr.contains(&legacy_record.display().to_string()),
|
||||
"expected stderr to mention the legacy record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(¤t_record.display().to_string()),
|
||||
"expected stderr to mention the current record path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("legacy Fabro CLI"),
|
||||
"expected stderr to instruct manual cleanup, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
fabro_home.exists(),
|
||||
"uninstall should not remove ~/.fabro when the legacy daemon detector fires"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use fabro_types::settings::server::{
|
|||
use fabro_util::Home;
|
||||
|
||||
use super::{ResolveError, default_interp, parse_socket_addr, require_interp};
|
||||
use crate::user::default_storage_dir;
|
||||
|
||||
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerSettings {
|
||||
let storage = resolve_storage(layer.storage.as_ref());
|
||||
|
|
@ -57,7 +58,7 @@ fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings
|
|||
ServerStorageSettings {
|
||||
root: layer
|
||||
.and_then(|storage| storage.root.clone())
|
||||
.unwrap_or_else(|| default_interp(Home::from_env().storage_dir())),
|
||||
.unwrap_or_else(|| default_interp(default_storage_dir())),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ impl Storage {
|
|||
self.root.join("cache")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn slatedb_cache_dir(&self) -> PathBuf {
|
||||
self.cache_dir().join("slatedb")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn secrets_path(&self) -> PathBuf {
|
||||
self.root
|
||||
|
|
@ -173,6 +178,10 @@ mod tests {
|
|||
storage.cache_dir(),
|
||||
std::path::Path::new("/tmp/fabro-data/cache")
|
||||
);
|
||||
assert_eq!(
|
||||
storage.slatedb_cache_dir(),
|
||||
std::path::Path::new("/tmp/fabro-data/cache/slatedb")
|
||||
);
|
||||
assert_eq!(
|
||||
storage.secrets_path(),
|
||||
std::path::Path::new("/tmp/fabro-data/vaults/default/secrets.json")
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@
|
|||
//! `~/.fabro/settings.toml` file. Runtime types that used to be
|
||||
//! re-exported from here live in `fabro_types::settings::user` now.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
|
||||
|
|
@ -15,23 +13,18 @@ use crate::home::Home;
|
|||
use crate::load::load_settings_path;
|
||||
|
||||
pub const SETTINGS_CONFIG_FILENAME: &str = "settings.toml";
|
||||
pub const LEGACY_USER_CONFIG_FILENAME: &str = "cli.toml";
|
||||
pub const LEGACY_OLD_USER_CONFIG_FILENAME: &str = "user.toml";
|
||||
pub const LEGACY_SERVER_CONFIG_FILENAME: &str = "server.toml";
|
||||
pub const FABRO_CONFIG_ENV: &str = "FABRO_CONFIG";
|
||||
|
||||
static WARNED_LEGACY_USER_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
||||
|
||||
pub fn default_settings_path() -> PathBuf {
|
||||
Home::from_env().user_config()
|
||||
}
|
||||
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
Home::from_env().root().join("fabro.sock")
|
||||
pub fn default_storage_dir() -> PathBuf {
|
||||
Home::from_env().root().join("storage")
|
||||
}
|
||||
|
||||
pub fn legacy_default_storage_root() -> PathBuf {
|
||||
Home::from_env().root().to_path_buf()
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
Home::from_env().root().join("fabro.sock")
|
||||
}
|
||||
|
||||
pub fn active_settings_path(path: Option<&Path>) -> PathBuf {
|
||||
|
|
@ -47,37 +40,9 @@ fn active_settings_path_with_lookup(
|
|||
.unwrap_or_else(default_settings_path)
|
||||
}
|
||||
|
||||
pub fn legacy_user_config_path() -> Option<PathBuf> {
|
||||
Some(Home::from_env().root().join(LEGACY_USER_CONFIG_FILENAME))
|
||||
}
|
||||
|
||||
pub fn legacy_old_user_config_path() -> Option<PathBuf> {
|
||||
Some(
|
||||
Home::from_env()
|
||||
.root()
|
||||
.join(LEGACY_OLD_USER_CONFIG_FILENAME),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn legacy_server_config_path() -> Option<PathBuf> {
|
||||
Some(Home::from_env().root().join(LEGACY_SERVER_CONFIG_FILENAME))
|
||||
}
|
||||
|
||||
fn warned_legacy_user_configs() -> &'static Mutex<HashSet<PathBuf>> {
|
||||
WARNED_LEGACY_USER_CONFIGS.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn should_warn_about_legacy_user_config(path: &Path) -> bool {
|
||||
warned_legacy_user_configs()
|
||||
.lock()
|
||||
.expect("legacy user config warning lock poisoned")
|
||||
.insert(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Load settings config from an explicit path or `~/.fabro/settings.toml`,
|
||||
/// returning defaults if the default file doesn't exist. An explicit path that
|
||||
/// doesn't exist is an error.
|
||||
#[allow(clippy::print_stderr)]
|
||||
pub fn load_settings_config(path: Option<&Path>) -> Result<SettingsLayer> {
|
||||
if let Some(explicit) = path
|
||||
.map(Path::to_path_buf)
|
||||
|
|
@ -86,25 +51,7 @@ pub fn load_settings_config(path: Option<&Path>) -> Result<SettingsLayer> {
|
|||
return load_v2_layer_from_path(&explicit);
|
||||
}
|
||||
|
||||
for legacy_path in [
|
||||
legacy_user_config_path(),
|
||||
legacy_old_user_config_path(),
|
||||
legacy_server_config_path(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if legacy_path.is_file() && should_warn_about_legacy_user_config(&legacy_path) {
|
||||
let target = default_settings_path();
|
||||
eprintln!(
|
||||
"Warning: ignoring legacy config file {}. Rename it to {}.",
|
||||
legacy_path.display(),
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let default = Home::from_env().root().join(SETTINGS_CONFIG_FILENAME);
|
||||
let default = default_settings_path();
|
||||
if default.is_file() {
|
||||
load_v2_layer_from_path(&default)
|
||||
} else {
|
||||
|
|
@ -119,23 +66,10 @@ fn load_v2_layer_from_path(path: &Path) -> Result<SettingsLayer> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
LEGACY_OLD_USER_CONFIG_FILENAME, LEGACY_SERVER_CONFIG_FILENAME,
|
||||
LEGACY_USER_CONFIG_FILENAME, SETTINGS_CONFIG_FILENAME, active_settings_path_with_lookup,
|
||||
default_settings_path, default_socket_path, legacy_old_user_config_path,
|
||||
legacy_server_config_path, legacy_user_config_path, should_warn_about_legacy_user_config,
|
||||
SETTINGS_CONFIG_FILENAME, active_settings_path_with_lookup, default_settings_path,
|
||||
default_socket_path, default_storage_dir,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn should_warn_about_legacy_user_config_once_per_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first = dir.path().join("cli.toml");
|
||||
let second = dir.path().join("other-cli.toml");
|
||||
|
||||
assert!(should_warn_about_legacy_user_config(&first));
|
||||
assert!(!should_warn_about_legacy_user_config(&first));
|
||||
assert!(should_warn_about_legacy_user_config(&second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_paths_use_expected_filenames() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
|
|
@ -144,33 +78,8 @@ mod tests {
|
|||
default_settings_path(),
|
||||
home.join(".fabro").join(SETTINGS_CONFIG_FILENAME)
|
||||
);
|
||||
assert_eq!(default_storage_dir(), home.join(".fabro/storage"));
|
||||
assert_eq!(default_socket_path(), home.join(".fabro/fabro.sock"));
|
||||
assert_eq!(
|
||||
legacy_user_config_path(),
|
||||
Some(home.join(".fabro").join(LEGACY_USER_CONFIG_FILENAME))
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_old_user_config_path(),
|
||||
Some(home.join(".fabro").join(LEGACY_OLD_USER_CONFIG_FILENAME))
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_server_config_path(),
|
||||
Some(home.join(".fabro").join(LEGACY_SERVER_CONFIG_FILENAME))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_warn_once_per_legacy_path_even_with_multiple_filenames() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let user = dir.path().join("user.toml");
|
||||
let server = dir.path().join("server.toml");
|
||||
let cli = dir.path().join("cli.toml");
|
||||
|
||||
assert!(should_warn_about_legacy_user_config(&user));
|
||||
assert!(!should_warn_about_legacy_user_config(&user));
|
||||
assert!(should_warn_about_legacy_user_config(&server));
|
||||
assert!(!should_warn_about_legacy_user_config(&server));
|
||||
assert!(should_warn_about_legacy_user_config(&cli));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use fabro_config::parse_settings_layer;
|
||||
use fabro_config::user::default_storage_dir;
|
||||
use fabro_types::settings::server::{
|
||||
GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerListenSettings,
|
||||
};
|
||||
|
|
@ -16,7 +17,7 @@ fn resolves_server_defaults_from_empty_settings() {
|
|||
|
||||
assert_eq!(
|
||||
settings.storage.root.as_source(),
|
||||
Home::from_env().storage_dir().to_string_lossy()
|
||||
default_storage_dir().to_string_lossy()
|
||||
);
|
||||
assert!(settings.web.enabled);
|
||||
assert_eq!(settings.web.url.as_source(), "http://localhost:3000");
|
||||
|
|
@ -36,8 +37,7 @@ fn resolves_server_defaults_from_empty_settings() {
|
|||
ObjectStoreSettings::Local { root } => {
|
||||
assert_eq!(
|
||||
root.as_source(),
|
||||
Home::from_env()
|
||||
.storage_dir()
|
||||
default_storage_dir()
|
||||
.join("objects")
|
||||
.join("artifacts")
|
||||
.to_string_lossy()
|
||||
|
|
@ -51,8 +51,7 @@ fn resolves_server_defaults_from_empty_settings() {
|
|||
ObjectStoreSettings::Local { root } => {
|
||||
assert_eq!(
|
||||
root.as_source(),
|
||||
Home::from_env()
|
||||
.storage_dir()
|
||||
default_storage_dir()
|
||||
.join("objects")
|
||||
.join("slatedb")
|
||||
.to_string_lossy()
|
||||
|
|
|
|||
|
|
@ -815,6 +815,7 @@ async fn post_install_finish(
|
|||
}
|
||||
|
||||
let mut server_env_secrets = Vec::new();
|
||||
let mut dev_token: Option<String> = None;
|
||||
match github {
|
||||
GithubInstallState::Token(github) => {
|
||||
if let Err(err) = write_token_settings(&mut settings_doc) {
|
||||
|
|
@ -826,6 +827,25 @@ async fn post_install_finish(
|
|||
secret_type: VaultSecretType::Environment,
|
||||
description: None,
|
||||
});
|
||||
let home = state.home.clone().unwrap_or_else(Home::from_env);
|
||||
let token = match dev_token::load_or_create_dev_token(&home.dev_token_path()) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return install_error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Err(err) = dev_token::write_dev_token(
|
||||
&Storage::new(state.storage_dir.as_ref())
|
||||
.server_state()
|
||||
.dev_token_path(),
|
||||
&token,
|
||||
) {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
dev_token = Some(token);
|
||||
}
|
||||
GithubInstallState::App(github) => {
|
||||
if let Err(err) = write_github_app_settings(
|
||||
|
|
@ -862,22 +882,6 @@ async fn post_install_finish(
|
|||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
};
|
||||
let home = state.home.clone().unwrap_or_else(Home::from_env);
|
||||
let dev_token = match dev_token::load_or_create_dev_token(&home.dev_token_path()) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
};
|
||||
if let Err(err) = dev_token::write_dev_token(
|
||||
&Storage::new(state.storage_dir.as_ref())
|
||||
.server_state()
|
||||
.dev_token_path(),
|
||||
&dev_token,
|
||||
) {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
|
||||
server_env_secrets.extend([
|
||||
(
|
||||
"FABRO_JWT_PRIVATE_KEY".to_string(),
|
||||
|
|
@ -888,8 +892,10 @@ async fn post_install_finish(
|
|||
BASE64_STANDARD.encode(jwt_public_pem.as_bytes()),
|
||||
),
|
||||
("SESSION_SECRET".to_string(), session_secret),
|
||||
("FABRO_DEV_TOKEN".to_string(), dev_token.clone()),
|
||||
]);
|
||||
if let Some(token) = dev_token.as_ref() {
|
||||
server_env_secrets.push(("FABRO_DEV_TOKEN".to_string(), token.clone()));
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
1971
lib/crates/fabro-spa/assets/assets/entry-g6egnqzk.js
generated
Normal file
1971
lib/crates/fabro-spa/assets/assets/entry-g6egnqzk.js
generated
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1969
lib/crates/fabro-spa/assets/assets/entry-h3nj59g0.js
generated
1969
lib/crates/fabro-spa/assets/assets/entry-h3nj59g0.js
generated
File diff suppressed because one or more lines are too long
4
lib/crates/fabro-spa/assets/index.html
generated
4
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -17,7 +17,7 @@
|
|||
(function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t="dark";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()
|
||||
</script>
|
||||
<link rel="stylesheet" href="/assets/app.css" />
|
||||
<link rel="stylesheet" href="/assets/entry-v1ec8kqg.css" />
|
||||
<link rel="stylesheet" href="/assets/entry-g7fj8z7g.css" />
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
<script type="module" src="/assets/chunk-sadshphz.js"></script>
|
||||
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
|
||||
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
|
||||
<script type="module" src="/assets/entry-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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue