Merge remote-tracking branch 'origin/main'

# Conflicts:
#	lib/crates/fabro-server/tests/it/api/mod.rs
This commit is contained in:
Bryan Helmkamp 2026-04-19 13:36:54 -04:00
commit dd4e467bfc
No known key found for this signature in database
70 changed files with 1785 additions and 1345 deletions

View file

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

80
Cargo.lock generated
View file

@ -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",
@ -1774,7 +1774,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"async-trait",
"dialoguer",
@ -1788,7 +1788,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"anyhow",
"async-trait",
@ -1817,7 +1817,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"proc-macro2",
"quote",
@ -1826,7 +1826,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"anyhow",
"fabro-config",
@ -1842,7 +1842,7 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"insta",
"serde",
@ -1851,7 +1851,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"axum",
"base64",
@ -1869,7 +1869,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"cc",
"libc",
@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "fabro-retro"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"anyhow",
"chrono",
@ -1896,7 +1896,7 @@ dependencies = [
[[package]]
name = "fabro-sandbox"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"anyhow",
"async-trait",
@ -1927,7 +1927,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"anyhow",
"axum",
@ -1966,8 +1966,6 @@ dependencies = [
"hmac",
"http-body-util",
"httpmock",
"hyper",
"hyper-util",
"ipnet",
"jsonwebtoken",
"mime_guess",
@ -1975,9 +1973,6 @@ dependencies = [
"object_store",
"rand 0.9.4",
"regex",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"semver",
"serde",
"serde_json",
@ -1986,23 +1981,20 @@ 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]]
name = "fabro-slack"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"fabro-http",
"fabro-interview",
@ -2021,14 +2013,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",
@ -2050,7 +2042,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"anyhow",
"base64",
@ -2075,7 +2067,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"anyhow",
"fabro-util",
@ -2087,7 +2079,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"assert_cmd",
"axum",
@ -2108,7 +2100,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"async-trait",
"fabro-github",
@ -2121,7 +2113,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"chrono",
"clap",
@ -2141,7 +2133,7 @@ dependencies = [
[[package]]
name = "fabro-util"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"aho-corasick",
"anyhow",
@ -2162,7 +2154,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"fabro-graphviz",
"fabro-model",
@ -2172,7 +2164,7 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"chrono",
"serde",
@ -2183,7 +2175,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"anyhow",
"assert_cmd",
@ -6773,7 +6765,7 @@ dependencies = [
[[package]]
name = "twin-github"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"axum",
"base64",
@ -6791,7 +6783,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.208.0-nightly.0"
version = "0.208.0-nightly.1"
dependencies = [
"anyhow",
"async-stream",

View file

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

View file

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

View file

@ -3,9 +3,28 @@ export interface ApiOptions {
request?: Request;
}
export interface PaginatedEnvelope<T> {
data: T[];
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}`;
}
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<Response> {
const { init } = options ?? {};
const response = await fetch(`/api/v1${path}`, {
const response = await fetch(buildApiPath(path), {
...init,
credentials: "include",
headers: init?.headers,
@ -27,6 +46,68 @@ export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T>
return response.json() as Promise<T>;
}
export async function apiPaginatedJson<TItem, TExtra extends object = {}>(
path: string,
options?: ApiOptions,
): Promise<PaginatedEnvelope<TItem> & TExtra> {
const limit = 100;
let offset = 0;
const data: TItem[] = [];
let extras: TExtra | null = null;
let pagesLoaded = 0;
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<TItem> & TExtra;
if (extras == null) {
const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope<TItem> &
Record<string, unknown>;
extras = rest as TExtra;
}
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)),
data,
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;
}
}
export function isNotAvailable(status: number): boolean {
return status === 404 || status === 501;
}
@ -49,7 +130,7 @@ export async function apiJsonOrNull<T>(
}
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 +161,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" });
}

View file

@ -1,18 +1,78 @@
import { describe, expect, test } from "bun:test";
import { mapRunSummaryToRunItem } from "./runs";
import { columnForStatus, 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",
status: "running",
repository: { name: "myrepo" },
status: "paused",
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();
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");
});
});
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,
@ -23,19 +83,24 @@ 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", () => {
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,
@ -47,3 +112,9 @@ describe("mapRunSummaryToRunItem", () => {
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();
});
});

View file

@ -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";
@ -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;
@ -29,13 +31,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<ColumnStatus, string> = {
working: "Working",
initializing: "Initializing",
review: "Verify",
merge: "Merge",
running: "Running",
waiting: "Waiting",
succeeded: "Succeeded",
@ -47,17 +46,19 @@ 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;
function displayRunTitle(title: string | null | undefined): string {
return title?.trim() ? title : "Untitled run";
}
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: 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,
@ -66,8 +67,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 +75,45 @@ 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<string, string>;
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: displayRunTitle(summary.title),
workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown",
lifecycleStatus: summary.status,
lifecycleStatusLabel: lifecycleStatusLabel(summary.status),
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 | null {
switch (status) {
case "submitted":
case "starting":
return "initializing";
case "running":
return "running";
case "paused":
return "waiting";
case "succeeded":
return "succeeded";
case "failed":
case "dead":
return "failed";
case "removing":
default:
return null;
}
}
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 +121,7 @@ export function deriveCiStatus(checks: CheckRun[]): CiStatus {
}
export const statusColors: Record<ColumnStatus, { dot: string; text: string }> = {
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" },
@ -149,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"]);

View file

@ -103,7 +103,7 @@ export default function RunDetail({ loaderData, params }: any) {
</span>
<span className="font-mono text-xs text-fg-muted">{run.repo}</span>
{run.elapsed && (
<span className={`font-mono text-xs ${run.elapsedWarning ? "text-amber" : "text-fg-muted"}`}>{run.elapsed}</span>
<span className="font-mono text-xs text-fg-muted">{run.elapsed}</span>
)}
</div>
</div>

View file

@ -18,10 +18,10 @@ 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 type { PaginatedRunList } from "@qltysh/fabro-api-client";
import { apiPaginatedJson } from "../api";
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<string, ColumnStyle> = {
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,12 +46,15 @@ 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) {
const response = await apiJson<BoardRunsResponse>("/boards/runs", { request });
const response = await apiPaginatedJson<
PaginatedBoardRunList["data"][number],
{ columns: BoardRunsResponse["columns"] }
>("/boards/runs", { request });
const apiRuns = response.data;
const grouped = new Map<string, RunItem[]>();
@ -62,8 +62,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));
}
}
@ -77,6 +77,21 @@ export async function loader({ request }: any) {
return { columns };
}
function boardLifecycleStatusLabel(run: Pick<RunItem, "column" | "lifecycleStatusLabel">): 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<RunWithStatus, "statusLabel" | "lifecycleStatusLabel">): string | null {
if (run.lifecycleStatusLabel == null || run.lifecycleStatusLabel === run.statusLabel) {
return null;
}
return run.lifecycleStatusLabel;
}
function GitBranchIcon({ className }: { className?: string }) {
return (
@ -254,6 +269,8 @@ function PrCard({
iconColor: string;
actions?: string[];
}) {
const lifecycleLabel = boardLifecycleStatusLabel(pr);
return (
<Link to={`/runs/${pr.id}`} className="group block rounded-md border border-line bg-panel/80 p-4 transition-all duration-200 hover:border-line-strong hover:bg-panel hover:shadow-lg hover:shadow-black/20">
<div className="mb-2 flex items-center gap-1.5">
@ -266,6 +283,11 @@ function PrCard({
#{pr.number}
</span>
)}
{lifecycleLabel != null && (
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-fg-muted">
{lifecycleLabel}
</span>
)}
</div>
<p className="text-sm leading-snug text-fg-2">{pr.title}</p>
@ -294,7 +316,7 @@ function PrCard({
</span>
)}
{pr.elapsed != null && (
<span className={`ml-auto font-mono ${pr.elapsedWarning ? "text-amber" : "text-fg-muted"}`}>{pr.elapsed}</span>
<span className="ml-auto font-mono text-fg-muted">{pr.elapsed}</span>
)}
</div>
)}
@ -440,15 +462,22 @@ function BoardColumn({ column }: { column: Column }) {
type ViewMode = "columns" | "list";
function RunRow({ run }: { run: RunWithStatus }) {
const lifecycleLabel = listLifecycleStatusLabel(run);
return (
<Link to={`/runs/${run.id}`} className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<span className={`font-mono text-xs pr-2 ${run.elapsedWarning ? "text-amber" : "text-fg-muted"}`}>
<span className="font-mono text-xs pr-2 text-fg-muted">
{run.elapsed}
</span>
<span className="flex items-center gap-2 min-w-0">
<span className="font-mono text-xs font-medium text-teal-500">{run.repo}</span>
<span className="truncate text-sm text-fg-2">{run.title}</span>
{lifecycleLabel != null && (
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-fg-muted">
{lifecycleLabel}
</span>
)}
{run.comments != null && run.comments > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-fg-muted">
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
@ -686,6 +715,7 @@ export default function Runs({ loaderData }: any) {
(!query ||
item.title.toLowerCase().includes(lowerQuery) ||
item.repo.toLowerCase().includes(lowerQuery) ||
item.lifecycleStatusLabel?.toLowerCase().includes(lowerQuery) ||
(item.number != null && `#${item.number}`.includes(lowerQuery))),
),
}));

View file

@ -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";
@ -9,11 +9,17 @@ import type { PaginatedRunList } from "@qltysh/fabro-api-client";
export async function loader({ request, params }: any) {
const result = await apiJsonOrNull<PaginatedRunList>(`/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,
}));
const runs: RunWithStatus[] = apiRuns
.map((r) => {
const column = columnForStatus(r.status);
if (column == null) return null;
return {
...mapRunSummaryToRunItem(r),
status: column,
statusLabel: columnNames[column],
};
})
.filter((run): run is RunWithStatus => run != null);
return { runs };
}
@ -34,7 +40,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
<span className={`text-xs font-medium ${colors.text}`}>{run.statusLabel}</span>
</span>
<span className={`font-mono text-xs pr-2 ${run.elapsedWarning ? "text-amber" : "text-fg-muted"}`}>
<span className="font-mono text-xs pr-2 text-fg-muted">
{run.elapsed}
</span>

View file

@ -93,8 +93,9 @@ Once `https://<FABRO_DOMAIN>/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 <url>` 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.
</Card>
<Card title="Server Configuration" icon="gear" href="/administration/server-configuration">
Full `settings.toml` reference — TLS, auth methods, concurrency, and more.
Full `settings.toml` reference — reverse-proxy TLS, auth methods, concurrency, and more.
</Card>
</Columns>

View file

@ -75,8 +75,9 @@ Once the deploy is healthy, Fly exposes a `<app>.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://<your-app>.fly.dev/api/v1"
[cli.target]
type = "http"
url = "https://<your-app>.fly.dev/api/v1"
```
Then commands like `fabro model list --server <url>` will hit your Fly instance.

View file

@ -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://<your-service>.up.railway.app/api/v1"
[cli.target]
type = "http"
url = "https://<your-service>.up.railway.app/api/v1"
```
Then commands like `fabro model list --server <url>` 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.
</Card>
<Card title="Server Configuration" icon="gear" href="/administration/server-configuration">
Full `settings.toml` reference — TLS, auth methods, concurrency, and more.
Full `settings.toml` reference — reverse-proxy TLS, auth methods, concurrency, and more.
</Card>
</Columns>

View file

@ -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://<your-service>.onrender.com/api/v1"
[cli.target]
type = "http"
url = "https://<your-service>.onrender.com/api/v1"
```
Then commands like `fabro model list --server <url>` 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.
</Card>
<Card title="Server Configuration" icon="gear" href="/administration/server-configuration">
Full `settings.toml` reference — TLS, auth methods, concurrency, and more.
Full `settings.toml` reference — reverse-proxy TLS, auth methods, concurrency, and more.
</Card>
</Columns>

View file

@ -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
<Columns cols={2}>
<Card title="Server Configuration" icon="gear" href="/administration/server-configuration">
Full settings.toml reference — authentication, TLS, run defaults, and more.
Full settings.toml reference — authentication, reverse-proxy TLS, run defaults, and more.
</Card>
<Card title="Deploy to Railway" icon="train" href="/administration/deploy-railway">
Step-by-step guide for deploying Fabro on Railway.

View file

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

View file

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

View file

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

View file

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

View file

@ -402,15 +402,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]
@ -706,7 +707,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/PaginatedRunList"
$ref: "#/components/schemas/PaginatedBoardRunList"
/api/v1/runs/{id}/state:
get:
@ -2189,6 +2190,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:
@ -3494,7 +3514,11 @@ components:
type: object
required:
- run_id
- goal
- title
- labels
- repository
- created_at
properties:
run_id:
type: string
@ -3503,20 +3527,29 @@ 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:
type: ["string", "null"]
oneOf:
- $ref: "#/components/schemas/StatusReason"
- type: "null"
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
@ -3525,6 +3558,8 @@ components:
type: ["integer", "null"]
format: int64
minimum: 0
elapsed_secs:
type: ["number", "null"]
total_usd_micros:
type: ["integer", "null"]
format: int64
@ -3535,10 +3570,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.
@ -3833,34 +3880,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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -595,17 +595,35 @@ impl ServerStoreClient {
}
pub(crate) async fn list_store_runs(&self) -> Result<Vec<RunSummary>> {
let response = self
.client
.list_runs()
.send()
.await
.map_err(map_api_error)?;
response
.into_inner()
.into_iter()
.map(convert_type)
.collect::<Result<Vec<_>>>()
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::<Result<Vec<_>>>()?;
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<RunProjection> {

View file

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

View file

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

View file

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

View file

@ -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<ResolveError>) -> 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<ResolveError>,
) -> (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<ResolveError>,
) -> (Option<TlsConfig>, 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");

View file

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

View file

@ -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::<Vec<_>>()
.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"));
}

View file

@ -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::<Vec<_>>()
.join("\n");
assert!(rendered.contains("server.listen.tls.key"));
assert!(err.to_string().contains("unknown field `tls`"));
}
#[test]

View file

@ -48,14 +48,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"

View file

@ -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<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> 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<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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,319 @@ fn ts(s: &str) -> DateTime<Utc> {
}
mod runs {
use std::collections::HashMap;
use std::str::FromStr;
use std::time::Duration;
use fabro_api::types::*;
use super::ts;
use crate::server::truncate_goal;
pub(super) fn list_items() -> Vec<RunListItem> {
fn labels(entries: &[(&str, &str)]) -> HashMap<String, String> {
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<f64>,
status_reason: Option<&str>,
pending_control: Option<RunControlAction>,
total_usd_micros: Option<i64>,
entries: &[(&str, &str)],
) -> StoreRunSummary {
let status_reason = status_reason.and_then(parse_status_reason);
StoreRunSummary {
created_at: ts(created_at),
duration_ms: elapsed_secs.and_then(duration_ms_from_secs),
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,
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<StatusReason> {
StatusReason::from_str(reason).ok()
}
fn duration_ms_from_secs(secs: f64) -> Option<i64> {
let duration = Duration::try_from_secs_f64(secs).ok()?;
duration.as_millis().try_into().ok()
}
fn take_summary(
summaries: &mut HashMap<String, StoreRunSummary>,
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<RunPullRequest>,
sandbox: Option<RunSandbox>,
question: Option<RunQuestion>,
) -> RunListItem {
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: summary.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<f64>) -> 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<CheckRun>,
) -> RunPullRequest {
RunPullRequest {
number,
additions: Some(additions),
deletions: Some(deletions),
comments: Some(comments),
checks,
}
}
pub(super) fn columns() -> Vec<BoardColumnDefinition> {
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<StoreRunSummary> {
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<RunListItem> {
let mut summaries = summaries()
.into_iter()
.map(|summary| (summary.run_id.clone(), summary))
.collect::<HashMap<_, _>>();
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,
),
]
}
@ -1394,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 {

View file

@ -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, String> {
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<String, String> {
let mut reader = std::io::Cursor::new(pem.as_bytes());
let certs: Vec<_> = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.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<String>, label: &str| -> Result<String, String> {
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(),

View file

@ -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<Self> {
pub fn from_cache_dir(cache_dir: &Path) -> Result<Self> {
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;

View file

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

View file

@ -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")
@ -351,7 +349,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)?;
@ -516,12 +514,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 {
@ -564,38 +556,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::<SocketAddr>(),
)
.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::<SocketAddr>(),
)
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()))
.await?;
}
}

View file

@ -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;
@ -2603,6 +2604,109 @@ fn board_columns() -> serde_json::Value {
])
}
pub(crate) 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<u64>) -> Option<f64> {
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,
})
}
async fn board_run_metadata(
state: &AppState,
run_id: RunId,
) -> serde_json::Map<String, serde_json::Value> {
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<T>(items: Vec<T>, pagination: &PaginationParams) -> (Vec<T>, 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<Arc<AppState>>,
@ -2619,37 +2723,26 @@ async fn list_board_runs(
.into_response();
}
};
let all_items: Vec<serde_json::Value> = summaries
let board_summaries: Vec<_> = summaries
.into_iter()
.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 })),
}))
Some((summary, column))
})
.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 (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);
}
data.push(item);
}
(
StatusCode::OK,
Json(serde_json::json!({
@ -2661,13 +2754,31 @@ async fn list_board_runs(
.into_response()
}
async fn list_runs(_auth: AuthenticatedService, State(state): State<Arc<AppState>>) -> Response {
async fn list_runs(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> 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::<Vec<_>>();
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()
}
@ -4586,7 +4697,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) => {
@ -7342,6 +7453,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());
}
@ -8313,7 +8429,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()
@ -8337,10 +8454,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]
@ -8592,7 +8717,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"
@ -8600,8 +8725,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();
@ -8717,9 +8843,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]
@ -9106,11 +9234,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());
}
@ -9180,19 +9311,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]
@ -9224,7 +9357,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");
}
@ -9277,15 +9410,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");
@ -9298,6 +9433,124 @@ 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::<RunId>()
.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?"));
}
#[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::<RunId>()
.unwrap();
let second_run_id = create_and_start_run(&app, MINIMAL_DOT)
.await
.parse::<RunId>()
.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());

View file

@ -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
//!
@ -58,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 address + TLS key/cert paths: host operational details.
server.listen = None;
}
@ -132,10 +130,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 +243,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"]

View file

@ -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<Arc<ServerConfig>> {
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<F>(
listener: TcpListener,
tls_acceptor: tokio_rustls::TlsAcceptor,
router: axum::Router,
shutdown: F,
) -> anyhow::Result<()>
where
F: Future<Output = ()> + 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<Incoming>| {
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<std::path::PathBuf> {
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<CertificateDer<'static>> {
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::<Result<Vec<_>, _>>()
.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()))
}

View file

@ -1,9 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBRjCB+aADAgECAhRKD83+hLEUl2GQUrsSgNaDBjPrpDAFBgMrZXAwETEPMA0G
A1UEAwwGVGVzdENBMB4XDTI2MDQwNTE2MjEzM1oXDTM2MDQwMjE2MjEzM1owETEP
MA0GA1UEAwwGVGVzdENBMCowBQYDK2VwAyEA3vVnIRyxAa9q+qtf0OPWoOUKff1D
Pq5LpXPUTh1nrJejYzBhMB0GA1UdDgQWBBT88UyTLCWai4vJtkS5K0zutivZOTAf
BgNVHSMEGDAWgBT88UyTLCWai4vJtkS5K0zutivZOTAPBgNVHRMBAf8EBTADAQH/
MA4GA1UdDwEB/wQEAwIBBjAFBgMrZXADQQBUmXc96ILueacLnf7kSJS35wiCl044
Js8vwgQuTkJ9SDhuCOt88E4b9vZMhx2kOBLiwTyTdOILhVECPE9FZicD
-----END CERTIFICATE-----

View file

@ -1,9 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBMjCB5aADAgECAhRjMLlP+97gUZFyv5k1WdriOASrLDAFBgMrZXAwETEPMA0G
A1UEAwwGVGVzdENBMB4XDTI2MDQwNTE2MjEzM1oXDTM2MDQwMjE2MjEzM1owEzER
MA8GA1UEAwwIdGVzdHVzZXIwKjAFBgMrZXADIQCYYub304Ilt7lkzkN5plpIlGCo
xR8wL18Xob7/hW2SWqNNMEswCQYDVR0TBAIwADAdBgNVHQ4EFgQUL4ve1GH0sFJ+
33TwQP1R6oactHYwHwYDVR0jBBgwFoAU/PFMkywlmouLybZEuStM7rYr2TkwBQYD
K2VwA0EAEpBsV5kpyuEF3t5GzuxELDJgtVxGLpZD5PsPqj+wxv5j6TeOwCE/LRRV
JsKYJt3SMdqySx84dfscPD9c5HMPCw==
-----END CERTIFICATE-----

View file

@ -1,3 +0,0 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIME1COxOi67I+kdoIH+ms4c0zKA8D7M8SkeJyjC89+pj
-----END PRIVATE KEY-----

View file

@ -1,3 +0,0 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIMr+udNo63lm79G+2xETGqoQsMJUvpbTUFhXdgKNI10C
-----END PRIVATE KEY-----

View file

@ -1,3 +0,0 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA93ZZOd4zYtjwgdzSw+brqyWM9USG5INKCGWUEHRVRBw=
-----END PUBLIC KEY-----

View file

@ -1,9 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBOTCB7KADAgECAhRjMLlP+97gUZFyv5k1WdriOASrKzAFBgMrZXAwETEPMA0G
A1UEAwwGVGVzdENBMB4XDTI2MDQwNTE2MjEzM1oXDTM2MDQwMjE2MjEzM1owFDES
MBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEAn8X6FEFjCq5MKfiSVNKjRY5p
TKdDrASo29olFWz8qy+jUzBRMA8GA1UdEQQIMAaHBH8AAAEwHQYDVR0OBBYEFL7n
tv01dMhzLJ0dzTo7tEAXrYjuMB8GA1UdIwQYMBaAFPzxTJMsJZqLi8m2RLkrTO62
K9k5MAUGAytlcANBAGpMB98RKLprVHiagV1Myj08TK2Lz4+K+Hs2fhUVMgRP9JXV
vf8tC77XV/fH9wIKeaPvsupFO73AdD0BQZb8bQ0=
-----END CERTIFICATE-----

View file

@ -1,3 +0,0 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIAX0EXHZDH5uU2h5ctNqHfa9hMtO9tfoM1kCRL9JHGtA
-----END PRIVATE KEY-----

View file

@ -1,9 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBMzCB5qADAgECAhRT8XV12gL48jHEcgn7qTk7b5ocbzAFBgMrZXAwEjEQMA4G
A1UEAwwHV3JvbmdDQTAeFw0yNjA0MDUxNjIxMzNaFw0zNjA0MDIxNjIxMzNaMBMx
ETAPBgNVBAMMCGludHJ1ZGVyMCowBQYDK2VwAyEADrp6dr+UfNhzR6guiNU5ns0c
Y97Ari4gVZnh8DE1MB6jTTBLMAkGA1UdEwQCMAAwHQYDVR0OBBYEFIF2t8T34ktN
Y276k4702JltR0iEMB8GA1UdIwQYMBaAFNFUQIdydtMb4t9g8+0s9DHh0pYIMAUG
AytlcANBAFO7sA+Po2qFaTRSdpxuAQIbywHiF92uyombcfQkQPgbVbAA3oH9gh32
4uG4c1OCE+w1AI1f2/EpC4zZRPZVAwI=
-----END CERTIFICATE-----

View file

@ -1,3 +0,0 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIAU8EOnIZ26wKCqJ/WTcoCBHETbSYsILQ9zxddB92JVQ
-----END PRIVATE KEY-----

View file

@ -0,0 +1,55 @@
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"
);
}
#[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"
);
}

View file

@ -1,8 +1,8 @@
mod docs;
mod install;
mod install_openai_compatible;
mod routing;
mod runs;
mod settings;
mod system;
#[cfg(target_os = "linux")]
mod tls;
mod tcp;

View file

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

View file

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

View file

@ -0,0 +1,226 @@
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
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_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, ip_allowlist: Arc<IpAllowlistConfig>) -> 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::<SocketAddr>(),
)
.await;
});
addr
}
#[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(
ServerState::new(tempdir.path()).env_path(),
format!("FABRO_DEV_TOKEN={TEST_DEV_TOKEN}\n"),
)
.unwrap();
config_path
}
async fn spawn_served_listener(
settings: impl AsRef<str>,
) -> (JoinHandle<anyhow::Result<()>>, 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_health(client: &fabro_http::HttpClient, url: &str) {
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 health endpoint at {url}");
}
#[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()),
};
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")))
.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, 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();
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 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
[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}"),
};
let client = build_unix_client(&path);
wait_for_health(&client, "http://fabro/health").await;
let response = client
.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();
}
#[tokio::test]
async fn tcp_ip_allowlist_uses_connect_info() {
let addr = start_tcp_server(
AuthMode::Disabled,
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,
}),
)
.await;
let client = fabro_http::test_http_client().unwrap();
let response = client
.get(format!("http://127.0.0.1:{}{}", addr.port(), api("/runs")))
.send()
.await
.unwrap();
assert_eq!(response.status(), 403);
}

View file

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

View file

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

View file

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

View file

@ -37,7 +37,6 @@ pub enum ServerListenSettings {
Tcp {
#[serde(serialize_with = "serialize_socket_addr")]
address: SocketAddr,
tls: Option<TlsConfig>,
},
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<InterpString>,
@ -307,16 +291,13 @@ pub struct ServerLayer {
pub integrations: Option<ServerIntegrationsLayer>,
}
/// `[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<InterpString>,
#[serde(default)]
tls: Option<ServerListenTlsLayer>,
},
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<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<InterpString>,
}
/// `[server.api]` — API surface settings.
///
/// `url` is an optional public URL; it is **not** derived from `server.listen`.

View file

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

View file

@ -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<RequestArgs> => {
listRuns: async (pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/runs`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -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<PaginatedRunList>> {
async listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedBoardRunList>> {
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<Array<StoreRunSummary>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(options);
async listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
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<PaginatedRunList> {
listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedBoardRunList> {
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<Array<StoreRunSummary>> {
return localVarFp.listRuns(options).then((request) => request(axios, basePath));
listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
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));
}
/**

View file

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

View file

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

View file

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

View file

@ -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<BoardColumnDefinition>;
'data': Array<RunListItem>;
'meta': PaginationMeta;
}

View file

@ -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<RunListItem>;
'data': Array<StoreRunSummary>;
'meta': PaginationMeta;
}

View file

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

View file

@ -13,9 +13,15 @@
*/
// 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';
// May contain unused imports in some cases
// @ts-ignore
import type { StatusReason } from './status-reason';
/**
* Durable run summary derived from the backing store.
@ -24,14 +30,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;
'status_reason'?: StatusReason | null;
'pending_control'?: RunControlAction | null;
'duration_ms'?: number | null;
'elapsed_secs'?: number | null;
'total_usd_micros'?: number | null;
}