From 2424f4c5dd6be3c7ce056410a97e5e460c255d64 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:39:21 +1000 Subject: [PATCH 1/3] fix(app): load worktree inventory on demand Global bootstrap issued GET /api/worktree for every project row on the server, each booting a Location and running Git discovery. With a few hundred historical projects this saturated the browser's per-origin connection pool, stalling health probes and user actions. Project metadata is now loaded alone. A project's worktree inventory loads when a view shows it: a mounted Location (session or new-session view) or a project selected on Home. Loaded inventories survive metadata refetches and project.updated payloads, and worktree.updated refreshes only the affected project when it was loaded. Home resolves a session's project by directory first and by projectID second, so worktree sessions of not-yet-loaded projects keep their label and open at the project root. --- packages/app/src/home/model.ts | 8 ++ packages/app/src/home/sessions/controller.tsx | 11 +- .../app/src/home/sessions/records.test.ts | 22 ++++ packages/app/src/home/sessions/records.ts | 23 ++-- .../server/global-sync/bootstrap.test.ts | 85 ++++++-------- .../runtime/server/global-sync/bootstrap.ts | 55 ++++----- packages/app/src/runtime/server/sync.tsx | 31 ++++- packages/app/src/workspaces/inventory.test.ts | 111 ++++++++++++++++++ packages/app/src/workspaces/inventory.ts | 58 +++++++++ packages/app/src/workspaces/location.tsx | 11 +- 10 files changed, 307 insertions(+), 108 deletions(-) create mode 100644 packages/app/src/workspaces/inventory.test.ts create mode 100644 packages/app/src/workspaces/inventory.ts diff --git a/packages/app/src/home/model.ts b/packages/app/src/home/model.ts index c29dc89a9e82..4f5beb160a4b 100644 --- a/packages/app/src/home/model.ts +++ b/packages/app/src/home/model.ts @@ -34,6 +34,14 @@ export function createHomeController() { const conn = list[0] if (conn) setSelection({ server: ServerConnection.key(conn) }) }) + createEffect(() => { + const ctx = focusedServerCtx() + const id = selectedProject()?.id + if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return + // Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees. + const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree + if (root) void ctx.sync.worktrees.load(root) + }) function setSelection(next: HomeProjectSelection) { layout.home.setSelection(next) diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index 1fd0e12a46f2..72e5eafdc4a5 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title" import { showToast } from "@/shell/notifications/toast" import { archiveHomeSession } from "./archive" import type { HomeController } from "../model" -import { buildHomeSessionRecords, type HomeSessionRecord } from "./records" +import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records" export type { HomeSessionRecord } from "./records" @@ -270,14 +270,7 @@ export function createHomeSessionsController(home: HomeController) { }, create: home.project.openNewSession, open: (session: SessionInfo, options?: OpenSessionOptions) => { - const directoryKey = pathKey(session.location.directory) - const project = home.project - .list() - .find( - (item) => - pathKey(item.worktree) === directoryKey || - item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey), - ) + const project = homeProjectForSession(session, home.project.list()) const conn = home.server.focused() if (!conn) return const connKey = ServerConnection.key(conn) diff --git a/packages/app/src/home/sessions/records.test.ts b/packages/app/src/home/sessions/records.test.ts index 6ff82fca4d12..03aa9afa4bc4 100644 --- a/packages/app/src/home/sessions/records.test.ts +++ b/packages/app/src/home/sessions/records.test.ts @@ -36,4 +36,26 @@ describe("buildHomeSessionRecords", () => { expect(records.map((record) => record.session.id)).toEqual(["a"]) }) + + test("labels a worktree session with its project before that project's inventory has loaded", () => { + const records = buildHomeSessionRecords({ + sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")], + projectDirectories: () => undefined, + projects: () => [{ ...opened, name: "Project A" }], + }) + + expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" }) + expect(records[0]?.projectName).toBe("Project A") + }) + + test("prefers the added project whose directory matches over a sibling entry with the same ID", () => { + const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject + const records = buildHomeSessionRecords({ + sessions: () => [session("n", "/repo/a/packages/app", "project-a")], + projectDirectories: () => undefined, + projects: () => [opened, nested], + }) + + expect(records[0]?.project.worktree).toBe("/repo/a/packages/app") + }) }) diff --git a/packages/app/src/home/sessions/records.ts b/packages/app/src/home/sessions/records.ts index f83a30eddc48..8c6595d5889d 100644 --- a/packages/app/src/home/sessions/records.ts +++ b/packages/app/src/home/sessions/records.ts @@ -22,13 +22,7 @@ export function buildHomeSessionRecords(input: { return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] .sort(compareSessionTime) .map((session) => { - const directory = pathKey(session.location.directory) - const project = input - .projects() - .find( - (item) => - pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), - ) ?? { + const project = homeProjectForSession(session, input.projects()) ?? { id: session.projectID, worktree: session.location.directory, expanded: false, @@ -36,3 +30,18 @@ export function buildHomeSessionRecords(input: { return { session, project, projectName: displayName(project) } }) } + +// Worktree inventories load on demand, so a worktree session may not match any directory yet; +// the session's project ID still identifies its added project. +export function homeProjectForSession( + session: SessionInfo, + projects: readonly T[], +) { + const directory = pathKey(session.location.directory) + return ( + projects.find( + (item) => + pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), + ) ?? projects.find((item) => item.id === session.projectID) + ) +} diff --git a/packages/app/src/runtime/server/global-sync/bootstrap.test.ts b/packages/app/src/runtime/server/global-sync/bootstrap.test.ts index 511cff488cd5..27229bbef2fd 100644 --- a/packages/app/src/runtime/server/global-sync/bootstrap.test.ts +++ b/packages/app/src/runtime/server/global-sync/bootstrap.test.ts @@ -6,6 +6,7 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap" import { ServerScope } from "@/runtime/server/scope" import type { ServerApi } from "@/runtime/server/api" import type { ServerSync } from "@/runtime/server/sync" +import { worktreeInventoryKey } from "@/workspaces/inventory" test("bootstraps projects through the native store setter and preserves subsequent updates", async () => { const api = OpenCode.make({ @@ -20,7 +21,6 @@ test("bootstraps projects through the native store setter and preserves subseque }) if (url.pathname === "/api/project") return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }]) - if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }]) throw new Error(`Unexpected request: ${url.pathname}`) }, { preconnect() {} }, @@ -47,6 +47,18 @@ test("bootstraps projects through the native store setter and preserves subseque await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient }) expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]]) expect(store.config).toEqual({}) + + // A refetch keeps the inventory a view already loaded for this project. + queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [ + { directory: "/repo" }, + { directory: "/repo/feature", strategy: "git" }, + ]) + await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient }) + expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"]) + expect(store.project[0]?.worktrees).toEqual([ + { directory: "/repo" }, + { directory: "/repo/feature", strategy: "git" }, + ]) } finally { queryClient.clear() } @@ -76,70 +88,39 @@ describe("query keys", () => { expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" }) }) - test("loads each project's inventory through its own location using the real client", async () => { - const calls: string[] = [] + test("loads project metadata without enumerating any project's worktrees", async () => { + const requests: string[] = [] const api = OpenCode.make({ baseUrl: "http://localhost:3000", fetch: Object.assign( async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(new Request(input, init).url) - if (url.pathname === "/api/project") - return Response.json([ - { id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, - { id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, - ]) - const directory = url.searchParams.get("location[directory]") - if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`) - calls.push(directory) + requests.push(url.pathname) + if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`) return Response.json([ - { directory }, - { directory: `${directory}/clone` }, - { directory: `${directory}/copy`, strategy: "git" }, + ...Array.from({ length: 300 }, (_, index) => ({ + id: `historical-${index.toString().padStart(3, "0")}`, + canonical: `/history/${index}`, + time: { created: 1, updated: 1 }, + sandboxes: [], + })), + { id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, + { id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] }, + { id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] }, ]) }, { preconnect() {} }, ), }) - const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree)) - - expect(result.map((project) => project.id)).toEqual(["a", "b"]) - expect(result.map((project) => project.sandboxes)).toEqual([ - ["/a/clone", "/a/copy"], - ["/b/clone", "/b/copy"], - ]) - expect(result.map((project) => project.worktrees)).toEqual([ - [{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }], - [{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }], - ]) - expect(calls.toSorted()).toEqual(["/a", "/b"]) - }) - - test("keeps projects whose directory inventory cannot load", async () => { - const api = OpenCode.make({ - baseUrl: "http://localhost:3000", - fetch: Object.assign( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = new URL(new Request(input, init).url) - if (url.pathname === "/api/project") - return Response.json([ - { id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, - { id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, - ]) - const directory = url.searchParams.get("location[directory]") - if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`) - if (directory === "/b") return Response.json({ message: "unavailable" }, { status: 503 }) - return Response.json([{ directory: "/a/copy", strategy: "git" }]) - }, - { preconnect() {} }, - ), - }) - - const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree)) + const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project)) - expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([ - { id: "a", sandboxes: ["/a/copy"] }, - { id: "b", sandboxes: [] }, + expect(requests).toEqual(["/api/project"]) + expect(result).toHaveLength(302) + expect(result.slice(0, 2)).toMatchObject([ + { id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] }, + { id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] }, ]) + expect(result.some((project) => project.id === "test")).toBe(false) }) }) diff --git a/packages/app/src/runtime/server/global-sync/bootstrap.ts b/packages/app/src/runtime/server/global-sync/bootstrap.ts index b1010c17909c..ebeac493737a 100644 --- a/packages/app/src/runtime/server/global-sync/bootstrap.ts +++ b/packages/app/src/runtime/server/global-sync/bootstrap.ts @@ -15,8 +15,7 @@ import { cmp, normalizeProjectInfo } from "./utils" import { formatServerError } from "@/runtime/server/errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" import type { ServerScope } from "@/runtime/server/scope" -import type { ServerApi } from "@/runtime/server/api" -import { sameDirectory } from "@/workspaces/paths" +import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory" type GlobalStore = { path: Path @@ -64,42 +63,21 @@ type ProjectApi = { readonly list: () => Promise readonly current: (input?: ProjectCurrentInput) => Promise } -type WorktreeApi = Pick type LocationApi = { readonly get: (input?: LocationGetInput) => Promise } -export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) => +// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory). +export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) => queryOptions({ queryKey: [scope, "project"], queryFn: () => retry(() => - projects.list().then(async (items) => { - return ( - await Promise.all( - items - .filter((project) => !!project?.id) - .map(async (project) => { - const directories = await worktrees - .list({ location: { directory: project.canonical } }) - .catch(() => [ - { directory: project.canonical }, - ...(project.sandboxes ?? []) - .filter((directory) => !sameDirectory(project.canonical, directory)) - .map((directory) => ({ directory })), - ]) - return normalizeProjectInfo({ - ...project, - sandboxes: directories - .map((item) => item.directory) - .filter((directory) => !sameDirectory(project.canonical, directory)), - worktrees: directories, - }) - }), - ) - ) + projects.list().then((items) => + items + .filter((project) => !!project?.id) + .map(normalizeProjectInfo) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - }), + .sort((a, b) => cmp(a.id, b.id)), + ), ), }) @@ -107,7 +85,6 @@ export async function bootstrapGlobal(input: { serverAPI: { readonly location: LocationApi readonly project: ProjectApi - readonly worktree: WorktreeApi } scope: ServerScope setGlobalStore: SetStoreFunction @@ -117,9 +94,17 @@ export async function bootstrapGlobal(input: { () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)), () => - input.queryClient - .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree)) - .then((data) => input.setGlobalStore("project", data)), + input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) => + input.setGlobalStore( + "project", + data.map((project) => + withWorktreeInventory( + project, + input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)), + ), + ), + ), + ), ] await runAll(slow) } diff --git a/packages/app/src/runtime/server/sync.tsx b/packages/app/src/runtime/server/sync.tsx index 375aab7ddeb1..1683bc9b3131 100644 --- a/packages/app/src/runtime/server/sync.tsx +++ b/packages/app/src/runtime/server/sync.tsx @@ -20,6 +20,8 @@ import { toggleMcp } from "./global-sync/mcp" import { createConnectionSync } from "./server-sync/connection" import { usePlatform } from "@/runtime/platform/platform" import type { Data } from "@opencode-ai/client/solid" +import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory" +import { sameDirectory } from "@/workspaces/paths" type GlobalStore = { path: Path @@ -79,6 +81,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { }) const queryClient = useQueryClient() + const worktrees = createWorktreeInventory({ + scope: serverSDK.scope, + queryClient, + api: () => serverSDK.api.worktree, + updated: (directory, items) => + setGlobalStore("project", (projects) => + projects.map((project) => + sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project, + ), + ), + }) const bootstrap = useQuery(() => ({ queryKey: [serverSDK.scope, "bootstrap"], queryFn: async () => { @@ -197,17 +210,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { function applyProjectUpdate(update: Parameters[1]) { setGlobalStore("project", (projects) => - projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)), + projects.map((project) => + project.id === update.id + ? // The wire payload carries no worktrees; keep the inventory this project already loaded. + withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical)) + : project, + ), ) } const unsub = serverSDK.event.listen((event) => { connection.handleEvent({ type: event.type }) if (event.type === "project.updated") applyProjectUpdate(event.data) + if (event.type === "worktree.updated") { + const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree + if (root) void worktrees.refresh(root) + void bootstrap.refetch() + return + } if (!event.location) { - if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated") - bootstrap.refetch() + if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch() return } @@ -216,7 +239,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { if (!children.children[key]) return children.mark(key) if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key) - if (event.type === "worktree.updated") void bootstrap.refetch() }) onCleanup(unsub) @@ -261,6 +283,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, + worktrees, mcp: { toggle: async (directory: string, name: string) => { const key = directoryKey(directory) diff --git a/packages/app/src/workspaces/inventory.test.ts b/packages/app/src/workspaces/inventory.test.ts new file mode 100644 index 000000000000..af1ef5b60dc4 --- /dev/null +++ b/packages/app/src/workspaces/inventory.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test" +import { QueryClient } from "@tanstack/solid-query" +import type { WorktreeDirectory } from "@opencode-ai/client/promise" +import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryKey } from "./inventory" +import { ServerScope } from "@/runtime/server/scope" +import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global-sync/utils" + +function setup(list: (directory: string) => Promise) { + const client = new QueryClient() + const calls: string[] = [] + const updates: Array<[string, WorktreeDirectory[]]> = [] + const inventory = createWorktreeInventory({ + scope: ServerScope.local, + queryClient: client, + api: () => ({ + list: (input) => { + const directory = input!.location!.directory! + calls.push(directory) + return list(directory) + }, + }), + updated: (directory, items) => updates.push([directory, items]), + }) + return { client, calls, updates, inventory } +} + +describe("createWorktreeInventory", () => { + test("loads once per project, shares in-flight work, and publishes the result", async () => { + const gate = Promise.withResolvers() + const setupResult = setup(async (directory) => { + await gate.promise + return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }] + }) + const first = setupResult.inventory.load("/repo") + const second = setupResult.inventory.load("/repo/") + expect(setupResult.calls).toEqual(["/repo"]) + gate.resolve() + expect(await first).toHaveLength(2) + expect(await second).toHaveLength(2) + await setupResult.inventory.load("/repo") + expect(setupResult.calls).toEqual(["/repo"]) + expect(setupResult.updates).toEqual([ + ["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]], + ]) + expect(setupResult.inventory.cached("/repo/")).toHaveLength(2) + setupResult.client.clear() + }) + + test("refreshes only inventories a view already loaded", async () => { + const setupResult = setup(async (directory) => [{ directory }]) + await setupResult.inventory.refresh("/never-opened") + expect(setupResult.calls).toEqual([]) + await setupResult.inventory.load("/opened") + await setupResult.inventory.refresh("/opened") + expect(setupResult.calls).toEqual(["/opened", "/opened"]) + setupResult.client.clear() + }) + + test("a failed load is not cached and never rejects the caller", async () => { + let fail = true + const setupResult = setup(async (directory) => { + if (fail) throw new Error("Location unavailable") + return [{ directory }] + }) + expect(await setupResult.inventory.load("/repo")).toBeUndefined() + expect(setupResult.inventory.cached("/repo")).toBeUndefined() + fail = false + expect(await setupResult.inventory.load("/repo")).toEqual([{ directory: "/repo" }]) + expect(setupResult.calls).toEqual(["/repo", "/repo"]) + setupResult.client.clear() + }) + + test("keys are partitioned by server and normalized by path", () => { + const remote = "https://remote.example" as typeof ServerScope.local + expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual( + worktreeInventoryKey(ServerScope.local, "C:/Repo"), + ) + expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo")) + }) +}) + +describe("withWorktreeInventory", () => { + const metadata = { + id: "project", + canonical: "/repo", + name: "Before", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + + test("derives the workspace list from the inventory, excluding the project root", () => { + const worktrees = [ + { directory: "/repo/" }, + { directory: "/repo/feature", strategy: "git" }, + { directory: "/elsewhere" }, + ] + expect(withWorktreeInventory(normalizeProjectInfo(metadata), worktrees)).toMatchObject({ + worktree: "/repo", + sandboxes: ["/repo/feature", "/elsewhere"], + worktrees, + }) + }) + + test("leaves metadata untouched without an inventory and survives metadata updates", () => { + const project = normalizeProjectInfo(metadata) + expect(withWorktreeInventory(project, undefined)).toBe(project) + const cached = [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }] + const updated = updateProjectInfo(withWorktreeInventory(project, cached), { ...metadata, name: "After" }) + expect(withWorktreeInventory(updated, cached)).toMatchObject({ name: "After", sandboxes: ["/repo/feature"] }) + }) +}) diff --git a/packages/app/src/workspaces/inventory.ts b/packages/app/src/workspaces/inventory.ts new file mode 100644 index 000000000000..6016745fab8b --- /dev/null +++ b/packages/app/src/workspaces/inventory.ts @@ -0,0 +1,58 @@ +import type { QueryClient } from "@tanstack/solid-query" +import type { WorktreeDirectory } from "@opencode-ai/client/promise" +import type { ServerApi } from "@/runtime/server/api" +import type { ServerScope } from "@/runtime/server/scope" +import type { Project } from "@/runtime/server/types" +import { pathKey } from "./path-key" +import { sameDirectory } from "./paths" + +export function worktreeInventoryKey(scope: ServerScope, directory: string) { + return [scope, "worktree", pathKey(directory)] as const +} + +// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list. +export function withWorktreeInventory(project: Project, worktrees: readonly WorktreeDirectory[] | undefined): Project { + if (!worktrees) return project + return { + ...project, + worktrees: [...worktrees], + sandboxes: worktrees + .map((item) => item.directory) + .filter((directory) => !sameDirectory(project.worktree, directory)), + } +} + +// Listing a project's worktrees boots its Location on the server and runs discovery, so only +// projects the user is looking at are loaded. Historical projects stay metadata-only. +export function createWorktreeInventory(input: { + scope: ServerScope + queryClient: QueryClient + api: () => Pick + updated: (directory: string, worktrees: WorktreeDirectory[]) => void +}) { + const options = (directory: string) => ({ + queryKey: worktreeInventoryKey(input.scope, directory), + queryFn: () => + input + .api() + .list({ location: { directory } }) + .then((items) => { + input.updated(directory, items) + return items + }), + // `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list. + staleTime: Infinity, + gcTime: Infinity, + retry: false, + }) + return { + cached: (directory: string) => + input.queryClient.getQueryData(worktreeInventoryKey(input.scope, directory)), + load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined), + // Only inventories some view already demanded are refreshed. + refresh: (directory: string) => { + if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve() + return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined) + }, + } +} diff --git a/packages/app/src/workspaces/location.tsx b/packages/app/src/workspaces/location.tsx index 2bd83bc3e27c..056247b10a9f 100644 --- a/packages/app/src/workspaces/location.tsx +++ b/packages/app/src/workspaces/location.tsx @@ -3,7 +3,7 @@ import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise import { retry } from "@opencode-ai/util/retry" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { type LocationContext, useServerSDK } from "@/runtime/server/client" -import { useData } from "@/runtime/server/current" +import { useData, useServer } from "@/runtime/server/current" export type { LocationContext } from "@/runtime/server/client" export type WorkspaceLocation = LocationContext & { @@ -15,6 +15,7 @@ const context = createSimpleContext({ name: "Location", init: (props: { directory: string | Accessor; workspaceID?: string | Accessor }) => { const serverSDK = useServerSDK() + const server = useServer() const data = useData() const ref = createMemo( () => ({ @@ -40,6 +41,14 @@ const context = createSimpleContext({ retryIf: () => !stale, }).catch(() => undefined) }) + createEffect(() => { + const id = current()?.project.id + if (!id || serverSDK.connection.status() !== "connected") return + // Showing a Location is the demand for its project's worktree inventory (workspace styling, picker). + // Key it by the metadata root so the result merges into the same global project record. + const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree + if (root) void server.ctx.sync.worktrees.load(root) + }) const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory)) return createMemo(() => ({ From 16d60f000ac251a68757aa581b0130aaf3fcd615 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:39:36 +1000 Subject: [PATCH 2/3] fix(app): cap concurrent server requests and log stalls Route every SDK request to a server through a FIFO queue capped at four in flight. Chromium allows six connections per origin; the event stream holds one and health probes use their own fetch, so the app's own bursts can no longer starve them inside the browser where nothing can observe it. /api/event is exempt. When the oldest queued request has waited two seconds, log the in-flight and queued requests once per ten seconds so debug exports show what the server was busy with. --- packages/app/src/runtime/server/client.tsx | 4 +- .../src/runtime/server/request-queue.test.ts | 113 ++++++++++++++++++ .../app/src/runtime/server/request-queue.ts | 84 +++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/runtime/server/request-queue.test.ts create mode 100644 packages/app/src/runtime/server/request-queue.ts diff --git a/packages/app/src/runtime/server/client.tsx b/packages/app/src/runtime/server/client.tsx index 71ac4db5f72f..e45bc144c818 100644 --- a/packages/app/src/runtime/server/client.tsx +++ b/packages/app/src/runtime/server/client.tsx @@ -6,6 +6,7 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api" import { usePlatform } from "@/runtime/platform/platform" import { ServerConnection } from "./registry" import { createRefCountMap } from "@/runtime/server/refcount" +import { createRequestQueue } from "@/runtime/server/request-queue" import { ServerScope } from "@/runtime/server/scope" import { useServer } from "./current" @@ -114,8 +115,9 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase; readonly api: ServerApi readonly pty: ReturnType } { + const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch }) const build = (http: ServerConnection.HttpBase) => { - const api = createApiForServer({ server: http, fetch: input.fetch }) + const api = createApiForServer({ server: http, fetch: queue.fetch }) return { http, api, pty: createPtyClient(api, { url: http.url }) } } const state = { current: build(input.http) } diff --git a/packages/app/src/runtime/server/request-queue.test.ts b/packages/app/src/runtime/server/request-queue.test.ts new file mode 100644 index 000000000000..5254eccd7106 --- /dev/null +++ b/packages/app/src/runtime/server/request-queue.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import { createRequestQueue } from "./request-queue" + +function setup(input?: { limit?: number; stallMs?: number }) { + const pending: Array<{ url: string; resolve: () => void }> = [] + const logs: Array<{ message: string; data: Record }> = [] + let clock = 0 + const queue = createRequestQueue({ + limit: input?.limit ?? 2, + stallMs: input?.stallMs, + now: () => clock, + log: (message, data) => logs.push({ message, data }), + fetch: Object.assign( + (resource: RequestInfo | URL) => + new Promise((resolve) => { + pending.push({ url: new Request(resource).url, resolve: () => resolve(new Response("ok")) }) + }), + { preconnect() {} }, + ), + }) + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) } +} + +describe("createRequestQueue", () => { + test("caps concurrent requests and starts queued ones as slots free up", async () => { + const input = setup() + const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`)) + await input.settle() + expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"]) + expect(input.queue.queued()).toBe(1) + input.pending[0]!.resolve() + await input.settle() + expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"]) + input.pending.forEach((item) => item.resolve()) + await Promise.all(responses) + expect(input.queue.inflight()).toBe(0) + }) + + test("never counts the event stream against the budget", async () => { + const input = setup({ limit: 1 }) + void input.queue.fetch("http://server/api/session") + void input.queue.fetch("http://server/api/event") + await input.settle() + expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"]) + expect(input.queue.inflight()).toBe(1) + }) + + test("aborted requests leave the queue without being sent", async () => { + const input = setup({ limit: 1 }) + const controller = new AbortController() + void input.queue.fetch("http://server/api/first") + const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal }) + controller.abort() + await input.settle() + input.pending[0]!.resolve() + await expect(aborted).rejects.toBeInstanceOf(DOMException) + expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"]) + expect(input.queue.inflight()).toBe(0) + }) + + test("a burst that drains promptly is not thrashing", async () => { + const input = setup({ stallMs: 5 }) + const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`)) + await input.settle() + expect(input.queue.queued()).toBe(10) + // Drain two at a time before the stall threshold elapses. + for (let round = 0; round < 6; round++) { + input.pending.splice(0).forEach((item) => item.resolve()) + await input.settle() + } + await Promise.all(responses) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(input.logs).toEqual([]) + }) + + test("logs what is in flight and queued once per burst after requests stall", async () => { + const input = setup({ stallMs: 5 }) + input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined) + input.tick(50) + input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined) + input.tick(50) + input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined) + input.tick(100) + input.queue.fetch("http://server/api/health").catch(() => undefined) + expect(input.logs).toEqual([]) + input.tick(2_000) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(input.logs).toEqual([ + { + message: "server thrashing detected", + data: { + limit: 2, + inflight: [ + { method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 }, + { method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 }, + ], + queued: [ + { method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 }, + { method: "GET", url: "http://server/api/health", ms: 2_000 }, + ], + }, + }, + ]) + // Still stalled within the rate limit: no repeat. + input.tick(2_000) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(input.logs).toHaveLength(1) + input.tick(10_000) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(input.logs).toHaveLength(2) + }) +}) diff --git a/packages/app/src/runtime/server/request-queue.ts b/packages/app/src/runtime/server/request-queue.ts new file mode 100644 index 000000000000..61fcbb68f471 --- /dev/null +++ b/packages/app/src/runtime/server/request-queue.ts @@ -0,0 +1,84 @@ +type Entry = { method: string; url: string; at: number } + +// Chromium allows six connections per origin. The event stream holds one for the life of the +// connection and health probes use their own fetch, so the app's API calls stay below that or +// a burst stalls probes and user actions inside the browser where nothing can observe it. +export const requestQueueLimit = 4 + +// A mount legitimately fires a dozen requests at once; only a request that has waited this long +// for a slot indicates the server is not keeping up. +export const requestStallMs = 2_000 + +export function createRequestQueue(input: { + fetch: typeof globalThis.fetch + limit?: number + stallMs?: number + log?: (message: string, data: Record) => void + now?: () => number +}) { + const limit = input.limit ?? requestQueueLimit + const stallMs = input.stallMs ?? requestStallMs + // Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object. + const base = input.fetch + const now = input.now ?? Date.now + const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data)) + const inflight = new Set() + const waiting: Array<{ entry: Entry; start: () => void }> = [] + let warned = -Infinity + let watcher: ReturnType | undefined + + const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at }) + // Debug exports include the console, so list what the server is busy with while requests wait. + const watch = () => { + watcher = undefined + const oldest = waiting[0]?.entry + if (!oldest) return + if (now() - oldest.at >= stallMs && now() - warned >= 10_000) { + warned = now() + log("server thrashing detected", { + limit, + inflight: [...inflight].map(describe), + queued: waiting.map((item) => describe(item.entry)), + }) + } + watcher = setTimeout(watch, stallMs) + } + const release = (entry: Entry) => { + inflight.delete(entry) + waiting.shift()?.start() + } + const acquire = (entry: Entry) => + new Promise((resolve) => { + const start = () => { + entry.at = now() + inflight.add(entry) + resolve() + } + if (inflight.size < limit) return start() + waiting.push({ entry, start }) + watcher ??= setTimeout(watch, stallMs) + }) + + const fetch: typeof globalThis.fetch = Object.assign( + async (resource: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(resource, init) + // The event stream is long-lived; never count it against the request budget. + if (new URL(request.url).pathname === "/api/event") return base(request) + const entry = { method: request.method, url: request.url, at: now() } + await acquire(entry) + if (request.signal.aborted) { + release(entry) + throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError") + } + return base(request).finally(() => release(entry)) + }, + // Bun's fetch type carries preconnect; the browser never calls it. + { preconnect: () => {} }, + ) + + return { + fetch, + inflight: () => inflight.size, + queued: () => waiting.length, + } +} From ea0065586ff630ace4d4f408f6541bf473cc5933 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:16:16 +1000 Subject: [PATCH 3/3] test(app): return the required project in spec-local location mocks LocationGetOutput.project is required. Three spec-local mocks omitted it, so the Location provider's inventory lookup threw under those fixtures. Also answer GET /api/worktree with the root instead of an empty object. --- packages/app/e2e/regression/cross-server-tab-close.spec.ts | 7 ++++++- .../app/e2e/regression/remote-session-settings.spec.ts | 7 ++++++- packages/app/e2e/regression/tab-navigate-mousedown.spec.ts | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 29c26a4bb07f..16c096dacc34 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -86,7 +86,12 @@ async function mockServers(page: Page, requests: string[]) { } return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory }) } - if (url.pathname === "/api/location") return json(route, { directory: current.directory }) + if (url.pathname === "/api/location") + return json(route, { + directory: current.directory, + project: { id: current.projectID, directory: current.directory, canonical: current.directory }, + }) + if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }]) if (url.pathname === "/api/vcs") return json(route, { location: { directory: current.directory }, diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index ebb9f36dfc37..85b2a25a73f1 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -396,7 +396,12 @@ async function mockServers( return json(route, { data: [], cursor: {} }) if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`)) return json(route, { data: [] }) - if (url.pathname === "/api/location") return json(route, { directory }) + if (url.pathname === "/api/location") + return json(route, { + directory, + project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory }, + }) + if (url.pathname === "/api/worktree") return json(route, [{ directory }]) if (url.pathname === "/api/vcs") return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] }) diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index b6abe02b76d4..dd471f311e90 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -628,7 +628,12 @@ async function mockServer(page: Page) { url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory }, ) } - if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory }) + if (url.pathname === "/api/location") + return json(route, { + directory: sessionA.directory, + project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory }, + }) + if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }]) if (url.pathname === "/api/vcs") return json(route, { location: { directory: sessionA.directory },