diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index eedfff9744f8..48f66ab76b56 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -116,6 +116,8 @@ export function makeDevelopmentEnvironmentScript(environment) { ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH], ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL], ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS], + ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS], + ["T3CODE_OTLP_PROTOCOL", environment.T3CODE_OTLP_PROTOCOL], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); return [ diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 50f5e5f0ac84..84d08cd9b6b8 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -21,12 +21,17 @@ describe("electron development launcher", () => { VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", T3CODE_PORT: "16566", T3CODE_HOME: "/tmp/t3", + T3CODE_OTLP_PROTOCOL: "http/protobuf", }); assert.include( environmentScript, "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi", ); + assert.include( + environmentScript, + "if [ -z \"${T3CODE_OTLP_PROTOCOL:-}\" ]; then export T3CODE_OTLP_PROTOCOL='http/protobuf'; fi", + ); assert.notInclude(environmentScript, "\nexport VITE_DEV_SERVER_URL="); }); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index d157a4c6ba44..924b8ce6e72b 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -1,3 +1,4 @@ +import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Option from "effect/Option"; @@ -48,6 +49,10 @@ export const DesktopConfig = Config.all({ otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), + otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option), + otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( + Config.withDefault("http/json"), + ), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 1ebd5dae56c2..0e5fbecd0224 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -52,6 +52,8 @@ describe("DesktopEnvironment", () => { T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", + T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", + T3CODE_OTLP_PROTOCOL: "http/protobuf", }, ); @@ -85,6 +87,14 @@ describe("DesktopEnvironment", () => { assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); assert.equal(environment.otlpExportIntervalMs, 2500); + assert.deepEqual( + environment.otlpHeaders, + Option.some({ + authorization: "Basic abc==", + "x-tenant": "t3", + }), + ); + assert.equal(environment.otlpProtocol, "http/protobuf"); }), ); @@ -102,6 +112,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + assert.equal(environment.otlpProtocol, "http/json"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index e604cb767f3f..e7a489d5e89d 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -16,6 +16,7 @@ import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; +import type { OtlpProtocol } from "@t3tools/shared/observability"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; @@ -72,6 +73,8 @@ export class DesktopEnvironment extends Context.Service< readonly commitHashOverride: Option.Option; readonly otlpTracesUrl: Option.Option; readonly otlpExportIntervalMs: number; + readonly otlpHeaders: Option.Option>; + readonly otlpProtocol: OtlpProtocol; readonly branding: DesktopAppBranding; readonly displayName: string; readonly appUserModelId: string; @@ -225,6 +228,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( commitHashOverride: config.commitHashOverride, otlpTracesUrl: config.otlpTracesUrl, otlpExportIntervalMs: config.otlpExportIntervalMs, + otlpHeaders: config.otlpHeaders, + otlpProtocol: config.otlpProtocol, branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index d2ff0b4e2ad5..19ed351dffb7 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,5 +1,9 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { + makeLocalFileTracer, + makeTraceSink, + otlpSerializationLayer, +} from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -17,7 +21,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; -import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import { OtlpExporter, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -584,6 +588,7 @@ const tracerLayer = Layer.unwrap( : yield* OtlpTracer.make({ url: otlpTracesUrl.value, exportInterval: `${environment.otlpExportIntervalMs} millis`, + headers: Option.getOrUndefined(environment.otlpHeaders), resource: { serviceName: "desktop", attributes: { @@ -591,7 +596,7 @@ const tracerLayer = Layer.unwrap( "service.mode": environment.isDevelopment ? "development" : "packaged", }, }, - }); + }).pipe(Effect.provide(otlpSerializationLayer(environment.otlpProtocol))); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, @@ -603,7 +608,7 @@ const tracerLayer = Layer.unwrap( return Layer.succeed(Tracer.Tracer, tracer); }), -).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(OtlpSerialization.layerJson)); +).pipe(Layer.provide(OtlpExporter.layerFlusher)); export const layer = Layer.mergeAll( backendOutputLogFactoryLayer, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 642a1bc82f42..595582a16280 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -399,10 +399,10 @@ describe("DesktopBackendConfiguration", () => { observedProbeRoots.push(root); return { ok: true, resolvedPath }; }, - // The staged runtime carries its own Node, so the preflight must not - // go looking for one in the distro. + // The staged runtime carries its own Node and node-pty, so it must + // not require the mounted server tree's native dependency check. ensureNodePty: () => { - throw new Error("the staged runtime must not probe for Node"); + throw new Error("the staged runtime must not probe for node-pty"); }, }), }, @@ -853,10 +853,14 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousOpenAiKey = process.env.OPENAI_API_KEY; const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; + const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; + const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; try { process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; + process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"'; + process.env.T3CODE_OTLP_PROTOCOL = "http/protobuf"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -876,13 +880,14 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); + assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); // The existing WSLENV is preserved byte-for-byte (note the empty // "::" segment survives — WSL ignores it, so we don't normalize // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is // already declared, so it isn't forwarded twice. assert.equal( config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY", + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", ); }).pipe( Effect.provide( @@ -905,6 +910,8 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OPENAI_API_KEY", previousOpenAiKey); restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); + restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); + restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 3486daebf79e..a36902304809 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -87,11 +87,16 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; -// Sensitive env vars that the WSL backend needs but Windows process.env won't -// forward across the wsl.exe boundary without WSLENV. The dev-server URL is -// handled separately via a `--dev-url` CLI flag because WSLENV translation of +// Env vars that the WSL backend needs but Windows process.env won't forward +// across the wsl.exe boundary without WSLENV. The dev-server URL is handled +// separately via a `--dev-url` CLI flag because WSLENV translation of // URL-shaped values (colons / slashes) is unreliable. -const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; +const WSL_FORWARDED_ENV_NAMES = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "T3CODE_OTLP_HEADERS", + "T3CODE_OTLP_PROTOCOL", +] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; @@ -384,8 +389,8 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - // The staged runtime is self-contained, so the only question is whether - // it runs here; there is no Node to find or node-pty to load. + // The staged runtime supplies its own Node and node-pty. Provider PATH + // discovery must not require either dependency for runtime readiness. const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 51584e3c796d..c35b52e8343d 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -711,6 +711,7 @@ export const make = Effect.gen(function* () { const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( info.releaseNotes, info.version, + state.channel, ); yield* setState( reduceDesktopUpdateStateOnUpdateAvailable( diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 3ba2444dc185..57d185c0975e 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -21,6 +21,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { "**Full Changelog**: https://github.com/pingdotgg/t3code/compare/old...new", ].join("\n"), "0.0.36-nightly.20260828.1213", + "nightly", ); expect(result).toEqual({ @@ -50,6 +51,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { "

New Contributors

  • @human made their first contribution
" + "

Full Changelog

", "1.2.3", + "latest", ); expect(result).toEqual({ @@ -69,6 +71,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }, ], "1.2.4", + "latest", ); expect(result.releaseNotes).toEqual([ @@ -85,6 +88,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { { version: "1.2.1", note: "- Older release" }, ], "1.2.3", + "latest", ); expect(result).toEqual({ @@ -96,6 +100,42 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); + it("drops releases from other trains before grouping on nightly", () => { + // electron-updater's full changelog is "every version above the running + // one", and preview sorts above nightly, so the preview cuts come first. + const releaseNotes = [ + { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, + { version: "0.0.41-preview.20260913.1669", note: "- Maintainer test build" }, + { version: "0.0.41-nightly.20260914.1707", note: "- Nightly change 2" }, + { version: "0.0.41-nightly.20260914.1700", note: "- Nightly change 1" }, + ]; + + const result = normalizeDesktopUpdateReleaseNotes( + releaseNotes, + "0.0.41-nightly.20260914.1707", + "nightly", + ); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual([ + "0.0.41-nightly.20260914.1707", + "0.0.41-nightly.20260914.1700", + ]); + expect(result.omittedReleaseCount).toBe(0); + }); + + it("keeps only stable releases on the latest channel", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "0.0.42", note: "- Stable change" }, + { version: "0.0.42-nightly.20260915.1710", note: "- Nightly change" }, + ], + "0.0.42", + "latest", + ); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual(["0.0.42"]); + }); + it("counts valid groups before applying the six-release limit", () => { const releaseNotes = [ { version: "1.3.9", note: "- Change 9" }, @@ -108,7 +148,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { { version: "1.3.2", note: "- Change 2" }, ]; - const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9"); + const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9", "latest"); expect(result.releaseNotes.map(({ version }) => version)).toEqual([ "1.3.9", @@ -122,7 +162,11 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("decodes valid HTML entities", () => { - const result = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + const result = normalizeDesktopUpdateReleaseNotes( + "- Fix & polish 😀", + "1.0.0", + "latest", + ); expect(result).toEqual({ releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], omittedReleaseCount: 0, @@ -140,6 +184,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { null, ], "1.2.3", + "latest", ); expect(result).toEqual({ @@ -149,14 +194,18 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("returns an empty result for an invalid payload", () => { - expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({ + expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0", "latest")).toEqual({ releaseNotes: [], omittedReleaseCount: 0, }); }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - const result = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + const result = normalizeDesktopUpdateReleaseNotes( + "- Broken entity �", + "1.0.0", + "latest", + ); expect(result).toEqual({ releaseNotes: [{ version: "1.0.0", items: ["Broken entity �"], totalItems: 1 }], omittedReleaseCount: 0, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 3b2f32e646a1..3cab5f15e451 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,4 +1,6 @@ -import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; +import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -122,16 +124,27 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } +/** + * Turns electron-updater's release notes into the groups the popover shows. + * With `fullChangelog` on (nightly), electron-updater collects every GitHub + * release whose version is semver-greater than the running one, whatever + * train it belongs to; a maintainers' `-preview.` cut sorts above every + * `-nightly.` of the same base version and would lead the list. Only + * releases on the channel being followed are kept, the same test the + * updater applies to the offered version itself. + */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, + channel: DesktopUpdateChannel, ): NormalizedDesktopUpdateReleaseNotes { - const rawNotes = + const rawNotes = ( typeof releaseNotes === "string" ? [{ version: fallbackVersion, note: releaseNotes }] : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) - : []; + : [] + ).filter((entry) => resolveDefaultDesktopUpdateChannel(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { const { items, totalItems } = extractReleaseNoteItems(entry.note); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 366bdfce9946..e28a9c6c7800 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -15,6 +15,7 @@ import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, + buildWslRuntimeProbeScript, DesktopWslDistroListError, formatMissingToolsReason, parseNodePath, @@ -446,6 +447,75 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed }; }; + const probeFixture = (fixture: ReturnType) => + runShell( + [ + `export HOME=${sh(`${fixture.work}/home`)}`, + 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"', + // Isolate login profiles and hide the host's Node/version managers. + // The resolver must discover the fixture's installation itself. + "bash() { (", + " command() {", + ' case "$*" in', + ' "-v node"|"-v mise"|"-v fnm"|"-v nodenv") return 1 ;;', + ' *) builtin command "$@" ;;', + " esac", + " }", + ' eval "$2"', + "); }", + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + it("discovers version-managed Node for providers with a standalone runtime", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const nodeBin = `${fixture.work}/home/.nvm/versions/node/v24.15.0/bin`; + const setup = runShell( + [ + "set -eu", + `mkdir -p ${sh(nodeBin)}`, + `printf '%s' ${sh('#!/bin/sh\nprintf "linux-node-provider\\n"\n')} > ${sh(`${nodeBin}/node`)}`, + `chmod +x ${sh(`${nodeBin}/node`)}`, + ].join("\n"), + ); + expect(setup.status, setup.stderr).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + const resolvedPath = parseResolvedPath(probe.stdout); + expect(resolvedPath?.split(":")).toContain(nodeBin); + const provider = runShell(`export PATH=${sh(resolvedPath ?? "")}\nnode provider.js`); + expect(provider.status, provider.stderr).toBe(0); + expect(provider.stdout).toBe("linux-node-provider\n"); + }); + + it("keeps standalone runtime readiness independent of Node availability", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).not.toBeNull(); + }); + + it("keeps the inherited PATH when bash is unavailable", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const probe = runShell( + [ + "bash() { return 127; }", + 'export PATH="/fixture/bin:/usr/bin:/bin"', + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).toBe("/fixture/bin:/usr/bin:/bin"); + }); + it("reuses a warm cache without touching the archive", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index ebd4f853da60..95b217c622b1 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -122,8 +122,8 @@ export class DesktopWslEnvironment extends Context.Service< // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; // Proves a staged self-contained runtime can run (`/t3 --version`) - // and captures the user's login-shell PATH for the launch. Needs no Node - // in the distro; the mounted server tree still goes through ensureNodePty. + // and resolves the user's PATH, including version-managed Node for provider + // CLIs. Node is optional; the mounted tree still requires ensureNodePty. readonly probeRuntime: ( distro: string | null, linuxAppRoot: string, @@ -545,13 +545,12 @@ require("node-pty"); NODE`; // Readiness proof for a staged self-contained runtime: the executable runs and -// reports its version, and the login shell's PATH is captured for the launch. -// This runs under plain `sh` (no Node resolver preamble, since the runtime -// needs no Node), so the login shell is entered explicitly for the PATH -// capture; a distro without bash falls back to the PATH sh was started with. -const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) => +// reports its version. Provider CLIs may still need version-managed Node, so +// resolve it before capturing PATH without requiring it for runtime readiness. +// A distro without bash falls back to the PATH sh was started with. +export const buildWslRuntimeProbeScript = (linuxAppRoot: string) => [ - `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, + `bash -lc ${shellQuote(`${buildWslNodeEnvPreamble()}${RESOLVED_PATH_LINE}`)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, ].join("\n"); @@ -676,9 +675,14 @@ const probeWslRuntimeImpl = ( linuxAppRoot: string, ): Effect.Effect => Effect.gen(function* () { - const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, { - resolveNode: false, - }); + const probe = yield* runWslShell( + distro, + buildWslRuntimeProbeScript(linuxAppRoot), + PROBE_TIMEOUT, + { + resolveNode: false, + }, + ); const transportFailureReason = formatWslShellTransportFailureReason( probe.transportFailure, "the staged runtime", diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index e1a13d4ce8e7..5ba2f281126c 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -101,6 +101,8 @@ const makeCliTestServerConfig = (baseDir: string) => otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, + otlpProtocol: "http/json", mode: "web", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 0c28bce28ae5..8c4dbbcdbbf2 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -52,6 +52,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, + otlpProtocol: "http/json", devAllowedOrigins: [], } as const; @@ -719,4 +721,128 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); }), ); + + it.effect("decodes percent-encoded OTLP headers from env", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-headers-base"); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otlpHeaders).toEqual({ + authorization: "Basic abc==", + "x-tenant": "t3", + }); + }), + ); + + it.effect("keeps whitespace-separated pairs and literal equals signs in OTLP headers", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-headers-loose-base"); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_HEADERS: "authorization=Bearer abc==, x-tenant=t3", + T3CODE_OTLP_TRACES_URL: "http://collector.internal:4318", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otlpHeaders).toEqual({ + authorization: "Bearer abc==", + "x-tenant": "t3", + }); + expect(resolved.otlpTracesUrl).toBe("http://collector.internal:4318"); + }), + ); + + it.effect("reads the OTLP protocol from env", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-protocol-base"); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_OTLP_PROTOCOL: "http/protobuf" } }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otlpProtocol).toBe("http/protobuf"); + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 1759b4b03830..e0ba4e190f13 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,4 +1,5 @@ import * as NetService from "@t3tools/shared/Net"; +import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -99,6 +100,13 @@ const EnvServerConfig = Config.all({ Config.withDefault(10_000), ), otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), + otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( + Config.withDefault("http/json"), + ), mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -387,6 +395,8 @@ export const resolveServerConfig = ( persistedObservabilitySettings.otlpMetricsUrl, otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, + otlpHeaders: env.otlpHeaders, + otlpProtocol: env.otlpProtocol, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 7fd376c6f881..07a4d5fe7896 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -322,6 +322,8 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, + otlpProtocol: "http/json", mode: "web", port: state.port, host: state.host, diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index ca090bc2870d..e4b16b8f7190 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -1,4 +1,3 @@ -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -95,19 +94,6 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.deepEqual(commands, ["tar"]); assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); - if ((yield* HostProcessPlatform) !== "win32") { - // The old launcher must still be able to start this archive after the - // first npm-to-executable update, including from the final directory. - yield* fs.writeFileString(paths.entryPath, '#!/bin/sh\nprintf "%s\\n" "$@"\n'); - yield* fs.chmod(paths.entryPath, 0o755); - const runner = yield* ProcessRunner.make(); - const legacyStart = yield* runner.run({ - command: process.execPath, - args: [path.join(paths.versionDir, "node_modules/t3/dist/bin.mjs"), "serve"], - }); - assert.equal(Number(legacyStart.code), 0, legacyStart.stderr); - assert.equal(legacyStart.stdout.trim(), "serve"); - } }), ); @@ -223,40 +209,6 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); - it.effect("backfills a cached archive without downloading or replacing it", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-legacy-cache-" }); - const cached = pinnedRuntimePaths(path, baseDir, version, "linux"); - const legacyEntry = path.join(cached.versionDir, "node_modules/t3/dist/bin.mjs"); - yield* fs.makeDirectory(cached.versionDir, { recursive: true }); - yield* fs.writeFileString(cached.entryPath, "cached executable\n"); - yield* fs.writeFileString(cached.sentinelPath, `${version}\n`); - const requests: string[] = []; - const commands: string[] = []; - yield* ensurePinnedRuntimeInstalled({ - baseDir, - version, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: releaseHttpClient(yield* validChecksums, requests), - runner: extractingRunner(fs, path, commands), - validate: () => - fs.exists(legacyEntry).pipe( - Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing legacy entry"))), - Effect.orDie, - ), - }); - assert.deepEqual(requests, []); - assert.deepEqual(commands, []); - assert.equal(yield* fs.readFileString(cached.entryPath), "cached executable\n"); - assert.equal(yield* fs.readFileString(cached.sentinelPath), `${version}\n`); - }), - ); - it.effect("preserves a completed runtime when validation fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index ef929ebb8236..680d80e46cd1 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -8,7 +8,6 @@ import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; -import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; import { CLI_RELEASE_CHECKSUMS_FILE, cliArchiveFileName, @@ -230,24 +229,6 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( input: PinnedRuntimeInstallInput, ) { const { fs } = input; - // Old service launchers still use the npm entry point, including when an - // archive was cached before this compatibility wrapper existed. - const ensureLegacyEntry = Effect.fn("cloud.pinned_runtime.ensure_legacy_entry")( - function* (versionDir: string) { - const legacyDir = input.path.join(versionDir, "node_modules", "t3", "dist"); - const entryPath = input.path.join(legacyDir, "bin.mjs"); - if (yield* fs.exists(entryPath)) return; - yield* fs.makeDirectory(legacyDir, { recursive: true }); - yield* fs.writeFileString(entryPath, legacyCliLauncherScript("archive")); - }, - Effect.mapError( - (cause) => - new PinnedRuntimeInstallError({ - step: "writing the legacy service entry point", - cause, - }), - ), - ); const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ fs.exists(paths.versionDir), @@ -261,7 +242,6 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( const alreadyPinned = entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; if (alreadyPinned) { - yield* ensureLegacyEntry(paths.versionDir); yield* input.validate(paths); return paths; } @@ -310,8 +290,6 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( return yield* Effect.gen(function* () { yield* installFromArchive(input, stagingDir); - yield* ensureLegacyEntry(stagingDir); - yield* input.validate(stagingPaths); yield* fs .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) @@ -350,10 +328,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ), ), ); - if (!published) { - yield* ensureLegacyEntry(paths.versionDir); - yield* input.validate(paths); - } + if (!published) yield* input.validate(paths); return paths; }).pipe( Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b0544ef30aeb..919628c8d1ed 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -17,6 +17,7 @@ import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; +import { OtlpProtocol } from "@t3tools/shared/observability"; export const DEFAULT_PORT = 3773; @@ -73,6 +74,8 @@ export class ServerConfig extends Context.Service< readonly otlpMetricsUrl: string | undefined; readonly otlpExportIntervalMs: number; readonly otlpServiceName: string; + readonly otlpHeaders: Readonly> | undefined; + readonly otlpProtocol: OtlpProtocol; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -197,6 +200,8 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, + otlpProtocol: "http/json", cwd, baseDir, ...derivedPaths, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a12a8242b0d2..2577866838b1 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -54,6 +54,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, + otlpProtocol: "http/json", cwd: process.cwd(), baseDir, mode: "web", diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 4d2dac735425..c56420178552 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -16,7 +16,6 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { cast } from "effect/Function"; import { - HttpBody, HttpClient, HttpClientResponse, HttpMiddleware, @@ -26,7 +25,7 @@ import { HttpServerRespondable, } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; -import { OtlpTracer } from "effect/unstable/observability"; +import { OtlpTracer, OtlpSerialization } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; @@ -319,8 +318,10 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig.ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; + const otlpHeaders = config.otlpHeaders; const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; + const serialization = yield* OtlpSerialization.OtlpSerialization; const bodyJson = cast(yield* request.json); yield* Effect.try({ @@ -342,7 +343,8 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( return yield* httpClient .post(otlpTracesUrl, { - body: HttpBody.jsonUnsafe(bodyJson), + body: serialization.traces(bodyJson), + headers: otlpHeaders, }) .pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 1795808bc179..08084a94df28 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { DEFAULT_KEYBINDINGS, - DEFAULT_RESOLVED_KEYBINDINGS, + mergeWithDefaultKeybindings, compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, parseKeybindingShortcut, @@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { }; } -function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { - if (custom.length === 0) { - return [...DEFAULT_RESOLVED_KEYBINDINGS]; - } - - const overriddenCommands = new Set(custom.map((binding) => binding.command)); - const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( - (binding) => !overriddenCommands.has(binding.command), - ); - const merged = [...retainedDefaults, ...custom]; - - if (merged.length <= MAX_KEYBINDINGS_COUNT) { - return merged; - } - - // Keep the latest rules when the config exceeds max size; later rules have higher precedence. - return merged.slice(-MAX_KEYBINDINGS_COUNT); -} - /** * Keybindings - Service tag for keybinding configuration operations. */ diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 8aac0927534b..7a7aaf910a9b 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -1,12 +1,15 @@ import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { + makeLocalFileTracer, + makeTraceSink, + otlpSerializationLayer, +} from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; import * as Tracer from "effect/Tracer"; import * as OtlpExporter from "effect/unstable/observability/OtlpExporter"; import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics"; -import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization"; import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; import * as ServerConfig from "../../config.ts"; @@ -14,11 +17,10 @@ import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttributio import { ServerLoggerLive } from "../../serverLogger.ts"; import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; -const otlpSerializationLayer = OtlpSerialization.layerJson; - export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const serializationLayer = otlpSerializationLayer(config.otlpProtocol); const attribution = yield* ResourceAttribution.ResourceAttribution; const traceReferencesLayer = Layer.mergeAll( @@ -49,6 +51,7 @@ export const ObservabilityLive = Layer.unwrap( : yield* OtlpTracer.make({ url: config.otlpTracesUrl, exportInterval: `${config.otlpExportIntervalMs} millis`, + headers: config.otlpHeaders, resource: { serviceName: config.otlpServiceName, attributes: { @@ -72,7 +75,7 @@ export const ObservabilityLive = Layer.unwrap( BrowserTraceCollector.layer(sink), ); }), - ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(otlpSerializationLayer)); + ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(serializationLayer)); const metricsLayer = config.otlpMetricsUrl === undefined @@ -80,6 +83,7 @@ export const ObservabilityLive = Layer.unwrap( : OtlpMetrics.layer({ url: config.otlpMetricsUrl, exportInterval: `${config.otlpExportIntervalMs} millis`, + headers: config.otlpHeaders, resource: { serviceName: config.otlpServiceName, attributes: { @@ -87,7 +91,7 @@ export const ObservabilityLive = Layer.unwrap( "service.mode": config.mode, }, }, - }).pipe(Layer.provideMerge(otlpSerializationLayer)); + }).pipe(Layer.provideMerge(serializationLayer)); return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); }), diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 399d86f7a133..5c45362daa74 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -11,6 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isCommandMissingCause, + parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, } from "./providerSnapshot.ts"; @@ -93,6 +94,29 @@ describe("providerModelsFromSettings", () => { }); }); +describe("parseGenericCliVersion", () => { + it("parses a bare version", () => { + expect(parseGenericCliVersion("1.14.19")).toBe("1.14.19"); + }); + + it("parses a v-prefixed version", () => { + expect(parseGenericCliVersion("opencode v2.0.3")).toBe("2.0.3"); + expect(parseGenericCliVersion("v22.19.0")).toBe("22.19.0"); + }); + + it("parses a version embedded in other output", () => { + expect(parseGenericCliVersion("codex-cli 0.53.0 (build abc)")).toBe("0.53.0"); + }); + + it("returns null when no version is present", () => { + expect(parseGenericCliVersion("no version here")).toBeNull(); + }); + + it("ignores versions glued to other word characters", () => { + expect(parseGenericCliVersion("build2.0.3artifact")).toBeNull(); + }); +}); + describe("ProviderCommandNotFoundError", () => { it("classifies normalized platform failures without parsing messages", () => { expect( diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 40ae0eeefde0..c5503a175498 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -107,8 +107,14 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman return result; }).pipe(Effect.scoped); +/** + * Return the first semantic version found in CLI output, or null. Accepts a + * leading "v" (for example `opencode v2.0.3`). + */ export function parseGenericCliVersion(output: string): string | null { - const match = output.match(/\b(\d+\.\d+\.\d+)\b/); + // "opencode v2.0.3"-style output: the optional "v" has to be consumed first, + // since "v2" itself contains no word boundary. + const match = output.match(/\bv?(\d+\.\d+\.\d+)\b/); return match?.[1] ?? null; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3eb40a1b9906..d5ba7bc6bf56 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -216,6 +216,7 @@ import { transferBudgetViolations, } from "../integration/TransferBudgetReport.integration.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { otlpSerializationLayer } from "@t3tools/shared/observability"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); @@ -576,6 +577,8 @@ const buildAppUnderTest = (options?: { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, + otlpProtocol: "http/json", mode: "desktop", port: 0, host: "127.0.0.1", @@ -1059,6 +1062,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.browserTraceCollector, }), ), + Layer.provide(otlpSerializationLayer(config.otlpProtocol)), Layer.provide( Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({ publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), @@ -5215,6 +5219,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("forwards browser OTLP traces as protobuf when the protocol is http/protobuf", () => + Effect.gen(function* () { + const upstreamRequests: Array<{ + readonly body: string; + readonly contentType: string | null; + }> = []; + const localTraceRecords: Array = []; + // Produced by effect's own tracer, so enum fields are numeric and the + // protobuf encoder accepts them. The hand-written payload in the JSON + // test uses enum names, which only the JSON path tolerates. + const payload = yield* makeBrowserOtlpPayload("client.protobuf.test"); + + const collector = yield* Effect.acquireRelease( + Effect.promise(async () => { + const NodeHttp = await import("node:http"); + + return await new Promise<{ + readonly close: () => Promise; + readonly url: string; + }>((resolve, reject) => { + const server = NodeHttp.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => { + upstreamRequests.push({ + body: Buffer.concat(chunks).toString("utf8"), + contentType: request.headers["content-type"] ?? null, + }); + response.statusCode = 204; + response.end(); + }); + }); + + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Expected TCP collector address")); + return; + } + + resolve({ + url: `http://127.0.0.1:${address.port}/v1/traces`, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) { + rejectClose(error); + return; + } + resolveClose(); + }); + }), + }); + }); + }); + }), + ({ close }) => Effect.promise(close), + ); + + yield* buildAppUnderTest({ + config: { + otlpTracesUrl: collector.url, + otlpProtocol: "http/protobuf", + }, + layers: { + browserTraceCollector: { + record: (records) => + Effect.sync(() => { + localTraceRecords.push(...records); + }), + }, + }, + }); + + const response = yield* HttpClient.post("/api/observability/v1/traces", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + "content-type": "application/json", + }, + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: HttpBody.text(JSON.stringify(payload), "application/json"), + }); + + assert.equal(response.status, 204); + // The local collector still decodes the browser's JSON before forwarding. + assert.equal(localTraceRecords.length, 1); + assert.equal(upstreamRequests.length, 1); + const forwarded = upstreamRequests[0]; + assert.notEqual(forwarded, undefined); + if (!forwarded) { + return; + } + assert.equal(forwarded.contentType, "application/x-protobuf"); + // Protobuf strings are raw UTF-8, so the span and service names survive + // the stub's utf8 decode even though the surrounding bytes don't. + assert.notEqual(forwarded.body[0], "{"); + assert.include(forwarded.body, "client.protobuf.test"); + assert.include(forwarded.body, "t3-web"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("responds to browser OTLP trace preflight requests with CORS headers", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3e3f834658ea..7b7ffe626bc8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -8,7 +8,17 @@ import { HistoryIcon, ScaleIcon, } from "lucide-react"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + type Ref, + memo, + useImperativeHandle, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -25,7 +35,10 @@ import { resolvePreviousWorktreeSeed, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; -import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; +import { + BranchToolbarBranchSelector, + type BranchToolbarBranchSelectorHandle, +} from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { Button } from "./ui/button"; @@ -46,7 +59,13 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; +export interface BranchToolbarHandle { + openBranchPicker: () => void; + usePreviousWorktree: () => void; +} + interface BranchToolbarProps { + ref?: Ref; environmentId: EnvironmentId; threadId: ThreadId; showGitControls: boolean; @@ -169,6 +188,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ render={ + } /> (current === entry.instanceId ? null : current)) } disabled={isDisabled} + focusableWhenDisabled={!isDisabled} + aria-pressed={isSelected} type="button" aria-label={ isUnavailable || isContextDisabled @@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ) : null} - + ); const trigger = isDisabled ? ( @@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { })} - + ); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 47ce59efde52..0a863e0bd446 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -618,6 +618,7 @@ export const TraitsPicker = memo(function TraitsPicker({ void) | undefined; readonly className?: string | undefined; readonly videoClassName?: string | undefined; /** Styles the loading and failure panels, which otherwise assume an inline light surface. */ @@ -34,6 +36,7 @@ export function MediaVideoPlayer({ revision = null, preload = "visible", autoPlay = false, + onOpen, className, videoClassName, stateClassName, @@ -131,7 +134,7 @@ export function MediaVideoPlayer({ style={style} data-markdown-copy={copyMarkdown} > - {failed ? ( + {failed && !onOpen ? ( - ) : src !== null ? ( + ) : src !== null && !failed ? (