From 8ceb246b5aff35bdb548af3e6de22effd2eed8dd Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 23:32:35 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20batch=20archive/unarchive=20API?= =?UTF-8?q?=20endpoints=20and=20update=20web=20bulk=20act=E2=80=A6=20(#380?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The web UI previously issued one archive/unarchive HTTP request per selected run. This PR adds `POST /api/v1/runs/archive` and `POST /api/v1/runs/unarchive` endpoints that process up to 250 runs in a single fail-soft, non-transactional request, then wires the web bulk-action toolbar and board column menu to use them. ### Plan Summary - **OpenAPI contract** — four new schemas (`BatchRunLifecycleRequest`, `BatchRunLifecycleResponse`, `BatchRunLifecycleResult`, `BatchRunLifecycleSummary`) and two new paths; Rust and TypeScript clients regenerated. - **Server handlers** — `batch_archive_runs` / `batch_unarchive_runs` behind `RequiredUser`; full request validation (empty, >250, duplicates, unparseable IDs) before any mutation; per-item outcome mapping (`archived`, `already_archived`, `unarchived`, `not_archived`, `conflict`, `not_found`, `error`). - **Frontend helpers** — `archiveRuns` / `unarchiveRuns` wrappers in `run-actions.ts`; single-run helpers unchanged. - **UI integration** — `BulkActionToolbar` and `ColumnActionsMenu` replaced `Promise.allSettled` fan-out with one batch call; new `summarizeBatchLifecycleAction` helper drives toast copy for all-success, partial, and all-failure cases. ## Key Design Decisions **Fail-soft `200` for valid batches.** A batch where some items fail is still a successfully *processed* request; the per-item `ok` flag and `summary` counts communicate individual outcomes without requiring the caller to handle HTTP errors for partial failures. Request-level problems (bad IDs, empty list) still return `400`. **`RequiredUser` only.** Batch endpoints accept any-run mutations from a request body, so a run-scoped worker token must not be accepted. This is enforced at the handler level, separate from existing single-run lifecycle routes. **Request validation before any mutation.** Empty list, >250 IDs, duplicate IDs, and unparseable IDs all return `400` before touching any run — avoiding partial mutation surprises from invalid input. **Idempotent outcomes are successes.** `already_archived` (archive of an already-archived run) and `not_archived` (unarchive of a terminal non-archived run) both set `ok=true`. This matches the existing single-run semantics and avoids spurious failures in retry scenarios. **`ask_fabro_readiness` hoisted out of the per-item loop.** Readiness resolution involves LLM credential work; it's identical for every run in the batch, so it's resolved once before the loop and shared via `&AskFabroReadiness`. **`uniqueItems: true` / `Set` workaround.** The OpenAPI generator maps `uniqueItems` arrays to `Set` in TypeScript, but the HTTP wire format is still a JSON array. The frontend helper casts through `unknown` to send an array so Axios serializes correctly. ### Fabro Details
Ran 8 stages in 47m 48s for $23.53 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 1s | – | 0 | | preflight_compile | 2m 4s | – | 0 | | preflight_lint | 2m 16s | – | 0 | | implement | 20m 17s | $15.09 | 0 | | simplify_opus | 10m 27s | $6.18 | 0 | | simplify_gpt | 3m 58s | $2.25 | 0 | | verify | 8m 14s | – | 0 | | **Total** | **47m 48s** | **$23.53** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14 edges) ```dot digraph ImplementPlan { graph [ goal="Implement and simplify", model_stylesheet=" * { model: claude-opus-4-7; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> exit [condition="outcome=succeeded"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- apps/fabro-web/app/lib/run-actions.test.ts | 107 +++++- apps/fabro-web/app/lib/run-actions.ts | 42 ++- apps/fabro-web/app/routes/runs.test.tsx | 25 ++ apps/fabro-web/app/routes/runs.tsx | 104 +++--- docs/public/api-reference/fabro-api.yaml | 194 ++++++++++ lib/crates/fabro-server/src/error.rs | 19 + lib/crates/fabro-server/src/server.rs | 42 +-- .../src/server/handler/lifecycle.rs | 232 ++++++++++-- lib/crates/fabro-server/src/server/tests.rs | 340 ++++++++++++++++++ .../src/.openapi-generator/FILES | 4 + .../fabro-api-client/src/api/runs-api.ts | 154 ++++++++ .../src/models/batch-run-lifecycle-request.ts | 25 ++ .../models/batch-run-lifecycle-response.ts | 32 ++ .../src/models/batch-run-lifecycle-result.ts | 59 +++ .../src/models/batch-run-lifecycle-summary.ts | 33 ++ .../fabro-api-client/src/models/index.ts | 4 + 16 files changed, 1327 insertions(+), 89 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/models/batch-run-lifecycle-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/batch-run-lifecycle-response.ts create mode 100644 lib/packages/fabro-api-client/src/models/batch-run-lifecycle-result.ts create mode 100644 lib/packages/fabro-api-client/src/models/batch-run-lifecycle-summary.ts diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts index 1f61f41a1..f092bc7b1 100644 --- a/apps/fabro-web/app/lib/run-actions.test.ts +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { AxiosAdapter } from "axios"; -import type { Run, RunStatus } from "@qltysh/fabro-api-client"; +import type { BatchRunLifecycleResponse, Run, RunStatus } from "@qltysh/fabro-api-client"; import { archiveRun, + archiveRuns, canArchive, canApprove, canCancel, @@ -14,6 +15,7 @@ import { mapError, retryRun, unarchiveRun, + unarchiveRuns, } from "./run-actions"; import { generatedAxios } from "./api-client"; @@ -23,6 +25,12 @@ type StubResponseInit = { statusText?: string; }; +type CapturedRequest = { + url?: string; + method?: string; + data?: unknown; +}; + const originalAdapter = generatedAxios.defaults.adapter; function makeRun(status: RunStatus, archived = false): Run { @@ -65,8 +73,14 @@ function makeRun(status: RunStatus, archived = false): Run { }; } -function stubGeneratedAxiosOnce(init: StubResponseInit) { +function stubGeneratedAxiosOnce(init: StubResponseInit): { requests: CapturedRequest[] } { + const requests: CapturedRequest[] = []; generatedAxios.defaults.adapter = (async (config) => { + requests.push({ + url: config.url, + method: config.method, + data: config.data, + }); if (init.status >= 400) { throw { isAxiosError: true, @@ -87,6 +101,25 @@ function stubGeneratedAxiosOnce(init: StubResponseInit) { config, }; }) as AxiosAdapter; + return { requests }; +} + +function batchResponse( + results: BatchRunLifecycleResponse["results"], +): BatchRunLifecycleResponse { + const succeeded = results.filter((result) => result.ok).length; + return { + results, + summary: { + requested: results.length, + succeeded, + failed: results.length - succeeded, + }, + }; +} + +function requestJsonBody(request: CapturedRequest): unknown { + return typeof request.data === "string" ? JSON.parse(request.data) : request.data; } async function expectLifecycleError( @@ -141,6 +174,76 @@ describe("run lifecycle actions", () => { expect(result.lifecycle.archived).toBe(false); }); + test("archiveRuns sends one batch request and parses results", async () => { + const stub = stubGeneratedAxiosOnce({ + status: 200, + body: batchResponse([ + { + run_id: "run-1", + ok: true, + outcome: "archived", + run: { ...makeRun({ kind: "succeeded", reason: "completed" }, true), id: "run-1" }, + }, + { + run_id: "run-2", + ok: true, + outcome: "already_archived", + run: { ...makeRun({ kind: "succeeded", reason: "completed" }, true), id: "run-2" }, + }, + ]), + }); + + const result = await archiveRuns(["run-1", "run-2"]); + + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]?.method?.toUpperCase()).toBe("POST"); + expect(stub.requests[0]?.url).toBe("/api/v1/runs/archive"); + expect(requestJsonBody(stub.requests[0]!)).toEqual({ run_ids: ["run-1", "run-2"] }); + expect(result.summary).toEqual({ requested: 2, succeeded: 2, failed: 0 }); + expect(result.results.map((entry) => entry.outcome)).toEqual(["archived", "already_archived"]); + }); + + test("unarchiveRuns resolves mixed per-item results without throwing", async () => { + stubGeneratedAxiosOnce({ + status: 200, + body: batchResponse([ + { + run_id: "run-1", + ok: true, + outcome: "unarchived", + run: { ...makeRun({ kind: "succeeded", reason: "completed" }), id: "run-1" }, + }, + { + run_id: "run-missing", + ok: false, + outcome: "not_found", + error: { status: "404", title: "Not Found", detail: "Run not found." }, + }, + ]), + }); + + const result = await unarchiveRuns(["run-1", "run-missing"]); + + expect(result.summary).toEqual({ requested: 2, succeeded: 1, failed: 1 }); + expect(result.results[1]?.ok).toBe(false); + expect(result.results[1]?.error?.status).toBe("404"); + }); + + test("batch lifecycle helpers preserve request-level error envelopes", async () => { + stubGeneratedAxiosOnce({ + status: 400, + body: { + errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }], + }, + }); + + const error = await expectLifecycleError(archiveRuns([])); + expect(error).toEqual({ + status: 400, + errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }], + }); + }); + test("retryRun parses a 201 response", async () => { stubGeneratedAxiosOnce({ status: 201, diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index a785feafa..819ec29de 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -1,4 +1,9 @@ -import type { ErrorResponseEntry, Run } from "@qltysh/fabro-api-client"; +import type { + BatchRunLifecycleRequest, + BatchRunLifecycleResponse, + ErrorResponseEntry, + Run, +} from "@qltysh/fabro-api-client"; import { ApiError, @@ -58,6 +63,20 @@ export async function unarchiveRun(id: string, request?: Request): Promise return runLifecycleAction(id, "unarchive", request); } +export async function archiveRuns( + runIds: string[], + request?: Request, +): Promise { + return batchRunLifecycleAction(runIds, "archive", request); +} + +export async function unarchiveRuns( + runIds: string[], + request?: Request, +): Promise { + return batchRunLifecycleAction(runIds, "unarchive", request); +} + export async function retryRun(id: string, request?: Request): Promise { return runLifecycleAction(id, "retry", request); } @@ -182,6 +201,27 @@ async function runLifecycleAction( } } +async function batchRunLifecycleAction( + runIds: string[], + action: "archive" | "unarchive", + request?: Request, +): Promise { + try { + // openapi-generator's TypeScript client represents `uniqueItems` arrays as + // Set, but the HTTP wire contract is still a JSON array. Keep an array + // here so Axios serializes the request body correctly. + const body = { run_ids: runIds } as unknown as BatchRunLifecycleRequest; + switch (action) { + case "archive": + return await apiData(() => runsApi.batchArchiveRuns(body, requestSignalOptions(request))); + case "unarchive": + return await apiData(() => runsApi.batchUnarchiveRuns(body, requestSignalOptions(request))); + } + } catch (error) { + throw lifecycleActionErrorFromError(error); + } +} + function lifecycleActionErrorFromError(error: unknown): LifecycleActionError { if (!(error instanceof ApiError)) throw error; return { diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx index c56e56e1e..94d8f5cff 100644 --- a/apps/fabro-web/app/routes/runs.test.tsx +++ b/apps/fabro-web/app/routes/runs.test.tsx @@ -9,6 +9,7 @@ import { RUNS_PREFERENCES_STORAGE_KEY, runsQuickStartCommands, shouldRefreshBoardForEvent, + summarizeBatchLifecycleAction, } from "./runs"; function boardRun(id: string, column: BoardColumn, questionText?: string): Run { @@ -173,6 +174,30 @@ describe("runs route board mapping", () => { "fabro run hello", ]); }); + + test("summarizes successful batch archive and unarchive actions", () => { + expect( + summarizeBatchLifecycleAction("Archive", { requested: 2, succeeded: 2, failed: 0 }), + ).toEqual({ message: "Archived 2 runs." }); + expect( + summarizeBatchLifecycleAction("Unarchive", { requested: 1, succeeded: 1, failed: 0 }), + ).toEqual({ message: "Unarchived 1 run." }); + }); + + test("summarizes partial and failed batch lifecycle actions", () => { + expect( + summarizeBatchLifecycleAction("Archive", { requested: 3, succeeded: 2, failed: 1 }), + ).toEqual({ + message: "Archived 2 of 3 runs. 1 failed.", + tone: "error", + }); + expect( + summarizeBatchLifecycleAction("Unarchive", { requested: 2, succeeded: 0, failed: 2 }), + ).toEqual({ + message: "Couldn't unarchive 2 runs. Try again.", + tone: "error", + }); + }); }); describe("runs route workspace preferences", () => { diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 2388a8859..5e26b7c96 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -27,12 +27,15 @@ import { formatRelativeTime } from "../lib/format"; import { EmptyState } from "../components/state"; import { InlineMarkdown } from "../components/inline-markdown"; import { PullRequestChip } from "../components/pull-request-chip"; +import { plural } from "../components/settings-panel"; import { useToast } from "../components/toast"; import { mutateRunListCaches } from "../lib/board-cache"; import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events"; import { useAllRuns, useAuthConfig, useRunsPage, useSystemInfo } from "../lib/queries"; -import { archiveRun, canArchive, canUnarchive, unarchiveRun } from "../lib/run-actions"; +import { archiveRuns, canArchive, canUnarchive, unarchiveRuns } from "../lib/run-actions"; import type { + BatchRunLifecycleResponse, + BatchRunLifecycleSummary, BoardColumn, ListRunsDirectionEnum, ListRunsSortEnum, @@ -65,6 +68,33 @@ const columnStyles: Record = { const defaultColumnStyle: ColumnStyle = { actions: [] }; const defaultColumnColors = { label: "", dot: "bg-fg-muted", text: "text-fg-muted" }; +type BatchLifecycleLabel = "Archive" | "Unarchive"; + +interface BatchLifecycleToast { + message: string; + tone?: "error"; +} + +export function summarizeBatchLifecycleAction( + label: BatchLifecycleLabel, + summary: BatchRunLifecycleSummary, +): BatchLifecycleToast { + const { requested, succeeded, failed } = summary; + if (failed === 0) { + return { message: `${label}d ${succeeded} ${plural(succeeded, "run", "runs")}.` }; + } + if (succeeded === 0) { + return { + message: `Couldn't ${label.toLowerCase()} ${requested} ${plural(requested, "run", "runs")}. Try again.`, + tone: "error", + }; + } + return { + message: `${label}d ${succeeded} of ${requested} ${plural(requested, "run", "runs")}. ${failed} failed.`, + tone: "error", + }; +} + interface BoardRunsResponse { data: Run[]; } @@ -463,25 +493,16 @@ function ColumnActionsMenu({ column }: { column: Column }) { setPending(true); const total = archivable.length; try { - const results = await Promise.allSettled( - archivable.map((item) => archiveRun(item.id)), + const response = await archiveRuns(archivable.map((item) => item.id)); + push(summarizeBatchLifecycleAction("Archive", response.summary)); + } catch { + push( + summarizeBatchLifecycleAction("Archive", { + requested: total, + succeeded: 0, + failed: total, + }), ); - const succeeded = results.filter((r) => r.status === "fulfilled").length; - const failed = total - succeeded; - const runWord = (n: number) => (n === 1 ? "run" : "runs"); - if (failed === 0) { - push({ message: `Archived ${total} ${runWord(total)}.` }); - } else if (succeeded === 0) { - push({ - message: `Couldn't archive ${total} ${runWord(total)}. Try again.`, - tone: "error", - }); - } else { - push({ - message: `Archived ${succeeded} of ${total} runs. ${failed} failed.`, - tone: "error", - }); - } } finally { setPending(false); mutateRunListCaches(mutate); @@ -1408,37 +1429,34 @@ function BulkActionToolbar({ if (count === 0) return null; - const runWord = (n: number) => (n === 1 ? "run" : "runs"); - async function runBulk( label: "Archive" | "Unarchive", eligible: RunWithStatus[], - action: (id: string) => Promise, + action: (ids: string[]) => Promise, ) { if (pending) return; if (eligible.length === 0) { - push({ message: `No selected ${runWord(count)} can be ${label.toLowerCase()}d.`, tone: "error" }); + push({ + message: `No selected ${plural(count, "run", "runs")} can be ${label.toLowerCase()}d.`, + tone: "error", + }); return; } setPending(true); try { - const results = await Promise.allSettled(eligible.map((r) => action(r.id))); - const succeeded = results.filter((r) => r.status === "fulfilled").length; - const failed = eligible.length - succeeded; - if (failed === 0) { - push({ message: `${label}d ${succeeded} ${runWord(succeeded)}.` }); + const response = await action(eligible.map((r) => r.id)); + push(summarizeBatchLifecycleAction(label, response.summary)); + if (response.summary.failed === 0) { onClear(); - } else if (succeeded === 0) { - push({ - message: `Couldn't ${label.toLowerCase()} ${eligible.length} ${runWord(eligible.length)}. Try again.`, - tone: "error", - }); - } else { - push({ - message: `${label}d ${succeeded} of ${eligible.length} ${runWord(eligible.length)}. ${failed} failed.`, - tone: "error", - }); } + } catch { + push( + summarizeBatchLifecycleAction(label, { + requested: eligible.length, + succeeded: 0, + failed: eligible.length, + }), + ); } finally { setPending(false); mutateRunListCaches(mutate); @@ -1453,20 +1471,20 @@ function BulkActionToolbar({ >
- {count} {runWord(count)} selected + {count} {plural(count, "run", "runs")} selected