diff --git a/apps/desktop/scripts/main-process-bundle.test.mjs b/apps/desktop/scripts/main-process-bundle.test.mjs index 678b62f58aaca..6e1f767d7d395 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/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 7464d8fbe9e2f..26f6dbe026868 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 492903139547b..9c8186711f6ec 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/compileCache.ts b/apps/desktop/src/compileCache.ts index abf8918df2af0..7bbd9adc7f407 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/main.ts b/apps/desktop/src/main.ts index 9a3c327e6b86b..2890f164f6f12 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/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 5a2b5463fc674..7bf1246f9a497 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -302,9 +302,7 @@ export const make = Effect.gen(function* () { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee } = tab.debuggee; const send = (method: string, params: Record, sessionId?: string) => - sessionId === undefined - ? sendPageCommand(webContents, debuggee, method, params) - : debuggee.sendCommand(method, params, sessionId); + sendPageCommand(webContents, debuggee, method, params, sessionId); const detachedOwners = new Set(); /** Viewers and recordings stream; the agent's own sessions never do. */ const streamingOwners = new Set(); diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index b531182c92959..579b564f59aa1 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -1,3 +1,5 @@ +import * as NodeVM from "node:vm"; + import { describe, expect, it, vi } from "vite-plus/test"; import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts"; @@ -42,6 +44,149 @@ describe("preview guest input", () => { expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything()); }); + it.each([ + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "restore", + }, + { + sessionId: "child-frame", + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "restore", + }, + { + sessionId: undefined, + method: "Input.dispatchTouchEvent", + type: "touchStart", + outcome: "restore", + }, + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "other-field", + }, + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "removed", + }, + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "failure", + }, + ])( + "preserves host focus for $method in $sessionId with $outcome", + async ({ sessionId, method, type, outcome }) => { + const document = { activeElement: {} as object }; + const composer = { + isConnected: true, + focus: () => { + document.activeElement = composer; + }, + }; + const preview = { tagName: "WEBVIEW", getWebContentsId: () => 7 }; + const otherField = { tagName: "INPUT" }; + document.activeElement = composer; + const context = NodeVM.createContext({ document }); + const host = { + isDestroyed: () => false, + executeJavaScript: async (script: string) => NodeVM.runInContext(script, context), + }; + const guest = { id: 7, focusedFrame: null, hostWebContents: host }; + const debuggee = { + sendCommand: async () => { + document.activeElement = outcome === "other-field" ? otherField : preview; + if (outcome === "removed") composer.isConnected = false; + if (outcome === "failure") throw new Error("Pointer failed"); + return {}; + }, + }; + const result = sendPageCommand( + guest as unknown as Electron.WebContents, + debuggee as unknown as Electron.Debugger, + method, + { type, x: 10, y: 10, button: "left" }, + sessionId, + ); + if (outcome === "failure") await expect(result).rejects.toThrow("Pointer failed"); + else await result; + expect(document.activeElement).toBe( + outcome === "other-field" ? otherField : outcome === "removed" ? preview : composer, + ); + expect(Object.keys(context)).toEqual(["document"]); + }, + ); + + it.each([ + { + method: "Input.dispatchMouseEvent", + press: "mousePressed", + release: "mouseReleased", + failPress: false, + }, + { + method: "Input.dispatchTouchEvent", + press: "touchStart", + release: "touchEnd", + failPress: false, + }, + { + method: "Input.dispatchMouseEvent", + press: "mousePressed", + release: "mouseReleased", + failPress: true, + }, + ])( + "keeps $method releases behind presses waiting for host focus, failed press: $failPress", + async ({ method, press, release, failPress }) => { + const captureStarted = Promise.withResolvers(); + const finishCapture = Promise.withResolvers(); + const events: Array = []; + const guest = { + id: 7, + focusedFrame: null, + hostWebContents: { + isDestroyed: () => false, + executeJavaScript: async () => { + captureStarted.resolve(); + await finishCapture.promise; + }, + }, + }; + const debuggee = { + sendCommand: async (_method: string, params: Record) => { + events.push(params["type"]); + if (failPress && params["type"] === press) throw new Error("Press failed"); + return {}; + }, + }; + const send = (type: string) => + sendPageCommand( + guest as unknown as Electron.WebContents, + debuggee as unknown as Electron.Debugger, + method, + { type }, + ); + const pressed = send(press); + await captureStarted.promise; + const released = send(release); + finishCapture.resolve(); + const results = await Promise.allSettled([pressed, released]); + expect(results.map((result) => result.status)).toEqual([ + failPress ? "rejected" : "fulfilled", + "fulfilled", + ]); + expect(events).toEqual([press, release]); + }, + ); + it("refuses input when a cross-site frame holds the unfocused page's focus", async () => { const guest = makeGuest({ focused: false, focusedDocument: false }); await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow( diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 42201b48ed61c..d3ccf29c76ed7 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -92,13 +92,78 @@ const evaluate = async (debuggee: Electron.Debugger, expression: string) => { return reply.result?.value; }; +let focusSequence = 0; + +/** A CDP pointer press focuses the guest's native view even with focus emulation enabled. */ +const sendPointerPress = async ( + webContents: Electron.WebContents, + debuggee: Electron.Debugger, + method: string, + params: Record, + sessionId: string | undefined, +) => { + const host = webContents.hostWebContents; + if (!host || host.isDestroyed() || webContents.focusedFrame) { + return debuggee.sendCommand(method, params, sessionId); + } + const key = JSON.stringify(`__t3PreviewFocus${++focusSequence}`); + await host.executeJavaScript(`(() => { + const active = document.activeElement; + if (!active || active === document.body || active.tagName === "WEBVIEW") return; + globalThis[${key}] = () => { + delete globalThis[${key}]; + const current = document.activeElement; + if (active.isConnected && current?.tagName === "WEBVIEW" && + current.getWebContentsId() === ${webContents.id}) active.focus({ preventScroll: true }); + }; + })()`); + try { + return await debuggee.sendCommand(method, params, sessionId); + } finally { + if (!host.isDestroyed()) { + await host.executeJavaScript(`globalThis[${key}]?.()`); + } + } +}; + +const pointerCommands = new WeakMap>(); + +/** Focus capture crosses processes; later pointer events must not overtake the press. */ +const sendPointerCommand = async ( + webContents: Electron.WebContents, + debuggee: Electron.Debugger, + method: string, + params: Record, + sessionId: string | undefined, +) => { + const previous = pointerCommands.get(webContents) ?? Promise.resolve(); + const current = previous + .catch(() => {}) + .then(() => + params["type"] === "mousePressed" || params["type"] === "touchStart" + ? sendPointerPress(webContents, debuggee, method, params, sessionId) + : debuggee.sendCommand(method, params, sessionId), + ); + pointerCommands.set(webContents, current); + try { + return await current; + } finally { + if (pointerCommands.get(webContents) === current) pointerCommands.delete(webContents); + } +}; + /** Sends one command to the guest's page, keeping reloads and input inside it. */ export const sendPageCommand = async ( webContents: Electron.WebContents, debuggee: Electron.Debugger, method: string, params: Record, + sessionId?: string, ): Promise => { + if (method === "Input.dispatchMouseEvent" || method === "Input.dispatchTouchEvent") { + return sendPointerCommand(webContents, debuggee, method, params, sessionId); + } + if (sessionId !== undefined) return debuggee.sendCommand(method, params, sessionId); // Electron's debugger routes Page.reload on a webview to its embedder window. if (method === "Page.reload") { if (params["ignoreCache"] === true) webContents.reloadIgnoringCache(); diff --git a/apps/desktop/src/serverCompileCache.ts b/apps/desktop/src/serverCompileCache.ts new file mode 100644 index 0000000000000..01e71b1aa8720 --- /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/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 33325d9de9907..79344567fcd6c 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 255eb202df68a..556f7090b422e 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 f3f0163401ab2..1180c0fa70915 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 8800c4f428331..0a2b10e08c5a8 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/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 30db8274c208f..6f05b1e9e94ab 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 1fb81088175c1..a319898222092 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/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 55c3aa03b6490..1160d9011275d 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/mobile/src/features/threads/ChildThreadApprovals.tsx b/apps/mobile/src/features/threads/ChildThreadApprovals.tsx new file mode 100644 index 0000000000000..392d185e9afd4 --- /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 870ab31f00003..a71920c199f17 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 6ba5c635685a3..bfd088ccf7f44 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/bin.ts b/apps/server/src/bin.ts index 936c79943fd74..a6dfe16f066a4 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/compileCache.ts b/apps/server/src/compileCache.ts index 0a7345995a811..b01db74cbe34d 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/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts index c46e358518084..b6ac9bfb320ff 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts @@ -1,17 +1,107 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + PreviewTabId, + ProviderInstanceId, + ThreadId, + type PreviewAutomationRequest, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import { createPendingAttachmentId, parseThreadSegmentFromAttachmentId, } from "../../../attachmentStore.ts"; import * as ServerConfig from "../../../config.ts"; -import { claimPreviewRecording } from "./handlers.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { liveThreadsLayer } from "../../McpToolAccess.testkit.ts"; +import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; +import { claimPreviewRecording, layerStandard } from "./handlers.ts"; +import { PreviewStandardToolkit } from "./tools.ts"; + +it.effect("preview_open navigates the opened tab to a dev server in one tool call", () => + Effect.scoped( + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const connected = yield* Deferred.make(); + const tabId = PreviewTabId.make("opened-tab"); + const target = { + kind: "environment-port" as const, + port: 3000, + path: "/settings?tab=account", + }; + const requests: PreviewAutomationRequest[] = []; + const status = { + available: true, + visible: false, + tabId, + url: "http://localhost:3000/settings?tab=account", + title: "Settings", + loading: false, + }; + const events = yield* broker.connect({ + clientId: "client-1", + environmentId: scope.environmentId, + }); + yield* Stream.runForEach(events, (event) => { + if (event.type === "connected") return Deferred.succeed(connected, undefined); + requests.push(event.request); + return broker.respond({ + clientId: "client-1", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: event.request.operation === "open" ? { ...status, url: "about:blank" } : status, + }); + }).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + const toolkit = yield* PreviewStandardToolkit.pipe( + Effect.provide(McpToolAccess.HandlersLayer.layer(layerStandard)), + ); + const results = yield* toolkit + .handle("preview_open", { + url: "http://localhost:3000", + target, + open: false, + reuseExistingTab: false, + }) + .pipe(Stream.unwrap, Stream.runCollect); + expect(results.at(-1)?.result).toEqual(status); + expect(requests.map(({ operation, input }) => ({ operation, input }))).toEqual([ + { operation: "open", input: { open: false, show: false, reuseExistingTab: false } }, + { operation: "navigate", input: { target } }, + ]); + expect(requests[1]?.tabId).toBe(tabId); + expect(requests[1]?.tabIdExplicit).toBe(true); + }).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + environmentId: EnvironmentId.make("environment-1"), + requestNamespace: "provider-session-1", + thread: { + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["preview"] as const), + issuedAt: 1, + }), + Effect.provide( + Layer.mergeAll(PreviewAutomationBroker.layer, liveThreadsLayer).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ), + ), +); describe("claimPreviewRecording", () => { it.effect("overlapping and repeated claims return the same retained recording", () => diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 797c7d7b4715a..94df4a13fec48 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -195,8 +195,21 @@ const handlers = { preview_status: McpToolAccess.readsAsCaller((input) => invokeTargeted("status", input ?? {}), ), - preview_open: McpToolAccess.actsAsCaller((input) => - invokeTargeted("open", normalizePreviewOpenInput(input)), + // Models often pass a dev server as both `url` and `target`; the target knows the environment. + preview_open: McpToolAccess.actsAsCaller(({ target, url, ...input }) => + Effect.gen(function* () { + const opened = yield* invokeTargeted( + "open", + normalizePreviewOpenInput( + target === undefined && url !== undefined ? { ...input, url } : input, + ), + ); + if (target === undefined || opened.tabId === null) return opened; + return yield* invokeTargeted("navigate", { + target, + tabId: opened.tabId, + }); + }), ), preview_navigate: McpToolAccess.actsAsCaller((input) => invokeTargeted("navigate", input, input.timeoutMs), diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index cefe68c1b0e80..d53c3e3a40d33 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; import { Tool } from "effect/ai"; import { PreviewToolkit } from "./tools.ts"; @@ -36,3 +37,17 @@ it("exports provider-compatible object schemas", () => { ).toBeDefined(); } }); + +it("preview_open accepts a target while preserving open input constraints", () => { + const decode = Schema.decodeUnknownSync(PreviewToolkit.tools.preview_open.parametersSchema); + const target = { kind: "environment-port", port: 3000 }; + expect(decode({ target })).toEqual({ target }); + expect(decode({})).toEqual({}); + expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com" }); + expect(decode({ url: "http://localhost:3000", target })).toEqual({ + url: "http://localhost:3000", + target, + }); + expect(() => decode({ target: { ...target, port: 0 } })).toThrow(); + expect(() => decode({ target, tabId: "tab-1", reuseExistingTab: false })).toThrow(); +}); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index cd84874f68979..33c3443a1c26e 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -1,4 +1,5 @@ import { + BrowserNavigationTarget, OrchestratorMcpFailure, ToolActivityIcon, PreviewAutomationActInput, @@ -62,11 +63,17 @@ const PreviewStatusTool = Tool.make("preview_status", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); +// `url` already covers websites, so open only adds the dev-server target. +const EnvironmentPortTarget = BrowserNavigationTarget.members[1]; + const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: - "Open a preview tab in this thread and show it. `open=false` keeps it in the background. Reuses your current tab unless `tabId` or `reuseExistingTab=false` says otherwise. Parallel subagents in one session must each use `reuseExistingTab=false`, then their own returned `tabId`.", - parameters: PreviewAutomationOpenInput, + "Open a preview tab in this thread and show it. For a dev server, pass `target` instead of `url`. `open=false` keeps it in the background. Reuses your current tab unless `tabId` or `reuseExistingTab=false` says otherwise. Parallel subagents in one session must each use `reuseExistingTab=false`, then their own returned `tabId`.", + parameters: PreviewAutomationOpenInput.mapFields( + (fields) => ({ ...fields, target: Schema.optional(EnvironmentPortTarget) }), + { unsafePreserveChecks: true }, + ), success: PreviewAutomationStatus, failure: PreviewToolFailure, dependencies, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 342f6ced28bae..8494c4cab7812 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 d8a88b7dd2b96..48ed26517c236 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/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index 2f21ea0a8a942..9b95422c4f5c1 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/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 3e1e5dc80f702..1719d3ca6bd19 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5153, "codexAdditionalContextBytes": 6883, - "mcpToolListJsonBytes": 47074 + "mcpToolListJsonBytes": 47538 } diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts index 616643c6e1e9e..fe014b82e2dd5 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 70dc2e8d750ef..7d9de87ecfd98 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/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 82d165972750f..75650b55354b4 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -2,7 +2,7 @@ import * as Scheduler from "../scheduling/Scheduler.ts"; import * as NodeUtil from "node:util"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import { ProjectId, ProviderInstanceId, @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as SqlClient from "effect/sql/SqlClient"; @@ -476,3 +477,70 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", () }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("a run interrupted right after it reserves the task does not block the next run", () => + Effect.gen(function* () { + const launches = yield* Ref.make(0); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + Ref.update(launches, (n) => n + 1).pipe( + Effect.as({ threadId: ThreadId.make("thread:launched"), resumed: false } as never), + ), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Layer.succeed(Scheduler.Scheduler, { register: () => Effect.void }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const { task } = yield* service.upsert({ + id: ScheduledTaskId.make("task:interrupted-reservation"), + title: "Check in", + prompt: "Check progress", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project:test"), + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + + // The reservation is the first Set.add of the task id; interrupt the run right there. + const add = Set.prototype.add; + let interrupted = false; + const first = yield* Effect.gen(function* () { + using _reserve = vi.spyOn(Set.prototype, "add").mockImplementation(function ( + this: Set, + value: unknown, + ) { + const result = add.call(this, value); + if (value === task.id && !interrupted) { + interrupted = true; + Fiber.getCurrent()?.interruptUnsafe(); + } + return result; + }); + return yield* service + .runNow({ id: task.id }) + .pipe(Effect.forkChild, Effect.flatMap(Fiber.await)); + }); + assert.isTrue(interrupted); + assert.isTrue(Exit.hasInterrupts(first)); + + const second = yield* service.runNow({ id: task.id }); + assert.equal(second.threadId, ThreadId.make("thread:launched")); + assert.equal(yield* Ref.get(launches), 1); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index c12474a6fb800..d69254009b04c 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -786,20 +786,7 @@ export const layer = Layer.effect( // Read before reserving: nothing between the reservation and its release may fail. const startedAt = yield* localNow; const startedAtIso = iso(startedAt); - const reserved = yield* Ref.modify(activeRuns, (active) => { - if (active.has(task.id)) return [false, active] as const; - const next = new Set(active); - next.add(task.id); - return [true, next] as const; - }); - if (!reserved) { - if (trigger !== "scheduled") { - return yield* taskError("Schedule task is already running.", { taskId: task.id }); - } - return { task, threadId: null, dispatchFailed: false }; - } - - return yield* Effect.gen(function* () { + const run = Effect.gen(function* () { // Re-read stale poll snapshots so deleted, paused or postponed tasks cannot fire. const active = yield* findTask(task.id); if (active === null) { @@ -1018,13 +1005,30 @@ export const layer = Layer.effect( Effect.onError((cause) => releaseStuckRun(task, startedAtIso, errorMessage(cause), webhook?.deliveryId), ), - Effect.ensuring( - Ref.update(activeRuns, (active) => { - const next = new Set(active); - next.delete(task.id); - return next; - }), - ), + ); + + // Reserve and release as one bracket so an interrupt cannot leave the task marked busy. + return yield* Effect.acquireUseRelease( + Ref.modify(activeRuns, (active) => { + if (active.has(task.id)) return [false, active] as const; + const next = new Set(active); + next.add(task.id); + return [true, next] as const; + }), + (reserved) => + reserved + ? run + : trigger !== "scheduled" + ? Effect.fail(taskError("Schedule task is already running.", { taskId: task.id })) + : Effect.succeed({ task, threadId: null, dispatchFailed: false }), + (reserved) => + reserved + ? Ref.update(activeRuns, (active) => { + const next = new Set(active); + next.delete(task.id); + return next; + }) + : Effect.void, ); }); @@ -2243,83 +2247,87 @@ export const layer = Layer.effect( } yield* Effect.forEach( tasks, - ({ task_id: taskId }) => - Effect.gen(function* () { - const reserved = yield* Ref.modify(pendingInFlight, (active) => - active.has(taskId) - ? ([false, active] as const) - : ([true, new Set(active).add(taskId)] as const), - ); - if (!reserved) return; - const permit = yield* webhookPermit(taskId); - // One worker per task waits on its permit, then drains persisted rows in order. - // Other tasks remain independent and ticks never reload an active task's payloads. - const dispatch = Effect.gen(function* () { - while (true) { - const [entry] = yield* sql<{ - delivery_id: string; - task_json: string; - request_json: string; - state: string; - }>` + ({ task_id: taskId }) => { + // One worker per task waits on its permit, then drains persisted rows in order. + // Other tasks remain independent and ticks never reload an active task's payloads. + const dispatch = Effect.gen(function* () { + while (true) { + const [entry] = yield* sql<{ + delivery_id: string; + task_json: string; + request_json: string; + state: string; + }>` SELECT delivery_id, task_json, request_json, state FROM scheduled_task_webhook_pending WHERE task_id = ${taskId} AND state IN ('dispatching', 'running', 'completed', 'failed') ORDER BY due_at, rowid LIMIT 1 `.pipe(NodeSqliteClient.readOnly); - if (entry === undefined) return; - const remove = sql`DELETE FROM scheduled_task_webhook_pending WHERE delivery_id = ${entry.delivery_id}`; - if (entry.state === "completed" || entry.state === "failed") { - if (entry.state === "failed") { - yield* markDeliveryFailed(entry.delivery_id, "The run failed to start."); - yield* releaseFailedDelivery(entry.delivery_id); - } - yield* remove; - continue; - } - const prepared = yield* Effect.gen(function* () { - const task = yield* decodePendingTask(entry.task_json); - const request = yield* decodePendingRequest(entry.request_json); - const frameId = Buffer.from(yield* crypto.randomBytes(6)).toString("hex"); - return { task, rendered: renderWebhookPrompt(task.prompt, request, frameId) }; - }).pipe(Effect.exit); - if (Exit.isFailure(prepared)) { - if (Cause.hasInterruptsOnly(prepared.cause)) - return yield* Effect.failCause(prepared.cause); - yield* markDeliveryFailed( - entry.delivery_id, - "The pending webhook payload could not be prepared.", - ); - yield* releaseFailedDelivery(entry.delivery_id); - yield* remove; - continue; - } - const { task, rendered } = prepared.value; - if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { - yield* markDeliveryFailed(entry.delivery_id, PROMPT_TOO_LONG); + if (entry === undefined) return; + const remove = sql`DELETE FROM scheduled_task_webhook_pending WHERE delivery_id = ${entry.delivery_id}`; + if (entry.state === "completed" || entry.state === "failed") { + if (entry.state === "failed") { + yield* markDeliveryFailed(entry.delivery_id, "The run failed to start."); yield* releaseFailedDelivery(entry.delivery_id); - } else { - yield* sql`UPDATE scheduled_task_webhook_deliveries SET outcome = 'accepted', error = NULL, rendered_prompt = ${rendered.prompt.slice(0, WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT)}, missing_fields_json = ${encodeMissingFieldsJson(rendered.missing)} WHERE delivery_id = ${entry.delivery_id}`; - if (!(yield* runWebhookDelivery(task, entry.delivery_id, rendered.prompt))) - return; } yield* remove; + continue; } - }); - yield* dispatch.pipe( - permit.withPermits(1), - Effect.catch((cause) => - Effect.logWarning("Pending webhook dispatch failed", { cause }), - ), - Effect.ensuring( - Ref.update(pendingInFlight, (active) => { - const next = new Set(active); - next.delete(taskId); - return next; - }), - ), - Effect.withSpan("ScheduledTaskService.dispatchPendingWebhook", { root: true }), - Effect.forkIn(serviceScope), - ); - }), + const prepared = yield* Effect.gen(function* () { + const task = yield* decodePendingTask(entry.task_json); + const request = yield* decodePendingRequest(entry.request_json); + const frameId = Buffer.from(yield* crypto.randomBytes(6)).toString("hex"); + return { task, rendered: renderWebhookPrompt(task.prompt, request, frameId) }; + }).pipe(Effect.exit); + if (Exit.isFailure(prepared)) { + if (Cause.hasInterruptsOnly(prepared.cause)) + return yield* Effect.failCause(prepared.cause); + yield* markDeliveryFailed( + entry.delivery_id, + "The pending webhook payload could not be prepared.", + ); + yield* releaseFailedDelivery(entry.delivery_id); + yield* remove; + continue; + } + const { task, rendered } = prepared.value; + if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + yield* markDeliveryFailed(entry.delivery_id, PROMPT_TOO_LONG); + yield* releaseFailedDelivery(entry.delivery_id); + } else { + yield* sql`UPDATE scheduled_task_webhook_deliveries SET outcome = 'accepted', error = NULL, rendered_prompt = ${rendered.prompt.slice(0, WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT)}, missing_fields_json = ${encodeMissingFieldsJson(rendered.missing)} WHERE delivery_id = ${entry.delivery_id}`; + if (!(yield* runWebhookDelivery(task, entry.delivery_id, rendered.prompt))) return; + } + yield* remove; + } + }); + // The worker owns its reservation, so an interrupt anywhere still releases it. + return Effect.acquireUseRelease( + Ref.modify(pendingInFlight, (active) => + active.has(taskId) + ? ([false, active] as const) + : ([true, new Set(active).add(taskId)] as const), + ), + (reserved) => + reserved + ? webhookPermit(taskId).pipe( + Effect.flatMap((permit) => dispatch.pipe(permit.withPermits(1))), + Effect.catch((cause) => + Effect.logWarning("Pending webhook dispatch failed", { cause }), + ), + Effect.withSpan("ScheduledTaskService.dispatchPendingWebhook", { + root: true, + }), + ) + : Effect.void, + (reserved) => + reserved + ? Ref.update(pendingInFlight, (active) => { + const next = new Set(active); + next.delete(taskId); + return next; + }) + : Effect.void, + ).pipe(Effect.forkIn(serviceScope)); + }, { discard: true }, ); }); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 3bc3fe506e78f..aaeb374bd0dd4 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -12,6 +12,7 @@ import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; @@ -1005,6 +1006,57 @@ it.effect("recovers a persisted coalescing window after restarting the service", ); }); +it.effect("a pending dispatch interrupted at its permit does not block later polls", () => { + const workers = new Map>(); + // One shared dispatcher lets the test run the retry's forked worker to completion. + const mixed = new EffectScheduler.MixedScheduler("sync"); + const dispatcher = mixed.makeDispatcher(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.content-type}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* TestClock.adjust("10 seconds"); + const poll = workers.get("webhook-coalescing")!; + + const entered = yield* Deferred.make>(); + const interruptAtPermit = Tracer.make({ + span: (options) => { + if (options.name === "ScheduledTaskService.webhookPermit") { + const fiber = Fiber.getCurrent()!; + Deferred.doneUnsafe(entered, Effect.succeed(fiber)); + fiber.interruptUnsafe(); + } + return new Tracer.NativeSpan(options); + }, + }); + const firstPoll = yield* poll.pipe(Effect.withTracer(interruptAtPermit), Effect.forkChild); + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(yield* Deferred.await(entered)))); + yield* Fiber.await(firstPoll); + + yield* poll; + dispatcher.flush(); + + assert.equal(yield* Queue.size(launches), 1); + assert.lengthOf(yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`, 0); + }), + { workers }, + ).pipe( + Effect.provideService(EffectScheduler.Scheduler, { + executionMode: mixed.executionMode, + makeDispatcher: () => dispatcher, + shouldYield: (fiber) => mixed.shouldYield(fiber), + }), + ); +}); + it.effect("rolls back replay and pending state when recording a delivery fails", () => { const workers = new Map>(); return withService( diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 417841cb53e39..eb5858c4ba43b 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 4edc6621b0c3e..07b6f604e08a9 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 07a78dc6d50de..32ef901326f77 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2639,7 +2639,10 @@ 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")); yield* Effect.promise(async () => { await NodeFSP.writeFile(transcript, claudeLine(1, 5)); const lastWrite = Date.parse("2026-08-01T10:00:00Z") / 1000; @@ -2665,7 +2668,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", () => diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 042f8274543e5..be3a286ffc32b 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 a8839d0c1cd0c..327e0bcb1bb41 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); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 8483720c9c403..cd3fc1e6ff7bf 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"; @@ -128,7 +129,7 @@ import { subscribeOrchestrationV2Shell, subscribeOrchestrationV2Thread, WS_RPC_SERVER_OPTIONS, - withLateEditorConfig, + withLateConfigDiscovery, } from "./ws.ts"; const snapshotConfig = (fields: Partial) => @@ -143,7 +144,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 })), { @@ -151,6 +152,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, @@ -170,24 +172,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", () => @@ -602,9 +615,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; @@ -619,6 +632,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, @@ -637,7 +653,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)({ @@ -712,12 +734,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({ @@ -754,6 +778,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 621e898b99bc9..92c89177129fe 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"; @@ -314,10 +315,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( @@ -338,7 +340,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] => { @@ -353,6 +362,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": @@ -1755,6 +1768,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; @@ -1774,7 +1788,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(), }); @@ -2205,7 +2221,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) @@ -3128,9 +3148,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) => ({ @@ -3217,9 +3241,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 1b3bbee034642..8607371535e58 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/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 020963f23310e..0fbbb1876c242 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,4 +1,5 @@ import { CustomBackground } from "./CustomBackground"; +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"; @@ -3540,6 +3541,7 @@ export default function ChatView(props: ChatViewProps) { : undefined } > + )} + {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} closeType === "keyboard"} > ({ 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 0000000000000..5a7cb3074b4a6 --- /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/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 4ae861d517d0f..8c3f814427991 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -438,7 +438,7 @@ export function ThreadDetailsPrRow({ {detail !== null && (showChecks || trailingAction !== null || watching) ? (
{openRow(detail.title, "row", true)} -
+
#{number} {showChecks && checksRollup !== null ? ( event.stopPropagation()} > { 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 974f81c8d76b3..34c4403a20e53 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/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index c7919d658ccf6..9d7aae58033f1 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/operations/development.md b/docs/operations/development.md index 3a56a23f63daf..ea7f10a6ba2dd 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 @@ -233,6 +261,29 @@ 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 macOS and Windows desktop artifact builds generate server bytecode with the packaged +Electron executable, after ASAR creation and before signing. Linux builds skip generation until +native packaging and relocated AppImage reuse have been validated. Cross-platform and +cross-architecture builds also skip generation 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. + +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/docs/operations/release.md b/docs/operations/release.md index 18daadc5f2787..29b9a2b9bfb58 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/docs/user/permission-modes.md b/docs/user/permission-modes.md index a344d98f64516..3c330bc6bae90 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/evals/mcp-tool-use/cases/visual-preview.ndjson b/evals/mcp-tool-use/cases/visual-preview.ndjson index 908beafe152bc..1940692d9fe17 100644 --- a/evals/mcp-tool-use/cases/visual-preview.ndjson +++ b/evals/mcp-tool-use/cases/visual-preview.ndjson @@ -27,3 +27,4 @@ {"id": "proactive-visual-latency-regression", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "data that reads better as a chart answered in prose only", "rule": "When a chart, diagram, or mockup says more than prose: html_preview, then html_render", "source": {"report": "agents used inline HTML visuals less often after #24850 made the rule ask-only"}, "experience": "The user pasted a latency table across releases and asked where it regressed. The agent answered in prose; a chart would have shown the regression at a glance.", "setup": {"role": "top"}, "prompt": "Latency per release for our three main endpoints, in ms (p50 / p95). Which release regressed, and on which endpoint?\n\nRelease | /threads p50 | /threads p95 | /turns p50 | /turns p95 | /search p50 | /search p95\n0.41 | 38 | 92 | 120 | 310 | 44 | 101\n0.42 | 37 | 95 | 118 | 305 | 46 | 104\n0.43 | 39 | 97 | 121 | 318 | 45 | 99\n0.44 | 41 | 210 | 119 | 312 | 47 | 102\n0.45 | 40 | 236 | 162 | 480 | 46 | 100\n0.46 | 42 | 241 | 165 | 492 | 48 | 103", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "notCalls": ["preview_open", "preview_navigate"]}} {"id": "proactive-visual-ci-time-breakdown", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "data that reads better as a chart answered in prose only", "rule": "When a chart, diagram, or mockup says more than prose: html_preview, then html_render", "source": {"report": "agents used inline HTML visuals less often after #24850 made the rule ask-only"}, "experience": "The user shared CI job times over several runs and asked what eats the time. The agent answered in prose; a stacked chart would have shown it.", "setup": {"role": "top"}, "prompt": "CI got slow. Here are job times in minutes for the last 8 runs on main. What's eating the time?\n\nRun | install | typecheck | lint | test-server | test-web | build | e2e\n1 | 2.1 | 3.0 | 1.2 | 6.8 | 4.1 | 3.3 | 7.9\n2 | 2.0 | 3.1 | 1.1 | 6.9 | 4.0 | 3.4 | 8.2\n3 | 2.2 | 3.0 | 1.2 | 7.4 | 4.2 | 3.3 | 8.0\n4 | 2.1 | 3.2 | 1.3 | 9.8 | 4.1 | 3.5 | 8.1\n5 | 2.3 | 3.1 | 1.2 | 12.6 | 4.3 | 3.4 | 8.4\n6 | 2.2 | 3.3 | 1.2 | 13.1 | 4.2 | 3.6 | 12.9\n7 | 2.1 | 3.2 | 1.3 | 13.4 | 4.4 | 3.5 | 13.2\n8 | 2.2 | 3.2 | 1.2 | 13.0 | 4.3 | 3.4 | 13.5", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "notCalls": ["preview_open", "preview_navigate"]}} {"id": "requested-after-video-for-edit", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "agent refuses requested browser recording of its own edit", "experience": "A user asked for an after video of a UI fix. The agent declined because the injected instructions prohibited preview for its own edits.", "rule": "Use preview when the user asks for browser work, including recording an edit.", "setup": {"role": "top", "context": "The UI fix is complete and a development server is already running at http://localhost:3000. Browser verification and recording are authorized.", "mocks": {"preview_status": {"available": true, "tabId": "tab_1", "url": "http://localhost:3000/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:3000/"}, "preview_recording_start": {"recording": true, "tabId": "tab_1", "startedAt": "2026-01-01T00:00:00.000Z"}, "preview_recording_stop": {"id": "recording_1", "tabId": "tab_1", "path": "/tmp/after.webm", "mimeType": "video/webm", "sizeBytes": 1024, "createdAt": "2026-01-01T00:00:01.000Z"}}}, "prompt": "Use the T3 browser to verify the UI fix you just made and record an after video.", "expect": {"calls": [{"tool": "preview_recording_start"}, {"tool": "preview_recording_stop"}], "order": ["preview_recording_start", "preview_recording_stop"]}, "source": {"report": "A user requested an after video of a UI fix; the agent declined because preview was prohibited for its own edits."}} +{"id": "dev-server-opened-blank-then-navigated", "area": "visual-preview", "kind": "bad", "pattern": "preview_open on a blank tab, then preview_navigate to the dev-server port", "experience": "Asked to open a dev server in the T3 preview, agents opened a blank tab and navigated it to the port in a second call. 121 of these pairs in a month of maintainer threads.", "rule": "preview_open takes a url but not a dev-server target", "privacy": "commit-safe", "setup": {"role": "top", "context": "CLAUDE.md says: for t3code browser work, use the server-side preview_* mcp tools, with `target: {kind: \"environment-port\", port: ...}` for dev servers in the current environment. The dev server for this repo is running; `vp run dev` printed `Local: http://localhost:5173/`.", "mocks": {"preview_status": {"available": true, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:5173/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true}}}}, "prompt": "open the dev server in the t3 preview, i want to click around", "expect": {"calls": [{"tool": "preview_open", "args": {"target.port": 5173}}], "maxCalls": {"preview_open": 1, "preview_navigate": 0}}, "maxSteps": 6, "source": {"thread": "Maintainer threads, 2026-10: preview_open with no url followed by preview_navigate to an environment-port target, 121 times"}} diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index 1bb42346d650a..b407b147b9be2 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -362,12 +362,15 @@ export const normalizeToolName = (name: string) => { ? bare.slice(T3_PREFIX.length) : (CODEX_NAMES[bare] ?? bare.slice(bare.lastIndexOf("_") + 1)); }; -export const normalizeToolInput = (name: string, input: unknown) => - normalizeToolName(name) === "Bash" && isRecord(input) +export const normalizeToolInput = (name: string, input: unknown) => { + const decode = t3InputDecoders.get(normalizeToolName(name)); + if (decode !== undefined) return Option.getOrElse(decode(input), () => input); + return normalizeToolName(name) === "Bash" && isRecord(input) ? { ...input, command: input.command ?? input.cmd } : typeof input === "string" && normalizeToolName(name) === "Edit" ? { patch: input } : input; +}; // McpHttpServer advertises each tool's JSON schema, and Claude Code passes the MCP name, // description, and schema through unchanged. @@ -384,6 +387,15 @@ const t3SourceTools: ReadonlyArray = [ DeviceToolkit, HtmlToolkit, ].flatMap((toolkit): ReadonlyArray => Object.values(toolkit.tools)); +// McpHttpServer decodes arguments with each tool's JSON codec, which drops undeclared keys. +const t3InputDecoders = new Map( + t3SourceTools.map((tool) => [ + tool.name, + Schema.decodeUnknownOption( + Schema.toCodecJson(tool.parametersSchema as Schema.Codec), + ), + ]), +); const t3Tools: ReadonlyArray = t3SourceTools.map((tool) => ({ name: `${T3_PREFIX}${tool.name}`, description: Tool.getDescription(tool) ?? "", diff --git a/knip.jsonc b/knip.jsonc index c2093737fd942..615581793fb3b 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/package.json b/package.json index c78b879301b9e..b2912d680c855 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/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index d5d2c4ec16d8d..d9aaf9fdca71a 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 e089eb36d08c7..a010018c5051d 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))); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 2937326d95c72..5dbc71426b10f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -307,10 +307,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({ @@ -320,6 +321,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/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 5a004f93a2a4c..5e7045515f1f4 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/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index 351f0d2b87731..b4c3ac7b7558b 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 c628079d0b491..fdf0863af0a16 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)), ); }); diff --git a/packages/provider-cursor/src/server/sdk.ts b/packages/provider-cursor/src/server/sdk.ts index a8a10e365b362..9443bf735a08a 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/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 690e6d94523c9..a4a17cda2cda9 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 73c54d1f8b88d..c5e6a7346fbef 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 = { + 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/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts index 0c55b082dcfb9..7dbe4bf60ba35 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 909247dae1262..b9d1121a66104 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( diff --git a/scripts/lib/server-compile-cache.test.ts b/scripts/lib/server-compile-cache.test.ts new file mode 100644 index 0000000000000..bb9294c54219c --- /dev/null +++ b/scripts/lib/server-compile-cache.test.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off +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 { expect, it } from "vite-plus/test"; +import { cacheKey, seedPreparedCache } from "./server-compile-cache.ts"; + +it.each(["x64", "arm64"])("skips prepared bytecode generation on Linux %s", async (arch) => { + const { spawnSync } = await import("node:child_process"); + const hook = NodeURL.fileURLToPath( + new URL("../prepare-server-compile-cache.cjs", import.meta.url), + ); + const result = spawnSync( + process.execPath, + [ + "-e", + `Object.defineProperty(process, "platform", { value: "linux" }); + Object.defineProperty(process, "arch", { value: ${JSON.stringify(arch)} }); + require(${JSON.stringify(hook)})({ electronPlatformName: "linux", arch: ${arch === "x64" ? 1 : 3} }).catch(error => { console.error(error); process.exitCode = 1; });`, + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Skipping unsupported target linux"); +}); + +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 0000000000000..63a21174ff651 --- /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 0000000000000..1c4b886d115ac --- /dev/null +++ b/scripts/prepare-server-compile-cache.cjs @@ -0,0 +1,60 @@ +/* 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 }) => { + // Linux packaging and relocated AppImage cache reuse still need native validation. + if (electronPlatformName !== "darwin" && electronPlatformName !== "win32") { + console.log(`[server-compile-cache] Skipping unsupported target ${electronPlatformName}`); + return; + } + 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"); +}; diff --git a/scripts/profile-startup-modules.mjs b/scripts/profile-startup-modules.mjs new file mode 100644 index 0000000000000..2db139a92ac48 --- /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 0000000000000..f14dfc0155191 --- /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 0000000000000..998843ed1cf12 --- /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 }); + } +});