authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-02 20:33:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-02 20:33:30-07:00
loge9ed849b53bc5644551aacb389f6c2094807371c
tree57478dad56ccd65df64747f7181687c6350d1c66
parente8f5b4ad5b28b63563fe3812998077ffb343aec8
signature Signed by PGP key B5690EEEBB952194

Persist server OTLP tracing settings across restarts (#1705)

Co-authored-by: codex <codex@users.noreply.github.com>

9 files changed, 272 insertions(+), 3 deletions(-)

apps/desktop/src/main.ts+34-1
...@@ -28,6 +28,7 @@ import { autoUpdater } from "electron-updater";...@@ -28,6 +28,7 @@ import { autoUpdater } from "electron-updater";
28import type { ContextMenuItem } from "@t3tools/contracts";28import type { ContextMenuItem } from "@t3tools/contracts";
29import { NetService } from "@t3tools/shared/Net";29import { NetService } from "@t3tools/shared/Net";
30import { RotatingFileSink } from "@t3tools/shared/logging";30import { RotatingFileSink } from "@t3tools/shared/logging";
31import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
31import { showDesktopConfirmDialog } from "./confirmDialog";32import { showDesktopConfirmDialog } from "./confirmDialog";
32import { syncShellEnvironment } from "./syncShellEnvironment";33import { syncShellEnvironment } from "./syncShellEnvironment";
33import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";34import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
...@@ -76,6 +77,7 @@ const LOG_DIR = Path.join(STATE_DIR, "logs");...@@ -76,6 +77,7 @@ const LOG_DIR = Path.join(STATE_DIR, "logs");
76const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;77const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
77const LOG_FILE_MAX_FILES = 10;78const LOG_FILE_MAX_FILES = 10;
78const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");79const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
80const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
79const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;81const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
80const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;82const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
81const DESKTOP_UPDATE_CHANNEL = "latest";83const DESKTOP_UPDATE_CHANNEL = "latest";
...@@ -99,8 +101,10 @@ let aboutCommitHashCache: string | null | undefined;...@@ -99,8 +101,10 @@ let aboutCommitHashCache: string | null | undefined;
99let desktopLogSink: RotatingFileSink | null = null;101let desktopLogSink: RotatingFileSink | null = null;
100let backendLogSink: RotatingFileSink | null = null;102let backendLogSink: RotatingFileSink | null = null;
101let restoreStdIoCapture: (() => void) | null = null;103let restoreStdIoCapture: (() => void) | null = null;
104let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
102105
103let destructiveMenuIconCache: Electron.NativeImage | null | undefined;106let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
107const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
104const desktopRuntimeInfo = resolveDesktopRuntimeInfo({108const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
105 platform: process.platform,109 platform: process.platform,
106 processArch: process.arch,110 processArch: process.arch,
...@@ -121,6 +125,21 @@ function sanitizeLogValue(value: string): string {...@@ -121,6 +125,21 @@ function sanitizeLogValue(value: string): string {
121 return value.replace(/\s+/g, " ").trim();125 return value.replace(/\s+/g, " ").trim();
122}126}
123127
128function readPersistedBackendObservabilitySettings(): {
129 readonly otlpTracesUrl: string | undefined;
130 readonly otlpMetricsUrl: string | undefined;
131} {
132 try {
133 if (!FS.existsSync(SERVER_SETTINGS_PATH)) {
134 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
135 }
136 return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8"));
137 } catch (error) {
138 console.warn("[desktop] failed to read persisted backend observability settings", error);
139 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
140 }
141}
142
124function backendChildEnv(): NodeJS.ProcessEnv {143function backendChildEnv(): NodeJS.ProcessEnv {
125 const env = { ...process.env };144 const env = { ...process.env };
126 delete env.T3CODE_PORT;145 delete env.T3CODE_PORT;
...@@ -988,6 +1007,7 @@ function scheduleBackendRestart(reason: string): void {...@@ -988,6 +1007,7 @@ function scheduleBackendRestart(reason: string): void {
988function startBackend(): void {1007function startBackend(): void {
989 if (isQuitting || backendProcess) return;1008 if (isQuitting || backendProcess) return;
9901009
1010 backendObservabilitySettings = readPersistedBackendObservabilitySettings();
991 const backendEntry = resolveBackendEntry();1011 const backendEntry = resolveBackendEntry();
992 if (!FS.existsSync(backendEntry)) {1012 if (!FS.existsSync(backendEntry)) {
993 scheduleBackendRestart(`missing server entry at ${backendEntry}`);1013 scheduleBackendRestart(`missing server entry at ${backendEntry}`);
...@@ -1016,6 +1036,12 @@ function startBackend(): void {...@@ -1016,6 +1036,12 @@ function startBackend(): void {
1016 port: backendPort,1036 port: backendPort,
1017 t3Home: BASE_DIR,1037 t3Home: BASE_DIR,
1018 authToken: backendAuthToken,1038 authToken: backendAuthToken,
1039 ...(backendObservabilitySettings.otlpTracesUrl
1040 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
1041 : {}),
1042 ...(backendObservabilitySettings.otlpMetricsUrl
1043 ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl }
1044 : {}),
1019 })}\n`,1045 })}\n`,
1020 );1046 );
1021 bootstrapStream.end();1047 bootstrapStream.end();
...@@ -1042,21 +1068,26 @@ function startBackend(): void {...@@ -1042,21 +1068,26 @@ function startBackend(): void {
1042 });1068 });
10431069
1044 child.on("error", (error) => {1070 child.on("error", (error) => {
1071 const wasExpected = expectedBackendExitChildren.has(child);
1045 if (backendProcess === child) {1072 if (backendProcess === child) {
1046 backendProcess = null;1073 backendProcess = null;
1047 }1074 }
1048 closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`);1075 closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`);
1076 if (wasExpected) {
1077 return;
1078 }
1049 scheduleBackendRestart(error.message);1079 scheduleBackendRestart(error.message);
1050 });1080 });
10511081
1052 child.on("exit", (code, signal) => {1082 child.on("exit", (code, signal) => {
1083 const wasExpected = expectedBackendExitChildren.has(child);
1053 if (backendProcess === child) {1084 if (backendProcess === child) {
1054 backendProcess = null;1085 backendProcess = null;
1055 }1086 }
1056 closeBackendSession(1087 closeBackendSession(
1057 `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,1088 `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
1058 );1089 );
1059 if (isQuitting) return;1090 if (isQuitting || wasExpected) return;
1060 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;1091 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
1061 scheduleBackendRestart(reason);1092 scheduleBackendRestart(reason);
1062 });1093 });
...@@ -1073,6 +1104,7 @@ function stopBackend(): void {...@@ -1073,6 +1104,7 @@ function stopBackend(): void {
1073 if (!child) return;1104 if (!child) return;
10741105
1075 if (child.exitCode === null && child.signalCode === null) {1106 if (child.exitCode === null && child.signalCode === null) {
1107 expectedBackendExitChildren.add(child);
1076 child.kill("SIGTERM");1108 child.kill("SIGTERM");
1077 setTimeout(() => {1109 setTimeout(() => {
1078 if (child.exitCode === null && child.signalCode === null) {1110 if (child.exitCode === null && child.signalCode === null) {
...@@ -1093,6 +1125,7 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {...@@ -1093,6 +1125,7 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
1093 if (!child) return;1125 if (!child) return;
1094 const backendChild = child;1126 const backendChild = child;
1095 if (backendChild.exitCode !== null || backendChild.signalCode !== null) return;1127 if (backendChild.exitCode !== null || backendChild.signalCode !== null) return;
1128 expectedBackendExitChildren.add(backendChild);
10961129
1097 await new Promise<void>((resolve) => {1130 await new Promise<void>((resolve) => {
1098 let settled = false;1131 let settled = false;
apps/server/src/cli-config.test.ts+67
...@@ -167,6 +167,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -167,6 +167,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
167 authToken: "bootstrap-token",167 authToken: "bootstrap-token",
168 autoBootstrapProjectFromCwd: false,168 autoBootstrapProjectFromCwd: false,
169 logWebSocketEvents: true,169 logWebSocketEvents: true,
170 otlpTracesUrl: "http://localhost:4318/v1/traces",
171 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
170 });172 });
171 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));173 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
172174
...@@ -202,6 +204,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -202,6 +204,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
202 expect(resolved).toEqual({204 expect(resolved).toEqual({
203 logLevel: "Info",205 logLevel: "Info",
204 ...defaultObservabilityConfig,206 ...defaultObservabilityConfig,
207 otlpTracesUrl: "http://localhost:4318/v1/traces",
208 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
205 mode: "desktop",209 mode: "desktop",
206 port: 4888,210 port: 4888,
207 cwd: process.cwd(),211 cwd: process.cwd(),
...@@ -332,4 +336,67 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -332,4 +336,67 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
332 });336 });
333 }),337 }),
334 );338 );
339
340 it.effect("falls back to persisted observability settings when env vars are absent", () =>
341 Effect.gen(function* () {
342 const fs = yield* FileSystem.FileSystem;
343 const path = yield* Path.Path;
344 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" });
345 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
346 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
347 yield* fs.writeFileString(
348 derivedPaths.settingsPath,
349 `${JSON.stringify({
350 observability: {
351 otlpTracesUrl: "http://localhost:4318/v1/traces",
352 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
353 },
354 })}\n`,
355 );
356
357 const resolved = yield* resolveServerConfig(
358 {
359 mode: Option.some("desktop"),
360 port: Option.some(4888),
361 host: Option.none(),
362 baseDir: Option.some(baseDir),
363 devUrl: Option.none(),
364 noBrowser: Option.none(),
365 authToken: Option.none(),
366 bootstrapFd: Option.none(),
367 autoBootstrapProjectFromCwd: Option.none(),
368 logWebSocketEvents: Option.none(),
369 },
370 Option.none(),
371 ).pipe(
372 Effect.provide(
373 Layer.mergeAll(
374 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
375 NetService.layer,
376 ),
377 ),
378 );
379
380 expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
381 expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics");
382 expect(resolved).toEqual({
383 logLevel: "Info",
384 ...defaultObservabilityConfig,
385 otlpTracesUrl: "http://localhost:4318/v1/traces",
386 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
387 mode: "desktop",
388 port: 4888,
389 cwd: process.cwd(),
390 baseDir,
391 ...derivedPaths,
392 host: "127.0.0.1",
393 staticDir: resolved.staticDir,
394 devUrl: undefined,
395 noBrowser: true,
396 authToken: undefined,
397 autoBootstrapProjectFromCwd: false,
398 logWebSocketEvents: false,
399 });
400 }),
401 );
335});402});
apps/server/src/cli.ts+33-2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { NetService } from "@t3tools/shared/Net";1import { NetService } from "@t3tools/shared/Net";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
2import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect";3import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect";
3import { Command, Flag, GlobalFlag } from "effect/unstable/cli";4import { Command, Flag, GlobalFlag } from "effect/unstable/cli";
45
...@@ -27,6 +28,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({...@@ -27,6 +28,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({
27 authToken: Schema.optional(Schema.String),28 authToken: Schema.optional(Schema.String),
28 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),29 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
29 logWebSocketEvents: Schema.optional(Schema.Boolean),30 logWebSocketEvents: Schema.optional(Schema.Boolean),
31 otlpTracesUrl: Schema.optional(Schema.String),
32 otlpMetricsUrl: Schema.optional(Schema.String),
30});33});
3134
32const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(35const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
...@@ -152,6 +155,17 @@ const resolveOptionPrecedence = <Value>(...@@ -152,6 +155,17 @@ const resolveOptionPrecedence = <Value>(
152 ...values: ReadonlyArray<Option.Option<Value>>155 ...values: ReadonlyArray<Option.Option<Value>>
153): Option.Option<Value> => Option.firstSomeOf(values);156): Option.Option<Value> => Option.firstSomeOf(values);
154157
158const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) {
159 const fs = yield* FileSystem.FileSystem;
160 const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
161 if (!exists) {
162 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
163 }
164
165 const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => ""));
166 return parsePersistedServerObservabilitySettings(raw);
167});
168
155export const resolveServerConfig = (169export const resolveServerConfig = (
156 flags: CliServerFlags,170 flags: CliServerFlags,
157 cliLogLevel: Option.Option<LogLevel.LogLevel>,171 cliLogLevel: Option.Option<LogLevel.LogLevel>,
...@@ -213,6 +227,9 @@ export const resolveServerConfig = (...@@ -213,6 +227,9 @@ export const resolveServerConfig = (
213 );227 );
214 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);228 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
215 yield* ensureServerDirectories(derivedPaths);229 yield* ensureServerDirectories(derivedPaths);
230 const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(
231 derivedPaths.settingsPath,
232 );
216 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;233 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
217 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });234 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
218 const noBrowser = resolveBooleanFlag(235 const noBrowser = resolveBooleanFlag(
...@@ -278,8 +295,22 @@ export const resolveServerConfig = (...@@ -278,8 +295,22 @@ export const resolveServerConfig = (
278 traceBatchWindowMs: env.traceBatchWindowMs,295 traceBatchWindowMs: env.traceBatchWindowMs,
279 traceMaxBytes: env.traceMaxBytes,296 traceMaxBytes: env.traceMaxBytes,
280 traceMaxFiles: env.traceMaxFiles,297 traceMaxFiles: env.traceMaxFiles,
281 otlpTracesUrl: env.otlpTracesUrl,298 otlpTracesUrl:
282 otlpMetricsUrl: env.otlpMetricsUrl,299 env.otlpTracesUrl ??
300 Option.getOrUndefined(
301 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
302 Option.fromUndefinedOr(bootstrap.otlpTracesUrl),
303 ),
304 ) ??
305 persistedObservabilitySettings.otlpTracesUrl,
306 otlpMetricsUrl:
307 env.otlpMetricsUrl ??
308 Option.getOrUndefined(
309 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
310 Option.fromUndefinedOr(bootstrap.otlpMetricsUrl),
311 ),
312 ) ??
313 persistedObservabilitySettings.otlpMetricsUrl,
283 otlpExportIntervalMs: env.otlpExportIntervalMs,314 otlpExportIntervalMs: env.otlpExportIntervalMs,
284 otlpServiceName: env.otlpServiceName,315 otlpServiceName: env.otlpServiceName,
285 mode,316 mode,
apps/server/src/serverSettings.test.ts+26
...@@ -171,6 +171,24 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -171,6 +171,24 @@ it.layer(NodeServices.layer)("server settings", (it) => {
171 }).pipe(Effect.provide(makeServerSettingsLayer())),171 }).pipe(Effect.provide(makeServerSettingsLayer())),
172 );172 );
173173
174 it.effect("trims observability settings when updates are applied", () =>
175 Effect.gen(function* () {
176 const serverSettings = yield* ServerSettingsService;
177
178 const next = yield* serverSettings.updateSettings({
179 observability: {
180 otlpTracesUrl: " http://localhost:4318/v1/traces ",
181 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
182 },
183 });
184
185 assert.deepEqual(next.observability, {
186 otlpTracesUrl: "http://localhost:4318/v1/traces",
187 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
188 });
189 }).pipe(Effect.provide(makeServerSettingsLayer())),
190 );
191
174 it.effect("defaults blank binary paths to provider executables", () =>192 it.effect("defaults blank binary paths to provider executables", () =>
175 Effect.gen(function* () {193 Effect.gen(function* () {
176 const serverSettings = yield* ServerSettingsService;194 const serverSettings = yield* ServerSettingsService;
...@@ -197,6 +215,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -197,6 +215,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {
197 const serverConfig = yield* ServerConfig;215 const serverConfig = yield* ServerConfig;
198 const fileSystem = yield* FileSystem.FileSystem;216 const fileSystem = yield* FileSystem.FileSystem;
199 const next = yield* serverSettings.updateSettings({217 const next = yield* serverSettings.updateSettings({
218 observability: {
219 otlpTracesUrl: "http://localhost:4318/v1/traces",
220 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
221 },
200 providers: {222 providers: {
201 codex: {223 codex: {
202 binaryPath: "/opt/homebrew/bin/codex",224 binaryPath: "/opt/homebrew/bin/codex",
...@@ -208,6 +230,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -208,6 +230,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {
208230
209 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);231 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
210 assert.deepEqual(JSON.parse(raw), {232 assert.deepEqual(JSON.parse(raw), {
233 observability: {
234 otlpTracesUrl: "http://localhost:4318/v1/traces",
235 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
236 },
211 providers: {237 providers: {
212 codex: {238 codex: {
213 binaryPath: "/opt/homebrew/bin/codex",239 binaryPath: "/opt/homebrew/bin/codex",
apps/web/src/components/KeybindingsToast.browser.tsx+2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import "../index.css";1import "../index.css";
22
3import {3import {
4 DEFAULT_SERVER_SETTINGS,
4 ORCHESTRATION_WS_METHODS,5 ORCHESTRATION_WS_METHODS,
5 type MessageId,6 type MessageId,
6 type OrchestrationReadModel,7 type OrchestrationReadModel,
...@@ -63,6 +64,7 @@ function createBaseServerConfig(): ServerConfig {...@@ -63,6 +64,7 @@ function createBaseServerConfig(): ServerConfig {
63 otlpMetricsEnabled: false,64 otlpMetricsEnabled: false,
64 },65 },
65 settings: {66 settings: {
67 ...DEFAULT_SERVER_SETTINGS,
66 enableAssistantStreaming: false,68 enableAssistantStreaming: false,
67 defaultThreadEnvMode: "local" as const,69 defaultThreadEnvMode: "local" as const,
68 textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" },70 textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" },
packages/contracts/src/settings.ts+13
...@@ -71,6 +71,12 @@ export const ClaudeSettings = Schema.Struct({...@@ -71,6 +71,12 @@ export const ClaudeSettings = Schema.Struct({
71});71});
72export type ClaudeSettings = typeof ClaudeSettings.Type;72export type ClaudeSettings = typeof ClaudeSettings.Type;
7373
74export const ObservabilitySettings = Schema.Struct({
75 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")),
76 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")),
77});
78export type ObservabilitySettings = typeof ObservabilitySettings.Type;
79
74export const ServerSettings = Schema.Struct({80export const ServerSettings = Schema.Struct({
75 enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),81 enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
76 defaultThreadEnvMode: ThreadEnvMode.pipe(82 defaultThreadEnvMode: ThreadEnvMode.pipe(
...@@ -88,6 +94,7 @@ export const ServerSettings = Schema.Struct({...@@ -88,6 +94,7 @@ export const ServerSettings = Schema.Struct({
88 codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))),94 codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))),
89 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))),95 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))),
90 }).pipe(Schema.withDecodingDefault(() => ({}))),96 }).pipe(Schema.withDecodingDefault(() => ({}))),
97 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))),
91});98});
92export type ServerSettings = typeof ServerSettings.Type;99export type ServerSettings = typeof ServerSettings.Type;
93100
...@@ -158,6 +165,12 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -158,6 +165,12 @@ export const ServerSettingsPatch = Schema.Struct({
158 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),165 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
159 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),166 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
160 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),167 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
168 observability: Schema.optionalKey(
169 Schema.Struct({
170 otlpTracesUrl: Schema.optionalKey(Schema.String),
171 otlpMetricsUrl: Schema.optionalKey(Schema.String),
172 }),
173 ),
161 providers: Schema.optionalKey(174 providers: Schema.optionalKey(
162 Schema.Struct({175 Schema.Struct({
163 codex: Schema.optionalKey(CodexSettingsPatch),176 codex: Schema.optionalKey(CodexSettingsPatch),
packages/shared/package.json+4
...@@ -40,6 +40,10 @@...@@ -40,6 +40,10 @@
40 "types": "./src/Struct.ts",40 "types": "./src/Struct.ts",
41 "import": "./src/Struct.ts"41 "import": "./src/Struct.ts"
42 },42 },
43 "./serverSettings": {
44 "types": "./src/serverSettings.ts",
45 "import": "./src/serverSettings.ts"
46 },
43 "./String": {47 "./String": {
44 "types": "./src/String.ts",48 "types": "./src/String.ts",
45 "import": "./src/String.ts"49 "import": "./src/String.ts"
packages/shared/src/serverSettings.test.ts created+53
...@@ -0,0 +1,53 @@
1import { describe, expect, it } from "vitest";
2import {
3 extractPersistedServerObservabilitySettings,
4 normalizePersistedServerSettingString,
5 parsePersistedServerObservabilitySettings,
6} from "./serverSettings";
7
8describe("serverSettings helpers", () => {
9 it("normalizes optional persisted strings", () => {
10 expect(normalizePersistedServerSettingString(undefined)).toBeUndefined();
11 expect(normalizePersistedServerSettingString(" ")).toBeUndefined();
12 expect(normalizePersistedServerSettingString(" http://localhost:4318/v1/traces ")).toBe(
13 "http://localhost:4318/v1/traces",
14 );
15 });
16
17 it("extracts persisted observability settings", () => {
18 expect(
19 extractPersistedServerObservabilitySettings({
20 observability: {
21 otlpTracesUrl: " http://localhost:4318/v1/traces ",
22 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
23 },
24 }),
25 ).toEqual({
26 otlpTracesUrl: "http://localhost:4318/v1/traces",
27 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
28 });
29 });
30
31 it("parses lenient persisted settings JSON", () => {
32 expect(
33 parsePersistedServerObservabilitySettings(
34 JSON.stringify({
35 observability: {
36 otlpTracesUrl: "http://localhost:4318/v1/traces",
37 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
38 },
39 }),
40 ),
41 ).toEqual({
42 otlpTracesUrl: "http://localhost:4318/v1/traces",
43 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
44 });
45 });
46
47 it("falls back cleanly when persisted settings are invalid", () => {
48 expect(parsePersistedServerObservabilitySettings("{")).toEqual({
49 otlpTracesUrl: undefined,
50 otlpMetricsUrl: undefined,
51 });
52 });
53});
packages/shared/src/serverSettings.ts created+40
...@@ -0,0 +1,40 @@
1import { ServerSettings } from "@t3tools/contracts";
2import { Schema } from "effect";
3import { fromLenientJson } from "./schemaJson";
4
5const ServerSettingsJson = fromLenientJson(ServerSettings);
6
7export interface PersistedServerObservabilitySettings {
8 readonly otlpTracesUrl: string | undefined;
9 readonly otlpMetricsUrl: string | undefined;
10}
11
12export function normalizePersistedServerSettingString(
13 value: string | null | undefined,
14): string | undefined {
15 const trimmed = value?.trim();
16 return trimmed && trimmed.length > 0 ? trimmed : undefined;
17}
18
19export function extractPersistedServerObservabilitySettings(input: {
20 readonly observability?: {
21 readonly otlpTracesUrl?: string;
22 readonly otlpMetricsUrl?: string;
23 };
24}): PersistedServerObservabilitySettings {
25 return {
26 otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl),
27 otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl),
28 };
29}
30
31export function parsePersistedServerObservabilitySettings(
32 raw: string,
33): PersistedServerObservabilitySettings {
34 try {
35 const decoded = Schema.decodeUnknownSync(ServerSettingsJson)(raw);
36 return extractPersistedServerObservabilitySettings(decoded);
37 } catch {
38 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
39 }
40}