mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
perf(gateway): share one node.list snapshot across session catalog providers (#114646)
This commit is contained in:
committed by
GitHub
parent
1af330548a
commit
8f8e40dabe
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user