Submatter/packages/shared/src/convoy-lifecycle/state-machine.ts
Randall Stillwell 6f139d6af2 feat(owner-cockpit): forge webhooks + artifacts + copilot (Epic 8/11/12)
- Forge webhook POST /api/webhooks/forge HMAC sha256 verify, marker echodo-convoy → pr_review→merged as automation
- Completion artifacts table 0017, deterministic summary template, gallery /artifacts
- Ops copilot tRPC copilot + context tools (fleet, convoy, backlog) + panel + Draft Task → backlog.createTask
- Doc-writer SOPs: webhook rotation, copilot prompt, published flow examples
- Allow pr_review→merged as automation for webhook

Convoy: .convoys/owner-cockpit-v02

Co-authored-by: orchestrator
2026-08-25 17:49:38 -05:00

253 lines
6.7 KiB
TypeScript

import type { ConvoyStatus, ConvoyTransitionErrorCode } from "./schemas";
export const VALID_TRANSITIONS: Record<ConvoyStatus, ConvoyStatus[]> = {
draft: ["convoy_review", "blocked", "cancelled"],
convoy_review: ["ready", "draft", "blocked", "cancelled"],
ready: ["in_progress", "blocked", "cancelled"],
in_progress: ["pr_review", "doc_review", "blocked", "cancelled"],
pr_review: ["merged", "in_progress", "blocked", "cancelled"],
doc_review: ["published", "in_progress", "blocked", "cancelled"],
merged: ["released", "blocked"],
published: ["released", "blocked"],
released: [],
blocked: [
"draft",
"convoy_review",
"ready",
"in_progress",
"pr_review",
"doc_review",
"merged",
"published",
"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: "doc_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", "automation"] },
{ 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: "doc_review", to: "published", actors: ["human-gate-2", "doc-approver"] },
{
from: "doc_review",
to: "in_progress",
actors: ["human-gate-2", "role-implementer", "doc-approver"],
},
{
from: "doc_review",
to: "blocked",
actors: ["human-gate-2", "automation", "doc-approver"],
requiresReason: true,
},
{
from: "doc_review",
to: "cancelled",
actors: ["human-gate-1", "human-gate-2", "doc-approver"],
requiresReason: true,
},
{ from: "merged", to: "released", actors: ["human-gate-3"] },
{
from: "merged",
to: "blocked",
actors: ["human-gate-3", "automation"],
requiresReason: true,
},
{ from: "published", to: "released", actors: ["human-gate-3"] },
{
from: "published",
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: "doc_review",
actors: ["human-gate-2", "role-conductor", "automation", "doc-approver"],
requiresReason: true,
},
{
from: "blocked",
to: "merged",
actors: ["human-gate-2", "role-conductor", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "published",
actors: ["human-gate-2", "role-conductor", "automation", "doc-approver"],
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<string, ConvoyStatus> = {
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;
}