Candidate manifest 8b4ab31014a124ec0e3abba44238976d6f4c9af48cbdb36c2c77d18f0137f888 from Darkwing's round 2 packet (0a4076e8e), on BASE1223a0f37. 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]>
225 lines
8.6 KiB
JavaScript
225 lines
8.6 KiB
JavaScript
#!/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));
|