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,358 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
+ import { existsSync, rmSync } from 'node:fs';
3
+ import type { Workflow } from '../parser/schema';
4
+ import { WorkflowParser } from '../parser/workflow-parser';
5
+ import { WorkflowRegistry } from '../utils/workflow-registry';
6
+ import { WorkflowRunner } from './workflow-runner';
7
+
8
+ describe('WorkflowRunner', () => {
9
+ const dbPath = ':memory:';
10
+
11
+ afterAll(() => {
12
+ if (existsSync('test-resume.db')) {
13
+ rmSync('test-resume.db');
14
+ }
15
+ });
16
+
17
+ beforeEach(() => {
18
+ mock.restore();
19
+ });
20
+
21
+ const workflow: Workflow = {
22
+ name: 'test-workflow',
23
+ steps: [
24
+ {
25
+ id: 'step1',
26
+ type: 'shell',
27
+ run: 'echo "hello"',
28
+ needs: [],
29
+ },
30
+ {
31
+ id: 'step2',
32
+ type: 'shell',
33
+ run: 'echo "${{ steps.step1.output.stdout.trim() }} world"',
34
+ needs: ['step1'],
35
+ },
36
+ ],
37
+ outputs: {
38
+ final: '${{ steps.step2.output.stdout.trim() }}',
39
+ },
40
+ } as unknown as Workflow;
41
+
42
+ it('should run a simple workflow successfully', async () => {
43
+ const runner = new WorkflowRunner(workflow, { dbPath });
44
+ const outputs = await runner.run();
45
+ expect(outputs.final).toBe('hello world');
46
+ });
47
+
48
+ it('should handle foreach steps', async () => {
49
+ const foreachWorkflow: Workflow = {
50
+ name: 'foreach-workflow',
51
+ steps: [
52
+ {
53
+ id: 'gen',
54
+ type: 'shell',
55
+ run: 'echo "[1, 2, 3]"',
56
+ transform: 'JSON.parse(output.stdout)',
57
+ needs: [],
58
+ },
59
+ {
60
+ id: 'process',
61
+ type: 'shell',
62
+ run: 'echo "item-${{ item }}"',
63
+ foreach: '${{ steps.gen.output }}',
64
+ needs: ['gen'],
65
+ },
66
+ ],
67
+ outputs: {
68
+ results: '${{ steps.process.output.map(o => o.stdout.trim()) }}',
69
+ },
70
+ } as unknown as Workflow;
71
+
72
+ const runner = new WorkflowRunner(foreachWorkflow, { dbPath });
73
+ const outputs = await runner.run();
74
+ expect(outputs.results).toEqual(['item-1', 'item-2', 'item-3']);
75
+ });
76
+
77
+ it('should handle skip conditions', async () => {
78
+ const skipWorkflow: Workflow = {
79
+ name: 'skip-workflow',
80
+ steps: [
81
+ {
82
+ id: 's1',
83
+ type: 'shell',
84
+ run: 'echo 1',
85
+ if: '${{ false }}',
86
+ needs: [],
87
+ },
88
+ {
89
+ id: 's2',
90
+ type: 'shell',
91
+ run: 'echo 2',
92
+ needs: ['s1'],
93
+ },
94
+ ],
95
+ outputs: {
96
+ s1_status: '${{ steps.s1.status }}',
97
+ },
98
+ } as unknown as Workflow;
99
+ const runner = new WorkflowRunner(skipWorkflow, { dbPath });
100
+ const outputs = await runner.run();
101
+ expect(outputs.s1_status).toBe('skipped');
102
+ });
103
+
104
+ it('should execute finally block', async () => {
105
+ let finallyExecuted = false;
106
+ const runnerLogger = {
107
+ log: (msg: string) => {
108
+ if (msg.includes('Finally step fin completed')) {
109
+ finallyExecuted = true;
110
+ }
111
+ },
112
+ error: (msg: string) => console.error(msg),
113
+ warn: (msg: string) => console.warn(msg),
114
+ };
115
+
116
+ const finallyWorkflow: Workflow = {
117
+ name: 'finally-workflow',
118
+ steps: [{ id: 's1', type: 'shell', run: 'echo 1', needs: [] }],
119
+ finally: [{ id: 'fin', type: 'shell', run: 'echo finally', needs: [] }],
120
+ } as unknown as Workflow;
121
+
122
+ const runner = new WorkflowRunner(finallyWorkflow, { dbPath, logger: runnerLogger });
123
+ await runner.run();
124
+ expect(finallyExecuted).toBe(true);
125
+ });
126
+
127
+ it('should apply defaults and validate inputs', async () => {
128
+ const workflowWithInputs: Workflow = {
129
+ name: 'input-wf',
130
+ inputs: {
131
+ name: { type: 'string', default: 'Keystone' },
132
+ count: { type: 'number' },
133
+ },
134
+ steps: [
135
+ {
136
+ id: 's1',
137
+ type: 'shell',
138
+ run: 'echo "${{ inputs.name }} ${{ inputs.count }}"',
139
+ needs: [],
140
+ },
141
+ ],
142
+ } as unknown as Workflow;
143
+
144
+ const runner1 = new WorkflowRunner(workflowWithInputs, { dbPath, inputs: {} });
145
+ await expect(runner1.run()).rejects.toThrow(/Missing required input: count/);
146
+
147
+ const runner2 = new WorkflowRunner(workflowWithInputs, { dbPath, inputs: { count: 10 } });
148
+ const outputs = await runner2.run();
149
+ expect(outputs).toBeDefined();
150
+ });
151
+
152
+ it('should handle step failure and workflow failure', async () => {
153
+ const failWorkflow: Workflow = {
154
+ name: 'fail-wf',
155
+ steps: [{ id: 'fail', type: 'shell', run: 'exit 1', needs: [] }],
156
+ } as unknown as Workflow;
157
+ const runner = new WorkflowRunner(failWorkflow, { dbPath });
158
+ await expect(runner.run()).rejects.toThrow(/Step fail failed/);
159
+ });
160
+
161
+ it('should execute steps in parallel', async () => {
162
+ const parallelWorkflow: Workflow = {
163
+ name: 'parallel-wf',
164
+ steps: [
165
+ { id: 's1', type: 'sleep', duration: 100, needs: [] },
166
+ { id: 's2', type: 'sleep', duration: 100, needs: [] },
167
+ ],
168
+ outputs: {
169
+ done: 'true',
170
+ },
171
+ } as unknown as Workflow;
172
+
173
+ const start = Date.now();
174
+ const runner = new WorkflowRunner(parallelWorkflow, { dbPath });
175
+ await runner.run();
176
+ const duration = Date.now() - start;
177
+
178
+ // If sequential, it would take > 200ms. If parallel, it should take ~100ms.
179
+ // We use a safe buffer.
180
+ expect(duration).toBeLessThan(180);
181
+ expect(duration).toBeGreaterThanOrEqual(100);
182
+ });
183
+
184
+ it('should handle sub-workflows', async () => {
185
+ const childWorkflow: Workflow = {
186
+ name: 'child-wf',
187
+ inputs: {
188
+ val: { type: 'string' },
189
+ },
190
+ steps: [{ id: 'cs1', type: 'shell', run: 'echo "child-${{ inputs.val }}"', needs: [] }],
191
+ outputs: {
192
+ out: '${{ steps.cs1.output.stdout.trim() }}',
193
+ },
194
+ } as unknown as Workflow;
195
+
196
+ const parentWorkflow: Workflow = {
197
+ name: 'parent-wf',
198
+ steps: [
199
+ {
200
+ id: 'sub',
201
+ type: 'workflow',
202
+ path: 'child.yaml',
203
+ inputs: { val: 'test' },
204
+ needs: [],
205
+ },
206
+ ],
207
+ outputs: {
208
+ final: '${{ steps.sub.output.out }}',
209
+ },
210
+ } as unknown as Workflow;
211
+
212
+ spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('child.yaml');
213
+ spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue(childWorkflow);
214
+
215
+ const runner = new WorkflowRunner(parentWorkflow, { dbPath });
216
+ const outputs = await runner.run();
217
+ expect(outputs.final).toBe('child-test');
218
+ });
219
+
220
+ it('should resume a failed workflow', async () => {
221
+ const resumeDbPath = 'test-resume.db';
222
+ if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
223
+
224
+ const workflow: Workflow = {
225
+ name: 'resume-wf',
226
+ steps: [
227
+ { id: 's1', type: 'shell', run: 'echo "one"', needs: [] },
228
+ { id: 's2', type: 'shell', run: 'exit 1', needs: ['s1'] },
229
+ ],
230
+ } as unknown as Workflow;
231
+
232
+ const runner1 = new WorkflowRunner(workflow, { dbPath: resumeDbPath });
233
+ let runId = '';
234
+ try {
235
+ await runner1.run();
236
+ } catch (e) {
237
+ // @ts-ignore
238
+ runId = runner1.runId;
239
+ }
240
+
241
+ expect(runId).not.toBe('');
242
+
243
+ // "Fix" the workflow for the second run
244
+ const fixedWorkflow: Workflow = {
245
+ name: 'resume-wf',
246
+ steps: [
247
+ { id: 's1', type: 'shell', run: 'echo "one"', needs: [] },
248
+ { id: 's2', type: 'shell', run: 'echo "two"', needs: ['s1'] },
249
+ ],
250
+ outputs: {
251
+ out: '${{ steps.s1.output.stdout.trim() }}-${{ steps.s2.output.stdout.trim() }}',
252
+ },
253
+ } as unknown as Workflow;
254
+
255
+ let s1Executed = false;
256
+ const logger = {
257
+ log: (msg: string) => {
258
+ if (msg.includes('Executing step: s1')) s1Executed = true;
259
+ },
260
+ error: () => {},
261
+ warn: () => {},
262
+ };
263
+
264
+ const runner2 = new WorkflowRunner(fixedWorkflow, {
265
+ dbPath: resumeDbPath,
266
+ resumeRunId: runId,
267
+ // @ts-ignore
268
+ logger: logger,
269
+ });
270
+ const outputs = await runner2.run();
271
+
272
+ expect(outputs.out).toBe('one-two');
273
+ expect(s1Executed).toBe(false); // Should have been skipped
274
+ });
275
+
276
+ it('should redact secrets from outputs', async () => {
277
+ const workflow: Workflow = {
278
+ name: 'redaction-wf',
279
+ steps: [{ id: 's1', type: 'shell', run: 'echo "Secret is my-super-secret"', needs: [] }],
280
+ outputs: {
281
+ out: '${{ steps.s1.output.stdout.trim() }}',
282
+ },
283
+ } as unknown as Workflow;
284
+
285
+ // @ts-ignore
286
+ spyOn(WorkflowRunner.prototype, 'loadSecrets').mockReturnValue({
287
+ MY_SECRET: 'my-super-secret',
288
+ });
289
+
290
+ const runner = new WorkflowRunner(workflow, { dbPath });
291
+ await runner.run();
292
+
293
+ expect(runner.redact('my-super-secret')).toBe('***REDACTED***');
294
+ });
295
+
296
+ it('should return run ID', () => {
297
+ const runner = new WorkflowRunner(workflow, { dbPath });
298
+ expect(runner.getRunId()).toBeDefined();
299
+ expect(typeof runner.getRunId()).toBe('string');
300
+ });
301
+
302
+ it('should continue even if finally step fails', async () => {
303
+ let finallyFailedLogged = false;
304
+ const runnerLogger = {
305
+ log: () => {},
306
+ error: (msg: string) => {
307
+ if (msg.includes('Finally step fin failed')) {
308
+ finallyFailedLogged = true;
309
+ }
310
+ },
311
+ warn: () => {},
312
+ };
313
+
314
+ const failFinallyWorkflow: Workflow = {
315
+ name: 'fail-finally-workflow',
316
+ steps: [{ id: 's1', type: 'shell', run: 'echo 1', needs: [] }],
317
+ finally: [{ id: 'fin', type: 'shell', run: 'exit 1', needs: [] }],
318
+ } as unknown as Workflow;
319
+
320
+ const runner = new WorkflowRunner(failFinallyWorkflow, { dbPath, logger: runnerLogger });
321
+ await runner.run();
322
+ expect(finallyFailedLogged).toBe(true);
323
+ });
324
+
325
+ it('should retry failed steps', async () => {
326
+ let retryLogged = false;
327
+ const runnerLogger = {
328
+ log: (msg: string) => {
329
+ if (msg.includes('Retry 1/1 for step fail')) {
330
+ retryLogged = true;
331
+ }
332
+ },
333
+ error: () => {},
334
+ warn: () => {},
335
+ };
336
+
337
+ const retryWorkflow: Workflow = {
338
+ name: 'retry-workflow',
339
+ steps: [
340
+ {
341
+ id: 'fail',
342
+ type: 'shell',
343
+ run: 'exit 1',
344
+ retry: { count: 1, backoff: 'linear' },
345
+ needs: [],
346
+ },
347
+ ],
348
+ } as unknown as Workflow;
349
+
350
+ const runner = new WorkflowRunner(retryWorkflow, { dbPath, logger: runnerLogger });
351
+ try {
352
+ await runner.run();
353
+ } catch (e) {
354
+ // Expected to fail
355
+ }
356
+ expect(retryLogged).toBe(true);
357
+ });
358
+ });