@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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-BhXCLr7m.js → CompareDrawer-BpwZCB6M.js} +1 -1
- package/.output/public/assets/{ReplayDialog-CzRPSXwa.js → ReplayDialog-Clratkzl.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-lMQonao2.js → RequestAnatomy-EtiX0r_G.js} +1 -1
- package/.output/public/assets/{ResponseView-Bt0vngo0.js → ResponseView-CJqxo-EN.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-Dq9XY2E9.js → StreamingChunkSequence-BIbRqQiV.js} +1 -1
- package/.output/public/assets/{index-B4nxi_tZ.js → index-B-0F9n1w.js} +8 -8
- package/.output/public/assets/{json-viewer-C8ttTXtv.js → json-viewer-D-z1r1Pp.js} +1 -1
- package/.output/public/assets/{main-Dgme52Fp.js → main-CZJ63sQh.js} +1 -1
- package/.output/server/_ssr/{CompareDrawer-D-Nj8wmx.mjs → CompareDrawer-BJr-913n.mjs} +4 -3
- package/.output/server/_ssr/{ReplayDialog-DcucC22E.mjs → ReplayDialog-BwmToGuR.mjs} +5 -4
- package/.output/server/_ssr/{RequestAnatomy-aL8GAcW2.mjs → RequestAnatomy-BmMiPRPB.mjs} +3 -2
- package/.output/server/_ssr/{ResponseView-BHgpoGaF.mjs → ResponseView-ZB9-8Raw.mjs} +4 -3
- package/.output/server/_ssr/{StreamingChunkSequence-DrT7StyS.mjs → StreamingChunkSequence-DWm4CQWC.mjs} +4 -3
- package/.output/server/_ssr/{index-nUG0H1oS.mjs → index-C7I_Qgt0.mjs} +18 -20
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{json-viewer-DLsDT0RE.mjs → json-viewer-D9XETzwp.mjs} +3 -2
- package/.output/server/_ssr/{router-DG_jmXCF.mjs → router-711KpGkz.mjs} +647 -98
- package/.output/server/{_tanstack-start-manifest_v-D0JtrQPv.mjs → _tanstack-start-manifest_v-noQw0Vmw.mjs} +1 -1
- package/.output/server/index.mjs +53 -53
- package/package.json +1 -1
- package/src/components/proxy-viewer/TurnGroup.tsx +10 -13
- package/src/proxy/handler.ts +52 -84
- package/src/proxy/logFinalizer.ts +301 -0
- package/src/proxy/logFinalizer.worker.ts +24 -0
- package/src/proxy/schemas.ts +8 -3
- package/src/proxy/sessionProcess.ts +133 -0
- package/src/proxy/sessionRuntime.ts +85 -0
- package/src/proxy/sessionSupervisor.ts +282 -0
- package/src/proxy/sessionWorkerEntry.ts +26 -0
- package/src/proxy/store.ts +64 -20
- package/src/routes/api/logs.stream.ts +2 -2
- 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
|
+
}
|
package/src/proxy/schemas.ts
CHANGED
|
@@ -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
|
-
|
|
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 ??
|
|
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:
|
|
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
|
+
}
|