keystone-cli 0.3.1 → 0.4.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 +18 -1
- package/package.json +1 -1
- package/src/db/workflow-db.ts +26 -7
- package/src/expression/evaluator.ts +1 -0
- package/src/parser/agent-parser.test.ts +8 -5
- package/src/parser/schema.ts +8 -2
- package/src/runner/audit-verification.test.ts +106 -0
- package/src/runner/llm-adapter.ts +196 -4
- package/src/runner/llm-clarification.test.ts +182 -0
- package/src/runner/llm-executor.ts +118 -26
- package/src/runner/mcp-manager.ts +4 -1
- package/src/runner/mcp-server.test.ts +115 -1
- package/src/runner/mcp-server.ts +161 -4
- package/src/runner/shell-executor.ts +1 -1
- package/src/runner/step-executor.test.ts +33 -10
- package/src/runner/step-executor.ts +110 -14
- package/src/runner/workflow-runner.test.ts +132 -0
- package/src/runner/workflow-runner.ts +118 -23
- package/src/templates/agents/keystone-architect.md +21 -5
- package/src/templates/full-feature-demo.yaml +5 -0
- package/src/ui/dashboard.tsx +32 -4
- package/src/utils/auth-manager.test.ts +31 -0
- package/src/utils/auth-manager.ts +21 -5
- package/src/utils/json-parser.test.ts +35 -0
- package/src/utils/json-parser.ts +95 -0
- package/src/utils/mermaid.ts +12 -0
- package/src/utils/sandbox.test.ts +12 -4
- package/src/utils/sandbox.ts +69 -49
package/src/runner/mcp-server.ts
CHANGED
|
@@ -153,6 +153,40 @@ export class MCPServer {
|
|
|
153
153
|
required: ['run_id', 'input'],
|
|
154
154
|
},
|
|
155
155
|
},
|
|
156
|
+
{
|
|
157
|
+
name: 'start_workflow',
|
|
158
|
+
description:
|
|
159
|
+
'Start a workflow asynchronously. Returns immediately with a run_id. Use get_run_status to poll for completion.',
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
workflow_name: {
|
|
164
|
+
type: 'string',
|
|
165
|
+
description: 'The name of the workflow to run (e.g., "deploy", "cleanup")',
|
|
166
|
+
},
|
|
167
|
+
inputs: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
description: 'Key-value pairs for workflow inputs',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
required: ['workflow_name'],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'get_run_status',
|
|
177
|
+
description:
|
|
178
|
+
'Get the current status of a workflow run. Returns status and outputs if complete.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
run_id: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description: 'The ID of the workflow run',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
required: ['run_id'],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
156
190
|
],
|
|
157
191
|
},
|
|
158
192
|
};
|
|
@@ -335,17 +369,24 @@ export class MCPServer {
|
|
|
335
369
|
throw new Error(`Run ${run_id} is not paused (status: ${run.status})`);
|
|
336
370
|
}
|
|
337
371
|
|
|
338
|
-
// Find the pending
|
|
372
|
+
// Find the pending or suspended step
|
|
339
373
|
const steps = this.db.getStepsByRun(run_id);
|
|
340
|
-
const pendingStep = steps.find(
|
|
374
|
+
const pendingStep = steps.find(
|
|
375
|
+
(s) => s.status === 'pending' || s.status === 'suspended'
|
|
376
|
+
);
|
|
341
377
|
if (!pendingStep) {
|
|
342
|
-
throw new Error(`No pending step found for run ${run_id}`);
|
|
378
|
+
throw new Error(`No pending or suspended step found for run ${run_id}`);
|
|
343
379
|
}
|
|
344
380
|
|
|
345
381
|
// Fulfill the step in the DB
|
|
346
382
|
let output: unknown = input;
|
|
347
383
|
const lowerInput = input.trim().toLowerCase();
|
|
348
|
-
if (
|
|
384
|
+
if (
|
|
385
|
+
lowerInput === 'confirm' ||
|
|
386
|
+
lowerInput === 'y' ||
|
|
387
|
+
lowerInput === 'yes' ||
|
|
388
|
+
lowerInput === ''
|
|
389
|
+
) {
|
|
349
390
|
output = true;
|
|
350
391
|
} else if (lowerInput === 'n' || lowerInput === 'no') {
|
|
351
392
|
output = false;
|
|
@@ -366,6 +407,7 @@ export class MCPServer {
|
|
|
366
407
|
|
|
367
408
|
const runner = new WorkflowRunner(workflow, {
|
|
368
409
|
resumeRunId: run_id,
|
|
410
|
+
resumeInputs: { [pendingStep.step_id]: { __answer: output } },
|
|
369
411
|
logger,
|
|
370
412
|
preventExit: true,
|
|
371
413
|
});
|
|
@@ -440,6 +482,121 @@ export class MCPServer {
|
|
|
440
482
|
};
|
|
441
483
|
}
|
|
442
484
|
|
|
485
|
+
// --- Tool: start_workflow (async) ---
|
|
486
|
+
if (toolParams.name === 'start_workflow') {
|
|
487
|
+
const { workflow_name, inputs } = toolParams.arguments as {
|
|
488
|
+
workflow_name: string;
|
|
489
|
+
inputs?: Record<string, unknown>;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const path = WorkflowRegistry.resolvePath(workflow_name);
|
|
493
|
+
const workflow = WorkflowParser.loadWorkflow(path);
|
|
494
|
+
|
|
495
|
+
// Create a silent logger - we don't capture logs for async runs
|
|
496
|
+
const logger = {
|
|
497
|
+
log: () => {},
|
|
498
|
+
error: () => {},
|
|
499
|
+
warn: () => {},
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const runner = new WorkflowRunner(workflow, {
|
|
503
|
+
inputs: inputs || {},
|
|
504
|
+
logger,
|
|
505
|
+
preventExit: true,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const runId = runner.getRunId();
|
|
509
|
+
|
|
510
|
+
// Start the workflow asynchronously - don't await
|
|
511
|
+
runner.run().then(
|
|
512
|
+
(outputs) => {
|
|
513
|
+
// Update DB with success on completion (RunStatus uses 'completed')
|
|
514
|
+
this.db.updateRunStatus(runId, 'completed', outputs);
|
|
515
|
+
},
|
|
516
|
+
(error) => {
|
|
517
|
+
// Update DB with failure
|
|
518
|
+
if (error instanceof WorkflowSuspendedError) {
|
|
519
|
+
this.db.updateRunStatus(runId, 'paused');
|
|
520
|
+
} else {
|
|
521
|
+
this.db.updateRunStatus(
|
|
522
|
+
runId,
|
|
523
|
+
'failed',
|
|
524
|
+
undefined,
|
|
525
|
+
error instanceof Error ? error.message : String(error)
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
jsonrpc: '2.0',
|
|
533
|
+
id,
|
|
534
|
+
result: {
|
|
535
|
+
content: [
|
|
536
|
+
{
|
|
537
|
+
type: 'text',
|
|
538
|
+
text: JSON.stringify(
|
|
539
|
+
{
|
|
540
|
+
status: 'running',
|
|
541
|
+
run_id: runId,
|
|
542
|
+
workflow: workflow_name,
|
|
543
|
+
hint: 'Use get_run_status to check for completion.',
|
|
544
|
+
},
|
|
545
|
+
null,
|
|
546
|
+
2
|
|
547
|
+
),
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// --- Tool: get_run_status ---
|
|
555
|
+
if (toolParams.name === 'get_run_status') {
|
|
556
|
+
const { run_id } = toolParams.arguments as { run_id: string };
|
|
557
|
+
const run = this.db.getRun(run_id);
|
|
558
|
+
|
|
559
|
+
if (!run) {
|
|
560
|
+
throw new Error(`Run ID ${run_id} not found`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const response: Record<string, unknown> = {
|
|
564
|
+
run_id,
|
|
565
|
+
workflow: run.workflow_name,
|
|
566
|
+
status: run.status,
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
// Include outputs if completed successfully
|
|
570
|
+
if (run.status === 'completed' && run.outputs) {
|
|
571
|
+
response.outputs = JSON.parse(run.outputs);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Include error if failed
|
|
575
|
+
if (run.status === 'failed' && run.error) {
|
|
576
|
+
response.error = run.error;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Include hint for paused workflows
|
|
580
|
+
if (run.status === 'paused') {
|
|
581
|
+
response.hint =
|
|
582
|
+
'Workflow is paused waiting for human input. Use answer_human_input to resume.';
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Include hint for running workflows
|
|
586
|
+
if (run.status === 'running') {
|
|
587
|
+
response.hint =
|
|
588
|
+
'Workflow is still running. Call get_run_status again to check for completion.';
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
jsonrpc: '2.0',
|
|
593
|
+
id,
|
|
594
|
+
result: {
|
|
595
|
+
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
443
600
|
throw new Error(`Unknown tool: ${toolParams.name}`);
|
|
444
601
|
} catch (error) {
|
|
445
602
|
return {
|
|
@@ -60,7 +60,7 @@ export interface ShellResult {
|
|
|
60
60
|
* Check if a command contains potentially dangerous shell metacharacters
|
|
61
61
|
* Returns true if the command looks like it might contain unescaped user input
|
|
62
62
|
*/
|
|
63
|
-
function detectShellInjectionRisk(command: string): boolean {
|
|
63
|
+
export function detectShellInjectionRisk(command: string): boolean {
|
|
64
64
|
// Common shell metacharacters that indicate potential injection
|
|
65
65
|
const dangerousPatterns = [
|
|
66
66
|
/;[\s]*\w/, // Command chaining with semicolon
|
|
@@ -34,7 +34,7 @@ interface RequestOutput {
|
|
|
34
34
|
// Mock node:readline/promises
|
|
35
35
|
const mockRl = {
|
|
36
36
|
question: mock(() => Promise.resolve('')),
|
|
37
|
-
close: mock(() => {
|
|
37
|
+
close: mock(() => {}),
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
mock.module('node:readline/promises', () => ({
|
|
@@ -49,13 +49,13 @@ describe('step-executor', () => {
|
|
|
49
49
|
beforeAll(() => {
|
|
50
50
|
try {
|
|
51
51
|
mkdirSync(tempDir, { recursive: true });
|
|
52
|
-
} catch (e) {
|
|
52
|
+
} catch (e) {}
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
afterAll(() => {
|
|
56
56
|
try {
|
|
57
57
|
rmSync(tempDir, { recursive: true, force: true });
|
|
58
|
-
} catch (e) {
|
|
58
|
+
} catch (e) {}
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
beforeEach(() => {
|
|
@@ -306,6 +306,29 @@ describe('step-executor', () => {
|
|
|
306
306
|
// @ts-ignore
|
|
307
307
|
expect(result.output.data).toBe('plain text');
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
it('should include response body in error for failed requests', async () => {
|
|
311
|
+
// @ts-ignore
|
|
312
|
+
global.fetch.mockResolvedValue(
|
|
313
|
+
new Response('{"error": "bad request details"}', {
|
|
314
|
+
status: 400,
|
|
315
|
+
statusText: 'Bad Request',
|
|
316
|
+
headers: { 'Content-Type': 'application/json' },
|
|
317
|
+
})
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const step: RequestStep = {
|
|
321
|
+
id: 'req1',
|
|
322
|
+
type: 'request',
|
|
323
|
+
url: 'https://api.example.com/fail',
|
|
324
|
+
method: 'POST',
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const result = await executeStep(step, context);
|
|
328
|
+
expect(result.status).toBe('failed');
|
|
329
|
+
expect(result.error).toContain('HTTP 400: Bad Request');
|
|
330
|
+
expect(result.error).toContain('Response Body: {"error": "bad request details"}');
|
|
331
|
+
});
|
|
309
332
|
});
|
|
310
333
|
|
|
311
334
|
describe('human', () => {
|
|
@@ -330,7 +353,7 @@ describe('step-executor', () => {
|
|
|
330
353
|
};
|
|
331
354
|
|
|
332
355
|
// @ts-ignore
|
|
333
|
-
const result = await executeStep(step, context, { log: () => {
|
|
356
|
+
const result = await executeStep(step, context, { log: () => {} });
|
|
334
357
|
expect(result.status).toBe('success');
|
|
335
358
|
expect(result.output).toBe(true);
|
|
336
359
|
expect(mockRl.question).toHaveBeenCalled();
|
|
@@ -347,7 +370,7 @@ describe('step-executor', () => {
|
|
|
347
370
|
};
|
|
348
371
|
|
|
349
372
|
// @ts-ignore
|
|
350
|
-
const result = await executeStep(step, context, { log: () => {
|
|
373
|
+
const result = await executeStep(step, context, { log: () => {} });
|
|
351
374
|
expect(result.status).toBe('success');
|
|
352
375
|
expect(result.output).toBe('user response');
|
|
353
376
|
});
|
|
@@ -363,19 +386,19 @@ describe('step-executor', () => {
|
|
|
363
386
|
// Test 'yes'
|
|
364
387
|
mockRl.question.mockResolvedValue('yes');
|
|
365
388
|
// @ts-ignore
|
|
366
|
-
let result = await executeStep(step, context, { log: () => {
|
|
389
|
+
let result = await executeStep(step, context, { log: () => {} });
|
|
367
390
|
expect(result.output).toBe(true);
|
|
368
391
|
|
|
369
392
|
// Test 'no'
|
|
370
393
|
mockRl.question.mockResolvedValue('no');
|
|
371
394
|
// @ts-ignore
|
|
372
|
-
result = await executeStep(step, context, { log: () => {
|
|
395
|
+
result = await executeStep(step, context, { log: () => {} });
|
|
373
396
|
expect(result.output).toBe(false);
|
|
374
397
|
|
|
375
398
|
// Test empty string (default to true)
|
|
376
399
|
mockRl.question.mockResolvedValue('');
|
|
377
400
|
// @ts-ignore
|
|
378
|
-
result = await executeStep(step, context, { log: () => {
|
|
401
|
+
result = await executeStep(step, context, { log: () => {} });
|
|
379
402
|
expect(result.output).toBe(true);
|
|
380
403
|
});
|
|
381
404
|
|
|
@@ -390,7 +413,7 @@ describe('step-executor', () => {
|
|
|
390
413
|
};
|
|
391
414
|
|
|
392
415
|
// @ts-ignore
|
|
393
|
-
const result = await executeStep(step, context, { log: () => {
|
|
416
|
+
const result = await executeStep(step, context, { log: () => {} });
|
|
394
417
|
expect(result.status).toBe('success');
|
|
395
418
|
expect(result.output).toBe('some custom response');
|
|
396
419
|
});
|
|
@@ -406,7 +429,7 @@ describe('step-executor', () => {
|
|
|
406
429
|
};
|
|
407
430
|
|
|
408
431
|
// @ts-ignore
|
|
409
|
-
const result = await executeStep(step, context, { log: () => {
|
|
432
|
+
const result = await executeStep(step, context, { log: () => {} });
|
|
410
433
|
expect(result.status).toBe('suspended');
|
|
411
434
|
expect(result.error).toBe('Proceed?');
|
|
412
435
|
});
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
Step,
|
|
12
12
|
WorkflowStep,
|
|
13
13
|
} from '../parser/schema.ts';
|
|
14
|
-
import { executeShell } from './shell-executor.ts';
|
|
14
|
+
import { detectShellInjectionRisk, executeShell } from './shell-executor.ts';
|
|
15
15
|
import type { Logger } from './workflow-runner.ts';
|
|
16
16
|
|
|
17
17
|
import * as readline from 'node:readline/promises';
|
|
@@ -34,6 +34,11 @@ export interface StepResult {
|
|
|
34
34
|
output: unknown;
|
|
35
35
|
status: 'success' | 'failed' | 'suspended';
|
|
36
36
|
error?: string;
|
|
37
|
+
usage?: {
|
|
38
|
+
prompt_tokens: number;
|
|
39
|
+
completion_tokens: number;
|
|
40
|
+
total_tokens: number;
|
|
41
|
+
};
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/**
|
|
@@ -45,16 +50,17 @@ export async function executeStep(
|
|
|
45
50
|
logger: Logger = console,
|
|
46
51
|
executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
|
|
47
52
|
mcpManager?: MCPManager,
|
|
48
|
-
workflowDir?: string
|
|
53
|
+
workflowDir?: string,
|
|
54
|
+
dryRun?: boolean
|
|
49
55
|
): Promise<StepResult> {
|
|
50
56
|
try {
|
|
51
57
|
let result: StepResult;
|
|
52
58
|
switch (step.type) {
|
|
53
59
|
case 'shell':
|
|
54
|
-
result = await executeShellStep(step, context, logger);
|
|
60
|
+
result = await executeShellStep(step, context, logger, dryRun);
|
|
55
61
|
break;
|
|
56
62
|
case 'file':
|
|
57
|
-
result = await executeFileStep(step, context, logger);
|
|
63
|
+
result = await executeFileStep(step, context, logger, dryRun);
|
|
58
64
|
break;
|
|
59
65
|
case 'request':
|
|
60
66
|
result = await executeRequestStep(step, context, logger);
|
|
@@ -69,7 +75,7 @@ export async function executeStep(
|
|
|
69
75
|
result = await executeLlmStep(
|
|
70
76
|
step,
|
|
71
77
|
context,
|
|
72
|
-
(s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager, workflowDir),
|
|
78
|
+
(s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager, workflowDir, dryRun),
|
|
73
79
|
logger,
|
|
74
80
|
mcpManager,
|
|
75
81
|
workflowDir
|
|
@@ -129,8 +135,61 @@ export async function executeStep(
|
|
|
129
135
|
async function executeShellStep(
|
|
130
136
|
step: ShellStep,
|
|
131
137
|
context: ExpressionContext,
|
|
132
|
-
logger: Logger
|
|
138
|
+
logger: Logger,
|
|
139
|
+
dryRun?: boolean
|
|
133
140
|
): Promise<StepResult> {
|
|
141
|
+
if (dryRun) {
|
|
142
|
+
const command = ExpressionEvaluator.evaluateString(step.run, context);
|
|
143
|
+
logger.log(`[DRY RUN] Would execute shell command: ${command}`);
|
|
144
|
+
return {
|
|
145
|
+
output: { stdout: '[DRY RUN] Success', stderr: '', exitCode: 0 },
|
|
146
|
+
status: 'success',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// Check for risk and prompt if TTY
|
|
150
|
+
const command = ExpressionEvaluator.evaluateString(step.run, context);
|
|
151
|
+
const isRisky = detectShellInjectionRisk(command);
|
|
152
|
+
|
|
153
|
+
if (isRisky) {
|
|
154
|
+
// Check if we have a resume approval
|
|
155
|
+
const stepInputs = context.inputs
|
|
156
|
+
? (context.inputs as Record<string, unknown>)[step.id]
|
|
157
|
+
: undefined;
|
|
158
|
+
if (
|
|
159
|
+
stepInputs &&
|
|
160
|
+
typeof stepInputs === 'object' &&
|
|
161
|
+
'__approved' in stepInputs &&
|
|
162
|
+
stepInputs.__approved === true
|
|
163
|
+
) {
|
|
164
|
+
// Already approved, proceed
|
|
165
|
+
} else {
|
|
166
|
+
const message = `Potentially risky shell command detected: ${command}`;
|
|
167
|
+
|
|
168
|
+
if (!process.stdin.isTTY) {
|
|
169
|
+
return {
|
|
170
|
+
output: null,
|
|
171
|
+
status: 'suspended',
|
|
172
|
+
error: `APPROVAL_REQUIRED: ${message}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rl = readline.createInterface({
|
|
177
|
+
input: process.stdin,
|
|
178
|
+
output: process.stdout,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
logger.warn(`\n⚠️ ${message}`);
|
|
183
|
+
const answer = (await rl.question('Do you want to execute this command? (y/N): ')).trim();
|
|
184
|
+
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
|
|
185
|
+
throw new Error('Command execution denied by user');
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
rl.close();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
134
193
|
const result = await executeShell(step, context, logger);
|
|
135
194
|
|
|
136
195
|
if (result.stdout) {
|
|
@@ -165,10 +224,20 @@ async function executeShellStep(
|
|
|
165
224
|
async function executeFileStep(
|
|
166
225
|
step: FileStep,
|
|
167
226
|
context: ExpressionContext,
|
|
168
|
-
_logger: Logger
|
|
227
|
+
_logger: Logger,
|
|
228
|
+
dryRun?: boolean
|
|
169
229
|
): Promise<StepResult> {
|
|
170
230
|
const path = ExpressionEvaluator.evaluateString(step.path, context);
|
|
171
231
|
|
|
232
|
+
if (dryRun && step.op !== 'read') {
|
|
233
|
+
const opVerb = step.op === 'write' ? 'write to' : 'append to';
|
|
234
|
+
_logger.log(`[DRY RUN] Would ${opVerb} file: ${path}`);
|
|
235
|
+
return {
|
|
236
|
+
output: { path, bytes: 0 },
|
|
237
|
+
status: 'success',
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
172
241
|
switch (step.op) {
|
|
173
242
|
case 'read': {
|
|
174
243
|
const file = Bun.file(path);
|
|
@@ -298,7 +367,13 @@ async function executeRequestStep(
|
|
|
298
367
|
data: responseData,
|
|
299
368
|
},
|
|
300
369
|
status: response.ok ? 'success' : 'failed',
|
|
301
|
-
error: response.ok
|
|
370
|
+
error: response.ok
|
|
371
|
+
? undefined
|
|
372
|
+
: `HTTP ${response.status}: ${response.statusText}${
|
|
373
|
+
responseText
|
|
374
|
+
? `\nResponse Body: ${responseText.substring(0, 500)}${responseText.length > 500 ? '...' : ''}`
|
|
375
|
+
: ''
|
|
376
|
+
}`,
|
|
302
377
|
};
|
|
303
378
|
}
|
|
304
379
|
|
|
@@ -312,6 +387,21 @@ async function executeHumanStep(
|
|
|
312
387
|
): Promise<StepResult> {
|
|
313
388
|
const message = ExpressionEvaluator.evaluateString(step.message, context);
|
|
314
389
|
|
|
390
|
+
// Check if we have a resume answer
|
|
391
|
+
const stepInputs = context.inputs
|
|
392
|
+
? (context.inputs as Record<string, unknown>)[step.id]
|
|
393
|
+
: undefined;
|
|
394
|
+
if (stepInputs && typeof stepInputs === 'object' && '__answer' in stepInputs) {
|
|
395
|
+
const answer = (stepInputs as Record<string, unknown>).__answer;
|
|
396
|
+
return {
|
|
397
|
+
output:
|
|
398
|
+
step.inputType === 'confirm'
|
|
399
|
+
? answer === true || answer === 'true' || answer === 'yes' || answer === 'y'
|
|
400
|
+
: answer,
|
|
401
|
+
status: 'success',
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
315
405
|
// If not a TTY (e.g. MCP server), suspend execution
|
|
316
406
|
if (!process.stdin.isTTY) {
|
|
317
407
|
return {
|
|
@@ -396,12 +486,18 @@ async function executeScriptStep(
|
|
|
396
486
|
_logger: Logger
|
|
397
487
|
): Promise<StepResult> {
|
|
398
488
|
try {
|
|
399
|
-
const result = await SafeSandbox.execute(
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
489
|
+
const result = await SafeSandbox.execute(
|
|
490
|
+
step.run,
|
|
491
|
+
{
|
|
492
|
+
inputs: context.inputs,
|
|
493
|
+
secrets: context.secrets,
|
|
494
|
+
steps: context.steps,
|
|
495
|
+
env: context.env,
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
allowInsecureFallback: step.allowInsecure,
|
|
499
|
+
}
|
|
500
|
+
);
|
|
405
501
|
|
|
406
502
|
return {
|
|
407
503
|
output: result,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
|
2
2
|
import { existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { WorkflowDb } from '../db/workflow-db';
|
|
3
4
|
import type { Workflow } from '../parser/schema';
|
|
4
5
|
import { WorkflowParser } from '../parser/workflow-parser';
|
|
5
6
|
import { WorkflowRegistry } from '../utils/workflow-registry';
|
|
@@ -12,6 +13,9 @@ describe('WorkflowRunner', () => {
|
|
|
12
13
|
if (existsSync('test-resume.db')) {
|
|
13
14
|
rmSync('test-resume.db');
|
|
14
15
|
}
|
|
16
|
+
if (existsSync('test-foreach-resume.db')) {
|
|
17
|
+
rmSync('test-foreach-resume.db');
|
|
18
|
+
}
|
|
15
19
|
});
|
|
16
20
|
|
|
17
21
|
beforeEach(() => {
|
|
@@ -273,6 +277,51 @@ describe('WorkflowRunner', () => {
|
|
|
273
277
|
expect(s1Executed).toBe(false); // Should have been skipped
|
|
274
278
|
});
|
|
275
279
|
|
|
280
|
+
it('should merge resumeInputs with stored inputs on resume', async () => {
|
|
281
|
+
const resumeDbPath = 'test-merge-inputs.db';
|
|
282
|
+
if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
|
|
283
|
+
|
|
284
|
+
const workflow: Workflow = {
|
|
285
|
+
name: 'merge-wf',
|
|
286
|
+
inputs: {
|
|
287
|
+
initial: { type: 'string' },
|
|
288
|
+
resumed: { type: 'string' },
|
|
289
|
+
},
|
|
290
|
+
steps: [{ id: 's1', type: 'shell', run: 'exit 1', needs: [] }],
|
|
291
|
+
outputs: {
|
|
292
|
+
merged: '${{ inputs.initial }}-${{ inputs.resumed }}',
|
|
293
|
+
},
|
|
294
|
+
} as unknown as Workflow;
|
|
295
|
+
|
|
296
|
+
const runner1 = new WorkflowRunner(workflow, {
|
|
297
|
+
dbPath: resumeDbPath,
|
|
298
|
+
inputs: { initial: 'first', resumed: 'pending' },
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
let runId = '';
|
|
302
|
+
try {
|
|
303
|
+
await runner1.run();
|
|
304
|
+
} catch (e) {
|
|
305
|
+
runId = runner1.getRunId();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const fixedWorkflow: Workflow = {
|
|
309
|
+
...workflow,
|
|
310
|
+
steps: [{ id: 's1', type: 'shell', run: 'echo ok', needs: [] }],
|
|
311
|
+
} as unknown as Workflow;
|
|
312
|
+
|
|
313
|
+
const runner2 = new WorkflowRunner(fixedWorkflow, {
|
|
314
|
+
dbPath: resumeDbPath,
|
|
315
|
+
resumeRunId: runId,
|
|
316
|
+
resumeInputs: { resumed: 'second' },
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const outputs = await runner2.run();
|
|
320
|
+
expect(outputs.merged).toBe('first-second');
|
|
321
|
+
|
|
322
|
+
if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
|
|
323
|
+
});
|
|
324
|
+
|
|
276
325
|
it('should redact secrets from outputs', async () => {
|
|
277
326
|
const workflow: Workflow = {
|
|
278
327
|
name: 'redaction-wf',
|
|
@@ -355,4 +404,87 @@ describe('WorkflowRunner', () => {
|
|
|
355
404
|
}
|
|
356
405
|
expect(retryLogged).toBe(true);
|
|
357
406
|
});
|
|
407
|
+
|
|
408
|
+
it('should handle foreach suspension and resume correctly', async () => {
|
|
409
|
+
const resumeDbPath = 'test-foreach-resume.db';
|
|
410
|
+
if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
|
|
411
|
+
|
|
412
|
+
const workflow: Workflow = {
|
|
413
|
+
name: 'foreach-suspend-wf',
|
|
414
|
+
steps: [
|
|
415
|
+
{
|
|
416
|
+
id: 'gen',
|
|
417
|
+
type: 'shell',
|
|
418
|
+
run: 'echo "[1, 2]"',
|
|
419
|
+
transform: 'JSON.parse(output.stdout)',
|
|
420
|
+
needs: [],
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
id: 'process',
|
|
424
|
+
type: 'human',
|
|
425
|
+
message: 'Item ${{ item }}',
|
|
426
|
+
foreach: '${{ steps.gen.output }}',
|
|
427
|
+
needs: ['gen'],
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
outputs: {
|
|
431
|
+
results: '${{ steps.process.output }}',
|
|
432
|
+
},
|
|
433
|
+
} as unknown as Workflow;
|
|
434
|
+
|
|
435
|
+
// First run - should suspend
|
|
436
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
437
|
+
process.stdin.isTTY = false;
|
|
438
|
+
|
|
439
|
+
const runner1 = new WorkflowRunner(workflow, { dbPath: resumeDbPath });
|
|
440
|
+
let suspendedError: unknown;
|
|
441
|
+
try {
|
|
442
|
+
await runner1.run();
|
|
443
|
+
} catch (e) {
|
|
444
|
+
suspendedError = e;
|
|
445
|
+
} finally {
|
|
446
|
+
process.stdin.isTTY = originalIsTTY;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
expect(suspendedError).toBeDefined();
|
|
450
|
+
expect(
|
|
451
|
+
typeof suspendedError === 'object' && suspendedError !== null && 'name' in suspendedError
|
|
452
|
+
? (suspendedError as { name: string }).name
|
|
453
|
+
: undefined
|
|
454
|
+
).toBe('WorkflowSuspendedError');
|
|
455
|
+
|
|
456
|
+
const runId = runner1.getRunId();
|
|
457
|
+
|
|
458
|
+
// Check DB status - parent should be 'paused' and step should be 'suspended'
|
|
459
|
+
const db = new WorkflowDb(resumeDbPath);
|
|
460
|
+
const run = db.getRun(runId);
|
|
461
|
+
expect(run?.status).toBe('paused');
|
|
462
|
+
|
|
463
|
+
const steps = db.getStepsByRun(runId);
|
|
464
|
+
const parentStep = steps.find(
|
|
465
|
+
(s: { step_id: string; iteration_index: number | null }) =>
|
|
466
|
+
s.step_id === 'process' && s.iteration_index === null
|
|
467
|
+
);
|
|
468
|
+
expect(parentStep?.status).toBe('suspended');
|
|
469
|
+
db.close();
|
|
470
|
+
|
|
471
|
+
// Second run - resume with answers
|
|
472
|
+
const runner2 = new WorkflowRunner(workflow, {
|
|
473
|
+
dbPath: resumeDbPath,
|
|
474
|
+
resumeRunId: runId,
|
|
475
|
+
resumeInputs: {
|
|
476
|
+
process: { __answer: 'ok' },
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const outputs = await runner2.run();
|
|
481
|
+
expect(outputs.results).toEqual(['ok', 'ok']);
|
|
482
|
+
|
|
483
|
+
const finalDb = new WorkflowDb(resumeDbPath);
|
|
484
|
+
const finalRun = finalDb.getRun(runId);
|
|
485
|
+
expect(finalRun?.status).toBe('completed');
|
|
486
|
+
finalDb.close();
|
|
487
|
+
|
|
488
|
+
if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
|
|
489
|
+
});
|
|
358
490
|
});
|