mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
perf(plugins): make every plugin closure statically kysely-free and guard it transitively (#120876)
* perf(plugins): close the last kysely closure chains and guard reachability transitively Follow-up to #120698/#120811/#120882: the closure guard's enumerated barrel bans cannot catch new heavy edges, and two closures still statically reached kysely on main. - guard: add a transitive kysely-reachability test that walks static value imports from every doctor-contract and legacy-setup closure through plugin, plugin-sdk, and relative core graphs, failing with the full import chain; type-only and lazy dynamic imports stay allowed - llm-task/model refs: manifest-model-id-normalization reads snapshots through a registration-slot runtime bridge (snapshot modules register at eval; require fallback covers cold processes) and current-plugin-metadata-state moves its process-scoped facts onto a globalThis singleton so dual module instances share published state - telegram: split thread-bindings-store.ts (pure record shapes + legacy-file readers) out of the acp-runtime-heavy manager, delete the consumer-less testing export, move the pure bot-user-id token parse to token-fingerprint.ts, and lazy-import token.js in the async update-offset detector llm-task enumeration drops to ~0.8s/157 modules cold; every closure is now statically kysely-free and stays that way by construction. * fix(telegram): repoint the native-command menu state at the token-fingerprint parser
This commit is contained in:
committed by
GitHub
parent
4847655826
commit
499d81cdbd
@@ -2,8 +2,10 @@
|
||||
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { getOptionalTelegramRuntime } from "./runtime.js";
|
||||
import { fingerprintTelegramBotToken } from "./token-fingerprint.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token.js";
|
||||
import {
|
||||
fingerprintTelegramBotToken,
|
||||
resolveTelegramBotUserIdFromToken,
|
||||
} from "./token-fingerprint.js";
|
||||
|
||||
const TELEGRAM_MENU_LOCALE_LEDGER_VERSION = 1;
|
||||
const TELEGRAM_MENU_LOCALE_LEDGER_NAMESPACE = "telegram.command-menu-locales";
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import type { TelegramApiCallOpts, TelegramSendOpts } from "./send-message-types.js";
|
||||
import { prepareTelegramOutbound } from "./send-outbound.js";
|
||||
import { resolveMarkdownTableMode } from "./send.runtime.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
|
||||
|
||||
type TelegramEditMessageTextParams = Parameters<TelegramApiContext["api"]["editMessageText"]>[3];
|
||||
type TelegramEditMessageCaptionParams = Parameters<
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "./send-context.js";
|
||||
import type { TelegramLocationSendOpts, TelegramSendResult } from "./send-message-types.js";
|
||||
import { finalizeTelegramOutbound, prepareTelegramOutbound } from "./send-outbound.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
|
||||
|
||||
type TelegramSendLocationParams = Parameters<TelegramApiContext["api"]["sendLocation"]>[3];
|
||||
type TelegramSendVenueParams = Parameters<TelegramApiContext["api"]["sendVenue"]>[5];
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
} from "./send.runtime.js";
|
||||
import { recordSentMessage } from "./sent-message-cache.js";
|
||||
import { parseTelegramTarget } from "./targets.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
|
||||
|
||||
const MAX_TELEGRAM_PHOTO_DIMENSION_SUM = 10_000;
|
||||
const MAX_TELEGRAM_PHOTO_ASPECT_RATIO = 20;
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
import { finalizeTelegramOutbound, prepareTelegramOutbound } from "./send-outbound.js";
|
||||
import { normalizePollInput, type PollInput } from "./send.runtime.js";
|
||||
import { parseTelegramTarget } from "./targets.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
|
||||
|
||||
type TelegramSendPollParams = Parameters<TelegramApiContext["api"]["sendPoll"]>[3];
|
||||
|
||||
|
||||
@@ -36,11 +36,10 @@ import {
|
||||
} from "./sticker-cache-store.js";
|
||||
import {
|
||||
listTelegramLegacyThreadBindingEntries,
|
||||
resolveTelegramThreadBindingsPath,
|
||||
TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES,
|
||||
TELEGRAM_THREAD_BINDINGS_NAMESPACE,
|
||||
testing as telegramThreadBindingTesting,
|
||||
} from "./thread-bindings.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
} from "./thread-bindings-store.js";
|
||||
import {
|
||||
listTelegramLegacyTopicNameCacheEntries,
|
||||
resolveTopicNameCacheNamespace,
|
||||
@@ -259,11 +258,14 @@ function detectTelegramBotInfoCacheLegacyStateMigration(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function detectTelegramUpdateOffsetLegacyStateMigration(params: {
|
||||
async function detectTelegramUpdateOffsetLegacyStateMigration(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}): ChannelLegacyStateMigrationPlan[] {
|
||||
}): Promise<ChannelLegacyStateMigrationPlan[]> {
|
||||
// token.js pulls provider-auth's config graph; keep it lazy so setup/doctor
|
||||
// closure cold-load stays light.
|
||||
const { resolveTelegramToken } = await import("./token.js");
|
||||
const stateDir = resolveMigrationStateDir(params);
|
||||
return listTelegramLegacySidecarAccountIds({
|
||||
cfg: params.cfg,
|
||||
@@ -388,7 +390,7 @@ function detectTelegramThreadBindingLegacyStateMigration(params: {
|
||||
prefix: "thread-bindings-",
|
||||
suffix: ".json",
|
||||
}).flatMap((accountId) => {
|
||||
const persistedPath = telegramThreadBindingTesting.resolveBindingsPath(accountId, params.env);
|
||||
const persistedPath = resolveTelegramThreadBindingsPath(accountId, params.env);
|
||||
if (!fileExists(persistedPath)) {
|
||||
return [];
|
||||
}
|
||||
@@ -484,7 +486,7 @@ export async function detectTelegramLegacyStateMigrations(params: {
|
||||
stateDir?: string;
|
||||
}): Promise<ChannelLegacyStateMigrationPlan[]> {
|
||||
const plans: ChannelLegacyStateMigrationPlan[] = [];
|
||||
plans.push(...detectTelegramUpdateOffsetLegacyStateMigration(params));
|
||||
plans.push(...(await detectTelegramUpdateOffsetLegacyStateMigration(params)));
|
||||
plans.push(...detectTelegramBotInfoCacheLegacyStateMigration(params));
|
||||
plans.push(...detectTelegramStickerCacheLegacyStateMigration(params));
|
||||
plans.push(...detectTelegramMessageCacheLegacyStateMigration(params));
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Pure Telegram thread-binding record shapes and legacy-file readers, split
|
||||
// from thread-bindings.ts so doctor/setup closures (state-migrations) never
|
||||
// load the acp-runtime/session graph the manager runtime needs.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export const TELEGRAM_THREAD_BINDINGS_NAMESPACE = "telegram.thread-bindings";
|
||||
export const TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES = 5_000;
|
||||
const TELEGRAM_THREAD_BINDINGS_STORE_VERSION = 1;
|
||||
|
||||
export type TelegramBindingTargetKind = "subagent" | "acp";
|
||||
|
||||
export type TelegramThreadBindingRecord = {
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
targetKind: TelegramBindingTargetKind;
|
||||
targetSessionKey: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
boundBy?: string;
|
||||
boundAt: number;
|
||||
lastActivityAt: number;
|
||||
idleTimeoutMs?: number;
|
||||
maxAgeMs?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type StoredTelegramBindingState = {
|
||||
version: number;
|
||||
bindings: TelegramThreadBindingRecord[];
|
||||
};
|
||||
|
||||
export function resolveStoredBindingKey(params: {
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
}): string {
|
||||
return createHash("sha256")
|
||||
.update(`${params.accountId}\0${params.conversationId}`, "utf8")
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
export function resolveTelegramThreadBindingsPath(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
return path.join(stateDir, "telegram", `thread-bindings-${accountId}.json`);
|
||||
}
|
||||
|
||||
function normalizeMetadataForStore(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
const serialized = JSON.stringify(metadata);
|
||||
if (!serialized) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(serialized) as Record<string, unknown>;
|
||||
return Object.keys(parsed).length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function sanitizeStoredBinding(
|
||||
accountId: string,
|
||||
entry: Partial<TelegramThreadBindingRecord> | null | undefined,
|
||||
): TelegramThreadBindingRecord | null {
|
||||
const conversationId = normalizeOptionalString(entry?.conversationId);
|
||||
const targetSessionKey = normalizeOptionalString(entry?.targetSessionKey) ?? "";
|
||||
const targetKind = entry?.targetKind === "subagent" ? "subagent" : "acp";
|
||||
if (!conversationId || !targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const boundAt =
|
||||
typeof entry?.boundAt === "number" && Number.isFinite(entry.boundAt)
|
||||
? Math.floor(entry.boundAt)
|
||||
: Date.now();
|
||||
const lastActivityAt =
|
||||
typeof entry?.lastActivityAt === "number" && Number.isFinite(entry.lastActivityAt)
|
||||
? Math.floor(entry.lastActivityAt)
|
||||
: boundAt;
|
||||
const record: TelegramThreadBindingRecord = {
|
||||
accountId,
|
||||
conversationId,
|
||||
targetSessionKey,
|
||||
targetKind,
|
||||
boundAt,
|
||||
lastActivityAt,
|
||||
};
|
||||
if (typeof entry?.idleTimeoutMs === "number" && Number.isFinite(entry.idleTimeoutMs)) {
|
||||
record.idleTimeoutMs = Math.max(0, Math.floor(entry.idleTimeoutMs));
|
||||
}
|
||||
if (typeof entry?.maxAgeMs === "number" && Number.isFinite(entry.maxAgeMs)) {
|
||||
record.maxAgeMs = Math.max(0, Math.floor(entry.maxAgeMs));
|
||||
}
|
||||
if (typeof entry?.agentId === "string" && entry.agentId.trim()) {
|
||||
record.agentId = entry.agentId.trim();
|
||||
}
|
||||
if (typeof entry?.label === "string" && entry.label.trim()) {
|
||||
record.label = entry.label.trim();
|
||||
}
|
||||
if (typeof entry?.boundBy === "string" && entry.boundBy.trim()) {
|
||||
record.boundBy = entry.boundBy.trim();
|
||||
}
|
||||
const metadata = normalizeMetadataForStore(
|
||||
entry?.metadata && typeof entry.metadata === "object" ? { ...entry.metadata } : undefined,
|
||||
);
|
||||
if (metadata) {
|
||||
record.metadata = metadata;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function readLegacyBindingsFile(
|
||||
filePath: string,
|
||||
accountId: string,
|
||||
): TelegramThreadBindingRecord[] {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as StoredTelegramBindingState;
|
||||
if (
|
||||
parsed?.version !== TELEGRAM_THREAD_BINDINGS_STORE_VERSION ||
|
||||
!Array.isArray(parsed.bindings)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const bindings: TelegramThreadBindingRecord[] = [];
|
||||
for (const entry of parsed.bindings) {
|
||||
const record = sanitizeStoredBinding(accountId, entry);
|
||||
if (record) {
|
||||
bindings.push(record);
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code !== "ENOENT") {
|
||||
logVerbose(`telegram thread bindings load failed (${accountId}): ${String(err)}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listTelegramLegacyThreadBindingEntries(params: {
|
||||
accountId: string;
|
||||
persistedPath?: string;
|
||||
}): Array<{ key: string; value: TelegramThreadBindingRecord }> {
|
||||
const bindings = readLegacyBindingsFile(
|
||||
params.persistedPath ?? resolveTelegramThreadBindingsPath(params.accountId),
|
||||
params.accountId,
|
||||
);
|
||||
return bindings.map((value) => ({ key: resolveStoredBindingKey(value), value }));
|
||||
}
|
||||
@@ -29,8 +29,10 @@ vi.mock("openclaw/plugin-sdk/acp-runtime", async () => {
|
||||
import {
|
||||
TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES,
|
||||
TELEGRAM_THREAD_BINDINGS_NAMESPACE,
|
||||
testing,
|
||||
} from "./thread-bindings-store.js";
|
||||
import {
|
||||
createTelegramThreadBindingManager as createTelegramThreadBindingManagerImpl,
|
||||
resetTelegramThreadBindingsForTests,
|
||||
setTelegramThreadBindingIdleTimeoutBySessionKey,
|
||||
setTelegramThreadBindingMaxAgeBySessionKey,
|
||||
} from "./thread-bindings.js";
|
||||
@@ -111,12 +113,12 @@ describe("telegram thread bindings", () => {
|
||||
"openclaw/plugin-sdk/acp-runtime",
|
||||
);
|
||||
readAcpSessionEntryMock.mockImplementation(acpRuntime.readAcpSessionEntry);
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
clearTelegramRuntimeForTest();
|
||||
resetPluginStateStoreForTests();
|
||||
await openClawState.cleanup();
|
||||
@@ -211,7 +213,7 @@ describe("telegram thread bindings", () => {
|
||||
import.meta.url,
|
||||
"./thread-bindings.js?scope=shared-b",
|
||||
);
|
||||
await bindingsA.testing.resetTelegramThreadBindingsForTests();
|
||||
await bindingsA.resetTelegramThreadBindingsForTests();
|
||||
|
||||
try {
|
||||
const managerA = bindingsA.createTelegramThreadBindingManager({
|
||||
@@ -246,7 +248,7 @@ describe("telegram thread bindings", () => {
|
||||
?.getByConversationId("-100200300:topic:44")?.targetSessionKey,
|
||||
).toBe("agent:main:subagent:child-shared");
|
||||
} finally {
|
||||
await bindingsA.testing.resetTelegramThreadBindingsForTests();
|
||||
await bindingsA.resetTelegramThreadBindingsForTests();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -354,7 +356,7 @@ describe("telegram thread bindings", () => {
|
||||
reason: "test-detach",
|
||||
});
|
||||
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
|
||||
const reloaded = createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
@@ -439,7 +441,7 @@ describe("telegram thread bindings", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
|
||||
const reloaded = createTelegramThreadBindingManager({
|
||||
accountId: "metadata",
|
||||
@@ -491,7 +493,7 @@ describe("telegram thread bindings", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
readAcpSessionEntryMock.mockReturnValue({
|
||||
cfg: {} as never,
|
||||
storePath: "/tmp/acp-store.json",
|
||||
@@ -509,7 +511,7 @@ describe("telegram thread bindings", () => {
|
||||
});
|
||||
|
||||
expect(reloaded.getByConversationId("cleanup-me")).toBeUndefined();
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
expect(storedBindings().map((binding) => binding.conversationId)).not.toContain("cleanup-me");
|
||||
});
|
||||
|
||||
@@ -530,7 +532,7 @@ describe("telegram thread bindings", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
|
||||
const reloaded = createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
@@ -561,7 +563,7 @@ describe("telegram thread bindings", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
readAcpSessionEntryMock.mockReturnValue({
|
||||
cfg: {} as never,
|
||||
storePath: "/tmp/acp-store.json",
|
||||
@@ -609,7 +611,7 @@ describe("telegram thread bindings", () => {
|
||||
idleTimeoutMs: 90_000,
|
||||
});
|
||||
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
|
||||
expect(
|
||||
storedBindings().find((binding) => binding.accountId === "persist-reset")?.idleTimeoutMs,
|
||||
@@ -648,7 +650,7 @@ describe("telegram thread bindings", () => {
|
||||
});
|
||||
manager.touchConversation("-100200300:topic:100");
|
||||
|
||||
await testing.resetTelegramThreadBindingsForTests();
|
||||
await resetTelegramThreadBindingsForTests();
|
||||
await flushMicrotasks();
|
||||
expect(unhandled).toStrictEqual([]);
|
||||
} finally {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
// Telegram plugin module implements thread bindings behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readAcpSessionEntry } from "openclaw/plugin-sdk/acp-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
@@ -20,41 +16,22 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { normalizeAccountId, isAcpSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getTelegramRuntime } from "./runtime.js";
|
||||
import { loadTelegramSendModule } from "./send-runtime.js";
|
||||
import {
|
||||
resolveStoredBindingKey,
|
||||
sanitizeStoredBinding,
|
||||
TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES,
|
||||
TELEGRAM_THREAD_BINDINGS_NAMESPACE,
|
||||
type TelegramBindingTargetKind,
|
||||
type TelegramThreadBindingRecord,
|
||||
} from "./thread-bindings-store.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
|
||||
const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_THREAD_BINDING_MAX_AGE_MS = 0;
|
||||
const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 60_000;
|
||||
const STORE_VERSION = 1;
|
||||
export const TELEGRAM_THREAD_BINDINGS_NAMESPACE = "telegram.thread-bindings";
|
||||
export const TELEGRAM_THREAD_BINDINGS_MAX_ENTRIES = 5_000;
|
||||
|
||||
type TelegramBindingTargetKind = "subagent" | "acp";
|
||||
|
||||
type TelegramThreadBindingRecord = {
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
targetKind: TelegramBindingTargetKind;
|
||||
targetSessionKey: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
boundBy?: string;
|
||||
boundAt: number;
|
||||
lastActivityAt: number;
|
||||
idleTimeoutMs?: number;
|
||||
maxAgeMs?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type StoredTelegramBindingState = {
|
||||
version: number;
|
||||
bindings: TelegramThreadBindingRecord[];
|
||||
};
|
||||
|
||||
type TelegramThreadBindingStore = PluginStateSyncKeyedStore<TelegramThreadBindingRecord>;
|
||||
|
||||
type TelegramThreadBindingManager = {
|
||||
@@ -118,13 +95,6 @@ function resolveBindingKey(params: { accountId: string; conversationId: string }
|
||||
return `${params.accountId}:${params.conversationId}`;
|
||||
}
|
||||
|
||||
function resolveStoredBindingKey(params: { accountId: string; conversationId: string }): string {
|
||||
return createHash("sha256")
|
||||
.update(`${params.accountId}\0${params.conversationId}`, "utf8")
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function openThreadBindingStore(): TelegramThreadBindingStore {
|
||||
return getTelegramRuntime().state.openSyncKeyedStore<TelegramThreadBindingRecord>({
|
||||
namespace: TELEGRAM_THREAD_BINDINGS_NAMESPACE,
|
||||
@@ -239,25 +209,6 @@ function fromSessionBindingInput(params: {
|
||||
return record;
|
||||
}
|
||||
|
||||
function resolveBindingsPath(accountId: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
return path.join(stateDir, "telegram", `thread-bindings-${accountId}.json`);
|
||||
}
|
||||
|
||||
function normalizeMetadataForStore(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
const serialized = JSON.stringify(metadata);
|
||||
if (!serialized) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(serialized) as Record<string, unknown>;
|
||||
return Object.keys(parsed).length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function summarizeLifecycleForLog(
|
||||
record: TelegramThreadBindingRecord,
|
||||
defaults: {
|
||||
@@ -273,83 +224,6 @@ function summarizeLifecycleForLog(
|
||||
return `idle=${idleLabel} maxAge=${maxAgeLabel}`;
|
||||
}
|
||||
|
||||
function sanitizeStoredBinding(
|
||||
accountId: string,
|
||||
entry: Partial<TelegramThreadBindingRecord> | null | undefined,
|
||||
): TelegramThreadBindingRecord | null {
|
||||
const conversationId = normalizeOptionalString(entry?.conversationId);
|
||||
const targetSessionKey = normalizeOptionalString(entry?.targetSessionKey) ?? "";
|
||||
const targetKind = entry?.targetKind === "subagent" ? "subagent" : "acp";
|
||||
if (!conversationId || !targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const boundAt =
|
||||
typeof entry?.boundAt === "number" && Number.isFinite(entry.boundAt)
|
||||
? Math.floor(entry.boundAt)
|
||||
: Date.now();
|
||||
const lastActivityAt =
|
||||
typeof entry?.lastActivityAt === "number" && Number.isFinite(entry.lastActivityAt)
|
||||
? Math.floor(entry.lastActivityAt)
|
||||
: boundAt;
|
||||
const record: TelegramThreadBindingRecord = {
|
||||
accountId,
|
||||
conversationId,
|
||||
targetSessionKey,
|
||||
targetKind,
|
||||
boundAt,
|
||||
lastActivityAt,
|
||||
};
|
||||
if (typeof entry?.idleTimeoutMs === "number" && Number.isFinite(entry.idleTimeoutMs)) {
|
||||
record.idleTimeoutMs = Math.max(0, Math.floor(entry.idleTimeoutMs));
|
||||
}
|
||||
if (typeof entry?.maxAgeMs === "number" && Number.isFinite(entry.maxAgeMs)) {
|
||||
record.maxAgeMs = Math.max(0, Math.floor(entry.maxAgeMs));
|
||||
}
|
||||
if (typeof entry?.agentId === "string" && entry.agentId.trim()) {
|
||||
record.agentId = entry.agentId.trim();
|
||||
}
|
||||
if (typeof entry?.label === "string" && entry.label.trim()) {
|
||||
record.label = entry.label.trim();
|
||||
}
|
||||
if (typeof entry?.boundBy === "string" && entry.boundBy.trim()) {
|
||||
record.boundBy = entry.boundBy.trim();
|
||||
}
|
||||
const metadata = normalizeMetadataForStore(
|
||||
entry?.metadata && typeof entry.metadata === "object" ? { ...entry.metadata } : undefined,
|
||||
);
|
||||
if (metadata) {
|
||||
record.metadata = metadata;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function readLegacyBindingsFile(
|
||||
filePath: string,
|
||||
accountId: string,
|
||||
): TelegramThreadBindingRecord[] {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as StoredTelegramBindingState;
|
||||
if (parsed?.version !== STORE_VERSION || !Array.isArray(parsed.bindings)) {
|
||||
return [];
|
||||
}
|
||||
const bindings: TelegramThreadBindingRecord[] = [];
|
||||
for (const entry of parsed.bindings) {
|
||||
const record = sanitizeStoredBinding(accountId, entry);
|
||||
if (record) {
|
||||
bindings.push(record);
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code !== "ENOENT") {
|
||||
logVerbose(`telegram thread bindings load failed (${accountId}): ${String(err)}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function loadBindingsFromStore(accountId: string): TelegramThreadBindingRecord[] {
|
||||
let store: TelegramThreadBindingStore;
|
||||
try {
|
||||
@@ -918,20 +792,4 @@ export function resetTelegramThreadBindingsForTests(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
export function listTelegramLegacyThreadBindingEntries(params: {
|
||||
accountId: string;
|
||||
persistedPath?: string;
|
||||
}): Array<{ key: string; value: TelegramThreadBindingRecord }> {
|
||||
const bindings = readLegacyBindingsFile(
|
||||
params.persistedPath ?? resolveBindingsPath(params.accountId),
|
||||
params.accountId,
|
||||
);
|
||||
return bindings.map((value) => ({ key: resolveStoredBindingKey(value), value }));
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
resetTelegramThreadBindingsForTests,
|
||||
resolveBindingsPath,
|
||||
resolveStoredBindingKey,
|
||||
};
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Telegram plugin module implements token fingerprint behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
/**
|
||||
* Derive a short, non-reversible fingerprint of a Telegram bot token suitable
|
||||
@@ -11,3 +12,12 @@ import { createHash } from "node:crypto";
|
||||
export function fingerprintTelegramBotToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/** Parse the numeric bot user id prefix from a Telegram bot token. */
|
||||
export function resolveTelegramBotUserIdFromToken(token?: string): number | undefined {
|
||||
const rawBotId = token?.trim().split(":", 1)[0];
|
||||
if (!rawBotId || !/^\d+$/.test(rawBotId)) {
|
||||
return undefined;
|
||||
}
|
||||
return parseStrictPositiveInteger(rawBotId);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveTelegramBotUserIdFromToken, resolveTelegramToken } from "./token.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
|
||||
describe("resolveTelegramBotUserIdFromToken", () => {
|
||||
it.each([
|
||||
|
||||
@@ -3,7 +3,6 @@ import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-core"
|
||||
import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
@@ -29,14 +28,6 @@ export type TelegramTokenResolution = BaseTokenResolution & {
|
||||
credentialDiagnostics?: CredentialUnavailableDiagnostic[];
|
||||
};
|
||||
|
||||
export function resolveTelegramBotUserIdFromToken(token?: string): number | undefined {
|
||||
const rawBotId = token?.trim().split(":", 1)[0];
|
||||
if (!rawBotId || !/^\d+$/.test(rawBotId)) {
|
||||
return undefined;
|
||||
}
|
||||
return parseStrictPositiveInteger(rawBotId);
|
||||
}
|
||||
|
||||
type RuntimeTokenValueResolution =
|
||||
| { status: "available"; value: string }
|
||||
| { status: "configured_unavailable" }
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-run
|
||||
import { getTelegramRuntime } from "./runtime.js";
|
||||
import { normalizeTelegramStateAccountId } from "./state-account-id.js";
|
||||
import { fingerprintTelegramBotToken } from "./token-fingerprint.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token.js";
|
||||
import { resolveTelegramBotUserIdFromToken } from "./token-fingerprint.js";
|
||||
|
||||
const STORE_VERSION = 3;
|
||||
export const TELEGRAM_UPDATE_OFFSET_NAMESPACE = "telegram.update-offsets";
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolvePluginControlPlaneFingerprint,
|
||||
type ResolvePluginControlPlaneContextParams,
|
||||
} from "./plugin-control-plane-context.js";
|
||||
import { registerPluginMetadataSnapshotReaders } from "./plugin-metadata-snapshot.runtime.js";
|
||||
import type {
|
||||
PluginMetadataSnapshot,
|
||||
PluginMetadataSnapshotPluginIdScope,
|
||||
@@ -416,3 +417,8 @@ export function getCurrentPluginMetadataSnapshot(
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
// Light bridges (plugin-metadata-snapshot.runtime.ts) serve reads through this
|
||||
// instance whenever the metadata system is loaded; the require fallback only
|
||||
// covers cold processes.
|
||||
registerPluginMetadataSnapshotReaders({ getCurrentPluginMetadataSnapshot });
|
||||
|
||||
@@ -4,37 +4,54 @@ import {
|
||||
type ManifestModelIdNormalizationRecord,
|
||||
} from "@openclaw/model-catalog-core/provider-model-id-normalization";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
|
||||
let currentPluginMetadataSnapshot: unknown;
|
||||
let currentPluginMetadataSnapshotConfigFingerprint: string | undefined;
|
||||
let currentPluginMetadataSnapshotCompatiblePolicyHashes: readonly string[] | undefined;
|
||||
let currentPluginMetadataSnapshotCompatibleConfigFingerprints: readonly string[] | undefined;
|
||||
let currentManifestModelIdNormalizationRecords:
|
||||
| readonly ManifestModelIdNormalizationRecord[]
|
||||
| undefined;
|
||||
// Temporary snapshot owners compare this publication token before restoring;
|
||||
// lifecycle clears and newer publications must always win.
|
||||
let currentPluginMetadataSnapshotRevision = Symbol("plugin-metadata-snapshot");
|
||||
let currentPluginMetadataConfigIdentities = new WeakSet<OpenClawConfig>();
|
||||
export type CurrentPluginMetadataSnapshotRevision = symbol;
|
||||
|
||||
export type CurrentPluginMetadataSnapshotRevision = typeof currentPluginMetadataSnapshotRevision;
|
||||
type CurrentPluginMetadataMutableState = {
|
||||
snapshot: unknown;
|
||||
configFingerprint: string | undefined;
|
||||
compatiblePolicyHashes: readonly string[] | undefined;
|
||||
compatibleConfigFingerprints: readonly string[] | undefined;
|
||||
manifestModelIdNormalizationRecords: readonly ManifestModelIdNormalizationRecord[] | undefined;
|
||||
// Temporary snapshot owners compare this publication token before restoring;
|
||||
// lifecycle clears and newer publications must always win.
|
||||
revision: CurrentPluginMetadataSnapshotRevision;
|
||||
configIdentities: WeakSet<OpenClawConfig>;
|
||||
};
|
||||
|
||||
// Process-scoped facts must survive dual module instances (ESM plus the lazy
|
||||
// require bridge in plugin-metadata-snapshot.runtime.ts), so the state lives
|
||||
// on a globalThis singleton like the scoped snapshot ALS.
|
||||
const state = resolveGlobalSingleton<CurrentPluginMetadataMutableState>(
|
||||
Symbol.for("openclaw.currentPluginMetadataState"),
|
||||
() => ({
|
||||
snapshot: undefined,
|
||||
configFingerprint: undefined,
|
||||
compatiblePolicyHashes: undefined,
|
||||
compatibleConfigFingerprints: undefined,
|
||||
manifestModelIdNormalizationRecords: undefined,
|
||||
revision: Symbol("plugin-metadata-snapshot"),
|
||||
configIdentities: new WeakSet<OpenClawConfig>(),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Owns config identity reuse for the current immutable metadata snapshot. */
|
||||
export const currentPluginMetadataConfigIdentityCache = {
|
||||
add(config: OpenClawConfig): void {
|
||||
currentPluginMetadataConfigIdentities.add(config);
|
||||
state.configIdentities.add(config);
|
||||
},
|
||||
capture(): WeakSet<OpenClawConfig> {
|
||||
return currentPluginMetadataConfigIdentities;
|
||||
return state.configIdentities;
|
||||
},
|
||||
clear(): void {
|
||||
currentPluginMetadataConfigIdentities = new WeakSet();
|
||||
state.configIdentities = new WeakSet();
|
||||
},
|
||||
has(config: OpenClawConfig): boolean {
|
||||
return currentPluginMetadataConfigIdentities.has(config);
|
||||
return state.configIdentities.has(config);
|
||||
},
|
||||
restore(identities: WeakSet<OpenClawConfig>): void {
|
||||
currentPluginMetadataConfigIdentities = identities;
|
||||
state.configIdentities = identities;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,32 +63,28 @@ export function setCurrentPluginMetadataSnapshotState(
|
||||
compatibleConfigFingerprints?: readonly string[],
|
||||
manifestModelIdNormalizationRecords?: readonly ManifestModelIdNormalizationRecord[],
|
||||
): CurrentPluginMetadataSnapshotRevision {
|
||||
currentPluginMetadataSnapshot = snapshot;
|
||||
currentPluginMetadataSnapshotConfigFingerprint = snapshot ? configFingerprint : undefined;
|
||||
currentPluginMetadataSnapshotCompatiblePolicyHashes = snapshot
|
||||
? compatiblePolicyHashes
|
||||
: undefined;
|
||||
currentPluginMetadataSnapshotCompatibleConfigFingerprints = snapshot
|
||||
? compatibleConfigFingerprints
|
||||
: undefined;
|
||||
currentManifestModelIdNormalizationRecords = snapshot
|
||||
state.snapshot = snapshot;
|
||||
state.configFingerprint = snapshot ? configFingerprint : undefined;
|
||||
state.compatiblePolicyHashes = snapshot ? compatiblePolicyHashes : undefined;
|
||||
state.compatibleConfigFingerprints = snapshot ? compatibleConfigFingerprints : undefined;
|
||||
state.manifestModelIdNormalizationRecords = snapshot
|
||||
? manifestModelIdNormalizationRecords
|
||||
: undefined;
|
||||
setCurrentManifestModelIdNormalizationRecords(currentManifestModelIdNormalizationRecords);
|
||||
currentPluginMetadataSnapshotRevision = Symbol("plugin-metadata-snapshot");
|
||||
return currentPluginMetadataSnapshotRevision;
|
||||
setCurrentManifestModelIdNormalizationRecords(state.manifestModelIdNormalizationRecords);
|
||||
state.revision = Symbol("plugin-metadata-snapshot");
|
||||
return state.revision;
|
||||
}
|
||||
|
||||
/** Clears the process-current plugin metadata snapshot. */
|
||||
function clearCurrentPluginMetadataSnapshotState(): CurrentPluginMetadataSnapshotRevision {
|
||||
currentPluginMetadataSnapshot = undefined;
|
||||
currentPluginMetadataSnapshotConfigFingerprint = undefined;
|
||||
currentPluginMetadataSnapshotCompatiblePolicyHashes = undefined;
|
||||
currentPluginMetadataSnapshotCompatibleConfigFingerprints = undefined;
|
||||
currentManifestModelIdNormalizationRecords = undefined;
|
||||
state.snapshot = undefined;
|
||||
state.configFingerprint = undefined;
|
||||
state.compatiblePolicyHashes = undefined;
|
||||
state.compatibleConfigFingerprints = undefined;
|
||||
state.manifestModelIdNormalizationRecords = undefined;
|
||||
setCurrentManifestModelIdNormalizationRecords(undefined);
|
||||
currentPluginMetadataSnapshotRevision = Symbol("plugin-metadata-snapshot");
|
||||
return currentPluginMetadataSnapshotRevision;
|
||||
state.revision = Symbol("plugin-metadata-snapshot");
|
||||
return state.revision;
|
||||
}
|
||||
|
||||
/** Clears the snapshot, its identity cache, and process-wide model normalization. */
|
||||
@@ -90,11 +103,11 @@ export function getCurrentPluginMetadataSnapshotState(): {
|
||||
revision: CurrentPluginMetadataSnapshotRevision;
|
||||
} {
|
||||
return {
|
||||
snapshot: currentPluginMetadataSnapshot,
|
||||
configFingerprint: currentPluginMetadataSnapshotConfigFingerprint,
|
||||
compatiblePolicyHashes: currentPluginMetadataSnapshotCompatiblePolicyHashes,
|
||||
compatibleConfigFingerprints: currentPluginMetadataSnapshotCompatibleConfigFingerprints,
|
||||
manifestModelIdNormalizationRecords: currentManifestModelIdNormalizationRecords,
|
||||
revision: currentPluginMetadataSnapshotRevision,
|
||||
snapshot: state.snapshot,
|
||||
configFingerprint: state.configFingerprint,
|
||||
compatiblePolicyHashes: state.compatiblePolicyHashes,
|
||||
compatibleConfigFingerprints: state.compatibleConfigFingerprints,
|
||||
manifestModelIdNormalizationRecords: state.manifestModelIdNormalizationRecords,
|
||||
revision: state.revision,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -363,6 +363,83 @@ function collectHeavyRuntimeDoctorMigrationImports(): string[] {
|
||||
return violations;
|
||||
}
|
||||
|
||||
const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/";
|
||||
|
||||
function isKyselySpecifier(specifier: string): boolean {
|
||||
return specifier === "kysely" || specifier.startsWith("kysely/");
|
||||
}
|
||||
|
||||
// The transitive walk follows relative imports (plugin-local, core src, and the
|
||||
// deep-relative package bridges) plus openclaw/plugin-sdk/* subpaths. Other bare
|
||||
// specifiers are node builtins or npm/workspace packages; only the repo root
|
||||
// depends on kysely, so every kysely edge is reachable through this resolution.
|
||||
function collectTraversalValueReferences(filePath: string, source: string): ModuleReference[] {
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
||||
const staticValueReferenceKeys = collectStaticValueReferenceKeys(sourceFile);
|
||||
return collectModuleReferencesFromSource(source, {
|
||||
fileName: filePath,
|
||||
acceptSpecifier: (specifier) =>
|
||||
isKyselySpecifier(specifier) ||
|
||||
specifier.startsWith(".") ||
|
||||
specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX),
|
||||
}).filter((reference) =>
|
||||
staticValueReferenceKeys.has(`${reference.kind}\0${reference.line}\0${reference.specifier}`),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTraversalModule(filePath: string, specifier: string): string | null {
|
||||
if (specifier.startsWith(".")) {
|
||||
return resolveRelativeSourceModule(filePath, specifier);
|
||||
}
|
||||
if (specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) {
|
||||
const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
|
||||
return resolveRelativeSourceModule(
|
||||
path.join(REPO_ROOT, "src/plugin-sdk", "entrypoint-anchor.ts"),
|
||||
`./${subpath}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cached per file: null = kysely unreachable; otherwise the first found import
|
||||
// chain from this file to a kysely value import (repo-relative, entry first).
|
||||
const kyselyReachabilityByFile = new Map<string, string[] | null>();
|
||||
|
||||
function findKyselyChain(filePath: string, inProgress: Set<string>): string[] | null {
|
||||
const cached = kyselyReachabilityByFile.get(filePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
if (inProgress.has(filePath)) {
|
||||
// Cycle back-edge: the ancestor still explores its remaining children, so
|
||||
// skipping here cannot hide a kysely edge.
|
||||
return null;
|
||||
}
|
||||
inProgress.add(filePath);
|
||||
let chain: string[] | null = null;
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
for (const reference of collectTraversalValueReferences(filePath, source)) {
|
||||
if (isKyselySpecifier(reference.specifier)) {
|
||||
chain = [`${formatRepoPath(filePath)}:${reference.line} imports ${reference.specifier}`];
|
||||
break;
|
||||
}
|
||||
const resolvedPath = resolveTraversalModule(filePath, reference.specifier);
|
||||
if (!resolvedPath || !isInsideRoot(REPO_ROOT, resolvedPath)) {
|
||||
continue;
|
||||
}
|
||||
const childChain = findKyselyChain(resolvedPath, inProgress);
|
||||
if (childChain) {
|
||||
chain = [`${formatRepoPath(filePath)}:${reference.line} -> ${reference.specifier}`].concat(
|
||||
childChain,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
inProgress.delete(filePath);
|
||||
kyselyReachabilityByFile.set(filePath, chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
describe("doctor contract import closures", () => {
|
||||
it("classifies only static value module edges", () => {
|
||||
const source = [
|
||||
@@ -390,4 +467,19 @@ describe("doctor contract import closures", () => {
|
||||
it("keeps the runtime doctor migration helper off state DB and plugin-state graphs", () => {
|
||||
expect(collectHeavyRuntimeDoctorMigrationImports()).toStrictEqual([]);
|
||||
});
|
||||
|
||||
// The exhaustive backstop: no doctor-contract or legacy-setup closure may reach
|
||||
// the kysely package through any static value import chain, regardless of which
|
||||
// barrel introduces the edge. Type-only and dynamic imports stay allowed.
|
||||
it("keeps kysely statically unreachable from every plugin closure", () => {
|
||||
const violations = collectClosureEntries()
|
||||
.flatMap((entry) => {
|
||||
const chain = findKyselyChain(entry.entryPath, new Set());
|
||||
return chain
|
||||
? [`${entry.pluginId}: closure reaches kysely via\n ${chain.join("\n ")}`]
|
||||
: [];
|
||||
})
|
||||
.toSorted();
|
||||
expect(violations).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@ import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/e
|
||||
import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
|
||||
import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js";
|
||||
import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js";
|
||||
// Registers the snapshot resolver in the runtime bridge slot. Production and
|
||||
// jiti load it via the bridge's require fallback; vitest workers lack a CJS TS
|
||||
// hook, so the no-snapshot fallback path needs the ESM registration.
|
||||
import "./plugin-metadata-snapshot.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
|
||||
import { resetPluginRuntimeStateForTest } from "./runtime.js";
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@ import {
|
||||
normalizeProviderModelIdWithPolicies,
|
||||
} from "@openclaw/model-catalog-core/provider-model-id-normalization";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import type { PluginManifestModelIdNormalizationProvider } from "./manifest.js";
|
||||
import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
|
||||
// Snapshot reads go through the registration-slot bridge so this module stays
|
||||
// off the control-plane/kysely graph; doctor closures cold-load it via
|
||||
// parseModelRef consumers.
|
||||
import {
|
||||
getCurrentPluginMetadataSnapshotRuntime,
|
||||
resolvePluginMetadataSnapshotRuntime,
|
||||
} from "./plugin-metadata-snapshot.runtime.js";
|
||||
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-workspace-state.js";
|
||||
|
||||
type ManifestModelIdNormalizationLookupParams = {
|
||||
@@ -34,7 +39,7 @@ function resolveMetadataSnapshotForPolicies(
|
||||
const env = params.env ?? process.env;
|
||||
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
|
||||
if (params.config === undefined) {
|
||||
const currentSnapshot = getCurrentPluginMetadataSnapshot({
|
||||
const currentSnapshot = getCurrentPluginMetadataSnapshotRuntime({
|
||||
env,
|
||||
workspaceDir,
|
||||
allowWorkspaceScopedSnapshot: true,
|
||||
@@ -48,12 +53,15 @@ function resolveMetadataSnapshotForPolicies(
|
||||
};
|
||||
}
|
||||
}
|
||||
const snapshot = resolvePluginMetadataSnapshot({
|
||||
const snapshot = resolvePluginMetadataSnapshotRuntime({
|
||||
config: params.config ?? {},
|
||||
env,
|
||||
workspaceDir,
|
||||
allowWorkspaceScopedCurrent: true,
|
||||
});
|
||||
if (!snapshot) {
|
||||
return { plugins: [], cacheable: false };
|
||||
}
|
||||
return {
|
||||
plugins: snapshot.plugins,
|
||||
configFingerprint: snapshot.configFingerprint,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Lazy bridge for plugin metadata snapshot reads. The snapshot modules pull
|
||||
* the control-plane context (installed-plugin index/kysely), which light
|
||||
* shared modules and doctor closures must not cold-load at import time.
|
||||
*
|
||||
* The snapshot module registers its reader here at eval time, so any process
|
||||
* that published or scoped a snapshot serves reads through the registered
|
||||
* instance. The require fallback only covers cold processes that never loaded
|
||||
* the metadata system (built code loads .js; source/jiti paths resolve .ts).
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
|
||||
type CurrentSnapshotModule = Pick<
|
||||
typeof import("./current-plugin-metadata-snapshot.js"),
|
||||
"getCurrentPluginMetadataSnapshot"
|
||||
>;
|
||||
type SnapshotLoaderModule = Pick<
|
||||
typeof import("./plugin-metadata-snapshot.js"),
|
||||
"resolvePluginMetadataSnapshot"
|
||||
>;
|
||||
|
||||
type SnapshotReaderSlot = {
|
||||
getCurrentPluginMetadataSnapshot?: CurrentSnapshotModule["getCurrentPluginMetadataSnapshot"];
|
||||
resolvePluginMetadataSnapshot?: SnapshotLoaderModule["resolvePluginMetadataSnapshot"];
|
||||
};
|
||||
|
||||
// globalThis-keyed so a require-loaded second module instance shares the slot.
|
||||
const snapshotReaderSlot = resolveGlobalSingleton<SnapshotReaderSlot>(
|
||||
Symbol.for("openclaw.pluginMetadataSnapshotReaders"),
|
||||
() => ({}),
|
||||
);
|
||||
|
||||
/** Called by the snapshot modules at eval time; last registration wins. */
|
||||
export function registerPluginMetadataSnapshotReaders(readers: SnapshotReaderSlot): void {
|
||||
Object.assign(snapshotReaderSlot, readers);
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function createModuleLoader(candidates: readonly string[]): () => unknown {
|
||||
let loaded: unknown;
|
||||
let attempted = false;
|
||||
return () => {
|
||||
if (loaded) {
|
||||
return loaded;
|
||||
}
|
||||
if (attempted) {
|
||||
return null;
|
||||
}
|
||||
attempted = true;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
loaded = require(candidate);
|
||||
return loaded;
|
||||
} catch {
|
||||
// Try source/runtime candidates in order.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
const loadCurrentSnapshotModule = createModuleLoader([
|
||||
"./current-plugin-metadata-snapshot.js",
|
||||
"./current-plugin-metadata-snapshot.ts",
|
||||
]) as () => CurrentSnapshotModule | null;
|
||||
const loadSnapshotLoaderModule = createModuleLoader([
|
||||
"./plugin-metadata-snapshot.js",
|
||||
"./plugin-metadata-snapshot.ts",
|
||||
]) as () => SnapshotLoaderModule | null;
|
||||
|
||||
/** Reads the current plugin metadata snapshot, loading the snapshot graph lazily. */
|
||||
export function getCurrentPluginMetadataSnapshotRuntime(
|
||||
params: Parameters<CurrentSnapshotModule["getCurrentPluginMetadataSnapshot"]>[0],
|
||||
): ReturnType<CurrentSnapshotModule["getCurrentPluginMetadataSnapshot"]> {
|
||||
const reader =
|
||||
snapshotReaderSlot.getCurrentPluginMetadataSnapshot ??
|
||||
loadCurrentSnapshotModule()?.getCurrentPluginMetadataSnapshot;
|
||||
return reader?.(params) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a plugin metadata snapshot, or undefined when the metadata system
|
||||
* is unavailable (cold test workers without a CJS TS hook); callers treat that
|
||||
* as "no manifest policies exist".
|
||||
*/
|
||||
export function resolvePluginMetadataSnapshotRuntime(
|
||||
params: Parameters<SnapshotLoaderModule["resolvePluginMetadataSnapshot"]>[0],
|
||||
): ReturnType<SnapshotLoaderModule["resolvePluginMetadataSnapshot"]> | undefined {
|
||||
const resolver =
|
||||
snapshotReaderSlot.resolvePluginMetadataSnapshot ??
|
||||
loadSnapshotLoaderModule()?.resolvePluginMetadataSnapshot;
|
||||
return resolver?.(params);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js";
|
||||
import { buildPluginMetadataProviderFacts } from "./plugin-metadata-provider-facts.js";
|
||||
import { registerPluginMetadataSnapshotReaders } from "./plugin-metadata-snapshot.runtime.js";
|
||||
import type {
|
||||
LoadPluginMetadataSnapshotParams,
|
||||
PluginMetadataSnapshot,
|
||||
@@ -425,3 +426,8 @@ function loadPluginMetadataSnapshotImpl(
|
||||
discovery: registryResult.discovery,
|
||||
};
|
||||
}
|
||||
|
||||
// Light bridges (plugin-metadata-snapshot.runtime.ts) serve loads through this
|
||||
// instance whenever the metadata system is loaded; the require fallback only
|
||||
// covers cold processes.
|
||||
registerPluginMetadataSnapshotReaders({ resolvePluginMetadataSnapshot });
|
||||
|
||||
Reference in New Issue
Block a user