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 @@...@@ -14,12 +14,12 @@
14 "smoke-test": "node scripts/smoke-test.mjs"14 "smoke-test": "node scripts/smoke-test.mjs"
15 },15 },
16 "dependencies": {16 "dependencies": {
17 "@t3tools/shared": "workspace:*",
18 "effect": "catalog:",17 "effect": "catalog:",
19 "electron": "40.6.0"18 "electron": "40.6.0"
20 },19 },
21 "devDependencies": {20 "devDependencies": {
22 "@t3tools/contracts": "workspace:*",21 "@t3tools/contracts": "workspace:*",
22 "@t3tools/shared": "workspace:*",
23 "@types/node": "catalog:",23 "@types/node": "catalog:",
24 "electronmon": "^2.0.2",24 "electronmon": "^2.0.2",
25 "tsdown": "catalog:",25 "tsdown": "catalog:",
apps/desktop/src/fixPath.ts+2-2
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1import { execFileSync } from "node:child_process";1import * as ChildProcess from "node:child_process";
22
3export function fixPath(): void {3export function fixPath(): void {
4 if (process.platform !== "darwin") return;4 if (process.platform !== "darwin") return;
55
6 try {6 try {
7 const shell = process.env.SHELL ?? "/bin/zsh";7 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"], {
9 encoding: "utf8",9 encoding: "utf8",
10 timeout: 5000,10 timeout: 5000,
11 });11 });
apps/desktop/src/main.ts+229-49
...@@ -1,15 +1,16 @@...@@ -1,15 +1,16 @@
1import { spawn, type ChildProcess } from "node:child_process";1import * as ChildProcess from "node:child_process";
2import { randomBytes } from "node:crypto";2import * as Crypto from "node:crypto";
3import fs from "node:fs";3import * as FS from "node:fs";
4import os from "node:os";4import * as OS from "node:os";
5import path from "node:path";5import * as Path from "node:path";
66
7import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";7import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";
8import type { MenuItemConstructorOptions } from "electron";8import type { MenuItemConstructorOptions } from "electron";
9import { Effect } from "effect";9import * as Effect from "effect/Effect";
1010
11import type { ContextMenuItem } from "@t3tools/contracts";11import type { ContextMenuItem } from "@t3tools/contracts";
12import { NetService } from "@t3tools/shared/Net";12import { NetService } from "@t3tools/shared/Net";
13import { RotatingFileSink } from "@t3tools/shared/logging";
13import { showDesktopConfirmDialog } from "./confirmDialog";14import { showDesktopConfirmDialog } from "./confirmDialog";
14import { fixPath } from "./fixPath";15import { fixPath } from "./fixPath";
1516
...@@ -21,17 +22,21 @@ const CONTEXT_MENU_CHANNEL = "desktop:context-menu";...@@ -21,17 +22,21 @@ const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
21const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";22const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
22const MENU_ACTION_CHANNEL = "desktop:menu-action";23const MENU_ACTION_CHANNEL = "desktop:menu-action";
23const STATE_DIR =24const 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");
25const DESKTOP_SCHEME = "t3";26const DESKTOP_SCHEME = "t3";
26const ROOT_DIR = path.resolve(__dirname, "../../..");27const ROOT_DIR = Path.resolve(__dirname, "../../..");
27const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);28const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
28const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";29const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
29const APP_USER_MODEL_ID = "com.t3tools.t3code";30const APP_USER_MODEL_ID = "com.t3tools.t3code";
30const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;31const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
31const COMMIT_HASH_DISPLAY_LENGTH = 12;32const 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
33let mainWindow: BrowserWindow | null = null;38let mainWindow: BrowserWindow | null = null;
34let backendProcess: ChildProcess | null = null;39let backendProcess: ChildProcess.ChildProcess | null = null;
35let backendPort = 0;40let backendPort = 0;
36let backendAuthToken = "";41let backendAuthToken = "";
37let backendWsUrl = "";42let backendWsUrl = "";
...@@ -40,9 +45,133 @@ let restartTimer: ReturnType<typeof setTimeout> | null = null;...@@ -40,9 +45,133 @@ let restartTimer: ReturnType<typeof setTimeout> | null = null;
40let isQuitting = false;45let isQuitting = false;
41let desktopProtocolRegistered = false;46let desktopProtocolRegistered = false;
42let aboutCommitHashCache: string | null | undefined;47let aboutCommitHashCache: string | null | undefined;
48let desktopLogSink: RotatingFileSink | null = null;
49let backendLogSink: RotatingFileSink | null = null;
50let restoreStdIoCapture: (() => void) | null = null;
4351
44let destructiveMenuIconCache: Electron.NativeImage | null | undefined;52let 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
46function getDestructiveMenuIcon(): Electron.NativeImage | undefined {175function getDestructiveMenuIcon(): Electron.NativeImage | undefined {
47 if (process.platform !== "darwin") return undefined;176 if (process.platform !== "darwin") return undefined;
48 if (destructiveMenuIconCache !== undefined) {177 if (destructiveMenuIconCache !== undefined) {
...@@ -97,13 +226,13 @@ function normalizeCommitHash(value: unknown): string | null {...@@ -97,13 +226,13 @@ function normalizeCommitHash(value: unknown): string | null {
97}226}
98227
99function resolveEmbeddedCommitHash(): string | null {228function resolveEmbeddedCommitHash(): string | null {
100 const packageJsonPath = path.join(resolveAppRoot(), "package.json");229 const packageJsonPath = Path.join(resolveAppRoot(), "package.json");
101 if (!fs.existsSync(packageJsonPath)) {230 if (!FS.existsSync(packageJsonPath)) {
102 return null;231 return null;
103 }232 }
104233
105 try {234 try {
106 const raw = fs.readFileSync(packageJsonPath, "utf8");235 const raw = FS.readFileSync(packageJsonPath, "utf8");
107 const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown };236 const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown };
108 return normalizeCommitHash(parsed.t3codeCommitHash);237 return normalizeCommitHash(parsed.t3codeCommitHash);
109 } catch {238 } catch {
...@@ -134,25 +263,25 @@ function resolveAboutCommitHash(): string | null {...@@ -134,25 +263,25 @@ function resolveAboutCommitHash(): string | null {
134}263}
135264
136function resolveBackendEntry(): string {265function resolveBackendEntry(): string {
137 return path.join(resolveAppRoot(), "apps/server/dist/index.mjs");266 return Path.join(resolveAppRoot(), "apps/server/dist/index.mjs");
138}267}
139268
140function resolveBackendCwd(): string {269function resolveBackendCwd(): string {
141 if (!app.isPackaged) {270 if (!app.isPackaged) {
142 return resolveAppRoot();271 return resolveAppRoot();
143 }272 }
144 return os.homedir();273 return OS.homedir();
145}274}
146275
147function resolveDesktopStaticDir(): string | null {276function resolveDesktopStaticDir(): string | null {
148 const appRoot = resolveAppRoot();277 const appRoot = resolveAppRoot();
149 const candidates = [278 const candidates = [
150 path.join(appRoot, "apps/server/dist/client"),279 Path.join(appRoot, "apps/server/dist/client"),
151 path.join(appRoot, "apps/web/dist"),280 Path.join(appRoot, "apps/web/dist"),
152 ];281 ];
153282
154 for (const candidate of candidates) {283 for (const candidate of candidates) {
155 if (fs.existsSync(path.join(candidate, "index.html"))) {284 if (FS.existsSync(Path.join(candidate, "index.html"))) {
156 return candidate;285 return candidate;
157 }286 }
158 }287 }
...@@ -163,35 +292,50 @@ function resolveDesktopStaticDir(): string | null {...@@ -163,35 +292,50 @@ function resolveDesktopStaticDir(): string | null {
163function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string {292function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string {
164 const url = new URL(requestUrl);293 const url = new URL(requestUrl);
165 const rawPath = decodeURIComponent(url.pathname);294 const rawPath = decodeURIComponent(url.pathname);
166 const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, "");295 const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, "");
167 if (normalizedPath.includes("..")) {296 if (normalizedPath.includes("..")) {
168 return path.join(staticRoot, "index.html");297 return Path.join(staticRoot, "index.html");
169 }298 }
170299
171 const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html";300 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)) {
175 return resolvedPath;304 return resolvedPath;
176 }305 }
177306
178 const nestedIndex = path.join(resolvedPath, "index.html");307 const nestedIndex = Path.join(resolvedPath, "index.html");
179 if (fs.existsSync(nestedIndex)) {308 if (FS.existsSync(nestedIndex)) {
180 return nestedIndex;309 return nestedIndex;
181 }310 }
182311
183 return path.join(staticRoot, "index.html");312 return Path.join(staticRoot, "index.html");
184}313}
185314
186function isStaticAssetRequest(requestUrl: string): boolean {315function isStaticAssetRequest(requestUrl: string): boolean {
187 try {316 try {
188 const url = new URL(requestUrl);317 const url = new URL(requestUrl);
189 return path.extname(url.pathname).length > 0;318 return Path.extname(url.pathname).length > 0;
190 } catch {319 } catch {
191 return false;320 return false;
192 }321 }
193}322}
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
195function registerDesktopProtocol(): void {339function registerDesktopProtocol(): void {
196 if (isDevelopment || desktopProtocolRegistered) return;340 if (isDevelopment || desktopProtocolRegistered) return;
197341
...@@ -202,19 +346,19 @@ function registerDesktopProtocol(): void {...@@ -202,19 +346,19 @@ function registerDesktopProtocol(): void {
202 );346 );
203 }347 }
204348
205 const staticRootResolved = path.resolve(staticRoot);349 const staticRootResolved = Path.resolve(staticRoot);
206 const staticRootPrefix = `${staticRootResolved}${path.sep}`;350 const staticRootPrefix = `${staticRootResolved}${Path.sep}`;
207 const fallbackIndex = path.join(staticRootResolved, "index.html");351 const fallbackIndex = Path.join(staticRootResolved, "index.html");
208352
209 protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => {353 protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => {
210 try {354 try {
211 const candidate = resolveDesktopStaticPath(staticRootResolved, request.url);355 const candidate = resolveDesktopStaticPath(staticRootResolved, request.url);
212 const resolvedCandidate = path.resolve(candidate);356 const resolvedCandidate = Path.resolve(candidate);
213 const isInRoot =357 const isInRoot =
214 resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);358 resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);
215 const isAssetRequest = isStaticAssetRequest(request.url);359 const isAssetRequest = isStaticAssetRequest(request.url);
216360
217 if (!isInRoot || !fs.existsSync(resolvedCandidate)) {361 if (!isInRoot || !FS.existsSync(resolvedCandidate)) {
218 if (isAssetRequest) {362 if (isAssetRequest) {
219 callback({ error: -6 });363 callback({ error: -6 });
220 return;364 return;
...@@ -309,13 +453,13 @@ function configureApplicationMenu(): void {...@@ -309,13 +453,13 @@ function configureApplicationMenu(): void {
309453
310function resolveResourcePath(fileName: string): string | null {454function resolveResourcePath(fileName: string): string | null {
311 const candidates = [455 const candidates = [
312 path.join(__dirname, "../resources", fileName),456 Path.join(__dirname, "../resources", fileName),
313 path.join(process.resourcesPath, "resources", fileName),457 Path.join(process.resourcesPath, "resources", fileName),
314 path.join(process.resourcesPath, fileName),458 Path.join(process.resourcesPath, fileName),
315 ];459 ];
316460
317 for (const candidate of candidates) {461 for (const candidate of candidates) {
318 if (fs.existsSync(candidate)) {462 if (FS.existsSync(candidate)) {
319 return candidate;463 return candidate;
320 }464 }
321 }465 }
...@@ -376,12 +520,13 @@ function startBackend(): void {...@@ -376,12 +520,13 @@ function startBackend(): void {
376 if (isQuitting || backendProcess) return;520 if (isQuitting || backendProcess) return;
377521
378 const backendEntry = resolveBackendEntry();522 const backendEntry = resolveBackendEntry();
379 if (!fs.existsSync(backendEntry)) {523 if (!FS.existsSync(backendEntry)) {
380 scheduleBackendRestart(`missing server entry at ${backendEntry}`);524 scheduleBackendRestart(`missing server entry at ${backendEntry}`);
381 return;525 return;
382 }526 }
383527
384 const child = spawn(process.execPath, [backendEntry], {528 const captureBackendLogs = app.isPackaged && backendLogSink !== null;
529 const child = ChildProcess.spawn(process.execPath, [backendEntry], {
385 cwd: resolveBackendCwd(),530 cwd: resolveBackendCwd(),
386 // In Electron main, process.execPath points to the Electron binary.531 // In Electron main, process.execPath points to the Electron binary.
387 // Run the child in Node mode so this backend process does not become a GUI app instance.532 // Run the child in Node mode so this backend process does not become a GUI app instance.
...@@ -389,9 +534,20 @@ function startBackend(): void {...@@ -389,9 +534,20 @@ function startBackend(): void {
389 ...backendEnv(),534 ...backendEnv(),
390 ELECTRON_RUN_AS_NODE: "1",535 ELECTRON_RUN_AS_NODE: "1",
391 },536 },
392 stdio: "inherit",537 stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit",
393 });538 });
394 backendProcess = child;539 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
396 child.once("spawn", () => {552 child.once("spawn", () => {
397 restartAttempt = 0;553 restartAttempt = 0;
...@@ -401,6 +557,7 @@ function startBackend(): void {...@@ -401,6 +557,7 @@ function startBackend(): void {
401 if (backendProcess === child) {557 if (backendProcess === child) {
402 backendProcess = null;558 backendProcess = null;
403 }559 }
560 closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`);
404 scheduleBackendRestart(error.message);561 scheduleBackendRestart(error.message);
405 });562 });
406563
...@@ -408,6 +565,9 @@ function startBackend(): void {...@@ -408,6 +565,9 @@ function startBackend(): void {
408 if (backendProcess === child) {565 if (backendProcess === child) {
409 backendProcess = null;566 backendProcess = null;
410 }567 }
568 closeBackendSession(
569 `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
570 );
411 if (isQuitting) return;571 if (isQuitting) return;
412 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;572 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
413 scheduleBackendRestart(reason);573 scheduleBackendRestart(reason);
...@@ -566,7 +726,7 @@ function createWindow(): BrowserWindow {...@@ -566,7 +726,7 @@ function createWindow(): BrowserWindow {
566 titleBarStyle: "hiddenInset",726 titleBarStyle: "hiddenInset",
567 trafficLightPosition: { x: 16, y: 18 },727 trafficLightPosition: { x: 16, y: 18 },
568 webPreferences: {728 webPreferences: {
569 preload: path.join(__dirname, "preload.js"),729 preload: Path.join(__dirname, "preload.js"),
570 contextIsolation: true,730 contextIsolation: true,
571 nodeIntegration: false,731 nodeIntegration: false,
572 sandbox: true,732 sandbox: true,
...@@ -604,37 +764,53 @@ function createWindow(): BrowserWindow {...@@ -604,37 +764,53 @@ function createWindow(): BrowserWindow {
604configureAppIdentity();764configureAppIdentity();
605765
606async function bootstrap(): Promise<void> {766async function bootstrap(): Promise<void> {
767 writeDesktopLogHeader("bootstrap start");
607 backendPort = await Effect.service(NetService).pipe(768 backendPort = await Effect.service(NetService).pipe(
608 Effect.flatMap((net) => net.reserveLoopbackPort()),769 Effect.flatMap((net) => net.reserveLoopbackPort()),
609 Effect.provide(NetService.layer),770 Effect.provide(NetService.layer),
610 Effect.runPromise,771 Effect.runPromise,
611 );772 );
612 backendAuthToken = randomBytes(24).toString("hex");773 writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
774 backendAuthToken = Crypto.randomBytes(24).toString("hex");
613 backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;775 backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
614 process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;776 process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
777 writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`);
615778
616 registerIpcHandlers();779 registerIpcHandlers();
780 writeDesktopLogHeader("bootstrap ipc handlers registered");
617 startBackend();781 startBackend();
782 writeDesktopLogHeader("bootstrap backend start requested");
618 mainWindow = createWindow();783 mainWindow = createWindow();
784 writeDesktopLogHeader("bootstrap main window created");
619}785}
620786
621app.on("before-quit", () => {787app.on("before-quit", () => {
622 isQuitting = true;788 isQuitting = true;
789 writeDesktopLogHeader("before-quit received");
623 stopBackend();790 stopBackend();
791 restoreStdIoCapture?.();
624});792});
625793
626app.whenReady().then(() => {794app
627 configureAppIdentity();795 .whenReady()
628 configureApplicationMenu();796 .then(() => {
629 registerDesktopProtocol();797 writeDesktopLogHeader("app ready");
630 void bootstrap();798 configureAppIdentity();
799 configureApplicationMenu();
800 registerDesktopProtocol();
801 void bootstrap().catch((error) => {
802 handleFatalStartupError("bootstrap", error);
803 });
631804
632 app.on("activate", () => {805 app.on("activate", () => {
633 if (BrowserWindow.getAllWindows().length === 0) {806 if (BrowserWindow.getAllWindows().length === 0) {
634 mainWindow = createWindow();807 mainWindow = createWindow();
635 }808 }
809 });
810 })
811 .catch((error) => {
812 handleFatalStartupError("whenReady", error);
636 });813 });
637});
638814
639app.on("window-all-closed", () => {815app.on("window-all-closed", () => {
640 if (process.platform !== "darwin") {816 if (process.platform !== "darwin") {
...@@ -646,14 +822,18 @@ if (process.platform !== "win32") {...@@ -646,14 +822,18 @@ if (process.platform !== "win32") {
646 process.on("SIGINT", () => {822 process.on("SIGINT", () => {
647 if (isQuitting) return;823 if (isQuitting) return;
648 isQuitting = true;824 isQuitting = true;
825 writeDesktopLogHeader("SIGINT received");
649 stopBackend();826 stopBackend();
827 restoreStdIoCapture?.();
650 app.quit();828 app.quit();
651 });829 });
652830
653 process.on("SIGTERM", () => {831 process.on("SIGTERM", () => {
654 if (isQuitting) return;832 if (isQuitting) return;
655 isQuitting = true;833 isQuitting = true;
834 writeDesktopLogHeader("SIGTERM received");
656 stopBackend();835 stopBackend();
836 restoreStdIoCapture?.();
657 app.quit();837 app.quit();
658 });838 });
659}839}
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([...@@ -12,7 +12,7 @@ export default defineConfig([
12 ...shared,12 ...shared,
13 entry: ["src/main.ts"],13 entry: ["src/main.ts"],
14 clean: true,14 clean: true,
15 noExternal: ["@t3tools/contracts"],15 noExternal: (id) => id.startsWith("@t3tools/"),
16 },16 },
17 {17 {
18 ...shared,18 ...shared,
apps/server/src/provider/Layers/CodexAdapter.test.ts+15-1
...@@ -15,7 +15,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -15,7 +15,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
15import { afterAll, assert, it, vi } from "@effect/vitest";15import { afterAll, assert, it, vi } from "@effect/vitest";
16import { assertFailure } from "@effect/vitest/utils";16import { assertFailure } from "@effect/vitest/utils";
1717
18import { Effect, Fiber, Layer, Stream } from "effect";18import { Effect, Fiber, Layer, Option, Stream } from "effect";
1919
20import {20import {
21 CodexAppServerManager,21 CodexAppServerManager,
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24import { ServerConfig } from "../../config.ts";24import { ServerConfig } from "../../config.ts";
25import { ProviderAdapterValidationError } from "../Errors.ts";25import { ProviderAdapterValidationError } from "../Errors.ts";
26import { CodexAdapter } from "../Services/CodexAdapter.ts";26import { CodexAdapter } from "../Services/CodexAdapter.ts";
27import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
27import { makeCodexAdapterLive } from "./CodexAdapter.ts";28import { makeCodexAdapterLive } from "./CodexAdapter.ts";
2829
29const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);30const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
...@@ -121,10 +122,21 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -121,10 +122,21 @@ class FakeCodexManager extends CodexAppServerManager {
121 }122 }
122}123}
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
124const validationManager = new FakeCodexManager();135const validationManager = new FakeCodexManager();
125const validationLayer = it.layer(136const validationLayer = it.layer(
126 makeCodexAdapterLive({ manager: validationManager }).pipe(137 makeCodexAdapterLive({ manager: validationManager }).pipe(
127 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),138 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
139 Layer.provideMerge(providerSessionDirectoryTestLayer),
128 Layer.provideMerge(NodeServices.layer),140 Layer.provideMerge(NodeServices.layer),
129 ),141 ),
130);142);
...@@ -159,6 +171,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => {...@@ -159,6 +171,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => {
159const sessionErrorLayer = it.layer(171const sessionErrorLayer = it.layer(
160 makeCodexAdapterLive({ manager: sessionErrorManager }).pipe(172 makeCodexAdapterLive({ manager: sessionErrorManager }).pipe(
161 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),173 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
174 Layer.provideMerge(providerSessionDirectoryTestLayer),
162 Layer.provideMerge(NodeServices.layer),175 Layer.provideMerge(NodeServices.layer),
163 ),176 ),
164);177);
...@@ -195,6 +208,7 @@ const lifecycleManager = new FakeCodexManager();...@@ -195,6 +208,7 @@ const lifecycleManager = new FakeCodexManager();
195const lifecycleLayer = it.layer(208const lifecycleLayer = it.layer(
196 makeCodexAdapterLive({ manager: lifecycleManager }).pipe(209 makeCodexAdapterLive({ manager: lifecycleManager }).pipe(
197 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),210 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
211 Layer.provideMerge(providerSessionDirectoryTestLayer),
198 Layer.provideMerge(NodeServices.layer),212 Layer.provideMerge(NodeServices.layer),
199 ),213 ),
200);214);
apps/server/src/provider/Layers/CodexAdapter.ts+31-8
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17 ProviderThreadId,17 ProviderThreadId,
18 ProviderTurnId,18 ProviderTurnId,
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import { Effect, FileSystem, Layer, Queue, Schema, Stream } from "effect";20import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect";
2121
22import {22import {
23 ProviderAdapterProcessError,23 ProviderAdapterProcessError,
...@@ -28,10 +28,11 @@ import {...@@ -28,10 +28,11 @@ import {
28 type ProviderAdapterError,28 type ProviderAdapterError,
29} from "../Errors.ts";29} from "../Errors.ts";
30import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";30import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
31import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
31import { CodexAppServerManager } from "../../codexAppServerManager.ts";32import { CodexAppServerManager } from "../../codexAppServerManager.ts";
32import { resolveAttachmentPath } from "../../attachmentStore.ts";33import { resolveAttachmentPath } from "../../attachmentStore.ts";
33import { ServerConfig } from "../../config.ts";34import { ServerConfig } from "../../config.ts";
34import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";35import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3536
36const PROVIDER = "codex" as const;37const PROVIDER = "codex" as const;
3738
...@@ -39,6 +40,7 @@ export interface CodexAdapterLiveOptions {...@@ -39,6 +40,7 @@ export interface CodexAdapterLiveOptions {
39 readonly manager?: CodexAppServerManager;40 readonly manager?: CodexAppServerManager;
40 readonly makeManager?: () => CodexAppServerManager;41 readonly makeManager?: () => CodexAppServerManager;
41 readonly nativeEventLogPath?: string;42 readonly nativeEventLogPath?: string;
43 readonly nativeEventLogger?: EventNdjsonLogger;
42}44}
4345
44function toMessage(cause: unknown, fallback: string): string {46function toMessage(cause: unknown, fallback: string): string {
...@@ -430,10 +432,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -430,10 +432,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
430 Effect.gen(function* () {432 Effect.gen(function* () {
431 const fileSystem = yield* FileSystem.FileSystem;433 const fileSystem = yield* FileSystem.FileSystem;
432 const serverConfig = yield* Effect.service(ServerConfig);434 const serverConfig = yield* Effect.service(ServerConfig);
435 const directory = yield* ProviderSessionDirectory;
433 const nativeEventLogger =436 const nativeEventLogger =
434 options?.nativeEventLogPath !== undefined437 options?.nativeEventLogger ??
438 (options?.nativeEventLogPath !== undefined
435 ? makeEventNdjsonLogger(options.nativeEventLogPath)439 ? makeEventNdjsonLogger(options.nativeEventLogPath)
436 : undefined;440 : undefined);
437441
438 const manager = yield* Effect.acquireRelease(442 const manager = yield* Effect.acquireRelease(
439 Effect.sync(() => {443 Effect.sync(() => {
...@@ -580,11 +584,30 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -580,11 +584,30 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
580584
581 yield* Effect.acquireRelease(585 yield* Effect.acquireRelease(
582 Effect.sync(() => {586 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
583 const listener = (event: ProviderEvent) => {609 const listener = (event: ProviderEvent) => {
584 nativeEventLogger?.write({610 writeNativeEvent(event);
585 observedAt: new Date().toISOString(),
586 event,
587 });
588 Queue.offerAllUnsafe(runtimeEventQueue, mapToRuntimeEvents(event));611 Queue.offerAllUnsafe(runtimeEventQueue, mapToRuntimeEvents(event));
589 };612 };
590 manager.on("event", listener);613 manager.on("event", listener);
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+92-12
...@@ -7,31 +7,111 @@ import { describe, expect, it } from "vitest";...@@ -7,31 +7,111 @@ import { describe, expect, it } from "vitest";
7import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";7import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
88
9describe("EventNdjsonLogger", () => {9describe("EventNdjsonLogger", () => {
10 it("writes NDJSON records to disk", () => {10 it("writes effect-style lines to thread-scoped files", () => {
11 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-"));11 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
14 try {14 try {
15 const logger = makeEventNdjsonLogger(filePath);15 const logger = makeEventNdjsonLogger(basePath);
16 expect(logger).toBeDefined();16 expect(logger).toBeDefined();
17 if (!logger) {17 if (!logger) {
18 return;18 return;
19 }19 }
2020
21 logger.write({ type: "native", payload: { id: "evt-1" } });21 logger.write({
22 logger.write({ type: "canonical", payload: { id: "evt-2" } });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 });
23 logger.close();33 logger.close();
2434
25 const raw = fs.readFileSync(filePath, "utf8").trim();35 const threadOnePath = path.join(tempDir, "thread-1.log");
26 const lines = raw.split("\n");36 const threadTwoPath = path.join(tempDir, "thread-2.log");
27 expect(lines.length).toBe(2);37 expect(fs.existsSync(threadOnePath)).toBe(true);
38 expect(fs.existsSync(threadTwoPath)).toBe(true);
2839
29 const first = JSON.parse(lines[0] ?? "{}");40 const first = fs.readFileSync(threadOnePath, "utf8").trim();
30 const second = JSON.parse(lines[1] ?? "{}");41 const second = fs.readFileSync(threadTwoPath, "utf8").trim();
31 expect(first).toEqual({ type: "native", payload: { id: "evt-1" } });42 expect(first).toBe(
32 expect(second).toEqual({ type: "canonical", payload: { id: "evt-2" } });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 );
33 } finally {48 } finally {
34 fs.rmSync(tempDir, { recursive: true, force: true });49 fs.rmSync(tempDir, { recursive: true, force: true });
35 }50 }
36 });51 });
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
37});117});
apps/server/src/provider/Layers/EventNdjsonLogger.ts+129-10
...@@ -1,15 +1,20 @@...@@ -1,15 +1,20 @@
1/**1/**
2 * Provider event NDJSON logger helper.2 * Provider event logger helper.
3 *3 *
4 * Best-effort writer for observability logs. Failures are downgraded to warnings4 * Best-effort writer for observability logs. Each record is formatted as a
5 * so provider runtime behavior is unaffected.5 * single effect-style text line in a thread-scoped file. Failures are
6 * downgraded to warnings so provider runtime behavior is unaffected.
6 */7 */
7import fs from "node:fs";8import fs from "node:fs";
8import path from "node:path";9import path from "node:path";
910
11import { RotatingFileSink } from "@t3tools/shared/logging";
12import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";
10import { createLogger } from "../../logger.ts";13import { createLogger } from "../../logger.ts";
1114
12const logger = createLogger("provider-observability");15const logger = createLogger("provider-observability");
16const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
17const DEFAULT_MAX_FILES = 10;
1318
14export interface EventNdjsonLogger {19export interface EventNdjsonLogger {
15 readonly filePath: string;20 readonly filePath: string;
...@@ -17,6 +22,11 @@ export interface EventNdjsonLogger {...@@ -17,6 +22,11 @@ export interface EventNdjsonLogger {
17 close: () => void;22 close: () => void;
18}23}
1924
25export interface EventNdjsonLoggerOptions {
26 readonly maxBytes?: number;
27 readonly maxFiles?: number;
28}
29
20function stringifyRecord(record: unknown): string | undefined {30function stringifyRecord(record: unknown): string | undefined {
21 try {31 try {
22 return JSON.stringify(record);32 return JSON.stringify(record);
...@@ -26,10 +36,79 @@ function stringifyRecord(record: unknown): string | undefined {...@@ -26,10 +36,79 @@ function stringifyRecord(record: unknown): string | undefined {
26 }36 }
27}37}
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
30 try {110 try {
31 fs.mkdirSync(path.dirname(filePath), { recursive: true });111 fs.mkdirSync(path.dirname(filePath), { recursive: true });
32 fs.appendFileSync(filePath, "", { encoding: "utf8", flag: "a" });
33 } catch (error) {112 } catch (error) {
34 logger.warn("failed to create provider event log directory", { filePath, error });113 logger.warn("failed to create provider event log directory", { filePath, error });
35 return undefined;114 return undefined;
...@@ -37,6 +116,38 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und...@@ -37,6 +116,38 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und
37116
38 try {117 try {
39 let broken = false;118 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
41 return {152 return {
42 filePath,153 filePath,
...@@ -44,19 +155,27 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und...@@ -44,19 +155,27 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und
44 if (broken) {155 if (broken) {
45 return;156 return;
46 }157 }
47 const serialized = stringifyRecord(record);158 const threadSegment = resolveThreadSegment(record);
48 if (!serialized) {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) {
49 return;168 return;
50 }169 }
51 try {170 try {
52 fs.appendFileSync(filePath, `${serialized}\n`, { encoding: "utf8", flag: "a" });171 sink.write(line);
53 } catch (error) {172 } catch (error) {
54 broken = true;173 broken = true;
55 logger.warn("provider event log write failed", { filePath, error });174 logger.warn("provider event log write failed", { filePath, threadSegment, error });
56 }175 }
57 },176 },
58 close() {177 close() {
59 // no-op for sync append logger178 sinks.clear();
60 },179 },
61 };180 };
62 } catch (error) {181 } catch (error) {
apps/server/src/provider/Layers/ProviderService.ts+24-9
...@@ -30,10 +30,11 @@ import {...@@ -30,10 +30,11 @@ import {
30 ProviderSessionDirectory,30 ProviderSessionDirectory,
31 type ProviderSessionBinding,31 type ProviderSessionBinding,
32} from "../Services/ProviderSessionDirectory.ts";32} from "../Services/ProviderSessionDirectory.ts";
33import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";33import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3434
35export interface ProviderServiceLiveOptions {35export interface ProviderServiceLiveOptions {
36 readonly canonicalEventLogPath?: string;36 readonly canonicalEventLogPath?: string;
37 readonly canonicalEventLogger?: EventNdjsonLogger;
37}38}
3839
39const ProviderRollbackConversationInput = Schema.Struct({40const ProviderRollbackConversationInput = Schema.Struct({
...@@ -108,9 +109,10 @@ function readPersistedCwd(...@@ -108,9 +109,10 @@ function readPersistedCwd(
108const makeProviderService = (options?: ProviderServiceLiveOptions) =>109const makeProviderService = (options?: ProviderServiceLiveOptions) =>
109 Effect.gen(function* () {110 Effect.gen(function* () {
110 const canonicalEventLogger =111 const canonicalEventLogger =
111 options?.canonicalEventLogPath !== undefined112 options?.canonicalEventLogger ??
113 (options?.canonicalEventLogPath !== undefined
112 ? makeEventNdjsonLogger(options.canonicalEventLogPath)114 ? makeEventNdjsonLogger(options.canonicalEventLogPath)
113 : undefined;115 : undefined);
114116
115 const registry = yield* ProviderAdapterRegistry;117 const registry = yield* ProviderAdapterRegistry;
116 const directory = yield* ProviderSessionDirectory;118 const directory = yield* ProviderSessionDirectory;
...@@ -140,12 +142,25 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -140,12 +142,25 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
140 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>142 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
141 canonicalizeRuntimeEventSession(event).pipe(143 canonicalizeRuntimeEventSession(event).pipe(
142 Effect.tap((canonicalEvent) =>144 Effect.tap((canonicalEvent) =>
143 Effect.sync(() => {145 canonicalEventLogger
144 canonicalEventLogger?.write({146 ? Effect.tap(
145 observedAt: new Date().toISOString(),147 Effect.catch(
146 event: canonicalEvent,148 directory.getThreadId(canonicalEvent.sessionId).pipe(
147 });149 Effect.map(Option.getOrUndefined),
148 }),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,
149 ),164 ),
150 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),165 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),
151 Effect.asVoid,166 Effect.asVoid,
apps/server/src/serverLayers.ts+13-8
...@@ -23,6 +23,7 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe...@@ -23,6 +23,7 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe
23import { makeProviderServiceLive } from "./provider/Layers/ProviderService";23import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
24import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";24import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
25import { ProviderService } from "./provider/Services/ProviderService";25import { ProviderService } from "./provider/Services/ProviderService";
26import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
2627
27import { TerminalManagerLive } from "./terminal/Layers/Manager";28import { TerminalManagerLive } from "./terminal/Layers/Manager";
28import { KeybindingsLive } from "./keybindings";29import { KeybindingsLive } from "./keybindings";
...@@ -41,17 +42,21 @@ export function makeServerProviderLayer(): Layer.Layer<...@@ -41,17 +42,21 @@ export function makeServerProviderLayer(): Layer.Layer<
41> {42> {
42 return Effect.gen(function* () {43 return Effect.gen(function* () {
43 const { stateDir } = yield* ServerConfig;44 const { stateDir } = yield* ServerConfig;
44 const providerLogsDir = path.join(stateDir, "logs", "providers");45 const providerLogsDir = path.join(stateDir, "logs", "provider");
45 const codexAdapterLayer = makeCodexAdapterLive({46 const providerEventLogger = makeEventNdjsonLogger(path.join(providerLogsDir, "events.log"));
46 nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),
47 });
48 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer));
49 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(47 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
50 Layer.provide(ProviderSessionRuntimeRepositoryLive),48 Layer.provide(ProviderSessionRuntimeRepositoryLive),
51 );49 );
52 return makeProviderServiceLive({50 const codexAdapterLayer = makeCodexAdapterLive(
53 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),51 providerEventLogger ? { nativeEventLogger: providerEventLogger } : undefined,
54 }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));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));
55 }).pipe(Layer.unwrap);60 }).pipe(Layer.unwrap);
56}61}
5762
apps/server/tsdown.config.ts+1-1
...@@ -9,7 +9,7 @@ export default defineConfig({...@@ -9,7 +9,7 @@ export default defineConfig({
9 outDir: "dist",9 outDir: "dist",
10 sourcemap: true,10 sourcemap: true,
11 clean: true,11 clean: true,
12 noExternal: ["@t3tools/contracts"],12 noExternal: (id) => id.startsWith("@t3tools/"),
13 inlineOnly: false,13 inlineOnly: false,
14 banner: {14 banner: {
15 js: "#!/usr/bin/env node\n",15 js: "#!/usr/bin/env node\n",
bun.lock+1-1
...@@ -16,12 +16,12 @@...@@ -16,12 +16,12 @@
16 "name": "@t3tools/desktop",16 "name": "@t3tools/desktop",
17 "version": "0.0.0-alpha.12",17 "version": "0.0.0-alpha.12",
18 "dependencies": {18 "dependencies": {
19 "@t3tools/shared": "workspace:*",
20 "effect": "catalog:",19 "effect": "catalog:",
21 "electron": "40.6.0",20 "electron": "40.6.0",
22 },21 },
23 "devDependencies": {22 "devDependencies": {
24 "@t3tools/contracts": "workspace:*",23 "@t3tools/contracts": "workspace:*",
24 "@t3tools/shared": "workspace:*",
25 "@types/node": "catalog:",25 "@types/node": "catalog:",
26 "electronmon": "^2.0.2",26 "electronmon": "^2.0.2",
27 "tsdown": "catalog:",27 "tsdown": "catalog:",
packages/shared/package.json+4
...@@ -8,6 +8,10 @@...@@ -8,6 +8,10 @@
8 "types": "./src/git.ts",8 "types": "./src/git.ts",
9 "import": "./src/git.ts"9 "import": "./src/git.ts"
10 },10 },
11 "./logging": {
12 "types": "./src/logging.ts",
13 "import": "./src/logging.ts"
14 },
11 "./Net": {15 "./Net": {
12 "types": "./src/Net.ts",16 "types": "./src/Net.ts",
13 "import": "./src/Net.ts"17 "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}