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