import type { ConvoyStatus, ConvoyTransitionErrorCode } from "./schemas"; export const VALID_TRANSITIONS: Record = { draft: ["convoy_review", "blocked", "cancelled"], convoy_review: ["ready", "draft", "blocked", "cancelled"], ready: ["in_progress", "blocked", "cancelled"], in_progress: ["pr_review", "blocked", "cancelled"], pr_review: ["merged", "in_progress", "blocked", "cancelled"], merged: ["released", "blocked"], released: [], blocked: [ "draft", "convoy_review", "ready", "in_progress", "pr_review", "merged", "cancelled", ], cancelled: [], }; type TransitionRule = { from: ConvoyStatus; to: ConvoyStatus; actors: readonly string[]; requiresReason?: boolean; }; /** Explicit (from, to, actor) allow-list — gate actors are never implied by pipeline roles. */ export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [ { from: "draft", to: "convoy_review", actors: ["role-architect"] }, { from: "draft", to: "blocked", actors: ["role-architect", "role-conductor", "human-gate-1", "automation"], requiresReason: true, }, { from: "draft", to: "cancelled", actors: ["human-gate-1", "role-conductor"], requiresReason: true, }, { from: "convoy_review", to: "ready", actors: ["human-gate-1"] }, { from: "convoy_review", to: "draft", actors: ["human-gate-1"] }, { from: "convoy_review", to: "blocked", actors: ["human-gate-1", "role-architect", "automation"], requiresReason: true, }, { from: "convoy_review", to: "cancelled", actors: ["human-gate-1"], requiresReason: true, }, { from: "ready", to: "in_progress", actors: ["role-implementer"] }, { from: "ready", to: "blocked", actors: ["role-implementer", "human-gate-1", "automation"], requiresReason: true, }, { from: "ready", to: "cancelled", actors: ["human-gate-1", "role-conductor"], requiresReason: true, }, { from: "in_progress", to: "pr_review", actors: ["role-implementer", "automation"] }, { from: "in_progress", to: "blocked", actors: ["role-implementer", "human-gate-2", "automation"], requiresReason: true, }, { from: "in_progress", to: "cancelled", actors: ["human-gate-1", "role-conductor"], requiresReason: true, }, { from: "pr_review", to: "merged", actors: ["human-gate-2"] }, { from: "pr_review", to: "in_progress", actors: ["human-gate-2", "role-implementer"] }, { from: "pr_review", to: "blocked", actors: ["human-gate-2", "automation"], requiresReason: true, }, { from: "pr_review", to: "cancelled", actors: ["human-gate-1", "human-gate-2"], requiresReason: true, }, { from: "merged", to: "released", actors: ["human-gate-3"] }, { from: "merged", to: "blocked", actors: ["human-gate-3", "automation"], requiresReason: true, }, { from: "blocked", to: "draft", actors: ["human-gate-1", "role-conductor", "automation"], requiresReason: true, }, { from: "blocked", to: "convoy_review", actors: ["human-gate-1", "role-conductor", "automation"], requiresReason: true, }, { from: "blocked", to: "ready", actors: ["human-gate-1", "role-conductor", "automation"], requiresReason: true, }, { from: "blocked", to: "in_progress", actors: ["human-gate-2", "role-conductor", "automation"], requiresReason: true, }, { from: "blocked", to: "pr_review", actors: ["human-gate-2", "role-conductor", "automation"], requiresReason: true, }, { from: "blocked", to: "merged", actors: ["human-gate-2", "role-conductor", "automation"], requiresReason: true, }, { from: "blocked", to: "cancelled", actors: ["human-gate-1", "human-gate-2", "human-gate-3", "role-conductor"], requiresReason: true, }, ]; export class ConvoyTransitionError extends Error { readonly code: ConvoyTransitionErrorCode; constructor(message: string, code: ConvoyTransitionErrorCode) { super(message); this.name = "ConvoyTransitionError"; this.code = code; } } export function validateConvoyTransition(input: { from: ConvoyStatus; to: ConvoyStatus; actor: string; reason?: string; }): void { const allowedTargets = VALID_TRANSITIONS[input.from]; if (!allowedTargets.includes(input.to)) { throw new ConvoyTransitionError( `Invalid transition ${input.from} → ${input.to}`, "INVALID_TRANSITION", ); } const rule = ALLOWED_TRANSITIONS.find( (candidate) => candidate.from === input.from && candidate.to === input.to, ); if (!rule) { throw new ConvoyTransitionError( `No actor may perform transition ${input.from} → ${input.to}`, "FORBIDDEN_ACTOR", ); } if (!rule.actors.includes(input.actor)) { throw new ConvoyTransitionError( `Actor ${input.actor} cannot perform transition ${input.from} → ${input.to}`, "FORBIDDEN_ACTOR", ); } if (rule.requiresReason && !input.reason?.trim()) { throw new ConvoyTransitionError( `Transition ${input.from} → ${input.to} requires a reason`, "REASON_REQUIRED", ); } } /** Map local `.convoys/*.md` frontmatter status to server enum for reconcile. */ export const FILE_STATUS_TO_SERVER: Record = { open: "draft", "in-progress": "in_progress", merged: "merged", shipped: "released", abandoned: "cancelled", }; export function mapFileStatusToServer(fileStatus: string): ConvoyStatus | null { return FILE_STATUS_TO_SERVER[fileStatus] ?? null; }