Compare commits
2
Commits
refactor
...
t3/b15e72fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b867cf8e | ||
|
|
7b51dbbba4 |
No files matched your search
@@ -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-.
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user