@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
@@ -1,7 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { logger, resolveLogDir } from "./logger";
4
- import { ToolTraceEventSchema, type ApiFormat, type CaptureIncompleteReason } from "./schemas";
4
+ import {
5
+ ToolTraceEventSchema,
6
+ type ApiFormat,
7
+ type CaptureIncompleteReason,
8
+ type StopReason,
9
+ } from "./schemas";
5
10
  import type { LogIndexEntry, LogIndexSummary } from "./logIndex";
6
11
  import { ensurePrivateDirectorySync, securePrivateFileSync } from "./privateDataPath";
7
12
 
@@ -52,6 +57,7 @@ CREATE TABLE IF NOT EXISTS log_index (
52
57
  dropped_bytes INTEGER NOT NULL DEFAULT 0,
53
58
  capture_incomplete_reason TEXT,
54
59
  warnings_json TEXT,
60
+ stop_reason TEXT,
55
61
  tool_trace_events_json TEXT,
56
62
  error TEXT
57
63
  );
@@ -99,10 +105,11 @@ INSERT INTO log_index (
99
105
  dropped_bytes,
100
106
  capture_incomplete_reason,
101
107
  warnings_json,
108
+ stop_reason,
102
109
  tool_trace_events_json,
103
110
  error
104
111
  ) VALUES (
105
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
112
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
106
113
  )
107
114
  ON CONFLICT(id) DO UPDATE SET
108
115
  file = excluded.file,
@@ -141,6 +148,7 @@ ON CONFLICT(id) DO UPDATE SET
141
148
  dropped_bytes = excluded.dropped_bytes,
142
149
  capture_incomplete_reason = excluded.capture_incomplete_reason,
143
150
  warnings_json = excluded.warnings_json,
151
+ stop_reason = excluded.stop_reason,
144
152
  tool_trace_events_json = excluded.tool_trace_events_json,
145
153
  error = excluded.error
146
154
  `;
@@ -235,6 +243,7 @@ const REQUIRED_SCHEMA_COLUMNS = [
235
243
  { name: "dropped_chunks", definition: "INTEGER NOT NULL DEFAULT 0" },
236
244
  { name: "dropped_bytes", definition: "INTEGER NOT NULL DEFAULT 0" },
237
245
  { name: "capture_incomplete_reason", definition: "TEXT" },
246
+ { name: "stop_reason", definition: "TEXT" },
238
247
  { name: "tool_trace_events_json", definition: "TEXT" },
239
248
  ];
240
249
 
@@ -446,6 +455,7 @@ function entryParams(entry: LogIndexEntry): readonly unknown[] {
446
455
  summary?.droppedBytes ?? 0,
447
456
  summary?.captureIncompleteReason ?? null,
448
457
  warningJson(summary?.warnings),
458
+ summary?.stopReason ?? null,
449
459
  toolTraceEventsJson(summary),
450
460
  summary?.error ?? null,
451
461
  ];
@@ -496,6 +506,20 @@ function readCaptureIncompleteReason(row: unknown): CaptureIncompleteReason | nu
496
506
  }
497
507
  }
498
508
 
509
+ function readStopReason(row: unknown): StopReason | null {
510
+ const value = readString(row, "stop_reason");
511
+ switch (value) {
512
+ case "end_turn":
513
+ case "tool_use":
514
+ case "stop":
515
+ case "length":
516
+ return value;
517
+ case null:
518
+ default:
519
+ return null;
520
+ }
521
+ }
522
+
499
523
  function readWarnings(row: unknown): string[] | undefined {
500
524
  const value = readString(row, "warnings_json");
501
525
  if (value === null) return undefined;
@@ -566,6 +590,7 @@ function summaryFromRow(row: unknown, id: number): LogIndexSummary {
566
590
  droppedBytes: readNumber(row, "dropped_bytes") ?? 0,
567
591
  captureIncompleteReason: readCaptureIncompleteReason(row),
568
592
  warnings: readWarnings(row),
593
+ stopReason: readStopReason(row),
569
594
  toolTraceEvents: readToolTraceEvents(row),
570
595
  error: readString(row, "error"),
571
596
  };
@@ -602,6 +627,57 @@ export async function upsertSqliteLogIndexEntry(entry: LogIndexEntry): Promise<b
602
627
  return runPrepared(state.db, UPSERT_SQL, entryParams(entry));
603
628
  }
604
629
 
630
+ export type SqliteLogCursorEntryFilters = {
631
+ sessionId?: string;
632
+ model?: string;
633
+ clientPid?: number;
634
+ };
635
+
636
+ function sqliteCursorWhereClause(filters: SqliteLogCursorEntryFilters): {
637
+ sql: string;
638
+ params: unknown[];
639
+ } {
640
+ const clauses: string[] = [];
641
+ const params: unknown[] = [];
642
+ if (filters.sessionId !== undefined) {
643
+ clauses.push("session_id = ?");
644
+ params.push(filters.sessionId);
645
+ }
646
+ if (filters.model !== undefined) {
647
+ clauses.push("model = ?");
648
+ params.push(filters.model);
649
+ }
650
+ if (filters.clientPid !== undefined) {
651
+ clauses.push("client_pid = ?");
652
+ params.push(filters.clientPid);
653
+ }
654
+ return {
655
+ sql: clauses.length === 0 ? "" : ` WHERE ${clauses.join(" AND ")}`,
656
+ params,
657
+ };
658
+ }
659
+
660
+ export async function listSqliteLogCursorEntries(
661
+ filters: SqliteLogCursorEntryFilters,
662
+ ): Promise<LogIndexEntry[] | null> {
663
+ const state = await getSqliteState();
664
+ if (state.status !== "ready") return null;
665
+ const where = sqliteCursorWhereClause(filters);
666
+ const rows = allPrepared(
667
+ state.db,
668
+ `SELECT * FROM log_index${where.sql} ORDER BY id ASC`,
669
+ where.params,
670
+ );
671
+ if (rows === null) return null;
672
+ const entries: LogIndexEntry[] = [];
673
+ for (const row of rows) {
674
+ const entry = entryFromRow(row);
675
+ if (entry === null || entry.summary === undefined) return null;
676
+ entries.push(entry);
677
+ }
678
+ return entries;
679
+ }
680
+
605
681
  export async function syncSqliteLogIndexEntries(
606
682
  entries: readonly LogIndexEntry[],
607
683
  ): Promise<boolean> {
@@ -0,0 +1,432 @@
1
+ import { existsSync, lstatSync, readdirSync, unlinkSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { getChunksDir } from "./chunkStorage";
4
+ import { getDataDir } from "./dataDir";
5
+ import { resolveApplicationLogPath, resolveLogDir } from "./logger";
6
+ import { getRawStreamDir } from "./rawStreamCapture";
7
+ import { getSessionArchiveRoot } from "./sessionArchive";
8
+
9
+ export type StorageArtifactKind =
10
+ | "captured-log-jsonl"
11
+ | "sqlite-log-index"
12
+ | "session-archive"
13
+ | "streaming-chunk"
14
+ | "raw-stream-temp"
15
+ | "application-log"
16
+ | "evidence"
17
+ | "backup";
18
+
19
+ export type StorageArtifactSensitivity = "captured-data" | "secret-adjacent" | "operational";
20
+
21
+ export type StorageArtifactCleanupBehavior =
22
+ | "retention-gc"
23
+ | "orphan-reclaim"
24
+ | "rotation"
25
+ | "manual-owner-delete";
26
+
27
+ export type StorageArtifactInventoryRule = {
28
+ kind: StorageArtifactKind;
29
+ root: string;
30
+ sensitivity: StorageArtifactSensitivity;
31
+ cleanupBehavior: StorageArtifactCleanupBehavior;
32
+ shouldCount: (fileName: string, path: string) => boolean;
33
+ };
34
+
35
+ export type StorageArtifactSummary = {
36
+ kind: StorageArtifactKind;
37
+ root: string;
38
+ sensitivity: StorageArtifactSensitivity;
39
+ cleanupBehavior: StorageArtifactCleanupBehavior;
40
+ fileCount: number;
41
+ bytes: number;
42
+ oldestModifiedAt: string | null;
43
+ newestModifiedAt: string | null;
44
+ };
45
+
46
+ export type StorageLifecycleInventory = {
47
+ checkedAt: string;
48
+ totalFiles: number;
49
+ totalBytes: number;
50
+ artifacts: StorageArtifactSummary[];
51
+ };
52
+
53
+ export type StorageLifecycleQuotaPolicy = {
54
+ maxAgeMs: number;
55
+ maxTotalBytes: number;
56
+ };
57
+
58
+ export type StorageLifecycleGcError = {
59
+ kind: StorageArtifactKind;
60
+ path: string;
61
+ message: string;
62
+ };
63
+
64
+ export type StorageLifecycleGcResult = {
65
+ checkedAt: string;
66
+ policy: StorageLifecycleQuotaPolicy;
67
+ before: StorageLifecycleInventory;
68
+ after: StorageLifecycleInventory;
69
+ filesScanned: number;
70
+ bytesScanned: number;
71
+ filesDeleted: number;
72
+ bytesDeleted: number;
73
+ reclaimableBytes: number;
74
+ errors: StorageLifecycleGcError[];
75
+ };
76
+
77
+ type MutableDirectoryStats = {
78
+ fileCount: number;
79
+ bytes: number;
80
+ oldestModifiedMs: number | null;
81
+ newestModifiedMs: number | null;
82
+ };
83
+
84
+ type StorageArtifactFile = {
85
+ rule: StorageArtifactInventoryRule;
86
+ path: string;
87
+ bytes: number;
88
+ modifiedMs: number;
89
+ };
90
+
91
+ const MAX_INVENTORY_DEPTH = 8;
92
+ const DAY_MS = 24 * 60 * 60 * 1000;
93
+ const DEFAULT_RETENTION_DAYS = 7;
94
+ const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
95
+ const DEFAULT_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1000;
96
+
97
+ let maintenanceTimer: ReturnType<typeof setInterval> | null = null;
98
+ let lastStorageLifecycleGcResult: StorageLifecycleGcResult | null = null;
99
+
100
+ function emptyStats(): MutableDirectoryStats {
101
+ return {
102
+ fileCount: 0,
103
+ bytes: 0,
104
+ oldestModifiedMs: null,
105
+ newestModifiedMs: null,
106
+ };
107
+ }
108
+
109
+ function recordFile(stats: MutableDirectoryStats, bytes: number, modifiedMs: number): void {
110
+ stats.fileCount += 1;
111
+ stats.bytes += bytes;
112
+ stats.oldestModifiedMs =
113
+ stats.oldestModifiedMs === null ? modifiedMs : Math.min(stats.oldestModifiedMs, modifiedMs);
114
+ stats.newestModifiedMs =
115
+ stats.newestModifiedMs === null ? modifiedMs : Math.max(stats.newestModifiedMs, modifiedMs);
116
+ }
117
+
118
+ function isoFromMs(value: number | null): string | null {
119
+ return value === null ? null : new Date(value).toISOString();
120
+ }
121
+
122
+ function isPathInsideRoot(root: string, path: string): boolean {
123
+ const relativePath = relative(resolve(root), resolve(path));
124
+ return (
125
+ relativePath.length === 0 ||
126
+ (!relativePath.startsWith("..") &&
127
+ !relativePath.startsWith("/") &&
128
+ !relativePath.startsWith("\\"))
129
+ );
130
+ }
131
+
132
+ function collectRuleFiles(rule: StorageArtifactInventoryRule): StorageArtifactFile[] {
133
+ const files: StorageArtifactFile[] = [];
134
+ if (!existsSync(rule.root)) return files;
135
+
136
+ const visit = (currentDir: string, depth: number): void => {
137
+ if (depth > MAX_INVENTORY_DEPTH) return;
138
+ let entries: string[];
139
+ try {
140
+ entries = readdirSync(currentDir);
141
+ } catch {
142
+ return;
143
+ }
144
+
145
+ for (const entry of entries) {
146
+ const path = join(currentDir, entry);
147
+ if (!isPathInsideRoot(rule.root, path)) continue;
148
+ try {
149
+ const fileStats = lstatSync(path);
150
+ if (fileStats.isDirectory()) {
151
+ visit(path, depth + 1);
152
+ continue;
153
+ }
154
+ if (!fileStats.isFile() || !rule.shouldCount(entry, path)) continue;
155
+ files.push({ rule, path, bytes: fileStats.size, modifiedMs: fileStats.mtimeMs });
156
+ } catch {
157
+ // Files may disappear while inventory is collected; keep inventory best-effort and safe.
158
+ }
159
+ }
160
+ };
161
+
162
+ visit(rule.root, 0);
163
+ return files;
164
+ }
165
+
166
+ function collectRuleStats(rule: StorageArtifactInventoryRule): MutableDirectoryStats {
167
+ const stats = emptyStats();
168
+ for (const file of collectRuleFiles(rule)) {
169
+ recordFile(stats, file.bytes, file.modifiedMs);
170
+ }
171
+ return stats;
172
+ }
173
+
174
+ function isAutoDeletableCleanupBehavior(behavior: StorageArtifactCleanupBehavior): boolean {
175
+ switch (behavior) {
176
+ case "retention-gc":
177
+ case "orphan-reclaim":
178
+ return true;
179
+ case "rotation":
180
+ case "manual-owner-delete":
181
+ return false;
182
+ }
183
+ }
184
+
185
+ function summarizeRule(rule: StorageArtifactInventoryRule): StorageArtifactSummary {
186
+ const stats = collectRuleStats(rule);
187
+ return {
188
+ kind: rule.kind,
189
+ root: rule.root,
190
+ sensitivity: rule.sensitivity,
191
+ cleanupBehavior: rule.cleanupBehavior,
192
+ fileCount: stats.fileCount,
193
+ bytes: stats.bytes,
194
+ oldestModifiedAt: isoFromMs(stats.oldestModifiedMs),
195
+ newestModifiedAt: isoFromMs(stats.newestModifiedMs),
196
+ };
197
+ }
198
+
199
+ function isSessionArchiveFile(fileName: string): boolean {
200
+ return fileName === "session.sqlite" || fileName.startsWith("session.sqlite-");
201
+ }
202
+
203
+ function isSqliteLogIndexFile(fileName: string): boolean {
204
+ return fileName === "inspector.sqlite" || fileName.startsWith("inspector.sqlite-");
205
+ }
206
+
207
+ function isBackupFile(fileName: string): boolean {
208
+ return fileName.endsWith(".bak") || fileName.endsWith(".backup");
209
+ }
210
+
211
+ function applicationLogMatcher(logPath: string): (fileName: string, path: string) => boolean {
212
+ const directory = dirname(logPath);
213
+ const baseName = basename(logPath);
214
+ return (fileName, path) => {
215
+ if (dirname(path) !== directory) return false;
216
+ if (fileName === baseName) return true;
217
+ return fileName.startsWith(`${baseName}.`) && /^\d+$/.test(fileName.slice(baseName.length + 1));
218
+ };
219
+ }
220
+
221
+ function parsePositiveIntegerEnv(name: string, fallback: number): number {
222
+ const value = process.env[name];
223
+ if (value === undefined) return fallback;
224
+ const parsed = Number.parseInt(value, 10);
225
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
226
+ return parsed;
227
+ }
228
+
229
+ export function resolveStorageLifecycleQuotaPolicy(): StorageLifecycleQuotaPolicy {
230
+ const retentionDays = parsePositiveIntegerEnv(
231
+ "AGENT_INSPECTOR_STORAGE_RETENTION_DAYS",
232
+ parsePositiveIntegerEnv("LOG_RETENTION_DAYS", DEFAULT_RETENTION_DAYS),
233
+ );
234
+ return {
235
+ maxAgeMs: retentionDays * DAY_MS,
236
+ maxTotalBytes: parsePositiveIntegerEnv(
237
+ "AGENT_INSPECTOR_STORAGE_MAX_BYTES",
238
+ DEFAULT_MAX_TOTAL_BYTES,
239
+ ),
240
+ };
241
+ }
242
+
243
+ export function getStorageLifecycleInventoryRules(): StorageArtifactInventoryRule[] {
244
+ const applicationLogPath = resolveApplicationLogPath();
245
+ return [
246
+ {
247
+ kind: "captured-log-jsonl",
248
+ root: resolveLogDir(),
249
+ sensitivity: "captured-data",
250
+ cleanupBehavior: "retention-gc",
251
+ shouldCount: (fileName) => fileName.endsWith(".jsonl"),
252
+ },
253
+ {
254
+ kind: "sqlite-log-index",
255
+ root: resolveLogDir(),
256
+ sensitivity: "captured-data",
257
+ cleanupBehavior: "orphan-reclaim",
258
+ shouldCount: isSqliteLogIndexFile,
259
+ },
260
+ {
261
+ kind: "session-archive",
262
+ root: getSessionArchiveRoot(),
263
+ sensitivity: "captured-data",
264
+ cleanupBehavior: "retention-gc",
265
+ shouldCount: isSessionArchiveFile,
266
+ },
267
+ {
268
+ kind: "streaming-chunk",
269
+ root: getChunksDir(),
270
+ sensitivity: "captured-data",
271
+ cleanupBehavior: "retention-gc",
272
+ shouldCount: (fileName) => fileName.endsWith(".json"),
273
+ },
274
+ {
275
+ kind: "raw-stream-temp",
276
+ root: getRawStreamDir(),
277
+ sensitivity: "captured-data",
278
+ cleanupBehavior: "orphan-reclaim",
279
+ shouldCount: () => true,
280
+ },
281
+ {
282
+ kind: "application-log",
283
+ root: dirname(applicationLogPath),
284
+ sensitivity: "operational",
285
+ cleanupBehavior: "rotation",
286
+ shouldCount: applicationLogMatcher(applicationLogPath),
287
+ },
288
+ {
289
+ kind: "evidence",
290
+ root: join(getDataDir(), "evidence"),
291
+ sensitivity: "captured-data",
292
+ cleanupBehavior: "manual-owner-delete",
293
+ shouldCount: () => true,
294
+ },
295
+ {
296
+ kind: "backup",
297
+ root: getDataDir(),
298
+ sensitivity: "secret-adjacent",
299
+ cleanupBehavior: "orphan-reclaim",
300
+ shouldCount: isBackupFile,
301
+ },
302
+ ];
303
+ }
304
+
305
+ export function getStorageLifecycleInventory(
306
+ rules: readonly StorageArtifactInventoryRule[] = getStorageLifecycleInventoryRules(),
307
+ ): StorageLifecycleInventory {
308
+ const artifacts = rules.map(summarizeRule);
309
+ return {
310
+ checkedAt: new Date().toISOString(),
311
+ totalFiles: artifacts.reduce((sum, artifact) => sum + artifact.fileCount, 0),
312
+ totalBytes: artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0),
313
+ artifacts,
314
+ };
315
+ }
316
+
317
+ function collectLifecycleFiles(
318
+ rules: readonly StorageArtifactInventoryRule[] = getStorageLifecycleInventoryRules(),
319
+ ): StorageArtifactFile[] {
320
+ return rules.flatMap((rule) => collectRuleFiles(rule));
321
+ }
322
+
323
+ function selectGcCandidates(
324
+ files: readonly StorageArtifactFile[],
325
+ policy: StorageLifecycleQuotaPolicy,
326
+ nowMs: number,
327
+ ): StorageArtifactFile[] {
328
+ const autoDeletableFiles = files.filter((file) =>
329
+ isAutoDeletableCleanupBehavior(file.rule.cleanupBehavior),
330
+ );
331
+ const expiredFiles = autoDeletableFiles.filter(
332
+ (file) => nowMs - file.modifiedMs > policy.maxAgeMs,
333
+ );
334
+ const expiredPaths = new Set(expiredFiles.map((file) => file.path));
335
+ const candidates = [...expiredFiles];
336
+ let totalBytes =
337
+ files.reduce((sum, file) => sum + file.bytes, 0) -
338
+ expiredFiles.reduce((sum, file) => sum + file.bytes, 0);
339
+
340
+ if (totalBytes <= policy.maxTotalBytes) return candidates;
341
+
342
+ const quotaCandidates = autoDeletableFiles
343
+ .filter((file) => !expiredPaths.has(file.path))
344
+ .sort((a, b) => a.modifiedMs - b.modifiedMs);
345
+
346
+ for (const file of quotaCandidates) {
347
+ if (totalBytes <= policy.maxTotalBytes) break;
348
+ candidates.push(file);
349
+ totalBytes -= file.bytes;
350
+ }
351
+
352
+ return candidates;
353
+ }
354
+
355
+ function deleteLifecycleFile(file: StorageArtifactFile): StorageLifecycleGcError | null {
356
+ if (!isPathInsideRoot(file.rule.root, file.path)) {
357
+ return { kind: file.rule.kind, path: file.path, message: "path outside lifecycle root" };
358
+ }
359
+ try {
360
+ unlinkSync(file.path);
361
+ return null;
362
+ } catch (err) {
363
+ if (err instanceof Error && err.message.includes("ENOENT")) return null;
364
+ return { kind: file.rule.kind, path: file.path, message: String(err) };
365
+ }
366
+ }
367
+
368
+ export function runStorageLifecycleGc(
369
+ policy: StorageLifecycleQuotaPolicy = resolveStorageLifecycleQuotaPolicy(),
370
+ rules: readonly StorageArtifactInventoryRule[] = getStorageLifecycleInventoryRules(),
371
+ nowMs: number = Date.now(),
372
+ ): StorageLifecycleGcResult {
373
+ const before = getStorageLifecycleInventory(rules);
374
+ const files = collectLifecycleFiles(rules);
375
+ const candidates = selectGcCandidates(files, policy, nowMs);
376
+ const errors: StorageLifecycleGcError[] = [];
377
+ let filesDeleted = 0;
378
+ let bytesDeleted = 0;
379
+
380
+ for (const file of candidates) {
381
+ const error = deleteLifecycleFile(file);
382
+ if (error !== null) {
383
+ errors.push(error);
384
+ continue;
385
+ }
386
+ filesDeleted += 1;
387
+ bytesDeleted += file.bytes;
388
+ }
389
+
390
+ const after = getStorageLifecycleInventory(rules);
391
+ const autoDeletableBytes = after.artifacts
392
+ .filter((artifact) => isAutoDeletableCleanupBehavior(artifact.cleanupBehavior))
393
+ .reduce((sum, artifact) => sum + artifact.bytes, 0);
394
+
395
+ const result = {
396
+ checkedAt: new Date(nowMs).toISOString(),
397
+ policy,
398
+ before,
399
+ after,
400
+ filesScanned: files.length,
401
+ bytesScanned: files.reduce((sum, file) => sum + file.bytes, 0),
402
+ filesDeleted,
403
+ bytesDeleted,
404
+ reclaimableBytes: autoDeletableBytes,
405
+ errors,
406
+ };
407
+ lastStorageLifecycleGcResult = result;
408
+ return result;
409
+ }
410
+
411
+ export function getLastStorageLifecycleGcResult(): StorageLifecycleGcResult | null {
412
+ return lastStorageLifecycleGcResult;
413
+ }
414
+
415
+ export function installStorageLifecycleMaintenance(): void {
416
+ if (maintenanceTimer !== null) return;
417
+ if (process.env["NODE_ENV"] === "test") return;
418
+ const intervalMs = parsePositiveIntegerEnv(
419
+ "AGENT_INSPECTOR_STORAGE_GC_INTERVAL_MS",
420
+ DEFAULT_MAINTENANCE_INTERVAL_MS,
421
+ );
422
+ maintenanceTimer = setInterval(() => {
423
+ runStorageLifecycleGc();
424
+ }, intervalMs);
425
+ maintenanceTimer.unref();
426
+ }
427
+
428
+ export function stopStorageLifecycleMaintenanceForTests(): void {
429
+ if (maintenanceTimer === null) return;
430
+ clearInterval(maintenanceTimer);
431
+ maintenanceTimer = null;
432
+ }