@decocms/bindings 1.0.1 → 1.0.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/bindings",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
@@ -230,7 +230,6 @@ export const LanguageModelMessageSchema = z.union([
230
230
  export const LanguageModelPromptSchema = z
231
231
  .array(LanguageModelMessageSchema)
232
232
  .describe("A list of messages forming the prompt");
233
-
234
233
  /**
235
234
  * Language Model Call Options Schema
236
235
  * Based on LanguageModelV2CallOptions from @ai-sdk/provider
@@ -9,113 +9,137 @@
9
9
  */
10
10
 
11
11
  import { z } from "zod";
12
- import type { Binder } from "../core/binder";
12
+ import { type Binder, bindingClient, type ToolBinder } from "../core/binder";
13
13
  import {
14
14
  BaseCollectionEntitySchema,
15
15
  createCollectionBindings,
16
16
  } from "./collections";
17
-
18
17
  export const ToolCallActionSchema = z.object({
19
- connectionId: z.string().describe("Integration connection ID"),
20
- toolName: z.string().describe("Name of the tool to call"),
18
+ toolName: z
19
+ .string()
20
+ .describe("Name of the tool to invoke on that connection"),
21
+ transformCode: z
22
+ .string()
23
+ .optional()
24
+ .describe(`Pure TypeScript function for data transformation of the tool call result. Must be a TypeScript file that declares the Output interface and exports a default function: \`interface Output { ... } export default async function(input): Output { ... }\`
25
+ The input will match with the tool call outputSchema. If transformCode is not provided, the tool call result will be used as the step output.
26
+ Providing an transformCode is recommended because it both allows you to transform the data and validate it against a JSON Schema - tools are ephemeral and may return unexpected data.`),
21
27
  });
22
28
  export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
23
29
 
24
30
  export const CodeActionSchema = z.object({
25
- code: z.string().describe("TypeScript code for pure data transformation"),
31
+ code: z.string().describe(
32
+ `Pure TypeScript function for data transformation. Useful to merge data from multiple steps and transform it. Must be a TypeScript file that declares the Output interface and exports a default function: \`interface Output { ... } export default async function(input): Output { ... }\`
33
+ The input is the resolved value of the references in the input field. Example:
34
+ {
35
+ "input": {
36
+ "name": "@Step_1.name",
37
+ "age": "@Step_2.age"
38
+ },
39
+ "code": "export default function(input): Output { return { result: \`\${input.name} is \${input.age} years old.\` } }"
40
+ }
41
+ `,
42
+ ),
26
43
  });
27
44
  export type CodeAction = z.infer<typeof CodeActionSchema>;
28
- export const SleepActionSchema = z.union([
29
- z.object({
30
- sleepMs: z.number().describe("Milliseconds to sleep"),
31
- }),
32
- z.object({
33
- sleepUntil: z.string().describe("ISO date string or @ref to sleep until"),
34
- }),
35
- ]);
36
45
 
37
46
  export const WaitForSignalActionSchema = z.object({
38
47
  signalName: z
39
48
  .string()
40
- .describe("Name of the signal to wait for (must be unique per execution)"),
41
- timeoutMs: z
42
- .number()
43
- .optional()
44
- .describe("Maximum time to wait in milliseconds (default: no timeout)"),
45
- description: z
46
- .string()
47
- .optional()
48
- .describe("Human-readable description of what this signal is waiting for"),
49
+ .describe(
50
+ "Signal name to wait for (e.g., 'approval'). Execution pauses until SEND_SIGNAL is called with this name.",
51
+ ),
49
52
  });
50
53
  export type WaitForSignalAction = z.infer<typeof WaitForSignalActionSchema>;
51
54
 
52
55
  export const StepActionSchema = z.union([
53
- ToolCallActionSchema.describe(
54
- "Call an external tool (non-deterministic, checkpointed)",
55
- ),
56
+ ToolCallActionSchema.describe("Call an external tool via MCP connection. "),
56
57
  CodeActionSchema.describe(
57
- "Pure TypeScript data transformation (deterministic, replayable)",
58
+ "Run pure TypeScript code for data transformation. Useful to merge data from multiple steps and transform it.",
58
59
  ),
59
- SleepActionSchema.describe("Wait for time"),
60
- WaitForSignalActionSchema.describe("Wait for external signal"),
60
+ // WaitForSignalActionSchema.describe(
61
+ // "Pause execution until an external signal is received (human-in-the-loop)",
62
+ // ),
61
63
  ]);
62
64
  export type StepAction = z.infer<typeof StepActionSchema>;
65
+
63
66
  /**
64
- * Step Schema - Unified schema for all step types
65
- *
66
- * Step types:
67
- * - tool: Call external service via MCP (non-deterministic, checkpointed)
68
- * - transform: Pure TypeScript data transformation (deterministic, replayable)
69
- * - sleep: Wait for time
70
- * - waitForSignal: Block until external signal (human-in-the-loop)
67
+ * Step Config Schema - Optional configuration for retry, timeout, and looping
71
68
  */
72
- export const StepSchema = z.object({
73
- name: z.string().min(1).describe("Unique step name within workflow"),
74
- action: StepActionSchema,
75
- input: z
76
- .record(z.unknown())
69
+ export const StepConfigSchema = z.object({
70
+ maxAttempts: z
71
+ .number()
77
72
  .optional()
78
- .describe(
79
- "Input object with @ref resolution or default values. Example: { 'user_id': '@input.user_id', 'product_id': '@input.product_id' }",
80
- ),
81
- config: z
82
- .object({
83
- maxAttempts: z.number().default(3).describe("Maximum retry attempts"),
84
- backoffMs: z
85
- .number()
86
- .default(1000)
87
- .describe("Initial backoff in milliseconds"),
88
- timeoutMs: z.number().default(10000).describe("Timeout in milliseconds"),
89
- })
73
+ .describe("Max retry attempts on failure (default: 1, no retries)"),
74
+ backoffMs: z
75
+ .number()
76
+ .optional()
77
+ .describe("Initial delay between retries in ms (doubles each attempt)"),
78
+ timeoutMs: z
79
+ .number()
90
80
  .optional()
91
- .describe("Step configuration (max attempts, backoff, timeout)"),
81
+ .describe("Max execution time in ms before step fails (default: 30000)"),
92
82
  });
93
-
94
- export type Step = z.infer<typeof StepSchema>;
83
+ export type StepConfig = z.infer<typeof StepConfigSchema>;
95
84
 
96
85
  /**
97
- * Trigger Schema - Fire another workflow when execution completes
86
+ * Step Schema - A single unit of work in a workflow
87
+ *
88
+ * Action types:
89
+ * - Tool call: Invoke an external tool via MCP connection
90
+ * - Code: Run pure TypeScript for data transformation
91
+ * - Wait for signal: Pause until external input (human-in-the-loop)
92
+ *
93
+ * Data flow uses @ref syntax:
94
+ * - @input.field → workflow input
95
+ * - @stepName.field → output from a previous step
98
96
  */
99
- export const TriggerSchema = z.object({
100
- /**
101
- * Target workflow ID to execute
102
- */
103
- workflowId: z.string().describe("Target workflow ID to trigger"),
104
-
105
- /**
106
- * Input for the new execution (uses @refs like step inputs)
107
- * Maps output data to workflow input fields.
108
- *
109
- * If any @ref doesn't resolve (property missing), this trigger is SKIPPED.
110
- */
97
+
98
+ type JsonSchema = {
99
+ type?: string;
100
+ properties?: Record<string, unknown>;
101
+ required?: string[];
102
+ description?: string;
103
+ additionalProperties?: boolean;
104
+ additionalItems?: boolean;
105
+ items?: JsonSchema;
106
+ };
107
+ const JsonSchemaSchema: z.ZodType<JsonSchema> = z.lazy(() =>
108
+ z
109
+ .object({
110
+ type: z.string().optional(),
111
+ properties: z.record(z.unknown()).optional(),
112
+ required: z.array(z.string()).optional(),
113
+ description: z.string().optional(),
114
+ additionalProperties: z.boolean().optional(),
115
+ additionalItems: z.boolean().optional(),
116
+ items: JsonSchemaSchema.optional(),
117
+ })
118
+ .passthrough(),
119
+ );
120
+
121
+ export const StepSchema = z.object({
122
+ name: z
123
+ .string()
124
+ .min(1)
125
+ .describe(
126
+ "Unique identifier for this step. Other steps reference its output as @name.field",
127
+ ),
128
+ description: z.string().optional().describe("What this step does"),
129
+ action: StepActionSchema,
111
130
  input: z
112
131
  .record(z.unknown())
132
+ .optional()
113
133
  .describe(
114
- "Input mapping with @refs from current workflow output. Example: { 'user_id': '@stepName.output.user_id' }",
134
+ "Data passed to the action. Use @ref for dynamic values: @input.field (workflow input), @stepName.field (previous step output), @item/@index (loop context). Example: { 'userId': '@input.user_id', 'data': '@fetch.result' }",
115
135
  ),
136
+ outputSchema: JsonSchemaSchema.optional().describe(
137
+ "Optional JSON Schema describing the expected output of the step.",
138
+ ),
139
+ config: StepConfigSchema.optional().describe("Retry and timeout settings"),
116
140
  });
117
141
 
118
- export type Trigger = z.infer<typeof TriggerSchema>;
142
+ export type Step = z.infer<typeof StepSchema>;
119
143
 
120
144
  /**
121
145
  * Workflow Execution Status
@@ -128,8 +152,8 @@ export type Trigger = z.infer<typeof TriggerSchema>;
128
152
  */
129
153
 
130
154
  const WorkflowExecutionStatusEnum = z
131
- .enum(["pending", "running", "completed", "cancelled"])
132
- .default("pending");
155
+ .enum(["enqueued", "running", "success", "error", "failed", "cancelled"])
156
+ .default("enqueued");
133
157
  export type WorkflowExecutionStatus = z.infer<
134
158
  typeof WorkflowExecutionStatusEnum
135
159
  >;
@@ -140,38 +164,48 @@ export type WorkflowExecutionStatus = z.infer<
140
164
  * Includes lock columns and retry tracking.
141
165
  */
142
166
  export const WorkflowExecutionSchema = BaseCollectionEntitySchema.extend({
143
- workflow_id: z.string(),
144
- status: WorkflowExecutionStatusEnum,
145
- input: z.record(z.unknown()).optional(),
146
- output: z.unknown().optional(),
147
- parent_execution_id: z.string().nullish(),
148
- completed_at_epoch_ms: z.number().nullish(),
149
- locked_until_epoch_ms: z.number().nullish(),
150
- lock_id: z.string().nullish(),
151
- retry_count: z.number().default(0),
152
- max_retries: z.number().default(10),
153
- error: z.string().nullish(),
167
+ steps: z
168
+ .array(StepSchema)
169
+ .describe("Steps that make up the workflow")
170
+ .describe("Workflow that was executed"),
171
+ gateway_id: z
172
+ .string()
173
+ .describe("ID of the gateway that will be used to execute the workflow"),
174
+ status: WorkflowExecutionStatusEnum.describe(
175
+ "Current status of the workflow execution",
176
+ ),
177
+ input: z
178
+ .record(z.unknown())
179
+ .optional()
180
+ .describe("Input data for the workflow execution"),
181
+ output: z
182
+ .unknown()
183
+ .optional()
184
+ .describe("Output data for the workflow execution"),
185
+ completed_at_epoch_ms: z
186
+ .number()
187
+ .nullish()
188
+ .describe("Timestamp of when the workflow execution completed"),
189
+ start_at_epoch_ms: z
190
+ .number()
191
+ .nullish()
192
+ .describe("Timestamp of when the workflow execution started or will start"),
193
+ timeout_ms: z
194
+ .number()
195
+ .nullish()
196
+ .describe("Timeout in milliseconds for the workflow execution"),
197
+ deadline_at_epoch_ms: z
198
+ .number()
199
+ .nullish()
200
+ .describe(
201
+ "Deadline for the workflow execution - when the workflow execution will be cancelled if it is not completed. This is read-only and is set by the workflow engine when an execution is created.",
202
+ ),
203
+ error: z
204
+ .unknown()
205
+ .describe("Error that occurred during the workflow execution"),
154
206
  });
155
207
  export type WorkflowExecution = z.infer<typeof WorkflowExecutionSchema>;
156
208
 
157
- /**
158
- * Execution Step Result Schema
159
- *
160
- * Includes attempt tracking and error history.
161
- */
162
- export const WorkflowExecutionStepResultSchema =
163
- BaseCollectionEntitySchema.extend({
164
- execution_id: z.string(),
165
- step_id: z.string(),
166
-
167
- input: z.record(z.unknown()).nullish(),
168
- output: z.unknown().nullish(), // Can be object or array (forEach steps produce arrays)
169
- error: z.string().nullish(),
170
- completed_at_epoch_ms: z.number().nullish(),
171
- });
172
- export type WorkflowExecutionStepResult = z.infer<
173
- typeof WorkflowExecutionStepResultSchema
174
- >;
175
209
  /**
176
210
  * Event Type Enum
177
211
  *
@@ -216,29 +250,35 @@ export const WorkflowEventSchema = BaseCollectionEntitySchema.extend({
216
250
  export type WorkflowEvent = z.infer<typeof WorkflowEventSchema>;
217
251
 
218
252
  /**
219
- * Workflow entity schema for workflows
220
- * Extends BaseCollectionEntitySchema with workflow-specific fields
221
- * Base schema already includes: id, title, created_at, updated_at, created_by, updated_by
253
+ * Workflow Schema - A sequence of steps that execute with data flowing between them
254
+ *
255
+ * Key concepts:
256
+ * - Steps run in parallel unless they reference each other via @ref
257
+ * - Use @ref to wire data: @input.field, @stepName.field, @item (in loops)
258
+ * - Execution order is auto-determined from @ref dependencies
259
+ *
260
+ * Example: 2 parallel fetches + 1 merge step
261
+ * {
262
+ * "title": "Fetch and Merge",
263
+ * "steps": [
264
+ * { "name": "fetch_users", "action": { "connectionId": "api", "toolName": "getUsers" } },
265
+ * { "name": "fetch_orders", "action": { "connectionId": "api", "toolName": "getOrders" } },
266
+ * { "name": "merge", "action": { "code": "..." }, "input": { "users": "@fetch_users.data", "orders": "@fetch_orders.data" } }
267
+ * ]
268
+ * }
269
+ * → fetch_users and fetch_orders run in parallel; merge waits for both
222
270
  */
223
271
  export const WorkflowSchema = BaseCollectionEntitySchema.extend({
224
- description: z.string().optional().describe("Workflow description"),
272
+ description: z
273
+ .string()
274
+ .optional()
275
+ .describe("Human-readable summary of what this workflow does"),
225
276
 
226
- /**
227
- * Steps organized into phases.
228
- * - Phases execute sequentially
229
- * - Steps within a phase execute in parallel
230
- */
231
277
  steps: z
232
- .array(z.array(StepSchema))
233
- .describe("2D array: phases (sequential) containing steps (parallel)"),
234
-
235
- /**
236
- * Triggers to fire when execution completes successfully
237
- */
238
- triggers: z
239
- .array(TriggerSchema)
240
- .optional()
241
- .describe("Workflows to trigger on completion"),
278
+ .array(StepSchema)
279
+ .describe(
280
+ "Ordered list of steps. Execution order is auto-determined by @ref dependencies: steps with no @ref dependencies run in parallel; steps referencing @stepName wait for that step to complete.",
281
+ ),
242
282
  });
243
283
 
244
284
  export type Workflow = z.infer<typeof WorkflowSchema>;
@@ -254,29 +294,95 @@ export const WORKFLOWS_COLLECTION_BINDING = createCollectionBindings(
254
294
  WorkflowSchema,
255
295
  );
256
296
 
257
- export const WORKFLOW_EXECUTIONS_COLLECTION_BINDING = createCollectionBindings(
258
- "workflow_execution",
259
- WorkflowExecutionSchema,
260
- {
261
- readOnly: true,
297
+ const DEFAULT_STEP_CONFIG: StepConfig = {
298
+ maxAttempts: 1,
299
+ timeoutMs: 30000,
300
+ };
301
+
302
+ // export const DEFAULT_WAIT_FOR_SIGNAL_STEP: Omit<Step, "name"> = {
303
+ // action: {
304
+ // signalName: "approve_output",
305
+ // },
306
+ // outputSchema: {
307
+ // type: "object",
308
+ // properties: {
309
+ // approved: {
310
+ // type: "boolean",
311
+ // description: "Whether the output was approved",
312
+ // },
313
+ // },
314
+ // },
315
+ // };
316
+ export const DEFAULT_TOOL_STEP: Omit<Step, "name"> = {
317
+ action: {
318
+ toolName: "LLM_DO_GENERATE",
319
+ transformCode: `
320
+ interface Input {
321
+
322
+ }
323
+ export default function(input) { return input.result }`,
324
+ },
325
+ input: {
326
+ modelId: "anthropic/claude-4.5-haiku",
327
+ prompt: "Write a haiku about the weather.",
262
328
  },
263
- );
264
329
 
265
- export const WORKFLOW_STEP_RESULTS_COLLECTION_BINDING =
266
- createCollectionBindings(
267
- "workflow_execution_step_results",
268
- WorkflowExecutionStepResultSchema,
269
- {
270
- readOnly: true,
330
+ config: DEFAULT_STEP_CONFIG,
331
+ outputSchema: {
332
+ type: "object",
333
+ properties: {
334
+ result: {
335
+ type: "string",
336
+ description: "The result of the step",
337
+ },
271
338
  },
272
- );
339
+ },
340
+ };
341
+ export const DEFAULT_CODE_STEP: Step = {
342
+ name: "Initial Step",
343
+ action: {
344
+ code: `
345
+ interface Input {
346
+ example: string;
347
+ }
273
348
 
274
- export const WORKFLOW_EVENTS_COLLECTION_BINDING = createCollectionBindings(
275
- "workflow_events",
276
- WorkflowEventSchema,
277
- {
278
- readOnly: true,
349
+ interface Output {
350
+ result: unknown;
351
+ }
352
+
353
+ export default async function(input: Input): Promise<Output> {
354
+ return {
355
+ result: input.example
356
+ }
357
+ }`,
279
358
  },
359
+ config: DEFAULT_STEP_CONFIG,
360
+ outputSchema: {
361
+ type: "object",
362
+ properties: {
363
+ result: {
364
+ type: "string",
365
+ description: "The result of the step",
366
+ },
367
+ },
368
+ required: ["result"],
369
+ description:
370
+ "The output of the step. This is a JSON Schema describing the expected output of the step.",
371
+ },
372
+ };
373
+
374
+ export const createDefaultWorkflow = (id?: string): Workflow => ({
375
+ id: id || crypto.randomUUID(),
376
+ title: "Default Workflow",
377
+ description: "The default workflow for the toolkit",
378
+ steps: [DEFAULT_CODE_STEP],
379
+ created_at: new Date().toISOString(),
380
+ updated_at: new Date().toISOString(),
381
+ });
382
+
383
+ export const WORKFLOW_EXECUTIONS_COLLECTION_BINDING = createCollectionBindings(
384
+ "workflow_execution",
385
+ WorkflowExecutionSchema,
280
386
  );
281
387
 
282
388
  /**
@@ -289,9 +395,275 @@ export const WORKFLOW_EVENTS_COLLECTION_BINDING = createCollectionBindings(
289
395
  * - COLLECTION_WORKFLOW_LIST: List available workflows with their configurations
290
396
  * - COLLECTION_WORKFLOW_GET: Get a single workflow by ID (includes steps and triggers)
291
397
  */
292
- export const WORKFLOWS_BINDING = [
398
+ export const WORKFLOW_COLLECTIONS_BINDINGS = [
293
399
  ...WORKFLOWS_COLLECTION_BINDING,
294
400
  ...WORKFLOW_EXECUTIONS_COLLECTION_BINDING,
295
- ...WORKFLOW_STEP_RESULTS_COLLECTION_BINDING,
296
- ...WORKFLOW_EVENTS_COLLECTION_BINDING,
297
401
  ] as const satisfies Binder;
402
+
403
+ export const WORKFLOW_BINDING = [
404
+ ...WORKFLOW_COLLECTIONS_BINDINGS,
405
+ ] satisfies ToolBinder[];
406
+
407
+ export const WorkflowBinding = bindingClient(WORKFLOW_BINDING);
408
+
409
+ export const WORKFLOW_EXECUTION_BINDING = createCollectionBindings(
410
+ "workflow_execution",
411
+ WorkflowExecutionSchema,
412
+ );
413
+
414
+ /**
415
+ * DAG (Directed Acyclic Graph) utilities for workflow step execution
416
+ *
417
+ * Pure TypeScript functions for analyzing step dependencies and grouping
418
+ * steps into execution levels for parallel execution.
419
+ *
420
+ * Can be used in both frontend (visualization) and backend (execution).
421
+ */
422
+
423
+ /**
424
+ * Minimal step interface for DAG computation.
425
+ * This allows the DAG utilities to work with any step-like object.
426
+ */
427
+ export interface DAGStep {
428
+ name: string;
429
+ input?: unknown;
430
+ }
431
+
432
+ /**
433
+ * Extract all @ref references from a value recursively.
434
+ * Finds patterns like @stepName or @stepName.field
435
+ *
436
+ * @param input - Any value that might contain @ref strings
437
+ * @returns Array of unique reference names (without @ prefix)
438
+ */
439
+ export function getAllRefs(input: unknown): string[] {
440
+ const refs: string[] = [];
441
+
442
+ function traverse(value: unknown) {
443
+ if (typeof value === "string") {
444
+ const matches = value.match(/@(\w+)/g);
445
+ if (matches) {
446
+ refs.push(...matches.map((m) => m.substring(1))); // Remove @ prefix
447
+ }
448
+ } else if (Array.isArray(value)) {
449
+ value.forEach(traverse);
450
+ } else if (typeof value === "object" && value !== null) {
451
+ Object.values(value).forEach(traverse);
452
+ }
453
+ }
454
+
455
+ traverse(input);
456
+ return [...new Set(refs)].sort(); // Dedupe and sort for consistent results
457
+ }
458
+
459
+ /**
460
+ * Get the dependencies of a step (other steps it references).
461
+ * Only returns dependencies that are actual step names (filters out built-ins like "item", "index", "input").
462
+ *
463
+ * @param step - The step to analyze
464
+ * @param allStepNames - Set of all step names in the workflow
465
+ * @returns Array of step names this step depends on
466
+ */
467
+ export function getStepDependencies(
468
+ step: DAGStep,
469
+ allStepNames: Set<string>,
470
+ ): string[] {
471
+ const deps: string[] = [];
472
+
473
+ function traverse(value: unknown) {
474
+ if (typeof value === "string") {
475
+ // Match @stepName or @stepName.something patterns
476
+ const matches = value.match(/@(\w+)/g);
477
+ if (matches) {
478
+ for (const match of matches) {
479
+ const refName = match.substring(1); // Remove @
480
+ // Only count as dependency if it references another step
481
+ // (not "item", "index", "input" from forEach or workflow input)
482
+ if (allStepNames.has(refName)) {
483
+ deps.push(refName);
484
+ }
485
+ }
486
+ }
487
+ } else if (Array.isArray(value)) {
488
+ value.forEach(traverse);
489
+ } else if (typeof value === "object" && value !== null) {
490
+ Object.values(value).forEach(traverse);
491
+ }
492
+ }
493
+
494
+ traverse(step.input);
495
+ return [...new Set(deps)];
496
+ }
497
+
498
+ /**
499
+ * Build edges for the DAG: [fromStep, toStep][]
500
+ */
501
+ export function buildDagEdges(steps: Step[]): [string, string][] {
502
+ const stepNames = new Set(steps.map((s) => s.name));
503
+ const edges: [string, string][] = [];
504
+
505
+ for (const step of steps) {
506
+ const deps = getStepDependencies(step, stepNames);
507
+ for (const dep of deps) {
508
+ edges.push([dep, step.name]);
509
+ }
510
+ }
511
+
512
+ return edges;
513
+ }
514
+
515
+ /**
516
+ * Compute topological levels for all steps.
517
+ * Level 0 = no dependencies on other steps
518
+ * Level N = depends on at least one step at level N-1
519
+ *
520
+ * @param steps - Array of steps to analyze
521
+ * @returns Map from step name to level number
522
+ */
523
+ export function computeStepLevels<T extends DAGStep>(
524
+ steps: T[],
525
+ ): Map<string, number> {
526
+ const stepNames = new Set(steps.map((s) => s.name));
527
+ const levels = new Map<string, number>();
528
+
529
+ // Build dependency map
530
+ const depsMap = new Map<string, string[]>();
531
+ for (const step of steps) {
532
+ depsMap.set(step.name, getStepDependencies(step, stepNames));
533
+ }
534
+
535
+ // Compute level for each step (with memoization)
536
+ function getLevel(stepName: string, visited: Set<string>): number {
537
+ if (levels.has(stepName)) return levels.get(stepName)!;
538
+ if (visited.has(stepName)) return 0; // Cycle detection
539
+
540
+ visited.add(stepName);
541
+ const deps = depsMap.get(stepName) || [];
542
+
543
+ if (deps.length === 0) {
544
+ levels.set(stepName, 0);
545
+ return 0;
546
+ }
547
+
548
+ const maxDepLevel = Math.max(...deps.map((d) => getLevel(d, visited)));
549
+ const level = maxDepLevel + 1;
550
+ levels.set(stepName, level);
551
+ return level;
552
+ }
553
+
554
+ for (const step of steps) {
555
+ getLevel(step.name, new Set());
556
+ }
557
+
558
+ return levels;
559
+ }
560
+
561
+ /**
562
+ * Group steps by their execution level.
563
+ * Steps at the same level have no dependencies on each other and can run in parallel.
564
+ *
565
+ * @param steps - Array of steps to group
566
+ * @returns Array of step arrays, where index is the level
567
+ */
568
+ export function groupStepsByLevel<T extends DAGStep>(steps: T[]): T[][] {
569
+ const levels = computeStepLevels(steps);
570
+ const maxLevel = Math.max(...Array.from(levels.values()), -1);
571
+
572
+ const grouped: T[][] = [];
573
+ for (let level = 0; level <= maxLevel; level++) {
574
+ const stepsAtLevel = steps.filter((s) => levels.get(s.name) === level);
575
+ if (stepsAtLevel.length > 0) {
576
+ grouped.push(stepsAtLevel);
577
+ }
578
+ }
579
+
580
+ return grouped;
581
+ }
582
+
583
+ /**
584
+ * Get the dependency signature for a step (for grouping steps with same deps).
585
+ *
586
+ * @param step - The step to get signature for
587
+ * @returns Comma-separated sorted list of dependencies
588
+ */
589
+ export function getRefSignature(step: DAGStep): string {
590
+ const inputRefs = getAllRefs(step.input);
591
+ const allRefs = [...new Set([...inputRefs])].sort();
592
+ return allRefs.join(",");
593
+ }
594
+
595
+ /**
596
+ * Build a dependency graph for visualization.
597
+ * Returns edges as [fromStep, toStep] pairs.
598
+ *
599
+ * @param steps - Array of steps
600
+ * @returns Array of [source, target] pairs representing edges
601
+ */
602
+ export function buildDependencyEdges<T extends DAGStep>(
603
+ steps: T[],
604
+ ): [string, string][] {
605
+ const stepNames = new Set(steps.map((s) => s.name));
606
+ const edges: [string, string][] = [];
607
+
608
+ for (const step of steps) {
609
+ const deps = getStepDependencies(step, stepNames);
610
+ for (const dep of deps) {
611
+ edges.push([dep, step.name]);
612
+ }
613
+ }
614
+
615
+ return edges;
616
+ }
617
+
618
+ /**
619
+ * Validate that there are no cycles in the step dependencies.
620
+ *
621
+ * @param steps - Array of steps to validate
622
+ * @returns Object with isValid and optional error message
623
+ */
624
+ export function validateNoCycles<T extends DAGStep>(
625
+ steps: T[],
626
+ ): { isValid: boolean; error?: string } {
627
+ const stepNames = new Set(steps.map((s) => s.name));
628
+ const depsMap = new Map<string, string[]>();
629
+
630
+ for (const step of steps) {
631
+ depsMap.set(step.name, getStepDependencies(step, stepNames));
632
+ }
633
+
634
+ const visited = new Set<string>();
635
+ const recursionStack = new Set<string>();
636
+
637
+ function hasCycle(stepName: string, path: string[]): string[] | null {
638
+ if (recursionStack.has(stepName)) {
639
+ return [...path, stepName];
640
+ }
641
+ if (visited.has(stepName)) {
642
+ return null;
643
+ }
644
+
645
+ visited.add(stepName);
646
+ recursionStack.add(stepName);
647
+
648
+ const deps = depsMap.get(stepName) || [];
649
+ for (const dep of deps) {
650
+ const cycle = hasCycle(dep, [...path, stepName]);
651
+ if (cycle) return cycle;
652
+ }
653
+
654
+ recursionStack.delete(stepName);
655
+ return null;
656
+ }
657
+
658
+ for (const step of steps) {
659
+ const cycle = hasCycle(step.name, []);
660
+ if (cycle) {
661
+ return {
662
+ isValid: false,
663
+ error: `Circular dependency detected: ${cycle.join(" -> ")}`,
664
+ };
665
+ }
666
+ }
667
+
668
+ return { isValid: true };
669
+ }