@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.
- package/README.md +92 -0
- package/dist/aggregator.d.ts +123 -0
- package/dist/aggregator.d.ts.map +1 -0
- package/dist/aggregator.js +1155 -0
- package/dist/aggregator.js.map +1 -0
- package/dist/discovery.d.ts +48 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +177 -0
- package/dist/discovery.js.map +1 -0
- package/dist/history.d.ts +47 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +149 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/live-client.d.ts +50 -0
- package/dist/live-client.d.ts.map +1 -0
- package/dist/live-client.js +159 -0
- package/dist/live-client.js.map +1 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +359 -0
- package/dist/server.js.map +1 -0
- package/dist/session-model.d.ts +54 -0
- package/dist/session-model.d.ts.map +1 -0
- package/dist/session-model.js +10 -0
- package/dist/session-model.js.map +1 -0
- package/package.json +39 -0
- package/webapp/dist/apple-touch-icon.png +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/webapp/dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- package/webapp/dist/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- package/webapp/dist/assets/index-ByXpmZqx.js +138 -0
- package/webapp/dist/assets/index-D-V-lq0B.css +1 -0
- package/webapp/dist/assets/newsreader-latin-400-italic-CEihAR-f.woff2 +0 -0
- package/webapp/dist/assets/newsreader-latin-400-italic-CNZoH1hn.woff +0 -0
- package/webapp/dist/assets/newsreader-latin-400-normal-BFBkh4jY.woff2 +0 -0
- package/webapp/dist/assets/newsreader-latin-400-normal-gRTjlS2D.woff +0 -0
- package/webapp/dist/assets/newsreader-latin-500-italic-Bsgye1qA.woff2 +0 -0
- package/webapp/dist/assets/newsreader-latin-500-italic-m-xI0GeS.woff +0 -0
- package/webapp/dist/assets/newsreader-latin-500-normal-B66TYsaK.woff2 +0 -0
- package/webapp/dist/assets/newsreader-latin-500-normal-DFwuUcdu.woff +0 -0
- package/webapp/dist/assets/newsreader-latin-ext-400-italic-BMTE_bNQ.woff2 +0 -0
- package/webapp/dist/assets/newsreader-latin-ext-400-italic-qdgKLcPG.woff +0 -0
- package/webapp/dist/assets/newsreader-latin-ext-400-normal-DYA1XoQK.woff +0 -0
- package/webapp/dist/assets/newsreader-latin-ext-400-normal-svq1FPys.woff2 +0 -0
- package/webapp/dist/assets/newsreader-latin-ext-500-italic-B83LgwQp.woff2 +0 -0
- package/webapp/dist/assets/newsreader-latin-ext-500-italic-Bz3NsAdg.woff +0 -0
- package/webapp/dist/assets/newsreader-latin-ext-500-normal-BNHmvKvI.woff2 +0 -0
- package/webapp/dist/assets/newsreader-latin-ext-500-normal-CZruMFou.woff +0 -0
- package/webapp/dist/assets/newsreader-vietnamese-400-italic-QbB8kb5s.woff +0 -0
- package/webapp/dist/assets/newsreader-vietnamese-400-italic-bZegYFuM.woff2 +0 -0
- package/webapp/dist/assets/newsreader-vietnamese-400-normal-BekUZro8.woff +0 -0
- package/webapp/dist/assets/newsreader-vietnamese-400-normal-DdKr49mV.woff2 +0 -0
- package/webapp/dist/assets/newsreader-vietnamese-500-italic-BsTGSz8L.woff +0 -0
- package/webapp/dist/assets/newsreader-vietnamese-500-italic-D26NjlyL.woff2 +0 -0
- package/webapp/dist/assets/newsreader-vietnamese-500-normal-BEAbKU8A.woff +0 -0
- package/webapp/dist/assets/newsreader-vietnamese-500-normal-CL6a8tp2.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-latin-400-normal-BnQMeOim.woff +0 -0
- package/webapp/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-latin-500-normal-CNSSEhBt.woff +0 -0
- package/webapp/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-latin-600-normal-BflQw4A9.woff +0 -0
- package/webapp/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-latin-700-normal-CwsQ-cCU.woff +0 -0
- package/webapp/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-latin-ext-400-normal-CfP_5XZW.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-latin-ext-400-normal-DRPE3kg4.woff +0 -0
- package/webapp/dist/assets/space-grotesk-latin-ext-500-normal-3dgZTiw9.woff +0 -0
- package/webapp/dist/assets/space-grotesk-latin-ext-500-normal-DUe3BAxM.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-latin-ext-600-normal-DxxdqCpr.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-latin-ext-600-normal-VcznFIpX.woff +0 -0
- package/webapp/dist/assets/space-grotesk-latin-ext-700-normal-BQnZhY3m.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-latin-ext-700-normal-HVCqSBdx.woff +0 -0
- package/webapp/dist/assets/space-grotesk-vietnamese-400-normal-B7xT_GF5.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-vietnamese-400-normal-BIWiOVfw.woff +0 -0
- package/webapp/dist/assets/space-grotesk-vietnamese-500-normal-BTqKIpxg.woff +0 -0
- package/webapp/dist/assets/space-grotesk-vietnamese-500-normal-BmEvtly_.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-vietnamese-600-normal-D6zpsUhD.woff +0 -0
- package/webapp/dist/assets/space-grotesk-vietnamese-600-normal-DUi7WF5p.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-vietnamese-700-normal-DMty7AZE.woff2 +0 -0
- package/webapp/dist/assets/space-grotesk-vietnamese-700-normal-Duxec5Rn.woff +0 -0
- package/webapp/dist/favicon.ico +0 -0
- package/webapp/dist/icon-192.png +0 -0
- package/webapp/dist/icon-512.png +0 -0
- package/webapp/dist/icon.svg +31 -0
- package/webapp/dist/index.html +25 -0
- package/webapp/dist/manifest.webmanifest +1 -0
- package/webapp/dist/registerSW.js +1 -0
- package/webapp/dist/sw.js +1 -0
- 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
|