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}