Public: anyone can read this; the crew can change it.

A move made in the terminal reaches the open web pages too #91

Closed opened this
Closed without merging

scout/tempo:scout/31-a-move-made-in-the-terminal-reaches-the → main 3 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
12 files changed +264 −36
README.md +11 −7
@@ -74,7 +74,11 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
7474 the 503 this same server answers with once a board has 100 tabs open
7575 on it (`MAX_SUBSCRIBERS_PER_BOARD` in `src/limits.ts`). The button
7676 opens a fresh connection and, once it's up, reconciles the same way
77 an automatic reconnect does.
77 an automatic reconnect does. A move made in the terminal (`src/tty.ts`,
78 a separate process) reaches the same open page too — see `src/watch.ts`
79 below — arriving as a whole-board reconcile rather than one placed
80 card, since the poller that notices it only knows the board changed,
81 not what changed.
7882 - **Agents**: `POST /mcp` speaks MCP over plain JSON-RPC 2.0
7983 (`initialize`, `tools/list`, `tools/call`) with five tools —
8084 `list_boards`, `list_columns`, `list_cards`, `add_card`, `move_card` —
@@ -147,12 +151,12 @@ background, reverting if it fails.
147151 came from the page or another terminal. A redraw never disturbs what
148152 you're doing: an open prompt keeps its text, the filter stays, and the
149153 cursor rides the same card or column (found by id) rather than jumping
150 when the list changes under it. It's one-way: only `src/routes.ts` and
151 `src/mcp.ts` call `bus.publish`, and the terminal writes straight to
152 `src/db.ts`, so a change made *in* the terminal still doesn't reach an
153 open web page until its next load — the revision table makes that cheap
154 to close (the server could poll it and publish too), but that's outside
155 this issue.
154 when the list changes under it. The other direction rides the same
155 revision counter: `src/watch.ts` polls it, about twice a second, for
156 every board with an open `/events` listener, and publishes one `external`
157 event (`by: null`) when it moved without this process having published it
158 itself — so a card moved *in* the terminal reaches an open web page within
159 a second too, the same way a live edit from another tab does.
156160
157161 ## Limits
158162
src/bus.ts +7 −1
@@ -15,7 +15,8 @@ export type EventKind =
1515 | "column.moved"
1616 | "column.deleted"
1717 | "board.renamed"
18 | "undo";
18 | "undo"
19 | "external";
1920
2021 export type BoardEvent = {
2122 readonly kind: EventKind;
@@ -48,3 +49,8 @@ export function subscribe(boardId: string, fn: (event: BoardEvent) => void): ()
4849 export function subscriberCount(boardId: string): number {
4950 return boards.get(boardId)?.size ?? 0;
5051 }
52
53 /** Every board id with at least one open listener — what `src/watch.ts` needs to know which boards are worth polling. */
54 export function subscribedBoardIds(): string[] {
55 return [...boards.keys()];
56 }
src/db.ts +6 −4
@@ -114,10 +114,12 @@ function logAction(boardId: string, payload: Payload): void {
114114 }
115115
116116 // The terminal (src/tty.ts) is a separate process on a pty; it cannot hear
117 // the web server's in-process event bus (src/live.ts), so it polls this
118 // instead. A scope's revision is a counter bumped in the same transaction
119 // as every write that changes what that scope shows, never derived from
120 // the action log — the log is trimmed and undone, so it is not a clock.
117 // the web server's in-process event bus (src/bus.ts), so it polls this
118 // instead — and src/watch.ts polls it right back, for the direction a
119 // change made in the terminal has to travel to reach the bus. A scope's
120 // revision is a counter bumped in the same transaction as every write that
121 // changes what that scope shows, never derived from the action log — the
122 // log is trimmed and undone, so it is not a clock.
121123 // "__boards__" is not a valid board id (those are 8 lowercase/digit
122124 // characters from randomId), so it cannot collide with one.
123125 const BOARDS_SCOPE = "__boards__";
src/mcp.ts +2
@@ -12,11 +12,13 @@ import * as bus from "./bus.ts";
1212 import * as db from "./db.ts";
1313 import { MAX_CARDS_PER_BOARD, writeBucket } from "./limits.ts";
1414 import { cleanText } from "./text.ts";
15 import { noteRevision } from "./watch.ts";
1516 import type { Who } from "./who.ts";
1617
1718 // An agent has no tab of its own, so there is nothing to echo-suppress.
1819 function publish(boardId: string, who: Who, kind: bus.EventKind, ids: Record<string, string>): void {
1920 bus.publish(boardId, { kind, boardId, by: who.role === "anon" ? null : { id: who.id, name: who.name }, client: null, ids });
21 noteRevision(boardId, db.boardRevision(boardId));
2022 }
2123
2224 const BOARD_ID = /^[a-z0-9]{8}$/;
src/pages/board.ts +1 −1
@@ -289,6 +289,6 @@ const boardShortcuts: [string, string][] = [
289289
290290 // Named by content — a change to static/board.js gets a new hash here,
291291 // so the year-long cache on /static/ never serves a stale script.
292 export const BOARD_JS = "board.822340210.js";
292 export const BOARD_JS = "board.351899687.js";
293293 export const THEME_JS = "theme.1025564933.js";
294294 export const BOARDS_JS = "boards.608180649.js";
src/routes.ts +2
@@ -11,6 +11,7 @@ import { boardCreationBucket, callerKey, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BO
1111 import { rpc } from "./mcp.ts";
1212 import { boardPage, landingPage } from "./pages/board.ts";
1313 import { cleanText } from "./text.ts";
14 import { noteRevision } from "./watch.ts";
1415 import type { Who } from "./who.ts";
1516
1617 const BOARD_ID = /^[a-z0-9]{8}$/;
@@ -32,6 +33,7 @@ function publish(boardId: string, request: Request, who: Who, kind: EventKind, i
3233 client: request.headers.get(CLIENT_HEADER)?.slice(0, 64) ?? null,
3334 ids,
3435 });
36 noteRevision(boardId, db.boardRevision(boardId));
3537 }
3638
3739 // Pages carry a visitor's name and sign-out link once they're signed in, so
src/server.test.ts +63 −2
@@ -1,7 +1,7 @@
11 import { expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
2 import { existsSync, mkdtempSync, rmSync } from "node:fs";
33 import { tmpdir } from "node:os";
4 import { join } from "node:path";
4 import { dirname, join } from "node:path";
55 import { subscriberCount } from "./bus.ts";
66
77 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
@@ -884,3 +884,64 @@ test("list_boards over MCP only ever shows the caller's own boards", async () =>
884884 const carol = JSON.parse((await (await call(identityHeaders("u5", "carol"))).json()).result.content[0].text);
885885 expect(carol.some((b: { name: string }) => b.name === "Mine")).toBe(true);
886886 });
887
888 // Same shape as db.test.ts's own-process-writes-while-another-holds-the-lock
889 // tests: a second process, writing through db.ts exactly the way src/tty.ts
890 // does (never touching this process's in-memory bus), stands in for the
891 // terminal. src/watch.ts polls boardRevision and republishes for it, so an
892 // open /events stream hears about a move it could never have heard about
893 // any other way.
894 test("a card moved by a second process (standing in for the ssh terminal) reaches an open stream", async () => {
895 const id = await newBoard();
896 const page = await (await fetch(base + `/b/${id}`)).text();
897 const [todoId, doingId] = [...page.matchAll(/data-column-id="([a-z0-9]{12})"/g)].map((m) => m[1]);
898 const created = await post(`/b/${id}/columns/${todoId}/cards`, { title: "Moved from the terminal" });
899 const { id: cardId } = (await created.json()) as { id: string };
900
901 const stream = await fetch(base + `/b/${id}/events`);
902 const reader = stream.body!.getReader();
903 const decoder = new TextDecoder();
904 let buffer = "";
905 async function nextFrame(): Promise<string> {
906 while (!buffer.includes("\n\n")) {
907 const { value, done } = await reader.read();
908 if (done) throw new Error("stream ended");
909 buffer += decoder.decode(value, { stream: true });
910 }
911 const at = buffer.indexOf("\n\n");
912 const frame = buffer.slice(0, at);
913 buffer = buffer.slice(at + 2);
914 return frame;
915 }
916
917 const script = `
918 import * as db from "./src/db.ts";
919 db.moveCard(process.env.BOARD_ID, process.env.CARD_ID, process.env.COLUMN_ID, 0);
920 `;
921 const child = Bun.spawn({
922 cmd: [process.execPath, "-e", script],
923 cwd: join(import.meta.dir, ".."),
924 // The live server's actual data directory, not process.env.FORGE_APP_DATA:
925 // bun test shares one module cache (and so one server, one db.ts) across
926 // every test file in the run, and each file's own top-level assignment to
927 // that env var only wins the race to be read once, by whichever file's
928 // import of db.ts happens to run first — reading it back here would as
929 // likely name a different file's directory as this server's own.
930 env: { ...process.env, FORGE_APP_DATA: dirname(db.db.filename), BOARD_ID: id, CARD_ID: cardId, COLUMN_ID: doingId },
931 stderr: "pipe",
932 });
933 const exitCode = await child.exited;
934 if (exitCode !== 0) console.error(await new Response(child.stderr).text());
935 expect(exitCode).toBe(0);
936
937 let event: { kind: string; boardId: string; by: unknown } | undefined;
938 for (let i = 0; i < 10 && !event; i++) {
939 const frame = await nextFrame();
940 const data = frame.match(/^data: (.*)$/m)?.[1];
941 if (data) event = JSON.parse(data);
942 }
943 expect(event?.kind).toBe("external");
944 expect(event?.boardId).toBe(id);
945 expect(event).not.toHaveProperty("by");
946 await reader.cancel();
947 }, 10000);
src/server.ts +5
@@ -11,6 +11,7 @@ import "./db.ts";
1111 import { readBody } from "./body.ts";
1212 import { MAX_REQUEST_BODY_BYTES, MAX_REQUEST_DRAIN_BYTES } from "./limits.ts";
1313 import { route } from "./routes.ts";
14 import { startWatching } from "./watch.ts";
1415 import { who } from "./who.ts";
1516
1617 const env = process.env.FORGE_APP_ENV ?? "local";
@@ -124,4 +125,8 @@ export const server = Bun.serve({
124125 },
125126 });
126127
128 // A move made in src/tty.ts (a separate process on a pty) reaches every
129 // open board page through this: see src/watch.ts.
130 startWatching();
131
127132 console.log(`tempo listening on http://${server.hostname}:${server.port} (${env})`);
src/watch.test.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * Unit-level: pollExternalWrites against bus.ts and db.ts directly, no
3 * HTTP server. src/server.test.ts covers the cross-process, over-the-wire
4 * shape of the same behaviour.
5 */
6 import { expect, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10
11 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
12 const db = await import("./db.ts");
13 const bus = await import("./bus.ts");
14 const watch = await import("./watch.ts");
15
16 function setup() {
17 const board = db.createBoard("Board");
18 const column = db.listColumns(board.id)[0];
19 return { boardId: board.id, columnId: column.id };
20 }
21
22 test("a board with no listener is never polled or published to", () => {
23 const { boardId, columnId } = setup();
24 db.createCard(boardId, columnId, "first");
25 watch.pollExternalWrites();
26 db.createCard(boardId, columnId, "second");
27 const heard: unknown[] = [];
28 const stop = bus.subscribe(boardId, (e) => heard.push(e));
29 watch.pollExternalWrites(); // first look after subscribing only sets the baseline
30 expect(heard).toEqual([]);
31 stop();
32 });
33
34 test("a write made outside publish() reaches a listener within one poll, as external with by: null", () => {
35 const { boardId, columnId } = setup();
36 const heard: unknown[] = [];
37 const stop = bus.subscribe(boardId, (e) => heard.push(e));
38
39 watch.pollExternalWrites(); // establishes the baseline for this newly-subscribed board
40 expect(heard).toEqual([]);
41
42 db.createCard(boardId, columnId, "written elsewhere"); // e.g. a move made in src/tty.ts
43 watch.pollExternalWrites();
44
45 expect(heard).toEqual([{ kind: "external", boardId, by: null, client: null, ids: {} }]);
46 stop();
47 });
48
49 test("a write already announced through noteRevision is not announced again by the poller", () => {
50 const { boardId, columnId } = setup();
51 const heard: unknown[] = [];
52 const stop = bus.subscribe(boardId, (e) => heard.push(e));
53 watch.pollExternalWrites();
54
55 db.createCard(boardId, columnId, "announced by routes.ts");
56 watch.noteRevision(boardId, db.boardRevision(boardId));
57 watch.pollExternalWrites();
58
59 expect(heard).toEqual([]);
60 stop();
61 });
62
63 test("the tick after everyone leaves forgets the board, so a later resubscribe does not replay a stale change as new", () => {
64 const { boardId, columnId } = setup();
65 const stop1 = bus.subscribe(boardId, () => {});
66 watch.pollExternalWrites(); // baseline set while the first listener is open
67 stop1();
68 watch.pollExternalWrites(); // the next tick, with no listener left, forgets that baseline
69
70 db.createCard(boardId, columnId, "while nobody was watching");
71
72 const heard: unknown[] = [];
73 const stop2 = bus.subscribe(boardId, (e) => heard.push(e));
74 watch.pollExternalWrites(); // first look after resubscribing only re-establishes the baseline
75 expect(heard).toEqual([]);
76 stop2();
77 });
src/watch.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * The bridge from a write made outside this process to the in-process bus
3 * (`src/bus.ts`). `src/tty.ts` runs on its own pty process and writes
4 * through `src/db.ts` directly — it cannot call `bus.publish`, the
5 * listeners live in here. So this polls `db.boardRevision` for every board
6 * with an open listener and publishes one `external` event when it moved.
7 * A write this process already published itself (see the `publish` helpers
8 * in `src/routes.ts` and `src/mcp.ts`) calls `noteRevision` too, so the
9 * same change is never announced twice.
10 */
11
12 import { publish, subscribedBoardIds } from "./bus.ts";
13 import { boardRevision } from "./db.ts";
14
15 const seen = new Map<string, number>();
16
17 /** Record the revision a board's own write already announced through the bus. */
18 export function noteRevision(boardId: string, revision: number): void {
19 seen.set(boardId, revision);
20 }
21
22 /** One pass: publish `external` for any watched board whose revision moved since it was last looked at. */
23 export function pollExternalWrites(): void {
24 const active = subscribedBoardIds();
25 for (const boardId of active) {
26 const revision = boardRevision(boardId);
27 const before = seen.get(boardId);
28 seen.set(boardId, revision);
29 if (before !== undefined && before !== revision) {
30 publish(boardId, { kind: "external", boardId, by: null, client: null, ids: {} });
31 }
32 }
33 const activeSet = new Set(active);
34 for (const boardId of seen.keys()) if (!activeSet.has(boardId)) seen.delete(boardId);
35 }
36
37 /** Starts polling on an interval; returns a function that stops it. Unref'd so it never keeps the process alive on its own. */
38 export function startWatching(intervalMs = 500): () => void {
39 const timer = setInterval(pollExternalWrites, intervalMs);
40 timer.unref?.();
41 return () => clearInterval(timer);
42 }
src/write-limit.test.ts +42 −19
@@ -7,6 +7,17 @@
77 * shares one key with theirs (see callerKey). `afterAll` below undoes the
88 * drain once this file is done, so this is safe regardless of what order
99 * bun runs test files in.
10 *
11 * writeBucket refills off the real clock, so a drained bucket only stays
12 * empty for as long as no real time passes before the write it's meant to
13 * refuse lands. A request sent over an actual socket (like every other test
14 * file's `fetch`) hands that landing to the OS's own scheduling, which is
15 * plenty of time for a token to trickle back under load — the bucket's
16 * 300/minute rate needs only ~200ms to hand back one. So this file calls
17 * `route()` directly, the same function the real server's `fetch` handler
18 * calls after its own request setup, instead of going over the network:
19 * same rate-limiting code, same writeBucket, but no socket round trip
20 * between draining and the write actually being checked.
1021 */
1122 import { afterAll, expect, test } from "bun:test";
1223 import { mkdtempSync } from "node:fs";
@@ -17,17 +28,30 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
1728 process.env.PORT = "0";
1829 process.env.HOST = "127.0.0.1";
1930 const { server } = await import("./server.ts");
31 const { route } = await import("./routes.ts");
2032 const { callerKey, writeBucket } = await import("./limits.ts");
2133 const { ANON } = await import("./who.ts");
2234 const db = await import("./db.ts");
2335 const base = `http://${server.hostname}:${server.port}`;
2436
25 // Every request below is anonymous over loopback, so this is the one key
26 // they all spend from — the same key a real flood from this address would.
27 const anonKey = callerKey(ANON, "127.0.0.1");
37 // Every request below is anonymous from this made-up address, so this is
38 // the one key they all spend from — the same key a real flood from it would.
39 const anonIp = "127.0.0.1";
40 const anonKey = callerKey(ANON, anonIp);
2841
29 const post = (path: string, body: Record<string, string> = {}, headers: Record<string, string> = {}) =>
30 fetch(base + path, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...headers }, body: new URLSearchParams(body) });
42 function post(path: string, body: Record<string, string> = {}, headers: Record<string, string> = {}): Promise<Response> {
43 const request = new Request(base + path, {
44 method: "POST",
45 headers: { "content-type": "application/x-www-form-urlencoded", ...headers },
46 body: new URLSearchParams(body),
47 });
48 return route(request, new URL(request.url), "test", ANON, anonIp).then((r) => r ?? new Response("not found", { status: 404 }));
49 }
50
51 const mcp = (body: unknown): Promise<Response> => {
52 const request = new Request(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
53 return route(request, new URL(request.url), "test", ANON, anonIp).then((r) => r ?? new Response("not found", { status: 404 }));
54 };
3155
3256 function drainWriteBucket(): void {
3357 while (writeBucket.take(anonKey)) {
@@ -82,7 +106,11 @@ test("a flood retrying the same refusal without navigating gets the cheap answer
82106 const refused = await post(`${boardUrl}/rename`, { name: "New name" });
83107 expect(refused.status).toBe(429);
84108 expect(refused.headers.get("retry-after")).toMatch(/^\d+$/);
85 expect(refused.headers.get("content-type")).not.toContain("text/html");
109 // route() is called directly here (see the file comment), so this never
110 // reaches Bun's socket writer — the one that would fill in a default
111 // text/plain content-type for a string body with none set, the way a real
112 // client would see it. Absent is just as much "not html" as text/plain is.
113 expect(refused.headers.get("content-type") ?? "").not.toContain("text/html");
86114 expect(await refused.text()).toBe("too many changes just now — try again shortly");
87115 });
88116
@@ -92,15 +120,11 @@ test("an MCP write past the limit comes back as a readable tool error, not a tra
92120
93121 drainWriteBucket();
94122
95 const response = await fetch(base + "/mcp", {
96 method: "POST",
97 headers: { "content-type": "application/json" },
98 body: JSON.stringify({
99 jsonrpc: "2.0",
100 id: 1,
101 method: "tools/call",
102 params: { name: "add_card", arguments: { board_id: board.id, column_id: columnId, title: "should be refused" } },
103 }),
123 const response = await mcp({
124 jsonrpc: "2.0",
125 id: 1,
126 method: "tools/call",
127 params: { name: "add_card", arguments: { board_id: board.id, column_id: columnId, title: "should be refused" } },
104128 });
105129 expect(response.status).toBe(200); // JSON-RPC over HTTP: the transport succeeded
106130 const rpc = await response.json();
@@ -111,14 +135,13 @@ test("an MCP write past the limit comes back as a readable tool error, not a tra
111135
112136 test("a drained bucket still lets initialize, tools/list and the read-only tools answer", async () => {
113137 drainWriteBucket();
114 const call = (body: unknown) => fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
115138
116 const init = await (await call({ jsonrpc: "2.0", id: 1, method: "initialize" })).json();
139 const init = await (await mcp({ jsonrpc: "2.0", id: 1, method: "initialize" })).json();
117140 expect(init.result.serverInfo.name).toBe("tempo");
118141
119 const listed = await (await call({ jsonrpc: "2.0", id: 2, method: "tools/list" })).json();
142 const listed = await (await mcp({ jsonrpc: "2.0", id: 2, method: "tools/list" })).json();
120143 expect(listed.result.tools.length).toBeGreaterThan(0);
121144
122 const boards = await (await call({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "list_boards", arguments: {} } })).json();
145 const boards = await (await mcp({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "list_boards", arguments: {} } })).json();
123146 expect(boards.result.isError).toBeUndefined();
124147 });
static/board.822340210.js → static/board.351899687.js renamed +6 −2
@@ -425,6 +425,10 @@
425425 const focused = root.querySelector(".board :focus");
426426 return !!focused && (focused.tagName === "INPUT" || focused.tagName === "SELECT");
427427 }
428 // A move made from the terminal (src/tty.ts, on a separate process) is
429 // reported the same way as undo: its scope isn't known here either, so
430 // it waits out anything mid-type or mid-drag, then reconciles the whole
431 // board rather than guessing what changed.
428432
429433 // An event that would clobber something the user is mid-typing (or
430434 // mid-drag) waits until they're done rather than overwriting it. A tab
@@ -450,7 +454,7 @@
450454 (event.kind === "card.moved" && dragging && dragging.el.dataset.cardId === event.ids.cardId) ||
451455 (event.kind === "column.renamed" && isRenamingColumn(event.ids.columnId)) ||
452456 (event.kind === "board.renamed" && isRenamingBoard()) ||
453 (event.kind === "undo" && (dragging || isTypingSomewhere()))
457 ((event.kind === "undo" || event.kind === "external") && (dragging || isTypingSomewhere()))
454458 ) {
455459 pending.push(event);
456460 if (pending.length > PENDING_LIMIT) {
@@ -500,7 +504,7 @@
500504 for (const opt of root.querySelectorAll(`select[name=column_id] option[value="${CSS.escape(ids.columnId)}"]`)) opt.remove();
501505 return;
502506 }
503 if (kind === "undo") return reconcile();
507 if (kind === "undo" || kind === "external") return reconcile();
504508
505509 const fresh = await freshBoard();
506510 if (!fresh) return;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs