Skip to content

Commit addd220

Browse files
committed
Add named snapshot database targets
1 parent a341086 commit addd220

4 files changed

Lines changed: 175 additions & 7 deletions

File tree

src/cli/program/register.snapshot.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,30 @@ describe("registerSnapshotCommand", () => {
8282
);
8383
});
8484

85+
it("runs snapshot create for a named global target", async () => {
86+
await runCli(["snapshot", "create", "--target", "global", "--repository", "/tmp/snapshots"]);
87+
88+
expect(mocks.snapshotCreateCommand).toHaveBeenCalledWith(
89+
{
90+
target: "global",
91+
repository: "/tmp/snapshots",
92+
},
93+
mocks.runtime,
94+
);
95+
});
96+
97+
it("runs snapshot create for a named agent target", async () => {
98+
await runCli(["snapshot", "create", "--agent", "main", "--repository", "/tmp/snapshots"]);
99+
100+
expect(mocks.snapshotCreateCommand).toHaveBeenCalledWith(
101+
{
102+
agent: "main",
103+
repository: "/tmp/snapshots",
104+
},
105+
mocks.runtime,
106+
);
107+
});
108+
85109
it("runs snapshot list with forwarded options", async () => {
86110
await runCli(["snapshot", "list", "--repository", "/tmp/snapshots", "--json"]);
87111

src/cli/program/register.snapshot.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export function registerSnapshotCommand(program: Command): void {
2626
snapshot
2727
.command("create")
2828
.description("Create a consistent SQLite snapshot in a local repository")
29-
.requiredOption("--db <path>", "SQLite database path")
29+
.option("--db <path>", "SQLite database path")
30+
.option("--target <target>", "OpenClaw database target (global)")
31+
.option("--agent <id>", "OpenClaw agent id for the per-agent database")
3032
.requiredOption("--repository <path>", "Snapshot repository directory")
3133
.option("--id <id>", "Logical database id recorded in the manifest")
3234
.option("--kind <kind>", "Logical database kind recorded in the manifest")

src/commands/snapshot.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,20 @@ import {
1212
} from "./snapshot.js";
1313

1414
let workspaceDir: string;
15+
let previousOpenClawStateDir: string | undefined;
1516

1617
describe("snapshot cli", () => {
1718
beforeEach(async () => {
19+
previousOpenClawStateDir = process.env.OPENCLAW_STATE_DIR;
1820
workspaceDir = await fs.mkdtemp(path.join(tmpdir(), "snapshot-cli-"));
1921
});
2022

2123
afterEach(async () => {
24+
if (previousOpenClawStateDir === undefined) {
25+
delete process.env.OPENCLAW_STATE_DIR;
26+
} else {
27+
process.env.OPENCLAW_STATE_DIR = previousOpenClawStateDir;
28+
}
2229
await fs.rm(workspaceDir, { recursive: true, force: true });
2330
});
2431

@@ -107,10 +114,90 @@ describe("snapshot cli", () => {
107114
await expect(snapshotCreateCommand({ repository: workspaceDir }, runtime)).resolves.toBe(2);
108115

109116
expect(runtime.logs).toEqual([]);
110-
expect(runtime.errors).toEqual(["Missing required --db value."]);
117+
expect(runtime.errors).toEqual([
118+
"Missing snapshot source. Provide one of --db <path>, --target global, or --agent <id>.",
119+
]);
120+
});
121+
122+
it("creates a snapshot from the named global OpenClaw database", async () => {
123+
const runtime = createRuntimeCapture();
124+
const stateDir = path.join(workspaceDir, "state-root");
125+
const dbPath = path.join(stateDir, "state", "openclaw.sqlite");
126+
const repositoryPath = path.join(workspaceDir, "snapshots");
127+
process.env.OPENCLAW_STATE_DIR = stateDir;
128+
await createSqliteDatabase(dbPath, "from-global");
129+
130+
await expect(
131+
snapshotCreateCommand({ target: "global", repository: repositoryPath, json: true }, runtime),
132+
).resolves.toBe(0);
133+
134+
const createReport = JSON.parse(runtime.logs.shift() ?? "{}") as {
135+
snapshotPath?: string;
136+
manifest?: { database?: { id?: string; kind?: string } };
137+
};
138+
expect(createReport.snapshotPath).toBeTruthy();
139+
expect(createReport.manifest?.database).toMatchObject({
140+
id: "global",
141+
kind: "global-control-plane",
142+
});
143+
expect(runtime.errors).toEqual([]);
144+
});
145+
146+
it("creates a snapshot from the named per-agent OpenClaw database", async () => {
147+
const runtime = createRuntimeCapture();
148+
const stateDir = path.join(workspaceDir, "state-root");
149+
const dbPath = path.join(stateDir, "agents", "ops-team", "agent", "openclaw-agent.sqlite");
150+
const repositoryPath = path.join(workspaceDir, "snapshots");
151+
process.env.OPENCLAW_STATE_DIR = stateDir;
152+
await createSqliteDatabase(dbPath, "from-agent");
153+
154+
await expect(
155+
snapshotCreateCommand({ agent: "Ops Team", repository: repositoryPath, json: true }, runtime),
156+
).resolves.toBe(0);
157+
158+
const createReport = JSON.parse(runtime.logs.shift() ?? "{}") as {
159+
snapshotPath?: string;
160+
manifest?: { database?: { id?: string; kind?: string } };
161+
};
162+
expect(createReport.snapshotPath).toBeTruthy();
163+
expect(createReport.manifest?.database).toMatchObject({
164+
id: "agent:ops-team",
165+
kind: "agent-data-plane",
166+
});
167+
expect(runtime.errors).toEqual([]);
168+
});
169+
170+
it("rejects ambiguous snapshot source selectors", async () => {
171+
const runtime = createRuntimeCapture();
172+
173+
await expect(
174+
snapshotCreateCommand(
175+
{ db: "/tmp/source.sqlite", target: "global", repository: workspaceDir },
176+
runtime,
177+
),
178+
).resolves.toBe(2);
179+
180+
expect(runtime.logs).toEqual([]);
181+
expect(runtime.errors).toEqual([
182+
"Choose only one snapshot source: --db, --target, or --agent.",
183+
]);
111184
});
112185
});
113186

187+
async function createSqliteDatabase(dbPath: string, value: string): Promise<void> {
188+
await fs.mkdir(path.dirname(dbPath), { recursive: true });
189+
const db = new DatabaseSync(dbPath);
190+
try {
191+
db.exec(`
192+
PRAGMA journal_mode = WAL;
193+
CREATE TABLE entries (value TEXT NOT NULL);
194+
`);
195+
db.prepare("INSERT INTO entries (value) VALUES (?)").run(value);
196+
} finally {
197+
db.close();
198+
}
199+
}
200+
114201
function createRuntimeCapture(): RuntimeEnv & {
115202
readonly logs: string[];
116203
readonly errors: string[];

src/commands/snapshot.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
// Core command handlers for SQLite snapshot artifacts.
2+
import { normalizeAgentId } from "../routing/session-key.js";
3+
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
24
import { createLocalSqliteSnapshotProvider } from "../snapshot/local-repository.js";
35
import type {
46
SnapshotManifest,
57
SnapshotSummary,
68
SnapshotVerificationResult,
79
} from "../snapshot/snapshot-provider.js";
8-
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
10+
import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js";
11+
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
912

1013
export interface SnapshotCreateOptions {
1114
readonly db?: string;
15+
readonly target?: string;
16+
readonly agent?: string;
1217
readonly repository?: string;
1318
readonly id?: string;
1419
readonly kind?: string;
@@ -48,18 +53,24 @@ type SnapshotListReport = {
4853
readonly snapshots: readonly SnapshotSummary[];
4954
};
5055

56+
type SnapshotCreateSource = {
57+
readonly path: string;
58+
readonly id?: string;
59+
readonly kind?: string;
60+
};
61+
5162
export async function snapshotCreateCommand(
5263
options: SnapshotCreateOptions,
5364
runtime: RuntimeEnv,
5465
): Promise<number> {
5566
try {
5667
const repositoryPath = requireOption(options.repository, "--repository");
57-
const dbPath = requireOption(options.db, "--db");
68+
const source = resolveSnapshotCreateSource(options);
5869
const provider = createLocalSqliteSnapshotProvider({ repositoryPath });
5970
const result = await provider.create({
60-
path: dbPath,
61-
...(options.id ? { id: options.id } : {}),
62-
...(options.kind ? { kind: options.kind } : {}),
71+
path: source.path,
72+
...(source.id ? { id: source.id } : {}),
73+
...(source.kind ? { kind: source.kind } : {}),
6374
});
6475
writeCreateReport(
6576
{ ok: true, snapshotPath: result.ref.path, manifest: result.manifest },
@@ -125,6 +136,46 @@ export async function snapshotListCommand(
125136
}
126137
}
127138

139+
function resolveSnapshotCreateSource(options: SnapshotCreateOptions): SnapshotCreateSource {
140+
const selectors = [
141+
hasValue(options.db),
142+
hasValue(options.target),
143+
hasValue(options.agent),
144+
].filter(Boolean).length;
145+
if (selectors === 0) {
146+
throw new Error(
147+
"Missing snapshot source. Provide one of --db <path>, --target global, or --agent <id>.",
148+
);
149+
}
150+
if (selectors > 1) {
151+
throw new Error("Choose only one snapshot source: --db, --target, or --agent.");
152+
}
153+
if (hasValue(options.db)) {
154+
return {
155+
path: requireValue(options.db, "--db"),
156+
...(options.id ? { id: options.id } : {}),
157+
...(options.kind ? { kind: options.kind } : {}),
158+
};
159+
}
160+
if (hasValue(options.target)) {
161+
const target = requireValue(options.target, "--target").toLowerCase();
162+
if (target !== "global") {
163+
throw new Error(`Unsupported snapshot target "${target}". Supported targets: global.`);
164+
}
165+
return {
166+
path: resolveOpenClawStateSqlitePath(),
167+
id: options.id ?? "global",
168+
kind: options.kind ?? "global-control-plane",
169+
};
170+
}
171+
const agentId = normalizeAgentId(requireValue(options.agent, "--agent"));
172+
return {
173+
path: resolveOpenClawAgentSqlitePath({ agentId }),
174+
id: options.id ?? `agent:${agentId}`,
175+
kind: options.kind ?? "agent-data-plane",
176+
};
177+
}
178+
128179
function writeCreateReport(
129180
report: SnapshotCreateReport,
130181
options: SnapshotJsonOptions,
@@ -193,6 +244,10 @@ function requireOption(value: string | undefined, flag: string): string {
193244
return requireValue(value, flag);
194245
}
195246

247+
function hasValue(value: string | undefined): boolean {
248+
return value !== undefined && value.trim() !== "";
249+
}
250+
196251
function requireValue(value: string | undefined, label: string): string {
197252
if (value === undefined || value.trim() === "") {
198253
throw new Error(`Missing required ${label} value.`);

0 commit comments

Comments
 (0)