mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 14:24:07 +00:00
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:
committed by
GitHub
parent
12d4dda1bb
commit
33c246dbba
99
extensions/active-memory/doctor-contract-api.test.ts
Normal file
99
extensions/active-memory/doctor-contract-api.test.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
146
extensions/active-memory/doctor-contract-api.ts
Normal file
146
extensions/active-memory/doctor-contract-api.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
139
extensions/msteams/doctor-contract-api.test.ts
Normal file
139
extensions/msteams/doctor-contract-api.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
256
extensions/msteams/doctor-contract-api.ts
Normal file
256
extensions/msteams/doctor-contract-api.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
104
extensions/nostr/doctor-contract-api.test.ts
Normal file
104
extensions/nostr/doctor-contract-api.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
296
extensions/nostr/doctor-contract-api.ts
Normal file
296
extensions/nostr/doctor-contract-api.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
91
extensions/phone-control/doctor-contract-api.test.ts
Normal file
91
extensions/phone-control/doctor-contract-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
162
extensions/phone-control/doctor-contract-api.ts
Normal file
162
extensions/phone-control/doctor-contract-api.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/**");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user