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
Expand Up @@ -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
---

Expand Down
38 changes: 36 additions & 2 deletions apps/app/src/directory-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<Page>
<Group
Expand All @@ -46,10 +61,29 @@ export function TeamSettings({ active }: { active: boolean }) {
label="Tailscale"
description={checked.tailscale.loopback
? "Loopback only, so teammates can't reach this host"
: checked.tailscale.restart
? `${checked.tailscale.name}. Ace Helper started before this tailnet was connected; restart it to use the tailnet.`
: checked.tailscale.name}
footer={checked.tailscale.error && <Problem>{checked.tailscale.error}</Problem>}
footer={
<>
{checked.tailscale.error && <Problem>{checked.tailscale.error}</Problem>}
{restart.busy && <Status>Restarting Ace Helper…</Status>}
{restart.error && <Problem>{restart.error}</Problem>}
</>
}
>
<Value>{tailnet[checked.tailscale.state]}</Value>
<Value>
{checked.tailscale.restart ? "Restart needed" : tailnet[checked.tailscale.state]}
</Value>
{checked.tailscale.restart && desktop && (
<Button
variant="outline"
disabled={restart.busy}
onClick={() => void restartHelper()}
>
Restart
</Button>
)}
</Row>
<TeamDirectory directory={checked.directory} onChange={() => checks.refresh()} />
</>
Expand Down
10 changes: 9 additions & 1 deletion apps/host/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion apps/host/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function diagnostics(sync?: boolean): Promise<Diagnostics> {
}
const [tools, tailscale] = await Promise.all([
Promise.all([git(), shell()]),
tailnet.diagnostics(),
tailnet.diagnostics(started),
]);
return {
tools,
Expand Down
5 changes: 5 additions & 0 deletions apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions apps/host/src/tailnet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,16 @@ export async function domain(): Promise<string | undefined> {
return (await status())?.MagicDNSSuffix;
}

export async function diagnostics(): Promise<Diagnostics["tailscale"]> {
/** `started` is the tailnet the host read at startup, which its channels and listeners still use. */
export async function diagnostics(started?: string): Promise<Diagnostics["tailscale"]> {
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 };
}
Expand Down