mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
refactor: store sandbox registry in sqlite
Move sandbox registry runtime state to SQLite and migrate legacy JSON registry files/directories via doctor.
This commit is contained in:
parent
e06f6ffc3e
commit
ce015cef57
6 changed files with 586 additions and 226 deletions
|
|
@ -230,7 +230,7 @@ Notes:
|
|||
- Doctor removes retired `plugins.entries.codex.config.codexDynamicToolsProfile`; Codex app-server always keeps Codex-native workspace tools native.
|
||||
- Doctor warns when skills allowed for the default agent are unavailable in the current runtime environment because bins, env vars, config, or OS requirements are missing. `doctor --fix` can disable those unavailable skills with `skills.entries.<skill>.enabled=false`; install/configure the missing requirement instead when you want to keep the skill active.
|
||||
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
|
||||
- If legacy sandbox registry files (`~/.openclaw/sandbox/containers.json` or `~/.openclaw/sandbox/browsers.json`) are present, doctor reports them; `openclaw doctor --fix` migrates valid entries into sharded registry directories and quarantines invalid legacy files.
|
||||
- If legacy sandbox registry files or shard directories are present (`~/.openclaw/sandbox/containers.json`, `~/.openclaw/sandbox/browsers.json`, `~/.openclaw/sandbox/containers/`, or `~/.openclaw/sandbox/browsers/`), doctor reports them; `openclaw doctor --fix` migrates valid entries into SQLite and quarantines invalid legacy files.
|
||||
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. For exec-backed SecretRefs, doctor skips execution unless `--allow-exec` is present.
|
||||
- If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early.
|
||||
- After state-directory migrations, doctor warns when enabled default Telegram or Discord accounts depend on env fallback and `TELEGRAM_BOT_TOKEN` or `DISCORD_BOT_TOKEN` is unavailable to the doctor process.
|
||||
|
|
|
|||
|
|
@ -166,12 +166,12 @@ Prefer `openclaw sandbox recreate` over manual backend-specific cleanup. It uses
|
|||
|
||||
## Registry migration
|
||||
|
||||
OpenClaw stores sandbox runtime metadata as one JSON shard per container/browser entry under the sandbox state directory. Older installs may still have monolithic legacy files:
|
||||
OpenClaw stores sandbox runtime metadata in the shared SQLite state database. Older installs may still have legacy sandbox registry files:
|
||||
|
||||
- `~/.openclaw/sandbox/containers.json`
|
||||
- `~/.openclaw/sandbox/browsers.json`
|
||||
|
||||
Regular sandbox runtime reads do not rewrite those files. Run `openclaw doctor --fix` to migrate valid legacy entries into the sharded registry directories. Invalid legacy files are quarantined so one bad old registry cannot hide current runtime entries.
|
||||
Some upgrades may also have one JSON shard per container/browser under `~/.openclaw/sandbox/containers/` or `~/.openclaw/sandbox/browsers/`. Regular sandbox runtime reads do not rewrite those legacy sources. Run `openclaw doctor --fix` to migrate valid legacy entries into SQLite. Invalid legacy files are quarantined so one bad old registry cannot hide current runtime entries.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,31 @@
|
|||
// Sandbox registry tests cover sharded registry migration, locking, ordering,
|
||||
// Sandbox registry tests cover legacy registry migration, ordering,
|
||||
// and race-safety for container/browser runtime records.
|
||||
import fs from "node:fs/promises";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WriteDelayConfig = {
|
||||
targetFile: "containers.json" | "browsers.json" | null;
|
||||
containerName: string;
|
||||
started: boolean;
|
||||
markStarted: () => void;
|
||||
waitForRelease: Promise<void>;
|
||||
};
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
TEST_STATE_DIR,
|
||||
PREVIOUS_OPENCLAW_STATE_DIR,
|
||||
SANDBOX_REGISTRY_PATH,
|
||||
SANDBOX_BROWSER_REGISTRY_PATH,
|
||||
SANDBOX_CONTAINERS_DIR,
|
||||
SANDBOX_BROWSERS_DIR,
|
||||
writeGateState,
|
||||
} = vi.hoisted(() => {
|
||||
const path = require("node:path");
|
||||
const { mkdtempSync } = require("node:fs");
|
||||
const { tmpdir } = require("node:os");
|
||||
const baseDir = mkdtempSync(path.join(tmpdir(), "openclaw-sandbox-registry-"));
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = baseDir;
|
||||
|
||||
return {
|
||||
TEST_STATE_DIR: baseDir,
|
||||
PREVIOUS_OPENCLAW_STATE_DIR: previousStateDir,
|
||||
SANDBOX_REGISTRY_PATH: path.join(baseDir, "containers.json"),
|
||||
SANDBOX_BROWSER_REGISTRY_PATH: path.join(baseDir, "browsers.json"),
|
||||
SANDBOX_CONTAINERS_DIR: path.join(baseDir, "containers"),
|
||||
SANDBOX_BROWSERS_DIR: path.join(baseDir, "browsers"),
|
||||
writeGateState: { active: null as WriteDelayConfig | null },
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -42,37 +37,8 @@ vi.mock("./constants.js", () => ({
|
|||
SANDBOX_BROWSERS_DIR,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/json-files.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/json-files.js")>(
|
||||
"../../infra/json-files.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
writeJson: async (
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
options?: Parameters<typeof actual.writeJson>[2],
|
||||
) => {
|
||||
// Gate selected writes to model a concurrent update/remove interleave at
|
||||
// the JSON-file boundary.
|
||||
const payload = JSON.stringify(value);
|
||||
const gate = writeGateState.active;
|
||||
if (
|
||||
gate &&
|
||||
(!gate.targetFile || filePath.includes(gate.targetFile)) &&
|
||||
payloadMentionsContainer(payload, gate.containerName)
|
||||
) {
|
||||
if (!gate.started) {
|
||||
gate.started = true;
|
||||
gate.markStarted();
|
||||
}
|
||||
await gate.waitForRelease;
|
||||
}
|
||||
await actual.writeJson(filePath, value, options);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
|
||||
import { hashTextSha256 } from "./hash.js";
|
||||
import {
|
||||
migrateLegacySandboxRegistryFiles,
|
||||
readBrowserRegistry,
|
||||
|
|
@ -88,13 +54,6 @@ type SandboxBrowserRegistryEntry = import("./registry.js").SandboxBrowserRegistr
|
|||
type SandboxRegistryEntry = import("./registry.js").SandboxRegistryEntry;
|
||||
type MigrationResult = Awaited<ReturnType<typeof migrateLegacySandboxRegistryFiles>>[number];
|
||||
|
||||
function payloadMentionsContainer(payload: string, containerName: string): boolean {
|
||||
return (
|
||||
payload.includes(`"containerName":"${containerName}"`) ||
|
||||
payload.includes(`"containerName": "${containerName}"`)
|
||||
);
|
||||
}
|
||||
|
||||
async function seedMalformedContainerRegistry(payload: string) {
|
||||
await fs.writeFile(SANDBOX_REGISTRY_PATH, payload, "utf-8");
|
||||
}
|
||||
|
|
@ -103,39 +62,9 @@ async function seedMalformedBrowserRegistry(payload: string) {
|
|||
await fs.writeFile(SANDBOX_BROWSER_REGISTRY_PATH, payload, "utf-8");
|
||||
}
|
||||
|
||||
function installWriteGate(
|
||||
targetFile: "containers.json" | "browsers.json" | null,
|
||||
containerName: string,
|
||||
): { waitForStart: Promise<void>; release: () => void } {
|
||||
let markStarted = () => {};
|
||||
const waitForStart = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
let resolveRelease = () => {};
|
||||
const waitForRelease = new Promise<void>((resolve) => {
|
||||
resolveRelease = resolve;
|
||||
});
|
||||
writeGateState.active = {
|
||||
targetFile,
|
||||
containerName,
|
||||
started: false,
|
||||
markStarted,
|
||||
waitForRelease,
|
||||
};
|
||||
return {
|
||||
waitForStart,
|
||||
release: () => {
|
||||
resolveRelease();
|
||||
writeGateState.active = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
writeGateState.active = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await fs.rm(path.join(TEST_STATE_DIR, "state"), { recursive: true, force: true });
|
||||
await fs.rm(SANDBOX_CONTAINERS_DIR, { recursive: true, force: true });
|
||||
await fs.rm(SANDBOX_BROWSERS_DIR, { recursive: true, force: true });
|
||||
await fs.rm(SANDBOX_REGISTRY_PATH, { force: true });
|
||||
|
|
@ -145,7 +74,13 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
afterAll(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await fs.rm(TEST_STATE_DIR, { recursive: true, force: true });
|
||||
if (PREVIOUS_OPENCLAW_STATE_DIR === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = PREVIOUS_OPENCLAW_STATE_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
function browserEntry(
|
||||
|
|
@ -185,6 +120,28 @@ async function seedBrowserRegistry(entries: SandboxBrowserRegistryEntry[]) {
|
|||
);
|
||||
}
|
||||
|
||||
async function seedShardedContainerRegistry(entries: SandboxRegistryEntry[]) {
|
||||
await fs.mkdir(SANDBOX_CONTAINERS_DIR, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
await fs.writeFile(
|
||||
path.join(SANDBOX_CONTAINERS_DIR, `${hashTextSha256(entry.containerName)}.json`),
|
||||
`${JSON.stringify(entry, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedShardedBrowserRegistry(entries: SandboxBrowserRegistryEntry[]) {
|
||||
await fs.mkdir(SANDBOX_BROWSERS_DIR, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
await fs.writeFile(
|
||||
path.join(SANDBOX_BROWSERS_DIR, `${hashTextSha256(entry.containerName)}.json`),
|
||||
`${JSON.stringify(entry, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedStaleLock(lockPath: string) {
|
||||
await fs.writeFile(
|
||||
lockPath,
|
||||
|
|
@ -246,7 +203,7 @@ describe("registry race safety", () => {
|
|||
expect(entry?.configLabelKind).toBe("Image");
|
||||
});
|
||||
|
||||
it("migrates legacy container and browser registry files after explicit repair", async () => {
|
||||
it("migrates legacy monolithic container and browser registry files after explicit repair", async () => {
|
||||
await seedContainerRegistry([
|
||||
containerEntry({
|
||||
containerName: "legacy-container",
|
||||
|
|
@ -297,7 +254,35 @@ describe("registry race safety", () => {
|
|||
expect(browser?.configHash).toBe("legacy-browser-hash");
|
||||
});
|
||||
|
||||
it("does not overwrite newer sharded entries during legacy migration", async () => {
|
||||
it("migrates legacy sharded container and browser registry files after explicit repair", async () => {
|
||||
await seedShardedContainerRegistry([
|
||||
containerEntry({
|
||||
containerName: "legacy-container",
|
||||
sessionKey: "agent:legacy",
|
||||
lastUsedAtMs: 7,
|
||||
configHash: "legacy-container-hash",
|
||||
}),
|
||||
]);
|
||||
await seedShardedBrowserRegistry([
|
||||
browserEntry({
|
||||
containerName: "legacy-browser",
|
||||
sessionKey: "agent:legacy",
|
||||
cdpPort: 9333,
|
||||
noVncPort: 6081,
|
||||
configHash: "legacy-browser-hash",
|
||||
}),
|
||||
]);
|
||||
|
||||
const migrationResults = await migrateLegacySandboxRegistryFiles();
|
||||
expect(requireMigrationResult(migrationResults, "containers").status).toBe("migrated");
|
||||
expect(requireMigrationResult(migrationResults, "browsers").status).toBe("migrated");
|
||||
await expectPathMissing(SANDBOX_CONTAINERS_DIR);
|
||||
await expectPathMissing(SANDBOX_BROWSERS_DIR);
|
||||
expect((await readRegistry()).entries[0]?.containerName).toBe("legacy-container");
|
||||
expect((await readBrowserRegistry()).entries[0]?.containerName).toBe("legacy-browser");
|
||||
});
|
||||
|
||||
it("does not overwrite newer SQLite entries during legacy migration", async () => {
|
||||
await updateRegistry(
|
||||
containerEntry({
|
||||
containerName: "container-a",
|
||||
|
|
@ -320,7 +305,30 @@ describe("registry race safety", () => {
|
|||
expect(entry?.lastUsedAtMs).toBe(10);
|
||||
});
|
||||
|
||||
it("reads a single sharded entry without scanning the full registry", async () => {
|
||||
it("prefers newer sharded entries over stale monolithic entries during legacy migration", async () => {
|
||||
await seedContainerRegistry([
|
||||
containerEntry({
|
||||
containerName: "container-a",
|
||||
sessionKey: "legacy-session",
|
||||
lastUsedAtMs: 1,
|
||||
}),
|
||||
]);
|
||||
await seedShardedContainerRegistry([
|
||||
containerEntry({
|
||||
containerName: "container-a",
|
||||
sessionKey: "sharded-session",
|
||||
lastUsedAtMs: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
await migrateLegacySandboxRegistryFiles();
|
||||
|
||||
const entry = await readRegistryEntry("container-a");
|
||||
expect(entry?.sessionKey).toBe("sharded-session");
|
||||
expect(entry?.lastUsedAtMs).toBe(10);
|
||||
});
|
||||
|
||||
it("reads a single SQLite entry without scanning the full registry", async () => {
|
||||
await updateRegistry(containerEntry({ containerName: "container-x", sessionKey: "sess:x" }));
|
||||
await updateRegistry(containerEntry({ containerName: "container-y", sessionKey: "sess:y" }));
|
||||
|
||||
|
|
@ -347,24 +355,19 @@ describe("registry race safety", () => {
|
|||
});
|
||||
|
||||
it("prevents concurrent container remove/update from resurrecting deleted entries", async () => {
|
||||
// Delete wins over an in-flight stale update so prune/remove cannot be
|
||||
// undone by a delayed writer.
|
||||
await updateRegistry(containerEntry({ containerName: "container-x" }));
|
||||
const writeGate = installWriteGate(null, "container-x");
|
||||
|
||||
const updatePromise = updateRegistry(
|
||||
containerEntry({ containerName: "container-x", configHash: "updated" }),
|
||||
);
|
||||
await writeGate.waitForStart;
|
||||
const removePromise = removeRegistryEntry("container-x");
|
||||
writeGate.release();
|
||||
await Promise.all([updatePromise, removePromise]);
|
||||
|
||||
const registry = await readRegistry();
|
||||
expect(registry.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stores unsafe container names as encoded shard filenames", async () => {
|
||||
it("stores unsafe container names without writing path-derived files", async () => {
|
||||
await updateRegistry(containerEntry({ containerName: "../escape" }));
|
||||
|
||||
const registry = await readRegistry();
|
||||
|
|
@ -406,14 +409,11 @@ describe("registry race safety", () => {
|
|||
|
||||
it("prevents concurrent browser remove/update from resurrecting deleted entries", async () => {
|
||||
await updateBrowserRegistry(browserEntry({ containerName: "browser-x" }));
|
||||
const writeGate = installWriteGate(null, "browser-x");
|
||||
|
||||
const updatePromise = updateBrowserRegistry(
|
||||
browserEntry({ containerName: "browser-x", configHash: "updated" }),
|
||||
);
|
||||
await writeGate.waitForStart;
|
||||
const removePromise = removeBrowserRegistryEntry("browser-x");
|
||||
writeGate.release();
|
||||
await Promise.all([updatePromise, removePromise]);
|
||||
|
||||
const registry = await readBrowserRegistry();
|
||||
|
|
@ -443,4 +443,28 @@ describe("registry race safety", () => {
|
|||
);
|
||||
expect(requireMigrationResult(migrationResults, "browsers").status).toBe("quarantined-invalid");
|
||||
});
|
||||
|
||||
it("quarantines malformed sharded registry directories during migration", async () => {
|
||||
await fs.mkdir(SANDBOX_CONTAINERS_DIR, { recursive: true });
|
||||
await fs.mkdir(SANDBOX_BROWSERS_DIR, { recursive: true });
|
||||
await seedShardedContainerRegistry([
|
||||
containerEntry({ containerName: "valid-container", sessionKey: "agent:valid" }),
|
||||
]);
|
||||
await seedShardedBrowserRegistry([
|
||||
browserEntry({ containerName: "valid-browser", sessionKey: "agent:valid" }),
|
||||
]);
|
||||
await fs.writeFile(path.join(SANDBOX_CONTAINERS_DIR, "bad.json"), "{bad json", "utf-8");
|
||||
await fs.writeFile(path.join(SANDBOX_BROWSERS_DIR, "bad.json"), "{bad json", "utf-8");
|
||||
|
||||
const migrationResults = await migrateLegacySandboxRegistryFiles();
|
||||
|
||||
expect(requireMigrationResult(migrationResults, "containers").status).toBe(
|
||||
"quarantined-invalid",
|
||||
);
|
||||
expect(requireMigrationResult(migrationResults, "browsers").status).toBe("quarantined-invalid");
|
||||
expect((await readRegistry()).entries[0]?.containerName).toBe("valid-container");
|
||||
expect((await readBrowserRegistry()).entries[0]?.containerName).toBe("valid-browser");
|
||||
await expectPathMissing(SANDBOX_CONTAINERS_DIR);
|
||||
await expectPathMissing(SANDBOX_BROWSERS_DIR);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
/**
|
||||
* Persistent sandbox registry storage.
|
||||
*
|
||||
* Tracks runtime and browser containers with sharded JSON files plus migration support for legacy registries.
|
||||
* Tracks runtime and browser containers in the shared state DB plus migration support for legacy registries.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import { z } from "zod";
|
||||
import { writeJson } from "../../infra/json-files.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../../state/openclaw-state-db.js";
|
||||
import { safeParseJsonWithSchema } from "../../utils/zod-parse.js";
|
||||
import { acquireSessionWriteLock } from "../session-write-lock.js";
|
||||
import {
|
||||
|
|
@ -15,7 +21,6 @@ import {
|
|||
SANDBOX_CONTAINERS_DIR,
|
||||
SANDBOX_REGISTRY_PATH,
|
||||
} from "./constants.js";
|
||||
import { hashTextSha256 } from "./hash.js";
|
||||
|
||||
export type SandboxRegistryEntry = {
|
||||
containerName: string;
|
||||
|
|
@ -58,7 +63,19 @@ type RegistryFile = {
|
|||
entries: RegistryEntryPayload[];
|
||||
};
|
||||
|
||||
type ShardedRegistryRead<T extends RegistryEntry> = {
|
||||
entries: T[];
|
||||
validFiles: string[];
|
||||
invalidFiles: string[];
|
||||
};
|
||||
|
||||
type LegacyRegistryKind = "containers" | "browsers";
|
||||
type SandboxRegistryKind = "container" | "browser";
|
||||
type SandboxRegistryTable = OpenClawStateKyselyDatabase["sandbox_registry_entries"];
|
||||
type SandboxRegistryDatabase = Pick<OpenClawStateKyselyDatabase, "sandbox_registry_entries">;
|
||||
type SandboxRegistryRow = Selectable<SandboxRegistryTable>;
|
||||
type SandboxRegistryInsert = Insertable<SandboxRegistryTable>;
|
||||
type SandboxRegistryUpdate = Updateable<SandboxRegistryTable>;
|
||||
|
||||
type LegacyRegistryTarget = {
|
||||
kind: LegacyRegistryKind;
|
||||
|
|
@ -70,11 +87,13 @@ export type LegacySandboxRegistryInspection = LegacyRegistryTarget & {
|
|||
exists: boolean;
|
||||
valid: boolean;
|
||||
entries: number;
|
||||
source: "monolithic" | "sharded";
|
||||
};
|
||||
|
||||
export type LegacySandboxRegistryMigrationResult = LegacyRegistryTarget & {
|
||||
status: "missing" | "migrated" | "removed-empty" | "quarantined-invalid";
|
||||
entries: number;
|
||||
source?: "monolithic" | "sharded";
|
||||
quarantinePath?: string;
|
||||
};
|
||||
|
||||
|
|
@ -88,6 +107,220 @@ const RegistryFileSchema = z.object({
|
|||
entries: z.array(RegistryEntrySchema),
|
||||
});
|
||||
|
||||
function getSandboxRegistryKysely(db: import("node:sqlite").DatabaseSync) {
|
||||
return getNodeSqliteKysely<SandboxRegistryDatabase>(db);
|
||||
}
|
||||
|
||||
function parseRegistryEntryJson(row: SandboxRegistryRow): RegistryEntryPayload | null {
|
||||
try {
|
||||
const parsed = JSON.parse(row.entry_json) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as RegistryEntryPayload)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rowToContainerEntry(row: SandboxRegistryRow): SandboxRegistryEntry | null {
|
||||
if (row.registry_kind !== "container") {
|
||||
return null;
|
||||
}
|
||||
const payload = parseRegistryEntryJson(row);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
return normalizeSandboxRegistryEntry({
|
||||
...payload,
|
||||
containerName: row.container_name,
|
||||
sessionKey: row.session_key ?? String(payload.sessionKey ?? ""),
|
||||
createdAtMs: row.created_at_ms ?? Number(payload.createdAtMs ?? 0),
|
||||
lastUsedAtMs: row.last_used_at_ms ?? Number(payload.lastUsedAtMs ?? 0),
|
||||
image: row.image ?? String(payload.image ?? ""),
|
||||
...(row.backend_id != null ? { backendId: row.backend_id } : {}),
|
||||
...(row.runtime_label != null ? { runtimeLabel: row.runtime_label } : {}),
|
||||
...(row.config_label_kind != null ? { configLabelKind: row.config_label_kind } : {}),
|
||||
...(row.config_hash != null ? { configHash: row.config_hash } : {}),
|
||||
} as SandboxRegistryEntry);
|
||||
}
|
||||
|
||||
function rowToBrowserEntry(row: SandboxRegistryRow): SandboxBrowserRegistryEntry | null {
|
||||
if (row.registry_kind !== "browser") {
|
||||
return null;
|
||||
}
|
||||
const payload = parseRegistryEntryJson(row);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
containerName: row.container_name,
|
||||
sessionKey: row.session_key ?? String(payload.sessionKey ?? ""),
|
||||
createdAtMs: row.created_at_ms ?? Number(payload.createdAtMs ?? 0),
|
||||
lastUsedAtMs: row.last_used_at_ms ?? Number(payload.lastUsedAtMs ?? 0),
|
||||
image: row.image ?? String(payload.image ?? ""),
|
||||
cdpPort: row.cdp_port ?? Number(payload.cdpPort ?? 0),
|
||||
...(row.no_vnc_port != null ? { noVncPort: row.no_vnc_port } : {}),
|
||||
...(row.config_hash != null ? { configHash: row.config_hash } : {}),
|
||||
} as SandboxBrowserRegistryEntry;
|
||||
}
|
||||
|
||||
function containerEntryToRow(entry: SandboxRegistryEntry, existing?: SandboxRegistryEntry | null) {
|
||||
const next: SandboxRegistryEntry = {
|
||||
...entry,
|
||||
backendId: entry.backendId ?? existing?.backendId,
|
||||
runtimeLabel: entry.runtimeLabel ?? existing?.runtimeLabel,
|
||||
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
||||
image: existing?.image ?? entry.image,
|
||||
configLabelKind: entry.configLabelKind ?? existing?.configLabelKind,
|
||||
configHash: entry.configHash ?? existing?.configHash,
|
||||
};
|
||||
return {
|
||||
registry_kind: "container",
|
||||
container_name: next.containerName,
|
||||
session_key: next.sessionKey,
|
||||
backend_id: next.backendId ?? null,
|
||||
runtime_label: next.runtimeLabel ?? null,
|
||||
image: next.image,
|
||||
created_at_ms: next.createdAtMs,
|
||||
last_used_at_ms: next.lastUsedAtMs,
|
||||
config_label_kind: next.configLabelKind ?? null,
|
||||
config_hash: next.configHash ?? null,
|
||||
cdp_port: null,
|
||||
no_vnc_port: null,
|
||||
entry_json: JSON.stringify(next),
|
||||
updated_at: Date.now(),
|
||||
} satisfies SandboxRegistryInsert;
|
||||
}
|
||||
|
||||
function browserEntryToRow(
|
||||
entry: SandboxBrowserRegistryEntry,
|
||||
existing?: SandboxBrowserRegistryEntry | null,
|
||||
) {
|
||||
const next: SandboxBrowserRegistryEntry = {
|
||||
...entry,
|
||||
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
||||
image: existing?.image ?? entry.image,
|
||||
configHash: entry.configHash ?? existing?.configHash,
|
||||
};
|
||||
return {
|
||||
registry_kind: "browser",
|
||||
container_name: next.containerName,
|
||||
session_key: next.sessionKey,
|
||||
backend_id: null,
|
||||
runtime_label: null,
|
||||
image: next.image,
|
||||
created_at_ms: next.createdAtMs,
|
||||
last_used_at_ms: next.lastUsedAtMs,
|
||||
config_label_kind: null,
|
||||
config_hash: next.configHash ?? null,
|
||||
cdp_port: next.cdpPort,
|
||||
no_vnc_port: next.noVncPort ?? null,
|
||||
entry_json: JSON.stringify(next),
|
||||
updated_at: Date.now(),
|
||||
} satisfies SandboxRegistryInsert;
|
||||
}
|
||||
|
||||
function rowToUpdate(row: SandboxRegistryInsert): SandboxRegistryUpdate {
|
||||
const { registry_kind: _registryKind, container_name: _containerName, ...update } = row;
|
||||
return update;
|
||||
}
|
||||
|
||||
function readRegistryRows(kind: SandboxRegistryKind): SandboxRegistryRow[] {
|
||||
const { db } = openOpenClawStateDatabase();
|
||||
const stateDb = getSandboxRegistryKysely(db);
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.selectFrom("sandbox_registry_entries")
|
||||
.selectAll()
|
||||
.where("registry_kind", "=", kind)
|
||||
.orderBy("container_name", "asc"),
|
||||
).rows;
|
||||
}
|
||||
|
||||
function readRegistryRow(
|
||||
kind: SandboxRegistryKind,
|
||||
containerName: string,
|
||||
): SandboxRegistryRow | null {
|
||||
const { db } = openOpenClawStateDatabase();
|
||||
const stateDb = getSandboxRegistryKysely(db);
|
||||
return (
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.selectFrom("sandbox_registry_entries")
|
||||
.selectAll()
|
||||
.where("registry_kind", "=", kind)
|
||||
.where("container_name", "=", containerName)
|
||||
.limit(1),
|
||||
).rows[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function insertRegistryRowIfMissing(row: SandboxRegistryInsert): void {
|
||||
runOpenClawStateWriteTransaction(({ db }) => {
|
||||
const stateDb = getSandboxRegistryKysely(db);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.insertInto("sandbox_registry_entries")
|
||||
.values(row)
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["registry_kind", "container_name"]).doNothing(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function insertRegistryRow(
|
||||
db: import("node:sqlite").DatabaseSync,
|
||||
row: SandboxRegistryInsert,
|
||||
): void {
|
||||
const stateDb = getSandboxRegistryKysely(db);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.insertInto("sandbox_registry_entries")
|
||||
.values(row)
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["registry_kind", "container_name"]).doUpdateSet(rowToUpdate(row)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function readRegistryRowFromDb(
|
||||
db: import("node:sqlite").DatabaseSync,
|
||||
kind: SandboxRegistryKind,
|
||||
containerName: string,
|
||||
): SandboxRegistryRow | null {
|
||||
const stateDb = getSandboxRegistryKysely(db);
|
||||
return (
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.selectFrom("sandbox_registry_entries")
|
||||
.selectAll()
|
||||
.where("registry_kind", "=", kind)
|
||||
.where("container_name", "=", containerName)
|
||||
.limit(1),
|
||||
).rows[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function removeRegistryRow(kind: SandboxRegistryKind, containerName: string): void {
|
||||
runOpenClawStateWriteTransaction(({ db }) => {
|
||||
const stateDb = getSandboxRegistryKysely(db);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.deleteFrom("sandbox_registry_entries")
|
||||
.where("registry_kind", "=", kind)
|
||||
.where("container_name", "=", containerName),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSandboxRegistryEntry(entry: SandboxRegistryEntry): SandboxRegistryEntry {
|
||||
return {
|
||||
...entry,
|
||||
|
|
@ -127,88 +360,49 @@ async function readLegacyRegistryFile(registryPath: string): Promise<RegistryFil
|
|||
}
|
||||
}
|
||||
|
||||
/** Reads all registered sandbox runtime containers from the sharded registry. */
|
||||
/** Reads all registered sandbox runtime containers from SQLite. */
|
||||
export async function readRegistry(): Promise<SandboxRegistry> {
|
||||
const entries = await readShardedEntries<SandboxRegistryEntry>(SANDBOX_CONTAINERS_DIR);
|
||||
const entries = readRegistryRows("container")
|
||||
.map((row) => rowToContainerEntry(row))
|
||||
.filter((entry): entry is SandboxRegistryEntry => entry != null);
|
||||
return {
|
||||
entries: entries.map((entry) => normalizeSandboxRegistryEntry(entry)),
|
||||
};
|
||||
}
|
||||
|
||||
function shardedEntryFilePath(dir: string, containerName: string): string {
|
||||
return path.join(dir, `${hashTextSha256(containerName)}.json`);
|
||||
}
|
||||
|
||||
async function withEntryLock<T>(
|
||||
async function readShardedEntriesDetailed<T extends RegistryEntry>(
|
||||
dir: string,
|
||||
containerName: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const entryPath = shardedEntryFilePath(dir, containerName);
|
||||
// Entry-level locks keep independent container updates concurrent while serializing writes for
|
||||
// the same container hash path.
|
||||
const lock = await acquireSessionWriteLock({
|
||||
sessionFile: entryPath,
|
||||
allowReentrant: false,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function readShardedEntry<T extends RegistryEntry>(
|
||||
dir: string,
|
||||
containerName: string,
|
||||
): Promise<T | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(shardedEntryFilePath(dir, containerName), "utf-8");
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const parsed = safeParseJsonWithSchema(RegistryEntrySchema, raw) as T | null;
|
||||
return parsed?.containerName === containerName ? parsed : null;
|
||||
}
|
||||
|
||||
async function writeShardedEntry(dir: string, entry: RegistryEntryPayload): Promise<void> {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await writeJson(shardedEntryFilePath(dir, entry.containerName), entry, {
|
||||
trailingNewline: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function removeShardedEntry(dir: string, containerName: string): Promise<void> {
|
||||
await fs.rm(shardedEntryFilePath(dir, containerName), { force: true });
|
||||
}
|
||||
|
||||
async function readShardedEntries<T extends RegistryEntry>(dir: string): Promise<T[]> {
|
||||
): Promise<ShardedRegistryRead<T>> {
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(dir);
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return [];
|
||||
return { entries: [], validFiles: [], invalidFiles: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const invalidFiles: string[] = [];
|
||||
const validFiles: string[] = [];
|
||||
const entries = await Promise.all(
|
||||
files
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.toSorted()
|
||||
.map(async (name) => {
|
||||
const filePath = path.join(dir, name);
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(dir, name), "utf-8");
|
||||
return safeParseJsonWithSchema(RegistryEntrySchema, raw) as T | null;
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
const entry = safeParseJsonWithSchema(RegistryEntrySchema, raw) as T | null;
|
||||
if (!entry) {
|
||||
invalidFiles.push(filePath);
|
||||
} else {
|
||||
validFiles.push(filePath);
|
||||
}
|
||||
return entry;
|
||||
} catch {
|
||||
invalidFiles.push(filePath);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
|
|
@ -219,9 +413,13 @@ async function readShardedEntries<T extends RegistryEntry>(dir: string): Promise
|
|||
validEntries.push(entry);
|
||||
}
|
||||
}
|
||||
return validEntries.toSorted((left, right) =>
|
||||
left.containerName.localeCompare(right.containerName),
|
||||
);
|
||||
return {
|
||||
entries: validEntries.toSorted((left, right) =>
|
||||
left.containerName.localeCompare(right.containerName),
|
||||
),
|
||||
validFiles: validFiles.toSorted(),
|
||||
invalidFiles: invalidFiles.toSorted(),
|
||||
};
|
||||
}
|
||||
|
||||
async function quarantineLegacyRegistry(registryPath: string): Promise<string> {
|
||||
|
|
@ -235,16 +433,39 @@ async function quarantineLegacyRegistry(registryPath: string): Promise<string> {
|
|||
return quarantinePath;
|
||||
}
|
||||
|
||||
async function quarantineInvalidShards(
|
||||
dir: string,
|
||||
invalidFiles: readonly string[],
|
||||
): Promise<string> {
|
||||
const quarantineDir = `${dir}.invalid-${Date.now()}`;
|
||||
await fs.mkdir(quarantineDir, { recursive: true });
|
||||
for (const invalidFile of invalidFiles) {
|
||||
await fs
|
||||
.rename(invalidFile, path.join(quarantineDir, path.basename(invalidFile)))
|
||||
.catch(async (error: unknown) => {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
return quarantineDir;
|
||||
}
|
||||
|
||||
async function removeFiles(files: readonly string[]): Promise<void> {
|
||||
await Promise.all(files.map((file) => fs.rm(file, { force: true })));
|
||||
}
|
||||
|
||||
async function migrateMonolithicIfNeeded(
|
||||
target: LegacyRegistryTarget,
|
||||
): Promise<LegacySandboxRegistryMigrationResult> {
|
||||
const { registryPath, shardedDir } = target;
|
||||
const { registryPath } = target;
|
||||
try {
|
||||
await fs.access(registryPath);
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return { ...target, status: "missing", entries: 0 };
|
||||
return { ...target, source: "monolithic", status: "missing", entries: 0 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -253,26 +474,124 @@ async function migrateMonolithicIfNeeded(
|
|||
const registry = await readLegacyRegistryFile(registryPath);
|
||||
if (!registry) {
|
||||
const quarantinePath = await quarantineLegacyRegistry(registryPath);
|
||||
return { ...target, status: "quarantined-invalid", entries: 0, quarantinePath };
|
||||
return {
|
||||
...target,
|
||||
source: "monolithic",
|
||||
status: "quarantined-invalid",
|
||||
entries: 0,
|
||||
quarantinePath,
|
||||
};
|
||||
}
|
||||
if (registry.entries.length === 0) {
|
||||
await fs.rm(registryPath, { force: true });
|
||||
return { ...target, status: "removed-empty", entries: 0 };
|
||||
return { ...target, source: "monolithic", status: "removed-empty", entries: 0 };
|
||||
}
|
||||
await fs.mkdir(shardedDir, { recursive: true });
|
||||
for (const entry of registry.entries) {
|
||||
await withEntryLock(shardedDir, entry.containerName, async () => {
|
||||
const existing = await readShardedEntry(shardedDir, entry.containerName);
|
||||
if (!existing) {
|
||||
await writeShardedEntry(shardedDir, entry);
|
||||
}
|
||||
});
|
||||
writeLegacyEntryIfMissing(target.kind, entry);
|
||||
}
|
||||
await fs.rm(registryPath, { force: true });
|
||||
return { ...target, status: "migrated", entries: registry.entries.length };
|
||||
return {
|
||||
...target,
|
||||
source: "monolithic",
|
||||
status: "migrated",
|
||||
entries: registry.entries.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function writeLegacyEntryIfMissing(kind: LegacyRegistryKind, entry: RegistryEntryPayload): boolean {
|
||||
if (kind === "containers") {
|
||||
insertRegistryRowIfMissing(
|
||||
containerEntryToRow({
|
||||
...entry,
|
||||
containerName: entry.containerName,
|
||||
sessionKey: typeof entry.sessionKey === "string" ? entry.sessionKey : "",
|
||||
createdAtMs: typeof entry.createdAtMs === "number" ? entry.createdAtMs : 0,
|
||||
lastUsedAtMs: typeof entry.lastUsedAtMs === "number" ? entry.lastUsedAtMs : 0,
|
||||
image: typeof entry.image === "string" ? entry.image : "",
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
insertRegistryRowIfMissing(
|
||||
browserEntryToRow({
|
||||
...entry,
|
||||
containerName: entry.containerName,
|
||||
sessionKey: typeof entry.sessionKey === "string" ? entry.sessionKey : "",
|
||||
createdAtMs: typeof entry.createdAtMs === "number" ? entry.createdAtMs : 0,
|
||||
lastUsedAtMs: typeof entry.lastUsedAtMs === "number" ? entry.lastUsedAtMs : 0,
|
||||
image: typeof entry.image === "string" ? entry.image : "",
|
||||
cdpPort: typeof entry.cdpPort === "number" ? entry.cdpPort : 0,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function migrateShardedIfNeeded(
|
||||
target: LegacyRegistryTarget,
|
||||
): Promise<LegacySandboxRegistryMigrationResult> {
|
||||
let dirExists = false;
|
||||
try {
|
||||
const stat = await fs.stat(target.shardedDir);
|
||||
dirExists = stat.isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!dirExists) {
|
||||
return { ...target, source: "sharded", status: "missing", entries: 0 };
|
||||
}
|
||||
const { entries, validFiles, invalidFiles } =
|
||||
await readShardedEntriesDetailed<RegistryEntryPayload>(target.shardedDir);
|
||||
if (invalidFiles.length > 0) {
|
||||
for (const entry of entries) {
|
||||
writeLegacyEntryIfMissing(target.kind, entry);
|
||||
}
|
||||
await removeFiles(validFiles);
|
||||
const quarantinePath = await quarantineInvalidShards(target.shardedDir, invalidFiles);
|
||||
await fs.rm(target.shardedDir, { recursive: true, force: true });
|
||||
return {
|
||||
...target,
|
||||
source: "sharded",
|
||||
status: "quarantined-invalid",
|
||||
entries: entries.length,
|
||||
quarantinePath,
|
||||
};
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
await fs.rm(target.shardedDir, { recursive: true, force: true });
|
||||
return { ...target, source: "sharded", status: "removed-empty", entries: 0 };
|
||||
}
|
||||
for (const entry of entries) {
|
||||
writeLegacyEntryIfMissing(target.kind, entry);
|
||||
}
|
||||
await fs.rm(target.shardedDir, { recursive: true, force: true });
|
||||
return { ...target, source: "sharded", status: "migrated", entries: entries.length };
|
||||
}
|
||||
|
||||
function combineMigrationResults(
|
||||
target: LegacyRegistryTarget,
|
||||
monolithic: LegacySandboxRegistryMigrationResult,
|
||||
sharded: LegacySandboxRegistryMigrationResult,
|
||||
): LegacySandboxRegistryMigrationResult {
|
||||
if (monolithic.status === "quarantined-invalid") {
|
||||
return monolithic;
|
||||
}
|
||||
if (sharded.status === "quarantined-invalid") {
|
||||
return sharded;
|
||||
}
|
||||
const entries = monolithic.entries + sharded.entries;
|
||||
if (entries > 0) {
|
||||
return { ...target, status: "migrated", entries };
|
||||
}
|
||||
if (monolithic.status === "removed-empty" || sharded.status === "removed-empty") {
|
||||
return { ...target, status: "removed-empty", entries: 0 };
|
||||
}
|
||||
return { ...target, status: "missing", entries: 0 };
|
||||
}
|
||||
|
||||
function legacyRegistryTargets(): LegacyRegistryTarget[] {
|
||||
return [
|
||||
{
|
||||
|
|
@ -288,7 +607,7 @@ function legacyRegistryTargets(): LegacyRegistryTarget[] {
|
|||
];
|
||||
}
|
||||
|
||||
/** Inspects old monolithic registry files without mutating them. */
|
||||
/** Inspects old registry files without mutating them. */
|
||||
export async function inspectLegacySandboxRegistryFiles(): Promise<
|
||||
LegacySandboxRegistryInspection[]
|
||||
> {
|
||||
|
|
@ -299,30 +618,59 @@ export async function inspectLegacySandboxRegistryFiles(): Promise<
|
|||
} catch (error) {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code === "ENOENT") {
|
||||
inspections.push({ ...target, exists: false, valid: true, entries: 0 });
|
||||
continue;
|
||||
inspections.push({
|
||||
...target,
|
||||
source: "monolithic",
|
||||
exists: false,
|
||||
valid: true,
|
||||
entries: 0,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const registry = await readLegacyRegistryFile(target.registryPath);
|
||||
if (!inspections.some((entry) => entry.kind === target.kind && entry.source === "monolithic")) {
|
||||
const registry = await readLegacyRegistryFile(target.registryPath);
|
||||
inspections.push({
|
||||
...target,
|
||||
source: "monolithic",
|
||||
exists: true,
|
||||
valid: Boolean(registry),
|
||||
entries: registry?.entries.length ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
const sharded = await readShardedEntriesDetailed<RegistryEntryPayload>(target.shardedDir);
|
||||
let shardedExists = false;
|
||||
try {
|
||||
shardedExists = (await fs.stat(target.shardedDir)).isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
inspections.push({
|
||||
...target,
|
||||
exists: true,
|
||||
valid: Boolean(registry),
|
||||
entries: registry?.entries.length ?? 0,
|
||||
source: "sharded",
|
||||
exists: shardedExists,
|
||||
valid: sharded.invalidFiles.length === 0,
|
||||
entries: sharded.entries.length,
|
||||
});
|
||||
}
|
||||
return inspections;
|
||||
}
|
||||
|
||||
/** Migrates old monolithic registry files into sharded entry files when present. */
|
||||
/** Migrates old registry files into SQLite when present. */
|
||||
export async function migrateLegacySandboxRegistryFiles(): Promise<
|
||||
LegacySandboxRegistryMigrationResult[]
|
||||
> {
|
||||
const results: LegacySandboxRegistryMigrationResult[] = [];
|
||||
for (const target of legacyRegistryTargets()) {
|
||||
results.push(await migrateMonolithicIfNeeded(target));
|
||||
const sharded = await migrateShardedIfNeeded(target);
|
||||
const monolithic = await migrateMonolithicIfNeeded(target);
|
||||
results.push(combineMigrationResults(target, monolithic, sharded));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
|
@ -331,60 +679,44 @@ export async function migrateLegacySandboxRegistryFiles(): Promise<
|
|||
export async function readRegistryEntry(
|
||||
containerName: string,
|
||||
): Promise<SandboxRegistryEntry | null> {
|
||||
const entry = await readShardedEntry<SandboxRegistryEntry>(SANDBOX_CONTAINERS_DIR, containerName);
|
||||
const row = readRegistryRow("container", containerName);
|
||||
const entry = row ? rowToContainerEntry(row) : null;
|
||||
return entry ? normalizeSandboxRegistryEntry(entry) : null;
|
||||
}
|
||||
|
||||
/** Creates or updates one sandbox runtime registry entry, preserving immutable creation fields. */
|
||||
export async function updateRegistry(entry: SandboxRegistryEntry) {
|
||||
await withEntryLock(SANDBOX_CONTAINERS_DIR, entry.containerName, async () => {
|
||||
const existing = await readShardedEntry<SandboxRegistryEntry>(
|
||||
SANDBOX_CONTAINERS_DIR,
|
||||
entry.containerName,
|
||||
);
|
||||
await writeShardedEntry(SANDBOX_CONTAINERS_DIR, {
|
||||
...entry,
|
||||
backendId: entry.backendId ?? existing?.backendId,
|
||||
runtimeLabel: entry.runtimeLabel ?? existing?.runtimeLabel,
|
||||
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
||||
image: existing?.image ?? entry.image,
|
||||
configLabelKind: entry.configLabelKind ?? existing?.configLabelKind,
|
||||
configHash: entry.configHash ?? existing?.configHash,
|
||||
});
|
||||
runOpenClawStateWriteTransaction(({ db }) => {
|
||||
const existingRow = readRegistryRowFromDb(db, "container", entry.containerName);
|
||||
const existing = existingRow ? rowToContainerEntry(existingRow) : null;
|
||||
insertRegistryRow(db, containerEntryToRow(entry, existing));
|
||||
});
|
||||
}
|
||||
|
||||
/** Removes one sandbox runtime registry entry by container name. */
|
||||
export async function removeRegistryEntry(containerName: string) {
|
||||
await withEntryLock(SANDBOX_CONTAINERS_DIR, containerName, async () => {
|
||||
await removeShardedEntry(SANDBOX_CONTAINERS_DIR, containerName);
|
||||
});
|
||||
removeRegistryRow("container", containerName);
|
||||
}
|
||||
|
||||
/** Reads all registered browser sandbox containers from the sharded registry. */
|
||||
/** Reads all registered browser sandbox containers from SQLite. */
|
||||
export async function readBrowserRegistry(): Promise<SandboxBrowserRegistry> {
|
||||
return { entries: await readShardedEntries<SandboxBrowserRegistryEntry>(SANDBOX_BROWSERS_DIR) };
|
||||
return {
|
||||
entries: readRegistryRows("browser")
|
||||
.map((row) => rowToBrowserEntry(row))
|
||||
.filter((entry): entry is SandboxBrowserRegistryEntry => entry != null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates or updates one browser sandbox registry entry, preserving immutable creation fields. */
|
||||
export async function updateBrowserRegistry(entry: SandboxBrowserRegistryEntry) {
|
||||
await withEntryLock(SANDBOX_BROWSERS_DIR, entry.containerName, async () => {
|
||||
const existing = await readShardedEntry<SandboxBrowserRegistryEntry>(
|
||||
SANDBOX_BROWSERS_DIR,
|
||||
entry.containerName,
|
||||
);
|
||||
await writeShardedEntry(SANDBOX_BROWSERS_DIR, {
|
||||
...entry,
|
||||
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
||||
image: existing?.image ?? entry.image,
|
||||
configHash: entry.configHash ?? existing?.configHash,
|
||||
});
|
||||
runOpenClawStateWriteTransaction(({ db }) => {
|
||||
const existingRow = readRegistryRowFromDb(db, "browser", entry.containerName);
|
||||
const existing = existingRow ? rowToBrowserEntry(existingRow) : null;
|
||||
insertRegistryRow(db, browserEntryToRow(entry, existing));
|
||||
});
|
||||
}
|
||||
|
||||
/** Removes one browser sandbox registry entry by container name. */
|
||||
export async function removeBrowserRegistryEntry(containerName: string) {
|
||||
await withEntryLock(SANDBOX_BROWSERS_DIR, containerName, async () => {
|
||||
await removeShardedEntry(SANDBOX_BROWSERS_DIR, containerName);
|
||||
});
|
||||
removeRegistryRow("browser", containerName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -358,25 +358,27 @@ export async function maybeRepairSandboxImages(
|
|||
|
||||
function formatLegacyRegistryInspectionLine(file: LegacySandboxRegistryInspection): string {
|
||||
const status = file.valid ? `${file.entries} entr${file.entries === 1 ? "y" : "ies"}` : "invalid";
|
||||
return `- ${file.kind}: ${shortenHomePath(file.registryPath)} (${status})`;
|
||||
const sourcePath = file.source === "sharded" ? file.shardedDir : file.registryPath;
|
||||
return `- ${file.kind} ${file.source}: ${shortenHomePath(sourcePath)} (${status})`;
|
||||
}
|
||||
|
||||
function formatLegacyRegistryMigrationLine(result: LegacySandboxRegistryMigrationResult): string {
|
||||
const file = shortenHomePath(result.registryPath);
|
||||
if (result.status === "migrated") {
|
||||
return `- Migrated ${result.kind} registry from ${file} into ${result.entries} shard${result.entries === 1 ? "" : "s"}.`;
|
||||
return `- Migrated ${result.kind} registry into ${result.entries} SQLite row${result.entries === 1 ? "" : "s"}.`;
|
||||
}
|
||||
if (result.status === "removed-empty") {
|
||||
return `- Removed empty legacy ${result.kind} registry ${file}.`;
|
||||
return `- Removed empty legacy ${result.kind} registry files.`;
|
||||
}
|
||||
if (result.status === "quarantined-invalid") {
|
||||
const sourcePath = result.source === "sharded" ? result.shardedDir : result.registryPath;
|
||||
const file = shortenHomePath(sourcePath);
|
||||
const quarantine = result.quarantinePath ? ` to ${shortenHomePath(result.quarantinePath)}` : "";
|
||||
return `- Quarantined invalid legacy ${result.kind} registry ${file}${quarantine}.`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Migrates legacy sandbox registry files into the current sharded registry layout. */
|
||||
/** Migrates legacy sandbox registry files and directories into SQLite. */
|
||||
export async function maybeRepairSandboxRegistryFiles(prompter: DoctorPrompter): Promise<void> {
|
||||
const legacyFiles = (await inspectLegacySandboxRegistryFiles()).filter((file) => file.exists);
|
||||
if (legacyFiles.length === 0) {
|
||||
|
|
@ -388,7 +390,7 @@ export async function maybeRepairSandboxRegistryFiles(prompter: DoctorPrompter):
|
|||
[
|
||||
"Legacy sandbox registry files detected.",
|
||||
...legacyFiles.map(formatLegacyRegistryInspectionLine),
|
||||
`Run ${formatCliCommand("openclaw doctor --fix")} to migrate them to sharded registry files.`,
|
||||
`Run ${formatCliCommand("openclaw doctor --fix")} to migrate them to SQLite.`,
|
||||
].join("\n"),
|
||||
"Sandbox",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ describe("maybeRepairSandboxRegistryFiles", () => {
|
|||
kind: "containers",
|
||||
registryPath: "/tmp/openclaw/sandbox/containers.json",
|
||||
shardedDir: "/tmp/openclaw/sandbox/containers",
|
||||
source: "monolithic",
|
||||
exists: true,
|
||||
valid: true,
|
||||
entries: 2,
|
||||
|
|
@ -270,8 +271,8 @@ describe("maybeRepairSandboxRegistryFiles", () => {
|
|||
expect(note).toHaveBeenCalledWith(
|
||||
[
|
||||
"Legacy sandbox registry files detected.",
|
||||
"- containers: /tmp/openclaw/sandbox/containers.json (2 entries)",
|
||||
"Run openclaw doctor --fix to migrate them to sharded registry files.",
|
||||
"- containers monolithic: /tmp/openclaw/sandbox/containers.json (2 entries)",
|
||||
"Run openclaw doctor --fix to migrate them to SQLite.",
|
||||
].join("\n"),
|
||||
"Sandbox",
|
||||
);
|
||||
|
|
@ -283,6 +284,7 @@ describe("maybeRepairSandboxRegistryFiles", () => {
|
|||
kind: "containers",
|
||||
registryPath: "/tmp/openclaw/sandbox/containers.json",
|
||||
shardedDir: "/tmp/openclaw/sandbox/containers",
|
||||
source: "monolithic",
|
||||
exists: true,
|
||||
valid: true,
|
||||
entries: 2,
|
||||
|
|
@ -305,7 +307,7 @@ describe("maybeRepairSandboxRegistryFiles", () => {
|
|||
|
||||
expect(migrateLegacySandboxRegistryFiles).toHaveBeenCalledTimes(1);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
"- Migrated containers registry from /tmp/openclaw/sandbox/containers.json into 2 shards.",
|
||||
"- Migrated containers registry into 2 SQLite rows.",
|
||||
"Doctor changes",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue