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>
27 lines
1.7 KiB
TypeScript
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;
|
|
}
|