ACP: extract runtime backend registry

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 19:08:48 +00:00
parent 9320a332b6
commit 46b1ba0326
No known key found for this signature in database
5 changed files with 233 additions and 104 deletions

View File

@ -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 };

View File

@ -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<string, AcpRuntimeBackend>;
};
const ACP_RUNTIME_REGISTRY_STATE_KEY = Symbol.for("openclaw.acpRuntimeRegistryState");
function createAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState {
return {
backendsById: new Map<string, AcpRuntimeBackend>(),
};
}
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();
},
};

View File

@ -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[] };

View File

@ -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();
});
});

View File

@ -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<string, ExtensionHostAcpRuntimeBackend>;
};
const ACP_RUNTIME_REGISTRY_STATE_KEY = Symbol.for("openclaw.acpRuntimeRegistryState");
function createExtensionHostAcpRuntimeRegistryGlobalState(): ExtensionHostAcpRuntimeRegistryGlobalState {
return {
backendsById: new Map<string, ExtensionHostAcpRuntimeBackend>(),
};
}
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();
},
};