review loop: working repositories, git socket, push and review verdicts, row 93 (#1587)
Commits move as v2 bundles over bus-host/git.sock (decision 135). The bus
host keeps one bare working repository per business and project, seeds main
from the project root under a per-repository lock, fetches a pushed bundle
with fsck into a temp ref, requires history shared with main, and moves the
task's work ref only as a fast-forward. Host and runner git run with no
system or global config, hooks at /dev/null and local transports only. The
socket binds only for roles holding git.push.working or review.verdict.
The broker records a push as the action.allowed for git.push.working with
its commit, takes review.request for the latest push and one review.verdict
per request, and refuses task.close on a pushed task without an approve
verdict for the latest push. recordEvent reserves both shapes, so agents
can't forge either.
Reviewed by Dewey (28196) and Sage (28197), both approve. Gate on 7a729aef:
14 node suites and 9 scripts green.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+199
-6
@@ -85,6 +85,12 @@ function identifier(x) {
|
||||
return x;
|
||||
}
|
||||
const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x);
|
||||
// A git commit id, SHA-1 or SHA-256.
|
||||
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
|
||||
const VERDICTS = ['approve', 'changes'];
|
||||
// Row 93 (#1587): the review loop's evidence, written only by the verbs below. A push is an
|
||||
// action.allowed for git.push.working that names its commit, written only by recordPush.
|
||||
const REVIEW_KINDS = ['review.requested', 'review.verdict'];
|
||||
|
||||
export class Broker {
|
||||
#store;
|
||||
@@ -232,10 +238,11 @@ export class Broker {
|
||||
}
|
||||
// Trusted launcher API: records a refusal the host decided itself (instance list, capacity,
|
||||
// credentials) as action.refused against the caller, the way request() records its own.
|
||||
refuse(cap, code) {
|
||||
// A task ref as subject files the refusal on that task's trail, as request() does.
|
||||
refuse(cap, code, subject = null) {
|
||||
const s = this.#session(cap);
|
||||
if (!/^[a-z-]{1,64}$/.test(code)) fail('invalid-request');
|
||||
return this.#refused(s, new BusError(code), null).code;
|
||||
return this.#refused(s, new BusError(code), { args: { task_ref: subject } }).code;
|
||||
}
|
||||
// Trusted human transport invokes only AFTER checking its CLI process and launch ancestry.
|
||||
bindHuman(record) {
|
||||
@@ -422,7 +429,8 @@ export class Broker {
|
||||
return { class: cls, decision: d.id };
|
||||
}
|
||||
// Caller owns the transaction: authority, consumption and effect commit together.
|
||||
#consumeAuthority(s, action, context = {}) {
|
||||
// detail adds to the action.allowed body: a push's commit (row 93).
|
||||
#consumeAuthority(s, action, context = {}, detail = {}) {
|
||||
const result = this.#checkAuthority(s, action, context);
|
||||
// decision.raise records context, not permission consumed by an executor.
|
||||
if (
|
||||
@@ -437,22 +445,111 @@ export class Broker {
|
||||
this.#event(
|
||||
s,
|
||||
'action.allowed',
|
||||
{ action, ...result, target: context.target ?? null },
|
||||
{ ...detail, action, ...result, target: context.target ?? null },
|
||||
context.target ?? null,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
// Trusted S3 handler calls inside its own operation; not a generic socket action executor.
|
||||
// Row 93 (#1587): task.close on a task with a recorded push also needs
|
||||
// context.verdict, checked by #closeVerdict; a refusal there rolls the action.allowed back.
|
||||
authorize(cap, action, context = {}) {
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => this.#consumeAuthority(s, action, context));
|
||||
return this.#store.transaction(() => {
|
||||
const result = this.#consumeAuthority(s, action, context);
|
||||
if (action === 'task.close') this.#closeVerdict(s, context.target, context.verdict);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
// A task with a pushed commit closes only on an approve verdict for the latest push. A task with
|
||||
// no push closes as before, its verdict a citation the broker doesn't check.
|
||||
#closeVerdict(s, task, verdict) {
|
||||
const push = this.#latestPush(s.business, task);
|
||||
if (!push) return;
|
||||
const v = id(verdict)
|
||||
? this.#store.get(
|
||||
"SELECT body FROM events WHERE business=? AND kind='review.verdict' AND id=? AND subject=?",
|
||||
s.business,
|
||||
verdict,
|
||||
task,
|
||||
)
|
||||
: null;
|
||||
if (!v) fail('verdict-not-found');
|
||||
const body = JSON.parse(v.body);
|
||||
if (body.verdict !== 'approve') fail('verdict-not-approve');
|
||||
if (body.commit !== push.commit) fail('verdict-stale');
|
||||
}
|
||||
// A push is the action.allowed recordPush wrote, the only one for git.push.working with a commit.
|
||||
#latestPush(business, task) {
|
||||
const r = this.#store.get(
|
||||
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
|
||||
business,
|
||||
task,
|
||||
);
|
||||
return r ? JSON.parse(r.body) : null;
|
||||
}
|
||||
// Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded
|
||||
// unless it refuses: the push is recorded, and its authority consumed, once the ref moved.
|
||||
checkPush(cap, task_ref) {
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
if (!taskRef(task_ref)) fail('invalid-request');
|
||||
return this.#checkAuthority(s, 'git.push.working', { target: task_ref });
|
||||
});
|
||||
}
|
||||
// Row 93: records a push the git socket made (packages/seat/src/working.mjs) as the action.allowed
|
||||
// for git.push.working, with the commit. A refusal here moves the ref back. Trusted host only.
|
||||
recordPush(cap, push) {
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
keys(push, ['task_ref', 'project', 'branch', 'ref', 'commit', 'previous'], ['task_ref', 'project', 'branch', 'ref', 'commit', 'previous']);
|
||||
if (
|
||||
!taskRef(push.task_ref) ||
|
||||
!Object.hasOwn(this.#business(s.business).projects ?? {}, push.project) ||
|
||||
!oid(push.commit) ||
|
||||
(push.previous !== null && !oid(push.previous))
|
||||
)
|
||||
fail('invalid-request');
|
||||
string(push.branch, 200);
|
||||
string(push.ref, 200);
|
||||
const { task_ref, project, branch, ref, commit, previous } = push;
|
||||
this.#consumeAuthority(s, 'git.push.working', { target: task_ref }, { project, branch, ref, commit, previous });
|
||||
return { task_ref, commit };
|
||||
});
|
||||
}
|
||||
// Row 93: what the git socket's fetch for a reviewer may send, from the review.requested event the
|
||||
// reviewer cites. The caller must hold review.verdict and be the request's reviewer. Trusted host only.
|
||||
reviewFetch(cap, { task_ref, request } = {}) {
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
if (!taskRef(task_ref) || !id(request)) fail('invalid-request');
|
||||
if (this.#classification(s, 'review.verdict') === 'gated') fail('not-authorized');
|
||||
const r = this.#reviewRequest(s, task_ref, request);
|
||||
return { project: r.project, commit: r.commit, ref: r.ref };
|
||||
});
|
||||
}
|
||||
// A review.requested event on the task, addressed to this session's role.
|
||||
#reviewRequest(s, task, request) {
|
||||
const r = this.#store.get(
|
||||
"SELECT actor_role, body FROM events WHERE business=? AND kind='review.requested' AND id=? AND subject=?",
|
||||
s.business,
|
||||
request,
|
||||
task,
|
||||
);
|
||||
const body = r ? JSON.parse(r.body) : null;
|
||||
if (!body || body.to !== s.role) fail('request-not-found');
|
||||
return { ...body, from: r.actor_role };
|
||||
}
|
||||
// Trusted adapters only. No agent socket route reaches this method.
|
||||
recordEvent(cap, { kind, body, subject = null }) {
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
if (['human.input', 'launch.revoked', 'launch.restored'].includes(kind)) fail('reserved-event');
|
||||
if (['human.input', 'launch.revoked', 'launch.restored', ...REVIEW_KINDS].includes(kind)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && body?.action === 'git.push.working') fail('reserved-event');
|
||||
return this.#event(s, kind, body, subject);
|
||||
});
|
||||
}
|
||||
@@ -873,6 +970,83 @@ export class Broker {
|
||||
this.#delivery(m.id, 'read', s.human ? null : this.#holder(s.business, s.role));
|
||||
return { read: true };
|
||||
}
|
||||
// Row 93 (#1587): the coder asks a reviewer role to review the task's latest pushed commit.
|
||||
case 'review.request': {
|
||||
keys(a, ['task_ref', 'commit', 'to', 'note', 'decision'], ['task_ref', 'commit', 'to']);
|
||||
if (!taskRef(a.task_ref) || !oid(a.commit)) fail('invalid-request');
|
||||
const withinRole = this.#classification(s, 'review.request') === 'within-role';
|
||||
this.#consumeAuthority(s, 'review.request', {
|
||||
decision: withinRole ? null : (a.decision ?? null),
|
||||
target: a.task_ref,
|
||||
});
|
||||
if (a.note !== undefined) string(a.note, 16384);
|
||||
if (!Object.hasOwn(b.roles, a.to)) fail('unknown-role');
|
||||
if (a.to === s.role) fail('self-review');
|
||||
const reviewer = b.roles[a.to].authority;
|
||||
if (![...reviewer.withinRole, ...reviewer.crossRole].includes('review.verdict')) fail('not-a-reviewer');
|
||||
const push = this.#latestPush(s.business, a.task_ref);
|
||||
if (push?.commit !== a.commit) {
|
||||
const older = this.#store.get(
|
||||
"SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND subject=? AND json_extract(body,'$.commit')=?",
|
||||
s.business,
|
||||
a.task_ref,
|
||||
a.commit,
|
||||
);
|
||||
fail(older ? 'stale-commit' : 'unknown-commit');
|
||||
}
|
||||
const mid = randomUUID();
|
||||
const eid = this.#event(
|
||||
s,
|
||||
'review.requested',
|
||||
{ task_ref: a.task_ref, project: push.project, ref: push.ref, commit: a.commit, to: a.to, message: mid },
|
||||
a.task_ref,
|
||||
);
|
||||
this.#reviewMessage(s, mid, {
|
||||
to: a.to,
|
||||
cls: 'REVIEW-REQUEST',
|
||||
decision: withinRole ? null : (a.decision ?? null),
|
||||
body: `Review request ${eid} for ${a.task_ref}: commit ${a.commit} on ${push.ref} in project ${push.project}. Fetch it with fetch_review, then answer with review_verdict citing this request.${a.note ? '\n\n' + a.note : ''}`,
|
||||
});
|
||||
return { request: eid, message: mid, commit: a.commit, ref: push.ref };
|
||||
}
|
||||
// Row 93: the reviewer's answer to one review.request, sent back to the role that asked.
|
||||
case 'review.verdict': {
|
||||
keys(a, ['task_ref', 'request', 'verdict', 'body', 'decision'], ['task_ref', 'request', 'verdict', 'body']);
|
||||
if (!taskRef(a.task_ref) || !id(a.request) || !VERDICTS.includes(a.verdict)) fail('invalid-request');
|
||||
const withinRole = this.#classification(s, 'review.verdict') === 'within-role';
|
||||
this.#consumeAuthority(s, 'review.verdict', {
|
||||
decision: withinRole ? null : (a.decision ?? null),
|
||||
target: a.task_ref,
|
||||
});
|
||||
string(a.body, 32768);
|
||||
const r = this.#reviewRequest(s, a.task_ref, a.request);
|
||||
// One verdict per request: a changed mind needs a new request, so a close can't cite an
|
||||
// approve that a later verdict on the same request took back.
|
||||
if (
|
||||
this.#store.get(
|
||||
"SELECT 1 FROM events WHERE business=? AND kind='review.verdict' AND subject=? AND json_extract(body,'$.request')=?",
|
||||
s.business,
|
||||
a.task_ref,
|
||||
a.request,
|
||||
)
|
||||
)
|
||||
fail('verdict-exists');
|
||||
const mid = randomUUID();
|
||||
const eid = this.#event(
|
||||
s,
|
||||
'review.verdict',
|
||||
{ task_ref: a.task_ref, request: a.request, commit: r.commit, verdict: a.verdict, message: mid },
|
||||
a.task_ref,
|
||||
);
|
||||
this.#reviewMessage(s, mid, {
|
||||
to: r.from,
|
||||
cls: 'REVIEW-RESULT',
|
||||
in_reply_to: r.message,
|
||||
decision: withinRole ? null : (a.decision ?? null),
|
||||
body: `Review verdict ${eid} on ${a.task_ref}, commit ${r.commit}: ${a.verdict}. A task_close cites this verdict id.\n\n${a.body}`,
|
||||
});
|
||||
return { verdict: eid, message: mid, commit: r.commit };
|
||||
}
|
||||
case 'launch.revoke':
|
||||
case 'launch.restore': {
|
||||
keys(a, []);
|
||||
@@ -1055,6 +1229,25 @@ export class Broker {
|
||||
decision,
|
||||
);
|
||||
}
|
||||
// The message a review verb sends, routed like message.send's.
|
||||
#reviewMessage(s, mid, { to, cls, in_reply_to = null, decision = null, body }) {
|
||||
this.#store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
mid,
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.role,
|
||||
s.run,
|
||||
to,
|
||||
cls,
|
||||
in_reply_to,
|
||||
decision,
|
||||
null,
|
||||
body,
|
||||
);
|
||||
const h = this.#holder(s.business, to);
|
||||
this.#delivery(mid, 'routed', h?.op === 'claim' ? h : null);
|
||||
}
|
||||
#delivery(mid, op, h, transport = null) {
|
||||
this.#store.run(
|
||||
'INSERT INTO deliveries(message,at,op,holder_run,transport,address) VALUES(?,?,?,?,?,?)',
|
||||
|
||||
@@ -2,12 +2,25 @@
|
||||
// never command arguments, stdout or service-token-bearing environment variables.
|
||||
import { startBroker } from './runtime.mjs';
|
||||
import { BusError } from './broker.mjs';
|
||||
const LAUNCH_OPS = new Set(['identity', 'authorizeLaunch', 'refuse', 'endLaunch', 'credentialStatus']);
|
||||
const LAUNCH_OPS = new Set([
|
||||
'identity',
|
||||
'authorizeLaunch',
|
||||
'refuse',
|
||||
'endLaunch',
|
||||
'credentialStatus',
|
||||
'checkPush',
|
||||
'recordPush',
|
||||
'reviewFetch',
|
||||
]);
|
||||
function launchOp(m) {
|
||||
if (m.op === 'identity') return runtime.identity(m.cap);
|
||||
if (m.op === 'authorizeLaunch') return runtime.authorizeLaunch(m.cap, m.instance);
|
||||
if (m.op === 'refuse') return runtime.refuse(m.cap, m.code);
|
||||
if (m.op === 'refuse') return runtime.refuse(m.cap, m.code, m.subject);
|
||||
if (m.op === 'endLaunch') return runtime.endLaunch(m.record);
|
||||
// Row 93 (#1587): the git socket's ops.
|
||||
if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref);
|
||||
if (m.op === 'recordPush') return runtime.recordPush(m.cap, m.push);
|
||||
if (m.op === 'reviewFetch') return runtime.reviewFetch(m.cap, m.args);
|
||||
return runtime.credentialStatus(m.business, m.role);
|
||||
}
|
||||
// The host's request id comes back on the reply, so a late reply can't
|
||||
|
||||
@@ -75,15 +75,22 @@ export async function startBroker({
|
||||
});
|
||||
// S6 launcher operations. The host decides capacity and the instance list; the broker
|
||||
// authorizes role.launch and keeps the evidence, refusals included.
|
||||
function authorizeLaunch(cap, instance) {
|
||||
// A refusal is recorded against the caller, as request() records one.
|
||||
function refusing(cap, fn, subject = null) {
|
||||
try {
|
||||
return broker.authorize(cap, 'role.launch', { target: instance });
|
||||
return fn();
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError)) throw e;
|
||||
broker.refuse(cap, e.code);
|
||||
broker.refuse(cap, e.code, subject);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const authorizeLaunch = (cap, instance) =>
|
||||
refusing(cap, () => broker.authorize(cap, 'role.launch', { target: instance }));
|
||||
// Row 93 (#1587): the host's git socket. A push is checked before the host takes the bundle
|
||||
// and recorded once the ref moved; a reviewer's fetch is checked against its review.requested.
|
||||
const checkPush = (cap, task_ref) => refusing(cap, () => broker.checkPush(cap, task_ref), task_ref);
|
||||
const reviewFetch = (cap, args) => refusing(cap, () => broker.reviewFetch(cap, args), args?.task_ref);
|
||||
// Metadata only (service, date, state); the token stays in Credentials.
|
||||
function credentialStatus(business, role) {
|
||||
return credentials
|
||||
@@ -99,7 +106,10 @@ export async function startBroker({
|
||||
endLaunch: (r) => broker.endLaunch(r),
|
||||
identity: (cap) => broker.identity(cap),
|
||||
authorizeLaunch,
|
||||
refuse: (cap, code) => broker.refuse(cap, code),
|
||||
checkPush,
|
||||
recordPush: (cap, push) => refusing(cap, () => broker.recordPush(cap, push), push?.task_ref),
|
||||
reviewFetch,
|
||||
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
|
||||
credentialStatus,
|
||||
launches: bound,
|
||||
readers: readCaps,
|
||||
|
||||
Reference in New Issue
Block a user