| 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,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 @@ |
| 1 | import { execFileSync } from "node:child_process"; | 1 | import * as ChildProcess from "node:child_process"; |
| 2 | 2 | ||
| 3 | export function fixPath(): void { | 3 | export function fixPath(): void { |
| 4 | if (process.platform !== "darwin") return; | 4 | if (process.platform !== "darwin") return; |
| 5 | 5 | ||
| 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 @@ |
| 1 | import { spawn, type ChildProcess } from "node:child_process"; | 1 | import * as ChildProcess from "node:child_process"; |
| 2 | import { randomBytes } from "node:crypto"; | 2 | import * as Crypto from "node:crypto"; |
| 3 | import fs from "node:fs"; | 3 | import * as FS from "node:fs"; |
| 4 | import os from "node:os"; | 4 | import * as OS from "node:os"; |
| 5 | import path from "node:path"; | 5 | import * as Path from "node:path"; |
| 6 | 6 | ||
| 7 | import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; | 7 | import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; |
| 8 | import type { MenuItemConstructorOptions } from "electron"; | 8 | import type { MenuItemConstructorOptions } from "electron"; |
| 9 | import { Effect } from "effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | 10 | ||
| 11 | import type { ContextMenuItem } from "@t3tools/contracts"; | 11 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 12 | import { NetService } from "@t3tools/shared/Net"; | 12 | import { NetService } from "@t3tools/shared/Net"; |
| 13 | import { RotatingFileSink } from "@t3tools/shared/logging"; | ||
| 13 | import { showDesktopConfirmDialog } from "./confirmDialog"; | 14 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 14 | import { fixPath } from "./fixPath"; | 15 | import { fixPath } from "./fixPath"; |
| 15 | 16 | ||
| ... | @@ -21,17 +22,21 @@ const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | ... | @@ -21,17 +22,21 @@ const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 21 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 22 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 22 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 23 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 23 | const STATE_DIR = | 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 | const DESKTOP_SCHEME = "t3"; | 26 | const DESKTOP_SCHEME = "t3"; |
| 26 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | 27 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 27 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 28 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 28 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | 29 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; |
| 29 | const APP_USER_MODEL_ID = "com.t3tools.t3code"; | 30 | const APP_USER_MODEL_ID = "com.t3tools.t3code"; |
| 30 | const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; | 31 | const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; |
| 31 | const COMMIT_HASH_DISPLAY_LENGTH = 12; | 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 | let mainWindow: BrowserWindow | null = null; | 38 | let mainWindow: BrowserWindow | null = null; |
| 34 | let backendProcess: ChildProcess | null = null; | 39 | let backendProcess: ChildProcess.ChildProcess | null = null; |
| 35 | let backendPort = 0; | 40 | let backendPort = 0; |
| 36 | let backendAuthToken = ""; | 41 | let backendAuthToken = ""; |
| 37 | let backendWsUrl = ""; | 42 | let backendWsUrl = ""; |
| ... | @@ -40,9 +45,133 @@ let restartTimer: ReturnType<typeof setTimeout> | null = null; | ... | @@ -40,9 +45,133 @@ let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 40 | let isQuitting = false; | 45 | let isQuitting = false; |
| 41 | let desktopProtocolRegistered = false; | 46 | let desktopProtocolRegistered = false; |
| 42 | let aboutCommitHashCache: string | null | undefined; | 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 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; | 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 | function getDestructiveMenuIcon(): Electron.NativeImage | undefined { | 175 | function 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 | } |
| 98 | 227 | ||
| 99 | function resolveEmbeddedCommitHash(): string | null { | 228 | function 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 | } |
| 104 | 233 | ||
| 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 | } |
| 135 | 264 | ||
| 136 | function resolveBackendEntry(): string { | 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 | function resolveBackendCwd(): string { | 269 | function 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 | } |
| 146 | 275 | ||
| 147 | function resolveDesktopStaticDir(): string | null { | 276 | function 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 | ]; |
| 153 | 282 | ||
| 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 { |
| 163 | function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { | 292 | function 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 | } |
| 170 | 299 | ||
| 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); |
| 173 | 302 | ||
| 174 | if (path.extname(resolvedPath)) { | 303 | if (Path.extname(resolvedPath)) { |
| 175 | return resolvedPath; | 304 | return resolvedPath; |
| 176 | } | 305 | } |
| 177 | 306 | ||
| 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 | } |
| 182 | 311 | ||
| 183 | return path.join(staticRoot, "index.html"); | 312 | return Path.join(staticRoot, "index.html"); |
| 184 | } | 313 | } |
| 185 | 314 | ||
| 186 | function isStaticAssetRequest(requestUrl: string): boolean { | 315 | function 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 | } |
| 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 | function registerDesktopProtocol(): void { | 339 | function registerDesktopProtocol(): void { |
| 196 | if (isDevelopment || desktopProtocolRegistered) return; | 340 | if (isDevelopment || desktopProtocolRegistered) return; |
| 197 | 341 | ||
| ... | @@ -202,19 +346,19 @@ function registerDesktopProtocol(): void { | ... | @@ -202,19 +346,19 @@ function registerDesktopProtocol(): void { |
| 202 | ); | 346 | ); |
| 203 | } | 347 | } |
| 204 | 348 | ||
| 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"); |
| 208 | 352 | ||
| 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); |
| 216 | 360 | ||
| 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 { |
| 309 | 453 | ||
| 310 | function resolveResourcePath(fileName: string): string | null { | 454 | function 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 | ]; |
| 316 | 460 | ||
| 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; |
| 377 | 521 | ||
| 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 | } |
| 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 | 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); | ||
| 395 | 551 | ||
| 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 | }); |
| 406 | 563 | ||
| ... | @@ -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 { |
| 604 | configureAppIdentity(); | 764 | configureAppIdentity(); |
| 605 | 765 | ||
| 606 | async function bootstrap(): Promise<void> { | 766 | async 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}`); | ||
| 615 | 778 | ||
| 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 | } |
| 620 | 786 | ||
| 621 | app.on("before-quit", () => { | 787 | app.on("before-quit", () => { |
| 622 | isQuitting = true; | 788 | isQuitting = true; |
| 789 | writeDesktopLogHeader("before-quit received"); | ||
| 623 | stopBackend(); | 790 | stopBackend(); |
| 791 | restoreStdIoCapture?.(); | ||
| 624 | }); | 792 | }); |
| 625 | 793 | ||
| 626 | app.whenReady().then(() => { | 794 | app |
| 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 | }); | ||
| 631 | 804 | ||
| 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 | }); | ||
| 638 | 814 | ||
| 639 | app.on("window-all-closed", () => { | 815 | app.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 | }); |
| 652 | 830 | ||
| 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 @@ | ||
| 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,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"; |
| 15 | import { afterAll, assert, it, vi } from "@effect/vitest"; | 15 | import { afterAll, assert, it, vi } from "@effect/vitest"; |
| 16 | import { assertFailure } from "@effect/vitest/utils"; | 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 | import { | 20 | import { |
| 21 | CodexAppServerManager, | 21 | CodexAppServerManager, |
| ... | @@ -24,6 +24,7 @@ import { | ... | @@ -24,6 +24,7 @@ import { |
| 24 | import { ServerConfig } from "../../config.ts"; | 24 | import { ServerConfig } from "../../config.ts"; |
| 25 | import { ProviderAdapterValidationError } from "../Errors.ts"; | 25 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 26 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | 26 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 27 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | ||
| 27 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; | 28 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; |
| 28 | 29 | ||
| 29 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | 30 | const 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 | } |
| 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 | const validationManager = new FakeCodexManager(); | 135 | const validationManager = new FakeCodexManager(); |
| 125 | const validationLayer = it.layer( | 136 | const 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 () => { |
| 159 | const sessionErrorLayer = it.layer( | 171 | const 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(); |
| 195 | const lifecycleLayer = it.layer( | 208 | const 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"; |
| 20 | import { Effect, FileSystem, Layer, Queue, Schema, Stream } from "effect"; | 20 | import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect"; |
| 21 | 21 | ||
| 22 | import { | 22 | import { |
| 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"; |
| 30 | import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; | 30 | import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 31 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | ||
| 31 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; | 32 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; |
| 32 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 33 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 33 | import { ServerConfig } from "../../config.ts"; | 34 | import { ServerConfig } from "../../config.ts"; |
| 34 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 35 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 35 | 36 | ||
| 36 | const PROVIDER = "codex" as const; | 37 | const PROVIDER = "codex" as const; |
| 37 | 38 | ||
| ... | @@ -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 | } |
| 43 | 45 | ||
| 44 | function toMessage(cause: unknown, fallback: string): string { | 46 | function 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 !== undefined | 437 | options?.nativeEventLogger ?? |
| 438 | (options?.nativeEventLogPath !== undefined | ||
| 435 | ? makeEventNdjsonLogger(options.nativeEventLogPath) | 439 | ? makeEventNdjsonLogger(options.nativeEventLogPath) |
| 436 | : undefined; | 440 | : undefined); |
| 437 | 441 | ||
| 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) => |
| 580 | 584 | ||
| 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"; |
| 7 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 7 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 8 | 8 | ||
| 9 | describe("EventNdjsonLogger", () => { | 9 | describe("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"); |
| 13 | 13 | ||
| 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 | } |
| 20 | 20 | ||
| 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(); |
| 24 | 34 | ||
| 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); | ||
| 28 | 39 | ||
| 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 warnings | 4 | * 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 | */ |
| 7 | import fs from "node:fs"; | 8 | import fs from "node:fs"; |
| 8 | import path from "node:path"; | 9 | import path from "node:path"; |
| 9 | 10 | ||
| 11 | import { RotatingFileSink } from "@t3tools/shared/logging"; | ||
| 12 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; | ||
| 10 | import { createLogger } from "../../logger.ts"; | 13 | import { createLogger } from "../../logger.ts"; |
| 11 | 14 | ||
| 12 | const logger = createLogger("provider-observability"); | 15 | const logger = createLogger("provider-observability"); |
| 16 | const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; | ||
| 17 | const DEFAULT_MAX_FILES = 10; | ||
| 13 | 18 | ||
| 14 | export interface EventNdjsonLogger { | 19 | export 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 | } |
| 19 | 24 | ||
| 25 | export interface EventNdjsonLoggerOptions { | ||
| 26 | readonly maxBytes?: number; | ||
| 27 | readonly maxFiles?: number; | ||
| 28 | } | ||
| 29 | |||
| 20 | function stringifyRecord(record: unknown): string | undefined { | 30 | function 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 | } |
| 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 | 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 |
| 37 | 116 | ||
| 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 | }; | ||
| 40 | 151 | ||
| 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 logger | 178 | 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"; |
| 33 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 33 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 34 | 34 | ||
| 35 | export interface ProviderServiceLiveOptions { | 35 | export interface ProviderServiceLiveOptions { |
| 36 | readonly canonicalEventLogPath?: string; | 36 | readonly canonicalEventLogPath?: string; |
| 37 | readonly canonicalEventLogger?: EventNdjsonLogger; | ||
| 37 | } | 38 | } |
| 38 | 39 | ||
| 39 | const ProviderRollbackConversationInput = Schema.Struct({ | 40 | const ProviderRollbackConversationInput = Schema.Struct({ |
| ... | @@ -108,9 +109,10 @@ function readPersistedCwd( | ... | @@ -108,9 +109,10 @@ function readPersistedCwd( |
| 108 | const makeProviderService = (options?: ProviderServiceLiveOptions) => | 109 | const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 109 | Effect.gen(function* () { | 110 | Effect.gen(function* () { |
| 110 | const canonicalEventLogger = | 111 | const canonicalEventLogger = |
| 111 | options?.canonicalEventLogPath !== undefined | 112 | options?.canonicalEventLogger ?? |
| 113 | (options?.canonicalEventLogPath !== undefined | ||
| 112 | ? makeEventNdjsonLogger(options.canonicalEventLogPath) | 114 | ? makeEventNdjsonLogger(options.canonicalEventLogPath) |
| 113 | : undefined; | 115 | : undefined); |
| 114 | 116 | ||
| 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 |
| 23 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; | 23 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 24 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | 24 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 25 | import { ProviderService } from "./provider/Services/ProviderService"; | 25 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 26 | import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; | ||
| 26 | 27 | ||
| 27 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; | 28 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; |
| 28 | import { KeybindingsLive } from "./keybindings"; | 29 | import { 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 | } |
| 57 | 62 |
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 @@ | ||
| 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 | } | ||