Skip to content

Commit 8de63ca

Browse files
authored
refactor(gateway): split startup and runtime seams (#63975)
Merged via squash. Prepared head SHA: c6e47ef Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
1 parent 33ad806 commit 8de63ca

21 files changed

Lines changed: 2505 additions & 1446 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010
- macOS/Talk: add an experimental local MLX speech provider for Talk Mode, with explicit provider selection, local utterance playback, interruption handling, and system-voice fallback. (#63539) Thanks @ImLukeF.
1111
- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819.
1212
- QA/testing: add a `--runner multipass` lane for `openclaw qa suite` so repo-backed QA scenarios can run inside a disposable Linux VM and write back the usual report, summary, and VM logs. (#63426) Thanks @shakkernerd.
13+
- Gateway: split startup and runtime seams so gateway lifecycle sequencing, reload state, and shutdown behavior stay easier to maintain without changing observed behavior. (#63975) Thanks @gumadeiras.
1314

1415
### Fixes
1516

src/gateway/server-aux-handlers.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { OpenClawConfig } from "../config/config.js";
2+
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
3+
import { type PluginApprovalRequestPayload } from "../infra/plugin-approvals.js";
4+
import {
5+
resolveCommandSecretsFromActiveRuntimeSnapshot,
6+
type CommandSecretAssignment,
7+
} from "../secrets/runtime-command-secrets.js";
8+
import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime.js";
9+
import { createExecApprovalIosPushDelivery } from "./exec-approval-ios-push.js";
10+
import { ExecApprovalManager } from "./exec-approval-manager.js";
11+
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
12+
import { createPluginApprovalHandlers } from "./server-methods/plugin-approval.js";
13+
import { createSecretsHandlers } from "./server-methods/secrets.js";
14+
import {
15+
disconnectStaleSharedGatewayAuthClients,
16+
setCurrentSharedGatewaySessionGeneration,
17+
type SharedGatewayAuthClient,
18+
type SharedGatewaySessionGenerationState,
19+
} from "./server-shared-auth-generation.js";
20+
import type { ActivateRuntimeSecrets } from "./server-startup-config.js";
21+
22+
type GatewayAuxHandlerLogger = {
23+
warn?: (message: string) => void;
24+
error?: (message: string) => void;
25+
debug?: (message: string) => void;
26+
};
27+
28+
export function createGatewayAuxHandlers(params: {
29+
log: GatewayAuxHandlerLogger;
30+
activateRuntimeSecrets: ActivateRuntimeSecrets;
31+
sharedGatewaySessionGenerationState: SharedGatewaySessionGenerationState;
32+
resolveSharedGatewaySessionGenerationForConfig: (config: OpenClawConfig) => string | undefined;
33+
clients: Iterable<SharedGatewayAuthClient>;
34+
}) {
35+
const execApprovalManager = new ExecApprovalManager();
36+
const execApprovalForwarder = createExecApprovalForwarder();
37+
const execApprovalIosPushDelivery = createExecApprovalIosPushDelivery({ log: params.log });
38+
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, {
39+
forwarder: execApprovalForwarder,
40+
iosPushDelivery: execApprovalIosPushDelivery,
41+
});
42+
const pluginApprovalManager = new ExecApprovalManager<PluginApprovalRequestPayload>();
43+
const pluginApprovalHandlers = createPluginApprovalHandlers(pluginApprovalManager, {
44+
forwarder: execApprovalForwarder,
45+
});
46+
const secretsHandlers = createSecretsHandlers({
47+
reloadSecrets: async () => {
48+
const active = getActiveSecretsRuntimeSnapshot();
49+
if (!active) {
50+
throw new Error("Secrets runtime snapshot is not active.");
51+
}
52+
const previousSharedGatewaySessionGeneration =
53+
params.sharedGatewaySessionGenerationState.current;
54+
const prepared = await params.activateRuntimeSecrets(active.sourceConfig, {
55+
reason: "reload",
56+
activate: true,
57+
});
58+
const nextSharedGatewaySessionGeneration =
59+
params.resolveSharedGatewaySessionGenerationForConfig(prepared.config);
60+
setCurrentSharedGatewaySessionGeneration(
61+
params.sharedGatewaySessionGenerationState,
62+
nextSharedGatewaySessionGeneration,
63+
);
64+
if (previousSharedGatewaySessionGeneration !== nextSharedGatewaySessionGeneration) {
65+
disconnectStaleSharedGatewayAuthClients({
66+
clients: params.clients,
67+
expectedGeneration: nextSharedGatewaySessionGeneration,
68+
});
69+
}
70+
return { warningCount: prepared.warnings.length };
71+
},
72+
resolveSecrets: async ({ commandName, targetIds }) => {
73+
const { assignments, diagnostics, inactiveRefPaths } =
74+
resolveCommandSecretsFromActiveRuntimeSnapshot({
75+
commandName,
76+
targetIds: new Set(targetIds),
77+
});
78+
if (assignments.length === 0) {
79+
return { assignments: [] as CommandSecretAssignment[], diagnostics, inactiveRefPaths };
80+
}
81+
return { assignments, diagnostics, inactiveRefPaths };
82+
},
83+
});
84+
85+
return {
86+
execApprovalManager,
87+
pluginApprovalManager,
88+
extraHandlers: {
89+
...execApprovalHandlers,
90+
...pluginApprovalHandlers,
91+
...secretsHandlers,
92+
},
93+
};
94+
}

src/gateway/server-close.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,28 @@ const shutdownLog = createSubsystemLogger("gateway/shutdown");
1212
const WEBSOCKET_CLOSE_GRACE_MS = 1_000;
1313
const WEBSOCKET_CLOSE_FORCE_CONTINUE_MS = 250;
1414

15+
export async function runGatewayClosePrelude(params: {
16+
stopDiagnostics?: () => void;
17+
clearSkillsRefreshTimer?: () => void;
18+
skillsChangeUnsub?: () => void;
19+
disposeAuthRateLimiter?: () => void;
20+
disposeBrowserAuthRateLimiter: () => void;
21+
stopModelPricingRefresh?: () => void;
22+
stopChannelHealthMonitor?: () => void;
23+
clearSecretsRuntimeSnapshot?: () => void;
24+
closeMcpServer?: () => Promise<void>;
25+
}): Promise<void> {
26+
params.stopDiagnostics?.();
27+
params.clearSkillsRefreshTimer?.();
28+
params.skillsChangeUnsub?.();
29+
params.disposeAuthRateLimiter?.();
30+
params.disposeBrowserAuthRateLimiter();
31+
params.stopModelPricingRefresh?.();
32+
params.stopChannelHealthMonitor?.();
33+
params.clearSecretsRuntimeSnapshot?.();
34+
await params.closeMcpServer?.().catch(() => {});
35+
}
36+
1537
export function createGatewayCloseHandler(params: {
1638
bonjourStop: (() => Promise<void>) | null;
1739
tailscaleCleanup: (() => Promise<void>) | null;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import path from "node:path";
2+
import {
3+
ensureControlUiAssetsBuilt,
4+
isPackageProvenControlUiRootSync,
5+
resolveControlUiRootOverrideSync,
6+
resolveControlUiRootSync,
7+
} from "../infra/control-ui-assets.js";
8+
import type { RuntimeEnv } from "../runtime.js";
9+
import type { ControlUiRootState } from "./control-ui.js";
10+
11+
export async function resolveGatewayControlUiRootState(params: {
12+
controlUiRootOverride?: string;
13+
controlUiEnabled: boolean;
14+
gatewayRuntime: RuntimeEnv;
15+
log: { warn: (message: string) => void };
16+
}): Promise<ControlUiRootState | undefined> {
17+
if (params.controlUiRootOverride) {
18+
const resolvedOverride = resolveControlUiRootOverrideSync(params.controlUiRootOverride);
19+
const resolvedOverridePath = path.resolve(params.controlUiRootOverride);
20+
if (!resolvedOverride) {
21+
params.log.warn(`gateway: controlUi.root not found at ${resolvedOverridePath}`);
22+
}
23+
return resolvedOverride
24+
? { kind: "resolved", path: resolvedOverride }
25+
: { kind: "invalid", path: resolvedOverridePath };
26+
}
27+
28+
if (!params.controlUiEnabled) {
29+
return undefined;
30+
}
31+
32+
const resolveRoot = () =>
33+
resolveControlUiRootSync({
34+
moduleUrl: import.meta.url,
35+
argv1: process.argv[1],
36+
cwd: process.cwd(),
37+
});
38+
39+
let resolvedRoot = resolveRoot();
40+
if (!resolvedRoot) {
41+
const ensureResult = await ensureControlUiAssetsBuilt(params.gatewayRuntime);
42+
if (!ensureResult.ok && ensureResult.message) {
43+
params.log.warn(`gateway: ${ensureResult.message}`);
44+
}
45+
resolvedRoot = resolveRoot();
46+
}
47+
48+
if (!resolvedRoot) {
49+
return { kind: "missing" };
50+
}
51+
52+
return {
53+
kind: isPackageProvenControlUiRootSync(resolvedRoot, {
54+
moduleUrl: import.meta.url,
55+
argv1: process.argv[1],
56+
cwd: process.cwd(),
57+
})
58+
? "bundled"
59+
: "resolved",
60+
path: resolvedRoot,
61+
};
62+
}

src/gateway/server-live-state.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { PluginServicesHandle } from "../plugins/services.js";
2+
import type { HooksConfigResolved } from "./hooks.js";
3+
import type { GatewayCronState } from "./server-cron.js";
4+
import type { HookClientIpConfig } from "./server-http.js";
5+
import {
6+
createGatewayServerMutableState,
7+
type GatewayServerMutableState,
8+
} from "./server-runtime-handles.js";
9+
10+
export type GatewayServerLiveState = GatewayServerMutableState & {
11+
hooksConfig: HooksConfigResolved | null;
12+
hookClientIpConfig: HookClientIpConfig;
13+
cronState: GatewayCronState;
14+
pluginServices: PluginServicesHandle | null;
15+
gatewayMethods: string[];
16+
};
17+
18+
export function createGatewayServerLiveState(params: {
19+
hooksConfig: HooksConfigResolved | null;
20+
hookClientIpConfig: HookClientIpConfig;
21+
cronState: GatewayCronState;
22+
gatewayMethods: string[];
23+
}): GatewayServerLiveState {
24+
return {
25+
...createGatewayServerMutableState(),
26+
hooksConfig: params.hooksConfig,
27+
hookClientIpConfig: params.hookClientIpConfig,
28+
cronState: params.cronState,
29+
pluginServices: null,
30+
gatewayMethods: params.gatewayMethods,
31+
};
32+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { NodeRegistry } from "./node-registry.js";
2+
import {
3+
createSessionEventSubscriberRegistry,
4+
createSessionMessageSubscriberRegistry,
5+
} from "./server-chat.js";
6+
import { safeParseJson } from "./server-methods/nodes.helpers.js";
7+
import { hasConnectedMobileNode } from "./server-mobile-nodes.js";
8+
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
9+
10+
export function createGatewayNodeSessionRuntime(params: {
11+
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
12+
}) {
13+
const nodeRegistry = new NodeRegistry();
14+
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
15+
const nodeSubscriptions = createNodeSubscriptionManager();
16+
const sessionEventSubscribers = createSessionEventSubscriberRegistry();
17+
const sessionMessageSubscribers = createSessionMessageSubscriberRegistry();
18+
const nodeSendEvent = (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => {
19+
const payload = safeParseJson(opts.payloadJSON ?? null);
20+
nodeRegistry.sendEvent(opts.nodeId, opts.event, payload);
21+
};
22+
const nodeSendToSession = (sessionKey: string, event: string, payload: unknown) =>
23+
nodeSubscriptions.sendToSession(sessionKey, event, payload, nodeSendEvent);
24+
const nodeSendToAllSubscribed = (event: string, payload: unknown) =>
25+
nodeSubscriptions.sendToAllSubscribed(event, payload, nodeSendEvent);
26+
const broadcastVoiceWakeChanged = (triggers: string[]) => {
27+
params.broadcast("voicewake.changed", { triggers }, { dropIfSlow: true });
28+
};
29+
const hasMobileNodeConnected = () => hasConnectedMobileNode(nodeRegistry);
30+
31+
return {
32+
nodeRegistry,
33+
nodePresenceTimers,
34+
sessionEventSubscribers,
35+
sessionMessageSubscribers,
36+
nodeSendToSession,
37+
nodeSendToAllSubscribed,
38+
nodeSubscribe: nodeSubscriptions.subscribe,
39+
nodeUnsubscribe: nodeSubscriptions.unsubscribe,
40+
nodeUnsubscribeAll: nodeSubscriptions.unsubscribeAll,
41+
broadcastVoiceWakeChanged,
42+
hasMobileNodeConnected,
43+
};
44+
}

0 commit comments

Comments
 (0)