@ably/ai-transport 0.2.0 → 0.3.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/README.md +10 -19
- package/dist/ably-ai-transport.js +1790 -1091
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +2 -2
- package/dist/core/agent.d.ts +20 -5
- package/dist/core/channel-options.d.ts +57 -0
- package/dist/core/codec/codec-event.d.ts +9 -0
- package/dist/core/codec/decoder.d.ts +4 -1
- package/dist/core/codec/define-codec.d.ts +100 -0
- package/dist/core/codec/encoder.d.ts +2 -7
- package/dist/core/codec/field-bag.d.ts +85 -0
- package/dist/core/codec/fields.d.ts +141 -0
- package/dist/core/codec/index.d.ts +8 -1
- package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
- package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
- package/dist/core/codec/input-descriptors.d.ts +281 -0
- package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
- package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
- package/dist/core/codec/output-descriptors.d.ts +237 -0
- package/dist/core/codec/types.d.ts +95 -36
- package/dist/core/codec/well-known-inputs.d.ts +52 -0
- package/dist/core/transport/agent-view.d.ts +296 -0
- package/dist/core/transport/decode-fold.d.ts +40 -32
- package/dist/core/transport/headers.d.ts +30 -1
- package/dist/core/transport/index.d.ts +1 -1
- package/dist/core/transport/invocation.d.ts +1 -1
- package/dist/core/transport/load-history-pages.d.ts +71 -0
- package/dist/core/transport/load-history.d.ts +21 -16
- package/dist/core/transport/run-manager.d.ts +9 -11
- package/dist/core/transport/session-support.d.ts +55 -0
- package/dist/core/transport/tree.d.ts +165 -15
- package/dist/core/transport/types/agent.d.ts +120 -98
- package/dist/core/transport/types/client.d.ts +45 -12
- package/dist/core/transport/types/tree.d.ts +52 -10
- package/dist/core/transport/types/view.d.ts +55 -28
- package/dist/core/transport/view.d.ts +176 -58
- package/dist/core/transport/wire-log.d.ts +102 -0
- package/dist/errors.d.ts +10 -4
- package/dist/index.d.ts +6 -5
- package/dist/react/ably-ai-transport-react.js +784 -415
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/client-session-context.d.ts +2 -1
- package/dist/react/contexts/client-session-provider.d.ts +3 -0
- package/dist/react/index.d.ts +2 -1
- package/dist/react/internal/skipped-session.d.ts +8 -0
- package/dist/react/use-view.d.ts +3 -3
- package/dist/utils.d.ts +22 -54
- package/dist/vercel/ably-ai-transport-vercel.js +2297 -2026
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
- package/dist/vercel/codec/events.d.ts +1 -2
- package/dist/vercel/codec/fields.d.ts +44 -0
- package/dist/vercel/codec/fold-content.d.ts +16 -0
- package/dist/vercel/codec/fold-data.d.ts +16 -0
- package/dist/vercel/codec/fold-input.d.ts +67 -0
- package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
- package/dist/vercel/codec/fold-text.d.ts +16 -0
- package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
- package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
- package/dist/vercel/codec/index.d.ts +5 -30
- package/dist/vercel/codec/inputs.d.ts +11 -0
- package/dist/vercel/codec/outputs.d.ts +11 -0
- package/dist/vercel/codec/reducer-state.d.ts +121 -0
- package/dist/vercel/codec/reducer.d.ts +20 -102
- package/dist/vercel/codec/tool-transitions.d.ts +0 -6
- package/dist/vercel/codec/wire-data.d.ts +34 -0
- package/dist/vercel/index.d.ts +1 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2013 -9500
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -70
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +2 -1
- package/dist/vercel/run-end-reason.d.ts +66 -11
- package/dist/vercel/tool-part.d.ts +21 -0
- package/dist/vercel/transport/chat-transport.d.ts +0 -2
- package/dist/vercel/transport/index.d.ts +1 -1
- package/dist/vercel/transport/run-output-stream.d.ts +6 -8
- package/dist/version.d.ts +1 -1
- package/package.json +2 -2
- package/src/constants.ts +2 -2
- package/src/core/agent.ts +43 -19
- package/src/core/channel-options.ts +89 -0
- package/src/core/codec/codec-event.ts +27 -0
- package/src/core/codec/decoder.ts +145 -21
- package/src/core/codec/define-codec.ts +432 -0
- package/src/core/codec/encoder.ts +13 -54
- package/src/core/codec/field-bag.ts +142 -0
- package/src/core/codec/fields.ts +193 -0
- package/src/core/codec/index.ts +43 -0
- package/src/core/codec/input-descriptor-decoder.ts +97 -0
- package/src/core/codec/input-descriptor-encoder.ts +150 -0
- package/src/core/codec/input-descriptors.ts +373 -0
- package/src/core/codec/output-descriptor-decoder.ts +139 -0
- package/src/core/codec/output-descriptor-encoder.ts +101 -0
- package/src/core/codec/output-descriptors.ts +307 -0
- package/src/core/codec/types.ts +99 -36
- package/src/core/codec/well-known-inputs.ts +96 -0
- package/src/core/transport/agent-session.ts +330 -589
- package/src/core/transport/agent-view.ts +738 -0
- package/src/core/transport/client-session.ts +74 -69
- package/src/core/transport/decode-fold.ts +57 -47
- package/src/core/transport/headers.ts +57 -4
- package/src/core/transport/index.ts +2 -1
- package/src/core/transport/invocation.ts +1 -1
- package/src/core/transport/load-history-pages.ts +220 -0
- package/src/core/transport/load-history.ts +63 -61
- package/src/core/transport/pipe-stream.ts +10 -1
- package/src/core/transport/run-manager.ts +25 -31
- package/src/core/transport/session-support.ts +96 -0
- package/src/core/transport/tree.ts +414 -47
- package/src/core/transport/types/agent.ts +129 -102
- package/src/core/transport/types/client.ts +49 -13
- package/src/core/transport/types/tree.ts +61 -12
- package/src/core/transport/types/view.ts +57 -28
- package/src/core/transport/view.ts +520 -172
- package/src/core/transport/wire-log.ts +189 -0
- package/src/errors.ts +10 -3
- package/src/index.ts +44 -11
- package/src/react/contexts/client-session-context.ts +1 -1
- package/src/react/contexts/client-session-provider.tsx +38 -2
- package/src/react/index.ts +2 -1
- package/src/react/internal/skipped-session.ts +62 -0
- package/src/react/use-client-session.ts +7 -30
- package/src/react/use-view.ts +3 -3
- package/src/utils.ts +31 -97
- package/src/vercel/codec/decode-lifecycle.ts +70 -0
- package/src/vercel/codec/events.ts +1 -3
- package/src/vercel/codec/fields.ts +58 -0
- package/src/vercel/codec/fold-content.ts +54 -0
- package/src/vercel/codec/fold-data.ts +46 -0
- package/src/vercel/codec/fold-input.ts +255 -0
- package/src/vercel/codec/fold-lifecycle.ts +85 -0
- package/src/vercel/codec/fold-text.ts +55 -0
- package/src/vercel/codec/fold-tool-input.ts +86 -0
- package/src/vercel/codec/fold-tool-output.ts +79 -0
- package/src/vercel/codec/index.ts +23 -63
- package/src/vercel/codec/inputs.ts +116 -0
- package/src/vercel/codec/outputs.ts +207 -0
- package/src/vercel/codec/reducer-state.ts +169 -0
- package/src/vercel/codec/reducer.ts +52 -838
- package/src/vercel/codec/tool-transitions.ts +1 -12
- package/src/vercel/codec/wire-data.ts +64 -0
- package/src/vercel/index.ts +1 -0
- package/src/vercel/react/contexts/chat-transport-context.ts +1 -1
- package/src/vercel/react/use-chat-transport.ts +8 -28
- package/src/vercel/react/use-message-sync.ts +5 -10
- package/src/vercel/run-end-reason.ts +95 -16
- package/src/vercel/tool-part.ts +25 -0
- package/src/vercel/transport/chat-transport.ts +10 -22
- package/src/vercel/transport/index.ts +1 -1
- package/src/vercel/transport/run-output-stream.ts +7 -8
- package/src/version.ts +1 -1
- package/dist/core/transport/branch-chain.d.ts +0 -43
- package/dist/core/transport/load-conversation.d.ts +0 -128
- package/dist/vercel/codec/decoder.d.ts +0 -9
- package/dist/vercel/codec/encoder.d.ts +0 -11
- package/src/core/transport/branch-chain.ts +0 -58
- package/src/core/transport/load-conversation.ts +0 -355
- package/src/vercel/codec/decoder.ts +0 -696
- package/src/vercel/codec/encoder.ts +0 -548
|
@@ -25,6 +25,7 @@ import type * as Ably from 'ably';
|
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
27
|
HEADER_CODEC_MESSAGE_ID,
|
|
28
|
+
HEADER_EVENT_ID,
|
|
28
29
|
HEADER_FORK_OF,
|
|
29
30
|
HEADER_INPUT_CODEC_MESSAGE_ID,
|
|
30
31
|
HEADER_INVOCATION_ID,
|
|
@@ -33,20 +34,62 @@ import {
|
|
|
33
34
|
HEADER_ROLE,
|
|
34
35
|
HEADER_RUN_CLIENT_ID,
|
|
35
36
|
HEADER_RUN_ID,
|
|
37
|
+
HEADER_STREAM,
|
|
36
38
|
} from '../../constants.js';
|
|
37
39
|
import { EventEmitter } from '../../event-emitter.js';
|
|
38
40
|
import type { Logger } from '../../logger.js';
|
|
39
|
-
import
|
|
41
|
+
import { getTransportHeaders } from '../../utils.js';
|
|
42
|
+
import { toCodecEvents } from '../codec/codec-event.js';
|
|
43
|
+
import type { CodecEvent, CodecInputEvent, CodecOutputEvent, Reducer } from '../codec/types.js';
|
|
40
44
|
import type { ConversationNode, InputNode, OutputEvent, RunLifecycleEvent, RunNode, Tree } from './types.js';
|
|
45
|
+
import { WireLog } from './wire-log.js';
|
|
41
46
|
|
|
42
47
|
// ---------------------------------------------------------------------------
|
|
43
48
|
// Internal node type
|
|
44
49
|
// ---------------------------------------------------------------------------
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
/**
|
|
52
|
+
* How long (in ms, on the Ably message-timestamp timeline) a structurally
|
|
53
|
+
* complete run's event log is retained after the node's last observed
|
|
54
|
+
* activity. Bounds cross-publisher live delivery reorder: a wire can be
|
|
55
|
+
* delivered after a higher-serial wire by at most this window. Conservative
|
|
56
|
+
* placeholder pending confirmation of the actual cross-region bound.
|
|
57
|
+
*/
|
|
58
|
+
export const REORDER_WINDOW_MS = 120_000;
|
|
59
|
+
|
|
60
|
+
interface InternalNode<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection> {
|
|
47
61
|
node: ConversationNode<TProjection>;
|
|
48
62
|
/** Insertion sequence — tiebreaker for nodes with no sort serial (optimistic). */
|
|
49
63
|
insertSeq: number;
|
|
64
|
+
/**
|
|
65
|
+
* The node's event log: every serial-bearing wire applied to this node, in
|
|
66
|
+
* canonical serial order. Owns its own record/refold/replay-guard/sweep
|
|
67
|
+
* mutation (see {@link WireLog}). Optimistic (serial-less) applies are not
|
|
68
|
+
* recorded.
|
|
69
|
+
*/
|
|
70
|
+
log: WireLog<CodecEvent<TInput, TOutput>>;
|
|
71
|
+
/**
|
|
72
|
+
* Max Ably message timestamp (epoch ms) of everything applied to this node,
|
|
73
|
+
* including its run lifecycle events; 0 until a timestamped apply. The
|
|
74
|
+
* retention sweep measures {@link REORDER_WINDOW_MS} from here.
|
|
75
|
+
*/
|
|
76
|
+
lastActivityTs: number;
|
|
77
|
+
/**
|
|
78
|
+
* Whether this run's `ai-run-start` has been observed (run nodes only —
|
|
79
|
+
* always false for input nodes). The structural half of log retention:
|
|
80
|
+
* run-start is the run's serial floor, so once it is observed no older
|
|
81
|
+
* history page can deliver further wires for this node.
|
|
82
|
+
*/
|
|
83
|
+
runStartSeen: boolean;
|
|
84
|
+
/** Whether this node is already queued for sweeping (guards double-enqueue). */
|
|
85
|
+
sweepQueued: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Whether an optimistic (serial-less) seed has been folded into the
|
|
88
|
+
* projection but not into the log. The first serial-bearing wire (the echo)
|
|
89
|
+
* refolds the node from the log alone, discarding the seed, then clears
|
|
90
|
+
* this — so a codec needs no seed-replacement logic of its own.
|
|
91
|
+
*/
|
|
92
|
+
optimistic: boolean;
|
|
50
93
|
}
|
|
51
94
|
|
|
52
95
|
/**
|
|
@@ -145,26 +188,38 @@ export interface TreeInternal<
|
|
|
145
188
|
* @param events.outputs - Agent-published events (`ai-output` wire).
|
|
146
189
|
* @param headers - Transport headers from the inbound Ably message.
|
|
147
190
|
* @param serial - Ably channel serial; undefined for optimistic inserts.
|
|
191
|
+
* @param timestamp - Ably server timestamp (epoch ms) of the message —
|
|
192
|
+
* top-level `Message.timestamp`, the message's create time on every
|
|
193
|
+
* delivery (an append's own receive time lives in `version.timestamp`) —
|
|
194
|
+
* or undefined for optimistic inserts. Advances the Tree's event-log
|
|
195
|
+
* retention clock and the owning node's last-activity time.
|
|
196
|
+
* @param version - The delivery's `Message.version.serial`, or undefined
|
|
197
|
+
* when the delivery carried none (optimistic inserts, never-mutated
|
|
198
|
+
* deliveries from sources that omit it). Guards the node's event log
|
|
199
|
+
* against whole-wire replays: a delivery at or below the version already
|
|
200
|
+
* decoded into its log entry is dropped.
|
|
148
201
|
*/
|
|
149
202
|
applyMessage(
|
|
150
203
|
events: { inputs: TInput[]; outputs: TOutput[] },
|
|
151
204
|
headers: Record<string, string>,
|
|
152
205
|
serial?: string,
|
|
206
|
+
timestamp?: number,
|
|
207
|
+
version?: string,
|
|
153
208
|
): void;
|
|
154
209
|
|
|
155
210
|
/**
|
|
156
211
|
* Apply a run-lifecycle event.
|
|
157
212
|
*
|
|
158
213
|
* - `start`: creates the reply run (if missing) or, for an existing run,
|
|
159
|
-
* sets RunNode.
|
|
214
|
+
* sets RunNode.state to 'active', promotes startSerial, and backfills
|
|
160
215
|
* structural metadata (parent / forkOf / regenerates / invocationId).
|
|
161
|
-
* - `suspend`: sets RunNode.
|
|
216
|
+
* - `suspend`: sets RunNode.state to 'suspended' and records `endSerial`.
|
|
162
217
|
* The run stays live so a resume under the same `runId` picks up where it
|
|
163
218
|
* left off.
|
|
164
|
-
* - `resume`: re-activates an existing suspended Run (
|
|
219
|
+
* - `resume`: re-activates an existing suspended Run (state back to
|
|
165
220
|
* 'active') without touching its structure or serials — a pure re-entry
|
|
166
221
|
* signal. A no-op if the Run is not yet known.
|
|
167
|
-
* - `end`: sets RunNode.
|
|
222
|
+
* - `end`: sets RunNode.state to the terminal reason and records
|
|
168
223
|
* `endSerial`.
|
|
169
224
|
*
|
|
170
225
|
* Always emits a 'run' event to subscribers.
|
|
@@ -214,7 +269,7 @@ export class DefaultTree<
|
|
|
214
269
|
TOutput extends CodecOutputEvent,
|
|
215
270
|
TProjection,
|
|
216
271
|
> implements TreeInternal<TInput, TOutput, TProjection> {
|
|
217
|
-
private readonly _codec: Reducer<TInput
|
|
272
|
+
private readonly _codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>;
|
|
218
273
|
private readonly _logger: Logger;
|
|
219
274
|
private readonly _emitter: EventEmitter<TreeEventsMap<TOutput>>;
|
|
220
275
|
|
|
@@ -222,7 +277,7 @@ export class DefaultTree<
|
|
|
222
277
|
* All nodes indexed by their primary key ({@link nodeKey}): a reply run's
|
|
223
278
|
* runId, or an input node's codec-message-id.
|
|
224
279
|
*/
|
|
225
|
-
private readonly _nodeIndex = new Map<string, InternalNode<TProjection>>();
|
|
280
|
+
private readonly _nodeIndex = new Map<string, InternalNode<TInput, TOutput, TProjection>>();
|
|
226
281
|
|
|
227
282
|
/**
|
|
228
283
|
* Maps every observed `codec-message-id` to its owning node's key
|
|
@@ -240,7 +295,7 @@ export class DefaultTree<
|
|
|
240
295
|
* serial (optimistic) sort after all serial-bearing nodes, ordered among
|
|
241
296
|
* themselves by insertion sequence.
|
|
242
297
|
*/
|
|
243
|
-
private readonly _sortedNodes: InternalNode<TProjection>[] = [];
|
|
298
|
+
private readonly _sortedNodes: InternalNode<TInput, TOutput, TProjection>[] = [];
|
|
244
299
|
|
|
245
300
|
/**
|
|
246
301
|
* Parent index: parent node key (the key its children's
|
|
@@ -273,10 +328,37 @@ export class DefaultTree<
|
|
|
273
328
|
* the View calls `getSiblingNodes` once per visible node plus extra
|
|
274
329
|
* per-message branch-anchor probes from React components.
|
|
275
330
|
*/
|
|
276
|
-
private _siblingCache = new Map<string, InternalNode<TProjection>[]>();
|
|
331
|
+
private _siblingCache = new Map<string, InternalNode<TInput, TOutput, TProjection>[]>();
|
|
277
332
|
private _siblingCacheVersion = -1;
|
|
278
333
|
|
|
279
|
-
|
|
334
|
+
/**
|
|
335
|
+
* Index from `event-id` header to the raw Ably message that carried it.
|
|
336
|
+
* Populated incrementally as messages arrive via {@link emitAblyMessage};
|
|
337
|
+
* reads back the raw message for the agent's input-event lookup
|
|
338
|
+
* ({@link findAblyMessageByEventId}). Bounded by the Tree's lifetime — cleared
|
|
339
|
+
* when the Tree is replaced on continuity loss / session close.
|
|
340
|
+
*/
|
|
341
|
+
private readonly _eventIdIndex = new Map<string, Ably.InboundMessage>();
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Event-log retention logical clock: the max Ably message timestamp (epoch
|
|
345
|
+
* ms) observed across every apply, 0 until the first timestamped one. Only
|
|
346
|
+
* ever advances — older-page history application carries smaller timestamps
|
|
347
|
+
* and leaves it (and therefore the sweep) untouched.
|
|
348
|
+
*/
|
|
349
|
+
private _clock = 0;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Keys of structurally complete run nodes (run-start and run-end both
|
|
353
|
+
* observed) whose event logs await the retention window, in completion
|
|
354
|
+
* order. Drained from the front whenever {@link _clock} advances; sweeping
|
|
355
|
+
* only at clock advances keeps a history page's batch atomic — applying an
|
|
356
|
+
* older page can never advance the clock, so a node cannot be swept between
|
|
357
|
+
* its run-start and the rest of its wires in the same page.
|
|
358
|
+
*/
|
|
359
|
+
private readonly _sweepQueue: string[] = [];
|
|
360
|
+
|
|
361
|
+
constructor(codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>, logger: Logger) {
|
|
280
362
|
this._codec = codec;
|
|
281
363
|
this._logger = logger;
|
|
282
364
|
this._emitter = new EventEmitter<TreeEventsMap<TOutput>>(logger);
|
|
@@ -302,7 +384,10 @@ export class DefaultTree<
|
|
|
302
384
|
* @returns Negative if a sorts before b, positive if after, zero if equal.
|
|
303
385
|
*/
|
|
304
386
|
// Spec: AIT-CT13a
|
|
305
|
-
private _compareNodes(
|
|
387
|
+
private _compareNodes(
|
|
388
|
+
a: InternalNode<TInput, TOutput, TProjection>,
|
|
389
|
+
b: InternalNode<TInput, TOutput, TProjection>,
|
|
390
|
+
): number {
|
|
306
391
|
const sa = sortSerial(a.node);
|
|
307
392
|
const sb = sortSerial(b.node);
|
|
308
393
|
if (sa === undefined && sb === undefined) return a.insertSeq - b.insertSeq;
|
|
@@ -317,7 +402,7 @@ export class DefaultTree<
|
|
|
317
402
|
* Insert a node into the sorted list at the correct position via binary search.
|
|
318
403
|
* @param internal - The node to insert.
|
|
319
404
|
*/
|
|
320
|
-
private _insertSortedNode(internal: InternalNode<TProjection>): void {
|
|
405
|
+
private _insertSortedNode(internal: InternalNode<TInput, TOutput, TProjection>): void {
|
|
321
406
|
const startSerial = sortSerial(internal.node);
|
|
322
407
|
|
|
323
408
|
// Fast path: null-startSerial always appends to end.
|
|
@@ -345,7 +430,7 @@ export class DefaultTree<
|
|
|
345
430
|
* Remove a node from the sorted list.
|
|
346
431
|
* @param internal - The node to remove.
|
|
347
432
|
*/
|
|
348
|
-
private _removeSortedNode(internal: InternalNode<TProjection>): void {
|
|
433
|
+
private _removeSortedNode(internal: InternalNode<TInput, TOutput, TProjection>): void {
|
|
349
434
|
const idx = this._sortedNodes.indexOf(internal);
|
|
350
435
|
if (idx !== -1) this._sortedNodes.splice(idx, 1);
|
|
351
436
|
}
|
|
@@ -359,7 +444,11 @@ export class DefaultTree<
|
|
|
359
444
|
* @param entry - The internal node to insert.
|
|
360
445
|
* @param parentCodecMessageId - The node's structural parent, or undefined for a root.
|
|
361
446
|
*/
|
|
362
|
-
private _insertNode(
|
|
447
|
+
private _insertNode(
|
|
448
|
+
key: string,
|
|
449
|
+
entry: InternalNode<TInput, TOutput, TProjection>,
|
|
450
|
+
parentCodecMessageId: string | undefined,
|
|
451
|
+
): void {
|
|
363
452
|
this._nodeIndex.set(key, entry);
|
|
364
453
|
this._addToParentIndex(parentCodecMessageId, key);
|
|
365
454
|
this._insertSortedNode(entry);
|
|
@@ -373,7 +462,7 @@ export class DefaultTree<
|
|
|
373
462
|
* optimistic-serial promotion paths when the server relay/echo arrives.
|
|
374
463
|
* @param entry - The internal node whose serial was just promoted.
|
|
375
464
|
*/
|
|
376
|
-
private _promoteSerial(entry: InternalNode<TProjection>): void {
|
|
465
|
+
private _promoteSerial(entry: InternalNode<TInput, TOutput, TProjection>): void {
|
|
377
466
|
this._removeSortedNode(entry);
|
|
378
467
|
this._insertSortedNode(entry);
|
|
379
468
|
this._structuralVersion++;
|
|
@@ -389,8 +478,8 @@ export class DefaultTree<
|
|
|
389
478
|
* @param messageId - The reducer routing key (codec-message-id), or undefined.
|
|
390
479
|
*/
|
|
391
480
|
private _foldInto(
|
|
392
|
-
entry: InternalNode<TProjection>,
|
|
393
|
-
events:
|
|
481
|
+
entry: InternalNode<TInput, TOutput, TProjection>,
|
|
482
|
+
events: CodecEvent<TInput, TOutput>[],
|
|
394
483
|
serial: string | undefined,
|
|
395
484
|
messageId: string | undefined,
|
|
396
485
|
): void {
|
|
@@ -403,6 +492,196 @@ export class DefaultTree<
|
|
|
403
492
|
}
|
|
404
493
|
}
|
|
405
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Record a serial-bearing wire in the node's event log and fold it. Events
|
|
497
|
+
* extending the log tail (the common case — in-order live delivery) fold
|
|
498
|
+
* incrementally onto the existing projection, identical to a bare
|
|
499
|
+
* {@link _foldInto}. Events that land earlier in the log (an earlier-serial
|
|
500
|
+
* wire delivered late — cross-publisher reorder, or a history page applying
|
|
501
|
+
* an older message after a newer one) cannot be folded incrementally without
|
|
502
|
+
* corrupting serial order, so the node is refolded from the whole log via
|
|
503
|
+
* {@link _refold}.
|
|
504
|
+
*
|
|
505
|
+
* Optimistic (serial-less) applies and empty event batches are not logged;
|
|
506
|
+
* an optimistic seed folds into the projection but never into the log, and
|
|
507
|
+
* marks the node `optimistic`. The first serial-bearing wire (the echo of
|
|
508
|
+
* the optimistic input, which re-delivers the seeded content) refolds the
|
|
509
|
+
* node from the log alone — rebuilding the projection without the seed
|
|
510
|
+
* rather than folding the echo on top of it. The codec therefore never sees
|
|
511
|
+
* the seed and its echo in one projection, and needs no seed-replacement
|
|
512
|
+
* logic. The seed must be a faithful preview of the echo, since the echo's
|
|
513
|
+
* content is what survives.
|
|
514
|
+
*
|
|
515
|
+
* Whole-wire replays are dropped at the log: each entry records the highest
|
|
516
|
+
* `Message.version.serial` decoded into it (`decodedThrough`), so a
|
|
517
|
+
* version-bearing delivery the entry has already incorporated — a second
|
|
518
|
+
* hydration over a populated Tree, a remounted View's re-fetch, an agent
|
|
519
|
+
* re-walk — records nothing and folds nothing. A newer version of a
|
|
520
|
+
* discrete wire (an edited discrete) is likewise dropped; propagating edits
|
|
521
|
+
* into projections is deliberately out of scope.
|
|
522
|
+
* @param entry - The internal node whose log and projection are updated.
|
|
523
|
+
* @param events - The decoded events to fold, in wire order.
|
|
524
|
+
* @param serial - Ably channel serial; undefined for an optimistic insert.
|
|
525
|
+
* @param messageId - The reducer routing key (codec-message-id), or undefined.
|
|
526
|
+
* @param version - The delivery's `Message.version.serial`, or undefined.
|
|
527
|
+
* @param streamed - Whether the delivery is part of a streamed wire.
|
|
528
|
+
*/
|
|
529
|
+
private _recordAndFold(
|
|
530
|
+
entry: InternalNode<TInput, TOutput, TProjection>,
|
|
531
|
+
events: CodecEvent<TInput, TOutput>[],
|
|
532
|
+
serial: string | undefined,
|
|
533
|
+
messageId: string | undefined,
|
|
534
|
+
version: string | undefined,
|
|
535
|
+
streamed: boolean,
|
|
536
|
+
): void {
|
|
537
|
+
// A serial-less optimistic seed (or an empty batch) is not logged. Fold it
|
|
538
|
+
// in; a non-empty seed marks the node so its echo refolds the seed away.
|
|
539
|
+
if (serial === undefined || events.length === 0) {
|
|
540
|
+
if (serial === undefined && events.length > 0) entry.optimistic = true;
|
|
541
|
+
this._foldInto(entry, events, serial, messageId);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const fold = entry.log.record(serial, messageId, events, version, streamed);
|
|
546
|
+
if (fold === 'dropped') {
|
|
547
|
+
// The version guard rejected a re-delivery the log already incorporated —
|
|
548
|
+
// a whole-wire replay (second hydration, remount, agent re-walk, or a
|
|
549
|
+
// `loadOlder()` re-applying a swept run's history) or an edit to a
|
|
550
|
+
// discrete. Nothing to fold.
|
|
551
|
+
this._logger.debug('Tree._recordAndFold(); version guard dropped re-delivered wire', {
|
|
552
|
+
key: nodeKey(entry.node),
|
|
553
|
+
serial,
|
|
554
|
+
version,
|
|
555
|
+
swept: entry.log.swept,
|
|
556
|
+
});
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (entry.optimistic && !entry.log.swept) {
|
|
560
|
+
// First serial-bearing wire (the echo) on a node that carries an
|
|
561
|
+
// optimistic seed. The seed is in the projection but not the log, so
|
|
562
|
+
// refold from the log alone — the echo re-delivers the seeded content —
|
|
563
|
+
// rebuilding the projection without the seed instead of folding the echo
|
|
564
|
+
// on top of it.
|
|
565
|
+
entry.optimistic = false;
|
|
566
|
+
this._refold(entry);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (fold === 'refold') {
|
|
570
|
+
this._refold(entry);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
// 'incremental'. On a swept log this is a genuinely-new wire outside the
|
|
574
|
+
// reorder window (it should not occur) folding in arrival order — the log
|
|
575
|
+
// could not refold it.
|
|
576
|
+
if (entry.log.swept) {
|
|
577
|
+
this._logger.warn('Tree._recordAndFold(); late wire after log retention window; folding in arrival order', {
|
|
578
|
+
key: nodeKey(entry.node),
|
|
579
|
+
serial,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
this._foldInto(entry, events, serial, messageId);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Rebuild a node's projection from its event log in canonical serial order:
|
|
587
|
+
* a fresh {@link Reducer.init} folded through every logged event, each with
|
|
588
|
+
* its own wire's serial and messageId. Used when a late, earlier-serial wire
|
|
589
|
+
* makes incremental folding unsound. Reducer purity (a fold is a function of
|
|
590
|
+
* its inputs alone) is what makes the rebuild faithful; the per-fold
|
|
591
|
+
* try/catch mirrors {@link _foldInto} so one throwing event can't abort the
|
|
592
|
+
* rebuild.
|
|
593
|
+
*
|
|
594
|
+
* Rebuilds the projection only; the surrounding apply emits its usual
|
|
595
|
+
* `output` event carrying just the triggering wire's events. Consumers read
|
|
596
|
+
* the rebuilt state from `node.projection` (the View recomputes its message
|
|
597
|
+
* list from it), so on the refold path the event's `events` payload is not a
|
|
598
|
+
* delta of the full projection change.
|
|
599
|
+
* @param entry - The internal node whose projection is rebuilt in place.
|
|
600
|
+
*/
|
|
601
|
+
private _refold(entry: InternalNode<TInput, TOutput, TProjection>): void {
|
|
602
|
+
let projection = this._codec.init();
|
|
603
|
+
entry.log.replay((event, serial, messageId) => {
|
|
604
|
+
try {
|
|
605
|
+
projection = this._codec.fold(projection, event, { serial, messageId });
|
|
606
|
+
} catch (error) {
|
|
607
|
+
this._logger.error('Tree._refold(); fold threw', { key: nodeKey(entry.node), messageId, err: error });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
entry.node.projection = projection;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// -------------------------------------------------------------------------
|
|
614
|
+
// Event-log retention
|
|
615
|
+
// -------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Record activity on a node and advance the retention clock. Updates the
|
|
619
|
+
* node's `lastActivityTs` and the Tree-wide `_clock` to the given timestamp
|
|
620
|
+
* when it is newer; a clock advance drains the sweep queue. `undefined`
|
|
621
|
+
* (an optimistic local apply) advances nothing.
|
|
622
|
+
* @param entry - The node the activity belongs to.
|
|
623
|
+
* @param timestamp - Ably message timestamp (epoch ms), or undefined.
|
|
624
|
+
*/
|
|
625
|
+
private _recordActivity(entry: InternalNode<TInput, TOutput, TProjection>, timestamp: number | undefined): void {
|
|
626
|
+
if (timestamp === undefined) return;
|
|
627
|
+
if (timestamp > entry.lastActivityTs) entry.lastActivityTs = timestamp;
|
|
628
|
+
if (timestamp > this._clock) {
|
|
629
|
+
this._clock = timestamp;
|
|
630
|
+
this._drainSweepQueue();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Queue a run node's event log for retention sweeping once the node is
|
|
636
|
+
* structurally complete: its run-start (serial floor — no older history page
|
|
637
|
+
* can add to it) and its run-end (no further agent output) have both been
|
|
638
|
+
* observed. The actual drop happens in {@link _drainSweepQueue} once the
|
|
639
|
+
* reorder window has also lapsed. No-op for input nodes (never swept — no
|
|
640
|
+
* floor marker, and their logs are bounded by one user message), for nodes
|
|
641
|
+
* already queued or swept, and while either marker is missing.
|
|
642
|
+
* @param entry - The node to consider for sweeping.
|
|
643
|
+
*/
|
|
644
|
+
private _maybeQueueSweep(entry: InternalNode<TInput, TOutput, TProjection>): void {
|
|
645
|
+
const node = entry.node;
|
|
646
|
+
if (node.kind !== 'run') return;
|
|
647
|
+
if (entry.log.swept || entry.sweepQueued) return;
|
|
648
|
+
if (!entry.runStartSeen) return;
|
|
649
|
+
if (node.state.status === 'active' || node.state.status === 'suspended') return;
|
|
650
|
+
entry.sweepQueued = true;
|
|
651
|
+
this._sweepQueue.push(node.runId);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Drop the event logs of queued nodes whose retention window has lapsed:
|
|
656
|
+
* `lastActivityTs + REORDER_WINDOW_MS < _clock`. Drains from the front and
|
|
657
|
+
* stops at the first node still inside the window — completion order is
|
|
658
|
+
* time-ordered for live traffic, so this is amortised O(1) per apply, and
|
|
659
|
+
* stopping early only ever over-retains (memory, never correctness). Called
|
|
660
|
+
* only when the clock advances, so applying an older history page (smaller
|
|
661
|
+
* timestamps) can never sweep mid-batch. Deleted nodes are skipped.
|
|
662
|
+
*/
|
|
663
|
+
private _drainSweepQueue(): void {
|
|
664
|
+
while (this._sweepQueue.length > 0) {
|
|
665
|
+
const key = this._sweepQueue[0];
|
|
666
|
+
const entry = key === undefined ? undefined : this._nodeIndex.get(key);
|
|
667
|
+
if (!entry || entry.log.swept) {
|
|
668
|
+
this._sweepQueue.shift();
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (entry.lastActivityTs + REORDER_WINDOW_MS >= this._clock) return;
|
|
672
|
+
this._sweepQueue.shift();
|
|
673
|
+
entry.sweepQueued = false;
|
|
674
|
+
// Drop the decoded payloads (the unbounded cost) but keep each entry's
|
|
675
|
+
// replay key, so a post-sweep whole-wire replay is still recognised and
|
|
676
|
+
// dropped rather than re-folded (a refold can no longer rebuild them).
|
|
677
|
+
entry.log.sweep();
|
|
678
|
+
this._logger.debug('Tree._drainSweepQueue(); dropped event-log payloads, kept replay keys', {
|
|
679
|
+
key,
|
|
680
|
+
lastActivityTs: entry.lastActivityTs,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
406
685
|
// -------------------------------------------------------------------------
|
|
407
686
|
// Parent index maintenance
|
|
408
687
|
// -------------------------------------------------------------------------
|
|
@@ -471,7 +750,7 @@ export class DefaultTree<
|
|
|
471
750
|
* @returns The ordered list of sibling nodes.
|
|
472
751
|
*/
|
|
473
752
|
// Spec: AIT-CT13b
|
|
474
|
-
private _getSiblingGroup(key: string): InternalNode<TProjection>[] {
|
|
753
|
+
private _getSiblingGroup(key: string): InternalNode<TInput, TOutput, TProjection>[] {
|
|
475
754
|
if (this._siblingCacheVersion !== this._structuralVersion) {
|
|
476
755
|
this._siblingCache.clear();
|
|
477
756
|
this._siblingCacheVersion = this._structuralVersion;
|
|
@@ -495,7 +774,7 @@ export class DefaultTree<
|
|
|
495
774
|
// still files/groups correctly — the parent codec-message-id is known at
|
|
496
775
|
// creation, the resolved key may not be.
|
|
497
776
|
const parentKey = original.parentCodecMessageId;
|
|
498
|
-
const siblings: InternalNode<TProjection>[] = [];
|
|
777
|
+
const siblings: InternalNode<TInput, TOutput, TProjection>[] = [];
|
|
499
778
|
const candidateKeys = this._parentIndex.get(parentKey);
|
|
500
779
|
if (candidateKeys) {
|
|
501
780
|
for (const childKey of candidateKeys) {
|
|
@@ -668,6 +947,8 @@ export class DefaultTree<
|
|
|
668
947
|
events: { inputs: TInput[]; outputs: TOutput[] },
|
|
669
948
|
headers: Record<string, string>,
|
|
670
949
|
serial?: string,
|
|
950
|
+
timestamp?: number,
|
|
951
|
+
version?: string,
|
|
671
952
|
): void {
|
|
672
953
|
const wireRunId = headers[HEADER_RUN_ID];
|
|
673
954
|
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
|
|
@@ -691,7 +972,7 @@ export class DefaultTree<
|
|
|
691
972
|
}
|
|
692
973
|
|
|
693
974
|
// Fold inputs first, then outputs, preserving wire order.
|
|
694
|
-
const all:
|
|
975
|
+
const all: CodecEvent<TInput, TOutput>[] = toCodecEvents(events);
|
|
695
976
|
|
|
696
977
|
// Wire-only metadata-carrier messages (e.g. `ait-regenerate`) decode to
|
|
697
978
|
// zero events and don't need a node at the tree level — the eventual reply
|
|
@@ -710,9 +991,9 @@ export class DefaultTree<
|
|
|
710
991
|
const structuralBefore = this._structuralVersion;
|
|
711
992
|
|
|
712
993
|
if (inputNodeCodecMessageId !== undefined) {
|
|
713
|
-
this._applyInputMessage(inputNodeCodecMessageId, headers, serial, all);
|
|
994
|
+
this._applyInputMessage(inputNodeCodecMessageId, headers, serial, timestamp, version, all);
|
|
714
995
|
} else if (wireRunId !== undefined) {
|
|
715
|
-
this._applyRunMessage(wireRunId, events, headers, serial);
|
|
996
|
+
this._applyRunMessage(wireRunId, events, headers, serial, timestamp, version);
|
|
716
997
|
}
|
|
717
998
|
|
|
718
999
|
if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
|
|
@@ -726,13 +1007,17 @@ export class DefaultTree<
|
|
|
726
1007
|
* @param codecMessageId - The input node's codec-message-id (its primary key).
|
|
727
1008
|
* @param headers - Transport headers from the inbound Ably message.
|
|
728
1009
|
* @param serial - Ably channel serial; undefined for an optimistic insert.
|
|
729
|
-
* @param
|
|
1010
|
+
* @param timestamp - Ably server timestamp (epoch ms); undefined for an optimistic insert.
|
|
1011
|
+
* @param version - The delivery's `Message.version.serial`, or undefined.
|
|
1012
|
+
* @param all - The direction-tagged input events to fold, in wire order.
|
|
730
1013
|
*/
|
|
731
1014
|
private _applyInputMessage(
|
|
732
1015
|
codecMessageId: string,
|
|
733
1016
|
headers: Record<string, string>,
|
|
734
1017
|
serial: string | undefined,
|
|
735
|
-
|
|
1018
|
+
timestamp: number | undefined,
|
|
1019
|
+
version: string | undefined,
|
|
1020
|
+
all: CodecEvent<TInput, TOutput>[],
|
|
736
1021
|
): void {
|
|
737
1022
|
let entry = this._nodeIndex.get(codecMessageId);
|
|
738
1023
|
if (!entry) {
|
|
@@ -747,7 +1032,11 @@ export class DefaultTree<
|
|
|
747
1032
|
this._promoteSerial(entry);
|
|
748
1033
|
}
|
|
749
1034
|
|
|
750
|
-
this.
|
|
1035
|
+
this._recordActivity(entry, timestamp);
|
|
1036
|
+
|
|
1037
|
+
// Log the wire and fold it — incrementally onto the tail in the common
|
|
1038
|
+
// case, or by refolding the node if this wire arrived out of serial order.
|
|
1039
|
+
this._recordAndFold(entry, all, serial, codecMessageId, version, headers[HEADER_STREAM] === 'true');
|
|
751
1040
|
|
|
752
1041
|
// An input node owns no agent outputs; the event still fires (empty
|
|
753
1042
|
// outputs) so consumers observe the projection change. It has no run-id —
|
|
@@ -774,19 +1063,23 @@ export class DefaultTree<
|
|
|
774
1063
|
* @param events.outputs - Agent-published events (`ai-output` wire).
|
|
775
1064
|
* @param headers - Transport headers from the inbound Ably message.
|
|
776
1065
|
* @param serial - Ably channel serial; undefined for an optimistic insert.
|
|
1066
|
+
* @param timestamp - Ably server timestamp (epoch ms); undefined for an optimistic insert.
|
|
1067
|
+
* @param version - The delivery's `Message.version.serial`, or undefined.
|
|
777
1068
|
*/
|
|
778
1069
|
private _applyRunMessage(
|
|
779
1070
|
wireRunId: string,
|
|
780
1071
|
events: { inputs: TInput[]; outputs: TOutput[] },
|
|
781
1072
|
headers: Record<string, string>,
|
|
782
1073
|
serial: string | undefined,
|
|
1074
|
+
timestamp: number | undefined,
|
|
1075
|
+
version: string | undefined,
|
|
783
1076
|
): void {
|
|
784
1077
|
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
|
|
785
1078
|
// The triggering input's codec-message-id (the agent's echo), surfaced on
|
|
786
1079
|
// the `output` event as the stream's causal routing key.
|
|
787
1080
|
const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
|
|
788
1081
|
// Fold inputs first, then outputs, preserving wire order.
|
|
789
|
-
const all:
|
|
1082
|
+
const all: CodecEvent<TInput, TOutput>[] = toCodecEvents(events);
|
|
790
1083
|
const outputs = events.outputs;
|
|
791
1084
|
|
|
792
1085
|
let run = this._nodeIndex.get(wireRunId);
|
|
@@ -816,7 +1109,13 @@ export class DefaultTree<
|
|
|
816
1109
|
const ownerKey = nodeKey(run.node);
|
|
817
1110
|
if (codecMessageId) this._codecMessageIdToNodeKey.set(codecMessageId, ownerKey);
|
|
818
1111
|
|
|
819
|
-
this.
|
|
1112
|
+
this._recordActivity(run, timestamp);
|
|
1113
|
+
|
|
1114
|
+
// Log the wire and fold it — incrementally onto the tail in the common
|
|
1115
|
+
// case, or by refolding the node if this wire arrived out of serial order.
|
|
1116
|
+
// `run` may be a reconciled optimistic node: record on whichever entry
|
|
1117
|
+
// owns the fold.
|
|
1118
|
+
this._recordAndFold(run, all, serial, codecMessageId, version, headers[HEADER_STREAM] === 'true');
|
|
820
1119
|
|
|
821
1120
|
this._emitter.emit('output', { runId: ownerKey, inputCodecMessageId, codecMessageId, serial, events: outputs });
|
|
822
1121
|
}
|
|
@@ -876,8 +1175,12 @@ export class DefaultTree<
|
|
|
876
1175
|
const existing = this._nodeIndex.get(event.runId);
|
|
877
1176
|
if (existing?.node.kind === 'run') {
|
|
878
1177
|
const node = existing.node;
|
|
879
|
-
|
|
880
|
-
|
|
1178
|
+
// Activate only a suspended run. A run-start can be observed AFTER the
|
|
1179
|
+
// run's terminal event (history pages replay newest-first, so an older
|
|
1180
|
+
// page delivers the start last) — like a stray resume, it must never
|
|
1181
|
+
// resurrect a run that has ended.
|
|
1182
|
+
if (node.state.status === 'suspended') {
|
|
1183
|
+
node.state = { status: 'active' };
|
|
881
1184
|
}
|
|
882
1185
|
if (event.serial && !node.startSerial) {
|
|
883
1186
|
node.startSerial = event.serial;
|
|
@@ -919,10 +1222,18 @@ export class DefaultTree<
|
|
|
919
1222
|
if (node.invocationId === '' && event.invocationId !== '') {
|
|
920
1223
|
node.invocationId = event.invocationId;
|
|
921
1224
|
}
|
|
1225
|
+
// The run's serial floor is now observed: no older history page can
|
|
1226
|
+
// deliver further wires for this node. With a terminal status this
|
|
1227
|
+
// makes the node structurally complete — eligible for log retention
|
|
1228
|
+
// sweeping once the reorder window lapses.
|
|
1229
|
+
existing.runStartSeen = true;
|
|
1230
|
+
this._recordActivity(existing, event.timestamp);
|
|
1231
|
+
this._maybeQueueSweep(existing);
|
|
922
1232
|
} else if (!existing) {
|
|
923
1233
|
const run = this._createRunFromLifecycle(event);
|
|
924
1234
|
this._insertNode(event.runId, run, run.node.parentCodecMessageId);
|
|
925
1235
|
this._indexReplyRun(run.node, event.runId);
|
|
1236
|
+
this._recordActivity(run, event.timestamp);
|
|
926
1237
|
}
|
|
927
1238
|
}
|
|
928
1239
|
|
|
@@ -937,8 +1248,9 @@ export class DefaultTree<
|
|
|
937
1248
|
private _applyRunSuspend(event: RunLifecycleEvent & { type: 'suspend' }): void {
|
|
938
1249
|
const run = this._nodeIndex.get(event.runId);
|
|
939
1250
|
if (run?.node.kind === 'run') {
|
|
940
|
-
run.node.
|
|
1251
|
+
run.node.state = { status: 'suspended' };
|
|
941
1252
|
run.node.endSerial = event.serial;
|
|
1253
|
+
this._recordActivity(run, event.timestamp);
|
|
942
1254
|
}
|
|
943
1255
|
}
|
|
944
1256
|
|
|
@@ -955,23 +1267,33 @@ export class DefaultTree<
|
|
|
955
1267
|
*/
|
|
956
1268
|
private _applyRunResume(event: RunLifecycleEvent & { type: 'resume' }): void {
|
|
957
1269
|
const run = this._nodeIndex.get(event.runId);
|
|
958
|
-
if (run?.node.kind === 'run' && run.node.status === 'suspended') {
|
|
959
|
-
run.node.
|
|
1270
|
+
if (run?.node.kind === 'run' && run.node.state.status === 'suspended') {
|
|
1271
|
+
run.node.state = { status: 'active' };
|
|
1272
|
+
this._recordActivity(run, event.timestamp);
|
|
960
1273
|
}
|
|
961
1274
|
}
|
|
962
1275
|
|
|
963
1276
|
/**
|
|
964
|
-
* Apply a run-end lifecycle event: record the terminal reason
|
|
965
|
-
*
|
|
966
|
-
* structure, so this never mutates
|
|
967
|
-
* emits.
|
|
1277
|
+
* Apply a run-end lifecycle event: record the terminal reason (and, for an
|
|
1278
|
+
* error end, the error) as the node's state, plus the serial it ended at.
|
|
1279
|
+
* State/endSerial are content, not structure, so this never mutates
|
|
1280
|
+
* `_structuralVersion`; the caller owns the emits.
|
|
1281
|
+
*
|
|
1282
|
+
* A run-end for an unknown runId is a no-op: nothing else is known about the
|
|
1283
|
+
* run yet, so there is no node to mark. When that happens during history
|
|
1284
|
+
* replay (a page boundary falling just before the run-end, so the run's
|
|
1285
|
+
* other wires arrive in later pages), the run is never marked terminal and
|
|
1286
|
+
* its event log is retained for the Tree's lifetime — over-retention, never
|
|
1287
|
+
* corruption.
|
|
968
1288
|
* @param event - The run-end lifecycle event.
|
|
969
1289
|
*/
|
|
970
1290
|
private _applyRunEnd(event: RunLifecycleEvent & { type: 'end' }): void {
|
|
971
1291
|
const run = this._nodeIndex.get(event.runId);
|
|
972
1292
|
if (run?.node.kind === 'run') {
|
|
973
|
-
run.node.
|
|
1293
|
+
run.node.state = event.reason === 'error' ? { status: 'error', error: event.error } : { status: event.reason };
|
|
974
1294
|
run.node.endSerial = event.serial;
|
|
1295
|
+
this._recordActivity(run, event.timestamp);
|
|
1296
|
+
this._maybeQueueSweep(run);
|
|
975
1297
|
}
|
|
976
1298
|
}
|
|
977
1299
|
|
|
@@ -1012,7 +1334,7 @@ export class DefaultTree<
|
|
|
1012
1334
|
runId: string,
|
|
1013
1335
|
headers: Record<string, string>,
|
|
1014
1336
|
serial: string | undefined,
|
|
1015
|
-
): InternalNode<TProjection> {
|
|
1337
|
+
): InternalNode<TInput, TOutput, TProjection> {
|
|
1016
1338
|
const forkOfMsgId = headers[HEADER_FORK_OF];
|
|
1017
1339
|
return this._buildRunNode({
|
|
1018
1340
|
runId,
|
|
@@ -1024,9 +1346,37 @@ export class DefaultTree<
|
|
|
1024
1346
|
clientId: headers[HEADER_RUN_CLIENT_ID] ?? '',
|
|
1025
1347
|
invocationId: headers[HEADER_INVOCATION_ID] ?? '',
|
|
1026
1348
|
startSerial: serial,
|
|
1349
|
+
// Created from a content wire — the run's ai-run-start has not been
|
|
1350
|
+
// observed (it may still be in an unloaded older history page).
|
|
1351
|
+
runStartSeen: false,
|
|
1027
1352
|
});
|
|
1028
1353
|
}
|
|
1029
1354
|
|
|
1355
|
+
/**
|
|
1356
|
+
* Wrap a freshly-built conversation node in its internal envelope — sort
|
|
1357
|
+
* sequence, event log, and retention/promotion state. The single home for
|
|
1358
|
+
* those per-node fields, so a new field is added in one place rather than at
|
|
1359
|
+
* every node-construction site.
|
|
1360
|
+
* @param node - The conversation node to wrap.
|
|
1361
|
+
* @param runStartSeen - Whether the run's ai-run-start has been observed
|
|
1362
|
+
* (run nodes only; always false for input nodes).
|
|
1363
|
+
* @returns A newly-allocated internal node ready for insertion.
|
|
1364
|
+
*/
|
|
1365
|
+
private _wrapNode(
|
|
1366
|
+
node: ConversationNode<TProjection>,
|
|
1367
|
+
runStartSeen = false,
|
|
1368
|
+
): InternalNode<TInput, TOutput, TProjection> {
|
|
1369
|
+
return {
|
|
1370
|
+
node,
|
|
1371
|
+
insertSeq: this._seqCounter++,
|
|
1372
|
+
log: new WireLog<CodecEvent<TInput, TOutput>>(),
|
|
1373
|
+
lastActivityTs: 0,
|
|
1374
|
+
runStartSeen,
|
|
1375
|
+
sweepQueued: false,
|
|
1376
|
+
optimistic: false,
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1030
1380
|
/**
|
|
1031
1381
|
* Allocate a RunNode from already-resolved fields. Shared by the
|
|
1032
1382
|
* header-driven and lifecycle-driven run creators: both build the identical
|
|
@@ -1039,6 +1389,7 @@ export class DefaultTree<
|
|
|
1039
1389
|
* @param params.clientId - The publishing client's id.
|
|
1040
1390
|
* @param params.invocationId - The agent invocation id.
|
|
1041
1391
|
* @param params.startSerial - Ably channel serial; undefined for optimistic inserts.
|
|
1392
|
+
* @param params.runStartSeen - Whether the run's ai-run-start has been observed (true only for lifecycle-created runs).
|
|
1042
1393
|
* @returns A newly-allocated internal run node ready for insertion.
|
|
1043
1394
|
*/
|
|
1044
1395
|
private _buildRunNode(params: {
|
|
@@ -1049,7 +1400,8 @@ export class DefaultTree<
|
|
|
1049
1400
|
clientId: string;
|
|
1050
1401
|
invocationId: string;
|
|
1051
1402
|
startSerial: string | undefined;
|
|
1052
|
-
|
|
1403
|
+
runStartSeen: boolean;
|
|
1404
|
+
}): InternalNode<TInput, TOutput, TProjection> {
|
|
1053
1405
|
const node: RunNode<TProjection> = {
|
|
1054
1406
|
kind: 'run',
|
|
1055
1407
|
runId: params.runId,
|
|
@@ -1058,13 +1410,13 @@ export class DefaultTree<
|
|
|
1058
1410
|
regeneratesCodecMessageId: params.regeneratesCodecMessageId,
|
|
1059
1411
|
clientId: params.clientId,
|
|
1060
1412
|
invocationId: params.invocationId,
|
|
1061
|
-
status: 'active',
|
|
1413
|
+
state: { status: 'active' },
|
|
1062
1414
|
projection: this._codec.init(),
|
|
1063
1415
|
startSerial: params.startSerial,
|
|
1064
1416
|
endSerial: undefined,
|
|
1065
1417
|
};
|
|
1066
1418
|
|
|
1067
|
-
return
|
|
1419
|
+
return this._wrapNode(node, params.runStartSeen);
|
|
1068
1420
|
}
|
|
1069
1421
|
|
|
1070
1422
|
/**
|
|
@@ -1078,7 +1430,7 @@ export class DefaultTree<
|
|
|
1078
1430
|
codecMessageId: string,
|
|
1079
1431
|
headers: Record<string, string>,
|
|
1080
1432
|
serial: string | undefined,
|
|
1081
|
-
): InternalNode<TProjection> {
|
|
1433
|
+
): InternalNode<TInput, TOutput, TProjection> {
|
|
1082
1434
|
const forkOfMsgId = headers[HEADER_FORK_OF];
|
|
1083
1435
|
const node: InputNode<TProjection> = {
|
|
1084
1436
|
kind: 'input',
|
|
@@ -1090,7 +1442,7 @@ export class DefaultTree<
|
|
|
1090
1442
|
projection: this._codec.init(),
|
|
1091
1443
|
serial,
|
|
1092
1444
|
};
|
|
1093
|
-
return
|
|
1445
|
+
return this._wrapNode(node);
|
|
1094
1446
|
}
|
|
1095
1447
|
|
|
1096
1448
|
/**
|
|
@@ -1100,7 +1452,9 @@ export class DefaultTree<
|
|
|
1100
1452
|
* its channel serial.
|
|
1101
1453
|
* @returns A newly-allocated internal run node ready for insertion.
|
|
1102
1454
|
*/
|
|
1103
|
-
private _createRunFromLifecycle(
|
|
1455
|
+
private _createRunFromLifecycle(
|
|
1456
|
+
event: RunLifecycleEvent & { type: 'start' },
|
|
1457
|
+
): InternalNode<TInput, TOutput, TProjection> {
|
|
1104
1458
|
const forkOfMsgId = event.forkOf;
|
|
1105
1459
|
return this._buildRunNode({
|
|
1106
1460
|
runId: event.runId,
|
|
@@ -1110,6 +1464,8 @@ export class DefaultTree<
|
|
|
1110
1464
|
clientId: event.clientId,
|
|
1111
1465
|
invocationId: event.invocationId,
|
|
1112
1466
|
startSerial: event.serial,
|
|
1467
|
+
// Created from the run-start itself — the serial floor is observed.
|
|
1468
|
+
runStartSeen: true,
|
|
1113
1469
|
});
|
|
1114
1470
|
}
|
|
1115
1471
|
|
|
@@ -1139,13 +1495,24 @@ export class DefaultTree<
|
|
|
1139
1495
|
}
|
|
1140
1496
|
|
|
1141
1497
|
/**
|
|
1142
|
-
* Forward a raw Ably message event to tree subscribers.
|
|
1498
|
+
* Forward a raw Ably message event to tree subscribers. Also indexes the
|
|
1499
|
+
* Ably message by `event-id` header (if present) for
|
|
1500
|
+
* {@link findAblyMessageByEventId} lookups.
|
|
1143
1501
|
* @param msg - The raw Ably message to emit.
|
|
1144
1502
|
*/
|
|
1145
1503
|
emitAblyMessage(msg: Ably.InboundMessage): void {
|
|
1146
1504
|
this._logger.trace('DefaultTree.emitAblyMessage();');
|
|
1505
|
+
const headers = getTransportHeaders(msg);
|
|
1506
|
+
const eventId = headers[HEADER_EVENT_ID];
|
|
1507
|
+
if (eventId !== undefined && !this._eventIdIndex.has(eventId)) {
|
|
1508
|
+
this._eventIdIndex.set(eventId, msg);
|
|
1509
|
+
}
|
|
1147
1510
|
this._emitter.emit('ably-message', msg);
|
|
1148
1511
|
}
|
|
1512
|
+
|
|
1513
|
+
findAblyMessageByEventId(eventId: string): Ably.InboundMessage | undefined {
|
|
1514
|
+
return this._eventIdIndex.get(eventId);
|
|
1515
|
+
}
|
|
1149
1516
|
}
|
|
1150
1517
|
|
|
1151
1518
|
// ---------------------------------------------------------------------------
|
|
@@ -1162,6 +1529,6 @@ export class DefaultTree<
|
|
|
1162
1529
|
* emitAblyMessage). Public consumers see the narrower {@link Tree} interface.
|
|
1163
1530
|
*/
|
|
1164
1531
|
export const createTree = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection>(
|
|
1165
|
-
codec: Reducer<TInput
|
|
1532
|
+
codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>,
|
|
1166
1533
|
logger: Logger,
|
|
1167
1534
|
): DefaultTree<TInput, TOutput, TProjection> => new DefaultTree<TInput, TOutput, TProjection>(codec, logger);
|