master
John Lauer v2.0.397 (insiders): First-run fix (arav-rog): npm install can no longer fail on puppeteer's postinstall Chrome downloa… 8b4f135 2mo ago
// consent.js — the human-consent registry behind pup's approval toasts (v2.0.397, pup-bridge#92).
//
// ab's BRIDGE_SDK hard rule (ab 2.1.30, adom-bridge#142/#137): a verb parked on a HUMAN decision
// must never end in a bare transport timeout. It answers with a structured, non-terminal
//   { success:false, errorCode:"blocked_on_user", stillRunning:true, blockedBy, _hint }
// so the calling AI can tell "waiting on the user" from "failed".
//
// pup's approval toasts (pup_credential_reveal, pup_fill_card) used to block the verb for the
// toast's full 120 s. That is longer than some ab per-verb budgets (a 60 s cap was measured on
// ab 2.0.97), so a slow human produced exactly the bare timeout the rule forbids, while the toast
// stayed up and a late click authorised something nobody was listening for.
//
// The registry fixes that in one place:
//   - the verb waits IN-VERB for at most inVerbMs (45 s, under every ab budget), then returns
//     blocked_on_user with stillRunning:true; the toast stays up and the decision keeps being
//     collected in the background for totalSec (120 s);
//   - a re-call of the SAME verb with the SAME args (same key) RE-ATTACHES to the pending toast:
//     no second toast, and a decision that arrived in between is handed over exactly ONCE;
//   - on the toast's own timeout the sticky toast is retracted (notify_dismiss) and the recorded
//     outcome is consent_timeout, picked up by the next re-call or dropped after pickupMs.
//
// Pure module: everything OS-facing (notify_user / notify_dismiss) comes in through `abCommand`.

'use strict';

function createRegistry({ abCommand, abPayload, inVerbMs = 45000, totalSec = 120, pickupMs = 120000, log = () => {}, now = Date.now, setTimer = setTimeout } = {}) {
  if (typeof abCommand !== 'function') throw new Error('consent registry needs abCommand(verb, args, opts)');
  const payloadOf = typeof abPayload === 'function' ? abPayload : (r) => r;
  const map = new Map();   // consentId -> record { id, key, verb, what, since, decided, decision, pickedUp, attached, promise }

  function findPending(key) {
    for (const rec of map.values()) if (rec.key === key && !rec.pickedUp) return rec;
    return null;
  }

  // Resolve to the decision string (the button label) or null on timeout / transport error.
  function collect(rec, toast, callerThread, callerReason) {
    return (async () => {
      let decision = null;
      try {
        const r = await abCommand('notify_user', { id: rec.id, ...toast, wait: true, timeoutSec: totalSec }, { timeoutMs: (totalSec + 10) * 1000, callerThread, callerReason });
        const p = payloadOf(r) || {};
        decision = p.action || (r && r.action) || null;
      } catch (e) { decision = null; }
      rec.decision = decision; rec.decided = now();
      let retract = 'n/a';
      if (!decision) {
        // A reminder toast is STICKY: ab never auto-dismisses it, timeoutSec only bounds the wait.
        try { const rr = await abCommand('notify_dismiss', { id: rec.id }, { timeoutMs: 8000 }); const pp = payloadOf(rr) || {}; retract = String(pp.dismissed !== undefined ? pp.dismissed : (rr && rr.dismissed)); }
        catch (e) { retract = 'error: ' + e.message; }
      }
      log(rec, decision, retract);
      const t = setTimer(() => { if (map.get(rec.id) === rec) map.delete(rec.id); }, pickupMs);
      if (t && typeof t.unref === 'function') t.unref();
      return decision;
    })();
  }

  // opts: { key, verb, what, toast:{ idPrefix, ...notify_user args (title, body, buttons, level, scenario) }, callerThread, callerReason }
  // -> { pending:true, consentId, since, attached, remainingSec }
  // -> { pending:false, consentId, decision:string|null, attached }
  async function ask(opts) {
    const { key, verb, what, toast, callerThread, callerReason } = opts || {};
    if (!key || !verb || !toast) throw new Error('consent.ask needs key, verb, toast');
    let rec = findPending(key);
    if (!rec) {
      const { idPrefix, ...toastArgs } = toast;
      rec = { id: `${idPrefix || 'consent'}-${now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, key, verb, what: what || `Waiting for the user to answer "${toastArgs.title || verb}"`, since: now(), decided: null, decision: undefined, pickedUp: false, attached: 0, promise: null };
      rec.promise = collect(rec, toastArgs, callerThread, callerReason);
      map.set(rec.id, rec);
    } else {
      rec.attached++;
    }
    let capTimer = null;
    const cap = new Promise((resolve) => { capTimer = setTimer(() => resolve('__pending__'), inVerbMs); });   // NOT unref'd: the verb is awaiting it
    const outcome = await Promise.race([rec.promise, cap]);
    if (capTimer) clearTimeout(capTimer);
    if (outcome === '__pending__') {
      const remainingSec = Math.max(0, Math.round(totalSec - (now() - rec.since) / 1000));
      return { pending: true, consentId: rec.id, since: rec.since, attached: rec.attached, remainingSec };
    }
    rec.pickedUp = true; map.delete(rec.id);
    return { pending: false, consentId: rec.id, decision: rec.decision, attached: rec.attached };
  }

  // Rows for pup_readiness.active[]: still-open toasts are blockedOnUser; a decided-but-unpicked
  // record shows as consent_decided so a poller knows to re-call the verb and collect it.
  function rows(at) {
    const t = at || now(); const out = [];
    for (const [id, c] of map) {
      const decided = !!c.decided;
      out.push({
        id, verb: c.verb, phase: decided ? 'consent_decided' : 'consent', stepLabel: decided ? `The user answered; re-call ${c.verb} with the same args to collect the decision` : c.what,
        percent: null, elapsedSec: Math.round((t - c.since) / 1000), estimatedSec: null, etaSec: null, confidence: 'measured',
        blockedOnUser: !decided, blockedBy: decided ? null : 'your Approve/Deny click on the desktop toast', reattach: `${c.verb} again with the SAME args`,
      });
    }
    return out;
  }

  return { ask, rows, map, inVerbMs, totalSec };
}

// The response shape every blocked-on-a-human path returns (SDK 2.1.30). stillRunning:true = the
// wait continues in the background (re-call the same verb to re-attach); false = the human must
// act first, then the caller re-issues the verb.
function blockedOnUser(extra) {
  return Object.assign({ success: false, status: 'error', errorCode: 'blocked_on_user', stillRunning: false }, extra || {});
}

module.exports = { createRegistry, blockedOnUser };