keystone-cli 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +43 -4
  2. package/package.json +4 -1
  3. package/src/cli.ts +1 -0
  4. package/src/commands/event.ts +9 -0
  5. package/src/commands/run.ts +17 -0
  6. package/src/db/dynamic-state-manager.ts +12 -9
  7. package/src/db/memory-db.test.ts +19 -1
  8. package/src/db/memory-db.ts +101 -22
  9. package/src/db/workflow-db.ts +181 -9
  10. package/src/expression/evaluator.ts +4 -1
  11. package/src/parser/config-schema.ts +6 -0
  12. package/src/parser/schema.ts +1 -0
  13. package/src/runner/__test__/llm-test-setup.ts +43 -11
  14. package/src/runner/durable-timers.test.ts +1 -1
  15. package/src/runner/executors/dynamic-executor.ts +125 -88
  16. package/src/runner/executors/engine-executor.ts +10 -39
  17. package/src/runner/executors/file-executor.ts +67 -0
  18. package/src/runner/executors/foreach-executor.ts +170 -17
  19. package/src/runner/executors/human-executor.ts +18 -0
  20. package/src/runner/executors/llm/stream-handler.ts +103 -0
  21. package/src/runner/executors/llm/tool-manager.ts +360 -0
  22. package/src/runner/executors/llm-executor.ts +288 -555
  23. package/src/runner/executors/memory-executor.ts +41 -34
  24. package/src/runner/executors/shell-executor.ts +96 -52
  25. package/src/runner/executors/subworkflow-executor.ts +16 -0
  26. package/src/runner/executors/types.ts +3 -1
  27. package/src/runner/executors/verification_fixes.test.ts +46 -0
  28. package/src/runner/join-scheduling.test.ts +2 -1
  29. package/src/runner/llm-adapter.integration.test.ts +10 -5
  30. package/src/runner/llm-adapter.ts +57 -18
  31. package/src/runner/llm-clarification.test.ts +4 -1
  32. package/src/runner/llm-executor.test.ts +21 -7
  33. package/src/runner/mcp-client.ts +36 -2
  34. package/src/runner/mcp-server.ts +65 -36
  35. package/src/runner/recovery-security.test.ts +5 -2
  36. package/src/runner/reflexion.test.ts +6 -3
  37. package/src/runner/services/context-builder.ts +13 -4
  38. package/src/runner/services/workflow-validator.ts +2 -1
  39. package/src/runner/standard-tools-ast.test.ts +4 -2
  40. package/src/runner/standard-tools-execution.test.ts +14 -1
  41. package/src/runner/standard-tools-integration.test.ts +6 -0
  42. package/src/runner/standard-tools.ts +13 -10
  43. package/src/runner/step-executor.ts +2 -2
  44. package/src/runner/tool-integration.test.ts +4 -1
  45. package/src/runner/workflow-runner.test.ts +23 -12
  46. package/src/runner/workflow-runner.ts +172 -79
  47. package/src/runner/workflow-state.ts +181 -111
  48. package/src/ui/dashboard.tsx +17 -3
  49. package/src/utils/config-loader.ts +4 -0
  50. package/src/utils/constants.ts +4 -0
  51. package/src/utils/context-injector.test.ts +27 -27
  52. package/src/utils/context-injector.ts +68 -26
  53. package/src/utils/process-sandbox.ts +138 -148
  54. package/src/utils/redactor.ts +39 -9
  55. package/src/utils/resource-loader.ts +24 -19
  56. package/src/utils/sandbox.ts +6 -0
  57. package/src/utils/stream-utils.ts +58 -0
@@ -208,7 +208,7 @@ function convertToExecutableStep(
208
208
  export async function executeDynamicStep(
209
209
  step: DynamicStep,
210
210
  context: ExpressionContext,
211
- executeStepFn: (step: Step, context: ExpressionContext) => Promise<StepResult>,
211
+ executeStepFn: (step: Step, context: ExpressionContext, depth?: number) => Promise<StepResult>,
212
212
  logger: Logger,
213
213
  options: StepExecutorOptions & {
214
214
  stateManager?: DynamicStateManager;
@@ -216,9 +216,18 @@ export async function executeDynamicStep(
216
216
  saveState?: (stepId: string, state: DynamicStepState) => Promise<void>;
217
217
  executeLlmStep?: typeof executeLlmStep;
218
218
  executeHumanStep?: typeof executeHumanStep;
219
+ depth?: number;
219
220
  }
220
221
  ): Promise<StepResult> {
221
222
  const { runId, db, abortSignal } = options;
223
+ const depth = options.depth || 0;
224
+
225
+ // Prevent infinite recursion in dynamic steps
226
+ // 10 is matching WorkflowRunner.MAX_RECURSION_DEPTH
227
+ // We should ideally import this constant, but for now we hardcode or add to LIMITS
228
+ if (depth > 10) {
229
+ throw new Error('Maximum workflow recursion depth (10) exceeded in dynamic step.');
230
+ }
222
231
  const stateManager = options.stateManager || (db ? new DynamicStateManager(db) : null);
223
232
 
224
233
  const { state, dbState } = await initializeState(step, runId, stateManager, options.loadState);
@@ -242,7 +251,7 @@ export async function executeDynamicStep(
242
251
  stateManager,
243
252
  executeStepFn,
244
253
  logger,
245
- options
254
+ { ...options, depth }
246
255
  );
247
256
  }
248
257
 
@@ -259,7 +268,7 @@ export async function executeDynamicStep(
259
268
  stateManager,
260
269
  executeStepFn,
261
270
  logger,
262
- options
271
+ { ...options, depth }
263
272
  );
264
273
  }
265
274
  }
@@ -329,12 +338,13 @@ async function handlePlanningPhase(
329
338
  state: DynamicStepState,
330
339
  dbState: DynamicStepState | null,
331
340
  stateManager: DynamicStateManager | null,
332
- executeStepFn: (step: Step, context: ExpressionContext) => Promise<StepResult>,
341
+ executeStepFn: (step: Step, context: ExpressionContext, depth?: number) => Promise<StepResult>,
333
342
  logger: Logger,
334
343
  options: StepExecutorOptions & {
335
344
  stateManager?: DynamicStateManager;
336
345
  saveState?: (stepId: string, state: DynamicStepState) => Promise<void>;
337
346
  executeLlmStep?: typeof executeLlmStep;
347
+ depth?: number;
338
348
  }
339
349
  ) {
340
350
  const { runId, emitEvent, workflowName, abortSignal, mcpManager, workflowDir } = options;
@@ -392,6 +402,17 @@ async function handlePlanningPhase(
392
402
  }
393
403
 
394
404
  state.generatedPlan = planResult.output as DynamicPlan;
405
+ // Early validation of plan structure and dependencies
406
+ try {
407
+ topologicalSort(state.generatedPlan.steps);
408
+ } catch (err) {
409
+ state.status = 'failed';
410
+ state.error = err instanceof Error ? err.message : String(err);
411
+ if (stateManager && dbState && dbState.id)
412
+ await stateManager.finish(dbState.id, 'failed', state.error);
413
+ throw new Error(state.error);
414
+ }
415
+
395
416
  state.status = step.confirmPlan ? 'awaiting_confirmation' : 'executing';
396
417
 
397
418
  logger.log(` 📋 Plan generated with ${state.generatedPlan.steps.length} steps:`);
@@ -449,6 +470,8 @@ async function handleConfirmationPhase(
449
470
  try {
450
471
  const modifiedPlan = JSON.parse(response) as DynamicPlan;
451
472
  if (modifiedPlan.steps && Array.isArray(modifiedPlan.steps)) {
473
+ // Validate modified plan
474
+ topologicalSort(modifiedPlan.steps);
452
475
  state.generatedPlan = modifiedPlan;
453
476
  logger.log(' ✓ Using modified plan');
454
477
  }
@@ -480,14 +503,15 @@ async function handleExecutionPhase(
480
503
  state: DynamicStepState,
481
504
  dbState: DynamicStepState | null,
482
505
  stateManager: DynamicStateManager | null,
483
- executeStepFn: (step: Step, context: ExpressionContext) => Promise<StepResult>,
506
+ executeStepFn: (step: Step, context: ExpressionContext, depth?: number) => Promise<StepResult>,
484
507
  logger: Logger,
485
508
  options: StepExecutorOptions & {
486
509
  stateManager?: DynamicStateManager;
487
510
  saveState?: (stepId: string, state: DynamicStepState) => Promise<void>;
511
+ depth?: number;
488
512
  }
489
513
  ) {
490
- const { abortSignal, runId, workflowName, emitEvent, saveState } = options;
514
+ const { abortSignal, runId, workflowName, emitEvent, saveState, depth = 0 } = options;
491
515
 
492
516
  // Detect circular dependencies and validate plan
493
517
  topologicalSort(state.generatedPlan.steps);
@@ -522,6 +546,9 @@ async function handleExecutionPhase(
522
546
  1;
523
547
  logger.log(` 🚀 Starting parallel execution (concurrency: ${maxConcurrency})`);
524
548
 
549
+ // Track running promises to avoid busy wait
550
+ const executionPromises = new Set<Promise<void>>();
551
+
525
552
  while (completed.size + failed.size < state.generatedPlan.steps.length) {
526
553
  if (abortSignal?.aborted) throw new Error('Dynamic step execution canceled');
527
554
 
@@ -551,94 +578,104 @@ async function handleExecutionPhase(
551
578
  break;
552
579
  }
553
580
 
581
+ // Helper to wrap execution with cleanup
582
+ const executeWrapper = async (genStep: GeneratedStep, i: number) => {
583
+ try {
584
+ logger.log(
585
+ ` ⚡ [${i + 1}/${state.generatedPlan.steps.length}] Executing step: ${genStep.name}`
586
+ );
587
+
588
+ const executableStep = convertToExecutableStep(genStep, step.id, step.allowInsecure);
589
+ const stepContext = {
590
+ ...dynamicContext,
591
+ steps: {
592
+ ...(dynamicContext.steps || {}),
593
+ ...Object.fromEntries(
594
+ Array.from(resultsMap.entries()).map(([id, res]) => [
595
+ `${step.id}_${id}`,
596
+ { output: res.output },
597
+ ])
598
+ ),
599
+ },
600
+ } as ExpressionContext;
601
+
602
+ if (emitEvent && runId && workflowName) {
603
+ emitEvent({
604
+ type: 'step.start',
605
+ timestamp: new Date().toISOString(),
606
+ runId,
607
+ workflow: workflowName,
608
+ stepId: executableStep.id,
609
+ stepType: executableStep.type,
610
+ phase: 'main',
611
+ stepIndex: i + 1,
612
+ totalSteps: state.generatedPlan.steps.length,
613
+ });
614
+ }
615
+
616
+ const res = await executeStepFn(executableStep, stepContext, depth + 1);
617
+ resultsMap.set(genStep.id, res);
618
+ state.stepResults.set(genStep.id, res);
619
+
620
+ if (stateManager && dbState && dbState.id) {
621
+ await stateManager.completeStep(dbState.id, genStep.id, res);
622
+ await stateManager.updateProgress(dbState.id, completed.size + failed.size + 1);
623
+ } else if (saveState) {
624
+ await saveState(step.id, state);
625
+ }
626
+
627
+ if (emitEvent && runId && workflowName) {
628
+ emitEvent({
629
+ type: 'step.end',
630
+ timestamp: new Date().toISOString(),
631
+ runId,
632
+ workflow: workflowName,
633
+ stepId: executableStep.id,
634
+ stepType: executableStep.type,
635
+ phase: 'main',
636
+ status: res.status as any,
637
+ stepIndex: i + 1,
638
+ totalSteps: state.generatedPlan.steps.length,
639
+ });
640
+ }
641
+
642
+ if (res.status === 'success') {
643
+ completed.add(genStep.id);
644
+ } else {
645
+ failed.add(genStep.id);
646
+ if (!(genStep.allowStepFailure ?? step.allowStepFailure ?? false)) {
647
+ state.status = 'failed';
648
+ state.error = `Step "${genStep.name}" failed: ${res.error}`;
649
+ }
650
+ }
651
+ } catch (err) {
652
+ const failRes: StepResult = { status: 'failed', error: String(err), output: {} };
653
+ resultsMap.set(genStep.id, failRes);
654
+ state.stepResults.set(genStep.id, failRes);
655
+ failed.add(genStep.id);
656
+ state.status = 'failed';
657
+ state.error = `Step "${genStep.name}" crashed: ${err}`;
658
+ } finally {
659
+ running.delete(genStep.id);
660
+ }
661
+ };
662
+
554
663
  if (ready.length > 0 && running.size < maxConcurrency) {
555
664
  const toStart = ready.slice(0, maxConcurrency - running.size);
556
665
  for (const genStep of toStart) {
557
666
  running.add(genStep.id);
558
- (async () => {
559
- try {
560
- const i = state.generatedPlan.steps.indexOf(genStep);
561
- logger.log(
562
- ` ⚡ [${i + 1}/${state.generatedPlan.steps.length}] Executing step: ${genStep.name}`
563
- );
564
-
565
- const executableStep = convertToExecutableStep(genStep, step.id, step.allowInsecure);
566
- const stepContext = {
567
- ...dynamicContext,
568
- steps: {
569
- ...(dynamicContext.steps || {}),
570
- ...Object.fromEntries(
571
- Array.from(resultsMap.entries()).map(([id, res]) => [
572
- `${step.id}_${id}`,
573
- { output: res.output },
574
- ])
575
- ),
576
- },
577
- } as ExpressionContext;
578
-
579
- if (emitEvent && runId && workflowName) {
580
- emitEvent({
581
- type: 'step.start',
582
- timestamp: new Date().toISOString(),
583
- runId,
584
- workflow: workflowName,
585
- stepId: executableStep.id,
586
- stepType: executableStep.type,
587
- phase: 'main',
588
- stepIndex: i + 1,
589
- totalSteps: state.generatedPlan.steps.length,
590
- });
591
- }
592
-
593
- const res = await executeStepFn(executableStep, stepContext);
594
- resultsMap.set(genStep.id, res);
595
- state.stepResults.set(genStep.id, res);
596
-
597
- if (stateManager && dbState && dbState.id) {
598
- await stateManager.completeStep(dbState.id, genStep.id, res);
599
- await stateManager.updateProgress(dbState.id, completed.size + failed.size + 1);
600
- } else if (saveState) {
601
- await saveState(step.id, state);
602
- }
603
-
604
- if (emitEvent && runId && workflowName) {
605
- emitEvent({
606
- type: 'step.end',
607
- timestamp: new Date().toISOString(),
608
- runId,
609
- workflow: workflowName,
610
- stepId: executableStep.id,
611
- stepType: executableStep.type,
612
- phase: 'main',
613
- status: res.status as any,
614
- stepIndex: i + 1,
615
- totalSteps: state.generatedPlan.steps.length,
616
- });
617
- }
618
-
619
- if (res.status === 'success') {
620
- completed.add(genStep.id);
621
- } else {
622
- failed.add(genStep.id);
623
- if (!(genStep.allowStepFailure ?? step.allowStepFailure ?? false)) {
624
- state.status = 'failed';
625
- state.error = `Step "${genStep.name}" failed: ${res.error}`;
626
- }
627
- }
628
- } catch (err) {
629
- const failRes: StepResult = { status: 'failed', error: String(err), output: {} };
630
- resultsMap.set(genStep.id, failRes);
631
- state.stepResults.set(genStep.id, failRes);
632
- failed.add(genStep.id);
633
- state.status = 'failed';
634
- state.error = `Step "${genStep.name}" crashed: ${err}`;
635
- } finally {
636
- running.delete(genStep.id);
637
- }
638
- })();
667
+ const i = state.generatedPlan.steps.indexOf(genStep);
668
+ const promise = executeWrapper(genStep, i).then(() => {
669
+ executionPromises.delete(promise);
670
+ });
671
+ executionPromises.add(promise);
639
672
  }
640
673
  }
641
- await new Promise((resolve) => setTimeout(resolve, 100));
674
+
675
+ // Wait for at least one task to complete before re-evaluating loop
676
+ if (executionPromises.size > 0) {
677
+ await Promise.race(executionPromises);
678
+ }
642
679
  }
643
680
 
644
681
  const allSatisfied = Array.from(resultsMap.entries()).every(
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync, mkdirSync } from 'node:fs';
4
4
  import * as path from 'node:path';
5
+ import { StringDecoder } from 'node:string_decoder';
5
6
  import yaml from 'js-yaml';
6
7
  import type { ExpressionContext } from '../../expression/evaluator';
7
8
  import { ExpressionEvaluator } from '../../expression/evaluator';
@@ -52,44 +53,7 @@ class LRUCache<K, V> {
52
53
  }
53
54
 
54
55
  const VERSION_CACHE = new LRUCache<string, string>(LIMITS.VERSION_CACHE_MAX_SIZE);
55
- const TRUNCATED_SUFFIX = '... [truncated output]';
56
-
57
- function createOutputLimiter(maxBytes: number) {
58
- let bytes = 0;
59
- let text = '';
60
- let truncated = false;
61
-
62
- const append = (chunk: Buffer | string) => {
63
- if (truncated || maxBytes <= 0) {
64
- truncated = true;
65
- return;
66
- }
67
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
68
- const remaining = maxBytes - bytes;
69
- if (remaining <= 0) {
70
- truncated = true;
71
- return;
72
- }
73
- if (buffer.length <= remaining) {
74
- text += buffer.toString();
75
- bytes += buffer.length;
76
- return;
77
- }
78
- text += buffer.subarray(0, remaining).toString();
79
- bytes = maxBytes;
80
- truncated = true;
81
- };
82
-
83
- const finalize = () => (truncated ? `${text}${TRUNCATED_SUFFIX}` : text);
84
-
85
- return {
86
- append,
87
- finalize,
88
- get truncated() {
89
- return truncated;
90
- },
91
- };
92
- }
56
+ import { createOutputLimiter } from '../../utils/stream-utils';
93
57
 
94
58
  export interface EngineExecutionResult {
95
59
  stdout: string;
@@ -163,11 +127,17 @@ async function runCommand(
163
127
  if (child.stdout) {
164
128
  child.stdout.on('data', (chunk: Buffer) => {
165
129
  stdoutLimiter.append(chunk);
130
+ if (stdoutLimiter.truncated) {
131
+ child.kill();
132
+ }
166
133
  });
167
134
  }
168
135
  if (child.stderr) {
169
136
  child.stderr.on('data', (chunk: Buffer) => {
170
137
  stderrLimiter.append(chunk);
138
+ if (stderrLimiter.truncated) {
139
+ child.kill();
140
+ }
171
141
  });
172
142
  }
173
143
 
@@ -208,7 +178,8 @@ async function checkEngineVersion(
208
178
  cwd: string,
209
179
  abortSignal?: AbortSignal
210
180
  ): Promise<string> {
211
- const cacheKey = `${command}::${versionArgs.join(' ')}`;
181
+ const pathEnv = env.PATH || env.Path || env.path || '';
182
+ const cacheKey = `${command}::${versionArgs.join(' ')}::${cwd}::${pathEnv}`;
212
183
  const cached = VERSION_CACHE.get(cacheKey);
213
184
  if (cached) return cached;
214
185
 
@@ -113,6 +113,73 @@ export function parseUnifiedDiff(patch: string): UnifiedDiff {
113
113
  }
114
114
 
115
115
  export function applyUnifiedDiff(content: string, patch: string, targetPath: string): string {
116
+ // Try using system `patch` command first as it's more robust
117
+ try {
118
+ const { spawnSync } = require('node:child_process');
119
+
120
+ // Check if patch is available (quick check)
121
+ // We assume standard unix `patch` or compatible.
122
+ // writing content to temp file and patch to temp file?
123
+ // actually, we can pipe to stdin.
124
+ // echo content | patch -o output
125
+ // But patch usually works on files.
126
+
127
+ // Since we are operating on in-memory strings (content), using `patch` binary requires tmp files.
128
+ // This might be slow for many small files.
129
+ // BUT the robustness is worth it.
130
+
131
+ const fs = require('node:fs');
132
+ const os = require('node:os');
133
+ const path = require('node:path');
134
+
135
+ // Create temp dir
136
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'keystone-patch-'));
137
+ const tmpSrc = path.join(tmpDir, 'source');
138
+ const tmpPatch = path.join(tmpDir, 'changes.patch');
139
+
140
+ try {
141
+ fs.writeFileSync(tmpSrc, content);
142
+ fs.writeFileSync(tmpPatch, patch);
143
+
144
+ // Run patch: patch -p1 -i changes.patch -o output (if headers have paths)
145
+ // Or just patch tmpSrc < changes.patch?
146
+ // Unified diffs usually expect paths.
147
+ // If we force it...
148
+ // `patch` utility is tricky with paths.
149
+ // LLM generated diffs might have /dev/null or a/b paths.
150
+
151
+ // Let's try `git apply` if inside a git repo?
152
+ // No, we might not be in a git repo.
153
+
154
+ // Let's stick to the JS Custom Parser BUT make it more lenient/robust as per user request?
155
+ // User said: "rely on the system's patch or git apply"
156
+
157
+ // Let's try `patch -u -l --fuzz=2 -i patchfile srcfile -o outfile`
158
+ const result = spawnSync(
159
+ 'patch',
160
+ ['-u', '-l', '--fuzz=2', '-i', tmpPatch, tmpSrc, '-o', path.join(tmpDir, 'result')],
161
+ {
162
+ encoding: 'utf-8',
163
+ stdio: 'pipe',
164
+ }
165
+ );
166
+
167
+ if (result.status === 0 && fs.existsSync(path.join(tmpDir, 'result'))) {
168
+ return fs.readFileSync(path.join(tmpDir, 'result'), 'utf-8');
169
+ }
170
+ } catch (e) {
171
+ // ignore
172
+ } finally {
173
+ // cleanup
174
+ try {
175
+ fs.rmSync(tmpDir, { recursive: true, force: true });
176
+ } catch {}
177
+ }
178
+ } catch (e) {
179
+ // ignore
180
+ }
181
+
182
+ // Fallback to JS implementation
116
183
  const diff = parseUnifiedDiff(patch);
117
184
  assertDiffMatchesTarget(diff.newFile || diff.originalFile, targetPath);
118
185