Files
openclaw/src/plugins/session-conversation-binding.ts
T
Peter Steinberger a4110c31b3 refactor: adopt canonical async serialization (#118235)
* refactor: adopt canonical async serialization

* style: satisfy dedupe parameter lint

* style: resolve dedupe timestamp internally
2026-08-02 16:12:04 -07:00

103 lines
3.7 KiB
TypeScript

import crypto from "node:crypto";
import {
createConversationBindingRecord,
resolveConversationBindingRecord,
unbindConversationBindingRecord,
} from "../bindings/records.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js";
import { bindConversationNow, buildPluginBindingIdentity } from "./conversation-binding.js";
import type {
PluginConversationBinding,
PluginConversationBindingRequestParams,
} from "./conversation-binding.types.js";
const log = createSubsystemLogger("plugins/binding");
// Serializes bind+finalize+rollback per session so a failing older attempt
// can never unbind or restore over a newer successful one (all session binds
// go through this in-process seam).
const pluginSessionBindQueue = new KeyedAsyncQueue();
/** Binds a plugin-owned runtime to one authenticated Control UI session. */
export async function bindPluginSessionConversation(params: {
pluginId: string;
pluginName?: string;
pluginRoot: string;
sessionKey: string;
binding: PluginConversationBindingRequestParams;
afterBind?: () => Promise<void>;
}): Promise<PluginConversationBinding> {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
throw new Error("session key is required for a plugin session binding");
}
return await pluginSessionBindQueue.enqueue(sessionKey, async () =>
bindPluginSessionConversationExclusive({ ...params, sessionKey }),
);
}
async function bindPluginSessionConversationExclusive(params: {
pluginId: string;
pluginName?: string;
pluginRoot: string;
sessionKey: string;
binding: PluginConversationBindingRequestParams;
afterBind?: () => Promise<void>;
}): Promise<PluginConversationBinding> {
const sessionKey = params.sessionKey;
const conversation = {
channel: INTERNAL_MESSAGE_CHANNEL,
accountId: "default",
conversationId: sessionKey,
};
const previous = resolveConversationBindingRecord(conversation);
const bindingAttemptId = crypto.randomUUID();
const binding = await bindConversationNow({
identity: buildPluginBindingIdentity(params),
conversation,
targetSessionKey: sessionKey,
summary: params.binding.summary,
detachHint: params.binding.detachHint,
data: params.binding.data,
bindingAttemptId,
});
try {
await params.afterBind?.();
return binding;
} catch (error) {
const current = resolveConversationBindingRecord(conversation);
if (current?.metadata?.bindingAttemptId !== bindingAttemptId) {
throw error;
}
try {
await unbindConversationBindingRecord({
bindingId: current.bindingId,
reason: "plugin-session-bind-rollback",
});
if (previous && (previous.expiresAt === undefined || previous.expiresAt > Date.now())) {
await createConversationBindingRecord({
targetSessionKey: previous.targetSessionKey,
targetKind: previous.targetKind,
conversation: previous.conversation,
placement: "current",
metadata: previous.metadata,
...(previous.expiresAt === undefined
? {}
: { ttlMs: Math.max(1, previous.expiresAt - Date.now()) }),
});
}
} catch (rollbackError) {
// The finalize failure is superseded by the rollback failure on the
// throw path; keep it observable for diagnosis.
log.warn("plugin session binding finalization failed before rollback", { error });
throw new Error(
"plugin session binding finalization failed and its previous binding could not be restored",
{ cause: rollbackError },
);
}
throw error;
}
}