@telorun/sdk 0.77.0 → 0.80.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 (61) 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 +37 -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/zone-attribute.d.ts +101 -0
  34. package/dist/zone-attribute.d.ts.map +1 -0
  35. package/dist/zone-attribute.js +130 -0
  36. package/dist/zone-attributes/entries/atomic.json +7 -0
  37. package/dist/zone-attributes/entries/idempotent.json +6 -0
  38. package/dist/zone-attributes/entries/index.d.ts +3 -0
  39. package/dist/zone-attributes/entries/index.d.ts.map +1 -0
  40. package/dist/zone-attributes/entries/index.js +13 -0
  41. package/dist/zone-attributes/entries/no-suspend.json +6 -0
  42. package/dist/zone-attributes/entries/replayed.json +6 -0
  43. package/package.json +1 -1
  44. package/src/cancellation.ts +50 -1
  45. package/src/contract-errors.ts +9 -0
  46. package/src/durable-run.ts +450 -0
  47. package/src/durable-suspension.ts +188 -0
  48. package/src/durable-target-encoding.ts +181 -0
  49. package/src/duration.ts +5 -5
  50. package/src/evaluation-context.ts +17 -0
  51. package/src/index.ts +5 -1
  52. package/src/invoke-step.ts +378 -24
  53. package/src/resource-context.ts +37 -0
  54. package/src/resource-instance.ts +32 -2
  55. package/src/step-engine.ts +627 -0
  56. package/src/zone-attribute.ts +208 -0
  57. package/src/zone-attributes/entries/atomic.json +7 -0
  58. package/src/zone-attributes/entries/idempotent.json +6 -0
  59. package/src/zone-attributes/entries/index.ts +14 -0
  60. package/src/zone-attributes/entries/no-suspend.json +6 -0
  61. package/src/zone-attributes/entries/replayed.json +6 -0
@@ -0,0 +1,450 @@
1
+ /**
2
+ * The durable-execution replay seam — `kernel/specs/durable-execution.md`.
3
+ *
4
+ * Durable execution is **journal plus deterministic replay**, and Telo can have
5
+ * it because of a property that fell out of `Run.Sequence`'s design: control
6
+ * flow is a finite, DECLARED set of CEL expressions over run state, not
7
+ * arbitrary code. So replay is re-running the step list while returning recorded
8
+ * values instead of computing them. No continuation capture.
9
+ *
10
+ * **What is shared is this narrow seam, not a portable engine vocabulary.** The
11
+ * temptation is to abstract durable execution itself — one surface every backend
12
+ * implements — and it fails the same way twice: the abstraction becomes the
13
+ * union of every engine's lifecycle model (identity policy, schedule overlap,
14
+ * cancel-versus-terminate, deployment pinning), and each backend still loses the
15
+ * half of its own model that did not generalize. Portability is close to
16
+ * worthless here — in-flight runs do not migrate between engines, the
17
+ * configuration shares nothing, and nobody switches durable engines twice.
18
+ *
19
+ * The real constraint is that **the step engine must not fork**. It lives in
20
+ * this package, which is symlinked into every controller bundle rather than
21
+ * inlined, so there is exactly one implementation however many backends exist.
22
+ * What still needs a seam is the other half — *whether and where a step
23
+ * executes* — and that is all this file is.
24
+ *
25
+ * **The kernel is a pure conduit**: it carries the handle on `InvokeContext` and
26
+ * never calls it, which is why the contract lives here rather than in the kernel
27
+ * — the split logging already makes between `Logger` / `RecordBuffer` and the
28
+ * `Telo.LogSink` abstract. The consequence is stated rather than implied: unlike
29
+ * `zones`, this member does NOT cross the ABI, so a second runtime threads a
30
+ * handle it owns.
31
+ */
32
+
33
+ import type { InvokeContext, ZoneEntry } from "./cancellation.js";
34
+ import { InvokeError } from "./invoke-error.js";
35
+ import { VALUE_TYPES, VALUE_TYPE_BINDINGS } from "./value-type.js";
36
+ import type { OpenZoneAttributes, ZoneAttributes } from "./zone-attribute.js";
37
+
38
+ /**
39
+ * WHERE a step's target is declared, as opposed to which live object it is.
40
+ *
41
+ * This is the part of `step()` that nothing may move later, and it is what keeps
42
+ * the seam from being theatre. A target arrives at the engine as a **live
43
+ * instance** — Phase-5 injection has already replaced the `!ref` sentinel — and
44
+ * instance identity is process-local by construction (`ResourceHandle.ref` is
45
+ * declaration-site *diagnostics*, and there is deliberately no reverse
46
+ * handle→instance mapping). A backend asked to execute a step somewhere else
47
+ * would therefore have nothing to resolve, which would make the remote half
48
+ * unreachable and reduce `step(path, target, inputs)` to lookup-plus-record
49
+ * under a longer name.
50
+ *
51
+ * The three forms follow the three ways a resource is declared, and each is
52
+ * derivable identically by the analyzer and at runtime. The ENCODING — how one
53
+ * is written into bytes that cross a process boundary — is deliberately not
54
+ * fixed here: nothing in this slice sends one anywhere, and a normative format
55
+ * frozen with no consumer is the failure the sequencing rule exists to prevent.
56
+ */
57
+ export interface DurableTarget {
58
+ /** Canonical `<module>.<Kind>` of the target. */
59
+ readonly kind: string;
60
+ /** The target's `metadata.name`. Names are dot-free by the reference
61
+ * grammar's load-bearing invariant, so `(module, name)` is unambiguous. */
62
+ readonly name: string;
63
+ /** Source of the module that DECLARED the target, when known — the half that
64
+ * disambiguates two libraries each declaring a `store`. */
65
+ readonly module?: string;
66
+ /**
67
+ * The target is declared inside a `with:` scope.
68
+ *
69
+ * Separate from {@link scope} because the two are known at different places: a
70
+ * step engine can see THAT its target came from a scope — it resolved the name
71
+ * there — while the tuple identifying which scope RUN needs the step path the
72
+ * scope was opened at, which a scope handle is built without. Recording only
73
+ * the tuple would make an underivable scope indistinguishable from a
74
+ * module-level target, and that is the one difference that must not be lost:
75
+ * a scoped instance encoded as module-level resolves, at the far end, to a
76
+ * DIFFERENT resource that merely shares its name.
77
+ */
78
+ readonly scoped?: true;
79
+ /** A `with:`-scoped instance: the scope run is what makes it distinct, and
80
+ * inside a durable run a scope run is opened by a step at a determined path,
81
+ * so the tuple stays deterministic. */
82
+ readonly scope?: {
83
+ readonly owner: string;
84
+ readonly site: string;
85
+ readonly stepPath: string;
86
+ };
87
+ /** An inline declaration: anonymous in the manifest, not anonymous in the
88
+ * graph — the call graph already gives one its own node. */
89
+ readonly pointer?: string;
90
+ }
91
+
92
+ /** Why a decision was recorded. Carried for diagnostics and for a backend that
93
+ * wants to render a run; the engine's own behaviour does not branch on it. */
94
+ export type DurableDecisionKind =
95
+ | "inputs"
96
+ | "predicate"
97
+ | "condition"
98
+ | "collection"
99
+ | "switch"
100
+ | "value";
101
+
102
+ /**
103
+ * The three operations a backend implements, and the middle one is the whole
104
+ * design.
105
+ *
106
+ * A fourth member is a **question, not an operation** ({@link writesInside}).
107
+ */
108
+ export interface DurableRunHandle {
109
+ /** This run's identity, as the backend minted or accepted it. */
110
+ readonly runId: string;
111
+
112
+ /**
113
+ * Hand over an effect to be performed: the backend decides **whether**
114
+ * (replay returns the recorded result) and **where** (in process now, or
115
+ * shipped elsewhere and awaited).
116
+ *
117
+ * `execute` performs the in-process dispatch. Passing it is NOT the
118
+ * lookup-plus-record decomposition this seam rejects — there the CALLER
119
+ * performed the effect between two halves of one operation, which silently
120
+ * fixed the step engine and the resource graph in one process. Here the
121
+ * backend decides; `execute` is merely the local capability it may choose to
122
+ * use, and a relocating backend ignores it and ships `target` instead.
123
+ *
124
+ * Wherever a step ends up running, the executing side MUST dispatch through
125
+ * its kernel's invocation chokepoint, so the invocation contract, tracing,
126
+ * zones and observed state hold identically. A backend may move WHERE a step
127
+ * executes; it may not move it outside the runtime's dispatch.
128
+ */
129
+ step(
130
+ path: string,
131
+ target: DurableTarget | undefined,
132
+ inputs: unknown,
133
+ execute: () => Promise<unknown>,
134
+ ): Promise<unknown>;
135
+
136
+ /**
137
+ * Record a control-flow decision on first execution and return it verbatim on
138
+ * replay — a resolved input set, a branch predicate, a loop condition, an
139
+ * iteration collection.
140
+ *
141
+ * **This is the load-bearing half.** The tempting claim — "a run's entire
142
+ * mutable state is the `steps` map" — is FALSE: the CEL scope a step's inputs
143
+ * and a branch's predicate evaluate against also carries `resources.<name>`
144
+ * snapshots, `resources.<name>.status` (a live reading, republished on every
145
+ * dispatch BY DESIGN), provider values, variables and secrets. Re-evaluating
146
+ * any of those in a fresh process against freshly-created resources can yield
147
+ * a different answer, and the sharpest case is silent: an iteration whose
148
+ * collection comes from a resource read returns a different order on resume,
149
+ * index N now names a different element, and the journal hands back the
150
+ * recorded result for that path — with the same target, so no mismatch is
151
+ * detectable. Wrong results, no error.
152
+ *
153
+ * Recording the value rather than a digest is deliberate. Digest-and-detect is
154
+ * equally closed for DETECTION and much cheaper, and it is wrong: observed
155
+ * state is *defined* as a live reading, so a run would fail on every resume
156
+ * where the world had moved, which it usually has. That is not durability; it
157
+ * is fragility with good error messages.
158
+ *
159
+ * Replay is then a pure function of `(journal, manifest)` — a CLOSURE
160
+ * property, and closure is what makes this survive an ambient value source
161
+ * added years from now without anyone re-auditing a list.
162
+ */
163
+ decide<T>(path: string, kind: DurableDecisionKind, compute: () => T): Promise<T>;
164
+
165
+ /**
166
+ * Suspend the run until a time or a token.
167
+ *
168
+ * **A park is recorded, not merely thrown.** `where` is what makes it
169
+ * recoverable: the step path is where a resume re-enters and where a delivery
170
+ * writes its payload, so a backend that took only `until` could wake a run
171
+ * without knowing what it was waiting at. The parking resource's name rides
172
+ * along for diagnostics, since "run 41 is parked" is not an operator's answer.
173
+ *
174
+ * Called through {@link parkRun}, never directly — the latch that catches a
175
+ * swallowed suspension is set there, so a backend cannot forget it.
176
+ */
177
+ park(
178
+ where: { readonly path: string; readonly resource: string },
179
+ until: { readonly at?: number; readonly token?: string },
180
+ ): Promise<never>;
181
+
182
+ /**
183
+ * Does this handle's own recording land inside the given zone's atomicity?
184
+ *
185
+ * A question, not an operation — and it is what lets the step engine stop
186
+ * collapsing an `atomic` zone when collapsing would be pessimistic. A
187
+ * collapsed atomic zone is at-least-once (the whole zone re-runs on resume,
188
+ * because a crash between COMMIT and the journal write leaves work done and
189
+ * unrecorded) and that is unavoidable ONLY while the journal is somewhere
190
+ * else. When the journal writes into the very transaction whose effects it
191
+ * records, COMMIT is atomic over both and the window closes.
192
+ *
193
+ * So the collapse rule reads the attribute correctly rather than overriding
194
+ * it: `atomic` says *effects inside are discarded together*, and collapse
195
+ * follows only when the journal's own writes are NOT among them.
196
+ *
197
+ * Every backend that cannot answer yes returns false and behaves exactly as it
198
+ * would have without the question existing.
199
+ */
200
+ writesInside(zone: ZoneEntry): boolean;
201
+
202
+ /**
203
+ * Told when a region was collapsed to one entry, and why.
204
+ *
205
+ * Optional, and a NOTIFICATION rather than a question: the collapse decision
206
+ * is the step engine's, and the handle is being informed so it can report.
207
+ * That reporting is a conformance requirement rather than a nicety — whether a
208
+ * deployment got exactly-once or at-least-once turns on whether the journal's
209
+ * writes land inside the transaction's atomicity, which is a runtime
210
+ * coincidence the manifest cannot show. A durability feature whose guarantee
211
+ * is decided invisibly has to say which way it resolved.
212
+ */
213
+ noteZoneMode?(info: ZoneJournalingMode): void;
214
+ }
215
+
216
+ /**
217
+ * How one region resolved, at the moment it resolved.
218
+ *
219
+ * Both outcomes are reported, not only the collapsed one, and that is the point:
220
+ * `perStep` is the exactly-once regime and it is reached by an ATTESTATION made
221
+ * at runtime, so an operator asking "did this deployment get exactly-once"
222
+ * needs the affirmative answer as much as the negative. One field to filter on
223
+ * (`mode`) rather than the presence or absence of a record.
224
+ */
225
+ export interface ZoneJournalingMode {
226
+ /** The providing kind — `Sql.Transaction`, `Idempotency.Once`. */
227
+ readonly zone: string;
228
+ readonly attribute: "atomic" | "idempotent";
229
+ /** `collapsed`: the region records one entry and re-runs whole on resume.
230
+ * `perStep`: each step is recorded, and a rollback discards the records with
231
+ * the effects they describe. */
232
+ readonly mode: "collapsed" | "perStep";
233
+ /** The author's own sentence from the attribute, so the reason an operator
234
+ * reads is the manifest's rather than a generic one. */
235
+ readonly reason: string;
236
+ /** Why `perStep` was reached. Only `writesInside` today — the handle attested
237
+ * that its own records land inside this zone's atomicity — and named rather
238
+ * than implied, so a second attestation route is additive. */
239
+ readonly attestation?: "writesInside";
240
+ }
241
+
242
+ /**
243
+ * Compose a step path — the journal's key, and the reason journaling lives in
244
+ * the step engine at all.
245
+ *
246
+ * A step path is the only naturally deterministic key available, and it survives
247
+ * CONCURRENCY where a per-run call ordinal would not: two branches of a
248
+ * concurrent fan-out interleave their dispatches, so an ordinal would number
249
+ * them differently on every run while their paths stay fixed. It is also what
250
+ * makes each branch of a fan-out an independently resumable subtree.
251
+ *
252
+ * Segments are joined with `/`; a repetition (a loop turn, an iteration element)
253
+ * qualifies its segment with `[index]`. Both are properties of the WRITTEN
254
+ * structure plus the run's own decisions, never of wall-clock order.
255
+ */
256
+ export function stepPath(...segments: (string | number)[]): string {
257
+ return segments
258
+ .map((s) => (typeof s === "number" ? `[${s}]` : s))
259
+ .join("/")
260
+ .replace(/\/\[/g, "[");
261
+ }
262
+
263
+ /** True when a run handle is ambient — the test the step engine makes before it
264
+ * journals anything, so a non-durable sequence pays nothing. */
265
+ export function durableHandleOf(ctx: InvokeContext | undefined): DurableRunHandle | undefined {
266
+ return ctx?.durable;
267
+ }
268
+
269
+ /**
270
+ * What the step engine consults before journaling anything — the collapse rule,
271
+ * read off the ambient zone stack.
272
+ *
273
+ * > A region collapses to one entry when **re-running it is safe** — because its
274
+ * > effects are discarded together, or because re-running is a no-op.
275
+ *
276
+ * The rule has no fields in it, at either end. It used to be a caller-side
277
+ * `checkpoint: collapse` plus a callee-side `requireCheckpoints:` veto, which
278
+ * was wrong four ways at once: a boolean where every neighbouring annotation
279
+ * carries a reason; the opposite polarity from *everything journaled by
280
+ * default*, so forgetting to veto was silent; a veto available only on
281
+ * `Run.Sequence`, leaving a collapsed script or imported invocable unprotected;
282
+ * and a contradiction check to reconcile it with atomicity. Underneath all four,
283
+ * collapse was sold as a cost lever while being a CORRECTNESS decision — it
284
+ * silently converts exactly-once into at-least-once.
285
+ *
286
+ * A region with a property is a zone, so it is declared the way every other
287
+ * region property is. Nothing collapses a sequence because nothing wrapped it.
288
+ */
289
+ export function collapsesJournalEntries(
290
+ zones: readonly { kind: string; attributes: ZoneAttributes; entry: ZoneEntry }[],
291
+ handle: DurableRunHandle,
292
+ ): { collapsed: boolean; resolutions: ZoneJournalingMode[] } {
293
+ // Every region carrying one of the two attributes is resolved and REPORTED,
294
+ // including the ones walked past before a collapsing one was found: a
295
+ // transaction that attested its writes is an exactly-once region whether or
296
+ // not an idempotent zone further out collapses the whole thing, and reporting
297
+ // only the verdict would lose it.
298
+ const resolutions: ZoneJournalingMode[] = [];
299
+ for (const zone of zones) {
300
+ // `idempotent` collapses FULL STOP: there is nothing for the journal to be
301
+ // inside, and re-running is a no-op either way.
302
+ if (zone.attributes.idempotent) {
303
+ resolutions.push({
304
+ zone: zone.kind,
305
+ attribute: "idempotent",
306
+ mode: "collapsed",
307
+ reason: zone.attributes.idempotent,
308
+ });
309
+ return { collapsed: true, resolutions };
310
+ }
311
+ // `atomic` collapses UNLESS the handle attests its own writes land inside
312
+ // that atomicity. This is not an override of the attribute; it is the
313
+ // attribute read correctly — *effects inside are discarded together*, so
314
+ // collapse follows only when the journal's writes are not among them. When
315
+ // they are, per-step journaling is consistent by construction AND strictly
316
+ // better: finer replay granularity, and no re-running a committed
317
+ // transaction.
318
+ if (zone.attributes.atomic) {
319
+ if (handle.writesInside(zone.entry)) {
320
+ resolutions.push({
321
+ zone: zone.kind,
322
+ attribute: "atomic",
323
+ mode: "perStep",
324
+ reason: zone.attributes.atomic,
325
+ attestation: "writesInside",
326
+ });
327
+ continue;
328
+ }
329
+ resolutions.push({
330
+ zone: zone.kind,
331
+ attribute: "atomic",
332
+ mode: "collapsed",
333
+ reason: zone.attributes.atomic,
334
+ });
335
+ return { collapsed: true, resolutions };
336
+ }
337
+ }
338
+ return { collapsed: false, resolutions };
339
+ }
340
+
341
+ /** The zone-reading half of a step context — declared here so both the leaf and
342
+ * the engine read the collapse rule through one signature. */
343
+ export interface ZoneReadingContext {
344
+ zoneAttributes?(ctx?: InvokeContext): readonly OpenZoneAttributes[];
345
+ }
346
+
347
+ /**
348
+ * Should the step engine record nothing of its own right here?
349
+ *
350
+ * True inside a collapsed region, where per-step entries would describe work
351
+ * that is about to happen again — the region re-runs whole on resume, which is
352
+ * exactly what its author's attribute claims is safe.
353
+ *
354
+ * A host with no zone machinery reads as "no zone open", which journals MORE
355
+ * rather than less: the safe direction, since an unjournaled effect re-executes
356
+ * silently while a redundant entry costs a write.
357
+ */
358
+ export function journalingSuppressed(
359
+ ctx: ZoneReadingContext,
360
+ invokeCtx: InvokeContext | undefined,
361
+ handle: DurableRunHandle,
362
+ ): boolean {
363
+ const zones = ctx.zoneAttributes?.(invokeCtx);
364
+ if (!zones || zones.length === 0) return false;
365
+ const verdict = collapsesJournalEntries(zones, handle);
366
+ for (const resolution of verdict.resolutions) handle.noteZoneMode?.(resolution);
367
+ return verdict.collapsed;
368
+ }
369
+
370
+ /**
371
+ * Reject a value that cannot survive being recorded and read back.
372
+ *
373
+ * **`JSON.stringify` is not the test, and believing it was left the gate open.**
374
+ * A live handle has no enumerable state, so `JSON.stringify(stream)` returns
375
+ * `{}` and throws nothing — the one case the spec names first would have been
376
+ * recorded as an empty object and replayed as one, which is silent corruption
377
+ * rather than the loud failure §6 requires. The static half is deliberately only
378
+ * a warning ("the runtime is the gate"), so a gate that cannot see the case
379
+ * leaves it unenforced end to end.
380
+ *
381
+ * So a live value is detected STRUCTURALLY, by the value-type vocabulary's own
382
+ * binding table: an entry declaring `live: true` names a binding, and the host's
383
+ * table maps that binding to the constructor an assertion tests against. No type
384
+ * name is written here, so a live type added later is covered by its entry
385
+ * alone — the same reason the static rule reads the `live` field rather than
386
+ * naming `Telo.Stream`.
387
+ *
388
+ * Lives in the SDK rather than in a backend because it is a property of the
389
+ * CONTRACT (spec §6), not of one journal: a backend that skipped it would be
390
+ * non-conforming in a way nothing else could catch.
391
+ */
392
+ export function assertJournalable(value: unknown, where: { run: string; path: string }): void {
393
+ const live = findLiveValue(value, new Set());
394
+ if (live) {
395
+ throw new InvokeError(
396
+ "ERR_DURABLE_UNJOURNALABLE_VALUE",
397
+ `Run '${where.run}': the value produced at '${where.path}' contains a ${live} handle, ` +
398
+ `which cannot be recorded. A live value is produced by CONSUMING it, so it exists ` +
399
+ `exactly once and a record of it would be a record of nothing — a replay would hand ` +
400
+ `the next step an empty value instead of the data. Read what you need out of it ` +
401
+ `inside the step and return that, or move the streaming work outside the durable body.`,
402
+ { run: where.run, path: where.path, valueType: live },
403
+ );
404
+ }
405
+ try {
406
+ JSON.stringify(value);
407
+ } catch (err) {
408
+ throw new InvokeError(
409
+ "ERR_DURABLE_UNJOURNALABLE_VALUE",
410
+ `Run '${where.run}': the value produced at '${where.path}' cannot be serialized ` +
411
+ `(${(err as Error).message}). A durable step's result and every decision it reaches ` +
412
+ `must survive being written and read back.`,
413
+ { run: where.run, path: where.path },
414
+ { cause: err },
415
+ );
416
+ }
417
+ }
418
+
419
+ /** The name of the first live value type found anywhere in `value`, or
420
+ * undefined. Walks plain containers only — a class instance that is not a live
421
+ * type is left to the serializer to judge, since `toJSON` may well make it
422
+ * recordable. */
423
+ function findLiveValue(value: unknown, seen: Set<object>): string | undefined {
424
+ if (!value || typeof value !== "object") return undefined;
425
+ if (seen.has(value)) return undefined;
426
+ seen.add(value);
427
+
428
+ for (const entry of VALUE_TYPES.values()) {
429
+ if (!entry.live || !entry.binding) continue;
430
+ const binding = VALUE_TYPE_BINDINGS[entry.binding];
431
+ if (binding && value instanceof binding.constructor) return entry.name;
432
+ }
433
+
434
+ if (Array.isArray(value)) {
435
+ for (const item of value) {
436
+ const found = findLiveValue(item, seen);
437
+ if (found) return found;
438
+ }
439
+ return undefined;
440
+ }
441
+ // Only plain objects are descended into: walking an arbitrary instance's
442
+ // fields would report a live handle a `toJSON` was about to drop anyway.
443
+ const proto = Object.getPrototypeOf(value);
444
+ if (proto !== Object.prototype && proto !== null) return undefined;
445
+ for (const item of Object.values(value as Record<string, unknown>)) {
446
+ const found = findLiveValue(item, seen);
447
+ if (found) return found;
448
+ }
449
+ return undefined;
450
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Suspension — the signal a parking kind raises, and the latch that catches a
3
+ * swallowed one. Slice 4 of `kernel/specs/durable-execution.md`.
4
+ *
5
+ * **Suspension is a distinct signal, not an error.** It unwinds the stack to the
6
+ * workflow boundary, so a `try:` step must not catch it, a composer's
7
+ * `catches:` must not map it, and a retry policy must not re-attempt it. Those
8
+ * three sites rethrow on {@link isSuspension} before they convert anything.
9
+ *
10
+ * **But naming the known swallowers is not a defence.** The signal passes
11
+ * through every controller between the parking kind and the workflow — HTTP
12
+ * handlers, agent tool loops, a cache view, and any third-party controller with
13
+ * a `catch (e)` in it. A swallowed suspension silently converts a park into a
14
+ * completed step and duplicates every effect after it, and a pure-conduit kernel
15
+ * cannot see that happen. Enumerating the swallowers is unbounded and would go
16
+ * stale on the next module.
17
+ *
18
+ * So it is **latched, not just thrown**: {@link parkRun} records the signal
19
+ * against the run handle at the moment it raises, and the workflow kind treats
20
+ * *an invocation that returned normally while a suspension is latched* as a hard
21
+ * error ({@link assertNotSwallowed}). Detection is O(1), needs no cooperation
22
+ * from the swallower, and turns an unbounded-surface silent corruption into one
23
+ * loud failure at the boundary that owns the run.
24
+ *
25
+ * **The latch lives here rather than on the backend's handle**, and the
26
+ * distinction is what makes it a guarantee: a backend that had to set it could
27
+ * forget to, and the failure of forgetting is the silent corruption this exists
28
+ * to catch. It is a `WeakMap` in the SDK — the one package with a single scope
29
+ * per process (`REALM_COLLAPSE_NAMES` symlinks it onto the kernel's copy), so
30
+ * the payload rule's "provider-private state hangs off an injected instance"
31
+ * does not bite: there is exactly one map however many controller bundles are
32
+ * loaded.
33
+ */
34
+
35
+ import type { InvokeContext } from "./cancellation.js";
36
+ import type { DurableRunHandle } from "./durable-run.js";
37
+ import { InvokeError } from "./invoke-error.js";
38
+
39
+ /**
40
+ * The backoff above which a retry PARKS instead of sleeping.
41
+ *
42
+ * A threshold is unavoidable and so is stating where it came from. Below it,
43
+ * sleeping in process is cheaper than a park: a park is a journal write, a
44
+ * process exit and a poller pass, and paying that to save a few seconds of an
45
+ * idle timer is worse on both counts. Above it, holding a process open to wait
46
+ * is precisely what durability exists to stop — and the plan's own retry
47
+ * example (`delay: 10s`, three attempts) sits deliberately below it, while a
48
+ * policy backing off to minutes sits above.
49
+ *
50
+ * Shared with the analyzer, which decides statically whether a step's declared
51
+ * policy COULD suspend, so the check and the behaviour cannot disagree about
52
+ * where the line is.
53
+ */
54
+ export const SUSPENDING_BACKOFF_MS = 30_000;
55
+
56
+ /** The code every suspension carries, so a runtime that only sees a shape can
57
+ * still recognise one. */
58
+ export const ERR_DURABLE_SUSPENDED = "ERR_DURABLE_SUSPENDED";
59
+
60
+ /** Where a parked run is waiting for. Exactly one half is meaningful to a
61
+ * backend at a time, but both may be present: an await with a deadline parks on
62
+ * its token AND is due at a time. */
63
+ export interface ParkUntil {
64
+ /** Epoch milliseconds at which the run becomes due. */
65
+ readonly at?: number;
66
+ /** The token a delivery must carry to wake this run. */
67
+ readonly token?: string;
68
+ }
69
+
70
+ /**
71
+ * The signal itself.
72
+ *
73
+ * An `Error` so it unwinds, and deliberately **not** an {@link InvokeError}: an
74
+ * `InvokeError` is the channel a `catches:` list maps by code, and a suspension
75
+ * that could be named there would be catchable by configuration — which is the
76
+ * corruption, spelled as a feature.
77
+ */
78
+ export class DurableSuspension extends Error {
79
+ readonly code = ERR_DURABLE_SUSPENDED;
80
+
81
+ constructor(
82
+ /** The run that parked. */
83
+ readonly runId: string,
84
+ /** The step path it parked at — the journal key its park is recorded under. */
85
+ readonly path: string,
86
+ /** The parking resource's `metadata.name`, so the diagnostic names what
87
+ * waited rather than only where. */
88
+ readonly resource: string,
89
+ readonly until: ParkUntil,
90
+ ) {
91
+ super(
92
+ `Run '${runId}' parked at '${path}' (${resource}). This is a suspension signal, not a ` +
93
+ `failure: it must reach the workflow that owns the run.`,
94
+ );
95
+ this.name = "DurableSuspension";
96
+ }
97
+ }
98
+
99
+ /** Is this the suspension signal? Structural rather than `instanceof`, for the
100
+ * same reason every other cross-boundary test here is: the SDK is one scope per
101
+ * process today, and a runtime that threads a handle it owns should still be
102
+ * recognised. */
103
+ export function isSuspension(err: unknown): err is DurableSuspension {
104
+ return (
105
+ typeof err === "object" &&
106
+ err !== null &&
107
+ (err as { code?: unknown }).code === ERR_DURABLE_SUSPENDED
108
+ );
109
+ }
110
+
111
+ const LATCHED = new WeakMap<DurableRunHandle, DurableSuspension>();
112
+
113
+ /**
114
+ * Park the run — the one way a parking kind suspends.
115
+ *
116
+ * Latches first, then asks the backend to record the park, then throws. Each of
117
+ * the three is load-bearing. Latching FIRST means a backend whose `park()`
118
+ * itself throws something else still leaves the evidence behind. Throwing here
119
+ * rather than trusting `park()`'s `Promise<never>` means a backend that returns
120
+ * — the one bug that would convert a wait into a completed step — is caught by
121
+ * the seam instead of by whatever ran next.
122
+ */
123
+ export async function parkRun(
124
+ handle: DurableRunHandle,
125
+ where: { readonly path: string; readonly resource: string },
126
+ until: ParkUntil,
127
+ ): Promise<never> {
128
+ const signal = new DurableSuspension(handle.runId, where.path, where.resource, until);
129
+ LATCHED.set(handle, signal);
130
+ await handle.park(where, until);
131
+ throw signal;
132
+ }
133
+
134
+ /** The suspension latched against this run, if one was raised during this
135
+ * execution. */
136
+ export function latchedSuspension(handle: DurableRunHandle): DurableSuspension | undefined {
137
+ return LATCHED.get(handle);
138
+ }
139
+
140
+ /**
141
+ * The workflow boundary's check: a body that returned normally while a
142
+ * suspension is latched swallowed one.
143
+ *
144
+ * Raised rather than repaired, because there is nothing to repair: every effect
145
+ * after the swallow already ran, un-parked and un-recorded, and the run's own
146
+ * record would say it completed. What is actionable is the pair of names — the
147
+ * resource that parked and the step path — since the swallower is somewhere
148
+ * between them.
149
+ */
150
+ export function assertNotSwallowed(handle: DurableRunHandle): void {
151
+ const signal = LATCHED.get(handle);
152
+ if (!signal) return;
153
+ throw new InvokeError(
154
+ "ERR_DURABLE_SUSPENSION_SWALLOWED",
155
+ `Run '${signal.runId}': '${signal.resource}' parked the run at step '${signal.path}', but ` +
156
+ `the body returned normally — something between them caught the suspension signal and ` +
157
+ `continued. Every step after the park has now run outside the journal, and the run would ` +
158
+ `have been recorded as completed. A controller in that path has a 'catch' that swallows ` +
159
+ `unknown errors; it must rethrow anything it did not recognise.`,
160
+ { run: signal.runId, path: signal.path, resource: signal.resource },
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Refuse to park inside a zone that forbids it.
166
+ *
167
+ * The runtime half of `noSuspend` — one rule rather than an enumeration of
168
+ * forbidden kinds, so a parking kind added later is covered without touching
169
+ * anything here. The zone's own declared sentence is printed verbatim: it is the
170
+ * author's statement of what is being held open, and nothing generated says it
171
+ * better.
172
+ */
173
+ export function assertMaySuspend(
174
+ ctx: { zoneAttributes?(ctx?: InvokeContext): readonly { kind: string; attributes: { noSuspend?: string } }[] },
175
+ invokeCtx: InvokeContext | undefined,
176
+ where: { readonly resource: string },
177
+ ): void {
178
+ for (const zone of ctx.zoneAttributes?.(invokeCtx) ?? []) {
179
+ const reason = zone.attributes.noSuspend;
180
+ if (!reason) continue;
181
+ throw new InvokeError(
182
+ "ERR_DURABLE_SUSPEND_FORBIDDEN",
183
+ `'${where.resource}' would park the run, but it is inside a ${zone.kind} zone that ` +
184
+ `cannot be held open across a suspension: ${reason}`,
185
+ { resource: where.resource, zone: zone.kind, reason },
186
+ );
187
+ }
188
+ }