feat(server): read a location's catalog in one request by Hona · Pull Request #47578 · anomalyco/opencode · GitHub
Skip to content

feat(server): read a location's catalog in one request - #47578

Open
Hona wants to merge 1 commit into
anomalyco:v2from
Hona:location-catalog
Open

feat(server): read a location's catalog in one request#47578
Hona wants to merge 1 commit into
anomalyco:v2from
Hona:location-catalog

Conversation

@Hona

@Hona Hona commented Sep 6, 2026

Copy link
Copy Markdown
Member

data.location.sync — the shared read both the TUI and Desktop use to open a Location — fans out into 13 requests: location.get plus twelve list endpoints. Every session tab mount pays it, every /cd pays it, and reconnect pays it once per open directory. In a Desktop netlog those twelve endpoints were 646 of 759 real requests.

This PR adds one server read model for a Location and teaches the shared client to seed from it, so both clients go from 13 requests to 2 with no client-specific code.

flowchart LR
  subgraph before [Before: 13 requests]
    L[location.get] --> F[agent · command · integration · mcp · mcp/resource · model · provider · reference · skill · shell · form · vcs]
  end
  subgraph after [After: 2 requests]
    C["GET /api/location/catalog"] --- V["GET /api/vcs (git; kept separate)"]
  end
Loading

Measured

One location.sync per round, 30 rounds per strategy, p50 / p90, real servers with populated project directories. "Desktop today" is twelve requests through the app's 4-slot request queue; "TUI today" is twelve requests at once (Bun has no per-origin limit).

Environment Desktop today TUI today Catalog Desktop TUI
Local sidecar (loopback) 19.9 / 26.1 ms 18.7 / 26.1 ms 11.3 / 14.4 ms 1.8× 1.7×
LAN, Tailscale to a MacBook (~5 ms RTT) 33.3 / 46.3 ms 23.5 / 37.3 ms ~13.4 / ~17.2 ms* 2.5× 1.8×
WAN, Debian VPS (25 ms RTT) 88.7 / 96.8 ms 44.9 / 49.1 ms 40.5 / 42.5 ms 2.2× 1.1×

* the LAN server runs a build without the endpoint; that row is one request to the slowest member (/api/model), which bounds the catalog's cost. The WAN row is the real endpoint.

  • Desktop gains everywhere: its queue turns twelve requests into three waves, and the catalog is one wave. Over the WAN that is ~48 ms per location.sync, and ~140 ms on a first mount once the CORS preflights Chromium still pays against remote servers are counted (see below).
  • The TUI gains latency locally and on the LAN; over the WAN it already sent everything in one wave, so its gain is twelve handler invocations and connections becoming one on the server.
  • The catalog sits ~15 ms above a bare GET /api/health round trip in every environment — the server's concurrent read of nine services, which is the irreducible part.

Local sidecar

packages/server/test/location-catalog.bench.ts runs one location.sync against a real server 30 times per strategy, on this repository's project directory (populated agents, commands, skills, MCP config), idle server, loopback.

Strategy p50 p90 requests
Desktop today: 12 requests, 4 at a time (request queue) 19.9 ms 26.1 ms 12
TUI today: 12 requests, all at once 18.7 ms 26.1 ms 12
Catalog: 1 request 11.3 ms 14.4 ms 1

That is the floor. Bun's fetch does not pay Chromium's per-request setup (2–7 ms each in the netlog), and an idle server does not queue; under a busy sidecar the twelve requests wait behind each other and behind other clients, while the catalog is one handler invocation. Reconnect with 20 open directories goes from 240 requests to 20.

LAN

Remote server over the LAN (Windows → macOS over Tailscale, real project directory, keep-alive, 30 rounds). The remote runs a build without the endpoint, so the catalog row is one request to the slowest member (/api/model, 21 ms p50) — what the catalog's cost is bounded by:

Strategy p50 p90 requests
Desktop today: 12 requests, 4 at a time 33.3 ms 46.3 ms 12
TUI today: 12 requests, all at once 23.5 ms 37.3 ms 12
Catalog (estimate): 1 request 13.4 ms 17.2 ms 1

WAN

Remote server over the WAN (Windows → a Debian VPS, 25 ms ping, SSH tunnel, this branch's server, real project directory, keep-alive, 30 rounds). Here the catalog row is the real endpoint:

Strategy p50 p90 requests
Desktop today: 12 requests, 4 at a time 88.7 ms 96.8 ms 12
TUI today: 12 requests, all at once 44.9 ms 49.1 ms 12
Catalog: 1 request 40.5 ms 42.5 ms 1
Round-trip floor: GET /api/health 25.5 ms 26.4 ms 1

Desktop's three queue waves collapse to one round trip plus ~15 ms of concurrent server work (the catalog sits 15 ms above the bare round trip). The TUI already sent all twelve at once, so over a WAN its gain is server load and connections rather than latency; its latency gain is the local and LAN cases above.

Desktop against a remote server still sends Authorization from the renderer, so each of those twelve URLs also pays a CORS preflight on first use: a first mount of a new directory is 24 round trips today versus 2 — about six queue waves, or ~180 ms at this RTT.

Where Desktop and the TUI converge

Neither client talks to the server directly for this; both call the same createData().location.sync from @opencode-ai/client/solid, which is the single place the change lives.

flowchart TB
  subgraph desktop [Desktop · packages/app]
    D1["workspaces/location.tsx · LocationProvider<br/>(session tab mount)"]
    D2["runtime/server/sync.tsx · bootstrapInstance<br/>(project open, reconnect)"]
  end
  subgraph tui [TUI · packages/tui]
    T1["context/location.tsx · LocationProvider<br/>(/cd, session open)"]
    T2["context/data.tsx · DataProvider bootstrap"]
    T3["component/dialog-session-list.tsx"]
  end
  D1 & D2 & T1 & T2 & T3 --> S["@opencode-ai/client/solid<br/>createData().location.sync(ref) · data.ts"]
  S --> G["@opencode-ai/client/promise (generated)<br/>api.location.catalog({ location })"]
  G -->|"GET /api/location/catalog?location[directory]=…"| P["@opencode-ai/protocol<br/>LocationGroup · location.catalog"]
  P --> H["@opencode-ai/server<br/>LocationHandler → LocationCatalog.read"]
  H -->|"Effect.all, unbounded"| C["core services<br/>Agent · Command · Integration · Mcp · Catalog · Reference · Skill · Shell · Form"]
Loading

Before, the same location.sync frame fanned out into twelve generated-client calls, each becoming its own HTTP request, LocationMiddleware resolution, handler fiber, and JSON encode — on both clients, because it is the same code.

One contract, enforced in both directions. LocationCatalog is a struct whose fields are the list endpoints' own data schemas (Schema.Array(Agent.Info), Mcp.ResourceCatalog, …), so:

  • the server test asserts catalog.data[field] deep-equals each list endpoint's data for the same location;
  • the client's per-resource catalog: (response) => response.data.agent readers are typed against the generated LocationCatalogOutput, so a field that changes shape fails typecheck in @opencode-ai/client rather than at runtime in either app;
  • the per-resource endpoints stay the unit of invalidation: an agent.updated event still refetches /api/agent only, on both clients, because that logic also lives in data.ts.

The TUI and Desktop need no code changes and gain the same behaviour: the request count drops from 13 to 2 per Location, and the response is one consistent snapshot rather than twelve reads racing service settlement.

Server — packages/protocol, packages/server

  • GET /api/location/catalog (location.catalog) returns Location.response(LocationCatalog): exactly what each list endpoint returns, keyed by field.
  • packages/server/src/location-catalog.ts owns the read: the nine services, one Effect.all with unbounded concurrency. mcpServers (the public MCP summary) lives there too and mcp.list reuses it, so the two endpoints cannot drift.
  • VCS is deliberately excluded: git status on a large repository takes seconds (p50 1.8 s in the netlog) and would hold back everything else.
  • location-catalog.test.ts asserts the catalog equals the individual endpoints for the same location.
export const read = Effect.gen(function* () {
  const agent = yield* Agent.Service
  // … eight more services
  return yield* Effect.all(
    { agent: agent.list(), command: command.list(), /* … */ mcp: mcpServers, form: form.list() },
    { concurrency: "unbounded" },
  )
})

Client — packages/client/src/solid/data.ts

Each locationResource now declares how to read itself from the catalog response, and the catalog resources are one list that drives seeding, syncing, and invalidation. The three hand-maintained 13-line blocks are gone.

const agents = locationResource("agent", (location) => api().agent.list({ location }), {
  catalog: (response) => response.data.agent,
})
// … one per field; shells and MCP resources map through the same transforms their own reads use
const catalogResources = [agents, commands, integrations, mcpServers, mcpResources, models, providers, references, skills, shells]

async sync(ref) {
  await sync.run(`location.catalog:${id}`, async () => {
    const response = await api().location.catalog({ location: locationQuery(location) })
    batch(() => {
      // info + global forms, then:
      catalogResources.forEach((resource) => resource.seed(location, response))
    })
  })
  await Promise.all([syncInfo(ref), vcs.sync(location), ...catalogResources.map((r) => r.sync(location)), forms])
}
  • seed publishes the field and marks its sync key current, so the per-resource sync() calls views make (useProviders, MCP dialogs, …) are no-ops until an event invalidates them. After agent.updated, the next location.sync fetches only /api/agent.
  • seed never overwrites a key that already has a current or in-flight read: a read that lands while the catalog is in flight is newer and wins.
  • Generated promise/effect clients regenerated (bun run generate).

Tests

  • TUI and app e2e fixtures compose /api/location/catalog from their per-endpoint mocks, so specs keep stubbing /api/agent, /api/skill, … individually; app specs that hand-roll the whole API return the catalog directly.
  • Two tests that encoded the old wire pattern were rewritten to the composite semantics: the TUI prompt keeps the source model until the whole target catalog lands (no more partial "Target Model provider" frame), and an MCP status event that arrives mid-bootstrap is not overwritten by the older catalog response.

@Hona
Hona requested a review from Brendonovich as a code owner September 6, 2026 05:09
Copilot AI lite review requested due to automatic review settings September 6, 2026 05:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Hona
Hona force-pushed the location-catalog branch 3 times, most recently from d8c910e to 13049ef Compare September 6, 2026 07:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants