From 0bc95fcdea918e2d155c53ab21e5beefdaa59733 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 1 Nov 2026 03:56:58 -0600 Subject: [PATCH 001/188] feat(dev): add startup profiling for cold-start diagnosis (#28566) --- apps/server/src/bin.ts | 4 + apps/server/src/persistence/Sqlite.ts | 2 +- apps/server/src/ws.test.ts | 62 ++++- apps/server/src/ws.ts | 45 +++- apps/web/scripts/warm-dep-cache.ts | 8 + docs/operations/development.md | 28 +++ package.json | 1 + packages/provider-cursor/src/server/sdk.ts | 8 + scripts/profile-startup-modules.mjs | 262 +++++++++++++++++++++ scripts/profile-startup.mjs | 165 +++++++++++++ scripts/profile-startup.test.ts | 149 ++++++++++++ 11 files changed, 715 insertions(+), 19 deletions(-) create mode 100644 scripts/profile-startup-modules.mjs create mode 100644 scripts/profile-startup.mjs create mode 100644 scripts/profile-startup.test.ts diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 936c79943fd7..a6dfe16f066a 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -40,7 +40,11 @@ if ( // Windows desktop launches without stdin; attach before reading, except on fast paths using pipes. const { attachConsoleStdin } = await import("./cli/consoleStdin.ts"); attachConsoleStdin(); + const importStart = performance.now(); const { runCli } = await import("./binCli.ts"); + if (process.env.T3CODE_STARTUP_PROFILE_DIR) { + performance.measure("server.import.binCli", { start: importStart }); + } runCli(); } } diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index 2f21ea0a8a94..9b95422c4f5c 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -41,7 +41,7 @@ const layerSetup = Layer.effectDiscard( yield* sql`DELETE FROM sqlite_stat1;`; yield* sql`ANALYZE sqlite_schema;`; } - }), + }).pipe(Effect.withSpan("Sqlite.setup")), ); export const layerFromPath = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) { diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ba647514ecef..ca899d4ef04a 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -21,6 +21,7 @@ import { import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; @@ -127,7 +128,7 @@ import { subscribeOrchestrationV2Shell, subscribeOrchestrationV2Thread, WS_RPC_SERVER_OPTIONS, - withLateEditorConfig, + withLateConfigDiscovery, } from "./ws.ts"; const snapshotConfig = (fields: Partial) => @@ -142,7 +143,7 @@ const settingsUpdated = (settings: object): ServerConfigStreamEvent => ({ it.effect("resends late editors without rolling back updates already sent", () => Effect.gen(function* () { const settingsSent = yield* Deferred.make(); - const events = yield* withLateEditorConfig( + const events = yield* withLateConfigDiscovery( snapshotConfig({ settings: { enableProviderUpdateChecks: true } as never }), Stream.make(settingsUpdated({ enableProviderUpdateChecks: false })), { @@ -150,6 +151,7 @@ it.effect("resends late editors without rolling back updates already sent", () = resolveFileManagerRevealKind: () => Deferred.await(settingsSent).pipe(Effect.as("file-explorer" as const)), }, + { resolveTargets: () => Effect.succeed([]) }, ).pipe( Stream.tap((event) => event.type === "settingsUpdated" ? Deferred.succeed(settingsSent, undefined) : Effect.void, @@ -169,24 +171,35 @@ it.effect("resends late editors without rolling back updates already sent", () = }), ); -it.effect("resends a file manager reveal kind that missed the snapshot", () => +it.effect("resends a reveal kind and remote targets that outlast the snapshot timeout", () => Effect.gen(function* () { - const events = yield* withLateEditorConfig( + const targets = [{ kind: "mdns" as const, host: "test.local" }]; + const finishDiscovery = yield* Deferred.make(); + const collecting = yield* withLateConfigDiscovery( snapshotConfig({ availableEditors: ["file-manager"] }), Stream.empty, { resolveAvailableEditors: () => Effect.succeed(["file-manager"]), resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), }, - ).pipe(Stream.runCollect); + { resolveTargets: () => Deferred.await(finishDiscovery) }, + ).pipe(Stream.runCollect, Effect.forkScoped({ startImmediately: true })); + yield* TestClock.adjust("6 seconds"); + yield* Deferred.succeed(finishDiscovery, targets); + const events = yield* Fiber.join(collecting); - const [late] = Array.from(events); - assert.equal(events.length, 1); + const [late, remote] = Array.from(events); + assert.equal(events.length, 2); assert.equal(late?.type, "snapshot"); if (late?.type === "snapshot") { assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); } - }), + assert.equal(remote?.type, "snapshot"); + if (remote?.type === "snapshot") { + assert.deepEqual(remote.config.remoteOpenTargets, targets); + assert.equal(remote.config.shellRevealInFileManagerKind, "file-explorer"); + } + }).pipe(Effect.scoped), ); it.effect("runs editor, remote open target and direct endpoint discovery side by side", () => @@ -600,9 +613,9 @@ const layerConfigSources = Layer.mergeAll( Layer.provideMerge(NodeServices.layer), ); -it.effect( - "preserves settings during discovery and keybindings during snapshot ACK at the config RPC boundary", - () => +it.effect.each(["pending", "cached"] as const)( + "%s remote discovery preserves initial config and updates through discovery and ACK", + (discovery) => Effect.gen(function* () { const keybindings = yield* Keybindings.Keybindings; const settings = yield* ServerSettings.ServerSettingsService; @@ -617,6 +630,9 @@ it.effect( const initialKeybindings = (yield* keybindings.getSnapshot).keybindings; const discoveryStarted = yield* Deferred.make(); const finishDiscovery = yield* Deferred.make(); + const remoteDiscoveryStarted = yield* Deferred.make(); + const finishRemoteDiscovery = yield* Deferred.make(); + const targets = [{ kind: "mdns" as const, host: "test.local" }]; const responses = yield* Queue.unbounded(); const configRpcServices = Layer.mergeAll( layerUnusedConfigRpcServices, @@ -635,7 +651,13 @@ it.effect( }), Layer.mock(UsageLimitSources.UsageLimitSources)({ streamChanges: Stream.make([]) }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ - resolveTargets: () => Effect.succeed([]), + resolveTargets: () => + Deferred.succeed(remoteDiscoveryStarted, undefined).pipe( + Effect.andThen( + discovery === "cached" ? Effect.void : Deferred.await(finishRemoteDiscovery), + ), + Effect.as(targets), + ), }), Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ @@ -710,12 +732,14 @@ it.effect( headers: Headers.empty, }); yield* Deferred.await(discoveryStarted); + assert.isTrue(yield* Deferred.isDone(remoteDiscoveryStarted)); const duringDiscovery = yield* settings.updateSettings({ defaultAutoPull: true }); yield* Deferred.succeed(finishDiscovery, undefined); const snapshot = yield* Queue.take(responses); assert.equal(snapshot.type, "snapshot"); if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); + assert.deepEqual(snapshot.config.remoteOpenTargets, discovery === "cached" ? targets : []); assert.isFalse(snapshot.config.settings.defaultAutoPull); assert.deepEqual(snapshot.config.keybindings, initialKeybindings); const duringAck = yield* keybindings.upsertKeybindingRule({ @@ -752,6 +776,20 @@ it.effect( } assert.isEmpty(expectedSettings); assert.isEmpty(expectedKeybindings); + if (discovery === "pending") { + yield* Deferred.await(remoteDiscoveryStarted); + yield* Deferred.succeed(finishRemoteDiscovery, undefined); + const resolved = yield* Queue.take(responses); + assert.equal(resolved.type, "snapshot"); + if (resolved.type !== "snapshot") return yield* Effect.die("Missing remote targets"); + assert.deepEqual(resolved.config.remoteOpenTargets, targets); + assert.deepEqual( + resolved.config.settings, + ServerSettings.redactServerSettingsForClient(latestSettings), + ); + assert.deepEqual(resolved.config.keybindings, latestKeybindings); + yield* acknowledge; + } yield* server.write(0, { _tag: "Interrupt", requestId, interruptors: [] }); }).pipe( Effect.provide( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8373bbfae0c5..abc7c71f4b8b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -9,6 +9,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Base64 from "effect/encoding/Base64"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -313,10 +314,11 @@ const resolveEditorConfig = Effect.fn("ws.resolveEditorConfig")(function* }; }); -export const withLateEditorConfig = ( +export const withLateConfigDiscovery = ( config: ClientServerConfig, liveUpdates: Stream.Stream, launcher: EditorDiscovery, + remoteOpenTargets: RemoteOpenTargets.RemoteOpenTargets["Service"], ) => { const lateEditorConfig = Stream.fromEffect(launcher.resolveAvailableEditors()).pipe( Stream.filter( @@ -337,7 +339,14 @@ export const withLateEditorConfig = ( Stream.map((editorConfig) => ({ type: "editorsResolved" as const, editorConfig })), ); - return Stream.merge(liveUpdates, lateEditorConfig).pipe( + const lateRemoteOpenTargets = Stream.fromEffect(remoteOpenTargets.resolveTargets()).pipe( + Stream.filter( + (targets) => JSON.stringify(targets) !== JSON.stringify(config.remoteOpenTargets ?? []), + ), + Stream.map((targets) => ({ type: "remoteOpenTargetsResolved" as const, targets })), + ); + + return Stream.merge(liveUpdates, Stream.merge(lateEditorConfig, lateRemoteOpenTargets)).pipe( Stream.mapAccum( (): ClientServerConfig => config, (current, event): readonly [ClientServerConfig, ReadonlyArray] => { @@ -352,6 +361,10 @@ export const withLateEditorConfig = ( const next = { ...rest, ...event.editorConfig }; return [next, [{ version: 1, type: "snapshot", config: next }]]; } + case "remoteOpenTargetsResolved": { + const next = { ...current, remoteOpenTargets: event.targets }; + return [next, [{ version: 1, type: "snapshot", config: next }]]; + } case "keybindingsUpdated": return [{ ...current, ...event.payload }, [event]]; case "providerStatuses": @@ -1754,6 +1767,7 @@ export const layerWsRpc = ( const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: { readonly usageLimitsCommand: boolean; readonly workspaceSnapshotsOnDemand: boolean; + readonly deferRemoteOpenTargets: boolean; }) { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; @@ -1773,7 +1787,9 @@ export const layerWsRpc = ( const discovery = yield* resolveOpenDiscoveryForConfig({ editors: externalLauncher.resolveAvailableEditors(), fileManagerRevealKind: externalLauncher.resolveFileManagerRevealKind(), - remoteOpenTargets: remoteOpenTargets.resolveTargets(), + remoteOpenTargets: options.deferRemoteOpenTargets + ? Effect.succeed([]) + : remoteOpenTargets.resolveTargets(), directEndpoints: directEndpoints.resolve(), }); @@ -2203,7 +2219,11 @@ export const layerWsRpc = ( ), [WS_METHODS.serverProbe]: (_input) => Effect.succeed({}), [WS_METHODS.serverGetConfig]: (_input) => - loadServerConfig({ usageLimitsCommand: false, workspaceSnapshotsOnDemand: false }), + loadServerConfig({ + usageLimitsCommand: false, + workspaceSnapshotsOnDemand: false, + deferRemoteOpenTargets: false, + }), [WS_METHODS.serverSearchAcpRegistry]: (input) => acpRegistryCatalog .search(input) @@ -3126,9 +3146,13 @@ export const layerWsRpc = ( // Buffer changes through discovery and the initial snapshot's RPC ACK wait. const keybindingsChanges = yield* keybindings.subscribeChanges; const settingsChanges = yield* serverSettings.subscribeChanges; + const remoteTargets = yield* Effect.forkScoped(remoteOpenTargets.resolveTargets(), { + startImmediately: true, + }); const config = yield* loadServerConfig({ usageLimitsCommand, workspaceSnapshotsOnDemand, + deferRemoteOpenTargets: true, }); const keybindingsUpdates = keybindingsChanges.pipe( Stream.map((event) => ({ @@ -3215,9 +3239,18 @@ export const layerWsRpc = ( ), ); + const remoteResult = yield* Effect.sync(() => remoteTargets.pollUnsafe()); + const initialConfig = + remoteResult?._tag === "Success" + ? { ...config, remoteOpenTargets: remoteResult.value } + : config; return Stream.concat( - rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]), - withLateEditorConfig(config, liveUpdates, externalLauncher), + rpcInitialItems([ + { version: 1 as const, type: "snapshot" as const, config: initialConfig }, + ]), + withLateConfigDiscovery(initialConfig, liveUpdates, externalLauncher, { + resolveTargets: () => Fiber.join(remoteTargets), + }), ); }), ), diff --git a/apps/web/scripts/warm-dep-cache.ts b/apps/web/scripts/warm-dep-cache.ts index 1b3bbee03464..8607371535e5 100644 --- a/apps/web/scripts/warm-dep-cache.ts +++ b/apps/web/scripts/warm-dep-cache.ts @@ -10,6 +10,14 @@ import { optimizeDeps, resolveConfig } from "vite"; const webRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); +const configStart = performance.now(); const config = await resolveConfig({ root: webRoot, logLevel: "error" }, "serve"); +if (process.env.T3CODE_STARTUP_PROFILE_DIR) { + performance.measure("web.warmDepCache.resolveConfig", { start: configStart }); +} +const optimizeStart = performance.now(); await optimizeDeps(config); +if (process.env.T3CODE_STARTUP_PROFILE_DIR) { + performance.measure("web.warmDepCache.optimizeDeps", { start: optimizeStart }); +} console.log("[warm-dep-cache] web dependency cache is warm"); diff --git a/docs/operations/development.md b/docs/operations/development.md index 3a56a23f63da..df4affcad2ae 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -86,6 +86,34 @@ To add a scenario, point a `SCENARIOS` entry at a generic ACP replay fixture The agent replays session updates, permission requests, and questions. It drops session setup and vendor frames, so Grok and fx recordings lose their vendor-only behavior. +### Profiling dev startup + +Run `vp run profile:startup` to capture the first 20 seconds of inherited Node processes under +`.t3/startup-profiles/`. Workers that execute the preload get separate profiles. +It starts the normal dev runner and accepts its flags. +Stop it with Ctrl+C after the capture. Set `T3CODE_STARTUP_PROFILE_DIR` to choose another output +directory. Profiling adds overhead, so compare readiness with an ordinary `vp run dev` run. + +Open a `.cpuprofile` in Chrome DevTools' Performance panel for sampled function stacks and source +lines. Each `.modules.json` is a Chrome trace containing timed module resolution, loading, +synchronous subprocess calls, and startup measures. Per-file records include importer URLs, +source loading, preparation, recursive dependency linking, and top-level evaluation. Preparation +uses synchronous loader boundaries; linking includes dependencies, and CommonJS evaluation can +include nested `require` calls. Some ESM/CommonJS interop reports a combined stage. `.process.json` records the process, Node version, and capture window. +The server's nested Effect and SQL spans remain in `.t3/userdata/logs/server.trace.ndjson`. + +The preload temporarily adds module-body timing markers and uses one automatic debugger pause +to instrument the live Node loader. The launcher adds `--no-node-snapshot` to inherited +`NODE_OPTIONS` because Node 26's snapshotted loader cannot accept that breakpoint. Include the +same flag when using the preload directly. This disables Node's built-in startup snapshot, +can change compilation-cache hits, and adds overhead. +Treat per-file captures as diagnostic runs; use uninstrumented runs for baseline startup latency. +The first entry module can compile before loader instrumentation is installed. + +A fresh process still uses filesystem, Node compilation, provider, and Vite dependency caches. +Measure a missing Vite dependency cache separately from a restart with an existing database. +Neither the Vite ready message nor the HTTP listener proves that a browser has rendered the app. + ### Sharing and remote debugging `vp run dev --share` publishes the web port over the machine's tailnet and prints a pairing URL diff --git a/package.json b/package.json index c78b879301b9..b2912d680c85 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "prepare": "vp config --no-agent", "dev": "node scripts/dev-runner.ts dev", + "profile:startup": "node scripts/profile-startup.mjs", "dev:share": "node scripts/dev-runner.ts dev --share", "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", diff --git a/packages/provider-cursor/src/server/sdk.ts b/packages/provider-cursor/src/server/sdk.ts index a8a10e365b36..9443bf735a08 100644 --- a/packages/provider-cursor/src/server/sdk.ts +++ b/packages/provider-cursor/src/server/sdk.ts @@ -3,6 +3,7 @@ import * as NodeModule from "node:module"; // createRequire loads the disk-backed SDK and adjacent chunks even from a Node SEA executable. const requireCursorSdk = NodeModule.createRequire(import.meta.url); +const sdkImportStart = performance.now(); export const { Agent, AuthenticationError, @@ -12,6 +13,13 @@ export const { CursorSdkError, InMemoryCredentialStore, } = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk"); +if (process.env.T3CODE_STARTUP_PROFILE_DIR) { + performance.measure("provider.cursor.importSdk", { start: sdkImportStart }); +} +const sqliteImportStart = performance.now(); export const { SqliteLocalAgentStore } = requireCursorSdk( "@cursor/sdk/sqlite", ) as typeof import("@cursor/sdk/sqlite"); +if (process.env.T3CODE_STARTUP_PROFILE_DIR) { + performance.measure("provider.cursor.importSqliteStore", { start: sqliteImportStart }); +} diff --git a/scripts/profile-startup-modules.mjs b/scripts/profile-startup-modules.mjs new file mode 100644 index 000000000000..2db139a92ac4 --- /dev/null +++ b/scripts/profile-startup-modules.mjs @@ -0,0 +1,262 @@ +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePerfHooks from "node:perf_hooks"; +import * as NodeURL from "node:url"; + +export function profileStartupModules(session, events, threadId) { + const now = () => NodePerfHooks.performance.now(); + const origin = NodePerfHooks.performance.timeOrigin; + const loaded = new Map(); + const compiling = new Map(); + const evaluating = new Map(); + const registryKey = "__t3codeStartupModuleProfiler"; + const previousRegistry = globalThis[registryKey]; + const previousModuleRegistry = NodeModule.default[registryKey]; + const restorers = []; + let active = true; + let installed = false; + let breakpoint; + let currentLink; + let nextLink = 0; + + const record = (stage, url, start, end, attributes = {}) => { + if (!active) return; + events.push({ + name: `${stage} ${url}`, + cat: "module.detail", + ph: "X", + pid: process.pid, + tid: threadId, + ts: (origin + start) * 1000, + dur: (end - start) * 1000, + args: { stage, url, ...attributes }, + }); + }; + const toUrl = (file) => + file instanceof URL + ? file.href + : typeof file === "string" + ? file.startsWith("file:") + ? file + : NodeURL.pathToFileURL(file).href + : undefined; + const inject = (source, statement, preserveDirectives) => { + let offset = source.startsWith("#!") ? source.indexOf("\n") + 1 : 0; + if (preserveDirectives) { + for (;;) { + const trivia = /^(?:\s+|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/.exec( + source.slice(offset), + )[0]; + offset += trivia.length; + if (!/^["']/.test(source.slice(offset))) break; + const directive = /^(?:"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*')\s*;/.exec( + source.slice(offset), + ); + // Leave ambiguous semicolon-free prologues untouched rather than changing strict-mode semantics. + if (!directive) return undefined; + offset += directive[0].length; + } + } + return source.slice(0, offset) + statement + source.slice(offset); + }; + const registry = { + begin(url) { + if (active) evaluating.set(url, now()); + }, + end(url) { + const start = evaluating.get(url); + if (start === undefined) return; + record("evaluate", url, start, now(), { + format: "esm", + boundary: "module body; top-level await included", + }); + evaluating.delete(url); + }, + cjsBegin(filename) { + const url = toUrl(filename), + pending = compiling.get(url); + if (!pending) return; + pending.evaluationStart = now(); + record("prepare", url, pending.start, pending.evaluationStart, { + format: "commonjs", + boundary: "_compile entry to module-body entry", + }); + }, + install(job) { + if (installed) return; + installed = true; + const loader = Object.getPrototypeOf(job.loader); + for (const method of [ + "loadAndTranslate", + "loadAndTranslateForRequireInImportedCJS", + "loadAndTranslateForImportInRequiredESM", + ]) { + const original = loader[method]; + if (typeof original !== "function") continue; + loader[method] = function (url, ...args) { + const start = now(); + try { + return original.call(this, url, ...args); + } finally { + const moduleUrl = typeof url === "string" ? url : url.url; + const pending = loaded.get(moduleUrl); + if (pending && pending.start >= start) + record("prepare", moduleUrl, pending.start, now(), { + format: pending.format, + boundary: + "source-ready to loadAndTranslate return; compilation and format preparation", + }); + } + }; + restorers.push(() => { + loader[method] = original; + }); + } + const prototype = Object.getPrototypeOf(Object.getPrototypeOf(job)), + link = prototype.syncLink; + prototype.syncLink = function (...args) { + const start = now(), + parent = currentLink, + id = ++nextLink; + currentLink = { id, url: this.url }; + try { + return link.apply(this, args); + } finally { + record("link", this.url, start, now(), { + id, + parentId: parent?.id, + importedBy: parent?.url, + boundary: "ModuleJob.syncLink; includes recursive dependency loading", + }); + currentLink = parent; + } + }; + restorers.push(() => { + prototype.syncLink = link; + }); + }, + }; + globalThis[registryKey] = registry; + NodeModule.default[registryKey] = registry; + + session.post("Debugger.enable"); + // One pause obtains the live loader; subsequent measurements use synchronous method boundaries. + const source = process.binding("natives")["internal/modules/esm/module_job"].split("\n"); + const classLine = source.findIndex((line) => line.includes("class ModuleJob extends")); + const lineNumber = source.findIndex( + (line, index) => index > classLine && line.includes("this.linked = this.link("), + ); + const paused = ({ params }) => { + if (!breakpoint || !params.hitBreakpoints?.includes(breakpoint)) return; + const start = now(); + session.post("Debugger.removeBreakpoint", { breakpointId: breakpoint }); + breakpoint = undefined; + session.post( + "Debugger.evaluateOnCallFrame", + { + callFrameId: params.callFrames[0].callFrameId, + expression: `globalThis.${registryKey}.install(this)`, + silent: true, + }, + (error, result) => { + if (error || result?.exceptionDetails) + record("instrumentation-error", "node:esm-loader", start, now()); + }, + ); + record("instrumentation-setup", "node:esm-loader", start, now()); + session.post("Debugger.resume"); + }; + session.on("Debugger.paused", paused); + if (lineNumber >= 0) + session.post( + "Debugger.setBreakpointByUrl", + { + url: "node:internal/modules/esm/module_job", + lineNumber, + }, + (error, result) => { + if (!error) breakpoint = result.breakpointId; + }, + ); + + const readFileSync = NodeFS.default.readFileSync; + NodeFS.default.readFileSync = function (file, ...args) { + const url = toUrl(file); + if (!url || !/\.[cm]?[jt]sx?(?:$|\?)/.test(url)) return readFileSync.call(this, file, ...args); + const start = now(); + try { + return readFileSync.call(this, file, ...args); + } finally { + record("read", url, start, now()); + } + }; + const compile = NodeModule.default.prototype._compile; + NodeModule.default.prototype._compile = function (source, filename, ...args) { + const url = toUrl(filename), + pending = { start: now() }; + const format = args[0] ?? loaded.get(url)?.format; + const isEsm = + format === "module" || + format === "module-typescript" || + /(^|[;\n])\s*(?:import[\s.]|export\s)/.test(source); + const annotated = isEsm + ? undefined + : inject(source, `module.constructor.${registryKey}?.cjsBegin(__filename);`, true); + compiling.set(url, pending); + try { + return compile.call(this, annotated ?? source, filename, ...args); + } finally { + const end = now(); + record(isEsm ? "require-esm" : "commonjs", url, pending.start, end, { + importedBy: this.parent?.filename ? toUrl(this.parent.filename) : undefined, + sourceBytes: Buffer.byteLength(source), + }); + if (pending.evaluationStart !== undefined) + record("evaluate", url, pending.evaluationStart, end, { + format: "commonjs", + includesNestedRequires: true, + }); + compiling.delete(url); + } + }; + NodeModule.syncBuiltinESMExports(); + + return { + sourceReady(url, result) { + if (!url.startsWith("file:")) return result; + const format = result.format; + let source = result.source; + if (typeof source !== "string" && ArrayBuffer.isView(source)) + source = Buffer.from(source.buffer, source.byteOffset, source.byteLength).toString("utf8"); + if (typeof source === "string" && (format === "module" || format === "module-typescript")) { + // A module-local globalThis binding would put the injected access in its temporal dead zone. + if (!/\b(?:const|let|var|class|function|as)\s+globalThis\b/.test(source)) { + source = + inject(source, `globalThis.${registryKey}?.begin(import.meta.url);`, false) + + `\n;globalThis.${registryKey}?.end(import.meta.url);\n`; + result = { ...result, source }; + } + } + loaded.set(url, { start: now(), format }); + return result; + }, + finish() { + const end = now(); + for (const [url, start] of evaluating) + record("evaluate", url, start, end, { incomplete: true }); + record("instrumentation-status", "node:esm-loader", end, end, { installed }); + active = false; + if (breakpoint) session.post("Debugger.removeBreakpoint", { breakpointId: breakpoint }); + session.off("Debugger.paused", paused); + session.post("Debugger.disable"); + for (const restore of restorers.reverse()) restore(); + NodeFS.default.readFileSync = readFileSync; + NodeModule.default.prototype._compile = compile; + NodeModule.syncBuiltinESMExports(); + if (previousRegistry === undefined) delete globalThis[registryKey]; + else globalThis[registryKey] = previousRegistry; + if (previousModuleRegistry === undefined) delete NodeModule.default[registryKey]; + else NodeModule.default[registryKey] = previousModuleRegistry; + }, + }; +} diff --git a/scripts/profile-startup.mjs b/scripts/profile-startup.mjs new file mode 100644 index 000000000000..f14dfc015519 --- /dev/null +++ b/scripts/profile-startup.mjs @@ -0,0 +1,165 @@ +// Opt in with T3CODE_STARTUP_PROFILE_DIR=/absolute/path NODE_OPTIONS="--no-node-snapshot --import=/absolute/path/to/this/file". +import * as NodeFS from "node:fs"; +import * as NodeInspector from "node:inspector"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodePerfHooks from "node:perf_hooks"; +import * as NodeWorkerThreads from "node:worker_threads"; +import * as NodeURL from "node:url"; + +import { profileStartupModules } from "./profile-startup-modules.mjs"; + +const directory = process.env.T3CODE_STARTUP_PROFILE_DIR; +const isLauncher = process.argv[1] === NodeURL.fileURLToPath(import.meta.url); +if (directory && !isLauncher) { + NodeFS.mkdirSync(directory, { recursive: true }); + const { threadId } = NodeWorkerThreads; + const prefix = NodePath.join(directory, `${process.pid}-${threadId}`); + const origin = NodePerfHooks.performance.timeOrigin; + const events = []; + const session = new NodeInspector.Session(); + session.connect(); + session.post("Profiler.setSamplingInterval", { interval: 1000 }); + session.post("Profiler.enable"); + const profileStartTimeUnixMs = origin + NodePerfHooks.performance.now(); + session.post("Profiler.start"); + + const moduleDetails = profileStartupModules(session, events, threadId); + + const measure = (name, operation, cat = "module", attributes = {}) => { + const start = NodePerfHooks.performance.now(); + try { + return operation(); + } finally { + events.push({ + name, + cat, + ph: "X", + pid: process.pid, + tid: threadId, + ts: (origin + start) * 1000, + dur: (NodePerfHooks.performance.now() - start) * 1000, + args: attributes, + }); + } + }; + + const recordMeasures = (entries) => { + for (const entry of entries) { + events.push({ + name: entry.name, + cat: "startup", + ph: "X", + pid: process.pid, + tid: threadId, + ts: (origin + entry.startTime) * 1000, + dur: entry.duration * 1000, + }); + } + }; + const observer = new NodePerfHooks.PerformanceObserver((list) => + recordMeasures(list.getEntries()), + ); + observer.observe({ entryTypes: ["measure"] }); + const execFileSync = NodeChildProcess.default.execFileSync; + NodeChildProcess.default.execFileSync = function (file, ...args) { + return measure( + `execFileSync ${file}`, + () => execFileSync.call(this, file, ...args), + "subprocess", + ); + }; + NodeModule.syncBuiltinESMExports(); + + const hooks = NodeModule.registerHooks({ + resolve(specifier, context, next) { + const attributes = { specifier, parentURL: context.parentURL }; + return measure( + `resolve ${specifier}`, + () => { + const result = next(specifier, context); + attributes.url = result.url; + attributes.format = result.format; + return result; + }, + "module", + attributes, + ); + }, + load(url, context, next) { + const result = measure(`load ${url}`, () => next(url, context), "module", { + url, + format: context.format, + }); + return moduleDetails.sourceReady(url, result); + }, + }); + + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + hooks.deregister(); + moduleDetails.finish(); + recordMeasures(observer.takeRecords()); + observer.disconnect(); + NodeChildProcess.default.execFileSync = execFileSync; + NodeModule.syncBuiltinESMExports(); + session.post("Profiler.stop", (error, result) => { + if (!error) NodeFS.writeFileSync(`${prefix}.cpuprofile`, JSON.stringify(result.profile)); + session.disconnect(); + }); + NodeFS.writeFileSync(`${prefix}.modules.json`, JSON.stringify({ traceEvents: events })); + NodeFS.writeFileSync( + `${prefix}.process.json`, + JSON.stringify({ + pid: process.pid, + parentPid: process.ppid, + threadId, + nodeVersion: process.version, + executable: process.execPath, + entry: process.argv[1], + timeOrigin: origin, + profileStartTimeUnixMs, + captureEnd: origin + NodePerfHooks.performance.now(), + cpu: process.cpuUsage(), + memory: process.memoryUsage(), + }), + ); + }; + // Bound the capture without keeping dev servers or short-lived helpers alive. + setTimeout(finish, 20_000).unref(); + process.once("exit", finish); +} + +if (isLauncher) { + const output = NodePath.resolve( + directory ?? + NodePath.join(".t3/startup-profiles", new Date().toISOString().replaceAll(":", "-")), + ); + console.log(`[profile-startup] Writing CPU profiles and timeline events to ${output}`); + const child = NodeChildProcess.spawn( + process.execPath, + ["scripts/dev-runner.ts", "dev", ...process.argv.slice(2)], + { + stdio: "inherit", + env: { + ...process.env, + T3CODE_STARTUP_PROFILE_DIR: output, + // Node 26's snapshotted loader cannot accept the debugger breakpoint used by this profiler. + NODE_OPTIONS: + `${process.env.NODE_OPTIONS ?? ""} --no-node-snapshot --import=${import.meta.url}`.trim(), + }, + }, + ); + process.on("SIGINT", () => child.kill("SIGINT")); + process.on("SIGTERM", () => child.kill("SIGTERM")); + child.on("error", (error) => { + console.error(error); + process.exitCode = 1; + }); + child.on("exit", (code) => { + process.exitCode = code ?? 1; + }); +} diff --git a/scripts/profile-startup.test.ts b/scripts/profile-startup.test.ts new file mode 100644 index 000000000000..998843ed1cf1 --- /dev/null +++ b/scripts/profile-startup.test.ts @@ -0,0 +1,149 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Runs the profiling preload in a real Node subprocess. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as Schema from "effect/Schema"; +import { expect, it } from "vite-plus/test"; + +const decodeTrace = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + traceEvents: Schema.Array( + Schema.Struct({ + name: Schema.String, + ts: Schema.Number, + dur: Schema.Number, + args: Schema.optional( + Schema.Struct({ + stage: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + parentURL: Schema.optional(Schema.String), + importedBy: Schema.optional(Schema.String), + installed: Schema.optional(Schema.Boolean), + }), + ), + }), + ), + }), + ), +); + +const decodeRunner = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ pid: Schema.Number, args: Schema.Array(Schema.String) })), +); + +it("attributes ESM and CommonJS imports without changing module execution", () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-startup-profile-")); + try { + NodeFS.writeFileSync(NodePath.join(directory, "package.json"), '{"type":"module"}'); + const fixtures = { + "root.mjs": ` + import { value } from './dependency.mjs'; + import strict from './strict.cjs'; + import loose from './loose.cjs'; + import interop from './interop.cjs'; + if (value !== 42 || strict !== undefined || loose !== true || interop !== 9) { + throw Error('Profiling changed module semantics'); + } + `, + "dependency.mjs": "export const value = 42;", + "strict.cjs": `/* retain the directive */\n"use strict"; module.exports = (function () { return this; })();`, + "loose.cjs": "module.exports = (function () { return this; })() === globalThis;", + "interop.cjs": "module.exports = require('./required.js').value;", + "required.js": "export const value = 9;", + }; + for (const [name, source] of Object.entries(fixtures)) { + NodeFS.writeFileSync(NodePath.join(directory, name), source); + } + const output = NodePath.join(directory, "capture"); + const root = NodeURL.pathToFileURL( + NodeFS.realpathSync(NodePath.join(directory, "root.mjs")), + ).href; + NodeChildProcess.execFileSync( + process.execPath, + [ + "--no-node-snapshot", + "--import", + NodeURL.pathToFileURL(NodePath.join(import.meta.dirname, "profile-startup.mjs")).href, + "--input-type=module", + "-e", + `await import(${JSON.stringify(root)})`, + ], + { env: { ...process.env, NODE_OPTIONS: "", T3CODE_STARTUP_PROFILE_DIR: output } }, + ); + const spans = NodeFS.readdirSync(output) + .filter((name) => name.endsWith(".modules.json")) + .flatMap( + (name) => decodeTrace(NodeFS.readFileSync(NodePath.join(output, name), "utf8")).traceEvents, + ); + expect(spans.some((span) => span.args?.installed === true)).toBe(true); + for (const name of Object.keys(fixtures)) { + const url = NodeURL.pathToFileURL(NodeFS.realpathSync(NodePath.join(directory, name))).href; + const stages = spans.filter((span) => span.args?.url === url).map((span) => span.args?.stage); + expect(stages, name).toEqual( + expect.arrayContaining( + name === "required.js" ? ["link", "require-esm"] : ["prepare", "evaluate"], + ), + ); + } + const prepared = spans.find( + (span) => span.args?.url === root && span.args?.stage === "prepare", + ); + const linked = spans.find((span) => span.args?.url === root && span.args?.stage === "link"); + if (!prepared || !linked) throw Error("Missing root module preparation/link boundaries"); + expect(prepared.ts + prepared.dur).toBeLessThanOrEqual(linked.ts); + const dependency = NodeURL.pathToFileURL( + NodeFS.realpathSync(NodePath.join(directory, "dependency.mjs")), + ).href; + expect( + spans.find((span) => span.args?.url === dependency && span.args?.stage === "link")?.args + ?.importedBy, + ).toBe(root); + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); + +it("captures only the dev runner and preserves its exit status", () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-startup-launcher-")); + try { + NodeFS.mkdirSync(NodePath.join(directory, "scripts")); + NodeFS.writeFileSync( + NodePath.join(directory, "scripts/dev-runner.ts"), + "console.log(JSON.stringify({ pid: process.pid, args: process.argv.slice(2) }));", + ); + const output = NodePath.join(directory, "capture"); + const stdout = NodeChildProcess.execFileSync( + process.execPath, + [NodePath.join(import.meta.dirname, "profile-startup.mjs"), "--no-seed"], + { + cwd: directory, + env: { ...process.env, NODE_OPTIONS: "", T3CODE_STARTUP_PROFILE_DIR: output }, + encoding: "utf8", + }, + ); + const runner = decodeRunner(stdout.trim().split("\n").at(-1)); + expect(runner.args).toEqual(["dev", "--no-seed"]); + expect(NodeFS.readdirSync(output).sort()).toEqual( + ["cpuprofile", "modules.json", "process.json"].map((suffix) => `${runner.pid}-0.${suffix}`), + ); + NodeFS.writeFileSync( + NodePath.join(directory, "scripts/dev-runner.ts"), + "process.kill(process.pid, 'SIGTERM');", + ); + const interrupted = NodeChildProcess.spawnSync( + process.execPath, + [NodePath.join(import.meta.dirname, "profile-startup.mjs")], + { + cwd: directory, + env: { ...process.env, NODE_OPTIONS: "", T3CODE_STARTUP_PROFILE_DIR: output }, + }, + ); + expect(interrupted.error).toBeUndefined(); + expect(interrupted.status).toBe(1); + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); From 70f475f51127d3e8025777b6582e75556b974675 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 1 Nov 2026 04:03:01 -0600 Subject: [PATCH 002/188] feat(clients): surface child approvals in parent threads (#28516) --- .../features/threads/ChildThreadApprovals.tsx | 88 +++++++ .../features/threads/ThreadDetailScreen.tsx | 248 ++++++++++-------- apps/mobile/src/state/threads.ts | 5 + .../Adapters/ClaudeAdapterV2.test.ts | 94 +++++++ .../Adapters/ClaudeAdapterV2.ts | 21 ++ apps/web/src/components/ChatView.tsx | 2 + .../ChildThreadApprovals.browser.test.tsx | 52 ++++ .../components/chat/ChildThreadApprovals.tsx | 95 +++++++ apps/web/src/state/threads.ts | 5 + docs/user/permission-modes.md | 7 +- .../src/state/threadRequests.test.ts | 66 ++++- .../src/state/threadRequests.ts | 49 ++++ 12 files changed, 613 insertions(+), 119 deletions(-) create mode 100644 apps/mobile/src/features/threads/ChildThreadApprovals.tsx create mode 100644 apps/web/src/components/chat/ChildThreadApprovals.browser.test.tsx create mode 100644 apps/web/src/components/chat/ChildThreadApprovals.tsx diff --git a/apps/mobile/src/features/threads/ChildThreadApprovals.tsx b/apps/mobile/src/features/threads/ChildThreadApprovals.tsx new file mode 100644 index 000000000000..392d185e9afd --- /dev/null +++ b/apps/mobile/src/features/threads/ChildThreadApprovals.tsx @@ -0,0 +1,88 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; +import { useEnvironmentScope } from "../../state/session"; +import { ScrollView, View } from "react-native"; +import Animated, { useAnimatedStyle, type DerivedValue } from "react-native-reanimated"; +import { AppText } from "../../components/ui/AppText"; +import { childThreadApprovalsAtom, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { PendingApprovalCard } from "./PendingApprovalCard"; + +function ChildApproval({ entry }: { entry: ChildThreadApproval }) { + const canRespond = useEnvironmentScope(entry.thread.environmentId, AuthOrchestrationOperateScope); + const respond = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false }); + const [responding, setResponding] = useState(false); + const [error, setError] = useState(null); + return ( + + + Approval from {entry.title} + + { + if (!canRespond || responding) return; + setResponding(true); + setError(null); + const result = await respond({ + environmentId: entry.thread.environmentId, + input: { threadId: entry.thread.threadId, requestId, decision }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Failed to submit approval decision.", + ); + } + setResponding(false); + return result; + }} + /> + {error !== null ? ( + + {error} + + ) : null} + + ); +} + +export function ChildThreadApprovals({ + threadRef, + maxHeight, +}: { + threadRef: ScopedThreadRef | null; + maxHeight: DerivedValue; +}) { + const approvals = useAtomValue(childThreadApprovalsAtom(threadRef)); + const heightStyle = useAnimatedStyle(() => ({ maxHeight: Math.min(320, maxHeight.value / 2) })); + if (approvals.length === 0) return null; + return ( + + + {approvals.map((entry) => ( + + ))} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 870ab31f0000..a71920c199f1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import { ChildThreadApprovals } from "./ChildThreadApprovals"; import { useOpenThreadInspector } from "./thread-inspector-navigation"; import { useSidebarWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; @@ -306,6 +307,8 @@ export interface ThreadDetailScreenProps { readonly showContent?: boolean; } +// iOS questions overlay the controls; Android questions stack with child approvals. +const ComposerPanels = Platform.OS === "android" ? Animated.ScrollView : Animated.View; const USAGE_LIMITS_PANEL_GAP = 12; const USAGE_LIMITS_PANEL_MIN_HEIGHT = 44; @@ -960,6 +963,10 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { composerOverlapHeight: composerBottomInset, }), ); + const composerPanelsHeightStyle = useAnimatedStyle(() => ({ + maxHeight: pendingUserInputMaxHeight.value, + flexGrow: 0, + })); // The usage panel stacks with the composer and any approval card, so it gets // whatever room they leave below the header. Measured only while it is open // so typing in the composer does not re-render the screen. @@ -1504,130 +1511,141 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { }); }} /> - {props.queuedRunEdit !== null ? ( - - { - voiceInputSession.cancel(props.composerDraftKey); - props.onCancelQueuedRunEdit(); - }} - /> - - ) : null} - - - - {props.draftMode ? null : ( - + {props.queuedRunEdit !== null ? ( + + { + voiceInputSession.cancel(props.composerDraftKey); + props.onCancelQueuedRunEdit(); + }} + /> + + ) : null} + - )} - {props.feedbackSubmissions.map((submission) => ( - props.onDismissFeedback(submission.id)} + - ))} - {composerError !== null ? ( - - clearThreadComposerError(selectedThreadKey)} - /> - - ) : null} - {usageLimitsReport && activeUserInputRequestId === null ? ( - - + + {props.draftMode ? null : ( + - - ) : null} - {props.creationState?.kind === "failed" ? ( - - ( + props.onDismissFeedback(submission.id)} /> - - ) : null} - {props.activePendingApproval || props.activePendingUserInput ? ( - - {props.activePendingApproval ? ( - + clearThreadComposerError(selectedThreadKey)} /> - ) : null} - {props.activePendingUserInput ? ( - + ) : null} + {usageLimitsReport && activeUserInputRequestId === null ? ( + + - ) : null} - - ) : null} + + ) : null} + {props.creationState?.kind === "failed" ? ( + + + + ) : null} + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 6ba5c635685a..bfd088ccf7f4 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,3 +1,4 @@ +import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests"; import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentThreadDetailAtoms, @@ -43,6 +44,10 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: threadEnvironment.snapshotAtom, }); +export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom, + pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom, +}); // Long enough for the opened screen to subscribe; its subscription keeps the thread after this. const OPENING_THREAD_HOLD_MS = 3_000; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 342f6ced28ba..8494c4cab781 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1512,6 +1512,74 @@ describe("ClaudeAdapterV2 resume compaction", () => { assert.isFunction(callback); const canUseTool = openedOptions?.canUseTool; assert.isFunction(canUseTool); + const shellSafetyReason = + "Dangerous rm operation not ruled out — the shell may read this command differently than it was parsed"; + for (const [safetyIndex, { askRule, reason }] of [ + { askRule: false, reason: shellSafetyReason }, + { askRule: true, reason: shellSafetyReason }, + { + askRule: false, + reason: + "Dangerous rm operation on possibly-empty variable path: $TARGET\nUse a literal absolute path instead.", + }, + { askRule: false, reason: "This shell -c script runs rm and could not be checked" }, + { askRule: false, reason: "A permission rule denies a command in this shell -c script" }, + ].entries()) { + const safetyRequest = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "runtime_request.updated"), + Stream.runHead, + Effect.forkScoped, + ); + const safetySignal = yield* Effect.abortSignal; + const safetyDecision = yield* Effect.promise(() => + canUseTool!( + "Bash", + { command: "for pair in a b; do echo $pair; done" }, + { + signal: safetySignal, + toolUseID: `tool-shell-safety-${safetyIndex}`, + requestId: `request-shell-safety-${safetyIndex}`, + decisionReason: `\u001b[33m${reason}\u001b[39m`, + suppressAlwaysAllowRule: true, + ...(askRule + ? { + matchedAskRule: { + source: "userSettings" as const, + toolName: "Bash", + ruleContent: "*", + }, + } + : {}), + }, + ), + ).pipe(Effect.forkScoped); + const safety = yield* Effect.raceFirst( + Fiber.join(safetyDecision).pipe( + Effect.map((result) => ({ type: "decision", result }) as const), + ), + Fiber.join(safetyRequest).pipe( + Effect.map((event) => ({ type: "approval", event }) as const), + ), + ); + assert.equal(safety.type, askRule ? "approval" : "decision"); + if (safety.type === "decision") { + assert.equal(safety.result?.behavior, "deny"); + if (safety.result?.behavior === "deny") { + assert.include(safety.result.message, reason); + assert.notInclude(safety.result.message, "\u001b["); + } + } else if ( + Option.isSome(safety.event) && + safety.event.value.type === "runtime_request.updated" + ) { + yield* runtime.respondToRuntimeRequest({ + requestId: safety.event.value.runtimeRequest.id, + decision: "decline", + }); + assert.equal((yield* Fiber.join(safetyDecision))?.behavior, "deny"); + } + yield* Fiber.interrupt(safetyRequest); + } const longQuestion = `Choose a deployment target: ${"region ".repeat(80)}`; const questionRequestEvent = yield* runtime.events.pipe( Stream.filter((event) => event.type === "runtime_request.updated"), @@ -3877,6 +3945,32 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal((yield* Fiber.join(forwardedDecision))?.behavior, "deny"); yield* harness.offerAndWait(frame); } + const fallbackDecision = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "Bash", + { command: "echo checked" }, + { + signal: new AbortController().signal, + toolUseID: "safety-check-after-downgrade", + requestId: "request-safety-check-after-downgrade", + decisionReason: + "Dangerous rm operation not ruled out — the shell may read this command differently than it was parsed", + }, + ), + ).pipe(Effect.forkScoped); + const fallbackFirst = yield* Effect.raceFirst( + Queue.take(harness.runtimeRequestReceipts).pipe( + Effect.map((event) => ({ type: "request", event }) as const), + ), + Fiber.join(fallbackDecision).pipe(Effect.map(() => ({ type: "decision" }) as const)), + ); + assert.equal(fallbackFirst.type, "request"); + if (fallbackFirst.type !== "request") return; + yield* harness.runtime.respondToRuntimeRequest({ + requestId: fallbackFirst.event.runtimeRequest.id, + decision: "decline", + }); + assert.equal((yield* Fiber.join(fallbackDecision))?.behavior, "deny"); yield* finish; const unrestrictedOptions = harness.getOpenedOptions(); yield* start("/unrestricted"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d8a88b7dd2b9..48ed26517c23 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -9763,6 +9763,27 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } satisfies PermissionResult; } + const activePermissionMode = + (yield* Ref.get(queryContext))?.permissionMode ?? context.queryPolicy.permissionMode; + const safetyReason = NodeUtil.stripVTControlCharacters( + callbackOptions.decisionReason ?? "", + ).trim(); + // The SDK omits the CLI's dangerousRemoval discriminator, so match its removal warnings. + if ( + toolName === "Bash" && + activePermissionMode === "bypassPermissions" && + callbackOptions.matchedAskRule === undefined && + (/^Dangerous rm(?:dir)?\b/i.test(safetyReason) || + safetyReason === "This shell -c script runs rm and could not be checked" || + safetyReason === "A permission rule denies a command in this shell -c script") + ) { + return { + behavior: "deny", + message: `Claude Code's removal safety check denied this command; the user did not reject it. The command did not run. Do not bypass the check by splitting the removal, putting it in a script, or using another tool or shell. If the warning below supplies a safe rewrite, use it and let the same check evaluate it. Otherwise continue the rest of the task without the removal and tell the user what you needed to delete and why. Warning: ${safetyReason}`, + toolUseID: callbackOptions.toolUseID, + } satisfies PermissionResult; + } + const requestKind = providerRequestKindFromClaudeTool(toolName); // The user approves the command itself; Claude's title and description only summarize it. const command = inputRecordValue(nativeToolInput, "command"); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ea477b914479..55d951d1bdf8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { ChildThreadApprovals } from "./chat/ChildThreadApprovals"; import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; @@ -3539,6 +3540,7 @@ export default function ChatView(props: ChatViewProps) { : undefined } > + ({ respond: vi.fn(), approvalsAtom: null as unknown })); + +vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.respond })); +vi.mock("../../state/threads", () => ({ + childThreadApprovalsAtom: () => state.approvalsAtom, + threadEnvironment: { respondToApproval: {} }, +})); + +import { ChildThreadApprovals } from "./ChildThreadApprovals"; + +function setup() { + state.respond.mockReset(); + state.respond + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Child environment offline")))) + .mockResolvedValueOnce(AsyncResult.success(undefined)); + const thread = { + environmentId: EnvironmentId.make("child-env"), + threadId: ThreadId.make("child"), + }; + state.approvalsAtom = Atom.make>([ + { + thread, + title: "Compile task", + approval: { + requestId: RuntimeRequestId.make("approval"), + requestKind: "command", + detail: "Run compiler", + createdAt: "2026-11-01T00:00:00.000Z", + responseCapability: "live", + }, + }, + ]); + return thread; +} + +it("shows a failed decision and clears the error when the user retries", async () => { + const thread = setup(); + await using screen = await render(); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).toHaveTextContent("Child environment offline"); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ChildThreadApprovals.tsx b/apps/web/src/components/chat/ChildThreadApprovals.tsx new file mode 100644 index 000000000000..5a7cb3074b4a --- /dev/null +++ b/apps/web/src/components/chat/ChildThreadApprovals.tsx @@ -0,0 +1,95 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; +import type { ChildThreadApproval } from "@t3tools/client-runtime/state/thread-requests"; +import { fullAccessOverrideExplanation } from "@t3tools/client-runtime/state/thread-requests"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; +import { useEnvironmentScope } from "../../state/session"; +import { childThreadApprovalsAtom, threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; + +function ChildApproval({ entry }: { entry: ChildThreadApproval }) { + const canRespond = useEnvironmentScope(entry.thread.environmentId, AuthOrchestrationOperateScope); + const respond = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false }); + const [responding, setResponding] = useState(false); + const [error, setError] = useState(null); + const { approval } = entry; + return ( +
+

Approval needed from {entry.title}

+
+        {approval.detail || approval.requestKind}
+      
+ {approval.appName ?

{approval.appName}

: null} + {approval.responseCapability !== "live" ? ( +

+ Provider process is gone. Interrupt or restart the child thread to respond. +

+ ) : null} + {approval.reason ?

{approval.reason}

: null} + {approval.fullAccessOverride ? ( +

+ {fullAccessOverrideExplanation(approval.fullAccessOverride)} +

+ ) : null} +
+ { + if (!canRespond || responding) return; + setResponding(true); + setError(null); + const result = await respond({ + environmentId: entry.thread.environmentId, + input: { threadId: entry.thread.threadId, requestId, decision }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Failed to submit approval decision.", + ); + } + setResponding(false); + return result; + }} + /> +
+ {error !== null ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +export function ChildThreadApprovals({ threadRef }: { threadRef: ScopedThreadRef | null }) { + const approvals = useAtomValue(childThreadApprovalsAtom(threadRef)); + if (approvals.length === 0) return null; + return ( +
+ {approvals.map((entry) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index c7919d658ccf..9d7aae58033f 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -1,3 +1,4 @@ +import { createChildThreadApprovalsAtom } from "@t3tools/client-runtime/state/thread-requests"; import { useAtomValue } from "@effect/atom-react"; import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections"; import { arrayElementsEqual } from "@t3tools/client-runtime/state/entities"; @@ -39,6 +40,10 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: threadEnvironment.snapshotAtom, }); +export const childThreadApprovalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: environmentThreadShells.childThreadInputsAtom, + pendingRequestsAtom: environmentThreadDetails.pendingRequestsAtom, +}); const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( Atom.withLabel("web-environment-thread:empty"), diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index a344d98f6451..3c330bc6bae9 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -18,7 +18,8 @@ and modes you choose in a draft keep their permissions. | **Full access** | Allows commands and edits without approval prompts. | Approve or reject requests in the conversation to let the agent continue. Permission modes do -not prevent the agent from asking questions about the task. +not prevent the agent from asking questions about the task. Subagent approval requests appear in +the parent conversation with the subagent's name, so you can respond without switching threads. ## Provider differences @@ -41,7 +42,9 @@ ACP Registry agents run their own tools in their own mode; T3 Code answers their by the permission mode. See [ACP Registry permissions](./providers-acp.md#permissions-and-terminals). Claude can still ask in **Full access** when its settings disable bypass permissions or require -approval for an action. +approval for an action. Dangerous-removal safety checks in bypass mode reject the command with +the original warning. Claude can use a safe rewrite when the warning supplies one; otherwise it +continues without the removal and tells you what it skipped. While a Claude approval is pending, type a text reply to decline that request and give feedback directly to the agent that asked, including a subagent. Other pending requests stay unanswered. diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index d5d2c4ec16d8..d9aaf9fdca71 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -1,5 +1,9 @@ +import { presentThreadShell } from "./models.ts"; +import { threadKey } from "./entities.ts"; import { EnvironmentId, + ThreadId, + type ScopedThreadRef, NodeId, ProviderSessionId, RuntimeRequestId, @@ -7,11 +11,13 @@ import { type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { AsyncResult } from "effect/reactivity"; +import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; import * as Cause from "effect/Cause"; -import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts"; +import { v2Now, v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { createQuestionHistoryProjector, + createChildThreadApprovalsAtom, + type PendingThreadRequests, derivePendingThreadRequests, declinePendingApprovals, sendApprovalFeedback, @@ -353,3 +359,59 @@ it("restores old text answers without mutating history or replacing unchanged ro expect(project(answered)).toBe(result); expect(project({ ...answered, visibleTurnItems: [...rows] })[0]).toBe(result[0]); }); + +it("loads only child approvals and retains their destination across environments", () => { + const local = EnvironmentId.make("local"); + const remote = EnvironmentId.make("remote"); + const parent = { environmentId: local, threadId: ThreadId.make("parent") }; + const childId = ThreadId.make("child"); + const children = Atom.make( + [local, remote].map((environmentId) => ({ + ...presentThreadShell(environmentId, v2ThreadShell), + id: childId, + title: environmentId, + hasPendingApprovals: true, + })), + ); + const details = Atom.family((key: string) => + Atom.make({ + approvals: [ + { + requestId: RuntimeRequestId.make(key), + requestKind: "command", + createdAt: "2026-10-19T00:00:00Z", + responseCapability: "live", + }, + ], + userInputs: [], + }), + ); + const readDetails = vi.fn((ref: ScopedThreadRef) => details(threadKey(ref))); + const approvalsAtom = createChildThreadApprovalsAtom({ + childThreadInputsAtom: () => children, + pendingRequestsAtom: readDetails, + }); + const registry = AtomRegistry.make(); + const unmount = registry.mount(approvalsAtom(parent)); + const pending = registry.get(approvalsAtom(parent)); + expect(pending.map((entry) => entry.thread)).toEqual( + [local, remote].map((environmentId) => ({ environmentId, threadId: childId })), + ); + expect(pending.map((entry) => entry.title)).toEqual(["local", "remote"]); + registry.set(details(threadKey({ environmentId: remote, threadId: childId })), { + approvals: [], + userInputs: [], + }); + expect(registry.get(approvalsAtom(parent))).toEqual([pending[0]]); + readDetails.mockClear(); + registry.set( + children, + registry + .get(children) + .map((child) => ({ ...child, hasPendingApprovals: false, hasPendingUserInput: true })), + ); + expect(registry.get(approvalsAtom(parent))).toEqual([]); + expect(readDetails).not.toHaveBeenCalled(); + unmount(); + registry.dispose(); +}); diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index e089eb36d08c..a010018c5051 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -1,5 +1,6 @@ import type { EnvironmentId, + ScopedThreadRef, ThreadId, OrchestrationV2ThreadProjection, OrchestrationV2RuntimeRequest, @@ -10,6 +11,9 @@ import type { ProviderRequestKind, RuntimeRequestId, } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { parseThreadKey, threadKey } from "./entities.ts"; +import type { EnvironmentThreadShell } from "./models.ts"; import * as DateTime from "effect/DateTime"; import type { AtomCommandResult } from "./runtime.ts"; import type { ThreadStructure, ThreadStructureVisibleTurnItem } from "./models.ts"; @@ -339,3 +343,48 @@ export function createQuestionHistoryProjector< return previousResult; }; } + +export interface ChildThreadApproval { + readonly thread: ScopedThreadRef; + readonly title: string; + readonly approval: ThreadPendingApproval; +} + +export function createChildThreadApprovalsAtom(input: { + readonly childThreadInputsAtom: ( + ref: ScopedThreadRef, + ) => Atom.Atom>; + readonly pendingRequestsAtom: (ref: ScopedThreadRef) => Atom.Atom; +}) { + const family = Atom.family((key: string) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next = get(input.childThreadInputsAtom(parseThreadKey(key))).flatMap((child) => { + if (!child.hasPendingApprovals) return []; + const thread = { environmentId: child.environmentId, threadId: child.id }; + // Only children asking for approval need a detail subscription. + return (get(input.pendingRequestsAtom(thread))?.approvals ?? []).map((approval) => ({ + thread, + title: child.title, + approval, + })); + }); + if ( + next.length === previous.length && + next.every((entry, index) => { + const old = previous[index]!; + return ( + old.approval === entry.approval && + old.title === entry.title && + threadKey(old.thread) === threadKey(entry.thread) + ); + }) + ) + return previous; + previous = next; + return next; + }); + }); + const empty = Atom.make>([]); + return (ref: ScopedThreadRef | null) => (ref === null ? empty : family(threadKey(ref))); +} From 460b88bc425f41ed5e7b5208a64b448f34098b9b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:11:49 -0800 Subject: [PATCH 003/188] feat(release): update notes lead with up to 7 highlights (#28593) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/updates/releaseNotes.test.ts | 20 ++++ apps/desktop/src/updates/releaseNotes.ts | 62 +++++----- .../SidebarUpdatePill.browser.test.tsx | 15 ++- .../sidebar/SidebarUpdateReleaseNotes.tsx | 71 +++++------ docs/operations/release.md | 2 +- packages/contracts/src/ipc.ts | 4 +- scripts/generate-release-notes.test.ts | 54 ++++++++- scripts/generate-release-notes.ts | 111 +++++++++++------- 8 files changed, 221 insertions(+), 118 deletions(-) diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 30db8274c208..6f05b1e9e94a 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -92,6 +92,26 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ]); }); + it.each([ + "**v1.2.4** · 3 changes from 2 contributors\n- **One-time tasks.** Pick a date.\n\n### Plus 2 more bug fixes and performance improvements.", + "

v1.2.4 · 3 changes from 2 contributors

  • One-time tasks. Pick a date.

Plus 2 more bug fixes and performance improvements.

", + ])("keeps highlight labels and the remaining change count from the feed", (summary) => { + const { releaseNotes } = normalizeDesktopUpdateReleaseNotes( + summary + "\n
Full changelograw list
", + "1.2.4", + "latest", + ); + expect(releaseNotes).toEqual([ + { + version: "1.2.4", + stats: "3 changes from 2 contributors", + items: ["**One-time tasks.** Pick a date."], + totalItems: 1, + footer: "Plus 2 more bug fixes and performance improvements.", + }, + ]); + }); + it("does not count Markdown or HTML section headings as changes", () => { const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`); const result = normalizeDesktopUpdateReleaseNotes( diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 1fb81088175c..a31989822209 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -54,17 +54,18 @@ function decodeHtmlEntities(input: string): string { ); } -function stripMarkup(input: string): string { - return decodeHtmlEntities( +function stripMarkup(input: string, keepBold = false): string { + const markdown = decodeHtmlEntities( input .replace(//gi, "\n") .replace(/]*>/gi, "\n- ") .replace(/]*>/gi, (_, level: string) => `\n${"#".repeat(Number(level))} `) .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") + .replace(/<\/?(?:strong|b)\b[^>]*>/gi, "**") .replace(/<[^>]*>/g, "") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1"), + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"), ); + return keepBold ? markdown : markdown.replace(/\*\*([^*]+)\*\*/g, "$1"); } function truncateReleaseNoteItem(item: string): string { @@ -92,40 +93,42 @@ function isIgnoredReleaseNoteLine(line: string): boolean { ); } -interface ExtractedReleaseNoteItems { - readonly items: ReadonlyArray; - readonly totalItems: number; - readonly intro?: string; - readonly stats?: string; -} +type ExtractedReleaseNoteItems = Omit; -/** - * Reads a generated summary (`scripts/generate-release-notes.ts`): a `v1.2.3 · N changes…` - * header, a highlights paragraph, and fix bullets. PR numbers are left to the GitHub page. - */ +/** Older generated notes have a highlights paragraph followed by a separate list of fixes. */ function extractReleaseNoteSummary(summary: string): ExtractedReleaseNoteItems { let stats: string | undefined; + let footer: string | undefined; const intro: string[] = []; const items: string[] = []; - for (const rawLine of stripMarkup(summary).split("\n")) { - const line = rawLine.trim().replace(/\s+/g, " "); - if (line === "" || /^#{1,6}\s+/.test(line)) continue; - if (stats === undefined && /^v\S+ · /.test(line)) { - stats = line.slice(line.indexOf(" · ") + 3); - continue; - } - const text = line - .replace(/^[-*]\s+/, "") + for (const rawLine of stripMarkup(summary, true).split("\n")) { + const line = rawLine + .trim() + .replace(/\s+/g, " ") .replace(/\s*\((?:#\d+(?:,\s*)?)+\)\.?$/, "") .trim(); - if (/^[-*]\s+/.test(line)) items.push(text); - else intro.push(text); + const plain = line.replace(/\*\*([^*]+)\*\*/g, "$1"); + if (/^### Plus \d+ more /.test(plain)) { + footer = plain.slice(4); + continue; + } + if (plain === "" || /^#{1,6}\s+/.test(plain)) continue; + if (stats === undefined && /^v\S+ · /.test(plain)) { + stats = plain.slice(plain.indexOf(" · ") + 3); + continue; + } + if (/^[-*]\s+/.test(line)) { + items.push(line.replace(/^[-*]\s+/, "")); + } else { + intro.push(plain); + } } return { items, totalItems: items.length + intro.length, ...(intro.length > 0 ? { intro: intro.join(" ") } : {}), ...(stats ? { stats } : {}), + ...(footer ? { footer } : {}), }; } @@ -184,16 +187,13 @@ export function normalizeDesktopUpdateReleaseNotes( ).filter((entry) => desktopUpdateChannelOf(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { - const { items, totalItems, intro, stats } = extractReleaseNoteItems(entry.note); - if (totalItems === 0) return []; + const note = extractReleaseNoteItems(entry.note); + if (note.totalItems === 0 && !note.stats) return []; const releasedAt = resolveReleasedAt(entry.version, fallbackVersion, releaseDate); return [ { version: entry.version, - items, - totalItems, - ...(intro ? { intro } : {}), - ...(stats ? { stats } : {}), + ...note, ...(releasedAt ? { releasedAt } : {}), }, ]; diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 18ebd70c6162..03bb2c72849e 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -100,8 +100,19 @@ describe("SidebarUpdatePill release notes", () => { await userEvent.tab(); expect(releaseNotes()).toBeNull(); - await publish(nightlyUpdate(RELEASE_NOTES)); - await vi.waitFor(() => expect(releaseNotes()).not.toBeNull()); + await publish( + nightlyUpdate([ + { + version: "0.0.50-nightly.2", + items: ["**One-time tasks.** Pick a date."], + totalItems: 1, + stats: "3 changes from 2 contributors", + footer: "Plus 2 more bug fixes.", + }, + ]), + ); + await expect.element(page.getByText("One-time tasks.", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Plus 2 more bug fixes.")).toBeVisible(); }); }); diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx index 974f81c8d76b..34c4403a20e5 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -101,48 +101,53 @@ export function SidebarUpdateReleaseNotes({ ? "View release on GitHub" : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`); - if (releaseNote.intro !== undefined) { - return ( -
0 ? "mt-4" : undefined} key={releaseNote.version}> - {releaseNote.version !== state.availableVersion ? ( + const generated = releaseNote.stats !== undefined || releaseNote.intro !== undefined; + return ( +
0 ? "mt-4" : undefined} key={releaseNote.version}> + {generated ? ( + releaseNote.version !== state.availableVersion && (

{releaseNote.releasedAt ? `${releaseNote.version} · ${formatReleaseAge(releaseNote.releasedAt)}` : releaseNote.version}

- ) : null} + ) + ) : ( +

+ {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} +

+ )} + {releaseNote.intro ? (

{releaseNote.intro}

- {releaseUrl ? ( - - {releaseNote.stats ?? "View release on GitHub"} - - ) : null} -
- ); - } - - return ( -
0 ? "mt-4" : undefined} key={releaseNote.version}> -
-

- {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} -

-
    - {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => ( -
  • - {item} -
  • - ))} + ) : releaseNote.items.length > 0 ? ( +
      + {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => { + const highlight = /^\*\*([^*]+)\*\* (.+)$/.exec(item); + return ( +
    • + {highlight ? ( + <> + {highlight[1]} {highlight[2]} + + ) : ( + item + )} +
    • + ); + })}
    - {releaseUrl ? ( - - {linkLabel} - - ) : null} -
-
+ ) : null} + {releaseNote.footer && ( +

{releaseNote.footer}

+ )} + {releaseUrl && ( + + {linkLabel} + + )} +
); })} {state.omittedReleaseCount > 0 ? ( diff --git a/docs/operations/release.md b/docs/operations/release.md index 18daadc5f278..29b9a2b9bfb5 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -42,7 +42,7 @@ the version. - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - - The release job's **Write release notes** step (`scripts/generate-release-notes.ts`) builds the body: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
`. It needs the `OPENAI_API_KEY` secret. The summary leaves out commits whose scopes are all mobile; the header counts every commit. The desktop update popover, on every channel, shows the highlights paragraph and the header counts as a short note per release, with the offered release's age from the update feed's `releaseDate`. The nightly Discord post shows the part above `
`. If the step fails or there is nothing to summarize, it writes no file and the release uses GitHub's generated notes as before. + - The release job's **Write release notes** step (`scripts/generate-release-notes.ts`) builds the body: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
`. It needs the `OPENAI_API_KEY` secret. The desktop update popover shows up to seven highlights and the number of other fixes and performance improvements. Mobile-only commits count toward release totals but cannot be highlights. The offered release's age comes from the update feed's `releaseDate`. The nightly Discord post shows the part above `
`. If summary generation fails, the release uses GitHub's generated notes. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. - Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but the Windows archives is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f14452fec5a9..0466f245844a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -290,10 +290,11 @@ export interface DesktopUpdateReleaseNote { totalItems: number; /** ISO time from the update feed for the offered version, else `YYYY-MM-DD` from a prerelease version. */ releasedAt?: string; - /** Generated summaries only: the highlights paragraph. `items` then holds the fixes. */ + /** Paragraph-style summaries from older releases keep their fixes in `items`. */ intro?: string; /** Generated summaries only, for example "275 changes from 22 contributors". */ stats?: string; + footer?: string; } export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ @@ -303,6 +304,7 @@ export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ releasedAt: Schema.optionalKey(Schema.String), intro: Schema.optionalKey(Schema.String), stats: Schema.optionalKey(Schema.String), + footer: Schema.optionalKey(Schema.String), }); export const DesktopUpdateStateSchema = Schema.Struct({ diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts index 0c55b082dcfb..7dbe4bf60ba3 100644 --- a/scripts/generate-release-notes.test.ts +++ b/scripts/generate-release-notes.test.ts @@ -4,8 +4,9 @@ import * as Option from "effect/Option"; import { countContributors, + formatReleaseSummary, isReleaseNoteCommit, - summaryText, + readReleaseHighlights, type PullRequest, } from "./generate-release-notes.ts"; @@ -53,13 +54,54 @@ it("leaves mobile-only scopes out and keeps shared ones", () => { } }); -it("uses model text only from a completed response", () => { +it("reads highlights only from a completed JSON response", () => { const output = [ - { type: "message", content: [{ type: "output_text", text: " ## Highlights\nX " }] }, + { type: "message", content: [{ type: "output_text", text: ' {"highlights":[]} ' }] }, ]; + assert.deepStrictEqual(readReleaseHighlights({ status: "completed", output }), Option.some([])); + assert.deepStrictEqual(readReleaseHighlights({ status: "incomplete", output }), Option.none()); +}); + +it("counts fixes and performance changes once, outside the selected highlights", () => { + const commits = [ + "feat: schedule a task once", + "fix: use the selected time", + "perf(server): read less history", + "fix(mobile): restore the chat position", + "docs: update instructions", + ].map((subject) => ({ subject, authorName: "" })); + const selected = [ + { label: "One-time tasks", detail: "Schedule a task for a specific time.", commits: [1, 2] }, + ] as const; + assert.deepStrictEqual( + formatReleaseSummary(commits, selected), + Option.some( + "- **One-time tasks.** Schedule a task for a specific time.\n\n### Plus 2 more bug fixes and performance improvements.", + ), + ); assert.deepStrictEqual( - summaryText({ status: "completed", output }), - Option.some("## Highlights\nX"), + formatReleaseSummary(commits.slice(0, 2), selected), + Option.some("- **One-time tasks.** Schedule a task for a specific time."), ); - assert.deepStrictEqual(summaryText({ status: "incomplete", output }), Option.none()); + assert.deepStrictEqual(formatReleaseSummary(commits.slice(2), selected), Option.none()); + assert.deepStrictEqual( + formatReleaseSummary(commits, []), + Option.some("**3 bug fixes and performance improvements.**"), + ); + assert.deepStrictEqual(formatReleaseSummary(commits.slice(-1), []), Option.some("")); +}); + +it("counts fixes beyond the seven displayed highlights in the footer", () => { + const commits = Array.from({ length: 8 }, (_, i) => ({ + subject: `fix: change ${i + 1}`, + authorName: "", + })); + const highlights = commits.map((_, i) => ({ + label: `Fix ${i + 1}`, + detail: "A fix.", + commits: [i + 1] as const, + })); + const summary = Option.getOrThrow(formatReleaseSummary(commits, highlights)); + assert.lengthOf(summary.match(/^- /gm) ?? [], 7); + assert.include(summary, "Plus 1 more bug fix."); }); diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index 909247dae126..b9d1121a6610 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -20,38 +20,15 @@ const PULL_REQUEST_NUMBER = /\(#(\d+)\)$/; const PULL_REQUEST_BODY_LIMIT = 300; const SUMMARY_MODEL = "gpt-6.1-terra"; -const SUMMARY_INSTRUCTIONS = `You write release notes for T3 Code, a GUI for coding agents (web and desktop apps backed by a server; providers like Codex, Claude, Cursor, OpenCode). +const SUMMARY_INSTRUCTIONS = `Write up to 7 bullet points for the T3 Code update popover. Cover web, desktop, and server. Mobile has its own release notes. -These notes cover the web app, desktop app, and server only. The mobile app has its own release notes, so leave out anything only about mobile, iOS, or Android. +Give a feature a bullet only when it lets users do something they could not do before, removes a real limitation they hit in normal use, or cuts meaningful steps or wait time from a common task. Judge the outcome, regardless of the commit prefix. A new setting, a large diff, or support for one more edge case is not enough. -You get the commit subjects merged since the last release, oldest first. Most end with a PR number like (#1234). Each commit shows lines added and removed and files changed, then a trimmed PR description on the next line starting with ">". Use the descriptions to understand what changed for users and why it matters. Line counts hint at size, but a small change can still matter most. +Lead with new capabilities. Give a bug fix its own bullet only when the source shows lost work or a core workflow that users could not complete in normal use. Leave isolated failures, cosmetic polish, and routine fixes in the count. Skip internal work, docs, tests, and dependency bumps. Group commits only when they deliver the same user outcome. Seven is a ceiling. Stop when the qualifying changes run out, even if that leaves zero bullets. -Output Markdown in exactly this shape and nothing else: +Each bullet has a short feature name and one sentence of at most 20 words. Say what the user can do or what now works. Use product names people see in the app and leave out protocol or implementation details. Describe the shipped behavior at the end of the range. Mark user-accessible Labs or beta features "(beta)". Skip dev-only features. No title, headings, introduction, example prompts, hype, PR numbers, or em dashes. -## Highlights -Up to 4 short sentences. Name only the new features a user would care about most. If no new feature matters to users, write one sentence about the most important fix. Each feature gets a few words that make the benefit clear, not a description of how it works or where the button is. - -## Fixes -- Up to 10 bullets, one fix each, under 10 words, ending with the PR number like (#123). Fewer is fine. Never pad. -- Each bullet names what was broken and says it works now. A user who hit the bug should recognize it. -- Only list fixes from fix commits. New abilities go in Highlights or nowhere. Say "again" only if the commit says it. - -How to write: -- Pick only significant, user-facing changes. Leave out visual polish (focus rings, spacing, colors), layout tweaks, refactors, tests, CI, build, docs, deps, internal tooling, and performance or efficiency work a user would not feel. -- Include Labs and beta features, with "(beta)" after the feature. Leave out work marked internal testing or dev-only. -- Describe each feature as it is at the end of the range. If a beta feature became standard later in the range, it is not beta. -- Say what the user gets, as short as possible. Cut details, conditions, and mechanics. -- No defaults or rollout state ("on by default", "now available", "behind a flag"). -- For lists of file types or formats, use common extensions: "previews for .docx, .ttf, .sqlite, .zip and more". -- No internal names, env vars, file paths, or code terms. -- No em dashes. No hype words. Do not invent anything that is not in the input. - -Examples of the tone: -- Bad: "Cmd+K now finds the right thread first, and it searches message text too." Good: "Much better Cmd+K search." -- Bad: "You can share a thread with a guest who watches it live and sends messages that you approve." Good: "Share threads with guests, safely." -- Bad: "Triage issues from the command palette." Good: "Fix and debug T3 Code itself with a new 'triage' command." -- Bad: "Settings and Usage open as large modals over your thread." Good: leave it out. -- Bad: "Cmd/Ctrl+W closes tabs safely (#25860)" Good: "Cmd/Ctrl+W no longer closes the whole window (#25860)"`; +Return JSON with a "highlights" array. For each highlight, give "label" (plain text, no final period), "detail" (one sentence), and "commits" (the bracketed input numbers of all commits it covers). Use the subjects and PR descriptions as source material. The release script adds the formatting, count of remaining fixes and performance improvements, and contributor credit.`; class EmptyReleaseRangeError extends Schema.TaggedError()( "EmptyReleaseRangeError", @@ -101,6 +78,49 @@ const SummaryResponse = Schema.Struct({ ), }); +const ReleaseHighlights = Schema.Struct({ + highlights: Schema.Array( + Schema.Struct({ + label: Schema.NonEmptyString, + detail: Schema.NonEmptyString, + commits: Schema.NonEmptyArray(Schema.Int), + }), + ), +}); +const decodeHighlights = Schema.decodeUnknownOption(Schema.fromJsonString(ReleaseHighlights)); + +export function formatReleaseSummary( + commits: ReadonlyArray, + selected: (typeof ReleaseHighlights.Type)["highlights"], +): Option.Option { + const highlights = selected.slice(0, 7); + const covered = new Set(highlights.flatMap((highlight) => highlight.commits)); + for (const id of covered) { + const commit = commits[id - 1]; + if (!commit || !isReleaseNoteCommit(commit)) return Option.none(); + } + const bullets = highlights.map( + ({ label, detail }) => `- **${label.replace(/\.$/, "")}.** ${detail}`, + ); + const remaining = commits.flatMap(({ subject }, index) => { + if (covered.has(index + 1)) return []; + return /^(fix|perf)(?:\([^)]*\))?!?:/i.exec(subject)?.[1]?.toLowerCase() ?? []; + }); + if (remaining.length === 0) return Option.some(bullets.join("\n")); + + const count = remaining.length; + let kind = "bug fixes and performance improvements"; + if (!remaining.includes("perf")) kind = count === 1 ? "bug fix" : "bug fixes"; + if (!remaining.includes("fix")) + kind = count === 1 ? "performance improvement" : "performance improvements"; + // Older popovers skip headings. A footer paragraph would hide the bullets on those clients. + return Option.some( + bullets.length + ? `${bullets.join("\n")}\n\n### Plus ${count} more ${kind}.` + : `**${count} ${kind}.**`, + ); +} + const pullRequestNumber = (subject: string) => { const match = PULL_REQUEST_NUMBER.exec(subject); return match ? Number(match[1]) : undefined; @@ -129,11 +149,14 @@ function buildSummaryInput( pullRequests: ReadonlyMap, ): string { return commits - .flatMap(({ subject }) => { + .flatMap((commit, index) => { + if (!isReleaseNoteCommit(commit)) return []; + const { subject } = commit; + const source = `[${index + 1}] ${subject}`; const number = pullRequestNumber(subject); const pullRequest = number === undefined ? undefined : pullRequests.get(number); - if (!pullRequest) return [subject]; - const line = `${subject} [+${pullRequest.additions} -${pullRequest.deletions}, ${pullRequest.changedFiles} files]`; + if (!pullRequest) return [source]; + const line = `${source} [+${pullRequest.additions} -${pullRequest.deletions}, ${pullRequest.changedFiles} files]`; const body = trimPullRequestBody(pullRequest.body); return body ? [line, ` > ${body}`] : [line]; }) @@ -175,7 +198,8 @@ function buildReleaseHeader(version: string, changes: number, contributors: numb /** The summary leads; GitHub's full list folds under it. Clients cut the body at `
`. */ function assembleReleaseNotes(header: string, summary: string, fullChangelog: string): string { - return `${header}\n\n${summary.trim()}\n\n
\nFull changelog\n\n${fullChangelog.trim()}\n\n
\n`; + const folded = `
\nFull changelog\n\n${fullChangelog.trim()}\n\n
\n`; + return [header, summary, folded].filter(Boolean).join("\n\n"); } const run = Effect.fn("generateReleaseNotes.run")(function* ( @@ -261,7 +285,7 @@ const readFullChangelog = Effect.fn("generateReleaseNotes.readFullChangelog")(fu return (yield* decodeJson(GeneratedNotes)(response)).body; }); -export function summaryText(response: typeof SummaryResponse.Type): Option.Option { +export function readReleaseHighlights(response: typeof SummaryResponse.Type) { // An incomplete response can carry partial text; publishing it would cut the notes short. if (response.status !== "completed") return Option.none(); const text = response.output @@ -269,7 +293,7 @@ export function summaryText(response: typeof SummaryResponse.Type): Option.Optio .flatMap((part) => (part.type === "output_text" && part.text ? [part.text] : [])) .join("") .trim(); - return text === "" ? Option.none() : Option.some(text); + return Option.map(decodeHighlights(text), ({ highlights }) => highlights); } const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: string) { @@ -281,7 +305,8 @@ const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: model: SUMMARY_MODEL, reasoning: { effort: "low" }, instructions: SUMMARY_INSTRUCTIONS, - input: `Commits:\n${input}`, + text: { format: { type: "json_object" } }, + input: `Choose highlights as JSON from these commits:\n${input}`, }), Effect.flatMap(httpClient.execute), Effect.flatMap(HttpClientResponse.filterStatusOk), @@ -296,7 +321,7 @@ const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: outputTokens: response.usage?.output_tokens, }), ); - return summaryText(response); + return readReleaseHighlights(response); }); const command = Command.make( @@ -311,13 +336,11 @@ const command = Command.make( }, ({ repository, tag, ref, previousTag, version, output }) => Effect.gen(function* () { - // The header counts every commit in the range; the summary skips mobile-only work. const allCommits = yield* readCommits(previousTag, ref); // The spawner ignores exit codes, so a failed git log shows up as an empty range. if (allCommits.length === 0) { return yield* new EmptyReleaseRangeError({ range: `${previousTag}..${ref}` }); } - const commits = allCommits.filter(isReleaseNoteCommit); const numbers = [ ...new Set(allCommits.flatMap(({ subject }) => pullRequestNumber(subject) ?? [])), ]; @@ -333,11 +356,11 @@ const command = Command.make( allCommits.length, countContributors(allCommits, pullRequests), ); - // With no summary, write nothing: the release falls back to GitHub's generated notes. - const summary = - commits.length === 0 - ? Option.none() - : yield* summarize(buildSummaryInput(commits, pullRequests)); + const input = buildSummaryInput(allCommits, pullRequests); + const selected = input === "" ? Option.some([]) : yield* summarize(input); + const summary = Option.flatMap(selected, (highlights) => + formatReleaseSummary(allCommits, highlights), + ); if (Option.isNone(summary)) { return yield* Effect.logWarning("no release summary, leaving GitHub's notes in place"); } @@ -349,7 +372,7 @@ const command = Command.make( ); yield* fs.rename(`${output}.tmp`, output); yield* Effect.logInfo("release notes written").pipe( - Effect.annotateLogs({ output, commits: commits.length }), + Effect.annotateLogs({ output, commits: allCommits.length }), ); }), ).pipe( From 1280ccd0d2a8525c840999fe633cff20ebdeafd5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:20:21 -0800 Subject: [PATCH 004/188] test(server): pin clock in saved-usage window test (#28603) Co-authored-by: Claude Opus 5.5 --- apps/server/src/usage/UsageService.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 07a78dc6d50d..fbb1aaf87261 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2640,6 +2640,8 @@ describe("UsageService", () => { it.live("reports saved usage of a removed directory only for windows it reaches", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; + // Saved usage is pruned after the retention window, so pin "now" near the write. + yield* TestClock.setTime(Date.parse("2026-08-15T00:00:00Z")); yield* Effect.promise(async () => { await NodeFSP.writeFile(transcript, claudeLine(1, 5)); const lastWrite = Date.parse("2026-08-01T10:00:00Z") / 1000; @@ -2665,7 +2667,7 @@ describe("UsageService", () => { assert.deepStrictEqual(later.buckets, []); assert.strictEqual(later.sources[0]?.status, "missing"); assert.strictEqual(later.sources[0]?.scannedFiles, 0); - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); it.live("does not share an in-flight scan after custom prices change", () => From da216062c959b9fbf1bd06fb098d76e14d5f9350 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:28:56 -0800 Subject: [PATCH 005/188] fix(web): sidebar project filter no longer refocuses after a pick (#28605) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4fa3f06e7175..deede26763fd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3057,10 +3057,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve )} + {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} closeType === "keyboard"} > Date: Sun, 1 Nov 2026 04:42:13 -0600 Subject: [PATCH 006/188] feat(desktop): prepare server bytecode cache in native builds (#28572) --- .../scripts/main-process-bundle.test.mjs | 11 +- apps/desktop/src/compileCache.ts | 11 +- apps/desktop/src/serverCompileCache.ts | 1 + apps/desktop/vite.config.ts | 2 +- apps/server/src/compileCache.ts | 8 +- docs/operations/development.md | 22 +++ knip.jsonc | 2 + scripts/build-desktop-artifact.ts | 1 + scripts/lib/server-compile-cache.test.ts | 108 ++++++++++++++ scripts/lib/server-compile-cache.ts | 136 ++++++++++++++++++ scripts/prepare-server-compile-cache.cjs | 55 +++++++ 11 files changed, 345 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/serverCompileCache.ts create mode 100644 scripts/lib/server-compile-cache.test.ts create mode 100644 scripts/lib/server-compile-cache.ts create mode 100644 scripts/prepare-server-compile-cache.cjs diff --git a/apps/desktop/scripts/main-process-bundle.test.mjs b/apps/desktop/scripts/main-process-bundle.test.mjs index 678b62f58aac..6e1f767d7d39 100644 --- a/apps/desktop/scripts/main-process-bundle.test.mjs +++ b/apps/desktop/scripts/main-process-bundle.test.mjs @@ -3,6 +3,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeVM from "node:vm"; +import * as NodeURL from "node:url"; import { build } from "vite-plus/pack"; import { assert, it } from "vite-plus/test"; @@ -114,18 +115,16 @@ it("loads the emitted packaged boot entry and backend cache preload", async () = const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-")); try { const entries = ["src/boot.ts", "src/compileCache.ts"]; - await NodeFSP.mkdir(NodePath.join(directory, "src")); - await Promise.all( - entries.map((entry) => - NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)), - ), - ); assert.ok(Array.isArray(desktopConfig.pack)); for (const packConfig of desktopConfig.pack) { if (!Array.isArray(packConfig.entry)) continue; if (!packConfig.entry.some((entry) => entries.includes(entry))) continue; await build({ ...packConfig, + entry: packConfig.entry.map((entry) => + NodeURL.fileURLToPath(new URL(`../${entry}`, import.meta.url)), + ), + outDir: NodePath.join(directory, "dist-electron"), config: false, cwd: directory, tsconfig: false, diff --git a/apps/desktop/src/compileCache.ts b/apps/desktop/src/compileCache.ts index abf8918df2af..7bbd9adc7f40 100644 --- a/apps/desktop/src/compileCache.ts +++ b/apps/desktop/src/compileCache.ts @@ -2,16 +2,23 @@ import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import { enablePreparedServerCache } from "../../../scripts/lib/server-compile-cache.ts"; // Linux /tmp is shared; macOS and Windows temp dirs are per user. // AppImage mounts change paths each launch, so Node's path-keyed cache would miss and accumulate copies. try { - if (!process.env.APPIMAGE) { + if (!process.env.APPIMAGE || process.env.ELECTRON_RUN_AS_NODE === "1") { const cacheRoot = // oxlint-disable-next-line t3code/no-global-process-runtime -- Loads before any Effect runtime. process.platform === "linux" ? process.env.XDG_CACHE_HOME || NodePath.join(NodeOS.homedir(), ".cache") : NodeOS.tmpdir(); - NodeModule.enableCompileCache(NodePath.join(cacheRoot, "t3code", "compile-cache")); + const directory = NodePath.join(cacheRoot, "t3code", "compile-cache"); + const prepared = + process.env.ELECTRON_RUN_AS_NODE === "1" && + enablePreparedServerCache(process.argv[1] ?? "", directory); + if (!prepared && !process.env.APPIMAGE) { + NodeModule.enableCompileCache(directory); + } } } catch {} diff --git a/apps/desktop/src/serverCompileCache.ts b/apps/desktop/src/serverCompileCache.ts new file mode 100644 index 000000000000..01e71b1aa872 --- /dev/null +++ b/apps/desktop/src/serverCompileCache.ts @@ -0,0 +1 @@ +export { generatePreparedServerCache } from "../../../scripts/lib/server-compile-cache.ts"; diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 55c3aa03b649..1160d9011275 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -93,7 +93,7 @@ export default defineConfig({ dts: false, sourcemap: true, outExtensions: () => ({ js: ".cjs" }), - entry: ["src/boot.ts", "src/compileCache.ts"], + entry: ["src/boot.ts", "src/compileCache.ts", "src/serverCompileCache.ts"], clean: false, deps: { neverBundle: (id) => id === "./main.cjs" || id === "./compileCache.cjs", diff --git a/apps/server/src/compileCache.ts b/apps/server/src/compileCache.ts index 0a7345995a81..b01db74cbe34 100644 --- a/apps/server/src/compileCache.ts +++ b/apps/server/src/compileCache.ts @@ -2,6 +2,8 @@ import * as NodeModule from "node:module"; import * as Effect from "effect/Effect"; // Windows force-kills on quit; persist the cache after startup. -export const flushCompileCache = Effect.try(() => NodeModule.flushCompileCache()).pipe( - Effect.ignore, -); +export const flushCompileCache = Effect.try(() => { + if (!Reflect.get(globalThis, Symbol.for("t3code.preparedServerCache"))) { + NodeModule.flushCompileCache(); + } +}).pipe(Effect.ignore); diff --git a/docs/operations/development.md b/docs/operations/development.md index df4affcad2ae..537e574978fa 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -261,6 +261,28 @@ DMGs default to the host architecture. Use `--arch` to choose another target and to retain packaging files for inspection. Run `vp run dist:desktop:artifact --help` for other options. +### Prepared server bytecode + +Native desktop artifact builds generate server bytecode with the packaged Electron executable, +after ASAR creation and before signing. Cross-platform and cross-architecture builds skip this +step explicitly. CLI, SEA and WSL runtimes keep their existing behavior; only desktop backend +imports covered by generation receive prepared bytecode. Worker-only modules compile normally. + +Electron's Node 24 cache has no read-only option and includes the producer UID in its directory +name. The backend links individual prepared files into its writable cache with keys for the +installed module paths. Node replaces files atomically, so regeneration cannot write through those +links into signed resources. Windows uses hard links and falls back to normal caching when volumes +differ or resource permissions prevent linking. Protected installations therefore may not reuse +prepared bytecode. No cache bytes are copied during startup. AppImage backends discard their links +on normal exit because each mount changes module paths. + +The key calculation follows Node's internal portable ESM format. Generation checks that the +packaged engine produced the expected CLI entry and fails the build if that format changes. +The manifest also checks Electron, Node, OS, architecture and V8's flags-sensitive version tag. +Missing or rejected entries compile from the shipped JavaScript. Prepared backends skip the +explicit startup flush; Node can persist missing entries on normal exit. The writable fallback +keeps the explicit flush needed when Windows force-kills the backend. + ### Linux AppImage prerequisites Build on Linux because the browser-secret helper links against the host's libsecret. Install diff --git a/knip.jsonc b/knip.jsonc index c2093737fd94..615581793fb3 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -32,6 +32,7 @@ "mobile-native-client.ts", "setup-worktree.ts", "sign-macos.ts", + "prepare-server-compile-cache.cjs", "smoke-desktop-app.ts", "verify-windows-desktop-artifact.ts", ], @@ -73,6 +74,7 @@ "src/snapShot/SnapShotAccessibilityWorker.ts!", "src/boot.ts!", "src/compileCache.ts!", + "src/serverCompileCache.ts!", "src/main.ts!", "src/preload.ts!", "src/preview-pick-preload.ts!", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 8ba7f1adcbbd..5de3c068fc6c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2771,6 +2771,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( arch?: typeof BuildArch.Type, ) { const buildConfig: Record = { + afterPack: (yield* Path.Path).join(yield* RepoRoot, "scripts/prepare-server-compile-cache.cjs"), appId: DESKTOP_APP_ID, productName: resolveDesktopProductName(version), artifactName: "T3-Code-${version}-${arch}.${ext}", diff --git a/scripts/lib/server-compile-cache.test.ts b/scripts/lib/server-compile-cache.test.ts new file mode 100644 index 000000000000..1092de3ec519 --- /dev/null +++ b/scripts/lib/server-compile-cache.test.ts @@ -0,0 +1,108 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it } from "vite-plus/test"; +import { cacheKey, seedPreparedCache } from "./server-compile-cache.ts"; + +it("seeds relocated cache entries without allowing writes into shipped resources", () => { + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-prepared-test-")), + ); + try { + const prepared = NodePath.join(root, "resources", "server-compile-cache"); + const active = NodePath.join(root, "user", "different-uid-tag"); + NodeFS.mkdirSync(prepared, { recursive: true }); + NodeFS.mkdirSync(active, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(prepared, "12345678"), "prepared bytes"); + const manifest = { + format: 1, + runtime: "matching", + entries: [{ module: "app.asar/apps/server/dist/chunk.mjs", file: "12345678" }], + }; + NodeFS.writeFileSync(NodePath.join(prepared, "manifest.json"), JSON.stringify(manifest)); + expect(seedPreparedCache(prepared, active, "wrong-engine")).toBe(false); + expect(seedPreparedCache(prepared, active, "matching")).toBe(true); + const key = cacheKey(active, NodePath.join(root, "resources", manifest.entries[0]!.module)); + expect(NodeFS.readFileSync(NodePath.join(active, key), "utf8")).toBe("prepared bytes"); + // Node persists by replacing the directory entry, never by overwriting the linked inode. + NodeFS.rmSync(NodePath.join(active, key)); + NodeFS.writeFileSync(NodePath.join(active, key), "runtime replacement"); + expect(NodeFS.readFileSync(NodePath.join(prepared, "12345678"), "utf8")).toBe("prepared bytes"); + expect(seedPreparedCache(prepared, active, "matching")).toBe(true); + expect(NodeFS.readFileSync(NodePath.join(active, key), "utf8")).toBe("runtime replacement"); + const missing = { module: "app.asar/apps/server/dist/missing.mjs", file: "87654321" }; + for (const entries of [ + [...manifest.entries, missing], + [missing, ...manifest.entries], + ]) { + NodeFS.writeFileSync( + NodePath.join(prepared, "manifest.json"), + JSON.stringify({ ...manifest, entries }), + ); + expect(seedPreparedCache(prepared, active, "matching")).toBe(false); + } + NodeFS.writeFileSync(NodePath.join(prepared, "manifest.json"), "corrupt"); + expect(seedPreparedCache(prepared, active, "matching")).toBe(false); + expect(seedPreparedCache(NodePath.join(root, "missing"), active, "matching")).toBe(false); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it.each(["app", "server"])( + "generates %s.asar cache with the consumer engine and falls back after damage", + async (archive) => { + const { spawnSync } = await import("node:child_process"); + const helper = new URL("./server-compile-cache.ts", import.meta.url).href; + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cache-engine-")), + ); + const run = (code: string) => { + const env = { + ...process.env, + NODE_DEBUG_NATIVE: "COMPILE_CACHE", + NODE_COMPILE_CACHE: undefined, + NODE_DISABLE_COMPILE_CACHE: undefined, + }; + const result = spawnSync(process.execPath, ["--input-type=module", "-e", code], { + env, + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + return result.stderr; + }; + try { + const producer = NodePath.join(root, "producer", "resources"); + const consumer = NodePath.join(root, "relocated", "resources"); + const relative = `${archive}.asar/apps/server/dist/binCli-fixture.mjs`; + NodeFS.mkdirSync(NodePath.dirname(NodePath.join(producer, relative)), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(producer, relative), "export const answer = 42;"); + run( + `import {generatePreparedServerCache} from ${JSON.stringify(helper)}; await generatePreparedServerCache(${JSON.stringify(producer)});`, + ); + NodeFS.cpSync(producer, consumer, { recursive: true }); + const consume = (home: string, appImage = false) => + run( + `if (${appImage}) process.env.APPIMAGE = "fixture"; import {enablePreparedServerCache} from ${JSON.stringify(helper)}; import {pathToFileURL} from 'node:url'; if(!enablePreparedServerCache(${JSON.stringify(NodePath.join(consumer, relative))},${JSON.stringify(NodePath.join(root, home))})) throw Error('cache disabled'); const m = await import(pathToFileURL(${JSON.stringify(NodePath.join(consumer, relative))}).href); if(m.answer!==42) throw Error('source fallback failed');`, + ); + const prepared = NodePath.join(consumer, "server-compile-cache"); + NodeFS.chmodSync(prepared, 0o555); + expect(consume("first-user")).toContain("was accepted"); + expect(consume("appimage-user", true)).toContain("was accepted"); + expect(NodeFS.readdirSync(NodePath.join(root, "appimage-user", "server"))).toEqual([]); + NodeFS.chmodSync(prepared, 0o755); + for (const file of NodeFS.readdirSync(prepared).filter((file) => file !== "manifest.json")) { + NodeFS.writeFileSync(NodePath.join(prepared, file), "broken"); + } + expect(consume("second-user")).toContain("was not initialized"); + for (const file of NodeFS.readdirSync(prepared).filter((file) => file !== "manifest.json")) { + expect(NodeFS.readFileSync(NodePath.join(prepared, file), "utf8")).toBe("broken"); + } + NodeFS.rmSync(prepared, { recursive: true }); + expect(consume("third-user")).toContain("was not initialized"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }, +); diff --git a/scripts/lib/server-compile-cache.ts b/scripts/lib/server-compile-cache.ts new file mode 100644 index 000000000000..63a21174ff65 --- /dev/null +++ b/scripts/lib/server-compile-cache.ts @@ -0,0 +1,136 @@ +/* oxlint-disable t3code/no-global-process-runtime -- Runs in the packaged engine before an Effect runtime exists. */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeModule from "node:module"; +import * as NodeV8 from "node:v8"; +import * as NodeZlib from "node:zlib"; +import * as NodeURL from "node:url"; + +export const runtimeIdentity = () => + [ + process.versions.electron, + process.version, + process.platform, + process.arch, + NodeV8.cachedDataVersionTag(), + ].join("-"); + +// Node 24's portable key is CRC32 of the one-byte ESM type followed by the relative filename. +// Generation verifies this against files produced by the packaged engine before shipping them. +export const cacheKey = (active: string, filename: string) => + NodeZlib.crc32(NodePath.relative(active, filename), NodeZlib.crc32(Buffer.from([1]))) + .toString(16) + .padStart(8, "0"); + +export function seedPreparedCache(prepared: string, active: string, runtime: string): boolean { + try { + const manifest: unknown = JSON.parse( + NodeFS.readFileSync(NodePath.join(prepared, "manifest.json"), "utf8"), + ); + if ( + typeof manifest !== "object" || + manifest === null || + !("format" in manifest) || + manifest.format !== 1 || + !("runtime" in manifest) || + manifest.runtime !== runtime || + !("entries" in manifest) || + !Array.isArray(manifest.entries) || + manifest.entries.length === 0 + ) + return false; + for (const entry of manifest.entries) { + if ( + typeof entry?.module !== "string" || + typeof entry?.file !== "string" || + !/^[a-f0-9]{8}$/.test(entry.file) || + !/^(app|server)\.asar\/apps\/server\/dist\/[^/\\]+\.mjs$/.test(entry.module) + ) + return false; + const source = NodePath.join(prepared, entry.file); + const destination = NodePath.join( + active, + cacheKey(active, NodePath.join(NodePath.dirname(prepared), entry.module)), + ); + try { + // Node replaces cache files atomically. A directory link would instead allow writes into signed resources. + if (process.platform === "win32") NodeFS.linkSync(source, destination); + else NodeFS.symlinkSync(source, destination); + } catch {} + if (!NodeFS.existsSync(destination)) return false; + } + return true; + } catch { + return false; + } +} + +export function enablePreparedServerCache(entry: string, cacheRoot: string): boolean { + try { + const archive = entry.match(/^(.*[\\/](?:app|server)\.asar)[\\/]/)?.[1]; + if (!archive) return false; + const resources = NodeFS.realpathSync(NodePath.dirname(archive)); + // Keep different installations and builds separate without reading or copying their source files. + const prepared = NodePath.join(resources, "server-compile-cache"); + if (process.env.APPIMAGE && !NodeFS.existsSync(NodePath.join(prepared, "manifest.json"))) + return false; + const identity = NodeZlib.crc32(resources).toString(16); + NodeFS.mkdirSync(NodePath.join(cacheRoot, "server"), { recursive: true }); + const directory = process.env.APPIMAGE + ? NodeFS.mkdtempSync(NodePath.join(cacheRoot, "server", "appimage-")) + : NodePath.join(cacheRoot, "server", identity); + if (process.env.APPIMAGE) { + // Each mount has different module paths; discard its links rather than accumulating caches. + process.once("exit", () => { + try { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } catch {} + }); + } + const result = NodeModule.enableCompileCache({ + directory, + portable: true, + }); + const active = NodeModule.getCompileCacheDir(); + if (result.status !== NodeModule.constants.compileCacheStatus.ENABLED || !active) return false; + const seeded = seedPreparedCache(prepared, active, runtimeIdentity()); + if (seeded) Reflect.set(globalThis, Symbol.for("t3code.preparedServerCache"), true); + return true; + } catch { + return false; + } +} + +export async function generatePreparedServerCache(resourcesInput: string): Promise { + const resources = NodeFS.realpathSync(resourcesInput); + const prepared = NodePath.join(resources, "server-compile-cache"); + const archive = NodeFS.existsSync(NodePath.join(resources, "server.asar")) + ? "server.asar" + : "app.asar"; + const dist = NodePath.join(resources, archive, "apps/server/dist"); + NodeFS.mkdirSync(prepared, { recursive: true }); + const result = NodeModule.enableCompileCache({ directory: prepared, portable: true }); + if (result.status !== NodeModule.constants.compileCacheStatus.ENABLED) + throw new Error("Cannot enable release compile cache"); + const cli = NodeFS.readdirSync(dist).find((file) => /^binCli-.*\.mjs$/.test(file)); + if (!cli) throw new Error("Missing bundled server CLI"); + await import(NodeURL.pathToFileURL(NodePath.join(dist, cli)).href); + NodeModule.flushCompileCache(); + const active = NodeModule.getCompileCacheDir()!; + const entries = NodeFS.readdirSync(dist) + .filter((file) => file.endsWith(".mjs")) + .flatMap((file) => { + const key = cacheKey(active, NodePath.join(dist, file)); + if (!NodeFS.existsSync(NodePath.join(active, key))) return []; + NodeFS.copyFileSync(NodePath.join(active, key), NodePath.join(prepared, key)); + return [{ module: `${archive}/apps/server/dist/${file}`, file: key }]; + }); + if (!entries.some((entry) => entry.module.endsWith(`/${cli}`))) + throw new Error("Packaged engine cache key format is unsupported"); + NodeFS.writeFileSync( + NodePath.join(prepared, "manifest.json"), + JSON.stringify({ format: 1, runtime: runtimeIdentity(), entries }), + ); + NodeFS.rmSync(active, { recursive: true }); +} diff --git a/scripts/prepare-server-compile-cache.cjs b/scripts/prepare-server-compile-cache.cjs new file mode 100644 index 000000000000..cc3a28b4ed91 --- /dev/null +++ b/scripts/prepare-server-compile-cache.cjs @@ -0,0 +1,55 @@ +/* oxlint-disable t3code/no-global-process-runtime -- Packaging must compare the build host with the executable target. */ +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); + +// afterPack runs before signing and installer creation, against the final ASAR layout. +module.exports = async ({ appOutDir, electronPlatformName, arch, packager }) => { + const targetArch = ["ia32", "x64", "armv7l", "arm64", "universal"][arch]; + if (electronPlatformName !== process.platform || targetArch !== process.arch) { + console.log( + `[server-compile-cache] Skipping unexecutable target ${electronPlatformName}/${targetArch}`, + ); + return; + } + const product = packager.appInfo.productFilename; + const resources = + electronPlatformName === "darwin" + ? path.join(appOutDir, `${product}.app`, "Contents", "Resources") + : path.join(appOutDir, "resources"); + const executable = + electronPlatformName === "darwin" + ? path.join(appOutDir, `${product}.app`, "Contents", "MacOS", product) + : path.join( + appOutDir, + electronPlatformName === "win32" ? `${product}.exe` : packager.executableName, + ); + const helper = path.join( + resources, + "app.asar", + "apps/desktop/dist-electron/serverCompileCache.cjs", + ); + const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1" }; + for (const name of Object.keys(env)) { + if (name.startsWith("NODE_") || name.startsWith("T3CODE_")) delete env[name]; + } + const state = fs.mkdtempSync(path.join(os.tmpdir(), "t3-release-cache-")); + env.T3CODE_HOME = state; + let result; + try { + result = spawnSync( + executable, + [ + "-e", + `require(${JSON.stringify(helper)}).generatePreparedServerCache(${JSON.stringify(resources)}).then(()=>process.exit(0),e=>{console.error(e);process.exit(1)})`, + ], + { env, encoding: "utf8", timeout: 120000 }, + ); + } finally { + fs.rmSync(state, { recursive: true, force: true }); + } + if (result.status !== 0) + throw new Error(`Server compile cache generation failed: ${result.error || result.stderr}`); + console.log("[server-compile-cache] Prepared server bytecode with packaged Electron"); +}; From 8b4de61b8b4fa902873ec0d0cd88aef2bc3716a7 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 1 Nov 2026 05:04:28 -0600 Subject: [PATCH 007/188] fix(desktop): discover shell environment without blocking startup (#28556) --- .../DesktopBackendConfiguration.test.ts | 4 + .../backend/DesktopBackendConfiguration.ts | 1 + apps/desktop/src/main.ts | 2 +- .../src/shell/DesktopShellEnvironment.test.ts | 279 +++++++++++++++++- .../src/shell/DesktopShellEnvironment.ts | 182 ++++++++++-- .../DesktopTelemetryPublisher.test.ts | 89 ++++-- .../telemetry/DesktopTelemetryPublisher.ts | 4 +- .../DesktopTelemetryReceiver.test.ts | 185 +++++++++++- .../DesktopTelemetryReceiver.ts | 77 +++++ .../src/telemetry/AnalyticsService.test.ts | 26 ++ apps/server/src/telemetry/AnalyticsService.ts | 25 +- apps/server/src/usage/UsageService.test.ts | 1 + packages/contracts/src/resourceTelemetry.ts | 40 +++ packages/shared/src/shell.test.ts | 48 +++ packages/shared/src/shell.ts | 17 +- 15 files changed, 914 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 7464d8fbe9e2..26f6dbe02686 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -22,6 +23,8 @@ import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; const layerShellEnvironment = (prepared = true) => Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(prepared), + awaitDiscovery: Effect.succeed(prepared), + updates: Stream.empty, }); const layerBackendConfiguration = (prepared = true) => @@ -237,6 +240,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(first.cwd, environment.backendCwd); assert.equal(first.captureOutput, true); assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); + assert.equal(first.env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING, "1"); assert.isUndefined(first.env.T3CODE_PORT); assert.isUndefined(first.env.T3CODE_MODE); assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 492903139547..9c8186711f6e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -532,6 +532,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", + T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1", T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), }, extendEnv: true, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9a3c327e6b86..2890f164f6f1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -186,10 +186,10 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe( const layerDesktopBackend = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), - Layer.provideMerge(DesktopShellEnvironment.layer), Layer.provideMerge(DesktopWslEnvironment.layer), Layer.provideMerge(DesktopWslServerTree.layer), Layer.provideMerge(DesktopTelemetryPublisher.layer), + Layer.provideMerge(DesktopShellEnvironment.layer), Layer.provideMerge(layerDesktopWindow), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 33325d9de990..79344567fcd6 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -3,11 +3,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeOS from "node:os"; @@ -19,6 +23,15 @@ import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; vi.mock("node:os", { spy: true }); const textEncoder = new TextEncoder(); +const cacheCodec = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Number, + key: Schema.String, + environment: Schema.Record(Schema.String, Schema.NullOr(Schema.String)), + }), +); +const encodeCache = Schema.encodeSync(cacheCodec); +const decodeCache = Schema.decodeUnknownEffect(cacheCodec); function envOutput(values: Readonly>): string { return Object.entries(values) @@ -69,18 +82,28 @@ function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; readonly userShell?: string; + readonly stateDir?: string; + readonly use?: ( + service: DesktopShellEnvironment.DesktopShellEnvironment["Service"], + ) => Effect.Effect; readonly handler: ( command: ChildProcess.Command, ) => string | { output: string; exitCode: number }; readonly failure?: PlatformError.PlatformError; readonly processSpawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; }) { - const layerEnvironment = Layer.succeed( + const layerEnvironment = Layer.effect( DesktopEnvironment.DesktopEnvironment, - DesktopEnvironment.DesktopEnvironment.of({ - platform: input.platform, - } as DesktopEnvironment.DesktopEnvironment["Service"]), - ); + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = input.stateDir ?? (yield* fs.makeTempDirectoryScoped()); + return DesktopEnvironment.DesktopEnvironment.of({ + platform: input.platform, + stateDir, + homeDirectory: "/test-home", + } as DesktopEnvironment.DesktopEnvironment["Service"]); + }), + ).pipe(Layer.provide(NodeServices.layer)); const layerSpawner = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { @@ -97,8 +120,9 @@ function runShellEnvironment(input: { const program = Effect.gen(function* () { const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; - const prepared = yield* shellEnvironment.installIntoProcess; - assert.equal(yield* shellEnvironment.installIntoProcess, prepared); + if (input.use) return yield* input.use(shellEnvironment); + const prepared = yield* shellEnvironment.awaitDiscovery; + assert.equal(yield* shellEnvironment.awaitDiscovery, prepared); return prepared; }).pipe( Effect.provide( @@ -121,6 +145,247 @@ function runShellEnvironment(input: { } describe("DesktopShellEnvironment", () => { + it.effect("does not wait for discovery when the cache is missing", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + yield* runShellEnvironment({ + env: { SHELL: "/test-shell", PATH: "/usr/bin" }, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(makeProcess(envOutput({ PATH: "/fresh/bin" }))), + ), + ), + use: (service) => + Effect.gen(function* () { + const startup = yield* service.installIntoProcess.pipe(Effect.forkChild); + yield* Deferred.await(started); + assert.isDefined(startup.pollUnsafe()); + yield* Deferred.succeed(release, undefined); + return yield* service.awaitDiscovery; + }), + }); + }), + ); + + it.effect( + "reuses cached paths immediately and streams fresh discovery without keeping removed paths", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner.pipe( + Effect.provide(NodeServices.layer), + ); + const executable = yield* HostProcess.ExecutablePath; + const first = { SHELL: "/bin/zsh", PATH: "/usr/bin" }; + yield* runShellEnvironment({ + env: first, + stateDir, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/old/bin:/usr/bin", + __MISE_DIFF: "old-mise", + SSH_AUTH_SOCK: "/old-session.sock", + JAVA_HOME: "/old/java", + SSL_CERT_FILE: "/old/cert.pem", + }), + }); + const cachePath = `${stateDir}/shell-environment-cache.json`; + const cached = yield* fs.readFileString(cachePath).pipe(Effect.flatMap(decodeCache)); + assert.equal(cached.environment.PATH, "/old/bin:/usr/bin"); + assert.notProperty(cached.environment, "SSH_AUTH_SOCK"); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/launcher/bin", + SSH_AUTH_SOCK: "/current-session.sock", + }; + const source = [ + "const values = { PATH: '/fresh/bin:' + process.env.PATH, __MISE_DIFF: 'fresh-mise', JAVA_HOME: process.env.JAVA_HOME, SSL_CERT_FILE: process.env.SSL_CERT_FILE };", + "for (const [name, value] of Object.entries(values)) {", + " if (value !== undefined) process.stdout.write('__T3CODE_ENV_' + name + '_START__\\n' + value + '\\n__T3CODE_ENV_' + name + '_END__\\n');", + "}", + ].join("\n"); + let calls = 0; + yield* runShellEnvironment({ + env, + stateDir, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make((command) => { + calls++; + return Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen( + command._tag === "StandardCommand" + ? spawner.spawn(ChildProcess.make(executable, ["-e", source], command.options)) + : Effect.succeed(makeProcess("")), + ), + ); + }), + use: (service) => + Effect.gen(function* () { + yield* service.installIntoProcess; + yield* service.installIntoProcess; + assert.equal(env.PATH, "/old/bin:/usr/bin:/launcher/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/current-session.sock"); + const replay = yield* Stream.runHead(service.updates); + assert.isTrue(Option.isSome(replay)); + if (Option.isSome(replay)) + assert.equal(replay.value.environment.__MISE_DIFF, "old-mise"); + yield* Deferred.await(started); + assert.equal(calls, 1); + yield* Deferred.succeed(release, undefined); + yield* service.awaitDiscovery; + assert.equal(env.PATH, "/fresh/bin:/launcher/bin"); + assert.equal(env.__MISE_DIFF, "fresh-mise"); + assert.isUndefined(env.JAVA_HOME); + assert.isUndefined(env.SSL_CERT_FILE); + const fresh = yield* Stream.runHead(service.updates); + assert.isTrue(Option.isSome(fresh)); + if (Option.isSome(fresh)) { + assert.equal(fresh.value.environment.PATH, env.PATH); + assert.isTrue(fresh.value.discovered); + } + return true; + }), + }); + const refreshed = yield* fs.readFileString(cachePath).pipe(Effect.flatMap(decodeCache)); + assert.equal(refreshed.environment.PATH, "/fresh/bin:/launcher/bin"); + assert.notProperty(refreshed.environment, "JAVA_HOME"); + assert.notProperty(refreshed.environment, "SSL_CERT_FILE"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect.each([ + "not json", + '{"version":2,"key":"old","environment":{"PATH":"/bad/bin"}}', + '{"version":1,"key":"other-shell","environment":{"PATH":"/bad/bin"}}', + ])("ignores an unusable cache and still starts before discovery: %s", (contents) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(`${stateDir}/shell-environment-cache.json`, contents); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" }; + yield* runShellEnvironment({ + env, + stateDir, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(makeProcess(envOutput({ PATH: "/fresh/bin" }))), + ), + ), + use: (service) => + Effect.gen(function* () { + const startup = yield* service.installIntoProcess.pipe(Effect.forkChild); + yield* Deferred.await(started); + assert.isDefined(startup.pollUnsafe()); + assert.equal(env.PATH, "/usr/bin"); + yield* Deferred.succeed(release, undefined); + return yield* service.awaitDiscovery; + }), + }); + assert.equal(env.PATH, "/fresh/bin:/usr/bin"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "retains the last successful cache on refresh failure and ignores session values in the cache", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped(); + const contents = encodeCache({ + version: 1, + key: '["darwin","/test-home","/bin/zsh",""]', + environment: { + PATH: "/cached/bin", + __MISE_DIFF: "cached-mise", + JAVA_HOME: "/cached/java", + NODE_EXTRA_CA_CERTS: "/cached/ca.pem", + SSL_CERT_FILE: "/cached/cert.pem", + SSL_CERT_DIR: "/cached/certs", + SSH_AUTH_SOCK: "/stale.sock", + NODE_OPTIONS: "--bad", + }, + }); + const cachePath = `${stateDir}/shell-environment-cache.json`; + yield* fs.writeFileString(cachePath, contents); + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + __MISE_DIFF: "launcher-mise", + JAVA_HOME: "/launcher/java", + NODE_EXTRA_CA_CERTS: "/launcher/ca.pem", + SSL_CERT_FILE: "", + SSH_AUTH_SOCK: "/current.sock", + }; + const prepared = yield* runShellEnvironment({ + env, + stateDir, + platform: "darwin", + handler: () => "", + use: (service) => + Effect.gen(function* () { + yield* service.installIntoProcess; + const replay = yield* Stream.runHead(service.updates); + assert.isTrue(Option.isSome(replay)); + if (Option.isSome(replay)) { + assert.equal(replay.value.environment.JAVA_HOME, "/launcher/java"); + assert.equal(replay.value.environment.NODE_EXTRA_CA_CERTS, "/launcher/ca.pem"); + } + return yield* service.awaitDiscovery; + }), + }); + assert.isFalse(prepared); + assert.equal(env.PATH, "/cached/bin:/usr/bin"); + assert.equal(env.__MISE_DIFF, "cached-mise"); + assert.equal(env.JAVA_HOME, "/launcher/java"); + assert.equal(env.NODE_EXTRA_CA_CERTS, "/launcher/ca.pem"); + assert.equal(env.SSL_CERT_FILE, ""); + assert.equal(env.SSL_CERT_DIR, "/cached/certs"); + assert.equal(env.SSH_AUTH_SOCK, "/current.sock"); + assert.isUndefined(env.NODE_OPTIONS); + assert.equal(yield* fs.readFileString(cachePath), contents); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("cancels background discovery when the application scope closes", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + yield* runShellEnvironment({ + env: { SHELL: "/bin/zsh", PATH: "/usr/bin" }, + platform: "darwin", + handler: () => "", + processSpawner: ChildProcessSpawner.make(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(stopped, undefined)), + ), + ), + use: (service) => + Effect.gen(function* () { + yield* service.installIntoProcess; + yield* Deferred.await(started); + return true; + }), + }); + yield* Deferred.await(stopped); + }), + ); + it.effect( "captures the login-shell PATH when startup writes more than a pipe buffer to stderr", () => diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 255eb202df68..556f7090b422 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,4 +1,6 @@ +import { DesktopShellEnvironmentSnapshot, ShellEnvironmentPatch } from "@t3tools/contracts"; import { + applyShellEnvironment, buildEnvironmentCaptureCommand, listLoginShellCandidates, setWindowsPath, @@ -6,6 +8,11 @@ import { import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -68,8 +75,10 @@ export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErr export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, { - // Reuses the installation result; false leaves server hydration enabled. + // Desktop owns discovery, including on cache misses; the backend receives updates over fd4. readonly installIntoProcess: Effect.Effect; + readonly awaitDiscovery: Effect.Effect; + readonly updates: Stream.Stream; } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} @@ -97,6 +106,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "T3CODE_TELEMETRY_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", + "JAVA_HOME", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; @@ -244,6 +254,7 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( readonly probe: DesktopShellEnvironmentProbe; readonly command: string; readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; readonly timeout: Duration.Duration; readonly shell?: boolean; }): Effect.fn.Return { @@ -253,6 +264,8 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { shell: input.shell ?? false, + env: input.env, + extendEnv: false, stdin: "ignore", stdout: "pipe", stderr: "ignore", @@ -308,6 +321,7 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( const readLoginShellEnvironment = ( shell: string, names: ReadonlyArray, + env: NodeJS.ProcessEnv, ): Effect.Effect => names.length === 0 ? Effect.succeed({}) @@ -315,20 +329,24 @@ const readLoginShellEnvironment = ( probe: "login-shell", command: shell, args: ["-ilc", buildEnvironmentCaptureCommand(shell, names)], + env, timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); -const readLaunchctlPath = runCommandOutput({ - probe: "launchctl-path", - command: "/bin/launchctl", - args: ["getenv", "PATH"], - timeout: LAUNCHCTL_TIMEOUT, -}).pipe(Effect.map(trimNonEmpty)); +const readLaunchctlPath = (env: NodeJS.ProcessEnv) => + runCommandOutput({ + env, + probe: "launchctl-path", + command: "/bin/launchctl", + args: ["getenv", "PATH"], + timeout: LAUNCHCTL_TIMEOUT, + }).pipe(Effect.map(trimNonEmpty)); const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( function* ( names: ReadonlyArray, options: WindowsProbeOptions, + env: NodeJS.ProcessEnv, ): Effect.fn.Return { if (names.length === 0) return {}; @@ -345,6 +363,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, + env, timeout: LOGIN_SHELL_TIMEOUT, }); const environment = extractEnvironment(output, names); @@ -363,8 +382,8 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin ): Effect.fn.Return { const [noProfile, profile] = yield* Effect.all( [ - readWindowsEnvironment(["PATH"], { loadProfile: false }), - readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }), + readWindowsEnvironment(["PATH"], { loadProfile: false }, config.env), + readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }, config.env), ], { concurrency: 2 }, ); @@ -402,14 +421,14 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) { Object.assign( shellEnvironment, - yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), + yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env), ); if (shellEnvironment.PATH) break; } const launchctlPath = config.platform === "darwin" && !shellEnvironment.PATH - ? yield* readLaunchctlPath + ? yield* readLaunchctlPath(config.env) : Option.none(); const mergedPath = mergePaths(config.platform, [ trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), @@ -450,6 +469,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", + "JAVA_HOME", "T3CODE_TELEMETRY_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { @@ -512,24 +532,140 @@ const installShellEnvironment = ( return Effect.succeed(false); }; +const ShellEnvironmentCache = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + key: Schema.String, + environment: ShellEnvironmentPatch, + }), +); +const encodeCacheKey = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); +const decodeCache = Schema.decodeUnknownEffect(ShellEnvironmentCache); +const encodeCache = Schema.encodeEffect(ShellEnvironmentCache); +const CACHED_ENV_NAMES = [ + "PATH", + "__MISE_DIFF", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "JAVA_HOME", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", +] as const; + /** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = - yield* Effect.cached( - installShellEnvironment({ - env: process.env, - platform: environment.platform, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), - ), + const scope = yield* Scope.Scope; + const env = process.env; + const inherited = { ...env }; + const key = encodeCacheKey([ + environment.platform, + environment.homeDirectory, + inherited.SHELL ?? "", + inherited.ZDOTDIR ?? "", + ]); + const cachePath = path.join(environment.stateDir, "shell-environment-cache.json"); + const changes = yield* PubSub.unbounded(); + const latest = yield* Ref.make>(Option.none()); + const discovered = yield* Deferred.make(); + const publish = Effect.fn("desktop.shellEnvironment.publish")(function* ( + patch: ShellEnvironmentPatch, + discovered = false, + ) { + applyShellEnvironment(env, patch, environment.platform); + const message: DesktopShellEnvironmentSnapshot = { + version: 1, + type: "shellEnvironment", + discovered, + platform: environment.platform, + environment: patch, + }; + yield* Ref.set(latest, Option.some(message)); + yield* PubSub.publish(changes, message); + }); + const refresh = Effect.gen(function* () { + // Refresh from the inherited baseline so removed cached paths and values do not survive. + const fresh = { ...inherited }; + const prepared = yield* installShellEnvironment({ env: fresh, platform: environment.platform }); + const patch = Object.fromEntries( + Object.keys(ShellEnvironmentPatch.fields).map((name) => [name, fresh[name] ?? null]), ); - - return DesktopShellEnvironment.of({ installIntoProcess }); + if (prepared || Option.isNone(yield* Ref.get(latest))) yield* publish(patch, prepared); + if (prepared) { + const cached = Object.fromEntries( + CACHED_ENV_NAMES.flatMap((name) => (fresh[name] ? [[name, fresh[name]]] : [])), + ); + const persist = Effect.gen(function* () { + const encoded = yield* encodeCache({ version: 1, key, environment: cached }); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(cachePath + ".tmp", encoded, { mode: 0o600 }); + yield* fileSystem.rename(cachePath + ".tmp", cachePath); + }); + yield* persist.pipe( + Effect.catch(() => Effect.logWarning("Could not cache shell environment")), + ); + } + return prepared; + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.refresh"), + Effect.onExit((exit) => Deferred.done(discovered, exit)), + ); + const installIntoProcess = yield* Effect.cached( + Effect.gen(function* () { + const cache = yield* fileSystem + .readFileString(cachePath) + .pipe(Effect.flatMap(decodeCache), Effect.option); + if (Option.isSome(cache) && cache.value.key === key) { + const cached = Object.fromEntries( + CACHED_ENV_NAMES.flatMap((name) => { + const value = cache.value.environment[name]; + // mise's metadata must describe the cached PATH entries restored alongside it. + if (name !== "PATH" && name !== "__MISE_DIFF" && inherited[name] !== undefined) + return []; + return typeof value === "string" ? [[name, value]] : []; + }), + ); + const baseline = { ...inherited }; + const cachedPath = cached.PATH; + if (cachedPath) { + cached.PATH = Option.getOrElse( + mergePaths(environment.platform, [Option.some(cachedPath), readEnvPath(inherited)]), + () => cachedPath, + ); + } + applyShellEnvironment(baseline, cached, environment.platform); + yield* publish( + Object.fromEntries( + Object.keys(ShellEnvironmentPatch.fields).map((name) => [name, baseline[name] ?? null]), + ), + ); + } + yield* Effect.forkIn(refresh, scope); + return true; + }).pipe(Effect.withSpan("desktop.shellEnvironment.installIntoProcess")), + ); + const updates = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const snapshot = yield* Ref.get(latest); + return Stream.concat( + Option.isSome(snapshot) ? Stream.make(snapshot.value) : Stream.empty, + Stream.fromSubscription(subscription), + ); + }), + ); + return DesktopShellEnvironment.of({ + installIntoProcess, + awaitDiscovery: installIntoProcess.pipe(Effect.andThen(Deferred.await(discovered))), + updates, + }); }); export const layer = Layer.effect(DesktopShellEnvironment, make); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index f3f0163401ab..1180c0fa7091 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; +import * as Context from "effect/Context"; +import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -8,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -18,6 +21,7 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts"; import * as DesktopRendererHistory from "./DesktopRendererHistory.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; const layerHistory = Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { register: () => Effect.void, @@ -57,39 +61,66 @@ function layerElectronApp( } describe("DesktopTelemetryPublisher", () => { - it.effect("stops when its scope closes during an Electron telemetry sample", () => - Effect.gen(function* () { - const pollStarted = yield* Deferred.make(); - const blockPoll = yield* Deferred.make(); - const layerPower = Layer.succeed( - ElectronPowerMonitor.ElectronPowerMonitor, - ElectronPowerMonitor.ElectronPowerMonitor.of({ - isOnBatteryPower: Effect.succeed(false), - getSystemIdleTime: Deferred.succeed(pollStarted, undefined).pipe( - Effect.andThen(Deferred.await(blockPoll)), - Effect.as(0), + it.effect( + "forwards shell discovery while a telemetry sample is pending and stops on scope close", + () => + Effect.gen(function* () { + const pollStarted = yield* Deferred.make(); + const blockPoll = yield* Deferred.make(); + const layerPower = Layer.succeed( + ElectronPowerMonitor.ElectronPowerMonitor, + ElectronPowerMonitor.ElectronPowerMonitor.of({ + isOnBatteryPower: Effect.succeed(false), + getSystemIdleTime: Deferred.succeed(pollStarted, undefined).pipe( + Effect.andThen(Deferred.await(blockPoll)), + Effect.as(0), + ), + getSystemIdleState: () => Effect.succeed("active"), + getCurrentThermalState: Effect.succeed("nominal"), + onSimpleEvent: () => Effect.void, + onThermalStateChange: () => Effect.void, + onSpeedLimitChange: () => Effect.void, + }), + ); + const layer = DesktopTelemetryPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerElectronApp([]), + layerPower, + layerHistory, + Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { + installIntoProcess: Effect.succeed(true), + awaitDiscovery: Effect.succeed(true), + updates: Stream.make({ + version: 1, + type: "shellEnvironment", + discovered: true, + platform: "darwin", + environment: { PATH: "/discovered/bin" }, + }), + }), + ), ), - getSystemIdleState: () => Effect.succeed("active"), - getCurrentThermalState: Effect.succeed("nominal"), - onSimpleEvent: () => Effect.void, - onThermalStateChange: () => Effect.void, - onSpeedLimitChange: () => Effect.void, - }), - ); - const layer = DesktopTelemetryPublisher.layer.pipe( - Layer.provide(Layer.mergeAll(layerElectronApp([]), layerPower, layerHistory)), - ); - const scope = yield* Scope.make(); + ); + const scope = yield* Scope.make(); - yield* Layer.buildWithScope(layer, scope); - yield* Deferred.await(pollStarted); + const context = yield* Layer.buildWithScope(layer, scope); + const publisher = Context.get(context, DesktopTelemetryPublisher.DesktopTelemetryPublisher); + const update = yield* publisher.encoded.pipe( + Stream.filter((bytes) => new TextDecoder().decode(bytes).includes("shellEnvironment")), + Stream.runHead, + ); + assert.isTrue(Option.isSome(update)); + if (Option.isSome(update)) + assert.include(new TextDecoder().decode(update.value), "/discovered/bin"); + yield* Deferred.await(pollStarted); - const closeFiber = yield* Scope.close(scope, Exit.void).pipe(Effect.forkDetach); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + const closeFiber = yield* Scope.close(scope, Exit.void).pipe(Effect.forkDetach); + yield* Effect.yieldNow; + yield* Effect.yieldNow; - assert.isDefined(closeFiber.pollUnsafe()); - }), + assert.isDefined(closeFiber.pollUnsafe()); + }), ); }); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 8800c4f42833..0a2b10e08c5a 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -24,6 +24,7 @@ import * as Stream from "effect/Stream"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts"; import * as DesktopRendererHistory from "./DesktopRendererHistory.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; const LIVE_SAMPLE_INTERVAL = Duration.seconds(1); const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5); @@ -146,6 +147,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { const electronApp = yield* ElectronApp.ElectronApp; const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; yield* electronApp.whenReady; const initialPowerState: PowerState = { @@ -400,7 +402,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { type: "desktopTelemetryHello", electronPid: process.pid, } as const), - Stream.merge(snapshots, updateReports), + Stream.merge(Stream.merge(snapshots, updateReports), shellEnvironment.updates), ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`))); const publishUpdateReport: DesktopTelemetryPublisher["Service"]["publishUpdateReport"] = ( diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts index 616643c6e1e9..fe014b82e2dd 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts @@ -1,12 +1,195 @@ // @effect-diagnostics nodeBuiltinImport:off import { it } from "@effect/vitest"; +import * as NodeTls from "node:tls"; +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Cause from "effect/Cause"; +import * as FileSystem from "effect/FileSystem"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { describe, expect } from "vite-plus/test"; -import { requireDesktopTelemetryWriteProgress } from "./DesktopTelemetryReceiver.ts"; +import { + makeApplyDesktopShellEnvironmentSnapshot, + requireDesktopTelemetryWriteProgress, +} from "./DesktopTelemetryReceiver.ts"; describe("DesktopTelemetryReceiver", () => { + it.live( + "preserves system and bundled roots when the first snapshot withdraws overlapping extras", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const directory = yield* fs.makeTempDirectoryScoped(); + const certificatePath = path.join(directory, "startup-extra-ca.pem"); + yield* fs.writeFileString( + certificatePath, + [...NodeTls.getCACertificates("system"), ...NodeTls.rootCertificates].join("\n"), + ); + const child = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + [ + "--use-bundled-ca", + "--use-system-ca", + "--input-type=module", + "--eval", + ` +import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; +import * as NodeTls from "node:tls"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { makeApplyDesktopShellEnvironmentSnapshot } from ${JSON.stringify(new URL("./DesktopTelemetryReceiver.ts", import.meta.url).href)}; +const fingerprints = (certificates) => [...new Set(certificates.map((pem) => new NodeCrypto.X509Certificate(pem).fingerprint256))].sort(); +const expected = fingerprints([...NodeTls.getCACertificates("bundled"), ...NodeTls.getCACertificates("system")]); +const applySnapshot = makeApplyDesktopShellEnvironmentSnapshot(process.env, process.platform); +await Effect.runPromise(applySnapshot({ + version: 1, + type: "shellEnvironment", + platform: process.platform, + discovered: true, + environment: { NODE_EXTRA_CA_CERTS: null }, +}).pipe(Effect.provide(NodeServices.layer))); +NodeAssert.equal(process.env.NODE_EXTRA_CA_CERTS, undefined); +NodeAssert.deepEqual(fingerprints(NodeTls.getCACertificates("default")), expected); +`, + ], + { + cwd: path.resolve(import.meta.dirname, "../.."), + env: { NODE_OPTIONS: "", NODE_EXTRA_CA_CERTS: certificatePath }, + }, + ), + ); + const result = yield* Effect.all( + { + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: child.exitCode.pipe(Effect.map(Number)), + }, + { concurrency: "unbounded" }, + ); + expect(result.exitCode, result.stderr).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("updates live TLS trust when shell certificates arrive after startup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const certificatePath = path.join(directory, "extra-ca.pem"); + const original = NodeTls.getCACertificates("default"); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeTls.setDefaultCACertificates(original)), + ); + const baseline = NodeTls.rootCertificates[0]!; + const extra = NodeTls.rootCertificates[1]!; + const replacement = NodeTls.rootCertificates[2]!; + NodeTls.setDefaultCACertificates([baseline]); + yield* fs.writeFileString(certificatePath, `${extra}\n${replacement}`); + const environment: NodeJS.ProcessEnv = { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" }; + const applySnapshot = makeApplyDesktopShellEnvironmentSnapshot(environment, "darwin"); + const message = { + version: 1, + type: "shellEnvironment", + platform: "darwin", + discovered: true, + environment: { NODE_EXTRA_CA_CERTS: certificatePath }, + } as const; + const fingerprints = (certificates: ReadonlyArray) => + certificates.map((pem) => new NodeCrypto.X509Certificate(pem).fingerprint256).sort(); + yield* applySnapshot({ ...message, platform: "win32" }); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual(fingerprints([baseline])); + expect(environment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING).toBe("1"); + yield* applySnapshot(message); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual( + fingerprints([baseline, extra, replacement]), + ); + expect(environment.NODE_EXTRA_CA_CERTS).toBe(certificatePath); + expect(environment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING).toBeUndefined(); + + yield* fs.writeFileString(certificatePath, replacement); + yield* applySnapshot(message); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual( + fingerprints([baseline, replacement]), + ); + + yield* fs.writeFileString( + certificatePath, + "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----", + ); + yield* applySnapshot(message); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual( + fingerprints([baseline, replacement]), + ); + yield* fs.remove(certificatePath); + const warnings: Array> = []; + yield* applySnapshot(message).pipe( + Effect.provide( + Logger.layer([Logger.make((entry) => warnings.push(entry))], { + mergeWithExisting: false, + }), + ), + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]!.message).toEqual(["Failed to update desktop shell TLS certificates."]); + expect(Cause.squash(warnings[0]!.cause)).toMatchObject({ _tag: "PlatformError" }); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual( + fingerprints([baseline, replacement]), + ); + + yield* applySnapshot({ ...message, environment: { NODE_EXTRA_CA_CERTS: null } }); + expect(fingerprints(NodeTls.getCACertificates("default"))).toEqual(fingerprints([baseline])); + expect(environment.NODE_EXTRA_CA_CERTS).toBeUndefined(); + + NodeTls.setDefaultCACertificates([]); + yield* fs.writeFileString(certificatePath, extra); + const applyOpaqueTrust = makeApplyDesktopShellEnvironmentSnapshot(environment, "darwin"); + yield* applyOpaqueTrust(message); + expect(NodeTls.getCACertificates("default")).toEqual([]); + expect(environment.NODE_EXTRA_CA_CERTS).toBe(certificatePath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.each([ + { platform: "darwin", discovered: true, applied: true, pending: false }, + { platform: "win32", discovered: true, applied: false, pending: true }, + { platform: "darwin", discovered: false, applied: true, pending: true }, + ])( + "applies platform=$platform discovered=$discovered with pending=$pending", + ({ platform, discovered, applied, pending }) => + Effect.gen(function* () { + const environment: NodeJS.ProcessEnv = { + PATH: "/old/bin", + JAVA_HOME: "/old/java", + UNRELATED: "keep", + T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1", + }; + yield* makeApplyDesktopShellEnvironmentSnapshot( + environment, + "darwin", + )({ + version: 1, + type: "shellEnvironment", + platform, + discovered, + environment: { PATH: "/fresh/bin", JAVA_HOME: null }, + }); + expect(environment).toEqual({ + PATH: applied ? "/fresh/bin" : "/old/bin", + ...(applied ? {} : { JAVA_HOME: "/old/java" }), + UNRELATED: "keep", + ...(pending ? { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" } : {}), + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("models a zero-byte control write as a stalled descriptor", () => Effect.gen(function* () { const error = yield* requireDesktopTelemetryWriteProgress(7, 42, 0).pipe(Effect.flip); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 70dc2e8d750e..7d9de87ecfd9 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -1,22 +1,28 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodeNet from "node:net"; +import * as NodeTls from "node:tls"; import * as NodeStream from "@effect/platform-node/NodeStream"; import { DesktopHostTelemetryMessage, type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, type DesktopHostTelemetrySnapshot, + type DesktopShellEnvironmentSnapshot, DesktopTelemetryControlMessage, type DesktopUpdateChannel, type DesktopUpdateStatusReport, type ResourceTelemetrySourceStatus, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { applyShellEnvironment } from "@t3tools/shared/shell"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -202,6 +208,70 @@ export class DesktopTelemetryReceiver extends Context.Service< } >()("t3/resourceTelemetry/DesktopTelemetryReceiver") {} +class DesktopShellCertificatesUpdateFailed extends Schema.TaggedError()( + "DesktopShellCertificatesUpdateFailed", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to update desktop shell TLS certificates."; + } +} + +export function makeApplyDesktopShellEnvironmentSnapshot( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +) { + let baseCertificates: Array | undefined; + return Effect.fn("resourceTelemetry.desktopTelemetryReceiver.applyShellEnvironment")(function* ( + message: DesktopShellEnvironmentSnapshot, + ) { + if (message.platform !== platform) return; + // Node's first default-CA enumeration consults process.env, so capture it before the patch. + if (message.environment.NODE_EXTRA_CA_CERTS !== undefined && baseCertificates === undefined) { + // Remove only the startup extras, preserving duplicates from bundled or system trust. + const extras = [...NodeTls.getCACertificates("extra")]; + baseCertificates = NodeTls.getCACertificates("default").filter((certificate) => { + const index = extras.indexOf(certificate); + if (index === -1) return true; + extras.splice(index, 1); + return false; + }); + } + applyShellEnvironment(environment, message.environment, platform); + if (message.environment.NODE_EXTRA_CA_CERTS !== undefined) { + yield* Effect.gen(function* () { + const certificates = baseCertificates; + // --use-openssl-ca roots are not enumerable; replacing the list would discard them. + if (certificates === undefined || certificates.length === 0) { + yield* Effect.logWarning( + "Preserving non-enumerable TLS trust. Restart to apply updated desktop shell certificates.", + ); + return; + } + const fs = yield* FileSystem.FileSystem; + const file = message.environment.NODE_EXTRA_CA_CERTS; + const bundle = file ? yield* fs.readFileString(file) : undefined; + yield* Effect.try({ + try: () => { + const extras = + bundle?.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? []; + if (bundle?.trim() && extras.length === 0) { + throw new Error("No PEM certificates found."); + } + NodeTls.setDefaultCACertificates([...certificates, ...extras]); + }, + catch: (cause) => new DesktopShellCertificatesUpdateFailed({ cause }), + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to update desktop shell TLS certificates.", Cause.fail(cause)), + ), + ); + } + if (message.discovered) delete environment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING; + }); +} + const decodeMessage = Schema.decodeUnknownEffect(DesktopHostTelemetryMessage); const encodeControlMessage = Schema.encodeEffect( Schema.fromJsonString(DesktopTelemetryControlMessage), @@ -328,6 +398,9 @@ export function requireDesktopTelemetryWriteProgress( /** @public */ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { const config = yield* ServerConfig.ServerConfig; + const environment = yield* HostProcess.Environment; + const platform = yield* HostProcess.Platform; + const applyShellSnapshot = makeApplyDesktopShellEnvironmentSnapshot(environment, platform); const serverSettings = yield* ServerSettings.ServerSettingsService; const latest = yield* Ref.make(Option.none()); const receiverStartedAt = yield* DateTime.now; @@ -523,6 +596,10 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") ); } + if (message.type === "shellEnvironment") { + return recordContact.pipe(Effect.andThen(applyShellSnapshot(message))); + } + if (message.type === "desktopUpdateStatus") { return recordContact.pipe( Effect.andThen(Ref.set(latestUpdateReport, Option.some(message))), diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 417841cb53e3..eb5858c4ba43 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -165,6 +165,32 @@ const sendFor = ( }); it.layer(NodeServices.layer)("AnalyticsService test", (it) => { + it.effect("waits for desktop shell consent and honors a late telemetry opt-out", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" }; + const batches = yield* sendFor( + 0, + respondWithStatus(204), + Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + yield* analytics.record("test.before-discovery"); + yield* analytics.flush; + env.T3CODE_TELEMETRY_ENABLED = "false"; + delete env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING; + yield* analytics.record("test.opted-out"); + yield* analytics.flush; + env.T3CODE_TELEMETRY_ENABLED = "true"; + yield* analytics.record("test.allowed"); + yield* analytics.flush; + }), + ).pipe(Effect.provideService(HostProcess.Environment, env)); + assert.deepEqual( + batches.flat().map((event) => event.event), + ["test.allowed"], + ); + }), + ); + it.effect("client owner reports cannot use up the server's owner cap", () => Effect.gen(function* () { const batches = yield* sendFor( diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 4edc6621b0c3..07b6f604e08a 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -3,6 +3,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import type { ClientOs } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -116,6 +117,26 @@ function serverOsFromNodePlatform(platform: string): ClientOs { /** @public */ export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; + const hostEnvironment = yield* HostProcess.Environment; + const awaitingShellConsent = hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1"; + // A cached PATH does not establish consent from the current login shell. + const telemetryEnabled = Effect.suspend(() => { + if (!telemetryConfig.enabled) return Effect.succeed(false); + if (!awaitingShellConsent) return Effect.succeed(true); + if (hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1") + return Effect.succeed(false); + const preference = hostEnvironment.T3CODE_TELEMETRY_ENABLED; + return Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe( + Config.withDefault(true), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ + env: preference === undefined ? {} : { T3CODE_TELEMETRY_ENABLED: preference }, + }), + ), + Effect.orElseSucceed(() => false), + ); + }); const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig.ServerConfig; const { distinctId, installId } = yield* getTelemetryIdentity; @@ -172,7 +193,7 @@ export const make = Effect.gen(function* () { const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( events: ReadonlyArray, ) { - if (!telemetryConfig.enabled || !distinctId) return; + if (!(yield* telemetryEnabled) || !distinctId) return; const payload = { api_key: telemetryConfig.posthogKey, @@ -284,7 +305,7 @@ export const make = Effect.gen(function* () { const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( function* (event, properties) { - if (!telemetryConfig.enabled || !distinctId) return; + if (!(yield* telemetryEnabled) || !distinctId) return; // Catch Node UUID defects too; best-effort telemetry drops events without UUIDs. const uuid = yield* Effect.exit(crypto.randomUUIDv7()); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index fbb1aaf87261..32ef901326f7 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2639,6 +2639,7 @@ describe("UsageService", () => { it.live("reports saved usage of a removed directory only for windows it reaches", () => Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-08-12T00:00:00Z")); const { transcript, settings, home } = yield* setup; // Saved usage is pruned after the retention window, so pin "now" near the write. yield* TestClock.setTime(Date.parse("2026-08-15T00:00:00Z")); diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 5a004f93a2a4..5e7045515f1f 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -295,8 +295,48 @@ export const DesktopUpdateStatusReport = Schema.Struct({ }); export type DesktopUpdateStatusReport = typeof DesktopUpdateStatusReport.Type; +export const ShellEnvironmentPatch = Schema.Struct({ + PATH: Schema.optionalKey(Schema.NullOr(Schema.String)), + DBUS_SESSION_BUS_ADDRESS: Schema.optionalKey(Schema.NullOr(Schema.String)), + DISPLAY: Schema.optionalKey(Schema.NullOr(Schema.String)), + LANG: Schema.optionalKey(Schema.NullOr(Schema.String)), + LC_ALL: Schema.optionalKey(Schema.NullOr(Schema.String)), + LC_CTYPE: Schema.optionalKey(Schema.NullOr(Schema.String)), + SSH_AUTH_SOCK: Schema.optionalKey(Schema.NullOr(Schema.String)), + HOMEBREW_PREFIX: Schema.optionalKey(Schema.NullOr(Schema.String)), + HOMEBREW_CELLAR: Schema.optionalKey(Schema.NullOr(Schema.String)), + HOMEBREW_REPOSITORY: Schema.optionalKey(Schema.NullOr(Schema.String)), + XDG_CONFIG_HOME: Schema.optionalKey(Schema.NullOr(Schema.String)), + XDG_CURRENT_DESKTOP: Schema.optionalKey(Schema.NullOr(Schema.String)), + XDG_DATA_HOME: Schema.optionalKey(Schema.NullOr(Schema.String)), + XDG_RUNTIME_DIR: Schema.optionalKey(Schema.NullOr(Schema.String)), + XDG_SESSION_DESKTOP: Schema.optionalKey(Schema.NullOr(Schema.String)), + XDG_SESSION_TYPE: Schema.optionalKey(Schema.NullOr(Schema.String)), + WAYLAND_DISPLAY: Schema.optionalKey(Schema.NullOr(Schema.String)), + SSL_CERT_FILE: Schema.optionalKey(Schema.NullOr(Schema.String)), + SSL_CERT_DIR: Schema.optionalKey(Schema.NullOr(Schema.String)), + NODE_EXTRA_CA_CERTS: Schema.optionalKey(Schema.NullOr(Schema.String)), + T3CODE_TELEMETRY_ENABLED: Schema.optionalKey(Schema.NullOr(Schema.String)), + T3CODE_GITHUB_APP_CLIENT_ID: Schema.optionalKey(Schema.NullOr(Schema.String)), + __MISE_DIFF: Schema.optionalKey(Schema.NullOr(Schema.String)), + JAVA_HOME: Schema.optionalKey(Schema.NullOr(Schema.String)), + FNM_DIR: Schema.optionalKey(Schema.NullOr(Schema.String)), + FNM_MULTISHELL_PATH: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +export type ShellEnvironmentPatch = typeof ShellEnvironmentPatch.Type; + +export const DesktopShellEnvironmentSnapshot = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("shellEnvironment"), + discovered: Schema.Boolean, + platform: Schema.String, + environment: ShellEnvironmentPatch, +}); +export type DesktopShellEnvironmentSnapshot = typeof DesktopShellEnvironmentSnapshot.Type; + export const DesktopHostTelemetryMessage = Schema.Union([ DesktopHostTelemetryHello, + DesktopShellEnvironmentSnapshot, DesktopHostTelemetrySnapshot, DesktopUpdateStatusReport, ]); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 690e6d94523c..a4a17cda2cda 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,6 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { DesktopHostTelemetryMessage } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -14,6 +16,7 @@ import * as NodePath from "node:path"; import { describe, expect, it, vi } from "vite-plus/test"; import { + applyShellEnvironment, CommandAvailability, CommandResolutionCache, type CommandAvailabilityChecker, @@ -35,6 +38,8 @@ import { XcodeGitLocation, } from "./shell.ts"; +const decodeDesktopHostMessage = Schema.decodeUnknownSync(DesktopHostTelemetryMessage); + const nativeProbe = vi.hoisted(() => ({ execFile: vi.fn(), })); @@ -1045,3 +1050,46 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { }), ); }); + +describe("desktop shell environment updates", () => { + it.each(["win32", "linux", "darwin"] as const)( + "applies shell updates with %s PATH semantics", + (platform) => { + const message = decodeDesktopHostMessage({ + version: 1, + type: "shellEnvironment", + discovered: true, + platform, + environment: { + PATH: "C:\\fresh", + FNM_DIR: null, + __MISE_DIFF: null, + SSH_AUTH_SOCK: "/current.sock", + NODE_OPTIONS: "--bad", + }, + }); + expect(message.type).toBe("shellEnvironment"); + if (message.type !== "shellEnvironment") throw new Error("Expected shell environment update"); + const env: NodeJS.ProcessEnv = { + Path: "C:\\stale", + FNM_DIR: "stale", + __MISE_DIFF: "stale", + UNRELATED: "keep", + }; + applyShellEnvironment(env, message.environment, platform); + expect(env).toEqual({ + PATH: "C:\\fresh", + ...(platform === "win32" ? {} : { Path: "C:\\stale" }), + SSH_AUTH_SOCK: "/current.sock", + UNRELATED: "keep", + }); + env.Path = "C:\\stale"; + env.path = "C:\\other"; + applyShellEnvironment(env, { PATH: null, SSH_AUTH_SOCK: null }, platform); + expect(env).toEqual({ + ...(platform === "win32" ? {} : { Path: "C:\\stale", path: "C:\\other" }), + UNRELATED: "keep", + }); + }, + ); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 73c54d1f8b88..c5e6a7346fbe 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import type { ShellEnvironmentPatch } from "@t3tools/contracts"; import * as HostProcess from "./HostProcess.ts"; import * as Context from "effect/Context"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; @@ -908,13 +909,13 @@ function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray Date: Sun, 1 Nov 2026 05:05:11 -0600 Subject: [PATCH 008/188] fix(server): refresh Changes totals after remote polls (#28600) --- .../src/vcs/VcsStatusBroadcaster.test.ts | 90 +++++++++++++++---- apps/server/src/vcs/VcsStatusBroadcaster.ts | 16 +--- 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 042f8274543e..be3a286ffc32 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -656,9 +656,12 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect("an explicit refresh reads local totals after the fetch", () => { + it.effect("a remote poll refreshes Changes totals when divergence counts stay the same", () => { const state: Parameters[0] = { - currentLocalStatus: baseLocalStatus, + currentLocalStatus: { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 745, deletions: 24 }, + }, currentRemoteStatus: baseRemoteStatus, localStatusCalls: 0, remoteStatusCalls: 0, @@ -668,24 +671,79 @@ describe("VcsStatusBroadcaster", () => { return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const fetchStarted = yield* Deferred.make(); - const finishFetch = yield* Deferred.make(); - state.beforeRemoteStatus = Deferred.succeed(fetchStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishFetch)), + yield* broadcaster.getStatus({ cwd: "/repo" }); + const subscribed = yield* Deferred.make(); + const polled = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "remoteUpdated") return Deferred.succeed(polled, undefined); + return Effect.void; + }), + Effect.forkScoped, ); - const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); - yield* Deferred.await(fetchStarted); - - const fetchedLocal: VcsStatusLocalResult = { + yield* Deferred.await(subscribed); + state.currentLocalStatus = { ...baseLocalStatus, - branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, + branchChanges: { baseRef: "origin/main", insertions: 107, deletions: 4 }, }; - state.currentLocalStatus = fetchedLocal; - yield* Deferred.succeed(finishFetch, undefined); + state.currentRemoteStatus = remoteStatusWithPr; + yield* TestClock.adjust("30 seconds"); + yield* Deferred.await(polled); + const status = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.deepStrictEqual(status.branchChanges, state.currentLocalStatus.branchChanges); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); - const status = yield* Fiber.join(refresh); - assert.deepStrictEqual(status.branchChanges, fetchedLocal.branchChanges); - }).pipe(Effect.provide(layerTestFor(state))); + it.effect("an explicit refresh reads local totals after the fetch", () => { + return Effect.gen(function* () { + const fetchStarted = yield* Deferred.make(); + const finishFetch = yield* Deferred.make(); + let local: VcsStatusLocalResult = { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 745, deletions: 24 }, + }; + let cachedLocal: VcsStatusLocalResult | undefined; + const readLocalStatus = () => Effect.sync(() => (cachedLocal ??= local)); + const layerTest = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(layerBackgroundPolicy(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: readLocalStatus, + invalidateStatus: () => + Effect.sync(() => { + cachedLocal = undefined; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + cachedLocal = undefined; + }), + currentBranch: () => Effect.succeed(baseLocalStatus.refName), + invalidateRemoteStatus: () => Effect.void, + remoteStatus: () => + Deferred.succeed(fetchStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishFetch)), + Effect.as(baseRemoteStatus), + ), + }), + ), + ); + yield* Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(fetchStarted); + // Another reader can refill the local cache while fetch is still running. + yield* readLocalStatus(); + local = { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, + }; + yield* Deferred.succeed(finishFetch, undefined); + const status = yield* Fiber.join(refresh); + assert.deepStrictEqual(status.branchChanges, local.branchChanges); + }).pipe(Effect.provide(layerTest)); + }); }); it.effect("publishes local status while an earlier lookup holds the remote lock", () => { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index a8839d0c1cd0..327e0bcb1bb4 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -477,23 +477,14 @@ export const make = Effect.gen(function* () { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { - const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value; const remoteRead = yield* readRemoteStatus(cwd, options); - const remote = remoteRead?.value; const pulled = options?.refreshUpstream === false ? null : yield* maybeAutoPull(cwd, remoteRead, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; - if ( - remote && - (!previousRemote || - previousRemote.aheadCount !== remote.aheadCount || - previousRemote.behindCount !== remote.behindCount || - previousRemote.aheadOfDefaultCount !== remote.aheadOfDefaultCount) - ) { - yield* refreshLocalStatusCore(cwd); - } + // Base refs and merge bases can move without changing divergence counts. + yield* refreshLocalStatusCore(cwd); return yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); }), ); @@ -530,8 +521,7 @@ export const make = Effect.gen(function* () { yield* workflow.invalidateStatus(cwd); const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream }); // Fetch can move the base used for local Changes totals. - const local = yield* workflow.localStatus({ cwd }); - yield* updateCachedLocalStatus(cwd, local, { publish: true }); + yield* refreshLocalStatusCore(cwd); // Without a fetch, the cached behind count cannot justify an automatic pull. const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remoteRead, [rawCwd]) : null; if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); From 2b1021ce411db0f0aade4c513ab2bb4b0cdcaf94 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 1 Nov 2026 07:06:39 -0600 Subject: [PATCH 009/188] perf(codex): trace app server request latency (#28666) --- .../src/protocol.test.ts | 90 +++++++++++++++++++ .../effect-codex-app-server/src/protocol.ts | 33 ++++--- 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index 351f0d2b8773..b4c3ac7b7558 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -7,6 +7,7 @@ import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; import * as TestClock from "effect/testing/TestClock"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -186,6 +187,95 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect( + "correlates concurrent request spans through responses, failures and interruption", + () => { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + return Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ stdio }); + const methods = ["model/list", "account/read", "skills/list"]; + const requests = []; + for (const method of methods) { + requests.push( + yield* transport.request(method, { secret: "private-payload" }).pipe(Effect.forkScoped), + ); + yield* Queue.take(output); + } + yield* Queue.offer( + input, + encodeJsonl({ id: 2, error: { code: -32602, message: "Invalid params" } }), + ); + yield* Fiber.await(requests[1]!); + yield* Queue.offer(input, encodeJsonl({ id: 1, result: { secret: "private-result" } })); + yield* Fiber.join(requests[0]!); + yield* Fiber.interrupt(requests[2]!); + // A late response for an interrupted request must not affect the next request. + yield* Queue.offer(input, encodeJsonl({ id: 3, result: {} })); + const next = yield* transport.request("config/read").pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer(input, encodeJsonl({ id: 4, result: {} })); + yield* Fiber.join(next); + for (const [index, method] of [...methods, "config/read"].entries()) { + const request = spans.find( + (span) => + span.name === "CodexAppServerProtocol.request" && + span.attributes.get("rpc.request_id") === index + 1, + ); + assert.isDefined(request); + assert.equal(request!.attributes.get("rpc.method"), method); + assert.equal(request!.attributes.get("rpc.system"), "jsonrpc"); + assert.equal(request!.status._tag, "Ended"); + if (request!.status._tag === "Ended") { + assert.equal( + request!.status.exit._tag, + index === 1 || index === 2 ? "Failure" : "Success", + ); + } + for (const phase of ["enqueue", "awaitResponse"]) { + const child: Tracer.NativeSpan | undefined = spans.find( + (span) => + span.name === `CodexAppServerProtocol.request.${phase}` && + span.parent?._tag === "Some" && + span.parent.value.spanId === request!.spanId, + ); + assert.isDefined(child); + assert.equal(child!.traceId, request!.traceId); + assert.equal(child!.attributes.get("rpc.method"), method); + assert.equal(child!.attributes.get("rpc.request_id"), index + 1); + assert.equal(child!.status._tag, "Ended"); + } + } + const failedSend = yield* transport + .request("thread/start", { value: 1n }) + .pipe(Effect.exit); + assert.equal(failedSend._tag, "Failure"); + const sendSpans = spans.filter((span) => span.attributes.get("rpc.request_id") === 5); + assert.deepEqual( + sendSpans.map((span) => span.name), + ["CodexAppServerProtocol.request", "CodexAppServerProtocol.request.enqueue"], + ); + for (const span of sendSpans) { + assert.equal(span.attributes.get("rpc.method"), "thread/start"); + assert.equal(span.status._tag, "Ended"); + if (span.status._tag === "Ended") assert.equal(span.status.exit._tag, "Failure"); + } + for (const span of spans) { + const attrs = JSON.stringify([...span.attributes]); + assert.notInclude(attrs, "private-payload"); + assert.notInclude(attrs, "private-result"); + } + }).pipe(Effect.withTracer(tracer)); + }, + ); + it.effect.each([1, 7, 1024])( "preserves JSONL framing and UTF-8 across %i-byte input chunks", (chunkSize) => diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index c628079d0b49..fdf0863af0a1 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -502,17 +502,30 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa nextRequestId, (current) => [current, current + 1] as const, ); + const attributes = { + "rpc.system": "jsonrpc", + "rpc.method": method, + "rpc.request_id": requestId, + }; + yield* Effect.annotateCurrentSpan(attributes); const deferred = yield* Deferred.make(); - yield* Ref.update(pending, (current) => - new Map(current).set(String(requestId), { deferred, method }), - ); - yield* offerOutgoing({ - id: requestId, - method, - ...(payload !== undefined ? { params: payload } : {}), - }).pipe(Effect.tapError(() => removePending(String(requestId)))); - return yield* Deferred.await(deferred).pipe( - Effect.onInterrupt(() => removePending(String(requestId))), + return yield* Effect.acquireUseRelease( + Ref.update(pending, (current) => + new Map(current).set(String(requestId), { deferred, method }), + ), + () => + Effect.gen(function* () { + yield* offerOutgoing({ + id: requestId, + method, + ...(payload !== undefined ? { params: payload } : {}), + }).pipe(Effect.withSpan("CodexAppServerProtocol.request.enqueue", { attributes })); + // Includes queue/stdin delivery, Codex work and response routing, not just T3 CPU. + return yield* Deferred.await(deferred).pipe( + Effect.withSpan("CodexAppServerProtocol.request.awaitResponse", { attributes }), + ); + }), + () => removePending(String(requestId)), ); }); From 82b10d546c1041d8eff40425f076d61f608d518f Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 1 Nov 2026 07:21:42 -0600 Subject: [PATCH 010/188] fix(web): align pull request file viewed checkboxes (#28665) --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 86e2e27b9836..8246fad432b4 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -204,7 +204,7 @@ function FileViewedToggle({ {stat}