perf(gateway): share one node.list snapshot across session catalog providers (#114646)

This commit is contained in:
Peter Steinberger
2026-07-27 13:14:07 -04:00
committed by GitHub
parent 1af330548a
commit 8f8e40dabe
11 changed files with 174 additions and 30 deletions
@@ -382,7 +382,7 @@ async function listPiHosts(
}
let nodes: Awaited<ReturnType<PluginRuntime["nodes"]["list"]>>["nodes"];
try {
nodes = (await runtime.nodes.list()).nodes;
nodes = (await (query.listNodes?.() ?? runtime.nodes.list())).nodes;
} catch {
return hosts;
}
+19 -10
View File
@@ -745,19 +745,20 @@ describe("Pi session catalog", () => {
}),
};
const invoke = vi.fn().mockResolvedValue(page);
const nodes = [
{
nodeId: "node-1",
connected: true,
commands: [PI_SESSIONS_LIST_COMMAND, PI_TERMINAL_RESUME_COMMAND],
},
];
const runtimeListNodes = vi.fn().mockResolvedValue({ nodes });
const requestListNodes = vi.fn().mockResolvedValue({ nodes });
registerPiSessionCatalog({
pluginConfig: {},
runtime: {
nodes: {
list: vi.fn().mockResolvedValue({
nodes: [
{
nodeId: "node-1",
connected: true,
commands: [PI_SESSIONS_LIST_COMMAND, PI_TERMINAL_RESUME_COMMAND],
},
],
}),
list: runtimeListNodes,
invoke,
},
},
@@ -768,7 +769,13 @@ describe("Pi session catalog", () => {
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(provider!.list({ hostIds: ["node:node-1"], search: "remote" })).resolves.toEqual([
await expect(
provider!.list({
hostIds: ["node:node-1"],
search: "remote",
listNodes: requestListNodes,
}),
).resolves.toEqual([
expect.objectContaining({
sessions: [
expect.objectContaining({
@@ -779,6 +786,8 @@ describe("Pi session catalog", () => {
],
}),
]);
expect(requestListNodes).toHaveBeenCalledOnce();
expect(runtimeListNodes).not.toHaveBeenCalled();
expect(invoke).toHaveBeenNthCalledWith(1, {
nodeId: "node-1",
command: PI_SESSIONS_LIST_COMMAND,
+17 -4
View File
@@ -2261,20 +2261,33 @@ describe("Claude session catalog", () => {
await openGate;
return await originalOpen(...args);
});
const listNodes = vi.fn(async () => ({ nodes: [] }));
const runtimeListNodes = vi.fn(async () => ({ nodes: [] }));
const requestListNodes = vi.fn(async () => ({ nodes: [] }));
const provider = captureCatalogProvider({
nodes: { list: listNodes },
nodes: { list: runtimeListNodes },
} as unknown as PluginRuntime);
const listing = provider.list({});
const listing = provider.list({ listNodes: requestListNodes });
await opened;
expect(listNodes).toHaveBeenCalledOnce();
expect(requestListNodes).toHaveBeenCalledOnce();
expect(runtimeListNodes).not.toHaveBeenCalled();
releaseOpen();
await expect(listing).resolves.toMatchObject([
{ hostId: "gateway:local", sessions: [expect.objectContaining({ threadId: sessionId })] },
]);
});
it("falls back to the plugin node runtime without a request snapshot", async () => {
const runtimeListNodes = vi.fn(async () => ({ nodes: [] }));
const provider = captureCatalogProvider({
nodes: { list: runtimeListNodes },
} as unknown as PluginRuntime);
await expect(provider.list({ hostIds: ["node:missing"] })).resolves.toEqual([]);
expect(runtimeListNodes).toHaveBeenCalledOnce();
});
it("keeps the underlying paired-node list failure", async () => {
const runtime = {
nodes: {
+4 -2
View File
@@ -1116,6 +1116,7 @@ function parseGatewayQuery(value: unknown): {
async function listClaudeSessionCatalog(params: {
runtime: PluginRuntime;
query?: unknown;
listNodes?: Parameters<SessionCatalogProvider["list"]>[0]["listNodes"];
onHost?: (host: ClaudeSessionCatalogHost) => void;
}): Promise<ClaudeSessionCatalogResult> {
const query = parseGatewayQuery(params.query);
@@ -1165,7 +1166,7 @@ async function listClaudeSessionCatalog(params: {
}
let nodes: Awaited<ReturnType<PluginRuntime["nodes"]["list"]>>["nodes"];
try {
nodes = (await params.runtime.nodes.list()).nodes;
nodes = (await (params.listNodes?.() ?? params.runtime.nodes.list())).nodes;
} catch (error) {
const registryHost: ClaudeSessionCatalogHost = {
hostId: "node:registry",
@@ -1598,12 +1599,13 @@ export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
list: async (query) => {
const adopted = listBoundClaudeSessions(api, query.sessionEntries);
const localCliAvailable = catalogTerminal.isClaudeCliAvailable();
const { onHost, sessionEntries: _sessionEntries, ...gatewayQuery } = query;
const { listNodes, onHost, sessionEntries: _sessionEntries, ...gatewayQuery } = query;
const mapHost = (host: ClaudeSessionCatalogHost) =>
toGenericClaudeHost(host, adopted, localCliAvailable);
const result = await listClaudeSessionCatalog({
runtime: api.runtime,
query: gatewayQuery,
listNodes,
...(onHost ? { onHost: (host) => onHost(mapHost(host)) } : {}),
});
return result.hosts.map(mapHost);
@@ -1388,6 +1388,29 @@ describe("Codex supervision catalog", () => {
expect(runtime.nodes.list).not.toHaveBeenCalled();
});
it("prefers the request node snapshot and retains the plugin runtime fallback", async () => {
const control = createControl();
const { runtime } = createRuntime();
const { api, getProvider } = createGatewayApi(runtime);
registerCodexSessionCatalog({
api,
bindingStore: createCodexTestBindingStore(),
control,
getRuntimeConfig: () => config,
});
const provider = getProvider();
const requestListNodes = vi.fn(async () => ({ nodes: [] }));
await expect(
provider?.list({ hostIds: ["node:missing"], listNodes: requestListNodes }),
).resolves.toEqual([]);
expect(requestListNodes).toHaveBeenCalledOnce();
expect(runtime.nodes.list).not.toHaveBeenCalled();
await expect(provider?.list({ hostIds: ["node:missing"] })).resolves.toEqual([]);
expect(runtime.nodes.list).toHaveBeenCalledOnce();
});
it("enriches only the local source row with its adopted OpenClaw session", async () => {
const control = createControl({
listPage: vi.fn(async () => ({
+4 -2
View File
@@ -422,6 +422,7 @@ async function listCodexSessionCatalog(params: {
runtime: PluginRuntime;
control: CodexSessionCatalogControl;
query?: CodexSessionCatalogParams;
listNodes?: Parameters<SessionCatalogProvider["list"]>[0]["listNodes"];
onHost?: (host: CodexSessionCatalogHost) => void;
sessionEntries?: SessionCatalogEntrySnapshot;
}): Promise<CodexSessionCatalogResult> {
@@ -452,7 +453,7 @@ async function listCodexSessionCatalog(params: {
}
let nodes: CatalogNode[];
try {
nodes = (await params.runtime.nodes.list()).nodes
nodes = (await (params.listNodes?.() ?? params.runtime.nodes.list())).nodes
.filter(
(node) =>
node.commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) &&
@@ -1273,7 +1274,7 @@ function registerCodexSessionCatalog(params: {
),
list: async (query) => {
const localTerminalAvailable = resolveLocalCodexTerminalExecutable() !== undefined;
const { onHost, sessionEntries, ...gatewayQuery } = query;
const { listNodes, onHost, sessionEntries, ...gatewayQuery } = query;
const mapHost = (host: CodexSessionCatalogHost) =>
toGenericCatalogHost(host, localTerminalAvailable);
return (
@@ -1283,6 +1284,7 @@ function registerCodexSessionCatalog(params: {
runtime: params.api.runtime,
control: params.control,
query: gatewayQuery,
listNodes,
sessionEntries,
...(onHost ? { onHost: (host) => onHost(mapHost(host)) } : {}),
})
@@ -374,7 +374,7 @@ async function listOpenCodeHosts(
}
let nodes: Awaited<ReturnType<PluginRuntime["nodes"]["list"]>>["nodes"];
try {
nodes = (await runtime.nodes.list()).nodes;
nodes = (await (query.listNodes?.() ?? runtime.nodes.list())).nodes;
} catch {
return hosts;
}
+19 -10
View File
@@ -688,19 +688,20 @@ describe("OpenCode session catalog", () => {
}),
};
const invoke = vi.fn().mockResolvedValue(page);
const nodes = [
{
nodeId: "node-1",
connected: true,
commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND],
},
];
const runtimeListNodes = vi.fn().mockResolvedValue({ nodes });
const requestListNodes = vi.fn().mockResolvedValue({ nodes });
registerOpenCodeSessionCatalog({
pluginConfig: {},
runtime: {
nodes: {
list: vi.fn().mockResolvedValue({
nodes: [
{
nodeId: "node-1",
connected: true,
commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND],
},
],
}),
list: runtimeListNodes,
invoke,
},
},
@@ -711,7 +712,13 @@ describe("OpenCode session catalog", () => {
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(provider!.list({ hostIds: ["node:node-1"], search: "remote" })).resolves.toEqual([
await expect(
provider!.list({
hostIds: ["node:node-1"],
search: "remote",
listNodes: requestListNodes,
}),
).resolves.toEqual([
expect.objectContaining({
sessions: [
expect.objectContaining({
@@ -722,6 +729,8 @@ describe("OpenCode session catalog", () => {
],
}),
]);
expect(requestListNodes).toHaveBeenCalledOnce();
expect(runtimeListNodes).not.toHaveBeenCalled();
expect(invoke).toHaveBeenNthCalledWith(1, {
nodeId: "node-1",
command: OPENCODE_SESSIONS_LIST_COMMAND,
@@ -350,6 +350,72 @@ describe("session catalog Gateway methods", () => {
});
});
it("shares one lazy Gateway node snapshot across catalog providers", async () => {
const previousNodesRuntime = gatewaySubagentState.nodes;
const dispatchNodeList = vi.fn(async () => ({
nodes: [{ nodeId: "shared-node", connected: true }],
}));
gatewaySubagentState.nodes = {
list: dispatchNodeList,
invoke: vi.fn(async () => undefined),
};
try {
const catalogUsingNodes = (id: string) =>
provider(id, {
list: vi.fn(async ({ listNodes }) => {
expect(await listNodes?.()).toEqual({
nodes: [{ nodeId: "shared-node", connected: true }],
});
return [];
}),
});
hoisted.activeRegistry.sessionCatalogs = [
{ provider: catalogUsingNodes("zeta") },
{ provider: catalogUsingNodes("alpha") },
];
await call("sessions.catalog.list", {});
expect(dispatchNodeList).toHaveBeenCalledOnce();
} finally {
gatewaySubagentState.nodes = previousNodesRuntime;
}
});
it("keeps catalog-filtered Gateway node snapshots lazy", async () => {
const previousNodesRuntime = gatewaySubagentState.nodes;
const dispatchNodeList = vi.fn(async () => ({ nodes: [] }));
gatewaySubagentState.nodes = {
list: dispatchNodeList,
invoke: vi.fn(async () => undefined),
};
try {
const selectedList = vi.fn(async () => []);
hoisted.activeRegistry.sessionCatalogs = [
{
provider: provider("selected", { list: selectedList }),
},
{
provider: provider("unselected", {
list: vi.fn(async ({ listNodes }) => {
await listNodes?.();
return [];
}),
}),
},
];
await call("sessions.catalog.list", { catalogId: "selected" });
expect(selectedList).toHaveBeenCalledWith(
expect.objectContaining({ listNodes: expect.any(Function) }),
);
expect(dispatchNodeList).not.toHaveBeenCalled();
} finally {
gatewaySubagentState.nodes = previousNodesRuntime;
}
});
it("uses the pinned Gateway catalog runtime after active registry churn", async () => {
const previousNodesRuntime = gatewaySubagentState.nodes;
const listNodes = vi.fn(async () => ({ nodes: [] }));
@@ -14,8 +14,10 @@ import {
validateSessionsCatalogReadParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { getActivePluginSessionExtensionRegistry } from "../../plugins/runtime.js";
import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js";
import type {
SessionCatalogCreateTarget,
SessionCatalogListProviderParams,
SessionCatalogProvider,
} from "../../plugins/session-catalog.js";
import { bindPluginSessionConversation } from "../../plugins/session-conversation-binding.js";
@@ -29,6 +31,20 @@ import { assertValidParams } from "./validation.js";
const SESSION_CATALOG_SEARCH_MAX_UTF16_UNITS = 500;
function createSessionCatalogRequestNodeSnapshot(): NonNullable<
SessionCatalogListProviderParams["listNodes"]
> {
let request: ReturnType<NonNullable<SessionCatalogListProviderParams["listNodes"]>> | undefined;
return () => {
// Every provider sees the same promise so one catalog request cannot multiply the
// pairing-store scans performed by the Gateway node.list runtime.
request ??=
gatewaySubagentState.nodes?.list() ??
Promise.reject(new Error("Plugin node runtime is only available inside the Gateway."));
return request;
};
}
function normalizeSessionCatalogSearch(search: string | undefined): string | undefined {
const normalized = normalizeOptionalString(search);
return normalized
@@ -204,6 +220,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
cfg: config,
fallbackAgentId: resolvedAgent.agentId,
});
const listNodes = createSessionCatalogRequestNodeSnapshot();
const catalogList = await Promise.all(
selected.map(async (provider): Promise<SessionCatalog> => {
const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId);
@@ -236,6 +253,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
hostIds: request.hostIds,
...(request.cursors !== undefined ? { cursors: request.cursors } : {}),
sessionEntries: requestEntries.sessionEntries,
listNodes,
...(onHost ? { onHost } : {}),
});
return catalogResult(
+2
View File
@@ -18,6 +18,8 @@ export type SessionCatalogListProviderParams = {
cursors?: Record<string, string>;
/** Request-owned session entries. Providers must not retain this past `list`. */
sessionEntries?: SessionCatalogEntrySnapshot;
/** Lazily lists Gateway nodes once per catalog request. Providers must not retain this past `list`. */
listNodes?: () => ReturnType<PluginRuntime["nodes"]["list"]>;
/** Publishes completed hosts without waiting for slower machines in the same list. */
onHost?: (host: SessionCatalogHost) => void;
};