Public: anyone can read this; the crew can change it.
The board says when it has stopped being live, instead of going quietly stale #82
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/67-the-board-says-when-it-has-stopped-being → main 1 commit fork
4 files
changed +156
−13
README.md +10 −1
| @@ -65,7 +65,16 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 65 | 65 | trying to replay whatever was missed while it was down. Claiming a |
| 66 | 66 | board doesn't publish an event — "Kept by" is the one piece of board |
| 67 | 67 | state that only updates on next load, a deliberate narrowing (see |
| 68 | "What's simulated"). | |
| 68 | "What's simulated"). A small dot and label next to the board's name | |
| 69 | say whether that connection is actually up: "Live" once it's open, | |
| 70 | "Not live — reconnecting" if a drop lingers past about a second (a | |
| 71 | quick blip never gets a word), and "Not live — Reconnect" with a | |
| 72 | button if the browser has given up retrying on its own — which per | |
| 73 | the EventSource spec is permanent for any non-200 response, including | |
| 74 | the 503 this same server answers with once a board has 100 tabs open | |
| 75 | on it (`MAX_SUBSCRIBERS_PER_BOARD` in `src/limits.ts`). The button | |
| 76 | opens a fresh connection and, once it's up, reconciles the same way | |
| 77 | an automatic reconnect does. | |
| 69 | 78 | - **Agents**: `POST /mcp` speaks MCP over plain JSON-RPC 2.0 |
| 70 | 79 | (`initialize`, `tools/list`, `tools/call`) with five tools — |
| 71 | 80 | `list_boards`, `list_columns`, `list_cards`, `add_card`, `move_card` — |
src/live-refresh.test.ts +60
| @@ -53,14 +53,31 @@ afterEach(() => { | ||
| 53 | 53 | }); |
| 54 | 54 | |
| 55 | 55 | class FakeEventSource { |
| 56 | static readonly CONNECTING = 0; | |
| 57 | static readonly OPEN = 1; | |
| 58 | static readonly CLOSED = 2; | |
| 56 | 59 | onmessage: ((message: { data: string }) => void) | null = null; |
| 57 | 60 | onopen: (() => void) | null = null; |
| 61 | onerror: (() => void) | null = null; | |
| 62 | readyState = FakeEventSource.CONNECTING; | |
| 58 | 63 | constructor(_url: string) { |
| 59 | 64 | latestSource = this; |
| 60 | 65 | } |
| 61 | 66 | } |
| 62 | 67 | let latestSource: FakeEventSource | undefined; |
| 63 | 68 | |
| 69 | // Fires onopen/onerror on whichever EventSource the script most recently | |
| 70 | // constructed — the same one a real browser would be retrying, including | |
| 71 | // the fresh one board.js makes when the Reconnect button is clicked. | |
| 72 | function open(): void { | |
| 73 | latestSource!.readyState = FakeEventSource.OPEN; | |
| 74 | latestSource!.onopen?.(); | |
| 75 | } | |
| 76 | function fail(readyState: number): void { | |
| 77 | latestSource!.readyState = readyState; | |
| 78 | latestSource!.onerror?.(); | |
| 79 | } | |
| 80 | ||
| 64 | 81 | async function run(boardId: string, onFetch?: (path: string, init: RequestInit) => void): Promise<{ window: Window; document: Document }> { |
| 65 | 82 | const html = await (await fetch(`${base}/b/${boardId}`)).text(); |
| 66 | 83 | const [, scriptSrc] = html.match(/<script src="(\/static\/board[^"]+)"/)!; |
| @@ -215,3 +232,46 @@ test("a column added or deleted elsewhere updates every card's Move to column dr | ||
| 215 | 232 | |
| 216 | 233 | await window.close(); |
| 217 | 234 | }); |
| 235 | ||
| 236 | test("the board says when its live connection drops, waits out a routine reconnect before saying so, and offers a way back from a permanent failure", async () => { | |
| 237 | const boardId = await newBoard(); | |
| 238 | let htmlFetches = 0; | |
| 239 | const { window, document } = await run(boardId, (_path, init) => { | |
| 240 | const headers = init.headers as Record<string, string> | undefined; | |
| 241 | if (headers?.accept === "text/html") htmlFetches++; | |
| 242 | }); | |
| 243 | const liveText = () => document.getElementById("live-text")!.textContent; | |
| 244 | const reconnectBtn = document.getElementById("live-reconnect") as unknown as HTMLButtonElement; | |
| 245 | ||
| 246 | expect(liveText()).toBe("Not live"); // server-rendered, before any script has run | |
| 247 | expect(reconnectBtn.hidden).toBe(true); | |
| 248 | ||
| 249 | open(); | |
| 250 | expect(liveText()).toBe("Live"); | |
| 251 | ||
| 252 | // A drop the browser will retry on its own: no flicker right away, and | |
| 253 | // "reconnecting" only once it's clear this isn't an instant recovery. | |
| 254 | fail(FakeEventSource.CONNECTING); | |
| 255 | expect(liveText()).toBe("Live"); | |
| 256 | await new Promise((resolve) => setTimeout(resolve, 1100)); | |
| 257 | expect(liveText()).toBe("Not live — reconnecting"); | |
| 258 | expect(reconnectBtn.hidden).toBe(true); | |
| 259 | ||
| 260 | // The permanent failure EventSource itself never retries from (a 503 from | |
| 261 | // a full board, among others): says so immediately and offers a way back. | |
| 262 | fail(FakeEventSource.CLOSED); | |
| 263 | expect(liveText()).toBe("Not live — Reconnect"); | |
| 264 | expect(reconnectBtn.hidden).toBe(false); | |
| 265 | ||
| 266 | // The button constructs a fresh connection; once it opens, reconcile() | |
| 267 | // re-reads the board exactly once, same as an automatic reconnect would. | |
| 268 | const before = htmlFetches; | |
| 269 | reconnectBtn.dispatchEvent(new window.Event("click", { bubbles: true })); | |
| 270 | expect(reconnectBtn.hidden).toBe(true); | |
| 271 | open(); | |
| 272 | await new Promise((resolve) => setTimeout(resolve, 20)); | |
| 273 | expect(liveText()).toBe("Live"); | |
| 274 | expect(htmlFetches).toBe(before + 1); | |
| 275 | ||
| 276 | await window.close(); | |
| 277 | }); |
src/pages/board.ts +12 −1
| @@ -44,6 +44,12 @@ const styles = ` | ||
| 44 | 44 | button.ghost, a.ghost { background: transparent; color: inherit; border: 1px solid #8884; padding: .35rem .7rem; border-radius: .4rem; } |
| 45 | 45 | .tools { position: fixed; top: .75rem; right: .75rem; display: flex; gap: .4rem; z-index: 10; } |
| 46 | 46 | .tools button { padding: .3rem .55rem; font-size: 1rem; line-height: 1; } |
| 47 | .live { display: inline-flex; align-items: center; gap: .35rem; font-size: .8rem; opacity: .85; white-space: nowrap; } | |
| 48 | .live .dot { width: .55rem; height: .55rem; border-radius: 50%; background: #8886; flex: none; } | |
| 49 | .live[data-state="live"] .dot { background: #16a34a; } | |
| 50 | .live[data-state="reconnecting"] .dot { background: #d97706; } | |
| 51 | .live[data-state="closed"] .dot { background: #b91c1c; } | |
| 52 | .live button { padding: .1rem .5rem; font-size: .75rem; } | |
| 47 | 53 | .board { display: flex; gap: 1rem; align-items: flex-start; overflow-x: auto; max-width: 100%; padding-bottom: 1rem; } |
| 48 | 54 | .column { background: #8881; border-radius: .6rem; padding: .6rem; width: 16rem; flex: none; } |
| 49 | 55 | .column.drop-target { outline: 2px dashed var(--accent); } |
| @@ -240,6 +246,11 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat | ||
| 240 | 246 | <input name="name" value="${escape(board.name)}" maxlength="100" aria-label="Board name"> |
| 241 | 247 | <button class="ghost">Rename</button> |
| 242 | 248 | </form> |
| 249 | <span class="live" id="live-status" data-state="off"> | |
| 250 | <span class="dot" aria-hidden="true"></span> | |
| 251 | <span id="live-text" aria-live="polite">Not live</span> | |
| 252 | <button type="button" id="live-reconnect" class="ghost" hidden>Reconnect</button> | |
| 253 | </span> | |
| 243 | 254 | ${doorLink(who, `/b/${board.id}`)} |
| 244 | 255 | </header> |
| 245 | 256 | <p class="hint">Share this board with its URL. Drag a card, focus it (Tab) and press the arrow keys, or press and hold it on a touchscreen, to move it — no mouse needed. Type a title and press Enter to save it. ${keepHtml(board, who)}</p> |
| @@ -278,6 +289,6 @@ const boardShortcuts: [string, string][] = [ | ||
| 278 | 289 | |
| 279 | 290 | // Named by content — a change to static/board.js gets a new hash here, |
| 280 | 291 | // so the year-long cache on /static/ never serves a stale script. |
| 281 | export const BOARD_JS = "board.109353762.js"; | |
| 292 | export const BOARD_JS = "board.822340210.js"; | |
| 282 | 293 | export const THEME_JS = "theme.1025564933.js"; |
| 283 | 294 | export const BOARDS_JS = "boards.608180649.js"; |
static/board.109353762.js → static/board.822340210.js renamed +74 −11
| @@ -578,20 +578,83 @@ | ||
| 578 | 578 | } |
| 579 | 579 | } |
| 580 | 580 | |
| 581 | const source = new EventSource(`/b/${boardId}/events`); | |
| 582 | source.onmessage = (message) => { | |
| 583 | const event = JSON.parse(message.data); | |
| 584 | if (event.client && event.client === clientId) return; // this tab already applied it optimistically | |
| 585 | apply(event); | |
| 586 | }; | |
| 581 | // ---- live status: the board's own connection, not its content ---- | |
| 582 | // Server-rendered neutral ("Not live", grey dot, hidden button) so a | |
| 583 | // page with JavaScript off never claims to be live and nothing here | |
| 584 | // shifts layout by creating or removing elements — only text, a class | |
| 585 | // and one boolean toggle on markup that already exists. | |
| 586 | const liveStatus = document.getElementById("live-status"); | |
| 587 | const liveText = document.getElementById("live-text"); | |
| 588 | const reconnectBtn = document.getElementById("live-reconnect"); | |
| 589 | // A connection that drops and comes straight back within about a | |
| 590 | // second is the common case (a deploy, a blip) — flashing "not live" | |
| 591 | // for that would be noise, so the reconnecting state waits this long | |
| 592 | // before it shows itself, and never appears at all if onopen wins the race. | |
| 593 | const NOT_LIVE_DELAY_MS = 1000; | |
| 594 | let notLiveTimer = null; | |
| 595 | function setLive() { | |
| 596 | clearTimeout(notLiveTimer); | |
| 597 | notLiveTimer = null; | |
| 598 | if (liveStatus) liveStatus.dataset.state = "live"; | |
| 599 | if (liveText) liveText.textContent = "Live"; | |
| 600 | if (reconnectBtn) reconnectBtn.hidden = true; | |
| 601 | } | |
| 602 | function setReconnecting() { | |
| 603 | if (notLiveTimer || (liveStatus && liveStatus.dataset.state === "reconnecting")) return; | |
| 604 | notLiveTimer = setTimeout(() => { | |
| 605 | notLiveTimer = null; | |
| 606 | if (liveStatus) liveStatus.dataset.state = "reconnecting"; | |
| 607 | if (liveText) liveText.textContent = "Not live — reconnecting"; | |
| 608 | if (reconnectBtn) reconnectBtn.hidden = true; | |
| 609 | }, NOT_LIVE_DELAY_MS); | |
| 610 | } | |
| 611 | // The permanent failure the EventSource spec leaves silent: a non-200 | |
| 612 | // or wrong-content-type response (the board's own 503 when it is full, | |
| 613 | // among others) closes the connection for good and the browser will | |
| 614 | // never retry. This is the one state that offers a way back rather | |
| 615 | // than implying one is already coming. | |
| 616 | function setClosed() { | |
| 617 | clearTimeout(notLiveTimer); | |
| 618 | notLiveTimer = null; | |
| 619 | if (liveStatus) liveStatus.dataset.state = "closed"; | |
| 620 | if (liveText) liveText.textContent = "Not live — Reconnect"; | |
| 621 | if (reconnectBtn) reconnectBtn.hidden = false; | |
| 622 | } | |
| 623 | ||
| 624 | function wireMessages(es) { | |
| 625 | es.onmessage = (message) => { | |
| 626 | const event = JSON.parse(message.data); | |
| 627 | if (event.client && event.client === clientId) return; // this tab already applied it optimistically | |
| 628 | apply(event); | |
| 629 | }; | |
| 630 | } | |
| 631 | ||
| 587 | 632 | // A dropped connection reconnects on its own; the reconnect itself |
| 588 | 633 | // (not the first, successful connection) means something may have been |
| 589 | 634 | // missed while it was down, so that one reconciles by re-reading the |
| 590 | // board rather than trusting nothing was lost. | |
| 635 | // board rather than trusting nothing was lost. The same is true of a | |
| 636 | // reconnect started by hand from the button below. | |
| 591 | 637 | let connectedBefore = false; |
| 592 | source.onopen = () => { | |
| 593 | if (connectedBefore) reconcile(); | |
| 594 | connectedBefore = true; | |
| 595 | }; | |
| 638 | function connect() { | |
| 639 | const es = new EventSource(`/b/${boardId}/events`); | |
| 640 | wireMessages(es); | |
| 641 | es.onopen = () => { | |
| 642 | setLive(); | |
| 643 | if (connectedBefore) reconcile(); | |
| 644 | connectedBefore = true; | |
| 645 | }; | |
| 646 | es.onerror = () => { | |
| 647 | if (es.readyState === EventSource.CLOSED) setClosed(); | |
| 648 | else setReconnecting(); | |
| 649 | }; | |
| 650 | return es; | |
| 651 | } | |
| 652 | let source = connect(); | |
| 653 | if (reconnectBtn) { | |
| 654 | reconnectBtn.addEventListener("click", () => { | |
| 655 | reconnectBtn.hidden = true; | |
| 656 | source = connect(); | |
| 657 | }); | |
| 658 | } | |
| 596 | 659 | } |
| 597 | 660 | })(); |
new above
offline