Skip to main content

mlua_swarm_server/operator_ws/
login.rs

1//! REST-like Operator session resource.
2//!
3//! Provides the `POST/GET/DELETE /v1/operators` + `WS /v1/operators/:sid/ws`
4//! route family — the sole WS Operator session route. `session.rs` /
5//! `protocol.rs` are unchanged by this module.
6//!
7//! ## Login flow
8//!
9//! ```text
10//! POST /v1/operators { roles?: ["main-ai"], capability_manifest?: {...} }
11//!   → 409 if any role already owns a live entry (roles alias exclusivity,
12//!     v1.md §Auth session flow)
13//!   → { sid: "S-<hex>", token: "<10-hex>", roles: [...] }
14//!   The manifest is pinned to this session and later resolved through the
15//!   Core `AgentBindingProvider` interface before any Runner-backed spawn.
16//!
17//! WS /v1/operators/:sid/ws
18//!   Authorization: Bearer <token>   (mandatory — no empty-string default)
19//!   → 401 missing/empty Bearer, 404 unknown sid, 401 token mismatch
20//!   → registers a `WSOperatorSession` into the engine's 3 registries
21//!     (senior_bridge / spawn_hook / operator) + role aliases, same pattern
22//!     as `handler::handle_socket`. Reconnect (same sid, matching token)
23//!     reuses the existing `WSOperatorSession` via `replace_tx`.
24//!
25//! DELETE /v1/operators/:sid   (Bearer required)
26//!   → unregisters the 3 registries + role aliases + `operator_sessions`
27//!     entry + releases `roles_to_sid` ownership.
28//!
29//! GET /v1/operators/:sid   (Bearer required)
30//!   → { sid, roles, connected }
31//! ```
32//!
33//! `OperatorSessionEntry` is the login-flow record (`AppState.operator_sessions`),
34//! distinct from `mlua_swarm::OperatorSession` (the engine-side
35//! `attach`/session-token record) and from `WSOperatorSession` (the 3-trait WS
36//! session, `session.rs`) — this module owns the mapping `sid → (token, roles,
37//! Option<WSOperatorSession>)` that the login flow is built on.
38
39use axum::{
40    extract::{
41        ws::{Message, WebSocket, WebSocketUpgrade},
42        Path, State,
43    },
44    http::{HeaderMap, StatusCode},
45    response::{IntoResponse, Response},
46    Json,
47};
48use futures_util::{sink::SinkExt, stream::StreamExt};
49use mlua_swarm::{AgentProviderManifest, Operator, SeniorBridge, SessionId, SpawnHook};
50use serde::{Deserialize, Serialize};
51use serde_json::json;
52use std::sync::Arc;
53use tokio::sync::{mpsc, Mutex};
54
55use super::protocol::{ClientMsg, PendingReply, ServerMsg};
56use super::session::WSOperatorSession;
57use crate::AppState;
58
59/// Login-flow record for a minted Operator session. Held in
60/// `AppState.operator_sessions`, keyed by `sid`. `ws_session` starts `None`
61/// (login only mints sid+token) and is set on first successful WS connect;
62/// on reconnect the same `WSOperatorSession` is reused (`replace_tx`) rather
63/// than re-registered.
64pub struct OperatorSessionEntry {
65    /// Server-minted session id (typed [`SessionId`] since issue #14).
66    pub sid: SessionId,
67    /// Bearer auth token (10-hex-char) required on the WS upgrade and admin routes.
68    pub token: String,
69    /// Role aliases claimed by this session (roles-exclusivity set).
70    pub roles: Vec<String>,
71    /// Provider-owned effective capability manifest submitted at join.
72    pub capability_manifest: Option<AgentProviderManifest>,
73    /// GH #81 Layer 2: unix epoch seconds when `POST /v1/operators` minted
74    /// this entry. Surfaced by `GET /v1/operators` so a recovery driver
75    /// can pick the oldest stale session without probing each sid
76    /// individually.
77    pub joined_at_secs: u64,
78    /// The reusable 3-trait session object once a WS has connected at least
79    /// once; `None` before first connect. Its sender tracks current connectivity.
80    pub ws_session: Mutex<Option<Arc<WSOperatorSession>>>,
81}
82
83// ─── POST /v1/operators (mint) ──────────────────────────────────────────────
84
85/// Body for `POST /v1/operators`.
86#[derive(Debug, Deserialize, Default)]
87pub struct OperatorsCreateReq {
88    /// Role aliases to claim exclusively (empty = no exclusivity claimed).
89    #[serde(default)]
90    pub roles: Vec<String>,
91    /// Effective execution capabilities supplied by the Operator/MainAI.
92    #[serde(default)]
93    pub capability_manifest: Option<AgentProviderManifest>,
94}
95
96/// Response for `POST /v1/operators`.
97#[derive(Debug, Serialize)]
98pub struct OperatorsCreateResp {
99    /// Newly minted session id (typed [`SessionId`]; serializes as the
100    /// plain `S-<hex>` string — the wire shape is unchanged).
101    pub sid: SessionId,
102    /// Bearer auth token required on the WS upgrade and admin routes.
103    pub token: String,
104    /// Echoes the granted role aliases.
105    pub roles: Vec<String>,
106}
107
108/// `POST /v1/operators`. Mints `sid` (`S-<hex>` — the shared `SessionId`
109/// shape; issue #11) + a 10-hex-char token
110/// (`mlua_swarm::types::secure_hex(5)` — OS-RNG hex, unguessable across
111/// calls and restarts, which is the point: this token is the sole bearer
112/// secret on the short-handle path). When `roles` is non-empty, checks
113/// `AppState.roles_to_sid` for conflicts under a single lock (check + insert
114/// atomic w.r.t. concurrent mints) and returns `409 CONFLICT` with the
115/// conflicting role names on collision. Empty `roles` never conflicts (= no
116/// exclusivity is claimed).
117pub async fn operators_create(
118    State(state): State<AppState>,
119    Json(req): Json<OperatorsCreateReq>,
120) -> Response {
121    let roles = req.roles;
122    let capability_manifest = req.capability_manifest;
123    // The sid is the operator-session identity, so it mints in the same
124    // `SessionId` shape (`S-<hex>`) as the engine-side session id — one
125    // session-id form across the system (issue #11 observation 2; the old
126    // `op-<uuid>` shape collided with the operator-backend registry prefix).
127    // It is an identifier, not a secret: `token` (secure_hex) is the sole
128    // bearer credential on this path.
129    let sid = SessionId::new();
130    let token = mlua_swarm::types::secure_hex(5);
131
132    {
133        let mut map = state.roles_to_sid.lock().await;
134        let conflicts: Vec<String> = roles
135            .iter()
136            .filter(|r| map.contains_key(r.as_str()))
137            .cloned()
138            .collect();
139        if !conflicts.is_empty() {
140            // GH #81 Layer 2 (a): identify the holding session per
141            // conflicted role so a recovery driver knows which sid to
142            // release without probing. The pre-#81 `conflicts: [role]`
143            // array stays byte-identical for callers that already
144            // ignore unknown keys; the new `conflicts_detail: [{role,
145            // sid}]` array is an additive companion.
146            let conflicts_detail: Vec<serde_json::Value> = conflicts
147                .iter()
148                .map(|r| {
149                    let holder = map.get(r.as_str()).map(|sid| sid.to_string());
150                    json!({ "role": r, "sid": holder })
151                })
152                .collect();
153            return (
154                StatusCode::CONFLICT,
155                Json(json!({
156                    "error": "roles conflict",
157                    "conflicts": conflicts,
158                    "conflicts_detail": conflicts_detail,
159                })),
160            )
161                .into_response();
162        }
163        for r in &roles {
164            map.insert(r.clone(), sid.clone());
165        }
166    }
167
168    let joined_at_secs = std::time::SystemTime::now()
169        .duration_since(std::time::UNIX_EPOCH)
170        .map(|d| d.as_secs())
171        .unwrap_or(0);
172    let entry = Arc::new(OperatorSessionEntry {
173        sid: sid.clone(),
174        token: token.clone(),
175        roles: roles.clone(),
176        capability_manifest,
177        joined_at_secs,
178        ws_session: Mutex::new(None),
179    });
180    state
181        .operator_sessions
182        .lock()
183        .await
184        .insert(sid.clone(), entry);
185
186    (
187        StatusCode::OK,
188        Json(OperatorsCreateResp { sid, token, roles }),
189    )
190        .into_response()
191}
192
193// ─── WS /v1/operators/:sid/ws (Bearer required) ─────────────────────────────
194
195/// Extracts `Authorization: Bearer <token>`; missing header, wrong scheme, or
196/// an empty token all resolve to a `401` response. `Authorization` is
197/// mandatory on the WS path — there is no empty-string default.
198fn extract_bearer_token_required(headers: &HeaderMap) -> Result<String, Box<Response>> {
199    let token = headers
200        .get(axum::http::header::AUTHORIZATION)
201        .and_then(|v| v.to_str().ok())
202        .and_then(|s| s.strip_prefix("Bearer "))
203        .map(|s| s.trim().to_string())
204        .filter(|s| !s.is_empty());
205    token.ok_or_else(|| {
206        Box::new((StatusCode::UNAUTHORIZED, "missing or empty Bearer token").into_response())
207    })
208}
209
210/// `GET /v1/operators/:sid/ws` (WS upgrade). Bearer mandatory. `404` on
211/// unknown sid, `401` on token mismatch. On successful upgrade, registers (or
212/// reuses, on reconnect) a `WSOperatorSession` under `sid` — same 3-registry
213/// pattern as `handler::handle_socket`, plus role-alias registration for
214/// every role minted alongside this sid.
215pub async fn operators_ws_connect(
216    State(state): State<AppState>,
217    Path(sid): Path<String>,
218    headers: HeaderMap,
219    ws: WebSocketUpgrade,
220) -> Response {
221    let bearer = match extract_bearer_token_required(&headers) {
222        Ok(t) => t,
223        Err(resp) => return *resp,
224    };
225    // A string that doesn't even parse as a SessionId can't be a known sid.
226    let Ok(sid) = SessionId::parse(sid) else {
227        return (StatusCode::NOT_FOUND, "unknown sid").into_response();
228    };
229
230    let entry = {
231        let map = state.operator_sessions.lock().await;
232        map.get(&sid).cloned()
233    };
234    let entry = match entry {
235        Some(e) => e,
236        None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
237    };
238    if !mlua_swarm::types::ct_eq(entry.token.as_bytes(), bearer.as_bytes()) {
239        return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
240    }
241
242    ws.on_upgrade(move |socket| handle_operator_socket(socket, state, entry))
243}
244
245/// Bidirectional pump for a single WS connection, bound to an
246/// `OperatorSessionEntry`. Owns the full wire protocol pump (write task /
247/// read task / `ClientMsg` dispatch / disconnect) for this session.
248async fn handle_operator_socket(
249    socket: WebSocket,
250    state: AppState,
251    entry: Arc<OperatorSessionEntry>,
252) {
253    let (tx, mut rx) = mpsc::unbounded_channel::<ServerMsg>();
254
255    let existing_ws = entry.ws_session.lock().await.clone();
256    let session = match existing_ws {
257        Some(ws_session) => {
258            // Reconnect: reuse the existing WSOperatorSession on this entry; only swap out `tx`.
259            ws_session.replace_tx(tx.clone()).await;
260            ws_session
261        }
262        None => {
263            let ws_session = Arc::new(WSOperatorSession::new_with_base_url(
264                entry.sid.clone(),
265                tx.clone(),
266                state.base_url.clone(),
267            ));
268            state
269                .engine
270                .register_senior_bridge(
271                    entry.sid.clone(),
272                    ws_session.clone() as Arc<dyn SeniorBridge>,
273                )
274                .await;
275            state
276                .engine
277                .register_spawn_hook(entry.sid.clone(), ws_session.clone() as Arc<dyn SpawnHook>)
278                .await;
279            state
280                .engine
281                .register_operator(entry.sid.clone(), ws_session.clone() as Arc<dyn Operator>)
282                .await;
283            if let Some(factory) = &state.ws_operator_factory {
284                factory
285                    .register_operator(entry.sid.clone(), ws_session.clone() as Arc<dyn Operator>);
286            }
287            // Role exclusivity was already resolved at login (POST) time. Here
288            // we just bind the same session into the three registries + factory
289            // under its role aliases (same shape as handler::handle_socket's
290            // ?roles= path).
291            for role in &entry.roles {
292                if let Some(factory) = &state.ws_operator_factory {
293                    factory
294                        .register_operator(role.clone(), ws_session.clone() as Arc<dyn Operator>);
295                }
296                state
297                    .engine
298                    .register_operator(role.clone(), ws_session.clone() as Arc<dyn Operator>)
299                    .await;
300            }
301            *entry.ws_session.lock().await = Some(ws_session.clone());
302            ws_session
303        }
304    };
305
306    let (mut ws_sink, mut ws_stream) = socket.split();
307
308    // write task: mpsc → WebSocket
309    let write_task = tokio::spawn(async move {
310        while let Some(msg) = rx.recv().await {
311            let txt = match serde_json::to_string(&msg) {
312                Ok(s) => s,
313                Err(_) => continue,
314            };
315            if ws_sink.send(Message::Text(txt)).await.is_err() {
316                break;
317            }
318        }
319        let _ = ws_sink.close().await;
320    });
321
322    // read task: WS message → ClientMsg parse → session.resolve_pending
323    let session_for_read = session.clone();
324    let read_result: Result<(), String> = async {
325        while let Some(item) = ws_stream.next().await {
326            match item {
327                Ok(Message::Text(t)) => {
328                    let parsed: ClientMsg = match serde_json::from_str(&t) {
329                        Ok(p) => p,
330                        Err(_) => continue,
331                    };
332                    match parsed {
333                        ClientMsg::Answer { req_id, value } => {
334                            session_for_read
335                                .resolve_pending(&req_id, PendingReply::Answer(value))
336                                .await;
337                        }
338                        ClientMsg::HookAck { req_id, ok, reason } => {
339                            session_for_read
340                                .resolve_pending(&req_id, PendingReply::HookAck { ok, reason })
341                                .await;
342                        }
343                        ClientMsg::SpawnAck {
344                            req_id,
345                            value,
346                            ok,
347                            error,
348                            stats,
349                        } => {
350                            session_for_read
351                                .resolve_pending(
352                                    &req_id,
353                                    PendingReply::SpawnAck {
354                                        value,
355                                        ok,
356                                        error,
357                                        stats,
358                                    },
359                                )
360                                .await;
361                        }
362                        ClientMsg::SpawnHalt {
363                            req_id,
364                            value,
365                            reason,
366                        } => {
367                            session_for_read
368                                .resolve_pending(&req_id, PendingReply::SpawnHalt { value, reason })
369                                .await;
370                        }
371                    }
372                }
373                Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
374                Ok(Message::Close(_)) | Err(_) => break,
375                _ => {}
376            }
377        }
378        Ok(())
379    }
380    .await;
381
382    // Clear only this socket's sender. A reconnect may already have installed
383    // a replacement while this older socket was unwinding.
384    session.clear_tx_if(&tx).await;
385    write_task.abort();
386    let _ = read_result;
387}
388
389// ─── DELETE /v1/operators/:sid (Bearer required) ────────────────────────────
390
391/// Shared teardown for `DELETE /v1/operators/:sid` (`operators_delete`) and
392/// `DELETE /v1/operators/by-role/:role` (`operators_delete_by_role` — GH #81
393/// Layer 2 (c)): drops the 3 engine registries + role aliases +
394/// `ws_operator_factory` bindings + `operator_sessions` entry, and releases
395/// the sid's ownership in `roles_to_sid`. Idempotent w.r.t. a concurrent
396/// delete — every `remove` / `unregister` is a no-op when the entry is
397/// already gone.
398async fn teardown_operator_session(
399    state: &AppState,
400    sid: &SessionId,
401    entry: &Arc<OperatorSessionEntry>,
402) {
403    state.engine.unregister_senior_bridge(sid.as_str()).await;
404    state.engine.unregister_spawn_hook(sid.as_str()).await;
405    state.engine.unregister_operator(sid.as_str()).await;
406    if let Some(factory) = &state.ws_operator_factory {
407        factory.unregister_operator(sid.as_str());
408    }
409    for role in &entry.roles {
410        state.engine.unregister_operator(role).await;
411        if let Some(factory) = &state.ws_operator_factory {
412            factory.unregister_operator(role);
413        }
414    }
415
416    if let Some(session) = entry.ws_session.lock().await.take() {
417        // B-2: fail every parked spawn/ask/hook_before on this session
418        // right away. Teardown removes the session from `operator_sessions`
419        // below (no reconnect can find it again), so unlike a plain WS
420        // disconnect there is no reconnect/resend contract to preserve —
421        // an in-flight spawn parked in `send_and_await` would otherwise
422        // orphan until the run's sync timeout (up to 300s) fires.
423        session.fail_pending("operator session torn down").await;
424        session.clear_tx().await;
425    }
426
427    state.operator_sessions.lock().await.remove(sid);
428
429    {
430        let mut map = state.roles_to_sid.lock().await;
431        for role in &entry.roles {
432            if map.get(role) == Some(sid) {
433                map.remove(role);
434            }
435        }
436    }
437}
438
439/// `DELETE /v1/operators/:sid`. Bearer mandatory. `404` on unknown sid, `401`
440/// on token mismatch. Drops the 3 engine registries + role aliases +
441/// `ws_operator_factory` bindings + `operator_sessions` entry, and releases
442/// this sid's ownership in `roles_to_sid` (re-opening the role names for a
443/// future mint).
444pub async fn operators_delete(
445    State(state): State<AppState>,
446    Path(sid): Path<String>,
447    headers: HeaderMap,
448) -> Response {
449    let bearer = match extract_bearer_token_required(&headers) {
450        Ok(t) => t,
451        Err(resp) => return *resp,
452    };
453    let Ok(sid) = SessionId::parse(sid) else {
454        return (StatusCode::NOT_FOUND, "unknown sid").into_response();
455    };
456
457    let entry = {
458        let map = state.operator_sessions.lock().await;
459        map.get(&sid).cloned()
460    };
461    let entry = match entry {
462        Some(e) => e,
463        None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
464    };
465    if !mlua_swarm::types::ct_eq(entry.token.as_bytes(), bearer.as_bytes()) {
466        return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
467    }
468
469    teardown_operator_session(&state, &sid, &entry).await;
470
471    StatusCode::NO_CONTENT.into_response()
472}
473
474// ─── GH #81 Layer 2: GET /v1/operators + DELETE /v1/operators/by-role/:role
475
476/// GH #81 Layer 2 (b): one entry in the `GET /v1/operators` list response.
477/// Bare identity fields (no token, no capability manifest — those live
478/// behind Bearer on `GET /v1/operators/:sid`); this list surface is
479/// read-only observability, on the same trust tier as `GET /v1/status`.
480#[derive(Debug, Serialize)]
481pub struct OperatorsListEntry {
482    /// Session id (`S-<hex>`) — safe to expose; token is the sole bearer secret.
483    pub sid: SessionId,
484    /// Role aliases held by this session.
485    pub roles: Vec<String>,
486    /// Unix epoch seconds when the session minted (from
487    /// [`OperatorSessionEntry::joined_at_secs`]).
488    pub joined_at_secs: u64,
489    /// Whether a WS is currently attached to this session (matches the
490    /// `connected` field on `GET /v1/operators/:sid`).
491    pub connected: bool,
492}
493
494/// Response body for `GET /v1/operators` (GH #81 Layer 2 (b)).
495#[derive(Debug, Serialize)]
496pub struct OperatorsListResp {
497    /// One entry per live session, ordered by `sid` (deterministic —
498    /// callers can `.iter().find(...)` without probing the map order).
499    pub operators: Vec<OperatorsListEntry>,
500}
501
502/// `GET /v1/operators`. Read-only enumeration of every live session's
503/// `{sid, roles, joined_at_secs, connected}` (GH #81 Layer 2 (b)). Same
504/// trust tier as `GET /v1/status` — no Bearer required; sids are
505/// identifiers, not secrets. Answers "which sid holds `main-ai`?"
506/// without probing every sid individually via `GET /v1/operators/:sid`,
507/// which was the pre-#81 recovery gap.
508pub async fn operators_list(State(state): State<AppState>) -> Response {
509    let entries: Vec<(SessionId, Arc<OperatorSessionEntry>)> = {
510        let map = state.operator_sessions.lock().await;
511        map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
512    };
513    let mut operators = Vec::with_capacity(entries.len());
514    for (sid, entry) in entries {
515        let session = entry.ws_session.lock().await.clone();
516        let connected = match session {
517            Some(session) => session.is_connected().await,
518            None => false,
519        };
520        operators.push(OperatorsListEntry {
521            sid,
522            roles: entry.roles.clone(),
523            joined_at_secs: entry.joined_at_secs,
524            connected,
525        });
526    }
527    operators.sort_by(|a, b| a.sid.as_str().cmp(b.sid.as_str()));
528    (StatusCode::OK, Json(OperatorsListResp { operators })).into_response()
529}
530
531/// `DELETE /v1/operators/by-role/:role`. Releases the session currently
532/// holding `role` without requiring the caller to know the sid or its
533/// Bearer token (GH #81 Layer 2 (c)). Recovery route for a stale session
534/// whose driver crashed after minting the sid — pre-#81 the only reliable
535/// recovery was a full server restart, which also dropped every OTHER live
536/// session. Same trust tier as the server-shutdown surface
537/// (`mlua_swarm_server_shutdown`): admin observability, no Bearer.
538///
539/// `404` when no session holds the role, `204` on successful teardown. The
540/// response body on `204` is empty (`teardown_operator_session` performs
541/// the same cleanup as `operators_delete`).
542///
543/// # In-flight protection (`409` unless `?force=true`)
544///
545/// A role name is process-global, so "the session holding `main-ai`" may
546/// well be another driver's live session rather than the stale one the
547/// caller meant to clear — and teardown fails every parked spawn on it
548/// ([`teardown_operator_session`]'s `fail_pending`). When the holding sid is
549/// pinned by at least one `Running` Run (`RunRecord.operator_sid`), this
550/// route refuses with `409` and lists those run ids, so the recovery habit
551/// cannot take a working run down as collateral. `?force=true` performs the
552/// teardown anyway — the escape hatch for a genuinely wedged session whose
553/// runs will never finish.
554///
555/// The check reads through [`crate::AppState::run_store`]; a store read
556/// failure is itself a `409` (refuse rather than tear down blind).
557pub async fn operators_delete_by_role(
558    State(state): State<AppState>,
559    Path(role): Path<String>,
560    axum::extract::Query(query): axum::extract::Query<OperatorsDeleteByRoleQuery>,
561) -> Response {
562    let sid = {
563        let map = state.roles_to_sid.lock().await;
564        match map.get(role.as_str()) {
565            Some(sid) => sid.clone(),
566            None => {
567                return (
568                    StatusCode::NOT_FOUND,
569                    Json(json!({"error": "no session holds this role", "role": role})),
570                )
571                    .into_response();
572            }
573        }
574    };
575    let entry = {
576        let map = state.operator_sessions.lock().await;
577        map.get(&sid).cloned()
578    };
579    let entry = match entry {
580        Some(e) => e,
581        None => {
582            // The role was mapped to a sid that has no matching
583            // `operator_sessions` entry — a torn state that a mint-time
584            // atomic guard prevents in normal operation. Release the
585            // stale role mapping so a future mint can reclaim the
586            // name, then report NOT_FOUND.
587            let mut map = state.roles_to_sid.lock().await;
588            if map.get(role.as_str()) == Some(&sid) {
589                map.remove(role.as_str());
590            }
591            return (
592                StatusCode::NOT_FOUND,
593                Json(json!({
594                    "error": "torn role mapping cleared; role now open",
595                    "role": role,
596                })),
597            )
598                .into_response();
599        }
600    };
601    if !query.force {
602        match active_runs_for_sid(&state, &sid).await {
603            Ok(active_runs) if !active_runs.is_empty() => {
604                return (
605                    StatusCode::CONFLICT,
606                    Json(json!({
607                        "error": "session is driving in-flight runs; \
608                                  tearing it down would fail their parked spawns",
609                        "role": role,
610                        "sid": sid,
611                        "active_runs": active_runs,
612                        "hint": "wait for the runs to finish, or repeat with ?force=true \
613                                 to tear the session down anyway",
614                    })),
615                )
616                    .into_response();
617            }
618            Ok(_) => {}
619            Err(error) => {
620                // Unknown occupancy is not "no occupancy": refusing keeps a
621                // store outage from turning this recovery route into a
622                // silent killer of someone else's runs. `?force=true` still
623                // gets through.
624                tracing::warn!(%role, %sid, %error, "operators_delete_by_role: run occupancy check failed");
625                return (
626                    StatusCode::CONFLICT,
627                    Json(json!({
628                        "error": format!(
629                            "cannot verify whether this session is driving in-flight runs: {error}"
630                        ),
631                        "role": role,
632                        "sid": sid,
633                        "hint": "retry, or repeat with ?force=true to tear the session down \
634                                 without the check",
635                    })),
636                )
637                    .into_response();
638            }
639        }
640    }
641    teardown_operator_session(&state, &sid, &entry).await;
642    StatusCode::NO_CONTENT.into_response()
643}
644
645/// Query string of `DELETE /v1/operators/by-role/:role`.
646#[derive(Debug, Deserialize, Default)]
647pub struct OperatorsDeleteByRoleQuery {
648    /// Tear the session down even while it is driving `Running` runs.
649    /// `false` (the default, and the shape every pre-guard caller sends)
650    /// refuses with `409` in that case.
651    #[serde(default)]
652    pub force: bool,
653}
654
655/// Ids of the `Running` runs pinned to `sid` (`RunRecord.operator_sid`),
656/// ascending by `created_at` so the response order is stable. Empty means
657/// the session drives nothing right now.
658async fn active_runs_for_sid(state: &AppState, sid: &SessionId) -> Result<Vec<String>, String> {
659    let mut running = state
660        .run_store
661        .list_running()
662        .await
663        .map_err(|e| e.to_string())?;
664    running.sort_by_key(|record| record.created_at);
665    Ok(running
666        .into_iter()
667        .filter(|record| record.operator_sid.as_deref() == Some(sid.as_str()))
668        .map(|record| record.id.to_string())
669        .collect())
670}
671
672// ─── GET /v1/operators/:sid (Bearer required) ───────────────────────────────
673
674/// Response for `GET /v1/operators/:sid`.
675#[derive(Debug, Serialize)]
676pub struct OperatorsInfoResp {
677    /// Echoes the requested session id.
678    pub sid: SessionId,
679    /// Role aliases held by this session.
680    pub roles: Vec<String>,
681    /// Capability manifest pinned when this session joined.
682    #[serde(skip_serializing_if = "Option::is_none")]
683    pub capability_manifest: Option<AgentProviderManifest>,
684    /// Whether a WS is currently attached (not merely that the session ever connected).
685    pub connected: bool,
686}
687
688/// `GET /v1/operators/:sid`. Bearer mandatory. `404` on unknown sid, `401` on
689/// token mismatch. `connected` reflects whether the reusable session currently
690/// owns a live sender, not merely whether it connected at least once.
691pub async fn operators_info(
692    State(state): State<AppState>,
693    Path(sid): Path<String>,
694    headers: HeaderMap,
695) -> Response {
696    let bearer = match extract_bearer_token_required(&headers) {
697        Ok(t) => t,
698        Err(resp) => return *resp,
699    };
700    let Ok(sid) = SessionId::parse(sid) else {
701        return (StatusCode::NOT_FOUND, "unknown sid").into_response();
702    };
703
704    let entry = {
705        let map = state.operator_sessions.lock().await;
706        map.get(&sid).cloned()
707    };
708    let entry = match entry {
709        Some(e) => e,
710        None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
711    };
712    if !mlua_swarm::types::ct_eq(entry.token.as_bytes(), bearer.as_bytes()) {
713        return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
714    }
715
716    let session = entry.ws_session.lock().await.clone();
717    let connected = match session {
718        Some(session) => session.is_connected().await,
719        None => false,
720    };
721    (
722        StatusCode::OK,
723        Json(OperatorsInfoResp {
724            sid: entry.sid.clone(),
725            roles: entry.roles.clone(),
726            capability_manifest: entry.capability_manifest.clone(),
727            connected,
728        }),
729    )
730        .into_response()
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use axum::http::HeaderValue;
737
738    fn headers_with_bearer(token: &str) -> HeaderMap {
739        let mut h = HeaderMap::new();
740        h.insert(
741            axum::http::header::AUTHORIZATION,
742            HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
743        );
744        h
745    }
746
747    #[test]
748    fn extract_bearer_token_required_accepts_valid() {
749        let h = headers_with_bearer("abc123");
750        assert_eq!(extract_bearer_token_required(&h).unwrap(), "abc123");
751    }
752
753    #[test]
754    fn extract_bearer_token_required_rejects_missing_header() {
755        let h = HeaderMap::new();
756        assert!(extract_bearer_token_required(&h).is_err());
757    }
758
759    #[test]
760    fn extract_bearer_token_required_rejects_empty_token() {
761        let h = headers_with_bearer("");
762        assert!(extract_bearer_token_required(&h).is_err());
763    }
764
765    #[test]
766    fn extract_bearer_token_required_rejects_wrong_scheme() {
767        let mut h = HeaderMap::new();
768        h.insert(
769            axum::http::header::AUTHORIZATION,
770            HeaderValue::from_static("Basic dXNlcjpwYXNz"),
771        );
772        assert!(extract_bearer_token_required(&h).is_err());
773    }
774
775    #[test]
776    fn operators_create_request_accepts_capability_manifest() {
777        let req: OperatorsCreateReq = serde_json::from_value(serde_json::json!({
778            "roles": ["main-ai"],
779            "capability_manifest": {
780                "provider_id": "main-ai-self-report",
781                "capabilities": [{
782                    "launch_variant": "mse-coder",
783                    "resolved_model": "claude-sonnet-4",
784                    "effective_tools": ["Read", "Edit"]
785                }]
786            }
787        }))
788        .unwrap();
789        assert_eq!(req.roles, ["main-ai"]);
790        assert_eq!(
791            req.capability_manifest.unwrap().provider_id,
792            "main-ai-self-report"
793        );
794    }
795
796    #[test]
797    fn operators_create_request_keeps_manifest_optional_on_wire() {
798        let req: OperatorsCreateReq =
799            serde_json::from_value(serde_json::json!({ "roles": [] })).unwrap();
800        assert!(req.capability_manifest.is_none());
801    }
802
803    // ── by-role teardown: in-flight protection ───────────────────────────
804
805    mod by_role_in_flight {
806        use super::*;
807        use mlua_swarm::core::config::EngineCfg;
808        use mlua_swarm::core::engine::Engine;
809        use mlua_swarm::store::output::InMemoryOutputStore;
810        use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus};
811        use mlua_swarm::store::task::InMemoryTaskStore;
812        use mlua_swarm::RunId;
813        use mlua_swarm::TaskId;
814        use std::collections::HashMap;
815
816        fn test_state() -> AppState {
817            let engine =
818                Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
819            let compiler = mlua_swarm::Compiler::new(crate::default_registry());
820            let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
821            AppState {
822                engine,
823                sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
824                task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
825                ws_operator_factory: None,
826                data_store: Arc::new(InMemoryOutputStore::new()),
827                operator_sessions: Arc::new(Mutex::new(HashMap::new())),
828                roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
829                task_store: Arc::new(InMemoryTaskStore::new()),
830                run_store: Arc::new(InMemoryRunStore::new()),
831                replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
832                run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
833                base_url: None,
834                sync_timeout_secs: 300,
835            }
836        }
837
838        /// Seed one live session holding `role` (no WS attached — teardown
839        /// and the guard both work off the login record).
840        async fn seed_session(state: &AppState, role: &str) -> SessionId {
841            let sid = SessionId::new();
842            let entry = Arc::new(OperatorSessionEntry {
843                sid: sid.clone(),
844                token: "token".to_string(),
845                roles: vec![role.to_string()],
846                capability_manifest: None,
847                joined_at_secs: 0,
848                ws_session: Mutex::new(None),
849            });
850            state
851                .operator_sessions
852                .lock()
853                .await
854                .insert(sid.clone(), entry);
855            state
856                .roles_to_sid
857                .lock()
858                .await
859                .insert(role.to_string(), sid.clone());
860            sid
861        }
862
863        async fn seed_run(state: &AppState, sid: Option<&SessionId>, status: RunStatus) -> RunId {
864            let run_id = RunId::new();
865            state
866                .run_store
867                .create(RunRecord {
868                    id: run_id.clone(),
869                    task_id: TaskId::new(),
870                    status,
871                    step_entries: Vec::new(),
872                    degradations: Vec::new(),
873                    operator_sid: sid.map(|s| s.to_string()),
874                    result_ref: None,
875                    input_json: None,
876                    created_at: 0,
877                    updated_at: 0,
878                })
879                .await
880                .expect("seed run");
881            run_id
882        }
883
884        async fn body_json(response: Response) -> serde_json::Value {
885            let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
886                .await
887                .expect("read body");
888            serde_json::from_slice(&bytes).expect("json body")
889        }
890
891        async fn session_is_live(state: &AppState, sid: &SessionId) -> bool {
892            state.operator_sessions.lock().await.contains_key(sid)
893        }
894
895        /// No run pins the holder: the recovery route behaves exactly as it
896        /// did before the guard.
897        #[tokio::test]
898        async fn idle_holder_is_still_torn_down() {
899            let state = test_state();
900            let sid = seed_session(&state, "main-ai").await;
901            // A Running run belonging to a DIFFERENT session must not
902            // protect this one.
903            let other = SessionId::new();
904            seed_run(&state, Some(&other), RunStatus::Running).await;
905            // ...and a finished run of this session must not either.
906            seed_run(&state, Some(&sid), RunStatus::Done).await;
907
908            let response = operators_delete_by_role(
909                State(state.clone()),
910                Path("main-ai".to_string()),
911                axum::extract::Query(OperatorsDeleteByRoleQuery::default()),
912            )
913            .await;
914            assert_eq!(response.status(), StatusCode::NO_CONTENT);
915            assert!(!session_is_live(&state, &sid).await);
916        }
917
918        /// The holder is driving a Running run: refuse, and say which runs
919        /// would have been failed.
920        #[tokio::test]
921        async fn holder_driving_a_running_run_is_refused_with_its_run_ids() {
922            let state = test_state();
923            let sid = seed_session(&state, "main-ai").await;
924            let run_id = seed_run(&state, Some(&sid), RunStatus::Running).await;
925
926            let response = operators_delete_by_role(
927                State(state.clone()),
928                Path("main-ai".to_string()),
929                axum::extract::Query(OperatorsDeleteByRoleQuery::default()),
930            )
931            .await;
932            assert_eq!(response.status(), StatusCode::CONFLICT);
933            let body = body_json(response).await;
934            assert_eq!(
935                body["active_runs"],
936                serde_json::json!([run_id.to_string()]),
937                "the 409 must name the in-flight runs: {body}"
938            );
939            assert!(
940                session_is_live(&state, &sid).await,
941                "a refused teardown must leave the session (and its parked spawns) alone"
942            );
943            // The role stays claimed — a refused recovery changes nothing.
944            assert_eq!(
945                state.roles_to_sid.lock().await.get("main-ai"),
946                Some(&sid),
947                "a refused teardown must not release the role"
948            );
949        }
950
951        /// `?force=true` is the escape hatch for a wedged session whose runs
952        /// will never finish.
953        #[tokio::test]
954        async fn force_tears_down_despite_in_flight_runs() {
955            let state = test_state();
956            let sid = seed_session(&state, "main-ai").await;
957            seed_run(&state, Some(&sid), RunStatus::Running).await;
958
959            let response = operators_delete_by_role(
960                State(state.clone()),
961                Path("main-ai".to_string()),
962                axum::extract::Query(OperatorsDeleteByRoleQuery { force: true }),
963            )
964            .await;
965            assert_eq!(response.status(), StatusCode::NO_CONTENT);
966            assert!(!session_is_live(&state, &sid).await);
967        }
968
969        /// Unknown role keeps its pre-guard `404` (the guard runs after the
970        /// lookup, not before it).
971        #[tokio::test]
972        async fn unknown_role_still_404s() {
973            let state = test_state();
974            let response = operators_delete_by_role(
975                State(state),
976                Path("nobody-holds-this".to_string()),
977                axum::extract::Query(OperatorsDeleteByRoleQuery::default()),
978            )
979            .await;
980            assert_eq!(response.status(), StatusCode::NOT_FOUND);
981        }
982    }
983}