bus: a closed decision sends the raising role a message (#1609)
Row 115. When a decision closes by resolve or withdraw, and the closer is a human or another run, the broker routes a DECISION message to the raising role's holder: "Decision <id> on <action> was resolved by <by>: <choice> (<text>)" or "withdrawn by <by>", plus the note if there is one. A seen op sends nothing. #request falls back to the human.input that closed the decision, filtered to resolved and withdrawn. Candidate manifest 1910eb5a672e670cb7c8a197ee3fdd888412dd803fe52a0a15680103c26c078c from Darkwing's round 2 packet (40e5917f9), on BASE94ece491f. Filbert approved in 28428 and Sage in 28427. Sage reran the full gate at the landing target with the candidate applied. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+29
-3
@@ -140,11 +140,11 @@ an operator inspect content and delivery evidence after an uncertain result.
|
|||||||
| `role.release` | No args; only its current holder releases. |
|
| `role.release` | No args; only its current holder releases. |
|
||||||
| `role.revoke` | `{role,decision}`; needs human-approved gated decision for this exact target and holder incarnation. |
|
| `role.revoke` | `{role,decision}`; needs human-approved gated decision for this exact target and holder incarnation. |
|
||||||
| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and a gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. |
|
| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and a gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. |
|
||||||
| `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. |
|
| `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. Sends the raising role a DECISION message (Decision close messages, below). |
|
||||||
| `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. |
|
| `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. |
|
||||||
| `decision.withdraw` | `{id,note?}`; raiser or human. Never rewrites the decision. |
|
| `decision.withdraw` | `{id,note?}`; raiser or human. Never rewrites the decision. A human withdraw sends the raising role a DECISION message; the raising run's own withdraw sends none. |
|
||||||
| `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`. Human instruction's `request` is its `human.input` event ID. |
|
| `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`. Human instruction's `request` is its `human.input` event ID. |
|
||||||
| `message.receive` | No args; up to 100 undelivered messages for current holder, including originating `request` for human instructions. |
|
| `message.receive` | No args; up to 100 undelivered messages for current holder, including originating `request` for human instructions. A human decision close's message carries the close's `human.input` answer event as its `request`; a role close's has `null`. |
|
||||||
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
|
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
|
||||||
| `review.request` | `{task_ref,commit,to,note?,decision?}`; action `review.request` (row 93, #1587). `commit` must be the task's latest pushed commit: an older one refuses `stale-commit`, any other `unknown-commit`. `to` must be another role holding `review.verdict` (`unknown-role`, `self-review`, `not-a-reviewer`). Writes a `review.requested` event `{task_ref,project,ref,commit,to,message,held}` and sends `to` a REVIEW-REQUEST message naming it. `held` is the run id holding the `to` role when the request is routed (row 105, #1599), or `null` when no run holds it: the message then waits for a claim, and the requester should tell whoever launches roles. Returns `{request,message,commit,ref,held}`. |
|
| `review.request` | `{task_ref,commit,to,note?,decision?}`; action `review.request` (row 93, #1587). `commit` must be the task's latest pushed commit: an older one refuses `stale-commit`, any other `unknown-commit`. `to` must be another role holding `review.verdict` (`unknown-role`, `self-review`, `not-a-reviewer`). Writes a `review.requested` event `{task_ref,project,ref,commit,to,message,held}` and sends `to` a REVIEW-REQUEST message naming it. `held` is the run id holding the `to` role when the request is routed (row 105, #1599), or `null` when no run holds it: the message then waits for a claim, and the requester should tell whoever launches roles. Returns `{request,message,commit,ref,held}`. |
|
||||||
| `review.verdict` | `{task_ref,request,verdict,body,decision?}`; action `review.verdict`. `verdict` is `approve` or `changes`. `request` must be a `review.requested` event on the task addressed to the caller's role (`request-not-found`), with no verdict yet (`verdict-exists`). Writes a `review.verdict` event `{task_ref,request,commit,verdict,message}` and sends the requesting role a REVIEW-RESULT in reply to the request's message. Returns `{verdict,message,commit}`. |
|
| `review.verdict` | `{task_ref,request,verdict,body,decision?}`; action `review.verdict`. `verdict` is `approve` or `changes`. `request` must be a `review.requested` event on the task addressed to the caller's role (`request-not-found`), with no verdict yet (`verdict-exists`). Writes a `review.verdict` event `{task_ref,request,commit,verdict,message}` and sends the requesting role a REVIEW-RESULT in reply to the request's message. Returns `{verdict,message,commit}`. |
|
||||||
@@ -250,6 +250,32 @@ an uncertain external outcome must not be retried with that approval. S3/S6
|
|||||||
must implement their own external-operation identity and uncertain-outcome
|
must implement their own external-operation identity and uncertain-outcome
|
||||||
handling. Raising/superseding and resolving are transactional.
|
handling. Raising/superseding and resolving are transactional.
|
||||||
|
|
||||||
|
### Decision close messages (row 115, #1609)
|
||||||
|
|
||||||
|
A `decision.resolve` or `decision.withdraw` by anyone but the raising run sends
|
||||||
|
the raising role one message, written in the same transaction as the close, so
|
||||||
|
a refused close sends nothing. It goes to `raised_by_role` with class
|
||||||
|
`DECISION`, `decision` set to the decision's id, and no `in_reply_to` or
|
||||||
|
`corrects`. A human close is sent `from_role 'human'`, `from_run 'human-cli'`;
|
||||||
|
a role close is sent from the closing role and run. It is routed like
|
||||||
|
`message.send`'s: to the role's current holder, or with no holder until the
|
||||||
|
next claim routes it. The body is
|
||||||
|
|
||||||
|
```
|
||||||
|
Decision <id> on <action> was resolved by <by>: <choice> (<option text>).
|
||||||
|
Decision <id> on <action> was withdrawn by <by>.
|
||||||
|
```
|
||||||
|
|
||||||
|
where `<by>` is the close's `decision_events.by` (the human's name or the
|
||||||
|
closing run). A close with a note adds `Note: <note>` on its own line.
|
||||||
|
|
||||||
|
No message goes out for `decision.seen`, for the raising run's own withdraw, for
|
||||||
|
a supersede (also the raising run's withdraw), or for a within-role decision,
|
||||||
|
which closes inside `decision.raise`. `message.receive` gives a human close's
|
||||||
|
message the close's `human.input` answer event as its `request`, so the role's
|
||||||
|
turn cites it as it cites a human instruction; a role close's `request` is
|
||||||
|
`null`. The message also appears in the decision's `trail`.
|
||||||
|
|
||||||
## Human and reader paths
|
## Human and reader paths
|
||||||
|
|
||||||
`src/human-cli.mjs <socket>` is S4's transport shim, not another product CLI.
|
`src/human-cli.mjs <socket>` is S4's transport shim, not another product CLI.
|
||||||
|
|||||||
@@ -889,6 +889,30 @@ export class Broker {
|
|||||||
{ kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
|
{ kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
|
||||||
d.id,
|
d.id,
|
||||||
);
|
);
|
||||||
|
// Row 115 (#1609): a close by anyone but the raising run tells the raising role, routed like message.send's.
|
||||||
|
if (op !== 'seen' && (s.human || s.run !== d.raised_by_run)) {
|
||||||
|
const outcome =
|
||||||
|
op === 'resolved'
|
||||||
|
? `resolved by ${by}: ${a.choice} (${JSON.parse(d.options).find((o) => o.key === a.choice).text})`
|
||||||
|
: `withdrawn by ${by}`;
|
||||||
|
const mid = randomUUID();
|
||||||
|
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.human ? 'human' : s.role,
|
||||||
|
s.human ? 'human-cli' : s.run,
|
||||||
|
d.raised_by_role,
|
||||||
|
'DECISION',
|
||||||
|
null,
|
||||||
|
d.id,
|
||||||
|
null,
|
||||||
|
`Decision ${d.id} on ${d.action} was ${outcome}.${a.note === undefined ? '' : `\nNote: ${a.note}`}`,
|
||||||
|
);
|
||||||
|
const h = this.#holder(s.business, d.raised_by_role);
|
||||||
|
this.#delivery(mid, 'routed', h?.op === 'claim' ? h : null);
|
||||||
|
}
|
||||||
return { id: d.id, op };
|
return { id: d.id, op };
|
||||||
}
|
}
|
||||||
case 'message.send': {
|
case 'message.send': {
|
||||||
@@ -978,15 +1002,7 @@ export class Broker {
|
|||||||
s.human || s.tester ? null : this.#holder(s.business, s.role),
|
s.human || s.tester ? null : this.#holder(s.business, s.role),
|
||||||
s.human || s.tester ? 'cli' : 'broker',
|
s.human || s.tester ? 'cli' : 'broker',
|
||||||
);
|
);
|
||||||
return rows.map((m) => ({
|
return rows.map((m) => ({ ...m, request: this.#request(s.business, m) }));
|
||||||
...m,
|
|
||||||
request:
|
|
||||||
this.#store.get(
|
|
||||||
"SELECT id FROM events WHERE business=? AND kind='human.input' AND subject=? ORDER BY seq LIMIT 1",
|
|
||||||
s.business,
|
|
||||||
m.id,
|
|
||||||
)?.id ?? null,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
case 'message.read': {
|
case 'message.read': {
|
||||||
keys(a, ['id'], ['id']);
|
keys(a, ['id'], ['id']);
|
||||||
@@ -1292,6 +1308,24 @@ export class Broker {
|
|||||||
decision,
|
decision,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// A message's originating human.input: a human instruction's own, or for the
|
||||||
|
// message a human decision close sends (row 115, #1609), the close's answer.
|
||||||
|
// A decision has one close, so the answer is unique.
|
||||||
|
#request(business, m) {
|
||||||
|
const own = this.#store.get(
|
||||||
|
"SELECT id FROM events WHERE business=? AND kind='human.input' AND subject=? ORDER BY seq LIMIT 1",
|
||||||
|
business,
|
||||||
|
m.id,
|
||||||
|
);
|
||||||
|
if (own || m.class !== 'DECISION' || m.from_role !== 'human' || !m.decision) return own?.id ?? null;
|
||||||
|
return (
|
||||||
|
this.#store.get(
|
||||||
|
"SELECT id FROM events WHERE business=? AND kind='human.input' AND subject=? AND json_extract(body,'$.kind')='answer' AND json_extract(body,'$.op') IN ('resolved','withdrawn') ORDER BY seq LIMIT 1",
|
||||||
|
business,
|
||||||
|
m.decision,
|
||||||
|
)?.id ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
// The message a review verb sends, routed like message.send's.
|
// The message a review verb sends, routed like message.send's.
|
||||||
#reviewMessage(s, mid, { to, cls, in_reply_to = null, decision = null, body }) {
|
#reviewMessage(s, mid, { to, cls, in_reply_to = null, decision = null, body }) {
|
||||||
this.#store.run(
|
this.#store.run(
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Row 115 (#1609): a decision closed by anyone but the raising run sends the
|
||||||
|
// raising role a DECISION message in the same transaction as the close.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Store } from '../src/store.mjs';
|
||||||
|
import { Broker } from '../src/broker.mjs';
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ key: 'yes', text: 'Allow' },
|
||||||
|
{ key: 'no', text: 'Decline' },
|
||||||
|
];
|
||||||
|
const roles = {
|
||||||
|
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||||
|
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||||
|
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||||
|
};
|
||||||
|
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles } };
|
||||||
|
|
||||||
|
function setup(t) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'bus-decision-close-'));
|
||||||
|
const store = new Store(root);
|
||||||
|
const b = new Broker({ store, businesses });
|
||||||
|
t.after(() => {
|
||||||
|
store.close();
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
const agent = (role, run = `${role}-run`) => {
|
||||||
|
const cap = b.bindLaunch({ business: 'demo', role, run, harness: 'pi', address: run });
|
||||||
|
b.request(cap, { verb: 'role.claim' });
|
||||||
|
return cap;
|
||||||
|
};
|
||||||
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||||
|
const coder = agent('coder');
|
||||||
|
const cto = agent('cto');
|
||||||
|
const pm = agent('pm');
|
||||||
|
// Routed to cto (technical arbiter), to pm (delivery arbiter), and to the human (gated).
|
||||||
|
const toCto = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1' });
|
||||||
|
const toPm = raise(b, coder, 'task.scope.change', { domain: 'delivery', target: 'vikunja:3/2' });
|
||||||
|
const toHuman = raise(b, coder, 'deploy', { target: 'release-1', task_ref: 'vikunja:3/3', blocking: true });
|
||||||
|
assert.deepEqual([toCto.route_to, toPm.route_to, toHuman.route_to], ['cto', 'pm', 'human']);
|
||||||
|
return { b, store, agent, human, coder, cto, pm, toCto, toPm, toHuman };
|
||||||
|
}
|
||||||
|
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
|
||||||
|
const raise = (b, cap, action, extra = {}) =>
|
||||||
|
call(b, cap, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra });
|
||||||
|
const closes = (store) => store.all("SELECT * FROM messages WHERE class='DECISION' ORDER BY seq");
|
||||||
|
const deliveries = (store, mid) =>
|
||||||
|
store.all('SELECT op,holder_run,transport,address FROM deliveries WHERE message=? ORDER BY seq', mid).map((r) => ({ ...r }));
|
||||||
|
const answer = (store, decision, op = 'resolved') =>
|
||||||
|
store.get(
|
||||||
|
"SELECT id FROM events WHERE kind='human.input' AND subject=? AND json_extract(body,'$.kind')='answer' AND json_extract(body,'$.op')=?",
|
||||||
|
decision,
|
||||||
|
op,
|
||||||
|
).id;
|
||||||
|
|
||||||
|
test('a human resolve sends the raising role one message from the human, and its request is the answer', (t) => {
|
||||||
|
const { b, store, human, coder, toHuman } = setup(t);
|
||||||
|
call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'no', note: 'not today' });
|
||||||
|
const [m, ...rest] = closes(store);
|
||||||
|
assert.equal(rest.length, 0);
|
||||||
|
const closed = store.get("SELECT by FROM decision_events WHERE decision=? AND op='resolved'", toHuman.id);
|
||||||
|
assert.equal(closed.by, 'jason');
|
||||||
|
assert.equal(m.business, 'demo');
|
||||||
|
assert.equal(m.from_role, 'human');
|
||||||
|
assert.equal(m.from_run, 'human-cli');
|
||||||
|
assert.equal(m.to_role, 'coder');
|
||||||
|
assert.equal(m.class, 'DECISION');
|
||||||
|
assert.equal(m.in_reply_to, null);
|
||||||
|
assert.equal(m.decision, toHuman.id);
|
||||||
|
assert.equal(m.corrects, null);
|
||||||
|
assert.equal(m.body, `Decision ${toHuman.id} on deploy was resolved by jason: no (Decline).\nNote: not today`);
|
||||||
|
// Routed to the coder's holder, as message.send routes.
|
||||||
|
assert.deepEqual(deliveries(store, m.id), [{ op: 'routed', holder_run: 'coder-run', transport: 'pi', address: 'coder-run' }]);
|
||||||
|
const [got] = call(b, coder, 'message.receive');
|
||||||
|
assert.equal(got.id, m.id);
|
||||||
|
assert.equal(got.request, answer(store, toHuman.id));
|
||||||
|
// The message joins the decision's trail.
|
||||||
|
assert.ok(call(b, human, 'trail', { subject: toHuman.id }).some((r) => r.table === 'messages' && r.id === m.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cross-role decision resolved by its arbiter comes from that role and run, with no request', (t) => {
|
||||||
|
const { b, store, coder, cto, toCto } = setup(t);
|
||||||
|
call(b, cto, 'decision.resolve', { id: toCto.id, choice: 'yes' });
|
||||||
|
const [m, ...rest] = closes(store);
|
||||||
|
assert.equal(rest.length, 0);
|
||||||
|
assert.equal(m.from_role, 'cto');
|
||||||
|
assert.equal(m.from_run, 'cto-run');
|
||||||
|
assert.equal(m.to_role, 'coder');
|
||||||
|
assert.equal(m.decision, toCto.id);
|
||||||
|
// No note, so no Note line; by is the arbiter's run.
|
||||||
|
assert.equal(m.body, `Decision ${toCto.id} on task.scope.change was resolved by cto-run: yes (Allow).`);
|
||||||
|
const [got] = call(b, coder, 'message.receive');
|
||||||
|
assert.equal(got.id, m.id);
|
||||||
|
assert.equal(got.request, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a human withdraw sends one message with the note', (t) => {
|
||||||
|
const { b, store, human, coder, toPm } = setup(t);
|
||||||
|
call(b, human, 'decision.withdraw', { id: toPm.id, note: 'superseded by the plan\nsee #1609' });
|
||||||
|
const [m, ...rest] = closes(store);
|
||||||
|
assert.equal(rest.length, 0);
|
||||||
|
assert.equal(m.from_role, 'human');
|
||||||
|
assert.equal(m.to_role, 'coder');
|
||||||
|
assert.equal(m.body, `Decision ${toPm.id} on task.scope.change was withdrawn by jason.\nNote: superseded by the plan\nsee #1609`);
|
||||||
|
const [got] = call(b, coder, 'message.receive');
|
||||||
|
assert.equal(got.request, answer(store, toPm.id, 'withdrawn'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a human seen before the close leaves the request on the close's answer, not the seen", (t) => {
|
||||||
|
const { b, store, human, coder, toHuman } = setup(t);
|
||||||
|
const other = raise(b, coder, 'deploy', { target: 'release-2', task_ref: 'vikunja:3/5' });
|
||||||
|
assert.equal(other.route_to, 'human');
|
||||||
|
// A human seen writes a human.input answer with op 'seen' on the decision too.
|
||||||
|
call(b, human, 'decision.seen', { id: toHuman.id });
|
||||||
|
call(b, human, 'decision.seen', { id: other.id });
|
||||||
|
const seen = answer(store, toHuman.id, 'seen');
|
||||||
|
assert.ok(seen);
|
||||||
|
call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' });
|
||||||
|
call(b, human, 'decision.withdraw', { id: other.id });
|
||||||
|
const got = call(b, coder, 'message.receive');
|
||||||
|
assert.deepEqual(
|
||||||
|
got.map((m) => [m.decision, m.request]),
|
||||||
|
[
|
||||||
|
[toHuman.id, answer(store, toHuman.id, 'resolved')],
|
||||||
|
[other.id, answer(store, other.id, 'withdrawn')],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.notEqual(got[0].request, seen);
|
||||||
|
assert.notEqual(got[1].request, answer(store, other.id, 'seen'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the raising run's withdraw, a supersede, a within-role raise and seen send nothing", (t) => {
|
||||||
|
const { b, store, human, coder, cto, toCto, toPm, toHuman } = setup(t);
|
||||||
|
call(b, coder, 'decision.withdraw', { id: toPm.id, note: 'not needed' });
|
||||||
|
raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1', supersedes: toCto.id });
|
||||||
|
raise(b, coder, 'message.send', { choice: 'yes' });
|
||||||
|
const late = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/4' });
|
||||||
|
call(b, cto, 'decision.seen', { id: late.id, note: 'looking' });
|
||||||
|
call(b, human, 'decision.seen', { id: toHuman.id });
|
||||||
|
// Three withdrawals or closes and two seen rows, and no DECISION message.
|
||||||
|
assert.equal(store.get("SELECT count(*) AS n FROM decision_events WHERE op IN ('withdrawn','resolved')").n, 3);
|
||||||
|
assert.equal(store.get("SELECT count(*) AS n FROM decision_events WHERE op='seen'").n, 2);
|
||||||
|
assert.deepEqual(closes(store), []);
|
||||||
|
assert.deepEqual(call(b, coder, 'message.receive'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refused closes send nothing: invalid-choice, decision-closed, not-resolver', (t) => {
|
||||||
|
const { b, store, human, coder, cto, pm, toCto, toPm, toHuman } = setup(t);
|
||||||
|
assert.throws(() => call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'maybe' }), /invalid-choice/);
|
||||||
|
assert.throws(() => call(b, pm, 'decision.resolve', { id: toCto.id, choice: 'yes' }), /not-resolver/);
|
||||||
|
assert.throws(() => call(b, cto, 'decision.withdraw', { id: toCto.id }), /not-resolver/);
|
||||||
|
assert.deepEqual(closes(store), []);
|
||||||
|
call(b, pm, 'decision.resolve', { id: toPm.id, choice: 'no' });
|
||||||
|
assert.equal(closes(store).length, 1);
|
||||||
|
assert.throws(() => call(b, pm, 'decision.resolve', { id: toPm.id, choice: 'yes' }), /decision-closed/);
|
||||||
|
assert.throws(() => call(b, human, 'decision.withdraw', { id: toPm.id }), /decision-closed/);
|
||||||
|
assert.equal(closes(store).length, 1);
|
||||||
|
assert.equal(call(b, coder, 'message.receive').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a raising role nobody holds at the close gets the message on the next claim', (t) => {
|
||||||
|
const { b, store, agent, human, coder, toHuman } = setup(t);
|
||||||
|
call(b, coder, 'role.release');
|
||||||
|
call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' });
|
||||||
|
const [m] = closes(store);
|
||||||
|
assert.deepEqual(deliveries(store, m.id), [{ op: 'routed', holder_run: null, transport: null, address: null }]);
|
||||||
|
const next = agent('coder', 'coder-run-2');
|
||||||
|
assert.deepEqual(deliveries(store, m.id).at(-1), { op: 'routed', holder_run: 'coder-run-2', transport: 'pi', address: 'coder-run-2' });
|
||||||
|
const [got] = call(b, next, 'message.receive');
|
||||||
|
assert.equal(got.id, m.id);
|
||||||
|
assert.equal(got.body, `Decision ${toHuman.id} on deploy was resolved by jason: yes (Allow).`);
|
||||||
|
assert.equal(got.request, answer(store, toHuman.id));
|
||||||
|
assert.equal(deliveries(store, m.id).at(-1).op, 'delivered');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a human DECISION send keeps its own instruction as the request, not a close's answer", (t) => {
|
||||||
|
const { b, store, human, coder, toHuman } = setup(t);
|
||||||
|
call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'no' });
|
||||||
|
const sent = call(b, human, 'message.send', { to: 'coder', body: 'about that', class: 'DECISION', decision: toHuman.id });
|
||||||
|
const got = call(b, coder, 'message.receive');
|
||||||
|
assert.deepEqual(
|
||||||
|
got.map((m) => [m.from_role, m.request]),
|
||||||
|
[
|
||||||
|
['human', answer(store, toHuman.id)],
|
||||||
|
['human', sent.request],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.notEqual(sent.request, answer(store, toHuman.id));
|
||||||
|
});
|
||||||
@@ -216,7 +216,9 @@ test('message.send consumes approval and prevents a later send or authorize', (t
|
|||||||
/decision-consumed/,
|
/decision-consumed/,
|
||||||
);
|
);
|
||||||
assert.throws(() => f.use(d), /decision-consumed/);
|
assert.throws(() => f.use(d), /decision-consumed/);
|
||||||
assert.equal(f.store.get('SELECT count(*) AS n FROM messages').n, 1);
|
// The approval's close sent the raiser one DECISION message (row 115, #1609); one send got through.
|
||||||
|
assert.equal(f.store.get("SELECT count(*) AS n FROM messages WHERE class='DECISION'").n, 1);
|
||||||
|
assert.equal(f.store.get("SELECT count(*) AS n FROM messages WHERE class<>'DECISION'").n, 1);
|
||||||
const fresh = f.approve('message.send', 'delivery', 'pm');
|
const fresh = f.approve('message.send', 'delivery', 'pm');
|
||||||
f.use(fresh);
|
f.use(fresh);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
|
|||||||
@@ -266,6 +266,42 @@ test("a message read a day after it was sent shows the time it was sent", async
|
|||||||
assert.ok(Date.parse(reply.at) - Date.parse(sentAt) >= 86_000_000);
|
assert.ok(Date.parse(reply.at) - Date.parse(sentAt) >= 86_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row 115 (#1609): the human closing the role's decision gives the role a turn. The runner is unchanged;
|
||||||
|
// the header cites the decision and the close's human.input answer as the request.
|
||||||
|
test("a decision the human resolves becomes a turn citing the decision and the answer", async (t) => {
|
||||||
|
const ctx = await setup(t);
|
||||||
|
const r = startRunner(ctx.runDir, ctx.cap);
|
||||||
|
t.after(() => r.child.kill("SIGKILL"));
|
||||||
|
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
||||||
|
const d = ctx.call(ctx.cap, "decision.raise", {
|
||||||
|
action: "deploy",
|
||||||
|
target: "release-1",
|
||||||
|
question: "Ship release-1?",
|
||||||
|
options: [{ key: "yes", text: "Allow" }, { key: "no", text: "Decline" }],
|
||||||
|
recommendation: "yes",
|
||||||
|
blocking: false,
|
||||||
|
});
|
||||||
|
assert.equal(d.route_to, "human");
|
||||||
|
ctx.call(ctx.human, "decision.resolve", { id: d.id, choice: "yes" });
|
||||||
|
const m = ctx.store.get("SELECT id,at FROM messages WHERE class='DECISION' AND decision=?", d.id);
|
||||||
|
const answer = ctx.store.get("SELECT id FROM events WHERE kind='human.input' AND subject=?", d.id).id;
|
||||||
|
const [reply] = await until(() => {
|
||||||
|
const got = ctx.call(ctx.human, "message.receive");
|
||||||
|
return got.length ? got : null;
|
||||||
|
});
|
||||||
|
assert.equal(reply.from_role, "coder");
|
||||||
|
assert.equal(reply.class, "RESULT");
|
||||||
|
assert.equal(reply.in_reply_to, m.id);
|
||||||
|
assert.ok(
|
||||||
|
reply.body.endsWith(
|
||||||
|
`\nMessage ${m.id} from human, sent ${m.at}, class DECISION, citing decision ${d.id}, request ${answer}:\n\n` +
|
||||||
|
`Decision ${d.id} on deploy was resolved by jason: yes (Allow).`,
|
||||||
|
),
|
||||||
|
reply.body,
|
||||||
|
);
|
||||||
|
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", m.id));
|
||||||
|
});
|
||||||
|
|
||||||
test("a failed state read fails the turn: no adapter runs, the sender hears why, the message is read", async (t) => {
|
test("a failed state read fails the turn: no adapter runs, the sender hears why, the message is read", async (t) => {
|
||||||
const ctx = await setup(t);
|
const ctx = await setup(t);
|
||||||
ctx.inbox = [];
|
ctx.inbox = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user