@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.
- package/.output/backend/nitro.json +1 -1
- package/.output/cli.js +140 -20
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-0qlvqhVh.mjs → router-Be6BLuut.mjs} +2454 -1135
- package/.output/server/_tanstack-start-manifest_v-ClTaMkEj.mjs +4 -0
- package/.output/server/index.mjs +1 -1
- package/.output/ui/assets/{CompareDrawer-BOk9hd-Z.js → CompareDrawer-CGk2HFI1.js} +1 -1
- package/.output/ui/assets/{InspectorPet-DhHDBpk7.js → InspectorPet-CDM7SdOT.js} +1 -1
- package/.output/ui/assets/ProxyViewerContainer-CaWD78tw.js +60 -0
- package/.output/ui/assets/{ReplayDialog-DJhKtuNB.js → ReplayDialog-DqaeV1He.js} +1 -1
- package/.output/ui/assets/{RequestAnatomy-hP0tsqDC.js → RequestAnatomy-BtHl9P0k.js} +1 -1
- package/.output/ui/assets/{ResponseView-BSiUkwCb.js → ResponseView-C6t1vpxs.js} +1 -1
- package/.output/ui/assets/{StreamingChunkSequence-CzQqwv4X.js → StreamingChunkSequence-R4mgd3GQ.js} +1 -1
- package/.output/ui/assets/{_sessionId-BU-i6h0V.js → _sessionId-CL3hdLlQ.js} +1 -1
- package/.output/ui/assets/{_sessionId-C2Pq4Jb0.js → _sessionId-HS666S72.js} +1 -1
- package/.output/ui/assets/index-B-Qod-aA.css +1 -0
- package/.output/ui/assets/{index-D8C-t-2U.js → index-Bd9jPL4n.js} +1 -1
- package/.output/ui/assets/{index-zHpVF0jk.js → index-C4NHrclw.js} +1 -1
- package/.output/ui/assets/{index-UUnRwbOJ.js → index-UF1PJEcq.js} +1 -1
- package/.output/ui/assets/{index-B7NeIBDf.js → index-tX38BNV2.js} +2 -2
- package/.output/ui/assets/{json-viewer-DOzS5rUT.js → json-viewer-KSgzN_Ov.js} +1 -1
- package/.output/ui/assets/{jszip.min-CCCY7qNk.js → jszip.min-BiMDxghN.js} +1 -1
- package/.output/ui/index.html +2 -2
- package/.output/workers/logFinalizer.worker.js +261 -0
- package/.output/workers/sessionWorkerEntry.js +261 -0
- package/package.json +13 -6
- package/src/backend/routes/api/logs.$id.replay.ts +6 -1
- package/src/backend/routes/api/logs.stream.ts +7 -0
- package/src/backend/routes/api/providers.$providerId.model-metadata.ts +15 -4
- package/src/backend/routes/api/storage.ts +56 -0
- package/src/backend/routes/metrics.ts +14 -0
- package/src/cli/doctor.ts +135 -2
- package/src/cli.ts +7 -4
- package/src/components/ProxyViewerContainer.tsx +41 -24
- package/src/components/providers/SettingsDialog.tsx +261 -70
- package/src/components/proxy-viewer/LogEntry.tsx +19 -5
- package/src/components/proxy-viewer/bodyHydration.ts +67 -0
- package/src/components/proxy-viewer/proxyViewerContainerLogic.ts +78 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/log.ts +5 -0
- package/src/knowledge/openclawClient.ts +11 -2
- package/src/knowledge/openclawGatewayClient.ts +6 -1
- package/src/lib/resourceLimits.ts +191 -0
- package/src/lib/safeFetch.ts +428 -20
- package/src/lib/stopReason.ts +66 -39
- package/src/proxy/ecosystemTasks.ts +20 -0
- package/src/proxy/handler.ts +17 -4
- package/src/proxy/identityProxy.ts +36 -16
- package/src/proxy/logIndex.ts +57 -6
- package/src/proxy/logger.ts +4 -0
- package/src/proxy/rawStreamCapture.ts +16 -1
- package/src/proxy/runtimeHealth.ts +4 -2
- package/src/proxy/runtimeMetrics.ts +149 -0
- package/src/proxy/schemas.ts +2 -0
- package/src/proxy/sessionRuntime.ts +38 -0
- package/src/proxy/sqliteLogIndex.ts +78 -2
- package/src/proxy/storageLifecycle.ts +432 -0
- package/src/proxy/store.ts +69 -13
- package/.output/server/_tanstack-start-manifest_v-DnbdNeun.mjs +0 -4
- package/.output/ui/assets/ProxyViewerContainer-BRYf987G.js +0 -59
- 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 {
|
|
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
|
+
}
|