Skip to main content

mlua_swarm_server/
worker.rs

1//! HTTP `/v1/worker/*` endpoints (SubAgent self-fetch path).
2//!
3//! # 7-Entry pointer #6 (Output Event design)
4//!
5//! **This endpoint accesses `OutputStore` directly and does NOT go through the engine.**
6//! It is one of the seven entry points enumerated in project `CLAUDE.md` §"Output Event
7//! Design SoT". For the canonical description, see the crate root doc of
8//! `mlua-swarm-output-store` (`cargo doc -p mlua-swarm-output-store`).
9//!
10//! # Path
11//!
12//! A thin-payload path where a SubAgent (= worker process launched by a MainAI) uses
13//! the capability token it received via WS Spawn to self-fetch its prompt and
14//! submit its result — putting the token in `Authorization: Bearer <encoded CapToken>`.
15//!
16//! ## Routes
17//!
18//! - `GET /v1/worker/prompt?task_id=<tid>` — via `engine.fetch_worker_payload`,
19//!   returns `{task_id, attempt, agent, system?, prompt}`.
20//! - `POST /v1/worker/result` with body `{task_id, value, ok}` — appends one `Final`
21//!   to the output tail via `engine.submit_output(Final)` (= the canonical path
22//!   through which the dispatch layer decides Pass/Blocked) and updates
23//!   `task.last_result` via `engine.post_result`.
24//!
25//! ## Bearer authentication
26//!
27//! The Bearer value is the string produced by `CapToken::encode()` (= URL-safe
28//! base64 of serde_json). The server decodes it with `CapToken::decode` and then,
29//! inside the engine, verifies HMAC sig + role × verb gate + TTL via
30//! `verify_token_for_task` (= self-contained capability token; no server-side
31//! store lookup required).
32//!
33//! Tokens are minted during the "2) mint outside the lock" phase of
34//! `engine.dispatch_attempt` (`Role::Worker`, 600s TTL, `scopes=["*"]`).
35//! The verb gate covers `FetchPrompt` / `EmitOutput` / `PostResult` — the worker
36//! leaf capability set (`crate::types::WORKER_LEAF_VERBS`).
37
38use axum::{
39    extract::{Query, State},
40    http::{header::AUTHORIZATION, HeaderMap, StatusCode},
41    Json,
42};
43use mlua_swarm::{CapToken, ContentRef, OutputEvent, StepId, WorkerPayload};
44use serde::Deserialize;
45use serde_json::Value;
46
47use crate::{ApiError, AppState};
48
49/// Query params for `GET /v1/worker/prompt`.
50#[derive(Debug, Deserialize)]
51pub struct PromptQuery {
52    /// Task the fetched prompt belongs to; cross-checked against the Bearer
53    /// handle/token. Typed [`StepId`] since issue #14 — the wire shape stays
54    /// a plain string; a bad prefix is rejected at deserialize.
55    pub task_id: StepId,
56}
57
58/// `GET /v1/worker/prompt?task_id=<tid>`. Bearer = encoded `CapToken` or short `wh-` handle.
59/// Thin HTTP wrapper over `engine.fetch_worker_payload` / `fetch_worker_payload_trusted`.
60/// Short-handle path (recommended for SubAgents): handle → task_id
61/// cross-check → trusted fetch.
62/// Full-`CapToken` path: token decode → verify → fetch.
63pub async fn worker_prompt(
64    State(state): State<AppState>,
65    headers: HeaderMap,
66    Query(q): Query<PromptQuery>,
67) -> Result<Json<WorkerPayload>, ApiError> {
68    let task_id = q.task_id;
69    let bearer = extract_bearer_raw(&headers)?;
70    let payload = if let Some(handle) = parse_worker_handle(&bearer) {
71        // Short-handle path: verify handle → task_id (security: confirm the handle is bound to this task).
72        let resolved = state
73            .engine
74            .task_id_from_handle(handle)
75            .await
76            .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?;
77        if resolved != task_id {
78            return Err(ApiError::bad_request(format!(
79                "handle {handle} is bound to task {resolved}, not {task_id}"
80            )));
81        }
82        state
83            .engine
84            .fetch_worker_payload_trusted(&task_id)
85            .await
86            .map_err(|e| ApiError::engine(format!("fetch_worker_payload_trusted: {e}")))?
87    } else {
88        // Full CapToken path (the alternate Bearer form).
89        let token = CapToken::decode(bearer.trim())
90            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
91        state
92            .engine
93            .fetch_worker_payload(&token, &task_id)
94            .await
95            .map_err(|e| ApiError::engine(format!("fetch_worker_payload: {e}")))?
96    };
97    Ok(Json(payload))
98}
99
100/// Body for `POST /v1/worker/result`.
101#[derive(Debug, Deserialize)]
102pub struct WorkerResultReq {
103    /// Task this result belongs to (looked up together with the Bearer
104    /// token). Typed [`StepId`] since issue #14 (see [`PromptQuery`]).
105    pub task_id: StepId,
106    /// `WorkerResult.value` (= the value returned by the Operator: LLM inference result or tool execution result).
107    pub value: Value,
108    /// `WorkerResult.ok`. `false` makes the dispatch path decide Blocked
109    /// (= same semantics as `OutputEvent::Final { ok: false, .. }` from a
110    /// `SpawnerAdapter`). Defaults to `true`.
111    #[serde(default = "default_ok_true")]
112    pub ok: bool,
113    /// Optional explicit attempt. Normally omitted (= the server looks up `task.attempt`).
114    /// A carry for race-condition tests that need to write to a fixed attempt.
115    #[serde(default)]
116    pub attempt: Option<u32>,
117}
118
119fn default_ok_true() -> bool {
120    true
121}
122
123/// `POST /v1/worker/result`. Bearer = encoded `CapToken`.
124/// Fires `engine.submit_output(Final)` + `engine.post_result`.
125pub async fn worker_result(
126    State(state): State<AppState>,
127    headers: HeaderMap,
128    Json(req): Json<WorkerResultReq>,
129) -> Result<StatusCode, ApiError> {
130    let token = decode_worker_bearer(&headers)?;
131    let task_id = req.task_id.clone();
132
133    // Use body-explicit attempt if provided; otherwise the current task.attempt.
134    let attempt = match req.attempt {
135        Some(n) => n,
136        None => state
137            .engine
138            .task_attempt(&task_id)
139            .await
140            .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?,
141    };
142
143    let event = OutputEvent::Final {
144        content: ContentRef::Inline {
145            value: req.value.clone(),
146        },
147        ok: req.ok,
148    };
149    state
150        .engine
151        .submit_output(&token, &task_id, attempt, event)
152        .await
153        .map_err(|e| ApiError::engine(format!("submit_output: {e}")))?;
154    state
155        .engine
156        .post_result(&token, &task_id, req.value)
157        .await
158        .map_err(|e| ApiError::engine(format!("post_result: {e}")))?;
159    Ok(StatusCode::NO_CONTENT)
160}
161
162/// `POST /v1/worker/submit`. Bearer = encoded `CapToken`. Body = raw text/octet.
163///
164/// Simplification-axis endpoint for SubAgents. Removes the JSON construction,
165/// duplicated `task_id`, and JSON-escape burden of `/v1/worker/result` — the
166/// worker completes a POST with just token + raw body. Origin: the recent clean-up
167/// of the SubAgent contract drift (fewer IDs to pass around, multi-line escape
168/// accidents eliminated).
169///
170/// Behavior:
171/// - `task_id` is auto-looked-up server-side from the token (already bound to the `CapToken`).
172/// - Body raw bytes go as-is into `Value::String` for `submit_output` + `post_result`.
173/// - `ok=true` fixed (= the submit endpoint is success-path only). For the error
174///   path, use `/v1/worker/result` with an explicit `ok=false`.
175#[derive(Debug, Deserialize, Default)]
176pub struct SubmitQuery {
177    /// Optional. `ok=false` signals failure (= `DispatchOutcome::Blocked`, caught
178    /// by the flow.ir Try path). Unspecified (`None`) is treated as `ok=true`
179    /// (= normal success).
180    #[serde(default)]
181    pub ok: Option<bool>,
182}
183
184/// `POST /v1/worker/submit`. Simplified counterpart of [`worker_result`]:
185/// the caller sends only the raw result body, `task_id` is resolved
186/// server-side from the Bearer handle/token, and `ok` defaults to `true`
187/// unless overridden via [`SubmitQuery::ok`]. See the module doc for the
188/// short-handle vs full-`CapToken` Bearer forms.
189pub async fn worker_submit(
190    State(state): State<AppState>,
191    headers: HeaderMap,
192    Query(q): Query<SubmitQuery>,
193    body: axum::body::Bytes,
194) -> Result<StatusCode, ApiError> {
195    // Bearer accepts either (a) `wh-<8 hex>` short handle (recommended for
196    // SubAgents) or (b) base64-wrapped CapToken JSON (the full-token form).
197    let bearer = extract_bearer_raw(&headers)?;
198    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
199        state
200            .engine
201            .task_id_from_handle(handle)
202            .await
203            .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
204    } else {
205        let token = CapToken::decode(bearer.trim())
206            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
207        state
208            .engine
209            .task_id_from_token(&token)
210            .await
211            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
212    };
213    let attempt = state
214        .engine
215        .task_attempt(&task_id)
216        .await
217        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
218    // Strip trailing whitespace (newlines, etc.) so flow.ir `Eq` string matches
219    // don't drift on `"BLOCKED\n" == "BLOCKED"` false results. Origin: the recent clean-up
220    // verdict_loop smoke — sharp-edge removal. Internal `\n` inside the raw bytes
221    // is preserved (= only trailing).
222    let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
223    let value = Value::String(body_str);
224
225    // The handle path = trusted internal API (= the server-minted handle is validated
226    // by the earlier lookup); the full-token path = existing verify-by-token API.
227    // Both are reflected identically into final + last_result.
228    // `?ok=false` in the query signals failure (= `DispatchOutcome::Blocked`,
229    // the flow.ir Try catch path).
230    let ok = q.ok.unwrap_or(true);
231    state
232        .engine
233        .submit_worker_result_trusted(&task_id, attempt, value, ok)
234        .await
235        .map_err(|e| ApiError::engine(format!("submit_worker_result_trusted: {e}")))?;
236    Ok(StatusCode::NO_CONTENT)
237}
238
239/// Extracts the raw string from the `Authorization` header (= strips the `Bearer `
240/// prefix). To let `worker_submit` accept both short handles and full tokens, we
241/// fetch the raw value before any decode.
242fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
243    let v = headers
244        .get(AUTHORIZATION)
245        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
246        .to_str()
247        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
248    let s = v
249        .strip_prefix("Bearer ")
250        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
251        .trim();
252    if s.is_empty() {
253        return Err(ApiError::bad_request("Bearer is empty".into()));
254    }
255    Ok(s.to_string())
256}
257
258/// Decides whether the Bearer is a short handle (`wh-XXXXXXXX`). Returns
259/// `Some(handle)` on a match, `None` otherwise (= caller proceeds to try decoding
260/// as full `CapToken` JSON).
261fn parse_worker_handle(s: &str) -> Option<&str> {
262    let s = s.trim();
263    if s.starts_with("wh-")
264        && s.len() >= 5
265        && s.len() <= 64
266        && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
267    {
268        Some(s)
269    } else {
270        None
271    }
272}
273
274/// Decodes an encoded `CapToken` from `Authorization: Bearer <encoded CapToken>`.
275/// Kept separate from `extract_bearer` (sid-only) — kept as a distinct fn so
276/// that sid strings and encoded tokens are not confused, distinguishing them by type.
277fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
278    let v = headers
279        .get(AUTHORIZATION)
280        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
281        .to_str()
282        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
283    let encoded = v
284        .strip_prefix("Bearer ")
285        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
286        .trim();
287    if encoded.is_empty() {
288        return Err(ApiError::bad_request("Bearer token is empty".into()));
289    }
290    CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
291}