From 967e98194efd03e39ae2e678a8168022236b76a2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 11 Aug 2026 04:00:16 -0700 Subject: [PATCH] FN-8950: hide Promote until plan gates clear Prevent task-card promotion shortcuts when plan review or approval holds remain. - Mirror default-on plan-review gate and approval-hold predicates in dashboard helpers. - Suppress Promote across planning, review, and approval states. - Add regression coverage and a patch changeset. Files changed: .changeset/fn-8950-promote-plan-gate.md | 7 ++ packages/dashboard/app/components/TaskCard.tsx | 29 +++++-- .../__tests__/TaskCard.cost-badge.test.tsx | 2 + .../__tests__/TaskCard.footer-wrap.test.tsx | 2 + .../app/components/__tests__/TaskCard.test.tsx | 96 ++++++++++++++-------- .../utils/__tests__/reviewBudgetApproval.test.ts | 77 +++++++++++++++++ .../dashboard/app/utils/reviewBudgetApproval.ts | 43 ++++++++++ 7 files changed, 218 insertions(+), 38 deletions(-) Fusion-Task-Id: FN-8950 Fusion-Task-Lineage: c587b3b4-03b4-4cd2-9588-826706788eb7 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8950-promote-plan-gate.md | 7 ++ .../dashboard/app/components/TaskCard.tsx | 29 +++++- .../__tests__/TaskCard.cost-badge.test.tsx | 2 + .../__tests__/TaskCard.footer-wrap.test.tsx | 2 + .../components/__tests__/TaskCard.test.tsx | 94 ++++++++++++------- .../__tests__/reviewBudgetApproval.test.ts | 77 +++++++++++++++ .../app/utils/reviewBudgetApproval.ts | 43 +++++++++ 7 files changed, 217 insertions(+), 37 deletions(-) create mode 100644 .changeset/fn-8950-promote-plan-gate.md create mode 100644 packages/dashboard/app/utils/__tests__/reviewBudgetApproval.test.ts diff --git a/.changeset/fn-8950-promote-plan-gate.md b/.changeset/fn-8950-promote-plan-gate.md new file mode 100644 index 0000000000..7331250439 --- /dev/null +++ b/.changeset/fn-8950-promote-plan-gate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Hide Promote until a task’s required plan review and approval holds clear. +category: fix +dev: Adds `isPlanReviewGateUnsatisfied` and `isTaskBlockedOnApprovalHold`, mirroring server predicates with the default-on plan-review fallback and column-independent approval holds. diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index d8c1b1f993..e78b3b38e4 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -58,7 +58,12 @@ import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity"; import { getPrBadgeModifierClass } from "../utils/prBadgeClass"; import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; import { getTaskStatusBadgeLabel, getTaskWipLifecycleBadgeLabel, type TaskStatusBadgeContext, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel"; -import { isReviewBudgetExhaustedApproval, isTaskAwaitingPlanApproval } from "../utils/reviewBudgetApproval"; +import { + isPlanReviewGateUnsatisfied, + isReviewBudgetExhaustedApproval, + isTaskAwaitingPlanApproval, + isTaskBlockedOnApprovalHold, +} from "../utils/reviewBudgetApproval"; import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback"; import type { ToastType } from "../hooks/useToast"; import { useConfirm } from "../hooks/useConfirm"; @@ -1511,6 +1516,10 @@ function TaskCardComponent({ () => isPlanReviewRunning(task), [task.steps, task.enabledWorkflowSteps, task.workflowStepResults], ); + const planReviewGateUnsatisfied = useMemo( + () => isPlanReviewGateUnsatisfied(task), + [task.enabledWorkflowSteps, task.workflowStepResults], + ); /* FNXC:WorkflowResolvedColumns 2026-07-30-00:40 (partial-supply seam, caught by the gate): `getRunningOptionalGateBadge` takes resolved flags and BOTH ListView call sites supply them; this @@ -1549,6 +1558,7 @@ function TaskCardComponent({ */ const isPlanReviewReplanCapApproval = isReviewBudgetExhaustedApproval(task); const isAwaitingApproval = isTaskAwaitingPlanApproval(task, isIntakeColumn); + const isBlockedOnApprovalHold = isTaskBlockedOnApprovalHold(task); const isAwaitingInput = task.status === "awaiting-user-input"; const isArchived = isArchivedColumn; /* @@ -1590,12 +1600,21 @@ function TaskCardComponent({ Post-U11, the hold column is also the planning lane, so Promote must not be offered while a card is unplanned, being planned, in Plan Review, or awaiting plan approval. That click is rejected as `unplanned-for-execution` and the force path would start implementation against an incomplete plan. `awaitingPlanning` is absent from SSE payloads, so its step-count fallback deliberately matches the Ready / Queued to plan badge pair. `isAwaitingApproval` only applies on an intake-trait merged planning lane or for the `plan-review-replan-cap` reason. + + FNXC:TaskCardPromote 2026-08-11-09:13: + FN-8950 anticipates `issueRelease`'s approval and unplanned refusal arms. An enabled-but-pending + default-on Plan Review in Todo and an absent enabled-step selection are both blocked; approval + holds are blocked on every column rather than only intake. This is deliberately conservative, + not exact parity: the card cannot resolve custom defaultOn values, plan-review's column/WIP + position, or capacity continuations, and also suppresses the planning-stage `specifying` and + `plan-review-unavailable` statuses. Hiding a shortcut is safer than offering a click the server + rejects: capacity release and explicit force promotion remain available. */ const isStillInPlanning = awaitingPlanning - || task.status === "planning" - || task.status === "needs-replan" - || planReviewRunning - || isAwaitingApproval; + || ["planning", "specifying", "needs-replan", "plan-review-unavailable"].includes(task.status ?? "") + || planReviewGateUnsatisfied + || isAwaitingApproval + || isBlockedOnApprovalHold; const showPromoteAction = Boolean(onPromote) && !isStillInPlanning; const showIdleTodoBadge = !isPaused && isHoldColumn diff --git a/packages/dashboard/app/components/__tests__/TaskCard.cost-badge.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.cost-badge.test.tsx index efbb5e8620..d59bcacebc 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.cost-badge.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.cost-badge.test.tsx @@ -40,6 +40,8 @@ function taskWithUsage(overrides: Partial = {}): Task { column: "todo", steps: [{ name: "Implement", status: "pending" }] as any, awaitingPlanning: false, + // FNXC:TaskCardPromote 2026-08-11-09:13: This promote-visible fixture explicitly disables the default-on plan-review gate. + enabledWorkflowSteps: [], dependencies: [], tokenUsage: { inputTokens: 1_000_000, diff --git a/packages/dashboard/app/components/__tests__/TaskCard.footer-wrap.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.footer-wrap.test.tsx index 74a29a8704..07c5b4193a 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.footer-wrap.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.footer-wrap.test.tsx @@ -88,6 +88,8 @@ function makeTask(overrides: Partial = {}): Task { column: "in-progress", status: "executing" as Task["status"], steps: [], + // FNXC:TaskCardPromote 2026-08-11-09:13: Promote-visible footer fixtures explicitly clear the default-on plan-review gate. + enabledWorkflowSteps: [], dependencies: [], sourceType: "dashboard_ui", githubTracking: { diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index db29abaa44..1093322688 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -6724,6 +6724,7 @@ describe("TaskCard", () => { id: "FN-8324", column: "todo", awaitingPlanning: false, + enabledWorkflowSteps: [], steps: [{ name: "Implement", status: "pending" }] as any, tokenUsage: { inputTokens: 1_000_000, @@ -7881,7 +7882,7 @@ describe("TaskCard mission badge", () => { try { render( { render( { const soloRender = render( { render( { render( { render( { it("suppresses Promote for every planning state while retaining the planned capacity-hold action", () => { let sequence = 0; const makePromoteFixture = (overrides: Partial = {}) => makeTask({ - id: `FN-8907-${sequence++}`, + id: `FN-8950-${sequence++}`, title: `Promote fixture ${sequence}`, column: "todo", awaitingPlanning: false, status: null as any, steps: [{ name: "Implement", status: "pending" }] as any, + // FNXC:TaskCardPromote 2026-08-11-09:13: An explicit empty list disables the built-in default-on plan-review group for promotable controls. + enabledWorkflowSteps: [], ...overrides, }); const renderPromoteFixture = (task: Task, taskColumnFlags: any, cost = false) => render( @@ -8023,19 +8026,22 @@ describe("TaskCard mission badge", () => { , ); - const expectSuppressedWithControl = (planning: Partial, control: Partial, flags: any) => { + const expectSuppressedWithControl = (name: string, planning: Partial, control: Partial, flags: any) => { const planningTask = makePromoteFixture(planning); const suppressed = renderPromoteFixture(planningTask, flags); - expect(screen.getByText(planningTask.title)).toBeInTheDocument(); - expect(screen.queryByTestId(`card-promote-${planningTask.id}`)).toBeNull(); + expect(screen.getByText(planningTask.title), `${name}: card must render`).toBeInTheDocument(); + // FNXC:TaskCardPromote 2026-08-11-09:13: Soft assertion keeps the gate-only revert evidence comprehensive by reporting every named escape in one run. + expect.soft(screen.queryByTestId(`card-promote-${planningTask.id}`), `${name}: Promote must be suppressed`).toBeNull(); suppressed.unmount(); const controlTask = makePromoteFixture(control); const positive = renderPromoteFixture(controlTask, flags); - expect(screen.getByText(controlTask.title)).toBeInTheDocument(); - expect(screen.getByTestId(`card-promote-${controlTask.id}`)).toBeInTheDocument(); + expect(screen.getByText(controlTask.title), `${name}: control card must render`).toBeInTheDocument(); + expect(screen.getByTestId(`card-promote-${controlTask.id}`), `${name}: control must keep Promote`).toBeInTheDocument(); positive.unmount(); }; + const passedPlanReview = [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "passed" }] as any; + const auditedSkippedPlanReview = [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "skipped", bypassedFromStatus: "failed", bypassedFromVerdict: "REVISE", bypassedBy: "operator", bypassedAt: "2026-08-11T00:00:00Z", bypassReason: "review dispatch failed" }] as any; // Harness self-check: planned capacity-held cards must reach the rendered Promote branch. const readyTask = makePromoteFixture(); @@ -8045,29 +8051,52 @@ describe("TaskCard mission badge", () => { expect(readyPromote).toHaveTextContent("Promote"); ready.unmount(); - expectSuppressedWithControl({ awaitingPlanning: true }, { awaitingPlanning: false }, { hold: true }); - expectSuppressedWithControl({ awaitingPlanning: undefined, steps: [] }, { awaitingPlanning: undefined, steps: [{ name: "Implement", status: "pending" }] as any }, { hold: true }); - expectSuppressedWithControl({ status: "planning" as any }, { status: null as any }, { hold: true }); - expectSuppressedWithControl({ status: "needs-replan" as any }, { status: null as any }, { hold: true }); - expectSuppressedWithControl({ - enabledWorkflowSteps: ["plan-review"], - workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending", startedAt: "2026-08-09T00:00:00Z" }], - }, { - enabledWorkflowSteps: ["plan-review"], - workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending" }], - }, { hold: true }); - expectSuppressedWithControl({ awaitingPlanning: true }, { awaitingPlanning: false }, undefined); + expectSuppressedWithControl("awaitingPlanning", { awaitingPlanning: true }, { awaitingPlanning: false }, { hold: true }); + expectSuppressedWithControl("empty steps", { awaitingPlanning: undefined, steps: [] }, { awaitingPlanning: undefined, steps: [{ name: "Implement", status: "pending" }] as any }, { hold: true }); + expectSuppressedWithControl("planning status", { status: "planning" as any }, { status: null as any }, { hold: true }); + expectSuppressedWithControl("needs-replan status", { status: "needs-replan" as any }, { status: null as any }, { hold: true }); - /* isTaskAwaitingPlanApproval requires intake unless the replan-cap reason is set. */ - expectSuppressedWithControl({ status: "awaiting-approval" as any }, { status: null as any }, { hold: true, intake: true }); - expectSuppressedWithControl( - { status: "awaiting-approval" as any, awaitingApprovalReason: "plan-review-replan-cap" as any }, - { status: "awaiting-approval" as any, awaitingApprovalReason: undefined }, - { hold: true }, - ); + /* + FNXC:TaskCardPromote 2026-08-11-09:13: + FN-8950 corrects the former control, which used pending Plan Review and therefore asserted the + defect. An omitted selection is default-on, so every positive control explicitly clears or + satisfies the plan gate. + */ + expectSuppressedWithControl("absent enabled-steps array (default-on)", { enabledWorkflowSteps: undefined, workflowStepResults: undefined }, { enabledWorkflowSteps: [] }, { hold: true }); + expectSuppressedWithControl("enabled plan-review with no results", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: undefined }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, { hold: true }); + expectSuppressedWithControl("enabled plan-review with empty results", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [] }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, { hold: true }); + expectSuppressedWithControl("enabled-not-started plan-review", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending" }] as any }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, { hold: true }); + expectSuppressedWithControl("running pending plan-review", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending", startedAt: "2026-08-11T00:00:00Z" }] as any }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, { hold: true }); + expectSuppressedWithControl("failed plan-review", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "failed" }] as any }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, { hold: true }); + expectSuppressedWithControl("advisory_failure plan-review", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "advisory_failure" }] as any }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, { hold: true }); + expectSuppressedWithControl("superseded passed plan-review", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ ...passedPlanReview[0], supersededAt: "2026-08-11T00:00:00Z" }] }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, { hold: true }); + expectSuppressedWithControl("unaudited skipped plan-review", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "skipped", bypassedFromStatus: "failed", bypassedFromVerdict: "REVISE", bypassedBy: "operator" }] as any }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: auditedSkippedPlanReview }, { hold: true }); + expectSuppressedWithControl("duplicate superseded plan-review", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ ...passedPlanReview[0], supersededAt: "2026-08-11T00:00:00Z" }, { workflowStepId: "code-review", workflowStepName: "Code Review", status: "passed" }] }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, { hold: true }); + expectSuppressedWithControl("plan-review disabled by another explicit group", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: undefined }, { enabledWorkflowSteps: ["code-review"] }, { hold: true }); + + // FNXC:TaskCardPromote 2026-08-11-09:13: The extra planning-stage statuses are conservative ledger arms; issueRelease does not literally reject them. + expectSuppressedWithControl("status specifying", { status: "specifying" as any }, { status: null as any }, { hold: true }); + expectSuppressedWithControl("status plan-review-unavailable", { status: "plan-review-unavailable" as any }, { status: null as any }, { hold: true }); + expectSuppressedWithControl("pause-shaped approval hold", { paused: true, pausedReason: "awaiting-approval" as any, status: null as any }, { paused: false, pausedReason: undefined, status: null as any }, { hold: true }); + + /* FNXC:TaskCardPromote 2026-08-11-09:13: Promote mirrors core's column-independent approval hold; intake remains only for approval badge and control rendering. */ + expectSuppressedWithControl("status awaiting-approval on a hold-only lane", { status: "awaiting-approval" as any }, { status: null as any }, { hold: true }); + expectSuppressedWithControl("awaiting-approval intake lane", { status: "awaiting-approval" as any }, { status: null as any }, { hold: true, intake: true }); + expectSuppressedWithControl("plan-review-replan-cap", { status: "awaiting-approval" as any, awaitingApprovalReason: "plan-review-replan-cap" as any }, { status: null as any, awaitingApprovalReason: undefined }, { hold: true }); + + // Repeat the gate and approval shapes for legacy fallback and merged planning trait resolution. + expectSuppressedWithControl("enabled-not-started plan-review no-metadata fallback", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending" }] as any }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, undefined); + expectSuppressedWithControl("enabled-not-started plan-review merged planning lane", { enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending" }] as any }, { enabledWorkflowSteps: ["plan-review"], workflowStepResults: passedPlanReview }, { hold: true, intake: true }); + expectSuppressedWithControl("status awaiting-approval no-metadata fallback", { status: "awaiting-approval" as any }, { status: null as any }, undefined); + expectSuppressedWithControl("status awaiting-approval merged planning lane", { status: "awaiting-approval" as any }, { status: null as any }, { hold: true, intake: true }); + + const pendingGate = makePromoteFixture({ enabledWorkflowSteps: ["plan-review"], workflowStepResults: undefined }); + const pendingGateRender = renderPromoteFixture(pendingGate, { hold: true }); + expect(screen.getByText("Ready")).toBeInTheDocument(); + pendingGateRender.unmount(); // Promote/cost-row CSS is media-query-only, so DOM absence covers desktop and mobile alike. - const pricedPlanning = makePromoteFixture({ awaitingPlanning: true, tokenUsage: { + const pricedPlanning = makePromoteFixture({ enabledWorkflowSteps: ["plan-review"], workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending" }] as any, tokenUsage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000, firstUsedAt: "2026-08-09T00:00:00Z", lastUsedAt: "2026-08-09T00:00:00Z", modelProvider: "openai", modelId: "gpt-5-mini", } }); @@ -8723,6 +8752,7 @@ describe("TaskCard trailing-row layout (FN-8631)", () => { id: `FN-cost-${width}`, column: "todo", awaitingPlanning: false, + enabledWorkflowSteps: [], steps: [{ name: "Implement", status: "pending" }] as any, tokenUsage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000, firstUsedAt: "2026-01-01T00:00:00Z", lastUsedAt: "2026-01-01T00:00:00Z", modelProvider: "openai", modelId: "gpt-5-mini" }, } as Partial)} diff --git a/packages/dashboard/app/utils/__tests__/reviewBudgetApproval.test.ts b/packages/dashboard/app/utils/__tests__/reviewBudgetApproval.test.ts new file mode 100644 index 0000000000..ce521b4302 --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/reviewBudgetApproval.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { Task } from "@fusion/core"; +import type { WorkflowStepResult } from "../../../../core/src/types/workflow/workflow-steps"; +import { isTaskBlockedOnApproval } from "../../../../core/src/merge/task-merge"; +import { isPlanReviewSatisfied } from "../../../../core/src/planner/plan-approval"; +import { isWorkflowOptionalGroupEnabled } from "../../../../core/src/workflows/workflow-optional-steps"; +import { isPlanReviewGateUnsatisfied, isTaskBlockedOnApprovalHold } from "../reviewBudgetApproval"; + +/* +FNXC:TaskCardPromote 2026-08-11-09:09: +FN-8950 deliberately duplicates core's browser-unsafe plan-approval rule. This pinning test is +the anti-drift mechanism: defaultOn=true encodes the built-in plan-review group's declaration, +and the approval mirror must agree with core without a column argument. It is excluded from the +gate-only failing-before demonstration because these dashboard helpers did not exist before it. +*/ +const planResult = (overrides: Partial = {}): WorkflowStepResult => ({ + workflowStepId: "plan-review", + workflowStepName: "Plan Review", + status: "pending", + ...overrides, +}); + +const gateTask = (workflowStepResults?: WorkflowStepResult[], enabledWorkflowSteps?: string[] | null) => ({ + enabledWorkflowSteps, + workflowStepResults, +}) as Pick; + +describe("FN-8950 plan-review gate contract", () => { + it.each([ + ["passed", planResult({ status: "passed" })], + ["superseded passed", planResult({ status: "passed", supersededAt: "2026-08-11T00:00:00.000Z" })], + ["audited skipped", planResult({ status: "skipped", bypassedFromStatus: "failed", bypassedFromVerdict: "REVISE", bypassedBy: "operator", bypassedAt: "2026-08-11T00:00:00.000Z", bypassReason: "review dispatch failed" })], + ["skipped missing source status", planResult({ status: "skipped", bypassedFromVerdict: "REVISE", bypassedBy: "operator", bypassedAt: "2026-08-11T00:00:00.000Z", bypassReason: "review dispatch failed" })], + ["skipped missing verdict", planResult({ status: "skipped", bypassedFromStatus: "failed", bypassedBy: "operator", bypassedAt: "2026-08-11T00:00:00.000Z", bypassReason: "review dispatch failed" })], + ["skipped missing actor", planResult({ status: "skipped", bypassedFromStatus: "failed", bypassedFromVerdict: "REVISE", bypassedAt: "2026-08-11T00:00:00.000Z", bypassReason: "review dispatch failed" })], + ["skipped missing time", planResult({ status: "skipped", bypassedFromStatus: "failed", bypassedFromVerdict: "REVISE", bypassedBy: "operator", bypassReason: "review dispatch failed" })], + ["skipped missing reason", planResult({ status: "skipped", bypassedFromStatus: "failed", bypassedFromVerdict: "REVISE", bypassedBy: "operator", bypassedAt: "2026-08-11T00:00:00.000Z" })], + ["failed", planResult({ status: "failed" })], + ["advisory failure", planResult({ status: "advisory_failure" })], + ["pending", planResult()], + ["running pending", planResult({ startedAt: "2026-08-11T00:00:00.000Z" })], + ["other step", planResult({ workflowStepId: "code-review", status: "passed" })], + ])("matches core satisfaction for %s", (_name, result) => { + expect(isPlanReviewGateUnsatisfied(gateTask([result], ["plan-review"]))).toBe(!isPlanReviewSatisfied(result)); + }); + + it.each([ + [undefined, true], + [null, true], + [[], false], + [["code-review"], false], + [["plan-review"], true], + [["plan-review", "code-review"], true], + ] as const)("matches core enablement for %j", (enabledWorkflowSteps, applicable) => { + expect(isWorkflowOptionalGroupEnabled(enabledWorkflowSteps ?? undefined, "plan-review", true)).toBe(applicable); + expect(isPlanReviewGateUnsatisfied(gateTask(undefined, enabledWorkflowSteps))).toBe(applicable); + }); + + it("treats an absent enabled-steps array as the default-on unsatisfied gate", () => { + expect(isPlanReviewGateUnsatisfied(gateTask())).toBe(true); + expect(isPlanReviewGateUnsatisfied(gateTask([], []))).toBe(false); + expect(isPlanReviewGateUnsatisfied(gateTask([], ["code-review"]))).toBe(false); + }); +}); + +describe("FN-8950 approval-hold contract", () => { + it.each([ + ["status without reason", { status: "awaiting-approval" }], + ["status with reason", { status: "awaiting-approval", paused: false, pausedReason: undefined }], + ["approval pause with null status", { status: null, paused: true, pausedReason: "awaiting-approval" }], + ["unrelated pause", { status: null, paused: true, pausedReason: "other" }], + ["neither shape", { status: null, paused: false, pausedReason: undefined }], + ])("matches core for %s", (_name, task) => { + const approvalTask = task as Pick; + expect(isTaskBlockedOnApprovalHold(approvalTask)).toBe(isTaskBlockedOnApproval(approvalTask)); + }); +}); diff --git a/packages/dashboard/app/utils/reviewBudgetApproval.ts b/packages/dashboard/app/utils/reviewBudgetApproval.ts index 8696a0e4ce..f448d348d1 100644 --- a/packages/dashboard/app/utils/reviewBudgetApproval.ts +++ b/packages/dashboard/app/utils/reviewBudgetApproval.ts @@ -20,3 +20,46 @@ export function isTaskAwaitingPlanApproval(task: Task, isIntakeColumn: boolean): return task.status === "awaiting-approval" && (isIntakeColumn || task.awaitingApprovalReason === "plan-review-replan-cap"); } + +/* +FNXC:TaskCardPromote 2026-08-11-09:09: +FN-8950 mirrors `isPlanReviewSatisfied`, `isWorkflowOptionalGroupEnabled`, and +`isTaskBlockedOnApproval` here because core's plan-approval module imports `node:crypto` at +module top and must not enter the browser bundle. The default-on fallback is load-bearing: +an absent enabled-steps array means the built-in default-on plan-review gate applies, rather +than that the gate is disabled. The contract test below prevents this deliberate duplication +from drifting. + +`isTaskBlockedOnApprovalHold` intentionally has no column argument. Unlike the intake-gated +approval-control predicate above, the server refuses both approval-hold shapes on every column. +*/ +export function isPlanReviewGateUnsatisfied( + task: Pick, + options?: { defaultOn?: boolean }, +): boolean { + const enabled = Array.isArray(task.enabledWorkflowSteps) + ? task.enabledWorkflowSteps.includes("plan-review") + : (options?.defaultOn ?? true); + if (!enabled) return false; + + return !task.workflowStepResults?.some((result) => { + if (result.workflowStepId !== "plan-review" || result.supersededAt != null) return false; + if (result.status === "passed") return true; + return result.status === "skipped" + && (result.bypassedFromStatus === "failed" || result.bypassedFromStatus === "advisory_failure") + && result.bypassedFromVerdict === "REVISE" + && typeof result.bypassedBy === "string" + && result.bypassedBy.trim().length > 0 + && typeof result.bypassedAt === "string" + && result.bypassedAt.trim().length > 0 + && typeof result.bypassReason === "string" + && result.bypassReason.trim().length > 0; + }); +} + +export function isTaskBlockedOnApprovalHold( + task: Pick, +): boolean { + return (task.paused === true && task.pausedReason === "awaiting-approval") + || task.status === "awaiting-approval"; +}