From 46b1ba0326ed7616bd51fe52dd9b0adc391bccde Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 19:08:48 +0000 Subject: [PATCH] ACP: extract runtime backend registry --- src/acp/control-plane/manager.types.ts | 6 +- src/acp/runtime/registry.ts | 113 +++------------- .../reply/commands-acp/diagnostics.ts | 9 +- .../acp-runtime-backend-registry.test.ts | 85 ++++++++++++ .../acp-runtime-backend-registry.ts | 124 ++++++++++++++++++ 5 files changed, 233 insertions(+), 104 deletions(-) create mode 100644 src/extension-host/acp-runtime-backend-registry.test.ts create mode 100644 src/extension-host/acp-runtime-backend-registry.ts diff --git a/src/acp/control-plane/manager.types.ts b/src/acp/control-plane/manager.types.ts index a2989c0d0f2..130ef07bccf 100644 --- a/src/acp/control-plane/manager.types.ts +++ b/src/acp/control-plane/manager.types.ts @@ -5,8 +5,8 @@ import type { SessionAcpMeta, SessionEntry, } from "../../config/sessions/types.js"; +import { requireExtensionHostAcpRuntimeBackend } from "../../extension-host/acp-runtime-backend-registry.js"; import type { AcpRuntimeError } from "../runtime/errors.js"; -import { requireAcpRuntimeBackend } from "../runtime/registry.js"; import { listAcpSessionEntries, readAcpSessionEntry, @@ -135,14 +135,14 @@ export type AcpSessionManagerDeps = { listAcpSessions: typeof listAcpSessionEntries; readSessionEntry: typeof readAcpSessionEntry; upsertSessionMeta: typeof upsertAcpSessionMeta; - requireRuntimeBackend: typeof requireAcpRuntimeBackend; + requireRuntimeBackend: typeof requireExtensionHostAcpRuntimeBackend; }; export const DEFAULT_DEPS: AcpSessionManagerDeps = { listAcpSessions: listAcpSessionEntries, readSessionEntry: readAcpSessionEntry, upsertSessionMeta: upsertAcpSessionMeta, - requireRuntimeBackend: requireAcpRuntimeBackend, + requireRuntimeBackend: requireExtensionHostAcpRuntimeBackend, }; export type { AcpSessionRuntimeOptions, SessionAcpMeta, SessionEntry }; diff --git a/src/acp/runtime/registry.ts b/src/acp/runtime/registry.ts index 4c0a3d73cd0..3879113cfc6 100644 --- a/src/acp/runtime/registry.ts +++ b/src/acp/runtime/registry.ts @@ -1,118 +1,35 @@ -import { AcpRuntimeError } from "./errors.js"; -import type { AcpRuntime } from "./types.js"; +import { + __testing as extensionHostTesting, + getExtensionHostAcpRuntimeBackend, + registerExtensionHostAcpRuntimeBackend, + requireExtensionHostAcpRuntimeBackend, + unregisterExtensionHostAcpRuntimeBackend, + type ExtensionHostAcpRuntimeBackend, +} from "../../extension-host/acp-runtime-backend-registry.js"; -export type AcpRuntimeBackend = { - id: string; - runtime: AcpRuntime; - healthy?: () => boolean; -}; - -type AcpRuntimeRegistryGlobalState = { - backendsById: Map; -}; - -const ACP_RUNTIME_REGISTRY_STATE_KEY = Symbol.for("openclaw.acpRuntimeRegistryState"); - -function createAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState { - return { - backendsById: new Map(), - }; -} - -function resolveAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState { - const runtimeGlobal = globalThis as typeof globalThis & { - [ACP_RUNTIME_REGISTRY_STATE_KEY]?: AcpRuntimeRegistryGlobalState; - }; - if (!runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY]) { - runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY] = createAcpRuntimeRegistryGlobalState(); - } - return runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY]; -} - -const ACP_BACKENDS_BY_ID = resolveAcpRuntimeRegistryGlobalState().backendsById; - -function normalizeBackendId(id: string | undefined): string { - return id?.trim().toLowerCase() || ""; -} - -function isBackendHealthy(backend: AcpRuntimeBackend): boolean { - if (!backend.healthy) { - return true; - } - try { - return backend.healthy(); - } catch { - return false; - } -} +export type AcpRuntimeBackend = ExtensionHostAcpRuntimeBackend; export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void { - const id = normalizeBackendId(backend.id); - if (!id) { - throw new Error("ACP runtime backend id is required"); - } - if (!backend.runtime) { - throw new Error(`ACP runtime backend "${id}" is missing runtime implementation`); - } - ACP_BACKENDS_BY_ID.set(id, { - ...backend, - id, - }); + registerExtensionHostAcpRuntimeBackend(backend); } export function unregisterAcpRuntimeBackend(id: string): void { - const normalized = normalizeBackendId(id); - if (!normalized) { - return; - } - ACP_BACKENDS_BY_ID.delete(normalized); + unregisterExtensionHostAcpRuntimeBackend(id); } export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null { - const normalized = normalizeBackendId(id); - if (normalized) { - return ACP_BACKENDS_BY_ID.get(normalized) ?? null; - } - if (ACP_BACKENDS_BY_ID.size === 0) { - return null; - } - for (const backend of ACP_BACKENDS_BY_ID.values()) { - if (isBackendHealthy(backend)) { - return backend; - } - } - return ACP_BACKENDS_BY_ID.values().next().value ?? null; + return getExtensionHostAcpRuntimeBackend(id); } export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend { - const normalized = normalizeBackendId(id); - const backend = getAcpRuntimeBackend(normalized || undefined); - if (!backend) { - throw new AcpRuntimeError( - "ACP_BACKEND_MISSING", - "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", - ); - } - if (!isBackendHealthy(backend)) { - throw new AcpRuntimeError( - "ACP_BACKEND_UNAVAILABLE", - "ACP runtime backend is currently unavailable. Try again in a moment.", - ); - } - if (normalized && backend.id !== normalized) { - throw new AcpRuntimeError( - "ACP_BACKEND_MISSING", - `ACP runtime backend "${normalized}" is not registered.`, - ); - } - return backend; + return requireExtensionHostAcpRuntimeBackend(id); } export const __testing = { resetAcpRuntimeBackendsForTests() { - ACP_BACKENDS_BY_ID.clear(); + extensionHostTesting.resetExtensionHostAcpRuntimeBackendsForTests(); }, getAcpRuntimeRegistryGlobalStateForTests() { - return resolveAcpRuntimeRegistryGlobalState(); + return extensionHostTesting.getExtensionHostAcpRuntimeRegistryGlobalStateForTests(); }, }; diff --git a/src/auto-reply/reply/commands-acp/diagnostics.ts b/src/auto-reply/reply/commands-acp/diagnostics.ts index d521ac7ae5f..9ff681bc794 100644 --- a/src/auto-reply/reply/commands-acp/diagnostics.ts +++ b/src/auto-reply/reply/commands-acp/diagnostics.ts @@ -1,10 +1,13 @@ import { getAcpSessionManager } from "../../../acp/control-plane/manager.js"; import { formatAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js"; import { toAcpRuntimeError } from "../../../acp/runtime/errors.js"; -import { getAcpRuntimeBackend, requireAcpRuntimeBackend } from "../../../acp/runtime/registry.js"; import { resolveSessionStorePathForAcp } from "../../../acp/runtime/session-meta.js"; import { loadSessionStore } from "../../../config/sessions.js"; import type { SessionEntry } from "../../../config/sessions/types.js"; +import { + getExtensionHostAcpRuntimeBackend, + requireExtensionHostAcpRuntimeBackend, +} from "../../../extension-host/acp-runtime-backend-registry.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; import { resolveAcpCommandBindingContext } from "./context.js"; @@ -29,7 +32,7 @@ export async function handleAcpDoctorAction( const backendId = resolveConfiguredAcpBackendId(params.cfg); const installHint = resolveAcpInstallCommandHint(params.cfg); - const registeredBackend = getAcpRuntimeBackend(backendId); + const registeredBackend = getExtensionHostAcpRuntimeBackend(backendId); const managerSnapshot = getAcpSessionManager().getObservabilitySnapshot(params.cfg); const lines = ["ACP doctor:", "-----", `configuredBackend: ${backendId}`]; lines.push(`activeRuntimeSessions: ${managerSnapshot.runtimeCache.activeSessions}`); @@ -81,7 +84,7 @@ export async function handleAcpDoctorAction( } try { - const backend = requireAcpRuntimeBackend(backendId); + const backend = requireExtensionHostAcpRuntimeBackend(backendId); const capabilities = backend.runtime.getCapabilities ? await backend.runtime.getCapabilities({}) : { controls: [] as string[], configOptionKeys: [] as string[] }; diff --git a/src/extension-host/acp-runtime-backend-registry.test.ts b/src/extension-host/acp-runtime-backend-registry.test.ts new file mode 100644 index 00000000000..a7d080c174e --- /dev/null +++ b/src/extension-host/acp-runtime-backend-registry.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AcpRuntimeError } from "../acp/runtime/errors.js"; +import type { AcpRuntime } from "../acp/runtime/types.js"; +import { + __testing, + getExtensionHostAcpRuntimeBackend, + registerExtensionHostAcpRuntimeBackend, + requireExtensionHostAcpRuntimeBackend, + unregisterExtensionHostAcpRuntimeBackend, +} from "./acp-runtime-backend-registry.js"; + +function createRuntimeStub(): AcpRuntime { + return { + ensureSession: vi.fn(async (input) => ({ + sessionKey: input.sessionKey, + backend: "stub", + runtimeSessionName: `${input.sessionKey}:runtime`, + })), + runTurn: vi.fn(async function* () {}), + cancel: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; +} + +describe("extension host acp runtime backend registry", () => { + beforeEach(() => { + __testing.resetExtensionHostAcpRuntimeBackendsForTests(); + }); + + it("registers and resolves backends by id", () => { + const runtime = createRuntimeStub(); + registerExtensionHostAcpRuntimeBackend({ id: "acpx", runtime }); + + const backend = getExtensionHostAcpRuntimeBackend("acpx"); + expect(backend?.id).toBe("acpx"); + expect(backend?.runtime).toBe(runtime); + }); + + it("prefers a healthy backend when resolving without explicit id", () => { + registerExtensionHostAcpRuntimeBackend({ + id: "unhealthy", + runtime: createRuntimeStub(), + healthy: () => false, + }); + registerExtensionHostAcpRuntimeBackend({ + id: "healthy", + runtime: createRuntimeStub(), + healthy: () => true, + }); + + expect(getExtensionHostAcpRuntimeBackend()?.id).toBe("healthy"); + }); + + it("throws typed errors for missing or unavailable backends", () => { + expect(() => requireExtensionHostAcpRuntimeBackend()).toThrowError(AcpRuntimeError); + + registerExtensionHostAcpRuntimeBackend({ + id: "acpx", + runtime: createRuntimeStub(), + healthy: () => false, + }); + + try { + requireExtensionHostAcpRuntimeBackend("acpx"); + throw new Error("expected requireExtensionHostAcpRuntimeBackend to throw"); + } catch (error) { + expect(error).toBeInstanceOf(AcpRuntimeError); + expect((error as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE"); + } + }); + + it("shares backend state globally for cross-loader access", () => { + const runtime = createRuntimeStub(); + const sharedState = __testing.getExtensionHostAcpRuntimeRegistryGlobalStateForTests(); + + sharedState.backendsById.set("acpx", { + id: "acpx", + runtime, + }); + + expect(getExtensionHostAcpRuntimeBackend("acpx")?.runtime).toBe(runtime); + unregisterExtensionHostAcpRuntimeBackend("acpx"); + expect(getExtensionHostAcpRuntimeBackend("acpx")).toBeNull(); + }); +}); diff --git a/src/extension-host/acp-runtime-backend-registry.ts b/src/extension-host/acp-runtime-backend-registry.ts new file mode 100644 index 00000000000..b9176b08be3 --- /dev/null +++ b/src/extension-host/acp-runtime-backend-registry.ts @@ -0,0 +1,124 @@ +import { AcpRuntimeError } from "../acp/runtime/errors.js"; +import type { AcpRuntime } from "../acp/runtime/types.js"; + +export type ExtensionHostAcpRuntimeBackend = { + id: string; + runtime: AcpRuntime; + healthy?: () => boolean; +}; + +type ExtensionHostAcpRuntimeRegistryGlobalState = { + backendsById: Map; +}; + +const ACP_RUNTIME_REGISTRY_STATE_KEY = Symbol.for("openclaw.acpRuntimeRegistryState"); + +function createExtensionHostAcpRuntimeRegistryGlobalState(): ExtensionHostAcpRuntimeRegistryGlobalState { + return { + backendsById: new Map(), + }; +} + +function resolveExtensionHostAcpRuntimeRegistryGlobalState(): ExtensionHostAcpRuntimeRegistryGlobalState { + const runtimeGlobal = globalThis as typeof globalThis & { + [ACP_RUNTIME_REGISTRY_STATE_KEY]?: ExtensionHostAcpRuntimeRegistryGlobalState; + }; + if (!runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY]) { + runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY] = + createExtensionHostAcpRuntimeRegistryGlobalState(); + } + return runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY]; +} + +const EXTENSION_HOST_ACP_BACKENDS_BY_ID = + resolveExtensionHostAcpRuntimeRegistryGlobalState().backendsById; + +function normalizeBackendId(id: string | undefined): string { + return id?.trim().toLowerCase() || ""; +} + +function isBackendHealthy(backend: ExtensionHostAcpRuntimeBackend): boolean { + if (!backend.healthy) { + return true; + } + try { + return backend.healthy(); + } catch { + return false; + } +} + +export function registerExtensionHostAcpRuntimeBackend( + backend: ExtensionHostAcpRuntimeBackend, +): void { + const id = normalizeBackendId(backend.id); + if (!id) { + throw new Error("ACP runtime backend id is required"); + } + if (!backend.runtime) { + throw new Error(`ACP runtime backend "${id}" is missing runtime implementation`); + } + EXTENSION_HOST_ACP_BACKENDS_BY_ID.set(id, { + ...backend, + id, + }); +} + +export function unregisterExtensionHostAcpRuntimeBackend(id: string): void { + const normalized = normalizeBackendId(id); + if (!normalized) { + return; + } + EXTENSION_HOST_ACP_BACKENDS_BY_ID.delete(normalized); +} + +export function getExtensionHostAcpRuntimeBackend( + id?: string, +): ExtensionHostAcpRuntimeBackend | null { + const normalized = normalizeBackendId(id); + if (normalized) { + return EXTENSION_HOST_ACP_BACKENDS_BY_ID.get(normalized) ?? null; + } + if (EXTENSION_HOST_ACP_BACKENDS_BY_ID.size === 0) { + return null; + } + for (const backend of EXTENSION_HOST_ACP_BACKENDS_BY_ID.values()) { + if (isBackendHealthy(backend)) { + return backend; + } + } + return EXTENSION_HOST_ACP_BACKENDS_BY_ID.values().next().value ?? null; +} + +export function requireExtensionHostAcpRuntimeBackend(id?: string): ExtensionHostAcpRuntimeBackend { + const normalized = normalizeBackendId(id); + const backend = getExtensionHostAcpRuntimeBackend(normalized || undefined); + if (!backend) { + throw new AcpRuntimeError( + "ACP_BACKEND_MISSING", + "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", + ); + } + if (!isBackendHealthy(backend)) { + throw new AcpRuntimeError( + "ACP_BACKEND_UNAVAILABLE", + "ACP runtime backend is currently unavailable. Try again in a moment.", + ); + } + if (normalized && backend.id !== normalized) { + throw new AcpRuntimeError( + "ACP_BACKEND_MISSING", + `ACP runtime backend "${normalized}" is not registered.`, + ); + } + return backend; +} + +export const __testing = { + resetExtensionHostAcpRuntimeBackendsForTests() { + EXTENSION_HOST_ACP_BACKENDS_BY_ID.clear(); + }, + getExtensionHostAcpRuntimeRegistryGlobalStateForTests() { + return resolveExtensionHostAcpRuntimeRegistryGlobalState(); + }, +};