| author | |
| committer | |
| log | 981cf405612932afa8a0b98b8c98474ef7e1015d |
| tree | 77f358d2b39ba5f0104d2f6d8bf9e7cfaf9a0388 |
| parent | 689dda59e997337103c76ed93af0574dcc554d26 |
- 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 behavior12 files changed, 386 insertions(+), 74 deletions(-)
apps/server/scripts/logger-scope-repro.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { Effect, FileSystem, Layer, Logger, ServiceMap } from "effect"; | |
| 6 | ||
| 7 | import { makeEventNdjsonLogger } from "../src/provider/Layers/EventNdjsonLogger.ts"; | |
| 8 | ||
| 9 | class LogDir extends ServiceMap.Service<LogDir, string>()("t3/scripts/logger-scope-repro/LogDir") {} | |
| 10 | ||
| 11 | const main = Effect.gen(function* () { | |
| 12 | const logdir = yield* LogDir; | |
| 13 | const providerLogPath = path.join(logdir, "provider"); | |
| 14 | ||
| 15 | yield* Effect.logInfo(`providerLogPath=${providerLogPath}`); | |
| 16 | ||
| 17 | const providerLogger = yield* makeEventNdjsonLogger(providerLogPath, { | |
| 18 | stream: "native", | |
| 19 | batchWindowMs: 10, | |
| 20 | }); | |
| 21 | ||
| 22 | yield* Effect.logInfo("before provider write"); | |
| 23 | ||
| 24 | if (providerLogger) { | |
| 25 | yield* providerLogger.write( | |
| 26 | { | |
| 27 | kind: "probe", | |
| 28 | message: "provider-only event", | |
| 29 | }, | |
| 30 | "thread-123" as never, | |
| 31 | ); | |
| 32 | } | |
| 33 | ||
| 34 | yield* Effect.logInfo("after provider write"); | |
| 35 | yield* Effect.sleep("50 millis"); | |
| 36 | ||
| 37 | if (providerLogger) { | |
| 38 | yield* providerLogger.close(); | |
| 39 | } | |
| 40 | yield* Effect.logInfo("after provider close"); | |
| 41 | ||
| 42 | const fs = yield* FileSystem.FileSystem; | |
| 43 | const logContents = yield* fs | |
| 44 | .readDirectory(logdir, { recursive: true }) | |
| 45 | .pipe( | |
| 46 | Effect.flatMap((entries) => | |
| 47 | Effect.all(entries.map((entry) => fs.readFileString(path.join(logdir, entry)))), | |
| 48 | ), | |
| 49 | ); | |
| 50 | yield* Effect.logInfo(`logContents=${logContents}`); | |
| 51 | }); | |
| 52 | ||
| 53 | Effect.gen(function* () { | |
| 54 | const fs = yield* FileSystem.FileSystem; | |
| 55 | const logdir = path.join(process.cwd(), "logtest"); | |
| 56 | yield* fs.makeDirectory(logdir); | |
| 57 | ||
| 58 | const fileLogger = yield* Logger.formatSimple.pipe( | |
| 59 | Logger.toFile(path.join(logdir, "global.log")), | |
| 60 | ); | |
| 61 | const dualLogger = Logger.layer([fileLogger, Logger.consolePretty()]); | |
| 62 | ||
| 63 | const mainLayer = Layer.mergeAll(dualLogger, Layer.succeed(LogDir, logdir)); | |
| 64 | ||
| 65 | yield* main.pipe(Effect.provide(mainLayer)); | |
| 66 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); |
apps/server/src/codexAppServerManager.ts+42| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | type ProviderSessionStartInput, |
| 19 | 19 | type ProviderTurnStartResult, |
| 20 | 20 | } from "@t3tools/contracts"; |
| 21 | import { Effect } from "effect"; | |
| 21 | 22 | |
| 22 | 23 | type PendingRequestKey = string; |
| 23 | 24 | |
| ... | ... | @@ -265,6 +266,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 265 | 266 | experimentalRawEvents: false, |
| 266 | 267 | }; |
| 267 | 268 | const resumeThreadId = readResumeThreadId(input); |
| 269 | this.emitLifecycleEvent( | |
| 270 | context, | |
| 271 | "session/threadOpenRequested", | |
| 272 | resumeThreadId | |
| 273 | ? `Attempting to resume thread ${resumeThreadId}.` | |
| 274 | : "Starting a new Codex thread.", | |
| 275 | ); | |
| 276 | await Effect.logInfo("codex app-server opening thread", { | |
| 277 | sessionId, | |
| 278 | requestedRuntimeMode: input.runtimeMode, | |
| 279 | requestedModel: normalizedModel ?? null, | |
| 280 | requestedCwd: resolvedCwd, | |
| 281 | resumeThreadId: resumeThreadId ?? null, | |
| 282 | }).pipe(Effect.runPromise); | |
| 268 | 283 | |
| 269 | 284 | let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start"; |
| 270 | 285 | let threadOpenResponse: unknown; |
| ... | ... | @@ -277,6 +292,18 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 277 | 292 | }); |
| 278 | 293 | } catch (error) { |
| 279 | 294 | if (!isRecoverableThreadResumeError(error)) { |
| 295 | this.emitErrorEvent( | |
| 296 | context, | |
| 297 | "session/threadResumeFailed", | |
| 298 | error instanceof Error ? error.message : "Codex thread resume failed.", | |
| 299 | ); | |
| 300 | await Effect.logWarning("codex app-server thread resume failed", { | |
| 301 | sessionId, | |
| 302 | requestedRuntimeMode: input.runtimeMode, | |
| 303 | resumeThreadId, | |
| 304 | recoverable: false, | |
| 305 | cause: error instanceof Error ? error.message : String(error), | |
| 306 | }).pipe(Effect.runPromise); | |
| 280 | 307 | throw error; |
| 281 | 308 | } |
| 282 | 309 | |
| ... | ... | @@ -286,6 +313,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 286 | 313 | "session/threadResumeFallback", |
| 287 | 314 | `Could not resume thread ${resumeThreadId}; started a new thread instead.`, |
| 288 | 315 | ); |
| 316 | await Effect.logWarning("codex app-server thread resume fell back to fresh start", { | |
| 317 | sessionId, | |
| 318 | requestedRuntimeMode: input.runtimeMode, | |
| 319 | resumeThreadId, | |
| 320 | recoverable: true, | |
| 321 | cause: error instanceof Error ? error.message : String(error), | |
| 322 | }).pipe(Effect.runPromise); | |
| 289 | 323 | threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams); |
| 290 | 324 | } |
| 291 | 325 | } else { |
| ... | ... | @@ -307,6 +341,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 307 | 341 | threadId, |
| 308 | 342 | resumeCursor: { threadId }, |
| 309 | 343 | }); |
| 344 | this.emitLifecycleEvent(context, "session/threadOpenResolved", `Codex ${threadOpenMethod} resolved.`); | |
| 345 | await Effect.logInfo("codex app-server thread open resolved", { | |
| 346 | sessionId, | |
| 347 | threadOpenMethod, | |
| 348 | requestedResumeThreadId: resumeThreadId ?? null, | |
| 349 | resolvedThreadId: threadId, | |
| 350 | requestedRuntimeMode: input.runtimeMode, | |
| 351 | }).pipe(Effect.runPromise); | |
| 310 | 352 | this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`); |
| 311 | 353 | return { ...context.session }; |
| 312 | 354 | } catch (error) { |
apps/server/src/main.ts+21-26| ... | ... | @@ -24,6 +24,7 @@ import * as SqlitePersistence from "./persistence/Layers/Sqlite"; |
| 24 | 24 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; |
| 25 | 25 | import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; |
| 26 | 26 | import { Server } from "./wsServer"; |
| 27 | import { ServerLoggerLive } from "./serverLogger"; | |
| 27 | 28 | |
| 28 | 29 | export class StartupError extends Data.TaggedError("StartupError")<{ |
| 29 | 30 | readonly message: string; |
| ... | ... | @@ -120,6 +121,9 @@ const CliEnvConfig = Config.all({ |
| 120 | 121 | ), |
| 121 | 122 | }); |
| 122 | 123 | |
| 124 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | |
| 125 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | |
| 126 | ||
| 123 | 127 | const ServerConfigLive = (input: CliInput) => |
| 124 | 128 | Layer.effect( |
| 125 | 129 | ServerConfig, |
| ... | ... | @@ -151,25 +155,16 @@ const ServerConfigLive = (input: CliInput) => |
| 151 | 155 | Option.getOrUndefined(input.stateDir) ?? env.stateDir, |
| 152 | 156 | ); |
| 153 | 157 | const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); |
| 154 | const noBrowser = Option.match(input.noBrowser, { | |
| 155 | // effect/cli boolean flags parse to `false` when absent; in that case | |
| 156 | // we still want env/mode fallbacks to apply. | |
| 157 | onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), | |
| 158 | onNone: () => env.noBrowser ?? mode === "desktop", | |
| 159 | }); | |
| 158 | const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); | |
| 160 | 159 | const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; |
| 161 | const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { | |
| 162 | // effect/cli boolean flags parse to `false` when absent; in that case | |
| 163 | // we still want env/mode fallbacks to apply. | |
| 164 | onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")), | |
| 165 | onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web", | |
| 166 | }); | |
| 167 | const logWebSocketEvents = Option.match(input.logWebSocketEvents, { | |
| 168 | // effect/cli boolean flags parse to `false` when absent; in that case | |
| 169 | // we still want env/dev fallbacks to apply. | |
| 170 | onSome: (value) => (value ? true : (env.logWebSocketEvents ?? Boolean(devUrl))), | |
| 171 | onNone: () => env.logWebSocketEvents ?? Boolean(devUrl), | |
| 172 | }); | |
| 160 | const autoBootstrapProjectFromCwd = resolveBooleanFlag( | |
| 161 | input.autoBootstrapProjectFromCwd, | |
| 162 | env.autoBootstrapProjectFromCwd ?? mode === "web", | |
| 163 | ); | |
| 164 | const logWebSocketEvents = resolveBooleanFlag( | |
| 165 | input.logWebSocketEvents, | |
| 166 | env.logWebSocketEvents ?? Boolean(devUrl), | |
| 167 | ); | |
| 173 | 168 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; |
| 174 | 169 | const { join } = yield* Path.Path; |
| 175 | 170 | const keybindingsConfigPath = join(stateDir, "keybindings.json"); |
| ... | ... | @@ -178,7 +173,7 @@ const ServerConfigLive = (input: CliInput) => |
| 178 | 173 | env.host ?? |
| 179 | 174 | (mode === "desktop" ? "127.0.0.1" : undefined); |
| 180 | 175 | |
| 181 | return { | |
| 176 | const config: ServerConfigShape = { | |
| 182 | 177 | mode, |
| 183 | 178 | port, |
| 184 | 179 | cwd: cliConfig.cwd, |
| ... | ... | @@ -192,6 +187,8 @@ const ServerConfigLive = (input: CliInput) => |
| 192 | 187 | autoBootstrapProjectFromCwd, |
| 193 | 188 | logWebSocketEvents, |
| 194 | 189 | } satisfies ServerConfigShape; |
| 190 | ||
| 191 | return config; | |
| 195 | 192 | }), |
| 196 | 193 | ); |
| 197 | 194 | |
| ... | ... | @@ -201,6 +198,7 @@ const LayerLive = (input: CliInput) => |
| 201 | 198 | Layer.provideMerge(makeServerProviderLayer()), |
| 202 | 199 | Layer.provideMerge(ProviderHealthLive), |
| 203 | 200 | Layer.provideMerge(SqlitePersistence.layerConfig), |
| 201 | Layer.provideMerge(ServerLoggerLive), | |
| 204 | 202 | Layer.provideMerge(ServerConfigLive(input)), |
| 205 | 203 | ); |
| 206 | 204 | |
| ... | ... | @@ -235,14 +233,11 @@ const makeServerProgram = (input: CliInput) => |
| 235 | 233 | config.host && !isWildcardHost(config.host) |
| 236 | 234 | ? `http://${formatHostForUrl(config.host)}:${config.port}` |
| 237 | 235 | : localUrl; |
| 236 | const { authToken, devUrl, ...safeConfig } = config; | |
| 238 | 237 | yield* Effect.logInfo("T3 Code running", { |
| 239 | url: bindUrl, | |
| 240 | localUrl, | |
| 241 | bindHost: config.host ?? "default", | |
| 242 | cwd: config.cwd, | |
| 243 | mode: config.mode, | |
| 244 | stateDir: config.stateDir, | |
| 245 | authEnabled: Boolean(config.authToken), | |
| 238 | ...safeConfig, | |
| 239 | devUrl: devUrl?.toString(), | |
| 240 | authEnabled: Boolean(authToken), | |
| 246 | 241 | }); |
| 247 | 242 | |
| 248 | 243 | if (!config.noBrowser) { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+30| ... | ... | @@ -509,6 +509,16 @@ describe("ProviderCommandReactor", () => { |
| 509 | 509 | const harness = await createHarness(); |
| 510 | 510 | const now = new Date().toISOString(); |
| 511 | 511 | |
| 512 | await Effect.runPromise( | |
| 513 | harness.engine.dispatch({ | |
| 514 | type: "thread.runtime-mode.set", | |
| 515 | commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access"), | |
| 516 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 517 | runtimeMode: "full-access", | |
| 518 | createdAt: now, | |
| 519 | }), | |
| 520 | ); | |
| 521 | ||
| 512 | 522 | await Effect.runPromise( |
| 513 | 523 | harness.engine.dispatch({ |
| 514 | 524 | type: "thread.turn.start", |
| ... | ... | @@ -538,6 +548,11 @@ describe("ProviderCommandReactor", () => { |
| 538 | 548 | }), |
| 539 | 549 | ); |
| 540 | 550 | |
| 551 | await waitFor(async () => { | |
| 552 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 553 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 554 | return thread?.runtimeMode === "approval-required"; | |
| 555 | }); | |
| 541 | 556 | await waitFor(() => harness.startSession.mock.calls.length === 2); |
| 542 | 557 | await waitFor(() => harness.stopSession.mock.calls.length === 1); |
| 543 | 558 | |
| ... | ... | @@ -636,6 +651,16 @@ describe("ProviderCommandReactor", () => { |
| 636 | 651 | const harness = await createHarness(); |
| 637 | 652 | const now = new Date().toISOString(); |
| 638 | 653 | |
| 654 | await Effect.runPromise( | |
| 655 | harness.engine.dispatch({ | |
| 656 | type: "thread.runtime-mode.set", | |
| 657 | commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-initial-full-access-2"), | |
| 658 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 659 | runtimeMode: "full-access", | |
| 660 | createdAt: now, | |
| 661 | }), | |
| 662 | ); | |
| 663 | ||
| 639 | 664 | await Effect.runPromise( |
| 640 | 665 | harness.engine.dispatch({ |
| 641 | 666 | type: "thread.turn.start", |
| ... | ... | @@ -669,6 +694,11 @@ describe("ProviderCommandReactor", () => { |
| 669 | 694 | }), |
| 670 | 695 | ); |
| 671 | 696 | |
| 697 | await waitFor(async () => { | |
| 698 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 699 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 700 | return thread?.runtimeMode === "approval-required"; | |
| 701 | }); | |
| 672 | 702 | await waitFor(() => harness.startSession.mock.calls.length === 2); |
| 673 | 703 | await Effect.runPromise(Effect.sleep("30 millis")); |
| 674 | 704 |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+21| ... | ... | @@ -283,10 +283,31 @@ const make = Effect.gen(function* () { |
| 283 | 283 | providerChanged || shouldRestartForModelChange |
| 284 | 284 | ? undefined |
| 285 | 285 | : (activeSession?.resumeCursor ?? undefined); |
| 286 | yield* Effect.logInfo("provider command reactor restarting provider session", { | |
| 287 | threadId, | |
| 288 | existingSessionId, | |
| 289 | currentProvider, | |
| 290 | desiredProvider: options?.provider ?? currentProvider, | |
| 291 | currentRuntimeMode: thread.session?.runtimeMode, | |
| 292 | desiredRuntimeMode: thread.runtimeMode, | |
| 293 | runtimeModeChanged, | |
| 294 | providerChanged, | |
| 295 | modelChanged, | |
| 296 | shouldRestartForModelChange, | |
| 297 | hasResumeCursor: resumeCursor !== undefined, | |
| 298 | }); | |
| 286 | 299 | const restartedSession = yield* startProviderSession({ |
| 287 | 300 | ...(resumeCursor !== undefined ? { resumeCursor } : {}), |
| 288 | 301 | ...(options?.provider !== undefined ? { provider: options.provider } : {}), |
| 289 | 302 | }); |
| 303 | yield* Effect.logInfo("provider command reactor restarted provider session", { | |
| 304 | threadId, | |
| 305 | previousSessionId: existingSessionId, | |
| 306 | restartedSessionId: restartedSession.sessionId, | |
| 307 | provider: restartedSession.provider, | |
| 308 | providerThreadId: restartedSession.threadId, | |
| 309 | runtimeMode: restartedSession.runtimeMode, | |
| 310 | }); | |
| 290 | 311 | yield* bindSessionToThread(restartedSession); |
| 291 | 312 | yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( |
| 292 | 313 | Effect.catchCause((cause) => |
apps/server/src/orchestration/decider.projectScripts.test.ts+6-4| ... | ... | @@ -251,10 +251,12 @@ describe("decider project scripts", () => { |
| 251 | 251 | if (singleResult === null) { |
| 252 | 252 | throw new Error("Expected a single runtime-mode-set event."); |
| 253 | 253 | } |
| 254 | expect(singleResult.type).toBe("thread.runtime-mode-set"); | |
| 255 | expect(singleResult.payload).toMatchObject({ | |
| 256 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 257 | runtimeMode: "approval-required", | |
| 254 | expect(singleResult).toMatchObject({ | |
| 255 | type: "thread.runtime-mode-set", | |
| 256 | payload: { | |
| 257 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 258 | runtimeMode: "approval-required", | |
| 259 | }, | |
| 258 | 260 | }); |
| 259 | 261 | }); |
| 260 | 262 | }); |
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+6-5| ... | ... | @@ -34,6 +34,7 @@ import { |
| 34 | 34 | RuntimeItemId, |
| 35 | 35 | RuntimeRequestId, |
| 36 | 36 | RuntimeSessionId, |
| 37 | RuntimeTaskId, | |
| 37 | 38 | ThreadId, |
| 38 | 39 | TurnId, |
| 39 | 40 | } from "@t3tools/contracts"; |
| ... | ... | @@ -1130,7 +1131,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1130 | 1131 | ...base, |
| 1131 | 1132 | type: "task.started", |
| 1132 | 1133 | payload: { |
| 1133 | taskId: message.task_id, | |
| 1134 | taskId: RuntimeTaskId.makeUnsafe(message.task_id), | |
| 1134 | 1135 | description: message.description, |
| 1135 | 1136 | ...(message.task_type ? { taskType: message.task_type } : {}), |
| 1136 | 1137 | }, |
| ... | ... | @@ -1141,7 +1142,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1141 | 1142 | ...base, |
| 1142 | 1143 | type: "task.progress", |
| 1143 | 1144 | payload: { |
| 1144 | taskId: message.task_id, | |
| 1145 | taskId: RuntimeTaskId.makeUnsafe(message.task_id), | |
| 1145 | 1146 | description: message.description, |
| 1146 | 1147 | ...(message.usage ? { usage: message.usage } : {}), |
| 1147 | 1148 | ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), |
| ... | ... | @@ -1153,7 +1154,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1153 | 1154 | ...base, |
| 1154 | 1155 | type: "task.completed", |
| 1155 | 1156 | payload: { |
| 1156 | taskId: message.task_id, | |
| 1157 | taskId: RuntimeTaskId.makeUnsafe(message.task_id), | |
| 1157 | 1158 | status: message.status, |
| 1158 | 1159 | ...(message.summary ? { summary: message.summary } : {}), |
| 1159 | 1160 | ...(message.usage ? { usage: message.usage } : {}), |
| ... | ... | @@ -1259,7 +1260,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1259 | 1260 | return; |
| 1260 | 1261 | } |
| 1261 | 1262 | |
| 1262 | if (message.type === "rate_limit") { | |
| 1263 | if (message.type === "rate_limit_event") { | |
| 1263 | 1264 | yield* offerRuntimeEvent({ |
| 1264 | 1265 | ...base, |
| 1265 | 1266 | type: "account.rate-limits.updated", |
| ... | ... | @@ -1295,7 +1296,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1295 | 1296 | case "tool_progress": |
| 1296 | 1297 | case "tool_use_summary": |
| 1297 | 1298 | case "auth_status": |
| 1298 | case "rate_limit": | |
| 1299 | case "rate_limit_event": | |
| 1299 | 1300 | yield* handleSdkTelemetryMessage(context, message); |
| 1300 | 1301 | return; |
| 1301 | 1302 | default: |
apps/server/src/provider/Layers/ProviderService.ts+44-3| ... | ... | @@ -539,9 +539,50 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 539 | 539 | }); |
| 540 | 540 | |
| 541 | 541 | const listSessions: ProviderServiceShape["listSessions"] = () => |
| 542 | Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe( | |
| 543 | Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), | |
| 544 | ); | |
| 542 | Effect.gen(function* () { | |
| 543 | const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions()); | |
| 544 | const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); | |
| 545 | const persistedBindings = yield* directory | |
| 546 | .listSessionIds() | |
| 547 | .pipe( | |
| 548 | Effect.flatMap((sessionIds) => | |
| 549 | Effect.forEach( | |
| 550 | sessionIds, | |
| 551 | (sessionId) => | |
| 552 | directory.getBinding(sessionId).pipe( | |
| 553 | Effect.orElseSucceed(() => Option.none<ProviderSessionBinding>()), | |
| 554 | ), | |
| 555 | { concurrency: "unbounded" }, | |
| 556 | ), | |
| 557 | ), | |
| 558 | Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderSessionBinding>>), | |
| 559 | ); | |
| 560 | const bindingsBySessionId = new Map<ProviderSessionId, ProviderSessionBinding>(); | |
| 561 | for (const bindingOption of persistedBindings) { | |
| 562 | const binding = Option.getOrUndefined(bindingOption); | |
| 563 | if (binding) { | |
| 564 | bindingsBySessionId.set(binding.sessionId, binding); | |
| 565 | } | |
| 566 | } | |
| 567 | ||
| 568 | return activeSessions.map((session) => { | |
| 569 | const binding = bindingsBySessionId.get(session.sessionId); | |
| 570 | if (!binding) { | |
| 571 | return session; | |
| 572 | } | |
| 573 | ||
| 574 | return { | |
| 575 | ...session, | |
| 576 | ...(session.threadId === undefined && binding.providerThreadId | |
| 577 | ? { threadId: binding.providerThreadId } | |
| 578 | : {}), | |
| 579 | ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined | |
| 580 | ? { resumeCursor: binding.resumeCursor } | |
| 581 | : {}), | |
| 582 | ...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}), | |
| 583 | }; | |
| 584 | }); | |
| 585 | }); | |
| 545 | 586 | |
| 546 | 587 | const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) => |
| 547 | 588 | registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities)); |
apps/server/src/serverLogger.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import { Effect, Logger, Option } from "effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | ||
| 7 | import { ServerConfig } from "./config"; | |
| 8 | ||
| 9 | export const ServerLoggerLive = Effect.gen(function* () { | |
| 10 | const config = yield* Effect.serviceOption(ServerConfig); | |
| 11 | if (Option.isNone(config)) { | |
| 12 | return Logger.layer([Logger.defaultLogger]); | |
| 13 | } | |
| 14 | ||
| 15 | const logDir = path.join(config.value.stateDir, "logs"); | |
| 16 | const logPath = path.join(logDir, "server.log"); | |
| 17 | ||
| 18 | yield* Effect.sync(() => { | |
| 19 | fs.mkdirSync(logDir, { recursive: true }); | |
| 20 | }); | |
| 21 | ||
| 22 | const fileLogger = Logger.formatSimple.pipe(Logger.toFile(logPath)); | |
| 23 | ||
| 24 | return Logger.layer([Logger.defaultLogger, fileLogger], { | |
| 25 | mergeWithExisting: false, | |
| 26 | }); | |
| 27 | }).pipe(Layer.unwrap); |
apps/server/src/wsServer.ts+32-30| ... | ... | @@ -613,6 +613,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 613 | 613 | ); |
| 614 | 614 | } |
| 615 | 615 | |
| 616 | const runPromise = yield* Effect.map(Effect.services<never>(), Effect.runPromiseWith); | |
| 617 | yield* Effect.addFinalizer(() => | |
| 618 | Effect.catch(liveProviderService.stopAll(), (cause) => | |
| 619 | Effect.logWarning("failed to stop provider service", { cause }), | |
| 620 | ), | |
| 621 | ); | |
| 622 | ||
| 623 | const unsubscribeTerminalEvents = yield* terminalManager.subscribe( | |
| 624 | (event) => void Effect.runPromise(onTerminalEvent(event)), | |
| 625 | ); | |
| 626 | yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); | |
| 627 | ||
| 628 | yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( | |
| 629 | Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), | |
| 630 | ); | |
| 631 | ||
| 632 | yield* Effect.addFinalizer(() => | |
| 633 | Effect.all([ | |
| 634 | closeAllClients, | |
| 635 | closeWebSocketServer.pipe( | |
| 636 | Effect.catch((error) => | |
| 637 | Effect.logWarning("failed to close web socket server", { cause: error }), | |
| 638 | ), | |
| 639 | ), | |
| 640 | ]), | |
| 641 | ); | |
| 642 | ||
| 616 | 643 | const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { |
| 617 | 644 | switch (request.body._tag) { |
| 618 | 645 | case ORCHESTRATION_WS_METHODS.getSnapshot: |
| ... | ... | @@ -800,6 +827,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 800 | 827 | id: request.value.id, |
| 801 | 828 | result: result.value, |
| 802 | 829 | }); |
| 830 | ||
| 803 | 831 | ws.send(response); |
| 804 | 832 | }); |
| 805 | 833 | |
| ... | ... | @@ -828,7 +856,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 828 | 856 | }); |
| 829 | 857 | |
| 830 | 858 | wss.on("connection", (ws) => { |
| 831 | void Effect.runPromise(Ref.update(clients, (clients) => clients.add(ws))); | |
| 859 | void runPromise(Ref.update(clients, (clients) => clients.add(ws))); | |
| 832 | 860 | |
| 833 | 861 | const segments = cwd.split(/[/\\]/).filter(Boolean); |
| 834 | 862 | const projectName = segments[segments.length - 1] ?? "project"; |
| ... | ... | @@ -847,7 +875,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 847 | 875 | ws.send(JSON.stringify(welcome)); |
| 848 | 876 | |
| 849 | 877 | ws.on("message", (raw) => { |
| 850 | void Effect.runPromise( | |
| 878 | void runPromise( | |
| 851 | 879 | handleMessage(ws, raw).pipe( |
| 852 | 880 | Effect.catch((error) => Effect.logError("Error handling message", error)), |
| 853 | 881 | ), |
| ... | ... | @@ -855,7 +883,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 855 | 883 | }); |
| 856 | 884 | |
| 857 | 885 | ws.on("close", () => { |
| 858 | void Effect.runPromise( | |
| 886 | void runPromise( | |
| 859 | 887 | Ref.update(clients, (clients) => { |
| 860 | 888 | clients.delete(ws); |
| 861 | 889 | return clients; |
| ... | ... | @@ -864,7 +892,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 864 | 892 | }); |
| 865 | 893 | |
| 866 | 894 | ws.on("error", () => { |
| 867 | void Effect.runPromise( | |
| 895 | void runPromise( | |
| 868 | 896 | Ref.update(clients, (clients) => { |
| 869 | 897 | clients.delete(ws); |
| 870 | 898 | return clients; |
| ... | ... | @@ -873,32 +901,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 873 | 901 | }); |
| 874 | 902 | }); |
| 875 | 903 | |
| 876 | yield* Effect.addFinalizer(() => | |
| 877 | Effect.catch(liveProviderService.stopAll(), (cause) => | |
| 878 | Effect.logWarning("failed to stop provider service", { cause }), | |
| 879 | ), | |
| 880 | ); | |
| 881 | ||
| 882 | const unsubscribeTerminalEvents = yield* terminalManager.subscribe( | |
| 883 | (event) => void Effect.runPromise(onTerminalEvent(event)), | |
| 884 | ); | |
| 885 | yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); | |
| 886 | ||
| 887 | yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( | |
| 888 | Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), | |
| 889 | ); | |
| 890 | ||
| 891 | yield* Effect.addFinalizer(() => | |
| 892 | Effect.all([ | |
| 893 | closeAllClients, | |
| 894 | closeWebSocketServer.pipe( | |
| 895 | Effect.catch((error) => | |
| 896 | Effect.logWarning("failed to close web socket server", { cause: error }), | |
| 897 | ), | |
| 898 | ), | |
| 899 | ]), | |
| 900 | ); | |
| 901 | ||
| 902 | 904 | return httpServer; |
| 903 | 905 | }); |
| 904 | 906 |
scripts/dev-runner.test.ts+45| ... | ... | @@ -97,6 +97,51 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 97 | 97 | assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/"); |
| 98 | 98 | }), |
| 99 | 99 | ); |
| 100 | ||
| 101 | it.effect("does not force websocket logging on in dev mode when unset", () => | |
| 102 | Effect.gen(function* () { | |
| 103 | const env = yield* createDevRunnerEnv({ | |
| 104 | mode: "dev", | |
| 105 | baseEnv: { | |
| 106 | T3CODE_LOG_WS_EVENTS: "keep-me-out", | |
| 107 | }, | |
| 108 | serverOffset: 0, | |
| 109 | webOffset: 0, | |
| 110 | stateDir: undefined, | |
| 111 | authToken: undefined, | |
| 112 | noBrowser: undefined, | |
| 113 | autoBootstrapProjectFromCwd: undefined, | |
| 114 | logWebSocketEvents: undefined, | |
| 115 | host: undefined, | |
| 116 | port: undefined, | |
| 117 | devUrl: undefined, | |
| 118 | }); | |
| 119 | ||
| 120 | assert.equal(env.T3CODE_MODE, "web"); | |
| 121 | assert.equal(env.T3CODE_LOG_WS_EVENTS, undefined); | |
| 122 | }), | |
| 123 | ); | |
| 124 | ||
| 125 | it.effect("forwards explicit websocket logging false without coercing it away", () => | |
| 126 | Effect.gen(function* () { | |
| 127 | const env = yield* createDevRunnerEnv({ | |
| 128 | mode: "dev", | |
| 129 | baseEnv: {}, | |
| 130 | serverOffset: 0, | |
| 131 | webOffset: 0, | |
| 132 | stateDir: undefined, | |
| 133 | authToken: undefined, | |
| 134 | noBrowser: undefined, | |
| 135 | autoBootstrapProjectFromCwd: undefined, | |
| 136 | logWebSocketEvents: false, | |
| 137 | host: undefined, | |
| 138 | port: undefined, | |
| 139 | devUrl: undefined, | |
| 140 | }); | |
| 141 | ||
| 142 | assert.equal(env.T3CODE_LOG_WS_EVENTS, "0"); | |
| 143 | }), | |
| 144 | ); | |
| 100 | 145 | }); |
| 101 | 146 | |
| 102 | 147 | describe("findFirstAvailableOffset", () => { |
scripts/dev-runner.ts+46-6| ... | ... | @@ -189,9 +189,6 @@ export function createDevRunnerEnv({ |
| 189 | 189 | |
| 190 | 190 | if (mode === "dev") { |
| 191 | 191 | output.T3CODE_MODE = "web"; |
| 192 | if (logWebSocketEvents === undefined) { | |
| 193 | output.T3CODE_LOG_WS_EVENTS = "1"; | |
| 194 | } | |
| 195 | 192 | delete output.T3CODE_DESKTOP_WS_URL; |
| 196 | 193 | } |
| 197 | 194 | |
| ... | ... | @@ -350,6 +347,35 @@ interface DevRunnerCliInput { |
| 350 | 347 | readonly turboArgs: ReadonlyArray<string>; |
| 351 | 348 | } |
| 352 | 349 | |
| 350 | const readOptionalBooleanEnv = (name: string): boolean | undefined => { | |
| 351 | const value = process.env[name]; | |
| 352 | if (value === undefined) { | |
| 353 | return undefined; | |
| 354 | } | |
| 355 | if (value === "1" || value.toLowerCase() === "true") { | |
| 356 | return true; | |
| 357 | } | |
| 358 | if (value === "0" || value.toLowerCase() === "false") { | |
| 359 | return false; | |
| 360 | } | |
| 361 | return undefined; | |
| 362 | }; | |
| 363 | ||
| 364 | const resolveOptionalBooleanOverride = ( | |
| 365 | explicitValue: boolean | undefined, | |
| 366 | envValue: boolean | undefined, | |
| 367 | ): boolean | undefined => { | |
| 368 | if (explicitValue === true) { | |
| 369 | return true; | |
| 370 | } | |
| 371 | ||
| 372 | if (explicitValue === false) { | |
| 373 | return envValue; | |
| 374 | } | |
| 375 | ||
| 376 | return envValue; | |
| 377 | }; | |
| 378 | ||
| 353 | 379 | export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 354 | 380 | return Effect.gen(function* () { |
| 355 | 381 | const { portOffset, devInstance } = yield* OffsetConfig.asEffect().pipe( |
| ... | ... | @@ -371,6 +397,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 371 | 397 | }), |
| 372 | 398 | }); |
| 373 | 399 | |
| 400 | const envOverrides = { | |
| 401 | noBrowser: readOptionalBooleanEnv("T3CODE_NO_BROWSER"), | |
| 402 | autoBootstrapProjectFromCwd: readOptionalBooleanEnv( | |
| 403 | "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD", | |
| 404 | ), | |
| 405 | logWebSocketEvents: readOptionalBooleanEnv("T3CODE_LOG_WS_EVENTS"), | |
| 406 | }; | |
| 407 | ||
| 374 | 408 | const { serverOffset, webOffset } = yield* resolveModePortOffsets({ |
| 375 | 409 | mode: input.mode, |
| 376 | 410 | startOffset: offset, |
| ... | ... | @@ -385,9 +419,15 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 385 | 419 | webOffset, |
| 386 | 420 | stateDir: input.stateDir, |
| 387 | 421 | authToken: input.authToken, |
| 388 | noBrowser: input.noBrowser, | |
| 389 | autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, | |
| 390 | logWebSocketEvents: input.logWebSocketEvents, | |
| 422 | noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), | |
| 423 | autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( | |
| 424 | input.autoBootstrapProjectFromCwd, | |
| 425 | envOverrides.autoBootstrapProjectFromCwd, | |
| 426 | ), | |
| 427 | logWebSocketEvents: resolveOptionalBooleanOverride( | |
| 428 | input.logWebSocketEvents, | |
| 429 | envOverrides.logWebSocketEvents, | |
| 430 | ), | |
| 391 | 431 | host: input.host, |
| 392 | 432 | port: input.port, |
| 393 | 433 | devUrl: input.devUrl, |