From 6417151c093e54dbba0e6c824c69d03983793b9a Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:35:26 +0530 Subject: [PATCH 01/88] fix(web): fill combobox search fields (#32926) Co-authored-by: Bryan Tobing --- apps/web/src/components/ui/combobox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 739eb15b9a064..ccd3c61a9e7e4 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -114,7 +114,7 @@ function ComboboxSearchInput(props: React.ComponentProps) /> Date: Sun, 8 Nov 2026 08:22:49 -0500 Subject: [PATCH 02/88] fix(server): mise-installed providers update through mise instead of their own updater (#21524) Mise-owned providers requested as latest now update with mise upgrade ; pinned tools, version paths and mise x scripts stay manual. Co-authored-by: Ryan Hughes Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../src/provider/Drivers/ClaudeMaintenance.ts | 1 + .../src/provider/Drivers/CodexDriver.ts | 1 + docs/internals/providers.md | 12 +- docs/user/install.md | 14 +- .../src/server/maintenanceResolver.test.ts | 483 +++++++++++++++++- .../src/server/maintenanceResolver.ts | 178 ++++++- packages/provider-grok/src/server/driver.ts | 13 +- packages/provider-muse/src/server/driver.ts | 13 +- .../provider-muse/src/server/maintenance.ts | 23 +- .../provider-opencode/src/server/driver.ts | 2 + packages/provider-pi/src/server/driver.ts | 1 + 11 files changed, 705 insertions(+), 36 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts index d9975762647d4..e278c25d6d3d5 100644 --- a/apps/server/src/provider/Drivers/ClaudeMaintenance.ts +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts @@ -27,6 +27,7 @@ const NativeVersion = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+$/)); const update = makePackageManagedProviderMaintenanceResolver({ provider: ProviderDriverKind.make("claudeAgent"), npmPackageName: "@anthropic-ai/claude-code", + mise: { tool: "claude" }, nativeUpdate: { args: ["update"], isCommandPath: (commandPath) => { diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 871ce9ed6e84f..dee8a04aee9fa 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -81,6 +81,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { return makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", + mise: { tool: "codex" }, nativeUpdate: { args: ["update"], isCommandPath: (commandPath) => codexStandaloneHome(commandPath) !== null, diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 3a31a9dac93cd..634b83980a540 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -88,8 +88,16 @@ either the resolved path or its real target, since those installers place real f symlinks there. Volta is proven by its `volta-shim` link plus the package's image directory. When nothing is proven, the provider's own updater (`claude update`, `codex update`, `opencode upgrade`, `pi update --self`, `grok update`) runs instead, because each one detects its installer itself; -the runner's version check catches an updater that exits 0 without updating. Mise installs stay -manual-only because their version is pinned in mise's config. A Grok cask whose Homebrew prefix +the runner's version check catches an updater that exits 0 without updating. Mise is checked +first, since a `mise x` wrapper can sit at a native installer's path, and `MISE_DATA_DIR` can put +installs anywhere, so `mise which` is asked whenever mise is on `PATH`. Each driver names its fixed +mise tool; mise must resolve the binary and list it in an active install of that tool requested as +`latest`, which gets `mise upgrade `. T3 Code keeps launching the configured path, so that +path must be a shim or under the tool's `latest` link, which the upgrade repoints: a version +directory (including one `mise activate` put on `PATH`) would keep running the old binary until mise +prunes it. Everything else mise touches (fixed version paths, pinned tools, wrappers, OpenCode 1.x, +whose mise tool crosses into 2.x) stays manual-only, so T3 Code never edits mise config. `mise latest` supplies the version, because settings such as +`minimum_release_age` can hold mise behind npm. A Grok cask whose Homebrew prefix cannot be verified also stays manual-only: `grok update` would install a second copy. npm updates pin `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew compares against `brew info` since casks trail npm by hours; native installs share npm's version diff --git a/docs/user/install.md b/docs/user/install.md index 1b892851dd39a..4689a2965889b 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -204,12 +204,14 @@ on the environment's machine. An unlisted version is unverified. When a provider CLI is behind its latest release, its provider card shows the available version. **Update now** runs the installer that owns the CLI -(Homebrew, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or the -CLI's own update command when T3 Code cannot tell. Update a CLI installed with -mise through mise. Cursor and Antigravity update with T3 Code. Homebrew and -Chocolatey installs compare against the version their package manager offers, -which can trail the npm release; update Claude installed with Chocolatey -through Chocolatey. +(Homebrew, mise, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or +the CLI's own update command when T3 Code cannot tell. T3 Code runs `mise upgrade` +only for a tool your mise config requests as `latest` when the provider's binary +path is a mise shim or under the tool's `installs//latest` link; update a +pinned version, or a path into one version's directory, through mise yourself. +Cursor and Antigravity update with T3 Code. Homebrew and Chocolatey installs +compare against the version their package manager offers, which can trail the +npm release; update Claude installed with Chocolatey through Chocolatey. Add another provider instance for a separate account or configuration. Each instance can have its own environment variables, such as API keys or a custom diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 9447742ca354b..e5298def7f0da 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -14,10 +14,10 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as TestClock from "effect/testing/TestClock"; import * as ProviderLatestVersions from "./ProviderLatestVersions.ts"; import { createProviderVersionAdvisory, @@ -66,6 +66,12 @@ const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), }, }); +const miseToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + mise: { tool: "package-tool" }, + nativeUpdate: null, +}); const installedPackageToolProvider: ServerProvider = { instanceId: ProviderInstanceId.make("packageTool"), driver: driver("packageTool"), @@ -131,6 +137,121 @@ function stdoutSpawner( }); } +type MiseLatestAnswer = string | { readonly stdout?: string; readonly exitCode?: number } | "hang"; + +/** A `mise` answering `which`, `ls --json --installed [tool]`, and `latest`, recording each call. */ +function miseSpawner( + answers: { + readonly which: string; + readonly latest?: MiseLatestAnswer; + readonly installed: Record>; + }, + spawned: Array>, +) { + return ChildProcessSpawner.make((command) => { + const { command: executable, args } = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + spawned.push([executable, ...args]); + const latest = answers.latest ?? ""; + const hang = args[0] === "latest" && latest === "hang"; + let stdout = ""; + let exitCode = 0; + if (args[0] === "which") { + stdout = `${answers.which}\n`; + } else if (args[0] === "latest" && typeof latest === "string") { + stdout = latest === "hang" ? "" : `${latest}\n`; + } else if (args[0] === "latest" && typeof latest !== "string") { + stdout = latest.stdout ?? ""; + exitCode = latest.exitCode ?? 0; + } else if (args[0] === "ls") { + // `mise ls --json ` lists one tool's installs; without a tool, every tool's. + const tool = args.slice(1).find((arg) => !arg.startsWith("-")); + stdout = JSON.stringify(tool ? (answers.installed[tool] ?? []) : answers.installed); + } else { + exitCode = 1; + } + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: hang ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(hang), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: hang ? Stream.never : Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); +} + +/** + * A mise binary on its own PATH dir and an install of `package-tool` 1.0.0 in its data dir, + * with the `latest` link mise keeps pointing at the newest install. + */ +function miseFixture( + tempDir: string, + options: { + readonly misePresent?: boolean; + readonly tool?: string; + readonly dataDir?: string; + } = {}, +) { + const miseBinDir = NodePath.join(tempDir, "mise-bin"); + const misePath = NodePath.join(miseBinDir, "mise"); + if (options.misePresent !== false) writeExecutable(misePath); + const toolDir = NodePath.join( + options.dataDir ?? NodePath.join(tempDir, "mise"), + "installs", + "package-tool", + ); + const latestDir = NodePath.join(toolDir, "latest"); + let installPath = ""; + let binaryPath = ""; + /** Installs `version` and points `latest` at it, as `mise upgrade` does. */ + const install = (version: string) => { + installPath = NodePath.join(toolDir, version); + binaryPath = NodePath.join(installPath, "bin", "package-tool"); + writeExecutable(binaryPath); + if (symlinksSupported) { + NodeFS.rmSync(latestDir, { force: true }); + NodeFS.symlinkSync(installPath, latestDir); + } + return binaryPath; + }; + install("1.0.0"); + const installed = ( + install: { readonly requested_version?: string; readonly active?: boolean } = {}, + ) => ({ + [options.tool ?? "package-tool"]: [ + { install_path: installPath, requested_version: "latest", active: true, ...install }, + ], + }); + return { + miseBinDir, + misePath, + get binaryPath() { + return binaryPath; + }, + latestBinaryPath: NodePath.join(latestDir, "bin", "package-tool"), + install, + installed, + }; +} + +/** A mise shim: a link named for the tool that points at mise itself. */ +function linkShim(tempDir: string, misePath: string, name: string) { + const shimPath = NodePath.join(tempDir, "mise", "shims", name); + NodeFS.mkdirSync(NodePath.dirname(shimPath), { recursive: true }); + NodeFS.symlinkSync(misePath, shimPath); + return shimPath; +} + it.layer( Layer.mergeAll( NodeServices.layer, @@ -1487,6 +1608,366 @@ it.layer( }), ); + it.effect.each([ + { name: "its latest link", via: "latest" }, + { name: "a latest link in a custom data dir", via: "latest", dataDir: "data" }, + { name: "a shim", via: "shim" }, + ])( + "upgrades the mapped mise tool through $name requested as latest", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-upgrade"); + const mise = miseFixture( + tempDir, + fixture.dataDir ? { dataDir: NodePath.join(tempDir, fixture.dataDir) } : {}, + ); + const binaryPath = + fixture.via === "latest" + ? mise.latestBinaryPath + : linkShim(tempDir, mise.misePath, "package-tool"); + const spawned: Array> = []; + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath, + env: { PATH: mise.miseBinDir }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner({ which: mise.binaryPath, installed: mise.installed() }, spawned), + ), + ); + + expect(capabilities.update).toMatchObject({ + command: "mise upgrade package-tool", + executable: mise.misePath, + args: ["upgrade", "package-tool"], + }); + expect(spawned).toContainEqual([mise.misePath, "which", "package-tool"]); + // Resolution proves ownership but never asks mise for releases. + expect(spawned.some((args) => args[1] === "latest")).toBe(false); + }), + { skip: !symlinksSupported || windowsHost }, + ); + + it.effect.each([ + { name: "a pinned version", installed: { requested_version: "1.0.0" } }, + { name: "an inactive install", installed: { active: false } }, + { name: "an install of another mise tool", tool: "other-tool" }, + { name: "an install mise which does not run", which: "elsewhere" }, + { name: "an install without mise on PATH", misePresent: false }, + { name: "a provider with no mise tool", definition: "unmapped" }, + { name: "a mise x wrapper", via: "wrapper" }, + { name: "a fixed version path", via: "versioned" }, + { name: "a version mise activate put on PATH", via: "activated" }, + ] as const)( + "leaves $name manual-only", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-manual"); + const mise = miseFixture(tempDir, { + misePresent: !("misePresent" in fixture), + tool: "tool" in fixture ? fixture.tool : "package-tool", + }); + let binaryPath = symlinksSupported ? mise.latestBinaryPath : mise.binaryPath; + let path = mise.miseBinDir; + if ("via" in fixture && fixture.via === "versioned") { + binaryPath = mise.binaryPath; + } else if ("via" in fixture && fixture.via === "activated") { + binaryPath = "package-tool"; + path = [NodePath.dirname(mise.binaryPath), mise.miseBinDir].join(NodePath.delimiter); + } else if ("via" in fixture) { + binaryPath = NodePath.join(tempDir, "bin", "package-tool"); + NodeFS.mkdirSync(NodePath.dirname(binaryPath), { recursive: true }); + NodeFS.writeFileSync( + binaryPath, + '#!/bin/sh\nexec mise x package-tool -- package-tool "$@"\n', + ); + NodeFS.chmodSync(binaryPath, 0o755); + } + const which = + "which" in fixture + ? NodePath.join(tempDir, "elsewhere", "package-tool") + : mise.binaryPath; + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + "definition" in fixture ? packageToolUpdate : miseToolUpdate, + { binaryPath, env: { PATH: path } }, + ).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner( + { + which, + installed: mise.installed("installed" in fixture ? fixture.installed : {}), + }, + [], + ), + ), + ); + + expect(capabilities.update).toBeNull(); + expect(capabilities.lookupLatestVersion).toBeUndefined(); + }), + { skip: windowsHost }, + ); + + it.effect.skipIf(windowsHost)( + "keeps the provider's own updater for an install mise does not own", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-native"); + const nativePath = NodePath.join(tempDir, ".local", "bin", "native-package-tool"); + writeExecutable(nativePath); + const miseMappedNative = makePackageManagedProviderMaintenanceResolver({ + provider: driver("nativePackageTool"), + npmPackageName: "@example/native-package-tool", + mise: { tool: "native-package-tool" }, + nativeUpdate: { + args: ["update"], + isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), + }, + }); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseMappedNative, { + binaryPath: nativePath, + env: { PATH: "" }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + + expect(capabilities.update).toEqual({ + command: `${nativePath} update`, + executable: nativePath, + args: ["update"], + lockKey: "nativePackageTool-native", + }); + }), + ); + + it.effect.each([ + { + name: "a mise tool whose upgrades are disabled", + mise: { tool: "package-tool", upgrade: false }, + }, + { name: "a pinned mise tool", mise: { tool: "package-tool" }, pinned: true }, + { + name: "an inactive version of a mise tool", + mise: { tool: "package-tool" }, + inactive: true, + }, + { + name: "an inactive version of a mise tool whose upgrades are disabled", + mise: { tool: "package-tool", upgrade: false }, + inactive: true, + }, + ] as const)( + "keeps $name in a custom data dir manual-only instead of running its own updater", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-custom-manual"); + const mise = miseFixture(tempDir, { dataDir: NodePath.join(tempDir, "data") }); + let binaryPath = mise.latestBinaryPath; + let installed = mise.installed("pinned" in fixture ? { requested_version: "1.0.0" } : {}); + if ("inactive" in fixture) { + // Configured to 1.0.0 while mise resolves its active 2.0.0 instead. + binaryPath = mise.binaryPath; + const inactive = { ...installed["package-tool"]![0]!, active: false }; + mise.install("2.0.0"); + installed = { "package-tool": [inactive, ...mise.installed()["package-tool"]!] }; + } + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + makePackageManagedProviderMaintenanceResolver({ + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + mise: fixture.mise, + nativeUpdate: { args: ["upgrade"] }, + }), + { binaryPath, env: { PATH: mise.miseBinDir } }, + ).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner({ which: mise.binaryPath, installed }, []), + ), + ); + + expect(capabilities.update).toBeNull(); + }), + { skip: !symlinksSupported || windowsHost }, + ); + + it.effect.skipIf(!symlinksSupported || windowsHost)( + "updates an npm global under mise's Node with npm even when mise runs it", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-node-npm"); + const mise = miseFixture(tempDir); + const nodePrefix = NodePath.join(tempDir, "mise", "installs", "node", "22.0.0"); + const binaryPath = NodePath.join(nodePrefix, "bin", "package-tool"); + const target = NodePath.join( + nodePrefix, + "lib", + "node_modules", + "@example", + "package-tool", + "bin", + "package-tool.js", + ); + writeExecutable(target); + NodeFS.mkdirSync(NodePath.dirname(binaryPath), { recursive: true }); + NodeFS.symlinkSync(target, binaryPath); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath, + env: { PATH: mise.miseBinDir }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + // mise resolves the npm global through its Node; no `package-tool` tool is installed. + miseSpawner({ which: binaryPath, installed: {} }, []), + ), + ); + + expect(capabilities.update?.args).toEqual([ + "install", + "-g", + "--prefix", + NodeFS.realpathSync(nodePrefix), + "--allow-scripts=@example/package-tool", + "@example/package-tool@latest", + ]); + }), + ); + + it.effect.skipIf(!symlinksSupported || windowsHost)( + "asks mise for its latest release only while update checks are enabled", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-latest"); + const mise = miseFixture(tempDir); + const spawned: Array> = []; + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath: mise.latestBinaryPath, + env: { PATH: mise.miseBinDir }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner( + // mise's `minimum_release_age` can hold it behind npm's 1.2.0. + { which: mise.binaryPath, latest: "1.1.0", installed: mise.installed() }, + spawned, + ), + ), + ); + const advise = (enableProviderUpdateChecks: boolean) => + enrichProviderSnapshotWithVersionAdvisory(installedPackageToolProvider, capabilities, { + enableProviderUpdateChecks, + }); + + expect((yield* advise(false)).versionAdvisory).toMatchObject({ + status: "unknown", + latestVersion: null, + }); + expect(spawned.filter((args) => args[1] === "latest")).toEqual([]); + + expect((yield* advise(true)).versionAdvisory).toMatchObject({ + status: "behind_latest", + latestVersion: "1.1.0", + }); + expect((yield* advise(true)).versionAdvisory?.latestVersion).toBe("1.1.0"); + expect(spawned.filter((args) => args[1] === "latest")).toEqual([ + [mise.misePath, "latest", "package-tool"], + ]); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("mise answers for its own installs")), + ), + ), + ); + + it.effect.skipIf(!symlinksSupported || windowsHost)( + "keeps each mise environment's latest release apart", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-latest-env"); + const mise = miseFixture(tempDir); + const advise = (env: NodeJS.ProcessEnv, latest: string) => + resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath: mise.latestBinaryPath, + env: { PATH: mise.miseBinDir, ...env }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner({ which: mise.binaryPath, latest, installed: mise.installed() }, []), + ), + Effect.flatMap((capabilities) => + enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + { + enableProviderUpdateChecks: true, + }, + ), + ), + Effect.map((snapshot) => snapshot.versionAdvisory?.latestVersion), + ); + + expect(yield* advise({ MISE_MINIMUM_RELEASE_AGE: "7d" }, "1.1.0")).toBe("1.1.0"); + expect(yield* advise({}, "1.2.0")).toBe("1.2.0"); + expect(yield* advise({ MISE_MINIMUM_RELEASE_AGE: "7d" }, "1.2.0")).toBe("1.1.0"); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("mise answers for its own installs")), + ), + ), + ); + + it.effect.each([ + { name: "fails", latest: { exitCode: 1 } }, + { name: "prints nothing", latest: { stdout: "" } }, + { name: "never answers", latest: "hang" }, + ] as const)( + "reports an unknown latest version when mise latest $name", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-mise-latest-unknown"); + const mise = miseFixture(tempDir); + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(miseToolUpdate, { + binaryPath: mise.latestBinaryPath, + env: { PATH: mise.miseBinDir }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + miseSpawner( + { which: mise.binaryPath, latest: fixture.latest, installed: mise.installed() }, + [], + ), + ), + ); + expect(capabilities.update).toMatchObject({ args: ["upgrade", "package-tool"] }); + + const advisory = yield* enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + { enableProviderUpdateChecks: true }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("1 minute"); + + expect((yield* Fiber.join(advisory)).versionAdvisory).toMatchObject({ + status: "unknown", + latestVersion: null, + }); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("an offline mise must not fall back to npm")), + ), + ), + { skip: !symlinksSupported || windowsHost }, + ); + it.effect.skipIf(!symlinksSupported)("updates Yarn global installs with yarn", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-yarn-capabilities"); diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 364b4cee9bf42..bb60e8e865714 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Synchronous hashing keeps env values out of cache keys. +import * as NodeCrypto from "node:crypto"; export type { ProviderMaintenanceCapabilities, ProviderMaintenanceCommandAction, @@ -92,6 +94,14 @@ export interface PackageManagedProviderMaintenanceDefinition { | NodeJS.ProcessEnv | ((context: ProviderMaintenanceResolutionContext) => NodeJS.ProcessEnv); } | null; + /** The mise registry tool that installs this provider; unset keeps mise installs manual-only. */ + readonly mise?: MiseProviderTool; +} + +export interface MiseProviderTool { + readonly tool: string; + /** False still claims the tool's installs, but keeps them manual-only. */ + readonly upgrade?: boolean; } const NpmLatestVersionResponse = Schema.Struct({ @@ -242,19 +252,23 @@ function isYarnGlobalCommandPath(commandPath: string): boolean { } /** - * Version-manager installs are pinned in its config, so updating them means editing that. + * Paths mise owns. npm globals under mise's own Node belong to npm, which proves their prefix. * A command wrapper is a symlink to the `mise` binary, which dispatches on argv[0]. */ function isMiseCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( - normalized.includes("/mise/installs/") || + (normalized.includes("/mise/installs/") && !normalized.includes("/mise/installs/node/")) || normalized.includes("/mise/shims/") || - normalized.includes("/mise/command-wrappers/") || - /\/mise(?:\.exe)?$/.test(normalized) + normalized.includes("/mise/command-wrappers/") ); } +/** A mise shim or command wrapper is a link to mise itself, wherever mise keeps its data. */ +function isMiseExecutablePath(commandPath: string): boolean { + return /(?:^|\/)mise(?:\.exe)?$/.test(normalizeCommandPath(commandPath)); +} + const winGetClaudeCommandPath = /^[a-z]:\/(?:users\/[^/]+\/appdata\/local\/microsoft|program files(?: \(x86\))?)\/winget\/(?:links|packages\/anthropic\.claudecode_[^/]+)\/claude\.exe$/; @@ -463,6 +477,141 @@ const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion return nonEmptyString(listedEntries?.[0]?.dependencies?.[packageName]?.version); }); +const MiseInstall = Schema.Struct({ + install_path: Schema.String, + requested_version: Schema.optional(Schema.NullOr(Schema.String)), + active: Schema.optional(Schema.Boolean), +}); +const decodeMiseToolInstalls = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(MiseInstall)), +); +const decodeMiseInstalls = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Array(MiseInstall))), +); + +/** The installs `mise ls --json --installed ` lists, refusing any other tool's. */ +function miseToolInstalls( + listing: string, + miseTool: string, +): ReadonlyArray { + const installs = decodeMiseToolInstalls(listing); + if (Option.isSome(installs)) return installs.value; + const byTool = Option.getOrUndefined(decodeMiseInstalls(listing)); + return byTool && Object.keys(byTool).every((tool) => tool === miseTool) + ? (byTool[miseTool] ?? []) + : []; +} + +/** + * Null when mise does not own the executable. Upgrades need an active `latest` install of the + * driver's fixed mise tool, launched through a shim or the `latest` link `mise upgrade` moves; + * a fixed version's path would keep running the pruned binary, so it stays manual-only. + */ +export const resolveMiseProviderMaintenance = Effect.fn("resolveMiseProviderMaintenance")( + function* ( + owner: { readonly provider: ProviderDriverKind; readonly packageName: string | null }, + mise: MiseProviderTool | undefined, + context: ProviderMaintenanceResolutionContext, + ) { + const commandPaths = [context.resolvedCommandPath, context.realCommandPath]; + const isShim = isMiseExecutablePath(context.realCommandPath); + const looksMise = + isShim || commandPaths.some(isMiseCommandPath) || (yield* isMiseWrapperScript(context)); + const manual = (installPath: string) => + makeManualOnlyProviderMaintenanceCapabilities({ ...owner, installPath }); + const notOwned = (installPath: string) => (looksMise ? manual(installPath) : null); + const commandName = normalizeCommandPath(context.resolvedCommandPath) + .split("/") + .at(-1) + ?.replace(/\.(?:exe|cmd|bat|ps1)$/, ""); + if (!mise) return notOwned(context.realCommandPath); + const miseTool = mise.tool; + // A shim runs whatever mise resolves for its own name, which must be the mapped tool's. + if (isShim && commandName !== miseTool) return manual(context.realCommandPath); + + const fileSystem = yield* FileSystem.FileSystem; + const realPath = (target: string) => + fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => target)); + const misePath = + (yield* resolveCommandPath("mise", { env: context.env })) ?? + (isShim ? context.realCommandPath : null); + if (!misePath) return notOwned(context.realCommandPath); + // Asked even off mise's usual paths, since `MISE_DATA_DIR` can put installs anywhere. + const which = nonEmptyString(yield* runProbe(misePath, ["which", miseTool], context.env)); + const resolvedPath = which ? yield* realPath(which) : null; + // Through a shim T3 Code runs whatever mise resolves; otherwise the configured binary. + const binaryPath = isShim ? resolvedPath : context.realCommandPath; + if (!binaryPath) return manual(context.realCommandPath); + const binary = normalizeCommandPath(binaryPath); + const runsBinary = resolvedPath !== null && normalizeCommandPath(resolvedPath) === binary; + const listing = yield* runProbe( + misePath, + ["ls", "--json", "--installed", miseTool], + context.env, + ); + if (!listing) return runsBinary ? manual(binaryPath) : notOwned(binaryPath); + const isUnder = (path: string, dir: string) => + path.startsWith(`${normalizeCommandPath(dir).replace(/\/+$/, "")}/`); + // An inactive or shadowed install is still mise's, even when `mise which` resolves another. + const owning = yield* Effect.filter(miseToolInstalls(listing, miseTool), (install) => + realPath(install.install_path).pipe( + Effect.map((installPath) => isUnder(binary, installPath)), + ), + ); + // `mise which` also resolves binaries from other tools, such as npm globals under mise's Node. + if (owning.length === 0) return notOwned(binaryPath); + if (!runsBinary) return manual(binaryPath); + const install = owning.find( + (candidate) => candidate.active === true && candidate.requested_version === "latest", + ); + if (!install || mise.upgrade === false) return manual(binaryPath); + if (!isShim) { + // Keep the path's case: `realPath` needs it on case-sensitive filesystems. + const toolDir = install.install_path + .replaceAll("\\", "/") + .replace(/\/+$/, "") + .replace(/\/[^/]+$/, ""); + const latestDirs = [toolDir, yield* realPath(toolDir)].map((dir) => `${dir}/latest`); + const resolved = normalizeCommandPath(context.resolvedCommandPath); + if (!latestDirs.some((dir) => isUnder(resolved, dir))) return manual(binaryPath); + } + const args = ["upgrade", miseTool]; + const services = yield* Effect.context(); + // Mise reads its settings from this environment, so each one gets its own cached answer. + const envHash = NodeCrypto.createHash("sha256"); + for (const [name, value] of Object.entries(context.env).toSorted(([a], [b]) => + a < b ? -1 : 1, + )) { + envHash.update(`${name}\0${value ?? ""}\0`); + } + const envKey = envHash.digest("hex"); + const capabilities = makeProviderMaintenanceCapabilities({ + ...owner, + updateExecutable: misePath, + updateArgs: args, + updateLockKey: "mise", + updateCommand: ["mise", ...args].join(" "), + platform: context.platform, + // Mise read its config from this environment, so the upgrade runs in it too. + env: context.env, + // Settings such as `minimum_release_age` can hold mise behind the registry, + // so only mise can say what it installs. It may go to the network, so ask lazily. + latestVersion: null, + lookupLatestVersion: runProbe(misePath, ["latest", miseTool], context.env).pipe( + Effect.map((latest) => { + const version = nonEmptyString(latest); + return version && /^\S+$/.test(version) ? version : null; + }), + Effect.provideContext(services), + ), + }); + return { + ...capabilities, + latestVersionCacheKey: ["mise", misePath, miseTool, envKey].join("\0"), + }; + }, +); + export const resolvePackageManagedProviderMaintenance = Effect.fn( "resolvePackageManagedProviderMaintenance", )(function* ( @@ -517,6 +666,13 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( }); } + const mise = yield* resolveMiseProviderMaintenance( + { provider: definition.provider, packageName }, + definition.mise, + context, + ); + if (mise) return mise; + const nativeUpdate = definition.nativeUpdate; const nativeEnv = nativeUpdate?.env; const native = nativeUpdate @@ -532,13 +688,11 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( : {}), }) : manual; - // A native-looking launcher can still delegate to an npm- or mise-owned install. + // A native-looking launcher can still delegate to an npm-owned install. if ( nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath) && - !npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName) && - !commandPaths.some(isMiseCommandPath) && - !(yield* isMiseWrapperScript(context)) + !npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName) ) { return native; } @@ -633,14 +787,6 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( } const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); - if ( - commandPaths.some(isMiseCommandPath) || - // Mise shims resolve to the version manager's keg, not the provider's. - (homebrew?.kind === "formula" && homebrew.name.toLowerCase() === "mise") || - (yield* isMiseWrapperScript(context)) - ) { - return manual; - } if (homebrew) { return ( (yield* resolveHomebrewProviderMaintenance( diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index c9f683c3417ee..15506a9c12340 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -37,6 +37,7 @@ import { makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, resolveHomebrewProviderMaintenance, + resolveMiseProviderMaintenance, resolveProviderMaintenanceCapabilitiesEffect, } from "@t3tools/provider-core/server/maintenanceResolver"; import { @@ -49,10 +50,10 @@ const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); // npm latest matches the stable channel installed by grok update. const GROK_NPM_PACKAGE = "@xai-official/grok"; -// `grok update` installs under `GROK_HOME`; use the resolved executable and instance environment. +// `grok update` installs under `GROK_HOME` from the resolved executable and instance environment. // npm 12 blocks the postinstall that moves the new binary in, yet exits 0, so allow it by env. -// Homebrew casks need brew upgrades; `grok update` would install a second copy. -// Keep casks manual when their brew cannot vouch for them. +// Mise and Homebrew casks upgrade through their manager; `grok update` would install a second +// copy, so a cask its brew cannot vouch for stays manual. const UPDATE: ProviderMaintenanceCapabilitiesResolver = { resolve: Effect.fn("resolveGrokMaintenance")(function* (context) { if (!context) { @@ -61,6 +62,12 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { packageName: GROK_NPM_PACKAGE, }); } + const mise = yield* resolveMiseProviderMaintenance( + { provider: DRIVER_KIND, packageName: GROK_NPM_PACKAGE }, + { tool: "grok" }, + context, + ); + if (mise) return mise; const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); // Only a cask is Grok's: a formula keg such as `node` holding an npm global is not. if (homebrew?.kind === "cask") { diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 390764c20bcd0..c24111f07d155 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -121,12 +121,13 @@ export const MuseDriver: ProviderDriver< const capabilities = yield* resolveInstallation(options); // The maintenance runner requests fresh capabilities around an explicit update // and needs the native target version to verify that the command actually upgraded. - const latestVersion = options?.fresh - ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), - ) - : undefined; + const latestVersion = + options?.fresh && !capabilities.lookupLatestVersion + ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + ) + : undefined; return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities; }); const snapshot = yield* makeManagedServerProvider>({ diff --git a/packages/provider-muse/src/server/maintenance.ts b/packages/provider-muse/src/server/maintenance.ts index 0a3a59de128e8..342e91638b830 100644 --- a/packages/provider-muse/src/server/maintenance.ts +++ b/packages/provider-muse/src/server/maintenance.ts @@ -12,6 +12,7 @@ import { makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, + resolveMiseProviderMaintenance, } from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; @@ -37,7 +38,10 @@ export const compareMuseVersions = (current: string, latest: string) => ? compareSemverVersions(comparableMuseVersion(current), comparableMuseVersion(latest)) : compareSemverVersions(current.replace(MUSE_REVISION, ""), latest.replace(MUSE_REVISION, "")); -/** Only the official launcher owns an update channel; a standalone binary cannot update itself. */ +/** + * Mise upgrades a binary it installed. Otherwise only the official launcher owns + * an update channel; a standalone binary cannot update itself. + */ export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { resolve: Effect.fn("MuseMaintenance.resolve")(function* (context) { const manual = { @@ -45,6 +49,12 @@ export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { compareVersions: compareMuseVersions, }; if (!context || context.platform === "win32") return manual; + const mise = yield* resolveMiseProviderMaintenance( + { provider: DRIVER, packageName: null }, + { tool: "muse" }, + context, + ); + if (mise) return { ...mise, compareVersions: compareMuseVersions }; const fs = yield* FileSystem.FileSystem; const isLauncher = yield* Effect.gen(function* () { const stat = yield* fs.stat(context.realCommandPath); @@ -119,6 +129,15 @@ export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (inp readonly environment: NodeJS.ProcessEnv; }) { const { snapshot } = input; + const options = { enableProviderUpdateChecks: input.enableProviderUpdateChecks }; + // Mise names the version it would install, so its answer alone counts. + if (input.maintenanceCapabilities.lookupLatestVersion) { + return yield* enrichProviderSnapshotWithVersionAdvisory( + snapshot, + input.maintenanceCapabilities, + options, + ); + } const latestVersion = input.enableProviderUpdateChecks && snapshot.enabled && snapshot.installed && snapshot.version ? yield* latestMuseVersion(input.environment) @@ -126,6 +145,6 @@ export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (inp return yield* enrichProviderSnapshotWithVersionAdvisory( snapshot, { ...input.maintenanceCapabilities, latestVersion }, - { enableProviderUpdateChecks: input.enableProviderUpdateChecks }, + options, ); }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 8da2dfe6f3ae4..0da1a04ff1fd7 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -88,6 +88,8 @@ export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) => isCommandPath: isOpenCodeNativeCommandPath, requiresCommandPath: true, }, + // mise's `opencode` follows releases into 2.x, so it would upgrade 1.x across that line. + mise: { tool: "opencode", upgrade: generation === "v2" }, }); type OpenCodeRuntimeProbe = Effect.Success< diff --git a/packages/provider-pi/src/server/driver.ts b/packages/provider-pi/src/server/driver.ts index c37e5d757f382..9f32de9d29c4d 100644 --- a/packages/provider-pi/src/server/driver.ts +++ b/packages/provider-pi/src/server/driver.ts @@ -45,6 +45,7 @@ const DRIVER_KIND = ProviderDriverKind.make("pi"); const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@earendil-works/pi-coding-agent", + mise: { tool: "pi" }, nativeUpdate: { args: ["update", "--self"] }, }); From 0d809db501817356882ed4a2d79b3f2d52f515f1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:13:51 +0100 Subject: [PATCH 03/88] perf(sidebar): avoid full git status for branch labels (#32947) Co-authored-by: GPT 6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- .../git/GitBranchesSheet.occupancy.test.tsx | 2 +- .../threads/git/GitBranchesSheet.test.tsx | 2 +- .../features/threads/git/GitBranchesSheet.tsx | 2 +- apps/server/src/auth/RpcAuthorization.test.ts | 61 ++++ apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../src/observability/RpcInstrumentation.ts | 2 + .../src/vcs/VcsStatusBroadcaster.test.ts | 272 +++++++++++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 146 ++++++++-- apps/server/src/ws.ts | 3 + .../components/sidebar/SidebarThreadRow.tsx | 37 ++- apps/web/src/state/vcs.ts | 6 + .../client-runtime/src/rpc/client.test.ts | 71 ++++- packages/client-runtime/src/rpc/client.ts | 72 ++++- packages/client-runtime/src/state/vcs.test.ts | 78 +++++ packages/client-runtime/src/state/vcs.ts | 21 +- packages/contracts/src/environment.ts | 1 + packages/contracts/src/git.ts | 3 + packages/contracts/src/rpc.ts | 18 ++ 19 files changed, 751 insertions(+), 49 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx index 26337e8be75a4..b2d29db8c8c72 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx @@ -51,7 +51,7 @@ vi.mock("../../../state/use-selected-thread-git-actions", () => ({ useSelectedThreadGitActions: () => ({ canChangeThreadBranch: true }), })); vi.mock("../../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); -vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { branch: () => null } })); import { GitBranchesSheet } from "./GitBranchesSheet"; diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx index 509dd51240219..6bd008116babb 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx @@ -71,7 +71,7 @@ vi.mock("../../../state/use-selected-thread-git-actions", () => ({ }, }), })); -vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { branch: () => null } })); vi.mock("./gitSheetComponents", () => ({ SheetActionButton: (props: ActionProps) => { state.actions.set(props.label, props); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index f8ddd8d7498bb..67d0818ee6c4d 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -33,7 +33,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null - ? vcsEnvironment.status({ + ? vcsEnvironment.branch({ environmentId: selectedThread.environmentId, input: { cwd: selectedThreadCwd }, }) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index e59c428dca0aa..b5cade0f84d46 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -68,6 +68,12 @@ describe("RPC authorization scopes", () => { } }); + it("lets thread readers observe and refresh checkout branches", () => { + for (const method of [WS_METHODS.subscribeVcsBranch, WS_METHODS.vcsRefreshBranch]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( @@ -133,6 +139,61 @@ describe("RPC scope middleware", () => { ); }); +it.effect.each([WS_METHODS.subscribeVcsBranch, WS_METHODS.subscribeVcsStatus] as const)( + "enforces read permission on stream %s", + (method) => + Effect.gen(function* () { + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + ( + tag, + ): tag is Exclude< + keyof typeof RPC_REQUIRED_SCOPES, + typeof WS_METHODS.subscribeVcsBranch | typeof WS_METHODS.subscribeVcsStatus + > => tag !== WS_METHODS.subscribeVcsBranch && tag !== WS_METHODS.subscribeVcsStatus, + ), + ); + let handled = 0; + const handlers = Layer.mergeAll( + group.toLayerHandler(WS_METHODS.subscribeVcsBranch, () => + Stream.fromEffect( + Effect.sync(() => { + handled++; + return { refName: "main" }; + }), + ), + ), + group.toLayerHandler(WS_METHODS.subscribeVcsStatus, () => + Stream.fromEffect( + Effect.sync(() => { + handled++; + }), + ).pipe(Stream.drain), + ), + ); + const denied = yield* RpcTest.makeClient(group).pipe( + Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([]))), + ); + expect( + yield* Stream.runDrain( + method === WS_METHODS.subscribeVcsBranch + ? denied[method]({ cwd: "/repo" }).pipe(Stream.drain) + : denied[method]({ cwd: "/repo" }).pipe(Stream.drain), + ).pipe(Effect.flip), + ).toMatchObject({ requiredPermission: AuthOrchestrationReadScope }); + expect(handled).toBe(0); + const granted = yield* RpcTest.makeClient(group).pipe( + Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([AuthOrchestrationReadScope]))), + ); + yield* Stream.runDrain( + method === WS_METHODS.subscribeVcsBranch + ? granted[method]({ cwd: "/repo" }).pipe(Stream.drain) + : granted[method]({ cwd: "/repo" }).pipe(Stream.drain), + ); + expect(handled).toBe(1); + }).pipe(Effect.scoped), +); + describe("settings mutation authorization", () => { const group = WsRpcGroup.omit( ...[...WsRpcGroup.requests.keys()].filter( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 174a5e8b29cee..70b8db54f6775 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -169,6 +169,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.mcpAppsReadResource]: AuthOrchestrationReadScope, [WS_METHODS.mcpAppsUpdateModelContext]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeVcsBranch]: AuthOrchestrationReadScope, + [WS_METHODS.vcsRefreshBranch]: AuthOrchestrationReadScope, [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 6d2c30db16130..ddce94a44e363 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -227,6 +227,7 @@ export const make = Effect.gen(function* () { pullRequests: true, pullRequestChecks: true, sideChats: true, + vcsBranchStatus: true, inlineMessageContext: true, requiredWorktreeBootstrap: true, threadSettlement: true, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index d19f83a932d1f..c6b60310d4939 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -173,6 +173,8 @@ const RPC_AGGREGATES = { [WS_METHODS.mcpAppsUpdateModelContext]: "provider", [WS_METHODS.mcpAppsReadResource]: "provider", [WS_METHODS.subscribeVcsStatus]: "vcs", + [WS_METHODS.subscribeVcsBranch]: "vcs", + [WS_METHODS.vcsRefreshBranch]: "vcs", [WS_METHODS.subscribeWorktreeSetup]: "vcs", [WS_METHODS.worktreeSetupCancel]: "vcs", [WS_METHODS.subscribeResourceTelemetry]: "server", diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 60f39b592d6de..a9df07044943a 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -11,6 +11,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as Scheduler from "effect/Scheduler"; import * as Stream from "effect/Stream"; @@ -77,6 +78,9 @@ function layerTestFor(state: { automaticFetchInterval?: Effect.Effect; beforeRemoteStatus?: Effect.Effect; beforeLocalStatus?: Effect.Effect; + beforeCurrentBranch?: Effect.Effect; + beforeInvalidate?: Effect.Effect; + branchRead?: () => Effect.Effect; fileSystem?: FileSystem.FileSystem; pullCurrentBranch?: GitWorkflowService.GitWorkflowService["Service"]["pullCurrentBranch"]; }) { @@ -102,7 +106,12 @@ function layerTestFor(state: { Effect.as(state.currentLocalStatus), ); }), - currentBranch: () => Effect.sync(() => state.currentLocalStatus.refName), + currentBranch: () => + state.branchRead + ? state.branchRead() + : (state.beforeCurrentBranch ?? Effect.void).pipe( + Effect.andThen(Effect.sync(() => state.currentLocalStatus.refName)), + ), remoteStatus: (_input, options) => Effect.suspend(() => { return (state.beforeRemoteStatus ?? Effect.void).pipe( @@ -121,7 +130,7 @@ function layerTestFor(state: { invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; - }), + }).pipe(Effect.andThen(state.beforeInvalidate ?? Effect.void)), invalidateRemoteStatus: () => Effect.sync(() => { state.remoteInvalidationCalls += 1; @@ -207,6 +216,135 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); }); + it.effect( + "streams fresh branch-only reads, detached HEAD, non-repositories, and detailed demand without mixing results", + () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + fileSystem: FileSystem.makeNoop({ + realPath: (cwd) => Effect.succeed(cwd === "/alias" ? "/repo" : cwd), + }), + }; + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const branches = yield* Stream.toPull(broadcaster.streamBranchStatus({ cwd: "/repo" })); + assert.deepEqual(yield* branches, [{ refName: baseLocalStatus.refName }]); + assert.equal(state.localStatusCalls, 0); + state.currentLocalStatus = { ...baseLocalStatus, refName: "external" }; + yield* broadcaster.refreshBranchStatus({ cwd: "/repo" }); + assert.deepEqual(yield* branches, [{ refName: "external" }]); + state.currentLocalStatus = { ...baseLocalStatus, refName: null }; + yield* broadcaster.refreshBranchStatus({ cwd: "/repo" }); + assert.deepEqual(yield* branches, [{ refName: null }]); + assert.equal(state.localStatusCalls, 0); + const detailScope = yield* Scope.make(); + const details = yield* Stream.toPull( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + ).pipe(Scope.provide(detailScope)); + assert.deepEqual(yield* details, [ + { _tag: "snapshot", local: state.currentLocalStatus, remote: null }, + ]); + const callsWithDetail = state.localStatusCalls; + const extraBranches = yield* Stream.toPull( + broadcaster.streamBranchStatus({ cwd: "/repo" }), + ); + yield* extraBranches; + yield* broadcaster.refreshBranchStatus({ cwd: "/repo" }); + assert.equal(state.localStatusCalls, callsWithDetail); + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "changed", + workingTree: { files: [], insertions: 12, deletions: 3 }, + }; + yield* broadcaster.refreshBranchStatus({ cwd: "/repo" }); + assert.deepEqual(yield* details, [ + { _tag: "localUpdated", local: state.currentLocalStatus }, + ]); + assert.deepEqual(yield* branches, [{ refName: "changed" }]); + const secondScope = yield* Scope.make(); + const secondDetails = yield* Stream.toPull( + broadcaster.streamStatus({ cwd: "/alias", includeRemote: false }), + ).pipe(Scope.provide(secondScope)); + yield* secondDetails; + yield* Scope.close(detailScope, Exit.void); + state.currentLocalStatus = { + ...state.currentLocalStatus, + workingTree: { files: [], insertions: 20, deletions: 4 }, + }; + yield* broadcaster.refreshLocalStatus("/repo"); + assert.deepEqual(yield* secondDetails, [ + { _tag: "localUpdated", local: state.currentLocalStatus }, + ]); + yield* Scope.close(secondScope, Exit.void); + const callsAfterRelease = state.localStatusCalls; + state.currentLocalStatus = { + ...state.currentLocalStatus, + workingTree: { files: [], insertions: 30, deletions: 5 }, + }; + yield* broadcaster.refreshBranchStatus({ cwd: "/repo" }); + assert.equal(state.localStatusCalls, callsAfterRelease); + const reopened = yield* Stream.toPull( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + ); + assert.deepEqual(yield* reopened, [ + { _tag: "snapshot", local: state.currentLocalStatus, remote: null }, + ]); + state.currentLocalStatus = { + ...baseLocalStatus, + isRepo: false, + refName: null, + }; + yield* broadcaster.refreshBranchStatus({ cwd: "/repo" }); + assert.deepEqual(yield* reopened, [ + { _tag: "localUpdated", local: state.currentLocalStatus }, + ]); + assert.deepEqual(yield* branches, [{ refName: null }]); + assert.equal(state.remoteStatusCalls, 0); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }, + ); + + it.effect("upgrades an in-flight branch refresh when detailed demand arrives", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const reading = yield* Deferred.make(); + const resume = yield* Deferred.make(); + state.beforeCurrentBranch = Deferred.succeed(reading, undefined).pipe( + Effect.andThen(Deferred.await(resume)), + ); + const refresh = yield* broadcaster + .refreshBranchStatus({ cwd: "/repo" }) + .pipe(Effect.forkScoped); + yield* Deferred.await(reading); + const detailsFiber = yield* Stream.runHead( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + ).pipe(Effect.forkScoped); + state.currentLocalStatus = { + ...baseLocalStatus, + workingTree: { files: [], insertions: 8, deletions: 2 }, + }; + yield* Deferred.succeed(resume, undefined); + yield* Fiber.join(refresh); + assert.deepEqual( + yield* Fiber.join(detailsFiber), + Option.some({ _tag: "snapshot" as const, local: state.currentLocalStatus, remote: null }), + ); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + it.effect("publishes terminal branch switches and validates cached status reads", () => { const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, @@ -419,6 +557,7 @@ describe("VcsStatusBroadcaster", () => { state.backgroundWorkEnabled = false; state.currentRemoteStatus = { ...remoteStatusWithPr, aheadCount: 7 }; + yield* broadcaster.refreshBranchStatus({ cwd: "/repo" }); const refreshed = yield* broadcaster.refreshPullRequestStatus("/repo"); assert.deepStrictEqual(refreshed, state.currentRemoteStatus); const merged = yield* broadcaster.getStatus({ cwd: "/repo" }); @@ -1112,3 +1251,132 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTest)); }); }); + +const newState = (): Parameters[0] => ({ + fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }), + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, +}); +it.effect("detail join during invalidation must get fresh totals", () => { + const state = newState(); + return Effect.gen(function* () { + const b = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const oldScope = yield* Scope.make(); + const old = yield* Stream.toPull(b.streamStatus({ cwd: "/repo", includeRemote: false })).pipe( + Scope.provide(oldScope), + ); + yield* old; + yield* Scope.close(oldScope, Exit.void); + state.currentLocalStatus = { + ...baseLocalStatus, + workingTree: { files: [], insertions: 42, deletions: 0 }, + }; + const entered = yield* Deferred.make(); + const resume = yield* Deferred.make(); + state.beforeInvalidate = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(resume)), + ); + const refresh = yield* b.refreshBranchStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(entered); + const snapshotFiber = yield* Stream.runHead( + b.streamStatus({ cwd: "/repo", includeRemote: false }), + ).pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(resume, undefined); + yield* Fiber.join(refresh); + assert.deepEqual( + yield* Fiber.join(snapshotFiber), + Option.some({ _tag: "snapshot" as const, local: state.currentLocalStatus, remote: null }), + ); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); +}); +it.effect("serializes branch reads with detailed refresh publication", () => { + const state = newState(); + return Effect.gen(function* () { + const b = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const entered = yield* Deferred.make(); + const resume = yield* Deferred.make(); + state.branchRead = () => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(resume)), + Effect.as("old"), + ); + const older = yield* b.refreshBranchStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(entered); + state.currentLocalStatus = { ...baseLocalStatus, refName: "new" }; + const newer = yield* b + .refreshLocalStatus("/repo") + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(resume, undefined); + yield* Fiber.join(older); + yield* Fiber.join(newer); + state.branchRead = () => Effect.succeed("new"); + const result = yield* b.getStatus({ cwd: "/repo" }); + assert.equal(result.refName, "new"); + assert.equal(state.localStatusCalls, 1); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); +}); + +it.effect("publishes overlapping branch reads in checkout order", () => { + const state = newState(); + return Effect.gen(function* () { + const b = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const branches = yield* Stream.toPull(b.streamBranchStatus({ cwd: "/repo" })); + yield* branches; + const entered = yield* Deferred.make(); + const resume = yield* Deferred.make(); + let reads = 0; + state.branchRead = () => + ++reads === 1 + ? Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(resume)), + Effect.as("old"), + ) + : Effect.succeed("new"); + const older = yield* b.refreshBranchStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(entered); + const newer = yield* b + .refreshBranchStatus({ cwd: "/repo" }) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(resume, undefined); + yield* Fiber.join(older); + yield* Fiber.join(newer); + assert.deepEqual(yield* branches, [{ refName: "old" }, { refName: "new" }]); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); +}); + +it.effect("does not replay older queued branches after a new subscriber snapshot", () => { + const state = newState(); + return Effect.gen(function* () { + const b = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const entered = yield* Deferred.make(); + const resume = yield* Deferred.make(); + const values = yield* Queue.unbounded<{ readonly refName: string | null }>(); + let reads = 0; + state.branchRead = () => + ++reads === 1 + ? Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(resume)), + Effect.as("old"), + ) + : Effect.succeed("new"); + const older = yield* b.refreshBranchStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(entered); + const subscriber = yield* b.streamBranchStatus({ cwd: "/repo" }).pipe( + Stream.tap((value) => Queue.offer(values, value)), + Stream.take(2), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + yield* Deferred.succeed(resume, undefined); + yield* Fiber.join(older); + assert.deepEqual(yield* Queue.take(values), { refName: "new" }); + state.branchRead = () => Effect.succeed("latest"); + yield* b.refreshBranchStatus({ cwd: "/repo" }); + assert.deepEqual(yield* Queue.take(values), { refName: "latest" }); + yield* Fiber.join(subscriber); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); +}); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 3921077ebbb42..be1c86940e64e 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -15,6 +15,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import type { GitManagerServiceError, VcsStatusInput, + VcsBranchStatus, VcsStatusLocalResult, VcsStatusRemoteResult, VcsStatusResult, @@ -205,6 +206,12 @@ function remoteRefreshFailureDelay( export class VcsStatusBroadcaster extends Context.Service< VcsStatusBroadcaster, { + readonly streamBranchStatus: ( + input: VcsStatusInput, + ) => Stream.Stream; + readonly refreshBranchStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; readonly streamBranchChanges: Stream.Stream<{ readonly cwd: string; readonly refName: string | null; @@ -250,12 +257,32 @@ export const make = Effect.gen(function* () { (pubsub) => PubSub.shutdown(pubsub), ); const branchChangesPubSub = yield* Effect.acquireRelease( - PubSub.unbounded<{ readonly cwd: string; readonly refName: string | null }>(), + PubSub.unbounded<{ + readonly cwd: string; + readonly refName: string | null; + readonly revision: number; + }>(), (pubsub) => PubSub.shutdown(pubsub), ); const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); + const detailedSubscribers = new Map(); + const branches = new Map< + string, + { readonly refName: string | null; readonly revision: number } + >(); + let branchRevision = 0; + const publishBranch = Effect.fn("VcsStatusBroadcaster.publishBranch")(function* ( + cwd: string, + refName: string | null, + ) { + if (branches.get(cwd)?.refName === refName) return; + const revision = ++branchRevision; + branches.set(cwd, { refName, revision }); + yield* PubSub.publish(branchChangesPubSub, { cwd, refName, revision }); + }); + const localRefreshLocks = yield* KeyedLock.make(); const cacheRef = yield* Ref.make(new Map()); // One permit per cwd for remote reads that write the cache. Without it a // periodic poll that started before an agent creates a PR can finish after the @@ -274,21 +301,15 @@ export const make = Effect.gen(function* () { const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local }; - const [localChanged, branchChanged, remoteCleared] = yield* Ref.modify(cacheRef, (cache) => { + const [localChanged, remoteCleared] = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const remote = previous.remote?.branch === local.refName ? previous.remote : null; return [ - [ - previous.local?.fingerprint !== nextLocal.fingerprint, - previous.local?.value.refName !== local.refName, - previous.remote !== remote, - ], + [previous.local?.fingerprint !== nextLocal.fingerprint, previous.remote !== remote], new Map(cache).set(cwd, { local: nextLocal, remote }), ] as const; }); - if (branchChanged && local.isRepo) { - yield* PubSub.publish(branchChangesPubSub, { cwd, refName: local.refName }); - } + yield* publishBranch(cwd, local.refName); if (options?.publish && remoteCleared) { yield* PubSub.publish(changesPubSub, { cwd, @@ -332,24 +353,30 @@ export const make = Effect.gen(function* () { return yield* updateCachedLocalStatus(cwd, local); }); - const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( - cwd: string, - ) { - const cached = yield* getCachedStatus(cwd); - if (cached?.local) { - const branch = yield* workflow.currentBranch(cwd); - if (branch === cached.local.value.refName) return cached.local.value; - return yield* refreshLocalStatusCore(cwd); - } - return yield* loadLocalStatus(cwd); - }); + const getOrLoadLocalStatusUnlocked = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")( + function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + const branch = yield* workflow.currentBranch(cwd); + if (branch === cached.local.value.refName) return cached.local.value; + yield* workflow.invalidateLocalStatus(cwd); + return yield* updateCachedLocalStatus(cwd, yield* workflow.localStatus({ cwd }), { + publish: true, + }); + } + return yield* loadLocalStatus(cwd); + }, + ); + + const getOrLoadLocalStatus = (cwd: string) => + localRefreshLocks.withLock(cwd, getOrLoadLocalStatusUnlocked(cwd)); const getStatusParts = Effect.fn("VcsStatusBroadcaster.getStatusParts")(function* (cwd: string) { const cached = yield* getCachedStatus(cwd); if (cached?.local) { return { local: cached.local.value, remote: cached.remote?.value ?? null }; } - return { local: yield* loadLocalStatus(cwd), remote: null }; + return { local: yield* getOrLoadLocalStatus(cwd), remote: null }; }); const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); @@ -378,7 +405,6 @@ export const make = Effect.gen(function* () { // A refresh that started after a request saw every change that request could mean, so queued // callers share it instead of each running git status again. - const localRefreshLocks = yield* KeyedLock.make(); const localRefreshRequests = new Map(); const localRefreshResults = new Map< string, @@ -556,7 +582,7 @@ export const make = Effect.gen(function* () { cwd, Effect.gen(function* () { const cached = yield* getCachedStatus(cwd); - if (!cached?.local || cached.remote?.value === null) return null; + if (!cached || cached.remote?.value === null) return null; // Turn completion must update divergence even when background polling is paused. const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream: false, @@ -738,6 +764,18 @@ export const make = Effect.gen(function* () { Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + yield* Effect.acquireRelease( + Effect.sync(() => { + const previous = detailedSubscribers.get(cwd) ?? 0; + detailedSubscribers.set(cwd, previous + 1); + }), + () => + Effect.sync(() => { + const remaining = (detailedSubscribers.get(cwd) ?? 1) - 1; + if (remaining === 0) detailedSubscribers.delete(cwd); + else detailedSubscribers.set(cwd, remaining); + }), + ); const subscription = yield* PubSub.subscribe(changesPubSub); const initialLocal = yield* getOrLoadLocalStatus(cwd); const cachedStatus = yield* getCachedStatus(cwd); @@ -768,8 +806,66 @@ export const make = Effect.gen(function* () { }), ); + // Serialize branch publication and cache invalidation with detailed reads and refreshes. + const refreshBranchStatusCore = Effect.fn("VcsStatusBroadcaster.refreshBranchStatusCore")( + (cwd: string) => + localRefreshLocks.withLock( + cwd, + Effect.gen(function* () { + if (detailedSubscribers.has(cwd)) { + const local = yield* getOrLoadLocalStatusUnlocked(cwd); + return { refName: local.refName, revision: branches.get(cwd)?.revision ?? 0 }; + } + const refName = yield* workflow.currentBranch(cwd); + // HEAD cannot certify working-tree totals, even when it has not moved. + localRefreshResults.delete(cwd); + yield* workflow.invalidateLocalStatus(cwd); + yield* Ref.update(cacheRef, (cache) => { + const previous = cache.get(cwd); + return previous ? new Map(cache).set(cwd, { ...previous, local: null }) : cache; + }); + if (detailedSubscribers.has(cwd)) { + const local = yield* workflow.localStatus({ cwd }); + yield* updateCachedLocalStatus(cwd, local, { publish: true }); + return { refName: local.refName, revision: branches.get(cwd)?.revision ?? 0 }; + } + yield* publishBranch(cwd, refName); + return { refName, revision: branches.get(cwd)?.revision ?? 0 }; + }), + ), + ); + + const refreshBranchStatus: VcsStatusBroadcaster["Service"]["refreshBranchStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshBranchStatus", + )(function* (input) { + const { refName } = yield* refreshBranchStatusCore( + yield* withFileSystem(normalizeCwd(input.cwd)), + ); + return { refName }; + }); + + const streamBranchStatus: VcsStatusBroadcaster["Service"]["streamBranchStatus"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const subscription = yield* PubSub.subscribe(branchChangesPubSub); + const branch = yield* refreshBranchStatusCore(cwd); + return Stream.concat( + Stream.make({ refName: branch.refName }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === cwd && event.revision > branch.revision), + Stream.map(({ refName }) => ({ refName })), + ), + ).pipe(Stream.changesWith((left, right) => left.refName === right.refName)); + }), + ); + return VcsStatusBroadcaster.of({ - streamBranchChanges: Stream.fromPubSub(branchChangesPubSub), + streamBranchStatus, + refreshBranchStatus, + streamBranchChanges: Stream.fromPubSub(branchChangesPubSub).pipe( + Stream.map(({ cwd, refName }) => ({ cwd, refName })), + ), getStatus, refreshLocalStatus, refreshStatus, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1fa81ddc7026e..8d272dd584eeb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3112,6 +3112,9 @@ export const layerWsRpc = ( }), [WS_METHODS.assetsPersistChatAttachments]: (input) => persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))), + [WS_METHODS.subscribeVcsBranch]: (input) => vcsStatusBroadcaster.streamBranchStatus(input), + [WS_METHODS.vcsRefreshBranch]: (input) => + vcsStatusBroadcaster.refreshBranchStatus(input).pipe(Effect.asVoid), [WS_METHODS.subscribeVcsStatus]: (input) => vcsStatusBroadcaster.streamStatus(input), [WS_METHODS.subscribeWorktreeSetup]: (input) => worktreeSetupTracker.stream(input.threadId), [WS_METHODS.worktreeSetupCancel]: (input) => diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 41cb733daff15..39cf531b2a3c3 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1,3 +1,4 @@ +import { useAtomMount } from "@effect/atom-react"; import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; @@ -68,7 +69,7 @@ import { useUiStateStore } from "../../uiStateStore"; import { useThreadSelectionStore } from "../../threadSelectionStore"; import { useNowMinute } from "../../hooks/useNowMinute"; import { useNowSecond } from "../../hooks/useNowSecond"; -import { vcsEnvironment } from "../../state/vcs"; +import { vcsEnvironment, vcsBranchRevalidation } from "../../state/vcs"; import { useEnvironmentQuery } from "../../state/query"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { formatRelativeTimeLabel, parseTimestampDate } from "../../timestampFormat"; @@ -687,14 +688,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP thread.pullRequests, thread.branchPullRequest, ); - const gitStatus = useEnvironmentQuery( + const branchTarget = leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd, includeRemote: false }, - }) - : null, + ? { environmentId: thread.environmentId, cwd: gitCwd } + : null; + const gitStatus = useEnvironmentQuery( + branchTarget === null + ? null + : vcsEnvironment.branch({ + environmentId: branchTarget.environmentId, + input: { cwd: branchTarget.cwd }, + }), ); + useAtomMount(vcsBranchRevalidation(branchTarget)); const visibleGitStatus = useRetainedValue( JSON.stringify([thread.environmentId, gitCwd]), gitStatus.data, @@ -1640,14 +1646,19 @@ export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props props.isHighlighted || props.isRouteActive, ); const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; - const gitStatus = useEnvironmentQuery( + const branchTarget = leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd, includeRemote: false }, - }) - : null, + ? { environmentId: thread.environmentId, cwd: gitCwd } + : null; + const gitStatus = useEnvironmentQuery( + branchTarget === null + ? null + : vcsEnvironment.branch({ + environmentId: branchTarget.environmentId, + input: { cwd: branchTarget.cwd }, + }), ); + useAtomMount(vcsBranchRevalidation(branchTarget)); const visibleGitStatus = useRetainedValue( JSON.stringify([thread.environmentId, gitCwd]), gitStatus.data, diff --git a/apps/web/src/state/vcs.ts b/apps/web/src/state/vcs.ts index fd4f34caaa68d..7604ff3b23773 100644 --- a/apps/web/src/state/vcs.ts +++ b/apps/web/src/state/vcs.ts @@ -19,3 +19,9 @@ export const vcsStatusRevalidation = createVcsStatusRevalidationAtomFamily({ subscribeWake: subscribeDocumentReturn, wakeDebounceMs: 250, }); + +export const vcsBranchRevalidation = createVcsStatusRevalidationAtomFamily({ + refreshStatus: vcsEnvironment.refreshBranch, + subscribeWake: subscribeDocumentReturn, + wakeDebounceMs: 250, +}); diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index e9779c4f86606..9207ff1aafd58 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, type RelayClientInstallProgressEvent, + type ServerConfig, type ServerLifecycleStreamEvent, WS_METHODS, } from "@t3tools/contracts"; @@ -26,7 +27,7 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { runStream, subscribe, subscribeDynamicWithSession } from "./client.ts"; +import { runStream, subscribe, subscribeDynamicWithSession, refreshVcsBranch } from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -414,3 +415,71 @@ describe("environment RPC", () => { }), ); }); + +it.effect.each([true, false])( + "refreshes branches using the connected server capability (%s)", + (supported) => + Effect.gen(function* () { + const { activeSession, supervisor } = yield* makeHarness(); + const inputs: unknown[] = []; + const client = { + [supported ? WS_METHODS.vcsRefreshBranch : WS_METHODS.vcsRefreshStatus]: ( + input: unknown, + ) => { + inputs.push(input); + return Effect.void; + }, + } as unknown as WsRpcProtocolClient; + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(client), + initialConfig: Effect.succeed({ + environment: { capabilities: supported ? { vcsBranchStatus: true } : {} }, + } as ServerConfig), + }), + ); + expect( + yield* refreshVcsBranch({ cwd: "/repo" }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ), + ).toBeUndefined(); + expect(inputs).toEqual(supported ? [{ cwd: "/repo" }] : []); + }).pipe(Effect.scoped), +); + +it.effect("binds branch capability selection and execution to one session", () => + Effect.gen(function* () { + const { activeSession, supervisor } = yield* makeHarness(); + const configRequested = yield* Deferred.make(); + const config = yield* Deferred.make(); + const executed = yield* Deferred.make(); + const original = { + [WS_METHODS.vcsRefreshBranch]: () => + Deferred.succeed(executed, undefined).pipe(Effect.asVoid), + } as unknown as WsRpcProtocolClient; + const replacement = { + [WS_METHODS.vcsRefreshStatus]: () => Effect.succeed({ refName: "replacement" }), + } as unknown as WsRpcProtocolClient; + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(original), + initialConfig: Deferred.succeed(configRequested, undefined).pipe( + Effect.andThen(Deferred.await(config)), + ), + }), + ); + const refresh = yield* refreshVcsBranch({ cwd: "/repo" }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* Deferred.await(configRequested); + yield* SubscriptionRef.set(activeSession, Option.some(session(replacement))); + yield* Deferred.succeed(config, { + environment: { capabilities: { vcsBranchStatus: true } }, + } as ServerConfig); + expect(yield* Fiber.join(refresh)).toBeUndefined(); + yield* Deferred.await(executed); + }).pipe(Effect.scoped), +); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index e74abde6a2c59..d3a49c2aa58b3 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -68,6 +68,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.subscribeVcsStatus + | typeof WS_METHODS.subscribeVcsBranch | typeof WS_METHODS.subscribeWorktreeSetup | typeof WS_METHODS.subscribeProjectClones | typeof WS_METHODS.terminalAttach @@ -186,15 +187,14 @@ const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( yield* guard.authorize(supervisor.target.environmentId, method, input); }); -export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < +const requestInSession = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, ->(tag: TTag, input: EnvironmentRpcInput) { +>(session: RpcSession, tag: TTag, input: EnvironmentRpcInput) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; yield* Effect.annotateCurrentSpan({ "environment.id": supervisor.target.environmentId, "rpc.method": tag, }); - const session = yield* currentSession(); yield* authorizeRequest(tag, input); const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( @@ -208,6 +208,14 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < return yield* method(input).pipe(Effect.ensuring(completeObservation)); }); +// The session-bound request owns the existing EnvironmentRpc.request span. +export const requestGuarded = Effect.fnUntraced(function* ( + tag: TTag, + input: EnvironmentRpcInput, +) { + return yield* requestInSession(yield* currentSession(), tag, input); +}); + export function runStreamGuarded( tag: TTag, input: EnvironmentRpcInput, @@ -300,7 +308,10 @@ function subscribeDynamicMapped( method: tag, input, }); - const stream = mapStream(session, method(input)).pipe( + const stream = mapStream( + session, + Stream.suspend(() => method(input)), + ).pipe( Stream.onFirst(() => Effect.sync(() => { expectedFailureRetries = 0; @@ -436,6 +447,59 @@ export function subscribe( return subscribeDynamic(tag, () => Effect.succeed(input), options); } +export const subscribeVcsBranch = ( + input: EnvironmentRpcInput, +) => + subscribeDynamicMapped( + WS_METHODS.subscribeVcsBranch, + () => Effect.succeed(input), + (session, stream) => + Stream.unwrap( + session.initialConfig.pipe( + Effect.mapError( + (error) => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ), + Effect.map((config) => + config.environment.capabilities.vcsBranchStatus === true + ? stream + : session.client[WS_METHODS.subscribeVcsStatus]({ + ...input, + includeRemote: false, + }).pipe( + Stream.filter((event) => event._tag !== "remoteUpdated"), + Stream.map((event) => ({ refName: event.local.refName })), + ), + ), + ), + ), + ); + +export const refreshVcsBranch = Effect.fn("EnvironmentRpc.refreshVcsBranch")(function* ( + input: EnvironmentRpcInput, +) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* currentSession(); + const config = yield* session.initialConfig.pipe( + Effect.mapError( + (error) => + new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: error.message, + }), + ), + ); + if (config.environment.capabilities.vcsBranchStatus === true) { + return yield* requestInSession(session, WS_METHODS.vcsRefreshBranch, input); + } + // Older hosts retain their existing status stream without adding per-row wake scans. +}); + export const request = >( tag: TTag, input: EnvironmentRpcInput, diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index cdbda23422237..7327c8cc203ee 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -7,6 +7,7 @@ import { type VcsListRefsInput, type VcsListRefsResult, type VcsStatusResult, + type ServerConfig, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -124,6 +125,83 @@ function cacheWithRefs( }); } +describe("branch subscriptions", () => { + it.effect.each([true, false])( + "shares and releases branch streams across session replacement (capability %s)", + (supported) => + Effect.gen(function* () { + const released = yield* Queue.unbounded(); + let subscriptions = 0; + const clientFor = (refName: string, branchSupported: boolean) => + ({ + [branchSupported ? WS_METHODS.subscribeVcsBranch : WS_METHODS.subscribeVcsStatus]: + () => { + subscriptions += 1; + return Stream.concat( + Stream.make( + branchSupported + ? { refName } + : { _tag: "snapshot", local: status({ refName }), remote: null }, + ), + Stream.never, + ).pipe(Stream.ensuring(Queue.offer(released, refName))); + }, + }) as unknown as WsRpcProtocolClient; + const branchSession = (refName: string, branchSupported: boolean): RpcSession => ({ + ...session(clientFor(refName, branchSupported)), + initialConfig: Effect.succeed({ + environment: { capabilities: branchSupported ? { vcsBranchStatus: true } : {} }, + } as ServerConfig), + }); + const activeSession = yield* SubscriptionRef.make( + Option.some(branchSession("main", supported)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + followStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ); + const atoms = createVcsEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (value) => + Effect.sync(() => value.dispose()), + ); + const target = { environmentId: TARGET.environmentId, input: { cwd: "/repo" } }; + const first = yield* Stream.toPull( + AtomRegistry.toStreamResult(registry, atoms.branch(target)), + ); + expect(yield* first).toEqual([{ refName: "main" }]); + const second = yield* Stream.toPull( + AtomRegistry.toStreamResult(registry, atoms.branch(target)), + ); + expect(yield* second).toEqual([{ refName: "main" }]); + expect(subscriptions).toBe(1); + yield* SubscriptionRef.set( + activeSession, + Option.some(branchSession("external", !supported)), + ); + expect(yield* Queue.take(released)).toBe("main"); + expect(yield* first).toEqual([{ refName: "external" }]); + expect(yield* second).toEqual([{ refName: "external" }]); + registry.dispose(); + expect(yield* Queue.take(released)).toBe("external"); + }).pipe(Effect.scoped), + ); +}); + describe("cached VCS refs", () => { it("invalidates all ref streams in the mutated environment", () => { const registry = AtomRegistry.make(); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index ef508f2126c7f..12d55a7477058 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -24,7 +24,13 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as Persistence from "../platform/persistence.ts"; -import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { + request, + subscribe, + subscribeVcsBranch, + refreshVcsBranch, + type EnvironmentRpcInput, +} from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; import { @@ -294,6 +300,19 @@ export function createVcsEnvironmentAtoms( label: "environment-data:vcs:read-refs", tag: WS_METHODS.vcsListRefs, }), + branch: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:vcs:branch", + idleTtlMs: VCS_STATUS_IDLE_TTL_MS, + subscribe: (input: EnvironmentRpcInput) => + subscribeVcsBranch(input), + }), + refreshBranch: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:refresh-branch", + tag: WS_METHODS.vcsRefreshBranch, + execute: refreshVcsBranch, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", idleTtlMs: VCS_STATUS_IDLE_TTL_MS, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 96b4537478ecc..cd5e6833468e8 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -88,6 +88,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pullRequestChecks: Schema.optionalKey(Schema.Boolean), /** Side chats: hidden `lineage.sideChat` forks. Older servers ignore the fork flag. */ sideChats: Schema.optionalKey(Schema.Boolean), + vcsBranchStatus: Schema.optionalKey(Schema.Boolean), /** Every protocol-2 server sends this; older mobile builds still read it. */ inlineMessageContext: Schema.optionalKey(Schema.Boolean), requiredWorktreeBootstrap: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 0dc958f59c490..a3b5e34076219 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -111,6 +111,9 @@ export const VcsStatusInput = Schema.Struct({ }); export type VcsStatusInput = typeof VcsStatusInput.Type; +export const VcsBranchStatus = Schema.Struct({ refName: Schema.NullOr(Schema.String) }); +export type VcsBranchStatus = typeof VcsBranchStatus.Type; + export const VcsStatusSubscriptionInput = Schema.Struct({ ...VcsStatusInput.fields, /** Passive observers receive cached remote status without retaining its refresh loop. */ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a9868d64a8110..088a57cc7882d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -145,6 +145,7 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, VcsStatusInput, + VcsBranchStatus, VcsStatusSubscriptionInput, VcsStatusResult, VcsStatusStreamEvent, @@ -605,6 +606,8 @@ export const WS_METHODS = { subscribeProjectClones: "subscribeProjectClones", subscribeVcsStatus: "subscribeVcsStatus", + subscribeVcsBranch: "subscribeVcsBranch", + vcsRefreshBranch: "vcs.refreshBranch", subscribeWorktreeSetup: "subscribeWorktreeSetup", worktreeSetupCancel: "worktreeSetup.cancel", subscribeTerminalMetadata: "subscribeTerminalMetadata", @@ -1454,6 +1457,19 @@ const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, error: Schema.Union([ProviderUploadFeedbackError, EnvironmentAuthorizationError]), }); +const WsSubscribeVcsBranchRpc = Rpc.make(WS_METHODS.subscribeVcsBranch, { + payload: VcsStatusInput, + success: VcsBranchStatus, + error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), + stream: true, +}); + +const WsVcsRefreshBranchRpc = Rpc.make(WS_METHODS.vcsRefreshBranch, { + payload: VcsStatusInput, + success: Schema.Void, + error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), +}); + const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusSubscriptionInput, success: VcsStatusStreamEvent, @@ -2149,6 +2165,8 @@ export const WsRpcGroup = RpcGroup.make( WsMcpAppsUpdateModelContextRpc, WsProviderUploadFeedbackRpc, WsSubscribeVcsStatusRpc, + WsSubscribeVcsBranchRpc, + WsVcsRefreshBranchRpc, WsSubscribeWorktreeSetupRpc, WsWorktreeSetupCancelRpc, WsVcsPullRpc, From 401318317177a375f064d4fabd18d49fb4b5231f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:15:12 +0100 Subject: [PATCH 04/88] perf(desktop): speed up cold Linux x64 backend startup (#32987) Co-authored-by: GPT 6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- docs/operations/development.md | 17 +- scripts/lib/server-compile-cache.test.ts | 205 +++++++++++++++++++---- scripts/lib/server-compile-cache.ts | 52 +++++- scripts/prepare-server-compile-cache.cjs | 7 +- 4 files changed, 234 insertions(+), 47 deletions(-) diff --git a/docs/operations/development.md b/docs/operations/development.md index 74ac4b50fc305..6490eb631c767 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -270,13 +270,14 @@ 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. macOS x64 builds on ARM64 -hosts run the packaged x64 Electron under Rosetta to generate the cache. Other cross-platform and -cross-architecture builds 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. +Native macOS and Windows desktop artifact builds, and native Linux x64 builds, generate server +bytecode with the packaged Electron executable, after ASAR creation and before signing. Generation imports the CLI and server +module declarations without launching server services; worker-only modules compile normally. +AppImage backends use temporary links for each mount and remove them on normal exit. Later launches +reclaim abandoned cache directories whose links point to gone mounts; AppImage main-process caching +stays disabled. macOS x64 builds on ARM64 hosts run the packaged x64 Electron +under Rosetta to generate the cache. Other cross-platform and cross-architecture builds skip +generation explicitly. CLI, SEA and WSL runtimes keep their existing behavior. 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 @@ -286,7 +287,7 @@ differ or resource permissions prevent linking. Protected installations therefor 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. +packaged engine produced the expected CLI and server entries 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 diff --git a/scripts/lib/server-compile-cache.test.ts b/scripts/lib/server-compile-cache.test.ts index 9853d4aed85fb..d90405a20b5e6 100644 --- a/scripts/lib/server-compile-cache.test.ts +++ b/scripts/lib/server-compile-cache.test.ts @@ -6,38 +6,54 @@ 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.each([ + { platform: "freebsd", hostArch: "x64", arch: 1, reason: "unsupported target freebsd" }, + { platform: "linux", hostArch: "arm64", arch: 3, reason: "unsupported Linux architecture arm64" }, +])( + "skips unsupported $platform/$hostArch cache generation", + async ({ platform, hostArch, arch, reason }) => { + 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: ${JSON.stringify(platform)} }); + Object.defineProperty(process, "arch", { value: ${JSON.stringify(hostArch)} }); + require(${JSON.stringify(hook)})({ electronPlatformName: ${JSON.stringify(platform)}, arch: ${arch} }).catch(error => { console.error(error); process.exitCode = 1; });`, + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain(`Skipping ${reason}`); + }, +); -it("prepares macOS x64 cache on an ARM64 build host", async () => { +// macOS runs on an ARM64 host to exercise the Rosetta build-host check. +it.each([ + { + platform: "darwin", + hostArch: "arm64", + resources: "Fixture.app/Contents/Resources", + executable: "Fixture.app/Contents/MacOS/Fixture", + }, + { platform: "linux", hostArch: "x64", resources: "resources", executable: "fixture" }, +])("prepares $platform x64 cache", async ({ platform, hostArch, ...layout }) => { const { spawnSync } = await import("node:child_process"); const root = NodeFS.realpathSync( - NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-rosetta-cache-")), + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-hook-cache-")), ); try { - const resources = NodePath.join(root, "Fixture.app", "Contents", "Resources"); + const resources = NodePath.join(root, layout.resources); const archive = NodePath.join(resources, "app.asar"); const dist = NodePath.join(archive, "apps/server/dist"); const helper = NodePath.join(archive, "apps/desktop/dist-electron/serverCompileCache.cjs"); NodeFS.mkdirSync(dist, { recursive: true }); NodeFS.mkdirSync(NodePath.dirname(helper), { recursive: true }); NodeFS.writeFileSync(NodePath.join(dist, "binCli-fixture.mjs"), "export const answer = 42;"); + NodeFS.writeFileSync(NodePath.join(dist, "server-fixture.mjs"), "export const answer = 43;"); NodeFS.writeFileSync( helper, `module.exports = require(${JSON.stringify(NodeURL.fileURLToPath(new URL("./server-compile-cache.ts", import.meta.url)))});`, @@ -45,7 +61,7 @@ it("prepares macOS x64 cache on an ARM64 build host", async () => { const hook = NodeURL.fileURLToPath( new URL("../prepare-server-compile-cache.cjs", import.meta.url), ); - // The fixture uses Node for generation; the hook still sees an ARM64 macOS build host. + // Node stands in for the packaged engine. const result = spawnSync( process.execPath, [ @@ -54,14 +70,14 @@ it("prepares macOS x64 cache on an ARM64 build host", async () => { const childProcess = require("node:child_process"); const spawn = childProcess.spawnSync; childProcess.spawnSync = (executable, args, options) => { - require("node:assert/strict").equal(executable, ${JSON.stringify(NodePath.join(root, "Fixture.app", "Contents", "MacOS", "Fixture"))}); + require("node:assert/strict").equal(executable, ${JSON.stringify(NodePath.join(root, layout.executable))}); return spawn(process.execPath, args, options); }; - Object.defineProperty(process, "platform", { value: "darwin" }); - Object.defineProperty(process, "arch", { value: "arm64" }); + Object.defineProperty(process, "platform", { value: ${JSON.stringify(platform)} }); + Object.defineProperty(process, "arch", { value: ${JSON.stringify(hostArch)} }); require(${JSON.stringify(hook)})({ - appOutDir: ${JSON.stringify(root)}, electronPlatformName: "darwin", arch: 1, - packager: { appInfo: { productFilename: "Fixture" } } + appOutDir: ${JSON.stringify(root)}, electronPlatformName: ${JSON.stringify(platform)}, arch: 1, + packager: { appInfo: { productFilename: "Fixture" }, executableName: "fixture" } }).catch(error => { console.error(error); process.exitCode = 1; }); `, ], @@ -71,11 +87,19 @@ it("prepares macOS x64 cache on an ARM64 build host", async () => { const manifest = JSON.parse( NodeFS.readFileSync(NodePath.join(resources, "server-compile-cache/manifest.json"), "utf8"), ); - expect(manifest.entries).toEqual([ + expect( + manifest.entries.sort((a: { module: string }, b: { module: string }) => + a.module.localeCompare(b.module), + ), + ).toEqual([ { module: "app.asar/apps/server/dist/binCli-fixture.mjs", file: expect.stringMatching(/^[a-f0-9]{8}$/), }, + { + module: "app.asar/apps/server/dist/server-fixture.mjs", + file: expect.stringMatching(/^[a-f0-9]{8}$/), + }, ]); expect( NodeFS.statSync(NodePath.join(resources, "server-compile-cache", manifest.entries[0].file)) @@ -86,6 +110,107 @@ it("prepares macOS x64 cache on an ARM64 build host", async () => { } }); +it("rejects ambiguous bundled server entries before importing them", async () => { + const { spawnSync } = await import("node:child_process"); + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cache-ambiguous-")); + try { + const dist = NodePath.join(root, "app.asar/apps/server/dist"); + NodeFS.mkdirSync(dist, { recursive: true }); + for (const file of ["binCli-fixture.mjs", "server-fixture.mjs", "server-unrelated.mjs"]) { + NodeFS.writeFileSync(NodePath.join(dist, file), "throw Error('Unexpected import');"); + } + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + `import {generatePreparedServerCache} from ${JSON.stringify(new URL("./server-compile-cache.ts", import.meta.url).href)}; await generatePreparedServerCache(${JSON.stringify(root)});`, + ], + { + encoding: "utf8", + env: { + ...process.env, + NODE_COMPILE_CACHE: undefined, + NODE_DISABLE_COMPILE_CACHE: undefined, + }, + }, + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Ambiguous bundled server entry"); + expect(NodeFS.existsSync(NodePath.join(root, "server-compile-cache/manifest.json"))).toBe( + false, + ); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it("reclaims only AppImage cache directories linked to gone mounts", async () => { + const { spawnSync } = await import("node:child_process"); + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cache-reclaim-")), + ); + try { + const resources = NodePath.join(root, ".mount_live/resources"); + const prepared = NodePath.join(resources, "server-compile-cache"); + const cacheRoot = NodePath.join(root, "cache"); + const server = NodePath.join(cacheRoot, "server"); + NodeFS.mkdirSync(prepared, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(prepared, "manifest.json"), "{}"); + NodeFS.writeFileSync(NodePath.join(prepared, "12345678"), "live bytes"); + const gone = NodePath.join(root, ".mount_gone/resources/server-compile-cache/12345678"); + for (const [name, targets] of [ + ["appimage-stale", [gone]], + ["appimage-live", [NodePath.join(prepared, "12345678")]], + ["appimage-live-damaged", [NodePath.join(prepared, "87654321")]], + ["appimage-mixed", [gone, NodePath.join(prepared, "12345678")]], + ["appimage-unrelated", [NodePath.join(root, "unrelated-missing")]], + ["other-cache", [gone]], + ["appimage-initializing", []], + ] as const) { + const active = NodePath.join(server, name, "v24-engine-uid"); + NodeFS.mkdirSync(active, { recursive: true }); + targets.forEach((target, index) => + NodeFS.symlinkSync(target, NodePath.join(active, index === 0 ? "12345678" : "87654321")), + ); + NodeFS.writeFileSync(NodePath.join(active, "abcdef01"), "runtime replacement"); + } + NodeFS.symlinkSync( + NodePath.join(server, "other-cache"), + NodePath.join(server, "appimage-directory-link"), + ); + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + `process.env.APPIMAGE = "fixture"; import {enablePreparedServerCache} from ${JSON.stringify(new URL("./server-compile-cache.ts", import.meta.url).href)}; if (!enablePreparedServerCache(${JSON.stringify(NodePath.join(resources, "app.asar/apps/server/dist/binCli-fixture.mjs"))}, ${JSON.stringify(cacheRoot)})) throw Error("cache disabled");`, + ], + { + encoding: "utf8", + env: { + ...process.env, + NODE_COMPILE_CACHE: undefined, + NODE_DISABLE_COMPILE_CACHE: undefined, + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(NodeFS.readdirSync(server).sort()).toEqual([ + "appimage-directory-link", + "appimage-initializing", + "appimage-live", + "appimage-live-damaged", + "appimage-mixed", + "appimage-unrelated", + "other-cache", + ]); + expect(NodeFS.readFileSync(NodePath.join(prepared, "12345678"), "utf8")).toBe("live bytes"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + it("seeds relocated cache entries without allowing writes into shipped resources", () => { const root = NodeFS.realpathSync( NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-prepared-test-")), @@ -158,21 +283,33 @@ it.each(["app", "server"])( 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;"); + NodeFS.writeFileSync( + NodePath.join(producer, relative), + "export const answer = 42; export const loadServer = () => import('./server-fixture.mjs');", + ); + NodeFS.writeFileSync( + NodePath.join(producer, `${archive}.asar/apps/server/dist/server-fixture.mjs`), + "export const answer = 43;", + ); 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');`, + `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 || (await m.loadServer()).answer!==43) 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); + try { + const first = consume("first-user"); + expect(first).toMatch(/binCli-fixture\.mjs was accepted/); + expect(first).toMatch(/server-fixture\.mjs was accepted/); + expect(consume("appimage-user", true)).toContain("was accepted"); + expect(NodeFS.readdirSync(NodePath.join(root, "appimage-user", "server"))).toEqual([]); + } finally { + NodeFS.chmodSync(prepared, 0o755); + } for (const file of NodeFS.readdirSync(prepared).filter((file) => file !== "manifest.json")) { NodeFS.writeFileSync(NodePath.join(prepared, file), "broken"); } diff --git a/scripts/lib/server-compile-cache.ts b/scripts/lib/server-compile-cache.ts index 63a21174ff651..94a732a47d9bc 100644 --- a/scripts/lib/server-compile-cache.ts +++ b/scripts/lib/server-compile-cache.ts @@ -77,6 +77,45 @@ export function enablePreparedServerCache(entry: string, cacheRoot: string): boo return false; const identity = NodeZlib.crc32(resources).toString(16); NodeFS.mkdirSync(NodePath.join(cacheRoot, "server"), { recursive: true }); + if (process.env.APPIMAGE) { + try { + for (const candidate of NodeFS.readdirSync(NodePath.join(cacheRoot, "server"), { + withFileTypes: true, + })) { + if (!candidate.isDirectory() || !candidate.name.startsWith("appimage-")) continue; + const abandoned = NodePath.join(cacheRoot, "server", candidate.name); + let hasStaleLink = false; + // Node nests files under an engine/UID directory and can replace links with regular files. + const removable = NodeFS.readdirSync(abandoned, { withFileTypes: true }).every( + (engine) => + engine.isDirectory() && + NodeFS.readdirSync(NodePath.join(abandoned, engine.name), { + withFileTypes: true, + }).every((file) => { + if (!/^[a-f0-9]{8}$/.test(file.name)) return false; + if (!file.isSymbolicLink()) return file.isFile(); + const target = NodeFS.readlinkSync( + NodePath.join(abandoned, engine.name, file.name), + ); + const mount = target.match(/^(.*[\\/]\.mount_[^/\\]+)[\\/]/)?.[1]; + if ( + !mount || + !NodePath.isAbsolute(target) || + NodePath.basename(NodePath.dirname(target)) !== "server-compile-cache" || + !/^[a-f0-9]{8}$/.test(NodePath.basename(target)) + ) + return false; + if (NodeFS.lstatSync(mount, { throwIfNoEntry: false })) return false; + hasStaleLink = true; + return true; + }), + ); + if (removable && hasStaleLink) NodeFS.rmSync(abandoned, { recursive: true, force: true }); + } + } catch { + // Concurrent removal or restricted permissions must not prevent enabling this launch's cache. + } + } const directory = process.env.APPIMAGE ? NodeFS.mkdtempSync(NodePath.join(cacheRoot, "server", "appimage-")) : NodePath.join(cacheRoot, "server", identity); @@ -113,12 +152,19 @@ export async function generatePreparedServerCache(resourcesInput: string): Promi 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)); + const files = NodeFS.readdirSync(dist); + const cli = files.find((file) => /^binCli-.*\.mjs$/.test(file)); if (!cli) throw new Error("Missing bundled server CLI"); + const servers = files.filter((file) => /^server-.*\.mjs$/.test(file)); + if (servers.length > 1) throw new Error("Ambiguous bundled server entry"); + const server = servers[0]; + if (!server) throw new Error("Missing bundled server entry"); await import(NodeURL.pathToFileURL(NodePath.join(dist, cli)).href); + // The CLI loads the server dynamically; importing its declarative layers does not launch them. + await import(NodeURL.pathToFileURL(NodePath.join(dist, server)).href); NodeModule.flushCompileCache(); const active = NodeModule.getCompileCacheDir()!; - const entries = NodeFS.readdirSync(dist) + const entries = files .filter((file) => file.endsWith(".mjs")) .flatMap((file) => { const key = cacheKey(active, NodePath.join(dist, file)); @@ -126,7 +172,7 @@ export async function generatePreparedServerCache(resourcesInput: string): Promi 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}`))) + if (![cli, server].every((file) => entries.some((entry) => entry.module.endsWith(`/${file}`)))) throw new Error("Packaged engine cache key format is unsupported"); NodeFS.writeFileSync( NodePath.join(prepared, "manifest.json"), diff --git a/scripts/prepare-server-compile-cache.cjs b/scripts/prepare-server-compile-cache.cjs index e2918c852bf7c..ab169c093f65d 100644 --- a/scripts/prepare-server-compile-cache.cjs +++ b/scripts/prepare-server-compile-cache.cjs @@ -6,12 +6,15 @@ 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") { + if (!["darwin", "win32", "linux"].includes(electronPlatformName)) { console.log(`[server-compile-cache] Skipping unsupported target ${electronPlatformName}`); return; } const targetArch = ["ia32", "x64", "armv7l", "arm64", "universal"][arch]; + if (electronPlatformName === "linux" && targetArch !== "x64") { + console.log(`[server-compile-cache] Skipping unsupported Linux architecture ${targetArch}`); + return; + } const runsWithRosetta = electronPlatformName === "darwin" && process.platform === "darwin" && From 23e3465ec6a509db01aaf364c7ca6675525aac25 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 8 Nov 2026 13:41:00 -0600 Subject: [PATCH 05/88] fix(clients): unloaded thread links show their title and project icon (#32830) --- apps/mobile/src/state/entities.test.ts | 72 ++++++++++ apps/mobile/src/state/entities.ts | 49 +++++-- apps/mobile/src/state/orchestration.ts | 2 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 9 ++ .../orchestration-v2/ProjectionStore.test.ts | 18 +++ .../src/orchestration-v2/ProjectionStore.ts | 23 +++ .../ProviderTurnControlService.test.ts | 1 + .../ThreadManagementService.ts | 2 + .../src/relay/AgentAwarenessRelay.test.ts | 1 + apps/server/src/ws.ts | 11 ++ apps/web/src/components/ThreadContextChip.tsx | 23 ++- .../chat/MarkdownThreadLink.browser.test.tsx | 134 ++++++++++++++++++ .../components/chat/MarkdownThreadLink.tsx | 49 +++++-- .../client-runtime/src/state/orchestration.ts | 56 ++++++-- .../client-runtime/src/state/runtime.test.ts | 34 +++++ packages/contracts/src/orchestrationV2.ts | 11 ++ packages/contracts/src/rpc.ts | 10 ++ packages/contracts/src/server.ts | 1 + packages/shared/src/threadLinks.ts | 2 +- 21 files changed, 470 insertions(+), 40 deletions(-) create mode 100644 apps/mobile/src/state/entities.test.ts create mode 100644 apps/web/src/components/chat/MarkdownThreadLink.browser.test.tsx diff --git a/apps/mobile/src/state/entities.test.ts b/apps/mobile/src/state/entities.test.ts new file mode 100644 index 0000000000000..4bccd724be33d --- /dev/null +++ b/apps/mobile/src/state/entities.test.ts @@ -0,0 +1,72 @@ +import { EnvironmentId, ProjectId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ reply: null as unknown })); +vi.mock(import("./threads"), async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentThreadDetails: {} as never, + environmentThreadShells: { + threadShellAtom: () => Atom.make(null), + threadRefsAtom: Atom.make([{ environmentId: "remote", threadId: "older-thread" }]), + } as never, + }; +}); +vi.mock(import("./orchestration"), () => ({ + orchestrationEnvironment: { threadLinkMetadata: () => state.reply } as never, +})); +vi.mock(import("./atom-registry"), () => ({ appAtomRegistry: {} as never })); +vi.mock(import("./projects"), () => ({ environmentProjects: {} as never })); +vi.mock(import("./server"), () => ({ + environmentServerConfigsAtom: {} as never, + serverEnvironment: {} as never, +})); +vi.mock(import("./assets"), () => ({ projectFaviconUrlAtom: (() => null) as never })); +vi.mock(import("../native/ProjectChipIconImages"), () => ({ + projectGlyphImageAtom: (() => null) as never, + projectGlyphKey: (() => "") as never, +})); + +import { resolveThreadLink } from "./entities"; + +function setup() { + const reply = Atom.make< + AsyncResult.AsyncResult, string> + >(AsyncResult.initial()); + state.reply = reply; + return { registry: AtomRegistry.make(), reply }; +} + +it("uses confirmed matches instead of provisional titles when resolving legacy links", () => { + const { registry, reply } = setup(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + const local: ScopedThreadRef = { + environmentId: EnvironmentId.make("local"), + threadId: ThreadId.make("older-thread"), + }; + const remote: ScopedThreadRef = { ...local, environmentId: EnvironmentId.make("remote") }; + const link = { environmentId: null, threadId: local.threadId }; + const resolve = () => resolveThreadLink(registry.get.bind(registry), link, local.environmentId); + const release = registry.mount(reply); + try { + expect(resolve()).toEqual(remote); + registry.set(reply, AsyncResult.failure(Cause.fail("offline"))); + expect(resolve()).toEqual(remote); + expect(resolveThreadLink(registry.get.bind(registry), local, remote.environmentId)).toEqual( + local, + ); + registry.set( + reply, + AsyncResult.success({ + [local.threadId]: { title: "Thread", projectId: ProjectId.make("project") }, + }), + ); + expect(resolve()).toEqual(local); + registry.set(reply, AsyncResult.success({})); + expect(resolve()).toEqual(remote); + } finally { + release(); + } +}); diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 1625998fdc629..c9d1834a78bdb 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -32,6 +32,7 @@ import { threadChipTargets } from "../lib/enrichedLinkPresentation"; import { resolveProjectChipIcon } from "../lib/projectIcon"; import { projectGlyphImageAtom, projectGlyphKey } from "../native/ProjectChipIconImages"; import { projectFaviconUrlAtom } from "./assets"; +import { orchestrationEnvironment } from "./orchestration"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom, serverEnvironment } from "./server"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; @@ -55,12 +56,22 @@ const EMPTY_THREAD_LINK_TITLES_ATOM = Atom.make>(new const decodeThreadKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, ThreadId])); const threadKey = (ref: ScopedThreadRef) => JSON.stringify([ref.environmentId, ref.threadId]); -// Null when this client doesn't know the thread. Keyed, so a link never walks an environment's threads. +// Omitted and archived targets need only a metadata lookup, not their conversation history. const threadTitleAtom = Atom.family((key: string) => { const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key))); - return Atom.make((get) => get(environmentThreadShells.threadShellAtom(ref))?.title ?? null).pipe( - Atom.withLabel(`mobile-thread-title:${key}`), - ); + return Atom.make((get) => { + const shell = get(environmentThreadShells.threadShellAtom(ref)); + if (shell !== null) return shell.title; + const titles = get( + orchestrationEnvironment.threadLinkMetadata({ + environmentId: ref.environmentId, + input: { threadIds: [ref.threadId] }, + }), + ); + return AsyncResult.isSuccess(titles) + ? (titles.value[ref.threadId]?.title ?? (titles.waiting ? "Thread" : null)) + : "Thread"; + }).pipe(Atom.withLabel(`mobile-thread-title:${key}`)); }); const decodeThreadKeys = Schema.decodeUnknownSync(Schema.Array(Schema.String)); @@ -92,11 +103,22 @@ const threadProjectChipIconAtom = Atom.family((key: string) => { const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key))); return Atom.make((get): string | null => { const shell = get(environmentThreadShells.threadShellAtom(ref)); - const project = shell + const metadata = + shell ?? + AsyncResult.getOrElse( + get( + orchestrationEnvironment.threadLinkMetadata({ + environmentId: ref.environmentId, + input: { threadIds: [ref.threadId] }, + }), + ), + () => null, + )?.[ref.threadId]; + const project = metadata?.projectId ? get( environmentProjects.projectAtom({ environmentId: ref.environmentId, - projectId: shell.projectId, + projectId: metadata.projectId, }), ) : null; @@ -222,13 +244,20 @@ export function resolveThreadLink( link: ThreadLink, messageEnvironmentId: EnvironmentId, ): ScopedThreadRef | null { - const known = threadLinkCandidates(link, messageEnvironmentId).find( - (ref) => read(threadTitleAtom(threadKey(ref))) !== null, - ); + const known = threadLinkCandidates(link, messageEnvironmentId).find((ref) => { + if (read(environmentThreadShells.threadShellAtom(ref)) !== null) return true; + const metadata = read( + orchestrationEnvironment.threadLinkMetadata({ + environmentId: ref.environmentId, + input: { threadIds: [ref.threadId] }, + }), + ); + return AsyncResult.getOrElse(metadata, () => null)?.[ref.threadId] !== undefined; + }); if (known) return known; return link.environmentId === null ? guessThreadLinkRef(link, read(environmentThreadShells.threadRefsAtom)) - : null; + : scopeThreadRef(link.environmentId, link.threadId); } export function useThreadChipIcons( diff --git a/apps/mobile/src/state/orchestration.ts b/apps/mobile/src/state/orchestration.ts index 766185f2a71dc..b18d4acb68082 100644 --- a/apps/mobile/src/state/orchestration.ts +++ b/apps/mobile/src/state/orchestration.ts @@ -1,8 +1,10 @@ import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentShell } from "./shell"; import { recordReviewTurnDiffAnswer } from "./reviewTurnDiffs"; export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime, { + shellState: environmentShell.stateValueAtom, onCheckpointDiffAnswer: recordReviewTurnDiffAnswer, }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 70b8db54f6775..6169700132233 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,6 +52,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadTitles]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index c6b60310d4939..9183ee264e795 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -25,6 +25,7 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadTitles]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d3c4391b1fe8..8ff71b0243c6f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,4 +1,5 @@ import type { + OrchestrationV2ThreadLinkMetadata, GuestShareId, OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, @@ -479,6 +480,9 @@ export interface OrchestratorV2Shape { shareId: GuestShareId, ) => Effect.Effect; /** Titles of the given threads that exist, archived ones included. */ + readonly getThreadLinkMetadata: ( + threadIds: ReadonlyArray, + ) => Effect.Effect, OrchestratorV2Error>; readonly getThreadTitles: ( threadIds: ReadonlyArray, ) => Effect.Effect, OrchestratorV2Error>; @@ -14540,6 +14544,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .findGuestShareThreadId(shareId) .pipe(Effect.mapError(shellProjectionError)); }), + getThreadLinkMetadata: Effect.fn("OrchestratorV2.getThreadLinkMetadata")(function (threadIds) { + return projectionStore + .getThreadLinkMetadata(threadIds) + .pipe(Effect.mapError(shellProjectionError)); + }), getThreadTitles: Effect.fn("OrchestratorV2.getThreadTitles")(function (threadIds) { return projectionStore.getThreadTitles(threadIds).pipe(Effect.mapError(shellProjectionError)); }), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 8ff160f0cc2ac..1ca7eb67036fa 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1327,6 +1327,24 @@ const threadWorktreePathsIncludeArchivedOnRequest = Effect.gen(function* () { { threadId: ThreadId.make("thread:active"), worktreePath: "/worktrees/active" }, { threadId: ThreadId.make("thread:archived"), worktreePath: "/worktrees/archived" }, ]); + assert.deepEqual( + yield* store.getThreadLinkMetadata([ + deleted.id, + ThreadId.make("thread:active"), + ThreadId.make("thread:archived"), + ThreadId.make("unknown"), + ]), + { + [ThreadId.make("thread:active")]: { + title: "Title for thread:active", + projectId: ProjectId.make("project:worktree-paths"), + }, + [ThreadId.make("thread:archived")]: { + title: "Title for thread:archived", + projectId: ProjectId.make("project:worktree-paths"), + }, + }, + ); assert.deepEqual( yield* store.getThreadTitles([ deleted.id, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index dea68ee7723d1..ba87697d60f03 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -6,6 +6,7 @@ import * as Stream from "effect/Stream"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { makeThreadFind } from "./ThreadFind.ts"; import type { + OrchestrationV2ThreadLinkMetadata, OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, OrchestrationV2ThreadHistoryPage, @@ -562,6 +563,9 @@ export interface ProjectionStoreV2Shape { threadId?: ThreadId, ) => Effect.Effect, ProjectionStoreV2Error>; /** Titles of the given threads that exist, archived ones included. */ + readonly getThreadLinkMetadata: ( + threadIds: ReadonlyArray, + ) => Effect.Effect, ProjectionStoreV2Error>; readonly getThreadTitles: ( threadIds: ReadonlyArray, ) => Effect.Effect, ProjectionStoreV2Error>; @@ -7306,6 +7310,24 @@ export const layer: Layer.Layer = })); }, Effect.mapError(shellSnapshotReadError)); + const getThreadLinkMetadata = Effect.fn("ProjectionStore.getThreadLinkMetadata")(function* ( + threadIds: ReadonlyArray, + ) { + if (threadIds.length === 0) return {}; + const rows = yield* sql<{ + readonly thread_id: ThreadId; + readonly title: string; + readonly project_id: ProjectId; + }>` + SELECT thread_id, title, project_id + FROM orchestration_v2_projection_threads + WHERE deleted_at IS NULL AND thread_id IN ${sql.in(threadIds)} + `; + return Object.fromEntries( + rows.map((row) => [row.thread_id, { title: row.title, projectId: row.project_id }]), + ); + }, Effect.mapError(shellSnapshotReadError)); + const getThreadTitles = Effect.fn("ProjectionStore.getThreadTitles")(function* ( threadIds: ReadonlyArray, ) { @@ -7485,6 +7507,7 @@ export const layer: Layer.Layer = getSettlementCandidates: readOnReader(getSettlementCandidates), getThreadsWithPullRequests: readOnReader(getThreadsWithPullRequests), getThreadTitles: readOnReader(getThreadTitles), + getThreadLinkMetadata: readOnReader(getThreadLinkMetadata), getThreadWorktreePaths: readOnReader(getThreadWorktreePaths), getOpenRemoteDelegatedTasks: getOpenRemoteDelegatedTasks.pipe(NodeSqliteClient.readOnly), getThreadProjection: readOnReader(getThreadProjection), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 55e2a9e50e3ef..15800ab273b89 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -221,6 +221,7 @@ it.effect( readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), getThreadTitles: () => Effect.die("unused getThreadTitles"), + getThreadLinkMetadata: () => Effect.die("unused getThreadLinkMetadata"), findGuestShareThreadId: () => Effect.die("unused findGuestShareThreadId"), getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 46ea454310554..085be554c0c2e 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -423,6 +423,7 @@ export interface ThreadManagementServiceShape { >; readonly getThreadShell: Orchestrator.OrchestratorV2["Service"]["getThreadShell"]; readonly listAppOwnedChildThreadIds: Orchestrator.OrchestratorV2["Service"]["listAppOwnedChildThreadIds"]; + readonly getThreadLinkMetadata: Orchestrator.OrchestratorV2["Service"]["getThreadLinkMetadata"]; readonly getThreadTitles: Orchestrator.OrchestratorV2["Service"]["getThreadTitles"]; /** Omit `projectId` to list every project's threads. Side chats are never listed. */ readonly listProjectThreads: (input: { @@ -1609,6 +1610,7 @@ const make = Effect.gen(function* () { }, ), getThreadTitles: orchestrator.getThreadTitles, + getThreadLinkMetadata: orchestrator.getThreadLinkMetadata, listProjectThreads, sendToThread, assertProviderInstanceEnabled, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index e76e2a6b64b05..ae17bd7583bc3 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -204,6 +204,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( listOlderSettledThreads: unused, readShellSnapshot: unused, getThreadTitles: unused, + getThreadLinkMetadata: unused, ensureLegacyTranscript: unused, dispatch: unused, searchThread: () => Effect.die("unused"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8d272dd584eeb..185273eb47aed 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1965,6 +1965,7 @@ export const layerWsRpc = ( : { managedPolicy: serverSettings.managedPolicy }), shellResumeCompletionMarker: true, shellThreadFamilies: true, + threadLinkMetadata: true, shellOlderSettled: true, threadResumeCompletionMarker: true, threadSnapshotPagination: true, @@ -2158,6 +2159,16 @@ export const layerWsRpc = ( getOrchestrationV2ArchivedShellSnapshot, [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: (input) => readOmittableShellRows({ familyOf: input.threadId }, "Failed to load the thread family"), + [ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata]: (input) => + threadManagement.getThreadLinkMetadata(input.threadIds).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetShellSnapshotError({ + message: "Failed to load thread link metadata", + cause, + }), + ), + ), [ORCHESTRATION_V2_WS_METHODS.getThreadTitles]: (input) => threadManagement.getThreadTitles(input.threadIds).pipe( Effect.mapError( diff --git a/apps/web/src/components/ThreadContextChip.tsx b/apps/web/src/components/ThreadContextChip.tsx index 715a210f4e917..6ff0852b185e4 100644 --- a/apps/web/src/components/ThreadContextChip.tsx +++ b/apps/web/src/components/ThreadContextChip.tsx @@ -1,22 +1,35 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { ThreadContextRecord } from "@t3tools/contracts"; +import type { ThreadContextRecord, ProjectId } from "@t3tools/contracts"; import { Link } from "@tanstack/react-router"; import { MessagesSquareIcon } from "lucide-react"; import { useProject, useThreadShell } from "~/state/entities"; +import { orchestrationEnvironment } from "~/state/orchestration"; +import { useEnvironmentQuery } from "~/state/query"; import { ContextChip, ContextChipLabel } from "./ContextChip"; import { ProjectFavicon } from "./ProjectFavicon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function ThreadContextChip(props: { record: Pick; + projectId?: ProjectId | null; copyMarkdown?: string; unreadable?: boolean; }) { const { environmentId, threadId } = props.record; const shell = useThreadShell({ environmentId, threadId }); - const project = useProject(shell ? scopeProjectRef(environmentId, shell.projectId) : null); - const title = shell?.title?.trim() || props.record.title; + const lookup = useEnvironmentQuery( + shell === null && props.projectId === undefined + ? orchestrationEnvironment.threadLinkMetadata({ + environmentId, + input: { threadIds: [threadId] }, + }) + : null, + ); + const metadata = lookup.data?.[threadId]; + const projectId = shell?.projectId ?? props.projectId ?? metadata?.projectId; + const project = useProject(projectId ? scopeProjectRef(environmentId, projectId) : null); + const title = shell?.title?.trim() || metadata?.title.trim() || props.record.title; return ( {props.unreadable ? "On another machine, so this machine's agent can't read it. Remove it or switch back to send." - : shell - ? "Open thread" - : "Thread no longer available"} + : "Open thread"} ); diff --git a/apps/web/src/components/chat/MarkdownThreadLink.browser.test.tsx b/apps/web/src/components/chat/MarkdownThreadLink.browser.test.tsx new file mode 100644 index 0000000000000..a379a6afc580a --- /dev/null +++ b/apps/web/src/components/chat/MarkdownThreadLink.browser.test.tsx @@ -0,0 +1,134 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { MarkdownThreadLink } from "./MarkdownThreadLink"; + +const state = vi.hoisted(() => ({ + reply: null as unknown, + remote: null as unknown, +})); +vi.mock(import("../../state/entities"), () => ({ + useThreadShell: ((ref: { environmentId: string } | null) => + ref?.environmentId === "remote" ? state.remote : null) as never, + useThreadRefs: (enabled = true) => + enabled + ? [{ environmentId: EnvironmentId.make("remote"), threadId: ThreadId.make("older-thread") }] + : [], + useProject: (ref: { projectId: string } | null) => + ref?.projectId === "essay-project" ? ({ title: "Essays" } as never) : null, +})); +vi.mock(import("../ProjectFavicon"), () => ({ + ProjectFavicon: () => ( + + P + + ), +})); +vi.mock(import("../contextChipParts"), () => ({ + UnresolvedChip: ({ label }: { label: string }) => {label}, +})); +vi.mock(import("../../state/orchestration"), () => ({ + orchestrationEnvironment: { threadLinkMetadata: () => state.reply } as never, +})); + +function setup() { + const reply = Atom.make< + AsyncResult.AsyncResult, string> + >(AsyncResult.initial()); + state.reply = reply; + state.remote = { + environmentId: EnvironmentId.make("remote"), + id: ThreadId.make("older-thread"), + title: "Remote thread", + projectId: ProjectId.make("essay-project"), + }; + return { reply, registry: AtomRegistry.make() }; +} + +it.each([ + { legacy: false, encoded: false }, + { legacy: true, encoded: false }, + { legacy: false, encoded: true }, +])( + "resolves an unloaded thread with its icon and the correct destination, %j", + async ({ legacy, encoded }) => { + const { reply, registry } = setup(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + const environmentId = EnvironmentId.make("environment"); + const threadId = ThreadId.make(encoded ? "older%2Dthread" : "older-thread"); + await using screen = await render( + + + , + ); + await expect + .element(screen.getByRole("link", { name: "Thread, Thread", exact: true })) + .toBeVisible(); + await act(() => registry.set(reply, AsyncResult.failure(Cause.fail("offline")))); + await expect + .element(screen.getByRole("link", { name: "Thread, Thread", exact: true })) + .toBeVisible(); + await act(() => + registry.set( + reply, + AsyncResult.success({ + [threadId]: { title: "Older essay", projectId: ProjectId.make("essay-project") }, + ...(encoded + ? { + "older-thread": { + title: "Decoded essay", + projectId: ProjectId.make("essay-project"), + }, + } + : {}), + }), + ), + ); + const link = screen.getByRole("link", { name: "Thread, Older essay" }); + await expect.element(link).toBeVisible(); + await expect.element(screen.getByRole("img", { name: "Essays project icon" })).toBeVisible(); + await link.click(); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe( + `/environment/${encodeURIComponent(threadId)}`, + ), + ); + if (encoded) { + await act(() => + registry.set( + reply, + AsyncResult.success({ + "older-thread": { title: "Decoded essay", projectId: ProjectId.make("essay-project") }, + }), + ), + ); + const decoded = screen.getByRole("link", { name: "Thread, Decoded essay" }); + await expect.element(decoded).toBeVisible(); + await decoded.click(); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe("/environment/older-thread"), + ); + } + await act(() => registry.set(reply, AsyncResult.success({}))); + if (legacy) { + const remote = screen.getByRole("link", { name: "Thread, Remote thread" }); + await expect.element(remote).toBeVisible(); + await remote.click(); + await vi.waitFor(() => + expect(screen.router?.state.location.pathname).toBe("/remote/older-thread"), + ); + } else { + await expect + .element(screen.getByText("Thread no longer available", { exact: true })) + .toBeVisible(); + } + }, +); diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index f3e95c026be28..a88535167db7d 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,15 +1,16 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId } from "@t3tools/contracts"; import { formatHostedThreadLink, guessThreadLinkRef, - percentDecodedThreadLinkId, + threadLinkCandidates, type ThreadLink, unknownThreadLinkLabel, } from "@t3tools/shared/threadLinks"; import { configuredHostedAppUrl } from "../../hostedPairing"; import { useThreadRefs, useThreadShell } from "../../state/entities"; +import { orchestrationEnvironment } from "../../state/orchestration"; +import { useEnvironmentQuery } from "../../state/query"; import { UnresolvedChip } from "../contextChipParts"; import { ThreadContextChip } from "../ThreadContextChip"; @@ -19,26 +20,46 @@ export function MarkdownThreadLink(props: { readonly link: ThreadLink; readonly label: string | null; }) { - const environmentId = props.link.environmentId ?? props.environmentId; - const decodedId = percentDecodedThreadLinkId(props.link.threadId); - const written = useThreadShell(scopeThreadRef(environmentId, props.link.threadId)); - const decoded = useThreadShell( - written === null && decodedId !== null ? scopeThreadRef(environmentId, decodedId) : null, + const candidates = threadLinkCandidates(props.link, props.environmentId); + const writtenRef = candidates[0]!; + const written = useThreadShell(writtenRef); + const lookup = useEnvironmentQuery( + written === null + ? orchestrationEnvironment.threadLinkMetadata({ + environmentId: writtenRef.environmentId, + input: { threadIds: candidates.map((ref) => ref.threadId) }, + }) + : null, ); - const local = written ?? decoded; - const guess = local === null && props.link.environmentId === null; + const settled = lookup.isSuccess && !lookup.isPending; + const target = + written !== null + ? { ref: writtenRef, metadata: written } + : (candidates.flatMap((ref) => { + const metadata = lookup.data?.[ref.threadId]; + return metadata ? [{ ref, metadata }] : []; + })[0] ?? null); + const missing = target === null && settled; + const guess = missing && props.link.environmentId === null; const refs = useThreadRefs(guess); - const remote = useThreadShell(guess ? guessThreadLinkRef(props.link, refs) : null); - const thread = local ?? remote; - if (thread === null) { + const remoteRef = guess + ? guessThreadLinkRef( + props.link, + refs.filter((ref) => ref.environmentId !== writtenRef.environmentId), + ) + : null; + const remote = useThreadShell(remoteRef); + const resolved = target ?? (remoteRef && remote ? { ref: remoteRef, metadata: remote } : null); + if (missing && resolved === null) { const unknown = unknownThreadLinkLabel(props.link, props.environmentId); return ; } - const ref = { environmentId: thread.environmentId, threadId: thread.id }; - const title = thread.title.trim() || props.label || "Untitled thread"; + const ref = resolved?.ref ?? writtenRef; + const title = resolved?.metadata.title.trim() || props.label || "Thread"; return ( ); diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index f5be5e2f63cae..af89357f0f1ed 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -2,6 +2,7 @@ import { ORCHESTRATION_V2_WS_METHODS, type EnvironmentId, type ThreadId, + type OrchestrationV2ThreadLinkMetadata, type OrchestrationGetTurnDiffResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -14,6 +15,7 @@ import { Atom } from "effect/reactivity"; import { applyCommandOutputFrame, EMPTY_COMMAND_OUTPUT } from "./commandOutput.ts"; import { + createEnvironmentQueryAtomFamily, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, @@ -24,7 +26,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as ManagedRelayDpopSigner from "../relay/managedRelay.ts"; -import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import { EnvironmentRpcUnavailableError, request } from "../rpc/client.ts"; import type { EnvironmentShellState } from "./shell.ts"; import { fetchEnvironmentThreadTranscript } from "./threadTranscriptHttp.ts"; @@ -47,6 +49,20 @@ export function createOrchestrationEnvironmentAtoms( ? {} : { onAnswer: options.onCheckpointDiffAnswer }; const shellState = options.shellState; + const refreshThreadLookup = ({ + environmentId, + input, + }: { + readonly environmentId: EnvironmentId; + readonly input: { readonly threadIds: ReadonlyArray }; + }) => { + if (shellState === undefined) return undefined; + const state = shellState(environmentId); + return Atom.make((get) => { + const { removedAt } = get(state); + return Math.max(-1, ...input.threadIds.map((threadId) => removedAt.get(threadId) ?? -1)); + }); + }; return { threadTitles: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:thread-titles", @@ -55,14 +71,36 @@ export function createOrchestrationEnvironmentAtoms( idleTtlMs: 0, // Deleting a thread the shell already left out keeps these ids, so the query would not rerun. // A removal can also be an archive, so it only prompts a re-check. - refreshTrigger: ({ environmentId, input }) => { - if (shellState === undefined) return undefined; - const state = shellState(environmentId); - return Atom.make((get) => { - const { removedAt } = get(state); - return Math.max(-1, ...input.threadIds.map((threadId) => removedAt.get(threadId) ?? -1)); - }); - }, + refreshTrigger: refreshThreadLookup, + }), + threadLinkMetadata: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:orchestration:thread-link-metadata", + staleTimeMs: 0, + idleTtlMs: 0, + refreshTrigger: refreshThreadLookup, + execute: Effect.fn("orchestration.threadLinkMetadata")(function* (input: { + readonly threadIds: ReadonlyArray; + }) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const session = yield* SubscriptionRef.get(supervisor.session); + if (Option.isNone(session)) { + return yield* new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: "The environment has no session.", + }); + } + const config = yield* session.value.initialConfig; + if (config.threadLinkMetadata === true) { + return yield* request(ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata, input); + } + const titles = yield* request(ORCHESTRATION_V2_WS_METHODS.getThreadTitles, input); + return Object.fromEntries( + Object.entries(titles).map(([id, title]) => [ + id, + { title, projectId: null } satisfies OrchestrationV2ThreadLinkMetadata, + ]), + ); + }), }), threadTranscript: createEnvironmentCommand(runtime, { label: "environment-data:orchestration:thread-transcript", diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index f79f00cdaa80e..7850f1226f4e9 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ORCHESTRATION_V2_WS_METHODS, ThreadId, + ProjectId, type ReviewDiffPreviewInput, type PreviewEvent, type TerminalAttachInput, @@ -1023,6 +1024,39 @@ describe("environment query lifecycle", () => { ), ); + it.effect.each([true, false])("loads thread link metadata with server support %s", (supported) => + Effect.scoped( + Effect.gen(function* () { + const projectId = ProjectId.make("project"); + const tag = supported + ? ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata + : ORCHESTRATION_V2_WS_METHODS.getThreadTitles; + const harness = yield* makeEnvironmentQueryHarness(Effect.void, { + rpcSession: { + initialConfig: Effect.succeed({ threadLinkMetadata: supported }), + client: { + [tag]: () => + Effect.succeed( + supported ? { fork: { title: "Fork", projectId } } : { fork: "Fork" }, + ), + }, + } as unknown as RpcSession.RpcSession, + }); + const runtime = harness.runtime as unknown as Atom.AtomRuntime< + EnvironmentRegistry.EnvironmentRegistry | HttpClient.HttpClient + >; + const atom = createOrchestrationEnvironmentAtoms(runtime).threadLinkMetadata({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: { threadIds: [ThreadId.make("fork")] }, + }); + const registry = yield* mountEnvironmentQuery(atom); + expect(yield* readEnvironmentQuery(registry, atom)).toEqual({ + fork: { title: "Fork", projectId: supported ? projectId : null }, + }); + }), + ), + ); + it.effect("re-checks thread titles when one of their threads leaves the shell", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 5c430ef75581a..2f5185e5b4436 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3597,6 +3597,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", getThreadFamily: "orchestration.getThreadFamily", getThreadTitles: "orchestration.getThreadTitles", + getThreadLinkMetadata: "orchestration.getThreadLinkMetadata", getWorktreeThreadIds: "orchestration.getWorktreeThreadIds", getOlderSettledThreads: "orchestration.getOlderSettledThreads", getThreadProjection: "orchestration.getThreadProjection", @@ -3728,6 +3729,12 @@ export const OrchestrationV2GetThreadTitlesInput = Schema.Struct({ /** Titles of the requested threads that exist, archived ones included. Deleted and unknown ids are absent. */ export const OrchestrationV2ThreadTitles = Schema.Record(ThreadId, Schema.String); +export const OrchestrationV2ThreadLinkMetadata = Schema.Struct({ + title: Schema.String, + projectId: Schema.NullOr(ProjectId), +}); +export type OrchestrationV2ThreadLinkMetadata = typeof OrchestrationV2ThreadLinkMetadata.Type; + export const OrchestrationV2GetWorktreeThreadIdsInput = Schema.Struct({ worktreePath: TrimmedNonEmptyString, }); @@ -4254,6 +4261,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2GetThreadTitlesInput, output: OrchestrationV2ThreadTitles, }, + getThreadLinkMetadata: { + input: OrchestrationV2GetThreadTitlesInput, + output: Schema.Record(ThreadId, OrchestrationV2ThreadLinkMetadata), + }, getWorktreeThreadIds: { input: OrchestrationV2GetWorktreeThreadIdsInput, output: OrchestrationV2WorktreeThreadIds, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 088a57cc7882d..ca5db22127486 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1811,6 +1811,15 @@ const WsOrchestrationV2GetThreadTitlesRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), }); +const WsOrchestrationV2GetThreadLinkMetadataRpc = Rpc.make( + ORCHESTRATION_V2_WS_METHODS.getThreadLinkMetadata, + { + payload: OrchestrationV2RpcSchemas.getThreadLinkMetadata.input, + success: OrchestrationV2RpcSchemas.getThreadLinkMetadata.output, + error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), + }, +); + const WsOrchestrationV2GetWorktreeThreadIdsRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds, { @@ -2231,6 +2240,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2GetArchivedShellSnapshotRpc, WsOrchestrationV2GetThreadFamilyRpc, WsOrchestrationV2GetThreadTitlesRpc, + WsOrchestrationV2GetThreadLinkMetadataRpc, WsOrchestrationV2GetWorktreeThreadIdsRpc, WsOrchestrationV2GetOlderSettledThreadsRpc, WsOrchestrationV2GetThreadProjectionRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3d45dfb87310f..40a1aa8b2ca86 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -629,6 +629,7 @@ export const ServerConfig = Schema.Struct({ * `getThreadFamily` and `getWorktreeThreadIds` serve the rows it leaves out. */ shellThreadFamilies: Schema.optionalKey(Schema.Boolean), + threadLinkMetadata: Schema.optionalKey(Schema.Boolean), /** * The shell can leave older settled threads out of snapshots (`omitOlderSettled`), and * `getOlderSettledThreads` serves them. Only servers that also advertise `shellThreadFamilies`. diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index aba7d5f983a0a..dadbafb16df49 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -71,7 +71,7 @@ export function parseThreadLinkHref(href: string): ThreadLink | null { return environmentId === null || threadId === null ? null : { environmentId, threadId }; } -export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null { +function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null { try { const decoded = decodeURIComponent(threadId); return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded)); From f558007b20926ba49a51dba158933a13f3f20c9e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:55:44 -0800 Subject: [PATCH 06/88] fix(clients): parse angle-bracketed thread link destinations (#30709) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Yash Singh --- .../src/lib/enrichedLinkPresentation.test.ts | 12 ++++++++++++ apps/mobile/src/lib/enrichedLinkPresentation.ts | 2 +- packages/shared/src/threadLinks.test.ts | 15 +++++++++++++++ packages/shared/src/threadLinks.ts | 3 ++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index 4a3b0174ff67d..3bcebe5b55d99 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -103,4 +103,16 @@ describe("Enriched link presentation", () => { ]); expect(threadChipTargets(here, "No links here.")).toEqual([]); }); + + it("finds thread links with angle-bracketed destinations", () => { + const here = "env-here" as EnvironmentId; + expect(threadChipTargets(here, "See [A]().")).toEqual([ + { + href: "t3-thread://v2/env-here/thread_a", + environmentId: here, + threadId: "thread_a", + link: true, + }, + ]); + }); }); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 12859d2dc8bd8..a6fce5e5b6a9b 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -58,7 +58,7 @@ export function threadChipTargets( ): ReadonlyArray { const targets = new Map(); if (hasThreadLinks(markdown)) { - for (const [, href = ""] of markdown.matchAll(/\]\((t3-thread:\/\/v[12]\/[^\s)]+)\)/g)) { + for (const [, href = ""] of markdown.matchAll(/\]\()]+)>?\)/g)) { const link = parseThreadLinkHref(href); if (link) { targets.set(href, { diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index 0c19f18163e64..c935c379a06be 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -104,6 +104,21 @@ describe("thread links", () => { ).toBe("[There](t3-thread://v2/there/t1) and [Here](t3-thread://v2/local/t1)"); }); + it("reads an angle-bracketed destination as the labeled link, not an autolink", () => { + const title = titlesIn({ local: { t1: "Here" }, there: { t1: "There" } }); + expect( + relabelThreadLinks( + "[old]() and [older]()", + local, + title, + ), + ).toBe("[There](t3-thread://v2/there/t1) and [Here](t3-thread://v2/local/t1)"); + expect( + relabelThreadLinks("[Kept label]()", local, () => undefined), + ).toBe("Kept label"); + expect(threadLinkRefs("[a]()", local)).toEqual([ref("there", "t2")]); + }); + it("turns a v2 link it cannot resolve into plain text, never the raw link", () => { expect( relabelThreadLinks( diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index dadbafb16df49..e13e2d1064e6f 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -11,8 +11,9 @@ const HOSTED_PREFIX = `${DEFAULT_HOSTED_APP_URL}/threads/`; const HOSTED_SEGMENT = "[A-Za-z0-9_%-]+"; const HOSTED_LINK = `${HOSTED_PREFIX.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}${HOSTED_SEGMENT}/${HOSTED_SEGMENT}`; // Code comes first in the alternation so a link written inside a code span or fence is skipped. +// A labeled link's destination may be angle-bracketed, as CommonMark allows: [label](). const THREAD_LINK_OUTSIDE_CODE = new RegExp( - String.raw`(?(\`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(\`+)[^\n]*?\4)|\[(?