keystone-cli 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 (46) hide show
  1. package/README.md +136 -0
  2. package/logo.png +0 -0
  3. package/package.json +45 -0
  4. package/src/cli.ts +775 -0
  5. package/src/db/workflow-db.test.ts +99 -0
  6. package/src/db/workflow-db.ts +265 -0
  7. package/src/expression/evaluator.test.ts +247 -0
  8. package/src/expression/evaluator.ts +517 -0
  9. package/src/parser/agent-parser.test.ts +123 -0
  10. package/src/parser/agent-parser.ts +59 -0
  11. package/src/parser/config-schema.ts +54 -0
  12. package/src/parser/schema.ts +157 -0
  13. package/src/parser/workflow-parser.test.ts +212 -0
  14. package/src/parser/workflow-parser.ts +228 -0
  15. package/src/runner/llm-adapter.test.ts +329 -0
  16. package/src/runner/llm-adapter.ts +306 -0
  17. package/src/runner/llm-executor.test.ts +537 -0
  18. package/src/runner/llm-executor.ts +256 -0
  19. package/src/runner/mcp-client.test.ts +122 -0
  20. package/src/runner/mcp-client.ts +123 -0
  21. package/src/runner/mcp-manager.test.ts +143 -0
  22. package/src/runner/mcp-manager.ts +85 -0
  23. package/src/runner/mcp-server.test.ts +242 -0
  24. package/src/runner/mcp-server.ts +436 -0
  25. package/src/runner/retry.test.ts +52 -0
  26. package/src/runner/retry.ts +58 -0
  27. package/src/runner/shell-executor.test.ts +123 -0
  28. package/src/runner/shell-executor.ts +166 -0
  29. package/src/runner/step-executor.test.ts +465 -0
  30. package/src/runner/step-executor.ts +354 -0
  31. package/src/runner/timeout.test.ts +20 -0
  32. package/src/runner/timeout.ts +30 -0
  33. package/src/runner/tool-integration.test.ts +198 -0
  34. package/src/runner/workflow-runner.test.ts +358 -0
  35. package/src/runner/workflow-runner.ts +955 -0
  36. package/src/ui/dashboard.tsx +165 -0
  37. package/src/utils/auth-manager.test.ts +152 -0
  38. package/src/utils/auth-manager.ts +88 -0
  39. package/src/utils/config-loader.test.ts +52 -0
  40. package/src/utils/config-loader.ts +85 -0
  41. package/src/utils/mermaid.test.ts +51 -0
  42. package/src/utils/mermaid.ts +87 -0
  43. package/src/utils/redactor.test.ts +66 -0
  44. package/src/utils/redactor.ts +60 -0
  45. package/src/utils/workflow-registry.test.ts +108 -0
  46. package/src/utils/workflow-registry.ts +121 -0
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+
3
+ export const ConfigSchema = z.object({
4
+ default_provider: z.string().default('openai'),
5
+ default_model: z.string().optional(),
6
+ providers: z
7
+ .record(
8
+ z.object({
9
+ type: z.enum(['openai', 'anthropic', 'copilot']).default('openai'),
10
+ base_url: z.string().optional(),
11
+ api_key_env: z.string().optional(),
12
+ default_model: z.string().optional(),
13
+ })
14
+ )
15
+ .default({
16
+ openai: {
17
+ type: 'openai',
18
+ base_url: 'https://api.openai.com/v1',
19
+ api_key_env: 'OPENAI_API_KEY',
20
+ default_model: 'gpt-4o',
21
+ },
22
+ anthropic: {
23
+ type: 'anthropic',
24
+ base_url: 'https://api.anthropic.com/v1',
25
+ api_key_env: 'ANTHROPIC_API_KEY',
26
+ default_model: 'claude-3-5-sonnet-20240620',
27
+ },
28
+ copilot: {
29
+ type: 'copilot',
30
+ base_url: 'https://api.githubcopilot.com',
31
+ default_model: 'gpt-4o',
32
+ },
33
+ }),
34
+ model_mappings: z.record(z.string()).default({
35
+ 'claude-*': 'anthropic',
36
+ }),
37
+ storage: z
38
+ .object({
39
+ retention_days: z.number().default(30),
40
+ })
41
+ .default({}),
42
+ workflows_directory: z.string().default('workflows'),
43
+ mcp_servers: z
44
+ .record(
45
+ z.object({
46
+ command: z.string(),
47
+ args: z.array(z.string()).optional(),
48
+ env: z.record(z.string()).optional(),
49
+ })
50
+ )
51
+ .default({}),
52
+ });
53
+
54
+ export type Config = z.infer<typeof ConfigSchema>;
@@ -0,0 +1,157 @@
1
+ import { z } from 'zod';
2
+
3
+ // ===== Input/Output Schema =====
4
+
5
+ const InputSchema = z.object({
6
+ type: z.string(),
7
+ default: z.any().optional(),
8
+ description: z.string().optional(),
9
+ });
10
+
11
+ // ===== Retry Schema =====
12
+
13
+ const RetrySchema = z.object({
14
+ count: z.number().int().min(0).default(0),
15
+ backoff: z.enum(['linear', 'exponential']).default('linear'),
16
+ baseDelay: z.number().int().min(0).default(1000),
17
+ });
18
+
19
+ // ===== Base Step Schema =====
20
+
21
+ const BaseStepSchema = z.object({
22
+ id: z.string(),
23
+ type: z.string(),
24
+ needs: z.array(z.string()).default([]),
25
+ if: z.string().optional(),
26
+ timeout: z.number().int().positive().optional(),
27
+ retry: RetrySchema.optional(),
28
+ foreach: z.string().optional(),
29
+ // Accept both number and string (for expressions or YAML number-as-string)
30
+ concurrency: z.union([z.number().int().positive(), z.string()]).optional(),
31
+ transform: z.string().optional(),
32
+ });
33
+
34
+ // ===== Step Type Schemas =====
35
+
36
+ const ShellStepSchema = BaseStepSchema.extend({
37
+ type: z.literal('shell'),
38
+ run: z.string(),
39
+ dir: z.string().optional(),
40
+ env: z.record(z.string()).optional(),
41
+ });
42
+
43
+ // Forward declaration for AgentToolSchema which depends on StepSchema
44
+ const AgentToolSchema = z.object({
45
+ name: z.string(),
46
+ description: z.string().optional(),
47
+ parameters: z.any().optional(), // JSON Schema for tool arguments
48
+ execution: z.lazy(() => StepSchema), // Tools are essentially steps
49
+ });
50
+
51
+ const LlmStepSchema = BaseStepSchema.extend({
52
+ type: z.literal('llm'),
53
+ agent: z.string(),
54
+ provider: z.string().optional(),
55
+ model: z.string().optional(),
56
+ prompt: z.string(),
57
+ schema: z.any().optional(),
58
+ tools: z.array(AgentToolSchema).optional(),
59
+ maxIterations: z.number().int().positive().default(10),
60
+ useGlobalMcp: z.boolean().optional(),
61
+ mcpServers: z
62
+ .array(
63
+ z.union([
64
+ z.string(),
65
+ z.object({
66
+ name: z.string(),
67
+ command: z.string(),
68
+ args: z.array(z.string()).optional(),
69
+ env: z.record(z.string()).optional(),
70
+ }),
71
+ ])
72
+ )
73
+ .optional(),
74
+ });
75
+
76
+ const WorkflowStepSchema = BaseStepSchema.extend({
77
+ type: z.literal('workflow'),
78
+ path: z.string(),
79
+ inputs: z.record(z.string()).optional(),
80
+ });
81
+
82
+ const FileStepSchema = BaseStepSchema.extend({
83
+ type: z.literal('file'),
84
+ path: z.string(),
85
+ content: z.string().optional(),
86
+ op: z.enum(['read', 'write', 'append']),
87
+ });
88
+
89
+ const RequestStepSchema = BaseStepSchema.extend({
90
+ type: z.literal('request'),
91
+ url: z.string(),
92
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('GET'),
93
+ body: z.any().optional(),
94
+ headers: z.record(z.string()).optional(),
95
+ });
96
+
97
+ const HumanStepSchema = BaseStepSchema.extend({
98
+ type: z.literal('human'),
99
+ message: z.string(),
100
+ inputType: z.enum(['confirm', 'text']).default('confirm'),
101
+ });
102
+
103
+ const SleepStepSchema = BaseStepSchema.extend({
104
+ type: z.literal('sleep'),
105
+ duration: z.union([z.number().int().positive(), z.string()]),
106
+ });
107
+
108
+ // ===== Discriminated Union for Steps =====
109
+
110
+ export const StepSchema = z.discriminatedUnion('type', [
111
+ ShellStepSchema,
112
+ LlmStepSchema,
113
+ WorkflowStepSchema,
114
+ FileStepSchema,
115
+ RequestStepSchema,
116
+ HumanStepSchema,
117
+ SleepStepSchema,
118
+ ]);
119
+
120
+ // ===== Workflow Schema =====
121
+
122
+ export const WorkflowSchema = z.object({
123
+ name: z.string(),
124
+ description: z.string().optional(),
125
+ inputs: z.record(InputSchema).optional(),
126
+ outputs: z.record(z.string()).optional(),
127
+ env: z.record(z.string()).optional(),
128
+ steps: z.array(StepSchema),
129
+ finally: z.array(StepSchema).optional(),
130
+ });
131
+
132
+ // ===== Agent Schema =====
133
+
134
+ export const AgentSchema = z.object({
135
+ name: z.string(),
136
+ description: z.string().optional(),
137
+ provider: z.string().optional(),
138
+ model: z.string().optional(),
139
+ tools: z.array(AgentToolSchema).default([]),
140
+ systemPrompt: z.string(),
141
+ });
142
+
143
+ // ===== Types =====
144
+
145
+ export type WorkflowInput = z.infer<typeof InputSchema>;
146
+ export type RetryConfig = z.infer<typeof RetrySchema>;
147
+ export type Step = z.infer<typeof StepSchema>;
148
+ export type ShellStep = z.infer<typeof ShellStepSchema>;
149
+ export type LlmStep = z.infer<typeof LlmStepSchema>;
150
+ export type WorkflowStep = z.infer<typeof WorkflowStepSchema>;
151
+ export type FileStep = z.infer<typeof FileStepSchema>;
152
+ export type RequestStep = z.infer<typeof RequestStepSchema>;
153
+ export type HumanStep = z.infer<typeof HumanStepSchema>;
154
+ export type SleepStep = z.infer<typeof SleepStepSchema>;
155
+ export type Workflow = z.infer<typeof WorkflowSchema>;
156
+ export type AgentTool = z.infer<typeof AgentToolSchema>;
157
+ export type Agent = z.infer<typeof AgentSchema>;
@@ -0,0 +1,212 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import type { Workflow } from './schema';
5
+ import { WorkflowParser } from './workflow-parser';
6
+
7
+ describe('WorkflowParser', () => {
8
+ const tempDir = join(process.cwd(), 'temp-test-workflows');
9
+ try {
10
+ mkdirSync(tempDir, { recursive: true });
11
+ } catch (e) {}
12
+
13
+ afterAll(() => {
14
+ try {
15
+ rmSync(tempDir, { recursive: true, force: true });
16
+ } catch (e) {}
17
+ });
18
+ describe('topologicalSort', () => {
19
+ test('should sort simple dependencies', () => {
20
+ const workflow = {
21
+ steps: [
22
+ { id: 'step1', type: 'shell', run: 'echo 1', needs: [] },
23
+ { id: 'step2', type: 'shell', run: 'echo 2', needs: ['step1'] },
24
+ { id: 'step3', type: 'shell', run: 'echo 3', needs: ['step2'] },
25
+ ],
26
+ } as unknown as Workflow;
27
+ expect(WorkflowParser.topologicalSort(workflow)).toEqual(['step1', 'step2', 'step3']);
28
+ });
29
+
30
+ test('should handle parallel branches', () => {
31
+ const workflow = {
32
+ steps: [
33
+ { id: 'step1', type: 'shell', run: 'echo 1', needs: [] },
34
+ { id: 'step2a', type: 'shell', run: 'echo 2a', needs: ['step1'] },
35
+ { id: 'step2b', type: 'shell', run: 'echo 2b', needs: ['step1'] },
36
+ { id: 'step3', type: 'shell', run: 'echo 3', needs: ['step2a', 'step2b'] },
37
+ ],
38
+ } as unknown as Workflow;
39
+ const result = WorkflowParser.topologicalSort(workflow);
40
+ expect(result[0]).toBe('step1');
41
+ expect(new Set([result[1], result[2]])).toEqual(new Set(['step2a', 'step2b']));
42
+ expect(result[3]).toBe('step3');
43
+ });
44
+
45
+ test('should throw on circular dependencies', () => {
46
+ const workflow = {
47
+ steps: [
48
+ { id: 'step1', type: 'shell', run: 'echo 1', needs: ['step2'] },
49
+ { id: 'step2', type: 'shell', run: 'echo 2', needs: ['step1'] },
50
+ ],
51
+ } as unknown as Workflow;
52
+ expect(() => WorkflowParser.topologicalSort(workflow)).toThrow(/circular dependency/i);
53
+ });
54
+
55
+ test('should throw on missing dependencies', () => {
56
+ const workflow = {
57
+ steps: [{ id: 'step1', type: 'shell', run: 'echo 1', needs: ['non-existent'] }],
58
+ } as unknown as Workflow;
59
+ expect(() => WorkflowParser.topologicalSort(workflow)).toThrow(
60
+ /depends on non-existent step/
61
+ );
62
+ });
63
+ });
64
+
65
+ describe('loadWorkflow', () => {
66
+ test('should load valid workflow', () => {
67
+ const content = `
68
+ name: example-workflow
69
+ steps:
70
+ - id: step1
71
+ type: shell
72
+ run: echo hello
73
+ `;
74
+ const filePath = join(tempDir, 'valid.yaml');
75
+ writeFileSync(filePath, content);
76
+ const workflow = WorkflowParser.loadWorkflow(filePath);
77
+ expect(workflow.name).toBe('example-workflow');
78
+ expect(workflow.steps.length).toBeGreaterThan(0);
79
+ });
80
+
81
+ test('should throw on invalid schema', () => {
82
+ const content = `
83
+ name: invalid
84
+ steps:
85
+ - id: step1
86
+ type: invalid-type
87
+ `;
88
+ const filePath = join(tempDir, 'invalid.yaml');
89
+ writeFileSync(filePath, content);
90
+ expect(() => WorkflowParser.loadWorkflow(filePath)).toThrow(/Invalid workflow schema/);
91
+ });
92
+
93
+ test('should throw on non-existent file', () => {
94
+ expect(() => WorkflowParser.loadWorkflow('non-existent.yaml')).toThrow(
95
+ /Failed to parse workflow/
96
+ );
97
+ });
98
+
99
+ test('should validate DAG during load', () => {
100
+ const content = `
101
+ name: circular
102
+ steps:
103
+ - id: step1
104
+ type: shell
105
+ run: echo 1
106
+ needs: [step2]
107
+ - id: step2
108
+ type: shell
109
+ run: echo 2
110
+ needs: [step1]
111
+ `;
112
+ const filePath = join(tempDir, 'circular.yaml');
113
+ writeFileSync(filePath, content);
114
+ expect(() => WorkflowParser.loadWorkflow(filePath)).toThrow(/Circular dependency detected/);
115
+ });
116
+
117
+ test('should validate finally block Step ID conflicts', () => {
118
+ const content = `
119
+ name: conflict
120
+ steps:
121
+ - id: step1
122
+ type: shell
123
+ run: echo 1
124
+ finally:
125
+ - id: step1
126
+ type: shell
127
+ run: echo finally
128
+ `;
129
+ const filePath = join(tempDir, 'conflict.yaml');
130
+ writeFileSync(filePath, content);
131
+ expect(() => WorkflowParser.loadWorkflow(filePath)).toThrow(/conflicts with main steps/);
132
+ });
133
+
134
+ test('should validate duplicate finally block Step IDs', () => {
135
+ const content = `
136
+ name: finally-dup
137
+ steps:
138
+ - id: step1
139
+ type: shell
140
+ run: echo 1
141
+ finally:
142
+ - id: fin1
143
+ type: shell
144
+ run: echo f1
145
+ - id: fin1
146
+ type: shell
147
+ run: echo f2
148
+ `;
149
+ const filePath = join(tempDir, 'finally-dup.yaml');
150
+ writeFileSync(filePath, content);
151
+ expect(() => WorkflowParser.loadWorkflow(filePath)).toThrow(
152
+ /Duplicate Step ID "fin1" in finally block/
153
+ );
154
+ });
155
+
156
+ test('should throw on non-existent agents in LLM steps', () => {
157
+ const content = `
158
+ name: llm-agent
159
+ steps:
160
+ - id: step1
161
+ type: llm
162
+ agent: non-existent-agent
163
+ prompt: hello
164
+ `;
165
+ const filePath = join(tempDir, 'llm-agent.yaml');
166
+ writeFileSync(filePath, content);
167
+
168
+ expect(() => WorkflowParser.loadWorkflow(filePath)).toThrow(
169
+ /Agent "non-existent-agent" referenced in step "step1" not found/
170
+ );
171
+ });
172
+
173
+ test('should resolve implicit dependencies from expressions', () => {
174
+ const content = `
175
+ name: implicit-deps
176
+ steps:
177
+ - id: ask_name
178
+ type: human
179
+ message: "What is your name?"
180
+ - id: greet
181
+ type: shell
182
+ run: echo "Hello, \${{ steps.ask_name.output }}!"
183
+ `;
184
+ const filePath = join(tempDir, 'implicit-deps.yaml');
185
+ writeFileSync(filePath, content);
186
+
187
+ const workflow = WorkflowParser.loadWorkflow(filePath);
188
+ const greetStep = workflow.steps.find((s) => s.id === 'greet');
189
+ expect(greetStep?.needs).toContain('ask_name');
190
+ });
191
+
192
+ test('should resolve implicit dependencies in finally block', () => {
193
+ const content = `
194
+ name: finally-implicit
195
+ steps:
196
+ - id: step1
197
+ type: shell
198
+ run: echo 1
199
+ finally:
200
+ - id: cleanup
201
+ type: shell
202
+ run: echo "Cleaning up after \${{ steps.step1.output }}"
203
+ `;
204
+ const filePath = join(tempDir, 'finally-implicit.yaml');
205
+ writeFileSync(filePath, content);
206
+
207
+ const workflow = WorkflowParser.loadWorkflow(filePath);
208
+ const cleanupStep = workflow.finally?.find((s) => s.id === 'cleanup');
209
+ expect(cleanupStep?.needs).toContain('step1');
210
+ });
211
+ });
212
+ });
@@ -0,0 +1,228 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import * as yaml from 'js-yaml';
4
+ import { z } from 'zod';
5
+ import { ExpressionEvaluator } from '../expression/evaluator.ts';
6
+ import { resolveAgentPath } from './agent-parser.ts';
7
+ import { type Workflow, WorkflowSchema } from './schema.ts';
8
+
9
+ export class WorkflowParser {
10
+ /**
11
+ * Load and validate a workflow from a YAML file
12
+ */
13
+ static loadWorkflow(path: string): Workflow {
14
+ try {
15
+ const content = readFileSync(path, 'utf-8');
16
+ const raw = yaml.load(content);
17
+ const workflow = WorkflowSchema.parse(raw);
18
+
19
+ // Resolve implicit dependencies from expressions
20
+ WorkflowParser.resolveImplicitDependencies(workflow);
21
+
22
+ // Validate DAG (no circular dependencies)
23
+ WorkflowParser.validateDAG(workflow);
24
+
25
+ // Validate agents exist
26
+ WorkflowParser.validateAgents(workflow);
27
+
28
+ // Validate finally block
29
+ WorkflowParser.validateFinally(workflow);
30
+
31
+ return workflow;
32
+ } catch (error) {
33
+ if (error instanceof z.ZodError) {
34
+ const issues = error.issues
35
+ .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
36
+ .join('\n');
37
+ throw new Error(`Invalid workflow schema at ${path}:\n${issues}`);
38
+ }
39
+ if (error instanceof Error) {
40
+ throw new Error(`Failed to parse workflow at ${path}: ${error.message}`);
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Automatically detect step dependencies from expressions
48
+ */
49
+ private static resolveImplicitDependencies(workflow: Workflow): void {
50
+ const allSteps = [...workflow.steps, ...(workflow.finally || [])];
51
+ for (const step of allSteps) {
52
+ const detected = new Set<string>();
53
+
54
+ // Helper to scan any value for dependencies
55
+ const scan = (value: unknown) => {
56
+ if (typeof value === 'string') {
57
+ for (const dep of ExpressionEvaluator.findStepDependencies(value)) {
58
+ detected.add(dep);
59
+ }
60
+ } else if (Array.isArray(value)) {
61
+ for (const item of value) {
62
+ scan(item);
63
+ }
64
+ } else if (value && typeof value === 'object') {
65
+ for (const val of Object.values(value)) {
66
+ scan(val);
67
+ }
68
+ }
69
+ };
70
+
71
+ // Scan all step properties
72
+ scan(step);
73
+
74
+ // Add detected dependencies to step.needs
75
+ for (const depId of detected) {
76
+ // Step cannot depend on itself
77
+ if (depId !== step.id && !step.needs.includes(depId)) {
78
+ step.needs.push(depId);
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Validate that the workflow forms a valid DAG (no cycles)
86
+ */
87
+ private static validateDAG(workflow: Workflow): void {
88
+ const stepMap = new Map(workflow.steps.map((step) => [step.id, step.needs]));
89
+ const visited = new Set<string>();
90
+ const recursionStack = new Set<string>();
91
+
92
+ const hasCycle = (stepId: string): boolean => {
93
+ if (!visited.has(stepId)) {
94
+ visited.add(stepId);
95
+ recursionStack.add(stepId);
96
+
97
+ const dependencies = stepMap.get(stepId) || [];
98
+ for (const dep of dependencies) {
99
+ if (!stepMap.has(dep)) {
100
+ throw new Error(`Step "${stepId}" depends on non-existent step "${dep}"`);
101
+ }
102
+ if (!visited.has(dep) && hasCycle(dep)) {
103
+ return true;
104
+ }
105
+ if (recursionStack.has(dep)) {
106
+ return true;
107
+ }
108
+ }
109
+ }
110
+ recursionStack.delete(stepId);
111
+ return false;
112
+ };
113
+
114
+ for (const step of workflow.steps) {
115
+ if (hasCycle(step.id)) {
116
+ throw new Error(`Circular dependency detected involving step "${step.id}"`);
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Validate that all agents referenced in LLM steps exist
123
+ */
124
+ private static validateAgents(workflow: Workflow): void {
125
+ const allSteps = [...workflow.steps, ...(workflow.finally || [])];
126
+ for (const step of allSteps) {
127
+ if (step.type === 'llm') {
128
+ try {
129
+ resolveAgentPath(step.agent);
130
+ } catch (error) {
131
+ throw new Error(`Agent "${step.agent}" referenced in step "${step.id}" not found.`);
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Validate finally block
139
+ */
140
+ private static validateFinally(workflow: Workflow): void {
141
+ if (!workflow.finally) return;
142
+
143
+ const mainStepIds = new Set(workflow.steps.map((s) => s.id));
144
+ const finallyStepIds = new Set<string>();
145
+
146
+ for (const step of workflow.finally) {
147
+ if (mainStepIds.has(step.id)) {
148
+ throw new Error(`Step ID "${step.id}" in finally block conflicts with main steps`);
149
+ }
150
+ if (finallyStepIds.has(step.id)) {
151
+ throw new Error(`Duplicate Step ID "${step.id}" in finally block`);
152
+ }
153
+ finallyStepIds.add(step.id);
154
+
155
+ // Finally steps can only depend on main steps or previous finally steps
156
+ for (const dep of step.needs) {
157
+ if (!mainStepIds.has(dep) && !finallyStepIds.has(dep)) {
158
+ throw new Error(
159
+ `Finally step "${step.id}" depends on non-existent step "${dep}". Finally steps can only depend on main steps or previous finally steps.`
160
+ );
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Perform topological sort on steps
168
+ * Returns steps in execution order
169
+ */
170
+ static topologicalSort(workflow: Workflow): string[] {
171
+ const stepMap = new Map(workflow.steps.map((step) => [step.id, step.needs]));
172
+ const inDegree = new Map<string, number>();
173
+
174
+ // Validate all dependencies exist before sorting
175
+ for (const step of workflow.steps) {
176
+ for (const dep of step.needs) {
177
+ if (!stepMap.has(dep)) {
178
+ throw new Error(`Step "${step.id}" depends on non-existent step "${dep}"`);
179
+ }
180
+ }
181
+ }
182
+
183
+ // Initialize in-degree
184
+ for (const step of workflow.steps) {
185
+ inDegree.set(step.id, 0);
186
+ }
187
+
188
+ // Calculate in-degree
189
+ // In-degree = number of dependencies a step has
190
+ for (const step of workflow.steps) {
191
+ inDegree.set(step.id, step.needs.length);
192
+ }
193
+
194
+ // Kahn's algorithm
195
+ const queue: string[] = [];
196
+ const result: string[] = [];
197
+
198
+ // Add all nodes with in-degree 0
199
+ for (const [stepId, degree] of inDegree.entries()) {
200
+ if (degree === 0) {
201
+ queue.push(stepId);
202
+ }
203
+ }
204
+
205
+ while (queue.length > 0) {
206
+ const stepId = queue.shift();
207
+ if (!stepId) continue;
208
+ result.push(stepId);
209
+
210
+ // Find all steps that depend on this step
211
+ for (const step of workflow.steps) {
212
+ if (step.needs.includes(stepId)) {
213
+ const newDegree = (inDegree.get(step.id) || 0) - 1;
214
+ inDegree.set(step.id, newDegree);
215
+ if (newDegree === 0) {
216
+ queue.push(step.id);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ if (result.length !== workflow.steps.length) {
223
+ throw new Error('Topological sort failed - circular dependency detected');
224
+ }
225
+
226
+ return result;
227
+ }
228
+ }