@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.
- package/dist/builders/channel-builder.d.ts +18 -0
- package/dist/builders/control-flow-builders.d.ts +17 -30
- package/dist/builders/decide-builders.d.ts +5 -5
- package/dist/builders/decide-builders.js +4 -0
- package/dist/builders/every.d.ts +2 -2
- package/dist/builders/every.js +2 -0
- package/dist/builders/loop-builder.d.ts +1 -1
- package/dist/builders/loop-builder.js +1 -1
- package/dist/builders/provide-builder.d.ts +2 -2
- package/dist/builders/provide-builder.js +2 -0
- package/dist/builders/spawn-builder.d.ts +2 -2
- package/dist/builders/spawn-builder.js +2 -0
- package/dist/builders/step-builders.d.ts +8 -8
- package/dist/builders/workflow-hydrator.js +6 -1
- package/dist/harness/agent-harness.d.ts +18 -5
- package/dist/harness/agent-harness.js +49 -14
- package/dist/harness/deps/runtime.d.ts +3 -2
- package/dist/harness/deps/runtime.js +2 -2
- package/dist/harness/model-call.js +10 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.js +3 -3
- package/dist/interpreter/child-span.d.ts +17 -0
- package/dist/interpreter/child-span.js +51 -0
- package/dist/interpreter/execute-action.js +21 -16
- package/dist/interpreter/execute-control.js +64 -8
- package/dist/interpreter/execute.d.ts +10 -0
- package/dist/interpreter/execute.js +41 -12
- package/dist/observability/genai-attributes.d.ts +45 -29
- package/dist/observability/genai-attributes.js +19 -1
- package/dist/runtime/channel-store.d.ts +73 -1
- package/dist/runtime/channel-store.js +179 -63
- package/dist/runtime/context-impl.d.ts +7 -1
- package/dist/runtime/context-impl.js +15 -0
- package/dist/runtime/durable/harness-checkpoints.d.ts +5 -1
- package/dist/runtime/durable/harness-checkpoints.js +8 -2
- package/dist/runtime/durable/step-ledger.d.ts +14 -4
- package/dist/runtime/durable/step-ledger.js +22 -5
- package/dist/types/checkpoint.d.ts +31 -0
- package/dist/types/checkpoint.js +29 -0
- package/dist/until/combinators.d.ts +10 -0
- package/dist/until/combinators.js +37 -13
- package/dist/until/predicates.js +4 -2
- package/package.json +3 -3
- 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
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
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
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
157
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
201
|
-
*
|
|
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()`. */
|
package/dist/types/checkpoint.js
CHANGED
|
@@ -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.
|