diff --git a/run.json b/run.json index cbd6f729b..505fc74a1 100644 --- a/run.json +++ b/run.json @@ -492,7 +492,7 @@ "kind": "running" }, "status_updated_at": "2026-05-24T01:42:40.350880Z", - "last_event_at": "2026-05-24T02:10:48.610885Z", + "last_event_at": "2026-05-24T02:21:20.136600Z", "pending_control": null, "checkpoints": [ { @@ -740,9 +740,9 @@ } }, { - "seq": 0, + "seq": 951, "checkpoint": { - "timestamp": "2026-05-24T02:10:48.638348Z", + "timestamp": "2026-05-24T02:10:52.796073Z", "current_node": "implement", "completed_nodes": [ "start", @@ -753,27 +753,150 @@ ], "node_retries": {}, "context_values": { - "internal.retry_count.preflight_compile": 0, - "thread.preflight_compile.current_node": "preflight_lint", - "thread.preflight_lint.current_node": "implement", - "failure_class": "", - "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", - "failure_signature": "", "thread.start.current_node": "toolchain", + "graph.rankdir": "LR", + "internal.retry_count.start": 0, + "last_stage": "implement", + "thread.toolchain.current_node": "preflight_compile", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "current_node": "implement", + "failure_class": "", + "graph.goal": "---\ntitle: feat: Batch run archive actions\ntype: feat\nstatus: active\ndate: 2026-05-24\n---\n\n# feat: Batch Run Archive Actions\n\n## Overview\n\nAdd API support for archiving and unarchiving multiple runs in one request, then update the web list and board multi-run actions to use the new batch endpoints. The existing single-run archive and unarchive endpoints remain unchanged for run-scoped callers and direct lifecycle actions.\n\n## Problem Frame\n\nThe web UI currently performs multi-run archive/unarchive actions by issuing one lifecycle request per selected run. That works, but it puts batch orchestration in the browser, repeats request overhead, and leaves API/CLI/MCP consumers without a first-class batch contract. A bounded fail-soft batch endpoint gives the server ownership of the multi-run operation while preserving the independent event stream semantics of each run.\n\n## Requirements Trace\n\n- R1. Provide public API endpoints that archive and unarchive many runs in one request.\n- R2. Preserve existing single-run archive/unarchive behavior, including eligibility, idempotency, and emitted events.\n- R3. Return per-run results so mixed-success batches can be reported without rolling back successful items.\n- R4. Update web bulk actions to make one request per batch action instead of one request per run.\n- R5. Keep cache invalidation and toast behavior equivalent to the current UI.\n\n## Scope Boundaries\n\n- Do not make batch archive/unarchive transactional; each run remains an independent event stream.\n- Do not add new workflow event variants; successful batch items emit the same run archive/unarchive events as single-run actions.\n- Do not change the existing `POST /api/v1/runs/{id}/archive` or `POST /api/v1/runs/{id}/unarchive` contracts.\n- Do not add CLI commands in this change; this plan is limited to HTTP API and web UI usage.\n\n## Context & Research\n\n### Relevant Code and Patterns\n\n- OpenAPI is the source of truth for HTTP contracts in `docs/public/api-reference/fabro-api.yaml`.\n- Server lifecycle routes live in `lib/crates/fabro-server/src/server/handler/lifecycle.rs`; single-run archive/unarchive already funnel through `operations::archive` and `operations::unarchive`.\n- Batch routes that are not tied to one path run ID should use `RequiredUser`, not `RequireRunScopedOrRunTools`, because a run-scoped worker token cannot safely authorize mutation of arbitrary run IDs from a request body.\n- Web lifecycle helpers live in `apps/fabro-web/app/lib/run-actions.ts`; list and board multi-run archive behavior lives in `apps/fabro-web/app/routes/runs.tsx`.\n- Run-list cache invalidation already uses `mutateRunListCaches` in `apps/fabro-web/app/lib/board-cache.ts`.\n\n### Strategy Docs\n\n- `docs/internal/testing-strategy.md`: server tests should assert public HTTP contracts and prefer structured assertions.\n- `docs/internal/error-handling-strategy.md`: preserve structured errors internally and return curated API messages at HTTP boundaries.\n- `docs/internal/events-strategy.md`: no new events are needed because existing per-run lifecycle events already represent the durable state transition.\n\n## Key Technical Decisions\n\n- Add collection action endpoints `POST /api/v1/runs/archive` and `POST /api/v1/runs/unarchive`. These avoid changing single-run URLs and keep generated client methods clear (`batchArchiveRuns`, `batchUnarchiveRuns`).\n- Use fail-soft HTTP `200` responses for valid batch requests, even when individual items fail. Per-item failures carry structured result entries; request-level validation failures still return normal `400` errors.\n- Validate `run_ids` at the request boundary: non-empty, maximum 250 IDs, no duplicates, and every value parseable as a `RunId`. Invalid request bodies must not mutate any runs.\n- Process eligible IDs sequentially in server code. The batch is bounded, lifecycle operations append events, and sequential processing avoids adding lock-order or concurrency behavior that the feature does not need.\n- Treat idempotent single-run outcomes as successful batch outcomes: already archived counts as success for archive; terminal not-archived counts as success for unarchive.\n\n## API Contract\n\nAdd these OpenAPI operations:\n\n- `POST /api/v1/runs/archive`\n - operationId: `batchArchiveRuns`\n - request: `BatchRunLifecycleRequest`\n - response: `BatchRunLifecycleResponse`\n- `POST /api/v1/runs/unarchive`\n - operationId: `batchUnarchiveRuns`\n - request: `BatchRunLifecycleRequest`\n - response: `BatchRunLifecycleResponse`\n\nAdd schemas:\n\n- `BatchRunLifecycleRequest`\n - required `run_ids`\n - `run_ids`: array of strings, `minItems: 1`, `maxItems: 250`, `uniqueItems: true`\n- `BatchRunLifecycleResponse`\n - required `results`, `summary`\n - `results`: array of `BatchRunLifecycleResult`, ordered exactly like the request `run_ids`\n - `summary`: `BatchRunLifecycleSummary`\n- `BatchRunLifecycleResult`\n - required `run_id`, `ok`, `outcome`\n - `run_id`: string\n - `ok`: boolean\n - `outcome`: enum covering `archived`, `already_archived`, `unarchived`, `not_archived`, `not_found`, `conflict`, `error`\n - optional `run`: `Run`, present for successful items when a decorated summary can be loaded\n - optional `error`: `ErrorResponseEntry`, present for failed items\n- `BatchRunLifecycleSummary`\n - required `requested`, `succeeded`, `failed`\n - all integer counts\n\n## Implementation Units\n\n- [ ] **Unit 1: OpenAPI batch lifecycle contract**\n\n**Goal:** Add the public API contract and generated clients for batch archive/unarchive.\n\n**Requirements:** R1, R3\n\n**Dependencies:** None\n\n**Files:**\n- Modify: `docs/public/api-reference/fabro-api.yaml`\n- Generated by build/codegen: `lib/crates/fabro-api/src/generated.rs`\n- Generated by codegen: `lib/packages/fabro-api-client/src/api/runs-api.ts`\n- Generated by codegen: `lib/packages/fabro-api-client/src/models/*`\n\n**Approach:**\n- Add the two collection action paths and the four batch lifecycle schemas described in the API Contract section.\n- Keep response status simple: `200` for a valid processed batch; `400`, `401`, and `500` for request-level failures.\n- Run Rust generation through `cargo build -p fabro-api`.\n- Run TypeScript generation through `cd lib/packages/fabro-api-client && bun run generate`.\n\n**Patterns to follow:**\n- Existing single-run archive/unarchive path docs in the same OpenAPI file.\n- Existing generated client workflow described in `AGENTS.md`.\n\n**Test scenarios:**\n- Happy path: generated Rust and TypeScript clients expose `batchArchiveRuns` and `batchUnarchiveRuns`.\n- Contract: request schema enforces `run_ids` as the only required input.\n- Contract: result entries can represent both a successful decorated `Run` and a failed `ErrorResponseEntry`.\n\n**Verification:**\n- API generation completes without hand-edited generated files.\n\n- [ ] **Unit 2: Server batch lifecycle handlers**\n\n**Goal:** Implement the two new endpoints in the server using existing archive/unarchive operations.\n\n**Requirements:** R1, R2, R3\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `lib/crates/fabro-server/src/server/handler/lifecycle.rs`\n- Test: `lib/crates/fabro-server/src/server/tests.rs`\n\n**Approach:**\n- Register `POST /runs/archive` and `POST /runs/unarchive` in the lifecycle route set.\n- Use `RequiredUser` for both handlers and convert the user into `Principal::User` for per-run operation calls.\n- Add a small shared batch helper that accepts the parsed request, the action, and the actor, then returns `BatchRunLifecycleResponse`.\n- Before processing any item, validate the entire request for empty list, over-limit list, duplicate IDs, and invalid IDs. Return a normal `400` API error if validation fails.\n- For each valid ID, call `operations::archive` or `operations::unarchive`; map success outcomes to item-level success results and map `RunNotFound`/`Precondition` to item-level `not_found`/`conflict` failures.\n- For successful items, load and decorate the current run summary the same way single-run lifecycle responses do. If summary loading fails after the operation succeeds, record that item as `error` rather than hiding the failure.\n\n**Patterns to follow:**\n- `run_archive_action` for operation mapping and existing error semantics.\n- `run_response` / `state.decorate_run_summary` for response shape.\n- Existing server tests around `archive_and_unarchive_updates_listing_visibility`.\n\n**Test scenarios:**\n- Happy path: two terminal runs archived in one request return two successful result entries, summary `requested=2/succeeded=2/failed=0`, and both runs are hidden from default `GET /api/v1/runs`.\n- Happy path: two archived runs unarchived in one request return successful entries and both runs reappear in default listing.\n- Idempotency: archiving an already archived run returns `ok=true` with `already_archived`; unarchiving a terminal non-archived run returns `ok=true` with `not_archived`.\n- Mixed result: a batch containing one terminal run, one running run, and one missing run returns ordered results with one success, one `conflict`, and one `not_found`; the terminal run is still archived.\n- Error path: empty `run_ids`, duplicate IDs, invalid IDs, and more than 250 IDs return request-level `400` and mutate no runs.\n- Auth path: unauthenticated requests are rejected; run-scoped worker authentication is not accepted for batch endpoints.\n\n**Verification:**\n- Existing single-run archive/unarchive tests pass unchanged.\n- New endpoint tests prove both API contract and run-list visibility effects.\n\n- [ ] **Unit 3: Frontend lifecycle helpers**\n\n**Goal:** Add typed web helpers for batch archive/unarchive.\n\n**Requirements:** R3, R4, R5\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `apps/fabro-web/app/lib/run-actions.ts`\n- Test: `apps/fabro-web/app/lib/run-actions.test.ts`\n\n**Approach:**\n- Add `archiveRuns(runIds: string[])` and `unarchiveRuns(runIds: string[])` wrappers around the generated client methods.\n- Keep existing `archiveRun` and `unarchiveRun` helpers unchanged.\n- Preserve existing `LifecycleActionError` behavior for single-run actions. Batch helpers should return the generated batch response for valid mixed results and throw only for request-level API failures.\n\n**Patterns to follow:**\n- Existing lifecycle action helpers in `run-actions.ts`.\n- Axios adapter tests in `run-actions.test.ts`.\n\n**Test scenarios:**\n- Happy path: `archiveRuns([\"run-1\", \"run-2\"])` sends one generated-client request and returns parsed batch results.\n- Mixed result: helper resolves a response containing one success and one per-item failure without throwing.\n- Request error: helper throws parsed `ApiError`/lifecycle-style data when the server returns a request-level `400`.\n- Regression: existing `archiveRun`, `unarchiveRun`, `canArchive`, and `canUnarchive` tests continue to pass.\n\n**Verification:**\n- Web unit tests cover the new helper contract without changing single-run behavior.\n\n- [ ] **Unit 4: Web bulk-action integration**\n\n**Goal:** Replace multi-request UI orchestration with one batch request per bulk action.\n\n**Requirements:** R4, R5\n\n**Dependencies:** Units 1 and 3\n\n**Files:**\n- Modify: `apps/fabro-web/app/routes/runs.tsx`\n- Test: `apps/fabro-web/app/routes/runs.test.tsx`\n\n**Approach:**\n- Update `BulkActionToolbar` to call `archiveRuns` or `unarchiveRuns` once with eligible selected IDs.\n- Add a small pure batch-summary helper in `runs.tsx`, export it for route tests, and use it to compute toast messages from the batch response summary rather than `Promise.allSettled`.\n- Keep current eligibility filtering: archive only selected runs whose lifecycle status is archivable, unarchive only selected archived runs.\n- Clear selection only when all eligible items succeed, matching the current all-success behavior.\n- Call `mutateRunListCaches` once after the batch settles.\n- Update the kanban column archive-all action to call `archiveRuns` once with the column’s eligible IDs and reuse the same success/partial/failure toast behavior where practical.\n\n**Patterns to follow:**\n- Current `BulkActionToolbar` pending state, clear-selection behavior, and toast wording.\n- Current `ColumnActionsMenu` archive-all action and cache invalidation.\n\n**Test scenarios:**\n- Happy path: selecting multiple archived rows and clicking Unarchive calls the batch helper once and clears selection after all items succeed.\n- Happy path: selecting multiple terminal rows and clicking Archive calls the batch helper once and invalidates run-list caches once.\n- Mixed result: partial failure keeps selection and shows an error-tone partial-success toast with succeeded/failed counts.\n- Ineligible selection: selecting only non-archivable runs still shows the existing “No selected runs can be archived” style error without making an API request.\n- Board action: archive-all for a column calls the batch helper once with all eligible IDs.\n\n**Verification:**\n- UI behavior remains equivalent from the user’s perspective, but network behavior becomes one request per bulk action.\n\n## System-Wide Impact\n\n- **Auth:** Batch endpoints are user-only. This intentionally avoids giving a worker token with one run scope the ability to mutate arbitrary run IDs from a request body.\n- **Events:** No new event names or payloads. Each successful item appends the existing per-run archive/unarchive event.\n- **Caching:** Frontend run-list caches are still invalidated after lifecycle changes. The batch path should reduce invalidation churn from once per selected run to once per user action.\n- **Generated clients:** Both Rust and TypeScript generated clients change because OpenAPI is the source of truth.\n- **Compatibility:** Single-run endpoints and existing generated methods remain available and unchanged.\n\n## Risks & Mitigations\n\n| Risk | Mitigation |\n|------|------------|\n| Route ambiguity with `/runs/{id}` paths | Use static collection action paths and add server tests that call the exact batch routes. |\n| Partial mutation surprises | Use explicit fail-soft result entries and summarize succeeded/failed counts. |\n| Worker token privilege expansion | Require `RequiredUser` for batch endpoints. |\n| Duplicate IDs producing confusing results | Reject duplicates at request validation before any mutation. |\n| UI toast regressions | Cover all-success, all-failure, partial-failure, and ineligible-selection behavior in frontend tests. |\n\n## Documentation / Operational Notes\n\n- The OpenAPI descriptions should explicitly state that batch actions are fail-soft and not transactional.\n- No migration, feature flag, or rollout sequencing is required.\n- No new public docs are required unless API reference publishing is part of the release process.\n\n## Sources & References\n\n- OpenAPI source: `docs/public/api-reference/fabro-api.yaml`\n- Server lifecycle handlers: `lib/crates/fabro-server/src/server/handler/lifecycle.rs`\n- Workflow archive operations: `lib/crates/fabro-workflow/src/operations/archive.rs`\n- Web lifecycle helpers: `apps/fabro-web/app/lib/run-actions.ts`\n- Web runs route: `apps/fabro-web/app/routes/runs.tsx`\n- Testing guidance: `docs/internal/testing-strategy.md`\n- Error handling guidance: `docs/internal/error-handling-strategy.md`\n- Event guidance: `docs/internal/events-strategy.md`\n", + "internal.retry_count.preflight_compile": 0, + "internal.retry_count.preflight_lint": 0, + "internal.node_visit_count": 1, + "internal.retry_count.toolchain": 0, + "internal.run_id": "01KSBT48J14ZMK9HQN48SVMG3T", + "internal.retry_count.implement": 0, + "last_response": "Implemented batch run archive/unarchive end-to-end.\n\nSummary:\n- Added OpenAPI endpoints:\n - `POST /api/v1/runs/archive`\n - `POST /api/v1/runs/unarchive`\n- Added batch lifecycle schemas and regenerat", + "outcome": "succeeded", + "thread.preflight_lint.current_node": "implement", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.fidelity": "compact", + "internal.thread_id": "preflight_lint", + "failure_signature": "", + "response.implement": "Implemented batch run archive/unarchive end-to-end.\n\nSummary:\n- Added OpenAPI endpoints:\n - `POST /api/v1/runs/archive`\n - `POST /api/v1/runs/unarchive`\n- Added batch lifecycle schemas and regenerated Rust/TypeScript API clients.\n- Implemented server batch handlers with:\n - `RequiredUser` auth only\n - pre-mutation validation for empty, >250, duplicate, and invalid IDs\n - sequential fail-soft processing\n - ordered per-run results and summary counts\n - existing `operations::archive` / `operations::unarchive` behavior and events\n- Added server tests covering:\n - archive/unarchive visibility changes\n - mixed success/conflict/not_found results\n - idempotent archive/unarchive outcomes\n - invalid request rejection before mutation\n - unauthenticated and worker-token rejection\n- Added web helpers:\n - `archiveRuns(runIds)`\n - `unarchiveRuns(runIds)`\n- Updated runs list bulk actions and board “Archive all” to use one batch request per action.\n- Preserved cache invalidation/toast behavior, with partial-success batch summaries.\n- Added frontend helper and route-summary tests.\n\nValidation run:\n- `cargo build -q -p fabro-api` ✅\n- `cargo check -q --workspace` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings` ✅\n- `cargo nextest run -p fabro-api` ✅\n- Targeted `fabro-server` batch/single archive tests ✅\n- `cd lib/packages/fabro-api-client && bun run generate` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd apps/fabro-web && bun run test` ✅\n\nNote:\n- Full `cargo nextest run -p fabro-server` still fails on existing/unrelated `server::tests::get_graph_returns_svg`; it invokes the test binary as the graph render subprocess and gets `running 0 tests...` instead of SVG. The new batch lifecycle tests pass.", + "internal.work_dir": "/home/daytona/workspace/fabro", + "thread.preflight_compile.current_node": "preflight_lint" + }, + "node_outcomes": { + "implement": { + "status": "succeeded", + "context_updates": { + "last_response": "Implemented batch run archive/unarchive end-to-end.\n\nSummary:\n- Added OpenAPI endpoints:\n - `POST /api/v1/runs/archive`\n - `POST /api/v1/runs/unarchive`\n- Added batch lifecycle schemas and regenerat", + "response.implement": "Implemented batch run archive/unarchive end-to-end.\n\nSummary:\n- Added OpenAPI endpoints:\n - `POST /api/v1/runs/archive`\n - `POST /api/v1/runs/unarchive`\n- Added batch lifecycle schemas and regenerated Rust/TypeScript API clients.\n- Implemented server batch handlers with:\n - `RequiredUser` auth only\n - pre-mutation validation for empty, >250, duplicate, and invalid IDs\n - sequential fail-soft processing\n - ordered per-run results and summary counts\n - existing `operations::archive` / `operations::unarchive` behavior and events\n- Added server tests covering:\n - archive/unarchive visibility changes\n - mixed success/conflict/not_found results\n - idempotent archive/unarchive outcomes\n - invalid request rejection before mutation\n - unauthenticated and worker-token rejection\n- Added web helpers:\n - `archiveRuns(runIds)`\n - `unarchiveRuns(runIds)`\n- Updated runs list bulk actions and board “Archive all” to use one batch request per action.\n- Preserved cache invalidation/toast behavior, with partial-success batch summaries.\n- Added frontend helper and route-summary tests.\n\nValidation run:\n- `cargo build -q -p fabro-api` ✅\n- `cargo check -q --workspace` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings` ✅\n- `cargo nextest run -p fabro-api` ✅\n- Targeted `fabro-server` batch/single archive tests ✅\n- `cd lib/packages/fabro-api-client && bun run generate` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd apps/fabro-web && bun run test` ✅\n\nNote:\n- Full `cargo nextest run -p fabro-server` still fails on existing/unrelated `server::tests::get_graph_returns_svg`; it invokes the test binary as the graph render subprocess and gets `running 0 tests...` instead of SVG. The new batch lifecycle tests pass.", + "last_stage": "implement" + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 321093, + "output_tokens": 20925, + "reasoning_tokens": 13248, + "cache_read_tokens": 24927232, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 15094271 + } + }, + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null + }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null + }, + "start": { + "status": "succeeded", + "usage": null + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: 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", + "usage": null + } + }, + "next_node_id": "simplify_opus", + "git_commit_sha": "b25098513ab3875216918b4f7d3bd26eb05dddb0", + "node_visits": { + "preflight_lint": 1, + "implement": 1, + "start": 1, + "toolchain": 1, + "preflight_compile": 1 + } + }, + "diff": { + "patch": "diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts\nindex 1f61f41a1..f092bc7b1 100644\n--- a/apps/fabro-web/app/lib/run-actions.test.ts\n+++ b/apps/fabro-web/app/lib/run-actions.test.ts\n@@ -1,9 +1,10 @@\n import { afterEach, describe, expect, test } from \"bun:test\";\n import type { AxiosAdapter } from \"axios\";\n-import type { Run, RunStatus } from \"@qltysh/fabro-api-client\";\n+import type { BatchRunLifecycleResponse, Run, RunStatus } from \"@qltysh/fabro-api-client\";\n \n import {\n archiveRun,\n+ archiveRuns,\n canArchive,\n canApprove,\n canCancel,\n@@ -14,6 +15,7 @@ import {\n mapError,\n retryRun,\n unarchiveRun,\n+ unarchiveRuns,\n } from \"./run-actions\";\n import { generatedAxios } from \"./api-client\";\n \n@@ -23,6 +25,12 @@ type StubResponseInit = {\n statusText?: string;\n };\n \n+type CapturedRequest = {\n+ url?: string;\n+ method?: string;\n+ data?: unknown;\n+};\n+\n const originalAdapter = generatedAxios.defaults.adapter;\n \n function makeRun(status: RunStatus, archived = false): Run {\n@@ -65,8 +73,14 @@ function makeRun(status: RunStatus, archived = false): Run {\n };\n }\n \n-function stubGeneratedAxiosOnce(init: StubResponseInit) {\n+function stubGeneratedAxiosOnce(init: StubResponseInit): { requests: CapturedRequest[] } {\n+ const requests: CapturedRequest[] = [];\n generatedAxios.defaults.adapter = (async (config) => {\n+ requests.push({\n+ url: config.url,\n+ method: config.method,\n+ data: config.data,\n+ });\n if (init.status >= 400) {\n throw {\n isAxiosError: true,\n@@ -87,6 +101,25 @@ function stubGeneratedAxiosOnce(init: StubResponseInit) {\n config,\n };\n }) as AxiosAdapter;\n+ return { requests };\n+}\n+\n+function batchResponse(\n+ results: BatchRunLifecycleResponse[\"results\"],\n+): BatchRunLifecycleResponse {\n+ const succeeded = results.filter((result) => result.ok).length;\n+ return {\n+ results,\n+ summary: {\n+ requested: results.length,\n+ succeeded,\n+ failed: results.length - succeeded,\n+ },\n+ };\n+}\n+\n+function requestJsonBody(request: CapturedRequest): unknown {\n+ return typeof request.data === \"string\" ? JSON.parse(request.data) : request.data;\n }\n \n async function expectLifecycleError(\n@@ -141,6 +174,76 @@ describe(\"run lifecycle actions\", () => {\n expect(result.lifecycle.archived).toBe(false);\n });\n \n+ test(\"archiveRuns sends one batch request and parses results\", async () => {\n+ const stub = stubGeneratedAxiosOnce({\n+ status: 200,\n+ body: batchResponse([\n+ {\n+ run_id: \"run-1\",\n+ ok: true,\n+ outcome: \"archived\",\n+ run: { ...makeRun({ kind: \"succeeded\", reason: \"completed\" }, true), id: \"run-1\" },\n+ },\n+ {\n+ run_id: \"run-2\",\n+ ok: true,\n+ outcome: \"already_archived\",\n+ run: { ...makeRun({ kind: \"succeeded\", reason: \"completed\" }, true), id: \"run-2\" },\n+ },\n+ ]),\n+ });\n+\n+ const result = await archiveRuns([\"run-1\", \"run-2\"]);\n+\n+ expect(stub.requests).toHaveLength(1);\n+ expect(stub.requests[0]?.method?.toUpperCase()).toBe(\"POST\");\n+ expect(stub.requests[0]?.url).toBe(\"/api/v1/runs/archive\");\n+ expect(requestJsonBody(stub.requests[0]!)).toEqual({ run_ids: [\"run-1\", \"run-2\"] });\n+ expect(result.summary).toEqual({ requested: 2, succeeded: 2, failed: 0 });\n+ expect(result.results.map((entry) => entry.outcome)).toEqual([\"archived\", \"already_archived\"]);\n+ });\n+\n+ test(\"unarchiveRuns resolves mixed per-item results without throwing\", async () => {\n+ stubGeneratedAxiosOnce({\n+ status: 200,\n+ body: batchResponse([\n+ {\n+ run_id: \"run-1\",\n+ ok: true,\n+ outcome: \"unarchived\",\n+ run: { ...makeRun({ kind: \"succeeded\", reason: \"completed\" }), id: \"run-1\" },\n+ },\n+ {\n+ run_id: \"run-missing\",\n+ ok: false,\n+ outcome: \"not_found\",\n+ error: { status: \"404\", title: \"Not Found\", detail: \"Run not found.\" },\n+ },\n+ ]),\n+ });\n+\n+ const result = await unarchiveRuns([\"run-1\", \"run-missing\"]);\n+\n+ expect(result.summary).toEqual({ requested: 2, succeeded: 1, failed: 1 });\n+ expect(result.results[1]?.ok).toBe(false);\n+ expect(result.results[1]?.error?.status).toBe(\"404\");\n+ });\n+\n+ test(\"batch lifecycle helpers preserve request-level error envelopes\", async () => {\n+ stubGeneratedAxiosOnce({\n+ status: 400,\n+ body: {\n+ errors: [{ status: \"400\", title: \"Bad Request\", detail: \"run_ids must contain at least one run ID.\" }],\n+ },\n+ });\n+\n+ const error = await expectLifecycleError(archiveRuns([]));\n+ expect(error).toEqual({\n+ status: 400,\n+ errors: [{ status: \"400\", title: \"Bad Request\", detail: \"run_ids must contain at least one run ID.\" }],\n+ });\n+ });\n+\n test(\"retryRun parses a 201 response\", async () => {\n stubGeneratedAxiosOnce({\n status: 201,\ndiff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts\nindex a785feafa..819ec29de 100644\n--- a/apps/fabro-web/app/lib/run-actions.ts\n+++ b/apps/fabro-web/app/lib/run-actions.ts\n@@ -1,4 +1,9 @@\n-import type { ErrorResponseEntry, Run } from \"@qltysh/fabro-api-client\";\n+import type {\n+ BatchRunLifecycleRequest,\n+ BatchRunLifecycleResponse,\n+ ErrorResponseEntry,\n+ Run,\n+} from \"@qltysh/fabro-api-client\";\n \n import {\n ApiError,\n@@ -58,6 +63,20 @@ export async function unarchiveRun(id: string, request?: Request): Promise\n return runLifecycleAction(id, \"unarchive\", request);\n }\n \n+export async function archiveRuns(\n+ runIds: string[],\n+ request?: Request,\n+): Promise {\n+ return batchRunLifecycleAction(runIds, \"archive\", request);\n+}\n+\n+export async function unarchiveRuns(\n+ runIds: string[],\n+ request?: Request,\n+): Promise {\n+ return batchRunLifecycleAction(runIds, \"unarchive\", request);\n+}\n+\n export async function retryRun(id: string, request?: Request): Promise {\n return runLifecycleAction(id, \"retry\", request);\n }\n@@ -182,6 +201,27 @@ async function runLifecycleAction(\n }\n }\n \n+async function batchRunLifecycleAction(\n+ runIds: string[],\n+ action: \"archive\" | \"unarchive\",\n+ request?: Request,\n+): Promise {\n+ try {\n+ // openapi-generator's TypeScript client represents `uniqueItems` arrays as\n+ // Set, but the HTTP wire contract is still a JSON array. Keep an array\n+ // here so Axios serializes the request body correctly.\n+ const body = { run_ids: runIds } as unknown as BatchRunLifecycleRequest;\n+ switch (action) {\n+ case \"archive\":\n+ return await apiData(() => runsApi.batchArchiveRuns(body, requestSignalOptions(request)));\n+ case \"unarchive\":\n+ return await apiData(() => runsApi.batchUnarchiveRuns(body, requestSignalOptions(request)));\n+ }\n+ } catch (error) {\n+ throw lifecycleActionErrorFromError(error);\n+ }\n+}\n+\n function lifecycleActionErrorFromError(error: unknown): LifecycleActionError {\n if (!(error instanceof ApiError)) throw error;\n return {\ndiff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx\nindex c56e56e1e..4c6c85370 100644\n--- a/apps/fabro-web/app/routes/runs.test.tsx\n+++ b/apps/fabro-web/app/routes/runs.test.tsx\n@@ -9,6 +9,7 @@ import {\n RUNS_PREFERENCES_STORAGE_KEY,\n runsQuickStartCommands,\n shouldRefreshBoardForEvent,\n+ summarizeBatchLifecycleAction,\n } from \"./runs\";\n \n function boardRun(id: string, column: BoardColumn, questionText?: string): Run {\n@@ -173,6 +174,36 @@ describe(\"runs route board mapping\", () => {\n \"fabro run hello\",\n ]);\n });\n+\n+ test(\"summarizes successful batch archive and unarchive actions\", () => {\n+ expect(\n+ summarizeBatchLifecycleAction(\"Archive\", 2, { succeeded: 2, failed: 0 }),\n+ ).toEqual({\n+ toast: { message: \"Archived 2 runs.\" },\n+ allSucceeded: true,\n+ });\n+ expect(\n+ summarizeBatchLifecycleAction(\"Unarchive\", 1, { succeeded: 1, failed: 0 }),\n+ ).toEqual({\n+ toast: { message: \"Unarchived 1 run.\" },\n+ allSucceeded: true,\n+ });\n+ });\n+\n+ test(\"summarizes partial and failed batch lifecycle actions\", () => {\n+ expect(\n+ summarizeBatchLifecycleAction(\"Archive\", 3, { succeeded: 2, failed: 1 }),\n+ ).toEqual({\n+ toast: { message: \"Archived 2 of 3 runs. 1 failed.\", tone: \"error\" },\n+ allSucceeded: false,\n+ });\n+ expect(\n+ summarizeBatchLifecycleAction(\"Unarchive\", 2, { succeeded: 0, failed: 2 }),\n+ ).toEqual({\n+ toast: { message: \"Couldn't unarchive 2 runs. Try again.\", tone: \"error\" },\n+ allSucceeded: false,\n+ });\n+ });\n });\n \n describe(\"runs route workspace preferences\", () => {\ndiff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx\nindex 2388a8859..b0d7d3456 100644\n--- a/apps/fabro-web/app/routes/runs.tsx\n+++ b/apps/fabro-web/app/routes/runs.tsx\n@@ -31,8 +31,10 @@ import { useToast } from \"../components/toast\";\n import { mutateRunListCaches } from \"../lib/board-cache\";\n import { shouldRefreshBoardForEvent, useBoardEvents } from \"../lib/board-events\";\n import { useAllRuns, useAuthConfig, useRunsPage, useSystemInfo } from \"../lib/queries\";\n-import { archiveRun, canArchive, canUnarchive, unarchiveRun } from \"../lib/run-actions\";\n+import { archiveRuns, canArchive, canUnarchive, unarchiveRuns } from \"../lib/run-actions\";\n import type {\n+ BatchRunLifecycleResponse,\n+ BatchRunLifecycleSummary,\n BoardColumn,\n ListRunsDirectionEnum,\n ListRunsSortEnum,\n@@ -65,6 +67,51 @@ const columnStyles: Record = {\n const defaultColumnStyle: ColumnStyle = { actions: [] };\n const defaultColumnColors = { label: \"\", dot: \"bg-fg-muted\", text: \"text-fg-muted\" };\n \n+function runWord(n: number) {\n+ return n === 1 ? \"run\" : \"runs\";\n+}\n+\n+type BatchLifecycleLabel = \"Archive\" | \"Unarchive\";\n+\n+interface BatchLifecycleToastSummary {\n+ toast: {\n+ message: string;\n+ tone?: \"error\";\n+ };\n+ allSucceeded: boolean;\n+}\n+\n+export function summarizeBatchLifecycleAction(\n+ label: BatchLifecycleLabel,\n+ total: number,\n+ summary: Pick,\n+): BatchLifecycleToastSummary {\n+ const succeeded = summary.succeeded;\n+ const failed = summary.failed;\n+ if (failed === 0) {\n+ return {\n+ toast: { message: `${label}d ${succeeded} ${runWord(succeeded)}.` },\n+ allSucceeded: true,\n+ };\n+ }\n+ if (succeeded === 0) {\n+ return {\n+ toast: {\n+ message: `Couldn't ${label.toLowerCase()} ${total} ${runWord(total)}. Try again.`,\n+ tone: \"error\",\n+ },\n+ allSucceeded: false,\n+ };\n+ }\n+ return {\n+ toast: {\n+ message: `${label}d ${succeeded} of ${total} ${runWord(total)}. ${failed} failed.`,\n+ tone: \"error\",\n+ },\n+ allSucceeded: false,\n+ };\n+}\n+\n interface BoardRunsResponse {\n data: Run[];\n }\n@@ -463,25 +510,15 @@ function ColumnActionsMenu({ column }: { column: Column }) {\n setPending(true);\n const total = archivable.length;\n try {\n- const results = await Promise.allSettled(\n- archivable.map((item) => archiveRun(item.id)),\n+ const response = await archiveRuns(archivable.map((item) => item.id));\n+ push(summarizeBatchLifecycleAction(\"Archive\", total, response.summary).toast);\n+ } catch {\n+ push(\n+ summarizeBatchLifecycleAction(\"Archive\", total, {\n+ succeeded: 0,\n+ failed: total,\n+ }).toast,\n );\n- const succeeded = results.filter((r) => r.status === \"fulfilled\").length;\n- const failed = total - succeeded;\n- const runWord = (n: number) => (n === 1 ? \"run\" : \"runs\");\n- if (failed === 0) {\n- push({ message: `Archived ${total} ${runWord(total)}.` });\n- } else if (succeeded === 0) {\n- push({\n- message: `Couldn't archive ${total} ${runWord(total)}. Try again.`,\n- tone: \"error\",\n- });\n- } else {\n- push({\n- message: `Archived ${succeeded} of ${total} runs. ${failed} failed.`,\n- tone: \"error\",\n- });\n- }\n } finally {\n setPending(false);\n mutateRunListCaches(mutate);\n@@ -1408,12 +1445,10 @@ function BulkActionToolbar({\n \n if (count === 0) return null;\n \n- const runWord = (n: number) => (n === 1 ? \"run\" : \"runs\");\n-\n async function runBulk(\n label: \"Archive\" | \"Unarchive\",\n eligible: RunWithStatus[],\n- action: (id: string) => Promise,\n+ action: (ids: string[]) => Promise,\n ) {\n if (pending) return;\n if (eligible.length === 0) {\n@@ -1422,23 +1457,19 @@ function BulkActionToolbar({\n }\n setPending(true);\n try {\n- const results = await Promise.allSettled(eligible.map((r) => action(r.id)));\n- const succeeded = results.filter((r) => r.status === \"fulfilled\").length;\n- const failed = eligible.length - succeeded;\n- if (failed === 0) {\n- push({ message: `${label}d ${succeeded} ${runWord(succeeded)}.` });\n+ const response = await action(eligible.map((r) => r.id));\n+ const summary = summarizeBatchLifecycleAction(label, eligible.length, response.summary);\n+ push(summary.toast);\n+ if (summary.allSucceeded) {\n onClear();\n- } else if (succeeded === 0) {\n- push({\n- message: `Couldn't ${label.toLowerCase()} ${eligible.length} ${runWord(eligible.length)}. Try again.`,\n- tone: \"error\",\n- });\n- } else {\n- push({\n- message: `${label}d ${succeeded} of ${eligible.length} ${runWord(eligible.length)}. ${failed} failed.`,\n- tone: \"error\",\n- });\n }\n+ } catch {\n+ push(\n+ summarizeBatchLifecycleAction(label, eligible.length, {\n+ succeeded: 0,\n+ failed: eligible.length,\n+ }).toast,\n+ );\n } finally {\n setPending(false);\n mutateRunListCaches(mutate);\n@@ -1459,14 +1490,14 @@ function BulkActionToolbar({\n }\n- disabled={pending || archivable.length === 0}\n- onClick={() => runBulk(\"Archive\", archivable, archiveRun)}\n+ disabled={pending}\n+ onClick={() => runBulk(\"Archive\", archivable, archiveRuns)}\n />\n }\n- disabled={pending || unarchivable.length === 0}\n- onClick={() => runBulk(\"Unarchive\", unarchivable, unarchiveRun)}\n+ disabled={pending}\n+ onClick={() => runBulk(\"Unarchive\", unarchivable, unarchiveRuns)}\n />\n \n+ Marks up to 250 terminal runs as archived in one fail-soft,\n+ non-transactional request. Each run is processed independently and\n+ successful items emit the same per-run archive events as\n+ `POST /api/v1/runs/{id}/archive`. A valid batch returns `200` even\n+ when some items fail; inspect `results` and `summary` for per-run\n+ outcomes. Invalid request bodies are rejected before mutating any run.\n+ requestBody:\n+ required: true\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/BatchRunLifecycleRequest\"\n+ responses:\n+ \"200\":\n+ description: Batch processed\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/BatchRunLifecycleResponse\"\n+ \"400\":\n+ description: Invalid batch request\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"401\":\n+ description: Not authenticated\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"500\":\n+ description: Request-level server error\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+\n+ /api/v1/runs/unarchive:\n+ post:\n+ operationId: batchUnarchiveRuns\n+ tags: [Runs]\n+ summary: Unarchive Runs\n+ description: >\n+ Restores up to 250 archived runs in one fail-soft, non-transactional\n+ request. Each run is processed independently and successful items emit\n+ the same per-run unarchive events as\n+ `POST /api/v1/runs/{id}/unarchive`. A valid batch returns `200` even\n+ when some items fail; inspect `results` and `summary` for per-run\n+ outcomes. Invalid request bodies are rejected before mutating any run.\n+ requestBody:\n+ required: true\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/BatchRunLifecycleRequest\"\n+ responses:\n+ \"200\":\n+ description: Batch processed\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/BatchRunLifecycleResponse\"\n+ \"400\":\n+ description: Invalid batch request\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"401\":\n+ description: Not authenticated\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"500\":\n+ description: Request-level server error\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+\n /api/v1/runs/resolve:\n get:\n operationId: resolveRun\n@@ -5184,6 +5290,94 @@ components:\n meta:\n $ref: \"#/components/schemas/PaginationMeta\"\n \n+ BatchRunLifecycleRequest:\n+ description: Run IDs to archive or unarchive as one bounded fail-soft batch.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - run_ids\n+ properties:\n+ run_ids:\n+ type: array\n+ description: Run IDs to process, in result order.\n+ minItems: 1\n+ maxItems: 250\n+ uniqueItems: true\n+ items:\n+ type: string\n+ example: 01HZX6M29F1CD5YYMHT1F5D7WQ\n+\n+ BatchRunLifecycleResponse:\n+ description: Per-run results for a fail-soft batch archive or unarchive request.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - results\n+ - summary\n+ properties:\n+ results:\n+ type: array\n+ description: Results ordered exactly like the request `run_ids`.\n+ items:\n+ $ref: \"#/components/schemas/BatchRunLifecycleResult\"\n+ summary:\n+ $ref: \"#/components/schemas/BatchRunLifecycleSummary\"\n+\n+ BatchRunLifecycleResult:\n+ description: Result for one run in a batch archive or unarchive request.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - run_id\n+ - ok\n+ - outcome\n+ properties:\n+ run_id:\n+ type: string\n+ description: Run ID from the request item.\n+ ok:\n+ type: boolean\n+ description: Whether this item succeeded.\n+ outcome:\n+ type: string\n+ enum:\n+ - archived\n+ - already_archived\n+ - unarchived\n+ - not_archived\n+ - not_found\n+ - conflict\n+ - error\n+ description: Machine-readable item outcome.\n+ run:\n+ $ref: \"#/components/schemas/Run\"\n+ description: Decorated run summary for successful items when it can be loaded.\n+ error:\n+ $ref: \"#/components/schemas/ErrorResponseEntry\"\n+ description: Structured item error for failed items.\n+\n+ BatchRunLifecycleSummary:\n+ description: Aggregate counts for a batch archive or unarchive request.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - requested\n+ - succeeded\n+ - failed\n+ properties:\n+ requested:\n+ type: integer\n+ minimum: 0\n+ description: Number of requested run IDs.\n+ succeeded:\n+ type: integer\n+ minimum: 0\n+ description: Number of item results with `ok=true`.\n+ failed:\n+ type: integer\n+ minimum: 0\n+ description: Number of item results with `ok=false`.\n+\n PairId:\n type: string\n description: Durable run pair identifier.\ndiff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs\nindex 179e6b8d3..996748775 100644\n--- a/lib/crates/fabro-server/src/server.rs\n+++ b/lib/crates/fabro-server/src/server.rs\n@@ -23,23 +23,25 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;\n use bytes::Bytes;\n pub use fabro_api::types::{\n AggregateBilling, AggregateBillingTotals, ApiQuestion, AppendEventResponse, ArtifactEntry,\n- ArtifactListResponse, BillingByModel, BillingStageRef, CloseRunPullRequestResponse,\n- CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse,\n- CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,\n- CreateRunPullRequestRequest, CreateSecretRequest, DeleteRunResponse, DeleteRunSandbox,\n- DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow,\n- ForkRequest, ForkResponse, LinkRunPullRequestRequest, MergeRunPullRequestRequest,\n- MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList,\n- PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, Provider,\n- ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection,\n- RenderWorkflowGraphRequest, RewindRequest, RewindResponse, RunArtifactEntry,\n- RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest,\n- RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,\n- SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState,\n- StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources,\n- SystemDiskResourceScope, SystemDiskResources, SystemInfoResponse, SystemMemoryResourceScope,\n- SystemMemoryResources, SystemRepairRunIssue, SystemRepairRunsResponse, SystemResourcesResponse,\n- SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse,\n+ ArtifactListResponse, BatchRunLifecycleRequest, BatchRunLifecycleResponse,\n+ BatchRunLifecycleResult, BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary,\n+ BillingByModel, BillingStageRef, CloseRunPullRequestResponse, CompletionContentPart,\n+ CompletionMessage, CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode,\n+ CompletionUsage, CreateCompletionRequest, CreateRunPullRequestRequest, CreateSecretRequest,\n+ DeleteRunResponse, DeleteRunSandbox, DeleteSecretRequest, DenyRunRequest, DiskUsageResponse,\n+ DiskUsageRunRow, DiskUsageSummaryRow, ErrorResponseEntry, ForkRequest, ForkResponse,\n+ LinkRunPullRequestRequest, MergeRunPullRequestRequest, MergeRunPullRequestResponse,\n+ ModelReference, PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse,\n+ PreviewUrlRequest, PreviewUrlResponse, Provider, ProviderList, PruneRunEntry, PruneRunsRequest,\n+ PruneRunsResponse, RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RewindRequest,\n+ RewindResponse, Run, RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage,\n+ RunBillingTotals, RunError, RunManifest, RunStage, SandboxDetails, SandboxFileEntry,\n+ SandboxFileListResponse, SandboxService, SandboxServiceListResponse, SshAccessRequest,\n+ SshAccessResponse, StageHandler, StageState, StartRunRequest, SubmitAnswerRequest,\n+ SystemCpuResourceScope, SystemCpuResources, SystemDiskResourceScope, SystemDiskResources,\n+ SystemInfoResponse, SystemMemoryResourceScope, SystemMemoryResources, SystemRepairRunIssue,\n+ SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts, TimelineEntryResponse,\n+ VncPreviewResponse, WriteBlobResponse,\n };\n use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message};\n #[cfg(test)]\ndiff --git a/lib/crates/fabro-server/src/server/handler/lifecycle.rs b/lib/crates/fabro-server/src/server/handler/lifecycle.rs\nindex b7aa26c8a..249b0d22e 100644\n--- a/lib/crates/fabro-server/src/server/handler/lifecycle.rs\n+++ b/lib/crates/fabro-server/src/server/handler/lifecycle.rs\n@@ -1,11 +1,14 @@\n+use std::collections::HashSet;\n use std::sync::Arc;\n \n use chrono::Utc;\n \n use super::super::{\n- ApiError, AppState, DenyRunRequest, FailureReason, ForkRequest, ForkResponse, HeaderMap,\n+ ApiError, AppState, BatchRunLifecycleRequest, BatchRunLifecycleResponse,\n+ BatchRunLifecycleResult, BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary,\n+ DenyRunRequest, ErrorResponseEntry, FailureReason, ForkRequest, ForkResponse, HeaderMap,\n IntoResponse, Json, Path, PendingReason, Principal, RequireRunScopedOrRunTools, RequiredUser,\n- Response, RewindRequest, RewindResponse, Router, RunAnswerTransport, RunControlAction,\n+ Response, RewindRequest, RewindResponse, Router, Run, RunAnswerTransport, RunControlAction,\n RunExecutionMode, RunId, RunRunnableSource, RunStatus, StartRunRequest, State, StatusCode,\n Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError, append_control_request,\n clear_live_run_state, durable_run_status, get, load_pending_control, managed_run, operations,\n@@ -22,6 +25,8 @@ pub(super) fn routes() -> Router> {\n .route(\"/runs/{id}/deny\", post(deny_run))\n .route(\"/runs/{id}/pause\", post(pause_run))\n .route(\"/runs/{id}/unpause\", post(unpause_run))\n+ .route(\"/runs/archive\", post(batch_archive_runs))\n+ .route(\"/runs/unarchive\", post(batch_unarchive_runs))\n .route(\"/runs/{id}/archive\", post(archive_run))\n .route(\"/runs/{id}/rewind\", post(rewind_run))\n .route(\"/runs/{id}/retry\", post(retry_run))\n@@ -684,6 +689,34 @@ async fn unarchive_run(\n run_archive_action(state, actor, id, ArchiveAction::Unarchive).await\n }\n \n+async fn batch_archive_runs(\n+ RequiredUser(user): RequiredUser,\n+ State(state): State>,\n+ Json(request): Json,\n+) -> Response {\n+ batch_run_archive_action(\n+ state,\n+ Principal::User(user),\n+ request,\n+ ArchiveAction::Archive,\n+ )\n+ .await\n+}\n+\n+async fn batch_unarchive_runs(\n+ RequiredUser(user): RequiredUser,\n+ State(state): State>,\n+ Json(request): Json,\n+) -> Response {\n+ batch_run_archive_action(\n+ state,\n+ Principal::User(user),\n+ request,\n+ ArchiveAction::Unarchive,\n+ )\n+ .await\n+}\n+\n async fn rewind_run(\n subject: RequiredUser,\n State(state): State>,\n@@ -876,6 +909,175 @@ enum ArchiveAction {\n Unarchive,\n }\n \n+const MAX_BATCH_RUN_LIFECYCLE_IDS: usize = 250;\n+\n+async fn batch_run_archive_action(\n+ state: Arc,\n+ actor: Principal,\n+ request: BatchRunLifecycleRequest,\n+ action: ArchiveAction,\n+) -> Response {\n+ let ids = match validate_batch_run_ids(request) {\n+ Ok(ids) => ids,\n+ Err(err) => return err.into_response(),\n+ };\n+ let mut results = Vec::with_capacity(ids.len());\n+\n+ for id in ids {\n+ results.push(batch_run_archive_item(state.as_ref(), actor.clone(), id, action).await);\n+ }\n+\n+ let requested = results.len() as u64;\n+ let succeeded = results.iter().filter(|result| result.ok).count() as u64;\n+ (\n+ StatusCode::OK,\n+ Json(BatchRunLifecycleResponse {\n+ results,\n+ summary: BatchRunLifecycleSummary {\n+ requested,\n+ succeeded,\n+ failed: requested - succeeded,\n+ },\n+ }),\n+ )\n+ .into_response()\n+}\n+\n+fn validate_batch_run_ids(request: BatchRunLifecycleRequest) -> Result, ApiError> {\n+ if request.run_ids.is_empty() {\n+ return Err(ApiError::bad_request(\n+ \"run_ids must contain at least one run ID.\",\n+ ));\n+ }\n+ if request.run_ids.len() > MAX_BATCH_RUN_LIFECYCLE_IDS {\n+ return Err(ApiError::bad_request(format!(\n+ \"run_ids must contain no more than {MAX_BATCH_RUN_LIFECYCLE_IDS} run IDs.\",\n+ )));\n+ }\n+\n+ let mut seen = HashSet::with_capacity(request.run_ids.len());\n+ let mut ids = Vec::with_capacity(request.run_ids.len());\n+ for raw in request.run_ids {\n+ let id = raw.parse::().map_err(|_| {\n+ ApiError::bad_request(format!(\"run_ids contains invalid run ID: {raw}\"))\n+ })?;\n+ if !seen.insert(id) {\n+ return Err(ApiError::bad_request(\n+ \"run_ids must not contain duplicate IDs.\",\n+ ));\n+ }\n+ ids.push(id);\n+ }\n+ Ok(ids)\n+}\n+\n+async fn batch_run_archive_item(\n+ state: &AppState,\n+ actor: Principal,\n+ id: RunId,\n+ action: ArchiveAction,\n+) -> BatchRunLifecycleResult {\n+ let result = match action {\n+ ArchiveAction::Archive => operations::archive(&state.store, &id, Some(actor))\n+ .await\n+ .map(|outcome| match outcome {\n+ operations::ArchiveOutcome::Archived { .. } => {\n+ BatchRunLifecycleResultOutcome::Archived\n+ }\n+ operations::ArchiveOutcome::AlreadyArchived => {\n+ BatchRunLifecycleResultOutcome::AlreadyArchived\n+ }\n+ }),\n+ ArchiveAction::Unarchive => operations::unarchive(&state.store, &id, Some(actor))\n+ .await\n+ .map(|outcome| match outcome {\n+ operations::UnarchiveOutcome::Unarchived { .. } => {\n+ BatchRunLifecycleResultOutcome::Unarchived\n+ }\n+ operations::UnarchiveOutcome::NotArchived { .. } => {\n+ BatchRunLifecycleResultOutcome::NotArchived\n+ }\n+ }),\n+ };\n+\n+ match result {\n+ Ok(outcome) => match load_decorated_run_after_lifecycle_action(state, id).await {\n+ Ok(run) => batch_success_result(id, outcome, run),\n+ Err(error) => batch_failure_result(id, BatchRunLifecycleResultOutcome::Error, error),\n+ },\n+ Err(WorkflowError::Precondition(message)) => batch_failure_result(\n+ id,\n+ BatchRunLifecycleResultOutcome::Conflict,\n+ batch_error_entry(StatusCode::CONFLICT, message),\n+ ),\n+ Err(WorkflowError::RunNotFound(_)) => batch_failure_result(\n+ id,\n+ BatchRunLifecycleResultOutcome::NotFound,\n+ batch_error_entry(StatusCode::NOT_FOUND, \"Run not found.\"),\n+ ),\n+ Err(err) => batch_failure_result(\n+ id,\n+ BatchRunLifecycleResultOutcome::Error,\n+ batch_error_entry(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),\n+ ),\n+ }\n+}\n+\n+async fn load_decorated_run_after_lifecycle_action(\n+ state: &AppState,\n+ id: RunId,\n+) -> Result {\n+ match state.store.get_cached_summary(&id, Utc::now()).await {\n+ Ok(Some(summary)) => Ok(state.decorate_run_summary(summary).await),\n+ Ok(None) => Err(batch_error_entry(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ \"Failed to load run summary after lifecycle action.\",\n+ )),\n+ Err(err) => Err(batch_error_entry(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ err.to_string(),\n+ )),\n+ }\n+}\n+\n+fn batch_success_result(\n+ id: RunId,\n+ outcome: BatchRunLifecycleResultOutcome,\n+ run: Run,\n+) -> BatchRunLifecycleResult {\n+ BatchRunLifecycleResult {\n+ run_id: id.to_string(),\n+ ok: true,\n+ outcome,\n+ run: Some(run),\n+ error: None,\n+ }\n+}\n+\n+fn batch_failure_result(\n+ id: RunId,\n+ outcome: BatchRunLifecycleResultOutcome,\n+ error: ErrorResponseEntry,\n+) -> BatchRunLifecycleResult {\n+ BatchRunLifecycleResult {\n+ run_id: id.to_string(),\n+ ok: false,\n+ outcome,\n+ run: None,\n+ error: Some(error),\n+ }\n+}\n+\n+fn batch_error_entry(status: StatusCode, detail: impl Into) -> ErrorResponseEntry {\n+ ErrorResponseEntry {\n+ status: status.as_u16().to_string(),\n+ title: status.canonical_reason().unwrap_or(\"Unknown\").to_string(),\n+ detail: detail.into(),\n+ code: None,\n+ request_id: None,\n+ }\n+}\n+\n async fn run_archive_action(\n state: Arc,\n actor: Principal,\ndiff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs\nindex 47c7d94d2..bd17bee80 100644\n--- a/lib/crates/fabro-server/src/server/tests.rs\n+++ b/lib/crates/fabro-server/src/server/tests.rs\n@@ -632,6 +632,15 @@ fn json_bearer_request(\n .unwrap()\n }\n \n+fn json_request(method: Method, path: &str, body: &serde_json::Value) -> Request {\n+ Request::builder()\n+ .method(method)\n+ .uri(api(path))\n+ .header(header::CONTENT_TYPE, \"application/json\")\n+ .body(Body::from(serde_json::to_vec(&body).unwrap()))\n+ .unwrap()\n+}\n+\n fn canonical_origin_settings(url: &str) -> ServerSettings {\n server_settings_from_toml(&format!(\n r#\"\n@@ -10512,6 +10521,337 @@ async fn archive_and_unarchive_updates_listing_visibility() {\n assert_eq!(run_json_status(restored_item)[\"reason\"], \"completed\");\n }\n \n+fn run_submitted_event() -> workflow_event::Event {\n+ workflow_event::Event::RunSubmitted {\n+ definition_blob: None,\n+ }\n+}\n+\n+fn workflow_completed_event() -> workflow_event::Event {\n+ workflow_event::Event::WorkflowRunCompleted {\n+ timing: fabro_types::RunTiming::wall_only(1000),\n+ artifact_count: 0,\n+ status: \"succeeded\".to_string(),\n+ reason: SuccessReason::Completed,\n+ total_usd_micros: None,\n+ final_git_commit_sha: None,\n+ final_patch: None,\n+ diff_summary: None,\n+ billing: None,\n+ }\n+}\n+\n+async fn create_succeeded_run(state: &Arc, run_id: RunId) {\n+ create_durable_run_with_events(state, run_id, &[\n+ run_submitted_event(),\n+ workflow_completed_event(),\n+ ])\n+ .await;\n+}\n+\n+async fn create_running_run(state: &Arc, run_id: RunId) {\n+ create_durable_run_with_events(state, run_id, &[\n+ run_submitted_event(),\n+ workflow_event::Event::RunRunning,\n+ ])\n+ .await;\n+}\n+\n+fn batch_lifecycle_body(run_ids: &[RunId]) -> serde_json::Value {\n+ json!({\n+ \"run_ids\": run_ids.iter().map(ToString::to_string).collect::>(),\n+ })\n+}\n+\n+fn assert_batch_result(result: &serde_json::Value, run_id: RunId, ok: bool, outcome: &str) {\n+ assert_eq!(result[\"run_id\"], run_id.to_string());\n+ assert_eq!(result[\"ok\"], ok);\n+ assert_eq!(result[\"outcome\"], outcome);\n+ if ok {\n+ assert!(\n+ result[\"run\"].is_object(),\n+ \"successful result should include run: {result}\"\n+ );\n+ assert!(\n+ result[\"error\"].is_null(),\n+ \"successful result should omit error: {result}\"\n+ );\n+ } else {\n+ assert!(\n+ result[\"error\"].is_object(),\n+ \"failed result should include error: {result}\"\n+ );\n+ assert!(\n+ result[\"run\"].is_null(),\n+ \"failed result should omit run: {result}\"\n+ );\n+ }\n+}\n+\n+#[tokio::test]\n+async fn batch_archive_and_unarchive_updates_listing_visibility() {\n+ let state = test_app_state();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+ let first_id = RunId::new();\n+ let second_id = RunId::new();\n+ create_succeeded_run(&state, first_id).await;\n+ create_succeeded_run(&state, second_id).await;\n+\n+ let archive_response = app\n+ .clone()\n+ .oneshot(json_request(\n+ Method::POST,\n+ \"/runs/archive\",\n+ &batch_lifecycle_body(&[first_id, second_id]),\n+ ))\n+ .await\n+ .unwrap();\n+ let archive_body = response_json!(archive_response, StatusCode::OK).await;\n+ assert_eq!(archive_body[\"summary\"][\"requested\"], 2);\n+ assert_eq!(archive_body[\"summary\"][\"succeeded\"], 2);\n+ assert_eq!(archive_body[\"summary\"][\"failed\"], 0);\n+ let archive_results = archive_body[\"results\"].as_array().unwrap();\n+ assert_batch_result(&archive_results[0], first_id, true, \"archived\");\n+ assert!(run_json_archived(&archive_results[0][\"run\"]));\n+ assert_batch_result(&archive_results[1], second_id, true, \"archived\");\n+ assert!(run_json_archived(&archive_results[1][\"run\"]));\n+\n+ let hidden_response = app\n+ .clone()\n+ .oneshot(\n+ Request::builder()\n+ .method(\"GET\")\n+ .uri(api(\"/runs\"))\n+ .body(Body::empty())\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+ let hidden_body = response_json!(hidden_response, StatusCode::OK).await;\n+ assert!(\n+ hidden_body[\"data\"].as_array().unwrap().iter().all(|item| {\n+ let item_id = run_json_id(item);\n+ item_id != Some(&first_id.to_string()) && item_id != Some(&second_id.to_string())\n+ }),\n+ \"archived runs should be hidden from default listing\"\n+ );\n+\n+ let unarchive_response = app\n+ .clone()\n+ .oneshot(json_request(\n+ Method::POST,\n+ \"/runs/unarchive\",\n+ &batch_lifecycle_body(&[first_id, second_id]),\n+ ))\n+ .await\n+ .unwrap();\n+ let unarchive_body = response_json!(unarchive_response, StatusCode::OK).await;\n+ assert_eq!(unarchive_body[\"summary\"][\"requested\"], 2);\n+ assert_eq!(unarchive_body[\"summary\"][\"succeeded\"], 2);\n+ assert_eq!(unarchive_body[\"summary\"][\"failed\"], 0);\n+ let unarchive_results = unarchive_body[\"results\"].as_array().unwrap();\n+ assert_batch_result(&unarchive_results[0], first_id, true, \"unarchived\");\n+ assert!(!run_json_archived(&unarchive_results[0][\"run\"]));\n+ assert_batch_result(&unarchive_results[1], second_id, true, \"unarchived\");\n+ assert!(!run_json_archived(&unarchive_results[1][\"run\"]));\n+\n+ let restored_response = app\n+ .oneshot(\n+ Request::builder()\n+ .method(\"GET\")\n+ .uri(api(\"/runs\"))\n+ .body(Body::empty())\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+ let restored_body = response_json!(restored_response, StatusCode::OK).await;\n+ for run_id in [first_id, second_id] {\n+ let restored_item = restored_body[\"data\"]\n+ .as_array()\n+ .unwrap()\n+ .iter()\n+ .find(|item| run_json_id(item) == Some(&run_id.to_string()))\n+ .expect(\"unarchived run should reappear in default listing\");\n+ assert_eq!(run_json_status(restored_item)[\"kind\"], \"succeeded\");\n+ }\n+}\n+\n+#[tokio::test]\n+async fn batch_archive_reports_ordered_mixed_results_without_rollback() {\n+ let state = test_app_state();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+ let already_archived_id = RunId::new();\n+ let terminal_id = RunId::new();\n+ let running_id = RunId::new();\n+ let missing_id = RunId::new();\n+ create_succeeded_run(&state, already_archived_id).await;\n+ create_succeeded_run(&state, terminal_id).await;\n+ create_running_run(&state, running_id).await;\n+\n+ let already_archived_response = app\n+ .clone()\n+ .oneshot(\n+ Request::builder()\n+ .method(\"POST\")\n+ .uri(api(&format!(\"/runs/{already_archived_id}/archive\")))\n+ .body(Body::empty())\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+ assert_status!(already_archived_response, StatusCode::OK).await;\n+\n+ let response = app\n+ .clone()\n+ .oneshot(json_request(\n+ Method::POST,\n+ \"/runs/archive\",\n+ &batch_lifecycle_body(&[already_archived_id, terminal_id, running_id, missing_id]),\n+ ))\n+ .await\n+ .unwrap();\n+ let body = response_json!(response, StatusCode::OK).await;\n+ assert_eq!(body[\"summary\"][\"requested\"], 4);\n+ assert_eq!(body[\"summary\"][\"succeeded\"], 2);\n+ assert_eq!(body[\"summary\"][\"failed\"], 2);\n+ let results = body[\"results\"].as_array().unwrap();\n+ assert_batch_result(&results[0], already_archived_id, true, \"already_archived\");\n+ assert_batch_result(&results[1], terminal_id, true, \"archived\");\n+ assert_batch_result(&results[2], running_id, false, \"conflict\");\n+ assert_eq!(results[2][\"error\"][\"status\"], \"409\");\n+ assert_batch_result(&results[3], missing_id, false, \"not_found\");\n+ assert_eq!(results[3][\"error\"][\"status\"], \"404\");\n+\n+ let terminal_response = app\n+ .oneshot(\n+ Request::builder()\n+ .method(\"GET\")\n+ .uri(api(&format!(\"/runs/{terminal_id}\")))\n+ .body(Body::empty())\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+ let terminal_body = response_json!(terminal_response, StatusCode::OK).await;\n+ assert!(run_json_archived(&terminal_body));\n+}\n+\n+#[tokio::test]\n+async fn batch_unarchive_treats_terminal_not_archived_as_success() {\n+ let state = test_app_state();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+ let archived_id = RunId::new();\n+ let not_archived_id = RunId::new();\n+ create_succeeded_run(&state, archived_id).await;\n+ create_succeeded_run(&state, not_archived_id).await;\n+\n+ let archive_response = app\n+ .clone()\n+ .oneshot(\n+ Request::builder()\n+ .method(\"POST\")\n+ .uri(api(&format!(\"/runs/{archived_id}/archive\")))\n+ .body(Body::empty())\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+ assert_status!(archive_response, StatusCode::OK).await;\n+\n+ let response = app\n+ .oneshot(json_request(\n+ Method::POST,\n+ \"/runs/unarchive\",\n+ &batch_lifecycle_body(&[archived_id, not_archived_id]),\n+ ))\n+ .await\n+ .unwrap();\n+ let body = response_json!(response, StatusCode::OK).await;\n+ assert_eq!(body[\"summary\"][\"requested\"], 2);\n+ assert_eq!(body[\"summary\"][\"succeeded\"], 2);\n+ assert_eq!(body[\"summary\"][\"failed\"], 0);\n+ let results = body[\"results\"].as_array().unwrap();\n+ assert_batch_result(&results[0], archived_id, true, \"unarchived\");\n+ assert_batch_result(&results[1], not_archived_id, true, \"not_archived\");\n+}\n+\n+#[tokio::test]\n+async fn batch_lifecycle_rejects_invalid_requests_before_mutating_runs() {\n+ let state = test_app_state();\n+ let app = crate::test_support::build_test_router(Arc::clone(&state));\n+ let run_id = RunId::new();\n+ create_succeeded_run(&state, run_id).await;\n+ let too_many_ids = (0..251)\n+ .map(|_| RunId::new().to_string())\n+ .collect::>();\n+ let invalid_requests = [\n+ json!({ \"run_ids\": [] }),\n+ json!({ \"run_ids\": [run_id.to_string(), run_id.to_string()] }),\n+ json!({ \"run_ids\": [\"not-a-run-id\"] }),\n+ json!({ \"run_ids\": too_many_ids }),\n+ ];\n+\n+ for body in invalid_requests {\n+ let response = app\n+ .clone()\n+ .oneshot(json_request(Method::POST, \"/runs/archive\", &body))\n+ .await\n+ .unwrap();\n+ assert_status!(response, StatusCode::BAD_REQUEST).await;\n+ }\n+\n+ let response = app\n+ .oneshot(\n+ Request::builder()\n+ .method(\"GET\")\n+ .uri(api(&format!(\"/runs/{run_id}\")))\n+ .body(Body::empty())\n+ .unwrap(),\n+ )\n+ .await\n+ .unwrap();\n+ let body = response_json!(response, StatusCode::OK).await;\n+ assert!(!run_json_archived(&body));\n+}\n+\n+#[tokio::test]\n+async fn batch_lifecycle_requires_user_authentication() {\n+ let (_state, app) = jwt_auth_app();\n+ let user_jwt = issue_test_user_jwt();\n+ let run_id = create_run_with_bearer(&app, &user_jwt).await;\n+ let worker_token = issue_test_worker_token(&run_id);\n+ let body = batch_lifecycle_body(&[run_id]);\n+\n+ let unauthenticated = app\n+ .clone()\n+ .oneshot(json_request(Method::POST, \"/runs/archive\", &body))\n+ .await\n+ .unwrap();\n+ assert_status!(unauthenticated, StatusCode::UNAUTHORIZED).await;\n+\n+ for path in [\"/runs/archive\", \"/runs/unarchive\"] {\n+ let worker_response = app\n+ .clone()\n+ .oneshot(json_bearer_request(\n+ Method::POST,\n+ path,\n+ &worker_token,\n+ &body,\n+ ))\n+ .await\n+ .unwrap();\n+ assert!(\n+ matches!(\n+ worker_response.status(),\n+ StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN\n+ ),\n+ \"{path} unexpectedly accepted worker token with status {}\",\n+ worker_response.status()\n+ );\n+ }\n+}\n+\n #[tokio::test]\n async fn archive_unknown_run_returns_not_found() {\n let app = test_app_with();\ndiff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\nindex 2f3ad178a..619940ff5 100644\n--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n+++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n@@ -45,6 +45,10 @@ models/auth-session-user.ts\n models/auth-session.ts\n models/auth-sessions-response.ts\n models/automation-ref.ts\n+models/batch-run-lifecycle-request.ts\n+models/batch-run-lifecycle-response.ts\n+models/batch-run-lifecycle-result.ts\n+models/batch-run-lifecycle-summary.ts\n models/billed-token-counts.ts\n models/billing-by-model.ts\n models/billing-model-ref.ts\ndiff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts\nindex 9a9d2ad2e..327bd8611 100644\n--- a/lib/packages/fabro-api-client/src/api/runs-api.ts\n+++ b/lib/packages/fabro-api-client/src/api/runs-api.ts\n@@ -22,6 +22,10 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj\n // @ts-ignore\n import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';\n // @ts-ignore\n+import type { BatchRunLifecycleRequest } from '../models';\n+// @ts-ignore\n+import type { BatchRunLifecycleResponse } from '../models';\n+// @ts-ignore\n import type { BoardColumn } from '../models';\n // @ts-ignore\n import type { CloseRunPullRequestResponse } from '../models';\n@@ -156,6 +160,88 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)\n options: localVarRequestOptions,\n };\n },\n+ /**\n+ * Marks up to 250 terminal runs as archived in one fail-soft, non-transactional request. Each run is processed independently and successful items emit the same per-run archive events as `POST /api/v1/runs/{id}/archive`. A valid batch returns `200` even when some items fail; inspect `results` and `summary` for per-run outcomes. Invalid request bodies are rejected before mutating any run.\n+ * @summary Archive Runs\n+ * @param {BatchRunLifecycleRequest} batchRunLifecycleRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ batchArchiveRuns: async (batchRunLifecycleRequest: BatchRunLifecycleRequest, options: RawAxiosRequestConfig = {}): Promise => {\n+ // verify required parameter 'batchRunLifecycleRequest' is not null or undefined\n+ assertParamExists('batchArchiveRuns', 'batchRunLifecycleRequest', batchRunLifecycleRequest)\n+ const localVarPath = `/api/v1/runs/archive`;\n+ // use dummy base URL string because the URL constructor only accepts absolute URLs.\n+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);\n+ let baseOptions;\n+ if (configuration) {\n+ baseOptions = configuration.baseOptions;\n+ }\n+\n+ const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};\n+ const localVarHeaderParameter = {} as any;\n+ const localVarQueryParameter = {} as any;\n+\n+ // authentication SessionCookie required\n+\n+ // authentication BearerAuth required\n+ // http bearer authentication required\n+ await setBearerAuthToObject(localVarHeaderParameter, configuration)\n+\n+ localVarHeaderParameter['Content-Type'] = 'application/json';\n+ localVarHeaderParameter['Accept'] = 'application/json';\n+\n+ setSearchParams(localVarUrlObj, localVarQueryParameter);\n+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};\n+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};\n+ localVarRequestOptions.data = serializeDataIfNeeded(batchRunLifecycleRequest, localVarRequestOptions, configuration)\n+\n+ return {\n+ url: toPathString(localVarUrlObj),\n+ options: localVarRequestOptions,\n+ };\n+ },\n+ /**\n+ * Restores up to 250 archived runs in one fail-soft, non-transactional request. Each run is processed independently and successful items emit the same per-run unarchive events as `POST /api/v1/runs/{id}/unarchive`. A valid batch returns `200` even when some items fail; inspect `results` and `summary` for per-run outcomes. Invalid request bodies are rejected before mutating any run.\n+ * @summary Unarchive Runs\n+ * @param {BatchRunLifecycleRequest} batchRunLifecycleRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ batchUnarchiveRuns: async (batchRunLifecycleRequest: BatchRunLifecycleRequest, options: RawAxiosRequestConfig = {}): Promise => {\n+ // verify required parameter 'batchRunLifecycleRequest' is not null or undefined\n+ assertParamExists('batchUnarchiveRuns', 'batchRunLifecycleRequest', batchRunLifecycleRequest)\n+ const localVarPath = `/api/v1/runs/unarchive`;\n+ // use dummy base URL string because the URL constructor only accepts absolute URLs.\n+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);\n+ let baseOptions;\n+ if (configuration) {\n+ baseOptions = configuration.baseOptions;\n+ }\n+\n+ const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};\n+ const localVarHeaderParameter = {} as any;\n+ const localVarQueryParameter = {} as any;\n+\n+ // authentication SessionCookie required\n+\n+ // authentication BearerAuth required\n+ // http bearer authentication required\n+ await setBearerAuthToObject(localVarHeaderParameter, configuration)\n+\n+ localVarHeaderParameter['Content-Type'] = 'application/json';\n+ localVarHeaderParameter['Accept'] = 'application/json';\n+\n+ setSearchParams(localVarUrlObj, localVarQueryParameter);\n+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};\n+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};\n+ localVarRequestOptions.data = serializeDataIfNeeded(batchRunLifecycleRequest, localVarRequestOptions, configuration)\n+\n+ return {\n+ url: toPathString(localVarUrlObj),\n+ options: localVarRequestOptions,\n+ };\n+ },\n /**\n * Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.\n * @summary Cancel Run\n@@ -1436,6 +1522,32 @@ export const RunsApiFp = function(configuration?: Configuration) {\n const localVarOperationServerBasePath = operationServerMap['RunsApi.archiveRun']?.[localVarOperationServerIndex]?.url;\n return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n },\n+ /**\n+ * Marks up to 250 terminal runs as archived in one fail-soft, non-transactional request. Each run is processed independently and successful items emit the same per-run archive events as `POST /api/v1/runs/{id}/archive`. A valid batch returns `200` even when some items fail; inspect `results` and `summary` for per-run outcomes. Invalid request bodies are rejected before mutating any run.\n+ * @summary Archive Runs\n+ * @param {BatchRunLifecycleRequest} batchRunLifecycleRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ async batchArchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {\n+ const localVarAxiosArgs = await localVarAxiosParamCreator.batchArchiveRuns(batchRunLifecycleRequest, options);\n+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;\n+ const localVarOperationServerBasePath = operationServerMap['RunsApi.batchArchiveRuns']?.[localVarOperationServerIndex]?.url;\n+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n+ },\n+ /**\n+ * Restores up to 250 archived runs in one fail-soft, non-transactional request. Each run is processed independently and successful items emit the same per-run unarchive events as `POST /api/v1/runs/{id}/unarchive`. A valid batch returns `200` even when some items fail; inspect `results` and `summary` for per-run outcomes. Invalid request bodies are rejected before mutating any run.\n+ * @summary Unarchive Runs\n+ * @param {BatchRunLifecycleRequest} batchRunLifecycleRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ async batchUnarchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {\n+ const localVarAxiosArgs = await localVarAxiosParamCreator.batchUnarchiveRuns(batchRunLifecycleRequest, options);\n+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;\n+ const localVarOperationServerBasePath = operationServerMap['RunsApi.batchUnarchiveRuns']?.[localVarOperationServerIndex]?.url;\n+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n+ },\n /**\n * Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.\n * @summary Cancel Run\n@@ -1859,6 +1971,26 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?\n archiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise {\n return localVarFp.archiveRun(id, options).then((request) => request(axios, basePath));\n },\n+ /**\n+ * Marks up to 250 terminal runs as archived in one fail-soft, non-transactional request. Each run is processed independently and successful items emit the same per-run archive events as `POST /api/v1/runs/{id}/archive`. A valid batch returns `200` even when some items fail; inspect `results` and `summary` for per-run outcomes. Invalid request bodies are rejected before mutating any run.\n+ * @summary Archive Runs\n+ * @param {BatchRunLifecycleRequest} batchRunLifecycleRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ batchArchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig): AxiosPromise {\n+ return localVarFp.batchArchiveRuns(batchRunLifecycleRequest, options).then((request) => request(axios, basePath));\n+ },\n+ /**\n+ * Restores up to 250 archived runs in one fail-soft, non-transactional request. Each run is processed independently and successful items emit the same per-run unarchive events as `POST /api/v1/runs/{id}/unarchive`. A valid batch returns `200` even when some items fail; inspect `results` and `summary` for per-run outcomes. Invalid request bodies are rejected before mutating any run.\n+ * @summary Unarchive Runs\n+ * @param {BatchRunLifecycleRequest} batchRunLifecycleRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ batchUnarchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig): AxiosPromise {\n+ return localVarFp.batchUnarchiveRuns(batchRunLifecycleRequest, options).then((request) => request(axios, basePath));\n+ },\n /**\n * Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.\n * @summary Cancel Run\n@@ -2195,6 +2327,28 @@ export class RunsApi extends BaseAPI {\n return RunsApiFp(this.configuration).archiveRun(id, options).then((request) => request(this.axios, this.basePath));\n }\n \n+ /**\n+ * Marks up to 250 terminal runs as archived in one fail-soft, non-transactional request. Each run is processed independently and successful items emit the same per-run archive events as `POST /api/v1/runs/{id}/archive`. A valid batch returns `200` even when some items fail; inspect `results` and `summary` for per-run outcomes. Invalid request bodies are rejected before mutating any run.\n+ * @summary Archive Runs\n+ * @param {BatchRunLifecycleRequest} batchRunLifecycleRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ public batchArchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig) {\n+ return RunsApiFp(this.configuration).batchArchiveRuns(batchRunLifecycleRequest, options).then((request) => request(this.axios, this.basePath));\n+ }\n+\n+ /**\n+ * Restores up to 250 archived runs in one fail-soft, non-transactional request. Each run is processed independently and successful items emit the same per-run unarchive events as `POST /api/v1/runs/{id}/unarchive`. A valid batch returns `200` even when some items fail; inspect `results` and `summary` for per-run outcomes. Invalid request bodies are rejected before mutating any run.\n+ * @summary Unarchive Runs\n+ * @param {BatchRunLifecycleRequest} batchRunLifecycleRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ public batchUnarchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig) {\n+ return RunsApiFp(this.configuration).batchUnarchiveRuns(batchRunLifecycleRequest, options).then((request) => request(this.axios, this.basePath));\n+ }\n+\n /**\n * Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.\n * @summary Cancel Run\ndiff --git a/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-request.ts b/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-request.ts\nnew file mode 100644\nindex 000000000..b9225f933\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-request.ts\n@@ -0,0 +1,25 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+\n+/**\n+ * Run IDs to archive or unarchive as one bounded fail-soft batch.\n+ */\n+export interface BatchRunLifecycleRequest {\n+ /**\n+ * Run IDs to process, in result order.\n+ */\n+ 'run_ids': Set;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-response.ts b/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-response.ts\nnew file mode 100644\nindex 000000000..8dc5a80dd\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-response.ts\n@@ -0,0 +1,32 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { BatchRunLifecycleResult } from './batch-run-lifecycle-result';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { BatchRunLifecycleSummary } from './batch-run-lifecycle-summary';\n+\n+/**\n+ * Per-run results for a fail-soft batch archive or unarchive request.\n+ */\n+export interface BatchRunLifecycleResponse {\n+ /**\n+ * Results ordered exactly like the request `run_ids`.\n+ */\n+ 'results': Array;\n+ 'summary': BatchRunLifecycleSummary;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-result.ts b/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-result.ts\nnew file mode 100644\nindex 000000000..d3559109f\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-result.ts\n@@ -0,0 +1,59 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { ErrorResponseEntry } from './error-response-entry';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { Run } from './run';\n+\n+/**\n+ * Result for one run in a batch archive or unarchive request.\n+ */\n+export interface BatchRunLifecycleResult {\n+ /**\n+ * Run ID from the request item.\n+ */\n+ 'run_id': string;\n+ /**\n+ * Whether this item succeeded.\n+ */\n+ 'ok': boolean;\n+ /**\n+ * Machine-readable item outcome.\n+ */\n+ 'outcome': BatchRunLifecycleResultOutcomeEnum;\n+ /**\n+ * Decorated run summary for successful items when it can be loaded.\n+ */\n+ 'run'?: Run;\n+ /**\n+ * Structured item error for failed items.\n+ */\n+ 'error'?: ErrorResponseEntry;\n+}\n+\n+export const BatchRunLifecycleResultOutcomeEnum = {\n+ ARCHIVED: 'archived',\n+ ALREADY_ARCHIVED: 'already_archived',\n+ UNARCHIVED: 'unarchived',\n+ NOT_ARCHIVED: 'not_archived',\n+ NOT_FOUND: 'not_found',\n+ CONFLICT: 'conflict',\n+ ERROR: 'error'\n+} as const;\n+\n+export type BatchRunLifecycleResultOutcomeEnum = typeof BatchRunLifecycleResultOutcomeEnum[keyof typeof BatchRunLifecycleResultOutcomeEnum];\ndiff --git a/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-summary.ts b/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-summary.ts\nnew file mode 100644\nindex 000000000..e01f9815d\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/batch-run-lifecycle-summary.ts\n@@ -0,0 +1,33 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+\n+/**\n+ * Aggregate counts for a batch archive or unarchive request.\n+ */\n+export interface BatchRunLifecycleSummary {\n+ /**\n+ * Number of requested run IDs.\n+ */\n+ 'requested': number;\n+ /**\n+ * Number of item results with `ok=true`.\n+ */\n+ 'succeeded': number;\n+ /**\n+ * Number of item results with `ok=false`.\n+ */\n+ 'failed': number;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts\nindex b40104395..e70e4f2db 100644\n--- a/lib/packages/fabro-api-client/src/models/index.ts\n+++ b/lib/packages/fabro-api-client/src/models/index.ts\n@@ -22,6 +22,10 @@ export * from './auth-session';\n export * from './auth-session-user';\n export * from './auth-sessions-response';\n export * from './automation-ref';\n+export * from './batch-run-lifecycle-request';\n+export * from './batch-run-lifecycle-response';\n+export * from './batch-run-lifecycle-result';\n+export * from './batch-run-lifecycle-summary';\n export * from './billed-token-counts';\n export * from './billing-by-model';\n export * from './billing-model-ref';\n", + "summary": { + "files_changed": 15, + "additions": 1317, + "deletions": 63 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-24T02:21:20.249402Z", + "current_node": "simplify_opus", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus" + ], + "node_retries": {}, + "context_values": { + "thread.preflight_lint.current_node": "implement", + "thread.start.current_node": "toolchain", + "failure_signature": "", + "internal.retry_count.implement": 0, "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", "internal.retry_count.preflight_lint": 0, "response.implement": "Implemented batch run archive/unarchive end-to-end.\n\nSummary:\n- Added OpenAPI endpoints:\n - `POST /api/v1/runs/archive`\n - `POST /api/v1/runs/unarchive`\n- Added batch lifecycle schemas and regenerated Rust/TypeScript API clients.\n- Implemented server batch handlers with:\n - `RequiredUser` auth only\n - pre-mutation validation for empty, >250, duplicate, and invalid IDs\n - sequential fail-soft processing\n - ordered per-run results and summary counts\n - existing `operations::archive` / `operations::unarchive` behavior and events\n- Added server tests covering:\n - archive/unarchive visibility changes\n - mixed success/conflict/not_found results\n - idempotent archive/unarchive outcomes\n - invalid request rejection before mutation\n - unauthenticated and worker-token rejection\n- Added web helpers:\n - `archiveRuns(runIds)`\n - `unarchiveRuns(runIds)`\n- Updated runs list bulk actions and board “Archive all” to use one batch request per action.\n- Preserved cache invalidation/toast behavior, with partial-success batch summaries.\n- Added frontend helper and route-summary tests.\n\nValidation run:\n- `cargo build -q -p fabro-api` ✅\n- `cargo check -q --workspace` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings` ✅\n- `cargo nextest run -p fabro-api` ✅\n- Targeted `fabro-server` batch/single archive tests ✅\n- `cd lib/packages/fabro-api-client && bun run generate` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd apps/fabro-web && bun run test` ✅\n\nNote:\n- Full `cargo nextest run -p fabro-server` still fails on existing/unrelated `server::tests::get_graph_returns_svg`; it invokes the test binary as the graph render subprocess and gets `running 0 tests...` instead of SVG. The new batch lifecycle tests pass.", - "last_stage": "implement", - "internal.retry_count.implement": 0, + "thread.implement.current_node": "simplify_opus", + "response.simplify_opus": "All checks pass. Here's a summary of what was fixed.\n\n## Summary\n\nLaunched three parallel review agents (reuse / quality / efficiency) and aggregated their findings. Applied the high-value fixes:\n\n### Rust (`lib/crates/fabro-server`)\n\n1. **Efficiency P0 — readiness resolved once per batch.** `batch_run_archive_item` previously called `state.decorate_run_summary(...)` inside the per-id loop, which re-resolves LLM credentials on every iteration (up to 250 LLM client builds per request). Now `batch_run_archive_action` calls `state.ask_fabro_readiness().await` once, then per-item uses `readiness.decorate(summary)` directly. Exposed `AskFabroReadiness`, `AskFabroReadiness::decorate`, and `AppState::ask_fabro_readiness` as `pub(crate)`.\n2. **Removed duplicate `WorkflowError → HTTP` mapping.** Extracted `archive_workflow_error_to_api_error(err) -> ApiError` and shared it between the new batch handler and the existing single-run `run_archive_action`.\n3. **Removed duplicate `operations::{archive,unarchive}` glue.** Extracted `run_archive_operation(state, id, actor, action)` returning `Result` (the single-run handler discards the outcome). The single-run handler and batch handler now share this body.\n4. **Removed hand-rolled `batch_error_entry` builder.** Added `ApiError::into_response_entry() -> ErrorResponseEntry` in `error.rs` (one source of truth for the wire shape). Batch failures now construct standard `ApiError` instances (`ApiError::not_found`, `ApiError::new(StatusCode::CONFLICT, ...)`, etc.) and convert at the boundary. Dropped the `batch_success_result` / `batch_failure_result` builders; result entries are now inlined or built via `batch_result_failure(id, outcome, ApiError)`.\n5. Removed now-unused `Run` and `ErrorResponseEntry` imports from `lifecycle.rs`.\n\n### TypeScript (`apps/fabro-web`)\n\n6. **Removed local `runWord` helper**, switched all callers to the existing `plural(n, \"run\", \"runs\")` helper from `components/settings-panel`.\n7. **Simplified `summarizeBatchLifecycleAction`.** Dropped the redundant `total` parameter (was always either `summary.requested` or fabricated by callers) and the redundant `allSucceeded` wrapper (derivable from `summary.failed === 0`). Now takes a single `BatchRunLifecycleSummary` and returns the toast directly. Callers in `ColumnActionsMenu` and `BulkActionToolbar` updated; tests rewritten accordingly.\n\n### Skipped findings (intentional)\n\n- **`uniqueItems: true` on `BatchRunLifecycleRequest.run_ids`** — explicitly required by the implementation plan; the `as unknown as` cast workaround stays. The server-side dedup validation matches the schema constraint and produces a 400 with a clear message.\n- **Boilerplate handler pair `batch_archive_runs`/`batch_unarchive_runs`** — mirrors the existing single-run `archive_run`/`unarchive_run` pattern in the same file; collapsing would diverge from convention.\n- **`json_bearer_request` → `json_request` refactor** — pre-existing helper, low-priority; left alone.\n- **`BatchLifecycleLabel` stringly-typed verb** — localized to one helper, low impact; the simplification above already removed the redundant `.toLowerCase()` plumbing in callers.\n\n### Verification\n\n- `cargo check -p fabro-server --tests` — clean\n- `cargo +nightly-2026-04-14 clippy -p fabro-server --all-targets -- -D warnings` — clean\n- `cargo +nightly-2026-04-14 fmt -p fabro-server` — applied\n- `cargo nextest run -p fabro-server -E 'test(/archive|api_error/)'` — 11/11 pass (6 batch tests + 5 single-run/error tests)\n- `cd apps/fabro-web && bun test app/routes/runs.test.tsx app/lib/run-actions.test.ts` — 27/27 pass\n- `cd apps/fabro-web && bun run typecheck` — clean", "internal.work_dir": "/home/daytona/workspace/fabro", "outcome": "succeeded", - "last_response": "Implemented batch run archive/unarchive end-to-end.\n\nSummary:\n- Added OpenAPI endpoints:\n - `POST /api/v1/runs/archive`\n - `POST /api/v1/runs/unarchive`\n- Added batch lifecycle schemas and regenerat", - "graph.goal": "---\ntitle: feat: Batch run archive actions\ntype: feat\nstatus: active\ndate: 2026-05-24\n---\n\n# feat: Batch Run Archive Actions\n\n## Overview\n\nAdd API support for archiving and unarchiving multiple runs in one request, then update the web list and board multi-run actions to use the new batch endpoints. The existing single-run archive and unarchive endpoints remain unchanged for run-scoped callers and direct lifecycle actions.\n\n## Problem Frame\n\nThe web UI currently performs multi-run archive/unarchive actions by issuing one lifecycle request per selected run. That works, but it puts batch orchestration in the browser, repeats request overhead, and leaves API/CLI/MCP consumers without a first-class batch contract. A bounded fail-soft batch endpoint gives the server ownership of the multi-run operation while preserving the independent event stream semantics of each run.\n\n## Requirements Trace\n\n- R1. Provide public API endpoints that archive and unarchive many runs in one request.\n- R2. Preserve existing single-run archive/unarchive behavior, including eligibility, idempotency, and emitted events.\n- R3. Return per-run results so mixed-success batches can be reported without rolling back successful items.\n- R4. Update web bulk actions to make one request per batch action instead of one request per run.\n- R5. Keep cache invalidation and toast behavior equivalent to the current UI.\n\n## Scope Boundaries\n\n- Do not make batch archive/unarchive transactional; each run remains an independent event stream.\n- Do not add new workflow event variants; successful batch items emit the same run archive/unarchive events as single-run actions.\n- Do not change the existing `POST /api/v1/runs/{id}/archive` or `POST /api/v1/runs/{id}/unarchive` contracts.\n- Do not add CLI commands in this change; this plan is limited to HTTP API and web UI usage.\n\n## Context & Research\n\n### Relevant Code and Patterns\n\n- OpenAPI is the source of truth for HTTP contracts in `docs/public/api-reference/fabro-api.yaml`.\n- Server lifecycle routes live in `lib/crates/fabro-server/src/server/handler/lifecycle.rs`; single-run archive/unarchive already funnel through `operations::archive` and `operations::unarchive`.\n- Batch routes that are not tied to one path run ID should use `RequiredUser`, not `RequireRunScopedOrRunTools`, because a run-scoped worker token cannot safely authorize mutation of arbitrary run IDs from a request body.\n- Web lifecycle helpers live in `apps/fabro-web/app/lib/run-actions.ts`; list and board multi-run archive behavior lives in `apps/fabro-web/app/routes/runs.tsx`.\n- Run-list cache invalidation already uses `mutateRunListCaches` in `apps/fabro-web/app/lib/board-cache.ts`.\n\n### Strategy Docs\n\n- `docs/internal/testing-strategy.md`: server tests should assert public HTTP contracts and prefer structured assertions.\n- `docs/internal/error-handling-strategy.md`: preserve structured errors internally and return curated API messages at HTTP boundaries.\n- `docs/internal/events-strategy.md`: no new events are needed because existing per-run lifecycle events already represent the durable state transition.\n\n## Key Technical Decisions\n\n- Add collection action endpoints `POST /api/v1/runs/archive` and `POST /api/v1/runs/unarchive`. These avoid changing single-run URLs and keep generated client methods clear (`batchArchiveRuns`, `batchUnarchiveRuns`).\n- Use fail-soft HTTP `200` responses for valid batch requests, even when individual items fail. Per-item failures carry structured result entries; request-level validation failures still return normal `400` errors.\n- Validate `run_ids` at the request boundary: non-empty, maximum 250 IDs, no duplicates, and every value parseable as a `RunId`. Invalid request bodies must not mutate any runs.\n- Process eligible IDs sequentially in server code. The batch is bounded, lifecycle operations append events, and sequential processing avoids adding lock-order or concurrency behavior that the feature does not need.\n- Treat idempotent single-run outcomes as successful batch outcomes: already archived counts as success for archive; terminal not-archived counts as success for unarchive.\n\n## API Contract\n\nAdd these OpenAPI operations:\n\n- `POST /api/v1/runs/archive`\n - operationId: `batchArchiveRuns`\n - request: `BatchRunLifecycleRequest`\n - response: `BatchRunLifecycleResponse`\n- `POST /api/v1/runs/unarchive`\n - operationId: `batchUnarchiveRuns`\n - request: `BatchRunLifecycleRequest`\n - response: `BatchRunLifecycleResponse`\n\nAdd schemas:\n\n- `BatchRunLifecycleRequest`\n - required `run_ids`\n - `run_ids`: array of strings, `minItems: 1`, `maxItems: 250`, `uniqueItems: true`\n- `BatchRunLifecycleResponse`\n - required `results`, `summary`\n - `results`: array of `BatchRunLifecycleResult`, ordered exactly like the request `run_ids`\n - `summary`: `BatchRunLifecycleSummary`\n- `BatchRunLifecycleResult`\n - required `run_id`, `ok`, `outcome`\n - `run_id`: string\n - `ok`: boolean\n - `outcome`: enum covering `archived`, `already_archived`, `unarchived`, `not_archived`, `not_found`, `conflict`, `error`\n - optional `run`: `Run`, present for successful items when a decorated summary can be loaded\n - optional `error`: `ErrorResponseEntry`, present for failed items\n- `BatchRunLifecycleSummary`\n - required `requested`, `succeeded`, `failed`\n - all integer counts\n\n## Implementation Units\n\n- [ ] **Unit 1: OpenAPI batch lifecycle contract**\n\n**Goal:** Add the public API contract and generated clients for batch archive/unarchive.\n\n**Requirements:** R1, R3\n\n**Dependencies:** None\n\n**Files:**\n- Modify: `docs/public/api-reference/fabro-api.yaml`\n- Generated by build/codegen: `lib/crates/fabro-api/src/generated.rs`\n- Generated by codegen: `lib/packages/fabro-api-client/src/api/runs-api.ts`\n- Generated by codegen: `lib/packages/fabro-api-client/src/models/*`\n\n**Approach:**\n- Add the two collection action paths and the four batch lifecycle schemas described in the API Contract section.\n- Keep response status simple: `200` for a valid processed batch; `400`, `401`, and `500` for request-level failures.\n- Run Rust generation through `cargo build -p fabro-api`.\n- Run TypeScript generation through `cd lib/packages/fabro-api-client && bun run generate`.\n\n**Patterns to follow:**\n- Existing single-run archive/unarchive path docs in the same OpenAPI file.\n- Existing generated client workflow described in `AGENTS.md`.\n\n**Test scenarios:**\n- Happy path: generated Rust and TypeScript clients expose `batchArchiveRuns` and `batchUnarchiveRuns`.\n- Contract: request schema enforces `run_ids` as the only required input.\n- Contract: result entries can represent both a successful decorated `Run` and a failed `ErrorResponseEntry`.\n\n**Verification:**\n- API generation completes without hand-edited generated files.\n\n- [ ] **Unit 2: Server batch lifecycle handlers**\n\n**Goal:** Implement the two new endpoints in the server using existing archive/unarchive operations.\n\n**Requirements:** R1, R2, R3\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `lib/crates/fabro-server/src/server/handler/lifecycle.rs`\n- Test: `lib/crates/fabro-server/src/server/tests.rs`\n\n**Approach:**\n- Register `POST /runs/archive` and `POST /runs/unarchive` in the lifecycle route set.\n- Use `RequiredUser` for both handlers and convert the user into `Principal::User` for per-run operation calls.\n- Add a small shared batch helper that accepts the parsed request, the action, and the actor, then returns `BatchRunLifecycleResponse`.\n- Before processing any item, validate the entire request for empty list, over-limit list, duplicate IDs, and invalid IDs. Return a normal `400` API error if validation fails.\n- For each valid ID, call `operations::archive` or `operations::unarchive`; map success outcomes to item-level success results and map `RunNotFound`/`Precondition` to item-level `not_found`/`conflict` failures.\n- For successful items, load and decorate the current run summary the same way single-run lifecycle responses do. If summary loading fails after the operation succeeds, record that item as `error` rather than hiding the failure.\n\n**Patterns to follow:**\n- `run_archive_action` for operation mapping and existing error semantics.\n- `run_response` / `state.decorate_run_summary` for response shape.\n- Existing server tests around `archive_and_unarchive_updates_listing_visibility`.\n\n**Test scenarios:**\n- Happy path: two terminal runs archived in one request return two successful result entries, summary `requested=2/succeeded=2/failed=0`, and both runs are hidden from default `GET /api/v1/runs`.\n- Happy path: two archived runs unarchived in one request return successful entries and both runs reappear in default listing.\n- Idempotency: archiving an already archived run returns `ok=true` with `already_archived`; unarchiving a terminal non-archived run returns `ok=true` with `not_archived`.\n- Mixed result: a batch containing one terminal run, one running run, and one missing run returns ordered results with one success, one `conflict`, and one `not_found`; the terminal run is still archived.\n- Error path: empty `run_ids`, duplicate IDs, invalid IDs, and more than 250 IDs return request-level `400` and mutate no runs.\n- Auth path: unauthenticated requests are rejected; run-scoped worker authentication is not accepted for batch endpoints.\n\n**Verification:**\n- Existing single-run archive/unarchive tests pass unchanged.\n- New endpoint tests prove both API contract and run-list visibility effects.\n\n- [ ] **Unit 3: Frontend lifecycle helpers**\n\n**Goal:** Add typed web helpers for batch archive/unarchive.\n\n**Requirements:** R3, R4, R5\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `apps/fabro-web/app/lib/run-actions.ts`\n- Test: `apps/fabro-web/app/lib/run-actions.test.ts`\n\n**Approach:**\n- Add `archiveRuns(runIds: string[])` and `unarchiveRuns(runIds: string[])` wrappers around the generated client methods.\n- Keep existing `archiveRun` and `unarchiveRun` helpers unchanged.\n- Preserve existing `LifecycleActionError` behavior for single-run actions. Batch helpers should return the generated batch response for valid mixed results and throw only for request-level API failures.\n\n**Patterns to follow:**\n- Existing lifecycle action helpers in `run-actions.ts`.\n- Axios adapter tests in `run-actions.test.ts`.\n\n**Test scenarios:**\n- Happy path: `archiveRuns([\"run-1\", \"run-2\"])` sends one generated-client request and returns parsed batch results.\n- Mixed result: helper resolves a response containing one success and one per-item failure without throwing.\n- Request error: helper throws parsed `ApiError`/lifecycle-style data when the server returns a request-level `400`.\n- Regression: existing `archiveRun`, `unarchiveRun`, `canArchive`, and `canUnarchive` tests continue to pass.\n\n**Verification:**\n- Web unit tests cover the new helper contract without changing single-run behavior.\n\n- [ ] **Unit 4: Web bulk-action integration**\n\n**Goal:** Replace multi-request UI orchestration with one batch request per bulk action.\n\n**Requirements:** R4, R5\n\n**Dependencies:** Units 1 and 3\n\n**Files:**\n- Modify: `apps/fabro-web/app/routes/runs.tsx`\n- Test: `apps/fabro-web/app/routes/runs.test.tsx`\n\n**Approach:**\n- Update `BulkActionToolbar` to call `archiveRuns` or `unarchiveRuns` once with eligible selected IDs.\n- Add a small pure batch-summary helper in `runs.tsx`, export it for route tests, and use it to compute toast messages from the batch response summary rather than `Promise.allSettled`.\n- Keep current eligibility filtering: archive only selected runs whose lifecycle status is archivable, unarchive only selected archived runs.\n- Clear selection only when all eligible items succeed, matching the current all-success behavior.\n- Call `mutateRunListCaches` once after the batch settles.\n- Update the kanban column archive-all action to call `archiveRuns` once with the column’s eligible IDs and reuse the same success/partial/failure toast behavior where practical.\n\n**Patterns to follow:**\n- Current `BulkActionToolbar` pending state, clear-selection behavior, and toast wording.\n- Current `ColumnActionsMenu` archive-all action and cache invalidation.\n\n**Test scenarios:**\n- Happy path: selecting multiple archived rows and clicking Unarchive calls the batch helper once and clears selection after all items succeed.\n- Happy path: selecting multiple terminal rows and clicking Archive calls the batch helper once and invalidates run-list caches once.\n- Mixed result: partial failure keeps selection and shows an error-tone partial-success toast with succeeded/failed counts.\n- Ineligible selection: selecting only non-archivable runs still shows the existing “No selected runs can be archived” style error without making an API request.\n- Board action: archive-all for a column calls the batch helper once with all eligible IDs.\n\n**Verification:**\n- UI behavior remains equivalent from the user’s perspective, but network behavior becomes one request per bulk action.\n\n## System-Wide Impact\n\n- **Auth:** Batch endpoints are user-only. This intentionally avoids giving a worker token with one run scope the ability to mutate arbitrary run IDs from a request body.\n- **Events:** No new event names or payloads. Each successful item appends the existing per-run archive/unarchive event.\n- **Caching:** Frontend run-list caches are still invalidated after lifecycle changes. The batch path should reduce invalidation churn from once per selected run to once per user action.\n- **Generated clients:** Both Rust and TypeScript generated clients change because OpenAPI is the source of truth.\n- **Compatibility:** Single-run endpoints and existing generated methods remain available and unchanged.\n\n## Risks & Mitigations\n\n| Risk | Mitigation |\n|------|------------|\n| Route ambiguity with `/runs/{id}` paths | Use static collection action paths and add server tests that call the exact batch routes. |\n| Partial mutation surprises | Use explicit fail-soft result entries and summarize succeeded/failed counts. |\n| Worker token privilege expansion | Require `RequiredUser` for batch endpoints. |\n| Duplicate IDs producing confusing results | Reject duplicates at request validation before any mutation. |\n| UI toast regressions | Cover all-success, all-failure, partial-failure, and ineligible-selection behavior in frontend tests. |\n\n## Documentation / Operational Notes\n\n- The OpenAPI descriptions should explicitly state that batch actions are fail-soft and not transactional.\n- No migration, feature flag, or rollout sequencing is required.\n- No new public docs are required unless API reference publishing is part of the release process.\n\n## Sources & References\n\n- OpenAPI source: `docs/public/api-reference/fabro-api.yaml`\n- Server lifecycle handlers: `lib/crates/fabro-server/src/server/handler/lifecycle.rs`\n- Workflow archive operations: `lib/crates/fabro-workflow/src/operations/archive.rs`\n- Web lifecycle helpers: `apps/fabro-web/app/lib/run-actions.ts`\n- Web runs route: `apps/fabro-web/app/routes/runs.tsx`\n- Testing guidance: `docs/internal/testing-strategy.md`\n- Error handling guidance: `docs/internal/error-handling-strategy.md`\n- Event guidance: `docs/internal/events-strategy.md`\n", "thread.toolchain.current_node": "preflight_compile", "internal.retry_count.start": 0, - "current_node": "implement", "internal.run_id": "01KSBT48J14ZMK9HQN48SVMG3T", - "internal.thread_id": "preflight_lint", + "internal.thread_id": "implement", + "thread.preflight_compile.current_node": "preflight_lint", + "last_stage": "simplify_opus", + "failure_class": "", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "last_response": "All checks pass. Here's a summary of what was fixed.\n\n## Summary\n\nLaunched three parallel review agents (reuse / quality / efficiency) and aggregated their findings. Applied the high-value fixes:\n\n###", + "graph.goal": "---\ntitle: feat: Batch run archive actions\ntype: feat\nstatus: active\ndate: 2026-05-24\n---\n\n# feat: Batch Run Archive Actions\n\n## Overview\n\nAdd API support for archiving and unarchiving multiple runs in one request, then update the web list and board multi-run actions to use the new batch endpoints. The existing single-run archive and unarchive endpoints remain unchanged for run-scoped callers and direct lifecycle actions.\n\n## Problem Frame\n\nThe web UI currently performs multi-run archive/unarchive actions by issuing one lifecycle request per selected run. That works, but it puts batch orchestration in the browser, repeats request overhead, and leaves API/CLI/MCP consumers without a first-class batch contract. A bounded fail-soft batch endpoint gives the server ownership of the multi-run operation while preserving the independent event stream semantics of each run.\n\n## Requirements Trace\n\n- R1. Provide public API endpoints that archive and unarchive many runs in one request.\n- R2. Preserve existing single-run archive/unarchive behavior, including eligibility, idempotency, and emitted events.\n- R3. Return per-run results so mixed-success batches can be reported without rolling back successful items.\n- R4. Update web bulk actions to make one request per batch action instead of one request per run.\n- R5. Keep cache invalidation and toast behavior equivalent to the current UI.\n\n## Scope Boundaries\n\n- Do not make batch archive/unarchive transactional; each run remains an independent event stream.\n- Do not add new workflow event variants; successful batch items emit the same run archive/unarchive events as single-run actions.\n- Do not change the existing `POST /api/v1/runs/{id}/archive` or `POST /api/v1/runs/{id}/unarchive` contracts.\n- Do not add CLI commands in this change; this plan is limited to HTTP API and web UI usage.\n\n## Context & Research\n\n### Relevant Code and Patterns\n\n- OpenAPI is the source of truth for HTTP contracts in `docs/public/api-reference/fabro-api.yaml`.\n- Server lifecycle routes live in `lib/crates/fabro-server/src/server/handler/lifecycle.rs`; single-run archive/unarchive already funnel through `operations::archive` and `operations::unarchive`.\n- Batch routes that are not tied to one path run ID should use `RequiredUser`, not `RequireRunScopedOrRunTools`, because a run-scoped worker token cannot safely authorize mutation of arbitrary run IDs from a request body.\n- Web lifecycle helpers live in `apps/fabro-web/app/lib/run-actions.ts`; list and board multi-run archive behavior lives in `apps/fabro-web/app/routes/runs.tsx`.\n- Run-list cache invalidation already uses `mutateRunListCaches` in `apps/fabro-web/app/lib/board-cache.ts`.\n\n### Strategy Docs\n\n- `docs/internal/testing-strategy.md`: server tests should assert public HTTP contracts and prefer structured assertions.\n- `docs/internal/error-handling-strategy.md`: preserve structured errors internally and return curated API messages at HTTP boundaries.\n- `docs/internal/events-strategy.md`: no new events are needed because existing per-run lifecycle events already represent the durable state transition.\n\n## Key Technical Decisions\n\n- Add collection action endpoints `POST /api/v1/runs/archive` and `POST /api/v1/runs/unarchive`. These avoid changing single-run URLs and keep generated client methods clear (`batchArchiveRuns`, `batchUnarchiveRuns`).\n- Use fail-soft HTTP `200` responses for valid batch requests, even when individual items fail. Per-item failures carry structured result entries; request-level validation failures still return normal `400` errors.\n- Validate `run_ids` at the request boundary: non-empty, maximum 250 IDs, no duplicates, and every value parseable as a `RunId`. Invalid request bodies must not mutate any runs.\n- Process eligible IDs sequentially in server code. The batch is bounded, lifecycle operations append events, and sequential processing avoids adding lock-order or concurrency behavior that the feature does not need.\n- Treat idempotent single-run outcomes as successful batch outcomes: already archived counts as success for archive; terminal not-archived counts as success for unarchive.\n\n## API Contract\n\nAdd these OpenAPI operations:\n\n- `POST /api/v1/runs/archive`\n - operationId: `batchArchiveRuns`\n - request: `BatchRunLifecycleRequest`\n - response: `BatchRunLifecycleResponse`\n- `POST /api/v1/runs/unarchive`\n - operationId: `batchUnarchiveRuns`\n - request: `BatchRunLifecycleRequest`\n - response: `BatchRunLifecycleResponse`\n\nAdd schemas:\n\n- `BatchRunLifecycleRequest`\n - required `run_ids`\n - `run_ids`: array of strings, `minItems: 1`, `maxItems: 250`, `uniqueItems: true`\n- `BatchRunLifecycleResponse`\n - required `results`, `summary`\n - `results`: array of `BatchRunLifecycleResult`, ordered exactly like the request `run_ids`\n - `summary`: `BatchRunLifecycleSummary`\n- `BatchRunLifecycleResult`\n - required `run_id`, `ok`, `outcome`\n - `run_id`: string\n - `ok`: boolean\n - `outcome`: enum covering `archived`, `already_archived`, `unarchived`, `not_archived`, `not_found`, `conflict`, `error`\n - optional `run`: `Run`, present for successful items when a decorated summary can be loaded\n - optional `error`: `ErrorResponseEntry`, present for failed items\n- `BatchRunLifecycleSummary`\n - required `requested`, `succeeded`, `failed`\n - all integer counts\n\n## Implementation Units\n\n- [ ] **Unit 1: OpenAPI batch lifecycle contract**\n\n**Goal:** Add the public API contract and generated clients for batch archive/unarchive.\n\n**Requirements:** R1, R3\n\n**Dependencies:** None\n\n**Files:**\n- Modify: `docs/public/api-reference/fabro-api.yaml`\n- Generated by build/codegen: `lib/crates/fabro-api/src/generated.rs`\n- Generated by codegen: `lib/packages/fabro-api-client/src/api/runs-api.ts`\n- Generated by codegen: `lib/packages/fabro-api-client/src/models/*`\n\n**Approach:**\n- Add the two collection action paths and the four batch lifecycle schemas described in the API Contract section.\n- Keep response status simple: `200` for a valid processed batch; `400`, `401`, and `500` for request-level failures.\n- Run Rust generation through `cargo build -p fabro-api`.\n- Run TypeScript generation through `cd lib/packages/fabro-api-client && bun run generate`.\n\n**Patterns to follow:**\n- Existing single-run archive/unarchive path docs in the same OpenAPI file.\n- Existing generated client workflow described in `AGENTS.md`.\n\n**Test scenarios:**\n- Happy path: generated Rust and TypeScript clients expose `batchArchiveRuns` and `batchUnarchiveRuns`.\n- Contract: request schema enforces `run_ids` as the only required input.\n- Contract: result entries can represent both a successful decorated `Run` and a failed `ErrorResponseEntry`.\n\n**Verification:**\n- API generation completes without hand-edited generated files.\n\n- [ ] **Unit 2: Server batch lifecycle handlers**\n\n**Goal:** Implement the two new endpoints in the server using existing archive/unarchive operations.\n\n**Requirements:** R1, R2, R3\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `lib/crates/fabro-server/src/server/handler/lifecycle.rs`\n- Test: `lib/crates/fabro-server/src/server/tests.rs`\n\n**Approach:**\n- Register `POST /runs/archive` and `POST /runs/unarchive` in the lifecycle route set.\n- Use `RequiredUser` for both handlers and convert the user into `Principal::User` for per-run operation calls.\n- Add a small shared batch helper that accepts the parsed request, the action, and the actor, then returns `BatchRunLifecycleResponse`.\n- Before processing any item, validate the entire request for empty list, over-limit list, duplicate IDs, and invalid IDs. Return a normal `400` API error if validation fails.\n- For each valid ID, call `operations::archive` or `operations::unarchive`; map success outcomes to item-level success results and map `RunNotFound`/`Precondition` to item-level `not_found`/`conflict` failures.\n- For successful items, load and decorate the current run summary the same way single-run lifecycle responses do. If summary loading fails after the operation succeeds, record that item as `error` rather than hiding the failure.\n\n**Patterns to follow:**\n- `run_archive_action` for operation mapping and existing error semantics.\n- `run_response` / `state.decorate_run_summary` for response shape.\n- Existing server tests around `archive_and_unarchive_updates_listing_visibility`.\n\n**Test scenarios:**\n- Happy path: two terminal runs archived in one request return two successful result entries, summary `requested=2/succeeded=2/failed=0`, and both runs are hidden from default `GET /api/v1/runs`.\n- Happy path: two archived runs unarchived in one request return successful entries and both runs reappear in default listing.\n- Idempotency: archiving an already archived run returns `ok=true` with `already_archived`; unarchiving a terminal non-archived run returns `ok=true` with `not_archived`.\n- Mixed result: a batch containing one terminal run, one running run, and one missing run returns ordered results with one success, one `conflict`, and one `not_found`; the terminal run is still archived.\n- Error path: empty `run_ids`, duplicate IDs, invalid IDs, and more than 250 IDs return request-level `400` and mutate no runs.\n- Auth path: unauthenticated requests are rejected; run-scoped worker authentication is not accepted for batch endpoints.\n\n**Verification:**\n- Existing single-run archive/unarchive tests pass unchanged.\n- New endpoint tests prove both API contract and run-list visibility effects.\n\n- [ ] **Unit 3: Frontend lifecycle helpers**\n\n**Goal:** Add typed web helpers for batch archive/unarchive.\n\n**Requirements:** R3, R4, R5\n\n**Dependencies:** Unit 1\n\n**Files:**\n- Modify: `apps/fabro-web/app/lib/run-actions.ts`\n- Test: `apps/fabro-web/app/lib/run-actions.test.ts`\n\n**Approach:**\n- Add `archiveRuns(runIds: string[])` and `unarchiveRuns(runIds: string[])` wrappers around the generated client methods.\n- Keep existing `archiveRun` and `unarchiveRun` helpers unchanged.\n- Preserve existing `LifecycleActionError` behavior for single-run actions. Batch helpers should return the generated batch response for valid mixed results and throw only for request-level API failures.\n\n**Patterns to follow:**\n- Existing lifecycle action helpers in `run-actions.ts`.\n- Axios adapter tests in `run-actions.test.ts`.\n\n**Test scenarios:**\n- Happy path: `archiveRuns([\"run-1\", \"run-2\"])` sends one generated-client request and returns parsed batch results.\n- Mixed result: helper resolves a response containing one success and one per-item failure without throwing.\n- Request error: helper throws parsed `ApiError`/lifecycle-style data when the server returns a request-level `400`.\n- Regression: existing `archiveRun`, `unarchiveRun`, `canArchive`, and `canUnarchive` tests continue to pass.\n\n**Verification:**\n- Web unit tests cover the new helper contract without changing single-run behavior.\n\n- [ ] **Unit 4: Web bulk-action integration**\n\n**Goal:** Replace multi-request UI orchestration with one batch request per bulk action.\n\n**Requirements:** R4, R5\n\n**Dependencies:** Units 1 and 3\n\n**Files:**\n- Modify: `apps/fabro-web/app/routes/runs.tsx`\n- Test: `apps/fabro-web/app/routes/runs.test.tsx`\n\n**Approach:**\n- Update `BulkActionToolbar` to call `archiveRuns` or `unarchiveRuns` once with eligible selected IDs.\n- Add a small pure batch-summary helper in `runs.tsx`, export it for route tests, and use it to compute toast messages from the batch response summary rather than `Promise.allSettled`.\n- Keep current eligibility filtering: archive only selected runs whose lifecycle status is archivable, unarchive only selected archived runs.\n- Clear selection only when all eligible items succeed, matching the current all-success behavior.\n- Call `mutateRunListCaches` once after the batch settles.\n- Update the kanban column archive-all action to call `archiveRuns` once with the column’s eligible IDs and reuse the same success/partial/failure toast behavior where practical.\n\n**Patterns to follow:**\n- Current `BulkActionToolbar` pending state, clear-selection behavior, and toast wording.\n- Current `ColumnActionsMenu` archive-all action and cache invalidation.\n\n**Test scenarios:**\n- Happy path: selecting multiple archived rows and clicking Unarchive calls the batch helper once and clears selection after all items succeed.\n- Happy path: selecting multiple terminal rows and clicking Archive calls the batch helper once and invalidates run-list caches once.\n- Mixed result: partial failure keeps selection and shows an error-tone partial-success toast with succeeded/failed counts.\n- Ineligible selection: selecting only non-archivable runs still shows the existing “No selected runs can be archived” style error without making an API request.\n- Board action: archive-all for a column calls the batch helper once with all eligible IDs.\n\n**Verification:**\n- UI behavior remains equivalent from the user’s perspective, but network behavior becomes one request per bulk action.\n\n## System-Wide Impact\n\n- **Auth:** Batch endpoints are user-only. This intentionally avoids giving a worker token with one run scope the ability to mutate arbitrary run IDs from a request body.\n- **Events:** No new event names or payloads. Each successful item appends the existing per-run archive/unarchive event.\n- **Caching:** Frontend run-list caches are still invalidated after lifecycle changes. The batch path should reduce invalidation churn from once per selected run to once per user action.\n- **Generated clients:** Both Rust and TypeScript generated clients change because OpenAPI is the source of truth.\n- **Compatibility:** Single-run endpoints and existing generated methods remain available and unchanged.\n\n## Risks & Mitigations\n\n| Risk | Mitigation |\n|------|------------|\n| Route ambiguity with `/runs/{id}` paths | Use static collection action paths and add server tests that call the exact batch routes. |\n| Partial mutation surprises | Use explicit fail-soft result entries and summarize succeeded/failed counts. |\n| Worker token privilege expansion | Require `RequiredUser` for batch endpoints. |\n| Duplicate IDs producing confusing results | Reject duplicates at request validation before any mutation. |\n| UI toast regressions | Cover all-success, all-failure, partial-failure, and ineligible-selection behavior in frontend tests. |\n\n## Documentation / Operational Notes\n\n- The OpenAPI descriptions should explicitly state that batch actions are fail-soft and not transactional.\n- No migration, feature flag, or rollout sequencing is required.\n- No new public docs are required unless API reference publishing is part of the release process.\n\n## Sources & References\n\n- OpenAPI source: `docs/public/api-reference/fabro-api.yaml`\n- Server lifecycle handlers: `lib/crates/fabro-server/src/server/handler/lifecycle.rs`\n- Workflow archive operations: `lib/crates/fabro-workflow/src/operations/archive.rs`\n- Web lifecycle helpers: `apps/fabro-web/app/lib/run-actions.ts`\n- Web runs route: `apps/fabro-web/app/routes/runs.tsx`\n- Testing guidance: `docs/internal/testing-strategy.md`\n- Error handling guidance: `docs/internal/error-handling-strategy.md`\n- Event guidance: `docs/internal/events-strategy.md`\n", + "internal.retry_count.simplify_opus": 0, + "current_node": "simplify_opus", + "internal.retry_count.preflight_compile": 0, "internal.node_visit_count": 1, "graph.rankdir": "LR", "internal.fidelity": "compact", @@ -800,6 +923,45 @@ "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", "usage": null }, + "simplify_opus": { + "status": "succeeded", + "context_updates": { + "last_stage": "simplify_opus", + "response.simplify_opus": "All checks pass. Here's a summary of what was fixed.\n\n## Summary\n\nLaunched three parallel review agents (reuse / quality / efficiency) and aggregated their findings. Applied the high-value fixes:\n\n### Rust (`lib/crates/fabro-server`)\n\n1. **Efficiency P0 — readiness resolved once per batch.** `batch_run_archive_item` previously called `state.decorate_run_summary(...)` inside the per-id loop, which re-resolves LLM credentials on every iteration (up to 250 LLM client builds per request). Now `batch_run_archive_action` calls `state.ask_fabro_readiness().await` once, then per-item uses `readiness.decorate(summary)` directly. Exposed `AskFabroReadiness`, `AskFabroReadiness::decorate`, and `AppState::ask_fabro_readiness` as `pub(crate)`.\n2. **Removed duplicate `WorkflowError → HTTP` mapping.** Extracted `archive_workflow_error_to_api_error(err) -> ApiError` and shared it between the new batch handler and the existing single-run `run_archive_action`.\n3. **Removed duplicate `operations::{archive,unarchive}` glue.** Extracted `run_archive_operation(state, id, actor, action)` returning `Result` (the single-run handler discards the outcome). The single-run handler and batch handler now share this body.\n4. **Removed hand-rolled `batch_error_entry` builder.** Added `ApiError::into_response_entry() -> ErrorResponseEntry` in `error.rs` (one source of truth for the wire shape). Batch failures now construct standard `ApiError` instances (`ApiError::not_found`, `ApiError::new(StatusCode::CONFLICT, ...)`, etc.) and convert at the boundary. Dropped the `batch_success_result` / `batch_failure_result` builders; result entries are now inlined or built via `batch_result_failure(id, outcome, ApiError)`.\n5. Removed now-unused `Run` and `ErrorResponseEntry` imports from `lifecycle.rs`.\n\n### TypeScript (`apps/fabro-web`)\n\n6. **Removed local `runWord` helper**, switched all callers to the existing `plural(n, \"run\", \"runs\")` helper from `components/settings-panel`.\n7. **Simplified `summarizeBatchLifecycleAction`.** Dropped the redundant `total` parameter (was always either `summary.requested` or fabricated by callers) and the redundant `allSucceeded` wrapper (derivable from `summary.failed === 0`). Now takes a single `BatchRunLifecycleSummary` and returns the toast directly. Callers in `ColumnActionsMenu` and `BulkActionToolbar` updated; tests rewritten accordingly.\n\n### Skipped findings (intentional)\n\n- **`uniqueItems: true` on `BatchRunLifecycleRequest.run_ids`** — explicitly required by the implementation plan; the `as unknown as` cast workaround stays. The server-side dedup validation matches the schema constraint and produces a 400 with a clear message.\n- **Boilerplate handler pair `batch_archive_runs`/`batch_unarchive_runs`** — mirrors the existing single-run `archive_run`/`unarchive_run` pattern in the same file; collapsing would diverge from convention.\n- **`json_bearer_request` → `json_request` refactor** — pre-existing helper, low-priority; left alone.\n- **`BatchLifecycleLabel` stringly-typed verb** — localized to one helper, low impact; the simplification above already removed the redundant `.toLowerCase()` plumbing in callers.\n\n### Verification\n\n- `cargo check -p fabro-server --tests` — clean\n- `cargo +nightly-2026-04-14 clippy -p fabro-server --all-targets -- -D warnings` — clean\n- `cargo +nightly-2026-04-14 fmt -p fabro-server` — applied\n- `cargo nextest run -p fabro-server -E 'test(/archive|api_error/)'` — 11/11 pass (6 batch tests + 5 single-run/error tests)\n- `cd apps/fabro-web && bun test app/routes/runs.test.tsx app/lib/run-actions.test.ts` — 27/27 pass\n- `cd apps/fabro-web && bun run typecheck` — clean", + "last_response": "All checks pass. Here's a summary of what was fixed.\n\n## Summary\n\nLaunched three parallel review agents (reuse / quality / efficiency) and aggregated their findings. Applied the high-value fixes:\n\n###" + }, + "notes": "Stage completed: simplify_opus", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 83032, + "output_tokens": 25666, + "reasoning_tokens": 0, + "cache_read_tokens": 4039192, + "cache_write_tokens": 497002 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 497002, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 6182668 + }, + "files_touched": [ + "apps/fabro-web/app/routes/runs.test.tsx", + "apps/fabro-web/app/routes/runs.tsx", + "lib/crates/fabro-server/src/error.rs", + "lib/crates/fabro-server/src/server.rs", + "lib/crates/fabro-server/src/server/handler/lifecycle.rs" + ] + }, "implement": { "status": "succeeded", "context_updates": { @@ -839,13 +1001,14 @@ "usage": null } }, - "next_node_id": "simplify_opus", + "next_node_id": "simplify_gpt", "node_visits": { "toolchain": 1, "implement": 1, "preflight_lint": 1, "start": 1, - "preflight_compile": 1 + "preflight_compile": 1, + "simplify_opus": 1 } }, "diff": {} @@ -875,7 +1038,12 @@ "first_event_seq": 53, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-24T02:10:48.637657Z" + }, "provider_used": { "mode": "agent", "provider": "openai", @@ -889,17 +1057,24 @@ "output": null, "started_at": "2026-05-24T01:47:14.934564Z", "handler": "agent", + "timing": { + "wall_time_ms": 1217681, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { "input_tokens": 321093, "output_tokens": 20925, "total_tokens": 25282498, "reasoning_tokens": 13248, "cache_read_tokens": 24927232, - "cache_write_tokens": 0 + "cache_write_tokens": 0, + "total_usd_micros": 15094271 }, "model": { "provider": "openai", - "model_id": "gpt-5.5-2026-04-23" + "model_id": "gpt-5.5" }, "todos": { "kind": "openai_plan", @@ -1441,7 +1616,7 @@ } ] }, - "state": "running" + "state": "succeeded" }, "toolchain@1": { "first_event_seq": 23, @@ -1491,6 +1666,136 @@ }, "state": "succeeded" }, + "simplify_opus@1": { + "first_event_seq": 954, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-24T02:10:52.799657Z", + "handler": "agent", + "usage": { + "input_tokens": 83032, + "output_tokens": 25666, + "total_tokens": 4644892, + "reasoning_tokens": 0, + "cache_read_tokens": 4039192, + "cache_write_tokens": 497002, + "total_usd_micros": 6182668 + }, + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "todos": { + "kind": "anthropic_tasks", + "list_id": "anthropic_tasks:0303bca2-612e-4729-86b3-afc5ac218ce8", + "items": [ + { + "id": "1", + "status": "completed", + "order": 0, + "subject": "Code reuse review", + "description": "Agent 1: find duplication of existing utilities in the batch lifecycle change\n" + }, + { + "id": "2", + "status": "completed", + "order": 1, + "subject": "Code quality review", + "description": "Agent 2: review for hacky patterns, redundant state, copy-paste in the batch lifecycle change\n" + }, + { + "id": "3", + "status": "completed", + "order": 2, + "subject": "Efficiency review", + "description": "Agent 3: review for unnecessary work, missed concurrency, hot-path bloat in batch lifecycle\n" + } + ] + }, + "subagents": [ + { + "agent_id": "39c4b473", + "depth": 1, + "task": "You are doing a CODE REUSE review for a feature that adds batch run archive/unarchive endpoints.\n\nRead the diff at /tmp/review.diff. Working directory: /home/daytona/workspace/fabro.\n\nFor each new function or block of new logic, search the codebase for existing utilities that could replace it. Use grep liberally:\n- Rust new code is in lib/crates/fabro-server/src/server/handler/lifecycle.rs (helpers `validate_batch_run_ids`, `batch_run_archive_item`, `load_decorated_run_after_lifecycle_action`, `batch_success_result`, `batch_failure_result`, `batch_error_entry`, plus the new route handlers).\n- The existing `run_archive_action` and `run_response` helpers are right above in the same file. Check whether the new batch handler unnecessarily reimplements logic that could reuse `run_response` and/or unify the error mapping with `run_archive_action`.\n- Check for an existing ErrorResponseEntry constructor / ApiError-to-entry helper before accepting the new `batch_error_entry` (grep ErrorResponseEntry, ApiError::new usages, ApiError -> ErrorResponseEntry conversions).\n- TypeScript new code is in apps/fabro-web/app/lib/run-actions.ts (`archiveRuns`, `unarchiveRuns`, `batchRunLifecycleAction`) and apps/fabro-web/app/routes/runs.tsx (`summarizeBatchLifecycleAction`, `runWord`). Check for an existing pluralize helper.\n\nDo NOT fix anything. Just report a concise list of concrete findings with file:line references and a suggested existing utility / function to reuse. If something looks clean, say so. Keep the report short.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 31 + } + }, + { + "agent_id": "f0c1c4ac", + "depth": 1, + "task": "You are doing a CODE QUALITY review for a feature that adds batch run archive/unarchive endpoints.\n\nRead the diff at /tmp/review.diff. Working directory: /home/daytona/workspace/fabro.\n\nLook for these specific anti-patterns:\n1. Redundant state, derivable cached values, unnecessary indirections.\n2. Parameter sprawl (e.g. helper functions accepting more args than needed).\n3. Copy-paste with slight variation — especially:\n - The two new handlers `batch_archive_runs` and `batch_unarchive_runs` in lib/crates/fabro-server/src/server/handler/lifecycle.rs are nearly identical; could they be unified or simplified?\n - The new `load_decorated_run_after_lifecycle_action` is similar to the existing `run_response` helper at the top of the same file.\n - The error mapping in `batch_run_archive_item` (Precondition -> Conflict, RunNotFound -> NotFound, _ -> Error) mirrors the existing `run_archive_action`.\n - The two batch helpers in run-actions.ts have an unusual `as unknown as BatchRunLifecycleRequest` cast around `{ run_ids: ... }`. Is there a cleaner way given the generated client uses Set?\n4. Leaky abstractions / boundary breaks.\n5. Stringly-typed code or raw strings where enums exist (e.g. action=\"archive\"|\"unarchive\" union; `BatchLifecycleLabel = \"Archive\" | \"Unarchive\"` and then `.toLowerCase()` of it).\n6. Toast summary helper in runs.tsx (`summarizeBatchLifecycleAction`) — is the shape (toast + allSucceeded boolean) sensible or overly complex?\n7. The new test helper `json_request` in tests.rs — is there an existing one?\n\nBe aggressive (greenfield). Report a concise prioritized list with file:line refs and concrete recommendations. Do NOT fix anything. Keep the report short.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 20 + } + }, + { + "agent_id": "afa913f3", + "depth": 1, + "task": "You are doing an EFFICIENCY review for a feature that adds batch run archive/unarchive endpoints.\n\nRead the diff at /tmp/review.diff. Working directory: /home/daytona/workspace/fabro.\n\nFocus on:\n1. Sequential vs concurrent: in lib/crates/fabro-server/src/server/handler/lifecycle.rs `batch_run_archive_action` processes IDs sequentially (the spec says \"Process eligible IDs sequentially in server code\"). Confirm the loop avoids any per-iteration redundant work (e.g. cloning state, recomputing things). Note: the spec explicitly chose sequential — do not recommend parallelizing.\n2. Redundant work in `batch_run_archive_item`: it calls `operations::archive` then `state.store.get_cached_summary` then `state.decorate_run_summary`. Compare to single-run `run_archive_action` which only calls operations and then `run_response`. Is there extra work, e.g. is decorate_run_summary itself doing N+1 IO that compounds in the batch loop?\n3. Web: in apps/fabro-web/app/routes/runs.tsx — the batch action now triggers `mutateRunListCaches(mutate)` once per batch (good). Confirm there's no duplicate invalidation or extra request triggered for unchanged things.\n4. Memory: any unbounded data structures? The batch is capped at 250 so the Vec/HashSet are bounded — that's fine.\n5. Unnecessary existence checks (TOCTOU).\n6. Hot-path bloat.\n\nBriefly grep `state.decorate_run_summary` and `get_cached_summary` to understand what they do (cost-wise).\n\nReport a concise prioritized list of concrete findings with file:line refs. Do NOT fix anything. Keep the report short.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 48 + } + } + ], + "context_window": { + "provider": "anthropic", + "model": "claude-opus-4-7", + "context_window_tokens": 1000000, + "input_tokens": 95501, + "usage_percent": 9.5501, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-05-24T02:21:20.135894Z", + "event_seq": 1680, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 2438, + "usage_percent": 0.2438 + }, + { + "category": "tools", + "tokens": 2755, + "usage_percent": 0.2755 + }, + { + "category": "memory", + "tokens": 5755, + "usage_percent": 0.5755 + }, + { + "category": "conversation", + "tokens": 84545, + "usage_percent": 8.4545 + }, + { + "category": "other", + "tokens": 8, + "usage_percent": 0.0008 + } + ], + "warnings": [] + }, + "state": "running" + }, "preflight_compile@1": { "first_event_seq": 33, "prompt": null, diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..bfc71086f --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,1693 @@ +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..4c6c85370 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,36 @@ describe("runs route board mapping", () => { + "fabro run hello", + ]); + }); ++ ++ test("summarizes successful batch archive and unarchive actions", () => { ++ expect( ++ summarizeBatchLifecycleAction("Archive", 2, { succeeded: 2, failed: 0 }), ++ ).toEqual({ ++ toast: { message: "Archived 2 runs." }, ++ allSucceeded: true, ++ }); ++ expect( ++ summarizeBatchLifecycleAction("Unarchive", 1, { succeeded: 1, failed: 0 }), ++ ).toEqual({ ++ toast: { message: "Unarchived 1 run." }, ++ allSucceeded: true, ++ }); ++ }); ++ ++ test("summarizes partial and failed batch lifecycle actions", () => { ++ expect( ++ summarizeBatchLifecycleAction("Archive", 3, { succeeded: 2, failed: 1 }), ++ ).toEqual({ ++ toast: { message: "Archived 2 of 3 runs. 1 failed.", tone: "error" }, ++ allSucceeded: false, ++ }); ++ expect( ++ summarizeBatchLifecycleAction("Unarchive", 2, { succeeded: 0, failed: 2 }), ++ ).toEqual({ ++ toast: { message: "Couldn't unarchive 2 runs. Try again.", tone: "error" }, ++ allSucceeded: false, ++ }); ++ }); + }); + + 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..b0d7d3456 100644 +--- a/apps/fabro-web/app/routes/runs.tsx ++++ b/apps/fabro-web/app/routes/runs.tsx +@@ -31,8 +31,10 @@ 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 +67,51 @@ const columnStyles: Record = { + const defaultColumnStyle: ColumnStyle = { actions: [] }; + const defaultColumnColors = { label: "", dot: "bg-fg-muted", text: "text-fg-muted" }; + ++function runWord(n: number) { ++ return n === 1 ? "run" : "runs"; ++} ++ ++type BatchLifecycleLabel = "Archive" | "Unarchive"; ++ ++interface BatchLifecycleToastSummary { ++ toast: { ++ message: string; ++ tone?: "error"; ++ }; ++ allSucceeded: boolean; ++} ++ ++export function summarizeBatchLifecycleAction( ++ label: BatchLifecycleLabel, ++ total: number, ++ summary: Pick, ++): BatchLifecycleToastSummary { ++ const succeeded = summary.succeeded; ++ const failed = summary.failed; ++ if (failed === 0) { ++ return { ++ toast: { message: `${label}d ${succeeded} ${runWord(succeeded)}.` }, ++ allSucceeded: true, ++ }; ++ } ++ if (succeeded === 0) { ++ return { ++ toast: { ++ message: `Couldn't ${label.toLowerCase()} ${total} ${runWord(total)}. Try again.`, ++ tone: "error", ++ }, ++ allSucceeded: false, ++ }; ++ } ++ return { ++ toast: { ++ message: `${label}d ${succeeded} of ${total} ${runWord(total)}. ${failed} failed.`, ++ tone: "error", ++ }, ++ allSucceeded: false, ++ }; ++} ++ + interface BoardRunsResponse { + data: Run[]; + } +@@ -463,25 +510,15 @@ 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", total, response.summary).toast); ++ } catch { ++ push( ++ summarizeBatchLifecycleAction("Archive", total, { ++ succeeded: 0, ++ failed: total, ++ }).toast, + ); +- 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,12 +1445,10 @@ 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) { +@@ -1422,23 +1457,19 @@ function BulkActionToolbar({ + } + 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)); ++ const summary = summarizeBatchLifecycleAction(label, eligible.length, response.summary); ++ push(summary.toast); ++ if (summary.allSucceeded) { + 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, eligible.length, { ++ succeeded: 0, ++ failed: eligible.length, ++ }).toast, ++ ); + } finally { + setPending(false); + mutateRunListCaches(mutate); +@@ -1459,14 +1490,14 @@ function BulkActionToolbar({ +