From 981cf405612932afa8a0b98b8c98474ef7e1015d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 16:19:36 -0800 Subject: [PATCH] Improve session restart handling and server logging - add structured lifecycle logs for Codex thread open/resume and provider session restarts - introduce persistent server file logging layer and tighten WebSocket server startup/finalizer ordering - preserve env-based boolean overrides in dev runner and stop forcing WS event logging in dev - enrich provider session listing from persisted bindings and fix Claude rate-limit/task-id event mapping - update orchestration and dev-runner tests for runtime-mode restart and env-flag behavior --- apps/server/scripts/logger-scope-repro.ts | 66 +++++++++++++++++++ apps/server/src/codexAppServerManager.ts | 42 ++++++++++++ apps/server/src/main.ts | 47 ++++++------- .../Layers/ProviderCommandReactor.test.ts | 30 +++++++++ .../Layers/ProviderCommandReactor.ts | 21 ++++++ .../decider.projectScripts.test.ts | 10 +-- .../src/provider/Layers/ClaudeCodeAdapter.ts | 11 ++-- .../src/provider/Layers/ProviderService.ts | 47 ++++++++++++- apps/server/src/serverLogger.ts | 27 ++++++++ apps/server/src/wsServer.ts | 62 ++++++++--------- scripts/dev-runner.test.ts | 45 +++++++++++++ scripts/dev-runner.ts | 52 +++++++++++++-- 12 files changed, 386 insertions(+), 74 deletions(-) create mode 100644 apps/server/scripts/logger-scope-repro.ts create mode 100644 apps/server/src/serverLogger.ts diff --git a/apps/server/scripts/logger-scope-repro.ts b/apps/server/scripts/logger-scope-repro.ts new file mode 100644 index 0000000000000000000000000000000000000000..52f6fc1e93aec86796bddf36a27f1fdba401423e --- /dev/null +++ b/apps/server/scripts/logger-scope-repro.ts @@ -0,0 +1,66 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import path from "node:path"; + +import { Effect, FileSystem, Layer, Logger, ServiceMap } from "effect"; + +import { makeEventNdjsonLogger } from "../src/provider/Layers/EventNdjsonLogger.ts"; + +class LogDir extends ServiceMap.Service()("t3/scripts/logger-scope-repro/LogDir") {} + +const main = Effect.gen(function* () { + const logdir = yield* LogDir; + const providerLogPath = path.join(logdir, "provider"); + + yield* Effect.logInfo(`providerLogPath=${providerLogPath}`); + + const providerLogger = yield* makeEventNdjsonLogger(providerLogPath, { + stream: "native", + batchWindowMs: 10, + }); + + yield* Effect.logInfo("before provider write"); + + if (providerLogger) { + yield* providerLogger.write( + { + kind: "probe", + message: "provider-only event", + }, + "thread-123" as never, + ); + } + + yield* Effect.logInfo("after provider write"); + yield* Effect.sleep("50 millis"); + + if (providerLogger) { + yield* providerLogger.close(); + } + yield* Effect.logInfo("after provider close"); + + const fs = yield* FileSystem.FileSystem; + const logContents = yield* fs + .readDirectory(logdir, { recursive: true }) + .pipe( + Effect.flatMap((entries) => + Effect.all(entries.map((entry) => fs.readFileString(path.join(logdir, entry)))), + ), + ); + yield* Effect.logInfo(`logContents=${logContents}`); +}); + +Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const logdir = path.join(process.cwd(), "logtest"); + yield* fs.makeDirectory(logdir); + + const fileLogger = yield* Logger.formatSimple.pipe( + Logger.toFile(path.join(logdir, "global.log")), + ); + const dualLogger = Logger.layer([fileLogger, Logger.consolePretty()]); + + const mainLayer = Layer.mergeAll(dualLogger, Layer.succeed(LogDir, logdir)); + + yield* main.pipe(Effect.provide(mainLayer)); +}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index ba9bf2a33ade7001cba0323c56ede9c12939c9ed..ba7844a2e9aac435f284f3c483cbfdee4d1fc239 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -18,6 +18,7 @@ import { type ProviderSessionStartInput, type ProviderTurnStartResult, } from "@t3tools/contracts"; +import { Effect } from "effect"; type PendingRequestKey = string; @@ -265,6 +266,20 @@ export class CodexAppServerManager extends EventEmitter, envValue: boolean) => + Option.getOrElse(Option.filter(flag, Boolean), () => envValue); + const ServerConfigLive = (input: CliInput) => Layer.effect( ServerConfig, @@ -151,25 +155,16 @@ const ServerConfigLive = (input: CliInput) => Option.getOrUndefined(input.stateDir) ?? env.stateDir, ); const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); - const noBrowser = Option.match(input.noBrowser, { - // effect/cli boolean flags parse to `false` when absent; in that case - // we still want env/mode fallbacks to apply. - onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), - onNone: () => env.noBrowser ?? mode === "desktop", - }); + const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; - const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { - // effect/cli boolean flags parse to `false` when absent; in that case - // we still want env/mode fallbacks to apply. - onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")), - onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web", - }); - const logWebSocketEvents = Option.match(input.logWebSocketEvents, { - // effect/cli boolean flags parse to `false` when absent; in that case - // we still want env/dev fallbacks to apply. - onSome: (value) => (value ? true : (env.logWebSocketEvents ?? Boolean(devUrl))), - onNone: () => env.logWebSocketEvents ?? Boolean(devUrl), - }); + const autoBootstrapProjectFromCwd = resolveBooleanFlag( + input.autoBootstrapProjectFromCwd, + env.autoBootstrapProjectFromCwd ?? mode === "web", + ); + const logWebSocketEvents = resolveBooleanFlag( + input.logWebSocketEvents, + env.logWebSocketEvents ?? Boolean(devUrl), + ); const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; const { join } = yield* Path.Path; const keybindingsConfigPath = join(stateDir, "keybindings.json"); @@ -178,7 +173,7 @@ const ServerConfigLive = (input: CliInput) => env.host ?? (mode === "desktop" ? "127.0.0.1" : undefined); - return { + const config: ServerConfigShape = { mode, port, cwd: cliConfig.cwd, @@ -192,6 +187,8 @@ const ServerConfigLive = (input: CliInput) => autoBootstrapProjectFromCwd, logWebSocketEvents, } satisfies ServerConfigShape; + + return config; }), ); @@ -201,6 +198,7 @@ const LayerLive = (input: CliInput) => Layer.provideMerge(makeServerProviderLayer()), Layer.provideMerge(ProviderHealthLive), Layer.provideMerge(SqlitePersistence.layerConfig), + Layer.provideMerge(ServerLoggerLive), Layer.provideMerge(ServerConfigLive(input)), ); @@ -235,14 +233,11 @@ const makeServerProgram = (input: CliInput) => config.host && !isWildcardHost(config.host) ? `http://${formatHostForUrl(config.host)}:${config.port}` : localUrl; + const { authToken, devUrl, ...safeConfig } = config; yield* Effect.logInfo("T3 Code running", { - url: bindUrl, - localUrl, - bindHost: config.host ?? "default", - cwd: config.cwd, - mode: config.mode, - stateDir: config.stateDir, - authEnabled: Boolean(config.authToken), + ...safeConfig, + devUrl: devUrl?.toString(), + authEnabled: Boolean(authToken), }); if (!config.noBrowser) { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 020f6c9fc6139f3e2788ef8976a9115f5b9794bb..9d3c4a21309ec0da52de194ed2c8e186965f5c33 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -509,6 +509,16 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = new Date().toISOString(); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -538,6 +548,11 @@ describe("ProviderCommandReactor", () => { }), ); + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + return thread?.runtimeMode === "approval-required"; + }); await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.stopSession.mock.calls.length === 1); @@ -636,6 +651,16 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = new Date().toISOString(); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -669,6 +694,11 @@ describe("ProviderCommandReactor", () => { }), ); + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + return thread?.runtimeMode === "approval-required"; + }); await waitFor(() => harness.startSession.mock.calls.length === 2); await Effect.runPromise(Effect.sleep("30 millis")); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b0ae0685f13058eb272e3ff859465c379f542439..196dd7c9e7aab2dd4c60c0c9a2d535cda534013f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -283,10 +283,31 @@ const make = Effect.gen(function* () { providerChanged || shouldRestartForModelChange ? undefined : (activeSession?.resumeCursor ?? undefined); + yield* Effect.logInfo("provider command reactor restarting provider session", { + threadId, + existingSessionId, + currentProvider, + desiredProvider: options?.provider ?? currentProvider, + currentRuntimeMode: thread.session?.runtimeMode, + desiredRuntimeMode: thread.runtimeMode, + runtimeModeChanged, + providerChanged, + modelChanged, + shouldRestartForModelChange, + hasResumeCursor: resumeCursor !== undefined, + }); const restartedSession = yield* startProviderSession({ ...(resumeCursor !== undefined ? { resumeCursor } : {}), ...(options?.provider !== undefined ? { provider: options.provider } : {}), }); + yield* Effect.logInfo("provider command reactor restarted provider session", { + threadId, + previousSessionId: existingSessionId, + restartedSessionId: restartedSession.sessionId, + provider: restartedSession.provider, + providerThreadId: restartedSession.threadId, + runtimeMode: restartedSession.runtimeMode, + }); yield* bindSessionToThread(restartedSession); yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( Effect.catchCause((cause) => diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 25d95c50b86a8660417e1c04a434ebd928674e0e..2b109bad0a0fe24827f5ea2efdc8063f51e90e0e 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -251,10 +251,12 @@ describe("decider project scripts", () => { if (singleResult === null) { throw new Error("Expected a single runtime-mode-set event."); } - expect(singleResult.type).toBe("thread.runtime-mode-set"); - expect(singleResult.payload).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), - runtimeMode: "approval-required", + expect(singleResult).toMatchObject({ + type: "thread.runtime-mode-set", + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "approval-required", + }, }); }); }); diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index f2d6787acd1047f56e2b084657d507a932867ea1..36ea3686c35709cea176115c5cc7faed34d938be 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -34,6 +34,7 @@ import { RuntimeItemId, RuntimeRequestId, RuntimeSessionId, + RuntimeTaskId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -1130,7 +1131,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "task.started", payload: { - taskId: message.task_id, + taskId: RuntimeTaskId.makeUnsafe(message.task_id), description: message.description, ...(message.task_type ? { taskType: message.task_type } : {}), }, @@ -1141,7 +1142,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "task.progress", payload: { - taskId: message.task_id, + taskId: RuntimeTaskId.makeUnsafe(message.task_id), description: message.description, ...(message.usage ? { usage: message.usage } : {}), ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), @@ -1153,7 +1154,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...base, type: "task.completed", payload: { - taskId: message.task_id, + taskId: RuntimeTaskId.makeUnsafe(message.task_id), status: message.status, ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), @@ -1259,7 +1260,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { return; } - if (message.type === "rate_limit") { + if (message.type === "rate_limit_event") { yield* offerRuntimeEvent({ ...base, type: "account.rate-limits.updated", @@ -1295,7 +1296,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { case "tool_progress": case "tool_use_summary": case "auth_status": - case "rate_limit": + case "rate_limit_event": yield* handleSdkTelemetryMessage(context, message); return; default: diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 3fdccff4f10a24ad9b442dfe34ec4b4973be66a8..4f2976c83be824c8edd473a3a19aab98d7e1d43e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -539,9 +539,50 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => }); const listSessions: ProviderServiceShape["listSessions"] = () => - Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe( - Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), - ); + Effect.gen(function* () { + const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions()); + const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); + const persistedBindings = yield* directory + .listSessionIds() + .pipe( + Effect.flatMap((sessionIds) => + Effect.forEach( + sessionIds, + (sessionId) => + directory.getBinding(sessionId).pipe( + Effect.orElseSucceed(() => Option.none()), + ), + { concurrency: "unbounded" }, + ), + ), + Effect.orElseSucceed(() => [] as Array>), + ); + const bindingsBySessionId = new Map(); + for (const bindingOption of persistedBindings) { + const binding = Option.getOrUndefined(bindingOption); + if (binding) { + bindingsBySessionId.set(binding.sessionId, binding); + } + } + + return activeSessions.map((session) => { + const binding = bindingsBySessionId.get(session.sessionId); + if (!binding) { + return session; + } + + return { + ...session, + ...(session.threadId === undefined && binding.providerThreadId + ? { threadId: binding.providerThreadId } + : {}), + ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined + ? { resumeCursor: binding.resumeCursor } + : {}), + ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}), + }; + }); + }); const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) => registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities)); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts new file mode 100644 index 0000000000000000000000000000000000000000..0abf9f382687e209854da3fbcc8688cdd3fbcfe5 --- /dev/null +++ b/apps/server/src/serverLogger.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { Effect, Logger, Option } from "effect"; +import * as Layer from "effect/Layer"; + +import { ServerConfig } from "./config"; + +export const ServerLoggerLive = Effect.gen(function* () { + const config = yield* Effect.serviceOption(ServerConfig); + if (Option.isNone(config)) { + return Logger.layer([Logger.defaultLogger]); + } + + const logDir = path.join(config.value.stateDir, "logs"); + const logPath = path.join(logDir, "server.log"); + + yield* Effect.sync(() => { + fs.mkdirSync(logDir, { recursive: true }); + }); + + const fileLogger = Logger.formatSimple.pipe(Logger.toFile(logPath)); + + return Logger.layer([Logger.defaultLogger, fileLogger], { + mergeWithExisting: false, + }); +}).pipe(Layer.unwrap); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ea4392d450c8eacaae766c0b7a2a4eb829972ec3..971bbddf34711fd2e3023e82e024dcd2e63d7363 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -613,6 +613,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); } + const runPromise = yield* Effect.map(Effect.services(), Effect.runPromiseWith); + yield* Effect.addFinalizer(() => + Effect.catch(liveProviderService.stopAll(), (cause) => + Effect.logWarning("failed to stop provider service", { cause }), + ), + ); + + const unsubscribeTerminalEvents = yield* terminalManager.subscribe( + (event) => void Effect.runPromise(onTerminalEvent(event)), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); + + yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( + Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), + ); + + yield* Effect.addFinalizer(() => + Effect.all([ + closeAllClients, + closeWebSocketServer.pipe( + Effect.catch((error) => + Effect.logWarning("failed to close web socket server", { cause: error }), + ), + ), + ]), + ); + const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { switch (request.body._tag) { case ORCHESTRATION_WS_METHODS.getSnapshot: @@ -800,6 +827,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< id: request.value.id, result: result.value, }); + ws.send(response); }); @@ -828,7 +856,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); wss.on("connection", (ws) => { - void Effect.runPromise(Ref.update(clients, (clients) => clients.add(ws))); + void runPromise(Ref.update(clients, (clients) => clients.add(ws))); const segments = cwd.split(/[/\\]/).filter(Boolean); const projectName = segments[segments.length - 1] ?? "project"; @@ -847,7 +875,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ws.send(JSON.stringify(welcome)); ws.on("message", (raw) => { - void Effect.runPromise( + void runPromise( handleMessage(ws, raw).pipe( Effect.catch((error) => Effect.logError("Error handling message", error)), ), @@ -855,7 +883,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); ws.on("close", () => { - void Effect.runPromise( + void runPromise( Ref.update(clients, (clients) => { clients.delete(ws); return clients; @@ -864,7 +892,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); ws.on("error", () => { - void Effect.runPromise( + void runPromise( Ref.update(clients, (clients) => { clients.delete(ws); return clients; @@ -873,32 +901,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); }); - yield* Effect.addFinalizer(() => - Effect.catch(liveProviderService.stopAll(), (cause) => - Effect.logWarning("failed to stop provider service", { cause }), - ), - ); - - const unsubscribeTerminalEvents = yield* terminalManager.subscribe( - (event) => void Effect.runPromise(onTerminalEvent(event)), - ); - yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); - - yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( - Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), - ); - - yield* Effect.addFinalizer(() => - Effect.all([ - closeAllClients, - closeWebSocketServer.pipe( - Effect.catch((error) => - Effect.logWarning("failed to close web socket server", { cause: error }), - ), - ), - ]), - ); - return httpServer; }); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 61809a40f3260038c2d6820d2722b7e2af73e5d6..704a285414ff821bdaff5d5de56efc8fd6fbc112 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -97,6 +97,51 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/"); }), ); + + it.effect("does not force websocket logging on in dev mode when unset", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: { + T3CODE_LOG_WS_EVENTS: "keep-me-out", + }, + serverOffset: 0, + webOffset: 0, + stateDir: undefined, + authToken: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_MODE, "web"); + assert.equal(env.T3CODE_LOG_WS_EVENTS, undefined); + }), + ); + + it.effect("forwards explicit websocket logging false without coercing it away", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + stateDir: undefined, + authToken: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: false, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_LOG_WS_EVENTS, "0"); + }), + ); }); describe("findFirstAvailableOffset", () => { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 9880cb3ad179da0d05f9d7ecd434e0a0f047ab54..faab7d4ae1ee9c94d2b4a755601b64bf1833d653 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -189,9 +189,6 @@ export function createDevRunnerEnv({ if (mode === "dev") { output.T3CODE_MODE = "web"; - if (logWebSocketEvents === undefined) { - output.T3CODE_LOG_WS_EVENTS = "1"; - } delete output.T3CODE_DESKTOP_WS_URL; } @@ -350,6 +347,35 @@ interface DevRunnerCliInput { readonly turboArgs: ReadonlyArray; } +const readOptionalBooleanEnv = (name: string): boolean | undefined => { + const value = process.env[name]; + if (value === undefined) { + return undefined; + } + if (value === "1" || value.toLowerCase() === "true") { + return true; + } + if (value === "0" || value.toLowerCase() === "false") { + return false; + } + return undefined; +}; + +const resolveOptionalBooleanOverride = ( + explicitValue: boolean | undefined, + envValue: boolean | undefined, +): boolean | undefined => { + if (explicitValue === true) { + return true; + } + + if (explicitValue === false) { + return envValue; + } + + return envValue; +}; + export function runDevRunnerWithInput(input: DevRunnerCliInput) { return Effect.gen(function* () { const { portOffset, devInstance } = yield* OffsetConfig.asEffect().pipe( @@ -371,6 +397,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { }), }); + const envOverrides = { + noBrowser: readOptionalBooleanEnv("T3CODE_NO_BROWSER"), + autoBootstrapProjectFromCwd: readOptionalBooleanEnv( + "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD", + ), + logWebSocketEvents: readOptionalBooleanEnv("T3CODE_LOG_WS_EVENTS"), + }; + const { serverOffset, webOffset } = yield* resolveModePortOffsets({ mode: input.mode, startOffset: offset, @@ -385,9 +419,15 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { webOffset, stateDir: input.stateDir, authToken: input.authToken, - noBrowser: input.noBrowser, - autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, - logWebSocketEvents: input.logWebSocketEvents, + noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), + autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( + input.autoBootstrapProjectFromCwd, + envOverrides.autoBootstrapProjectFromCwd, + ), + logWebSocketEvents: resolveOptionalBooleanOverride( + input.logWebSocketEvents, + envOverrides.logWebSocketEvents, + ), host: input.host, port: input.port, devUrl: input.devUrl, -- 2.54.0