@astrale-os/sdk 0.6.0-beta.40 → 0.6.0-beta.41

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.
@@ -11,6 +11,8 @@ export interface DurableRun {
11
11
  readonly serving: AdmittedDurableServing;
12
12
  /** What each Step attempt holds. */
13
13
  readonly steps: StepAuthorities;
14
+ /** The clock that times each Step attempt against its Step's `maxDurationMs`; `Date.now` by default. */
15
+ readonly now?: () => number;
14
16
  }
15
17
  /** Engine handlers of one durable Workflow: the `run` and the shared `signal`. */
16
18
  export declare function durableHandlers(input: DurableRun): Readonly<{
@@ -1,17 +1,14 @@
1
1
  import { declaredErrorFactory } from '../../application/error/index.js';
2
2
  import { acceptStepId } from '../../application/workflow/step/index.js';
3
3
  import { acceptStepOutput } from '../../application/workflow/step/output.js';
4
- import { ResponseError } from '../../platform/client/index.js';
5
4
  import { validateOutput } from '../invocation/result/index.js';
5
+ import { BACKOFF, UNAVAILABLE, failedAttemptError, failedAttemptOf, nextAttempt, } from './attempts.js';
6
6
  import { decodeEnvelope } from './envelope.js';
7
- import { delayedRetry, replayedFailure, StepExhausted, StepRetryAfter, attemptFailure, terminalFailure, } from './failure.js';
7
+ import { replayedFailure, StepExhausted, StepRetryAfter, terminalFailure } from './failure.js';
8
8
  import { stepServing } from './options.js';
9
9
  import { keySequence } from './steps.js';
10
10
  const MAXIMUM_SLEEP_MS = 365 * 24 * 60 * 60_000;
11
11
  const MAXIMUM_SIGNAL_NAME = 128;
12
- /** Bounds of one `result` wait and of the durable pause between two of them. */
13
- const AWAIT_WAIT_MS = 10_000;
14
- const AWAIT_MAXIMUM_PAUSE_MS = 5 * 60_000;
15
12
  /** Engine handlers of one durable Workflow: the `run` and the shared `signal`. */
16
13
  export function durableHandlers(input) {
17
14
  const { engine } = input.serving;
@@ -95,52 +92,84 @@ function durableContext(input, context, envelope) {
95
92
  running = undefined;
96
93
  });
97
94
  };
95
+ const now = input.now ?? Date.now;
98
96
  /**
99
- * One journaled unit of work holding the capabilities of an Action as the Domain alone. Its
100
- * Mutations, durable calls and Integration calls share one key sequence derived from its name.
101
- * With a `retry` budget, the engine retries a failed attempt within it, after its backoff or the
102
- * delay the failure prescribes, and the Step throws `StepExhausted` once it is spent; without
103
- * one, a failed attempt fails the run's attempt, which the engine retries until it pauses.
97
+ * One attempt of Step `id`, holding the capabilities of an Action as the Domain alone. Its
98
+ * Mutations, durable calls and Integration calls share one key sequence derived from the Step's
99
+ * name, which every attempt derives again. The engine journals its outcome: the Step's output, its
100
+ * declared error, or the failed attempt, as `failedAttemptError` records it. The Step decides from
101
+ * that journal alone whether to go on, so a replay decides alike.
104
102
  *
105
103
  * The engine promise is returned unawaited: awaiting it is what tells the engine the run waits on
106
104
  * it, so parallel Steps hand theirs to the engine's own combinator instead.
107
105
  */
108
- const journaled = (id, name, timeoutMs, retry, execute) => context.run(name, async () => {
109
- const expiry = new AbortController();
110
- const timer = setTimeout(() => expiry.abort(new Error(`Workflow Step ${id} exceeded its ${timeoutMs} ms timeout.`)), timeoutMs);
111
- let step;
112
- try {
113
- const lifetime = {
114
- deadline: Date.now() + timeoutMs,
115
- signal: AbortSignal.any([signal, expiry.signal]),
116
- };
117
- const next = await keySequence(execution.id, name);
118
- const { authority } = (step = input.steps({ name, execution, next, lifetime }));
119
- const output = await Promise.race([
120
- Promise.resolve().then(() => execute(authority)),
121
- expired(expiry.signal),
122
- ]);
106
+ const attempt = (id, held, execute) => {
107
+ const name = `step:${id}`;
108
+ const { timeoutMs } = held;
109
+ return context.run(name, async () => {
110
+ const startedAt = now();
111
+ const expiry = new AbortController();
112
+ const timer = setTimeout(() => expiry.abort(new Error(`Workflow Step ${id} exceeded its ${timeoutMs} ms timeout.`)), timeoutMs);
113
+ let step;
123
114
  try {
124
- return acceptStepOutput(output);
115
+ const lifetime = {
116
+ deadline: Date.now() + timeoutMs,
117
+ signal: AbortSignal.any([signal, expiry.signal]),
118
+ };
119
+ const next = await keySequence(execution.id, name);
120
+ const { authority } = (step = input.steps({ name, execution, next, lifetime }));
121
+ const output = await Promise.race([
122
+ Promise.resolve().then(() => execute(authority)),
123
+ expired(expiry.signal),
124
+ ]);
125
+ try {
126
+ return acceptStepOutput(output);
127
+ }
128
+ catch (cause) {
129
+ throw new TypeError(`Workflow ${name} output must be portable.`, { cause });
130
+ }
125
131
  }
126
132
  catch (cause) {
127
- throw new TypeError(`Workflow ${name} output must be portable.`, { cause });
133
+ throw (terminalFailure(engine, cause, definition) ??
134
+ failedAttemptError(engine, cause, startedAt, now(), step?.repeatsEffects() ?? false));
128
135
  }
136
+ finally {
137
+ clearTimeout(timer);
138
+ step?.close();
139
+ }
140
+ });
141
+ };
142
+ /**
143
+ * How Step `id` goes on after an attempt that failed with the journaled `outcome`, given what its
144
+ * earlier attempts spent. A declared error, an exhaustion the Step threw again, and the engine's
145
+ * cancellation are final: they throw, as the Workflow sees them.
146
+ */
147
+ const afterFailure = (id, held, spent, outcome) => {
148
+ const failed = failedAttemptOf(engine, outcome);
149
+ if (failed === undefined)
150
+ throw replayedFailure(engine, outcome, definition, id) ?? outcome;
151
+ return nextAttempt(spent, failed, held.retry);
152
+ };
153
+ /**
154
+ * One Step, awaited alone: attempted until it succeeds, fails for good or spends its budget, each
155
+ * attempt after a durable wait the failure of the previous one prescribes.
156
+ */
157
+ const attempts = async (id, held, execute) => {
158
+ let spent;
159
+ for (;;) {
160
+ let next;
161
+ try {
162
+ return await attempt(id, held, execute);
163
+ }
164
+ catch (outcome) {
165
+ next = afterFailure(id, held, spent, outcome);
166
+ }
167
+ if (next.kind === 'exhausted')
168
+ throw new StepExhausted(id, next.refusal);
169
+ spent = next.spending;
170
+ await context.sleep(next.delayMs, `${next.wait}:step:${id}`);
129
171
  }
130
- catch (cause) {
131
- throw (terminalFailure(engine, cause, definition) ??
132
- delayedRetry(engine, cause) ??
133
- attemptFailure(cause));
134
- }
135
- finally {
136
- clearTimeout(timer);
137
- step?.close();
138
- }
139
- }, retry);
140
- /** One Step, awaited alone; its failure reaches the Workflow as `replayedFailure` reads it. */
141
- const journal = (id, name, timeoutMs, retry, execute) => Promise.resolve(journaled(id, name, timeoutMs, retry, execute)).catch((cause) => {
142
- throw replayedFailure(engine, cause, definition, id) ?? cause;
143
- });
172
+ };
144
173
  const step = (declared, execute) => {
145
174
  let accepted;
146
175
  try {
@@ -152,13 +181,15 @@ function durableContext(input, context, envelope) {
152
181
  return Promise.reject(cause);
153
182
  }
154
183
  steps.add(accepted.id);
155
- const { held } = accepted;
156
- return sequenced(`Step ${accepted.id}`, () => journal(accepted.id, `step:${accepted.id}`, held.timeoutMs, held.retry, execute));
184
+ return sequenced(`Step ${accepted.id}`, () => attempts(accepted.id, accepted.held, execute));
157
185
  };
158
186
  /**
159
- * The Steps `declare` declares, started together and awaited through the engine's combinator as
160
- * one operation. Once all have settled, their outputs in declaration order, or the failure of the
161
- * first failed one in that order, as that Step alone fails.
187
+ * The Steps `declare` declares, attempted together in rounds, each round awaited through the
188
+ * engine's combinator as one operation. A Step whose attempt failed and that goes on is attempted
189
+ * again in the next round, after one durable wait, the longest any of them prescribes; each Step
190
+ * spends its own budget. Once all have settled, their outputs in declaration order, or the failure
191
+ * of the first failed one in that order, as that Step alone fails. A cancellation ends the round
192
+ * it reaches.
162
193
  */
163
194
  const parallel = (declare) => {
164
195
  const declared = new Map();
@@ -201,14 +232,50 @@ function durableContext(input, context, envelope) {
201
232
  for (const { id } of group)
202
233
  steps.add(id);
203
234
  return sequenced(`parallel Steps ${group.map(({ id }) => id).join(', ')}`, async () => {
204
- const started = group.map(({ id, held, execute }) => journaled(id, `step:${id}`, held.timeoutMs, held.retry, execute));
205
- const settled = await engine.RestatePromise.allSettled(started);
206
- const failed = settled.findIndex(({ status }) => status === 'rejected');
207
- if (failed === -1) {
208
- return settled.map((result) => result.value);
235
+ const states = group.map((declared) => ({
236
+ ...declared,
237
+ spent: undefined,
238
+ result: undefined,
239
+ }));
240
+ for (;;) {
241
+ const pending = states.filter(({ result }) => result === undefined);
242
+ const settled = await engine.RestatePromise.allSettled(pending.map(({ id, held, execute }) => attempt(id, held, execute)));
243
+ const cancelled = settled.find((outcome) => outcome.status === 'rejected' && outcome.reason instanceof engine.CancelledError);
244
+ if (cancelled !== undefined)
245
+ throw cancelled.reason;
246
+ let delayMs = 0;
247
+ let wait = BACKOFF;
248
+ settled.forEach((outcome, index) => {
249
+ const state = pending[index];
250
+ if (outcome.status === 'fulfilled') {
251
+ state.result = outcome;
252
+ return;
253
+ }
254
+ let next;
255
+ try {
256
+ next = afterFailure(state.id, state.held, state.spent, outcome.reason);
257
+ }
258
+ catch (failure) {
259
+ state.result = { status: 'rejected', reason: failure };
260
+ return;
261
+ }
262
+ if (next.kind === 'exhausted') {
263
+ state.result = { status: 'rejected', reason: new StepExhausted(state.id, next.refusal) };
264
+ return;
265
+ }
266
+ state.spent = next.spending;
267
+ delayMs = Math.max(delayMs, next.delayMs);
268
+ if (next.wait === UNAVAILABLE)
269
+ wait = UNAVAILABLE;
270
+ });
271
+ if (states.every(({ result }) => result !== undefined))
272
+ break;
273
+ await context.sleep(delayMs, `${wait}:parallel`);
209
274
  }
210
- const cause = settled[failed].reason;
211
- throw replayedFailure(engine, cause, definition, group[failed].id) ?? cause;
275
+ const failed = states.find(({ result }) => result.status === 'rejected');
276
+ if (failed !== undefined)
277
+ throw failed.result.reason;
278
+ return states.map(({ result }) => result.value);
212
279
  });
213
280
  };
214
281
  return Object.freeze({
@@ -253,29 +320,6 @@ function durableContext(input, context, envelope) {
253
320
  cancelled: (error) => error instanceof engine.CancelledError,
254
321
  exhausted: (error) => error instanceof StepExhausted,
255
322
  retryAfter: (delayMs, cause) => new StepRetryAfter(delayMs, cause),
256
- awaitExecution: (target) => sequenced(`wait for Execution ${target.id}`, async () => {
257
- for (let poll = 1;; poll++) {
258
- // A poll is no Step of the author's: it has no budget, so an outage pauses the run instead.
259
- const name = `await:${target.id}:${poll}`;
260
- const polled = await journal(name, name, serving.step.timeoutMs, undefined, async ({ kernel }) => {
261
- try {
262
- const outcome = await kernel.execution.result(target, {
263
- waitMs: Math.min(AWAIT_WAIT_MS, Math.floor(serving.step.timeoutMs / 2)),
264
- });
265
- return { ended: true, outcome };
266
- }
267
- catch (cause) {
268
- if (cause instanceof ResponseError && cause.reason?.code === 'EXECUTION_PENDING') {
269
- return { ended: false };
270
- }
271
- throw cause;
272
- }
273
- });
274
- if (polled.ended)
275
- return polled.outcome;
276
- await context.sleep(Math.min(AWAIT_MAXIMUM_PAUSE_MS, 1_000 * 2 ** Math.min(poll, 16)));
277
- }
278
- }),
279
323
  ...(execution.receiver === undefined ? {} : { self: execution.receiver }),
280
324
  });
281
325
  }
@@ -16,6 +16,8 @@ export interface StepAttempt {
16
16
  /** What one Step attempt holds, released once the attempt ends. */
17
17
  export interface StepAuthorityBinding {
18
18
  readonly authority: DurableStepAuthority;
19
+ /** Whether the attempt ran an `invoke` without a key, which the next attempt runs again. */
20
+ repeatsEffects(): boolean;
19
21
  close(): void;
20
22
  }
21
23
  /** Bind the capabilities one Step attempt holds. */
@@ -35,8 +37,12 @@ export declare function sessionSteps(input: {
35
37
  * replays the same keys: the Kernel answers a Mutation with its receipt and a durable call with the
36
38
  * Execution it started, or refuses a request that changed under its key. Any other `invoke` carries
37
39
  * no key, because the Kernel accepts one only for a durable Function, and runs again with the Step.
40
+ *
41
+ * A keyed call and a Query repeat no effect when sent again, so their failures are marked
42
+ * resendable: an unreachable Kernel then costs the Step no attempt. `unkeyed` hears each `invoke`
43
+ * without a key that may have run.
38
44
  */
39
- export declare function keyed<Session extends Pick<BoundClientSession, 'invoke' | 'mutate'>>(session: Session, domain: Domain, next: () => string): Session;
45
+ export declare function keyed<Session extends Pick<BoundClientSession, 'invoke' | 'mutate'>>(session: Session, domain: Domain, next: () => string, unkeyed: () => void): Session;
40
46
  /** The key sequence of one Step of one Execution: `<execution>.<digest of the name>.<position>`. */
41
47
  export declare function keySequence(execution: DurableExecution['id'], name: string): Promise<() => string>;
42
48
  export {};
@@ -1,6 +1,7 @@
1
1
  import { bindSession } from '../../platform/client/session/index.js';
2
2
  import { bindCapabilities } from '../invocation/capabilities/index.js';
3
3
  import { bindIntegrationAuthentication } from '../invocation/integration/index.js';
4
+ import { resendableCall, unkeyedCall } from './attempts.js';
4
5
  import { stepCalls } from './integrations.js';
5
6
  /**
6
7
  * Steps acting as the Domain through a Session of the Execution they belong to: the capabilities of
@@ -12,13 +13,20 @@ export function sessionSteps(input) {
12
13
  const session = executor(execution.kernel, execution.id, lifetime);
13
14
  try {
14
15
  const bound = bindSession(session, lifetime);
15
- const self = keyed(bound, domain, next);
16
+ let repeats = false;
17
+ const self = keyed(bound, domain, next, () => {
18
+ repeats = true;
19
+ });
16
20
  const auth = bindIntegrationAuthentication(lifetime, bound);
17
21
  const authority = Object.freeze({
18
22
  ...bindCapabilities(domain, { self }),
19
23
  integrations: integrations(stepCalls({ next, lifetime, auth })),
20
24
  });
21
- return Object.freeze({ authority, close: () => session.close() });
25
+ return Object.freeze({
26
+ authority,
27
+ repeatsEffects: () => repeats,
28
+ close: () => session.close(),
29
+ });
22
30
  }
23
31
  catch (cause) {
24
32
  session.close();
@@ -32,13 +40,31 @@ export function sessionSteps(input) {
32
40
  * replays the same keys: the Kernel answers a Mutation with its receipt and a durable call with the
33
41
  * Execution it started, or refuses a request that changed under its key. Any other `invoke` carries
34
42
  * no key, because the Kernel accepts one only for a durable Function, and runs again with the Step.
43
+ *
44
+ * A keyed call and a Query repeat no effect when sent again, so their failures are marked
45
+ * resendable: an unreachable Kernel then costs the Step no attempt. `unkeyed` hears each `invoke`
46
+ * without a key that may have run.
35
47
  */
36
- export function keyed(session, domain, next) {
48
+ export function keyed(session, domain, next, unkeyed) {
37
49
  const invokeSource = session.invoke;
38
50
  const mutateSource = session.mutate;
39
- const invoke = ((reference, value, options) => invokeSource(reference, value, declaredDurable(domain, reference) ? withKey(options, next) : options));
40
- const mutate = ((author, options) => mutateSource(author, withKey(options, next)));
41
- return Object.freeze({ ...session, invoke, mutate });
51
+ const querySource = session.query;
52
+ const invoke = ((reference, value, options) => {
53
+ const sent = declaredDurable(domain, reference) ? withKey(options, next) : options;
54
+ const send = () => invokeSource(reference, value, sent);
55
+ return sent?.idempotencyKey === undefined ? unkeyedCall(send, unkeyed) : resendableCall(send);
56
+ });
57
+ const mutate = ((author, options) => {
58
+ const sent = withKey(options, next);
59
+ return sentKeyed(sent, () => mutateSource(author, sent));
60
+ });
61
+ const query = querySource === undefined
62
+ ? {}
63
+ : { query: (...input) => resendableCall(() => querySource(...input)) };
64
+ return Object.freeze({ ...session, invoke, mutate, ...query });
65
+ }
66
+ function sentKeyed(options, send) {
67
+ return options?.idempotencyKey === undefined ? send() : resendableCall(send);
42
68
  }
43
69
  /**
44
70
  * Whether the Domain's Schema closure declares the referenced callable durable. An untyped Path
@@ -7,23 +7,25 @@ import type { NodeId } from '../../platform/graph/node/index.js';
7
7
  import type { CallableInputOf, ResolvedMethod, schema } from '../../platform/schema/index.js';
8
8
  import type { Value } from '../../platform/value/index.js';
9
9
  import type { TestStepCapabilities } from './steps.js';
10
- export interface TestEngineOptions {
10
+ export interface TestWorkflowEngineOptions {
11
11
  /** Start of the virtual clock, in Unix milliseconds; the current time by default. */
12
12
  readonly now?: number;
13
13
  /**
14
- * The deployment's `durable.retry`: the budget of every Step that declares none, 20 attempts by
15
- * default, and with `maxAttempts` the attempts of a run failing outside any Step before it pauses.
14
+ * The deployment's `durable.retry`: the budget of every Step that declares none, and with
15
+ * `maxAttempts` the attempts of a run failing outside any Step before it pauses.
16
16
  */
17
17
  readonly retry?: DurableStepRetry;
18
18
  }
19
19
  /**
20
20
  * An in-memory workflow engine that runs durable Workflows with the receiver a Host serves: it
21
- * journals each Step, replays the journal on every attempt, retries a failed Step within its budget
22
- * and fails it once the budget is spent, keeps timers on a virtual clock, buffers signals, pauses a
23
- * run failing outside any Step after its last attempt and cancels cooperatively. A retry does not
24
- * wait out its backoff, but the backoff it skips counts toward the Step's `maxDurationMs`.
21
+ * journals each Step attempt, replays the journal on every attempt, keeps timers on a virtual clock,
22
+ * buffers signals, pauses a run failing outside any Step after its last attempt and cancels
23
+ * cooperatively. A Step retries as on a Host: a failure of its own code spends its budget, and a
24
+ * Kernel or a transport that is unavailable makes it wait without spending it. The wait after the
25
+ * Step's own failure is not waited out, but counts toward its `maxDurationMs`; a wait for the
26
+ * platform is a timer, which `advance` moves past.
25
27
  */
26
- export interface TestEngine {
28
+ export interface TestWorkflowEngine {
27
29
  /** The virtual clock, in Unix milliseconds. */
28
30
  readonly now: number;
29
31
  /** Start one durable Execution and settle it: it ends, waits, or pauses. */
@@ -56,7 +58,7 @@ type ProvidersOf<Definitions extends Integrations> = keyof Definitions extends n
56
58
  } : {
57
59
  readonly providers: Providers<Definitions>;
58
60
  };
59
- /** One durable Execution on a test engine. */
61
+ /** One durable Execution on a test workflow engine. */
60
62
  export interface TestExecution<Output> {
61
63
  readonly id: NodeId;
62
64
  readonly state: Extract<ExecutionState, 'running' | 'waiting' | 'paused' | 'completed' | 'failed' | 'cancelled'>;
@@ -96,12 +98,17 @@ export type TestJournalEntry = {
96
98
  readonly kind: 'run';
97
99
  readonly name: string;
98
100
  readonly settled: boolean;
99
- /** Times the Step ran, retries and interruptions included; a replay runs it no more. */
101
+ /**
102
+ * Times this entry's work ran, interruptions included; a replay runs it no more. Each retry of
103
+ * a Step is an entry of its own, after the wait its failure prescribes.
104
+ */
100
105
  readonly attempts: number;
101
106
  readonly value?: unknown;
102
107
  readonly error?: unknown;
103
108
  } | {
104
109
  readonly kind: 'sleep';
110
+ /** The wait's name, if it has one: `retry:` or `unavailable:` and what it retries. */
111
+ readonly name?: string;
105
112
  readonly until: number;
106
113
  readonly settled: boolean;
107
114
  } | {
@@ -117,5 +124,5 @@ export type TestJournalEntry = {
117
124
  readonly outcome?: 'signal' | 'timeout' | 'cancelled';
118
125
  };
119
126
  /** Create an in-memory workflow engine for tests. */
120
- export declare function createTestEngine(options?: TestEngineOptions): TestEngine;
127
+ export declare function createTestWorkflowEngine(options?: TestWorkflowEngineOptions): TestWorkflowEngine;
121
128
  export {};
@@ -12,6 +12,10 @@ const TEST_KERNEL = issuer.accept('https://kernel.test.astrale.invalid');
12
12
  const QUIET_TURNS = 64;
13
13
  /** Metadata the Kernel reads a declared failure from. */
14
14
  const FAILURE = 'astrale.failure';
15
+ /** Metadata a failed Step attempt's journal entry records it under. */
16
+ const ATTEMPT = 'astrale.attempt';
17
+ /** Name prefix of the wait a Step's code failure prescribes before its next attempt. */
18
+ const BACKOFF = 'retry:';
15
19
  class TerminalError extends Error {
16
20
  code;
17
21
  metadata;
@@ -59,10 +63,10 @@ class JournalMismatch extends Error {
59
63
  this.name = 'JournalMismatch';
60
64
  }
61
65
  }
62
- /** The promises the test engine's `run` returned: the only ones its combinator accepts. */
66
+ /** The promises the test workflow engine's `run` returned: the only ones its combinator accepts. */
63
67
  const RUNS = new WeakSet();
64
68
  const ENGINE = Object.freeze({
65
- createEndpointHandler: () => () => Promise.reject(new Error('A test engine serves no HTTP.')),
69
+ createEndpointHandler: () => () => Promise.reject(new Error('A test workflow engine serves no HTTP.')),
66
70
  workflow: (definition) => definition,
67
71
  TerminalError,
68
72
  CancelledError,
@@ -81,7 +85,7 @@ const ENGINE = Object.freeze({
81
85
  }),
82
86
  });
83
87
  /** Create an in-memory workflow engine for tests. */
84
- export function createTestEngine(options = {}) {
88
+ export function createTestWorkflowEngine(options = {}) {
85
89
  const serving = durableServing({
86
90
  engine: ENGINE,
87
91
  ...(options.retry === undefined ? {} : { retry: options.retry }),
@@ -89,7 +93,7 @@ export function createTestEngine(options = {}) {
89
93
  const maxAttempts = serving.retryPolicy.maxAttempts;
90
94
  let now = options.now ?? Date.now();
91
95
  if (!Number.isFinite(now))
92
- throw new TypeError('Test engine now must be finite.');
96
+ throw new TypeError('Test workflow engine now must be finite.');
93
97
  let sequence = 0;
94
98
  const runs = new Set();
95
99
  const clock = { now: () => now };
@@ -106,7 +110,7 @@ export function createTestEngine(options = {}) {
106
110
  },
107
111
  async advance(durationMs) {
108
112
  if (!Number.isSafeInteger(durationMs) || durationMs < 0) {
109
- throw new TypeError('Test engine advance takes a non-negative integer of milliseconds.');
113
+ throw new TypeError('Test workflow engine advance takes a non-negative integer of milliseconds.');
110
114
  }
111
115
  now += durationMs;
112
116
  for (const run of runs)
@@ -126,6 +130,8 @@ class TestRun {
126
130
  signals = new Map();
127
131
  interruptions = new Set();
128
132
  cancellation = 'none';
133
+ /** The waits after a Step's own failures the run did not wait out, which its Steps still spent. */
134
+ skipped = 0;
129
135
  failures = 0;
130
136
  handlers;
131
137
  envelope;
@@ -145,6 +151,7 @@ class TestRun {
145
151
  workflow: admitted,
146
152
  domain,
147
153
  serving,
154
+ now: () => this.clock.now() + this.skipped,
148
155
  steps: testSteps({
149
156
  domain,
150
157
  capabilities: (input.capabilities ?? {}),
@@ -312,12 +319,12 @@ class Attempt {
312
319
  this.run = run;
313
320
  this.mode = mode;
314
321
  this.context = Object.freeze({
315
- run: (name, action, options) => {
316
- const run = this.journalRun(name, action, options);
322
+ run: (name, action) => {
323
+ const run = this.journalRun(name, action);
317
324
  RUNS.add(run);
318
325
  return run;
319
326
  },
320
- sleep: (durationMs) => this.sleep(durationMs),
327
+ sleep: (durationMs, name) => this.sleep(durationMs, name ?? ''),
321
328
  promise: (name) => ({ get: () => this.promise(name) }),
322
329
  request: () => ({ attemptCompletedSignal: this.controller.signal }),
323
330
  });
@@ -389,11 +396,10 @@ class Attempt {
389
396
  return new Promise(() => { });
390
397
  }
391
398
  /**
392
- * One journaled `run`. A failure within the budget of `options` ends the attempt before the
393
- * Workflow sees it, as the engine retries the `run` with a fresh attempt; a spent budget settles
394
- * the entry with a `TerminalError`. Without options, the failure reaches the Workflow.
399
+ * One journaled `run`. A `TerminalError` settles the entry; any other failure reaches the
400
+ * Workflow unjournaled, so the `run` runs again on the next attempt.
395
401
  */
396
- journalRun(name, action, options) {
402
+ journalRun(name, action) {
397
403
  let entry;
398
404
  try {
399
405
  entry = this.take('run', name);
@@ -431,20 +437,14 @@ class Attempt {
431
437
  if (cause instanceof TerminalError) {
432
438
  entry.settled = true;
433
439
  entry.error = cause;
440
+ // A failed Step attempt keeps what it threw on the live error, which tests inspect.
441
+ if (cause.metadata?.[ATTEMPT] !== undefined) {
442
+ this.run.failure = cause.cause ?? cause;
443
+ }
434
444
  throw cause;
435
445
  }
436
446
  this.run.failure = cause;
437
- if (options === undefined)
438
- throw cause;
439
- if (spent(entry, options)) {
440
- entry.settled = true;
441
- entry.error = new TerminalError(cause?.message ?? String(cause));
442
- throw entry.error;
443
- }
444
- // An attempt takes time too: a zero delay still spends a duration budget, by a millisecond.
445
- entry.waitedMs = (entry.waitedMs ?? 0) + Math.max(1, delayOf(entry, options, cause));
446
- this.end({ kind: 'retried', error: cause });
447
- return this.block();
447
+ throw cause;
448
448
  }
449
449
  finally {
450
450
  this.active -= 1;
@@ -452,10 +452,15 @@ class Attempt {
452
452
  }
453
453
  })();
454
454
  }
455
- sleep(durationMs) {
455
+ /**
456
+ * A durable timer on the virtual clock. The wait after a Step's own failure is not waited out: it
457
+ * settles at once and ends the attempt, as a timer that suspends the run does, and the time it
458
+ * skipped counts toward the Step's `maxDurationMs`.
459
+ */
460
+ sleep(durationMs, name) {
456
461
  let entry;
457
462
  try {
458
- entry = this.take('sleep', '');
463
+ entry = this.take('sleep', name);
459
464
  }
460
465
  catch (cause) {
461
466
  return Promise.reject(cause);
@@ -463,6 +468,13 @@ class Attempt {
463
468
  entry.until ??= this.run.clock.now() + durationMs;
464
469
  if (entry.settled)
465
470
  return this.answer(entry);
471
+ if (this.mode === 'run' && name.startsWith(BACKOFF)) {
472
+ entry.settled = true;
473
+ // An attempt takes time too: a zero delay still spends a duration budget, by a millisecond.
474
+ this.run.skipped += Math.max(1, durationMs);
475
+ this.end({ kind: 'retried', error: this.run.failure });
476
+ return this.block();
477
+ }
466
478
  if (this.run.clock.now() >= entry.until) {
467
479
  if (this.mode === 'run')
468
480
  entry.settled = true;
@@ -538,18 +550,6 @@ class Attempt {
538
550
  return Promise.resolve(value);
539
551
  }
540
552
  }
541
- /** Whether a Step that just failed spent its budget: its attempts, or the backoff it waited. */
542
- function spent(entry, options) {
543
- return ((options.maxRetryAttempts !== undefined && (entry.attempts ?? 0) >= options.maxRetryAttempts) ||
544
- (options.maxRetryDuration !== undefined && (entry.waitedMs ?? 0) >= options.maxRetryDuration));
545
- }
546
- /** The delay before a failed Step's next attempt: the one its failure prescribes, or its backoff. */
547
- function delayOf(entry, options, cause) {
548
- if (cause instanceof RetryableError && cause.retryAfter !== undefined)
549
- return cause.retryAfter;
550
- const retries = (entry.attempts ?? 1) - 1;
551
- return Math.min(options.maxRetryInterval, options.initialRetryInterval * options.retryIntervalFactor ** retries);
552
- }
553
553
  function outcomeOf(error) {
554
554
  const declared = error.metadata?.[FAILURE];
555
555
  if (declared !== undefined) {
@@ -584,7 +584,12 @@ function snapshot(entry) {
584
584
  ...(entry.error === undefined ? {} : { error: entry.error }),
585
585
  });
586
586
  case 'sleep':
587
- return Object.freeze({ kind: 'sleep', until: entry.until, settled: entry.settled });
587
+ return Object.freeze({
588
+ kind: 'sleep',
589
+ ...(entry.name === '' ? {} : { name: entry.name }),
590
+ until: entry.until,
591
+ settled: entry.settled,
592
+ });
588
593
  case 'signal':
589
594
  return Object.freeze({
590
595
  kind: 'signal',
@@ -1,3 +1,3 @@
1
- export { createTestEngine } from './engine.js';
2
- export type { TestEngine, TestEngineOptions, TestExecution, TestJournalEntry, TestStartOptions, } from './engine.js';
1
+ export { createTestWorkflowEngine } from './engine.js';
2
+ export type { TestExecution, TestJournalEntry, TestStartOptions, TestWorkflowEngine, TestWorkflowEngineOptions, } from './engine.js';
3
3
  export type { TestStepCapabilities } from './steps.js';
@@ -1 +1 @@
1
- export { createTestEngine } from './engine.js';
1
+ export { createTestWorkflowEngine } from './engine.js';
@@ -1,17 +1,22 @@
1
1
  import { bindDependencies } from '../../execution/invocation/dependency/index.js';
2
+ import { resendableCall } from '../../execution/workflows/attempts.js';
2
3
  import { stepCalls } from '../../execution/workflows/integrations.js';
3
4
  import { keyed } from '../../execution/workflows/steps.js';
4
5
  /** Bind each Step attempt of a test Execution to the test's capabilities, keyed as on a Host. */
5
6
  export function testSteps(input) {
6
7
  const { domain, capabilities, integrations } = input;
7
8
  return ({ next, lifetime }) => {
9
+ let repeats = false;
8
10
  const session = keyed({
9
11
  ...capabilities.kernel,
10
12
  invoke: capabilities.kernel?.invoke ?? absent('kernel.invoke'),
11
13
  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() })));
14
+ }, domain, next, () => {
15
+ repeats = true;
16
+ });
17
+ // A Query, and a Mutation under its key, repeat no effect when sent again, as on a Host.
18
+ const query = (definition, value) => resendableCall(() => call(capabilities.query, 'query', (answer) => answer(definition, value)));
19
+ const mutate = (definition, value) => resendableCall(() => call(capabilities.mutate, 'mutate', (answer) => answer(definition, value, Object.freeze({ idempotencyKey: next() }))));
15
20
  const graph = Object.freeze({ query, mutate });
16
21
  const auth = Object.freeze({
17
22
  mint: (request) => call(capabilities.kernel?.auth?.mint, 'kernel.auth.mint', (mint) => mint(request)),
@@ -24,7 +29,7 @@ export function testSteps(input) {
24
29
  mutate,
25
30
  integrations: integrations(stepCalls({ next, lifetime, auth })),
26
31
  });
27
- return Object.freeze({ authority, close() { } });
32
+ return Object.freeze({ authority, repeatsEffects: () => repeats, close() { } });
28
33
  };
29
34
  }
30
35
  function call(answer, name, apply) {