Compare commits

...
2 Commits
10 changed files with 531 additions and 65 deletions

No files matched your search

+8
View File
@@ -5979,3 +5979,11 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr
2026-11-03 | Lead Codex, request-to-product design handoff (Vikunja #797, Gitea #1839) | After: docs/plans/2026-11-03_request-to-product-design.md proposes general Console answers first, durable pre-task intake and three modes, typed project bootstrap, project-scoped approved PRDs and automatic continuation through reviewed delivery; original pomodoro is the acceptance case. Opus independent builder check also failed Claude rate limit; lead self-check only, no independent approval. Await owner decision on concrete authority/schema scope and temporary GPT implementation delegation while Claude is unavailable. Documentation only; product behavior and acceptance not claimed.
2026-11-03 | Lead Codex, #797 / Gitea #1839 implementation start | Jason reports CLI Proxy API leaves Claude accounts unauthenticated and authorizes GPT agents for these tasks. Execute the staged design with GPT coding and independent review; general Console buttons first under existing REQ-WEB-2, detailed lifecycle/PRD plan in parallel. No further Claude calls or production-host changes.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 before | Prepared checkout t3/b15e72fa at 7e849a7b0b635d34950a5c5c2e4b78c4238fe651. Console answers currently require two-option grant requests. Scope: ordinary 2–9 choices through existing preview/confirmation/resolve, preserved grant and identity gates; no tracker/role/PRD/runtime changes. Baseline bus 12/12; initial WebUI 12 pass/5 fail from missing oauth4webapi@3.8.8, restored with locked npm ci --ignore-scripts, then WebUI 17/17 including Chromium. Intended RED: broker 14 pass/3 fail decision-not-eligible; HTTP/browser 12 pass/5 fail ordinary confirmation 503 or absent option panel; real SSO browser 0 pass/1 fail zero buttons instead of three. Owner continuation asks for user in changed prose, technical identifiers retained for #798.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 after | Ordinary user-routed decisions now expose authoritative kind/options through the existing two Console verbs; confirmation uses the selected key/text and ordinary receipts say Recorded. The digest binds the complete decision row and raise authorization/grant context; stale membership/context refuses transactionally. Grant approve/deny/summary and one-use executor path, OIDC/session/origin checks, role attribution and authority remain intact. Cancellation, navigation, reload, closure and changed-context refresh drop UI confirmations without replay. Added real fake-OP signed-in Chromium -> HTTP -> broker third-option coverage: one resolution, verified-subject audit, raiser notice and zero grant activity. RED-to-GREEN also checked project/spec digest binding. Targeted broker 17/17 and WebUI 25/25; final full bus 1407/1407 and WebUI 509/509, zero failures/skips. First full runs: bus 1406 pass/1 fail at unchanged host-run T27 sweep (expected one removed dir, got []), isolated T27 group 4/4 and full rerun green without changing it; WebUI 507 pass/1 fail/1 skip due absent package-local esbuild@0.28.2, restored with its lockfile, isolated build/provenance 7/7 and full rerun green. A newly added SSO test transport initially used nonexistent broker.release; corrected to disconnect after diagnosing its timeout, without weakening assertions. No independent reviewer launched; controller provides review. No tracker, role JSON, PRD, host/service/config/secrets changes, push, merge, PR or deployment. Internal identifier migration remains #798 / Gitea #1840.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 Console fix round 1 before | Fix base 7b51dbbba4778e7cb4d171dbf4c6e10f011b926e in the original prepared t3/b15e72fa checkout. Verify Important I1 in the existing Chromium harness: an obsolete A inbox/trail read (including kept-data fallback on error) must not replace shared answer state behind B controls. Scope is the decision state/render generation boundary only; controller supplies re-check. M1 editor-build warning remains deferred; no bus, lifecycle, rename, dependency, tracker or live operations.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 Console fix round 1 after | Verified Important I1 with three real Chromium regressions on 7b51dbbba: delayed A inbox, delayed A trail and failed delayed A trail with cached fallback all left B displayed but sent A confirmation URLs (0 pass/3 fail, no fixture/module errors). Root cause: decisionView mutated shared answer before render discarded obsolete HTML; fallback could start after a newer render too. Pass the originating render validity closure (generation, exact route, signed-in state) through both live and kept-data view paths and check before decision answer-state mutation; use the same check before committing page HTML. Small decision-state fix only; no renderer cleanup. GREEN I1 3/3; covering browser/SSO/HTTP 28/28; full WebUI 512/512, zero failures/skips/cancellations. Full output retains M1 ignored-bare-import warning at unchanged prosemirror-transform mark.ts import; deferred per controller. No bus file changed or bus suite rerun, no lifecycle/editor/rename/dependency/tracker/live changes, no subagent or reviewer, no push/merge/PR. Controller supplies fresh re-check. Prior evidence preserved; round 1 logs/report under /mnt/storage/scratch/tmp/mosaic-797-.
+4
View File
@@ -1980,3 +1980,7 @@ are never rewritten or removed; corrections are new entries.
2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | Vikunja #415 and #416, .53 and .54 Console writes | bd79daffd on t3/console-writes-53-54 (base 1ff45c4ad): factory takes the paired --sso --send with a console.sso check before any unit write; .53 business document on provider proxy, route anthropic, account factory/proxy; library --send not done (needs the #385 refusal reverted, returned to the lead); cli case2-unit 88, stack-upgrade 25, case1-accept 53, business 8, 3 and 3, no skips; 2 mutants killed; no push, queue or PR action.
2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | editor-browser paste test round 2 (Vikunja #784) | e71222d1a on t3/784-r2 (base 0c6aa1a1e): test-only; a MutationObserver on the live document records handler-bearing elements entering the page and h.quiet() asserts none, so the CSP filter cannot hide an attach-then-remove; the reviewer's mutation now fails the paste and live-handler tests; the four earlier mutants still killed; editor-browser 17/17, editor 5/5, editor-provenance 6/6, no skips; no push, queue or PR action.
2026-11-03 | Lead Codex GPT-6.1 Sol, resumed c5c1e8ba | Jason request-to-product intake, general Console decision buttons and autonomous product workflow (#797, Gitea #1839) | Recovered owner decisions and current code; queue next sage returned historical ledger row 7, direct owner request takes priority; drafted scoped design and acceptance, both Claude research/review delegates failed account rate limit; no product/host/config/role change, no provider substitution; owner alignment and coding-model exception outstanding.
2026-11-03 | GPT-6.1 Sol implementation sub-agent, prepared t3/b15e72fa | #797 / Gitea #1839 stage 1 ordinary Console answers | 2–9 authoritative choices, selected-option confirmation/receipt, full-context digest and lifecycle coverage; targeted broker 17 and WebUI 25, full bus 1407 and WebUI 509 passing with zero skips; initial unchanged T27 flake and locked dependency restoration recorded in BUILD-LOG; local commit only, controller review pending, no trackers or external state changed.
2026-11-03 | GPT-6.1 Sol implementation sub-agent, original t3/b15e72fa checkout | #797 / Gitea #1839 Console fix round 1, reviewer I1 | Real-browser delayed inbox/trail and cached-error races RED 0/3 -> GREEN 3/3; originating generation/route guard protects answer-state mutation and HTML commit; covering 28/28 and full WebUI 512/512, zero skips; M1 warning retained, controller re-check pending; local commit only, no bus/lifecycle/editor/rename/tracker/live changes.
+19 -15
View File
@@ -1681,17 +1681,21 @@ export class Broker {
['provider', 'iss', 'sub'].every((k) => typeof x.subject[k] === 'string' && x.subject[k].length > 0)
);
}
// Row 252 (design section 1): a Console-eligible grant request, or null. Open is the caller's
// check. Eligible is route_to human, action credential.mint, a raise with a validated grant binding
// and exactly two distinct option keys, one of them approvalChoice. Approve is that key and deny is
// the other. The summary carries only the binding's method, agent, host and target projects. The
// digest binds the id, business, options, approval key and binding, so any change reads stale.
// #797 / #1839: authoritative answers for a user-routed decision. Open is the caller's check.
// Ordinary decisions have 2–9 choices. Grant requests keep row 252's narrower eligibility and
// approve/deny summary; a missing binding never falls through to the ordinary path. The digest
// binds the question/action, route, full options and raise-time authorization/grant context.
#consoleResolution(d) {
if (d.route_to !== 'human' || d.action !== 'credential.mint') return null;
if (d.route_to !== 'human') return null;
const raise = this.#raised(d.business, d.id);
if (!object(raise?.grant) || typeof raise.grant.method !== 'string') return null;
if (!raise) return null;
const options = JSON.parse(d.options);
if (options.length !== 2 || options[0].key === options[1].key) return null;
if (!Array.isArray(options) || options.length < 2 || options.length > 9 ||
options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) ||
new Set(options.map(o => o.key)).size !== options.length) return null;
const digest = sha256(JSON.stringify([{ ...d, options }, raise]));
if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest };
if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null;
const approve = options.find((o) => o.key === raise.approvalChoice);
if (!approve) return null;
const deny = options.find((o) => o.key !== raise.approvalChoice);
@@ -1703,10 +1707,11 @@ export class Broker {
...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}),
};
return {
kind: 'grant', options,
approve: { key: approve.key, text: approve.text },
deny: { key: deny.key, text: deny.text },
grant,
digest: sha256(JSON.stringify([d.id, d.business, options, raise.approvalChoice, raise.grant])),
digest,
};
}
// r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only
@@ -6076,9 +6081,8 @@ export class Broker {
}
return this.#decisionView(this.#decision(s, did));
}
// Row 252 (#1753, design section 3): the Console's read of one grant request before its
// confirmation. It returns the two answers, a safe grant summary and the digest the answer must
// carry. Anything not eligible refuses, so the browser never decides eligibility (J1).
// The Console reads authoritative choices and context before confirmation. Ineligible
// decisions refuse here, so the browser never decides eligibility.
case 'decision.preview.console': {
keys(a, ['id'], ['id']);
identifier(a.id);
@@ -6100,13 +6104,13 @@ export class Broker {
if (a.note !== undefined) string(a.note);
const d = this.#decision(s, a.id);
if (this.#closed(d.id)) fail('decision-closed');
// Row 252 (design section 3): the Console answer rechecks eligibility, the digest and the choice
// in this transaction. It answers one of the two keys, never a key the browser names (J2).
// Recheck eligibility, context digest and membership in the current options in this
// transaction, before any resolution, audit, notification or grant execution.
if (viaConsole) {
if (!s.console || s.human !== b.human) fail('human-required');
const c = this.#consoleResolution(d);
if (!c) fail('decision-not-eligible');
if (a.digest !== c.digest || ![c.approve.key, c.deny.key].includes(a.choice)) fail('decision-stale');
if (a.digest !== c.digest || !c.options.some(o => o.key === a.choice)) fail('decision-stale');
} else if (verb === 'decision.withdraw') {
if (s.human) {
this.#human(s);
+104 -1
View File
@@ -152,7 +152,7 @@ test('preview: the two real answers, a grant summary and the digest, for an open
// The inbox row says the same, for the human and the reader. The agent-facing views do not.
for (const who of ['human', 'reader']) {
const row = f.call(who, 'inbox').find((r) => r.id === d.id);
assert.deepEqual(row.console, { approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
assert.deepEqual(row.console, { kind: 'grant', options, approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
}
});
@@ -171,6 +171,109 @@ test('J1, J2: a decision that is not an eligible grant request refuses both cons
}
});
test('ordinary three-choice decisions expose authoritative options and resolve the third choice once with Console provenance', (t) => {
const f = setup(t);
const choices = [
{ key: 'new/autonomous', text: 'New project — autonomous' },
{ key: 'new/guided', text: 'New project — guided' },
{ key: 'existing:project', text: 'Add to existing project' },
];
const d = f.call('chief', 'decision.raise', { action: 'prd.approve', question: 'How should we handle this request?', options: choices, recommendation: choices[0].key, approvalChoice: choices[0].key, blocking: false });
const p = f.call('console', 'decision.preview.console', { id: d.id });
assert.deepEqual(p.options, choices);
assert.equal(p.kind, 'decision');
assert.equal(p.grant, undefined);
assert.equal(p.approve, undefined);
for (const who of ['human', 'reader']) assert.deepEqual(f.call(who, 'inbox').find(r => r.id === d.id).console.options, choices);
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[2].key, digest: p.digest });
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: choices[2].key });
assert.deepEqual(f.closes(d.id).map(c => [c.op, c.by, c.choice, c.via]), [['resolved', 'jason', choices[2].key, 'console']]);
const inputs = f.events().filter(e => e.event === 'human.input' && e.about === d.id);
assert.equal(inputs.length, 1);
assert.deepEqual(inputs[0].subject, SUBJECT);
assert.equal(inputs[0].via, 'console');
assert.equal(inputs[0].actor_role, null);
assert.equal(inputs[0].actor_run, null);
assert.equal(inputs[0].decisionSeq, f.closes(d.id)[0].seq);
const notices = f.store.all("SELECT * FROM messages WHERE decision=? AND from_role='human' AND class='DECISION'", d.id);
assert.equal(notices.length, 1);
assert.equal(notices[0].to_role, 'chief');
assert.equal(notices[0].from_run, 'human-console');
assert.match(notices[0].body, /resolved by jason: existing:project \(Add to existing project\)/);
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[2].key, digest: p.digest }), 'decision-closed');
assert.equal(f.enrolls.length, 0);
assert.deepEqual(f.grantEvents(d.id), []);
assert.deepEqual(f.make().reconcileGrants(), []);
});
test('ordinary PRD approval and nine non-yes/no choices use the same protocol without grant execution', (t) => {
const f = setup(t);
for (const choices of [options, Array.from({ length: 9 }, (_, i) => ({ key: `choice-${i}`, text: `Choice ${i}` }))]) {
const d = f.call('chief', 'decision.raise', { action: 'prd.approve', question: 'Approve this PRD?', options: choices, recommendation: choices[0].key, approvalChoice: choices[0].key, blocking: false });
const p = f.call('console', 'decision.preview.console', { id: d.id });
assert.deepEqual(p.options, choices);
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'outside', digest: p.digest }), 'decision-stale');
assert.equal(f.closes(d.id).length, 0);
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[0].key, digest: p.digest }), { id: d.id, op: 'resolved', choice: choices[0].key });
assert.deepEqual(f.grantEvents(d.id), []);
}
assert.equal(f.enrolls.length, 0);
});
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
const f = setup(t);
for (const route of ['pm', 'chief']) {
const id = grantDecision(f.store, { action: 'prd.approve', route, close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
f.call(route, 'decision.resolve', { id, choice: 'yes' });
assert.deepEqual(f.closes(id).map(c => [c.by, c.via]), [[route + '-run', 'broker']]);
assert.equal(f.events().filter(e => e.event === 'human.input' && e.about === id).length, 0);
}
});
test('the digest binds ordinary question, action, options, business and raise authorization context', (t) => {
const f = setup(t);
const d = f.call('chief', 'decision.raise', { action: 'prd.approve', target: 'product-1', question: 'Approve draft?', options, recommendation: 'yes', blocking: false });
const p = f.call('console', 'decision.preview.console', { id: d.id });
const original = f.store.get('SELECT * FROM decisions WHERE id=?', d.id);
const event = f.store.get("SELECT * FROM events WHERE subject=? AND kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise'", d.id);
// Each immutable variant is inserted into a fresh scratch store, never rewritten in place.
for (const change of [
{ question: 'Approve changed draft?' }, { action: 'deploy' },
{ project: 'product-2' }, { spec_commit: 'f'.repeat(40) },
{ options: JSON.stringify([{ key: 'yes', text: 'Changed' }, options[1]]) },
{ authorization: { target: 'product-2' } }, { authorization: { approvalChoice: 'no' } },
]) {
const g = setup(t), { authorization, ...fields } = change;
const row = { ...original, ...fields };
const columns = Object.keys(row);
g.store.run(`INSERT INTO decisions(${columns.join(',')}) VALUES(${columns.map(() => '?').join(',')})`, ...Object.values(row));
const e = { ...event, body: JSON.stringify({ ...JSON.parse(event.body), ...authorization }) };
const ec = Object.keys(e);
g.store.run(`INSERT INTO events(${ec.join(',')}) VALUES(${ec.map(() => '?').join(',')})`, ...Object.values(e));
const current = g.call('console', 'decision.preview.console', { id: d.id });
assert.notEqual(current.digest, p.digest);
refusesSync(() => g.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: p.digest }), 'decision-stale');
assert.deepEqual(g.closes(d.id), []);
}
const other = grantDecision(f.store, { business: 'other', action: 'prd.approve', close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id: other }), 'decision-not-found');
refusesSync(() => f.call('console', 'decision.resolve.console', { id: other, choice: 'yes', digest: p.digest }), 'decision-not-found');
});
test('missing and malformed grant bindings cannot become ordinary Console answers or start an executor', (t) => {
const f = setup(t);
for (const grant of [undefined, {}, { host: 'web1' }, { method: 7 }]) {
const id = grantDecision(f.store, { grant, close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
assert.deepEqual(f.closes(id), []);
assert.deepEqual(f.grantEvents(id), []);
}
assert.equal(f.enrolls.length, 0);
});
test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => {
const f = setup(t);
const d = await f.raise();
+20 -10
View File
@@ -328,28 +328,38 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
same functions, so both show the same broker data.
- Nothing writes but the "Message a role" box, the Settings update, the Assistant
section's three buttons and the spec and docs editor's Save, all under `--send` (above,
"Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny below.
"Assistant settings" and "Spec and docs editor"), and the decision answers below.
A decision shows `mosaic decide <id> <key>` for each choice,
with Copy, which only puts the command on the clipboard. It is answered in a
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
with Copy, which only puts the command on the clipboard. This optional terminal
fallback remains available (REQ-DEC-3, Q4). Seen is not set from the browser. The
command has no `--business`, so `mosaic decide` uses the running bus host's
business, as the Console does by default. From a Console started with
`--business` for another business the copied command refuses (no bus host,
or no such decision) rather than answering elsewhere.
- Row 252 (#1753, PRD 1.9 REQ-WEB-2): a grant request (`credential.mint` routed
to the human, raised with a grant and two options) can also be approved or
denied on its decision page, only when the Console runs with both `--sso` and
- #797 / Gitea #1839 stage 1 (REQ-WEB-2), extending row 252 (#1753): an open
decision routed to the user offers one answer button per option, including
ordinary two-to-nine-option decisions with non-yes/no keys. A grant request
(`credential.mint`, raised with a grant and two options) retains Approve and
Deny with its concrete rights summary. Answers are enabled only with `--sso` and
`--send` and the page is signed in as a `console.sso` subject. The first click
POSTs `/api/decisions/<id>/confirmation` and shows what the answer does; only
Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to
the session, decision and choice). Each step spawns
the session, business, decision, choice and current context digest). The broker
supplies authoritative `options` and a `kind` of `decision` or `grant`; grants
also retain `approve`, `deny` and `grant`. Ordinary HTTP confirmations carry
`kind: decision`, `action: answer` and the selected key/text, so the receipt
records that choice without claiming a grant started. Each step spawns
`packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's
child holding the signed-in subject, and the bus records the answer `via
console` with a `human.input` naming that subject. Nothing retries; an unknown
outcome says to refresh. Without `--sso` the page has no buttons and says why;
outcome says to refresh. Cancellation, navigation and page reload discard the
page's confirmation; a refresh that finds a closed decision or a changed
context drops it too. The broker rechecks eligibility, digest and choice in
the resolve transaction. Missing or invalid grant bindings cannot fall through
to ordinary answers. Without `--sso` the page has no buttons and says why;
under `--sso` without `--send` it says the Console is read-only for decisions.
The tester listener has no decision routes. Every other decision stays
terminal-only.
The tester listener has no decision routes. Decisions routed to agent roles
remain with those roles, using their real role/run attribution.
- What no Q1 verb returns is labelled where it would show, for example
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
- A task's list row can only say its current state came from a Vikunja read,
+20 -10
View File
@@ -1,6 +1,6 @@
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5): Approve and Deny for a grant
// request, from the signed-in Console only. Two POSTs. /confirmation asks the bus for the decision's
// two answers and digest and returns a one-use confirmation bound to this session, business,
// #797 / #1839 extends row 252's signed-in Console answers to ordinary decisions. Two POSTs.
// /confirmation asks the bus for authoritative answers and digest and returns a one-use
// confirmation bound to this session, business,
// decision, choice and digest. /resolve takes only that choice and confirmation, consumes the
// confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus
// rechecks everything in the resolve transaction. Nothing here retries.
@@ -89,10 +89,17 @@ async function readBody(req) {
return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' };
} catch { return { code: 'invalid-request' }; }
}
const preview = (v, id) =>
v && typeof v === 'object' && v.id === id && typeof v.business === 'string' && DIGEST.test(v.digest ?? '') &&
['approve', 'deny'].every((k) => typeof v[k]?.key === 'string' && typeof v[k]?.text === 'string') &&
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string';
const option = o => o && typeof o.key === 'string' && o.key.length > 0 && typeof o.text === 'string' && o.text.length > 0;
const options = list => Array.isArray(list) && list.length >= 2 && list.length <= 9 && list.every(option) && new Set(list.map(o => o.key)).size === list.length;
function preview(v, id) {
if (!v || v.id !== id || typeof v.business !== 'string' || !DIGEST.test(v.digest ?? '')) return false;
if (v.kind === 'decision') return options(v.options) && !['grant', 'approve', 'deny'].some(k => k in v);
// Retain the existing grant preview fields, including previews from an older pinned broker.
return (v.kind === undefined || v.kind === 'grant') && option(v.approve) && option(v.deny) &&
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string' &&
(v.options === undefined || (options(v.options) && v.options.length === 2 &&
[v.approve, v.deny].every(o => v.options.some(x => x.key === o.key && x.text === o.text))));
}
// `session` is the live sign-in session object this request passed, and `live()` reads it again
// right before each spawn. `call(verb, args, { subject, business })` is the console transport.
@@ -120,13 +127,16 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
let v;
try { v = await decide.call('decision.preview.console', { id }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); }
if (!preview(v, id)) return refuse('confirmation-unavailable');
const action = value.choice === v.approve.key ? 'approve' : value.choice === v.deny.key ? 'deny' : null;
if (!action) return refuse('decision-stale');
const ordinary = v.kind === 'decision';
const chosen = (ordinary ? v.options : [v.approve, v.deny]).find(o => o.key === value.choice);
if (!chosen) return refuse('decision-stale');
const action = ordinary ? 'answer' : value.choice === v.approve.key ? 'approve' : 'deny';
if (live() !== session) return refuse('sign-in-required');
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest });
if (!confirmation) return refuse('confirmation-unavailable');
line(`action=${action}`);
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: v[action].text, grant: v.grant, confirmation });
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: chosen.text,
...(ordinary ? { kind: 'decision' } : { grant: v.grant }), confirmation });
}
const keys = Object.keys(value);
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
+40 -25
View File
@@ -258,7 +258,7 @@
// Inbox
function decisionCard(d) {
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge('Approve or Deny here', 'ok')}` : ''}</p>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(d.console.kind === 'decision' ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}</p>
<p class="small s1-auth">${approves(d.authorization)}</p>
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
}
@@ -316,7 +316,7 @@
el.outerHTML = composerHtml();
restore(k);
}
// Row 252 (#1753, design section 5): Approve and Deny for an open grant request, drawn only when
// #797 / #1839 extends row 252's answer panel to every eligible option, drawn only when
// the server says this Console answers decisions (`decide` in /api/config: signed in through SSO
// and started with --send) and the bus marked the inbox row eligible (its `console` field). The
// first click asks the Console for a one-use confirmation that names what the answer does. Only
@@ -324,7 +324,11 @@
// unknown outcome says to refresh first. The state lives in `answer`, not the DOM, so the 10 s
// refresh redraws it. The server and the bus check everything again; this is presentation only.
let canDecide = false, ssoOn = false, answer = null;
document.addEventListener('mosaic-signed-out', () => { answer = null; });
const ACTIONS = { approve: 'Approve', deny: 'Deny' };
const answerChoices = c => c.kind === 'decision'
? c.options.map((o, i) => ({ ...o, step: `option-${i}`, label: o.text }))
: Object.entries(ACTIONS).map(([step, label]) => ({ ...c[step], step, label }));
const grantText = g => [`method ${g.method}`, g.agent && `agent ${g.agent}`, g.host && `host ${g.host}`, Array.isArray(g.projects) && g.projects.length && `projects ${g.projects.join(', ')}`].filter(Boolean).map(s => txt(String(s))).join(' · ');
function decideHtml() {
const a = answer;
@@ -333,17 +337,18 @@
let body;
if (a.result) {
const r = a.result;
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}. ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'} History below shows it once the bus is read again.</p>`;
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.</p>`;
} else if (a.confirm) {
const c = a.confirm, label = `Confirm ${c.action}`;
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd><dt>Grant</dt><dd>${grantText(c.grant)}</dd></dl>
<p class="small muted">${c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd>${c.kind === 'decision' ? '' : `<dt>Grant</dt><dd>${grantText(c.grant)}</dd>`}</dl>
<p class="small muted">${c.kind === 'decision' ? 'Confirm to record this choice.' : c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
} else {
body = `<ul class="s1-plain">${Object.entries(ACTIONS).map(([k, l]) => `<li><b>${l}</b> chooses <b class="s1-key">${txt(a.console[k].key)}</b> ${txt(a.console[k].text)}</li>`).join('')}</ul>
<p class="small muted">Grant: ${grantText(a.console.grant)}. Either button opens a confirmation first. Nothing is answered until you confirm.</p>
<div class="s1-cmd">${Object.entries(ACTIONS).map(([k, l]) => `<button type="button" class="btn${k === 'approve' ? ' primary' : ''}" data-decide="${k}"${off}>${a.pending === k ? 'Checking…' : l}</button>`).join('')}</div>`;
const choices = answerChoices(a.console);
body = `<ul class="s1-plain">${choices.map(o => `<li>${a.console.kind === 'decision' ? '' : `<b>${txt(o.label)}</b> chooses `}<b class="s1-key">${txt(o.key)}</b> ${txt(o.text)}</li>`).join('')}</ul>
<p class="small muted">${a.console.kind === 'decision' ? '' : `Grant: ${grantText(a.console.grant)}. `}Each button opens a confirmation first. Nothing is answered until you confirm.</p>
<div class="s1-cmd">${choices.map(o => `<button type="button" class="btn${o.step === 'approve' ? ' primary' : ''}" data-decide="${o.step}"${off}>${a.pending === o.step ? 'Checking…' : txt(o.label)}</button>`).join('')}</div>`;
}
const e = a.error;
const said = e ? `<p class="receipt failed" id="decide-status" tabindex="-1">Not answered here: ${txt(e.message)} (<code>${txt(e.code)}</code>).${e.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}</p>` : '';
@@ -375,18 +380,20 @@
async function decide(step) {
const a = answer;
if (!a || a.pending || a.result) return;
if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide="approve"]'); announce('Cancelled. Nothing was answered.'); return; }
if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide]'); announce('Cancelled. Nothing was answered.'); return; }
if (out()) { a.confirm = null; a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); return; }
if (step === 'approve' || step === 'deny') {
const choice = a.console[step].key;
const selected = answerChoices(a.console).find(o => o.step === step);
if (selected) {
const choice = selected.key, ordinary = a.console.kind === 'decision';
a.pending = step; a.error = null; a.confirm = null; paintDecide();
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice });
a.pending = null;
// The page moved to another decision meanwhile: its confirmation is dropped, never shown.
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); signOut(); return; }
if (res?.ok && json?.id === a.id && json.action === step && json.choice === choice && typeof json.confirmation === 'string' && json.grant && typeof json.grant.method === 'string') {
a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${step}: ${json.text}`);
if (res?.ok && json?.id === a.id && json.action === (ordinary ? 'answer' : step) && json.choice === choice && typeof json.text === 'string' && typeof json.confirmation === 'string' &&
(ordinary ? json.kind === 'decision' && !json.grant : json.grant && typeof json.grant.method === 'string')) {
a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${json.action}: ${json.text}`);
} else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
return;
}
@@ -399,8 +406,8 @@
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
a.result = { action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
paintDecide('#decide-status'); announce(c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
paintDecide('#decide-status'); announce(c.kind === 'decision' ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
}
async function inboxView(get) {
@@ -408,7 +415,7 @@
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
countInbox(rows);
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
const answered = canDecide ? 'A grant request can be approved or denied here, after a confirmation. Every other decision is answered in a terminal with <code>mosaic decide</code>.'
const answered = canDecide ? 'Open an eligible decision to answer any of its options here, after a confirmation. Grant requests keep their Approve and Deny confirmation. You can also use <code>mosaic decide</code> in a terminal.'
: 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.';
return `${head('Inbox', `${readNote()} ${answered}`)}
${await composer(get)}
@@ -426,8 +433,10 @@
<p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
<nav aria-label="Message pages">${after ? '<a href="#/messages">Newest messages</a> · ' : ''}${page.next ? `<a data-older href="#/messages?after=${enc(page.next)}">Older messages</a>` : ''}</nav>`;
}
async function decisionView(get, id) {
async function decisionView(get, id, current) {
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
// Obsolete reads, including kept-data fallback, must not replace the displayed answer state.
if (!current()) return '';
if (!d) return notFound(`No decision with id ${txt(id)}.`);
const c = d.closed;
const status = c ? (c.op === 'resolved' ? `Resolved: ${txt(c.by)} chose “${txt(c.choice)}”${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}.` : `${esc(c.op[0].toUpperCase() + c.op.slice(1))} by ${txt(c.by)} ${when(c.at)}${c.note ? `: ${txt(c.note)}` : ''}.`) : `Open since ${when(d.at)}.`;
@@ -439,7 +448,10 @@
// or this page just answered it.
const eligible = canDecide && answerable && open?.console ? open.console : null;
if (answer?.id !== d.id) answer = eligible ? { id: d.id, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
else if (eligible && !answer.pending && !answer.confirm) answer.console = eligible;
else if (eligible && !answer.pending) {
if (answer.console.digest !== eligible.digest) answer.confirm = null;
answer.console = eligible;
}
else if (!eligible && !answer.pending && !answer.result) answer = null;
const options = `<ol class="s1-opts">${d.options.map(o => {
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
@@ -454,8 +466,8 @@
// draws a disabled one (design section 5).
const why = !answerable || isTester || canDecide ? ''
: ssoOn ? ' This Console is read-only for decisions: it was started without writes enabled (--send), so answers go through a terminal.'
: ' Answering a grant request from the Console needs sign-in (--sso) and writes enabled (--send).';
const help = eligible ? '<p class="small muted">Approve or Deny in the panel above, or run one of these in a terminal. Copy only puts the command on your clipboard.</p>'
: ' Answering a decision from the Console needs sign-in (--sso) and writes enabled (--send).';
const help = eligible ? '<p class="small muted">Choose an answer in the panel above. You can also run one of these in a terminal. Copy only puts the command on your clipboard.</p>'
: answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
return `${head(`Decision ${txt(short(d.id))}`, status)}
@@ -1177,12 +1189,12 @@
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
const routeOf = () => location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
function view(get, path, q, opts) {
function view(get, path, q, opts, current) {
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
const [a, b, c] = parts, n = parts.length;
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
if (a === 'tasks' && n === 1) return tasksView(get);
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
@@ -1231,7 +1243,10 @@
clearTimeout(timer);
if (out()) return;
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
// Leaving a decision discards its confirmation immediately, including a preview in flight.
if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null;
const g = ++gen;
const current = () => g === gen && route === routeOf() && !out();
document.body.classList.toggle('on-bus', !board);
$('bus-view').hidden = board;
sections(path);
@@ -1269,15 +1284,15 @@
try { await live('messages'); }
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
}
html = await view(live, path, new URLSearchParams(query), opts); readAt = live.oldest(); }
html = await view(live, path, new URLSearchParams(query), opts, current); readAt = live.oldest(); }
catch (err) {
if (err.code === 'signed-out') return;
const kept = reader(false);
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query), opts); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query), opts, current); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
if (refusal(err)) forget();
}
if (g !== gen) return;
if (!current()) return;
freshBus(state, SOURCES[path.split('/')[1]]);
const k = !navigated && shown === route ? keep() : null;
unmountEditor();
+179 -3
View File
@@ -36,6 +36,20 @@ const STUB = `(() => {
const c = await (await real(url, init)).json();
return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) });
}
if (window.__delayRead?.url === u) {
const delay = window.__delayRead;
window.__delayRead = null;
const response = await real(url, init);
window.__readHeld = true;
await new Promise(resolve => { window.__releaseRead = resolve; });
if (delay.reject) throw new TypeError('Delayed read failed');
return response;
}
if (u === '/api/bus/inbox' && window.__changedDecision) {
const body = await (await real(url, init)).json();
body.rows = body.rows.map(row => row.id === window.__changedDecision ? { ...row, console: { ...row.console, digest: 'e'.repeat(64) } } : row);
return answer(200, body);
}
if (!u.startsWith('/api/decisions/')) return real(url, init);
window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect });
const a = window.__answers.shift();
@@ -64,13 +78,25 @@ function hostileGrant(store, grant) {
return id;
}
async function setup() {
async function setup({ ordinary = false, paired = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-'));
const store = new Store(root), b0 = new Broker({ store, businesses });
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
const open = hostileGrant(store, grant);
const deploy = grantDecision(store, { action: 'deploy', grant, close: null });
const bare = grantDecision(store, { close: null });
const chief = b0.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
b0.request(chief, { verb: 'role.claim' });
const normal = ordinary ? b0.request(chief, { verb: 'decision.raise', args: {
action: 'prd.approve', question: `Choose a project ${HOSTILE}`, blocking: false,
options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: `Add to existing project ${HOSTILE}` }],
recommendation: 'new/autonomous', approvalChoice: 'new/autonomous',
} }).id : null;
const other = paired ? b0.request(chief, { verb: 'decision.raise', args: {
action: 'prd.approve', question: 'Decision B: choose a project', blocking: false,
options: [{ key: 'B-0', text: 'B autonomous' }, { key: 'B-1', text: 'B guided' }, { key: 'B-2', text: 'B existing project' }],
recommendation: 'B-0', approvalChoice: 'B-0',
} }).id : null;
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser();
@@ -93,7 +119,7 @@ async function setup() {
};
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, store, base, open, deploy, bare, wait, text, has, posts, queue, click, page, settle, done };
return { b, store, base, open, deploy, bare, normal, other, wait, text, has, posts, queue, click, page, settle, done };
}
const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } });
@@ -135,7 +161,7 @@ test('J16: no controls without sso and decide, read-only reasons, terminal lines
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3');
await s.settle();
assert.match(await s.text('#bus-view'), /A grant request can be approved or denied here, after a confirmation\./);
assert.match(await s.text('#bus-view'), /Open an eligible decision to answer any of its options here, after a confirmation\./);
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1);
assert.deepEqual(await s.posts(), []);
} finally { await s.done(); }
@@ -248,3 +274,153 @@ test('a sign-in redirect at either step signs the page out and answers nothing',
assert.equal((await s.posts()).length, 2);
} finally { await s.done(); }
});
test('ordinary choices render every key/text, cancel and navigation discard confirmations, and the third option records an accurate receipt', { timeout: 120000 }, async () => {
const s = await setup({ ordinary: true });
const confirmation = { status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: `Choose a project ${HOSTILE}`, choice: 'existing:project', text: `Add to existing project ${HOSTILE}`, confirmation: 'd'.repeat(64) } };
try {
await s.page('sso&decide', s.normal);
assert.equal(await s.has('#decide-section'), true);
assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => e.textContent)'), ['New project — autonomous', 'New project — guided', `Add to existing project ${HOSTILE}`]);
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
assert.doesNotMatch(await s.text('#decide-section'), /Grant:|Approve|Deny/);
await s.queue({ ...confirmation, hold: true });
await s.click('option-2');
await s.click('option-2');
await s.b.evaluate('window.__release(), true');
await s.wait('document.querySelector("[data-decide=confirm]")');
assert.equal((await s.posts()).length, 1);
assert.match(await s.text('.s1-confirm'), /Confirm answer/);
assert.doesNotMatch(await s.text('.s1-confirm'), /Grant|Approving|Denying/);
await s.settle();
assert.equal(await s.has('[data-decide=confirm]'), true);
await s.click('cancel');
assert.equal(await s.has('[data-decide=confirm]'), false);
assert.equal((await s.posts()).length, 1);
await s.queue(confirmation);
await s.click('option-2');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox")');
await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.normal}`);
await s.wait('document.querySelector("#decide-section")');
assert.equal(await s.has('[data-decide=confirm]'), false, 'leaving the decision must discard its confirmation');
assert.equal((await s.posts()).length, 2);
// Navigation while the preview is in flight must also discard the late answer.
await s.queue({ ...confirmation, hold: true });
await s.click('option-2');
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox")');
await s.b.evaluate('window.__release(), true');
await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.normal}`);
await s.wait('document.querySelector("#decide-section")');
assert.equal(await s.has('[data-decide=confirm]'), false);
await s.queue(confirmation);
await s.click('option-2');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.queue({ status: 200, hold: true, body: { id: s.normal, op: 'resolved', choice: 'existing:project' } });
await s.click('confirm');
await s.click('confirm');
await s.b.evaluate('window.__release(), true');
await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
assert.match(await s.text('#decide-status'), /Recorded: you chose “existing:project”, Add to existing project/);
assert.doesNotMatch(await s.text('#decide-status'), /grant|denial|Approved|Denied|Nothing starts/);
await s.settle();
const posts = await s.posts();
assert.equal(posts.filter(p => p.url.endsWith('/resolve')).length, 1);
assert.equal(await s.has('[data-decide]'), false);
} finally { await s.done(); }
});
test('ordinary confirmation is dropped on closure, reload, session expiry and uncertain outcome without automatic replay', { timeout: 120000 }, async () => {
const s = await setup({ ordinary: true });
const confirmation = { status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: 'Choose a project', choice: 'new/guided', text: 'New project — guided', confirmation: 'd'.repeat(64) } };
try {
await s.page('sso&decide', s.normal);
assert.equal(await s.has('#decide-section'), true);
await s.queue(confirmation);
await s.click('option-1');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.page('sso&decide', s.normal);
assert.equal(await s.has('[data-decide=confirm]'), false);
assert.equal((await s.posts()).length, 0);
await s.queue(confirmation);
await s.click('option-1');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.queue({ reject: true });
await s.click('confirm');
await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"');
await s.settle();
assert.equal((await s.posts()).length, 2);
assert.equal(await s.has('[data-decide=confirm]'), false);
await s.queue(confirmation);
await s.click('option-1');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.queue({ status: 401, body: { error: 'sign-in-required', message: 'Sign in again before answering.' } });
await s.click('confirm');
await s.wait('document.querySelector("#decide-status code")?.textContent === "sign-in-required" || document.body.classList.contains("signed-out")');
assert.equal(await s.has('[data-decide=confirm]'), false);
assert.equal((await s.posts()).length, 4);
await s.page('sso&decide', s.normal);
await s.queue(confirmation);
await s.click('option-1');
await s.wait('document.querySelector("[data-decide=confirm]")');
s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.normal, '2026-11-03T00:00:00.000Z', 'resolved', 'jason', 'new/autonomous', 'cli');
await s.wait('!document.querySelector("#decide-section")');
assert.equal((await s.posts()).filter(p => p.url.endsWith('/resolve')).length, 0);
} finally { await s.done(); }
});
test('a changed authoritative context drops an open ordinary confirmation before another answer can be submitted', { timeout: 120000 }, async () => {
const s = await setup({ ordinary: true });
try {
await s.page('sso&decide', s.normal);
await s.queue({ status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: 'Choose a project', choice: 'new/autonomous', text: 'New project — autonomous', confirmation: 'c'.repeat(64) } });
await s.click('option-0');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.b.evaluate(`window.__changedDecision = ${JSON.stringify(s.normal)}`);
await s.wait('!document.querySelector("[data-decide=confirm]") && document.querySelector("[data-decide=option-0]")');
assert.equal((await s.posts()).length, 1);
assert.equal((await s.posts()).filter(p => p.url.endsWith('/resolve')).length, 0);
} finally { await s.done(); }
});
for (const [read, reject] of [['inbox', false], ['trail', false], ['trail', true]]) {
test(`I1: obsolete A ${read} read${reject ? ' failure with cached fallback' : ' completion'} cannot redirect B answer controls`, { timeout: 120000 }, async () => {
const s = await setup({ ordinary: true, paired: true });
try {
// Warm A's cache so the failed read also exercises the kept-data continuation.
await s.page('sso&decide', s.normal);
await s.b.evaluate('document.querySelector("#pause").click(), true');
assert.equal(await s.b.evaluate('document.querySelector("#pause").getAttribute("aria-pressed")'), 'true');
const url = read === 'inbox' ? '/api/bus/inbox' : `/api/bus/trail?subject=${encodeURIComponent(s.normal)}`;
await s.b.evaluate(`window.__delayRead = ${JSON.stringify({ url, reject })}; document.querySelector('#refresh').click(); true`);
await s.wait('window.__readHeld === true');
await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.other}`);
await s.wait('document.querySelector("#bus-view .s1-q")?.textContent === "Decision B: choose a project" && document.querySelector("[data-decide=option-2]")?.textContent === "B existing project"');
await s.b.evaluate('window.__releaseRead(), true');
await s.settle();
assert.equal(await s.text('#bus-view .s1-q'), 'Decision B: choose a project');
assert.equal(await s.text('[data-decide=option-2]'), 'B existing project');
await s.queue({ status: 200, body: { id: s.other, kind: 'decision', action: 'answer', question: 'Decision B: choose a project', choice: 'B-2', text: 'B existing project', confirmation: 'b'.repeat(64) } });
await s.click('option-2');
const [preview] = await s.posts();
assert.equal(preview.url, `/api/decisions/${encodeURIComponent(s.other)}/confirmation`, 'B controls must request confirmation for B after the obsolete A continuation');
assert.deepEqual(JSON.parse(preview.body), { choice: 'B-2' });
await s.wait('document.querySelector("[data-decide=confirm]")');
assert.equal(await s.text('#bus-view .s1-q'), 'Decision B: choose a project');
assert.match(await s.text('.s1-confirm'), /B-2.*B existing project/);
await s.queue({ status: 200, body: { id: s.other, op: 'resolved', choice: 'B-2' } });
await s.click('confirm');
await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
const posts = await s.posts();
assert.equal(posts.length, 2);
assert.equal(posts[1].url, `/api/decisions/${encodeURIComponent(s.other)}/resolve`);
assert.deepEqual(JSON.parse(posts[1].body), { choice: 'B-2', confirmation: 'b'.repeat(64) });
} finally { await s.done(); }
});
}
+76 -1
View File
@@ -7,7 +7,13 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { readFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../../bus/src/broker.mjs';
import { Store } from '../../bus/src/store.mjs';
import { views } from '../../bus/src/views.mjs';
import { browser } from './browser.mjs';
import { startServer } from '../src/serve.mjs';
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
import { DECISION_REFUSALS } from '../src/decision-http.mjs';
@@ -96,6 +102,75 @@ test('a signed-in owner confirms and answers, with the session\'s provider, issu
assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false);
});
test('a signed-in browser selects the third ordinary option through HTTP and records exactly one audited resolution and raiser notification, with zero grants', { timeout: 120000 }, async t => {
const root = mkdtempSync(join(tmpdir(), 'console-797-sso-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { delivery: 'pm', technical: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } };
let grants = 0;
const broker = new Broker({ store, businesses: { demo: business }, hostGrant: { enroll: async () => { grants++; } } });
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
broker.request(chief, { verb: 'role.claim' });
const d = broker.request(chief, { verb: 'decision.raise', args: { action: 'prd.approve', question: 'Choose a product project', blocking: false,
options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }], recommendation: 'new/autonomous', approvalChoice: 'new/autonomous' } });
const reader = broker.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => broker.request(reader, { verb, args }) });
const calls = [];
const env = await setup(t, { bus, decisionWrite: async (verb, args, ctx) => {
calls.push({ verb, args, ctx });
const cap = broker.bindConsole({ business: ctx.business ?? 'demo', human: 'jason', subject: ctx.subject });
try { return broker.request(cap, { verb, args }); } finally { broker.disconnect(cap); }
} });
const session = await env.signIn();
const b = await browser();
t.after(() => b.close());
// Existing CDP harness pattern: fulfill a synthetic HTTPS origin from this scratch listener,
// carrying the real fake-OP session just established above; no live Console or proxy is used.
b.on('Fetch.requestPaused', ({ requestId, request: incoming }) => {
const url = new URL(incoming.url);
const req = request({ host: '127.0.0.1', port: env.port, path: url.pathname + url.search, method: incoming.method,
headers: { ...incoming.headers, host: HOST, cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } }, res => {
const parts = [];
res.on('data', part => parts.push(part));
res.on('end', () => {
const responseHeaders = Object.entries(res.headers).filter(([name]) => !['connection', 'keep-alive', 'transfer-encoding'].includes(name))
.flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).map(value => ({ name, value: String(value) })));
b.call('Fetch.fulfillRequest', { requestId, responseCode: res.statusCode, responseHeaders, body: Buffer.concat(parts).toString('base64') }).catch(() => {});
});
});
req.on('error', () => b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {}));
req.end(incoming.postData);
});
await b.call('Fetch.enable', { patterns: [{ urlPattern: ORIGIN + '/*', requestStage: 'Request' }] });
await b.navigate(`${ORIGIN}/#/inbox/${d.id}`);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,50))}throw new Error('timed out')})()`);
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && document.querySelector("#bus-view .s1-opts")');
assert.equal(await b.evaluate('document.querySelectorAll("#decide-section [data-decide]").length'), 3);
await b.evaluate('document.querySelector("[data-decide=option-2]").click()');
await wait('document.querySelector("[data-decide=confirm]")');
assert.equal(store.get("SELECT count(*) AS n FROM decision_events WHERE decision=? AND op='resolved'", d.id).n, 0);
await b.evaluate('document.querySelector("[data-decide=confirm]").click(); document.querySelector("[data-decide=confirm]")?.click()');
await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
assert.match(await b.evaluate('document.querySelector("#decide-status").textContent'), /existing:project.*Add to existing project/);
assert.doesNotMatch(await b.evaluate('document.querySelector("#decide-status").textContent'), /grant|denial|Approved|Denied/);
const closes = store.all('SELECT * FROM decision_events WHERE decision=?', d.id);
assert.equal(closes.length, 1);
assert.deepEqual([closes[0].op, closes[0].by, closes[0].choice, closes[0].via], ['resolved', 'jason', 'existing:project', 'console']);
const inputs = store.all("SELECT * FROM events WHERE kind='human.input' AND subject=?", d.id);
assert.equal(inputs.length, 1);
const input = JSON.parse(inputs[0].body);
assert.deepEqual(input.subject, { provider: 'a', iss: env.iss.issuer, sub: 'user-a' });
assert.equal(input.via, 'console');
assert.equal(input.decisionSeq, closes[0].seq);
const notices = store.all("SELECT * FROM messages WHERE decision=? AND class='DECISION' AND from_role='human'", d.id);
assert.equal(notices.length, 1);
assert.equal(notices[0].to_role, 'chief');
assert.equal(notices[0].from_run, 'human-console');
assert.equal(calls.filter(c => c.verb === 'decision.resolve.console').length, 1);
assert.equal(grants, 0);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 0);
});
test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => {
const env = await setup(t);
refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required');
@@ -86,6 +86,67 @@ test('deny resolves without a grant field', async () => {
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
});
const ORDINARY = {
id: 'd1', business: 'demo', question: 'How should we handle this request?', digest: DIGEST, kind: 'decision',
options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }],
};
test('ordinary confirmation carries the selected third key/text and decision context, then resolves once in its business', async () => {
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
const c = await s.confirm('existing:project');
assert.equal(c.status, 200, c.text);
assert.deepEqual({ ...c.json(), confirmation: 'x' }, { id: 'd1', kind: 'decision', question: ORDINARY.question, action: 'answer', choice: 'existing:project', text: 'Add to existing project', confirmation: 'x' });
const body = { choice: 'existing:project', confirmation: c.json().confirmation };
assert.deepEqual((await s.resolve(body)).json(), { id: 'd1', op: 'resolved', choice: 'existing:project' });
assert.equal(s.calls[1].ctx.business, 'demo');
assert.equal(s.calls[1].args.digest, DIGEST);
refused(await s.resolve(body), 'confirmation-required');
assert.equal(s.calls.length, 2);
});
test('ordinary options are validated authoritatively at confirmation, including two and nine choices', async () => {
for (const options of [ORDINARY.options.slice(0, 2), Array.from({ length: 9 }, (_, i) => ({ key: `key:${i}`, text: `Answer ${i}` }))]) {
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
assert.equal((await s.confirm(options.at(-1).key)).status, 200);
refused(await s.confirm('not-listed'), 'decision-stale');
}
for (const options of [[], ORDINARY.options.slice(0, 1), Array(10).fill(ORDINARY.options[0]), [ORDINARY.options[0], ORDINARY.options[0]], [{ key: 1, text: 'x' }, ORDINARY.options[1]], [{ key: 'x' }, ORDINARY.options[1]]]) {
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
refused(await s.confirm('new/autonomous'), 'confirmation-unavailable');
assert.equal(s.confirmations.size, 0);
}
});
test('ordinary stale/closed and uncertain outcomes spend their confirmation and never replay', async () => {
for (const code of ['decision-stale', 'decision-closed', 'outcome-unknown']) {
const s = setup({ answer: () => ORDINARY, resolve: () => { throw new BusReadError(code); } });
const c = await s.confirm('new/guided');
assert.equal(c.status, 200, c.text);
const body = { choice: 'new/guided', confirmation: c.json().confirmation };
refused(await s.resolve(body), code);
refused(await s.resolve(body), 'confirmation-required');
assert.equal(s.calls.length, 2);
}
});
test('ordinary confirmations stay bound to the issuing session, choice, decision and business', async () => {
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
let c = (await s.confirm('new/guided')).json();
refused(await s.resolve({ choice: 'existing:project', confirmation: c.confirmation }), 'confirmation-required');
c = (await s.confirm('new/guided')).json();
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
c = (await s.confirm('new/guided')).json();
s.current = null;
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'sign-in-required');
s.current = s.session;
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
const other = setup({ answer: () => ({ ...ORDINARY, business: 'other' }) });
other.confirmations = s.confirmations;
c = (await s.confirm('new/guided')).json();
refused(await other.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
assert.equal(s.calls.filter(x => x.verb === 'decision.resolve.console').length, 0);
assert.equal(other.calls.length, 0);
});
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
const s = setup();
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');