import { decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs'; import { autonomyEvent } from './autonomy-state.mjs'; import { decisionResolution } from './autonomy-state.mjs'; // The checks a landing makes on its merge decision and the task's review // (row 137, #1631). Two callers: scripts/land-work/read.mjs, which lands on // canonical refactor from a read-only copy of the bus, and the broker's // checkLand and recordLand, which land on a working repository's main. // Moved here from read.mjs (row 116, #1610); every query is a SELECT. // // readLanding(q, business, id, { into, arbiter }) runs over q.get(sql, ...params) // and q.all(sql, ...params). It throws a LandingRefusal with land-work's reason // codes, or returns what the landing needs. // // `into` is the branch the caller lands on, and the raise's target must // match it: 'refactor' for land-work, the task ref for a working repository's // main. So a decision raised for one can't be used for the other, and a // decision raised by an agent for its task never names refactor. // // Without `arbiter`, the human must have resolved the decision through the // CLI: land-work leaves it out, because canonical refactor stays the lead's // landing (PRD 1.5). The broker passes `arbiter: true` (REQ-DEC-2), which // also accepts a cross-role decision routed to an arbiter and resolved by it. // The broker lets only a run of the route_to role resolve one, and a hard wait // is never cross-role, so no hard wait passes here (row 139 routes them to the // human). export 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})?$/; export class LandingRefusal extends Error { constructor(reason, detail) { super(detail); this.reason = reason; } } const refuse = (reason, detail) => { throw new LandingRefusal(reason, detail); }; const json = (text) => { try { return JSON.parse(text); } catch { return null; } }; // The queries. Each takes the business so one bus can't answer for another. const queries = { decision: (q, b, id) => q.get('SELECT * FROM decisions WHERE business = ? AND id = ?', b, id), // The broker files a raise under its task ref when it has one, else under the decision id // (decision.raise), so the raise is found by the decision its body names. raise: (q, b, id) => q.all( `SELECT * FROM events WHERE business = ? AND kind = 'action.allowed' AND json_extract(body, '$.operation') = 'decision.raise' AND json_extract(body, '$.decision') = ? ORDER BY seq`, b, id, ), closes: (q, id) => q.all(`SELECT * FROM decision_events WHERE decision = ? AND op IN ('resolved','withdrawn','expired') ORDER BY seq`, id), answer: (q, b, id) => q.all( `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`, b, id, id, ), later: (q, b, d) => q.all( `SELECT * FROM decisions WHERE business = ? AND id <> ? AND (supersedes = ? OR (seq > ? AND action = ?)) ORDER BY seq`, b, d.id, d.id, d.seq, MERGE, ), verdict: (q, b, task) => q.get(`SELECT * FROM events WHERE business = ? AND kind = 'review.verdict' AND subject = ? ORDER BY seq DESC LIMIT 1`, b, task), }; // The task ref a work ref names, or null. export function workTask(workRef) { const m = WORK_REF.exec(workRef ?? ''); return m ? `vikunja:${m[1]}/${m[2]}` : null; } export function readLanding(q, business, id, { into, arbiter = false }) { const b = business.id; const d = queries.decision(q, b, id); const raises = queries.raise(q, 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 resolved=decisionResolution(q,b,id); const closes=resolved?[resolved]:[]; 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(q, b, id); const answer = answers.length === 1 ? answers[0] : null; const human = res.by === business.human && res.via === 'cli' && answer && answer.actor_role === null && answer.actor_run === null && json(answer.body)?.choice === res.choice; // A cross-role decision routed to an agent closes only by decision.resolve, which takes it from // the routed role alone (s.role === route_to), so res.by here is the arbiter's run. const arbitrated = arbiter && d.class === 'cross-role' && d.route_to !== 'human' && res.via === 'broker' && !answers.length; if(arbitrated && !business.roles?.[d.route_to]?.authority?.withinRole?.includes('decision.resolve.technical'))refuse('not-resolver','The technical resolver lost decision.resolve.technical; resolve afresh under current policy.'); if(arbitrated) { if(d.route_to===d.raised_by_role || resolverSubject(business,d.route_to,raise.target))refuse('not-resolver','A person decides a resolver subject.'); const confirmed=autonomyEvent(q,b,id,['autonomy.confirmed']); if((decisionAutonomy('merge',business.autonomy)==='in the loop' || raise.autonomyLevel==='in the loop') && (!confirmed || confirmed.body.resolution.at!==res.at || confirmed.body.resolution.by!==res.by))refuse('confirmation-required','A person must confirm the arbiter resolution.'); if(autonomyEvent(q,b,id,['autonomy.reverse.consume']))refuse('decision-reversed','This decision was reversed.'); } if (!human && !arbitrated) 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 task = named.length === 1 ? workTask(named[0]) : null; if (!task) refuse('work-ref', `the question names ${named.join(', ') || 'no work ref'}`); const workRef = named[0]; 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: refactor for land-work, the task itself for a working repository. const want = into === 'refactor' ? 'refactor' : into === 'main' ? task : null; if (want === null || raise.target !== want) refuse('target-branch', `the raise targets ${raise.target}, landing on ${into} needs ${want}`); const later = queries.later(q, 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(q, 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, via: res.via, resolved_at: res.at, subject: String(merge.text ?? '').split('\n')[0].trim(), }; }