@ably/ai-transport 0.1.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 +93 -111
- package/dist/ably-ai-transport.js +2401 -1387
- 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 +116 -42
- package/dist/core/agent.d.ts +44 -0
- 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 +24 -24
- package/dist/core/codec/define-codec.d.ts +100 -0
- package/dist/core/codec/encoder.d.ts +10 -12
- 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 -2
- 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/lifecycle-tracker.d.ts +10 -9
- 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 +470 -119
- package/dist/core/codec/well-known-inputs.d.ts +52 -0
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/agent-view.d.ts +296 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +55 -0
- package/dist/core/transport/headers.d.ts +121 -14
- package/dist/core/transport/index.d.ts +5 -6
- package/dist/core/transport/internal/bounded-map.d.ts +20 -0
- package/dist/core/transport/invocation.d.ts +74 -0
- package/dist/core/transport/load-history-pages.d.ts +71 -0
- package/dist/core/transport/load-history.d.ts +44 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -9
- package/dist/core/transport/run-manager.d.ts +76 -0
- package/dist/core/transport/session-support.d.ts +55 -0
- package/dist/core/transport/tree.d.ts +523 -109
- package/dist/core/transport/types/agent.d.ts +375 -0
- package/dist/core/transport/types/client.d.ts +201 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +357 -0
- package/dist/core/transport/types/view.d.ts +249 -0
- package/dist/core/transport/types.d.ts +13 -553
- package/dist/core/transport/view.d.ts +390 -84
- package/dist/core/transport/wire-log.d.ts +102 -0
- package/dist/errors.d.ts +27 -10
- package/dist/index.d.ts +8 -9
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1365 -1010
- 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 +37 -0
- package/dist/react/contexts/client-session-provider.d.ts +56 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +13 -12
- package/dist/react/internal/skipped-session.d.ts +8 -0
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +17 -14
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +14 -13
- package/dist/react/use-tree.d.ts +30 -15
- package/dist/react/use-view.d.ts +81 -50
- package/dist/utils.d.ts +48 -71
- package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
- 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 +50 -0
- 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 +7 -20
- 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 +62 -0
- package/dist/vercel/codec/tool-transitions.d.ts +2 -8
- package/dist/vercel/codec/wire-data.d.ts +34 -0
- package/dist/vercel/index.d.ts +5 -5
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
- 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 -45
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
- package/dist/vercel/react/index.d.ts +1 -2
- package/dist/vercel/react/use-chat-transport.d.ts +30 -26
- package/dist/vercel/react/use-message-sync.d.ts +17 -30
- package/dist/vercel/run-end-reason.d.ts +84 -0
- package/dist/vercel/tool-part.d.ts +21 -0
- package/dist/vercel/transport/chat-transport.d.ts +41 -24
- package/dist/vercel/transport/index.d.ts +24 -20
- package/dist/vercel/transport/run-output-stream.d.ts +54 -0
- package/dist/version.d.ts +2 -0
- package/package.json +31 -24
- package/src/constants.ts +124 -51
- package/src/core/agent.ts +92 -0
- package/src/core/channel-options.ts +89 -0
- package/src/core/codec/codec-event.ts +27 -0
- package/src/core/codec/decoder.ts +202 -105
- package/src/core/codec/define-codec.ts +432 -0
- package/src/core/codec/encoder.ts +114 -107
- package/src/core/codec/field-bag.ts +142 -0
- package/src/core/codec/fields.ts +193 -0
- package/src/core/codec/index.ts +56 -6
- 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/lifecycle-tracker.ts +10 -9
- 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 +505 -126
- package/src/core/codec/well-known-inputs.ts +96 -0
- package/src/core/transport/agent-session.ts +1085 -0
- package/src/core/transport/agent-view.ts +738 -0
- package/src/core/transport/client-session.ts +780 -0
- package/src/core/transport/decode-fold.ts +101 -0
- package/src/core/transport/headers.ts +234 -22
- package/src/core/transport/index.ts +27 -27
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-history-pages.ts +220 -0
- package/src/core/transport/load-history.ts +271 -0
- package/src/core/transport/pipe-stream.ts +63 -39
- package/src/core/transport/run-manager.ts +243 -0
- package/src/core/transport/session-support.ts +96 -0
- package/src/core/transport/tree.ts +1293 -308
- package/src/core/transport/types/agent.ts +434 -0
- package/src/core/transport/types/client.ts +247 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +393 -0
- package/src/core/transport/types/view.ts +288 -0
- package/src/core/transport/types.ts +13 -706
- package/src/core/transport/view.ts +1229 -450
- package/src/core/transport/wire-log.ts +189 -0
- package/src/errors.ts +29 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +86 -42
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +222 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +24 -13
- package/src/react/internal/skipped-session.ts +62 -0
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +32 -22
- package/src/react/use-client-session.ts +178 -0
- package/src/react/use-create-view.ts +33 -29
- package/src/react/use-tree.ts +61 -30
- package/src/react/use-view.ts +138 -96
- package/src/utils.ts +83 -131
- package/src/vercel/codec/decode-lifecycle.ts +70 -0
- package/src/vercel/codec/events.ts +85 -0
- 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 +28 -21
- 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 +191 -0
- package/src/vercel/codec/tool-transitions.ts +3 -14
- package/src/vercel/codec/wire-data.ts +64 -0
- package/src/vercel/index.ts +7 -19
- package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
- package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
- package/src/vercel/react/index.ts +3 -5
- package/src/vercel/react/use-chat-transport.ts +44 -66
- package/src/vercel/react/use-message-sync.ts +75 -39
- package/src/vercel/run-end-reason.ts +157 -0
- package/src/vercel/tool-part.ts +25 -0
- package/src/vercel/transport/chat-transport.ts +380 -98
- package/src/vercel/transport/index.ts +38 -37
- package/src/vercel/transport/run-output-stream.ts +169 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/decode-history.d.ts +0 -43
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -29
- package/dist/core/transport/turn-manager.d.ts +0 -37
- package/dist/react/contexts/transport-context.d.ts +0 -31
- package/dist/react/contexts/transport-provider.d.ts +0 -49
- package/dist/react/create-transport-hooks.d.ts +0 -124
- package/dist/react/use-active-turns.d.ts +0 -12
- package/dist/react/use-client-transport.d.ts +0 -80
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/dist/vercel/codec/decoder.d.ts +0 -22
- package/dist/vercel/codec/encoder.d.ts +0 -41
- package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
- package/dist/vercel/tool-approvals.d.ts +0 -124
- package/dist/vercel/tool-events.d.ts +0 -26
- package/src/core/transport/client-transport.ts +0 -977
- package/src/core/transport/decode-history.ts +0 -485
- package/src/core/transport/server-transport.ts +0 -612
- package/src/core/transport/stream-router.ts +0 -136
- package/src/core/transport/turn-manager.ts +0 -165
- package/src/react/contexts/transport-context.ts +0 -37
- package/src/react/contexts/transport-provider.tsx +0 -164
- package/src/react/create-transport-hooks.ts +0 -144
- package/src/react/use-active-turns.ts +0 -72
- package/src/react/use-client-transport.ts +0 -197
- package/src/vercel/codec/accumulator.ts +0 -588
- package/src/vercel/codec/decoder.ts +0 -618
- package/src/vercel/codec/encoder.ts +0 -410
- package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
- package/src/vercel/tool-approvals.ts +0 -380
- package/src/vercel/tool-events.ts +0 -53
|
@@ -1,81 +1,254 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tree — materializes a branching conversation
|
|
3
|
-
*
|
|
2
|
+
* Tree — materializes a branching conversation as a forest of nodes. Each turn
|
|
3
|
+
* is two nodes: a user {@link InputNode} keyed by its client-owned
|
|
4
|
+
* codec-message-id and an agent {@link RunNode} keyed by the agent-minted
|
|
5
|
+
* run-id, parented to the input node.
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Each node holds a per-node codec {@link TProjection} which the Tree folds
|
|
8
|
+
* from inbound events. The Tree owns the complete conversation state across
|
|
9
|
+
* every observed node. The {@link View} walks the parent chain to extract a
|
|
10
|
+
* flat message list for rendering.
|
|
9
11
|
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* `applyMessage()` is the entry point for inbound channel messages — it
|
|
13
|
+
* classifies a run-less user input into an input node (keyed by
|
|
14
|
+
* codec-message-id) or routes a run-bearing wire to its reply run (keyed by
|
|
15
|
+
* run-id), folds events into that node's projection, and maintains a secondary
|
|
16
|
+
* `codecMessageId -> nodeKey` index. `applyRunLifecycle()` handles run-start /
|
|
17
|
+
* run-suspend / run-resume / run-end events.
|
|
13
18
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
19
|
+
* Sibling structure: editing a prompt produces a sibling input node linked by
|
|
20
|
+
* {@link InputNode.forkOf}; regenerating a reply produces a sibling reply run
|
|
21
|
+
* sharing the same input-node parent (no fork-of).
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
import type * as Ably from 'ably';
|
|
20
25
|
|
|
21
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
HEADER_CODEC_MESSAGE_ID,
|
|
28
|
+
HEADER_EVENT_ID,
|
|
29
|
+
HEADER_FORK_OF,
|
|
30
|
+
HEADER_INPUT_CODEC_MESSAGE_ID,
|
|
31
|
+
HEADER_INVOCATION_ID,
|
|
32
|
+
HEADER_MSG_REGENERATE,
|
|
33
|
+
HEADER_PARENT,
|
|
34
|
+
HEADER_ROLE,
|
|
35
|
+
HEADER_RUN_CLIENT_ID,
|
|
36
|
+
HEADER_RUN_ID,
|
|
37
|
+
HEADER_STREAM,
|
|
38
|
+
} from '../../constants.js';
|
|
22
39
|
import { EventEmitter } from '../../event-emitter.js';
|
|
23
40
|
import type { Logger } from '../../logger.js';
|
|
24
|
-
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';
|
|
44
|
+
import type { ConversationNode, InputNode, OutputEvent, RunLifecycleEvent, RunNode, Tree } from './types.js';
|
|
45
|
+
import { WireLog } from './wire-log.js';
|
|
25
46
|
|
|
26
47
|
// ---------------------------------------------------------------------------
|
|
27
48
|
// Internal node type
|
|
28
49
|
// ---------------------------------------------------------------------------
|
|
29
50
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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> {
|
|
61
|
+
node: ConversationNode<TProjection>;
|
|
62
|
+
/** Insertion sequence — tiebreaker for nodes with no sort serial (optimistic). */
|
|
33
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;
|
|
34
93
|
}
|
|
35
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The primary key a node is indexed under: a reply run's `runId`, or an input
|
|
97
|
+
* node's `codecMessageId` (the client owns it before the agent mints a runId).
|
|
98
|
+
* @param node - The node to key.
|
|
99
|
+
* @returns The node's primary key.
|
|
100
|
+
*/
|
|
101
|
+
export const nodeKey = <TProjection>(node: ConversationNode<TProjection>): string =>
|
|
102
|
+
node.kind === 'run' ? node.runId : node.codecMessageId;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The serial a node sorts by: a reply run's `startSerial`, an input node's
|
|
106
|
+
* `serial`. Undefined for an optimistic (not-yet-acked) node, which tail-sorts.
|
|
107
|
+
* @param node - The node to read.
|
|
108
|
+
* @returns The sort serial, or undefined for an optimistic node.
|
|
109
|
+
*/
|
|
110
|
+
const sortSerial = <TProjection>(node: ConversationNode<TProjection>): string | undefined =>
|
|
111
|
+
node.kind === 'run' ? node.startSerial : node.serial;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Add a value to a `Map<K, Set<V>>`, creating the bucket Set on first use.
|
|
115
|
+
* @param map - The Map to mutate.
|
|
116
|
+
* @param key - The bucket key.
|
|
117
|
+
* @param value - The value to add.
|
|
118
|
+
*/
|
|
119
|
+
const addToSetMap = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
|
|
120
|
+
let set = map.get(key);
|
|
121
|
+
if (!set) {
|
|
122
|
+
set = new Set();
|
|
123
|
+
map.set(key, set);
|
|
124
|
+
}
|
|
125
|
+
set.add(value);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove a value from a `Map<K, Set<V>>`, dropping the bucket when it empties.
|
|
130
|
+
* @param map - The Map to mutate.
|
|
131
|
+
* @param key - The bucket key.
|
|
132
|
+
* @param value - The value to remove.
|
|
133
|
+
*/
|
|
134
|
+
const deleteFromSetMap = <K, V>(map: Map<K, Set<V>>, key: K, value: V): void => {
|
|
135
|
+
const set = map.get(key);
|
|
136
|
+
if (!set) return;
|
|
137
|
+
set.delete(value);
|
|
138
|
+
if (set.size === 0) map.delete(key);
|
|
139
|
+
};
|
|
140
|
+
|
|
36
141
|
// ---------------------------------------------------------------------------
|
|
37
|
-
// Internal interface — extended surface consumed by View
|
|
142
|
+
// Internal interface — extended surface consumed by View / ClientSession
|
|
38
143
|
// ---------------------------------------------------------------------------
|
|
39
144
|
|
|
40
|
-
/** Internal tree surface used by View — not part of the public Tree API. */
|
|
41
|
-
export interface TreeInternal<
|
|
145
|
+
/** Internal tree surface used by View and ClientSession — not part of the public Tree API. */
|
|
146
|
+
export interface TreeInternal<
|
|
147
|
+
TInput extends CodecInputEvent,
|
|
148
|
+
TOutput extends CodecOutputEvent,
|
|
149
|
+
TProjection,
|
|
150
|
+
> extends Tree<TOutput, TProjection> {
|
|
151
|
+
/**
|
|
152
|
+
* Walk the visible node chain (both input nodes and reply runs) along the
|
|
153
|
+
* selected branches, in chronological order. The View renders from this.
|
|
154
|
+
* @param selections - Per-group selected member key, keyed by group root.
|
|
155
|
+
* @returns The visible nodes in chronological order.
|
|
156
|
+
*/
|
|
157
|
+
visibleNodes(selections?: Map<string, string>): ConversationNode<TProjection>[];
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get the "group root" key for a sibling group — the stable key the
|
|
161
|
+
* selection map is keyed by (the earliest edit version for input nodes, the
|
|
162
|
+
* original reply for a regenerate group).
|
|
163
|
+
*/
|
|
164
|
+
getGroupRoot(key: string): string;
|
|
165
|
+
|
|
42
166
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
167
|
+
* The reply runs parented at an input node (its codec-message-id), in
|
|
168
|
+
* iteration order. Empty when none have been observed. Used to resolve a
|
|
169
|
+
* user prompt to its reply run(s).
|
|
170
|
+
* @param inputCodecMessageId - The input node's codec-message-id.
|
|
171
|
+
* @returns The reply runs parented at that input.
|
|
47
172
|
*/
|
|
48
|
-
|
|
173
|
+
getReplyRuns(inputCodecMessageId: string): RunNode<TProjection>[];
|
|
49
174
|
|
|
50
175
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
176
|
+
* Apply an inbound channel message to the tree.
|
|
177
|
+
*
|
|
178
|
+
* Classifies the message and routes it to the owning node:
|
|
179
|
+
* 1. Run-less user input (no run-id, a `user`-role message carrying a
|
|
180
|
+
* codec-message-id and input events): creates or promotes the input node
|
|
181
|
+
* keyed by that codec-message-id, folds the input events.
|
|
182
|
+
* 2. Run-bearing wire (assistant output, continuation tool-resolution, or a
|
|
183
|
+
* fresh agent-minted run): routes to the reply run by run-id (reconciling
|
|
184
|
+
* an optimistic insert by codec-message-id), folds events.
|
|
185
|
+
* @param events - Decoded codec events, split by wire direction. Both are
|
|
186
|
+
* folded into the node's projection, inputs first.
|
|
187
|
+
* @param events.inputs - Client-published events (`ai-input` wire).
|
|
188
|
+
* @param events.outputs - Agent-published events (`ai-output` wire).
|
|
189
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
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.
|
|
56
201
|
*/
|
|
57
|
-
|
|
202
|
+
applyMessage(
|
|
203
|
+
events: { inputs: TInput[]; outputs: TOutput[] },
|
|
204
|
+
headers: Record<string, string>,
|
|
205
|
+
serial?: string,
|
|
206
|
+
timestamp?: number,
|
|
207
|
+
version?: string,
|
|
208
|
+
): void;
|
|
58
209
|
|
|
59
210
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
211
|
+
* Apply a run-lifecycle event.
|
|
212
|
+
*
|
|
213
|
+
* - `start`: creates the reply run (if missing) or, for an existing run,
|
|
214
|
+
* sets RunNode.state to 'active', promotes startSerial, and backfills
|
|
215
|
+
* structural metadata (parent / forkOf / regenerates / invocationId).
|
|
216
|
+
* - `suspend`: sets RunNode.state to 'suspended' and records `endSerial`.
|
|
217
|
+
* The run stays live so a resume under the same `runId` picks up where it
|
|
218
|
+
* left off.
|
|
219
|
+
* - `resume`: re-activates an existing suspended Run (state back to
|
|
220
|
+
* 'active') without touching its structure or serials — a pure re-entry
|
|
221
|
+
* signal. A no-op if the Run is not yet known.
|
|
222
|
+
* - `end`: sets RunNode.state to the terminal reason and records
|
|
223
|
+
* `endSerial`.
|
|
224
|
+
*
|
|
225
|
+
* Always emits a 'run' event to subscribers.
|
|
226
|
+
* @param event - Lifecycle event payload, including the channel serial.
|
|
62
227
|
*/
|
|
63
|
-
|
|
228
|
+
applyRunLifecycle(event: RunLifecycleEvent): void;
|
|
64
229
|
|
|
65
230
|
/**
|
|
66
|
-
* Get the
|
|
67
|
-
*
|
|
231
|
+
* Get the node keyed by `key`, or undefined if `key` names no node. The
|
|
232
|
+
* key is a {@link nodeKey} — a runId (reply run) or an input node's
|
|
233
|
+
* codec-message-id — so the result is a {@link ConversationNode} union:
|
|
234
|
+
* narrow on `kind` before reading kind-specific fields. Pairs with
|
|
235
|
+
* {@link getNodeByCodecMessageId}, which resolves an arbitrary owned
|
|
236
|
+
* codec-message-id (including an assistant message's) to its node.
|
|
237
|
+
* @param key - The node key to look up.
|
|
238
|
+
* @returns The node, or undefined if not found.
|
|
68
239
|
*/
|
|
69
|
-
|
|
240
|
+
getNode(key: string): ConversationNode<TProjection> | undefined;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Remove a node from the tree by its key ({@link nodeKey} — a runId or an
|
|
244
|
+
* input node's codec-message-id). Children become unreachable because their
|
|
245
|
+
* parent is no longer on the active path.
|
|
246
|
+
* @param key - The node key to remove.
|
|
247
|
+
*/
|
|
248
|
+
delete(key: string): void;
|
|
70
249
|
|
|
71
250
|
/** Forward a raw Ably message event to tree subscribers. */
|
|
72
251
|
emitAblyMessage(msg: Ably.InboundMessage): void;
|
|
73
|
-
/** Forward a turn lifecycle event to tree subscribers. */
|
|
74
|
-
emitTurn(event: TurnLifecycleEvent): void;
|
|
75
|
-
/** Register an active turn. */
|
|
76
|
-
trackTurn(turnId: string, clientId: string): void;
|
|
77
|
-
/** Unregister an active turn. */
|
|
78
|
-
untrackTurn(turnId: string): void;
|
|
79
252
|
}
|
|
80
253
|
|
|
81
254
|
// ---------------------------------------------------------------------------
|
|
@@ -83,49 +256,112 @@ export interface TreeInternal<TMessage> extends Tree<TMessage> {
|
|
|
83
256
|
// ---------------------------------------------------------------------------
|
|
84
257
|
|
|
85
258
|
/** EventEmitter events map for the tree. */
|
|
86
|
-
interface TreeEventsMap {
|
|
259
|
+
interface TreeEventsMap<TOutput extends CodecOutputEvent> {
|
|
87
260
|
update: undefined;
|
|
88
261
|
'ably-message': Ably.InboundMessage;
|
|
89
|
-
|
|
262
|
+
run: RunLifecycleEvent;
|
|
263
|
+
output: OutputEvent<TOutput>;
|
|
90
264
|
}
|
|
91
265
|
|
|
92
266
|
// Spec: AIT-CT13
|
|
93
|
-
export class DefaultTree<
|
|
94
|
-
|
|
95
|
-
|
|
267
|
+
export class DefaultTree<
|
|
268
|
+
TInput extends CodecInputEvent,
|
|
269
|
+
TOutput extends CodecOutputEvent,
|
|
270
|
+
TProjection,
|
|
271
|
+
> implements TreeInternal<TInput, TOutput, TProjection> {
|
|
272
|
+
private readonly _codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>;
|
|
273
|
+
private readonly _logger: Logger;
|
|
274
|
+
private readonly _emitter: EventEmitter<TreeEventsMap<TOutput>>;
|
|
96
275
|
|
|
97
276
|
/**
|
|
98
|
-
* All nodes
|
|
99
|
-
*
|
|
100
|
-
* ordered among themselves by insertion sequence.
|
|
277
|
+
* All nodes indexed by their primary key ({@link nodeKey}): a reply run's
|
|
278
|
+
* runId, or an input node's codec-message-id.
|
|
101
279
|
*/
|
|
102
|
-
private readonly
|
|
280
|
+
private readonly _nodeIndex = new Map<string, InternalNode<TInput, TOutput, TProjection>>();
|
|
103
281
|
|
|
104
282
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
283
|
+
* Maps every observed `codec-message-id` to its owning node's key
|
|
284
|
+
* ({@link nodeKey}). For a reply run that is the runId of every message the
|
|
285
|
+
* run published; for an input node it is the input's own codec-message-id.
|
|
286
|
+
* Resolves fork-of / parent codec-message-ids to node keys, routes
|
|
287
|
+
* continuation amend wires to existing nodes, and backs UI lookups that hold
|
|
288
|
+
* a codec-message-id.
|
|
107
289
|
*/
|
|
108
|
-
private readonly
|
|
290
|
+
private readonly _codecMessageIdToNodeKey = new Map<string, string>();
|
|
109
291
|
|
|
110
|
-
|
|
111
|
-
|
|
292
|
+
/**
|
|
293
|
+
* All nodes sorted by their sort serial ({@link sortSerial}: `startSerial`
|
|
294
|
+
* for runs, `serial` for input nodes), lexicographically. Nodes with no sort
|
|
295
|
+
* serial (optimistic) sort after all serial-bearing nodes, ordered among
|
|
296
|
+
* themselves by insertion sequence.
|
|
297
|
+
*/
|
|
298
|
+
private readonly _sortedNodes: InternalNode<TInput, TOutput, TProjection>[] = [];
|
|
112
299
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Parent index: parent node key (the key its children's
|
|
302
|
+
* `parentCodecMessageId` resolves to) to the set of child node keys. Root
|
|
303
|
+
* nodes (no parent) are indexed under the key `undefined`. Kind-blind — a
|
|
304
|
+
* reply run and an input node parent off each other through the same index.
|
|
305
|
+
*/
|
|
306
|
+
private readonly _parentIndex = new Map<string | undefined, Set<string>>();
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Reverse edge: an input node's codec-message-id to the set of reply-run ids
|
|
310
|
+
* parented at it. Lets the View resolve a user prompt to its (selected) reply
|
|
311
|
+
* run, and groups regenerate siblings (which all parent at the same input
|
|
312
|
+
* node).
|
|
313
|
+
*/
|
|
314
|
+
private readonly _replyRunsByInput = new Map<string, Set<string>>();
|
|
115
315
|
|
|
116
316
|
/** Monotonically increasing counter for insertion sequence. */
|
|
117
317
|
private _seqCounter = 0;
|
|
118
318
|
|
|
119
|
-
/** Incremented on structural changes; unchanged on
|
|
319
|
+
/** Incremented on structural changes; unchanged on projection-only updates. */
|
|
120
320
|
private _structuralVersion = 0;
|
|
121
321
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Cached sibling-group lookups keyed by node key. The walk over forkOf
|
|
324
|
+
* chains and the per-parent fan-out are pure functions of the node
|
|
325
|
+
* graph, so the cache is keyed against {@link _structuralVersion}:
|
|
326
|
+
* any topology mutation drops the cache and the next lookup
|
|
327
|
+
* recomputes. Hits matter most during a single render pass where
|
|
328
|
+
* the View calls `getSiblingNodes` once per visible node plus extra
|
|
329
|
+
* per-message branch-anchor probes from React components.
|
|
330
|
+
*/
|
|
331
|
+
private _siblingCache = new Map<string, InternalNode<TInput, TOutput, TProjection>[]>();
|
|
332
|
+
private _siblingCacheVersion = -1;
|
|
333
|
+
|
|
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[] = [];
|
|
125
360
|
|
|
126
|
-
constructor(logger: Logger) {
|
|
361
|
+
constructor(codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>, logger: Logger) {
|
|
362
|
+
this._codec = codec;
|
|
127
363
|
this._logger = logger;
|
|
128
|
-
this._emitter = new EventEmitter<TreeEventsMap
|
|
364
|
+
this._emitter = new EventEmitter<TreeEventsMap<TOutput>>(logger);
|
|
129
365
|
}
|
|
130
366
|
|
|
131
367
|
// -------------------------------------------------------------------------
|
|
@@ -133,405 +369,1149 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
|
|
|
133
369
|
// -------------------------------------------------------------------------
|
|
134
370
|
|
|
135
371
|
/**
|
|
136
|
-
* Compare two nodes for sorted list ordering.
|
|
137
|
-
* Serial-bearing nodes sort by serial (
|
|
138
|
-
*
|
|
139
|
-
*
|
|
372
|
+
* Compare two nodes (Run or input) for sorted list ordering.
|
|
373
|
+
* Serial-bearing nodes sort by their sort serial (`startSerial` for runs,
|
|
374
|
+
* `serial` for input nodes), lexicographically.
|
|
375
|
+
* Nodes with no sort serial sort after all serial-bearing nodes.
|
|
376
|
+
* Among them, sort by insertion sequence.
|
|
377
|
+
*
|
|
378
|
+
* Optimistic (null-serial) nodes intentionally tail-sort so they reorder
|
|
379
|
+
* into place when the server relay arrives and `applyMessage` promotes
|
|
380
|
+
* startSerial — see {@link applyMessage}'s `_removeSortedNode` /
|
|
381
|
+
* `_insertSortedNode` pair on the promotion path.
|
|
140
382
|
* @param a - First node to compare.
|
|
141
383
|
* @param b - Second node to compare.
|
|
142
384
|
* @returns Negative if a sorts before b, positive if after, zero if equal.
|
|
143
385
|
*/
|
|
144
386
|
// Spec: AIT-CT13a
|
|
145
|
-
private _compareNodes(
|
|
146
|
-
|
|
147
|
-
|
|
387
|
+
private _compareNodes(
|
|
388
|
+
a: InternalNode<TInput, TOutput, TProjection>,
|
|
389
|
+
b: InternalNode<TInput, TOutput, TProjection>,
|
|
390
|
+
): number {
|
|
391
|
+
const sa = sortSerial(a.node);
|
|
392
|
+
const sb = sortSerial(b.node);
|
|
148
393
|
if (sa === undefined && sb === undefined) return a.insertSeq - b.insertSeq;
|
|
149
|
-
if (sa === undefined) return 1;
|
|
150
|
-
if (sb === undefined) return -1;
|
|
394
|
+
if (sa === undefined) return 1;
|
|
395
|
+
if (sb === undefined) return -1;
|
|
151
396
|
if (sa < sb) return -1;
|
|
152
397
|
if (sa > sb) return 1;
|
|
153
|
-
return a.insertSeq - b.insertSeq;
|
|
398
|
+
return a.insertSeq - b.insertSeq;
|
|
154
399
|
}
|
|
155
400
|
|
|
156
401
|
/**
|
|
157
|
-
* Insert a node into
|
|
402
|
+
* Insert a node into the sorted list at the correct position via binary search.
|
|
158
403
|
* @param internal - The node to insert.
|
|
159
404
|
*/
|
|
160
|
-
private
|
|
161
|
-
const
|
|
405
|
+
private _insertSortedNode(internal: InternalNode<TInput, TOutput, TProjection>): void {
|
|
406
|
+
const startSerial = sortSerial(internal.node);
|
|
162
407
|
|
|
163
|
-
// Fast path: null-
|
|
164
|
-
if (
|
|
165
|
-
this.
|
|
408
|
+
// Fast path: null-startSerial always appends to end.
|
|
409
|
+
if (startSerial === undefined) {
|
|
410
|
+
this._sortedNodes.push(internal);
|
|
166
411
|
return;
|
|
167
412
|
}
|
|
168
413
|
|
|
169
|
-
// Binary search for insertion point among serial-bearing nodes.
|
|
170
414
|
let lo = 0;
|
|
171
|
-
let hi = this.
|
|
415
|
+
let hi = this._sortedNodes.length;
|
|
172
416
|
while (lo < hi) {
|
|
173
417
|
const mid = (lo + hi) >>> 1;
|
|
174
|
-
const midNode = this.
|
|
175
|
-
if (!midNode) break; // unreachable
|
|
418
|
+
const midNode = this._sortedNodes[mid];
|
|
419
|
+
if (!midNode) break; // unreachable
|
|
176
420
|
if (this._compareNodes(midNode, internal) <= 0) {
|
|
177
421
|
lo = mid + 1;
|
|
178
422
|
} else {
|
|
179
423
|
hi = mid;
|
|
180
424
|
}
|
|
181
425
|
}
|
|
182
|
-
this.
|
|
426
|
+
this._sortedNodes.splice(lo, 0, internal);
|
|
183
427
|
}
|
|
184
428
|
|
|
185
429
|
/**
|
|
186
|
-
* Remove a node from
|
|
430
|
+
* Remove a node from the sorted list.
|
|
187
431
|
* @param internal - The node to remove.
|
|
188
432
|
*/
|
|
189
|
-
private
|
|
190
|
-
const idx = this.
|
|
191
|
-
if (idx !== -1) this.
|
|
433
|
+
private _removeSortedNode(internal: InternalNode<TInput, TOutput, TProjection>): void {
|
|
434
|
+
const idx = this._sortedNodes.indexOf(internal);
|
|
435
|
+
if (idx !== -1) this._sortedNodes.splice(idx, 1);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Insert a freshly-created node into the primary store, the parent index, and
|
|
440
|
+
* the sorted list, then bump the structural version. Kind-specific secondary
|
|
441
|
+
* indexing — the codec-message-id map for input nodes, the reply→input edge
|
|
442
|
+
* for reply runs — is the caller's responsibility.
|
|
443
|
+
* @param key - The node's primary key ({@link nodeKey}).
|
|
444
|
+
* @param entry - The internal node to insert.
|
|
445
|
+
* @param parentCodecMessageId - The node's structural parent, or undefined for a root.
|
|
446
|
+
*/
|
|
447
|
+
private _insertNode(
|
|
448
|
+
key: string,
|
|
449
|
+
entry: InternalNode<TInput, TOutput, TProjection>,
|
|
450
|
+
parentCodecMessageId: string | undefined,
|
|
451
|
+
): void {
|
|
452
|
+
this._nodeIndex.set(key, entry);
|
|
453
|
+
this._addToParentIndex(parentCodecMessageId, key);
|
|
454
|
+
this._insertSortedNode(entry);
|
|
455
|
+
this._structuralVersion++;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Re-sort a node whose sort key just changed and bump the structural version.
|
|
460
|
+
* The caller mutates the serial field (`serial` for input nodes, `startSerial`
|
|
461
|
+
* for runs); this keeps the sorted list and version in step. Used on the
|
|
462
|
+
* optimistic-serial promotion paths when the server relay/echo arrives.
|
|
463
|
+
* @param entry - The internal node whose serial was just promoted.
|
|
464
|
+
*/
|
|
465
|
+
private _promoteSerial(entry: InternalNode<TInput, TOutput, TProjection>): void {
|
|
466
|
+
this._removeSortedNode(entry);
|
|
467
|
+
this._insertSortedNode(entry);
|
|
468
|
+
this._structuralVersion++;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Fold a batch of events into a node's projection in place, isolating each
|
|
473
|
+
* fold in a try/catch so a throwing reducer can't abort the rest of the batch
|
|
474
|
+
* or the surrounding apply.
|
|
475
|
+
* @param entry - The internal node whose projection is folded in place.
|
|
476
|
+
* @param events - The decoded events to fold, in wire order.
|
|
477
|
+
* @param serial - Ably channel serial; coerced to '' for an optimistic insert.
|
|
478
|
+
* @param messageId - The reducer routing key (codec-message-id), or undefined.
|
|
479
|
+
*/
|
|
480
|
+
private _foldInto(
|
|
481
|
+
entry: InternalNode<TInput, TOutput, TProjection>,
|
|
482
|
+
events: CodecEvent<TInput, TOutput>[],
|
|
483
|
+
serial: string | undefined,
|
|
484
|
+
messageId: string | undefined,
|
|
485
|
+
): void {
|
|
486
|
+
for (const event of events) {
|
|
487
|
+
try {
|
|
488
|
+
entry.node.projection = this._codec.fold(entry.node.projection, event, { serial: serial ?? '', messageId });
|
|
489
|
+
} catch (error) {
|
|
490
|
+
this._logger.error('Tree._foldInto(); fold threw', { key: nodeKey(entry.node), messageId, err: error });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
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;
|
|
192
611
|
}
|
|
193
612
|
|
|
194
613
|
// -------------------------------------------------------------------------
|
|
195
|
-
//
|
|
614
|
+
// Event-log retention
|
|
196
615
|
// -------------------------------------------------------------------------
|
|
197
616
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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();
|
|
203
631
|
}
|
|
204
|
-
set.add(msgId);
|
|
205
632
|
}
|
|
206
633
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
+
});
|
|
212
682
|
}
|
|
213
683
|
}
|
|
214
684
|
|
|
685
|
+
// -------------------------------------------------------------------------
|
|
686
|
+
// Parent index maintenance
|
|
687
|
+
// -------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
private _addToParentIndex(parentNodeKey: string | undefined, childKey: string): void {
|
|
690
|
+
addToSetMap(this._parentIndex, parentNodeKey, childKey);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private _removeFromParentIndex(parentNodeKey: string | undefined, childKey: string): void {
|
|
694
|
+
deleteFromSetMap(this._parentIndex, parentNodeKey, childKey);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Resolve a node's structural parent to the parent node's key
|
|
699
|
+
* ({@link nodeKey}), or undefined for a root. The parent is named by a
|
|
700
|
+
* codec-message-id (`parentCodecMessageId`); this maps it through the
|
|
701
|
+
* codec-message-id index to the owning node's key (a runId for a reply run,
|
|
702
|
+
* a codec-message-id for an input node). Returns undefined when the parent
|
|
703
|
+
* hasn't been observed yet (the node is treated as a root until it arrives).
|
|
704
|
+
* @param node - The node whose parent to resolve.
|
|
705
|
+
* @returns The parent node's key, or undefined.
|
|
706
|
+
*/
|
|
707
|
+
private _parentKeyOf(node: ConversationNode<TProjection>): string | undefined {
|
|
708
|
+
const parentCodecMessageId = node.parentCodecMessageId;
|
|
709
|
+
return parentCodecMessageId === undefined ? undefined : this._codecMessageIdToNodeKey.get(parentCodecMessageId);
|
|
710
|
+
}
|
|
711
|
+
|
|
215
712
|
// -------------------------------------------------------------------------
|
|
216
713
|
// Sibling grouping
|
|
217
714
|
// -------------------------------------------------------------------------
|
|
218
715
|
|
|
219
716
|
/**
|
|
220
|
-
*
|
|
717
|
+
* Walk an input node's `forkOf` chain to the group root — the earliest edit
|
|
718
|
+
* version sharing the same structural parent. Stops at a missing target, a
|
|
719
|
+
* non-input target, a parent mismatch, or a cycle.
|
|
720
|
+
* @param node - The input node to walk from.
|
|
721
|
+
* @returns The group-root input node (the node itself when it is the root).
|
|
722
|
+
*/
|
|
723
|
+
private _inputGroupRoot(node: InputNode<TProjection>): InputNode<TProjection> {
|
|
724
|
+
let current = node;
|
|
725
|
+
const visited = new Set<string>([nodeKey(current)]);
|
|
726
|
+
while (current.forkOf !== undefined) {
|
|
727
|
+
if (visited.has(current.forkOf)) break;
|
|
728
|
+
const forkTarget = this._nodeIndex.get(current.forkOf);
|
|
729
|
+
if (forkTarget?.node.kind !== 'input' || forkTarget.node.parentCodecMessageId !== current.parentCodecMessageId) {
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
current = forkTarget.node;
|
|
733
|
+
visited.add(nodeKey(current));
|
|
734
|
+
}
|
|
735
|
+
return current;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Get the sibling group that the node keyed by `key` belongs to. Kind-split:
|
|
221
740
|
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
741
|
+
* - **Reply runs** — every reply run sharing the same input-node parent is a
|
|
742
|
+
* sibling (the original reply + its regenerators all parent at the same
|
|
743
|
+
* input node M_user). No fork-of involved.
|
|
744
|
+
* - **Input nodes** — edit versions: nodes sharing a parent AND linked by a
|
|
745
|
+
* `forkOf` chain to the group root.
|
|
746
|
+
*
|
|
747
|
+
* Returned ordered by startSerial (original/oldest first). A group of one is
|
|
748
|
+
* returned as a single-element array (no branching).
|
|
749
|
+
* @param key - The node key ({@link nodeKey}) to look up the group for.
|
|
227
750
|
* @returns The ordered list of sibling nodes.
|
|
228
751
|
*/
|
|
229
752
|
// Spec: AIT-CT13b
|
|
230
|
-
private _getSiblingGroup(
|
|
231
|
-
|
|
753
|
+
private _getSiblingGroup(key: string): InternalNode<TInput, TOutput, TProjection>[] {
|
|
754
|
+
if (this._siblingCacheVersion !== this._structuralVersion) {
|
|
755
|
+
this._siblingCache.clear();
|
|
756
|
+
this._siblingCacheVersion = this._structuralVersion;
|
|
757
|
+
}
|
|
758
|
+
const cached = this._siblingCache.get(key);
|
|
759
|
+
if (cached) return cached;
|
|
760
|
+
|
|
761
|
+
const entry = this._nodeIndex.get(key);
|
|
232
762
|
if (!entry) return [];
|
|
233
763
|
|
|
234
|
-
//
|
|
235
|
-
//
|
|
764
|
+
// The "original" anchors the group's parent + kind. For an input node,
|
|
765
|
+
// walk the forkOf chain to the earliest version sharing the parent; for a
|
|
766
|
+
// reply run the node itself anchors (all same-parent runs are siblings).
|
|
236
767
|
let original = entry.node;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const candidateIds = this._parentIndex.get(parentId);
|
|
253
|
-
if (candidateIds) {
|
|
254
|
-
for (const childId of candidateIds) {
|
|
255
|
-
const childEntry = this._nodeIndex.get(childId);
|
|
256
|
-
if (childEntry && this._isSiblingOf(childEntry.node, originalId)) {
|
|
768
|
+
if (original.kind === 'input') {
|
|
769
|
+
original = this._inputGroupRoot(original);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// `_parentIndex` is keyed by the raw structural `parentCodecMessageId` (not
|
|
773
|
+
// the resolved parent node key) so a run observed before its input node
|
|
774
|
+
// still files/groups correctly — the parent codec-message-id is known at
|
|
775
|
+
// creation, the resolved key may not be.
|
|
776
|
+
const parentKey = original.parentCodecMessageId;
|
|
777
|
+
const siblings: InternalNode<TInput, TOutput, TProjection>[] = [];
|
|
778
|
+
const candidateKeys = this._parentIndex.get(parentKey);
|
|
779
|
+
if (candidateKeys) {
|
|
780
|
+
for (const childKey of candidateKeys) {
|
|
781
|
+
const childEntry = this._nodeIndex.get(childKey);
|
|
782
|
+
if (childEntry && this._isSiblingOf(childEntry.node, original)) {
|
|
257
783
|
siblings.push(childEntry);
|
|
258
784
|
}
|
|
259
785
|
}
|
|
260
786
|
}
|
|
261
787
|
|
|
262
|
-
// Sort by Ably serial (lexicographic). Messages without a serial
|
|
263
|
-
// (optimistic inserts before server relay) sort after all serial-bearing
|
|
264
|
-
// siblings — they represent the user's most recent action.
|
|
265
788
|
siblings.sort((a, b) => this._compareNodes(a, b));
|
|
266
|
-
|
|
789
|
+
// Cache against the queried key AND every member of the group: a single
|
|
790
|
+
// group is the same array regardless of which member triggered the lookup,
|
|
791
|
+
// so subsequent queries against any member hit without recomputing.
|
|
792
|
+
for (const sib of siblings) {
|
|
793
|
+
this._siblingCache.set(nodeKey(sib.node), siblings);
|
|
794
|
+
}
|
|
795
|
+
this._siblingCache.set(key, siblings);
|
|
796
|
+
return siblings;
|
|
267
797
|
}
|
|
268
798
|
|
|
269
799
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
* @param
|
|
275
|
-
* @
|
|
800
|
+
* Whether `node` belongs to the sibling group anchored at `original`.
|
|
801
|
+
* Requires the same kind and the same structural parent; reply runs need
|
|
802
|
+
* nothing more (same-parent runs are regenerate siblings), input nodes must
|
|
803
|
+
* additionally be forkOf-linked to the original (edit versions).
|
|
804
|
+
* @param node - The candidate node.
|
|
805
|
+
* @param original - The group's anchor node.
|
|
806
|
+
* @returns True if `node` is a sibling of `original`.
|
|
276
807
|
*/
|
|
277
|
-
private _isSiblingOf(node:
|
|
278
|
-
if (node.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
808
|
+
private _isSiblingOf(node: ConversationNode<TProjection>, original: ConversationNode<TProjection>): boolean {
|
|
809
|
+
if (node.kind !== original.kind) return false;
|
|
810
|
+
if (node.parentCodecMessageId !== original.parentCodecMessageId) return false;
|
|
811
|
+
// Same-parent reply runs are regenerate siblings — no fork-of needed.
|
|
812
|
+
if (node.kind === 'run') return true;
|
|
813
|
+
// Input nodes: must be forkOf-linked to the original (edit versions).
|
|
814
|
+
const originalKey = nodeKey(original);
|
|
815
|
+
if (nodeKey(node) === originalKey) return true;
|
|
816
|
+
let current: ConversationNode<TProjection> = node;
|
|
817
|
+
const visited = new Set<string>([nodeKey(current)]);
|
|
818
|
+
while (current.kind === 'input' && current.forkOf !== undefined) {
|
|
819
|
+
if (current.forkOf === originalKey) return true;
|
|
820
|
+
if (visited.has(current.forkOf)) break;
|
|
284
821
|
const target = this._nodeIndex.get(current.forkOf);
|
|
285
822
|
if (!target) break;
|
|
286
823
|
current = target.node;
|
|
287
|
-
visited.add(current
|
|
824
|
+
visited.add(nodeKey(current));
|
|
288
825
|
}
|
|
289
826
|
return false;
|
|
290
827
|
}
|
|
291
828
|
|
|
292
829
|
/**
|
|
293
|
-
* Get the "group root"
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
830
|
+
* Get the "group root" key for a sibling group — the stable key the
|
|
831
|
+
* selection map is keyed by. For an input node (edit versions) that is the
|
|
832
|
+
* earliest fork-of ancestor; for a reply run (regenerate group) it is the
|
|
833
|
+
* oldest same-parent run (the original reply).
|
|
834
|
+
* @param key - Any node key in the sibling group.
|
|
835
|
+
* @returns The group root's key.
|
|
297
836
|
*/
|
|
298
|
-
getGroupRoot(
|
|
299
|
-
const entry = this._nodeIndex.get(
|
|
300
|
-
if (!entry) return
|
|
837
|
+
getGroupRoot(key: string): string {
|
|
838
|
+
const entry = this._nodeIndex.get(key);
|
|
839
|
+
if (!entry) return key;
|
|
301
840
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
while (current.forkOf) {
|
|
305
|
-
if (visited.has(current.forkOf)) break; // cycle guard
|
|
306
|
-
const forkTarget = this._nodeIndex.get(current.forkOf);
|
|
307
|
-
if (!forkTarget || forkTarget.node.parentId !== current.parentId) break;
|
|
308
|
-
current = forkTarget.node;
|
|
309
|
-
visited.add(current.msgId);
|
|
841
|
+
if (entry.node.kind === 'input') {
|
|
842
|
+
return nodeKey(this._inputGroupRoot(entry.node));
|
|
310
843
|
}
|
|
311
|
-
|
|
844
|
+
|
|
845
|
+
// Reply run: the oldest same-parent run is the original reply.
|
|
846
|
+
const group = this._getSiblingGroup(key);
|
|
847
|
+
const root = group[0]?.node;
|
|
848
|
+
return root ? nodeKey(root) : key;
|
|
312
849
|
}
|
|
313
850
|
|
|
314
851
|
// -------------------------------------------------------------------------
|
|
315
852
|
// Public query methods
|
|
316
853
|
// -------------------------------------------------------------------------
|
|
317
854
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
855
|
+
/**
|
|
856
|
+
* Walk the visible node chain along the selected branches, kind-blind. An
|
|
857
|
+
* input node and a reply run reach each other through the same
|
|
858
|
+
* parent-membership check, so seed-only user→user chains and the
|
|
859
|
+
* input→reply→input weave both resolve here. Sibling groups (edit versions /
|
|
860
|
+
* regenerate runs) collapse to the selected member.
|
|
861
|
+
* @param selections - Per-group selected member key, keyed by group root.
|
|
862
|
+
* @returns The visible nodes (both kinds) in chronological order.
|
|
863
|
+
*/
|
|
864
|
+
visibleNodes(selections: Map<string, string> = new Map<string, string>()): ConversationNode<TProjection>[] {
|
|
865
|
+
this._logger.trace('DefaultTree.visibleNodes();');
|
|
866
|
+
const result: ConversationNode<TProjection>[] = [];
|
|
321
867
|
const currentPath = new Set<string>();
|
|
322
|
-
|
|
323
|
-
// re-resolving for every member of the group.
|
|
324
|
-
const resolvedGroups = new Map<string, string>(); // groupRootId → selected msgId
|
|
868
|
+
const resolvedGroups = new Map<string, string>(); // groupRootKey -> selected key
|
|
325
869
|
|
|
326
|
-
for (const internal of this.
|
|
870
|
+
for (const internal of this._sortedNodes) {
|
|
327
871
|
const node = internal.node;
|
|
328
|
-
const
|
|
872
|
+
const key = nodeKey(node);
|
|
329
873
|
|
|
330
|
-
// Step 1:
|
|
331
|
-
|
|
874
|
+
// Step 1: Parent reachability (kind-blind — the parent may be an input
|
|
875
|
+
// node or a reply run; resolve its key and check the active path).
|
|
876
|
+
const parentKey = this._parentKeyOf(node);
|
|
877
|
+
if (parentKey !== undefined && !currentPath.has(parentKey)) {
|
|
332
878
|
continue;
|
|
333
879
|
}
|
|
334
880
|
|
|
335
|
-
// Step 2:
|
|
336
|
-
const group = this._getSiblingGroup(
|
|
881
|
+
// Step 2: Sibling selection.
|
|
882
|
+
const group = this._getSiblingGroup(key);
|
|
337
883
|
if (group.length > 1) {
|
|
338
|
-
const
|
|
339
|
-
let
|
|
340
|
-
if (
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
selectedId = preferredId;
|
|
884
|
+
const groupRootKey = this.getGroupRoot(key);
|
|
885
|
+
let selectedKey = resolvedGroups.get(groupRootKey);
|
|
886
|
+
if (selectedKey === undefined) {
|
|
887
|
+
const preferredKey = selections.get(groupRootKey);
|
|
888
|
+
if (preferredKey !== undefined && group.some((n) => nodeKey(n.node) === preferredKey)) {
|
|
889
|
+
selectedKey = preferredKey;
|
|
345
890
|
} else {
|
|
346
891
|
const latest = group.at(-1);
|
|
347
892
|
if (!latest) break; // unreachable: group.length > 1
|
|
348
|
-
|
|
893
|
+
selectedKey = nodeKey(latest.node);
|
|
349
894
|
}
|
|
350
|
-
resolvedGroups.set(
|
|
895
|
+
resolvedGroups.set(groupRootKey, selectedKey);
|
|
351
896
|
}
|
|
352
|
-
if (
|
|
897
|
+
if (key !== selectedKey) {
|
|
353
898
|
continue;
|
|
354
899
|
}
|
|
355
900
|
}
|
|
356
901
|
|
|
357
|
-
currentPath.add(
|
|
902
|
+
currentPath.add(key);
|
|
358
903
|
result.push(node);
|
|
359
904
|
}
|
|
360
905
|
|
|
361
906
|
return result;
|
|
362
907
|
}
|
|
363
908
|
|
|
364
|
-
|
|
365
|
-
this._logger.trace('DefaultTree.
|
|
366
|
-
|
|
909
|
+
getRunNode(runId: string): RunNode<TProjection> | undefined {
|
|
910
|
+
this._logger.trace('DefaultTree.getRunNode();', { runId });
|
|
911
|
+
const node = this._nodeIndex.get(runId)?.node;
|
|
912
|
+
return node?.kind === 'run' ? node : undefined;
|
|
367
913
|
}
|
|
368
914
|
|
|
369
|
-
|
|
370
|
-
|
|
915
|
+
getNode(key: string): ConversationNode<TProjection> | undefined {
|
|
916
|
+
this._logger.trace('DefaultTree.getNode();', { key });
|
|
917
|
+
return this._nodeIndex.get(key)?.node;
|
|
371
918
|
}
|
|
372
919
|
|
|
373
|
-
|
|
374
|
-
|
|
920
|
+
getNodeByCodecMessageId(codecMessageId: string): ConversationNode<TProjection> | undefined {
|
|
921
|
+
this._logger.trace('DefaultTree.getNodeByCodecMessageId();', { codecMessageId });
|
|
922
|
+
const key = this._codecMessageIdToNodeKey.get(codecMessageId);
|
|
923
|
+
return key === undefined ? undefined : this._nodeIndex.get(key)?.node;
|
|
375
924
|
}
|
|
376
925
|
|
|
377
|
-
|
|
378
|
-
this.
|
|
379
|
-
|
|
926
|
+
getReplyRuns(inputCodecMessageId: string): RunNode<TProjection>[] {
|
|
927
|
+
const runIds = this._replyRunsByInput.get(inputCodecMessageId);
|
|
928
|
+
if (!runIds) return [];
|
|
929
|
+
const result: RunNode<TProjection>[] = [];
|
|
930
|
+
for (const runId of runIds) {
|
|
931
|
+
const node = this._nodeIndex.get(runId)?.node;
|
|
932
|
+
if (node?.kind === 'run') result.push(node);
|
|
933
|
+
}
|
|
934
|
+
return result;
|
|
380
935
|
}
|
|
381
936
|
|
|
382
|
-
|
|
383
|
-
this._logger.trace('DefaultTree.
|
|
384
|
-
return this.
|
|
937
|
+
getSiblingNodes(key: string): ConversationNode<TProjection>[] {
|
|
938
|
+
this._logger.trace('DefaultTree.getSiblingNodes();', { key });
|
|
939
|
+
return this._getSiblingGroup(key).map((n) => n.node);
|
|
385
940
|
}
|
|
386
941
|
|
|
387
942
|
// -------------------------------------------------------------------------
|
|
388
943
|
// Mutation
|
|
389
944
|
// -------------------------------------------------------------------------
|
|
390
945
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
946
|
+
applyMessage(
|
|
947
|
+
events: { inputs: TInput[]; outputs: TOutput[] },
|
|
948
|
+
headers: Record<string, string>,
|
|
949
|
+
serial?: string,
|
|
950
|
+
timestamp?: number,
|
|
951
|
+
version?: string,
|
|
952
|
+
): void {
|
|
953
|
+
const wireRunId = headers[HEADER_RUN_ID];
|
|
954
|
+
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
|
|
955
|
+
|
|
956
|
+
// Classify: with NO run-id, a user message carrying a codec-message-id and
|
|
957
|
+
// at least one input event forms an INPUT node keyed by that
|
|
958
|
+
// codec-message-id — the client owns it; the agent mints the reply run-id
|
|
959
|
+
// separately. Everything else needs a run-id to route to a reply run.
|
|
960
|
+
// Capturing the id (not a boolean) narrows it to `string` for the input path.
|
|
961
|
+
const inputNodeCodecMessageId =
|
|
962
|
+
wireRunId === undefined &&
|
|
963
|
+
codecMessageId !== undefined &&
|
|
964
|
+
headers[HEADER_ROLE] === 'user' &&
|
|
965
|
+
events.inputs.length > 0
|
|
966
|
+
? codecMessageId
|
|
967
|
+
: undefined;
|
|
968
|
+
|
|
969
|
+
if (wireRunId === undefined && inputNodeCodecMessageId === undefined) {
|
|
970
|
+
this._logger.warn('Tree.applyMessage(); message has no run-id and is not a user input; skipping');
|
|
415
971
|
return;
|
|
416
972
|
}
|
|
417
973
|
|
|
418
|
-
|
|
974
|
+
// Fold inputs first, then outputs, preserving wire order.
|
|
975
|
+
const all: CodecEvent<TInput, TOutput>[] = toCodecEvents(events);
|
|
419
976
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
977
|
+
// Wire-only metadata-carrier messages (e.g. `ait-regenerate`) decode to
|
|
978
|
+
// zero events and don't need a node at the tree level — the eventual reply
|
|
979
|
+
// run is created later by run-start, and any regenerate / parent
|
|
980
|
+
// information the wire carried is reread from the run-start headers.
|
|
981
|
+
// Skipping here avoids a phantom node that would inflate sibling counts.
|
|
982
|
+
const existingKey = inputNodeCodecMessageId ?? wireRunId;
|
|
983
|
+
if (all.length === 0 && existingKey !== undefined && !this._nodeIndex.has(existingKey)) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// `update` is the structural channel: emit it only when this apply
|
|
988
|
+
// actually changes the tree shape (new node, startSerial promotion).
|
|
989
|
+
// Content-only folds (streaming chunks into an existing node) flow through
|
|
990
|
+
// `output` instead, so they leave `_structuralVersion` untouched.
|
|
991
|
+
const structuralBefore = this._structuralVersion;
|
|
992
|
+
|
|
993
|
+
if (inputNodeCodecMessageId !== undefined) {
|
|
994
|
+
this._applyInputMessage(inputNodeCodecMessageId, headers, serial, timestamp, version, all);
|
|
995
|
+
} else if (wireRunId !== undefined) {
|
|
996
|
+
this._applyRunMessage(wireRunId, events, headers, serial, timestamp, version);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Apply a run-less user input wire: create (or promote the serial of) the
|
|
1004
|
+
* input node keyed by its codec-message-id, fold the input events into its
|
|
1005
|
+
* own projection, and emit an `output` event (with empty outputs — input
|
|
1006
|
+
* folds carry none) so the View observes the optimistic insert.
|
|
1007
|
+
* @param codecMessageId - The input node's codec-message-id (its primary key).
|
|
1008
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
1009
|
+
* @param serial - Ably channel serial; undefined for an optimistic insert.
|
|
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.
|
|
1013
|
+
*/
|
|
1014
|
+
private _applyInputMessage(
|
|
1015
|
+
codecMessageId: string,
|
|
1016
|
+
headers: Record<string, string>,
|
|
1017
|
+
serial: string | undefined,
|
|
1018
|
+
timestamp: number | undefined,
|
|
1019
|
+
version: string | undefined,
|
|
1020
|
+
all: CodecEvent<TInput, TOutput>[],
|
|
1021
|
+
): void {
|
|
1022
|
+
let entry = this._nodeIndex.get(codecMessageId);
|
|
1023
|
+
if (!entry) {
|
|
1024
|
+
entry = this._createInputNodeFromHeaders(codecMessageId, headers, serial);
|
|
1025
|
+
this._insertNode(codecMessageId, entry, entry.node.parentCodecMessageId);
|
|
1026
|
+
this._codecMessageIdToNodeKey.set(codecMessageId, codecMessageId);
|
|
1027
|
+
this._logger.debug('Tree.applyMessage(); created input node', { codecMessageId });
|
|
1028
|
+
} else if (entry.node.kind === 'input' && serial && !entry.node.serial) {
|
|
1029
|
+
// Promote optimistic serial when the relay/echo arrives.
|
|
1030
|
+
this._logger.debug('Tree.applyMessage(); promoting input serial', { codecMessageId, serial });
|
|
1031
|
+
entry.node.serial = serial;
|
|
1032
|
+
this._promoteSerial(entry);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
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');
|
|
1040
|
+
|
|
1041
|
+
// An input node owns no agent outputs; the event still fires (empty
|
|
1042
|
+
// outputs) so consumers observe the projection change. It has no run-id —
|
|
1043
|
+
// the causal routing key is the input's own codec-message-id.
|
|
1044
|
+
this._emitter.emit('output', {
|
|
1045
|
+
runId: undefined,
|
|
1046
|
+
inputCodecMessageId: codecMessageId,
|
|
1047
|
+
codecMessageId,
|
|
427
1048
|
serial,
|
|
428
|
-
|
|
1049
|
+
events: [],
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
429
1052
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1053
|
+
/**
|
|
1054
|
+
* Apply a reply-run wire (assistant output, continuation tool-resolution, or
|
|
1055
|
+
* a fresh run keyed by the agent-minted run-id): create or reconcile the run
|
|
1056
|
+
* node, fold its events, maintain the codec-message-id and reply→input
|
|
1057
|
+
* indices, and emit the `output` event. Derives the codec-message-id,
|
|
1058
|
+
* triggering-input id, fold list, and outputs from `events`/`headers`,
|
|
1059
|
+
* mirroring `applyMessage`.
|
|
1060
|
+
* @param wireRunId - The run-id from the inbound wire (the node's primary key).
|
|
1061
|
+
* @param events - The decoded inputs and outputs from the wire.
|
|
1062
|
+
* @param events.inputs - Client-published events (`ai-input` wire).
|
|
1063
|
+
* @param events.outputs - Agent-published events (`ai-output` wire).
|
|
1064
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
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.
|
|
1068
|
+
*/
|
|
1069
|
+
private _applyRunMessage(
|
|
1070
|
+
wireRunId: string,
|
|
1071
|
+
events: { inputs: TInput[]; outputs: TOutput[] },
|
|
1072
|
+
headers: Record<string, string>,
|
|
1073
|
+
serial: string | undefined,
|
|
1074
|
+
timestamp: number | undefined,
|
|
1075
|
+
version: string | undefined,
|
|
1076
|
+
): void {
|
|
1077
|
+
const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
|
|
1078
|
+
// The triggering input's codec-message-id (the agent's echo), surfaced on
|
|
1079
|
+
// the `output` event as the stream's causal routing key.
|
|
1080
|
+
const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
|
|
1081
|
+
// Fold inputs first, then outputs, preserving wire order.
|
|
1082
|
+
const all: CodecEvent<TInput, TOutput>[] = toCodecEvents(events);
|
|
1083
|
+
const outputs = events.outputs;
|
|
1084
|
+
|
|
1085
|
+
let run = this._nodeIndex.get(wireRunId);
|
|
1086
|
+
|
|
1087
|
+
// Reconcile an optimistic insert with its serial-bearing echo by
|
|
1088
|
+
// codec-message-id rather than the wire run-id — covers assistant content
|
|
1089
|
+
// that pins a codec-message-id before its run-id is indexed.
|
|
1090
|
+
if (!run && codecMessageId !== undefined) {
|
|
1091
|
+
const indexedKey = this._codecMessageIdToNodeKey.get(codecMessageId);
|
|
1092
|
+
const indexed = indexedKey === undefined ? undefined : this._nodeIndex.get(indexedKey);
|
|
1093
|
+
if (indexed?.node.kind === 'run' && indexed.node.startSerial === undefined) run = indexed;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (!run) {
|
|
1097
|
+
run = this._createRunFromHeaders(wireRunId, headers, serial);
|
|
1098
|
+
this._insertNode(wireRunId, run, run.node.parentCodecMessageId);
|
|
1099
|
+
this._indexReplyRun(run.node, wireRunId);
|
|
1100
|
+
this._logger.debug('Tree.applyMessage(); created new Run', { runId: wireRunId });
|
|
1101
|
+
} else if (serial && run.node.kind === 'run' && !run.node.startSerial) {
|
|
1102
|
+
// Promote optimistic startSerial when the relay/echo arrives.
|
|
1103
|
+
this._logger.debug('Tree.applyMessage(); promoting startSerial', { runId: wireRunId, serial });
|
|
1104
|
+
run.node.startSerial = serial;
|
|
1105
|
+
this._promoteSerial(run);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Index the codec-message-id against the node that actually owns it.
|
|
1109
|
+
const ownerKey = nodeKey(run.node);
|
|
1110
|
+
if (codecMessageId) this._codecMessageIdToNodeKey.set(codecMessageId, ownerKey);
|
|
1111
|
+
|
|
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');
|
|
1119
|
+
|
|
1120
|
+
this._emitter.emit('output', { runId: ownerKey, inputCodecMessageId, codecMessageId, serial, events: outputs });
|
|
436
1121
|
}
|
|
437
1122
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
1123
|
+
/**
|
|
1124
|
+
* Record a reply run against its input-node parent (the reverse edge powering
|
|
1125
|
+
* `getReplyRuns` and regenerate sibling grouping). A reply run's
|
|
1126
|
+
* `parentCodecMessageId` is its input node's codec-message-id (the master
|
|
1127
|
+
* invariant), so no resolution is needed.
|
|
1128
|
+
* @param node - The reply run node.
|
|
1129
|
+
* @param runId - The run's id.
|
|
1130
|
+
*/
|
|
1131
|
+
private _indexReplyRun(node: ConversationNode<TProjection>, runId: string): void {
|
|
1132
|
+
if (node.parentCodecMessageId === undefined) return;
|
|
1133
|
+
addToSetMap(this._replyRunsByInput, node.parentCodecMessageId, runId);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
applyRunLifecycle(event: RunLifecycleEvent): void {
|
|
1137
|
+
this._logger.trace('DefaultTree.applyRunLifecycle();', { type: event.type, runId: event.runId });
|
|
1138
|
+
// Structural channel: emit `update` only when the lifecycle event changes
|
|
1139
|
+
// the tree shape. Only run-start can do that (a new Run, startSerial
|
|
1140
|
+
// promotion, or structural-metadata backfill); suspend/resume/end mutate
|
|
1141
|
+
// status/endSerial on an existing node — content, not structure — so the
|
|
1142
|
+
// conditional naturally never fires for them.
|
|
1143
|
+
const structuralBefore = this._structuralVersion;
|
|
1144
|
+
switch (event.type) {
|
|
1145
|
+
case 'start': {
|
|
1146
|
+
this._applyRunStart(event);
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
case 'suspend': {
|
|
1150
|
+
this._applyRunSuspend(event);
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
case 'resume': {
|
|
1154
|
+
this._applyRunResume(event);
|
|
1155
|
+
break;
|
|
1156
|
+
}
|
|
1157
|
+
case 'end': {
|
|
1158
|
+
this._applyRunEnd(event);
|
|
1159
|
+
break;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
this._emitter.emit('run', event);
|
|
1163
|
+
if (this._structuralVersion !== structuralBefore) this._emitter.emit('update');
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Apply a run-start lifecycle event's structural effect: create the reply
|
|
1168
|
+
* run if it doesn't exist yet, or backfill an optimistic / wire-created
|
|
1169
|
+
* node's structure and metadata from the canonical run-start. Mutates
|
|
1170
|
+
* `_structuralVersion` when the tree shape changes; the caller owns the
|
|
1171
|
+
* `run`/`update` emits.
|
|
1172
|
+
* @param event - The run-start lifecycle event.
|
|
1173
|
+
*/
|
|
1174
|
+
private _applyRunStart(event: RunLifecycleEvent & { type: 'start' }): void {
|
|
1175
|
+
const existing = this._nodeIndex.get(event.runId);
|
|
1176
|
+
if (existing?.node.kind === 'run') {
|
|
1177
|
+
const node = existing.node;
|
|
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' };
|
|
1184
|
+
}
|
|
1185
|
+
if (event.serial && !node.startSerial) {
|
|
1186
|
+
node.startSerial = event.serial;
|
|
1187
|
+
this._promoteSerial(existing);
|
|
1188
|
+
}
|
|
1189
|
+
// Backfill structural metadata if the Run was created from an
|
|
1190
|
+
// assistant wire that arrived before run-start (history pagination
|
|
1191
|
+
// boundary or out-of-order delivery). The run-start lifecycle event is
|
|
1192
|
+
// the canonical source for parent/forkOf/regenerates; only fill in
|
|
1193
|
+
// fields the wire didn't already populate. A run-start is always a
|
|
1194
|
+
// first start (continuations re-enter via `ai-run-resume`, which
|
|
1195
|
+
// carries no structural metadata), so it is unconditionally
|
|
1196
|
+
// authoritative here. `parent` is the run's STRUCTURAL parent (its
|
|
1197
|
+
// input node) — reachability and the reply→input edge read it.
|
|
1198
|
+
if (node.parentCodecMessageId === undefined && event.parent !== undefined) {
|
|
1199
|
+
node.parentCodecMessageId = event.parent;
|
|
1200
|
+
this._removeFromParentIndex(undefined, event.runId);
|
|
1201
|
+
this._addToParentIndex(node.parentCodecMessageId, event.runId);
|
|
1202
|
+
this._indexReplyRun(node, event.runId);
|
|
1203
|
+
this._structuralVersion++;
|
|
1204
|
+
}
|
|
1205
|
+
if (node.forkOf === undefined && event.forkOf !== undefined) {
|
|
1206
|
+
const forkOfKey = this._codecMessageIdToNodeKey.get(event.forkOf);
|
|
1207
|
+
if (forkOfKey !== undefined && forkOfKey !== event.runId) {
|
|
1208
|
+
node.forkOf = forkOfKey;
|
|
1209
|
+
this._structuralVersion++;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (node.regeneratesCodecMessageId === undefined && event.regenerates !== undefined) {
|
|
1213
|
+
node.regeneratesCodecMessageId = event.regenerates;
|
|
1214
|
+
this._structuralVersion++;
|
|
1215
|
+
}
|
|
1216
|
+
// Adopt the agent-minted invocation-id onto the optimistic node. The
|
|
1217
|
+
// agent mints it, so a node created from an optimistic insert (or an
|
|
1218
|
+
// assistant wire that arrived before run-start) carries an empty id
|
|
1219
|
+
// until the agent's run-start delivers it. Metadata, not structure —
|
|
1220
|
+
// consumers re-read it on the `run` emit, so no structural-version
|
|
1221
|
+
// bump.
|
|
1222
|
+
if (node.invocationId === '' && event.invocationId !== '') {
|
|
1223
|
+
node.invocationId = event.invocationId;
|
|
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);
|
|
1232
|
+
} else if (!existing) {
|
|
1233
|
+
const run = this._createRunFromLifecycle(event);
|
|
1234
|
+
this._insertNode(event.runId, run, run.node.parentCodecMessageId);
|
|
1235
|
+
this._indexReplyRun(run.node, event.runId);
|
|
1236
|
+
this._recordActivity(run, event.timestamp);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
441
1239
|
|
|
442
|
-
|
|
1240
|
+
/**
|
|
1241
|
+
* Apply a run-suspend lifecycle event: pause the run without ending it —
|
|
1242
|
+
* mark the node 'suspended' and record the serial it paused at, but keep the
|
|
1243
|
+
* Run live so a resume under the same runId resumes it. Status/endSerial are
|
|
1244
|
+
* content, not structure, so this never mutates `_structuralVersion`; the
|
|
1245
|
+
* caller owns the emits.
|
|
1246
|
+
* @param event - The run-suspend lifecycle event.
|
|
1247
|
+
*/
|
|
1248
|
+
private _applyRunSuspend(event: RunLifecycleEvent & { type: 'suspend' }): void {
|
|
1249
|
+
const run = this._nodeIndex.get(event.runId);
|
|
1250
|
+
if (run?.node.kind === 'run') {
|
|
1251
|
+
run.node.state = { status: 'suspended' };
|
|
1252
|
+
run.node.endSerial = event.serial;
|
|
1253
|
+
this._recordActivity(run, event.timestamp);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
443
1256
|
|
|
444
|
-
|
|
1257
|
+
/**
|
|
1258
|
+
* Apply a run-resume lifecycle event: re-enter an already-started run by
|
|
1259
|
+
* flipping a suspended run back to 'active'. Pure re-entry — it carries no
|
|
1260
|
+
* parent/forkOf and does not promote startSerial (the original run-start owns
|
|
1261
|
+
* the run's structure). Only a suspended run resumes: a no-op when the run
|
|
1262
|
+
* isn't known (e.g. a resume replayed from a newer history page before its
|
|
1263
|
+
* run-start) and a no-op for an already-active or terminal
|
|
1264
|
+
* (complete/cancelled/error) run — a stray resume must never resurrect a run
|
|
1265
|
+
* that has ended. The caller owns the emits.
|
|
1266
|
+
* @param event - The run-resume lifecycle event.
|
|
1267
|
+
*/
|
|
1268
|
+
private _applyRunResume(event: RunLifecycleEvent & { type: 'resume' }): void {
|
|
1269
|
+
const run = this._nodeIndex.get(event.runId);
|
|
1270
|
+
if (run?.node.kind === 'run' && run.node.state.status === 'suspended') {
|
|
1271
|
+
run.node.state = { status: 'active' };
|
|
1272
|
+
this._recordActivity(run, event.timestamp);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
445
1275
|
|
|
446
|
-
|
|
447
|
-
|
|
1276
|
+
/**
|
|
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.
|
|
1288
|
+
* @param event - The run-end lifecycle event.
|
|
1289
|
+
*/
|
|
1290
|
+
private _applyRunEnd(event: RunLifecycleEvent & { type: 'end' }): void {
|
|
1291
|
+
const run = this._nodeIndex.get(event.runId);
|
|
1292
|
+
if (run?.node.kind === 'run') {
|
|
1293
|
+
run.node.state = event.reason === 'error' ? { status: 'error', error: event.error } : { status: event.reason };
|
|
1294
|
+
run.node.endSerial = event.serial;
|
|
1295
|
+
this._recordActivity(run, event.timestamp);
|
|
1296
|
+
this._maybeQueueSweep(run);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
448
1299
|
|
|
449
|
-
|
|
450
|
-
this.
|
|
1300
|
+
delete(key: string): void {
|
|
1301
|
+
const entry = this._nodeIndex.get(key);
|
|
1302
|
+
if (!entry) return;
|
|
1303
|
+
|
|
1304
|
+
this._logger.debug('Tree.delete();', { key });
|
|
451
1305
|
|
|
452
|
-
|
|
453
|
-
this.
|
|
1306
|
+
this._removeFromParentIndex(entry.node.parentCodecMessageId, key);
|
|
1307
|
+
this._removeSortedNode(entry);
|
|
1308
|
+
this._nodeIndex.delete(key);
|
|
1309
|
+
// Drop the reply→input reverse edge.
|
|
1310
|
+
if (entry.node.kind === 'run' && entry.node.parentCodecMessageId !== undefined) {
|
|
1311
|
+
deleteFromSetMap(this._replyRunsByInput, entry.node.parentCodecMessageId, key);
|
|
1312
|
+
}
|
|
1313
|
+
// _codecMessageIdToNodeKey entries pointing at this node linger but are
|
|
1314
|
+
// harmless; they'll be overwritten if the node is re-created and remain
|
|
1315
|
+
// dangling otherwise. Cleanup not worth the index walk.
|
|
454
1316
|
|
|
455
|
-
// Children are NOT deleted — they become unreachable in flattenNodes()
|
|
456
|
-
// because their parent is no longer on the active path.
|
|
457
1317
|
this._structuralVersion++;
|
|
458
1318
|
this._emitter.emit('update');
|
|
459
1319
|
}
|
|
460
1320
|
|
|
461
1321
|
// -------------------------------------------------------------------------
|
|
462
|
-
//
|
|
1322
|
+
// Internal helpers
|
|
463
1323
|
// -------------------------------------------------------------------------
|
|
464
1324
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
1325
|
+
/**
|
|
1326
|
+
* Build a fresh RunNode from a wire message's headers. Used when an
|
|
1327
|
+
* inbound message arrives before any run-start event for its runId.
|
|
1328
|
+
* @param runId - The run-id from the inbound wire.
|
|
1329
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
1330
|
+
* @param serial - Ably channel serial; undefined for optimistic inserts.
|
|
1331
|
+
* @returns A newly-allocated internal run node ready for insertion.
|
|
1332
|
+
*/
|
|
1333
|
+
private _createRunFromHeaders(
|
|
1334
|
+
runId: string,
|
|
1335
|
+
headers: Record<string, string>,
|
|
1336
|
+
serial: string | undefined,
|
|
1337
|
+
): InternalNode<TInput, TOutput, TProjection> {
|
|
1338
|
+
const forkOfMsgId = headers[HEADER_FORK_OF];
|
|
1339
|
+
return this._buildRunNode({
|
|
1340
|
+
runId,
|
|
1341
|
+
parentCodecMessageId: headers[HEADER_PARENT],
|
|
1342
|
+
// forkOf is resolved to the fork target's node key (an input node's
|
|
1343
|
+
// codec-message-id, or a run's id) — the same space `_isSiblingOf` walks.
|
|
1344
|
+
forkOf: forkOfMsgId ? this._codecMessageIdToNodeKey.get(forkOfMsgId) : undefined,
|
|
1345
|
+
regeneratesCodecMessageId: headers[HEADER_MSG_REGENERATE],
|
|
1346
|
+
clientId: headers[HEADER_RUN_CLIENT_ID] ?? '',
|
|
1347
|
+
invocationId: headers[HEADER_INVOCATION_ID] ?? '',
|
|
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,
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
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
|
+
};
|
|
478
1378
|
}
|
|
479
1379
|
|
|
1380
|
+
/**
|
|
1381
|
+
* Allocate a RunNode from already-resolved fields. Shared by the
|
|
1382
|
+
* header-driven and lifecycle-driven run creators: both build the identical
|
|
1383
|
+
* RunNode literal and stamp an insert sequence.
|
|
1384
|
+
* @param params - The resolved run fields.
|
|
1385
|
+
* @param params.runId - The run's id (its primary key).
|
|
1386
|
+
* @param params.parentCodecMessageId - Structural parent codec-message-id, or undefined for a root.
|
|
1387
|
+
* @param params.forkOf - The resolved fork target's node key (already mapped through the codec-message-id index), or undefined.
|
|
1388
|
+
* @param params.regeneratesCodecMessageId - The codec-message-id this run regenerates, or undefined.
|
|
1389
|
+
* @param params.clientId - The publishing client's id.
|
|
1390
|
+
* @param params.invocationId - The agent invocation id.
|
|
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).
|
|
1393
|
+
* @returns A newly-allocated internal run node ready for insertion.
|
|
1394
|
+
*/
|
|
1395
|
+
private _buildRunNode(params: {
|
|
1396
|
+
runId: string;
|
|
1397
|
+
parentCodecMessageId: string | undefined;
|
|
1398
|
+
forkOf: string | undefined;
|
|
1399
|
+
regeneratesCodecMessageId: string | undefined;
|
|
1400
|
+
clientId: string;
|
|
1401
|
+
invocationId: string;
|
|
1402
|
+
startSerial: string | undefined;
|
|
1403
|
+
runStartSeen: boolean;
|
|
1404
|
+
}): InternalNode<TInput, TOutput, TProjection> {
|
|
1405
|
+
const node: RunNode<TProjection> = {
|
|
1406
|
+
kind: 'run',
|
|
1407
|
+
runId: params.runId,
|
|
1408
|
+
parentCodecMessageId: params.parentCodecMessageId,
|
|
1409
|
+
forkOf: params.forkOf,
|
|
1410
|
+
regeneratesCodecMessageId: params.regeneratesCodecMessageId,
|
|
1411
|
+
clientId: params.clientId,
|
|
1412
|
+
invocationId: params.invocationId,
|
|
1413
|
+
state: { status: 'active' },
|
|
1414
|
+
projection: this._codec.init(),
|
|
1415
|
+
startSerial: params.startSerial,
|
|
1416
|
+
endSerial: undefined,
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
return this._wrapNode(node, params.runStartSeen);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Build a fresh InputNode from a run-less user input wire's headers.
|
|
1424
|
+
* @param codecMessageId - The input's codec-message-id (its primary key).
|
|
1425
|
+
* @param headers - Transport headers from the inbound Ably message.
|
|
1426
|
+
* @param serial - Ably channel serial; undefined for optimistic inserts.
|
|
1427
|
+
* @returns A newly-allocated internal input node ready for insertion.
|
|
1428
|
+
*/
|
|
1429
|
+
private _createInputNodeFromHeaders(
|
|
1430
|
+
codecMessageId: string,
|
|
1431
|
+
headers: Record<string, string>,
|
|
1432
|
+
serial: string | undefined,
|
|
1433
|
+
): InternalNode<TInput, TOutput, TProjection> {
|
|
1434
|
+
const forkOfMsgId = headers[HEADER_FORK_OF];
|
|
1435
|
+
const node: InputNode<TProjection> = {
|
|
1436
|
+
kind: 'input',
|
|
1437
|
+
codecMessageId,
|
|
1438
|
+
parentCodecMessageId: headers[HEADER_PARENT],
|
|
1439
|
+
// An edit's fork-of names the original prompt's codec-message-id, which
|
|
1440
|
+
// IS that input node's key — no resolution needed.
|
|
1441
|
+
forkOf: forkOfMsgId,
|
|
1442
|
+
projection: this._codec.init(),
|
|
1443
|
+
serial,
|
|
1444
|
+
};
|
|
1445
|
+
return this._wrapNode(node);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Build a fresh RunNode from a run-start lifecycle event. Used when a
|
|
1450
|
+
* run-start event arrives before any message for its runId.
|
|
1451
|
+
* @param event - The run-start lifecycle event from the agent, including
|
|
1452
|
+
* its channel serial.
|
|
1453
|
+
* @returns A newly-allocated internal run node ready for insertion.
|
|
1454
|
+
*/
|
|
1455
|
+
private _createRunFromLifecycle(
|
|
1456
|
+
event: RunLifecycleEvent & { type: 'start' },
|
|
1457
|
+
): InternalNode<TInput, TOutput, TProjection> {
|
|
1458
|
+
const forkOfMsgId = event.forkOf;
|
|
1459
|
+
return this._buildRunNode({
|
|
1460
|
+
runId: event.runId,
|
|
1461
|
+
parentCodecMessageId: event.parent,
|
|
1462
|
+
forkOf: forkOfMsgId ? this._codecMessageIdToNodeKey.get(forkOfMsgId) : undefined,
|
|
1463
|
+
regeneratesCodecMessageId: event.regenerates,
|
|
1464
|
+
clientId: event.clientId,
|
|
1465
|
+
invocationId: event.invocationId,
|
|
1466
|
+
startSerial: event.serial,
|
|
1467
|
+
// Created from the run-start itself — the serial floor is observed.
|
|
1468
|
+
runStartSeen: true,
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// -------------------------------------------------------------------------
|
|
1473
|
+
// Events
|
|
1474
|
+
// -------------------------------------------------------------------------
|
|
1475
|
+
|
|
480
1476
|
// Spec: AIT-CT8b, AIT-CT8e
|
|
481
1477
|
on(event: 'update', handler: () => void): () => void;
|
|
482
1478
|
on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
|
|
483
|
-
on(event: '
|
|
1479
|
+
on(event: 'run', handler: (event: RunLifecycleEvent) => void): () => void;
|
|
1480
|
+
on(event: 'output', handler: (event: OutputEvent<TOutput>) => void): () => void;
|
|
484
1481
|
on(
|
|
485
|
-
event: 'update' | 'ably-message' | '
|
|
486
|
-
handler:
|
|
1482
|
+
event: 'update' | 'ably-message' | 'run' | 'output',
|
|
1483
|
+
handler:
|
|
1484
|
+
| (() => void)
|
|
1485
|
+
| ((msg: Ably.InboundMessage) => void)
|
|
1486
|
+
| ((event: RunLifecycleEvent) => void)
|
|
1487
|
+
| ((event: OutputEvent<TOutput>) => void),
|
|
487
1488
|
): () => void {
|
|
488
1489
|
// CAST: overload signatures enforce correct handler types per event name.
|
|
489
|
-
const cb = handler as (arg: TreeEventsMap[keyof TreeEventsMap]) => void;
|
|
1490
|
+
const cb = handler as (arg: TreeEventsMap<TOutput>[keyof TreeEventsMap<TOutput>]) => void;
|
|
490
1491
|
this._emitter.on(event, cb);
|
|
491
1492
|
return () => {
|
|
492
1493
|
this._emitter.off(event, cb);
|
|
493
1494
|
};
|
|
494
1495
|
}
|
|
495
1496
|
|
|
496
|
-
// -------------------------------------------------------------------------
|
|
497
|
-
// Internal methods (called by the transport, not part of Tree interface)
|
|
498
|
-
// -------------------------------------------------------------------------
|
|
499
|
-
|
|
500
1497
|
/**
|
|
501
|
-
* 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.
|
|
502
1501
|
* @param msg - The raw Ably message to emit.
|
|
503
1502
|
*/
|
|
504
1503
|
emitAblyMessage(msg: Ably.InboundMessage): void {
|
|
505
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
|
+
}
|
|
506
1510
|
this._emitter.emit('ably-message', msg);
|
|
507
1511
|
}
|
|
508
1512
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
* @param event - The turn lifecycle event to emit.
|
|
512
|
-
*/
|
|
513
|
-
emitTurn(event: TurnLifecycleEvent): void {
|
|
514
|
-
this._logger.trace('DefaultTree.emitTurn();', { turnId: event.turnId });
|
|
515
|
-
this._emitter.emit('turn', event);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Register an active turn.
|
|
520
|
-
* @param turnId - The turn's unique identifier.
|
|
521
|
-
* @param clientId - The client that owns the turn.
|
|
522
|
-
*/
|
|
523
|
-
trackTurn(turnId: string, clientId: string): void {
|
|
524
|
-
this._logger.trace('DefaultTree.trackTurn();', { turnId, clientId });
|
|
525
|
-
this._turnClientIds.set(turnId, clientId);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* Unregister an active turn.
|
|
530
|
-
* @param turnId - The turn to untrack.
|
|
531
|
-
*/
|
|
532
|
-
untrackTurn(turnId: string): void {
|
|
533
|
-
this._logger.trace('DefaultTree.untrackTurn();', { turnId });
|
|
534
|
-
this._turnClientIds.delete(turnId);
|
|
1513
|
+
findAblyMessageByEventId(eventId: string): Ably.InboundMessage | undefined {
|
|
1514
|
+
return this._eventIdIndex.get(eventId);
|
|
535
1515
|
}
|
|
536
1516
|
}
|
|
537
1517
|
|
|
@@ -540,10 +1520,15 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
|
|
|
540
1520
|
// ---------------------------------------------------------------------------
|
|
541
1521
|
|
|
542
1522
|
/**
|
|
543
|
-
* Create a Tree that materializes branching history from a flat
|
|
1523
|
+
* Create a Tree that materializes branching conversation history from a flat
|
|
1524
|
+
* oplog of Ably messages as a two-node-per-turn forest (input node + reply run).
|
|
1525
|
+
* @param codec - Codec used to fold inbound events into per-Run projections.
|
|
544
1526
|
* @param logger - Logger for diagnostic output.
|
|
545
|
-
* @returns A new {@link DefaultTree} instance. The
|
|
546
|
-
* directly for internal methods (
|
|
547
|
-
* Public consumers see the narrower {@link Tree} interface.
|
|
1527
|
+
* @returns A new {@link DefaultTree} instance. The session uses DefaultTree
|
|
1528
|
+
* directly for internal methods (applyMessage, applyRunLifecycle,
|
|
1529
|
+
* emitAblyMessage). Public consumers see the narrower {@link Tree} interface.
|
|
548
1530
|
*/
|
|
549
|
-
export const createTree = <
|
|
1531
|
+
export const createTree = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection>(
|
|
1532
|
+
codec: Reducer<CodecEvent<TInput, TOutput>, TProjection>,
|
|
1533
|
+
logger: Logger,
|
|
1534
|
+
): DefaultTree<TInput, TOutput, TProjection> => new DefaultTree<TInput, TOutput, TProjection>(codec, logger);
|