1use 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 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 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 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, }
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 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 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 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 "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 "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 "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 "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 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 ("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 ("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 ("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 ("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 ("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 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 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 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 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 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 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}