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
This commit is contained in:
Peter Steinberger
2026-05-31 18:09:27 +01:00
committed by GitHub
parent 12d4dda1bb
commit 33c246dbba
19 changed files with 1576 additions and 445 deletions

View File

@@ -0,0 +1,99 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type {
OpenKeyedStoreOptions,
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("active-memory", {
...options,
env: options.env ?? env,
});
},
};
}
describe("active-memory doctor state migration", () => {
let stateDir = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
resetPluginStateStoreForTests();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-doctor-"));
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
it("imports legacy session opt-outs into plugin state", async () => {
const sourcePath = path.join(stateDir, "plugins", "active-memory", "session-toggles.json");
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(
sourcePath,
JSON.stringify({
sessions: {
"telegram:dm:123": { disabled: true, updatedAt: 1700 },
"telegram:dm:456": { disabled: false, updatedAt: 1701 },
},
}),
);
const migration = stateMigrations[0];
await expect(
migration.detectLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toMatchObject({
preview: [expect.stringContaining("1 entry")],
});
const result = await migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
});
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
expect.stringContaining("Migrated 1 Active Memory session toggle entry"),
expect.stringContaining("Archived Active Memory session toggles legacy source"),
]);
await expect(fs.access(sourcePath)).rejects.toThrow();
await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined();
const entries = await createDoctorContext(env)
.openPluginStateKeyedStore({
namespace: "session-toggles",
maxEntries: 10_000,
})
.entries();
expect(entries).toMatchObject([
{
key: expect.any(String),
value: {
sessionKey: "telegram:dm:123",
disabled: true,
updatedAt: 1700,
},
},
]);
});
});

View File

@@ -0,0 +1,146 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
type ActiveMemoryToggleEntry = {
sessionKey: string;
disabled: boolean;
updatedAt: number;
};
const TOGGLE_STATE_FILE = "session-toggles.json";
const SESSION_TOGGLES_NAMESPACE = "session-toggles";
const MAX_TOGGLE_ENTRIES = 10_000;
function resolveToggleStatePath(stateDir: string): string {
return path.join(stateDir, "plugins", "active-memory", TOGGLE_STATE_FILE);
}
function activeMemoryToggleKey(sessionKey: string): string {
return crypto.createHash("sha256").update(sessionKey, "utf8").digest("hex");
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
async function readLegacyToggleEntries(filePath: string): Promise<ActiveMemoryToggleEntry[]> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object") {
return [];
}
const sessions = (parsed as { sessions?: unknown }).sessions;
if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) {
return [];
}
const entries: ActiveMemoryToggleEntry[] = [];
for (const [sessionKey, value] of Object.entries(sessions)) {
if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
continue;
}
if ((value as { disabled?: unknown }).disabled !== true) {
continue;
}
const updatedAt =
typeof (value as { updatedAt?: unknown }).updatedAt === "number"
? (value as { updatedAt: number }).updatedAt
: Date.now();
entries.push({ sessionKey, disabled: true, updatedAt });
}
return entries;
} catch {
return [];
}
}
async function archiveLegacySource(params: {
filePath: string;
label: string;
changes: string[];
warnings: string[];
}): Promise<void> {
const archivedPath = `${params.filePath}.migrated`;
if (await fileExists(archivedPath)) {
params.warnings.push(
`Left migrated ${params.label} source in place because ${archivedPath} already exists`,
);
return;
}
try {
await fs.rename(params.filePath, archivedPath);
params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`);
} catch (err) {
params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`);
}
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "active-memory-session-toggles-json-to-plugin-state",
label: "Active Memory session toggles",
async detectLegacyState(params) {
const filePath = resolveToggleStatePath(params.stateDir);
const entries = await readLegacyToggleEntries(filePath);
if (entries.length === 0) {
return null;
}
return {
preview: [
`- Active Memory session toggles: ${entries.length} ${entries.length === 1 ? "entry" : "entries"} -> plugin state (${SESSION_TOGGLES_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const filePath = resolveToggleStatePath(params.stateDir);
const entries = await readLegacyToggleEntries(filePath);
if (entries.length === 0) {
return { changes, warnings };
}
const store = params.context.openPluginStateKeyedStore<ActiveMemoryToggleEntry>({
namespace: SESSION_TOGGLES_NAMESPACE,
maxEntries: MAX_TOGGLE_ENTRIES,
});
const existingKeys = new Set((await store.entries()).map((entry) => entry.key));
const missingEntries = entries.filter(
(entry) => !existingKeys.has(activeMemoryToggleKey(entry.sessionKey)),
);
if (missingEntries.length > MAX_TOGGLE_ENTRIES - existingKeys.size) {
warnings.push(
`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`,
);
return { changes, warnings };
}
let imported = 0;
for (const entry of entries) {
const key = activeMemoryToggleKey(entry.sessionKey);
if (existingKeys.has(key)) {
continue;
}
await store.register(key, entry);
existingKeys.add(key);
imported++;
}
if (imported > 0) {
changes.push(
`Migrated ${imported} Active Memory session toggle ${imported === 1 ? "entry" : "entries"} -> plugin state`,
);
}
await archiveLegacySource({
filePath,
label: "Active Memory session toggles",
changes,
warnings,
});
return { changes, warnings };
},
},
];

View File

@@ -2,6 +2,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import plugin, { testing } from "./index.js";
@@ -156,6 +161,11 @@ describe("active-memory plugin", () => {
},
state: {
resolveStateDir: () => stateDir,
openKeyedStore: (options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests("active-memory", {
...options,
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
}),
},
config: {
current: () => configFile,
@@ -309,6 +319,7 @@ describe("active-memory plugin", () => {
beforeEach(async () => {
vi.clearAllMocks();
resetPluginStateStoreForTests();
runEmbeddedAgent.mockReset();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-test-"));
configFile = {

View File

@@ -24,7 +24,7 @@ import {
} from "openclaw/plugin-sdk/plugin-config-runtime";
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing";
import { isPathInside, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
import { isPathInside } from "openclaw/plugin-sdk/security-runtime";
import {
asOptionalRecord as asRecord,
normalizeOptionalString,
@@ -88,7 +88,6 @@ const ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW = new Set([
"web_search",
"write",
]);
const TOGGLE_STATE_FILE = "session-toggles.json";
const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000;
const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000;
const DEFAULT_TRANSCRIPT_READ_MAX_BYTES = 50 * 1024 * 1024;
@@ -287,44 +286,16 @@ type CachedActiveRecallResult = {
type ActiveMemoryChatType = "direct" | "group" | "channel" | "explicit";
type ActiveMemoryToggleStore = {
sessions?: Record<string, { disabled?: boolean; updatedAt?: number }>;
type ActiveMemoryToggleEntry = {
sessionKey: string;
disabled: true;
updatedAt: number;
};
type AsyncLock = <T>(task: () => Promise<T>) => Promise<T>;
const toggleStoreLocks = new Map<string, AsyncLock>();
let lastActiveRecallCacheSweepAt = 0;
let minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
let setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
let timeoutPartialDataGraceMs = TIMEOUT_PARTIAL_DATA_GRACE_MS;
function createAsyncLock(): AsyncLock {
let lock: Promise<void> = Promise.resolve();
return async function withLock<T>(task: () => Promise<T>): Promise<T> {
const previous = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await task();
} finally {
release?.();
}
};
}
function withToggleStoreLock<T>(statePath: string, task: () => Promise<T>): Promise<T> {
let withLock = toggleStoreLocks.get(statePath);
if (!withLock) {
withLock = createAsyncLock();
toggleStoreLocks.set(statePath, withLock);
}
return withLock(task);
}
type ActiveMemoryThinkingLevel =
| "off"
| "minimal"
@@ -696,54 +667,14 @@ function resolveRecallRunChannelContext(params: {
}
}
function resolveToggleStatePath(api: OpenClawPluginApi): string {
return path.join(
api.runtime.state.resolveStateDir(),
"plugins",
"active-memory",
TOGGLE_STATE_FILE,
);
function activeMemoryToggleKey(sessionKey: string): string {
return crypto.createHash("sha256").update(sessionKey, "utf8").digest("hex");
}
async function readToggleStore(statePath: string): Promise<ActiveMemoryToggleStore> {
try {
const raw = await fs.readFile(statePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") {
return {};
}
const sessions = (parsed as { sessions?: unknown }).sessions;
if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) {
return {};
}
const nextSessions: NonNullable<ActiveMemoryToggleStore["sessions"]> = {};
for (const [sessionKey, value] of Object.entries(sessions)) {
if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
continue;
}
const disabled = (value as { disabled?: unknown }).disabled === true;
const updatedAt =
typeof (value as { updatedAt?: unknown }).updatedAt === "number"
? (value as { updatedAt: number }).updatedAt
: undefined;
if (disabled) {
nextSessions[sessionKey] = { disabled, updatedAt };
}
}
return Object.keys(nextSessions).length > 0 ? { sessions: nextSessions } : {};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {};
}
return {};
}
}
async function writeToggleStore(statePath: string, store: ActiveMemoryToggleStore): Promise<void> {
await replaceFileAtomic({
filePath: statePath,
content: `${JSON.stringify(store, null, 2)}\n`,
tempPrefix: ".active-memory",
function openActiveMemoryToggleStore(api: OpenClawPluginApi) {
return api.runtime.state.openKeyedStore<ActiveMemoryToggleEntry>({
namespace: "session-toggles",
maxEntries: 10_000,
});
}
@@ -756,8 +687,13 @@ async function isSessionActiveMemoryDisabled(params: {
return false;
}
try {
const store = await readToggleStore(resolveToggleStatePath(params.api));
return store.sessions?.[sessionKey]?.disabled === true;
const store = openActiveMemoryToggleStore(params.api);
const key = activeMemoryToggleKey(sessionKey);
const stored = await store.lookup(key);
if (stored?.disabled === true) {
return true;
}
return false;
} catch (error) {
params.api.logger.debug?.(
`active-memory: failed to read session toggle (${error instanceof Error ? error.message : String(error)})`,
@@ -771,17 +707,16 @@ async function setSessionActiveMemoryDisabled(params: {
sessionKey: string;
disabled: boolean;
}): Promise<void> {
const statePath = resolveToggleStatePath(params.api);
await withToggleStoreLock(statePath, async () => {
const store = await readToggleStore(statePath);
const sessions = { ...store.sessions };
if (params.disabled) {
sessions[params.sessionKey] = { disabled: true, updatedAt: Date.now() };
} else {
delete sessions[params.sessionKey];
}
await writeToggleStore(statePath, Object.keys(sessions).length > 0 ? { sessions } : {});
});
const store = openActiveMemoryToggleStore(params.api);
if (params.disabled) {
await store.register(activeMemoryToggleKey(params.sessionKey), {
sessionKey: params.sessionKey,
disabled: true,
updatedAt: Date.now(),
});
} else {
await store.delete(activeMemoryToggleKey(params.sessionKey));
}
}
function resolveCommandSessionKey(params: {

View File

@@ -0,0 +1,139 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type {
OpenKeyedStoreOptions,
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("msteams", {
...options,
env: options.env ?? env,
});
},
};
}
function encodeSessionKey(sessionKey: string): string {
return Buffer.from(sessionKey, "utf8").toString("base64url");
}
function learningStoreKey(storePath: string, sessionKey: string): string {
return createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
}
describe("msteams doctor state migration", () => {
let stateDir = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
resetPluginStateStoreForTests();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-doctor-"));
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
it("imports legacy feedback learnings into plugin state", async () => {
const agentStoreTemplate = path.join(stateDir, "agents", "{agentId}", "sessions");
const mainStorePath = path.join(stateDir, "agents", "main", "sessions");
const workStorePath = path.join(stateDir, "agents", "work", "sessions");
const encodedSessionKey = "msteams:user1";
const encodedSourcePath = path.join(
mainStorePath,
`${encodeSessionKey(encodedSessionKey)}.learnings.json`,
);
const sanitizedSessionKey = "msteams:channel:19:abc@thread.tacv2";
const sanitizedSourcePath = path.join(
workStorePath,
"msteams_channel_19_abc_thread_tacv2.learnings.json",
);
await fs.mkdir(mainStorePath, { recursive: true });
await fs.mkdir(workStorePath, { recursive: true });
await fs.writeFile(
path.join(workStorePath, "sessions.json"),
JSON.stringify({ sessions: { [sanitizedSessionKey]: {} } }),
);
await fs.writeFile(encodedSourcePath, JSON.stringify(["Be concise", "Use examples"]));
await fs.writeFile(sanitizedSourcePath, JSON.stringify(["Prefer cards for channel feedback"]));
const migration = stateMigrations[0];
const context = createDoctorContext(env);
await context
.openPluginStateKeyedStore({
namespace: "feedback-learnings",
maxEntries: 10_000,
})
.register(learningStoreKey(mainStorePath, encodedSessionKey), {
sessionKey: encodedSessionKey,
learnings: ["Use examples", "New runtime note"],
updatedAt: 1900,
});
await expect(
migration.detectLegacyState({
config: {
session: { store: agentStoreTemplate },
agents: { list: [{ id: "work" }] },
},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
}),
).resolves.toMatchObject({
preview: [expect.stringContaining("2 files")],
});
const result = await migration.migrateLegacyState({
config: {
session: { store: agentStoreTemplate },
agents: { list: [{ id: "work" }] },
},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
});
expect(result.changes).toEqual([
expect.stringContaining("Migrated 2 Microsoft Teams feedback-learning entries"),
expect.stringContaining("Archived Microsoft Teams feedback-learning legacy source"),
expect.stringContaining("Archived Microsoft Teams feedback-learning legacy source"),
]);
expect(result.warnings).toEqual([]);
await expect(fs.access(encodedSourcePath)).rejects.toThrow();
await expect(fs.access(sanitizedSourcePath)).rejects.toThrow();
await expect(fs.access(`${encodedSourcePath}.migrated`)).resolves.toBeUndefined();
await expect(fs.access(`${sanitizedSourcePath}.migrated`)).resolves.toBeUndefined();
const store = context.openPluginStateKeyedStore({
namespace: "feedback-learnings",
maxEntries: 10_000,
});
await expect(
store.lookup(learningStoreKey(mainStorePath, encodedSessionKey)),
).resolves.toMatchObject({
sessionKey: encodedSessionKey,
learnings: ["Be concise", "Use examples", "New runtime note"],
});
await expect(
store.lookup(learningStoreKey(workStorePath, sanitizedSessionKey)),
).resolves.toMatchObject({
sessionKey: sanitizedSessionKey,
learnings: ["Prefer cards for channel feedback"],
});
});
});

View File

@@ -0,0 +1,256 @@
import crypto from "node:crypto";
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
type FeedbackLearningEntry = {
sessionKey: string;
learnings: string[];
updatedAt: number;
};
const LEARNINGS_NAMESPACE = "feedback-learnings";
const MAX_LEARNING_ENTRIES = 10_000;
function encodeSessionKey(sessionKey: string): string {
return Buffer.from(sessionKey, "utf8").toString("base64url");
}
function learningStoreKey(storePath: string, sessionKey: string): string {
return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
}
function decodeSessionKey(fileStem: string): string | null {
try {
const decoded = Buffer.from(fileStem, "base64url").toString("utf8");
return encodeSessionKey(decoded) === fileStem && decoded.trim() ? decoded : null;
} catch {
return null;
}
}
function resolveLearningSessionKey(fileStem: string): string | null {
return decodeSessionKey(fileStem);
}
function legacySanitizeSessionKey(sessionKey: string): string {
return sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
}
async function listKnownSessionKeys(storePath: string): Promise<string[]> {
const candidates = [storePath, path.join(storePath, "sessions.json")];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(await fs.readFile(candidate, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
continue;
}
const sessions =
(parsed as { sessions?: unknown }).sessions &&
typeof (parsed as { sessions?: unknown }).sessions === "object" &&
!Array.isArray((parsed as { sessions?: unknown }).sessions)
? (parsed as { sessions: Record<string, unknown> }).sessions
: (parsed as Record<string, unknown>);
return Object.keys(sessions).filter((key) => key.trim());
} catch {
// Try the next known session index shape/location.
}
}
return [];
}
function resolveLegacySanitizedSessionKey(
fileStem: string,
knownSessionKeys: string[],
): string | null {
const matches = knownSessionKeys.filter(
(sessionKey) => legacySanitizeSessionKey(sessionKey) === fileStem,
);
return matches.length === 1 ? matches[0] : null;
}
function listAgentIds(config: { agents?: { list?: Array<{ id?: unknown }> } }): string[] {
const ids = new Set<string>(["main"]);
for (const agent of config.agents?.list ?? []) {
if (typeof agent.id === "string" && agent.id.trim()) {
ids.add(agent.id.trim());
}
}
return [...ids];
}
function listCandidateStorePaths(params: {
config: Parameters<PluginDoctorStateMigration["migrateLegacyState"]>[0]["config"];
env: NodeJS.ProcessEnv;
}): string[] {
const paths = new Set<string>();
paths.add(resolveStorePath(params.config.session?.store, { env: params.env }));
for (const agentId of listAgentIds(params.config)) {
paths.add(resolveStorePath(params.config.session?.store, { agentId, env: params.env }));
}
return [...paths];
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
async function listLegacyLearningFiles(
storePath: string,
): Promise<
Array<{ storePath: string; sessionKey: string | null; filePath: string; learnings: string[] }>
> {
let entries: Dirent[] = [];
try {
entries = await fs.readdir(storePath, { withFileTypes: true });
} catch {
return [];
}
const suffix = ".learnings.json";
const knownSessionKeys = await listKnownSessionKeys(storePath);
const files: Array<{
storePath: string;
sessionKey: string | null;
filePath: string;
learnings: string[];
}> = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(suffix)) {
continue;
}
const fileStem = entry.name.slice(0, -suffix.length);
const sessionKey =
resolveLearningSessionKey(fileStem) ??
resolveLegacySanitizedSessionKey(fileStem, knownSessionKeys);
const filePath = path.join(storePath, entry.name);
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
if (Array.isArray(parsed)) {
const learnings = parsed.filter((item): item is string => typeof item === "string");
if (learnings.length > 0) {
files.push({ storePath, sessionKey, filePath, learnings: learnings.slice(-10) });
}
}
} catch {
// Malformed legacy feedback notes are ignored by migration.
}
}
return files;
}
async function archiveLegacySource(params: {
filePath: string;
changes: string[];
warnings: string[];
}): Promise<void> {
const archivedPath = `${params.filePath}.migrated`;
if (await fileExists(archivedPath)) {
params.warnings.push(
`Left migrated Microsoft Teams feedback-learning source in place because ${archivedPath} already exists`,
);
return;
}
try {
await fs.rename(params.filePath, archivedPath);
params.changes.push(
`Archived Microsoft Teams feedback-learning legacy source -> ${archivedPath}`,
);
} catch (err) {
params.warnings.push(
`Failed archiving Microsoft Teams feedback-learning legacy source: ${String(err)}`,
);
}
}
function mergeLearnings(legacy: string[], existing?: FeedbackLearningEntry): string[] {
const seen = new Set<string>();
const merged: string[] = [];
for (const learning of [...legacy, ...(existing?.learnings ?? [])]) {
if (seen.has(learning)) {
continue;
}
seen.add(learning);
merged.push(learning);
}
return merged.slice(-10);
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "msteams-feedback-learnings-json-to-plugin-state",
label: "Microsoft Teams feedback learnings",
async detectLegacyState(params) {
const files = (
await Promise.all(
listCandidateStorePaths(params).map((storePath) => listLegacyLearningFiles(storePath)),
)
).flat();
if (files.length === 0) {
return null;
}
return {
preview: [
`- Microsoft Teams feedback learnings: ${files.length} ${files.length === 1 ? "file" : "files"} -> plugin state (${LEARNINGS_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const files = (
await Promise.all(
listCandidateStorePaths(params).map((storePath) => listLegacyLearningFiles(storePath)),
)
).flat();
const store = params.context.openPluginStateKeyedStore<FeedbackLearningEntry>({
namespace: LEARNINGS_NAMESPACE,
maxEntries: MAX_LEARNING_ENTRIES,
});
const existingEntries = await store.entries();
const existingKeys = new Set(existingEntries.map((entry) => entry.key));
const importableFiles = files.filter((file) => file.sessionKey);
const missingKeys = new Set(
importableFiles
.map((file) => learningStoreKey(file.storePath, file.sessionKey ?? ""))
.filter((key) => !existingKeys.has(key)),
);
if (missingKeys.size > MAX_LEARNING_ENTRIES - existingKeys.size) {
warnings.push(
`Skipped Microsoft Teams feedback-learning migration because plugin state has room for ${MAX_LEARNING_ENTRIES - existingKeys.size} of ${missingKeys.size} missing entries; left legacy sources in place`,
);
return { changes, warnings };
}
let imported = 0;
for (const file of files) {
if (!file.sessionKey) {
warnings.push(
`Left Microsoft Teams feedback-learning source in place because its legacy filename cannot be mapped to a session key: ${file.filePath}`,
);
continue;
}
const key = learningStoreKey(file.storePath, file.sessionKey);
const existing = await store.lookup(key);
await store.register(key, {
sessionKey: existing?.sessionKey ?? file.sessionKey,
learnings: mergeLearnings(file.learnings, existing),
updatedAt: Date.now(),
});
imported++;
await archiveLegacySource({ filePath: file.filePath, changes, warnings });
}
if (imported > 0) {
changes.unshift(
`Migrated ${imported} Microsoft Teams feedback-learning ${imported === 1 ? "entry" : "entries"} -> plugin state`,
);
}
return { changes, warnings };
},
},
];

View File

@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import crypto from "node:crypto";
import { getMSTeamsRuntime } from "./runtime.js";
/** Default cooldown between reflections per session (5 minutes). */
export const DEFAULT_COOLDOWN_MS = 300_000;
@@ -9,33 +9,24 @@ const lastReflectionBySession = new Map<string, number>();
/** Maximum cooldown entries before pruning expired ones. */
const MAX_COOLDOWN_ENTRIES = 500;
const LEARNINGS_NAMESPACE = "feedback-learnings";
const MAX_LEARNING_ENTRIES = 10_000;
function legacySanitizeSessionKey(sessionKey: string): string {
return sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
type FeedbackLearningEntry = {
sessionKey: string;
learnings: string[];
updatedAt: number;
};
function learningStoreKey(storePath: string, sessionKey: string): string {
return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
}
function encodeSessionKey(sessionKey: string): string {
return Buffer.from(sessionKey, "utf8").toString("base64url");
}
function resolveLearningsFilePath(storePath: string, sessionKey: string): string {
return `${storePath}/${encodeSessionKey(sessionKey)}.learnings.json`;
}
function resolveLegacyLearningsFilePath(storePath: string, sessionKey: string): string {
return `${storePath}/${legacySanitizeSessionKey(sessionKey)}.learnings.json`;
}
async function readLearningsFile(
filePath: string,
): Promise<{ exists: boolean; learnings: string[] }> {
try {
const content = await fs.readFile(filePath, "utf-8");
const parsed = JSON.parse(content);
return { exists: true, learnings: Array.isArray(parsed) ? parsed : [] };
} catch {
return { exists: false, learnings: [] };
}
function openLearningStore() {
return getMSTeamsRuntime().state.openKeyedStore<FeedbackLearningEntry>({
namespace: LEARNINGS_NAMESPACE,
maxEntries: MAX_LEARNING_ENTRIES,
});
}
/** Prune expired cooldown entries to prevent unbounded memory growth. */
@@ -72,31 +63,25 @@ export function clearReflectionCooldowns(): void {
lastReflectionBySession.clear();
}
/** Store a learning derived from feedback reflection in a session companion file. */
/** Store a learning derived from feedback reflection. */
export async function storeSessionLearning(params: {
storePath: string;
sessionKey: string;
learning: string;
}): Promise<void> {
const learningsFile = resolveLearningsFilePath(params.storePath, params.sessionKey);
const legacyLearningsFile = resolveLegacyLearningsFilePath(params.storePath, params.sessionKey);
const { exists, learnings: existingLearnings } = await readLearningsFile(learningsFile);
const { learnings: legacyLearnings } =
exists || legacyLearningsFile === learningsFile
? { learnings: [] as string[] }
: await readLearningsFile(legacyLearningsFile);
let learnings = exists ? existingLearnings : legacyLearnings;
const store = openLearningStore();
const key = learningStoreKey(params.storePath, params.sessionKey);
const existing = await store.lookup(key);
let learnings = existing?.learnings ?? [];
learnings.push(params.learning);
if (learnings.length > 10) {
learnings = learnings.slice(-10);
}
await writeJsonFileAtomically(learningsFile, learnings);
if (!exists && legacyLearningsFile !== learningsFile) {
await fs.rm(legacyLearningsFile, { force: true }).catch(() => undefined);
}
await store.register(key, {
sessionKey: params.sessionKey,
learnings,
updatedAt: Date.now(),
});
}
/** Load session learnings for injection into extraSystemPrompt. */
@@ -104,10 +89,10 @@ export async function loadSessionLearnings(
storePath: string,
sessionKey: string,
): Promise<string[]> {
const learningsFile = resolveLearningsFilePath(storePath, sessionKey);
const { exists, learnings } = await readLearningsFile(learningsFile);
if (exists) {
return learnings;
const key = learningStoreKey(storePath, sessionKey);
const stored = await openLearningStore().lookup(key);
if (stored) {
return stored.learnings;
}
return (await readLearningsFile(resolveLegacyLearningsFilePath(storePath, sessionKey))).learnings;
return [];
}

View File

@@ -1,7 +1,8 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { storeSessionLearning } from "./feedback-reflection-store.js";
import {
buildFeedbackEvent,
@@ -12,6 +13,10 @@ import {
parseReflectionResponse,
recordReflectionTime,
} from "./feedback-reflection.js";
import { setMSTeamsRuntime } from "./runtime.js";
import { msteamsRuntimeStub } from "./test-support/runtime.js";
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
describe("buildFeedbackEvent", () => {
it("builds a well-formed custom event", () => {
@@ -161,7 +166,17 @@ describe("reflection cooldown", () => {
describe("loadSessionLearnings", () => {
let tmpDir: string;
beforeEach(() => {
resetPluginStateStoreForTests();
setMSTeamsRuntime(msteamsRuntimeStub);
});
afterEach(async () => {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
@@ -169,15 +184,24 @@ describe("loadSessionLearnings", () => {
it("returns empty array when file doesn't exist", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
const learnings = await loadSessionLearnings(tmpDir, "nonexistent");
expect(learnings).toStrictEqual([]);
});
it("reads existing learnings", async () => {
it("reads persisted learnings from plugin state", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
const safeKey = Buffer.from("msteams:user1", "utf8").toString("base64url");
const filePath = path.join(tmpDir, `${safeKey}.learnings.json`);
await writeFile(filePath, JSON.stringify(["Be concise", "Use examples"]), "utf-8");
process.env.OPENCLAW_STATE_DIR = tmpDir;
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams:user1",
learning: "Be concise",
});
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams:user1",
learning: "Use examples",
});
const learnings = await loadSessionLearnings(tmpDir, "msteams:user1");
expect(learnings).toEqual(["Be concise", "Use examples"]);
@@ -185,6 +209,7 @@ describe("loadSessionLearnings", () => {
it("keeps distinct session keys isolated across the filename persistence boundary", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
await storeSessionLearning({
storePath: tmpDir,
@@ -201,37 +226,28 @@ describe("loadSessionLearnings", () => {
await expect(loadSessionLearnings(tmpDir, "msteams/user1")).resolves.toEqual(["Avoid bullets"]);
});
it("reads and migrates legacy sanitized session learning files", async () => {
it("keeps the same session key isolated by store path", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
const legacyFile = path.join(tmpDir, "msteams_user1.learnings.json");
await writeFile(legacyFile, JSON.stringify(["Legacy learning"]), "utf-8");
await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual([
"Legacy learning",
]);
process.env.OPENCLAW_STATE_DIR = tmpDir;
const workStorePath = path.join(tmpDir, "work");
const opsStorePath = path.join(tmpDir, "ops");
await storeSessionLearning({
storePath: tmpDir,
storePath: workStorePath,
sessionKey: "msteams:user1",
learning: "New learning",
learning: "Use bullets",
});
await storeSessionLearning({
storePath: opsStorePath,
sessionKey: "msteams:user1",
learning: "Avoid bullets",
});
const migratedFile = path.join(
tmpDir,
`${Buffer.from("msteams:user1", "utf8").toString("base64url")}.learnings.json`,
);
await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual([
"Legacy learning",
"New learning",
await expect(loadSessionLearnings(workStorePath, "msteams:user1")).resolves.toEqual([
"Use bullets",
]);
await expect(rm(legacyFile, { force: false })).rejects.toHaveProperty("code", "ENOENT");
await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual([
"Legacy learning",
"New learning",
await expect(loadSessionLearnings(opsStorePath, "msteams:user1")).resolves.toEqual([
"Avoid bullets",
]);
await expect(loadSessionLearnings(tmpDir, "msteams/user1")).resolves.toStrictEqual([]);
await expect(
import("node:fs/promises").then((fs) => fs.readFile(migratedFile, "utf-8")),
).resolves.toContain("Legacy learning");
});
});

View File

@@ -0,0 +1,104 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type {
OpenKeyedStoreOptions,
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("nostr", {
...options,
env: options.env ?? env,
});
},
};
}
describe("nostr doctor state migration", () => {
let stateDir = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
resetPluginStateStoreForTests();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-nostr-doctor-"));
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
it("imports legacy bus and profile state into plugin state", async () => {
const nostrDir = path.join(stateDir, "nostr");
const busPath = path.join(nostrDir, "bus-state-main.json");
const profilePath = path.join(nostrDir, "profile-state-main.json");
await fs.mkdir(nostrDir, { recursive: true });
await fs.writeFile(
busPath,
JSON.stringify({
version: 1,
lastProcessedAt: 1700,
gatewayStartedAt: 1600,
}),
);
await fs.writeFile(
profilePath,
JSON.stringify({
version: 1,
lastPublishedAt: 1800,
lastPublishedEventId: "event-1",
lastPublishResults: { "wss://relay.example": "ok", bad: "nope" },
}),
);
const context = createDoctorContext(env);
const busResult = await stateMigrations[0].migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
});
const profileResult = await stateMigrations[1].migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
});
expect(busResult.warnings).toEqual([]);
expect(profileResult.warnings).toEqual([]);
await expect(fs.access(busPath)).rejects.toThrow();
await expect(fs.access(profilePath)).rejects.toThrow();
await expect(fs.access(`${busPath}.migrated`)).resolves.toBeUndefined();
await expect(fs.access(`${profilePath}.migrated`)).resolves.toBeUndefined();
await expect(
context.openPluginStateKeyedStore({ namespace: "bus-state", maxEntries: 256 }).lookup("main"),
).resolves.toEqual({
version: 2,
lastProcessedAt: 1700,
gatewayStartedAt: 1600,
recentEventIds: [],
});
await expect(
context
.openPluginStateKeyedStore({ namespace: "profile-state", maxEntries: 256 })
.lookup("main"),
).resolves.toEqual({
version: 1,
lastPublishedAt: 1800,
lastPublishedEventId: "event-1",
lastPublishResults: { "wss://relay.example": "ok" },
});
});
});

View File

@@ -0,0 +1,296 @@
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
type NostrBusState = {
version: 2;
lastProcessedAt: number | null;
gatewayStartedAt: number | null;
recentEventIds: string[];
};
type NostrProfileState = {
version: 1;
lastPublishedAt: number | null;
lastPublishedEventId: string | null;
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
};
const BUS_STATE_NAMESPACE = "bus-state";
const PROFILE_STATE_NAMESPACE = "profile-state";
const MAX_NOSTR_STATE_ENTRIES = 256;
function normalizeAccountId(accountId?: string): string {
const trimmed = accountId?.trim();
if (!trimmed) {
return "default";
}
return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
}
function finiteNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function parseBusState(value: unknown): NostrBusState | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const parsed = value as Record<string, unknown>;
if (parsed.version !== 1 && parsed.version !== 2) {
return null;
}
return {
version: 2,
lastProcessedAt: finiteNumberOrNull(parsed.lastProcessedAt),
gatewayStartedAt: finiteNumberOrNull(parsed.gatewayStartedAt),
recentEventIds:
parsed.version === 2 && Array.isArray(parsed.recentEventIds)
? parsed.recentEventIds.filter((entry): entry is string => typeof entry === "string")
: [],
};
}
function parseProfileState(value: unknown): NostrProfileState | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const parsed = value as Record<string, unknown>;
if (parsed.version !== 1) {
return null;
}
const rawResults = parsed.lastPublishResults;
const lastPublishResults: Record<string, "ok" | "failed" | "timeout"> = {};
if (rawResults && typeof rawResults === "object" && !Array.isArray(rawResults)) {
for (const [relay, result] of Object.entries(rawResults)) {
if (result === "ok" || result === "failed" || result === "timeout") {
lastPublishResults[relay] = result;
}
}
}
return {
version: 1,
lastPublishedAt: finiteNumberOrNull(parsed.lastPublishedAt),
lastPublishedEventId:
typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null,
lastPublishResults:
rawResults === null || Object.keys(lastPublishResults).length === 0
? null
: lastPublishResults,
};
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
async function readJsonFile(filePath: string): Promise<unknown> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
}
async function listLegacyFiles(params: {
stateDir: string;
prefix: string;
parse: (value: unknown) => unknown;
}): Promise<Array<{ accountId: string; filePath: string; value: unknown }>> {
const dir = path.join(params.stateDir, "nostr");
let entries: Dirent[] = [];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const suffix = ".json";
const files: Array<{ accountId: string; filePath: string; value: unknown }> = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.startsWith(params.prefix) || !entry.name.endsWith(suffix)) {
continue;
}
const rawAccountId = entry.name.slice(params.prefix.length, -suffix.length);
const accountId = normalizeAccountId(rawAccountId);
const filePath = path.join(dir, entry.name);
try {
const value = params.parse(await readJsonFile(filePath));
if (value) {
files.push({ accountId, filePath, value });
}
} catch {
// Malformed legacy cache/cursor files are ignored by migration.
}
}
return files;
}
async function archiveLegacySource(params: {
filePath: string;
label: string;
changes: string[];
warnings: string[];
}): Promise<void> {
const archivedPath = `${params.filePath}.migrated`;
if (await fileExists(archivedPath)) {
params.warnings.push(
`Left migrated ${params.label} source in place because ${archivedPath} already exists`,
);
return;
}
try {
await fs.rename(params.filePath, archivedPath);
params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`);
} catch (err) {
params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`);
}
}
async function ensureStoreCapacity(params: {
files: Array<{ accountId: string }>;
store: { entries: () => Promise<Array<{ key: string; value: unknown }>> };
maxEntries: number;
label: string;
warnings: string[];
}): Promise<Set<string> | null> {
const existingKeys = new Set((await params.store.entries()).map((entry) => entry.key));
const missingKeys = new Set(
params.files.map((file) => file.accountId).filter((key) => !existingKeys.has(key)),
);
if (missingKeys.size > params.maxEntries - existingKeys.size) {
params.warnings.push(
`Skipped migrating ${params.label} because plugin state has room for ${params.maxEntries - existingKeys.size} of ${missingKeys.size} missing entries; left legacy sources in place`,
);
return null;
}
return existingKeys;
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "nostr-bus-state-json-to-plugin-state",
label: "Nostr bus state",
async detectLegacyState(params) {
const files = await listLegacyFiles({
stateDir: params.stateDir,
prefix: "bus-state-",
parse: parseBusState,
});
if (files.length === 0) {
return null;
}
return {
preview: [
`- Nostr bus state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${BUS_STATE_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const files = await listLegacyFiles({
stateDir: params.stateDir,
prefix: "bus-state-",
parse: parseBusState,
});
const store = params.context.openPluginStateKeyedStore<NostrBusState>({
namespace: BUS_STATE_NAMESPACE,
maxEntries: MAX_NOSTR_STATE_ENTRIES,
});
const existingKeys = await ensureStoreCapacity({
files,
store,
maxEntries: MAX_NOSTR_STATE_ENTRIES,
label: "Nostr bus state",
warnings,
});
if (!existingKeys) {
return { changes, warnings };
}
let imported = 0;
for (const file of files) {
if (!existingKeys.has(file.accountId)) {
await store.register(file.accountId, file.value as NostrBusState);
existingKeys.add(file.accountId);
imported++;
}
await archiveLegacySource({
filePath: file.filePath,
label: "Nostr bus state",
changes,
warnings,
});
}
if (imported > 0) {
changes.unshift(
`Migrated ${imported} Nostr bus-state ${imported === 1 ? "entry" : "entries"} -> plugin state`,
);
}
return { changes, warnings };
},
},
{
id: "nostr-profile-state-json-to-plugin-state",
label: "Nostr profile state",
async detectLegacyState(params) {
const files = await listLegacyFiles({
stateDir: params.stateDir,
prefix: "profile-state-",
parse: parseProfileState,
});
if (files.length === 0) {
return null;
}
return {
preview: [
`- Nostr profile state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${PROFILE_STATE_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const files = await listLegacyFiles({
stateDir: params.stateDir,
prefix: "profile-state-",
parse: parseProfileState,
});
const store = params.context.openPluginStateKeyedStore<NostrProfileState>({
namespace: PROFILE_STATE_NAMESPACE,
maxEntries: MAX_NOSTR_STATE_ENTRIES,
});
const existingKeys = await ensureStoreCapacity({
files,
store,
maxEntries: MAX_NOSTR_STATE_ENTRIES,
label: "Nostr profile state",
warnings,
});
if (!existingKeys) {
return { changes, warnings };
}
let imported = 0;
for (const file of files) {
if (!existingKeys.has(file.accountId)) {
await store.register(file.accountId, file.value as NostrProfileState);
existingKeys.add(file.accountId);
imported++;
}
await archiveLegacySource({
filePath: file.filePath,
label: "Nostr profile state",
changes,
warnings,
});
}
if (imported > 0) {
changes.unshift(
`Migrated ${imported} Nostr profile-state ${imported === 1 ? "entry" : "entries"} -> plugin state`,
);
}
return { changes, warnings };
},
},
];

View File

@@ -1,6 +1,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { describe, expect, it } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
import {
@@ -16,8 +21,14 @@ async function withTempStateDir<T>(fn: (dir: string) => Promise<T>) {
const previous = process.env.OPENCLAW_STATE_DIR;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-nostr-"));
process.env.OPENCLAW_STATE_DIR = dir;
resetPluginStateStoreForTests();
setNostrRuntime({
state: {
openKeyedStore: (options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests("nostr", {
...options,
env: { ...process.env, OPENCLAW_STATE_DIR: dir },
}),
resolveStateDir: (env, homedir) => {
const stateEnv = env ?? process.env;
const override = stateEnv.OPENCLAW_STATE_DIR?.trim();
@@ -85,55 +96,6 @@ describe("nostr bus state store", () => {
expect(stateB?.lastProcessedAt).toBe(2000);
});
});
it("upgrades v1 bus state files on read", async () => {
await withTempStateDir(async (dir) => {
const filePath = path.join(dir, "nostr", "bus-state-test-bot.json");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify({
version: 1,
lastProcessedAt: 1700000000,
gatewayStartedAt: 1700000100,
}),
"utf-8",
);
const state = await readNostrBusState({ accountId: "test-bot" });
expect(state).toEqual({
version: 2,
lastProcessedAt: 1700000000,
gatewayStartedAt: 1700000100,
recentEventIds: [],
});
});
});
it("drops malformed recent event ids while keeping the state", async () => {
await withTempStateDir(async (dir) => {
const filePath = path.join(dir, "nostr", "bus-state-test-bot.json");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify({
version: 2,
lastProcessedAt: 1700000000,
gatewayStartedAt: 1700000100,
recentEventIds: ["evt-1", 2, null],
}),
"utf-8",
);
const state = await readNostrBusState({ accountId: "test-bot" });
expect(state).toEqual({
version: 2,
lastProcessedAt: 1700000000,
gatewayStartedAt: 1700000100,
recentEventIds: ["evt-1"],
});
});
});
});
describe("nostr profile state store", () => {
@@ -159,34 +121,6 @@ describe("nostr profile state store", () => {
});
});
});
it("drops malformed relay results while keeping valid state fields", async () => {
await withTempStateDir(async (dir) => {
const filePath = path.join(dir, "nostr", "profile-state-test-bot.json");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify({
version: 1,
lastPublishedAt: 1700000000,
lastPublishedEventId: "evt-1",
lastPublishResults: {
"wss://relay.example": "ok",
"wss://relay.bad": "unknown",
},
}),
"utf-8",
);
const state = await readNostrProfileState({ accountId: "test-bot" });
expect(state).toEqual({
version: 1,
lastPublishedAt: 1700000000,
lastPublishedEventId: "evt-1",
lastPublishResults: null,
});
});
});
});
describe("computeSinceTimestamp", () => {

View File

@@ -1,8 +1,3 @@
import os from "node:os";
import path from "node:path";
import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { privateFileStore } from "openclaw/plugin-sdk/security-runtime";
import { z } from "zod";
import { getNostrRuntime } from "./runtime.js";
const STORE_VERSION = 2;
@@ -29,33 +24,6 @@ type NostrProfileState = {
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
};
const NullableFiniteNumberSchema = z.number().finite().nullable().catch(null);
const NostrBusStateV1Schema = z.object({
version: z.literal(1),
lastProcessedAt: NullableFiniteNumberSchema,
gatewayStartedAt: NullableFiniteNumberSchema,
});
const NostrBusStateSchema = z.object({
version: z.literal(2),
lastProcessedAt: NullableFiniteNumberSchema,
gatewayStartedAt: NullableFiniteNumberSchema,
recentEventIds: z
.array(z.unknown())
.catch([])
.transform((ids) => ids.filter((id): id is string => typeof id === "string")),
});
const NostrProfileStateSchema = z.object({
version: z.literal(1),
lastPublishedAt: NullableFiniteNumberSchema,
lastPublishedEventId: z.string().nullable().catch(null),
lastPublishResults: z
.record(z.string(), z.enum(["ok", "failed", "timeout"]))
.nullable()
.catch(null),
});
function normalizeAccountId(accountId?: string): string {
const trimmed = accountId?.trim();
if (!trimmed) {
@@ -64,57 +32,29 @@ function normalizeAccountId(accountId?: string): string {
return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
}
function resolveNostrStatePath(accountId?: string, env: NodeJS.ProcessEnv = process.env): string {
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
const normalized = normalizeAccountId(accountId);
return path.join(stateDir, "nostr", `bus-state-${normalized}.json`);
function openNostrBusStateStore(env?: NodeJS.ProcessEnv) {
return getNostrRuntime().state.openKeyedStore<NostrBusState>({
namespace: "bus-state",
maxEntries: 256,
...(env ? { env } : {}),
});
}
function resolveNostrProfileStatePath(
accountId?: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir);
const normalized = normalizeAccountId(accountId);
return path.join(stateDir, "nostr", `profile-state-${normalized}.json`);
}
function safeParseState(raw: string): NostrBusState | null {
const parsedV2 = safeParseJsonWithSchema(NostrBusStateSchema, raw);
if (parsedV2) {
return parsedV2;
}
const parsedV1 = safeParseJsonWithSchema(NostrBusStateV1Schema, raw);
if (!parsedV1) {
return null;
}
// Back-compat: v1 state files
return {
version: 2,
lastProcessedAt: parsedV1.lastProcessedAt,
gatewayStartedAt: parsedV1.gatewayStartedAt,
recentEventIds: [],
};
function openNostrProfileStateStore(env?: NodeJS.ProcessEnv) {
return getNostrRuntime().state.openKeyedStore<NostrProfileState>({
namespace: "profile-state",
maxEntries: 256,
...(env ? { env } : {}),
});
}
export async function readNostrBusState(params: {
accountId?: string;
env?: NodeJS.ProcessEnv;
}): Promise<NostrBusState | null> {
const filePath = resolveNostrStatePath(params.accountId, params.env);
try {
const raw = await privateFileStore(path.dirname(filePath)).readTextIfExists(
path.basename(filePath),
);
if (raw === null) {
return null;
}
return safeParseState(raw);
} catch {
return null;
}
return (
(await openNostrBusStateStore(params.env).lookup(normalizeAccountId(params.accountId))) ?? null
);
}
export async function writeNostrBusState(params: {
@@ -124,21 +64,18 @@ export async function writeNostrBusState(params: {
recentEventIds?: string[];
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const filePath = resolveNostrStatePath(params.accountId, params.env);
const payload: NostrBusState = {
version: STORE_VERSION,
lastProcessedAt: params.lastProcessedAt,
gatewayStartedAt: params.gatewayStartedAt,
recentEventIds: (params.recentEventIds ?? []).filter((x): x is string => typeof x === "string"),
};
await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), payload, {
trailingNewline: true,
});
await openNostrBusStateStore(params.env).register(normalizeAccountId(params.accountId), payload);
}
/**
* Determine the `since` timestamp for subscription.
* Returns the later of: lastProcessedAt or gatewayStartedAt (both from disk),
* Returns the later of: lastProcessedAt or gatewayStartedAt (both from state),
* falling back to `now` for fresh starts.
*/
export function computeSinceTimestamp(
@@ -164,26 +101,14 @@ export function computeSinceTimestamp(
// Profile State Management
// ============================================================================
function safeParseProfileState(raw: string): NostrProfileState | null {
return safeParseJsonWithSchema(NostrProfileStateSchema, raw);
}
export async function readNostrProfileState(params: {
accountId?: string;
env?: NodeJS.ProcessEnv;
}): Promise<NostrProfileState | null> {
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
try {
const raw = await privateFileStore(path.dirname(filePath)).readTextIfExists(
path.basename(filePath),
);
if (raw === null) {
return null;
}
return safeParseProfileState(raw);
} catch {
return null;
}
return (
(await openNostrProfileStateStore(params.env).lookup(normalizeAccountId(params.accountId))) ??
null
);
}
export async function writeNostrProfileState(params: {
@@ -193,14 +118,14 @@ export async function writeNostrProfileState(params: {
lastPublishResults: Record<string, "ok" | "failed" | "timeout">;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const filePath = resolveNostrProfileStatePath(params.accountId, params.env);
const payload: NostrProfileState = {
version: PROFILE_STATE_VERSION,
lastPublishedAt: params.lastPublishedAt,
lastPublishedEventId: params.lastPublishedEventId,
lastPublishResults: params.lastPublishResults,
};
await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), payload, {
trailingNewline: true,
});
await openNostrProfileStateStore(params.env).register(
normalizeAccountId(params.accountId),
payload,
);
}

View File

@@ -0,0 +1,91 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type {
OpenKeyedStoreOptions,
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("phone-control", {
...options,
env: options.env ?? env,
});
},
};
}
describe("phone-control doctor state migration", () => {
let stateDir = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
resetPluginStateStoreForTests();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-phone-control-doctor-"));
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
it("imports legacy armed state into plugin state", async () => {
const sourcePath = path.join(stateDir, "plugins", "phone-control", "armed.json");
const legacyState = {
version: 2,
armedAtMs: 100,
expiresAtMs: 200,
group: "writes",
armedCommands: ["sms.send"],
addedToAllow: ["sms.send"],
removedFromDeny: [],
};
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(sourcePath, JSON.stringify(legacyState));
const migration = stateMigrations[0];
await expect(
migration.detectLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toMatchObject({
preview: [expect.stringContaining("Phone Control armed state")],
});
const result = await migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
});
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
"Migrated Phone Control armed state -> plugin state",
expect.stringContaining("Archived Phone Control armed-state legacy source"),
]);
await expect(fs.access(sourcePath)).rejects.toThrow();
await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined();
await expect(
createDoctorContext(env)
.openPluginStateKeyedStore({
namespace: "armed",
maxEntries: 1,
})
.lookup("current"),
).resolves.toEqual(legacyState);
});
});

View File

@@ -0,0 +1,162 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
type ArmGroup = "camera" | "screen" | "writes" | "all";
type ArmStateFileV1 = {
version: 1;
armedAtMs: number;
expiresAtMs: number | null;
removedFromDeny: string[];
};
type ArmStateFileV2 = {
version: 2;
armedAtMs: number;
expiresAtMs: number | null;
group: ArmGroup;
armedCommands: string[];
addedToAllow: string[];
removedFromDeny: string[];
};
type ArmStateFile = ArmStateFileV1 | ArmStateFileV2;
const ARM_STATE_NAMESPACE = "armed";
const ARM_STATE_KEY = "current";
function resolveArmStatePath(stateDir: string): string {
return path.join(stateDir, "plugins", "phone-control", "armed.json");
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
}
function parseArmState(value: unknown): ArmStateFile | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const parsed = value as Record<string, unknown>;
if (parsed.version !== 1 && parsed.version !== 2) {
return null;
}
if (typeof parsed.armedAtMs !== "number") {
return null;
}
if (!(parsed.expiresAtMs === null || typeof parsed.expiresAtMs === "number")) {
return null;
}
if (parsed.version === 1) {
if (!isStringArray(parsed.removedFromDeny)) {
return null;
}
return {
version: 1,
armedAtMs: parsed.armedAtMs,
expiresAtMs: parsed.expiresAtMs,
removedFromDeny: parsed.removedFromDeny,
};
}
const group = typeof parsed.group === "string" ? parsed.group : "";
if (group !== "camera" && group !== "screen" && group !== "writes" && group !== "all") {
return null;
}
if (
!isStringArray(parsed.armedCommands) ||
!isStringArray(parsed.addedToAllow) ||
!isStringArray(parsed.removedFromDeny)
) {
return null;
}
return {
version: 2,
armedAtMs: parsed.armedAtMs,
expiresAtMs: parsed.expiresAtMs,
group,
armedCommands: parsed.armedCommands,
addedToAllow: parsed.addedToAllow,
removedFromDeny: parsed.removedFromDeny,
};
}
async function readLegacyArmState(filePath: string): Promise<ArmStateFile | null> {
try {
return parseArmState(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown);
} catch {
return null;
}
}
async function archiveLegacySource(params: {
filePath: string;
changes: string[];
warnings: string[];
}): Promise<void> {
const archivedPath = `${params.filePath}.migrated`;
if (await fileExists(archivedPath)) {
params.warnings.push(
`Left migrated Phone Control armed-state source in place because ${archivedPath} already exists`,
);
return;
}
try {
await fs.rename(params.filePath, archivedPath);
params.changes.push(`Archived Phone Control armed-state legacy source -> ${archivedPath}`);
} catch (err) {
params.warnings.push(
`Failed archiving Phone Control armed-state legacy source: ${String(err)}`,
);
}
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "phone-control-armed-json-to-plugin-state",
label: "Phone Control armed state",
async detectLegacyState(params) {
const filePath = resolveArmStatePath(params.stateDir);
const state = await readLegacyArmState(filePath);
if (!state) {
return null;
}
return {
preview: [
`- Phone Control armed state: ${filePath} -> plugin state (${ARM_STATE_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const filePath = resolveArmStatePath(params.stateDir);
const state = await readLegacyArmState(filePath);
if (!state) {
return { changes, warnings };
}
const store = params.context.openPluginStateKeyedStore<ArmStateFile>({
namespace: ARM_STATE_NAMESPACE,
maxEntries: 1,
});
const existing = await store.lookup(ARM_STATE_KEY);
if (existing) {
warnings.push("Left Phone Control armed-state source in place because plugin state exists");
return { changes, warnings };
}
await store.register(ARM_STATE_KEY, state);
changes.push("Migrated Phone Control armed state -> plugin state");
await archiveLegacySource({ filePath, changes, warnings });
return { changes, warnings };
},
},
];

View File

@@ -1,8 +1,13 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import registerPhoneControl from "./index.js";
import type {
OpenClawPluginApi,
@@ -28,6 +33,11 @@ function createApi(params: {
runtime: {
state: {
resolveStateDir: () => params.stateDir,
openKeyedStore: (options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests("phone-control", {
...options,
env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir },
}),
},
config: {
current: () => params.getConfig(),
@@ -126,6 +136,10 @@ async function withRegisteredPhoneControl(
}
describe("phone-control plugin", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
});
it("arms sms.send as part of the writes group", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile, getConfig }) => {
expect(command.name).toBe("phone");

View File

@@ -1,11 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
@@ -40,7 +37,8 @@ type ArmStateFileV2 = {
type ArmStateFile = ArmStateFileV1 | ArmStateFileV2;
const STATE_VERSION = 2;
const STATE_REL_PATH = ["plugins", "phone-control", "armed.json"] as const;
const ARM_STATE_NAMESPACE = "armed";
const ARM_STATE_KEY = "current";
const PHONE_ADMIN_SCOPE = "operator.admin";
const GROUP_COMMANDS: Record<Exclude<ArmGroup, "all">, string[]> = {
@@ -100,77 +98,24 @@ function formatDuration(ms: number): string {
return `${d}d`;
}
function resolveStatePath(stateDir: string): string {
return path.join(stateDir, ...STATE_REL_PATH);
function openArmStateStore(api: OpenClawPluginApi) {
return api.runtime.state.openKeyedStore<ArmStateFile>({
namespace: ARM_STATE_NAMESPACE,
maxEntries: 1,
});
}
async function readArmState(statePath: string): Promise<ArmStateFile | null> {
try {
const raw = await fs.readFile(statePath, "utf8");
// Type as unknown record first to allow property access during validation
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (parsed.version !== 1 && parsed.version !== 2) {
return null;
}
if (typeof parsed.armedAtMs !== "number") {
return null;
}
if (!(parsed.expiresAtMs === null || typeof parsed.expiresAtMs === "number")) {
return null;
}
if (parsed.version === 1) {
if (
!Array.isArray(parsed.removedFromDeny) ||
!parsed.removedFromDeny.every((v: unknown) => typeof v === "string")
) {
return null;
}
return parsed as unknown as ArmStateFile;
}
const group = typeof parsed.group === "string" ? parsed.group : "";
if (group !== "camera" && group !== "screen" && group !== "writes" && group !== "all") {
return null;
}
if (
!Array.isArray(parsed.armedCommands) ||
!parsed.armedCommands.every((v: unknown) => typeof v === "string")
) {
return null;
}
if (
!Array.isArray(parsed.addedToAllow) ||
!parsed.addedToAllow.every((v: unknown) => typeof v === "string")
) {
return null;
}
if (
!Array.isArray(parsed.removedFromDeny) ||
!parsed.removedFromDeny.every((v: unknown) => typeof v === "string")
) {
return null;
}
return parsed as unknown as ArmStateFile;
} catch {
return null;
}
async function readArmState(api: OpenClawPluginApi): Promise<ArmStateFile | null> {
return (await openArmStateStore(api).lookup(ARM_STATE_KEY)) ?? null;
}
async function writeArmState(statePath: string, state: ArmStateFile | null): Promise<void> {
async function writeArmState(api: OpenClawPluginApi, state: ArmStateFile | null): Promise<void> {
const store = openArmStateStore(api);
if (!state) {
try {
await fs.unlink(statePath);
} catch {
// ignore
}
await store.delete(ARM_STATE_KEY);
return;
}
await replaceFileAtomic({
filePath: statePath,
content: `${JSON.stringify(state, null, 2)}\n`,
tempPrefix: ".phone-control-arm",
});
await store.register(ARM_STATE_KEY, state);
}
function normalizeDenyList(cfg: OpenClawPluginApi["config"]): string[] {
@@ -200,12 +145,10 @@ function patchConfigNodeLists(
async function disarmNow(params: {
api: OpenClawPluginApi;
stateDir: string;
statePath: string;
reason: string;
}): Promise<{ changed: boolean; restored: string[]; removed: string[] }> {
const { api, stateDir, statePath, reason } = params;
const state = await readArmState(statePath);
const { api, reason } = params;
const state = await readArmState(api);
if (!state) {
return { changed: false, restored: [], removed: [] };
}
@@ -248,8 +191,8 @@ async function disarmNow(params: {
},
});
}
await writeArmState(statePath, null);
api.logger.info(`phone-control: disarmed (${reason}) stateDir=${stateDir}`);
await writeArmState(api, null);
api.logger.info(`phone-control: disarmed (${reason})`);
return {
changed: removed.length > 0 || restored.length > 0,
removed: uniqSorted(removed),
@@ -350,10 +293,9 @@ export default definePluginEntry({
const timerService: OpenClawPluginService = {
id: "phone-control-expiry",
start: async (ctx) => {
const statePath = resolveStatePath(ctx.stateDir);
start: async () => {
const tick = async () => {
const state = await readArmState(statePath);
const state = await readArmState(api);
if (!state || state.expiresAtMs == null) {
return;
}
@@ -362,8 +304,6 @@ export default definePluginEntry({
}
await disarmNow({
api,
stateDir: ctx.stateDir,
statePath,
reason: "expired",
});
};
@@ -396,16 +336,13 @@ export default definePluginEntry({
const tokens = args.split(/\s+/).filter(Boolean);
const action = normalizeLowercaseStringOrEmpty(tokens[0]);
const stateDir = api.runtime.state.resolveStateDir();
const statePath = resolveStatePath(stateDir);
if (!action || action === "help") {
const state = await readArmState(statePath);
const state = await readArmState(api);
return { text: `${formatStatus(state)}\n\n${formatHelp()}` };
}
if (action === "status") {
const state = await readArmState(statePath);
const state = await readArmState(api);
return { text: formatStatus(state) };
}
@@ -422,8 +359,6 @@ export default definePluginEntry({
}
const res = await disarmNow({
api,
stateDir,
statePath,
reason: "manual",
});
if (!res.changed) {
@@ -491,7 +426,7 @@ export default definePluginEntry({
},
});
await writeArmState(statePath, {
await writeArmState(api, {
version: STATE_VERSION,
armedAtMs,
expiresAtMs,

View File

@@ -94,6 +94,55 @@ module.exports = {
);
}
function writeDistDoctorPlugin(pluginRoot: string, pluginId: string): void {
fs.mkdirSync(path.join(pluginRoot, "dist"), { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id: pluginId,
name: "Dist Doctor",
version: "0.0.0-test",
configSchema: {},
},
null,
2,
),
"utf8",
);
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify(
{
name: `@openclaw/${pluginId}`,
version: "0.0.0-test",
type: "module",
openclaw: {
extensions: ["./dist/index.js"],
},
},
null,
2,
),
"utf8",
);
fs.writeFileSync(path.join(pluginRoot, "dist", "index.js"), "export {};\n", "utf8");
fs.writeFileSync(
path.join(pluginRoot, "dist", "doctor-contract-api.cjs"),
`
module.exports = {
legacyConfigRules: [
{
path: ["plugins", "entries", ${JSON.stringify(pluginId)}, "config", "distOnly"],
message: "dist doctor contract warning",
},
],
};
`,
"utf8",
);
}
function writeDoctorSessionOwnerPlugin(pluginRoot: string, pluginId: string): void {
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
@@ -192,6 +241,26 @@ describe("doctor contract registry load-path plugins", () => {
]);
});
it("discovers doctor warning rules from package dist contracts", () => {
const stateDir = makeTempDir();
const pluginRoot = makeTempDir();
const pluginId = "dist-doctor";
writeDistDoctorPlugin(pluginRoot, pluginId);
const config = createDoctorPluginConfig(pluginRoot, pluginId);
const rules = listPluginDoctorLegacyConfigRules({
config,
env: makeHermeticDoctorEnv(stateDir),
pluginIds: [pluginId],
});
expect(rules).toEqual([
{
path: ["plugins", "entries", pluginId, "config", "distOnly"],
message: "dist doctor contract warning",
},
]);
});
it("applies compatibility normalizers from plugins.load.paths", () => {
const stateDir = makeTempDir();
const pluginRoot = makeTempDir();

View File

@@ -104,16 +104,14 @@ function resolveContractApiPath(rootDir: string): string | null {
const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT
? CONTRACT_API_EXTENSIONS
: ([...CONTRACT_API_EXTENSIONS.slice(3), ...CONTRACT_API_EXTENSIONS.slice(0, 3)] as const);
for (const extension of orderedExtensions) {
const candidate = path.join(rootDir, `doctor-contract-api${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
for (const extension of orderedExtensions) {
const candidate = path.join(rootDir, `contract-api${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
for (const basename of ["doctor-contract-api", "contract-api"]) {
for (const extension of orderedExtensions) {
for (const baseDir of [rootDir, path.join(rootDir, "dist")]) {
const candidate = path.join(baseDir, `${basename}${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
}
return null;

View File

@@ -85,4 +85,20 @@ describe("plugin npm runtime build planning", () => {
"skills/**",
]);
});
it("builds doctor contract surfaces for publishable channel plugins", () => {
for (const pluginDir of ["msteams", "nostr"]) {
const plan = expectPluginNpmRuntimeBuildPlan(
resolvePluginNpmRuntimeBuildPlan({
repoRoot,
packageDir: path.join(repoRoot, "extensions", pluginDir),
}),
);
expect(plan.entry["doctor-contract-api"]).toBe(
path.join(repoRoot, "extensions", pluginDir, "doctor-contract-api.ts"),
);
expect(plan.runtimeBuildOutputs).toContain("./dist/doctor-contract-api.js");
expect(plan.packageFiles).toContain("dist/**");
}
});
});