@noetic-tools/core 5.7.0 → 5.8.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 (44) hide show
  1. package/dist/builders/channel-builder.d.ts +18 -0
  2. package/dist/builders/control-flow-builders.d.ts +17 -30
  3. package/dist/builders/decide-builders.d.ts +5 -5
  4. package/dist/builders/decide-builders.js +4 -0
  5. package/dist/builders/every.d.ts +2 -2
  6. package/dist/builders/every.js +2 -0
  7. package/dist/builders/loop-builder.d.ts +1 -1
  8. package/dist/builders/loop-builder.js +1 -1
  9. package/dist/builders/provide-builder.d.ts +2 -2
  10. package/dist/builders/provide-builder.js +2 -0
  11. package/dist/builders/spawn-builder.d.ts +2 -2
  12. package/dist/builders/spawn-builder.js +2 -0
  13. package/dist/builders/step-builders.d.ts +8 -8
  14. package/dist/builders/workflow-hydrator.js +6 -1
  15. package/dist/harness/agent-harness.d.ts +18 -5
  16. package/dist/harness/agent-harness.js +49 -14
  17. package/dist/harness/deps/runtime.d.ts +3 -2
  18. package/dist/harness/deps/runtime.js +2 -2
  19. package/dist/harness/model-call.js +10 -0
  20. package/dist/index.d.ts +7 -5
  21. package/dist/index.js +3 -3
  22. package/dist/interpreter/child-span.d.ts +17 -0
  23. package/dist/interpreter/child-span.js +51 -0
  24. package/dist/interpreter/execute-action.js +21 -16
  25. package/dist/interpreter/execute-control.js +64 -8
  26. package/dist/interpreter/execute.d.ts +10 -0
  27. package/dist/interpreter/execute.js +41 -12
  28. package/dist/observability/genai-attributes.d.ts +45 -29
  29. package/dist/observability/genai-attributes.js +19 -1
  30. package/dist/runtime/channel-store.d.ts +73 -1
  31. package/dist/runtime/channel-store.js +179 -63
  32. package/dist/runtime/context-impl.d.ts +7 -1
  33. package/dist/runtime/context-impl.js +15 -0
  34. package/dist/runtime/durable/harness-checkpoints.d.ts +5 -1
  35. package/dist/runtime/durable/harness-checkpoints.js +8 -2
  36. package/dist/runtime/durable/step-ledger.d.ts +14 -4
  37. package/dist/runtime/durable/step-ledger.js +22 -5
  38. package/dist/types/checkpoint.d.ts +31 -0
  39. package/dist/types/checkpoint.js +29 -0
  40. package/dist/until/combinators.d.ts +10 -0
  41. package/dist/until/combinators.js +37 -13
  42. package/dist/until/predicates.js +4 -2
  43. package/package.json +3 -3
  44. package/schema/noetic-workflow.schema.json +57 -0
@@ -1,7 +1,20 @@
1
- import { frameworkCast, NoeticErrorImpl } from '@noetic-tools/types';
2
- const MAX_TOPIC_TIMEOUT = 300_000; // 5 minutes
1
+ import { frameworkCast, NoeticConfigError, NoeticErrorImpl } from '@noetic-tools/types';
3
2
  const DEFAULT_SEND_TIMEOUT = 30_000;
4
3
  const RESOLVED = Promise.resolve();
4
+ /**
5
+ * Reject a negative (or NaN) channel timeout. `0` is legal and disables the
6
+ * timeout; anything below it is a caller bug, not a shorter wait.
7
+ */
8
+ function invalidTimeout(channelName, timeout) {
9
+ if (timeout >= 0) {
10
+ return null;
11
+ }
12
+ return new NoeticConfigError({
13
+ code: 'INVALID_CHANNEL_TIMEOUT',
14
+ message: `Channel '${channelName}': timeout must be a non-negative number of milliseconds, got ${timeout}.`,
15
+ hint: 'Pass a positive timeout in milliseconds, or 0 to wait with no timeout.',
16
+ });
17
+ }
5
18
  /** Build the `cancelled` error a blocked channel operation rejects with on abort. */
6
19
  function cancelledError(signal) {
7
20
  return new NoeticErrorImpl({
@@ -9,6 +22,10 @@ function cancelledError(signal) {
9
22
  reason: typeof signal.reason === 'string' ? signal.reason : undefined,
10
23
  });
11
24
  }
25
+ /** Print a channel warning with the `[noetic]` prefix. */
26
+ export function warnToConsole(warning) {
27
+ console.warn(`[noetic] ${warning.message}`);
28
+ }
12
29
  export class ChannelStore {
13
30
  channels = new Map();
14
31
  closedExecutions = new Set();
@@ -21,25 +38,87 @@ export class ChannelStore {
21
38
  */
22
39
  handleObserved = new Set();
23
40
  subscribersByExecution = new Map();
41
+ /**
42
+ * Channel name → ids of the root executions that used it, which decides
43
+ * the channels an execution's checkpoint carries. Ids stay after their
44
+ * execution closes: a host may checkpoint a finished run, and channel
45
+ * state itself lives for the harness lifetime (spec 06, Lifecycle).
46
+ */
47
+ users = new Map();
48
+ warn;
49
+ constructor(opts = {}) {
50
+ this.warn = opts.onWarning ?? warnToConsole;
51
+ }
24
52
  getOrCreate(channel) {
25
- let state = frameworkCast(this.channels.get(channel.name));
26
- if (!state) {
27
- state = {
28
- mode: channel.mode,
29
- hasValue: false,
30
- valueWaiters: [],
31
- queue: [],
32
- capacity: channel.capacity ?? 1_000,
33
- queueWaiters: [],
34
- pendingSenders: [],
35
- topicSubscribers: new Set(),
36
- wakeSubscribers: new Set(),
37
- externalSubscribers: new Set(),
38
- };
39
- this.channels.set(channel.name, frameworkCast(state));
53
+ const existing = frameworkCast(this.channels.get(channel.name));
54
+ if (existing) {
55
+ return existing;
40
56
  }
57
+ const state = emptyState(channel.mode, channel.capacity ?? 1_000);
58
+ this.channels.set(channel.name, frameworkCast(state));
41
59
  return state;
42
60
  }
61
+ /**
62
+ * Record that `executionId` (a root execution) uses `channel`, so the
63
+ * channel rides along in its checkpoints.
64
+ */
65
+ claim(channel, executionId) {
66
+ this.addUser(channel.name, executionId);
67
+ }
68
+ addUser(channelName, executionId) {
69
+ let ids = this.users.get(channelName);
70
+ if (!ids) {
71
+ ids = new Set();
72
+ this.users.set(channelName, ids);
73
+ }
74
+ ids.add(executionId);
75
+ }
76
+ /**
77
+ * Stored state (values, queued items) of every channel `executionId` used.
78
+ * Parked receivers, parked senders, and subscribers are live promises and
79
+ * stay out; topic channels store nothing and are skipped.
80
+ */
81
+ snapshot(executionId) {
82
+ const out = [];
83
+ for (const [name, ids] of this.users) {
84
+ const state = this.channels.get(name);
85
+ if (!state || !ids.has(executionId)) {
86
+ continue;
87
+ }
88
+ if (state.mode === 'value') {
89
+ out.push({
90
+ name,
91
+ mode: 'value',
92
+ value: state.currentValue,
93
+ hasValue: state.hasValue,
94
+ });
95
+ }
96
+ if (state.mode === 'queue') {
97
+ out.push({
98
+ name,
99
+ mode: 'queue',
100
+ capacity: state.capacity,
101
+ queue: [
102
+ ...state.queue,
103
+ ],
104
+ });
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+ /**
110
+ * Refill channel state from a checkpoint and mark `executionId` as a user.
111
+ * A channel that already has live state keeps it — live state is never
112
+ * older than a snapshot.
113
+ */
114
+ restore(executionId, snapshots) {
115
+ for (const snap of snapshots) {
116
+ if (!this.channels.has(snap.name)) {
117
+ this.channels.set(snap.name, restoredState(snap));
118
+ }
119
+ this.addUser(snap.name, executionId);
120
+ }
121
+ }
43
122
  /**
44
123
  * Subscribe to wake notifications on a channel without consuming any value.
45
124
  * Fired once on the next `send()` for any mode, then auto-removed. Returns
@@ -70,8 +149,16 @@ export class ChannelStore {
70
149
  * - **topic**: deliver to current subscribers, resolve immediately.
71
150
  *
72
151
  * External callers use `ChannelHandle.send` (sync, drop-oldest) instead.
152
+ * `opts.timeout: 0` parks with no timeout and warns `unbounded_wait`.
153
+ *
154
+ * @throws `NoeticConfigError` with code `INVALID_CHANNEL_TIMEOUT` (as a
155
+ * rejection) when `opts.timeout` is negative.
73
156
  */
74
157
  send(channel, value, opts) {
158
+ const invalid = invalidTimeout(channel.name, opts?.timeout ?? DEFAULT_SEND_TIMEOUT);
159
+ if (invalid) {
160
+ return Promise.reject(invalid);
161
+ }
75
162
  const state = this.getOrCreate(channel);
76
163
  let result = RESOLVED;
77
164
  switch (state.mode) {
@@ -106,8 +193,8 @@ export class ChannelStore {
106
193
  }
107
194
  break;
108
195
  case 'topic':
109
- for (const sub of state.topicSubscribers) {
110
- sub(value);
196
+ for (const waiter of state.topicWaiters.splice(0)) {
197
+ waiter.resolve(value);
111
198
  }
112
199
  for (const sub of state.externalSubscribers) {
113
200
  sub.push(value);
@@ -153,7 +240,15 @@ export class ChannelStore {
153
240
  state.pendingSenders.splice(idx, 1);
154
241
  return true;
155
242
  };
156
- if (timeout > 0) {
243
+ if (timeout === 0) {
244
+ this.warn({
245
+ kind: 'unbounded_wait',
246
+ channelName,
247
+ operation: 'send',
248
+ message: `Channel '${channelName}': send parked on a full queue with timeout 0 — it waits until a consumer frees a slot or the context aborts.`,
249
+ });
250
+ }
251
+ else {
157
252
  timer = setTimeout(() => {
158
253
  if (removeEntry()) {
159
254
  entry.reject(new NoeticErrorImpl({
@@ -208,7 +303,20 @@ export class ChannelStore {
208
303
  wake();
209
304
  }
210
305
  }
306
+ /**
307
+ * Receive by mode: value returns the current value, queue dequeues the
308
+ * head, topic waits for the next send. A receiver that has to wait gets
309
+ * `channel_timeout` after `timeout` ms; `0` waits with no timeout in every
310
+ * mode and warns `unbounded_wait`.
311
+ *
312
+ * @throws `NoeticConfigError` with code `INVALID_CHANNEL_TIMEOUT` (as a
313
+ * rejection) when `timeout` is negative.
314
+ */
211
315
  async recv(channel, timeout = 30_000, signal) {
316
+ const invalid = invalidTimeout(channel.name, timeout);
317
+ if (invalid) {
318
+ throw invalid;
319
+ }
212
320
  // An already-aborted context must reject promptly rather than park a
213
321
  // waiter that nothing will ever wake (spec 09, Cancellation item 2).
214
322
  if (signal?.aborted) {
@@ -235,46 +343,8 @@ export class ChannelStore {
235
343
  }
236
344
  return this.waitWithTimeout(state.queueWaiters, channel.name, timeout, signal);
237
345
  }
238
- case 'topic': {
239
- // Clamp timeout to prevent indefinite subscriber leaks
240
- let effectiveTimeout = timeout;
241
- if (effectiveTimeout <= 0) {
242
- console.warn(`[noetic] Channel '${channel.name}': topic recv with non-positive timeout, clamping to ${MAX_TOPIC_TIMEOUT}ms`);
243
- effectiveTimeout = MAX_TOPIC_TIMEOUT;
244
- }
245
- return new Promise((resolve, reject) => {
246
- let removeAbortListener = null;
247
- const timer = setTimeout(() => {
248
- state.topicSubscribers.delete(handler);
249
- removeAbortListener?.();
250
- reject(new NoeticErrorImpl({
251
- kind: 'channel_timeout',
252
- channelName: channel.name,
253
- timeout: effectiveTimeout,
254
- }));
255
- }, effectiveTimeout);
256
- const handler = (value) => {
257
- clearTimeout(timer);
258
- state.topicSubscribers.delete(handler);
259
- removeAbortListener?.();
260
- resolve(value);
261
- };
262
- state.topicSubscribers.add(handler);
263
- if (signal) {
264
- const onAbort = () => {
265
- clearTimeout(timer);
266
- state.topicSubscribers.delete(handler);
267
- reject(cancelledError(signal));
268
- };
269
- signal.addEventListener('abort', onAbort, {
270
- once: true,
271
- });
272
- removeAbortListener = () => {
273
- signal.removeEventListener('abort', onAbort);
274
- };
275
- }
276
- });
277
- }
346
+ case 'topic':
347
+ return this.waitWithTimeout(state.topicWaiters, channel.name, timeout, signal);
278
348
  }
279
349
  }
280
350
  tryRecv(channel) {
@@ -325,7 +395,15 @@ export class ChannelStore {
325
395
  waiters.splice(idx, 1);
326
396
  return true;
327
397
  };
328
- if (timeout > 0) {
398
+ if (timeout === 0) {
399
+ this.warn({
400
+ kind: 'unbounded_wait',
401
+ channelName,
402
+ operation: 'recv',
403
+ message: `Channel '${channelName}': recv waiting with timeout 0 — it waits until a value arrives or the context aborts.`,
404
+ });
405
+ }
406
+ else {
329
407
  timer = setTimeout(() => {
330
408
  if (removeEntry()) {
331
409
  wrappedReject(new NoeticErrorImpl({
@@ -362,7 +440,12 @@ export class ChannelStore {
362
440
  state.queueWaiters.length === 0 &&
363
441
  state.queue.length >= state.capacity;
364
442
  if (isFullQueue) {
365
- console.warn(`[noetic] Channel '${channel.name}': queue at capacity (${state.capacity}), dropping oldest message (external sender).`);
443
+ this.warn({
444
+ kind: 'overflow_drop',
445
+ channelName: channel.name,
446
+ capacity: state.capacity,
447
+ message: `Channel '${channel.name}': queue at capacity (${state.capacity}), dropping oldest message (external sender).`,
448
+ });
366
449
  state.queue.shift();
367
450
  state.queue.push(value);
368
451
  this.fireWakes(state);
@@ -391,10 +474,12 @@ export class ChannelStore {
391
474
  if (this.closedExecutions.has(executionId)) {
392
475
  return CLOSED_ITERABLE;
393
476
  }
477
+ this.claim(channel, executionId);
394
478
  const state = this.getOrCreate(channel);
395
479
  const subscriber = new ExternalSubscriber({
396
480
  channelName: channel.name,
397
481
  conflate: state.mode === 'value',
482
+ warn: this.warn,
398
483
  takeShared: state.mode === 'queue'
399
484
  ? () => {
400
485
  const head = this.dequeueHead(state);
@@ -462,6 +547,7 @@ export class ChannelStore {
462
547
  }
463
548
  getHandle(channel, executionId) {
464
549
  this.handleObserved.add(executionId);
550
+ this.claim(channel, executionId);
465
551
  const store = this;
466
552
  return {
467
553
  get closed() {
@@ -506,6 +592,31 @@ export class ChannelStore {
506
592
  this.closedExecutions.delete(executionId);
507
593
  }
508
594
  }
595
+ function emptyState(mode, capacity) {
596
+ return {
597
+ mode,
598
+ hasValue: false,
599
+ valueWaiters: [],
600
+ queue: [],
601
+ capacity,
602
+ queueWaiters: [],
603
+ pendingSenders: [],
604
+ topicWaiters: [],
605
+ wakeSubscribers: new Set(),
606
+ externalSubscribers: new Set(),
607
+ };
608
+ }
609
+ function restoredState(snap) {
610
+ if (snap.mode === 'value') {
611
+ const state = emptyState('value', 1_000);
612
+ state.currentValue = snap.value;
613
+ state.hasValue = snap.hasValue;
614
+ return state;
615
+ }
616
+ const state = emptyState('queue', snap.capacity);
617
+ state.queue.push(...snap.queue);
618
+ return state;
619
+ }
509
620
  const MAX_EXTERNAL_BUFFER = 1_000;
510
621
  const CLOSED_ITERABLE = {
511
622
  [Symbol.asyncIterator]() {
@@ -559,7 +670,12 @@ class ExternalSubscriber {
559
670
  return;
560
671
  }
561
672
  if (this.buffer.length >= MAX_EXTERNAL_BUFFER) {
562
- console.warn(`[noetic] Channel '${this.opts.channelName}': external subscriber buffer at capacity (${MAX_EXTERNAL_BUFFER}), dropping oldest value.`);
673
+ this.opts.warn({
674
+ kind: 'subscriber_drop',
675
+ channelName: this.opts.channelName,
676
+ capacity: MAX_EXTERNAL_BUFFER,
677
+ message: `Channel '${this.opts.channelName}': external subscriber buffer at capacity (${MAX_EXTERNAL_BUFFER}), dropping oldest value.`,
678
+ });
563
679
  this.buffer.shift();
564
680
  }
565
681
  this.buffer.push(value);
@@ -1,5 +1,11 @@
1
1
  import type { AgentHarnessContract, Channel, ChannelStore, Context, ContextData, ContextLayer, CwdState, EventBroadcaster, FrontierFrame, FsAdapter, Item, ItemLog, ItemSchemaRegistry, LastLayerUsage, Span, StepMeta, TokenUsage, Tool } from './context-deps.js';
2
2
  import type { StepLedger } from './durable/step-ledger.js';
3
+ /**
4
+ * @internal
5
+ * Id of the root execution `ctx` runs under — the id `run()` opens and
6
+ * closes channel scopes with (spec 06).
7
+ */
8
+ export declare function rootIdOf(ctx: Context): string;
3
9
  /**
4
10
  * @internal
5
11
  * The cancellation tree rooted at `ctx`, in pre-order (a parent before its
@@ -156,7 +162,7 @@ export declare class ContextImpl implements Context<ContextData> {
156
162
  recv<T>(ch: Channel<T>, opts?: {
157
163
  timeout?: number;
158
164
  }): Promise<T>;
159
- send<T>(ch: Channel<T>, value: T): Promise<void>;
165
+ send<T>(ch: Channel<T>, value: NoInfer<T>): Promise<void>;
160
166
  tryRecv<T>(ch: Channel<T>): T | null;
161
167
  checkpoint(): Promise<void>;
162
168
  complete<T>(value: T): void;
@@ -9,6 +9,18 @@ class NoopSpan {
9
9
  addEvent(_name, _attributes) { }
10
10
  end() { }
11
11
  }
12
+ /**
13
+ * @internal
14
+ * Id of the root execution `ctx` runs under — the id `run()` opens and
15
+ * closes channel scopes with (spec 06).
16
+ */
17
+ export function rootIdOf(ctx) {
18
+ let node = ctx;
19
+ while (node.parent) {
20
+ node = node.parent;
21
+ }
22
+ return node.id;
23
+ }
12
24
  /**
13
25
  * @internal
14
26
  * The cancellation tree rooted at `ctx`, in pre-order (a parent before its
@@ -215,6 +227,7 @@ export class ContextImpl {
215
227
  if (!this.channelStore) {
216
228
  return Promise.reject(new Error('No channel store configured'));
217
229
  }
230
+ this.channelStore.claim(ch, rootIdOf(this));
218
231
  return this.channelStore.recv(ch, opts?.timeout, this._abortController.signal);
219
232
  }
220
233
  send(ch, value) {
@@ -224,6 +237,7 @@ export class ContextImpl {
224
237
  // Internal sender: back-pressured on full queue channels (default 30s →
225
238
  // channel_timeout); aborting this context rejects a parked send with
226
239
  // 'cancelled' via the shared abort signal.
240
+ this.channelStore.claim(ch, rootIdOf(this));
227
241
  return this.channelStore.send(ch, value, {
228
242
  signal: this._abortController.signal,
229
243
  });
@@ -232,6 +246,7 @@ export class ContextImpl {
232
246
  if (!this.channelStore) {
233
247
  throw new Error('No channel store configured');
234
248
  }
249
+ this.channelStore.claim(ch, rootIdOf(this));
235
250
  return this.channelStore.tryRecv(ch);
236
251
  }
237
252
  async checkpoint() {
@@ -1,5 +1,6 @@
1
1
  import type { LayerStateStore } from '@noetic-tools/context';
2
2
  import type { Context, Item, ItemSchemaRegistry, RestoreContextOptions } from '@noetic-tools/types';
3
+ import type { ChannelStore } from '../channel-store.js';
3
4
  import type { EventBroadcaster } from '../event-broadcaster.js';
4
5
  import type { CheckpointStore } from './checkpoint-store.js';
5
6
  import type { StepLedgerRetention, StepLedgerStore } from './step-ledger.js';
@@ -28,6 +29,7 @@ export interface CheckpointHarnessHandle {
28
29
  readonly stepLedgerStore?: StepLedgerStore;
29
30
  readonly stepLedgerRetention?: StepLedgerRetention;
30
31
  readonly layerStateStore: LayerStateStore;
32
+ readonly channelStore: Pick<ChannelStore, 'snapshot' | 'restore'>;
31
33
  readonly itemSchemas: ItemSchemaRegistry;
32
34
  createContext(opts?: RestoreCheckpointOptions & {
33
35
  items?: Item[];
@@ -51,7 +53,9 @@ export declare function captureCheckpoint(h: CheckpointHarnessHandle, ctx: Conte
51
53
  * `null` if no snapshot is recorded for `executionId`. Layer state is
52
54
  * replayed into `layerStateStore` keyed by the original executionId so
53
55
  * the restored context observes the pre-crash state through
54
- * `readLayerState` and the context projectors.
56
+ * `readLayerState` and the context projectors. Channel values and queued
57
+ * items are refilled into the channel store, for channels with no live
58
+ * state.
55
59
  *
56
60
  * `opts` carries the host's own context wiring (broadcaster, parent, state,
57
61
  * layer overrides) into the rebuilt context. A snapshot cannot round-trip live
@@ -1,5 +1,5 @@
1
1
  import { CheckpointSchemaVersion } from '../../types/checkpoint.js';
2
- import { ContextImpl } from '../context-impl.js';
2
+ import { ContextImpl, rootIdOf } from '../context-impl.js';
3
3
  import { StepLedger } from './step-ledger.js';
4
4
  //#endregion
5
5
  //#region captureCheckpoint
@@ -47,6 +47,9 @@ export async function captureCheckpoint(h, ctx) {
47
47
  ...ctx.itemLog.items,
48
48
  ],
49
49
  },
50
+ // Channel state belongs to the root execution, so a child checkpoint
51
+ // carries the channels its root used.
52
+ channels: h.channelStore.snapshot(rootIdOf(ctx)),
50
53
  capturedAt: new Date().toISOString(),
51
54
  };
52
55
  try {
@@ -63,7 +66,9 @@ export async function captureCheckpoint(h, ctx) {
63
66
  * `null` if no snapshot is recorded for `executionId`. Layer state is
64
67
  * replayed into `layerStateStore` keyed by the original executionId so
65
68
  * the restored context observes the pre-crash state through
66
- * `readLayerState` and the context projectors.
69
+ * `readLayerState` and the context projectors. Channel values and queued
70
+ * items are refilled into the channel store, for channels with no live
71
+ * state.
67
72
  *
68
73
  * `opts` carries the host's own context wiring (broadcaster, parent, state,
69
74
  * layer overrides) into the rebuilt context. A snapshot cannot round-trip live
@@ -91,6 +96,7 @@ export async function restoreFromCheckpoint(h, executionId, opts) {
91
96
  for (const [layerId, state] of Object.entries(snapshot.layers)) {
92
97
  h.layerStateStore.set(executionId, layerId, state);
93
98
  }
99
+ h.channelStore.restore(executionId, snapshot.channels ?? []);
94
100
  const items = h.itemSchemas.parseMany(snapshot.itemLog.items);
95
101
  const cwdInit = snapshot.cwd?.current ?? undefined;
96
102
  /* Caller wiring first, snapshot second: a host may legitimately swap the context
@@ -11,6 +11,12 @@
11
11
  * into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
12
12
  * step — O(n²) bytes over a run.
13
13
  *
14
+ * An entry's key is one of two forms (spec 23a § Identity). By default it is the step's
15
+ * execution path (`ContextImpl.currentPath()`): positional, so a resumed run lines up
16
+ * recorded outputs with steps by where they ran. A step with a `replayKey` is keyed
17
+ * `key:<replayKey>` instead: content-addressed, so it finds its output wherever it
18
+ * runs. A `durable: false` step has no key and is never recorded.
19
+ *
14
20
  * Sharding bounds the cost of one append but not the total, so retention is bounded on
15
21
  * two axes (`StepLedgerRetention`): an oversized entry is dropped rather than written,
16
22
  * and past `maxEntries` the oldest entry is evicted. Both degradations are safe by
@@ -19,7 +25,9 @@
19
25
  */
20
26
  import type { StorageAdapter } from '@noetic-tools/context';
21
27
  import { z } from 'zod';
22
- /** @public One completed step, keyed by its execution path. */
28
+ /** Marks a ledger key as a step's `replayKey` rather than its execution path. */
29
+ export declare const REPLAY_KEY_PREFIX = "key:";
30
+ /** @public One completed step, keyed by its execution path or its `key:` replay key. */
23
31
  export declare const StepLedgerEntrySchema: z.ZodObject<{
24
32
  path: z.ZodString;
25
33
  stepId: z.ZodString;
@@ -153,9 +161,11 @@ export declare class StepLedger {
153
161
  get stats(): StepLedgerStats;
154
162
  /**
155
163
  * The recorded output for `path`, when a previous run completed the same step there.
156
- * A divergence (different step id or kind at this path) discards the entry and every
157
- * entry recorded beneath it, then returns undefined so the step runs fresh — the
158
- * subtree's recorded outputs belong to a branch that no longer exists.
164
+ * A divergence (different step id or kind at this key) returns undefined so the step
165
+ * runs fresh. At a positional path it also discards every entry recorded beneath it —
166
+ * the subtree's recorded outputs belong to a branch that no longer exists. A `key:`
167
+ * entry discards only itself: its children are keyed by their own paths or keys, not
168
+ * nested under it.
159
169
  */
160
170
  take(path: string, step: {
161
171
  id: string;
@@ -11,6 +11,12 @@
11
11
  * into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
12
12
  * step — O(n²) bytes over a run.
13
13
  *
14
+ * An entry's key is one of two forms (spec 23a § Identity). By default it is the step's
15
+ * execution path (`ContextImpl.currentPath()`): positional, so a resumed run lines up
16
+ * recorded outputs with steps by where they ran. A step with a `replayKey` is keyed
17
+ * `key:<replayKey>` instead: content-addressed, so it finds its output wherever it
18
+ * runs. A `durable: false` step has no key and is never recorded.
19
+ *
14
20
  * Sharding bounds the cost of one append but not the total, so retention is bounded on
15
21
  * two axes (`StepLedgerRetention`): an oversized entry is dropped rather than written,
16
22
  * and past `maxEntries` the oldest entry is evicted. Both degradations are safe by
@@ -21,9 +27,14 @@ import { storageGetMany } from '@noetic-tools/context';
21
27
  import { NoeticConfigError } from '@noetic-tools/types';
22
28
  import { z } from 'zod';
23
29
  //#region Entry
24
- /** @public One completed step, keyed by its execution path. */
30
+ /** Marks a ledger key as a step's `replayKey` rather than its execution path. */
31
+ export const REPLAY_KEY_PREFIX = 'key:';
32
+ /** @public One completed step, keyed by its execution path or its `key:` replay key. */
25
33
  export const StepLedgerEntrySchema = z.object({
26
- /** Execution path key — see `ContextImpl.currentPath()`. Unique per dispatch. */
34
+ /**
35
+ * Ledger key: the execution path (`ContextImpl.currentPath()`, unique per dispatch)
36
+ * or `key:<replayKey>` for a step that sets `replayKey`.
37
+ */
27
38
  path: z.string().min(1),
28
39
  stepId: z.string(),
29
40
  kind: z.string(),
@@ -197,9 +208,11 @@ export class StepLedger {
197
208
  }
198
209
  /**
199
210
  * The recorded output for `path`, when a previous run completed the same step there.
200
- * A divergence (different step id or kind at this path) discards the entry and every
201
- * entry recorded beneath it, then returns undefined so the step runs fresh — the
202
- * subtree's recorded outputs belong to a branch that no longer exists.
211
+ * A divergence (different step id or kind at this key) returns undefined so the step
212
+ * runs fresh. At a positional path it also discards every entry recorded beneath it —
213
+ * the subtree's recorded outputs belong to a branch that no longer exists. A `key:`
214
+ * entry discards only itself: its children are keyed by their own paths or keys, not
215
+ * nested under it.
203
216
  */
204
217
  take(path, step) {
205
218
  const entry = this.replayable.get(path);
@@ -207,6 +220,10 @@ export class StepLedger {
207
220
  return undefined;
208
221
  }
209
222
  if (entry.stepId !== step.id || entry.kind !== step.kind) {
223
+ if (path.startsWith(REPLAY_KEY_PREFIX)) {
224
+ this.replayable.delete(path);
225
+ return undefined;
226
+ }
210
227
  this.discardSubtree(path);
211
228
  return undefined;
212
229
  }
@@ -72,6 +72,26 @@ export declare const ItemLogSnapshotSchema: z.ZodObject<{
72
72
  }, z.core.$strip>;
73
73
  /** @public Item log portion of a snapshot. */
74
74
  export type ItemLogSnapshot = z.infer<typeof ItemLogSnapshotSchema>;
75
+ /**
76
+ * @public
77
+ * Channel state an execution used, captured so a restored execution sees the
78
+ * same values and queued items. Only stored data is kept — parked receivers,
79
+ * parked senders, and subscribers are live promises that cannot round-trip.
80
+ * Topic channels hold no stored data, so they never appear.
81
+ */
82
+ export declare const ChannelSnapshotSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
83
+ name: z.ZodString;
84
+ mode: z.ZodLiteral<"value">;
85
+ value: z.ZodOptional<z.ZodUnknown>;
86
+ hasValue: z.ZodBoolean;
87
+ }, z.core.$strip>, z.ZodObject<{
88
+ name: z.ZodString;
89
+ mode: z.ZodLiteral<"queue">;
90
+ capacity: z.ZodNumber;
91
+ queue: z.ZodArray<z.ZodUnknown>;
92
+ }, z.core.$strip>], "mode">;
93
+ /** @public One channel's stored state inside a checkpoint snapshot. */
94
+ export type ChannelSnapshot = z.infer<typeof ChannelSnapshotSchema>;
75
95
  /**
76
96
  * @public
77
97
  * Top-level checkpoint payload persisted through a `CheckpointStore`. Carries
@@ -101,6 +121,17 @@ export declare const CheckpointSnapshotSchema: z.ZodObject<{
101
121
  itemLog: z.ZodObject<{
102
122
  items: z.ZodArray<z.ZodUnknown>;
103
123
  }, z.core.$strip>;
124
+ channels: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
125
+ name: z.ZodString;
126
+ mode: z.ZodLiteral<"value">;
127
+ value: z.ZodOptional<z.ZodUnknown>;
128
+ hasValue: z.ZodBoolean;
129
+ }, z.core.$strip>, z.ZodObject<{
130
+ name: z.ZodString;
131
+ mode: z.ZodLiteral<"queue">;
132
+ capacity: z.ZodNumber;
133
+ queue: z.ZodArray<z.ZodUnknown>;
134
+ }, z.core.$strip>], "mode">>>;
104
135
  capturedAt: z.ZodString;
105
136
  }, z.core.$strip>;
106
137
  /** @public Snapshot of an execution suitable for `CheckpointStore.save()`. */
@@ -68,6 +68,29 @@ export const PendingAskUserSnapshotSchema = z.object({
68
68
  export const ItemLogSnapshotSchema = z.object({
69
69
  items: z.array(z.unknown()),
70
70
  });
71
+ /**
72
+ * @public
73
+ * Channel state an execution used, captured so a restored execution sees the
74
+ * same values and queued items. Only stored data is kept — parked receivers,
75
+ * parked senders, and subscribers are live promises that cannot round-trip.
76
+ * Topic channels hold no stored data, so they never appear.
77
+ */
78
+ export const ChannelSnapshotSchema = z.discriminatedUnion('mode', [
79
+ z.object({
80
+ name: z.string(),
81
+ mode: z.literal('value'),
82
+ /** The last value written. Absent when the channel was never written. */
83
+ value: z.unknown().optional(),
84
+ hasValue: z.boolean(),
85
+ }),
86
+ z.object({
87
+ name: z.string(),
88
+ mode: z.literal('queue'),
89
+ capacity: z.number(),
90
+ /** Queued items, oldest first. */
91
+ queue: z.array(z.unknown()),
92
+ }),
93
+ ]);
71
94
  /**
72
95
  * @public
73
96
  * Top-level checkpoint payload persisted through a `CheckpointStore`. Carries
@@ -84,6 +107,12 @@ export const CheckpointSnapshotSchema = z.object({
84
107
  cwd: CwdSnapshotSchema.nullable(),
85
108
  askUser: z.array(PendingAskUserSnapshotSchema),
86
109
  itemLog: ItemLogSnapshotSchema,
110
+ /**
111
+ * Stored state of every channel the execution used. Optional so snapshots
112
+ * written before channels were captured still load (they restore with no
113
+ * channel state).
114
+ */
115
+ channels: z.array(ChannelSnapshotSchema).optional(),
87
116
  /** ISO-8601 timestamp of when the snapshot was taken. */
88
117
  capturedAt: z.string(),
89
118
  });
@@ -2,6 +2,11 @@ import type { Until } from '@noetic-tools/types';
2
2
  /**
3
3
  * Combines predicates with OR semantics; stops the loop when any predicate says stop.
4
4
  *
5
+ * Predicates run in order and the first stop verdict short-circuits, so it is
6
+ * returned as is. When none stops, the combined verdict carries every
7
+ * predicate's `reason` (joined with `'; '`) and `feedback` (joined with `'\n'`),
8
+ * so a failing `until.verified()` still hands its feedback to `prepareNext`.
9
+ *
5
10
  * @public
6
11
  * @param predicates - Until predicates to evaluate.
7
12
  * @returns A combined `Until` that short-circuits on the first stop verdict.
@@ -10,6 +15,11 @@ export declare function any(...predicates: Until[]): Until;
10
15
  /**
11
16
  * Combines predicates with AND semantics; stops the loop only when all predicates say stop.
12
17
  *
18
+ * Predicates run in order and the first continue verdict short-circuits, so it
19
+ * is returned as is. When all stop, the combined verdict carries every
20
+ * predicate's `reason` (joined with `'; '`, default `'All predicates
21
+ * satisfied'`) and `feedback` (joined with `'\n'`).
22
+ *
13
23
  * @public
14
24
  * @param predicates - Until predicates to evaluate.
15
25
  * @returns A combined `Until` that requires all predicates to be satisfied.