diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 009fd1855..98586b2c4 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -14,7 +14,7 @@ function makeRun(overrides: Partial = {}): Run { id: "01ABC", goal: "Fix the build", title: "Fix the build", - workflow: { slug: "fix_build", name: "Fix Build", graph_name: "FixBuild" }, + workflow: { slug: "fix_build", name: "Fix Build", graph_name: "FixBuild", node_count: 0, edge_count: 0 }, automation: null, repository: { name: "myrepo", origin_url: null, provider: "unknown" }, created_by: null, @@ -22,6 +22,7 @@ function makeRun(overrides: Partial = {}): Run { labels: {}, lifecycle: { status: { kind: "running" }, + approval: null, pending_control: null, queue_position: null, error: null, @@ -59,6 +60,7 @@ function withStatus(status: ApiRunStatus): Pick { return { lifecycle: { status, + approval: null, pending_control: null, queue_position: null, error: null, @@ -127,7 +129,7 @@ describe("mapRunToRunItem", () => { id: "01DEF", goal: "", title: "", - workflow: { slug: null, name: null, graph_name: null }, + workflow: { slug: null, name: null, graph_name: null, node_count: 0, edge_count: 0 }, source_directory: null, repository: { name: "unknown", origin_url: null, provider: "unknown" }, ...withStatus({ kind: "submitted" }), @@ -150,20 +152,22 @@ describe("mapRunToRunItem", () => { test("falls back to graph name and slug for workflow labels", () => { const graphFallback = mapRunToRunItem( - makeRun({ workflow: { slug: "fix_build", name: null, graph_name: "FixBuild" } }), + makeRun({ workflow: { slug: "fix_build", name: null, graph_name: "FixBuild", node_count: 0, edge_count: 0 } }), ); const slugFallback = mapRunToRunItem( - makeRun({ workflow: { slug: "fix_build", name: null, graph_name: null } }), + makeRun({ workflow: { slug: "fix_build", name: null, graph_name: null, node_count: 0, edge_count: 0 } }), ); expect(graphFallback.workflow).toBe("FixBuild"); expect(slugFallback.workflow).toBe("fix_build"); }); - test("recognizes canonical blocked and queued run statuses", () => { - expect(isRunStatus("queued")).toBe(true); + test("recognizes canonical blocked, pending, and runnable run statuses", () => { + expect(isRunStatus("pending")).toBe(true); + expect(isRunStatus("runnable")).toBe(true); expect(isRunStatus("blocked")).toBe(true); - expect(runStatusDisplay).toHaveProperty("queued"); + expect(runStatusDisplay).toHaveProperty("pending"); + expect(runStatusDisplay).toHaveProperty("runnable"); expect(runStatusDisplay).toHaveProperty("blocked"); }); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index ca024a43e..d72b88706 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -41,7 +41,8 @@ export interface RunItem { } export const columnStatuses = [ - BoardColumn.QUEUED, + BoardColumn.PENDING, + BoardColumn.RUNNABLE, BoardColumn.INITIALIZING, BoardColumn.RUNNING, BoardColumn.BLOCKED, @@ -52,7 +53,8 @@ export const columnStatuses = [ ] as const satisfies readonly BoardColumn[]; export const columnStatusDisplay: Record = { - queued: { label: "Queued", dot: "bg-fg-muted", text: "text-fg-muted" }, + pending: { label: "Pending", dot: "bg-fg-muted", text: "text-fg-muted" }, + runnable: { label: "Runnable", dot: "bg-cyan-500", text: "text-cyan-500" }, initializing: { label: "Initializing", dot: "bg-amber", text: "text-amber" }, running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" }, blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" }, @@ -113,8 +115,10 @@ export function mapRunToRunItem(run: Run): RunItem { export function columnForStatus(status: ApiRunStatus | null | undefined): BoardColumn | null { switch (status?.kind) { case "submitted": - case "queued": - return "queued"; + case "pending": + return "pending"; + case "runnable": + return "runnable"; case "starting": return "initializing"; case "running": @@ -141,7 +145,7 @@ export function columnForRun(run: Run): BoardColumn | null { export function toRunWithStatus(run: Run): RunWithStatus { const item = mapRunListItem(run); - const column = columnForRun(run) ?? "queued"; + const column = columnForRun(run) ?? "pending"; return { ...item, status: column, @@ -157,7 +161,8 @@ export function deriveCiStatus(checks: CheckRun[]): CiStatus { export type RunStatus = | "submitted" - | "queued" + | "pending" + | "runnable" | "starting" | "running" | "blocked" @@ -170,7 +175,8 @@ export type RunStatus = export const runStatusDisplay: Record = { submitted: { label: "Submitted", dot: "bg-fg-muted", text: "text-fg-muted" }, - queued: { label: "Queued", dot: "bg-fg-muted", text: "text-fg-muted" }, + pending: { label: "Pending", dot: "bg-fg-muted", text: "text-fg-muted" }, + runnable: { label: "Runnable", dot: "bg-cyan-500", text: "text-cyan-500" }, starting: { label: "Starting", dot: "bg-amber", text: "text-amber" }, running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" }, blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" }, @@ -205,4 +211,4 @@ export const ciConfig: Record void } | null = null const useSWRMutationMock = mock((_key: unknown, _fetcher: unknown, options: unknown) => { lastMutationOptions = options as { onSuccess?: (result: unknown) => void }; - return {}; + return { + trigger: mock(), + isMutating: false, + reset: mock(), + }; }); mock.module("swr", () => ({ @@ -24,8 +28,10 @@ mock.module("./api-client", () => ({ })); mock.module("./run-actions", () => ({ + approveRun: mock(), archiveRun: mock(), cancelRun: mock(), + denyRun: mock(), isLifecycleActionError: () => false, retryRun: mock(), unarchiveRun: mock(), diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts index dbbade092..0fb4db240 100644 --- a/apps/fabro-web/app/lib/mutations.ts +++ b/apps/fabro-web/app/lib/mutations.ts @@ -18,8 +18,10 @@ import { mutateRunListCaches } from "./board-cache"; import { queryKeys } from "./query-keys"; import type { LifecycleAction, LifecycleActionError } from "./run-actions"; import { + approveRun, archiveRun, cancelRun, + denyRun, isLifecycleActionError, retryRun, unarchiveRun, @@ -64,6 +66,14 @@ export function useCancelRun(id: string | undefined) { return useLifecycleMutation(id, "cancel", cancelRun); } +export function useApproveRun(id: string | undefined) { + return useLifecycleMutation(id, "approve", approveRun); +} + +export function useDenyRun(id: string | undefined) { + return useLifecycleMutation(id, "deny", denyRun); +} + export function useArchiveRun(id: string | undefined) { return useLifecycleMutation(id, "archive", archiveRun); } diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 7351388c4..da551a33f 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -78,6 +78,8 @@ export const queryKeys = { pullRequest: (id: string) => ["runs", "pull-request", id] as const, preview: (id: string) => ["runs", "preview", id] as const, cancel: (id: string) => ["runs", "cancel", id] as const, + approve: (id: string) => ["runs", "approve", id] as const, + deny: (id: string) => ["runs", "deny", id] as const, retry: (id: string) => ["runs", "retry", id] as const, archive: (id: string) => ["runs", "archive", id] as const, unarchive: (id: string) => ["runs", "unarchive", id] as const, diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts index e754985c0..1f61f41a1 100644 --- a/apps/fabro-web/app/lib/run-actions.test.ts +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -5,6 +5,7 @@ import type { Run, RunStatus } from "@qltysh/fabro-api-client"; import { archiveRun, canArchive, + canApprove, canCancel, canRetry, canUnarchive, @@ -29,7 +30,7 @@ function makeRun(status: RunStatus, archived = false): Run { id: "run-1", goal: "Fix the build", title: "Fix the build", - workflow: { slug: "fix_build", name: "Fix Build" }, + workflow: { slug: "fix_build", name: "Fix Build", graph_name: null, node_count: 0, edge_count: 0 }, automation: null, repository: null, created_by: null, @@ -37,6 +38,7 @@ function makeRun(status: RunStatus, archived = false): Run { labels: {}, lifecycle: { status, + approval: null, pending_control: null, queue_position: null, error: null, @@ -143,7 +145,7 @@ describe("run lifecycle actions", () => { stubGeneratedAxiosOnce({ status: 201, body: { - ...makeRun({ kind: "queued" }), + ...makeRun({ kind: "submitted" }), id: "run-2", retried_from: "run-1", }, @@ -152,7 +154,7 @@ describe("run lifecycle actions", () => { const result = await retryRun("run-1"); expect(result.id).toBe("run-2"); expect(result.retried_from).toBe("run-1"); - expect(result.lifecycle.status.kind).toBe("queued"); + expect(result.lifecycle.status.kind).toBe("submitted"); }); test("404 and 409 preserve the parsed error envelope", async () => { @@ -194,13 +196,16 @@ describe("run lifecycle actions", () => { test("mapError returns user-facing copy for lifecycle conflicts", () => { expect(mapError({ status: 409, errors: [] }, "cancel")).toBe("This run can no longer be cancelled."); + expect(mapError({ status: 409, errors: [] }, "approve")).toBe("This run is no longer pending approval."); + expect(mapError({ status: 409, errors: [] }, "deny")).toBe("This run is no longer pending approval."); expect(mapError({ status: 409, errors: [] }, "archive")).toBe("Only terminal runs can be archived."); expect(mapError({ status: 409, errors: [] }, "unarchive")).toBe("Active runs can't be unarchived."); }); test("status predicates align with the documented run statuses", () => { expect(canCancel("submitted")).toBe(true); - expect(canCancel("queued")).toBe(true); + expect(canCancel("pending")).toBe(true); + expect(canCancel("runnable")).toBe(true); expect(canCancel("starting")).toBe(true); expect(canCancel("running")).toBe(true); expect(canCancel("paused")).toBe(true); @@ -216,6 +221,23 @@ describe("run lifecycle actions", () => { expect(canUnarchive("failed")).toBe(false); }); + test("approval predicate requires pending status and pending approval state", () => { + expect(canApprove({ + ...makeRun({ kind: "pending", reason: "approval_required" }), + lifecycle: { + ...makeRun({ kind: "pending", reason: "approval_required" }).lifecycle, + approval: { + state: "pending", + requested_at: "2026-05-23T12:00:00Z", + decided_at: null, + denial_reason: null, + }, + }, + })).toBe(true); + expect(canApprove(makeRun({ kind: "pending", reason: "approval_required" }))).toBe(false); + expect(canApprove(makeRun({ kind: "runnable" }))).toBe(false); + }); + test("canRetry allows failed and dead runs except cancelled or archived runs", () => { expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }))).toBe(true); expect(canRetry(makeRun({ kind: "dead" }))).toBe(true); diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index 6ec296436..a785feafa 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -9,7 +9,13 @@ import { } from "./api-client"; import type { RunStatus } from "../data/runs"; -export type LifecycleAction = "cancel" | "archive" | "unarchive" | "retry"; +export type LifecycleAction = + | "cancel" + | "approve" + | "deny" + | "archive" + | "unarchive" + | "retry"; export interface LifecycleActionError { status: number; @@ -18,7 +24,8 @@ export interface LifecycleActionError { const CANCELABLE_STATUSES = new Set([ "submitted", - "queued", + "pending", + "runnable", "starting", "running", "paused", @@ -35,6 +42,14 @@ export async function cancelRun(id: string, request?: Request): Promise { return runLifecycleAction(id, "cancel", request); } +export async function approveRun(id: string, request?: Request): Promise { + return runLifecycleAction(id, "approve", request); +} + +export async function denyRun(id: string, request?: Request): Promise { + return runLifecycleAction(id, "deny", request); +} + export async function archiveRun(id: string, request?: Request): Promise { return runLifecycleAction(id, "archive", request); } @@ -60,6 +75,10 @@ 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 !!status && ARCHIVABLE_STATUSES.has(status as RunStatus); } @@ -104,6 +123,9 @@ export function mapError(error: unknown, action: LifecycleAction): string { 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": @@ -122,6 +144,10 @@ export function mapError(error: unknown, action: LifecycleAction): string { 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": @@ -140,6 +166,10 @@ async function runLifecycleAction( 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": diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index 990cd9970..7ba5dd48b 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -36,7 +36,11 @@ const subscriptions = new Map(); const TERMINAL_EVENTS = new Set(["run.completed", "run.failed"]); const RUN_SUMMARY_EVENTS = new Set([ "run.submitted", - "run.queued", + "run.start_requested", + "run.pending", + "run.approved", + "run.denied", + "run.runnable", "run.starting", "run.running", "run.paused", diff --git a/apps/fabro-web/app/lib/run-phases.test.ts b/apps/fabro-web/app/lib/run-phases.test.ts index e138c70b6..a0ad5654e 100644 --- a/apps/fabro-web/app/lib/run-phases.test.ts +++ b/apps/fabro-web/app/lib/run-phases.test.ts @@ -4,8 +4,10 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; import { deriveRunPhases } from "./run-phases"; const CREATED = "2026-05-23T12:00:00.000Z"; -const T_QUEUED = "2026-05-23T12:00:01.000Z"; -const T_STARTING = "2026-05-23T12:00:03.000Z"; +const T_REQUESTED = "2026-05-23T12:00:01.000Z"; +const T_PENDING = "2026-05-23T12:00:02.000Z"; +const T_RUNNABLE = "2026-05-23T12:00:03.000Z"; +const T_STARTING = "2026-05-23T12:00:04.000Z"; const T_RUNNING = "2026-05-23T12:00:10.000Z"; function makeEvent(name: string, ts: string, seq: number): EventEnvelope { @@ -35,33 +37,11 @@ describe("deriveRunPhases", () => { ]); }); - test("closes submitted at run.queued and opens an in-progress queued phase", () => { - const phases = deriveRunPhases( - [makeEvent("run.queued", T_QUEUED, 1)], - CREATED, - ); - expect(phases).toEqual([ - { - kind: "submitted", - label: "Submitted", - startMs: Date.parse(CREATED), - endMs: Date.parse(T_QUEUED), - }, - { - kind: "queued", - label: "Queued", - startMs: Date.parse(T_QUEUED), - endMs: null, - }, - ]); - }); - - test("emits submitted, queued, and initializing through run.running", () => { + test("closes submitted at run.start_requested and opens pending when approval is required", () => { const phases = deriveRunPhases( [ - makeEvent("run.queued", T_QUEUED, 1), - makeEvent("run.starting", T_STARTING, 2), - makeEvent("run.running", T_RUNNING, 3), + makeEvent("run.start_requested", T_REQUESTED, 1), + makeEvent("run.pending", T_PENDING, 2), ], CREATED, ); @@ -70,12 +50,45 @@ describe("deriveRunPhases", () => { kind: "submitted", label: "Submitted", startMs: Date.parse(CREATED), - endMs: Date.parse(T_QUEUED), + endMs: Date.parse(T_REQUESTED), }, { - kind: "queued", - label: "Queued", - startMs: Date.parse(T_QUEUED), + kind: "pending", + label: "Pending", + startMs: Date.parse(T_PENDING), + endMs: null, + }, + ]); + }); + + test("emits submitted, pending, runnable, and initializing through run.running", () => { + const phases = deriveRunPhases( + [ + makeEvent("run.start_requested", T_REQUESTED, 1), + makeEvent("run.pending", T_PENDING, 2), + makeEvent("run.runnable", T_RUNNABLE, 3), + makeEvent("run.starting", T_STARTING, 4), + makeEvent("run.running", T_RUNNING, 5), + ], + CREATED, + ); + expect(phases).toEqual([ + { + kind: "submitted", + label: "Submitted", + startMs: Date.parse(CREATED), + endMs: Date.parse(T_REQUESTED), + }, + { + kind: "pending", + label: "Pending", + startMs: Date.parse(T_PENDING), + endMs: Date.parse(T_RUNNABLE), + }, + { + kind: "runnable", + label: "Runnable", + startMs: Date.parse(T_RUNNABLE), endMs: Date.parse(T_STARTING), }, { @@ -87,7 +100,7 @@ describe("deriveRunPhases", () => { ]); }); - test("skips the queued phase when there was no run.queued event", () => { + test("skips pending and runnable phases when those events are missing", () => { const phases = deriveRunPhases( [ makeEvent("run.starting", T_STARTING, 1), @@ -101,7 +114,7 @@ describe("deriveRunPhases", () => { expect(phases[1]!.endMs).toBe(Date.parse(T_RUNNING)); }); - test("uses run.starting as fallback end for submitted when queued is missing", () => { + test("uses run.starting as fallback end for submitted when pre-execution events are missing", () => { const phases = deriveRunPhases( [makeEvent("run.starting", T_STARTING, 1)], CREATED, @@ -112,7 +125,7 @@ describe("deriveRunPhases", () => { test("ignores unrelated events", () => { const phases = deriveRunPhases( [ - makeEvent("agent.message", T_QUEUED, 1), + makeEvent("agent.message", T_REQUESTED, 1), makeEvent("stage.started", T_STARTING, 2), ], CREATED, diff --git a/apps/fabro-web/app/lib/run-phases.ts b/apps/fabro-web/app/lib/run-phases.ts index fb1769ae1..839b6581d 100644 --- a/apps/fabro-web/app/lib/run-phases.ts +++ b/apps/fabro-web/app/lib/run-phases.ts @@ -1,6 +1,6 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; -export type RunPhaseKind = "submitted" | "queued" | "initializing"; +export type RunPhaseKind = "submitted" | "pending" | "runnable" | "initializing"; export interface RunPhase { kind: RunPhaseKind; @@ -11,7 +11,8 @@ export interface RunPhase { const PHASE_LABEL: Record = { submitted: "Submitted", - queued: "Queued", + pending: "Pending", + runnable: "Runnable", initializing: "Initializing", }; @@ -27,17 +28,45 @@ export function deriveRunPhases( const createdMs = Date.parse(createdAtIso); if (Number.isNaN(createdMs)) return []; - const firstTs = (name: string): number | null => { - if (!events) return null; - const event = events.find((e) => e.event === name); - if (!event) return null; - const ms = Date.parse(event.ts); - return Number.isNaN(ms) ? null : ms; - }; + let startRequestedMs: number | null = null; + let pendingMs: number | null = null; + let runnableMs: number | null = null; + let startingMs: number | null = null; + let runningMs: number | null = null; + let remaining = 5; - const queuedMs = firstTs("run.queued"); - const startingMs = firstTs("run.starting"); - const runningMs = firstTs("run.running"); + for (const event of events ?? []) { + if (remaining === 0) break; + let target: "startRequested" | "pending" | "runnable" | "starting" | "running" | null = null; + switch (event.event) { + case "run.start_requested": + if (startRequestedMs == null) target = "startRequested"; + break; + case "run.pending": + if (pendingMs == null) target = "pending"; + break; + case "run.runnable": + if (runnableMs == null) target = "runnable"; + break; + case "run.starting": + if (startingMs == null) target = "starting"; + break; + case "run.running": + if (runningMs == null) target = "running"; + break; + } + if (target == null) continue; + const ms = Date.parse(event.ts); + if (Number.isNaN(ms)) continue; + switch (target) { + case "startRequested": startRequestedMs = ms; break; + case "pending": pendingMs = ms; break; + case "runnable": runnableMs = ms; break; + case "starting": startingMs = ms; break; + case "running": runningMs = ms; break; + } + remaining -= 1; + } const phases: RunPhase[] = []; @@ -45,14 +74,23 @@ export function deriveRunPhases( kind: "submitted", label: PHASE_LABEL.submitted, startMs: createdMs, - endMs: queuedMs ?? startingMs ?? runningMs, + endMs: startRequestedMs ?? pendingMs ?? runnableMs ?? startingMs ?? runningMs, }); - if (queuedMs != null) { + if (pendingMs != null) { phases.push({ - kind: "queued", - label: PHASE_LABEL.queued, - startMs: queuedMs, + kind: "pending", + label: PHASE_LABEL.pending, + startMs: pendingMs, + endMs: runnableMs ?? startingMs ?? runningMs, + }); + } + + if (runnableMs != null) { + phases.push({ + kind: "runnable", + label: PHASE_LABEL.runnable, + startMs: runnableMs, endMs: startingMs ?? runningMs, }); } diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index 98ef24f7b..64cc380a7 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -58,7 +58,9 @@ const mutationState = () => ({ mock.module("../lib/mutations", () => ({ useArchiveRun: mutationState, + useApproveRun: mutationState, useCancelRun: mutationState, + useDenyRun: mutationState, useInterruptRun: mutationState, usePreviewRun: mutationState, useRetryRun: mutationState, @@ -91,18 +93,18 @@ function makeRunSummary( status === "succeeded" ? { kind: "succeeded", reason: "completed" } : status === "failed" - ? { kind: "failed", reason: "error" } + ? { kind: "failed", reason: "workflow_error" } : status === "dead" ? { kind: "dead" } : status === "blocked" - ? { kind: "blocked", reason: "interview", pending_question_id: null } + ? { kind: "blocked", blocked_reason: "human_input_required" } : { kind: status }; const archived = status === "archived"; return { id: "run_1", goal: "Run 1", title, - workflow: { slug: "default", name: "Default" }, + workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 }, automation: null, repository: { name: "fabro", origin_url: null, provider: "unknown" }, created_by: null, @@ -110,6 +112,7 @@ function makeRunSummary( labels: {}, lifecycle: { status: archived ? { kind: "succeeded", reason: "completed" } : apiStatus, + approval: null, pending_control: null, queue_position: null, error: null, @@ -223,7 +226,8 @@ function tabCountBadges(renderer: TestRenderer.ReactTestRenderer) { describe("lifecycleActionVisibility", () => { test("shows cancel for active cancellable states and hides it elsewhere", () => { expect(lifecycleActionVisibility("submitted").showPrimaryCancel).toBe(true); - expect(lifecycleActionVisibility("queued").showPrimaryCancel).toBe(true); + expect(lifecycleActionVisibility("pending").showPrimaryCancel).toBe(true); + expect(lifecycleActionVisibility("runnable").showPrimaryCancel).toBe(true); expect(lifecycleActionVisibility("starting").showPrimaryCancel).toBe(true); expect(lifecycleActionVisibility("running").showPrimaryCancel).toBe(true); expect(lifecycleActionVisibility("paused").showPrimaryCancel).toBe(true); @@ -293,7 +297,14 @@ describe("handleLifecycleToastResult", () => { const initialState: LifecycleToastState = { activeArchiveToastId: null, - lastProcessed: { cancel: null, archive: null, unarchive: null }, + lastProcessed: { + cancel: null, + approve: null, + deny: null, + archive: null, + unarchive: null, + retry: null, + }, }; test("replaying the same cancel success result does not enqueue a duplicate toast", () => { @@ -359,7 +370,14 @@ describe("handleLifecycleToastResult", () => { }; const stateWithActiveToast: LifecycleToastState = { activeArchiveToastId: "toast-9", - lastProcessed: { cancel: null, archive: null, unarchive: null }, + lastProcessed: { + cancel: null, + approve: null, + deny: null, + archive: null, + unarchive: null, + retry: null, + }, }; const nextState = handleLifecycleToastResult("unarchive", result, stateWithActiveToast, api); @@ -430,14 +448,21 @@ describe("RunDetail full-height child routes", () => { intent: "retry", ok: true, run: { - ...makeRunSummary("queued"), + ...makeRunSummary("runnable"), id: "run_retry", retried_from: "run_1", }, }; const initialState: LifecycleToastState = { activeArchiveToastId: null, - lastProcessed: { cancel: null, archive: null, unarchive: null, retry: null }, + lastProcessed: { + cancel: null, + approve: null, + deny: null, + archive: null, + unarchive: null, + retry: null, + }, }; const next = handleLifecycleToastResult( diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index f86755066..f189d1e79 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -66,7 +66,9 @@ import { useDemoMode } from "../lib/demo-mode"; import { useSWRConfig } from "swr"; import { useArchiveRun, + useApproveRun, useCancelRun, + useDenyRun, useInterruptRun, usePreviewRun, useRetryRun, @@ -81,6 +83,7 @@ import { useRunToasts } from "../hooks/use-run-toasts"; import { useRun, useRunPullRequest, useRunQuestions, useRunState } from "../lib/queries"; import { canArchive, + canApprove, canCancel, canDelete, canRetry, @@ -160,7 +163,14 @@ type ToastApi = Pick, "push" | "dismiss">; const INITIAL_LIFECYCLE_TOAST_STATE: LifecycleToastState = { activeArchiveToastId: null, - lastProcessed: { cancel: null, archive: null, unarchive: null, retry: null }, + lastProcessed: { + cancel: null, + approve: null, + deny: null, + archive: null, + unarchive: null, + retry: null, + }, }; export function lifecycleActionVisibility(status: string | null | undefined) { @@ -401,6 +411,8 @@ export default function RunDetail({ params }: { params: { id: string } }) { const basePath = `/runs/${params.id}`; const previewMutation = usePreviewRun(params.id); const cancelMutation = useCancelRun(params.id); + const approveMutation = useApproveRun(params.id); + const denyMutation = useDenyRun(params.id); const archiveMutation = useArchiveRun(params.id); const unarchiveMutation = useUnarchiveRun(params.id); const retryMutation = useRetryRun(params.id); @@ -461,6 +473,24 @@ export default function RunDetail({ params }: { params: { id: string } }) { ); }, [archiveMutation.data, dismiss, push]); + useEffect(() => { + lifecycleToastStateRef.current = handleLifecycleToastResult( + "approve", + approveMutation.data, + lifecycleToastStateRef.current, + { push, dismiss }, + ); + }, [approveMutation.data, dismiss, push]); + + useEffect(() => { + lifecycleToastStateRef.current = handleLifecycleToastResult( + "deny", + denyMutation.data, + lifecycleToastStateRef.current, + { push, dismiss }, + ); + }, [denyMutation.data, dismiss, push]); + useEffect(() => { lifecycleToastStateRef.current = handleLifecycleToastResult( "unarchive", @@ -525,6 +555,9 @@ export default function RunDetail({ params }: { params: { id: string } }) { const visibility = lifecycleActionVisibility(run.lifecycleStatus); const previewPending = previewMutation.isMutating; const cancelPending = cancelMutation.isMutating; + const approvalActionVisible = canApprove(summary); + const approvePending = approveMutation.isMutating; + const denyPending = denyMutation.isMutating; const archivePending = archiveMutation.isMutating; const unarchivePending = unarchiveMutation.isMutating; const retryPending = retryMutation.isMutating; @@ -673,6 +706,12 @@ export default function RunDetail({ params }: { params: { id: string } }) { canArchive={visibility.showArchive} archivePending={archivePending} onArchive={() => void archiveMutation.trigger()} + canApprove={approvalActionVisible} + approvePending={approvePending} + onApprove={() => void approveMutation.trigger()} + canDeny={approvalActionVisible} + denyPending={denyPending} + onDeny={() => void denyMutation.trigger()} canRetry={!demoMode && canRetry(summary)} retryPending={retryPending} onRetry={() => void retryMutation.trigger()} @@ -865,6 +904,16 @@ export function handleLifecycleToastResult( return nextState; } + if (intent === "approve") { + toastApi.push({ message: "Run approved." }); + return nextState; + } + + if (intent === "deny") { + toastApi.push({ message: "Run denied." }); + return nextState; + } + if (intent === "retry") { toastApi.push({ message: "Retry started." }); navigate?.(`/runs/${result.run.id}`); @@ -925,6 +974,12 @@ interface ActionsMenuProps { canArchive: boolean; archivePending: boolean; onArchive: () => void; + canApprove: boolean; + approvePending: boolean; + onApprove: () => void; + canDeny: boolean; + denyPending: boolean; + onDeny: () => void; canRetry: boolean; retryPending: boolean; onRetry: () => void; @@ -945,6 +1000,8 @@ function ActionsMenu(props: ActionsMenuProps) { canFocusSteer, onFocusSteer, canPreview, previewPending, onPreview, canArchive, archivePending, onArchive, + canApprove, approvePending, onApprove, + canDeny, denyPending, onDeny, canRetry, retryPending, onRetry, canUnarchive, unarchivePending, onUnarchive, canDelete, deletePending, onDelete, @@ -953,11 +1010,19 @@ function ActionsMenu(props: ActionsMenuProps) { const hasOps = canPreview || canSendInterrupt || canFocusSteer; - const hasLifecycle = canRetry || canArchive || canUnarchive; - const hasDestructive = canCancel || canDelete; + const hasLifecycle = canApprove || canRetry || canArchive || canUnarchive; + const hasDestructive = canDeny || canCancel || canDelete; const hasAny = hasOps || hasLifecycle || hasDestructive; const anyPending = - previewPending || retryPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending; + previewPending || + approvePending || + retryPending || + archivePending || + unarchivePending || + denyPending || + deletePending || + cancelPending || + interruptPending; const separators = actionMenuSeparatorVisibility({ hasLifecycle, hasDestructive }); if (!hasAny) return null; @@ -1009,6 +1074,18 @@ function ActionsMenu(props: ActionsMenuProps) { {separators.afterOperations && (
)} + {canApprove && ( + + + + )} {canRetry && ( + + )} {canCancel && (