fix(web): board runs endpoint reads from store, not in-memory state

The /boards/runs endpoint was driven by the in-memory state.runs map,
which is empty after server restart. Now reads from SlateDB store so
runs persist across restarts.

Also makes board columns dynamic from the API response instead of
hardcoded in the frontend. Real mode returns: pending, running, waiting,
succeeded, failed. Demo mode returns: working, pending, review, merge.

Board layout changed from fixed 3-column grid to horizontal scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-15 08:36:55 -04:00
parent 57e0385c98
commit a12ceb0ad0
No known key found for this signature in database
4 changed files with 160 additions and 85 deletions

View file

@ -29,13 +29,17 @@ export interface RunItem {
sandboxId?: string;
}
export type ColumnStatus = "working" | "pending" | "review" | "merge";
export type ColumnStatus = "working" | "pending" | "review" | "merge" | "running" | "waiting" | "succeeded" | "failed";
export const columnNames: Record<ColumnStatus, string> = {
working: "Working",
pending: "Pending",
review: "Verify",
merge: "Merge",
running: "Running",
waiting: "Waiting",
succeeded: "Succeeded",
failed: "Failed",
};
export interface RunWithStatus extends RunItem {
@ -107,6 +111,10 @@ export const statusColors: Record<ColumnStatus, { dot: string; text: string }> =
pending: { 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" },
failed: { dot: "bg-coral", text: "text-coral" },
};
export const ciConfig: Record<CiStatus, { label: string; dot: string; text: string }> = {

View file

@ -27,26 +27,39 @@ export function meta({}: any) {
return [{ title: "Runs — Fabro" }];
}
const columnConfig: {
id: ColumnStatus;
name: string;
interface ColumnStyle {
accent: string;
iconColor: string;
iconType: "branch" | "pr";
actions: string[];
}[] = [
{ id: "working", name: "Working", accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] },
{ id: "pending", name: "Pending", accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: ["Answer Question"] },
{ id: "merge", name: "Complete", accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] },
];
}
const columnStyles: Record<string, ColumnStyle> = {
working: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] },
pending: { 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: [] },
failed: { accent: "bg-coral", iconColor: "text-coral", iconType: "branch", actions: [] },
};
const defaultColumnStyle: ColumnStyle = { accent: "bg-fg-muted", iconColor: "text-fg-muted", iconType: "branch", actions: [] };
interface BoardRunsResponse {
columns: { id: string; name: string }[];
data: PaginatedRunList["data"];
meta: PaginatedRunList["meta"];
}
export async function loader({ request }: any) {
const response = await apiJson<PaginatedRunList>("/boards/runs", { request });
const response = await apiJson<BoardRunsResponse>("/boards/runs", { request });
const apiRuns = response.data;
const grouped = new Map<ColumnStatus, RunItem[]>();
for (const cfg of columnConfig) {
grouped.set(cfg.id, []);
const grouped = new Map<string, RunItem[]>();
for (const col of response.columns) {
grouped.set(col.id, []);
}
for (const apiRun of apiRuns) {
if (grouped.has(apiRun.status)) {
@ -54,9 +67,11 @@ export async function loader({ request }: any) {
}
}
const columns = columnConfig.map((cfg) => ({
...cfg,
items: grouped.get(cfg.id) ?? [],
const columns = response.columns.map((col) => ({
id: col.id as ColumnStatus,
name: col.name,
...(columnStyles[col.id] ?? defaultColumnStyle),
items: grouped.get(col.id) ?? [],
}));
return { columns };
@ -592,9 +607,11 @@ export default function Runs({ loaderData }: any) {
</div>
{view === "columns" ? (
<div className="grid grid-cols-3 gap-5 pb-4">
<div className="flex gap-5 overflow-x-auto pb-4">
{filteredColumns.map((col) => (
<BoardColumn key={col.id} column={col} />
<div key={col.id} className="w-72 shrink-0">
<BoardColumn column={col} />
</div>
))}
</div>
) : (

View file

@ -46,11 +46,27 @@ pub(crate) async fn list_runs(
}
pub(crate) async fn list_board_runs(
auth: AuthenticatedService,
state: State<Arc<AppState>>,
pagination: Query<PaginationParams>,
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
list_runs(auth, state, pagination).await
let items = runs::list_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 } })),
)
.into_response()
}
pub(crate) async fn create_run_stub(

View file

@ -2412,63 +2412,60 @@ fn test_secret_store_path() -> PathBuf {
dir.join("secrets.json")
}
fn board_column(status: RunStatus) -> Option<&'static str> {
fn board_column(status: WorkflowRunStatus) -> Option<&'static str> {
match status {
RunStatus::Running => Some("working"),
RunStatus::Paused => Some("pending"),
RunStatus::Completed => Some("merge"),
_ => None,
WorkflowRunStatus::Submitted | WorkflowRunStatus::Starting => Some("pending"),
WorkflowRunStatus::Running => Some("running"),
WorkflowRunStatus::Paused => Some("waiting"),
WorkflowRunStatus::Succeeded => Some("succeeded"),
WorkflowRunStatus::Failed | WorkflowRunStatus::Dead => Some("failed"),
WorkflowRunStatus::Removing => None,
}
}
fn board_columns() -> serde_json::Value {
serde_json::json!([
{"id": "pending", "name": "Pending"},
{"id": "running", "name": "Running"},
{"id": "waiting", "name": "Waiting"},
{"id": "succeeded", "name": "Succeeded"},
{"id": "failed", "name": "Failed"},
])
}
async fn list_board_runs(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
let live_runs: Vec<(RunId, RunStatus, chrono::DateTime<chrono::Utc>)> = {
let runs = state.runs.lock().expect("runs lock poisoned");
runs.iter()
.map(|(id, managed_run)| (*id, managed_run.status, managed_run.created_at))
.collect()
};
let summaries = match state
.store
.list_runs(&fabro_store::ListRunsQuery::default())
.await
{
Ok(runs) => runs
.into_iter()
.map(|summary| (summary.run_id, summary))
.collect::<HashMap<_, _>>(),
Ok(runs) => runs,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let all_items: Vec<serde_json::Value> = live_runs
.iter()
.filter_map(|(id, status, created_at)| {
let column = board_column(*status)?;
let summary = summaries.get(id);
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 all_items: Vec<serde_json::Value> = 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
.and_then(|s| s.host_repo_path.as_deref())
.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);
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": id.to_string(),
"id": summary.run_id.to_string(),
"title": title,
"repository": { "name": repo_name },
"workflow": { "slug": workflow_slug, "name": workflow_name },
@ -2486,6 +2483,7 @@ async fn list_board_runs(
(
StatusCode::OK,
Json(serde_json::json!({
"columns": board_columns(),
"data": data,
"meta": { "has_more": has_more }
})),
@ -8506,7 +8504,7 @@ level = "debug"
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
// Verify the run appears on the board (store has Submitted status → "pending" column)
let req = Request::builder()
.method("GET")
.uri(api("/boards/runs"))
@ -8520,7 +8518,7 @@ level = "debug"
.iter()
.find(|item| item["id"].as_str() == Some(run_id_str.as_str()))
.expect("board item should exist");
assert_eq!(item["status"].as_str(), Some("working"));
assert_eq!(item["status"].as_str(), Some("pending"));
}
#[tokio::test]
@ -8997,18 +8995,22 @@ timeout = "30s"
}
#[tokio::test]
async fn boards_runs_excludes_non_board_statuses() {
async fn boards_runs_excludes_removing_status() {
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();
let run_id = fixtures::RUN_1;
// 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;
}
// A run in Removing status should not appear on the board
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::RunSubmitted {
reason: None,
definition_blob: None,
},
workflow_event::Event::RunStarting { reason: None },
workflow_event::Event::RunRunning { reason: None },
workflow_event::Event::RunRemoving { reason: None },
])
.await;
let req = Request::builder()
.method("GET")
@ -9019,25 +9021,49 @@ timeout = "30s"
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");
let found = data
.iter()
.any(|i| i["id"].as_str() == Some(&run_id.to_string()));
assert!(!found, "removing run should not appear on the board");
}
#[tokio::test]
async fn boards_runs_maps_paused_to_pending_and_completed_to_merge() {
async fn boards_runs_maps_statuses_to_columns() {
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 paused_id = fixtures::RUN_1;
let succeeded_id = fixtures::RUN_2;
{
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;
}
create_durable_run_with_events(&state, paused_id, &[
workflow_event::Event::RunSubmitted {
reason: None,
definition_blob: None,
},
workflow_event::Event::RunStarting { reason: None },
workflow_event::Event::RunRunning { reason: None },
workflow_event::Event::RunPaused,
])
.await;
create_durable_run_with_events(&state, succeeded_id, &[
workflow_event::Event::RunSubmitted {
reason: None,
definition_blob: None,
},
workflow_event::Event::RunStarting { reason: None },
workflow_event::Event::RunRunning { reason: None },
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
billing: None,
},
])
.await;
let req = Request::builder()
.method("GET")
@ -9050,14 +9076,22 @@ timeout = "30s"
let paused_item = data
.iter()
.find(|i| i["id"].as_str() == Some(&paused_id_str))
.find(|i| i["id"].as_str() == Some(&paused_id.to_string()))
.expect("paused run should be on board");
assert_eq!(paused_item["status"].as_str().unwrap(), "pending");
assert_eq!(paused_item["status"].as_str().unwrap(), "waiting");
let completed_item = data
let succeeded_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");
.find(|i| i["id"].as_str() == Some(&succeeded_id.to_string()))
.expect("succeeded run should be on board");
assert_eq!(succeeded_item["status"].as_str().unwrap(), "succeeded");
// Verify columns are included in the response
let columns = body["columns"].as_array().expect("columns should be array");
assert!(columns.len() > 0);
assert!(columns.iter().any(|c| c["id"].as_str() == Some("waiting")));
assert!(columns
.iter()
.any(|c| c["id"].as_str() == Some("succeeded")));
}
}