diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index e614114d3..ebc03cfa5 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -1,18 +1,52 @@ import { describe, expect, test } from "bun:test"; -import { mapRunSummaryToRunItem } from "./runs"; +import { mapRunListItem, mapRunSummaryToRunItem } from "./runs"; -describe("mapRunSummaryToRunItem", () => { - test("maps store run summary to RunItem", () => { +describe("mapRunListItem", () => { + test("trusts shared server fields for board items", () => { const summary = { run_id: "01ABC", - goal: "Fix the build", + goal: "## Fix the build", + title: "Server supplied title", workflow_slug: "fix_build", workflow_name: "Fix Build", host_repo_path: "/home/user/myrepo", + repository: { name: "myrepo" }, status: "running", + labels: {}, + column: "running", + elapsed_secs: 65, duration_ms: 65000, total_usd_micros: 500000, + created_at: "2026-04-08T12:00:00Z", + start_time: "2026-04-08T12:00:00Z", + status_reason: null, + pending_control: null, + } as const; + const item = mapRunListItem(summary); + expect(item.id).toBe("01ABC"); + expect(item.title).toBe("Server supplied title"); + expect(item.workflow).toBe("fix_build"); + expect(item.repo).toBe("myrepo"); + expect(item.elapsed).toBeDefined(); + }); +}); + +describe("mapRunSummaryToRunItem", () => { + test("maps canonical run summary to RunItem", () => { + const summary = { + run_id: "01ABC", + goal: "Fix the build", + title: "Fix the build", + workflow_slug: "fix_build", + workflow_name: "Fix Build", + host_repo_path: "/home/user/myrepo", + repository: { name: "myrepo" }, + status: "running", + duration_ms: 65000, + elapsed_secs: 65, + total_usd_micros: 500000, labels: {}, + created_at: "2026-04-08T12:00:00Z", start_time: "2026-04-08T12:00:00Z", status_reason: null, pending_control: null, @@ -28,21 +62,25 @@ describe("mapRunSummaryToRunItem", () => { test("handles missing optional fields", () => { const summary = { run_id: "01DEF", - goal: null, + goal: "", + title: "", workflow_slug: null, workflow_name: null, host_repo_path: null, + repository: { name: "unknown" }, status: "submitted", duration_ms: null, + elapsed_secs: null, total_usd_micros: null, labels: {}, + created_at: "2026-04-08T12:00:00Z", start_time: null, status_reason: null, pending_control: null, }; const item = mapRunSummaryToRunItem(summary); expect(item.id).toBe("01DEF"); - expect(item.title).toBe("Untitled run"); + expect(item.title).toBe(""); expect(item.workflow).toBe("unknown"); expect(item.repo).toBe("unknown"); }); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index eb9c30745..c0f8f2763 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -1,5 +1,5 @@ import { formatElapsedSecs, formatDurationSecs } from "../lib/format"; -import type { RunListItem } from "@qltysh/fabro-api-client"; +import type { RunListItem, StoreRunSummary } from "@qltysh/fabro-api-client"; export type CiStatus = "passing" | "failing" | "pending"; @@ -29,13 +29,10 @@ export interface RunItem { sandboxId?: string; } -export type ColumnStatus = "working" | "initializing" | "review" | "merge" | "running" | "waiting" | "succeeded" | "failed"; +export type ColumnStatus = "initializing" | "running" | "waiting" | "succeeded" | "failed"; export const columnNames: Record = { - working: "Working", initializing: "Initializing", - review: "Verify", - merge: "Merge", running: "Running", waiting: "Waiting", succeeded: "Succeeded", @@ -47,17 +44,12 @@ export interface RunWithStatus extends RunItem { statusLabel: string; } -function truncateGoal(goal: string): string { - const firstLine = goal.split("\n")[0].replace(/^#+\s*/, "").trim(); - return firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine; -} - export function mapRunListItem(item: RunListItem): RunItem { return { - id: item.id, + id: item.run_id, repo: item.repository.name, - title: truncateGoal(item.title), - workflow: item.workflow.slug, + title: item.title, + workflow: item.workflow_slug ?? item.workflow_name ?? "unknown", number: item.pull_request?.number, additions: item.pull_request?.additions, deletions: item.pull_request?.deletions, @@ -66,8 +58,7 @@ export function mapRunListItem(item: RunListItem): RunItem { status: c.status, duration: c.duration_secs != null ? formatDurationSecs(c.duration_secs) : undefined, })), - elapsed: item.timings?.elapsed_secs != null ? formatElapsedSecs(item.timings.elapsed_secs) : undefined, - elapsedWarning: item.timings?.elapsed_warning, + elapsed: item.elapsed_secs != null ? formatElapsedSecs(item.elapsed_secs) : undefined, resources: item.sandbox?.resources ? `${item.sandbox.resources.cpu} CPU / ${item.sandbox.resources.memory} GB` : undefined, comments: item.pull_request?.comments, question: item.question?.text, @@ -75,36 +66,42 @@ export function mapRunListItem(item: RunListItem): RunItem { }; } -export interface RunSummaryResponse { - run_id: string; - goal: string | null; - workflow_slug: string | null; - workflow_name: string | null; - host_repo_path: string | null; - status: string | null; - status_reason: string | null; - pending_control: string | null; - duration_ms: number | null; - total_usd_micros: number | null; - labels: Record; - start_time: string | null; -} +export type RunSummaryResponse = StoreRunSummary; export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem { - const repoPath = summary.host_repo_path ?? ""; - const repoName = repoPath.split("/").pop() || "unknown"; return { id: summary.run_id, - repo: repoName, - title: summary.goal ? truncateGoal(summary.goal) : "Untitled run", - workflow: summary.workflow_slug ?? "unknown", + repo: summary.repository.name, + title: summary.title, + workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown", elapsed: - summary.duration_ms != null + summary.elapsed_secs != null + ? formatElapsedSecs(summary.elapsed_secs) + : summary.duration_ms != null ? formatElapsedSecs(summary.duration_ms / 1000) : undefined, }; } +export function columnForStatus(status: string | null | undefined): ColumnStatus { + switch (status) { + case "submitted": + case "starting": + return "initializing"; + case "running": + return "running"; + case "paused": + return "waiting"; + case "succeeded": + return "succeeded"; + case "failed": + case "dead": + case "removing": + default: + return "failed"; + } +} + export function deriveCiStatus(checks: CheckRun[]): CiStatus { if (checks.some((c) => c.status === "failure")) return "failing"; if (checks.some((c) => c.status === "pending" || c.status === "queued")) return "pending"; @@ -112,10 +109,7 @@ export function deriveCiStatus(checks: CheckRun[]): CiStatus { } export const statusColors: Record = { - working: { dot: "bg-teal-500", text: "text-teal-500" }, initializing: { dot: "bg-amber", text: "text-amber" }, - review: { dot: "bg-mint", text: "text-mint" }, - merge: { dot: "bg-teal-300", text: "text-teal-300" }, running: { dot: "bg-teal-500", text: "text-teal-500" }, waiting: { dot: "bg-amber", text: "text-amber" }, succeeded: { dot: "bg-teal-300", text: "text-teal-300" }, diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 4113c42c1..dc55e3395 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -21,7 +21,7 @@ import { CSS } from "@dnd-kit/utilities"; import { ciConfig, statusColors, deriveCiStatus, mapRunListItem } from "../data/runs"; import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs"; import { apiJson } from "../api"; -import type { PaginatedRunList } from "@qltysh/fabro-api-client"; +import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client"; export function meta({}: any) { return [{ title: "Runs — Fabro" }]; @@ -35,10 +35,7 @@ interface ColumnStyle { } const columnStyles: Record = { - working: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] }, initializing: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: [] }, - review: { accent: "bg-mint", iconColor: "text-mint", iconType: "pr", actions: [] }, - merge: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] }, running: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] }, waiting: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: ["Answer Question"] }, succeeded: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: [] }, @@ -49,8 +46,8 @@ const defaultColumnStyle: ColumnStyle = { accent: "bg-fg-muted", iconColor: "tex interface BoardRunsResponse { columns: { id: string; name: string }[]; - data: PaginatedRunList["data"]; - meta: PaginatedRunList["meta"]; + data: PaginatedBoardRunList["data"]; + meta: PaginatedBoardRunList["meta"]; } export async function loader({ request }: any) { @@ -62,8 +59,8 @@ export async function loader({ request }: any) { grouped.set(col.id, []); } for (const apiRun of apiRuns) { - if (grouped.has(apiRun.status)) { - grouped.get(apiRun.status)?.push(mapRunListItem(apiRun)); + if (grouped.has(apiRun.column)) { + grouped.get(apiRun.column)?.push(mapRunListItem(apiRun)); } } diff --git a/apps/fabro-web/app/routes/workflow-runs.tsx b/apps/fabro-web/app/routes/workflow-runs.tsx index 4af7240b6..6478fb41f 100644 --- a/apps/fabro-web/app/routes/workflow-runs.tsx +++ b/apps/fabro-web/app/routes/workflow-runs.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { Link, useParams } from "react-router"; -import { ciConfig, columnNames, deriveCiStatus, mapRunListItem, statusColors } from "../data/runs"; +import { ciConfig, columnNames, columnForStatus, deriveCiStatus, mapRunSummaryToRunItem, statusColors } from "../data/runs"; import type { ColumnStatus, RunWithStatus } from "../data/runs"; import { apiJsonOrNull } from "../api"; import type { PaginatedRunList } from "@qltysh/fabro-api-client"; @@ -10,9 +10,9 @@ export async function loader({ request, params }: any) { const result = await apiJsonOrNull(`/workflows/${params.name}/runs`, { request }); const apiRuns = result?.data ?? []; const runs: RunWithStatus[] = apiRuns.map((r) => ({ - ...mapRunListItem(r), - status: r.status as ColumnStatus, - statusLabel: columnNames[r.status as ColumnStatus] ?? r.status, + ...mapRunSummaryToRunItem(r), + status: columnForStatus(r.status), + statusLabel: columnNames[columnForStatus(r.status)], })); return { runs }; } diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 58229ee81..f78bbf048 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -124,15 +124,16 @@ paths: tags: [Runs] summary: List Runs description: Returns durable run summaries from the backing store, including runs persisted before the current server boot. + parameters: + - $ref: "#/components/parameters/PageLimit" + - $ref: "#/components/parameters/PageOffset" responses: "200": - description: Durable run summaries + description: Paginated durable run summaries content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/StoreRunSummary" + $ref: "#/components/schemas/PaginatedRunList" post: operationId: createRun tags: [Runs] @@ -428,7 +429,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PaginatedRunList" + $ref: "#/components/schemas/PaginatedBoardRunList" /api/v1/runs/{id}/state: get: @@ -1702,6 +1703,25 @@ components: - data - meta properties: + data: + type: array + items: + $ref: "#/components/schemas/StoreRunSummary" + meta: + $ref: "#/components/schemas/PaginationMeta" + + PaginatedBoardRunList: + description: Paginated list of board runs with shared canonical fields plus board metadata. + type: object + required: + - columns + - data + - meta + properties: + columns: + type: array + items: + $ref: "#/components/schemas/BoardColumnDefinition" data: type: array items: @@ -3007,7 +3027,11 @@ components: type: object required: - run_id + - goal + - title - labels + - repository + - created_at properties: run_id: type: string @@ -3016,16 +3040,23 @@ components: workflow_slug: type: ["string", "null"] goal: - type: ["string", "null"] + type: string + title: + type: string labels: type: object additionalProperties: type: string host_repo_path: type: ["string", "null"] + repository: + $ref: "#/components/schemas/RepositoryReference" start_time: type: ["string", "null"] format: date-time + created_at: + type: string + format: date-time status: type: ["string", "null"] status_reason: @@ -3038,6 +3069,8 @@ components: type: ["integer", "null"] format: int64 minimum: 0 + elapsed_secs: + type: ["number", "null"] total_usd_micros: type: ["integer", "null"] format: int64 @@ -3048,10 +3081,22 @@ components: description: Board column status for a run in the list view. type: string enum: - - working - initializing - - review - - merge + - running + - waiting + - succeeded + - failed + + BoardColumnDefinition: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string CheckRunStatus: description: Status of a CI check run. @@ -3346,34 +3391,66 @@ components: # ── Run Board Schemas (updated) ───────────────────────────────────── RunListItem: - description: Summary of a run shown in the board view. + description: Canonical run summary shown in the board view, extended with board-specific metadata. type: object required: - - id - - repository + - run_id + - goal - title - - workflow - status + - labels + - repository - created_at + - column properties: - id: + run_id: type: string description: Unique run identifier (ULID). example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z + workflow_name: + type: ["string", "null"] + workflow_slug: + type: ["string", "null"] + goal: + type: string repository: $ref: "#/components/schemas/RepositoryReference" title: type: string description: Human-readable title describing the run's goal. example: Add rate limiting to auth endpoints - workflow: - $ref: "#/components/schemas/WorkflowReference" status: + type: string + labels: + type: object + additionalProperties: + type: string + host_repo_path: + type: ["string", "null"] + start_time: + type: ["string", "null"] + format: date-time + status_reason: + oneOf: + - $ref: "#/components/schemas/StatusReason" + - type: "null" + pending_control: + oneOf: + - $ref: "#/components/schemas/RunControlAction" + - type: "null" + duration_ms: + type: ["integer", "null"] + format: int64 + minimum: 0 + elapsed_secs: + type: ["number", "null"] + total_usd_micros: + type: ["integer", "null"] + format: int64 + column: $ref: "#/components/schemas/BoardColumn" pull_request: $ref: "#/components/schemas/RunPullRequest" - timings: - $ref: "#/components/schemas/RunTimings" sandbox: $ref: "#/components/schemas/RunSandbox" question: diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index eae036e4b..9c98c4949 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -589,17 +589,35 @@ impl ServerStoreClient { } pub(crate) async fn list_store_runs(&self) -> Result> { - let response = self - .client - .list_runs() - .send() - .await - .map_err(map_api_error)?; - response - .into_inner() - .into_iter() - .map(convert_type) - .collect::>>() + let mut all_runs = Vec::new(); + let mut offset = 0_u64; + let limit = 100_u64; + + loop { + let response = self + .client + .list_runs() + .page_limit(limit) + .page_offset(offset) + .send() + .await + .map_err(map_api_error)?; + let parsed = response.into_inner(); + let batch = parsed + .data + .into_iter() + .map(convert_type) + .collect::>>()?; + let batch_len = batch.len() as u64; + all_runs.extend(batch); + + if !parsed.meta.has_more || batch_len == 0 { + break; + } + offset += batch_len; + } + + Ok(all_runs) } pub(crate) async fn get_run_state(&self, run_id: &RunId) -> Result { diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index c10bb077d..cbd7dd8b6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -263,23 +263,27 @@ fn ps_uses_configured_server_target_without_server_flag() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": run_id, "workflow_name": "Remote Workflow", "workflow_slug": "remote-workflow", "goal": "Remote goal", + "title": "Remote goal", "labels": { "suite": "remote" }, "host_repo_path": "/srv/repo", + "repository": { "name": "repo" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "succeeded", "status_reason": null, "duration_ms": 123, "total_usd_micros": null - } - ]) + }], + "meta": { "has_more": false } + }) .to_string(), ); }); diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index 3b9a663d5..69e2129d6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -157,21 +157,25 @@ fn rm_force_removes_active_run() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": run_id, "workflow_name": "Active Workflow", "workflow_slug": "active-workflow", "goal": "Active goal", + "title": "Active goal", "labels": {}, "host_repo_path": null, + "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "running", "status_reason": null, "duration_ms": 123, "total_usd_micros": null - } - ]) + }], + "meta": { "has_more": false } + }) .to_string(), ); }); @@ -270,21 +274,25 @@ fn rm_uses_configured_server_target_without_local_run_dir() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": run_id, "workflow_name": "Remote Workflow", "workflow_slug": "remote-workflow", "goal": "Remote goal", + "title": "Remote goal", "labels": {}, "host_repo_path": null, + "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "succeeded", "status_reason": null, "duration_ms": 123, "total_usd_micros": null - } - ]) + }], + "meta": { "has_more": false } + }) .to_string(), ); }); diff --git a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs index ce54a6548..29ca459c7 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs @@ -238,21 +238,25 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": success_run_id, "workflow_name": "Remote Workflow", "workflow_slug": "remote-workflow", "goal": "Remote output", + "title": "Remote output", "labels": {}, "host_repo_path": null, + "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "running", "status_reason": null, "duration_ms": 12, "total_usd_micros": null - } - ]) + }], + "meta": { "has_more": false } + }) .to_string(), ); }); @@ -336,21 +340,25 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!([ - { + serde_json::json!({ + "data": [{ "run_id": eof_run_id, "workflow_name": "Remote Workflow", "workflow_slug": "remote-workflow", "goal": "Remote output", + "title": "Remote output", "labels": {}, "host_repo_path": null, + "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", + "created_at": "2026-04-05T12:00:00Z", "status": "running", "status_reason": null, "duration_ms": 12, - "total_cost": null - } - ]) + "total_usd_micros": null + }], + "meta": { "has_more": false } + }) .to_string(), ); }); diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 585ae8554..5e9b3fd69 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -4,7 +4,6 @@ #![allow(clippy::default_trait_access, clippy::unreadable_literal)] use std::sync::Arc; -use std::time::Duration; use axum::Json; use axum::extract::{Path, Query, State}; @@ -42,7 +41,7 @@ pub(crate) async fn list_runs( State(_state): State>, Query(pagination): Query, ) -> Response { - paginated_response(runs::list_items(), &pagination) + paginated_response(runs::summaries(), &pagination) } pub(crate) async fn list_board_runs( @@ -50,21 +49,19 @@ pub(crate) async fn list_board_runs( State(_state): State>, Query(pagination): Query, ) -> Response { - let items = runs::list_items(); + let items = runs::board_items(); let limit = pagination.limit.clamp(1, 100) as usize; let offset = pagination.offset as usize; let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect(); let has_more = data.len() > limit; data.truncate(limit); - let columns = json!([ - {"id": "working", "name": "Working"}, - {"id": "pending", "name": "Pending"}, - {"id": "review", "name": "Review"}, - {"id": "merge", "name": "Merge"}, - ]); ( StatusCode::OK, - Json(json!({ "columns": columns, "data": data, "meta": { "has_more": has_more } })), + Json(json!({ + "columns": runs::columns(), + "data": data, + "meta": { "has_more": has_more } + })), ) .into_response() } @@ -202,32 +199,8 @@ pub(crate) async fn get_run_status( State(_state): State>, Path(id): Path, ) -> Response { - match runs::list_items().into_iter().find(|r| r.id == id) { - Some(item) => { - let elapsed_ms = item - .timings - .as_ref() - .and_then(|t| Duration::try_from_secs_f64(t.elapsed_secs).ok()) - .and_then(|duration| u64::try_from(duration.as_millis()).ok()); - ( - StatusCode::OK, - Json(json!({ - "run_id": item.id, - "goal": item.title, - "workflow_slug": item.workflow.slug, - "workflow_name": item.workflow.slug, - "host_repo_path": format!("/demo/{}", item.repository.name), - "labels": {}, - "start_time": item.created_at.to_rfc3339(), - "status": "running", - "status_reason": null, - "pending_control": null, - "duration_ms": elapsed_ms, - "total_usd_micros": null, - })), - ) - .into_response() - } + match runs::summaries().into_iter().find(|run| run.run_id == id) { + Some(run) => (StatusCode::OK, Json(run)).into_response(), None => ApiError::not_found("Run not found.").into_response(), } } @@ -669,462 +642,310 @@ fn ts(s: &str) -> DateTime { } mod runs { + use std::collections::HashMap; + use fabro_api::types::*; use super::ts; - pub(super) fn list_items() -> Vec { + fn labels(entries: &[(&str, &str)]) -> HashMap { + entries + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + } + + fn summary( + run_id: &str, + repo_name: &str, + workflow_slug: &str, + workflow_name: &str, + goal: &str, + status: Option<&str>, + created_at: &str, + elapsed_secs: Option, + status_reason: Option<&str>, + pending_control: Option, + total_usd_micros: Option, + entries: &[(&str, &str)], + ) -> StoreRunSummary { + StoreRunSummary { + created_at: ts(created_at), + duration_ms: elapsed_secs.map(|secs| (secs * 1000.0).round() as i64), + elapsed_secs, + goal: goal.into(), + host_repo_path: Some(format!("/demo/{repo_name}")), + labels: labels(entries), + pending_control, + repository: RepositoryReference { + name: repo_name.into(), + }, + run_id: run_id.into(), + start_time: Some(ts(created_at)), + status: status.map(str::to_string), + status_reason: status_reason.map(str::to_string), + title: goal.into(), + total_usd_micros, + workflow_name: Some(workflow_name.into()), + workflow_slug: Some(workflow_slug.into()), + } + } + + fn take_summary( + summaries: &mut HashMap, + run_id: &str, + ) -> StoreRunSummary { + summaries + .remove(run_id) + .unwrap_or_else(|| panic!("missing demo summary: {run_id}")) + } + + fn board_item( + summary: StoreRunSummary, + column: BoardColumn, + pull_request: Option, + sandbox: Option, + question: Option, + ) -> RunListItem { + let status_reason = summary + .status_reason + .as_deref() + .and_then(|reason| StatusReason::try_from(reason).ok()); + + RunListItem { + column, + created_at: summary.created_at, + duration_ms: summary.duration_ms, + elapsed_secs: summary.elapsed_secs, + goal: summary.goal, + host_repo_path: summary.host_repo_path, + labels: summary.labels, + pending_control: summary.pending_control, + pull_request, + question, + repository: summary.repository, + run_id: summary.run_id, + sandbox, + start_time: summary.start_time, + status: summary.status.unwrap_or_default(), + status_reason, + title: summary.title, + total_usd_micros: summary.total_usd_micros, + workflow_name: summary.workflow_name, + workflow_slug: summary.workflow_slug, + } + } + + fn check(name: &str, status: CheckRunStatus, duration_secs: Option) -> CheckRun { + CheckRun { + name: name.into(), + status, + duration_secs, + } + } + + fn sandbox(id: &str, cpu: i64, memory: i64) -> RunSandbox { + RunSandbox { + id: id.into(), + resources: Some(SandboxResources { cpu, memory }), + } + } + + fn pull_request( + number: i64, + additions: i64, + deletions: i64, + comments: i64, + checks: Vec, + ) -> RunPullRequest { + RunPullRequest { + number, + additions: Some(additions), + deletions: Some(deletions), + comments: Some(comments), + checks, + } + } + + pub(super) fn columns() -> Vec { vec![ - RunListItem { - id: "run-1".into(), - repository: RepositoryReference { - name: "api-server".into(), - }, - title: "Add rate limiting to auth endpoints".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Working, - pull_request: None, - timings: Some(RunTimings { - elapsed_secs: 420.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-a1b2c3d4".into(), - resources: Some(SandboxResources { - cpu: 4, - memory: 8, - }), - }), - question: None, - created_at: ts("2026-03-06T14:30:00Z"), + BoardColumnDefinition { + id: "initializing".into(), + name: "Initializing".into(), }, - RunListItem { - id: "run-2".into(), - repository: RepositoryReference { - name: "web-dashboard".into(), - }, - title: "Migrate to React Router v7".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Working, - pull_request: None, - timings: Some(RunTimings { - elapsed_secs: 8100.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-e5f6g7h8".into(), - resources: Some(SandboxResources { - cpu: 8, - memory: 16, - }), - }), - question: None, - created_at: ts("2026-03-06T12:00:00Z"), + BoardColumnDefinition { + id: "running".into(), + name: "Running".into(), }, - RunListItem { - id: "run-3".into(), - repository: RepositoryReference { - name: "cli-tools".into(), - }, - title: "Fix config parsing for nested values".into(), - workflow: WorkflowReference { - slug: "fix_build".into(), - }, - status: BoardColumn::Working, - pull_request: None, - timings: Some(RunTimings { - elapsed_secs: 2700.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-i9j0k1l2".into(), - resources: Some(SandboxResources { - cpu: 2, - memory: 4, - }), - }), - question: None, - created_at: ts("2026-03-05T09:20:00Z"), + BoardColumnDefinition { + id: "waiting".into(), + name: "Waiting".into(), }, - RunListItem { - id: "run-4".into(), - repository: RepositoryReference { - name: "api-server".into(), - }, - title: "Update OpenAPI spec for v3".into(), - workflow: WorkflowReference { - slug: "expand".into(), - }, - status: BoardColumn::Initializing, - pull_request: Some(RunPullRequest { - number: 0, - additions: Some(567), - deletions: Some(234), - comments: Some(0), - checks: vec![], - }), - timings: Some(RunTimings { - elapsed_secs: 4320.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-q7r8s9t0".into(), - resources: None, - }), - question: Some(RunQuestion { + BoardColumnDefinition { + id: "succeeded".into(), + name: "Succeeded".into(), + }, + BoardColumnDefinition { + id: "failed".into(), + name: "Failed".into(), + }, + ] + } + + pub(super) fn summaries() -> Vec { + vec![ + summary( + "run-1", + "api-server", + "implement", + "Implement", + "Add rate limiting to auth endpoints", + Some("running"), + "2026-03-06T14:30:00Z", + Some(420.0), + None, + None, + None, + &[("branch", "rate-limit"), ("team", "platform")], + ), + summary( + "run-2", + "web-dashboard", + "implement", + "Implement", + "Migrate to React Router v7", + Some("running"), + "2026-03-06T12:00:00Z", + Some(8100.0), + None, + Some(RunControlAction::Pause), + None, + &[("owner", "frontend")], + ), + summary( + "run-3", + "shared-types", + "expand", + "Expand", + "Update OpenAPI spec for v3", + Some("starting"), + "2026-03-04T15:00:00Z", + Some(4320.0), + None, + None, + None, + &[("priority", "high")], + ), + summary( + "run-4", + "shared-types", + "implement", + "Implement", + "Add pipeline event types", + Some("paused"), + "2026-03-04T10:00:00Z", + Some(1680.0), + None, + None, + None, + &[("owner", "runtime")], + ), + summary( + "run-5", + "web-dashboard", + "implement", + "Implement", + "Add dark mode toggle", + Some("failed"), + "2026-03-03T16:45:00Z", + Some(2100.0), + Some("workflow_error"), + None, + None, + &[("environment", "staging")], + ), + summary( + "run-6", + "api-server", + "implement", + "Implement", + "Implement webhook retry logic", + Some("succeeded"), + "2026-02-28T14:00:00Z", + Some(259200.0), + Some("completed"), + None, + Some(720000), + &[("release", "preview")], + ), + ] + } + + pub(super) fn board_items() -> Vec { + let mut summaries = summaries() + .into_iter() + .map(|summary| (summary.run_id.clone(), summary)) + .collect::>(); + + vec![ + board_item( + take_summary(&mut summaries, "run-1"), + BoardColumn::Running, + None, + Some(sandbox("sb-a1b2c3d4", 4, 8)), + None, + ), + board_item( + take_summary(&mut summaries, "run-2"), + BoardColumn::Running, + None, + Some(sandbox("sb-e5f6g7h8", 8, 16)), + None, + ), + board_item( + take_summary(&mut summaries, "run-3"), + BoardColumn::Initializing, + Some(pull_request(0, 567, 234, 0, vec![])), + Some(sandbox("sb-q7r8s9t0", 4, 8)), + Some(RunQuestion { text: "Accept or push for another round?".into(), }), - created_at: ts("2026-03-04T15:00:00Z"), - }, - RunListItem { - id: "run-5".into(), - repository: RepositoryReference { - name: "shared-types".into(), - }, - title: "Add pipeline event types".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Initializing, - pull_request: Some(RunPullRequest { - number: 0, - additions: Some(145), - deletions: Some(23), - comments: Some(0), - checks: vec![], - }), - timings: Some(RunTimings { - elapsed_secs: 1680.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-u1v2w3x4".into(), - resources: None, - }), - question: Some(RunQuestion { + ), + board_item( + take_summary(&mut summaries, "run-4"), + BoardColumn::Waiting, + Some(pull_request(0, 145, 23, 0, vec![])), + Some(sandbox("sb-u1v2w3x4", 4, 8)), + Some(RunQuestion { text: "Proceed from investigation to fix?".into(), }), - created_at: ts("2026-03-04T10:00:00Z"), - }, - RunListItem { - id: "run-6".into(), - repository: RepositoryReference { - name: "web-dashboard".into(), - }, - title: "Add dark mode toggle".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Review, - pull_request: Some(RunPullRequest { - number: 889, - additions: Some(234), - deletions: Some(67), - comments: Some(4), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(23.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(72.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(154.0), - }, - CheckRun { - name: "integration-tests".into(), - status: CheckRunStatus::Failure, - duration_secs: Some(296.0), - }, - CheckRun { - name: "e2e / chrome".into(), - status: CheckRunStatus::Failure, - duration_secs: Some(182.0), - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Success, - duration_secs: Some(105.0), - }, - CheckRun { - name: "coverage".into(), - status: CheckRunStatus::Skipped, - duration_secs: None, - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 2100.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-m3n4o5p6".into(), - resources: None, - }), - question: None, - created_at: ts("2026-03-03T16:45:00Z"), - }, - RunListItem { - id: "run-7".into(), - repository: RepositoryReference { - name: "infrastructure".into(), - }, - title: "Terraform module for Redis cluster".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Review, - pull_request: Some(RunPullRequest { - number: 156, - additions: Some(412), - deletions: Some(0), - comments: Some(1), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(18.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(56.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Pending, - duration_secs: None, - }, - CheckRun { - name: "integration-tests".into(), - status: CheckRunStatus::Queued, - duration_secs: None, - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Pending, - duration_secs: None, - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 720.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-y5z6a7b8".into(), - resources: None, - }), - question: None, - created_at: ts("2026-03-03T11:00:00Z"), - }, - RunListItem { - id: "run-8".into(), - repository: RepositoryReference { - name: "api-server".into(), - }, - title: "Implement webhook retry logic".into(), - workflow: WorkflowReference { - slug: "implement".into(), - }, - status: BoardColumn::Merge, - pull_request: Some(RunPullRequest { - number: 1249, - additions: Some(189), - deletions: Some(45), - comments: Some(7), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(21.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(68.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(192.0), - }, - CheckRun { - name: "integration-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(334.0), - }, - CheckRun { - name: "e2e / chrome".into(), - status: CheckRunStatus::Success, - duration_secs: Some(262.0), - }, - CheckRun { - name: "e2e / firefox".into(), - status: CheckRunStatus::Success, - duration_secs: Some(285.0), - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Success, - duration_secs: Some(121.0), - }, - CheckRun { - name: "deploy-preview".into(), - status: CheckRunStatus::Success, - duration_secs: Some(93.0), - }, - CheckRun { - name: "security-scan".into(), - status: CheckRunStatus::Skipped, - duration_secs: None, - }, - CheckRun { - name: "performance".into(), - status: CheckRunStatus::Success, - duration_secs: Some(138.0), - }, - CheckRun { - name: "bundle-size".into(), - status: CheckRunStatus::Success, - duration_secs: Some(34.0), - }, - CheckRun { - name: "accessibility".into(), - status: CheckRunStatus::Success, - duration_secs: Some(72.0), - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 259200.0, - elapsed_warning: Some(true), - }), - sandbox: Some(RunSandbox { - id: "sb-c9d0e1f2".into(), - resources: None, - }), - question: None, - created_at: ts("2026-02-28T14:00:00Z"), - }, - RunListItem { - id: "run-9".into(), - repository: RepositoryReference { - name: "cli-tools".into(), - }, - title: "Add --verbose flag to run command".into(), - workflow: WorkflowReference { - slug: "expand".into(), - }, - status: BoardColumn::Merge, - pull_request: Some(RunPullRequest { - number: 430, - additions: Some(56), - deletions: Some(12), - comments: Some(2), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(15.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(48.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(116.0), - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Success, - duration_secs: Some(82.0), - }, - CheckRun { - name: "coverage".into(), - status: CheckRunStatus::Success, - duration_secs: Some(124.0), - }, - CheckRun { - name: "bundle-size".into(), - status: CheckRunStatus::Skipped, - duration_secs: None, - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 3900.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-g3h4i5j6".into(), - resources: None, - }), - question: None, - created_at: ts("2026-02-27T09:00:00Z"), - }, - RunListItem { - id: "run-10".into(), - repository: RepositoryReference { - name: "shared-types".into(), - }, - title: "Export utility type helpers".into(), - workflow: WorkflowReference { - slug: "sync_drift".into(), - }, - status: BoardColumn::Merge, - pull_request: Some(RunPullRequest { - number: 76, - additions: Some(34), - deletions: Some(8), - comments: Some(0), - checks: vec![ - CheckRun { - name: "lint".into(), - status: CheckRunStatus::Success, - duration_secs: Some(12.0), - }, - CheckRun { - name: "typecheck".into(), - status: CheckRunStatus::Success, - duration_secs: Some(34.0), - }, - CheckRun { - name: "unit-tests".into(), - status: CheckRunStatus::Success, - duration_secs: Some(75.0), - }, - CheckRun { - name: "build".into(), - status: CheckRunStatus::Success, - duration_secs: Some(58.0), - }, - ], - }), - timings: Some(RunTimings { - elapsed_secs: 2880.0, - elapsed_warning: Some(false), - }), - sandbox: Some(RunSandbox { - id: "sb-k7l8m9n0".into(), - resources: None, - }), - question: None, - created_at: ts("2026-02-26T08:00:00Z"), - }, + ), + board_item( + take_summary(&mut summaries, "run-5"), + BoardColumn::Failed, + Some(pull_request(889, 234, 67, 4, vec![ + check("lint", CheckRunStatus::Success, Some(23.0)), + check("typecheck", CheckRunStatus::Success, Some(72.0)), + check("unit-tests", CheckRunStatus::Success, Some(154.0)), + check("integration-tests", CheckRunStatus::Failure, Some(296.0)), + check("build", CheckRunStatus::Success, Some(105.0)), + ])), + None, + None, + ), + board_item( + take_summary(&mut summaries, "run-6"), + BoardColumn::Succeeded, + Some(pull_request(1249, 189, 45, 7, vec![ + check("lint", CheckRunStatus::Success, Some(21.0)), + check("typecheck", CheckRunStatus::Success, Some(68.0)), + check("unit-tests", CheckRunStatus::Success, Some(192.0)), + check("integration-tests", CheckRunStatus::Success, Some(334.0)), + check("deploy-preview", CheckRunStatus::Success, Some(93.0)), + ])), + None, + None, + ), ] } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 824978703..a11465725 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -73,6 +73,7 @@ use fabro_types::{ RunSubjectProvenance, }; use fabro_util::redact::redact_jsonl_line; +use fabro_util::text::strip_goal_decoration; use fabro_util::version::FABRO_VERSION; use fabro_vault::{Error as VaultError, SecretType, Vault}; use fabro_workflow::Error as WorkflowError; @@ -2578,6 +2579,65 @@ fn board_columns() -> serde_json::Value { ]) } +fn truncate_goal(goal: &str) -> String { + const MAX_LEN: usize = 100; + + let stripped = strip_goal_decoration(goal); + let char_count = stripped.chars().count(); + if char_count <= MAX_LEN { + return stripped.to_string(); + } + + let truncated: String = stripped.chars().take(MAX_LEN - 3).collect(); + format!("{truncated}...") +} + +fn repository_name(host_repo_path: Option<&str>) -> String { + host_repo_path + .and_then(|path| path.rsplit(['/', '\\']).find(|segment| !segment.is_empty())) + .unwrap_or("unknown") + .to_string() +} + +fn elapsed_secs(duration_ms: Option) -> Option { + duration_ms.map(|ms| ms as f64 / 1000.0) +} + +fn summary_to_api_run_summary(summary: fabro_store::RunSummary) -> serde_json::Value { + let goal = summary.goal.unwrap_or_default(); + let title = truncate_goal(&goal); + let repository = repository_name(summary.host_repo_path.as_deref()); + let created_at = summary.run_id.created_at().to_rfc3339(); + + serde_json::json!({ + "run_id": summary.run_id.to_string(), + "workflow_name": summary.workflow_name, + "workflow_slug": summary.workflow_slug, + "goal": goal, + "title": title, + "labels": summary.labels, + "host_repo_path": summary.host_repo_path, + "repository": { "name": repository }, + "start_time": summary.start_time.map(|time| time.to_rfc3339()), + "status": summary.status, + "status_reason": summary.status_reason.map(api_status_reason), + "pending_control": summary.pending_control.map(api_pending_control), + "duration_ms": summary.duration_ms, + "elapsed_secs": elapsed_secs(summary.duration_ms), + "total_usd_micros": summary.total_usd_micros, + "created_at": created_at, + }) +} + +fn paginate_items(items: Vec, pagination: PaginationParams) -> (Vec, bool) { + let limit = pagination.limit.clamp(1, 100) as usize; + let offset = pagination.offset as usize; + let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect(); + let has_more = data.len() > limit; + data.truncate(limit); + (data, has_more) +} + async fn list_board_runs( _auth: AuthenticatedService, State(state): State>, @@ -2599,32 +2659,12 @@ async fn list_board_runs( .filter_map(|summary| { let status = summary.status?; let column = board_column(status)?; - let title = summary.goal.as_deref().unwrap_or("Untitled run"); - let workflow_slug = summary.workflow_slug.as_deref().unwrap_or("unknown"); - let workflow_name = summary.workflow_name.as_deref().unwrap_or(workflow_slug); - let repo_name = summary - .host_repo_path - .as_deref() - .and_then(|p| p.rsplit('/').next()) - .unwrap_or("unknown"); - let elapsed_secs = summary.duration_ms.map(|ms| ms as f64 / 1000.0); - let created_at = summary.run_id.created_at(); - Some(serde_json::json!({ - "id": summary.run_id.to_string(), - "title": title, - "repository": { "name": repo_name }, - "workflow": { "slug": workflow_slug, "name": workflow_name }, - "status": column, - "created_at": created_at.to_rfc3339(), - "timings": elapsed_secs.map(|s| serde_json::json!({ "elapsed_secs": s })), - })) + let mut item = summary_to_api_run_summary(summary); + item["column"] = serde_json::json!(column); + Some(item) }) .collect(); - let limit = pagination.limit.clamp(1, 100) as usize; - let offset = pagination.offset as usize; - let page: Vec<_> = all_items.into_iter().skip(offset).take(limit + 1).collect(); - let has_more = page.len() > limit; - let data: Vec<_> = page.into_iter().take(limit).collect(); + let (data, has_more) = paginate_items(all_items, pagination); ( StatusCode::OK, Json(serde_json::json!({ @@ -2636,13 +2676,31 @@ async fn list_board_runs( .into_response() } -async fn list_runs(_auth: AuthenticatedService, State(state): State>) -> Response { +async fn list_runs( + _auth: AuthenticatedService, + State(state): State>, + Query(pagination): Query, +) -> Response { match state .store .list_runs(&fabro_store::ListRunsQuery::default()) .await { - Ok(runs) => (StatusCode::OK, Json(runs)).into_response(), + Ok(runs) => { + let items = runs + .into_iter() + .map(summary_to_api_run_summary) + .collect::>(); + let (data, has_more) = paginate_items(items, pagination); + ( + StatusCode::OK, + Json(serde_json::json!({ + "data": data, + "meta": { "has_more": has_more } + })), + ) + .into_response() + } Err(err) => { ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() } @@ -8288,7 +8346,8 @@ slug = "fabro" let response = app.clone().oneshot(req).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_json(response.into_body()).await; - assert_eq!(body.as_array().unwrap().len(), 0); + assert_eq!(body["data"].as_array().unwrap().len(), 0); + assert_eq!(body["meta"]["has_more"].as_bool(), Some(false)); // Start a run let req = Request::builder() @@ -8312,10 +8371,18 @@ slug = "fabro" let response = app.oneshot(req).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_json(response.into_body()).await; - let items = body.as_array().unwrap(); + let items = body["data"].as_array().unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0]["run_id"].as_str().unwrap(), run_id.to_string()); + assert!(items[0]["goal"].is_string()); + assert!(items[0]["title"].is_string()); + assert!(items[0]["repository"]["name"].is_string()); + assert!(items[0]["created_at"].is_string()); assert!(items[0]["status"].as_str().is_some()); + assert!(items[0]["labels"].is_object()); + assert!(items[0]["status_reason"].is_null()); + assert!(items[0]["pending_control"].is_null()); + assert!(items[0]["total_usd_micros"].is_null()); } #[tokio::test] @@ -8567,7 +8634,7 @@ level = "debug" .as_array() .unwrap() .iter() - .find(|item| item["id"].as_str() == Some(run_id_str.as_str())); + .find(|item| item["run_id"].as_str() == Some(run_id_str.as_str())); assert!( board_item.is_some(), "cancelled run should appear on the board" @@ -8575,8 +8642,9 @@ level = "debug" assert_eq!( board_item.unwrap()["status"].as_str(), Some("failed"), - "cancelled run should be in the failed column" + "cancelled run should preserve the failed lifecycle status" ); + assert_eq!(board_item.unwrap()["column"].as_str(), Some("failed")); let run_store = state.store.open_run_reader(&run_id).await.unwrap(); let status = run_store.state().await.unwrap().status.unwrap(); @@ -8692,9 +8760,11 @@ level = "debug" .as_array() .unwrap() .iter() - .find(|item| item["id"].as_str() == Some(run_id_str.as_str())) + .find(|item| item["run_id"].as_str() == Some(run_id_str.as_str())) .expect("board item should exist"); - assert_eq!(item["status"].as_str(), Some("initializing")); + assert!(item["status"].as_str().is_some()); + assert_eq!(item["column"].as_str(), Some("initializing")); + assert_eq!(item["pending_control"].as_str(), Some("pause")); } #[tokio::test] @@ -9081,11 +9151,14 @@ timeout = "30s" let data = body["data"].as_array().expect("data should be array"); assert!(!data.is_empty(), "demo should return runs"); let first = &data[0]; - assert!(first["id"].is_string()); + assert!(first["run_id"].is_string()); + assert!(first["goal"].is_string()); assert!(first["repository"].is_object()); assert!(first["title"].is_string()); - assert!(first["workflow"].is_object()); assert!(first["status"].is_string()); + assert!(first["column"].is_string()); + assert!(first["workflow_slug"].is_string() || first["workflow_slug"].is_null()); + assert!(first["labels"].is_object()); assert!(first["created_at"].is_string()); } @@ -9155,19 +9228,21 @@ timeout = "30s" let data = body["data"].as_array().expect("data should be array"); let item = data .iter() - .find(|i| i["id"].as_str() == Some(&run_id)) + .find(|i| i["run_id"].as_str() == Some(&run_id)) .expect("run should be in board"); - // Should have RunListItem fields + // Should have canonical run summary fields plus board-specific column + assert!(item["goal"].is_string()); assert!(item["title"].is_string()); assert!(item["repository"].is_object()); - assert!(item["workflow"].is_object()); - // Status should be a board column, not a lifecycle status - let status = item["status"].as_str().unwrap(); - assert!( - ["working", "initializing", "review", "merge"].contains(&status), - "status should be a board column, got: {status}" - ); + assert!(item["workflow_slug"].is_string() || item["workflow_slug"].is_null()); + assert!(item["workflow_name"].is_string() || item["workflow_name"].is_null()); + assert!(item["labels"].is_object()); + assert!(item["status"].is_string()); + assert!(item["column"].is_string()); assert!(item["created_at"].is_string()); + assert!(item["pending_control"].is_null()); + assert!(item["status_reason"].is_null()); + assert!(item["total_usd_micros"].is_null()); } #[tokio::test] @@ -9199,7 +9274,7 @@ timeout = "30s" let data = body["data"].as_array().expect("data should be array"); let found = data .iter() - .any(|i| i["id"].as_str() == Some(&run_id.to_string())); + .any(|i| i["run_id"].as_str() == Some(&run_id.to_string())); assert!(!found, "removing run should not appear on the board"); } @@ -9252,15 +9327,17 @@ timeout = "30s" let paused_item = data .iter() - .find(|i| i["id"].as_str() == Some(&paused_id.to_string())) + .find(|i| i["run_id"].as_str() == Some(&paused_id.to_string())) .expect("paused run should be on board"); - assert_eq!(paused_item["status"].as_str().unwrap(), "waiting"); + assert_eq!(paused_item["status"].as_str().unwrap(), "paused"); + assert_eq!(paused_item["column"].as_str().unwrap(), "waiting"); let succeeded_item = data .iter() - .find(|i| i["id"].as_str() == Some(&succeeded_id.to_string())) + .find(|i| i["run_id"].as_str() == Some(&succeeded_id.to_string())) .expect("succeeded run should be on board"); assert_eq!(succeeded_item["status"].as_str().unwrap(), "succeeded"); + assert_eq!(succeeded_item["column"].as_str().unwrap(), "succeeded"); // Verify columns are included in the response let columns = body["columns"].as_array().expect("columns should be array"); diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 404f03203..4bb71a1da 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -31,6 +31,7 @@ models/assistant-stage-turn.ts models/billed-token-counts.ts models/billing-by-model.ts models/billing-stage-ref.ts +models/board-column-definition.ts models/board-column.ts models/check-run-status.ts models/check-run.ts @@ -86,6 +87,7 @@ models/model.ts models/node-state.ts models/node-status-record.ts models/paginated-api-question-list.ts +models/paginated-board-run-list.ts models/paginated-event-list.ts models/paginated-history-entry-list.ts models/paginated-model-list.ts diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index ceb9421fa..302b201a2 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -24,6 +24,8 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError // @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore +import type { PaginatedBoardRunList } from '../models'; +// @ts-ignore import type { PaginatedRunList } from '../models'; // @ts-ignore import type { PreflightResponse } from '../models'; @@ -212,10 +214,12 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) /** * Returns durable run summaries from the backing store, including runs persisted before the current server boot. * @summary List Runs + * @param {number} [pageLimit] Maximum number of items to return per page. + * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRuns: async (options: RawAxiosRequestConfig = {}): Promise => { + listRuns: async (pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise => { const localVarPath = `/api/v1/runs`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -234,6 +238,14 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (pageLimit !== undefined) { + localVarQueryParameter['page[limit]'] = pageLimit; + } + + if (pageOffset !== undefined) { + localVarQueryParameter['page[offset]'] = pageOffset; + } + localVarHeaderParameter['Accept'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -586,7 +598,7 @@ export const RunsApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listBoardRuns(pageLimit, pageOffset, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunsApi.listBoardRuns']?.[localVarOperationServerIndex]?.url; @@ -595,11 +607,13 @@ export const RunsApiFp = function(configuration?: Configuration) { /** * Returns durable run summaries from the backing store, including runs persisted before the current server boot. * @summary List Runs + * @param {number} [pageLimit] Maximum number of items to return per page. + * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listRuns(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(options); + async listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunsApi.listRuns']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -743,17 +757,19 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { + listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.listBoardRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath)); }, /** * Returns durable run summaries from the backing store, including runs persisted before the current server boot. * @summary List Runs + * @param {number} [pageLimit] Maximum number of items to return per page. + * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRuns(options?: RawAxiosRequestConfig): AxiosPromise> { - return localVarFp.listRuns(options).then((request) => request(axios, basePath)); + listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath)); }, /** * Pauses a running run. Returns 409 if the run is not running. @@ -881,11 +897,13 @@ export class RunsApi extends BaseAPI { /** * Returns durable run summaries from the backing store, including runs persisted before the current server boot. * @summary List Runs + * @param {number} [pageLimit] Maximum number of items to return per page. + * @param {number} [pageOffset] Number of items to skip before returning results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public listRuns(options?: RawAxiosRequestConfig) { - return RunsApiFp(this.configuration).listRuns(options).then((request) => request(this.axios, this.basePath)); + public listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/lib/packages/fabro-api-client/src/models/board-column-definition.ts b/lib/packages/fabro-api-client/src/models/board-column-definition.ts new file mode 100644 index 000000000..f1730a16c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/board-column-definition.ts @@ -0,0 +1,21 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface BoardColumnDefinition { + 'id': string; + 'name': string; +} + diff --git a/lib/packages/fabro-api-client/src/models/board-column.ts b/lib/packages/fabro-api-client/src/models/board-column.ts index 24385ce4b..7de1d800a 100644 --- a/lib/packages/fabro-api-client/src/models/board-column.ts +++ b/lib/packages/fabro-api-client/src/models/board-column.ts @@ -19,10 +19,11 @@ */ export const BoardColumn = { - WORKING: 'working', INITIALIZING: 'initializing', - REVIEW: 'review', - MERGE: 'merge' + RUNNING: 'running', + WAITING: 'waiting', + SUCCEEDED: 'succeeded', + FAILED: 'failed' } as const; export type BoardColumn = typeof BoardColumn[keyof typeof BoardColumn]; diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index ef42d8cb7..002634357 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -14,6 +14,7 @@ export * from './billed-token-counts'; export * from './billing-by-model'; export * from './billing-stage-ref'; export * from './board-column'; +export * from './board-column-definition'; export * from './check-run'; export * from './check-run-status'; export * from './code-location'; @@ -67,6 +68,7 @@ export * from './model-test-result'; export * from './node-state'; export * from './node-status-record'; export * from './paginated-api-question-list'; +export * from './paginated-board-run-list'; export * from './paginated-event-list'; export * from './paginated-history-entry-list'; export * from './paginated-model-list'; diff --git a/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts b/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts new file mode 100644 index 000000000..42335c819 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts @@ -0,0 +1,34 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { BoardColumnDefinition } from './board-column-definition'; +// May contain unused imports in some cases +// @ts-ignore +import type { PaginationMeta } from './pagination-meta'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunListItem } from './run-list-item'; + +/** + * Paginated list of board runs with shared canonical fields plus board metadata. + */ +export interface PaginatedBoardRunList { + 'columns': Array; + 'data': Array; + 'meta': PaginationMeta; +} + diff --git a/lib/packages/fabro-api-client/src/models/paginated-run-list.ts b/lib/packages/fabro-api-client/src/models/paginated-run-list.ts index b0c4b1675..da1f937ac 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-run-list.ts +++ b/lib/packages/fabro-api-client/src/models/paginated-run-list.ts @@ -18,13 +18,13 @@ import type { PaginationMeta } from './pagination-meta'; // May contain unused imports in some cases // @ts-ignore -import type { RunListItem } from './run-list-item'; +import type { StoreRunSummary } from './store-run-summary'; /** * Paginated list of runs. */ export interface PaginatedRunList { - 'data': Array; + 'data': Array; 'meta': PaginationMeta; } diff --git a/lib/packages/fabro-api-client/src/models/run-list-item.ts b/lib/packages/fabro-api-client/src/models/run-list-item.ts index e4f166bac..cfadd64f3 100644 --- a/lib/packages/fabro-api-client/src/models/run-list-item.ts +++ b/lib/packages/fabro-api-client/src/models/run-list-item.ts @@ -21,6 +21,9 @@ import type { BoardColumn } from './board-column'; import type { RepositoryReference } from './repository-reference'; // May contain unused imports in some cases // @ts-ignore +import type { RunControlAction } from './run-control-action'; +// May contain unused imports in some cases +// @ts-ignore import type { RunPullRequest } from './run-pull-request'; // May contain unused imports in some cases // @ts-ignore @@ -30,28 +33,35 @@ import type { RunQuestion } from './run-question'; import type { RunSandbox } from './run-sandbox'; // May contain unused imports in some cases // @ts-ignore -import type { RunTimings } from './run-timings'; -// May contain unused imports in some cases -// @ts-ignore -import type { WorkflowReference } from './workflow-reference'; +import type { StatusReason } from './status-reason'; /** - * Summary of a run shown in the board view. + * Canonical run summary shown in the board view, extended with board-specific metadata. */ export interface RunListItem { /** * Unique run identifier (ULID). */ - 'id': string; + 'run_id': string; + 'workflow_name'?: string | null; + 'workflow_slug'?: string | null; + 'goal': string; 'repository': RepositoryReference; /** * Human-readable title describing the run\'s goal. */ 'title': string; - 'workflow': WorkflowReference; - 'status': BoardColumn; + 'status': string; + 'labels': { [key: string]: string; }; + 'host_repo_path'?: string | null; + 'start_time'?: string | null; + 'status_reason'?: StatusReason | null; + 'pending_control'?: RunControlAction | null; + 'duration_ms'?: number | null; + 'elapsed_secs'?: number | null; + 'total_usd_micros'?: number | null; + 'column': BoardColumn; 'pull_request'?: RunPullRequest; - 'timings'?: RunTimings; 'sandbox'?: RunSandbox; 'question'?: RunQuestion; /** diff --git a/lib/packages/fabro-api-client/src/models/store-run-summary.ts b/lib/packages/fabro-api-client/src/models/store-run-summary.ts index 5c520dfa7..00aa48e92 100644 --- a/lib/packages/fabro-api-client/src/models/store-run-summary.ts +++ b/lib/packages/fabro-api-client/src/models/store-run-summary.ts @@ -13,6 +13,9 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { RepositoryReference } from './repository-reference'; // May contain unused imports in some cases // @ts-ignore import type { RunControlAction } from './run-control-action'; @@ -24,14 +27,18 @@ export interface StoreRunSummary { 'run_id': string; 'workflow_name'?: string | null; 'workflow_slug'?: string | null; - 'goal'?: string | null; + 'goal': string; + 'title': string; 'labels': { [key: string]: string; }; 'host_repo_path'?: string | null; + 'repository': RepositoryReference; 'start_time'?: string | null; + 'created_at': string; 'status'?: string | null; 'status_reason'?: string | null; 'pending_control'?: RunControlAction | null; 'duration_ms'?: number | null; + 'elapsed_secs'?: number | null; 'total_usd_micros'?: number | null; }