@mono-agent/session-web 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +92 -0
  2. package/dist/aggregator.d.ts +123 -0
  3. package/dist/aggregator.d.ts.map +1 -0
  4. package/dist/aggregator.js +1155 -0
  5. package/dist/aggregator.js.map +1 -0
  6. package/dist/discovery.d.ts +48 -0
  7. package/dist/discovery.d.ts.map +1 -0
  8. package/dist/discovery.js +177 -0
  9. package/dist/discovery.js.map +1 -0
  10. package/dist/history.d.ts +47 -0
  11. package/dist/history.d.ts.map +1 -0
  12. package/dist/history.js +149 -0
  13. package/dist/history.js.map +1 -0
  14. package/dist/index.d.ts +17 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +13 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/live-client.d.ts +50 -0
  19. package/dist/live-client.d.ts.map +1 -0
  20. package/dist/live-client.js +159 -0
  21. package/dist/live-client.js.map +1 -0
  22. package/dist/server.d.ts +30 -0
  23. package/dist/server.d.ts.map +1 -0
  24. package/dist/server.js +359 -0
  25. package/dist/server.js.map +1 -0
  26. package/dist/session-model.d.ts +54 -0
  27. package/dist/session-model.d.ts.map +1 -0
  28. package/dist/session-model.js +10 -0
  29. package/dist/session-model.js.map +1 -0
  30. package/package.json +39 -0
  31. package/webapp/dist/apple-touch-icon.png +0 -0
  32. package/webapp/dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  33. package/webapp/dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  34. package/webapp/dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  35. package/webapp/dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  36. package/webapp/dist/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  37. package/webapp/dist/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  38. package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  39. package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  40. package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  41. package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  42. package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  43. package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  44. package/webapp/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  45. package/webapp/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  46. package/webapp/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  47. package/webapp/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  48. package/webapp/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  49. package/webapp/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  50. package/webapp/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  51. package/webapp/dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  52. package/webapp/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  53. package/webapp/dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  54. package/webapp/dist/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  55. package/webapp/dist/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  56. package/webapp/dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  57. package/webapp/dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  58. package/webapp/dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  59. package/webapp/dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  60. package/webapp/dist/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  61. package/webapp/dist/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  62. package/webapp/dist/assets/index-ByXpmZqx.js +138 -0
  63. package/webapp/dist/assets/index-D-V-lq0B.css +1 -0
  64. package/webapp/dist/assets/newsreader-latin-400-italic-CEihAR-f.woff2 +0 -0
  65. package/webapp/dist/assets/newsreader-latin-400-italic-CNZoH1hn.woff +0 -0
  66. package/webapp/dist/assets/newsreader-latin-400-normal-BFBkh4jY.woff2 +0 -0
  67. package/webapp/dist/assets/newsreader-latin-400-normal-gRTjlS2D.woff +0 -0
  68. package/webapp/dist/assets/newsreader-latin-500-italic-Bsgye1qA.woff2 +0 -0
  69. package/webapp/dist/assets/newsreader-latin-500-italic-m-xI0GeS.woff +0 -0
  70. package/webapp/dist/assets/newsreader-latin-500-normal-B66TYsaK.woff2 +0 -0
  71. package/webapp/dist/assets/newsreader-latin-500-normal-DFwuUcdu.woff +0 -0
  72. package/webapp/dist/assets/newsreader-latin-ext-400-italic-BMTE_bNQ.woff2 +0 -0
  73. package/webapp/dist/assets/newsreader-latin-ext-400-italic-qdgKLcPG.woff +0 -0
  74. package/webapp/dist/assets/newsreader-latin-ext-400-normal-DYA1XoQK.woff +0 -0
  75. package/webapp/dist/assets/newsreader-latin-ext-400-normal-svq1FPys.woff2 +0 -0
  76. package/webapp/dist/assets/newsreader-latin-ext-500-italic-B83LgwQp.woff2 +0 -0
  77. package/webapp/dist/assets/newsreader-latin-ext-500-italic-Bz3NsAdg.woff +0 -0
  78. package/webapp/dist/assets/newsreader-latin-ext-500-normal-BNHmvKvI.woff2 +0 -0
  79. package/webapp/dist/assets/newsreader-latin-ext-500-normal-CZruMFou.woff +0 -0
  80. package/webapp/dist/assets/newsreader-vietnamese-400-italic-QbB8kb5s.woff +0 -0
  81. package/webapp/dist/assets/newsreader-vietnamese-400-italic-bZegYFuM.woff2 +0 -0
  82. package/webapp/dist/assets/newsreader-vietnamese-400-normal-BekUZro8.woff +0 -0
  83. package/webapp/dist/assets/newsreader-vietnamese-400-normal-DdKr49mV.woff2 +0 -0
  84. package/webapp/dist/assets/newsreader-vietnamese-500-italic-BsTGSz8L.woff +0 -0
  85. package/webapp/dist/assets/newsreader-vietnamese-500-italic-D26NjlyL.woff2 +0 -0
  86. package/webapp/dist/assets/newsreader-vietnamese-500-normal-BEAbKU8A.woff +0 -0
  87. package/webapp/dist/assets/newsreader-vietnamese-500-normal-CL6a8tp2.woff2 +0 -0
  88. package/webapp/dist/assets/space-grotesk-latin-400-normal-BnQMeOim.woff +0 -0
  89. package/webapp/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
  90. package/webapp/dist/assets/space-grotesk-latin-500-normal-CNSSEhBt.woff +0 -0
  91. package/webapp/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
  92. package/webapp/dist/assets/space-grotesk-latin-600-normal-BflQw4A9.woff +0 -0
  93. package/webapp/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
  94. package/webapp/dist/assets/space-grotesk-latin-700-normal-CwsQ-cCU.woff +0 -0
  95. package/webapp/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
  96. package/webapp/dist/assets/space-grotesk-latin-ext-400-normal-CfP_5XZW.woff2 +0 -0
  97. package/webapp/dist/assets/space-grotesk-latin-ext-400-normal-DRPE3kg4.woff +0 -0
  98. package/webapp/dist/assets/space-grotesk-latin-ext-500-normal-3dgZTiw9.woff +0 -0
  99. package/webapp/dist/assets/space-grotesk-latin-ext-500-normal-DUe3BAxM.woff2 +0 -0
  100. package/webapp/dist/assets/space-grotesk-latin-ext-600-normal-DxxdqCpr.woff2 +0 -0
  101. package/webapp/dist/assets/space-grotesk-latin-ext-600-normal-VcznFIpX.woff +0 -0
  102. package/webapp/dist/assets/space-grotesk-latin-ext-700-normal-BQnZhY3m.woff2 +0 -0
  103. package/webapp/dist/assets/space-grotesk-latin-ext-700-normal-HVCqSBdx.woff +0 -0
  104. package/webapp/dist/assets/space-grotesk-vietnamese-400-normal-B7xT_GF5.woff2 +0 -0
  105. package/webapp/dist/assets/space-grotesk-vietnamese-400-normal-BIWiOVfw.woff +0 -0
  106. package/webapp/dist/assets/space-grotesk-vietnamese-500-normal-BTqKIpxg.woff +0 -0
  107. package/webapp/dist/assets/space-grotesk-vietnamese-500-normal-BmEvtly_.woff2 +0 -0
  108. package/webapp/dist/assets/space-grotesk-vietnamese-600-normal-D6zpsUhD.woff +0 -0
  109. package/webapp/dist/assets/space-grotesk-vietnamese-600-normal-DUi7WF5p.woff2 +0 -0
  110. package/webapp/dist/assets/space-grotesk-vietnamese-700-normal-DMty7AZE.woff2 +0 -0
  111. package/webapp/dist/assets/space-grotesk-vietnamese-700-normal-Duxec5Rn.woff +0 -0
  112. package/webapp/dist/favicon.ico +0 -0
  113. package/webapp/dist/icon-192.png +0 -0
  114. package/webapp/dist/icon-512.png +0 -0
  115. package/webapp/dist/icon.svg +31 -0
  116. package/webapp/dist/index.html +25 -0
  117. package/webapp/dist/manifest.webmanifest +1 -0
  118. package/webapp/dist/registerSW.js +1 -0
  119. package/webapp/dist/sw.js +1 -0
  120. package/webapp/dist/workbox-9c191d2f.js +1 -0
@@ -0,0 +1,1155 @@
1
+ /**
2
+ * The session store + orchestrator behind the web operator surface. It folds two
3
+ * sources into one per-instance session model, keyed by `sourceId + runId` at
4
+ * the browser boundary and by `runId` inside each instance state (the finished
5
+ * on-disk artifact is authoritative):
6
+ *
7
+ * - **Recorded history**: seeded from each instance's artifact dir on discovery,
8
+ * then refreshed by watching that dir for changed `*.summary.json` files.
9
+ * - **Live sub-runs**: streamed from each running instance's operator-adapter live SSE
10
+ * endpoint (over HTTP) and folded frame-by-frame into a provisional session
11
+ * that firms up when the run finishes.
12
+ *
13
+ * Instance membership itself is reconciled from the trace-source registry — both
14
+ * `fs.watch` (fast, best-effort) and a periodic timer (the reliable source of
15
+ * truth). All state changes fan out to browser subscribers as
16
+ * {@link BrowserStreamFrame}s.
17
+ */
18
+ import { watch } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { mapRunToSession } from "@mono-agent/observability";
21
+ import { discoverWebInstances, resolveLiveApiKey } from "./discovery.js";
22
+ import { listInstanceSessionSummaries, listInstanceSessionSummaryPage, projectStaleRunningSession, readInstanceSession, readInstanceSessionSummaryByFileName, } from "./history.js";
23
+ import { connectLiveStream } from "./live-client.js";
24
+ /** On-disk recorded-run summary suffix (observability's `SUMMARY_SUFFIX`, not exported). */
25
+ const SUMMARY_SUFFIX = ".summary.json";
26
+ const MEMORY_ARTIFACT_NAMESPACE = "memory";
27
+ const MAX_SUPPRESSED_MEMORY_LIVE_RUNS = 512;
28
+ const RUN_SUMMARY_STATUSES = new Set([
29
+ "running",
30
+ "succeeded",
31
+ "failed",
32
+ "cancelled",
33
+ "interrupted",
34
+ ]);
35
+ export class SessionAggregator {
36
+ registryDirs;
37
+ maxRunsPerInstance;
38
+ staleAfterMs;
39
+ env;
40
+ logger;
41
+ reconcileIntervalMs;
42
+ registryDebounceMs;
43
+ artifactDebounceMs;
44
+ liveFoldDebounceMs;
45
+ instancesDebounceMs;
46
+ fetchImpl;
47
+ includeMemory;
48
+ clock;
49
+ states = new Map();
50
+ subscribers = new Set();
51
+ registryWatchers = [];
52
+ reconcileTimer;
53
+ reconcileDebounceTimer;
54
+ instancesEmitTimer;
55
+ lifecycleGeneration = 0;
56
+ stopped = false;
57
+ constructor(options) {
58
+ this.registryDirs = options.registryDirs;
59
+ this.maxRunsPerInstance = options.maxRunsPerInstance;
60
+ this.staleAfterMs = options.staleAfterMs;
61
+ this.env = options.env;
62
+ this.logger = options.logger;
63
+ this.reconcileIntervalMs = options.reconcileIntervalMs ?? 5000;
64
+ this.registryDebounceMs = options.registryDebounceMs ?? 300;
65
+ this.artifactDebounceMs = options.artifactDebounceMs ?? 300;
66
+ this.liveFoldDebounceMs = options.liveFoldDebounceMs ?? 150;
67
+ this.instancesDebounceMs = options.instancesDebounceMs ?? 100;
68
+ this.fetchImpl = options.fetchImpl;
69
+ this.includeMemory = options.includeMemory === true;
70
+ this.clock = options.clock ?? (() => Date.now());
71
+ }
72
+ /** Discover instances, seed history, open live connections, and start watching. */
73
+ async start() {
74
+ this.stopped = false;
75
+ const generation = ++this.lifecycleGeneration;
76
+ const discovered = await this.discover();
77
+ if (!this.isActive(generation)) {
78
+ return;
79
+ }
80
+ for (const instance of discovered) {
81
+ await this.addInstance(instance, { emitSessions: false }, generation);
82
+ if (!this.isActive(generation)) {
83
+ return;
84
+ }
85
+ }
86
+ if (!this.isActive(generation)) {
87
+ return;
88
+ }
89
+ this.setupRegistryWatchers();
90
+ this.reconcileTimer = setInterval(() => {
91
+ void this.reconcile();
92
+ }, this.reconcileIntervalMs);
93
+ this.reconcileTimer.unref?.();
94
+ }
95
+ getInstances() {
96
+ return [...this.states.values()].map((state) => this.projectInstance(state));
97
+ }
98
+ /** Sessions for one instance (by sourceId) or all ("all"), newest-first by `startTs`. */
99
+ getSessions(filter) {
100
+ this.refreshStaleRunningSessions(filter, { emit: false });
101
+ return this.collectSessions(filter);
102
+ }
103
+ /** Step-less list projection for `/api/sessions` and browser SSE snapshots. */
104
+ getSessionSummaries(filter) {
105
+ this.refreshStaleRunningSessions(filter, { emit: false });
106
+ return this.collectSessions(filter).map((session) => toBrowserListSession(session));
107
+ }
108
+ async getSessionSummariesPage(filter, page) {
109
+ this.refreshStaleRunningSessions(filter, { emit: false });
110
+ const states = this.filteredStates(filter);
111
+ if (states.length === 0 || page.limit === 0) {
112
+ const total = await this.countPagedSessions(states, Math.max(1, page.offset + page.limit));
113
+ return { sessions: [], total, offset: page.offset, limit: page.limit, hasMore: total > page.offset };
114
+ }
115
+ const maxRuns = page.offset + page.limit;
116
+ const pages = await Promise.all(states.map(async (state) => {
117
+ const result = await listInstanceSessionSummaryPage(state.discovered, {
118
+ maxRuns,
119
+ includeMemory: this.includeMemory,
120
+ nowMs: this.now(),
121
+ });
122
+ return { state, result };
123
+ }));
124
+ const sessions = pages
125
+ .flatMap(({ state, result }) => result.summaries.map((entry) => {
126
+ const cached = state.sessions.get(entry.session.id);
127
+ return cached === undefined
128
+ ? entry.session
129
+ : projectStaleRunningSession(mergeSessionPreservingVisibleDetail(cached, entry.session), this.now());
130
+ }))
131
+ .sort((left, right) => startMs(right.startTs) - startMs(left.startTs));
132
+ const total = pages.reduce((sum, pageResult) => sum + pageResult.result.total, 0);
133
+ return {
134
+ sessions: sessions.slice(page.offset, page.offset + page.limit).map((session) => toBrowserListSession(session)),
135
+ total,
136
+ offset: page.offset,
137
+ limit: page.limit,
138
+ hasMore: page.offset + page.limit < total,
139
+ };
140
+ }
141
+ collectSessions(filter) {
142
+ return this.filteredStates(filter)
143
+ .flatMap((state) => [...state.sessions.values()])
144
+ .sort((left, right) => startMs(right.startTs) - startMs(left.startTs));
145
+ }
146
+ /** One session, reading it from disk on demand when it isn't already held. */
147
+ async getSession(sourceId, runId) {
148
+ const state = this.states.get(sourceId);
149
+ if (state === undefined) {
150
+ return undefined;
151
+ }
152
+ this.refreshStaleRunningSessionsForState(state, { emit: false });
153
+ const existing = state.sessions.get(runId);
154
+ const diskSignature = state.diskRunSignatures.get(runId);
155
+ if (existing !== undefined &&
156
+ (state.detailLoaded.has(runId) ||
157
+ diskSignature === undefined ||
158
+ (diskSignature.status === "running" && state.liveFinished.has(runId)))) {
159
+ return existing;
160
+ }
161
+ let session;
162
+ try {
163
+ session = await readInstanceSession(state.discovered, runId, { includeMemory: this.includeMemory, nowMs: this.now() });
164
+ }
165
+ catch (error) {
166
+ this.logger?.warn?.("Failed to read session on demand.", { sourceId, runId, error: errorMessage(error) });
167
+ return existing;
168
+ }
169
+ if (session !== undefined && this.isCurrentState(state)) {
170
+ this.insertDiskSession(state, runId, session, { emit: false, detailLoaded: true });
171
+ return state.sessions.get(runId) ?? session;
172
+ }
173
+ return session ?? existing;
174
+ }
175
+ /** Subscribe to the browser frame fan-out. Returns an unsubscribe function. */
176
+ subscribe(listener) {
177
+ this.subscribers.add(listener);
178
+ return () => {
179
+ this.subscribers.delete(listener);
180
+ };
181
+ }
182
+ /** Close every live connection, watcher, and timer, and drop all subscribers. */
183
+ async stop() {
184
+ this.stopped = true;
185
+ this.lifecycleGeneration += 1;
186
+ if (this.reconcileTimer !== undefined) {
187
+ clearInterval(this.reconcileTimer);
188
+ this.reconcileTimer = undefined;
189
+ }
190
+ if (this.reconcileDebounceTimer !== undefined) {
191
+ clearTimeout(this.reconcileDebounceTimer);
192
+ this.reconcileDebounceTimer = undefined;
193
+ }
194
+ if (this.instancesEmitTimer !== undefined) {
195
+ clearTimeout(this.instancesEmitTimer);
196
+ this.instancesEmitTimer = undefined;
197
+ }
198
+ for (const watcher of this.registryWatchers) {
199
+ watcher.close();
200
+ }
201
+ this.registryWatchers.length = 0;
202
+ for (const state of this.states.values()) {
203
+ this.teardownState(state);
204
+ }
205
+ this.states.clear();
206
+ this.subscribers.clear();
207
+ }
208
+ async discover() {
209
+ return discoverWebInstances({
210
+ registryDirs: this.registryDirs,
211
+ ...(this.staleAfterMs === undefined ? {} : { staleAfterMs: this.staleAfterMs }),
212
+ ...(this.env === undefined ? {} : { env: this.env }),
213
+ });
214
+ }
215
+ async addInstance(discovered, options, generation = this.lifecycleGeneration) {
216
+ const state = {
217
+ discovered,
218
+ sessions: new Map(),
219
+ liveConnected: false,
220
+ live: undefined,
221
+ artifactWatcher: undefined,
222
+ memoryArtifactWatcher: undefined,
223
+ artifactTimers: new Map(),
224
+ diskRunSignatures: new Map(),
225
+ detailLoaded: new Set(),
226
+ maxRunsPerInstance: this.maxRunsPerInstance,
227
+ artifactFinished: new Set(),
228
+ liveFinished: new Set(),
229
+ suppressedMemoryLiveRuns: new BoundedStringFifoSet(MAX_SUPPRESSED_MEMORY_LIVE_RUNS),
230
+ };
231
+ this.states.set(discovered.instance.sourceId, state);
232
+ try {
233
+ await this.seedHistory(state, { emitSessions: false });
234
+ }
235
+ catch (error) {
236
+ this.logger?.warn?.("Failed to seed instance history.", {
237
+ sourceId: discovered.instance.sourceId,
238
+ error: errorMessage(error),
239
+ });
240
+ }
241
+ if (!this.isCurrentState(state, generation)) {
242
+ this.teardownState(state);
243
+ return;
244
+ }
245
+ this.watchArtifacts(state);
246
+ await this.connectLive(state, generation);
247
+ if (!this.isCurrentState(state, generation)) {
248
+ this.teardownState(state);
249
+ return;
250
+ }
251
+ if (options.emitSessions) {
252
+ for (const session of state.sessions.values()) {
253
+ this.emit({ t: "session_upsert", session });
254
+ }
255
+ }
256
+ this.scheduleInstancesEmit();
257
+ }
258
+ async connectLive(state, generation = this.lifecycleGeneration) {
259
+ const baseUrl = state.discovered.liveBaseUrl;
260
+ if (baseUrl === undefined) {
261
+ return;
262
+ }
263
+ const apiKey = await resolveLiveApiKey(state.discovered, this.env);
264
+ if (!this.isCurrentState(state, generation)) {
265
+ return;
266
+ }
267
+ const conn = connectLiveStream({
268
+ baseUrl,
269
+ ...(apiKey === undefined ? {} : { apiKey }),
270
+ onFrame: (frame) => this.handleLiveFrame(state, frame),
271
+ onStatus: (status) => this.handleLiveStatus(state, status),
272
+ ...(this.logger === undefined ? {} : { logger: this.logger }),
273
+ ...(this.fetchImpl === undefined ? {} : { fetchImpl: this.fetchImpl }),
274
+ });
275
+ if (!this.isCurrentState(state, generation)) {
276
+ conn.close();
277
+ return;
278
+ }
279
+ state.live = { conn, runs: new Map(), recomputeTimers: new Map() };
280
+ }
281
+ disconnectLive(state) {
282
+ const live = state.live;
283
+ if (live === undefined) {
284
+ return;
285
+ }
286
+ live.conn.close();
287
+ for (const timer of live.recomputeTimers.values()) {
288
+ clearTimeout(timer);
289
+ }
290
+ live.recomputeTimers.clear();
291
+ live.runs.clear();
292
+ state.live = undefined;
293
+ if (state.liveConnected) {
294
+ state.liveConnected = false;
295
+ this.scheduleInstancesEmit();
296
+ }
297
+ }
298
+ handleLiveStatus(state, status) {
299
+ if (state.live === undefined || !this.isCurrentState(state)) {
300
+ return;
301
+ }
302
+ const connected = status === "connected";
303
+ if (state.liveConnected !== connected) {
304
+ state.liveConnected = connected;
305
+ this.scheduleInstancesEmit();
306
+ }
307
+ }
308
+ handleLiveFrame(state, frame) {
309
+ const live = state.live;
310
+ if (live === undefined || !this.isCurrentState(state)) {
311
+ return;
312
+ }
313
+ if (frame.sourceId !== state.discovered.instance.sourceId) {
314
+ return;
315
+ }
316
+ if (!this.includeMemory && this.shouldDropMemoryLiveFrame(state, frame)) {
317
+ this.suppressMemoryLiveRun(state, frame.runId);
318
+ return;
319
+ }
320
+ // A run that already finished (live) or was materialized from its terminal
321
+ // disk artifact must not be resurrected by a late/replayed non-terminal frame.
322
+ if (state.liveFinished.has(frame.runId) || state.artifactFinished.has(frame.runId)) {
323
+ return;
324
+ }
325
+ if (frame.t === "run_started") {
326
+ // `run_started` may arrive *after* `event` frames (viewer connected mid-run,
327
+ // or the frame was reordered): keep any already-folded events, but adopt the
328
+ // authoritative conversationId/source/startedAt this frame carries.
329
+ const existing = live.runs.get(frame.runId);
330
+ if (existing === undefined) {
331
+ live.runs.set(frame.runId, { summary: runningSummaryFromStart(frame), events: [], seenEventKeys: new Set() });
332
+ }
333
+ else {
334
+ existing.summary = runningSummaryFromStart(frame);
335
+ }
336
+ this.scheduleLiveRecompute(state, frame.runId);
337
+ return;
338
+ }
339
+ if (frame.t === "event") {
340
+ let run = live.runs.get(frame.runId);
341
+ if (run === undefined) {
342
+ run = { summary: runningSummaryFromEvent(frame), events: [], seenEventKeys: new Set() };
343
+ live.runs.set(frame.runId, run);
344
+ }
345
+ const eventKey = liveEventKey(frame);
346
+ if (eventKey !== undefined) {
347
+ if (run.seenEventKeys.has(eventKey)) {
348
+ return;
349
+ }
350
+ run.seenEventKeys.add(eventKey);
351
+ }
352
+ run.events.push(coerceRuntimeEvent(frame.event));
353
+ this.scheduleLiveRecompute(state, frame.runId);
354
+ return;
355
+ }
356
+ // run_finished is terminal: fold once with the authoritative summary (cancelling
357
+ // any pending debounced recompute), then drop the run's live state so its
358
+ // `events[]` aren't retained for the lifetime of the process.
359
+ const run = live.runs.get(frame.runId);
360
+ let summary = coerceRunSummary(frame.summary) ?? run?.summary ?? finishedSummary(frame.runId, frame.status);
361
+ if (run !== undefined) {
362
+ // Preserve conversationId/startedAt/source a live placeholder already learned
363
+ // but a sparse `run_finished.summary` may omit.
364
+ summary = backfillRunningSummary(summary, run.summary);
365
+ }
366
+ const events = run?.events ?? [];
367
+ const pending = live.recomputeTimers.get(frame.runId);
368
+ if (pending !== undefined) {
369
+ clearTimeout(pending);
370
+ live.recomputeTimers.delete(frame.runId);
371
+ }
372
+ this.recomputeLiveRun(state, frame.runId, summary, events);
373
+ live.runs.delete(frame.runId);
374
+ // Mark terminal so a late/replayed frame can't rebuild it as "running".
375
+ state.liveFinished.add(frame.runId);
376
+ }
377
+ scheduleLiveRecompute(state, runId) {
378
+ const live = state.live;
379
+ if (live === undefined || live.recomputeTimers.has(runId)) {
380
+ return;
381
+ }
382
+ const timer = setTimeout(() => {
383
+ live.recomputeTimers.delete(runId);
384
+ const run = live.runs.get(runId);
385
+ if (run !== undefined) {
386
+ this.recomputeLiveRun(state, runId, run.summary, run.events);
387
+ }
388
+ }, this.liveFoldDebounceMs);
389
+ timer.unref?.();
390
+ live.recomputeTimers.set(runId, timer);
391
+ }
392
+ recomputeLiveRun(state, runId, summary, events) {
393
+ if (!this.isCurrentState(state)) {
394
+ return;
395
+ }
396
+ // The finished on-disk artifact is authoritative: once a run has been
397
+ // materialized from its terminal artifact, a late/mid-run live fold must not
398
+ // clobber it with a truncated view (a viewer that connected mid-run misses the
399
+ // early events). Skip the overwrite entirely for such runs.
400
+ if (state.artifactFinished.has(runId)) {
401
+ return;
402
+ }
403
+ const sourceId = state.discovered.instance.sourceId;
404
+ const session = { ...mapRunToSession(summary, events, this.mapOptions(state)), sourceId };
405
+ this.insertSession(state, runId, session, { emit: true });
406
+ }
407
+ insertDiskSession(state, runId, session, options) {
408
+ const existing = state.sessions.get(runId);
409
+ const nextSession = existing !== undefined && state.liveFinished.has(runId)
410
+ ? mergeSessionPreservingVisibleDetail(existing, session)
411
+ : session;
412
+ if (isTerminalSessionStatus(nextSession.status)) {
413
+ state.artifactFinished.add(runId);
414
+ }
415
+ if (options.detailLoaded || sessionHasVisibleDetail(nextSession)) {
416
+ state.detailLoaded.add(runId);
417
+ }
418
+ this.insertSession(state, runId, nextSession, options);
419
+ }
420
+ insertDiskSummarySession(state, entry, options) {
421
+ const runId = entry.session.id;
422
+ const previousSignature = state.diskRunSignatures.get(runId);
423
+ const existing = state.sessions.get(runId);
424
+ const nextSession = existing !== undefined ? mergeSessionPreservingVisibleDetail(existing, entry.session) : entry.session;
425
+ if (!diskRunSignatureEquals(previousSignature, entry.signature)) {
426
+ state.detailLoaded.delete(runId);
427
+ }
428
+ state.diskRunSignatures.set(runId, entry.signature);
429
+ if (isTerminalSessionStatus(entry.signature.status)) {
430
+ state.artifactFinished.add(runId);
431
+ }
432
+ this.insertSession(state, runId, nextSession, { emit: false });
433
+ if (options.emit) {
434
+ this.emit({ t: "session_upsert", session: toBrowserListSession(nextSession) });
435
+ }
436
+ }
437
+ insertSession(state, runId, session, options) {
438
+ const projected = projectStaleRunningSession(session, this.now());
439
+ state.sessions.set(runId, projected);
440
+ this.evictSessionOverflow(state, runId);
441
+ if (options.emit) {
442
+ this.emit({ t: "session_upsert", session: projected });
443
+ }
444
+ this.scheduleInstancesEmit();
445
+ }
446
+ /**
447
+ * Bound `state.sessions` to the per-instance cap, evicting the oldest *completed*
448
+ * sessions by `startTs`. A still-running session is never evicted (its live fold
449
+ * is in flight); each drop emits a `session_removed` so browser subscribers stay
450
+ * in sync with the server-side map.
451
+ */
452
+ evictSessionOverflow(state, protectedId) {
453
+ const cap = state.maxRunsPerInstance;
454
+ while (state.sessions.size > cap) {
455
+ let oldestId;
456
+ let oldestMs = Number.POSITIVE_INFINITY;
457
+ for (const [id, session] of state.sessions) {
458
+ // Never evict a still-running run, nor the run just written by the caller
459
+ // (evicting-then-upserting the same id would emit removed→upsert and
460
+ // diverge the browser from the server map).
461
+ if (session.status === "running" || id === protectedId) {
462
+ continue;
463
+ }
464
+ const ms = startMs(session.startTs);
465
+ if (ms < oldestMs) {
466
+ oldestMs = ms;
467
+ oldestId = id;
468
+ }
469
+ }
470
+ if (oldestId === undefined) {
471
+ // Every remaining session is running or protected — nothing safe to evict.
472
+ break;
473
+ }
474
+ state.sessions.delete(oldestId);
475
+ state.diskRunSignatures.delete(oldestId);
476
+ state.detailLoaded.delete(oldestId);
477
+ state.artifactFinished.delete(oldestId);
478
+ state.liveFinished.delete(oldestId);
479
+ state.suppressedMemoryLiveRuns.delete(oldestId);
480
+ this.emit({ t: "session_removed", sourceId: state.discovered.instance.sourceId, runId: oldestId });
481
+ }
482
+ }
483
+ watchArtifacts(state) {
484
+ const artifactDir = state.discovered.instance.artifactDir;
485
+ let watcher;
486
+ try {
487
+ watcher = watch(artifactDir, (_eventType, filename) => {
488
+ if (filename === null) {
489
+ return;
490
+ }
491
+ const name = typeof filename === "string" ? filename : String(filename);
492
+ if (name === MEMORY_ARTIFACT_NAMESPACE && this.includeMemory) {
493
+ this.watchMemoryArtifacts(state);
494
+ return;
495
+ }
496
+ if (!name.endsWith(SUMMARY_SUFFIX)) {
497
+ return;
498
+ }
499
+ if (name.length > SUMMARY_SUFFIX.length) {
500
+ this.scheduleArtifactReread(state, name);
501
+ }
502
+ });
503
+ }
504
+ catch (error) {
505
+ this.logger?.warn?.("Failed to watch artifact dir.", { artifactDir, error: errorMessage(error) });
506
+ return;
507
+ }
508
+ watcher.on("error", (error) => {
509
+ this.logger?.warn?.("Artifact watcher error.", { artifactDir, error: errorMessage(error) });
510
+ });
511
+ state.artifactWatcher = watcher;
512
+ if (this.includeMemory) {
513
+ this.watchMemoryArtifacts(state);
514
+ }
515
+ }
516
+ watchMemoryArtifacts(state) {
517
+ if (state.memoryArtifactWatcher !== undefined) {
518
+ return;
519
+ }
520
+ const artifactDir = join(state.discovered.instance.artifactDir, MEMORY_ARTIFACT_NAMESPACE);
521
+ let watcher;
522
+ try {
523
+ watcher = watch(artifactDir, (_eventType, filename) => {
524
+ if (filename === null) {
525
+ return;
526
+ }
527
+ const name = typeof filename === "string" ? filename : String(filename);
528
+ if (name.endsWith(SUMMARY_SUFFIX) && name.length > SUMMARY_SUFFIX.length) {
529
+ this.scheduleArtifactReread(state, `${MEMORY_ARTIFACT_NAMESPACE}/${name}`);
530
+ }
531
+ });
532
+ }
533
+ catch (error) {
534
+ if (isErrno(error, "ENOENT")) {
535
+ return;
536
+ }
537
+ this.logger?.warn?.("Failed to watch memory artifact dir.", { artifactDir, error: errorMessage(error) });
538
+ return;
539
+ }
540
+ watcher.on("error", (error) => {
541
+ this.logger?.warn?.("Memory artifact watcher error.", { artifactDir, error: errorMessage(error) });
542
+ });
543
+ state.memoryArtifactWatcher = watcher;
544
+ }
545
+ scheduleArtifactReread(state, summaryFileName) {
546
+ const existing = state.artifactTimers.get(summaryFileName);
547
+ if (existing !== undefined) {
548
+ clearTimeout(existing);
549
+ }
550
+ const timer = setTimeout(() => {
551
+ state.artifactTimers.delete(summaryFileName);
552
+ void this.rereadArtifactSummaryFile(state, summaryFileName);
553
+ }, this.artifactDebounceMs);
554
+ timer.unref?.();
555
+ state.artifactTimers.set(summaryFileName, timer);
556
+ }
557
+ async rereadArtifactSummaryFile(state, summaryFileName) {
558
+ if (!this.isCurrentState(state)) {
559
+ return;
560
+ }
561
+ let entry;
562
+ try {
563
+ entry = await readInstanceSessionSummaryByFileName(state.discovered, summaryFileName, {
564
+ maxRuns: state.maxRunsPerInstance,
565
+ includeMemory: this.includeMemory,
566
+ nowMs: this.now(),
567
+ });
568
+ }
569
+ catch (error) {
570
+ this.logger?.warn?.("Failed to re-read changed run artifact.", { summaryFileName, error: errorMessage(error) });
571
+ return;
572
+ }
573
+ if (entry === undefined || !this.isCurrentState(state)) {
574
+ return;
575
+ }
576
+ const runId = entry.session.id;
577
+ if (entry.signature.status === "running" && state.liveFinished.has(runId)) {
578
+ // A debounced artifact watch can read the start-time "running" summary after
579
+ // live SSE already delivered the terminal frame. Keep the terminal live fold
580
+ // until the disk artifact itself becomes terminal.
581
+ return;
582
+ }
583
+ this.insertDiskSummarySession(state, entry, { emit: true });
584
+ }
585
+ setupRegistryWatchers() {
586
+ for (const dir of this.registryDirs) {
587
+ let watcher;
588
+ try {
589
+ watcher = watch(dir, () => this.scheduleReconcile());
590
+ }
591
+ catch (error) {
592
+ this.logger?.warn?.("Failed to watch registry dir.", { dir, error: errorMessage(error) });
593
+ continue;
594
+ }
595
+ watcher.on("error", (error) => {
596
+ this.logger?.warn?.("Registry watcher error.", { dir, error: errorMessage(error) });
597
+ });
598
+ this.registryWatchers.push(watcher);
599
+ }
600
+ }
601
+ scheduleReconcile() {
602
+ if (this.stopped || this.reconcileDebounceTimer !== undefined) {
603
+ return;
604
+ }
605
+ this.reconcileDebounceTimer = setTimeout(() => {
606
+ this.reconcileDebounceTimer = undefined;
607
+ void this.reconcile();
608
+ }, this.registryDebounceMs);
609
+ this.reconcileDebounceTimer.unref?.();
610
+ }
611
+ async reconcile() {
612
+ if (this.stopped) {
613
+ return;
614
+ }
615
+ for (const state of this.states.values()) {
616
+ this.refreshStaleRunningSessionsForState(state, { emit: true });
617
+ }
618
+ const generation = this.lifecycleGeneration;
619
+ let discovered;
620
+ try {
621
+ discovered = await this.discover();
622
+ }
623
+ catch (error) {
624
+ this.logger?.warn?.("Registry reconcile failed.", { error: errorMessage(error) });
625
+ return;
626
+ }
627
+ if (!this.isActive(generation)) {
628
+ return;
629
+ }
630
+ const byId = new Map(discovered.map((instance) => [instance.instance.sourceId, instance]));
631
+ let changed = false;
632
+ for (const [sourceId, state] of [...this.states]) {
633
+ if (!byId.has(sourceId)) {
634
+ this.removeInstance(state);
635
+ this.states.delete(sourceId);
636
+ changed = true;
637
+ }
638
+ }
639
+ for (const instance of discovered) {
640
+ const existing = this.states.get(instance.instance.sourceId);
641
+ if (existing === undefined) {
642
+ await this.addInstance(instance, { emitSessions: true }, generation);
643
+ changed = true;
644
+ }
645
+ else {
646
+ if (await this.updateInstance(existing, instance, generation)) {
647
+ changed = true;
648
+ }
649
+ if (this.isCurrentState(existing, generation)) {
650
+ if (existing.artifactWatcher === undefined) {
651
+ try {
652
+ if (await this.refreshDiskState(existing, { emitSessions: true })) {
653
+ changed = true;
654
+ }
655
+ this.watchArtifacts(existing);
656
+ }
657
+ catch (error) {
658
+ this.logger?.warn?.("Failed to refresh instance history.", {
659
+ sourceId: existing.discovered.instance.sourceId,
660
+ error: errorMessage(error),
661
+ });
662
+ }
663
+ }
664
+ else if (this.includeMemory && existing.memoryArtifactWatcher === undefined) {
665
+ this.watchMemoryArtifacts(existing);
666
+ }
667
+ }
668
+ }
669
+ }
670
+ if (changed) {
671
+ this.scheduleInstancesEmit();
672
+ }
673
+ }
674
+ async updateInstance(state, discovered, generation = this.lifecycleGeneration) {
675
+ const previous = state.discovered;
676
+ state.discovered = discovered;
677
+ let changed = previous.instance.health !== discovered.instance.health ||
678
+ previous.instance.label !== discovered.instance.label ||
679
+ previous.instance.cwd !== discovered.instance.cwd ||
680
+ previous.instance.timeZone !== discovered.instance.timeZone ||
681
+ previous.instance.timezone !== discovered.instance.timezone;
682
+ let liveReconnected = false;
683
+ if (previous.instance.artifactDir !== discovered.instance.artifactDir) {
684
+ this.disconnectLive(state);
685
+ liveReconnected = true;
686
+ if (state.artifactWatcher !== undefined) {
687
+ state.artifactWatcher.close();
688
+ state.artifactWatcher = undefined;
689
+ }
690
+ if (state.memoryArtifactWatcher !== undefined) {
691
+ state.memoryArtifactWatcher.close();
692
+ state.memoryArtifactWatcher = undefined;
693
+ }
694
+ for (const timer of state.artifactTimers.values()) {
695
+ clearTimeout(timer);
696
+ }
697
+ state.artifactTimers.clear();
698
+ const removedRunIds = [...state.sessions.keys()];
699
+ state.sessions.clear();
700
+ state.diskRunSignatures.clear();
701
+ state.detailLoaded.clear();
702
+ state.artifactFinished.clear();
703
+ state.liveFinished.clear();
704
+ state.suppressedMemoryLiveRuns.clear();
705
+ for (const runId of removedRunIds) {
706
+ this.emit({ t: "session_removed", sourceId: discovered.instance.sourceId, runId });
707
+ }
708
+ await this.seedHistory(state, { emitSessions: true });
709
+ if (!this.isCurrentState(state, generation)) {
710
+ return changed;
711
+ }
712
+ this.watchArtifacts(state);
713
+ await this.connectLive(state, generation);
714
+ changed = true;
715
+ }
716
+ if (!liveReconnected && previous.liveBaseUrl !== discovered.liveBaseUrl) {
717
+ this.disconnectLive(state);
718
+ await this.connectLive(state, generation);
719
+ changed = true;
720
+ }
721
+ return changed;
722
+ }
723
+ removeInstance(state) {
724
+ const sourceId = state.discovered.instance.sourceId;
725
+ for (const runId of state.sessions.keys()) {
726
+ this.emit({ t: "session_removed", sourceId, runId });
727
+ }
728
+ this.teardownState(state);
729
+ }
730
+ /** Close connections/watchers/timers for an instance without emitting any frames. */
731
+ teardownState(state) {
732
+ this.disconnectLive(state);
733
+ if (state.artifactWatcher !== undefined) {
734
+ state.artifactWatcher.close();
735
+ state.artifactWatcher = undefined;
736
+ }
737
+ if (state.memoryArtifactWatcher !== undefined) {
738
+ state.memoryArtifactWatcher.close();
739
+ state.memoryArtifactWatcher = undefined;
740
+ }
741
+ for (const timer of state.artifactTimers.values()) {
742
+ clearTimeout(timer);
743
+ }
744
+ state.artifactTimers.clear();
745
+ state.diskRunSignatures.clear();
746
+ state.detailLoaded.clear();
747
+ state.artifactFinished.clear();
748
+ state.liveFinished.clear();
749
+ state.suppressedMemoryLiveRuns.clear();
750
+ state.sessions.clear();
751
+ }
752
+ scheduleInstancesEmit() {
753
+ if (this.stopped || this.instancesEmitTimer !== undefined) {
754
+ return;
755
+ }
756
+ this.instancesEmitTimer = setTimeout(() => {
757
+ this.instancesEmitTimer = undefined;
758
+ this.emit({ t: "instances", instances: this.getInstances() });
759
+ }, this.instancesDebounceMs);
760
+ this.instancesEmitTimer.unref?.();
761
+ }
762
+ emit(frame) {
763
+ for (const listener of [...this.subscribers]) {
764
+ try {
765
+ listener(frame);
766
+ }
767
+ catch (error) {
768
+ this.logger?.warn?.("Browser stream subscriber threw.", { error: errorMessage(error) });
769
+ }
770
+ }
771
+ }
772
+ projectInstance(state) {
773
+ const base = state.discovered.instance;
774
+ return {
775
+ sourceId: base.sourceId,
776
+ label: base.label,
777
+ cwd: base.cwd,
778
+ artifactDir: base.artifactDir,
779
+ health: base.health,
780
+ ...(base.timeZone === undefined ? {} : { timeZone: base.timeZone }),
781
+ ...(base.timezone === undefined ? {} : { timezone: base.timezone }),
782
+ liveConnected: state.liveConnected,
783
+ counts: { runs: state.sessions.size },
784
+ };
785
+ }
786
+ async seedHistory(state, options) {
787
+ await this.refreshDiskState(state, options);
788
+ }
789
+ async refreshDiskState(state, options) {
790
+ const sessions = await listInstanceSessionSummaries(state.discovered, {
791
+ maxRuns: state.maxRunsPerInstance,
792
+ includeMemory: this.includeMemory,
793
+ nowMs: this.now(),
794
+ });
795
+ let changed = false;
796
+ for (const entry of sessions) {
797
+ if (entry.signature.status === "running" && state.liveFinished.has(entry.session.id)) {
798
+ continue;
799
+ }
800
+ const existing = state.sessions.get(entry.session.id);
801
+ const previousSignature = state.diskRunSignatures.get(entry.session.id);
802
+ if (existing !== undefined && diskRunSignatureEquals(previousSignature, entry.signature)) {
803
+ continue;
804
+ }
805
+ this.insertDiskSummarySession(state, entry, { emit: options.emitSessions });
806
+ changed = true;
807
+ }
808
+ return changed;
809
+ }
810
+ isActive(generation = this.lifecycleGeneration) {
811
+ return !this.stopped && generation === this.lifecycleGeneration;
812
+ }
813
+ filteredStates(filter) {
814
+ if (filter === "all") {
815
+ return [...this.states.values()];
816
+ }
817
+ const state = this.states.get(filter);
818
+ return state === undefined ? [] : [state];
819
+ }
820
+ refreshStaleRunningSessions(filter, options) {
821
+ for (const state of this.filteredStates(filter)) {
822
+ this.refreshStaleRunningSessionsForState(state, options);
823
+ }
824
+ }
825
+ refreshStaleRunningSessionsForState(state, options) {
826
+ let changed = false;
827
+ for (const [runId, session] of state.sessions) {
828
+ const projected = projectStaleRunningSession(session, this.now());
829
+ if (projected.status === session.status) {
830
+ continue;
831
+ }
832
+ state.sessions.set(runId, projected);
833
+ changed = true;
834
+ if (options.emit) {
835
+ this.emit({ t: "session_upsert", session: toBrowserListSession(projected) });
836
+ }
837
+ }
838
+ if (changed) {
839
+ this.evictSessionOverflow(state);
840
+ this.scheduleInstancesEmit();
841
+ }
842
+ return changed;
843
+ }
844
+ async countPagedSessions(states, maxRuns) {
845
+ const pages = await Promise.all(states.map((state) => listInstanceSessionSummaryPage(state.discovered, {
846
+ maxRuns,
847
+ includeMemory: this.includeMemory,
848
+ nowMs: this.now(),
849
+ })));
850
+ return pages.reduce((sum, page) => sum + page.total, 0);
851
+ }
852
+ now() {
853
+ return this.clock();
854
+ }
855
+ isCurrentState(state, generation = this.lifecycleGeneration) {
856
+ return this.isActive(generation) && this.states.get(state.discovered.instance.sourceId) === state;
857
+ }
858
+ mapOptions(state) {
859
+ const cwd = state.discovered.instance.cwd;
860
+ return {
861
+ instanceLabel: state.discovered.instance.label,
862
+ ...(cwd.length === 0 ? {} : { cwd }),
863
+ };
864
+ }
865
+ shouldDropMemoryLiveFrame(state, frame) {
866
+ return state.suppressedMemoryLiveRuns.has(frame.runId) || liveFrameIsMemory(frame);
867
+ }
868
+ suppressMemoryLiveRun(state, runId) {
869
+ state.suppressedMemoryLiveRuns.add(runId);
870
+ state.live?.runs.delete(runId);
871
+ const pending = state.live?.recomputeTimers.get(runId);
872
+ if (pending !== undefined) {
873
+ clearTimeout(pending);
874
+ state.live?.recomputeTimers.delete(runId);
875
+ }
876
+ const hadSession = state.sessions.delete(runId);
877
+ state.diskRunSignatures.delete(runId);
878
+ state.detailLoaded.delete(runId);
879
+ state.artifactFinished.delete(runId);
880
+ state.liveFinished.delete(runId);
881
+ if (hadSession) {
882
+ this.emit({ t: "session_removed", sourceId: state.discovered.instance.sourceId, runId });
883
+ this.scheduleInstancesEmit();
884
+ }
885
+ }
886
+ }
887
+ class BoundedStringFifoSet {
888
+ maxSize;
889
+ values = new Set();
890
+ order = [];
891
+ constructor(maxSize) {
892
+ this.maxSize = maxSize;
893
+ }
894
+ get size() {
895
+ return this.values.size;
896
+ }
897
+ has(value) {
898
+ return this.values.has(value);
899
+ }
900
+ add(value) {
901
+ if (this.values.has(value)) {
902
+ return;
903
+ }
904
+ this.values.add(value);
905
+ this.order.push(value);
906
+ while (this.values.size > this.maxSize) {
907
+ const oldest = this.order.shift();
908
+ if (oldest !== undefined) {
909
+ this.values.delete(oldest);
910
+ }
911
+ }
912
+ }
913
+ delete(value) {
914
+ if (!this.values.delete(value)) {
915
+ return;
916
+ }
917
+ const index = this.order.indexOf(value);
918
+ if (index >= 0) {
919
+ this.order.splice(index, 1);
920
+ }
921
+ }
922
+ clear() {
923
+ this.values.clear();
924
+ this.order.length = 0;
925
+ }
926
+ }
927
+ function runningSummaryFromStart(frame) {
928
+ return {
929
+ runId: frame.runId,
930
+ conversationId: frame.conversationId,
931
+ status: "running",
932
+ startedAt: frame.startedAt,
933
+ durationMs: 0,
934
+ eventCount: 0,
935
+ artifactPaths: [],
936
+ ...(frame.source === undefined ? {} : { source: frame.source }),
937
+ ...(frame.sourceDetail === undefined ? {} : { sourceDetail: frame.sourceDetail }),
938
+ };
939
+ }
940
+ function liveFrameIsMemory(frame) {
941
+ if (runIdLooksMemory(frame.runId)) {
942
+ return true;
943
+ }
944
+ if (frame.t === "run_started") {
945
+ return frame.source === "memory" || conversationIdLooksMemory(frame.conversationId);
946
+ }
947
+ if (frame.t === "run_finished") {
948
+ return summaryPayloadLooksMemory(frame.summary);
949
+ }
950
+ return eventPayloadLooksMemory(frame.event);
951
+ }
952
+ function summaryPayloadLooksMemory(value) {
953
+ if (!isRecord(value)) {
954
+ return false;
955
+ }
956
+ return value.source === "memory" ||
957
+ (typeof value.conversationId === "string" && conversationIdLooksMemory(value.conversationId)) ||
958
+ (typeof value.runId === "string" && runIdLooksMemory(value.runId));
959
+ }
960
+ function eventPayloadLooksMemory(value) {
961
+ if (!isRecord(value)) {
962
+ return false;
963
+ }
964
+ return value.source === "memory" ||
965
+ (typeof value.conversationId === "string" && conversationIdLooksMemory(value.conversationId)) ||
966
+ (typeof value.runId === "string" && runIdLooksMemory(value.runId));
967
+ }
968
+ function conversationIdLooksMemory(conversationId) {
969
+ return conversationId.startsWith("memory:");
970
+ }
971
+ function runIdLooksMemory(runId) {
972
+ return runId.startsWith("mem-");
973
+ }
974
+ function runningSummaryFromEvent(frame) {
975
+ // Default `startedAt` to the event's own timestamp (else wall clock now) so a live
976
+ // run whose first observed frame is an `event` (run_started missed/dropped) still
977
+ // sorts to the TOP of the newest-first list rather than the bottom (empty startTs).
978
+ const startedAt = eventStartedAt(frame.event) ?? new Date().toISOString();
979
+ return {
980
+ runId: frame.runId,
981
+ conversationId: "",
982
+ status: "running",
983
+ startedAt,
984
+ durationMs: 0,
985
+ eventCount: 0,
986
+ artifactPaths: [],
987
+ };
988
+ }
989
+ /**
990
+ * Merge a later authoritative summary with an earlier live-derived one, filling only
991
+ * the identity/provenance fields the later summary left as placeholders
992
+ * (`conversationId:""`, or absent `startedAt`/`source`/`sourceDetail`). Lets a sparse
993
+ * `run_finished.summary` inherit provenance a live placeholder already learned rather
994
+ * than regressing it to blanks.
995
+ */
996
+ function backfillRunningSummary(base, from) {
997
+ const conversationId = base.conversationId.length > 0 ? base.conversationId : from.conversationId;
998
+ const startedAt = base.startedAt ?? from.startedAt;
999
+ const source = base.source ?? from.source;
1000
+ const sourceDetail = base.sourceDetail ?? from.sourceDetail;
1001
+ const providerSessionId = base.providerSessionId !== undefined ? base.providerSessionId : from.providerSessionId;
1002
+ const isolated = base.isolated !== undefined ? base.isolated : from.isolated;
1003
+ return {
1004
+ ...base,
1005
+ conversationId,
1006
+ ...(startedAt === undefined ? {} : { startedAt }),
1007
+ ...(source === undefined ? {} : { source }),
1008
+ ...(sourceDetail === undefined ? {} : { sourceDetail }),
1009
+ ...(providerSessionId === undefined ? {} : { providerSessionId }),
1010
+ ...(isolated === undefined ? {} : { isolated }),
1011
+ };
1012
+ }
1013
+ /**
1014
+ * Best-effort ISO timestamp off a raw runtime event (`timestamp`/`createdAt`/`time`,
1015
+ * an ISO string or an epoch number), mirroring observability's `eventTimestamp`.
1016
+ * Returns `undefined` when the event carries none.
1017
+ */
1018
+ function eventStartedAt(event) {
1019
+ if (!isRecord(event)) {
1020
+ return undefined;
1021
+ }
1022
+ const raw = event.timestamp ?? event.createdAt ?? event.time;
1023
+ if (typeof raw === "string" && raw.trim().length > 0) {
1024
+ return raw.trim();
1025
+ }
1026
+ if (typeof raw === "number" && Number.isFinite(raw)) {
1027
+ const ms = raw < 1e12 ? raw * 1000 : raw;
1028
+ return new Date(ms).toISOString();
1029
+ }
1030
+ return undefined;
1031
+ }
1032
+ function finishedSummary(runId, status) {
1033
+ return {
1034
+ runId,
1035
+ conversationId: "",
1036
+ status: normalizeStatus(status),
1037
+ durationMs: 0,
1038
+ eventCount: 0,
1039
+ artifactPaths: [],
1040
+ };
1041
+ }
1042
+ function normalizeStatus(status) {
1043
+ return RUN_SUMMARY_STATUSES.has(status) ? status : "failed";
1044
+ }
1045
+ function isTerminalSessionStatus(status) {
1046
+ return status !== "running" && status !== "stalled";
1047
+ }
1048
+ /** Accept a live `run_finished.summary` payload only when it is a plausible {@link RunSummary}. */
1049
+ function coerceRunSummary(value) {
1050
+ if (!isRecord(value) || typeof value.runId !== "string") {
1051
+ return undefined;
1052
+ }
1053
+ const status = typeof value.status === "string" ? normalizeStatus(value.status) : "succeeded";
1054
+ const durationMs = typeof value.durationMs === "number" && Number.isFinite(value.durationMs) ? value.durationMs : 0;
1055
+ const eventCount = typeof value.eventCount === "number" && Number.isFinite(value.eventCount) ? value.eventCount : 0;
1056
+ const artifactPaths = Array.isArray(value.artifactPaths)
1057
+ ? value.artifactPaths.filter((entry) => typeof entry === "string")
1058
+ : [];
1059
+ return {
1060
+ ...value,
1061
+ runId: value.runId,
1062
+ conversationId: typeof value.conversationId === "string" ? value.conversationId : "",
1063
+ status,
1064
+ durationMs,
1065
+ eventCount,
1066
+ artifactPaths,
1067
+ };
1068
+ }
1069
+ function coerceRuntimeEvent(value) {
1070
+ return isRecord(value) ? value : { type: "unknown" };
1071
+ }
1072
+ function mergeSessionPreservingVisibleDetail(existing, incoming) {
1073
+ const preserveTimeline = existing.steps.length > 0 &&
1074
+ (incoming.steps.length < existing.steps.length || incoming.totals.steps < existing.totals.steps);
1075
+ const preserveFinalText = existing.finalText.trim().length > 0 && incoming.finalText.trim().length === 0;
1076
+ if (!preserveTimeline && !preserveFinalText) {
1077
+ return incoming;
1078
+ }
1079
+ return {
1080
+ ...incoming,
1081
+ ...(preserveTimeline
1082
+ ? {
1083
+ steps: existing.steps,
1084
+ toolCounts: existing.toolCounts,
1085
+ totals: {
1086
+ ...incoming.totals,
1087
+ asst: Math.max(incoming.totals.asst, existing.totals.asst),
1088
+ tcalls: Math.max(incoming.totals.tcalls, existing.totals.tcalls),
1089
+ think: Math.max(incoming.totals.think, existing.totals.think),
1090
+ steps: Math.max(incoming.totals.steps, existing.totals.steps),
1091
+ },
1092
+ }
1093
+ : {}),
1094
+ ...(preserveFinalText
1095
+ ? {
1096
+ finalText: existing.finalText,
1097
+ outcome: existing.outcome,
1098
+ ...(existing.finalTr === undefined ? {} : { finalTr: existing.finalTr }),
1099
+ }
1100
+ : {}),
1101
+ };
1102
+ }
1103
+ function toBrowserListSession(session) {
1104
+ const { instrTr: _instrTr, recalled: _recalled, finalTr: _finalTr, ...withoutDetailText } = session;
1105
+ return {
1106
+ ...withoutDetailText,
1107
+ instr: "",
1108
+ hasRecall: false,
1109
+ finalText: "",
1110
+ toolCounts: {},
1111
+ totals: {
1112
+ ...session.totals,
1113
+ asst: 0,
1114
+ tcalls: 0,
1115
+ think: 0,
1116
+ },
1117
+ steps: [],
1118
+ };
1119
+ }
1120
+ function sessionHasVisibleDetail(session) {
1121
+ return (session.steps.length > 0 ||
1122
+ session.finalText.trim().length > 0 ||
1123
+ Object.keys(session.toolCounts).length > 0);
1124
+ }
1125
+ function diskRunSignatureEquals(left, right) {
1126
+ return (left !== undefined &&
1127
+ left.summaryFileName === right.summaryFileName &&
1128
+ left.summaryMtimeMs === right.summaryMtimeMs &&
1129
+ left.updatedAt === right.updatedAt &&
1130
+ left.status === right.status &&
1131
+ left.eventCount === right.eventCount);
1132
+ }
1133
+ function liveEventKey(frame) {
1134
+ if (typeof frame.eventIndex === "number" && Number.isFinite(frame.eventIndex)) {
1135
+ return `event:${frame.eventIndex}`;
1136
+ }
1137
+ if (typeof frame.seq === "number" && Number.isFinite(frame.seq)) {
1138
+ return `seq:${frame.seq}`;
1139
+ }
1140
+ return undefined;
1141
+ }
1142
+ function startMs(timestamp) {
1143
+ const ms = Date.parse(timestamp);
1144
+ return Number.isFinite(ms) ? ms : 0;
1145
+ }
1146
+ function isRecord(value) {
1147
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1148
+ }
1149
+ function isErrno(error, code) {
1150
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
1151
+ }
1152
+ function errorMessage(error) {
1153
+ return error instanceof Error ? error.message : String(error);
1154
+ }
1155
+ //# sourceMappingURL=aggregator.js.map