keystone-cli 0.3.2 → 0.4.1

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.
@@ -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 human step
372
+ // Find the pending or suspended step
339
373
  const steps = this.db.getStepsByRun(run_id);
340
- const pendingStep = steps.find((s) => s.status === 'pending');
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 (lowerInput === 'confirm' || lowerInput === 'y' || lowerInput === 'yes' || lowerInput === '') {
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 ? undefined : `HTTP ${response.status}: ${response.statusText}`,
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(step.run, {
400
- inputs: context.inputs,
401
- secrets: context.secrets,
402
- steps: context.steps,
403
- env: context.env,
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
  });