Ships BUILTIN_PR_WORKFLOW_IR (builtin:pr-workflow) wiring the lifecycle end to end: start -> pr-create -> await-review hold -> (changes-requested) pr-respond -[rework]-> await-review -> (approved) auto-merge gate -> pr-merge -> end, with conflict/failed holds and manual force-merge/close edges. Completes U6's validator side: validateV2 permits a top-level kind:rework edge whose loop-head opts in via config.reworkRegion (non- rework cycles still rejected). Additive (default builtin:coding untouched); behind workflowGraphExecutor. Legacy PrCommentHandler/PrMonitor retirement deferred (they remain the flag-off path's PR handling) — recorded in the plan. Fast e2e test; 41 core IR/builtin tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
917 lines
33 KiB
TypeScript
917 lines
33 KiB
TypeScript
import type {
|
|
WorkflowIr,
|
|
WorkflowIrColumn,
|
|
WorkflowIrEdge,
|
|
WorkflowIrNode,
|
|
WorkflowIrNodeKind,
|
|
WorkflowIrV1,
|
|
WorkflowIrV2,
|
|
WorkflowHoldRelease,
|
|
WorkflowForeachConfig,
|
|
WorkflowFieldDefinition,
|
|
WorkflowFieldType,
|
|
} from "./workflow-ir-types.js";
|
|
|
|
export class WorkflowIrError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "WorkflowIrError";
|
|
}
|
|
}
|
|
|
|
const HOLD_RELEASE_KINDS: ReadonlySet<WorkflowHoldRelease> = new Set([
|
|
"manual",
|
|
"timer",
|
|
"capacity",
|
|
"dependency",
|
|
"external-event",
|
|
]);
|
|
|
|
/** Seam config values that may not appear inside a parallel branch (KTD-11):
|
|
* one worktree/session per task and exclusive merge are physical constraints.
|
|
* Step-inversion (KTD-4) extends this posture: `step-execute` seam prompt nodes
|
|
* may never appear in a split branch either. */
|
|
const SEAM_FORBIDDEN_IN_BRANCH: ReadonlySet<string> = new Set([
|
|
"execute",
|
|
"merge",
|
|
"step-execute",
|
|
]);
|
|
|
|
/** Step-inversion field-type whitelist (KTD-13). */
|
|
const WORKFLOW_FIELD_TYPES: ReadonlySet<WorkflowFieldType> = new Set([
|
|
"string",
|
|
"text",
|
|
"number",
|
|
"boolean",
|
|
"enum",
|
|
"multi-enum",
|
|
"date",
|
|
"url",
|
|
]);
|
|
|
|
const FIELD_RENDER_PLACEMENTS: ReadonlySet<string> = new Set([
|
|
"card",
|
|
"detail",
|
|
"detail-section",
|
|
]);
|
|
|
|
const FIELD_RENDER_WIDGETS: ReadonlySet<string> = new Set([
|
|
"select",
|
|
"radio",
|
|
"chips",
|
|
"input",
|
|
"textarea",
|
|
"toggle",
|
|
]);
|
|
|
|
/** Hard cap on a foreach `maxReworkCycles` (KTD-5: default 3, clamp >10 to 10,
|
|
* reject <1). */
|
|
const MAX_REWORK_CYCLES_CAP = 10;
|
|
|
|
/** Parallel concurrency bounds (KTD-3): range 1..8. */
|
|
const MAX_FOREACH_CONCURRENCY = 8;
|
|
|
|
/** The implicit step-source artifact allowed when no artifacts are declared. */
|
|
const IMPLICIT_DEFAULT_ARTIFACT = "PROMPT.md";
|
|
|
|
/** True when a prompt node carries the `step-execute` seam (KTD-2/KTD-4). */
|
|
function isStepExecuteNode(node: WorkflowIrNode): boolean {
|
|
return node.kind === "prompt" && node.config?.seam === "step-execute";
|
|
}
|
|
|
|
/** Default-workflow column ids in legacy enum order (KTD-1). */
|
|
export const DEFAULT_WORKFLOW_COLUMN_IDS = [
|
|
"triage",
|
|
"todo",
|
|
"in-progress",
|
|
"in-review",
|
|
"done",
|
|
"archived",
|
|
] as const;
|
|
|
|
/** Place a v1 node into a synthesized default-workflow column by its seam. */
|
|
function defaultColumnForNode(node: WorkflowIrNode): string {
|
|
const seam = node.config?.seam;
|
|
if (seam === "execute") return "in-progress";
|
|
if (seam === "review") return "in-review";
|
|
if (seam === "merge") return "in-review";
|
|
return "todo";
|
|
}
|
|
|
|
/** The synthesized default-workflow columns used when upgrading a v1 graph. The
|
|
* trait set here is intentionally minimal (placement only); the full default
|
|
* workflow with traits is BUILTIN_CODING_WORKFLOW_IR. */
|
|
function synthesizeDefaultColumns(): WorkflowIrColumn[] {
|
|
return DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] }));
|
|
}
|
|
|
|
/** Upgrade a v1 graph to v2 by synthesizing default columns and placing nodes
|
|
* by their seam (execute→in-progress, review/merge→in-review, others→todo). */
|
|
function upgradeV1ToV2(ir: WorkflowIrV1): WorkflowIrV2 {
|
|
return {
|
|
version: "v2",
|
|
name: ir.name,
|
|
columns: synthesizeDefaultColumns(),
|
|
nodes: ir.nodes.map((node) =>
|
|
node.column ? node : { ...node, column: defaultColumnForNode(node) },
|
|
),
|
|
edges: ir.edges,
|
|
};
|
|
}
|
|
|
|
function buildOutgoing(edges: WorkflowIrEdge[]): Map<string, WorkflowIrEdge[]> {
|
|
const outgoing = new Map<string, WorkflowIrEdge[]>();
|
|
for (const edge of edges) {
|
|
const list = outgoing.get(edge.from);
|
|
if (list) list.push(edge);
|
|
else outgoing.set(edge.from, [edge]);
|
|
}
|
|
return outgoing;
|
|
}
|
|
|
|
function seamOf(node: WorkflowIrNode): string | undefined {
|
|
const seam = node.config?.seam;
|
|
return typeof seam === "string" ? seam : undefined;
|
|
}
|
|
|
|
/**
|
|
* Validate `split`/`join` parallelism (KTD-11):
|
|
* - every split has a reachable matching join (recursively for nested splits);
|
|
* - execute/merge seam nodes inside a branch reject (seam-in-branch);
|
|
* - join `quorum(n)` with n exceeding the split's branch count rejects.
|
|
*/
|
|
function validateParallelism(
|
|
nodes: WorkflowIrNode[],
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
nodesById: Map<string, WorkflowIrNode>,
|
|
): void {
|
|
const splits = nodes.filter((n) => n.kind === "split");
|
|
|
|
for (const split of splits) {
|
|
const branchEdges = outgoing.get(split.id) ?? [];
|
|
if (branchEdges.length < 2) {
|
|
throw new WorkflowIrError(`split '${split.id}' must fan out into at least two branches`);
|
|
}
|
|
|
|
// Walk each branch forward until the matching join is reached. Track join
|
|
// hit-counts and ensure every branch reaches the SAME join (nested splits
|
|
// resolve to their own join first, so balanced nesting still terminates).
|
|
const joinsReached = new Set<string>();
|
|
for (const edge of branchEdges) {
|
|
const join = walkBranchToJoin(edge.to, split.id, outgoing, nodesById);
|
|
if (!join) {
|
|
throw new WorkflowIrError(`split '${split.id}' has a branch with no reachable matching join`);
|
|
}
|
|
joinsReached.add(join);
|
|
}
|
|
if (joinsReached.size !== 1) {
|
|
throw new WorkflowIrError(`split '${split.id}' branches converge on more than one join`);
|
|
}
|
|
const joinId = [...joinsReached][0];
|
|
const join = nodesById.get(joinId)!;
|
|
|
|
const mode = join.config?.mode;
|
|
if (mode && typeof mode === "object" && "quorum" in mode) {
|
|
const n = (mode as { quorum: unknown }).quorum;
|
|
if (typeof n !== "number" || !Number.isInteger(n) || n < 1) {
|
|
throw new WorkflowIrError(`join '${join.id}' quorum must be a positive integer`);
|
|
}
|
|
if (n > branchEdges.length) {
|
|
throw new WorkflowIrError(
|
|
`join '${join.id}' quorum(${n}) exceeds the split's ${branchEdges.length} branches`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Walk a single branch from `startNodeId` until a `join` node is reached.
|
|
* Rejects execute/merge seam nodes encountered inside the branch. Handles one
|
|
* level of nesting by recursing through inner splits to their inner join. */
|
|
function walkBranchToJoin(
|
|
startNodeId: string,
|
|
ownerSplitId: string,
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
nodesById: Map<string, WorkflowIrNode>,
|
|
): string | undefined {
|
|
const visited = new Set<string>();
|
|
let cursor: string | undefined = startNodeId;
|
|
while (cursor && !visited.has(cursor)) {
|
|
visited.add(cursor);
|
|
const node = nodesById.get(cursor);
|
|
if (!node) return undefined;
|
|
|
|
if (node.kind === "join") return node.id;
|
|
|
|
if (node.kind === "split") {
|
|
// Nested split: resolve to its inner join, then continue from there.
|
|
const inner = (outgoing.get(node.id) ?? [])
|
|
.map((e) => walkBranchToJoin(e.to, node.id, outgoing, nodesById))
|
|
.find(Boolean);
|
|
if (!inner) return undefined;
|
|
cursor = innerJoinNext(inner, outgoing);
|
|
continue;
|
|
}
|
|
|
|
const seam = seamOf(node);
|
|
if (seam && SEAM_FORBIDDEN_IN_BRANCH.has(seam)) {
|
|
throw new WorkflowIrError(
|
|
`seam '${seam}' node '${node.id}' is forbidden inside a parallel branch of split '${ownerSplitId}'`,
|
|
);
|
|
}
|
|
|
|
const next = (outgoing.get(cursor) ?? []).find((e) => e.condition !== "failure");
|
|
cursor = next?.to;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** The node following a join along its (non-failure) outgoing edge. */
|
|
function innerJoinNext(joinId: string, outgoing: Map<string, WorkflowIrEdge[]>): string | undefined {
|
|
return (outgoing.get(joinId) ?? []).find((e) => e.condition !== "failure")?.to;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step-inversion validation (FN step-inversion, U1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** True for a `rework`-kind edge (KTD-5). */
|
|
function isReworkEdge(edge: WorkflowIrEdge): boolean {
|
|
return edge.kind === "rework";
|
|
}
|
|
|
|
/** Collect the set of node ids reachable from `start` following non-rework edges
|
|
* (rework edges are intra-template back-edges; the top-level reachability /
|
|
* dominance analysis ignores them). */
|
|
function reachableFrom(
|
|
start: string,
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
): Set<string> {
|
|
const seen = new Set<string>();
|
|
const queue = [start];
|
|
while (queue.length) {
|
|
const id = queue.shift()!;
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
for (const edge of outgoing.get(id) ?? []) {
|
|
if (isReworkEdge(edge)) continue;
|
|
if (!seen.has(edge.to)) queue.push(edge.to);
|
|
}
|
|
}
|
|
return seen;
|
|
}
|
|
|
|
/**
|
|
* Validate a foreach `template` subgraph recursively (KTD-3):
|
|
* - non-empty;
|
|
* - exactly one entry (no incoming template edges) and one exit (no outgoing);
|
|
* - NO nested foreach;
|
|
* - `step-execute` seam nodes are legal here but never inside a split branch
|
|
* (SEAM_FORBIDDEN_IN_BRANCH already enforces this via validateParallelism);
|
|
* - rework edges legal only when both endpoints are inside this template;
|
|
* - step-review verdict routing rules (KTD-4).
|
|
*/
|
|
function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set<string>): void {
|
|
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
|
|
if (!cfg || cfg.source !== "task-steps") {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' must declare source 'task-steps'`,
|
|
);
|
|
}
|
|
const template = cfg.template;
|
|
if (
|
|
!template ||
|
|
!Array.isArray(template.nodes) ||
|
|
!Array.isArray(template.edges)
|
|
) {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' must declare a template with nodes and edges arrays`,
|
|
);
|
|
}
|
|
if (template.nodes.length === 0) {
|
|
throw new WorkflowIrError(`foreach node '${node.id}' template must be non-empty`);
|
|
}
|
|
|
|
// mode / isolation / concurrency (KTD-3).
|
|
const mode = cfg.mode ?? "sequential";
|
|
if (mode !== "sequential" && mode !== "parallel") {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' mode must be 'sequential' or 'parallel'`,
|
|
);
|
|
}
|
|
const isolation = cfg.isolation ?? (mode === "parallel" ? "worktree" : "shared");
|
|
if (isolation !== "shared" && isolation !== "worktree") {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' isolation must be 'shared' or 'worktree'`,
|
|
);
|
|
}
|
|
if (mode === "parallel" && isolation === "shared") {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' cannot combine mode 'parallel' with isolation 'shared' (concurrent writes in one worktree are unguardable races)`,
|
|
);
|
|
}
|
|
if (cfg.concurrency !== undefined) {
|
|
if (mode !== "parallel") {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' concurrency is only valid in 'parallel' mode`,
|
|
);
|
|
}
|
|
const c = cfg.concurrency;
|
|
if (typeof c !== "number" || !Number.isInteger(c) || c < 1 || c > MAX_FOREACH_CONCURRENCY) {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' concurrency must be an integer in 1..${MAX_FOREACH_CONCURRENCY}`,
|
|
);
|
|
}
|
|
}
|
|
if (cfg.maxReworkCycles !== undefined) {
|
|
const m = cfg.maxReworkCycles;
|
|
if (typeof m !== "number" || !Number.isInteger(m) || m < 1) {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' maxReworkCycles must be an integer >= 1`,
|
|
);
|
|
}
|
|
// >10 is clamped at parse time (clampForeachConfig); validation only rejects <1.
|
|
}
|
|
|
|
const templateNodes = template.nodes;
|
|
const templateIds = new Set(templateNodes.map((n) => n.id));
|
|
if (templateIds.size !== templateNodes.length) {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' template has duplicate node ids`,
|
|
);
|
|
}
|
|
|
|
// No nested foreach.
|
|
for (const inner of templateNodes) {
|
|
if (inner.kind === "foreach") {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Edge endpoints must reference template nodes; rework edges must stay intra-template.
|
|
for (const edge of template.edges) {
|
|
const fromInside = templateIds.has(edge.from);
|
|
const toInside = templateIds.has(edge.to);
|
|
if (!fromInside || !toInside) {
|
|
if (isReworkEdge(edge)) {
|
|
throw new WorkflowIrError(
|
|
`rework edge '${edge.from}' -> '${edge.to}' in foreach '${node.id}' must have both endpoints inside the same template`,
|
|
);
|
|
}
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Single entry / single exit (ignoring rework back-edges, which intentionally
|
|
// create incoming edges to earlier template nodes).
|
|
const incoming = new Map<string, number>();
|
|
const outgoingCount = new Map<string, number>();
|
|
for (const edge of template.edges) {
|
|
if (isReworkEdge(edge)) continue;
|
|
incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
|
|
outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1);
|
|
}
|
|
const entries = templateNodes.filter((n) => (incoming.get(n.id) ?? 0) === 0);
|
|
const exits = templateNodes.filter((n) => (outgoingCount.get(n.id) ?? 0) === 0);
|
|
if (entries.length !== 1) {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' template must have exactly one entry node (found ${entries.length})`,
|
|
);
|
|
}
|
|
if (exits.length !== 1) {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' template must have exactly one exit node (found ${exits.length})`,
|
|
);
|
|
}
|
|
|
|
// Recurse: validate the template as its own region for parallelism + verdict
|
|
// routing. step-execute nodes legal here (they are not validated as forbidden
|
|
// at top level — that check lives in validateStepExecutePlacement).
|
|
const templateById = new Map(templateNodes.map((n) => [n.id, n]));
|
|
const templateOutgoing = buildOutgoing(template.edges);
|
|
validateParallelism(templateNodes, templateOutgoing, templateById);
|
|
validateStepReviewRouting(templateNodes, templateOutgoing, templateById, true);
|
|
|
|
// Defensive: top-level node ids and template node ids should not collide
|
|
// (instance identity is `<foreachId>#<i>:<templateNodeId>`, but a raw collision
|
|
// is still confusing).
|
|
for (const id of templateIds) {
|
|
if (topLevelNodeIds.has(id)) {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' template node id '${id}' collides with a top-level node id`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** step-execute seam nodes are legal ONLY inside a foreach template (KTD-4):
|
|
* reject any at the top level. (Inside-split-branch rejection is handled by
|
|
* SEAM_FORBIDDEN_IN_BRANCH within validateParallelism.) */
|
|
function validateStepExecutePlacement(topLevelNodes: WorkflowIrNode[]): void {
|
|
for (const node of topLevelNodes) {
|
|
if (isStepExecuteNode(node)) {
|
|
throw new WorkflowIrError(
|
|
`step-execute seam node '${node.id}' is only legal inside a foreach template`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* step-review verdict routing (KTD-4). For each step-review node:
|
|
* - it must have outgoing edges covering `outcome:approve` and `outcome:revise`;
|
|
* - `outcome:rethink` optional (defaults to the revise target with reset semantics);
|
|
* - `outcome:unavailable` optional;
|
|
* - a step-review node inside a split branch is advisory-only: it must NOT carry
|
|
* rework or `outcome:approve` routing.
|
|
*/
|
|
function validateStepReviewRouting(
|
|
nodes: WorkflowIrNode[],
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
nodesById: Map<string, WorkflowIrNode>,
|
|
insideForeachTemplate: boolean,
|
|
): void {
|
|
// Determine which nodes sit inside a split branch (advisory-only zone).
|
|
const inBranch = nodesInSplitBranches(nodes, outgoing, nodesById);
|
|
|
|
for (const node of nodes) {
|
|
if (node.kind !== "step-review") continue;
|
|
if (node.config?.type !== "plan" && node.config?.type !== "code") {
|
|
throw new WorkflowIrError(
|
|
`step-review node '${node.id}' must declare type 'plan' or 'code'`,
|
|
);
|
|
}
|
|
if (node.config.model !== undefined && typeof node.config.model !== "string") {
|
|
throw new WorkflowIrError(
|
|
`step-review node '${node.id}' model must be a string when present`,
|
|
);
|
|
}
|
|
|
|
const edges = outgoing.get(node.id) ?? [];
|
|
const conditions = new Set(edges.map((e) => e.condition));
|
|
const hasRework = edges.some(isReworkEdge);
|
|
|
|
if (inBranch.has(node.id)) {
|
|
// Advisory-only inside a split branch: no rework, no approve routing.
|
|
if (hasRework) {
|
|
throw new WorkflowIrError(
|
|
`step-review node '${node.id}' inside a split branch is advisory-only and may not have rework edges`,
|
|
);
|
|
}
|
|
if (conditions.has("outcome:approve")) {
|
|
throw new WorkflowIrError(
|
|
`step-review node '${node.id}' inside a split branch is advisory-only and may not carry outcome:approve routing`,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Main-path step-review: must route approve and revise.
|
|
if (!conditions.has("outcome:approve")) {
|
|
throw new WorkflowIrError(
|
|
`step-review node '${node.id}' must route outcome:approve`,
|
|
);
|
|
}
|
|
if (!conditions.has("outcome:revise")) {
|
|
throw new WorkflowIrError(
|
|
`step-review node '${node.id}' must route outcome:revise`,
|
|
);
|
|
}
|
|
void insideForeachTemplate;
|
|
}
|
|
}
|
|
|
|
/** Compute the set of node ids that lie strictly inside some split..join branch
|
|
* region. Walks each split's branches forward to the join. Lightweight; used
|
|
* for the step-review advisory-only rule. */
|
|
function nodesInSplitBranches(
|
|
nodes: WorkflowIrNode[],
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
nodesById: Map<string, WorkflowIrNode>,
|
|
): Set<string> {
|
|
const inBranch = new Set<string>();
|
|
const splits = nodes.filter((n) => n.kind === "split");
|
|
for (const split of splits) {
|
|
for (const edge of outgoing.get(split.id) ?? []) {
|
|
let cursor: string | undefined = edge.to;
|
|
const visited = new Set<string>();
|
|
while (cursor && !visited.has(cursor)) {
|
|
const id: string = cursor;
|
|
visited.add(id);
|
|
const n = nodesById.get(id);
|
|
if (!n || n.kind === "join") break;
|
|
inBranch.add(id);
|
|
const next: WorkflowIrEdge | undefined = (outgoing.get(id) ?? []).find(
|
|
(e) => !isReworkEdge(e) && e.condition !== "failure",
|
|
);
|
|
cursor = next?.to;
|
|
}
|
|
}
|
|
}
|
|
return inBranch;
|
|
}
|
|
|
|
/**
|
|
* Cycle detection across the top-level graph that EXEMPTS rework edges (KTD-5).
|
|
* Any non-rework cycle is rejected; rework edges (intra-template back-edges) are
|
|
* skipped. Run over the top-level graph; template internals are validated
|
|
* separately.
|
|
*/
|
|
function validateNoIllegalCycles(
|
|
nodes: WorkflowIrNode[],
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
): void {
|
|
const WHITE = 0;
|
|
const GRAY = 1;
|
|
const BLACK = 2;
|
|
const color = new Map<string, number>();
|
|
for (const n of nodes) color.set(n.id, WHITE);
|
|
|
|
const visit = (id: string): void => {
|
|
color.set(id, GRAY);
|
|
for (const edge of outgoing.get(id) ?? []) {
|
|
if (isReworkEdge(edge)) continue;
|
|
const c = color.get(edge.to);
|
|
if (c === GRAY) {
|
|
throw new WorkflowIrError(
|
|
`Workflow IR has an illegal cycle (edge '${edge.from}' -> '${edge.to}'); only rework edges may form cycles`,
|
|
);
|
|
}
|
|
if (c === WHITE) visit(edge.to);
|
|
}
|
|
color.set(id, BLACK);
|
|
};
|
|
|
|
for (const n of nodes) {
|
|
if (color.get(n.id) === WHITE) visit(n.id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dominance check (KTD-3): every `foreach(source:"task-steps")` must be dominated
|
|
* by a `parse-steps` node — a parse-steps node lies on EVERY path from start to
|
|
* the foreach. Implemented via the classic "removal disconnects start from
|
|
* target" definition, which is correct for DAGs: for each parse-steps node,
|
|
* check whether the foreach is still reachable from start with that node removed.
|
|
* The foreach is dominated iff some parse-steps node's removal disconnects it.
|
|
*/
|
|
function validateForeachDominance(
|
|
nodes: WorkflowIrNode[],
|
|
edges: WorkflowIrEdge[],
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
): void {
|
|
const startNode = nodes.find((n) => n.kind === "start");
|
|
if (!startNode) return; // parse-time guarantees exactly one start.
|
|
const foreaches = nodes.filter(
|
|
(n) => n.kind === "foreach" && (n.config as { source?: unknown } | undefined)?.source === "task-steps",
|
|
);
|
|
if (foreaches.length === 0) return;
|
|
const parseStepsNodes = nodes.filter((n) => n.kind === "parse-steps");
|
|
|
|
for (const fe of foreaches) {
|
|
// Reachable from start at all?
|
|
if (!reachableFrom(startNode.id, outgoing).has(fe.id)) {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${fe.id}' is not reachable from the start node`,
|
|
);
|
|
}
|
|
const dominated = parseStepsNodes.some((ps) => {
|
|
if (ps.id === fe.id) return false;
|
|
// Build outgoing with ps removed (as both source and target).
|
|
const trimmed = buildOutgoing(
|
|
edges.filter((e) => e.from !== ps.id && e.to !== ps.id),
|
|
);
|
|
return !reachableFrom(startNode.id, trimmed).has(fe.id);
|
|
});
|
|
if (!dominated) {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${fe.id}' (source:'task-steps') must be dominated by a parse-steps node on every path from start`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Validate `parse-steps` node config (KTD-12). */
|
|
function validateParseStepsNodes(ir: WorkflowIrV2): void {
|
|
const declaredArtifacts = new Set((ir.artifacts ?? []).map((a) => a.key));
|
|
const hasDeclaredArtifacts = (ir.artifacts ?? []).length > 0;
|
|
|
|
for (const node of ir.nodes) {
|
|
if (node.kind !== "parse-steps") continue;
|
|
const cfg = node.config as { artifact?: unknown; parser?: unknown } | undefined;
|
|
const artifact = cfg?.artifact;
|
|
const parser = cfg?.parser;
|
|
if (typeof parser !== "string" || parser.trim() === "") {
|
|
throw new WorkflowIrError(
|
|
`parse-steps node '${node.id}' must declare a non-empty parser`,
|
|
);
|
|
}
|
|
if (typeof artifact !== "string" || artifact.trim() === "") {
|
|
throw new WorkflowIrError(
|
|
`parse-steps node '${node.id}' must declare a non-empty artifact`,
|
|
);
|
|
}
|
|
if (hasDeclaredArtifacts) {
|
|
if (!declaredArtifacts.has(artifact)) {
|
|
throw new WorkflowIrError(
|
|
`parse-steps node '${node.id}' references undeclared artifact '${artifact}'`,
|
|
);
|
|
}
|
|
} else if (artifact !== IMPLICIT_DEFAULT_ARTIFACT) {
|
|
throw new WorkflowIrError(
|
|
`parse-steps node '${node.id}' references artifact '${artifact}', but only '${IMPLICIT_DEFAULT_ARTIFACT}' is allowed when no artifacts are declared`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Validate `code` node config (KTD-15). TS is NOT compiled in core (esbuild
|
|
* check is engine/editor side). */
|
|
function validateCodeNodes(nodes: WorkflowIrNode[]): void {
|
|
const MAX_SOURCE = 65536;
|
|
for (const node of nodes) {
|
|
if (node.kind !== "code") continue;
|
|
const cfg = node.config as { source?: unknown; timeoutMs?: unknown } | undefined;
|
|
const source = cfg?.source;
|
|
if (typeof source !== "string" || source.length === 0) {
|
|
throw new WorkflowIrError(`code node '${node.id}' must declare a non-empty source`);
|
|
}
|
|
if (source.length > MAX_SOURCE) {
|
|
throw new WorkflowIrError(
|
|
`code node '${node.id}' source exceeds ${MAX_SOURCE} characters`,
|
|
);
|
|
}
|
|
if (cfg?.timeoutMs !== undefined) {
|
|
const t = cfg.timeoutMs;
|
|
if (typeof t !== "number" || !Number.isInteger(t) || t < 1000 || t > 300000) {
|
|
throw new WorkflowIrError(
|
|
`code node '${node.id}' timeoutMs must be an integer in 1000..300000`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Validate `fields` declarations (KTD-13). */
|
|
function validateFields(fields: WorkflowFieldDefinition[] | undefined): void {
|
|
if (fields === undefined) return;
|
|
if (!Array.isArray(fields)) {
|
|
throw new WorkflowIrError("Workflow IR fields must be an array");
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const field of fields) {
|
|
if (!field || typeof field.id !== "string" || field.id === "") {
|
|
throw new WorkflowIrError("Workflow field must have a non-empty id");
|
|
}
|
|
if (seen.has(field.id)) {
|
|
throw new WorkflowIrError(`Workflow IR has duplicate field id '${field.id}'`);
|
|
}
|
|
seen.add(field.id);
|
|
if (typeof field.name !== "string" || field.name === "") {
|
|
throw new WorkflowIrError(`Workflow field '${field.id}' must have a non-empty name`);
|
|
}
|
|
if (!WORKFLOW_FIELD_TYPES.has(field.type)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow field '${field.id}' has unknown type '${String(field.type)}'`,
|
|
);
|
|
}
|
|
const isEnum = field.type === "enum" || field.type === "multi-enum";
|
|
if (isEnum) {
|
|
if (!Array.isArray(field.options) || field.options.length === 0) {
|
|
throw new WorkflowIrError(
|
|
`Workflow field '${field.id}' of type '${field.type}' must declare non-empty options`,
|
|
);
|
|
}
|
|
const optSeen = new Set<string>();
|
|
for (const opt of field.options) {
|
|
if (!opt || typeof opt.value !== "string" || opt.value === "") {
|
|
throw new WorkflowIrError(
|
|
`Workflow field '${field.id}' option must have a non-empty value`,
|
|
);
|
|
}
|
|
if (typeof opt.label !== "string" || opt.label === "") {
|
|
throw new WorkflowIrError(
|
|
`Workflow field '${field.id}' option '${opt.value}' must have a non-empty label`,
|
|
);
|
|
}
|
|
if (optSeen.has(opt.value)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow field '${field.id}' has duplicate option value '${opt.value}'`,
|
|
);
|
|
}
|
|
optSeen.add(opt.value);
|
|
}
|
|
} else if (field.options !== undefined) {
|
|
throw new WorkflowIrError(
|
|
`Workflow field '${field.id}' of type '${field.type}' must not declare options`,
|
|
);
|
|
}
|
|
if (field.render !== undefined) {
|
|
const r = field.render;
|
|
if (r.placement !== undefined && !FIELD_RENDER_PLACEMENTS.has(r.placement)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow field '${field.id}' render.placement '${String(r.placement)}' is not allowed`,
|
|
);
|
|
}
|
|
if (r.widget !== undefined && !FIELD_RENDER_WIDGETS.has(r.widget)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow field '${field.id}' render.widget '${String(r.widget)}' is not allowed`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateColumns(ir: WorkflowIrV2): void {
|
|
if (!Array.isArray(ir.columns)) {
|
|
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const column of ir.columns) {
|
|
if (!column || typeof column.id !== "string" || !column.id) {
|
|
throw new WorkflowIrError("Workflow IR column must have a non-empty id");
|
|
}
|
|
if (seen.has(column.id)) {
|
|
throw new WorkflowIrError(`Workflow IR has duplicate column id '${column.id}'`);
|
|
}
|
|
seen.add(column.id);
|
|
if (!Array.isArray(column.traits)) {
|
|
throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateV2(ir: WorkflowIrV2): void {
|
|
validateColumns(ir);
|
|
|
|
const columnIds = new Set(ir.columns.map((c) => c.id));
|
|
const nodesById = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
|
|
for (const node of ir.nodes) {
|
|
if (node.column !== undefined && !columnIds.has(node.column)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow node '${node.id}' references undefined column '${node.column}'`,
|
|
);
|
|
}
|
|
if (node.kind === "hold") {
|
|
const release = node.config?.release;
|
|
if (!HOLD_RELEASE_KINDS.has(release as WorkflowHoldRelease)) {
|
|
throw new WorkflowIrError(
|
|
`hold node '${node.id}' has unknown release kind '${String(release)}'`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const outgoing = buildOutgoing(ir.edges);
|
|
validateParallelism(ir.nodes, outgoing, nodesById);
|
|
|
|
// Step-inversion (U1) — additive validation. Order matters: validate node
|
|
// configs first, then structural rules.
|
|
const topLevelIds = new Set(ir.nodes.map((n) => n.id));
|
|
validateStepExecutePlacement(ir.nodes);
|
|
for (const node of ir.nodes) {
|
|
if (node.kind === "foreach") validateForeach(node, topLevelIds);
|
|
}
|
|
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
|
|
validateParseStepsNodes(ir);
|
|
validateCodeNodes(ir.nodes);
|
|
validateFields(ir.fields);
|
|
|
|
// Rework edges are legal intra-template (foreach, KTD-5) and — since U6
|
|
// generalized the bounded-rework mechanism to the top-level walk — for a
|
|
// designated top-level rework region (the PR review loop: await-review →
|
|
// pr-respond → rework back to await-review). A top-level rework edge is legal
|
|
// ONLY when its target (the loop head) explicitly opts in via
|
|
// `config.reworkRegion === true`; the executor seeds the bound from that head's
|
|
// `config.maxReworkCycles` (shared default + clamp). This keeps every other
|
|
// top-level back-edge rejected (validateNoIllegalCycles below still throws for
|
|
// non-rework cycles), so the relaxation is narrow and opt-in.
|
|
for (const edge of ir.edges) {
|
|
if (!isReworkEdge(edge)) continue;
|
|
const head = nodesById.get(edge.to);
|
|
if (head?.config?.reworkRegion === true) continue;
|
|
throw new WorkflowIrError(
|
|
`rework edge '${edge.from}' -> '${edge.to}' is only legal inside a foreach template ` +
|
|
`or into a top-level rework region head (config.reworkRegion: true)`,
|
|
);
|
|
}
|
|
|
|
validateNoIllegalCycles(ir.nodes, outgoing);
|
|
validateForeachDominance(ir.nodes, ir.edges, outgoing);
|
|
}
|
|
|
|
/** Clamp foreach `maxReworkCycles` > cap down to the cap, in place, mirroring the
|
|
* maxRetries clamp posture (KTD-5). Reject-of-<1 happens in validation. */
|
|
function clampForeachConfigs(ir: WorkflowIrV2): void {
|
|
for (const node of ir.nodes) {
|
|
if (node.kind !== "foreach") continue;
|
|
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
|
|
if (
|
|
cfg &&
|
|
typeof cfg.maxReworkCycles === "number" &&
|
|
cfg.maxReworkCycles > MAX_REWORK_CYCLES_CAP
|
|
) {
|
|
cfg.maxReworkCycles = MAX_REWORK_CYCLES_CAP;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr {
|
|
const value: unknown = typeof input === "string" ? JSON.parse(input) : input;
|
|
if (!value || typeof value !== "object") {
|
|
throw new WorkflowIrError("Workflow IR must be an object");
|
|
}
|
|
const ir = value as WorkflowIr;
|
|
if (ir.version !== "v1" && ir.version !== "v2") {
|
|
throw new WorkflowIrError("Workflow IR version must be v1 or v2");
|
|
}
|
|
if (!Array.isArray(ir.nodes) || !Array.isArray(ir.edges)) {
|
|
throw new WorkflowIrError("Workflow IR nodes/edges must be arrays");
|
|
}
|
|
const startCount = ir.nodes.filter((n) => n.kind === "start").length;
|
|
const endCount = ir.nodes.filter((n) => n.kind === "end").length;
|
|
if (startCount !== 1 || endCount !== 1) {
|
|
throw new WorkflowIrError("Workflow IR must contain exactly one start and one end node");
|
|
}
|
|
|
|
if (ir.version === "v1") {
|
|
// Read-path upgrade: v1 graphs become v2 with synthesized default columns
|
|
// and seam-based node placement. v1 fixtures keep parsing (FN-5769 contract).
|
|
return upgradeV1ToV2(ir);
|
|
}
|
|
|
|
clampForeachConfigs(ir);
|
|
validateV2(ir);
|
|
return ir;
|
|
}
|
|
|
|
/** v1 node kinds (FN-5769). A pure-v1 graph uses only these; the v2-only kinds
|
|
* (hold/split/join) force v2 persistence. */
|
|
const V1_NODE_KINDS: ReadonlySet<WorkflowIrNodeKind> = new Set([
|
|
"start",
|
|
"prompt",
|
|
"script",
|
|
"gate",
|
|
"end",
|
|
]);
|
|
|
|
/**
|
|
* Rollback compat (FN issue #1405): if `ir` is a v2 graph that is byte-for-byte
|
|
* equivalent to an upgraded-v1 graph — only v1 node kinds, no hold/split/join,
|
|
* and exactly the synthesized default columns at their seam-derived placement —
|
|
* downgrade it back to the v1 shape so pre-v2 binaries (which hard-reject
|
|
* version !== 'v1') can still load the row. Returns the original `ir` unchanged
|
|
* when any v2-only feature is present (custom columns, non-default placement,
|
|
* v2-only node kinds), since those genuinely require v2.
|
|
*/
|
|
export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
|
|
if (ir.version !== "v2") return ir;
|
|
|
|
// Any v2-only node kind means the graph cannot be represented in v1.
|
|
for (const node of ir.nodes) {
|
|
if (!V1_NODE_KINDS.has(node.kind)) return ir;
|
|
}
|
|
|
|
// Step-inversion declarations (artifacts/fields) are v2-only features.
|
|
if ((ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0)) {
|
|
return ir;
|
|
}
|
|
|
|
// Columns must be exactly the synthesized default set, same ids, same order,
|
|
// with the minimal (placement-only) empty trait set. Any custom column, rename,
|
|
// reorder, or applied trait forces v2.
|
|
if (ir.columns.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return ir;
|
|
for (let i = 0; i < ir.columns.length; i++) {
|
|
const col = ir.columns[i];
|
|
const expectedId = DEFAULT_WORKFLOW_COLUMN_IDS[i];
|
|
if (col.id !== expectedId || col.name !== expectedId || col.traits.length !== 0) {
|
|
return ir;
|
|
}
|
|
}
|
|
|
|
// Every node must sit in its default seam-derived column. A node placed
|
|
// elsewhere is a v2 feature (custom placement) and must stay v2.
|
|
for (const node of ir.nodes) {
|
|
if (node.column !== defaultColumnForNode(node)) return ir;
|
|
}
|
|
|
|
// Pure v1: emit the v1 shape, dropping the synthesized `column` fields so the
|
|
// result round-trips through a pre-v2 binary. (Re-reading it on a v2 binary
|
|
// re-upgrades it to the identical v2 graph via upgradeV1ToV2.)
|
|
return {
|
|
version: "v1",
|
|
name: ir.name,
|
|
nodes: ir.nodes.map(({ column: _column, ...rest }) => rest),
|
|
edges: ir.edges,
|
|
};
|
|
}
|
|
|
|
export function serializeWorkflowIr(ir: WorkflowIr): string {
|
|
return JSON.stringify(ir, null, 2);
|
|
}
|