diff --git a/desktop/broker.js b/desktop/broker.js index fe81952..dfbb652 100644 --- a/desktop/broker.js +++ b/desktop/broker.js @@ -17,12 +17,14 @@ const { token } = require('./resolve') // GET endpoints the broker answers itself, from the local store, in FastAPI's response shape // (parity-tested against the live FastAPI). Cost/autonomy numbers ride as JSON numbers, so a whole // dollar serializes as `1` here vs `1.0` in Python -- semantically identical after JSON.parse (what -// the SPA does), so the parity check compares parsed values, not bytes. settings/pending follow -// later: settings reads an environment-detected terminal, so it isn't a pure static read yet. +// the SPA does), so the parity check compares parsed values, not bytes. Still forwarded: settings +// (reads an environment-detected terminal, not a pure static read) and the liveness-bearing reads +// (inflight/runs) + the SSE live mirror (Phase 5 flock/pid migration). const SERVED = { '/api/plate': (sopDir) => ({ plate: store.plate(sopDir) }), '/api/queue': (sopDir) => ({ queue: store.queue(sopDir) }), '/api/procedures': (sopDir) => ({ procedures: store.procedures(sopDir) }), + '/api/pending': (sopDir) => ({ pending: store.pending(sopDir) }), } // Constant-time token compare (the broker now gates the reads it serves, mirroring FastAPI's check). diff --git a/desktop/store.js b/desktop/store.js index 45bbf4f..8123d05 100644 --- a/desktop/store.js +++ b/desktop/store.js @@ -152,6 +152,82 @@ function procedures(sopDir) { return out.sort((a, b) => (a.title.toLowerCase() < b.title.toLowerCase() ? -1 : a.title.toLowerCase() > b.title.toLowerCase() ? 1 : 0)) } +// A parked result's `## Candidates` fenced-json block as [{title,url,note}] (mirrors +// generate_dashboard.parse_candidates). Empty on absent/malformed; never throws. +function parseCandidates(content) { + const m = /^##\s+Candidates\s*$[\s\S]*?```json\s*([\s\S]*?)```/m.exec(content) + if (!m) return [] + let data + // JSON.parse rejects bare NaN/Infinity (which aren't valid JSON) where Python json.loads accepts + // them, so such a non-standard block yields [] here vs a parsed list in Python. Accepted divergence: + // candidate fields are contract-typed strings, and regex-stripping NaN would corrupt a string value + // that legitimately contains "NaN". + try { data = JSON.parse(m[1]) } catch (_) { return [] } + if (!Array.isArray(data)) return [] + const str = (v) => (typeof v === 'string' ? v : '') // non-string field -> '' (Python str() of a + const cut = (v, n) => Array.from(v).slice(0, n).join('') // list/dict is junk too); truncate by CODE POINT (Python [:n]) + const out = [] + for (const d of data) { + if (d && typeof d === 'object' && !Array.isArray(d)) { + out.push({ + title: cut(str(d.title) || str(d.url) || 'Item', 140), + url: cut(str(d.url), 500), + note: cut(str(d.note), 300), + }) + } + } + return out +} + +// First .md file named `${sopId}.md` anywhere under sopDir (mirrors Python rglob -- NOT pruned, so it +// finds a source SOP even outside the iter_sops set). null if none. +function findSopFile(dir, name) { + let entries + try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (_) { return null } + for (const e of entries) if (e.isFile() && e.name === name) return path.join(dir, e.name) + for (const e of entries) { + if (e.isDirectory()) { const f = findSopFile(path.join(dir, e.name), name); if (f) return f } + } + return null +} + +// The first `next:` SOP id declared by sopId (mirrors generate_dashboard.sop_next). +function sopNext(sopDir, sopId) { + if (!sopId) return null + const p = findSopFile(sopDir, `${sopId}.md`) + if (!p) return null + let meta + try { meta = parseFrontmatter(readTextStrict(p)) } catch (_) { return null } + return String(meta.next || '').split(',')[0].trim() || null +} + +// Parked results awaiting a decision (status: pending), for 'Needs your eyes'. Mirrors +// dashboard_app._pending over generate_dashboard.collect_pending: human title from the body's +// '# Pending: X' heading, the candidate list, and the downstream SOP for an apply. +function pending(sopDir) { + const pdir = path.join(sopDir, 'pending') + let files + try { files = fs.readdirSync(pdir).filter((f) => f.endsWith('.md')).sort() } catch (_) { return [] } + const out = [] + for (const f of files) { + let content + try { content = readTextStrict(path.join(pdir, f)) } catch (_) { continue } // skip unreadable/non-utf8 + const meta = parseFrontmatter(content) + if (String(meta.status || '').trim() !== 'pending') continue + const m = /^#\s+(?:Pending:\s*)?(.+)$/m.exec(content) + const title = m ? m[1].trim() : (meta.sop || f) + const candidates = parseCandidates(content) + out.push({ + file: f, + sop: meta.sop || '', + title, + candidates, + next: candidates.length ? sopNext(sopDir, meta.sop) : null, + }) + } + return out +} + // Minimal frontmatter parser for the simple `key: value` lines we read (matches smbos_lib for these). function parseFrontmatter(text) { const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text) @@ -167,4 +243,4 @@ function parseFrontmatter(text) { return out } -module.exports = { plate, queue, procedures } +module.exports = { plate, queue, procedures, pending } diff --git a/desktop/store.test.js b/desktop/store.test.js index 0b27529..5cd2e97 100644 --- a/desktop/store.test.js +++ b/desktop/store.test.js @@ -88,3 +88,39 @@ test('procedures/queue skip a non-UTF-8 file (parity: Python read_text raises -> assert.deepEqual(store.queue(d), []) // bad queue file skipped }) +test('pending: status-pending files, with title, candidates, and next', () => { + const d = tmpSop(); fs.mkdirSync(path.join(d, 'pending')); fs.mkdirSync(path.join(d, 'ops')) + fs.writeFileSync(path.join(d, 'ops', 'dedupe.md'), '---\nid: dedupe\nnext: apply-dedupe, other\n---\n') + fs.writeFileSync(path.join(d, 'pending', 'p1.md'), + '---\nstatus: pending\nsop: dedupe\n---\n# Pending: Review 3 dupes\n\n## Candidates\n```json\n' + + '[{"title":"A","url":"http://a","note":"n"},{"url":"http://b"}]\n```\n') + fs.writeFileSync(path.join(d, 'pending', 'p2.md'), '---\nstatus: approved\nsop: x\n---\n# Done\n') // not pending + const items = store.pending(d) + assert.equal(items.length, 1) + assert.equal(items[0].file, 'p1.md') + assert.equal(items[0].sop, 'dedupe') + assert.equal(items[0].title, 'Review 3 dupes') // '# Pending: ' prefix stripped + assert.deepEqual(items[0].candidates, [ + { title: 'A', url: 'http://a', note: 'n' }, + { title: 'http://b', url: 'http://b', note: '' }, // title falls back to url + ]) + assert.equal(items[0].next, 'apply-dedupe') // first id of the source SOP's next: list +}) + +test('pending candidates: a non-string field coerces to empty; title falls back to url', () => { + const d = tmpSop(); fs.mkdirSync(path.join(d, 'pending')) + fs.writeFileSync(path.join(d, 'pending', 'p.md'), + '---\nstatus: pending\nsop: s\n---\n# T\n## Candidates\n```json\n' + + '[{"title":["a","b"],"url":"http://u","note":42}]\n```\n') // title is a list, note a number + assert.deepEqual(store.pending(d)[0].candidates, [{ title: 'http://u', url: 'http://u', note: '' }]) +}) + +test('pending: no candidates -> next null; plain heading -> title; no dir -> empty', () => { + const d = tmpSop(); fs.mkdirSync(path.join(d, 'pending')) + fs.writeFileSync(path.join(d, 'pending', 'p.md'), '---\nstatus: pending\nsop: s\n---\n# Just a note\n') + const items = store.pending(d) + assert.equal(items[0].title, 'Just a note') + assert.deepEqual(items[0].candidates, []); assert.equal(items[0].next, null) + assert.deepEqual(store.pending(tmpSop()), []) +}) +