Files

171 lines
7.9 KiB
JavaScript

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(),
};
}