mirror of
https://github.com/obra/superpowers.git
synced 2026-06-12 13:49:05 +08:00
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
This commit is contained in:
@@ -73,8 +73,8 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
|
.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 { 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(--success); border-radius: 50%; }
|
.header .status::before { content: ''; width: 6px; height: 6px; background: var(--status-color, var(--success)); border-radius: 50%; }
|
||||||
|
|
||||||
.main { flex: 1; overflow-y: auto; }
|
.main { flex: 1; overflow-y: auto; }
|
||||||
#frame-content { padding: 2rem; min-height: 100%; }
|
#frame-content { padding: 2rem; min-height: 100%; }
|
||||||
|
|||||||
@@ -1,26 +1,98 @@
|
|||||||
(function() {
|
(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;
|
const WS_URL = 'ws://' + window.location.host;
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let eventQueue = [];
|
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() {
|
function connect() {
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
setStatus(everConnected ? 'reconnecting' : 'connected');
|
||||||
ws = new WebSocket(WS_URL);
|
ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
ws.onopen = () => {
|
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.forEach(e => ws.send(JSON.stringify(e)));
|
||||||
eventQueue = [];
|
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) => {
|
ws.onmessage = (msg) => {
|
||||||
const data = JSON.parse(msg.data);
|
let data;
|
||||||
if (data.type === 'reload') {
|
try { data = JSON.parse(msg.data); } catch (e) { return; }
|
||||||
window.location.reload();
|
if (data.type === 'reload') window.location.reload();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
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) {
|
function sendEvent(event) {
|
||||||
|
|||||||
84
tests/brainstorm-server/helper.test.js
Normal file
84
tests/brainstorm-server/helper.test.js
Normal file
@@ -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);
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "brainstorm-server-tests",
|
"name": "brainstorm-server-tests",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"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": {
|
"dependencies": {
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user