Files
fusion/packages/dashboard/app/utils/releaseGate.ts
gsxdsm a6ce7f89ef FN-8987: expose release-gate verdicts for Promote visibility
Expose server-evaluated release-gate state so Promote controls accurately reflect approval readiness.

- Attach transient release-gate verdicts to task API responses.
- Preserve only fresh REST verdicts across task snapshots and render them in task cards.
- Align hold-release gating and document the Promote-state contract.

Files changed:
 .changeset/fn-8987-release-gate-verdict.md         |   7 +
 docs/dashboard-guide.md                            |   4 +
 docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md |   1 +
 docs/workflow-steps.md                             |   4 +
 packages/core/src/index.gate.ts                    |   2 +-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/types.ts                         |   2 +
 packages/core/src/types/task/task-core.ts          |  20 +++
 packages/dashboard/app/components/TaskCard.tsx     |   3 +-
 packages/dashboard/app/components/__tests__/TaskCard.test.tsx |  31 ++++
 packages/dashboard/app/hooks/__tests__/useTasks.test.ts |  26 ++++
 packages/dashboard/app/hooks/useTasks.ts           | 135 +++++++++++++++--
 packages/dashboard/app/utils/__tests__/releaseGate.contract.test.ts |  29 ++++
 packages/dashboard/app/utils/__tests__/releaseGate.test.ts |  49 ++++++
 packages/dashboard/app/utils/releaseGate.ts        |  26 ++++
 packages/dashboard/app/utils/reviewBudgetApproval.ts |   9 ++
 packages/dashboard/src/routes/register-task-workflow-routes.ts |  20 ++-
 packages/engine/src/__tests__/release-gate-verdict.test.ts |  40 +++++
 packages/engine/src/execution/hold-release.ts      | 166 +++++++++------------
 packages/engine/src/index.ts                       |   3 +
 20 files changed, 469 insertions(+), 110 deletions(-)

Fusion-Task-Id: FN-8987

Fusion-Task-Lineage: 6f1742bc-2b2b-4a32-9be5-92160335d90d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-11 14:22:14 -07:00

27 lines
1.7 KiB
TypeScript

import type { Task, TaskReleaseGateVerdict } from "@fusion/core";
export const RELEASE_GATE_VERDICT_MAX_AGE_MS = 30_000;
/** Stable browser-visible evidence; undefined and [] intentionally serialize differently. */
export function releaseGateEvidenceFingerprint(task: Pick<Task, "column" | "status" | "paused" | "pausedReason" | "workflowIrPin" | "workflowIrPinNodeId" | "enabledWorkflowSteps" | "workflowStepResults" | "steps">): string {
const review = (task.workflowStepResults ?? [])
.filter((entry) => entry.workflowStepId === "plan-review")
.map((entry) => [entry.workflowStepId, entry.status, entry.verdict ?? null, entry.supersededAt ?? null])
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
return JSON.stringify({
column: task.column, status: task.status ?? null, paused: task.paused ?? null, pausedReason: task.pausedReason ?? null,
workflowIrPin: task.workflowIrPin ?? null, workflowIrPinNodeId: task.workflowIrPinNodeId ?? null,
enabledWorkflowSteps: task.enabledWorkflowSteps === undefined ? "__undefined__" : [...task.enabledWorkflowSteps].sort(),
review, stepCount: task.steps?.length ?? 0,
});
}
export interface ReleaseGateProvenance { fingerprint: string; capturedAt: number; }
export function isReleaseGateVerdictFresh(verdict: TaskReleaseGateVerdict, task: Task, provenance: ReleaseGateProvenance | undefined, now: number): boolean {
if (!provenance || provenance.fingerprint !== releaseGateEvidenceFingerprint(task)) return false;
if (task.updatedAt && (!verdict.evaluatedForUpdatedAt || task.updatedAt > verdict.evaluatedForUpdatedAt)) return false;
const evaluatedAt = Date.parse(verdict.evaluatedAt);
return Number.isFinite(evaluatedAt) && now - evaluatedAt <= RELEASE_GATE_VERDICT_MAX_AGE_MS;
}