From a1ad81430f85887e09a124e1d92abfef85ed0fd5 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 09:55:01 -0400 Subject: [PATCH 01/12] refactor(server): store GitHub meta cache under storage root Keep GitHub /meta cache state under the resolved server storage tree by adding a storage cache accessor and wiring the resolver to use /cache. --- lib/crates/fabro-config/src/storage.rs | 9 +++++++++ lib/crates/fabro-server/src/ip_allowlist.rs | 20 +++++++++++++++----- lib/crates/fabro-server/src/serve.rs | 2 +- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/crates/fabro-config/src/storage.rs b/lib/crates/fabro-config/src/storage.rs index 0aad99eab..dc0a4f2a5 100644 --- a/lib/crates/fabro-config/src/storage.rs +++ b/lib/crates/fabro-config/src/storage.rs @@ -34,6 +34,11 @@ impl Storage { self.root.join("logs") } + #[must_use] + pub fn cache_dir(&self) -> PathBuf { + self.root.join("cache") + } + #[must_use] pub fn secrets_path(&self) -> PathBuf { self.root @@ -164,6 +169,10 @@ mod tests { storage.logs_dir(), std::path::Path::new("/tmp/fabro-data/logs") ); + assert_eq!( + storage.cache_dir(), + std::path::Path::new("/tmp/fabro-data/cache") + ); assert_eq!( storage.secrets_path(), std::path::Path::new("/tmp/fabro-data/vaults/default/secrets.json") diff --git a/lib/crates/fabro-server/src/ip_allowlist.rs b/lib/crates/fabro-server/src/ip_allowlist.rs index aaa7ce4d2..0d8f0956b 100644 --- a/lib/crates/fabro-server/src/ip_allowlist.rs +++ b/lib/crates/fabro-server/src/ip_allowlist.rs @@ -10,7 +10,6 @@ use axum::response::{IntoResponse, Response}; use fabro_types::settings::server::{ IpAllowEntry, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, }; -use fabro_util::Home; use ipnet::IpNet; use serde::{Deserialize, Serialize}; use tracing::warn; @@ -61,11 +60,11 @@ impl GitHubMetaResolver { } } - pub fn from_home() -> Result { + pub fn from_cache_dir(cache_dir: &Path) -> Result { Ok(Self::new( fabro_http::http_client().context("building GitHub meta HTTP client")?, GITHUB_META_URL.to_string(), - github_meta_cache_path(Home::from_env().root()), + github_meta_cache_path(cache_dir), )) } @@ -315,8 +314,8 @@ fn normalize_ip(ip: IpAddr) -> IpAddr { } } -pub fn github_meta_cache_path(home: &Path) -> PathBuf { - home.join("cache/github-meta-hooks.json") +pub fn github_meta_cache_path(cache_dir: &Path) -> PathBuf { + cache_dir.join("github-meta-hooks.json") } #[cfg(test)] @@ -408,6 +407,17 @@ mod tests { assert!(allowlist.contains(&"::ffff:10.1.2.3".parse().unwrap())); } + #[test] + fn github_meta_resolver_uses_storage_cache_dir() { + let cache_dir = tempfile::tempdir().unwrap(); + let resolver = GitHubMetaResolver::from_cache_dir(cache_dir.path()).unwrap(); + + assert_eq!( + resolver.cache_path, + cache_dir.path().join("github-meta-hooks.json") + ); + } + #[tokio::test] async fn resolve_ip_allowlist_config_expands_github_meta_hooks() { let mock_server = MockServer::start_async().await; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 49917ce95..dc48a4c67 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -351,7 +351,7 @@ where (auth_mode, max_concurrent_runs) }; let web_enabled = router_web_enabled(&resolved_server_settings); - let github_meta_resolver = GitHubMetaResolver::from_home()?; + let github_meta_resolver = GitHubMetaResolver::from_cache_dir(&storage.cache_dir())?; let (object_store, slatedb_prefix, flush_interval, disk_cache) = build_slatedb_store(&resolved_server_settings)?; From 0892c73a65541cf3edb3c1cd961e95fa294daa5c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 10:02:39 -0400 Subject: [PATCH 02/12] ci(release): restore musl toolchain for x86_64 musl tests The release workflow now builds musl artifacts with cargo-zigbuild, but x86_64 musl tests still run through plain cargo test via nextest. Restore musl-tools and the target-specific compiler/linker env for that test path so fabro-proc's build.rs can compile its C helper again. --- .github/workflows/release.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd53c4a22..83196b8aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,10 @@ jobs: sudo apt-get update sudo apt-get install -y build-essential pkg-config libssl-dev + - name: Install musl toolchain for x86_64-musl tests + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get install -y musl-tools + - name: Set up Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: @@ -85,11 +89,20 @@ jobs: - uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest + - name: Test (x86_64-musl) + # nextest still shells through cargo test for this target, so + # build.rs C code needs an explicit musl compiler/linker. + if: matrix.target == 'x86_64-unknown-linux-musl' + env: + CC_x86_64_unknown_linux_musl: musl-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + run: cargo nextest run --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci + - name: Test # aarch64-musl test runs have not been validated on the compile # runner yet; shipping binary is exercised via Docker smoke tests. # Re-enable after verifying the workspace passes on this target. - if: matrix.target != 'aarch64-unknown-linux-musl' + if: matrix.target != 'aarch64-unknown-linux-musl' && matrix.target != 'x86_64-unknown-linux-musl' run: cargo nextest run --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci - name: Build (musl via cargo-zigbuild) From 51faecf266da3422ac0a03a04238df8fba6d8ab1 Mon Sep 17 00:00:00 2001 From: "fabro-releases[bot]" Date: Sun, 19 Apr 2026 14:27:05 +0000 Subject: [PATCH 03/12] Bump version to 0.208.0-nightly.1 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a1804ed1b..b06c09a0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,7 +1473,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -1509,7 +1509,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "chrono", "openapiv3", @@ -1527,7 +1527,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -1548,7 +1548,7 @@ dependencies = [ [[package]] name = "fabro-checkpoint" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "chrono", "fabro-types", @@ -1562,7 +1562,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "assert_cmd", @@ -1647,7 +1647,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "chrono", @@ -1668,7 +1668,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "async-trait", "fabro-types", @@ -1683,7 +1683,7 @@ dependencies = [ [[package]] name = "fabro-devcontainer" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "fabro-http", "fabro-util", @@ -1699,7 +1699,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "base64", "chrono", @@ -1715,7 +1715,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "fabro-types", @@ -1728,7 +1728,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "async-trait", "fabro-agent", @@ -1751,7 +1751,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "http", "reqwest 0.13.2", @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "async-trait", "dialoguer", @@ -1774,7 +1774,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -1803,7 +1803,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "proc-macro2", "quote", @@ -1812,7 +1812,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "fabro-config", @@ -1828,7 +1828,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "insta", "serde", @@ -1837,7 +1837,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "axum", "base64", @@ -1855,7 +1855,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "cc", "libc", @@ -1864,7 +1864,7 @@ dependencies = [ [[package]] name = "fabro-retro" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "chrono", @@ -1882,7 +1882,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -1913,7 +1913,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "axum", @@ -1987,7 +1987,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "fabro-http", "fabro-interview", @@ -2006,14 +2006,14 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "rust-embed", ] [[package]] name = "fabro-store" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "async-trait", "bytes", @@ -2035,7 +2035,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "base64", @@ -2060,7 +2060,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "fabro-util", @@ -2072,7 +2072,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "assert_cmd", "axum", @@ -2093,7 +2093,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "async-trait", "fabro-github", @@ -2106,7 +2106,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "chrono", "clap", @@ -2126,7 +2126,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "aho-corasick", "anyhow", @@ -2147,7 +2147,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "fabro-graphviz", "fabro-model", @@ -2157,7 +2157,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "chrono", "serde", @@ -2168,7 +2168,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "assert_cmd", @@ -6758,7 +6758,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "axum", "base64", @@ -6776,7 +6776,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 62d8cb2c8..58d237d59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.208.0-nightly.0" +version = "0.208.0-nightly.1" license = "MIT" [workspace.dependencies] From 8ab689da7826e13d11dece5400bff2995d4b6b4e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 10:37:26 -0400 Subject: [PATCH 04/12] feat(runs): canonicalize paginated run list responses Unify /api/v1/runs and /api/v1/boards/runs around a shared paginated summary contract with additive convenience fields. Update the server, demo data, generated clients, CLI pagination, and web consumers so board views become a thin projection over the canonical run summary surface. --- apps/fabro-web/app/data/runs.test.ts | 50 +- apps/fabro-web/app/data/runs.ts | 70 +- apps/fabro-web/app/routes/runs.tsx | 13 +- apps/fabro-web/app/routes/workflow-runs.tsx | 8 +- docs/api-reference/fabro-api.yaml | 113 ++- lib/crates/fabro-cli/src/server_client.rs | 40 +- lib/crates/fabro-cli/tests/it/cmd/ps.rs | 12 +- lib/crates/fabro-cli/tests/it/cmd/rm.rs | 24 +- .../fabro-cli/tests/it/scenario/smoke.rs | 26 +- lib/crates/fabro-server/src/demo/mod.rs | 781 +++++++----------- lib/crates/fabro-server/src/server.rs | 171 ++-- .../src/.openapi-generator/FILES | 2 + .../fabro-api-client/src/api/runs-api.ts | 36 +- .../src/models/board-column-definition.ts | 21 + .../src/models/board-column.ts | 7 +- .../fabro-api-client/src/models/index.ts | 2 + .../src/models/paginated-board-run-list.ts | 34 + .../src/models/paginated-run-list.ts | 4 +- .../src/models/run-list-item.ts | 28 +- .../src/models/store-run-summary.ts | 9 +- 20 files changed, 794 insertions(+), 657 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/models/board-column-definition.ts create mode 100644 lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index e614114d3..ebc03cfa5 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -1,18 +1,52 @@ import { describe, expect, test } from "bun:test"; -import { mapRunSummaryToRunItem } from "./runs"; +import { mapRunListItem, mapRunSummaryToRunItem } from "./runs"; -describe("mapRunSummaryToRunItem", () => { - test("maps store run summary to RunItem", () => { +describe("mapRunListItem", () => { + test("trusts shared server fields for board items", () => { const summary = { run_id: "01ABC", - goal: "Fix the build", + goal: "## Fix the build", + title: "Server supplied title", workflow_slug: "fix_build", workflow_name: "Fix Build", host_repo_path: "/home/user/myrepo", + repository: { name: "myrepo" }, status: "running", + labels: {}, + column: "running", + elapsed_secs: 65, duration_ms: 65000, total_usd_micros: 500000, + created_at: "2026-04-08T12:00:00Z", + start_time: "2026-04-08T12:00:00Z", + status_reason: null, + pending_control: null, + } as const; + const item = mapRunListItem(summary); + expect(item.id).toBe("01ABC"); + expect(item.title).toBe("Server supplied title"); + expect(item.workflow).toBe("fix_build"); + expect(item.repo).toBe("myrepo"); + expect(item.elapsed).toBeDefined(); + }); +}); + +describe("mapRunSummaryToRunItem", () => { + test("maps canonical run summary to RunItem", () => { + const summary = { + run_id: "01ABC", + goal: "Fix the build", + title: "Fix the build", + workflow_slug: "fix_build", + workflow_name: "Fix Build", + host_repo_path: "/home/user/myrepo", + repository: { name: "myrepo" }, + status: "running", + duration_ms: 65000, + elapsed_secs: 65, + total_usd_micros: 500000, labels: {}, + created_at: "2026-04-08T12:00:00Z", start_time: "2026-04-08T12:00:00Z", status_reason: null, pending_control: null, @@ -28,21 +62,25 @@ describe("mapRunSummaryToRunItem", () => { test("handles missing optional fields", () => { const summary = { run_id: "01DEF", - goal: null, + goal: "", + title: "", workflow_slug: null, workflow_name: null, host_repo_path: null, + repository: { name: "unknown" }, status: "submitted", duration_ms: null, + elapsed_secs: null, total_usd_micros: null, labels: {}, + created_at: "2026-04-08T12:00:00Z", start_time: null, status_reason: null, pending_control: null, }; const item = mapRunSummaryToRunItem(summary); expect(item.id).toBe("01DEF"); - expect(item.title).toBe("Untitled run"); + expect(item.title).toBe(""); expect(item.workflow).toBe("unknown"); expect(item.repo).toBe("unknown"); }); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index eb9c30745..c0f8f2763 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -1,5 +1,5 @@ import { formatElapsedSecs, formatDurationSecs } from "../lib/format"; -import type { RunListItem } from "@qltysh/fabro-api-client"; +import type { RunListItem, StoreRunSummary } from "@qltysh/fabro-api-client"; export type CiStatus = "passing" | "failing" | "pending"; @@ -29,13 +29,10 @@ export interface RunItem { sandboxId?: string; } -export type ColumnStatus = "working" | "initializing" | "review" | "merge" | "running" | "waiting" | "succeeded" | "failed"; +export type ColumnStatus = "initializing" | "running" | "waiting" | "succeeded" | "failed"; export const columnNames: Record = { - working: "Working", initializing: "Initializing", - review: "Verify", - merge: "Merge", running: "Running", waiting: "Waiting", succeeded: "Succeeded", @@ -47,17 +44,12 @@ export interface RunWithStatus extends RunItem { statusLabel: string; } -function truncateGoal(goal: string): string { - const firstLine = goal.split("\n")[0].replace(/^#+\s*/, "").trim(); - return firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine; -} - export function mapRunListItem(item: RunListItem): RunItem { return { - id: item.id, + id: item.run_id, repo: item.repository.name, - title: truncateGoal(item.title), - workflow: item.workflow.slug, + title: item.title, + workflow: item.workflow_slug ?? item.workflow_name ?? "unknown", number: item.pull_request?.number, additions: item.pull_request?.additions, deletions: item.pull_request?.deletions, @@ -66,8 +58,7 @@ export function mapRunListItem(item: RunListItem): RunItem { status: c.status, duration: c.duration_secs != null ? formatDurationSecs(c.duration_secs) : undefined, })), - elapsed: item.timings?.elapsed_secs != null ? formatElapsedSecs(item.timings.elapsed_secs) : undefined, - elapsedWarning: item.timings?.elapsed_warning, + elapsed: item.elapsed_secs != null ? formatElapsedSecs(item.elapsed_secs) : undefined, resources: item.sandbox?.resources ? `${item.sandbox.resources.cpu} CPU / ${item.sandbox.resources.memory} GB` : undefined, comments: item.pull_request?.comments, question: item.question?.text, @@ -75,36 +66,42 @@ export function mapRunListItem(item: RunListItem): RunItem { }; } -export interface RunSummaryResponse { - run_id: string; - goal: string | null; - workflow_slug: string | null; - workflow_name: string | null; - host_repo_path: string | null; - status: string | null; - status_reason: string | null; - pending_control: string | null; - duration_ms: number | null; - total_usd_micros: number | null; - labels: Record; - start_time: string | null; -} +export type RunSummaryResponse = StoreRunSummary; export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem { - const repoPath = summary.host_repo_path ?? ""; - const repoName = repoPath.split("/").pop() || "unknown"; return { id: summary.run_id, - repo: repoName, - title: summary.goal ? truncateGoal(summary.goal) : "Untitled run", - workflow: summary.workflow_slug ?? "unknown", + repo: summary.repository.name, + title: summary.title, + workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown", elapsed: - summary.duration_ms != null + summary.elapsed_secs != null + ? formatElapsedSecs(summary.elapsed_secs) + : summary.duration_ms != null ? formatElapsedSecs(summary.duration_ms / 1000) : undefined, }; } +export function columnForStatus(status: string | null | undefined): ColumnStatus { + switch (status) { + case "submitted": + case "starting": + return "initializing"; + case "running": + return "running"; + case "paused": + return "waiting"; + case "succeeded": + return "succeeded"; + case "failed": + case "dead": + case "removing": + default: + return "failed"; + } +} + export function deriveCiStatus(checks: CheckRun[]): CiStatus { if (checks.some((c) => c.status === "failure")) return "failing"; if (checks.some((c) => c.status === "pending" || c.status === "queued")) return "pending"; @@ -112,10 +109,7 @@ export function deriveCiStatus(checks: CheckRun[]): CiStatus { } export const statusColors: Record = { - working: { dot: "bg-teal-500", text: "text-teal-500" }, initializing: { dot: "bg-amber", text: "text-amber" }, - review: { dot: "bg-mint", text: "text-mint" }, - merge: { dot: "bg-teal-300", text: "text-teal-300" }, running: { dot: "bg-teal-500", text: "text-teal-500" }, waiting: { dot: "bg-amber", text: "text-amber" }, succeeded: { dot: "bg-teal-300", text: "text-teal-300" }, diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 4113c42c1..dc55e3395 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -21,7 +21,7 @@ import { CSS } from "@dnd-kit/utilities"; import { ciConfig, statusColors, deriveCiStatus, mapRunListItem } from "../data/runs"; import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs"; import { apiJson } from "../api"; -import type { PaginatedRunList } from "@qltysh/fabro-api-client"; +import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client"; export function meta({}: any) { return [{ title: "Runs — Fabro" }]; @@ -35,10 +35,7 @@ interface ColumnStyle { } const columnStyles: Record = { - working: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] }, initializing: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: [] }, - review: { accent: "bg-mint", iconColor: "text-mint", iconType: "pr", actions: [] }, - merge: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] }, running: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] }, waiting: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: ["Answer Question"] }, succeeded: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: [] }, @@ -49,8 +46,8 @@ const defaultColumnStyle: ColumnStyle = { accent: "bg-fg-muted", iconColor: "tex interface BoardRunsResponse { columns: { id: string; name: string }[]; - data: PaginatedRunList["data"]; - meta: PaginatedRunList["meta"]; + data: PaginatedBoardRunList["data"]; + meta: PaginatedBoardRunList["meta"]; } export async function loader({ request }: any) { @@ -62,8 +59,8 @@ export async function loader({ request }: any) { grouped.set(col.id, []); } for (const apiRun of apiRuns) { - if (grouped.has(apiRun.status)) { - grouped.get(apiRun.status)?.push(mapRunListItem(apiRun)); + if (grouped.has(apiRun.column)) { + grouped.get(apiRun.column)?.push(mapRunListItem(apiRun)); } } diff --git a/apps/fabro-web/app/routes/workflow-runs.tsx b/apps/fabro-web/app/routes/workflow-runs.tsx index 4af7240b6..6478fb41f 100644 --- a/apps/fabro-web/app/routes/workflow-runs.tsx +++ b/apps/fabro-web/app/routes/workflow-runs.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { Link, useParams } from "react-router"; -import { ciConfig, columnNames, deriveCiStatus, mapRunListItem, statusColors } from "../data/runs"; +import { ciConfig, columnNames, columnForStatus, deriveCiStatus, mapRunSummaryToRunItem, statusColors } from "../data/runs"; import type { ColumnStatus, RunWithStatus } from "../data/runs"; import { apiJsonOrNull } from "../api"; import type { PaginatedRunList } from "@qltysh/fabro-api-client"; @@ -10,9 +10,9 @@ export async function loader({ request, params }: any) { const result = await apiJsonOrNull(`/workflows/${params.name}/runs`, { request }); const apiRuns = result?.data ?? []; const runs: RunWithStatus[] = apiRuns.map((r) => ({ - ...mapRunListItem(r), - status: r.status as ColumnStatus, - statusLabel: columnNames[r.status as ColumnStatus] ?? r.status, + ...mapRunSummaryToRunItem(r), + status: columnForStatus(r.status), + statusLabel: columnNames[columnForStatus(r.status)], })); return { runs }; } diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 58229ee81..f78bbf048 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -124,15 +124,16 @@ paths: tags: [Runs] summary: List Runs description: Returns durable run summaries from the backing store, including runs persisted before the current server boot. + parameters: + - $ref: "#/components/parameters/PageLimit" + - $ref: "#/components/parameters/PageOffset" responses: "200": - description: Durable run summaries + description: Paginated durable run summaries content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/StoreRunSummary" + $ref: "#/components/schemas/PaginatedRunList" post: operationId: createRun tags: [Runs] @@ -428,7 +429,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PaginatedRunList" + $ref: "#/components/schemas/PaginatedBoardRunList" /api/v1/runs/{id}/state: get: @@ -1702,6 +1703,25 @@ components: - data - meta properties: + data: + type: array + items: + $ref: "#/components/schemas/StoreRunSummary" + meta: + $ref: "#/components/schemas/PaginationMeta" + + PaginatedBoardRunList: + description: Paginated list of board runs with shared canonical fields plus board metadata. + type: object + required: + - columns + - data + - meta + properties: + columns: + type: array + items: + $ref: "#/components/schemas/BoardColumnDefinition" data: type: array items: @@ -3007,7 +3027,11 @@ components: type: object required: - run_id + - goal + - title - labels + - repository + - created_at properties: run_id: type: string @@ -3016,16 +3040,23 @@ components: workflow_slug: type: ["string", "null"] goal: - type: ["string", "null"] + type: string + title: + type: string labels: type: object additionalProperties: type: string host_repo_path: type: ["string", "null"] + repository: + $ref: "#/components/schemas/RepositoryReference" start_time: type: ["string", "null"] format: date-time + created_at: + type: string + format: date-time status: type: ["string", "null"] status_reason: @@ -3038,6 +3069,8 @@ components: type: ["integer", "null"] format: int64 minimum: 0 + elapsed_secs: + type: ["number", "null"] total_usd_micros: type: ["integer", "null"] format: int64 @@ -3048,10 +3081,22 @@ components: description: Board column status for a run in the list view. type: string enum: - - working - initializing - - review - - merge + - running + - waiting + - succeeded + - failed + + BoardColumnDefinition: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string CheckRunStatus: description: Status of a CI check run. @@ -3346,34 +3391,66 @@ components: # ── Run Board Schemas (updated) ───────────────────────────────────── RunListItem: - description: Summary of a run shown in the board view. + description: Canonical run summary shown in the board view, extended with board-specific metadata. type: object required: - - id - - repository + - run_id + - goal - title - - workflow - status + - labels + - repository - created_at + - column properties: - id: + run_id: type: string description: Unique run identifier (ULID). example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z + workflow_name: + type: ["string", "null"] + workflow_slug: + type: ["string", "null"] + goal: + type: string repository: $ref: "#/components/schemas/RepositoryReference" title: type: string description: Human-readable title describing the run's goal. example: Add rate limiting to auth endpoints - workflow: - $ref: "#/components/schemas/WorkflowReference" status: + type: string + labels: + type: object + additionalProperties: + type: string + host_repo_path: + type: ["string", "null"] + start_time: + type: ["string", "null"] + format: date-time + status_reason: + oneOf: + - $ref: "#/components/schemas/StatusReason" + - type: "null" + pending_control: + oneOf: + - $ref: "#/components/schemas/RunControlAction" + - type: "null" + duration_ms: + type: ["integer", "null"] + format: int64 + minimum: 0 + elapsed_secs: + type: ["number", "null"] + total_usd_micros: + type: ["integer", "null"] + format: int64 + column: $ref: "#/components/schemas/BoardColumn" pull_request: $ref: "#/components/schemas/RunPullRequest" - timings: - $ref: "#/components/schemas/RunTimings" sandbox: $ref: "#/components/schemas/RunSandbox" question: diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index eae036e4b..9c98c4949 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -589,17 +589,35 @@ impl ServerStoreClient { } pub(crate) async fn list_store_runs(&self) -> Result> { - let response = self - .client - .list_runs() - .send() - .await - .map_err(map_api_error)?; - response - .into_inner() - .into_iter() - .map(convert_type) - .collect::>>() + let mut all_runs = Vec::new(); + let mut offset = 0_u64; + let limit = 100_u64; + + loop { + let response = self + .client + .list_runs() + .page_limit(limit) + .page_offset(offset) + .send() + .await + .map_err(map_api_error)?; + let parsed = response.into_inner(); + let batch = parsed + .data + .into_iter() + .map(convert_type) + .collect::>>()?; + let batch_len = batch.len() as u64; + all_runs.extend(batch); + + if !parsed.meta.has_more || batch_len == 0 { + break; + } + offset += batch_len; + } + + Ok(all_runs) } pub(crate) async fn get_run_state(&self, run_id: &RunId) -> Result { diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index c10bb077d..cbd7dd8b6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -263,23 +263,27 @@ fn ps_uses_configured_server_target_without_server_flag() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": run_id, "workflow_name": "Remote Workflow", "workflow_slug": "remote-workflow", "goal": "Remote goal", + "title": "Remote goal", "labels": { "suite": "remote" }, "host_repo_path": "/srv/repo", + "repository": { "name": "repo" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "succeeded", "status_reason": null, "duration_ms": 123, "total_usd_micros": null - } - ]) + }], + "meta": { "has_more": false } + }) .to_string(), ); }); diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index 3b9a663d5..69e2129d6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -157,21 +157,25 @@ fn rm_force_removes_active_run() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": run_id, "workflow_name": "Active Workflow", "workflow_slug": "active-workflow", "goal": "Active goal", + "title": "Active goal", "labels": {}, "host_repo_path": null, + "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "running", "status_reason": null, "duration_ms": 123, "total_usd_micros": null - } - ]) + }], + "meta": { "has_more": false } + }) .to_string(), ); }); @@ -270,21 +274,25 @@ fn rm_uses_configured_server_target_without_local_run_dir() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": run_id, "workflow_name": "Remote Workflow", "workflow_slug": "remote-workflow", "goal": "Remote goal", + "title": "Remote goal", "labels": {}, "host_repo_path": null, + "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "succeeded", "status_reason": null, "duration_ms": 123, "total_usd_micros": null - } - ]) + }], + "meta": { "has_more": false } + }) .to_string(), ); }); diff --git a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs index ce54a6548..29ca459c7 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs @@ -238,21 +238,25 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": success_run_id, "workflow_name": "Remote Workflow", "workflow_slug": "remote-workflow", "goal": "Remote output", + "title": "Remote output", "labels": {}, "host_repo_path": null, + "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "running", "status_reason": null, "duration_ms": 12, "total_usd_micros": null - } - ]) + }], + "meta": { "has_more": false } + }) .to_string(), ); }); @@ -336,21 +340,25 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": eof_run_id, "workflow_name": "Remote Workflow", "workflow_slug": "remote-workflow", "goal": "Remote output", + "title": "Remote output", "labels": {}, "host_repo_path": null, + "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "running", "status_reason": null, "duration_ms": 12, - "total_cost": null - } - ]) + "total_usd_micros": null + }], + "meta": { "has_more": false } + }) .to_string(), ); }); diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 585ae8554..5e9b3fd69 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -4,7 +4,6 @@ #![allow(clippy::default_trait_access, clippy::unreadable_literal)] use std::sync::Arc; -use std::time::Duration; use axum::Json; use axum::extract::{Path, Query, State}; @@ -42,7 +41,7 @@ pub(crate) async fn list_runs( State(_state): State>, Query(pagination): Query, ) -> Response { - paginated_response(runs::list_items(), &pagination) + paginated_response(runs::summaries(), &pagination) } pub(crate) async fn list_board_runs( @@ -50,21 +49,19 @@ pub(crate) async fn list_board_runs( State(_state): State>, Query(pagination): Query, ) -> Response { - let items = runs::list_items(); + let items = runs::board_items(); let limit = pagination.limit.clamp(1, 100) as usize; let offset = pagination.offset as usize; let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect(); let has_more = data.len() > limit; data.truncate(limit); - let columns = json!([ - {"id": "working", "name": "Working"}, - {"id": "pending", "name": "Pending"}, - {"id": "review", "name": "Review"}, - {"id": "merge", "name": "Merge"}, - ]); ( StatusCode::OK, - Json(json!({ "columns": columns, "data": data, "meta": { "has_more": has_more } })), + Json(json!({ + "columns": runs::columns(), + "data": data, + "meta": { "has_more": has_more } + })), ) .into_response() } @@ -202,32 +199,8 @@ pub(crate) async fn get_run_status( State(_state): State>, Path(id): Path, ) -> Response { - match runs::list_items().into_iter().find(|r| r.id == id) { - Some(item) => { - let elapsed_ms = item - .timings - .as_ref() - .and_then(|t| Duration::try_from_secs_f64(t.elapsed_secs).ok()) - .and_then(|duration| u64::try_from(duration.as_millis()).ok()); - ( - StatusCode::OK, - Json(json!({ - "run_id": item.id, - "goal": item.title, - "workflow_slug": item.workflow.slug, - "workflow_name": item.workflow.slug, - "host_repo_path": format!("/demo/{}", item.repository.name), - "labels": {}, - "start_time": item.created_at.to_rfc3339(), - "status": "running", - "status_reason": null, - "pending_control": null, - "duration_ms": elapsed_ms, - "total_usd_micros": null, - })), - ) - .into_response() - } + match runs::summaries().into_iter().find(|run| run.run_id == id) { + Some(run) => (StatusCode::OK, Json(run)).into_response(), None => ApiError::not_found("Run not found.").into_response(), } } @@ -669,462 +642,310 @@ fn ts(s: &str) -> DateTime { } mod runs { + use std::collections::HashMap; + use fabro_api::types::*; use super::ts; - pub(super) fn list_items() -> Vec { + fn labels(entries: &[(&str, &str)]) -> HashMap { + entries + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + } + + fn summary( + run_id: &str, + repo_name: &str, + workflow_slug: &str, + workflow_name: &str, + goal: &str, + status: Option<&str>, + created_at: &str, + elapsed_secs: Option, + status_reason: Option<&str>, + pending_control: Option, + total_usd_micros: Option, + entries: &[(&str, &str)], + ) -> StoreRunSummary { + StoreRunSummary { + created_at: ts(created_at), + duration_ms: elapsed_secs.map(|secs| (secs * 1000.0).round() as i64), + elapsed_secs, + goal: goal.into(), + host_repo_path: Some(format!("/demo/{repo_name}")), + labels: labels(entries), + pending_control, + repository: RepositoryReference { + name: repo_name.into(), + }, + run_id: run_id.into(), + start_time: Some(ts(created_at)), + status: status.map(str::to_string), + status_reason: status_reason.map(str::to_string), + title: goal.into(), + total_usd_micros, + workflow_name: Some(workflow_name.into()), + workflow_slug: Some(workflow_slug.into()), + } + } + + fn take_summary( + summaries: &mut HashMap, + run_id: &str, + ) -> StoreRunSummary { + summaries + .remove(run_id) + .unwrap_or_else(|| panic!("missing demo summary: {run_id}")) + } + + fn board_item( + summary: StoreRunSummary, + column: BoardColumn, + pull_request: Option, + sandbox: Option, + question: Option, + ) -> RunListItem { + let status_reason = summary + .status_reason + .as_deref() + .and_then(|reason| StatusReason::try_from(reason).ok()); + + RunListItem { + column, + created_at: summary.created_at, + duration_ms: summary.duration_ms, + elapsed_secs: summary.elapsed_secs, + goal: summary.goal, + host_repo_path: summary.host_repo_path, + labels: summary.labels, + pending_control: summary.pending_control, + pull_request, + question, + repository: summary.repository, + run_id: summary.run_id, + sandbox, + start_time: summary.start_time, + status: summary.status.unwrap_or_default(), + status_reason, + title: summary.title, + total_usd_micros: summary.total_usd_micros, + workflow_name: summary.workflow_name, + workflow_slug: summary.workflow_slug, + } + } + + fn check(name: &str, status: CheckRunStatus, duration_secs: Option) -> CheckRun { + CheckRun { + name: name.into(), + status, + duration_secs, + } + } + + fn sandbox(id: &str, cpu: i64, memory: i64) -> RunSandbox { + RunSandbox { + id: id.into(), + resources: Some(SandboxResources { cpu, memory }), + } + } + + fn pull_request( + number: i64, + additions: i64, + deletions: i64, + comments: i64, + checks: Vec, + ) -> RunPullRequest { + RunPullRequest { + number, + additions: Some(additions), + deletions: Some(deletions), + comments: Some(comments), + checks, + } + } + + pub(super) fn columns() -> Vec { vec![ - RunListItem { - id: "run-1".into(), - repository: RepositoryReference { - name: "api-server".into(), - }, - title: "Add rate limiting to auth endpoints".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Working, - pull_request: None, - timings: Some(RunTimings { - elapsed_secs: 420.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-a1b2c3d4".into(), - resources: Some(SandboxResources { - cpu: 4, - memory: 8, - }), - }), - question: None, - created_at: ts("2026-03-06T14:30:00Z"), + BoardColumnDefinition { + id: "initializing".into(), + name: "Initializing".into(), }, - RunListItem { - id: "run-2".into(), - repository: RepositoryReference { - name: "web-dashboard".into(), - }, - title: "Migrate to React Router v7".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Working, - pull_request: None, - timings: Some(RunTimings { - elapsed_secs: 8100.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-e5f6g7h8".into(), - resources: Some(SandboxResources { - cpu: 8, - memory: 16, - }), - }), - question: None, - created_at: ts("2026-03-06T12:00:00Z"), + BoardColumnDefinition { + id: "running".into(), + name: "Running".into(), }, - RunListItem { - id: "run-3".into(), - repository: RepositoryReference { - name: "cli-tools".into(), - }, - title: "Fix config parsing for nested values".into(), - workflow: WorkflowReference { - slug: "fix_build".into(), - }, - status: BoardColumn::Working, - pull_request: None, - timings: Some(RunTimings { - elapsed_secs: 2700.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-i9j0k1l2".into(), - resources: Some(SandboxResources { - cpu: 2, - memory: 4, - }), - }), - question: None, - created_at: ts("2026-03-05T09:20:00Z"), + BoardColumnDefinition { + id: "waiting".into(), + name: "Waiting".into(), }, - RunListItem { - id: "run-4".into(), - repository: RepositoryReference { - name: "api-server".into(), - }, - title: "Update OpenAPI spec for v3".into(), - workflow: WorkflowReference { - slug: "expand".into(), - }, - status: BoardColumn::Initializing, - pull_request: Some(RunPullRequest { - number: 0, - additions: Some(567), - deletions: Some(234), - comments: Some(0), - checks: vec![], - }), - timings: Some(RunTimings { - elapsed_secs: 4320.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-q7r8s9t0".into(), - resources: None, - }), - question: Some(RunQuestion { + BoardColumnDefinition { + id: "succeeded".into(), + name: "Succeeded".into(), + }, + BoardColumnDefinition { + id: "failed".into(), + name: "Failed".into(), + }, + ] + } + + pub(super) fn summaries() -> Vec { + vec![ + summary( + "run-1", + "api-server", + "implement", + "Implement", + "Add rate limiting to auth endpoints", + Some("running"), + "2026-03-06T14:30:00Z", + Some(420.0), + None, + None, + None, + &[("branch", "rate-limit"), ("team", "platform")], + ), + summary( + "run-2", + "web-dashboard", + "implement", + "Implement", + "Migrate to React Router v7", + Some("running"), + "2026-03-06T12:00:00Z", + Some(8100.0), + None, + Some(RunControlAction::Pause), + None, + &[("owner", "frontend")], + ), + summary( + "run-3", + "shared-types", + "expand", + "Expand", + "Update OpenAPI spec for v3", + Some("starting"), + "2026-03-04T15:00:00Z", + Some(4320.0), + None, + None, + None, + &[("priority", "high")], + ), + summary( + "run-4", + "shared-types", + "implement", + "Implement", + "Add pipeline event types", + Some("paused"), + "2026-03-04T10:00:00Z", + Some(1680.0), + None, + None, + None, + &[("owner", "runtime")], + ), + summary( + "run-5", + "web-dashboard", + "implement", + "Implement", + "Add dark mode toggle", + Some("failed"), + "2026-03-03T16:45:00Z", + Some(2100.0), + Some("workflow_error"), + None, + None, + &[("environment", "staging")], + ), + summary( + "run-6", + "api-server", + "implement", + "Implement", + "Implement webhook retry logic", + Some("succeeded"), + "2026-02-28T14:00:00Z", + Some(259200.0), + Some("completed"), + None, + Some(720000), + &[("release", "preview")], + ), + ] + } + + pub(super) fn board_items() -> Vec { + let mut summaries = summaries() + .into_iter() + .map(|summary| (summary.run_id.clone(), summary)) + .collect::>(); + + vec![ + board_item( + take_summary(&mut summaries, "run-1"), + BoardColumn::Running, + None, + Some(sandbox("sb-a1b2c3d4", 4, 8)), + None, + ), + board_item( + take_summary(&mut summaries, "run-2"), + BoardColumn::Running, + None, + Some(sandbox("sb-e5f6g7h8", 8, 16)), + None, + ), + board_item( + take_summary(&mut summaries, "run-3"), + BoardColumn::Initializing, + Some(pull_request(0, 567, 234, 0, vec![])), + Some(sandbox("sb-q7r8s9t0", 4, 8)), + Some(RunQuestion { text: "Accept or push for another round?".into(), }), - created_at: ts("2026-03-04T15:00:00Z"), - }, - RunListItem { - id: "run-5".into(), - repository: RepositoryReference { - name: "shared-types".into(), - }, - title: "Add pipeline event types".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Initializing, - pull_request: Some(RunPullRequest { - number: 0, - additions: Some(145), - deletions: Some(23), - comments: Some(0), - checks: vec![], - }), - timings: Some(RunTimings { - elapsed_secs: 1680.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-u1v2w3x4".into(), - resources: None, - }), - question: Some(RunQuestion { + ), + board_item( + take_summary(&mut summaries, "run-4"), + BoardColumn::Waiting, + Some(pull_request(0, 145, 23, 0, vec![])), + Some(sandbox("sb-u1v2w3x4", 4, 8)), + Some(RunQuestion { text: "Proceed from investigation to fix?".into(), }), - created_at: ts("2026-03-04T10:00:00Z"), - }, - RunListItem { - id: "run-6".into(), - repository: RepositoryReference { - name: "web-dashboard".into(), - }, - title: "Add dark mode toggle".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Review, - pull_request: Some(RunPullRequest { - number: 889, - additions: Some(234), - deletions: Some(67), - comments: Some(4), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(23.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(72.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(154.0), - }, - CheckRun { - name: "integration-tests".into(), - status: CheckRunStatus::Failure, - duration_secs: Some(296.0), - }, - CheckRun { - name: "e2e / chrome".into(), - status: CheckRunStatus::Failure, - duration_secs: Some(182.0), - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Success, - duration_secs: Some(105.0), - }, - CheckRun { - name: "coverage".into(), - status: CheckRunStatus::Skipped, - duration_secs: None, - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 2100.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-m3n4o5p6".into(), - resources: None, - }), - question: None, - created_at: ts("2026-03-03T16:45:00Z"), - }, - RunListItem { - id: "run-7".into(), - repository: RepositoryReference { - name: "infrastructure".into(), - }, - title: "Terraform module for Redis cluster".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Review, - pull_request: Some(RunPullRequest { - number: 156, - additions: Some(412), - deletions: Some(0), - comments: Some(1), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(18.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(56.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Pending, - duration_secs: None, - }, - CheckRun { - name: "integration-tests".into(), - status: CheckRunStatus::Queued, - duration_secs: None, - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Pending, - duration_secs: None, - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 720.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-y5z6a7b8".into(), - resources: None, - }), - question: None, - created_at: ts("2026-03-03T11:00:00Z"), - }, - RunListItem { - id: "run-8".into(), - repository: RepositoryReference { - name: "api-server".into(), - }, - title: "Implement webhook retry logic".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Merge, - pull_request: Some(RunPullRequest { - number: 1249, - additions: Some(189), - deletions: Some(45), - comments: Some(7), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(21.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(68.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(192.0), - }, - CheckRun { - name: "integration-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(334.0), - }, - CheckRun { - name: "e2e / chrome".into(), - status: CheckRunStatus::Success, - duration_secs: Some(262.0), - }, - CheckRun { - name: "e2e / firefox".into(), - status: CheckRunStatus::Success, - duration_secs: Some(285.0), - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Success, - duration_secs: Some(121.0), - }, - CheckRun { - name: "deploy-preview".into(), - status: CheckRunStatus::Success, - duration_secs: Some(93.0), - }, - CheckRun { - name: "security-scan".into(), - status: CheckRunStatus::Skipped, - duration_secs: None, - }, - CheckRun { - name: "performance".into(), - status: CheckRunStatus::Success, - duration_secs: Some(138.0), - }, - CheckRun { - name: "bundle-size".into(), - status: CheckRunStatus::Success, - duration_secs: Some(34.0), - }, - CheckRun { - name: "accessibility".into(), - status: CheckRunStatus::Success, - duration_secs: Some(72.0), - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 259200.0, - elapsed_warning: Some(true), - }), - sandbox: Some(RunSandbox { - id: "sb-c9d0e1f2".into(), - resources: None, - }), - question: None, - created_at: ts("2026-02-28T14:00:00Z"), - }, - RunListItem { - id: "run-9".into(), - repository: RepositoryReference { - name: "cli-tools".into(), - }, - title: "Add --verbose flag to run command".into(), - workflow: WorkflowReference { - slug: "expand".into(), - }, - status: BoardColumn::Merge, - pull_request: Some(RunPullRequest { - number: 430, - additions: Some(56), - deletions: Some(12), - comments: Some(2), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(15.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(48.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(116.0), - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Success, - duration_secs: Some(82.0), - }, - CheckRun { - name: "coverage".into(), - status: CheckRunStatus::Success, - duration_secs: Some(124.0), - }, - CheckRun { - name: "bundle-size".into(), - status: CheckRunStatus::Skipped, - duration_secs: None, - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 3900.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-g3h4i5j6".into(), - resources: None, - }), - question: None, - created_at: ts("2026-02-27T09:00:00Z"), - }, - RunListItem { - id: "run-10".into(), - repository: RepositoryReference { - name: "shared-types".into(), - }, - title: "Export utility type helpers".into(), - workflow: WorkflowReference { - slug: "sync_drift".into(), - }, - status: BoardColumn::Merge, - pull_request: Some(RunPullRequest { - number: 76, - additions: Some(34), - deletions: Some(8), - comments: Some(0), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(12.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(34.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(75.0), - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Success, - duration_secs: Some(58.0), - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 2880.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-k7l8m9n0".into(), - resources: None, - }), - question: None, - created_at: ts("2026-02-26T08:00:00Z"), - }, + ), + board_item( + take_summary(&mut summaries, "run-5"), + BoardColumn::Failed, + Some(pull_request(889, 234, 67, 4, vec![ + check("lint", CheckRunStatus::Success, Some(23.0)), + check("typecheck", CheckRunStatus::Success, Some(72.0)), + check("unit-tests", CheckRunStatus::Success, Some(154.0)), + check("integration-tests", CheckRunStatus::Failure, Some(296.0)), + check("build", CheckRunStatus::Success, Some(105.0)), + ])), + None, + None, + ), + board_item( + take_summary(&mut summaries, "run-6"), + BoardColumn::Succeeded, + Some(pull_request(1249, 189, 45, 7, vec![ + check("lint", CheckRunStatus::Success, Some(21.0)), + check("typecheck", CheckRunStatus::Success, Some(68.0)), + check("unit-tests", CheckRunStatus::Success, Some(192.0)), + check("integration-tests", CheckRunStatus::Success, Some(334.0)), + check("deploy-preview", CheckRunStatus::Success, Some(93.0)), + ])), + None, + None, + ), ] } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 824978703..a11465725 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -73,6 +73,7 @@ use fabro_types::{ RunSubjectProvenance, }; use fabro_util::redact::redact_jsonl_line; +use fabro_util::text::strip_goal_decoration; use fabro_util::version::FABRO_VERSION; use fabro_vault::{Error as VaultError, SecretType, Vault}; use fabro_workflow::Error as WorkflowError; @@ -2578,6 +2579,65 @@ fn board_columns() -> serde_json::Value { ]) } +fn truncate_goal(goal: &str) -> String { + const MAX_LEN: usize = 100; + + let stripped = strip_goal_decoration(goal); + let char_count = stripped.chars().count(); + if char_count <= MAX_LEN { + return stripped.to_string(); + } + + let truncated: String = stripped.chars().take(MAX_LEN - 3).collect(); + format!("{truncated}...") +} + +fn repository_name(host_repo_path: Option<&str>) -> String { + host_repo_path + .and_then(|path| path.rsplit(['/', '\\']).find(|segment| !segment.is_empty())) + .unwrap_or("unknown") + .to_string() +} + +fn elapsed_secs(duration_ms: Option) -> Option { + duration_ms.map(|ms| ms as f64 / 1000.0) +} + +fn summary_to_api_run_summary(summary: fabro_store::RunSummary) -> serde_json::Value { + let goal = summary.goal.unwrap_or_default(); + let title = truncate_goal(&goal); + let repository = repository_name(summary.host_repo_path.as_deref()); + let created_at = summary.run_id.created_at().to_rfc3339(); + + serde_json::json!({ + "run_id": summary.run_id.to_string(), + "workflow_name": summary.workflow_name, + "workflow_slug": summary.workflow_slug, + "goal": goal, + "title": title, + "labels": summary.labels, + "host_repo_path": summary.host_repo_path, + "repository": { "name": repository }, + "start_time": summary.start_time.map(|time| time.to_rfc3339()), + "status": summary.status, + "status_reason": summary.status_reason.map(api_status_reason), + "pending_control": summary.pending_control.map(api_pending_control), + "duration_ms": summary.duration_ms, + "elapsed_secs": elapsed_secs(summary.duration_ms), + "total_usd_micros": summary.total_usd_micros, + "created_at": created_at, + }) +} + +fn paginate_items(items: Vec, pagination: PaginationParams) -> (Vec, bool) { + let limit = pagination.limit.clamp(1, 100) as usize; + let offset = pagination.offset as usize; + let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect(); + let has_more = data.len() > limit; + data.truncate(limit); + (data, has_more) +} + async fn list_board_runs( _auth: AuthenticatedService, State(state): State>, @@ -2599,32 +2659,12 @@ async fn list_board_runs( .filter_map(|summary| { let status = summary.status?; let column = board_column(status)?; - let title = summary.goal.as_deref().unwrap_or("Untitled run"); - let workflow_slug = summary.workflow_slug.as_deref().unwrap_or("unknown"); - let workflow_name = summary.workflow_name.as_deref().unwrap_or(workflow_slug); - let repo_name = summary - .host_repo_path - .as_deref() - .and_then(|p| p.rsplit('/').next()) - .unwrap_or("unknown"); - let elapsed_secs = summary.duration_ms.map(|ms| ms as f64 / 1000.0); - let created_at = summary.run_id.created_at(); - Some(serde_json::json!({ - "id": summary.run_id.to_string(), - "title": title, - "repository": { "name": repo_name }, - "workflow": { "slug": workflow_slug, "name": workflow_name }, - "status": column, - "created_at": created_at.to_rfc3339(), - "timings": elapsed_secs.map(|s| serde_json::json!({ "elapsed_secs": s })), - })) + let mut item = summary_to_api_run_summary(summary); + item["column"] = serde_json::json!(column); + Some(item) }) .collect(); - let limit = pagination.limit.clamp(1, 100) as usize; - let offset = pagination.offset as usize; - let page: Vec<_> = all_items.into_iter().skip(offset).take(limit + 1).collect(); - let has_more = page.len() > limit; - let data: Vec<_> = page.into_iter().take(limit).collect(); + let (data, has_more) = paginate_items(all_items, pagination); ( StatusCode::OK, Json(serde_json::json!({ @@ -2636,13 +2676,31 @@ async fn list_board_runs( .into_response() } -async fn list_runs(_auth: AuthenticatedService, State(state): State>) -> Response { +async fn list_runs( + _auth: AuthenticatedService, + State(state): State>, + Query(pagination): Query, +) -> Response { match state .store .list_runs(&fabro_store::ListRunsQuery::default()) .await { - Ok(runs) => (StatusCode::OK, Json(runs)).into_response(), + Ok(runs) => { + let items = runs + .into_iter() + .map(summary_to_api_run_summary) + .collect::>(); + let (data, has_more) = paginate_items(items, pagination); + ( + StatusCode::OK, + Json(serde_json::json!({ + "data": data, + "meta": { "has_more": has_more } + })), + ) + .into_response() + } Err(err) => { ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() } @@ -8288,7 +8346,8 @@ slug = "fabro" let response = app.clone().oneshot(req).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_json(response.into_body()).await; - assert_eq!(body.as_array().unwrap().len(), 0); + assert_eq!(body["data"].as_array().unwrap().len(), 0); + assert_eq!(body["meta"]["has_more"].as_bool(), Some(false)); // Start a run let req = Request::builder() @@ -8312,10 +8371,18 @@ slug = "fabro" let response = app.oneshot(req).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_json(response.into_body()).await; - let items = body.as_array().unwrap(); + let items = body["data"].as_array().unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0]["run_id"].as_str().unwrap(), run_id.to_string()); + assert!(items[0]["goal"].is_string()); + assert!(items[0]["title"].is_string()); + assert!(items[0]["repository"]["name"].is_string()); + assert!(items[0]["created_at"].is_string()); assert!(items[0]["status"].as_str().is_some()); + assert!(items[0]["labels"].is_object()); + assert!(items[0]["status_reason"].is_null()); + assert!(items[0]["pending_control"].is_null()); + assert!(items[0]["total_usd_micros"].is_null()); } #[tokio::test] @@ -8567,7 +8634,7 @@ level = "debug" .as_array() .unwrap() .iter() - .find(|item| item["id"].as_str() == Some(run_id_str.as_str())); + .find(|item| item["run_id"].as_str() == Some(run_id_str.as_str())); assert!( board_item.is_some(), "cancelled run should appear on the board" @@ -8575,8 +8642,9 @@ level = "debug" assert_eq!( board_item.unwrap()["status"].as_str(), Some("failed"), - "cancelled run should be in the failed column" + "cancelled run should preserve the failed lifecycle status" ); + assert_eq!(board_item.unwrap()["column"].as_str(), Some("failed")); let run_store = state.store.open_run_reader(&run_id).await.unwrap(); let status = run_store.state().await.unwrap().status.unwrap(); @@ -8692,9 +8760,11 @@ level = "debug" .as_array() .unwrap() .iter() - .find(|item| item["id"].as_str() == Some(run_id_str.as_str())) + .find(|item| item["run_id"].as_str() == Some(run_id_str.as_str())) .expect("board item should exist"); - assert_eq!(item["status"].as_str(), Some("initializing")); + assert!(item["status"].as_str().is_some()); + assert_eq!(item["column"].as_str(), Some("initializing")); + assert_eq!(item["pending_control"].as_str(), Some("pause")); } #[tokio::test] @@ -9081,11 +9151,14 @@ timeout = "30s" let data = body["data"].as_array().expect("data should be array"); assert!(!data.is_empty(), "demo should return runs"); let first = &data[0]; - assert!(first["id"].is_string()); + assert!(first["run_id"].is_string()); + assert!(first["goal"].is_string()); assert!(first["repository"].is_object()); assert!(first["title"].is_string()); - assert!(first["workflow"].is_object()); assert!(first["status"].is_string()); + assert!(first["column"].is_string()); + assert!(first["workflow_slug"].is_string() || first["workflow_slug"].is_null()); + assert!(first["labels"].is_object()); assert!(first["created_at"].is_string()); } @@ -9155,19 +9228,21 @@ timeout = "30s" let data = body["data"].as_array().expect("data should be array"); let item = data .iter() - .find(|i| i["id"].as_str() == Some(&run_id)) + .find(|i| i["run_id"].as_str() == Some(&run_id)) .expect("run should be in board"); - // Should have RunListItem fields + // Should have canonical run summary fields plus board-specific column + assert!(item["goal"].is_string()); assert!(item["title"].is_string()); assert!(item["repository"].is_object()); - assert!(item["workflow"].is_object()); - // Status should be a board column, not a lifecycle status - let status = item["status"].as_str().unwrap(); - assert!( - ["working", "initializing", "review", "merge"].contains(&status), - "status should be a board column, got: {status}" - ); + assert!(item["workflow_slug"].is_string() || item["workflow_slug"].is_null()); + assert!(item["workflow_name"].is_string() || item["workflow_name"].is_null()); + assert!(item["labels"].is_object()); + assert!(item["status"].is_string()); + assert!(item["column"].is_string()); assert!(item["created_at"].is_string()); + assert!(item["pending_control"].is_null()); + assert!(item["status_reason"].is_null()); + assert!(item["total_usd_micros"].is_null()); } #[tokio::test] @@ -9199,7 +9274,7 @@ timeout = "30s" let data = body["data"].as_array().expect("data should be array"); let found = data .iter() - .any(|i| i["id"].as_str() == Some(&run_id.to_string())); + .any(|i| i["run_id"].as_str() == Some(&run_id.to_string())); assert!(!found, "removing run should not appear on the board"); } @@ -9252,15 +9327,17 @@ timeout = "30s" let paused_item = data .iter() - .find(|i| i["id"].as_str() == Some(&paused_id.to_string())) + .find(|i| i["run_id"].as_str() == Some(&paused_id.to_string())) .expect("paused run should be on board"); - assert_eq!(paused_item["status"].as_str().unwrap(), "waiting"); + assert_eq!(paused_item["status"].as_str().unwrap(), "paused"); + assert_eq!(paused_item["column"].as_str().unwrap(), "waiting"); let succeeded_item = data .iter() - .find(|i| i["id"].as_str() == Some(&succeeded_id.to_string())) + .find(|i| i["run_id"].as_str() == Some(&succeeded_id.to_string())) .expect("succeeded run should be on board"); assert_eq!(succeeded_item["status"].as_str().unwrap(), "succeeded"); + assert_eq!(succeeded_item["column"].as_str().unwrap(), "succeeded"); // Verify columns are included in the response let columns = body["columns"].as_array().expect("columns should be array"); diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 404f03203..4bb71a1da 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -31,6 +31,7 @@ models/assistant-stage-turn.ts models/billed-token-counts.ts models/billing-by-model.ts models/billing-stage-ref.ts +models/board-column-definition.ts models/board-column.ts models/check-run-status.ts models/check-run.ts @@ -86,6 +87,7 @@ models/model.ts models/node-state.ts models/node-status-record.ts models/paginated-api-question-list.ts +models/paginated-board-run-list.ts models/paginated-event-list.ts models/paginated-history-entry-list.ts models/paginated-model-list.ts diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index ceb9421fa..302b201a2 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -24,6 +24,8 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError // @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore +import type { PaginatedBoardRunList } from '../models'; +// @ts-ignore import type { PaginatedRunList } from '../models'; // @ts-ignore import type { PreflightResponse } from '../models'; @@ -212,10 +214,12 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) /** * Returns durable run summaries from the backing store, including runs persisted before the current server boot. * @summary List Runs + * @param {number} [pageLimit] Maximum number of items to return per page. + * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRuns: async (options: RawAxiosRequestConfig = {}): Promise => { + listRuns: async (pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise => { const localVarPath = `/api/v1/runs`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -234,6 +238,14 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (pageLimit !== undefined) { + localVarQueryParameter['page[limit]'] = pageLimit; + } + + if (pageOffset !== undefined) { + localVarQueryParameter['page[offset]'] = pageOffset; + } + localVarHeaderParameter['Accept'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -586,7 +598,7 @@ export const RunsApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listBoardRuns(pageLimit, pageOffset, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunsApi.listBoardRuns']?.[localVarOperationServerIndex]?.url; @@ -595,11 +607,13 @@ export const RunsApiFp = function(configuration?: Configuration) { /** * Returns durable run summaries from the backing store, including runs persisted before the current server boot. * @summary List Runs + * @param {number} [pageLimit] Maximum number of items to return per page. + * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listRuns(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(options); + async listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunsApi.listRuns']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -743,17 +757,19 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { + listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.listBoardRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath)); }, /** * Returns durable run summaries from the backing store, including runs persisted before the current server boot. * @summary List Runs + * @param {number} [pageLimit] Maximum number of items to return per page. + * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRuns(options?: RawAxiosRequestConfig): AxiosPromise> { - return localVarFp.listRuns(options).then((request) => request(axios, basePath)); + listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath)); }, /** * Pauses a running run. Returns 409 if the run is not running. @@ -881,11 +897,13 @@ export class RunsApi extends BaseAPI { /** * Returns durable run summaries from the backing store, including runs persisted before the current server boot. * @summary List Runs + * @param {number} [pageLimit] Maximum number of items to return per page. + * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public listRuns(options?: RawAxiosRequestConfig) { - return RunsApiFp(this.configuration).listRuns(options).then((request) => request(this.axios, this.basePath)); + public listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/lib/packages/fabro-api-client/src/models/board-column-definition.ts b/lib/packages/fabro-api-client/src/models/board-column-definition.ts new file mode 100644 index 000000000..f1730a16c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/board-column-definition.ts @@ -0,0 +1,21 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface BoardColumnDefinition { + 'id': string; + 'name': string; +} + diff --git a/lib/packages/fabro-api-client/src/models/board-column.ts b/lib/packages/fabro-api-client/src/models/board-column.ts index 24385ce4b..7de1d800a 100644 --- a/lib/packages/fabro-api-client/src/models/board-column.ts +++ b/lib/packages/fabro-api-client/src/models/board-column.ts @@ -19,10 +19,11 @@ */ export const BoardColumn = { - WORKING: 'working', INITIALIZING: 'initializing', - REVIEW: 'review', - MERGE: 'merge' + RUNNING: 'running', + WAITING: 'waiting', + SUCCEEDED: 'succeeded', + FAILED: 'failed' } as const; export type BoardColumn = typeof BoardColumn[keyof typeof BoardColumn]; diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index ef42d8cb7..002634357 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -14,6 +14,7 @@ export * from './billed-token-counts'; export * from './billing-by-model'; export * from './billing-stage-ref'; export * from './board-column'; +export * from './board-column-definition'; export * from './check-run'; export * from './check-run-status'; export * from './code-location'; @@ -67,6 +68,7 @@ export * from './model-test-result'; export * from './node-state'; export * from './node-status-record'; export * from './paginated-api-question-list'; +export * from './paginated-board-run-list'; export * from './paginated-event-list'; export * from './paginated-history-entry-list'; export * from './paginated-model-list'; diff --git a/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts b/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts new file mode 100644 index 000000000..42335c819 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts @@ -0,0 +1,34 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { BoardColumnDefinition } from './board-column-definition'; +// May contain unused imports in some cases +// @ts-ignore +import type { PaginationMeta } from './pagination-meta'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunListItem } from './run-list-item'; + +/** + * Paginated list of board runs with shared canonical fields plus board metadata. + */ +export interface PaginatedBoardRunList { + 'columns': Array; + 'data': Array; + 'meta': PaginationMeta; +} + diff --git a/lib/packages/fabro-api-client/src/models/paginated-run-list.ts b/lib/packages/fabro-api-client/src/models/paginated-run-list.ts index b0c4b1675..da1f937ac 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-run-list.ts +++ b/lib/packages/fabro-api-client/src/models/paginated-run-list.ts @@ -18,13 +18,13 @@ import type { PaginationMeta } from './pagination-meta'; // May contain unused imports in some cases // @ts-ignore -import type { RunListItem } from './run-list-item'; +import type { StoreRunSummary } from './store-run-summary'; /** * Paginated list of runs. */ export interface PaginatedRunList { - 'data': Array; + 'data': Array; 'meta': PaginationMeta; } diff --git a/lib/packages/fabro-api-client/src/models/run-list-item.ts b/lib/packages/fabro-api-client/src/models/run-list-item.ts index e4f166bac..cfadd64f3 100644 --- a/lib/packages/fabro-api-client/src/models/run-list-item.ts +++ b/lib/packages/fabro-api-client/src/models/run-list-item.ts @@ -21,6 +21,9 @@ import type { BoardColumn } from './board-column'; import type { RepositoryReference } from './repository-reference'; // May contain unused imports in some cases // @ts-ignore +import type { RunControlAction } from './run-control-action'; +// May contain unused imports in some cases +// @ts-ignore import type { RunPullRequest } from './run-pull-request'; // May contain unused imports in some cases // @ts-ignore @@ -30,28 +33,35 @@ import type { RunQuestion } from './run-question'; import type { RunSandbox } from './run-sandbox'; // May contain unused imports in some cases // @ts-ignore -import type { RunTimings } from './run-timings'; -// May contain unused imports in some cases -// @ts-ignore -import type { WorkflowReference } from './workflow-reference'; +import type { StatusReason } from './status-reason'; /** - * Summary of a run shown in the board view. + * Canonical run summary shown in the board view, extended with board-specific metadata. */ export interface RunListItem { /** * Unique run identifier (ULID). */ - 'id': string; + 'run_id': string; + 'workflow_name'?: string | null; + 'workflow_slug'?: string | null; + 'goal': string; 'repository': RepositoryReference; /** * Human-readable title describing the run\'s goal. */ 'title': string; - 'workflow': WorkflowReference; - 'status': BoardColumn; + 'status': string; + 'labels': { [key: string]: string; }; + 'host_repo_path'?: string | null; + 'start_time'?: string | null; + 'status_reason'?: StatusReason | null; + 'pending_control'?: RunControlAction | null; + 'duration_ms'?: number | null; + 'elapsed_secs'?: number | null; + 'total_usd_micros'?: number | null; + 'column': BoardColumn; 'pull_request'?: RunPullRequest; - 'timings'?: RunTimings; 'sandbox'?: RunSandbox; 'question'?: RunQuestion; /** diff --git a/lib/packages/fabro-api-client/src/models/store-run-summary.ts b/lib/packages/fabro-api-client/src/models/store-run-summary.ts index 5c520dfa7..00aa48e92 100644 --- a/lib/packages/fabro-api-client/src/models/store-run-summary.ts +++ b/lib/packages/fabro-api-client/src/models/store-run-summary.ts @@ -13,6 +13,9 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { RepositoryReference } from './repository-reference'; // May contain unused imports in some cases // @ts-ignore import type { RunControlAction } from './run-control-action'; @@ -24,14 +27,18 @@ export interface StoreRunSummary { 'run_id': string; 'workflow_name'?: string | null; 'workflow_slug'?: string | null; - 'goal'?: string | null; + 'goal': string; + 'title': string; 'labels': { [key: string]: string; }; 'host_repo_path'?: string | null; + 'repository': RepositoryReference; 'start_time'?: string | null; + 'created_at': string; 'status'?: string | null; 'status_reason'?: string | null; 'pending_control'?: RunControlAction | null; 'duration_ms'?: number | null; + 'elapsed_secs'?: number | null; 'total_usd_micros'?: number | null; } From 914778c8a8396816ca6d3de78a529bb52fc18e20 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 10:43:51 -0400 Subject: [PATCH 05/12] refactor(server): remove inbound TLS termination Remove server-side TLS listener support so Fabro only binds plain TCP or Unix sockets, and update docs/tests around proxy-terminated HTTPS. This also drops the removed [server.listen.tls] config shape and the inbound TLS-specific diagnostics, fixtures, and integration coverage. --- Cargo.lock | 8 - docs/administration/deploy-digital-ocean.mdx | 7 +- docs/administration/deploy-fly-io.mdx | 5 +- docs/administration/deploy-railway.mdx | 7 +- docs/administration/deploy-render.mdx | 7 +- docs/administration/deploy-server.mdx | 15 +- docs/administration/security.mdx | 8 +- docs/administration/server-configuration.mdx | 6 +- docs/administration/troubleshooting.mdx | 4 +- docs/api-reference/demo-mode.mdx | 2 +- docs/api-reference/overview.mdx | 18 +- docs/reference/architecture.mdx | 20 +- docs/reference/cli.mdx | 4 +- docs/reference/user-configuration.mdx | 8 +- lib/crates/fabro-config/src/resolve/server.rs | 53 +--- lib/crates/fabro-config/tests/resolve_root.rs | 7 +- .../fabro-config/tests/resolve_server.rs | 18 +- lib/crates/fabro-server/Cargo.toml | 8 - lib/crates/fabro-server/src/diagnostics.rs | 71 ----- lib/crates/fabro-server/src/lib.rs | 1 - lib/crates/fabro-server/src/serve.rs | 41 +-- lib/crates/fabro-server/src/settings_view.rs | 13 +- lib/crates/fabro-server/src/tls.rs | 121 -------- .../fabro-server/tests/fixtures/mtls/ca.crt | 9 - .../tests/fixtures/mtls/client.crt | 9 - .../tests/fixtures/mtls/client.key | 3 - .../fixtures/mtls/jwt-ed25519-private.pem | 3 - .../fixtures/mtls/jwt-ed25519-public.pem | 3 - .../tests/fixtures/mtls/server.crt | 9 - .../tests/fixtures/mtls/server.key | 3 - .../tests/fixtures/mtls/wrong-client.crt | 9 - .../tests/fixtures/mtls/wrong-client.key | 3 - lib/crates/fabro-server/tests/it/api/docs.rs | 37 +++ lib/crates/fabro-server/tests/it/api/mod.rs | 4 +- lib/crates/fabro-server/tests/it/api/runs.rs | 4 - .../fabro-server/tests/it/api/settings.rs | 4 - lib/crates/fabro-server/tests/it/api/tcp.rs | 280 ++++++++++++++++++ lib/crates/fabro-server/tests/it/api/tls.rs | 155 ---------- lib/crates/fabro-types/src/settings/mod.rs | 2 +- .../fabro-types/src/settings/resolved.rs | 12 +- lib/crates/fabro-types/src/settings/server.rs | 30 +- 41 files changed, 406 insertions(+), 625 deletions(-) delete mode 100644 lib/crates/fabro-server/src/tls.rs delete mode 100644 lib/crates/fabro-server/tests/fixtures/mtls/ca.crt delete mode 100644 lib/crates/fabro-server/tests/fixtures/mtls/client.crt delete mode 100644 lib/crates/fabro-server/tests/fixtures/mtls/client.key delete mode 100644 lib/crates/fabro-server/tests/fixtures/mtls/jwt-ed25519-private.pem delete mode 100644 lib/crates/fabro-server/tests/fixtures/mtls/jwt-ed25519-public.pem delete mode 100644 lib/crates/fabro-server/tests/fixtures/mtls/server.crt delete mode 100644 lib/crates/fabro-server/tests/fixtures/mtls/server.key delete mode 100644 lib/crates/fabro-server/tests/fixtures/mtls/wrong-client.crt delete mode 100644 lib/crates/fabro-server/tests/fixtures/mtls/wrong-client.key create mode 100644 lib/crates/fabro-server/tests/it/api/docs.rs create mode 100644 lib/crates/fabro-server/tests/it/api/tcp.rs delete mode 100644 lib/crates/fabro-server/tests/it/api/tls.rs diff --git a/Cargo.lock b/Cargo.lock index a1804ed1b..6f2dfec48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1951,8 +1951,6 @@ dependencies = [ "hmac", "http-body-util", "httpmock", - "hyper", - "hyper-util", "ipnet", "jsonwebtoken", "mime_guess", @@ -1960,9 +1958,6 @@ dependencies = [ "object_store", "rand 0.9.4", "regex", - "rustls", - "rustls-pemfile", - "rustls-pki-types", "semver", "serde", "serde_json", @@ -1971,18 +1966,15 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls", "tokio-stream", "toml 0.8.23", "toml_edit", "tower", "tower-http", - "tower-service", "tracing", "ulid", "uuid", "walkdir", - "x509-parser", ] [[package]] diff --git a/docs/administration/deploy-digital-ocean.mdx b/docs/administration/deploy-digital-ocean.mdx index b82c862d2..764757212 100644 --- a/docs/administration/deploy-digital-ocean.mdx +++ b/docs/administration/deploy-digital-ocean.mdx @@ -93,8 +93,9 @@ Once `https:///health` returns `ok`, two things to grab: 2. **Point your local CLI at the server** — add the URL to `~/.fabro/settings.toml`: ```toml title="~/.fabro/settings.toml" - [server] - target = "https://fabro.example.com/api/v1" + [cli.target] + type = "http" + url = "https://fabro.example.com/api/v1" ``` Then commands like `fabro model list --server ` will hit your Droplet. @@ -131,6 +132,6 @@ To pin a specific version instead of `:nightly`, edit `docker-compose.yaml` and Auth, dev tokens, submitting runs, and pointing the CLI at your deployment. - Full `settings.toml` reference — TLS, auth methods, concurrency, and more. + Full `settings.toml` reference — reverse-proxy TLS, auth methods, concurrency, and more. diff --git a/docs/administration/deploy-fly-io.mdx b/docs/administration/deploy-fly-io.mdx index cf3c4e61b..51bc1d21f 100644 --- a/docs/administration/deploy-fly-io.mdx +++ b/docs/administration/deploy-fly-io.mdx @@ -75,8 +75,9 @@ Once the deploy is healthy, Fly exposes a `.fly.dev` URL (or your custom do 2. **Point your local CLI at the server** — add the Fly URL to `~/.fabro/settings.toml`: ```toml title="~/.fabro/settings.toml" - [server] - target = "https://.fly.dev/api/v1" + [cli.target] + type = "http" + url = "https://.fly.dev/api/v1" ``` Then commands like `fabro model list --server ` will hit your Fly instance. diff --git a/docs/administration/deploy-railway.mdx b/docs/administration/deploy-railway.mdx index e0560b019..c932246fb 100644 --- a/docs/administration/deploy-railway.mdx +++ b/docs/administration/deploy-railway.mdx @@ -52,8 +52,9 @@ Once the deploy is healthy, Railway exposes a `*.up.railway.app` URL (or your cu 2. **Point your local CLI at the server** — add the Railway URL to `~/.fabro/settings.toml`: ```toml title="~/.fabro/settings.toml" - [server] - target = "https://.up.railway.app/api/v1" + [cli.target] + type = "http" + url = "https://.up.railway.app/api/v1" ``` Then commands like `fabro model list --server ` will hit your Railway instance. @@ -77,6 +78,6 @@ Railway re-pulls the GHCR image on every deploy. `Dockerfile.deploy` references Auth, dev tokens, submitting runs, and pointing the CLI at your deployment. - Full `settings.toml` reference — TLS, auth methods, concurrency, and more. + Full `settings.toml` reference — reverse-proxy TLS, auth methods, concurrency, and more. diff --git a/docs/administration/deploy-render.mdx b/docs/administration/deploy-render.mdx index 2f2bdc1fb..ff8837365 100644 --- a/docs/administration/deploy-render.mdx +++ b/docs/administration/deploy-render.mdx @@ -58,8 +58,9 @@ Once the deploy is healthy, Render exposes a `*.onrender.com` URL (or your custo 2. **Point your local CLI at the server** — add the Render URL to `~/.fabro/settings.toml`: ```toml title="~/.fabro/settings.toml" - [server] - target = "https://.onrender.com/api/v1" + [cli.target] + type = "http" + url = "https://.onrender.com/api/v1" ``` Then commands like `fabro model list --server ` will hit your Render instance. @@ -83,6 +84,6 @@ Render re-pulls the GHCR image on every deploy. `Dockerfile.deploy` references t Auth, dev tokens, submitting runs, and pointing the CLI at your deployment. - Full `settings.toml` reference — TLS, auth methods, concurrency, and more. + Full `settings.toml` reference — reverse-proxy TLS, auth methods, concurrency, and more. diff --git a/docs/administration/deploy-server.mdx b/docs/administration/deploy-server.mdx index 67e9d13dc..d243e9f77 100644 --- a/docs/administration/deploy-server.mdx +++ b/docs/administration/deploy-server.mdx @@ -22,7 +22,7 @@ Both interfaces use the same workflow engine, the same Graphviz files, and the s | **Events** | Printed to stderr | Streamed via SSE | | **Persistence** | Checkpoint files only | Persistent run store + checkpoint files | | **Web UI** | Not available | Full React interface | -| **Authentication** | None | JWT and/or mTLS | +| **Authentication** | None | Dev token and/or GitHub OAuth | ## Starting the server @@ -118,25 +118,26 @@ Send the `X-Fabro-Demo: 1` header on any API request to get static mock data wit The CLI can target a running Fabro server for commands that support a remote API. Configure `~/.fabro/settings.toml`: ```toml title="settings.toml" -[server] -target = "https://fabro.example.com:3000/api/v1" +[cli.target] +type = "http" +url = "https://fabro.example.com/api/v1" ``` Or use the `--server` flag: ```bash -fabro model list --server https://fabro.example.com:3000/api/v1 +fabro model list --server https://fabro.example.com/api/v1 ``` -`fabro model list` and `fabro model test` honor `[server].target` by default unless you explicitly pass `--storage-dir`. `fabro exec` remains a local agent session and only uses the server when you pass `--server`. +`fabro model list` and `fabro model test` honor `[cli.target]` by default unless you explicitly pass `--storage-dir`. `fabro exec` remains a local agent session and only uses the server when you pass `--server`. -See [User Configuration](/reference/user-configuration#server-section) for the full connection options, including mTLS setup. +See [User Configuration](/reference/user-configuration#cli.target-section) for the full connection options, including client certificates for proxy-terminated HTTPS endpoints. ## Next steps - Full settings.toml reference — authentication, TLS, run defaults, and more. + Full settings.toml reference — authentication, reverse-proxy TLS, run defaults, and more. Step-by-step guide for deploying Fabro on Railway. diff --git a/docs/administration/security.mdx b/docs/administration/security.mdx index 24c574921..2a3feec24 100644 --- a/docs/administration/security.mdx +++ b/docs/administration/security.mdx @@ -28,10 +28,10 @@ Fabro is single-tenant software designed for small, trusted teams. The following ### Authentication -- **Enable authentication.** Fabro supports GitHub OAuth and Tailscale header-based auth for the web app. Do not use `insecure_disabled` outside of local development. -- **Configure a username allowlist.** Both GitHub and Tailscale auth support `allowed_usernames` in `settings.toml`. An empty allowlist rejects all requests. -- **Use JWT to connect the web app to the API.** Configure `FABRO_JWT_PRIVATE_KEY` on the web app and `FABRO_JWT_PUBLIC_KEY` on the API server. JWT tokens are Ed25519-signed and short-lived (30 seconds). -- **Use mTLS for machine-to-machine API access.** Configure `[api.tls]` in `settings.toml` with server cert, key, and CA. Set client auth to `Required` for programmatic clients (CI, scripts). +- **Enable authentication.** Fabro supports `dev-token` and GitHub OAuth. Do not disable auth outside of local development or controlled demos. +- **Configure a username allowlist for GitHub OAuth.** `[server.auth.github].allowed_usernames` should contain the exact GitHub users allowed to log in. An empty list rejects everyone. +- **Configure the session secret used by the web flow.** `SESSION_SECRET` should be provisioned with a strong value on long-lived deployments. If you also provision `FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY`, treat them as server runtime secrets, but they are not what currently gates browser auth. +- **Terminate HTTPS or mTLS upstream when needed.** Fabro's listener is plain HTTP/Unix only. If CI, scripts, or a browser must connect over HTTPS, terminate TLS at a reverse proxy or load balancer and keep the Fabro listener on a private network. ### Secrets diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index 358cf6b86..30fbe39e9 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -22,6 +22,8 @@ Legacy `server.toml`, `user.toml`, and `cli.toml` are ignored with a warning. Re The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing. +Fabro does not terminate inbound TLS directly. Bind `[server.listen]` to a Unix socket or plain TCP port, and terminate HTTPS or mTLS at a reverse proxy, load balancer, or platform ingress in front of Fabro. Use `[server.api].url` and `[server.web].url` for those external HTTPS URLs. + ### Full reference ```toml title="settings.toml" @@ -31,10 +33,6 @@ _version = 1 type = "tcp" address = "0.0.0.0:3000" -[server.listen.tls] -cert = "/etc/fabro/tls/cert.pem" -key = "/etc/fabro/tls/key.pem" - [server.api] url = "https://fabro.example.com/api/v1" diff --git a/docs/administration/troubleshooting.mdx b/docs/administration/troubleshooting.mdx index 90d34559d..89142fc64 100644 --- a/docs/administration/troubleshooting.mdx +++ b/docs/administration/troubleshooting.mdx @@ -10,7 +10,7 @@ The `fabro doctor` command validates your installation: ```bash fabro doctor # Local config checks + live server diagnostics fabro doctor --verbose # Show detailed output for each check -fabro doctor --server https://fabro.example.com:3000/api/v1 +fabro doctor --server https://fabro.example.com/api/v1 ``` It checks: @@ -29,7 +29,7 @@ It checks: **Port already in use** — Change the port with `fabro server start --port 3001` or stop the conflicting process. -**SSE streams disconnecting** — If using a reverse proxy, ensure buffering is disabled and the connection timeout is long enough for workflow runs. See the [reverse proxy example](/administration/deployment#binding-and-tls). +**SSE streams disconnecting** — If using a reverse proxy, ensure buffering is disabled and the connection timeout is long enough for workflow runs. See the [DigitalOcean reverse-proxy example](/administration/deploy-digital-ocean). **Run config validation errors** — Use `fabro preflight` to validate without executing: diff --git a/docs/api-reference/demo-mode.mdx b/docs/api-reference/demo-mode.mdx index 0af074560..380f0414b 100644 --- a/docs/api-reference/demo-mode.mdx +++ b/docs/api-reference/demo-mode.mdx @@ -13,7 +13,7 @@ Demo mode is activated **per-request** by sending a header: X-Fabro-Demo: 1 ``` -When the server receives this header, it routes the request to a parallel set of demo handlers that return static JSON instead of hitting the real backend. Authentication is bypassed — no JWT or mTLS credentials are needed. +When the server receives this header, it routes the request to a parallel set of demo handlers that return static JSON instead of hitting the real backend. Authentication is bypassed — no credentials are needed. Requests **without** the header are routed to the real API as usual, so demo and production traffic coexist on the same server. diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 7c8a4e359..bbb30e5e1 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -11,13 +11,13 @@ The Fabro API is a REST API for managing workflow runs, interactive sessions, an ## Base URL -The versioned API is served by `fabro server start`, which defaults to: +By default, `fabro server start` listens on the Unix socket `~/.fabro/fabro.sock`. If you bind Fabro to TCP instead, the versioned API is served at a URL like: ``` http://localhost:3000/api/v1 ``` -The base URL is configurable via `settings.toml`: +The advertised public API URL is configurable via `settings.toml`: ```toml title="settings.toml" [server.api] @@ -63,19 +63,9 @@ When `"github"` is enabled, browser users can sign in through GitHub OAuth. Succ When `[server.web].enabled = true`, the server requires `SESSION_SECRET` and issues a private `__fabro_session` cookie after successful login. The cookie is session transport only; the underlying bootstrap method remains `dev-token` or `github`, and that provenance is preserved in run metadata. -### HTTPS +### HTTPS and Reverse Proxies -If you want HTTPS on the listener, configure shared TLS on `[server.listen.tls]`: - -```toml title="settings.toml" -[server.listen] -type = "tcp" -address = "0.0.0.0:3000" - -[server.listen.tls] -cert = "/etc/fabro/tls/cert.pem" -key = "/etc/fabro/tls/key.pem" -``` +Fabro's listener is plain HTTP (or a Unix socket) only. If you want a public HTTPS endpoint, terminate TLS at a reverse proxy, load balancer, or platform ingress and point it at Fabro's internal listener. ## Errors diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index df9e90456..ff8015e71 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -28,7 +28,7 @@ CLI mode is ideal for: fabro server start ``` -`fabro server start` starts an HTTP server (default `127.0.0.1:3000`) with persistent run storage. Runs are submitted via the REST API and executed asynchronously. +`fabro server start` starts an HTTP server, binding to `~/.fabro/fabro.sock` by default (or plain TCP when configured), with persistent run storage. Runs are submitted via the REST API and executed asynchronously. Public HTTPS, when needed, is terminated upstream by a reverse proxy or platform ingress. ### Configuration @@ -38,11 +38,11 @@ Key server config options: | Setting | Description | |---|---| -| `api.host` / `api.port` | Bind address (default `127.0.0.1:3000`) | -| `api.authentication_strategies` | Auth methods: `jwt`, `mtls`, or both | -| `api.tls` | Optional HTTPS with cert/key/CA paths | -| `max_concurrent_runs` | Scheduler concurrency limit (default 5) | -| `[llm]`, `[sandbox]`, `[vars]` | Defaults applied to every run (overridable per-run) | +| `server.listen` | Bind transport: Unix socket or plain TCP listener | +| `server.api.url` / `server.web.url` | External HTTPS URLs advertised to clients | +| `server.auth.methods` | Bootstrap auth methods: `dev-token`, `github`, or both | +| `server.scheduler.max_concurrent_runs` | Scheduler concurrency limit (default 5) | +| `[run.*]` | Defaults applied to every run (overridable per-run) | ### Run lifecycle @@ -61,10 +61,10 @@ In API mode, human-in-the-loop questions are served over HTTP instead of termina ### Authentication -API mode supports two authentication strategies, configurable in `settings.toml`: +API mode supports two bootstrap auth methods, configurable in `settings.toml`: -- **JWT** — EdDSA-signed tokens (used by the web UI) -- **mTLS** — Mutual TLS with client certificates (used for service-to-service communication) +- **`dev-token`** — Bearer token access for operators and automation +- **`github`** — GitHub OAuth for browser users, resulting in a session cookie ### Demo mode @@ -75,7 +75,7 @@ Demo mode is per-request: send the `X-Fabro-Demo: 1` HTTP header to get static m The web UI is a React app (`apps/fabro-web`) that connects to the API server. Start it alongside `fabro server start`: ```bash -fabro server start # API on port 3000 +fabro server start # API on ~/.fabro/fabro.sock by default cd apps/fabro-web && bun run dev # rebuilds web assets on change; refresh the browser ``` diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 417a8ac9e..79da428d5 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -41,7 +41,7 @@ name = "claude-sonnet-4-5" [cli.target] type = "http" -url = "https://fabro.example.com:3000/api/v1" +url = "https://fabro.example.com/api/v1" ``` `[cli.exec]` config applies to `fabro exec`. `[run.model]` sets the default workflow model/provider for commands like `fabro run` and `fabro preflight`. `[cli.target]` stores connection info for commands that can target a remote Fabro server. @@ -767,7 +767,7 @@ Check environment and integration health. `fabro doctor` always performs live se ```bash fabro doctor fabro doctor -v -fabro doctor --server https://fabro.example.com:3000/api/v1 +fabro doctor --server https://fabro.example.com/api/v1 ``` | Flag | Description | diff --git a/docs/reference/user-configuration.mdx b/docs/reference/user-configuration.mdx index 56c4f913d..c9c29f9bf 100644 --- a/docs/reference/user-configuration.mdx +++ b/docs/reference/user-configuration.mdx @@ -61,7 +61,7 @@ _version = 1 [cli.target] type = "http" -url = "https://fabro.example.com:3000/api/v1" +url = "https://fabro.example.com/api/v1" [cli.target.tls] cert = "~/.fabro/tls/client.crt" @@ -245,7 +245,7 @@ Connection info for commands that target a remote Fabro server. ```toml title="settings.toml" [cli.target] type = "http" -url = "https://fabro.example.com:3000/api/v1" +url = "https://fabro.example.com/api/v1" ``` | Key | Description | @@ -257,14 +257,14 @@ url = "https://fabro.example.com:3000/api/v1" `fabro model` uses `[cli.target]` by default when no explicit `--storage-dir` is passed. An explicit `--server` flag overrides the configured target: ```bash -fabro model list --server https://fabro.example.com:3000/api/v1 +fabro model list --server https://fabro.example.com/api/v1 ``` `fabro exec` does not automatically use `[cli.target]`. It only routes model traffic through a Fabro server when you pass `--server` for that invocation. ### `[cli.target.tls]` section -Optional mTLS configuration for authenticating with an HTTP target. When present, the CLI presents a client certificate during the TLS handshake. +Optional client-certificate configuration for authenticating with an HTTP target. When present, the CLI presents a client certificate during the TLS handshake with your external HTTPS endpoint or reverse proxy. ```toml title="settings.toml" [cli.target.tls] diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index 5d4f5a439..6716440fc 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -7,10 +7,9 @@ use fabro_types::settings::server::{ ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer, ServerIntegrationsSettings, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerLayer, ServerListenLayer, ServerListenSettings, - ServerListenTlsLayer, ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, - ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, - ServerWebLayer, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, - TlsConfig, + ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, ServerSlateDbLayer, + ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, ServerWebLayer, + ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, }; use fabro_util::Home; @@ -18,7 +17,7 @@ use super::{ResolveError, default_interp, parse_socket_addr, require_interp}; pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> ServerSettings { let storage = resolve_storage(layer.storage.as_ref()); - let (listen, _valid_tls) = resolve_listen(layer.listen.as_ref(), errors); + let listen = resolve_listen(layer.listen.as_ref(), errors); let web = resolve_web(layer.api.as_ref(), layer.web.as_ref()); let auth = resolve_auth(layer.auth.as_ref(), errors); let ip_allowlist = resolve_ip_allowlist(layer.ip_allowlist.as_ref(), errors); @@ -65,49 +64,27 @@ fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings fn resolve_listen( layer: Option<&ServerListenLayer>, errors: &mut Vec, -) -> (ServerListenSettings, bool) { +) -> ServerListenSettings { match layer { - None => ( - ServerListenSettings::Unix { - path: default_interp(Home::from_env().socket_path()), - }, - false, - ), - Some(ServerListenLayer::Unix { path }) => ( - ServerListenSettings::Unix { - path: path - .clone() - .unwrap_or_else(|| default_interp(Home::from_env().socket_path())), - }, - false, - ), - Some(ServerListenLayer::Tcp { address, tls }) => { + None => ServerListenSettings::Unix { + path: default_interp(Home::from_env().socket_path()), + }, + Some(ServerListenLayer::Unix { path }) => ServerListenSettings::Unix { + path: path + .clone() + .unwrap_or_else(|| default_interp(Home::from_env().socket_path())), + }, + Some(ServerListenLayer::Tcp { address }) => { let address = parse_socket_addr( &require_interp(address.as_ref(), "server.listen.address", errors), "server.listen.address", errors, ); - let (tls, valid_tls) = resolve_tls(tls.as_ref(), errors); - (ServerListenSettings::Tcp { address, tls }, valid_tls) + ServerListenSettings::Tcp { address } } } } -fn resolve_tls( - layer: Option<&ServerListenTlsLayer>, - errors: &mut Vec, -) -> (Option, bool) { - let Some(layer) = layer else { - return (None, false); - }; - - let cert = require_interp(layer.cert.as_ref(), "server.listen.tls.cert", errors); - let key = require_interp(layer.key.as_ref(), "server.listen.tls.key", errors); - let valid = layer.cert.is_some() && layer.key.is_some(); - - (Some(TlsConfig { cert, key }), valid) -} - fn resolve_web(_api: Option<&ServerApiLayer>, layer: Option<&ServerWebLayer>) -> ServerWebSettings { let layer = layer.expect("defaults.toml should provide server.web defaults"); diff --git a/lib/crates/fabro-config/tests/resolve_root.rs b/lib/crates/fabro-config/tests/resolve_root.rs index a613265a3..0048eb4f3 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/tests/resolve_root.rs @@ -27,10 +27,7 @@ _version = 1 [server.listen] type = "tcp" -address = "127.0.0.1:3000" - -[server.listen.tls] -cert = "/tmp/server.pem" +address = "not-a-socket-addr" [server.auth] methods = ["github"] @@ -50,7 +47,7 @@ provider = "not-a-provider" .collect::>() .join("\n"); - assert!(rendered.contains("server.listen.tls.key")); + assert!(rendered.contains("server.listen.address")); assert!(rendered.contains("server.auth.github.allowed_usernames")); assert!(rendered.contains("run.sandbox.provider")); } diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/tests/resolve_server.rs index 673cd3a25..811d8bb63 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/tests/resolve_server.rs @@ -65,8 +65,8 @@ fn resolves_server_defaults_from_empty_settings() { } #[test] -fn reports_tls_shape_errors() { - let file = parse( +fn parsing_rejects_inbound_listener_tls_configuration() { + let err = fabro_config::parse_settings_layer( r#" _version = 1 @@ -76,19 +76,11 @@ address = "127.0.0.1:32276" [server.listen.tls] cert = "/etc/fabro/server.pem" - "#, - ); + ) + .expect_err("listener TLS should be rejected at parse time"); - let errors = fabro_config::resolve_server_from_file(&file) - .expect_err("incomplete tls config should fail"); - let rendered = errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); - - assert!(rendered.contains("server.listen.tls.key")); + assert!(err.to_string().contains("unknown field `tls`")); } #[test] diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index c5d10f4a7..cd00f2832 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -47,14 +47,6 @@ tokio-stream = { workspace = true, features = ["sync"] } base64.workspace = true jsonwebtoken.workspace = true tokio.workspace = true -tokio-rustls = "0.26" -rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } -rustls-pemfile = "2" -rustls-pki-types = "1" -hyper = "1" -hyper-util = { version = "0.1", features = ["tokio", "server-auto", "http1", "http2"] } -tower-service = "0.3" -x509-parser = "0.16" serde.workspace = true serde_json.workspace = true serde_yaml = "0.9" diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index 4682119ec..6123adc5c 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -1,4 +1,3 @@ -use std::path::PathBuf; use std::time::Duration; use base64::Engine as _; @@ -47,37 +46,6 @@ fn decode_pem_value(name: &str, value: &str) -> Result { String::from_utf8(bytes).map_err(|e| format!("{name} base64 decoded to invalid UTF-8: {e}")) } -fn validate_tls_cert(pem: &str, now_epoch: i64) -> Result { - let mut reader = std::io::Cursor::new(pem.as_bytes()); - let certs: Vec<_> = rustls_pemfile::certs(&mut reader) - .collect::, _>>() - .map_err(|e| format!("failed to parse certificate PEM: {e}"))?; - if certs.is_empty() { - return Err("no certificates found in PEM".to_string()); - } - let (_, parsed) = x509_parser::parse_x509_certificate(&certs[0]) - .map_err(|e| format!("failed to parse X.509 certificate: {e}"))?; - let not_after = parsed.validity().not_after.timestamp(); - if not_after <= now_epoch { - return Err("certificate has expired".to_string()); - } - let cn = parsed - .subject() - .iter_common_name() - .next() - .and_then(|cn| cn.as_str().ok()) - .unwrap_or("(no CN)"); - Ok(format!("CN={cn}, valid")) -} - -fn validate_tls_private_key(pem: &str) -> Result<(), String> { - let mut reader = std::io::Cursor::new(pem.as_bytes()); - rustls_pemfile::private_key(&mut reader) - .map_err(|e| format!("failed to parse private key PEM: {e}"))? - .ok_or_else(|| "no private key found in PEM".to_string())?; - Ok(()) -} - fn validate_session_secret(value: &str) -> Result<(), String> { session_secret::validate_session_secret(value) } @@ -492,11 +460,6 @@ async fn check_brave_search(state: &AppState) -> CheckResult { } fn check_crypto(state: &AppState) -> CheckResult { - let settings_file = state - .settings - .read() - .expect("settings lock poisoned") - .clone(); let resolved_server_settings = state.server_settings(); let mut details = Vec::new(); @@ -559,40 +522,6 @@ fn check_crypto(state: &AppState) -> CheckResult { } } - if let Some(listen) = settings_file - .server - .as_ref() - .and_then(|s| s.listen.as_ref()) - { - use fabro_types::settings::server::ServerListenLayer; - - if let ServerListenLayer::Tcp { tls: Some(tls), .. } = listen { - let read = |raw: Option, label: &str| -> Result { - let Some(path_str) = raw else { - return Err(format!("server.listen.tls.{label} is not configured")); - }; - let path = PathBuf::from(&path_str); - let expanded = fabro_config::expand_tilde(&path); - std::fs::read_to_string(&expanded) - .map_err(|e| format!("{}: {e}", expanded.display())) - }; - match ( - read(tls.cert.as_ref().map(InterpString::as_source), "cert"), - read(tls.key.as_ref().map(InterpString::as_source), "key"), - ) { - (Ok(cert_pem), Ok(key_pem)) => { - if let Err(err) = validate_tls_cert(&cert_pem, chrono::Utc::now().timestamp()) { - errors.push(err); - } - if let Err(err) = validate_tls_private_key(&key_pem) { - errors.push(err); - } - } - _ => errors.push("failed to read TLS files".to_string()), - } - } - } - if errors.is_empty() { CheckResult { name: "Crypto".to_string(), diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index a1547dc86..b13afd51a 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -19,7 +19,6 @@ pub mod server; mod server_secrets; mod settings_view; pub mod static_files; -pub mod tls; pub mod web_auth; pub use error::{ApiError, Error, Result}; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 49917ce95..363cb6e2e 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -33,7 +33,6 @@ use crate::server::{ reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler, }; use crate::server_secrets::ServerSecrets; -use crate::tls::{build_rustls_config, serve_tls_with_shutdown}; const TEST_IN_MEMORY_STORE_ENV: &str = "FABRO_TEST_IN_MEMORY_STORE"; pub const DEFAULT_TCP_PORT: u16 = 32276; @@ -246,7 +245,6 @@ fn bind_override_layer(bind: BindRequest) -> SettingsLayer { }, BindRequest::Tcp(address) => ServerListenLayer::Tcp { address: Some(InterpString::parse(&address.to_string())), - tls: None, }, BindRequest::TcpHost(_) => { unreachable!("host-only bind requests are handled before building a settings override") @@ -515,12 +513,6 @@ where } }); - // Branch: TLS, plain TCP, or Unix socket - let tls_settings = match &resolved_server_settings.listen { - ServerListenSettings::Tcp { tls, .. } => tls.clone(), - ServerListenSettings::Unix { .. } => None, - }; - let bound_listener = bind_listener(&bind_request).await?; let bind_addr = bound_listener.bind.clone(); if bound_listener.used_random_port_fallback { @@ -563,38 +555,19 @@ where match bound_listener.listener { BoundListener::Unix(listener) => { - if tls_settings.is_some() { - warn!("TLS is configured but not supported on Unix sockets; ignoring TLS settings"); - } announce_server_ready(&bind_addr, styles); axum::serve(listener, router) .with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())) .await?; } BoundListener::Tcp(listener) => { - if let Some(ref tls_settings) = tls_settings { - let rustls_config = build_rustls_config(tls_settings)?; - let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config); - - info!("TLS enabled"); - announce_server_ready(&bind_addr, styles); - - serve_tls_with_shutdown( - listener, - tls_acceptor, - router, - wait_for_shutdown(shutdown_rx.clone()), - ) - .await?; - } else { - announce_server_ready(&bind_addr, styles); - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())) - .await?; - } + announce_server_ready(&bind_addr, styles); + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())) + .await?; } } diff --git a/lib/crates/fabro-server/src/settings_view.rs b/lib/crates/fabro-server/src/settings_view.rs index a91a5ea30..f620891e1 100644 --- a/lib/crates/fabro-server/src/settings_view.rs +++ b/lib/crates/fabro-server/src/settings_view.rs @@ -10,9 +10,8 @@ //! //! Per the requirements doc, only the transport bind needs redaction now: //! -//! - `server.listen` — the whole subtree. Bind address reveals network -//! topology; `[server.listen.tls]` cert/key paths reveal the host filesystem -//! layout. +//! - `server.listen` — the whole subtree. Bind addresses and socket paths +//! reveal network topology and host filesystem layout. //! //! ## Why that's all //! @@ -132,10 +131,6 @@ _version = 1 [server.listen] type = "tcp" address = "127.0.0.1:32276" - -[server.listen.tls] -cert = "/etc/fabro/tls/cert.pem" -key = "/etc/fabro/tls/key.pem" "#, ); let redacted = redact_for_api(&settings); @@ -249,10 +244,6 @@ _version = 1 type = "tcp" address = "127.0.0.1:32276" -[server.listen.tls] -cert = "/etc/fabro/tls/cert.pem" -key = "/etc/fabro/tls/key.pem" - [server.auth] methods = ["github", "dev-token"] diff --git a/lib/crates/fabro-server/src/tls.rs b/lib/crates/fabro-server/src/tls.rs deleted file mode 100644 index 07da9e745..000000000 --- a/lib/crates/fabro-server/src/tls.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::future::Future; -use std::path::Path; -use std::pin::Pin; -use std::sync::Arc; - -use anyhow::Context; -use axum::extract::ConnectInfo; -use fabro_types::settings::{InterpString, TlsConfig}; -use rustls::ServerConfig; -use rustls_pki_types::{CertificateDer, PrivateKeyDer}; -use tokio::net::TcpListener; -use tracing::error; - -/// Build a rustls `ServerConfig` from the `[server.listen.tls]` configuration. -pub fn build_rustls_config(tls_settings: &TlsConfig) -> anyhow::Result> { - let cert = resolve_path(&tls_settings.cert)?; - let key_path = resolve_path(&tls_settings.key)?; - - let certs = load_certs(&cert); - let key = load_private_key(&key_path); - - let config = ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certs, key) - .expect("invalid server certificate or key"); - - Ok(Arc::new(config)) -} - -pub async fn serve_tls( - listener: TcpListener, - tls_acceptor: tokio_rustls::TlsAcceptor, - router: axum::Router, -) -> anyhow::Result<()> { - serve_tls_with_shutdown(listener, tls_acceptor, router, std::future::pending()).await -} - -/// Serve requests over TLS until the supplied shutdown future resolves. -pub async fn serve_tls_with_shutdown( - listener: TcpListener, - tls_acceptor: tokio_rustls::TlsAcceptor, - router: axum::Router, - shutdown: F, -) -> anyhow::Result<()> -where - F: Future + Send, -{ - use hyper::body::Incoming; - use hyper::service::service_fn; - use hyper_util::rt::{TokioExecutor, TokioIo}; - use hyper_util::server::conn::auto::Builder; - use tower_service::Service; - - let builder = Builder::new(TokioExecutor::new()); - let mut shutdown = Pin::from(Box::new(shutdown)); - - loop { - let accepted = tokio::select! { - () = &mut shutdown => return Ok(()), - accepted = listener.accept() => accepted?, - }; - let (tcp_stream, remote_addr) = accepted; - - let tls_acceptor = tls_acceptor.clone(); - let router = router.clone(); - let builder = builder.clone(); - - tokio::spawn(async move { - let tls_stream = match tls_acceptor.accept(tcp_stream).await { - Ok(s) => s, - Err(e) => { - error!(%remote_addr, "TLS handshake failed: {e}"); - return; - } - }; - - let io = TokioIo::new(tls_stream); - - let service = service_fn(move |mut req: hyper::Request| { - let mut router = router.clone(); - async move { - req.extensions_mut().insert(ConnectInfo(remote_addr)); - router.call(req).await - } - }); - - if let Err(e) = builder.serve_connection(io, service).await { - error!(%remote_addr, "connection error: {e}"); - } - }); - } -} - -pub use fabro_config::expand_tilde; - -fn resolve_path(value: &InterpString) -> anyhow::Result { - let resolved = value - .resolve(|name| std::env::var(name).ok()) - .with_context(|| format!("failed to resolve {}", value.as_source()))?; - Ok(expand_tilde(Path::new(&resolved.value))) -} - -fn load_certs(path: &Path) -> Vec> { - let path = expand_tilde(path); - let file = std::fs::File::open(&path) - .unwrap_or_else(|e| panic!("failed to open certificate file {}: {e}", path.display())); - let mut reader = std::io::BufReader::new(file); - rustls_pemfile::certs(&mut reader) - .collect::, _>>() - .unwrap_or_else(|e| panic!("failed to parse certificates from {}: {e}", path.display())) -} - -fn load_private_key(path: &Path) -> PrivateKeyDer<'static> { - let path = expand_tilde(path); - let file = std::fs::File::open(&path) - .unwrap_or_else(|e| panic!("failed to open private key file {}: {e}", path.display())); - let mut reader = std::io::BufReader::new(file); - rustls_pemfile::private_key(&mut reader) - .unwrap_or_else(|e| panic!("failed to parse private key from {}: {e}", path.display())) - .unwrap_or_else(|| panic!("no private key found in {}", path.display())) -} diff --git a/lib/crates/fabro-server/tests/fixtures/mtls/ca.crt b/lib/crates/fabro-server/tests/fixtures/mtls/ca.crt deleted file mode 100644 index 25f16654c..000000000 --- a/lib/crates/fabro-server/tests/fixtures/mtls/ca.crt +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIBRjCB+aADAgECAhRKD83+hLEUl2GQUrsSgNaDBjPrpDAFBgMrZXAwETEPMA0G -A1UEAwwGVGVzdENBMB4XDTI2MDQwNTE2MjEzM1oXDTM2MDQwMjE2MjEzM1owETEP -MA0GA1UEAwwGVGVzdENBMCowBQYDK2VwAyEA3vVnIRyxAa9q+qtf0OPWoOUKff1D -Pq5LpXPUTh1nrJejYzBhMB0GA1UdDgQWBBT88UyTLCWai4vJtkS5K0zutivZOTAf -BgNVHSMEGDAWgBT88UyTLCWai4vJtkS5K0zutivZOTAPBgNVHRMBAf8EBTADAQH/ -MA4GA1UdDwEB/wQEAwIBBjAFBgMrZXADQQBUmXc96ILueacLnf7kSJS35wiCl044 -Js8vwgQuTkJ9SDhuCOt88E4b9vZMhx2kOBLiwTyTdOILhVECPE9FZicD ------END CERTIFICATE----- diff --git a/lib/crates/fabro-server/tests/fixtures/mtls/client.crt b/lib/crates/fabro-server/tests/fixtures/mtls/client.crt deleted file mode 100644 index 3f6dcd30f..000000000 --- a/lib/crates/fabro-server/tests/fixtures/mtls/client.crt +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIBMjCB5aADAgECAhRjMLlP+97gUZFyv5k1WdriOASrLDAFBgMrZXAwETEPMA0G -A1UEAwwGVGVzdENBMB4XDTI2MDQwNTE2MjEzM1oXDTM2MDQwMjE2MjEzM1owEzER -MA8GA1UEAwwIdGVzdHVzZXIwKjAFBgMrZXADIQCYYub304Ilt7lkzkN5plpIlGCo -xR8wL18Xob7/hW2SWqNNMEswCQYDVR0TBAIwADAdBgNVHQ4EFgQUL4ve1GH0sFJ+ -33TwQP1R6oactHYwHwYDVR0jBBgwFoAU/PFMkywlmouLybZEuStM7rYr2TkwBQYD -K2VwA0EAEpBsV5kpyuEF3t5GzuxELDJgtVxGLpZD5PsPqj+wxv5j6TeOwCE/LRRV -JsKYJt3SMdqySx84dfscPD9c5HMPCw== ------END CERTIFICATE----- diff --git a/lib/crates/fabro-server/tests/fixtures/mtls/client.key b/lib/crates/fabro-server/tests/fixtures/mtls/client.key deleted file mode 100644 index ca97bb3ba..000000000 --- a/lib/crates/fabro-server/tests/fixtures/mtls/client.key +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIME1COxOi67I+kdoIH+ms4c0zKA8D7M8SkeJyjC89+pj ------END PRIVATE KEY----- diff --git a/lib/crates/fabro-server/tests/fixtures/mtls/jwt-ed25519-private.pem b/lib/crates/fabro-server/tests/fixtures/mtls/jwt-ed25519-private.pem deleted file mode 100644 index b847be8a6..000000000 --- a/lib/crates/fabro-server/tests/fixtures/mtls/jwt-ed25519-private.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIMr+udNo63lm79G+2xETGqoQsMJUvpbTUFhXdgKNI10C ------END PRIVATE KEY----- diff --git a/lib/crates/fabro-server/tests/fixtures/mtls/jwt-ed25519-public.pem b/lib/crates/fabro-server/tests/fixtures/mtls/jwt-ed25519-public.pem deleted file mode 100644 index b1abb273e..000000000 --- a/lib/crates/fabro-server/tests/fixtures/mtls/jwt-ed25519-public.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEA93ZZOd4zYtjwgdzSw+brqyWM9USG5INKCGWUEHRVRBw= ------END PUBLIC KEY----- diff --git a/lib/crates/fabro-server/tests/fixtures/mtls/server.crt b/lib/crates/fabro-server/tests/fixtures/mtls/server.crt deleted file mode 100644 index bca953a06..000000000 --- a/lib/crates/fabro-server/tests/fixtures/mtls/server.crt +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIBOTCB7KADAgECAhRjMLlP+97gUZFyv5k1WdriOASrKzAFBgMrZXAwETEPMA0G -A1UEAwwGVGVzdENBMB4XDTI2MDQwNTE2MjEzM1oXDTM2MDQwMjE2MjEzM1owFDES -MBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEAn8X6FEFjCq5MKfiSVNKjRY5p -TKdDrASo29olFWz8qy+jUzBRMA8GA1UdEQQIMAaHBH8AAAEwHQYDVR0OBBYEFL7n -tv01dMhzLJ0dzTo7tEAXrYjuMB8GA1UdIwQYMBaAFPzxTJMsJZqLi8m2RLkrTO62 -K9k5MAUGAytlcANBAGpMB98RKLprVHiagV1Myj08TK2Lz4+K+Hs2fhUVMgRP9JXV -vf8tC77XV/fH9wIKeaPvsupFO73AdD0BQZb8bQ0= ------END CERTIFICATE----- diff --git a/lib/crates/fabro-server/tests/fixtures/mtls/server.key b/lib/crates/fabro-server/tests/fixtures/mtls/server.key deleted file mode 100644 index 67ff8e49a..000000000 --- a/lib/crates/fabro-server/tests/fixtures/mtls/server.key +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIAX0EXHZDH5uU2h5ctNqHfa9hMtO9tfoM1kCRL9JHGtA ------END PRIVATE KEY----- diff --git a/lib/crates/fabro-server/tests/fixtures/mtls/wrong-client.crt b/lib/crates/fabro-server/tests/fixtures/mtls/wrong-client.crt deleted file mode 100644 index 17b0ab0aa..000000000 --- a/lib/crates/fabro-server/tests/fixtures/mtls/wrong-client.crt +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIBMzCB5qADAgECAhRT8XV12gL48jHEcgn7qTk7b5ocbzAFBgMrZXAwEjEQMA4G -A1UEAwwHV3JvbmdDQTAeFw0yNjA0MDUxNjIxMzNaFw0zNjA0MDIxNjIxMzNaMBMx -ETAPBgNVBAMMCGludHJ1ZGVyMCowBQYDK2VwAyEADrp6dr+UfNhzR6guiNU5ns0c -Y97Ari4gVZnh8DE1MB6jTTBLMAkGA1UdEwQCMAAwHQYDVR0OBBYEFIF2t8T34ktN -Y276k4702JltR0iEMB8GA1UdIwQYMBaAFNFUQIdydtMb4t9g8+0s9DHh0pYIMAUG -AytlcANBAFO7sA+Po2qFaTRSdpxuAQIbywHiF92uyombcfQkQPgbVbAA3oH9gh32 -4uG4c1OCE+w1AI1f2/EpC4zZRPZVAwI= ------END CERTIFICATE----- diff --git a/lib/crates/fabro-server/tests/fixtures/mtls/wrong-client.key b/lib/crates/fabro-server/tests/fixtures/mtls/wrong-client.key deleted file mode 100644 index 80c20f2da..000000000 --- a/lib/crates/fabro-server/tests/fixtures/mtls/wrong-client.key +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIAU8EOnIZ26wKCqJ/WTcoCBHETbSYsILQ9zxddB92JVQ ------END PRIVATE KEY----- diff --git a/lib/crates/fabro-server/tests/it/api/docs.rs b/lib/crates/fabro-server/tests/it/api/docs.rs new file mode 100644 index 000000000..d43c6a6c4 --- /dev/null +++ b/lib/crates/fabro-server/tests/it/api/docs.rs @@ -0,0 +1,37 @@ +use std::path::PathBuf; + +fn read_doc(relative_path: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../") + .join(relative_path); + std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())) +} + +#[test] +fn active_server_docs_describe_the_unix_socket_default() { + let architecture = read_doc("docs/reference/architecture.mdx"); + assert!( + architecture.contains("~/.fabro/fabro.sock"), + "architecture doc should mention the default Unix socket bind" + ); + + let api_overview = read_doc("docs/api-reference/overview.mdx"); + assert!( + api_overview.contains("~/.fabro/fabro.sock"), + "API overview should mention the default Unix socket bind" + ); +} + +#[test] +fn security_doc_does_not_require_jwt_keys_for_the_current_web_flow() { + let security = read_doc("docs/administration/security.mdx"); + assert!( + security.contains("SESSION_SECRET"), + "security doc should still mention the session secret" + ); + assert!( + !security.contains("`FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, and `SESSION_SECRET`"), + "security doc should not describe JWT keys as required for the current web flow" + ); +} diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs index 26702579b..d924a389f 100644 --- a/lib/crates/fabro-server/tests/it/api/mod.rs +++ b/lib/crates/fabro-server/tests/it/api/mod.rs @@ -1,6 +1,6 @@ +mod docs; mod routing; mod runs; mod settings; mod system; -#[cfg(target_os = "linux")] -mod tls; +mod tcp; diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/crates/fabro-server/tests/it/api/runs.rs index 0097a3c2b..ff6edafba 100644 --- a/lib/crates/fabro-server/tests/it/api/runs.rs +++ b/lib/crates/fabro-server/tests/it/api/runs.rs @@ -21,10 +21,6 @@ _version = 1 type = "tcp" address = "127.0.0.1:32276" -[server.listen.tls] -cert = "/etc/fabro/tls/cert.pem" -key = "/etc/fabro/tls/key.pem" - [server.auth] methods = ["dev-token", "github"] diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index fd9525e3d..c82772f04 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -19,10 +19,6 @@ _version = 1 type = "tcp" address = "127.0.0.1:32276" -[server.listen.tls] -cert = "/etc/fabro/tls/cert.pem" -key = "/etc/fabro/tls/key.pem" - [server.storage] root = "/srv/fabro" diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs new file mode 100644 index 000000000..a78e6eaa7 --- /dev/null +++ b/lib/crates/fabro-server/tests/it/api/tcp.rs @@ -0,0 +1,280 @@ +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +#[cfg(unix)] +use std::time::{SystemTime, UNIX_EPOCH}; + +use fabro_server::bind::Bind; +use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; +use fabro_server::jwt_auth::{AuthMode, ConfiguredAuth}; +use fabro_server::serve::{ServeArgs, serve_command}; +use fabro_server::server::{ + RouterOptions, build_router, build_router_with_options, create_app_state, +}; +use fabro_types::settings::ServerAuthMethod; +use fabro_util::terminal::Styles; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::sleep; + +use crate::helpers::api; + +const TEST_DEV_TOKEN: &str = + "fabro_dev_abababababababababababababababababababababababababababababababab"; + +async fn start_tcp_server(auth_mode: AuthMode) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let state = create_app_state(); + let router = build_router(state, auth_mode); + + tokio::spawn(async move { + let _ = axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await; + }); + + addr +} + +async fn start_tcp_server_with_allowlist( + auth_mode: AuthMode, + ip_allowlist: Arc, +) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let state = create_app_state(); + let router = + build_router_with_options(state, auth_mode, ip_allowlist, RouterOptions::default()); + + tokio::spawn(async move { + let _ = axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await; + }); + + addr +} + +fn build_client() -> fabro_http::HttpClient { + fabro_http::test_http_client().unwrap() +} + +#[cfg(unix)] +fn build_unix_client(path: &Path) -> fabro_http::HttpClient { + fabro_http::HttpClientBuilder::new() + .unix_socket(path) + .no_proxy() + .build() + .unwrap() +} + +fn write_test_config(tempdir: &TempDir, settings: &str) -> PathBuf { + let config_path = tempdir.path().join("settings.toml"); + std::fs::write(&config_path, settings).unwrap(); + std::fs::write( + tempdir.path().join("server.env"), + format!("FABRO_DEV_TOKEN={TEST_DEV_TOKEN}\n"), + ) + .unwrap(); + config_path +} + +async fn spawn_served_listener( + settings: impl AsRef, +) -> (JoinHandle>, Bind, TempDir) { + let tempdir = tempfile::tempdir().unwrap(); + let config_path = write_test_config(&tempdir, settings.as_ref()); + let styles: &'static Styles = Box::leak(Box::new(Styles::new(false))); + let (tx, rx) = tokio::sync::oneshot::channel(); + let mut tx = Some(tx); + let storage_dir = tempdir.path().to_path_buf(); + + let handle = tokio::spawn(async move { + Box::pin(serve_command( + ServeArgs { + bind: None, + web: false, + no_web: true, + model: None, + provider: None, + sandbox: None, + max_concurrent_runs: None, + config: Some(config_path), + #[cfg(debug_assertions)] + watch_web: false, + }, + styles, + Some(storage_dir), + move |bind| { + let sender = tx.take().expect("server should only report readiness once"); + sender.send(bind.clone()).ok(); + Ok(()) + }, + )) + .await + }); + + let bind = rx.await.expect("server should report its bind address"); + (handle, bind, tempdir) +} + +async fn wait_for_tcp_health(addr: SocketAddr) { + let client = build_client(); + let url = format!("http://127.0.0.1:{}/health", addr.port()); + + for _ in 0..50 { + if let Ok(response) = client.get(&url).send().await { + if response.status() == 200 { + return; + } + } + sleep(Duration::from_millis(10)).await; + } + + panic!("timed out waiting for TCP health endpoint at {url}"); +} + +#[cfg(unix)] +async fn wait_for_unix_health(path: &Path) { + let client = build_unix_client(path); + + for _ in 0..50 { + if let Ok(response) = client.get("http://fabro/health").send().await { + if response.status() == 200 { + return; + } + } + sleep(Duration::from_millis(10)).await; + } + + panic!( + "timed out waiting for Unix socket health endpoint at {}", + path.display() + ); +} + +#[tokio::test] +async fn tcp_accepts_plain_http_requests() { + let (handle, bind, _tempdir) = spawn_served_listener( + r#" +_version = 1 + +[server.listen] +type = "tcp" +address = "127.0.0.1:0" + +[server.auth] +methods = ["dev-token"] +"#, + ) + .await; + let addr = match bind { + Bind::Tcp(addr) => addr, + Bind::Unix(path) => panic!("expected TCP bind, got unix socket at {}", path.display()), + }; + wait_for_tcp_health(addr).await; + + let client = build_client(); + + let response = client + .get(format!("http://127.0.0.1:{}{}", addr.port(), api("/runs"))) + .bearer_auth(TEST_DEV_TOKEN) + .send() + .await + .expect("plain HTTP request should succeed"); + + assert_eq!(response.status(), 200); + handle.abort(); +} + +#[tokio::test] +async fn tcp_dev_token_auth_uses_bearer_auth() { + let auth_mode = AuthMode::Enabled(ConfiguredAuth { + methods: vec![ServerAuthMethod::DevToken], + dev_token: Some(TEST_DEV_TOKEN.to_string()), + }); + let addr = start_tcp_server(auth_mode).await; + let client = build_client(); + let url = format!("http://127.0.0.1:{}{}", addr.port(), api("/runs")); + + let unauthorized = client.get(&url).send().await.unwrap(); + assert_eq!(unauthorized.status(), 401); + + let authorized = client + .get(url) + .bearer_auth(TEST_DEV_TOKEN) + .send() + .await + .unwrap(); + assert_eq!(authorized.status(), 200); +} + +#[cfg(unix)] +#[tokio::test] +async fn unix_socket_accepts_plain_http_requests() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let socket_path = std::env::temp_dir().join(format!("fabro-server-it-{unique}.sock")); + let (handle, bind, _tempdir) = spawn_served_listener(format!( + r#" +_version = 1 + +[server.listen] +type = "unix" +path = "{}" + +[server.auth] +methods = ["dev-token"] +"#, + socket_path.display() + )) + .await; + let path = match bind { + Bind::Unix(path) => path, + Bind::Tcp(addr) => panic!("expected Unix bind, got TCP address {addr}"), + }; + wait_for_unix_health(&path).await; + + let response = build_unix_client(&path) + .get(format!("http://fabro{}", api("/runs"))) + .bearer_auth(TEST_DEV_TOKEN) + .send() + .await + .expect("Unix-socket HTTP request should succeed"); + + assert_eq!(response.status(), 200); + handle.abort(); + std::fs::remove_file(&path).ok(); +} + +#[tokio::test] +async fn tcp_ip_allowlist_uses_connect_info() { + let addr = start_tcp_server_with_allowlist( + AuthMode::Disabled, + Arc::new(IpAllowlistConfig { + allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), + trusted_proxy_count: 0, + }), + ) + .await; + let client = build_client(); + + let response = client + .get(format!("http://127.0.0.1:{}{}", addr.port(), api("/runs"))) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), 403); +} diff --git a/lib/crates/fabro-server/tests/it/api/tls.rs b/lib/crates/fabro-server/tests/it/api/tls.rs deleted file mode 100644 index 8b719eeef..000000000 --- a/lib/crates/fabro-server/tests/it/api/tls.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; -use fabro_server::jwt_auth::{AuthMode, ConfiguredAuth}; -use fabro_server::server::{ - RouterOptions, build_router, build_router_with_options, create_app_state, -}; -use fabro_server::tls::build_rustls_config; -use fabro_types::settings::{InterpString, ServerAuthMethod, TlsConfig}; -use tokio::net::TcpListener; - -use crate::helpers::api; - -fn fixture_path(name: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/mtls") - .join(name) -} - -struct PkiPaths { - ca_cert: PathBuf, - server_cert: PathBuf, - server_key: PathBuf, -} - -fn fixture_pki() -> PkiPaths { - PkiPaths { - ca_cert: fixture_path("ca.crt"), - server_cert: fixture_path("server.crt"), - server_key: fixture_path("server.key"), - } -} - -async fn start_tls_server(tls_settings: &TlsConfig, auth_mode: AuthMode) -> std::net::SocketAddr { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let rustls_config = build_rustls_config(tls_settings).unwrap(); - let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config); - - let state = create_app_state(); - let router = build_router(state, auth_mode); - - tokio::spawn(async move { - let _ = fabro_server::tls::serve_tls(listener, tls_acceptor, router).await; - }); - - addr -} - -async fn start_tls_server_with_allowlist( - tls_settings: &TlsConfig, - auth_mode: AuthMode, - ip_allowlist: Arc, -) -> std::net::SocketAddr { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let rustls_config = build_rustls_config(tls_settings).unwrap(); - let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config); - - let state = create_app_state(); - let router = - build_router_with_options(state, auth_mode, ip_allowlist, RouterOptions::default()); - - tokio::spawn(async move { - let _ = fabro_server::tls::serve_tls(listener, tls_acceptor, router).await; - }); - - addr -} - -fn build_client(ca_cert_path: &Path) -> fabro_http::HttpClient { - let ca_pem = std::fs::read(ca_cert_path).unwrap(); - let ca_cert = fabro_http::tls::Certificate::from_pem(&ca_pem).unwrap(); - - fabro_http::HttpClientBuilder::new() - .add_root_certificate(ca_cert) - .no_proxy() - .use_rustls_tls() - .build() - .unwrap() -} - -fn install_crypto_provider() { - let _ = rustls::crypto::ring::default_provider().install_default(); -} - -fn tls_settings(pki: &PkiPaths) -> TlsConfig { - TlsConfig { - cert: InterpString::parse(&pki.server_cert.to_string_lossy()), - key: InterpString::parse(&pki.server_key.to_string_lossy()), - } -} - -#[tokio::test] -async fn tls_accepts_requests_without_client_cert() { - install_crypto_provider(); - let pki = fixture_pki(); - let addr = start_tls_server(&tls_settings(&pki), AuthMode::Disabled).await; - let client = build_client(&pki.ca_cert); - - let response = client - .get(format!("https://127.0.0.1:{}{}", addr.port(), api("/runs"))) - .send() - .await - .expect("request over TLS should succeed without a client certificate"); - - assert_eq!(response.status(), 200); -} - -#[tokio::test] -async fn tls_dev_token_auth_does_not_require_client_cert() { - install_crypto_provider(); - let pki = fixture_pki(); - let dev_token = "fabro_dev_abababababababababababababababababababababababababababababababab"; - let auth_mode = AuthMode::Enabled(ConfiguredAuth { - methods: vec![ServerAuthMethod::DevToken], - dev_token: Some(dev_token.to_string()), - }); - let addr = start_tls_server(&tls_settings(&pki), auth_mode).await; - let client = build_client(&pki.ca_cert); - let url = format!("https://127.0.0.1:{}{}", addr.port(), api("/runs")); - - let unauthorized = client.get(&url).send().await.unwrap(); - assert_eq!(unauthorized.status(), 401); - - let authorized = client.get(url).bearer_auth(dev_token).send().await.unwrap(); - assert_eq!(authorized.status(), 200); -} - -#[tokio::test] -async fn tls_ip_allowlist_uses_connect_info() { - install_crypto_provider(); - let pki = fixture_pki(); - let addr = start_tls_server_with_allowlist( - &tls_settings(&pki), - AuthMode::Disabled, - Arc::new(IpAllowlistConfig { - allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), - trusted_proxy_count: 0, - }), - ) - .await; - let client = build_client(&pki.ca_cert); - - let response = client - .get(format!("https://127.0.0.1:{}{}", addr.port(), api("/runs"))) - .send() - .await - .unwrap(); - - assert_eq!(response.status(), 403); -} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 3f839aa72..5a034b8d9 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -52,7 +52,7 @@ pub use server::{ ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerLayer, ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, ServerSlateDbSettings, ServerStorageSettings, - ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, TlsConfig, + ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, }; pub use size::{ParseSizeError, Size}; pub use splice_array::{SPLICE_MARKER, SpliceArray, SpliceArrayError}; diff --git a/lib/crates/fabro-types/src/settings/resolved.rs b/lib/crates/fabro-types/src/settings/resolved.rs index 79a4825ff..16ad7a310 100644 --- a/lib/crates/fabro-types/src/settings/resolved.rs +++ b/lib/crates/fabro-types/src/settings/resolved.rs @@ -29,7 +29,7 @@ mod tests { DockerfileSource, McpServerSettings, McpTransport, RunAgentSettings, RunGoal, RunSettings, }; use crate::settings::server::{ - ObjectStoreSettings, ServerListenSettings, ServerSettings, ServerSlateDbSettings, TlsConfig, + ObjectStoreSettings, ServerListenSettings, ServerSettings, ServerSlateDbSettings, }; #[test] @@ -119,19 +119,11 @@ mod tests { assert_eq!( serde_json::to_value(ServerListenSettings::Tcp { address: "127.0.0.1:8080".parse().unwrap(), - tls: Some(TlsConfig { - cert: InterpString::parse("/tmp/server.crt"), - key: InterpString::parse("/tmp/server.key"), - }), }) .unwrap(), json!({ "type": "tcp", - "address": "127.0.0.1:8080", - "tls": { - "cert": "/tmp/server.crt", - "key": "/tmp/server.key" - } + "address": "127.0.0.1:8080" }) ); diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index ba7895e25..b9280eef7 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -37,7 +37,6 @@ pub enum ServerListenSettings { Tcp { #[serde(serialize_with = "serialize_socket_addr")] address: SocketAddr, - tls: Option, }, Unix { path: InterpString, @@ -52,21 +51,6 @@ impl Default for ServerListenSettings { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct TlsConfig { - pub cert: InterpString, - pub key: InterpString, -} - -impl Default for TlsConfig { - fn default() -> Self { - Self { - cert: InterpString::parse(""), - key: InterpString::parse(""), - } - } -} - #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct ServerApiSettings { pub url: Option, @@ -307,16 +291,13 @@ pub struct ServerLayer { pub integrations: Option, } -/// `[server.listen]` — shared bind transport. TLS lives under -/// `[server.listen.tls]`. +/// `[server.listen]` — shared bind transport. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] pub enum ServerListenLayer { Tcp { #[serde(default)] address: Option, - #[serde(default)] - tls: Option, }, Unix { #[serde(default)] @@ -324,15 +305,6 @@ pub enum ServerListenLayer { }, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerListenTlsLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cert: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub key: Option, -} - /// `[server.api]` — API surface settings. /// /// `url` is an optional public URL; it is **not** derived from `server.listen`. From 21240e2d091ce52eef4447574a0d68079f9f1baf Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 10:48:42 -0400 Subject: [PATCH 06/12] fix(server): address TLS removal review follow-ups --- docs/administration/deploy-server.mdx | 2 +- docs/changelog/2026-03-03.mdx | 2 ++ lib/crates/fabro-server/src/settings_view.rs | 2 +- lib/crates/fabro-server/tests/it/api/docs.rs | 18 ++++++++++++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/administration/deploy-server.mdx b/docs/administration/deploy-server.mdx index d243e9f77..13b0e28af 100644 --- a/docs/administration/deploy-server.mdx +++ b/docs/administration/deploy-server.mdx @@ -131,7 +131,7 @@ fabro model list --server https://fabro.example.com/api/v1 `fabro model list` and `fabro model test` honor `[cli.target]` by default unless you explicitly pass `--storage-dir`. `fabro exec` remains a local agent session and only uses the server when you pass `--server`. -See [User Configuration](/reference/user-configuration#cli.target-section) for the full connection options, including client certificates for proxy-terminated HTTPS endpoints. +See [User Configuration](/reference/user-configuration#cli-target-section) for the full connection options, including client certificates for proxy-terminated HTTPS endpoints. ## Next steps diff --git a/docs/changelog/2026-03-03.mdx b/docs/changelog/2026-03-03.mdx index 2801c34a8..2b9137aea 100644 --- a/docs/changelog/2026-03-03.mdx +++ b/docs/changelog/2026-03-03.mdx @@ -7,6 +7,8 @@ date: "2026-03-03" Two new authentication methods for the API server. Mutual TLS provides strong identity verification for production deployments — both client and server present certificates. For teams on a Tailscale network, API requests can authenticate using Tailscale identity with no tokens or certificates required. +Historical note: later releases removed inbound mutual TLS listener support from `fabro-server`; current deployments terminate TLS or mTLS upstream at a reverse proxy or platform ingress. + ## Setup wizard Previously, getting Fabro running meant manually editing config files and setting environment variables. Now run `fabro install` for an interactive walkthrough that configures API keys, server settings, and authentication. diff --git a/lib/crates/fabro-server/src/settings_view.rs b/lib/crates/fabro-server/src/settings_view.rs index f620891e1..771519af2 100644 --- a/lib/crates/fabro-server/src/settings_view.rs +++ b/lib/crates/fabro-server/src/settings_view.rs @@ -57,7 +57,7 @@ pub(crate) fn redact_for_api(settings: &SettingsLayer) -> SettingsLayer { let mut out = settings.clone(); if let Some(server) = out.server.as_mut() { - // Bind address + TLS key/cert paths: host operational details. + // Bind addresses and socket paths: host operational details. server.listen = None; } diff --git a/lib/crates/fabro-server/tests/it/api/docs.rs b/lib/crates/fabro-server/tests/it/api/docs.rs index d43c6a6c4..e3c3be45e 100644 --- a/lib/crates/fabro-server/tests/it/api/docs.rs +++ b/lib/crates/fabro-server/tests/it/api/docs.rs @@ -35,3 +35,21 @@ fn security_doc_does_not_require_jwt_keys_for_the_current_web_flow() { "security doc should not describe JWT keys as required for the current web flow" ); } + +#[test] +fn deploy_server_doc_links_to_the_cli_target_section_slug() { + let deploy_server = read_doc("docs/administration/deploy-server.mdx"); + assert!( + deploy_server.contains("/reference/user-configuration#cli-target-section"), + "deploy-server doc should link to the Mintlify slug for the [cli.target] section" + ); +} + +#[test] +fn changelog_marks_removed_mutual_tls_as_historical() { + let changelog = read_doc("docs/changelog/2026-03-03.mdx"); + assert!( + changelog.contains("removed inbound mutual TLS listener support"), + "historical changelog should clarify that inbound mutual TLS is no longer supported" + ); +} From 79cd760cd9761881e3a62767c8797bc096c16190 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 11:04:45 -0400 Subject: [PATCH 07/12] refactor(server): dedupe tcp test helpers and drop narrative comment Collapses duplicated helpers in tests/it/api/tcp.rs introduced with the TLS-removal test suite (single start_tcp_server, single wait_for_health), uses ServerState::env_path() in write_test_config, and replaces the manual SystemTime-based unique-socket path with a tempdir. Also removes a narrative comment in settings_view that the module docstring already covers. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-server/src/settings_view.rs | 1 - lib/crates/fabro-server/tests/it/api/tcp.rs | 90 ++++---------------- 2 files changed, 18 insertions(+), 73 deletions(-) diff --git a/lib/crates/fabro-server/src/settings_view.rs b/lib/crates/fabro-server/src/settings_view.rs index 771519af2..fe2640c53 100644 --- a/lib/crates/fabro-server/src/settings_view.rs +++ b/lib/crates/fabro-server/src/settings_view.rs @@ -57,7 +57,6 @@ pub(crate) fn redact_for_api(settings: &SettingsLayer) -> SettingsLayer { let mut out = settings.clone(); if let Some(server) = out.server.as_mut() { - // Bind addresses and socket paths: host operational details. server.listen = None; } diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs index a78e6eaa7..ca2fb773b 100644 --- a/lib/crates/fabro-server/tests/it/api/tcp.rs +++ b/lib/crates/fabro-server/tests/it/api/tcp.rs @@ -2,16 +2,13 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -#[cfg(unix)] -use std::time::{SystemTime, UNIX_EPOCH}; +use fabro_config::ServerState; use fabro_server::bind::Bind; use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; use fabro_server::jwt_auth::{AuthMode, ConfiguredAuth}; use fabro_server::serve::{ServeArgs, serve_command}; -use fabro_server::server::{ - RouterOptions, build_router, build_router_with_options, create_app_state, -}; +use fabro_server::server::{RouterOptions, build_router_with_options, create_app_state}; use fabro_types::settings::ServerAuthMethod; use fabro_util::terminal::Styles; use tempfile::TempDir; @@ -24,28 +21,7 @@ use crate::helpers::api; const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; -async fn start_tcp_server(auth_mode: AuthMode) -> SocketAddr { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let state = create_app_state(); - let router = build_router(state, auth_mode); - - tokio::spawn(async move { - let _ = axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .await; - }); - - addr -} - -async fn start_tcp_server_with_allowlist( - auth_mode: AuthMode, - ip_allowlist: Arc, -) -> SocketAddr { +async fn start_tcp_server(auth_mode: AuthMode, ip_allowlist: Arc) -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -64,10 +40,6 @@ async fn start_tcp_server_with_allowlist( addr } -fn build_client() -> fabro_http::HttpClient { - fabro_http::test_http_client().unwrap() -} - #[cfg(unix)] fn build_unix_client(path: &Path) -> fabro_http::HttpClient { fabro_http::HttpClientBuilder::new() @@ -81,7 +53,7 @@ fn write_test_config(tempdir: &TempDir, settings: &str) -> PathBuf { let config_path = tempdir.path().join("settings.toml"); std::fs::write(&config_path, settings).unwrap(); std::fs::write( - tempdir.path().join("server.env"), + ServerState::new(tempdir.path()).env_path(), format!("FABRO_DEV_TOKEN={TEST_DEV_TOKEN}\n"), ) .unwrap(); @@ -127,12 +99,9 @@ async fn spawn_served_listener( (handle, bind, tempdir) } -async fn wait_for_tcp_health(addr: SocketAddr) { - let client = build_client(); - let url = format!("http://127.0.0.1:{}/health", addr.port()); - +async fn wait_for_health(client: &fabro_http::HttpClient, url: &str) { for _ in 0..50 { - if let Ok(response) = client.get(&url).send().await { + if let Ok(response) = client.get(url).send().await { if response.status() == 200 { return; } @@ -140,26 +109,7 @@ async fn wait_for_tcp_health(addr: SocketAddr) { sleep(Duration::from_millis(10)).await; } - panic!("timed out waiting for TCP health endpoint at {url}"); -} - -#[cfg(unix)] -async fn wait_for_unix_health(path: &Path) { - let client = build_unix_client(path); - - for _ in 0..50 { - if let Ok(response) = client.get("http://fabro/health").send().await { - if response.status() == 200 { - return; - } - } - sleep(Duration::from_millis(10)).await; - } - - panic!( - "timed out waiting for Unix socket health endpoint at {}", - path.display() - ); + panic!("timed out waiting for health endpoint at {url}"); } #[tokio::test] @@ -181,9 +131,8 @@ methods = ["dev-token"] Bind::Tcp(addr) => addr, Bind::Unix(path) => panic!("expected TCP bind, got unix socket at {}", path.display()), }; - wait_for_tcp_health(addr).await; - - let client = build_client(); + let client = fabro_http::test_http_client().unwrap(); + wait_for_health(&client, &format!("http://127.0.0.1:{}/health", addr.port())).await; let response = client .get(format!("http://127.0.0.1:{}{}", addr.port(), api("/runs"))) @@ -202,8 +151,8 @@ async fn tcp_dev_token_auth_uses_bearer_auth() { methods: vec![ServerAuthMethod::DevToken], dev_token: Some(TEST_DEV_TOKEN.to_string()), }); - let addr = start_tcp_server(auth_mode).await; - let client = build_client(); + let addr = start_tcp_server(auth_mode, Arc::new(IpAllowlistConfig::default())).await; + let client = fabro_http::test_http_client().unwrap(); let url = format!("http://127.0.0.1:{}{}", addr.port(), api("/runs")); let unauthorized = client.get(&url).send().await.unwrap(); @@ -221,11 +170,8 @@ async fn tcp_dev_token_auth_uses_bearer_auth() { #[cfg(unix)] #[tokio::test] async fn unix_socket_accepts_plain_http_requests() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let socket_path = std::env::temp_dir().join(format!("fabro-server-it-{unique}.sock")); + let socket_dir = tempfile::tempdir().unwrap(); + let socket_path = socket_dir.path().join("fabro.sock"); let (handle, bind, _tempdir) = spawn_served_listener(format!( r#" _version = 1 @@ -244,9 +190,10 @@ methods = ["dev-token"] Bind::Unix(path) => path, Bind::Tcp(addr) => panic!("expected Unix bind, got TCP address {addr}"), }; - wait_for_unix_health(&path).await; + let client = build_unix_client(&path); + wait_for_health(&client, "http://fabro/health").await; - let response = build_unix_client(&path) + let response = client .get(format!("http://fabro{}", api("/runs"))) .bearer_auth(TEST_DEV_TOKEN) .send() @@ -255,12 +202,11 @@ methods = ["dev-token"] assert_eq!(response.status(), 200); handle.abort(); - std::fs::remove_file(&path).ok(); } #[tokio::test] async fn tcp_ip_allowlist_uses_connect_info() { - let addr = start_tcp_server_with_allowlist( + let addr = start_tcp_server( AuthMode::Disabled, Arc::new(IpAllowlistConfig { allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), @@ -268,7 +214,7 @@ async fn tcp_ip_allowlist_uses_connect_info() { }), ) .await; - let client = build_client(); + let client = fabro_http::test_http_client().unwrap(); let response = client .get(format!("http://127.0.0.1:{}{}", addr.port(), api("/runs"))) From 6226858648127699bcf1afa5b8cde86cda6c4a93 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 11:12:58 -0400 Subject: [PATCH 08/12] fix(runs): finish canonical run summary rollout Complete the /runs and /boards/runs canonicalization work by fixing the run-detail response shape, preserving lifecycle status separately from board columns, loading all board pages in the web client, and aligning the shared status_reason typing. --- apps/fabro-web/app/api.test.ts | 64 +++++++- apps/fabro-web/app/api.ts | 66 ++++++++- apps/fabro-web/app/data/runs.test.ts | 39 ++++- apps/fabro-web/app/data/runs.ts | 27 +++- apps/fabro-web/app/routes/run-detail.tsx | 2 +- apps/fabro-web/app/routes/runs.tsx | 43 +++++- apps/fabro-web/app/routes/workflow-runs.tsx | 18 ++- docs/api-reference/fabro-api.yaml | 4 +- lib/crates/fabro-server/src/demo/mod.rs | 16 +- lib/crates/fabro-server/src/server.rs | 140 ++++++++++++++++-- .../src/models/store-run-summary.ts | 5 +- 11 files changed, 380 insertions(+), 44 deletions(-) diff --git a/apps/fabro-web/app/api.test.ts b/apps/fabro-web/app/api.test.ts index 2e9dacb56..5201fbcc7 100644 --- a/apps/fabro-web/app/api.test.ts +++ b/apps/fabro-web/app/api.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; -import { getAuthConfig, isNotAvailable, loginDevToken } from "./api"; +import { apiPaginatedJson, getAuthConfig, isNotAvailable, loginDevToken } from "./api"; afterEach(() => { mock.restore(); @@ -61,3 +61,65 @@ describe("auth helpers", () => { }); }); }); + +describe("apiPaginatedJson", () => { + test("loads and concatenates all pages while preserving first-page extras", async () => { + const fetchMock = mock((input: string | URL | Request) => { + const url = String(input); + if (url.includes("page%5Boffset%5D=0")) { + return Promise.resolve( + new Response( + JSON.stringify({ + columns: [{ id: "running", name: "Running" }], + data: [{ id: "run-1" }, { id: "run-2" }], + meta: { has_more: true }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + columns: [{ id: "ignored", name: "Ignored" }], + data: [{ id: "run-3" }], + meta: { has_more: false }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await apiPaginatedJson<{ id: string }, { columns: { id: string; name: string }[] }>( + "/boards/runs", + ); + + expect(result.columns).toEqual([{ id: "running", name: "Running" }]); + expect(result.data).toEqual([{ id: "run-1" }, { id: "run-2" }, { id: "run-3" }]); + expect(result.meta).toEqual({ has_more: false }); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "/api/v1/boards/runs?page%5Blimit%5D=100&page%5Boffset%5D=0", + { + credentials: "include", + headers: undefined, + }, + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/v1/boards/runs?page%5Blimit%5D=100&page%5Boffset%5D=2", + { + credentials: "include", + headers: undefined, + }, + ); + }); +}); diff --git a/apps/fabro-web/app/api.ts b/apps/fabro-web/app/api.ts index 08c19ac68..da43e2c09 100644 --- a/apps/fabro-web/app/api.ts +++ b/apps/fabro-web/app/api.ts @@ -3,9 +3,25 @@ export interface ApiOptions { request?: Request; } +export interface PaginatedEnvelope { + data: T[]; + meta: { has_more: boolean }; +} + +function buildApiPath(path: string): string { + return `/api/v1${path}`; +} + +function buildPaginatedApiPath(path: string, limit: number, offset: number): string { + const url = new URL(buildApiPath(path), "http://fabro.local"); + url.searchParams.set("page[limit]", String(limit)); + url.searchParams.set("page[offset]", String(offset)); + return `${url.pathname}${url.search}`; +} + export async function apiFetch(path: string, options?: ApiOptions): Promise { const { init } = options ?? {}; - const response = await fetch(`/api/v1${path}`, { + const response = await fetch(buildApiPath(path), { ...init, credentials: "include", headers: init?.headers, @@ -27,6 +43,50 @@ export async function apiJson(path: string, options?: ApiOptions): Promise return response.json() as Promise; } +export async function apiPaginatedJson( + path: string, + options?: ApiOptions, +): Promise & TExtra> { + const limit = 100; + let offset = 0; + const data: TItem[] = []; + let extras: TExtra | null = null; + + while (true) { + const response = await fetch(buildPaginatedApiPath(path, limit, offset), { + ...options?.init, + credentials: "include", + headers: options?.init?.headers, + }); + + if (response.status === 401) { + window.location.href = "/login"; + throw new Error("Unauthorized"); + } + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + + const page = (await response.json()) as PaginatedEnvelope & TExtra; + if (extras == null) { + const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope & + Record; + extras = rest as TExtra; + } + + data.push(...page.data); + if (!page.meta.has_more || page.data.length === 0) { + return { + ...(extras ?? ({} as TExtra)), + data, + meta: { has_more: false }, + }; + } + + offset += page.data.length; + } +} + export function isNotAvailable(status: number): boolean { return status === 404 || status === 501; } @@ -49,7 +109,7 @@ export async function apiJsonOrNull( } export async function getAuthConfig(): Promise<{ methods: string[] }> { - const response = await fetch("/api/v1/auth/config", { credentials: "include" }); + const response = await fetch(buildApiPath("/auth/config"), { credentials: "include" }); if (!response.ok) { throw new Response(null, { status: response.status, statusText: response.statusText }); } @@ -80,7 +140,7 @@ export async function getAuthMe(): Promise<{ provider: string; demoMode: boolean; }> { - const response = await fetch("/api/v1/auth/me", { credentials: "include" }); + const response = await fetch(buildApiPath("/auth/me"), { credentials: "include" }); if (response.status === 401) { throw new Response(null, { status: 401, statusText: "Unauthorized" }); } diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index ebc03cfa5..13d0602b5 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mapRunListItem, mapRunSummaryToRunItem } from "./runs"; +import { columnForStatus, mapRunListItem, mapRunSummaryToRunItem } from "./runs"; describe("mapRunListItem", () => { test("trusts shared server fields for board items", () => { @@ -11,7 +11,7 @@ describe("mapRunListItem", () => { workflow_name: "Fix Build", host_repo_path: "/home/user/myrepo", repository: { name: "myrepo" }, - status: "running", + status: "paused", labels: {}, column: "running", elapsed_secs: 65, @@ -28,6 +28,32 @@ describe("mapRunListItem", () => { expect(item.workflow).toBe("fix_build"); expect(item.repo).toBe("myrepo"); expect(item.elapsed).toBeDefined(); + expect(item.column).toBe("running"); + expect(item.lifecycleStatus).toBe("paused"); + }); + + test("uses a fallback title when the server title is blank", () => { + const summary = { + run_id: "01EMPTY", + goal: "", + title: "", + workflow_slug: "fix_build", + workflow_name: "Fix Build", + host_repo_path: "/home/user/myrepo", + repository: { name: "myrepo" }, + status: "running", + labels: {}, + column: "running", + elapsed_secs: null, + duration_ms: null, + total_usd_micros: null, + created_at: "2026-04-08T12:00:00Z", + start_time: null, + status_reason: null, + pending_control: null, + } as const; + + expect(mapRunListItem(summary).title).toBe("Untitled run"); }); }); @@ -57,6 +83,7 @@ describe("mapRunSummaryToRunItem", () => { expect(item.workflow).toBe("fix_build"); expect(item.repo).toBe("myrepo"); expect(item.elapsed).toBeDefined(); + expect(item.lifecycleStatus).toBe("running"); }); test("handles missing optional fields", () => { @@ -80,8 +107,14 @@ describe("mapRunSummaryToRunItem", () => { }; const item = mapRunSummaryToRunItem(summary); expect(item.id).toBe("01DEF"); - expect(item.title).toBe(""); + expect(item.title).toBe("Untitled run"); expect(item.workflow).toBe("unknown"); expect(item.repo).toBe("unknown"); }); }); + +describe("columnForStatus", () => { + test("returns null for lifecycle states that do not map to a board column", () => { + expect(columnForStatus("removing")).toBeNull(); + }); +}); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index c0f8f2763..aa9769ad2 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -16,12 +16,14 @@ export interface RunItem { repo: string; title: string; workflow: string; + column?: ColumnStatus; + lifecycleStatus?: string | null; + lifecycleStatusLabel?: string; number?: number; additions?: number; deletions?: number; checks?: CheckRun[]; elapsed?: string; - elapsedWarning?: boolean; resources?: string; actionDisabled?: boolean; comments?: number; @@ -44,12 +46,19 @@ export interface RunWithStatus extends RunItem { statusLabel: string; } +function displayRunTitle(title: string | null | undefined): string { + return title?.trim() ? title : "Untitled run"; +} + export function mapRunListItem(item: RunListItem): RunItem { return { id: item.run_id, repo: item.repository.name, - title: item.title, + title: displayRunTitle(item.title), workflow: item.workflow_slug ?? item.workflow_name ?? "unknown", + column: item.column, + lifecycleStatus: item.status, + lifecycleStatusLabel: lifecycleStatusLabel(item.status), number: item.pull_request?.number, additions: item.pull_request?.additions, deletions: item.pull_request?.deletions, @@ -72,8 +81,10 @@ export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem { return { id: summary.run_id, repo: summary.repository.name, - title: summary.title, + title: displayRunTitle(summary.title), workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown", + lifecycleStatus: summary.status, + lifecycleStatusLabel: lifecycleStatusLabel(summary.status), elapsed: summary.elapsed_secs != null ? formatElapsedSecs(summary.elapsed_secs) @@ -83,7 +94,7 @@ export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem { }; } -export function columnForStatus(status: string | null | undefined): ColumnStatus { +export function columnForStatus(status: string | null | undefined): ColumnStatus | null { switch (status) { case "submitted": case "starting": @@ -96,9 +107,10 @@ export function columnForStatus(status: string | null | undefined): ColumnStatus return "succeeded"; case "failed": case "dead": + return "failed"; case "removing": default: - return "failed"; + return null; } } @@ -143,6 +155,11 @@ export function isRunStatus(s: string): s is RunStatus { return knownRunStatuses.has(s); } +function lifecycleStatusLabel(status: string | null | undefined): string | undefined { + if (!status) return undefined; + return isRunStatus(status) ? runStatusDisplay[status].label : status; +} + /** Graph control nodes hidden from stage lists in the UI. */ const hiddenStageIds = new Set(["start", "exit"]); diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index acbc3fc6c..ac2d6a3e9 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -103,7 +103,7 @@ export default function RunDetail({ loaderData, params }: any) { {run.repo} {run.elapsed && ( - {run.elapsed} + {run.elapsed} )} diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index dc55e3395..b4f53c27e 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -18,9 +18,9 @@ import { arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { ciConfig, statusColors, deriveCiStatus, mapRunListItem } from "../data/runs"; +import { ciConfig, columnNames, statusColors, deriveCiStatus, mapRunListItem } from "../data/runs"; import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs"; -import { apiJson } from "../api"; +import { apiPaginatedJson } from "../api"; import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client"; export function meta({}: any) { @@ -51,7 +51,10 @@ interface BoardRunsResponse { } export async function loader({ request }: any) { - const response = await apiJson("/boards/runs", { request }); + const response = await apiPaginatedJson< + PaginatedBoardRunList["data"][number], + { columns: BoardRunsResponse["columns"] } + >("/boards/runs", { request }); const apiRuns = response.data; const grouped = new Map(); @@ -74,6 +77,21 @@ export async function loader({ request }: any) { return { columns }; } +function boardLifecycleStatusLabel(run: Pick): string | null { + if (run.lifecycleStatusLabel == null) return null; + if (run.column != null && columnNames[run.column] === run.lifecycleStatusLabel) { + return null; + } + return run.lifecycleStatusLabel; +} + +function listLifecycleStatusLabel(run: Pick): string | null { + if (run.lifecycleStatusLabel == null || run.lifecycleStatusLabel === run.statusLabel) { + return null; + } + return run.lifecycleStatusLabel; +} + function GitBranchIcon({ className }: { className?: string }) { return ( @@ -251,6 +269,8 @@ function PrCard({ iconColor: string; actions?: string[]; }) { + const lifecycleLabel = boardLifecycleStatusLabel(pr); + return (
@@ -263,6 +283,11 @@ function PrCard({ #{pr.number} )} + {lifecycleLabel != null && ( + + {lifecycleLabel} + + )}

{pr.title}

@@ -291,7 +316,7 @@ function PrCard({ )} {pr.elapsed != null && ( - {pr.elapsed} + {pr.elapsed} )} )} @@ -437,15 +462,22 @@ function BoardColumn({ column }: { column: Column }) { type ViewMode = "columns" | "list"; function RunRow({ run }: { run: RunWithStatus }) { + const lifecycleLabel = listLifecycleStatusLabel(run); + return ( - + {run.elapsed} {run.repo} {run.title} + {lifecycleLabel != null && ( + + {lifecycleLabel} + + )} {run.comments != null && run.comments > 0 && ( {run.statusLabel} - + {run.elapsed} diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index f78bbf048..31ea52368 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -3060,7 +3060,9 @@ components: status: type: ["string", "null"] status_reason: - type: ["string", "null"] + oneOf: + - $ref: "#/components/schemas/StatusReason" + - type: "null" pending_control: oneOf: - $ref: "#/components/schemas/RunControlAction" diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 5e9b3fd69..65fa4f781 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -669,6 +669,13 @@ mod runs { total_usd_micros: Option, entries: &[(&str, &str)], ) -> StoreRunSummary { + let status_reason = match status_reason { + Some("completed") => Some(StatusReason::Completed), + Some("workflow_error") => Some(StatusReason::WorkflowError), + Some(other) => panic!("unsupported demo status_reason: {other}"), + None => None, + }; + StoreRunSummary { created_at: ts(created_at), duration_ms: elapsed_secs.map(|secs| (secs * 1000.0).round() as i64), @@ -683,7 +690,7 @@ mod runs { run_id: run_id.into(), start_time: Some(ts(created_at)), status: status.map(str::to_string), - status_reason: status_reason.map(str::to_string), + status_reason, title: goal.into(), total_usd_micros, workflow_name: Some(workflow_name.into()), @@ -707,11 +714,6 @@ mod runs { sandbox: Option, question: Option, ) -> RunListItem { - let status_reason = summary - .status_reason - .as_deref() - .and_then(|reason| StatusReason::try_from(reason).ok()); - RunListItem { column, created_at: summary.created_at, @@ -728,7 +730,7 @@ mod runs { sandbox, start_time: summary.start_time, status: summary.status.unwrap_or_default(), - status_reason, + status_reason: summary.status_reason, title: summary.title, total_usd_micros: summary.total_usd_micros, workflow_name: summary.workflow_name, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index a11465725..603380d4b 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2629,6 +2629,50 @@ fn summary_to_api_run_summary(summary: fabro_store::RunSummary) -> serde_json::V }) } +async fn board_run_metadata( + state: &AppState, + run_id: RunId, +) -> serde_json::Map { + let mut metadata = serde_json::Map::new(); + let Ok(run_store) = state.store.open_run_reader(&run_id).await else { + return metadata; + }; + let Ok(run_state) = run_store.state().await else { + return metadata; + }; + + if let Some(pull_request) = run_state.pull_request { + metadata.insert( + "pull_request".to_string(), + serde_json::json!({ + "number": pull_request.number, + }), + ); + } + + if let Some(sandbox) = run_state.sandbox { + if let Some(identifier) = sandbox.identifier { + metadata.insert( + "sandbox".to_string(), + serde_json::json!({ + "id": identifier, + }), + ); + } + } + + if let Some(question) = run_state.pending_interviews.values().next() { + metadata.insert( + "question".to_string(), + serde_json::json!({ + "text": question.question.text, + }), + ); + } + + metadata +} + fn paginate_items(items: Vec, pagination: PaginationParams) -> (Vec, bool) { let limit = pagination.limit.clamp(1, 100) as usize; let offset = pagination.offset as usize; @@ -2654,16 +2698,22 @@ async fn list_board_runs( .into_response(); } }; - let all_items: Vec = summaries - .into_iter() - .filter_map(|summary| { - let status = summary.status?; - let column = board_column(status)?; - let mut item = summary_to_api_run_summary(summary); - item["column"] = serde_json::json!(column); - Some(item) - }) - .collect(); + let mut all_items = Vec::new(); + for summary in summaries { + let Some(status) = summary.status else { + continue; + }; + let Some(column) = board_column(status) else { + continue; + }; + let run_id = summary.run_id; + let mut item = summary_to_api_run_summary(summary); + item["column"] = serde_json::json!(column); + if let Some(object) = item.as_object_mut() { + object.extend(board_run_metadata(state.as_ref(), run_id).await); + } + all_items.push(item); + } let (data, has_more) = paginate_items(all_items, pagination); ( StatusCode::OK, @@ -4619,7 +4669,7 @@ async fn get_run_status( .await { Ok(runs) => match runs.into_iter().find(|run| run.run_id == id) { - Some(run) => (StatusCode::OK, Json(run)).into_response(), + Some(run) => (StatusCode::OK, Json(summary_to_api_run_summary(run))).into_response(), None => ApiError::not_found("Run not found.").into_response(), }, Err(err) => { @@ -7375,6 +7425,11 @@ slug = "fabro" let body = body_json(response.into_body()).await; assert_eq!(body["run_id"].as_str().unwrap(), run_id); + assert_eq!(body["goal"].as_str().unwrap(), "Test"); + assert_eq!(body["title"].as_str().unwrap(), "Test"); + assert!(body["repository"].is_object()); + assert!(!body["repository"]["name"].as_str().unwrap().is_empty()); + assert!(body["created_at"].is_string()); assert!(body["labels"].is_object()); } @@ -9350,6 +9405,69 @@ timeout = "30s" ); } + #[tokio::test] + async fn boards_runs_includes_live_board_metadata_from_run_state() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let run_id = create_and_start_run(&app, MINIMAL_DOT) + .await + .parse::() + .unwrap(); + let run_store = state.store.open_run(&run_id).await.unwrap(); + for event in [ + workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::SandboxInitialized { + provider: "local".to_string(), + working_directory: "/sandbox/workdir".to_string(), + identifier: Some("sb-test".to_string()), + host_working_directory: Some("/tmp/repo".to_string()), + container_mount_point: None, + }, + workflow_event::Event::PullRequestCreated { + pr_url: "https://github.com/acme/repo/pull/42".to_string(), + pr_number: 42, + owner: "acme".to_string(), + repo: "repo".to_string(), + base_branch: "main".to_string(), + head_branch: "fabro/run".to_string(), + title: "Fix board metadata".to_string(), + draft: false, + }, + workflow_event::Event::InterviewStarted { + question_id: "q-1".to_string(), + question: "Ship it?".to_string(), + stage: "review".to_string(), + question_type: "yes_no".to_string(), + options: vec![], + allow_freeform: false, + timeout_seconds: None, + context_display: None, + }, + ] { + workflow_event::append_event(&run_store, &run_id, &event) + .await + .unwrap(); + } + + let req = Request::builder() + .method("GET") + .uri(api("/boards/runs")) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + let data = body["data"].as_array().expect("data should be array"); + let item = data + .iter() + .find(|i| i["run_id"].as_str() == Some(&run_id.to_string())) + .expect("run should be in board"); + + assert_eq!(item["pull_request"]["number"].as_u64(), Some(42)); + assert_eq!(item["sandbox"]["id"].as_str(), Some("sb-test")); + assert_eq!(item["question"]["text"].as_str(), Some("Ship it?")); + } + #[test] fn validate_github_slug_accepts_real_names() { assert!(super::validate_github_slug("owner", "anthropic", 39).is_ok()); diff --git a/lib/packages/fabro-api-client/src/models/store-run-summary.ts b/lib/packages/fabro-api-client/src/models/store-run-summary.ts index 00aa48e92..da71ae481 100644 --- a/lib/packages/fabro-api-client/src/models/store-run-summary.ts +++ b/lib/packages/fabro-api-client/src/models/store-run-summary.ts @@ -19,6 +19,9 @@ import type { RepositoryReference } from './repository-reference'; // May contain unused imports in some cases // @ts-ignore import type { RunControlAction } from './run-control-action'; +// May contain unused imports in some cases +// @ts-ignore +import type { StatusReason } from './status-reason'; /** * Durable run summary derived from the backing store. @@ -35,7 +38,7 @@ export interface StoreRunSummary { 'start_time'?: string | null; 'created_at': string; 'status'?: string | null; - 'status_reason'?: string | null; + 'status_reason'?: StatusReason | null; 'pending_control'?: RunControlAction | null; 'duration_ms'?: number | null; 'elapsed_secs'?: number | null; From ec239aaf9c18952e303d9bdd4999ae2e9a0ef221 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 11:18:34 -0400 Subject: [PATCH 09/12] fix(cli): update install test for listener tls removal --- lib/crates/fabro-cli/src/commands/install.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 25fe3d6be..a5615dd58 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -2268,14 +2268,13 @@ mod tests { .and_then(|s| s.listen.as_ref()) .expect("server.listen should be set"); match listen { - ServerListenLayer::Tcp { address, tls } => { + ServerListenLayer::Tcp { address } => { assert_eq!( address .as_ref() .map(fabro_types::settings::InterpString::as_source), Some("127.0.0.1:32276".to_string()) ); - assert!(tls.is_none()); } ServerListenLayer::Unix { .. } => panic!("expected tcp listen"), } From b5bb134890fe4321e3e116afc611de753bf5bb7d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 11:35:30 -0400 Subject: [PATCH 10/12] fix(runs): bound board enrichment and demo normalization Paginate board-eligible summaries before enriching them from run state, add safety caps to paginated web fetches, and make demo run summaries follow the production title and status-reason normalization rules. --- apps/fabro-web/app/api.test.ts | 38 ++++++++++ apps/fabro-web/app/api.ts | 23 +++++- lib/crates/fabro-server/src/demo/mod.rs | 93 +++++++++++++++++++++++-- lib/crates/fabro-server/src/server.rs | 80 ++++++++++++++++++--- 4 files changed, 215 insertions(+), 19 deletions(-) diff --git a/apps/fabro-web/app/api.test.ts b/apps/fabro-web/app/api.test.ts index 5201fbcc7..52824a779 100644 --- a/apps/fabro-web/app/api.test.ts +++ b/apps/fabro-web/app/api.test.ts @@ -122,4 +122,42 @@ describe("apiPaginatedJson", () => { }, ); }); + + test("stops after a bounded number of pages when the server keeps advertising more data", async () => { + const warnMock = mock(() => {}); + const originalWarn = console.warn; + console.warn = warnMock; + + let callCount = 0; + const fetchMock = mock(() => { + callCount += 1; + if (callCount > 50) { + throw new Error("apiPaginatedJson should have stopped at the page cap"); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ id: `run-${callCount}` }], + meta: { has_more: true }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + }); + globalThis.fetch = fetchMock as typeof fetch; + + try { + const result = await apiPaginatedJson<{ id: string }>("/boards/runs"); + + expect(result.data).toHaveLength(50); + expect(result.meta).toEqual({ has_more: true }); + expect(warnMock).toHaveBeenCalledTimes(1); + } finally { + console.warn = originalWarn; + } + }); }); diff --git a/apps/fabro-web/app/api.ts b/apps/fabro-web/app/api.ts index da43e2c09..96aa1d72c 100644 --- a/apps/fabro-web/app/api.ts +++ b/apps/fabro-web/app/api.ts @@ -8,6 +8,9 @@ export interface PaginatedEnvelope { meta: { has_more: boolean }; } +const PAGINATED_API_MAX_PAGES = 50; +const PAGINATED_API_MAX_ITEMS = 5000; + function buildApiPath(path: string): string { return `/api/v1${path}`; } @@ -51,6 +54,7 @@ export async function apiPaginatedJson( let offset = 0; const data: TItem[] = []; let extras: TExtra | null = null; + let pagesLoaded = 0; while (true) { const response = await fetch(buildPaginatedApiPath(path, limit, offset), { @@ -74,7 +78,10 @@ export async function apiPaginatedJson( extras = rest as TExtra; } - data.push(...page.data); + pagesLoaded += 1; + const remainingItemBudget = PAGINATED_API_MAX_ITEMS - data.length; + const pageItems = remainingItemBudget > 0 ? page.data.slice(0, remainingItemBudget) : []; + data.push(...pageItems); if (!page.meta.has_more || page.data.length === 0) { return { ...(extras ?? ({} as TExtra)), @@ -82,6 +89,20 @@ export async function apiPaginatedJson( meta: { has_more: false }, }; } + if ( + pagesLoaded >= PAGINATED_API_MAX_PAGES || + pageItems.length < page.data.length || + data.length >= PAGINATED_API_MAX_ITEMS + ) { + console.warn( + `Stopped paginated API fetch for ${path} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`, + ); + return { + ...(extras ?? ({} as TExtra)), + data, + meta: { has_more: true }, + }; + } offset += page.data.length; } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 65fa4f781..5bd058a0d 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -647,6 +647,7 @@ mod runs { use fabro_api::types::*; use super::ts; + use crate::server::truncate_goal; fn labels(entries: &[(&str, &str)]) -> HashMap { entries @@ -669,12 +670,7 @@ mod runs { total_usd_micros: Option, entries: &[(&str, &str)], ) -> StoreRunSummary { - let status_reason = match status_reason { - Some("completed") => Some(StatusReason::Completed), - Some("workflow_error") => Some(StatusReason::WorkflowError), - Some(other) => panic!("unsupported demo status_reason: {other}"), - None => None, - }; + let status_reason = status_reason.and_then(parse_status_reason); StoreRunSummary { created_at: ts(created_at), @@ -691,13 +687,30 @@ mod runs { start_time: Some(ts(created_at)), status: status.map(str::to_string), status_reason, - title: goal.into(), + title: truncate_goal(goal), total_usd_micros, workflow_name: Some(workflow_name.into()), workflow_slug: Some(workflow_slug.into()), } } + fn parse_status_reason(reason: &str) -> Option { + match reason { + "completed" => Some(StatusReason::Completed), + "partial_success" => Some(StatusReason::PartialSuccess), + "workflow_error" => Some(StatusReason::WorkflowError), + "cancelled" => Some(StatusReason::Cancelled), + "terminated" => Some(StatusReason::Terminated), + "transient_infra" => Some(StatusReason::TransientInfra), + "budget_exhausted" => Some(StatusReason::BudgetExhausted), + "launch_failed" => Some(StatusReason::LaunchFailed), + "bootstrap_failed" => Some(StatusReason::BootstrapFailed), + "sandbox_init_failed" => Some(StatusReason::SandboxInitFailed), + "sandbox_initializing" => Some(StatusReason::SandboxInitializing), + _ => None, + } + } + fn take_summary( summaries: &mut HashMap, run_id: &str, @@ -951,6 +964,72 @@ mod runs { ] } + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn summary_parses_known_status_reason_values() { + let summary = summary( + "run-test", + "demo-repo", + "implement", + "Implement", + "Goal", + Some("failed"), + "2026-03-06T14:30:00Z", + Some(1.0), + Some("cancelled"), + None, + None, + &[], + ); + + assert_eq!(summary.status_reason, Some(StatusReason::Cancelled)); + } + + #[test] + fn summary_ignores_unknown_status_reason() { + let summary = summary( + "run-test", + "demo-repo", + "implement", + "Implement", + "Goal", + Some("failed"), + "2026-03-06T14:30:00Z", + Some(1.0), + Some("unexpected_reason"), + None, + None, + &[], + ); + + assert_eq!(summary.status_reason, None); + } + + #[test] + fn summary_derives_title_like_server() { + let goal = format!("## Plan: {}", "a".repeat(120)); + let summary = summary( + "run-test", + "demo-repo", + "implement", + "Implement", + &goal, + Some("running"), + "2026-03-06T14:30:00Z", + Some(1.0), + None, + None, + None, + &[], + ); + + assert_eq!(summary.title, format!("{}...", "a".repeat(97))); + } + } + pub(super) fn stages() -> Vec { vec![ RunStage { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 603380d4b..09393a7a0 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2579,7 +2579,7 @@ fn board_columns() -> serde_json::Value { ]) } -fn truncate_goal(goal: &str) -> String { +pub(crate) fn truncate_goal(goal: &str) -> String { const MAX_LEN: usize = 100; let stripped = strip_goal_decoration(goal); @@ -2698,23 +2698,26 @@ async fn list_board_runs( .into_response(); } }; - let mut all_items = Vec::new(); - for summary in summaries { - let Some(status) = summary.status else { - continue; - }; - let Some(column) = board_column(status) else { - continue; - }; + let board_summaries: Vec<_> = summaries + .into_iter() + .filter_map(|summary| { + let status = summary.status?; + let column = board_column(status)?; + Some((summary, column)) + }) + .collect(); + let (page_summaries, has_more) = paginate_items(board_summaries, pagination); + + let mut data = Vec::with_capacity(page_summaries.len()); + for (summary, column) in page_summaries { let run_id = summary.run_id; let mut item = summary_to_api_run_summary(summary); item["column"] = serde_json::json!(column); if let Some(object) = item.as_object_mut() { object.extend(board_run_metadata(state.as_ref(), run_id).await); } - all_items.push(item); + data.push(item); } - let (data, has_more) = paginate_items(all_items, pagination); ( StatusCode::OK, Json(serde_json::json!({ @@ -9468,6 +9471,61 @@ timeout = "30s" assert_eq!(item["question"]["text"].as_str(), Some("Ship it?")); } + #[tokio::test] + async fn boards_runs_page_limit_preserves_metadata_for_paged_items() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + + let first_run_id = create_and_start_run(&app, MINIMAL_DOT) + .await + .parse::() + .unwrap(); + let second_run_id = create_and_start_run(&app, MINIMAL_DOT) + .await + .parse::() + .unwrap(); + + for (run_id, sandbox_id) in [ + (first_run_id, "sb-first"), + (second_run_id, "sb-second"), + ] { + let run_store = state.store.open_run(&run_id).await.unwrap(); + for event in [ + workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::SandboxInitialized { + provider: "local".to_string(), + working_directory: "/sandbox/workdir".to_string(), + identifier: Some(sandbox_id.to_string()), + host_working_directory: Some("/tmp/repo".to_string()), + container_mount_point: None, + }, + ] { + workflow_event::append_event(&run_store, &run_id, &event) + .await + .unwrap(); + } + } + + let req = Request::builder() + .method("GET") + .uri(api("/boards/runs?page[limit]=1")) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + assert_eq!(body["meta"]["has_more"].as_bool(), Some(true)); + + let data = body["data"].as_array().expect("data should be array"); + assert_eq!(data.len(), 1); + + let item = &data[0]; + let sandbox_id = item["sandbox"]["id"] + .as_str() + .expect("paged item should still include sandbox metadata"); + assert!(matches!(sandbox_id, "sb-first" | "sb-second")); + } + #[test] fn validate_github_slug_accepts_real_names() { assert!(super::validate_github_slug("owner", "anthropic", 39).is_ok()); From ba3e760313f24e96fbae63a21d42b1888b2b68db Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 11:38:52 -0400 Subject: [PATCH 11/12] fix(runs): use generated demo status reason parser --- lib/crates/fabro-server/src/demo/mod.rs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 5bd058a0d..609eafe07 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -643,6 +643,7 @@ fn ts(s: &str) -> DateTime { mod runs { use std::collections::HashMap; + use std::str::FromStr; use fabro_api::types::*; @@ -695,20 +696,7 @@ mod runs { } fn parse_status_reason(reason: &str) -> Option { - match reason { - "completed" => Some(StatusReason::Completed), - "partial_success" => Some(StatusReason::PartialSuccess), - "workflow_error" => Some(StatusReason::WorkflowError), - "cancelled" => Some(StatusReason::Cancelled), - "terminated" => Some(StatusReason::Terminated), - "transient_infra" => Some(StatusReason::TransientInfra), - "budget_exhausted" => Some(StatusReason::BudgetExhausted), - "launch_failed" => Some(StatusReason::LaunchFailed), - "bootstrap_failed" => Some(StatusReason::BootstrapFailed), - "sandbox_init_failed" => Some(StatusReason::SandboxInitFailed), - "sandbox_initializing" => Some(StatusReason::SandboxInitializing), - _ => None, - } + StatusReason::from_str(reason).ok() } fn take_summary( From 1e6543528f30d470853b4bf11dd86fa6a4bb3918 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 12:02:19 -0400 Subject: [PATCH 12/12] fix(server): satisfy workspace clippy --- lib/crates/fabro-server/src/demo/mod.rs | 140 ++++++++++++------------ lib/crates/fabro-server/src/server.rs | 6 +- 2 files changed, 76 insertions(+), 70 deletions(-) diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 609eafe07..3905f02f8 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -644,6 +644,7 @@ fn ts(s: &str) -> DateTime { mod runs { use std::collections::HashMap; use std::str::FromStr; + use std::time::Duration; use fabro_api::types::*; @@ -675,7 +676,7 @@ mod runs { StoreRunSummary { created_at: ts(created_at), - duration_ms: elapsed_secs.map(|secs| (secs * 1000.0).round() as i64), + duration_ms: elapsed_secs.and_then(duration_ms_from_secs), elapsed_secs, goal: goal.into(), host_repo_path: Some(format!("/demo/{repo_name}")), @@ -699,6 +700,11 @@ mod runs { StatusReason::from_str(reason).ok() } + fn duration_ms_from_secs(secs: f64) -> Option { + let duration = Duration::try_from_secs_f64(secs).ok()?; + duration.as_millis().try_into().ok() + } + fn take_summary( summaries: &mut HashMap, run_id: &str, @@ -952,72 +958,6 @@ mod runs { ] } - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn summary_parses_known_status_reason_values() { - let summary = summary( - "run-test", - "demo-repo", - "implement", - "Implement", - "Goal", - Some("failed"), - "2026-03-06T14:30:00Z", - Some(1.0), - Some("cancelled"), - None, - None, - &[], - ); - - assert_eq!(summary.status_reason, Some(StatusReason::Cancelled)); - } - - #[test] - fn summary_ignores_unknown_status_reason() { - let summary = summary( - "run-test", - "demo-repo", - "implement", - "Implement", - "Goal", - Some("failed"), - "2026-03-06T14:30:00Z", - Some(1.0), - Some("unexpected_reason"), - None, - None, - &[], - ); - - assert_eq!(summary.status_reason, None); - } - - #[test] - fn summary_derives_title_like_server() { - let goal = format!("## Plan: {}", "a".repeat(120)); - let summary = summary( - "run-test", - "demo-repo", - "implement", - "Implement", - &goal, - Some("running"), - "2026-03-06T14:30:00Z", - Some(1.0), - None, - None, - None, - &[], - ); - - assert_eq!(summary.title, format!("{}...", "a".repeat(97))); - } - } - pub(super) fn stages() -> Vec { vec![ RunStage { @@ -1284,6 +1224,72 @@ mod runs { } }) } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn summary_parses_known_status_reason_values() { + let summary = summary( + "run-test", + "demo-repo", + "implement", + "Implement", + "Goal", + Some("failed"), + "2026-03-06T14:30:00Z", + Some(1.0), + Some("cancelled"), + None, + None, + &[], + ); + + assert_eq!(summary.status_reason, Some(StatusReason::Cancelled)); + } + + #[test] + fn summary_ignores_unknown_status_reason() { + let summary = summary( + "run-test", + "demo-repo", + "implement", + "Implement", + "Goal", + Some("failed"), + "2026-03-06T14:30:00Z", + Some(1.0), + Some("unexpected_reason"), + None, + None, + &[], + ); + + assert_eq!(summary.status_reason, None); + } + + #[test] + fn summary_derives_title_like_server() { + let goal = format!("## Plan: {}", "a".repeat(120)); + let summary = summary( + "run-test", + "demo-repo", + "implement", + "Implement", + &goal, + Some("running"), + "2026-03-06T14:30:00Z", + Some(1.0), + None, + None, + None, + &[], + ); + + assert_eq!(summary.title, format!("{}...", "a".repeat(97))); + } + } } mod billing { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 807943106..163dcfdb6 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2685,7 +2685,7 @@ async fn board_run_metadata( metadata } -fn paginate_items(items: Vec, pagination: PaginationParams) -> (Vec, bool) { +fn paginate_items(items: Vec, pagination: &PaginationParams) -> (Vec, bool) { let limit = pagination.limit.clamp(1, 100) as usize; let offset = pagination.offset as usize; let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect(); @@ -2718,7 +2718,7 @@ async fn list_board_runs( Some((summary, column)) }) .collect(); - let (page_summaries, has_more) = paginate_items(board_summaries, pagination); + let (page_summaries, has_more) = paginate_items(board_summaries, &pagination); let mut data = Vec::with_capacity(page_summaries.len()); for (summary, column) in page_summaries { @@ -2756,7 +2756,7 @@ async fn list_runs( .into_iter() .map(summary_to_api_run_summary) .collect::>(); - let (data, has_more) = paginate_items(items, pagination); + let (data, has_more) = paginate_items(items, &pagination); ( StatusCode::OK, Json(serde_json::json!({