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..89f9f69c --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md @@ -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 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..5bf1362e --- /dev/null +++ b/.lane/memory/apps/host/src/move.ts/01M5AZ6NMTVNC1WH65TAV3JB3W-a-hosted-source-is-frozen-an.md @@ -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 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