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
@@ -275,7 +275,7 @@ describe('WorkflowRunner', () => {
275
275
  {
276
276
  id: 's1',
277
277
  type: 'shell',
278
- run: 'echo "hello"',
278
+ run: 'echo hello',
279
279
  needs: [],
280
280
  outputSchema: {
281
281
  type: 'object',
@@ -577,7 +577,7 @@ describe('WorkflowRunner', () => {
577
577
  const cached = await db.getStepCache(cacheKey);
578
578
  expect(cached).not.toBeNull();
579
579
  expect(cached?.output).not.toContain(secret);
580
- expect(JSON.parse(cached?.output).stdout).toContain('***REDACTED***');
580
+ expect(JSON.parse(cached?.output || '{}').stdout).toContain('***REDACTED***');
581
581
  db.close();
582
582
 
583
583
  if (existsSync(memoizeDbPath)) rmSync(memoizeDbPath);
@@ -657,7 +657,7 @@ describe('WorkflowRunner', () => {
657
657
  const workflow: Workflow = {
658
658
  name: 'resume-wf',
659
659
  steps: [
660
- { id: 's1', type: 'shell', run: 'echo "one"', needs: [] },
660
+ { id: 's1', type: 'shell', run: 'echo one', needs: [] },
661
661
  { id: 's2', type: 'shell', run: 'exit 1', needs: ['s1'] },
662
662
  ],
663
663
  } as unknown as Workflow;
@@ -677,8 +677,8 @@ describe('WorkflowRunner', () => {
677
677
  const fixedWorkflow: Workflow = {
678
678
  name: 'resume-wf',
679
679
  steps: [
680
- { id: 's1', type: 'shell', run: 'echo "one"', needs: [] },
681
- { id: 's2', type: 'shell', run: 'echo "two"', needs: ['s1'] },
680
+ { id: 's1', type: 'shell', run: 'echo one', needs: [] },
681
+ { id: 's2', type: 'shell', run: 'echo two', needs: ['s1'] },
682
682
  ],
683
683
  outputs: {
684
684
  out: '${{ steps.s1.output.stdout.trim() }}-${{ steps.s2.output.stdout.trim() }}',
@@ -756,7 +756,15 @@ describe('WorkflowRunner', () => {
756
756
  it('should redact secrets from outputs', async () => {
757
757
  const workflow: Workflow = {
758
758
  name: 'redaction-wf',
759
- steps: [{ id: 's1', type: 'shell', run: 'echo "Secret is my-super-secret"', needs: [] }],
759
+ steps: [
760
+ {
761
+ id: 's1',
762
+ type: 'shell',
763
+ run: 'echo "Secret is my-super-secret"',
764
+ allowInsecure: true,
765
+ needs: [],
766
+ },
767
+ ],
760
768
  outputs: {
761
769
  out: '${{ steps.s1.output.stdout.trim() }}',
762
770
  },
@@ -779,7 +787,7 @@ describe('WorkflowRunner', () => {
779
787
  inputs: {
780
788
  token: { type: 'string', secret: true },
781
789
  },
782
- steps: [{ id: 's1', type: 'shell', run: 'echo "ok"', needs: [] }],
790
+ steps: [{ id: 's1', type: 'shell', run: 'echo ok', needs: [] }],
783
791
  } as unknown as Workflow;
784
792
 
785
793
  ConfigLoader.setConfig({
@@ -793,6 +801,8 @@ describe('WorkflowRunner', () => {
793
801
  engines: { allowlist: {}, denylist: [] },
794
802
  concurrency: { default: 10, pools: { llm: 2, shell: 5, http: 10, engine: 2 } },
795
803
  expression: { strict: false },
804
+ embedding_dimension: 1536,
805
+ logging: { suppress_security_warning: false, suppress_ai_sdk_warnings: false },
796
806
  });
797
807
 
798
808
  const runner = new WorkflowRunner(workflow, {
@@ -881,7 +891,8 @@ describe('WorkflowRunner', () => {
881
891
  });
882
892
 
883
893
  it('should handle foreach suspension and resume correctly', async () => {
884
- const resumeDbPath = 'test-foreach-resume.db';
894
+ const resumeDbPath = `test-foreach-resume-${randomUUID()}.db`;
895
+ if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
885
896
  if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
886
897
 
887
898
  const workflow: Workflow = {
@@ -965,7 +976,7 @@ describe('WorkflowRunner', () => {
965
976
  });
966
977
 
967
978
  it('should reuse persisted foreach items on resume even if inputs change', async () => {
968
- const resumeDbPath = 'test-foreach-resume-items.db';
979
+ const resumeDbPath = `test-foreach-resume-items-${randomUUID()}.db`;
969
980
  if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
970
981
 
971
982
  const workflow: Workflow = {
@@ -1023,7 +1034,7 @@ describe('WorkflowRunner', () => {
1023
1034
  });
1024
1035
 
1025
1036
  it('should resume a workflow marked as running (crashed process)', async () => {
1026
- const resumeDbPath = 'test-running-resume.db';
1037
+ const resumeDbPath = `test-running-resume-${randomUUID()}.db`;
1027
1038
  if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
1028
1039
 
1029
1040
  const workflow: Workflow = {
@@ -1105,7 +1116,7 @@ describe('WorkflowRunner', () => {
1105
1116
  });
1106
1117
 
1107
1118
  it('should allow cancellation via abortSignal', async () => {
1108
- const cancelDbPath = 'test-cancel.db';
1119
+ const cancelDbPath = `test-cancel-${randomUUID()}.db`;
1109
1120
  if (existsSync(cancelDbPath)) rmSync(cancelDbPath);
1110
1121
 
1111
1122
  const workflow: Workflow = {
@@ -1130,7 +1141,7 @@ describe('WorkflowRunner', () => {
1130
1141
  });
1131
1142
 
1132
1143
  it('should resume from canceled state', async () => {
1133
- const resumeDbPath = 'test-cancel-resume.db';
1144
+ const resumeDbPath = `test-cancel-resume-${randomUUID()}.db`;
1134
1145
  if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
1135
1146
 
1136
1147
  const workflow: Workflow = {
@@ -14,7 +14,7 @@ import { ConfigLoader } from '../utils/config-loader.ts';
14
14
  import { container } from '../utils/container.ts';
15
15
  import { extractJson } from '../utils/json-parser.ts';
16
16
  import { ConsoleLogger, type Logger } from '../utils/logger.ts';
17
- import type { Redactor } from '../utils/redactor.ts';
17
+ import { Redactor } from '../utils/redactor.ts';
18
18
  import { formatSchemaErrors, validateJsonSchema } from '../utils/schema-validator.ts';
19
19
  import { WorkflowRegistry } from '../utils/workflow-registry.ts';
20
20
  import type { EventHandler, StepPhase, WorkflowEvent } from './events.ts';
@@ -71,7 +71,7 @@ class RedactingLogger implements Logger {
71
71
  }
72
72
  }
73
73
 
74
- class StepExecutionError extends Error {
74
+ export class StepExecutionError extends Error {
75
75
  constructor(public readonly result: StepResult) {
76
76
  super(result.error || 'Step failed');
77
77
  this.name = 'StepExecutionError';
@@ -160,9 +160,10 @@ export class WorkflowRunner {
160
160
  private hasWarnedMemory = false;
161
161
  private static readonly MEMORY_WARNING_THRESHOLD = 1000;
162
162
  private static readonly MAX_RECURSION_DEPTH = 10;
163
- private static readonly REDACTED_PLACEHOLDER = '***REDACTED***';
163
+ private static readonly REDACTED_PLACEHOLDER = Redactor.REDACTED_PLACEHOLDER;
164
164
  private depth = 0;
165
165
  private lastFailedStep?: { id: string; error: string };
166
+ private ownsDb = false;
166
167
  private abortController = new AbortController();
167
168
  private resourcePool!: ResourcePoolManager;
168
169
  private restored = false;
@@ -217,22 +218,38 @@ export class WorkflowRunner {
217
218
  }
218
219
 
219
220
  // Use injected instances or resolve from container or create new from paths
220
- this.db =
221
- options.db ||
222
- (options.dbPath
223
- ? new WorkflowDb(options.dbPath)
224
- : container.resolveOptional<WorkflowDb>('db')) ||
225
- new WorkflowDb(options.dbPath);
226
-
227
- this.memoryDb =
228
- options.memoryDb ||
229
- (options.memoryDbPath
230
- ? new MemoryDb(options.memoryDbPath)
231
- : container.resolveOptional<MemoryDb>('memoryDb')) ||
232
- new MemoryDb(options.memoryDbPath);
221
+ if (options.db) {
222
+ this.db = options.db;
223
+ this.ownsDb = false;
224
+ } else {
225
+ const fromContainer = container.resolveOptional<WorkflowDb>('db');
226
+ if (fromContainer && !options.dbPath) {
227
+ this.db = fromContainer;
228
+ this.ownsDb = false;
229
+ } else {
230
+ this.db = new WorkflowDb(options.dbPath);
231
+ this.ownsDb = true;
232
+ }
233
+ }
233
234
 
234
235
  this.secretManager = new SecretManager(options.secrets || {});
235
236
  this.initLogger(options);
237
+
238
+ // Initialize MemoryDB with reference counting logic
239
+ if (options.memoryDb) {
240
+ this.memoryDb = options.memoryDb;
241
+ this.memoryDb.retain();
242
+ } else if (options.memoryDbPath) {
243
+ this.memoryDb = MemoryDb.acquire(options.memoryDbPath);
244
+ } else {
245
+ const fromContainer = container.resolveOptional<MemoryDb>('memoryDb');
246
+ if (fromContainer) {
247
+ this.memoryDb = fromContainer;
248
+ this.memoryDb.retain();
249
+ } else {
250
+ this.memoryDb = MemoryDb.acquire(options.memoryDbPath);
251
+ }
252
+ }
236
253
  this.initRun(options);
237
254
 
238
255
  this.validator = new WorkflowValidator(this.workflow, this.inputs);
@@ -439,6 +456,7 @@ export class WorkflowRunner {
439
456
  redactForStorage: this.redactForStorage.bind(this),
440
457
  emitEvent: this.emitEvent.bind(this),
441
458
  workflowName: this.workflow.name,
459
+ depth: this.depth,
442
460
  });
443
461
 
444
462
  if (result.status === StepStatus.SUCCESS) {
@@ -466,13 +484,27 @@ export class WorkflowRunner {
466
484
  // 2. Recursive rollback for sub-workflows
467
485
  // Try to find if this step was a workflow step with a subRunId
468
486
  const stepExec = await this.db.getMainStep(this.runId, compRecord.step_id);
469
- const stepOutput = stepExec?.output;
470
- if (stepOutput) {
487
+
488
+ let subRunId: string | undefined;
489
+
490
+ // Check metadata first (reliable storage even if step crashed)
491
+ if (stepExec?.metadata) {
471
492
  try {
472
- const output = JSON.parse(stepOutput);
473
- const subRunId = output?.__subRunId;
474
- if (subRunId) {
475
- await this.cascadeRollback(subRunId, errorReason);
493
+ const metadata = JSON.parse(stepExec.metadata);
494
+ if (metadata.__subRunId) {
495
+ subRunId = metadata.__subRunId;
496
+ }
497
+ } catch (e) {
498
+ // Ignore metadata parsing check, fall back to output
499
+ }
500
+ }
501
+
502
+ // Fallback to output (legacy/completed steps)
503
+ if (!subRunId && stepExec?.output) {
504
+ try {
505
+ const output = JSON.parse(stepExec.output);
506
+ if (output?.__subRunId) {
507
+ subRunId = output.__subRunId;
476
508
  }
477
509
  } catch (e) {
478
510
  this.logger.warn(
@@ -480,6 +512,10 @@ export class WorkflowRunner {
480
512
  );
481
513
  }
482
514
  }
515
+
516
+ if (subRunId) {
517
+ await this.cascadeRollback(subRunId, errorReason);
518
+ }
483
519
  }
484
520
 
485
521
  this.logger.log(' Rollback completed.\n');
@@ -734,8 +770,10 @@ export class WorkflowRunner {
734
770
  scopedKey: string;
735
771
  ttlSeconds?: number;
736
772
  claimed: boolean;
737
- }
773
+ },
774
+ options?: { skipStatusUpdates?: boolean }
738
775
  ): Promise<StepContext> {
776
+ const skipStatusUpdates = options?.skipStatusUpdates === true;
739
777
  // Check idempotency key for dedup (scoped per run by default)
740
778
  const dedupEnabled = this.options.dedup !== false;
741
779
  let idempotencyKey: string | undefined = idempotencyContext?.rawKey;
@@ -763,7 +801,9 @@ export class WorkflowRunner {
763
801
  if (claim.status === 'hit') {
764
802
  this.logger.log(` ⟳ Step ${step.id} skipped (idempotency hit: ${idempotencyKey})`);
765
803
  const output = claim.output;
766
- await this.db.completeStep(stepExecId, 'success', output, claim.error || undefined);
804
+ if (!skipStatusUpdates) {
805
+ await this.db.completeStep(stepExecId, 'success', output, claim.error || undefined);
806
+ }
767
807
  return {
768
808
  output,
769
809
  outputs:
@@ -776,12 +816,14 @@ export class WorkflowRunner {
776
816
  }
777
817
  if (claim.status === 'in-flight') {
778
818
  const errorMsg = `Idempotency key already in-flight: ${idempotencyKey}`;
779
- await this.db.completeStep(
780
- stepExecId,
781
- StepStatus.FAILED,
782
- null,
783
- this.secretManager.redactAtRest ? this.secretManager.redact(errorMsg) : errorMsg
784
- );
819
+ if (!skipStatusUpdates) {
820
+ await this.db.completeStep(
821
+ stepExecId,
822
+ StepStatus.FAILED,
823
+ null,
824
+ this.secretManager.redactAtRest ? this.secretManager.redact(errorMsg) : errorMsg
825
+ );
826
+ }
785
827
  return {
786
828
  output: null,
787
829
  outputs: {},
@@ -800,8 +842,24 @@ export class WorkflowRunner {
800
842
  const cached = await this.db.getStepCache(cacheKey);
801
843
  if (cached) {
802
844
  this.logger.log(` ⚡ Step ${step.id} skipped (global cache hit)`);
845
+
846
+ // IMPORTANT: If we claimed an idempotency key, we MUST mark it as success
847
+ // otherwise it stays "in-flight" until TTL expires.
848
+ if (idempotencyClaimed && scopedIdempotencyKey && !skipStatusUpdates) {
849
+ await this.recordIdempotencyResult(
850
+ scopedIdempotencyKey,
851
+ step.id,
852
+ StepStatus.SUCCESS,
853
+ cached,
854
+ undefined,
855
+ idempotencyTtlSeconds
856
+ );
857
+ }
858
+
803
859
  const output = JSON.parse(cached.output);
804
- await this.db.completeStep(stepExecId, StepStatus.SUCCESS, output);
860
+ if (!skipStatusUpdates) {
861
+ await this.db.completeStep(stepExecId, StepStatus.SUCCESS, output);
862
+ }
805
863
  return {
806
864
  output,
807
865
  outputs:
@@ -842,7 +900,7 @@ export class WorkflowRunner {
842
900
  const isRecursion =
843
901
  (context.reflexionAttempts as number) > 0 || (context.autoHealAttempts as number) > 0;
844
902
 
845
- if (!isRecursion) {
903
+ if (!isRecursion && !skipStatusUpdates) {
846
904
  await this.db.startStep(stepExecId);
847
905
  }
848
906
 
@@ -859,12 +917,14 @@ export class WorkflowRunner {
859
917
  idempotencyTtlSeconds
860
918
  );
861
919
  }
862
- await this.db.completeStep(
863
- stepExecId,
864
- StepStatus.SUSPENDED,
865
- null,
866
- this.secretManager.redactAtRest ? this.secretManager.redact(message) : message
867
- );
920
+ if (!skipStatusUpdates) {
921
+ await this.db.completeStep(
922
+ stepExecId,
923
+ StepStatus.SUSPENDED,
924
+ null,
925
+ this.secretManager.redactAtRest ? this.secretManager.redact(message) : message
926
+ );
927
+ }
868
928
  return {
869
929
  output: null,
870
930
  outputs: {},
@@ -890,7 +950,9 @@ export class WorkflowRunner {
890
950
 
891
951
  if (action.type === 'skip') {
892
952
  this.logger.log(` ⏭️ Skipping step ${stepToExecute.id} at breakpoint`);
893
- await this.db.completeStep(stepExecId, StepStatus.SKIPPED, null, undefined, undefined);
953
+ if (!skipStatusUpdates) {
954
+ await this.db.completeStep(stepExecId, StepStatus.SKIPPED, null, undefined, undefined);
955
+ }
894
956
  return {
895
957
  output: null,
896
958
  outputs: {},
@@ -1082,7 +1144,9 @@ export class WorkflowRunner {
1082
1144
 
1083
1145
  const result = await withRetry(operationWithTimeout, step.retry, async (attempt, error) => {
1084
1146
  this.logger.log(` ↻ Retry ${attempt}/${step.retry?.count} for step ${step.id}`);
1085
- await this.db.incrementRetry(stepExecId);
1147
+ if (!skipStatusUpdates) {
1148
+ await this.db.incrementRetry(stepExecId);
1149
+ }
1086
1150
  });
1087
1151
 
1088
1152
  const persistedOutput = this.secretManager.redactForStorage(result.output);
@@ -1100,7 +1164,7 @@ export class WorkflowRunner {
1100
1164
  await this.db.createTimer(timerId, this.runId, step.id, 'human');
1101
1165
  }
1102
1166
  }
1103
- if (dedupEnabled && idempotencyClaimed) {
1167
+ if (dedupEnabled && idempotencyClaimed && !skipStatusUpdates) {
1104
1168
  await this.recordIdempotencyResult(
1105
1169
  scopedIdempotencyKey,
1106
1170
  step.id,
@@ -1110,15 +1174,17 @@ export class WorkflowRunner {
1110
1174
  idempotencyTtlSeconds
1111
1175
  );
1112
1176
  }
1113
- await this.db.completeStep(
1114
- stepExecId,
1115
- StepStatus.SUSPENDED,
1116
- persistedOutput,
1117
- this.secretManager.redactAtRest
1118
- ? this.secretManager.redact('Waiting for interaction')
1119
- : 'Waiting for interaction',
1120
- result.usage
1121
- );
1177
+ if (!skipStatusUpdates) {
1178
+ await this.db.completeStep(
1179
+ stepExecId,
1180
+ StepStatus.SUSPENDED,
1181
+ persistedOutput,
1182
+ this.secretManager.redactAtRest
1183
+ ? this.secretManager.redact('Waiting for interaction')
1184
+ : 'Waiting for interaction',
1185
+ result.usage
1186
+ );
1187
+ }
1122
1188
  return result;
1123
1189
  }
1124
1190
 
@@ -1131,7 +1197,7 @@ export class WorkflowRunner {
1131
1197
  const timerId = randomUUID();
1132
1198
  await this.db.createTimer(timerId, this.runId, step.id, 'sleep', wakeAt);
1133
1199
  }
1134
- if (dedupEnabled && idempotencyClaimed) {
1200
+ if (dedupEnabled && idempotencyClaimed && !skipStatusUpdates) {
1135
1201
  await this.recordIdempotencyResult(
1136
1202
  scopedIdempotencyKey,
1137
1203
  step.id,
@@ -1141,24 +1207,28 @@ export class WorkflowRunner {
1141
1207
  idempotencyTtlSeconds
1142
1208
  );
1143
1209
  }
1210
+ if (!skipStatusUpdates) {
1211
+ await this.db.completeStep(
1212
+ stepExecId,
1213
+ StepStatus.WAITING,
1214
+ persistedOutput,
1215
+ this.secretManager.redactAtRest ? this.secretManager.redact(waitError) : waitError,
1216
+ result.usage
1217
+ );
1218
+ }
1219
+ result.error = waitError;
1220
+ return result;
1221
+ }
1222
+
1223
+ if (!skipStatusUpdates) {
1144
1224
  await this.db.completeStep(
1145
1225
  stepExecId,
1146
- StepStatus.WAITING,
1226
+ result.status,
1147
1227
  persistedOutput,
1148
- this.secretManager.redactAtRest ? this.secretManager.redact(waitError) : waitError,
1228
+ persistedError,
1149
1229
  result.usage
1150
1230
  );
1151
- result.error = waitError;
1152
- return result;
1153
1231
  }
1154
-
1155
- await this.db.completeStep(
1156
- stepExecId,
1157
- result.status,
1158
- persistedOutput,
1159
- persistedError,
1160
- result.usage
1161
- );
1162
1232
  if (result.status === StepStatus.SUCCESS) {
1163
1233
  const existingTimer = await this.db.getTimerByStep(this.runId, step.id);
1164
1234
  if (existingTimer) {
@@ -1217,7 +1287,7 @@ export class WorkflowRunner {
1217
1287
  outputs = {};
1218
1288
  }
1219
1289
 
1220
- if (dedupEnabled && idempotencyClaimed) {
1290
+ if (dedupEnabled && idempotencyClaimed && !skipStatusUpdates) {
1221
1291
  await this.recordIdempotencyResult(
1222
1292
  scopedIdempotencyKey,
1223
1293
  step.id,
@@ -1237,7 +1307,7 @@ export class WorkflowRunner {
1237
1307
  };
1238
1308
 
1239
1309
  // Store in global cache if enabled
1240
- if (cacheKey && result.status === StepStatus.SUCCESS) {
1310
+ if (cacheKey && result.status === StepStatus.SUCCESS && !skipStatusUpdates) {
1241
1311
  const ttl = step.memoizeTtlSeconds;
1242
1312
  await this.db.storeStepCache(cacheKey, this.workflow.name, step.id, persistedOutput, ttl);
1243
1313
  }
@@ -1283,7 +1353,8 @@ export class WorkflowRunner {
1283
1353
  newStep as Step,
1284
1354
  nextContext,
1285
1355
  stepExecId,
1286
- idempotencyContextForRetry
1356
+ idempotencyContextForRetry,
1357
+ options // Pass options recursively (incl. skipStatusUpdates)
1287
1358
  );
1288
1359
  } catch (healError) {
1289
1360
  this.logger.error(
@@ -1332,7 +1403,8 @@ export class WorkflowRunner {
1332
1403
  newStep as Step,
1333
1404
  nextContext,
1334
1405
  stepExecId,
1335
- idempotencyContextForRetry
1406
+ idempotencyContextForRetry,
1407
+ options
1336
1408
  );
1337
1409
  } catch (healError) {
1338
1410
  this.logger.error(
@@ -1358,7 +1430,8 @@ export class WorkflowRunner {
1358
1430
  stepToRun,
1359
1431
  context,
1360
1432
  stepExecId,
1361
- idempotencyContextForRetry
1433
+ idempotencyContextForRetry,
1434
+ options
1362
1435
  );
1363
1436
  }
1364
1437
  if (action.type === 'skip') {
@@ -1947,7 +2020,8 @@ Revise the output to address the feedback. Return only the corrected output.`;
1947
2020
  private async executeSubWorkflow(
1948
2021
  step: WorkflowStep,
1949
2022
  context: ExpressionContext,
1950
- abortSignal?: AbortSignal
2023
+ abortSignal?: AbortSignal,
2024
+ stepExecutionId?: string
1951
2025
  ): Promise<StepResult> {
1952
2026
  const factory: RunnerFactory = {
1953
2027
  create: (workflow, options) => new WorkflowRunner(workflow, options),
@@ -1960,8 +2034,10 @@ Revise the output to address the feedback. Return only the corrected output.`;
1960
2034
  parentLogger: this.logger,
1961
2035
  parentMcpManager: this.mcpManager,
1962
2036
  parentDepth: this.depth,
1963
- parentOptions: this.options,
2037
+ parentOptions: { ...this.options, executeStep: undefined }, // Prevent recursion of injected executeStep
1964
2038
  abortSignal,
2039
+ stepExecutionId,
2040
+ parentDb: this.db,
1965
2041
  });
1966
2042
  }
1967
2043
 
@@ -1980,6 +2056,9 @@ Revise the output to address the feedback. Return only the corrected output.`;
1980
2056
  // Track step.end events for summary generation
1981
2057
  if (redacted.type === 'step.end') {
1982
2058
  this.stepEvents.push(redacted);
2059
+ if (this.stepEvents.length > 2000) {
2060
+ this.stepEvents.shift();
2061
+ }
1983
2062
  }
1984
2063
 
1985
2064
  if (redacted.type === 'llm.thought') {
@@ -2076,12 +2155,16 @@ Revise the output to address the feedback. Return only the corrected output.`;
2076
2155
 
2077
2156
  this.logger.log(`\n🏛️ ${isResume ? 'Resuming' : 'Running'} workflow: ${this.workflow.name}`);
2078
2157
  this.logger.log(`Run ID: ${this.runId}`);
2079
- this.logger.log(
2080
- '\n⚠️ Security Warning: Only run workflows from trusted sources.\n' +
2081
- ' Workflows can execute arbitrary shell commands and access your environment.\n'
2082
- );
2083
2158
 
2084
- this.secretManager.redactAtRest = ConfigLoader.load().storage?.redact_secrets_at_rest ?? true;
2159
+ const config = ConfigLoader.load();
2160
+ if (!config.logging?.suppress_security_warning) {
2161
+ this.logger.log(
2162
+ '\n⚠️ Security Warning: Only run workflows from trusted sources.\n' +
2163
+ ' Workflows can execute arbitrary shell commands and access your environment.\n'
2164
+ );
2165
+ }
2166
+
2167
+ this.secretManager.redactAtRest = config.storage?.redact_secrets_at_rest ?? true;
2085
2168
 
2086
2169
  // Apply defaults and validate inputs
2087
2170
  const validated = this.validator.applyDefaultsAndValidate();
@@ -2193,7 +2276,9 @@ Revise the output to address the feedback. Return only the corrected output.`;
2193
2276
  // 1. Find runnable steps from scheduler
2194
2277
  const runnableSteps = this.scheduler
2195
2278
  .getRunnableSteps(runningPromises.size, globalConcurrencyLimit)
2196
- .filter((step) => this.resourcePool.hasCapacity(step.pool || step.type));
2279
+ .filter(
2280
+ (step) => step.foreach || this.resourcePool.hasCapacity(step.pool || step.type)
2281
+ );
2197
2282
 
2198
2283
  for (const step of runnableSteps) {
2199
2284
  // Don't schedule new steps if canceled
@@ -2215,7 +2300,13 @@ Revise the output to address the feedback. Return only the corrected output.`;
2215
2300
  this.logger.debug?.(
2216
2301
  `[${stepIndex}/${totalSteps}] ⏳ Waiting for pool: ${poolName}`
2217
2302
  );
2218
- release = await this.resourcePool.acquire(poolName, { signal: this.abortSignal });
2303
+ // For foreach steps, we don't acquire the resource in the parent step
2304
+ // The ForeachExecutor will handle acquiring it for each iteration
2305
+ if (!step.foreach) {
2306
+ release = await this.resourcePool.acquire(poolName, {
2307
+ signal: this.abortSignal,
2308
+ });
2309
+ }
2219
2310
 
2220
2311
  this.logger.log(
2221
2312
  `[${stepIndex}/${totalSteps}] ▶ Executing step: ${step.id} (${step.type})`
@@ -2389,7 +2480,9 @@ Revise the output to address the feedback. Return only the corrected output.`;
2389
2480
  if (!this.options.mcpManager) {
2390
2481
  await this.mcpManager.stopAll();
2391
2482
  }
2392
- this.db.close();
2483
+ if (this.ownsDb) {
2484
+ this.db.close();
2485
+ }
2393
2486
  }
2394
2487
  }
2395
2488
 
@@ -2597,7 +2690,7 @@ Revise the output to address the feedback. Return only the corrected output.`;
2597
2690
  outputs[key] = ExpressionEvaluator.evaluate(expression, context);
2598
2691
  } catch (error) {
2599
2692
  this.logger.warn(
2600
- `Warning: Failed to evaluate output "${key}": ${error instanceof Error ? error.message : String(error)}`
2693
+ `⚠️ Failed to evaluate output '${key}': ${error instanceof Error ? error.message : String(error)}. Setting to null.`
2601
2694
  );
2602
2695
  outputs[key] = null;
2603
2696
  }