ACP: extract runtime backend registry
This commit is contained in:
parent
9320a332b6
commit
46b1ba0326
@ -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 };
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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[] };
|
||||
|
||||
85
src/extension-host/acp-runtime-backend-registry.test.ts
Normal file
85
src/extension-host/acp-runtime-backend-registry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
124
src/extension-host/acp-runtime-backend-registry.ts
Normal file
124
src/extension-host/acp-runtime-backend-registry.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user