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
@@ -0,0 +1,12 @@
---
id: 01M5AZ6NV8GAWASJKAVZPAMNM7
anchor: function local
created: 2026-10-19T20:56:13Z
norm: '1'
sig: 2bb79c92d28ae467
body_hash: fa355c5e44da2b1a
raw_hash: c5e60940ca9e03a5
lines: 92-145
---

a move's fence names "local", "compute", or a service URL; only a URL may be parsed as one
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function targets
created: 2026-10-16T18:56:03Z
norm: '1'
sig: 002f9c457b031ab4
body_hash: 85d4201c6260eec4
raw_hash: 8782a699c3cff424
vouched: 2026-10-19T17:59:13Z
body_hash: bab5cddb9dfa6ae8
raw_hash: 73b7e221730eeb13
vouched: 2026-10-19T20:56:20Z
lines: 64-79
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5AZ6NMTVNC1WH65TAV3JB3W
anchor: function toCompute
created: 2026-10-19T20:56:11Z
norm: '1'
sig: 21af9e438a769213
body_hash: 8574c71308234fbf
raw_hash: beeaf7b2694ba561
lines: 260-326
---

a hosted source is frozen and exported at its service, never read from the parked local store, and any failure before activation thaws it
7 changes: 6 additions & 1 deletion apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,12 @@ async function local(): Promise<Listing[]> {
const transition = computing.get(id);
if (transition) listing.computing = transition;
if (record.moving && moves.has(id)) {
listing.moving = record.moving.to === "local" ? name : new URL(record.moving.to).host;
const { to } = record.moving;
listing.moving = to === "local"
? name
: to === "compute"
? "cloud workspace"
: new URL(to).host;
}
return listing;
});
Expand Down
73 changes: 48 additions & 25 deletions apps/host/src/move.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Moving an idle channel between this host and a hosting service, or from this host to a cloud
* Moving an idle channel between this host and a hosting service, or from either to a cloud
* workspace. The channel keeps its ID and lanes; its pi store travels as one verified snapshot. A `moving` fence on the record keeps
* both copies from running while the move is under way, and an unknown activation keeps it.
*/
Expand Down Expand Up @@ -61,9 +61,11 @@ function movable(record: catalog.Listing): void {
if (record.busy) throw new Error("The channel is busy; stop its work before moving it");
}

/** The target that moves a local channel to a cloud workspace on the team's compute service. */
/** The target that moves a channel to a cloud workspace on the team's compute service. */
const COMPUTE = "compute";

const WORKSPACE: MoveTarget = { target: COMPUTE, label: "cloud workspace", place: "cloud" };

/** `host` is this host's name as the app shows it, its tailnet machine name when it has one. */
export async function targets(id: string, host: string): Promise<MoveTarget[]> {
const record = catalog.read(id);
Expand All @@ -73,7 +75,8 @@ export async function targets(id: string, host: string): Promise<MoveTarget[]> {
return [];
}
if (record.hosted) {
return [{ target: "local", label: `this host (${host})`, place: "local" }];
const local: MoveTarget = { target: "local", label: `this host (${host})`, place: "local" };
return await available(record.project) ? [local, WORKSPACE] : [local];
}
const team = directory.read().hosted;
const hosted = new Set([
Expand All @@ -87,7 +90,7 @@ export async function targets(id: string, host: string): Promise<MoveTarget[]> {
place: "hosted",
}));
if (!(await available(record.project))) return services;
return [...services, { target: COMPUTE, label: "cloud workspace", place: "cloud" }];
return [...services, WORKSPACE];
}

/** A transfer whose answer never arrived may still have taken effect at the service. */
Expand Down Expand Up @@ -194,6 +197,22 @@ async function readLocal(id: string): Promise<Snapshot> {
}
}

/** A refused or unanswered freeze may still have frozen the service, so its caller thaws on failure. */
async function exportHosted(auth: Auth, url: string, id: string): Promise<Snapshot> {
const { digest } = await transfer<{ digest: string }>(auth, url, id, { action: "freeze" });
const snapshot = await transfer<Snapshot>(auth, url, id, { action: "export" });
if (snapshot.digest !== digest) {
throw new Error("The exported snapshot does not match the frozen channel");
}
return snapshot;
}

async function thaw(auth: Auth, url: string, id: string): Promise<void> {
await transfer(auth, url, id, { action: "thaw" }).catch((cause: Error) =>
log("warn", "move.thaw.failed", { channel: id, error: cause.message })
);
}

async function toHosted(auth: Auth, record: catalog.Listing, url: string): Promise<void> {
const { id } = record;
let digest = record.moving?.digest;
Expand Down Expand Up @@ -235,30 +254,41 @@ async function toHosted(auth: Auth, record: catalog.Listing, url: string): Promi

/**
* Like `toHosted`, except the compute service creates the workspace that adopts the channel, and
* the channel stays stopped until its workspace starts.
* the channel stays stopped until its workspace starts. A hosted channel goes straight from its
* service to the compute service, frozen there like a move back to this host.
*/
async function toCompute(
auth: Auth,
record: catalog.Listing,
waiting: (text: string | null) => void,
): Promise<void> {
const { id } = record;
const { url, body } = await adoption(record);
const { id, hosted: source } = record;
const where = source
? `The channel is still on ${new URL(source).host}.`
: "The channel is still on this Mac.";
let digest = record.moving?.digest;
// A resumed move froze its source on the first attempt; thawing an active one is refused.
let freezing = !!digest;
const lift = async () => {
if (source && freezing) await thaw(auth, source, id);
catalog.write({ ...catalog.read(id), moving: undefined });
catalog.unlock(id);
};
const { url, body } = await adoption(record);
if (!digest) {
catalog.write({ ...record, moving: { to: COMPUTE } });
try {
await park(id);
const snapshot = await readLocal(id);
freezing = true;
const snapshot = source ? await exportHosted(auth, source, id) : await readLocal(id);
if (Buffer.byteLength(JSON.stringify(snapshot)) > MAX_SNAPSHOT) {
throw new Error("The channel is too large to move yet");
}
await post(auth, `${url}/workspaces/${id}/import`, { ...body, snapshot });
digest = snapshot.digest;
} catch (error) {
catalog.write({ ...catalog.read(id), moving: undefined });
catalog.unlock(id);
throw stranded(error, "The channel is still on this Mac.");
await lift();
throw stranded(error, where);
}
catalog.write({ ...catalog.read(id), moving: { to: COMPUTE, digest } });
}
Expand All @@ -271,9 +301,8 @@ async function toCompute(
"The channel may now be active there, so this copy stays fenced; run the same move again to finish it.",
);
}
catalog.write({ ...catalog.read(id), moving: undefined });
catalog.unlock(id);
throw stranded(error, "The channel is still on this Mac.");
await lift();
throw stranded(error, where);
}
catalog.write({
...catalog.read(id),
Expand Down Expand Up @@ -307,11 +336,7 @@ async function toLocal(auth: Auth, record: catalog.Listing): Promise<void> {
try {
await park(id);
freezing = true;
const { digest } = await transfer<{ digest: string }>(auth, url, id, { action: "freeze" });
const snapshot = await transfer<Snapshot>(auth, url, id, { action: "export" });
if (snapshot.digest !== digest) {
throw new Error("The exported snapshot does not match the frozen channel");
}
const snapshot = await exportHosted(auth, url, id);
for (const path of [incoming, `${incoming}-wal`, `${incoming}-shm`]) {
rmSync(path, { force: true });
}
Expand All @@ -325,11 +350,7 @@ async function toLocal(auth: Auth, record: catalog.Listing): Promise<void> {
for (const path of [`${storage}-wal`, `${storage}-shm`]) rmSync(path, { force: true });
renameSync(incoming, storage);
} catch (error) {
if (freezing) {
await transfer(auth, url, id, { action: "thaw" }).catch((cause: Error) =>
log("warn", "move.thaw.failed", { channel: id, error: cause.message })
);
}
if (freezing) await thaw(auth, url, id);
catalog.write({ ...catalog.read(id), moving: undefined });
catalog.unlock(id);
throw stranded(error, `The channel is still on ${new URL(url).host}.`);
Expand All @@ -344,7 +365,9 @@ function ready(id: string, to: string): catalog.Listing {
// Only an outbound move whose activation went unanswered resumes; anything else starts fresh.
if (!(record.moving?.digest && record.moving.to === to)) movable(record);
if (to === "local" && !record.hosted) throw new Error("The channel is already on this host");
if (to !== "local" && record.hosted) throw new Error("Move the channel to this host first");
if (to !== "local" && to !== COMPUTE && record.hosted) {
throw new Error("Move the channel to this host first");
}
return record;
}

Expand Down
5 changes: 4 additions & 1 deletion apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ export type Listing = {
computing?: "starting" | "stopping";
/** The cloud compute workspace is stopped; the chat stays readable. Absent on older hosts. */
stopped?: true;
/** Where a move under way is taking the channel: a hosting service's host, or this host's name. */
/**
* Where a move under way is taking the channel: a hosting service's host, "cloud workspace", or
* this host's name.
*/
moving?: string;
/** Its peer host stopped answering while relaying a request or watch here; absent on older hosts. */
stalled?: boolean;
Expand Down
19 changes: 11 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,14 +567,17 @@ atomically replaces the parked one; the frozen object remains. A later move out
A failed step before activation lifts the fence. An unanswered activation keeps it, and repeating
the same move retries activation. Watches on the old placement end and clients reattach.

When Cloud compute is available for the project, a local channel's menu also offers Move to cloud
workspace (`ace move <channel> --compute`). Its workspace starts from a fresh clone, so the host first
refuses any of the channel's lanes that have uncommitted or unpushed work. It then imports the
snapshot through the compute service's `import`, which stores the channel inactive with this host's
project path and the lanes' branches, activates it, saves the record as a stopped `compute` channel,
and starts it. The runner links that project path to its clone and checks each lane's branch out
at its old path, so the channel's records keep naming real paths. A start failure after activation
leaves the channel moved and stopped, for Start compute to retry.
When Cloud compute is available for the project, a local channel's menu, or a hosted channel's
whose workspace is this host, also offers Move to cloud workspace (`ace move <channel> --compute`).
Its workspace starts from a fresh clone, so the host first refuses any of the channel's lanes that
have uncommitted or unpushed work. It then imports the snapshot through the compute service's
`import`, which stores the channel inactive with this host's project path and the lanes' branches,
activates it, saves the record as a stopped `compute` channel, and starts it. A local channel's
snapshot comes from its parked store. A hosted channel's comes straight from its service, frozen and
exported as in a move back, never through local storage; a failure before activation thaws it, and
after activation the frozen object remains. The runner links that project path to its clone and
checks each lane's branch out at its old path, so the channel's records keep naming real paths. A
start failure after activation leaves the channel moved and stopped, for Start compute to retry.

## Shared services

Expand Down