fabro/apps/fabro-web/app/lib/run-actions.ts
2026-07-28 14:20:59 -04:00

325 lines
9.4 KiB
TypeScript

import type {
BatchDeleteRunsRequest,
BatchDeleteRunsResponse,
BatchRunLifecycleRequest,
BatchRunLifecycleResponse,
BatchRunLifecycleSummary,
ErrorResponseEntry,
Run,
RunControlAction,
} from "@qltysh/fabro-api-client";
import {
ApiError,
apiData,
apiResponse,
requestSignalOptions,
runsApi,
} from "./api-client";
import type { RunStatus } from "../data/runs";
export type LifecycleAction =
| "cancel"
| "approve"
| "deny"
| "archive"
| "unarchive"
| "retry";
export interface LifecycleActionError {
status: number;
errors: ErrorResponseEntry[];
}
const CANCELABLE_STATUSES = new Set<RunStatus>([
"submitted",
"pending",
"runnable",
"starting",
"running",
"paused",
"blocked",
]);
const TERMINAL_RUN_STATUSES = new Set<RunStatus>([
"succeeded",
"failed",
"dead",
]);
export async function cancelRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "cancel", request);
}
export async function approveRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "approve", request);
}
export async function denyRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "deny", request);
}
export async function archiveRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "archive", request);
}
export async function unarchiveRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "unarchive", request);
}
// Client-side batch summary for actions that don't have a server-side batch
// endpoint yet (currently: approve). Shape matches the server's batch
// responses so it slots into the same toolbar plumbing.
export interface BatchActionSummary {
summary: BatchRunLifecycleSummary;
}
export async function archiveRuns(
runIds: string[],
request?: Request,
): Promise<BatchRunLifecycleResponse> {
return batchRunLifecycleAction(runIds, "archive", request);
}
export async function approveRuns(
runIds: string[],
request?: Request,
): Promise<BatchActionSummary> {
const settled = await Promise.allSettled(
runIds.map((id) => approveRun(id, request)),
);
const succeeded = settled.filter((r) => r.status === "fulfilled").length;
return {
summary: {
requested: settled.length,
succeeded,
failed: settled.length - succeeded,
},
};
}
export async function unarchiveRuns(
runIds: string[],
request?: Request,
): Promise<BatchRunLifecycleResponse> {
return batchRunLifecycleAction(runIds, "unarchive", request);
}
export async function deleteRuns(
runIds: string[],
force = false,
request?: Request,
): Promise<BatchDeleteRunsResponse> {
try {
const body: BatchDeleteRunsRequest = { run_ids: runIds, force };
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);
}
export async function deleteRun(id: string, request?: Request): Promise<void> {
try {
await apiResponse(() => runsApi.deleteRun(id, undefined, requestSignalOptions(request)));
} catch (error) {
if (error instanceof ApiError && error.status === 404) return;
throw lifecycleActionErrorFromError(error);
}
}
export function canCancel(status: string | null | undefined): boolean {
return !!status && CANCELABLE_STATUSES.has(status as RunStatus);
}
export function canApprove(run: Run | null | undefined): boolean {
return run?.lifecycle.status.kind === "pending" && run.lifecycle.approval?.state === "pending";
}
export function canArchive(status: string | null | undefined): boolean {
return isTerminalRunStatus(status);
}
export function canUnarchive(status: string | null | undefined): boolean {
return status === "archived";
}
export function canRetry(run: Pick<Run, "lifecycle"> | null | undefined): boolean {
if (!run || run.lifecycle.archived) return false;
return isTerminalRunStatus(run.lifecycle.status.kind);
}
export function isTerminalRunStatus(
status: string | null | undefined,
): boolean {
return !!status && TERMINAL_RUN_STATUSES.has(status as RunStatus);
}
export function canDelete(status: string | null | undefined): boolean {
return status === "archived";
}
export function isTerminalCancelledRun(run: Pick<Run, "lifecycle">): boolean {
const status = run.lifecycle.status;
return status.kind === "failed" && status.reason === "cancelled";
}
export function isCancellationPending(
run: Pick<Run, "lifecycle"> | null | undefined,
mutationPending = false,
): boolean {
return isCancellationPendingState(
run?.lifecycle.status.kind,
run?.lifecycle.pending_control,
mutationPending,
);
}
export function isCancellationPendingState(
status: string | null | undefined,
pendingControl: RunControlAction | null | undefined,
mutationPending = false,
): boolean {
return canCancel(status) && (mutationPending || pendingControl === "cancel");
}
export function cancellationSuccessMessage(run: Pick<Run, "lifecycle">): string {
return isTerminalCancelledRun(run)
? "Run cancelled."
: "Cancellation requested.";
}
export function cancellationActionLabel(pending: boolean): string {
return pending ? "Cancelling…" : "Cancel";
}
export function deleteErrorMessage(error: unknown): string {
if (isLifecycleActionError(error)) {
if (error.status === 409) {
return "Active runs can't be deleted.";
}
const detail = error.errors[0]?.detail?.trim();
if (detail) return detail;
}
return "Couldn't delete the run right now. Try again.";
}
export function mapError(error: unknown, action: LifecycleAction): string {
if (isLifecycleActionError(error)) {
if (error.status === 404) {
return "This run no longer exists.";
}
if (error.status === 409) {
switch (action) {
case "cancel":
return "This run can no longer be cancelled.";
case "approve":
case "deny":
return "This run is no longer pending approval.";
case "archive":
return "Only terminal runs can be archived.";
case "unarchive":
return "Active runs can't be unarchived.";
case "retry":
return "This run can no longer be retried.";
}
}
const detail = error.errors[0]?.detail?.trim();
if (detail) {
return detail;
}
}
switch (action) {
case "cancel":
return "Couldn't cancel the run right now. Try again.";
case "approve":
return "Couldn't approve the run right now. Try again.";
case "deny":
return "Couldn't deny the run right now. Try again.";
case "archive":
return "Couldn't archive the run right now. Try again.";
case "unarchive":
return "Couldn't unarchive the run right now. Try again.";
case "retry":
return "Couldn't retry the run right now. Try again.";
}
}
async function runLifecycleAction(
id: string,
action: LifecycleAction,
request?: Request,
): Promise<Run> {
try {
switch (action) {
case "cancel":
return await apiData(() => runsApi.cancelRun(id, requestSignalOptions(request)));
case "approve":
return await apiData(() => runsApi.approveRun(id, requestSignalOptions(request)));
case "deny":
return await apiData(() => runsApi.denyRun(id, undefined, requestSignalOptions(request)));
case "archive":
return await apiData(() => runsApi.archiveRun(id, requestSignalOptions(request)));
case "unarchive":
return await apiData(() => runsApi.unarchiveRun(id, requestSignalOptions(request)));
case "retry":
return await apiData(() => runsApi.retryRun(id, requestSignalOptions(request)));
}
} catch (error) {
throw lifecycleActionErrorFromError(error);
}
}
async function batchRunLifecycleAction(
runIds: string[],
action: "archive" | "unarchive",
request?: Request,
): Promise<BatchRunLifecycleResponse> {
try {
const body: BatchRunLifecycleRequest = { run_ids: runIds };
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 {
status: error.status,
errors: parseLifecycleErrors(error.body),
};
}
function parseLifecycleErrors(body: unknown): ErrorResponseEntry[] {
if (!body || typeof body !== "object") return [];
const errors = (body as { errors?: unknown }).errors;
if (!Array.isArray(errors)) return [];
return errors.filter(isErrorResponseEntry);
}
export function isLifecycleActionError(value: unknown): value is LifecycleActionError {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return typeof record.status === "number" && Array.isArray(record.errors);
}
function isErrorResponseEntry(value: unknown): value is ErrorResponseEntry {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return (
typeof record.status === "string"
&& typeof record.title === "string"
&& typeof record.detail === "string"
);
}