Skip to main content

hatchery_node/server/
node_wake.rs

1//! Letter -> node -> g4a wake -> session turn.
2//!
3//! m4a only hands a letter over (its `M4A_LETTER_HOOK`); the node knows which of its sessions
4//! owns the addressed nick (it assigned that account at spawn) and asks g4a
5//! (`gate4agent_wake::hosted`) what to do. A live session gets the framed prompt as its next
6//! turn; the node's policy ([`crate::wake_pipeline`]) gates everything else: typing into a raw
7//! terminal needs an explicit terminal grant, a nick bound to a routine gets one routine POST via
8//! g4a, and anything else is refused (m4a keeps the letter in its durable inbox).
9//! Callers present the node's wake token (a 0600 file next to the accounts root), so another
10//! local user cannot type into a session through the door.
11
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15use gate4agent_wake::hosted::{plan_hosted_wake, HostedSessions, HostedTarget, HostedWake};
16use gate4agent_wake::{ProviderKind, WakeLetter};
17
18use crate::wake_pipeline::{deliver_routine, launch_spawn_profile, route, Route, RoutineLetter, SessionShape};
19
20use super::*;
21
22/// One letter for a session of this node.
23pub struct NodeLetter {
24    pub nick: String,
25    pub from: String,
26    pub event_id: String,
27    pub body: String,
28    /// The node spawned this session itself: a raw terminal gets the short wake line typed by
29    /// the node (no terminal grant needed; the line carries no letter text).
30    pub own_spawn: bool,
31}
32
33#[derive(Clone)]
34pub struct NodeWake {
35    shared: Arc<NodeShared>,
36    token: Arc<str>,
37    policy: Arc<crate::wake_pipeline::WakePolicy>,
38}
39
40struct Snapshot(BTreeMap<String, HostedTarget>);
41
42impl HostedSessions for Snapshot {
43    fn lookup(&self, nick: &str) -> Option<HostedTarget> {
44        self.0.get(nick).cloned()
45    }
46}
47
48fn kind_of(agent: &AgentId) -> Option<ProviderKind> {
49    ProviderKind::parse(crate::m4a_accounts::m4a_provider(agent.as_str())?)
50}
51
52impl NodeWake {
53    pub(super) fn new(shared: Arc<NodeShared>, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Self {
54        Self { shared, token: token.into(), policy }
55    }
56
57    /// Constant-time check of the caller's token.
58    pub fn authorized(&self, token: &str) -> bool {
59        let (a, b) = (self.token.as_bytes(), token.as_bytes());
60        a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
61    }
62
63    fn snapshot(&self) -> (Snapshot, BTreeMap<String, SessionAddress>) {
64        let live = self.shared.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).clone();
65        let bindings = self.shared.session_bindings.lock().unwrap_or_else(|p| p.into_inner());
66        let mut targets = BTreeMap::new();
67        let mut addresses = BTreeMap::new();
68        for (nick, (instance_id, agent)) in live {
69            let Some(provider) = kind_of(&agent) else { continue };
70            let binding = bindings.get(&instance_id);
71            if let Some(b) = binding {
72                addresses.insert(
73                    nick.clone(),
74                    SessionAddress { workspace_id: b.workspace_id.clone(), session: SessionKey { instance_id, generation: b.generation } },
75                );
76            }
77            let spawn_allowed = self.policy.spawn_profile(&nick).is_some();
78            targets.insert(nick.clone(), HostedTarget { nick, provider, live: binding.is_some(), spawn_allowed });
79        }
80        (Snapshot(targets), addresses)
81    }
82
83    async fn resume_with_wake(&self, nick: &str, address: &SessionAddress, prompt: String) -> Result<&'static str, String> {
84        let record_id = self
85            .shared
86            .session_bindings
87            .lock()
88            .unwrap_or_else(|p| p.into_inner())
89            .get(&address.session.instance_id)
90            .and_then(|b| b.record_id.clone())
91            .ok_or("exited session has no record to resume")?;
92        let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
93        match self.shared.resume_session_record(&record_id, size, Some(prompt)).await {
94            Ok(_) => {
95                tracing::info!(%nick, "letter resumed an exited session");
96                Ok("resumed")
97            }
98            Err(e) => {
99                tracing::warn!(%nick, code = ?e.code, "exited session not resumed");
100                Err("exited session could not be resumed".into())
101            }
102        }
103    }
104
105    /// Wake signal for a session this node spawned (from its mailbox listener): no letter text.
106    pub async fn wake_spawned(&self, nick: String, from: String, event_id: String) -> Result<&'static str, String> {
107        self.wake(NodeLetter { nick, from, event_id, body: String::new(), own_spawn: true }).await
108    }
109
110    /// Wakes the session that owns `letter.nick`. `Ok` names what happened; `Err` is safe to
111    /// log (it never carries the letter text).
112    pub async fn wake(&self, letter: NodeLetter) -> Result<&'static str, String> {
113        let (sessions, addresses) = self.snapshot();
114        let wake = WakeLetter { body: &letter.body, from_nick: &letter.from, event_id: &letter.event_id, room: None, reaction: None };
115        match plan_hosted_wake(&sessions, &letter.nick, &wake) {
116            HostedWake::Submit { nick, prompt } => {
117                let prompt = if self.policy.deliver_body { prompt } else { crate::wake_pipeline::wake_line(&letter.from, &letter.event_id) };
118                let address = addresses.get(&nick).cloned().ok_or("session vanished")?;
119                let wake_line = crate::wake_pipeline::wake_line(&letter.from, &letter.event_id);
120                let agent = match self.shared.validate_address(&address) {
121                    Ok(agent) => agent,
122                    Err(_) if letter.own_spawn => return self.resume_with_wake(&nick, &address, wake_line).await,
123                    Err(_) => return Err("session is not live".to_owned()),
124                };
125                let refused = |e: NodeFailure| {
126                    tracing::warn!(nick = %nick, code = ?e.code, "letter wake input refused");
127                    "session did not take the turn".to_owned()
128                };
129                let shape = if self.shared.require_session_runtime_policy(&address, ProviderRuntimeRequirement::SemanticPrompt).is_ok() {
130                    SessionShape::Semantic
131                } else {
132                    SessionShape::RawTerminal
133                };
134                let routed = match route(&self.policy, &nick, shape) {
135                    Route::Refuse(_) if letter.own_spawn && shape == SessionShape::RawTerminal => Route::TerminalTyping,
136                    // A session this node spawned in ACP takes the wake as a prompt turn.
137                    Route::Refuse(_) if letter.own_spawn && shape == SessionShape::Semantic => Route::Deliver,
138                    other => other,
139                };
140                let prompt = if letter.own_spawn { crate::wake_pipeline::wake_line(&letter.from, &letter.event_id) } else { prompt };
141                match routed {
142                    Route::Deliver => {
143                        let action = InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: prompt_framing(&agent) });
144                        match self.shared.dispatch_input_bounded(&address, action).await {
145                            // A session this node spawned whose process exited is resumed with
146                            // the wake as its first prompt (same record, same m4a account).
147                            Err(_) if letter.own_spawn => return self.resume_with_wake(&nick, &address, wake_line).await,
148                            other => other.map_err(refused)?,
149                        };
150                    }
151                    Route::TerminalTyping => {
152                        // Terminal capability, granted explicitly for this nick: type the prompt
153                        // as one line, then Enter.
154                        let text = prompt.replace(['\n', '\r'], " ");
155                        match self.shared.dispatch_input_bounded(&address, InputAction::TerminalText(TerminalText { text })).await {
156                            // A session this node spawned whose process exited is resumed with
157                            // the wake as its first prompt (same record, same m4a account).
158                            Err(_) if letter.own_spawn => return self.resume_with_wake(&nick, &address, wake_line).await,
159                            other => other.map_err(refused)?,
160                        };
161                        // TUIs (kimi) treat a fast Enter right after the text as part of a paste.
162                        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
163                        self.shared.dispatch_input_bounded(&address, InputAction::TerminalControl(TerminalControl::ControlM)).await.map_err(refused)?;
164                    }
165                    Route::Refuse(why) => {
166                        tracing::info!(nick = %nick, "letter not typed: no terminal grant");
167                        return Err(why);
168                    }
169                    Route::Routine(_) => return Err("unexpected routine route for a live session".into()),
170                    Route::Spawn(_) => return Err("unexpected spawn route for a live session".into()),
171                }
172                tracing::info!(nick = %nick, "letter woke a live session");
173                Ok("submitted")
174            }
175            HostedWake::Spawn { nick, .. } => match self.policy.spawn_profile(&nick).cloned() {
176                Some(profile) => {
177                    tokio::task::spawn_blocking(move || launch_spawn_profile(&profile))
178                        .await
179                        .map_err(|_| "spawn profile task failed".to_owned())?
180                        .map_err(|e| e)?;
181                    tracing::info!(nick = %nick, "letter woke an offline session via spawn profile");
182                    Ok("spawned")
183                }
184                None => Err("spawn on letter is not enabled on this node".into()),
185            },
186            HostedWake::Refuse(why) => match route(&self.policy, &letter.nick, SessionShape::None) {
187                Route::Spawn(profile) => {
188                    let nick = letter.nick.clone();
189                    tokio::task::spawn_blocking(move || launch_spawn_profile(&profile))
190                        .await
191                        .map_err(|_| "spawn profile task failed".to_owned())?
192                        .map_err(|e| e)?;
193                    tracing::info!(nick = %nick, "letter woke an offline session via spawn profile");
194                    Ok("spawned")
195                }
196                Route::Routine(target) => {
197                    let routine_letter = RoutineLetter {
198                        to: letter.nick.clone(),
199                        from: letter.from.clone(),
200                        event_id: letter.event_id.clone(),
201                        subject: None,
202                        room: None,
203                        body: letter.body.clone(),
204                        reply: None,
205                        note: None,
206                    };
207                    let nick = letter.nick.clone();
208                    match tokio::task::spawn_blocking(move || deliver_routine(&target, &routine_letter)).await {
209                        Ok(Ok(_)) => {
210                            tracing::info!(nick = %nick, "letter delivered to a routine");
211                            Ok("routine")
212                        }
213                        Ok(Err(e)) => Err(e),
214                        Err(_) => Err("routine delivery task failed".into()),
215                    }
216                }
217                _ => Err(why),
218            },
219        }
220    }
221}