Skip to main content

hatchery_server/
hub.rs

1//! P6: the hub behind the server control API: node calls forwarded by C2 and operator (HQ)
2//! calls for mail and tasks. All state lives under the data dir; admission is always read from
3//! the P2 ledger, never from the request.
4
5use crate::messenger::{Messenger, MessengerError, OPERATOR};
6use crate::provision::{read_ledger, LedgerEntry, NodeStatus};
7use crate::tasks::{Refused, Session, TaskStore};
8use serde_json::{json, Value};
9use std::path::{Path, PathBuf};
10use std::sync::{Condvar, Mutex};
11use std::time::{Duration, Instant};
12
13#[derive(Debug)]
14pub struct HubError {
15    pub status: u16,
16    pub message: String,
17}
18
19impl HubError {
20    fn new(status: u16, message: impl Into<String>) -> Self {
21        Self { status, message: message.into() }
22    }
23}
24
25impl From<MessengerError> for HubError {
26    fn from(e: MessengerError) -> Self {
27        match e {
28            MessengerError::Forbidden => Self::new(403, "refused"),
29            MessengerError::Bad(m) => Self::new(400, m),
30            MessengerError::Internal(m) => Self::new(500, m),
31        }
32    }
33}
34
35pub struct Hub {
36    data_dir: PathBuf,
37    /// m4a signer: per-human key trees, unlocked by each human's passkey PRF secret.
38    signer: Mutex<hatchery_keystore::keyring::Keyring>,
39    messenger: Mutex<Messenger>,
40    tasks: Mutex<TaskStore>,
41    /// Bumped on every message sent; lets HQ long-poll for new mail instead of polling blindly.
42    mail_signal: (Mutex<u64>, Condvar),
43    advice: Mutex<crate::routing::AdviceGate>,
44}
45
46fn refused(r: Refused) -> HubError {
47    match r {
48        Refused::NotFound => HubError::new(404, "not found"),
49        Refused::Conflict(m) => HubError::new(409, m),
50        Refused::Invalid(m) => HubError::new(400, m),
51    }
52}
53
54fn int(e: crate::error::ServerError) -> HubError {
55    HubError::new(500, e.to_string())
56}
57
58fn opt_str<'a>(v: &'a Value, name: &str) -> Option<&'a str> {
59    v.get(name).and_then(Value::as_str)
60}
61
62fn query_param<'a>(query: &'a str, name: &str) -> Option<&'a str> {
63    query.split('&').find_map(|kv| kv.strip_prefix(name).and_then(|r| r.strip_prefix('='))).filter(|v| !v.is_empty())
64}
65
66fn str_field<'a>(v: &'a Value, name: &str) -> Result<&'a str, HubError> {
67    v.get(name).and_then(Value::as_str).ok_or_else(|| HubError::new(400, format!("missing field: {name}")))
68}
69
70fn now_ms() -> u64 {
71    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64
72}
73
74impl Hub {
75    fn routing(&self) -> Result<(crate::routing::RoutingConfig, std::collections::BTreeMap<(String, String), crate::routing::SeatState>), HubError> {
76        let tasks = self.tasks.lock().expect("tasks");
77        let cfg = crate::routing::RoutingConfig::from_store(&tasks.inventory().map_err(int)?, tasks.roles().map_err(int)?);
78        // Fold the recent signal window (bounded table).
79        let mut all = Vec::new();
80        let mut after = None;
81        loop {
82            let page = tasks.signals(None, after, 500).map_err(int)?;
83            let done = page.len() < 500;
84            after = page.last().map(|s| s.id).or(after);
85            all.extend(page);
86            if done {
87                break;
88            }
89        }
90        let state = crate::routing::fold_signals(&all, &cfg);
91        Ok((cfg, state))
92    }
93
94    /// Core's own m4a (the in-process mailbox engine): read `run.close` letters that nodes'
95    /// sessions sent to the operator mailbox and close the named run when the letter comes
96    /// from the run's node. Each letter is acknowledged once handled. Returns runs closed.
97    pub fn poll_m4a(&self) -> usize {
98        let letters = match self.messenger.lock().expect("messenger").inbox(OPERATOR, 0, 256) {
99            Ok(l) => l,
100            Err(e) => {
101                tracing::debug!(error = %e, "m4a reader: operator inbox unavailable");
102                return 0;
103            }
104        };
105        let mut closed = 0;
106        for letter in letters {
107            let Some(cmd) = crate::m4a_gateway::parse_close(&letter.body) else { continue };
108            let tasks = self.tasks.lock().expect("tasks");
109            let outcome = match tasks.run(cmd.run_id) {
110                Ok(Some(run)) if run.node_id == letter.from => {
111                    match tasks.close_run(run.id, &cmd.status, "session", Some(&run.session_id), cmd.branch.as_deref(), cmd.commit.as_deref(), Some(&letter.message_id)) {
112                        Ok(Ok(r)) => {
113                            closed += 1;
114                            tracing::info!(run = r.id, status = %r.status, "run closed by its session via m4a");
115                            "closed"
116                        }
117                        Ok(Err(why)) => {
118                            tracing::info!(run = run.id, ?why, "m4a run.close refused");
119                            "refused"
120                        }
121                        Err(_) => continue, // store error: retry next tick
122                    }
123                }
124                Ok(_) => "ignored",
125                Err(_) => continue,
126            };
127            drop(tasks);
128            tracing::debug!(outcome, "m4a run.close letter handled");
129            let _ = self.messenger.lock().expect("messenger").ack(OPERATOR, &letter.message_id);
130        }
131        closed
132    }
133
134    /// Routing recommendation for `role` (operator API and node door).
135    pub fn recommend(&self, role: &str) -> Result<Value, HubError> {
136        let (cfg, state) = self.routing()?;
137        let inventory = self.tasks.lock().expect("tasks").inventory().map_err(int)?;
138        let rec = crate::routing::recommend_in(&cfg, Some(&inventory), &state, role, now_ms()).map_err(|e| HubError::new(400, e))?;
139        Ok(json!({ "recommendation": rec }))
140    }
141
142    pub fn open(data_dir: &Path) -> Result<Self, crate::error::ServerError> {
143        Ok(Self {
144            data_dir: data_dir.to_owned(),
145            signer: Mutex::new(hatchery_keystore::keyring::Keyring::new(signer_dir(data_dir)?)),
146            messenger: Mutex::new(Messenger::open(data_dir)?),
147            tasks: Mutex::new(TaskStore::open(data_dir)?),
148            mail_signal: (Mutex::new(0), Condvar::new()),
149            advice: Mutex::new(Default::default()),
150        })
151    }
152
153    fn bump_mail(&self) {
154        *self.mail_signal.0.lock().expect("signal") += 1;
155        self.mail_signal.1.notify_all();
156    }
157
158    fn entry(&self, node_id: &str) -> Option<LedgerEntry> {
159        read_ledger(&self.data_dir).ok()?.into_iter().find(|e| e.node_id == node_id)
160    }
161
162    /// Active (member/trusted) node holding `capability`, per the ledger.
163    fn admitted(&self, node_id: &str, capability: &str) -> bool {
164        node_id != OPERATOR
165            && self.entry(node_id).is_some_and(|e| {
166                matches!(e.status, NodeStatus::Member | NodeStatus::Trusted) && e.grants.capabilities.iter().any(|c| c == capability)
167            })
168    }
169
170    /// A call made on behalf of a node. The caller (C2) has authenticated the node and checked its
171    /// policy; the hub checks the ledger again, independently.
172    pub fn node_call(&self, node_id: &str, op: &str, payload: &Value) -> Result<Value, HubError> {
173        let capability = match op.split_once('.').map(|p| p.0) {
174            Some("mail") => "mail",
175            Some("task") | Some("run") => "tasks",
176            Some("signer") => "mail",
177            _ => return Err(HubError::new(404, "unknown operation")),
178        };
179        if !self.admitted(node_id, capability) {
180            return Err(HubError::new(403, "refused"));
181        }
182        match op {
183            "mail.send" => {
184                let key = payload.get("idempotency_key").and_then(Value::as_str).map(str::to_owned);
185                let id = self.messenger.lock().expect("messenger").send(node_id, str_field(payload, "to")?, str_field(payload, "subject")?, str_field(payload, "body")?, key)?;
186                self.bump_mail();
187                Ok(json!({ "message_id": id }))
188            }
189            "mail.inbox" => {
190                let since = payload.get("since_ms").and_then(Value::as_u64).unwrap_or(0);
191                let limit = payload.get("limit").and_then(Value::as_u64).unwrap_or(50).min(256) as u16;
192                let mail = self.messenger.lock().expect("messenger").inbox(node_id, since, limit)?;
193                Ok(json!({ "messages": mail }))
194            }
195            "mail.ack" => {
196                self.messenger.lock().expect("messenger").ack(node_id, str_field(payload, "message_id")?)?;
197                Ok(json!({ "ok": true }))
198            }
199            "task.list" => {
200                let tasks = self.tasks.lock().expect("tasks").list(Some(node_id), true).map_err(|e| HubError::new(500, e.to_string()))?;
201                Ok(json!({ "tasks": tasks }))
202            }
203            "task.update" => {
204                let id = payload.get("id").and_then(Value::as_i64).ok_or_else(|| HubError::new(400, "missing field: id"))?;
205                let status = str_field(payload, "status")?;
206                if !matches!(status, "in_progress" | "done") {
207                    return Err(HubError::new(400, "status must be in_progress or done"));
208                }
209                let changed = self.tasks.lock().expect("tasks").set_status(id, status, Some(node_id)).map_err(|e| HubError::new(500, e.to_string()))?;
210                if changed { Ok(json!({ "ok": true })) } else { Err(HubError::new(403, "refused")) }
211            }
212            "task.route" => self.recommend(str_field(payload, "role")?),
213            // What this node's provider CLIs expose (replaces the node's previous report).
214            "task.provider_inventory" => {
215                let providers: Vec<crate::routing::ProviderInventory> =
216                    serde_json::from_value(payload.get("providers").cloned().unwrap_or(Value::Null)).map_err(|_| HubError::new(400, "bad providers"))?;
217                let providers = crate::routing::clean_report(providers).map_err(|e| HubError::new(400, e))?;
218                self.tasks.lock().expect("tasks").set_inventory(node_id, &providers).map_err(int)?;
219                tracing::info!(node = node_id, providers = providers.len(), "provider inventory");
220                Ok(json!({ "ok": true }))
221            }
222            // Advisor anti-noise gate: an advisor session asks before it sends advice via m4a.
223            "task.advice_gate" => {
224                let (cfg, _) = self.routing()?;
225                let advisor = format!("{node_id}/{}", opt_str(payload, "advisor").unwrap_or("advisor"));
226                match self.advice.lock().expect("advice").check(&cfg.advisor, &advisor, str_field(payload, "topic")?, now_ms()) {
227                    Ok(()) => Ok(json!({ "allow": true })),
228                    Err(why) => Ok(json!({ "allow": false, "reason": why })),
229                }
230            }
231            // Short limit / login / ready signal read off a session screen by the node.
232            "task.session_signal" => {
233                let instance = payload.get("instance_id").and_then(Value::as_i64).ok_or_else(|| HubError::new(400, "missing field: instance_id"))?;
234                let sig = self
235                    .tasks
236                    .lock()
237                    .expect("tasks")
238                    .add_signal(node_id, str_field(payload, "workspace_id")?, instance, str_field(payload, "provider")?, str_field(payload, "kind")?, opt_str(payload, "hint"))
239                    .map_err(int)?
240                    .map_err(refused)?;
241                tracing::info!(node = node_id, provider = %sig.provider, kind = %sig.kind, "session signal");
242                Ok(json!({ "signal_id": sig.id }))
243            }
244            // The run's own session closes it (decision 8); the run must live on this node.
245            // `task.close_run` is the door-facing name; `run.close` is kept as an alias.
246            "task.close_run" | "run.close" => {
247                let run_id = payload.get("run_id").and_then(Value::as_i64).ok_or_else(|| HubError::new(400, "missing field: run_id"))?;
248                let tasks = self.tasks.lock().expect("tasks");
249                let run = tasks.run(run_id).map_err(int)?.ok_or_else(|| HubError::new(404, "not found"))?;
250                if run.node_id != node_id {
251                    return Err(HubError::new(403, "refused"));
252                }
253                let run = tasks
254                    .close_run(run_id, str_field(payload, "status")?, "session", opt_str(payload, "session_id"), opt_str(payload, "branch"), opt_str(payload, "commit"), opt_str(payload, "result_message_id"))
255                    .map_err(int)?
256                    .map_err(refused)?;
257                Ok(json!({ "run": run }))
258            }
259            // A fresh session on this node gets its identity from the account tree that owns
260            // the node. The token goes back to the node's proxy, never into the PTY.
261            "signer.provision" | "keystore.provision" => {
262                let mut k = self.signer.lock().expect("signer");
263                let account = k.account_for_node(node_id).ok_or_else(|| HubError::new(423, "signer locked for this node's account"))?;
264                let p = k
265                    .provision(&account, node_id, str_field(payload, "provider")?, str_field(payload, "session")?, hatchery_keystore::default_rights(), None)
266                    .map_err(signer_err)?;
267                Ok(json!({ "nick": p.nick, "token": p.token, "public_key": hex::encode(p.public_key) }))
268            }
269            "signer.sign" | "keystore.sign" => {
270                let payload_bytes = hex::decode(str_field(payload, "payload_hex")?).map_err(|_| HubError::new(400, "bad payload"))?;
271                let sig = self.signer.lock().expect("signer").sign_from_node(node_id, str_field(payload, "token")?, str_field(payload, "op")?, &payload_bytes).map_err(signer_err)?;
272                Ok(json!({ "nick": sig.nick, "public_key_hex": hex::encode(sig.public_key), "signature_hex": hex::encode(sig.signature) }))
273            }
274            // Signer-signed letter: core signs as the token's own nick and delivers from that nick.
275            "signer.send" | "keystore.send" => {
276                let (to, subject, body) = (str_field(payload, "to")?, str_field(payload, "subject")?, str_field(payload, "body")?);
277                let canonical = serde_json::to_vec(&json!({ "to": to, "subject": subject, "body": body })).map_err(|_| HubError::new(500, "encode"))?;
278                let sig = self.signer.lock().expect("signer").sign_from_node(node_id, str_field(payload, "token")?, hatchery_keystore::RIGHT_SEND, &canonical).map_err(signer_err)?;
279                if !hatchery_keystore::verify_signature(&sig, hatchery_keystore::RIGHT_SEND, &canonical) {
280                    return Err(HubError::new(500, "signature check failed"));
281                }
282                let key = payload.get("idempotency_key").and_then(Value::as_str).map(str::to_owned);
283                let id = {
284                    let mut m = self.messenger.lock().expect("messenger");
285                    m.ensure_session_participant(&sig.nick)?;
286                    m.send(&sig.nick, to, subject, body, key)?
287                };
288                self.bump_mail();
289                tracing::info!(node = node_id, nick = %sig.nick, "signer-signed letter accepted");
290                Ok(json!({ "message_id": id, "from": sig.nick, "signature_hex": hex::encode(sig.signature), "public_key_hex": hex::encode(sig.public_key) }))
291            }
292            "signer.revoke" | "keystore.revoke" => {
293                let session = str_field(payload, "session")?;
294                let mut k = self.signer.lock().expect("signer");
295                let account = k.account_for_node(node_id).ok_or_else(|| HubError::new(423, "signer locked"))?;
296                let b = k.branch(&account).map_err(signer_err)?;
297                b.session_on_node(session, node_id).map_err(signer_err)?;
298                b.revoke_session(session).map_err(signer_err)?;
299                Ok(json!({ "ok": true }))
300            }
301            _ => Err(HubError::new(404, "unknown operation")),
302        }
303    }
304
305    /// Operator signer calls. `prf_hex` is the account passkey's WebAuthn PRF output, evaluated
306    /// in the human's browser; the passkey itself never reaches core.
307    fn signer_operator(&self, op: &str, body: &Value) -> Result<Value, HubError> {
308        let account = str_field(body, "account")?;
309        let mut k = self.signer.lock().expect("signer");
310        let human = || -> Result<hatchery_keystore::HumanKey, HubError> {
311            let prf = hex::decode(str_field(body, "prf_hex")?).map_err(|_| HubError::new(400, "bad prf"))?;
312            if prf.len() < 32 {
313                return Err(HubError::new(400, "prf output too short"));
314            }
315            Ok(hatchery_keystore::HumanKey::from_passkey_prf(&prf))
316        };
317        match op {
318            "enroll" => k.enroll(account, &human()?).map_err(signer_err)?,
319            "unlock" => k.unlock(account, &human()?).map_err(signer_err)?,
320            "lock" => k.lock(account),
321            "register_node" => {
322                let h = human()?;
323                k.branch(account).map_err(signer_err)?.register_node(str_field(body, "node")?, &h).map_err(signer_err)?
324            }
325            "status" => return Ok(json!({ "account": account, "unlocked": k.is_unlocked(account) })),
326            _ => return Err(HubError::new(404, "unknown signer operation")),
327        }
328        tracing::info!(account, op, "signer operator call");
329        Ok(json!({ "ok": true, "unlocked": k.is_unlocked(account) }))
330    }
331
332    /// Operator (HQ) calls, authenticated by the control bearer in the HTTP layer.
333    pub fn operator_call(&self, method: &str, path: &str, body: &Value) -> Result<Value, HubError> {
334        let (path, query) = path.split_once('?').unwrap_or((path, ""));
335        let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
336        match (method, parts.as_slice()) {
337            ("POST", ["v1", "signer", op]) => self.signer_operator(op, body),
338            ("POST", ["v1", "tasks"]) => {
339                let assignee = opt_str(body, "assignee");
340                if let Some(node) = assignee {
341                    if !self.admitted(node, "tasks") {
342                        return Err(HubError::new(403, "assignee does not hold the tasks capability"));
343                    }
344                }
345                let tasks = self.tasks.lock().expect("tasks");
346                let op = opt_str(body, "op_id");
347                if let Some(done) = tasks.replay(op).map_err(int)? {
348                    return Ok(done);
349                }
350                let status = opt_str(body, "status");
351                if let Some(st) = status {
352                    if !matches!(st, "backlog" | "open" | "assigned") {
353                        return Err(HubError::new(400, "initial status must be backlog, open or assigned"));
354                    }
355                }
356                let task = tasks
357                    .create_full(str_field(body, "title")?, opt_str(body, "body").unwrap_or(""), assignee, opt_str(body, "role"), body.get("parent_id").and_then(Value::as_i64), status)
358                    .map_err(int)?;
359                let out = json!({ "task": task });
360                tasks.record(op, &out).map_err(int)?;
361                Ok(out)
362            }
363            ("GET", ["v1", "tasks"]) => {
364                let roles: Option<Vec<String>> = query_param(query, "role").map(|r| r.split(',').map(str::to_owned).collect());
365                let tasks = self.tasks.lock().expect("tasks").list_filtered(query_param(query, "assignee"), roles.as_deref(), false).map_err(int)?;
366                Ok(json!({ "tasks": tasks }))
367            }
368            ("GET", ["v1", "tasks", id]) => {
369                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
370                let tasks = self.tasks.lock().expect("tasks");
371                let task = tasks.get(id).map_err(int)?.ok_or_else(|| HubError::new(404, "not found"))?;
372                Ok(json!({ "task": task, "runs": tasks.runs(id).map_err(int)? }))
373            }
374            ("POST", ["v1", "tasks", id, "move"]) => {
375                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
376                let tasks = self.tasks.lock().expect("tasks");
377                let op = opt_str(body, "op_id");
378                if let Some(done) = tasks.replay(op).map_err(int)? {
379                    return Ok(done);
380                }
381                let task = tasks
382                    .move_to(id, str_field(body, "status")?, body.get("expected_revision").and_then(Value::as_i64))
383                    .map_err(int)?
384                    .map_err(refused)?;
385                let out = json!({ "task": task });
386                tasks.record(op, &out).map_err(int)?;
387                Ok(out)
388            }
389            ("POST", ["v1", "tasks", id, "runs"]) => {
390                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
391                let run = self.tasks.lock().expect("tasks").start_run(id, str_field(body, "session_id")?).map_err(int)?.map_err(refused)?;
392                Ok(json!({ "run": run }))
393            }
394            ("POST", ["v1", "runs", id, "close"]) => {
395                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
396                let run = self
397                    .tasks
398                    .lock()
399                    .expect("tasks")
400                    .close_run(id, str_field(body, "status")?, "operator", None, opt_str(body, "branch"), opt_str(body, "commit"), opt_str(body, "result_message_id"))
401                    .map_err(int)?
402                    .map_err(refused)?;
403                Ok(json!({ "run": run }))
404            }
405            ("GET", ["v1", "routing"]) => {
406                let (cfg, state) = self.routing()?;
407                let seats: Vec<Value> = cfg
408                    .seats
409                    .iter()
410                    .map(|seat| {
411                        let st = state.get(&(seat.node.clone(), seat.provider.clone())).cloned().unwrap_or_default();
412                        json!({ "seat": seat, "state": st })
413                    })
414                    .collect();
415                let inventory = self.tasks.lock().expect("tasks").inventory().map_err(int)?;
416                Ok(json!({ "roles": cfg.roles, "seats": seats, "inventory": inventory, "advisor": cfg.advisor }))
417            }
418            // The user's role choice: preset entries must be models some node reported.
419            ("POST", ["v1", "routing", "roles"]) => {
420                let role = str_field(body, "role")?;
421                if role.is_empty() || role.len() > 64 || !role.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)) {
422                    return Err(HubError::new(400, "bad role name"));
423                }
424                let preset: Vec<String> = body
425                    .get("preset")
426                    .and_then(Value::as_array)
427                    .ok_or_else(|| HubError::new(400, "missing field: preset"))?
428                    .iter()
429                    .map(|v| v.as_str().map(str::to_owned).ok_or_else(|| HubError::new(400, "preset entries are strings")))
430                    .collect::<Result<_, _>>()?;
431                if preset.len() > 16 {
432                    return Err(HubError::new(400, "preset too long"));
433                }
434                let tasks = self.tasks.lock().expect("tasks");
435                let inventory = tasks.inventory().map_err(int)?;
436                for entry in &preset {
437                    let m = crate::routing::parse_model_ref(entry).ok_or_else(|| HubError::new(400, "bad preset entry"))?;
438                    if !crate::routing::offered(&inventory, &m) {
439                        return Err(HubError::new(409, format!("not offered by any node: {entry}")));
440                    }
441                }
442                let r = crate::routing::Role { preset, auto: body.get("auto").and_then(Value::as_bool).unwrap_or(false) };
443                tasks.set_role(role, &r).map_err(int)?;
444                Ok(json!({ "role": role, "preset": r.preset, "auto": r.auto }))
445            }
446            ("POST", ["v1", "routing", "roles", role, "delete"]) => {
447                let gone = self.tasks.lock().expect("tasks").delete_role(role).map_err(int)?;
448                Ok(json!({ "deleted": gone }))
449            }
450            ("POST", ["v1", "routing", "recommend"]) => self.recommend(str_field(body, "role")?),
451            ("GET", ["v1", "signals"]) => {
452                let after = query_param(query, "after").and_then(|v| v.parse().ok());
453                let limit = query_param(query, "limit").and_then(|v| v.parse().ok()).unwrap_or(100);
454                let signals = self.tasks.lock().expect("tasks").signals(query_param(query, "node"), after, limit).map_err(int)?;
455                Ok(json!({ "signals": signals }))
456            }
457            ("GET", ["v1", "sessions"]) => {
458                let sessions = self.tasks.lock().expect("tasks").sessions(query_param(query, "node")).map_err(int)?;
459                Ok(json!({ "sessions": sessions }))
460            }
461            // Wake a session: a letter from core's own m4a to the session's node, naming the
462            // session; the node wakes it and the session reads the letter from m4a.
463            ("POST", ["v1", "sessions", id, "wake"]) => {
464                let session = self.tasks.lock().expect("tasks").session(id).map_err(int)?.ok_or_else(|| HubError::new(404, "not found"))?;
465                let nick = self.tasks.lock().expect("tasks").session_nick(id).map_err(int)?;
466                let text = opt_str(body, "text").map(str::trim).filter(|t| !t.is_empty()).unwrap_or("wake: check your tasks and m4a inbox");
467                if text.len() > 16 * 1024 {
468                    return Err(HubError::new(400, "text too long"));
469                }
470                let subject = format!("wake {}", nick.as_deref().unwrap_or(&session.session_id));
471                let message_id = self
472                    .messenger
473                    .lock()
474                    .expect("messenger")
475                    .send(OPERATOR, &session.node_id, &subject, text, opt_str(body, "op_id").map(str::to_owned))
476                    .map_err(|e| HubError::new(409, e.to_string()))?;
477                self.bump_mail();
478                Ok(json!({ "woke": id, "via": "m4a", "message_id": message_id }))
479            }
480            // Record a real provider session (a provider conversation id); drafts are not recorded.
481            ("POST", ["v1", "sessions"]) => {
482                let session = Session {
483                    session_id: str_field(body, "session_id")?.to_owned(),
484                    node_id: str_field(body, "node_id")?.to_owned(),
485                    provider: str_field(body, "provider")?.to_owned(),
486                    workspace_id: opt_str(body, "workspace_id").map(str::to_owned),
487                    role: opt_str(body, "role").map(str::to_owned),
488                    state: opt_str(body, "state").unwrap_or("live").to_owned(),
489                    created_at_ms: 0,
490                    updated_at_ms: 0,
491                };
492                let tasks = self.tasks.lock().expect("tasks");
493                let session = tasks.upsert_session(&session).map_err(int)?;
494                if let Some(nick) = opt_str(body, "nick").filter(|n| !n.is_empty() && n.len() <= 128) {
495                    tasks.set_session_nick(&session.session_id, nick).map_err(int)?;
496                }
497                Ok(json!({ "session": session }))
498            }
499            ("POST", ["v1", "tasks", id, "assign"]) => {
500                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
501                let node = str_field(body, "assignee")?;
502                if !self.admitted(node, "tasks") {
503                    return Err(HubError::new(403, "assignee does not hold the tasks capability"));
504                }
505                if self.tasks.lock().expect("tasks").assign(id, node).map_err(|e| HubError::new(500, e.to_string()))? { Ok(json!({ "ok": true })) } else { Err(HubError::new(409, "task cannot be assigned")) }
506            }
507            // Operator archive: finished tasks and idle session records leave the lists.
508            ("POST", ["v1", "tasks", id, "archive"]) => {
509                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
510                let task = self.tasks.lock().expect("tasks").archive_task(id).map_err(int)?.map_err(refused)?;
511                Ok(json!({ "task": task }))
512            }
513            ("POST", ["v1", "sessions", id, "archive"]) => {
514                self.tasks.lock().expect("tasks").archive_session(id).map_err(int)?.map_err(refused)?;
515                Ok(json!({ "archived": id }))
516            }
517            ("POST", ["v1", "tasks", id, "cancel"]) => {
518                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
519                if self.tasks.lock().expect("tasks").set_status(id, "cancelled", None).map_err(|e| HubError::new(500, e.to_string()))? { Ok(json!({ "ok": true })) } else { Err(HubError::new(409, "task is final")) }
520            }
521            ("POST", ["v1", "mail", "send"]) => {
522                let id = self.messenger.lock().expect("messenger").send(OPERATOR, str_field(body, "to")?, str_field(body, "subject")?, str_field(body, "body")?, None)?;
523                self.bump_mail();
524                Ok(json!({ "message_id": id }))
525            }
526            // Long-poll for HQ: returns as soon as the operator inbox holds a message newer than
527            // `since_ms`, or after `timeout_secs` (max 30) with an empty list.
528            ("POST", ["v1", "mail", "wait"]) => {
529                let since = body.get("since_ms").and_then(Value::as_u64).unwrap_or(0);
530                let deadline = Instant::now() + Duration::from_secs(body.get("timeout_secs").and_then(Value::as_u64).unwrap_or(25).min(30));
531                loop {
532                    let seen = *self.mail_signal.0.lock().expect("signal");
533                    let mail: Vec<_> = self.messenger.lock().expect("messenger").inbox(OPERATOR, since, 100)?.into_iter().filter(|m| m.created_at_ms > since).collect();
534                    if !mail.is_empty() {
535                        return Ok(json!({ "messages": mail }));
536                    }
537                    let now = Instant::now();
538                    if now >= deadline {
539                        return Ok(json!({ "messages": [] }));
540                    }
541                    let guard = self.mail_signal.0.lock().expect("signal");
542                    if *guard == seen {
543                        let _ = self.mail_signal.1.wait_timeout(guard, deadline - now).expect("signal wait");
544                    }
545                }
546            }
547            ("POST", ["v1", "mail", "thread"]) => {
548                let mail = self.messenger.lock().expect("messenger").thread(str_field(body, "node")?, 200)?;
549                Ok(json!({ "messages": mail }))
550            }
551            ("POST", ["v1", "mail", "inbox"]) => {
552                let mail = self.messenger.lock().expect("messenger").inbox(OPERATOR, 0, 100)?;
553                Ok(json!({ "messages": mail }))
554            }
555            _ => Err(HubError::new(404, "not found")),
556        }
557    }
558}
559
560#[cfg(test)]
561mod task_api_tests {
562    use super::*;
563
564    #[test]
565    fn signer_ops_need_unlock_and_letters_come_from_the_session_nick() {
566        let dir = tempfile::tempdir().unwrap();
567        let hub = Hub::open(dir.path()).unwrap();
568        let prf = hex::encode([5u8; 32]);
569        hub.operator_call("POST", "/v1/signer/enroll", &json!({"account": "owner", "prf_hex": prf})).unwrap();
570        hub.operator_call("POST", "/v1/signer/register_node", &json!({"account": "owner", "node": "alpha", "prf_hex": prf})).unwrap();
571        // Wrong passkey secret cannot register nodes.
572        assert_eq!(hub.operator_call("POST", "/v1/signer/register_node", &json!({"account": "owner", "node": "x", "prf_hex": hex::encode([6u8; 32])})).unwrap_err().status, 401);
573        let k = hub.signer.lock().unwrap();
574        assert_eq!(k.account_for_node("alpha").as_deref(), Some("owner"));
575        drop(k);
576        hub.operator_call("POST", "/v1/signer/lock", &json!({"account": "owner"})).unwrap();
577        assert!(hub.signer.lock().unwrap().account_for_node("alpha").is_none());
578        hub.operator_call("POST", "/v1/signer/unlock", &json!({"account": "owner", "prf_hex": prf})).unwrap();
579        let p = hub.signer.lock().unwrap().provision("owner", "alpha", "codex", "s1", hatchery_keystore::default_rights(), None).unwrap();
580        let mut m = hub.messenger.lock().unwrap();
581        m.ensure_session_participant(&p.nick).unwrap();
582        m.ensure_session_participant("peer-1").unwrap();
583        assert!(m.send(&p.nick, "peer-1", "s", "b", None).is_ok());
584    }
585
586    #[test]
587    fn roles_only_from_reported_models() {
588        let dir = tempfile::tempdir().unwrap();
589        let hub = Hub::open(dir.path()).unwrap();
590        let inv: Vec<crate::routing::ProviderInventory> = serde_json::from_value(json!([
591            {"provider": "codex", "source": "cli", "models": [{"id": "m1", "efforts": ["high"]}]},
592            {"provider": "grok", "source": "cli", "models": [{"id": "g1", "default": true}]}
593        ]))
594        .unwrap();
595        hub.tasks.lock().unwrap().set_inventory("alpha", &inv).unwrap();
596        // Invented model or effort: refused.
597        assert_eq!(hub.operator_call("POST", "/v1/routing/roles", &json!({"role": "coder", "preset": ["codex/m9"]})).unwrap_err().status, 409);
598        assert_eq!(hub.operator_call("POST", "/v1/routing/roles", &json!({"role": "coder", "preset": ["codex/m1:low"]})).unwrap_err().status, 409);
599        hub.operator_call("POST", "/v1/routing/roles", &json!({"role": "coder", "preset": ["codex/m1:high", "grok/g1"], "auto": true})).unwrap();
600        let r = hub.operator_call("POST", "/v1/routing/recommend", &json!({"role": "coder"})).unwrap();
601        assert_eq!(r["recommendation"]["pick"]["seat"]["id"], "alpha/codex");
602        assert_eq!(r["recommendation"]["auto"], true);
603        let g = hub.operator_call("GET", "/v1/routing", &json!({})).unwrap();
604        assert_eq!(g["inventory"].as_array().unwrap().len(), 2);
605        // A node that stops reporting codex: the role falls to grok.
606        hub.tasks.lock().unwrap().set_inventory("alpha", &inv[1..]).unwrap();
607        let r = hub.operator_call("POST", "/v1/routing/recommend", &json!({"role": "coder"})).unwrap();
608        assert_eq!(r["recommendation"]["pick"]["seat"]["id"], "alpha/grok");
609        hub.operator_call("POST", "/v1/routing/roles/coder/delete", &json!({})).unwrap();
610        assert!(hub.operator_call("POST", "/v1/routing/recommend", &json!({"role": "coder"})).is_err());
611    }
612
613    #[test]
614    fn wake_needs_a_known_session() {
615        let dir = tempfile::tempdir().unwrap();
616        let hub = Hub::open(dir.path()).unwrap();
617        assert_eq!(hub.operator_call("POST", "/v1/sessions/nope/wake", &json!({})).unwrap_err().status, 404);
618        // No ledger: the node is not a mailbox participant, so the letter is refused.
619        hub.operator_call("POST", "/v1/sessions", &json!({"session_id": "s1", "node_id": "alpha", "provider": "codex"})).unwrap();
620        assert!(hub.operator_call("POST", "/v1/sessions/s1/wake", &json!({})).is_err());
621        assert_eq!(hub.poll_m4a(), 0);
622    }
623
624    #[test]
625    fn operator_task_create_move_cancel_and_runs() {
626        let dir = tempfile::tempdir().unwrap();
627        let hub = Hub::open(dir.path()).unwrap();
628        let op = |m: &str, p: &str, b: Value| hub.operator_call(m, p, &b);
629        let t = op("POST", "/v1/tasks", json!({"title": "port", "role": "dev", "status": "backlog", "op_id": "c1"})).unwrap();
630        let id = t["task"]["id"].as_i64().unwrap();
631        // Idempotent create.
632        assert_eq!(op("POST", "/v1/tasks", json!({"title": "port", "op_id": "c1"})).unwrap(), t);
633        assert_eq!(op("GET", "/v1/tasks?role=advisor", json!({})).unwrap()["tasks"].as_array().unwrap().len(), 0);
634        assert_eq!(op("GET", "/v1/tasks?role=dev", json!({})).unwrap()["tasks"].as_array().unwrap().len(), 1);
635        let moved = op("POST", &format!("/v1/tasks/{id}/move"), json!({"status": "open", "expected_revision": 1})).unwrap();
636        assert_eq!(moved["task"]["revision"], 2);
637        assert_eq!(op("POST", &format!("/v1/tasks/{id}/move"), json!({"status": "open", "expected_revision": 1})).unwrap_err().status, 409);
638        // Runs need a recorded real session.
639        assert_eq!(op("POST", &format!("/v1/tasks/{id}/runs"), json!({"session_id": "s1"})).unwrap_err().status, 400);
640        op("POST", "/v1/sessions", json!({"session_id": "s1", "node_id": "alpha", "provider": "codex"})).unwrap();
641        let run = op("POST", &format!("/v1/tasks/{id}/runs"), json!({"session_id": "s1"})).unwrap();
642        let rid = run["run"]["id"].as_i64().unwrap();
643        // Node path is gated by the ledger.
644        assert_eq!(hub.node_call("alpha", "run.close", &json!({"run_id": rid, "status": "done", "session_id": "s1"})).unwrap_err().status, 403);
645        let closed = op("POST", &format!("/v1/runs/{rid}/close"), json!({"status": "cancelled"})).unwrap();
646        assert_eq!(closed["run"]["closed_by"], "operator");
647        let got = op("GET", &format!("/v1/tasks/{id}"), json!({})).unwrap();
648        assert_eq!(got["task"]["status"], "cancelled");
649        assert_eq!(got["runs"].as_array().unwrap().len(), 1);
650        assert_eq!(op("POST", &format!("/v1/tasks/{id}/move"), json!({"status": "open"})).unwrap_err().status, 409);
651        assert_eq!(op("GET", "/v1/sessions?node=alpha", json!({})).unwrap()["sessions"].as_array().unwrap().len(), 1);
652    }
653}
654
655fn signer_dir(data_dir: &Path) -> Result<PathBuf, crate::error::ServerError> {
656    let d = data_dir.join("m4a-signer");
657    std::fs::create_dir_all(&d).map_err(|e| crate::error::ServerError::sub("signer", format!("dir: {}", e.kind())))?;
658    #[cfg(unix)]
659    {
660        use std::os::unix::fs::PermissionsExt;
661        let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700));
662    }
663    Ok(d)
664}
665
666fn signer_err(e: hatchery_keystore::SignerError) -> HubError {
667    use hatchery_keystore::SignerError::*;
668    tracing::warn!(error = ?e, "m4a signer refused a node call");
669    match e {
670        Locked => HubError::new(423, "signer locked"),
671        WrongHumanKey | HumanApprovalRequired => HubError::new(401, "human key required"),
672        Denied(_) | Retired | BadToken => HubError::new(403, "refused"),
673        UnknownSession => HubError::new(404, "unknown session"),
674        Store(m) => HubError::new(500, format!("signer store: {m}")),
675    }
676}