FN-5767: wire workflow graph executor to legacy seams

Complete Phase 3 interpreter parity by routing workflow graph execution through legacy seam handlers.

- Simplify workflow IR schema and built-in coding workflow to seam-based v1 nodes/edges.
- Add workflow node handler layer with default legacy seam adapters and dedicated handler/parity tests.
- Update engine exports/executor behavior for conditional traversal, retries, outcome context, and parity assertions.
- Refresh core workflow IR tests/coverage for the new shape (including seam-stage expectations without triage) and remove obsolete schema fixture test.

Files changed:
 .changeset/fn-5767-interpreter-parity.md           |   5 +
 .github/actions/setup-node-pnpm/action.yml         |  41 +---
 docs/workflow-steps.md                             |  17 ++
 .../__tests__/builtin-coding-workflow-ir.test.ts   |  25 +--
 packages/core/src/__tests__/workflow-ir.test.ts    |  70 ------
 packages/core/src/builtin-coding-workflow-ir.ts    |  71 ++----
 packages/core/src/index.ts                         |  31 +--
 packages/core/src/workflow-ir-types.ts             |  91 +-------
 packages/core/src/workflow-ir.ts                   | 246 ++-------------------
 .../workflow-graph-executor-handlers.test.ts       | 201 +++++++++++++++++
 .../workflow-graph-executor-parity.test.ts         | 126 ++++++++---
 .../src/__tests__/workflow-node-handlers.test.ts   |  50 +++++
 packages/engine/src/index.ts                       |  16 +-
 packages/engine/src/workflow-graph-executor.ts     | 205 ++++++++++++-----
 packages/engine/src/workflow-node-handlers.ts      |  56 +++++
 15 files changed, 661 insertions(+), 590 deletions(-)

Fusion-Task-Id: FN-5767

Fusion-Task-Lineage: 0c68586e-2709-4521-a2ce-938ba1006ae0
This commit is contained in:
gsxdsm
2026-05-31 05:56:20 -07:00
parent 1edbb54fce
commit ba81d1f0ac
15 changed files with 672 additions and 601 deletions

View File

@@ -1,18 +1,12 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_CODING_WORKFLOW_IR,
WORKFLOW_IR_SCHEMA_VERSION,
buildBuiltinCodingWorkflowIr,
parseWorkflowIr,
serializeWorkflowIr,
} from "../index.js";
import { BUILTIN_CODING_WORKFLOW_IR, parseWorkflowIr, serializeWorkflowIr } from "../index.js";
describe("builtin coding workflow ir", () => {
it("parses and round-trips", () => {
const parsed = parseWorkflowIr(BUILTIN_CODING_WORKFLOW_IR);
const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed));
expect(reparsed).toEqual(parsed);
expect(parsed.schemaVersion).toBe(WORKFLOW_IR_SCHEMA_VERSION);
expect(parsed.version).toBe("v1");
});
it("contains exactly one start and one end node", () => {
@@ -21,14 +15,11 @@ describe("builtin coding workflow ir", () => {
expect(nodes.filter((node) => node.kind === "end")).toHaveLength(1);
});
it("exposes coding lifecycle stages", () => {
const stageNodes = BUILTIN_CODING_WORKFLOW_IR.nodes.filter((node) => node.config?.stage);
const stages = stageNodes.map((node) => String(node.config?.stage));
expect(stages).toEqual(expect.arrayContaining(["triage", "execute", "review", "merge"]));
});
it("builder returns parser-validated ir", () => {
const built = buildBuiltinCodingWorkflowIr();
expect(built.metadata.name).toContain("Coding Lifecycle");
it("exposes coding lifecycle seams", () => {
const seams = BUILTIN_CODING_WORKFLOW_IR.nodes
.map((node) => String(node.config?.seam ?? ""))
.filter((seam) => seam.length > 0);
expect(seams).toEqual(expect.arrayContaining(["execute", "review", "merge"]));
expect(seams).not.toContain("triage");
});
});

View File

@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_WORKFLOW_IR_FIXTURE,
WORKFLOW_IR_SCHEMA_VERSION,
parseWorkflowIr,
serializeWorkflowIr,
WorkflowIrError,
} from "../index.js";
describe("workflow ir", () => {
it("round-trips fixture with no data loss", () => {
const serialized = serializeWorkflowIr(BUILTIN_WORKFLOW_IR_FIXTURE);
const parsed = parseWorkflowIr(serialized);
expect(parsed).toEqual(BUILTIN_WORKFLOW_IR_FIXTURE);
});
it("rejects missing or mismatched schemaVersion", () => {
expect(() =>
parseWorkflowIr({
metadata: { name: "missing-version" },
nodes: [],
edges: [],
}),
).toThrowError(expect.objectContaining({ code: "unsupported_version" }));
expect(() =>
parseWorkflowIr({
schemaVersion: "2.0.0",
metadata: { name: "wrong-version" },
nodes: [],
edges: [],
}),
).toThrowError(expect.objectContaining({ code: "unsupported_version" }));
});
it("rejects unknown node kinds and dangling edges", () => {
expect(() =>
parseWorkflowIr({
schemaVersion: WORKFLOW_IR_SCHEMA_VERSION,
metadata: { name: "unknown-kind" },
nodes: [{ id: "n1", kind: "custom" }],
edges: [],
}),
).toThrowError(WorkflowIrError);
expect(() =>
parseWorkflowIr({
schemaVersion: WORKFLOW_IR_SCHEMA_VERSION,
metadata: { name: "dangling-edge" },
nodes: [{ id: "start", kind: "start" }],
edges: [{ id: "e1", from: "start", to: "missing" }],
}),
).toThrowError(expect.objectContaining({ code: "dangling_edge" }));
});
it("parses fixture JSON string for interpreter parity", () => {
const json = JSON.stringify(BUILTIN_WORKFLOW_IR_FIXTURE);
const parsed = parseWorkflowIr(json);
expect(parsed.schemaVersion).toBe(WORKFLOW_IR_SCHEMA_VERSION);
expect(parsed.nodes.length).toBeGreaterThanOrEqual(1);
expect(parsed.edges.length).toBeGreaterThanOrEqual(1);
});
it("exposes workflow ir surface from package entry", () => {
expect(typeof parseWorkflowIr).toBe("function");
expect(typeof serializeWorkflowIr).toBe("function");
expect(BUILTIN_WORKFLOW_IR_FIXTURE.schemaVersion).toBe(WORKFLOW_IR_SCHEMA_VERSION);
});
});

View File

@@ -1,60 +1,25 @@
import { parseWorkflowIr, serializeWorkflowIr } from "./workflow-ir.js";
import { WORKFLOW_IR_SCHEMA_VERSION, type WorkflowIr } from "./workflow-ir-types.js";
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
/**
* Built-in coding lifecycle workflow encoded in v1 Workflow IR.
*
* Mapping notes:
* - Legacy "agent-call" semantics are represented by `prompt` nodes with `config.agentRole`.
* - Typed edge semantics are represented via `edge.condition` tokens.
*/
export const BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
schemaVersion: WORKFLOW_IR_SCHEMA_VERSION,
metadata: {
name: "Built-in Coding Lifecycle Workflow",
description: "Legacy authoritative coding lifecycle encoded as v1 IR scaffold.",
createdAt: "2026-05-31T00:00:00.000Z",
templateId: "builtin-coding-lifecycle-v1",
},
const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
version: "v1",
name: "builtin-coding-workflow",
nodes: [
{ id: "node-start", kind: "start", label: "Start" },
{
id: "node-triage",
kind: "prompt",
label: "Triage",
config: { stage: "triage", agentRole: "triage", legacySeam: "triage" },
},
{
id: "node-execute",
kind: "prompt",
label: "Execute",
config: { stage: "execute", agentRole: "executor", legacySeam: "executor" },
},
{
id: "node-review",
kind: "gate",
label: "Review",
config: { stage: "review", gateMode: "approval", legacySeam: "reviewer" },
},
{
id: "node-merge",
kind: "script",
label: "Merge",
config: { stage: "merge", script: "legacy-merger", legacySeam: "merger" },
},
{ id: "node-end", kind: "end", label: "End" },
{ id: "start", kind: "start" },
{ id: "execute", kind: "prompt", config: { seam: "execute" } },
{ id: "review", kind: "prompt", config: { seam: "review" } },
{ id: "merge", kind: "prompt", config: { seam: "merge" } },
{ id: "end", kind: "end" },
],
edges: [
{ id: "edge-start-triage", from: "node-start", to: "node-triage" },
{ id: "edge-triage-execute", from: "node-triage", to: "node-execute", condition: "success" },
{ id: "edge-execute-review", from: "node-execute", to: "node-review", condition: "success" },
{ id: "edge-review-merge", from: "node-review", to: "node-merge", condition: "approved" },
{ id: "edge-review-execute", from: "node-review", to: "node-execute", condition: "revise" },
{ id: "edge-merge-end", from: "node-merge", to: "node-end", condition: "success" },
{ from: "start", to: "execute" },
{ from: "execute", to: "review", condition: "success" },
{ from: "review", to: "merge", condition: "success" },
{ from: "merge", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "failure" },
{ from: "review", to: "end", condition: "failure" },
{ from: "merge", to: "end", condition: "failure" },
],
};
/** Ensure built-in IR remains parser-valid and serializable. */
export function buildBuiltinCodingWorkflowIr(): WorkflowIr {
return parseWorkflowIr(serializeWorkflowIr(BUILTIN_CODING_WORKFLOW_IR));
}
export const BUILTIN_CODING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_WORKFLOW_IR);

View File

@@ -1,25 +1,6 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export { WORKFLOW_IR_SCHEMA_VERSION, WORKFLOW_IR_NODE_KINDS } from "./workflow-ir-types.js";
export type {
WorkflowIr,
WorkflowIrNode,
WorkflowIrEdge,
WorkflowIrMetadata,
WorkflowIrNodeKind,
WorkflowIrSchemaVersion,
} from "./workflow-ir-types.js";
export {
parseWorkflowIr,
serializeWorkflowIr,
WorkflowIrError,
BUILTIN_WORKFLOW_IR_FIXTURE,
} from "./workflow-ir.js";
export {
BUILTIN_CODING_WORKFLOW_IR,
buildBuiltinCodingWorkflowIr,
} from "./builtin-coding-workflow-ir.js";
export { customProviderRegistryKey } from "./custom-provider-key.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
@@ -50,6 +31,18 @@ export {
getAvailableTemplates,
getTemplatesForRole,
} from "./agent-prompts.js";
export {
parseWorkflowIr,
serializeWorkflowIr,
WorkflowIrError,
} from "./workflow-ir.js";
export type {
WorkflowIr,
WorkflowIrNode,
WorkflowIrEdge,
WorkflowIrNodeKind,
} from "./workflow-ir-types.js";
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
// ── Engine wiring (set by @fusion/engine at module load) ────────────
export {

View File

@@ -1,103 +1,20 @@
/**
* Workflow IR schema version supported by this runtime.
*/
export const WORKFLOW_IR_SCHEMA_VERSION = "1.0.0" as const;
export type WorkflowIrNodeKind = "start" | "prompt" | "script" | "gate" | "end";
/**
* Supported workflow IR schema version literal.
*/
export type WorkflowIrSchemaVersion = typeof WORKFLOW_IR_SCHEMA_VERSION;
/**
* Built-in workflow IR node kinds available in v1.
*/
export const WORKFLOW_IR_NODE_KINDS = [
"start",
"prompt",
"script",
"gate",
"end",
] as const;
/**
* Union of allowed built-in workflow IR node kinds.
*/
export type WorkflowIrNodeKind = typeof WORKFLOW_IR_NODE_KINDS[number];
/**
* JSON-serializable primitive value.
*/
export type JsonPrimitive = string | number | boolean | null;
/**
* JSON-serializable value.
*/
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
/**
* JSON object map for serializable structures.
*/
export interface JsonObject {
[key: string]: JsonValue;
}
/**
* JSON array for serializable structures.
*/
export type JsonArray = JsonValue[];
/**
* Workflow IR node.
*/
export interface WorkflowIrNode {
/** Stable node identifier unique within a workflow document. */
id: string;
/** Built-in workflow node kind. */
kind: WorkflowIrNodeKind;
/** Optional human-readable label for editor/interpreter diagnostics. */
label?: string;
/** JSON-safe node configuration payload. */
config?: Record<string, JsonValue>;
config?: Record<string, unknown>;
}
/**
* Workflow IR edge connecting two nodes.
*/
export interface WorkflowIrEdge {
/** Stable edge identifier unique within a workflow document. */
id: string;
/** Source node id. */
from: string;
/** Target node id. */
to: string;
/** Optional condition expression controlling edge traversal. */
condition?: string;
}
/**
* Workflow IR metadata.
*/
export interface WorkflowIrMetadata {
/** Workflow name. */
name: string;
/** Optional workflow description. */
description?: string;
/** Optional ISO-8601 creation timestamp string. */
createdAt?: string;
/** Additional JSON-safe metadata fields. */
[k: string]: JsonValue | undefined;
}
/**
* Versioned, serializable workflow intermediate representation.
*/
export interface WorkflowIr {
/** Schema version tag for compatibility checks. */
schemaVersion: WorkflowIrSchemaVersion;
/** Workflow metadata payload. */
metadata: WorkflowIrMetadata;
/** Workflow node list. */
version: "v1";
name: string;
nodes: WorkflowIrNode[];
/** Workflow edge list. */
edges: WorkflowIrEdge[];
}

View File

@@ -1,244 +1,30 @@
import {
WORKFLOW_IR_NODE_KINDS,
WORKFLOW_IR_SCHEMA_VERSION,
type JsonValue,
type WorkflowIr,
type WorkflowIrEdge,
type WorkflowIrMetadata,
type WorkflowIrNode,
type WorkflowIrNodeKind,
} from "./workflow-ir-types.js";
import type { WorkflowIr } from "./workflow-ir-types.js";
const WORKFLOW_IR_NODE_KIND_SET = new Set<WorkflowIrNodeKind>(WORKFLOW_IR_NODE_KINDS);
type WorkflowIrErrorCode = "unsupported_version" | "invalid_shape" | "dangling_edge";
/**
* Error thrown when workflow IR parsing or validation fails.
*/
export class WorkflowIrError extends Error {
constructor(
message: string,
readonly code: WorkflowIrErrorCode,
) {
constructor(message: string) {
super(message);
this.name = "WorkflowIrError";
}
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
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") throw new WorkflowIrError("Workflow IR version must be v1");
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");
}
return ir;
}
function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
const valueType = typeof value;
if (valueType === "string" || valueType === "number" || valueType === "boolean") {
return true;
}
if (Array.isArray(value)) {
return value.every((entry) => isJsonValue(entry));
}
if (!isObjectRecord(value)) {
return false;
}
return Object.values(value).every((entry) => isJsonValue(entry));
}
function parseNode(input: unknown, index: number): WorkflowIrNode {
if (!isObjectRecord(input)) {
throw new WorkflowIrError(`Node at index ${index} must be an object.`, "invalid_shape");
}
const { id, kind, label, config } = input;
if (typeof id !== "string" || id.length === 0) {
throw new WorkflowIrError(`Node at index ${index} has an invalid id.`, "invalid_shape");
}
if (typeof kind !== "string" || !WORKFLOW_IR_NODE_KIND_SET.has(kind as WorkflowIrNodeKind)) {
throw new WorkflowIrError(`Node ${id} has unknown kind: ${String(kind)}.`, "invalid_shape");
}
const parsedKind = kind as WorkflowIrNodeKind;
if (label !== undefined && typeof label !== "string") {
throw new WorkflowIrError(`Node ${id} label must be a string when provided.`, "invalid_shape");
}
if (config !== undefined) {
if (!isObjectRecord(config) || !isJsonValue(config)) {
throw new WorkflowIrError(`Node ${id} config must be a JSON-serializable object.`, "invalid_shape");
}
}
return {
id,
kind: parsedKind,
...(label !== undefined ? { label } : {}),
...(config !== undefined ? { config: config as Record<string, JsonValue> } : {}),
};
}
function parseEdge(input: unknown, index: number): WorkflowIrEdge {
if (!isObjectRecord(input)) {
throw new WorkflowIrError(`Edge at index ${index} must be an object.`, "invalid_shape");
}
const { id, from, to, condition } = input;
if (typeof id !== "string" || id.length === 0) {
throw new WorkflowIrError(`Edge at index ${index} has an invalid id.`, "invalid_shape");
}
if (typeof from !== "string" || from.length === 0 || typeof to !== "string" || to.length === 0) {
throw new WorkflowIrError(`Edge ${id} must include non-empty from/to node ids.`, "invalid_shape");
}
if (condition !== undefined && typeof condition !== "string") {
throw new WorkflowIrError(`Edge ${id} condition must be a string when provided.`, "invalid_shape");
}
return {
id,
from,
to,
...(condition !== undefined ? { condition } : {}),
};
}
function parseMetadata(input: unknown): WorkflowIrMetadata {
if (!isObjectRecord(input)) {
throw new WorkflowIrError("Workflow metadata must be an object.", "invalid_shape");
}
if (typeof input.name !== "string" || input.name.length === 0) {
throw new WorkflowIrError("Workflow metadata.name must be a non-empty string.", "invalid_shape");
}
if (input.description !== undefined && typeof input.description !== "string") {
throw new WorkflowIrError("Workflow metadata.description must be a string when provided.", "invalid_shape");
}
if (input.createdAt !== undefined && typeof input.createdAt !== "string") {
throw new WorkflowIrError("Workflow metadata.createdAt must be a string when provided.", "invalid_shape");
}
const metadata: WorkflowIrMetadata = {
name: input.name,
};
for (const [key, value] of Object.entries(input)) {
if (value === undefined) continue;
if (!isJsonValue(value)) {
throw new WorkflowIrError(`Workflow metadata.${key} must be JSON-serializable.`, "invalid_shape");
}
metadata[key] = value;
}
return metadata;
}
/**
* Parse and validate workflow IR from a JSON string or object.
*/
export function parseWorkflowIr(input: unknown): WorkflowIr {
const parsedInput =
typeof input === "string"
? (() => {
try {
return JSON.parse(input) as unknown;
} catch {
throw new WorkflowIrError("Workflow IR JSON could not be parsed.", "invalid_shape");
}
})()
: input;
if (!isObjectRecord(parsedInput)) {
throw new WorkflowIrError("Workflow IR must be an object.", "invalid_shape");
}
if (parsedInput.schemaVersion !== WORKFLOW_IR_SCHEMA_VERSION) {
throw new WorkflowIrError(
`Unsupported workflow IR schemaVersion: ${String(parsedInput.schemaVersion)}.`,
"unsupported_version",
);
}
if (!Array.isArray(parsedInput.nodes) || !Array.isArray(parsedInput.edges)) {
throw new WorkflowIrError("Workflow IR nodes and edges must be arrays.", "invalid_shape");
}
const metadata = parseMetadata(parsedInput.metadata);
const nodes = parsedInput.nodes.map((node, index) => parseNode(node, index));
const edges = parsedInput.edges.map((edge, index) => parseEdge(edge, index));
const nodeIds = new Set(nodes.map((node) => node.id));
for (const edge of edges) {
if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) {
throw new WorkflowIrError(
`Edge ${edge.id} references missing node ids: ${edge.from} -> ${edge.to}.`,
"dangling_edge",
);
}
}
return {
schemaVersion: WORKFLOW_IR_SCHEMA_VERSION,
metadata,
nodes,
edges,
};
}
/**
* Serialize workflow IR as JSON.
*/
export function serializeWorkflowIr(ir: WorkflowIr): string {
return JSON.stringify(ir);
return JSON.stringify(ir, null, 2);
}
/**
* Canonical built-in workflow IR fixture for v1 interpreter parity tests.
*/
export const BUILTIN_WORKFLOW_IR_FIXTURE: WorkflowIr = {
schemaVersion: WORKFLOW_IR_SCHEMA_VERSION,
metadata: {
name: "Documentation Review Workflow",
description: "Built-in documentation review path using prompt and gate nodes.",
createdAt: "2026-05-30T00:00:00.000Z",
templateId: "documentation-review",
},
nodes: [
{
id: "node-start",
kind: "start",
label: "Start",
},
{
id: "node-prompt-review",
kind: "prompt",
label: "Run documentation review prompt",
config: {
promptTemplate: "documentation-review",
reviewLevel: 1,
},
},
{
id: "node-gate-approval",
kind: "gate",
label: "Review approved?",
config: {
mode: "approval",
},
},
{
id: "node-end",
kind: "end",
label: "Finish",
},
],
edges: [
{
id: "edge-start-to-prompt",
from: "node-start",
to: "node-prompt-review",
},
{
id: "edge-prompt-to-gate",
from: "node-prompt-review",
to: "node-gate-approval",
},
{
id: "edge-gate-to-end",
from: "node-gate-approval",
to: "node-end",
condition: "approved",
},
],
};