Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions desktop/broker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
78 changes: 77 additions & 1 deletion desktop/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -167,4 +243,4 @@ function parseFrontmatter(text) {
return out
}

module.exports = { plate, queue, procedures }
module.exports = { plate, queue, procedures, pending }
36 changes: 36 additions & 0 deletions desktop/store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()), [])
})

Loading