@tonyclaw/llm-inspector 1.17.1 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BhXCLr7m.js → CompareDrawer-BpwZCB6M.js} +1 -1
  3. package/.output/public/assets/{ReplayDialog-CzRPSXwa.js → ReplayDialog-Clratkzl.js} +1 -1
  4. package/.output/public/assets/{RequestAnatomy-lMQonao2.js → RequestAnatomy-EtiX0r_G.js} +1 -1
  5. package/.output/public/assets/{ResponseView-Bt0vngo0.js → ResponseView-CJqxo-EN.js} +1 -1
  6. package/.output/public/assets/{StreamingChunkSequence-Dq9XY2E9.js → StreamingChunkSequence-BIbRqQiV.js} +1 -1
  7. package/.output/public/assets/{index-B4nxi_tZ.js → index-B-0F9n1w.js} +8 -8
  8. package/.output/public/assets/{json-viewer-C8ttTXtv.js → json-viewer-D-z1r1Pp.js} +1 -1
  9. package/.output/public/assets/{main-Dgme52Fp.js → main-CZJ63sQh.js} +1 -1
  10. package/.output/server/_ssr/{CompareDrawer-D-Nj8wmx.mjs → CompareDrawer-BJr-913n.mjs} +4 -3
  11. package/.output/server/_ssr/{ReplayDialog-DcucC22E.mjs → ReplayDialog-BwmToGuR.mjs} +5 -4
  12. package/.output/server/_ssr/{RequestAnatomy-aL8GAcW2.mjs → RequestAnatomy-BmMiPRPB.mjs} +3 -2
  13. package/.output/server/_ssr/{ResponseView-BHgpoGaF.mjs → ResponseView-ZB9-8Raw.mjs} +4 -3
  14. package/.output/server/_ssr/{StreamingChunkSequence-DrT7StyS.mjs → StreamingChunkSequence-DWm4CQWC.mjs} +4 -3
  15. package/.output/server/_ssr/{index-nUG0H1oS.mjs → index-C7I_Qgt0.mjs} +18 -20
  16. package/.output/server/_ssr/index.mjs +2 -2
  17. package/.output/server/_ssr/{json-viewer-DLsDT0RE.mjs → json-viewer-D9XETzwp.mjs} +3 -2
  18. package/.output/server/_ssr/{router-DG_jmXCF.mjs → router-711KpGkz.mjs} +647 -98
  19. package/.output/server/{_tanstack-start-manifest_v-D0JtrQPv.mjs → _tanstack-start-manifest_v-noQw0Vmw.mjs} +1 -1
  20. package/.output/server/index.mjs +53 -53
  21. package/package.json +1 -1
  22. package/src/components/proxy-viewer/TurnGroup.tsx +10 -13
  23. package/src/proxy/handler.ts +52 -84
  24. package/src/proxy/logFinalizer.ts +301 -0
  25. package/src/proxy/logFinalizer.worker.ts +24 -0
  26. package/src/proxy/schemas.ts +8 -3
  27. package/src/proxy/sessionProcess.ts +133 -0
  28. package/src/proxy/sessionRuntime.ts +85 -0
  29. package/src/proxy/sessionSupervisor.ts +282 -0
  30. package/src/proxy/sessionWorkerEntry.ts +26 -0
  31. package/src/proxy/store.ts +64 -20
  32. package/src/routes/api/logs.stream.ts +2 -2
  33. package/src/routes/api/sessions.ts +9 -2
@@ -0,0 +1,301 @@
1
+ import { Worker } from "node:worker_threads";
2
+ import { writeChunks } from "./chunkStorage";
3
+ import { formatForPath } from "./formats";
4
+ import { appendLogEntry, logger } from "./logger";
5
+ import type { CapturedLog } from "./schemas";
6
+ import { getSessionProcess } from "./sessionProcess";
7
+ import { finalizeLogUpdate } from "./store";
8
+
9
+ type BaseFinalizeLogJob = {
10
+ log: CapturedLog;
11
+ upstreamUrl: string;
12
+ elapsedMs: number;
13
+ };
14
+
15
+ export type FinalizeNonStreamingLogJob = BaseFinalizeLogJob & {
16
+ type: "non-streaming";
17
+ responseStatus: number;
18
+ responseBody: string;
19
+ };
20
+
21
+ export type FinalizeStreamingLogJob = BaseFinalizeLogJob & {
22
+ type: "streaming";
23
+ responseStatus: number;
24
+ rawStream: string;
25
+ };
26
+
27
+ export type FinalizeStreamAbortLogJob = BaseFinalizeLogJob & {
28
+ type: "stream-abort";
29
+ rawStream: string;
30
+ hasChunks: boolean;
31
+ };
32
+
33
+ export type FinalizeLogJob =
34
+ | FinalizeNonStreamingLogJob
35
+ | FinalizeStreamingLogJob
36
+ | FinalizeStreamAbortLogJob;
37
+
38
+ export type FinalizeLogResult = {
39
+ log: CapturedLog;
40
+ upstreamUrl: string;
41
+ error: string | null;
42
+ };
43
+
44
+ export function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string, unknown> {
45
+ return {
46
+ timestamp: log.timestamp,
47
+ id: log.id,
48
+ method: log.method,
49
+ path: log.path,
50
+ model: log.model,
51
+ sessionId: log.sessionId,
52
+ rawRequestBody: log.rawRequestBody,
53
+ responseStatus: log.responseStatus,
54
+ responseText: log.responseText,
55
+ inputTokens: log.inputTokens,
56
+ outputTokens: log.outputTokens,
57
+ cacheCreationInputTokens: log.cacheCreationInputTokens,
58
+ cacheReadInputTokens: log.cacheReadInputTokens,
59
+ elapsedMs: log.elapsedMs,
60
+ streaming: log.streaming,
61
+ userAgent: log.userAgent,
62
+ origin: log.origin,
63
+ upstreamUrl,
64
+ clientPort: log.clientPort,
65
+ clientPid: log.clientPid,
66
+ clientCwd: log.clientCwd,
67
+ clientProjectFolder: log.clientProjectFolder,
68
+ streamingChunks: log.streamingChunks,
69
+ streamingChunksPath: log.streamingChunksPath,
70
+ error: log.error,
71
+ };
72
+ }
73
+
74
+ function cloneLog(log: CapturedLog): CapturedLog {
75
+ return { ...log };
76
+ }
77
+
78
+ function errorMessage(err: unknown): string {
79
+ return err instanceof Error ? err.message : String(err);
80
+ }
81
+
82
+ function persistStreamingChunks(log: CapturedLog): void {
83
+ if (log.streamingChunks !== undefined && log.streamingChunks.chunks.length > 0) {
84
+ const chunkPath = writeChunks(
85
+ log.id,
86
+ log.streamingChunks.chunks,
87
+ log.streamingChunks.truncated,
88
+ );
89
+ log.streamingChunksPath = chunkPath;
90
+ }
91
+ }
92
+
93
+ function finalizeWithError(
94
+ job: FinalizeLogJob,
95
+ log: CapturedLog,
96
+ fallbackStatus: number,
97
+ fallbackResponseText: string,
98
+ err: unknown,
99
+ ): FinalizeLogResult {
100
+ const message = errorMessage(err);
101
+ logger.error(`[logFinalizer] Failed to finalize log #${log.id}:`, message);
102
+ log.responseStatus = log.responseStatus ?? fallbackStatus;
103
+ log.responseText = log.responseText ?? fallbackResponseText;
104
+ log.elapsedMs = job.elapsedMs;
105
+ log.error = message;
106
+ return { log, upstreamUrl: job.upstreamUrl, error: message };
107
+ }
108
+
109
+ function finalizeNonStreaming(
110
+ job: FinalizeNonStreamingLogJob,
111
+ log: CapturedLog,
112
+ ): FinalizeLogResult {
113
+ const formatHandler = formatForPath(log.path);
114
+ if (formatHandler === null) {
115
+ return finalizeWithError(job, log, job.responseStatus, job.responseBody, "Unsupported format");
116
+ }
117
+
118
+ try {
119
+ const tokens = formatHandler.extractTokens(job.responseBody);
120
+
121
+ log.elapsedMs = job.elapsedMs;
122
+ log.responseStatus = job.responseStatus;
123
+ log.responseText = job.responseBody;
124
+ log.inputTokens = tokens.inputTokens;
125
+ log.outputTokens = tokens.outputTokens;
126
+ log.cacheCreationInputTokens = tokens.cacheCreationInputTokens;
127
+ log.cacheReadInputTokens = tokens.cacheReadInputTokens;
128
+
129
+ return { log, upstreamUrl: job.upstreamUrl, error: null };
130
+ } catch (err) {
131
+ return finalizeWithError(job, log, job.responseStatus, job.responseBody, err);
132
+ }
133
+ }
134
+
135
+ function finalizeStreaming(job: FinalizeStreamingLogJob, log: CapturedLog): FinalizeLogResult {
136
+ const formatHandler = formatForPath(log.path);
137
+ if (formatHandler === null) {
138
+ return finalizeWithError(job, log, job.responseStatus, job.rawStream, "Unsupported format");
139
+ }
140
+
141
+ try {
142
+ log.elapsedMs = job.elapsedMs;
143
+ log.responseStatus = job.responseStatus;
144
+ log.responseText = formatHandler.extractStream(
145
+ job.rawStream,
146
+ log,
147
+ log.model ?? undefined,
148
+ true,
149
+ );
150
+ persistStreamingChunks(log);
151
+
152
+ return { log, upstreamUrl: job.upstreamUrl, error: null };
153
+ } catch (err) {
154
+ return finalizeWithError(job, log, job.responseStatus, job.rawStream, err);
155
+ }
156
+ }
157
+
158
+ function finalizeStreamAbort(job: FinalizeStreamAbortLogJob, log: CapturedLog): FinalizeLogResult {
159
+ const formatHandler = formatForPath(log.path);
160
+ if (formatHandler === null && job.hasChunks) {
161
+ return finalizeWithError(job, log, 499, "Client aborted", "Unsupported format");
162
+ }
163
+
164
+ try {
165
+ log.elapsedMs = job.elapsedMs;
166
+ if (job.hasChunks && formatHandler !== null) {
167
+ log.responseText = formatHandler.extractStream(
168
+ job.rawStream,
169
+ log,
170
+ log.model ?? undefined,
171
+ true,
172
+ );
173
+ persistStreamingChunks(log);
174
+ } else {
175
+ log.responseText = "Client aborted";
176
+ }
177
+
178
+ return { log, upstreamUrl: job.upstreamUrl, error: "Client aborted" };
179
+ } catch (err) {
180
+ return finalizeWithError(job, log, 499, "Client aborted", err);
181
+ }
182
+ }
183
+
184
+ export function buildFinalizeLogResult(job: FinalizeLogJob): FinalizeLogResult {
185
+ const log = cloneLog(job.log);
186
+
187
+ switch (job.type) {
188
+ case "non-streaming":
189
+ return finalizeNonStreaming(job, log);
190
+ case "streaming":
191
+ return finalizeStreaming(job, log);
192
+ case "stream-abort":
193
+ return finalizeStreamAbort(job, log);
194
+ }
195
+ }
196
+
197
+ export function commitFinalizeLogResult(result: FinalizeLogResult): void {
198
+ appendLogEntry({ ...buildFileLogEntry(result.log, result.upstreamUrl), error: result.error });
199
+ finalizeLogUpdate(result.log);
200
+ }
201
+
202
+ // ── Worker pool ────────────────────────────────────────────────
203
+ // buildFinalizeLogResult is the CPU-heavy step (token extraction,
204
+ // SSE parsing, chunk persistence). We offload it to a fixed pool
205
+ // of Worker Threads so the main thread stays responsive even under
206
+ // concurrent sessions. commitFinalizeLogResult stays in-process
207
+ // because it touches the in-memory cache, SSE emitter, and
208
+ // append‑only log — all of which belong to the main thread.
209
+
210
+ // ── Routing ─────────────────────────────────────────────────────
211
+ // FINALIZER_RUNTIME selects the execution backend:
212
+ // "process" → per-session child process (default, max isolation)
213
+ // "worker" → shared Worker Thread pool
214
+ // "inline" → synchronous in-process (debug / fallback)
215
+ // For backward compatibility, FINALIZER_USE_WORKER=0 forces "inline".
216
+
217
+ const RUNTIME: "process" | "worker" | "inline" = (() => {
218
+ if (process.env["FINALIZER_USE_WORKER"] === "0") return "inline";
219
+ const mode = process.env["FINALIZER_RUNTIME"];
220
+ if (mode === "worker") return "worker";
221
+ if (mode === "inline") return "inline";
222
+ return "process";
223
+ })();
224
+
225
+ function executeBuildInSessionProcess(job: FinalizeLogJob): Promise<FinalizeLogResult> {
226
+ const sessionId = job.log.sessionId ?? "__unassigned__";
227
+ return getSessionProcess(sessionId)
228
+ .enqueue(job)
229
+ .catch((err: unknown) => {
230
+ const message = err instanceof Error ? err.message : String(err);
231
+ logger.error(
232
+ `[logFinalizer] Session process failed for log #${job.log.id}, ` +
233
+ `falling back to in-process: ${message}`,
234
+ );
235
+ return buildFinalizeLogResult(job);
236
+ });
237
+ }
238
+
239
+ function resolveBuildPromise(job: FinalizeLogJob): Promise<FinalizeLogResult> {
240
+ switch (RUNTIME) {
241
+ case "process":
242
+ return executeBuildInSessionProcess(job);
243
+ case "worker":
244
+ return executeBuildInWorker(job);
245
+ case "inline":
246
+ return Promise.resolve(buildFinalizeLogResult(job));
247
+ }
248
+ }
249
+
250
+ const WORKER_COUNT = Math.max(1, Number(process.env["FINALIZER_WORKER_COUNT"]) || 4);
251
+ let _workers: Worker[] | null = null;
252
+ const _pending = new Map<string, (result: FinalizeLogResult) => void>();
253
+ let _nextWorker = 0;
254
+ let _jobSeq = 0;
255
+
256
+ function getWorkers(): Worker[] {
257
+ if (_workers !== null) return _workers;
258
+ _workers = [];
259
+ for (let i = 0; i < WORKER_COUNT; i++) {
260
+ const w = new Worker(new URL("./logFinalizer.worker.ts", import.meta.url));
261
+ w.on("message", (msg: { id: string; result: FinalizeLogResult }) => {
262
+ const resolve = _pending.get(msg.id);
263
+ if (resolve !== undefined) {
264
+ _pending.delete(msg.id);
265
+ resolve(msg.result);
266
+ }
267
+ });
268
+ w.on("error", (err) => {
269
+ logger.error(
270
+ "[logFinalizer] Worker error:",
271
+ err instanceof Error ? err.message : String(err),
272
+ );
273
+ });
274
+ _workers.push(w);
275
+ }
276
+ return _workers;
277
+ }
278
+
279
+ export function executeBuildInWorker(job: FinalizeLogJob): Promise<FinalizeLogResult> {
280
+ return new Promise((resolve) => {
281
+ const list = getWorkers();
282
+ // getWorkers() always returns at least 1 worker; the array index is safe
283
+ const idx = _nextWorker % list.length;
284
+ _nextWorker++;
285
+ const w = list[idx];
286
+ if (w === undefined) return; // unreachable, satisfies type-checker
287
+ const id = String(++_jobSeq);
288
+ _pending.set(id, resolve);
289
+ w.postMessage({ id, job });
290
+ });
291
+ }
292
+
293
+ export function executeFinalizeLogJob(job: FinalizeLogJob): Promise<void> {
294
+ return resolveBuildPromise(job).then((result) => {
295
+ commitFinalizeLogResult(result);
296
+ if (result.error !== null && result.error !== "Client aborted") {
297
+ return Promise.reject(new Error(result.error));
298
+ }
299
+ return undefined;
300
+ });
301
+ }
@@ -0,0 +1,24 @@
1
+ import { isMainThread, parentPort } from "node:worker_threads";
2
+ import {
3
+ buildFinalizeLogResult,
4
+ type FinalizeLogJob,
5
+ type FinalizeLogResult,
6
+ } from "./logFinalizer";
7
+
8
+ if (!isMainThread && parentPort !== null) {
9
+ const port = parentPort;
10
+ port.on("message", (msg: { id: string; job: FinalizeLogJob }) => {
11
+ let result: FinalizeLogResult;
12
+ try {
13
+ result = buildFinalizeLogResult(msg.job);
14
+ } catch (err) {
15
+ const message = err instanceof Error ? err.message : String(err);
16
+ result = {
17
+ log: msg.job.log,
18
+ upstreamUrl: msg.job.upstreamUrl,
19
+ error: message,
20
+ };
21
+ }
22
+ port.postMessage({ id: msg.id, result });
23
+ });
24
+ }
@@ -160,6 +160,10 @@ export type RequestMetadata = {
160
160
  sessionId: string | null;
161
161
  };
162
162
 
163
+ function extractSessionIdFromHeaders(headers: Headers): string | null {
164
+ return headers.get("x-llm-inspector-session-id") ?? headers.get("x-session-affinity");
165
+ }
166
+
163
167
  /**
164
168
  * Parse a request body exactly once and pull out everything the handler needs
165
169
  * for routing + log creation: the model (used for provider selection) and the
@@ -171,20 +175,21 @@ export type RequestMetadata = {
171
175
  * for the session id.
172
176
  */
173
177
  export function extractRequestMetadata(body: string | null, headers: Headers): RequestMetadata {
174
- if (body === null) return { model: null, sessionId: null };
178
+ const headerSessionId = extractSessionIdFromHeaders(headers);
179
+ if (body === null) return { model: null, sessionId: headerSessionId };
175
180
  try {
176
181
  const json: unknown = JSON.parse(body);
177
182
  const loose = LooseRequestSchema.safeParse(json);
178
183
  if (loose.success) {
179
184
  return {
180
185
  model: loose.data.model ?? null,
181
- sessionId: loose.data.metadata?.user_id ?? headers.get("x-session-affinity") ?? null,
186
+ sessionId: loose.data.metadata?.user_id ?? headerSessionId,
182
187
  };
183
188
  }
184
189
  } catch {
185
190
  // body not valid JSON
186
191
  }
187
- return { model: null, sessionId: null };
192
+ return { model: null, sessionId: headerSessionId };
188
193
  }
189
194
 
190
195
  /**
@@ -0,0 +1,133 @@
1
+ import { fork, type ChildProcess } from "node:child_process";
2
+ import { logger } from "./logger";
3
+ import type { FinalizeLogJob, FinalizeLogResult } from "./logFinalizer";
4
+
5
+ const IDLE_TIMEOUT_MS = Number(process.env["SESSION_PROCESS_IDLE_MS"]) || 5 * 60 * 1000; // 5 min default
6
+ const MAX_RESTARTS = 3;
7
+
8
+ const _processes = new Map<string, SessionProcess>();
9
+
10
+ type PendingJob = {
11
+ resolve: (result: FinalizeLogResult) => void;
12
+ reject: (err: Error) => void;
13
+ };
14
+
15
+ export class SessionProcess {
16
+ private child: ChildProcess | null = null;
17
+ private pending = new Map<string, PendingJob>();
18
+ private nextId = 0;
19
+ private idleTimer: ReturnType<typeof setTimeout> | null = null;
20
+ private restartCount = 0;
21
+ private destroyed = false;
22
+
23
+ constructor(private sessionId: string) {}
24
+
25
+ /** Number of outstanding jobs sent to this process. */
26
+ get pendingCount(): number {
27
+ return this.pending.size;
28
+ }
29
+
30
+ private ensureRunning(): ChildProcess {
31
+ if (this.child !== null && this.child.connected) return this.child;
32
+
33
+ const entryPath = new URL("./sessionWorkerEntry.ts", import.meta.url).pathname;
34
+ // On Windows, the path from URL.pathname starts with "/" which needs to
35
+ // be stripped when it's a drive letter path (e.g. "/C:/..." → "C:/...")
36
+ const resolvedPath =
37
+ process.platform === "win32" && entryPath.startsWith("/") ? entryPath.slice(1) : entryPath;
38
+
39
+ this.child = fork(resolvedPath, [], {
40
+ stdio: ["pipe", "pipe", "pipe", "ipc"],
41
+ });
42
+
43
+ this.restartCount += 1;
44
+
45
+ this.child.on("message", (msg: { id: string; result: FinalizeLogResult }) => {
46
+ const pending = this.pending.get(msg.id);
47
+ if (pending !== undefined) {
48
+ this.pending.delete(msg.id);
49
+ pending.resolve(msg.result);
50
+ }
51
+ this.resetIdleTimer();
52
+ });
53
+
54
+ this.child.on("error", (err) => {
55
+ logger.error(`[sessionProcess] Session ${this.sessionId} process error:`, err.message);
56
+ });
57
+
58
+ this.child.on("exit", (code, signal) => {
59
+ const wasConnected = this.child !== null;
60
+ this.child = null;
61
+
62
+ if (this.destroyed) return;
63
+
64
+ // Reject all pending jobs on unexpected exit
65
+ for (const [, pending] of this.pending) {
66
+ pending.reject(
67
+ new Error(
68
+ `Session process exited with code ${code ?? signal}, session=${this.sessionId}`,
69
+ ),
70
+ );
71
+ }
72
+ this.pending.clear();
73
+
74
+ if (wasConnected && this.restartCount <= MAX_RESTARTS) {
75
+ logger.warn(
76
+ `[sessionProcess] Session ${this.sessionId} worker exited (code=${code ?? signal}), ` +
77
+ `restart ${this.restartCount}/${MAX_RESTARTS}`,
78
+ );
79
+ }
80
+ });
81
+
82
+ this.resetIdleTimer();
83
+ return this.child;
84
+ }
85
+
86
+ enqueue(job: FinalizeLogJob): Promise<FinalizeLogResult> {
87
+ return new Promise((resolve, reject) => {
88
+ const child = this.ensureRunning();
89
+ const id = String(++this.nextId);
90
+ this.pending.set(id, { resolve, reject });
91
+ child.send({ id, job });
92
+ });
93
+ }
94
+
95
+ private resetIdleTimer(): void {
96
+ if (this.idleTimer !== null) clearTimeout(this.idleTimer);
97
+ this.idleTimer = setTimeout(() => {
98
+ if (this.pending.size === 0) {
99
+ this.destroy();
100
+ }
101
+ }, IDLE_TIMEOUT_MS);
102
+ }
103
+
104
+ destroy(): void {
105
+ this.destroyed = true;
106
+ if (this.idleTimer !== null) {
107
+ clearTimeout(this.idleTimer);
108
+ this.idleTimer = null;
109
+ }
110
+ if (this.child !== null) {
111
+ this.child.kill();
112
+ this.child = null;
113
+ }
114
+ _processes.delete(this.sessionId);
115
+ }
116
+ }
117
+
118
+ /** Get or create a dedicated child process for a session. */
119
+ export function getSessionProcess(sessionId: string): SessionProcess {
120
+ const existing = _processes.get(sessionId);
121
+ if (existing !== undefined) return existing;
122
+
123
+ const sp = new SessionProcess(sessionId);
124
+ _processes.set(sessionId, sp);
125
+ return sp;
126
+ }
127
+
128
+ /** Forcefully tear down all session processes (e.g. on server shutdown). */
129
+ export function destroyAllSessionProcesses(): void {
130
+ for (const [, sp] of _processes) {
131
+ sp.destroy();
132
+ }
133
+ }
@@ -0,0 +1,85 @@
1
+ import { logger } from "./logger";
2
+ import { executeFinalizeLogJob, type FinalizeLogJob } from "./logFinalizer";
3
+ import type { CapturedLog } from "./schemas";
4
+ import {
5
+ getLogSessionId,
6
+ markSessionTaskFinished,
7
+ markSessionTaskQueued,
8
+ markSessionTaskStarted,
9
+ } from "./sessionSupervisor";
10
+
11
+ const UNASSIGNED_SESSION_QUEUE = "__unassigned__";
12
+
13
+ export type SessionTaskName = "finalize-log";
14
+
15
+ type SessionTask<T> = () => Promise<T> | T;
16
+
17
+ const queues = new Map<string, Promise<void>>();
18
+
19
+ function queueKeyForSession(sessionId: string | null): string {
20
+ return sessionId ?? UNASSIGNED_SESSION_QUEUE;
21
+ }
22
+
23
+ function errorMessage(err: unknown): string {
24
+ return err instanceof Error ? err.message : String(err);
25
+ }
26
+
27
+ async function runSessionTask<T>(
28
+ sessionId: string | null,
29
+ taskName: SessionTaskName,
30
+ task: SessionTask<T>,
31
+ ): Promise<T> {
32
+ markSessionTaskStarted(sessionId);
33
+ try {
34
+ const result = await task();
35
+ markSessionTaskFinished(sessionId, null);
36
+ return result;
37
+ } catch (err) {
38
+ const message = errorMessage(err);
39
+ logger.error(`[sessionRuntime] ${taskName} failed for session ${sessionId ?? "unassigned"}`);
40
+ markSessionTaskFinished(sessionId, message);
41
+ return Promise.reject(err);
42
+ }
43
+ }
44
+
45
+ export function enqueueSessionTask<T>(
46
+ sessionId: string | null,
47
+ taskName: SessionTaskName,
48
+ task: SessionTask<T>,
49
+ ): Promise<T> {
50
+ const queueKey = queueKeyForSession(sessionId);
51
+ const previous = queues.get(queueKey) ?? Promise.resolve();
52
+
53
+ markSessionTaskQueued(sessionId);
54
+
55
+ const current = previous
56
+ .catch(() => undefined)
57
+ .then(() => runSessionTask(sessionId, taskName, task));
58
+ const settled = current.then(
59
+ () => undefined,
60
+ () => undefined,
61
+ );
62
+
63
+ queues.set(queueKey, settled);
64
+ void settled.then(() => {
65
+ if (queues.get(queueKey) === settled) {
66
+ queues.delete(queueKey);
67
+ }
68
+ });
69
+
70
+ return current;
71
+ }
72
+
73
+ export function enqueueLogTask<T>(
74
+ log: CapturedLog,
75
+ taskName: SessionTaskName,
76
+ task: SessionTask<T>,
77
+ ): Promise<T> {
78
+ return enqueueSessionTask(getLogSessionId(log), taskName, task);
79
+ }
80
+
81
+ export function enqueueFinalizeLogJob(job: FinalizeLogJob): Promise<void> {
82
+ return enqueueSessionTask(getLogSessionId(job.log), "finalize-log", () =>
83
+ executeFinalizeLogJob(job),
84
+ );
85
+ }