feat: Add batch archive/unarchive API endpoints and update web bulk act… (#380)

## Summary

The web UI previously issued one archive/unarchive HTTP request per
selected run. This PR adds `POST /api/v1/runs/archive` and `POST
/api/v1/runs/unarchive` endpoints that process up to 250 runs in a
single fail-soft, non-transactional request, then wires the web
bulk-action toolbar and board column menu to use them.

### Plan Summary

- **OpenAPI contract** — four new schemas (`BatchRunLifecycleRequest`,
`BatchRunLifecycleResponse`, `BatchRunLifecycleResult`,
`BatchRunLifecycleSummary`) and two new paths; Rust and TypeScript
clients regenerated.
- **Server handlers** — `batch_archive_runs` / `batch_unarchive_runs`
behind `RequiredUser`; full request validation (empty, >250, duplicates,
unparseable IDs) before any mutation; per-item outcome mapping
(`archived`, `already_archived`, `unarchived`, `not_archived`,
`conflict`, `not_found`, `error`).
- **Frontend helpers** — `archiveRuns` / `unarchiveRuns` wrappers in
`run-actions.ts`; single-run helpers unchanged.
- **UI integration** — `BulkActionToolbar` and `ColumnActionsMenu`
replaced `Promise.allSettled` fan-out with one batch call; new
`summarizeBatchLifecycleAction` helper drives toast copy for
all-success, partial, and all-failure cases.

## Key Design Decisions

**Fail-soft `200` for valid batches.** A batch where some items fail is
still a successfully *processed* request; the per-item `ok` flag and
`summary` counts communicate individual outcomes without requiring the
caller to handle HTTP errors for partial failures. Request-level
problems (bad IDs, empty list) still return `400`.

**`RequiredUser` only.** Batch endpoints accept any-run mutations from a
request body, so a run-scoped worker token must not be accepted. This is
enforced at the handler level, separate from existing single-run
lifecycle routes.

**Request validation before any mutation.** Empty list, >250 IDs,
duplicate IDs, and unparseable IDs all return `400` before touching any
run — avoiding partial mutation surprises from invalid input.

**Idempotent outcomes are successes.** `already_archived` (archive of an
already-archived run) and `not_archived` (unarchive of a terminal
non-archived run) both set `ok=true`. This matches the existing
single-run semantics and avoids spurious failures in retry scenarios.

**`ask_fabro_readiness` hoisted out of the per-item loop.** Readiness
resolution involves LLM credential work; it's identical for every run in
the batch, so it's resolved once before the loop and shared via
`&AskFabroReadiness`.

**`uniqueItems: true` / `Set<string>` workaround.** The OpenAPI
generator maps `uniqueItems` arrays to `Set<T>` in TypeScript, but the
HTTP wire format is still a JSON array. The frontend helper casts
through `unknown` to send an array so Axios serializes correctly.


### Fabro Details

<details>
<summary>Ran 8 stages in 47m 48s for $23.53</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 4s | – | 0 |
| preflight_lint | 2m 16s | – | 0 |
| implement | 20m 17s | $15.09 | 0 |
| simplify_opus | 10m 27s | $6.18 | 0 |
| simplify_gpt | 3m 58s | $2.25 | 0 |
| verify | 8m 14s | – | 0 |
| **Total** | **47m 48s** | **$23.53** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-23 23:32:35 -04:00 committed by GitHub
parent 6eb57685d4
commit 8ceb246b5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1327 additions and 89 deletions

View file

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

View file

@ -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<Run>
return runLifecycleAction(id, "unarchive", request);
}
export async function archiveRuns(
runIds: string[],
request?: Request,
): Promise<BatchRunLifecycleResponse> {
return batchRunLifecycleAction(runIds, "archive", request);
}
export async function unarchiveRuns(
runIds: string[],
request?: Request,
): Promise<BatchRunLifecycleResponse> {
return batchRunLifecycleAction(runIds, "unarchive", request);
}
export async function retryRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "retry", request);
}
@ -182,6 +201,27 @@ async function runLifecycleAction(
}
}
async function batchRunLifecycleAction(
runIds: string[],
action: "archive" | "unarchive",
request?: Request,
): Promise<BatchRunLifecycleResponse> {
try {
// openapi-generator's TypeScript client represents `uniqueItems` arrays as
// Set<T>, 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 {

View file

@ -9,6 +9,7 @@ import {
RUNS_PREFERENCES_STORAGE_KEY,
runsQuickStartCommands,
shouldRefreshBoardForEvent,
summarizeBatchLifecycleAction,
} from "./runs";
function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
@ -173,6 +174,30 @@ describe("runs route board mapping", () => {
"fabro run hello",
]);
});
test("summarizes successful batch archive and unarchive actions", () => {
expect(
summarizeBatchLifecycleAction("Archive", { requested: 2, succeeded: 2, failed: 0 }),
).toEqual({ message: "Archived 2 runs." });
expect(
summarizeBatchLifecycleAction("Unarchive", { requested: 1, succeeded: 1, failed: 0 }),
).toEqual({ message: "Unarchived 1 run." });
});
test("summarizes partial and failed batch lifecycle actions", () => {
expect(
summarizeBatchLifecycleAction("Archive", { requested: 3, succeeded: 2, failed: 1 }),
).toEqual({
message: "Archived 2 of 3 runs. 1 failed.",
tone: "error",
});
expect(
summarizeBatchLifecycleAction("Unarchive", { requested: 2, succeeded: 0, failed: 2 }),
).toEqual({
message: "Couldn't unarchive 2 runs. Try again.",
tone: "error",
});
});
});
describe("runs route workspace preferences", () => {

View file

@ -27,12 +27,15 @@ import { formatRelativeTime } from "../lib/format";
import { EmptyState } from "../components/state";
import { InlineMarkdown } from "../components/inline-markdown";
import { PullRequestChip } from "../components/pull-request-chip";
import { plural } from "../components/settings-panel";
import { useToast } from "../components/toast";
import { mutateRunListCaches } from "../lib/board-cache";
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
import { useAllRuns, useAuthConfig, useRunsPage, useSystemInfo } from "../lib/queries";
import { archiveRun, canArchive, canUnarchive, unarchiveRun } from "../lib/run-actions";
import { archiveRuns, canArchive, canUnarchive, unarchiveRuns } from "../lib/run-actions";
import type {
BatchRunLifecycleResponse,
BatchRunLifecycleSummary,
BoardColumn,
ListRunsDirectionEnum,
ListRunsSortEnum,
@ -65,6 +68,33 @@ const columnStyles: Record<BoardColumn, ColumnStyle> = {
const defaultColumnStyle: ColumnStyle = { actions: [] };
const defaultColumnColors = { label: "", dot: "bg-fg-muted", text: "text-fg-muted" };
type BatchLifecycleLabel = "Archive" | "Unarchive";
interface BatchLifecycleToast {
message: string;
tone?: "error";
}
export function summarizeBatchLifecycleAction(
label: BatchLifecycleLabel,
summary: BatchRunLifecycleSummary,
): BatchLifecycleToast {
const { requested, succeeded, failed } = summary;
if (failed === 0) {
return { message: `${label}d ${succeeded} ${plural(succeeded, "run", "runs")}.` };
}
if (succeeded === 0) {
return {
message: `Couldn't ${label.toLowerCase()} ${requested} ${plural(requested, "run", "runs")}. Try again.`,
tone: "error",
};
}
return {
message: `${label}d ${succeeded} of ${requested} ${plural(requested, "run", "runs")}. ${failed} failed.`,
tone: "error",
};
}
interface BoardRunsResponse {
data: Run[];
}
@ -463,25 +493,16 @@ function ColumnActionsMenu({ column }: { column: Column }) {
setPending(true);
const total = archivable.length;
try {
const results = await Promise.allSettled(
archivable.map((item) => archiveRun(item.id)),
const response = await archiveRuns(archivable.map((item) => item.id));
push(summarizeBatchLifecycleAction("Archive", response.summary));
} catch {
push(
summarizeBatchLifecycleAction("Archive", {
requested: total,
succeeded: 0,
failed: total,
}),
);
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const failed = total - succeeded;
const runWord = (n: number) => (n === 1 ? "run" : "runs");
if (failed === 0) {
push({ message: `Archived ${total} ${runWord(total)}.` });
} else if (succeeded === 0) {
push({
message: `Couldn't archive ${total} ${runWord(total)}. Try again.`,
tone: "error",
});
} else {
push({
message: `Archived ${succeeded} of ${total} runs. ${failed} failed.`,
tone: "error",
});
}
} finally {
setPending(false);
mutateRunListCaches(mutate);
@ -1408,37 +1429,34 @@ function BulkActionToolbar({
if (count === 0) return null;
const runWord = (n: number) => (n === 1 ? "run" : "runs");
async function runBulk(
label: "Archive" | "Unarchive",
eligible: RunWithStatus[],
action: (id: string) => Promise<unknown>,
action: (ids: string[]) => Promise<BatchRunLifecycleResponse>,
) {
if (pending) return;
if (eligible.length === 0) {
push({ message: `No selected ${runWord(count)} can be ${label.toLowerCase()}d.`, tone: "error" });
push({
message: `No selected ${plural(count, "run", "runs")} can be ${label.toLowerCase()}d.`,
tone: "error",
});
return;
}
setPending(true);
try {
const results = await Promise.allSettled(eligible.map((r) => action(r.id)));
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const failed = eligible.length - succeeded;
if (failed === 0) {
push({ message: `${label}d ${succeeded} ${runWord(succeeded)}.` });
const response = await action(eligible.map((r) => r.id));
push(summarizeBatchLifecycleAction(label, response.summary));
if (response.summary.failed === 0) {
onClear();
} else if (succeeded === 0) {
push({
message: `Couldn't ${label.toLowerCase()} ${eligible.length} ${runWord(eligible.length)}. Try again.`,
tone: "error",
});
} else {
push({
message: `${label}d ${succeeded} of ${eligible.length} ${runWord(eligible.length)}. ${failed} failed.`,
tone: "error",
});
}
} catch {
push(
summarizeBatchLifecycleAction(label, {
requested: eligible.length,
succeeded: 0,
failed: eligible.length,
}),
);
} finally {
setPending(false);
mutateRunListCaches(mutate);
@ -1453,20 +1471,20 @@ function BulkActionToolbar({
>
<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
label="Archive"
icon={<ArchiveBoxIcon className="size-4" aria-hidden="true" />}
disabled={pending || archivable.length === 0}
onClick={() => runBulk("Archive", archivable, archiveRun)}
disabled={pending}
onClick={() => runBulk("Archive", archivable, archiveRuns)}
/>
<BulkActionButton
label="Unarchive"
icon={<ArrowUturnLeftIcon className="size-4" aria-hidden="true" />}
disabled={pending || unarchivable.length === 0}
onClick={() => runBulk("Unarchive", unarchivable, unarchiveRun)}
disabled={pending}
onClick={() => runBulk("Unarchive", unarchivable, unarchiveRuns)}
/>
<button
type="button"

View file

@ -1143,6 +1143,112 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/archive:
post:
operationId: batchArchiveRuns
tags: [Runs]
summary: Archive Runs
description: >
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.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BatchRunLifecycleRequest"
responses:
"200":
description: Batch processed
content:
application/json:
schema:
$ref: "#/components/schemas/BatchRunLifecycleResponse"
"400":
description: Invalid batch request
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: Not authenticated
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Request-level server error
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/unarchive:
post:
operationId: batchUnarchiveRuns
tags: [Runs]
summary: Unarchive Runs
description: >
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.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BatchRunLifecycleRequest"
responses:
"200":
description: Batch processed
content:
application/json:
schema:
$ref: "#/components/schemas/BatchRunLifecycleResponse"
"400":
description: Invalid batch request
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: Not authenticated
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Request-level server error
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/resolve:
get:
operationId: resolveRun
@ -5184,6 +5290,94 @@ components:
meta:
$ref: "#/components/schemas/PaginationMeta"
BatchRunLifecycleRequest:
description: Run IDs to archive or unarchive as one bounded fail-soft batch.
type: object
additionalProperties: false
required:
- run_ids
properties:
run_ids:
type: array
description: Run IDs to process, in result order.
minItems: 1
maxItems: 250
uniqueItems: true
items:
type: string
example: 01HZX6M29F1CD5YYMHT1F5D7WQ
BatchRunLifecycleResponse:
description: Per-run results for a fail-soft batch archive or unarchive request.
type: object
additionalProperties: false
required:
- results
- summary
properties:
results:
type: array
description: Results ordered exactly like the request `run_ids`.
items:
$ref: "#/components/schemas/BatchRunLifecycleResult"
summary:
$ref: "#/components/schemas/BatchRunLifecycleSummary"
BatchRunLifecycleResult:
description: Result for one run in a batch archive or unarchive request.
type: object
additionalProperties: false
required:
- run_id
- ok
- outcome
properties:
run_id:
type: string
description: Run ID from the request item.
ok:
type: boolean
description: Whether this item succeeded.
outcome:
type: string
enum:
- archived
- already_archived
- unarchived
- not_archived
- not_found
- conflict
- error
description: Machine-readable item outcome.
run:
$ref: "#/components/schemas/Run"
description: Decorated run summary for successful items when it can be loaded.
error:
$ref: "#/components/schemas/ErrorResponseEntry"
description: Structured item error for failed items.
BatchRunLifecycleSummary:
description: Aggregate counts for a batch archive or unarchive request.
type: object
additionalProperties: false
required:
- requested
- succeeded
- failed
properties:
requested:
type: integer
minimum: 0
description: Number of requested run IDs.
succeeded:
type: integer
minimum: 0
description: Number of item results with `ok=true`.
failed:
type: integer
minimum: 0
description: Number of item results with `ok=false`.
PairId:
type: string
description: Durable run pair identifier.

View file

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

View file

@ -23,23 +23,25 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use bytes::Bytes;
pub use fabro_api::types::{
AggregateBilling, AggregateBillingTotals, ApiQuestion, AppendEventResponse, ArtifactEntry,
ArtifactListResponse, BillingByModel, BillingStageRef, CloseRunPullRequestResponse,
CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse,
CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
CreateRunPullRequestRequest, CreateSecretRequest, DeleteRunResponse, DeleteRunSandbox,
DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow,
ForkRequest, ForkResponse, LinkRunPullRequestRequest, MergeRunPullRequestRequest,
MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList,
PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, Provider,
ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection,
RenderWorkflowGraphRequest, RewindRequest, RewindResponse, RunArtifactEntry,
RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest,
RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,
SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState,
StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources,
SystemDiskResourceScope, SystemDiskResources, SystemInfoResponse, SystemMemoryResourceScope,
SystemMemoryResources, SystemRepairRunIssue, SystemRepairRunsResponse, SystemResourcesResponse,
SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse,
ArtifactListResponse, BatchRunLifecycleRequest, BatchRunLifecycleResponse,
BatchRunLifecycleResult, BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary,
BillingByModel, BillingStageRef, CloseRunPullRequestResponse, CompletionContentPart,
CompletionMessage, CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode,
CompletionUsage, CreateCompletionRequest, CreateRunPullRequestRequest, CreateSecretRequest,
DeleteRunResponse, DeleteRunSandbox, DeleteSecretRequest, DenyRunRequest, DiskUsageResponse,
DiskUsageRunRow, DiskUsageSummaryRow, ErrorResponseEntry, ForkRequest, ForkResponse,
LinkRunPullRequestRequest, MergeRunPullRequestRequest, MergeRunPullRequestResponse,
ModelReference, PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse,
PreviewUrlRequest, PreviewUrlResponse, Provider, ProviderList, PruneRunEntry, PruneRunsRequest,
PruneRunsResponse, RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RewindRequest,
RewindResponse, Run, RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage,
RunBillingTotals, RunError, RunManifest, RunStage, SandboxDetails, SandboxFileEntry,
SandboxFileListResponse, SandboxService, SandboxServiceListResponse, SshAccessRequest,
SshAccessResponse, StageHandler, StageState, StartRunRequest, SubmitAnswerRequest,
SystemCpuResourceScope, SystemCpuResources, SystemDiskResourceScope, SystemDiskResources,
SystemInfoResponse, SystemMemoryResourceScope, SystemMemoryResources, SystemRepairRunIssue,
SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts, TimelineEntryResponse,
VncPreviewResponse, WriteBlobResponse,
};
use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message};
#[cfg(test)]
@ -963,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
}
@ -1202,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

View file

@ -1,13 +1,16 @@
use std::collections::HashSet;
use std::sync::Arc;
use chrono::Utc;
use super::super::{
ApiError, AppState, 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,
ApiError, AppState, AskFabroReadiness, BatchRunLifecycleRequest, BatchRunLifecycleResponse,
BatchRunLifecycleResult, BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary,
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,
@ -22,6 +25,8 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/deny", post(deny_run))
.route("/runs/{id}/pause", post(pause_run))
.route("/runs/{id}/unpause", post(unpause_run))
.route("/runs/archive", post(batch_archive_runs))
.route("/runs/unarchive", post(batch_unarchive_runs))
.route("/runs/{id}/archive", post(archive_run))
.route("/runs/{id}/rewind", post(rewind_run))
.route("/runs/{id}/retry", post(retry_run))
@ -684,6 +689,34 @@ async fn unarchive_run(
run_archive_action(state, actor, id, ArchiveAction::Unarchive).await
}
async fn batch_archive_runs(
RequiredUser(user): RequiredUser,
State(state): State<Arc<AppState>>,
Json(request): Json<BatchRunLifecycleRequest>,
) -> Response {
batch_run_archive_action(
state,
Principal::User(user),
request,
ArchiveAction::Archive,
)
.await
}
async fn batch_unarchive_runs(
RequiredUser(user): RequiredUser,
State(state): State<Arc<AppState>>,
Json(request): Json<BatchRunLifecycleRequest>,
) -> Response {
batch_run_archive_action(
state,
Principal::User(user),
request,
ArchiveAction::Unarchive,
)
.await
}
async fn rewind_run(
subject: RequiredUser,
State(state): State<Arc<AppState>>,
@ -876,30 +909,183 @@ enum ArchiveAction {
Unarchive,
}
const MAX_BATCH_RUN_LIFECYCLE_IDS: usize = 250;
async fn batch_run_archive_action(
state: Arc<AppState>,
actor: Principal,
request: BatchRunLifecycleRequest,
action: ArchiveAction,
) -> Response {
let ids = match validate_batch_run_ids(request) {
Ok(ids) => ids,
Err(err) => return err.into_response(),
};
// 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(), &readiness, actor.clone(), id, action).await,
);
}
let requested = results.len() as u64;
let succeeded = results.iter().filter(|result| result.ok).count() as u64;
(
StatusCode::OK,
Json(BatchRunLifecycleResponse {
results,
summary: BatchRunLifecycleSummary {
requested,
succeeded,
failed: requested - succeeded,
},
}),
)
.into_response()
}
fn validate_batch_run_ids(request: BatchRunLifecycleRequest) -> Result<Vec<RunId>, ApiError> {
if request.run_ids.is_empty() {
return Err(ApiError::bad_request(
"run_ids must contain at least one run ID.",
));
}
if request.run_ids.len() > MAX_BATCH_RUN_LIFECYCLE_IDS {
return Err(ApiError::bad_request(format!(
"run_ids must contain no more than {MAX_BATCH_RUN_LIFECYCLE_IDS} run IDs.",
)));
}
let mut seen = HashSet::with_capacity(request.run_ids.len());
let mut ids = Vec::with_capacity(request.run_ids.len());
for raw in request.run_ids {
let id = raw.parse::<RunId>().map_err(|_| {
ApiError::bad_request(format!("run_ids contains invalid run ID: {raw}"))
})?;
if !seen.insert(id) {
return Err(ApiError::bad_request(
"run_ids must not contain duplicate IDs.",
));
}
ids.push(id);
}
Ok(ids)
}
async fn batch_run_archive_item(
state: &AppState,
readiness: &AskFabroReadiness,
actor: Principal,
id: RunId,
action: ArchiveAction,
) -> BatchRunLifecycleResult {
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 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,
},
Ok(None) => batch_result_failure(
id,
BatchRunLifecycleResultOutcome::Error,
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load run summary after lifecycle action.",
),
),
Err(err) => batch_result_failure(
id,
BatchRunLifecycleResultOutcome::Error,
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
),
}
}
fn batch_result_failure(
id: RunId,
outcome: BatchRunLifecycleResultOutcome,
error: ApiError,
) -> BatchRunLifecycleResult {
BatchRunLifecycleResult {
run_id: id.to_string(),
ok: false,
outcome,
run: None,
error: Some(error.into_response_entry()),
}
}
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 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()),
}
}
async fn run_archive_action(
state: Arc<AppState>,
actor: Principal,
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

@ -632,6 +632,15 @@ fn json_bearer_request(
.unwrap()
}
fn json_request(method: Method, path: &str, body: &serde_json::Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
fn canonical_origin_settings(url: &str) -> ServerSettings {
server_settings_from_toml(&format!(
r#"
@ -10512,6 +10521,337 @@ async fn archive_and_unarchive_updates_listing_visibility() {
assert_eq!(run_json_status(restored_item)["reason"], "completed");
}
fn run_submitted_event() -> workflow_event::Event {
workflow_event::Event::RunSubmitted {
definition_blob: None,
}
}
fn workflow_completed_event() -> workflow_event::Event {
workflow_event::Event::WorkflowRunCompleted {
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
}
}
async fn create_succeeded_run(state: &Arc<AppState>, run_id: RunId) {
create_durable_run_with_events(state, run_id, &[
run_submitted_event(),
workflow_completed_event(),
])
.await;
}
async fn create_running_run(state: &Arc<AppState>, run_id: RunId) {
create_durable_run_with_events(state, run_id, &[
run_submitted_event(),
workflow_event::Event::RunRunning,
])
.await;
}
fn batch_lifecycle_body(run_ids: &[RunId]) -> serde_json::Value {
json!({
"run_ids": run_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
})
}
fn assert_batch_result(result: &serde_json::Value, run_id: RunId, ok: bool, outcome: &str) {
assert_eq!(result["run_id"], run_id.to_string());
assert_eq!(result["ok"], ok);
assert_eq!(result["outcome"], outcome);
if ok {
assert!(
result["run"].is_object(),
"successful result should include run: {result}"
);
assert!(
result["error"].is_null(),
"successful result should omit error: {result}"
);
} else {
assert!(
result["error"].is_object(),
"failed result should include error: {result}"
);
assert!(
result["run"].is_null(),
"failed result should omit run: {result}"
);
}
}
#[tokio::test]
async fn batch_archive_and_unarchive_updates_listing_visibility() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let first_id = RunId::new();
let second_id = RunId::new();
create_succeeded_run(&state, first_id).await;
create_succeeded_run(&state, second_id).await;
let archive_response = app
.clone()
.oneshot(json_request(
Method::POST,
"/runs/archive",
&batch_lifecycle_body(&[first_id, second_id]),
))
.await
.unwrap();
let archive_body = response_json!(archive_response, StatusCode::OK).await;
assert_eq!(archive_body["summary"]["requested"], 2);
assert_eq!(archive_body["summary"]["succeeded"], 2);
assert_eq!(archive_body["summary"]["failed"], 0);
let archive_results = archive_body["results"].as_array().unwrap();
assert_batch_result(&archive_results[0], first_id, true, "archived");
assert!(run_json_archived(&archive_results[0]["run"]));
assert_batch_result(&archive_results[1], second_id, true, "archived");
assert!(run_json_archived(&archive_results[1]["run"]));
let hidden_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(api("/runs"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let hidden_body = response_json!(hidden_response, StatusCode::OK).await;
assert!(
hidden_body["data"].as_array().unwrap().iter().all(|item| {
let item_id = run_json_id(item);
item_id != Some(&first_id.to_string()) && item_id != Some(&second_id.to_string())
}),
"archived runs should be hidden from default listing"
);
let unarchive_response = app
.clone()
.oneshot(json_request(
Method::POST,
"/runs/unarchive",
&batch_lifecycle_body(&[first_id, second_id]),
))
.await
.unwrap();
let unarchive_body = response_json!(unarchive_response, StatusCode::OK).await;
assert_eq!(unarchive_body["summary"]["requested"], 2);
assert_eq!(unarchive_body["summary"]["succeeded"], 2);
assert_eq!(unarchive_body["summary"]["failed"], 0);
let unarchive_results = unarchive_body["results"].as_array().unwrap();
assert_batch_result(&unarchive_results[0], first_id, true, "unarchived");
assert!(!run_json_archived(&unarchive_results[0]["run"]));
assert_batch_result(&unarchive_results[1], second_id, true, "unarchived");
assert!(!run_json_archived(&unarchive_results[1]["run"]));
let restored_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api("/runs"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let restored_body = response_json!(restored_response, StatusCode::OK).await;
for run_id in [first_id, second_id] {
let restored_item = restored_body["data"]
.as_array()
.unwrap()
.iter()
.find(|item| run_json_id(item) == Some(&run_id.to_string()))
.expect("unarchived run should reappear in default listing");
assert_eq!(run_json_status(restored_item)["kind"], "succeeded");
}
}
#[tokio::test]
async fn batch_archive_reports_ordered_mixed_results_without_rollback() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let already_archived_id = RunId::new();
let terminal_id = RunId::new();
let running_id = RunId::new();
let missing_id = RunId::new();
create_succeeded_run(&state, already_archived_id).await;
create_succeeded_run(&state, terminal_id).await;
create_running_run(&state, running_id).await;
let already_archived_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(api(&format!("/runs/{already_archived_id}/archive")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(already_archived_response, StatusCode::OK).await;
let response = app
.clone()
.oneshot(json_request(
Method::POST,
"/runs/archive",
&batch_lifecycle_body(&[already_archived_id, terminal_id, running_id, missing_id]),
))
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["summary"]["requested"], 4);
assert_eq!(body["summary"]["succeeded"], 2);
assert_eq!(body["summary"]["failed"], 2);
let results = body["results"].as_array().unwrap();
assert_batch_result(&results[0], already_archived_id, true, "already_archived");
assert_batch_result(&results[1], terminal_id, true, "archived");
assert_batch_result(&results[2], running_id, false, "conflict");
assert_eq!(results[2]["error"]["status"], "409");
assert_batch_result(&results[3], missing_id, false, "not_found");
assert_eq!(results[3]["error"]["status"], "404");
let terminal_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{terminal_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let terminal_body = response_json!(terminal_response, StatusCode::OK).await;
assert!(run_json_archived(&terminal_body));
}
#[tokio::test]
async fn batch_unarchive_treats_terminal_not_archived_as_success() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let archived_id = RunId::new();
let not_archived_id = RunId::new();
create_succeeded_run(&state, archived_id).await;
create_succeeded_run(&state, not_archived_id).await;
let archive_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(api(&format!("/runs/{archived_id}/archive")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(archive_response, StatusCode::OK).await;
let response = app
.oneshot(json_request(
Method::POST,
"/runs/unarchive",
&batch_lifecycle_body(&[archived_id, not_archived_id]),
))
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["summary"]["requested"], 2);
assert_eq!(body["summary"]["succeeded"], 2);
assert_eq!(body["summary"]["failed"], 0);
let results = body["results"].as_array().unwrap();
assert_batch_result(&results[0], archived_id, true, "unarchived");
assert_batch_result(&results[1], not_archived_id, true, "not_archived");
}
#[tokio::test]
async fn batch_lifecycle_rejects_invalid_requests_before_mutating_runs() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = RunId::new();
create_succeeded_run(&state, run_id).await;
let too_many_ids = (0..251)
.map(|_| RunId::new().to_string())
.collect::<Vec<_>>();
let invalid_requests = [
json!({ "run_ids": [] }),
json!({ "run_ids": [run_id.to_string(), run_id.to_string()] }),
json!({ "run_ids": ["not-a-run-id"] }),
json!({ "run_ids": too_many_ids }),
];
for body in invalid_requests {
let response = app
.clone()
.oneshot(json_request(Method::POST, "/runs/archive", &body))
.await
.unwrap();
assert_status!(response, StatusCode::BAD_REQUEST).await;
}
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert!(!run_json_archived(&body));
}
#[tokio::test]
async fn batch_lifecycle_requires_user_authentication() {
let (_state, app) = jwt_auth_app();
let user_jwt = issue_test_user_jwt();
let run_id = create_run_with_bearer(&app, &user_jwt).await;
let worker_token = issue_test_worker_token(&run_id);
let body = batch_lifecycle_body(&[run_id]);
let unauthenticated = app
.clone()
.oneshot(json_request(Method::POST, "/runs/archive", &body))
.await
.unwrap();
assert_status!(unauthenticated, StatusCode::UNAUTHORIZED).await;
for path in ["/runs/archive", "/runs/unarchive"] {
let worker_response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
path,
&worker_token,
&body,
))
.await
.unwrap();
assert!(
matches!(
worker_response.status(),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
),
"{path} unexpectedly accepted worker token with status {}",
worker_response.status()
);
}
}
#[tokio::test]
async fn archive_unknown_run_returns_not_found() {
let app = test_app_with();

View file

@ -45,6 +45,10 @@ models/auth-session-user.ts
models/auth-session.ts
models/auth-sessions-response.ts
models/automation-ref.ts
models/batch-run-lifecycle-request.ts
models/batch-run-lifecycle-response.ts
models/batch-run-lifecycle-result.ts
models/batch-run-lifecycle-summary.ts
models/billed-token-counts.ts
models/billing-by-model.ts
models/billing-model-ref.ts

View file

@ -22,6 +22,10 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { BatchRunLifecycleRequest } from '../models';
// @ts-ignore
import type { BatchRunLifecycleResponse } from '../models';
// @ts-ignore
import type { BoardColumn } from '../models';
// @ts-ignore
import type { CloseRunPullRequestResponse } from '../models';
@ -156,6 +160,88 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* 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.
* @summary Archive Runs
* @param {BatchRunLifecycleRequest} batchRunLifecycleRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
batchArchiveRuns: async (batchRunLifecycleRequest: BatchRunLifecycleRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'batchRunLifecycleRequest' is not null or undefined
assertParamExists('batchArchiveRuns', 'batchRunLifecycleRequest', batchRunLifecycleRequest)
const localVarPath = `/api/v1/runs/archive`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(batchRunLifecycleRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* 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.
* @summary Unarchive Runs
* @param {BatchRunLifecycleRequest} batchRunLifecycleRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
batchUnarchiveRuns: async (batchRunLifecycleRequest: BatchRunLifecycleRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'batchRunLifecycleRequest' is not null or undefined
assertParamExists('batchUnarchiveRuns', 'batchRunLifecycleRequest', batchRunLifecycleRequest)
const localVarPath = `/api/v1/runs/unarchive`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(batchRunLifecycleRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.
* @summary Cancel Run
@ -1436,6 +1522,32 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.archiveRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* 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.
* @summary Archive Runs
* @param {BatchRunLifecycleRequest} batchRunLifecycleRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async batchArchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<BatchRunLifecycleResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.batchArchiveRuns(batchRunLifecycleRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.batchArchiveRuns']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* 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.
* @summary Unarchive Runs
* @param {BatchRunLifecycleRequest} batchRunLifecycleRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async batchUnarchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<BatchRunLifecycleResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.batchUnarchiveRuns(batchRunLifecycleRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.batchUnarchiveRuns']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.
* @summary Cancel Run
@ -1859,6 +1971,26 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
archiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.archiveRun(id, options).then((request) => request(axios, basePath));
},
/**
* 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.
* @summary Archive Runs
* @param {BatchRunLifecycleRequest} batchRunLifecycleRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
batchArchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig): AxiosPromise<BatchRunLifecycleResponse> {
return localVarFp.batchArchiveRuns(batchRunLifecycleRequest, options).then((request) => request(axios, basePath));
},
/**
* 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.
* @summary Unarchive Runs
* @param {BatchRunLifecycleRequest} batchRunLifecycleRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
batchUnarchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig): AxiosPromise<BatchRunLifecycleResponse> {
return localVarFp.batchUnarchiveRuns(batchRunLifecycleRequest, options).then((request) => request(axios, basePath));
},
/**
* Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.
* @summary Cancel Run
@ -2195,6 +2327,28 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).archiveRun(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* 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.
* @summary Archive Runs
* @param {BatchRunLifecycleRequest} batchRunLifecycleRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public batchArchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).batchArchiveRuns(batchRunLifecycleRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* 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.
* @summary Unarchive Runs
* @param {BatchRunLifecycleRequest} batchRunLifecycleRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public batchUnarchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).batchUnarchiveRuns(batchRunLifecycleRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.
* @summary Cancel Run

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Run IDs to archive or unarchive as one bounded fail-soft batch.
*/
export interface BatchRunLifecycleRequest {
/**
* Run IDs to process, in result order.
*/
'run_ids': Set<string>;
}

View file

@ -0,0 +1,32 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { BatchRunLifecycleResult } from './batch-run-lifecycle-result';
// May contain unused imports in some cases
// @ts-ignore
import type { BatchRunLifecycleSummary } from './batch-run-lifecycle-summary';
/**
* Per-run results for a fail-soft batch archive or unarchive request.
*/
export interface BatchRunLifecycleResponse {
/**
* Results ordered exactly like the request `run_ids`.
*/
'results': Array<BatchRunLifecycleResult>;
'summary': BatchRunLifecycleSummary;
}

View file

@ -0,0 +1,59 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ErrorResponseEntry } from './error-response-entry';
// May contain unused imports in some cases
// @ts-ignore
import type { Run } from './run';
/**
* Result for one run in a batch archive or unarchive request.
*/
export interface BatchRunLifecycleResult {
/**
* Run ID from the request item.
*/
'run_id': string;
/**
* Whether this item succeeded.
*/
'ok': boolean;
/**
* Machine-readable item outcome.
*/
'outcome': BatchRunLifecycleResultOutcomeEnum;
/**
* Decorated run summary for successful items when it can be loaded.
*/
'run'?: Run;
/**
* Structured item error for failed items.
*/
'error'?: ErrorResponseEntry;
}
export const BatchRunLifecycleResultOutcomeEnum = {
ARCHIVED: 'archived',
ALREADY_ARCHIVED: 'already_archived',
UNARCHIVED: 'unarchived',
NOT_ARCHIVED: 'not_archived',
NOT_FOUND: 'not_found',
CONFLICT: 'conflict',
ERROR: 'error'
} as const;
export type BatchRunLifecycleResultOutcomeEnum = typeof BatchRunLifecycleResultOutcomeEnum[keyof typeof BatchRunLifecycleResultOutcomeEnum];

View file

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Aggregate counts for a batch archive or unarchive request.
*/
export interface BatchRunLifecycleSummary {
/**
* Number of requested run IDs.
*/
'requested': number;
/**
* Number of item results with `ok=true`.
*/
'succeeded': number;
/**
* Number of item results with `ok=false`.
*/
'failed': number;
}

View file

@ -22,6 +22,10 @@ export * from './auth-session';
export * from './auth-session-user';
export * from './auth-sessions-response';
export * from './automation-ref';
export * from './batch-run-lifecycle-request';
export * from './batch-run-lifecycle-response';
export * from './batch-run-lifecycle-result';
export * from './batch-run-lifecycle-summary';
export * from './billed-token-counts';
export * from './billing-by-model';
export * from './billing-model-ref';