import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; export const DESKTOP_LOG_FILE_MAX_FILES = 10; const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; interface RotatingLogFileWriter { readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; readonly writeText: (chunk: string) => Effect.Effect; } export class DesktopBackendOutputLog extends Context.Service< DesktopBackendOutputLog, { readonly writeSessionBoundary: (input: { readonly phase: "START" | "END"; readonly details: string; }) => Effect.Effect; readonly writeOutputChunk: ( streamName: "stdout" | "stderr", chunk: Uint8Array, ) => Effect.Effect; } >()("@t3tools/desktop/app/DesktopBackendOutputLog") {} class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass()( "DesktopLogFileWriterConfigurationError", { option: Schema.Literals(["maxBytes", "maxFiles"]), value: Schema.Number, }, ) { override get message(): string { return `${this.option} must be >= 1 (received ${this.value})`; } } type DesktopLogFileWriterError = | DesktopLogFileWriterConfigurationError | PlatformError.PlatformError; const DesktopBackendChildLogRecord = Schema.Struct({ message: Schema.String, level: Schema.Literals(["INFO", "ERROR"]), timestamp: Schema.String, annotations: Schema.Record(Schema.String, Schema.Unknown), spans: Schema.Record(Schema.String, Schema.Unknown), fiberId: Schema.String, }); const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( Schema.fromJsonString(DesktopBackendChildLogRecord), ); const DesktopBackendOutputLogNoop: DesktopBackendOutputLog["Service"] = { writeSessionBoundary: () => Effect.void, writeOutputChunk: () => Effect.void, }; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const currentDesktopRunId = Effect.gen(function* () { const annotations = yield* References.CurrentLogAnnotations; const runId = annotations.runId; return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; }); const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); const refreshFileSize = ( fileSystem: FileSystem.FileSystem, filePath: string, ): Effect.Effect => fileSystem.stat(filePath).pipe( Effect.map((stat) => Number(stat.size)), Effect.orElseSucceed(() => 0), ); const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { readonly filePath: string; readonly maxBytes?: number; readonly maxFiles?: number; }): Effect.fn.Return< RotatingLogFileWriter, DesktopLogFileWriterError, FileSystem.FileSystem | Path.Path > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; const directory = path.dirname(input.filePath); const baseName = path.basename(input.filePath); if (maxBytes < 1) { return yield* new DesktopLogFileWriterConfigurationError({ option: "maxBytes", value: maxBytes, }); } if (maxFiles < 1) { return yield* new DesktopLogFileWriterConfigurationError({ option: "maxFiles", value: maxFiles, }); } yield* fileSystem.makeDirectory(directory, { recursive: true }); const withSuffix = (index: number) => `${input.filePath}.${index}`; const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); const mutex = yield* Semaphore.make(1); const pruneOverflowBackups = Effect.gen(function* () { const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); for (const entry of entries) { if (!entry.startsWith(`${baseName}.`)) continue; const suffix = Number(entry.slice(baseName.length + 1)); if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); } }); const rotate = Effect.gen(function* () { yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); for (let index = maxFiles - 1; index >= 1; index -= 1) { const source = withSuffix(index); const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); if (sourceExists) { yield* fileSystem.rename(source, withSuffix(index + 1)); } } const currentExists = yield* fileSystem .exists(input.filePath) .pipe(Effect.orElseSucceed(() => false)); if (currentExists) { yield* fileSystem.rename(input.filePath, withSuffix(1)); } yield* Ref.set(currentSize, 0); }).pipe( Effect.catch(() => refreshFileSize(fileSystem, input.filePath).pipe( Effect.flatMap((size) => Ref.set(currentSize, size)), ), ), ); const writeBytes = (chunk: Uint8Array): Effect.Effect => { if (chunk.byteLength === 0) return Effect.void; return mutex.withPermits(1)( Effect.gen(function* () { const beforeSize = yield* Ref.get(currentSize); if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { yield* rotate; } yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; yield* Ref.set(currentSize, afterSize); if (afterSize > maxBytes) { yield* rotate; } }).pipe( Effect.catch(() => refreshFileSize(fileSystem, input.filePath).pipe( Effect.flatMap((size) => Ref.set(currentSize, size)), ), ), ), ); }; yield* pruneOverflowBackups; return { writeBytes, writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), } satisfies RotatingLogFileWriter; }); const writeDevelopmentConsoleOutput = ( streamName: "stdout" | "stderr", chunk: Uint8Array, ): Effect.Effect => Effect.sync(() => { const output = streamName === "stderr" ? process.stderr : process.stdout; output.write(chunk); }).pipe(Effect.ignore); const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( function* ( logFile: RotatingLogFileWriter, input: { readonly message: string; readonly level: "INFO" | "ERROR"; readonly annotations: Record; }, ): Effect.fn.Return { return yield* Effect.gen(function* () { const timestamp = DateTime.formatIso(yield* DateTime.now); const encoded = yield* encodeDesktopBackendChildLogRecord({ message: input.message, level: input.level, timestamp, annotations: input.annotations, spans: {}, fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, }); yield* logFile.writeText(`${encoded}\n`); }).pipe(Effect.ignore({ log: true })); }, ); const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const writer = yield* makeRotatingLogFileWriter({ filePath: environment.path.join(environment.logDir, "server-child.log"), }).pipe(Effect.option); const service = Option.match(writer, { onNone: () => DesktopBackendOutputLogNoop, onSome: (logFile) => ({ writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( function* ({ phase, details }) { const runId = yield* currentDesktopRunId; yield* writeBackendChildLogRecord(logFile, { message: `backend child process session ${phase.toLowerCase()}`, level: "INFO", annotations: { component: "desktop-backend-child", runId, phase, details: sanitizeLogValue(details), }, }); }, ), writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( function* (streamName, chunk) { if (environment.isDevelopment) { yield* writeDevelopmentConsoleOutput(streamName, chunk); } const runId = yield* currentDesktopRunId; yield* writeBackendChildLogRecord(logFile, { message: "backend child process output", level: streamName === "stderr" ? "ERROR" : "INFO", annotations: { component: "desktop-backend-child", runId, stream: streamName, text: textDecoder.decode(chunk), }, }); }, ), }) satisfies DesktopBackendOutputLog["Service"], }); return DesktopBackendOutputLog.of(service); }); export const layer = Layer.effect(DesktopBackendOutputLog, make);