Skip to content

Commit eafe2a8

Browse files
authored
refactor: consolidate duplicated plugin state and doctor migration plumbing onto SDK seams (#99850)
* refactor(plugin-sdk): add createPersistentDedupeCache and migrate channel presence caches * refactor(matrix): adopt SDK approval reaction target store * refactor(plugin-sdk): share doctor legacy-state migration fs helpers * refactor(memory-core): dedupe qmd cache entry envelope validation * chore(plugin-sdk): pin surface budgets for shared dedupe and doctor helpers * test(matrix): use future approval expiry fixtures for reaction targets * test(matrix): use future approval expiry fixtures for reaction targets
1 parent 3d40447 commit eafe2a8

27 files changed

Lines changed: 566 additions & 947 deletions

docs/plugins/sdk-migration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ releases.
440440
| Heartbeat wake, event, and visibility helpers | `openclaw/plugin-sdk/heartbeat-runtime` |
441441
| Pending delivery queue drain | `openclaw/plugin-sdk/delivery-queue-runtime` |
442442
| Channel activity telemetry | `openclaw/plugin-sdk/channel-activity-runtime` |
443-
| In-memory dedupe caches | `openclaw/plugin-sdk/dedupe-runtime` |
443+
| In-memory and persistent-backed dedupe caches | `openclaw/plugin-sdk/dedupe-runtime` |
444444
| Safe local-file/media path helpers | `openclaw/plugin-sdk/file-access-runtime` |
445445
| Dispatcher-aware fetch | `openclaw/plugin-sdk/runtime-fetch` |
446446
| Proxy and guarded fetch helpers | `openclaw/plugin-sdk/fetch-runtime` |
@@ -567,7 +567,7 @@ releases.
567567
| `plugin-sdk/heartbeat-runtime` | Heartbeat helpers | Heartbeat wake, event, and visibility helpers |
568568
| `plugin-sdk/delivery-queue-runtime` | Delivery queue helpers | `drainPendingDeliveries` |
569569
| `plugin-sdk/channel-activity-runtime` | Channel activity helpers | `recordChannelActivity` |
570-
| `plugin-sdk/dedupe-runtime` | Dedupe helpers | In-memory dedupe caches |
570+
| `plugin-sdk/dedupe-runtime` | Dedupe helpers | In-memory and persistent-backed dedupe caches |
571571
| `plugin-sdk/file-access-runtime` | File access helpers | Safe local-file/media path helpers |
572572
| `plugin-sdk/transport-ready-runtime` | Transport readiness helpers | `waitForTransportReady` |
573573
| `plugin-sdk/exec-approvals-runtime` | Exec approval policy helpers | `loadExecApprovals`, `resolveExecApprovalsFromFile`, `ExecApprovalsFile` |

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ usage endpoint failed or returned no usable usage data.
290290
| `plugin-sdk/async-lock-runtime` | Process-local async lock helper for small runtime state files |
291291
| `plugin-sdk/channel-activity-runtime` | Channel activity telemetry helper |
292292
| `plugin-sdk/concurrency-runtime` | Bounded async task concurrency helper |
293-
| `plugin-sdk/dedupe-runtime` | In-memory dedupe cache helpers |
293+
| `plugin-sdk/dedupe-runtime` | In-memory and persistent-backed dedupe cache helpers |
294294
| `plugin-sdk/delivery-queue-runtime` | Outbound pending-delivery drain helper |
295295
| `plugin-sdk/file-access-runtime` | Safe local-file and media-source path helpers |
296296
| `plugin-sdk/heartbeat-runtime` | Heartbeat wake, event, and visibility helpers |

extensions/acpx/doctor-contract-api.ts

Lines changed: 8 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// ACPX doctor contract migrates shipped plugin-owned runtime state.
22
import fs from "node:fs/promises";
33
import path from "node:path";
4-
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
4+
import {
5+
archiveLegacyStateSource,
6+
type PluginDoctorStateMigration,
7+
} from "openclaw/plugin-sdk/runtime-doctor";
58
import {
69
normalizeAcpxProcessLease,
710
normalizeAcpxProcessLeaseFile,
@@ -26,15 +29,6 @@ function resolveLegacyProcessLeasePath(stateDir: string): string {
2629
return path.join(stateDir, "acpx", ACPX_LEGACY_PROCESS_LEASE_FILE);
2730
}
2831

29-
async function fileExists(filePath: string): Promise<boolean> {
30-
try {
31-
const stat = await fs.stat(filePath);
32-
return stat.isFile();
33-
} catch {
34-
return false;
35-
}
36-
}
37-
3832
async function readLegacyGatewayInstanceId(filePath: string): Promise<string | null> {
3933
try {
4034
const value = (await fs.readFile(filePath, "utf8")).trim();
@@ -55,27 +49,6 @@ async function readLegacyOpenProcessLeases(filePath: string): Promise<AcpxProces
5549
}
5650
}
5751

58-
async function archiveLegacySource(params: {
59-
filePath: string;
60-
label: string;
61-
changes: string[];
62-
warnings: string[];
63-
}): Promise<void> {
64-
const archivedPath = `${params.filePath}.migrated`;
65-
if (await fileExists(archivedPath)) {
66-
params.warnings.push(
67-
`Left migrated ACPX ${params.label} source in place because ${archivedPath} already exists`,
68-
);
69-
return;
70-
}
71-
try {
72-
await fs.rename(params.filePath, archivedPath);
73-
params.changes.push(`Archived ACPX ${params.label} legacy source -> ${archivedPath}`);
74-
} catch (err) {
75-
params.warnings.push(`Failed archiving ACPX ${params.label} legacy source: ${String(err)}`);
76-
}
77-
}
78-
7952
export const stateMigrations: PluginDoctorStateMigration[] = [
8053
{
8154
id: "acpx-runtime-state-to-plugin-state",
@@ -171,9 +144,9 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
171144
}
172145

173146
if (gatewayInstanceId) {
174-
await archiveLegacySource({
147+
await archiveLegacyStateSource({
175148
filePath: gatewayInstancePath,
176-
label: "gateway-instance-id",
149+
label: "ACPX gateway-instance-id",
177150
changes,
178151
warnings,
179152
});
@@ -193,9 +166,9 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
193166
changes.push(
194167
`Migrated ACPX process leases -> plugin state (${imported} imported, ${alreadyPresent} already present)`,
195168
);
196-
await archiveLegacySource({
169+
await archiveLegacyStateSource({
197170
filePath: processLeasePath,
198-
label: "process-leases",
171+
label: "ACPX process-leases",
199172
changes,
200173
warnings,
201174
});

extensions/active-memory/doctor-contract-api.ts

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import crypto from "node:crypto";
66
import fs from "node:fs/promises";
77
import path from "node:path";
8-
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
8+
import {
9+
archiveLegacyStateSource,
10+
type PluginDoctorStateMigration,
11+
} from "openclaw/plugin-sdk/runtime-doctor";
912

1013
type ActiveMemoryToggleEntry = {
1114
sessionKey: string;
@@ -25,15 +28,6 @@ function activeMemoryToggleKey(sessionKey: string): string {
2528
return crypto.createHash("sha256").update(sessionKey, "utf8").digest("hex");
2629
}
2730

28-
async function fileExists(filePath: string): Promise<boolean> {
29-
try {
30-
const stat = await fs.stat(filePath);
31-
return stat.isFile();
32-
} catch {
33-
return false;
34-
}
35-
}
36-
3731
async function readLegacyToggleEntries(filePath: string): Promise<ActiveMemoryToggleEntry[]> {
3832
try {
3933
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
@@ -64,27 +58,6 @@ async function readLegacyToggleEntries(filePath: string): Promise<ActiveMemoryTo
6458
}
6559
}
6660

67-
async function archiveLegacySource(params: {
68-
filePath: string;
69-
label: string;
70-
changes: string[];
71-
warnings: string[];
72-
}): Promise<void> {
73-
const archivedPath = `${params.filePath}.migrated`;
74-
if (await fileExists(archivedPath)) {
75-
params.warnings.push(
76-
`Left migrated ${params.label} source in place because ${archivedPath} already exists`,
77-
);
78-
return;
79-
}
80-
try {
81-
await fs.rename(params.filePath, archivedPath);
82-
params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`);
83-
} catch (err) {
84-
params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`);
85-
}
86-
}
87-
8861
/** State migrations exposed to OpenClaw doctor for Active Memory. */
8962
export const stateMigrations: PluginDoctorStateMigration[] = [
9063
{
@@ -139,7 +112,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
139112
`Migrated ${imported} Active Memory session toggle ${imported === 1 ? "entry" : "entries"} -> plugin state`,
140113
);
141114
}
142-
await archiveLegacySource({
115+
await archiveLegacyStateSource({
143116
filePath,
144117
label: "Active Memory session toggles",
145118
changes,

extensions/device-pair/doctor-contract-api.ts

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Device Pair doctor contract migrates shipped plugin-owned state.
22
import fs from "node:fs/promises";
33
import path from "node:path";
4-
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
4+
import {
5+
archiveLegacyStateSource,
6+
type PluginDoctorStateMigration,
7+
} from "openclaw/plugin-sdk/runtime-doctor";
58
import {
69
DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE,
710
DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
@@ -16,15 +19,6 @@ function resolveLegacyNotifyStatePath(stateDir: string): string {
1619
return path.join(stateDir, DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE);
1720
}
1821

19-
async function fileExists(filePath: string): Promise<boolean> {
20-
try {
21-
const stat = await fs.stat(filePath);
22-
return stat.isFile();
23-
} catch {
24-
return false;
25-
}
26-
}
27-
2822
async function readLegacyNotifyState(filePath: string): Promise<LegacyNotifyStateFile | null> {
2923
try {
3024
return normalizeLegacyNotifyState(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown);
@@ -33,26 +27,6 @@ async function readLegacyNotifyState(filePath: string): Promise<LegacyNotifyStat
3327
}
3428
}
3529

36-
async function archiveLegacySource(params: {
37-
filePath: string;
38-
changes: string[];
39-
warnings: string[];
40-
}): Promise<void> {
41-
const archivedPath = `${params.filePath}.migrated`;
42-
if (await fileExists(archivedPath)) {
43-
params.warnings.push(
44-
`Left migrated Device Pair notify-state source in place because ${archivedPath} already exists`,
45-
);
46-
return;
47-
}
48-
try {
49-
await fs.rename(params.filePath, archivedPath);
50-
params.changes.push(`Archived Device Pair notify-state legacy source -> ${archivedPath}`);
51-
} catch (err) {
52-
params.warnings.push(`Failed archiving Device Pair notify-state legacy source: ${String(err)}`);
53-
}
54-
}
55-
5630
export const stateMigrations: PluginDoctorStateMigration[] = [
5731
{
5832
id: "device-pair-notify-json-to-plugin-state",
@@ -99,7 +73,12 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
9973
changes.push(
10074
`Migrated Device Pair notify subscribers -> plugin state (${imported} imported, ${alreadyPresent} already present)`,
10175
);
102-
await archiveLegacySource({ filePath, changes, warnings });
76+
await archiveLegacyStateSource({
77+
filePath,
78+
label: "Device Pair notify-state",
79+
changes,
80+
warnings,
81+
});
10382
return { changes, warnings };
10483
},
10584
},

extensions/matrix/doctor-contract-api.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import type { Dirent } from "node:fs";
22
// Matrix API module exposes the plugin public contract.
33
import fs from "node:fs/promises";
44
import path from "node:path";
5-
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
5+
import {
6+
legacyStateFileExists,
7+
type PluginDoctorStateMigration,
8+
} from "openclaw/plugin-sdk/runtime-doctor";
69
import {
710
hasMatrixSyncCacheStateInStore,
811
openMatrixSyncCacheStoreOptions,
@@ -43,15 +46,6 @@ export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-co
4346
const MATRIX_SYNC_CACHE_FILENAME = "bot-storage.json";
4447
const MATRIX_STORAGE_META_FILENAME = "storage-meta.json";
4548

46-
async function fileExists(filePath: string): Promise<boolean> {
47-
try {
48-
const stat = await fs.stat(filePath);
49-
return stat.isFile();
50-
} catch {
51-
return false;
52-
}
53-
}
54-
5549
async function collectLegacyMatrixStateRoots(
5650
stateDir: string,
5751
filename: string,
@@ -105,7 +99,7 @@ async function archiveLegacySyncCache(params: {
10599
}): Promise<void> {
106100
const sourcePath = path.join(params.storageRootDir, MATRIX_SYNC_CACHE_FILENAME);
107101
const archivedPath = `${sourcePath}.migrated`;
108-
if (await fileExists(archivedPath)) {
102+
if (await legacyStateFileExists(archivedPath)) {
109103
params.warnings.push(
110104
`Left migrated Matrix sync cache in place because ${archivedPath} already exists`,
111105
);
@@ -128,7 +122,7 @@ async function archiveLegacyMatrixStateFile(params: {
128122
}): Promise<void> {
129123
const sourcePath = path.join(params.storageRootDir, params.filename);
130124
const archivedPath = `${sourcePath}.migrated`;
131-
if (await fileExists(archivedPath)) {
125+
if (await legacyStateFileExists(archivedPath)) {
132126
params.warnings.push(
133127
`Left migrated ${params.label} in place because ${archivedPath} already exists`,
134128
);

extensions/matrix/src/approval-handler.runtime.test.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
77
import { matrixApprovalNativeRuntime } from "./approval-handler.runtime.js";
88
import {
99
clearMatrixApprovalReactionTargetsForTest,
10-
resolveMatrixApprovalReactionTarget,
10+
resolveMatrixApprovalReactionTargetWithPersistence,
1111
} from "./approval-reactions.js";
1212

1313
type MatrixDeliverPendingParams = Parameters<
@@ -72,6 +72,11 @@ function buildMatrixApprovalRoomTarget(
7272
};
7373
}
7474

75+
// Pending approvals expire in the future; the reaction target store TTLs its
76+
// memory layer from `view.expiresAtMs - now`, so epoch-past fixtures would
77+
// evict the mapping before assertions run.
78+
const TEST_APPROVAL_EXPIRES_AT_MS = Date.now() + 5 * 60_000;
79+
7580
function buildExecApprovalView(
7681
overrides: Partial<MatrixPendingExecApprovalView> = {},
7782
): MatrixPendingExecApprovalView {
@@ -102,7 +107,7 @@ function buildExecApprovalView(
102107
command: "/approve req-1 deny",
103108
},
104109
],
105-
expiresAtMs: 1_000,
110+
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
106111
...overrides,
107112
};
108113
}
@@ -129,7 +134,7 @@ function buildPluginApprovalView(
129134
command: "/approve plugin:req-1 allow-once",
130135
},
131136
],
132-
expiresAtMs: 1_000,
137+
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
133138
...overrides,
134139
};
135140
}
@@ -273,7 +278,7 @@ describe("matrixApprovalNativeRuntime", () => {
273278
kind: "plugin",
274279
title: "Plugin Approval Required",
275280
description: "Approve the tool call.",
276-
expiresAtMs: 1_000,
281+
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
277282
metadata: [],
278283
allowedDecisions: ["allow-once"],
279284
actions: [
@@ -304,7 +309,7 @@ describe("matrixApprovalNativeRuntime", () => {
304309
});
305310
const reactMessage = vi.fn().mockImplementation(async () => {
306311
expect(
307-
resolveMatrixApprovalReactionTarget({
312+
await resolveMatrixApprovalReactionTargetWithPersistence({
308313
roomId: "!room:example.org",
309314
eventId: "$approval",
310315
reactionKey: "✅",
@@ -510,7 +515,7 @@ describe("matrixApprovalNativeRuntime", () => {
510515
eventId: "$primary",
511516
});
512517
expect(
513-
resolveMatrixApprovalReactionTarget({
518+
await resolveMatrixApprovalReactionTargetWithPersistence({
514519
roomId: "!room:example.org",
515520
eventId: "$primary",
516521
reactionKey: "✅",
@@ -520,7 +525,7 @@ describe("matrixApprovalNativeRuntime", () => {
520525
decision: "allow-once",
521526
});
522527
expect(
523-
resolveMatrixApprovalReactionTarget({
528+
await resolveMatrixApprovalReactionTargetWithPersistence({
524529
roomId: "!room:example.org",
525530
eventId: "$last",
526531
reactionKey: "✅",

0 commit comments

Comments
 (0)