| author | |
| committer | |
| log | 404e78a506ad1a6b0bd55eaa703fb0563e7dea7d |
| tree | a7baabc8c859f46e3e65bc8e9c28f3914d1b11d7 |
| parent | 73563c7c25e31262852a1282ed8681a58e5f4b26 |
15 files changed, 732 insertions(+), 103 deletions(-)
apps/desktop/package.json+1-1| ... | ... | @@ -14,12 +14,12 @@ |
| 14 | 14 | "smoke-test": "node scripts/smoke-test.mjs" |
| 15 | 15 | }, |
| 16 | 16 | "dependencies": { |
| 17 | "@t3tools/shared": "workspace:*", | |
| 18 | 17 | "effect": "catalog:", |
| 19 | 18 | "electron": "40.6.0" |
| 20 | 19 | }, |
| 21 | 20 | "devDependencies": { |
| 22 | 21 | "@t3tools/contracts": "workspace:*", |
| 22 | "@t3tools/shared": "workspace:*", | |
| 23 | 23 | "@types/node": "catalog:", |
| 24 | 24 | "electronmon": "^2.0.2", |
| 25 | 25 | "tsdown": "catalog:", |
apps/desktop/src/fixPath.ts+2-2| ... | ... | @@ -1,11 +1,11 @@ |
| 1 | import { execFileSync } from "node:child_process"; | |
| 1 | import * as ChildProcess from "node:child_process"; | |
| 2 | 2 | |
| 3 | 3 | export function fixPath(): void { |
| 4 | 4 | if (process.platform !== "darwin") return; |
| 5 | 5 | |
| 6 | 6 | try { |
| 7 | 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 | 9 | encoding: "utf8", |
| 10 | 10 | timeout: 5000, |
| 11 | 11 | }); |
apps/desktop/src/main.ts+229-49| ... | ... | @@ -1,15 +1,16 @@ |
| 1 | import { spawn, type ChildProcess } from "node:child_process"; | |
| 2 | import { randomBytes } from "node:crypto"; | |
| 3 | import fs from "node:fs"; | |
| 4 | import os from "node:os"; | |
| 5 | import path from "node:path"; | |
| 1 | import * as ChildProcess from "node:child_process"; | |
| 2 | import * as Crypto from "node:crypto"; | |
| 3 | import * as FS from "node:fs"; | |
| 4 | import * as OS from "node:os"; | |
| 5 | import * as Path from "node:path"; | |
| 6 | 6 | |
| 7 | 7 | import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; |
| 8 | 8 | import type { MenuItemConstructorOptions } from "electron"; |
| 9 | import { Effect } from "effect"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | 10 | |
| 11 | 11 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 12 | 12 | import { NetService } from "@t3tools/shared/Net"; |
| 13 | import { RotatingFileSink } from "@t3tools/shared/logging"; | |
| 13 | 14 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 14 | 15 | import { fixPath } from "./fixPath"; |
| 15 | 16 | |
| ... | ... | @@ -21,17 +22,21 @@ const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 21 | 22 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 22 | 23 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 23 | 24 | const 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"); | |
| 25 | 26 | const DESKTOP_SCHEME = "t3"; |
| 26 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | |
| 27 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); | |
| 27 | 28 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 28 | 29 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; |
| 29 | 30 | const APP_USER_MODEL_ID = "com.t3tools.t3code"; |
| 30 | 31 | const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; |
| 31 | 32 | const COMMIT_HASH_DISPLAY_LENGTH = 12; |
| 33 | const LOG_DIR = Path.join(STATE_DIR, "logs"); | |
| 34 | const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; | |
| 35 | const LOG_FILE_MAX_FILES = 10; | |
| 36 | const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); | |
| 32 | 37 | |
| 33 | 38 | let mainWindow: BrowserWindow | null = null; |
| 34 | let backendProcess: ChildProcess | null = null; | |
| 39 | let backendProcess: ChildProcess.ChildProcess | null = null; | |
| 35 | 40 | let backendPort = 0; |
| 36 | 41 | let backendAuthToken = ""; |
| 37 | 42 | let backendWsUrl = ""; |
| ... | ... | @@ -40,9 +45,133 @@ let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 40 | 45 | let isQuitting = false; |
| 41 | 46 | let desktopProtocolRegistered = false; |
| 42 | 47 | let aboutCommitHashCache: string | null | undefined; |
| 48 | let desktopLogSink: RotatingFileSink | null = null; | |
| 49 | let backendLogSink: RotatingFileSink | null = null; | |
| 50 | let restoreStdIoCapture: (() => void) | null = null; | |
| 43 | 51 | |
| 44 | 52 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; |
| 45 | 53 | |
| 54 | function logTimestamp(): string { | |
| 55 | return new Date().toISOString(); | |
| 56 | } | |
| 57 | ||
| 58 | function logScope(scope: string): string { | |
| 59 | return `${scope} run=${APP_RUN_ID}`; | |
| 60 | } | |
| 61 | ||
| 62 | function sanitizeLogValue(value: string): string { | |
| 63 | return value.replace(/\s+/g, " ").trim(); | |
| 64 | } | |
| 65 | ||
| 66 | function writeDesktopLogHeader(message: string): void { | |
| 67 | if (!desktopLogSink) return; | |
| 68 | desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); | |
| 69 | } | |
| 70 | ||
| 71 | function 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 | ||
| 79 | function formatErrorMessage(error: unknown): string { | |
| 80 | if (error instanceof Error) { | |
| 81 | return error.message; | |
| 82 | } | |
| 83 | return String(error); | |
| 84 | } | |
| 85 | ||
| 86 | function 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 | ||
| 102 | function 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 | ||
| 141 | function 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 | ||
| 162 | function 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 | ||
| 173 | initializePackagedLogging(); | |
| 174 | ||
| 46 | 175 | function getDestructiveMenuIcon(): Electron.NativeImage | undefined { |
| 47 | 176 | if (process.platform !== "darwin") return undefined; |
| 48 | 177 | if (destructiveMenuIconCache !== undefined) { |
| ... | ... | @@ -97,13 +226,13 @@ function normalizeCommitHash(value: unknown): string | null { |
| 97 | 226 | } |
| 98 | 227 | |
| 99 | 228 | function 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)) { | |
| 102 | 231 | return null; |
| 103 | 232 | } |
| 104 | 233 | |
| 105 | 234 | try { |
| 106 | const raw = fs.readFileSync(packageJsonPath, "utf8"); | |
| 235 | const raw = FS.readFileSync(packageJsonPath, "utf8"); | |
| 107 | 236 | const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown }; |
| 108 | 237 | return normalizeCommitHash(parsed.t3codeCommitHash); |
| 109 | 238 | } catch { |
| ... | ... | @@ -134,25 +263,25 @@ function resolveAboutCommitHash(): string | null { |
| 134 | 263 | } |
| 135 | 264 | |
| 136 | 265 | function resolveBackendEntry(): string { |
| 137 | return path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); | |
| 266 | return Path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); | |
| 138 | 267 | } |
| 139 | 268 | |
| 140 | 269 | function resolveBackendCwd(): string { |
| 141 | 270 | if (!app.isPackaged) { |
| 142 | 271 | return resolveAppRoot(); |
| 143 | 272 | } |
| 144 | return os.homedir(); | |
| 273 | return OS.homedir(); | |
| 145 | 274 | } |
| 146 | 275 | |
| 147 | 276 | function resolveDesktopStaticDir(): string | null { |
| 148 | 277 | const appRoot = resolveAppRoot(); |
| 149 | 278 | 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"), | |
| 152 | 281 | ]; |
| 153 | 282 | |
| 154 | 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 | 285 | return candidate; |
| 157 | 286 | } |
| 158 | 287 | } |
| ... | ... | @@ -163,35 +292,50 @@ function resolveDesktopStaticDir(): string | null { |
| 163 | 292 | function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { |
| 164 | 293 | const url = new URL(requestUrl); |
| 165 | 294 | const rawPath = decodeURIComponent(url.pathname); |
| 166 | const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, ""); | |
| 295 | const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, ""); | |
| 167 | 296 | if (normalizedPath.includes("..")) { |
| 168 | return path.join(staticRoot, "index.html"); | |
| 297 | return Path.join(staticRoot, "index.html"); | |
| 169 | 298 | } |
| 170 | 299 | |
| 171 | 300 | const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; |
| 172 | const resolvedPath = path.join(staticRoot, requestedPath); | |
| 301 | const resolvedPath = Path.join(staticRoot, requestedPath); | |
| 173 | 302 | |
| 174 | if (path.extname(resolvedPath)) { | |
| 303 | if (Path.extname(resolvedPath)) { | |
| 175 | 304 | return resolvedPath; |
| 176 | 305 | } |
| 177 | 306 | |
| 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)) { | |
| 180 | 309 | return nestedIndex; |
| 181 | 310 | } |
| 182 | 311 | |
| 183 | return path.join(staticRoot, "index.html"); | |
| 312 | return Path.join(staticRoot, "index.html"); | |
| 184 | 313 | } |
| 185 | 314 | |
| 186 | 315 | function isStaticAssetRequest(requestUrl: string): boolean { |
| 187 | 316 | try { |
| 188 | 317 | const url = new URL(requestUrl); |
| 189 | return path.extname(url.pathname).length > 0; | |
| 318 | return Path.extname(url.pathname).length > 0; | |
| 190 | 319 | } catch { |
| 191 | 320 | return false; |
| 192 | 321 | } |
| 193 | 322 | } |
| 194 | 323 | |
| 324 | function 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 | ||
| 195 | 339 | function registerDesktopProtocol(): void { |
| 196 | 340 | if (isDevelopment || desktopProtocolRegistered) return; |
| 197 | 341 | |
| ... | ... | @@ -202,19 +346,19 @@ function registerDesktopProtocol(): void { |
| 202 | 346 | ); |
| 203 | 347 | } |
| 204 | 348 | |
| 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"); | |
| 208 | 352 | |
| 209 | 353 | protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { |
| 210 | 354 | try { |
| 211 | 355 | const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); |
| 212 | const resolvedCandidate = path.resolve(candidate); | |
| 356 | const resolvedCandidate = Path.resolve(candidate); | |
| 213 | 357 | const isInRoot = |
| 214 | 358 | resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); |
| 215 | 359 | const isAssetRequest = isStaticAssetRequest(request.url); |
| 216 | 360 | |
| 217 | if (!isInRoot || !fs.existsSync(resolvedCandidate)) { | |
| 361 | if (!isInRoot || !FS.existsSync(resolvedCandidate)) { | |
| 218 | 362 | if (isAssetRequest) { |
| 219 | 363 | callback({ error: -6 }); |
| 220 | 364 | return; |
| ... | ... | @@ -309,13 +453,13 @@ function configureApplicationMenu(): void { |
| 309 | 453 | |
| 310 | 454 | function resolveResourcePath(fileName: string): string | null { |
| 311 | 455 | 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), | |
| 315 | 459 | ]; |
| 316 | 460 | |
| 317 | 461 | for (const candidate of candidates) { |
| 318 | if (fs.existsSync(candidate)) { | |
| 462 | if (FS.existsSync(candidate)) { | |
| 319 | 463 | return candidate; |
| 320 | 464 | } |
| 321 | 465 | } |
| ... | ... | @@ -376,12 +520,13 @@ function startBackend(): void { |
| 376 | 520 | if (isQuitting || backendProcess) return; |
| 377 | 521 | |
| 378 | 522 | const backendEntry = resolveBackendEntry(); |
| 379 | if (!fs.existsSync(backendEntry)) { | |
| 523 | if (!FS.existsSync(backendEntry)) { | |
| 380 | 524 | scheduleBackendRestart(`missing server entry at ${backendEntry}`); |
| 381 | 525 | return; |
| 382 | 526 | } |
| 383 | 527 | |
| 384 | const child = spawn(process.execPath, [backendEntry], { | |
| 528 | const captureBackendLogs = app.isPackaged && backendLogSink !== null; | |
| 529 | const child = ChildProcess.spawn(process.execPath, [backendEntry], { | |
| 385 | 530 | cwd: resolveBackendCwd(), |
| 386 | 531 | // In Electron main, process.execPath points to the Electron binary. |
| 387 | 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 | 534 | ...backendEnv(), |
| 390 | 535 | ELECTRON_RUN_AS_NODE: "1", |
| 391 | 536 | }, |
| 392 | stdio: "inherit", | |
| 537 | stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit", | |
| 393 | 538 | }); |
| 394 | 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); | |
| 395 | 551 | |
| 396 | 552 | child.once("spawn", () => { |
| 397 | 553 | restartAttempt = 0; |
| ... | ... | @@ -401,6 +557,7 @@ function startBackend(): void { |
| 401 | 557 | if (backendProcess === child) { |
| 402 | 558 | backendProcess = null; |
| 403 | 559 | } |
| 560 | closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); | |
| 404 | 561 | scheduleBackendRestart(error.message); |
| 405 | 562 | }); |
| 406 | 563 | |
| ... | ... | @@ -408,6 +565,9 @@ function startBackend(): void { |
| 408 | 565 | if (backendProcess === child) { |
| 409 | 566 | backendProcess = null; |
| 410 | 567 | } |
| 568 | closeBackendSession( | |
| 569 | `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, | |
| 570 | ); | |
| 411 | 571 | if (isQuitting) return; |
| 412 | 572 | const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; |
| 413 | 573 | scheduleBackendRestart(reason); |
| ... | ... | @@ -566,7 +726,7 @@ function createWindow(): BrowserWindow { |
| 566 | 726 | titleBarStyle: "hiddenInset", |
| 567 | 727 | trafficLightPosition: { x: 16, y: 18 }, |
| 568 | 728 | webPreferences: { |
| 569 | preload: path.join(__dirname, "preload.js"), | |
| 729 | preload: Path.join(__dirname, "preload.js"), | |
| 570 | 730 | contextIsolation: true, |
| 571 | 731 | nodeIntegration: false, |
| 572 | 732 | sandbox: true, |
| ... | ... | @@ -604,37 +764,53 @@ function createWindow(): BrowserWindow { |
| 604 | 764 | configureAppIdentity(); |
| 605 | 765 | |
| 606 | 766 | async function bootstrap(): Promise<void> { |
| 767 | writeDesktopLogHeader("bootstrap start"); | |
| 607 | 768 | backendPort = await Effect.service(NetService).pipe( |
| 608 | 769 | Effect.flatMap((net) => net.reserveLoopbackPort()), |
| 609 | 770 | Effect.provide(NetService.layer), |
| 610 | 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 | 775 | backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; |
| 614 | 776 | process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; |
| 777 | writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); | |
| 615 | 778 | |
| 616 | 779 | registerIpcHandlers(); |
| 780 | writeDesktopLogHeader("bootstrap ipc handlers registered"); | |
| 617 | 781 | startBackend(); |
| 782 | writeDesktopLogHeader("bootstrap backend start requested"); | |
| 618 | 783 | mainWindow = createWindow(); |
| 784 | writeDesktopLogHeader("bootstrap main window created"); | |
| 619 | 785 | } |
| 620 | 786 | |
| 621 | 787 | app.on("before-quit", () => { |
| 622 | 788 | isQuitting = true; |
| 789 | writeDesktopLogHeader("before-quit received"); | |
| 623 | 790 | stopBackend(); |
| 791 | restoreStdIoCapture?.(); | |
| 624 | 792 | }); |
| 625 | 793 | |
| 626 | app.whenReady().then(() => { | |
| 627 | configureAppIdentity(); | |
| 628 | configureApplicationMenu(); | |
| 629 | registerDesktopProtocol(); | |
| 630 | void bootstrap(); | |
| 794 | app | |
| 795 | .whenReady() | |
| 796 | .then(() => { | |
| 797 | writeDesktopLogHeader("app ready"); | |
| 798 | configureAppIdentity(); | |
| 799 | configureApplicationMenu(); | |
| 800 | registerDesktopProtocol(); | |
| 801 | void bootstrap().catch((error) => { | |
| 802 | handleFatalStartupError("bootstrap", error); | |
| 803 | }); | |
| 631 | 804 | |
| 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); | |
| 636 | 813 | }); |
| 637 | }); | |
| 638 | 814 | |
| 639 | 815 | app.on("window-all-closed", () => { |
| 640 | 816 | if (process.platform !== "darwin") { |
| ... | ... | @@ -646,14 +822,18 @@ if (process.platform !== "win32") { |
| 646 | 822 | process.on("SIGINT", () => { |
| 647 | 823 | if (isQuitting) return; |
| 648 | 824 | isQuitting = true; |
| 825 | writeDesktopLogHeader("SIGINT received"); | |
| 649 | 826 | stopBackend(); |
| 827 | restoreStdIoCapture?.(); | |
| 650 | 828 | app.quit(); |
| 651 | 829 | }); |
| 652 | 830 | |
| 653 | 831 | process.on("SIGTERM", () => { |
| 654 | 832 | if (isQuitting) return; |
| 655 | 833 | isQuitting = true; |
| 834 | writeDesktopLogHeader("SIGTERM received"); | |
| 656 | 835 | stopBackend(); |
| 836 | restoreStdIoCapture?.(); | |
| 657 | 837 | app.quit(); |
| 658 | 838 | }); |
| 659 | 839 | } |
apps/desktop/src/rotatingFileSink.test.ts created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { RotatingFileSink } from "@t3tools/shared/logging"; | |
| 6 | import { afterEach, describe, expect, it } from "vitest"; | |
| 7 | ||
| 8 | const tempRoots: string[] = []; | |
| 9 | ||
| 10 | function makeTempDir(): string { | |
| 11 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-")); | |
| 12 | tempRoots.push(dir); | |
| 13 | return dir; | |
| 14 | } | |
| 15 | ||
| 16 | afterEach(() => { | |
| 17 | for (const dir of tempRoots.splice(0)) { | |
| 18 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 19 | } | |
| 20 | }); | |
| 21 | ||
| 22 | describe("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 | 12 | ...shared, |
| 13 | 13 | entry: ["src/main.ts"], |
| 14 | 14 | clean: true, |
| 15 | noExternal: ["@t3tools/contracts"], | |
| 15 | noExternal: (id) => id.startsWith("@t3tools/"), | |
| 16 | 16 | }, |
| 17 | 17 | { |
| 18 | 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 | 15 | import { afterAll, assert, it, vi } from "@effect/vitest"; |
| 16 | 16 | import { assertFailure } from "@effect/vitest/utils"; |
| 17 | 17 | |
| 18 | import { Effect, Fiber, Layer, Stream } from "effect"; | |
| 18 | import { Effect, Fiber, Layer, Option, Stream } from "effect"; | |
| 19 | 19 | |
| 20 | 20 | import { |
| 21 | 21 | CodexAppServerManager, |
| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | import { ServerConfig } from "../../config.ts"; |
| 25 | 25 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 26 | 26 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 27 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | |
| 27 | 28 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; |
| 28 | 29 | |
| 29 | 30 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| ... | ... | @@ -121,10 +122,21 @@ class FakeCodexManager extends CodexAppServerManager { |
| 121 | 122 | } |
| 122 | 123 | } |
| 123 | 124 | |
| 125 | const 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 | ||
| 124 | 135 | const validationManager = new FakeCodexManager(); |
| 125 | 136 | const validationLayer = it.layer( |
| 126 | 137 | makeCodexAdapterLive({ manager: validationManager }).pipe( |
| 127 | 138 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 139 | Layer.provideMerge(providerSessionDirectoryTestLayer), | |
| 128 | 140 | Layer.provideMerge(NodeServices.layer), |
| 129 | 141 | ), |
| 130 | 142 | ); |
| ... | ... | @@ -159,6 +171,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => { |
| 159 | 171 | const sessionErrorLayer = it.layer( |
| 160 | 172 | makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( |
| 161 | 173 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 174 | Layer.provideMerge(providerSessionDirectoryTestLayer), | |
| 162 | 175 | Layer.provideMerge(NodeServices.layer), |
| 163 | 176 | ), |
| 164 | 177 | ); |
| ... | ... | @@ -195,6 +208,7 @@ const lifecycleManager = new FakeCodexManager(); |
| 195 | 208 | const lifecycleLayer = it.layer( |
| 196 | 209 | makeCodexAdapterLive({ manager: lifecycleManager }).pipe( |
| 197 | 210 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| 211 | Layer.provideMerge(providerSessionDirectoryTestLayer), | |
| 198 | 212 | Layer.provideMerge(NodeServices.layer), |
| 199 | 213 | ), |
| 200 | 214 | ); |
apps/server/src/provider/Layers/CodexAdapter.ts+31-8| ... | ... | @@ -17,7 +17,7 @@ import { |
| 17 | 17 | ProviderThreadId, |
| 18 | 18 | ProviderTurnId, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | import { Effect, FileSystem, Layer, Queue, Schema, Stream } from "effect"; | |
| 20 | import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect"; | |
| 21 | 21 | |
| 22 | 22 | import { |
| 23 | 23 | ProviderAdapterProcessError, |
| ... | ... | @@ -28,10 +28,11 @@ import { |
| 28 | 28 | type ProviderAdapterError, |
| 29 | 29 | } from "../Errors.ts"; |
| 30 | 30 | import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 31 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | |
| 31 | 32 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; |
| 32 | 33 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 33 | 34 | import { ServerConfig } from "../../config.ts"; |
| 34 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 35 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 35 | 36 | |
| 36 | 37 | const PROVIDER = "codex" as const; |
| 37 | 38 | |
| ... | ... | @@ -39,6 +40,7 @@ export interface CodexAdapterLiveOptions { |
| 39 | 40 | readonly manager?: CodexAppServerManager; |
| 40 | 41 | readonly makeManager?: () => CodexAppServerManager; |
| 41 | 42 | readonly nativeEventLogPath?: string; |
| 43 | readonly nativeEventLogger?: EventNdjsonLogger; | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | function toMessage(cause: unknown, fallback: string): string { |
| ... | ... | @@ -430,10 +432,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 430 | 432 | Effect.gen(function* () { |
| 431 | 433 | const fileSystem = yield* FileSystem.FileSystem; |
| 432 | 434 | const serverConfig = yield* Effect.service(ServerConfig); |
| 435 | const directory = yield* ProviderSessionDirectory; | |
| 433 | 436 | const nativeEventLogger = |
| 434 | options?.nativeEventLogPath !== undefined | |
| 437 | options?.nativeEventLogger ?? | |
| 438 | (options?.nativeEventLogPath !== undefined | |
| 435 | 439 | ? makeEventNdjsonLogger(options.nativeEventLogPath) |
| 436 | : undefined; | |
| 440 | : undefined); | |
| 437 | 441 | |
| 438 | 442 | const manager = yield* Effect.acquireRelease( |
| 439 | 443 | Effect.sync(() => { |
| ... | ... | @@ -580,11 +584,30 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 580 | 584 | |
| 581 | 585 | yield* Effect.acquireRelease( |
| 582 | 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 | 609 | const listener = (event: ProviderEvent) => { |
| 584 | nativeEventLogger?.write({ | |
| 585 | observedAt: new Date().toISOString(), | |
| 586 | event, | |
| 587 | }); | |
| 610 | writeNativeEvent(event); | |
| 588 | 611 | Queue.offerAllUnsafe(runtimeEventQueue, mapToRuntimeEvents(event)); |
| 589 | 612 | }; |
| 590 | 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 | 7 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 8 | 8 | |
| 9 | 9 | describe("EventNdjsonLogger", () => { |
| 10 | it("writes NDJSON records to disk", () => { | |
| 10 | it("writes effect-style lines to thread-scoped files", () => { | |
| 11 | 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"); | |
| 13 | 13 | |
| 14 | 14 | try { |
| 15 | const logger = makeEventNdjsonLogger(filePath); | |
| 15 | const logger = makeEventNdjsonLogger(basePath); | |
| 16 | 16 | expect(logger).toBeDefined(); |
| 17 | 17 | if (!logger) { |
| 18 | 18 | return; |
| 19 | 19 | } |
| 20 | 20 | |
| 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 | }); | |
| 23 | 33 | logger.close(); |
| 24 | 34 | |
| 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); | |
| 28 | 39 | |
| 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 | ); | |
| 33 | 48 | } finally { |
| 34 | 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 | 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 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. | |
| 6 | 7 | */ |
| 7 | 8 | import fs from "node:fs"; |
| 8 | 9 | import path from "node:path"; |
| 9 | 10 | |
| 11 | import { RotatingFileSink } from "@t3tools/shared/logging"; | |
| 12 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; | |
| 10 | 13 | import { createLogger } from "../../logger.ts"; |
| 11 | 14 | |
| 12 | 15 | const logger = createLogger("provider-observability"); |
| 16 | const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; | |
| 17 | const DEFAULT_MAX_FILES = 10; | |
| 13 | 18 | |
| 14 | 19 | export interface EventNdjsonLogger { |
| 15 | 20 | readonly filePath: string; |
| ... | ... | @@ -17,6 +22,11 @@ export interface EventNdjsonLogger { |
| 17 | 22 | close: () => void; |
| 18 | 23 | } |
| 19 | 24 | |
| 25 | export interface EventNdjsonLoggerOptions { | |
| 26 | readonly maxBytes?: number; | |
| 27 | readonly maxFiles?: number; | |
| 28 | } | |
| 29 | ||
| 20 | 30 | function stringifyRecord(record: unknown): string | undefined { |
| 21 | 31 | try { |
| 22 | 32 | return JSON.stringify(record); |
| ... | ... | @@ -26,10 +36,79 @@ function stringifyRecord(record: unknown): string | undefined { |
| 26 | 36 | } |
| 27 | 37 | } |
| 28 | 38 | |
| 29 | export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | undefined { | |
| 39 | function normalizeThreadSegment(raw: unknown): string | null { | |
| 40 | if (typeof raw !== "string") { | |
| 41 | return null; | |
| 42 | } | |
| 43 | return toSafeThreadAttachmentSegment(raw); | |
| 44 | } | |
| 45 | ||
| 46 | function 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 | ||
| 53 | function resolveThreadSegment(record: unknown): string | null { | |
| 54 | const root = asObject(record); | |
| 55 | return normalizeThreadSegment(root?.orchestrationThreadId); | |
| 56 | } | |
| 57 | ||
| 58 | function resolveThreadFilePath(baseFilePath: string, threadSegment: string): string { | |
| 59 | const dir = path.dirname(baseFilePath); | |
| 60 | return path.join(dir, `${threadSegment}.log`); | |
| 61 | } | |
| 62 | ||
| 63 | function 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 | ||
| 74 | function 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 | ||
| 91 | function 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 | ||
| 103 | export 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 | 110 | try { |
| 31 | 111 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); |
| 32 | fs.appendFileSync(filePath, "", { encoding: "utf8", flag: "a" }); | |
| 33 | 112 | } catch (error) { |
| 34 | 113 | logger.warn("failed to create provider event log directory", { filePath, error }); |
| 35 | 114 | return undefined; |
| ... | ... | @@ -37,6 +116,38 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und |
| 37 | 116 | |
| 38 | 117 | try { |
| 39 | 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 | }; | |
| 40 | 151 | |
| 41 | 152 | return { |
| 42 | 153 | filePath, |
| ... | ... | @@ -44,19 +155,27 @@ export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | und |
| 44 | 155 | if (broken) { |
| 45 | 156 | return; |
| 46 | 157 | } |
| 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) { | |
| 49 | 168 | return; |
| 50 | 169 | } |
| 51 | 170 | try { |
| 52 | fs.appendFileSync(filePath, `${serialized}\n`, { encoding: "utf8", flag: "a" }); | |
| 171 | sink.write(line); | |
| 53 | 172 | } catch (error) { |
| 54 | 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 | 177 | close() { |
| 59 | // no-op for sync append logger | |
| 178 | sinks.clear(); | |
| 60 | 179 | }, |
| 61 | 180 | }; |
| 62 | 181 | } catch (error) { |
apps/server/src/provider/Layers/ProviderService.ts+24-9| ... | ... | @@ -30,10 +30,11 @@ import { |
| 30 | 30 | ProviderSessionDirectory, |
| 31 | 31 | type ProviderSessionBinding, |
| 32 | 32 | } from "../Services/ProviderSessionDirectory.ts"; |
| 33 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 33 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 34 | 34 | |
| 35 | 35 | export interface ProviderServiceLiveOptions { |
| 36 | 36 | readonly canonicalEventLogPath?: string; |
| 37 | readonly canonicalEventLogger?: EventNdjsonLogger; | |
| 37 | 38 | } |
| 38 | 39 | |
| 39 | 40 | const ProviderRollbackConversationInput = Schema.Struct({ |
| ... | ... | @@ -108,9 +109,10 @@ function readPersistedCwd( |
| 108 | 109 | const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 109 | 110 | Effect.gen(function* () { |
| 110 | 111 | const canonicalEventLogger = |
| 111 | options?.canonicalEventLogPath !== undefined | |
| 112 | options?.canonicalEventLogger ?? | |
| 113 | (options?.canonicalEventLogPath !== undefined | |
| 112 | 114 | ? makeEventNdjsonLogger(options.canonicalEventLogPath) |
| 113 | : undefined; | |
| 115 | : undefined); | |
| 114 | 116 | |
| 115 | 117 | const registry = yield* ProviderAdapterRegistry; |
| 116 | 118 | const directory = yield* ProviderSessionDirectory; |
| ... | ... | @@ -140,12 +142,25 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 140 | 142 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 141 | 143 | canonicalizeRuntimeEventSession(event).pipe( |
| 142 | 144 | 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, | |
| 149 | 164 | ), |
| 150 | 165 | Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), |
| 151 | 166 | Effect.asVoid, |
apps/server/src/serverLayers.ts+13-8| ... | ... | @@ -23,6 +23,7 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe |
| 23 | 23 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 24 | 24 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 25 | 25 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 26 | import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; | |
| 26 | 27 | |
| 27 | 28 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; |
| 28 | 29 | import { KeybindingsLive } from "./keybindings"; |
| ... | ... | @@ -41,17 +42,21 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 41 | 42 | > { |
| 42 | 43 | return Effect.gen(function* () { |
| 43 | 44 | 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")); | |
| 49 | 47 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 50 | 48 | Layer.provide(ProviderSessionRuntimeRepositoryLive), |
| 51 | 49 | ); |
| 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)); | |
| 55 | 60 | }).pipe(Layer.unwrap); |
| 56 | 61 | } |
| 57 | 62 |
apps/server/tsdown.config.ts+1-1| ... | ... | @@ -9,7 +9,7 @@ export default defineConfig({ |
| 9 | 9 | outDir: "dist", |
| 10 | 10 | sourcemap: true, |
| 11 | 11 | clean: true, |
| 12 | noExternal: ["@t3tools/contracts"], | |
| 12 | noExternal: (id) => id.startsWith("@t3tools/"), | |
| 13 | 13 | inlineOnly: false, |
| 14 | 14 | banner: { |
| 15 | 15 | js: "#!/usr/bin/env node\n", |
bun.lock+1-1| ... | ... | @@ -16,12 +16,12 @@ |
| 16 | 16 | "name": "@t3tools/desktop", |
| 17 | 17 | "version": "0.0.0-alpha.12", |
| 18 | 18 | "dependencies": { |
| 19 | "@t3tools/shared": "workspace:*", | |
| 20 | 19 | "effect": "catalog:", |
| 21 | 20 | "electron": "40.6.0", |
| 22 | 21 | }, |
| 23 | 22 | "devDependencies": { |
| 24 | 23 | "@t3tools/contracts": "workspace:*", |
| 24 | "@t3tools/shared": "workspace:*", | |
| 25 | 25 | "@types/node": "catalog:", |
| 26 | 26 | "electronmon": "^2.0.2", |
| 27 | 27 | "tsdown": "catalog:", |
packages/shared/package.json+4| ... | ... | @@ -8,6 +8,10 @@ |
| 8 | 8 | "types": "./src/git.ts", |
| 9 | 9 | "import": "./src/git.ts" |
| 10 | 10 | }, |
| 11 | "./logging": { | |
| 12 | "types": "./src/logging.ts", | |
| 13 | "import": "./src/logging.ts" | |
| 14 | }, | |
| 11 | 15 | "./Net": { |
| 12 | 16 | "types": "./src/Net.ts", |
| 13 | 17 | "import": "./src/Net.ts" |
packages/shared/src/logging.ts created+115| ... | ... | @@ -0,0 +1,115 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | export interface RotatingFileSinkOptions { | |
| 5 | readonly filePath: string; | |
| 6 | readonly maxBytes: number; | |
| 7 | readonly maxFiles: number; | |
| 8 | readonly throwOnError?: boolean; | |
| 9 | } | |
| 10 | ||
| 11 | export 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 | } |