From 7975bc06ac09f8a77528eebfbdc9da8058caec0a Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Mon, 1 Jun 2026 08:10:21 -0700 Subject: [PATCH] clawdbot-13a: migrate bundled session store callers --- .../native-command-model-picker-apply.ts | 53 +++++++------- .../native-command.model-picker.test.ts | 14 ++-- .../src/monitor/thread-session-close.test.ts | 69 ++++++++++++++++--- .../src/monitor/thread-session-close.ts | 43 ++++++++---- extensions/telegram/src/bot-deps.ts | 15 +++- .../telegram/src/bot-handlers.runtime.ts | 48 ++++++++----- .../src/bot.create-telegram-bot.test.ts | 6 +- extensions/telegram/src/bot.ts | 38 +++++++++- .../auto-reply/web-auto-reply-utils.test.ts | 8 ++- scripts/check-session-accessor-boundary.mjs | 58 ++++++++++++---- .../check-session-accessor-boundary.test.ts | 52 ++++++++++---- 11 files changed, 299 insertions(+), 105 deletions(-) diff --git a/extensions/discord/src/monitor/native-command-model-picker-apply.ts b/extensions/discord/src/monitor/native-command-model-picker-apply.ts index b494d5555db..33ba997ba71 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-apply.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-apply.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { resolveStorePath, updateSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; +import { patchSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime"; import type { ButtonInteraction, StringSelectMenuInteraction } from "../internal/discord.js"; import { @@ -42,34 +42,39 @@ async function persistDiscordModelPickerOverride(params: { agentId: params.route.agentId, }); let persisted = false; - await updateSessionStore(storePath, (store) => { - const entry = store[params.route.sessionKey] ?? { + await patchSessionEntry({ + storePath, + sessionKey: params.route.sessionKey, + fallbackEntry: { sessionId: randomUUID(), updatedAt: Date.now(), - }; - store[params.route.sessionKey] = entry; - persisted = - applyModelOverrideToSessionEntry({ - entry, - selection: { - provider: params.provider, - model: params.model, - isDefault: params.isDefault, - }, - markLiveSwitchPending: true, - }).updated || persisted; - const runtime = params.runtime?.trim(); - if (runtime && runtime !== "auto" && runtime !== "default") { - if (entry.agentRuntimeOverride !== runtime) { - entry.agentRuntimeOverride = runtime; + }, + replaceEntry: true, + update: (entry) => { + persisted = + applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: params.provider, + model: params.model, + isDefault: params.isDefault, + }, + markLiveSwitchPending: true, + }).updated || persisted; + const runtime = params.runtime?.trim(); + if (runtime && runtime !== "auto" && runtime !== "default") { + if (entry.agentRuntimeOverride !== runtime) { + entry.agentRuntimeOverride = runtime; + delete entry.agentHarnessId; + persisted = true; + } + } else if (runtime && entry.agentRuntimeOverride) { + delete entry.agentRuntimeOverride; delete entry.agentHarnessId; persisted = true; } - } else if (runtime && entry.agentRuntimeOverride) { - delete entry.agentRuntimeOverride; - delete entry.agentHarnessId; - persisted = true; - } + return entry; + }, }); return persisted; } diff --git a/extensions/discord/src/monitor/native-command.model-picker.test.ts b/extensions/discord/src/monitor/native-command.model-picker.test.ts index 8ff498b7b10..fd2bfc394ac 100644 --- a/extensions/discord/src/monitor/native-command.model-picker.test.ts +++ b/extensions/discord/src/monitor/native-command.model-picker.test.ts @@ -14,7 +14,7 @@ import * as globalsModule from "openclaw/plugin-sdk/runtime-env"; import { loadSessionStore, resolveStorePath, - saveSessionStore, + upsertSessionEntry, } from "openclaw/plugin-sdk/session-store-runtime"; import * as commandTextModule from "openclaw/plugin-sdk/text-utility-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -814,8 +814,10 @@ describe("Discord model picker interactions", () => { }); const modelCommand = createModelCommandDefinition(); const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" }); - await saveSessionStore(storePath, { - "agent:worker:subagent:bound": { + await upsertSessionEntry({ + storePath, + sessionKey: "agent:worker:subagent:bound", + entry: { updatedAt: Date.now(), sessionId: "bound-session", }, @@ -864,8 +866,10 @@ describe("Discord model picker interactions", () => { const pickerData = createDefaultModelPickerData(); const modelCommand = createModelCommandDefinition(); const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" }); - await saveSessionStore(storePath, { - "agent:worker:subagent:bound": { + await upsertSessionEntry({ + storePath, + sessionKey: "agent:worker:subagent:bound", + entry: { updatedAt: Date.now(), sessionId: "bound-session", }, diff --git a/extensions/discord/src/monitor/thread-session-close.test.ts b/extensions/discord/src/monitor/thread-session-close.test.ts index e44324d42f2..c96784f7c5b 100644 --- a/extensions/discord/src/monitor/thread-session-close.test.ts +++ b/extensions/discord/src/monitor/thread-session-close.test.ts @@ -2,9 +2,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => { - const updateSessionStore = vi.fn(); + const listSessionEntries = vi.fn(); + const patchSessionEntry = vi.fn(); const resolveStorePath = vi.fn(() => "/tmp/openclaw-sessions.json"); - return { updateSessionStore, resolveStorePath }; + return { listSessionEntries, patchSessionEntry, resolveStorePath }; }); vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { @@ -13,16 +14,37 @@ vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { ); return { ...actual, - updateSessionStore: hoisted.updateSessionStore, + listSessionEntries: hoisted.listSessionEntries, + patchSessionEntry: hoisted.patchSessionEntry, resolveStorePath: hoisted.resolveStorePath, }; }); let closeDiscordThreadSessions: typeof import("./thread-session-close.js").closeDiscordThreadSessions; -function setupStore(store: Record) { - hoisted.updateSessionStore.mockImplementation( - async (_storePath: string, mutator: (s: typeof store) => unknown) => mutator(store), +function setupStore(store: Record) { + hoisted.listSessionEntries.mockImplementation(() => + Object.entries(store).map(([sessionKey, entry]) => ({ sessionKey, entry })), + ); + hoisted.patchSessionEntry.mockImplementation( + async (params: { + sessionKey: string; + update: (entry: { + sessionId?: string; + updatedAt: number; + }) => { sessionId?: string; updatedAt: number } | null; + }) => { + const entry = store[params.sessionKey]; + if (!entry) { + return null; + } + const next = params.update({ ...entry }); + if (!next) { + return entry; + } + store[params.sessionKey] = next; + return next; + }, ); } @@ -38,7 +60,8 @@ describe("closeDiscordThreadSessions", () => { }); beforeEach(() => { - hoisted.updateSessionStore.mockClear(); + hoisted.listSessionEntries.mockReset(); + hoisted.patchSessionEntry.mockReset(); hoisted.resolveStorePath.mockClear(); hoisted.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.json"); }); @@ -143,7 +166,8 @@ describe("closeDiscordThreadSessions", () => { }); expect(count).toBe(0); - expect(hoisted.updateSessionStore).not.toHaveBeenCalled(); + expect(hoisted.listSessionEntries).not.toHaveBeenCalled(); + expect(hoisted.patchSessionEntry).not.toHaveBeenCalled(); }); it("does not recount sessions that were already reset", async () => { @@ -164,6 +188,35 @@ describe("closeDiscordThreadSessions", () => { expect(store[UNMATCHED_KEY].updatedAt).toBe(1_700_000_000_001); }); + it("does not reset a matching session that changed after the list snapshot", async () => { + const store = { + [MATCHED_KEY]: { + sessionId: "fresh-session", + updatedAt: 2_000, + }, + }; + setupStore(store); + hoisted.listSessionEntries.mockReturnValue([ + { + sessionKey: MATCHED_KEY, + entry: { + sessionId: "old-session", + updatedAt: 1_000, + }, + }, + ]); + + const count = await closeDiscordThreadSessions({ + cfg: {}, + accountId: "default", + threadId: THREAD_ID, + }); + + expect(count).toBe(0); + expect(store[MATCHED_KEY].updatedAt).toBe(2_000); + expect(store[MATCHED_KEY].sessionId).toBe("fresh-session"); + }); + it("resolves the store path using cfg.session.store and accountId", async () => { const store = {}; setupStore(store); diff --git a/extensions/discord/src/monitor/thread-session-close.ts b/extensions/discord/src/monitor/thread-session-close.ts index c1314309d42..3904d1e6d18 100644 --- a/extensions/discord/src/monitor/thread-session-close.ts +++ b/extensions/discord/src/monitor/thread-session-close.ts @@ -1,6 +1,10 @@ // Discord plugin module implements thread session close behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { resolveStorePath, updateSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; +import { + listSessionEntries, + patchSessionEntry, + resolveStorePath, +} from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; /** @@ -44,21 +48,32 @@ export async function closeDiscordThreadSessions(params: { let resetCount = 0; - await updateSessionStore(storePath, (store) => { - for (const [key, entry] of Object.entries(store)) { - if (!entry || !sessionKeyContainsThreadId(key)) { - continue; - } - if (entry.updatedAt === 0) { - continue; - } - // Setting updatedAt to 0 signals that this session is stale. - // evaluateSessionFreshness will create a new session on the next message. - entry.updatedAt = 0; + for (const { sessionKey, entry } of listSessionEntries({ storePath })) { + if (!sessionKeyContainsThreadId(sessionKey) || entry.updatedAt === 0) { + continue; + } + // Setting updatedAt to 0 signals that this session is stale. + // evaluateSessionFreshness will create a new session on the next message. + let resetEntry = false; + await patchSessionEntry({ + storePath, + sessionKey, + replaceEntry: true, + update: (current) => { + if (current.updatedAt === 0) { + return null; + } + if (current.updatedAt !== entry.updatedAt || current.sessionId !== entry.sessionId) { + return null; + } + resetEntry = true; + return { ...current, updatedAt: 0 }; + }, + }); + if (resetEntry) { resetCount += 1; } - return resetCount; - }); + } return resetCount; } diff --git a/extensions/telegram/src/bot-deps.ts b/extensions/telegram/src/bot-deps.ts index 5f6c2431f99..e09c8951837 100644 --- a/extensions/telegram/src/bot-deps.ts +++ b/extensions/telegram/src/bot-deps.ts @@ -15,7 +15,12 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/re import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; -import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { + getSessionEntry, + listSessionEntries, + readSessionUpdatedAt, + resolveStorePath, +} from "openclaw/plugin-sdk/session-store-runtime"; import { loadSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/skill-commands-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; @@ -31,6 +36,8 @@ import { wasSentByBot } from "./sent-message-cache.js"; export type TelegramBotDeps = { getRuntimeConfig: typeof getRuntimeConfig; resolveStorePath: typeof resolveStorePath; + getSessionEntry?: typeof getSessionEntry; + listSessionEntries?: typeof listSessionEntries; loadSessionStore?: typeof loadSessionStore; readSessionUpdatedAt?: typeof readSessionUpdatedAt; recordInboundSession?: typeof recordInboundSession; @@ -64,6 +71,12 @@ export const defaultTelegramBotDeps: TelegramBotDeps = { get resolveStorePath() { return resolveStorePath; }, + get getSessionEntry() { + return getSessionEntry; + }, + get listSessionEntries() { + return listSessionEntries; + }, get readChannelAllowFromStore() { return readChannelAllowFromStore; }, diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index a7170ec697e..b5b93ed8c75 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -1,4 +1,5 @@ // Telegram plugin module implements bot handlers behavior. +import { randomUUID } from "node:crypto"; import type { Message, ReactionTypeEmoji } from "grammy/types"; import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers"; @@ -36,9 +37,9 @@ import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { - loadSessionStore, - resolveSessionStoreEntry, - updateSessionStore, + getSessionEntry, + listSessionEntries, + patchSessionEntry, } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; @@ -666,7 +667,7 @@ export const registerTelegramHandlers = ({ runtimeCfg?: OpenClawConfig; }): { agentId: string; - sessionEntry: ReturnType["existing"]; + sessionEntry: ReturnType; sessionKey: string; model?: string; } => { @@ -708,8 +709,12 @@ export const registerTelegramHandlers = ({ const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, { agentId: route.agentId, }); - const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath); - const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; + const entry = (telegramDeps.getSessionEntry ?? getSessionEntry)({ storePath, sessionKey }); + const store = Object.fromEntries( + (telegramDeps.listSessionEntries ?? listSessionEntries)({ storePath }).map( + ({ sessionKey: key, entry: value }) => [key, value], + ), + ); const storedOverride = resolveStoredModelOverride({ sessionEntry: entry, sessionStore: store, @@ -2716,18 +2721,25 @@ export const registerTelegramHandlers = ({ selection.model === resolvedDefault.model; try { - await updateSessionStore(storePath, (store) => { - const sessionKey = sessionState.sessionKey; - const entry = store[sessionKey] ?? {}; - store[sessionKey] = entry; - applyModelOverrideToSessionEntry({ - entry, - selection: { - provider: selection.provider, - model: selection.model, - isDefault: isDefaultSelection, - }, - }); + await patchSessionEntry({ + storePath, + sessionKey: sessionState.sessionKey, + fallbackEntry: { + sessionId: randomUUID(), + updatedAt: Date.now(), + }, + replaceEntry: true, + update: (entry) => { + applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: selection.provider, + model: selection.model, + isDefault: isDefaultSelection, + }, + }); + return entry; + }, }); } catch (err) { throw new TelegramRetryableCallbackError(err); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 418cca6edcc..9961ae23d65 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -5391,8 +5391,8 @@ describe("createTelegramBot", () => { await dispatch(0); }; - const updateSessionStoreSpy = vi.spyOn(sessionStoreRuntime, "updateSessionStore"); - updateSessionStoreSpy.mockRejectedValueOnce(new Error("session store boom")); + const patchSessionEntrySpy = vi.spyOn(sessionStoreRuntime, "patchSessionEntry"); + patchSessionEntrySpy.mockRejectedValueOnce(new Error("session store boom")); const ctx = { update: { update_id: 890 }, @@ -5414,7 +5414,7 @@ describe("createTelegramBot", () => { await expect(runMiddlewareChain(ctx)).rejects.toThrow("session store boom"); await runMiddlewareChain(ctx); } finally { - updateSessionStoreSpy.mockRestore(); + patchSessionEntrySpy.mockRestore(); } expect(editMessageTextSpy).toHaveBeenCalledTimes(1); diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index db50e00848b..d2c570d69ba 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -1,10 +1,11 @@ // Telegram plugin module implements bot behavior. +import { getSessionEntry, listSessionEntries } from "openclaw/plugin-sdk/session-store-runtime"; import { createTelegramBotCore, getTelegramSequentialKey, setTelegramBotRuntimeForTest, } from "./bot-core.js"; -import { defaultTelegramBotDeps } from "./bot-deps.js"; +import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js"; import type { TelegramBotOptions } from "./bot.types.js"; export type { TelegramBotOptions } from "./bot.types.js"; @@ -16,6 +17,39 @@ export function createTelegramBot( ): ReturnType { return createTelegramBotCore({ ...opts, - telegramDeps: opts.telegramDeps ?? defaultTelegramBotDeps, + telegramDeps: withTelegramSessionAccessorDeps(opts.telegramDeps ?? defaultTelegramBotDeps), }); } + +function withTelegramSessionAccessorDeps(deps: TelegramBotDeps): TelegramBotDeps { + if (!deps.loadSessionStore) { + return { + ...deps, + getSessionEntry: deps.getSessionEntry ?? getSessionEntry, + listSessionEntries: deps.listSessionEntries ?? listSessionEntries, + }; + } + + const listInjectedEntries = ( + scope: Parameters>[0] = {}, + ) => { + const storePath = + scope.storePath ?? deps.resolveStorePath(undefined, { agentId: scope.agentId }); + return Object.entries(deps.loadSessionStore?.(storePath) ?? {}).map(([sessionKey, entry]) => ({ + sessionKey, + entry, + })); + }; + + return { + ...deps, + // Existing Telegram tests and custom deps inject loadSessionStore; expose + // the same data through the accessor seam consumed by migrated handlers. + getSessionEntry: + deps.getSessionEntry ?? + ((scope) => + listInjectedEntries(scope).find(({ sessionKey }) => sessionKey === scope.sessionKey) + ?.entry), + listSessionEntries: deps.listSessionEntries ?? listInjectedEntries, + }; +} diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index 23b1e07866c..b468689ba81 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; -import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; +import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { withTempDir } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it, vi } from "vitest"; import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js"; @@ -277,8 +277,10 @@ describe("getSessionSnapshot", () => { const storePath = path.join(root, "sessions.json"); const sessionKey = "agent:main:whatsapp:dm:s1"; - await saveSessionStore(storePath, { - [sessionKey]: { + await upsertSessionEntry({ + storePath, + sessionKey, + entry: { sessionId: "snapshot-session", updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), lastChannel: "whatsapp", diff --git a/scripts/check-session-accessor-boundary.mjs b/scripts/check-session-accessor-boundary.mjs index 3a9d3119d11..b3185b2e758 100644 --- a/scripts/check-session-accessor-boundary.mjs +++ b/scripts/check-session-accessor-boundary.mjs @@ -18,6 +18,11 @@ const legacyReaderNames = new Set([ "readSessionStoreReadOnly", "resolveSessionStoreEntry", ]); +const legacyWholeStoreAccessNames = new Set([ + ...legacyReaderNames, + "saveSessionStore", + "updateSessionStore", +]); export const migratedSessionAccessorFiles = new Set([ "src/commands/export-trajectory.ts", @@ -36,10 +41,27 @@ export const migratedSessionAccessorFiles = new Set([ "src/infra/outbound/message-action-tts.ts", ]); +export const migratedBundledPluginSessionAccessorFiles = new Set([ + "extensions/discord/src/monitor/native-command-model-picker-apply.ts", + "extensions/discord/src/monitor/thread-session-close.ts", + "extensions/telegram/src/bot-handlers.runtime.ts", +]); + function normalizeRelativePath(filePath) { return filePath.replaceAll(path.sep, "/"); } +function legacyNamesForFile(fileName) { + const normalized = normalizeRelativePath(fileName); + if ( + fileName === "source.ts" || + [...migratedBundledPluginSessionAccessorFiles].some((filePath) => normalized.endsWith(filePath)) + ) { + return legacyWholeStoreAccessNames; + } + return legacyReaderNames; +} + function propertyAccessName(expression) { const unwrapped = unwrapExpression(expression); if (ts.isIdentifier(unwrapped)) { @@ -66,6 +88,7 @@ function bindingName(node) { export function findSessionAccessorBoundaryViolations(content, fileName = "source.ts") { const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const legacyNames = legacyNamesForFile(fileName); const violations = []; const visit = (node) => { @@ -74,10 +97,10 @@ export function findSessionAccessorBoundaryViolations(content, fileName = "sourc if (namedBindings && ts.isNamedImports(namedBindings)) { for (const specifier of namedBindings.elements) { const importedName = specifier.propertyName?.text ?? specifier.name.text; - if (legacyReaderNames.has(importedName)) { + if (legacyNames.has(importedName)) { violations.push({ line: toLine(sourceFile, specifier), - reason: `imports legacy session store reader "${importedName}"`, + reason: `imports legacy session store access "${importedName}"`, }); } } @@ -86,29 +109,29 @@ export function findSessionAccessorBoundaryViolations(content, fileName = "sourc if (ts.isBindingElement(node)) { const name = bindingName(node); - if (name && legacyReaderNames.has(name)) { + if (name && legacyNames.has(name)) { violations.push({ line: toLine(sourceFile, node), - reason: `aliases legacy session store reader "${name}"`, + reason: `aliases legacy session store access "${name}"`, }); } } - if (ts.isPropertyAccessExpression(node) && legacyReaderNames.has(node.name.text)) { + if (ts.isPropertyAccessExpression(node) && legacyNames.has(node.name.text)) { violations.push({ line: toLine(sourceFile, node.name), - reason: `references legacy session store reader "${node.name.text}"`, + reason: `references legacy session store access "${node.name.text}"`, }); } if ( ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) && - legacyReaderNames.has(node.argumentExpression.text) + legacyNames.has(node.argumentExpression.text) ) { violations.push({ line: toLine(sourceFile, node.argumentExpression), - reason: `references legacy session store reader "${node.argumentExpression.text}"`, + reason: `references legacy session store access "${node.argumentExpression.text}"`, }); } @@ -116,12 +139,12 @@ export function findSessionAccessorBoundaryViolations(content, fileName = "sourc const calleeName = propertyAccessName(node.expression); if ( calleeName && - legacyReaderNames.has(calleeName) && + legacyNames.has(calleeName) && ts.isIdentifier(unwrapExpression(node.expression)) ) { violations.push({ line: toLine(sourceFile, node.expression), - reason: `calls legacy session store reader "${calleeName}"`, + reason: `calls legacy session store access "${calleeName}"`, }); } } @@ -136,6 +159,8 @@ export function findSessionAccessorBoundaryViolations(content, fileName = "sourc export async function main() { const repoRoot = resolveRepoRoot(import.meta.url); const sourceRoots = resolveSourceRoots(repoRoot, [ + "extensions/discord/src/monitor", + "extensions/telegram/src", "src/commands", "src/config/sessions", "src/cron", @@ -145,8 +170,13 @@ export async function main() { const violations = await collectFileViolations({ repoRoot, sourceRoots, - skipFile: (filePath) => - !migratedSessionAccessorFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))), + skipFile: (filePath) => { + const relativePath = normalizeRelativePath(path.relative(repoRoot, filePath)); + return ( + !migratedSessionAccessorFiles.has(relativePath) && + !migratedBundledPluginSessionAccessorFiles.has(relativePath) + ); + }, findViolations: findSessionAccessorBoundaryViolations, }); @@ -155,12 +185,12 @@ export async function main() { return; } - console.error("Found legacy session store reader usage in session-accessor migrated files:"); + console.error("Found legacy session store access usage in session-accessor migrated files:"); for (const violation of violations) { console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`); } console.error( - "Use src/config/sessions/session-accessor.ts helpers for migrated read/projection paths. Expand this ratchet only after a slice migrates more files.", + "Use src/config/sessions/session-accessor.ts helpers for migrated paths. Expand this ratchet only after a slice migrates more files.", ); process.exit(1); } diff --git a/test/scripts/check-session-accessor-boundary.test.ts b/test/scripts/check-session-accessor-boundary.test.ts index 04d86dd4e14..fd30b348ba3 100644 --- a/test/scripts/check-session-accessor-boundary.test.ts +++ b/test/scripts/check-session-accessor-boundary.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { findSessionAccessorBoundaryViolations, + migratedBundledPluginSessionAccessorFiles, migratedSessionAccessorFiles, } from "../../scripts/check-session-accessor-boundary.mjs"; @@ -26,6 +27,16 @@ describe("session accessor boundary guard", () => { ); }); + it("ratchets only the bundled plugin files migrated by this slice", () => { + expect(migratedBundledPluginSessionAccessorFiles).toEqual( + new Set([ + "extensions/discord/src/monitor/native-command-model-picker-apply.ts", + "extensions/discord/src/monitor/thread-session-close.ts", + "extensions/telegram/src/bot-handlers.runtime.ts", + ]), + ); + }); + it("flags legacy reader imports", () => { expect( findSessionAccessorBoundaryViolations(` @@ -33,14 +44,14 @@ describe("session accessor boundary guard", () => { import { readSessionEntry, readSessionStoreReadOnly } from "../config/sessions/store-load.js"; `), ).toEqual([ - { line: 2, reason: 'imports legacy session store reader "loadSessionStore"' }, - { line: 2, reason: 'imports legacy session store reader "readSessionEntries"' }, - { line: 3, reason: 'imports legacy session store reader "readSessionEntry"' }, - { line: 3, reason: 'imports legacy session store reader "readSessionStoreReadOnly"' }, + { line: 2, reason: 'imports legacy session store access "loadSessionStore"' }, + { line: 2, reason: 'imports legacy session store access "readSessionEntries"' }, + { line: 3, reason: 'imports legacy session store access "readSessionEntry"' }, + { line: 3, reason: 'imports legacy session store access "readSessionStoreReadOnly"' }, ]); }); - it("flags direct and namespace legacy reader calls", () => { + it("flags direct and namespace legacy access calls", () => { expect( findSessionAccessorBoundaryViolations(` loadSessionStore(storePath); @@ -50,11 +61,11 @@ describe("session accessor boundary guard", () => { resolveSessionStoreEntry({ store, sessionKey }); `), ).toEqual([ - { line: 2, reason: 'calls legacy session store reader "loadSessionStore"' }, - { line: 3, reason: 'references legacy session store reader "readSessionEntries"' }, - { line: 4, reason: 'references legacy session store reader "loadSessionStore"' }, - { line: 5, reason: 'calls legacy session store reader "readSessionStoreReadOnly"' }, - { line: 6, reason: 'calls legacy session store reader "resolveSessionStoreEntry"' }, + { line: 2, reason: 'calls legacy session store access "loadSessionStore"' }, + { line: 3, reason: 'references legacy session store access "readSessionEntries"' }, + { line: 4, reason: 'references legacy session store access "loadSessionStore"' }, + { line: 5, reason: 'calls legacy session store access "readSessionStoreReadOnly"' }, + { line: 6, reason: 'calls legacy session store access "resolveSessionStoreEntry"' }, ]); }); @@ -66,9 +77,24 @@ describe("session accessor boundary guard", () => { const { loadSessionStore } = sessions; `), ).toEqual([ - { line: 2, reason: 'references legacy session store reader "loadSessionStore"' }, - { line: 3, reason: 'aliases legacy session store reader "readSessionEntries"' }, - { line: 4, reason: 'aliases legacy session store reader "loadSessionStore"' }, + { line: 2, reason: 'references legacy session store access "loadSessionStore"' }, + { line: 3, reason: 'aliases legacy session store access "readSessionEntries"' }, + { line: 4, reason: 'aliases legacy session store access "loadSessionStore"' }, + ]); + }); + + it("flags legacy whole-store writes", () => { + expect( + findSessionAccessorBoundaryViolations(` + import { saveSessionStore, updateSessionStore } from "../config/sessions.js"; + saveSessionStore(storePath, store); + updateSessionStore(storePath, update); + `), + ).toEqual([ + { line: 2, reason: 'imports legacy session store access "saveSessionStore"' }, + { line: 2, reason: 'imports legacy session store access "updateSessionStore"' }, + { line: 3, reason: 'calls legacy session store access "saveSessionStore"' }, + { line: 4, reason: 'calls legacy session store access "updateSessionStore"' }, ]); });