mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 00:52:10 -06:00
0b8aabe864
* docs: document markdown marker renderer * docs: document rendered markdown chunking * docs: document markdown text chunking * docs: document shared text chunking * docs: document plugin text chunking exports * docs: document avatar policy constants * docs: document node match candidates * docs: document scoped expiring id cache * docs: document runtime import normalization * docs: document string sample summaries * docs: document session usage timeseries types * docs: document session usage response types * docs: document manifest frontmatter shapes * docs: document channel route input metadata * docs: document pair loop guard settings * docs: document migration config patch helpers * docs: document api provider registry * docs: document tool call repair payloads * docs: document plugin tool payload helpers * docs: document lazy promise loader * docs: document store writer queue state * docs: document thread binding lifecycle * docs: document concurrency helper contract * docs: document gateway client info contract * docs: document delivery context contracts * docs: document secret ref defaults contract * docs: document command gating contract * docs: document avatar policy contract * docs: document node match policy * docs: document message channel normalization * docs: document boolean parsing contract * docs: document zod parse helpers * docs: document direct dm guard policy * docs: document fixed window limiter contract * docs: document node presence event contract * docs: document secret normalization contract * docs: document progress draft line removal * docs: document usage formatting contracts * docs: document agent run status contract * docs: document runtime import helpers * docs: document provider utility ownership * docs: document invalid config helpers * docs: document json compat parser * docs: document channel config metadata ownership * docs: document channel logging helpers * docs: document sender identity validation ownership * docs: document string sampling helper * docs: document global singleton helpers * docs: document transcript tool helpers * docs: document exec safe-bin normalization * docs: document reaction level resolver * docs: document account snapshot redaction boundary * docs: document messaging target helpers * docs: document thread binding messages * docs: document conversation binding context * docs: document conversation resolution helper * docs: document owner display secret retention * docs: document provider request config types * docs: document skills config types * docs: document memory config types * docs: document imessage config types * docs: document crestodian config types * docs: document tools config policies * docs: document shared config base types * docs: document channel config contracts * docs: document openclaw config state types * docs: document model config contracts * docs: document shared agent config types * docs: document agent defaults config types * docs: document secret input contracts * docs: document auth config contracts * docs: document gateway config contracts * docs: document tool call stream repair contracts * docs: document memory host facades * docs: document llm core contracts * docs: document markdown core contracts * docs: document gateway connect error contracts * docs: document gateway protocol primitives * docs: document gateway frame schemas * docs: document gateway device schemas * docs: document gateway environment schemas * docs: document gateway push schemas * docs: document gateway plugin schemas * docs: document gateway artifact schemas * docs: document gateway command schemas * docs: document gateway task schemas * docs: document gateway exec approval schemas * docs: document gateway secret schemas * docs: document gateway config schemas * docs: document gateway snapshot schemas * docs: document gateway chat schemas * docs: document gateway wizard schemas * docs: document gateway node schemas * docs: document gateway plugin approval schemas * docs: document gateway talk schemas * docs: document gateway agent schemas * docs: document gateway session schemas * docs: document gateway cron schemas * docs: document gateway agent model skill schemas * docs: document gateway skill proposal tool schemas * docs: document gateway protocol registry * docs: document gateway channel status schemas * docs: document gateway schema regression tests * docs: document gateway schema barrel * docs: document gateway validator tests * docs: document gateway primitive push tests * docs: document gateway contract tests * docs: document native protocol guard * docs: document channel schema tests * docs: document gateway protocol smoke tests * docs: document gateway protocol entrypoint * docs: document gateway protocol type exports * docs: document gateway error codes * docs: document protocol schema registry * docs: document talk audio codec * docs: document talk activation names * docs: document talk consult questions * docs: document talk consult tool * docs: document talk run control contracts * docs: document talk run control adapter * docs: document talkback consult queue * docs: document talk consult transcript guard * docs: document talk fast context runtime * docs: document forced talk consult coordinator * docs: document talk output activity tracker * docs: document talk event metrics * docs: document talk diagnostics * docs: document talk observability hook * docs: document talk provider resolver * docs: document talk provider registry * docs: document talk runtime primitives * docs: document talk consult controller logs * docs: document channel identity helpers * docs: document channel account allowlist helpers * docs: document channel metadata draft controls * docs: document channel ingress policy * docs: document channel sender access gates * docs: document channel catalog message contracts * docs: document channel account plugin helpers * docs: document configured binding helpers * docs: document channel acp approval config helpers * docs: document channel bundled config write helpers * docs: document channel plugin utility contracts * docs: document channel config access helpers * docs: document channel message action helpers * docs: document channel outbound runtime helpers * docs: document channel pairing promotion helpers * docs: document channel registry helpers * docs: document channel setup wizard helpers * docs: document channel lifecycle status helpers * docs: document channel target thread helpers * docs: document channel session binding helpers * docs: document channel package module probes * docs: document channel setup wizard contracts * docs: document channel plugin API barrels * docs: document channel contract test helpers * docs: document channel core helpers * docs: document small core facades * docs: document provider runtime helpers * docs: document persistence and realtime helpers * docs: document mcp and state helpers * docs: document tool planner contracts * docs: document music generation runtime * docs: document crestodian command flow * docs: document utility helpers * docs: document node host helpers * docs: document transcript contracts * docs: document trajectory export contracts * docs: document image generation contracts * docs: document routing helper contracts * docs: document session helper contracts * docs: document video generation contracts * docs: document model catalog contracts * docs: document proxy capture contracts * docs: document status rendering contracts * docs: document test helper contracts * docs: document wizard setup contracts * docs: document process contracts * docs: document memory host sdk contracts * docs: document tts contracts * docs: document secrets runtime contracts * docs: document shared helper contracts * docs: document hook runtime contracts * docs: document security audit contracts * docs: document flow contracts * docs: document media understanding contracts * docs: document tui contracts * docs: document logging contracts * docs: document llm contracts * docs: document cron contracts * docs: document daemon contracts * docs: document task contracts * docs: document acp contracts * docs: document test utility contracts * docs: document skill contracts * docs: document config contracts * docs: document outbound infra contracts * docs: document command analysis contracts * docs: document provider usage infra contracts * docs: document file safety infra contracts * docs: document exec approval infra contracts * docs: document gateway runtime infra contracts * docs: document infra utility contracts * docs: document infra queue storage contracts * docs: document heartbeat infra contracts * docs: document remaining infra contracts * docs: document gateway auth contracts * docs: document gateway display helpers * docs: document gateway http helpers * docs: document gateway node helpers * docs: document gateway mcp helpers * docs: document gateway support helpers * docs: document gateway server runtime helpers * docs: document gateway runtime bootstrap helpers * docs: document gateway session events * docs: document gateway utility helpers * docs: document gateway talk helpers * docs: document gateway helper contracts * docs: document gateway server method helpers * docs: document gateway server auth helpers * docs: document gateway server tests * docs: document gateway test helpers * docs: document gateway node tests * docs: document gateway channel tests * docs: document gateway session tests * docs: document gateway server startup tests * docs: document gateway tool test helpers * docs: document gateway server test helpers * docs: document gateway server method tests * docs: document remaining gateway tests * docs: document plugin sdk public subpaths * docs: document plugin sdk runtime helpers * docs: document plugin sdk memory provider helpers * docs: document plugin sdk runtime facades * docs: document plugin sdk command approval helpers * docs: document plugin sdk runtime types * docs: document plugin sdk browser account helpers * docs: document plugin sdk media memory helpers * docs: document plugin sdk core tests * docs: document plugin sdk contract helpers * docs: document plugin sdk test helpers * docs: document remaining plugin sdk tests * docs: document cli utility helpers * docs: document cli runtime helpers * docs: document cli command registration helpers * docs: document node cli helpers * docs: document cli program registration * docs: document message cli registration * docs: document daemon cli helpers * docs: document cli route parsers
547 lines
18 KiB
TypeScript
547 lines
18 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
import { normalizeNullableString as normalizeObservedValue } from "@openclaw/normalization-core/string-coerce";
|
|
import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
|
|
import { requireNodeSqlite } from "../infra/node-sqlite.js";
|
|
import { configureSqliteWalMaintenance, type SqliteWalMaintenance } from "../infra/sqlite-wal.js";
|
|
import { readCaptureBlobText, writeCaptureBlob } from "./blob-store.js";
|
|
import type {
|
|
CaptureBlobRecord,
|
|
CaptureEventRecord,
|
|
CaptureObservedDimension,
|
|
CaptureQueryPreset,
|
|
CaptureQueryRow,
|
|
CaptureSessionCoverageSummary,
|
|
CaptureSessionRecord,
|
|
CaptureSessionSummary,
|
|
} from "./types.js";
|
|
|
|
// SQLite-backed debug proxy store. Metadata stays in SQLite; large payloads are
|
|
// compressed into the blob directory and referenced by hash.
|
|
function ensureParentDir(filePath: string) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
}
|
|
|
|
type OpenedDatabase = {
|
|
db: DatabaseSync;
|
|
walMaintenance: SqliteWalMaintenance;
|
|
};
|
|
|
|
function openDatabase(dbPath: string): OpenedDatabase {
|
|
ensureParentDir(dbPath);
|
|
const { DatabaseSync } = requireNodeSqlite();
|
|
const db = new DatabaseSync(dbPath);
|
|
const walMaintenance = configureSqliteWalMaintenance(db);
|
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS capture_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
started_at INTEGER NOT NULL,
|
|
ended_at INTEGER,
|
|
mode TEXT NOT NULL,
|
|
source_scope TEXT NOT NULL,
|
|
source_process TEXT NOT NULL,
|
|
proxy_url TEXT,
|
|
db_path TEXT NOT NULL,
|
|
blob_dir TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS capture_events (
|
|
id INTEGER PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
ts INTEGER NOT NULL,
|
|
source_scope TEXT NOT NULL,
|
|
source_process TEXT NOT NULL,
|
|
protocol TEXT NOT NULL,
|
|
direction TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
flow_id TEXT NOT NULL,
|
|
method TEXT,
|
|
host TEXT,
|
|
path TEXT,
|
|
status INTEGER,
|
|
close_code INTEGER,
|
|
content_type TEXT,
|
|
headers_json TEXT,
|
|
data_text TEXT,
|
|
data_blob_id TEXT,
|
|
data_sha256 TEXT,
|
|
error_text TEXT,
|
|
meta_json TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS capture_events_session_ts_idx ON capture_events(session_id, ts);
|
|
CREATE INDEX IF NOT EXISTS capture_events_flow_idx ON capture_events(flow_id, ts);
|
|
`);
|
|
return { db, walMaintenance };
|
|
}
|
|
|
|
function serializeJson(value: unknown): string | null {
|
|
return value == null ? null : JSON.stringify(value);
|
|
}
|
|
|
|
// Metadata is optional and user/tool supplied, so parse defensively for coverage
|
|
// summaries instead of assuming every event has valid JSON.
|
|
function parseMetaJson(metaJson: unknown): Record<string, unknown> | null {
|
|
if (typeof metaJson !== "string" || metaJson.trim().length === 0) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(metaJson) as unknown;
|
|
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function sortObservedCounts(counts: Map<string, number>): CaptureObservedDimension[] {
|
|
return [...counts.entries()]
|
|
.map(([value, count]) => ({ value, count }))
|
|
.toSorted((left, right) => right.count - left.count || left.value.localeCompare(right.value));
|
|
}
|
|
|
|
export class DebugProxyCaptureStore {
|
|
readonly db: DatabaseSync;
|
|
private readonly walMaintenance: SqliteWalMaintenance;
|
|
private closed = false;
|
|
|
|
constructor(
|
|
readonly dbPath: string,
|
|
readonly blobDir: string,
|
|
) {
|
|
const opened = openDatabase(dbPath);
|
|
this.db = opened.db;
|
|
this.walMaintenance = opened.walMaintenance;
|
|
}
|
|
|
|
close(): void {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
this.walMaintenance.close();
|
|
this.db.close();
|
|
this.closed = true;
|
|
}
|
|
|
|
get isClosed(): boolean {
|
|
return this.closed;
|
|
}
|
|
|
|
upsertSession(session: CaptureSessionRecord): void {
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO capture_sessions (
|
|
id, started_at, ended_at, mode, source_scope, source_process, proxy_url, db_path, blob_dir
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
ended_at=excluded.ended_at,
|
|
proxy_url=excluded.proxy_url,
|
|
source_process=excluded.source_process`,
|
|
)
|
|
.run(
|
|
session.id,
|
|
session.startedAt,
|
|
session.endedAt ?? null,
|
|
session.mode,
|
|
session.sourceScope,
|
|
session.sourceProcess,
|
|
session.proxyUrl ?? null,
|
|
session.dbPath,
|
|
session.blobDir,
|
|
);
|
|
}
|
|
|
|
endSession(sessionId: string, endedAt = Date.now()): void {
|
|
this.db
|
|
.prepare(`UPDATE capture_sessions SET ended_at = ? WHERE id = ?`)
|
|
.run(endedAt, sessionId);
|
|
}
|
|
|
|
persistPayload(data: Buffer, contentType?: string): CaptureBlobRecord {
|
|
return writeCaptureBlob({ blobDir: this.blobDir, data, contentType });
|
|
}
|
|
|
|
recordEvent(event: CaptureEventRecord): void {
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO capture_events (
|
|
session_id, ts, source_scope, source_process, protocol, direction, kind, flow_id,
|
|
method, host, path, status, close_code, content_type, headers_json,
|
|
data_text, data_blob_id, data_sha256, error_text, meta_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
event.sessionId,
|
|
event.ts,
|
|
event.sourceScope,
|
|
event.sourceProcess,
|
|
event.protocol,
|
|
event.direction,
|
|
event.kind,
|
|
event.flowId,
|
|
event.method ?? null,
|
|
event.host ?? null,
|
|
event.path ?? null,
|
|
event.status ?? null,
|
|
event.closeCode ?? null,
|
|
event.contentType ?? null,
|
|
event.headersJson ?? null,
|
|
event.dataText ?? null,
|
|
event.dataBlobId ?? null,
|
|
event.dataSha256 ?? null,
|
|
event.errorText ?? null,
|
|
event.metaJson ?? null,
|
|
);
|
|
}
|
|
|
|
listSessions(limit = 50): CaptureSessionSummary[] {
|
|
return this.db
|
|
.prepare(
|
|
`SELECT
|
|
s.id,
|
|
s.started_at AS startedAt,
|
|
s.ended_at AS endedAt,
|
|
s.mode,
|
|
s.source_process AS sourceProcess,
|
|
s.proxy_url AS proxyUrl,
|
|
COUNT(e.id) AS eventCount
|
|
FROM capture_sessions s
|
|
LEFT JOIN capture_events e ON e.session_id = s.id
|
|
GROUP BY s.id
|
|
ORDER BY s.started_at DESC
|
|
LIMIT ?`,
|
|
)
|
|
.all(limit) as CaptureSessionSummary[];
|
|
}
|
|
|
|
getSessionEvents(sessionId: string, limit = 500): Array<Record<string, unknown>> {
|
|
return this.db
|
|
.prepare(
|
|
`SELECT
|
|
id, session_id AS sessionId, ts, source_scope AS sourceScope, source_process AS sourceProcess,
|
|
protocol, direction, kind, flow_id AS flowId, method, host, path, status, close_code AS closeCode,
|
|
content_type AS contentType, headers_json AS headersJson, data_text AS dataText,
|
|
data_blob_id AS dataBlobId, data_sha256 AS dataSha256, error_text AS errorText, meta_json AS metaJson
|
|
FROM capture_events
|
|
WHERE session_id = ?
|
|
ORDER BY ts DESC, id DESC
|
|
LIMIT ?`,
|
|
)
|
|
.all(sessionId, limit) as Array<Record<string, unknown>>;
|
|
}
|
|
|
|
summarizeSessionCoverage(sessionId: string): CaptureSessionCoverageSummary {
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT host, meta_json AS metaJson
|
|
FROM capture_events
|
|
WHERE session_id = ?`,
|
|
)
|
|
.all(sessionId) as Array<{ host?: string | null; metaJson?: string | null }>;
|
|
const providers = new Map<string, number>();
|
|
const apis = new Map<string, number>();
|
|
const models = new Map<string, number>();
|
|
const hosts = new Map<string, number>();
|
|
const localPeers = new Map<string, number>();
|
|
let unlabeledEventCount = 0;
|
|
for (const row of rows) {
|
|
const meta = parseMetaJson(row.metaJson);
|
|
const provider = normalizeObservedValue(meta?.provider);
|
|
const api = normalizeObservedValue(meta?.api);
|
|
const model = normalizeObservedValue(meta?.model);
|
|
const host = normalizeObservedValue(row.host);
|
|
if (!provider && !api && !model) {
|
|
unlabeledEventCount += 1;
|
|
}
|
|
if (provider) {
|
|
providers.set(provider, (providers.get(provider) ?? 0) + 1);
|
|
}
|
|
if (api) {
|
|
apis.set(api, (apis.get(api) ?? 0) + 1);
|
|
}
|
|
if (model) {
|
|
models.set(model, (models.get(model) ?? 0) + 1);
|
|
}
|
|
if (host) {
|
|
hosts.set(host, (hosts.get(host) ?? 0) + 1);
|
|
// Local model/provider endpoints are useful to surface separately when
|
|
// debugging why cloud-provider labels are absent.
|
|
if (
|
|
host === "127.0.0.1:11434" ||
|
|
host.startsWith("127.0.0.1:") ||
|
|
host.startsWith("localhost:")
|
|
) {
|
|
localPeers.set(host, (localPeers.get(host) ?? 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
sessionId,
|
|
totalEvents: rows.length,
|
|
unlabeledEventCount,
|
|
providers: sortObservedCounts(providers),
|
|
apis: sortObservedCounts(apis),
|
|
models: sortObservedCounts(models),
|
|
hosts: sortObservedCounts(hosts),
|
|
localPeers: sortObservedCounts(localPeers),
|
|
};
|
|
}
|
|
|
|
readBlob(blobId: string): string | null {
|
|
const row = this.db
|
|
.prepare(`SELECT data_blob_id AS blobId FROM capture_events WHERE data_blob_id = ? LIMIT 1`)
|
|
.get(blobId) as { blobId?: string } | undefined;
|
|
if (!row?.blobId) {
|
|
return null;
|
|
}
|
|
const blobPath = path.join(this.blobDir, `${row.blobId}.bin.gz`);
|
|
return fs.existsSync(blobPath) ? readCaptureBlobText(blobPath) : null;
|
|
}
|
|
|
|
queryPreset(preset: CaptureQueryPreset, sessionId?: string): CaptureQueryRow[] {
|
|
const sessionWhere = sessionId ? "AND session_id = ?" : "";
|
|
const args = sessionId ? [sessionId] : [];
|
|
switch (preset) {
|
|
// Presets are intentionally SQL-only summaries so the CLI can query large
|
|
// capture sessions without loading every event into memory.
|
|
case "double-sends":
|
|
return this.db
|
|
.prepare(
|
|
`SELECT host, path, method, COUNT(*) AS duplicateCount
|
|
FROM capture_events
|
|
WHERE kind = 'request' ${sessionWhere}
|
|
GROUP BY host, path, method, data_sha256
|
|
HAVING COUNT(*) > 1
|
|
ORDER BY duplicateCount DESC, host ASC`,
|
|
)
|
|
.all(...args) as CaptureQueryRow[];
|
|
case "retry-storms":
|
|
return this.db
|
|
.prepare(
|
|
`SELECT host, path, COUNT(*) AS errorCount
|
|
FROM capture_events
|
|
WHERE kind = 'response' AND status >= 429 ${sessionWhere}
|
|
GROUP BY host, path
|
|
HAVING COUNT(*) > 1
|
|
ORDER BY errorCount DESC, host ASC`,
|
|
)
|
|
.all(...args) as CaptureQueryRow[];
|
|
case "cache-busting":
|
|
return this.db
|
|
.prepare(
|
|
`SELECT host, path, COUNT(*) AS variantCount
|
|
FROM capture_events
|
|
WHERE kind = 'request'
|
|
AND (path LIKE '%?%' OR headers_json LIKE '%cache-control%' OR headers_json LIKE '%pragma%')
|
|
${sessionWhere}
|
|
GROUP BY host, path
|
|
ORDER BY variantCount DESC, host ASC`,
|
|
)
|
|
.all(...args) as CaptureQueryRow[];
|
|
case "ws-duplicate-frames":
|
|
return this.db
|
|
.prepare(
|
|
`SELECT host, path, COUNT(*) AS duplicateFrames
|
|
FROM capture_events
|
|
WHERE kind = 'ws-frame' AND direction = 'outbound' ${sessionWhere}
|
|
GROUP BY host, path, data_sha256
|
|
HAVING COUNT(*) > 1
|
|
ORDER BY duplicateFrames DESC, host ASC`,
|
|
)
|
|
.all(...args) as CaptureQueryRow[];
|
|
case "missing-ack":
|
|
return this.db
|
|
.prepare(
|
|
`SELECT flow_id AS flowId, host, path, COUNT(*) AS outboundFrames
|
|
FROM capture_events
|
|
WHERE kind = 'ws-frame' AND direction = 'outbound' ${sessionWhere}
|
|
AND flow_id NOT IN (
|
|
SELECT flow_id FROM capture_events
|
|
WHERE kind = 'ws-frame' AND direction = 'inbound' ${sessionId ? "AND session_id = ?" : ""}
|
|
)
|
|
GROUP BY flow_id, host, path
|
|
ORDER BY outboundFrames DESC`,
|
|
)
|
|
.all(...(sessionId ? [sessionId, sessionId] : [])) as CaptureQueryRow[];
|
|
case "error-bursts":
|
|
return this.db
|
|
.prepare(
|
|
`SELECT host, path, COUNT(*) AS errorCount
|
|
FROM capture_events
|
|
WHERE kind = 'error' ${sessionWhere}
|
|
GROUP BY host, path
|
|
ORDER BY errorCount DESC, host ASC`,
|
|
)
|
|
.all(...args) as CaptureQueryRow[];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
purgeAll(): { sessions: number; events: number; blobs: number } {
|
|
const sessionCount =
|
|
(this.db.prepare(`SELECT COUNT(*) AS count FROM capture_sessions`).get() as { count: number })
|
|
.count ?? 0;
|
|
const eventCount =
|
|
(this.db.prepare(`SELECT COUNT(*) AS count FROM capture_events`).get() as { count: number })
|
|
.count ?? 0;
|
|
this.db.exec(`DELETE FROM capture_events; DELETE FROM capture_sessions;`);
|
|
let blobs = 0;
|
|
if (fs.existsSync(this.blobDir)) {
|
|
for (const entry of fs.readdirSync(this.blobDir)) {
|
|
fs.rmSync(path.join(this.blobDir, entry), { force: true });
|
|
blobs += 1;
|
|
}
|
|
}
|
|
return { sessions: sessionCount, events: eventCount, blobs };
|
|
}
|
|
|
|
deleteSessions(sessionIds: string[]): { sessions: number; events: number; blobs: number } {
|
|
const uniqueSessionIds = normalizeUniqueStringEntries(sessionIds);
|
|
if (uniqueSessionIds.length === 0) {
|
|
return { sessions: 0, events: 0, blobs: 0 };
|
|
}
|
|
const placeholders = uniqueSessionIds.map(() => "?").join(", ");
|
|
const blobRows = this.db
|
|
.prepare(
|
|
`SELECT DISTINCT data_blob_id AS blobId
|
|
FROM capture_events
|
|
WHERE session_id IN (${placeholders})
|
|
AND data_blob_id IS NOT NULL`,
|
|
)
|
|
.all(...uniqueSessionIds) as Array<{ blobId?: string | null }>;
|
|
const eventCount =
|
|
(
|
|
this.db
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM capture_events
|
|
WHERE session_id IN (${placeholders})`,
|
|
)
|
|
.get(...uniqueSessionIds) as { count: number }
|
|
).count ?? 0;
|
|
const sessionCount =
|
|
(
|
|
this.db
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM capture_sessions
|
|
WHERE id IN (${placeholders})`,
|
|
)
|
|
.get(...uniqueSessionIds) as { count: number }
|
|
).count ?? 0;
|
|
this.db
|
|
.prepare(`DELETE FROM capture_events WHERE session_id IN (${placeholders})`)
|
|
.run(...uniqueSessionIds);
|
|
this.db
|
|
.prepare(`DELETE FROM capture_sessions WHERE id IN (${placeholders})`)
|
|
.run(...uniqueSessionIds);
|
|
const candidateBlobIds = blobRows
|
|
.map((row) => row.blobId?.trim())
|
|
.filter((blobId): blobId is string => Boolean(blobId));
|
|
const remainingBlobRefs =
|
|
// Shared blobs are deleted only when no surviving event references them.
|
|
candidateBlobIds.length > 0
|
|
? new Set(
|
|
(
|
|
this.db
|
|
.prepare(
|
|
`SELECT DISTINCT data_blob_id AS blobId
|
|
FROM capture_events
|
|
WHERE data_blob_id IN (${candidateBlobIds.map(() => "?").join(", ")})
|
|
AND data_blob_id IS NOT NULL`,
|
|
)
|
|
.all(...candidateBlobIds) as Array<{ blobId?: string | null }>
|
|
)
|
|
.map((row) => row.blobId?.trim())
|
|
.filter((blobId): blobId is string => Boolean(blobId)),
|
|
)
|
|
: new Set<string>();
|
|
let blobs = 0;
|
|
for (const row of blobRows) {
|
|
const blobId = row.blobId?.trim();
|
|
if (!blobId || remainingBlobRefs.has(blobId)) {
|
|
continue;
|
|
}
|
|
const blobPath = path.join(this.blobDir, `${blobId}.bin.gz`);
|
|
if (fs.existsSync(blobPath)) {
|
|
fs.rmSync(blobPath, { force: true });
|
|
blobs += 1;
|
|
}
|
|
}
|
|
return { sessions: sessionCount, events: eventCount, blobs };
|
|
}
|
|
}
|
|
|
|
let cachedStore: DebugProxyCaptureStore | null = null;
|
|
let cachedKey = "";
|
|
let cachedStoreLeases = 0;
|
|
|
|
export function getDebugProxyCaptureStore(dbPath: string, blobDir: string): DebugProxyCaptureStore {
|
|
const key = `${dbPath}:${blobDir}`;
|
|
if (!cachedStore || cachedStore.isClosed || cachedKey !== key) {
|
|
cachedStore = new DebugProxyCaptureStore(dbPath, blobDir);
|
|
cachedKey = key;
|
|
cachedStoreLeases = 0;
|
|
}
|
|
return cachedStore;
|
|
}
|
|
|
|
export function closeDebugProxyCaptureStore(): void {
|
|
if (!cachedStore) {
|
|
return;
|
|
}
|
|
cachedStore.close();
|
|
cachedStore = null;
|
|
cachedKey = "";
|
|
cachedStoreLeases = 0;
|
|
}
|
|
|
|
// Lease API keeps one cached synchronous SQLite connection alive across related
|
|
// capture operations, then closes it when the last owner releases.
|
|
export function acquireDebugProxyCaptureStore(
|
|
dbPath: string,
|
|
blobDir: string,
|
|
): { store: DebugProxyCaptureStore; release: () => void } {
|
|
const store = getDebugProxyCaptureStore(dbPath, blobDir);
|
|
const key = cachedKey;
|
|
cachedStoreLeases += 1;
|
|
let released = false;
|
|
return {
|
|
store,
|
|
release: () => {
|
|
if (released) {
|
|
return;
|
|
}
|
|
released = true;
|
|
cachedStoreLeases = Math.max(0, cachedStoreLeases - 1);
|
|
if (cachedStoreLeases === 0 && cachedStore === store && cachedKey === key) {
|
|
closeDebugProxyCaptureStore();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export function persistEventPayload(
|
|
store: DebugProxyCaptureStore,
|
|
params: { data?: Buffer | string | null; contentType?: string; previewLimit?: number },
|
|
): { dataText?: string; dataBlobId?: string; dataSha256?: string } {
|
|
if (params.data == null) {
|
|
return {};
|
|
}
|
|
const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data);
|
|
const previewLimit = params.previewLimit ?? 8192;
|
|
// Store the whole payload as a blob but keep a small UTF-8 preview inline for
|
|
// fast CLI listings and query output.
|
|
const blob = store.persistPayload(buffer, params.contentType);
|
|
return {
|
|
dataText: buffer.subarray(0, previewLimit).toString("utf8"),
|
|
dataBlobId: blob.blobId,
|
|
dataSha256: blob.sha256,
|
|
};
|
|
}
|
|
|
|
export function safeJsonString(value: unknown): string | undefined {
|
|
const raw = serializeJson(value);
|
|
return raw ?? undefined;
|
|
}
|