@astrale-os/sdk 0.6.0-beta.37 → 0.6.0-beta.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/application/runtime/workflows.js +0 -3
  2. package/dist/application/workflow/authoring.d.ts +1 -1
  3. package/dist/application/workflow/define.js +4 -15
  4. package/dist/application/workflow/durable.d.ts +43 -4
  5. package/dist/application/workflow/index.d.ts +1 -1
  6. package/dist/application/workflow/workflow.d.ts +0 -8
  7. package/dist/deployment/address/configuration.d.ts +1 -0
  8. package/dist/deployment/address/configuration.js +2 -1
  9. package/dist/execution/index.d.ts +1 -0
  10. package/dist/execution/index.js +1 -0
  11. package/dist/execution/workflows/engine.d.ts +19 -1
  12. package/dist/execution/workflows/failure.d.ts +40 -5
  13. package/dist/execution/workflows/failure.js +123 -6
  14. package/dist/execution/workflows/index.d.ts +2 -2
  15. package/dist/execution/workflows/index.js +1 -1
  16. package/dist/execution/workflows/options.d.ts +36 -9
  17. package/dist/execution/workflows/options.js +82 -19
  18. package/dist/execution/workflows/receiver.js +2 -0
  19. package/dist/execution/workflows/run.js +69 -29
  20. package/dist/testing/durable/engine.d.ts +16 -6
  21. package/dist/testing/durable/engine.js +67 -15
  22. package/dist/tooling/linter/implementations/source/dependencies.js +1 -73
  23. package/dist/tooling/linter/implementations/source/shared.d.ts +7 -0
  24. package/dist/tooling/linter/implementations/source/shared.js +92 -13
  25. package/dist/tooling/linter/implementations/source/workflows.js +426 -3
  26. package/dist/tooling/linter/policy/generated.js +2 -2
  27. package/dist/tooling/linter/requirements/registry.js +3 -1
  28. package/package.json +6 -6
@@ -1,5 +1,32 @@
1
+ /**
2
+ * The members of the Restate SDK Fetch entrypoint the durable receiver uses, which a platform's
3
+ * engine module must export.
4
+ */
5
+ export const DURABLE_ENGINE_EXPORTS = Object.freeze([
6
+ 'createEndpointHandler',
7
+ 'workflow',
8
+ 'TerminalError',
9
+ 'CancelledError',
10
+ 'RetryableError',
11
+ 'TimeoutError',
12
+ ]);
1
13
  const HOUR_MS = 60 * 60_000;
2
14
  const DAY_MS = 24 * HOUR_MS;
15
+ const DEFAULT_ATTEMPTS = 20;
16
+ /** The longest attempt a Step may declare, which the engine's abort timeout leaves to end first. */
17
+ const MAXIMUM_STEP_TIMEOUT_MS = 10 * 60_000;
18
+ /**
19
+ * How long the engine lets an attempt run without progress before it aborts it, declared on every
20
+ * Workflow service. Restate's own default is ten minutes, which a ten-minute Step would race: the
21
+ * margin lets a Step's own timeout fail the attempt, and report it, before the engine cuts it.
22
+ */
23
+ export const ENGINE_ABORT_TIMEOUT_MS = MAXIMUM_STEP_TIMEOUT_MS + 5 * 60_000;
24
+ const DEFAULT_STEP_RETRY = Object.freeze({
25
+ maxRetryAttempts: DEFAULT_ATTEMPTS,
26
+ initialRetryInterval: 1_000,
27
+ maxRetryInterval: 60_000,
28
+ retryIntervalFactor: 2,
29
+ });
3
30
  /** Capture one durable serving configuration, failing closed on any gap. */
4
31
  export function durableServing(input) {
5
32
  if (input === null ||
@@ -15,45 +42,81 @@ export function durableServing(input) {
15
42
  const engine = input.engine;
16
43
  if (engine === null ||
17
44
  typeof engine !== 'object' ||
18
- typeof engine.createEndpointHandler !== 'function' ||
19
- typeof engine.workflow !== 'function' ||
20
- typeof engine.TerminalError !== 'function' ||
21
- typeof engine.CancelledError !== 'function' ||
22
- typeof engine.TimeoutError !== 'function') {
45
+ DURABLE_ENGINE_EXPORTS.some((member) => typeof engine[member] !== 'function')) {
23
46
  invalid('Durable serving requires the Restate SDK Fetch entrypoint of this platform.');
24
47
  }
25
48
  const fetch = input.fetch ?? globalThis.fetch.bind(globalThis);
26
49
  if (typeof fetch !== 'function')
27
50
  invalid('Durable serving requires Fetch for engine discovery.');
28
- const retry = input.retry ?? {};
29
- if (retry === null ||
30
- typeof retry !== 'object' ||
31
- Array.isArray(retry) ||
32
- Reflect.ownKeys(retry).some((key) => key !== 'maxAttempts' && key !== 'initialIntervalMs' && key !== 'maxIntervalMs')) {
33
- invalid('Durable retry options are invalid.');
34
- }
35
- const initialInterval = bounded(retry.initialIntervalMs, 1_000, 1, HOUR_MS, 'initialIntervalMs');
36
- const maxInterval = bounded(retry.maxIntervalMs, 60_000, initialInterval, DAY_MS, 'maxIntervalMs');
51
+ const step = stepServing({ retry: input.retry, timeoutMs: input.stepTimeoutMs }, { retry: DEFAULT_STEP_RETRY, timeoutMs: 60_000 });
37
52
  return Object.freeze({
38
53
  engine: Object.freeze({
39
54
  createEndpointHandler: engine.createEndpointHandler.bind(engine),
40
55
  workflow: engine.workflow.bind(engine),
41
56
  TerminalError: engine.TerminalError,
42
57
  CancelledError: engine.CancelledError,
58
+ RetryableError: engine.RetryableError,
43
59
  TimeoutError: engine.TimeoutError,
44
60
  }),
45
61
  fetch,
46
62
  retentionMs: bounded(input.retentionMs, 7 * DAY_MS, HOUR_MS, 365 * DAY_MS, 'retentionMs'),
47
63
  retryPolicy: Object.freeze({
48
- maxAttempts: bounded(retry.maxAttempts, 20, 1, 1_000, 'maxAttempts'),
64
+ maxAttempts: input.retry?.maxAttempts ?? DEFAULT_ATTEMPTS,
49
65
  onMaxAttempts: 'pause',
50
- initialInterval,
51
- maxInterval,
52
- exponentiationFactor: 2,
66
+ initialInterval: step.retry.initialRetryInterval,
67
+ maxInterval: step.retry.maxRetryInterval,
68
+ exponentiationFactor: step.retry.retryIntervalFactor,
53
69
  }),
54
- stepTimeoutMs: bounded(input.stepTimeoutMs, 60_000, 1_000, HOUR_MS, 'stepTimeoutMs'),
70
+ step,
55
71
  });
56
72
  }
73
+ /**
74
+ * What one Step is held to: the retry budget and timeout it declares, in place of `fallback`'s. A
75
+ * declared limit replaces both limits of the fallback; an undeclared interval keeps the fallback's.
76
+ */
77
+ export function stepServing(declared, fallback) {
78
+ const retry = declared.retry;
79
+ if (retry === undefined) {
80
+ return Object.freeze({
81
+ retry: fallback.retry,
82
+ timeoutMs: stepTimeout(declared.timeoutMs, fallback.timeoutMs),
83
+ });
84
+ }
85
+ if (retry === null ||
86
+ typeof retry !== 'object' ||
87
+ Array.isArray(retry) ||
88
+ Reflect.ownKeys(retry).some((key) => key !== 'maxAttempts' &&
89
+ key !== 'maxDurationMs' &&
90
+ key !== 'initialIntervalMs' &&
91
+ key !== 'maxIntervalMs')) {
92
+ invalid('Durable retry options are invalid.');
93
+ }
94
+ const limited = retry.maxAttempts !== undefined || retry.maxDurationMs !== undefined;
95
+ const maxAttempts = limited
96
+ ? optionalBounded(retry.maxAttempts, 1, 1_000, 'maxAttempts')
97
+ : fallback.retry.maxRetryAttempts;
98
+ const maxDuration = limited
99
+ ? optionalBounded(retry.maxDurationMs, 1, 365 * DAY_MS, 'maxDurationMs')
100
+ : fallback.retry.maxRetryDuration;
101
+ const initialInterval = bounded(retry.initialIntervalMs, fallback.retry.initialRetryInterval, 1, HOUR_MS, 'initialIntervalMs');
102
+ const maxInterval = bounded(retry.maxIntervalMs, Math.max(fallback.retry.maxRetryInterval, initialInterval), initialInterval, DAY_MS, 'maxIntervalMs');
103
+ return Object.freeze({
104
+ retry: Object.freeze({
105
+ ...(maxAttempts === undefined ? {} : { maxRetryAttempts: maxAttempts }),
106
+ ...(maxDuration === undefined ? {} : { maxRetryDuration: maxDuration }),
107
+ initialRetryInterval: initialInterval,
108
+ maxRetryInterval: maxInterval,
109
+ retryIntervalFactor: fallback.retry.retryIntervalFactor,
110
+ }),
111
+ timeoutMs: stepTimeout(declared.timeoutMs, fallback.timeoutMs),
112
+ });
113
+ }
114
+ function stepTimeout(input, fallback) {
115
+ return bounded(input, fallback, 1_000, MAXIMUM_STEP_TIMEOUT_MS, 'Step timeout');
116
+ }
117
+ function optionalBounded(input, minimum, maximum, label) {
118
+ return input === undefined ? undefined : bounded(input, minimum, minimum, maximum, label);
119
+ }
57
120
  function bounded(input, fallback, minimum, maximum, label) {
58
121
  if (input === undefined)
59
122
  return fallback;
@@ -1,4 +1,5 @@
1
1
  import { workflowEngineDiscovery, WorkflowEngineUnavailable } from './discovery.js';
2
+ import { ENGINE_ABORT_TIMEOUT_MS } from './options.js';
2
3
  import { durableRoute } from './route.js';
3
4
  import { durableHandlers } from './run.js';
4
5
  import { sessionSteps } from './steps.js';
@@ -60,6 +61,7 @@ export function createDurableReceiver(input) {
60
61
  options: {
61
62
  workflowRetention: input.serving.retentionMs,
62
63
  retryPolicy: input.serving.retryPolicy,
64
+ abortTimeout: ENGINE_ABORT_TIMEOUT_MS,
63
65
  },
64
66
  });
65
67
  try {
@@ -4,10 +4,9 @@ import { acceptStepOutput } from '../../application/workflow/step/output.js';
4
4
  import { ResponseError } from '../../platform/client/index.js';
5
5
  import { validateOutput } from '../invocation/result/index.js';
6
6
  import { decodeEnvelope } from './envelope.js';
7
- import { replayedFailure, terminalFailure } from './failure.js';
7
+ import { delayedRetry, replayedFailure, StepExhausted, StepRetryAfter, attemptFailure, terminalFailure, } from './failure.js';
8
+ import { stepServing } from './options.js';
8
9
  import { keySequence } from './steps.js';
9
- /** Token of a Workflow that declares none. */
10
- const DEFAULT_COMPAT = '0';
11
10
  const MAXIMUM_SLEEP_MS = 365 * 24 * 60 * 60_000;
12
11
  const MAXIMUM_SIGNAL_NAME = 128;
13
12
  /** Bounds of one `result` wait and of the durable pause between two of them. */
@@ -37,15 +36,9 @@ async function run(input, context, raw) {
37
36
  const { workflow, serving } = input;
38
37
  const { engine } = serving;
39
38
  const definition = workflow.definition;
40
- // Journaled first. A redeployment that changed the token cannot replay this run: it fails every
41
- // attempt with this reason until the engine pauses it, then `resume` on compatible code or cancel.
42
- const token = definition.compat ?? DEFAULT_COMPAT;
43
- const recorded = await context.run('compat', async () => token);
44
- if (recorded !== token) {
45
- throw new Error(`Workflow ${workflow.address} started under compat token ${JSON.stringify(recorded)}; ` +
46
- `this deployment declares ${JSON.stringify(token)}.`);
47
- }
48
- // Code that cannot read what the Kernel recorded pauses the run the same way.
39
+ // A run replays the code of the release that started it, each release at its own URL. Code
40
+ // served in place of it replays the journal: the engine refuses a journal whose Steps no longer
41
+ // match, and code that cannot read what the Kernel recorded fails every attempt; both pause.
49
42
  const envelope = admitted(workflow, raw, input.kernel);
50
43
  const durable = durableContext(input, context, envelope);
51
44
  let output;
@@ -82,13 +75,25 @@ function durableContext(input, context, envelope) {
82
75
  /**
83
76
  * One journaled unit of work holding the capabilities of an Action as the Domain alone. Its
84
77
  * Mutations, durable calls and Integration calls share one key sequence derived from its name.
78
+ * With a `retry` budget, the engine retries a failed attempt within it, after its backoff or the
79
+ * delay the failure prescribes, and the Step throws `StepExhausted` once it is spent; without
80
+ * one, a failed attempt fails the run's attempt, which the engine retries until it pauses.
85
81
  */
86
- const journal = (name, execute) => Promise.resolve(context.run(name, async () => {
87
- const lifetime = { deadline: Date.now() + serving.stepTimeoutMs, signal };
88
- const next = await keySequence(execution.id, name);
89
- const step = input.steps({ name, execution, next, lifetime });
82
+ const journal = (id, name, timeoutMs, retry, execute) => Promise.resolve(context.run(name, async () => {
83
+ const expiry = new AbortController();
84
+ const timer = setTimeout(() => expiry.abort(new Error(`Workflow Step ${id} exceeded its ${timeoutMs} ms timeout.`)), timeoutMs);
85
+ let step;
90
86
  try {
91
- const output = await execute(step.authority);
87
+ const lifetime = {
88
+ deadline: Date.now() + timeoutMs,
89
+ signal: AbortSignal.any([signal, expiry.signal]),
90
+ };
91
+ const next = await keySequence(execution.id, name);
92
+ const { authority } = (step = input.steps({ name, execution, next, lifetime }));
93
+ const output = await Promise.race([
94
+ Promise.resolve().then(() => execute(authority)),
95
+ expired(expiry.signal),
96
+ ]);
92
97
  try {
93
98
  return acceptStepOutput(output);
94
99
  }
@@ -97,26 +102,30 @@ function durableContext(input, context, envelope) {
97
102
  }
98
103
  }
99
104
  catch (cause) {
100
- throw terminalFailure(engine, cause, definition) ?? cause;
105
+ throw (terminalFailure(engine, cause, definition) ??
106
+ delayedRetry(engine, cause) ??
107
+ attemptFailure(cause));
101
108
  }
102
109
  finally {
103
- step.close();
110
+ clearTimeout(timer);
111
+ step?.close();
104
112
  }
105
- })).catch((cause) => {
106
- throw replayedFailure(engine, cause, definition) ?? cause;
113
+ }, retry)).catch((cause) => {
114
+ throw replayedFailure(engine, cause, definition, id) ?? cause;
107
115
  });
108
- const step = (id, execute) => {
116
+ const step = (declared, execute) => {
109
117
  let accepted;
110
118
  try {
111
- accepted = acceptStepId(id);
112
- if (steps.has(accepted))
113
- throw new TypeError(`Workflow Step ${accepted} occurs more than once.`);
119
+ accepted = stepDeclaration(declared, serving.step);
120
+ if (steps.has(accepted.id))
121
+ throw new TypeError(`Workflow Step ${accepted.id} occurs more than once.`);
114
122
  }
115
123
  catch (cause) {
116
124
  return Promise.reject(cause);
117
125
  }
118
- steps.add(accepted);
119
- return journal(`step:${accepted}`, execute);
126
+ steps.add(accepted.id);
127
+ const { held } = accepted;
128
+ return journal(accepted.id, `step:${accepted.id}`, held.timeoutMs, held.retry, execute);
120
129
  };
121
130
  return Object.freeze({
122
131
  domain,
@@ -156,12 +165,16 @@ function durableContext(input, context, envelope) {
156
165
  }
157
166
  },
158
167
  cancelled: (error) => error instanceof engine.CancelledError,
168
+ exhausted: (error) => error instanceof StepExhausted,
169
+ retryAfter: (delayMs, cause) => new StepRetryAfter(delayMs, cause),
159
170
  async awaitExecution(target) {
160
171
  for (let poll = 1;; poll++) {
161
- const polled = await journal(`await:${target.id}:${poll}`, async ({ kernel }) => {
172
+ // A poll is no Step of the author's: it has no budget, so an outage pauses the run instead.
173
+ const name = `await:${target.id}:${poll}`;
174
+ const polled = await journal(name, name, serving.step.timeoutMs, undefined, async ({ kernel }) => {
162
175
  try {
163
176
  const outcome = await kernel.execution.result(target, {
164
- waitMs: Math.min(AWAIT_WAIT_MS, Math.floor(serving.stepTimeoutMs / 2)),
177
+ waitMs: Math.min(AWAIT_WAIT_MS, Math.floor(serving.step.timeoutMs / 2)),
165
178
  });
166
179
  return { ended: true, outcome };
167
180
  }
@@ -180,6 +193,33 @@ function durableContext(input, context, envelope) {
180
193
  ...(execution.receiver === undefined ? {} : { self: execution.receiver }),
181
194
  });
182
195
  }
196
+ /** Admit a Step's id, and its declared retry budget and timeout over the deployment's. */
197
+ function stepDeclaration(declared, fallback) {
198
+ if (typeof declared === 'string')
199
+ return { id: acceptStepId(declared), held: fallback };
200
+ if (declared === null ||
201
+ typeof declared !== 'object' ||
202
+ Array.isArray(declared) ||
203
+ Reflect.ownKeys(declared).some((key) => key !== 'id' && key !== 'retry' && key !== 'timeoutMs')) {
204
+ throw new TypeError('A Workflow Step declaration takes only id, retry and timeoutMs.');
205
+ }
206
+ return {
207
+ id: acceptStepId(declared.id),
208
+ held: stepServing({ retry: declared.retry, timeoutMs: declared.timeoutMs }, fallback),
209
+ };
210
+ }
211
+ /**
212
+ * Rejects with the abort reason once `signal` aborts, at once if it already has: a timeout may fire
213
+ * while the attempt derives its keys. Never settles otherwise.
214
+ */
215
+ function expired(signal) {
216
+ return new Promise((_, reject) => {
217
+ if (signal.aborted)
218
+ reject(signal.reason);
219
+ else
220
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
221
+ });
222
+ }
183
223
  function signalRequest(input) {
184
224
  if (input === null ||
185
225
  typeof input !== 'object' ||
@@ -1,7 +1,7 @@
1
1
  import type { ActionOutputOf, ResolvedDomainOf } from '../../application/action/index.js';
2
2
  import type { DeclaredErrors } from '../../application/error/index.js';
3
3
  import type { Integrations, Providers } from '../../application/integration/index.js';
4
- import type { CallableAtWorkflowAddress, WorkflowAddress, WorkflowDefinition } from '../../application/workflow/index.js';
4
+ import type { CallableAtWorkflowAddress, DurableStepRetry, WorkflowAddress, WorkflowDefinition } from '../../application/workflow/index.js';
5
5
  import type { ExecutionOutcome, ExecutionState } from '../../platform/client/session/index.js';
6
6
  import type { NodeId } from '../../platform/graph/node/index.js';
7
7
  import type { CallableInputOf, ResolvedMethod, schema } from '../../platform/schema/index.js';
@@ -10,13 +10,18 @@ import type { TestStepCapabilities } from './steps.js';
10
10
  export interface TestEngineOptions {
11
11
  /** Start of the virtual clock, in Unix milliseconds; the current time by default. */
12
12
  readonly now?: number;
13
- /** Attempts of a run before it pauses; 20 by default, as on a Host. */
14
- readonly maxAttempts?: number;
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.
16
+ */
17
+ readonly retry?: DurableStepRetry;
15
18
  }
16
19
  /**
17
20
  * An in-memory workflow engine that runs durable Workflows with the receiver a Host serves: it
18
- * journals each Step, replays the journal on every attempt, retries failed attempts, keeps timers
19
- * on a virtual clock, buffers signals, pauses after the last attempt and cancels cooperatively.
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`.
20
25
  */
21
26
  export interface TestEngine {
22
27
  /** The virtual clock, in Unix milliseconds. */
@@ -57,7 +62,10 @@ export interface TestExecution<Output> {
57
62
  readonly state: Extract<ExecutionState, 'running' | 'waiting' | 'paused' | 'completed' | 'failed' | 'cancelled'>;
58
63
  /** Present once the Execution ended, as `Execution.result` answers it. */
59
64
  readonly outcome: ExecutionOutcome<Output> | undefined;
60
- /** What the last failed attempt threw; it is why a paused Execution paused. */
65
+ /**
66
+ * What the last failed attempt threw: why a paused Execution paused, or what the last attempt of
67
+ * an exhausted Step threw, which its `STEP_EXHAUSTED` outcome does not carry.
68
+ */
61
69
  readonly failure: unknown;
62
70
  /** Attempts made so far, replays included. */
63
71
  readonly attempts: number;
@@ -88,6 +96,8 @@ export type TestJournalEntry = {
88
96
  readonly kind: 'run';
89
97
  readonly name: string;
90
98
  readonly settled: boolean;
99
+ /** Times the Step ran, retries and interruptions included; a replay runs it no more. */
100
+ readonly attempts: number;
91
101
  readonly value?: unknown;
92
102
  readonly error?: unknown;
93
103
  } | {
@@ -8,8 +8,6 @@ import { NodeId as nodeId } from '../../platform/graph/node/index.js';
8
8
  import { testSteps } from './steps.js';
9
9
  /** Kernel every test Execution names; Steps never reach it. */
10
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
11
  /** Microtask turns without progress after which a blocked attempt counts as suspended. */
14
12
  const QUIET_TURNS = 64;
15
13
  /** Metadata the Kernel reads a declared failure from. */
@@ -37,6 +35,15 @@ class TimeoutError extends TerminalError {
37
35
  this.name = 'TimeoutError';
38
36
  }
39
37
  }
38
+ class RetryableError extends Error {
39
+ retryAfter;
40
+ constructor(message, options) {
41
+ super(message, options?.cause === undefined ? undefined : { cause: options.cause });
42
+ this.name = 'RetryableError';
43
+ if (options?.retryAfter !== undefined)
44
+ this.retryAfter = options.retryAfter;
45
+ }
46
+ }
40
47
  /** The attempt lost its Step result on purpose; the engine retries it. */
41
48
  class Interruption extends Error {
42
49
  constructor(step) {
@@ -57,14 +64,16 @@ const ENGINE = Object.freeze({
57
64
  workflow: (definition) => definition,
58
65
  TerminalError,
59
66
  CancelledError,
67
+ RetryableError,
60
68
  TimeoutError,
61
69
  });
62
70
  /** Create an in-memory workflow engine for tests. */
63
71
  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
- }
72
+ const serving = durableServing({
73
+ engine: ENGINE,
74
+ ...(options.retry === undefined ? {} : { retry: options.retry }),
75
+ });
76
+ const maxAttempts = serving.retryPolicy.maxAttempts;
68
77
  let now = options.now ?? Date.now();
69
78
  if (!Number.isFinite(now))
70
79
  throw new TypeError('Test engine now must be finite.');
@@ -77,7 +86,7 @@ export function createTestEngine(options = {}) {
77
86
  },
78
87
  async start(workflow, input) {
79
88
  sequence += 1;
80
- const run = new TestRun(workflow, input, clock, maxAttempts, sequence);
89
+ const run = new TestRun(workflow, input, clock, serving, maxAttempts, sequence);
81
90
  runs.add(run);
82
91
  await run.settle();
83
92
  return run.execution;
@@ -108,7 +117,7 @@ class TestRun {
108
117
  handlers;
109
118
  envelope;
110
119
  execution;
111
- constructor(workflow, input, clock, maxAttempts, sequence) {
120
+ constructor(workflow, input, clock, serving, maxAttempts, sequence) {
112
121
  this.clock = clock;
113
122
  this.maxAttempts = maxAttempts;
114
123
  const domain = input.schema;
@@ -122,7 +131,7 @@ class TestRun {
122
131
  kernel: TEST_KERNEL,
123
132
  workflow: admitted,
124
133
  domain,
125
- serving: durableServing({ engine: ENGINE }),
134
+ serving,
126
135
  steps: testSteps({
127
136
  domain,
128
137
  capabilities: (input.capabilities ?? {}),
@@ -158,6 +167,10 @@ class TestRun {
158
167
  this.end({ kind: 'completed', value: attempted.value });
159
168
  return;
160
169
  }
170
+ if (attempted.kind === 'retried') {
171
+ this.failure = attempted.error;
172
+ continue;
173
+ }
161
174
  const { error } = attempted;
162
175
  if (error instanceof TerminalError) {
163
176
  this.end(outcomeOf(error));
@@ -186,7 +199,7 @@ class TestRun {
186
199
  .then(() => this.handlers.run(attempt.context, this.envelope))
187
200
  .then((value) => ({ kind: 'completed', value }), (error) => ({ kind: 'failed', error }))
188
201
  .finally(() => attempt.finish());
189
- return Promise.race([running, attempt.suspended()]).finally(() => attempt.finish());
202
+ return Promise.race([running, attempt.suspended(), attempt.ended]).finally(() => attempt.finish());
190
203
  }
191
204
  async signal(name, value) {
192
205
  if (this.ended)
@@ -276,12 +289,17 @@ class Attempt {
276
289
  progress = 0;
277
290
  done = false;
278
291
  controller = new AbortController();
292
+ end;
293
+ /** Settles when a Step ends the attempt without reaching the Workflow, as a retry or a crash. */
294
+ ended = new Promise((resolve) => {
295
+ this.end = resolve;
296
+ });
279
297
  context;
280
298
  constructor(run, mode) {
281
299
  this.run = run;
282
300
  this.mode = mode;
283
301
  this.context = Object.freeze({
284
- run: (name, action) => this.journalRun(name, action),
302
+ run: (name, action, options) => this.journalRun(name, action, options),
285
303
  sleep: (durationMs) => this.sleep(durationMs),
286
304
  promise: (name) => ({ get: () => this.promise(name) }),
287
305
  request: () => ({ attemptCompletedSignal: this.controller.signal }),
@@ -353,7 +371,12 @@ class Attempt {
353
371
  this.progress += 1;
354
372
  return new Promise(() => { });
355
373
  }
356
- journalRun(name, action) {
374
+ /**
375
+ * One journaled `run`. A failure within the budget of `options` ends the attempt before the
376
+ * Workflow sees it, as the engine retries the `run` with a fresh attempt; a spent budget settles
377
+ * the entry with a `TerminalError`. Without options, the failure reaches the Workflow.
378
+ */
379
+ journalRun(name, action, options) {
357
380
  let entry;
358
381
  try {
359
382
  entry = this.take('run', name);
@@ -369,11 +392,15 @@ class Attempt {
369
392
  return Promise.reject(new JournalMismatch(this.cursor - 1, 'a pending entry', `${name} to run again`));
370
393
  }
371
394
  this.active += 1;
395
+ entry.attempts = (entry.attempts ?? 0) + 1;
372
396
  return (async () => {
373
397
  try {
374
398
  const value = await action();
375
- if (this.run.interruptions.delete(name))
376
- throw new Interruption(name.replace(/^step:/u, ''));
399
+ if (this.run.interruptions.delete(name)) {
400
+ // A crash after the effect: the run is attempted again, and the Step with it.
401
+ this.end({ kind: 'failed', error: new Interruption(name.replace(/^step:/u, '')) });
402
+ return this.block();
403
+ }
377
404
  entry.settled = true;
378
405
  entry.value = value;
379
406
  return value;
@@ -382,8 +409,20 @@ class Attempt {
382
409
  if (cause instanceof TerminalError) {
383
410
  entry.settled = true;
384
411
  entry.error = cause;
412
+ throw cause;
385
413
  }
386
- throw cause;
414
+ this.run.failure = cause;
415
+ if (options === undefined)
416
+ throw cause;
417
+ if (spent(entry, options)) {
418
+ entry.settled = true;
419
+ entry.error = new TerminalError(cause?.message ?? String(cause));
420
+ throw entry.error;
421
+ }
422
+ // An attempt takes time too: a zero delay still spends a duration budget, by a millisecond.
423
+ entry.waitedMs = (entry.waitedMs ?? 0) + Math.max(1, delayOf(entry, options, cause));
424
+ this.end({ kind: 'retried', error: cause });
425
+ return this.block();
387
426
  }
388
427
  finally {
389
428
  this.active -= 1;
@@ -477,6 +516,18 @@ class Attempt {
477
516
  return Promise.resolve(value);
478
517
  }
479
518
  }
519
+ /** Whether a Step that just failed spent its budget: its attempts, or the backoff it waited. */
520
+ function spent(entry, options) {
521
+ return ((options.maxRetryAttempts !== undefined && (entry.attempts ?? 0) >= options.maxRetryAttempts) ||
522
+ (options.maxRetryDuration !== undefined && (entry.waitedMs ?? 0) >= options.maxRetryDuration));
523
+ }
524
+ /** The delay before a failed Step's next attempt: the one its failure prescribes, or its backoff. */
525
+ function delayOf(entry, options, cause) {
526
+ if (cause instanceof RetryableError && cause.retryAfter !== undefined)
527
+ return cause.retryAfter;
528
+ const retries = (entry.attempts ?? 1) - 1;
529
+ return Math.min(options.maxRetryInterval, options.initialRetryInterval * options.retryIntervalFactor ** retries);
530
+ }
480
531
  function outcomeOf(error) {
481
532
  const declared = error.metadata?.[FAILURE];
482
533
  if (declared !== undefined) {
@@ -506,6 +557,7 @@ function snapshot(entry) {
506
557
  kind: 'run',
507
558
  name: entry.name,
508
559
  settled: entry.settled,
560
+ attempts: entry.attempts ?? 0,
509
561
  ...(entry.settled && entry.error === undefined ? { value: entry.value } : {}),
510
562
  ...(entry.error === undefined ? {} : { error: entry.error }),
511
563
  });
@@ -1,7 +1,7 @@
1
1
  import ts from 'typescript';
2
2
  import { propertyChain, resolveImportedSymbol, unwrap, visit, } from '../../adapters/typescript/index.js';
3
3
  import { domainFunctionRequirements, callableReference, callableSelector, declaredDomainDependencies, remoteCallableKey, } from './callable-requirements.js';
4
- import { ambiguity, calls, isForeignDomain, production, violation } from './shared.js';
4
+ import { ambiguity, calls, contextPath, isForeignDomain, production, violation } from './shared.js';
5
5
  export const dependencyRules = [
6
6
  {
7
7
  id: 'FNC-XDOM-DECLARED',
@@ -158,75 +158,3 @@ function selectedCallable(input) {
158
158
  const chain = expression && propertyChain(expression);
159
159
  return chain && chain[0] === parameter.text ? chain.slice(1) : undefined;
160
160
  }
161
- /** Resolve handler context bindings and one immutable local alias without following arbitrary helpers. */
162
- function contextPath(input, run, aliases = 1) {
163
- const expression = unwrap(input);
164
- if (ts.isPropertyAccessExpression(expression)) {
165
- const base = contextPath(expression.expression, run, aliases);
166
- return base && [...base, expression.name.text];
167
- }
168
- if (ts.isElementAccessExpression(expression)) {
169
- const base = contextPath(expression.expression, run, aliases);
170
- return base && [...base, '[computed]'];
171
- }
172
- if (!ts.isIdentifier(expression))
173
- return undefined;
174
- let current = expression.parent;
175
- while (current) {
176
- if (ts.isFunctionLike(current)) {
177
- for (const parameter of current.parameters) {
178
- const path = bindingPath(parameter.name, expression.text);
179
- if (path)
180
- return parameter === current.parameters[0] &&
181
- (current === run || stepCallback(current, run))
182
- ? path
183
- : undefined;
184
- }
185
- }
186
- if (ts.isBlock(current) || ts.isSourceFile(current)) {
187
- for (const statement of current.statements) {
188
- if (ts.isFunctionDeclaration(statement) && statement.name?.text === expression.text)
189
- return undefined;
190
- if (!ts.isVariableStatement(statement))
191
- continue;
192
- for (const declaration of statement.declarationList.declarations) {
193
- const path = bindingPath(declaration.name, expression.text);
194
- if (!path)
195
- continue;
196
- if (aliases === 0 ||
197
- !declaration.initializer ||
198
- declaration.end > expression.pos ||
199
- !(statement.declarationList.flags & ts.NodeFlags.Const))
200
- return undefined;
201
- const base = contextPath(declaration.initializer, run, aliases - 1);
202
- return base && [...base, ...path];
203
- }
204
- }
205
- }
206
- current = current.parent;
207
- }
208
- return undefined;
209
- }
210
- /** A durable Step callback receives the handler's capabilities under the same names. */
211
- function stepCallback(callback, run) {
212
- const call = callback.parent;
213
- if (!ts.isCallExpression(call) || call.arguments[1] !== callback)
214
- return false;
215
- const callee = contextPath(call.expression, run, 0);
216
- return callee?.length === 2 && callee[0] === 'step' && callee[1] === 'run';
217
- }
218
- function bindingPath(name, identifier) {
219
- if (ts.isIdentifier(name))
220
- return name.text === identifier ? [] : undefined;
221
- if (!ts.isObjectBindingPattern(name))
222
- return undefined;
223
- for (const element of name.elements) {
224
- if (element.dotDotDotToken)
225
- continue;
226
- const nested = bindingPath(element.name, identifier);
227
- const property = element.propertyName ?? (ts.isIdentifier(element.name) ? element.name : undefined);
228
- if (nested && property && (ts.isIdentifier(property) || ts.isStringLiteral(property)))
229
- return [property.text, ...nested];
230
- }
231
- return undefined;
232
- }
@@ -36,5 +36,12 @@ export declare function hasForbiddenGlobalCall(file: SourceFile): Array<{
36
36
  node: ts.Node;
37
37
  name: string;
38
38
  }>;
39
+ /** The global a call reaches, as `name(…)` with no import or local binding, or `globalThis.name(…)`. */
40
+ export declare function globalCallName(file: SourceFile, node: ts.CallExpression): string | undefined;
41
+ /**
42
+ * Resolve the path of `input` from the first parameter of `run`, or of a durable Step callback in
43
+ * it, through one immutable local alias, without following arbitrary helpers.
44
+ */
45
+ export declare function contextPath(input: ts.Expression, run: ts.FunctionLikeDeclaration, aliases?: number): readonly string[] | undefined;
39
46
  export declare function isLocallyBoundIdentifier(node: ts.Identifier): boolean;
40
47
  export declare function forbiddenIoImport(specifier: string): boolean;