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.
- package/README.md +23 -3
- package/dist/ast/nodes.d.ts +8 -0
- package/dist/auth/circuit-breaker.d.ts +11 -0
- package/dist/auth/circuit-breaker.js +83 -12
- package/dist/auth/credentials.d.ts +6 -1
- package/dist/auth/credentials.js +12 -4
- package/dist/auth/oauth2-provider.js +13 -3
- package/dist/auth/rate-limiter.d.ts +8 -1
- package/dist/auth/rate-limiter.js +30 -10
- package/dist/auth/token-store.js +8 -1
- package/dist/cli.d.ts +11 -1
- package/dist/cli.js +65 -6
- package/dist/config/constants.d.ts +15 -4
- package/dist/config/constants.js +15 -4
- package/dist/control/server.d.ts +17 -0
- package/dist/control/server.js +82 -5
- package/dist/control/types.d.ts +6 -0
- package/dist/debug/cli-debugger.js +8 -3
- package/dist/execution/store.js +2 -2
- package/dist/execution-log/events.d.ts +125 -0
- package/dist/execution-log/events.js +17 -0
- package/dist/execution-log/fold.d.ts +38 -0
- package/dist/execution-log/fold.js +54 -0
- package/dist/execution-log/index.d.ts +18 -0
- package/dist/execution-log/index.js +6 -0
- package/dist/execution-log/postgres-store.d.ts +36 -0
- package/dist/execution-log/postgres-store.js +108 -0
- package/dist/execution-log/resume.d.ts +11 -0
- package/dist/execution-log/resume.js +5 -0
- package/dist/execution-log/sqlite-store.d.ts +16 -0
- package/dist/execution-log/sqlite-store.js +101 -0
- package/dist/execution-log/store.d.ts +72 -0
- package/dist/execution-log/store.js +182 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/interpreter/context.d.ts +15 -0
- package/dist/interpreter/context.js +3 -0
- package/dist/interpreter/evaluator.js +38 -8
- package/dist/interpreter/executor.d.ts +63 -1
- package/dist/interpreter/executor.js +406 -30
- package/dist/interpreter/fetch-handler.d.ts +39 -1
- package/dist/interpreter/fetch-handler.js +84 -15
- package/dist/interpreter/http.d.ts +31 -2
- package/dist/interpreter/http.js +187 -26
- package/dist/interpreter/index.d.ts +3 -3
- package/dist/interpreter/index.js +3 -3
- package/dist/interpreter/pagination.d.ts +1 -1
- package/dist/interpreter/pagination.js +7 -1
- package/dist/interpreter/step-handlers/for-handler.d.ts +3 -0
- package/dist/interpreter/step-handlers/for-handler.js +18 -3
- package/dist/interpreter/step-handlers/match-handler.js +5 -2
- package/dist/interpreter/step-handlers/store-handler.d.ts +7 -1
- package/dist/interpreter/step-handlers/store-handler.js +25 -16
- package/dist/interpreter/step-handlers/validate-handler.js +4 -1
- package/dist/interpreter/step-handlers/webhook-handler.d.ts +1 -0
- package/dist/interpreter/step-handlers/webhook-handler.js +13 -3
- package/dist/interpreter/store-manager.d.ts +1 -1
- package/dist/interpreter/store-manager.js +5 -1
- package/dist/loader/index.js +5 -8
- package/dist/mcp/sandbox.d.ts +41 -0
- package/dist/mcp/sandbox.js +76 -0
- package/dist/mcp/server.js +62 -9
- package/dist/oas/loader.d.ts +13 -1
- package/dist/oas/loader.js +25 -3
- package/dist/oas/mock-generator.js +13 -4
- package/dist/oas/validator.js +45 -5
- package/dist/observability/events.d.ts +6 -2
- package/dist/observability/events.js +0 -5
- package/dist/observability/logger.js +17 -10
- package/dist/observability/otel.d.ts +8 -0
- package/dist/observability/otel.js +45 -10
- package/dist/parser/action-parser.js +2 -2
- package/dist/parser/base.d.ts +7 -0
- package/dist/parser/base.js +11 -0
- package/dist/parser/expressions.d.ts +1 -0
- package/dist/parser/expressions.js +17 -4
- package/dist/parser/fetch-parser.js +13 -2
- package/dist/pause/index.d.ts +1 -0
- package/dist/pause/index.js +1 -0
- package/dist/pause/log-store.d.ts +33 -0
- package/dist/pause/log-store.js +98 -0
- package/dist/pause/manager.d.ts +12 -0
- package/dist/pause/manager.js +77 -28
- package/dist/pause/store.js +5 -3
- package/dist/scheduler/cron-parser.d.ts +10 -3
- package/dist/scheduler/cron-parser.js +227 -48
- package/dist/scheduler/scheduler.js +56 -22
- package/dist/stores/factory.d.ts +6 -0
- package/dist/stores/factory.js +11 -1
- package/dist/stores/file.js +9 -17
- package/dist/stores/memory.js +3 -12
- package/dist/stores/postgrest.d.ts +28 -0
- package/dist/stores/postgrest.js +84 -37
- package/dist/sync/index.d.ts +3 -2
- package/dist/sync/index.js +2 -1
- package/dist/sync/log-store.d.ts +30 -0
- package/dist/sync/log-store.js +45 -0
- package/dist/sync/store.js +1 -1
- package/dist/trace/index.d.ts +2 -0
- package/dist/trace/index.js +1 -0
- package/dist/trace/log-view.d.ts +57 -0
- package/dist/trace/log-view.js +76 -0
- package/dist/trace/recorder.d.ts +5 -1
- package/dist/trace/recorder.js +19 -6
- package/dist/trace/store.d.ts +6 -0
- package/dist/trace/store.js +47 -22
- package/dist/utils/deep-merge.d.ts +10 -0
- package/dist/utils/deep-merge.js +23 -0
- package/dist/utils/file.d.ts +13 -4
- package/dist/utils/file.js +70 -12
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/long-timeout.d.ts +19 -0
- package/dist/utils/long-timeout.js +33 -0
- package/dist/utils/path.d.ts +22 -1
- package/dist/utils/path.js +46 -1
- package/dist/utils/redact.d.ts +22 -0
- package/dist/utils/redact.js +42 -0
- package/dist/webhook/server.d.ts +9 -0
- package/dist/webhook/server.js +115 -30
- package/dist/webhook/types.d.ts +9 -1
- 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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
|
622
|
-
const
|
|
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
|
-
//
|
|
990
|
+
// Defer the sync checkpoint until the fetched data is durably stored.
|
|
747
991
|
if (result.checkpointKey && this.syncStore) {
|
|
748
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|