Merge pull request 'fix: keep sse streams alive behind proxies' (#34) from codex/issue-17-sse-heartbeat into codex/production-intelligence-terminal
All checks were successful
Build / test-and-image (push) Successful in 25s
Release Dry Run / release-dry-run (push) Successful in 13s
Codex Template Compliance / template-compliance (push) Successful in 5s

This commit was merged in pull request #34.
This commit is contained in:
2026-05-17 18:51:04 +00:00
5 changed files with 43 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ STALE_ALERT_COOLDOWN_MINUTES=60
DASHBOARD_URL= DASHBOARD_URL=
TERMINAL_ACTIONS_ENABLED=true TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN= SWEEP_TOKEN=
SSE_HEARTBEAT_INTERVAL_MS=25000
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000 TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
TERMINAL_ACTION_RATE_LIMIT_MAX=10 TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard BRIEF_VERBOSITY=standard

View File

@@ -134,6 +134,7 @@ STALE_ALERT_COOLDOWN_MINUTES=60
DASHBOARD_URL=https://intelligence.example.internal DASHBOARD_URL=https://intelligence.example.internal
TERMINAL_ACTIONS_ENABLED=true TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN= SWEEP_TOKEN=
SSE_HEARTBEAT_INTERVAL_MS=25000
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000 TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
TERMINAL_ACTION_RATE_LIMIT_MAX=10 TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard 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. - 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. - 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 #### 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: 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:

View File

@@ -29,6 +29,7 @@ export default {
terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', !!process.env.SWEEP_TOKEN || process.env.NODE_ENV !== 'production'), terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', !!process.env.SWEEP_TOKEN || process.env.NODE_ENV !== 'production'),
terminalActionRateLimitWindowMs: intEnv('TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS', 60_000), terminalActionRateLimitWindowMs: intEnv('TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS', 60_000),
terminalActionRateLimitMax: intEnv('TERMINAL_ACTION_RATE_LIMIT_MAX', 10), terminalActionRateLimitMax: intEnv('TERMINAL_ACTION_RATE_LIMIT_MAX', 10),
sseHeartbeatIntervalMs: intEnv('SSE_HEARTBEAT_INTERVAL_MS', 25000),
llm: { llm: {
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok

View File

@@ -370,10 +370,24 @@ app.get('/events', (req, res) => {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'Connection': 'keep-alive', 'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
}); });
res.write('retry: 10000\n');
res.write('data: {"type":"connected"}\n\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); sseClients.add(res);
req.on('close', () => sseClients.delete(res)); req.on('close', () => {
clearInterval(heartbeat);
sseClients.delete(res);
});
}); });
function broadcast(data) { function broadcast(data) {

View File

@@ -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', () => { test('intelligence store defines durable memory and prediction lifecycle tables', () => {
const store = readFileSync(new URL('../lib/intelligence-store.mjs', import.meta.url), 'utf8'); const store = readFileSync(new URL('../lib/intelligence-store.mjs', import.meta.url), 'utf8');
assert.match(store, /CREATE TABLE IF NOT EXISTS events/); assert.match(store, /CREATE TABLE IF NOT EXISTS events/);