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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 04:00:16 -07:00
parent 637854ad36
commit 967e98194e
7 changed files with 217 additions and 37 deletions

View File

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

View File

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

View File

@@ -40,6 +40,8 @@ function taskWithUsage(overrides: Partial<Task> = {}): 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,

View File

@@ -88,6 +88,8 @@ function makeTask(overrides: Partial<Task> = {}): 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: {

View File

@@ -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(
<TaskCard
task={makeTask({ id: "FN-777", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
task={makeTask({ id: "FN-777", column: "todo", awaitingPlanning: false, enabledWorkflowSteps: [], steps: [{ name: "Implement", status: "pending" }] as any })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
@@ -7912,7 +7913,7 @@ describe("TaskCard mission badge", () => {
render(
<TaskCard
task={makeTask({ id: "FN-781", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
task={makeTask({ id: "FN-781", column: "todo", awaitingPlanning: false, enabledWorkflowSteps: [], steps: [{ name: "Implement", status: "pending" }] as any })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
@@ -7934,7 +7935,7 @@ describe("TaskCard mission badge", () => {
const soloRender = render(
<TaskCard
task={makeTask({ id: "FN-782", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
task={makeTask({ id: "FN-782", column: "todo", awaitingPlanning: false, enabledWorkflowSteps: [], steps: [{ name: "Implement", status: "pending" }] as any })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
@@ -7948,7 +7949,7 @@ describe("TaskCard mission badge", () => {
render(
<TaskCard
task={makeTask({ id: "FN-783", column: "in-review", paused: false, userPaused: false, prInfo: undefined as any, awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
task={makeTask({ id: "FN-783", column: "in-review", paused: false, userPaused: false, prInfo: undefined as any, awaitingPlanning: false, enabledWorkflowSteps: [], steps: [{ name: "Implement", status: "pending" }] as any })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
@@ -7973,7 +7974,7 @@ describe("TaskCard mission badge", () => {
render(
<TaskCard
task={makeTask({ id: "FN-778", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
task={makeTask({ id: "FN-778", column: "todo", awaitingPlanning: false, enabledWorkflowSteps: [], steps: [{ name: "Implement", status: "pending" }] as any })}
onOpenDetail={onOpenDetail}
addToast={noop}
onPromote={onPromote}
@@ -7991,7 +7992,7 @@ describe("TaskCard mission badge", () => {
render(
<TaskCard
task={makeTask({ id: "FN-779", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
task={makeTask({ id: "FN-779", column: "todo", awaitingPlanning: false, enabledWorkflowSteps: [], steps: [{ name: "Implement", status: "pending" }] as any })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
@@ -8010,12 +8011,14 @@ describe("TaskCard mission badge", () => {
it("suppresses Promote for every planning state while retaining the planned capacity-hold action", () => {
let sequence = 0;
const makePromoteFixture = (overrides: Partial<Task> = {}) => 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", () => {
<TaskCard task={task} taskColumnFlags={taskColumnFlags} onOpenDetail={noop} addToast={noop} onPromote={vi.fn().mockResolvedValue(undefined)} />
</CostBadgeProvider>,
);
const expectSuppressedWithControl = (planning: Partial<Task>, control: Partial<Task>, flags: any) => {
const expectSuppressedWithControl = (name: string, planning: Partial<Task>, control: Partial<Task>, 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<Task>)}

View File

@@ -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> = {}): WorkflowStepResult => ({
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "pending",
...overrides,
});
const gateTask = (workflowStepResults?: WorkflowStepResult[], enabledWorkflowSteps?: string[] | null) => ({
enabledWorkflowSteps,
workflowStepResults,
}) as Pick<Task, "enabledWorkflowSteps" | "workflowStepResults">;
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<Task, "paused" | "pausedReason" | "status">;
expect(isTaskBlockedOnApprovalHold(approvalTask)).toBe(isTaskBlockedOnApproval(approvalTask));
});
});

View File

@@ -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<Task, "enabledWorkflowSteps" | "workflowStepResults">,
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<Task, "paused" | "pausedReason" | "status">,
): boolean {
return (task.paused === true && task.pausedReason === "awaiting-approval")
|| task.status === "awaiting-approval";
}