reqon-dsl 0.3.0 → 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.
Files changed (122) hide show
  1. package/README.md +23 -3
  2. package/dist/ast/nodes.d.ts +8 -0
  3. package/dist/auth/circuit-breaker.d.ts +11 -0
  4. package/dist/auth/circuit-breaker.js +83 -12
  5. package/dist/auth/credentials.d.ts +6 -1
  6. package/dist/auth/credentials.js +12 -4
  7. package/dist/auth/oauth2-provider.js +13 -3
  8. package/dist/auth/rate-limiter.d.ts +8 -1
  9. package/dist/auth/rate-limiter.js +30 -10
  10. package/dist/auth/token-store.js +8 -1
  11. package/dist/cli.d.ts +11 -1
  12. package/dist/cli.js +65 -6
  13. package/dist/config/constants.d.ts +15 -4
  14. package/dist/config/constants.js +15 -4
  15. package/dist/control/server.d.ts +17 -0
  16. package/dist/control/server.js +82 -5
  17. package/dist/control/types.d.ts +6 -0
  18. package/dist/debug/cli-debugger.js +8 -3
  19. package/dist/execution/store.js +2 -2
  20. package/dist/execution-log/events.d.ts +125 -0
  21. package/dist/execution-log/events.js +17 -0
  22. package/dist/execution-log/fold.d.ts +38 -0
  23. package/dist/execution-log/fold.js +54 -0
  24. package/dist/execution-log/index.d.ts +18 -0
  25. package/dist/execution-log/index.js +6 -0
  26. package/dist/execution-log/postgres-store.d.ts +36 -0
  27. package/dist/execution-log/postgres-store.js +108 -0
  28. package/dist/execution-log/resume.d.ts +11 -0
  29. package/dist/execution-log/resume.js +5 -0
  30. package/dist/execution-log/sqlite-store.d.ts +16 -0
  31. package/dist/execution-log/sqlite-store.js +101 -0
  32. package/dist/execution-log/store.d.ts +72 -0
  33. package/dist/execution-log/store.js +182 -0
  34. package/dist/index.d.ts +4 -3
  35. package/dist/index.js +4 -3
  36. package/dist/interpreter/context.d.ts +15 -0
  37. package/dist/interpreter/context.js +3 -0
  38. package/dist/interpreter/evaluator.js +38 -8
  39. package/dist/interpreter/executor.d.ts +63 -1
  40. package/dist/interpreter/executor.js +406 -30
  41. package/dist/interpreter/fetch-handler.d.ts +39 -1
  42. package/dist/interpreter/fetch-handler.js +84 -15
  43. package/dist/interpreter/http.d.ts +31 -2
  44. package/dist/interpreter/http.js +187 -26
  45. package/dist/interpreter/index.d.ts +3 -3
  46. package/dist/interpreter/index.js +3 -3
  47. package/dist/interpreter/pagination.d.ts +1 -1
  48. package/dist/interpreter/pagination.js +7 -1
  49. package/dist/interpreter/step-handlers/for-handler.d.ts +3 -0
  50. package/dist/interpreter/step-handlers/for-handler.js +18 -3
  51. package/dist/interpreter/step-handlers/match-handler.js +5 -2
  52. package/dist/interpreter/step-handlers/store-handler.d.ts +7 -1
  53. package/dist/interpreter/step-handlers/store-handler.js +25 -16
  54. package/dist/interpreter/step-handlers/validate-handler.js +4 -1
  55. package/dist/interpreter/step-handlers/webhook-handler.d.ts +1 -0
  56. package/dist/interpreter/step-handlers/webhook-handler.js +13 -3
  57. package/dist/interpreter/store-manager.d.ts +1 -1
  58. package/dist/interpreter/store-manager.js +5 -1
  59. package/dist/loader/index.js +5 -8
  60. package/dist/mcp/sandbox.d.ts +41 -0
  61. package/dist/mcp/sandbox.js +76 -0
  62. package/dist/mcp/server.js +62 -9
  63. package/dist/oas/loader.d.ts +13 -1
  64. package/dist/oas/loader.js +25 -3
  65. package/dist/oas/mock-generator.js +13 -4
  66. package/dist/oas/validator.js +45 -5
  67. package/dist/observability/events.d.ts +6 -2
  68. package/dist/observability/events.js +0 -5
  69. package/dist/observability/logger.js +17 -10
  70. package/dist/observability/otel.d.ts +8 -0
  71. package/dist/observability/otel.js +45 -10
  72. package/dist/parser/action-parser.js +2 -2
  73. package/dist/parser/base.d.ts +7 -0
  74. package/dist/parser/base.js +11 -0
  75. package/dist/parser/expressions.d.ts +1 -0
  76. package/dist/parser/expressions.js +17 -4
  77. package/dist/parser/fetch-parser.js +13 -2
  78. package/dist/pause/index.d.ts +1 -0
  79. package/dist/pause/index.js +1 -0
  80. package/dist/pause/log-store.d.ts +33 -0
  81. package/dist/pause/log-store.js +98 -0
  82. package/dist/pause/manager.d.ts +12 -0
  83. package/dist/pause/manager.js +77 -28
  84. package/dist/pause/store.js +5 -3
  85. package/dist/scheduler/cron-parser.d.ts +10 -3
  86. package/dist/scheduler/cron-parser.js +227 -48
  87. package/dist/scheduler/scheduler.js +56 -22
  88. package/dist/stores/factory.d.ts +6 -0
  89. package/dist/stores/factory.js +11 -1
  90. package/dist/stores/file.js +9 -17
  91. package/dist/stores/memory.js +3 -12
  92. package/dist/stores/postgrest.d.ts +28 -0
  93. package/dist/stores/postgrest.js +84 -37
  94. package/dist/sync/index.d.ts +3 -2
  95. package/dist/sync/index.js +2 -1
  96. package/dist/sync/log-store.d.ts +30 -0
  97. package/dist/sync/log-store.js +45 -0
  98. package/dist/sync/store.js +1 -1
  99. package/dist/trace/index.d.ts +2 -0
  100. package/dist/trace/index.js +1 -0
  101. package/dist/trace/log-view.d.ts +57 -0
  102. package/dist/trace/log-view.js +76 -0
  103. package/dist/trace/recorder.d.ts +5 -1
  104. package/dist/trace/recorder.js +19 -6
  105. package/dist/trace/store.d.ts +6 -0
  106. package/dist/trace/store.js +47 -22
  107. package/dist/utils/deep-merge.d.ts +10 -0
  108. package/dist/utils/deep-merge.js +23 -0
  109. package/dist/utils/file.d.ts +13 -4
  110. package/dist/utils/file.js +70 -12
  111. package/dist/utils/index.d.ts +1 -1
  112. package/dist/utils/index.js +1 -1
  113. package/dist/utils/long-timeout.d.ts +19 -0
  114. package/dist/utils/long-timeout.js +33 -0
  115. package/dist/utils/path.d.ts +22 -1
  116. package/dist/utils/path.js +46 -1
  117. package/dist/utils/redact.d.ts +22 -0
  118. package/dist/utils/redact.js +42 -0
  119. package/dist/webhook/server.d.ts +9 -0
  120. package/dist/webhook/server.js +115 -30
  121. package/dist/webhook/types.d.ts +9 -1
  122. package/package.json +22 -4
@@ -16,24 +16,33 @@
16
16
  */
17
17
  import { isParallelStage } from '../ast/nodes.js';
18
18
  import { createContext, childContext, setVariable } from './context.js';
19
+ import { createHash } from 'node:crypto';
19
20
  import { evaluate } from './evaluator.js';
20
21
  import { SourceManager } from './source-manager.js';
21
22
  import { StoreManager } from './store-manager.js';
22
23
  import { AdaptiveRateLimiter } from '../auth/rate-limiter.js';
23
24
  import { CircuitBreaker } from '../auth/circuit-breaker.js';
24
25
  import { createExecutionState, findResumePoint, FileExecutionStore, } from '../execution/index.js';
25
- import { FileSyncStore } from '../sync/index.js';
26
+ import { FileSyncStore, LogBackedSyncStore } from '../sync/index.js';
26
27
  import { FetchHandler } from './fetch-handler.js';
27
28
  import { ForHandler, MapHandler, ValidateHandler, StoreHandler, MatchHandler, ApplyHandler, WebhookHandler, PauseHandler, SkipSignal, AbortError, RetrySignal, JumpSignal, QueueSignal, } from './step-handlers/index.js';
28
29
  import { createStructuredLogger } from '../observability/index.js';
29
30
  import { PauseSignal } from './signals.js';
30
31
  import { FileTraceStore, createTraceRecorder, } from '../trace/index.js';
31
- import { FilePauseStore, createPauseManager, } from '../pause/index.js';
32
+ import { FilePauseStore, LogBackedPauseStore, createPauseManager, } from '../pause/index.js';
33
+ import { sleep } from '../utils/async.js';
34
+ import { redactNamedValue } from '../utils/redact.js';
35
+ import { effectId, loadState } from '../execution-log/index.js';
36
+ import { generateExecutionId } from '../execution/index.js';
37
+ /** Max parallel-stage actions running concurrently (bounds fan-out). */
38
+ const MAX_PARALLEL_ACTIONS = 8;
32
39
  export class MissionExecutor {
33
40
  config;
34
41
  ctx;
35
42
  errors = [];
36
43
  actionsRun = [];
44
+ /** Monotonic key generator for queued values lacking an id. */
45
+ queueCounter = 0;
37
46
  transforms = new Map();
38
47
  rateLimiter;
39
48
  circuitBreaker;
@@ -45,7 +54,15 @@ export class MissionExecutor {
45
54
  missionName;
46
55
  eventEmitter;
47
56
  logger;
48
- stepIndex = 0;
57
+ executionLog;
58
+ /** Stable id used for the execution event log (independent of persistState). */
59
+ logExecutionId;
60
+ /** Effect ids already applied (from the log) — replay skips these. */
61
+ appliedEffects = new Set();
62
+ /** Backfill page progress per step id (from the log) — resumes pagination. */
63
+ pageProgress = new Map();
64
+ /** The pause being resumed on this run — its step replays past, not into, a pause. */
65
+ resumingPause;
49
66
  debugController;
50
67
  traceRecorder;
51
68
  traceStore;
@@ -121,6 +138,7 @@ export class MissionExecutor {
121
138
  }
122
139
  // Initialize event emitter if provided
123
140
  this.eventEmitter = config.eventEmitter;
141
+ this.executionLog = config.executionLog;
124
142
  // Initialize logger if verbose or provided
125
143
  if (config.logger) {
126
144
  this.logger = config.logger;
@@ -145,9 +163,15 @@ export class MissionExecutor {
145
163
  // Initialize trace store
146
164
  this.traceStore =
147
165
  config.traceStore ?? new FileTraceStore(`${config.dataDir ?? '.reqon-data'}/traces`);
148
- // Initialize pause store and manager
166
+ // Initialize pause store and manager. In durable mode the execution log is
167
+ // the single source of truth — pause state (deadline, triggers, checkpoint)
168
+ // is recorded as pause events and folded back, rather than kept in a
169
+ // separate pause file.
149
170
  this.pauseStore =
150
- config.pauseStore ?? new FilePauseStore(`${config.dataDir ?? '.reqon-data'}/pauses`);
171
+ config.pauseStore ??
172
+ (config.executionLog
173
+ ? new LogBackedPauseStore(config.executionLog)
174
+ : new FilePauseStore(`${config.dataDir ?? '.reqon-data'}/pauses`));
151
175
  this.pauseManager =
152
176
  config.pauseManager ??
153
177
  createPauseManager({
@@ -171,6 +195,42 @@ export class MissionExecutor {
171
195
  }
172
196
  // Initialize or resume execution state
173
197
  await this.initializeExecutionState(mission);
198
+ // Establish a stable id for the execution log. On resume we reuse the prior
199
+ // id (so replay reads the same log); otherwise a fresh id.
200
+ this.logExecutionId =
201
+ this.executionState?.id ?? this.config.resumeFrom ?? generateExecutionId();
202
+ // Load already-applied effects from the log so replay skips them, and note
203
+ // a pending pause so we can record its resumption below.
204
+ let pendingPauseId;
205
+ let alreadyResumedPauseId;
206
+ if (this.executionLog) {
207
+ const prior = await loadState(this.executionLog, this.logExecutionId);
208
+ this.appliedEffects = new Set(prior.appliedEffects);
209
+ this.pageProgress = prior.pageProgress;
210
+ pendingPauseId = prior.pendingPauseId;
211
+ // A resume trigger may have recorded `pause.resumed` already; if the run
212
+ // didn't finish, we still need to replay past that pause.
213
+ alreadyResumedPauseId = prior.pendingPauseId ? undefined : prior.resumedPauseId;
214
+ }
215
+ await this.logEvent({ type: 'mission.started', mission: mission.name });
216
+ // If the prior log ended paused, this run is resuming that pause. Record it
217
+ // so the log's folded status leaves 'paused' before replay continues, and
218
+ // load the pause's checkpoint so the replayed pause step resumes past it
219
+ // (restoring captured state) rather than pausing again.
220
+ if (pendingPauseId) {
221
+ await this.logEvent({
222
+ type: 'pause.resumed',
223
+ pauseId: pendingPauseId,
224
+ resumedBy: this.config.resumeFrom ? 'resume' : 'replay',
225
+ });
226
+ this.resumingPause = await this.loadResumingPause(pendingPauseId);
227
+ }
228
+ else if (alreadyResumedPauseId) {
229
+ // The pause was already marked resumed out of band (by a webhook/timeout
230
+ // trigger) but the run didn't continue past it. Replay past it now without
231
+ // recording a second `pause.resumed` — otherwise the step re-pauses forever.
232
+ this.resumingPause = await this.loadResumingPause(alreadyResumedPauseId);
233
+ }
174
234
  // Initialize trace recorder if tracing is enabled
175
235
  if (mission.trace && this.traceStore && this.executionState) {
176
236
  this.traceRecorder = createTraceRecorder({
@@ -192,6 +252,7 @@ export class MissionExecutor {
192
252
  this.executionState.duration = Date.now() - startTime;
193
253
  await this.saveExecutionState();
194
254
  }
255
+ await this.logEvent({ type: 'mission.completed' });
195
256
  }
196
257
  catch (error) {
197
258
  // PauseSignal is not an error - execution was intentionally paused
@@ -199,7 +260,15 @@ export class MissionExecutor {
199
260
  this.log('Execution paused');
200
261
  this.currentPauseId = error.pauseId;
201
262
  // State is already set to 'paused' in checkPause() or pause handler
202
- // Don't record as error, just let execution end
263
+ // Don't record as error, just let execution end.
264
+ //
265
+ // A LogBackedPauseStore already appended pause.created (with the full
266
+ // pause payload) when the pause manager saved it, so only emit the bare
267
+ // event here when the pause store is *not* the log itself — otherwise
268
+ // we'd record the pause twice.
269
+ if (error.pauseId && !(this.pauseStore instanceof LogBackedPauseStore)) {
270
+ await this.logEvent({ type: 'pause.created', pauseId: error.pauseId });
271
+ }
203
272
  }
204
273
  else {
205
274
  this.errors.push({
@@ -215,6 +284,7 @@ export class MissionExecutor {
215
284
  this.executionState.duration = Date.now() - startTime;
216
285
  await this.saveExecutionState();
217
286
  }
287
+ await this.logEvent({ type: 'mission.failed', error: error.message });
218
288
  }
219
289
  }
220
290
  const duration = Date.now() - startTime;
@@ -273,7 +343,7 @@ export class MissionExecutor {
273
343
  actionsRun: this.actionsRun,
274
344
  errors: this.errors,
275
345
  stores: this.ctx.stores,
276
- executionId: this.executionState?.id,
346
+ executionId: this.executionState?.id ?? this.logExecutionId,
277
347
  state: this.executionState,
278
348
  traceId: this.traceRecorder ? this.executionState?.id : undefined,
279
349
  pauseId: this.currentPauseId,
@@ -360,10 +430,14 @@ export class MissionExecutor {
360
430
  async executeMission(mission) {
361
431
  this.log(`Executing mission: ${mission.name}`);
362
432
  this.missionName = mission.name;
363
- // Initialize sync store
433
+ // Initialize sync store. In durable mode the execution log is the single
434
+ // source of truth, so sync checkpoints are read back as a view over the log
435
+ // rather than from a separate sync file.
364
436
  this.syncStore =
365
437
  this.config.syncStore ??
366
- new FileSyncStore(mission.name, `${this.config.dataDir ?? '.reqon-data'}/sync`);
438
+ (this.executionLog
439
+ ? new LogBackedSyncStore(this.executionLog, mission.name)
440
+ : new FileSyncStore(mission.name, `${this.config.dataDir ?? '.reqon-data'}/sync`));
367
441
  // Initialize sources using SourceManager
368
442
  await this.sourceManager.initializeSources(mission.sources, this.ctx);
369
443
  // Initialize stores using StoreManager
@@ -414,11 +488,27 @@ export class MissionExecutor {
414
488
  // Track current stage index for pause handler
415
489
  this.currentStageIndex = i;
416
490
  // Execute stage (parallel or sequential)
417
- if (isParallelStage(stage)) {
418
- await this.executeParallelStage(i, stage, actions, mission);
491
+ try {
492
+ if (isParallelStage(stage)) {
493
+ await this.executeParallelStage(i, stage, actions, mission);
494
+ }
495
+ else if (stage.action) {
496
+ await this.executeSequentialStage(i, stage.action, actions, mission);
497
+ }
419
498
  }
420
- else if (stage.action) {
421
- await this.executeSequentialStage(i, stage.action, actions, mission);
499
+ catch (error) {
500
+ // A jump directive redirects the pipeline to a named action's stage.
501
+ if (error instanceof JumpSignal) {
502
+ const targetIndex = mission.pipeline.stages.findIndex((s) => !isParallelStage(s) && s.action === error.action);
503
+ if (targetIndex === -1) {
504
+ throw new Error(`Jump target action not found in pipeline: ${error.action}`);
505
+ }
506
+ this.log(`Jump to action '${error.action}' (stage ${targetIndex})`);
507
+ i = targetIndex - 1; // loop's i++ lands on the target stage
508
+ this.updateControlServerState();
509
+ continue;
510
+ }
511
+ throw error;
422
512
  }
423
513
  // Update control server with latest state after each stage
424
514
  this.updateControlServerState();
@@ -478,6 +568,11 @@ export class MissionExecutor {
478
568
  });
479
569
  }
480
570
  catch (error) {
571
+ // A jump directive is flow control, not a stage failure — let it bubble
572
+ // to the mission loop without polluting stage state.
573
+ if (error instanceof JumpSignal) {
574
+ throw error;
575
+ }
481
576
  // Mark stage as failed
482
577
  this.updateStageState(stageIndex, {
483
578
  status: 'failed',
@@ -505,6 +600,39 @@ export class MissionExecutor {
505
600
  throw error; // Re-throw to stop execution
506
601
  }
507
602
  }
603
+ /**
604
+ * Run all settled-style tasks with a bounded number in flight at once,
605
+ * preserving result order. Caps fan-out so a wide `run [...]` can't open an
606
+ * unbounded number of concurrent HTTP/store operations.
607
+ */
608
+ async settleWithLimit(tasks, limit) {
609
+ const results = new Array(tasks.length);
610
+ let next = 0;
611
+ const worker = async () => {
612
+ for (let i = next++; i < tasks.length; i = next++) {
613
+ try {
614
+ results[i] = { status: 'fulfilled', value: await tasks[i]() };
615
+ }
616
+ catch (reason) {
617
+ results[i] = { status: 'rejected', reason };
618
+ }
619
+ }
620
+ };
621
+ const workerCount = Math.min(Math.max(1, limit), tasks.length);
622
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
623
+ return results;
624
+ }
625
+ /**
626
+ * Execute a `run [A, B, ...]` stage.
627
+ *
628
+ * Failure semantics are **complete-then-fail**: every branch runs to
629
+ * completion (bounded by MAX_PARALLEL_ACTIONS in flight), then the stage
630
+ * fails if any branch failed. There is no cancellation of siblings and no
631
+ * rollback — a branch that committed store writes keeps them even if another
632
+ * branch failed. Each branch gets its own action scope (step counter +
633
+ * checkpoints); stores/sources/schemas are shared, so parallel branches that
634
+ * write the same key get last-writer-wins and should target disjoint keys.
635
+ */
508
636
  async executeParallelStage(stageIndex, stage, actions, mission) {
509
637
  const actionNames = stage.actions;
510
638
  const stageName = `[${actionNames.join(', ')}]`;
@@ -539,8 +667,9 @@ export class MissionExecutor {
539
667
  });
540
668
  this.log(`Executing parallel stage: ${stageName}`);
541
669
  try {
542
- // Execute all actions in parallel
543
- const results = await Promise.allSettled(actionDefs.map((action) => this.executeAction(action)));
670
+ // Execute all actions in parallel, bounded to MAX_PARALLEL_ACTIONS in
671
+ // flight. allSettled semantics: every started branch runs to completion.
672
+ const results = await this.settleWithLimit(actionDefs.map((action) => () => this.executeAction(action)), MAX_PARALLEL_ACTIONS);
544
673
  // Check for failures
545
674
  const failures = [];
546
675
  for (let i = 0; i < results.length; i++) {
@@ -606,27 +735,115 @@ export class MissionExecutor {
606
735
  }
607
736
  async executeAction(action) {
608
737
  this.log(`Executing action: ${action.name}`);
609
- // Create a child context for this action with its own response scope
610
- // This allows parallel actions to have independent response values
611
- const actionCtx = childContext(this.ctx);
612
- for (const step of action.steps) {
613
- await this.executeStep(step, action.name, actionCtx);
738
+ // Flow-control directives surface as thrown signals from a step (typically
739
+ // a `match` arm). Handle them at the action boundary: skip stops the rest
740
+ // of the action, queue stashes a value and stops, retry re-runs the whole
741
+ // action with backoff. Jump/Pause propagate to the mission loop.
742
+ const MAX_RETRY_FALLBACK = 3;
743
+ let attempt = 0;
744
+ for (;;) {
745
+ // Create a child context for this action with its own response scope and
746
+ // its own action scope (step counter + deferred checkpoints). Each attempt
747
+ // gets a fresh scope, so a retry's fetch doesn't double-record and so
748
+ // parallel actions never share a counter or checkpoint list.
749
+ const actionCtx = childContext(this.ctx);
750
+ actionCtx.actionScope = { stepIndex: 0, attempt, pendingCheckpoints: [] };
751
+ try {
752
+ for (const step of action.steps) {
753
+ await this.executeStep(step, action.name, actionCtx);
754
+ }
755
+ // Flush checkpoints for fetches that completed without a later store.
756
+ await this.flushPendingCheckpoints(actionCtx);
757
+ return;
758
+ }
759
+ catch (error) {
760
+ if (error instanceof SkipSignal) {
761
+ this.log(`Action ${action.name}: skip — remaining steps skipped`);
762
+ await this.flushPendingCheckpoints(actionCtx);
763
+ return;
764
+ }
765
+ if (error instanceof QueueSignal) {
766
+ await this.handleQueue(error);
767
+ await this.flushPendingCheckpoints(actionCtx);
768
+ return;
769
+ }
770
+ if (error instanceof RetrySignal) {
771
+ const maxAttempts = error.backoff?.maxAttempts ?? MAX_RETRY_FALLBACK;
772
+ attempt++;
773
+ if (attempt >= maxAttempts) {
774
+ throw new Error(`Action ${action.name} exhausted ${maxAttempts} retry attempt(s)`);
775
+ }
776
+ const delay = this.computeRetryDelay(error.backoff, attempt);
777
+ this.log(`Action ${action.name}: retry ${attempt}/${maxAttempts} in ${delay}ms`);
778
+ if (delay > 0)
779
+ await sleep(delay);
780
+ continue;
781
+ }
782
+ // JumpSignal, PauseSignal, and real errors propagate. The action's
783
+ // checkpoints live on actionCtx and are simply discarded (never flushed)
784
+ // since the data was not durably stored.
785
+ throw error;
786
+ }
787
+ }
788
+ }
789
+ /** Compute a retry backoff delay from a RetrySignal's backoff config. */
790
+ computeRetryDelay(backoff, attempt) {
791
+ const initial = backoff?.initialDelay ?? 0;
792
+ switch (backoff?.backoff) {
793
+ case 'exponential':
794
+ return initial * Math.pow(2, attempt - 1);
795
+ case 'linear':
796
+ return initial * attempt;
797
+ default:
798
+ return initial;
799
+ }
800
+ }
801
+ /** Push a queued value to its target store (queue directive). */
802
+ async handleQueue(signal) {
803
+ const target = signal.target;
804
+ if (!target) {
805
+ this.log('Queue directive without target — value discarded');
806
+ return;
614
807
  }
808
+ const store = this.ctx.stores.get(target);
809
+ if (!store) {
810
+ throw new Error(`Queue target store not found: ${target}`);
811
+ }
812
+ const value = signal.value;
813
+ const record = value && typeof value === 'object' && !Array.isArray(value)
814
+ ? value
815
+ : { value };
816
+ const key = typeof record.id === 'string' || typeof record.id === 'number'
817
+ ? String(record.id)
818
+ : `queued-${this.queueCounter++}`;
819
+ await store.set(key, record);
820
+ this.log(`Queued value to store '${target}' (key=${key})`);
615
821
  }
616
822
  async executeStep(step, actionName, ctx) {
617
823
  // Use provided context or default to this.ctx
618
824
  // NOTE: ctx is used for action-scoped operations (response, variables)
619
825
  // this.ctx is still used for mission-level resources (stores, sources)
620
826
  const execCtx = ctx ?? this.ctx;
621
- // Track step index for events
622
- const currentStepIndex = this.stepIndex++;
827
+ // Track step index per-action so parallel actions don't share a counter.
828
+ const scope = this.scopeFor(execCtx);
829
+ const currentStepIndex = scope.stepIndex++;
623
830
  const stepType = this.getStepType(step.type);
831
+ // Stable step identity for the execution log: action + per-action index.
832
+ const stepId = `${actionName}#${currentStepIndex}`;
624
833
  // Emit step.start event
625
834
  this.eventEmitter?.emit('step.start', {
626
835
  actionName,
627
836
  stepIndex: currentStepIndex,
628
837
  stepType,
629
838
  });
839
+ // Append step.started to the durable execution log.
840
+ await this.logEvent({
841
+ type: 'step.started',
842
+ stepId,
843
+ action: actionName,
844
+ stepType,
845
+ attempt: scope.attempt,
846
+ });
630
847
  const stepStartTime = Date.now();
631
848
  // Record trace snapshot before step
632
849
  if (this.traceRecorder) {
@@ -648,7 +865,7 @@ export class MissionExecutor {
648
865
  try {
649
866
  switch (step.type) {
650
867
  case 'FetchStep':
651
- await this.executeFetch(step, execCtx);
868
+ await this.executeFetch(step, execCtx, stepId);
652
869
  break;
653
870
  case 'ForStep':
654
871
  await this.executeFor(step, actionName, execCtx);
@@ -660,7 +877,7 @@ export class MissionExecutor {
660
877
  await this.executeValidate(step, execCtx);
661
878
  break;
662
879
  case 'StoreStep':
663
- await this.executeStore(step, execCtx);
880
+ await this.executeStore(step, execCtx, stepId);
664
881
  break;
665
882
  case 'MatchStep':
666
883
  await this.executeMatch(step, actionName, execCtx);
@@ -692,6 +909,8 @@ export class MissionExecutor {
692
909
  stepType,
693
910
  success: true,
694
911
  });
912
+ // Append step.completed to the durable execution log.
913
+ await this.logEvent({ type: 'step.completed', stepId, attempt: scope.attempt });
695
914
  }
696
915
  catch (error) {
697
916
  // Re-throw flow control signals without recording as errors
@@ -727,7 +946,7 @@ export class MissionExecutor {
727
946
  throw error;
728
947
  }
729
948
  }
730
- async executeFetch(step, ctx) {
949
+ async executeFetch(step, ctx, stepId) {
731
950
  const fetchHandler = new FetchHandler({
732
951
  ctx,
733
952
  oasSources: this.sourceManager.getAllOASSources(),
@@ -740,12 +959,82 @@ export class MissionExecutor {
740
959
  emit: this.eventEmitter
741
960
  ? (type, payload) => this.eventEmitter.emit(type, payload)
742
961
  : undefined,
962
+ // Durable mode: mutating fetches carry a stable Idempotency-Key.
963
+ idempotency: this.executionLog && this.logExecutionId && stepId
964
+ ? { executionId: this.logExecutionId, stepId }
965
+ : undefined,
966
+ // Resumable backfill: seed pagination from the log and record each page.
967
+ pagination: step.backfill && this.executionLog && this.logExecutionId && stepId
968
+ ? {
969
+ resume: this.pageProgress.get(stepId),
970
+ maxItemsPerRun: this.config.backfillMaxItemsPerRun,
971
+ onPage: async (progress) => {
972
+ await this.logEvent({
973
+ type: 'page.completed',
974
+ stepId,
975
+ page: progress.page,
976
+ cursor: progress.cursor,
977
+ recordCount: progress.recordCount,
978
+ done: progress.done,
979
+ });
980
+ },
981
+ }
982
+ : undefined,
743
983
  });
984
+ // Capture when the request began; used as the checkpoint fallback time so
985
+ // a sync without an explicit update field never advances past records
986
+ // written during the fetch.
987
+ const fetchStartedAt = new Date();
744
988
  const result = await fetchHandler.execute(step);
745
989
  ctx.response = result.data;
746
- // Update sync checkpoint after successful fetch
990
+ // Defer the sync checkpoint until the fetched data is durably stored.
747
991
  if (result.checkpointKey && this.syncStore) {
748
- await fetchHandler.recordCheckpoint(result.checkpointKey, step, result.data);
992
+ const key = result.checkpointKey;
993
+ const data = result.data;
994
+ this.scopeFor(ctx).pendingCheckpoints.push(async () => {
995
+ const syncedAt = await fetchHandler.recordCheckpoint(key, step, data, fetchStartedAt);
996
+ if (syncedAt) {
997
+ await this.logEvent({
998
+ type: 'checkpoint.advanced',
999
+ key,
1000
+ syncedAt: syncedAt.toISOString(),
1001
+ recordCount: Array.isArray(data) ? data.length : undefined,
1002
+ mission: this.missionName,
1003
+ });
1004
+ }
1005
+ });
1006
+ }
1007
+ }
1008
+ /**
1009
+ * Per-action mutable scope (step counter + deferred checkpoints). Lazily
1010
+ * created so a step run with the bare mission context still works; normally
1011
+ * executeAction installs a fresh scope that nested scopes inherit.
1012
+ */
1013
+ scopeFor(ctx) {
1014
+ if (!ctx.actionScope) {
1015
+ ctx.actionScope = { stepIndex: 0, attempt: 0, pendingCheckpoints: [] };
1016
+ }
1017
+ return ctx.actionScope;
1018
+ }
1019
+ /**
1020
+ * Append an event to the execution log. No-op (zero cost) when no log is
1021
+ * configured. The executionId is supplied from this run's stable log id.
1022
+ */
1023
+ async logEvent(event) {
1024
+ if (!this.executionLog || !this.logExecutionId)
1025
+ return;
1026
+ await this.executionLog.append({
1027
+ ...event,
1028
+ executionId: this.logExecutionId,
1029
+ });
1030
+ }
1031
+ /** Flush deferred sync checkpoints (called after a successful store / action). */
1032
+ async flushPendingCheckpoints(ctx) {
1033
+ const scope = this.scopeFor(ctx);
1034
+ const pending = scope.pendingCheckpoints;
1035
+ scope.pendingCheckpoints = [];
1036
+ for (const record of pending) {
1037
+ await record();
749
1038
  }
750
1039
  }
751
1040
  async executeFor(step, actionName, ctx) {
@@ -765,6 +1054,7 @@ export class MissionExecutor {
765
1054
  ? (cmd) => this.handleDebugCommand(cmd)
766
1055
  : undefined,
767
1056
  checkPause: this.config.controlServer ? () => this.checkPause() : undefined,
1057
+ handleQueue: (signal) => this.handleQueue(signal),
768
1058
  });
769
1059
  await handler.execute(step);
770
1060
  }
@@ -788,7 +1078,36 @@ export class MissionExecutor {
788
1078
  });
789
1079
  await handler.execute(step);
790
1080
  }
791
- async executeStore(step, ctx) {
1081
+ async executeStore(step, ctx, stepId) {
1082
+ // Dry runs use synthetic fetch data that has no real keys; persisting it
1083
+ // would both write garbage and trip key validation. Skip the write but
1084
+ // still advance checkpoints so the dry run exercises the sync path.
1085
+ if (this.config.dryRun) {
1086
+ this.log(`[dry run] skipping store to ${step.target}`);
1087
+ await this.flushPendingCheckpoints(ctx);
1088
+ return;
1089
+ }
1090
+ // Step-level effect identity (attempt-independent): a store effect already
1091
+ // applied in the log — whether by a prior run we are resuming or an earlier
1092
+ // action attempt — must not be re-applied. This is the exactly-once-on-replay
1093
+ // guarantee for store writes.
1094
+ //
1095
+ // The identity is keyed on the *content* being written, not just the target.
1096
+ // A resumable backfill re-runs the same step (same stepId) once per run, each
1097
+ // run storing a different page; keying on target alone made every run after
1098
+ // the first collide on one id and skip the write, silently dropping every
1099
+ // page but the first. Hashing the resolved payload keeps re-storing the same
1100
+ // data idempotent (the upsert is a no-op anyway) while a different page is a
1101
+ // distinct effect that applies.
1102
+ const discriminator = `${step.target}::${this.storeContentHash(step, ctx)}`;
1103
+ const fx = stepId && this.logExecutionId
1104
+ ? effectId(this.logExecutionId, stepId, 0, 'store', discriminator)
1105
+ : undefined;
1106
+ if (fx && this.appliedEffects.has(fx)) {
1107
+ this.log(`Skipping already-applied store to ${step.target} (resume)`);
1108
+ await this.flushPendingCheckpoints(ctx);
1109
+ return;
1110
+ }
792
1111
  const handler = new StoreHandler({
793
1112
  ctx,
794
1113
  log: (msg) => this.log(msg),
@@ -797,6 +1116,44 @@ export class MissionExecutor {
797
1116
  : undefined,
798
1117
  });
799
1118
  await handler.execute(step);
1119
+ // Record the effect as applied so replay/retry skips it.
1120
+ if (fx) {
1121
+ this.appliedEffects.add(fx);
1122
+ await this.logEvent({
1123
+ type: 'effect.applied',
1124
+ stepId: stepId,
1125
+ attempt: 0,
1126
+ effectType: 'store',
1127
+ effectId: fx,
1128
+ });
1129
+ }
1130
+ // Data is now durably stored — safe to advance any pending sync checkpoint.
1131
+ await this.flushPendingCheckpoints(ctx);
1132
+ }
1133
+ /**
1134
+ * A stable hash of the payload a store step will write, used to make the
1135
+ * store-effect identity content-aware. Resolving the source is a pure read of
1136
+ * the context (the same value the handler stores); on any evaluation failure we
1137
+ * fall back to a constant so behaviour matches the old target-only identity
1138
+ * rather than throwing inside the dedup path.
1139
+ */
1140
+ /** Load a pause's checkpoint into the shape the pause step uses to resume. */
1141
+ async loadResumingPause(pauseId) {
1142
+ const resumed = await this.pauseStore?.load(pauseId);
1143
+ return resumed
1144
+ ? { pauseId, checkpoint: resumed.checkpoint, payload: resumed.webhookPayload }
1145
+ : undefined;
1146
+ }
1147
+ storeContentHash(step, ctx) {
1148
+ try {
1149
+ const source = evaluate(step.source, ctx);
1150
+ return createHash('sha1')
1151
+ .update(JSON.stringify(source) ?? 'null')
1152
+ .digest('hex');
1153
+ }
1154
+ catch {
1155
+ return 'unhashable';
1156
+ }
800
1157
  }
801
1158
  async executeMatch(step, actionName, ctx) {
802
1159
  const handler = new MatchHandler({
@@ -821,7 +1178,8 @@ export class MissionExecutor {
821
1178
  async executeLet(step, ctx) {
822
1179
  const value = evaluate(step.value, ctx);
823
1180
  setVariable(ctx, step.name, value);
824
- this.log(`Set variable '${step.name}' = ${JSON.stringify(value)}`);
1181
+ // Redact before logging: the value (or a nested field) may be a secret.
1182
+ this.log(`Set variable '${step.name}' = ${JSON.stringify(redactNamedValue(step.name, value))}`);
825
1183
  }
826
1184
  async executeApply(step, ctx) {
827
1185
  const transform = this.transforms.get(step.transform);
@@ -854,6 +1212,21 @@ export class MissionExecutor {
854
1212
  if (!this.pauseManager) {
855
1213
  throw new Error('Pause manager not configured');
856
1214
  }
1215
+ // Resuming this very pause: don't pause again. Restore the captured
1216
+ // checkpoint (variables + response, plus any webhook payload) and fall
1217
+ // through so the steps after the pause run to completion.
1218
+ const resuming = this.resumingPause;
1219
+ if (resuming &&
1220
+ resuming.checkpoint.action === actionName &&
1221
+ resuming.checkpoint.stepIndex === stepIndex + 1) {
1222
+ this.resumingPause = undefined;
1223
+ for (const [key, value] of Object.entries(resuming.checkpoint.variables ?? {})) {
1224
+ ctx.variables.set(key, value);
1225
+ }
1226
+ ctx.response = resuming.payload ?? resuming.checkpoint.response;
1227
+ this.log(`Resuming past pause ${resuming.pauseId}`);
1228
+ return;
1229
+ }
857
1230
  // Mark execution state as paused before creating pause
858
1231
  if (this.executionState) {
859
1232
  this.executionState.status = 'paused';
@@ -866,7 +1239,10 @@ export class MissionExecutor {
866
1239
  ? (type, payload) => this.eventEmitter.emit(type, payload)
867
1240
  : undefined,
868
1241
  pauseManager: this.pauseManager,
869
- executionId: this.executionState?.id ?? 'ephemeral',
1242
+ // Anchor the pause to the durable log id so its pause.created lands under
1243
+ // the same execution the log replays on resume (executionState may be
1244
+ // absent when running without an execution store).
1245
+ executionId: this.logExecutionId ?? this.executionState?.id ?? 'ephemeral',
870
1246
  mission: this.missionName ?? 'unknown',
871
1247
  actionName,
872
1248
  stageIndex: this.currentStageIndex,