Add POST /api/v1/runs/delete batch delete endpoint (#382)

## Summary

Adds a fail-soft batch delete endpoint (`POST /api/v1/runs/delete`) that
mirrors the existing archive/unarchive batch pattern, processes 1–250
run IDs independently, and returns per-item outcomes with an aggregate
summary. Existing `DELETE /api/v1/runs/{id}` behavior is unchanged.

### Plan Summary

- **OpenAPI-first**: new
`BatchDeleteRunsRequest/Response/Result/Summary` schemas added to the
spec; Rust (`fabro-api`) and TypeScript (`fabro-api-client`) clients
regenerated.
- **Delete internals refactored**: `DeleteRunOutcome` gains `Deleted`
and `AlreadyAbsent` variants (replacing the old `NoContent`);
`delete_run_internal` and its helpers now return `Result<_, ApiError>`
instead of `Result<_, Response>`, enabling both the single-delete
handler and the new batch handler to reuse the same logic.
- **Batch handler**: `batch_delete_runs` in `lifecycle.rs` validates the
request (reusing the generalized `validate_batch_run_ids`), loops over
IDs, and assembles `BatchDeleteRunsResult` items mapping
`ApiError::status()` to outcome strings (`conflict`, `error`).
- **Web helper**: `deleteRuns` added to `run-actions.ts` alongside
`archiveRuns`/`unarchiveRuns`, with the same `as unknown as` cast needed
for the openapi-generator `Set<string>` quirk.
- **Tests**: six new server integration tests cover ordered results,
mixed outcomes without rollback, force deletion, sandbox preservation
handoff, pre-mutation validation rejection, and auth gating.

### Key design decisions

**`POST /runs/delete` not `DELETE /runs`** — JSON request bodies on
`DELETE` are poorly supported by proxies and HTTP clients; the existing
batch lifecycle endpoints already use JSON-body `POST` actions.

**`already_absent` counts as success** — consistent with single-delete
semantics where `204` means "deleted or already absent"; callers doing
cleanup don't need to special-case missing IDs.

**`force` is batch-wide** — callers needing mixed force behavior issue
separate requests; this keeps the request schema simple.

**`SandboxDeleteOutcome` internal enum** — introduced alongside
`DeleteRunOutcome` to cleanly separate the sandbox-layer result
(absent/cleaned/preserved) from the top-level outcome that callers see,
avoiding a leaky intermediate type.


### Fabro Details

<details>
<summary>Ran 8 stages in 41m 52s for $13.37</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 3s | – | 0 |
| preflight_lint | 2m 17s | – | 0 |
| implement | 15m 12s | $8.10 | 0 |
| simplify_opus | 8m 54s | $3.30 | 0 |
| simplify_gpt | 3m 54s | $1.97 | 0 |
| verify | 9m 0s | – | 0 |
| **Total** | **41m 52s** | **$13.37** | **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-24 09:00:16 -04:00 committed by GitHub
parent fd5d932346
commit 04e169ef79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 987 additions and 135 deletions

View file

@ -1,6 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { AxiosAdapter } from "axios";
import type { BatchRunLifecycleResponse, Run, RunStatus } from "@qltysh/fabro-api-client";
import type {
BatchDeleteRunsResponse,
BatchRunLifecycleResponse,
Run,
RunStatus,
} from "@qltysh/fabro-api-client";
import {
archiveRun,
@ -11,6 +16,7 @@ import {
canRetry,
canUnarchive,
cancelRun,
deleteRuns,
isTerminalCancelledRun,
mapError,
retryRun,
@ -118,6 +124,20 @@ function batchResponse(
};
}
function batchDeleteResponse(
results: BatchDeleteRunsResponse["results"],
): BatchDeleteRunsResponse {
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;
}
@ -229,6 +249,72 @@ describe("run lifecycle actions", () => {
expect(result.results[1]?.error?.status).toBe("404");
});
test("deleteRuns sends one batch request and parses results", async () => {
const stub = stubGeneratedAxiosOnce({
status: 200,
body: batchDeleteResponse([
{
run_id: "run-1",
ok: true,
outcome: "deleted",
},
{
run_id: "run-missing",
ok: true,
outcome: "already_absent",
},
]),
});
const result = await deleteRuns(["run-1", "run-missing"]);
expect(stub.requests).toHaveLength(1);
expect(stub.requests[0]?.method?.toUpperCase()).toBe("POST");
expect(stub.requests[0]?.url).toBe("/api/v1/runs/delete");
expect(requestJsonBody(stub.requests[0]!)).toEqual({
run_ids: ["run-1", "run-missing"],
force: false,
});
expect(result.summary).toEqual({ requested: 2, succeeded: 2, failed: 0 });
expect(result.results.map((entry) => entry.outcome)).toEqual(["deleted", "already_absent"]);
});
test("deleteRuns sends force when requested", async () => {
const stub = stubGeneratedAxiosOnce({
status: 200,
body: batchDeleteResponse([
{
run_id: "run-1",
ok: true,
outcome: "deleted",
},
]),
});
await deleteRuns(["run-1"], true);
expect(stub.requests).toHaveLength(1);
expect(requestJsonBody(stub.requests[0]!)).toEqual({
run_ids: ["run-1"],
force: true,
});
});
test("deleteRuns preserves 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(deleteRuns([]));
expect(error).toEqual({
status: 400,
errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }],
});
});
test("batch lifecycle helpers preserve request-level error envelopes", async () => {
stubGeneratedAxiosOnce({
status: 400,

View file

@ -1,4 +1,6 @@
import type {
BatchDeleteRunsRequest,
BatchDeleteRunsResponse,
BatchRunLifecycleRequest,
BatchRunLifecycleResponse,
ErrorResponseEntry,
@ -77,6 +79,22 @@ export async function unarchiveRuns(
return batchRunLifecycleAction(runIds, "unarchive", request);
}
export async function deleteRuns(
runIds: string[],
force = false,
request?: Request,
): Promise<BatchDeleteRunsResponse> {
try {
// See `batchRunLifecycleAction` for the `as unknown as` rationale:
// openapi-generator types `uniqueItems` arrays as `Set<T>` while the wire
// contract is a JSON array.
const body = { run_ids: runIds, force } as unknown as BatchDeleteRunsRequest;
return await apiData(() => runsApi.batchDeleteRuns(body, requestSignalOptions(request)));
} catch (error) {
throw lifecycleActionErrorFromError(error);
}
}
export async function retryRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "retry", request);
}

View file

@ -1196,6 +1196,57 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/delete:
post:
operationId: batchDeleteRuns
tags: [Runs]
summary: Delete Runs
description: >
Deletes up to 250 runs in one fail-soft, non-transactional request.
Each run is processed independently. 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/BatchDeleteRunsRequest"
responses:
"200":
description: Batch processed
content:
application/json:
schema:
$ref: "#/components/schemas/BatchDeleteRunsResponse"
"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
@ -5378,6 +5429,96 @@ components:
minimum: 0
description: Number of item results with `ok=false`.
BatchDeleteRunsRequest:
description: Run IDs to delete 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
force:
type: boolean
description: Whether to force deletion of active runs. Defaults to `false`.
default: false
BatchDeleteRunsResponse:
description: Per-run results for a fail-soft batch delete 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/BatchDeleteRunsResult"
summary:
$ref: "#/components/schemas/BatchDeleteRunsSummary"
BatchDeleteRunsResult:
description: Result for one run in a batch delete 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:
- deleted
- already_absent
- sandbox_preserved
- conflict
- error
description: Machine-readable item outcome.
sandbox:
$ref: "#/components/schemas/DeleteRunSandbox"
description: Sandbox handoff details when `outcome` is `sandbox_preserved`.
error:
$ref: "#/components/schemas/ErrorResponseEntry"
description: Structured item error for failed items.
BatchDeleteRunsSummary:
description: Aggregate counts for a batch delete 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

@ -23,25 +23,26 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use bytes::Bytes;
pub use fabro_api::types::{
AggregateBilling, AggregateBillingTotals, ApiQuestion, AppendEventResponse, ArtifactEntry,
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,
ArtifactListResponse, BatchDeleteRunsRequest, BatchDeleteRunsResponse, BatchDeleteRunsResult,
BatchDeleteRunsResultOutcome, BatchDeleteRunsSummary, 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)]
@ -2175,17 +2176,27 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
const MAX_PAGE_OFFSET: u32 = 1_000_000;
enum DeleteRunOutcome {
NoContent,
Deleted,
AlreadyAbsent,
Preserved(DeleteRunResponse),
}
enum SandboxDeleteOutcome {
/// The durable run store did not exist; nothing to delete.
Absent,
/// The sandbox resource was cleaned up (or there was none to clean).
Cleaned,
/// Sandbox is being handed off to the operator instead of deleted.
Preserved(DeleteRunResponse),
}
async fn delete_run_internal(
state: &Arc<AppState>,
state: &AppState,
id: RunId,
force: bool,
) -> Result<DeleteRunOutcome, Response> {
) -> Result<DeleteRunOutcome, ApiError> {
if !force {
reject_active_delete_without_force(state.as_ref(), &id).await?;
reject_active_delete_without_force(state, &id).await?;
}
let mut managed_run = if let Ok(mut runs) = state.runs.lock() {
@ -2193,8 +2204,9 @@ async fn delete_run_internal(
} else {
None
};
let had_managed_run = managed_run.is_some();
let durable_status = if managed_run.is_some() {
load_durable_run_status(state.as_ref(), &id).await
load_durable_run_status(state, &id).await
} else {
None
};
@ -2232,29 +2244,32 @@ async fn delete_run_internal(
if let Some(mut managed_run) = managed_run {
if let Some(run_dir) = managed_run.run_dir.take() {
remove_run_dir(&run_dir).map_err(|err| {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
})?;
remove_run_dir(&run_dir)
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
}
} else {
let storage = Storage::new(state.server_storage_dir());
let run_dir = storage.run_scratch(&id).root().to_path_buf();
remove_run_dir(&run_dir).map_err(|err| {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
})?;
remove_run_dir(&run_dir)
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
}
state.store.delete_run(&id).await.map_err(|err| {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
})?;
state
.store
.delete_run(&id)
.await
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
state
.artifact_store
.delete_for_run(&id)
.await
.map_err(|err| {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
})?;
Ok(delete_outcome)
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
match delete_outcome {
SandboxDeleteOutcome::Preserved(response) => Ok(DeleteRunOutcome::Preserved(response)),
SandboxDeleteOutcome::Cleaned => Ok(DeleteRunOutcome::Deleted),
SandboxDeleteOutcome::Absent if had_managed_run => Ok(DeleteRunOutcome::Deleted),
SandboxDeleteOutcome::Absent => Ok(DeleteRunOutcome::AlreadyAbsent),
}
}
async fn load_durable_run_status(state: &AppState, id: &RunId) -> Option<RunStatus> {
@ -2264,12 +2279,12 @@ async fn load_durable_run_status(state: &AppState, id: &RunId) -> Option<RunStat
}
async fn delete_run_sandbox_resource(
state: &Arc<AppState>,
state: &AppState,
id: RunId,
force: bool,
) -> Result<DeleteRunOutcome, Response> {
) -> Result<SandboxDeleteOutcome, ApiError> {
let Ok(run_store) = state.store.open_run(&id).await else {
return Ok(DeleteRunOutcome::NoContent);
return Ok(SandboxDeleteOutcome::Absent);
};
let projection = match run_store.state().await {
Ok(projection) => projection,
@ -2279,12 +2294,13 @@ async fn delete_run_sandbox_resource(
error = %render_with_causes(&err.to_string(), &collect_causes(&err)),
"Skipping sandbox provider delete because run projection cannot be loaded"
);
return Ok(DeleteRunOutcome::NoContent);
return Ok(SandboxDeleteOutcome::Cleaned);
}
Err(err) => {
return Err(
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
);
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
));
}
};
let delete_started = matches!(projection.status, RunStatus::Removing);
@ -2292,9 +2308,7 @@ async fn delete_run_sandbox_resource(
if !delete_started && can_mark_removing {
workflow_event::append_event(&run_store, &id, &workflow_event::Event::RunRemoving)
.await
.map_err(|err| {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
})?;
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
}
let preserve = projection
@ -2305,19 +2319,18 @@ async fn delete_run_sandbox_resource(
.lifecycle
.preserve;
let Some(record) = projection.sandbox else {
return Ok(DeleteRunOutcome::NoContent);
return Ok(SandboxDeleteOutcome::Cleaned);
};
let Some(runtime) = record.runtime.as_ref() else {
return Ok(SandboxDeleteOutcome::Cleaned);
};
if preserve {
return Ok(DeleteRunOutcome::Preserved(DeleteRunResponse {
return Ok(SandboxDeleteOutcome::Preserved(DeleteRunResponse {
deleted: true,
sandbox_preserved: true,
sandbox: DeleteRunSandbox {
provider: record.provider,
id: record
.runtime
.as_ref()
.map(|runtime| runtime.id.clone())
.unwrap_or_default(),
id: runtime.id.clone(),
},
}));
}
@ -2331,11 +2344,11 @@ async fn delete_run_sandbox_resource(
error = %render_with_causes(&err.to_string(), &collect_causes(err.as_ref())),
"Skipping sandbox provider delete during run deletion"
);
return Ok(DeleteRunOutcome::NoContent);
return Ok(SandboxDeleteOutcome::Cleaned);
}
Err(err) => {
let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref()));
return Err(ApiError::new(StatusCode::CONFLICT, detail).into_response());
return Err(ApiError::new(StatusCode::CONFLICT, detail));
}
};
if let Err(err) = sandbox.delete().await {
@ -2345,18 +2358,21 @@ async fn delete_run_sandbox_resource(
error = %err.display_with_causes(),
"Skipping failed sandbox provider delete during run deletion"
);
return Ok(DeleteRunOutcome::NoContent);
return Ok(SandboxDeleteOutcome::Cleaned);
}
return Err(ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response());
return Err(ApiError::new(
StatusCode::CONFLICT,
err.display_with_causes(),
));
}
Ok(DeleteRunOutcome::NoContent)
Ok(SandboxDeleteOutcome::Cleaned)
}
async fn reject_active_delete_without_force(
state: &AppState,
run_id: &RunId,
) -> Result<(), Response> {
) -> Result<(), ApiError> {
let managed_status = state
.runs
.lock()
@ -2367,8 +2383,7 @@ async fn reject_active_delete_without_force(
return Err(ApiError::new(
StatusCode::CONFLICT,
active_run_delete_message(*run_id, status),
)
.into_response());
));
}
return Ok(());
}
@ -2378,13 +2393,13 @@ async fn reject_active_delete_without_force(
Err(ApiError::new(
StatusCode::CONFLICT,
active_run_delete_message(*run_id, summary.lifecycle.status),
)
.into_response())
))
}
Ok(_) => Ok(()),
Err(err) => {
Err(ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())
}
Err(err) => Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
)),
}
}

View file

@ -4,16 +4,18 @@ use std::sync::Arc;
use chrono::Utc;
use super::super::{
ApiError, AppState, AskFabroReadiness, BatchRunLifecycleRequest, BatchRunLifecycleResponse,
BatchRunLifecycleResult, BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary,
ApiError, AppState, AskFabroReadiness, BatchDeleteRunsRequest, BatchDeleteRunsResponse,
BatchDeleteRunsResult, BatchDeleteRunsResultOutcome, BatchDeleteRunsSummary,
BatchRunLifecycleRequest, BatchRunLifecycleResponse, BatchRunLifecycleResult,
BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary, DeleteRunOutcome, DeleteRunSandbox,
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,
clear_live_run_state, delete_run_internal, 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,
};
use super::runs::run_provenance;
@ -26,6 +28,7 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/pause", post(pause_run))
.route("/runs/{id}/unpause", post(unpause_run))
.route("/runs/archive", post(batch_archive_runs))
.route("/runs/delete", post(batch_delete_runs))
.route("/runs/unarchive", post(batch_unarchive_runs))
.route("/runs/{id}/archive", post(archive_run))
.route("/runs/{id}/rewind", post(rewind_run))
@ -717,6 +720,38 @@ async fn batch_unarchive_runs(
.await
}
async fn batch_delete_runs(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Json(request): Json<BatchDeleteRunsRequest>,
) -> Response {
let force = request.force;
let ids = match validate_batch_run_ids(request.run_ids) {
Ok(ids) => ids,
Err(err) => return err.into_response(),
};
let mut results = Vec::with_capacity(ids.len());
for id in ids {
results.push(batch_delete_run_item(state.as_ref(), id, force).await);
}
let requested = results.len() as u64;
let succeeded = results.iter().filter(|result| result.ok).count() as u64;
(
StatusCode::OK,
Json(BatchDeleteRunsResponse {
results,
summary: BatchDeleteRunsSummary {
requested,
succeeded,
failed: requested - succeeded,
},
}),
)
.into_response()
}
async fn rewind_run(
subject: RequiredUser,
State(state): State<Arc<AppState>>,
@ -909,7 +944,7 @@ enum ArchiveAction {
Unarchive,
}
const MAX_BATCH_RUN_LIFECYCLE_IDS: usize = 250;
const MAX_BATCH_RUN_IDS: usize = 250;
async fn batch_run_archive_action(
state: Arc<AppState>,
@ -917,7 +952,7 @@ async fn batch_run_archive_action(
request: BatchRunLifecycleRequest,
action: ArchiveAction,
) -> Response {
let ids = match validate_batch_run_ids(request) {
let ids = match validate_batch_run_ids(request.run_ids) {
Ok(ids) => ids,
Err(err) => return err.into_response(),
};
@ -949,21 +984,21 @@ async fn batch_run_archive_action(
.into_response()
}
fn validate_batch_run_ids(request: BatchRunLifecycleRequest) -> Result<Vec<RunId>, ApiError> {
if request.run_ids.is_empty() {
fn validate_batch_run_ids(run_ids: Vec<String>) -> Result<Vec<RunId>, ApiError> {
if 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 {
if run_ids.len() > MAX_BATCH_RUN_IDS {
return Err(ApiError::bad_request(format!(
"run_ids must contain no more than {MAX_BATCH_RUN_LIFECYCLE_IDS} run IDs.",
"run_ids must contain no more than {MAX_BATCH_RUN_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 mut seen = HashSet::with_capacity(run_ids.len());
let mut ids = Vec::with_capacity(run_ids.len());
for raw in run_ids {
let id = raw.parse::<RunId>().map_err(|_| {
ApiError::bad_request(format!("run_ids contains invalid run ID: {raw}"))
})?;
@ -977,6 +1012,49 @@ fn validate_batch_run_ids(request: BatchRunLifecycleRequest) -> Result<Vec<RunId
Ok(ids)
}
async fn batch_delete_run_item(state: &AppState, id: RunId, force: bool) -> BatchDeleteRunsResult {
match delete_run_internal(state, id, force).await {
Ok(DeleteRunOutcome::Deleted) => {
batch_delete_success(id, BatchDeleteRunsResultOutcome::Deleted, None)
}
Ok(DeleteRunOutcome::AlreadyAbsent) => {
batch_delete_success(id, BatchDeleteRunsResultOutcome::AlreadyAbsent, None)
}
Ok(DeleteRunOutcome::Preserved(response)) => batch_delete_success(
id,
BatchDeleteRunsResultOutcome::SandboxPreserved,
Some(response.sandbox),
),
Err(error) => {
let outcome = match error.status() {
StatusCode::CONFLICT => BatchDeleteRunsResultOutcome::Conflict,
_ => BatchDeleteRunsResultOutcome::Error,
};
BatchDeleteRunsResult {
run_id: id.to_string(),
ok: false,
outcome,
sandbox: None,
error: Some(error.into_response_entry()),
}
}
}
}
fn batch_delete_success(
id: RunId,
outcome: BatchDeleteRunsResultOutcome,
sandbox: Option<DeleteRunSandbox>,
) -> BatchDeleteRunsResult {
BatchDeleteRunsResult {
run_id: id.to_string(),
ok: true,
outcome,
sandbox,
error: None,
}
}
async fn batch_run_archive_item(
state: &AppState,
readiness: &AskFabroReadiness,

View file

@ -32,10 +32,11 @@ use tokio::fs;
use tracing::info;
use super::super::{
AppState, ListResponse, PaginationParams, RunExecutionMode, answer_from_request,
api_question_from_pending_interview, default_page_limit, delete_run_internal,
load_pending_interview, managed_run, paginate_items, parse_run_id_path, parse_stage_id_path,
reject_if_archived, resolve_interp_string, submit_pending_interview_answer, workflow_event,
AppState, DeleteRunOutcome, ListResponse, PaginationParams, RunExecutionMode,
answer_from_request, api_question_from_pending_interview, default_page_limit,
delete_run_internal, load_pending_interview, managed_run, paginate_items, parse_run_id_path,
parse_stage_id_path, reject_if_archived, resolve_interp_string,
submit_pending_interview_answer, workflow_event,
};
use crate::error::ApiError;
use crate::principal_middleware::{
@ -508,12 +509,14 @@ async fn delete_run(
Err(response) => return response,
};
match delete_run_internal(&state, id, query.force).await {
Ok(super::super::DeleteRunOutcome::NoContent) => StatusCode::NO_CONTENT.into_response(),
Ok(super::super::DeleteRunOutcome::Preserved(response)) => {
match delete_run_internal(state.as_ref(), id, query.force).await {
Ok(DeleteRunOutcome::Deleted | DeleteRunOutcome::AlreadyAbsent) => {
StatusCode::NO_CONTENT.into_response()
}
Ok(DeleteRunOutcome::Preserved(response)) => {
(StatusCode::OK, Json(response)).into_response()
}
Err(response) => response,
Err(error) => error.into_response(),
}
}

View file

@ -207,8 +207,8 @@ async fn prune_runs(
}
for run_id in &prune_plan.run_ids {
if let Err(response) = delete_run_internal(&state, *run_id, true).await {
return response;
if let Err(error) = delete_run_internal(state.as_ref(), *run_id, true).await {
return error.into_response();
}
}

View file

@ -3725,7 +3725,9 @@ async fn delete_terminal_managed_run_does_not_send_cancel_signal() {
.expect("runs lock poisoned")
.insert(run_id, run);
delete_run_internal(&state, run_id, true).await.unwrap();
delete_run_internal(state.as_ref(), run_id, true)
.await
.unwrap();
assert!(!cancel_token.is_cancelled());
}
@ -10557,12 +10559,64 @@ async fn create_running_run(state: &Arc<AppState>, run_id: RunId) {
.await;
}
async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId) {
let mut settings = fabro_types::WorkflowSettings::default();
settings.run.environment.lifecycle.preserve = true;
let graph = Graph::new("test");
create_durable_run_with_events(state, run_id, &[
workflow_event::Event::RunCreated {
run_id,
title: None,
settings: serde_json::to_value(settings).unwrap(),
graph: serde_json::to_value(graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: std::collections::BTreeMap::default(),
run_dir: "/tmp/fabro-run".to_string(),
source_directory: Some("/tmp/fabro-run".to_string()),
workflow_slug: Some("test".to_string()),
db_prefix: None,
provenance: None,
manifest_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
},
workflow_event::Event::RunSubmitted {
definition_blob: None,
},
workflow_event::Event::SandboxInitialized {
provider: SandboxProvider::Local,
id: "sandbox-preserve-1".to_string(),
working_directory: "/tmp/fabro-preserved-sandbox".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
workspace_root: None,
repos_root: None,
primary_repo_path: None,
primary_repo_link: None,
},
])
.await;
}
fn batch_lifecycle_body(run_ids: &[RunId]) -> serde_json::Value {
json!({
"run_ids": run_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
})
}
fn batch_delete_body(run_ids: &[RunId], force: bool) -> serde_json::Value {
json!({
"run_ids": run_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
"force": force,
})
}
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);
@ -10588,6 +10642,23 @@ fn assert_batch_result(result: &serde_json::Value, run_id: RunId, ok: bool, outc
}
}
fn assert_batch_delete_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["error"].is_null(),
"successful delete result should omit error: {result}"
);
} else {
assert!(
result["error"].is_object(),
"failed delete result should include error: {result}"
);
}
}
#[tokio::test]
async fn batch_archive_and_unarchive_updates_listing_visibility() {
let state = test_app_state();
@ -10852,6 +10923,249 @@ async fn batch_lifecycle_requires_user_authentication() {
}
}
#[tokio::test]
async fn batch_delete_removes_runs_and_reports_ordered_results() {
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 response = app
.clone()
.oneshot(json_request(
Method::POST,
"/runs/delete",
&batch_delete_body(&[first_id, second_id], false),
))
.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_delete_result(&results[0], first_id, true, "deleted");
assert_batch_delete_result(&results[1], second_id, true, "deleted");
for run_id in [first_id, second_id] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::NOT_FOUND).await;
}
}
#[tokio::test]
async fn batch_delete_reports_mixed_results_without_rollback() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let terminal_id = RunId::new();
let running_id = RunId::new();
let missing_id = RunId::new();
create_succeeded_run(&state, terminal_id).await;
create_running_run(&state, running_id).await;
let response = app
.clone()
.oneshot(json_request(
Method::POST,
"/runs/delete",
&batch_delete_body(&[terminal_id, running_id, missing_id], false),
))
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["summary"]["requested"], 3);
assert_eq!(body["summary"]["succeeded"], 2);
assert_eq!(body["summary"]["failed"], 1);
let results = body["results"].as_array().unwrap();
assert_batch_delete_result(&results[0], terminal_id, true, "deleted");
assert_batch_delete_result(&results[1], running_id, false, "conflict");
assert_eq!(results[1]["error"]["status"], "409");
assert_batch_delete_result(&results[2], missing_id, true, "already_absent");
let deleted_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{terminal_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(deleted_response, StatusCode::NOT_FOUND).await;
let running_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{running_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(running_response, StatusCode::OK).await;
}
#[tokio::test]
async fn batch_delete_force_removes_active_runs() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = RunId::new();
create_running_run(&state, run_id).await;
let response = app
.clone()
.oneshot(json_request(
Method::POST,
"/runs/delete",
&batch_delete_body(&[run_id], true),
))
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["summary"]["requested"], 1);
assert_eq!(body["summary"]["succeeded"], 1);
assert_eq!(body["summary"]["failed"], 0);
let results = body["results"].as_array().unwrap();
assert_batch_delete_result(&results[0], run_id, true, "deleted");
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::NOT_FOUND).await;
}
#[tokio::test]
async fn batch_delete_with_preserved_sandbox_returns_handoff() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = RunId::new();
create_preserved_local_sandbox_run(&state, run_id).await;
let response = app
.clone()
.oneshot(json_request(
Method::POST,
"/runs/delete",
&batch_delete_body(&[run_id], true),
))
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["summary"]["requested"], 1);
assert_eq!(body["summary"]["succeeded"], 1);
assert_eq!(body["summary"]["failed"], 0);
let results = body["results"].as_array().unwrap();
assert_batch_delete_result(&results[0], run_id, true, "sandbox_preserved");
assert_eq!(results[0]["sandbox"]["provider"], "local");
assert_eq!(results[0]["sandbox"]["id"], "sandbox-preserve-1");
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::NOT_FOUND).await;
}
#[tokio::test]
async fn batch_delete_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": [], "force": false }),
json!({ "run_ids": [run_id.to_string(), run_id.to_string()], "force": false }),
json!({ "run_ids": ["not-a-run-id"], "force": false }),
json!({ "run_ids": too_many_ids, "force": false }),
];
for body in invalid_requests {
let response = app
.clone()
.oneshot(json_request(Method::POST, "/runs/delete", &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();
assert_status!(response, StatusCode::OK).await;
}
#[tokio::test]
async fn batch_delete_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_delete_body(&[run_id], false);
let unauthenticated = app
.clone()
.oneshot(json_request(Method::POST, "/runs/delete", &body))
.await
.unwrap();
assert_status!(unauthenticated, StatusCode::UNAUTHORIZED).await;
let worker_response = app
.oneshot(json_bearer_request(
Method::POST,
"/runs/delete",
&worker_token,
&body,
))
.await
.unwrap();
assert!(
matches!(
worker_response.status(),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
),
"/runs/delete unexpectedly accepted worker token with status {}",
worker_response.status()
);
}
#[tokio::test]
async fn archive_unknown_run_returns_not_found() {
let app = test_app_with();
@ -10968,48 +11282,7 @@ async fn delete_run_with_preserved_sandbox_returns_handoff() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = RunId::new();
let mut settings = fabro_types::WorkflowSettings::default();
settings.run.environment.lifecycle.preserve = true;
let graph = Graph::new("test");
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::RunCreated {
run_id,
title: None,
settings: serde_json::to_value(settings).unwrap(),
graph: serde_json::to_value(graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: std::collections::BTreeMap::default(),
run_dir: "/tmp/fabro-run".to_string(),
source_directory: Some("/tmp/fabro-run".to_string()),
workflow_slug: Some("test".to_string()),
db_prefix: None,
provenance: None,
manifest_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
},
workflow_event::Event::RunSubmitted {
definition_blob: None,
},
workflow_event::Event::SandboxInitialized {
provider: SandboxProvider::Local,
id: "sandbox-preserve-1".to_string(),
working_directory: "/tmp/fabro-preserved-sandbox".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
workspace_root: None,
repos_root: None,
primary_repo_path: None,
primary_repo_link: None,
},
])
.await;
create_preserved_local_sandbox_run(&state, run_id).await;
let req = Request::builder()
.method("DELETE")

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-delete-runs-request.ts
models/batch-delete-runs-response.ts
models/batch-delete-runs-result.ts
models/batch-delete-runs-summary.ts
models/batch-run-lifecycle-request.ts
models/batch-run-lifecycle-response.ts
models/batch-run-lifecycle-result.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 { BatchDeleteRunsRequest } from '../models';
// @ts-ignore
import type { BatchDeleteRunsResponse } from '../models';
// @ts-ignore
import type { BatchRunLifecycleRequest } from '../models';
// @ts-ignore
import type { BatchRunLifecycleResponse } from '../models';
@ -201,6 +205,47 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* Deletes up to 250 runs in one fail-soft, non-transactional request. Each run is processed independently. 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 Delete Runs
* @param {BatchDeleteRunsRequest} batchDeleteRunsRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
batchDeleteRuns: async (batchDeleteRunsRequest: BatchDeleteRunsRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'batchDeleteRunsRequest' is not null or undefined
assertParamExists('batchDeleteRuns', 'batchDeleteRunsRequest', batchDeleteRunsRequest)
const localVarPath = `/api/v1/runs/delete`;
// 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(batchDeleteRunsRequest, 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
@ -1535,6 +1580,19 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.batchArchiveRuns']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Deletes up to 250 runs in one fail-soft, non-transactional request. Each run is processed independently. 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 Delete Runs
* @param {BatchDeleteRunsRequest} batchDeleteRunsRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async batchDeleteRuns(batchDeleteRunsRequest: BatchDeleteRunsRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<BatchDeleteRunsResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.batchDeleteRuns(batchDeleteRunsRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.batchDeleteRuns']?.[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
@ -1981,6 +2039,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
batchArchiveRuns(batchRunLifecycleRequest: BatchRunLifecycleRequest, options?: RawAxiosRequestConfig): AxiosPromise<BatchRunLifecycleResponse> {
return localVarFp.batchArchiveRuns(batchRunLifecycleRequest, options).then((request) => request(axios, basePath));
},
/**
* Deletes up to 250 runs in one fail-soft, non-transactional request. Each run is processed independently. 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 Delete Runs
* @param {BatchDeleteRunsRequest} batchDeleteRunsRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
batchDeleteRuns(batchDeleteRunsRequest: BatchDeleteRunsRequest, options?: RawAxiosRequestConfig): AxiosPromise<BatchDeleteRunsResponse> {
return localVarFp.batchDeleteRuns(batchDeleteRunsRequest, 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
@ -2338,6 +2406,17 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).batchArchiveRuns(batchRunLifecycleRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Deletes up to 250 runs in one fail-soft, non-transactional request. Each run is processed independently. 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 Delete Runs
* @param {BatchDeleteRunsRequest} batchDeleteRunsRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public batchDeleteRuns(batchDeleteRunsRequest: BatchDeleteRunsRequest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).batchDeleteRuns(batchDeleteRunsRequest, 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

View file

@ -0,0 +1,29 @@
/* 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 delete as one bounded fail-soft batch.
*/
export interface BatchDeleteRunsRequest {
/**
* Run IDs to process, in result order.
*/
'run_ids': Set<string>;
/**
* Whether to force deletion of active runs. Defaults to `false`.
*/
'force'?: boolean;
}

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 { BatchDeleteRunsResult } from './batch-delete-runs-result';
// May contain unused imports in some cases
// @ts-ignore
import type { BatchDeleteRunsSummary } from './batch-delete-runs-summary';
/**
* Per-run results for a fail-soft batch delete request.
*/
export interface BatchDeleteRunsResponse {
/**
* Results ordered exactly like the request `run_ids`.
*/
'results': Array<BatchDeleteRunsResult>;
'summary': BatchDeleteRunsSummary;
}

View file

@ -0,0 +1,57 @@
/* 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 { DeleteRunSandbox } from './delete-run-sandbox';
// May contain unused imports in some cases
// @ts-ignore
import type { ErrorResponseEntry } from './error-response-entry';
/**
* Result for one run in a batch delete request.
*/
export interface BatchDeleteRunsResult {
/**
* Run ID from the request item.
*/
'run_id': string;
/**
* Whether this item succeeded.
*/
'ok': boolean;
/**
* Machine-readable item outcome.
*/
'outcome': BatchDeleteRunsResultOutcomeEnum;
/**
* Sandbox handoff details when `outcome` is `sandbox_preserved`.
*/
'sandbox'?: DeleteRunSandbox;
/**
* Structured item error for failed items.
*/
'error'?: ErrorResponseEntry;
}
export const BatchDeleteRunsResultOutcomeEnum = {
DELETED: 'deleted',
ALREADY_ABSENT: 'already_absent',
SANDBOX_PRESERVED: 'sandbox_preserved',
CONFLICT: 'conflict',
ERROR: 'error'
} as const;
export type BatchDeleteRunsResultOutcomeEnum = typeof BatchDeleteRunsResultOutcomeEnum[keyof typeof BatchDeleteRunsResultOutcomeEnum];

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 delete request.
*/
export interface BatchDeleteRunsSummary {
/**
* 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-delete-runs-request';
export * from './batch-delete-runs-response';
export * from './batch-delete-runs-result';
export * from './batch-delete-runs-summary';
export * from './batch-run-lifecycle-request';
export * from './batch-run-lifecycle-response';
export * from './batch-run-lifecycle-result';