171 lines
7.9 KiB
JavaScript
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(),
|
|
};
|
|
}
|