Skip to content

Commit 33c246d

Browse files
authored
refactor: move plugin state slices to sqlite
* refactor: move plugin state slices to sqlite * fix: keep legacy plugin state migration out of runtime * fix: add doctor migrations for plugin sqlite state * fix: preserve teams feedback learning migration keys * fix: merge teams legacy feedback learnings * fix: guard doctor imports against plugin state caps * fix: leave lossy teams learning filenames unmigrated * fix: preserve teams feedback learning scope * fix: load plugin doctor contracts from package dist * fix: satisfy plugin state migration gates
1 parent 12d4dda commit 33c246d

19 files changed

Lines changed: 1575 additions & 444 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import {
5+
createPluginStateKeyedStoreForTests,
6+
resetPluginStateStoreForTests,
7+
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
8+
import type {
9+
OpenKeyedStoreOptions,
10+
PluginDoctorStateMigrationContext,
11+
} from "openclaw/plugin-sdk/runtime-doctor";
12+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
13+
import { stateMigrations } from "./doctor-contract-api.js";
14+
15+
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
16+
return {
17+
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
18+
return createPluginStateKeyedStoreForTests<T>("active-memory", {
19+
...options,
20+
env: options.env ?? env,
21+
});
22+
},
23+
};
24+
}
25+
26+
describe("active-memory doctor state migration", () => {
27+
let stateDir = "";
28+
let env: NodeJS.ProcessEnv;
29+
30+
beforeEach(async () => {
31+
resetPluginStateStoreForTests();
32+
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-doctor-"));
33+
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
34+
});
35+
36+
afterEach(async () => {
37+
await fs.rm(stateDir, { recursive: true, force: true });
38+
});
39+
40+
it("imports legacy session opt-outs into plugin state", async () => {
41+
const sourcePath = path.join(stateDir, "plugins", "active-memory", "session-toggles.json");
42+
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
43+
await fs.writeFile(
44+
sourcePath,
45+
JSON.stringify({
46+
sessions: {
47+
"telegram:dm:123": { disabled: true, updatedAt: 1700 },
48+
"telegram:dm:456": { disabled: false, updatedAt: 1701 },
49+
},
50+
}),
51+
);
52+
53+
const migration = stateMigrations[0];
54+
await expect(
55+
migration.detectLegacyState({
56+
config: {},
57+
env,
58+
stateDir,
59+
oauthDir: path.join(stateDir, "oauth"),
60+
context: createDoctorContext(env),
61+
}),
62+
).resolves.toMatchObject({
63+
preview: [expect.stringContaining("1 entry")],
64+
});
65+
66+
const result = await migration.migrateLegacyState({
67+
config: {},
68+
env,
69+
stateDir,
70+
oauthDir: path.join(stateDir, "oauth"),
71+
context: createDoctorContext(env),
72+
});
73+
74+
expect(result.warnings).toEqual([]);
75+
expect(result.changes).toEqual([
76+
expect.stringContaining("Migrated 1 Active Memory session toggle entry"),
77+
expect.stringContaining("Archived Active Memory session toggles legacy source"),
78+
]);
79+
await expect(fs.access(sourcePath)).rejects.toThrow();
80+
await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined();
81+
82+
const entries = await createDoctorContext(env)
83+
.openPluginStateKeyedStore({
84+
namespace: "session-toggles",
85+
maxEntries: 10_000,
86+
})
87+
.entries();
88+
expect(entries).toMatchObject([
89+
{
90+
key: expect.any(String),
91+
value: {
92+
sessionKey: "telegram:dm:123",
93+
disabled: true,
94+
updatedAt: 1700,
95+
},
96+
},
97+
]);
98+
});
99+
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import crypto from "node:crypto";
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
5+
6+
type ActiveMemoryToggleEntry = {
7+
sessionKey: string;
8+
disabled: boolean;
9+
updatedAt: number;
10+
};
11+
12+
const TOGGLE_STATE_FILE = "session-toggles.json";
13+
const SESSION_TOGGLES_NAMESPACE = "session-toggles";
14+
const MAX_TOGGLE_ENTRIES = 10_000;
15+
16+
function resolveToggleStatePath(stateDir: string): string {
17+
return path.join(stateDir, "plugins", "active-memory", TOGGLE_STATE_FILE);
18+
}
19+
20+
function activeMemoryToggleKey(sessionKey: string): string {
21+
return crypto.createHash("sha256").update(sessionKey, "utf8").digest("hex");
22+
}
23+
24+
async function fileExists(filePath: string): Promise<boolean> {
25+
try {
26+
const stat = await fs.stat(filePath);
27+
return stat.isFile();
28+
} catch {
29+
return false;
30+
}
31+
}
32+
33+
async function readLegacyToggleEntries(filePath: string): Promise<ActiveMemoryToggleEntry[]> {
34+
try {
35+
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
36+
if (!parsed || typeof parsed !== "object") {
37+
return [];
38+
}
39+
const sessions = (parsed as { sessions?: unknown }).sessions;
40+
if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) {
41+
return [];
42+
}
43+
const entries: ActiveMemoryToggleEntry[] = [];
44+
for (const [sessionKey, value] of Object.entries(sessions)) {
45+
if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
46+
continue;
47+
}
48+
if ((value as { disabled?: unknown }).disabled !== true) {
49+
continue;
50+
}
51+
const updatedAt =
52+
typeof (value as { updatedAt?: unknown }).updatedAt === "number"
53+
? (value as { updatedAt: number }).updatedAt
54+
: Date.now();
55+
entries.push({ sessionKey, disabled: true, updatedAt });
56+
}
57+
return entries;
58+
} catch {
59+
return [];
60+
}
61+
}
62+
63+
async function archiveLegacySource(params: {
64+
filePath: string;
65+
label: string;
66+
changes: string[];
67+
warnings: string[];
68+
}): Promise<void> {
69+
const archivedPath = `${params.filePath}.migrated`;
70+
if (await fileExists(archivedPath)) {
71+
params.warnings.push(
72+
`Left migrated ${params.label} source in place because ${archivedPath} already exists`,
73+
);
74+
return;
75+
}
76+
try {
77+
await fs.rename(params.filePath, archivedPath);
78+
params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`);
79+
} catch (err) {
80+
params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`);
81+
}
82+
}
83+
84+
export const stateMigrations: PluginDoctorStateMigration[] = [
85+
{
86+
id: "active-memory-session-toggles-json-to-plugin-state",
87+
label: "Active Memory session toggles",
88+
async detectLegacyState(params) {
89+
const filePath = resolveToggleStatePath(params.stateDir);
90+
const entries = await readLegacyToggleEntries(filePath);
91+
if (entries.length === 0) {
92+
return null;
93+
}
94+
return {
95+
preview: [
96+
`- Active Memory session toggles: ${entries.length} ${entries.length === 1 ? "entry" : "entries"} -> plugin state (${SESSION_TOGGLES_NAMESPACE})`,
97+
],
98+
};
99+
},
100+
async migrateLegacyState(params) {
101+
const changes: string[] = [];
102+
const warnings: string[] = [];
103+
const filePath = resolveToggleStatePath(params.stateDir);
104+
const entries = await readLegacyToggleEntries(filePath);
105+
if (entries.length === 0) {
106+
return { changes, warnings };
107+
}
108+
const store = params.context.openPluginStateKeyedStore<ActiveMemoryToggleEntry>({
109+
namespace: SESSION_TOGGLES_NAMESPACE,
110+
maxEntries: MAX_TOGGLE_ENTRIES,
111+
});
112+
const existingKeys = new Set((await store.entries()).map((entry) => entry.key));
113+
const missingEntries = entries.filter(
114+
(entry) => !existingKeys.has(activeMemoryToggleKey(entry.sessionKey)),
115+
);
116+
if (missingEntries.length > MAX_TOGGLE_ENTRIES - existingKeys.size) {
117+
warnings.push(
118+
`Skipped Active Memory session toggle migration because plugin state has room for ${MAX_TOGGLE_ENTRIES - existingKeys.size} of ${missingEntries.length} missing entries; left legacy source in place`,
119+
);
120+
return { changes, warnings };
121+
}
122+
let imported = 0;
123+
for (const entry of entries) {
124+
const key = activeMemoryToggleKey(entry.sessionKey);
125+
if (existingKeys.has(key)) {
126+
continue;
127+
}
128+
await store.register(key, entry);
129+
existingKeys.add(key);
130+
imported++;
131+
}
132+
if (imported > 0) {
133+
changes.push(
134+
`Migrated ${imported} Active Memory session toggle ${imported === 1 ? "entry" : "entries"} -> plugin state`,
135+
);
136+
}
137+
await archiveLegacySource({
138+
filePath,
139+
label: "Active Memory session toggles",
140+
changes,
141+
warnings,
142+
});
143+
return { changes, warnings };
144+
},
145+
},
146+
];

extensions/active-memory/index.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
5+
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
6+
import {
7+
createPluginStateKeyedStoreForTests,
8+
resetPluginStateStoreForTests,
9+
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
510
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
611
import plugin, { testing } from "./index.js";
712

@@ -156,6 +161,11 @@ describe("active-memory plugin", () => {
156161
},
157162
state: {
158163
resolveStateDir: () => stateDir,
164+
openKeyedStore: (options: OpenKeyedStoreOptions) =>
165+
createPluginStateKeyedStoreForTests("active-memory", {
166+
...options,
167+
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
168+
}),
159169
},
160170
config: {
161171
current: () => configFile,
@@ -309,6 +319,7 @@ describe("active-memory plugin", () => {
309319

310320
beforeEach(async () => {
311321
vi.clearAllMocks();
322+
resetPluginStateStoreForTests();
312323
runEmbeddedAgent.mockReset();
313324
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-test-"));
314325
configFile = {

0 commit comments

Comments
 (0)