-
Notifications
You must be signed in to change notification settings - Fork 0
Desktop: broker owns the /events SSE live mirror (Phase 3 complete) #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| // The /events live-mirror stream, owned by the broker (completes Phase 3: the live mirror moves off | ||
| // FastAPI). Mirrors dashboard_app.event_stream: a snapshot on connect, a fresh snapshot whenever the | ||
| // DB changes (SQLite PRAGMA data_version on a held connection) OR a file/liveness signal changes, plus | ||
| // a heartbeat. Stops on client disconnect. | ||
|
|
||
| const { DatabaseSync } = require('node:sqlite') | ||
| const fs = require('fs') | ||
| const path = require('path') | ||
| const store = require('./store') | ||
| const liveness = require('./liveness') | ||
|
|
||
| const POLL_MS = (() => { const v = Number(process.env.SMBOS_SSE_POLL); return Number.isFinite(v) && v > 0 ? v * 1000 : 1000 })() | ||
| const HEARTBEAT_MS = (() => { const v = Number(process.env.SMBOS_SSE_HEARTBEAT); return Number.isFinite(v) && v > 0 ? v * 1000 : 10000 })() | ||
|
|
||
| function sse(event, payload) { | ||
| return `event: ${event}\ndata: ${payload}\n\n` | ||
| } | ||
|
|
||
| // The five live-mirror frames, each the BARE array (dashboard_app._snapshot uses json.dumps(list), | ||
| // not a {key: list} wrapper -- the SSE shape differs from the GET endpoints' wrapped shape). | ||
| function snapshot(sopDir) { | ||
| return [ | ||
| sse('plate', JSON.stringify(store.plate(sopDir))), | ||
| sse('inflight', JSON.stringify(liveness.inflightWithLiveness(store, sopDir))), | ||
| sse('pending', JSON.stringify(store.pending(sopDir))), | ||
| sse('queue', JSON.stringify(store.queue(sopDir))), | ||
| sse('runs', JSON.stringify(liveness.runsWithLiveness(store, sopDir))), | ||
| ] | ||
| } | ||
|
|
||
| // [name, mtime-ns] over dir/*.md, sorted, tolerant of a file vanishing between readdir and stat. | ||
| // Mirrors dashboard_app._dir_mtime_sig: pending/ and queue/ change with no DB write, so data_version | ||
| // misses them. Nanosecond mtime (like Python st_mtime_ns) catches two sub-millisecond rewrites. | ||
| function dirSig(dir) { | ||
| const sig = [] | ||
| let names | ||
| try { names = fs.readdirSync(dir) } catch (_) { return [] } | ||
| for (const f of names) { | ||
| if (!f.endsWith('.md')) continue | ||
| try { sig.push([f, String(fs.statSync(path.join(dir, f), { bigint: true }).mtimeNs)]) } catch (_) { /* vanished */ } | ||
| } | ||
| return sig.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) | ||
| } | ||
|
|
||
| // Change signals that a DB write wouldn't move: run liveness, session liveness, and the file-based | ||
| // pending/ + queue/ dirs. verify=false keeps the once-a-second poll cheap (no ps fork) -- it catches | ||
| // the common pid-gone flip; the rendered frame does the full check. Mirrors the _signals() tuple. | ||
| // JSON-encoded structured values (not delimiter-joined) so an id/filename can't collide on a separator. | ||
| function signals(sopDir) { | ||
| return JSON.stringify([ | ||
| liveness.activeRuns(sopDir, false).map((r) => [r.sop, r.state]).sort(), | ||
| liveness.inflightWithLiveness(store, sopDir, false).map((t) => [t.id, t.state]), | ||
| dirSig(path.join(sopDir, 'pending')), | ||
| dirSig(path.join(sopDir, 'queue')), | ||
| ]) | ||
| } | ||
|
|
||
| // Run the SSE loop on an already-Host-guarded, token-checked GET /events request. | ||
| function createEventStream(req, res, sopDir) { | ||
| res.writeHead(200, { | ||
| 'content-type': 'text/event-stream', | ||
| 'cache-control': 'no-cache', | ||
| connection: 'keep-alive', | ||
| 'x-accel-buffering': 'no', // don't let any proxy buffer the stream | ||
| }) | ||
| // One held read-only connection so data_version is comparable across polls (it's per-connection). | ||
| let db = null | ||
| let timer = null | ||
| const cleanup = () => { | ||
| if (timer) { clearInterval(timer); timer = null } | ||
| if (db) { try { db.close() } catch (_) { /* already gone */ } db = null } | ||
| } | ||
| // Attach cleanup BEFORE the first store/liveness read: if a read throws (corrupt/mid-recreate | ||
| // state.db), we must still release the connection + timer and not leak or crash the broker. | ||
| res.on('close', cleanup) | ||
| res.on('error', cleanup) | ||
|
|
||
| // Open lazily and re-open on error, so a state.db that doesn't exist yet at connect (fresh SOP dir) | ||
| // or is recreated mid-stream gets picked up -- otherwise data_version stays 0 forever and DB-only | ||
| // changes (a new waiting task) never re-emit until the client reconnects. | ||
| const ensureDb = () => { | ||
| if (db) return | ||
| try { db = new DatabaseSync(path.join(sopDir, 'state.db'), { readOnly: true }); db.exec('PRAGMA busy_timeout = 2000') } catch (_) { db = null } | ||
| } | ||
| const dataVersion = () => { | ||
| ensureDb() | ||
| try { return db ? db.prepare('PRAGMA data_version').get().data_version : 0 } catch (_) { db = null; return 0 } | ||
| } | ||
|
|
||
| try { | ||
|
|
||
| let lastDv = dataVersion() | ||
| let lastSig = signals(sopDir) | ||
| for (const frame of snapshot(sopDir)) res.write(frame) // initial snapshot | ||
|
|
||
| let sinceBeat = 0 | ||
| timer = setInterval(() => { | ||
| // Guard the WHOLE tick: dataVersion, signals, AND the snapshot re-emit. A synchronous store/ | ||
| // liveness read error (a mid-stream corrupt/recreating state.db) must not escape the interval | ||
| // callback and crash the broker -- skip the tick and try again next poll. | ||
| try { | ||
| sinceBeat += POLL_MS | ||
| const dv = dataVersion() | ||
| const sig = signals(sopDir) | ||
| if (dv !== lastDv || sig !== lastSig) { | ||
| lastDv = dv; lastSig = sig | ||
| for (const frame of snapshot(sopDir)) res.write(frame) // all frames on any change | ||
| } | ||
| if (sinceBeat >= HEARTBEAT_MS) { | ||
| sinceBeat = 0 | ||
| res.write(sse('heartbeat', JSON.stringify({ ts: new Date().toISOString() }))) | ||
| } | ||
| } catch (_) { /* transient read error: skip this tick */ } | ||
| }, POLL_MS) | ||
| } catch (_) { | ||
| // the initial snapshot/signals read failed: tear down cleanly instead of leaking + throwing | ||
| cleanup() | ||
| try { res.end() } catch (_) { /* already closed */ } | ||
| } | ||
| } | ||
|
|
||
| module.exports = { createEventStream, snapshot, signals } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| const test = require('node:test') | ||
| const assert = require('node:assert') | ||
| const fs = require('fs') | ||
| const os = require('os') | ||
| const path = require('path') | ||
| const { DatabaseSync } = require('node:sqlite') | ||
| const sse = require('./sse') | ||
|
|
||
| function seeded() { | ||
| const d = fs.mkdtempSync(path.join(os.tmpdir(), 'smbos-sse-')) | ||
| const db = new DatabaseSync(path.join(d, 'state.db')) | ||
| db.exec(`CREATE TABLE task (id INTEGER PRIMARY KEY, domain TEXT, kind TEXT, subject TEXT, status TEXT, | ||
| priority INTEGER DEFAULT 0, source_ref TEXT, created_at TEXT, updated_at TEXT)`) | ||
| db.exec(`CREATE TABLE run (id INTEGER PRIMARY KEY, sop_id TEXT, result TEXT, started_at TEXT)`) | ||
| db.exec("INSERT INTO task(id,subject,status,created_at,updated_at) VALUES(1,'waiting one','waiting','t','t')") | ||
| db.close() | ||
| return d | ||
| } | ||
|
|
||
| test('snapshot: five frames, each a BARE array (not a {key: ...} wrapper)', () => { | ||
| const d = seeded() | ||
| const frames = sse.snapshot(d) | ||
| assert.equal(frames.length, 5) | ||
| for (const ev of ['plate', 'inflight', 'pending', 'queue', 'runs']) { | ||
| const frame = frames.find((f) => f.startsWith(`event: ${ev}\n`)) | ||
| assert.ok(frame, `has a ${ev} frame`) | ||
| assert.ok(frame.endsWith('\n\n'), 'frame is terminated') | ||
| const data = JSON.parse(frame.match(/data: (.*)/)[1]) | ||
| assert.ok(Array.isArray(data), `${ev} data is a bare array`) | ||
| } | ||
| // the seeded waiting task shows up in the plate frame | ||
| const plate = JSON.parse(frames.find((f) => f.startsWith('event: plate\n')).match(/data: (.*)/)[1]) | ||
| assert.equal(plate[0].subject, 'waiting one') | ||
| }) | ||
|
|
||
| test('snapshot is resilient to a missing state.db: five empty-array frames, no throw', () => { | ||
| const d = fs.mkdtempSync(path.join(os.tmpdir(), 'smbos-sse-')) // a fresh SOP dir, no state.db yet | ||
| const frames = sse.snapshot(d) | ||
| assert.equal(frames.length, 5) | ||
| for (const ev of ['plate', 'inflight', 'pending', 'queue', 'runs']) { | ||
| const data = JSON.parse(frames.find((f) => f.startsWith(`event: ${ev}\n`)).match(/data: (.*)/)[1]) | ||
| assert.deepEqual(data, []) // the stream still opens; the held connection reopens when the db appears | ||
| } | ||
| }) | ||
|
|
||
| test('signals: change when a pending/queue file appears (data_version is blind to files)', () => { | ||
| const d = seeded() | ||
| const s0 = sse.signals(d) | ||
| fs.mkdirSync(path.join(d, 'pending')) | ||
| fs.writeFileSync(path.join(d, 'pending', 'x.md'), '---\nstatus: pending\n---\n# X\n') | ||
| const s1 = sse.signals(d) | ||
| assert.notEqual(s0, s1) // a parked result appearing must move the signal | ||
| fs.mkdirSync(path.join(d, 'queue')) | ||
| fs.writeFileSync(path.join(d, 'queue', 'q.md'), '---\nstatus: queued\n---\n') | ||
| assert.notEqual(s1, sse.signals(d)) // a queued run appearing too | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.