From 0a0a11dc55fb54868c7570d65d22e66dd03e3dee Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Mon, 19 Oct 2026 16:56:11 -0400 Subject: [PATCH 1/3] feat(host): move a hosted channel straight to a cloud workspace A hosted channel whose workspace is this host now offers Move to cloud workspace. The move freezes and exports it at its hosting service, imports it into the compute service, and activates it there, never going through local storage. A failure before activation thaws the source. Listings also stop failing while a channel moves to a cloud workspace, since its fence's target is not a URL. Why: apps/host/src/move.ts#function toCompute | a hosted source is frozen and exported at its service, never read from the parked local store, and any failure before activation thaws it Why: apps/host/src/gateway.ts#function local | a move's fence names "local", "compute", or a service URL; only a URL may be parsed as one --- apps/host/src/gateway.ts | 7 +++- apps/host/src/move.ts | 73 +++++++++++++++++++++++++-------------- apps/host/src/protocol.ts | 5 ++- docs/architecture.md | 19 +++++----- 4 files changed, 69 insertions(+), 35 deletions(-) diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 5a57780f..988db731 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -134,7 +134,12 @@ async function local(): Promise { 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; }); diff --git a/apps/host/src/move.ts b/apps/host/src/move.ts index 8bb618b0..13162ddd 100644 --- a/apps/host/src/move.ts +++ b/apps/host/src/move.ts @@ -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. */ @@ -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 { const record = catalog.read(id); @@ -73,7 +75,8 @@ export async function targets(id: string, host: string): Promise { 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([ @@ -87,7 +90,7 @@ export async function targets(id: string, host: string): Promise { 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. */ @@ -194,6 +197,22 @@ async function readLocal(id: string): Promise { } } +/** 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 { + const { digest } = await transfer<{ digest: string }>(auth, url, id, { action: "freeze" }); + const snapshot = await transfer(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 { + 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 { const { id } = record; let digest = record.moving?.digest; @@ -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 { - 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 } }); } @@ -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), @@ -307,11 +336,7 @@ async function toLocal(auth: Auth, record: catalog.Listing): Promise { try { await park(id); freezing = true; - const { digest } = await transfer<{ digest: string }>(auth, url, id, { action: "freeze" }); - const snapshot = await transfer(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 }); } @@ -325,11 +350,7 @@ async function toLocal(auth: Auth, record: catalog.Listing): Promise { 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}.`); @@ -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; } diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index cff8df37..919cc141 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -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; diff --git a/docs/architecture.md b/docs/architecture.md index cd8b464f..7d36abbe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 --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 --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 From 555b6e06dfa253ce86e4e9194996d85d6009d129 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Mon, 19 Oct 2026 16:56:28 -0400 Subject: [PATCH 2/3] chore: confirm lane notes --- ...AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md | 8 ++++++++ ...314FQ002GKKHTQ5G9Q4NZJ-the-directory-s-hosting-serv.md | 6 +++--- ...AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md | 8 ++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 .lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md create mode 100644 .lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md new file mode 100644 index 00000000..1115f281 --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md @@ -0,0 +1,8 @@ +--- +id: 01M5AZ6NV8GAWASJKAVZPAMNM7 +anchor: function local +created: 2026-10-19T20:56:13Z +norm: '1' +--- + +a move's fence names "local", "compute", or a service URL; only a URL may be parsed as one diff --git a/.lane/memory/apps/host/src/move.ts/01M5314FQ002GKKHTQ5G9Q4NZJ-the-directory-s-hosting-serv.md b/.lane/memory/apps/host/src/move.ts/01M5314FQ002GKKHTQ5G9Q4NZJ-the-directory-s-hosting-serv.md index f5625ef8..cb9eb7f1 100644 --- a/.lane/memory/apps/host/src/move.ts/01M5314FQ002GKKHTQ5G9Q4NZJ-the-directory-s-hosting-serv.md +++ b/.lane/memory/apps/host/src/move.ts/01M5314FQ002GKKHTQ5G9Q4NZJ-the-directory-s-hosting-serv.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md b/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md new file mode 100644 index 00000000..1a52857b --- /dev/null +++ b/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md @@ -0,0 +1,8 @@ +--- +id: 01M5AZ6NMTVNC1WH65TAV3JB3W +anchor: function toCompute +created: 2026-10-19T20:56:11Z +norm: '1' +--- + +a hosted source is frozen and exported at its service, never read from the parked local store, and any failure before activation thaws it From 485750a15a85122822fce9c79a09207ca3400a4a Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Mon, 19 Oct 2026 16:56:31 -0400 Subject: [PATCH 3/3] lane: sync hosted-compute memory --- ...01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md | 4 ++++ ...01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md index 1115f281..89f9f69c 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md @@ -3,6 +3,10 @@ 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 diff --git a/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md b/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md index 1a52857b..5bf1362e 100644 --- a/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md +++ b/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md @@ -3,6 +3,10 @@ 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