Bläddra i källkod

feat(brainstorm-companion): resilient reconnect, live status, paused overlay

The injected client reconnected on a fixed 1s timer with no feedback: if the
laptop slept or the server restarted, the page showed 'Connected' over a dead
socket and silently queued events. And when the server stopped, the user got a
bare connection-refused with no explanation.

helper.js now:
- reconnects with exponential backoff (500ms, doubling, capped at 30s; reset on
  open), with an onerror->close handler, nulls the socket on close, and clears a
  pending timer before scheduling another;
- drives the frame status pill Connected/Reconnecting/Disconnected via a
  --status-color custom property (frame-template.html);
- after ~15s disconnected, shows a self-styled 'Companion paused' overlay
  (tombstone) explaining the companion stopped and will reconnect automatically;
- on recovery from a tombstoned outage (e.g. server restarted on the same port)
  reloads to pick up the restarted server's current screen.

The reconnect-backoff is an exported pure function; helper.test.js unit-tests it
(doubling + cap progression) and asserts the status/tombstone/reconnect wiring.
DOM behaviour is verified live.

Refs #856, #1237
Jesse Vincent 3 månader sedan
förälder
incheckning
36ac3e1336

+ 2 - 2
skills/brainstorming/scripts/frame-template.html

@@ -73,8 +73,8 @@
       flex-shrink: 0;
     }
     .header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
-    .header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
-    .header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
+    .header .status { font-size: 0.7rem; color: var(--status-color, var(--success)); display: flex; align-items: center; gap: 0.4rem; }
+    .header .status::before { content: ''; width: 6px; height: 6px; background: var(--status-color, var(--success)); border-radius: 50%; }
 
     .main { flex: 1; overflow-y: auto; }
     #frame-content { padding: 2rem; min-height: 100%; }

+ 77 - 5
skills/brainstorming/scripts/helper.js

@@ -1,26 +1,98 @@
 (function() {
+  const MIN_RECONNECT_MS = 500;
+  const MAX_RECONNECT_MS = 30000;
+  const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected
+
+  // Pure: next backoff delay (doubles, capped). Exported for unit tests.
+  function nextReconnectDelay(current, max) {
+    return Math.min(current * 2, max);
+  }
+  if (typeof module !== 'undefined' && module.exports) {
+    module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS };
+  }
+
+  // Everything below is browser-only; bail out when loaded in Node (tests).
+  if (typeof window === 'undefined') return;
+
   const WS_URL = 'ws://' + window.location.host;
   let ws = null;
   let eventQueue = [];
+  let reconnectDelay = MIN_RECONNECT_MS;
+  let reconnectTimer = null;
+  let disconnectedSince = null;
+  let everConnected = false;
+  let tombstoneShown = false;
+
+  // Reflect connection state in the frame's status pill (absent on full-doc screens).
+  function setStatus(state) {
+    const el = document.querySelector('.status');
+    if (!el) return;
+    const map = {
+      connected:    ['Connected',     'var(--success)'],
+      reconnecting: ['Reconnecting…', 'var(--warning)'],
+      disconnected: ['Disconnected',  'var(--error)']
+    };
+    const [text, color] = map[state] || map.disconnected;
+    el.textContent = text;
+    el.style.setProperty('--status-color', color);
+  }
+
+  // Self-styled so it works on framed and full-document screens alike.
+  function showTombstone() {
+    if (tombstoneShown) return;
+    tombstoneShown = true;
+    const el = document.createElement('div');
+    el.id = 'bs-tombstone';
+    el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' +
+      'align-items:center;justify-content:center;padding:2rem;text-align:center;' +
+      'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif';
+    el.innerHTML = '<div style="max-width:480px">' +
+      '<h2 style="margin:0 0 .5rem;font-weight:600">Companion paused</h2>' +
+      '<p style="margin:0;opacity:.85">This brainstorm companion has stopped. ' +
+      'Ask your coding agent to bring it back — this page reconnects automatically.</p></div>';
+    if (document.body) document.body.appendChild(el);
+  }
 
   function connect() {
+    if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
+    setStatus(everConnected ? 'reconnecting' : 'connected');
     ws = new WebSocket(WS_URL);
 
     ws.onopen = () => {
+      const recovered = tombstoneShown;
+      everConnected = true;
+      disconnectedSince = null;
+      reconnectDelay = MIN_RECONNECT_MS;
+      tombstoneShown = false;
+      setStatus('connected');
       eventQueue.forEach(e => ws.send(JSON.stringify(e)));
       eventQueue = [];
+      // Recovered from a tombstoned outage (e.g. the server restarted on the same
+      // port) — reload to pick up the restarted server's current screen.
+      if (recovered) window.location.reload();
     };
 
     ws.onmessage = (msg) => {
-      const data = JSON.parse(msg.data);
-      if (data.type === 'reload') {
-        window.location.reload();
-      }
+      let data;
+      try { data = JSON.parse(msg.data); } catch (e) { return; }
+      if (data.type === 'reload') window.location.reload();
     };
 
     ws.onclose = () => {
-      setTimeout(connect, 1000);
+      ws = null;
+      if (disconnectedSince === null) disconnectedSince = Date.now();
+      if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) {
+        setStatus('disconnected');
+        showTombstone();
+      } else {
+        setStatus('reconnecting');
+      }
+      reconnectTimer = setTimeout(connect, reconnectDelay);
+      reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS);
     };
+
+    // Let onclose own reconnection so we don't schedule it twice.
+    ws.onerror = () => { try { ws.close(); } catch (e) {} };
   }
 
   function sendEvent(event) {

+ 84 - 0
tests/brainstorm-server/helper.test.js

@@ -0,0 +1,84 @@
+/**
+ * Tests for the injected browser client (helper.js).
+ *
+ * helper.js runs in the browser, so its DOM behaviour is exercised live; here we
+ * unit-test the pure reconnect-backoff function it exports and assert that the
+ * reconnect / status / tombstone wiring is present.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const HELPER = path.join(__dirname, '../../skills/brainstorming/scripts/helper.js');
+
+const src = fs.readFileSync(HELPER, 'utf-8');
+
+// helper.js is browser code, and the repo is an ES module package, so a plain
+// require() won't surface its exports. Evaluate the source in a CommonJS sandbox
+// with no `window`, so only the exported pure helpers run (not the browser code).
+const moduleShim = { exports: {} };
+new Function('module', src)(moduleShim);
+const { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS } = moduleShim.exports;
+
+let passed = 0, failed = 0;
+function test(name, fn) {
+  try { fn(); console.log(`  PASS: ${name}`); passed++; }
+  catch (e) { console.log(`  FAIL: ${name}`); console.log(`    ${e.message}`); failed++; }
+}
+
+console.log('\n--- Backoff (pure) ---');
+
+test('doubles the delay each call', () => {
+  assert.strictEqual(nextReconnectDelay(500, 30000), 1000);
+  assert.strictEqual(nextReconnectDelay(1000, 30000), 2000);
+  assert.strictEqual(nextReconnectDelay(2000, 30000), 4000);
+});
+
+test('caps at the maximum', () => {
+  assert.strictEqual(nextReconnectDelay(20000, 30000), 30000);
+  assert.strictEqual(nextReconnectDelay(30000, 30000), 30000);
+});
+
+test('full progression from MIN caps at MAX and never exceeds it', () => {
+  const seq = [MIN_RECONNECT_MS];
+  let d = MIN_RECONNECT_MS;
+  for (let i = 0; i < 10; i++) { d = nextReconnectDelay(d, MAX_RECONNECT_MS); seq.push(d); }
+  assert.strictEqual(seq[0], 500);
+  assert.deepStrictEqual(seq.slice(0, 7), [500, 1000, 2000, 4000, 8000, 16000, 30000]);
+  assert(seq.every(v => v <= MAX_RECONNECT_MS), 'never exceeds max');
+  assert.strictEqual(seq[seq.length - 1], 30000, 'settles at the cap');
+});
+
+test('exposes sane constants', () => {
+  assert.strictEqual(MIN_RECONNECT_MS, 500);
+  assert.strictEqual(MAX_RECONNECT_MS, 30000);
+  assert(TOMBSTONE_AFTER_MS >= 5000, 'tombstone grace is at least a few seconds');
+});
+
+console.log('\n--- Wiring (source) ---');
+
+test('reflects all three connection states', () => {
+  assert(/Connected/.test(src) && /Reconnecting/.test(src) && /Disconnected/.test(src),
+    'should set Connected / Reconnecting / Disconnected status');
+  assert(src.includes("setProperty('--status-color'"), 'drives the status dot via --status-color');
+});
+
+test('renders a tombstone overlay when paused', () => {
+  assert(src.includes('bs-tombstone'), 'creates the tombstone element');
+  assert(/Companion paused/.test(src), 'tombstone explains the companion paused');
+});
+
+test('hardens reconnection (onerror, null socket, clears pending timer)', () => {
+  assert(src.includes('onerror'), 'handles onerror');
+  assert(/ws = null/.test(src), 'nulls the socket on close so sendEvent queues');
+  assert(src.includes('clearTimeout'), 'clears a pending reconnect before scheduling another');
+  assert(src.includes('nextReconnectDelay'), 'uses exponential backoff for reconnects');
+});
+
+test('reloads on recovery and on reload messages', () => {
+  assert(/location\.reload\(\)/.test(src), 'reloads to pick up restarted/updated content');
+});
+
+console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
+if (failed > 0) process.exit(1);

+ 1 - 1
tests/brainstorm-server/package.json

@@ -2,7 +2,7 @@
   "name": "brainstorm-server-tests",
   "version": "1.0.0",
   "scripts": {
-    "test": "node ws-protocol.test.js && node server.test.js && node lifecycle.test.js && bash stop-server.test.sh"
+    "test": "node ws-protocol.test.js && node helper.test.js && node server.test.js && node lifecycle.test.js && bash stop-server.test.sh"
   },
   "dependencies": {
     "ws": "^8.19.0"