@tonyclaw/agent-inspector 3.1.19 → 3.1.21

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 (61) hide show
  1. package/.output/backend/nitro.json +1 -1
  2. package/.output/cli.js +140 -20
  3. package/.output/server/_ssr/index.mjs +2 -2
  4. package/.output/server/_ssr/{router-0qlvqhVh.mjs → router-Be6BLuut.mjs} +2454 -1135
  5. package/.output/server/_tanstack-start-manifest_v-ClTaMkEj.mjs +4 -0
  6. package/.output/server/index.mjs +1 -1
  7. package/.output/ui/assets/{CompareDrawer-BOk9hd-Z.js → CompareDrawer-CGk2HFI1.js} +1 -1
  8. package/.output/ui/assets/{InspectorPet-DhHDBpk7.js → InspectorPet-CDM7SdOT.js} +1 -1
  9. package/.output/ui/assets/ProxyViewerContainer-CaWD78tw.js +60 -0
  10. package/.output/ui/assets/{ReplayDialog-DJhKtuNB.js → ReplayDialog-DqaeV1He.js} +1 -1
  11. package/.output/ui/assets/{RequestAnatomy-hP0tsqDC.js → RequestAnatomy-BtHl9P0k.js} +1 -1
  12. package/.output/ui/assets/{ResponseView-BSiUkwCb.js → ResponseView-C6t1vpxs.js} +1 -1
  13. package/.output/ui/assets/{StreamingChunkSequence-CzQqwv4X.js → StreamingChunkSequence-R4mgd3GQ.js} +1 -1
  14. package/.output/ui/assets/{_sessionId-BU-i6h0V.js → _sessionId-CL3hdLlQ.js} +1 -1
  15. package/.output/ui/assets/{_sessionId-C2Pq4Jb0.js → _sessionId-HS666S72.js} +1 -1
  16. package/.output/ui/assets/index-B-Qod-aA.css +1 -0
  17. package/.output/ui/assets/{index-D8C-t-2U.js → index-Bd9jPL4n.js} +1 -1
  18. package/.output/ui/assets/{index-zHpVF0jk.js → index-C4NHrclw.js} +1 -1
  19. package/.output/ui/assets/{index-UUnRwbOJ.js → index-UF1PJEcq.js} +1 -1
  20. package/.output/ui/assets/{index-B7NeIBDf.js → index-tX38BNV2.js} +2 -2
  21. package/.output/ui/assets/{json-viewer-DOzS5rUT.js → json-viewer-KSgzN_Ov.js} +1 -1
  22. package/.output/ui/assets/{jszip.min-CCCY7qNk.js → jszip.min-BiMDxghN.js} +1 -1
  23. package/.output/ui/index.html +2 -2
  24. package/.output/workers/logFinalizer.worker.js +261 -0
  25. package/.output/workers/sessionWorkerEntry.js +261 -0
  26. package/package.json +13 -6
  27. package/src/backend/routes/api/logs.$id.replay.ts +6 -1
  28. package/src/backend/routes/api/logs.stream.ts +7 -0
  29. package/src/backend/routes/api/providers.$providerId.model-metadata.ts +15 -4
  30. package/src/backend/routes/api/storage.ts +56 -0
  31. package/src/backend/routes/metrics.ts +14 -0
  32. package/src/cli/doctor.ts +135 -2
  33. package/src/cli.ts +7 -4
  34. package/src/components/ProxyViewerContainer.tsx +41 -24
  35. package/src/components/providers/SettingsDialog.tsx +261 -70
  36. package/src/components/proxy-viewer/LogEntry.tsx +19 -5
  37. package/src/components/proxy-viewer/bodyHydration.ts +67 -0
  38. package/src/components/proxy-viewer/proxyViewerContainerLogic.ts +78 -0
  39. package/src/contracts/index.ts +2 -0
  40. package/src/contracts/log.ts +5 -0
  41. package/src/knowledge/openclawClient.ts +11 -2
  42. package/src/knowledge/openclawGatewayClient.ts +6 -1
  43. package/src/lib/resourceLimits.ts +191 -0
  44. package/src/lib/safeFetch.ts +428 -20
  45. package/src/lib/stopReason.ts +66 -39
  46. package/src/proxy/ecosystemTasks.ts +20 -0
  47. package/src/proxy/handler.ts +17 -4
  48. package/src/proxy/identityProxy.ts +36 -16
  49. package/src/proxy/logIndex.ts +57 -6
  50. package/src/proxy/logger.ts +4 -0
  51. package/src/proxy/rawStreamCapture.ts +16 -1
  52. package/src/proxy/runtimeHealth.ts +4 -2
  53. package/src/proxy/runtimeMetrics.ts +149 -0
  54. package/src/proxy/schemas.ts +2 -0
  55. package/src/proxy/sessionRuntime.ts +38 -0
  56. package/src/proxy/sqliteLogIndex.ts +78 -2
  57. package/src/proxy/storageLifecycle.ts +432 -0
  58. package/src/proxy/store.ts +69 -13
  59. package/.output/server/_tanstack-start-manifest_v-DnbdNeun.mjs +0 -4
  60. package/.output/ui/assets/ProxyViewerContainer-BRYf987G.js +0 -59
  61. package/.output/ui/assets/index-IBEDJoV_.css +0 -1
@@ -40,7 +40,7 @@ import {
40
40
  getRuntimeAdmissionState,
41
41
  type RuntimeRequestLease,
42
42
  } from "./runtimeAdmission";
43
- import { resourceLimitResponse } from "../lib/resourceLimits";
43
+ import { DEFAULT_RUNTIME_BUDGET_POLICY, resourceLimitResponse } from "../lib/resourceLimits";
44
44
  import {
45
45
  buildUpstreamUrl,
46
46
  describeApiRoute,
@@ -592,10 +592,19 @@ async function handleAdmittedProxy(req: Request): Promise<Response> {
592
592
  body: bodyToForward,
593
593
  signal: req.signal,
594
594
  },
595
- { allowLoopback: ALLOW_LOOPBACK },
595
+ {
596
+ allowLoopback: ALLOW_LOOPBACK,
597
+ deadlineMs: DEFAULT_RUNTIME_BUDGET_POLICY.upstream.fetchDeadlineMs,
598
+ responseBytes: DEFAULT_RUNTIME_BUDGET_POLICY.upstream.fetchResponseBytes,
599
+ },
596
600
  );
597
601
  if (!fetchResult.ok) {
598
- const status = fetchResult.kind === "network-policy" ? STATUS_FORBIDDEN : STATUS_BAD_GATEWAY;
602
+ const status =
603
+ fetchResult.kind === "network-policy"
604
+ ? STATUS_FORBIDDEN
605
+ : fetchResult.kind === "resource-limit"
606
+ ? STATUS_PAYLOAD_TOO_LARGE
607
+ : STATUS_BAD_GATEWAY;
599
608
  logger.warn(`[handler] Safe fetch blocked ${upstreamUrl}: ${fetchResult.reason}`);
600
609
  log.elapsedMs = Date.now() - startTime;
601
610
  log.responseStatus = status;
@@ -605,7 +614,11 @@ async function handleAdmittedProxy(req: Request): Promise<Response> {
605
614
  return new Response(
606
615
  fetchResult.kind === "network-policy"
607
616
  ? "Forbidden: upstream URL is not permitted"
608
- : "Proxy error: upstream redirects are not permitted",
617
+ : fetchResult.kind === "resource-limit"
618
+ ? "Proxy error: upstream response exceeded configured limits"
619
+ : fetchResult.kind === "timeout"
620
+ ? "Proxy error: upstream request timed out"
621
+ : "Proxy error: upstream redirects are not permitted",
609
622
  { status },
610
623
  );
611
624
  }
@@ -585,9 +585,10 @@ function normalizedHostname(value: string): string | null {
585
585
  }
586
586
 
587
587
  function isLoopbackHostname(hostname: string): boolean {
588
- if (hostname === "localhost" || hostname === "::1") return true;
589
- if (isIP(hostname) !== 4) return false;
590
- return hostname.split(".", 1)[0] === "127";
588
+ const normalized = hostname.toLowerCase().replace(/^\[|]$/g, "");
589
+ if (normalized === "localhost" || normalized === "::1") return true;
590
+ if (isIP(normalized) !== 4) return false;
591
+ return normalized.split(".", 1)[0] === "127";
591
592
  }
592
593
 
593
594
  export type UiOriginAllowlist = {
@@ -669,15 +670,31 @@ function normalizedRequestOrigin(origin: string): string | null {
669
670
  }
670
671
  }
671
672
 
673
+ function parsedRequestOrigin(origin: string): URL | null {
674
+ const normalized = normalizedRequestOrigin(origin);
675
+ if (normalized === null) return null;
676
+ return new URL(normalized);
677
+ }
678
+
679
+ function isSameOrLoopbackAliasOrigin(left: URL, right: URL): boolean {
680
+ if (left.origin === right.origin) return true;
681
+ return (
682
+ left.protocol === right.protocol &&
683
+ left.port === right.port &&
684
+ isLoopbackHostname(left.hostname) &&
685
+ isLoopbackHostname(right.hostname)
686
+ );
687
+ }
688
+
672
689
  function isSameRequestOrigin(
673
690
  origin: string,
674
691
  host: string | undefined,
675
692
  publicOrigin?: string,
676
693
  ): boolean {
677
- const parsedOrigin = normalizedRequestOrigin(origin);
694
+ const parsedOrigin = parsedRequestOrigin(origin);
678
695
  if (parsedOrigin === null) return false;
679
- const expected = normalizedRequestOrigin(publicOrigin ?? `http://${host ?? "localhost"}`);
680
- return expected !== null && parsedOrigin === expected;
696
+ const expected = parsedRequestOrigin(publicOrigin ?? `http://${host ?? "localhost"}`);
697
+ return expected !== null && isSameOrLoopbackAliasOrigin(parsedOrigin, expected);
681
698
  }
682
699
 
683
700
  function matchedUiOrigin(
@@ -797,24 +814,27 @@ export function isBrowserMutationAllowed(input: {
797
814
  const fetchSite = input.secFetchSite?.toLowerCase();
798
815
  const browserRequest = input.origin !== undefined || fetchSite !== undefined;
799
816
  if (!browserRequest) return true;
817
+ const sameOrigin =
818
+ input.origin !== undefined
819
+ ? isSameRequestOrigin(input.origin, input.host, input.publicOrigin)
820
+ : false;
800
821
  if (isModelProxyPath(pathname)) {
801
- if (fetchSite !== undefined && fetchSite !== "same-origin") return false;
802
- if (
803
- input.origin !== undefined &&
804
- !isSameRequestOrigin(input.origin, input.host, input.publicOrigin)
805
- ) {
822
+ if (fetchSite !== undefined && fetchSite !== "same-origin" && !sameOrigin) return false;
823
+ if (input.origin !== undefined && !sameOrigin) {
806
824
  return false;
807
825
  }
808
826
  return input.csrfToken === "1";
809
827
  }
810
828
  const allowedOrigin = matchedUiOrigin(input.origin, input.allowedUiOrigins);
811
- if (fetchSite !== undefined && fetchSite !== "same-origin" && allowedOrigin === null)
829
+ if (
830
+ fetchSite !== undefined &&
831
+ fetchSite !== "same-origin" &&
832
+ !sameOrigin &&
833
+ allowedOrigin === null
834
+ )
812
835
  return false;
813
836
  if (input.origin !== undefined) {
814
- if (
815
- !isSameRequestOrigin(input.origin, input.host, input.publicOrigin) &&
816
- allowedOrigin === null
817
- ) {
837
+ if (!sameOrigin && allowedOrigin === null) {
818
838
  return false;
819
839
  }
820
840
  }
@@ -9,12 +9,15 @@ import {
9
9
  type ApiFormat,
10
10
  type CaptureIncompleteReason,
11
11
  type CapturedLog,
12
+ type StopReason,
12
13
  type ToolTraceEvent,
13
14
  } from "./schemas";
14
15
  import { textByteLength } from "../lib/unknown";
15
16
  import { extractToolTraceEvents } from "../lib/toolTrace";
17
+ import { extractStopReason } from "../lib/stopReason";
16
18
  import {
17
19
  getSqliteLogIndexMaxId,
20
+ listSqliteLogCursorEntries,
18
21
  replaceSqliteLogIndexEntries,
19
22
  syncSqliteLogIndexEntries,
20
23
  upsertSqliteLogIndexEntry,
@@ -62,6 +65,7 @@ export type LogIndexSummary = {
62
65
  droppedBytes: number;
63
66
  captureIncompleteReason: CaptureIncompleteReason | null;
64
67
  warnings?: string[];
68
+ stopReason: StopReason | null;
65
69
  toolTraceEvents?: ToolTraceEvent[];
66
70
  error: string | null;
67
71
  };
@@ -141,6 +145,7 @@ export function createLogIndexEntryMetadata(log: CapturedLog): LogIndexEntryMeta
141
145
  droppedBytes: log.droppedBytes ?? 0,
142
146
  captureIncompleteReason: log.captureIncompleteReason ?? null,
143
147
  warnings: log.warnings,
148
+ stopReason: log.stopReason ?? extractStopReason(log),
144
149
  toolTraceEvents: extractToolTraceEvents(log),
145
150
  error: log.error ?? null,
146
151
  },
@@ -155,9 +160,36 @@ function createEmptyIndex(): LogIndex {
155
160
  };
156
161
  }
157
162
 
163
+ function createIndexFromEntries(entries: readonly LogIndexEntry[]): LogIndex {
164
+ const index = createEmptyIndex();
165
+ for (const entry of entries) {
166
+ index.entries[entry.id] = entry;
167
+ index.maxId = Math.max(index.maxId, entry.id);
168
+ }
169
+ return index;
170
+ }
171
+
172
+ function sqliteProjectionReferencesExistingLedgers(entries: readonly LogIndexEntry[]): boolean {
173
+ const logDir = resolveLogDir();
174
+ for (const entry of entries) {
175
+ if (!entry.file.endsWith(".jsonl")) return false;
176
+ if (!existsSync(join(logDir, entry.file))) return false;
177
+ }
178
+ return true;
179
+ }
180
+
158
181
  export async function loadIndex(): Promise<LogIndex> {
159
182
  if (cachedIndex !== null) return cachedIndex;
160
- // logs.idx and inspector.sqlite are projections only. A cold process always rebuilds from
183
+ const sqliteEntries = await listSqliteLogCursorEntries({});
184
+ if (sqliteEntries !== null) {
185
+ if (sqliteProjectionReferencesExistingLedgers(sqliteEntries)) {
186
+ cachedIndex = createIndexFromEntries(sqliteEntries);
187
+ allocatedMaxId = Math.max(allocatedMaxId, cachedIndex.maxId);
188
+ return cachedIndex;
189
+ }
190
+ logger.warn("[logIndex] SQLite projection references missing ledgers; rebuilding from JSONL");
191
+ }
192
+ // logs.idx remains a legacy fallback projection only. If SQLite is unavailable, rebuild from
161
193
  // authoritative JSONL rather than allowing a stale, conflicting, or higher derived row to win.
162
194
  return await rebuildIndex();
163
195
  }
@@ -208,9 +240,11 @@ export async function addToIndex(
208
240
  if (id > index.maxId) {
209
241
  index.maxId = id;
210
242
  }
211
- void upsertSqliteLogIndexEntry(entry);
212
- // Defer disk writes to reduce I/O - flush after a batch of updates
213
- scheduleIndexFlush();
243
+ const sqliteUpdated = await upsertSqliteLogIndexEntry(entry);
244
+ if (!sqliteUpdated) {
245
+ // Legacy fallback only: defer disk writes to reduce I/O when SQLite is unavailable.
246
+ scheduleIndexFlush();
247
+ }
214
248
  }
215
249
 
216
250
  /**
@@ -228,8 +262,8 @@ export async function updateLogIndexEntryMetadata(log: CapturedLog): Promise<voi
228
262
  ...createLogIndexEntryMetadata(log),
229
263
  };
230
264
  index.entries[log.id] = updated;
231
- await upsertSqliteLogIndexEntry(updated);
232
- scheduleIndexFlush();
265
+ const sqliteUpdated = await upsertSqliteLogIndexEntry(updated);
266
+ if (!sqliteUpdated) scheduleIndexFlush();
233
267
  }
234
268
 
235
269
  // Batch writes: collect pending flushes and write once
@@ -239,6 +273,7 @@ let indexFlushTimeout: ReturnType<typeof setTimeout> | null = null;
239
273
  async function flushIndexAsync(): Promise<void> {
240
274
  indexFlushScheduled = false;
241
275
  indexFlushTimeout = null;
276
+ if ((await listSqliteLogCursorEntries({})) !== null) return;
242
277
  const index = await loadIndex();
243
278
  await saveIndex(index);
244
279
  }
@@ -257,6 +292,7 @@ export async function flushIndex(): Promise<void> {
257
292
  indexFlushTimeout = null;
258
293
  }
259
294
  indexFlushScheduled = false;
295
+ if ((await listSqliteLogCursorEntries({})) !== null) return;
260
296
  const index = await loadIndex();
261
297
  await saveIndex(index);
262
298
  }
@@ -370,6 +406,20 @@ function readCaptureIncompleteReasonField(entry: object): CaptureIncompleteReaso
370
406
  }
371
407
  }
372
408
 
409
+ function readStopReasonField(entry: object): StopReason | null {
410
+ const value = readStringOrNullField(entry, "stopReason");
411
+ switch (value) {
412
+ case "end_turn":
413
+ case "tool_use":
414
+ case "stop":
415
+ case "length":
416
+ return value;
417
+ case null:
418
+ default:
419
+ return null;
420
+ }
421
+ }
422
+
373
423
  function readStringArrayField(entry: object, field: string): string[] | undefined {
374
424
  const desc = Object.getOwnPropertyDescriptor(entry, field);
375
425
  if (desc === undefined || !Array.isArray(desc.value)) return undefined;
@@ -424,6 +474,7 @@ function readLogIndexSummary(entry: object, id: number): LogIndexSummary {
424
474
  droppedBytes: readNumberOrNullField(entry, "droppedBytes") ?? 0,
425
475
  captureIncompleteReason: readCaptureIncompleteReasonField(entry),
426
476
  warnings: readStringArrayField(entry, "warnings"),
477
+ stopReason: readStopReasonField(entry),
427
478
  error: readStringOrNullField(entry, "error"),
428
479
  };
429
480
  }
@@ -61,6 +61,10 @@ function getInspectorLogPath(): string {
61
61
  return path.join(getDataDir(), "logs", "inspector.log");
62
62
  }
63
63
 
64
+ export function resolveApplicationLogPath(): string {
65
+ return getInspectorLogPath();
66
+ }
67
+
64
68
  function readBoundedInteger(
65
69
  name: string,
66
70
  fallback: number,
@@ -4,6 +4,11 @@ import { randomUUID } from "node:crypto";
4
4
  import { open, unlink, type FileHandle } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { getDataDir } from "./dataDir";
7
+ import {
8
+ DEFAULT_RUNTIME_BUDGET_POLICY,
9
+ type RuntimeBudgetPolicy,
10
+ runtimeBudgetPolicySnapshot,
11
+ } from "~/lib/resourceLimits";
7
12
  import {
8
13
  ensurePrivateDirectorySync,
9
14
  PRIVATE_FILE_MODE,
@@ -34,6 +39,16 @@ export type RawStreamCaptureOptions = {
34
39
  globalBudget?: RawStreamObservationBudget;
35
40
  };
36
41
 
42
+ export function rawStreamCaptureOptionsFromBudgetPolicy(
43
+ policy: RuntimeBudgetPolicy = DEFAULT_RUNTIME_BUDGET_POLICY,
44
+ ): Pick<RawStreamCaptureOptions, "queueMaxMessages" | "queueMaxBytes"> {
45
+ const snapshot = runtimeBudgetPolicySnapshot(policy);
46
+ return {
47
+ queueMaxMessages: snapshot.queues.rawStreamMessages,
48
+ queueMaxBytes: snapshot.queues.rawStreamBytes,
49
+ };
50
+ }
51
+
37
52
  export type RawStreamSource =
38
53
  | {
39
54
  type: "memory";
@@ -108,7 +123,7 @@ const globalObservationBudget = new RawStreamObservationBudget(
108
123
  ),
109
124
  );
110
125
 
111
- function getRawStreamDir(): string {
126
+ export function getRawStreamDir(): string {
112
127
  return join(getDataDir(), "raw-streams");
113
128
  }
114
129
 
@@ -8,6 +8,7 @@ import { isSqliteLogIndexReady } from "./sqliteLogIndex";
8
8
  import { recoverCapturedLogStorage } from "./jsonlRecovery";
9
9
  import { resolveLogDir } from "./logger";
10
10
  import { loadIndex } from "./logIndex";
11
+ import { getSessionRuntimeQueueHealth } from "./sessionRuntime";
11
12
 
12
13
  const DEFAULT_CACHE_MS = 2_000;
13
14
  const DEFAULT_MIN_DISK_HEADROOM_BYTES = 256 * 1024 * 1024;
@@ -86,6 +87,7 @@ function finalizerChecks(): {
86
87
  queue: ReadinessCheckLevel;
87
88
  } {
88
89
  const health = getFinalizerRuntimeHealth();
90
+ const sessionQueue = getSessionRuntimeQueueHealth();
89
91
  const runtimeArtifactMissing = health.runtime !== "inline" && !health.artifactAvailable;
90
92
  const initializedWorkerMissing =
91
93
  health.runtime === "worker" &&
@@ -104,9 +106,9 @@ function finalizerChecks(): {
104
106
  );
105
107
  const warningThreshold = Math.max(1, Math.floor(queueLimit * 0.8));
106
108
  const queue =
107
- health.pendingJobs >= queueLimit
109
+ health.pendingJobs + sessionQueue.queuedTasks >= queueLimit
108
110
  ? "unavailable"
109
- : health.pendingJobs >= warningThreshold
111
+ : health.pendingJobs + sessionQueue.queuedTasks >= warningThreshold
110
112
  ? "degraded"
111
113
  : "ok";
112
114
  return { workers, queue };
@@ -0,0 +1,149 @@
1
+ import { DEFAULT_RUNTIME_BUDGET_POLICY } from "../lib/resourceLimits";
2
+ import { getFinalizerRuntimeHealth } from "./logFinalizer";
3
+ import { getRuntimeAdmissionState } from "./runtimeAdmission";
4
+ import { getRuntimeReadiness } from "./runtimeHealth";
5
+ import { getSessionRuntimeQueueHealth } from "./sessionRuntime";
6
+ import { listSqliteLogCursorEntries } from "./sqliteLogIndex";
7
+ import { getStorageLifecycleInventory, getLastStorageLifecycleGcResult } from "./storageLifecycle";
8
+
9
+ export const PERFORMANCE_METRICS_SCHEMA = "agent-inspector.performance-metrics/v1";
10
+ export const OPERATIONAL_METRICS_SCHEMA = "agent-inspector.operational-metrics/v1";
11
+
12
+ export type SseRuntimeMetrics = {
13
+ activeClients: number;
14
+ capacity: number;
15
+ };
16
+
17
+ export type SafeOperationalMetrics = {
18
+ schema: typeof PERFORMANCE_METRICS_SCHEMA;
19
+ activeUpstream: number;
20
+ queueDepth: number;
21
+ queueBytes: number;
22
+ incompleteCaptures: number;
23
+ finalizerLagMs: number;
24
+ persistenceLagMs: number;
25
+ activeLeases: number;
26
+ runtime: {
27
+ schema: typeof OPERATIONAL_METRICS_SCHEMA;
28
+ collectedAt: string;
29
+ readiness: Awaited<ReturnType<typeof getRuntimeReadiness>>;
30
+ requests: {
31
+ activeProxyRequests: number;
32
+ proxyCapacity: number;
33
+ acceptingRequests: boolean;
34
+ };
35
+ limits: typeof DEFAULT_RUNTIME_BUDGET_POLICY;
36
+ queues: {
37
+ finalizerPendingJobs: number;
38
+ sessionQueuedTasks: number;
39
+ sessionActiveQueues: number;
40
+ sessionQueueLimit: number;
41
+ };
42
+ workers: ReturnType<typeof getFinalizerRuntimeHealth>;
43
+ storage: {
44
+ totalFiles: number;
45
+ totalBytes: number;
46
+ reclaimableBytes: number;
47
+ lastGc: {
48
+ checkedAt: string;
49
+ filesDeleted: number;
50
+ bytesDeleted: number;
51
+ errors: number;
52
+ } | null;
53
+ };
54
+ sse: SseRuntimeMetrics;
55
+ truncation: {
56
+ captureIncompleteLogs: number;
57
+ droppedChunks: number;
58
+ droppedBytes: number;
59
+ source: "sqlite" | "unavailable";
60
+ };
61
+ };
62
+ };
63
+
64
+ async function getTruncationMetrics(): Promise<SafeOperationalMetrics["runtime"]["truncation"]> {
65
+ const entries = await listSqliteLogCursorEntries({});
66
+ if (entries === null) {
67
+ return {
68
+ captureIncompleteLogs: 0,
69
+ droppedChunks: 0,
70
+ droppedBytes: 0,
71
+ source: "unavailable",
72
+ };
73
+ }
74
+
75
+ return {
76
+ captureIncompleteLogs: entries.filter((entry) => entry.summary?.captureIncomplete === true)
77
+ .length,
78
+ droppedChunks: entries.reduce((sum, entry) => sum + (entry.summary?.droppedChunks ?? 0), 0),
79
+ droppedBytes: entries.reduce((sum, entry) => sum + (entry.summary?.droppedBytes ?? 0), 0),
80
+ source: "sqlite",
81
+ };
82
+ }
83
+
84
+ export async function getSafeOperationalMetrics(
85
+ sse: SseRuntimeMetrics,
86
+ ): Promise<SafeOperationalMetrics> {
87
+ const [readiness, truncation] = await Promise.all([
88
+ getRuntimeReadiness(),
89
+ getTruncationMetrics(),
90
+ ]);
91
+ const admission = getRuntimeAdmissionState();
92
+ const workers = getFinalizerRuntimeHealth();
93
+ const sessionQueue = getSessionRuntimeQueueHealth();
94
+ const storageInventory = getStorageLifecycleInventory();
95
+ const lastGc = getLastStorageLifecycleGcResult();
96
+ const reclaimableBytes = storageInventory.artifacts
97
+ .filter(
98
+ (artifact) =>
99
+ artifact.cleanupBehavior === "retention-gc" ||
100
+ artifact.cleanupBehavior === "orphan-reclaim",
101
+ )
102
+ .reduce((sum, artifact) => sum + artifact.bytes, 0);
103
+ const queueDepth = workers.pendingJobs + sessionQueue.queuedTasks;
104
+
105
+ return {
106
+ schema: PERFORMANCE_METRICS_SCHEMA,
107
+ activeUpstream: admission.activeRequests,
108
+ queueDepth,
109
+ queueBytes: 0,
110
+ incompleteCaptures: truncation.captureIncompleteLogs,
111
+ finalizerLagMs: 0,
112
+ persistenceLagMs: 0,
113
+ activeLeases: sse.activeClients,
114
+ runtime: {
115
+ schema: OPERATIONAL_METRICS_SCHEMA,
116
+ collectedAt: new Date().toISOString(),
117
+ readiness,
118
+ requests: {
119
+ activeProxyRequests: admission.activeRequests,
120
+ proxyCapacity: admission.capacity,
121
+ acceptingRequests: admission.acceptingRequests,
122
+ },
123
+ limits: DEFAULT_RUNTIME_BUDGET_POLICY,
124
+ queues: {
125
+ finalizerPendingJobs: workers.pendingJobs,
126
+ sessionQueuedTasks: sessionQueue.queuedTasks,
127
+ sessionActiveQueues: sessionQueue.activeQueues,
128
+ sessionQueueLimit: sessionQueue.queueLimit,
129
+ },
130
+ workers,
131
+ storage: {
132
+ totalFiles: storageInventory.totalFiles,
133
+ totalBytes: storageInventory.totalBytes,
134
+ reclaimableBytes,
135
+ lastGc:
136
+ lastGc === null
137
+ ? null
138
+ : {
139
+ checkedAt: lastGc.checkedAt,
140
+ filesDeleted: lastGc.filesDeleted,
141
+ bytesDeleted: lastGc.bytesDeleted,
142
+ errors: lastGc.errors.length,
143
+ },
144
+ },
145
+ sse,
146
+ truncation,
147
+ },
148
+ };
149
+ }
@@ -5,6 +5,7 @@ export {
5
5
  CapturedLogSchema,
6
6
  JsonValueSchema,
7
7
  StreamingChunkSchema,
8
+ StopReasonSchema,
8
9
  ToolTraceEventSchema,
9
10
  } from "../contracts";
10
11
  export type {
@@ -13,6 +14,7 @@ export type {
13
14
  CaptureIncompleteReason,
14
15
  JsonValue,
15
16
  StreamingChunk,
17
+ StopReason,
16
18
  ToolTraceEvent,
17
19
  TokenUsage,
18
20
  } from "../contracts";
@@ -1,5 +1,6 @@
1
1
  import { logger } from "./logger";
2
2
  import { executeFinalizeLogJob, type FinalizeLogJob } from "./logFinalizer";
3
+ import { DEFAULT_RUNTIME_BUDGET_POLICY } from "../lib/resourceLimits";
3
4
  import {
4
5
  getLogSessionId,
5
6
  markSessionTaskFinished,
@@ -14,11 +15,25 @@ export type SessionTaskName = "finalize-log";
14
15
  type SessionTask<T> = () => Promise<T> | T;
15
16
 
16
17
  const queues = new Map<string, Promise<void>>();
18
+ let queuedTaskCount = 0;
19
+
20
+ export class SessionTaskQueueRejectedError extends Error {
21
+ constructor(limit: number) {
22
+ super(`Session task queue is full; limit=${String(limit)}`);
23
+ this.name = "SessionTaskQueueRejectedError";
24
+ }
25
+ }
17
26
 
18
27
  function queueKeyForSession(sessionId: string | null): string {
19
28
  return sessionId ?? UNASSIGNED_SESSION_QUEUE;
20
29
  }
21
30
 
31
+ function resolveSessionTaskQueueLimit(): number {
32
+ const configured = Number(process.env["SESSION_TASK_QUEUE_LIMIT"]);
33
+ if (Number.isSafeInteger(configured) && configured > 0) return configured;
34
+ return DEFAULT_RUNTIME_BUDGET_POLICY.queues.finalizerJobs;
35
+ }
36
+
22
37
  function errorMessage(err: unknown): string {
23
38
  return err instanceof Error ? err.message : String(err);
24
39
  }
@@ -46,9 +61,19 @@ export function enqueueSessionTask<T>(
46
61
  taskName: SessionTaskName,
47
62
  task: SessionTask<T>,
48
63
  ): Promise<T> {
64
+ const queueLimit = resolveSessionTaskQueueLimit();
65
+ if (queuedTaskCount >= queueLimit) {
66
+ logger.warn(
67
+ `[sessionRuntime] Rejecting ${taskName} for session ${sessionId ?? "unassigned"}: ` +
68
+ `queue full (${String(queuedTaskCount)}/${String(queueLimit)})`,
69
+ );
70
+ return Promise.reject(new SessionTaskQueueRejectedError(queueLimit));
71
+ }
72
+
49
73
  const queueKey = queueKeyForSession(sessionId);
50
74
  const previous = queues.get(queueKey) ?? Promise.resolve();
51
75
 
76
+ queuedTaskCount += 1;
52
77
  markSessionTaskQueued(sessionId);
53
78
 
54
79
  const current = previous
@@ -61,6 +86,7 @@ export function enqueueSessionTask<T>(
61
86
 
62
87
  queues.set(queueKey, settled);
63
88
  void settled.then(() => {
89
+ queuedTaskCount = Math.max(0, queuedTaskCount - 1);
64
90
  if (queues.get(queueKey) === settled) {
65
91
  queues.delete(queueKey);
66
92
  }
@@ -81,3 +107,15 @@ export async function flushSessionTasks(): Promise<void> {
81
107
  await Promise.all([...queues.values()]);
82
108
  }
83
109
  }
110
+
111
+ export function getSessionRuntimeQueueHealth(): {
112
+ activeQueues: number;
113
+ queuedTasks: number;
114
+ queueLimit: number;
115
+ } {
116
+ return {
117
+ activeQueues: queues.size,
118
+ queuedTasks: queuedTaskCount,
119
+ queueLimit: resolveSessionTaskQueueLimit(),
120
+ };
121
+ }