feat(runs): canonicalize paginated run list responses

Unify /api/v1/runs and /api/v1/boards/runs around a shared
paginated summary contract with additive convenience fields.

Update the server, demo data, generated clients, CLI pagination,
and web consumers so board views become a thin projection over the
canonical run summary surface.
This commit is contained in:
Bryan Helmkamp 2026-04-19 10:37:26 -04:00
parent 7933e1ed5c
commit 8ab689da78
No known key found for this signature in database
20 changed files with 794 additions and 657 deletions

View file

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

View file

@ -1,5 +1,5 @@
import { formatElapsedSecs, formatDurationSecs } from "../lib/format";
import type { RunListItem } from "@qltysh/fabro-api-client";
import type { RunListItem, StoreRunSummary } from "@qltysh/fabro-api-client";
export type CiStatus = "passing" | "failing" | "pending";
@ -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<ColumnStatus, string> = {
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<string, string>;
start_time: string | null;
}
export type RunSummaryResponse = StoreRunSummary;
export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem {
const repoPath = summary.host_repo_path ?? "";
const repoName = repoPath.split("/").pop() || "unknown";
return {
id: summary.run_id,
repo: repoName,
title: summary.goal ? truncateGoal(summary.goal) : "Untitled run",
workflow: summary.workflow_slug ?? "unknown",
repo: summary.repository.name,
title: 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<ColumnStatus, { dot: string; text: string }> = {
working: { dot: "bg-teal-500", text: "text-teal-500" },
initializing: { dot: "bg-amber", text: "text-amber" },
review: { dot: "bg-mint", text: "text-mint" },
merge: { dot: "bg-teal-300", text: "text-teal-300" },
running: { dot: "bg-teal-500", text: "text-teal-500" },
waiting: { dot: "bg-amber", text: "text-amber" },
succeeded: { dot: "bg-teal-300", text: "text-teal-300" },

View file

@ -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<string, ColumnStyle> = {
working: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] },
initializing: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: [] },
review: { accent: "bg-mint", iconColor: "text-mint", iconType: "pr", actions: [] },
merge: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] },
running: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] },
waiting: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: ["Answer Question"] },
succeeded: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: [] },
@ -49,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));
}
}

View file

@ -1,7 +1,7 @@
import { useState } from "react";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { Link, useParams } from "react-router";
import { ciConfig, columnNames, deriveCiStatus, mapRunListItem, statusColors } from "../data/runs";
import { ciConfig, columnNames, columnForStatus, deriveCiStatus, mapRunSummaryToRunItem, statusColors } from "../data/runs";
import type { ColumnStatus, RunWithStatus } from "../data/runs";
import { apiJsonOrNull } from "../api";
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
@ -10,9 +10,9 @@ export async function loader({ request, params }: any) {
const result = await apiJsonOrNull<PaginatedRunList>(`/workflows/${params.name}/runs`, { request });
const apiRuns = result?.data ?? [];
const runs: RunWithStatus[] = apiRuns.map((r) => ({
...mapRunListItem(r),
status: r.status as ColumnStatus,
statusLabel: columnNames[r.status as ColumnStatus] ?? r.status,
...mapRunSummaryToRunItem(r),
status: columnForStatus(r.status),
statusLabel: columnNames[columnForStatus(r.status)],
}));
return { runs };
}

View file

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

View file

@ -589,17 +589,35 @@ impl ServerStoreClient {
}
pub(crate) async fn list_store_runs(&self) -> Result<Vec<RunSummary>> {
let response = self
.client
.list_runs()
.send()
.await
.map_err(map_api_error)?;
response
.into_inner()
.into_iter()
.map(convert_type)
.collect::<Result<Vec<_>>>()
let mut all_runs = Vec::new();
let mut offset = 0_u64;
let limit = 100_u64;
loop {
let response = self
.client
.list_runs()
.page_limit(limit)
.page_offset(offset)
.send()
.await
.map_err(map_api_error)?;
let parsed = response.into_inner();
let batch = parsed
.data
.into_iter()
.map(convert_type)
.collect::<Result<Vec<_>>>()?;
let batch_len = batch.len() as u64;
all_runs.extend(batch);
if !parsed.meta.has_more || batch_len == 0 {
break;
}
offset += batch_len;
}
Ok(all_runs)
}
pub(crate) async fn get_run_state(&self, run_id: &RunId) -> Result<RunProjection> {

View file

@ -263,23 +263,27 @@ fn ps_uses_configured_server_target_without_server_flag() {
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!([
{
serde_json::json!({
"data": [{
"run_id": run_id,
"workflow_name": "Remote Workflow",
"workflow_slug": "remote-workflow",
"goal": "Remote goal",
"title": "Remote goal",
"labels": {
"suite": "remote"
},
"host_repo_path": "/srv/repo",
"repository": { "name": "repo" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "succeeded",
"status_reason": null,
"duration_ms": 123,
"total_usd_micros": null
}
])
}],
"meta": { "has_more": false }
})
.to_string(),
);
});

View file

@ -157,21 +157,25 @@ fn rm_force_removes_active_run() {
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!([
{
serde_json::json!({
"data": [{
"run_id": run_id,
"workflow_name": "Active Workflow",
"workflow_slug": "active-workflow",
"goal": "Active goal",
"title": "Active goal",
"labels": {},
"host_repo_path": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "running",
"status_reason": null,
"duration_ms": 123,
"total_usd_micros": null
}
])
}],
"meta": { "has_more": false }
})
.to_string(),
);
});
@ -270,21 +274,25 @@ fn rm_uses_configured_server_target_without_local_run_dir() {
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!([
{
serde_json::json!({
"data": [{
"run_id": run_id,
"workflow_name": "Remote Workflow",
"workflow_slug": "remote-workflow",
"goal": "Remote goal",
"title": "Remote goal",
"labels": {},
"host_repo_path": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "succeeded",
"status_reason": null,
"duration_ms": 123,
"total_usd_micros": null
}
])
}],
"meta": { "has_more": false }
})
.to_string(),
);
});

View file

@ -238,21 +238,25 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!([
{
serde_json::json!({
"data": [{
"run_id": success_run_id,
"workflow_name": "Remote Workflow",
"workflow_slug": "remote-workflow",
"goal": "Remote output",
"title": "Remote output",
"labels": {},
"host_repo_path": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "running",
"status_reason": null,
"duration_ms": 12,
"total_usd_micros": null
}
])
}],
"meta": { "has_more": false }
})
.to_string(),
);
});
@ -336,21 +340,25 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!([
{
serde_json::json!({
"data": [{
"run_id": eof_run_id,
"workflow_name": "Remote Workflow",
"workflow_slug": "remote-workflow",
"goal": "Remote output",
"title": "Remote output",
"labels": {},
"host_repo_path": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
"status": "running",
"status_reason": null,
"duration_ms": 12,
"total_cost": null
}
])
"total_usd_micros": null
}],
"meta": { "has_more": false }
})
.to_string(),
);
});

View file

@ -4,7 +4,6 @@
#![allow(clippy::default_trait_access, clippy::unreadable_literal)]
use std::sync::Arc;
use std::time::Duration;
use axum::Json;
use axum::extract::{Path, Query, State};
@ -42,7 +41,7 @@ pub(crate) async fn list_runs(
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
paginated_response(runs::list_items(), &pagination)
paginated_response(runs::summaries(), &pagination)
}
pub(crate) async fn list_board_runs(
@ -50,21 +49,19 @@ pub(crate) async fn list_board_runs(
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
let items = runs::list_items();
let items = runs::board_items();
let limit = pagination.limit.clamp(1, 100) as usize;
let offset = pagination.offset as usize;
let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect();
let has_more = data.len() > limit;
data.truncate(limit);
let columns = json!([
{"id": "working", "name": "Working"},
{"id": "pending", "name": "Pending"},
{"id": "review", "name": "Review"},
{"id": "merge", "name": "Merge"},
]);
(
StatusCode::OK,
Json(json!({ "columns": columns, "data": data, "meta": { "has_more": has_more } })),
Json(json!({
"columns": runs::columns(),
"data": data,
"meta": { "has_more": has_more }
})),
)
.into_response()
}
@ -202,32 +199,8 @@ pub(crate) async fn get_run_status(
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
match runs::list_items().into_iter().find(|r| r.id == id) {
Some(item) => {
let elapsed_ms = item
.timings
.as_ref()
.and_then(|t| Duration::try_from_secs_f64(t.elapsed_secs).ok())
.and_then(|duration| u64::try_from(duration.as_millis()).ok());
(
StatusCode::OK,
Json(json!({
"run_id": item.id,
"goal": item.title,
"workflow_slug": item.workflow.slug,
"workflow_name": item.workflow.slug,
"host_repo_path": format!("/demo/{}", item.repository.name),
"labels": {},
"start_time": item.created_at.to_rfc3339(),
"status": "running",
"status_reason": null,
"pending_control": null,
"duration_ms": elapsed_ms,
"total_usd_micros": null,
})),
)
.into_response()
}
match runs::summaries().into_iter().find(|run| run.run_id == id) {
Some(run) => (StatusCode::OK, Json(run)).into_response(),
None => ApiError::not_found("Run not found.").into_response(),
}
}
@ -669,462 +642,310 @@ fn ts(s: &str) -> DateTime<Utc> {
}
mod runs {
use std::collections::HashMap;
use fabro_api::types::*;
use super::ts;
pub(super) fn list_items() -> Vec<RunListItem> {
fn labels(entries: &[(&str, &str)]) -> HashMap<String, String> {
entries
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect()
}
fn summary(
run_id: &str,
repo_name: &str,
workflow_slug: &str,
workflow_name: &str,
goal: &str,
status: Option<&str>,
created_at: &str,
elapsed_secs: Option<f64>,
status_reason: Option<&str>,
pending_control: Option<RunControlAction>,
total_usd_micros: Option<i64>,
entries: &[(&str, &str)],
) -> StoreRunSummary {
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<String, StoreRunSummary>,
run_id: &str,
) -> StoreRunSummary {
summaries
.remove(run_id)
.unwrap_or_else(|| panic!("missing demo summary: {run_id}"))
}
fn board_item(
summary: StoreRunSummary,
column: BoardColumn,
pull_request: Option<RunPullRequest>,
sandbox: Option<RunSandbox>,
question: Option<RunQuestion>,
) -> RunListItem {
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<f64>) -> CheckRun {
CheckRun {
name: name.into(),
status,
duration_secs,
}
}
fn sandbox(id: &str, cpu: i64, memory: i64) -> RunSandbox {
RunSandbox {
id: id.into(),
resources: Some(SandboxResources { cpu, memory }),
}
}
fn pull_request(
number: i64,
additions: i64,
deletions: i64,
comments: i64,
checks: Vec<CheckRun>,
) -> RunPullRequest {
RunPullRequest {
number,
additions: Some(additions),
deletions: Some(deletions),
comments: Some(comments),
checks,
}
}
pub(super) fn columns() -> Vec<BoardColumnDefinition> {
vec![
RunListItem {
id: "run-1".into(),
repository: RepositoryReference {
name: "api-server".into(),
},
title: "Add rate limiting to auth endpoints".into(),
workflow: WorkflowReference {
slug: "implement".into(),
},
status: BoardColumn::Working,
pull_request: None,
timings: Some(RunTimings {
elapsed_secs: 420.0,
elapsed_warning: Some(false),
}),
sandbox: Some(RunSandbox {
id: "sb-a1b2c3d4".into(),
resources: Some(SandboxResources {
cpu: 4,
memory: 8,
}),
}),
question: None,
created_at: ts("2026-03-06T14:30:00Z"),
BoardColumnDefinition {
id: "initializing".into(),
name: "Initializing".into(),
},
RunListItem {
id: "run-2".into(),
repository: RepositoryReference {
name: "web-dashboard".into(),
},
title: "Migrate to React Router v7".into(),
workflow: WorkflowReference {
slug: "implement".into(),
},
status: BoardColumn::Working,
pull_request: None,
timings: Some(RunTimings {
elapsed_secs: 8100.0,
elapsed_warning: Some(false),
}),
sandbox: Some(RunSandbox {
id: "sb-e5f6g7h8".into(),
resources: Some(SandboxResources {
cpu: 8,
memory: 16,
}),
}),
question: None,
created_at: ts("2026-03-06T12:00:00Z"),
BoardColumnDefinition {
id: "running".into(),
name: "Running".into(),
},
RunListItem {
id: "run-3".into(),
repository: RepositoryReference {
name: "cli-tools".into(),
},
title: "Fix config parsing for nested values".into(),
workflow: WorkflowReference {
slug: "fix_build".into(),
},
status: BoardColumn::Working,
pull_request: None,
timings: Some(RunTimings {
elapsed_secs: 2700.0,
elapsed_warning: Some(false),
}),
sandbox: Some(RunSandbox {
id: "sb-i9j0k1l2".into(),
resources: Some(SandboxResources {
cpu: 2,
memory: 4,
}),
}),
question: None,
created_at: ts("2026-03-05T09:20:00Z"),
BoardColumnDefinition {
id: "waiting".into(),
name: "Waiting".into(),
},
RunListItem {
id: "run-4".into(),
repository: RepositoryReference {
name: "api-server".into(),
},
title: "Update OpenAPI spec for v3".into(),
workflow: WorkflowReference {
slug: "expand".into(),
},
status: BoardColumn::Initializing,
pull_request: Some(RunPullRequest {
number: 0,
additions: Some(567),
deletions: Some(234),
comments: Some(0),
checks: vec![],
}),
timings: Some(RunTimings {
elapsed_secs: 4320.0,
elapsed_warning: Some(false),
}),
sandbox: Some(RunSandbox {
id: "sb-q7r8s9t0".into(),
resources: None,
}),
question: Some(RunQuestion {
BoardColumnDefinition {
id: "succeeded".into(),
name: "Succeeded".into(),
},
BoardColumnDefinition {
id: "failed".into(),
name: "Failed".into(),
},
]
}
pub(super) fn summaries() -> Vec<StoreRunSummary> {
vec![
summary(
"run-1",
"api-server",
"implement",
"Implement",
"Add rate limiting to auth endpoints",
Some("running"),
"2026-03-06T14:30:00Z",
Some(420.0),
None,
None,
None,
&[("branch", "rate-limit"), ("team", "platform")],
),
summary(
"run-2",
"web-dashboard",
"implement",
"Implement",
"Migrate to React Router v7",
Some("running"),
"2026-03-06T12:00:00Z",
Some(8100.0),
None,
Some(RunControlAction::Pause),
None,
&[("owner", "frontend")],
),
summary(
"run-3",
"shared-types",
"expand",
"Expand",
"Update OpenAPI spec for v3",
Some("starting"),
"2026-03-04T15:00:00Z",
Some(4320.0),
None,
None,
None,
&[("priority", "high")],
),
summary(
"run-4",
"shared-types",
"implement",
"Implement",
"Add pipeline event types",
Some("paused"),
"2026-03-04T10:00:00Z",
Some(1680.0),
None,
None,
None,
&[("owner", "runtime")],
),
summary(
"run-5",
"web-dashboard",
"implement",
"Implement",
"Add dark mode toggle",
Some("failed"),
"2026-03-03T16:45:00Z",
Some(2100.0),
Some("workflow_error"),
None,
None,
&[("environment", "staging")],
),
summary(
"run-6",
"api-server",
"implement",
"Implement",
"Implement webhook retry logic",
Some("succeeded"),
"2026-02-28T14:00:00Z",
Some(259200.0),
Some("completed"),
None,
Some(720000),
&[("release", "preview")],
),
]
}
pub(super) fn board_items() -> Vec<RunListItem> {
let mut summaries = summaries()
.into_iter()
.map(|summary| (summary.run_id.clone(), summary))
.collect::<HashMap<_, _>>();
vec![
board_item(
take_summary(&mut summaries, "run-1"),
BoardColumn::Running,
None,
Some(sandbox("sb-a1b2c3d4", 4, 8)),
None,
),
board_item(
take_summary(&mut summaries, "run-2"),
BoardColumn::Running,
None,
Some(sandbox("sb-e5f6g7h8", 8, 16)),
None,
),
board_item(
take_summary(&mut summaries, "run-3"),
BoardColumn::Initializing,
Some(pull_request(0, 567, 234, 0, vec![])),
Some(sandbox("sb-q7r8s9t0", 4, 8)),
Some(RunQuestion {
text: "Accept or push for another round?".into(),
}),
created_at: ts("2026-03-04T15:00:00Z"),
},
RunListItem {
id: "run-5".into(),
repository: RepositoryReference {
name: "shared-types".into(),
},
title: "Add pipeline event types".into(),
workflow: WorkflowReference {
slug: "implement".into(),
},
status: BoardColumn::Initializing,
pull_request: Some(RunPullRequest {
number: 0,
additions: Some(145),
deletions: Some(23),
comments: Some(0),
checks: vec![],
}),
timings: Some(RunTimings {
elapsed_secs: 1680.0,
elapsed_warning: Some(false),
}),
sandbox: Some(RunSandbox {
id: "sb-u1v2w3x4".into(),
resources: None,
}),
question: Some(RunQuestion {
),
board_item(
take_summary(&mut summaries, "run-4"),
BoardColumn::Waiting,
Some(pull_request(0, 145, 23, 0, vec![])),
Some(sandbox("sb-u1v2w3x4", 4, 8)),
Some(RunQuestion {
text: "Proceed from investigation to fix?".into(),
}),
created_at: ts("2026-03-04T10:00:00Z"),
},
RunListItem {
id: "run-6".into(),
repository: RepositoryReference {
name: "web-dashboard".into(),
},
title: "Add dark mode toggle".into(),
workflow: WorkflowReference {
slug: "implement".into(),
},
status: BoardColumn::Review,
pull_request: Some(RunPullRequest {
number: 889,
additions: Some(234),
deletions: Some(67),
comments: Some(4),
checks: vec![
CheckRun {
name: "lint".into(),
status: CheckRunStatus::Success,
duration_secs: Some(23.0),
},
CheckRun {
name: "typecheck".into(),
status: CheckRunStatus::Success,
duration_secs: Some(72.0),
},
CheckRun {
name: "unit-tests".into(),
status: CheckRunStatus::Success,
duration_secs: Some(154.0),
},
CheckRun {
name: "integration-tests".into(),
status: CheckRunStatus::Failure,
duration_secs: Some(296.0),
},
CheckRun {
name: "e2e / chrome".into(),
status: CheckRunStatus::Failure,
duration_secs: Some(182.0),
},
CheckRun {
name: "build".into(),
status: CheckRunStatus::Success,
duration_secs: Some(105.0),
},
CheckRun {
name: "coverage".into(),
status: CheckRunStatus::Skipped,
duration_secs: None,
},
],
}),
timings: Some(RunTimings {
elapsed_secs: 2100.0,
elapsed_warning: Some(false),
}),
sandbox: Some(RunSandbox {
id: "sb-m3n4o5p6".into(),
resources: None,
}),
question: None,
created_at: ts("2026-03-03T16:45:00Z"),
},
RunListItem {
id: "run-7".into(),
repository: RepositoryReference {
name: "infrastructure".into(),
},
title: "Terraform module for Redis cluster".into(),
workflow: WorkflowReference {
slug: "implement".into(),
},
status: BoardColumn::Review,
pull_request: Some(RunPullRequest {
number: 156,
additions: Some(412),
deletions: Some(0),
comments: Some(1),
checks: vec![
CheckRun {
name: "lint".into(),
status: CheckRunStatus::Success,
duration_secs: Some(18.0),
},
CheckRun {
name: "typecheck".into(),
status: CheckRunStatus::Success,
duration_secs: Some(56.0),
},
CheckRun {
name: "unit-tests".into(),
status: CheckRunStatus::Pending,
duration_secs: None,
},
CheckRun {
name: "integration-tests".into(),
status: CheckRunStatus::Queued,
duration_secs: None,
},
CheckRun {
name: "build".into(),
status: CheckRunStatus::Pending,
duration_secs: None,
},
],
}),
timings: Some(RunTimings {
elapsed_secs: 720.0,
elapsed_warning: Some(false),
}),
sandbox: Some(RunSandbox {
id: "sb-y5z6a7b8".into(),
resources: None,
}),
question: None,
created_at: ts("2026-03-03T11:00:00Z"),
},
RunListItem {
id: "run-8".into(),
repository: RepositoryReference {
name: "api-server".into(),
},
title: "Implement webhook retry logic".into(),
workflow: WorkflowReference {
slug: "implement".into(),
},
status: BoardColumn::Merge,
pull_request: Some(RunPullRequest {
number: 1249,
additions: Some(189),
deletions: Some(45),
comments: Some(7),
checks: vec![
CheckRun {
name: "lint".into(),
status: CheckRunStatus::Success,
duration_secs: Some(21.0),
},
CheckRun {
name: "typecheck".into(),
status: CheckRunStatus::Success,
duration_secs: Some(68.0),
},
CheckRun {
name: "unit-tests".into(),
status: CheckRunStatus::Success,
duration_secs: Some(192.0),
},
CheckRun {
name: "integration-tests".into(),
status: CheckRunStatus::Success,
duration_secs: Some(334.0),
},
CheckRun {
name: "e2e / chrome".into(),
status: CheckRunStatus::Success,
duration_secs: Some(262.0),
},
CheckRun {
name: "e2e / firefox".into(),
status: CheckRunStatus::Success,
duration_secs: Some(285.0),
},
CheckRun {
name: "build".into(),
status: CheckRunStatus::Success,
duration_secs: Some(121.0),
},
CheckRun {
name: "deploy-preview".into(),
status: CheckRunStatus::Success,
duration_secs: Some(93.0),
},
CheckRun {
name: "security-scan".into(),
status: CheckRunStatus::Skipped,
duration_secs: None,
},
CheckRun {
name: "performance".into(),
status: CheckRunStatus::Success,
duration_secs: Some(138.0),
},
CheckRun {
name: "bundle-size".into(),
status: CheckRunStatus::Success,
duration_secs: Some(34.0),
},
CheckRun {
name: "accessibility".into(),
status: CheckRunStatus::Success,
duration_secs: Some(72.0),
},
],
}),
timings: Some(RunTimings {
elapsed_secs: 259200.0,
elapsed_warning: Some(true),
}),
sandbox: Some(RunSandbox {
id: "sb-c9d0e1f2".into(),
resources: None,
}),
question: None,
created_at: ts("2026-02-28T14:00:00Z"),
},
RunListItem {
id: "run-9".into(),
repository: RepositoryReference {
name: "cli-tools".into(),
},
title: "Add --verbose flag to run command".into(),
workflow: WorkflowReference {
slug: "expand".into(),
},
status: BoardColumn::Merge,
pull_request: Some(RunPullRequest {
number: 430,
additions: Some(56),
deletions: Some(12),
comments: Some(2),
checks: vec![
CheckRun {
name: "lint".into(),
status: CheckRunStatus::Success,
duration_secs: Some(15.0),
},
CheckRun {
name: "typecheck".into(),
status: CheckRunStatus::Success,
duration_secs: Some(48.0),
},
CheckRun {
name: "unit-tests".into(),
status: CheckRunStatus::Success,
duration_secs: Some(116.0),
},
CheckRun {
name: "build".into(),
status: CheckRunStatus::Success,
duration_secs: Some(82.0),
},
CheckRun {
name: "coverage".into(),
status: CheckRunStatus::Success,
duration_secs: Some(124.0),
},
CheckRun {
name: "bundle-size".into(),
status: CheckRunStatus::Skipped,
duration_secs: None,
},
],
}),
timings: Some(RunTimings {
elapsed_secs: 3900.0,
elapsed_warning: Some(false),
}),
sandbox: Some(RunSandbox {
id: "sb-g3h4i5j6".into(),
resources: None,
}),
question: None,
created_at: ts("2026-02-27T09:00:00Z"),
},
RunListItem {
id: "run-10".into(),
repository: RepositoryReference {
name: "shared-types".into(),
},
title: "Export utility type helpers".into(),
workflow: WorkflowReference {
slug: "sync_drift".into(),
},
status: BoardColumn::Merge,
pull_request: Some(RunPullRequest {
number: 76,
additions: Some(34),
deletions: Some(8),
comments: Some(0),
checks: vec![
CheckRun {
name: "lint".into(),
status: CheckRunStatus::Success,
duration_secs: Some(12.0),
},
CheckRun {
name: "typecheck".into(),
status: CheckRunStatus::Success,
duration_secs: Some(34.0),
},
CheckRun {
name: "unit-tests".into(),
status: CheckRunStatus::Success,
duration_secs: Some(75.0),
},
CheckRun {
name: "build".into(),
status: CheckRunStatus::Success,
duration_secs: Some(58.0),
},
],
}),
timings: Some(RunTimings {
elapsed_secs: 2880.0,
elapsed_warning: Some(false),
}),
sandbox: Some(RunSandbox {
id: "sb-k7l8m9n0".into(),
resources: None,
}),
question: None,
created_at: ts("2026-02-26T08:00:00Z"),
},
),
board_item(
take_summary(&mut summaries, "run-5"),
BoardColumn::Failed,
Some(pull_request(889, 234, 67, 4, vec![
check("lint", CheckRunStatus::Success, Some(23.0)),
check("typecheck", CheckRunStatus::Success, Some(72.0)),
check("unit-tests", CheckRunStatus::Success, Some(154.0)),
check("integration-tests", CheckRunStatus::Failure, Some(296.0)),
check("build", CheckRunStatus::Success, Some(105.0)),
])),
None,
None,
),
board_item(
take_summary(&mut summaries, "run-6"),
BoardColumn::Succeeded,
Some(pull_request(1249, 189, 45, 7, vec![
check("lint", CheckRunStatus::Success, Some(21.0)),
check("typecheck", CheckRunStatus::Success, Some(68.0)),
check("unit-tests", CheckRunStatus::Success, Some(192.0)),
check("integration-tests", CheckRunStatus::Success, Some(334.0)),
check("deploy-preview", CheckRunStatus::Success, Some(93.0)),
])),
None,
None,
),
]
}

View file

@ -73,6 +73,7 @@ use fabro_types::{
RunSubjectProvenance,
};
use fabro_util::redact::redact_jsonl_line;
use fabro_util::text::strip_goal_decoration;
use fabro_util::version::FABRO_VERSION;
use fabro_vault::{Error as VaultError, SecretType, Vault};
use fabro_workflow::Error as WorkflowError;
@ -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<u64>) -> Option<f64> {
duration_ms.map(|ms| ms as f64 / 1000.0)
}
fn summary_to_api_run_summary(summary: fabro_store::RunSummary) -> serde_json::Value {
let goal = summary.goal.unwrap_or_default();
let title = truncate_goal(&goal);
let repository = repository_name(summary.host_repo_path.as_deref());
let created_at = summary.run_id.created_at().to_rfc3339();
serde_json::json!({
"run_id": summary.run_id.to_string(),
"workflow_name": summary.workflow_name,
"workflow_slug": summary.workflow_slug,
"goal": goal,
"title": title,
"labels": summary.labels,
"host_repo_path": summary.host_repo_path,
"repository": { "name": repository },
"start_time": summary.start_time.map(|time| time.to_rfc3339()),
"status": summary.status,
"status_reason": summary.status_reason.map(api_status_reason),
"pending_control": summary.pending_control.map(api_pending_control),
"duration_ms": summary.duration_ms,
"elapsed_secs": elapsed_secs(summary.duration_ms),
"total_usd_micros": summary.total_usd_micros,
"created_at": created_at,
})
}
fn paginate_items<T>(items: Vec<T>, pagination: PaginationParams) -> (Vec<T>, bool) {
let limit = pagination.limit.clamp(1, 100) as usize;
let offset = pagination.offset as usize;
let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect();
let has_more = data.len() > limit;
data.truncate(limit);
(data, has_more)
}
async fn list_board_runs(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
@ -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<Arc<AppState>>) -> Response {
async fn list_runs(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
match state
.store
.list_runs(&fabro_store::ListRunsQuery::default())
.await
{
Ok(runs) => (StatusCode::OK, Json(runs)).into_response(),
Ok(runs) => {
let items = runs
.into_iter()
.map(summary_to_api_run_summary)
.collect::<Vec<_>>();
let (data, has_more) = paginate_items(items, pagination);
(
StatusCode::OK,
Json(serde_json::json!({
"data": data,
"meta": { "has_more": has_more }
})),
)
.into_response()
}
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
@ -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");

View file

@ -31,6 +31,7 @@ models/assistant-stage-turn.ts
models/billed-token-counts.ts
models/billing-by-model.ts
models/billing-stage-ref.ts
models/board-column-definition.ts
models/board-column.ts
models/check-run-status.ts
models/check-run.ts
@ -86,6 +87,7 @@ models/model.ts
models/node-state.ts
models/node-status-record.ts
models/paginated-api-question-list.ts
models/paginated-board-run-list.ts
models/paginated-event-list.ts
models/paginated-history-entry-list.ts
models/paginated-model-list.ts

View file

@ -24,6 +24,8 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { PaginatedBoardRunList } from '../models';
// @ts-ignore
import type { PaginatedRunList } from '../models';
// @ts-ignore
import type { PreflightResponse } from '../models';
@ -212,10 +214,12 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
/**
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
* @summary List Runs
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRuns: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listRuns: async (pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/runs`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -234,6 +238,14 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (pageLimit !== undefined) {
localVarQueryParameter['page[limit]'] = pageLimit;
}
if (pageOffset !== undefined) {
localVarQueryParameter['page[offset]'] = pageOffset;
}
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -586,7 +598,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
async listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedBoardRunList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listBoardRuns(pageLimit, pageOffset, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.listBoardRuns']?.[localVarOperationServerIndex]?.url;
@ -595,11 +607,13 @@ export const RunsApiFp = function(configuration?: Configuration) {
/**
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
* @summary List Runs
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listRuns(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<StoreRunSummary>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(options);
async listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.listRuns']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -743,17 +757,19 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedBoardRunList> {
return localVarFp.listBoardRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath));
},
/**
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
* @summary List Runs
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRuns(options?: RawAxiosRequestConfig): AxiosPromise<Array<StoreRunSummary>> {
return localVarFp.listRuns(options).then((request) => request(axios, basePath));
listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
return localVarFp.listRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath));
},
/**
* Pauses a running run. Returns 409 if the run is not running.
@ -881,11 +897,13 @@ export class RunsApi extends BaseAPI {
/**
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
* @summary List Runs
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listRuns(options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).listRuns(options).then((request) => request(this.axios, this.basePath));
public listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath));
}
/**

View file

@ -0,0 +1,21 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface BoardColumnDefinition {
'id': string;
'name': string;
}

View file

@ -19,10 +19,11 @@
*/
export const BoardColumn = {
WORKING: 'working',
INITIALIZING: 'initializing',
REVIEW: 'review',
MERGE: 'merge'
RUNNING: 'running',
WAITING: 'waiting',
SUCCEEDED: 'succeeded',
FAILED: 'failed'
} as const;
export type BoardColumn = typeof BoardColumn[keyof typeof BoardColumn];

View file

@ -14,6 +14,7 @@ export * from './billed-token-counts';
export * from './billing-by-model';
export * from './billing-stage-ref';
export * from './board-column';
export * from './board-column-definition';
export * from './check-run';
export * from './check-run-status';
export * from './code-location';
@ -67,6 +68,7 @@ export * from './model-test-result';
export * from './node-state';
export * from './node-status-record';
export * from './paginated-api-question-list';
export * from './paginated-board-run-list';
export * from './paginated-event-list';
export * from './paginated-history-entry-list';
export * from './paginated-model-list';

View file

@ -0,0 +1,34 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { BoardColumnDefinition } from './board-column-definition';
// May contain unused imports in some cases
// @ts-ignore
import type { PaginationMeta } from './pagination-meta';
// May contain unused imports in some cases
// @ts-ignore
import type { RunListItem } from './run-list-item';
/**
* Paginated list of board runs with shared canonical fields plus board metadata.
*/
export interface PaginatedBoardRunList {
'columns': Array<BoardColumnDefinition>;
'data': Array<RunListItem>;
'meta': PaginationMeta;
}

View file

@ -18,13 +18,13 @@
import type { PaginationMeta } from './pagination-meta';
// May contain unused imports in some cases
// @ts-ignore
import type { RunListItem } from './run-list-item';
import type { StoreRunSummary } from './store-run-summary';
/**
* Paginated list of runs.
*/
export interface PaginatedRunList {
'data': Array<RunListItem>;
'data': Array<StoreRunSummary>;
'meta': PaginationMeta;
}

View file

@ -21,6 +21,9 @@ import type { BoardColumn } from './board-column';
import type { RepositoryReference } from './repository-reference';
// May contain unused imports in some cases
// @ts-ignore
import type { RunControlAction } from './run-control-action';
// May contain unused imports in some cases
// @ts-ignore
import type { RunPullRequest } from './run-pull-request';
// May contain unused imports in some cases
// @ts-ignore
@ -30,28 +33,35 @@ import type { RunQuestion } from './run-question';
import type { RunSandbox } from './run-sandbox';
// May contain unused imports in some cases
// @ts-ignore
import type { RunTimings } from './run-timings';
// May contain unused imports in some cases
// @ts-ignore
import type { WorkflowReference } from './workflow-reference';
import type { StatusReason } from './status-reason';
/**
* Summary of a run shown in the board view.
* Canonical run summary shown in the board view, extended with board-specific metadata.
*/
export interface RunListItem {
/**
* Unique run identifier (ULID).
*/
'id': string;
'run_id': string;
'workflow_name'?: string | null;
'workflow_slug'?: string | null;
'goal': string;
'repository': RepositoryReference;
/**
* Human-readable title describing the run\'s goal.
*/
'title': string;
'workflow': WorkflowReference;
'status': BoardColumn;
'status': string;
'labels': { [key: string]: string; };
'host_repo_path'?: string | null;
'start_time'?: string | null;
'status_reason'?: StatusReason | null;
'pending_control'?: RunControlAction | null;
'duration_ms'?: number | null;
'elapsed_secs'?: number | null;
'total_usd_micros'?: number | null;
'column': BoardColumn;
'pull_request'?: RunPullRequest;
'timings'?: RunTimings;
'sandbox'?: RunSandbox;
'question'?: RunQuestion;
/**

View file

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