mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
better connection reliability
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { parseIntentBody, executeGatewayIntent } from "@/lib/controlplane/intent-route";
|
||||
import {
|
||||
parseIntentBody,
|
||||
executeGatewayIntent,
|
||||
LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS,
|
||||
} from "@/lib/controlplane/intent-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -18,5 +22,7 @@ export async function POST(request: Request) {
|
||||
return executeGatewayIntent("agent.wait", {
|
||||
runId,
|
||||
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
|
||||
}, {
|
||||
timeoutMs: typeof timeoutMs === "number" ? timeoutMs : LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
|
||||
import {
|
||||
executeGatewayIntent,
|
||||
LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS,
|
||||
parseIntentBody,
|
||||
} from "@/lib/controlplane/intent-route";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -18,5 +22,7 @@ export async function POST(request: Request) {
|
||||
return await executeGatewayIntent("cron.run", {
|
||||
id,
|
||||
mode: "force",
|
||||
}, {
|
||||
timeoutMs: LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { deriveRuntimeFreshness } from "@/lib/controlplane/degraded-read";
|
||||
import { peekControlPlaneRuntime } from "@/lib/controlplane/runtime";
|
||||
import { applyStudioSettingsPatch } from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
applyStudioSettingsPatch({ gatewayAutoStart: false });
|
||||
const controlPlane = peekControlPlaneRuntime();
|
||||
if (!controlPlane) {
|
||||
const summary = {
|
||||
|
||||
+41
-11
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
|
||||
import { type StudioSettingsPatch } from "@/lib/studio/settings";
|
||||
import { defaultStudioInstallContext } from "@/lib/studio/install-context";
|
||||
import {
|
||||
getControlPlaneRuntime,
|
||||
isStudioDomainApiModeEnabled,
|
||||
peekControlPlaneRuntime,
|
||||
} from "@/lib/controlplane/runtime";
|
||||
@@ -45,11 +46,15 @@ const gatewaySettingsChanged = (
|
||||
return left.url !== right.url || left.token !== right.token;
|
||||
};
|
||||
|
||||
const hasGatewayConfiguration = (settings: ReturnType<typeof loadStudioSettings>) => {
|
||||
const gateway = normalizeGatewaySettings(settings);
|
||||
return Boolean(gateway.url && gateway.token);
|
||||
};
|
||||
|
||||
const reconnectRuntimeForGatewaySettingsChange = async (
|
||||
previous: ReturnType<typeof loadStudioSettings>,
|
||||
next: ReturnType<typeof loadStudioSettings>
|
||||
): Promise<RuntimeReconnectMetadata | null> => {
|
||||
if (!gatewaySettingsChanged(previous, next)) return null;
|
||||
if (!isStudioDomainApiModeEnabled()) {
|
||||
return {
|
||||
attempted: false,
|
||||
@@ -57,20 +62,42 @@ const reconnectRuntimeForGatewaySettingsChange = async (
|
||||
reason: "domain_api_mode_disabled",
|
||||
};
|
||||
}
|
||||
const runtime = peekControlPlaneRuntime();
|
||||
if (!runtime) {
|
||||
return {
|
||||
attempted: false,
|
||||
restarted: false,
|
||||
reason: "runtime_not_initialized",
|
||||
};
|
||||
}
|
||||
const runtime = peekControlPlaneRuntime() ?? getControlPlaneRuntime();
|
||||
const previousStatus = runtime.connectionStatus();
|
||||
if (previousStatus === "stopped") {
|
||||
if (!hasGatewayConfiguration(next)) {
|
||||
return {
|
||||
attempted: false,
|
||||
restarted: false,
|
||||
reason: "gateway_not_configured",
|
||||
previousStatus,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await runtime.ensureStarted({ force: true });
|
||||
return {
|
||||
attempted: true,
|
||||
restarted: true,
|
||||
previousStatus,
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "controlplane_reconnect_failed";
|
||||
console.error("Failed to reconnect control-plane runtime after gateway settings update.", error);
|
||||
return {
|
||||
attempted: true,
|
||||
restarted: false,
|
||||
previousStatus,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!gatewaySettingsChanged(previous, next)) return null;
|
||||
if (!hasGatewayConfiguration(next)) {
|
||||
return {
|
||||
attempted: false,
|
||||
restarted: false,
|
||||
reason: "runtime_stopped",
|
||||
reason: "gateway_not_configured",
|
||||
previousStatus,
|
||||
};
|
||||
}
|
||||
@@ -135,7 +162,10 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
||||
}
|
||||
const previousSettings = loadStudioSettings();
|
||||
const nextSettings = applyStudioSettingsPatch(body);
|
||||
const nextSettings = applyStudioSettingsPatch({
|
||||
...body,
|
||||
gatewayAutoStart: true,
|
||||
});
|
||||
const runtimeReconnect = await reconnectRuntimeForGatewaySettingsChange(
|
||||
previousSettings,
|
||||
nextSettings
|
||||
|
||||
+32
-29
@@ -230,6 +230,7 @@ const AgentStudioPage = () => {
|
||||
testResult,
|
||||
saving: gatewaySaving,
|
||||
testing: gatewayTesting,
|
||||
disconnecting: gatewayDisconnecting,
|
||||
saveSettings,
|
||||
testConnection,
|
||||
disconnect,
|
||||
@@ -838,12 +839,7 @@ const AgentStudioPage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const {
|
||||
loadSummarySnapshot,
|
||||
loadAgentHistory,
|
||||
loadMoreAgentHistory,
|
||||
clearHistoryInFlight,
|
||||
} = useRuntimeSyncController({
|
||||
const { loadAgentHistory, loadMoreAgentHistory, clearHistoryInFlight } = useRuntimeSyncController({
|
||||
status: coreStatus,
|
||||
gatewayUrl,
|
||||
agents,
|
||||
@@ -1302,7 +1298,6 @@ const AgentStudioPage = () => {
|
||||
},
|
||||
onRuntimeStatus: (event) => {
|
||||
applyRuntimeStatusEvent(event);
|
||||
void loadSummarySnapshot();
|
||||
},
|
||||
resumeKey: runtimeStreamResumeKey ?? undefined,
|
||||
});
|
||||
@@ -1427,6 +1422,7 @@ const AgentStudioPage = () => {
|
||||
testResult={testResult}
|
||||
saving={gatewaySaving}
|
||||
testing={gatewayTesting}
|
||||
disconnecting={gatewayDisconnecting}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onTokenChange={setToken}
|
||||
onUseLocalDefaults={useLocalGatewayDefaults}
|
||||
@@ -1471,28 +1467,35 @@ const AgentStudioPage = () => {
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 px-3 pb-3 pt-2 sm:px-4 sm:pb-4 sm:pt-3 md:px-5 md:pb-5 md:pt-3">
|
||||
{connectionPanelVisible ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-12 z-[140] flex justify-center px-3 sm:px-4 md:px-5">
|
||||
<div className="glass-panel pointer-events-auto w-full max-w-4xl !bg-card px-4 py-4 sm:px-6 sm:py-6">
|
||||
<ConnectionPanel
|
||||
savedGatewayUrl={gatewayUrl}
|
||||
draftGatewayUrl={draftGatewayUrl}
|
||||
token={token}
|
||||
hasStoredToken={hasStoredToken}
|
||||
localGatewayDefaultsHasToken={localGatewayDefaultsHasToken}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
status={gatewayStatus}
|
||||
statusReason={statusReason}
|
||||
error={gatewayError}
|
||||
testResult={testResult}
|
||||
saving={gatewaySaving}
|
||||
testing={gatewayTesting}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onTokenChange={setToken}
|
||||
onSaveSettings={() => void saveSettings()}
|
||||
onTestConnection={() => void testConnection()}
|
||||
onDisconnect={() => void disconnect()}
|
||||
onClose={() => setShowConnectionPanel(false)}
|
||||
/>
|
||||
<div className="fixed inset-0 z-[140]" data-testid="gateway-connection-overlay">
|
||||
<div
|
||||
className="absolute inset-0 bg-transparent"
|
||||
onClick={() => setShowConnectionPanel(false)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-12 flex justify-center px-3 sm:px-4 md:px-5">
|
||||
<div className="glass-panel pointer-events-auto w-full max-w-4xl !bg-card px-4 py-4 sm:px-6 sm:py-6">
|
||||
<ConnectionPanel
|
||||
savedGatewayUrl={gatewayUrl}
|
||||
draftGatewayUrl={draftGatewayUrl}
|
||||
token={token}
|
||||
hasStoredToken={hasStoredToken}
|
||||
localGatewayDefaultsHasToken={localGatewayDefaultsHasToken}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
status={gatewayStatus}
|
||||
statusReason={statusReason}
|
||||
error={gatewayError}
|
||||
testResult={testResult}
|
||||
saving={gatewaySaving}
|
||||
testing={gatewayTesting}
|
||||
disconnecting={gatewayDisconnecting}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onTokenChange={setToken}
|
||||
onSaveSettings={() => void saveSettings()}
|
||||
onTestConnection={() => void testConnection()}
|
||||
onDisconnect={() => void disconnect()}
|
||||
onClose={() => setShowConnectionPanel(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -624,6 +624,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
liveAssistantCharCount,
|
||||
liveThinkingCharCount,
|
||||
runStartedAt,
|
||||
scrollToBottomOnOpenKey,
|
||||
scrollToBottomNextOutputRef,
|
||||
pendingExecApprovals,
|
||||
onResolveExecApproval,
|
||||
@@ -650,6 +651,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
liveAssistantCharCount: number;
|
||||
liveThinkingCharCount: number;
|
||||
runStartedAt: number | null;
|
||||
scrollToBottomOnOpenKey: string;
|
||||
scrollToBottomNextOutputRef: MutableRefObject<boolean>;
|
||||
pendingExecApprovals: PendingExecApproval[];
|
||||
onResolveExecApproval?: (id: string, decision: ExecApprovalDecision) => void;
|
||||
@@ -710,6 +712,11 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
});
|
||||
}, [scrollChatToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
setPinned(true);
|
||||
scheduleScrollToBottom();
|
||||
}, [scheduleScrollToBottom, scrollToBottomOnOpenKey, setPinned]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePinnedFromScroll();
|
||||
}, [updatePinnedFromScroll]);
|
||||
@@ -1472,6 +1479,7 @@ export const AgentChatPanel = ({
|
||||
const allowThinking = selectedModel?.reasoning !== false;
|
||||
|
||||
const avatarSeed = agent.avatarSeed ?? agent.agentId;
|
||||
const scrollToBottomOnOpenKey = `${agent.agentId}:${agent.sessionKey}:${agent.sessionEpoch ?? 0}`;
|
||||
const emptyStateTitle = useMemo(
|
||||
() => resolveEmptyChatIntroMessage(agent.agentId, agent.sessionEpoch),
|
||||
[agent.agentId, agent.sessionEpoch]
|
||||
@@ -1730,6 +1738,7 @@ export const AgentChatPanel = ({
|
||||
liveAssistantCharCount={liveAssistantText.length}
|
||||
liveThinkingCharCount={liveThinkingText.length}
|
||||
runStartedAt={agent.runStartedAt}
|
||||
scrollToBottomOnOpenKey={scrollToBottomOnOpenKey}
|
||||
scrollToBottomNextOutputRef={scrollToBottomNextOutputRef}
|
||||
pendingExecApprovals={pendingExecApprovals}
|
||||
onResolveExecApproval={onResolveExecApproval}
|
||||
|
||||
@@ -20,6 +20,7 @@ type ConnectionPanelProps = {
|
||||
| null;
|
||||
saving: boolean;
|
||||
testing: boolean;
|
||||
disconnecting: boolean;
|
||||
onGatewayUrlChange: (value: string) => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onSaveSettings: () => void;
|
||||
@@ -41,6 +42,7 @@ export const ConnectionPanel = ({
|
||||
testResult,
|
||||
saving,
|
||||
testing,
|
||||
disconnecting,
|
||||
onGatewayUrlChange,
|
||||
onTokenChange,
|
||||
onSaveSettings,
|
||||
@@ -48,7 +50,7 @@ export const ConnectionPanel = ({
|
||||
onDisconnect,
|
||||
onClose,
|
||||
}: ConnectionPanelProps) => {
|
||||
const actionBusy = saving || testing;
|
||||
const actionBusy = saving || testing || disconnecting;
|
||||
const tokenHelper = hasStoredToken
|
||||
? "Stored token available on this Studio host. Leave blank to keep it."
|
||||
: localGatewayDefaultsHasToken
|
||||
@@ -88,7 +90,7 @@ export const ConnectionPanel = ({
|
||||
onClick={onDisconnect}
|
||||
disabled={actionBusy}
|
||||
>
|
||||
Disconnect
|
||||
{disconnecting ? "Disconnecting…" : "Disconnect"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ type GatewayConnectScreenProps = {
|
||||
| null;
|
||||
saving: boolean;
|
||||
testing: boolean;
|
||||
disconnecting: boolean;
|
||||
onGatewayUrlChange: (value: string) => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onUseLocalDefaults: () => void;
|
||||
@@ -64,6 +65,7 @@ export const GatewayConnectScreen = ({
|
||||
testResult,
|
||||
saving,
|
||||
testing,
|
||||
disconnecting,
|
||||
onGatewayUrlChange,
|
||||
onTokenChange,
|
||||
onUseLocalDefaults,
|
||||
@@ -162,9 +164,10 @@ export const GatewayConnectScreen = ({
|
||||
}
|
||||
return "When Studio and OpenClaw share a host, the upstream should usually stay on localhost.";
|
||||
}, [selectedScenario, statusReason]);
|
||||
const actionBusy = saving || testing;
|
||||
const actionBusy = saving || testing || disconnecting;
|
||||
const saveLabel = saving ? "Saving…" : "Save settings";
|
||||
const testLabel = testing ? "Testing…" : "Test connection";
|
||||
const disconnectLabel = disconnecting ? "Disconnecting…" : "Disconnect";
|
||||
const statusDotClass =
|
||||
status === "connected"
|
||||
? "ui-dot-status-connected"
|
||||
@@ -335,7 +338,7 @@ export const GatewayConnectScreen = ({
|
||||
onClick={() => void onDisconnect()}
|
||||
disabled={actionBusy}
|
||||
>
|
||||
Disconnect
|
||||
{disconnectLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-err
|
||||
import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap";
|
||||
import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime";
|
||||
|
||||
export const LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS = 600_000;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
@@ -56,14 +58,18 @@ export const parseIntentBody = async (request: Request): Promise<Record<string,
|
||||
|
||||
export const executeGatewayIntent = async <T>(
|
||||
method: string,
|
||||
params: unknown
|
||||
params: unknown,
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<NextResponse> => {
|
||||
const runtimeOrError = await ensureDomainIntentRuntime();
|
||||
if (runtimeOrError instanceof Response) {
|
||||
return runtimeOrError as NextResponse;
|
||||
}
|
||||
try {
|
||||
const payload = await runtimeOrError.callGateway<T>(method, params);
|
||||
const payload =
|
||||
typeof options?.timeoutMs === "number"
|
||||
? await runtimeOrError.callGateway<T>(method, params, options)
|
||||
: await runtimeOrError.callGateway<T>(method, params);
|
||||
return NextResponse.json({ ok: true, payload });
|
||||
} catch (err) {
|
||||
if (err instanceof ControlPlaneGatewayError) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 8_000;
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
||||
const MAX_RECONNECT_DELAY_MS = 15_000;
|
||||
const CONNECT_PROTOCOL = 3;
|
||||
@@ -73,6 +73,13 @@ export class ControlPlaneGatewayError extends Error {
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
const resolveRequestTimeoutMs = (timeoutMs?: number): number => {
|
||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
||||
return Math.max(1, Math.floor(timeoutMs));
|
||||
}
|
||||
return DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
};
|
||||
|
||||
const resolveOriginForUpstream = (upstreamUrl: string): string => {
|
||||
const url = new URL(upstreamUrl);
|
||||
const proto = url.protocol === "wss:" ? "https:" : "http:";
|
||||
@@ -190,7 +197,11 @@ export class OpenClawGatewayAdapter {
|
||||
this.updateStatus("stopped", null);
|
||||
}
|
||||
|
||||
async request<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||
async request<T = unknown>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<T> {
|
||||
const normalizedMethod = method.trim();
|
||||
if (!normalizedMethod) {
|
||||
throw new Error("Gateway method is required.");
|
||||
@@ -208,13 +219,16 @@ export class OpenClawGatewayAdapter {
|
||||
|
||||
const id = String(this.nextRequestNumber++);
|
||||
const frame = { type: "req", id, method: normalizedMethod, params };
|
||||
const timeoutMs = resolveRequestTimeoutMs(options?.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Gateway request timed out for method: ${normalizedMethod}`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
reject(
|
||||
new Error(`Gateway request timed out after ${timeoutMs}ms for method: ${normalizedMethod}`)
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify(frame), (err) => {
|
||||
if (!err) return;
|
||||
@@ -227,11 +241,11 @@ export class OpenClawGatewayAdapter {
|
||||
} catch (error) {
|
||||
if (this.isOperatorScopeMissingError(error)) {
|
||||
await this.switchToLegacyControlUiProfile();
|
||||
return this.request<T>(method, params);
|
||||
return this.request<T>(method, params, options);
|
||||
}
|
||||
if (this.legacyProfileSwitchPromise && this.isTransientProfileSwitchError(error)) {
|
||||
await this.legacyProfileSwitchPromise;
|
||||
return this.request<T>(method, params);
|
||||
return this.request<T>(method, params, options);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -8,16 +8,22 @@ import {
|
||||
SQLiteControlPlaneProjectionStore,
|
||||
type BackfillAgentOutboxResult,
|
||||
} from "@/lib/controlplane/projection-store";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
type ControlPlaneRuntimeOptions = {
|
||||
adapterOptions?: OpenClawAdapterOptions;
|
||||
dbPath?: string;
|
||||
};
|
||||
|
||||
type EnsureStartedOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export class ControlPlaneRuntime {
|
||||
private readonly store: SQLiteControlPlaneProjectionStore;
|
||||
private readonly adapter: OpenClawGatewayAdapter;
|
||||
private readonly eventSubscribers = new Set<(entry: ControlPlaneOutboxEntry) => void>();
|
||||
private autoStartEnabled = true;
|
||||
|
||||
constructor(options?: ControlPlaneRuntimeOptions) {
|
||||
this.store = new SQLiteControlPlaneProjectionStore(options?.dbPath);
|
||||
@@ -27,11 +33,20 @@ export class ControlPlaneRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
async ensureStarted(): Promise<void> {
|
||||
async ensureStarted(options: EnsureStartedOptions = {}): Promise<void> {
|
||||
if (options.force) {
|
||||
this.autoStartEnabled = true;
|
||||
} else if (loadStudioSettings().gatewayAutoStart === false) {
|
||||
this.autoStartEnabled = false;
|
||||
return;
|
||||
} else {
|
||||
this.autoStartEnabled = true;
|
||||
}
|
||||
await this.adapter.start();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.autoStartEnabled = false;
|
||||
await this.adapter.stop();
|
||||
}
|
||||
|
||||
@@ -40,7 +55,11 @@ export class ControlPlaneRuntime {
|
||||
}
|
||||
|
||||
async reconnectForGatewaySettingsChange(): Promise<void> {
|
||||
if (this.adapter.getStatus() === "stopped") return;
|
||||
this.autoStartEnabled = true;
|
||||
if (this.adapter.getStatus() === "stopped") {
|
||||
await this.adapter.start();
|
||||
return;
|
||||
}
|
||||
await this.adapter.stop();
|
||||
await this.adapter.start();
|
||||
}
|
||||
@@ -72,8 +91,12 @@ export class ControlPlaneRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
async callGateway<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||
return await this.adapter.request<T>(method, params);
|
||||
async callGateway<T = unknown>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<T> {
|
||||
return await this.adapter.request<T>(method, params, options);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
||||
@@ -20,12 +20,14 @@ export type StudioFocusedPreference = {
|
||||
export type StudioSettings = {
|
||||
version: 1;
|
||||
gateway: StudioGatewaySettings | null;
|
||||
gatewayAutoStart: boolean;
|
||||
focused: Record<string, StudioFocusedPreference>;
|
||||
avatars: Record<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
export type StudioSettingsPatch = {
|
||||
gateway?: StudioGatewaySettingsPatch | null;
|
||||
gatewayAutoStart?: boolean | null;
|
||||
focused?: Record<string, Partial<StudioFocusedPreference> | null>;
|
||||
avatars?: Record<string, Record<string, string | null> | null>;
|
||||
};
|
||||
@@ -180,6 +182,7 @@ const normalizeAvatars = (value: unknown): Record<string, Record<string, string>
|
||||
export const defaultStudioSettings = (): StudioSettings => ({
|
||||
version: SETTINGS_VERSION,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
});
|
||||
@@ -187,11 +190,13 @@ export const defaultStudioSettings = (): StudioSettings => ({
|
||||
export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
|
||||
if (!isRecord(raw)) return defaultStudioSettings();
|
||||
const gateway = normalizeGatewaySettings(raw.gateway);
|
||||
const gatewayAutoStart = typeof raw.gatewayAutoStart === "boolean" ? raw.gatewayAutoStart : true;
|
||||
const focused = normalizeFocused(raw.focused);
|
||||
const avatars = normalizeAvatars(raw.avatars);
|
||||
return {
|
||||
version: SETTINGS_VERSION,
|
||||
gateway,
|
||||
gatewayAutoStart,
|
||||
focused,
|
||||
avatars,
|
||||
};
|
||||
@@ -202,6 +207,8 @@ export const mergeStudioSettings = (
|
||||
patch: StudioSettingsPatch
|
||||
): StudioSettings => {
|
||||
const nextGateway = mergeGatewaySettings(current.gateway, patch.gateway);
|
||||
const nextGatewayAutoStart =
|
||||
typeof patch.gatewayAutoStart === "boolean" ? patch.gatewayAutoStart : current.gatewayAutoStart;
|
||||
const nextFocused = { ...current.focused };
|
||||
const nextAvatars = { ...current.avatars };
|
||||
if (patch.focused) {
|
||||
@@ -246,6 +253,7 @@ export const mergeStudioSettings = (
|
||||
return {
|
||||
version: SETTINGS_VERSION,
|
||||
gateway: nextGateway ?? null,
|
||||
gatewayAutoStart: nextGatewayAutoStart,
|
||||
focused: nextFocused,
|
||||
avatars: nextAvatars,
|
||||
};
|
||||
|
||||
@@ -92,6 +92,7 @@ type StudioGatewaySettingsState = {
|
||||
| null;
|
||||
saving: boolean;
|
||||
testing: boolean;
|
||||
disconnecting: boolean;
|
||||
saveSettings: () => Promise<boolean>;
|
||||
testConnection: () => Promise<boolean>;
|
||||
disconnect: () => Promise<void>;
|
||||
@@ -148,6 +149,7 @@ export const useStudioGatewaySettings = (
|
||||
} | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const manualDisconnectRef = useRef(false);
|
||||
const didAutoConnectRef = useRef(false);
|
||||
@@ -264,6 +266,9 @@ export const useStudioGatewaySettings = (
|
||||
}, [applySettingsEnvelope, settingsCoordinator]);
|
||||
|
||||
const saveSettings = useCallback(async () => {
|
||||
if (disconnecting) {
|
||||
return false;
|
||||
}
|
||||
const trimmedGatewayUrl = draftGatewayUrl.trim();
|
||||
const trimmedToken = token.trim();
|
||||
const canUseExistingToken = hasStoredToken || localGatewayDefaultsHasToken;
|
||||
@@ -314,6 +319,7 @@ export const useStudioGatewaySettings = (
|
||||
}
|
||||
}, [
|
||||
applySettingsEnvelope,
|
||||
disconnecting,
|
||||
draftGatewayUrl,
|
||||
hasStoredToken,
|
||||
localGatewayDefaultsHasToken,
|
||||
@@ -323,6 +329,9 @@ export const useStudioGatewaySettings = (
|
||||
]);
|
||||
|
||||
const testConnection = useCallback(async () => {
|
||||
if (disconnecting) {
|
||||
return false;
|
||||
}
|
||||
const trimmedGatewayUrl = draftGatewayUrl.trim();
|
||||
if (!trimmedGatewayUrl) {
|
||||
setActionError("Gateway URL is required.");
|
||||
@@ -363,14 +372,14 @@ export const useStudioGatewaySettings = (
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [draftGatewayUrl, token]);
|
||||
}, [disconnecting, draftGatewayUrl, token]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
if (disconnecting) return;
|
||||
manualDisconnectRef.current = true;
|
||||
setDisconnecting(true);
|
||||
setActionError(null);
|
||||
setTestResult(null);
|
||||
setStatus("disconnected");
|
||||
setStatusReason(null);
|
||||
setConnectionError(null);
|
||||
try {
|
||||
const summary = await fetchJson<RuntimeSummaryEnvelope>("/api/runtime/disconnect", {
|
||||
@@ -382,8 +391,10 @@ export const useStudioGatewaySettings = (
|
||||
setStatus("error");
|
||||
setStatusReason(message);
|
||||
setActionError(message);
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
}, [applyRuntimeSummary]);
|
||||
}, [applyRuntimeSummary, disconnecting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
@@ -451,6 +462,7 @@ export const useStudioGatewaySettings = (
|
||||
testResult,
|
||||
saving,
|
||||
testing,
|
||||
disconnecting,
|
||||
saveSettings,
|
||||
testConnection,
|
||||
disconnect,
|
||||
@@ -475,6 +487,7 @@ export const useStudioGatewaySettings = (
|
||||
localGatewayDefaultsHasToken,
|
||||
saveSettings,
|
||||
saving,
|
||||
disconnecting,
|
||||
setGatewayUrl,
|
||||
setToken,
|
||||
status,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
const createAgent = (overrides: Partial<AgentState> = {}): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
@@ -38,6 +38,7 @@ const createAgent = (): AgentState => ({
|
||||
thinkingLevel: null,
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("AgentChatPanel scrolling", () => {
|
||||
@@ -56,14 +57,14 @@ describe("AgentChatPanel scrolling", () => {
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...agent, outputLines: ["> hello", "first answer"] },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
@@ -81,14 +82,14 @@ describe("AgentChatPanel scrolling", () => {
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...agent, outputLines: ["> hello", "first answer", "second answer"] },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
@@ -107,6 +108,69 @@ describe("AgentChatPanel scrolling", () => {
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scrolls to the bottom when a different agent is opened", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
(Element.prototype as unknown as { scrollIntoView: unknown }).scrollIntoView = scrollIntoView;
|
||||
|
||||
const { rerender } = render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent({
|
||||
outputLines: ["> hello", "first answer"],
|
||||
}),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const scrollEl = screen.getByTestId("agent-chat-scroll");
|
||||
Object.defineProperty(scrollEl, "clientHeight", { value: 100, configurable: true });
|
||||
Object.defineProperty(scrollEl, "scrollHeight", { value: 1000, configurable: true });
|
||||
Object.defineProperty(scrollEl, "scrollTop", { value: 0, writable: true, configurable: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
rerender(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent({
|
||||
agentId: "agent-2",
|
||||
name: "Agent Two",
|
||||
sessionKey: "agent:agent-2:studio:test-session",
|
||||
outputLines: ["> another", "reply"],
|
||||
}),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows history truncation banner only when scrolled to top", () => {
|
||||
const agent = createAgent();
|
||||
render(
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("hydrateAgentFleetFromGateway", () => {
|
||||
const settings: StudioSettings = {
|
||||
version: 1,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {
|
||||
"ws://localhost:18789": {
|
||||
@@ -246,7 +247,13 @@ describe("hydrateAgentFleetFromGateway", () => {
|
||||
client: { call },
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
cachedConfigSnapshot: null,
|
||||
loadStudioSettings: async () => ({ version: 1, gateway: null, focused: {}, avatars: {} }),
|
||||
loadStudioSettings: async () => ({
|
||||
version: 1,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
}),
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
|
||||
@@ -288,7 +295,13 @@ describe("hydrateAgentFleetFromGateway", () => {
|
||||
client: { call },
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
cachedConfigSnapshot: null,
|
||||
loadStudioSettings: async () => ({ version: 1, gateway: null, focused: {}, avatars: {} }),
|
||||
loadStudioSettings: async () => ({
|
||||
version: 1,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
}),
|
||||
isDisconnectLikeError: () => false,
|
||||
logError,
|
||||
});
|
||||
@@ -330,7 +343,13 @@ describe("hydrateAgentFleetFromGateway", () => {
|
||||
client: { call },
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
cachedConfigSnapshot: null,
|
||||
loadStudioSettings: async () => ({ version: 1, gateway: null, focused: {}, avatars: {} }),
|
||||
loadStudioSettings: async () => ({
|
||||
version: 1,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
}),
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("deriveHydrateAgentFleetResult", () => {
|
||||
const settings: StudioSettings = {
|
||||
version: 1,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {
|
||||
"ws://localhost:18789": {
|
||||
|
||||
@@ -16,6 +16,7 @@ const buildProps = () => ({
|
||||
testResult: null,
|
||||
saving: false,
|
||||
testing: false,
|
||||
disconnecting: false,
|
||||
onGatewayUrlChange: vi.fn(),
|
||||
onTokenChange: vi.fn(),
|
||||
onSaveSettings: vi.fn(),
|
||||
@@ -71,4 +72,18 @@ describe("ConnectionPanel close control", () => {
|
||||
expect(connected).toHaveAttribute("data-status", "connected");
|
||||
expect(connected).toHaveClass("ui-badge-status-connected");
|
||||
});
|
||||
|
||||
it("disables connection actions while disconnecting", () => {
|
||||
render(
|
||||
createElement(ConnectionPanel, {
|
||||
...buildProps(),
|
||||
status: "connected",
|
||||
disconnecting: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Save settings" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Test connection" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Disconnecting…" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,23 @@ describe("control-plane runtime", () => {
|
||||
|
||||
const makeRuntimeDbPath = () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "controlplane-runtime-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, "openclaw-studio", "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
gateway: { url: "ws://127.0.0.1:0", token: "placeholder" },
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
return path.join(tempDir, "runtime.db");
|
||||
};
|
||||
|
||||
@@ -29,6 +46,7 @@ describe("control-plane runtime", () => {
|
||||
await runtime.disconnect();
|
||||
runtime.close();
|
||||
resetControlPlaneRuntimeForTests();
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
delete process.env.STUDIO_DOMAIN_API_MODE;
|
||||
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
|
||||
if (tempDir) {
|
||||
@@ -95,6 +113,88 @@ describe("control-plane runtime", () => {
|
||||
await closeWebSocketServer(upstream);
|
||||
});
|
||||
|
||||
it("keeps a manual disconnect stopped until a forced restart", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
let connectionCount = 0;
|
||||
|
||||
upstream.on("connection", (ws) => {
|
||||
connectionCount += 1;
|
||||
ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: "n1" } }));
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw ?? ""));
|
||||
if (parsed?.method !== "connect") return;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3 },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const runtime = new ControlPlaneRuntime({
|
||||
dbPath: makeRuntimeDbPath(),
|
||||
adapterOptions: {
|
||||
loadSettings: () => ({ url: upstreamUrl, token: "upstream-token" }),
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.ensureStarted();
|
||||
expect(connectionCount).toBe(1);
|
||||
|
||||
await runtime.disconnect();
|
||||
expect(runtime.snapshot().status).toBe("stopped");
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir!, "openclaw-studio", "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
gateway: { url: upstreamUrl, token: "upstream-token" },
|
||||
gatewayAutoStart: false,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
await runtime.ensureStarted();
|
||||
expect(runtime.snapshot().status).toBe("stopped");
|
||||
expect(connectionCount).toBe(1);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir!, "openclaw-studio", "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
gateway: { url: upstreamUrl, token: "upstream-token" },
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
await runtime.ensureStarted({ force: true });
|
||||
expect(runtime.snapshot().status).toBe("connected");
|
||||
expect(connectionCount).toBe(2);
|
||||
|
||||
await runtime.disconnect();
|
||||
await closeWebSocketServer(upstream);
|
||||
});
|
||||
|
||||
it("enforces gateway method allowlist", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
|
||||
@@ -50,9 +50,11 @@ describe("intent routes", () => {
|
||||
callGateway,
|
||||
}),
|
||||
}));
|
||||
const { LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS } = await import("@/lib/controlplane/intent-route");
|
||||
const resetRoute = await import("@/app/api/intents/sessions-reset/route");
|
||||
const sessionSettingsRoute = await import("@/app/api/intents/session-settings-sync/route");
|
||||
const waitRoute = await import("@/app/api/intents/agent-wait/route");
|
||||
const cronRunRoute = await import("@/app/api/intents/cron-run/route");
|
||||
|
||||
const resetResponse = await resetRoute.POST(
|
||||
new Request("http://localhost/api/intents/sessions-reset", {
|
||||
@@ -78,16 +80,33 @@ describe("intent routes", () => {
|
||||
body: JSON.stringify({ runId: "run-1", timeoutMs: 3000 }),
|
||||
})
|
||||
);
|
||||
const cronRunResponse = await cronRunRoute.POST(
|
||||
new Request("http://localhost/api/intents/cron-run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: "job-1" }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(resetResponse.status).toBe(200);
|
||||
expect(sessionSettingsResponse.status).toBe(200);
|
||||
expect(waitResponse.status).toBe(200);
|
||||
expect(cronRunResponse.status).toBe(200);
|
||||
expect(callGateway).toHaveBeenCalledWith("sessions.reset", { key: "agent:agent-1:main" });
|
||||
expect(callGateway).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "agent:agent-1:main",
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
expect(callGateway).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 3000 });
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
"agent.wait",
|
||||
{ runId: "run-1", timeoutMs: 3000 },
|
||||
{ timeoutMs: 3000 }
|
||||
);
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
"cron.run",
|
||||
{ id: "job-1", mode: "force" },
|
||||
{ timeoutMs: LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS }
|
||||
);
|
||||
});
|
||||
|
||||
it("agent-create route composes workspace from config path and forwards to agents.create", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
|
||||
@@ -27,6 +27,78 @@ describe("OpenClawGatewayAdapter", () => {
|
||||
await closeWebSocketServer(upstream);
|
||||
upstream = null;
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("honors per-request timeout overrides", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
class TimeoutSocket extends EventEmitter {
|
||||
readyState: number = WebSocket.OPEN;
|
||||
|
||||
close() {
|
||||
if (this.readyState === WebSocket.CLOSED) return;
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.close();
|
||||
}
|
||||
|
||||
send(raw: string, callback?: (err?: Error) => void) {
|
||||
const parsed = JSON.parse(raw) as { id?: string; method?: string };
|
||||
callback?.();
|
||||
if (parsed.method !== "connect" || !parsed.id) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
this.emit(
|
||||
"message",
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3 },
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const socket = new TimeoutSocket();
|
||||
const adapter = new OpenClawGatewayAdapter({
|
||||
loadSettings: () => ({ url: "ws://127.0.0.1:9", token: "tkn" }),
|
||||
createWebSocket: () => socket as unknown as WebSocket,
|
||||
});
|
||||
|
||||
queueMicrotask(() => {
|
||||
socket.emit("message", JSON.stringify({ type: "event", event: "connect.challenge", payload: {} }));
|
||||
});
|
||||
|
||||
await adapter.start();
|
||||
|
||||
let settled = false;
|
||||
const request = adapter.request("cron.run", { id: "job-1" }, { timeoutMs: 25_000 });
|
||||
void request.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
}
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(24_999);
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(request).rejects.toThrow(
|
||||
"Gateway request timed out after 25000ms for method: cron.run"
|
||||
);
|
||||
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
it("rejects in-flight requests immediately when the socket closes", async () => {
|
||||
|
||||
@@ -196,6 +196,7 @@ describe("studioBootstrapOperation", () => {
|
||||
loadStudioSettings: async () => ({
|
||||
version: 1,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {
|
||||
"https://gateway.test": {
|
||||
mode: "focused",
|
||||
@@ -230,6 +231,7 @@ describe("studioBootstrapOperation", () => {
|
||||
loadStudioSettings: async () => ({
|
||||
version: 1,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {
|
||||
"https://gateway.test": {
|
||||
mode: "focused",
|
||||
|
||||
@@ -161,6 +161,7 @@ describe("studioBootstrapWorkflow", () => {
|
||||
const settings: StudioSettings = {
|
||||
version: 1,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {
|
||||
"https://gateway.test": {
|
||||
mode: "focused",
|
||||
@@ -198,6 +199,7 @@ describe("studioBootstrapWorkflow", () => {
|
||||
const settings: StudioSettings = {
|
||||
version: 1,
|
||||
gateway: null,
|
||||
gatewayAutoStart: true,
|
||||
focused: {
|
||||
"https://gateway.test": {
|
||||
mode: "focused",
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("studio settings route", () => {
|
||||
expect(body.installContext).toBeTruthy();
|
||||
expect(typeof body.domainApiModeEnabled).toBe("boolean");
|
||||
expect(body.settings?.version).toBe(1);
|
||||
expect(body.settings?.gatewayAutoStart).toBe(true);
|
||||
});
|
||||
|
||||
it("GET always reports domain mode enabled", async () => {
|
||||
@@ -130,8 +131,12 @@ describe("studio settings route", () => {
|
||||
const settingsPath = path.join(tempDir, "openclaw-studio", "settings.json");
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
const raw = fs.readFileSync(settingsPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { gateway?: { url?: string; token?: string } | null };
|
||||
const parsed = JSON.parse(raw) as {
|
||||
gateway?: { url?: string; token?: string } | null;
|
||||
gatewayAutoStart?: boolean;
|
||||
};
|
||||
expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
|
||||
expect(parsed.gatewayAutoStart).toBe(true);
|
||||
});
|
||||
|
||||
it("PUT url-only gateway patch preserves existing token", async () => {
|
||||
@@ -169,10 +174,11 @@ describe("studio settings route", () => {
|
||||
|
||||
const persisted = JSON.parse(
|
||||
fs.readFileSync(path.join(tempDir, "openclaw-studio", "settings.json"), "utf8")
|
||||
) as { gateway?: { url?: string; token?: string } };
|
||||
) as { gateway?: { url?: string; token?: string }; gatewayAutoStart?: boolean };
|
||||
expect(persisted.gateway).toEqual({
|
||||
url: "ws://new.example:18789",
|
||||
token: "secret-token",
|
||||
});
|
||||
expect(persisted.gatewayAutoStart).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
||||
|
||||
describe("studio settings route reconnect behavior", () => {
|
||||
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OPENCLAW_STATE_DIR = priorStateDir;
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("restarts a manually disconnected runtime when settings are saved without changing the gateway", async () => {
|
||||
tempDir = makeTempDir("studio-settings-reconnect-stopped-runtime");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, "openclaw-studio", "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
gateway: { url: "ws://remote.example:18789", token: "secret-token" },
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const ensureStarted = vi.fn(async () => {});
|
||||
const reconnectForGatewaySettingsChange = vi.fn(async () => {});
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
peekControlPlaneRuntime: () => ({
|
||||
connectionStatus: () => "stopped",
|
||||
ensureStarted,
|
||||
reconnectForGatewaySettingsChange,
|
||||
}),
|
||||
getControlPlaneRuntime: () => ({
|
||||
connectionStatus: () => "stopped",
|
||||
ensureStarted,
|
||||
reconnectForGatewaySettingsChange,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PUT } = await import("@/app/api/studio/route");
|
||||
const response = await PUT({
|
||||
json: async () => ({
|
||||
gateway: { url: "ws://remote.example:18789" },
|
||||
}),
|
||||
} as unknown as Request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(ensureStarted).toHaveBeenCalledWith({ force: true });
|
||||
expect(reconnectForGatewaySettingsChange).not.toHaveBeenCalled();
|
||||
|
||||
const body = (await response.json()) as {
|
||||
runtimeReconnect?: {
|
||||
attempted?: unknown;
|
||||
restarted?: unknown;
|
||||
previousStatus?: unknown;
|
||||
} | null;
|
||||
};
|
||||
expect(body.runtimeReconnect).toEqual({
|
||||
attempted: true,
|
||||
restarted: true,
|
||||
previousStatus: "stopped",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists manual disconnect across requests", async () => {
|
||||
tempDir = makeTempDir("studio-settings-disconnect-pause");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, "openclaw-studio", "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
gateway: { url: "ws://remote.example:18789", token: "secret-token" },
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
peekControlPlaneRuntime: () => null,
|
||||
}));
|
||||
|
||||
const { POST } = await import("@/app/api/runtime/disconnect/route");
|
||||
const response = await POST();
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const persisted = JSON.parse(
|
||||
fs.readFileSync(path.join(tempDir, "openclaw-studio", "settings.json"), "utf8")
|
||||
) as { gatewayAutoStart?: boolean };
|
||||
expect(persisted.gatewayAutoStart).toBe(false);
|
||||
});
|
||||
|
||||
it("creates and starts a runtime when save settings is the first reconnect request", async () => {
|
||||
tempDir = makeTempDir("studio-settings-start-missing-runtime");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, "openclaw-studio", "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
gateway: { url: "ws://remote.example:18789", token: "secret-token" },
|
||||
gatewayAutoStart: false,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const ensureStarted = vi.fn(async () => {});
|
||||
vi.doMock("@/lib/controlplane/runtime", () => ({
|
||||
isStudioDomainApiModeEnabled: () => true,
|
||||
peekControlPlaneRuntime: () => null,
|
||||
getControlPlaneRuntime: () => ({
|
||||
connectionStatus: () => "stopped",
|
||||
ensureStarted,
|
||||
reconnectForGatewaySettingsChange: vi.fn(async () => {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PUT } = await import("@/app/api/studio/route");
|
||||
const response = await PUT({
|
||||
json: async () => ({
|
||||
gateway: { url: "ws://remote.example:18789" },
|
||||
}),
|
||||
} as unknown as Request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(ensureStarted).toHaveBeenCalledWith({ force: true });
|
||||
|
||||
const persisted = JSON.parse(
|
||||
fs.readFileSync(path.join(tempDir, "openclaw-studio", "settings.json"), "utf8")
|
||||
) as { gatewayAutoStart?: boolean };
|
||||
expect(persisted.gatewayAutoStart).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createElement, useEffect } from "react";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchJson } from "@/lib/http";
|
||||
import type { StudioSettingsResponse } from "@/lib/studio/coordinator";
|
||||
import { defaultStudioInstallContext } from "@/lib/studio/install-context";
|
||||
import { useStudioGatewaySettings } from "@/lib/studio/useStudioGatewaySettings";
|
||||
|
||||
vi.mock("@/lib/http", () => ({
|
||||
fetchJson: vi.fn(),
|
||||
}));
|
||||
|
||||
type HookValue = ReturnType<typeof useStudioGatewaySettings>;
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error?: unknown) => void;
|
||||
};
|
||||
|
||||
const createDeferred = <T,>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error?: unknown) => void;
|
||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve;
|
||||
reject = nextReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const buildEnvelope = (): StudioSettingsResponse => ({
|
||||
settings: {
|
||||
version: 1,
|
||||
gateway: {
|
||||
url: "wss://remote.example:8443",
|
||||
token: "",
|
||||
},
|
||||
gatewayAutoStart: true,
|
||||
focused: {},
|
||||
avatars: {},
|
||||
},
|
||||
localGatewayDefaults: null,
|
||||
localGatewayDefaultsMeta: {
|
||||
hasToken: false,
|
||||
},
|
||||
gatewayMeta: {
|
||||
hasStoredToken: true,
|
||||
},
|
||||
installContext: defaultStudioInstallContext(),
|
||||
domainApiModeEnabled: true,
|
||||
});
|
||||
|
||||
const renderHook = () => {
|
||||
const coordinator = {
|
||||
loadSettings: vi.fn(async () => buildEnvelope().settings),
|
||||
loadSettingsEnvelope: vi.fn(async () => buildEnvelope()),
|
||||
flushPending: vi.fn(async () => {}),
|
||||
};
|
||||
const valueRef: { current: HookValue | null } = { current: null };
|
||||
|
||||
const Probe = () => {
|
||||
const value = useStudioGatewaySettings(coordinator);
|
||||
useEffect(() => {
|
||||
valueRef.current = value;
|
||||
}, [value]);
|
||||
return createElement("div", { "data-testid": "probe" }, "ok");
|
||||
};
|
||||
|
||||
const rendered = render(createElement(Probe));
|
||||
|
||||
return {
|
||||
coordinator,
|
||||
getValue: () => {
|
||||
if (!valueRef.current) {
|
||||
throw new Error("hook value unavailable");
|
||||
}
|
||||
return valueRef.current;
|
||||
},
|
||||
unmount: () => rendered.unmount(),
|
||||
};
|
||||
};
|
||||
|
||||
describe("useStudioGatewaySettings", () => {
|
||||
const mockedFetchJson = vi.mocked(fetchJson);
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetchJson.mockReset();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
enabled: true,
|
||||
summary: {
|
||||
status: "connected",
|
||||
reason: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("blocks save while disconnect is still in flight", async () => {
|
||||
const disconnectDeferred = createDeferred<{
|
||||
enabled: boolean;
|
||||
summary: {
|
||||
status: string;
|
||||
reason: string | null;
|
||||
asOf?: string | null;
|
||||
outboxHead?: number;
|
||||
};
|
||||
}>();
|
||||
mockedFetchJson.mockImplementation(async (input) => {
|
||||
if (input === "/api/runtime/disconnect") {
|
||||
return await disconnectDeferred.promise;
|
||||
}
|
||||
throw new Error(`Unexpected fetchJson call: ${String(input)}`);
|
||||
});
|
||||
|
||||
const ctx = renderHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctx.getValue().status).toBe("connected");
|
||||
});
|
||||
|
||||
let disconnectPromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
disconnectPromise = ctx.getValue().disconnect();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctx.getValue().disconnecting).toBe(true);
|
||||
});
|
||||
expect(ctx.getValue().status).toBe("connected");
|
||||
|
||||
let saveResult = true;
|
||||
await act(async () => {
|
||||
saveResult = await ctx.getValue().saveSettings();
|
||||
});
|
||||
|
||||
expect(saveResult).toBe(false);
|
||||
expect(ctx.coordinator.flushPending).not.toHaveBeenCalled();
|
||||
expect(mockedFetchJson).not.toHaveBeenCalledWith(
|
||||
"/api/studio",
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
disconnectDeferred.resolve({
|
||||
enabled: true,
|
||||
summary: {
|
||||
status: "stopped",
|
||||
reason: null,
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await disconnectPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctx.getValue().status).toBe("disconnected");
|
||||
});
|
||||
expect(ctx.getValue().disconnecting).toBe(false);
|
||||
ctx.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user