crates/signaltty-server/src/approvals.rs

//! Live native hook routes. Store is always locked before this registry.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;

use serde_json::{json, Value};
use signaltty_core::model::{AgentKind, LiveState};
use signaltty_proto::{code, Response};
use tokio::sync::{broadcast, oneshot};

use crate::params::{self, bad_params, decode};
use crate::router::{ConnEffect, Ctx};

const PREFIX: &str = "permission_";
#[derive(Default)]
pub struct Approvals {
    routes: Mutex<HashMap<String, Route>>,
}
struct Route {
    pane: String,
    session: String,
    agent: AgentKind,
    deadline: tokio::time::Instant,
    sender: oneshot::Sender<Value>,
}
impl Approvals {
    pub fn is_native(id: &str) -> bool {
        id.starts_with(PREFIX)
    }
    pub fn contains(&self, id: &str) -> bool {
        self.routes
            .lock()
            .unwrap()
            .get(id)
            .is_some_and(|route| !route.sender.is_closed())
    }
}

struct Waiting<'a> {
    ctx: &'a Ctx,
    pane: String,
    id: String,
}
impl Drop for Waiting<'_> {
    fn drop(&mut self) {
        let mut store = self.ctx.store.write().unwrap();
        self.ctx.approvals.routes.lock().unwrap().remove(&self.id);
        let current = store
            .panes
            .get(&self.pane)
            .and_then(|p| p.pending_decision.as_ref())
            .is_some_and(|d| d.id == self.id);
        let event = if current {
            store.clear_decision(&self.pane, "native_cancelled")
        } else {
            None
        };
        drop(store);
        if event.is_some() {
            self.ctx.mark_persist();
        }
    }
}

pub fn answer(ctx: &Ctx, pane_id: &str, id: &str, option: &str) -> Result<Value, (String, String)> {
    let verdict = signaltty_agent::permission::permission_output(option)
        .ok_or_else(|| bad_params(format!("unknown native permission option '{option}'")))?;
    let mut store = ctx.store.write().unwrap();
    let pane = store
        .panes
        .get(pane_id)
        .ok_or_else(|| (code::NO_SUCH_PANE.to_owned(), pane_id.to_owned()))?;
    if !matches!(pane.live, LiveState::Live) {
        return Err((code::PANE_EXITED.to_owned(), pane_id.to_owned()));
    }
    if !pane
        .pending_decision
        .as_ref()
        .is_some_and(|d| d.id == id && d.answerable)
    {
        return Err((code::NO_SUCH_DECISION.to_owned(), id.to_owned()));
    }
    let route = ctx
        .approvals
        .routes
        .lock()
        .unwrap()
        .remove(id)
        .filter(|route| {
            !route.sender.is_closed()
                && route.pane == pane_id
                && pane.agent.kind == route.agent
                && pane.agent.agent_session_id.as_deref() == Some(route.session.as_str())
                && tokio::time::Instant::now() < route.deadline
        })
        .ok_or_else(|| (code::NO_SUCH_DECISION.to_owned(), id.to_owned()))?;
    store.answer_decision(pane_id, id, option).unwrap();
    store.mark_seen(pane_id, "decision_answer");
    let pane = &store.panes[pane_id];
    let result = json!({"answered":true,"lifecycle":pane.lifecycle.as_str(),"attention":pane.attention.as_str()});
    let delivered = route.sender.send(verdict).is_ok();
    drop(store);

    ctx.mark_persist();
    if delivered {
        Ok(result)
    } else {
        Err((code::NO_SUCH_DECISION.to_owned(), id.to_owned()))
    }
}

pub async fn wait(ctx: &Ctx, req: &signaltty_proto::Request) -> (Response, ConnEffect) {
    let result = wait_inner(ctx, &req.params).await;
    let response = match result {
        Ok(result) => Response::ok(&req.id, result),
        Err((code, message)) => Response::err(&req.id, &code, message),
    };
    (response, ConnEffect::default())
}

async fn wait_inner(ctx: &Ctx, value: &Value) -> Result<Value, (String, String)> {
    let p: params::HookEvent = decode(value)?;
    if p.hook != "PermissionRequest" || p.decision.is_some() {
        return Err(bad_params(
            "native waiting requires PermissionRequest without an explicit decision",
        ));
    }
    let timeout = p.wait_timeout_s.unwrap_or(120);
    if !(1..=120).contains(&timeout) {
        return Err(bad_params("wait_timeout_s must be between 1 and 120"));
    }
    params::parse_severity(&p.severity)?;
    let permission =
        signaltty_agent::permission::native_permission(&p.agent, &p.payload).map_err(bad_params)?;
    let agent = AgentKind::parse(&p.agent).unwrap();
    let pane = p
        .pane_id
        .or_else(|| {
            p.client_pid
                .and_then(|pid| u32::try_from(pid).ok())
                .and_then(|pid| {
                    crate::attrib::resolve_pane_by_ancestry(pid, &ctx.ptys.child_pids())
                })
        })
        .ok_or_else(|| bad_params("native waiting requires a managed pane"))?;
    let id = signaltty_core::new_notif_id().replacen("notif_", PREFIX, 1);
    let mut events = ctx.bcast.subscribe();
    let (sender, mut receiver) = oneshot::channel();
    let expires = tokio::time::Instant::now() + Duration::from_secs(timeout);
    {
        let store = ctx.store.write().unwrap();
        let target = store
            .panes
            .get(&pane)
            .ok_or_else(|| (code::NO_SUCH_PANE.to_owned(), pane.clone()))?;
        if !matches!(target.live, LiveState::Live) {
            return Err((code::PANE_EXITED.to_owned(), pane));
        }
        ctx.approvals.routes.lock().unwrap().insert(
            id.clone(),
            Route {
                pane: pane.clone(),
                session: permission.session_id.clone(),
                agent,
                deadline: expires,
                sender,
            },
        );
    }
    let _waiting = Waiting {
        ctx,
        pane: pane.clone(),
        id: id.clone(),
    };
    let mut ingest = value.clone();
    ingest["wait_for_answer"] = json!(false);
    ingest["pane_id"] = json!(pane);
    ingest["decision"] = json!({"id":id,"prompt":permission.prompt,"options":signaltty_agent::permission::permission_options()});
    crate::router::h_native_hook_event(ctx, &ingest)?;
    let deadline = tokio::time::sleep_until(expires);
    tokio::pin!(deadline);
    loop {
        tokio::select! {
            // A consumed grant wins over the accompanying state-change event.
            biased;
            verdict = &mut receiver => return Ok(json!({"accepted":true,"native_verdict":verdict.ok()})),
            _ = &mut deadline => return Ok(json!({"accepted":true,"native_verdict":Value::Null,"cancelled":"timeout"})),
            _ = ctx.shutdown.notified() => return Ok(json!({"accepted":true,"native_verdict":Value::Null,"cancelled":"shutdown"})),
            event = events.recv() => {
                if matches!(event, Err(broadcast::error::RecvError::Closed)) {
                    return Ok(json!({"accepted":true,"native_verdict":Value::Null,"cancelled":"shutdown"}));
                }
                let store = ctx.store.read().unwrap();
                let still_current = store.panes.get(&pane).is_some_and(|p| {
                    matches!(p.live, LiveState::Live)
                        && p.agent.kind == agent
                        && p.agent.agent_session_id.as_deref() == Some(&permission.session_id)
                        && p.pending_decision.as_ref().is_some_and(|d| d.id == id && d.answerable)
                });
                if !still_current {
                    // The event arm may have won before the grant was queued,
                    // then waited for the answer's Store lock. Recheck the grant
                    // after that lock so consumption cannot masquerade as cancellation.
                    if let Ok(verdict) = receiver.try_recv() {
                        return Ok(json!({"accepted":true,"native_verdict":verdict}));
                    }
                    return Ok(json!({"accepted":true,"native_verdict":Value::Null,"cancelled":"moved_on"}));
                }
            }
        }
    }
}