diff --git a/.env.example b/.env.example index 559f763..ca66862 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ STALE_ALERT_COOLDOWN_MINUTES=60 DASHBOARD_URL= TERMINAL_ACTIONS_ENABLED=true SWEEP_TOKEN= +SSE_HEARTBEAT_INTERVAL_MS=25000 TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000 TERMINAL_ACTION_RATE_LIMIT_MAX=10 BRIEF_VERBOSITY=standard diff --git a/README.md b/README.md index fffd910..6c311c9 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ STALE_ALERT_COOLDOWN_MINUTES=60 DASHBOARD_URL=https://intelligence.example.internal TERMINAL_ACTIONS_ENABLED=true SWEEP_TOKEN= +SSE_HEARTBEAT_INTERVAL_MS=25000 TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000 TERMINAL_ACTION_RATE_LIMIT_MAX=10 BRIEF_VERBOSITY=standard @@ -234,6 +235,20 @@ Retention, backup, and privacy expectations: - Do not commit `runs/` or `.env`. API credentials stay in `.env`; memory stores derived observations, not secrets. - If you expose the dashboard through a reverse proxy, protect Terminal Actions and memory queries behind your normal authentication boundary. +#### Reverse Proxy SSE + +The dashboard receives live sweep updates from `GET /events` using Server-Sent Events. The server sends `retry: 10000` reconnect guidance and lightweight heartbeat comments every `SSE_HEARTBEAT_INTERVAL_MS` milliseconds so reverse proxies do not close an otherwise idle stream between 15-minute sweeps. + +Recommended proxy settings: + +| Proxy | Setting | +| --- | --- | +| Pangolin / Traefik-style frontends | Keep response streaming enabled and set idle timeouts above `SSE_HEARTBEAT_INTERVAL_MS`. | +| Nginx | Disable proxy buffering for `/events`, keep `proxy_read_timeout` above the heartbeat interval, and preserve `Connection: keep-alive`. | +| Cloudflare-style proxies | Keep the heartbeat below common idle cutoffs; the default 25s is intentionally conservative. | + +If you raise the heartbeat interval, keep it shorter than the lowest idle timeout in the proxy chain. + #### Scenario Watchlist Intelligence Terminal can track operator hypotheses across sweeps with a runtime scenario file at `runs/scenarios.json`. On first run, the server creates three disabled starter examples: diff --git a/crucix.config.mjs b/crucix.config.mjs index 484109b..b056dfa 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -29,6 +29,7 @@ export default { terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', !!process.env.SWEEP_TOKEN || process.env.NODE_ENV !== 'production'), terminalActionRateLimitWindowMs: intEnv('TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS', 60_000), terminalActionRateLimitMax: intEnv('TERMINAL_ACTION_RATE_LIMIT_MAX', 10), + sseHeartbeatIntervalMs: intEnv('SSE_HEARTBEAT_INTERVAL_MS', 25000), llm: { provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok diff --git a/server.mjs b/server.mjs index d9e0a3f..b2c38ce 100644 --- a/server.mjs +++ b/server.mjs @@ -370,10 +370,24 @@ app.get('/events', (req, res) => { 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', + 'X-Accel-Buffering': 'no', }); + res.write('retry: 10000\n'); res.write('data: {"type":"connected"}\n\n'); + const heartbeatMs = Math.max(5000, config.sseHeartbeatIntervalMs || 25000); + const heartbeat = setInterval(() => { + try { + res.write(`: heartbeat ${new Date().toISOString()}\n\n`); + } catch { + clearInterval(heartbeat); + sseClients.delete(res); + } + }, heartbeatMs); sseClients.add(res); - req.on('close', () => sseClients.delete(res)); + req.on('close', () => { + clearInterval(heartbeat); + sseClients.delete(res); + }); }); function broadcast(data) { diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs index 95aaab2..f00752b 100644 --- a/test/fetch-utils.test.mjs +++ b/test/fetch-utils.test.mjs @@ -101,6 +101,17 @@ test('safeFetchText returns text and byte count', async () => { } }); +test('SSE endpoint sends reconnect guidance and clears heartbeat timer', () => { + const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8'); + const config = readFileSync(new URL('../crucix.config.mjs', import.meta.url), 'utf8'); + assert.match(config, /sseHeartbeatIntervalMs/); + assert.match(server, /retry: 10000\\n/); + assert.match(server, /setInterval\(\(\) =>/); + assert.match(server, /: heartbeat/); + assert.match(server, /clearInterval\(heartbeat\)/); + assert.match(server, /X-Accel-Buffering/); +}); + test('intelligence store defines durable memory and prediction lifecycle tables', () => { const store = readFileSync(new URL('../lib/intelligence-store.mjs', import.meta.url), 'utf8'); assert.match(store, /CREATE TABLE IF NOT EXISTS events/);