Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { autoUpdater } from "electron-updater";
import type { ContextMenuItem } from "@t3tools/contracts";
import { NetService } from "@t3tools/shared/Net";
import { RotatingFileSink } from "@t3tools/shared/logging";
import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
import { showDesktopConfirmDialog } from "./confirmDialog";
import { syncShellEnvironment } from "./syncShellEnvironment";
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
Expand Down Expand Up @@ -76,6 +77,7 @@ const LOG_DIR = Path.join(STATE_DIR, "logs");
const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
const LOG_FILE_MAX_FILES = 10;
const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
const DESKTOP_UPDATE_CHANNEL = "latest";
Expand All @@ -99,8 +101,10 @@ let aboutCommitHashCache: string | null | undefined;
let desktopLogSink: RotatingFileSink | null = null;
let backendLogSink: RotatingFileSink | null = null;
let restoreStdIoCapture: (() => void) | null = null;
let backendObservabilitySettings = readPersistedBackendObservabilitySettings();

let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
platform: process.platform,
processArch: process.arch,
Expand All @@ -121,6 +125,21 @@ function sanitizeLogValue(value: string): string {
return value.replace(/\s+/g, " ").trim();
}

function readPersistedBackendObservabilitySettings(): {
readonly otlpTracesUrl: string | undefined;
readonly otlpMetricsUrl: string | undefined;
} {
try {
if (!FS.existsSync(SERVER_SETTINGS_PATH)) {
return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
}
return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8"));
} catch (error) {
console.warn("[desktop] failed to read persisted backend observability settings", error);
return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
}
}

function backendChildEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.T3CODE_PORT;
Expand Down Expand Up @@ -988,6 +1007,7 @@ function scheduleBackendRestart(reason: string): void {
function startBackend(): void {
if (isQuitting || backendProcess) return;

backendObservabilitySettings = readPersistedBackendObservabilitySettings();
const backendEntry = resolveBackendEntry();
if (!FS.existsSync(backendEntry)) {
scheduleBackendRestart(`missing server entry at ${backendEntry}`);
Expand Down Expand Up @@ -1016,6 +1036,12 @@ function startBackend(): void {
port: backendPort,
t3Home: BASE_DIR,
authToken: backendAuthToken,
...(backendObservabilitySettings.otlpTracesUrl
? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
: {}),
...(backendObservabilitySettings.otlpMetricsUrl
? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl }
: {}),
})}\n`,
);
bootstrapStream.end();
Expand All @@ -1042,21 +1068,26 @@ function startBackend(): void {
});

child.on("error", (error) => {
const wasExpected = expectedBackendExitChildren.has(child);
if (backendProcess === child) {
backendProcess = null;
}
closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`);
if (wasExpected) {
return;
}
scheduleBackendRestart(error.message);
});

child.on("exit", (code, signal) => {
const wasExpected = expectedBackendExitChildren.has(child);
if (backendProcess === child) {
backendProcess = null;
}
closeBackendSession(
`pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
);
if (isQuitting) return;
if (isQuitting || wasExpected) return;
const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
scheduleBackendRestart(reason);
});
Expand All @@ -1073,6 +1104,7 @@ function stopBackend(): void {
if (!child) return;

if (child.exitCode === null && child.signalCode === null) {
expectedBackendExitChildren.add(child);
child.kill("SIGTERM");
setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
Expand All @@ -1093,6 +1125,7 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
if (!child) return;
const backendChild = child;
if (backendChild.exitCode !== null || backendChild.signalCode !== null) return;
expectedBackendExitChildren.add(backendChild);

await new Promise<void>((resolve) => {
let settled = false;
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/cli-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
authToken: "bootstrap-token",
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: true,
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
});
const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));

Expand Down Expand Up @@ -202,6 +204,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved).toEqual({
logLevel: "Info",
...defaultObservabilityConfig,
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
mode: "desktop",
port: 4888,
cwd: process.cwd(),
Expand Down Expand Up @@ -332,4 +336,67 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
});
}),
);

it.effect("falls back to persisted observability settings when env vars are absent", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" });
const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
yield* fs.writeFileString(
derivedPaths.settingsPath,
`${JSON.stringify({
observability: {
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
},
})}\n`,
);

const resolved = yield* resolveServerConfig(
{
mode: Option.some("desktop"),
port: Option.some(4888),
host: Option.none(),
baseDir: Option.some(baseDir),
devUrl: Option.none(),
noBrowser: Option.none(),
authToken: Option.none(),
bootstrapFd: Option.none(),
autoBootstrapProjectFromCwd: Option.none(),
logWebSocketEvents: Option.none(),
},
Option.none(),
).pipe(
Effect.provide(
Layer.mergeAll(
ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
NetService.layer,
),
),
);

expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics");
expect(resolved).toEqual({
logLevel: "Info",
...defaultObservabilityConfig,
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
mode: "desktop",
port: 4888,
cwd: process.cwd(),
baseDir,
...derivedPaths,
host: "127.0.0.1",
staticDir: resolved.staticDir,
devUrl: undefined,
noBrowser: true,
authToken: undefined,
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: false,
});
}),
);
});
35 changes: 33 additions & 2 deletions apps/server/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NetService } from "@t3tools/shared/Net";
import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect";
import { Command, Flag, GlobalFlag } from "effect/unstable/cli";

Expand Down Expand Up @@ -27,6 +28,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({
authToken: Schema.optional(Schema.String),
autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
logWebSocketEvents: Schema.optional(Schema.Boolean),
otlpTracesUrl: Schema.optional(Schema.String),
otlpMetricsUrl: Schema.optional(Schema.String),
});

const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
Expand Down Expand Up @@ -152,6 +155,17 @@ const resolveOptionPrecedence = <Value>(
...values: ReadonlyArray<Option.Option<Value>>
): Option.Option<Value> => Option.firstSomeOf(values);

const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) {
const fs = yield* FileSystem.FileSystem;
const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
if (!exists) {
return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
}

const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => ""));
return parsePersistedServerObservabilitySettings(raw);
});

export const resolveServerConfig = (
flags: CliServerFlags,
cliLogLevel: Option.Option<LogLevel.LogLevel>,
Expand Down Expand Up @@ -213,6 +227,9 @@ export const resolveServerConfig = (
);
const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
yield* ensureServerDirectories(derivedPaths);
const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(
derivedPaths.settingsPath,
);
const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
const noBrowser = resolveBooleanFlag(
Expand Down Expand Up @@ -278,8 +295,22 @@ export const resolveServerConfig = (
traceBatchWindowMs: env.traceBatchWindowMs,
traceMaxBytes: env.traceMaxBytes,
traceMaxFiles: env.traceMaxFiles,
otlpTracesUrl: env.otlpTracesUrl,
otlpMetricsUrl: env.otlpMetricsUrl,
otlpTracesUrl:
env.otlpTracesUrl ??
Option.getOrUndefined(
Option.flatMap(bootstrapEnvelope, (bootstrap) =>
Option.fromUndefinedOr(bootstrap.otlpTracesUrl),
),
) ??
persistedObservabilitySettings.otlpTracesUrl,
otlpMetricsUrl:
env.otlpMetricsUrl ??
Option.getOrUndefined(
Option.flatMap(bootstrapEnvelope, (bootstrap) =>
Option.fromUndefinedOr(bootstrap.otlpMetricsUrl),
),
) ??
persistedObservabilitySettings.otlpMetricsUrl,
otlpExportIntervalMs: env.otlpExportIntervalMs,
otlpServiceName: env.otlpServiceName,
mode,
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,24 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("trims observability settings when updates are applied", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsService;

const next = yield* serverSettings.updateSettings({
observability: {
otlpTracesUrl: " http://localhost:4318/v1/traces ",
otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
},
});

assert.deepEqual(next.observability, {
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
});
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("defaults blank binary paths to provider executables", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsService;
Expand All @@ -197,6 +215,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {
const serverConfig = yield* ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const next = yield* serverSettings.updateSettings({
observability: {
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
},
providers: {
codex: {
binaryPath: "/opt/homebrew/bin/codex",
Expand All @@ -208,6 +230,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {

const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
assert.deepEqual(JSON.parse(raw), {
observability: {
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
},
providers: {
codex: {
binaryPath: "/opt/homebrew/bin/codex",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/KeybindingsToast.browser.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "../index.css";

import {
DEFAULT_SERVER_SETTINGS,
ORCHESTRATION_WS_METHODS,
type MessageId,
type OrchestrationReadModel,
Expand Down Expand Up @@ -63,6 +64,7 @@ function createBaseServerConfig(): ServerConfig {
otlpMetricsEnabled: false,
},
settings: {
...DEFAULT_SERVER_SETTINGS,
enableAssistantStreaming: false,
defaultThreadEnvMode: "local" as const,
textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" },
Expand Down
13 changes: 13 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ export const ClaudeSettings = Schema.Struct({
});
export type ClaudeSettings = typeof ClaudeSettings.Type;

export const ObservabilitySettings = Schema.Struct({
otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")),
otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")),
});
export type ObservabilitySettings = typeof ObservabilitySettings.Type;

export const ServerSettings = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
defaultThreadEnvMode: ThreadEnvMode.pipe(
Expand All @@ -88,6 +94,7 @@ export const ServerSettings = Schema.Struct({
codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))),
claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))),
}).pipe(Schema.withDecodingDefault(() => ({}))),
observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))),
});
export type ServerSettings = typeof ServerSettings.Type;

Expand Down Expand Up @@ -158,6 +165,12 @@ export const ServerSettingsPatch = Schema.Struct({
enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
observability: Schema.optionalKey(
Schema.Struct({
otlpTracesUrl: Schema.optionalKey(Schema.String),
otlpMetricsUrl: Schema.optionalKey(Schema.String),
}),
),
providers: Schema.optionalKey(
Schema.Struct({
codex: Schema.optionalKey(CodexSettingsPatch),
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
"types": "./src/Struct.ts",
"import": "./src/Struct.ts"
},
"./serverSettings": {
"types": "./src/serverSettings.ts",
"import": "./src/serverSettings.ts"
},
"./String": {
"types": "./src/String.ts",
"import": "./src/String.ts"
Expand Down
Loading
Loading