master
John Lauer rust: Phase 3 complete (vault, cards, wiki auto-sign + view flip, annotate, tab + screen recording, highFps lease, down… 4869577 2mo ago
//! The human-consent registry behind pup's approval toasts (port of src/consent.js, pup-bridge#92).
//! A verb parked on the human's click waits in-verb for at most IN_VERB (45 s, under every ab
//! budget), then answers blocked_on_user with stillRunning:true while the toast stays up for
//! TOTAL_SEC; a re-call with the same key re-attaches (no second toast) and collects the decision
//! exactly once; a timed-out toast is retracted (notify_dismiss).

use pup_core::{AbClient, Caller};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};

pub const IN_VERB: Duration = Duration::from_secs(45);
pub const TOTAL_SEC: u64 = 120;
const PICKUP: Duration = Duration::from_secs(120);

pub struct Rec { pub id: String, pub key: String, pub verb: String, pub what: String, pub since: Instant, pub since_ms: u64, pub decided: Option<Instant>, pub decision: Option<String>, pub picked_up: bool, pub attached: u32 }

pub struct Registry { map: Mutex<HashMap<String, Arc<(Mutex<Rec>, Condvar)>>> }

pub enum Ask { Pending { consent_id: String, remaining_sec: u64, attached: u32 }, Decided { consent_id: String, decision: Option<String> } }

impl Registry {
    pub fn new() -> Registry { Registry { map: Mutex::new(HashMap::new()) } }

    fn find_pending(&self, key: &str) -> Option<Arc<(Mutex<Rec>, Condvar)>> {
        self.map.lock().unwrap().values().find(|r| { let g = r.0.lock().unwrap(); g.key == key && !g.picked_up }).cloned()
    }

    /// Show the toast (or attach to the pending one) and wait up to IN_VERB for the click.
    pub fn ask(&self, ab: Arc<dyn AbClient>, caller: &Caller, key: &str, verb: &str, what: &str, id_prefix: &str, toast: Value) -> Ask {
        let rec = match self.find_pending(key) {
            Some(r) => { r.0.lock().unwrap().attached += 1; r }
            None => {
                let id = format!("{id_prefix}-{:x}-{}", pup_core::now_ms(), std::process::id() % 1000);
                let r = Arc::new((Mutex::new(Rec { id: id.clone(), key: key.into(), verb: verb.into(), what: what.into(), since: Instant::now(), since_ms: pup_core::now_ms(), decided: None, decision: None, picked_up: false, attached: 0 }), Condvar::new()));
                self.map.lock().unwrap().insert(id.clone(), r.clone());
                let (r2, ab2, caller2, id2) = (r.clone(), ab.clone(), caller.clone(), id.clone());
                std::thread::Builder::new().name("consent".into()).spawn(move || {
                    let mut args = toast; args["id"] = json!(id2); args["wait"] = json!(true); args["timeoutSec"] = json!(TOTAL_SEC);
                    let decision = ab2.call("notify_user", args, &caller2, Duration::from_secs(TOTAL_SEC + 10)).ok().and_then(|v| v["action"].as_str().map(|s| s.to_string()));
                    if decision.is_none() { let _ = ab2.call("notify_dismiss", json!({ "id": id2 }), &caller2, Duration::from_secs(8)); }
                    { let mut g = r2.0.lock().unwrap(); g.decision = decision.clone(); g.decided = Some(Instant::now()); pup_core::log(format!("[consent] {} {} ({}, attached {}x)", g.verb, decision.as_ref().map(|d| format!("ANSWERED \"{d}\"")).unwrap_or_else(|| "TIMED OUT (toast retracted)".into()), g.id, g.attached)); }
                    r2.1.notify_all();
                }).ok();
                r
            }
        };
        // wait for a decision, up to IN_VERB
        let deadline = Instant::now() + IN_VERB;
        let mut g = rec.0.lock().unwrap();
        while g.decided.is_none() { let left = deadline.saturating_duration_since(Instant::now()); if left.is_zero() { break; } let (ng, _) = rec.1.wait_timeout(g, left).unwrap(); g = ng; }
        if g.decided.is_none() { let remaining = TOTAL_SEC.saturating_sub(g.since.elapsed().as_secs()); return Ask::Pending { consent_id: g.id.clone(), remaining_sec: remaining, attached: g.attached }; }
        g.picked_up = true;
        let out = Ask::Decided { consent_id: g.id.clone(), decision: g.decision.clone() };
        let id = g.id.clone(); drop(g);
        self.map.lock().unwrap().remove(&id);
        out
    }

    /// Rows for pup_readiness.active[]; decided-but-uncollected records expire after PICKUP.
    pub fn rows(&self) -> Vec<Value> {
        let mut map = self.map.lock().unwrap();
        map.retain(|_, r| { let g = r.0.lock().unwrap(); !(g.decided.map(|d| d.elapsed() > PICKUP).unwrap_or(false)) });
        map.values().map(|r| { let g = r.0.lock().unwrap(); let decided = g.decided.is_some(); json!({ "id": g.id, "verb": g.verb, "phase": if decided { "consent_decided" } else { "consent" }, "stepLabel": if decided { format!("The user answered; re-call {} with the same args to collect the decision", g.verb) } else { g.what.clone() }, "percent": Value::Null, "elapsedSec": g.since.elapsed().as_secs(), "estimatedSec": Value::Null, "etaSec": Value::Null, "confidence": "measured", "blockedOnUser": !decided, "blockedBy": if decided { Value::Null } else { json!("your Approve/Deny click on the desktop toast") }, "reattach": format!("{} again with the SAME args", g.verb) }) }).collect()
    }
}