diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7aa874860862a9be84fb8b8b7a13cae56ed223e6..d6ec47fd09923fe893e827dc5034faa7b65ecd8f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,12 +14,12 @@ "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { - "@t3tools/shared": "workspace:*", "effect": "catalog:", "electron": "40.6.0" }, "devDependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", "@types/node": "catalog:", "electronmon": "^2.0.2", "tsdown": "catalog:", diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts index 5f708e3c83c2833ce66dd9f0a587b3cbfe32a4b5..150848c14c632530b80b199813f111b06263bc02 100644 --- a/apps/desktop/src/fixPath.ts +++ b/apps/desktop/src/fixPath.ts @@ -1,11 +1,11 @@ -import { execFileSync } from "node:child_process"; +import * as ChildProcess from "node:child_process"; export function fixPath(): void { if (process.platform !== "darwin") return; try { const shell = process.env.SHELL ?? "/bin/zsh"; - const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { + const result = ChildProcess.execFileSync(shell, ["-ilc", "echo -n $PATH"], { encoding: "utf8", timeout: 5000, }); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d076e7b1a4eec7aff8b3a05f82685265b53c02a9..50d0ebdc3a857be2c6434589e5c378e63db2b74d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,15 +1,16 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import * as ChildProcess from "node:child_process"; +import * as Crypto from "node:crypto"; +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; import type { MenuItemConstructorOptions } from "electron"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; +import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; @@ -21,17 +22,21 @@ const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const STATE_DIR = - process.env.T3CODE_STATE_DIR?.trim() || path.join(os.homedir(), ".t3", "userdata"); + process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata"); const DESKTOP_SCHEME = "t3"; -const ROOT_DIR = path.resolve(__dirname, "../../.."); +const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const APP_USER_MODEL_ID = "com.t3tools.t3code"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; +const LOG_DIR = Path.join(STATE_DIR, "logs"); +const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; +const LOG_FILE_MAX_FILES = 10; +const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); let mainWindow: BrowserWindow | null = null; -let backendProcess: ChildProcess | null = null; +let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; let backendAuthToken = ""; let backendWsUrl = ""; @@ -40,9 +45,133 @@ let restartTimer: ReturnType | null = null; let isQuitting = false; let desktopProtocolRegistered = false; let aboutCommitHashCache: string | null | undefined; +let desktopLogSink: RotatingFileSink | null = null; +let backendLogSink: RotatingFileSink | null = null; +let restoreStdIoCapture: (() => void) | null = null; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; +function logTimestamp(): string { + return new Date().toISOString(); +} + +function logScope(scope: string): string { + return `${scope} run=${APP_RUN_ID}`; +} + +function sanitizeLogValue(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function writeDesktopLogHeader(message: string): void { + if (!desktopLogSink) return; + desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); +} + +function writeBackendSessionBoundary(phase: "START" | "END", details: string): void { + if (!backendLogSink) return; + const normalizedDetails = sanitizeLogValue(details); + backendLogSink.write( + `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`, + ); +} + +function formatErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function writeDesktopStreamChunk( + streamName: "stdout" | "stderr", + chunk: unknown, + encoding: BufferEncoding | undefined, +): void { + if (!desktopLogSink) return; + const buffer = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined); + desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `); + desktopLogSink.write(buffer); + if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) { + desktopLogSink.write("\n"); + } +} + +function installStdIoCapture(): void { + if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) { + return; + } + + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + const patchWrite = + (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) => + ( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ): boolean => { + const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; + writeDesktopStreamChunk(streamName, chunk, encoding); + if (typeof encodingOrCallback === "function") { + return originalWrite(chunk, encodingOrCallback); + } + if (callback !== undefined) { + return originalWrite(chunk, encoding, callback); + } + if (encoding !== undefined) { + return originalWrite(chunk, encoding); + } + return originalWrite(chunk); + }; + + process.stdout.write = patchWrite("stdout", originalStdoutWrite); + process.stderr.write = patchWrite("stderr", originalStderrWrite); + + restoreStdIoCapture = () => { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + restoreStdIoCapture = null; + }; +} + +function initializePackagedLogging(): void { + if (!app.isPackaged) return; + try { + desktopLogSink = new RotatingFileSink({ + filePath: Path.join(LOG_DIR, "desktop-main.log"), + maxBytes: LOG_FILE_MAX_BYTES, + maxFiles: LOG_FILE_MAX_FILES, + }); + backendLogSink = new RotatingFileSink({ + filePath: Path.join(LOG_DIR, "server-child.log"), + maxBytes: LOG_FILE_MAX_BYTES, + maxFiles: LOG_FILE_MAX_FILES, + }); + installStdIoCapture(); + writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`); + } catch (error) { + // Logging setup should never block app startup. + console.error("[desktop] failed to initialize packaged logging", error); + } +} + +function captureBackendOutput(child: ChildProcess.ChildProcess): void { + if (!app.isPackaged || backendLogSink === null) return; + const writeChunk = (chunk: unknown): void => { + if (!backendLogSink) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); + backendLogSink.write(buffer); + }; + child.stdout?.on("data", writeChunk); + child.stderr?.on("data", writeChunk); +} + +initializePackagedLogging(); + function getDestructiveMenuIcon(): Electron.NativeImage | undefined { if (process.platform !== "darwin") return undefined; if (destructiveMenuIconCache !== undefined) { @@ -97,13 +226,13 @@ function normalizeCommitHash(value: unknown): string | null { } function resolveEmbeddedCommitHash(): string | null { - const packageJsonPath = path.join(resolveAppRoot(), "package.json"); - if (!fs.existsSync(packageJsonPath)) { + const packageJsonPath = Path.join(resolveAppRoot(), "package.json"); + if (!FS.existsSync(packageJsonPath)) { return null; } try { - const raw = fs.readFileSync(packageJsonPath, "utf8"); + const raw = FS.readFileSync(packageJsonPath, "utf8"); const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown }; return normalizeCommitHash(parsed.t3codeCommitHash); } catch { @@ -134,25 +263,25 @@ function resolveAboutCommitHash(): string | null { } function resolveBackendEntry(): string { - return path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); + return Path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); } function resolveBackendCwd(): string { if (!app.isPackaged) { return resolveAppRoot(); } - return os.homedir(); + return OS.homedir(); } function resolveDesktopStaticDir(): string | null { const appRoot = resolveAppRoot(); const candidates = [ - path.join(appRoot, "apps/server/dist/client"), - path.join(appRoot, "apps/web/dist"), + Path.join(appRoot, "apps/server/dist/client"), + Path.join(appRoot, "apps/web/dist"), ]; for (const candidate of candidates) { - if (fs.existsSync(path.join(candidate, "index.html"))) { + if (FS.existsSync(Path.join(candidate, "index.html"))) { return candidate; } } @@ -163,35 +292,50 @@ function resolveDesktopStaticDir(): string | null { function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { const url = new URL(requestUrl); const rawPath = decodeURIComponent(url.pathname); - const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, ""); + const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, ""); if (normalizedPath.includes("..")) { - return path.join(staticRoot, "index.html"); + return Path.join(staticRoot, "index.html"); } const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; - const resolvedPath = path.join(staticRoot, requestedPath); + const resolvedPath = Path.join(staticRoot, requestedPath); - if (path.extname(resolvedPath)) { + if (Path.extname(resolvedPath)) { return resolvedPath; } - const nestedIndex = path.join(resolvedPath, "index.html"); - if (fs.existsSync(nestedIndex)) { + const nestedIndex = Path.join(resolvedPath, "index.html"); + if (FS.existsSync(nestedIndex)) { return nestedIndex; } - return path.join(staticRoot, "index.html"); + return Path.join(staticRoot, "index.html"); } function isStaticAssetRequest(requestUrl: string): boolean { try { const url = new URL(requestUrl); - return path.extname(url.pathname).length > 0; + return Path.extname(url.pathname).length > 0; } catch { return false; } } +function handleFatalStartupError(stage: string, error: unknown): void { + const message = formatErrorMessage(error); + const detail = + error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; + writeDesktopLogHeader(`fatal startup error stage=${stage} message=${message}`); + console.error(`[desktop] fatal startup error (${stage})`, error); + if (!isQuitting) { + isQuitting = true; + dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`); + } + stopBackend(); + restoreStdIoCapture?.(); + app.quit(); +} + function registerDesktopProtocol(): void { if (isDevelopment || desktopProtocolRegistered) return; @@ -202,19 +346,19 @@ function registerDesktopProtocol(): void { ); } - const staticRootResolved = path.resolve(staticRoot); - const staticRootPrefix = `${staticRootResolved}${path.sep}`; - const fallbackIndex = path.join(staticRootResolved, "index.html"); + const staticRootResolved = Path.resolve(staticRoot); + const staticRootPrefix = `${staticRootResolved}${Path.sep}`; + const fallbackIndex = Path.join(staticRootResolved, "index.html"); protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { try { const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); - const resolvedCandidate = path.resolve(candidate); + const resolvedCandidate = Path.resolve(candidate); const isInRoot = resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); const isAssetRequest = isStaticAssetRequest(request.url); - if (!isInRoot || !fs.existsSync(resolvedCandidate)) { + if (!isInRoot || !FS.existsSync(resolvedCandidate)) { if (isAssetRequest) { callback({ error: -6 }); return; @@ -309,13 +453,13 @@ function configureApplicationMenu(): void { function resolveResourcePath(fileName: string): string | null { const candidates = [ - path.join(__dirname, "../resources", fileName), - path.join(process.resourcesPath, "resources", fileName), - path.join(process.resourcesPath, fileName), + Path.join(__dirname, "../resources", fileName), + Path.join(process.resourcesPath, "resources", fileName), + Path.join(process.resourcesPath, fileName), ]; for (const candidate of candidates) { - if (fs.existsSync(candidate)) { + if (FS.existsSync(candidate)) { return candidate; } } @@ -376,12 +520,13 @@ function startBackend(): void { if (isQuitting || backendProcess) return; const backendEntry = resolveBackendEntry(); - if (!fs.existsSync(backendEntry)) { + if (!FS.existsSync(backendEntry)) { scheduleBackendRestart(`missing server entry at ${backendEntry}`); return; } - const child = spawn(process.execPath, [backendEntry], { + const captureBackendLogs = app.isPackaged && backendLogSink !== null; + const child = ChildProcess.spawn(process.execPath, [backendEntry], { cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. // Run the child in Node mode so this backend process does not become a GUI app instance. @@ -389,9 +534,20 @@ function startBackend(): void { ...backendEnv(), ELECTRON_RUN_AS_NODE: "1", }, - stdio: "inherit", + stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit", }); backendProcess = child; + let backendSessionClosed = false; + const closeBackendSession = (details: string) => { + if (backendSessionClosed) return; + backendSessionClosed = true; + writeBackendSessionBoundary("END", details); + }; + writeBackendSessionBoundary( + "START", + `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`, + ); + captureBackendOutput(child); child.once("spawn", () => { restartAttempt = 0; @@ -401,6 +557,7 @@ function startBackend(): void { if (backendProcess === child) { backendProcess = null; } + closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); scheduleBackendRestart(error.message); }); @@ -408,6 +565,9 @@ function startBackend(): void { if (backendProcess === child) { backendProcess = null; } + closeBackendSession( + `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, + ); if (isQuitting) return; const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; scheduleBackendRestart(reason); @@ -566,7 +726,7 @@ function createWindow(): BrowserWindow { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 18 }, webPreferences: { - preload: path.join(__dirname, "preload.js"), + preload: Path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, sandbox: true, @@ -604,37 +764,53 @@ function createWindow(): BrowserWindow { configureAppIdentity(); async function bootstrap(): Promise { + writeDesktopLogHeader("bootstrap start"); backendPort = await Effect.service(NetService).pipe( Effect.flatMap((net) => net.reserveLoopbackPort()), Effect.provide(NetService.layer), Effect.runPromise, ); - backendAuthToken = randomBytes(24).toString("hex"); + writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); + backendAuthToken = Crypto.randomBytes(24).toString("hex"); backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; + writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); registerIpcHandlers(); + writeDesktopLogHeader("bootstrap ipc handlers registered"); startBackend(); + writeDesktopLogHeader("bootstrap backend start requested"); mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); } app.on("before-quit", () => { isQuitting = true; + writeDesktopLogHeader("before-quit received"); stopBackend(); + restoreStdIoCapture?.(); }); -app.whenReady().then(() => { - configureAppIdentity(); - configureApplicationMenu(); - registerDesktopProtocol(); - void bootstrap(); +app + .whenReady() + .then(() => { + writeDesktopLogHeader("app ready"); + configureAppIdentity(); + configureApplicationMenu(); + registerDesktopProtocol(); + void bootstrap().catch((error) => { + handleFatalStartupError("bootstrap", error); + }); - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createWindow(); - } + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + mainWindow = createWindow(); + } + }); + }) + .catch((error) => { + handleFatalStartupError("whenReady", error); }); -}); app.on("window-all-closed", () => { if (process.platform !== "darwin") { @@ -646,14 +822,18 @@ if (process.platform !== "win32") { process.on("SIGINT", () => { if (isQuitting) return; isQuitting = true; + writeDesktopLogHeader("SIGINT received"); stopBackend(); + restoreStdIoCapture?.(); app.quit(); }); process.on("SIGTERM", () => { if (isQuitting) return; isQuitting = true; + writeDesktopLogHeader("SIGTERM received"); stopBackend(); + restoreStdIoCapture?.(); app.quit(); }); } diff --git a/apps/desktop/src/rotatingFileSink.test.ts b/apps/desktop/src/rotatingFileSink.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..53dd98ade8c0c8394f5bade90bdde32e0b8b3c9e --- /dev/null +++ b/apps/desktop/src/rotatingFileSink.test.ts @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { RotatingFileSink } from "@t3tools/shared/logging"; +import { afterEach, describe, expect, it } from "vitest"; + +const tempRoots: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-")); + tempRoots.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("RotatingFileSink", () => { + it("rotates when writes exceed max bytes", () => { + const dir = makeTempDir(); + const logPath = path.join(dir, "desktop-main.log"); + const sink = new RotatingFileSink({ + filePath: logPath, + maxBytes: 10, + maxFiles: 3, + }); + + sink.write("12345"); + sink.write("67890"); + sink.write("abc"); + + expect(fs.readFileSync(path.join(dir, "desktop-main.log"), "utf8")).toBe("abc"); + expect(fs.readFileSync(path.join(dir, "desktop-main.log.1"), "utf8")).toBe("1234567890"); + }); + + it("retains only maxFiles backups", () => { + const dir = makeTempDir(); + const logPath = path.join(dir, "server-child.log"); + const sink = new RotatingFileSink({ + filePath: logPath, + maxBytes: 4, + maxFiles: 2, + }); + + sink.write("aaaa"); + sink.write("bbbb"); + sink.write("cccc"); + sink.write("dddd"); + + expect(fs.existsSync(path.join(dir, "server-child.log.1"))).toBe(true); + expect(fs.existsSync(path.join(dir, "server-child.log.2"))).toBe(true); + expect(fs.existsSync(path.join(dir, "server-child.log.3"))).toBe(false); + }); + + it("prunes stale backups above maxFiles on startup", () => { + const dir = makeTempDir(); + const logPath = path.join(dir, "desktop-main.log"); + fs.writeFileSync(path.join(dir, "desktop-main.log.1"), "first"); + fs.writeFileSync(path.join(dir, "desktop-main.log.4"), "stale"); + + const sink = new RotatingFileSink({ + filePath: logPath, + maxBytes: 16, + maxFiles: 2, + }); + sink.write("hello"); + + expect(fs.existsSync(path.join(dir, "desktop-main.log.4"))).toBe(false); + }); +}); diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 4cffbb2587936a57053b77090be840eecddc1a1b..f3ebc97325332f7262a8a4a10d81db2a9891119a 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -12,7 +12,7 @@ export default defineConfig([ ...shared, entry: ["src/main.ts"], clean: true, - noExternal: ["@t3tools/contracts"], + noExternal: (id) => id.startsWith("@t3tools/"), }, { ...shared, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 047adc6e4efd5b409105f058499c73e0e9e28fc3..2dd6062e603d4a33a9e767d527e6bddffcf5f886 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -15,7 +15,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterAll, assert, it, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Fiber, Layer, Stream } from "effect"; +import { Effect, Fiber, Layer, Option, Stream } from "effect"; import { CodexAppServerManager, @@ -24,6 +24,7 @@ import { import { ServerConfig } from "../../config.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); @@ -121,10 +122,21 @@ class FakeCodexManager extends CodexAppServerManager { } } +const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { + upsert: () => Effect.void, + getProvider: () => + Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), + getBinding: () => Effect.succeed(Option.none()), + getThreadId: () => Effect.succeed(Option.none()), + remove: () => Effect.void, + listSessionIds: () => Effect.succeed([]), +}); + const validationManager = new FakeCodexManager(); const validationLayer = it.layer( makeCodexAdapterLive({ manager: validationManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), ); @@ -159,6 +171,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => { const sessionErrorLayer = it.layer( makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), ); @@ -195,6 +208,7 @@ const lifecycleManager = new FakeCodexManager(); const lifecycleLayer = it.layer( makeCodexAdapterLive({ manager: lifecycleManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 768af2bb2123917f00a106fa4bcece479298fd92..89f65e3b210a8aa3258f55847b4714b97d287056 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -17,7 +17,7 @@ import { ProviderThreadId, ProviderTurnId, } from "@t3tools/contracts"; -import { Effect, FileSystem, Layer, Queue, Schema, Stream } from "effect"; +import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect"; import { ProviderAdapterProcessError, @@ -28,10 +28,11 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { CodexAppServerManager } from "../../codexAppServerManager.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "codex" as const; @@ -39,6 +40,7 @@ export interface CodexAdapterLiveOptions { readonly manager?: CodexAppServerManager; readonly makeManager?: () => CodexAppServerManager; readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; } function toMessage(cause: unknown, fallback: string): string { @@ -430,10 +432,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const serverConfig = yield* Effect.service(ServerConfig); + const directory = yield* ProviderSessionDirectory; const nativeEventLogger = - options?.nativeEventLogPath !== undefined + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined ? makeEventNdjsonLogger(options.nativeEventLogPath) - : undefined; + : undefined); const manager = yield* Effect.acquireRelease( Effect.sync(() => { @@ -580,11 +584,30 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => yield* Effect.acquireRelease( Effect.sync(() => { + const writeNativeEvent = (event: ProviderEvent) => { + if (!nativeEventLogger) { + return; + } + const loggedAt = new Date().toISOString(); + const resolveOrchestrationThreadId = Effect.catch( + directory.getThreadId(event.sessionId).pipe(Effect.map(Option.getOrUndefined)), + () => Effect.void, + ); + const writeLogEffect = Effect.tap(resolveOrchestrationThreadId, (orchestrationThreadId) => + Effect.sync(() => { + nativeEventLogger.write({ + observedAt: loggedAt, + stream: "native", + orchestrationThreadId: orchestrationThreadId ?? null, + event, + }); + }), + ); + void Effect.runPromise(writeLogEffect); + }; + const listener = (event: ProviderEvent) => { - nativeEventLogger?.write({ - observedAt: new Date().toISOString(), - event, - }); + writeNativeEvent(event); Queue.offerAllUnsafe(runtimeEventQueue, mapToRuntimeEvents(event)); }; manager.on("event", listener); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index 73e110295f3335680569cd42e793b5677ccf2b1d..f2ef4dcdc939c1db5e59a51d1b0b9775b7ecaffd 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -7,31 +7,111 @@ import { describe, expect, it } from "vitest"; import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; describe("EventNdjsonLogger", () => { - it("writes NDJSON records to disk", () => { + it("writes effect-style lines to thread-scoped files", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); - const filePath = path.join(tempDir, "provider-native.ndjson"); + const basePath = path.join(tempDir, "provider-native.ndjson"); try { - const logger = makeEventNdjsonLogger(filePath); + const logger = makeEventNdjsonLogger(basePath); expect(logger).toBeDefined(); if (!logger) { return; } - logger.write({ type: "native", payload: { id: "evt-1" } }); - logger.write({ type: "canonical", payload: { id: "evt-2" } }); + logger.write({ + observedAt: "2026-03-03T12:00:00.000Z", + stream: "native", + orchestrationThreadId: "thread-1", + event: { threadId: "provider-thread-1", id: "evt-1" }, + }); + logger.write({ + observedAt: "2026-03-03T12:00:01.000Z", + stream: "canonical", + orchestrationThreadId: "thread-2", + event: { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, + }); logger.close(); - const raw = fs.readFileSync(filePath, "utf8").trim(); - const lines = raw.split("\n"); - expect(lines.length).toBe(2); + const threadOnePath = path.join(tempDir, "thread-1.log"); + const threadTwoPath = path.join(tempDir, "thread-2.log"); + expect(fs.existsSync(threadOnePath)).toBe(true); + expect(fs.existsSync(threadTwoPath)).toBe(true); - const first = JSON.parse(lines[0] ?? "{}"); - const second = JSON.parse(lines[1] ?? "{}"); - expect(first).toEqual({ type: "native", payload: { id: "evt-1" } }); - expect(second).toEqual({ type: "canonical", payload: { id: "evt-2" } }); + const first = fs.readFileSync(threadOnePath, "utf8").trim(); + const second = fs.readFileSync(threadTwoPath, "utf8").trim(); + expect(first).toBe( + '[2026-03-03T12:00:00.000Z] NATIVE: {"threadId":"provider-thread-1","id":"evt-1"}', + ); + expect(second).toBe( + '[2026-03-03T12:00:01.000Z] ORCHESTRATION: {"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', + ); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } }); + + it("drops records without orchestration thread ids", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); + const basePath = path.join(tempDir, "provider-canonical.ndjson"); + + try { + const logger = makeEventNdjsonLogger(basePath); + expect(logger).toBeDefined(); + if (!logger) { + return; + } + + logger.write({ stream: "orchestration", event: { id: "evt-no-thread" } }); + logger.close(); + + const logFiles = fs.readdirSync(tempDir).filter((entry) => entry.endsWith(".log")); + expect(logFiles).toEqual([]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("rotates per-thread files when max size is exceeded", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); + const basePath = path.join(tempDir, "provider-native.ndjson"); + + try { + const logger = makeEventNdjsonLogger(basePath, { + maxBytes: 120, + maxFiles: 2, + }); + expect(logger).toBeDefined(); + if (!logger) { + return; + } + + for (let index = 0; index < 10; index += 1) { + logger.write({ + stream: "native", + orchestrationThreadId: "thread-rotate", + event: { + threadId: "provider-thread-rotate", + id: `evt-${index}`, + payload: "x".repeat(40), + }, + }); + } + logger.close(); + + const fileStem = "thread-rotate.log"; + const matchingFiles = fs + .readdirSync(tempDir) + .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) + .toSorted(); + + expect(matchingFiles.some((entry) => entry === `${fileStem}.1`)).toBe(true); + expect(matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`)).toBe( + true, + ); + expect(matchingFiles.some((entry) => entry === `${fileStem}.3`)).toBe(false); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index b842f076cb5d706510a58a23b88c10e27f5e8259..42dac0f9cbfd7ce2a69186f12b77fa68fe21231c 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -1,15 +1,20 @@ /** - * Provider event NDJSON logger helper. + * Provider event logger helper. * - * Best-effort writer for observability logs. Failures are downgraded to warnings - * so provider runtime behavior is unaffected. + * Best-effort writer for observability logs. Each record is formatted as a + * single effect-style text line in a thread-scoped file. Failures are + * downgraded to warnings so provider runtime behavior is unaffected. */ import fs from "node:fs"; import path from "node:path"; +import { RotatingFileSink } from "@t3tools/shared/logging"; +import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; import { createLogger } from "../../logger.ts"; const logger = createLogger("provider-observability"); +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; +const DEFAULT_MAX_FILES = 10; export interface EventNdjsonLogger { readonly filePath: string; @@ -17,6 +22,11 @@ export interface EventNdjsonLogger { close: () => void; } +export interface EventNdjsonLoggerOptions { + readonly maxBytes?: number; + readonly maxFiles?: number; +} + function stringifyRecord(record: unknown): string | undefined { try { return JSON.stringify(record); @@ -26,10 +36,79 @@ function stringifyRecord(record: unknown): string | undefined { } } -export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | undefined { +function normalizeThreadSegment(raw: unknown): string | null { + if (typeof raw !== "string") { + return null; + } + return toSafeThreadAttachmentSegment(raw); +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function resolveThreadSegment(record: unknown): string | null { + const root = asObject(record); + return normalizeThreadSegment(root?.orchestrationThreadId); +} + +function resolveThreadFilePath(baseFilePath: string, threadSegment: string): string { + const dir = path.dirname(baseFilePath); + return path.join(dir, `${threadSegment}.log`); +} + +function resolveObservedAt(raw: unknown): string { + if (typeof raw !== "string") { + return new Date().toISOString(); + } + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return new Date().toISOString(); + } + return trimmed; +} + +function resolveStreamLabel(raw: unknown): string { + if (typeof raw !== "string") { + return "ORCHESTRATION"; + } + const normalized = raw.trim().toLowerCase(); + if (normalized === "native") { + return "NATIVE"; + } + if (normalized === "canonical" || normalized === "orchestration") { + return "ORCHESTRATION"; + } + if (normalized.length === 0) { + return "ORCHESTRATION"; + } + return normalized.toUpperCase(); +} + +function toLogLine(record: unknown): string | undefined { + const root = asObject(record); + const payload = root && "event" in root ? root.event : record; + const serializedPayload = stringifyRecord(payload); + if (!serializedPayload) { + return undefined; + } + const observedAt = resolveObservedAt(root?.observedAt); + const streamLabel = resolveStreamLabel(root?.stream); + return `[${observedAt}] ${streamLabel}: ${serializedPayload}\n`; +} + +export function makeEventNdjsonLogger( + filePath: string, + options?: EventNdjsonLoggerOptions, +): EventNdjsonLogger | undefined { + const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES; + const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; + try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.appendFileSync(filePath, "", { encoding: "utf8", flag: "a" }); } catch (error) { logger.warn("failed to create provider event log directory", { filePath, error }); return undefined; @@ -37,6 +116,38 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und try { let broken = false; + const sinks = new Map(); + const failedSegments = new Set(); + + const resolveSink = (threadSegment: string): RotatingFileSink | undefined => { + if (failedSegments.has(threadSegment)) { + return undefined; + } + const existingSink = sinks.get(threadSegment); + if (existingSink) { + return existingSink; + } + + const threadFilePath = resolveThreadFilePath(filePath, threadSegment); + try { + const sink = new RotatingFileSink({ + filePath: threadFilePath, + maxBytes, + maxFiles, + throwOnError: true, + }); + sinks.set(threadSegment, sink); + return sink; + } catch (error) { + failedSegments.add(threadSegment); + logger.warn("failed to initialize provider thread log file", { + filePath: threadFilePath, + threadSegment, + error, + }); + return undefined; + } + }; return { filePath, @@ -44,19 +155,27 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und if (broken) { return; } - const serialized = stringifyRecord(record); - if (!serialized) { + const threadSegment = resolveThreadSegment(record); + if (!threadSegment) { + return; + } + const line = toLogLine(record); + if (!line) { + return; + } + const sink = resolveSink(threadSegment); + if (!sink) { return; } try { - fs.appendFileSync(filePath, `${serialized}\n`, { encoding: "utf8", flag: "a" }); + sink.write(line); } catch (error) { broken = true; - logger.warn("provider event log write failed", { filePath, error }); + logger.warn("provider event log write failed", { filePath, threadSegment, error }); } }, close() { - // no-op for sync append logger + sinks.clear(); }, }; } catch (error) { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7176fb80222e1985c80834d1a2ae723acdb4a6e1..70972d0648137719ec85179b8bf9dab90fea6deb 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -30,10 +30,11 @@ import { ProviderSessionDirectory, type ProviderSessionBinding, } from "../Services/ProviderSessionDirectory.ts"; -import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; + readonly canonicalEventLogger?: EventNdjsonLogger; } const ProviderRollbackConversationInput = Schema.Struct({ @@ -108,9 +109,10 @@ function readPersistedCwd( const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { const canonicalEventLogger = - options?.canonicalEventLogPath !== undefined + options?.canonicalEventLogger ?? + (options?.canonicalEventLogPath !== undefined ? makeEventNdjsonLogger(options.canonicalEventLogPath) - : undefined; + : undefined); const registry = yield* ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory; @@ -140,12 +142,25 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => canonicalizeRuntimeEventSession(event).pipe( Effect.tap((canonicalEvent) => - Effect.sync(() => { - canonicalEventLogger?.write({ - observedAt: new Date().toISOString(), - event: canonicalEvent, - }); - }), + canonicalEventLogger + ? Effect.tap( + Effect.catch( + directory.getThreadId(canonicalEvent.sessionId).pipe( + Effect.map(Option.getOrUndefined), + ), + () => Effect.void, + ), + (orchestrationThreadId) => + Effect.sync(() => { + canonicalEventLogger.write({ + observedAt: new Date().toISOString(), + stream: "canonical", + orchestrationThreadId: orchestrationThreadId ?? null, + event: canonicalEvent, + }); + }), + ) + : Effect.void, ), Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), Effect.asVoid, diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 632c0bb3e9392c6a6d4337de45fc2b72d407f14a..dd7d2593a296bf9eef5ef5f62aca79837900debc 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -23,6 +23,7 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; +import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { KeybindingsLive } from "./keybindings"; @@ -41,17 +42,21 @@ export function makeServerProviderLayer(): Layer.Layer< > { return Effect.gen(function* () { const { stateDir } = yield* ServerConfig; - const providerLogsDir = path.join(stateDir, "logs", "providers"); - const codexAdapterLayer = makeCodexAdapterLive({ - nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), - }); - const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer)); + const providerLogsDir = path.join(stateDir, "logs", "provider"); + const providerEventLogger = makeEventNdjsonLogger(path.join(providerLogsDir, "events.log")); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); - return makeProviderServiceLive({ - canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), - }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); + const codexAdapterLayer = makeCodexAdapterLive( + providerEventLogger ? { nativeEventLogger: providerEventLogger } : undefined, + ); + const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( + Layer.provide(codexAdapterLayer), + Layer.provideMerge(providerSessionDirectoryLayer), + ); + return makeProviderServiceLive( + providerEventLogger ? { canonicalEventLogger: providerEventLogger } : undefined, + ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); }).pipe(Layer.unwrap); } diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts index b0782cae0088ad7092fb55f9493580b3d274c6ce..f89bc7d3d763ca850d91af56e301026f62a6b029 100644 --- a/apps/server/tsdown.config.ts +++ b/apps/server/tsdown.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ outDir: "dist", sourcemap: true, clean: true, - noExternal: ["@t3tools/contracts"], + noExternal: (id) => id.startsWith("@t3tools/"), inlineOnly: false, banner: { js: "#!/usr/bin/env node\n", diff --git a/bun.lock b/bun.lock index 1b9bc00d001a6602aa2140d7622ce627fc1d4223..39ca9d872a02d6fd59e5decd1dd7519e941a9bf0 100644 --- a/bun.lock +++ b/bun.lock @@ -16,12 +16,12 @@ "name": "@t3tools/desktop", "version": "0.0.0-alpha.12", "dependencies": { - "@t3tools/shared": "workspace:*", "effect": "catalog:", "electron": "40.6.0", }, "devDependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", "@types/node": "catalog:", "electronmon": "^2.0.2", "tsdown": "catalog:", diff --git a/packages/shared/package.json b/packages/shared/package.json index 88c03898c86ba479e5a2078f69baa804e18f3e11..68d760c575fd4a208d45623a65217b249be2492d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -8,6 +8,10 @@ "types": "./src/git.ts", "import": "./src/git.ts" }, + "./logging": { + "types": "./src/logging.ts", + "import": "./src/logging.ts" + }, "./Net": { "types": "./src/Net.ts", "import": "./src/Net.ts" diff --git a/packages/shared/src/logging.ts b/packages/shared/src/logging.ts new file mode 100644 index 0000000000000000000000000000000000000000..98fee9464c795be0dfe722c8a4b0cd8687adddf9 --- /dev/null +++ b/packages/shared/src/logging.ts @@ -0,0 +1,115 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface RotatingFileSinkOptions { + readonly filePath: string; + readonly maxBytes: number; + readonly maxFiles: number; + readonly throwOnError?: boolean; +} + +export class RotatingFileSink { + private readonly filePath: string; + private readonly maxBytes: number; + private readonly maxFiles: number; + private readonly throwOnError: boolean; + private currentSize = 0; + + constructor(options: RotatingFileSinkOptions) { + if (options.maxBytes < 1) { + throw new Error(`maxBytes must be >= 1 (received ${options.maxBytes})`); + } + if (options.maxFiles < 1) { + throw new Error(`maxFiles must be >= 1 (received ${options.maxFiles})`); + } + + this.filePath = options.filePath; + this.maxBytes = options.maxBytes; + this.maxFiles = options.maxFiles; + this.throwOnError = options.throwOnError ?? false; + + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + this.pruneOverflowBackups(); + this.currentSize = this.readCurrentSize(); + } + + write(chunk: string | Buffer): void { + const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + if (buffer.length === 0) return; + + try { + if (this.currentSize > 0 && this.currentSize + buffer.length > this.maxBytes) { + this.rotate(); + } + + fs.appendFileSync(this.filePath, buffer); + this.currentSize += buffer.length; + + if (this.currentSize > this.maxBytes) { + this.rotate(); + } + } catch { + this.currentSize = this.readCurrentSize(); + if (this.throwOnError) { + throw new Error(`Failed to write log chunk to ${this.filePath}`); + } + } + } + + private rotate(): void { + try { + const oldest = this.withSuffix(this.maxFiles); + if (fs.existsSync(oldest)) { + fs.rmSync(oldest, { force: true }); + } + + for (let index = this.maxFiles - 1; index >= 1; index -= 1) { + const source = this.withSuffix(index); + const target = this.withSuffix(index + 1); + if (fs.existsSync(source)) { + fs.renameSync(source, target); + } + } + + if (fs.existsSync(this.filePath)) { + fs.renameSync(this.filePath, this.withSuffix(1)); + } + + this.currentSize = 0; + } catch { + this.currentSize = this.readCurrentSize(); + if (this.throwOnError) { + throw new Error(`Failed to rotate log file ${this.filePath}`); + } + } + } + + private pruneOverflowBackups(): void { + try { + const dir = path.dirname(this.filePath); + const baseName = path.basename(this.filePath); + for (const entry of fs.readdirSync(dir)) { + if (!entry.startsWith(`${baseName}.`)) continue; + const suffix = Number(entry.slice(baseName.length + 1)); + if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue; + fs.rmSync(path.join(dir, entry), { force: true }); + } + } catch { + if (this.throwOnError) { + throw new Error(`Failed to prune log backups for ${this.filePath}`); + } + } + } + + private readCurrentSize(): number { + try { + return fs.statSync(this.filePath).size; + } catch { + return 0; + } + } + + private withSuffix(index: number): string { + return `${this.filePath}.${index}`; + } +}