refactor(channels): drop pre-July-2026 Matrix state migrations (#163292)

## What Problem This Solves

Retires Matrix state imports whose last shipped JSON writers predate the July 2026 migration support window.

## User Impact

Upgrades from pre-July-2026 versions are no longer migrated: Matrix `thread-bindings.json` and `startup-verification.json`. Doctor and the affected runtime preserve those files and explain how to upgrade through OpenClaw 2026.9.5: run Doctor, start Matrix once to import them, then install the latest release.

July and newer SQLite bindings, verification cooldowns, device metadata, token-root selection, and schema upgrades remain supported. Public plugin SDK compatibility and other Matrix migrations are unchanged.

## Why This Change Was Made

Both file writers ended in `84a965a1a2` on May 30. The last confirmed stable JSON writer, `v2026.5.28`, was published May 30; `v2026.5.30-beta.1` first shipped SQLite on May 31, followed by stable `v2026.6.1` on June 3. Inspection of `v2026.7.1` confirms the same SQLite namespaces and keys, with no JSON rewrite or restoration path. The 2026.9.5 bridge retains both runtime imports.

Deletes JSON decoding, merging, import-marker stores, cleanup writes, and the obsolete thread-binding JSON root score. The plugin owns the source-preserving refusal; existing Doctor ordering and schema migration ownership stay intact. Net production reduction: 140 lines.

Other channel config aliases remain: supported July config rewrites can still preserve them. Separate owner follow-ups will cover independently proven formats.

## Evidence

- Independent isolated review: no actionable findings through P2.
- Matrix runtime/storage/Doctor boundary tests passed, including July-shaped binding keys and verification cooldowns, unchanged retired-file bytes, and archive exclusion.
- The intact shipped `v2026.7.1` account SQLite fixture upgraded through the actual Doctor CLI; canonical rows and archived database bytes remained intact.
- Doctor state/config migration suites passed. Plugin contracts: **1,132 tests / 48 files passed**.
- `node scripts/check-changed.mjs` passed; both `check-madge-import-cycles.ts` and `check-import-cycles.ts` report **0**.
- Linux single-worker wall measurements (`pnpm test <file> --maxWorkers=1`): Doctor import contract **2.89s**, storage **29.49s**, bindings **15.10s**, startup verification **13.73s**. No new process boot, timer, retry, or weakened assertion was added.

Proof used an isolated AWS checkout pinned to the base and byte-verified candidate patch. Hosted CI will validate the pushed head. Release history and retention decisions were checked separately from test execution.
This commit is contained in:
Peter Steinberger 2026-10-02 01:07:09 -05:00 • committed by GitHub
parent 8095d778b0
commit db793644e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 257 additions and 230 deletions

View file

@ -34,6 +34,16 @@ Doctor migration covers:
- importing file-based sidecar state (`bot-storage.json` sync cache, `recovery-key.json`, `legacy-crypto-migration.json`, IndexedDB snapshots) into Matrix SQLite state; migrated files are archived with a `.migrated` suffix
- reusing the most complete existing token-hash storage root for the same Matrix account, homeserver, user, and device when the access token changes later
## Retired pre-July state files
Current releases support state formats written on or after July 1, 2026.
Matrix thread bindings and startup-verification cooldowns already used SQLite
by then. Their older `thread-bindings.json` and `startup-verification.json`
imports are retired. Doctor and the affected runtime refuse these files and
leave them unchanged. If they remain in an active Matrix storage root, first
install OpenClaw `2026.9.5`, run `openclaw doctor --fix`, and start the Matrix
channel once to complete the import. Then upgrade to the latest release.
## Upgrading from OpenClaw releases older than 2026.4
Releases through the 2026.6 train also migrated the original flat single-store

View file

@ -22,6 +22,59 @@ vi.mock("./src/account-selection.js", () => {
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
it("refuses retired JSON state without changing it or inspecting token-root archives", async () => {
const stateDir = tempDirs.make("matrix-retired-state-");
const sources = [
path.join(stateDir, "matrix", "accounts", "default", "thread-bindings.json"),
path.join(
stateDir,
"matrix",
"accounts",
"ops",
"matrix.example.org__bot",
"0123456789abcdef",
"startup-verification.json",
),
];
const archive = path.join(
stateDir,
"matrix",
"accounts",
"ops",
"matrix.example.org__bot",
"sync-cache-backup",
"thread-bindings.json",
);
for (const file of [...sources, archive]) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '{"retained":true}\n');
}
const { stateMigrations } = await import("./doctor-contract-api.js");
const migration = stateMigrations.find((entry) => entry.id === "matrix-account-sqlite-schema")!;
const openPluginStateKeyedStore = vi.fn(() => {
throw new Error("retired state must not open a store");
});
const params = {
config: {},
env: { HOME: stateDir, OPENCLAW_STATE_DIR: stateDir },
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: { openPluginStateKeyedStore },
};
for (const run of [migration.detectLegacyState, migration.migrateLegacyState]) {
const result = run(params);
await expect(result).rejects.toThrow("Install OpenClaw 2026.9.5");
for (const file of sources) {
await expect(result).rejects.toThrow(file);
}
await expect(result).rejects.not.toThrow(archive);
}
expect(openPluginStateKeyedStore).not.toHaveBeenCalled();
for (const file of [...sources, archive]) {
expect(fs.readFileSync(file, "utf8")).toBe('{"retained":true}\n');
}
});
it("completes absent legacy-state checks without loading client runtimes", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-import-");
const { stateMigrations } = await import("./doctor-contract-api.js");

View file

@ -2,6 +2,7 @@
import path from "node:path";
import type { OpenClawStateDatabaseSchemaMigration } from "openclaw/plugin-sdk/doctor-repair-runtime";
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations";
import { describeRetiredMatrixState } from "./retired-state.js";
import { resolveMatrixSqliteStateEnv } from "./sqlite-state.js";
import { walkMatrixStateFiles } from "./state-layout-walk.js";
@ -10,12 +11,19 @@ const STATE_DATABASE_FILENAME = "openclaw.sqlite";
async function collectMatrixAccountStateRoots(stateDir: string): Promise<string[]> {
const { entries, failedDirs } = await walkMatrixStateFiles(
stateDir,
(name, depth) => depth === 5 && name === STATE_DATABASE_FILENAME,
(name, depth) =>
(depth === 5 && name === STATE_DATABASE_FILENAME) ||
((depth === 2 || depth === 4) &&
(name === "thread-bindings.json" || name === "startup-verification.json")),
[2, 4],
);
if (failedDirs.length > 0) {
throw failedDirs[0]!.error;
}
const retired = entries.filter((entry) => path.basename(entry.path) !== STATE_DATABASE_FILENAME);
if (retired.length > 0) {
throw new Error(retired.map((entry) => describeRetiredMatrixState(entry.path)).join("\n"));
}
return entries.map((entry) => path.dirname(path.dirname(entry.path))).toSorted();
}

View file

@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@ -116,8 +117,8 @@ describe("matrix client storage paths", () => {
}
async function setupCurrentTokenBackfillScenario(params: {
currentRootFiles: "thread-bindings" | "startup-verification";
oldRootFiles: "crypto-only" | "thread-bindings";
currentRootState: "thread-bindings" | "startup-verification";
oldRootState: "crypto-only" | "thread-bindings";
}) {
const stateDir = setupStateDir();
const canonicalPaths = resolveMatrixAccountStorageRoot({
@ -134,29 +135,15 @@ describe("matrix client storage paths", () => {
accessTokenHash: canonicalPaths.tokenHash,
deviceId: null,
});
if (params.currentRootFiles === "thread-bindings") {
writeJson(canonicalPaths.rootDir, "thread-bindings.json", {
version: 1,
bindings: [
{
accountId: "default",
conversationId: "$thread-new",
targetKind: "subagent",
targetSessionKey: "agent:ops:subagent:new",
boundAt: 1,
lastActivityAt: 1,
},
],
});
if (params.currentRootState === "thread-bindings") {
seedThreadBinding(canonicalPaths.rootDir, "new");
expect(
await claimCurrentTokenStorageState({
rootDir: canonicalPaths.rootDir,
}),
).toBe(true);
} else {
writeJson(canonicalPaths.rootDir, "startup-verification.json", {
deviceId: "DEVICE123",
});
seedStartupVerification(canonicalPaths.rootDir, "DEVICE123");
}
const oldStoragePaths = await seedExistingStorageRoot({
@ -165,24 +152,10 @@ describe("matrix client storage paths", () => {
storageMeta: await storageMetaFor("secret-token-old"),
});
fs.mkdirSync(oldStoragePaths.cryptoPath, { recursive: true });
if (params.oldRootFiles === "thread-bindings") {
writeJson(oldStoragePaths.rootDir, "thread-bindings.json", {
version: 1,
bindings: [
{
accountId: "default",
conversationId: "$thread-old",
targetKind: "subagent",
targetSessionKey: "agent:ops:subagent:old",
boundAt: 1,
lastActivityAt: 1,
},
],
});
if (params.oldRootState === "thread-bindings") {
seedThreadBinding(oldStoragePaths.rootDir, "old");
} else {
writeJson(oldStoragePaths.rootDir, "startup-verification.json", {
deviceId: "DEVICE123",
});
seedStartupVerification(oldStoragePaths.rootDir, "DEVICE123");
}
return { stateDir, canonicalPaths, oldStoragePaths };
@ -243,6 +216,38 @@ describe("matrix client storage paths", () => {
).register("current", value);
}
function seedThreadBinding(rootDir: string, suffix: "new" | "old"): void {
const conversationId = `$thread-${suffix}`;
const digest = createHash("sha256").update(`default\0\0${conversationId}`).digest("hex");
createPluginStateSyncKeyedStoreForTests("matrix", {
namespace: "thread-bindings",
maxEntries: 10_000,
env: { OPENCLAW_STATE_DIR: rootDir },
}).register(`default:${digest}`, {
accountId: "default",
conversationId,
targetKind: "subagent",
targetSessionKey: `agent:ops:subagent:${suffix}`,
boundAt: 1,
lastActivityAt: 1,
});
}
function seedStartupVerification(rootDir: string, deviceId: string): void {
createPluginStateSyncKeyedStoreForTests("matrix", {
namespace: "startup-verification",
maxEntries: 1_000,
env: { OPENCLAW_STATE_DIR: rootDir },
}).register("default", {
userId: defaultStorageAuth.userId,
deviceId,
attemptedAt: "2026-07-13T12:00:00.000Z",
outcome: "requested",
requestId: "verification-1",
transactionId: "txn-1",
});
}
function seedLegacyStorageMeta(rootDir: string, value: Record<string, unknown>): void {
fs.mkdirSync(rootDir, { recursive: true });
writeJson(rootDir, "storage-meta.json", value);
@ -395,9 +400,7 @@ describe("matrix client storage paths", () => {
seedStorageMeta(storagePaths.rootDir, params.storageMeta);
}
if (params.startupVerificationDeviceId) {
writeJson(storagePaths.rootDir, "startup-verification.json", {
deviceId: params.startupVerificationDeviceId,
});
seedStartupVerification(storagePaths.rootDir, params.startupVerificationDeviceId);
}
return storagePaths;
}
@ -779,7 +782,7 @@ describe("matrix client storage paths", () => {
);
});
it.each(["thread-bindings.json", "recovery-key.json", "crypto-idb-snapshot.json"])(
it.each(["recovery-key.json", "crypto-idb-snapshot.json"])(
"keeps a legacy %s root selectable until its state migrates",
async (legacyFilename) => {
const stateDir = setupStateDir();
@ -886,8 +889,8 @@ describe("matrix client storage paths", () => {
it("keeps the current-token storage root stable after deviceId backfill when startup claimed state there", async () => {
const { stateDir, canonicalPaths } = await setupCurrentTokenBackfillScenario({
currentRootFiles: "thread-bindings",
oldRootFiles: "crypto-only",
currentRootState: "thread-bindings",
oldRootState: "crypto-only",
});
await repairCurrentTokenStorageMetaDeviceId({
@ -911,10 +914,10 @@ describe("matrix client storage paths", () => {
expect(restartedPaths.rootDir).toBe(canonicalPaths.rootDir);
});
it("does not keep the current-token storage root sticky when only marker files exist after backfill", async () => {
it("does not keep the current-token storage root sticky when only startup verification state exists after backfill", async () => {
const { stateDir, oldStoragePaths } = await setupCurrentTokenBackfillScenario({
currentRootFiles: "startup-verification",
oldRootFiles: "thread-bindings",
currentRootState: "startup-verification",
oldRootState: "thread-bindings",
});
await repairCurrentTokenStorageMetaDeviceId({

View file

@ -26,7 +26,6 @@ import type { MatrixAuth, MatrixStoragePaths } from "./types.js";
const DEFAULT_ACCOUNT_KEY = "default";
const STORAGE_META_FILENAME = "storage-meta.json";
const THREAD_BINDINGS_FILENAME = "thread-bindings.json";
function openStorageMetaStore(rootDir: string) {
return getMatrixRuntime().state.openKeyedStore<MatrixStorageMetadata>(
@ -44,7 +43,6 @@ async function scoreStorageRoot(rootDir: string, metadata: MatrixStorageMetadata
}
for (const [filename, weight] of [
["crypto", 8],
[THREAD_BINDINGS_FILENAME, 4],
[MATRIX_LEGACY_CRYPTO_MIGRATION_FILENAME, 3],
[MATRIX_RECOVERY_KEY_FILENAME, 2],
[MATRIX_IDB_SNAPSHOT_FILENAME, 2],

View file

@ -199,26 +199,66 @@ describe("ensureMatrixStartupVerification", () => {
expect(harness.client.crypto.requestVerification).toHaveBeenCalledTimes(1);
});
it("supports disabling startup verification requests", async () => {
it.each([
{ mode: "if-unverified" as const, expected: "cooldown" },
{ mode: "off" as const, expected: "disabled" },
])("honors July SQLite startup state in $mode mode", async ({ mode, expected }) => {
const tempHome = createTempStateDir();
const harness = createHarness();
const stateFilePath = createStateFilePath(tempHome);
fs.writeFileSync(stateFilePath, JSON.stringify({ attemptedAt: "2026-03-08T12:00:00.000Z" }));
const store = createPluginStateKeyedStoreForTests("matrix", {
namespace: "startup-verification",
maxEntries: 1_000,
env: { ...process.env, OPENCLAW_STATE_DIR: tempHome },
});
const state = {
userId: "@bot:example.org",
deviceId: "DEVICE123",
attemptedAt: "2026-07-13T12:00:00.000Z",
outcome: "requested",
requestId: "verification-1",
transactionId: "txn-1",
};
await store.register("default", state);
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: createAuth(),
accountConfig: {
startupVerification: "off",
},
accountConfig: { startupVerification: mode },
stateFilePath,
nowMs: Date.parse("2026-07-13T12:01:00.000Z"),
});
expect(result.kind).toBe("disabled");
expect(result.kind).toBe(expected);
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
await expect(store.lookup("default")).resolves.toEqual(mode === "off" ? undefined : state);
expect(fs.existsSync(stateFilePath)).toBe(false);
});
it.each(["if-unverified", "off"] as const)(
"refuses retired startup JSON in %s mode without changing it",
async (startupVerification) => {
const tempHome = createTempStateDir();
const stateFilePath = createStateFilePath(tempHome);
const source = JSON.stringify({ attemptedAt: "2026-05-28T12:00:00.000Z" });
fs.writeFileSync(stateFilePath, source);
const harness = createHarness();
await expect(
ensureMatrixStartupVerification({
client: harness.client as never,
auth: createAuth(),
accountConfig: { startupVerification },
stateFilePath,
}),
).rejects.toThrow(/2026\.9\.5/);
expect(fs.readFileSync(stateFilePath, "utf8")).toBe(source);
expect(fs.existsSync(path.join(tempHome, "state"))).toBe(false);
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
},
);
it("persists a successful startup verification request", async () => {
const tempHome = createTempStateDir();
const harness = createHarness();

View file

@ -1,19 +1,16 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { tryReadJson } from "@openclaw/fs-safe/json";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixConfig } from "../../types.js";
import { recordCurrentStorageMetaDeviceId, resolveMatrixStoragePaths } from "../client/storage.js";
import type { MatrixAuth } from "../client/types.js";
import { assertMatrixSupportedStateFile } from "../retired-state.js";
import type { MatrixClient, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
import { resolveMatrixSqliteStateEnv } from "../sqlite-state.js";
const STARTUP_VERIFICATION_STATE_FILENAME = "startup-verification.json";
const STARTUP_VERIFICATION_NAMESPACE = "startup-verification";
const STARTUP_VERIFICATION_MIGRATIONS_NAMESPACE = "startup-verification-migrations";
const STARTUP_VERIFICATION_MAX_ENTRIES = 1_000;
const DEFAULT_STARTUP_VERIFICATION_MODE = "if-unverified" as const;
const DEFAULT_STARTUP_VERIFICATION_COOLDOWN_HOURS = 24;
@ -29,10 +26,6 @@ type MatrixStartupVerificationState = {
error?: string;
};
type MatrixStartupVerificationMigrationMarker = {
importedAt: number;
};
export type MatrixStartupVerificationOutcome =
| {
kind: "disabled" | "verified" | "cooldown" | "pending" | "requested" | "request-failed";
@ -83,124 +76,44 @@ function createStartupVerificationStore(params: { env?: NodeJS.ProcessEnv; state
});
}
function createStartupVerificationMigrationStore(params: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
}) {
return getMatrixRuntime().state.openKeyedStore<MatrixStartupVerificationMigrationMarker>({
namespace: STARTUP_VERIFICATION_MIGRATIONS_NAMESPACE,
maxEntries: STARTUP_VERIFICATION_MAX_ENTRIES,
env: resolveMatrixSqliteStateEnv(params),
});
}
function buildStartupVerificationImportKey(params: {
auth: MatrixAuth;
legacyFilePath: string;
}): string {
const accountId = buildStartupVerificationKey(params.auth);
const digest = createHash("sha256")
.update(accountId)
.update("\0")
.update(params.legacyFilePath)
.digest("hex");
return `${accountId}:${digest}`;
}
async function readLegacyStartupVerificationState(
filePath: string,
): Promise<MatrixStartupVerificationState | null> {
const value = await tryReadJson<MatrixStartupVerificationState>(filePath);
return value && typeof value === "object" ? value : null;
}
async function readStartupVerificationState(params: {
auth: MatrixAuth;
env?: NodeJS.ProcessEnv;
stateDir?: string;
legacyFilePath: string;
}): Promise<MatrixStartupVerificationState | null> {
const store = createStartupVerificationStore(params);
const key = buildStartupVerificationKey(params.auth);
const value = await store.lookup(key);
if (value && typeof value === "object") {
return value;
}
const migrationStore = createStartupVerificationMigrationStore(params);
const legacyImportKey = buildStartupVerificationImportKey({
auth: params.auth,
legacyFilePath: params.legacyFilePath,
});
if (await migrationStore.lookup(legacyImportKey)) {
return null;
}
const legacy = await readLegacyStartupVerificationState(params.legacyFilePath);
if (legacy) {
await store
.register(key, legacy)
.then(async () => {
if (typeof legacy.deviceId === "string" && legacy.deviceId.trim()) {
await recordCurrentStorageMetaDeviceId({
rootDir: path.dirname(params.legacyFilePath),
deviceId: legacy.deviceId,
});
}
await migrationStore.register(legacyImportKey, { importedAt: Date.now() });
await fs.rm(params.legacyFilePath, { force: true }).catch(() => {});
})
.catch(() => {});
}
return legacy;
const value = await createStartupVerificationStore(params).lookup(
buildStartupVerificationKey(params.auth),
);
return value && typeof value === "object" ? value : null;
}
async function writeStartupVerificationState(params: {
auth: MatrixAuth;
env?: NodeJS.ProcessEnv;
stateDir?: string;
legacyFilePath: string;
storageRootDir: string;
state: MatrixStartupVerificationState;
}): Promise<void> {
await createStartupVerificationStore(params).register(
buildStartupVerificationKey(params.auth),
params.state,
);
await createStartupVerificationMigrationStore(params)
.register(
buildStartupVerificationImportKey({
auth: params.auth,
legacyFilePath: params.legacyFilePath,
}),
{ importedAt: Date.now() },
)
.catch(() => {});
if (typeof params.state.deviceId === "string" && params.state.deviceId.trim()) {
await recordCurrentStorageMetaDeviceId({
rootDir: path.dirname(params.legacyFilePath),
rootDir: params.storageRootDir,
deviceId: params.state.deviceId,
});
}
await fs.rm(params.legacyFilePath, { force: true }).catch(() => {});
}
async function clearStartupVerificationState(params: {
auth: MatrixAuth;
env?: NodeJS.ProcessEnv;
stateDir?: string;
legacyFilePath: string;
}): Promise<void> {
await createStartupVerificationStore(params)
.delete(buildStartupVerificationKey(params.auth))
.catch(() => {});
await createStartupVerificationMigrationStore(params)
.register(
buildStartupVerificationImportKey({
auth: params.auth,
legacyFilePath: params.legacyFilePath,
}),
{ importedAt: Date.now() },
)
.catch(() => {});
await fs.rm(params.legacyFilePath, { force: true }).catch(() => {});
}
function resolveStateCooldownMs(
@ -285,11 +198,12 @@ export async function ensureMatrixStartupVerification(params: {
env: params.env,
stateDir: params.stateDir,
}));
await assertMatrixSupportedStateFile(statePath);
const stateLocation = {
auth: params.auth,
env: params.env,
stateDir: params.stateDir ?? path.dirname(statePath),
legacyFilePath: statePath,
storageRootDir: path.dirname(statePath),
};
const mode = params.accountConfig.startupVerification ?? DEFAULT_STARTUP_VERIFICATION_MODE;
if (verification.verified || mode === "off") {

View file

@ -0,0 +1,18 @@
import fs from "node:fs/promises";
import { hasNodeErrorCode } from "@openclaw/fs-safe/path";
export function describeRetiredMatrixState(filePath: string): string {
return `Retired pre-July Matrix state at ${filePath} was left unchanged. Install OpenClaw 2026.9.5, run "openclaw doctor --fix", and start the Matrix channel once to migrate it, then upgrade to latest. See https://docs.openclaw.ai/channels/matrix-migration.`;
}
export async function assertMatrixSupportedStateFile(filePath: string): Promise<void> {
try {
await fs.lstat(filePath);
} catch (error) {
if (hasNodeErrorCode(error, "ENOENT")) {
return;
}
throw error;
}
throw new Error(describeRetiredMatrixState(filePath));
}

View file

@ -212,6 +212,67 @@ describe("matrix thread bindings", () => {
vi.useRealTimers();
});
it("loads and updates thread bindings written by the July SQLite store", async () => {
const bindingsPath = await resolveBindingsFilePath();
const store = createPluginStateKeyedStoreForTests("matrix", {
namespace: "thread-bindings",
maxEntries: 10_000,
env: { ...process.env, OPENCLAW_STATE_DIR: path.dirname(bindingsPath) },
});
// v2026.7.1: account id plus sha256(account id, parent room, thread), NUL-separated.
const key = "ops:df4863925b4d3d0c82df25928e615588bf4a6bd9544af82cf52100573eba9b8b";
const stored = {
accountId,
conversationId: "$thread",
parentConversationId: "!room:example",
targetKind: "subagent",
targetSessionKey: "agent:ops:subagent:child",
agentId: "ops",
boundBy: "system",
boundAt: 1_783_944_000_000,
lastActivityAt: 1_783_944_000_000,
idleTimeoutMs,
maxAgeMs: 0,
};
await store.register(key, stored);
const manager = await createBindingManager();
const binding = getSessionBindingService().resolveByConversation(currentThreadConversation());
expect(binding?.targetSessionKey).toBe(stored.targetSessionKey);
expect(binding?.boundAt).toBe(stored.boundAt);
if (!binding) {
throw new Error("expected July Matrix thread binding");
}
getSessionBindingService().touch(binding.bindingId, stored.lastActivityAt + 1_000);
await manager.stop();
expect((await store.entries()).map((entry) => entry.key)).toEqual([key]);
await expect(store.lookup(key)).resolves.toMatchObject({
...stored,
lastActivityAt: stored.lastActivityAt + 1_000,
});
expect(fsSync.existsSync(bindingsPath)).toBe(false);
});
it("refuses retired thread-binding JSON without importing or deleting it", async () => {
const bindingsPath = await resolveBindingsFilePath();
const source = JSON.stringify({
version: 1,
bindings: [{ conversationId: "$thread", targetSessionKey: "agent:ops:subagent:child" }],
});
await fs.mkdir(path.dirname(bindingsPath), { recursive: true });
await fs.writeFile(bindingsPath, source);
await expect(createBindingManager()).rejects.toThrow(/2026\.9\.5/);
expect(await fs.readFile(bindingsPath, "utf8")).toBe(source);
expect(fsSync.existsSync(path.join(path.dirname(bindingsPath), "state"))).toBe(false);
expect(
getSessionBindingService().resolveByConversation(currentThreadConversation()),
).toBeNull();
expect(sendMessageMatrixMock).not.toHaveBeenCalled();
});
it("creates child Matrix thread bindings from a top-level room context", async () => {
await createBindingManager();

View file

@ -1,7 +1,5 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { tryReadJson } from "@openclaw/fs-safe/json";
import { resolveSessionAgentIdStrict } from "openclaw/plugin-sdk/agent-scope-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/session-key-runtime";
@ -16,6 +14,7 @@ import {
import { getMatrixRuntime } from "../runtime.js";
import { claimCurrentTokenStorageState, resolveMatrixStateFilePath } from "./client/storage.js";
import type { MatrixAuth } from "./client/types.js";
import { assertMatrixSupportedStateFile } from "./retired-state.js";
import type { MatrixClient } from "./sdk.js";
import { sendMessageMatrix } from "./send.js";
import { resolveMatrixSqliteStateEnv, resolveMatrixSqliteStateKey } from "./sqlite-state.js";
@ -34,22 +33,11 @@ import {
type MatrixThreadBindingRecord,
} from "./thread-bindings-shared.js";
const STORE_VERSION = 1;
const THREAD_BINDINGS_NAMESPACE = "thread-bindings";
const THREAD_BINDINGS_MIGRATIONS_NAMESPACE = "thread-bindings-migrations";
const THREAD_BINDINGS_MAX_ENTRIES = 10_000;
const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 60_000;
const TOUCH_PERSIST_DELAY_MS = 30_000;
type StoredMatrixThreadBindingState = {
version: number;
bindings: MatrixThreadBindingRecord[];
};
type MatrixThreadBindingMigrationMarker = {
importedAt: number;
};
async function resolveBindingsPath(params: {
auth: MatrixAuth;
accountId: string;
@ -73,14 +61,6 @@ function createThreadBindingStore(params: { env?: NodeJS.ProcessEnv; stateDir?:
});
}
function createThreadBindingMigrationStore(params: { env?: NodeJS.ProcessEnv; stateDir?: string }) {
return getMatrixRuntime().state.openKeyedStore<MatrixThreadBindingMigrationMarker>({
namespace: THREAD_BINDINGS_MIGRATIONS_NAMESPACE,
maxEntries: 1_000,
env: resolveMatrixSqliteStateEnv(params),
});
}
function buildThreadBindingStoreKey(record: {
accountId: string;
conversationId: string;
@ -96,18 +76,6 @@ function buildThreadBindingStoreKey(record: {
return `${record.accountId}:${digest}`;
}
function buildLegacyThreadBindingsImportKey(params: {
accountId: string;
legacyFilePath: string;
}): string {
const digest = createHash("sha256")
.update(params.accountId)
.update("\0")
.update(params.legacyFilePath)
.digest("hex");
return `${params.accountId}:${digest}`;
}
function normalizeBindingRecord(
entry: unknown,
accountId: string,
@ -155,21 +123,6 @@ function normalizeBindingRecord(
};
}
async function loadBindingsFromLegacyDisk(filePath: string, accountId: string) {
const value = await tryReadJson<StoredMatrixThreadBindingState>(filePath);
if (value?.version !== STORE_VERSION || !Array.isArray(value.bindings)) {
return [];
}
const loaded: MatrixThreadBindingRecord[] = [];
for (const entry of value.bindings) {
const record = normalizeBindingRecord(entry, accountId);
if (record) {
loaded.push(record);
}
}
return loaded;
}
async function loadBindingsFromPluginState(params: {
accountId: string;
env?: NodeJS.ProcessEnv;
@ -305,6 +258,7 @@ export async function createMatrixThreadBindingManager(params: {
env: params.env,
stateDir: params.stateDir,
});
await assertMatrixSupportedStateFile(legacyFilePath);
const sqliteStateDir = path.dirname(legacyFilePath);
const storageKey = resolveMatrixSqliteStateKey({ env: params.env, stateDir: sqliteStateDir });
const existingEntry = getMatrixThreadBindingManagerEntry(params.accountId);
@ -314,32 +268,11 @@ export async function createMatrixThreadBindingManager(params: {
}
await existingEntry.manager.stop();
}
const pluginLoaded = await loadBindingsFromPluginState({
const loaded = await loadBindingsFromPluginState({
accountId: params.accountId,
env: params.env,
stateDir: sqliteStateDir,
});
const migrationStore = createThreadBindingMigrationStore({
env: params.env,
stateDir: sqliteStateDir,
});
const legacyImportKey = buildLegacyThreadBindingsImportKey({
accountId: params.accountId,
legacyFilePath,
});
const pluginLoadedKeys = new Set(
pluginLoaded.map((record) => buildThreadBindingStoreKey(record)),
);
let legacyHadRows = false;
let legacyLoaded: MatrixThreadBindingRecord[] = [];
if (!(await migrationStore.lookup(legacyImportKey))) {
const legacyCandidates = await loadBindingsFromLegacyDisk(legacyFilePath, params.accountId);
legacyHadRows = legacyCandidates.length > 0;
legacyLoaded = legacyCandidates.filter(
(record) => !pluginLoadedKeys.has(buildThreadBindingStoreKey(record)),
);
}
const loaded = [...pluginLoaded, ...legacyLoaded];
for (const record of loaded) {
setBindingRecord(record);
}
@ -373,17 +306,6 @@ export async function createMatrixThreadBindingManager(params: {
idleTimeoutMs: params.idleTimeoutMs,
maxAgeMs: params.maxAgeMs,
};
if (legacyHadRows) {
if (legacyLoaded.length > 0) {
await persist();
}
await migrationStore.register(legacyImportKey, { importedAt: Date.now() });
await fs.rm(legacyFilePath, { force: true }).catch((err: unknown) => {
params.logVerboseMessage?.(
`matrix: failed removing migrated legacy thread bindings account=${params.accountId}: ${String(err)}`,
);
});
}
let persistTimer: NodeJS.Timeout | null = null;
const schedulePersist = (delayMs: number) => {
if (persistTimer) {