mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # lib/crates/fabro-server/tests/it/api/mod.rs
This commit is contained in:
commit
dd4e467bfc
70 changed files with 1785 additions and 1345 deletions
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
|
|
@ -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
80
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIBRjCB+aADAgECAhRKD83+hLEUl2GQUrsSgNaDBjPrpDAFBgMrZXAwETEPMA0G
|
||||
A1UEAwwGVGVzdENBMB4XDTI2MDQwNTE2MjEzM1oXDTM2MDQwMjE2MjEzM1owETEP
|
||||
MA0GA1UEAwwGVGVzdENBMCowBQYDK2VwAyEA3vVnIRyxAa9q+qtf0OPWoOUKff1D
|
||||
Pq5LpXPUTh1nrJejYzBhMB0GA1UdDgQWBBT88UyTLCWai4vJtkS5K0zutivZOTAf
|
||||
BgNVHSMEGDAWgBT88UyTLCWai4vJtkS5K0zutivZOTAPBgNVHRMBAf8EBTADAQH/
|
||||
MA4GA1UdDwEB/wQEAwIBBjAFBgMrZXADQQBUmXc96ILueacLnf7kSJS35wiCl044
|
||||
Js8vwgQuTkJ9SDhuCOt88E4b9vZMhx2kOBLiwTyTdOILhVECPE9FZicD
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIBMjCB5aADAgECAhRjMLlP+97gUZFyv5k1WdriOASrLDAFBgMrZXAwETEPMA0G
|
||||
A1UEAwwGVGVzdENBMB4XDTI2MDQwNTE2MjEzM1oXDTM2MDQwMjE2MjEzM1owEzER
|
||||
MA8GA1UEAwwIdGVzdHVzZXIwKjAFBgMrZXADIQCYYub304Ilt7lkzkN5plpIlGCo
|
||||
xR8wL18Xob7/hW2SWqNNMEswCQYDVR0TBAIwADAdBgNVHQ4EFgQUL4ve1GH0sFJ+
|
||||
33TwQP1R6oactHYwHwYDVR0jBBgwFoAU/PFMkywlmouLybZEuStM7rYr2TkwBQYD
|
||||
K2VwA0EAEpBsV5kpyuEF3t5GzuxELDJgtVxGLpZD5PsPqj+wxv5j6TeOwCE/LRRV
|
||||
JsKYJt3SMdqySx84dfscPD9c5HMPCw==
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIME1COxOi67I+kdoIH+ms4c0zKA8D7M8SkeJyjC89+pj
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIMr+udNo63lm79G+2xETGqoQsMJUvpbTUFhXdgKNI10C
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEA93ZZOd4zYtjwgdzSw+brqyWM9USG5INKCGWUEHRVRBw=
|
||||
-----END PUBLIC KEY-----
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIBOTCB7KADAgECAhRjMLlP+97gUZFyv5k1WdriOASrKzAFBgMrZXAwETEPMA0G
|
||||
A1UEAwwGVGVzdENBMB4XDTI2MDQwNTE2MjEzM1oXDTM2MDQwMjE2MjEzM1owFDES
|
||||
MBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEAn8X6FEFjCq5MKfiSVNKjRY5p
|
||||
TKdDrASo29olFWz8qy+jUzBRMA8GA1UdEQQIMAaHBH8AAAEwHQYDVR0OBBYEFL7n
|
||||
tv01dMhzLJ0dzTo7tEAXrYjuMB8GA1UdIwQYMBaAFPzxTJMsJZqLi8m2RLkrTO62
|
||||
K9k5MAUGAytlcANBAGpMB98RKLprVHiagV1Myj08TK2Lz4+K+Hs2fhUVMgRP9JXV
|
||||
vf8tC77XV/fH9wIKeaPvsupFO73AdD0BQZb8bQ0=
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIAX0EXHZDH5uU2h5ctNqHfa9hMtO9tfoM1kCRL9JHGtA
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIBMzCB5qADAgECAhRT8XV12gL48jHEcgn7qTk7b5ocbzAFBgMrZXAwEjEQMA4G
|
||||
A1UEAwwHV3JvbmdDQTAeFw0yNjA0MDUxNjIxMzNaFw0zNjA0MDIxNjIxMzNaMBMx
|
||||
ETAPBgNVBAMMCGludHJ1ZGVyMCowBQYDK2VwAyEADrp6dr+UfNhzR6guiNU5ns0c
|
||||
Y97Ari4gVZnh8DE1MB6jTTBLMAkGA1UdEwQCMAAwHQYDVR0OBBYEFIF2t8T34ktN
|
||||
Y276k4702JltR0iEMB8GA1UdIwQYMBaAFNFUQIdydtMb4t9g8+0s9DHh0pYIMAUG
|
||||
AytlcANBAFO7sA+Po2qFaTRSdpxuAQIbywHiF92uyombcfQkQPgbVbAA3oH9gh32
|
||||
4uG4c1OCE+w1AI1f2/EpC4zZRPZVAwI=
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIAU8EOnIZ26wKCqJ/WTcoCBHETbSYsILQ9zxddB92JVQ
|
||||
-----END PRIVATE KEY-----
|
||||
55
lib/crates/fabro-server/tests/it/api/docs.rs
Normal file
55
lib/crates/fabro-server/tests/it/api/docs.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
226
lib/crates/fabro-server/tests/it/api/tcp.rs
Normal file
226
lib/crates/fabro-server/tests/it/api/tcp.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue