diff --git a/.lane/memory/apps/host/src/catalog.ts/01M52ZT4QFXW6G0AFQFZNG86B1-channel-names-stay-unique-ac.md b/.lane/memory/apps/host/src/catalog.ts/01M52ZT4QFXW6G0AFQFZNG86B1-channel-names-stay-unique-ac.md index 1fe3eab1..69672aff 100644 --- a/.lane/memory/apps/host/src/catalog.ts/01M52ZT4QFXW6G0AFQFZNG86B1-channel-names-stay-unique-ac.md +++ b/.lane/memory/apps/host/src/catalog.ts/01M52ZT4QFXW6G0AFQFZNG86B1-channel-names-stay-unique-ac.md @@ -4,9 +4,9 @@ anchor: function draft created: 2026-10-16T18:32:55Z norm: '1' sig: 9775c8890db42443 -body_hash: 109c60b5db4c436b -raw_hash: 1963b18ff2faefeb -vouched: 2026-10-16T22:11:39Z +body_hash: 715d3c6a84062b19 +raw_hash: 4ac416bc7ff419f2 +vouched: 2026-10-21T21:54:51Z lines: 133-155 --- diff --git a/apps/app/src/directory-settings.tsx b/apps/app/src/directory-settings.tsx index 0b3aae6d..49f297c6 100644 --- a/apps/app/src/directory-settings.tsx +++ b/apps/app/src/directory-settings.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Button, Input } from "@ace/ui"; import type { Diagnostics } from "@ace/host/protocol"; +import { desktop } from "./desktop"; import { host } from "./host"; import { useDiagnostics } from "./host-settings"; import { Confirm, Group, Page, Problem, Row, Status, Value } from "./settings-layout"; @@ -26,6 +27,20 @@ const tailnet = { export function TeamSettings({ active }: { active: boolean }) { const checks = useDiagnostics(active); const checked = checks.value; + const [restart, setRestart] = useState<{ busy?: boolean; error?: string }>({}); + + async function restartHelper() { + if (!desktop) return; + setRestart({ busy: true }); + try { + await desktop.helper("restart"); + setRestart({}); + } catch (error) { + setRestart({ error: (error as Error).message }); + } + checks.refresh(); + } + return ( {checked.tailscale.error}} + footer={ + <> + {checked.tailscale.error && {checked.tailscale.error}} + {restart.busy && Restarting Ace Helper…} + {restart.error && {restart.error}} + + } > - {tailnet[checked.tailscale.state]} + + {checked.tailscale.restart ? "Restart needed" : tailnet[checked.tailscale.state]} + + {checked.tailscale.restart && desktop && ( + + )} checks.refresh()} /> diff --git a/apps/host/src/catalog.ts b/apps/host/src/catalog.ts index 80095266..1013d172 100644 --- a/apps/host/src/catalog.ts +++ b/apps/host/src/catalog.ts @@ -189,7 +189,15 @@ export function fork(source: Listing, model: ModelRef | undefined, branches: Set /** A new channel's record, not yet saved: a hosted channel saves it once the service has it. */ export function draft(project: string, model?: ModelRef, name?: string, hosted?: string): Listing { - if (!tailnet) throw new Error("Start Tailscale first: every channel belongs to a tailnet."); + if (!tailnet) { + // The tailnet is read once at startup, so Tailscale started later needs a restart. + const host = config.helper ? "Ace Helper" : "ace serve"; + throw new Error( + identity().tailnet + ? `Restart ${host}: it started before Tailscale was running.` + : "Start Tailscale first: every channel belongs to a tailnet.", + ); + } const names = taken(); const random = () => `${pick(WORDS[0])}-${pick(WORDS[1])}`; let chosen = name ? validateName(name) : random(); diff --git a/apps/host/src/diagnostics.ts b/apps/host/src/diagnostics.ts index c29087e0..e3d0b0e1 100644 --- a/apps/host/src/diagnostics.ts +++ b/apps/host/src/diagnostics.ts @@ -1,6 +1,7 @@ import { constants } from "node:fs"; import { access } from "node:fs/promises"; +import { tailnet as started } from "./catalog"; import { config } from "./config"; import * as directory from "./directory"; import { shell as environment } from "./environment"; @@ -47,7 +48,7 @@ export async function diagnostics(sync?: boolean): Promise { } const [tools, tailscale] = await Promise.all([ Promise.all([git(), shell()]), - tailnet.diagnostics(), + tailnet.diagnostics(started), ]); return { tools, diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index c19dab6b..dcb63f91 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -46,6 +46,11 @@ export type Diagnostics = { error?: string; /** Served with ace serve --loopback: no tailnet listener, peers, or directory entry. */ loopback?: boolean; + /** + * Tailscale runs on a tailnet the host did not start on, often none: the host reads its tailnet + * once, so it needs a restart before it can create or show that tailnet's channels. + */ + restart?: boolean; }; /** * `error` is the last directory sync's failure and `failed` when it happened; a successful sync diff --git a/apps/host/src/tailnet.ts b/apps/host/src/tailnet.ts index 83c11353..15b8f862 100644 --- a/apps/host/src/tailnet.ts +++ b/apps/host/src/tailnet.ts @@ -91,12 +91,16 @@ export async function domain(): Promise { return (await status())?.MagicDNSSuffix; } -export async function diagnostics(): Promise { +/** `started` is the tailnet the host read at startup, which its channels and listeners still use. */ +export async function diagnostics(started?: string): Promise { if (!binary) return { state: "missing" }; try { const value = await run(["status", "--json"]) as Status; if (value.BackendState !== "Running") return { state: "offline" }; - return { state: "running", name: machine(value.Self, value).name }; + const name = machine(value.Self, value).name; + return value.MagicDNSSuffix === started + ? { state: "running", name } + : { state: "running", name, restart: true }; } catch (error) { return { state: "error", error: (error as Error).message }; }