Persist optional credential-instance selections across model configuration without changing runtime credential behavior. - Add credential instance IDs to global, project, task, preset, and workflow IR model lanes. - Preserve selected instance IDs through model resolution and task persistence. - Add PostgreSQL migration coverage, unit tests, documentation, and a minor changeset. Files changed: .../fn-8660-credential-instance-selection.md | 7 ++ docs/settings-reference.md | 17 +++++ .../core/src/__tests__/model-resolution.test.ts | 37 ++++++++++ .../credential-instance-selection.pg.test.ts | 81 +++++++++++++++++++++ .../postgres/settings-persistence.pg.test.ts | 83 ++++++++++++++++++++++ .../src/__tests__/workflow-ir-settings.test.ts | 66 +++++++++++++++++ packages/core/src/builtin-workflow-settings.ts | 41 +++++++++++ packages/core/src/model-resolution.ts | 57 ++++++++++++++- .../0039_fn_8660_credential_instance_selection.sql | 9 +++ packages/core/src/postgres/schema-applier.ts | 14 +++- packages/core/src/postgres/schema/project.ts | 4 ++ packages/core/src/settings-schema.ts | 25 +++++++ packages/core/src/store.ts | 2 +- .../core/src/task-store/archive-lifecycle-2.ts | 8 +++ .../core/src/task-store/branch-and-pr-entities.ts | 2 +- packages/core/src/task-store/persistence.ts | 8 +++ packages/core/src/task-store/serialization.ts | 6 ++ packages/core/src/task-store/settings-ops.ts | 30 ++++++++ packages/core/src/task-store/task-creation.ts | 18 ++++- packages/core/src/task-store/task-mutation-ops.ts | 6 +- packages/core/src/task-store/task-row-mappers.ts | 6 +- packages/core/src/task-store/task-update.ts | 24 +++++++ packages/core/src/types/archive-planning.ts | 5 ++ packages/core/src/types/settings-scope.ts | 40 +++++++++++ packages/core/src/types/task-core.ts | 30 ++++++++ packages/core/src/types/workflow-steps.ts | 9 +++ packages/core/src/workflow-ir.ts | 18 +++++ packages/core/src/workflow-settings.ts | 10 +++ 28 files changed, 650 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-8660 Fusion-Task-Lineage: a3f625eb-018c-4084-954e-488b1d37691e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
1955 lines
76 KiB
TypeScript
1955 lines
76 KiB
TypeScript
import type {
|
|
WorkflowIr,
|
|
WorkflowIrColumn,
|
|
WorkflowIrEdge,
|
|
WorkflowIrNode,
|
|
WorkflowIrNodeKind,
|
|
WorkflowIrV1,
|
|
WorkflowIrV2,
|
|
WorkflowHoldRelease,
|
|
WorkflowForeachConfig,
|
|
WorkflowLoopConfig,
|
|
WorkflowOptionalGroupConfig,
|
|
WorkflowFieldDefinition,
|
|
WorkflowFieldType,
|
|
WorkflowSettingDefinition,
|
|
WorkflowSettingType,
|
|
} from "./workflow-ir-types.js";
|
|
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
|
|
import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js";
|
|
import { THINKING_LEVELS } from "./types.js";
|
|
import { resolveColumnFlags } from "./trait-registry.js";
|
|
import { isValidProviderInstanceId } from "./provider-instance.js";
|
|
// Side-effect import: registers the built-in traits so `resolveColumnFlags`
|
|
// resolves the built-in `merge-blocker`/`intake` flags during save-time
|
|
// validation (U2). Custom/plugin traits that set the same flags resolve too.
|
|
import "./builtin-traits.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",
|
|
]);
|
|
|
|
const THINKING_LEVEL_SET: ReadonlySet<string> = new Set(THINKING_LEVELS);
|
|
|
|
/** Workflow-settings (U1) value-type whitelist (mirrors WORKFLOW_FIELD_TYPES). */
|
|
export const WORKFLOW_SETTING_TYPES: ReadonlySet<WorkflowSettingType> = new Set([
|
|
"string",
|
|
"text",
|
|
"number",
|
|
"boolean",
|
|
"enum",
|
|
"multi-enum",
|
|
]);
|
|
|
|
/** Workflow-settings render-widget whitelist (mirrors FIELD_RENDER_WIDGETS;
|
|
* no placement — settings have no card/detail placement). */
|
|
export const SETTING_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;
|
|
const MAX_LOOP_ITERATIONS_CAP = 50;
|
|
const MAX_LOOP_TIMEOUT_MS = 3_600_000;
|
|
const WORKFLOW_EXTENSION_KEY_PATTERN = /^plugin:[a-z0-9]([a-z0-9-]*[a-z0-9])?:[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
const MAX_LOOP_REGEX_PATTERN_LENGTH = 256;
|
|
const LOOP_REGEX_NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[*+](?:[^()\\]|\\.)*\)\s*(?:[*+]|\{\d+,?\d*\})/;
|
|
const LOOP_REGEX_BACKREFERENCE = /\\[1-9]/;
|
|
|
|
/** 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";
|
|
}
|
|
|
|
function assertSafeLoopRegexPattern(nodeId: string, pattern: string): void {
|
|
if (pattern.length > MAX_LOOP_REGEX_PATTERN_LENGTH) {
|
|
throw new WorkflowIrError(
|
|
`loop node '${nodeId}' exitWhen.pattern must be ${MAX_LOOP_REGEX_PATTERN_LENGTH} characters or fewer`,
|
|
);
|
|
}
|
|
if (LOOP_REGEX_BACKREFERENCE.test(pattern) || LOOP_REGEX_NESTED_QUANTIFIER.test(pattern)) {
|
|
throw new WorkflowIrError(
|
|
`loop node '${nodeId}' exitWhen.pattern uses a potentially unsafe regex construct`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
const INTERPRETER_ENTRY_NODE_KINDS: ReadonlySet<WorkflowIrNodeKind> = new Set([
|
|
"merge-gate",
|
|
"merge-attempt",
|
|
"manual-merge-hold",
|
|
"retry-backoff",
|
|
"recovery-router",
|
|
"branch-group-member-integration",
|
|
"branch-group-promotion",
|
|
"pr-create",
|
|
"pr-respond",
|
|
"pr-merge",
|
|
]);
|
|
|
|
/*
|
|
FNXC:WorkflowValidation 2026-07-18-22:10:
|
|
U2 — a `merge-blocker` column blocks entry to complete-bound columns until a
|
|
merge-class node has completed. If a workflow declares merge-blocker but the
|
|
graph contains no reachable merge-class node, the gate can NEVER clear and the
|
|
card is stranded forever. Save-time validation rejects that shape. Mirrors the
|
|
engine's MERGE_REGION_KINDS plus the PR merge node (a PR-based workflow clears
|
|
its merge-blocker via `pr-merge`).
|
|
*/
|
|
const MERGE_CLASS_NODE_KINDS: ReadonlySet<WorkflowIrNodeKind> = new Set([
|
|
"merge-gate",
|
|
"merge-attempt",
|
|
"manual-merge-hold",
|
|
"retry-backoff",
|
|
"recovery-router",
|
|
"branch-group-member-integration",
|
|
"branch-group-promotion",
|
|
"pr-merge",
|
|
]);
|
|
|
|
/** Collect the set of node ids reachable from `startId` over the given outgoing
|
|
* edge map (top-level graph reachability). */
|
|
function collectReachableNodeIds(
|
|
startId: string,
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
): Set<string> {
|
|
const reachable = new Set<string>([startId]);
|
|
const stack = [startId];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop()!;
|
|
for (const edge of outgoing.get(current) ?? []) {
|
|
if (!reachable.has(edge.to)) {
|
|
reachable.add(edge.to);
|
|
stack.push(edge.to);
|
|
}
|
|
}
|
|
}
|
|
return reachable;
|
|
}
|
|
|
|
/**
|
|
* U2 save-time hard error: a workflow declaring a `merge-blocker` column MUST
|
|
* contain a merge-class node reachable from `start`. Without one the merge gate
|
|
* never clears. Fails open (no rejection) when no column resolves the
|
|
* merge-blocker flag — a merge-less docs-only workflow is unaffected.
|
|
*/
|
|
function validateMergeBlockerReachability(
|
|
ir: WorkflowIrV2,
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
): void {
|
|
const blockerColumn = ir.columns.find((c) => resolveColumnFlags(c).mergeBlocker === true);
|
|
if (!blockerColumn) return;
|
|
|
|
const startNode = ir.nodes.find((n) => n.kind === "start");
|
|
// A start-less graph is rejected elsewhere; treat all nodes as candidates here
|
|
// so this check never masks that more fundamental error.
|
|
const reachable = startNode
|
|
? collectReachableNodeIds(startNode.id, outgoing)
|
|
: new Set(ir.nodes.map((n) => n.id));
|
|
|
|
// A merge-class node is a merge-region node KIND, or the legacy/linear merge
|
|
// seam expressed as a prompt node with `config.seam === "merge"` (linear
|
|
// built-ins and custom seam workflows use the latter).
|
|
const isMergeClassNode = (n: WorkflowIrNode): boolean =>
|
|
MERGE_CLASS_NODE_KINDS.has(n.kind) || n.config?.seam === "merge";
|
|
const hasReachableMerge = ir.nodes.some((n) => isMergeClassNode(n) && reachable.has(n.id));
|
|
if (!hasReachableMerge) {
|
|
throw new WorkflowIrError(
|
|
`Workflow column '${blockerColumn.id}' declares the merge-blocker trait but the graph has ` +
|
|
`no reachable merge-class node (merge-gate/merge-attempt/manual-merge-hold/retry-backoff/` +
|
|
`recovery-router/branch-group-*/pr-merge). The merge-blocker gate can never clear without one.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a workflow's creation column — where new cards land (U2/R11). The
|
|
* intake-flagged column if present; otherwise the first column is the documented
|
|
* default. Returns undefined for a v1-style / empty-columns IR (no column model).
|
|
*/
|
|
export function resolveCreationColumn(ir: WorkflowIr): WorkflowIrColumn | undefined {
|
|
const columns = ir.version === "v2" ? ir.columns : undefined;
|
|
if (!columns || columns.length === 0) return undefined;
|
|
const intake = columns.find((c) => resolveColumnFlags(c).intake === true);
|
|
return intake ?? columns[0];
|
|
}
|
|
|
|
function validateRequiredTopLevelReachability(
|
|
nodes: WorkflowIrNode[],
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
): void {
|
|
/*
|
|
FNXC:WorkflowValidation 2026-06-27-07:40:
|
|
FN-7113 requires required top-level workflow nodes to be reachable from start at parse time, including interpreter-deferred branch graphs. Engine-owned recovery entry primitives stay exempt because they can be re-entered by persisted runtime state rather than by the author-facing start path.
|
|
*/
|
|
const startNode = nodes.find((node) => node.kind === "start");
|
|
if (!startNode) return;
|
|
const reachable = reachableFrom(startNode.id, outgoing);
|
|
for (const node of nodes) {
|
|
if (reachable.has(node.id) || INTERPRETER_ENTRY_NODE_KINDS.has(node.kind)) continue;
|
|
throw new WorkflowIrError(`Workflow node '${node.id}' is not reachable from the start node`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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>,
|
|
columnIds: 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 template groups. Also: a template node's declared `column` must resolve to a
|
|
// top-level column id (column-agent plan KTD-1) — otherwise a dangling reference
|
|
// is a silent no-binding no-op at runtime instead of a typed authoring error.
|
|
for (const inner of templateNodes) {
|
|
if (inner.kind === "foreach" || inner.kind === "loop") {
|
|
throw new WorkflowIrError(
|
|
`foreach node '${node.id}' template may not contain nested loop/foreach ('${inner.id}')`,
|
|
);
|
|
}
|
|
if (inner.column !== undefined && !columnIds.has(inner.column)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow node '${inner.id}' references undefined column '${inner.column}'`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateLoop(
|
|
node: WorkflowIrNode,
|
|
topLevelNodeIds: Set<string>,
|
|
columnIds: Set<string>,
|
|
): void {
|
|
const cfg = node.config as Partial<WorkflowLoopConfig> | undefined;
|
|
const template = cfg?.template;
|
|
if (
|
|
!cfg ||
|
|
!template ||
|
|
!Array.isArray(template.nodes) ||
|
|
!Array.isArray(template.edges)
|
|
) {
|
|
throw new WorkflowIrError(
|
|
`loop node '${node.id}' must declare a template with nodes and edges arrays`,
|
|
);
|
|
}
|
|
if (template.nodes.length === 0) {
|
|
throw new WorkflowIrError(`loop node '${node.id}' template must be non-empty`);
|
|
}
|
|
if (cfg.maxIterations !== undefined) {
|
|
const m = cfg.maxIterations;
|
|
if (typeof m !== "number" || !Number.isInteger(m) || m < 1) {
|
|
throw new WorkflowIrError(`loop node '${node.id}' maxIterations must be an integer >= 1`);
|
|
}
|
|
}
|
|
if (cfg.timeoutMs !== undefined) {
|
|
const t = cfg.timeoutMs;
|
|
if (typeof t !== "number" || !Number.isInteger(t) || t < 1 || t > MAX_LOOP_TIMEOUT_MS) {
|
|
throw new WorkflowIrError(
|
|
`loop node '${node.id}' timeoutMs must be an integer in 1..${MAX_LOOP_TIMEOUT_MS}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const exitWhen = cfg.exitWhen as WorkflowLoopConfig["exitWhen"] | undefined;
|
|
if (!exitWhen || typeof exitWhen !== "object") {
|
|
throw new WorkflowIrError(`loop node '${node.id}' must declare exitWhen`);
|
|
}
|
|
if (exitWhen.type === "output-contains") {
|
|
if (typeof exitWhen.value !== "string" || exitWhen.value.length === 0) {
|
|
throw new WorkflowIrError(`loop node '${node.id}' exitWhen.value must be a non-empty string`);
|
|
}
|
|
} else if (exitWhen.type === "output-matches") {
|
|
if (typeof exitWhen.pattern !== "string" || exitWhen.pattern.length === 0) {
|
|
throw new WorkflowIrError(`loop node '${node.id}' exitWhen.pattern must be a non-empty string`);
|
|
}
|
|
if (exitWhen.flags !== undefined && typeof exitWhen.flags !== "string") {
|
|
throw new WorkflowIrError(`loop node '${node.id}' exitWhen.flags must be a string when present`);
|
|
}
|
|
try {
|
|
new RegExp(exitWhen.pattern, exitWhen.flags);
|
|
} catch (err) {
|
|
throw new WorkflowIrError(
|
|
`loop node '${node.id}' exitWhen.pattern is invalid: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
assertSafeLoopRegexPattern(node.id, exitWhen.pattern);
|
|
} else {
|
|
throw new WorkflowIrError(`loop node '${node.id}' exitWhen.type must be output-contains or output-matches`);
|
|
}
|
|
|
|
const templateNodes = template.nodes;
|
|
const templateIds = new Set(templateNodes.map((n) => n.id));
|
|
if (templateIds.size !== templateNodes.length) {
|
|
throw new WorkflowIrError(`loop node '${node.id}' template has duplicate node ids`);
|
|
}
|
|
if (exitWhen.nodeId !== undefined && !templateIds.has(exitWhen.nodeId)) {
|
|
throw new WorkflowIrError(
|
|
`loop node '${node.id}' exitWhen.nodeId '${exitWhen.nodeId}' is not in the template`,
|
|
);
|
|
}
|
|
for (const inner of templateNodes) {
|
|
if (inner.kind === "loop" || inner.kind === "foreach") {
|
|
throw new WorkflowIrError(
|
|
`loop node '${node.id}' template may not contain nested loop/foreach ('${inner.id}')`,
|
|
);
|
|
}
|
|
if (isStepExecuteNode(inner)) {
|
|
throw new WorkflowIrError(
|
|
`step-execute seam node '${inner.id}' is only legal inside a foreach template`,
|
|
);
|
|
}
|
|
if (inner.column !== undefined && !columnIds.has(inner.column)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow node '${inner.id}' references undefined column '${inner.column}'`,
|
|
);
|
|
}
|
|
}
|
|
for (const edge of template.edges) {
|
|
const fromInside = templateIds.has(edge.from);
|
|
const toInside = templateIds.has(edge.to);
|
|
if (!fromInside || !toInside) {
|
|
throw new WorkflowIrError(
|
|
`loop node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`,
|
|
);
|
|
}
|
|
if (isReworkEdge(edge)) {
|
|
throw new WorkflowIrError(`loop node '${node.id}' template may not contain rework edges`);
|
|
}
|
|
}
|
|
|
|
const incoming = new Map<string, number>();
|
|
const outgoingCount = new Map<string, number>();
|
|
for (const edge of template.edges) {
|
|
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(
|
|
`loop node '${node.id}' template must have exactly one entry node (found ${entries.length})`,
|
|
);
|
|
}
|
|
if (exits.length !== 1) {
|
|
throw new WorkflowIrError(
|
|
`loop node '${node.id}' template must have exactly one exit node (found ${exits.length})`,
|
|
);
|
|
}
|
|
|
|
const templateById = new Map(templateNodes.map((n) => [n.id, n]));
|
|
const templateOutgoing = buildOutgoing(template.edges);
|
|
validateNoIllegalCycles(templateNodes, templateOutgoing);
|
|
validateParallelism(templateNodes, templateOutgoing, templateById);
|
|
validateStepReviewRouting(templateNodes, templateOutgoing, templateById, false);
|
|
|
|
for (const id of templateIds) {
|
|
if (topLevelNodeIds.has(id)) {
|
|
throw new WorkflowIrError(
|
|
`loop node '${node.id}' template node id '${id}' collides with a top-level node id`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowOptionalGroup 2026-06-21-11:00:
|
|
Validate an `optional-group` container template, mirroring `validateLoop` minus the loop's exit/iteration config.
|
|
The template runs once when enabled, so rework edges (and any cycles) are forbidden inside, single entry/exit is required, and nested foreach/loop groups are rejected — keeping the single-pass guarantee unambiguous.
|
|
`defaultOn` must be boolean when present; `name` must be a string when present.
|
|
*/
|
|
function validateOptionalGroup(
|
|
node: WorkflowIrNode,
|
|
topLevelNodeIds: Set<string>,
|
|
columnIds: Set<string>,
|
|
): void {
|
|
const cfg = node.config as Partial<WorkflowOptionalGroupConfig> | undefined;
|
|
const template = cfg?.template;
|
|
if (
|
|
!cfg ||
|
|
!template ||
|
|
!Array.isArray(template.nodes) ||
|
|
!Array.isArray(template.edges)
|
|
) {
|
|
throw new WorkflowIrError(
|
|
`optional-group node '${node.id}' must declare a template with nodes and edges arrays`,
|
|
);
|
|
}
|
|
if (template.nodes.length === 0) {
|
|
throw new WorkflowIrError(`optional-group node '${node.id}' template must be non-empty`);
|
|
}
|
|
if (cfg.defaultOn !== undefined && typeof cfg.defaultOn !== "boolean") {
|
|
throw new WorkflowIrError(`optional-group node '${node.id}' defaultOn must be a boolean`);
|
|
}
|
|
if (cfg.name !== undefined && typeof cfg.name !== "string") {
|
|
throw new WorkflowIrError(`optional-group node '${node.id}' name must be a string`);
|
|
}
|
|
// FNXC:WorkflowPostMerge 2026-06-26-09:00: `phase` is optional and defaults to
|
|
// "pre-merge"; only "pre-merge" | "post-merge" are valid when present.
|
|
if (cfg.phase !== undefined && cfg.phase !== "pre-merge" && cfg.phase !== "post-merge") {
|
|
throw new WorkflowIrError(`optional-group node '${node.id}' phase must be 'pre-merge' or 'post-merge'`);
|
|
}
|
|
/*
|
|
* FNXC:WorkflowOptionalStepRevisionBudget 2026-06-27-12:22:
|
|
* Parse-time validation accepts only an explicit non-negative integer budget or `"unbounded"`; absent remains byte-inert and resolves through the gate-specific runtime fallback at execution time.
|
|
*
|
|
* FNXC:WorkflowRevisionBudget 2026-06-30-20:36:
|
|
* The Plan Review/Code Review workflow setting values are validated separately from IR authoring. Invalid runtime values are ignored by the shared budget resolver, while invalid authored node budgets remain a parse error so custom workflow definitions cannot persist ambiguous caps.
|
|
*/
|
|
if (cfg.maxRevisions !== undefined) {
|
|
const maxRevisions = cfg.maxRevisions;
|
|
if (
|
|
maxRevisions !== "unbounded" &&
|
|
(typeof maxRevisions !== "number" || !Number.isInteger(maxRevisions) || maxRevisions < 0)
|
|
) {
|
|
throw new WorkflowIrError(
|
|
`optional-group node '${node.id}' maxRevisions must be a non-negative integer or "unbounded"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const templateNodes = template.nodes;
|
|
const templateIds = new Set(templateNodes.map((n) => n.id));
|
|
if (templateIds.size !== templateNodes.length) {
|
|
throw new WorkflowIrError(`optional-group node '${node.id}' template has duplicate node ids`);
|
|
}
|
|
for (const inner of templateNodes) {
|
|
if (inner.kind === "loop" || inner.kind === "foreach" || inner.kind === "optional-group") {
|
|
throw new WorkflowIrError(
|
|
`optional-group node '${node.id}' template may not contain nested loop/foreach/optional-group ('${inner.id}')`,
|
|
);
|
|
}
|
|
if (isStepExecuteNode(inner)) {
|
|
throw new WorkflowIrError(
|
|
`step-execute seam node '${inner.id}' is only legal inside a foreach template`,
|
|
);
|
|
}
|
|
if (inner.column !== undefined && !columnIds.has(inner.column)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow node '${inner.id}' references undefined column '${inner.column}'`,
|
|
);
|
|
}
|
|
}
|
|
for (const edge of template.edges) {
|
|
const fromInside = templateIds.has(edge.from);
|
|
const toInside = templateIds.has(edge.to);
|
|
if (!fromInside || !toInside) {
|
|
throw new WorkflowIrError(
|
|
`optional-group node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`,
|
|
);
|
|
}
|
|
if (isReworkEdge(edge)) {
|
|
throw new WorkflowIrError(`optional-group node '${node.id}' template may not contain rework edges`);
|
|
}
|
|
// FNXC:WorkflowOptionalGroup 2026-06-22-09:00: the single-pass walk
|
|
// (runOptionalGroup) surfaces a template-node failure as the GROUP's outcome
|
|
// and bails before evaluating that node's edges — so a `failure`-condition
|
|
// edge inside the template would silently never execute. Reject it as a typed
|
|
// authoring error; failure routing belongs on the group's OUTER edges.
|
|
// (Code review: Greptile P2.)
|
|
if (edge.condition === "failure") {
|
|
throw new WorkflowIrError(
|
|
`optional-group node '${node.id}' template may not contain failure-condition edges — ` +
|
|
`a template-node failure surfaces as the group's outcome and routes the group's outer failure edge`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const incoming = new Map<string, number>();
|
|
const outgoingCount = new Map<string, number>();
|
|
for (const edge of template.edges) {
|
|
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(
|
|
`optional-group node '${node.id}' template must have exactly one entry node (found ${entries.length})`,
|
|
);
|
|
}
|
|
if (exits.length !== 1) {
|
|
throw new WorkflowIrError(
|
|
`optional-group node '${node.id}' template must have exactly one exit node (found ${exits.length})`,
|
|
);
|
|
}
|
|
|
|
const templateById = new Map(templateNodes.map((n) => [n.id, n]));
|
|
const templateOutgoing = buildOutgoing(template.edges);
|
|
validateNoIllegalCycles(templateNodes, templateOutgoing);
|
|
validateParallelism(templateNodes, templateOutgoing, templateById);
|
|
validateStepReviewRouting(templateNodes, templateOutgoing, templateById, false);
|
|
|
|
for (const id of templateIds) {
|
|
if (topLevelNodeIds.has(id)) {
|
|
throw new WorkflowIrError(
|
|
`optional-group 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;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* FNXC:CredentialInstanceSelection 2026-08-01-05:43:
|
|
* Workflow model overrides may persist an optional credential-instance id but this data-only slice
|
|
* never consumes it at runtime. Validate authoring input recursively before the graph can persist.
|
|
*/
|
|
function validateCredentialInstanceIdConfig(nodes: WorkflowIrNode[]): void {
|
|
for (const node of nodes) {
|
|
const value = node.config?.credentialInstanceId;
|
|
if (value !== undefined && (typeof value !== "string" || !isValidProviderInstanceId(value))) {
|
|
throw new WorkflowIrError(`Workflow node '${node.id}' credentialInstanceId must be a valid string when present`);
|
|
}
|
|
const templateNodes = (node.config as { template?: { nodes?: unknown } } | undefined)?.template?.nodes;
|
|
if (Array.isArray(templateNodes)) validateCredentialInstanceIdConfig(templateNodes as WorkflowIrNode[]);
|
|
}
|
|
}
|
|
|
|
function validateThinkingLevelConfig(nodes: WorkflowIrNode[]): void {
|
|
for (const node of nodes) {
|
|
const value = node.config?.thinkingLevel;
|
|
/*
|
|
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
|
|
* Per-node thinking overrides are workflow model-binding config, so IR validation rejects unknown reasoning levels before editor-authored or imported graphs reach execution.
|
|
*/
|
|
if (value !== undefined && (typeof value !== "string" || !THINKING_LEVEL_SET.has(value))) {
|
|
throw new WorkflowIrError(
|
|
`Workflow node '${node.id}' thinkingLevel must be one of ${THINKING_LEVELS.join(", ")} when present`,
|
|
);
|
|
}
|
|
const templateNodes = (node.config as { template?: { nodes?: unknown } } | undefined)?.template?.nodes;
|
|
if (Array.isArray(templateNodes)) validateThinkingLevelConfig(templateNodes as WorkflowIrNode[]);
|
|
}
|
|
}
|
|
|
|
/** 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 workflow-authored notification node config. */
|
|
function validateNotifyNodes(nodes: WorkflowIrNode[]): void {
|
|
for (const node of nodes) {
|
|
if (node.kind !== "notify") continue;
|
|
const cfg = node.config as { event?: unknown; message?: unknown; title?: unknown } | undefined;
|
|
const event = cfg?.event;
|
|
if (typeof event !== "string" || event.trim() === "") {
|
|
throw new WorkflowIrError(`notify node '${node.id}' must declare a non-empty event`);
|
|
}
|
|
if (cfg?.message !== undefined && typeof cfg.message !== "string") {
|
|
throw new WorkflowIrError(`notify node '${node.id}' message must be a string`);
|
|
}
|
|
if (cfg?.title !== undefined && typeof cfg.title !== "string") {
|
|
throw new WorkflowIrError(`notify node '${node.id}' title must be a string`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowAskUserExitGate 2026-07-05-00:00:
|
|
FN-7579 adds two brainstorming/chat reach-out node kinds. `ask-user` reuses the
|
|
existing await-input park/resume plumbing (runAwaitInputNode) rather than a new
|
|
runner: it must carry a non-empty `config.question` (falling back to
|
|
`config.prompt`) OR omit both, in which case the engine's existing default
|
|
question string is used — validation only rejects a present-but-empty/non-string
|
|
value so authors cannot ship a blank prompt. `exit-gate` terminates the walk
|
|
early toward the terminal `end` node: it must have at least one outgoing edge
|
|
that (transitively, ignoring rework edges) reaches `end`, so an exit-gate can
|
|
never strand the graph. It is NOT itself an `end` node (the one-start/one-end
|
|
invariant is unaffected) — it only routes to one.
|
|
*/
|
|
function validateAskUserAndExitGateNodes(
|
|
nodes: WorkflowIrNode[],
|
|
outgoing: Map<string, WorkflowIrEdge[]>,
|
|
): void {
|
|
const endNode = nodes.find((n) => n.kind === "end");
|
|
|
|
for (const node of nodes) {
|
|
if (node.kind === "ask-user") {
|
|
const cfg = node.config as { question?: unknown; prompt?: unknown } | undefined;
|
|
if (cfg?.question !== undefined && (typeof cfg.question !== "string" || cfg.question.trim() === "")) {
|
|
throw new WorkflowIrError(
|
|
`ask-user node '${node.id}' question must be a non-empty string when present`,
|
|
);
|
|
}
|
|
if (cfg?.prompt !== undefined && (typeof cfg.prompt !== "string" || cfg.prompt.trim() === "")) {
|
|
throw new WorkflowIrError(
|
|
`ask-user node '${node.id}' prompt must be a non-empty string when present`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (node.kind === "exit-gate") {
|
|
if (!endNode) continue; // exactly-one-end invariant already failed elsewhere.
|
|
const reachable = reachableFrom(node.id, outgoing);
|
|
if (!reachable.has(endNode.id)) {
|
|
throw new WorkflowIrError(
|
|
`exit-gate node '${node.id}' must have a path to the terminal 'end' node`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Validate number-only setting constraints before values/defaults consume them. */
|
|
function validateSettingNumericConstraints(setting: WorkflowSettingDefinition): void {
|
|
if (setting.minimum !== undefined) {
|
|
if (setting.type !== "number") {
|
|
throw new WorkflowIrError(`Workflow setting '${setting.id}' minimum is only allowed for number settings`);
|
|
}
|
|
if (typeof setting.minimum !== "number" || !Number.isFinite(setting.minimum)) {
|
|
throw new WorkflowIrError(`Workflow setting '${setting.id}' minimum must be a finite number`);
|
|
}
|
|
}
|
|
if (setting.integer !== undefined) {
|
|
if (setting.type !== "number") {
|
|
throw new WorkflowIrError(`Workflow setting '${setting.id}' integer is only allowed for number settings`);
|
|
}
|
|
if (typeof setting.integer !== "boolean") {
|
|
throw new WorkflowIrError(`Workflow setting '${setting.id}' integer must be a boolean`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Validate that a setting's `default` conforms to its own type/options (U1).
|
|
* Unlike `validateFields`, settings validate defaults because the engine's
|
|
* effective-settings resolver (U3) consumes the default directly — a malformed
|
|
* default would feed garbage into execution. */
|
|
function validateSettingDefault(setting: WorkflowSettingDefinition): void {
|
|
const value = setting.default;
|
|
if (value === undefined) return;
|
|
const id = setting.id;
|
|
switch (setting.type) {
|
|
case "string":
|
|
case "text":
|
|
if (typeof value !== "string") {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${id}' default must be a string for type '${setting.type}'`,
|
|
);
|
|
}
|
|
break;
|
|
case "number":
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${id}' default must be a finite number`,
|
|
);
|
|
}
|
|
if (setting.integer === true && !Number.isInteger(value)) {
|
|
throw new WorkflowIrError(`Workflow setting '${id}' default must be an integer`);
|
|
}
|
|
if (setting.minimum !== undefined && value < setting.minimum) {
|
|
throw new WorkflowIrError(`Workflow setting '${id}' default must be at least ${setting.minimum}`);
|
|
}
|
|
break;
|
|
case "boolean":
|
|
if (typeof value !== "boolean") {
|
|
throw new WorkflowIrError(`Workflow setting '${id}' default must be a boolean`);
|
|
}
|
|
break;
|
|
case "enum": {
|
|
const allowed = new Set((setting.options ?? []).map((o) => o.value));
|
|
if (typeof value !== "string" || !allowed.has(value)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${id}' default '${String(value)}' is not one of its enum options`,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "multi-enum": {
|
|
const allowed = new Set((setting.options ?? []).map((o) => o.value));
|
|
if (!Array.isArray(value)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${id}' default must be an array for type 'multi-enum'`,
|
|
);
|
|
}
|
|
for (const entry of value) {
|
|
if (typeof entry !== "string" || !allowed.has(entry)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${id}' default '${String(entry)}' is not one of its enum options`,
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Validate `settings` declarations (U1, R1). Mirrors `validateFields`: non-empty
|
|
* unique ids, type whitelist, options iff enum-kind, unique option values, render
|
|
* widget whitelist — plus default validation (settings need it; see
|
|
* `validateSettingDefault`). */
|
|
function validateSettings(settings: WorkflowSettingDefinition[] | undefined): void {
|
|
if (settings === undefined) return;
|
|
if (!Array.isArray(settings)) {
|
|
throw new WorkflowIrError("Workflow IR settings must be an array");
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const setting of settings) {
|
|
if (!setting || typeof setting.id !== "string" || setting.id === "") {
|
|
throw new WorkflowIrError("Workflow setting must have a non-empty id");
|
|
}
|
|
if (seen.has(setting.id)) {
|
|
throw new WorkflowIrError(`Workflow IR has duplicate setting id '${setting.id}'`);
|
|
}
|
|
seen.add(setting.id);
|
|
if (typeof setting.name !== "string" || setting.name === "") {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${setting.id}' must have a non-empty name`,
|
|
);
|
|
}
|
|
if (!WORKFLOW_SETTING_TYPES.has(setting.type)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${setting.id}' has unknown type '${String(setting.type)}'`,
|
|
);
|
|
}
|
|
const isEnum = setting.type === "enum" || setting.type === "multi-enum";
|
|
if (isEnum) {
|
|
if (!Array.isArray(setting.options) || setting.options.length === 0) {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${setting.id}' of type '${setting.type}' must declare non-empty options`,
|
|
);
|
|
}
|
|
const optSeen = new Set<string>();
|
|
for (const opt of setting.options) {
|
|
if (!opt || typeof opt.value !== "string" || opt.value === "") {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${setting.id}' option must have a non-empty value`,
|
|
);
|
|
}
|
|
if (typeof opt.label !== "string" || opt.label === "") {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${setting.id}' option '${opt.value}' must have a non-empty label`,
|
|
);
|
|
}
|
|
if (optSeen.has(opt.value)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${setting.id}' has duplicate option value '${opt.value}'`,
|
|
);
|
|
}
|
|
optSeen.add(opt.value);
|
|
}
|
|
} else if (setting.options !== undefined) {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${setting.id}' of type '${setting.type}' must not declare options`,
|
|
);
|
|
}
|
|
if (setting.description !== undefined && typeof setting.description !== "string") {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${setting.id}' description must be a string`,
|
|
);
|
|
}
|
|
if (setting.render !== undefined) {
|
|
const r = setting.render;
|
|
if (r.widget !== undefined && !SETTING_RENDER_WIDGETS.has(r.widget)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow setting '${setting.id}' render.widget '${String(r.widget)}' is not allowed`,
|
|
);
|
|
}
|
|
}
|
|
validateSettingNumericConstraints(setting);
|
|
validateSettingDefault(setting);
|
|
}
|
|
}
|
|
|
|
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);
|
|
/*
|
|
FNXC:WorkflowColumnDescriptions 2026-07-22-12:00:
|
|
FN-8526 makes column explanatory copy first-class workflow metadata. Keep
|
|
its absent form as omission (not null) so existing definitions retain board
|
|
lifecycle-description fallback while arbitrary author string content round-trips.
|
|
*/
|
|
if (column.description !== undefined && typeof column.description !== "string") {
|
|
throw new WorkflowIrError(`Workflow IR column '${column.id}' description must be a string`);
|
|
}
|
|
if (!Array.isArray(column.traits)) {
|
|
throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`);
|
|
}
|
|
validateExtensionMetadata(`Workflow IR column '${column.id}'`, column.extensions);
|
|
validateColumnAgent(column);
|
|
validateColumnRecovery(column);
|
|
}
|
|
}
|
|
|
|
function validateExtensionMetadata(owner: string, extensions: unknown): void {
|
|
if (extensions === undefined) return;
|
|
if (!extensions || typeof extensions !== "object" || Array.isArray(extensions)) {
|
|
throw new WorkflowIrError(`${owner} extensions must be an object`);
|
|
}
|
|
for (const [key, value] of Object.entries(extensions as Record<string, unknown>)) {
|
|
if (!WORKFLOW_EXTENSION_KEY_PATTERN.test(key)) {
|
|
throw new WorkflowIrError(
|
|
`${owner} extension key '${key}' must be plugin-namespaced as plugin:<pluginId>:<extensionId>`,
|
|
);
|
|
}
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new WorkflowIrError(`${owner} extension '${key}' metadata must be an object`);
|
|
}
|
|
validateRegisteredExtensionMetadata(owner, key, value as Record<string, unknown>);
|
|
}
|
|
}
|
|
|
|
function validateRegisteredExtensionMetadata(
|
|
owner: string,
|
|
key: string,
|
|
value: Record<string, unknown>,
|
|
): void {
|
|
const definition = getWorkflowExtensionRegistry().get(key);
|
|
/*
|
|
FNXC:WorkflowValidation 2026-06-27-00:00:
|
|
FN-7113 requires plugin-referencing workflow graphs to validate against the same central gate as built-in/custom graphs. Reject unknown workflow extension keys by name so authoring surfaces cannot persist a graph whose plugin node/column contract is missing at save or launch time.
|
|
*/
|
|
if (!definition) {
|
|
throw new WorkflowIrError(`${owner} extension key '${key}' is not registered`);
|
|
}
|
|
const fields = definition.extension.configSchema?.fields;
|
|
if (!fields || fields.length === 0) return;
|
|
for (const field of fields) {
|
|
if (field.required && !(field.key in value)) {
|
|
throw new WorkflowIrError(`${owner} extension '${key}' missing required field '${field.key}'`);
|
|
}
|
|
if (field.key in value) {
|
|
validateExtensionFieldValue(owner, key, field, value[field.key]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateExtensionFieldValue(
|
|
owner: string,
|
|
key: string,
|
|
field: WorkflowExtensionConfigField,
|
|
value: unknown,
|
|
): void {
|
|
if (value === undefined) return;
|
|
const fail = (): never => {
|
|
throw new WorkflowIrError(
|
|
`${owner} extension '${key}' field '${field.key}' must be ${field.type}`,
|
|
);
|
|
};
|
|
if (field.type === "array") {
|
|
if (!Array.isArray(value)) fail();
|
|
return;
|
|
}
|
|
if (field.type === "object") {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) fail();
|
|
return;
|
|
}
|
|
if (field.type === "enum") {
|
|
if (typeof value !== "string") {
|
|
throw new WorkflowIrError(
|
|
`${owner} extension '${key}' field '${field.key}' must be ${field.type}`,
|
|
);
|
|
}
|
|
const enumValue: string = value;
|
|
if (!field.enumValues || field.enumValues.length === 0) {
|
|
throw new WorkflowIrError(
|
|
`${owner} extension '${key}' field '${field.key}' is enum but has no enumValues defined`,
|
|
);
|
|
}
|
|
if (!field.enumValues.includes(enumValue)) {
|
|
throw new WorkflowIrError(
|
|
`${owner} extension '${key}' field '${field.key}' must be one of: ${field.enumValues.join(", ")}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (typeof value !== field.type) fail();
|
|
}
|
|
|
|
/** Validate a column's optional permanent-agent binding (column-agent plan KTD-1).
|
|
* Mirrors the `validateFields` early-return shape: absent → no-op; present →
|
|
* `agentId` must be a non-empty string and `mode` exactly `defer`/`override`.
|
|
* Agent existence is NOT checked here (no agent store at the IR layer). */
|
|
function validateColumnAgent(column: WorkflowIrColumn): void {
|
|
const agent = column.agent;
|
|
if (agent === undefined) return;
|
|
if (!agent || typeof agent !== "object") {
|
|
throw new WorkflowIrError(`Workflow IR column '${column.id}' agent must be an object`);
|
|
}
|
|
if (typeof agent.agentId !== "string" || agent.agentId === "") {
|
|
throw new WorkflowIrError(
|
|
`Workflow IR column '${column.id}' agent must have a non-empty agentId`,
|
|
);
|
|
}
|
|
if (agent.mode !== "defer" && agent.mode !== "override") {
|
|
throw new WorkflowIrError(
|
|
`Workflow IR column '${column.id}' agent mode must be 'defer' or 'override' (got '${String(agent.mode)}')`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowRecoveryPolicy 2026-07-28-15:20 (PR #2478 review, P1):
|
|
Validate the optional recovery policy at PARSE, like every other IR field.
|
|
|
|
Without this, `parseWorkflowIr` happily persisted a negative or non-finite
|
|
`stalenessMs`, an `onStale` missing its `code`, or an unsupported action — and
|
|
the reconciler consumed them directly. A negative threshold makes every card
|
|
instantly stale; a non-finite one makes no card ever stale. Both are silent, and
|
|
both surface as a recovery sweep behaving inexplicably at 3am rather than as a
|
|
save that was refused.
|
|
|
|
Mirrors `validateColumnAgent`: absent → no-op; present → fully checked. The
|
|
action list is closed on purpose, so adding an action to the type without
|
|
teaching the reconciler about it fails at authoring time.
|
|
*/
|
|
function validateColumnRecovery(column: WorkflowIrColumn): void {
|
|
const recovery = column.recovery;
|
|
if (recovery === undefined) return;
|
|
if (!recovery || typeof recovery !== "object" || Array.isArray(recovery)) {
|
|
throw new WorkflowIrError(`Workflow IR column '${column.id}' recovery must be an object`);
|
|
}
|
|
|
|
const { stalenessMs, onStale } = recovery;
|
|
|
|
if (stalenessMs !== undefined) {
|
|
if (typeof stalenessMs !== "number" || !Number.isFinite(stalenessMs) || stalenessMs <= 0) {
|
|
throw new WorkflowIrError(
|
|
`Workflow IR column '${column.id}' recovery.stalenessMs must be a finite number greater than 0 (got '${String(stalenessMs)}')`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (onStale !== undefined) {
|
|
if (!onStale || typeof onStale !== "object" || Array.isArray(onStale)) {
|
|
throw new WorkflowIrError(`Workflow IR column '${column.id}' recovery.onStale must be an object`);
|
|
}
|
|
if (onStale.action !== "surface") {
|
|
throw new WorkflowIrError(
|
|
`Workflow IR column '${column.id}' recovery.onStale.action must be 'surface' (got '${String(onStale.action)}')`,
|
|
);
|
|
}
|
|
if (typeof onStale.code !== "string" || onStale.code.trim() === "") {
|
|
throw new WorkflowIrError(
|
|
`Workflow IR column '${column.id}' recovery.onStale must have a non-empty code`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/*
|
|
A policy is only actionable with BOTH halves: a threshold with no action never
|
|
fires, and an action with no threshold has nothing to fire on. Either alone is
|
|
almost certainly an authoring mistake, and accepting it would persist a policy
|
|
that silently does nothing — the exact failure this program keeps finding.
|
|
*/
|
|
if ((stalenessMs === undefined) !== (onStale === undefined)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow IR column '${column.id}' recovery requires both stalenessMs and onStale, or neither`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateV2(ir: WorkflowIrV2): void {
|
|
validateColumns(ir);
|
|
|
|
// FNXC:WorkflowValidation 2026-07-21-12:20:
|
|
// Capacity holds must have somewhere the scheduler can actually release
|
|
// them. Failing authoring here avoids durable continuations that can never
|
|
// become runnable.
|
|
for (const [index, column] of ir.columns.entries()) {
|
|
const hold = column.traits.find((trait) => trait.trait === "hold");
|
|
if (hold?.config?.release !== "capacity") continue;
|
|
const hasDownstreamCapacity = ir.columns
|
|
.slice(index + 1)
|
|
.some((candidate) => resolveColumnFlags(candidate).countsTowardWip === true);
|
|
if (!hasDownstreamCapacity) {
|
|
throw new WorkflowIrError(
|
|
`Workflow IR capacity hold column '${column.id}' requires a downstream wip column`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const columnIds = new Set(ir.columns.map((c) => c.id));
|
|
const nodeIds = new Set<string>();
|
|
for (const node of ir.nodes) {
|
|
/*
|
|
FNXC:WorkflowValidation 2026-06-27-00:00:
|
|
FN-7113 requires top-level duplicate node ids to fail before persistence or launch. Keep this check before nodesById is built so Map de-duplication cannot silently mask a malformed author/plugin workflow graph.
|
|
*/
|
|
if (nodeIds.has(node.id)) {
|
|
throw new WorkflowIrError(`Workflow IR has duplicate node id '${node.id}'`);
|
|
}
|
|
nodeIds.add(node.id);
|
|
}
|
|
const nodesById = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
|
|
for (const node of ir.nodes) {
|
|
validateExtensionMetadata(`Workflow node '${node.id}'`, node.extensions);
|
|
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)}'`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowValidation 2026-06-17-13:17:
|
|
Top-level workflow edges must reference declared top-level nodes. Fail closed on dangling endpoints so imported, AI-designed, and editor-authored IR cannot persist an edge to a non-existent node (FN-6583 / FN-6580 readiness gap).
|
|
*/
|
|
for (const edge of ir.edges) {
|
|
if (!nodesById.has(edge.from)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow edge '${edge.from}' -> '${edge.to}' references undefined node '${edge.from}'`,
|
|
);
|
|
}
|
|
if (!nodesById.has(edge.to)) {
|
|
throw new WorkflowIrError(
|
|
`Workflow edge '${edge.from}' -> '${edge.to}' references undefined node '${edge.to}'`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const outgoing = buildOutgoing(ir.edges);
|
|
validateParallelism(ir.nodes, outgoing, nodesById);
|
|
|
|
// U2: a merge-blocker column requires a reachable merge-class node, or its gate
|
|
// can never clear. Runs after edge validity + outgoing map are established.
|
|
validateMergeBlockerReachability(ir, outgoing);
|
|
|
|
// 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);
|
|
validateThinkingLevelConfig(ir.nodes);
|
|
validateCredentialInstanceIdConfig(ir.nodes);
|
|
for (const node of ir.nodes) {
|
|
if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds);
|
|
if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds);
|
|
if (node.kind === "optional-group") validateOptionalGroup(node, topLevelIds, columnIds);
|
|
}
|
|
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
|
|
validateParseStepsNodes(ir);
|
|
validateCodeNodes(ir.nodes);
|
|
validateNotifyNodes(ir.nodes);
|
|
validateAskUserAndExitGateNodes(ir.nodes, outgoing);
|
|
validateFields(ir.fields);
|
|
validateSettings(ir.settings);
|
|
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
|
// The legacy `optionalSteps` declaration field is retired (optional steps are
|
|
// now graph-native `optional-group` nodes). A legacy persisted `optionalSteps`
|
|
// key on an old v2 row is TOLERATED — no longer validated/required — so old
|
|
// rows still parse as v2.
|
|
|
|
// 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);
|
|
validateRequiredTopLevelReachability(ir.nodes, outgoing);
|
|
validateForeachDominance(ir.nodes, ir.edges, outgoing);
|
|
}
|
|
|
|
/** Clamp bounded workflow-node configs down to their caps, in place, mirroring
|
|
* the maxRetries clamp posture. Reject-of-<1 happens in validation. */
|
|
function clampForeachConfigs(ir: WorkflowIrV2): void {
|
|
for (const node of ir.nodes) {
|
|
if (node.kind === "loop") {
|
|
const cfg = node.config as Partial<WorkflowLoopConfig> | undefined;
|
|
if (
|
|
cfg &&
|
|
typeof cfg.maxIterations === "number" &&
|
|
cfg.maxIterations > MAX_LOOP_ITERATIONS_CAP
|
|
) {
|
|
cfg.maxIterations = MAX_LOOP_ITERATIONS_CAP;
|
|
}
|
|
continue;
|
|
}
|
|
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), workflow settings (U1), and
|
|
// any legacy persisted optional-step declarations are v2-only features.
|
|
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00 (updated 2026-06-22-09:00):
|
|
// `optionalSteps` is no longer a typed IR field (retired declaration model), but
|
|
// a legacy v2 row may still carry the key. Read it via an untyped cast so such a
|
|
// row is still treated as v2 (kept on v2, never silently downgraded). The mere
|
|
// PRESENCE of the key — including an empty `[]` — is the v2 signal: an author
|
|
// who wrote the key intended v2, and downgrading an `optionalSteps: []` row to
|
|
// v1 would still mutate its persisted shape. (Code review: CodeRabbit.)
|
|
const legacyOptionalSteps = (ir as { optionalSteps?: unknown }).optionalSteps;
|
|
if (
|
|
(ir.artifacts && ir.artifacts.length > 0) ||
|
|
(ir.fields && ir.fields.length > 0) ||
|
|
(ir.settings && ir.settings.length > 0) ||
|
|
legacyOptionalSteps !== undefined
|
|
) {
|
|
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;
|
|
}
|
|
// A permanent-agent binding is a v2-only feature (column-agent plan, R9): a
|
|
// graph that staffs a column can never round-trip through a pre-v2 binary.
|
|
if (col.agent !== undefined) return ir;
|
|
/*
|
|
FNXC:WorkflowRecoveryPolicy 2026-07-28-15:10 (PR #2478 review, P1):
|
|
A recovery policy is v2-only for the same reason as `agent`. The v1 shape has
|
|
no `columns` at all, so downgrading a workflow that declares one does not
|
|
degrade it — it PERMANENTLY DISCARDS the authored policy on the next save,
|
|
silently. The card would then simply stop being reconciled, with no error and
|
|
nothing in the diff to explain why.
|
|
|
|
The mere PRESENCE of the key is the v2 signal, matching the `optionalSteps`
|
|
rule above: an author who wrote `recovery: {}` intended v2, and downgrading
|
|
would still mutate the persisted shape.
|
|
*/
|
|
if (col.recovery !== undefined) return ir;
|
|
if (col.extensions !== undefined && Object.keys(col.extensions).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;
|
|
if (node.extensions !== undefined && Object.keys(node.extensions).length > 0) 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);
|
|
}
|
|
|
|
/**
|
|
* Strip the trust-escalating `cliSkipApproval`/`autoApprove` flags from every
|
|
* node config in an IR, recursing into template-group `config.template.nodes`.
|
|
* Mutates the passed IR in place and returns it alongside a `stripped` flag
|
|
* indicating whether anything was removed.
|
|
*
|
|
* These flags bypass the CLI first-run approval gate (see executor.ts). They are
|
|
* legitimate only for workflows authored through the trusted dashboard editor /
|
|
* executor lane; on prompt-injectable surfaces (chat/planning authoring tools,
|
|
* import, AI design) they must be removed at the write boundary.
|
|
*/
|
|
export function stripApprovalBypassFlags(ir: WorkflowIr): { ir: WorkflowIr; stripped: boolean } {
|
|
const nodes = (ir as { nodes?: WorkflowIrNode[] }).nodes;
|
|
if (!Array.isArray(nodes)) return { ir, stripped: false };
|
|
let stripped = false;
|
|
const stripNode = (node: WorkflowIrNode): void => {
|
|
// Untrusted input may contain non-object entries (null, strings, numbers)
|
|
// in `nodes` / `template.nodes`; skip them rather than dereferencing.
|
|
if (!node || typeof node !== "object") return;
|
|
const cfg = node.config as Record<string, unknown> | undefined;
|
|
if (cfg && typeof cfg === "object") {
|
|
if ("cliSkipApproval" in cfg) {
|
|
delete cfg.cliSkipApproval;
|
|
stripped = true;
|
|
}
|
|
if ("autoApprove" in cfg) {
|
|
delete cfg.autoApprove;
|
|
stripped = true;
|
|
}
|
|
const template = (cfg as { template?: { nodes?: unknown } }).template;
|
|
if (template && Array.isArray(template.nodes)) {
|
|
for (const inner of template.nodes as WorkflowIrNode[]) stripNode(inner);
|
|
}
|
|
}
|
|
};
|
|
for (const node of nodes) stripNode(node);
|
|
return { ir, stripped };
|
|
}
|