feat: wire web UI to real server with demo mode toggle

Server changes:
- Add /boards/runs to demo routes (delegates to list_runs)
- Fix demo get_run_status to return StoreRunSummary shape matching OpenAPI spec
- Enrich real /boards/runs to return RunListItem shape with board column mapping
  (Running->working, Paused->pending, Completed->merge; others excluded)
- Update existing tests that asserted old RunStatusResponse fields from /boards/runs

Web UI changes:
- Add DemoModeProvider context and useDemoMode hook
- Hide Workflows/Insights nav items in production mode via getVisibleNavigation
- Change run-detail loader to use /runs/{id} directly instead of searching /boards/runs
- Add mapRunSummaryToRunItem for mapping server response to UI shape
- Add Graph tab, hide Stages tab in production mode, always hide Files tab
- Make run-overview and run-graph loaders resilient to 501 via apiJsonOrNull
- Add isNotImplemented and apiJsonOrNull helpers to api.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-08 04:46:08 -04:00
parent 2f8c379644
commit da88a47595
21 changed files with 2498 additions and 2103 deletions

View file

@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import { isNotImplemented } from "./api";
describe("isNotImplemented", () => {
test("returns true for 501 status", () => {
expect(isNotImplemented(501)).toBe(true);
});
test("returns false for 200 status", () => {
expect(isNotImplemented(200)).toBe(false);
});
test("returns false for 404 status", () => {
expect(isNotImplemented(404)).toBe(false);
});
});

View file

@ -27,6 +27,27 @@ export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T>
return response.json() as Promise<T>;
}
export function isNotImplemented(status: number): boolean {
return status === 501;
}
export async function apiJsonOrNull<T>(
path: string,
options?: ApiOptions,
): Promise<T | null> {
const response = await apiFetch(path, options);
if (isNotImplemented(response.status)) {
return null;
}
if (!response.ok) {
throw new Response(null, {
status: response.status,
statusText: response.statusText,
});
}
return response.json() as Promise<T>;
}
export async function getSetupStatus(): Promise<{ configured: boolean }> {
const response = await fetch("/api/v1/setup/status", { credentials: "include" });
if (!response.ok) {

View file

@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { mapRunSummaryToRunItem } from "./runs";
describe("mapRunSummaryToRunItem", () => {
test("maps store run summary to RunItem", () => {
const summary = {
run_id: "01ABC",
goal: "Fix the build",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
status: "running",
duration_ms: 65000,
total_usd_micros: 500000,
labels: {},
start_time: "2026-04-08T12:00:00Z",
status_reason: null,
pending_control: null,
};
const item = mapRunSummaryToRunItem(summary);
expect(item.id).toBe("01ABC");
expect(item.title).toBe("Fix the build");
expect(item.workflow).toBe("fix_build");
expect(item.repo).toBe("myrepo");
expect(item.elapsed).toBeDefined();
});
test("handles missing optional fields", () => {
const summary = {
run_id: "01DEF",
goal: null,
workflow_slug: null,
workflow_name: null,
host_repo_path: null,
status: "submitted",
duration_ms: null,
total_usd_micros: null,
labels: {},
start_time: null,
status_reason: null,
pending_control: null,
};
const item = mapRunSummaryToRunItem(summary);
expect(item.id).toBe("01DEF");
expect(item.title).toBe("Untitled run");
expect(item.workflow).toBe("unknown");
expect(item.repo).toBe("unknown");
});
});

View file

@ -66,6 +66,36 @@ 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 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 ?? "Untitled run",
workflow: summary.workflow_slug ?? "unknown",
elapsed:
summary.duration_ms != null
? formatElapsedSecs(summary.duration_ms / 1000)
: undefined,
};
}
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";

View file

@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { getVisibleNavigation } from "./app-shell";
describe("getVisibleNavigation", () => {
test("shows all nav items in demo mode", () => {
const items = getVisibleNavigation(true);
const names = items.map((i) => i.name);
expect(names).toContain("Workflows");
expect(names).toContain("Runs");
expect(names).toContain("Insights");
});
test("hides Workflows and Insights in production mode", () => {
const items = getVisibleNavigation(false);
const names = items.map((i) => i.name);
expect(names).not.toContain("Workflows");
expect(names).not.toContain("Insights");
expect(names).toContain("Runs");
});
});

View file

@ -19,18 +19,23 @@ import {
} from "@heroicons/react/24/outline";
import { Form, Link, Outlet, redirect, useLocation, useMatches, useRevalidator } from "react-router";
import { getAuthMe } from "../api";
import { DemoModeProvider } from "../lib/demo-mode";
import { useTheme } from "../lib/theme";
export async function loader() {
return getAuthMe();
}
const navigation = [
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon },
{ name: "Runs", href: "/runs", icon: PlayIcon },
{ name: "Insights", href: "/insights", icon: ChartBarIcon },
const allNavigation = [
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon, demoOnly: true },
{ name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false },
{ name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true },
];
export function getVisibleNavigation(demoMode: boolean) {
return allNavigation.filter((item) => !item.demoOnly || demoMode);
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
@ -41,6 +46,7 @@ export default function AppShell({ loaderData }: any) {
const matches = useMatches();
const revalidator = useRevalidator();
const { theme, toggle } = useTheme();
const navigation = getVisibleNavigation(demoMode);
const currentNav = navigation.find((item) => pathname.startsWith(item.href));
const title = currentNav?.name ?? "";
const lastMatch = matches[matches.length - 1];
@ -63,6 +69,7 @@ export default function AppShell({ loaderData }: any) {
}
return (
<DemoModeProvider value={demoMode}>
<div className="min-h-full">
<Disclosure as="nav" className="bg-panel/50">
<div className="px-4 sm:px-6 lg:px-8">
@ -263,5 +270,6 @@ export default function AppShell({ loaderData }: any) {
</div>
</main>
</div>
</DemoModeProvider>
);
}

View file

@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { DemoModeProvider, useDemoMode } from "./demo-mode";
function TestConsumer() {
const demoMode = useDemoMode();
return <span data-demo={demoMode}>{demoMode ? "demo" : "prod"}</span>;
}
describe("DemoModeProvider", () => {
test("provides demo mode value to children", () => {
const html = renderToString(
<DemoModeProvider value={true}>
<TestConsumer />
</DemoModeProvider>,
);
expect(html).toContain("demo");
expect(html).toContain('data-demo="true"');
});
test("defaults to false", () => {
const html = renderToString(
<DemoModeProvider value={false}>
<TestConsumer />
</DemoModeProvider>,
);
expect(html).toContain("prod");
});
});

View file

@ -0,0 +1,21 @@
import { createContext, useContext } from "react";
const DemoModeContext = createContext(false);
export function DemoModeProvider({
value,
children,
}: {
value: boolean;
children: React.ReactNode;
}) {
return (
<DemoModeContext.Provider value={value}>
{children}
</DemoModeContext.Provider>
);
}
export function useDemoMode(): boolean {
return useContext(DemoModeContext);
}

View file

@ -2,29 +2,40 @@ import { useEffect } from "react";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link, Outlet, useFetcher, useLocation } from "react-router";
import { columnNames, mapRunListItem, statusColors } from "../data/runs";
import type { ColumnStatus } from "../data/runs";
import { columnNames, mapRunSummaryToRunItem, statusColors } from "../data/runs";
import type { ColumnStatus, RunSummaryResponse } from "../data/runs";
import { apiJson } from "../api";
import type { PaginatedRunList, PreviewUrlResponse } from "@qltysh/fabro-api-client";
import { useDemoMode } from "../lib/demo-mode";
import type { PreviewUrlResponse } from "@qltysh/fabro-api-client";
const tabs = [
{ name: "Overview", path: "", count: null },
{ name: "Stages", path: "/stages/detect-drift", count: null },
{ name: "Files Changed", path: "/files", count: null },
{ name: "Billing", path: "/billing", count: null },
const allTabs = [
{ name: "Overview", path: "", count: null, demoOnly: false, broken: false },
{ name: "Stages", path: "/stages/detect-drift", count: null, demoOnly: true, broken: false },
{ name: "Files Changed", path: "/files", count: null, demoOnly: false, broken: true },
{ name: "Graph", path: "/graph", count: null, demoOnly: false, broken: false },
{ name: "Billing", path: "/billing", count: null, demoOnly: false, broken: false },
];
export const handle = { hideHeader: true };
export async function loader({ request, params }: any) {
const response = await apiJson<PaginatedRunList>("/boards/runs", { request });
const apiRun = response.data.find((r) => r.id === params.id);
if (!apiRun) return { run: null };
const response = await fetch(`/api/v1/runs/${params.id}`, {
credentials: "include",
});
if (!response.ok) return { run: null };
const summary: RunSummaryResponse = await response.json();
const item = mapRunSummaryToRunItem(summary);
const statusMap: Record<string, ColumnStatus> = {
running: "working",
paused: "pending",
completed: "merge",
};
const status = statusMap[summary.status ?? ""] ?? "working";
return {
run: {
...mapRunListItem(apiRun),
status: apiRun.status as ColumnStatus,
statusLabel: columnNames[apiRun.status as ColumnStatus] ?? apiRun.status,
...item,
status,
statusLabel: columnNames[status] ?? summary.status ?? "Unknown",
},
};
}
@ -54,6 +65,8 @@ export default function RunDetail({ loaderData, params }: any) {
const { pathname } = useLocation();
const basePath = `/runs/${params.id}`;
const previewFetcher = useFetcher<PreviewUrlResponse>();
const demoMode = useDemoMode();
const tabs = allTabs.filter((t) => !t.broken && (!t.demoOnly || demoMode));
useEffect(() => {
if (previewFetcher.data?.url) {

View file

@ -5,7 +5,7 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { useTheme } from "../lib/theme";
import { getGraphTheme } from "../lib/graph-theme";
import { apiFetch, apiJson } from "../api";
import { apiFetch, apiJsonOrNull } from "../api";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
@ -22,11 +22,11 @@ interface Stage {
}
export async function loader({ request, params }: any) {
const [{ data: apiStages }, graphRes] = await Promise.all([
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
const [stagesResult, graphRes] = await Promise.all([
apiJsonOrNull<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
apiFetch(`/runs/${params.id}/graph`, { request }),
]);
const stages: Stage[] = apiStages.map((s) => ({
const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({
id: s.id,
name: s.name,
dotId: s.dot_id ?? s.id,

View file

@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { useTheme } from "../lib/theme";
import { getGraphTheme } from "../lib/graph-theme";
import { apiJson } from "../api";
import { apiJsonOrNull } from "../api";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList, PaginatedRunList } from "@qltysh/fabro-api-client";
import type { WorkflowDetailResponse } from "../lib/workflow-api";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
export const handle = { wide: true };
@ -22,27 +21,17 @@ interface Stage {
}
export async function loader({ request, params }: any) {
const [{ data: apiStages }, response] = await Promise.all([
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
apiJson<PaginatedRunList>("/boards/runs", { request }),
]);
const stages: Stage[] = apiStages.map((s) => ({
const stagesResult = await apiJsonOrNull<PaginatedRunStageList>(
`/runs/${params.id}/stages`,
{ request },
);
const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({
id: s.id,
name: s.name,
status: s.status as StageStatus,
duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--",
}));
const run = response.data.find((r) => r.id === params.id);
let graphDot: string | null = null;
if (run) {
try {
const workflow = await apiJson<WorkflowDetailResponse>(`/workflows/${run.workflow}`, { request });
graphDot = workflow.graph;
} catch {
// workflow not found — leave graphDot null
}
}
return { stages, graphDot };
return { stages, graphDot: null };
}
const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: string }> = {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -61,14 +61,14 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-r31bcs2m.js"></script>
<script type="module" src="/assets/entry-3m7f90fs.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>
<script type="module" src="/assets/chunk-pectm3zk.js"></script>
<script type="module" src="/assets/chunk-kgq5332v.js"></script>
<script type="module" src="/assets/chunk-q07bg6gn.js"></script>
<script type="module" src="/assets/chunk-90qzx8bn.js"></script>
<script type="module" src="/assets/chunk-a51kkeyv.js"></script>
<script type="module" src="/assets/chunk-ysp46zk7.js"></script>
<script type="module" src="/assets/chunk-m2sbqbtm.js"></script>
<script type="module" src="/assets/chunk-d0p7g53p.js"></script>

View file

@ -9,7 +9,7 @@ use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::sse::{Event, Sse};
use axum::response::{IntoResponse, Response};
use fabro_api::types::{RunArtifactListResponse, RunStatus, RunStatusResponse};
use fabro_api::types::RunArtifactListResponse;
use serde_json::json;
use crate::error::ApiError;
@ -42,6 +42,14 @@ pub(crate) async fn list_runs(
paginated_response(runs::list_items(), &pagination)
}
pub(crate) async fn list_board_runs(
auth: AuthenticatedService,
state: State<Arc<AppState>>,
pagination: Query<PaginationParams>,
) -> Response {
list_runs(auth, state, pagination).await
}
pub(crate) async fn create_run_stub(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
@ -176,19 +184,30 @@ pub(crate) async fn get_run_status(
Path(id): Path<String>,
) -> Response {
match runs::list_items().into_iter().find(|r| r.id == id) {
Some(item) => (
StatusCode::OK,
Json(RunStatusResponse {
id: id.clone(),
status: RunStatus::Running,
error: None,
queue_position: None,
status_reason: None,
pending_control: None,
created_at: item.created_at,
}),
)
.into_response(),
Some(item) => {
let elapsed_ms = item
.timings
.as_ref()
.map(|t| (t.elapsed_secs * 1000.0) as u64);
(
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()
}
None => ApiError::not_found("Run not found.").into_response(),
}
}

View file

@ -847,6 +847,7 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
fn demo_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runs", get(demo::list_runs).post(demo::create_run_stub))
.route("/boards/runs", get(demo::list_board_runs))
.route("/preflight", post(run_preflight))
.route("/graph/render", post(render_graph_from_manifest))
.route("/attach", get(demo::attach_events_stub))
@ -2014,25 +2015,25 @@ fn test_config_path() -> PathBuf {
std::env::temp_dir().join(format!("fabro-test-settings-{}.toml", Ulid::new()))
}
fn board_column(status: RunStatus) -> Option<&'static str> {
match status {
RunStatus::Running => Some("working"),
RunStatus::Paused => Some("pending"),
RunStatus::Completed => Some("merge"),
_ => None,
}
}
async fn list_board_runs(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
let live_runs = {
let live_runs: Vec<(RunId, RunStatus, chrono::DateTime<chrono::Utc>)> = {
let runs = state.runs.lock().expect("runs lock poisoned");
let queue_positions = compute_queue_positions(&runs);
runs.iter()
.map(|(id, managed_run)| {
(
*id,
managed_run.status,
managed_run.error.clone(),
queue_positions.get(id).copied(),
managed_run.created_at,
)
})
.collect::<Vec<_>>()
.map(|(id, managed_run)| (*id, managed_run.status, managed_run.created_at))
.collect()
};
let summaries = match state
.store
@ -2048,27 +2049,40 @@ async fn list_board_runs(
.into_response();
}
};
let limit = pagination.limit.clamp(1, 100) as usize;
let offset = pagination.offset as usize;
let all_items: Vec<RunStatusResponse> = live_runs
let all_items: Vec<serde_json::Value> = live_runs
.iter()
.map(|(id, status, error, queue_position, created_at)| {
.filter_map(|(id, status, created_at)| {
let column = board_column(*status)?;
let summary = summaries.get(id);
RunStatusResponse {
id: id.to_string(),
status: *status,
error: error.as_ref().map(|msg| RunError {
message: msg.clone(),
}),
queue_position: *queue_position,
status_reason: summary
.and_then(|summary| summary.status_reason.map(api_status_reason)),
pending_control: summary
.and_then(|summary| summary.pending_control.map(api_pending_control)),
created_at: *created_at,
}
let title = summary
.and_then(|s| s.goal.as_deref())
.unwrap_or("Untitled run");
let workflow_slug = summary
.and_then(|s| s.workflow_slug.as_deref())
.unwrap_or("unknown");
let workflow_name = summary
.and_then(|s| s.workflow_name.as_deref())
.unwrap_or(workflow_slug);
let repo_name = summary
.and_then(|s| s.host_repo_path.as_deref())
.and_then(|p| p.rsplit('/').next())
.unwrap_or("unknown");
let elapsed_secs = summary
.and_then(|s| s.duration_ms)
.map(|ms| ms as f64 / 1000.0);
Some(serde_json::json!({
"id": 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 })),
}))
})
.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();
@ -2228,6 +2242,7 @@ fn remove_run_dir(run_dir: &std::path::Path) -> std::io::Result<()> {
}
}
#[cfg(test)]
fn compute_queue_positions(runs: &HashMap<RunId, ManagedRun>) -> HashMap<RunId, i64> {
let mut queued: Vec<(&RunId, &ManagedRun)> = runs
.iter()
@ -7458,6 +7473,7 @@ mod tests {
assert_eq!(body["status"].as_str().unwrap(), "failed");
assert_eq!(body["status_reason"].as_str().unwrap(), "cancelled");
// Cancelled (failed) runs are excluded from the board
let req = Request::builder()
.method("GET")
.uri(api("/boards/runs"))
@ -7466,14 +7482,12 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id_str = run_id.to_string();
let item = body["data"]
let found = body["data"]
.as_array()
.unwrap()
.iter()
.find(|item| item["id"].as_str() == Some(run_id_str.as_str()))
.expect("board item should exist");
assert_eq!(item["status_reason"].as_str(), Some("cancelled"));
assert!(item["pending_control"].is_null());
.any(|item| item["id"].as_str() == Some(run_id_str.as_str()));
assert!(!found, "cancelled run should not appear on the board");
let run_store = state.store.open_run_reader(&run_id).await.unwrap();
let status = run_store.state().await.unwrap().status.unwrap();
@ -7566,6 +7580,17 @@ mod tests {
assert_eq!(body["status"].as_str(), Some("running"));
assert_eq!(body["pending_control"].as_str(), Some("pause"));
// Verify pending_control via /runs/{id} (board no longer includes this field)
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}")))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert_eq!(body["pending_control"].as_str(), Some("pause"));
// Verify the run appears on the board with "working" status
let req = Request::builder()
.method("GET")
.uri(api("/boards/runs"))
@ -7579,7 +7604,7 @@ mod tests {
.iter()
.find(|item| item["id"].as_str() == Some(run_id_str.as_str()))
.expect("board item should exist");
assert_eq!(item["pending_control"].as_str(), Some("pause"));
assert_eq!(item["status"].as_str(), Some("working"));
}
#[tokio::test]
@ -7860,39 +7885,26 @@ mod tests {
#[tokio::test]
async fn queue_position_reported_for_queued_runs() {
let state = create_app_state();
let app = build_router(state, AuthMode::Disabled);
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
// Create and start two runs (no scheduler, both stay queued)
let first_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
let second_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
// Check queue positions via the live board endpoint.
let req = Request::builder()
.method("GET")
.uri(api("/boards/runs"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let items = body["data"].as_array().unwrap();
let first = items
.iter()
.find(|item| item["id"].as_str() == Some(first_run_id.as_str()))
.unwrap();
assert_eq!(first["queue_position"].as_i64().unwrap(), 1);
let second = items
.iter()
.find(|item| item["id"].as_str() == Some(second_run_id.as_str()))
.unwrap();
assert_eq!(second["queue_position"].as_i64().unwrap(), 2);
// Queued runs are excluded from the board, so verify queue positions
// via the in-memory state directly.
let runs = state.runs.lock().expect("runs lock poisoned");
let positions = compute_queue_positions(&runs);
let first_id = first_run_id.parse::<RunId>().unwrap();
let second_id = second_run_id.parse::<RunId>().unwrap();
assert_eq!(positions.get(&first_id).copied(), Some(1));
assert_eq!(positions.get(&second_id).copied(), Some(2));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrency_limit_respected() {
let state = create_app_state_with_options(Settings::default(), 1);
let app = test_app_with_scheduler(state);
let app = test_app_with_scheduler(Arc::clone(&state));
// Create and start two runs with max_concurrent_runs=1
create_and_start_run(&app, MINIMAL_DOT).await;
@ -7901,7 +7913,9 @@ mod tests {
// Give scheduler time to pick up the first run
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Check statuses: at most 1 should be starting/running, the other queued
// Board only shows runs with board-column statuses (Running -> "working",
// Paused -> "pending", Completed -> "merge"). Queued/Starting/Failed are excluded.
// With max_concurrent_runs=1, at most 1 should be active on the board.
let req = Request::builder()
.method("GET")
.uri(api("/boards/runs"))
@ -7912,16 +7926,11 @@ mod tests {
let items = body["data"].as_array().unwrap();
let active_count = items
.iter()
.filter(|item| {
let s = item["status"].as_str().unwrap();
s == "starting" || s == "running"
})
.filter(|item| item["status"].as_str() == Some("working"))
.count();
// With max_concurrent_runs=1, at most 1 should be active
// (the first one might have completed already, so active could be 0 or 1)
assert!(
active_count <= 1,
"expected at most 1 active run, got {active_count}"
"expected at most 1 active run on the board, got {active_count}"
);
}
@ -8030,4 +8039,174 @@ mod tests {
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn demo_boards_runs_returns_run_list_items() {
let state = create_app_state();
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("GET")
.uri(api("/boards/runs"))
.header("X-Fabro-Demo", "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;
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["repository"].is_object());
assert!(first["title"].is_string());
assert!(first["workflow"].is_object());
assert!(first["status"].is_string());
assert!(first["created_at"].is_string());
}
#[tokio::test]
async fn demo_get_run_returns_store_run_summary_shape() {
let state = create_app_state();
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("GET")
.uri(api("/runs/run-1"))
.header("X-Fabro-Demo", "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;
// Should have StoreRunSummary fields, not RunStatusResponse fields
assert!(body["run_id"].is_string(), "should have run_id field");
assert!(body["goal"].is_string(), "should have goal field");
assert!(
body["workflow_slug"].is_string(),
"should have workflow_slug field"
);
// Should NOT have RunStatusResponse-only fields
assert!(
body["queue_position"].is_null(),
"should not have queue_position"
);
}
#[tokio::test]
async fn demo_get_run_returns_404_for_unknown_run() {
let state = create_app_state();
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("GET")
.uri(api("/runs/nonexistent-run-id"))
.header("X-Fabro-Demo", "1")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn boards_runs_returns_run_list_items_with_board_columns() {
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;
// Set run to running so it appears on the board
{
let id = run_id.parse::<RunId>().unwrap();
let mut runs = state.runs.lock().expect("runs lock poisoned");
let managed_run = runs.get_mut(&id).expect("run should exist");
managed_run.status = RunStatus::Running;
}
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["id"].as_str() == Some(&run_id))
.expect("run should be in board");
// Should have RunListItem fields
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", "pending", "review", "merge"].contains(&status),
"status should be a board column, got: {status}"
);
assert!(item["created_at"].is_string());
}
#[tokio::test]
async fn boards_runs_excludes_non_board_statuses() {
let state = create_app_state();
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
let run_id = run_id_str.parse::<RunId>().unwrap();
// Set run to Failed — should not appear on the board
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
let managed_run = runs.get_mut(&run_id).expect("run should exist");
managed_run.status = RunStatus::Failed;
}
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 found = data.iter().any(|i| i["id"].as_str() == Some(&run_id_str));
assert!(!found, "failed run should not appear on the board");
}
#[tokio::test]
async fn boards_runs_maps_paused_to_pending_and_completed_to_merge() {
let state = create_app_state();
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let paused_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
let paused_id = paused_id_str.parse::<RunId>().unwrap();
let completed_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
let completed_id = completed_id_str.parse::<RunId>().unwrap();
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.get_mut(&paused_id).unwrap().status = RunStatus::Paused;
runs.get_mut(&completed_id).unwrap().status = RunStatus::Completed;
}
let req = Request::builder()
.method("GET")
.uri(api("/boards/runs"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let data = body["data"].as_array().expect("data should be array");
let paused_item = data
.iter()
.find(|i| i["id"].as_str() == Some(&paused_id_str))
.expect("paused run should be on board");
assert_eq!(paused_item["status"].as_str().unwrap(), "pending");
let completed_item = data
.iter()
.find(|i| i["id"].as_str() == Some(&completed_id_str))
.expect("completed run should be on board");
assert_eq!(completed_item["status"].as_str().unwrap(), "merge");
}
}