@astrale-os/sdk 0.6.0-beta.35 → 0.6.0-beta.37
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 +10 -0
- package/dist/application/action/context.d.ts +14 -11
- package/dist/application/action/index.d.ts +1 -1
- package/dist/application/dependency/invoker.d.ts +10 -4
- package/dist/application/integration/define.d.ts +8 -1
- package/dist/application/integration/index.d.ts +2 -2
- package/dist/application/integration/integration.d.ts +13 -1
- package/dist/application/integration/operation.d.ts +14 -1
- package/dist/application/mutation/execution/execute.js +1 -1
- package/dist/application/mutation/failure.d.ts +6 -0
- package/dist/application/mutation/failure.js +24 -18
- package/dist/application/query/collection/union.js +11 -4
- package/dist/application/query/conformance.d.ts +15 -0
- package/dist/application/query/conformance.js +46 -0
- package/dist/application/query/define.js +4 -3
- package/dist/application/query/execution/execute.d.ts +0 -1
- package/dist/application/query/execution/execute.js +7 -1
- package/dist/application/query/failure.d.ts +8 -0
- package/dist/application/query/failure.js +8 -0
- package/dist/application/query/index.d.ts +1 -1
- package/dist/application/query/index.js +1 -1
- package/dist/application/workflow/authoring.d.ts +1 -1
- package/dist/application/workflow/context.d.ts +1 -1
- package/dist/application/workflow/durable.d.ts +55 -20
- package/dist/application/workflow/index.d.ts +1 -1
- package/dist/execution/cache/cache.d.ts +17 -0
- package/dist/execution/cache/cache.js +11 -0
- package/dist/execution/cache/index.d.ts +2 -0
- package/dist/execution/cache/index.js +2 -0
- package/dist/execution/cache/memory.d.ts +13 -0
- package/dist/execution/cache/memory.js +48 -0
- package/dist/execution/identity/authentication.js +12 -6
- package/dist/execution/identity/callback.d.ts +7 -8
- package/dist/execution/identity/callback.js +17 -14
- package/dist/execution/index.d.ts +1 -0
- package/dist/execution/index.js +1 -0
- package/dist/execution/invocation/admission/age.d.ts +57 -0
- package/dist/execution/invocation/admission/age.js +208 -0
- package/dist/execution/invocation/admission/authentication.d.ts +2 -0
- package/dist/execution/invocation/admission/authentication.js +6 -2
- package/dist/execution/invocation/admission/index.d.ts +3 -2
- package/dist/execution/invocation/admission/index.js +3 -2
- package/dist/execution/invocation/admission/target.d.ts +11 -0
- package/dist/execution/invocation/admission/target.js +22 -3
- package/dist/execution/invocation/capabilities/bind.d.ts +11 -0
- package/dist/execution/invocation/capabilities/bind.js +25 -0
- package/dist/execution/invocation/capabilities/index.d.ts +1 -0
- package/dist/execution/invocation/capabilities/index.js +1 -0
- package/dist/execution/invocation/dispatch/context.js +2 -21
- package/dist/execution/invocation/handler.js +8 -2
- package/dist/execution/invocation/integration/authentication.d.ts +3 -3
- package/dist/execution/invocation/integration/authentication.js +7 -7
- package/dist/execution/invocation/integration/call.js +2 -2
- package/dist/execution/invocation/integration/index.d.ts +1 -0
- package/dist/execution/invocation/integration/index.js +1 -0
- package/dist/execution/invocation/invocation.d.ts +6 -0
- package/dist/execution/invocation/invocation.js +94 -19
- package/dist/execution/invocation/lifecycle/active.d.ts +8 -2
- package/dist/execution/invocation/lifecycle/active.js +3 -3
- package/dist/execution/invocation/lifecycle/index.d.ts +1 -1
- package/dist/execution/serve.d.ts +8 -0
- package/dist/execution/serve.js +7 -0
- package/dist/execution/service/create.js +2 -0
- package/dist/execution/service/service.d.ts +6 -0
- package/dist/execution/workflows/engine.d.ts +12 -2
- package/dist/execution/workflows/index.d.ts +1 -1
- package/dist/execution/workflows/integrations.d.ts +13 -0
- package/dist/execution/workflows/integrations.js +25 -0
- package/dist/execution/workflows/options.js +3 -1
- package/dist/execution/workflows/receiver.d.ts +3 -0
- package/dist/execution/workflows/receiver.js +7 -2
- package/dist/execution/workflows/run.d.ts +3 -4
- package/dist/execution/workflows/run.js +32 -60
- package/dist/execution/workflows/steps.d.ts +42 -0
- package/dist/execution/workflows/steps.js +67 -0
- package/dist/platform/schema/index.d.ts +1 -1
- package/dist/testing/durable/engine.d.ts +111 -0
- package/dist/testing/durable/engine.js +540 -0
- package/dist/testing/durable/index.d.ts +3 -0
- package/dist/testing/durable/index.js +1 -0
- package/dist/testing/durable/steps.d.ts +30 -0
- package/dist/testing/durable/steps.js +40 -0
- package/dist/testing/index.d.ts +6 -1
- package/dist/testing/index.js +5 -1
- package/dist/tooling/cli/dotenv.d.ts +3 -3
- package/dist/tooling/cli/dotenv.js +3 -3
- package/dist/tooling/cli/log.d.ts +0 -2
- package/dist/tooling/cli/log.js +0 -2
- package/dist/tooling/linter/adapters/typescript/imports.d.ts +0 -1
- package/dist/tooling/linter/adapters/typescript/imports.js +1 -5
- package/dist/tooling/linter/adapters/typescript/index.d.ts +1 -1
- package/dist/tooling/linter/adapters/typescript/index.js +1 -1
- package/dist/tooling/linter/adapters/typescript/syntax.d.ts +3 -3
- package/dist/tooling/linter/adapters/typescript/syntax.js +13 -25
- package/dist/tooling/linter/implementations/source/callable-requirements.d.ts +0 -1
- package/dist/tooling/linter/implementations/source/callable-requirements.js +1 -6
- package/dist/tooling/linter/implementations/source/dependencies.js +12 -1
- package/dist/tooling/linter/implementations/source/mutations.js +1 -12
- package/dist/tooling/linter/implementations/source/queries.js +1 -17
- package/dist/tooling/linter/implementations/source/shared.d.ts +0 -4
- package/dist/tooling/linter/implementations/source/shared.js +1 -33
- package/dist/tooling/linter/implementations/source/workflows.js +5 -15
- package/dist/tooling/linter/policy/generated.js +2 -2
- package/package.json +6 -6
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { bindProviders } from '../../application/integration/index.js';
|
|
2
|
+
import { runtimeCallables } from '../../application/runtime/callables.js';
|
|
3
|
+
import { admitWorkflows } from '../../application/runtime/workflows.js';
|
|
4
|
+
import { durableServing } from '../../execution/workflows/options.js';
|
|
5
|
+
import { durableHandlers } from '../../execution/workflows/run.js';
|
|
6
|
+
import { issuer } from '../../platform/auth/index.js';
|
|
7
|
+
import { NodeId as nodeId } from '../../platform/graph/node/index.js';
|
|
8
|
+
import { testSteps } from './steps.js';
|
|
9
|
+
/** Kernel every test Execution names; Steps never reach it. */
|
|
10
|
+
const TEST_KERNEL = issuer.accept('https://kernel.test.astrale.invalid');
|
|
11
|
+
/** Attempts before a run pauses, as on a Host. */
|
|
12
|
+
const DEFAULT_MAXIMUM_ATTEMPTS = 20;
|
|
13
|
+
/** Microtask turns without progress after which a blocked attempt counts as suspended. */
|
|
14
|
+
const QUIET_TURNS = 64;
|
|
15
|
+
/** Metadata the Kernel reads a declared failure from. */
|
|
16
|
+
const FAILURE = 'astrale.failure';
|
|
17
|
+
class TerminalError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
metadata;
|
|
20
|
+
constructor(message, options) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'TerminalError';
|
|
23
|
+
this.code = options?.errorCode ?? 500;
|
|
24
|
+
if (options?.metadata !== undefined)
|
|
25
|
+
this.metadata = Object.freeze({ ...options.metadata });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
class CancelledError extends TerminalError {
|
|
29
|
+
constructor() {
|
|
30
|
+
super('Cancelled', { errorCode: 409 });
|
|
31
|
+
this.name = 'CancelledError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
class TimeoutError extends TerminalError {
|
|
35
|
+
constructor() {
|
|
36
|
+
super('Timeout occurred', { errorCode: 408 });
|
|
37
|
+
this.name = 'TimeoutError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The attempt lost its Step result on purpose; the engine retries it. */
|
|
41
|
+
class Interruption extends Error {
|
|
42
|
+
constructor(step) {
|
|
43
|
+
super(`Test interruption after Step ${step}, before the journal recorded it.`);
|
|
44
|
+
this.name = 'Interruption';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** The Workflow did not do what its journal recorded at this position. */
|
|
48
|
+
class JournalMismatch extends Error {
|
|
49
|
+
constructor(index, recorded, requested) {
|
|
50
|
+
super(`Journal entry ${index} recorded ${recorded}, but the Workflow requested ${requested}: ` +
|
|
51
|
+
'code between Steps must be deterministic.');
|
|
52
|
+
this.name = 'JournalMismatch';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const ENGINE = Object.freeze({
|
|
56
|
+
createEndpointHandler: () => () => Promise.reject(new Error('A test engine serves no HTTP.')),
|
|
57
|
+
workflow: (definition) => definition,
|
|
58
|
+
TerminalError,
|
|
59
|
+
CancelledError,
|
|
60
|
+
TimeoutError,
|
|
61
|
+
});
|
|
62
|
+
/** Create an in-memory workflow engine for tests. */
|
|
63
|
+
export function createTestEngine(options = {}) {
|
|
64
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAXIMUM_ATTEMPTS;
|
|
65
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) {
|
|
66
|
+
throw new TypeError('Test engine maxAttempts must be a positive integer.');
|
|
67
|
+
}
|
|
68
|
+
let now = options.now ?? Date.now();
|
|
69
|
+
if (!Number.isFinite(now))
|
|
70
|
+
throw new TypeError('Test engine now must be finite.');
|
|
71
|
+
let sequence = 0;
|
|
72
|
+
const runs = new Set();
|
|
73
|
+
const clock = { now: () => now };
|
|
74
|
+
return Object.freeze({
|
|
75
|
+
get now() {
|
|
76
|
+
return now;
|
|
77
|
+
},
|
|
78
|
+
async start(workflow, input) {
|
|
79
|
+
sequence += 1;
|
|
80
|
+
const run = new TestRun(workflow, input, clock, maxAttempts, sequence);
|
|
81
|
+
runs.add(run);
|
|
82
|
+
await run.settle();
|
|
83
|
+
return run.execution;
|
|
84
|
+
},
|
|
85
|
+
async advance(durationMs) {
|
|
86
|
+
if (!Number.isSafeInteger(durationMs) || durationMs < 0) {
|
|
87
|
+
throw new TypeError('Test engine advance takes a non-negative integer of milliseconds.');
|
|
88
|
+
}
|
|
89
|
+
now += durationMs;
|
|
90
|
+
for (const run of runs)
|
|
91
|
+
if (run.state === 'waiting')
|
|
92
|
+
await run.settle();
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
class TestRun {
|
|
97
|
+
clock;
|
|
98
|
+
maxAttempts;
|
|
99
|
+
state = 'running';
|
|
100
|
+
outcome;
|
|
101
|
+
failure;
|
|
102
|
+
attempts = 0;
|
|
103
|
+
journal = [];
|
|
104
|
+
signals = new Map();
|
|
105
|
+
interruptions = new Set();
|
|
106
|
+
cancellation = 'none';
|
|
107
|
+
failures = 0;
|
|
108
|
+
handlers;
|
|
109
|
+
envelope;
|
|
110
|
+
execution;
|
|
111
|
+
constructor(workflow, input, clock, maxAttempts, sequence) {
|
|
112
|
+
this.clock = clock;
|
|
113
|
+
this.maxAttempts = maxAttempts;
|
|
114
|
+
const domain = input.schema;
|
|
115
|
+
const [admitted] = admitWorkflows(runtimeCallables(domain), [workflow]);
|
|
116
|
+
if (admitted === undefined || !admitted.durable) {
|
|
117
|
+
throw new TypeError(`Workflow ${workflow.address} is not durable: its callable must declare execution: { durable: true }.`);
|
|
118
|
+
}
|
|
119
|
+
const providers = (input.providers ?? {});
|
|
120
|
+
const integrations = Object.fromEntries(Object.entries(providers).map(([name, provider]) => [name, provider.integration]));
|
|
121
|
+
this.handlers = durableHandlers({
|
|
122
|
+
kernel: TEST_KERNEL,
|
|
123
|
+
workflow: admitted,
|
|
124
|
+
domain,
|
|
125
|
+
serving: durableServing({ engine: ENGINE }),
|
|
126
|
+
steps: testSteps({
|
|
127
|
+
domain,
|
|
128
|
+
capabilities: (input.capabilities ?? {}),
|
|
129
|
+
integrations: bindProviders(integrations, providers),
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
const id = input.id ?? nodeId(`execution-test-${String(sequence).padStart(6, '0')}`);
|
|
133
|
+
this.envelope = Object.freeze({
|
|
134
|
+
execution: id,
|
|
135
|
+
function: { id: `function-test-${admitted.address}`, version: `sha256:${'0'.repeat(64)}` },
|
|
136
|
+
kernel: TEST_KERNEL,
|
|
137
|
+
initiator: input.initiator ?? { principal: 'principal-test', caller: 'caller-test' },
|
|
138
|
+
input: input.input,
|
|
139
|
+
...(input.self === undefined ? {} : { receiver: input.self }),
|
|
140
|
+
});
|
|
141
|
+
for (const step of (input.interruptAfter ?? [])) {
|
|
142
|
+
this.interruptions.add(`step:${step}`);
|
|
143
|
+
}
|
|
144
|
+
this.execution = this.view(id);
|
|
145
|
+
}
|
|
146
|
+
/** Attempt the run until it ends, waits, or pauses. */
|
|
147
|
+
async settle() {
|
|
148
|
+
if (this.ended || this.state === 'paused')
|
|
149
|
+
return;
|
|
150
|
+
this.state = 'running';
|
|
151
|
+
for (;;) {
|
|
152
|
+
const attempted = await this.attempt('run');
|
|
153
|
+
if (attempted.kind === 'suspended') {
|
|
154
|
+
this.state = 'waiting';
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (attempted.kind === 'completed') {
|
|
158
|
+
this.end({ kind: 'completed', value: attempted.value });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const { error } = attempted;
|
|
162
|
+
if (error instanceof TerminalError) {
|
|
163
|
+
this.end(outcomeOf(error));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.failure = error;
|
|
167
|
+
this.failures += 1;
|
|
168
|
+
if (this.failures >= this.maxAttempts) {
|
|
169
|
+
this.state = 'paused';
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
get ended() {
|
|
175
|
+
return this.state === 'completed' || this.state === 'failed' || this.state === 'cancelled';
|
|
176
|
+
}
|
|
177
|
+
end(outcome) {
|
|
178
|
+
this.outcome = Object.freeze(outcome);
|
|
179
|
+
this.state = outcome.kind;
|
|
180
|
+
}
|
|
181
|
+
/** One attempt of the run: replay the journal, then run on until it ends or blocks. */
|
|
182
|
+
attempt(mode) {
|
|
183
|
+
this.attempts += 1;
|
|
184
|
+
const attempt = new Attempt(this, mode);
|
|
185
|
+
const running = Promise.resolve()
|
|
186
|
+
.then(() => this.handlers.run(attempt.context, this.envelope))
|
|
187
|
+
.then((value) => ({ kind: 'completed', value }), (error) => ({ kind: 'failed', error }))
|
|
188
|
+
.finally(() => attempt.finish());
|
|
189
|
+
return Promise.race([running, attempt.suspended()]).finally(() => attempt.finish());
|
|
190
|
+
}
|
|
191
|
+
async signal(name, value) {
|
|
192
|
+
if (this.ended)
|
|
193
|
+
throw new Error(`Execution ${String(this.envelope.execution)} has ended.`);
|
|
194
|
+
const shared = {
|
|
195
|
+
promise: (promise) => ({
|
|
196
|
+
resolve: async (resolved) => {
|
|
197
|
+
if (this.signals.has(promise))
|
|
198
|
+
throw new TerminalError('Promise already resolved.');
|
|
199
|
+
this.signals.set(promise, resolved);
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
const { accepted } = (await this.handlers.signal(shared, { name, value }));
|
|
204
|
+
if (this.state === 'waiting')
|
|
205
|
+
await this.settle();
|
|
206
|
+
return accepted;
|
|
207
|
+
}
|
|
208
|
+
async cancel() {
|
|
209
|
+
if (this.ended)
|
|
210
|
+
return;
|
|
211
|
+
if (this.cancellation === 'none')
|
|
212
|
+
this.cancellation = 'requested';
|
|
213
|
+
if (this.state === 'paused') {
|
|
214
|
+
this.failures = 0;
|
|
215
|
+
this.state = 'running';
|
|
216
|
+
}
|
|
217
|
+
await this.settle();
|
|
218
|
+
}
|
|
219
|
+
async resume() {
|
|
220
|
+
if (this.state !== 'paused') {
|
|
221
|
+
throw new Error(`Execution ${String(this.envelope.execution)} is not paused.`);
|
|
222
|
+
}
|
|
223
|
+
this.failures = 0;
|
|
224
|
+
this.state = 'running';
|
|
225
|
+
await this.settle();
|
|
226
|
+
}
|
|
227
|
+
async replay() {
|
|
228
|
+
if (this.state === 'paused' || this.state === 'running') {
|
|
229
|
+
throw new Error('Replay an Execution that ended or waits; resume a paused one first.');
|
|
230
|
+
}
|
|
231
|
+
const attempted = await this.attempt('replay');
|
|
232
|
+
if (this.state === 'waiting') {
|
|
233
|
+
if (attempted.kind !== 'suspended')
|
|
234
|
+
throw divergence('ended instead of waiting', attempted);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const outcome = attempted.kind === 'completed'
|
|
238
|
+
? { kind: 'completed', value: attempted.value }
|
|
239
|
+
: attempted.kind === 'failed' && attempted.error instanceof TerminalError
|
|
240
|
+
? outcomeOf(attempted.error)
|
|
241
|
+
: undefined;
|
|
242
|
+
if (outcome === undefined || JSON.stringify(outcome) !== JSON.stringify(this.outcome)) {
|
|
243
|
+
throw divergence(`did not reach ${JSON.stringify(this.outcome)}`, attempted);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
view(id) {
|
|
247
|
+
const live = (read) => ({ enumerable: true, get: read });
|
|
248
|
+
return Object.freeze(Object.defineProperties({
|
|
249
|
+
id,
|
|
250
|
+
signal: (name, value) => this.signal(name, value),
|
|
251
|
+
cancel: () => this.cancel(),
|
|
252
|
+
resume: () => this.resume(),
|
|
253
|
+
replay: () => this.replay(),
|
|
254
|
+
interruptAfter: (step) => {
|
|
255
|
+
this.interruptions.add(`step:${step}`);
|
|
256
|
+
},
|
|
257
|
+
}, {
|
|
258
|
+
state: live(() => this.state),
|
|
259
|
+
outcome: live(() => this.outcome),
|
|
260
|
+
failure: live(() => this.failure),
|
|
261
|
+
attempts: live(() => this.attempts),
|
|
262
|
+
journal: live(() => Object.freeze(this.journal.map(snapshot))),
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The engine context of one attempt. Every operation takes the next journal position: a settled
|
|
268
|
+
* entry answers as it did, a pending one waits, and a new one runs. A replay records nothing new.
|
|
269
|
+
*/
|
|
270
|
+
class Attempt {
|
|
271
|
+
run;
|
|
272
|
+
mode;
|
|
273
|
+
cursor = 0;
|
|
274
|
+
active = 0;
|
|
275
|
+
blocked = 0;
|
|
276
|
+
progress = 0;
|
|
277
|
+
done = false;
|
|
278
|
+
controller = new AbortController();
|
|
279
|
+
context;
|
|
280
|
+
constructor(run, mode) {
|
|
281
|
+
this.run = run;
|
|
282
|
+
this.mode = mode;
|
|
283
|
+
this.context = Object.freeze({
|
|
284
|
+
run: (name, action) => this.journalRun(name, action),
|
|
285
|
+
sleep: (durationMs) => this.sleep(durationMs),
|
|
286
|
+
promise: (name) => ({ get: () => this.promise(name) }),
|
|
287
|
+
request: () => ({ attemptCompletedSignal: this.controller.signal }),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
finish() {
|
|
291
|
+
if (this.done)
|
|
292
|
+
return;
|
|
293
|
+
this.done = true;
|
|
294
|
+
this.controller.abort();
|
|
295
|
+
}
|
|
296
|
+
/** Resolves once the attempt waits on a pending entry and nothing else can move it. */
|
|
297
|
+
async suspended() {
|
|
298
|
+
for (;;) {
|
|
299
|
+
if (this.done)
|
|
300
|
+
return new Promise(() => { });
|
|
301
|
+
if (this.active > 0) {
|
|
302
|
+
await macrotask();
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const before = this.progress;
|
|
306
|
+
for (let turn = 0; turn < QUIET_TURNS && this.progress === before; turn++) {
|
|
307
|
+
await Promise.resolve();
|
|
308
|
+
}
|
|
309
|
+
if (this.done)
|
|
310
|
+
return new Promise(() => { });
|
|
311
|
+
if (this.progress !== before)
|
|
312
|
+
continue;
|
|
313
|
+
if (this.blocked > 0)
|
|
314
|
+
return { kind: 'suspended' };
|
|
315
|
+
await macrotask();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
take(kind, name) {
|
|
319
|
+
this.progress += 1;
|
|
320
|
+
const index = this.cursor++;
|
|
321
|
+
const recorded = this.run.journal[index];
|
|
322
|
+
if (recorded !== undefined) {
|
|
323
|
+
if (recorded.kind !== kind || recorded.name !== name) {
|
|
324
|
+
throw new JournalMismatch(index, describe(recorded), describe({ kind, name }));
|
|
325
|
+
}
|
|
326
|
+
return recorded;
|
|
327
|
+
}
|
|
328
|
+
if (this.mode === 'replay') {
|
|
329
|
+
throw new JournalMismatch(index, 'nothing', describe({ kind, name }));
|
|
330
|
+
}
|
|
331
|
+
const entry = { kind, name, settled: false };
|
|
332
|
+
this.run.journal.push(entry);
|
|
333
|
+
return entry;
|
|
334
|
+
}
|
|
335
|
+
/** Deliver a requested cancellation to the first entry that is not settled yet. */
|
|
336
|
+
cancelled(entry) {
|
|
337
|
+
if (this.mode === 'replay' || this.run.cancellation !== 'requested')
|
|
338
|
+
return false;
|
|
339
|
+
this.run.cancellation = 'delivered';
|
|
340
|
+
entry.settled = true;
|
|
341
|
+
entry.error = new CancelledError();
|
|
342
|
+
if (entry.kind === 'race')
|
|
343
|
+
entry.outcome = 'cancelled';
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
answer(entry) {
|
|
347
|
+
return entry.error === undefined
|
|
348
|
+
? Promise.resolve(entry.value)
|
|
349
|
+
: Promise.reject(entry.error);
|
|
350
|
+
}
|
|
351
|
+
block() {
|
|
352
|
+
this.blocked += 1;
|
|
353
|
+
this.progress += 1;
|
|
354
|
+
return new Promise(() => { });
|
|
355
|
+
}
|
|
356
|
+
journalRun(name, action) {
|
|
357
|
+
let entry;
|
|
358
|
+
try {
|
|
359
|
+
entry = this.take('run', name);
|
|
360
|
+
}
|
|
361
|
+
catch (cause) {
|
|
362
|
+
return Promise.reject(cause);
|
|
363
|
+
}
|
|
364
|
+
if (entry.settled)
|
|
365
|
+
return this.answer(entry);
|
|
366
|
+
if (this.cancelled(entry))
|
|
367
|
+
return this.answer(entry);
|
|
368
|
+
if (this.mode === 'replay') {
|
|
369
|
+
return Promise.reject(new JournalMismatch(this.cursor - 1, 'a pending entry', `${name} to run again`));
|
|
370
|
+
}
|
|
371
|
+
this.active += 1;
|
|
372
|
+
return (async () => {
|
|
373
|
+
try {
|
|
374
|
+
const value = await action();
|
|
375
|
+
if (this.run.interruptions.delete(name))
|
|
376
|
+
throw new Interruption(name.replace(/^step:/u, ''));
|
|
377
|
+
entry.settled = true;
|
|
378
|
+
entry.value = value;
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
catch (cause) {
|
|
382
|
+
if (cause instanceof TerminalError) {
|
|
383
|
+
entry.settled = true;
|
|
384
|
+
entry.error = cause;
|
|
385
|
+
}
|
|
386
|
+
throw cause;
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
this.active -= 1;
|
|
390
|
+
this.progress += 1;
|
|
391
|
+
}
|
|
392
|
+
})();
|
|
393
|
+
}
|
|
394
|
+
sleep(durationMs) {
|
|
395
|
+
let entry;
|
|
396
|
+
try {
|
|
397
|
+
entry = this.take('sleep', '');
|
|
398
|
+
}
|
|
399
|
+
catch (cause) {
|
|
400
|
+
return Promise.reject(cause);
|
|
401
|
+
}
|
|
402
|
+
entry.until ??= this.run.clock.now() + durationMs;
|
|
403
|
+
if (entry.settled)
|
|
404
|
+
return this.answer(entry);
|
|
405
|
+
if (this.run.clock.now() >= entry.until) {
|
|
406
|
+
if (this.mode === 'run')
|
|
407
|
+
entry.settled = true;
|
|
408
|
+
return Promise.resolve();
|
|
409
|
+
}
|
|
410
|
+
if (this.cancelled(entry))
|
|
411
|
+
return this.answer(entry);
|
|
412
|
+
return this.block();
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The engine promise of signal `name`. Awaited, it waits for the signal; `orTimeout` races it
|
|
416
|
+
* against a timer instead, so only one of the two takes a journal position.
|
|
417
|
+
*/
|
|
418
|
+
promise(name) {
|
|
419
|
+
let raced = false;
|
|
420
|
+
const awaited = Promise.resolve().then(() => (raced ? new Promise(() => { }) : this.wait(name)));
|
|
421
|
+
return Object.assign(awaited, {
|
|
422
|
+
orTimeout: (millis) => {
|
|
423
|
+
raced = true;
|
|
424
|
+
return this.race(name, millis);
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
wait(name) {
|
|
429
|
+
let entry;
|
|
430
|
+
try {
|
|
431
|
+
entry = this.take('signal', name);
|
|
432
|
+
}
|
|
433
|
+
catch (cause) {
|
|
434
|
+
return Promise.reject(cause);
|
|
435
|
+
}
|
|
436
|
+
if (entry.settled)
|
|
437
|
+
return this.answer(entry);
|
|
438
|
+
if (this.run.signals.has(name))
|
|
439
|
+
return this.settle(entry, this.run.signals.get(name));
|
|
440
|
+
if (this.cancelled(entry))
|
|
441
|
+
return this.answer(entry);
|
|
442
|
+
return this.block();
|
|
443
|
+
}
|
|
444
|
+
race(name, millis) {
|
|
445
|
+
let entry;
|
|
446
|
+
try {
|
|
447
|
+
entry = this.take('race', name);
|
|
448
|
+
}
|
|
449
|
+
catch (cause) {
|
|
450
|
+
return Promise.reject(cause);
|
|
451
|
+
}
|
|
452
|
+
entry.until ??= this.run.clock.now() + millis;
|
|
453
|
+
if (entry.settled)
|
|
454
|
+
return this.answer(entry);
|
|
455
|
+
if (this.run.signals.has(name)) {
|
|
456
|
+
if (this.mode === 'run')
|
|
457
|
+
entry.outcome = 'signal';
|
|
458
|
+
return this.settle(entry, this.run.signals.get(name));
|
|
459
|
+
}
|
|
460
|
+
if (this.run.clock.now() >= entry.until) {
|
|
461
|
+
if (this.mode === 'run') {
|
|
462
|
+
entry.settled = true;
|
|
463
|
+
entry.outcome = 'timeout';
|
|
464
|
+
entry.error = new TimeoutError();
|
|
465
|
+
}
|
|
466
|
+
return Promise.reject(new TimeoutError());
|
|
467
|
+
}
|
|
468
|
+
if (this.cancelled(entry))
|
|
469
|
+
return this.answer(entry);
|
|
470
|
+
return this.block();
|
|
471
|
+
}
|
|
472
|
+
settle(entry, value) {
|
|
473
|
+
if (this.mode === 'run') {
|
|
474
|
+
entry.settled = true;
|
|
475
|
+
entry.value = value;
|
|
476
|
+
}
|
|
477
|
+
return Promise.resolve(value);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function outcomeOf(error) {
|
|
481
|
+
const declared = error.metadata?.[FAILURE];
|
|
482
|
+
if (declared !== undefined) {
|
|
483
|
+
const failure = JSON.parse(declared);
|
|
484
|
+
return {
|
|
485
|
+
kind: 'failed',
|
|
486
|
+
error: Object.freeze({
|
|
487
|
+
...(failure.family === undefined ? {} : { family: failure.family }),
|
|
488
|
+
code: failure.code,
|
|
489
|
+
message: failure.message,
|
|
490
|
+
...(failure.details === undefined ? {} : { details: failure.details }),
|
|
491
|
+
}),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
// As the Kernel decodes it: a bare 409 is the engine's cancellation.
|
|
495
|
+
if (error.code === 409)
|
|
496
|
+
return { kind: 'cancelled' };
|
|
497
|
+
return {
|
|
498
|
+
kind: 'failed',
|
|
499
|
+
error: Object.freeze({ code: String(error.code), message: error.message }),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function snapshot(entry) {
|
|
503
|
+
switch (entry.kind) {
|
|
504
|
+
case 'run':
|
|
505
|
+
return Object.freeze({
|
|
506
|
+
kind: 'run',
|
|
507
|
+
name: entry.name,
|
|
508
|
+
settled: entry.settled,
|
|
509
|
+
...(entry.settled && entry.error === undefined ? { value: entry.value } : {}),
|
|
510
|
+
...(entry.error === undefined ? {} : { error: entry.error }),
|
|
511
|
+
});
|
|
512
|
+
case 'sleep':
|
|
513
|
+
return Object.freeze({ kind: 'sleep', until: entry.until, settled: entry.settled });
|
|
514
|
+
case 'signal':
|
|
515
|
+
return Object.freeze({
|
|
516
|
+
kind: 'signal',
|
|
517
|
+
name: entry.name,
|
|
518
|
+
settled: entry.settled,
|
|
519
|
+
...(entry.settled && entry.error === undefined ? { value: entry.value } : {}),
|
|
520
|
+
});
|
|
521
|
+
case 'race':
|
|
522
|
+
return Object.freeze({
|
|
523
|
+
kind: 'race',
|
|
524
|
+
name: entry.name,
|
|
525
|
+
until: entry.until,
|
|
526
|
+
settled: entry.settled,
|
|
527
|
+
...(entry.outcome === undefined ? {} : { outcome: entry.outcome }),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function describe(entry) {
|
|
532
|
+
return entry.name === '' ? entry.kind : `${entry.kind} ${entry.name}`;
|
|
533
|
+
}
|
|
534
|
+
function divergence(reason, attempted) {
|
|
535
|
+
const cause = attempted.kind === 'failed' ? attempted.error : undefined;
|
|
536
|
+
return new Error(`Replay diverged: the Workflow ${reason}.`, { cause });
|
|
537
|
+
}
|
|
538
|
+
function macrotask() {
|
|
539
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
540
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createTestEngine } from './engine.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IntegrationClientFactory, Integrations } from '../../application/integration/index.js';
|
|
2
|
+
import type { StepAuthorities } from '../../execution/workflows/steps.js';
|
|
3
|
+
import type { BoundClientSession } from '../../platform/client/session/index.js';
|
|
4
|
+
import type { Domain } from '../../platform/schema/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* What the Steps of a test Execution reach instead of a Kernel. Each answers the calls a Step makes
|
|
7
|
+
* through its capabilities; an absent one fails the call that needs it.
|
|
8
|
+
*/
|
|
9
|
+
export interface TestStepCapabilities {
|
|
10
|
+
/** Answers each Query a Step runs, through `query`, `graph.query` or `step.query`. */
|
|
11
|
+
readonly query?: (query: object, input: unknown) => unknown;
|
|
12
|
+
/**
|
|
13
|
+
* Answers each Mutation a Step runs, through `mutate`, `graph.mutate` or `step.mutate`. `call`
|
|
14
|
+
* carries the key the Host engine sends, so a retried Step presents the same one.
|
|
15
|
+
*/
|
|
16
|
+
readonly mutate?: (mutation: object, input: unknown, call: {
|
|
17
|
+
readonly idempotencyKey: string;
|
|
18
|
+
}) => unknown;
|
|
19
|
+
/**
|
|
20
|
+
* The Kernel Session of a Step. `kernel.invoke` and every `dependencies` call reach its `invoke`,
|
|
21
|
+
* with the Step's next key in `options.idempotencyKey` when the callee is durable.
|
|
22
|
+
*/
|
|
23
|
+
readonly kernel?: Partial<BoundClientSession>;
|
|
24
|
+
}
|
|
25
|
+
/** Bind each Step attempt of a test Execution to the test's capabilities, keyed as on a Host. */
|
|
26
|
+
export declare function testSteps(input: {
|
|
27
|
+
readonly domain: Domain;
|
|
28
|
+
readonly capabilities: TestStepCapabilities;
|
|
29
|
+
readonly integrations: IntegrationClientFactory<Integrations>;
|
|
30
|
+
}): StepAuthorities;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { bindDependencies } from '../../execution/invocation/dependency/index.js';
|
|
2
|
+
import { stepCalls } from '../../execution/workflows/integrations.js';
|
|
3
|
+
import { keyed } from '../../execution/workflows/steps.js';
|
|
4
|
+
/** Bind each Step attempt of a test Execution to the test's capabilities, keyed as on a Host. */
|
|
5
|
+
export function testSteps(input) {
|
|
6
|
+
const { domain, capabilities, integrations } = input;
|
|
7
|
+
return ({ next, lifetime }) => {
|
|
8
|
+
const session = keyed({
|
|
9
|
+
...capabilities.kernel,
|
|
10
|
+
invoke: capabilities.kernel?.invoke ?? absent('kernel.invoke'),
|
|
11
|
+
mutate: capabilities.kernel?.mutate ?? absent('kernel.mutate'),
|
|
12
|
+
}, domain, next);
|
|
13
|
+
const query = (definition, value) => call(capabilities.query, 'query', (answer) => answer(definition, value));
|
|
14
|
+
const mutate = (definition, value) => call(capabilities.mutate, 'mutate', (answer) => answer(definition, value, Object.freeze({ idempotencyKey: next() })));
|
|
15
|
+
const graph = Object.freeze({ query, mutate });
|
|
16
|
+
const auth = Object.freeze({
|
|
17
|
+
mint: (request) => call(capabilities.kernel?.auth?.mint, 'kernel.auth.mint', (mint) => mint(request)),
|
|
18
|
+
});
|
|
19
|
+
const authority = Object.freeze({
|
|
20
|
+
kernel: Object.freeze({ ...session, self: session }),
|
|
21
|
+
dependencies: bindDependencies(domain, { self: session }),
|
|
22
|
+
graph: Object.freeze({ ...graph, self: graph }),
|
|
23
|
+
query,
|
|
24
|
+
mutate,
|
|
25
|
+
integrations: integrations(stepCalls({ next, lifetime, auth })),
|
|
26
|
+
});
|
|
27
|
+
return Object.freeze({ authority, close() { } });
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function call(answer, name, apply) {
|
|
31
|
+
if (answer === undefined)
|
|
32
|
+
return Promise.reject(missing(name));
|
|
33
|
+
return Promise.resolve().then(() => apply(answer));
|
|
34
|
+
}
|
|
35
|
+
function absent(name) {
|
|
36
|
+
return () => Promise.reject(missing(name));
|
|
37
|
+
}
|
|
38
|
+
function missing(name) {
|
|
39
|
+
return new Error(`The test Execution supplies no ${name}: add it to its capabilities.`);
|
|
40
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Development and test resources: schema-admitted demo Datasets, their lazy project references, and
|
|
3
|
+
* an in-memory engine that runs durable Workflows.
|
|
4
|
+
*/
|
|
2
5
|
export { DATASET_FORMAT, DATASET_VERSION, DatasetAdmissionError, decodeDataset, defineDataset, encodeDataset, isDataset, } from './dataset/index.js';
|
|
3
6
|
export type { AuthoredDatasetVariables, Dataset, DatasetClasses, DatasetEdgeInput, DatasetEndpoint, DatasetGraph, DatasetInput, DatasetJson, DatasetNode, DatasetNodeInput, DatasetReferences, DatasetReferenceInput, DatasetVariable, DatasetVariableJson, DatasetVariables, DatasetVariablesOf, SchemaOfDataset, } from './dataset/index.js';
|
|
4
7
|
export { loadDataset, loadDatasets } from './load.js';
|
|
@@ -7,3 +10,5 @@ export { dataset, isDatasetReference } from './reference.js';
|
|
|
7
10
|
export type { DatasetReference } from './reference.js';
|
|
8
11
|
export { isTests, tests } from './tests.js';
|
|
9
12
|
export type { Tests, TestsInput } from './tests.js';
|
|
13
|
+
export { createTestEngine } from './durable/index.js';
|
|
14
|
+
export type { TestEngine, TestEngineOptions, TestExecution, TestJournalEntry, TestStartOptions, TestStepCapabilities, } from './durable/index.js';
|
package/dist/testing/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Development and test resources: schema-admitted demo Datasets, their lazy project references, and
|
|
3
|
+
* an in-memory engine that runs durable Workflows.
|
|
4
|
+
*/
|
|
2
5
|
export { DATASET_FORMAT, DATASET_VERSION, DatasetAdmissionError, decodeDataset, defineDataset, encodeDataset, isDataset, } from './dataset/index.js';
|
|
3
6
|
export { loadDataset, loadDatasets } from './load.js';
|
|
4
7
|
export { dataset, isDatasetReference } from './reference.js';
|
|
5
8
|
export { isTests, tests } from './tests.js';
|
|
9
|
+
export { createTestEngine } from './durable/index.js';
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal dotenv parser — the secrets-file boundary.
|
|
3
3
|
*
|
|
4
|
-
* `secrets: '.env.<env>'` in an adapter's params points a gitignored file whose
|
|
4
|
+
* `secrets: '.env.<env>'` in an adapter's params points to a gitignored file whose
|
|
5
5
|
* entire contents are secrets. `loadDeclaredSecrets` reads it into a flat
|
|
6
|
-
* `Record<string,string>` the CLI hands the adapter
|
|
7
|
-
*
|
|
6
|
+
* `Record<string,string>` the CLI hands the adapter, which binds it to the
|
|
7
|
+
* Environment's deployment. No `process.env` mutation,
|
|
8
8
|
* no interpolation magic beyond `${VAR}` against earlier keys in the same file.
|
|
9
9
|
*/
|
|
10
10
|
/** Parse one dotenv document into a flat record; `${VAR}` reads only earlier keys. */
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal dotenv parser — the secrets-file boundary.
|
|
3
3
|
*
|
|
4
|
-
* `secrets: '.env.<env>'` in an adapter's params points a gitignored file whose
|
|
4
|
+
* `secrets: '.env.<env>'` in an adapter's params points to a gitignored file whose
|
|
5
5
|
* entire contents are secrets. `loadDeclaredSecrets` reads it into a flat
|
|
6
|
-
* `Record<string,string>` the CLI hands the adapter
|
|
7
|
-
*
|
|
6
|
+
* `Record<string,string>` the CLI hands the adapter, which binds it to the
|
|
7
|
+
* Environment's deployment. No `process.env` mutation,
|
|
8
8
|
* no interpolation magic beyond `${VAR}` against earlier keys in the same file.
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync } from 'node:fs';
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/** CLI output helpers and ANSI colours for the one project command owner. */
|
|
2
|
-
export declare const GREEN = "\u001B[32m";
|
|
3
2
|
export declare const DIM = "\u001B[2m";
|
|
4
|
-
export declare const BOLD = "\u001B[1m";
|
|
5
3
|
export declare const YELLOW = "\u001B[33m";
|
|
6
4
|
export declare const RED = "\u001B[31m";
|
|
7
5
|
export declare const RESET = "\u001B[0m";
|