mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
feat(agents): add isolated database rehearsal contract
This commit is contained in:
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
agentsBindingsCommandMock: vi.fn(),
|
||||
agentsBindCommandMock: vi.fn(),
|
||||
agentsDeleteCommandMock: vi.fn(),
|
||||
agentsDatabaseRehearsalCommandMock: vi.fn(),
|
||||
agentsListCommandMock: vi.fn(),
|
||||
agentsSetIdentityCommandMock: vi.fn(),
|
||||
agentsUnbindCommandMock: vi.fn(),
|
||||
@@ -28,6 +29,7 @@ const agentsAddCommandMock = mocks.agentsAddCommandMock;
|
||||
const agentsBindingsCommandMock = mocks.agentsBindingsCommandMock;
|
||||
const agentsBindCommandMock = mocks.agentsBindCommandMock;
|
||||
const agentsDeleteCommandMock = mocks.agentsDeleteCommandMock;
|
||||
const agentsDatabaseRehearsalCommandMock = mocks.agentsDatabaseRehearsalCommandMock;
|
||||
const agentsListCommandMock = mocks.agentsListCommandMock;
|
||||
const agentsSetIdentityCommandMock = mocks.agentsSetIdentityCommandMock;
|
||||
const agentsUnbindCommandMock = mocks.agentsUnbindCommandMock;
|
||||
@@ -56,6 +58,10 @@ vi.mock("../../commands/agents.commands.delete.js", () => ({
|
||||
agentsDeleteCommand: mocks.agentsDeleteCommandMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/agents.db-rehearsal.js", () => ({
|
||||
agentsDatabaseRehearsalCommand: mocks.agentsDatabaseRehearsalCommandMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/agents.commands.identity.js", () => ({
|
||||
agentsSetIdentityCommand: mocks.agentsSetIdentityCommandMock,
|
||||
}));
|
||||
@@ -89,6 +95,7 @@ describe("agent command registration", () => {
|
||||
agentsBindingsCommandMock.mockResolvedValue(undefined);
|
||||
agentsBindCommandMock.mockResolvedValue(undefined);
|
||||
agentsDeleteCommandMock.mockResolvedValue(undefined);
|
||||
agentsDatabaseRehearsalCommandMock.mockResolvedValue(undefined);
|
||||
agentsListCommandMock.mockResolvedValue(undefined);
|
||||
agentsSetIdentityCommandMock.mockResolvedValue(undefined);
|
||||
agentsUnbindCommandMock.mockResolvedValue(undefined);
|
||||
@@ -378,6 +385,22 @@ describe("agent command registration", () => {
|
||||
expect(callRuntime).toBe(runtime);
|
||||
});
|
||||
|
||||
it("keeps the operator database rehearsal hidden and forwards its request source", async () => {
|
||||
const program = new Command();
|
||||
registerAgentsCommands(program);
|
||||
const agents = program.commands.find((command) => command.name() === "agents");
|
||||
const rehearsal = agents?.commands.find((command) => command.name() === "db-rehearsal");
|
||||
expect(rehearsal).toBeDefined();
|
||||
expect(agents?.helpInformation()).not.toContain("db-rehearsal");
|
||||
|
||||
await runCli(["agents", "db-rehearsal", "--request", "rehearsal.json"]);
|
||||
|
||||
expect(agentsDatabaseRehearsalCommandMock).toHaveBeenCalledWith(
|
||||
{ request: "rehearsal.json" },
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards set-identity options", async () => {
|
||||
await runCli([
|
||||
"agents",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { collectOption } from "./helpers.js";
|
||||
type AgentsAddModule = typeof import("../../commands/agents.commands.add.js");
|
||||
type AgentsBindModule = typeof import("../../commands/agents.commands.bind.js");
|
||||
type AgentsDeleteModule = typeof import("../../commands/agents.commands.delete.js");
|
||||
type AgentsDatabaseRehearsalModule = typeof import("../../commands/agents.db-rehearsal.js");
|
||||
type AgentsIdentityModule = typeof import("../../commands/agents.commands.identity.js");
|
||||
type AgentsListModule = typeof import("../../commands/agents.commands.list.js");
|
||||
type CliUtilsModule = typeof import("../cli-utils.js");
|
||||
@@ -39,6 +40,12 @@ async function loadAgentsDeleteCommand(): Promise<AgentsDeleteModule["agentsDele
|
||||
return (await import("../../commands/agents.commands.delete.js")).agentsDeleteCommand;
|
||||
}
|
||||
|
||||
async function loadAgentsDatabaseRehearsalCommand(): Promise<
|
||||
AgentsDatabaseRehearsalModule["agentsDatabaseRehearsalCommand"]
|
||||
> {
|
||||
return (await import("../../commands/agents.db-rehearsal.js")).agentsDatabaseRehearsalCommand;
|
||||
}
|
||||
|
||||
async function loadAgentsSetIdentityCommand(): Promise<
|
||||
AgentsIdentityModule["agentsSetIdentityCommand"]
|
||||
> {
|
||||
@@ -266,6 +273,15 @@ ${formatHelpExamples([
|
||||
});
|
||||
});
|
||||
|
||||
agents
|
||||
.command("db-rehearsal", { hidden: true })
|
||||
.requiredOption("--request <file|->", "Read the bounded rehearsal JSON request")
|
||||
.action(async (opts): Promise<void> => {
|
||||
const { defaultRuntime } = await loadAgentsActionRuntime();
|
||||
const agentsDatabaseRehearsalCommand = await loadAgentsDatabaseRehearsalCommand();
|
||||
await agentsDatabaseRehearsalCommand({ request: String(opts.request) }, defaultRuntime);
|
||||
});
|
||||
|
||||
agents.action(async (): Promise<void> => {
|
||||
await runAgentsCommandAction(async (runtime) => {
|
||||
const agentsListCommand = await loadAgentsListCommand();
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Agent DB rehearsal tests cover isolated inventory, migration, and compatibility probes.
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listOpenClawRegisteredAgentDatabases } from "../state/openclaw-agent-db-registry.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabaseByPath,
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
OPENCLAW_AGENT_SCHEMA_VERSION,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
import { runAgentDatabaseRehearsal } from "./agents.db-rehearsal.js";
|
||||
|
||||
type RehearsalSuccess = Awaited<ReturnType<typeof runAgentDatabaseRehearsal>>;
|
||||
|
||||
describe("agents database rehearsal", () => {
|
||||
const tempRoots = createSuiteTempRootTracker({ prefix: "openclaw-agent-db-rehearsal-" });
|
||||
|
||||
beforeAll(async () => {
|
||||
await tempRoots.setup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await tempRoots.cleanup();
|
||||
});
|
||||
|
||||
async function makeRoot(): Promise<string> {
|
||||
return await tempRoots.make();
|
||||
}
|
||||
|
||||
function createAgentDatabase(params: {
|
||||
root: string;
|
||||
agentId: string;
|
||||
relativePath: string;
|
||||
}): string {
|
||||
const env = { OPENCLAW_STATE_DIR: params.root };
|
||||
const pathname = path.join(params.root, params.relativePath);
|
||||
fs.mkdirSync(path.dirname(pathname), { recursive: true });
|
||||
const database = openOpenClawAgentDatabase({
|
||||
agentId: params.agentId,
|
||||
env,
|
||||
path: pathname,
|
||||
});
|
||||
database.db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
closeOpenClawAgentDatabaseByPath(pathname);
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function setAgentSchemaVersion(pathname: string, version: number): void {
|
||||
const database = new DatabaseSync(pathname);
|
||||
try {
|
||||
database.exec(`PRAGMA user_version = ${version};`);
|
||||
database
|
||||
.prepare("UPDATE schema_meta SET schema_version = ? WHERE meta_key = 'primary'")
|
||||
.run(version);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function readAgentSchemaVersion(pathname: string): number {
|
||||
const database = new DatabaseSync(pathname, { readOnly: true });
|
||||
try {
|
||||
return (database.prepare("PRAGMA user_version").get() as { user_version: number })
|
||||
.user_version;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
it("inventories effective roster, agent-dir, and fixed session-store claims without a database open", async () => {
|
||||
const stateRoot = await makeRoot();
|
||||
const configuredAgentDir = path.join(stateRoot, "custom-auth", "main");
|
||||
const fixedStore = path.join(stateRoot, "fixed-sessions", "sessions.json");
|
||||
const configPath = path.join(stateRoot, "openclaw.json");
|
||||
await fsp.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
entries: {
|
||||
main: { default: true, agentDir: configuredAgentDir },
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
session: { store: fixedStore },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = (await runAgentDatabaseRehearsal({
|
||||
schemaVersion: 1,
|
||||
mode: "inventory",
|
||||
stateRoot,
|
||||
configPath,
|
||||
})) as Extract<RehearsalSuccess, { mode: "inventory" }>;
|
||||
|
||||
expect(result.references).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
agentId: "main",
|
||||
path: path.join(configuredAgentDir, "openclaw-agent.sqlite"),
|
||||
claimKind: "agent-dir-database",
|
||||
ownerClaim: "configured-agent-dir",
|
||||
},
|
||||
{
|
||||
agentId: "ops",
|
||||
path: path.join(stateRoot, "agents", "ops", "agent", "openclaw-agent.sqlite"),
|
||||
claimKind: "agent-dir-database",
|
||||
ownerClaim: "default-agent-dir",
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
path: path.join(stateRoot, "fixed-sessions", "openclaw-agent.sqlite"),
|
||||
claimKind: "session-store-fixed-family",
|
||||
ownerClaim: "configured-session-store",
|
||||
},
|
||||
{
|
||||
agentId: "ops",
|
||||
path: path.join(stateRoot, "fixed-sessions", "openclaw-agent.sqlite"),
|
||||
claimKind: "session-store-fixed-family",
|
||||
ownerClaim: "configured-session-store",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.pluginPersistence).toEqual([
|
||||
expect.objectContaining({ pluginId: "*", kind: "indeterminate", copiedPath: null }),
|
||||
]);
|
||||
expect(result.complete).toBe(false);
|
||||
expect(fs.existsSync(resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: stateRoot }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports plugin inventory complete only when plugin loading is explicitly disabled", async () => {
|
||||
const stateRoot = await makeRoot();
|
||||
const configPath = path.join(stateRoot, "openclaw.json");
|
||||
await fsp.writeFile(configPath, JSON.stringify({ plugins: { enabled: false } }));
|
||||
|
||||
const result = (await runAgentDatabaseRehearsal({
|
||||
schemaVersion: 1,
|
||||
mode: "inventory",
|
||||
stateRoot,
|
||||
configPath,
|
||||
})) as Extract<RehearsalSuccess, { mode: "inventory" }>;
|
||||
|
||||
expect(result.pluginPersistence).toEqual([]);
|
||||
expect(result.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsupported plugin persistence before validating or opening any database", async () => {
|
||||
const root = await makeRoot();
|
||||
await expect(
|
||||
runAgentDatabaseRehearsal({
|
||||
schemaVersion: 1,
|
||||
mode: "migrate",
|
||||
privateStateRoot: root,
|
||||
agents: [{ agentId: "main", copiedPath: path.join(root, "missing.sqlite") }],
|
||||
pluginPersistence: [
|
||||
{ pluginId: "custom-store", kind: "sqlite", copiedPath: "/outside/plugin.sqlite" },
|
||||
],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "unsupported-plugin-persistence" });
|
||||
expect(fs.existsSync(resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: root }))).toBe(false);
|
||||
});
|
||||
|
||||
it("migrates every explicit composite row and replaces stale private registry rows", async () => {
|
||||
const root = await makeRoot();
|
||||
const first = createAgentDatabase({
|
||||
root,
|
||||
agentId: "main",
|
||||
relativePath: "copies/first/openclaw-agent.sqlite",
|
||||
});
|
||||
const second = createAgentDatabase({
|
||||
root,
|
||||
agentId: "main",
|
||||
relativePath: "copies/second/openclaw-agent.sqlite",
|
||||
});
|
||||
createAgentDatabase({
|
||||
root,
|
||||
agentId: "stale",
|
||||
relativePath: "copies/stale/openclaw-agent.sqlite",
|
||||
});
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
setAgentSchemaVersion(first, OPENCLAW_AGENT_SCHEMA_VERSION - 1);
|
||||
setAgentSchemaVersion(second, OPENCLAW_AGENT_SCHEMA_VERSION - 1);
|
||||
|
||||
const result = (await runAgentDatabaseRehearsal({
|
||||
schemaVersion: 1,
|
||||
mode: "migrate",
|
||||
privateStateRoot: root,
|
||||
agents: [
|
||||
{ agentId: "main", copiedPath: first },
|
||||
{ agentId: "main", copiedPath: second },
|
||||
],
|
||||
pluginPersistence: [],
|
||||
})) as Extract<RehearsalSuccess, { mode: "migrate" }>;
|
||||
|
||||
expect(result.sharedState).toMatchObject({
|
||||
schemaVersionAfter: OPENCLAW_STATE_SCHEMA_VERSION,
|
||||
role: "global",
|
||||
readOnly: false,
|
||||
});
|
||||
expect(result.agents).toHaveLength(2);
|
||||
expect(result.agents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
realPath: fs.realpathSync.native(first),
|
||||
before: expect.objectContaining({ userVersion: OPENCLAW_AGENT_SCHEMA_VERSION - 1 }),
|
||||
after: expect.objectContaining({ userVersion: OPENCLAW_AGENT_SCHEMA_VERSION }),
|
||||
migrated: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
realPath: fs.realpathSync.native(second),
|
||||
migrated: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const registry = listOpenClawRegisteredAgentDatabases({
|
||||
env: { OPENCLAW_STATE_DIR: root },
|
||||
includeIncompatibleSchemaVersions: true,
|
||||
});
|
||||
expect(registry.map(({ agentId, path: pathname }) => ({ agentId, path: pathname }))).toEqual([
|
||||
{ agentId: "main", path: fs.realpathSync.native(first) },
|
||||
{ agentId: "main", path: fs.realpathSync.native(second) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("read-only compatibility verifies exact registry without migrating files", async () => {
|
||||
const root = await makeRoot();
|
||||
const databasePath = createAgentDatabase({
|
||||
root,
|
||||
agentId: "main",
|
||||
relativePath: "copies/current/openclaw-agent.sqlite",
|
||||
});
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const beforeVersion = readAgentSchemaVersion(databasePath);
|
||||
const beforeMtime = fs.statSync(databasePath).mtimeMs;
|
||||
|
||||
const result = (await runAgentDatabaseRehearsal({
|
||||
schemaVersion: 1,
|
||||
mode: "read-only",
|
||||
privateStateRoot: root,
|
||||
agents: [{ agentId: "main", copiedPath: databasePath }],
|
||||
pluginPersistence: [],
|
||||
})) as Extract<RehearsalSuccess, { mode: "read-only" }>;
|
||||
|
||||
expect(result.sharedState.readOnly).toBe(true);
|
||||
expect(result.agents[0]).toMatchObject({
|
||||
migrated: false,
|
||||
before: { userVersion: beforeVersion },
|
||||
after: { userVersion: beforeVersion },
|
||||
});
|
||||
expect(readAgentSchemaVersion(databasePath)).toBe(beforeVersion);
|
||||
expect(fs.statSync(databasePath).mtimeMs).toBe(beforeMtime);
|
||||
});
|
||||
|
||||
it("rejects a hardlinked clone before writable shared state opens", async () => {
|
||||
const outside = await makeRoot();
|
||||
const root = await makeRoot();
|
||||
const source = createAgentDatabase({
|
||||
root: outside,
|
||||
agentId: "main",
|
||||
relativePath: "source/openclaw-agent.sqlite",
|
||||
});
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const alias = path.join(root, "copy", "openclaw-agent.sqlite");
|
||||
await fsp.mkdir(path.dirname(alias), { recursive: true });
|
||||
await fsp.link(source, alias);
|
||||
|
||||
await expect(
|
||||
runAgentDatabaseRehearsal({
|
||||
schemaVersion: 1,
|
||||
mode: "migrate",
|
||||
privateStateRoot: root,
|
||||
agents: [{ agentId: "main", copiedPath: alias }],
|
||||
pluginPersistence: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "path-not-private" });
|
||||
expect(fs.existsSync(resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: root }))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects copied paths that escape the private root", async () => {
|
||||
const outside = await makeRoot();
|
||||
const root = await makeRoot();
|
||||
const databasePath = createAgentDatabase({
|
||||
root: outside,
|
||||
agentId: "main",
|
||||
relativePath: "outside/openclaw-agent.sqlite",
|
||||
});
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
await expect(
|
||||
runAgentDatabaseRehearsal({
|
||||
schemaVersion: 1,
|
||||
mode: "read-only",
|
||||
privateStateRoot: root,
|
||||
agents: [{ agentId: "main", copiedPath: databasePath }],
|
||||
pluginPersistence: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "path-escape" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,688 @@
|
||||
// Hidden operator contract for isolated agent-database upgrade rehearsals.
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { listAgentIds, resolveAgentConfig, resolveAgentDir } from "../agents/agent-scope.js";
|
||||
import { createConfigIO } from "../config/io.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { resolveUnsuffixedSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
|
||||
import {
|
||||
isPerAgentSessionStoreConfig,
|
||||
listConfiguredSessionStoreAgentIds,
|
||||
} from "../config/sessions/targets.js";
|
||||
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js";
|
||||
import { readSqliteUserVersion } from "../infra/sqlite-user-version.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { writeRuntimeJson } from "../runtime.js";
|
||||
import { findOpenClawAgentDatabaseMediaMigrationRequiredError } from "../state/openclaw-agent-db-migration-required.js";
|
||||
import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js";
|
||||
import {
|
||||
listOpenClawRegisteredAgentDatabases,
|
||||
replaceOpenClawAgentDatabaseRegistryForRehearsal,
|
||||
} from "../state/openclaw-agent-db-registry.js";
|
||||
import { readExistingAgentSchemaMeta } from "../state/openclaw-agent-db-schema-helpers.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabaseByPath,
|
||||
migrateOpenClawAgentDatabaseForMaintenance,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseByPath,
|
||||
openExistingOpenClawStateDatabaseReadOnly,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
const REHEARSAL_SCHEMA_VERSION = 1;
|
||||
const REHEARSAL_REQUEST_MAX_BYTES = 256 * 1024;
|
||||
const REHEARSAL_MAX_AGENT_DATABASES = 256;
|
||||
const REHEARSAL_MAX_INVENTORY_REFERENCES = 256;
|
||||
|
||||
type RehearsalMode = "inventory" | "migrate" | "read-only";
|
||||
|
||||
type PluginPersistenceDeclaration = {
|
||||
pluginId: string;
|
||||
kind: string;
|
||||
copiedPath: string | null;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type AgentDatabaseRequest = {
|
||||
agentId: string;
|
||||
copiedPath: string;
|
||||
};
|
||||
|
||||
type InventoryRequest = {
|
||||
schemaVersion: 1;
|
||||
mode: "inventory";
|
||||
stateRoot: string;
|
||||
configPath: string;
|
||||
};
|
||||
|
||||
type RehearsalRequest = {
|
||||
schemaVersion: 1;
|
||||
mode: "migrate" | "read-only";
|
||||
privateStateRoot: string;
|
||||
agents: AgentDatabaseRequest[];
|
||||
pluginPersistence: PluginPersistenceDeclaration[];
|
||||
};
|
||||
|
||||
type ParsedRequest = InventoryRequest | RehearsalRequest;
|
||||
|
||||
type DatabaseSnapshot = {
|
||||
userVersion: number;
|
||||
metadataSchemaVersion: number | null;
|
||||
role: string | null;
|
||||
ownerAgentId: string | null;
|
||||
};
|
||||
|
||||
type PreparedAgentDatabase = {
|
||||
agentId: string;
|
||||
requestedPath: string;
|
||||
resolvedPath: string;
|
||||
realPath: string;
|
||||
before: DatabaseSnapshot;
|
||||
};
|
||||
|
||||
type InventoryReference = {
|
||||
agentId: string;
|
||||
path: string;
|
||||
claimKind: "agent-dir-database" | "session-store-database" | "session-store-fixed-family";
|
||||
ownerClaim:
|
||||
| "configured-agent-dir"
|
||||
| "default-agent-dir"
|
||||
| "configured-session-store"
|
||||
| "default-session-store";
|
||||
};
|
||||
|
||||
class AgentDatabaseRehearsalError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly unsupportedPluginPersistence?: readonly PluginPersistenceDeclaration[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AgentDatabaseRehearsalError";
|
||||
}
|
||||
}
|
||||
|
||||
function fail(code: string, message: string): never {
|
||||
throw new AgentDatabaseRehearsalError(code, message);
|
||||
}
|
||||
|
||||
function requiredString(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
fail("invalid-request", `${key} must be a non-empty string.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parsePluginPersistence(value: unknown): PluginPersistenceDeclaration[] {
|
||||
if (!Array.isArray(value)) {
|
||||
fail("invalid-request", "pluginPersistence must be an array.");
|
||||
}
|
||||
return value.map((entry, index) => {
|
||||
if (!isRecord(entry)) {
|
||||
fail("invalid-request", `pluginPersistence[${index}] must be an object.`);
|
||||
}
|
||||
const copiedPath = entry.copiedPath;
|
||||
if (copiedPath !== null && (typeof copiedPath !== "string" || !copiedPath.trim())) {
|
||||
fail("invalid-request", `pluginPersistence[${index}].copiedPath must be a string or null.`);
|
||||
}
|
||||
const reason = entry.reason;
|
||||
if (reason !== undefined && (typeof reason !== "string" || !reason.trim())) {
|
||||
fail("invalid-request", `pluginPersistence[${index}].reason must be a non-empty string.`);
|
||||
}
|
||||
return {
|
||||
pluginId: requiredString(entry, "pluginId"),
|
||||
kind: requiredString(entry, "kind"),
|
||||
copiedPath: copiedPath === null ? null : copiedPath.trim(),
|
||||
...(typeof reason === "string" ? { reason: reason.trim() } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseRequest(value: unknown): ParsedRequest {
|
||||
if (!isRecord(value)) {
|
||||
fail("invalid-request", "request must be a JSON object.");
|
||||
}
|
||||
if (value.schemaVersion !== REHEARSAL_SCHEMA_VERSION) {
|
||||
fail("unsupported-request-version", "schemaVersion must be 1.");
|
||||
}
|
||||
const mode = value.mode;
|
||||
if (mode === "inventory") {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
mode,
|
||||
stateRoot: requiredString(value, "stateRoot"),
|
||||
configPath: requiredString(value, "configPath"),
|
||||
};
|
||||
}
|
||||
if (mode !== "migrate" && mode !== "read-only") {
|
||||
fail("invalid-request", "mode must be inventory, migrate, or read-only.");
|
||||
}
|
||||
if (!Array.isArray(value.agents)) {
|
||||
fail("invalid-request", "agents must be an array.");
|
||||
}
|
||||
if (value.agents.length > REHEARSAL_MAX_AGENT_DATABASES) {
|
||||
fail(
|
||||
"agent-limit-exceeded",
|
||||
`agents exceeds the ${REHEARSAL_MAX_AGENT_DATABASES}-entry limit.`,
|
||||
);
|
||||
}
|
||||
const agents = value.agents.map((entry, index) => {
|
||||
if (!isRecord(entry)) {
|
||||
fail("invalid-request", `agents[${index}] must be an object.`);
|
||||
}
|
||||
return {
|
||||
agentId: normalizeAgentId(requiredString(entry, "agentId")),
|
||||
copiedPath: requiredString(entry, "copiedPath"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
mode,
|
||||
privateStateRoot: requiredString(value, "privateStateRoot"),
|
||||
agents,
|
||||
pluginPersistence: parsePluginPersistence(value.pluginPersistence),
|
||||
};
|
||||
}
|
||||
|
||||
function requireAbsoluteDirectory(
|
||||
rawPath: string,
|
||||
field: string,
|
||||
): { resolved: string; real: string } {
|
||||
if (!path.isAbsolute(rawPath)) {
|
||||
fail("path-not-absolute", `${field} must be absolute.`);
|
||||
}
|
||||
const resolved = path.resolve(rawPath);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(resolved);
|
||||
} catch (error) {
|
||||
fail("path-unavailable", `${field} is unavailable: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
fail("path-not-directory", `${field} must be an existing directory.`);
|
||||
}
|
||||
return { resolved, real: fs.realpathSync.native(resolved) };
|
||||
}
|
||||
|
||||
function requireAbsoluteConfigPath(rawPath: string): string {
|
||||
if (!path.isAbsolute(rawPath)) {
|
||||
fail("path-not-absolute", "configPath must be absolute.");
|
||||
}
|
||||
const resolved = path.resolve(rawPath);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(resolved);
|
||||
} catch (error) {
|
||||
fail("config-unavailable", `configPath is unavailable: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
fail("config-unavailable", "configPath must be a regular non-symlink file.");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function assertPathInsideRoot(root: string, candidate: string, label: string): void {
|
||||
if (candidate !== root && !isPathInside(root, candidate)) {
|
||||
fail("path-escape", `${label} must remain inside the private state root.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPrivateDatabaseFamily(params: {
|
||||
root: string;
|
||||
pathname: string;
|
||||
requireMain: boolean;
|
||||
label: string;
|
||||
}): string {
|
||||
const resolved = path.resolve(params.pathname);
|
||||
assertPathInsideRoot(params.root, resolved, params.label);
|
||||
const parentReal = fs.realpathSync.native(path.dirname(resolved));
|
||||
assertPathInsideRoot(params.root, parentReal, `${params.label} parent`);
|
||||
let mainReal: string | undefined;
|
||||
for (const candidate of resolveSqliteDatabaseFilePaths(resolved)) {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(candidate);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
fail("path-unavailable", `${params.label} is unavailable: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) {
|
||||
fail(
|
||||
"path-not-private",
|
||||
`${params.label} and its SQLite sidecars must be private regular files without symlink or hardlink aliases.`,
|
||||
);
|
||||
}
|
||||
const real = fs.realpathSync.native(candidate);
|
||||
assertPathInsideRoot(params.root, real, params.label);
|
||||
if (candidate === resolved) {
|
||||
mainReal = real;
|
||||
}
|
||||
}
|
||||
if (params.requireMain && !mainReal) {
|
||||
fail("database-missing", `${params.label} must be an existing SQLite file.`);
|
||||
}
|
||||
return mainReal ?? resolved;
|
||||
}
|
||||
|
||||
function inspectDatabaseSnapshot(pathname: string): DatabaseSnapshot {
|
||||
if (!fs.existsSync(pathname)) {
|
||||
return {
|
||||
userVersion: 0,
|
||||
metadataSchemaVersion: null,
|
||||
role: null,
|
||||
ownerAgentId: null,
|
||||
};
|
||||
}
|
||||
const database = openNodeSqliteDatabase(pathname, { readOnly: true });
|
||||
try {
|
||||
const metadata = readExistingAgentSchemaMeta(database);
|
||||
return {
|
||||
userVersion: readSqliteUserVersion(database),
|
||||
metadataSchemaVersion: metadata?.schemaVersion ?? null,
|
||||
role: metadata?.role ?? null,
|
||||
ownerAgentId: metadata?.agentId ?? null,
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function prepareAgentDatabases(
|
||||
request: RehearsalRequest,
|
||||
root: { resolved: string; real: string },
|
||||
): PreparedAgentDatabase[] {
|
||||
const seen = new Set<string>();
|
||||
return request.agents.map((entry, index) => {
|
||||
if (!path.isAbsolute(entry.copiedPath)) {
|
||||
fail("path-not-absolute", `agents[${index}].copiedPath must be absolute.`);
|
||||
}
|
||||
const requestedPath = path.resolve(entry.copiedPath);
|
||||
assertPathInsideRoot(root.resolved, requestedPath, `agents[${index}].copiedPath`);
|
||||
const realPath = assertPrivateDatabaseFamily({
|
||||
root: root.real,
|
||||
pathname: fs.realpathSync.native(requestedPath),
|
||||
requireMain: true,
|
||||
label: `agents[${index}].copiedPath`,
|
||||
});
|
||||
const compositeKey = `${entry.agentId}\0${realPath}`;
|
||||
if (seen.has(compositeKey)) {
|
||||
fail(
|
||||
"duplicate-agent-database",
|
||||
`agents[${index}] duplicates ${entry.agentId} at ${realPath}.`,
|
||||
);
|
||||
}
|
||||
seen.add(compositeKey);
|
||||
return {
|
||||
agentId: entry.agentId,
|
||||
requestedPath,
|
||||
resolvedPath: requestedPath,
|
||||
realPath,
|
||||
before: inspectDatabaseSnapshot(realPath),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function compareRegistryExact(
|
||||
expected: readonly { agentId: string; path: string; schemaVersion: number }[],
|
||||
actual: readonly { agentId: string; path: string; schemaVersion: number }[],
|
||||
): void {
|
||||
const key = (entry: { agentId: string; path: string; schemaVersion: number }) =>
|
||||
`${normalizeAgentId(entry.agentId)}\0${path.resolve(entry.path)}\0${entry.schemaVersion}`;
|
||||
const expectedKeys = expected.map(key).toSorted();
|
||||
const actualKeys = actual.map(key).toSorted();
|
||||
if (
|
||||
expectedKeys.length !== actualKeys.length ||
|
||||
expectedKeys.some((entry, index) => entry !== actualKeys[index])
|
||||
) {
|
||||
fail(
|
||||
"registry-mismatch",
|
||||
"private agent database registry does not exactly match the manifest.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function referenceKey(reference: InventoryReference): string {
|
||||
return [reference.agentId, reference.path, reference.claimKind, reference.ownerClaim].join("\0");
|
||||
}
|
||||
|
||||
async function runInventory(request: InventoryRequest) {
|
||||
const root = requireAbsoluteDirectory(request.stateRoot, "stateRoot");
|
||||
const configPath = requireAbsoluteConfigPath(request.configPath);
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: root.real,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
};
|
||||
const io = createConfigIO({
|
||||
configPath,
|
||||
env,
|
||||
logger: { error: () => undefined, warn: () => undefined },
|
||||
observe: false,
|
||||
pluginValidation: "skip",
|
||||
shellEnvFallback: "defer",
|
||||
});
|
||||
const snapshot = await io.readConfigFileSnapshot();
|
||||
if (!snapshot.valid) {
|
||||
fail("config-invalid", "configPath could not be resolved as a valid effective config.");
|
||||
}
|
||||
const config = snapshot.runtimeConfig;
|
||||
const references = new Map<string, InventoryReference>();
|
||||
const addReference = (reference: InventoryReference) => {
|
||||
references.set(referenceKey(reference), reference);
|
||||
if (references.size > REHEARSAL_MAX_INVENTORY_REFERENCES) {
|
||||
fail(
|
||||
"inventory-limit-exceeded",
|
||||
`inventory exceeds the ${REHEARSAL_MAX_INVENTORY_REFERENCES}-reference limit.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
for (const agentId of listAgentIds(config)) {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const configuredAgentDir = Boolean(
|
||||
resolveAgentConfig(config, normalizedAgentId)?.agentDir?.trim(),
|
||||
);
|
||||
const agentDir = resolveAgentDir(config, normalizedAgentId, env);
|
||||
addReference({
|
||||
agentId: normalizedAgentId,
|
||||
path: path.resolve(agentDir, "openclaw-agent.sqlite"),
|
||||
claimKind: "agent-dir-database",
|
||||
ownerClaim: configuredAgentDir ? "configured-agent-dir" : "default-agent-dir",
|
||||
});
|
||||
}
|
||||
const storeConfig = config.session?.store;
|
||||
const configuredSessionStore = Boolean(storeConfig?.trim());
|
||||
for (const agentId of listConfiguredSessionStoreAgentIds(config)) {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const storePath = resolveStorePath(storeConfig, { agentId: normalizedAgentId, env });
|
||||
const target = resolveUnsuffixedSqliteTargetFromSessionStorePath(storePath);
|
||||
addReference({
|
||||
agentId: normalizedAgentId,
|
||||
path: path.resolve(target.path),
|
||||
claimKind:
|
||||
isPerAgentSessionStoreConfig(storeConfig) || path.resolve(storePath).endsWith(".sqlite")
|
||||
? "session-store-database"
|
||||
: "session-store-fixed-family",
|
||||
ownerClaim: configuredSessionStore ? "configured-session-store" : "default-session-store",
|
||||
});
|
||||
}
|
||||
const pluginPersistence: PluginPersistenceDeclaration[] =
|
||||
config.plugins?.enabled === false
|
||||
? []
|
||||
: [
|
||||
{
|
||||
pluginId: "*",
|
||||
kind: "indeterminate",
|
||||
copiedPath: null,
|
||||
reason: "enabled plugin persistence has no manifest declaration surface",
|
||||
},
|
||||
];
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
ok: true as const,
|
||||
mode: "inventory" as const,
|
||||
runtimeVersion: VERSION,
|
||||
stateRoot: resolveStateDir(env),
|
||||
configPath,
|
||||
references: [...references.values()].toSorted(
|
||||
(left, right) =>
|
||||
left.agentId.localeCompare(right.agentId) ||
|
||||
left.path.localeCompare(right.path) ||
|
||||
left.claimKind.localeCompare(right.claimKind),
|
||||
),
|
||||
pluginPersistence,
|
||||
complete: pluginPersistence.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function runReadOnly(
|
||||
request: RehearsalRequest & { mode: "read-only" },
|
||||
root: { resolved: string; real: string },
|
||||
agents: PreparedAgentDatabase[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
) {
|
||||
const sharedPath = resolveOpenClawStateSqlitePath(env);
|
||||
const stateBefore = inspectDatabaseSnapshot(sharedPath);
|
||||
const state = await openExistingOpenClawStateDatabaseReadOnly({ env });
|
||||
if (!state) {
|
||||
fail("shared-state-missing", "private shared state database is missing.");
|
||||
}
|
||||
try {
|
||||
const results = agents.map((agent) => {
|
||||
const result = withOpenClawAgentDatabaseReadOnly(
|
||||
(database) => inspectDatabaseSnapshot(database.path),
|
||||
{ agentId: agent.agentId, env, path: agent.realPath },
|
||||
);
|
||||
if (!result.found) {
|
||||
fail(
|
||||
"agent-database-unreadable",
|
||||
`${agent.agentId} at ${agent.realPath} is unavailable (${result.reason}).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...agent,
|
||||
after: result.value,
|
||||
migrated: false,
|
||||
registry: {
|
||||
path: agent.realPath,
|
||||
schemaVersion: result.value.userVersion,
|
||||
},
|
||||
};
|
||||
});
|
||||
const registry = listOpenClawRegisteredAgentDatabases({
|
||||
env,
|
||||
includeIncompatibleSchemaVersions: true,
|
||||
});
|
||||
compareRegistryExact(
|
||||
results.map((result) => ({
|
||||
agentId: result.agentId,
|
||||
path: result.realPath,
|
||||
schemaVersion: result.after.userVersion,
|
||||
})),
|
||||
registry,
|
||||
);
|
||||
const stateAfter = inspectDatabaseSnapshot(sharedPath);
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
ok: true as const,
|
||||
mode: request.mode,
|
||||
runtimeVersion: VERSION,
|
||||
privateStateRoot: root.real,
|
||||
sharedState: {
|
||||
path: sharedPath,
|
||||
schemaVersionBefore: stateBefore.userVersion,
|
||||
schemaVersionAfter: stateAfter.userVersion,
|
||||
role: stateAfter.role,
|
||||
readOnly: true,
|
||||
},
|
||||
agents: results,
|
||||
pluginPersistence: request.pluginPersistence,
|
||||
};
|
||||
} finally {
|
||||
state.walMaintenance.close();
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrate(
|
||||
request: RehearsalRequest & { mode: "migrate" },
|
||||
root: { resolved: string; real: string },
|
||||
agents: PreparedAgentDatabase[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
) {
|
||||
const sharedPath = resolveOpenClawStateSqlitePath(env);
|
||||
assertPrivateDatabaseFamily({
|
||||
root: root.real,
|
||||
pathname: sharedPath,
|
||||
requireMain: false,
|
||||
label: "private shared state database",
|
||||
});
|
||||
const stateBefore = inspectDatabaseSnapshot(sharedPath);
|
||||
const openedPaths = new Set<string>();
|
||||
const state = openOpenClawStateDatabase({ env });
|
||||
try {
|
||||
const results = agents.map((agent) => {
|
||||
try {
|
||||
try {
|
||||
openOpenClawAgentDatabase({ agentId: agent.agentId, env, path: agent.realPath });
|
||||
} catch (error) {
|
||||
if (!findOpenClawAgentDatabaseMediaMigrationRequiredError(error)) {
|
||||
throw error;
|
||||
}
|
||||
migrateOpenClawAgentDatabaseForMaintenance({
|
||||
agentId: agent.agentId,
|
||||
env,
|
||||
pathname: agent.realPath,
|
||||
});
|
||||
openOpenClawAgentDatabase({ agentId: agent.agentId, env, path: agent.realPath });
|
||||
}
|
||||
openedPaths.add(agent.realPath);
|
||||
const after = inspectDatabaseSnapshot(agent.realPath);
|
||||
return {
|
||||
...agent,
|
||||
after,
|
||||
migrated:
|
||||
agent.before.userVersion !== after.userVersion ||
|
||||
agent.before.metadataSchemaVersion !== after.metadataSchemaVersion,
|
||||
registry: { path: agent.realPath, schemaVersion: after.userVersion },
|
||||
};
|
||||
} finally {
|
||||
closeOpenClawAgentDatabaseByPath(agent.realPath);
|
||||
openedPaths.delete(agent.realPath);
|
||||
}
|
||||
});
|
||||
const expectedRegistry = results.map((result) => ({
|
||||
agentId: result.agentId,
|
||||
path: result.realPath,
|
||||
schemaVersion: result.after.userVersion,
|
||||
}));
|
||||
replaceOpenClawAgentDatabaseRegistryForRehearsal({ entries: expectedRegistry, env });
|
||||
const registry = listOpenClawRegisteredAgentDatabases({
|
||||
env,
|
||||
includeIncompatibleSchemaVersions: true,
|
||||
});
|
||||
compareRegistryExact(expectedRegistry, registry);
|
||||
const stateAfter = inspectDatabaseSnapshot(sharedPath);
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
ok: true as const,
|
||||
mode: request.mode,
|
||||
runtimeVersion: VERSION,
|
||||
privateStateRoot: root.real,
|
||||
sharedState: {
|
||||
path: sharedPath,
|
||||
schemaVersionBefore: stateBefore.userVersion,
|
||||
schemaVersionAfter: stateAfter.userVersion,
|
||||
role: stateAfter.role,
|
||||
readOnly: false,
|
||||
},
|
||||
agents: results,
|
||||
pluginPersistence: request.pluginPersistence,
|
||||
};
|
||||
} finally {
|
||||
for (const pathname of openedPaths) {
|
||||
closeOpenClawAgentDatabaseByPath(pathname);
|
||||
}
|
||||
closeOpenClawStateDatabaseByPath(state.path);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAgentDatabaseRehearsal(requestValue: unknown) {
|
||||
const request = parseRequest(requestValue);
|
||||
if (request.mode === "inventory") {
|
||||
return await runInventory(request);
|
||||
}
|
||||
// Unsupported plugin state must stop before any writable shared or agent open.
|
||||
if (request.pluginPersistence.length > 0) {
|
||||
throw new AgentDatabaseRehearsalError(
|
||||
"unsupported-plugin-persistence",
|
||||
"plugin-owned persistence is unsupported by rehearsal schema v1.",
|
||||
request.pluginPersistence,
|
||||
);
|
||||
}
|
||||
const root = requireAbsoluteDirectory(request.privateStateRoot, "privateStateRoot");
|
||||
const agents = prepareAgentDatabases(request, root);
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, OPENCLAW_STATE_DIR: root.real };
|
||||
if (request.mode === "read-only") {
|
||||
return await runReadOnly(request, root, agents, env);
|
||||
}
|
||||
return runMigrate(request, root, agents, env);
|
||||
}
|
||||
|
||||
async function readRequestSource(source: string, stdin: AsyncIterable<unknown>): Promise<unknown> {
|
||||
let raw: Buffer;
|
||||
if (source === "-") {
|
||||
raw = await readByteStreamWithLimit(stdin, {
|
||||
maxBytes: REHEARSAL_REQUEST_MAX_BYTES,
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new AgentDatabaseRehearsalError("request-too-large", `request exceeds ${maxBytes} bytes.`),
|
||||
});
|
||||
} else {
|
||||
const file = await fsp.open(path.resolve(source), "r");
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile()) {
|
||||
fail("request-unavailable", "request source must be a regular file.");
|
||||
}
|
||||
if (stat.size > REHEARSAL_REQUEST_MAX_BYTES) {
|
||||
fail("request-too-large", `request exceeds ${REHEARSAL_REQUEST_MAX_BYTES} bytes.`);
|
||||
}
|
||||
raw = await readFileDescriptorBounded(file.fd, REHEARSAL_REQUEST_MAX_BYTES);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw.toString("utf8")) as unknown;
|
||||
} catch {
|
||||
return fail("invalid-json", "request source is not valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function agentsDatabaseRehearsalCommand(
|
||||
options: { request: string },
|
||||
runtime: RuntimeEnv,
|
||||
deps: { stdin?: AsyncIterable<unknown> } = {},
|
||||
): Promise<void> {
|
||||
let mode: RehearsalMode | undefined;
|
||||
try {
|
||||
const value = await readRequestSource(options.request, deps.stdin ?? process.stdin);
|
||||
mode =
|
||||
isRecord(value) && typeof value.mode === "string" ? (value.mode as RehearsalMode) : undefined;
|
||||
writeRuntimeJson(runtime, await runAgentDatabaseRehearsal(value), 0);
|
||||
} catch (error) {
|
||||
const typed =
|
||||
error instanceof AgentDatabaseRehearsalError
|
||||
? error
|
||||
: new AgentDatabaseRehearsalError("rehearsal-failed", formatErrorMessage(error));
|
||||
writeRuntimeJson(
|
||||
runtime,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
...(mode ? { mode } : {}),
|
||||
code: typed.code,
|
||||
message: typed.message,
|
||||
...(typed.unsupportedPluginPersistence
|
||||
? { unsupportedPluginPersistence: typed.unsupportedPluginPersistence }
|
||||
: {}),
|
||||
},
|
||||
0,
|
||||
);
|
||||
runtime.exit(1, { resetStream: process.stderr });
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export function assertOpenClawAgentDatabaseForMaintenance(
|
||||
/** Upgrade or repair a supported owned schema before strict offline maintenance. */
|
||||
export function migrateOpenClawAgentDatabaseForMaintenance(options: {
|
||||
agentId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pathname: string;
|
||||
}): void {
|
||||
const agentId = normalizeAgentId(options.agentId);
|
||||
@@ -99,6 +100,7 @@ export function migrateOpenClawAgentDatabaseForMaintenance(options: {
|
||||
}
|
||||
ensureOpenClawAgentDatabaseSchema(database, {
|
||||
agentId,
|
||||
...(options.env ? { env: options.env } : {}),
|
||||
path: options.pathname,
|
||||
});
|
||||
assertOpenClawAgentDatabaseForMaintenance(database, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
assertAgentDeletionPathFence,
|
||||
prepareAgentDeletionPathFence,
|
||||
@@ -642,6 +643,62 @@ export function registerOpenClawAgentDatabase(params: {
|
||||
invalidateRegisteredAgentDatabasesMemo({ env: params.env });
|
||||
}
|
||||
|
||||
/** Atomically replace one state root's registry with an exact reviewed set. */
|
||||
export function replaceOpenClawAgentDatabaseRegistryForRehearsal(params: {
|
||||
entries: readonly {
|
||||
agentId: string;
|
||||
path: string;
|
||||
schemaVersion: number;
|
||||
}[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
const prepared = params.entries.map((entry) => {
|
||||
const agentId = normalizeAgentId(entry.agentId);
|
||||
const pathname = path.resolve(entry.path);
|
||||
const deletionFence = prepareAgentDeletionPathFence(
|
||||
{ agentId, path: pathname },
|
||||
{ env: params.env },
|
||||
);
|
||||
const sizeBytes = (() => {
|
||||
try {
|
||||
return statSync(pathname).size;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return {
|
||||
agentId,
|
||||
deletionFence,
|
||||
path: pathname,
|
||||
schemaVersion: entry.schemaVersion,
|
||||
sizeBytes,
|
||||
};
|
||||
});
|
||||
const lastSeenAt = Date.now();
|
||||
runOpenClawStateWriteTransaction(
|
||||
(database) => {
|
||||
const db = getNodeSqliteKysely<OpenClawAgentRegistryDatabase>(database.db);
|
||||
executeSqliteQuerySync(database.db, db.deleteFrom("agent_databases"));
|
||||
for (const entry of prepared) {
|
||||
assertAgentDeletionPathFence(database.db, entry.deletionFence);
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
db.insertInto("agent_databases").values({
|
||||
agent_id: entry.agentId,
|
||||
path: entry.path,
|
||||
schema_version: entry.schemaVersion,
|
||||
last_seen_at: lastSeenAt,
|
||||
size_bytes: entry.sizeBytes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
{ env: params.env },
|
||||
{ operationLabel: "agent.registry.replace" },
|
||||
);
|
||||
invalidateRegisteredAgentDatabasesMemo({ env: params.env });
|
||||
}
|
||||
|
||||
function canonicalPathForRegistryBoundary(pathname: string): string {
|
||||
const identity = resolveAgentDatabasePathIdentity(pathname);
|
||||
if (identity.realPath) {
|
||||
|
||||
Reference in New Issue
Block a user