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 { 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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"
|
||||
&& (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