authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 17:01:12-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 18:22:29-08:00
log404e78a506ad1a6b0bd55eaa703fb0563e7dea7d
treea7baabc8c859f46e3e65bc8e9c28f3914d1b11d7
parent73563c7c25e31262852a1282ed8681a58e5f4b26

logging


15 files changed, 732 insertions(+), 103 deletions(-)

apps/desktop/package.json+1-1
......@@ -14,12 +14,12 @@
1414 "smoke-test": "node scripts/smoke-test.mjs"
1515 },
1616 "dependencies": {
17 "@t3tools/shared": "workspace:*",
1817 "effect": "catalog:",
1918 "electron": "40.6.0"
2019 },
2120 "devDependencies": {
2221 "@t3tools/contracts": "workspace:*",
22 "@t3tools/shared": "workspace:*",
2323 "@types/node": "catalog:",
2424 "electronmon": "^2.0.2",
2525 "tsdown": "catalog:",
apps/desktop/src/fixPath.ts+2-2
......@@ -1,11 +1,11 @@
1import { execFileSync } from "node:child_process";
1import * as ChildProcess from "node:child_process";
22
33export function fixPath(): void {
44 if (process.platform !== "darwin") return;
55
66 try {
77 const shell = process.env.SHELL ?? "/bin/zsh";
8 const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], {
8 const result = ChildProcess.execFileSync(shell, ["-ilc", "echo -n $PATH"], {
99 encoding: "utf8",
1010 timeout: 5000,
1111 });
apps/desktop/src/main.ts+229-49
......@@ -1,15 +1,16 @@
1import { spawn, type ChildProcess } from "node:child_process";
2import { randomBytes } from "node:crypto";
3import fs from "node:fs";
4import os from "node:os";
5import path from "node:path";
1import * as ChildProcess from "node:child_process";
2import * as Crypto from "node:crypto";
3import * as FS from "node:fs";
4import * as OS from "node:os";
5import * as Path from "node:path";
66
77import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";
88import type { MenuItemConstructorOptions } from "electron";
9import { Effect } from "effect";
9import * as Effect from "effect/Effect";
1010
1111import type { ContextMenuItem } from "@t3tools/contracts";
1212import { NetService } from "@t3tools/shared/Net";
13import { RotatingFileSink } from "@t3tools/shared/logging";
1314import { showDesktopConfirmDialog } from "./confirmDialog";
1415import { fixPath } from "./fixPath";
1516
......@@ -21,17 +22,21 @@ const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
2122const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
2223const MENU_ACTION_CHANNEL = "desktop:menu-action";
2324const STATE_DIR =
24 process.env.T3CODE_STATE_DIR?.trim() || path.join(os.homedir(), ".t3", "userdata");
25 process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata");
2526const DESKTOP_SCHEME = "t3";
26const ROOT_DIR = path.resolve(__dirname, "../../..");
27const ROOT_DIR = Path.resolve(__dirname, "../../..");
2728const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
2829const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
2930const APP_USER_MODEL_ID = "com.t3tools.t3code";
3031const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
3132const COMMIT_HASH_DISPLAY_LENGTH = 12;
33const LOG_DIR = Path.join(STATE_DIR, "logs");
34const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
35const LOG_FILE_MAX_FILES = 10;
36const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
3237
3338let mainWindow: BrowserWindow | null = null;
34let backendProcess: ChildProcess | null = null;
39let backendProcess: ChildProcess.ChildProcess | null = null;
3540let backendPort = 0;
3641let backendAuthToken = "";
3742let backendWsUrl = "";
......@@ -40,9 +45,133 @@ let restartTimer: ReturnType<typeof setTimeout> | null = null;
4045let isQuitting = false;
4146let desktopProtocolRegistered = false;
4247let aboutCommitHashCache: string | null | undefined;
48let desktopLogSink: RotatingFileSink | null = null;
49let backendLogSink: RotatingFileSink | null = null;
50let restoreStdIoCapture: (() => void) | null = null;
4351
4452let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
4553
54function logTimestamp(): string {
55 return new Date().toISOString();
56}
57
58function logScope(scope: string): string {
59 return `${scope} run=${APP_RUN_ID}`;
60}
61
62function sanitizeLogValue(value: string): string {
63 return value.replace(/\s+/g, " ").trim();
64}
65
66function writeDesktopLogHeader(message: string): void {
67 if (!desktopLogSink) return;
68 desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
69}
70
71function writeBackendSessionBoundary(phase: "START" | "END", details: string): void {
72 if (!backendLogSink) return;
73 const normalizedDetails = sanitizeLogValue(details);
74 backendLogSink.write(
75 `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`,
76 );
77}
78
79function formatErrorMessage(error: unknown): string {
80 if (error instanceof Error) {
81 return error.message;
82 }
83 return String(error);
84}
85
86function writeDesktopStreamChunk(
87 streamName: "stdout" | "stderr",
88 chunk: unknown,
89 encoding: BufferEncoding | undefined,
90): void {
91 if (!desktopLogSink) return;
92 const buffer = Buffer.isBuffer(chunk)
93 ? chunk
94 : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined);
95 desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `);
96 desktopLogSink.write(buffer);
97 if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) {
98 desktopLogSink.write("\n");
99 }
100}
101
102function installStdIoCapture(): void {
103 if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) {
104 return;
105 }
106
107 const originalStdoutWrite = process.stdout.write.bind(process.stdout);
108 const originalStderrWrite = process.stderr.write.bind(process.stderr);
109
110 const patchWrite =
111 (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) =>
112 (
113 chunk: string | Uint8Array,
114 encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
115 callback?: (error?: Error | null) => void,
116 ): boolean => {
117 const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined;
118 writeDesktopStreamChunk(streamName, chunk, encoding);
119 if (typeof encodingOrCallback === "function") {
120 return originalWrite(chunk, encodingOrCallback);
121 }
122 if (callback !== undefined) {
123 return originalWrite(chunk, encoding, callback);
124 }
125 if (encoding !== undefined) {
126 return originalWrite(chunk, encoding);
127 }
128 return originalWrite(chunk);
129 };
130
131 process.stdout.write = patchWrite("stdout", originalStdoutWrite);
132 process.stderr.write = patchWrite("stderr", originalStderrWrite);
133
134 restoreStdIoCapture = () => {
135 process.stdout.write = originalStdoutWrite;
136 process.stderr.write = originalStderrWrite;
137 restoreStdIoCapture = null;
138 };
139}
140
141function initializePackagedLogging(): void {
142 if (!app.isPackaged) return;
143 try {
144 desktopLogSink = new RotatingFileSink({
145 filePath: Path.join(LOG_DIR, "desktop-main.log"),
146 maxBytes: LOG_FILE_MAX_BYTES,
147 maxFiles: LOG_FILE_MAX_FILES,
148 });
149 backendLogSink = new RotatingFileSink({
150 filePath: Path.join(LOG_DIR, "server-child.log"),
151 maxBytes: LOG_FILE_MAX_BYTES,
152 maxFiles: LOG_FILE_MAX_FILES,
153 });
154 installStdIoCapture();
155 writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`);
156 } catch (error) {
157 // Logging setup should never block app startup.
158 console.error("[desktop] failed to initialize packaged logging", error);
159 }
160}
161
162function captureBackendOutput(child: ChildProcess.ChildProcess): void {
163 if (!app.isPackaged || backendLogSink === null) return;
164 const writeChunk = (chunk: unknown): void => {
165 if (!backendLogSink) return;
166 const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
167 backendLogSink.write(buffer);
168 };
169 child.stdout?.on("data", writeChunk);
170 child.stderr?.on("data", writeChunk);
171}
172
173initializePackagedLogging();
174
46175function getDestructiveMenuIcon(): Electron.NativeImage | undefined {
47176 if (process.platform !== "darwin") return undefined;
48177 if (destructiveMenuIconCache !== undefined) {
......@@ -97,13 +226,13 @@ function normalizeCommitHash(value: unknown): string | null {
97226}
98227
99228function resolveEmbeddedCommitHash(): string | null {
100 const packageJsonPath = path.join(resolveAppRoot(), "package.json");
101 if (!fs.existsSync(packageJsonPath)) {
229 const packageJsonPath = Path.join(resolveAppRoot(), "package.json");
230 if (!FS.existsSync(packageJsonPath)) {
102231 return null;
103232 }
104233
105234 try {
106 const raw = fs.readFileSync(packageJsonPath, "utf8");
235 const raw = FS.readFileSync(packageJsonPath, "utf8");
107236 const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown };
108237 return normalizeCommitHash(parsed.t3codeCommitHash);
109238 } catch {
......@@ -134,25 +263,25 @@ function resolveAboutCommitHash(): string | null {
134263}
135264
136265function resolveBackendEntry(): string {
137 return path.join(resolveAppRoot(), "apps/server/dist/index.mjs");
266 return Path.join(resolveAppRoot(), "apps/server/dist/index.mjs");
138267}
139268
140269function resolveBackendCwd(): string {
141270 if (!app.isPackaged) {
142271 return resolveAppRoot();
143272 }
144 return os.homedir();
273 return OS.homedir();
145274}
146275
147276function resolveDesktopStaticDir(): string | null {
148277 const appRoot = resolveAppRoot();
149278 const candidates = [
150 path.join(appRoot, "apps/server/dist/client"),
151 path.join(appRoot, "apps/web/dist"),
279 Path.join(appRoot, "apps/server/dist/client"),
280 Path.join(appRoot, "apps/web/dist"),
152281 ];
153282
154283 for (const candidate of candidates) {
155 if (fs.existsSync(path.join(candidate, "index.html"))) {
284 if (FS.existsSync(Path.join(candidate, "index.html"))) {
156285 return candidate;
157286 }
158287 }
......@@ -163,35 +292,50 @@ function resolveDesktopStaticDir(): string | null {
163292function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string {
164293 const url = new URL(requestUrl);
165294 const rawPath = decodeURIComponent(url.pathname);
166 const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, "");
295 const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, "");
167296 if (normalizedPath.includes("..")) {
168 return path.join(staticRoot, "index.html");
297 return Path.join(staticRoot, "index.html");
169298 }
170299
171300 const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html";
172 const resolvedPath = path.join(staticRoot, requestedPath);
301 const resolvedPath = Path.join(staticRoot, requestedPath);
173302
174 if (path.extname(resolvedPath)) {
303 if (Path.extname(resolvedPath)) {
175304 return resolvedPath;
176305 }
177306
178 const nestedIndex = path.join(resolvedPath, "index.html");
179 if (fs.existsSync(nestedIndex)) {
307 const nestedIndex = Path.join(resolvedPath, "index.html");
308 if (FS.existsSync(nestedIndex)) {
180309 return nestedIndex;
181310 }
182311
183 return path.join(staticRoot, "index.html");
312 return Path.join(staticRoot, "index.html");
184313}
185314
186315function isStaticAssetRequest(requestUrl: string): boolean {
187316 try {
188317 const url = new URL(requestUrl);
189 return path.extname(url.pathname).length > 0;
318 return Path.extname(url.pathname).length > 0;
190319 } catch {
191320 return false;
192321 }
193322}
194323
324function handleFatalStartupError(stage: string, error: unknown): void {
325 const message = formatErrorMessage(error);
326 const detail =
327 error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : "";
328 writeDesktopLogHeader(`fatal startup error stage=${stage} message=${message}`);
329 console.error(`[desktop] fatal startup error (${stage})`, error);
330 if (!isQuitting) {
331 isQuitting = true;
332 dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`);
333 }
334 stopBackend();
335 restoreStdIoCapture?.();
336 app.quit();
337}
338
195339function registerDesktopProtocol(): void {
196340 if (isDevelopment || desktopProtocolRegistered) return;
197341
......@@ -202,19 +346,19 @@ function registerDesktopProtocol(): void {
202346 );
203347 }
204348
205 const staticRootResolved = path.resolve(staticRoot);
206 const staticRootPrefix = `${staticRootResolved}${path.sep}`;
207 const fallbackIndex = path.join(staticRootResolved, "index.html");
349 const staticRootResolved = Path.resolve(staticRoot);
350 const staticRootPrefix = `${staticRootResolved}${Path.sep}`;
351 const fallbackIndex = Path.join(staticRootResolved, "index.html");
208352
209353 protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => {
210354 try {
211355 const candidate = resolveDesktopStaticPath(staticRootResolved, request.url);
212 const resolvedCandidate = path.resolve(candidate);
356 const resolvedCandidate = Path.resolve(candidate);
213357 const isInRoot =
214358 resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);
215359 const isAssetRequest = isStaticAssetRequest(request.url);
216360
217 if (!isInRoot || !fs.existsSync(resolvedCandidate)) {
361 if (!isInRoot || !FS.existsSync(resolvedCandidate)) {
218362 if (isAssetRequest) {
219363 callback({ error: -6 });
220364 return;
......@@ -309,13 +453,13 @@ function configureApplicationMenu(): void {
309453
310454function resolveResourcePath(fileName: string): string | null {
311455 const candidates = [
312 path.join(__dirname, "../resources", fileName),
313 path.join(process.resourcesPath, "resources", fileName),
314 path.join(process.resourcesPath, fileName),
456 Path.join(__dirname, "../resources", fileName),
457 Path.join(process.resourcesPath, "resources", fileName),
458 Path.join(process.resourcesPath, fileName),
315459 ];
316460
317461 for (const candidate of candidates) {
318 if (fs.existsSync(candidate)) {
462 if (FS.existsSync(candidate)) {
319463 return candidate;
320464 }
321465 }
......@@ -376,12 +520,13 @@ function startBackend(): void {
376520 if (isQuitting || backendProcess) return;
377521
378522 const backendEntry = resolveBackendEntry();
379 if (!fs.existsSync(backendEntry)) {
523 if (!FS.existsSync(backendEntry)) {
380524 scheduleBackendRestart(`missing server entry at ${backendEntry}`);
381525 return;
382526 }
383527
384 const child = spawn(process.execPath, [backendEntry], {
528 const captureBackendLogs = app.isPackaged && backendLogSink !== null;
529 const child = ChildProcess.spawn(process.execPath, [backendEntry], {
385530 cwd: resolveBackendCwd(),
386531 // In Electron main, process.execPath points to the Electron binary.
387532 // Run the child in Node mode so this backend process does not become a GUI app instance.
......@@ -389,9 +534,20 @@ function startBackend(): void {
389534 ...backendEnv(),
390535 ELECTRON_RUN_AS_NODE: "1",
391536 },
392 stdio: "inherit",
537 stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit",
393538 });
394539 backendProcess = child;
540 let backendSessionClosed = false;
541 const closeBackendSession = (details: string) => {
542 if (backendSessionClosed) return;
543 backendSessionClosed = true;
544 writeBackendSessionBoundary("END", details);
545 };
546 writeBackendSessionBoundary(
547 "START",
548 `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`,
549 );
550 captureBackendOutput(child);
395551
396552 child.once("spawn", () => {
397553 restartAttempt = 0;
......@@ -401,6 +557,7 @@ function startBackend(): void {
401557 if (backendProcess === child) {
402558 backendProcess = null;
403559 }
560 closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`);
404561 scheduleBackendRestart(error.message);
405562 });
406563
......@@ -408,6 +565,9 @@ function startBackend(): void {
408565 if (backendProcess === child) {
409566 backendProcess = null;
410567 }
568 closeBackendSession(
569 `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
570 );
411571 if (isQuitting) return;
412572 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
413573 scheduleBackendRestart(reason);
......@@ -566,7 +726,7 @@ function createWindow(): BrowserWindow {
566726 titleBarStyle: "hiddenInset",
567727 trafficLightPosition: { x: 16, y: 18 },
568728 webPreferences: {
569 preload: path.join(__dirname, "preload.js"),
729 preload: Path.join(__dirname, "preload.js"),
570730 contextIsolation: true,
571731 nodeIntegration: false,
572732 sandbox: true,
......@@ -604,37 +764,53 @@ function createWindow(): BrowserWindow {
604764configureAppIdentity();
605765
606766async function bootstrap(): Promise<void> {
767 writeDesktopLogHeader("bootstrap start");
607768 backendPort = await Effect.service(NetService).pipe(
608769 Effect.flatMap((net) => net.reserveLoopbackPort()),
609770 Effect.provide(NetService.layer),
610771 Effect.runPromise,
611772 );
612 backendAuthToken = randomBytes(24).toString("hex");
773 writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
774 backendAuthToken = Crypto.randomBytes(24).toString("hex");
613775 backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
614776 process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
777 writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`);
615778
616779 registerIpcHandlers();
780 writeDesktopLogHeader("bootstrap ipc handlers registered");
617781 startBackend();
782 writeDesktopLogHeader("bootstrap backend start requested");
618783 mainWindow = createWindow();
784 writeDesktopLogHeader("bootstrap main window created");
619785}
620786
621787app.on("before-quit", () => {
622788 isQuitting = true;
789 writeDesktopLogHeader("before-quit received");
623790 stopBackend();
791 restoreStdIoCapture?.();
624792});
625793
626app.whenReady().then(() => {
627 configureAppIdentity();
628 configureApplicationMenu();
629 registerDesktopProtocol();
630 void bootstrap();
794app
795 .whenReady()
796 .then(() => {
797 writeDesktopLogHeader("app ready");
798 configureAppIdentity();
799 configureApplicationMenu();
800 registerDesktopProtocol();
801 void bootstrap().catch((error) => {
802 handleFatalStartupError("bootstrap", error);
803 });
631804
632 app.on("activate", () => {
633 if (BrowserWindow.getAllWindows().length === 0) {
634 mainWindow = createWindow();
635 }
805 app.on("activate", () => {
806 if (BrowserWindow.getAllWindows().length === 0) {
807 mainWindow = createWindow();
808 }
809 });
810 })
811 .catch((error) => {
812 handleFatalStartupError("whenReady", error);
636813 });
637});
638814
639815app.on("window-all-closed", () => {
640816 if (process.platform !== "darwin") {
......@@ -646,14 +822,18 @@ if (process.platform !== "win32") {
646822 process.on("SIGINT", () => {
647823 if (isQuitting) return;
648824 isQuitting = true;
825 writeDesktopLogHeader("SIGINT received");
649826 stopBackend();
827 restoreStdIoCapture?.();
650828 app.quit();
651829 });
652830
653831 process.on("SIGTERM", () => {
654832 if (isQuitting) return;
655833 isQuitting = true;
834 writeDesktopLogHeader("SIGTERM received");
656835 stopBackend();
836 restoreStdIoCapture?.();
657837 app.quit();
658838 });
659839}
apps/desktop/src/rotatingFileSink.test.ts created+74
......@@ -0,0 +1,74 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { RotatingFileSink } from "@t3tools/shared/logging";
6import { afterEach, describe, expect, it } from "vitest";
7
8const tempRoots: string[] = [];
9
10function makeTempDir(): string {
11 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-"));
12 tempRoots.push(dir);
13 return dir;
14}
15
16afterEach(() => {
17 for (const dir of tempRoots.splice(0)) {
18 fs.rmSync(dir, { recursive: true, force: true });
19 }
20});
21
22describe("RotatingFileSink", () => {
23 it("rotates when writes exceed max bytes", () => {
24 const dir = makeTempDir();
25 const logPath = path.join(dir, "desktop-main.log");
26 const sink = new RotatingFileSink({
27 filePath: logPath,
28 maxBytes: 10,
29 maxFiles: 3,
30 });
31
32 sink.write("12345");
33 sink.write("67890");
34 sink.write("abc");
35
36 expect(fs.readFileSync(path.join(dir, "desktop-main.log"), "utf8")).toBe("abc");
37 expect(fs.readFileSync(path.join(dir, "desktop-main.log.1"), "utf8")).toBe("1234567890");
38 });
39
40 it("retains only maxFiles backups", () => {
41 const dir = makeTempDir();
42 const logPath = path.join(dir, "server-child.log");
43 const sink = new RotatingFileSink({
44 filePath: logPath,
45 maxBytes: 4,
46 maxFiles: 2,
47 });
48
49 sink.write("aaaa");
50 sink.write("bbbb");
51 sink.write("cccc");
52 sink.write("dddd");
53
54 expect(fs.existsSync(path.join(dir, "server-child.log.1"))).toBe(true);
55 expect(fs.existsSync(path.join(dir, "server-child.log.2"))).toBe(true);
56 expect(fs.existsSync(path.join(dir, "server-child.log.3"))).toBe(false);
57 });
58
59 it("prunes stale backups above maxFiles on startup", () => {
60 const dir = makeTempDir();
61 const logPath = path.join(dir, "desktop-main.log");
62 fs.writeFileSync(path.join(dir, "desktop-main.log.1"), "first");
63 fs.writeFileSync(path.join(dir, "desktop-main.log.4"), "stale");
64
65 const sink = new RotatingFileSink({
66 filePath: logPath,
67 maxBytes: 16,
68 maxFiles: 2,
69 });
70 sink.write("hello");
71
72 expect(fs.existsSync(path.join(dir, "desktop-main.log.4"))).toBe(false);
73 });
74});
apps/desktop/tsdown.config.ts+1-1
......@@ -12,7 +12,7 @@ export default defineConfig([
1212 ...shared,
1313 entry: ["src/main.ts"],
1414 clean: true,
15 noExternal: ["@t3tools/contracts"],
15 noExternal: (id) => id.startsWith("@t3tools/"),
1616 },
1717 {
1818 ...shared,
apps/server/src/provider/Layers/CodexAdapter.test.ts+15-1
......@@ -15,7 +15,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
1515import { afterAll, assert, it, vi } from "@effect/vitest";
1616import { assertFailure } from "@effect/vitest/utils";
1717
18import { Effect, Fiber, Layer, Stream } from "effect";
18import { Effect, Fiber, Layer, Option, Stream } from "effect";
1919
2020import {
2121 CodexAppServerManager,
......@@ -24,6 +24,7 @@ import {
2424import { ServerConfig } from "../../config.ts";
2525import { ProviderAdapterValidationError } from "../Errors.ts";
2626import { CodexAdapter } from "../Services/CodexAdapter.ts";
27import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
2728import { makeCodexAdapterLive } from "./CodexAdapter.ts";
2829
2930const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
......@@ -121,10 +122,21 @@ class FakeCodexManager extends CodexAppServerManager {
121122 }
122123}
123124
125const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
126 upsert: () => Effect.void,
127 getProvider: () =>
128 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
129 getBinding: () => Effect.succeed(Option.none()),
130 getThreadId: () => Effect.succeed(Option.none()),
131 remove: () => Effect.void,
132 listSessionIds: () => Effect.succeed([]),
133});
134
124135const validationManager = new FakeCodexManager();
125136const validationLayer = it.layer(
126137 makeCodexAdapterLive({ manager: validationManager }).pipe(
127138 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
139 Layer.provideMerge(providerSessionDirectoryTestLayer),
128140 Layer.provideMerge(NodeServices.layer),
129141 ),
130142);
......@@ -159,6 +171,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => {
159171const sessionErrorLayer = it.layer(
160172 makeCodexAdapterLive({ manager: sessionErrorManager }).pipe(
161173 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
174 Layer.provideMerge(providerSessionDirectoryTestLayer),
162175 Layer.provideMerge(NodeServices.layer),
163176 ),
164177);
......@@ -195,6 +208,7 @@ const lifecycleManager = new FakeCodexManager();
195208const lifecycleLayer = it.layer(
196209 makeCodexAdapterLive({ manager: lifecycleManager }).pipe(
197210 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
211 Layer.provideMerge(providerSessionDirectoryTestLayer),
198212 Layer.provideMerge(NodeServices.layer),
199213 ),
200214);
apps/server/src/provider/Layers/CodexAdapter.ts+31-8
......@@ -17,7 +17,7 @@ import {
1717 ProviderThreadId,
1818 ProviderTurnId,
1919} from "@t3tools/contracts";
20import { Effect, FileSystem, Layer, Queue, Schema, Stream } from "effect";
20import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect";
2121
2222import {
2323 ProviderAdapterProcessError,
......@@ -28,10 +28,11 @@ import {
2828 type ProviderAdapterError,
2929} from "../Errors.ts";
3030import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
31import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
3132import { CodexAppServerManager } from "../../codexAppServerManager.ts";
3233import { resolveAttachmentPath } from "../../attachmentStore.ts";
3334import { ServerConfig } from "../../config.ts";
34import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
35import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3536
3637const PROVIDER = "codex" as const;
3738
......@@ -39,6 +40,7 @@ export interface CodexAdapterLiveOptions {
3940 readonly manager?: CodexAppServerManager;
4041 readonly makeManager?: () => CodexAppServerManager;
4142 readonly nativeEventLogPath?: string;
43 readonly nativeEventLogger?: EventNdjsonLogger;
4244}
4345
4446function toMessage(cause: unknown, fallback: string): string {
......@@ -430,10 +432,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
430432 Effect.gen(function* () {
431433 const fileSystem = yield* FileSystem.FileSystem;
432434 const serverConfig = yield* Effect.service(ServerConfig);
435 const directory = yield* ProviderSessionDirectory;
433436 const nativeEventLogger =
434 options?.nativeEventLogPath !== undefined
437 options?.nativeEventLogger ??
438 (options?.nativeEventLogPath !== undefined
435439 ? makeEventNdjsonLogger(options.nativeEventLogPath)
436 : undefined;
440 : undefined);
437441
438442 const manager = yield* Effect.acquireRelease(
439443 Effect.sync(() => {
......@@ -580,11 +584,30 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
580584
581585 yield* Effect.acquireRelease(
582586 Effect.sync(() => {
587 const writeNativeEvent = (event: ProviderEvent) => {
588 if (!nativeEventLogger) {
589 return;
590 }
591 const loggedAt = new Date().toISOString();
592 const resolveOrchestrationThreadId = Effect.catch(
593 directory.getThreadId(event.sessionId).pipe(Effect.map(Option.getOrUndefined)),
594 () => Effect.void,
595 );
596 const writeLogEffect = Effect.tap(resolveOrchestrationThreadId, (orchestrationThreadId) =>
597 Effect.sync(() => {
598 nativeEventLogger.write({
599 observedAt: loggedAt,
600 stream: "native",
601 orchestrationThreadId: orchestrationThreadId ?? null,
602 event,
603 });
604 }),
605 );
606 void Effect.runPromise(writeLogEffect);
607 };
608
583609 const listener = (event: ProviderEvent) => {
584 nativeEventLogger?.write({
585 observedAt: new Date().toISOString(),
586 event,
587 });
610 writeNativeEvent(event);
588611 Queue.offerAllUnsafe(runtimeEventQueue, mapToRuntimeEvents(event));
589612 };
590613 manager.on("event", listener);
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+92-12
......@@ -7,31 +7,111 @@ import { describe, expect, it } from "vitest";
77import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
88
99describe("EventNdjsonLogger", () => {
10 it("writes NDJSON records to disk", () => {
10 it("writes effect-style lines to thread-scoped files", () => {
1111 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-"));
12 const filePath = path.join(tempDir, "provider-native.ndjson");
12 const basePath = path.join(tempDir, "provider-native.ndjson");
1313
1414 try {
15 const logger = makeEventNdjsonLogger(filePath);
15 const logger = makeEventNdjsonLogger(basePath);
1616 expect(logger).toBeDefined();
1717 if (!logger) {
1818 return;
1919 }
2020
21 logger.write({ type: "native", payload: { id: "evt-1" } });
22 logger.write({ type: "canonical", payload: { id: "evt-2" } });
21 logger.write({
22 observedAt: "2026-03-03T12:00:00.000Z",
23 stream: "native",
24 orchestrationThreadId: "thread-1",
25 event: { threadId: "provider-thread-1", id: "evt-1" },
26 });
27 logger.write({
28 observedAt: "2026-03-03T12:00:01.000Z",
29 stream: "canonical",
30 orchestrationThreadId: "thread-2",
31 event: { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" },
32 });
2333 logger.close();
2434
25 const raw = fs.readFileSync(filePath, "utf8").trim();
26 const lines = raw.split("\n");
27 expect(lines.length).toBe(2);
35 const threadOnePath = path.join(tempDir, "thread-1.log");
36 const threadTwoPath = path.join(tempDir, "thread-2.log");
37 expect(fs.existsSync(threadOnePath)).toBe(true);
38 expect(fs.existsSync(threadTwoPath)).toBe(true);
2839
29 const first = JSON.parse(lines[0] ?? "{}");
30 const second = JSON.parse(lines[1] ?? "{}");
31 expect(first).toEqual({ type: "native", payload: { id: "evt-1" } });
32 expect(second).toEqual({ type: "canonical", payload: { id: "evt-2" } });
40 const first = fs.readFileSync(threadOnePath, "utf8").trim();
41 const second = fs.readFileSync(threadTwoPath, "utf8").trim();
42 expect(first).toBe(
43 '[2026-03-03T12:00:00.000Z] NATIVE: {"threadId":"provider-thread-1","id":"evt-1"}',
44 );
45 expect(second).toBe(
46 '[2026-03-03T12:00:01.000Z] ORCHESTRATION: {"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}',
47 );
3348 } finally {
3449 fs.rmSync(tempDir, { recursive: true, force: true });
3550 }
3651 });
52
53 it("drops records without orchestration thread ids", () => {
54 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-"));
55 const basePath = path.join(tempDir, "provider-canonical.ndjson");
56
57 try {
58 const logger = makeEventNdjsonLogger(basePath);
59 expect(logger).toBeDefined();
60 if (!logger) {
61 return;
62 }
63
64 logger.write({ stream: "orchestration", event: { id: "evt-no-thread" } });
65 logger.close();
66
67 const logFiles = fs.readdirSync(tempDir).filter((entry) => entry.endsWith(".log"));
68 expect(logFiles).toEqual([]);
69 } finally {
70 fs.rmSync(tempDir, { recursive: true, force: true });
71 }
72 });
73
74 it("rotates per-thread files when max size is exceeded", () => {
75 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-"));
76 const basePath = path.join(tempDir, "provider-native.ndjson");
77
78 try {
79 const logger = makeEventNdjsonLogger(basePath, {
80 maxBytes: 120,
81 maxFiles: 2,
82 });
83 expect(logger).toBeDefined();
84 if (!logger) {
85 return;
86 }
87
88 for (let index = 0; index < 10; index += 1) {
89 logger.write({
90 stream: "native",
91 orchestrationThreadId: "thread-rotate",
92 event: {
93 threadId: "provider-thread-rotate",
94 id: `evt-${index}`,
95 payload: "x".repeat(40),
96 },
97 });
98 }
99 logger.close();
100
101 const fileStem = "thread-rotate.log";
102 const matchingFiles = fs
103 .readdirSync(tempDir)
104 .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`))
105 .toSorted();
106
107 expect(matchingFiles.some((entry) => entry === `${fileStem}.1`)).toBe(true);
108 expect(matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`)).toBe(
109 true,
110 );
111 expect(matchingFiles.some((entry) => entry === `${fileStem}.3`)).toBe(false);
112 } finally {
113 fs.rmSync(tempDir, { recursive: true, force: true });
114 }
115 });
116
37117});
apps/server/src/provider/Layers/EventNdjsonLogger.ts+129-10
......@@ -1,15 +1,20 @@
11/**
2 * Provider event NDJSON logger helper.
2 * Provider event logger helper.
33 *
4 * Best-effort writer for observability logs. Failures are downgraded to warnings
5 * so provider runtime behavior is unaffected.
4 * Best-effort writer for observability logs. Each record is formatted as a
5 * single effect-style text line in a thread-scoped file. Failures are
6 * downgraded to warnings so provider runtime behavior is unaffected.
67 */
78import fs from "node:fs";
89import path from "node:path";
910
11import { RotatingFileSink } from "@t3tools/shared/logging";
12import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";
1013import { createLogger } from "../../logger.ts";
1114
1215const logger = createLogger("provider-observability");
16const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
17const DEFAULT_MAX_FILES = 10;
1318
1419export interface EventNdjsonLogger {
1520 readonly filePath: string;
......@@ -17,6 +22,11 @@ export interface EventNdjsonLogger {
1722 close: () => void;
1823}
1924
25export interface EventNdjsonLoggerOptions {
26 readonly maxBytes?: number;
27 readonly maxFiles?: number;
28}
29
2030function stringifyRecord(record: unknown): string | undefined {
2131 try {
2232 return JSON.stringify(record);
......@@ -26,10 +36,79 @@ function stringifyRecord(record: unknown): string | undefined {
2636 }
2737}
2838
29export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | undefined {
39function normalizeThreadSegment(raw: unknown): string | null {
40 if (typeof raw !== "string") {
41 return null;
42 }
43 return toSafeThreadAttachmentSegment(raw);
44}
45
46function asObject(value: unknown): Record<string, unknown> | undefined {
47 if (!value || typeof value !== "object" || Array.isArray(value)) {
48 return undefined;
49 }
50 return value as Record<string, unknown>;
51}
52
53function resolveThreadSegment(record: unknown): string | null {
54 const root = asObject(record);
55 return normalizeThreadSegment(root?.orchestrationThreadId);
56}
57
58function resolveThreadFilePath(baseFilePath: string, threadSegment: string): string {
59 const dir = path.dirname(baseFilePath);
60 return path.join(dir, `${threadSegment}.log`);
61}
62
63function resolveObservedAt(raw: unknown): string {
64 if (typeof raw !== "string") {
65 return new Date().toISOString();
66 }
67 const trimmed = raw.trim();
68 if (trimmed.length === 0) {
69 return new Date().toISOString();
70 }
71 return trimmed;
72}
73
74function resolveStreamLabel(raw: unknown): string {
75 if (typeof raw !== "string") {
76 return "ORCHESTRATION";
77 }
78 const normalized = raw.trim().toLowerCase();
79 if (normalized === "native") {
80 return "NATIVE";
81 }
82 if (normalized === "canonical" || normalized === "orchestration") {
83 return "ORCHESTRATION";
84 }
85 if (normalized.length === 0) {
86 return "ORCHESTRATION";
87 }
88 return normalized.toUpperCase();
89}
90
91function toLogLine(record: unknown): string | undefined {
92 const root = asObject(record);
93 const payload = root && "event" in root ? root.event : record;
94 const serializedPayload = stringifyRecord(payload);
95 if (!serializedPayload) {
96 return undefined;
97 }
98 const observedAt = resolveObservedAt(root?.observedAt);
99 const streamLabel = resolveStreamLabel(root?.stream);
100 return `[${observedAt}] ${streamLabel}: ${serializedPayload}\n`;
101}
102
103export function makeEventNdjsonLogger(
104 filePath: string,
105 options?: EventNdjsonLoggerOptions,
106): EventNdjsonLogger | undefined {
107 const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
108 const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES;
109
30110 try {
31111 fs.mkdirSync(path.dirname(filePath), { recursive: true });
32 fs.appendFileSync(filePath, "", { encoding: "utf8", flag: "a" });
33112 } catch (error) {
34113 logger.warn("failed to create provider event log directory", { filePath, error });
35114 return undefined;
......@@ -37,6 +116,38 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und
37116
38117 try {
39118 let broken = false;
119 const sinks = new Map<string, RotatingFileSink>();
120 const failedSegments = new Set<string>();
121
122 const resolveSink = (threadSegment: string): RotatingFileSink | undefined => {
123 if (failedSegments.has(threadSegment)) {
124 return undefined;
125 }
126 const existingSink = sinks.get(threadSegment);
127 if (existingSink) {
128 return existingSink;
129 }
130
131 const threadFilePath = resolveThreadFilePath(filePath, threadSegment);
132 try {
133 const sink = new RotatingFileSink({
134 filePath: threadFilePath,
135 maxBytes,
136 maxFiles,
137 throwOnError: true,
138 });
139 sinks.set(threadSegment, sink);
140 return sink;
141 } catch (error) {
142 failedSegments.add(threadSegment);
143 logger.warn("failed to initialize provider thread log file", {
144 filePath: threadFilePath,
145 threadSegment,
146 error,
147 });
148 return undefined;
149 }
150 };
40151
41152 return {
42153 filePath,
......@@ -44,19 +155,27 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und
44155 if (broken) {
45156 return;
46157 }
47 const serialized = stringifyRecord(record);
48 if (!serialized) {
158 const threadSegment = resolveThreadSegment(record);
159 if (!threadSegment) {
160 return;
161 }
162 const line = toLogLine(record);
163 if (!line) {
164 return;
165 }
166 const sink = resolveSink(threadSegment);
167 if (!sink) {
49168 return;
50169 }
51170 try {
52 fs.appendFileSync(filePath, `${serialized}\n`, { encoding: "utf8", flag: "a" });
171 sink.write(line);
53172 } catch (error) {
54173 broken = true;
55 logger.warn("provider event log write failed", { filePath, error });
174 logger.warn("provider event log write failed", { filePath, threadSegment, error });
56175 }
57176 },
58177 close() {
59 // no-op for sync append logger
178 sinks.clear();
60179 },
61180 };
62181 } catch (error) {
apps/server/src/provider/Layers/ProviderService.ts+24-9
......@@ -30,10 +30,11 @@ import {
3030 ProviderSessionDirectory,
3131 type ProviderSessionBinding,
3232} from "../Services/ProviderSessionDirectory.ts";
33import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
33import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3434
3535export interface ProviderServiceLiveOptions {
3636 readonly canonicalEventLogPath?: string;
37 readonly canonicalEventLogger?: EventNdjsonLogger;
3738}
3839
3940const ProviderRollbackConversationInput = Schema.Struct({
......@@ -108,9 +109,10 @@ function readPersistedCwd(
108109const makeProviderService = (options?: ProviderServiceLiveOptions) =>
109110 Effect.gen(function* () {
110111 const canonicalEventLogger =
111 options?.canonicalEventLogPath !== undefined
112 options?.canonicalEventLogger ??
113 (options?.canonicalEventLogPath !== undefined
112114 ? makeEventNdjsonLogger(options.canonicalEventLogPath)
113 : undefined;
115 : undefined);
114116
115117 const registry = yield* ProviderAdapterRegistry;
116118 const directory = yield* ProviderSessionDirectory;
......@@ -140,12 +142,25 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
140142 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
141143 canonicalizeRuntimeEventSession(event).pipe(
142144 Effect.tap((canonicalEvent) =>
143 Effect.sync(() => {
144 canonicalEventLogger?.write({
145 observedAt: new Date().toISOString(),
146 event: canonicalEvent,
147 });
148 }),
145 canonicalEventLogger
146 ? Effect.tap(
147 Effect.catch(
148 directory.getThreadId(canonicalEvent.sessionId).pipe(
149 Effect.map(Option.getOrUndefined),
150 ),
151 () => Effect.void,
152 ),
153 (orchestrationThreadId) =>
154 Effect.sync(() => {
155 canonicalEventLogger.write({
156 observedAt: new Date().toISOString(),
157 stream: "canonical",
158 orchestrationThreadId: orchestrationThreadId ?? null,
159 event: canonicalEvent,
160 });
161 }),
162 )
163 : Effect.void,
149164 ),
150165 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),
151166 Effect.asVoid,
apps/server/src/serverLayers.ts+13-8
......@@ -23,6 +23,7 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe
2323import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
2424import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
2525import { ProviderService } from "./provider/Services/ProviderService";
26import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
2627
2728import { TerminalManagerLive } from "./terminal/Layers/Manager";
2829import { KeybindingsLive } from "./keybindings";
......@@ -41,17 +42,21 @@ export function makeServerProviderLayer(): Layer.Layer<
4142> {
4243 return Effect.gen(function* () {
4344 const { stateDir } = yield* ServerConfig;
44 const providerLogsDir = path.join(stateDir, "logs", "providers");
45 const codexAdapterLayer = makeCodexAdapterLive({
46 nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),
47 });
48 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer));
45 const providerLogsDir = path.join(stateDir, "logs", "provider");
46 const providerEventLogger = makeEventNdjsonLogger(path.join(providerLogsDir, "events.log"));
4947 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
5048 Layer.provide(ProviderSessionRuntimeRepositoryLive),
5149 );
52 return makeProviderServiceLive({
53 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
54 }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
50 const codexAdapterLayer = makeCodexAdapterLive(
51 providerEventLogger ? { nativeEventLogger: providerEventLogger } : undefined,
52 );
53 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
54 Layer.provide(codexAdapterLayer),
55 Layer.provideMerge(providerSessionDirectoryLayer),
56 );
57 return makeProviderServiceLive(
58 providerEventLogger ? { canonicalEventLogger: providerEventLogger } : undefined,
59 ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
5560 }).pipe(Layer.unwrap);
5661}
5762
apps/server/tsdown.config.ts+1-1
......@@ -9,7 +9,7 @@ export default defineConfig({
99 outDir: "dist",
1010 sourcemap: true,
1111 clean: true,
12 noExternal: ["@t3tools/contracts"],
12 noExternal: (id) => id.startsWith("@t3tools/"),
1313 inlineOnly: false,
1414 banner: {
1515 js: "#!/usr/bin/env node\n",
bun.lock+1-1
......@@ -16,12 +16,12 @@
1616 "name": "@t3tools/desktop",
1717 "version": "0.0.0-alpha.12",
1818 "dependencies": {
19 "@t3tools/shared": "workspace:*",
2019 "effect": "catalog:",
2120 "electron": "40.6.0",
2221 },
2322 "devDependencies": {
2423 "@t3tools/contracts": "workspace:*",
24 "@t3tools/shared": "workspace:*",
2525 "@types/node": "catalog:",
2626 "electronmon": "^2.0.2",
2727 "tsdown": "catalog:",
packages/shared/package.json+4
......@@ -8,6 +8,10 @@
88 "types": "./src/git.ts",
99 "import": "./src/git.ts"
1010 },
11 "./logging": {
12 "types": "./src/logging.ts",
13 "import": "./src/logging.ts"
14 },
1115 "./Net": {
1216 "types": "./src/Net.ts",
1317 "import": "./src/Net.ts"
packages/shared/src/logging.ts created+115
......@@ -0,0 +1,115 @@
1import fs from "node:fs";
2import path from "node:path";
3
4export interface RotatingFileSinkOptions {
5 readonly filePath: string;
6 readonly maxBytes: number;
7 readonly maxFiles: number;
8 readonly throwOnError?: boolean;
9}
10
11export class RotatingFileSink {
12 private readonly filePath: string;
13 private readonly maxBytes: number;
14 private readonly maxFiles: number;
15 private readonly throwOnError: boolean;
16 private currentSize = 0;
17
18 constructor(options: RotatingFileSinkOptions) {
19 if (options.maxBytes < 1) {
20 throw new Error(`maxBytes must be >= 1 (received ${options.maxBytes})`);
21 }
22 if (options.maxFiles < 1) {
23 throw new Error(`maxFiles must be >= 1 (received ${options.maxFiles})`);
24 }
25
26 this.filePath = options.filePath;
27 this.maxBytes = options.maxBytes;
28 this.maxFiles = options.maxFiles;
29 this.throwOnError = options.throwOnError ?? false;
30
31 fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
32 this.pruneOverflowBackups();
33 this.currentSize = this.readCurrentSize();
34 }
35
36 write(chunk: string | Buffer): void {
37 const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
38 if (buffer.length === 0) return;
39
40 try {
41 if (this.currentSize > 0 && this.currentSize + buffer.length > this.maxBytes) {
42 this.rotate();
43 }
44
45 fs.appendFileSync(this.filePath, buffer);
46 this.currentSize += buffer.length;
47
48 if (this.currentSize > this.maxBytes) {
49 this.rotate();
50 }
51 } catch {
52 this.currentSize = this.readCurrentSize();
53 if (this.throwOnError) {
54 throw new Error(`Failed to write log chunk to ${this.filePath}`);
55 }
56 }
57 }
58
59 private rotate(): void {
60 try {
61 const oldest = this.withSuffix(this.maxFiles);
62 if (fs.existsSync(oldest)) {
63 fs.rmSync(oldest, { force: true });
64 }
65
66 for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
67 const source = this.withSuffix(index);
68 const target = this.withSuffix(index + 1);
69 if (fs.existsSync(source)) {
70 fs.renameSync(source, target);
71 }
72 }
73
74 if (fs.existsSync(this.filePath)) {
75 fs.renameSync(this.filePath, this.withSuffix(1));
76 }
77
78 this.currentSize = 0;
79 } catch {
80 this.currentSize = this.readCurrentSize();
81 if (this.throwOnError) {
82 throw new Error(`Failed to rotate log file ${this.filePath}`);
83 }
84 }
85 }
86
87 private pruneOverflowBackups(): void {
88 try {
89 const dir = path.dirname(this.filePath);
90 const baseName = path.basename(this.filePath);
91 for (const entry of fs.readdirSync(dir)) {
92 if (!entry.startsWith(`${baseName}.`)) continue;
93 const suffix = Number(entry.slice(baseName.length + 1));
94 if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue;
95 fs.rmSync(path.join(dir, entry), { force: true });
96 }
97 } catch {
98 if (this.throwOnError) {
99 throw new Error(`Failed to prune log backups for ${this.filePath}`);
100 }
101 }
102 }
103
104 private readCurrentSize(): number {
105 try {
106 return fs.statSync(this.filePath).size;
107 } catch {
108 return 0;
109 }
110 }
111
112 private withSuffix(index: number): string {
113 return `${this.filePath}.${index}`;
114 }
115}