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:
7
.changeset/fn-8950-promote-plan-gate.md
Normal file
7
.changeset/fn-8950-promote-plan-gate.md
Normal 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.
|
||||||
@@ -58,7 +58,12 @@ import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity";
|
|||||||
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
||||||
import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
|
import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
|
||||||
import { getTaskStatusBadgeLabel, getTaskWipLifecycleBadgeLabel, type TaskStatusBadgeContext, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel";
|
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 { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
@@ -1511,6 +1516,10 @@ function TaskCardComponent({
|
|||||||
() => isPlanReviewRunning(task),
|
() => isPlanReviewRunning(task),
|
||||||
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults],
|
[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):
|
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
|
`getRunningOptionalGateBadge` takes resolved flags and BOTH ListView call sites supply them; this
|
||||||
@@ -1549,6 +1558,7 @@ function TaskCardComponent({
|
|||||||
*/
|
*/
|
||||||
const isPlanReviewReplanCapApproval = isReviewBudgetExhaustedApproval(task);
|
const isPlanReviewReplanCapApproval = isReviewBudgetExhaustedApproval(task);
|
||||||
const isAwaitingApproval = isTaskAwaitingPlanApproval(task, isIntakeColumn);
|
const isAwaitingApproval = isTaskAwaitingPlanApproval(task, isIntakeColumn);
|
||||||
|
const isBlockedOnApprovalHold = isTaskBlockedOnApprovalHold(task);
|
||||||
const isAwaitingInput = task.status === "awaiting-user-input";
|
const isAwaitingInput = task.status === "awaiting-user-input";
|
||||||
const isArchived = isArchivedColumn;
|
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.
|
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.
|
`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
|
const isStillInPlanning = awaitingPlanning
|
||||||
|| task.status === "planning"
|
|| ["planning", "specifying", "needs-replan", "plan-review-unavailable"].includes(task.status ?? "")
|
||||||
|| task.status === "needs-replan"
|
|| planReviewGateUnsatisfied
|
||||||
|| planReviewRunning
|
|| isAwaitingApproval
|
||||||
|| isAwaitingApproval;
|
|| isBlockedOnApprovalHold;
|
||||||
const showPromoteAction = Boolean(onPromote) && !isStillInPlanning;
|
const showPromoteAction = Boolean(onPromote) && !isStillInPlanning;
|
||||||
const showIdleTodoBadge = !isPaused
|
const showIdleTodoBadge = !isPaused
|
||||||
&& isHoldColumn
|
&& isHoldColumn
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ function taskWithUsage(overrides: Partial<Task> = {}): Task {
|
|||||||
column: "todo",
|
column: "todo",
|
||||||
steps: [{ name: "Implement", status: "pending" }] as any,
|
steps: [{ name: "Implement", status: "pending" }] as any,
|
||||||
awaitingPlanning: false,
|
awaitingPlanning: false,
|
||||||
|
// FNXC:TaskCardPromote 2026-08-11-09:13: This promote-visible fixture explicitly disables the default-on plan-review gate.
|
||||||
|
enabledWorkflowSteps: [],
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
tokenUsage: {
|
tokenUsage: {
|
||||||
inputTokens: 1_000_000,
|
inputTokens: 1_000_000,
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
status: "executing" as Task["status"],
|
status: "executing" as Task["status"],
|
||||||
steps: [],
|
steps: [],
|
||||||
|
// FNXC:TaskCardPromote 2026-08-11-09:13: Promote-visible footer fixtures explicitly clear the default-on plan-review gate.
|
||||||
|
enabledWorkflowSteps: [],
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
sourceType: "dashboard_ui",
|
sourceType: "dashboard_ui",
|
||||||
githubTracking: {
|
githubTracking: {
|
||||||
|
|||||||
@@ -6724,6 +6724,7 @@ describe("TaskCard", () => {
|
|||||||
id: "FN-8324",
|
id: "FN-8324",
|
||||||
column: "todo",
|
column: "todo",
|
||||||
awaitingPlanning: false,
|
awaitingPlanning: false,
|
||||||
|
enabledWorkflowSteps: [],
|
||||||
steps: [{ name: "Implement", status: "pending" }] as any,
|
steps: [{ name: "Implement", status: "pending" }] as any,
|
||||||
tokenUsage: {
|
tokenUsage: {
|
||||||
inputTokens: 1_000_000,
|
inputTokens: 1_000_000,
|
||||||
@@ -7881,7 +7882,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
try {
|
try {
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<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}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7912,7 +7913,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<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}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7934,7 +7935,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
const soloRender = render(
|
const soloRender = render(
|
||||||
<TaskCard
|
<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}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7948,7 +7949,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<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}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7973,7 +7974,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<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}
|
onOpenDetail={onOpenDetail}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7991,7 +7992,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<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}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -8010,12 +8011,14 @@ describe("TaskCard mission badge", () => {
|
|||||||
it("suppresses Promote for every planning state while retaining the planned capacity-hold action", () => {
|
it("suppresses Promote for every planning state while retaining the planned capacity-hold action", () => {
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
const makePromoteFixture = (overrides: Partial<Task> = {}) => makeTask({
|
const makePromoteFixture = (overrides: Partial<Task> = {}) => makeTask({
|
||||||
id: `FN-8907-${sequence++}`,
|
id: `FN-8950-${sequence++}`,
|
||||||
title: `Promote fixture ${sequence}`,
|
title: `Promote fixture ${sequence}`,
|
||||||
column: "todo",
|
column: "todo",
|
||||||
awaitingPlanning: false,
|
awaitingPlanning: false,
|
||||||
status: null as any,
|
status: null as any,
|
||||||
steps: [{ name: "Implement", status: "pending" }] 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,
|
...overrides,
|
||||||
});
|
});
|
||||||
const renderPromoteFixture = (task: Task, taskColumnFlags: any, cost = false) => render(
|
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)} />
|
<TaskCard task={task} taskColumnFlags={taskColumnFlags} onOpenDetail={noop} addToast={noop} onPromote={vi.fn().mockResolvedValue(undefined)} />
|
||||||
</CostBadgeProvider>,
|
</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 planningTask = makePromoteFixture(planning);
|
||||||
const suppressed = renderPromoteFixture(planningTask, flags);
|
const suppressed = renderPromoteFixture(planningTask, flags);
|
||||||
expect(screen.getByText(planningTask.title)).toBeInTheDocument();
|
expect(screen.getByText(planningTask.title), `${name}: card must render`).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId(`card-promote-${planningTask.id}`)).toBeNull();
|
// 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();
|
suppressed.unmount();
|
||||||
|
|
||||||
const controlTask = makePromoteFixture(control);
|
const controlTask = makePromoteFixture(control);
|
||||||
const positive = renderPromoteFixture(controlTask, flags);
|
const positive = renderPromoteFixture(controlTask, flags);
|
||||||
expect(screen.getByText(controlTask.title)).toBeInTheDocument();
|
expect(screen.getByText(controlTask.title), `${name}: control card must render`).toBeInTheDocument();
|
||||||
expect(screen.getByTestId(`card-promote-${controlTask.id}`)).toBeInTheDocument();
|
expect(screen.getByTestId(`card-promote-${controlTask.id}`), `${name}: control must keep Promote`).toBeInTheDocument();
|
||||||
positive.unmount();
|
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.
|
// Harness self-check: planned capacity-held cards must reach the rendered Promote branch.
|
||||||
const readyTask = makePromoteFixture();
|
const readyTask = makePromoteFixture();
|
||||||
@@ -8045,29 +8051,52 @@ describe("TaskCard mission badge", () => {
|
|||||||
expect(readyPromote).toHaveTextContent("Promote");
|
expect(readyPromote).toHaveTextContent("Promote");
|
||||||
ready.unmount();
|
ready.unmount();
|
||||||
|
|
||||||
expectSuppressedWithControl({ awaitingPlanning: true }, { awaitingPlanning: false }, { hold: true });
|
expectSuppressedWithControl("awaitingPlanning", { awaitingPlanning: true }, { awaitingPlanning: false }, { hold: true });
|
||||||
expectSuppressedWithControl({ awaitingPlanning: undefined, steps: [] }, { awaitingPlanning: undefined, steps: [{ name: "Implement", status: "pending" }] as any }, { hold: true });
|
expectSuppressedWithControl("empty steps", { 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("planning status", { status: "planning" as any }, { status: null as any }, { hold: true });
|
||||||
expectSuppressedWithControl({ status: "needs-replan" as any }, { status: null as any }, { hold: true });
|
expectSuppressedWithControl("needs-replan status", { 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);
|
|
||||||
|
|
||||||
/* isTaskAwaitingPlanApproval requires intake unless the replan-cap reason is set. */
|
/*
|
||||||
expectSuppressedWithControl({ status: "awaiting-approval" as any }, { status: null as any }, { hold: true, intake: true });
|
FNXC:TaskCardPromote 2026-08-11-09:13:
|
||||||
expectSuppressedWithControl(
|
FN-8950 corrects the former control, which used pending Plan Review and therefore asserted the
|
||||||
{ status: "awaiting-approval" as any, awaitingApprovalReason: "plan-review-replan-cap" as any },
|
defect. An omitted selection is default-on, so every positive control explicitly clears or
|
||||||
{ status: "awaiting-approval" as any, awaitingApprovalReason: undefined },
|
satisfies the plan gate.
|
||||||
{ hold: true },
|
*/
|
||||||
);
|
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.
|
// 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,
|
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",
|
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}`,
|
id: `FN-cost-${width}`,
|
||||||
column: "todo",
|
column: "todo",
|
||||||
awaitingPlanning: false,
|
awaitingPlanning: false,
|
||||||
|
enabledWorkflowSteps: [],
|
||||||
steps: [{ name: "Implement", status: "pending" }] as any,
|
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" },
|
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>)}
|
} as Partial<Task>)}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,3 +20,46 @@ export function isTaskAwaitingPlanApproval(task: Task, isIntakeColumn: boolean):
|
|||||||
return task.status === "awaiting-approval"
|
return task.status === "awaiting-approval"
|
||||||
&& (isIntakeColumn || task.awaitingApprovalReason === "plan-review-replan-cap");
|
&& (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";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user