keystone-cli 0.8.0 → 1.0.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 (103) hide show
  1. package/README.md +486 -54
  2. package/package.json +8 -2
  3. package/src/__fixtures__/index.ts +100 -0
  4. package/src/cli.ts +809 -90
  5. package/src/db/memory-db.ts +35 -1
  6. package/src/db/workflow-db.test.ts +24 -0
  7. package/src/db/workflow-db.ts +469 -14
  8. package/src/expression/evaluator.ts +68 -4
  9. package/src/parser/agent-parser.ts +6 -3
  10. package/src/parser/config-schema.ts +38 -2
  11. package/src/parser/schema.ts +192 -7
  12. package/src/parser/test-schema.ts +29 -0
  13. package/src/parser/workflow-parser.test.ts +54 -0
  14. package/src/parser/workflow-parser.ts +153 -7
  15. package/src/runner/aggregate-error.test.ts +57 -0
  16. package/src/runner/aggregate-error.ts +46 -0
  17. package/src/runner/audit-verification.test.ts +2 -2
  18. package/src/runner/auto-heal.test.ts +1 -1
  19. package/src/runner/blueprint-executor.test.ts +63 -0
  20. package/src/runner/blueprint-executor.ts +157 -0
  21. package/src/runner/concurrency-limit.test.ts +82 -0
  22. package/src/runner/debug-repl.ts +18 -3
  23. package/src/runner/durable-timers.test.ts +200 -0
  24. package/src/runner/engine-executor.test.ts +464 -0
  25. package/src/runner/engine-executor.ts +491 -0
  26. package/src/runner/foreach-executor.ts +30 -12
  27. package/src/runner/llm-adapter.test.ts +282 -5
  28. package/src/runner/llm-adapter.ts +581 -8
  29. package/src/runner/llm-clarification.test.ts +79 -21
  30. package/src/runner/llm-errors.ts +83 -0
  31. package/src/runner/llm-executor.test.ts +258 -219
  32. package/src/runner/llm-executor.ts +226 -29
  33. package/src/runner/mcp-client.ts +70 -3
  34. package/src/runner/mcp-manager.test.ts +52 -52
  35. package/src/runner/mcp-manager.ts +12 -5
  36. package/src/runner/mcp-server.test.ts +117 -78
  37. package/src/runner/mcp-server.ts +13 -4
  38. package/src/runner/optimization-runner.ts +48 -31
  39. package/src/runner/reflexion.test.ts +1 -1
  40. package/src/runner/resource-pool.test.ts +113 -0
  41. package/src/runner/resource-pool.ts +164 -0
  42. package/src/runner/shell-executor.ts +130 -32
  43. package/src/runner/standard-tools-integration.test.ts +36 -36
  44. package/src/runner/standard-tools.test.ts +18 -0
  45. package/src/runner/standard-tools.ts +110 -37
  46. package/src/runner/step-executor.test.ts +176 -16
  47. package/src/runner/step-executor.ts +530 -86
  48. package/src/runner/stream-utils.test.ts +14 -0
  49. package/src/runner/subflow-outputs.test.ts +103 -0
  50. package/src/runner/test-harness.ts +161 -0
  51. package/src/runner/tool-integration.test.ts +73 -79
  52. package/src/runner/workflow-runner.test.ts +492 -15
  53. package/src/runner/workflow-runner.ts +1438 -79
  54. package/src/runner/workflow-subflows.test.ts +255 -0
  55. package/src/templates/agents/keystone-architect.md +17 -12
  56. package/src/templates/agents/tester.md +21 -0
  57. package/src/templates/child-rollback.yaml +11 -0
  58. package/src/templates/decompose-implement.yaml +53 -0
  59. package/src/templates/decompose-problem.yaml +159 -0
  60. package/src/templates/decompose-research.yaml +52 -0
  61. package/src/templates/decompose-review.yaml +51 -0
  62. package/src/templates/dev.yaml +134 -0
  63. package/src/templates/engine-example.yaml +33 -0
  64. package/src/templates/fan-out-fan-in.yaml +61 -0
  65. package/src/templates/memory-service.yaml +1 -1
  66. package/src/templates/parent-rollback.yaml +16 -0
  67. package/src/templates/robust-automation.yaml +1 -1
  68. package/src/templates/scaffold-feature.yaml +29 -27
  69. package/src/templates/scaffold-generate.yaml +41 -0
  70. package/src/templates/scaffold-plan.yaml +53 -0
  71. package/src/types/status.ts +3 -0
  72. package/src/ui/dashboard.tsx +4 -3
  73. package/src/utils/assets.macro.ts +36 -0
  74. package/src/utils/auth-manager.ts +585 -8
  75. package/src/utils/blueprint-utils.test.ts +49 -0
  76. package/src/utils/blueprint-utils.ts +80 -0
  77. package/src/utils/circuit-breaker.test.ts +177 -0
  78. package/src/utils/circuit-breaker.ts +160 -0
  79. package/src/utils/config-loader.test.ts +100 -13
  80. package/src/utils/config-loader.ts +44 -17
  81. package/src/utils/constants.ts +62 -0
  82. package/src/utils/error-renderer.test.ts +267 -0
  83. package/src/utils/error-renderer.ts +320 -0
  84. package/src/utils/json-parser.test.ts +4 -0
  85. package/src/utils/json-parser.ts +18 -1
  86. package/src/utils/mermaid.ts +4 -0
  87. package/src/utils/paths.test.ts +46 -0
  88. package/src/utils/paths.ts +70 -0
  89. package/src/utils/process-sandbox.test.ts +128 -0
  90. package/src/utils/process-sandbox.ts +293 -0
  91. package/src/utils/rate-limiter.test.ts +143 -0
  92. package/src/utils/rate-limiter.ts +221 -0
  93. package/src/utils/redactor.test.ts +23 -15
  94. package/src/utils/redactor.ts +65 -25
  95. package/src/utils/resource-loader.test.ts +54 -0
  96. package/src/utils/resource-loader.ts +158 -0
  97. package/src/utils/sandbox.test.ts +69 -4
  98. package/src/utils/sandbox.ts +69 -6
  99. package/src/utils/schema-validator.ts +65 -0
  100. package/src/utils/workflow-registry.test.ts +57 -0
  101. package/src/utils/workflow-registry.ts +45 -25
  102. /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
  103. /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
@@ -1,13 +1,19 @@
1
- import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
1
+ import { afterAll, afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
2
  import { existsSync, rmSync } from 'node:fs';
3
3
  import { WorkflowDb } from '../db/workflow-db';
4
4
  import type { Workflow } from '../parser/schema';
5
5
  import { WorkflowParser } from '../parser/workflow-parser';
6
+ import { ConfigLoader } from '../utils/config-loader';
6
7
  import { WorkflowRegistry } from '../utils/workflow-registry';
7
8
  import { WorkflowRunner } from './workflow-runner';
8
9
 
9
10
  describe('WorkflowRunner', () => {
10
11
  const dbPath = ':memory:';
12
+ const activeSpies: Array<{ mockRestore: () => void }> = [];
13
+ const trackSpy = <T extends { mockRestore: () => void }>(spy: T): T => {
14
+ activeSpies.push(spy);
15
+ return spy;
16
+ };
11
17
 
12
18
  afterAll(() => {
13
19
  if (existsSync('test-resume.db')) {
@@ -18,8 +24,12 @@ describe('WorkflowRunner', () => {
18
24
  }
19
25
  });
20
26
 
21
- beforeEach(() => {
22
- mock.restore();
27
+ afterEach(() => {
28
+ for (const spy of activeSpies) {
29
+ spy.mockRestore();
30
+ }
31
+ activeSpies.length = 0;
32
+ ConfigLoader.clear();
23
33
  });
24
34
 
25
35
  const workflow: Workflow = {
@@ -49,6 +59,63 @@ describe('WorkflowRunner', () => {
49
59
  expect(outputs.final).toBe('hello world');
50
60
  });
51
61
 
62
+ it('should expose workflow env to shell steps', async () => {
63
+ const envWorkflow: Workflow = {
64
+ name: 'env-workflow',
65
+ inputs: {
66
+ token: { type: 'string', default: 'env-token' },
67
+ },
68
+ env: {
69
+ TOKEN: '${{ inputs.token }}',
70
+ },
71
+ steps: [
72
+ {
73
+ id: 'print',
74
+ type: 'shell',
75
+ run: 'echo $TOKEN',
76
+ needs: [],
77
+ },
78
+ ],
79
+ outputs: {
80
+ token: '${{ steps.print.output.stdout.trim() }}',
81
+ },
82
+ } as unknown as Workflow;
83
+
84
+ const runner = new WorkflowRunner(envWorkflow, { dbPath });
85
+ const outputs = await runner.run();
86
+ expect(outputs.token).toBe('env-token');
87
+ });
88
+
89
+ it('should skip workflow env entries that fail to evaluate', async () => {
90
+ const envWorkflow: Workflow = {
91
+ name: 'env-skip-workflow',
92
+ env: {
93
+ LATER: '${{ steps.after.output.stdout.trim() }}',
94
+ },
95
+ steps: [
96
+ {
97
+ id: 'before',
98
+ type: 'shell',
99
+ run: 'echo "start"',
100
+ needs: [],
101
+ },
102
+ {
103
+ id: 'after',
104
+ type: 'shell',
105
+ run: 'echo "later"',
106
+ needs: ['before'],
107
+ },
108
+ ],
109
+ outputs: {
110
+ first: '${{ steps.before.output.stdout.trim() }}',
111
+ },
112
+ } as unknown as Workflow;
113
+
114
+ const runner = new WorkflowRunner(envWorkflow, { dbPath });
115
+ const outputs = await runner.run();
116
+ expect(outputs.first).toBe('start');
117
+ });
118
+
52
119
  it('should handle foreach steps', async () => {
53
120
  const foreachWorkflow: Workflow = {
54
121
  name: 'foreach-workflow',
@@ -155,6 +222,85 @@ describe('WorkflowRunner', () => {
155
222
  expect(outputs).toBeDefined();
156
223
  });
157
224
 
225
+ it('should validate step input schema', async () => {
226
+ const schemaDbPath = 'test-step-input-schema.db';
227
+ const workflowWithInputSchema: Workflow = {
228
+ name: 'step-input-schema-wf',
229
+ steps: [
230
+ {
231
+ id: 's1',
232
+ type: 'shell',
233
+ run: 'echo "hello"',
234
+ needs: [],
235
+ inputSchema: {
236
+ type: 'object',
237
+ properties: { run: { type: 'number' } },
238
+ required: ['run'],
239
+ additionalProperties: false,
240
+ },
241
+ },
242
+ ],
243
+ } as unknown as Workflow;
244
+
245
+ const runner = new WorkflowRunner(workflowWithInputSchema, { dbPath: schemaDbPath });
246
+ await expect(runner.run()).rejects.toThrow(/Step s1 failed/);
247
+
248
+ const db = new WorkflowDb(schemaDbPath);
249
+ const steps = await db.getStepsByRun(runner.runId);
250
+ db.close();
251
+
252
+ expect(steps[0]?.error || '').toMatch(/Input schema validation failed/);
253
+ if (existsSync(schemaDbPath)) rmSync(schemaDbPath);
254
+ });
255
+
256
+ it('should validate step output schema', async () => {
257
+ const schemaDbPath = 'test-step-output-schema.db';
258
+ const workflowWithOutputSchema: Workflow = {
259
+ name: 'step-output-schema-wf',
260
+ steps: [
261
+ {
262
+ id: 's1',
263
+ type: 'shell',
264
+ run: 'echo "hello"',
265
+ needs: [],
266
+ outputSchema: {
267
+ type: 'object',
268
+ properties: { ok: { const: true } },
269
+ required: ['ok'],
270
+ additionalProperties: false,
271
+ },
272
+ },
273
+ ],
274
+ } as unknown as Workflow;
275
+
276
+ const runner = new WorkflowRunner(workflowWithOutputSchema, { dbPath: schemaDbPath });
277
+ await expect(runner.run()).rejects.toThrow(/Step s1 failed/);
278
+
279
+ const db = new WorkflowDb(schemaDbPath);
280
+ const steps = await db.getStepsByRun(runner.runId);
281
+ db.close();
282
+
283
+ expect(steps[0]?.error || '').toMatch(/Output schema validation failed/);
284
+ if (existsSync(schemaDbPath)) rmSync(schemaDbPath);
285
+ });
286
+
287
+ it('should enforce input enums', async () => {
288
+ const workflowWithEnums: Workflow = {
289
+ name: 'enum-wf',
290
+ inputs: {
291
+ mode: { type: 'string', values: ['fast', 'slow'] },
292
+ },
293
+ steps: [{ id: 's1', type: 'shell', run: 'echo "${{ inputs.mode }}"', needs: [] }],
294
+ } as unknown as Workflow;
295
+
296
+ const runner = new WorkflowRunner(workflowWithEnums, {
297
+ dbPath,
298
+ inputs: { mode: 'invalid' },
299
+ });
300
+
301
+ await expect(runner.run()).rejects.toThrow(/must be one of/);
302
+ });
303
+
158
304
  it('should handle step failure and workflow failure', async () => {
159
305
  const failWorkflow: Workflow = {
160
306
  name: 'fail-wf',
@@ -164,6 +310,185 @@ describe('WorkflowRunner', () => {
164
310
  await expect(runner.run()).rejects.toThrow(/Step fail failed/);
165
311
  });
166
312
 
313
+ it('should execute errors block when a step fails', async () => {
314
+ let errorsBlockExecuted = false;
315
+ let lastFailedStepId: string | undefined;
316
+ const runnerLogger = {
317
+ log: (msg: string) => {
318
+ if (msg.includes('Executing errors step: err1')) {
319
+ errorsBlockExecuted = true;
320
+ }
321
+ },
322
+ error: () => {},
323
+ warn: () => {},
324
+ info: () => {},
325
+ debug: () => {},
326
+ };
327
+
328
+ const errorsWorkflow: Workflow = {
329
+ name: 'errors-wf',
330
+ steps: [{ id: 's1', type: 'shell', run: 'exit 1', needs: [] }],
331
+ errors: [
332
+ {
333
+ id: 'err1',
334
+ type: 'shell',
335
+ run: 'echo "Handling failure of ${{ last_failed_step.id }}"',
336
+ needs: [],
337
+ },
338
+ ],
339
+ } as unknown as Workflow;
340
+
341
+ const runner = new WorkflowRunner(errorsWorkflow, { dbPath, logger: runnerLogger });
342
+ try {
343
+ await runner.run();
344
+ } catch (e) {
345
+ // Expected to fail
346
+ }
347
+
348
+ expect(errorsBlockExecuted).toBe(true);
349
+ });
350
+
351
+ it('should continue when allowFailure is true', async () => {
352
+ const allowFailureWorkflow: Workflow = {
353
+ name: 'allow-failure-wf',
354
+ steps: [
355
+ { id: 'fail', type: 'shell', run: 'exit 1', needs: [], allowFailure: true },
356
+ { id: 'next', type: 'shell', run: 'echo ok', needs: ['fail'] },
357
+ ],
358
+ outputs: {
359
+ status: '${{ steps.fail.status }}',
360
+ error: '${{ steps.fail.error }}',
361
+ },
362
+ } as unknown as Workflow;
363
+
364
+ const runner = new WorkflowRunner(allowFailureWorkflow, { dbPath });
365
+ const outputs = await runner.run();
366
+ expect(outputs.status).toBe('success');
367
+ expect(String(outputs.error || '')).toMatch(/exit 1|Step failed|Shell command exited/);
368
+ });
369
+
370
+ it('should deduplicate steps using idempotencyKey within a run', async () => {
371
+ const idempotencyDbPath = 'test-idempotency.db';
372
+ if (existsSync(idempotencyDbPath)) rmSync(idempotencyDbPath);
373
+
374
+ let idempotencyHitCount = 0;
375
+ const runnerLogger = {
376
+ log: (msg: string) => {
377
+ if (msg.includes('idempotency hit')) {
378
+ idempotencyHitCount++;
379
+ }
380
+ },
381
+ error: () => {},
382
+ warn: () => {},
383
+ info: () => {},
384
+ debug: () => {},
385
+ };
386
+
387
+ const idempotencyWorkflow: Workflow = {
388
+ name: 'idempotency-wf',
389
+ steps: [
390
+ {
391
+ id: 's1',
392
+ type: 'shell',
393
+ run: 'echo "executed"',
394
+ needs: [],
395
+ idempotencyKey: '"fixed-key-123"',
396
+ },
397
+ {
398
+ id: 's2',
399
+ type: 'shell',
400
+ run: 'echo "second"',
401
+ needs: ['s1'],
402
+ idempotencyKey: '"fixed-key-123"',
403
+ },
404
+ ],
405
+ outputs: {
406
+ out1: '${{ steps.s1.output.stdout.trim() }}',
407
+ out2: '${{ steps.s2.output.stdout.trim() }}',
408
+ },
409
+ } as unknown as Workflow;
410
+
411
+ const runner = new WorkflowRunner(idempotencyWorkflow, {
412
+ dbPath: idempotencyDbPath,
413
+ logger: runnerLogger,
414
+ });
415
+ const outputs = await runner.run();
416
+ expect(outputs.out1).toBe('executed');
417
+ expect(outputs.out2).toBe('executed');
418
+ expect(idempotencyHitCount).toBe(1);
419
+
420
+ if (existsSync(idempotencyDbPath)) rmSync(idempotencyDbPath);
421
+ });
422
+
423
+ it('should allow disabling idempotency deduplication', async () => {
424
+ const idempotencyDbPath = 'test-idempotency-disabled.db';
425
+ if (existsSync(idempotencyDbPath)) rmSync(idempotencyDbPath);
426
+
427
+ const idempotencyWorkflow: Workflow = {
428
+ name: 'idempotency-disabled-wf',
429
+ steps: [
430
+ {
431
+ id: 's1',
432
+ type: 'shell',
433
+ run: 'echo "first"',
434
+ needs: [],
435
+ idempotencyKey: '"fixed-key-123"',
436
+ },
437
+ {
438
+ id: 's2',
439
+ type: 'shell',
440
+ run: 'echo "second"',
441
+ needs: ['s1'],
442
+ idempotencyKey: '"fixed-key-123"',
443
+ },
444
+ ],
445
+ outputs: {
446
+ out1: '${{ steps.s1.output.stdout.trim() }}',
447
+ out2: '${{ steps.s2.output.stdout.trim() }}',
448
+ },
449
+ } as unknown as Workflow;
450
+
451
+ const runner = new WorkflowRunner(idempotencyWorkflow, {
452
+ dbPath: idempotencyDbPath,
453
+ dedup: false,
454
+ });
455
+ const outputs = await runner.run();
456
+ expect(outputs.out1).toBe('first');
457
+ expect(outputs.out2).toBe('second');
458
+
459
+ if (existsSync(idempotencyDbPath)) rmSync(idempotencyDbPath);
460
+ });
461
+
462
+ it('should detect in-flight idempotency keys', async () => {
463
+ const idempotencyDbPath = 'test-idempotency-inflight.db';
464
+ if (existsSync(idempotencyDbPath)) rmSync(idempotencyDbPath);
465
+
466
+ const idempotencyWorkflow: Workflow = {
467
+ name: 'idempotency-inflight-wf',
468
+ steps: [
469
+ {
470
+ id: 's1',
471
+ type: 'sleep',
472
+ duration: 50,
473
+ needs: [],
474
+ idempotencyKey: '"same-key"',
475
+ },
476
+ {
477
+ id: 's2',
478
+ type: 'sleep',
479
+ duration: 50,
480
+ needs: [],
481
+ idempotencyKey: '"same-key"',
482
+ },
483
+ ],
484
+ } as unknown as Workflow;
485
+
486
+ const runner = new WorkflowRunner(idempotencyWorkflow, { dbPath: idempotencyDbPath });
487
+ await expect(runner.run()).rejects.toThrow(/Idempotency key already in-flight/);
488
+
489
+ if (existsSync(idempotencyDbPath)) rmSync(idempotencyDbPath);
490
+ });
491
+
167
492
  it('should execute steps in parallel', async () => {
168
493
  const parallelWorkflow: Workflow = {
169
494
  name: 'parallel-wf',
@@ -211,12 +536,12 @@ describe('WorkflowRunner', () => {
211
536
  },
212
537
  ],
213
538
  outputs: {
214
- final: '${{ steps.sub.output.out }}',
539
+ final: '${{ steps.sub.output.outputs.out }}',
215
540
  },
216
541
  } as unknown as Workflow;
217
542
 
218
- spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('child.yaml');
219
- spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue(childWorkflow);
543
+ trackSpy(spyOn(WorkflowRegistry, 'resolvePath')).mockReturnValue('child.yaml');
544
+ trackSpy(spyOn(WorkflowParser, 'loadWorkflow')).mockReturnValue(childWorkflow);
220
545
 
221
546
  const runner = new WorkflowRunner(parentWorkflow, { dbPath });
222
547
  const outputs = await runner.run();
@@ -304,7 +629,7 @@ describe('WorkflowRunner', () => {
304
629
  try {
305
630
  await runner1.run();
306
631
  } catch (e) {
307
- runId = runner1.getRunId();
632
+ runId = runner1.runId;
308
633
  }
309
634
 
310
635
  const fixedWorkflow: Workflow = {
@@ -333,21 +658,60 @@ describe('WorkflowRunner', () => {
333
658
  },
334
659
  } as unknown as Workflow;
335
660
 
336
- // @ts-ignore
337
- spyOn(WorkflowRunner.prototype, 'loadSecrets').mockReturnValue({
338
- MY_SECRET: 'my-super-secret',
661
+ const secretValue = 'my-super-secret';
662
+ const runner = new WorkflowRunner(workflow, {
663
+ dbPath,
664
+ secrets: { KEYSTONE_TEST_REDACTION_SECRET: secretValue },
339
665
  });
666
+ await runner.run();
340
667
 
341
- const runner = new WorkflowRunner(workflow, { dbPath });
668
+ expect(runner.redact(secretValue)).toBe('***REDACTED***');
669
+ });
670
+
671
+ it('should redact secret inputs at rest', async () => {
672
+ const dbFile = 'test-secret-at-rest.db';
673
+ const workflow: Workflow = {
674
+ name: 'secret-input-wf',
675
+ inputs: {
676
+ token: { type: 'string', secret: true },
677
+ },
678
+ steps: [{ id: 's1', type: 'shell', run: 'echo "ok"', needs: [] }],
679
+ } as unknown as Workflow;
680
+
681
+ ConfigLoader.setConfig({
682
+ default_provider: 'openai',
683
+ providers: {
684
+ openai: { type: 'openai' },
685
+ },
686
+ model_mappings: {},
687
+ storage: { retention_days: 30, redact_secrets_at_rest: true },
688
+ mcp_servers: {},
689
+ engines: { allowlist: {}, denylist: [] },
690
+ concurrency: { default: 10, pools: { llm: 2, shell: 5, http: 10, engine: 2 } },
691
+ });
692
+
693
+ const runner = new WorkflowRunner(workflow, {
694
+ dbPath: dbFile,
695
+ inputs: { token: 'super-secret' },
696
+ });
342
697
  await runner.run();
343
698
 
344
- expect(runner.redact('my-super-secret')).toBe('***REDACTED***');
699
+ const db = new WorkflowDb(dbFile);
700
+ const run = await db.getRun(runner.runId);
701
+ db.close();
702
+
703
+ expect(run).toBeTruthy();
704
+ const persistedInputs = run ? JSON.parse(run.inputs) : {};
705
+ expect(persistedInputs.token).toBe('***REDACTED***');
706
+
707
+ ConfigLoader.clear();
708
+ if (existsSync(dbFile)) rmSync(dbFile);
345
709
  });
346
710
 
347
711
  it('should return run ID', () => {
348
712
  const runner = new WorkflowRunner(workflow, { dbPath });
349
- expect(runner.getRunId()).toBeDefined();
350
- expect(typeof runner.getRunId()).toBe('string');
713
+ expect(runner.runId).toBeDefined();
714
+ expect(typeof runner.runId).toBe('string');
351
715
  });
352
716
 
353
717
  it('should continue even if finally step fails', async () => {
@@ -455,7 +819,7 @@ describe('WorkflowRunner', () => {
455
819
  : undefined
456
820
  ).toBe('WorkflowSuspendedError');
457
821
 
458
- const runId = runner1.getRunId();
822
+ const runId = runner1.runId;
459
823
 
460
824
  // Check DB status - parent should be 'paused' and step should be 'suspended'
461
825
  const db = new WorkflowDb(resumeDbPath);
@@ -544,4 +908,117 @@ describe('WorkflowRunner', () => {
544
908
 
545
909
  if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
546
910
  });
911
+
912
+ it('should parse outputRetries and repairStrategy in step schema', async () => {
913
+ // This test verifies that the schema accepts outputRetries and repairStrategy
914
+ // Actual LLM repair would require mocking, so we just verify the config is parsed
915
+ const workflowWithRepair: Workflow = {
916
+ name: 'output-repair-wf',
917
+ steps: [
918
+ {
919
+ id: 's1',
920
+ type: 'shell',
921
+ run: 'echo "hello"',
922
+ needs: [],
923
+ outputRetries: 2,
924
+ repairStrategy: 'reask',
925
+ },
926
+ ],
927
+ } as unknown as Workflow;
928
+
929
+ // Verify the workflow can be created with these fields
930
+ expect(workflowWithRepair.steps[0].outputRetries).toBe(2);
931
+ expect(workflowWithRepair.steps[0].repairStrategy).toBe('reask');
932
+
933
+ // The runner should accept these fields without error
934
+ const runner = new WorkflowRunner(workflowWithRepair, { dbPath });
935
+ const outputs = await runner.run();
936
+ expect(outputs).toBeDefined();
937
+ });
938
+
939
+ it('should allow cancellation via abortSignal', async () => {
940
+ const cancelDbPath = 'test-cancel.db';
941
+ if (existsSync(cancelDbPath)) rmSync(cancelDbPath);
942
+
943
+ const workflow: Workflow = {
944
+ name: 'cancel-wf',
945
+ steps: [
946
+ { id: 's1', type: 'sleep', duration: 10, needs: [] },
947
+ { id: 's2', type: 'sleep', duration: 10, needs: ['s1'] },
948
+ ],
949
+ } as unknown as Workflow;
950
+
951
+ const runner = new WorkflowRunner(workflow, { dbPath: cancelDbPath, preventExit: true });
952
+
953
+ // Verify the abort signal is exposed and not yet aborted
954
+ expect(runner.abortSignal).toBeDefined();
955
+ expect(runner.abortSignal instanceof AbortSignal).toBe(true);
956
+ expect(runner.abortSignal.aborted).toBe(false);
957
+
958
+ // Run the workflow (short duration so it completes quickly)
959
+ await runner.run();
960
+
961
+ if (existsSync(cancelDbPath)) rmSync(cancelDbPath);
962
+ });
963
+
964
+ it('should resume from canceled state', async () => {
965
+ const resumeDbPath = 'test-cancel-resume.db';
966
+ if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
967
+
968
+ const workflow: Workflow = {
969
+ name: 'cancel-resume-wf',
970
+ steps: [
971
+ { id: 's1', type: 'shell', run: 'echo "one"', needs: [] },
972
+ { id: 's2', type: 'shell', run: 'echo "two"', needs: ['s1'] },
973
+ ],
974
+ outputs: {
975
+ out: '${{ steps.s1.output.stdout.trim() }}-${{ steps.s2.output.stdout.trim() }}',
976
+ },
977
+ } as unknown as Workflow;
978
+
979
+ // Manually create a "canceled" state in the DB
980
+ const db = new WorkflowDb(resumeDbPath);
981
+ const runId = crypto.randomUUID();
982
+ await db.createRun(runId, workflow.name, {});
983
+ await db.updateRunStatus(runId, 'canceled', undefined, 'Canceled by user');
984
+
985
+ // Create a completed step 1
986
+ const step1Id = crypto.randomUUID();
987
+ await db.createStep(step1Id, runId, 's1');
988
+ await db.startStep(step1Id);
989
+ await db.completeStep(step1Id, 'success', { stdout: 'one\n', exitCode: 0 });
990
+ db.close();
991
+
992
+ // Resume from canceled state
993
+ let loggedResume = false;
994
+ const logger = {
995
+ log: (msg: string) => {
996
+ if (msg.includes('Resuming a previously canceled run')) {
997
+ loggedResume = true;
998
+ }
999
+ },
1000
+ error: () => {},
1001
+ warn: () => {},
1002
+ info: () => {},
1003
+ };
1004
+
1005
+ const runner = new WorkflowRunner(workflow, {
1006
+ dbPath: resumeDbPath,
1007
+ resumeRunId: runId,
1008
+ // @ts-ignore
1009
+ logger: logger,
1010
+ });
1011
+
1012
+ const outputs = await runner.run();
1013
+ expect(outputs.out).toBe('one-two');
1014
+ expect(loggedResume).toBe(true);
1015
+
1016
+ // Verify final status is success
1017
+ const finalDb = new WorkflowDb(resumeDbPath);
1018
+ const finalRun = await finalDb.getRun(runId);
1019
+ expect(finalRun?.status).toBe('success');
1020
+ finalDb.close();
1021
+
1022
+ if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
1023
+ });
547
1024
  });