openhands / src /api /backend-registry /active-store.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3d700dd verified
Raw History Blame Contribute Delete
5.87 kB
import { getBackendHealthEntry } from "./health-store";
import {
readStoredActiveBackend,
readStoredBackends,
writeStoredActiveBackend,
writeStoredBackends,
} from "./storage";
import type { Backend, BackendSelection, ResolvedActiveBackend } from "./types";
import {
currentLocationSearch,
readBackendSelectionFromUrl,
} from "./url-selection";
type Listener = () => void;
interface Snapshot {
backends: Backend[];
selection: BackendSelection | null;
active: ResolvedActiveBackend;
}
export const NO_BACKEND_ID = "no-backend";
/**
* Sentinel returned when the registry has no usable backend. It must never be
* persisted, and callers must check `isNoBackend()` before interpreting fields
* like `kind`, `host`, or `apiKey`.
*/
export const NO_BACKEND: Backend = {
id: NO_BACKEND_ID,
name: "No Backend Available",
host: "",
apiKey: "",
kind: "local",
};
export function isNoBackend(backend: Backend): boolean {
return backend.id === NO_BACKEND_ID;
}
/**
* Choose an active backend when there is no valid explicit selection (fresh
* start, or the selected backend was removed). Most of the GUI speaks the
* local agent-server protocol, so a cloud or dead-local backend at index 0
* would leave `getEffectiveLocalBackend()` null and make every local-protocol
* call throw "No backend is configured" even when a healthy local backend is
* registered further down the list. Consult the synchronous, persisted health
* store and prefer a healthy local backend; fall back to any local backend so
* the effective-local resolver still resolves, then to the prior deterministic
* `backends[0]` behavior for the registry-has-no-local case.
*/
function pickFallbackBackend(backends: Backend[]): Backend {
const healthyLocalBackend = backends.find(
(backend) =>
backend.kind === "local" &&
getBackendHealthEntry(backend.id)?.disabled !== true,
);
if (healthyLocalBackend) return healthyLocalBackend;
const localBackend = backends.find((backend) => backend.kind === "local");
return localBackend ?? backends[0] ?? NO_BACKEND;
}
function computeSnapshot(
backends: Backend[],
selection: BackendSelection | null,
): Snapshot {
let activeBackend: Backend | null = null;
let activeOrgId: string | null = null;
if (selection) {
const found = backends.find((b) => b.id === selection.backendId);
if (found) {
activeBackend = found;
activeOrgId = selection.orgId ?? null;
}
// If the selection points at a removed backend, fall through to
// the unselected case below; we also drop the orgId since it only
// makes sense in the context of a specific cloud backend.
}
// @spec BM-003 — Fallback on active backend removal
if (!activeBackend) {
activeBackend = pickFallbackBackend(backends);
activeOrgId = null;
}
return {
backends,
selection,
active: { backend: activeBackend, orgId: activeOrgId },
};
}
/**
* Resolve the selection this tab boots with. A backend pinned in the URL wins
* over stored state so a link opened in a new tab (cmd/ctrl-click, middle
* click, "Open in new tab") lands on the backend that owns the linked
* conversation, even when the new tab starts with an empty `sessionStorage`
* and would otherwise adopt the `localStorage` fallback. The honoured
* selection is persisted so later in-tab navigation — which drops the query
* parameters — keeps the same backend.
*/
function readInitialSelection(backends: Backend[]): BackendSelection | null {
const fromUrl = readBackendSelectionFromUrl(
backends,
currentLocationSearch(),
);
if (fromUrl) {
writeStoredActiveBackend(fromUrl);
return fromUrl;
}
return readStoredActiveBackend();
}
const initialBackends = readStoredBackends();
let snapshot: Snapshot = computeSnapshot(
initialBackends,
readInitialSelection(initialBackends),
);
const listeners = new Set<Listener>();
function notify(): void {
listeners.forEach((listener) => listener());
}
export function getActiveBackend(): ResolvedActiveBackend {
return snapshot.active;
}
/**
* Pick the backend to use for *local agent-server protocol* calls.
*
* Most of the GUI's services (settings reads/writes, conversation CRUD,
* skills/MCP/secrets, etc.) speak the local agent-server's protocol —
* they would fail against a cloud host. Only the active backend is eligible:
* a cloud selection must not borrow another registered local backend.
*/
export function getEffectiveLocalBackend(): Backend | null {
const active = snapshot.active.backend;
if (active.kind === "local" && !isNoBackend(active)) return active;
return null;
}
export function getRegisteredBackends(): Backend[] {
return snapshot.backends;
}
export function getActiveSelection(): BackendSelection | null {
return snapshot.selection;
}
export function getSnapshot(): Snapshot {
return snapshot;
}
export function setActiveSelection(selection: BackendSelection | null): void {
writeStoredActiveBackend(selection);
snapshot = computeSnapshot(snapshot.backends, selection);
notify();
}
export function setRegisteredBackends(backends: Backend[]): void {
writeStoredBackends(backends);
let nextSelection = snapshot.selection;
if (
nextSelection &&
!backends.some((b) => b.id === nextSelection!.backendId)
) {
nextSelection = null;
writeStoredActiveBackend(null);
}
snapshot = computeSnapshot(backends, nextSelection);
notify();
}
export function subscribeActiveBackend(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Test-only: re-read storage and clear listeners. */
export function __resetActiveStoreForTests(): void {
const backends = readStoredBackends();
snapshot = computeSnapshot(backends, readInitialSelection(backends));
listeners.clear();
}