feat: formalize runtime profile resolution

This commit is contained in:
gsknnft
2026-04-04 13:06:49 -04:00
parent b573646f2d
commit a959d31728
3 changed files with 166 additions and 74 deletions
+31 -74
View File
@@ -14,6 +14,10 @@ import type {
StudioSettingsPatch,
StudioSettingsPublic,
} from "@/lib/studio/settings";
import {
resolveDefaultStudioGatewayProfile,
resolveStudioGatewayProfiles,
} from "@/lib/studio/settings";
import type {
StudioSettingsLoadOptions,
StudioSettingsResponse,
@@ -115,7 +119,6 @@ const parseConnectFailedCloseReason = (
const DEFAULT_UPSTREAM_GATEWAY_URL =
process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789";
const DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770";
const INITIAL_AUTO_CONNECT_DELAY_MS = 900;
const INITIAL_CONNECT_RETRY_DELAY_MS = 1_200;
const OPENCLAW_CONTROL_UI_CLIENT_ID = "openclaw-control-ui";
@@ -162,25 +165,6 @@ export const resolveInitialGatewayConnectAttemptCount = (
}
};
const resolveDefaultGatewayProfile = (
adapterType: StudioGatewayAdapterType,
localDefaults: StudioGatewaySettings | null
): { url: string; token: string } => {
switch (adapterType) {
case "custom":
return { url: DEFAULT_CUSTOM_RUNTIME_URL, token: "" };
case "demo":
case "hermes":
return { url: "ws://localhost:18789", token: "" };
case "openclaw":
default:
return {
url: localDefaults?.url || DEFAULT_UPSTREAM_GATEWAY_URL,
token: localDefaults?.token ?? "",
};
}
};
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
if (!value || typeof value !== "object") return null;
const raw = value as {
@@ -734,7 +718,7 @@ export const useGatewayConnection = (
(value: StudioGatewayAdapterType) => {
setSelectedAdapterTypeState(value);
const profile =
adapterProfiles[value] ?? resolveDefaultGatewayProfile(value, localGatewayDefaults);
adapterProfiles[value] ?? resolveDefaultStudioGatewayProfile(value, localGatewayDefaults);
setGatewayUrl(profile.url);
setToken(profile.token);
setError(null);
@@ -780,72 +764,45 @@ export const useGatewayConnection = (
: "openclaw",
}
: null;
// When the user has no saved gateway URL, prefer the runtime
// localGatewayDefaults (from openclaw.json, CLAW3D_GATEWAY_URL,
// or detected local Hermes/demo adapter ports)
// over the build-time NEXT_PUBLIC_GATEWAY_URL which may be stale
// or empty if the operator forgot to rebuild after .env changes.
const hasSavedUrl = Boolean(gateway?.url?.trim());
const savedAdapterType =
hasSavedUrl && gateway && "adapterType" in gateway && typeof gateway.adapterType === "string"
? ((gateway.adapterType === "demo" ||
const gatewaySettings: StudioGatewaySettings | null = gateway
? {
url: typeof gateway.url === "string" ? gateway.url.trim() : "",
token: "",
adapterType:
gateway.adapterType === "demo" ||
gateway.adapterType === "hermes" ||
gateway.adapterType === "openclaw" ||
gateway.adapterType === "custom"
? gateway.adapterType
: "openclaw") as StudioGatewayAdapterType)
: null;
const nextAdapterType =
savedAdapterType ??
lastKnownGood?.adapterType ??
normalizedDefaults?.adapterType ??
"openclaw";
const lastKnownGoodForSelectedAdapter =
lastKnownGood?.adapterType === nextAdapterType ? lastKnownGood : null;
const resolvedUrl = hasSavedUrl
? gateway!.url
: lastKnownGoodForSelectedAdapter?.url ||
normalizedDefaults?.url ||
DEFAULT_UPSTREAM_GATEWAY_URL;
const baseProfiles = {
...(gateway?.profiles
? normalizeGatewayProfilesPublic(gateway.profiles)
: undefined),
...(normalizedDefaults?.profiles ?? {}),
};
const mergedProfiles = {
...baseProfiles,
...(hasSavedUrl
? {
[nextAdapterType]: {
url: resolvedUrl,
token:
gateway && "token" in gateway && typeof gateway.token === "string"
? gateway.token
: "",
},
}
: {}),
};
const selectedProfile = (
mergedProfiles[nextAdapterType] ??
lastKnownGoodForSelectedAdapter ??
resolveDefaultGatewayProfile(nextAdapterType, normalizedDefaults)
);
? gateway.adapterType
: "openclaw",
...(gateway?.profiles
? { profiles: normalizeGatewayProfilesPublic(gateway.profiles) }
: {}),
...(lastKnownGood ? { lastKnownGood } : {}),
}
: null;
const resolvedGatewayProfiles = resolveStudioGatewayProfiles({
gateway: gatewaySettings,
localDefaults: normalizedDefaults,
});
const nextAdapterType = resolvedGatewayProfiles.selectedAdapterType;
const selectedProfile =
resolvedGatewayProfiles.activeProfile ??
resolveDefaultStudioGatewayProfile(nextAdapterType, normalizedDefaults);
const nextGatewayUrl = selectedProfile.url;
const nextToken = selectedProfile.token;
loadedGatewaySettings.current = {
gatewayUrl: nextGatewayUrl.trim(),
token: nextToken,
adapterType: nextAdapterType,
profiles: mergedProfiles,
hasLastKnownGood: Boolean(lastKnownGoodForSelectedAdapter?.url),
profiles: resolvedGatewayProfiles.profiles,
hasLastKnownGood: Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url),
};
setGatewayUrl(nextGatewayUrl);
setToken(nextToken);
setSelectedAdapterTypeState(nextAdapterType);
setAdapterProfiles(mergedProfiles);
setHasLastKnownGoodState(Boolean(lastKnownGoodForSelectedAdapter?.url));
setAdapterProfiles(resolvedGatewayProfiles.profiles);
setHasLastKnownGoodState(Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url));
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
+92
View File
@@ -24,6 +24,7 @@ export type StudioGatewaySettings = {
};
export type StudioGatewayAdapterType = "openclaw" | "hermes" | "demo" | "custom";
export const STUDIO_GATEWAY_ADAPTER_TYPES = ["openclaw", "hermes", "demo", "custom"] as const;
export type StudioGatewayProfile = {
url: string;
@@ -74,6 +75,13 @@ export type StudioGatewayConnectionStatePatch = {
adapterType?: StudioGatewayAdapterType | null;
};
export type ResolvedStudioGatewayProfiles = {
selectedAdapterType: StudioGatewayAdapterType;
activeProfile: StudioGatewayProfile;
profiles: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>>;
lastKnownGoodForSelected: StudioGatewayConnectionState | null;
};
export type FocusFilter = "all" | "running" | "approvals";
export type StudioViewMode = "focused";
@@ -222,6 +230,9 @@ export type StudioSettingsPatch = {
};
const SETTINGS_VERSION = 1 as const;
const DEFAULT_OPENCLAW_GATEWAY_URL = "ws://localhost:18789";
const DEFAULT_LOCAL_ADAPTER_GATEWAY_URL = "ws://localhost:18789";
const DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770";
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
@@ -830,6 +841,87 @@ const normalizeGatewayAdapterType = (
return fallback;
};
export const resolveDefaultStudioGatewayProfile = (
adapterType: StudioGatewayAdapterType,
localDefaults: StudioGatewaySettings | null = null
): StudioGatewayProfile => {
const explicitProfile = localDefaults?.profiles?.[adapterType];
if (explicitProfile?.url) {
return {
url: explicitProfile.url,
token: explicitProfile.token,
};
}
if (localDefaults?.adapterType === adapterType && localDefaults.url.trim()) {
return {
url: localDefaults.url,
token: localDefaults.token,
};
}
switch (adapterType) {
case "custom":
return { url: DEFAULT_CUSTOM_RUNTIME_URL, token: "" };
case "hermes":
case "demo":
return { url: DEFAULT_LOCAL_ADAPTER_GATEWAY_URL, token: "" };
case "openclaw":
default:
return { url: DEFAULT_OPENCLAW_GATEWAY_URL, token: "" };
}
};
export const resolveStudioGatewayProfiles = ({
gateway,
localDefaults = null,
}: {
gateway: StudioGatewaySettings | null;
localDefaults?: StudioGatewaySettings | null;
}): ResolvedStudioGatewayProfiles => {
const selectedAdapterType =
gateway?.adapterType ??
gateway?.lastKnownGood?.adapterType ??
localDefaults?.adapterType ??
"openclaw";
const profiles: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {
...(localDefaults?.profiles ?? {}),
...(gateway?.profiles ?? {}),
};
if (gateway?.url?.trim()) {
profiles[selectedAdapterType] = {
url: gateway.url,
token: gateway.token,
};
}
const lastKnownGoodForSelected =
gateway?.lastKnownGood?.adapterType === selectedAdapterType ? gateway.lastKnownGood : null;
if (!profiles[selectedAdapterType] && lastKnownGoodForSelected?.url) {
profiles[selectedAdapterType] = {
url: lastKnownGoodForSelected.url,
token: lastKnownGoodForSelected.token,
};
}
for (const adapterType of STUDIO_GATEWAY_ADAPTER_TYPES) {
if (profiles[adapterType]?.url) continue;
profiles[adapterType] = resolveDefaultStudioGatewayProfile(adapterType, localDefaults);
}
return {
selectedAdapterType,
activeProfile:
profiles[selectedAdapterType] ??
resolveDefaultStudioGatewayProfile(selectedAdapterType, localDefaults),
profiles,
lastKnownGoodForSelected,
};
};
const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreference> => {
if (!isRecord(value)) return {};
const focused: Record<string, StudioFocusedPreference> = {};
+43
View File
@@ -4,6 +4,8 @@ import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
import {
mergeStudioSettings,
normalizeStudioSettings,
resolveDefaultStudioGatewayProfile,
resolveStudioGatewayProfiles,
} from "@/lib/studio/settings";
describe("studio settings normalization", () => {
@@ -300,4 +302,45 @@ describe("studio settings normalization", () => {
}),
);
});
it("resolves simultaneous runtime profiles without collapsing them to one active url", () => {
const resolved = resolveStudioGatewayProfiles({
gateway: normalizeStudioSettings({
gateway: {
url: "ws://localhost:28789",
token: "",
adapterType: "hermes",
profiles: {
openclaw: { url: "ws://localhost:18789", token: "open-token" },
demo: { url: "ws://localhost:38789", token: "" },
},
},
}).gateway,
localDefaults: null,
});
expect(resolved.selectedAdapterType).toBe("hermes");
expect(resolved.activeProfile).toEqual({
url: "ws://localhost:28789",
token: "",
});
expect(resolved.profiles).toEqual(
expect.objectContaining({
openclaw: { url: "ws://localhost:18789", token: "open-token" },
hermes: { url: "ws://localhost:28789", token: "" },
demo: { url: "ws://localhost:38789", token: "" },
}),
);
});
it("resolves adapter-specific defaults for dormant profiles", () => {
expect(resolveDefaultStudioGatewayProfile("custom", null)).toEqual({
url: "http://localhost:7770",
token: "",
});
expect(resolveDefaultStudioGatewayProfile("demo", null)).toEqual({
url: "ws://localhost:18789",
token: "",
});
});
});