- 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
253 lines
6.7 KiB
TypeScript
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;
|
|
}
|