mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
025ae36a72
* fix(device-pair): verify retained subscribers before archiving the migration source Precheck remaining namespace capacity before importing legacy subscribers. Verify source and pre-existing destination keys in the shared JSON importer before reporting completion or archiving, preserving the source and warning when retention falls short. Keep runtime caps, eviction policy, and registration semantics unchanged. Prepared for draft PR maintainer review. Review-required: persistent-state retention semantics. * fix(device-pair): omit absent subscriber migration fields Keep absent account and thread fields out of normalized legacy subscribers so the strict plugin-state JSON serializer accepts ordinary subscriptions. Preserve numeric zero thread IDs and exercise all optional target combinations through the real migration contract.
134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
// Device Pair notify state helpers keep runtime and doctor migration in sync.
|
|
import { createHash } from "node:crypto";
|
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
|
|
export const DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE = "device-pair-notify.json";
|
|
export const DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE = "notify-subscribers";
|
|
export const DEVICE_PAIR_NOTIFY_SEEN_REQUEST_NAMESPACE = "notify-seen-requests";
|
|
export const DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES = 1024;
|
|
export const DEVICE_PAIR_NOTIFY_SEEN_REQUEST_MAX_ENTRIES = 4096;
|
|
export const DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS = 24 * 60 * 60 * 1000;
|
|
|
|
export type NotifySubscription = {
|
|
to: string;
|
|
accountId?: string;
|
|
messageThreadId?: string | number;
|
|
mode: "persistent" | "once";
|
|
addedAtMs: number;
|
|
/** Unique for new arms; absent only on subscriptions imported from legacy state. */
|
|
armId?: string;
|
|
};
|
|
|
|
export type NotifySeenRequest = {
|
|
requestId: string;
|
|
notifiedAtMs: number;
|
|
};
|
|
|
|
export type LegacyNotifyStateFile = {
|
|
subscribers: NotifySubscription[];
|
|
notifiedRequestIds: Record<string, number>;
|
|
};
|
|
|
|
export function normalizeLegacyNotifyState(raw: unknown): LegacyNotifyStateFile {
|
|
const root = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
|
const subscribersRaw = Array.isArray(root.subscribers) ? root.subscribers : [];
|
|
const notifiedRaw =
|
|
typeof root.notifiedRequestIds === "object" && root.notifiedRequestIds !== null
|
|
? (root.notifiedRequestIds as Record<string, unknown>)
|
|
: {};
|
|
|
|
const subscribers: NotifySubscription[] = [];
|
|
for (const item of subscribersRaw) {
|
|
if (typeof item !== "object" || item === null) {
|
|
continue;
|
|
}
|
|
const record = item as Record<string, unknown>;
|
|
const to = normalizeOptionalString(record.to) ?? "";
|
|
if (!to) {
|
|
continue;
|
|
}
|
|
const accountId = normalizeOptionalString(record.accountId) ?? undefined;
|
|
const messageThreadId =
|
|
typeof record.messageThreadId === "string"
|
|
? normalizeOptionalString(record.messageThreadId) || undefined
|
|
: typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
|
|
? Math.trunc(record.messageThreadId)
|
|
: undefined;
|
|
const mode = record.mode === "once" ? "once" : "persistent";
|
|
const addedAtMs =
|
|
typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs)
|
|
? Math.trunc(record.addedAtMs)
|
|
: Date.now();
|
|
subscribers.push({
|
|
to,
|
|
...(accountId ? { accountId } : {}),
|
|
...(messageThreadId != null ? { messageThreadId } : {}),
|
|
mode,
|
|
addedAtMs,
|
|
});
|
|
}
|
|
|
|
const notifiedRequestIds: Record<string, number> = {};
|
|
for (const [requestId, ts] of Object.entries(notifiedRaw)) {
|
|
const normalizedRequestId = normalizeOptionalString(requestId);
|
|
if (!normalizedRequestId) {
|
|
continue;
|
|
}
|
|
if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
|
|
continue;
|
|
}
|
|
notifiedRequestIds[normalizedRequestId] = Math.trunc(ts);
|
|
}
|
|
|
|
return { subscribers, notifiedRequestIds };
|
|
}
|
|
|
|
function normalizeNotifyThreadKey(messageThreadId?: string | number): string {
|
|
if (typeof messageThreadId === "number" && Number.isFinite(messageThreadId)) {
|
|
return String(Math.trunc(messageThreadId));
|
|
}
|
|
if (typeof messageThreadId !== "string") {
|
|
return "";
|
|
}
|
|
const normalized = normalizeOptionalString(messageThreadId);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
if (!/^-?\d+$/u.test(normalized)) {
|
|
return normalized;
|
|
}
|
|
try {
|
|
return BigInt(normalized).toString();
|
|
} catch {
|
|
return normalized;
|
|
}
|
|
}
|
|
|
|
export function notifySubscriberKey(subscriber: {
|
|
to: string;
|
|
accountId?: string;
|
|
messageThreadId?: string | number;
|
|
}): string {
|
|
return JSON.stringify([
|
|
subscriber.to,
|
|
subscriber.accountId ?? "",
|
|
normalizeNotifyThreadKey(subscriber.messageThreadId),
|
|
]);
|
|
}
|
|
|
|
function hashStoreKey(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
export function notifySubscriberStoreKey(subscriber: {
|
|
to: string;
|
|
accountId?: string;
|
|
messageThreadId?: string | number;
|
|
}): string {
|
|
return hashStoreKey(notifySubscriberKey(subscriber));
|
|
}
|
|
|
|
export function notifyRequestStoreKey(requestId: string): string {
|
|
return hashStoreKey(requestId);
|
|
}
|