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: 01M5MMH86TBN5X4NFTC9KCNEF9
anchor: function handle
created: 2026-10-23T15:02:08Z
norm: '1'
sig: 4bdf740420c97daf
body_hash: 6de71005918c450b
raw_hash: f80ca60da9be0283
lines: 109-159
---

a host leaves only as its whois machine name, and leaving calls members so the serving host re-reads its own directory now
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
id: 01M5MMHKVTATGPKXKCYAQ78HPE
anchor: function handle
created: 2026-10-23T15:02:21Z
norm: '1'
sig: 4bdf740420c97daf
body_hash: 6de71005918c450b
raw_hash: f80ca60da9be0283
lines: 109-159
supersedes: 01M5MH0DD8WZ8X1APBFA87YYPN
---

a host publishes and leaves only under its whois machine name (publishes also under its login); shares only as the caller's login; a host's first publish and its leave call members so the serving host re-reads its directory now
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function configure
created: 2026-10-15T04:08:53Z
norm: '1'
sig: f3e1bf286b59976d
body_hash: 8ac9e99449a990ef
raw_hash: 5b6fd56a9863703b
vouched: 2026-10-23T12:24:44Z
body_hash: 929475baa9c1eeb0
raw_hash: 9a3e5ddfb28d0a2a
vouched: 2026-10-23T15:02:22Z
lines: 46-54
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function watch
created: 2026-10-15T04:19:48Z
norm: '1'
sig: 576e815f30f4d9cc
body_hash: c1335e94c837e607
raw_hash: a182e4b2f0856f99
vouched: 2026-10-23T12:24:51Z
body_hash: cae48791b9d477fd
raw_hash: fb3f73b6c2c2e94b
vouched: 2026-10-23T15:02:22Z
lines: 89-149
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function configure
created: 2026-10-23T12:24:20Z
norm: '1'
sig: f3e1bf286b59976d
body_hash: 8ac9e99449a990ef
raw_hash: 5b6fd56a9863703b
body_hash: 929475baa9c1eeb0
raw_hash: 9a3e5ddfb28d0a2a
vouched: 2026-10-23T15:02:23Z
lines: 85-94
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5MMH7A97XGVD8D76KQ8T8PR
anchor: function configure
created: 2026-10-23T15:02:06Z
norm: '1'
sig: f3e1bf286b59976d
body_hash: 929475baa9c1eeb0
raw_hash: 9a3e5ddfb28d0a2a
lines: 91-104
---

leaving or changing directories DELETEs this host from the old one, after any publish under way to it, or the serving Mac keeps the stale row and a rejoin never counts as a join
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: c240b7c2f39b2623
raw_hash: 3915b3b7c7b5bb0f
vouched: 2026-10-23T14:00:32Z
body_hash: fe20eb5454fb7a15
raw_hash: f105febd5bb519a1
vouched: 2026-10-23T15:02:23Z
lines: 1-1153
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-22T14:00:47Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: c240b7c2f39b2623
raw_hash: 3915b3b7c7b5bb0f
vouched: 2026-10-23T14:00:32Z
body_hash: fe20eb5454fb7a15
raw_hash: f105febd5bb519a1
vouched: 2026-10-23T15:02:24Z
lines: 1-1309
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Directory
created: 2026-10-15T06:28:49Z
norm: '1'
sig: 6dd0ab7d7a4fcaa0
body_hash: 35f5333e78ef4ab2
raw_hash: 48cbc89e6e566ec8
vouched: 2026-10-19T20:58:11Z
body_hash: 6322e74c70344729
raw_hash: 6c5edbcdbf70a074
vouched: 2026-10-23T15:02:24Z
lines: 27-77
---

Expand Down
22 changes: 18 additions & 4 deletions apps/app/src/directory-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,17 @@ function shared(shares: Share[]): string {
return [...projects].map(([name, people]) => `${name} with ${people.join(", ")}`).join("; ");
}

/** `row` keeps each result beside the control that caused it. */
type Action = { row?: "directory" | "secret"; busy?: string; error?: string; message?: string };
/**
* `row` keeps each result beside the control that caused it. `joined` is whether `message` says
* this host is in a directory, so a join or leave made elsewhere, as with `ace directory`, hides it.
*/
type Action = {
row?: "directory" | "secret";
busy?: string;
error?: string;
message?: string;
joined?: boolean;
};

const link =
"rounded-sm underline outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30";
Expand All @@ -222,14 +231,15 @@ function TeamDirectory(
busy: string,
message: string,
request: () => Promise<unknown>,
joined?: boolean,
) {
setAction({ row, busy });
try {
await request();
setEditing(false);
setSecret("");
setConfirm(undefined);
setAction({ row, message });
setAction({ row, message, joined });
} catch (error) {
setAction({ row, error: (error as Error).message });
}
Expand All @@ -242,7 +252,9 @@ function TeamDirectory(
<>
{action.busy && <Status>{action.busy}</Status>}
{action.error && <Problem>{action.error}</Problem>}
{action.message && <Status>{action.message}</Status>}
{action.message && (action.joined === undefined || action.joined === !!directory.url) && (
<Status>{action.message}</Status>
)}
</>
);
}
Expand Down Expand Up @@ -291,6 +303,7 @@ function TeamDirectory(
url,
...(secret.trim() ? { secret } : {}),
}),
true,
);
}}
onKeyDown={(event) => {
Expand Down Expand Up @@ -371,6 +384,7 @@ function TeamDirectory(
"Leaving the directory…",
"Left the directory.",
() => host.request({ op: "directory-set", url: null }),
false,
)}
onCancel={() => setConfirm(undefined)}
>
Expand Down
20 changes: 17 additions & 3 deletions apps/host/src/directory-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,15 @@ export function shares(): Share[] {
return open().query<Share, []>('SELECT project, person, shared_by AS "by", at FROM shares').all();
}

/** Requests under `/directory`, with `path` the rest; `joined` runs when a host publishes for the first time. */
/**
* Requests under `/directory`, with `path` the rest; `members` runs when a host publishes for the
* first time or leaves, so this host's own views follow at once.
*/
export async function handle(
path: string,
request: Request,
caller: Caller,
joined: () => void,
members: () => void,
): Promise<Response> {
if (!isServing()) return new Response("This host serves no directory", { status: 404 });
const sql = open();
Expand All @@ -121,7 +124,18 @@ export async function handle(
}
const known = sql.query("SELECT 1 FROM hosts WHERE name = ?").get(host);
publish(sql, host, publication);
if (!known) joined();
if (!known) members();
return new Response(null, { status: 204 });
}
if (host && request.method === "DELETE") {
if (host !== caller.host) {
return new Response("Hosts leave only as themselves", { status: 403 });
}
sql.transaction(() => {
sql.run("DELETE FROM hosts WHERE name = ?", [host]);
sql.run("DELETE FROM channels WHERE host = ?", [host]);
})();
members();
return new Response(null, { status: 204 });
}
if (path === "/shares" && request.method === "POST") {
Expand Down
28 changes: 25 additions & 3 deletions apps/host/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ let error: string | undefined;
let failed: number | undefined;
/** The running host's watch, so a change made through it takes effect without waiting for a heartbeat. */
let active:
| { sync(): Promise<void>; again(): Promise<void>; changed(): void; chose(): void }
| {
sync(): Promise<void>;
again(): Promise<void>;
changed(): void;
chose(): void;
leave(base: string): Promise<void>;
}
| undefined;

/** A directory base URL without a trailing slash. */
Expand All @@ -83,7 +89,11 @@ export function canonical(value: string): string {
* an empty file, so a host that left never joins a peer's directory on its own again.
*/
export function configure(value: string | undefined): Promise<void> | undefined {
writeFileSync(config, JSON.stringify(value ? { url: canonical(value) } : {}));
const before = url();
const after = value && canonical(value);
writeFileSync(config, JSON.stringify(after ? { url: after } : {}));
// Teammates' views, including the serving Mac's, drop this host only when it says it left.
if (before && before !== after) void active?.leave(before);
known = { hosts: [], channels: [], shares: [] };
synced = undefined;
error = undefined;
Expand Down Expand Up @@ -339,7 +349,19 @@ export function watch({ self, channels, changed, chose, published, covered }: Op
if (!running || running.base !== url()) return join();
return running.again ??= running.done.then(join);
};
active = { sync: join, again: sync, changed, chose };
const leave = async (base: string) => {
// A publish under way would list this host again after it left.
if (running?.base === base) await running.done;
try {
await call(base, `/hosts/${encodeURIComponent(self().name)}`, controller.signal, {
method: "DELETE",
});
log("info", "directory.left", { url: base });
} catch (cause) {
log("warn", "directory.leave.failed", { url: base, ...failure(cause) });
}
};
active = { sync: join, again: sync, changed, chose, leave };
void join();
const interval = setInterval(() => {
// A failed publish keeps retrying every heartbeat; lobbies only carry what was published.
Expand Down
8 changes: 4 additions & 4 deletions apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,10 +411,10 @@ function asksChanged(): void {
}

/**
* A host joined the directory this one serves. This host reads it only on its heartbeat, so it
* reads it now for its apps' People and In the directory.
* A host joined or left the directory this one serves. This host reads it only on its heartbeat,
* so it reads it now for its apps' People and In the directory.
*/
function directoryJoined(): void {
function directoryMembers(): void {
void directory.refresh().then(settingsChanged);
}

Expand Down Expand Up @@ -1153,7 +1153,7 @@ async function directoryRequest(
return new Response("Forbidden", { status: 403 });
}
const project = served.lobbyOf(path);
if (project === undefined) return served.handle(path, request, caller, directoryJoined);
if (project === undefined) return served.handle(path, request, caller, directoryMembers);
if (!served.isServing()) return new Response("This host serves no directory", { status: 404 });
const data = {
...client(caller.login, true, false, caller.host),
Expand Down
12 changes: 12 additions & 0 deletions services/directory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ export class Directory extends DurableObject<Env> {
});
}

leave(name: string): void {
const sql = this.ctx.storage.sql;
this.ctx.storage.transactionSync(() => {
sql.exec("DELETE FROM hosts WHERE name = ?", name);
sql.exec("DELETE FROM channels WHERE host = ?", name);
});
}

read(): { hosts: Host[]; channels: unknown[]; shares: Share[] } {
const sql = this.ctx.storage.sql;
return {
Expand Down Expand Up @@ -173,6 +181,10 @@ export default {
await directory.publish(host[1], publication);
return new Response(null, { status: 204 });
}
if (host && request.method === "DELETE") {
await directory.leave(host[1]);
return new Response(null, { status: 204 });
}
if (pathname === "/shares" && request.method === "POST") {
const { shared, ...share } = await request.json<Share & { shared: boolean }>();
if (
Expand Down