Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ id: 01M4ZYZJRBTSD6WJVT8HEH8XQ0
anchor: function restore
created: 2026-10-15T14:20:42Z
norm: '1'
sig: 05c78f711a9a9228
body_hash: dac1c9f5bf404d33
raw_hash: 3f94de7e3f41e61b
vouched: 2026-10-22T21:13:56Z
sig: 34ffa326bc970eeb
body_hash: b7adc8d1cb030bd6
raw_hash: 9cd4b31a0f2d0251
vouched: 2026-10-22T22:15:45Z
lines: 119-158
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ id: 01M5J2ZT89CTF4ECFNG6YN408R
anchor: function restore
created: 2026-10-22T15:17:03Z
norm: '1'
sig: 05c78f711a9a9228
body_hash: dac1c9f5bf404d33
raw_hash: 3f94de7e3f41e61b
vouched: 2026-10-22T21:13:56Z
sig: 34ffa326bc970eeb
body_hash: b7adc8d1cb030bd6
raw_hash: 9cd4b31a0f2d0251
vouched: 2026-10-22T22:15:45Z
lines: 119-164
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ id: 01M5JQD5HXE7X0AR39PYQVNYXR
anchor: function restore
created: 2026-10-22T21:13:52Z
norm: '1'
sig: 05c78f711a9a9228
body_hash: dac1c9f5bf404d33
raw_hash: 3f94de7e3f41e61b
sig: 34ffa326bc970eeb
body_hash: b7adc8d1cb030bd6
raw_hash: 9cd4b31a0f2d0251
vouched: 2026-10-22T22:15:45Z
lines: 120-166
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function main
created: 2026-10-15T04:47:23Z
norm: '1'
sig: 7d5d3f63a3bddde4
body_hash: 400f5a75900a8a04
raw_hash: 5b856ae56acd38c6
vouched: 2026-10-22T21:30:38Z
body_hash: f49f2a7319f14703
raw_hash: 8fcaafdae8a2bc74
vouched: 2026-10-22T22:15:45Z
lines: 253-482
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function main
created: 2026-10-22T14:31:16Z
norm: '1'
sig: 7d5d3f63a3bddde4
body_hash: 400f5a75900a8a04
raw_hash: 5b856ae56acd38c6
vouched: 2026-10-22T21:30:38Z
body_hash: f49f2a7319f14703
raw_hash: 8fcaafdae8a2bc74
vouched: 2026-10-22T22:15:45Z
lines: 332-614
---

Expand Down
38 changes: 32 additions & 6 deletions apps/host/src/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
rmSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";

import { openNodeSqliteDatabase } from "@earendil-works/pi-durable/storage/sqlite/node";
Expand Down Expand Up @@ -79,7 +79,18 @@ export async function backup(record: catalog.Listing, directory: string): Promis
const hosted = record.hosted ? await exported(record) : undefined;
const path = resolve(directory);
// An existing destination must never be overwritten or removed by failed-backup cleanup.
mkdirSync(path, { mode: 0o700 });
try {
mkdirSync(path, { mode: 0o700 });
} catch (error) {
const { code } = error as NodeJS.ErrnoException;
if (code === "EEXIST") {
throw new Error(`${path} already exists. Back up into a new directory.`, { cause: error });
}
if (code === "ENOENT") {
throw new Error(`${dirname(path)} doesn't exist. Create it first.`, { cause: error });
}
throw error;
}
try {
const source = catalog.paths(record.id);
save(join(path, "channel.json"), record);
Expand Down Expand Up @@ -117,23 +128,38 @@ export async function backup(record: catalog.Listing, directory: string): Promis
* A backup as a new local channel. The new ID and lane prefix keep it from ever running as the
* original, which may still be live here or on its hosting service.
*/
export async function restore(directory: string, dir?: string): Promise<catalog.Listing> {
export async function restore(
directory: string,
options: { dir?: string; name?: string } = {},
): Promise<catalog.Listing> {
const path = resolve(directory);
if (!existsSync(path)) throw new Error(`${path} doesn't exist.`);
if (!existsSync(join(path, "backup.json"))) {
throw new Error(`${path} isn't an Ace backup: it has no backup.json.`);
}
const manifest = JSON.parse(readFileSync(join(path, "backup.json"), "utf8")) as {
format: number;
files: { path: string; sha256: string }[];
};
if (manifest.format !== 1) throw new Error(`Unsupported backup format ${manifest.format}`);
for (const file of manifest.files) {
if (!existsSync(join(path, file.path))) {
throw new Error(`The backup is incomplete: ${file.path} is missing from ${path}.`);
}
if (await checksum(join(path, file.path)) !== file.sha256) {
throw new Error(`${file.path} does not match its checksum in backup.json`);
}
}
const original = JSON.parse(readFileSync(join(path, "channel.json"), "utf8")) as catalog.Listing;
const draft = catalog.draft(
await project(options.dir || original.project),
original.model,
options.name,
);
const record: catalog.Listing = {
...catalog.draft(await project(dir || original.project), original.model),
name: catalog.restored(original.name),
named: original.named !== false,
...draft,
name: options.name ? draft.name : catalog.restored(original.name),
named: !!options.name || original.named !== false,
summary: original.summary,
};
const { base, storage } = catalog.paths(record.id);
Expand Down
7 changes: 4 additions & 3 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ const USAGE = `ace — channels on this host
ace tabs [--port <port>] [--json] list open windows and their current channel's tabs
ace tab rename <window> <channel> <tab> <name> rename one tab; an empty name restores its default
ace backup <channel> <directory> snapshot a local or hosted channel's data into a new directory
ace restore <directory> [--project <dir>] restore a backup as a new local channel
ace restore <directory> [--project <dir>] [--name <name>]
restore a backup as a new local channel
ace export <channel> <file> save a channel's chats as an editable seed file
ace import <file> [--project <dir>] [--name <name>] [--now]
add a seed as a new local channel; --now moves its
Expand Down Expand Up @@ -478,9 +479,9 @@ async function main() {
}
case "restore": {
if (positionals.length !== 2) {
throw new Error("ace restore <backup-directory> [--project <dir>]");
throw new Error("ace restore <backup-directory> [--project <dir>] [--name <name>]");
}
const record = await restore(ref!, flags.project);
const record = await restore(ref!, { dir: flags.project, name: flags.name });
return console.log(`${record.name}\t${record.id}`);
}
case "import": {
Expand Down
1 change: 1 addition & 0 deletions apps/host/src/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export async function defaultModel(): Promise<ModelRef> {
/** The Git top level containing `dir`, which needs a commit for lanes to start from. */
export async function project(dir: string): Promise<string> {
dir = resolve(dir === "~" ? homedir() : dir.startsWith("~/") ? homedir() + dir.slice(1) : dir);
if (!existsSync(dir)) throw new Error(`${dir} doesn't exist.`);
const result = await git(dir, ["rev-parse", "--show-toplevel"]);
if (result.status === null) throw new Error("Git is unavailable. Check Settings → Ace Helper.");
if (result.status !== 0) {
Expand Down
12 changes: 6 additions & 6 deletions docs/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,12 @@ keeps the original working-directory paths; it is not a way to move lanes to ano

## Restore as a new channel

`ace restore <backup-directory> [--project <dir>]` verifies every file against `backup.json`, then
adds the backup as a new local channel on this host, in the recorded project or `--project`. A
hosted snapshot is imported and checked against its digest; a local `channel.sqlite` is copied. The
copy has a new ID and a new lane branch prefix, so it can never run as the original channel, which
may still be live here or on its service. Its pi metadata keeps the original name, so refer to it by
ID while both exist. The backup itself is left unchanged, and a failed restore removes only the new
`ace restore <backup-directory> [--project <dir>] [--name <name>]` verifies every file against
`backup.json`, then adds the backup as a new local channel on this host, in the recorded project or
`--project`. A hosted snapshot is imported and checked against its digest; a local `channel.sqlite`
is copied. The copy has a new ID and a new lane branch prefix, so it can never run as the original
channel, which may still be live here or on its service. It takes `--name`, or the original's name
with `-1`, `-2`, … added while that name is taken. The backup itself is left unchanged, and a failed restore removes only the new
channel's directory.

The restored chats keep the original working-directory and lane paths. If the backup was taken
Expand Down