#!/usr/bin/env node // land-work's read of the bus (row 116, #1610). Every query land-work runs // against bus.sqlite is in this file, and every one is a SELECT. // // node scripts/land-work/read.mjs // // It opens /bus/bus.sqlite with readOnly: true and checks the // schema the way the Store does before reading anything. Output is one // "keyvalue" line per fact land-work needs, or one // "refusereasondetail" line and exit 2. A missing or unsafe // database or business file exits 3. // // Kept in step with packages/bus/src/store.mjs, which this imports from: // the schema_version literal '3b' below is a copy of the Store's. A schema // bump changes it there, and until it changes here every read refuses // bus-schema. import { DatabaseSync } from 'node:sqlite'; import { readFileSync, lstatSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createHash } from 'node:crypto'; import { schemaDigest, SCHEMA_SHA256 } from '../../packages/bus/src/store.mjs'; const SCHEMA_VERSION = '3b'; const MERGE = 'git.merge.protected'; const WORK_REF = /^work\/vikunja-([1-9][0-9]*)-([1-9][0-9]*)$/; const ANY_WORK_REF = /\bwork\/[A-Za-z0-9._/-]*[A-Za-z0-9_]/g; const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/; class Refusal extends Error { constructor(reason, detail) { super(detail); this.reason = reason; } } const refuse = (reason, detail) => { throw new Refusal(reason, detail); }; const json = (text) => { try { return JSON.parse(text); } catch { return null; } }; export function openBus(dataRoot) { const path = join(dataRoot, 'bus', 'bus.sqlite'); let s; try { s = lstatSync(path); } catch { throw Object.assign(new Error(`no bus database at ${path}`), { exit: 3 }); } if (s.isSymbolicLink() || !s.isFile() || s.uid !== process.getuid()) throw Object.assign(new Error(`unsafe bus database ${path}`), { exit: 3 }); const db = new DatabaseSync(path, { readOnly: true, timeout: 5000 }); try { checkSchema(db); } catch (e) { db.close(); throw e; } return db; } // The Store's own open check (store.mjs), against a reference built from the // same schema.sql the import has already verified against SCHEMA_SHA256. export function checkSchema(db) { const ddl = readFileSync(new URL('../../packages/bus/schema.sql', import.meta.url), 'utf8'); if (createHash('sha256').update(ddl).digest('hex') !== SCHEMA_SHA256) refuse('bus-schema', 'packages/bus/schema.sql does not match SCHEMA_SHA256'); const reference = new DatabaseSync(':memory:'); reference.exec(ddl); const expected = schemaDigest(reference); reference.close(); const meta = (key) => db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value; let digest; try { digest = schemaDigest(db); } catch (e) { refuse('bus-schema', `cannot read the schema: ${e.message}`); } if (digest !== expected) refuse('bus-schema', `schema digest ${digest.slice(0, 12)}, the store expects ${expected.slice(0, 12)}`); if (meta('schema_version') !== SCHEMA_VERSION || meta('schema_digest') !== expected) refuse('bus-schema', `meta says version ${meta('schema_version')}, digest ${String(meta('schema_digest')).slice(0, 12)}`); } // The queries. Each takes the business so one bus can't answer for another. const queries = { decision: (db, b, id) => db.prepare('SELECT * FROM decisions WHERE business = ? AND id = ?').get(b, id), raise: (db, b, id) => db .prepare( `SELECT * FROM events WHERE business = ? AND kind = 'action.allowed' AND subject = ? AND json_extract(body, '$.operation') = 'decision.raise' AND json_extract(body, '$.decision') = ? ORDER BY seq`, ) .all(b, id, id), closes: (db, id) => db .prepare( `SELECT * FROM decision_events WHERE decision = ? AND op IN ('resolved','withdrawn','expired') ORDER BY seq`, ) .all(id), answer: (db, b, id) => db .prepare( `SELECT * FROM events WHERE business = ? AND kind = 'human.input' AND subject = ? AND json_extract(body, '$.kind') = 'answer' AND json_extract(body, '$.decision') = ? AND json_extract(body, '$.op') = 'resolved' ORDER BY seq`, ) .all(b, id, id), later: (db, b, d) => db .prepare( `SELECT * FROM decisions WHERE business = ? AND id <> ? AND (supersedes = ? OR (seq > ? AND action = ?)) ORDER BY seq`, ) .all(b, d.id, d.id, d.seq, MERGE), verdict: (db, b, task) => db .prepare(`SELECT * FROM events WHERE business = ? AND kind = 'review.verdict' AND subject = ? ORDER BY seq DESC LIMIT 1`) .get(b, task), }; export function readLanding(db, business, id, base) { const b = business.id; const d = queries.decision(db, b, id); const raises = queries.raise(db, b, id); if (!d || raises.length !== 1) refuse('decision-not-found', `no decision ${id} with one raise in business ${b}`); const raise = json(raises[0].body) ?? {}; if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`); const closes = queries.closes(db, id); if (closes.length !== 1 || closes[0].op !== 'resolved') refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open'); const res = closes[0]; const answers = queries.answer(db, b, id); const answer = answers.length === 1 ? answers[0] : null; if ( res.by !== business.human || res.via !== 'cli' || !answer || answer.actor_role !== null || answer.actor_run !== null || json(answer.body)?.choice !== res.choice ) refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`); const options = json(d.options) ?? []; const merge = options.find((o) => o?.key === raise.approvalChoice); if (!raise.approvalChoice || !merge) refuse('not-merge-option', 'the raise names no approval option'); if (res.choice !== raise.approvalChoice) refuse('not-merge-option', `chose ${res.choice}, the merge option is ${raise.approvalChoice}`); // The work ref comes from the question: decisions don't carry it. const named = [...new Set(d.question.match(ANY_WORK_REF) ?? [])]; const m = named.length === 1 ? WORK_REF.exec(named[0]) : null; if (!m) refuse('work-ref', `the question names ${named.join(', ') || 'no work ref'}`); const workRef = named[0]; const task = `vikunja:${m[1]}/${m[2]}`; for (const ref of [d.task_ref, raise.task_ref]) if (ref != null && ref !== task) refuse('work-ref', `the decision cites ${ref}, the question ${workRef}`); // The target is the raise's, and only refactor ever lands. if (raise.target !== 'refactor' || raise.target !== base) refuse('target-branch', `the raise targets ${raise.target}, the base is ${base}`); const later = queries.later(db, b, d).filter( (r) => r.supersedes === id || r.task_ref === task || (r.question.match(ANY_WORK_REF) ?? []).includes(workRef), ); if (later.length) refuse('superseded', `later decision ${later.map((r) => r.id).join(', ')} on ${workRef}`); const v = queries.verdict(db, b, task); if (!v) refuse('no-verdict', `no review.verdict for ${task}`); const vb = json(v.body) ?? {}; if (vb.verdict !== 'approve' || vb.task_ref !== task || !OID.test(vb.commit ?? '')) refuse('not-approved', `latest verdict ${v.id} is ${vb.verdict} on ${vb.commit}`); return { task_ref: task, work_ref: workRef, commit: vb.commit, verdict: v.id, project: d.project ?? '', choice: res.choice, by: res.by, resolved_at: res.at, subject: String(merge.text ?? '').split('\n')[0].trim(), }; } function main([dataRoot, businessFile, id, base]) { const out = (k, v) => process.stdout.write(`${k}\t${String(v ?? '').replace(/[\t\n\r]/g, ' ')}\n`); let business; try { business = JSON.parse(readFileSync(businessFile, 'utf8')); } catch (e) { process.stderr.write(`land-work: cannot read the business file ${businessFile}: ${e.message}\n`); return 3; } if (typeof business?.id !== 'string' || typeof business?.human !== 'string' || !business.human) { process.stderr.write(`land-work: ${businessFile} names no business id and human\n`); return 3; } let db; try { db = openBus(dataRoot); for (const [k, v] of Object.entries(readLanding(db, business, id, base))) out(k, v); return 0; } catch (e) { if (e instanceof Refusal) { out('refuse', `${e.reason}\t${e.message}`); return 2; } process.stderr.write(`land-work: ${e.message}\n`); return e.exit ?? 1; } finally { db?.close(); } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = main(process.argv.slice(2));