keystone-cli 1.2.0 → 1.3.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.
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Dynamic State Manager
3
+ *
4
+ * Manages persistence for dynamic workflow steps, enabling resumability
5
+ * of LLM-orchestrated workflows.
6
+ */
7
+
8
+ import type { Database } from 'bun:sqlite';
9
+ import { randomUUID } from 'node:crypto';
10
+ import type {
11
+ DynamicPlan,
12
+ DynamicStepExecution,
13
+ DynamicStepState,
14
+ GeneratedStep,
15
+ } from '../runner/executors/dynamic-types.ts';
16
+
17
+ export type { DynamicPlan, DynamicStepExecution, DynamicStepState, GeneratedStep };
18
+ import type { WorkflowDb } from './workflow-db.ts';
19
+
20
+ /**
21
+ * Raw row from dynamic_workflow_state table
22
+ */
23
+ interface DynamicStateRow {
24
+ id: string;
25
+ run_id: string;
26
+ step_id: string;
27
+ workflow_id: string | null;
28
+ status: string;
29
+ generated_plan: string;
30
+ current_step_index: number;
31
+ started_at: string;
32
+ completed_at: string | null;
33
+ error: string | null;
34
+ metadata: string | null;
35
+ replan_count: number;
36
+ }
37
+
38
+ /**
39
+ * Raw row from dynamic_step_executions table
40
+ */
41
+ interface DynamicExecRow {
42
+ id: string;
43
+ state_id: string;
44
+ step_id: string;
45
+ step_name: string;
46
+ step_type: string;
47
+ step_definition: string;
48
+ status: string;
49
+ output: string | null;
50
+ error: string | null;
51
+ started_at: string | null;
52
+ completed_at: string | null;
53
+ execution_order: number;
54
+ }
55
+
56
+ /**
57
+ * Manages persistence for dynamic workflow steps
58
+ */
59
+ export class DynamicStateManager {
60
+ constructor(private db: WorkflowDb) {}
61
+
62
+ /**
63
+ * Access the underlying database for direct queries
64
+ */
65
+ private getDatabase(): Database {
66
+ return this.db.getDatabase();
67
+ }
68
+
69
+ /**
70
+ * Create a new dynamic state record
71
+ */
72
+ async create(params: {
73
+ runId: string;
74
+ stepId: string;
75
+ workflowId?: string;
76
+ }): Promise<DynamicStepState> {
77
+ const id = randomUUID();
78
+ const now = new Date().toISOString();
79
+
80
+ const state: DynamicStepState = {
81
+ id,
82
+ runId: params.runId,
83
+ stepId: params.stepId,
84
+ workflowId: params.workflowId || params.runId, // Fallback to runId if workflowId not provided
85
+ status: 'planning',
86
+ generatedPlan: { steps: [] },
87
+ currentStepIndex: 0,
88
+ stepResults: new Map(),
89
+ startedAt: now,
90
+ replanCount: 0,
91
+ };
92
+
93
+ const db = this.getDatabase();
94
+ db.prepare(`
95
+ INSERT INTO dynamic_workflow_state
96
+ (id, run_id, step_id, workflow_id, status, generated_plan, current_step_index, started_at, replan_count)
97
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
98
+ `).run(
99
+ id,
100
+ params.runId,
101
+ params.stepId,
102
+ params.workflowId || null,
103
+ 'planning',
104
+ JSON.stringify(state.generatedPlan),
105
+ 0,
106
+ now,
107
+ 0
108
+ );
109
+
110
+ return state;
111
+ }
112
+
113
+ /**
114
+ * Load existing state for resume
115
+ */
116
+ async load(runId: string, stepId: string): Promise<DynamicStepState | null> {
117
+ const db = this.getDatabase();
118
+ const row = db
119
+ .prepare(`
120
+ SELECT * FROM dynamic_workflow_state
121
+ WHERE run_id = ? AND step_id = ?
122
+ `)
123
+ .get(runId, stepId) as DynamicStateRow | null;
124
+
125
+ if (!row) return null;
126
+
127
+ return this.rowToState(row);
128
+ }
129
+
130
+ /**
131
+ * Load state by ID
132
+ */
133
+ async loadById(id: string): Promise<DynamicStepState | null> {
134
+ const db = this.getDatabase();
135
+ const row = db
136
+ .prepare(`
137
+ SELECT * FROM dynamic_workflow_state WHERE id = ?
138
+ `)
139
+ .get(id) as DynamicStateRow | null;
140
+
141
+ if (!row) return null;
142
+
143
+ return this.rowToState(row);
144
+ }
145
+
146
+ /**
147
+ * Convert a database row to DynamicStepState
148
+ */
149
+ private rowToState(row: DynamicStateRow): DynamicStepState {
150
+ return {
151
+ id: row.id,
152
+ runId: row.run_id,
153
+ stepId: row.step_id,
154
+ workflowId: row.workflow_id || row.run_id,
155
+ status: row.status as DynamicStepState['status'],
156
+ generatedPlan: JSON.parse(row.generated_plan),
157
+ currentStepIndex: row.current_step_index,
158
+ stepResults: new Map(),
159
+ startedAt: row.started_at,
160
+ completedAt: row.completed_at || undefined,
161
+ error: row.error || undefined,
162
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
163
+ replanCount: row.replan_count || 0,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Update state after plan generation
169
+ */
170
+ async setPlan(
171
+ stateId: string,
172
+ plan: DynamicPlan,
173
+ status: DynamicStepState['status'] = 'executing'
174
+ ): Promise<void> {
175
+ const db = this.getDatabase();
176
+ const now = new Date().toISOString();
177
+
178
+ // Load current state to get replanCount
179
+ const current = db
180
+ .prepare('SELECT replan_count FROM dynamic_workflow_state WHERE id = ?')
181
+ .get(stateId) as { replan_count: number };
182
+ const replanCount = current?.replan_count || 0;
183
+
184
+ db.prepare(`
185
+ UPDATE dynamic_workflow_state
186
+ SET generated_plan = ?, status = ?, updated_at = ?, replan_count = ?
187
+ WHERE id = ?
188
+ `).run(JSON.stringify(plan), status, now, replanCount, stateId);
189
+
190
+ // Delete previous execution records IF this is a re-plan (optional, but cleaner)
191
+ if (replanCount > 0) {
192
+ db.prepare('DELETE FROM dynamic_step_executions WHERE state_id = ?').run(stateId);
193
+ }
194
+
195
+ // Create execution records for each step
196
+ for (let i = 0; i < plan.steps.length; i++) {
197
+ const step = plan.steps[i];
198
+ db.prepare(`
199
+ INSERT INTO dynamic_step_executions
200
+ (id, state_id, step_id, step_name, step_type, step_definition, status, execution_order)
201
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)
202
+ `).run(randomUUID(), stateId, step.id, step.name, step.type, JSON.stringify(step), i);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Update status and optionally replan count
208
+ */
209
+ async updateStatus(
210
+ stateId: string,
211
+ status: DynamicStepState['status'],
212
+ replanCount?: number
213
+ ): Promise<void> {
214
+ const db = this.getDatabase();
215
+ const now = new Date().toISOString();
216
+
217
+ if (replanCount !== undefined) {
218
+ db.prepare(`
219
+ UPDATE dynamic_workflow_state
220
+ SET status = ?, replan_count = ?, updated_at = ?
221
+ WHERE id = ?
222
+ `).run(status, replanCount, now, stateId);
223
+ } else {
224
+ db.prepare(`
225
+ UPDATE dynamic_workflow_state
226
+ SET status = ?, updated_at = ?
227
+ WHERE id = ?
228
+ `).run(status, now, stateId);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Mark plan as confirmed and ready for execution
234
+ */
235
+ async confirmPlan(stateId: string): Promise<void> {
236
+ const db = this.getDatabase();
237
+ const now = new Date().toISOString();
238
+
239
+ db.prepare(`
240
+ UPDATE dynamic_workflow_state
241
+ SET status = 'executing', updated_at = ?
242
+ WHERE id = ?
243
+ `).run(now, stateId);
244
+ }
245
+
246
+ /**
247
+ * Update current step index (for resume)
248
+ */
249
+ async updateProgress(stateId: string, stepIndex: number): Promise<void> {
250
+ const db = this.getDatabase();
251
+ const now = new Date().toISOString();
252
+
253
+ db.prepare(`
254
+ UPDATE dynamic_workflow_state
255
+ SET current_step_index = ?, updated_at = ?
256
+ WHERE id = ?
257
+ `).run(stepIndex, now, stateId);
258
+ }
259
+
260
+ /**
261
+ * Record step execution start
262
+ */
263
+ async startStep(stateId: string, stepId: string): Promise<void> {
264
+ const db = this.getDatabase();
265
+ const now = new Date().toISOString();
266
+
267
+ db.prepare(`
268
+ UPDATE dynamic_step_executions
269
+ SET status = 'running', started_at = ?
270
+ WHERE state_id = ? AND step_id = ?
271
+ `).run(now, stateId, stepId);
272
+ }
273
+
274
+ /**
275
+ * Record step completion
276
+ */
277
+ async completeStep(
278
+ stateId: string,
279
+ stepId: string,
280
+ result: { status: string; output?: unknown; error?: string }
281
+ ): Promise<void> {
282
+ const db = this.getDatabase();
283
+ const now = new Date().toISOString();
284
+
285
+ // Map result.status to our status enum
286
+ let status: string;
287
+ switch (result.status) {
288
+ case 'success':
289
+ status = 'success';
290
+ break;
291
+ case 'failed':
292
+ status = 'failed';
293
+ break;
294
+ case 'skipped':
295
+ status = 'skipped';
296
+ break;
297
+ default:
298
+ status = 'failed';
299
+ }
300
+
301
+ db.prepare(`
302
+ UPDATE dynamic_step_executions
303
+ SET status = ?, output = ?, error = ?, completed_at = ?
304
+ WHERE state_id = ? AND step_id = ?
305
+ `).run(
306
+ status,
307
+ result.output !== undefined ? JSON.stringify(result.output) : null,
308
+ result.error || null,
309
+ now,
310
+ stateId,
311
+ stepId
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Mark workflow as completed or failed
317
+ */
318
+ async finish(stateId: string, status: 'completed' | 'failed', error?: string): Promise<void> {
319
+ const db = this.getDatabase();
320
+ const now = new Date().toISOString();
321
+
322
+ db.prepare(`
323
+ UPDATE dynamic_workflow_state
324
+ SET status = ?, error = ?, completed_at = ?, updated_at = ?
325
+ WHERE id = ?
326
+ `).run(status, error || null, now, now, stateId);
327
+ }
328
+
329
+ /**
330
+ * Get all step executions for a dynamic state
331
+ */
332
+ async getStepExecutions(stateId: string): Promise<DynamicStepExecution[]> {
333
+ const db = this.getDatabase();
334
+ const rows = db
335
+ .prepare(`
336
+ SELECT * FROM dynamic_step_executions
337
+ WHERE state_id = ?
338
+ ORDER BY execution_order ASC
339
+ `)
340
+ .all(stateId) as DynamicExecRow[];
341
+
342
+ return rows.map((row) => ({
343
+ id: row.id,
344
+ stateId: row.state_id,
345
+ stepId: row.step_id,
346
+ stepName: row.step_name,
347
+ stepType: row.step_type,
348
+ stepDefinition: JSON.parse(row.step_definition),
349
+ status: row.status as DynamicStepExecution['status'],
350
+ output: row.output ? JSON.parse(row.output) : undefined,
351
+ error: row.error || undefined,
352
+ startedAt: row.started_at || undefined,
353
+ completedAt: row.completed_at || undefined,
354
+ executionOrder: row.execution_order,
355
+ }));
356
+ }
357
+
358
+ /**
359
+ * Get step results as a Map (for executor integration)
360
+ */
361
+ async getStepResultsMap(
362
+ stateId: string
363
+ ): Promise<Map<string, { output: unknown; status: string; error?: string }>> {
364
+ const executions = await this.getStepExecutions(stateId);
365
+ const map = new Map<string, { output: unknown; status: string; error?: string }>();
366
+
367
+ for (const exec of executions) {
368
+ if (exec.status !== 'pending') {
369
+ map.set(exec.stepId, {
370
+ output: exec.output,
371
+ status: exec.status,
372
+ error: exec.error,
373
+ });
374
+ }
375
+ }
376
+
377
+ return map;
378
+ }
379
+
380
+ /**
381
+ * List all active (non-completed) dynamic states
382
+ */
383
+ async listActive(): Promise<DynamicStepState[]> {
384
+ const db = this.getDatabase();
385
+ const rows = db
386
+ .prepare(`
387
+ SELECT * FROM dynamic_workflow_state
388
+ WHERE status IN ('planning', 'executing')
389
+ ORDER BY started_at DESC
390
+ `)
391
+ .all() as DynamicStateRow[];
392
+
393
+ return rows.map((row) => this.rowToState(row));
394
+ }
395
+
396
+ /**
397
+ * List dynamic states by run ID
398
+ */
399
+ async listByRun(runId: string): Promise<DynamicStepState[]> {
400
+ const db = this.getDatabase();
401
+ const rows = db
402
+ .prepare(`
403
+ SELECT * FROM dynamic_workflow_state
404
+ WHERE run_id = ?
405
+ ORDER BY started_at ASC
406
+ `)
407
+ .all(runId) as DynamicStateRow[];
408
+
409
+ return rows.map((row) => this.rowToState(row));
410
+ }
411
+ }
@@ -101,6 +101,14 @@ export class DatabaseError extends Error {
101
101
  export class WorkflowDb {
102
102
  private db: Database;
103
103
 
104
+ /**
105
+ * Access the underlying database instance.
106
+ * This is intended for internal extensions like DynamicStateManager.
107
+ */
108
+ public getDatabase(): Database {
109
+ return this.db;
110
+ }
111
+
104
112
  private createRunStmt!: Statement;
105
113
  private updateRunStatusStmt!: Statement;
106
114
  private getRunStmt!: Statement;
@@ -549,6 +557,62 @@ export class WorkflowDb {
549
557
  PRAGMA user_version = 4;
550
558
  `);
551
559
  }
560
+
561
+ // Version 5: Add dynamic workflow state tables
562
+ if (version < 5) {
563
+ this.db.exec(`
564
+ -- Table for tracking dynamic step execution state
565
+ CREATE TABLE IF NOT EXISTS dynamic_workflow_state (
566
+ id TEXT PRIMARY KEY,
567
+ run_id TEXT NOT NULL,
568
+ step_id TEXT NOT NULL,
569
+ workflow_id TEXT,
570
+ status TEXT NOT NULL DEFAULT 'planning',
571
+ generated_plan TEXT NOT NULL DEFAULT '{"steps":[]}',
572
+ current_step_index INTEGER DEFAULT 0,
573
+ started_at TEXT NOT NULL,
574
+ completed_at TEXT,
575
+ error TEXT,
576
+ metadata TEXT,
577
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
578
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
579
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE,
580
+ UNIQUE (run_id, step_id)
581
+ );
582
+ CREATE INDEX IF NOT EXISTS idx_dynamic_state_run ON dynamic_workflow_state(run_id);
583
+ CREATE INDEX IF NOT EXISTS idx_dynamic_state_status ON dynamic_workflow_state(status);
584
+
585
+ -- Table for individual generated step executions
586
+ CREATE TABLE IF NOT EXISTS dynamic_step_executions (
587
+ id TEXT PRIMARY KEY,
588
+ state_id TEXT NOT NULL,
589
+ step_id TEXT NOT NULL,
590
+ step_name TEXT NOT NULL,
591
+ step_type TEXT NOT NULL,
592
+ step_definition TEXT NOT NULL,
593
+ status TEXT NOT NULL DEFAULT 'pending',
594
+ output TEXT,
595
+ error TEXT,
596
+ started_at TEXT,
597
+ completed_at TEXT,
598
+ execution_order INTEGER NOT NULL,
599
+ FOREIGN KEY (state_id) REFERENCES dynamic_workflow_state(id) ON DELETE CASCADE,
600
+ UNIQUE (state_id, step_id)
601
+ );
602
+ CREATE INDEX IF NOT EXISTS idx_dynamic_exec_state ON dynamic_step_executions(state_id);
603
+ CREATE INDEX IF NOT EXISTS idx_dynamic_exec_order ON dynamic_step_executions(state_id, execution_order);
604
+
605
+ PRAGMA user_version = 5;
606
+ `);
607
+ }
608
+
609
+ // Version 6: Add replan_count to dynamic_workflow_state
610
+ if (version < 6) {
611
+ this.db.exec(`
612
+ ALTER TABLE dynamic_workflow_state ADD COLUMN replan_count INTEGER DEFAULT 0;
613
+ PRAGMA user_version = 6;
614
+ `);
615
+ }
552
616
  }
553
617
 
554
618
  private initSchema(): void {
@@ -415,6 +415,35 @@ const WaitStepSchema = BaseStepSchema.extend({
415
415
  // timeout is already in BaseStepSchema, but let's make it explicit here if needed
416
416
  });
417
417
 
418
+ const DynamicStepSchema = BaseStepSchema.extend({
419
+ type: z.literal('dynamic'),
420
+ goal: z.string(), // The high-level goal to accomplish
421
+ context: z.string().optional(), // Additional context for the supervisor
422
+ prompt: z.string().optional(), // Custom supervisor prompt (overrides default)
423
+ supervisor: z.string().optional(), // Supervisor agent (defaults to agent or keystone-architect)
424
+ agent: z.string().optional().default('keystone-architect'), // Default agent for generated steps
425
+ provider: z.string().optional(),
426
+ model: z.string().optional(),
427
+ templates: z.record(z.string()).optional(), // Role -> Agent mapping, e.g., { "planner": "@org-planner" }
428
+ maxSteps: z.number().int().positive().default(20), // Max steps the supervisor can generate
429
+ maxIterations: z.number().int().positive().default(5), // Max LLM iterations for planning
430
+ allowStepFailure: z.boolean().optional().default(false), // Continue on individual step failure
431
+ stateFile: z.string().optional(), // Path to persist workflow state (for external tools)
432
+ concurrency: z.union([z.number().int().positive(), z.string()]).optional().default(1), // Max parallel steps
433
+ library: z
434
+ .array(
435
+ z.object({
436
+ name: z.string(),
437
+ description: z.string(),
438
+ steps: z.array(z.any()), // Pre-defined steps in this pattern
439
+ })
440
+ )
441
+ .optional(), // Library of pre-defined step patterns
442
+ confirmPlan: z.boolean().optional().default(false), // Review and approve plan before execution
443
+ maxReplans: z.number().int().nonnegative().default(3), // Max automatic recovery attempts
444
+ allowInsecure: z.boolean().optional(), // Allow generated steps to use insecure commands (e.g. shell redirects)
445
+ });
446
+
418
447
  // ===== Discriminated Union for Steps =====
419
448
 
420
449
  export const StepSchema: z.ZodType<any> = z.lazy(() =>
@@ -435,6 +464,7 @@ export const StepSchema: z.ZodType<any> = z.lazy(() =>
435
464
  ArtifactStepSchema as any,
436
465
  WaitStepSchema as any,
437
466
  GitStepSchema as any,
467
+ DynamicStepSchema as any,
438
468
  ])
439
469
  );
440
470
 
@@ -518,7 +548,8 @@ export type Step =
518
548
  | z.infer<typeof BlueprintStepSchema>
519
549
  | z.infer<typeof ArtifactStepSchema>
520
550
  | z.infer<typeof WaitStepSchema>
521
- | z.infer<typeof GitStepSchema>;
551
+ | z.infer<typeof GitStepSchema>
552
+ | z.infer<typeof DynamicStepSchema>;
522
553
 
523
554
  export type ShellStep = z.infer<typeof ShellStepSchema>;
524
555
  export type LlmStep = z.infer<typeof LlmStepSchema>;
@@ -539,6 +570,7 @@ export type Blueprint = z.infer<typeof BlueprintSchema>;
539
570
  export type Workflow = z.infer<typeof WorkflowSchema>;
540
571
  export type AgentTool = z.infer<typeof AgentToolSchema>;
541
572
  export type WaitStep = z.infer<typeof WaitStepSchema>;
573
+ export type DynamicStep = z.infer<typeof DynamicStepSchema>;
542
574
 
543
575
  // ===== Helper Schemas =====
544
576
  export {
@@ -565,5 +597,6 @@ export {
565
597
  MemoryStepSchema,
566
598
  ArtifactStepSchema,
567
599
  GitStepSchema,
600
+ DynamicStepSchema,
568
601
  };
569
602
  export type Agent = z.infer<typeof AgentSchema>;
@@ -56,13 +56,12 @@ describe('WorkflowParser', () => {
56
56
  expect(() => WorkflowParser.topologicalSort(workflow)).toThrow(/circular dependency/i);
57
57
  });
58
58
 
59
- test('should throw on missing dependencies', () => {
59
+ test('should NOT throw on missing dependencies (leniency for partial execution)', () => {
60
60
  const workflow = {
61
61
  steps: [{ id: 'step1', type: 'shell', run: 'echo 1', needs: ['non-existent'] }],
62
62
  } as unknown as Workflow;
63
- expect(() => WorkflowParser.topologicalSort(workflow)).toThrow(
64
- /depends on non-existent step/
65
- );
63
+ expect(() => WorkflowParser.topologicalSort(workflow)).not.toThrow();
64
+ expect(WorkflowParser.topologicalSort(workflow)).toEqual(['step1']);
66
65
  });
67
66
  });
68
67
 
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { ExpressionEvaluator } from '../expression/evaluator.ts';
5
5
  import { ResourceLoader } from '../utils/resource-loader.ts';
6
6
  import { validateJsonSchemaDefinition } from '../utils/schema-validator.ts';
7
+ import { topologicalSort } from '../utils/topo-sort.ts';
7
8
  import { resolveAgentPath } from './agent-parser.ts';
8
9
  import { type Workflow, WorkflowSchema } from './schema.ts';
9
10
 
@@ -298,68 +299,8 @@ export class WorkflowParser {
298
299
  * Returns steps in execution order
299
300
  */
300
301
  static topologicalSort(workflow: Workflow): string[] {
301
- const stepMap = new Map(workflow.steps.map((step) => [step.id, step.needs]));
302
- const inDegree = new Map<string, number>();
303
-
304
- // Validate all dependencies exist before sorting
305
- for (const step of workflow.steps) {
306
- const needs = step.needs || [];
307
- for (const dep of needs) {
308
- if (!stepMap.has(dep)) {
309
- throw new Error(`Step "${step.id}" depends on non-existent step "${dep}"`);
310
- }
311
- }
312
- }
313
-
314
- // Calculate in-degree
315
- // In-degree = number of dependencies a step has
316
- for (const step of workflow.steps) {
317
- const needs = step.needs || [];
318
- inDegree.set(step.id, needs.length);
319
- }
320
-
321
- // Build reverse dependency map for O(1) lookups instead of O(n)
322
- const dependents = new Map<string, string[]>();
323
- for (const step of workflow.steps) {
324
- const needs = step.needs || [];
325
- for (const dep of needs) {
326
- if (!dependents.has(dep)) dependents.set(dep, []);
327
- dependents.get(dep)?.push(step.id);
328
- }
329
- }
330
-
331
- // Kahn's algorithm
332
- const queue: string[] = [];
333
- const result: string[] = [];
334
-
335
- // Add all nodes with in-degree 0
336
- for (const [stepId, degree] of inDegree.entries()) {
337
- if (degree === 0) {
338
- queue.push(stepId);
339
- }
340
- }
341
-
342
- let queueIndex = 0;
343
- while (queueIndex < queue.length) {
344
- const stepId = queue[queueIndex];
345
- queueIndex += 1;
346
- result.push(stepId);
347
-
348
- // Find all steps that depend on this step (O(1) lookup)
349
- for (const dependentId of dependents.get(stepId) || []) {
350
- const newDegree = (inDegree.get(dependentId) || 0) - 1;
351
- inDegree.set(dependentId, newDegree);
352
- if (newDegree === 0) {
353
- queue.push(dependentId);
354
- }
355
- }
356
- }
357
-
358
- if (result.length !== workflow.steps.length) {
359
- throw new Error('Topological sort failed - circular dependency detected');
360
- }
361
-
362
- return result;
302
+ const sorted = topologicalSort(workflow.steps);
303
+ return sorted.map((s) => s.id);
363
304
  }
364
305
 
365
306
  /**