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
5/// The operator's own principal in node/ledger checks (never a node id).
6pub const OPERATOR: &str = "hatchery.operator";
7use crate::provision::{read_ledger, LedgerEntry, NodeStatus};
8use crate::tasks::{Refused, Session, TaskStore};
9use serde_json::{json, Value};
10use std::path::{Path, PathBuf};
11use std::sync::Mutex;
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
25pub struct Hub {
26    data_dir: PathBuf,
27    /// m4a signer: per-human key trees, unlocked by each human's passkey PRF secret.
28    signer: std::sync::Arc<Mutex<hatchery_keystore::keyring::Keyring>>,
29    tasks: Mutex<TaskStore>,
30    advice: Mutex<crate::routing::AdviceGate>,
31    /// The real m4a: where session letters go (no separate messenger for sessions).
32    link: Option<crate::m4a_link::M4aLink>,
33}
34
35fn refused(r: Refused) -> HubError {
36    match r {
37        Refused::NotFound => HubError::new(404, "not found"),
38        Refused::Conflict(m) => HubError::new(409, m),
39        Refused::Invalid(m) => HubError::new(400, m),
40    }
41}
42
43fn int(e: crate::error::ServerError) -> HubError {
44    HubError::new(500, e.to_string())
45}
46
47fn opt_str<'a>(v: &'a Value, name: &str) -> Option<&'a str> {
48    v.get(name).and_then(Value::as_str)
49}
50
51fn query_param<'a>(query: &'a str, name: &str) -> Option<&'a str> {
52    query.split('&').find_map(|kv| kv.strip_prefix(name).and_then(|r| r.strip_prefix('='))).filter(|v| !v.is_empty())
53}
54
55fn str_field<'a>(v: &'a Value, name: &str) -> Result<&'a str, HubError> {
56    v.get(name).and_then(Value::as_str).ok_or_else(|| HubError::new(400, format!("missing field: {name}")))
57}
58
59fn now_ms() -> u64 {
60    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64
61}
62
63impl Hub {
64    fn routing(&self) -> Result<(crate::routing::RoutingConfig, std::collections::BTreeMap<(String, String), crate::routing::SeatState>), HubError> {
65        let tasks = self.tasks.lock().expect("tasks");
66        let cfg = crate::routing::RoutingConfig::from_store(&tasks.inventory().map_err(int)?, tasks.roles().map_err(int)?);
67        // Fold the recent signal window (bounded table).
68        let mut all = Vec::new();
69        let mut after = None;
70        loop {
71            let page = tasks.signals(None, after, 500).map_err(int)?;
72            let done = page.len() < 500;
73            after = page.last().map(|s| s.id).or(after);
74            all.extend(page);
75            if done {
76                break;
77            }
78        }
79        let state = crate::routing::fold_signals(&all, &cfg);
80        Ok((cfg, state))
81    }
82
83    /// Routing recommendation for `role` (operator API and node door).
84    pub fn recommend(&self, role: &str) -> Result<Value, HubError> {
85        let (cfg, state) = self.routing()?;
86        let inventory = self.tasks.lock().expect("tasks").inventory().map_err(int)?;
87        let rec = crate::routing::recommend_in(&cfg, Some(&inventory), &state, role, now_ms()).map_err(|e| HubError::new(400, e))?;
88        Ok(json!({ "recommendation": rec }))
89    }
90
91    pub fn open(data_dir: &Path) -> Result<Self, crate::error::ServerError> {
92        Ok(Self {
93            data_dir: data_dir.to_owned(),
94            signer: std::sync::Arc::new(Mutex::new(hatchery_keystore::keyring::Keyring::new(signer_dir(data_dir)?))),
95            tasks: Mutex::new(TaskStore::open(data_dir)?),
96            advice: Mutex::new(Default::default()),
97            link: crate::m4a_link::M4aLink::from_env(data_dir),
98        })
99    }
100
101    fn entry(&self, node_id: &str) -> Option<LedgerEntry> {
102        read_ledger(&self.data_dir).ok()?.into_iter().find(|e| e.node_id == node_id)
103    }
104
105    /// Active (member/trusted) node holding `capability`, per the ledger.
106    fn admitted(&self, node_id: &str, capability: &str) -> bool {
107        node_id != OPERATOR
108            && self.entry(node_id).is_some_and(|e| {
109                matches!(e.status, NodeStatus::Member | NodeStatus::Trusted) && e.grants.capabilities.iter().any(|c| c == capability)
110            })
111    }
112
113    /// A call made on behalf of a node. The caller (C2) has authenticated the node and checked its
114    /// policy; the hub checks the ledger again, independently.
115    pub fn node_call(&self, node_id: &str, op: &str, payload: &Value) -> Result<Value, HubError> {
116        let capability = match op.split_once('.').map(|p| p.0) {
117            Some("task") | Some("run") => "tasks",
118            Some("signer") => "mail",
119            _ => return Err(HubError::new(404, "unknown operation")),
120        };
121        if !self.admitted(node_id, capability) {
122            return Err(HubError::new(403, "refused"));
123        }
124        match op {
125            "task.list" => {
126                let tasks = self.tasks.lock().expect("tasks").list(Some(node_id), true).map_err(|e| HubError::new(500, e.to_string()))?;
127                Ok(json!({ "tasks": tasks }))
128            }
129            "task.update" => {
130                let id = payload.get("id").and_then(Value::as_i64).ok_or_else(|| HubError::new(400, "missing field: id"))?;
131                let status = str_field(payload, "status")?;
132                if !matches!(status, "in_progress" | "done") {
133                    return Err(HubError::new(400, "status must be in_progress or done"));
134                }
135                let changed = self.tasks.lock().expect("tasks").set_status(id, status, Some(node_id)).map_err(|e| HubError::new(500, e.to_string()))?;
136                if changed { Ok(json!({ "ok": true })) } else { Err(HubError::new(403, "refused")) }
137            }
138            "task.route" => self.recommend(str_field(payload, "role")?),
139            // What this node's provider CLIs expose (replaces the node's previous report).
140            "task.provider_inventory" => {
141                let providers: Vec<crate::routing::ProviderInventory> =
142                    serde_json::from_value(payload.get("providers").cloned().unwrap_or(Value::Null)).map_err(|_| HubError::new(400, "bad providers"))?;
143                let providers = crate::routing::clean_report(providers).map_err(|e| HubError::new(400, e))?;
144                self.tasks.lock().expect("tasks").set_inventory(node_id, &providers).map_err(int)?;
145                tracing::info!(node = node_id, providers = providers.len(), "provider inventory");
146                Ok(json!({ "ok": true }))
147            }
148            // Advisor anti-noise gate: an advisor session asks before it sends advice via m4a.
149            "task.advice_gate" => {
150                let (cfg, _) = self.routing()?;
151                let advisor = format!("{node_id}/{}", opt_str(payload, "advisor").unwrap_or("advisor"));
152                match self.advice.lock().expect("advice").check(&cfg.advisor, &advisor, str_field(payload, "topic")?, now_ms()) {
153                    Ok(()) => Ok(json!({ "allow": true })),
154                    Err(why) => Ok(json!({ "allow": false, "reason": why })),
155                }
156            }
157            // Short limit / login / ready signal read off a session screen by the node.
158            "task.session_signal" => {
159                let instance = payload.get("instance_id").and_then(Value::as_i64).ok_or_else(|| HubError::new(400, "missing field: instance_id"))?;
160                let sig = self
161                    .tasks
162                    .lock()
163                    .expect("tasks")
164                    .add_signal(node_id, str_field(payload, "workspace_id")?, instance, str_field(payload, "provider")?, str_field(payload, "kind")?, opt_str(payload, "hint"))
165                    .map_err(int)?
166                    .map_err(refused)?;
167                tracing::info!(node = node_id, provider = %sig.provider, kind = %sig.kind, "session signal");
168                Ok(json!({ "signal_id": sig.id }))
169            }
170            // The run's own session closes it (decision 8); the run must live on this node.
171            // `task.close_run` is the door-facing name; `run.close` is kept as an alias.
172            "task.close_run" | "run.close" => {
173                let run_id = payload.get("run_id").and_then(Value::as_i64).ok_or_else(|| HubError::new(400, "missing field: run_id"))?;
174                let tasks = self.tasks.lock().expect("tasks");
175                let run = tasks.run(run_id).map_err(int)?.ok_or_else(|| HubError::new(404, "not found"))?;
176                if run.node_id != node_id {
177                    return Err(HubError::new(403, "refused"));
178                }
179                let run = tasks
180                    .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"))
181                    .map_err(int)?
182                    .map_err(refused)?;
183                Ok(json!({ "run": run }))
184            }
185            // A fresh session on this node gets its identity from the account tree that owns
186            // the node. The token goes back to the node's proxy, never into the PTY.
187            "signer.provision" | "keystore.provision" => {
188                let mut k = self.signer.lock().expect("signer");
189                let account = k.account_for_node(node_id).ok_or_else(|| HubError::new(423, "signer locked for this node's account"))?;
190                let p = k
191                    .provision(&account, node_id, str_field(payload, "provider")?, str_field(payload, "session")?, hatchery_keystore::default_rights(), None)
192                    .map_err(signer_err)?;
193                Ok(json!({ "nick": p.nick, "token": p.token, "public_key": hex::encode(p.public_key) }))
194            }
195            "signer.sign" | "keystore.sign" => {
196                let payload_bytes = hex::decode(str_field(payload, "payload_hex")?).map_err(|_| HubError::new(400, "bad payload"))?;
197                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)?;
198                Ok(json!({ "nick": sig.nick, "public_key_hex": hex::encode(sig.public_key), "signature_hex": hex::encode(sig.signature) }))
199            }
200            // Signer-signed letter: core signs as the token's own nick and delivers from that nick.
201            "signer.send" | "keystore.send" => {
202                let (to, subject, body) = (str_field(payload, "to")?, str_field(payload, "subject")?, str_field(payload, "body")?);
203                let canonical = serde_json::to_vec(&json!({ "to": to, "subject": subject, "body": body })).map_err(|_| HubError::new(500, "encode"))?;
204                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)?;
205                if !hatchery_keystore::verify_signature(&sig, hatchery_keystore::RIGHT_SEND, &canonical) {
206                    return Err(HubError::new(500, "signature check failed"));
207                }
208                // Into the real m4a, end-to-end encrypted, as the session's own m4a account
209                // (identity key and vault master key rooted in the keystore).
210                let link = self.link.as_ref().ok_or_else(|| HubError::new(503, "m4a link not configured"))?;
211                let token = str_field(payload, "token")?.to_owned();
212                let vault_key = self.signer.lock().expect("signer").m4a_vault_key(node_id, &token).map_err(signer_err)?;
213                let (signer, node) = (self.signer.clone(), node_id.to_owned());
214                let sign: crate::m4a_link::ExternalSign = Box::new(move |m: &[u8]| {
215                    signer.lock().expect("signer").sign_m4a_keyproof(&node, &token, m).map(|s| s.signature).map_err(|e| e.to_string())
216                });
217                // E2E setup (login, key upload, peer join, Megolm share) can take longer than a
218                // node call may block, so delivery runs on its own thread; the outcome is logged.
219                // One delivery at a time: a session's store is opened by one client only.
220                static DELIVERY: Mutex<()> = Mutex::new(());
221                let (link, nick, node, to, body) = (link.clone(), sig.nick.clone(), node_id.to_owned(), to.to_owned(), body.to_owned());
222                let public_key = sig.public_key;
223                std::thread::spawn(move || {
224                    let _one = DELIVERY.lock().unwrap_or_else(|e| e.into_inner());
225                    match link.deliver_e2e(&nick, public_key, vault_key, sign, &to, &body) {
226                        Ok((m4a, room, event)) => tracing::info!(node = %node, nick = %nick, m4a = %m4a, room = %room, event = %event, "signed e2e letter delivered to m4a"),
227                        Err(e) => tracing::warn!(node = %node, nick = %nick, status = e.0, error = %e.1, "m4a delivery failed"),
228                    }
229                });
230                Ok(json!({ "queued": true, "from": sig.nick, "signature_hex": hex::encode(sig.signature), "public_key_hex": hex::encode(sig.public_key) }))
231            }
232            "signer.revoke" | "keystore.revoke" => {
233                let session = str_field(payload, "session")?;
234                let mut k = self.signer.lock().expect("signer");
235                let account = k.account_for_node(node_id).ok_or_else(|| HubError::new(423, "signer locked"))?;
236                let b = k.branch(&account).map_err(signer_err)?;
237                b.session_on_node(session, node_id).map_err(signer_err)?;
238                b.revoke_session(session).map_err(signer_err)?;
239                Ok(json!({ "ok": true }))
240            }
241            _ => Err(HubError::new(404, "unknown operation")),
242        }
243    }
244
245    /// Operator signer calls. `prf_hex` is the account passkey's WebAuthn PRF output, evaluated
246    /// in the human's browser; the passkey itself never reaches core.
247    fn signer_operator(&self, op: &str, body: &Value) -> Result<Value, HubError> {
248        let account = str_field(body, "account")?;
249        let mut k = self.signer.lock().expect("signer");
250        let human = || -> Result<hatchery_keystore::HumanKey, HubError> {
251            let prf = hex::decode(str_field(body, "prf_hex")?).map_err(|_| HubError::new(400, "bad prf"))?;
252            if prf.len() < 32 {
253                return Err(HubError::new(400, "prf output too short"));
254            }
255            Ok(hatchery_keystore::HumanKey::from_passkey_prf(&prf))
256        };
257        match op {
258            "enroll" => k.enroll(account, &human()?).map_err(signer_err)?,
259            "unlock" => k.unlock(account, &human()?).map_err(signer_err)?,
260            "lock" => k.lock(account),
261            "register_node" => {
262                let h = human()?;
263                k.branch(account).map_err(signer_err)?.register_node(str_field(body, "node")?, &h).map_err(signer_err)?
264            }
265            "status" => return Ok(json!({ "account": account, "unlocked": k.is_unlocked(account), "exists": k.exists(account) })),
266            // Browser passkey login (PRF evaluated in the human's browser): first time enrolls the
267            // account's branch, then every active node is put under it, and other accounts
268            // holding those nodes are locked (the passkey account replaces any bootstrap one).
269            "passkey" => {
270                let h = human()?;
271                let enrolled = if k.exists(account) { k.unlock(account, &h).map_err(signer_err)?; false } else { k.enroll(account, &h).map_err(signer_err)?; true };
272                let mut nodes: Vec<String> = read_ledger(&self.data_dir)
273                    .unwrap_or_default()
274                    .into_iter()
275                    .filter(|e| matches!(e.status, NodeStatus::Member | NodeStatus::Trusted))
276                    .map(|e| e.node_id)
277                    .collect();
278                for n in std::env::var("HATCHERY_CORE_NODE_IDS").unwrap_or_default().split(',').map(str::trim).filter(|n| !n.is_empty()) {
279                    if !nodes.iter().any(|x| x == n) {
280                        nodes.push(n.to_owned());
281                    }
282                }
283                let b = k.branch(account).map_err(signer_err)?;
284                for n in &nodes {
285                    if !b.has_node(n) {
286                        b.register_node(n, &h).map_err(signer_err)?;
287                    }
288                }
289                let locked = k.take_nodes(account, &nodes);
290                tracing::info!(account, enrolled, nodes = nodes.len(), locked = locked.len(), "keystore unlocked by passkey");
291                return Ok(json!({ "ok": true, "unlocked": true, "enrolled": enrolled, "nodes": nodes, "locked_others": locked }));
292            }
293            _ => return Err(HubError::new(404, "unknown signer operation")),
294        }
295        tracing::info!(account, op, "signer operator call");
296        Ok(json!({ "ok": true, "unlocked": k.is_unlocked(account) }))
297    }
298
299    /// Operator (HQ) calls, authenticated by the control bearer in the HTTP layer.
300    pub fn operator_call(&self, method: &str, path: &str, body: &Value) -> Result<Value, HubError> {
301        let (path, query) = path.split_once('?').unwrap_or((path, ""));
302        let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
303        match (method, parts.as_slice()) {
304            ("POST", ["v1", "signer", op]) => self.signer_operator(op, body),
305            ("POST", ["v1", "tasks"]) => {
306                let assignee = opt_str(body, "assignee");
307                if let Some(node) = assignee {
308                    if !self.admitted(node, "tasks") {
309                        return Err(HubError::new(403, "assignee does not hold the tasks capability"));
310                    }
311                }
312                let tasks = self.tasks.lock().expect("tasks");
313                let op = opt_str(body, "op_id");
314                if let Some(done) = tasks.replay(op).map_err(int)? {
315                    return Ok(done);
316                }
317                let status = opt_str(body, "status");
318                if let Some(st) = status {
319                    if !matches!(st, "backlog" | "open" | "assigned") {
320                        return Err(HubError::new(400, "initial status must be backlog, open or assigned"));
321                    }
322                }
323                let task = tasks
324                    .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)
325                    .map_err(int)?;
326                let out = json!({ "task": task });
327                tasks.record(op, &out).map_err(int)?;
328                Ok(out)
329            }
330            ("GET", ["v1", "tasks"]) => {
331                let roles: Option<Vec<String>> = query_param(query, "role").map(|r| r.split(',').map(str::to_owned).collect());
332                let tasks = self.tasks.lock().expect("tasks").list_filtered(query_param(query, "assignee"), roles.as_deref(), false).map_err(int)?;
333                Ok(json!({ "tasks": tasks }))
334            }
335            ("GET", ["v1", "tasks", id]) => {
336                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
337                let tasks = self.tasks.lock().expect("tasks");
338                let task = tasks.get(id).map_err(int)?.ok_or_else(|| HubError::new(404, "not found"))?;
339                Ok(json!({ "task": task, "runs": tasks.runs(id).map_err(int)? }))
340            }
341            ("POST", ["v1", "tasks", id, "move"]) => {
342                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
343                let tasks = self.tasks.lock().expect("tasks");
344                let op = opt_str(body, "op_id");
345                if let Some(done) = tasks.replay(op).map_err(int)? {
346                    return Ok(done);
347                }
348                let task = tasks
349                    .move_to(id, str_field(body, "status")?, body.get("expected_revision").and_then(Value::as_i64))
350                    .map_err(int)?
351                    .map_err(refused)?;
352                let out = json!({ "task": task });
353                tasks.record(op, &out).map_err(int)?;
354                Ok(out)
355            }
356            ("POST", ["v1", "tasks", id, "runs"]) => {
357                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
358                let run = self.tasks.lock().expect("tasks").start_run(id, str_field(body, "session_id")?).map_err(int)?.map_err(refused)?;
359                Ok(json!({ "run": run }))
360            }
361            ("POST", ["v1", "runs", id, "close"]) => {
362                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
363                let run = self
364                    .tasks
365                    .lock()
366                    .expect("tasks")
367                    .close_run(id, str_field(body, "status")?, "operator", None, opt_str(body, "branch"), opt_str(body, "commit"), opt_str(body, "result_message_id"))
368                    .map_err(int)?
369                    .map_err(refused)?;
370                Ok(json!({ "run": run }))
371            }
372            ("GET", ["v1", "routing"]) => {
373                let (cfg, state) = self.routing()?;
374                let seats: Vec<Value> = cfg
375                    .seats
376                    .iter()
377                    .map(|seat| {
378                        let st = state.get(&(seat.node.clone(), seat.provider.clone())).cloned().unwrap_or_default();
379                        json!({ "seat": seat, "state": st })
380                    })
381                    .collect();
382                let inventory = self.tasks.lock().expect("tasks").inventory().map_err(int)?;
383                Ok(json!({ "roles": cfg.roles, "seats": seats, "inventory": inventory, "advisor": cfg.advisor }))
384            }
385            // The user's role choice: preset entries must be models some node reported.
386            ("POST", ["v1", "routing", "roles"]) => {
387                let role = str_field(body, "role")?;
388                if role.is_empty() || role.len() > 64 || !role.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)) {
389                    return Err(HubError::new(400, "bad role name"));
390                }
391                let preset: Vec<String> = body
392                    .get("preset")
393                    .and_then(Value::as_array)
394                    .ok_or_else(|| HubError::new(400, "missing field: preset"))?
395                    .iter()
396                    .map(|v| v.as_str().map(str::to_owned).ok_or_else(|| HubError::new(400, "preset entries are strings")))
397                    .collect::<Result<_, _>>()?;
398                if preset.len() > 16 {
399                    return Err(HubError::new(400, "preset too long"));
400                }
401                let tasks = self.tasks.lock().expect("tasks");
402                let inventory = tasks.inventory().map_err(int)?;
403                for entry in &preset {
404                    let m = crate::routing::parse_model_ref(entry).ok_or_else(|| HubError::new(400, "bad preset entry"))?;
405                    if !crate::routing::offered(&inventory, &m) {
406                        return Err(HubError::new(409, format!("not offered by any node: {entry}")));
407                    }
408                }
409                let r = crate::routing::Role { preset, auto: body.get("auto").and_then(Value::as_bool).unwrap_or(false) };
410                tasks.set_role(role, &r).map_err(int)?;
411                Ok(json!({ "role": role, "preset": r.preset, "auto": r.auto }))
412            }
413            ("POST", ["v1", "routing", "roles", role, "delete"]) => {
414                let gone = self.tasks.lock().expect("tasks").delete_role(role).map_err(int)?;
415                Ok(json!({ "deleted": gone }))
416            }
417            ("POST", ["v1", "routing", "recommend"]) => self.recommend(str_field(body, "role")?),
418            ("GET", ["v1", "signals"]) => {
419                let after = query_param(query, "after").and_then(|v| v.parse().ok());
420                let limit = query_param(query, "limit").and_then(|v| v.parse().ok()).unwrap_or(100);
421                let signals = self.tasks.lock().expect("tasks").signals(query_param(query, "node"), after, limit).map_err(int)?;
422                Ok(json!({ "signals": signals }))
423            }
424            ("GET", ["v1", "sessions"]) => {
425                let sessions = self.tasks.lock().expect("tasks").sessions(query_param(query, "node")).map_err(int)?;
426                Ok(json!({ "sessions": sessions }))
427            }
428            // Record a real provider session (a provider conversation id); drafts are not recorded.
429            ("POST", ["v1", "sessions"]) => {
430                let session = Session {
431                    session_id: str_field(body, "session_id")?.to_owned(),
432                    node_id: str_field(body, "node_id")?.to_owned(),
433                    provider: str_field(body, "provider")?.to_owned(),
434                    workspace_id: opt_str(body, "workspace_id").map(str::to_owned),
435                    role: opt_str(body, "role").map(str::to_owned),
436                    state: opt_str(body, "state").unwrap_or("live").to_owned(),
437                    created_at_ms: 0,
438                    updated_at_ms: 0,
439                };
440                let tasks = self.tasks.lock().expect("tasks");
441                let session = tasks.upsert_session(&session).map_err(int)?;
442                if let Some(nick) = opt_str(body, "nick").filter(|n| !n.is_empty() && n.len() <= 128) {
443                    tasks.set_session_nick(&session.session_id, nick).map_err(int)?;
444                }
445                Ok(json!({ "session": session }))
446            }
447            ("POST", ["v1", "tasks", id, "assign"]) => {
448                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
449                let node = str_field(body, "assignee")?;
450                if !self.admitted(node, "tasks") {
451                    return Err(HubError::new(403, "assignee does not hold the tasks capability"));
452                }
453                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")) }
454            }
455            // Operator archive: finished tasks and idle session records leave the lists.
456            ("POST", ["v1", "tasks", id, "archive"]) => {
457                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
458                let task = self.tasks.lock().expect("tasks").archive_task(id).map_err(int)?.map_err(refused)?;
459                Ok(json!({ "task": task }))
460            }
461            ("POST", ["v1", "sessions", id, "archive"]) => {
462                self.tasks.lock().expect("tasks").archive_session(id).map_err(int)?.map_err(refused)?;
463                Ok(json!({ "archived": id }))
464            }
465            ("POST", ["v1", "tasks", id, "cancel"]) => {
466                let id: i64 = id.parse().map_err(|_| HubError::new(400, "bad id"))?;
467                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")) }
468            }
469            _ => Err(HubError::new(404, "not found")),
470        }
471    }
472}
473
474#[cfg(test)]
475mod task_api_tests {
476    use super::*;
477
478    #[test]
479    fn signer_ops_need_unlock_and_letters_come_from_the_session_nick() {
480        let dir = tempfile::tempdir().unwrap();
481        let hub = Hub::open(dir.path()).unwrap();
482        let prf = hex::encode([5u8; 32]);
483        hub.operator_call("POST", "/v1/signer/enroll", &json!({"account": "owner", "prf_hex": prf})).unwrap();
484        hub.operator_call("POST", "/v1/signer/register_node", &json!({"account": "owner", "node": "alpha", "prf_hex": prf})).unwrap();
485        // Wrong passkey secret cannot register nodes.
486        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);
487        let k = hub.signer.lock().unwrap();
488        assert_eq!(k.account_for_node("alpha").as_deref(), Some("owner"));
489        drop(k);
490        hub.operator_call("POST", "/v1/signer/lock", &json!({"account": "owner"})).unwrap();
491        assert!(hub.signer.lock().unwrap().account_for_node("alpha").is_none());
492        hub.operator_call("POST", "/v1/signer/unlock", &json!({"account": "owner", "prf_hex": prf})).unwrap();
493        let p = hub.signer.lock().unwrap().provision("owner", "alpha", "codex", "s1", hatchery_keystore::default_rights(), None).unwrap();
494        assert_eq!(p.nick, hub.signer.lock().unwrap().sign(&p.token, hatchery_keystore::RIGHT_SEND, b"x").unwrap().nick);
495    }
496
497    #[test]
498    fn roles_only_from_reported_models() {
499        let dir = tempfile::tempdir().unwrap();
500        let hub = Hub::open(dir.path()).unwrap();
501        let inv: Vec<crate::routing::ProviderInventory> = serde_json::from_value(json!([
502            {"provider": "codex", "source": "cli", "models": [{"id": "m1", "efforts": ["high"]}]},
503            {"provider": "grok", "source": "cli", "models": [{"id": "g1", "default": true}]}
504        ]))
505        .unwrap();
506        hub.tasks.lock().unwrap().set_inventory("alpha", &inv).unwrap();
507        // Invented model or effort: refused.
508        assert_eq!(hub.operator_call("POST", "/v1/routing/roles", &json!({"role": "coder", "preset": ["codex/m9"]})).unwrap_err().status, 409);
509        assert_eq!(hub.operator_call("POST", "/v1/routing/roles", &json!({"role": "coder", "preset": ["codex/m1:low"]})).unwrap_err().status, 409);
510        hub.operator_call("POST", "/v1/routing/roles", &json!({"role": "coder", "preset": ["codex/m1:high", "grok/g1"], "auto": true})).unwrap();
511        let r = hub.operator_call("POST", "/v1/routing/recommend", &json!({"role": "coder"})).unwrap();
512        assert_eq!(r["recommendation"]["pick"]["seat"]["id"], "alpha/codex");
513        assert_eq!(r["recommendation"]["auto"], true);
514        let g = hub.operator_call("GET", "/v1/routing", &json!({})).unwrap();
515        assert_eq!(g["inventory"].as_array().unwrap().len(), 2);
516        // A node that stops reporting codex: the role falls to grok.
517        hub.tasks.lock().unwrap().set_inventory("alpha", &inv[1..]).unwrap();
518        let r = hub.operator_call("POST", "/v1/routing/recommend", &json!({"role": "coder"})).unwrap();
519        assert_eq!(r["recommendation"]["pick"]["seat"]["id"], "alpha/grok");
520        hub.operator_call("POST", "/v1/routing/roles/coder/delete", &json!({})).unwrap();
521        assert!(hub.operator_call("POST", "/v1/routing/recommend", &json!({"role": "coder"})).is_err());
522    }
523
524    #[test]
525    fn operator_task_create_move_cancel_and_runs() {
526        let dir = tempfile::tempdir().unwrap();
527        let hub = Hub::open(dir.path()).unwrap();
528        let op = |m: &str, p: &str, b: Value| hub.operator_call(m, p, &b);
529        let t = op("POST", "/v1/tasks", json!({"title": "port", "role": "dev", "status": "backlog", "op_id": "c1"})).unwrap();
530        let id = t["task"]["id"].as_i64().unwrap();
531        // Idempotent create.
532        assert_eq!(op("POST", "/v1/tasks", json!({"title": "port", "op_id": "c1"})).unwrap(), t);
533        assert_eq!(op("GET", "/v1/tasks?role=advisor", json!({})).unwrap()["tasks"].as_array().unwrap().len(), 0);
534        assert_eq!(op("GET", "/v1/tasks?role=dev", json!({})).unwrap()["tasks"].as_array().unwrap().len(), 1);
535        let moved = op("POST", &format!("/v1/tasks/{id}/move"), json!({"status": "open", "expected_revision": 1})).unwrap();
536        assert_eq!(moved["task"]["revision"], 2);
537        assert_eq!(op("POST", &format!("/v1/tasks/{id}/move"), json!({"status": "open", "expected_revision": 1})).unwrap_err().status, 409);
538        // Runs need a recorded real session.
539        assert_eq!(op("POST", &format!("/v1/tasks/{id}/runs"), json!({"session_id": "s1"})).unwrap_err().status, 400);
540        op("POST", "/v1/sessions", json!({"session_id": "s1", "node_id": "alpha", "provider": "codex"})).unwrap();
541        let run = op("POST", &format!("/v1/tasks/{id}/runs"), json!({"session_id": "s1"})).unwrap();
542        let rid = run["run"]["id"].as_i64().unwrap();
543        // Node path is gated by the ledger.
544        assert_eq!(hub.node_call("alpha", "run.close", &json!({"run_id": rid, "status": "done", "session_id": "s1"})).unwrap_err().status, 403);
545        let closed = op("POST", &format!("/v1/runs/{rid}/close"), json!({"status": "cancelled"})).unwrap();
546        assert_eq!(closed["run"]["closed_by"], "operator");
547        let got = op("GET", &format!("/v1/tasks/{id}"), json!({})).unwrap();
548        assert_eq!(got["task"]["status"], "cancelled");
549        assert_eq!(got["runs"].as_array().unwrap().len(), 1);
550        assert_eq!(op("POST", &format!("/v1/tasks/{id}/move"), json!({"status": "open"})).unwrap_err().status, 409);
551        assert_eq!(op("GET", "/v1/sessions?node=alpha", json!({})).unwrap()["sessions"].as_array().unwrap().len(), 1);
552    }
553}
554
555fn signer_dir(data_dir: &Path) -> Result<PathBuf, crate::error::ServerError> {
556    let d = data_dir.join("m4a-signer");
557    std::fs::create_dir_all(&d).map_err(|e| crate::error::ServerError::sub("signer", format!("dir: {}", e.kind())))?;
558    #[cfg(unix)]
559    {
560        use std::os::unix::fs::PermissionsExt;
561        let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700));
562    }
563    Ok(d)
564}
565
566fn signer_err(e: hatchery_keystore::SignerError) -> HubError {
567    use hatchery_keystore::SignerError::*;
568    tracing::warn!(error = ?e, "m4a signer refused a node call");
569    match e {
570        Locked => HubError::new(423, "signer locked"),
571        WrongHumanKey | HumanApprovalRequired => HubError::new(401, "human key required"),
572        Denied(_) | Retired | BadToken => HubError::new(403, "refused"),
573        UnknownSession => HubError::new(404, "unknown session"),
574        Store(m) => HubError::new(500, format!("signer store: {m}")),
575    }
576}