checkpoint

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-05-23 22:25:22 -04:00
parent a5f81654b0
commit b48852a97a
5 changed files with 1379 additions and 41 deletions

588
run.json

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,491 @@
diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx
index 4c6c85370..94d8f5cff 100644
--- a/apps/fabro-web/app/routes/runs.test.tsx
+++ b/apps/fabro-web/app/routes/runs.test.tsx
@@ -177,31 +177,25 @@ describe("runs route board mapping", () => {
test("summarizes successful batch archive and unarchive actions", () => {
expect(
- summarizeBatchLifecycleAction("Archive", 2, { succeeded: 2, failed: 0 }),
- ).toEqual({
- toast: { message: "Archived 2 runs." },
- allSucceeded: true,
- });
+ summarizeBatchLifecycleAction("Archive", { requested: 2, succeeded: 2, failed: 0 }),
+ ).toEqual({ message: "Archived 2 runs." });
expect(
- summarizeBatchLifecycleAction("Unarchive", 1, { succeeded: 1, failed: 0 }),
- ).toEqual({
- toast: { message: "Unarchived 1 run." },
- allSucceeded: true,
- });
+ summarizeBatchLifecycleAction("Unarchive", { requested: 1, succeeded: 1, failed: 0 }),
+ ).toEqual({ message: "Unarchived 1 run." });
});
test("summarizes partial and failed batch lifecycle actions", () => {
expect(
- summarizeBatchLifecycleAction("Archive", 3, { succeeded: 2, failed: 1 }),
+ summarizeBatchLifecycleAction("Archive", { requested: 3, succeeded: 2, failed: 1 }),
).toEqual({
- toast: { message: "Archived 2 of 3 runs. 1 failed.", tone: "error" },
- allSucceeded: false,
+ message: "Archived 2 of 3 runs. 1 failed.",
+ tone: "error",
});
expect(
- summarizeBatchLifecycleAction("Unarchive", 2, { succeeded: 0, failed: 2 }),
+ summarizeBatchLifecycleAction("Unarchive", { requested: 2, succeeded: 0, failed: 2 }),
).toEqual({
- toast: { message: "Couldn't unarchive 2 runs. Try again.", tone: "error" },
- allSucceeded: false,
+ message: "Couldn't unarchive 2 runs. Try again.",
+ tone: "error",
});
});
});
diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx
index b0d7d3456..5e26b7c96 100644
--- a/apps/fabro-web/app/routes/runs.tsx
+++ b/apps/fabro-web/app/routes/runs.tsx
@@ -27,6 +27,7 @@ import { formatRelativeTime } from "../lib/format";
import { EmptyState } from "../components/state";
import { InlineMarkdown } from "../components/inline-markdown";
import { PullRequestChip } from "../components/pull-request-chip";
+import { plural } from "../components/settings-panel";
import { useToast } from "../components/toast";
import { mutateRunListCaches } from "../lib/board-cache";
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
@@ -67,48 +68,30 @@ const columnStyles: Record<BoardColumn, ColumnStyle> = {
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;
+interface BatchLifecycleToast {
+ message: string;
+ tone?: "error";
}
export function summarizeBatchLifecycleAction(
label: BatchLifecycleLabel,
- total: number,
- summary: Pick<BatchRunLifecycleSummary, "succeeded" | "failed">,
-): BatchLifecycleToastSummary {
- const succeeded = summary.succeeded;
- const failed = summary.failed;
+ summary: BatchRunLifecycleSummary,
+): BatchLifecycleToast {
+ const { requested, succeeded, failed } = summary;
if (failed === 0) {
- return {
- toast: { message: `${label}d ${succeeded} ${runWord(succeeded)}.` },
- allSucceeded: true,
- };
+ return { message: `${label}d ${succeeded} ${plural(succeeded, "run", "runs")}.` };
}
if (succeeded === 0) {
return {
- toast: {
- message: `Couldn't ${label.toLowerCase()} ${total} ${runWord(total)}. Try again.`,
- tone: "error",
- },
- allSucceeded: false,
+ message: `Couldn't ${label.toLowerCase()} ${requested} ${plural(requested, "run", "runs")}. Try again.`,
+ tone: "error",
};
}
return {
- toast: {
- message: `${label}d ${succeeded} of ${total} ${runWord(total)}. ${failed} failed.`,
- tone: "error",
- },
- allSucceeded: false,
+ message: `${label}d ${succeeded} of ${requested} ${plural(requested, "run", "runs")}. ${failed} failed.`,
+ tone: "error",
};
}
@@ -511,13 +494,14 @@ function ColumnActionsMenu({ column }: { column: Column }) {
const total = archivable.length;
try {
const response = await archiveRuns(archivable.map((item) => item.id));
- push(summarizeBatchLifecycleAction("Archive", total, response.summary).toast);
+ push(summarizeBatchLifecycleAction("Archive", response.summary));
} catch {
push(
- summarizeBatchLifecycleAction("Archive", total, {
+ summarizeBatchLifecycleAction("Archive", {
+ requested: total,
succeeded: 0,
failed: total,
- }).toast,
+ }),
);
} finally {
setPending(false);
@@ -1452,23 +1436,26 @@ function BulkActionToolbar({
) {
if (pending) return;
if (eligible.length === 0) {
- push({ message: `No selected ${runWord(count)} can be ${label.toLowerCase()}d.`, tone: "error" });
+ push({
+ message: `No selected ${plural(count, "run", "runs")} can be ${label.toLowerCase()}d.`,
+ tone: "error",
+ });
return;
}
setPending(true);
try {
const response = await action(eligible.map((r) => r.id));
- const summary = summarizeBatchLifecycleAction(label, eligible.length, response.summary);
- push(summary.toast);
- if (summary.allSucceeded) {
+ push(summarizeBatchLifecycleAction(label, response.summary));
+ if (response.summary.failed === 0) {
onClear();
}
} catch {
push(
- summarizeBatchLifecycleAction(label, eligible.length, {
+ summarizeBatchLifecycleAction(label, {
+ requested: eligible.length,
succeeded: 0,
failed: eligible.length,
- }).toast,
+ }),
);
} finally {
setPending(false);
@@ -1484,7 +1471,7 @@ function BulkActionToolbar({
>
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-line-strong bg-panel py-2 pl-4 pr-2 text-sm text-fg-2 shadow-lg shadow-black/40">
<span className="font-medium">
- {count} {runWord(count)} selected
+ {count} {plural(count, "run", "runs")} selected
</span>
<span className="h-5 w-px bg-line" aria-hidden="true" />
<BulkActionButton
diff --git a/lib/crates/fabro-server/src/error.rs b/lib/crates/fabro-server/src/error.rs
index ba66d3198..356d68af1 100644
--- a/lib/crates/fabro-server/src/error.rs
+++ b/lib/crates/fabro-server/src/error.rs
@@ -1,6 +1,7 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
+use fabro_api::types::ErrorResponseEntry;
use fabro_vault::Error as VaultError;
use serde::Serialize;
@@ -128,6 +129,24 @@ impl ApiError {
pub(crate) fn code(&self) -> Option<&str> {
self.code.as_deref()
}
+
+ /// Convert into the OpenAPI-generated `ErrorResponseEntry` wire form. This
+ /// is used by endpoints that return per-item errors inside a larger payload
+ /// (e.g. batch lifecycle responses), where the outer response is `200` but
+ /// individual items carry structured failures.
+ pub fn into_response_entry(self) -> ErrorResponseEntry {
+ ErrorResponseEntry {
+ status: self.status.as_u16().to_string(),
+ title: self
+ .status
+ .canonical_reason()
+ .unwrap_or("Unknown")
+ .to_string(),
+ detail: self.detail,
+ code: self.code,
+ request_id: None,
+ }
+ }
}
impl From<Error> for ApiError {
diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs
index 996748775..fa7b06f0d 100644
--- a/lib/crates/fabro-server/src/server.rs
+++ b/lib/crates/fabro-server/src/server.rs
@@ -965,12 +965,12 @@ pub struct AppState {
type PullRequestCreateLocks = Arc<Mutex<HashMap<RunId, Arc<AsyncMutex<()>>>>>;
-struct AskFabroReadiness {
+pub(crate) struct AskFabroReadiness {
default_model: Option<String>,
}
impl AskFabroReadiness {
- fn decorate(&self, mut run: fabro_types::Run) -> fabro_types::Run {
+ pub(crate) fn decorate(&self, mut run: fabro_types::Run) -> fabro_types::Run {
run.ask_fabro = self.ask_fabro_for(&run);
run
}
@@ -1204,7 +1204,7 @@ impl AppState {
.collect()
}
- async fn ask_fabro_readiness(&self) -> AskFabroReadiness {
+ pub(crate) async fn ask_fabro_readiness(&self) -> AskFabroReadiness {
let provider_ids = self.ready_llm_provider_ids().await;
let default_model = if provider_ids.is_empty() {
None
diff --git a/lib/crates/fabro-server/src/server/handler/lifecycle.rs b/lib/crates/fabro-server/src/server/handler/lifecycle.rs
index 249b0d22e..8fe867a55 100644
--- a/lib/crates/fabro-server/src/server/handler/lifecycle.rs
+++ b/lib/crates/fabro-server/src/server/handler/lifecycle.rs
@@ -4,13 +4,13 @@ use std::sync::Arc;
use chrono::Utc;
use super::super::{
- ApiError, AppState, BatchRunLifecycleRequest, BatchRunLifecycleResponse,
+ ApiError, AppState, AskFabroReadiness, BatchRunLifecycleRequest, BatchRunLifecycleResponse,
BatchRunLifecycleResult, BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary,
- DenyRunRequest, ErrorResponseEntry, FailureReason, ForkRequest, ForkResponse, HeaderMap,
- IntoResponse, Json, Path, PendingReason, Principal, RequireRunScopedOrRunTools, RequiredUser,
- Response, RewindRequest, RewindResponse, Router, Run, RunAnswerTransport, RunControlAction,
- RunExecutionMode, RunId, RunRunnableSource, RunStatus, StartRunRequest, State, StatusCode,
- Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError, append_control_request,
+ DenyRunRequest, FailureReason, ForkRequest, ForkResponse, HeaderMap, IntoResponse, Json, Path,
+ PendingReason, Principal, RequireRunScopedOrRunTools, RequiredUser, Response, RewindRequest,
+ RewindResponse, Router, RunAnswerTransport, RunControlAction, RunExecutionMode, RunId,
+ RunRunnableSource, RunStatus, StartRunRequest, State, StatusCode, Storage,
+ TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError, append_control_request,
clear_live_run_state, durable_run_status, get, load_pending_control, managed_run, operations,
parse_run_id_path, persist_cancelled_run_status, post, reject_if_archived, sleep,
update_live_run_from_event, workflow_event,
@@ -921,10 +921,16 @@ async fn batch_run_archive_action(
Ok(ids) => ids,
Err(err) => return err.into_response(),
};
- let mut results = Vec::with_capacity(ids.len());
+ // Resolve Ask Fabro readiness once per batch instead of inside each
+ // per-item summary lookup; readiness is identical for every run in the
+ // request and resolving it performs LLM credential work.
+ let readiness = state.ask_fabro_readiness().await;
+ let mut results = Vec::with_capacity(ids.len());
for id in ids {
- results.push(batch_run_archive_item(state.as_ref(), actor.clone(), id, action).await);
+ results.push(
+ batch_run_archive_item(state.as_ref(), &readiness, actor.clone(), id, action).await,
+ );
}
let requested = results.len() as u64;
@@ -973,108 +979,101 @@ fn validate_batch_run_ids(request: BatchRunLifecycleRequest) -> Result<Vec<RunId
async fn batch_run_archive_item(
state: &AppState,
+ readiness: &AskFabroReadiness,
actor: Principal,
id: RunId,
action: ArchiveAction,
) -> BatchRunLifecycleResult {
- let result = match action {
- ArchiveAction::Archive => operations::archive(&state.store, &id, Some(actor))
- .await
- .map(|outcome| match outcome {
- operations::ArchiveOutcome::Archived { .. } => {
- BatchRunLifecycleResultOutcome::Archived
- }
- operations::ArchiveOutcome::AlreadyArchived => {
- BatchRunLifecycleResultOutcome::AlreadyArchived
- }
- }),
- ArchiveAction::Unarchive => operations::unarchive(&state.store, &id, Some(actor))
- .await
- .map(|outcome| match outcome {
- operations::UnarchiveOutcome::Unarchived { .. } => {
- BatchRunLifecycleResultOutcome::Unarchived
- }
- operations::UnarchiveOutcome::NotArchived { .. } => {
- BatchRunLifecycleResultOutcome::NotArchived
- }
- }),
+ let outcome = match run_archive_operation(state, &id, Some(actor), action).await {
+ Ok(outcome) => outcome,
+ Err(err) => {
+ let api_error = archive_workflow_error_to_api_error(err);
+ let result_outcome = match api_error.status() {
+ StatusCode::NOT_FOUND => BatchRunLifecycleResultOutcome::NotFound,
+ StatusCode::CONFLICT => BatchRunLifecycleResultOutcome::Conflict,
+ _ => BatchRunLifecycleResultOutcome::Error,
+ };
+ return batch_result_failure(id, result_outcome, api_error);
+ }
};
- match result {
- Ok(outcome) => match load_decorated_run_after_lifecycle_action(state, id).await {
- Ok(run) => batch_success_result(id, outcome, run),
- Err(error) => batch_failure_result(id, BatchRunLifecycleResultOutcome::Error, error),
+ match state.store.get_cached_summary(&id, Utc::now()).await {
+ Ok(Some(summary)) => BatchRunLifecycleResult {
+ run_id: id.to_string(),
+ ok: true,
+ outcome,
+ run: Some(readiness.decorate(summary)),
+ error: None,
},
- Err(WorkflowError::Precondition(message)) => batch_failure_result(
+ Ok(None) => batch_result_failure(
id,
- BatchRunLifecycleResultOutcome::Conflict,
- batch_error_entry(StatusCode::CONFLICT, message),
- ),
- Err(WorkflowError::RunNotFound(_)) => batch_failure_result(
- id,
- BatchRunLifecycleResultOutcome::NotFound,
- batch_error_entry(StatusCode::NOT_FOUND, "Run not found."),
+ BatchRunLifecycleResultOutcome::Error,
+ ApiError::new(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "Failed to load run summary after lifecycle action.",
+ ),
),
- Err(err) => batch_failure_result(
+ Err(err) => batch_result_failure(
id,
BatchRunLifecycleResultOutcome::Error,
- batch_error_entry(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
+ ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
),
}
}
-async fn load_decorated_run_after_lifecycle_action(
- state: &AppState,
- id: RunId,
-) -> Result<Run, ErrorResponseEntry> {
- match state.store.get_cached_summary(&id, Utc::now()).await {
- Ok(Some(summary)) => Ok(state.decorate_run_summary(summary).await),
- Ok(None) => Err(batch_error_entry(
- StatusCode::INTERNAL_SERVER_ERROR,
- "Failed to load run summary after lifecycle action.",
- )),
- Err(err) => Err(batch_error_entry(
- StatusCode::INTERNAL_SERVER_ERROR,
- err.to_string(),
- )),
- }
-}
-
-fn batch_success_result(
+fn batch_result_failure(
id: RunId,
outcome: BatchRunLifecycleResultOutcome,
- run: Run,
+ error: ApiError,
) -> BatchRunLifecycleResult {
BatchRunLifecycleResult {
run_id: id.to_string(),
- ok: true,
+ ok: false,
outcome,
- run: Some(run),
- error: None,
+ run: None,
+ error: Some(error.into_response_entry()),
}
}
-fn batch_failure_result(
- id: RunId,
- outcome: BatchRunLifecycleResultOutcome,
- error: ErrorResponseEntry,
-) -> BatchRunLifecycleResult {
- BatchRunLifecycleResult {
- run_id: id.to_string(),
- ok: false,
- outcome,
- run: None,
- error: Some(error),
+async fn run_archive_operation(
+ state: &AppState,
+ id: &RunId,
+ actor: Option<Principal>,
+ action: ArchiveAction,
+) -> Result<BatchRunLifecycleResultOutcome, WorkflowError> {
+ match action {
+ ArchiveAction::Archive => {
+ operations::archive(&state.store, id, actor)
+ .await
+ .map(|outcome| match outcome {
+ operations::ArchiveOutcome::Archived { .. } => {
+ BatchRunLifecycleResultOutcome::Archived
+ }
+ operations::ArchiveOutcome::AlreadyArchived => {
+ BatchRunLifecycleResultOutcome::AlreadyArchived
+ }
+ })
+ }
+ ArchiveAction::Unarchive => {
+ operations::unarchive(&state.store, id, actor)
+ .await
+ .map(|outcome| match outcome {
+ operations::UnarchiveOutcome::Unarchived { .. } => {
+ BatchRunLifecycleResultOutcome::Unarchived
+ }
+ operations::UnarchiveOutcome::NotArchived { .. } => {
+ BatchRunLifecycleResultOutcome::NotArchived
+ }
+ })
+ }
}
}
-fn batch_error_entry(status: StatusCode, detail: impl Into<String>) -> ErrorResponseEntry {
- ErrorResponseEntry {
- status: status.as_u16().to_string(),
- title: status.canonical_reason().unwrap_or("Unknown").to_string(),
- detail: detail.into(),
- code: None,
- request_id: None,
+fn archive_workflow_error_to_api_error(err: WorkflowError) -> ApiError {
+ match err {
+ WorkflowError::Precondition(message) => ApiError::new(StatusCode::CONFLICT, message),
+ WorkflowError::RunNotFound(_) => ApiError::not_found("Run not found."),
+ err => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
}
}
@@ -1084,24 +1083,9 @@ async fn run_archive_action(
id: RunId,
action: ArchiveAction,
) -> Response {
- let actor = Some(actor);
- let result = match action {
- ArchiveAction::Archive => operations::archive(&state.store, &id, actor)
- .await
- .map(|_| ()),
- ArchiveAction::Unarchive => operations::unarchive(&state.store, &id, actor)
- .await
- .map(|_| ()),
- };
- match result {
- Ok(()) => archive_status_response(state.as_ref(), id).await,
- Err(WorkflowError::Precondition(message)) => {
- ApiError::new(StatusCode::CONFLICT, message).into_response()
- }
- Err(WorkflowError::RunNotFound(_)) => ApiError::not_found("Run not found.").into_response(),
- Err(err) => {
- ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
- }
+ match run_archive_operation(state.as_ref(), &id, Some(actor), action).await {
+ Ok(_) => archive_status_response(state.as_ref(), id).await,
+ Err(err) => archive_workflow_error_to_api_error(err).into_response(),
}
}

View file

@ -0,0 +1,6 @@
{
"outcome": "succeeded",
"notes": "Stage completed: simplify_opus",
"failure_reason": null,
"timestamp": "2026-05-24T02:21:20.248646Z"
}

View file

@ -0,0 +1,330 @@
Goal: ---
title: feat: Batch run archive actions
type: feat
status: active
date: 2026-05-24
---
# feat: Batch Run Archive Actions
## Overview
Add 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.
## Problem Frame
The 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.
## Requirements Trace
- R1. Provide public API endpoints that archive and unarchive many runs in one request.
- R2. Preserve existing single-run archive/unarchive behavior, including eligibility, idempotency, and emitted events.
- R3. Return per-run results so mixed-success batches can be reported without rolling back successful items.
- R4. Update web bulk actions to make one request per batch action instead of one request per run.
- R5. Keep cache invalidation and toast behavior equivalent to the current UI.
## Scope Boundaries
- Do not make batch archive/unarchive transactional; each run remains an independent event stream.
- Do not add new workflow event variants; successful batch items emit the same run archive/unarchive events as single-run actions.
- Do not change the existing `POST /api/v1/runs/{id}/archive` or `POST /api/v1/runs/{id}/unarchive` contracts.
- Do not add CLI commands in this change; this plan is limited to HTTP API and web UI usage.
## Context & Research
### Relevant Code and Patterns
- OpenAPI is the source of truth for HTTP contracts in `docs/public/api-reference/fabro-api.yaml`.
- 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`.
- 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.
- 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`.
- Run-list cache invalidation already uses `mutateRunListCaches` in `apps/fabro-web/app/lib/board-cache.ts`.
### Strategy Docs
- `docs/internal/testing-strategy.md`: server tests should assert public HTTP contracts and prefer structured assertions.
- `docs/internal/error-handling-strategy.md`: preserve structured errors internally and return curated API messages at HTTP boundaries.
- `docs/internal/events-strategy.md`: no new events are needed because existing per-run lifecycle events already represent the durable state transition.
## Key Technical Decisions
- 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`).
- 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.
- 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.
- 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.
- Treat idempotent single-run outcomes as successful batch outcomes: already archived counts as success for archive; terminal not-archived counts as success for unarchive.
## API Contract
Add these OpenAPI operations:
- `POST /api/v1/runs/archive`
- operationId: `batchArchiveRuns`
- request: `BatchRunLifecycleRequest`
- response: `BatchRunLifecycleResponse`
- `POST /api/v1/runs/unarchive`
- operationId: `batchUnarchiveRuns`
- request: `BatchRunLifecycleRequest`
- response: `BatchRunLifecycleResponse`
Add schemas:
- `BatchRunLifecycleRequest`
- required `run_ids`
- `run_ids`: array of strings, `minItems: 1`, `maxItems: 250`, `uniqueItems: true`
- `BatchRunLifecycleResponse`
- required `results`, `summary`
- `results`: array of `BatchRunLifecycleResult`, ordered exactly like the request `run_ids`
- `summary`: `BatchRunLifecycleSummary`
- `BatchRunLifecycleResult`
- required `run_id`, `ok`, `outcome`
- `run_id`: string
- `ok`: boolean
- `outcome`: enum covering `archived`, `already_archived`, `unarchived`, `not_archived`, `not_found`, `conflict`, `error`
- optional `run`: `Run`, present for successful items when a decorated summary can be loaded
- optional `error`: `ErrorResponseEntry`, present for failed items
- `BatchRunLifecycleSummary`
- required `requested`, `succeeded`, `failed`
- all integer counts
## Implementation Units
- [ ] **Unit 1: OpenAPI batch lifecycle contract**
**Goal:** Add the public API contract and generated clients for batch archive/unarchive.
**Requirements:** R1, R3
**Dependencies:** None
**Files:**
- Modify: `docs/public/api-reference/fabro-api.yaml`
- Generated by build/codegen: `lib/crates/fabro-api/src/generated.rs`
- Generated by codegen: `lib/packages/fabro-api-client/src/api/runs-api.ts`
- Generated by codegen: `lib/packages/fabro-api-client/src/models/*`
**Approach:**
- Add the two collection action paths and the four batch lifecycle schemas described in the API Contract section.
- Keep response status simple: `200` for a valid processed batch; `400`, `401`, and `500` for request-level failures.
- Run Rust generation through `cargo build -p fabro-api`.
- Run TypeScript generation through `cd lib/packages/fabro-api-client && bun run generate`.
**Patterns to follow:**
- Existing single-run archive/unarchive path docs in the same OpenAPI file.
- Existing generated client workflow described in `AGENTS.md`.
**Test scenarios:**
- Happy path: generated Rust and TypeScript clients expose `batchArchiveRuns` and `batchUnarchiveRuns`.
- Contract: request schema enforces `run_ids` as the only required input.
- Contract: result entries can represent both a successful decorated `Run` and a failed `ErrorResponseEntry`.
**Verification:**
- API generation completes without hand-edited generated files.
- [ ] **Unit 2: Server batch lifecycle handlers**
**Goal:** Implement the two new endpoints in the server using existing archive/unarchive operations.
**Requirements:** R1, R2, R3
**Dependencies:** Unit 1
**Files:**
- Modify: `lib/crates/fabro-server/src/server/handler/lifecycle.rs`
- Test: `lib/crates/fabro-server/src/server/tests.rs`
**Approach:**
- Register `POST /runs/archive` and `POST /runs/unarchive` in the lifecycle route set.
- Use `RequiredUser` for both handlers and convert the user into `Principal::User` for per-run operation calls.
- Add a small shared batch helper that accepts the parsed request, the action, and the actor, then returns `BatchRunLifecycleResponse`.
- 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.
- 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.
- 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.
**Patterns to follow:**
- `run_archive_action` for operation mapping and existing error semantics.
- `run_response` / `state.decorate_run_summary` for response shape.
- Existing server tests around `archive_and_unarchive_updates_listing_visibility`.
**Test scenarios:**
- 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`.
- Happy path: two archived runs unarchived in one request return successful entries and both runs reappear in default listing.
- Idempotency: archiving an already archived run returns `ok=true` with `already_archived`; unarchiving a terminal non-archived run returns `ok=true` with `not_archived`.
- 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.
- Error path: empty `run_ids`, duplicate IDs, invalid IDs, and more than 250 IDs return request-level `400` and mutate no runs.
- Auth path: unauthenticated requests are rejected; run-scoped worker authentication is not accepted for batch endpoints.
**Verification:**
- Existing single-run archive/unarchive tests pass unchanged.
- New endpoint tests prove both API contract and run-list visibility effects.
- [ ] **Unit 3: Frontend lifecycle helpers**
**Goal:** Add typed web helpers for batch archive/unarchive.
**Requirements:** R3, R4, R5
**Dependencies:** Unit 1
**Files:**
- Modify: `apps/fabro-web/app/lib/run-actions.ts`
- Test: `apps/fabro-web/app/lib/run-actions.test.ts`
**Approach:**
- Add `archiveRuns(runIds: string[])` and `unarchiveRuns(runIds: string[])` wrappers around the generated client methods.
- Keep existing `archiveRun` and `unarchiveRun` helpers unchanged.
- 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.
**Patterns to follow:**
- Existing lifecycle action helpers in `run-actions.ts`.
- Axios adapter tests in `run-actions.test.ts`.
**Test scenarios:**
- Happy path: `archiveRuns(["run-1", "run-2"])` sends one generated-client request and returns parsed batch results.
- Mixed result: helper resolves a response containing one success and one per-item failure without throwing.
- Request error: helper throws parsed `ApiError`/lifecycle-style data when the server returns a request-level `400`.
- Regression: existing `archiveRun`, `unarchiveRun`, `canArchive`, and `canUnarchive` tests continue to pass.
**Verification:**
- Web unit tests cover the new helper contract without changing single-run behavior.
- [ ] **Unit 4: Web bulk-action integration**
**Goal:** Replace multi-request UI orchestration with one batch request per bulk action.
**Requirements:** R4, R5
**Dependencies:** Units 1 and 3
**Files:**
- Modify: `apps/fabro-web/app/routes/runs.tsx`
- Test: `apps/fabro-web/app/routes/runs.test.tsx`
**Approach:**
- Update `BulkActionToolbar` to call `archiveRuns` or `unarchiveRuns` once with eligible selected IDs.
- 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`.
- Keep current eligibility filtering: archive only selected runs whose lifecycle status is archivable, unarchive only selected archived runs.
- Clear selection only when all eligible items succeed, matching the current all-success behavior.
- Call `mutateRunListCaches` once after the batch settles.
- 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.
**Patterns to follow:**
- Current `BulkActionToolbar` pending state, clear-selection behavior, and toast wording.
- Current `ColumnActionsMenu` archive-all action and cache invalidation.
**Test scenarios:**
- Happy path: selecting multiple archived rows and clicking Unarchive calls the batch helper once and clears selection after all items succeed.
- Happy path: selecting multiple terminal rows and clicking Archive calls the batch helper once and invalidates run-list caches once.
- Mixed result: partial failure keeps selection and shows an error-tone partial-success toast with succeeded/failed counts.
- Ineligible selection: selecting only non-archivable runs still shows the existing “No selected runs can be archived” style error without making an API request.
- Board action: archive-all for a column calls the batch helper once with all eligible IDs.
**Verification:**
- UI behavior remains equivalent from the user’s perspective, but network behavior becomes one request per bulk action.
## System-Wide Impact
- **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.
- **Events:** No new event names or payloads. Each successful item appends the existing per-run archive/unarchive event.
- **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.
- **Generated clients:** Both Rust and TypeScript generated clients change because OpenAPI is the source of truth.
- **Compatibility:** Single-run endpoints and existing generated methods remain available and unchanged.
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| Route ambiguity with `/runs/{id}` paths | Use static collection action paths and add server tests that call the exact batch routes. |
| Partial mutation surprises | Use explicit fail-soft result entries and summarize succeeded/failed counts. |
| Worker token privilege expansion | Require `RequiredUser` for batch endpoints. |
| Duplicate IDs producing confusing results | Reject duplicates at request validation before any mutation. |
| UI toast regressions | Cover all-success, all-failure, partial-failure, and ineligible-selection behavior in frontend tests. |
## Documentation / Operational Notes
- The OpenAPI descriptions should explicitly state that batch actions are fail-soft and not transactional.
- No migration, feature flag, or rollout sequencing is required.
- No new public docs are required unless API reference publishing is part of the release process.
## Sources & References
- OpenAPI source: `docs/public/api-reference/fabro-api.yaml`
- Server lifecycle handlers: `lib/crates/fabro-server/src/server/handler/lifecycle.rs`
- Workflow archive operations: `lib/crates/fabro-workflow/src/operations/archive.rs`
- Web lifecycle helpers: `apps/fabro-web/app/lib/run-actions.ts`
- Web runs route: `apps/fabro-web/app/routes/runs.tsx`
- Testing guidance: `docs/internal/testing-strategy.md`
- Error handling guidance: `docs/internal/error-handling-strategy.md`
- Event guidance: `docs/internal/events-strategy.md`
## Completed stages
- **toolchain**: succeeded
- Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1`
- Output:
```
cargo 1.95.0 (f2d3ce0bd 2026-03-21)
```
- **preflight_compile**: succeeded
- Script: `cargo check -q --workspace 2>&1`
- Output: (empty)
- **preflight_lint**: succeeded
- Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1`
- Output: (empty)
- **implement**: succeeded
- Model: gpt-5.5, 321.1k tokens in / 34.2k out
- **simplify_opus**: succeeded
- Model: claude-opus-4-7, 83.0k tokens in / 25.7k out
- Files: 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
# Simplify: Code Review and Cleanup
Review changes vs. origin for reuse, quality, and efficiency. Fix any issues found.
## Phase 1: Identify Changes
Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation.
## Phase 2: Launch Three Review Agents in Parallel
Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.
### Agent 1: Code Reuse Review
For each change:
1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.
2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.
3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.
Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.
### Agent 2: Code Quality Review
Review the same changes for hacky patterns:
1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls
2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones
3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction
4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries
5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase
Note: This is a greenfield app, so be aggressive in optimizing quality.
### Agent 3: Efficiency Review
Review the same changes for efficiency:
1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns
2. Missed concurrency: independent operations run sequentially when they could run in parallel
3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths
4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error
5. Memory: unbounded data structures, missing cleanup, event listener leaks
6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one
## Phase 3: Fix Issues
Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.
When done, briefly summarize what was fixed (or confirm the code was already clean).

View file

@ -0,0 +1,5 @@
{
"mode": "agent",
"provider": "openai",
"model": "gpt-5.5"
}