| Total |
|
@@ -88,13 +101,11 @@ export default function RunBilling({ loaderData }: any) {
-
- By Model
-
+ By model
-
+
| Model |
Stages |
Tokens |
@@ -119,7 +130,7 @@ export default function RunBilling({ loaderData }: any) {
))}
-
+
| Total |
{stages.length}
diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx
index 974ca66ec..6a083ab7d 100644
--- a/apps/fabro-web/app/routes/run-detail.tsx
+++ b/apps/fabro-web/app/routes/run-detail.tsx
@@ -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();
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 Run not found. ;
+ return (
+
+
+
+ );
}
return (
@@ -107,16 +113,7 @@ export default function RunDetail({ loaderData, params }: any) {
)}
-
+ {/* TODO: restore an Open PR button when RunPullRequest gains a url field */}
{run.sandboxId && (
@@ -124,50 +121,16 @@ export default function RunDetail({ loaderData, params }: any) {
)}
- {run.sandboxId && (
-
- )}
diff --git a/apps/fabro-web/app/routes/run-graph.tsx b/apps/fabro-web/app/routes/run-graph.tsx
index 40282d60f..9b34a9c51 100644
--- a/apps/fabro-web/app/routes/run-graph.tsx
+++ b/apps/fabro-web/app/routes/run-graph.tsx
@@ -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 (null);
const svgRef = useRef(null);
const [error, setError] = useState(null);
- const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
+ const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
const [direction, setDirection] = useState("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) {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
(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 ("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) {
{graphSvg ? (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
) : (
- No workflow graph available.
+
)}
diff --git a/apps/fabro-web/app/routes/run-settings.tsx b/apps/fabro-web/app/routes/run-settings.tsx
index 40e42a5ad..9bfa09f99 100644
--- a/apps/fabro-web/app/routes/run-settings.tsx
+++ b/apps/fabro-web/app/routes/run-settings.tsx
@@ -33,6 +33,12 @@ export default function RunSettingsPage({ loaderData }: any) {
+
diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx
index 0bbff46f0..9b205d1df 100644
--- a/apps/fabro-web/app/routes/run-stages.tsx
+++ b/apps/fabro-web/app/routes/run-stages.tsx
@@ -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 (
-
+ System prompt
+
+
+
+
+
+
);
}
function AssistantBlock({ content }: { content: string }) {
return (
-
-
+
+ );
+}
+
+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 (
+
+ {children}
+
+ );
+}
+
+const COLLAPSE_AFTER_LINES = 20;
+
+function StreamLabel({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+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 (
+
+
+
+
-
-
-
+ {isLong && !expanded ? (
+
+ ) : null}
+ {visible}
);
}
@@ -208,53 +287,58 @@ function CommandBlock({ turn }: { turn: Extract }
const bgColor = turn.running ? "bg-teal-500/5" : failed ? "bg-coral/5" : "bg-mint/5";
return (
-
+
{/* Header */}
{turn.language === "python" ? "Python" : "Shell"}
- {turn.running && (
- Running...
- )}
- {!turn.running && turn.timedOut && (
- Timed out
- )}
- {!turn.running && !turn.timedOut && (
-
-
- exit {turn.exitCode ?? "?"}
-
- {turn.durationMs != null && (
-
- {turn.durationMs < 1000 ? `${turn.durationMs}ms` : `${(turn.durationMs / 1000).toFixed(1)}s`}
-
- )}
-
- )}
+
+ {turn.running ? (
+ Running…
+ ) : turn.timedOut ? (
+ Timed out
+ ) : (
+ <>
+
+ exit {turn.exitCode ?? "?"}
+
+ {turn.durationMs != null && (
+
+ {turn.durationMs < 1000
+ ? `${turn.durationMs}ms`
+ : `${(turn.durationMs / 1000).toFixed(1)}s`}
+
+ )}
+ >
+ )}
+ {turn.script ? (
+
+
+
+ ) : null}
+
{/* Script */}
{turn.script && (
- {turn.script}
+ {turn.script}
)}
{/* stdout */}
{turn.stdout && (
- stdout
- {turn.stdout}
+
)}
{/* stderr */}
{turn.stderr && (
- stderr
- {turn.stderr}
+
)}
@@ -287,7 +371,14 @@ export default function RunStages({ loaderData }: any) {
}, [isRunning]);
if (!stages.length) {
- return No stages available for this run. ;
+ return (
+
+
+
+ );
}
const selectedConfig = statusConfig[selectedStage.status];
@@ -301,10 +392,10 @@ export default function RunStages({ loaderData }: any) {
-
+
- {selectedStage.name}
- {headerDuration}
+ {selectedStage.name}
+ {headerDuration}
{turns.map((turn: TurnType, i: number) => {
diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx
index e1af8b25e..ad63af0c6 100644
--- a/apps/fabro-web/app/routes/runs.tsx
+++ b/apps/fabro-web/app/routes/runs.tsx
@@ -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 (
-
+
@@ -321,7 +322,7 @@ function PrCard({
)}
{lifecycleLabel != null && (
-
+
{lifecycleLabel}
)}
@@ -336,10 +337,10 @@ function PrCard({
)}
{pr.additions != null && pr.deletions != null && (
<>
-
+
+{pr.additions.toLocaleString()}
-
+
-{pr.deletions.toLocaleString()}
>
@@ -459,7 +460,7 @@ function BoardColumn({ column }: { column: Column }) {
const Icon = iconMap[column.iconType];
return (
-
+
{column.name}
@@ -501,7 +502,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
{run.repo}
{run.title}
{lifecycleLabel != null && (
-
+
{lifecycleLabel}
)}
@@ -607,12 +608,12 @@ function CopyButton({ text }: { text: string }) {
);
}
-function EmptyState() {
+function RunsLandingEmpty() {
return (
- Workflow runs will appear here as you run them.
+ Your runs will appear here.
@@ -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 (
@@ -750,7 +755,9 @@ export default function Runs({ loaderData }: any) {
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) {
-
+
))}
- {totalRuns === 0 && (
-
- )}
+ {totalRuns === 0 ? (
+
+ ) : filteredRuns === 0 ? (
+
+
+
+ ) : null}
>
) : (
<>
@@ -847,9 +865,16 @@ export default function Runs({ loaderData }: any) {
);
})}
- {totalRuns === 0 && (
-
- )}
+ {totalRuns === 0 ? (
+
+ ) : filteredRuns === 0 ? (
+
+
+
+ ) : null}
>
)}
diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx
index 9feecd60a..e557d622a 100644
--- a/apps/fabro-web/app/routes/settings.tsx
+++ b/apps/fabro-web/app/routes/settings.tsx
@@ -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("/settings", { request });
return { settings };
@@ -23,10 +21,15 @@ export default function Settings({ loaderData }: any) {
const { settings } = loaderData;
return (
-
+ <>
+
+ Snapshot of the server configuration. Edit via{" "}
+ settings.toml; changes
+ take effect on the next server restart.
+
-
+ >
);
}
diff --git a/apps/fabro-web/app/routes/setup.tsx b/apps/fabro-web/app/routes/setup.tsx
index a159ddb76..816ac9af9 100644
--- a/apps/fabro-web/app/routes/setup.tsx
+++ b/apps/fabro-web/app/routes/setup.tsx
@@ -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: (
+
+ Run{" "}
+ fabro install on the same
+ host that runs the Fabro server.
+
+ ),
+ },
+ {
+ title: "Choose GitHub App setup",
+ body: (
+
+ The CLI opens GitHub, exchanges the manifest code, and writes the
+ required settings and secrets locally.
+
+ ),
+ },
+ {
+ title: "Restart the server, then return to sign in",
+ body: (
+
+ Once the server comes back up, you can authenticate from the browser.
+
+ ),
+ },
+];
export default function Setup() {
return (
-
+
Set up Fabro
-
- Run the installer on the same host that runs the Fabro server to
- register a GitHub App and write local configuration.
+
+ Run the installer on the server host to register a GitHub App and write
+ local configuration.
-
-
-
- 1. Open a terminal on the server host
-
-
- fabro install
-
-
-
-
- 2. Choose GitHub App setup
-
-
- The CLI opens GitHub, exchanges the manifest code, and writes the
- required settings and secrets locally.
-
-
-
-
+
+ {steps.map((step, index) => (
+ -
+
+ {index + 1}
+
+
+ {step.title}
+ {step.body}
+
+
+ ))}
+
+
+ Continue to sign in
+
);
}
diff --git a/apps/fabro-web/app/routes/start.tsx b/apps/fabro-web/app/routes/start.tsx
index e0c0a50c0..a4832ee02 100644
--- a/apps/fabro-web/app/routes/start.tsx
+++ b/apps/fabro-web/app/routes/start.tsx
@@ -131,7 +131,7 @@ export default function Start({ loaderData }: any) {
diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx
index 30fbe39e9..0ce3714c6 100644
--- a/docs/administration/server-configuration.mdx
+++ b/docs/administration/server-configuration.mdx
@@ -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]`.
-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.
### Which sections are server-owned
diff --git a/docs/administration/troubleshooting.mdx b/docs/administration/troubleshooting.mdx
index 89142fc64..8e818eebb 100644
--- a/docs/administration/troubleshooting.mdx
+++ b/docs/administration/troubleshooting.mdx
@@ -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
diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml
index 5bfc1ab38..49ab01e6b 100644
--- a/docs/api-reference/fabro-api.yaml
+++ b/docs/api-reference/fabro-api.yaml
@@ -401,10 +401,11 @@ paths:
operationId: listRuns
tags: [Runs]
summary: List Runs
- description: Returns durable run summaries from the backing store, including runs persisted before the current server boot.
+ description: Returns durable run summaries from the backing store, including runs persisted before the current server boot. Archived runs are hidden by default; pass `include_archived=true` to include them in the response.
parameters:
- $ref: "#/components/parameters/PageLimit"
- $ref: "#/components/parameters/PageOffset"
+ - $ref: "#/components/parameters/IncludeArchived"
responses:
"200":
description: Paginated durable run summaries
@@ -674,6 +675,69 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
+ /api/v1/runs/{id}/archive:
+ post:
+ operationId: archiveRun
+ tags: [Runs]
+ summary: Archive Run
+ description: >
+ Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`.
+ Archived runs are hidden from default listings and are read-only until
+ unarchived. Idempotent on already-archived runs. Returns 409 if the run
+ is not terminal.
+ parameters:
+ - $ref: "#/components/parameters/RunId"
+ responses:
+ "200":
+ description: Run archived (or already archived)
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RunStatusResponse"
+ "404":
+ description: Run not found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "409":
+ description: Run is not terminal and cannot be archived
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+
+ /api/v1/runs/{id}/unarchive:
+ post:
+ operationId: unarchiveRun
+ tags: [Runs]
+ summary: Unarchive Run
+ description: >
+ Restores an archived run to its prior terminal status. Idempotent on
+ runs that are terminal but not archived (returns the current status
+ without emitting an event). Returns 409 if the run is active.
+ parameters:
+ - $ref: "#/components/parameters/RunId"
+ responses:
+ "200":
+ description: Run unarchived (or already not archived)
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RunStatusResponse"
+ "404":
+ description: Run not found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "409":
+ description: Run is active and cannot be unarchived
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+
/api/v1/runs/{id}/graph:
get:
operationId: retrieveRunGraph
@@ -1970,6 +2034,16 @@ components:
default: 0
example: 0
+ IncludeArchived:
+ name: include_archived
+ in: query
+ required: false
+ description: Whether to include archived runs in the response. Defaults to `false`.
+ schema:
+ type: boolean
+ default: false
+ example: false
+
ModelProviderFilter:
name: provider
in: query
@@ -2207,7 +2281,6 @@ components:
required:
- status
- restart_url
- - dev_token
properties:
status:
type: string
@@ -2217,6 +2290,10 @@ components:
format: uri
dev_token:
type: string
+ description: |
+ Dev token used to bootstrap login. Only included when the operator
+ chose the personal access token flow; GitHub App installs rely on
+ OAuth and do not receive a dev token.
# ── Pagination ───────────────────────────────────────────────────────
@@ -2630,7 +2707,11 @@ components:
# ── Run Schemas ──────────────────────────────────────────────────────
RunStatus:
- description: Lifecycle status of a run.
+ description: >
+ Lifecycle status of a run. `archived` is a terminal status reached by
+ an explicit user action on a previously terminal (`succeeded`, `failed`,
+ or `dead`) run; archived runs are hidden from default listings and are
+ read-only until unarchived.
type: string
enum:
- submitted
@@ -2643,6 +2724,7 @@ components:
- succeeded
- failed
- dead
+ - archived
RunManifest:
description: Self-contained workflow run manifest.
diff --git a/docs/changelog/2026-04-19.mdx b/docs/changelog/2026-04-19.mdx
new file mode 100644
index 000000000..51fbac4bd
--- /dev/null
+++ b/docs/changelog/2026-04-19.mdx
@@ -0,0 +1,24 @@
+---
+title: "Archive terminal runs"
+date: "2026-04-19"
+---
+
+## Archive terminal runs
+
+Terminal runs — `succeeded`, `failed`, and `dead` — can now be explicitly archived to signal "reviewed, no further action needed." Archived runs are hidden from default listings (`fabro ps`, `GET /api/v1/runs`) and are read-only: any mutation on an archived run returns an actionable error asking you to unarchive first.
+
+Two new CLI commands mirror `fabro rm`'s bulk-by-ID shape:
+
+```
+fabro archive [ ...]
+fabro unarchive [ ...]
+```
+
+Both are idempotent in their respective directions — archiving an already-archived run is a no-op, and unarchiving a non-archived terminal run is a no-op — so retries and mixed-id batches are safe. Unarchive restores the run's exact prior terminal status.
+
+On the HTTP surface, two new endpoints sit alongside the existing control family:
+
+- `POST /api/v1/runs/{id}/archive`
+- `POST /api/v1/runs/{id}/unarchive`
+
+`GET /api/v1/runs` gained an `include_archived=true` query parameter for opting in. `fabro ps` continues to show active runs only by default; `fabro ps -a` now shows active, terminal, and archived runs together.
diff --git a/docs/docs.json b/docs/docs.json
index 2b57cae55..e8863ad78 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -252,6 +252,7 @@
"group": "April 2026",
"icon": "clock-rotate-left",
"pages": [
+ "changelog/2026-04-19",
"changelog/2026-04-18",
"changelog/2026-04-17",
"changelog/2026-04-16",
diff --git a/docs/plans/2026-04-19-001-feat-archived-run-status-plan.md b/docs/plans/2026-04-19-001-feat-archived-run-status-plan.md
index af46f9f0b..c80c2d965 100644
--- a/docs/plans/2026-04-19-001-feat-archived-run-status-plan.md
+++ b/docs/plans/2026-04-19-001-feat-archived-run-status-plan.md
@@ -1,7 +1,7 @@
---
title: "feat: Add terminal `archived` status to workflow runs"
type: feat
-status: active
+status: completed
date: 2026-04-19
origin: docs/brainstorms/2026-04-19-run-archived-status-requirements.md
---
diff --git a/docs/reference/user-configuration.mdx b/docs/reference/user-configuration.mdx
index c9c29f9bf..3c426ba56 100644
--- a/docs/reference/user-configuration.mdx
+++ b/docs/reference/user-configuration.mdx
@@ -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.
-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.
## File location
diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs
index 6fc0cae84..5d836c5aa 100644
--- a/lib/crates/fabro-cli/src/args.rs
+++ b/lib/crates/fabro-cli/src/args.rs
@@ -292,6 +292,26 @@ pub(crate) struct RunsRemoveArgs {
pub(crate) force: bool,
}
+#[derive(Args)]
+pub(crate) struct RunsArchiveArgs {
+ #[command(flatten)]
+ pub(crate) server: ServerTargetArgs,
+
+ /// Run IDs or workflow names to archive
+ #[arg(required = true)]
+ pub(crate) runs: Vec,
+}
+
+#[derive(Args)]
+pub(crate) struct RunsUnarchiveArgs {
+ #[command(flatten)]
+ pub(crate) server: ServerTargetArgs,
+
+ /// Run IDs or workflow names to unarchive
+ #[arg(required = true)]
+ pub(crate) runs: Vec,
+}
+
#[derive(Args)]
pub(crate) struct LogsArgs {
#[command(flatten)]
@@ -942,6 +962,11 @@ pub(crate) enum RunsCommands {
Rm(RunsRemoveArgs),
/// Show detailed information about a workflow run
Inspect(InspectArgs),
+ /// Mark terminal runs as archived (reviewed, no further action needed).
+ /// Archived runs are hidden from default listings.
+ Archive(RunsArchiveArgs),
+ /// Restore archived runs to their prior terminal status.
+ Unarchive(RunsUnarchiveArgs),
}
impl RunsCommands {
@@ -950,6 +975,8 @@ impl RunsCommands {
Self::Ps(_) => "ps",
Self::Rm(_) => "rm",
Self::Inspect(_) => "inspect",
+ Self::Archive(_) => "archive",
+ Self::Unarchive(_) => "unarchive",
}
}
}
diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs
index a2db35feb..f4a182c27 100644
--- a/lib/crates/fabro-cli/src/commands/config/mod.rs
+++ b/lib/crates/fabro-cli/src/commands/config/mod.rs
@@ -30,10 +30,8 @@ fn config_layers(
Some(path) => workflow_and_project_layers(path, cwd)?,
None => (SettingsLayer::default(), load_settings_project(cwd)?),
};
- let user_layer = user_config::settings_layer_with_config_and_storage_dir(
- Some(ctx.base_config_path()),
- None,
- )?;
+ let user_layer =
+ user_config::load_settings_with_config_and_storage_dir(Some(ctx.base_config_path()), None)?;
Ok(EffectiveSettingsLayers::new(
SettingsLayer::default(),
workflow_layer,
diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs
index c2f461dc3..31ae8bf75 100644
--- a/lib/crates/fabro-cli/src/commands/doctor.rs
+++ b/lib/crates/fabro-cli/src/commands/doctor.rs
@@ -7,11 +7,7 @@ use std::path::{Path, PathBuf};
use anyhow::Result;
use fabro_api::types as api_types;
-use fabro_config::legacy_env;
-use fabro_config::user::{
- active_settings_path, legacy_old_user_config_path, legacy_server_config_path,
- legacy_user_config_path,
-};
+use fabro_config::user::active_settings_path;
use fabro_types::settings::CliSettings;
use fabro_types::settings::cli::{CliLayer, OutputFormat};
pub(crate) use fabro_util::check_report::{
@@ -27,12 +23,9 @@ use crate::command_context::CommandContext;
use crate::shared::print_json_pretty;
use crate::user_config;
-pub(crate) fn check_config(
- settings_path: Option,
- legacy_paths: &[PathBuf],
-) -> CheckResult {
- match (settings_path, legacy_paths.is_empty()) {
- (Some(path), true) => {
+pub(crate) fn check_config(settings_path: Option) -> CheckResult {
+ match settings_path {
+ Some(path) => {
let display = contract_tilde(&path);
CheckResult {
name: "Configuration".to_string(),
@@ -45,44 +38,7 @@ pub(crate) fn check_config(
remediation: None,
}
}
- (Some(path), false) => {
- let display = contract_tilde(&path);
- CheckResult {
- name: "Configuration".to_string(),
- status: CheckStatus::Warning,
- summary: display.display().to_string(),
- details: std::iter::once(CheckDetail::new(format!(
- "Loaded from {}",
- display.display()
- )))
- .chain(legacy_paths.iter().map(|legacy| {
- let legacy_display = contract_tilde(legacy);
- CheckDetail::new(format!(
- "Ignoring legacy config file {}",
- legacy_display.display()
- ))
- }))
- .collect(),
- remediation: Some("Delete or rename legacy config files".to_string()),
- }
- }
- (None, false) => CheckResult {
- name: "Configuration".to_string(),
- status: CheckStatus::Warning,
- summary: "legacy config files ignored".to_string(),
- details: legacy_paths
- .iter()
- .map(|legacy| {
- CheckDetail::new(format!("Found legacy config file {}", legacy.display()))
- })
- .chain(std::iter::once(CheckDetail::new(
- "Rename one to ~/.fabro/settings.toml or create a new settings.toml"
- .to_string(),
- )))
- .collect(),
- remediation: Some("Create ~/.fabro/settings.toml".to_string()),
- },
- (None, true) => CheckResult {
+ None => CheckResult {
name: "Configuration".to_string(),
status: CheckStatus::Warning,
summary: "no settings config file found".to_string(),
@@ -94,22 +50,6 @@ pub(crate) fn check_config(
}
}
-fn check_legacy_env(path: Option) -> Option {
- path.map(|path| {
- let display = contract_tilde(&path);
- CheckResult {
- name: "Legacy .env".to_string(),
- status: CheckStatus::Warning,
- summary: "legacy secrets file detected".to_string(),
- details: vec![CheckDetail::new(format!(
- "{} is no longer read by fabro",
- display.display()
- ))],
- remediation: Some("Re-enter credentials with `fabro provider login`.".to_string()),
- }
- })
-}
-
#[derive(Debug, Clone, PartialEq, Eq)]
struct StorageDirStatus {
path: PathBuf,
@@ -289,37 +229,20 @@ pub(crate) async fn run_doctor(
};
let settings_config_path = active_settings_path(None);
- let legacy_config_paths = [
- legacy_user_config_path(),
- legacy_old_user_config_path(),
- legacy_server_config_path(),
- ]
- .into_iter()
- .flatten()
- .filter(|path| path.exists())
- .collect::>();
- let legacy_env_path = {
- let p = legacy_env::legacy_env_file_path();
- p.exists().then_some(p)
- };
let settings = user_config::load_settings().unwrap_or_default();
- let storage_dir_path = user_config::storage_dir(&settings)
- .unwrap_or_else(|_| fabro_util::Home::from_env().storage_dir());
+ let storage_dir_path =
+ user_config::storage_dir(&settings).unwrap_or_else(|_| user_config::default_storage_dir());
let storage_dir = probe_storage_dir(&storage_dir_path);
- let mut local_checks = vec![
+ let local_checks = vec![
check_config(
settings_config_path
.exists()
.then_some(settings_config_path),
- &legacy_config_paths,
),
check_storage_dir(&storage_dir),
];
- if let Some(legacy_env_check) = check_legacy_env(legacy_env_path) {
- local_checks.push(legacy_env_check);
- }
let mut report = CheckReport {
title: "Fabro Doctor".to_string(),
@@ -475,45 +398,18 @@ mod tests {
#[test]
fn check_config_pass_with_path() {
- let result = check_config(Some(PathBuf::from("/home/user/.fabro/settings.toml")), &[]);
+ let result = check_config(Some(PathBuf::from("/home/user/.fabro/settings.toml")));
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains(".fabro/settings.toml"));
}
#[test]
fn check_config_warning_without_path() {
- let result = check_config(None, &[]);
+ let result = check_config(None);
assert_eq!(result.status, CheckStatus::Warning);
assert!(result.remediation.is_some());
}
- #[test]
- fn check_config_warning_for_legacy_only_path() {
- let result = check_config(None, &[PathBuf::from("/home/user/.fabro/cli.toml")]);
- assert_eq!(result.status, CheckStatus::Warning);
- assert!(result.summary.contains("legacy"));
- }
-
- #[test]
- fn check_legacy_env_warning_when_present() {
- let result = check_legacy_env(Some(PathBuf::from("/home/user/.fabro/.env")));
- assert_eq!(
- result.as_ref().map(|check| check.status),
- Some(CheckStatus::Warning)
- );
- assert!(
- result
- .as_ref()
- .is_some_and(|check| check.summary.contains("legacy secrets file"))
- );
- }
-
- #[test]
- fn check_legacy_env_is_omitted_when_absent() {
- let result = check_legacy_env(None);
- assert!(result.is_none());
- }
-
// -- check_storage_dir --
#[test]
diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs
index 02a114846..6cef70359 100644
--- a/lib/crates/fabro-cli/src/commands/install.rs
+++ b/lib/crates/fabro-cli/src/commands/install.rs
@@ -21,8 +21,8 @@ use dialoguer::theme::ColorfulTheme;
use dialoguer::{MultiSelect, Select};
use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType};
use fabro_auth::{AuthCredential, AuthMethod, codex_oauth_config, credential_id_for};
-use fabro_config::user::{SETTINGS_CONFIG_FILENAME, legacy_default_storage_root};
-use fabro_config::{ResolveError, Storage, envfile, legacy_env};
+use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir};
+use fabro_config::{ResolveError, Storage, envfile};
use fabro_install::{
InstallListenConfig, generate_jwt_keypair, merge_server_settings as merge_server_settings_impl,
write_github_app_settings, write_token_settings,
@@ -1171,7 +1171,9 @@ async fn persist_install_outputs(
settings_write,
server_was_running,
|path| Box::pin(server_client::connect_api_client(path)),
- |path, timeout| Box::pin(stop::stop_server(path, timeout)),
+ |path, timeout| {
+ Box::pin(async move { stop::stop_server(path, timeout).await.unwrap_or(false) })
+ },
)
.await
}
@@ -1360,7 +1362,9 @@ async fn restart_server_after_install(
restart_server_after_install_with(
storage_dir,
config_path,
- |path, timeout| Box::pin(stop::stop_server(path, timeout)),
+ |path, timeout| {
+ Box::pin(async move { stop::stop_server(path, timeout).await.unwrap_or(false) })
+ },
|storage_dir, config_path| {
Box::pin(start::ensure_server_running_for_storage(
storage_dir,
@@ -1483,9 +1487,9 @@ async fn run_install_github_inner(
let storage_dir = user_config::storage_dir(&parsed_settings).unwrap_or_else(|_| {
args.storage_dir
.clone_path()
- .unwrap_or_else(|| legacy_default_storage_root().join("storage"))
+ .unwrap_or_else(default_storage_dir)
});
- let server_was_running = record::active_server_record(&storage_dir).is_some();
+ let server_was_running = record::active_server_record(&storage_dir)?.is_some();
let mut doc: toml::Value = toml::from_str(&existing_config_contents)
.context("failed to parse existing settings.toml")?;
@@ -1628,7 +1632,7 @@ async fn run_install_inner(
let emoji = console::Emoji("⚒️ ", "");
let cli_settings = user_config::load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let storage_dir = user_config::storage_dir(&cli_settings)?;
- let server_was_running = record::active_server_record(&storage_dir).is_some();
+ let server_was_running = record::active_server_record(&storage_dir)?.is_some();
let fabro_dir = fabro_util::Home::from_env().root().to_path_buf();
let config_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME);
let existing_config_contents = std::fs::read_to_string(&config_path).ok();
@@ -1661,18 +1665,6 @@ async fn run_install_inner(
std::fs::create_dir_all(&fabro_dir)
.with_context(|| format!("creating fabro home directory {}", fabro_dir.display()))?;
- {
- let env_path = legacy_env::legacy_env_file_path();
- if env_path.exists() {
- fabro_util::printerr!(
- printer,
- " Warning: {} is no longer read by fabro server. This install will persist runtime secrets in server.env and workflow-visible credentials in the vault instead.",
- env_path.display()
- );
- fabro_util::printerr!(printer, "");
- }
- }
-
let facts = InstallFacts {
codex_detected: detect_binary_on_path("codex").await,
};
diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs
index 9654145a4..9445e6bac 100644
--- a/lib/crates/fabro-cli/src/commands/provider/login.rs
+++ b/lib/crates/fabro-cli/src/commands/provider/login.rs
@@ -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()
diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs
index 81add8adf..9d6d2e912 100644
--- a/lib/crates/fabro-cli/src/commands/run/command.rs
+++ b/lib/crates/fabro-cli/src/commands/run/command.rs
@@ -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;
diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs
index 4406ef5b5..cd43461e2 100644
--- a/lib/crates/fabro-cli/src/commands/run/mod.rs
+++ b/lib/crates/fabro-cli/src/commands/run/mod.rs
@@ -7,7 +7,7 @@ use fabro_util::terminal::Styles;
use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs};
use crate::command_context::CommandContext;
use crate::shared::print_json_pretty;
-use crate::user_config::settings_layer_with_storage_dir;
+use crate::user_config::load_settings_with_storage_dir;
pub(crate) mod attach;
pub(crate) mod command;
@@ -38,7 +38,7 @@ pub(crate) async fn dispatch(
RunCommands::Run(args) => Box::pin(command::execute(args, cli, cli_layer, printer)).await,
RunCommands::Create(args) => {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
- let cli_defaults = settings_layer_with_storage_dir(None)?;
+ let cli_defaults = load_settings_with_storage_dir(None)?;
let ctx = CommandContext::for_target(&args.target, printer, cli.clone(), cli_layer)?;
let created_run = Box::pin(create::create_run(
&ctx,
diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs
index 2adb7bea2..543a348bf 100644
--- a/lib/crates/fabro-cli/src/commands/run/rewind.rs
+++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs
@@ -42,6 +42,11 @@ pub(crate) async fn run(
let client = ctx.server().await?;
let run_id = client.resolve_run(&args.run_id).await?.run_id;
let state = client.get_run_state(&run_id).await?;
+ let current_status = state
+ .status
+ .as_ref()
+ .map(|record| record.status)
+ .context("run has no recorded status — cannot rewind")?;
let record = state.run.context("Failed to load run record from store")?;
ensure_matching_repo_origin(record.repo_origin_url.as_deref(), "rewind")?;
let store = Store::new(repo);
@@ -65,6 +70,7 @@ pub(crate) async fn run(
run_id,
target: target.clone(),
push: !args.no_push,
+ current_status,
})?;
let entry = timeline.resolve(&target)?;
reset_rewound_run_state(client.as_ref(), &store, &run_id, entry).await?;
diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs
index d6b795c06..ffe6a34b2 100644
--- a/lib/crates/fabro-cli/src/commands/run/wait.rs
+++ b/lib/crates/fabro-cli/src/commands/run/wait.rs
@@ -113,7 +113,8 @@ fn print_human_output(
RunStatus::Succeeded => (&styles.bold_green, "Succeeded"),
RunStatus::Failed => (&styles.bold_red, "Failed"),
RunStatus::Dead => (&styles.bold_red, "Dead"),
- // Poll loop only breaks on is_terminal() which is Succeeded | Failed | Dead
+ RunStatus::Archived => (&styles.dim, "Archived"),
+ // Poll loop only breaks on is_terminal() which is the four arms above.
_ => unreachable!(),
};
let status_display = style.apply_to(label);
diff --git a/lib/crates/fabro-cli/src/commands/runs/archive.rs b/lib/crates/fabro-cli/src/commands/runs/archive.rs
new file mode 100644
index 000000000..4ba328ef1
--- /dev/null
+++ b/lib/crates/fabro-cli/src/commands/runs/archive.rs
@@ -0,0 +1,138 @@
+use anyhow::{Result, bail};
+use fabro_types::settings::CliSettings;
+use fabro_types::settings::cli::{CliLayer, OutputFormat};
+use fabro_util::printer::Printer;
+
+use super::short_run_id;
+use crate::args::{RunsArchiveArgs, RunsUnarchiveArgs};
+use crate::command_context::CommandContext;
+use crate::server_client;
+use crate::server_runs::{
+ ServerRunSummaryInfo, ServerSummaryLookup, resolve_server_run_from_summaries,
+};
+use crate::shared::print_json_pretty;
+
+pub(crate) async fn archive_command(
+ args: &RunsArchiveArgs,
+ cli: &CliSettings,
+ cli_layer: &CliLayer,
+ printer: Printer,
+) -> Result<()> {
+ let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?;
+ let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?;
+ run_bulk(
+ Action::Archive,
+ &args.runs,
+ lookup.client(),
+ lookup.runs(),
+ cli,
+ printer,
+ )
+ .await
+}
+
+pub(crate) async fn unarchive_command(
+ args: &RunsUnarchiveArgs,
+ cli: &CliSettings,
+ cli_layer: &CliLayer,
+ printer: Printer,
+) -> Result<()> {
+ let ctx = CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?;
+ let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?;
+ run_bulk(
+ Action::Unarchive,
+ &args.runs,
+ lookup.client(),
+ lookup.runs(),
+ cli,
+ printer,
+ )
+ .await
+}
+
+#[derive(Clone, Copy)]
+enum Action {
+ Archive,
+ Unarchive,
+}
+
+impl Action {
+ fn past(self) -> &'static str {
+ match self {
+ Self::Archive => "archived",
+ Self::Unarchive => "unarchived",
+ }
+ }
+
+ fn json_key(self) -> &'static str {
+ self.past()
+ }
+}
+
+async fn run_bulk(
+ action: Action,
+ identifiers: &[String],
+ client: &server_client::ServerStoreClient,
+ runs: &[ServerRunSummaryInfo],
+ cli: &CliSettings,
+ printer: Printer,
+) -> Result<()> {
+ let json = cli.output.format == OutputFormat::Json;
+ let mut had_errors = false;
+ let mut changed = Vec::new();
+ let mut errors = Vec::new();
+
+ for identifier in identifiers {
+ let run = match resolve_server_run_from_summaries(runs, identifier) {
+ Ok(run) => run,
+ Err(err) => {
+ if !json {
+ fabro_util::printerr!(printer, "error: {identifier}: {err}");
+ }
+ errors.push(serde_json::json!({
+ "identifier": identifier,
+ "error": err.to_string(),
+ }));
+ had_errors = true;
+ continue;
+ }
+ };
+
+ let run_id = run.run_id();
+ let result = match action {
+ Action::Archive => client.archive_run(&run_id).await,
+ Action::Unarchive => client.unarchive_run(&run_id).await,
+ };
+ match result {
+ Ok(()) => {
+ let run_id_string = run_id.to_string();
+ changed.push(run_id_string.clone());
+ if !json {
+ fabro_util::printerr!(printer, "{}", short_run_id(&run_id_string));
+ }
+ }
+ Err(err) => {
+ if !json {
+ fabro_util::printerr!(printer, "error: {identifier}: {err}");
+ }
+ errors.push(serde_json::json!({
+ "identifier": identifier,
+ "error": err.to_string(),
+ }));
+ had_errors = true;
+ }
+ }
+ }
+
+ if json {
+ let mut body = serde_json::Map::new();
+ body.insert(action.json_key().to_string(), serde_json::json!(changed));
+ body.insert("errors".to_string(), serde_json::json!(errors));
+ print_json_pretty(&serde_json::Value::Object(body))?;
+ }
+
+ if had_errors {
+ bail!("some runs could not be {}", action.past());
+ }
+ Ok(())
+}
diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs
index 86796cfb0..e33769198 100644
--- a/lib/crates/fabro-cli/src/commands/runs/list.rs
+++ b/lib/crates/fabro-cli/src/commands/runs/list.rs
@@ -71,7 +71,7 @@ pub(crate) async fn list_command(
} else {
fabro_util::printerr!(
printer,
- "No running processes found. Use -a to show all runs."
+ "No running processes found. Use -a to show all runs (including archived)."
);
}
return Ok(());
@@ -149,7 +149,7 @@ fn status_cell(status: RunStatus, use_color: bool) -> CellStruct {
RunStatus::Succeeded => Some(Color::Green),
RunStatus::Failed => Some(Color::Red),
RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan),
- RunStatus::Queued | RunStatus::Dead => Some(Color::Ansi256(8)),
+ RunStatus::Queued | RunStatus::Dead | RunStatus::Archived => Some(Color::Ansi256(8)),
RunStatus::Blocked | RunStatus::Removing => Some(Color::Yellow),
RunStatus::Paused => Some(Color::Magenta),
};
diff --git a/lib/crates/fabro-cli/src/commands/runs/mod.rs b/lib/crates/fabro-cli/src/commands/runs/mod.rs
index 589d553c8..f3900a94e 100644
--- a/lib/crates/fabro-cli/src/commands/runs/mod.rs
+++ b/lib/crates/fabro-cli/src/commands/runs/mod.rs
@@ -6,6 +6,7 @@ use fabro_util::terminal::Styles;
use crate::args::RunsCommands;
+pub(crate) mod archive;
pub(crate) mod inspect;
pub(crate) mod list;
pub(crate) mod rm;
@@ -23,6 +24,12 @@ pub(crate) async fn dispatch(
}
RunsCommands::Rm(args) => rm::remove_command(&args, cli, cli_layer, printer).await,
RunsCommands::Inspect(args) => inspect::run(&args, cli, cli_layer, printer).await,
+ RunsCommands::Archive(args) => {
+ archive::archive_command(&args, cli, cli_layer, printer).await
+ }
+ RunsCommands::Unarchive(args) => {
+ archive::unarchive_command(&args, cli, cli_layer, printer).await
+ }
}
}
diff --git a/lib/crates/fabro-cli/src/commands/server/foreground.rs b/lib/crates/fabro-cli/src/commands/server/foreground.rs
index 390dc4a38..f2b536281 100644
--- a/lib/crates/fabro-cli/src/commands/server/foreground.rs
+++ b/lib/crates/fabro-cli/src/commands/server/foreground.rs
@@ -15,7 +15,7 @@ pub(crate) async fn execute(
record_path: PathBuf,
mut serve_args: ServeArgs,
bind: BindRequest,
- storage_dir: Option,
+ 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,
diff --git a/lib/crates/fabro-cli/src/commands/server/mod.rs b/lib/crates/fabro-cli/src/commands/server/mod.rs
index 77addbaae..eb8cfe6fb 100644
--- a/lib/crates/fabro-cli/src/commands/server/mod.rs
+++ b/lib/crates/fabro-cli/src/commands/server/mod.rs
@@ -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,
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,
diff --git a/lib/crates/fabro-cli/src/commands/server/record.rs b/lib/crates/fabro-cli/src/commands/server/record.rs
index 1a393fc52..d48088977 100644
--- a/lib/crates/fabro-cli/src/commands/server/record.rs
+++ b/lib/crates/fabro-cli/src/commands/server/record.rs
@@ -5,11 +5,12 @@
use std::path::{Path, PathBuf};
-use anyhow::{Context, Result};
+use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use fabro_config::Storage;
-use fabro_config::user::legacy_default_storage_root;
+use fabro_config::user::default_storage_dir;
use fabro_server::bind::Bind;
+use fabro_util::Home;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -55,9 +56,8 @@ fn server_record_path(storage_dir: &Path) -> PathBuf {
}
fn legacy_record_path(storage_dir: &Path) -> Option {
- let default_storage_dir = legacy_default_storage_root().join("storage");
- if storage_dir == default_storage_dir {
- Some(server_record_path(&legacy_default_storage_root()))
+ if storage_dir == default_storage_dir() {
+ Some(Home::from_env().root().join("server.json"))
} else {
None
}
@@ -76,14 +76,29 @@ fn active_server_record_at_path(path: PathBuf) -> Option {
}
}
-pub(crate) fn active_server_record_details(storage_dir: &Path) -> Option {
+pub(crate) fn active_server_record_details(
+ storage_dir: &Path,
+) -> Result | |