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
package/dist/config/constants.js
CHANGED
|
@@ -20,6 +20,8 @@ export const HTTP_RETRY_DEFAULTS = {
|
|
|
20
20
|
MAX_DELAY_MS: 30000,
|
|
21
21
|
/** Backoff strategy: 'exponential', 'linear', or 'constant' */
|
|
22
22
|
BACKOFF: 'exponential',
|
|
23
|
+
/** Per-attempt request timeout in milliseconds (aborts a hung request) */
|
|
24
|
+
TIMEOUT_MS: 30000,
|
|
23
25
|
};
|
|
24
26
|
/**
|
|
25
27
|
* Default HTTP headers
|
|
@@ -81,12 +83,16 @@ export const CIRCUIT_BREAKER_DEFAULTS = {
|
|
|
81
83
|
export const WEBHOOK_DEFAULTS = {
|
|
82
84
|
/** Default port for webhook server */
|
|
83
85
|
PORT: 3000,
|
|
84
|
-
/** Default host binding */
|
|
85
|
-
HOST: '
|
|
86
|
+
/** Default host binding — loopback only; opt into 0.0.0.0 explicitly. */
|
|
87
|
+
HOST: '127.0.0.1',
|
|
86
88
|
/** Default timeout for wait steps (5 minutes in ms) */
|
|
87
89
|
DEFAULT_TIMEOUT_MS: 300000,
|
|
88
90
|
/** Cleanup interval for expired registrations (1 minute in ms) */
|
|
89
91
|
CLEANUP_INTERVAL_MS: 60000,
|
|
92
|
+
/** Maximum accepted request body size in bytes (1 MiB) */
|
|
93
|
+
MAX_BODY_BYTES: 1024 * 1024,
|
|
94
|
+
/** Idle socket timeout in ms; drops slow-drip connections */
|
|
95
|
+
SOCKET_TIMEOUT_MS: 30000,
|
|
90
96
|
};
|
|
91
97
|
// ============================================
|
|
92
98
|
// Store Configuration
|
|
@@ -121,8 +127,13 @@ export const SCHEDULER_DEFAULTS = {
|
|
|
121
127
|
* Default execution configuration
|
|
122
128
|
*/
|
|
123
129
|
export const EXECUTION_DEFAULTS = {
|
|
124
|
-
/**
|
|
125
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Whether development mode is enabled by default. When true, sql/nosql
|
|
132
|
+
* stores fall back to local JSON files; default false so a mission that
|
|
133
|
+
* declares `store sql`/`nosql` errors loudly instead of silently writing
|
|
134
|
+
* to disk.
|
|
135
|
+
*/
|
|
136
|
+
DEVELOPMENT_MODE: false,
|
|
126
137
|
/** Whether to persist execution state by default */
|
|
127
138
|
PERSIST_STATE: false,
|
|
128
139
|
};
|
package/dist/control/server.d.ts
CHANGED
|
@@ -61,6 +61,23 @@ export declare class ControlServer {
|
|
|
61
61
|
* Handle incoming HTTP request
|
|
62
62
|
*/
|
|
63
63
|
private handleRequest;
|
|
64
|
+
/** True if a host string is a loopback address. */
|
|
65
|
+
private isLoopback;
|
|
66
|
+
/** True if the request carries an Origin header pointing at another origin. */
|
|
67
|
+
private hasCrossOriginHeader;
|
|
68
|
+
/**
|
|
69
|
+
* Enforce the shared-secret token on protected endpoints. When no token is
|
|
70
|
+
* configured, allow (the loopback bind + CSRF check are the baseline) but
|
|
71
|
+
* warn so operators know /status state is exposed locally.
|
|
72
|
+
*/
|
|
73
|
+
private authorized;
|
|
74
|
+
/**
|
|
75
|
+
* Constant-time string comparison. Avoids the timing oracle of `a !== b`,
|
|
76
|
+
* which short-circuits on the first differing byte and can leak the token
|
|
77
|
+
* prefix. Length is compared first (and non-constant-time), which only
|
|
78
|
+
* reveals the token length — not its contents.
|
|
79
|
+
*/
|
|
80
|
+
private safeEqual;
|
|
64
81
|
/**
|
|
65
82
|
* Handle health check
|
|
66
83
|
*/
|
package/dist/control/server.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createServer } from 'node:http';
|
|
8
8
|
import { parse as parseUrl } from 'node:url';
|
|
9
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
9
10
|
const DEFAULTS = {
|
|
10
11
|
PORT: 3001,
|
|
11
12
|
HOST: 'localhost',
|
|
@@ -34,6 +35,7 @@ export class ControlServer {
|
|
|
34
35
|
port: config.port ?? DEFAULTS.PORT,
|
|
35
36
|
host: config.host ?? DEFAULTS.HOST,
|
|
36
37
|
verbose: config.verbose ?? false,
|
|
38
|
+
authToken: config.authToken ?? '',
|
|
37
39
|
};
|
|
38
40
|
this.callbacks = callbacks;
|
|
39
41
|
}
|
|
@@ -43,8 +45,17 @@ export class ControlServer {
|
|
|
43
45
|
async start() {
|
|
44
46
|
if (this.running)
|
|
45
47
|
return;
|
|
48
|
+
// Hard-refuse to expose a state-changing server beyond loopback without a
|
|
49
|
+
// shared secret. /pause, /resume, and /status would otherwise be reachable
|
|
50
|
+
// by anyone who can route to this host. Bind to loopback or set authToken.
|
|
51
|
+
if (!this.isLoopback(this.config.host) && !this.config.authToken) {
|
|
52
|
+
throw new Error(`[Control] Refusing to start: binding to non-loopback host "${this.config.host}" ` +
|
|
53
|
+
`with no authToken would expose /pause, /resume, and /status without authentication. ` +
|
|
54
|
+
`Set an authToken or bind to localhost.`);
|
|
55
|
+
}
|
|
46
56
|
return new Promise((resolve, reject) => {
|
|
47
57
|
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
58
|
+
this.server.setTimeout(30000);
|
|
48
59
|
this.server.on('error', (error) => {
|
|
49
60
|
reject(error);
|
|
50
61
|
});
|
|
@@ -118,28 +129,41 @@ export class ControlServer {
|
|
|
118
129
|
const url = parseUrl(req.url ?? '/', true);
|
|
119
130
|
const path = url.pathname ?? '/';
|
|
120
131
|
const method = req.method ?? 'GET';
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
res.setHeader('
|
|
124
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
132
|
+
// No wildcard CORS. Same-origin reads only; we never reflect ACAO so a
|
|
133
|
+
// browser on another origin can't read /status.
|
|
134
|
+
res.setHeader('Vary', 'Origin');
|
|
125
135
|
// Handle preflight
|
|
126
136
|
if (method === 'OPTIONS') {
|
|
127
137
|
res.writeHead(204);
|
|
128
138
|
res.end();
|
|
129
139
|
return;
|
|
130
140
|
}
|
|
141
|
+
// CSRF defense: reject any request that carries a cross-origin Origin
|
|
142
|
+
// header (server-to-server clients and curl send none). Combined with the
|
|
143
|
+
// loopback bind, this stops a web page the operator visits from POSTing
|
|
144
|
+
// /pause or /resume to their local control server.
|
|
145
|
+
if (this.hasCrossOriginHeader(req)) {
|
|
146
|
+
this.sendJson(res, 403, { error: 'Cross-origin request rejected' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
131
149
|
try {
|
|
132
150
|
// Route requests
|
|
133
151
|
if (path === '/health' || path === '/_health') {
|
|
134
152
|
this.handleHealth(res);
|
|
135
153
|
}
|
|
136
154
|
else if (path === '/status') {
|
|
155
|
+
if (!this.authorized(req, res))
|
|
156
|
+
return;
|
|
137
157
|
this.handleStatus(res);
|
|
138
158
|
}
|
|
139
159
|
else if (path === '/pause' && method === 'POST') {
|
|
160
|
+
if (!this.authorized(req, res))
|
|
161
|
+
return;
|
|
140
162
|
this.handlePause(res);
|
|
141
163
|
}
|
|
142
164
|
else if (path === '/resume' && method === 'POST') {
|
|
165
|
+
if (!this.authorized(req, res))
|
|
166
|
+
return;
|
|
143
167
|
this.handleResume(res);
|
|
144
168
|
}
|
|
145
169
|
else {
|
|
@@ -150,6 +174,56 @@ export class ControlServer {
|
|
|
150
174
|
this.sendJson(res, 500, { error: error.message });
|
|
151
175
|
}
|
|
152
176
|
}
|
|
177
|
+
/** True if a host string is a loopback address. */
|
|
178
|
+
isLoopback(host) {
|
|
179
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
180
|
+
}
|
|
181
|
+
/** True if the request carries an Origin header pointing at another origin. */
|
|
182
|
+
hasCrossOriginHeader(req) {
|
|
183
|
+
const origin = req.headers.origin;
|
|
184
|
+
if (!origin || typeof origin !== 'string')
|
|
185
|
+
return false;
|
|
186
|
+
const host = req.headers.host;
|
|
187
|
+
try {
|
|
188
|
+
const originHost = new URL(origin).host;
|
|
189
|
+
return originHost !== host;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Unparseable Origin — treat as cross-origin (reject).
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Enforce the shared-secret token on protected endpoints. When no token is
|
|
198
|
+
* configured, allow (the loopback bind + CSRF check are the baseline) but
|
|
199
|
+
* warn so operators know /status state is exposed locally.
|
|
200
|
+
*/
|
|
201
|
+
authorized(req, res) {
|
|
202
|
+
if (!this.config.authToken) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
const header = req.headers.authorization;
|
|
206
|
+
const expected = `Bearer ${this.config.authToken}`;
|
|
207
|
+
if (typeof header !== 'string' || !this.safeEqual(header, expected)) {
|
|
208
|
+
this.sendJson(res, 401, { error: 'Unauthorized' });
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Constant-time string comparison. Avoids the timing oracle of `a !== b`,
|
|
215
|
+
* which short-circuits on the first differing byte and can leak the token
|
|
216
|
+
* prefix. Length is compared first (and non-constant-time), which only
|
|
217
|
+
* reveals the token length — not its contents.
|
|
218
|
+
*/
|
|
219
|
+
safeEqual(a, b) {
|
|
220
|
+
const bufA = Buffer.from(a);
|
|
221
|
+
const bufB = Buffer.from(b);
|
|
222
|
+
if (bufA.length !== bufB.length) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
return timingSafeEqual(bufA, bufB);
|
|
226
|
+
}
|
|
153
227
|
/**
|
|
154
228
|
* Handle health check
|
|
155
229
|
*/
|
|
@@ -224,7 +298,10 @@ export class ControlServer {
|
|
|
224
298
|
* Send JSON response
|
|
225
299
|
*/
|
|
226
300
|
sendJson(res, status, data) {
|
|
227
|
-
|
|
301
|
+
// Close the connection after each response. This localhost admin server has
|
|
302
|
+
// no need for keep-alive, and avoiding pooled sockets removes a client-side
|
|
303
|
+
// socket-reuse race (intermittent ECONNRESET on rapid sequential requests).
|
|
304
|
+
res.writeHead(status, { 'Content-Type': 'application/json', Connection: 'close' });
|
|
228
305
|
res.end(JSON.stringify(data, null, 2));
|
|
229
306
|
}
|
|
230
307
|
/**
|
package/dist/control/types.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface ControlServerConfig {
|
|
|
15
15
|
host?: string;
|
|
16
16
|
/** Enable verbose logging */
|
|
17
17
|
verbose?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Shared secret required on state-changing endpoints (/pause, /resume) and
|
|
20
|
+
* /status. When set, requests must present `Authorization: Bearer <secret>`
|
|
21
|
+
* or they are rejected with 401.
|
|
22
|
+
*/
|
|
23
|
+
authToken?: string;
|
|
18
24
|
}
|
|
19
25
|
/**
|
|
20
26
|
* Callbacks for control server events
|
|
@@ -36,17 +36,22 @@ export class CLIDebugger extends BaseDebugController {
|
|
|
36
36
|
// Print variables summary
|
|
37
37
|
const varKeys = Object.keys(s.variables);
|
|
38
38
|
if (varKeys.length > 0) {
|
|
39
|
-
const preview = varKeys
|
|
39
|
+
const preview = varKeys
|
|
40
|
+
.slice(0, 3)
|
|
41
|
+
.map((k) => `${k}: ${this.formatValue(s.variables[k])}`)
|
|
42
|
+
.join(', ');
|
|
40
43
|
const more = varKeys.length > 3 ? ` (+${varKeys.length - 3} more)` : '';
|
|
41
44
|
console.log(` Variables: { ${preview}${more} }`);
|
|
42
45
|
}
|
|
43
46
|
// Print stores summary
|
|
44
47
|
const storeEntries = Object.entries(s.stores);
|
|
45
48
|
if (storeEntries.length > 0) {
|
|
46
|
-
const storeInfo = storeEntries
|
|
49
|
+
const storeInfo = storeEntries
|
|
50
|
+
.map(([name, info]) => {
|
|
47
51
|
const count = info.count >= 0 ? ` (${info.count} items)` : '';
|
|
48
52
|
return `${name}: ${info.type}${count}`;
|
|
49
|
-
})
|
|
53
|
+
})
|
|
54
|
+
.join(', ');
|
|
50
55
|
console.log(` Stores: { ${storeInfo} }`);
|
|
51
56
|
}
|
|
52
57
|
// Print response summary
|
package/dist/execution/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { safeJoin } from '../utils/path.js';
|
|
2
2
|
import { ensureDirectory, writeJsonFile, readJsonFile, listFiles, deleteFile, restoreDates, restoreDatesInArray, } from '../utils/file.js';
|
|
3
3
|
/**
|
|
4
4
|
* File-based execution state store
|
|
@@ -12,7 +12,7 @@ export class FileExecutionStore {
|
|
|
12
12
|
this.initialized = ensureDirectory(this.baseDir);
|
|
13
13
|
}
|
|
14
14
|
getFilePath(id) {
|
|
15
|
-
return
|
|
15
|
+
return safeJoin(this.baseDir, `${id}.json`);
|
|
16
16
|
}
|
|
17
17
|
deserialize(parsed) {
|
|
18
18
|
// Restore Date objects
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution event log — the durable, append-only record of a mission run.
|
|
3
|
+
*
|
|
4
|
+
* Every step's start, completion, and side effect is appended as it happens.
|
|
5
|
+
* Resume becomes "replay the log and fold to last state" (see {@link ./fold}),
|
|
6
|
+
* and trace/time-travel, audit, and idempotent resume all derive from this one
|
|
7
|
+
* structure. Recorded values (timestamps, effect ids, outputs) are read back on
|
|
8
|
+
* replay rather than re-derived, so replay is deterministic.
|
|
9
|
+
*/
|
|
10
|
+
export type ExecutionEventType = 'mission.started' | 'step.started' | 'step.completed' | 'effect.applied' | 'page.completed' | 'checkpoint.advanced' | 'pause.created' | 'pause.resumed' | 'mission.completed' | 'mission.failed';
|
|
11
|
+
interface BaseEvent {
|
|
12
|
+
/** The run this event belongs to. */
|
|
13
|
+
executionId: string;
|
|
14
|
+
/** Event kind (discriminant). */
|
|
15
|
+
type: ExecutionEventType;
|
|
16
|
+
}
|
|
17
|
+
export interface MissionStartedEvent extends BaseEvent {
|
|
18
|
+
type: 'mission.started';
|
|
19
|
+
mission: string;
|
|
20
|
+
}
|
|
21
|
+
export interface StepStartedEvent extends BaseEvent {
|
|
22
|
+
type: 'step.started';
|
|
23
|
+
stepId: string;
|
|
24
|
+
action: string;
|
|
25
|
+
stepType: string;
|
|
26
|
+
attempt: number;
|
|
27
|
+
}
|
|
28
|
+
export interface StepCompletedEvent extends BaseEvent {
|
|
29
|
+
type: 'step.completed';
|
|
30
|
+
stepId: string;
|
|
31
|
+
attempt: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A side effect (a fetch or a store write) was applied. The {@link effectId}
|
|
35
|
+
* is the stable identity used to skip already-applied effects on replay.
|
|
36
|
+
*/
|
|
37
|
+
export interface EffectAppliedEvent extends BaseEvent {
|
|
38
|
+
type: 'effect.applied';
|
|
39
|
+
stepId: string;
|
|
40
|
+
attempt: number;
|
|
41
|
+
effectType: 'fetch' | 'store';
|
|
42
|
+
effectId: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A page of a resumable (backfill) paginated fetch finished — its data is
|
|
46
|
+
* fetched and persisted. Carries the position to resume from: the next page
|
|
47
|
+
* index and/or cursor. `done` marks the natural end of pagination (the API had
|
|
48
|
+
* no more), distinct from stopping early on a per-run item cap, so a resume
|
|
49
|
+
* knows whether to continue or skip.
|
|
50
|
+
*/
|
|
51
|
+
export interface PageCompletedEvent extends BaseEvent {
|
|
52
|
+
type: 'page.completed';
|
|
53
|
+
stepId: string;
|
|
54
|
+
/** Zero-based index of the next page to fetch on resume. */
|
|
55
|
+
page: number;
|
|
56
|
+
/** Cursor to resume from (cursor-based pagination). */
|
|
57
|
+
cursor?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Records *fetched* on this page. NOTE: this is the fetched count, not the
|
|
60
|
+
* persisted count — the page's data is still only in memory when this event is
|
|
61
|
+
* recorded; the downstream store step persists it afterwards. A crash between
|
|
62
|
+
* this event and the store's `effect.applied` advances the resume cursor past a
|
|
63
|
+
* page whose data was never stored. See the known crash-window note in
|
|
64
|
+
* CODE_REVIEW.md (C2); a full fix defers this advance until after persistence.
|
|
65
|
+
*/
|
|
66
|
+
recordCount?: number;
|
|
67
|
+
/** True once pagination has reached its natural end. */
|
|
68
|
+
done: boolean;
|
|
69
|
+
}
|
|
70
|
+
export interface CheckpointAdvancedEvent extends BaseEvent {
|
|
71
|
+
type: 'checkpoint.advanced';
|
|
72
|
+
key: string;
|
|
73
|
+
syncedAt: string;
|
|
74
|
+
/** Records fetched in the sync that advanced this checkpoint. */
|
|
75
|
+
recordCount?: number;
|
|
76
|
+
/** Opaque pagination cursor to resume from (cursor-based incremental sync). */
|
|
77
|
+
cursor?: string;
|
|
78
|
+
/** Mission that advanced the checkpoint (lets a shared log filter by mission). */
|
|
79
|
+
mission?: string;
|
|
80
|
+
}
|
|
81
|
+
export interface PauseCreatedEvent extends BaseEvent {
|
|
82
|
+
type: 'pause.created';
|
|
83
|
+
pauseId: string;
|
|
84
|
+
/**
|
|
85
|
+
* The full durable pause state (expiry, resume triggers, captured checkpoint).
|
|
86
|
+
* Carried in the log so a paused run — and the timer/webhook it waits on — can
|
|
87
|
+
* be reconstructed from the log alone, no separate pause file. Opaque to the
|
|
88
|
+
* log layer (typed `unknown` to avoid coupling it to the pause domain).
|
|
89
|
+
*/
|
|
90
|
+
pause?: unknown;
|
|
91
|
+
}
|
|
92
|
+
export interface PauseResumedEvent extends BaseEvent {
|
|
93
|
+
type: 'pause.resumed';
|
|
94
|
+
pauseId: string;
|
|
95
|
+
resumedBy: string;
|
|
96
|
+
/** Terminal status of the pause: resumed (default), cancelled, or expired. */
|
|
97
|
+
status?: 'resumed' | 'cancelled' | 'expired';
|
|
98
|
+
/** Recorded resume time (ISO 8601). */
|
|
99
|
+
resumedAt?: string;
|
|
100
|
+
/** Payload delivered by a webhook resume. */
|
|
101
|
+
webhookPayload?: unknown;
|
|
102
|
+
}
|
|
103
|
+
export interface MissionCompletedEvent extends BaseEvent {
|
|
104
|
+
type: 'mission.completed';
|
|
105
|
+
}
|
|
106
|
+
export interface MissionFailedEvent extends BaseEvent {
|
|
107
|
+
type: 'mission.failed';
|
|
108
|
+
error: string;
|
|
109
|
+
}
|
|
110
|
+
/** An event as appended by a caller (the log assigns seq + timestamp). */
|
|
111
|
+
export type ExecutionEvent = MissionStartedEvent | StepStartedEvent | StepCompletedEvent | EffectAppliedEvent | PageCompletedEvent | CheckpointAdvancedEvent | PauseCreatedEvent | PauseResumedEvent | MissionCompletedEvent | MissionFailedEvent;
|
|
112
|
+
/** A persisted event: the appended event plus the log's assigned metadata. */
|
|
113
|
+
export type StoredEvent = ExecutionEvent & {
|
|
114
|
+
/** Monotonic sequence within the execution, starting at 0. */
|
|
115
|
+
seq: number;
|
|
116
|
+
/** Recorded wall-clock time (ISO 8601); read back on replay, never re-derived. */
|
|
117
|
+
at: string;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Stable identity for a side effect: `(executionId, stepId, attempt, effectType)`
|
|
121
|
+
* plus a caller-supplied discriminator (e.g. the request signature or record
|
|
122
|
+
* key). Replay uses this to skip effects already recorded as applied.
|
|
123
|
+
*/
|
|
124
|
+
export declare function effectId(executionId: string, stepId: string, attempt: number, effectType: 'fetch' | 'store', discriminator: string): string;
|
|
125
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution event log — the durable, append-only record of a mission run.
|
|
3
|
+
*
|
|
4
|
+
* Every step's start, completion, and side effect is appended as it happens.
|
|
5
|
+
* Resume becomes "replay the log and fold to last state" (see {@link ./fold}),
|
|
6
|
+
* and trace/time-travel, audit, and idempotent resume all derive from this one
|
|
7
|
+
* structure. Recorded values (timestamps, effect ids, outputs) are read back on
|
|
8
|
+
* replay rather than re-derived, so replay is deterministic.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Stable identity for a side effect: `(executionId, stepId, attempt, effectType)`
|
|
12
|
+
* plus a caller-supplied discriminator (e.g. the request signature or record
|
|
13
|
+
* key). Replay uses this to skip effects already recorded as applied.
|
|
14
|
+
*/
|
|
15
|
+
export function effectId(executionId, stepId, attempt, effectType, discriminator) {
|
|
16
|
+
return `${executionId}::${stepId}::${attempt}::${effectType}::${discriminator}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold an execution event log down to current state.
|
|
3
|
+
*
|
|
4
|
+
* This is the heart of replay-based resume: replaying the log and folding it
|
|
5
|
+
* yields the exact state the run was in, including which effects already
|
|
6
|
+
* applied (so replay skips them) and which pause it is waiting on.
|
|
7
|
+
*/
|
|
8
|
+
import type { StoredEvent } from './events.js';
|
|
9
|
+
export type FoldedStatus = 'pending' | 'running' | 'paused' | 'completed' | 'failed';
|
|
10
|
+
export interface FoldedState {
|
|
11
|
+
status: FoldedStatus;
|
|
12
|
+
/** Sequence of the last event folded (-1 for an empty log). */
|
|
13
|
+
lastSeq: number;
|
|
14
|
+
/** Step ids that have completed (replay skips re-running them). */
|
|
15
|
+
completedSteps: Set<string>;
|
|
16
|
+
/** Effect ids already applied (replay skips re-applying them). */
|
|
17
|
+
appliedEffects: Set<string>;
|
|
18
|
+
/** Latest synced-at per checkpoint key. */
|
|
19
|
+
checkpoints: Map<string, string>;
|
|
20
|
+
/** Resume position per backfill step id (last persisted page + cursor). */
|
|
21
|
+
pageProgress: Map<string, {
|
|
22
|
+
page: number;
|
|
23
|
+
cursor?: string;
|
|
24
|
+
done: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
/** The pause this run is currently waiting on, if any. */
|
|
27
|
+
pendingPauseId?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The last pause marked resumed in the log. A resume trigger (webhook/timeout)
|
|
30
|
+
* can record `pause.resumed` before the executor re-runs, so this lets the
|
|
31
|
+
* executor recognise it must continue past that pause rather than create a new
|
|
32
|
+
* one. Cleared once the run completes or fails.
|
|
33
|
+
*/
|
|
34
|
+
resumedPauseId?: string;
|
|
35
|
+
/** Error message if the run failed. */
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function foldLog(events: StoredEvent[]): FoldedState;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function foldLog(events) {
|
|
2
|
+
const state = {
|
|
3
|
+
status: 'pending',
|
|
4
|
+
lastSeq: -1,
|
|
5
|
+
completedSteps: new Set(),
|
|
6
|
+
appliedEffects: new Set(),
|
|
7
|
+
checkpoints: new Map(),
|
|
8
|
+
pageProgress: new Map(),
|
|
9
|
+
};
|
|
10
|
+
for (const event of events) {
|
|
11
|
+
state.lastSeq = event.seq;
|
|
12
|
+
switch (event.type) {
|
|
13
|
+
case 'mission.started':
|
|
14
|
+
state.status = 'running';
|
|
15
|
+
break;
|
|
16
|
+
case 'step.completed':
|
|
17
|
+
state.completedSteps.add(event.stepId);
|
|
18
|
+
break;
|
|
19
|
+
case 'effect.applied':
|
|
20
|
+
state.appliedEffects.add(event.effectId);
|
|
21
|
+
break;
|
|
22
|
+
case 'page.completed':
|
|
23
|
+
state.pageProgress.set(event.stepId, {
|
|
24
|
+
page: event.page,
|
|
25
|
+
cursor: event.cursor,
|
|
26
|
+
done: event.done,
|
|
27
|
+
});
|
|
28
|
+
break;
|
|
29
|
+
case 'checkpoint.advanced':
|
|
30
|
+
state.checkpoints.set(event.key, event.syncedAt);
|
|
31
|
+
break;
|
|
32
|
+
case 'pause.created':
|
|
33
|
+
state.status = 'paused';
|
|
34
|
+
state.pendingPauseId = event.pauseId;
|
|
35
|
+
break;
|
|
36
|
+
case 'pause.resumed':
|
|
37
|
+
state.status = 'running';
|
|
38
|
+
state.pendingPauseId = undefined;
|
|
39
|
+
state.resumedPauseId = event.pauseId;
|
|
40
|
+
break;
|
|
41
|
+
case 'mission.completed':
|
|
42
|
+
state.status = 'completed';
|
|
43
|
+
state.resumedPauseId = undefined;
|
|
44
|
+
break;
|
|
45
|
+
case 'mission.failed':
|
|
46
|
+
state.status = 'failed';
|
|
47
|
+
state.error = event.error;
|
|
48
|
+
state.resumedPauseId = undefined;
|
|
49
|
+
break;
|
|
50
|
+
// step.started carries no folded state on its own.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution event log — the append-only foundation for durable execution.
|
|
3
|
+
*
|
|
4
|
+
* A running mission is modelled as an ordered log of events; resume is replay +
|
|
5
|
+
* fold (see {@link foldLog}), and idempotent effects fall out of recording an
|
|
6
|
+
* `effect.applied` event per side effect. This module is the seam the rest of
|
|
7
|
+
* the durable-execution work (transactional backend, exactly-once effects,
|
|
8
|
+
* trace/sync/pause as log views) builds on.
|
|
9
|
+
*/
|
|
10
|
+
export type { ExecutionEvent, StoredEvent, ExecutionEventType, MissionStartedEvent, StepStartedEvent, StepCompletedEvent, EffectAppliedEvent, CheckpointAdvancedEvent, PauseCreatedEvent, PauseResumedEvent, MissionCompletedEvent, MissionFailedEvent, } from './events.js';
|
|
11
|
+
export { effectId } from './events.js';
|
|
12
|
+
export type { ExecutionLogStore, CheckpointRecord } from './store.js';
|
|
13
|
+
export { MemoryExecutionLog, FileExecutionLog, reduceCheckpoints } from './store.js';
|
|
14
|
+
export { SqliteExecutionLog } from './sqlite-store.js';
|
|
15
|
+
export { PostgresExecutionLog, type PostgresExecutionLogOptions } from './postgres-store.js';
|
|
16
|
+
export type { FoldedState, FoldedStatus } from './fold.js';
|
|
17
|
+
export { foldLog } from './fold.js';
|
|
18
|
+
export { loadState } from './resume.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { effectId } from './events.js';
|
|
2
|
+
export { MemoryExecutionLog, FileExecutionLog, reduceCheckpoints } from './store.js';
|
|
3
|
+
export { SqliteExecutionLog } from './sqlite-store.js';
|
|
4
|
+
export { PostgresExecutionLog } from './postgres-store.js';
|
|
5
|
+
export { foldLog } from './fold.js';
|
|
6
|
+
export { loadState } from './resume.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres-backed execution log — a transactional, multi-node durable backend.
|
|
3
|
+
*
|
|
4
|
+
* Like the SQLite backend, `pg` is an *optional* peer dependency imported lazily
|
|
5
|
+
* via a non-literal specifier, so the core package carries no database driver
|
|
6
|
+
* and a missing one yields a clear, actionable error.
|
|
7
|
+
*
|
|
8
|
+
* Multi-writer safety: `seq` is assigned inside the INSERT under a
|
|
9
|
+
* `(execution_id, seq)` primary key. If two appenders race and compute the same
|
|
10
|
+
* next seq, one wins and the other gets a unique-violation (SQLSTATE 23505) and
|
|
11
|
+
* retries against the now-higher max — so concurrent appenders always land on
|
|
12
|
+
* distinct, contiguous sequence numbers.
|
|
13
|
+
*/
|
|
14
|
+
import type { ExecutionEvent, StoredEvent } from './events.js';
|
|
15
|
+
import type { CheckpointRecord, ExecutionLogStore } from './store.js';
|
|
16
|
+
export interface PostgresExecutionLogOptions {
|
|
17
|
+
/** Table to store events in. Default `reqon_execution_events`. */
|
|
18
|
+
table?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class PostgresExecutionLog implements ExecutionLogStore {
|
|
21
|
+
private connectionString;
|
|
22
|
+
private pool?;
|
|
23
|
+
private ready?;
|
|
24
|
+
private readonly table;
|
|
25
|
+
constructor(connectionString: string, options?: PostgresExecutionLogOptions);
|
|
26
|
+
private ensure;
|
|
27
|
+
private init;
|
|
28
|
+
append(event: ExecutionEvent): Promise<StoredEvent>;
|
|
29
|
+
read(executionId: string): Promise<StoredEvent[]>;
|
|
30
|
+
listCheckpoints(mission?: string): Promise<CheckpointRecord[]>;
|
|
31
|
+
listPauses(): Promise<StoredEvent[]>;
|
|
32
|
+
/** Drop all rows. Intended for test setup, not production use. */
|
|
33
|
+
reset(): Promise<void>;
|
|
34
|
+
/** Release the connection pool. */
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|