scripts: land-work turns a resolved merge decision into a gated local commit (row 116, #1610)

Candidate manifest 8b4ab31014a124ec0e3abba44238976d6f4c9af48cbdb36c2c77d18f0137f888
from Darkwing's round 2 packet (0a4076e8e), on BASE 1223a0f37. No
manifest path changed between BASE and the landing target. Filbert
approved round 2 in 28468, closing C1 and D1 from round 1 (28464).
Sage reran the full gate at the landing target with the candidate
applied. The replay acceptance follows ruling sage-0204.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 14:24:57 -05:00
co-authored by Claude Opus 5.5
parent 922cb8170b
commit 1f2ac5b848
6 changed files with 1021 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
#!/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 <dataRoot> <business-file> <decision-id> <base>
//
// It opens <dataRoot>/bus/bus.sqlite with readOnly: true and checks the
// schema the way the Store does before reading anything. Output is one
// "key<TAB>value" line per fact land-work needs, or one
// "refuse<TAB>reason<TAB>detail" 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));