Skip to content

Commit bbdc04d

Browse files
authored
feat(host): back up hosted and compute channels and restore backups as new channels (#435)
* feat(host): back up hosted and compute channels and restore backups as new channels `ace backup` now takes a hosted or cloud compute channel's pi state from its service with a new `backup` transfer action, which reads one consistent snapshot without freezing the channel. `ace restore` verifies a backup and adds it as a new local channel with a new ID and lane prefix. Why: packages/channel/src/transfer.ts#function backupSnapshot | the read stays one transaction on the channel's DurableSqlite, which serializes every pi read and write, so a backup needs no freeze Why: apps/host/src/backup.ts#function restore | a restored copy always gets a new ID and lane prefix so it can never run as the original channel Why: apps/host/src/backup.ts#function exported | the service is asked before the destination exists, so a refusal or an older service leaves nothing behind * chore: confirm and record lane notes for hosted backups * lane: sync hosted-backup memory
1 parent 7add838 commit bbdc04d

11 files changed

Lines changed: 241 additions & 41 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
id: 01M4ZYZJRBTSD6WJVT8HEH8XQ0
3+
anchor: function restore
4+
created: 2026-10-15T14:20:42Z
5+
norm: '1'
6+
sig: 05c78f711a9a9228
7+
body_hash: 61b995cf9ff2906b
8+
raw_hash: a42fcbbc15619cac
9+
lines: 119-158
10+
---
11+
12+
a restored copy always gets a new ID and lane prefix so it can never run as the original channel
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
id: 01M4ZYZJRPVAT5B4AKS3TAM4N1
3+
anchor: function exported
4+
created: 2026-10-15T14:20:42Z
5+
norm: '1'
6+
sig: b4af02e0f6ffc6ee
7+
body_hash: ed863c361f4c6e35
8+
raw_hash: e6c5fea64fdd5bb4
9+
lines: 52-69
10+
---
11+
12+
the service is asked before the destination exists, so a refusal or an older service leaves nothing behind

‎.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ anchor: function main
44
created: 2026-10-15T04:47:23Z
55
norm: '1'
66
sig: 7d5d3f63a3bddde4
7-
body_hash: 6fa49f3beb6a0972
8-
raw_hash: e2afff512c7cdab5
9-
vouched: 2026-10-15T06:07:50Z
7+
body_hash: 415212bf021919a0
8+
raw_hash: c683df669514ae50
9+
vouched: 2026-10-15T14:20:47Z
1010
lines: 253-482
1111
---
1212

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
id: 01M4ZYZJR07D3AKHPK6ZHZF7HB
3+
anchor: function backupSnapshot
4+
created: 2026-10-15T14:20:42Z
5+
norm: '1'
6+
sig: 6fc0ddf258dd3aea
7+
body_hash: 65320d8ff51c68e2
8+
raw_hash: 44f3f3a44af33ee7
9+
lines: 233-236
10+
---
11+
12+
the read stays one transaction on the channel's DurableSqlite, which serializes every pi read and write, so a backup needs no freeze

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ bun install
7979
lane install hooks # once per clone; captures `Why: <path>[#<anchor>] | <text>` commit trailers as notes
8080
bun ace --help # CLI
8181
bun ace tabs --json # discover open windows and tabs before targeting a tab rename
82-
bun ace backup <channel> <new-directory> # local channel data, including committed WAL entries
82+
bun ace backup <channel> <new-directory> # local or hosted channel data; `ace restore` adds it as a new channel
8383
bun types # type checks
8484
bun run ci # dprint + oxlint
8585
bun run fix # apply formatting and lint fixes

‎apps/host/src/backup.ts‎

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
import { createHash } from "node:crypto";
2-
import { chmodSync, createReadStream, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2+
import {
3+
chmodSync,
4+
copyFileSync,
5+
createReadStream,
6+
existsSync,
7+
mkdirSync,
8+
readFileSync,
9+
rmSync,
10+
writeFileSync,
11+
} from "node:fs";
312
import { join, resolve } from "node:path";
413
import { DatabaseSync } from "node:sqlite";
514

15+
import { openNodeSqliteDatabase } from "@earendil-works/pi-durable/storage/sqlite/node";
16+
17+
import { importSnapshot, type Snapshot } from "@ace/channel/transfer";
18+
619
import * as catalog from "./catalog";
20+
import { hostedAuth } from "./client";
21+
import { refusal } from "./http";
22+
import { project } from "./manage";
723

824
async function checksum(path: string): Promise<string> {
925
const hash = createHash("sha256");
@@ -32,20 +48,45 @@ function snapshot(source: string, target: string): void {
3248
}
3349
}
3450

51+
/** The hosting service's consistent export of a hosted channel's pi state, taken without freezing it. */
52+
async function exported(record: catalog.Listing): Promise<Snapshot> {
53+
const url = record.hosted!;
54+
let response: Response;
55+
try {
56+
response = await fetch(`${url}/channels/${record.id}/transfer`, {
57+
method: "POST",
58+
headers: { ...(await hostedAuth()), "content-type": "application/json" },
59+
body: JSON.stringify({ action: "backup" }),
60+
});
61+
} catch (error) {
62+
throw new Error(`Could not reach ${url}: ${(error as Error).message}.`, { cause: error });
63+
}
64+
if (response.ok) return await response.json() as Snapshot;
65+
if (response.status === 400 && (await response.clone().text()).includes("Unknown transfer")) {
66+
throw new Error(`${url} predates hosted channel backups; redeploy it to back this channel up.`);
67+
}
68+
throw new Error(await refusal(response, "The hosting service"));
69+
}
70+
71+
function save(path: string, value: unknown): void {
72+
writeFileSync(path, `${JSON.stringify(value, null, "\t")}\n`, { mode: 0o600, flag: "wx" });
73+
}
74+
3575
/** A channel storage snapshot; project files and lane worktrees need their own backup. */
3676
export async function backup(record: catalog.Listing, directory: string): Promise<string> {
37-
if (record.hosted) throw new Error("Hosted channel backups must be taken on the hosting service");
77+
// Read before creating the destination, so a refused or unreachable service leaves nothing behind.
78+
const hosted = record.hosted ? await exported(record) : undefined;
3879
const path = resolve(directory);
3980
// An existing destination must never be overwritten or removed by failed-backup cleanup.
4081
mkdirSync(path, { mode: 0o700 });
4182
try {
4283
const source = catalog.paths(record.id);
43-
writeFileSync(join(path, "channel.json"), `${JSON.stringify(record, null, "\t")}\n`, {
44-
mode: 0o600,
45-
flag: "wx",
46-
});
84+
save(join(path, "channel.json"), record);
4785
const names = ["channel.json"];
48-
if (existsSync(source.storage)) {
86+
if (hosted) {
87+
save(join(path, "snapshot.json"), hosted);
88+
names.push("snapshot.json");
89+
} else if (existsSync(source.storage)) {
4990
snapshot(source.storage, join(path, "channel.sqlite"));
5091
names.push("channel.sqlite");
5192
}
@@ -59,17 +100,59 @@ export async function backup(record: catalog.Listing, directory: string): Promis
59100
channel: record.id,
60101
home: catalog.home,
61102
project: record.project,
62-
lanes: source.lanes,
103+
...(hosted ? { hosted: record.hosted, digest: hosted.digest } : { lanes: source.lanes }),
63104
files,
64105
};
65106
// Written last: a partial directory is never advertised as a completed backup.
66-
writeFileSync(join(path, "backup.json"), `${JSON.stringify(manifest, null, "\t")}\n`, {
67-
mode: 0o600,
68-
flag: "wx",
69-
});
107+
save(join(path, "backup.json"), manifest);
70108
return path;
71109
} catch (error) {
72110
rmSync(path, { recursive: true, force: true });
73111
throw error;
74112
}
75113
}
114+
115+
/**
116+
* A backup as a new local channel. The new ID and lane prefix keep it from ever running as the
117+
* original, which may still be live here or on its hosting service.
118+
*/
119+
export async function restore(directory: string, dir?: string): Promise<catalog.Listing> {
120+
const path = resolve(directory);
121+
const manifest = JSON.parse(readFileSync(join(path, "backup.json"), "utf8")) as {
122+
format: number;
123+
files: { path: string; sha256: string }[];
124+
};
125+
if (manifest.format !== 1) throw new Error(`Unsupported backup format ${manifest.format}`);
126+
for (const file of manifest.files) {
127+
if (await checksum(join(path, file.path)) !== file.sha256) {
128+
throw new Error(`${file.path} does not match its checksum in backup.json`);
129+
}
130+
}
131+
const original = JSON.parse(readFileSync(join(path, "channel.json"), "utf8")) as catalog.Listing;
132+
const record: catalog.Listing = {
133+
...catalog.draft(await project(dir || original.project), original.model),
134+
name: original.name,
135+
named: original.named !== false,
136+
summary: original.summary,
137+
};
138+
const { base, storage } = catalog.paths(record.id);
139+
mkdirSync(base, { recursive: true });
140+
try {
141+
const names = new Set(manifest.files.map((file) => file.path));
142+
if (names.has("snapshot.json")) {
143+
const db = await openNodeSqliteDatabase(storage);
144+
try {
145+
await importSnapshot(db, JSON.parse(readFileSync(join(path, "snapshot.json"), "utf8")));
146+
} finally {
147+
await db.close();
148+
}
149+
} else if (names.has("channel.sqlite")) {
150+
copyFileSync(join(path, "channel.sqlite"), storage);
151+
}
152+
// Saved last: the catalog lists the channel only once its store is complete.
153+
return catalog.save(record);
154+
} catch (error) {
155+
rmSync(base, { recursive: true, force: true });
156+
throw error;
157+
}
158+
}

‎apps/host/src/cli.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { parseArgs } from "node:util";
55
import type { ChannelInfo, ChatId, Event, Request } from "@ace/channel/protocol";
66

77
import { appUrl, token } from "./auth";
8-
import { backup } from "./backup";
8+
import { backup, restore } from "./backup";
99
import * as catalog from "./catalog";
1010
import { Connection, request } from "./client";
1111
import { config } from "./config";
@@ -32,7 +32,8 @@ const HELP = `ace — channels on this host
3232
ace rename <channel> <name> rename without changing its lanes
3333
ace tabs [--port <port>] [--json] list open windows and their current channel's tabs
3434
ace tab rename <window> <channel> <tab> <name> rename one tab; an empty name restores its default
35-
ace backup <channel> <directory> snapshot local channel data into a new directory
35+
ace backup <channel> <directory> snapshot a local or hosted channel's data into a new directory
36+
ace restore <directory> [--project <dir>] restore a backup as a new local channel
3637
ace say <channel> [--chat <id>] <text…> post without invoking the agent
3738
ace ask <channel> [--chat <id>] [--model <provider/id>] [--detach] <text…>
3839
ace watch <channel> [--chat <id>]
@@ -332,6 +333,13 @@ async function main() {
332333
}
333334
return;
334335
}
336+
case "restore": {
337+
if (positionals.length !== 2) {
338+
throw new Error("ace restore <backup-directory> [--project <dir>]");
339+
}
340+
const record = await restore(ref!, flags.project);
341+
return console.log(`${record.name}\t${record.id}`);
342+
}
335343
case "models": {
336344
for (const m of await models()) console.log(`${m.provider}/${m.modelId}`);
337345
return;

‎docs/architecture.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,9 @@ Reachable peers forward the live listing; directory-only fallback listings clear
189189
animating from a stale cache, even for hosted channels.
190190

191191
Channel data survives app replacement and upgrades. `ace backup` takes a verified SQLite snapshot
192-
of a local channel while it runs, alongside its catalog record. Project files and lane worktrees
193-
have their own repository backup. Stored format changes require migrations; see [data](data.md).
192+
of a local channel while it runs, or a hosted or compute channel's transfer snapshot from its
193+
service without freezing it, alongside its catalog record. `ace restore` adds a backup as a new
194+
local channel with a new ID. Project files and lane worktrees have their own repository backup. Stored format changes require migrations; see [data](data.md).
194195

195196
Opening a folder adds a project to the host's `projects.json`, independently of channel creation
196197
or model credentials. This is a list of local folders, not channel history. Channels start only in

‎docs/data.md‎

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,31 +27,64 @@ started before Ace used it. SQLite may have
2727
committed data in `channel.sqlite-wal`, so copying only a live `channel.sqlite` is not a backup.
2828
The socket, worker PID, and logs are process state, not durable channel content.
2929

30-
## Back up a local channel
30+
## Back up a channel
3131

3232
Run `bun ace backup <channel> <new-directory>` from the checkout, or `ace backup` (`ace-canary backup`
33-
for Canary) from the command the app installs. The destination's parent must exist; the destination itself must be new. Backups include
34-
archived channels and work without starting dormant workers or loading provider credentials.
33+
for Canary) from the command the app installs. It works for local, hosted, and cloud compute
34+
channels this host owns. The destination's parent must exist; the destination itself must be new.
35+
Backups include archived channels and work without freezing the channel or stopping its work. Failed backups remove only their newly created
36+
output, and the directory and files are private to the current OS user. Store backups with the
37+
same care as the original chats and tool output.
3538

36-
The command uses SQLite's [VACUUM INTO](https://www.sqlite.org/lang_vacuum.html#vacuum_with_an_into_clause)
37-
to capture a consistent snapshot, including committed WAL entries, while a worker continues
38-
running. It opens the source read-only, checks the output's integrity, and writes:
39+
Every backup writes:
3940

40-
- `channel.json`: the channel's identity, owner, project, optional initial model, and when its
41-
transcript last grew.
42-
- `channel.sqlite`: all pi state at the snapshot point, if the channel has been opened before.
41+
- `channel.json`: the channel's catalog record: identity, owner, project, optional initial model,
42+
when its transcript last grew, and for a hosted channel its service URL.
43+
- The channel's pi state at the snapshot point, if it has any (below).
4344
- `backup.json`: format version, source paths, creation time, and SHA-256 checksums. This file is
44-
written last to mark a complete backup. Failed backups remove only their newly created output.
45-
46-
The directory and files are private to the current OS user. Store backups with the same care as
47-
the original chats and tool output. Hosted channels need backups from their hosting service;
48-
their local catalog entry is insufficient.
45+
written last to mark a complete backup.
46+
47+
**Local channels.** The command uses SQLite's
48+
[VACUUM INTO](https://www.sqlite.org/lang_vacuum.html#vacuum_with_an_into_clause) to capture a
49+
consistent snapshot, including committed WAL entries, while a worker continues running. It opens
50+
the source read-only, checks the output's integrity, and writes `channel.sqlite`. It needs no
51+
provider credentials and does not start dormant workers.
52+
53+
**Hosted and cloud compute channels.** The host asks the hosting service for the channel's pi
54+
state with `POST /channels/:id/transfer` `{ "action": "backup" }`, authenticated with the team's
55+
`ACE_SECRET`, and writes the answer as `snapshot.json`. This is the same version 1 snapshot format
56+
moves use: every pi table, with a SHA-256 digest of its canonical encoding, which `backup.json`
57+
also records. The service reads it in one transaction on the channel's Durable Object, where all
58+
pi reads and writes run one at a time, so the snapshot is consistent without freezing the channel;
59+
clients stay connected and runs continue. A snapshot is limited to 32 MiB, and a larger channel is
60+
refused with that reason. A service deployed before this action existed is reported as needing a
61+
redeploy. Inactive copies left by an unfinished move are refused, since they are not the channel.
62+
63+
A backup taken during a run records the run as unfinished, as a local snapshot does.
4964

5065
Project files, lane worktrees (including uncommitted and untracked changes), repository Git data,
51-
opened-project preferences, and Keychain items need their own backups. A channel snapshot keeps
52-
the original working-directory paths; it is not a way to move lanes to another machine.
66+
opened-project preferences, and Keychain items need their own backups. A cloud compute channel's
67+
workspace files are in its container and are covered by its
68+
[checkpoint](https://github.com/githubnext/ace/issues/413), not by `ace backup`. A channel snapshot
69+
keeps the original working-directory paths; it is not a way to move lanes to another machine.
70+
71+
## Restore as a new channel
72+
73+
`ace restore <backup-directory> [--project <dir>]` verifies every file against `backup.json`, then
74+
adds the backup as a new local channel on this host, in the recorded project or `--project`. A
75+
hosted snapshot is imported and checked against its digest; a local `channel.sqlite` is copied. The
76+
copy has a new ID and a new lane branch prefix, so it can never run as the original channel, which
77+
may still be live here or on its service. Its pi metadata keeps the original name, so refer to it by
78+
ID while both exist. The backup itself is left unchanged, and a failed restore removes only the new
79+
channel's directory.
5380

54-
## Recover on the same machine
81+
The restored chats keep the original working-directory and lane paths. If the backup was taken
82+
during a run, the copy resumes that run when it first opens, and pi reports interrupted tools that
83+
cannot safely be rerun. Stop the original channel's work first if both would use the same lanes.
84+
85+
## Recover in place on the same machine
86+
87+
To return a local channel to a snapshot under its original ID:
5588

5689
1. Keep the backup untouched and verify its files against the SHA-256 values in `backup.json`.
5790
2. Stop Ace Helper in Settings and stop any command-line host for this profile. Ensure the
@@ -66,7 +99,8 @@ the original working-directory paths; it is not a way to move lanes to another m
6699
pi reports interrupted tools that cannot safely be rerun. Do not run a second copy of the same
67100
snapshot against the same lane paths.
68101

69-
Automated restore and moving backups across machines are not implemented.
102+
A hosted channel is not replaced in place; restore its backup as a new channel. Moving backups
103+
across machines is not implemented.
70104

71105
## Upgrade contract
72106

@@ -144,3 +178,14 @@ build they reopened shared and unshared respectively, and the unshared channel's
144178
revisions matched its backup. Turning desktop tools off on the channel without stored settings
145179
kept it shared across a worker restart. A new channel stored `shared: false` at creation, refused a
146180
teammate's agent invocation, and kept the setting across a restart.
181+
182+
The October 15, 2026 hosted backup check ran local `wrangler dev` copies of `services/channel` and
183+
`services/compute` (containers off) with a loopback source host. A hosted channel with this host's
184+
workspace and a compute channel each held messages and real `openai/gpt-5-mini` runs, the hosted
185+
one including a `bash` tool result. `ace backup` saved both while they were active; a third backup
186+
taken mid-run kept the run's tasks as `waiting` and `running` while the live run finished and a
187+
message posted during it was admitted. `ace restore` imported each as a new local channel whose
188+
pi tables hashed to the backup digests. The restored copies replayed the same history and answered
189+
from it with a real model, and the mid-run copy reported the interrupted tool and reran it. The live
190+
channels kept answering afterwards, and a thaw request found both active, not frozen. With the
191+
previous service build, `ace backup` reported that the service needs a redeploy and left no output.

‎packages/channel/src/transfer.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,9 @@ const SEED = JSON.stringify({
9393
durable_schema: [[1, SCHEMA]],
9494
durable_metadata: [[1, "2", 1]],
9595
});
96-
// Durable Objects keep their KV API in `__cf_kv`; SQLite keeps its own `sqlite_*` tables.
97-
const INTERNAL = /^(sqlite_|_cf_|__cf_)/i;
96+
// Durable Objects keep their KV API in `__cf_kv`, local `wrangler dev` adds `__miniflare_do_name`,
97+
// and SQLite keeps its own `sqlite_*` tables.
98+
const INTERNAL = /^(sqlite_|_cf_|__cf_|__miniflare_)/i;
9899

99100
function encode(value: unknown): Cell {
100101
if (value === null || typeof value === "string") return value;
@@ -151,7 +152,11 @@ async function sha256(bytes: Uint8Array): Promise<string> {
151152
async function seal(body: Omit<Snapshot, "digest">): Promise<Snapshot> {
152153
const bytes = new TextEncoder().encode(canonical(body));
153154
if (bytes.byteLength > MAX_SNAPSHOT) {
154-
throw new Error(`Channel snapshot exceeds ${MAX_SNAPSHOT} bytes; moving it is not supported`);
155+
throw new Error(
156+
`The channel is larger than the ${
157+
MAX_SNAPSHOT / 1024 / 1024
158+
} MiB snapshot limit, so it cannot be moved or backed up`,
159+
);
155160
}
156161
return { ...body, digest: await sha256(bytes) };
157162
}
@@ -221,6 +226,15 @@ export async function exportSnapshot(db: SqliteDatabase, channel: string): Promi
221226
});
222227
}
223228

229+
/**
230+
* One consistent read of a channel database, including unfinished work: a backup records the
231+
* channel as it is, and a restored copy resumes that work when it opens.
232+
*/
233+
export async function backupSnapshot(db: SqliteDatabase, channel: string): Promise<Snapshot> {
234+
pinned();
235+
return db.transaction((tx) => read(tx, channel));
236+
}
237+
224238
function validate(snapshot: Snapshot): void {
225239
if (snapshot?.format !== "ace-channel-snapshot" || snapshot.version !== 1) {
226240
throw new Error("Not a version 1 Ace channel snapshot");

0 commit comments

Comments
 (0)