mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 19:11:33 +00:00
* fix(gateway): clear wake state on node removal * fix(gateway): invalidate wake flows on node removal * fix(gateway): preserve active wakes on disconnect * fix(gateway): share node removal runtime cleanup * fix(gateway): clean up pending node work reliably * fix(gateway): bind node work to pairing generations * fix(gateway): fence stale node pairing sessions * fix(gateway): tighten node pairing ownership * fix(gateway): close node pairing mutation races * fix(gateway): bind node sessions to pairing generation * fix(gateway): harden node pairing generation guards * fix(gateway): close node pairing generation races * fix(gateway): guard APNs transport ownership * fix(gateway): revoke reapproved node transports * fix(gateway): guard retired node lifecycles * fix(gateway): preserve pending work across reconnects * fix(gateway): bind node subscriptions to pairing generations * fix(gateway): settle subscription fanout failures * fix(gateway): fence pairing generation ownership * fix(gateway): fence asynchronous node events * fix(gateway): fence watch results and voice wake fanout * fix(gateway): guard voice wake generation fanout * fix(gateway): carry authenticated snapshot generation * fix(gateway): bind node state to pairing generation * fix(gateway): align generation guards with current base * fix(gateway): enforce pairing-owned node lifecycle * fix(gateway): fence detached voice session writes * chore(deps): bump proxyline to 0.3.4 * fix(gateway): close pairing lifecycle races Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com> * fix(gateway): distinguish pending action mutation Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com> * test(gateway): align lifecycle coverage Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com> * test(skills): split remote reconciliation coverage Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com> * style: format pairing lifecycle changes Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
277 lines
8.6 KiB
TypeScript
277 lines
8.6 KiB
TypeScript
// Gateway node subscription manager.
|
|
// Maintains bidirectional node/session fanout indexes.
|
|
import { serializeEventPayload, type SerializedEventPayload } from "./node-registry.js";
|
|
|
|
// Node subscription manager keeps bidirectional node/session indexes so gateway
|
|
// events can fan out by session and all node cleanup paths remove reverse links.
|
|
type NodeSendEventFn = (opts: {
|
|
nodeId: string;
|
|
pairingGeneration: string;
|
|
event: string;
|
|
payloadJSON?: SerializedEventPayload | null;
|
|
}) => void | Promise<unknown>;
|
|
|
|
type NodeListConnectedFn = () => Array<{ nodeId: string; pairingGeneration?: string }>;
|
|
|
|
type NodeSubscriptionManager = {
|
|
subscribe: (nodeId: string, pairingGeneration: string, sessionKey: string) => void;
|
|
unsubscribe: (nodeId: string, pairingGeneration: string, sessionKey: string) => void;
|
|
unsubscribeAll: (nodeId: string, pairingGeneration?: string) => void;
|
|
updatePairingGeneration: (params: {
|
|
nodeId: string;
|
|
previousPairingGeneration: string;
|
|
nextPairingGeneration: string;
|
|
preserveSubscriptions: boolean;
|
|
}) => void;
|
|
sendToSession: (
|
|
sessionKey: string,
|
|
event: string,
|
|
payload: unknown,
|
|
sendEvent?: NodeSendEventFn | null,
|
|
) => Promise<void>;
|
|
sendToAllSubscribed: (
|
|
event: string,
|
|
payload: unknown,
|
|
sendEvent?: NodeSendEventFn | null,
|
|
) => Promise<void>;
|
|
sendToAllConnected: (
|
|
event: string,
|
|
payload: unknown,
|
|
listConnected?: NodeListConnectedFn | null,
|
|
sendEvent?: NodeSendEventFn | null,
|
|
) => Promise<void>;
|
|
clear: () => void;
|
|
};
|
|
|
|
/** Manages node subscriptions to gateway session events. */
|
|
export function createNodeSubscriptionManager(): NodeSubscriptionManager {
|
|
const nodeSubscriptions = new Map<
|
|
string,
|
|
{ pairingGeneration: string; sessionKeys: Set<string> }
|
|
>();
|
|
const sessionSubscribers = new Map<string, Map<string, string>>();
|
|
|
|
const toPayloadJSON = (payload: unknown): SerializedEventPayload | null | undefined => {
|
|
try {
|
|
return serializeEventPayload(payload);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const settleFanout = async (sends: Array<() => void | Promise<unknown>>): Promise<void> => {
|
|
// Public gateway callers intentionally fire-and-forget fanout. Settle every
|
|
// sender so one transport failure cannot become an unhandled rejection.
|
|
await Promise.allSettled(sends.map((send) => Promise.resolve().then(send)));
|
|
};
|
|
|
|
const subscribe = (nodeId: string, pairingGeneration: string, sessionKey: string) => {
|
|
const normalizedNodeId = nodeId.trim();
|
|
const normalizedPairingGeneration = pairingGeneration.trim();
|
|
const normalizedSessionKey = sessionKey.trim();
|
|
if (!normalizedNodeId || !normalizedPairingGeneration || !normalizedSessionKey) {
|
|
return;
|
|
}
|
|
|
|
let nodeEntry = nodeSubscriptions.get(normalizedNodeId);
|
|
if (nodeEntry?.pairingGeneration !== normalizedPairingGeneration) {
|
|
unsubscribeAll(normalizedNodeId);
|
|
nodeEntry = undefined;
|
|
}
|
|
if (!nodeEntry) {
|
|
nodeEntry = {
|
|
pairingGeneration: normalizedPairingGeneration,
|
|
sessionKeys: new Set<string>(),
|
|
};
|
|
nodeSubscriptions.set(normalizedNodeId, nodeEntry);
|
|
}
|
|
if (nodeEntry.sessionKeys.has(normalizedSessionKey)) {
|
|
return;
|
|
}
|
|
nodeEntry.sessionKeys.add(normalizedSessionKey);
|
|
|
|
let sessionMap = sessionSubscribers.get(normalizedSessionKey);
|
|
if (!sessionMap) {
|
|
sessionMap = new Map<string, string>();
|
|
sessionSubscribers.set(normalizedSessionKey, sessionMap);
|
|
}
|
|
sessionMap.set(normalizedNodeId, normalizedPairingGeneration);
|
|
};
|
|
|
|
const unsubscribe = (nodeId: string, pairingGeneration: string, sessionKey: string) => {
|
|
const normalizedNodeId = nodeId.trim();
|
|
const normalizedPairingGeneration = pairingGeneration.trim();
|
|
const normalizedSessionKey = sessionKey.trim();
|
|
if (!normalizedNodeId || !normalizedPairingGeneration || !normalizedSessionKey) {
|
|
return;
|
|
}
|
|
|
|
const nodeEntry = nodeSubscriptions.get(normalizedNodeId);
|
|
if (nodeEntry?.pairingGeneration !== normalizedPairingGeneration) {
|
|
return;
|
|
}
|
|
nodeEntry.sessionKeys.delete(normalizedSessionKey);
|
|
if (nodeEntry.sessionKeys.size === 0) {
|
|
nodeSubscriptions.delete(normalizedNodeId);
|
|
}
|
|
|
|
const sessionMap = sessionSubscribers.get(normalizedSessionKey);
|
|
if (sessionMap?.get(normalizedNodeId) === normalizedPairingGeneration) {
|
|
sessionMap.delete(normalizedNodeId);
|
|
}
|
|
if (sessionMap?.size === 0) {
|
|
sessionSubscribers.delete(normalizedSessionKey);
|
|
}
|
|
};
|
|
|
|
function unsubscribeAll(nodeId: string, pairingGeneration?: string) {
|
|
const normalizedNodeId = nodeId.trim();
|
|
const nodeEntry = nodeSubscriptions.get(normalizedNodeId);
|
|
if (
|
|
!nodeEntry ||
|
|
(pairingGeneration !== undefined && nodeEntry.pairingGeneration !== pairingGeneration.trim())
|
|
) {
|
|
return;
|
|
}
|
|
// Remove reverse session indexes before deleting the node index so session
|
|
// fanout cannot retain disconnected node ids.
|
|
for (const sessionKey of nodeEntry.sessionKeys) {
|
|
const sessionMap = sessionSubscribers.get(sessionKey);
|
|
if (sessionMap?.get(normalizedNodeId) === nodeEntry.pairingGeneration) {
|
|
sessionMap.delete(normalizedNodeId);
|
|
}
|
|
if (sessionMap?.size === 0) {
|
|
sessionSubscribers.delete(sessionKey);
|
|
}
|
|
}
|
|
nodeSubscriptions.delete(normalizedNodeId);
|
|
}
|
|
|
|
const updatePairingGeneration = (params: {
|
|
nodeId: string;
|
|
previousPairingGeneration: string;
|
|
nextPairingGeneration: string;
|
|
preserveSubscriptions: boolean;
|
|
}) => {
|
|
const normalizedNodeId = params.nodeId.trim();
|
|
const previousPairingGeneration = params.previousPairingGeneration.trim();
|
|
const nextPairingGeneration = params.nextPairingGeneration.trim();
|
|
const nodeEntry = nodeSubscriptions.get(normalizedNodeId);
|
|
if (
|
|
!nodeEntry ||
|
|
!previousPairingGeneration ||
|
|
nodeEntry.pairingGeneration !== previousPairingGeneration
|
|
) {
|
|
return;
|
|
}
|
|
if (!params.preserveSubscriptions || !nextPairingGeneration) {
|
|
unsubscribeAll(normalizedNodeId, previousPairingGeneration);
|
|
return;
|
|
}
|
|
nodeEntry.pairingGeneration = nextPairingGeneration;
|
|
for (const sessionKey of nodeEntry.sessionKeys) {
|
|
sessionSubscribers.get(sessionKey)?.set(normalizedNodeId, nextPairingGeneration);
|
|
}
|
|
};
|
|
|
|
const sendToSession = async (
|
|
sessionKey: string,
|
|
event: string,
|
|
payload: unknown,
|
|
sendEvent?: NodeSendEventFn | null,
|
|
) => {
|
|
const normalizedSessionKey = sessionKey.trim();
|
|
if (!normalizedSessionKey || !sendEvent) {
|
|
return;
|
|
}
|
|
const subscribers = sessionSubscribers.get(normalizedSessionKey);
|
|
if (!subscribers || subscribers.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const payloadJSON = toPayloadJSON(payload);
|
|
if (payloadJSON === undefined) {
|
|
return;
|
|
}
|
|
// Serialize once per event and reuse across all subscribed nodes to keep
|
|
// fanout deterministic and avoid repeated JSON conversion.
|
|
await settleFanout(
|
|
[...subscribers].map(
|
|
([nodeId, pairingGeneration]) =>
|
|
() =>
|
|
sendEvent({ nodeId, pairingGeneration, event, payloadJSON }),
|
|
),
|
|
);
|
|
};
|
|
|
|
const sendToAllSubscribed = async (
|
|
event: string,
|
|
payload: unknown,
|
|
sendEvent?: NodeSendEventFn | null,
|
|
) => {
|
|
if (!sendEvent) {
|
|
return;
|
|
}
|
|
const payloadJSON = toPayloadJSON(payload);
|
|
if (payloadJSON === undefined) {
|
|
return;
|
|
}
|
|
await settleFanout(
|
|
[...nodeSubscriptions].map(
|
|
([nodeId, subscription]) =>
|
|
() =>
|
|
sendEvent({
|
|
nodeId,
|
|
pairingGeneration: subscription.pairingGeneration,
|
|
event,
|
|
payloadJSON,
|
|
}),
|
|
),
|
|
);
|
|
};
|
|
|
|
const sendToAllConnected = async (
|
|
event: string,
|
|
payload: unknown,
|
|
listConnected?: NodeListConnectedFn | null,
|
|
sendEvent?: NodeSendEventFn | null,
|
|
) => {
|
|
if (!sendEvent || !listConnected) {
|
|
return;
|
|
}
|
|
const payloadJSON = toPayloadJSON(payload);
|
|
if (payloadJSON === undefined) {
|
|
return;
|
|
}
|
|
await settleFanout(
|
|
listConnected().map(
|
|
(node) => () =>
|
|
node.pairingGeneration
|
|
? sendEvent({
|
|
nodeId: node.nodeId,
|
|
pairingGeneration: node.pairingGeneration,
|
|
event,
|
|
payloadJSON,
|
|
})
|
|
: undefined,
|
|
),
|
|
);
|
|
};
|
|
|
|
const clear = () => {
|
|
nodeSubscriptions.clear();
|
|
sessionSubscribers.clear();
|
|
};
|
|
|
|
return {
|
|
subscribe,
|
|
unsubscribe,
|
|
unsubscribeAll,
|
|
updatePairingGeneration,
|
|
sendToSession,
|
|
sendToAllSubscribed,
|
|
sendToAllConnected,
|
|
clear,
|
|
};
|
|
}
|