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.
- package/README.md +486 -54
- package/package.json +8 -2
- package/src/__fixtures__/index.ts +100 -0
- package/src/cli.ts +809 -90
- package/src/db/memory-db.ts +35 -1
- package/src/db/workflow-db.test.ts +24 -0
- package/src/db/workflow-db.ts +469 -14
- package/src/expression/evaluator.ts +68 -4
- package/src/parser/agent-parser.ts +6 -3
- package/src/parser/config-schema.ts +38 -2
- package/src/parser/schema.ts +192 -7
- package/src/parser/test-schema.ts +29 -0
- package/src/parser/workflow-parser.test.ts +54 -0
- package/src/parser/workflow-parser.ts +153 -7
- package/src/runner/aggregate-error.test.ts +57 -0
- package/src/runner/aggregate-error.ts +46 -0
- package/src/runner/audit-verification.test.ts +2 -2
- package/src/runner/auto-heal.test.ts +1 -1
- package/src/runner/blueprint-executor.test.ts +63 -0
- package/src/runner/blueprint-executor.ts +157 -0
- package/src/runner/concurrency-limit.test.ts +82 -0
- package/src/runner/debug-repl.ts +18 -3
- package/src/runner/durable-timers.test.ts +200 -0
- package/src/runner/engine-executor.test.ts +464 -0
- package/src/runner/engine-executor.ts +491 -0
- package/src/runner/foreach-executor.ts +30 -12
- package/src/runner/llm-adapter.test.ts +282 -5
- package/src/runner/llm-adapter.ts +581 -8
- package/src/runner/llm-clarification.test.ts +79 -21
- package/src/runner/llm-errors.ts +83 -0
- package/src/runner/llm-executor.test.ts +258 -219
- package/src/runner/llm-executor.ts +226 -29
- package/src/runner/mcp-client.ts +70 -3
- package/src/runner/mcp-manager.test.ts +52 -52
- package/src/runner/mcp-manager.ts +12 -5
- package/src/runner/mcp-server.test.ts +117 -78
- package/src/runner/mcp-server.ts +13 -4
- package/src/runner/optimization-runner.ts +48 -31
- package/src/runner/reflexion.test.ts +1 -1
- package/src/runner/resource-pool.test.ts +113 -0
- package/src/runner/resource-pool.ts +164 -0
- package/src/runner/shell-executor.ts +130 -32
- package/src/runner/standard-tools-integration.test.ts +36 -36
- package/src/runner/standard-tools.test.ts +18 -0
- package/src/runner/standard-tools.ts +110 -37
- package/src/runner/step-executor.test.ts +176 -16
- package/src/runner/step-executor.ts +530 -86
- package/src/runner/stream-utils.test.ts +14 -0
- package/src/runner/subflow-outputs.test.ts +103 -0
- package/src/runner/test-harness.ts +161 -0
- package/src/runner/tool-integration.test.ts +73 -79
- package/src/runner/workflow-runner.test.ts +492 -15
- package/src/runner/workflow-runner.ts +1438 -79
- package/src/runner/workflow-subflows.test.ts +255 -0
- package/src/templates/agents/keystone-architect.md +17 -12
- package/src/templates/agents/tester.md +21 -0
- package/src/templates/child-rollback.yaml +11 -0
- package/src/templates/decompose-implement.yaml +53 -0
- package/src/templates/decompose-problem.yaml +159 -0
- package/src/templates/decompose-research.yaml +52 -0
- package/src/templates/decompose-review.yaml +51 -0
- package/src/templates/dev.yaml +134 -0
- package/src/templates/engine-example.yaml +33 -0
- package/src/templates/fan-out-fan-in.yaml +61 -0
- package/src/templates/memory-service.yaml +1 -1
- package/src/templates/parent-rollback.yaml +16 -0
- package/src/templates/robust-automation.yaml +1 -1
- package/src/templates/scaffold-feature.yaml +29 -27
- package/src/templates/scaffold-generate.yaml +41 -0
- package/src/templates/scaffold-plan.yaml +53 -0
- package/src/types/status.ts +3 -0
- package/src/ui/dashboard.tsx +4 -3
- package/src/utils/assets.macro.ts +36 -0
- package/src/utils/auth-manager.ts +585 -8
- package/src/utils/blueprint-utils.test.ts +49 -0
- package/src/utils/blueprint-utils.ts +80 -0
- package/src/utils/circuit-breaker.test.ts +177 -0
- package/src/utils/circuit-breaker.ts +160 -0
- package/src/utils/config-loader.test.ts +100 -13
- package/src/utils/config-loader.ts +44 -17
- package/src/utils/constants.ts +62 -0
- package/src/utils/error-renderer.test.ts +267 -0
- package/src/utils/error-renderer.ts +320 -0
- package/src/utils/json-parser.test.ts +4 -0
- package/src/utils/json-parser.ts +18 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.test.ts +46 -0
- package/src/utils/paths.ts +70 -0
- package/src/utils/process-sandbox.test.ts +128 -0
- package/src/utils/process-sandbox.ts +293 -0
- package/src/utils/rate-limiter.test.ts +143 -0
- package/src/utils/rate-limiter.ts +221 -0
- package/src/utils/redactor.test.ts +23 -15
- package/src/utils/redactor.ts +65 -25
- package/src/utils/resource-loader.test.ts +54 -0
- package/src/utils/resource-loader.ts +158 -0
- package/src/utils/sandbox.test.ts +69 -4
- package/src/utils/sandbox.ts +69 -6
- package/src/utils/schema-validator.ts +65 -0
- package/src/utils/workflow-registry.test.ts +57 -0
- package/src/utils/workflow-registry.ts +45 -25
- /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
- /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { afterAll, afterEach,
|
|
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
|
-
|
|
22
|
-
|
|
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.
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
350
|
-
expect(typeof runner.
|
|
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.
|
|
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
|
});
|