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.
- package/README.md +136 -0
- package/logo.png +0 -0
- package/package.json +45 -0
- package/src/cli.ts +775 -0
- package/src/db/workflow-db.test.ts +99 -0
- package/src/db/workflow-db.ts +265 -0
- package/src/expression/evaluator.test.ts +247 -0
- package/src/expression/evaluator.ts +517 -0
- package/src/parser/agent-parser.test.ts +123 -0
- package/src/parser/agent-parser.ts +59 -0
- package/src/parser/config-schema.ts +54 -0
- package/src/parser/schema.ts +157 -0
- package/src/parser/workflow-parser.test.ts +212 -0
- package/src/parser/workflow-parser.ts +228 -0
- package/src/runner/llm-adapter.test.ts +329 -0
- package/src/runner/llm-adapter.ts +306 -0
- package/src/runner/llm-executor.test.ts +537 -0
- package/src/runner/llm-executor.ts +256 -0
- package/src/runner/mcp-client.test.ts +122 -0
- package/src/runner/mcp-client.ts +123 -0
- package/src/runner/mcp-manager.test.ts +143 -0
- package/src/runner/mcp-manager.ts +85 -0
- package/src/runner/mcp-server.test.ts +242 -0
- package/src/runner/mcp-server.ts +436 -0
- package/src/runner/retry.test.ts +52 -0
- package/src/runner/retry.ts +58 -0
- package/src/runner/shell-executor.test.ts +123 -0
- package/src/runner/shell-executor.ts +166 -0
- package/src/runner/step-executor.test.ts +465 -0
- package/src/runner/step-executor.ts +354 -0
- package/src/runner/timeout.test.ts +20 -0
- package/src/runner/timeout.ts +30 -0
- package/src/runner/tool-integration.test.ts +198 -0
- package/src/runner/workflow-runner.test.ts +358 -0
- package/src/runner/workflow-runner.ts +955 -0
- package/src/ui/dashboard.tsx +165 -0
- package/src/utils/auth-manager.test.ts +152 -0
- package/src/utils/auth-manager.ts +88 -0
- package/src/utils/config-loader.test.ts +52 -0
- package/src/utils/config-loader.ts +85 -0
- package/src/utils/mermaid.test.ts +51 -0
- package/src/utils/mermaid.ts +87 -0
- package/src/utils/redactor.test.ts +66 -0
- package/src/utils/redactor.ts +60 -0
- package/src/utils/workflow-registry.test.ts +108 -0
- 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
|
+
}
|