import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { TraceConfig } from "./types.ts"; export const DEFAULT_TRACE_CONFIG: TraceConfig = { enabled: true, autoOpen: true, persistence: true, contentMode: "full", viewerPort: 0, retentionDays: 14, maxProjectBytes: 1024 * 1024 * 1024, maxQueueBytes: 8 * 1024 * 1024, }; export interface LoadTraceConfigOptions { homeDir: string; cwd: string; } export interface LoadTraceConfigResult { config: TraceConfig; warnings: string[]; } export interface WriteGlobalTraceEnabledOptions { homeDir: string; enabled: boolean; } function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isMissingFileError(error: unknown): boolean { return error instanceof Error && "code" in error && error.code === "ENOENT"; } function globalTraceConfigPath(homeDir: string): string { return join(homeDir, ".pi", "agent", "trace-viewer.json"); } function loadConfigFile(path: string, config: TraceConfig, warnings: string[]): boolean | undefined { let parsed: unknown; try { parsed = JSON.parse(readFileSync(path, "utf8")); } catch (error) { if (!isMissingFileError(error)) { warnings.push(`Could not load trace viewer config ${path}: ${describeError(error)}`); } return undefined; } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { warnings.push(`Invalid trace viewer config ${path}: expected a JSON object`); return undefined; } const values = parsed as Record; let enabled: boolean | undefined; for (const field of ["enabled", "autoOpen", "persistence"] as const) { if (!(field in values)) continue; if (typeof values[field] === "boolean") { config[field] = values[field]; if (field === "enabled") enabled = values[field]; } else { warnings.push(`Invalid ${field} in trace viewer config ${path}: expected a boolean`); } } if ("contentMode" in values) { if (values.contentMode === "full") { config.contentMode = values.contentMode; } else { warnings.push(`Invalid contentMode in trace viewer config ${path}: expected "full"`); } } if ("viewerPort" in values) { if (typeof values.viewerPort === "number" && Number.isInteger(values.viewerPort) && values.viewerPort >= 0 && values.viewerPort <= 65535) { config.viewerPort = values.viewerPort; } else { warnings.push(`Invalid viewerPort in trace viewer config ${path}: expected an integer from 0 to 65535`); } } if ("retentionDays" in values) { if (typeof values.retentionDays === "number" && Number.isFinite(values.retentionDays) && values.retentionDays >= 0) { config.retentionDays = values.retentionDays; } else { warnings.push(`Invalid retentionDays in trace viewer config ${path}: expected a finite number greater than or equal to 0`); } } for (const field of ["maxProjectBytes", "maxQueueBytes"] as const) { if (!(field in values)) continue; if (typeof values[field] === "number" && Number.isFinite(values[field]) && values[field] > 0) { config[field] = values[field]; } else { warnings.push(`Invalid ${field} in trace viewer config ${path}: expected a finite number greater than 0`); } } return enabled; } export function loadTraceConfig(options: LoadTraceConfigOptions): LoadTraceConfigResult { const config = { ...DEFAULT_TRACE_CONFIG }; const warnings: string[] = []; const globalEnabled = loadConfigFile(globalTraceConfigPath(options.homeDir), config, warnings); loadConfigFile(join(options.cwd, ".pi", "trace-viewer.json"), config, warnings); if (globalEnabled === false) config.enabled = false; return { config, warnings }; } export async function writeGlobalTraceEnabled(options: WriteGlobalTraceEnabledOptions): Promise { const configDir = join(options.homeDir, ".pi", "agent"); const configPath = globalTraceConfigPath(options.homeDir); let values: Record = {}; try { const parsed: unknown = JSON.parse(await readFile(configPath, "utf8")); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new TypeError(`Invalid trace viewer config ${configPath}: expected a JSON object`); } values = parsed as Record; } catch (error) { if (!isMissingFileError(error)) { throw new Error(`Could not update trace viewer config ${configPath}: ${describeError(error)}`, { cause: error }); } } const temporaryPath = join(configDir, `.trace-viewer.json.${process.pid}.${randomUUID()}.tmp`); try { await mkdir(configDir, { recursive: true }); await writeFile(temporaryPath, `${JSON.stringify({ ...values, enabled: options.enabled }, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600, }); await rename(temporaryPath, configPath); } catch (error) { try { await rm(temporaryPath, { force: true }); } catch { /* Temporary-file cleanup is best effort. */ } throw new Error(`Could not update trace viewer config ${configPath}: ${describeError(error)}`, { cause: error }); } }