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    messenger: Mutex<Messenger>,
38    tasks: Mutex<TaskStore>,
39    /// Bumped on every message sent; lets HQ long-poll for new mail instead of polling blindly.
40    mail_signal: (Mutex<u64>, Condvar),
41    advice: Mutex<crate::routing::AdviceGate>,
42}
43
44fn refused(r: Refused) -> HubError {
45    match r {
46        Refused::NotFound => HubError::new(404, "not found"),
47        Refused::Conflict(m) => HubError::new(409, m),
48        Refused::Invalid(m) => HubError::new(400, m),
49    }
50}
51
52fn int(e: crate::error::ServerError) -> HubError {
53    HubError::new(500, e.to_string())
54}
55
56fn opt_str<'a>(v: &'a Value, name: &str) -> Option<&'a str> {
57    v.get(name).and_then(Value::as_str)
58}
59
60fn query_param<'a>(query: &'a str, name: &str) -> Option<&'a str> {
61    query.split('&').find_map(|kv| kv.strip_prefix(name).and_then(|r| r.strip_prefix('='))).filter(|v| !v.is_empty())
62}
63
64fn str_field<'a>(v: &'a Value, name: &str) -> Result<&'a str, HubError> {
65    v.get(name).and_then(Value::as_str).ok_or_else(|| HubError::new(400, format!("missing field: {name}")))
66}
67
68fn now_ms() -> u64 {
69    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64
70}
71
72impl Hub {
73    fn routing(&self) -> Result<(crate::routing::RoutingConfig, std::collections::BTreeMap<(String, String), crate::routing::SeatState>), HubError> {
74        let tasks = self.tasks.lock().expect("tasks");
75        let cfg = crate::routing::RoutingConfig::from_store(&tasks.inventory().map_err(int)?, tasks.roles().map_err(int)?);
76        // Fold the recent signal window (bounded table).
77        let mut all = Vec::new();
78        let mut after = None;
79        loop {
80            let page = tasks.signals(None, after, 500).map_err(int)?;
81            let done = page.len() < 500;
82            after = page.last().map(|s| s.id).or(after);
83            all.extend(page);
84            if done {
85                break;
86            }
87        }
88        let state = crate::routing::fold_signals(&all, &cfg);
89        Ok((cfg, state))
90    }
91
92    /// Core's own m4a (the in-process mailbox engine): read `run.close` letters that nodes'
93    /// sessions sent to the operator mailbox and close the named run when the letter comes
94    /// from the run's node. Each letter is acknowledged once handled. Returns runs closed.
95    pub fn poll_m4a(&self) -> usize {
96        let letters = match self.messenger.lock().expect("messenger").inbox(OPERATOR, 0, 256) {
97            Ok(l) => l,
98            Err(e) => {
99                tracing::debug!(error = %e, "m4a reader: operator inbox unavailable");
100                return 0;
101            }
102        };
103        let mut closed = 0;
104        for letter in letters {
105            let Some(cmd) = crate::m4a_gateway::parse_close(&letter.body) else { continue };
106            let tasks = self.tasks.lock().expect("tasks");
107            let outcome = match tasks.run(cmd.run_id) {
108                Ok(Some(run)) if run.node_id == letter.from => {
109                    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)) {
110                        Ok(Ok(r)) => {
111                            closed += 1;
112                            tracing::info!(run = r.id, status = %r.status, "run closed by its session via m4a");
113                            "closed"
114                        }
115                        Ok(Err(why)) => {
116                            tracing::info!(run = run.id, ?why, "m4a run.close refused");
117                            "refused"
118                        }
119                        Err(_) => continue, // store error: retry next tick
120                    }
121                }
122                Ok(_) => "ignored",
123                Err(_) => continue,
124            };
125            drop(tasks);
126            tracing::debug!(outcome, "m4a run.close letter handled");
127            let _ = self.messenger.lock().expect("messenger").ack(OPERATOR, &letter.message_id);
128        }
129        closed
130    }
131
132    /// Routing recommendation for `role` (operator API and node door).
133    pub fn recommend(&self, role: &str) -> Result<Value, HubError> {
134        let (cfg, state) = self.routing()?;
135        let inventory = self.tasks.lock().expect("tasks").inventory().map_err(int)?;
136        let rec = crate::routing::recommend_in(&cfg, Some(&inventory), &state, role, now_ms()).map_err(|e| HubError::new(400, e))?;
137        Ok(json!({ "recommendation": rec }))
138    }
139
140    pub fn open(data_dir: &Path) -> Result<Self, crate::error::ServerError> {
141        Ok(Self {
142            data_dir: data_dir.to_owned(),
143            messenger: Mutex::new(Messenger::open(data_dir)?),
144            tasks: Mutex::new(TaskStore::open(data_dir)?),
145            mail_signal: (Mutex::new(0), Condvar::new()),
146            advice: Mutex::new(Default::default()),
147        })
148    }
149
150    fn bump_mail(&self) {
151        *self.mail_signal.0.lock().expect("signal") += 1;
152        self.mail_signal.1.notify_all();
153    }
154
155    fn entry(&self, node_id: &str) -> Option<LedgerEntry> {
156        read_ledger(&self.data_dir).ok()?.into_iter().find(|e| e.node_id == node_id)
157    }
158
159    /// Active (member/trusted) node holding `capability`, per the ledger.
160    fn admitted(&self, node_id: &str, capability: &str) -> bool {
161        node_id != OPERATOR
162            && self.entry(node_id).is_some_and(|e| {
163                matches!(e.status, NodeStatus::Member | NodeStatus::Trusted) && e.grants.capabilities.iter().any(|c| c == capability)
164            })
165    }
166
167    /// A call made on behalf of a node. The caller (C2) has authenticated the node and checked its
168    /// policy; the hub checks the ledger again, independently.
169    pub fn node_call(&self, node_id: &str, op: &str, payload: &Value) -> Result<Value, HubError> {
170        let capability = match op.split_once('.').map(|p| p.0) {
171            Some("mail") => "mail",
172            Some("task") | Some("run") => "tasks",
173            _ => return Err(HubError::new(404, "unknown operation")),
174        };
175        if !self.admitted(node_id, capability) {
176            return Err(HubError::new(403, "refused"));
177        }
178        match op {
179            "mail.send" => {
180                let key = payload.get("idempotency_key").and_then(Value::as_str).map(str::to_owned);
181                let id = self.messenger.lock().expect("messenger").send(node_id, str_field(payload, "to")?, str_field(payload, "subject")?, str_field(payload, "body")?, key)?;
182                self.bump_mail();
183                Ok(json!({ "message_id": id }))
184            }
185            "mail.inbox" => {
186                let since = payload.get("since_ms").and_then(Value::as_u64).unwrap_or(0);
187                let limit = payload.get("limit").and_then(Value::as_u64).unwrap_or(50).min(256) as u16;
188                let mail = self.messenger.lock().expect("messenger").inbox(node_id, since, limit)?;
189                Ok(json!({ "messages": mail }))
190            }
191            "mail.ack" => {
192                self.messenger.lock().expect("messenger").ack(node_id, str_field(payload, "message_id")?)?;
193                Ok(json!({ "ok": true }))
194            }
195            "task.list" => {
196                let tasks = self.tasks.lock().expect("tasks").list(Some(node_id), true).map_err(|e| HubError::new(500, e.to_string()))?;
197                Ok(json!({ "tasks": tasks }))
198            }
199            "task.update" => {
200                let id = payload.get("id").and_then(Value::as_i64).ok_or_else(|| HubError::new(400, "missing field: id"))?;
201                let status = str_field(payload, "status")?;
202                if !matches!(status, "in_progress" | "done") {
203                    return Err(HubError::new(400, "status must be in_progress or done"));
204                }
205                let changed = self.tasks.lock().expect("tasks").set_status(id, status, Some(node_id)).map_err(|e| HubError::new(500, e.to_string()))?;
206                if changed { Ok(json!({ "ok": true })) } else { Err(HubError::new(403, "refused")) }
207            }
208            "task.route" => self.recommend(str_field(payload, "role")?),
209            // What this node's provider CLIs expose (replaces the node's previous report).
210            "task.provider_inventory" => {
211                let providers: Vec<crate::routing::ProviderInventory> =
212                    serde_json::from_value(payload.get("providers").cloned().unwrap_or(Value::Null)).map_err(|_| HubError::new(400, "bad providers"))?;
213                let providers = crate::routing::clean_report(providers).map_err(|e| HubError::new(400, e))?;
214                self.tasks.lock().expect("tasks").set_inventory(node_id, &providers).map_err(int)?;
215                tracing::info!(node = node_id, providers = providers.len(), "provider inventory");
216                Ok(json!({ "ok": true }))
217            }
218            // Advisor anti-noise gate: an advisor session asks before it sends advice via m4a.
219            "task.advice_gate" => {
220                let (cfg, _) = self.routing()?;
221                let advisor = format!("{node_id}/{}", opt_str(payload, "advisor").unwrap_or("advisor"));
222                match self.advice.lock().expect("advice").check(&cfg.advisor, &advisor, str_field(payload, "topic")?, now_ms()) {
223                    Ok(()) => Ok(json!({ "allow": true })),
224                    Err(why) => Ok(json!({ "allow": false, "reason": why })),
225                }
226            }
227            // Short limit / login / ready signal read off a session screen by the node.
228            "task.session_signal" => {
229                let instance = payload.get("instance_id").and_then(Value::as_i64).ok_or_else(|| HubError::new(400, "missing field: instance_id"))?;
230                let sig = self
231                    .tasks
232                    .lock()
233                    .expect("tasks")
234                    .add_signal(node_id, str_field(payload, "workspace_id")?, instance, str_field(payload, "provider")?, str_field(payload, "kind")?, opt_str(payload, "hint"))
235                    .map_err(int)?
236                    .map_err(refused)?;
237                tracing::info!(node = node_id, provider = %sig.provider, kind = %sig.kind, "session signal");
238                Ok(json!({ "signal_id": sig.id }))
239            }
240            // The run's own session closes it (decision 8); the run must live on this node.
241            // `task.close_run` is the door-facing name; `run.close` is kept as an alias.
242            "task.close_run" | "run.close" => {
243                let run_id = payload.get("run_id").and_then(Value::as_i64).ok_or_else(|| HubError::new(400, "missing field: run_id"))?;
244                let tasks = self.tasks.lock().expect("tasks");
245                let run = tasks.run(run_id).map_err(int)?.ok_or_else(|| HubError::new(404, "not found"))?;
246                if run.node_id != node_id {
247                    return Err(HubError::new(403, "refused"));
248                }
249                let run = tasks
250                    .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"))
251                    .map_err(int)?
252                    .map_err(refused)?;
253                Ok(json!({ "run": run }))
254            }
255            _ => Err(HubError::new(404, "unknown operation")),
256        }
257    }
258
259    /// Operator (HQ) calls, authenticated by the control bearer in the HTTP layer.
260    pub fn operator_call(&self, method: &str, path: &str, body: &Value) -> Result<Value, HubError> {
261        let (path, query) = path.split_once('?').unwrap_or((path, ""));
262        let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
263        match (method, parts.as_slice()) {
264            ("POST", ["v1", "tasks"]) => {
265                let assignee = opt_str(body, "assignee");
266                if let Some(node) = assignee {
267                    if !self.admitted(node, "tasks") {
268                        return Err(HubError::new(403, "assignee does not hold the tasks capability"));
269                    }
270                }
271                let tasks = self.tasks.lock().expect("tasks");
272                let op = opt_str(body, "op_id");
273                if let Some(done) = tasks.replay(op).map_err(int)? {
274                    return Ok(done);
275                }
276                let status = opt_str(body, "status");
277                if let Some(st) = status {
278                    if !matches!(st, "backlog" | "open" | "assigned") {
279                        return Err(HubError::new(400, "initial status must be backlog, open or assigned"));
280                    }
281                }
282                let task = tasks
283                    .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)
284                    .map_err(int)?;
285                let out = json!({ "task": task });
286                tasks.record(op, &out).map_err(int)?;
287                Ok(out)
288            }
289            ("GET", ["v1", "tasks"]) => {
290                let roles: Option<Vec<String>> = query_param(query, "role").map(|r| r.split(',').map(str::to_owned).collect());
291                let tasks = self.tasks.lock().expect("tasks").list_filtered(query_param(query, "assignee"), roles.as_deref(), false).map_err(int)?;
292                Ok(json!({ "tasks": tasks }))
293            }
294            ("GET", ["v1", "tasks", id]) => {
295                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
296                let tasks = self.tasks.lock().expect("tasks");
297                let task = tasks.get(id).map_err(int)?.ok_or_else(|| HubError::new(404, "not found"))?;
298                Ok(json!({ "task": task, "runs": tasks.runs(id).map_err(int)? }))
299            }
300            ("POST", ["v1", "tasks", id, "move"]) => {
301                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
302                let tasks = self.tasks.lock().expect("tasks");
303                let op = opt_str(body, "op_id");
304                if let Some(done) = tasks.replay(op).map_err(int)? {
305                    return Ok(done);
306                }
307                let task = tasks
308                    .move_to(id, str_field(body, "status")?, body.get("expected_revision").and_then(Value::as_i64))
309                    .map_err(int)?
310                    .map_err(refused)?;
311                let out = json!({ "task": task });
312                tasks.record(op, &out).map_err(int)?;
313                Ok(out)
314            }
315            ("POST", ["v1", "tasks", id, "runs"]) => {
316                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
317                let run = self.tasks.lock().expect("tasks").start_run(id, str_field(body, "session_id")?).map_err(int)?.map_err(refused)?;
318                Ok(json!({ "run": run }))
319            }
320            ("POST", ["v1", "runs", id, "close"]) => {
321                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
322                let run = self
323                    .tasks
324                    .lock()
325                    .expect("tasks")
326                    .close_run(id, str_field(body, "status")?, "operator", None, opt_str(body, "branch"), opt_str(body, "commit"), opt_str(body, "result_message_id"))
327                    .map_err(int)?
328                    .map_err(refused)?;
329                Ok(json!({ "run": run }))
330            }
331            ("GET", ["v1", "routing"]) => {
332                let (cfg, state) = self.routing()?;
333                let seats: Vec<Value> = cfg
334                    .seats
335                    .iter()
336                    .map(|seat| {
337                        let st = state.get(&(seat.node.clone(), seat.provider.clone())).cloned().unwrap_or_default();
338                        json!({ "seat": seat, "state": st })
339                    })
340                    .collect();
341                let inventory = self.tasks.lock().expect("tasks").inventory().map_err(int)?;
342                Ok(json!({ "roles": cfg.roles, "seats": seats, "inventory": inventory, "advisor": cfg.advisor }))
343            }
344            // The user's role choice: preset entries must be models some node reported.
345            ("POST", ["v1", "routing", "roles"]) => {
346                let role = str_field(body, "role")?;
347                if role.is_empty() || role.len() > 64 || !role.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)) {
348                    return Err(HubError::new(400, "bad role name"));
349                }
350                let preset: Vec<String> = body
351                    .get("preset")
352                    .and_then(Value::as_array)
353                    .ok_or_else(|| HubError::new(400, "missing field: preset"))?
354                    .iter()
355                    .map(|v| v.as_str().map(str::to_owned).ok_or_else(|| HubError::new(400, "preset entries are strings")))
356                    .collect::<Result<_, _>>()?;
357                if preset.len() > 16 {
358                    return Err(HubError::new(400, "preset too long"));
359                }
360                let tasks = self.tasks.lock().expect("tasks");
361                let inventory = tasks.inventory().map_err(int)?;
362                for entry in &preset {
363                    let m = crate::routing::parse_model_ref(entry).ok_or_else(|| HubError::new(400, "bad preset entry"))?;
364                    if !crate::routing::offered(&inventory, &m) {
365                        return Err(HubError::new(409, format!("not offered by any node: {entry}")));
366                    }
367                }
368                let r = crate::routing::Role { preset, auto: body.get("auto").and_then(Value::as_bool).unwrap_or(false) };
369                tasks.set_role(role, &r).map_err(int)?;
370                Ok(json!({ "role": role, "preset": r.preset, "auto": r.auto }))
371            }
372            ("POST", ["v1", "routing", "roles", role, "delete"]) => {
373                let gone = self.tasks.lock().expect("tasks").delete_role(role).map_err(int)?;
374                Ok(json!({ "deleted": gone }))
375            }
376            ("POST", ["v1", "routing", "recommend"]) => self.recommend(str_field(body, "role")?),
377            ("GET", ["v1", "signals"]) => {
378                let after = query_param(query, "after").and_then(|v| v.parse().ok());
379                let limit = query_param(query, "limit").and_then(|v| v.parse().ok()).unwrap_or(100);
380                let signals = self.tasks.lock().expect("tasks").signals(query_param(query, "node"), after, limit).map_err(int)?;
381                Ok(json!({ "signals": signals }))
382            }
383            ("GET", ["v1", "sessions"]) => {
384                let sessions = self.tasks.lock().expect("tasks").sessions(query_param(query, "node")).map_err(int)?;
385                Ok(json!({ "sessions": sessions }))
386            }
387            // Wake a session: a letter from core's own m4a to the session's node, naming the
388            // session; the node wakes it and the session reads the letter from m4a.
389            ("POST", ["v1", "sessions", id, "wake"]) => {
390                let session = self.tasks.lock().expect("tasks").session(id).map_err(int)?.ok_or_else(|| HubError::new(404, "not found"))?;
391                let nick = self.tasks.lock().expect("tasks").session_nick(id).map_err(int)?;
392                let text = opt_str(body, "text").map(str::trim).filter(|t| !t.is_empty()).unwrap_or("wake: check your tasks and m4a inbox");
393                if text.len() > 16 * 1024 {
394                    return Err(HubError::new(400, "text too long"));
395                }
396                let subject = format!("wake {}", nick.as_deref().unwrap_or(&session.session_id));
397                let message_id = self
398                    .messenger
399                    .lock()
400                    .expect("messenger")
401                    .send(OPERATOR, &session.node_id, &subject, text, opt_str(body, "op_id").map(str::to_owned))
402                    .map_err(|e| HubError::new(409, e.to_string()))?;
403                self.bump_mail();
404                Ok(json!({ "woke": id, "via": "m4a", "message_id": message_id }))
405            }
406            // Record a real provider session (a provider conversation id); drafts are not recorded.
407            ("POST", ["v1", "sessions"]) => {
408                let session = Session {
409                    session_id: str_field(body, "session_id")?.to_owned(),
410                    node_id: str_field(body, "node_id")?.to_owned(),
411                    provider: str_field(body, "provider")?.to_owned(),
412                    workspace_id: opt_str(body, "workspace_id").map(str::to_owned),
413                    role: opt_str(body, "role").map(str::to_owned),
414                    state: opt_str(body, "state").unwrap_or("live").to_owned(),
415                    created_at_ms: 0,
416                    updated_at_ms: 0,
417                };
418                let tasks = self.tasks.lock().expect("tasks");
419                let session = tasks.upsert_session(&session).map_err(int)?;
420                if let Some(nick) = opt_str(body, "nick").filter(|n| !n.is_empty() && n.len() <= 128) {
421                    tasks.set_session_nick(&session.session_id, nick).map_err(int)?;
422                }
423                Ok(json!({ "session": session }))
424            }
425            ("POST", ["v1", "tasks", id, "assign"]) => {
426                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
427                let node = str_field(body, "assignee")?;
428                if !self.admitted(node, "tasks") {
429                    return Err(HubError::new(403, "assignee does not hold the tasks capability"));
430                }
431                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")) }
432            }
433            // Operator archive: finished tasks and idle session records leave the lists.
434            ("POST", ["v1", "tasks", id, "archive"]) => {
435                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
436                let task = self.tasks.lock().expect("tasks").archive_task(id).map_err(int)?.map_err(refused)?;
437                Ok(json!({ "task": task }))
438            }
439            ("POST", ["v1", "sessions", id, "archive"]) => {
440                self.tasks.lock().expect("tasks").archive_session(id).map_err(int)?.map_err(refused)?;
441                Ok(json!({ "archived": id }))
442            }
443            ("POST", ["v1", "tasks", id, "cancel"]) => {
444                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
445                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")) }
446            }
447            ("POST", ["v1", "mail", "send"]) => {
448                let id = self.messenger.lock().expect("messenger").send(OPERATOR, str_field(body, "to")?, str_field(body, "subject")?, str_field(body, "body")?, None)?;
449                self.bump_mail();
450                Ok(json!({ "message_id": id }))
451            }
452            // Long-poll for HQ: returns as soon as the operator inbox holds a message newer than
453            // `since_ms`, or after `timeout_secs` (max 30) with an empty list.
454            ("POST", ["v1", "mail", "wait"]) => {
455                let since = body.get("since_ms").and_then(Value::as_u64).unwrap_or(0);
456                let deadline = Instant::now() + Duration::from_secs(body.get("timeout_secs").and_then(Value::as_u64).unwrap_or(25).min(30));
457                loop {
458                    let seen = *self.mail_signal.0.lock().expect("signal");
459                    let mail: Vec<_> = self.messenger.lock().expect("messenger").inbox(OPERATOR, since, 100)?.into_iter().filter(|m| m.created_at_ms > since).collect();
460                    if !mail.is_empty() {
461                        return Ok(json!({ "messages": mail }));
462                    }
463                    let now = Instant::now();
464                    if now >= deadline {
465                        return Ok(json!({ "messages": [] }));
466                    }
467                    let guard = self.mail_signal.0.lock().expect("signal");
468                    if *guard == seen {
469                        let _ = self.mail_signal.1.wait_timeout(guard, deadline - now).expect("signal wait");
470                    }
471                }
472            }
473            ("POST", ["v1", "mail", "thread"]) => {
474                let mail = self.messenger.lock().expect("messenger").thread(str_field(body, "node")?, 200)?;
475                Ok(json!({ "messages": mail }))
476            }
477            ("POST", ["v1", "mail", "inbox"]) => {
478                let mail = self.messenger.lock().expect("messenger").inbox(OPERATOR, 0, 100)?;
479                Ok(json!({ "messages": mail }))
480            }
481            _ => Err(HubError::new(404, "not found")),
482        }
483    }
484}
485
486#[cfg(test)]
487mod task_api_tests {
488    use super::*;
489
490    #[test]
491    fn roles_only_from_reported_models() {
492        let dir = tempfile::tempdir().unwrap();
493        let hub = Hub::open(dir.path()).unwrap();
494        let inv: Vec<crate::routing::ProviderInventory> = serde_json::from_value(json!([
495            {"provider": "codex", "source": "cli", "models": [{"id": "m1", "efforts": ["high"]}]},
496            {"provider": "grok", "source": "cli", "models": [{"id": "g1", "default": true}]}
497        ]))
498        .unwrap();
499        hub.tasks.lock().unwrap().set_inventory("alpha", &inv).unwrap();
500        // Invented model or effort: refused.
501        assert_eq!(hub.operator_call("POST", "/v1/routing/roles", &json!({"role": "coder", "preset": ["codex/m9"]})).unwrap_err().status, 409);
502        assert_eq!(hub.operator_call("POST", "/v1/routing/roles", &json!({"role": "coder", "preset": ["codex/m1:low"]})).unwrap_err().status, 409);
503        hub.operator_call("POST", "/v1/routing/roles", &json!({"role": "coder", "preset": ["codex/m1:high", "grok/g1"], "auto": true})).unwrap();
504        let r = hub.operator_call("POST", "/v1/routing/recommend", &json!({"role": "coder"})).unwrap();
505        assert_eq!(r["recommendation"]["pick"]["seat"]["id"], "alpha/codex");
506        assert_eq!(r["recommendation"]["auto"], true);
507        let g = hub.operator_call("GET", "/v1/routing", &json!({})).unwrap();
508        assert_eq!(g["inventory"].as_array().unwrap().len(), 2);
509        // A node that stops reporting codex: the role falls to grok.
510        hub.tasks.lock().unwrap().set_inventory("alpha", &inv[1..]).unwrap();
511        let r = hub.operator_call("POST", "/v1/routing/recommend", &json!({"role": "coder"})).unwrap();
512        assert_eq!(r["recommendation"]["pick"]["seat"]["id"], "alpha/grok");
513        hub.operator_call("POST", "/v1/routing/roles/coder/delete", &json!({})).unwrap();
514        assert!(hub.operator_call("POST", "/v1/routing/recommend", &json!({"role": "coder"})).is_err());
515    }
516
517    #[test]
518    fn wake_needs_a_known_session() {
519        let dir = tempfile::tempdir().unwrap();
520        let hub = Hub::open(dir.path()).unwrap();
521        assert_eq!(hub.operator_call("POST", "/v1/sessions/nope/wake", &json!({})).unwrap_err().status, 404);
522        // No ledger: the node is not a mailbox participant, so the letter is refused.
523        hub.operator_call("POST", "/v1/sessions", &json!({"session_id": "s1", "node_id": "alpha", "provider": "codex"})).unwrap();
524        assert!(hub.operator_call("POST", "/v1/sessions/s1/wake", &json!({})).is_err());
525        assert_eq!(hub.poll_m4a(), 0);
526    }
527
528    #[test]
529    fn operator_task_create_move_cancel_and_runs() {
530        let dir = tempfile::tempdir().unwrap();
531        let hub = Hub::open(dir.path()).unwrap();
532        let op = |m: &str, p: &str, b: Value| hub.operator_call(m, p, &b);
533        let t = op("POST", "/v1/tasks", json!({"title": "port", "role": "dev", "status": "backlog", "op_id": "c1"})).unwrap();
534        let id = t["task"]["id"].as_i64().unwrap();
535        // Idempotent create.
536        assert_eq!(op("POST", "/v1/tasks", json!({"title": "port", "op_id": "c1"})).unwrap(), t);
537        assert_eq!(op("GET", "/v1/tasks?role=advisor", json!({})).unwrap()["tasks"].as_array().unwrap().len(), 0);
538        assert_eq!(op("GET", "/v1/tasks?role=dev", json!({})).unwrap()["tasks"].as_array().unwrap().len(), 1);
539        let moved = op("POST", &format!("/v1/tasks/{id}/move"), json!({"status": "open", "expected_revision": 1})).unwrap();
540        assert_eq!(moved["task"]["revision"], 2);
541        assert_eq!(op("POST", &format!("/v1/tasks/{id}/move"), json!({"status": "open", "expected_revision": 1})).unwrap_err().status, 409);
542        // Runs need a recorded real session.
543        assert_eq!(op("POST", &format!("/v1/tasks/{id}/runs"), json!({"session_id": "s1"})).unwrap_err().status, 400);
544        op("POST", "/v1/sessions", json!({"session_id": "s1", "node_id": "alpha", "provider": "codex"})).unwrap();
545        let run = op("POST", &format!("/v1/tasks/{id}/runs"), json!({"session_id": "s1"})).unwrap();
546        let rid = run["run"]["id"].as_i64().unwrap();
547        // Node path is gated by the ledger.
548        assert_eq!(hub.node_call("alpha", "run.close", &json!({"run_id": rid, "status": "done", "session_id": "s1"})).unwrap_err().status, 403);
549        let closed = op("POST", &format!("/v1/runs/{rid}/close"), json!({"status": "cancelled"})).unwrap();
550        assert_eq!(closed["run"]["closed_by"], "operator");
551        let got = op("GET", &format!("/v1/tasks/{id}"), json!({})).unwrap();
552        assert_eq!(got["task"]["status"], "cancelled");
553        assert_eq!(got["runs"].as_array().unwrap().len(), 1);
554        assert_eq!(op("POST", &format!("/v1/tasks/{id}/move"), json!({"status": "open"})).unwrap_err().status, 409);
555        assert_eq!(op("GET", "/v1/sessions?node=alpha", json!({})).unwrap()["sessions"].as_array().unwrap().len(), 1);
556    }
557}