@telorun/sdk 0.75.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cancellation.d.ts +50 -1
  2. package/dist/cancellation.d.ts.map +1 -1
  3. package/dist/contract-errors.d.ts +8 -1
  4. package/dist/contract-errors.d.ts.map +1 -1
  5. package/dist/contract-errors.js +8 -0
  6. package/dist/durable-run.d.ts +310 -0
  7. package/dist/durable-run.d.ts.map +1 -0
  8. package/dist/durable-run.js +223 -0
  9. package/dist/durable-suspension.d.ts +143 -0
  10. package/dist/durable-suspension.d.ts.map +1 -0
  11. package/dist/durable-suspension.js +153 -0
  12. package/dist/durable-target-encoding.d.ts +49 -0
  13. package/dist/durable-target-encoding.d.ts.map +1 -0
  14. package/dist/durable-target-encoding.js +121 -0
  15. package/dist/duration.d.ts +1 -1
  16. package/dist/duration.js +5 -5
  17. package/dist/evaluation-context.d.ts +16 -0
  18. package/dist/evaluation-context.d.ts.map +1 -1
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -0
  22. package/dist/invoke-step.d.ts +86 -1
  23. package/dist/invoke-step.d.ts.map +1 -1
  24. package/dist/invoke-step.js +261 -18
  25. package/dist/resource-context.d.ts +23 -0
  26. package/dist/resource-context.d.ts.map +1 -1
  27. package/dist/resource-instance.d.ts +21 -1
  28. package/dist/resource-instance.d.ts.map +1 -1
  29. package/dist/resource-instance.js +6 -2
  30. package/dist/step-engine.d.ts +170 -0
  31. package/dist/step-engine.d.ts.map +1 -0
  32. package/dist/step-engine.js +365 -0
  33. package/dist/type-schema-ref.d.ts.map +1 -1
  34. package/dist/type-schema-ref.js +18 -0
  35. package/dist/zone-attribute.d.ts +101 -0
  36. package/dist/zone-attribute.d.ts.map +1 -0
  37. package/dist/zone-attribute.js +130 -0
  38. package/dist/zone-attributes/entries/atomic.json +7 -0
  39. package/dist/zone-attributes/entries/idempotent.json +6 -0
  40. package/dist/zone-attributes/entries/index.d.ts +3 -0
  41. package/dist/zone-attributes/entries/index.d.ts.map +1 -0
  42. package/dist/zone-attributes/entries/index.js +13 -0
  43. package/dist/zone-attributes/entries/no-suspend.json +6 -0
  44. package/dist/zone-attributes/entries/replayed.json +6 -0
  45. package/package.json +1 -1
  46. package/src/cancellation.ts +50 -1
  47. package/src/contract-errors.ts +9 -0
  48. package/src/durable-run.ts +450 -0
  49. package/src/durable-suspension.ts +188 -0
  50. package/src/durable-target-encoding.ts +181 -0
  51. package/src/duration.ts +5 -5
  52. package/src/evaluation-context.ts +17 -0
  53. package/src/index.ts +5 -1
  54. package/src/invoke-step.ts +378 -24
  55. package/src/resource-context.ts +23 -0
  56. package/src/resource-instance.ts +32 -2
  57. package/src/step-engine.ts +627 -0
  58. package/src/type-schema-ref.ts +16 -0
  59. package/src/zone-attribute.ts +208 -0
  60. package/src/zone-attributes/entries/atomic.json +7 -0
  61. package/src/zone-attributes/entries/idempotent.json +6 -0
  62. package/src/zone-attributes/entries/index.ts +14 -0
  63. package/src/zone-attributes/entries/no-suspend.json +6 -0
  64. package/src/zone-attributes/entries/replayed.json +6 -0
@@ -0,0 +1,627 @@
1
+ /**
2
+ * The step grammar and its execution: `invoke` / `value` / `if` / `while` /
3
+ * `switch` / `try` / `throw`, the `steps.<name>.result` accumulator, and the
4
+ * nested-scope walk that resolves an inline `invoke:` into a named resource.
5
+ *
6
+ * WHY THE SDK OWNS THIS. The leaf ({@link executeInvokeStep}) has always lived
7
+ * here; everything above it lived in `modules/run` for no reason anyone chose,
8
+ * and that is what made a step body something only `run`'s own kinds could have.
9
+ * `@telorun/sdk` is the single name in the bundle loader's `REALM_COLLAPSE_NAMES`
10
+ * — symlinked onto the KERNEL's own copy rather than inlined — so it is one
11
+ * version per process whatever anyone pins, and it is reachable from a controller
12
+ * bundle and from the kernel's own boot runner alike. A module library
13
+ * (`exports.code:`) is no longer copied per consumer, but it is still one scope
14
+ * per pinned version, it is outside the seam entirely for an npm-delivered
15
+ * controller, and the kernel cannot reach one at all. For a component whose
16
+ * contract is determinism across a durable run, one implementation is the whole
17
+ * premise.
18
+ *
19
+ * The context is STRUCTURAL ({@link StepEngineContext}), the property the leaf
20
+ * already proved: `ResourceContext` satisfies it, and so does a kernel-side
21
+ * adapter. Nothing here imports the kernel or `run`.
22
+ */
23
+
24
+ import type { Invocable } from "./capabilities/invokable.js";
25
+ import type { InvokeContext } from "./cancellation.js";
26
+ import {
27
+ durableHandleOf,
28
+ journalingSuppressed,
29
+ stepPath,
30
+ type DurableDecisionKind,
31
+ type DurableRunHandle,
32
+ } from "./durable-run.js";
33
+ import { isSuspension } from "./durable-suspension.js";
34
+ import { InvokeError, isInvokeError } from "./invoke-error.js";
35
+ import { executeInvokeStep, type InvokeStep, type InvokeStepContext } from "./invoke-step.js";
36
+ import type { KindRef, ScopeContext } from "./ref.js";
37
+
38
+ /**
39
+ * What the engine needs beyond the leaf's own contract: turning an inline
40
+ * `invoke: { kind, … }` into a named reference.
41
+ *
42
+ * Widened from {@link InvokeStepContext} rather than replaced, so one interface
43
+ * describes a step site whether or not control flow is involved. Satisfied
44
+ * structurally by `ResourceContext`; a host that composes steps in code supplies
45
+ * its own.
46
+ */
47
+ export interface StepEngineContext extends InvokeStepContext {
48
+ ensureKindRef(value: any, resourceName?: string): KindRef;
49
+ }
50
+
51
+ export interface IfStep {
52
+ name: string;
53
+ if: string;
54
+ then: Step[];
55
+ elseif?: Array<{
56
+ if: string;
57
+ then: Step[];
58
+ }>;
59
+ else?: Step[];
60
+ }
61
+
62
+ export interface WhileStep {
63
+ name: string;
64
+ while: string;
65
+ do: Step[];
66
+ }
67
+
68
+ export interface SwitchStep {
69
+ name: string;
70
+ switch: string;
71
+ cases: Record<string, Step[]>;
72
+ default?: Step[];
73
+ }
74
+
75
+ export interface TryStep {
76
+ name: string;
77
+ when?: string;
78
+ try: Step[];
79
+ catch?: Step[];
80
+ finally?: Step[];
81
+ }
82
+
83
+ export interface ThrowStep {
84
+ name: string;
85
+ throw: {
86
+ code: string;
87
+ message?: string;
88
+ data?: unknown;
89
+ };
90
+ }
91
+
92
+ export interface ValueStep {
93
+ name: string;
94
+ value: unknown;
95
+ }
96
+
97
+ export type Step =
98
+ | InvokeStep
99
+ | IfStep
100
+ | WhileStep
101
+ | SwitchStep
102
+ | TryStep
103
+ | ThrowStep
104
+ | ValueStep;
105
+
106
+ /** Code assigned to any caught failure that is not a structured `InvokeError`.
107
+ * Guarantees `error.code` is always a non-empty string inside a `catch`, so a
108
+ * `throw: { code: "${{ error.code }}" }` rethrow can never resolve to null.
109
+ * The analyzer's throws resolver mirrors this constant. */
110
+ export const PLAIN_ERROR_CODE = "INTERNAL_ERROR";
111
+
112
+ /** The `error` variable a `catch:` / `catches:` branch sees. */
113
+ export interface SequenceError {
114
+ message: string;
115
+ code: string;
116
+ data?: unknown;
117
+ step: string;
118
+ }
119
+
120
+ function isInvokeStep(step: Step): step is InvokeStep {
121
+ return "invoke" in step;
122
+ }
123
+ function isIfStep(step: Step): step is IfStep {
124
+ return "if" in step;
125
+ }
126
+ function isWhileStep(step: Step): step is WhileStep {
127
+ return "while" in step;
128
+ }
129
+ function isSwitchStep(step: Step): step is SwitchStep {
130
+ return "switch" in step;
131
+ }
132
+ function isTryStep(step: Step): step is TryStep {
133
+ return "try" in step;
134
+ }
135
+ function isThrowStep(step: Step): step is ThrowStep {
136
+ return "throw" in step;
137
+ }
138
+ function isValueStep(step: Step): step is ValueStep {
139
+ return "value" in step;
140
+ }
141
+
142
+ /**
143
+ * Who is running this body — the two facts the generated name of an inline
144
+ * `invoke:` is built from.
145
+ *
146
+ * Taken as identity rather than as a finished prefix because that name is
147
+ * MANIFEST-VISIBLE topology: it is what `steps.<name>.result`, a trace span and
148
+ * an `ERR_RESOURCE_NOT_FOUND` all print. Every caller used to spell the recipe
149
+ * itself (`` `Iteration${pascalCase(name)}` ``), which is the half of a
150
+ * must-not-fork component that forked anyway — the fifth composer would get the
151
+ * casing subtly wrong, or collide with the fourth.
152
+ */
153
+ export interface StepBodyOwner {
154
+ /** The owning kind's suffix (`Sequence`, `Iteration`, `Transaction`). */
155
+ kind: string;
156
+ /** The owning resource's `metadata.name`. */
157
+ resourceName: string;
158
+ }
159
+
160
+ /** Runs a step list against an `extraCtx` CEL scope, owning the full grammar —
161
+ * `invoke` / `value` / `if` / `while` / `switch` / `try` / `throw`. A composing
162
+ * kind injects its own scope variables (`item`, `index`, `iteration`,
163
+ * `previous`, …) through `extraCtx`; the engine knows none of them. */
164
+ export class StepEngine {
165
+ /** Prefix for generated inline-invoke resource names; unique per host resource
166
+ * (`SequenceMySeq`, `LoopPollUntilReady`). */
167
+ private readonly namePrefix: string;
168
+
169
+ constructor(
170
+ private readonly ctx: StepEngineContext,
171
+ owner: StepBodyOwner,
172
+ ) {
173
+ this.namePrefix = `${pascalCase(owner.kind)}${pascalCase(owner.resourceName)}`;
174
+ }
175
+
176
+ resolveInvokes(stepList: Step[], path: string[] = ["steps"]): void {
177
+ for (const [index, step] of stepList.entries()) {
178
+ const stepPath = [...path, String(index)];
179
+ if (isInvokeStep(step)) {
180
+ const raw = step.invoke as unknown;
181
+ if (!raw || typeof (raw as Invocable).invoke !== "function") {
182
+ (step as InvokeStep).invoke = this.ctx.ensureKindRef(
183
+ raw as any,
184
+ this.inlineInvokeResourceName(step.name, stepPath),
185
+ ) as KindRef<Invocable>;
186
+ }
187
+ }
188
+ if (isIfStep(step)) {
189
+ this.resolveInvokes(step.then, [...stepPath, "then"]);
190
+ if (step.elseif) {
191
+ for (const [elseifIndex, branch] of step.elseif.entries()) {
192
+ this.resolveInvokes(branch.then, [...stepPath, "elseif", String(elseifIndex), "then"]);
193
+ }
194
+ }
195
+ if (step.else) this.resolveInvokes(step.else, [...stepPath, "else"]);
196
+ }
197
+ if (isWhileStep(step)) this.resolveInvokes(step.do, [...stepPath, "do"]);
198
+ if (isSwitchStep(step)) {
199
+ for (const [caseName, branch] of Object.entries(step.cases)) {
200
+ this.resolveInvokes(branch, [...stepPath, "cases", caseName]);
201
+ }
202
+ if (step.default) this.resolveInvokes(step.default, [...stepPath, "default"]);
203
+ }
204
+ if (isTryStep(step)) {
205
+ this.resolveInvokes(step.try, [...stepPath, "try"]);
206
+ if (step.catch) this.resolveInvokes(step.catch, [...stepPath, "catch"]);
207
+ if (step.finally) this.resolveInvokes(step.finally, [...stepPath, "finally"]);
208
+ }
209
+ }
210
+ }
211
+
212
+ private inlineInvokeResourceName(stepName: string, stepPath: string[]): string {
213
+ const path = stepPath.map(pascalCase).join("");
214
+ const step = pascalCase(stepName);
215
+ return `${this.namePrefix}${path}${step}`;
216
+ }
217
+
218
+ /**
219
+ * @param path Journal key prefix for this list — see {@link stepPath}. A
220
+ * composer that repeats a body (an iteration element, a loop turn) qualifies
221
+ * it with the index, which is what makes each repetition an independently
222
+ * resumable subtree. Omitted, it is derived from the ambient step path, so a
223
+ * NESTED body nests its keys instead of restarting at the root — see
224
+ * {@link baseStepPath}.
225
+ */
226
+ async executeSteps(
227
+ stepList: Step[],
228
+ steps: Record<string, unknown>,
229
+ scope: ScopeContext | undefined,
230
+ extraCtx: Record<string, unknown>,
231
+ invokeCtx?: InvokeContext,
232
+ path?: string,
233
+ ): Promise<void> {
234
+ const base = path ?? baseStepPath(invokeCtx);
235
+ for (const step of stepList) {
236
+ await this.executeStep(step, steps, scope, extraCtx, invokeCtx, base);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The journal key of one step.
242
+ *
243
+ * Composed from the WRITTEN structure — the enclosing list's path plus this
244
+ * step's own name — never from execution order. A per-run call ordinal would
245
+ * be simpler and is wrong: two branches of a concurrent fan-out interleave
246
+ * their dispatches, so an ordinal numbers them differently on every run while
247
+ * these paths stay fixed.
248
+ */
249
+ private pathOf(path: string, step: Step): string {
250
+ // A missing name is refused rather than defaulted. The shared `Step` schema
251
+ // declares `name` required, so a manifest cannot reach this — but a caller
252
+ // assembling steps in code can, and an empty segment would give two such
253
+ // steps ONE journal key, where first-writer-wins hands the second the
254
+ // first's result. Silent, and indistinguishable from a correct replay.
255
+ if (!step.name) {
256
+ throw new InvokeError(
257
+ "ERR_STEP_NAME_REQUIRED",
258
+ `A step at '${path}' has no name. A name is what identifies the step in the run's ` +
259
+ `record, so two unnamed steps would share one key and the second would be handed ` +
260
+ `the first's result.`,
261
+ { path },
262
+ );
263
+ }
264
+ return stepPath(path, step.name);
265
+ }
266
+
267
+ /** The run handle to journal through, or undefined when this body is not
268
+ * inside a durable run — in which case the engine behaves exactly as it did
269
+ * before durability existed, and pays nothing for it. */
270
+ private handle(invokeCtx?: InvokeContext): DurableRunHandle | undefined {
271
+ return durableHandleOf(invokeCtx);
272
+ }
273
+
274
+ /**
275
+ * Evaluate a control-flow decision, journaling it when a run is durable.
276
+ *
277
+ * EVERY decision goes through here, which is the closure property the whole
278
+ * design rests on: a predicate, a loop condition and a switch key are all read
279
+ * from a CEL scope carrying live readings, so re-deriving one in a fresh
280
+ * process can send the replay down a different branch than the run took —
281
+ * silently, because the journal would then hand back a recorded result under a
282
+ * key the run reached for a different reason.
283
+ */
284
+ private async decide<T>(
285
+ invokeCtx: InvokeContext | undefined,
286
+ path: string,
287
+ kind: DurableDecisionKind,
288
+ compute: () => T,
289
+ ): Promise<T> {
290
+ const handle = this.handle(invokeCtx);
291
+ if (!handle || journalingSuppressed(this.ctx, invokeCtx, handle)) return compute();
292
+ return handle.decide(path, kind, compute);
293
+ }
294
+
295
+ private async executeStep(
296
+ step: Step,
297
+ steps: Record<string, unknown>,
298
+ scope: ScopeContext | undefined,
299
+ extraCtx: Record<string, unknown>,
300
+ invokeCtx?: InvokeContext,
301
+ path = "steps",
302
+ ): Promise<void> {
303
+ const here = this.pathOf(path, step);
304
+ if (isInvokeStep(step))
305
+ await executeInvokeStep(step, this.ctx, {
306
+ steps,
307
+ scope,
308
+ cel: extraCtx,
309
+ invokeCtx,
310
+ journalPath: here,
311
+ });
312
+ else if (isIfStep(step)) await this.executeIfStep(step, steps, scope, extraCtx, invokeCtx, here);
313
+ else if (isWhileStep(step))
314
+ await this.executeWhileStep(step, steps, scope, extraCtx, invokeCtx, here);
315
+ else if (isSwitchStep(step))
316
+ await this.executeSwitchStep(step, steps, scope, extraCtx, invokeCtx, here);
317
+ else if (isTryStep(step))
318
+ await this.executeTryStep(step, steps, scope, extraCtx, invokeCtx, here);
319
+ else if (isThrowStep(step)) this.executeThrowStep(step, steps, extraCtx);
320
+ else if (isValueStep(step)) await this.executeValueStep(step, steps, extraCtx, invokeCtx, here);
321
+ else throw new Error(`Step "${(step as Step).name}" has no recognized type key`);
322
+ }
323
+
324
+ private async executeIfStep(
325
+ step: IfStep,
326
+ steps: Record<string, unknown>,
327
+ scope: ScopeContext | undefined,
328
+ extraCtx: Record<string, unknown>,
329
+ invokeCtx?: InvokeContext,
330
+ path = "steps",
331
+ ): Promise<void> {
332
+ // Each predicate is journaled under its own key, so replay takes the branch
333
+ // the RUN took rather than the branch the predicate would evaluate to now.
334
+ if (await this.decide(invokeCtx, stepPath(path, "if"), "predicate", () =>
335
+ this.ctx.expandValue(step.if, { steps, ...extraCtx }),
336
+ )) {
337
+ await this.executeSteps(step.then, steps, scope, extraCtx, invokeCtx, stepPath(path, "then"));
338
+ return;
339
+ }
340
+
341
+ if (step.elseif) {
342
+ for (const [index, branch] of step.elseif.entries()) {
343
+ if (await this.decide(invokeCtx, stepPath(path, "elseif", index), "predicate", () =>
344
+ this.ctx.expandValue(branch.if, { steps, ...extraCtx }),
345
+ )) {
346
+ await this.executeSteps(
347
+ branch.then,
348
+ steps,
349
+ scope,
350
+ extraCtx,
351
+ invokeCtx,
352
+ stepPath(path, "elseif", index, "then"),
353
+ );
354
+ return;
355
+ }
356
+ }
357
+ }
358
+
359
+ if (step.else) {
360
+ await this.executeSteps(step.else, steps, scope, extraCtx, invokeCtx, stepPath(path, "else"));
361
+ }
362
+ }
363
+
364
+ private async executeWhileStep(
365
+ step: WhileStep,
366
+ steps: Record<string, unknown>,
367
+ scope: ScopeContext | undefined,
368
+ extraCtx: Record<string, unknown>,
369
+ invokeCtx?: InvokeContext,
370
+ path = "steps",
371
+ ): Promise<void> {
372
+ // The turn index qualifies both the condition's key and the body's, so each
373
+ // turn is an independently resumable subtree and a resume re-enters the turn
374
+ // it stopped in rather than restarting the loop.
375
+ for (let turn = 0; ; turn++) {
376
+ const go = await this.decide(invokeCtx, stepPath(path, "while", turn), "condition", () =>
377
+ this.ctx.expandValue(step.while, { steps, ...extraCtx }),
378
+ );
379
+ if (!go) return;
380
+ await this.executeSteps(
381
+ step.do,
382
+ steps,
383
+ scope,
384
+ extraCtx,
385
+ invokeCtx,
386
+ stepPath(path, "do", turn),
387
+ );
388
+ }
389
+ }
390
+
391
+ private async executeSwitchStep(
392
+ step: SwitchStep,
393
+ steps: Record<string, unknown>,
394
+ scope: ScopeContext | undefined,
395
+ extraCtx: Record<string, unknown>,
396
+ invokeCtx?: InvokeContext,
397
+ path = "steps",
398
+ ): Promise<void> {
399
+ const key = String(
400
+ await this.decide(invokeCtx, stepPath(path, "switch"), "switch", () =>
401
+ this.ctx.expandValue(step.switch, { steps, ...extraCtx }),
402
+ ),
403
+ );
404
+ if (Object.prototype.hasOwnProperty.call(step.cases, key)) {
405
+ await this.executeSteps(
406
+ step.cases[key],
407
+ steps,
408
+ scope,
409
+ extraCtx,
410
+ invokeCtx,
411
+ stepPath(path, "cases", key),
412
+ );
413
+ } else if (step.default) {
414
+ await this.executeSteps(
415
+ step.default,
416
+ steps,
417
+ scope,
418
+ extraCtx,
419
+ invokeCtx,
420
+ stepPath(path, "default"),
421
+ );
422
+ } else {
423
+ throw new Error(`Switch step "${step.name}": no matching case for "${key}" and no default`);
424
+ }
425
+ }
426
+
427
+ /** A pure step: expand the expression in the step scope and publish it as
428
+ * `steps.<name>.result`, the same shape an invoke step records — so a
429
+ * downstream step cannot tell how the value was produced. Nothing is
430
+ * dispatched, so there is no span and no topology edge. */
431
+ private async executeValueStep(
432
+ step: ValueStep,
433
+ steps: Record<string, unknown>,
434
+ extraCtx: Record<string, unknown>,
435
+ invokeCtx?: InvokeContext,
436
+ path = "steps",
437
+ ): Promise<void> {
438
+ try {
439
+ // Journaled like any other decision: a pure step's expression may be
440
+ // impure (`now()`, `uuid()`), and its value becomes `steps.<name>.result`
441
+ // that later steps read — so re-deriving it on replay would change the
442
+ // run's state without any dispatch having differed. This is also what lets
443
+ // a `Durable.Value` work INSIDE a collapsed region: collapse suppresses
444
+ // per-step entries, never a direct decision.
445
+ const result = await this.decide(invokeCtx, path, "value", () =>
446
+ this.ctx.expandValue(step.value, { steps, ...extraCtx }),
447
+ );
448
+ steps[step.name] = { result };
449
+ } catch (err) {
450
+ // A suspension is not this step's failure — it is the run leaving —
451
+ // so it passes through unattributed rather than being rewritten into an
452
+ // InvokeError a `catches:` list could name.
453
+ if (isSuspension(err)) throw err;
454
+ // Attribute the failure the way every other step branch does — a bare
455
+ // expression error names no step, no resource and no line, which is the
456
+ // one thing a `catch:` and a stack trace both need.
457
+ const failure = toSequenceError(err, step.name);
458
+ throw new InvokeError(failure.code, `Step "${step.name}": ${failure.message}`, {
459
+ step: step.name,
460
+ data: failure.data,
461
+ });
462
+ }
463
+ }
464
+
465
+ private executeThrowStep(
466
+ step: ThrowStep,
467
+ steps: Record<string, unknown>,
468
+ extraCtx: Record<string, unknown>,
469
+ ): never {
470
+ const cel = { steps, ...extraCtx };
471
+ const expanded = this.ctx.expandValue(step.throw, cel) as {
472
+ code: unknown;
473
+ message?: unknown;
474
+ data?: unknown;
475
+ };
476
+ const code = expanded?.code;
477
+ if (typeof code !== "string" || code.length === 0) {
478
+ // Structured error (not plain Error) so the failure stays in the InvokeError
479
+ // channel and a route's `catches:` list can still map it. The alternative —
480
+ // a plain Error — would skip catches: entirely and fall through to a 500.
481
+ throw new InvokeError(
482
+ "INVALID_THROW_STEP",
483
+ `throw.code is required and must resolve to a non-empty string (step "${step.name}")`,
484
+ { step: step.name, code },
485
+ );
486
+ }
487
+ const message = typeof expanded.message === "string" ? expanded.message : code;
488
+ throw new InvokeError(code, message, expanded.data);
489
+ }
490
+
491
+ private async executeTryStep(
492
+ step: TryStep,
493
+ steps: Record<string, unknown>,
494
+ scope: ScopeContext | undefined,
495
+ extraCtx: Record<string, unknown>,
496
+ invokeCtx?: InvokeContext,
497
+ path = "steps",
498
+ ): Promise<void> {
499
+ if (
500
+ step.when !== undefined &&
501
+ !(await this.decide(invokeCtx, stepPath(path, "when"), "predicate", () =>
502
+ this.ctx.expandValue(step.when, { steps, ...extraCtx }),
503
+ ))
504
+ ) {
505
+ return;
506
+ }
507
+
508
+ let tryFailed = false;
509
+ let tryError: unknown;
510
+
511
+ try {
512
+ await this.executeSteps(step.try, steps, scope, extraCtx, invokeCtx, stepPath(path, "try"));
513
+ } catch (err) {
514
+ // `try:` must NOT catch a suspension. The signal unwinds to the workflow
515
+ // that owns the run; absorbing it here would run the `catch:` branch and
516
+ // then continue, converting a park into a completed step and duplicating
517
+ // every effect after it. The latch would catch that at the boundary, but
518
+ // a hard error is a worse answer than simply not swallowing it.
519
+ if (isSuspension(err)) throw err;
520
+ tryFailed = true;
521
+ tryError = err;
522
+ }
523
+
524
+ if (tryFailed) {
525
+ if (step.catch) {
526
+ const seqErr = toSequenceError(tryError, step.name);
527
+ try {
528
+ await this.executeSteps(
529
+ step.catch,
530
+ steps,
531
+ scope,
532
+ { ...extraCtx, error: seqErr },
533
+ invokeCtx,
534
+ stepPath(path, "catch"),
535
+ );
536
+ } catch (catchErr) {
537
+ if (step.finally) {
538
+ await this.executeSteps(
539
+ step.finally,
540
+ steps,
541
+ scope,
542
+ { ...extraCtx, error: toSequenceError(catchErr, step.name) },
543
+ invokeCtx,
544
+ stepPath(path, "finally"),
545
+ );
546
+ }
547
+ throw catchErr;
548
+ }
549
+ if (step.finally) {
550
+ await this.executeSteps(
551
+ step.finally,
552
+ steps,
553
+ scope,
554
+ { ...extraCtx, error: null },
555
+ invokeCtx,
556
+ stepPath(path, "finally"),
557
+ );
558
+ }
559
+ } else {
560
+ if (step.finally) {
561
+ await this.executeSteps(
562
+ step.finally,
563
+ steps,
564
+ scope,
565
+ { ...extraCtx, error: toSequenceError(tryError, step.name) },
566
+ invokeCtx,
567
+ stepPath(path, "finally"),
568
+ );
569
+ }
570
+ throw tryError;
571
+ }
572
+ } else if (step.finally) {
573
+ await this.executeSteps(
574
+ step.finally,
575
+ steps,
576
+ scope,
577
+ { ...extraCtx, error: null },
578
+ invokeCtx,
579
+ stepPath(path, "finally"),
580
+ );
581
+ }
582
+ }
583
+ }
584
+
585
+
586
+ /** The naming recipe for a generated inline-invoke resource. Module-private: it
587
+ * is the engine's own, and a bare `pascalCase` on the SDK's flat surface is a
588
+ * utility nobody should be reimplementing a name from. */
589
+ function pascalCase(s: string): string {
590
+ return s
591
+ .split(/[^a-zA-Z0-9]+/)
592
+ .filter(Boolean)
593
+ .map((p) => p[0].toUpperCase() + p.slice(1))
594
+ .join("");
595
+ }
596
+
597
+ /** Normalize any caught failure to the `error` shape a `catch:` branch reads.
598
+ * Shared with the composers' whole-operation `catches:`, so one caught failure
599
+ * has one shape wherever it is read. */
600
+ export function toSequenceError(err: unknown, stepName: string): SequenceError {
601
+ if (isInvokeError(err)) {
602
+ // InvokeError.code is not validated non-empty at construction, so fall back
603
+ // to PLAIN_ERROR_CODE; message then falls back to the resolved code. Keeps
604
+ // both fields non-empty (see PLAIN_ERROR_CODE).
605
+ const code = err.code || PLAIN_ERROR_CODE;
606
+ return { message: err.message || code, code, data: err.data, step: stepName };
607
+ }
608
+ const message = (err instanceof Error ? err.message : String(err)) || "Unknown error";
609
+ return { message, code: PLAIN_ERROR_CODE, data: undefined, step: stepName };
610
+ }
611
+
612
+ /**
613
+ * Where a step list's journal keys hang from.
614
+ *
615
+ * At the top of a durable run there is no ambient path and the base is `steps`.
616
+ * Inside one, it is the path of the step that dispatched this body — so a nested
617
+ * sequence's `work` becomes `steps/importAll/work` rather than a second
618
+ * `steps/work`, and two nested bodies can no longer collide.
619
+ *
620
+ * The dispatching step's path is used directly rather than with a `steps`
621
+ * segment appended: the parent path already names one dispatch site, and every
622
+ * other segment the grammar produces (`then`, `do[2]`, `cases/x`) is distinct
623
+ * from a step name, so nothing else can generate the same key.
624
+ */
625
+ function baseStepPath(invokeCtx?: InvokeContext): string {
626
+ return invokeCtx?.durablePath ?? "steps";
627
+ }
@@ -59,6 +59,14 @@ const STRUCTURAL_KEYS = new Set([
59
59
  "properties",
60
60
  "required",
61
61
  "additionalProperties",
62
+ // `$defs` is a NAMESPACE, not a value: last-wins would drop every definition
63
+ // the other side declared, and a `$ref` pointing at one would then resolve to
64
+ // nothing. That is not hypothetical — a schema-valued slot is localized to
65
+ // `#/$defs/telo:<Fragment>` and hoisted here, so a child declaring any `$defs`
66
+ // of its own would erase the parent's hoisted entry and leave the parent's
67
+ // slots pointing at a definition that no longer exists.
68
+ "$defs",
69
+ "definitions",
62
70
  "allOf",
63
71
  "oneOf",
64
72
  "anyOf",
@@ -92,6 +100,7 @@ export function mergeTypeSchemas(
92
100
  ): Record<string, unknown> {
93
101
  const out: Record<string, unknown> = {};
94
102
  const properties: Record<string, unknown> = {};
103
+ const defs: Record<string, Record<string, unknown>> = { $defs: {}, definitions: {} };
95
104
  const required = new Set<string>();
96
105
  let additionalProperties: unknown;
97
106
  let hasAdditionalProperties = false;
@@ -106,6 +115,10 @@ export function mergeTypeSchemas(
106
115
  }
107
116
  const props = (schema as { properties?: unknown }).properties;
108
117
  if (props && typeof props === "object") Object.assign(properties, props);
118
+ for (const key of ["$defs", "definitions"] as const) {
119
+ const declared = (schema as Record<string, unknown>)[key];
120
+ if (declared && typeof declared === "object") Object.assign(defs[key], declared);
121
+ }
109
122
  const req = (schema as { required?: unknown }).required;
110
123
  if (Array.isArray(req)) for (const name of req) required.add(name as string);
111
124
  if ("additionalProperties" in schema) {
@@ -121,6 +134,9 @@ export function mergeTypeSchemas(
121
134
  }
122
135
 
123
136
  if (Object.keys(properties).length > 0) out.properties = properties;
137
+ for (const key of ["$defs", "definitions"] as const) {
138
+ if (Object.keys(defs[key]).length > 0) out[key] = defs[key];
139
+ }
124
140
  if (required.size > 0) out.required = [...required];
125
141
  if (hasAdditionalProperties) out.additionalProperties = additionalProperties;
126
142
  if (composition.length > 0) out.allOf = composition;