@dypai-ai/workflow-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/fixtures/capability-catalog.json +126 -0
  2. package/fixtures/legacy-create-booking.yaml +40 -0
  3. package/package.json +40 -0
  4. package/src/adapters/adapters.test.ts +168 -0
  5. package/src/adapters/engineBinding.ts +35 -0
  6. package/src/adapters/flowDefinitionToIr.ts +141 -0
  7. package/src/adapters/irToWorkflowCodeV2.ts +293 -0
  8. package/src/adapters/legacyYamlToIr.ts +340 -0
  9. package/src/adapters/placeholderToRef.ts +74 -0
  10. package/src/adapters/refToLegacyPlaceholder.ts +33 -0
  11. package/src/adapters/sqlBuilders.ts +81 -0
  12. package/src/adapters/triggers.ts +86 -0
  13. package/src/adapters/types.ts +15 -0
  14. package/src/adapters/workflowCodeTypes.ts +45 -0
  15. package/src/capabilities/agentBrief.ts +42 -0
  16. package/src/capabilities/capabilities.test.ts +126 -0
  17. package/src/capabilities/capabilityRegistry.ts +112 -0
  18. package/src/capabilities/catalogSchema.ts +14 -0
  19. package/src/capabilities/fromCatalog.ts +30 -0
  20. package/src/capabilities/index.ts +35 -0
  21. package/src/capabilities/types.ts +57 -0
  22. package/src/fixtures/createBooking.flow.ts +64 -0
  23. package/src/fixtures/createBooking.ir.ts +103 -0
  24. package/src/fixtures/listBookings.ir.ts +61 -0
  25. package/src/index.ts +172 -0
  26. package/src/ir/refs.ts +103 -0
  27. package/src/ir/schema.ts +149 -0
  28. package/src/ir/sourceMap.ts +59 -0
  29. package/src/ir/types.ts +147 -0
  30. package/src/ir/validate.test.ts +181 -0
  31. package/src/ir/validate.ts +365 -0
  32. package/src/registry/defineNode.ts +19 -0
  33. package/src/registry/nodeRegistry.ts +87 -0
  34. package/src/registry/nodes/dypaiDb.ts +164 -0
  35. package/src/registry/nodes/dypaiEmail.ts +57 -0
  36. package/src/registry/nodes/dypaiFlow.ts +25 -0
  37. package/src/registry/nodes/legacyWorkflow.ts +27 -0
  38. package/tsconfig.json +12 -0
@@ -0,0 +1,147 @@
1
+ export const WORKFLOW_IR_VERSION = "workflow-ir.v1" as const;
2
+
3
+ export type WorkflowIRVersion = typeof WORKFLOW_IR_VERSION;
4
+
5
+ export type JsonSchema = Record<string, unknown>;
6
+
7
+ export type SourceLocation = {
8
+ file?: string;
9
+ line?: number;
10
+ column?: number;
11
+ yamlPath?: string;
12
+ stepId?: string;
13
+ };
14
+
15
+ export type WorkflowSourceKind = "flow-ts" | "legacy-yaml" | "studio";
16
+
17
+ export type WorkflowSource = {
18
+ kind: WorkflowSourceKind;
19
+ file: string;
20
+ compiledAt?: string;
21
+ };
22
+
23
+ export type RefIR =
24
+ | { kind: "input"; path: string[] }
25
+ | { kind: "currentUserId" }
26
+ | { kind: "step"; stepId: string; path?: string[] }
27
+ | { kind: "literal"; value: unknown }
28
+ | { kind: "env"; name: string };
29
+
30
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
31
+
32
+ export type TriggerIR =
33
+ | { type: "http"; method: HttpMethod }
34
+ | { type: "schedule"; cron: string }
35
+ | { type: "webhook"; provider?: string };
36
+
37
+ export type AuthIR =
38
+ | { mode: "public" }
39
+ | { mode: "jwt"; allowedRoles: string[] }
40
+ | { mode: "api_key" };
41
+
42
+ export type ResponseIR = {
43
+ cardinality?: "single" | "many" | "none";
44
+ };
45
+
46
+ export type ResourceIR = {
47
+ type: string;
48
+ name: string;
49
+ access: "read" | "write" | "readwrite";
50
+ };
51
+
52
+ export type WorkflowStepIR = {
53
+ id: string;
54
+ node: string;
55
+ operation: string;
56
+ version: number;
57
+ config: Record<string, unknown>;
58
+ refs?: Record<string, RefIR>;
59
+ outputSchema?: JsonSchema;
60
+ source?: SourceLocation;
61
+ return?: boolean;
62
+ };
63
+
64
+ export type WorkflowEdgeIR = {
65
+ from: string;
66
+ to: string;
67
+ };
68
+
69
+ export type WorkflowIR = {
70
+ version: WorkflowIRVersion;
71
+ name: string;
72
+ source?: WorkflowSource;
73
+ trigger: TriggerIR;
74
+ auth: AuthIR;
75
+ inputSchema: JsonSchema;
76
+ outputSchema: JsonSchema;
77
+ response: ResponseIR;
78
+ steps: WorkflowStepIR[];
79
+ edges?: WorkflowEdgeIR[];
80
+ responseMap: Record<string, RefIR>;
81
+ resources?: ResourceIR[];
82
+ executionPlan?: string[][];
83
+ };
84
+
85
+ export type IRDiagnosticSeverity = "error" | "warn";
86
+
87
+ export type IRDiagnostic = {
88
+ severity: IRDiagnosticSeverity;
89
+ rule: string;
90
+ message: string;
91
+ fixHint?: string;
92
+ location?: SourceLocation;
93
+ path?: string;
94
+ };
95
+
96
+ export type IRValidationResult = {
97
+ ok: boolean;
98
+ diagnostics: IRDiagnostic[];
99
+ derivedResources?: ResourceIR[];
100
+ derivedSideEffects?: string[];
101
+ };
102
+
103
+ export type NodeOperationKey = `${string}@${string}@v${number}`;
104
+
105
+ export type SideEffectKind =
106
+ | "db.read"
107
+ | "db.write"
108
+ | "email.send"
109
+ | "http.response";
110
+
111
+ export type OperationDefinition = {
112
+ label?: string;
113
+ docs?: string;
114
+ inputSchema: JsonSchema;
115
+ outputSchema: JsonSchema;
116
+ sideEffects?: SideEffectKind[];
117
+ resources?: (config: Record<string, unknown>) => ResourceIR[];
118
+ validate?: (
119
+ config: Record<string, unknown>,
120
+ refs: Record<string, RefIR> | undefined,
121
+ ) => IRDiagnostic[];
122
+ };
123
+
124
+ export type NodeDefinition = {
125
+ id: string;
126
+ version: number;
127
+ label: string;
128
+ category: string;
129
+ operations: Record<string, OperationDefinition>;
130
+ sideEffects?: SideEffectKind[];
131
+ permissions?: string[];
132
+ ui?: Record<string, unknown>;
133
+ docs?: string;
134
+ adapters?: Record<string, unknown>;
135
+ };
136
+
137
+ export type AdapterTarget = "workflow_code.v2" | "engine.ir";
138
+
139
+ export type NodeAdapterDeclaration = {
140
+ target: AdapterTarget;
141
+ implemented: boolean;
142
+ };
143
+
144
+ export type RegistryLookup = {
145
+ node: NodeDefinition;
146
+ operation: OperationDefinition;
147
+ };
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createBookingWorkflowIR,
4
+ createBookingWorkflowIRMissingTable,
5
+ defaultNodeRegistry,
6
+ formatDiagnosticMessage,
7
+ formatSourceLocation,
8
+ minimalValidWorkflowIR,
9
+ validateWorkflowIR,
10
+ WORKFLOW_IR_VERSION,
11
+ type WorkflowIR,
12
+ } from "../index";
13
+
14
+ describe("validateWorkflowIR", () => {
15
+ test("accepts minimal valid IR", () => {
16
+ const result = validateWorkflowIR(minimalValidWorkflowIR());
17
+ expect(result.ok).toBe(true);
18
+ expect(result.diagnostics.filter((item) => item.severity === "error")).toHaveLength(0);
19
+ });
20
+
21
+ test("accepts hand-authored create-booking IR", () => {
22
+ const result = validateWorkflowIR(createBookingWorkflowIR);
23
+ expect(result.ok).toBe(true);
24
+ expect(result.derivedResources).toEqual([
25
+ { type: "postgres_table", name: "public.bookings", access: "write" },
26
+ ]);
27
+ expect(result.derivedSideEffects).toContain("db.write");
28
+ expect(result.derivedSideEffects).toContain("http.response");
29
+ });
30
+
31
+ test("rejects unknown node operation", () => {
32
+ const workflow: WorkflowIR = {
33
+ ...minimalValidWorkflowIR(),
34
+ steps: [
35
+ {
36
+ id: "bad",
37
+ node: "stripe.checkout",
38
+ operation: "create",
39
+ version: 1,
40
+ config: {},
41
+ },
42
+ {
43
+ id: "respond",
44
+ node: "dypai.flow",
45
+ operation: "return",
46
+ version: 1,
47
+ config: {},
48
+ return: true,
49
+ },
50
+ ],
51
+ };
52
+ const result = validateWorkflowIR(workflow);
53
+ expect(result.ok).toBe(false);
54
+ expect(result.diagnostics.some((item) => item.rule === "unknown_node_operation")).toBe(true);
55
+ });
56
+
57
+ test("rejects unknown operation on known node", () => {
58
+ const workflow: WorkflowIR = {
59
+ ...minimalValidWorkflowIR(),
60
+ steps: [
61
+ {
62
+ id: "bad",
63
+ node: "dypai.db",
64
+ operation: "upsert",
65
+ version: 1,
66
+ config: { table: "public.items", values: { name: "x" } },
67
+ },
68
+ {
69
+ id: "respond",
70
+ node: "dypai.flow",
71
+ operation: "return",
72
+ version: 1,
73
+ config: {},
74
+ return: true,
75
+ },
76
+ ],
77
+ };
78
+ const result = validateWorkflowIR(workflow);
79
+ expect(result.ok).toBe(false);
80
+ expect(result.diagnostics.some((item) => item.rule === "unknown_node_operation")).toBe(true);
81
+ });
82
+
83
+ test("rejects duplicate step ids", () => {
84
+ const workflow: WorkflowIR = {
85
+ ...minimalValidWorkflowIR(),
86
+ steps: [
87
+ {
88
+ id: "respond",
89
+ node: "dypai.flow",
90
+ operation: "return",
91
+ version: 1,
92
+ config: {},
93
+ return: true,
94
+ },
95
+ {
96
+ id: "respond",
97
+ node: "dypai.flow",
98
+ operation: "return",
99
+ version: 1,
100
+ config: {},
101
+ return: true,
102
+ },
103
+ ],
104
+ };
105
+ const result = validateWorkflowIR(workflow);
106
+ expect(result.ok).toBe(false);
107
+ expect(result.diagnostics.some((item) => item.rule === "duplicate_step_id")).toBe(true);
108
+ });
109
+
110
+ test("rejects invalid refs in responseMap", () => {
111
+ const workflow: WorkflowIR = {
112
+ ...minimalValidWorkflowIR(),
113
+ responseMap: {
114
+ ok: { kind: "step", stepId: "missing-step" },
115
+ },
116
+ };
117
+ const result = validateWorkflowIR(workflow);
118
+ expect(result.ok).toBe(false);
119
+ expect(result.diagnostics.some((item) => item.rule === "invalid_ref")).toBe(true);
120
+ });
121
+
122
+ test("allows JWT auth with empty allowedRoles (any authenticated user)", () => {
123
+ const workflow: WorkflowIR = {
124
+ ...minimalValidWorkflowIR(),
125
+ auth: { mode: "jwt", allowedRoles: [] },
126
+ };
127
+ const result = validateWorkflowIR(workflow);
128
+ expect(result.diagnostics.some((item) => item.rule === "jwt_missing_allowed_roles")).toBe(false);
129
+ });
130
+
131
+ test("preserves source maps in diagnostics for create-booking missing table", () => {
132
+ const result = validateWorkflowIR(createBookingWorkflowIRMissingTable);
133
+ expect(result.ok).toBe(false);
134
+ const tableDiagnostic = result.diagnostics.find((item) => item.rule === "db_insert_missing_table");
135
+ expect(tableDiagnostic).toBeDefined();
136
+ expect(formatSourceLocation(tableDiagnostic?.location)).toBe("dypai/flows/create-booking.flow.ts:17");
137
+ expect(formatDiagnosticMessage({
138
+ message: tableDiagnostic!.message,
139
+ location: tableDiagnostic!.location,
140
+ })).toContain("dypai/flows/create-booking.flow.ts:17");
141
+ });
142
+ });
143
+
144
+ describe("NodeRegistry v2", () => {
145
+ test("derives postgres resources for dypai.db.insert", () => {
146
+ const resources = defaultNodeRegistry.deriveResources("dypai.db", "insert", 1, {
147
+ table: "public.bookings",
148
+ values: { name: "Ada" },
149
+ });
150
+ expect(resources).toEqual([
151
+ { type: "postgres_table", name: "public.bookings", access: "write" },
152
+ ]);
153
+ });
154
+
155
+ test("derives email side effects for dypai.email.send", () => {
156
+ const effects = defaultNodeRegistry.deriveSideEffects("dypai.email", "send", 1);
157
+ expect(effects).toContain("email.send");
158
+ });
159
+
160
+ test("lists MVP node operations", () => {
161
+ const keys = defaultNodeRegistry.listOperationKeys();
162
+ expect(keys).toContain("dypai.db@query@v1");
163
+ expect(keys).toContain("dypai.db@insert@v1");
164
+ expect(keys).toContain("dypai.db@update@v1");
165
+ expect(keys).toContain("dypai.db@list@v1");
166
+ expect(keys).toContain("dypai.email@send@v1");
167
+ expect(keys).toContain("dypai.flow@return@v1");
168
+ });
169
+ });
170
+
171
+ describe("WorkflowIR schema helpers", () => {
172
+ test("detects non-IR payloads", () => {
173
+ const result = validateWorkflowIR({ version: "legacy" });
174
+ expect(result.ok).toBe(false);
175
+ expect(result.diagnostics[0]?.rule).toBe("invalid_workflow_ir");
176
+ });
177
+
178
+ test("requires workflow-ir.v1 version constant", () => {
179
+ expect(WORKFLOW_IR_VERSION).toBe("workflow-ir.v1");
180
+ });
181
+ });
@@ -0,0 +1,365 @@
1
+ import type { NodeRegistry } from "../registry/nodeRegistry";
2
+ import { defaultNodeRegistry, NodeRegistry as NodeRegistryClass } from "../registry/nodeRegistry";
3
+ import type { CapabilityCatalog } from "../capabilities/types";
4
+ import { collectRefs, isRefIR, validateRefIR } from "./refs";
5
+ import {
6
+ isRecord,
7
+ isValidEndpointName,
8
+ isValidHttpMethod,
9
+ isWorkflowIR,
10
+ schemaPropertyNames,
11
+ validateJsonValueAgainstSchema,
12
+ } from "./schema";
13
+ import { stepLocation, withSourceLocation } from "./sourceMap";
14
+ import type {
15
+ IRDiagnostic,
16
+ IRValidationResult,
17
+ RefIR,
18
+ ResourceIR,
19
+ SideEffectKind,
20
+ SourceLocation,
21
+ WorkflowIR,
22
+ WorkflowStepIR,
23
+ } from "./types";
24
+ import { WORKFLOW_IR_VERSION } from "./types";
25
+
26
+ function pushDiagnostic(
27
+ diagnostics: IRDiagnostic[],
28
+ diagnostic: IRDiagnostic,
29
+ location?: SourceLocation,
30
+ ): void {
31
+ diagnostics.push(location ? withSourceLocation(diagnostic, location) : diagnostic);
32
+ }
33
+
34
+ function sanitizeConfigForSchema(config: Record<string, unknown>): Record<string, unknown> {
35
+ const out: Record<string, unknown> = {};
36
+ for (const [key, value] of Object.entries(config)) {
37
+ if (isRefIR(value)) {
38
+ out[key] = "__ref__";
39
+ continue;
40
+ }
41
+ if (Array.isArray(value)) {
42
+ out[key] = value.map((item) => (isRefIR(item) ? "__ref__" : item));
43
+ continue;
44
+ }
45
+ if (isRecord(value)) {
46
+ out[key] = sanitizeConfigForSchema(value);
47
+ continue;
48
+ }
49
+ out[key] = value;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function validateStepRefs(
55
+ step: WorkflowStepIR,
56
+ ctx: { inputProperties: Set<string>; stepIds: Set<string> },
57
+ workflowSource?: SourceLocation,
58
+ ): IRDiagnostic[] {
59
+ const diagnostics: IRDiagnostic[] = [];
60
+ const location = stepLocation(workflowSource, step);
61
+ const refSources: Array<{ ref: RefIR; path: string }> = [];
62
+
63
+ if (step.refs) {
64
+ for (const [name, ref] of Object.entries(step.refs)) {
65
+ refSources.push({ ref, path: `steps.${step.id}.refs.${name}` });
66
+ }
67
+ }
68
+
69
+ for (const ref of collectRefs(step.config)) {
70
+ refSources.push({ ref, path: `steps.${step.id}.config` });
71
+ }
72
+
73
+ for (const { ref, path } of refSources) {
74
+ const message = validateRefIR(ref, {
75
+ inputSchemaProperties: ctx.inputProperties,
76
+ stepIds: ctx.stepIds,
77
+ }, path);
78
+ if (message) {
79
+ pushDiagnostic(diagnostics, {
80
+ severity: "error",
81
+ rule: "invalid_ref",
82
+ message,
83
+ path,
84
+ }, location);
85
+ }
86
+ }
87
+
88
+ return diagnostics;
89
+ }
90
+
91
+ function validateResponseMap(
92
+ workflow: WorkflowIR,
93
+ workflowSource?: SourceLocation,
94
+ ): IRDiagnostic[] {
95
+ const diagnostics: IRDiagnostic[] = [];
96
+ const outputProperties = schemaPropertyNames(workflow.outputSchema);
97
+ const stepIds = new Set(workflow.steps.map((step) => step.id));
98
+ const inputProperties = schemaPropertyNames(workflow.inputSchema);
99
+
100
+ for (const [key, ref] of Object.entries(workflow.responseMap)) {
101
+ const path = `responseMap.${key}`;
102
+ const location: SourceLocation = {
103
+ file: workflowSource?.file,
104
+ yamlPath: workflow.source?.kind === "legacy-yaml"
105
+ ? `workflow.output.${key}`
106
+ : undefined,
107
+ };
108
+
109
+ if (!outputProperties.has(key)) {
110
+ pushDiagnostic(diagnostics, {
111
+ severity: "error",
112
+ rule: "response_map_unknown_output_key",
113
+ message: `responseMap key '${key}' is not declared in outputSchema.properties.`,
114
+ fixHint: `Add '${key}' to outputSchema.properties or remove it from responseMap.`,
115
+ path,
116
+ }, location);
117
+ }
118
+
119
+ const refError = validateRefIR(ref, {
120
+ inputSchemaProperties: inputProperties,
121
+ stepIds,
122
+ }, path);
123
+ if (refError) {
124
+ pushDiagnostic(diagnostics, {
125
+ severity: "error",
126
+ rule: "invalid_ref",
127
+ message: refError,
128
+ path,
129
+ }, location);
130
+ }
131
+ }
132
+
133
+ for (const key of outputProperties) {
134
+ if (!(key in workflow.responseMap)) {
135
+ pushDiagnostic(diagnostics, {
136
+ severity: "warn",
137
+ rule: "output_key_not_mapped",
138
+ message: `outputSchema declares '${key}' but responseMap does not map it.`,
139
+ path: `outputSchema.properties.${key}`,
140
+ }, workflowSource);
141
+ }
142
+ }
143
+
144
+ return diagnostics;
145
+ }
146
+
147
+ function countPublicReturnSteps(steps: WorkflowStepIR[]): number {
148
+ return steps.filter((step) =>
149
+ step.return === true
150
+ || (step.node === "dypai.flow" && step.operation === "return"),
151
+ ).length;
152
+ }
153
+
154
+ export type ValidateWorkflowOptions = {
155
+ registry?: NodeRegistry;
156
+ capabilityCatalog?: CapabilityCatalog;
157
+ };
158
+
159
+ function resolveRegistry(options?: ValidateWorkflowOptions | NodeRegistry): NodeRegistry {
160
+ if (options instanceof NodeRegistryClass) return options;
161
+ if (options?.registry) return options.registry;
162
+ if (options?.capabilityCatalog) return NodeRegistryClass.withCatalog(options.capabilityCatalog);
163
+ return defaultNodeRegistry;
164
+ }
165
+
166
+ export function validateWorkflowIR(
167
+ workflow: unknown,
168
+ options?: ValidateWorkflowOptions | NodeRegistry,
169
+ ): IRValidationResult {
170
+ const registry = resolveRegistry(options);
171
+ const diagnostics: IRDiagnostic[] = [];
172
+
173
+ if (!isWorkflowIR(workflow)) {
174
+ return {
175
+ ok: false,
176
+ diagnostics: [{
177
+ severity: "error",
178
+ rule: "invalid_workflow_ir",
179
+ message: "Value is not a WorkflowIR v1 document.",
180
+ fixHint: `Expected version '${WORKFLOW_IR_VERSION}' with trigger, auth, schemas, steps, and responseMap.`,
181
+ }],
182
+ };
183
+ }
184
+
185
+ const workflowSource: SourceLocation | undefined = workflow.source
186
+ ? { file: workflow.source.file }
187
+ : undefined;
188
+
189
+ if (workflow.version !== WORKFLOW_IR_VERSION) {
190
+ pushDiagnostic(diagnostics, {
191
+ severity: "error",
192
+ rule: "unsupported_version",
193
+ message: `Unsupported WorkflowIR version '${workflow.version}'.`,
194
+ fixHint: `Use version '${WORKFLOW_IR_VERSION}'.`,
195
+ path: "version",
196
+ }, workflowSource);
197
+ }
198
+
199
+ if (!isValidEndpointName(workflow.name)) {
200
+ pushDiagnostic(diagnostics, {
201
+ severity: "error",
202
+ rule: "invalid_name",
203
+ message: `Workflow name '${workflow.name}' must be a kebab-case slug.`,
204
+ fixHint: "Use names like create-booking or list-slots.",
205
+ path: "name",
206
+ }, workflowSource);
207
+ }
208
+
209
+ if (workflow.trigger.type === "http") {
210
+ if (!isValidHttpMethod(workflow.trigger.method)) {
211
+ pushDiagnostic(diagnostics, {
212
+ severity: "error",
213
+ rule: "invalid_http_method",
214
+ message: `Unsupported HTTP method '${workflow.trigger.method}'.`,
215
+ fixHint: "Use GET, POST, PUT, PATCH, or DELETE.",
216
+ path: "trigger.method",
217
+ }, workflowSource);
218
+ }
219
+ } else if (workflow.trigger.type === "schedule") {
220
+ if (!workflow.trigger.cron?.trim()) {
221
+ pushDiagnostic(diagnostics, {
222
+ severity: "error",
223
+ rule: "schedule_missing_cron",
224
+ message: "Schedule trigger requires cron.",
225
+ path: "trigger.cron",
226
+ }, workflowSource);
227
+ }
228
+ }
229
+
230
+ const stepIds = new Set<string>();
231
+ for (const step of workflow.steps) {
232
+ if (stepIds.has(step.id)) {
233
+ pushDiagnostic(diagnostics, {
234
+ severity: "error",
235
+ rule: "duplicate_step_id",
236
+ message: `Duplicate step id '${step.id}'.`,
237
+ path: `steps.${step.id}`,
238
+ }, stepLocation(workflowSource, step));
239
+ }
240
+ stepIds.add(step.id);
241
+ }
242
+
243
+ const inputProperties = schemaPropertyNames(workflow.inputSchema);
244
+ const derivedResources: ResourceIR[] = [];
245
+ const derivedSideEffects = new Set<SideEffectKind>();
246
+
247
+ for (const step of workflow.steps) {
248
+ const location = stepLocation(workflowSource, step);
249
+ const resolved = registry.lookup(step.node, step.operation, step.version);
250
+
251
+ if (!resolved) {
252
+ const nodeDef = registry.getNode(step.node);
253
+ const availableOps = nodeDef ? Object.keys(nodeDef.operations).sort() : [];
254
+ pushDiagnostic(diagnostics, {
255
+ severity: "error",
256
+ rule: "unknown_node_operation",
257
+ message: `Step '${step.id}' uses unknown node operation ${step.node}.${step.operation}@v${step.version}.`,
258
+ fixHint: availableOps.length
259
+ ? `Available operations for ${step.node}: ${availableOps.join(", ")}`
260
+ : "Register the node in Node Registry v2 or fix node/operation/version.",
261
+ path: `steps.${step.id}`,
262
+ }, location);
263
+ continue;
264
+ }
265
+
266
+ const schemaIssues = validateJsonValueAgainstSchema(
267
+ sanitizeConfigForSchema(step.config),
268
+ resolved.operation.inputSchema,
269
+ `steps.${step.id}.config`,
270
+ );
271
+ for (const issue of schemaIssues) {
272
+ pushDiagnostic(diagnostics, {
273
+ severity: "error",
274
+ rule: "step_config_schema_mismatch",
275
+ message: `Step '${step.id}' config ${issue.message} at ${issue.path}.`,
276
+ path: issue.path,
277
+ }, location);
278
+ }
279
+
280
+ if (resolved.operation.validate) {
281
+ for (const diagnostic of resolved.operation.validate(step.config, step.refs)) {
282
+ pushDiagnostic(diagnostics, {
283
+ ...diagnostic,
284
+ path: diagnostic.path ?? `steps.${step.id}.config`,
285
+ }, location);
286
+ }
287
+ }
288
+
289
+ diagnostics.push(...validateStepRefs(step, {
290
+ inputProperties,
291
+ stepIds,
292
+ }, workflowSource));
293
+
294
+ for (const resource of registry.deriveResources(step.node, step.operation, step.version, step.config)) {
295
+ derivedResources.push(resource);
296
+ }
297
+ for (const effect of registry.deriveSideEffects(step.node, step.operation, step.version)) {
298
+ derivedSideEffects.add(effect);
299
+ }
300
+ }
301
+
302
+ diagnostics.push(...validateResponseMap(workflow, workflowSource));
303
+
304
+ const returnSteps = countPublicReturnSteps(workflow.steps);
305
+ if (returnSteps === 0) {
306
+ pushDiagnostic(diagnostics, {
307
+ severity: "error",
308
+ rule: "missing_public_return",
309
+ message: "Workflow must declare exactly one public return step.",
310
+ fixHint: "Add a dypai.flow.return step with return: true.",
311
+ path: "steps",
312
+ }, workflowSource);
313
+ } else if (returnSteps > 1) {
314
+ pushDiagnostic(diagnostics, {
315
+ severity: "error",
316
+ rule: "multiple_public_returns",
317
+ message: `Workflow declares ${returnSteps} public return steps; expected exactly one.`,
318
+ path: "steps",
319
+ }, workflowSource);
320
+ }
321
+
322
+ if (workflow.edges?.length) {
323
+ for (const edge of workflow.edges) {
324
+ if (edge.from !== "$trigger" && !stepIds.has(edge.from)) {
325
+ pushDiagnostic(diagnostics, {
326
+ severity: "error",
327
+ rule: "edge_unknown_from_step",
328
+ message: `Edge from '${edge.from}' references an unknown step.`,
329
+ path: "edges",
330
+ }, workflowSource);
331
+ }
332
+ if (!stepIds.has(edge.to)) {
333
+ pushDiagnostic(diagnostics, {
334
+ severity: "error",
335
+ rule: "edge_unknown_to_step",
336
+ message: `Edge to '${edge.to}' references an unknown step.`,
337
+ path: "edges",
338
+ }, workflowSource);
339
+ }
340
+ }
341
+ }
342
+
343
+ const hasErrors = diagnostics.some((item) => item.severity === "error");
344
+ return {
345
+ ok: !hasErrors,
346
+ diagnostics,
347
+ derivedResources,
348
+ derivedSideEffects: [...derivedSideEffects].sort(),
349
+ };
350
+ }
351
+
352
+ export function assertValidWorkflowIR(
353
+ workflow: unknown,
354
+ options?: ValidateWorkflowOptions | NodeRegistry,
355
+ ): WorkflowIR {
356
+ const result = validateWorkflowIR(workflow, options);
357
+ if (!result.ok || !isWorkflowIR(workflow)) {
358
+ const message = result.diagnostics
359
+ .filter((item) => item.severity === "error")
360
+ .map((item) => item.message)
361
+ .join("\n");
362
+ throw new Error(message || "Invalid WorkflowIR");
363
+ }
364
+ return workflow;
365
+ }
@@ -0,0 +1,19 @@
1
+ import type { IRDiagnostic, NodeDefinition, OperationDefinition } from "../ir/types";
2
+
3
+ export function defineOperation(definition: OperationDefinition): OperationDefinition {
4
+ return definition;
5
+ }
6
+
7
+ export function defineNode(definition: NodeDefinition): NodeDefinition {
8
+ return definition;
9
+ }
10
+
11
+ export function operationKey(nodeId: string, operation: string, version: number): string {
12
+ return `${nodeId}@${operation}@v${version}`;
13
+ }
14
+
15
+ export function mergeDiagnostics(
16
+ ...groups: Array<IRDiagnostic[] | undefined>
17
+ ): IRDiagnostic[] {
18
+ return groups.flatMap((group) => group ?? []);
19
+ }