Skip to main content

mlua_swarm_server/
tasks.rs

1//! HTTP surface for the Task/Run persistence axis (issue #13 ID-hierarchy
2//! reconciliation: Blueprint → Task → Run → Step → Attempt).
3//!
4//! - `GET  /v1/tasks`          — list every persisted `TaskRecord`, newest first.
5//! - `GET  /v1/tasks/:id`      — a `TaskRecord` plus every `RunRecord` kicked from it.
6//! - `POST /v1/tasks/:id/runs` — re-kick an existing Task: mints a fresh `RunId`,
7//!   replays the stored `blueprint_ref` / `input_ctx` through
8//!   `TaskApplication::handle_with_run`, and returns the new `{task_id, run_id}` pair.
9//! - `GET  /v1/runs/:id`       — a single `RunRecord` (`step_entries` trace included).
10//!
11//! `POST /v1/tasks` itself (the flow-eval entry point, `tasks_start` /
12//! `run_flow_form`) stays in `crate::lib` — it is the pre-existing
13//! Operator-inject-aware dispatch path this module's handlers re-kick
14//! through, not a new one. This module owns the read/list/re-kick surface
15//! plus the [`finalize_run`] persistence helper both paths share.
16//!
17//! Authorization follows the same convention as the existing `POST /v1/tasks`
18//! entry: no `Authorization` header is required (the route is open), and the
19//! only Operator-session correlation available is the request-body-level
20//! `operator_sid` (see `crate::FlowTasksReq` doc) — this module invents no
21//! new auth mechanism.
22
23use axum::{
24    extract::{Path, Query, State},
25    http::StatusCode,
26    Json,
27};
28use mlua_swarm::application::{TaskApplicationError, TaskApplicationInput, TaskApplicationOutput};
29use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStoreError};
30use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
31use mlua_swarm::{Role, RunId, TaskId};
32use serde::{Deserialize, Serialize};
33use std::time::Duration;
34
35use crate::{ApiError, AppState};
36
37/// Current Unix time in whole seconds. `TaskRecord` / `RunRecord` timestamps
38/// are `u64` seconds (not milliseconds) — see their field docs in
39/// `mlua_swarm::store::task` / `mlua_swarm::store::run`.
40pub(crate) fn now_secs() -> u64 {
41    std::time::SystemTime::now()
42        .duration_since(std::time::UNIX_EPOCH)
43        .map(|d| d.as_secs())
44        .unwrap_or(0)
45}
46
47/// Shared finalize step for a dispatched kick: updates the Run's
48/// `result_ref` + status and the owning Task's coarse status based on the
49/// `TaskApplication::handle_with_run` outcome, then returns that same
50/// outcome unchanged so callers keep shaping their own wire response /
51/// error.
52///
53/// Secondary persistence failures (the store call itself erroring) are
54/// logged via `tracing::warn!` and otherwise swallowed — they must not mask
55/// the primary dispatch outcome the caller already has in hand.
56pub(crate) async fn finalize_run(
57    state: &AppState,
58    task_id: &TaskId,
59    run_id: &RunId,
60    outcome: Result<TaskApplicationOutput, TaskApplicationError>,
61) -> Result<TaskApplicationOutput, TaskApplicationError> {
62    match &outcome {
63        Ok(out) => {
64            if let Err(e) = state
65                .run_store
66                .set_result(run_id, out.final_ctx.clone())
67                .await
68            {
69                tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
70            }
71            if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
72                tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
73            }
74            if let Err(e) = state
75                .task_store
76                .update_status(task_id, TaskRecordStatus::Done)
77                .await
78            {
79                tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
80            }
81        }
82        Err(e) => {
83            if let Err(store_err) = state
84                .run_store
85                .update_status(run_id, RunStatus::Failed)
86                .await
87            {
88                tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
89            }
90            if let Err(store_err) = state
91                .task_store
92                .update_status(task_id, TaskRecordStatus::Failed)
93                .await
94            {
95                tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
96            }
97            tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
98        }
99    }
100    outcome
101}
102
103/// Query params for `GET /v1/tasks`.
104#[derive(Debug, Deserialize, Default)]
105pub struct TasksListQuery {
106    /// Caps the returned list to the first N entries (already newest-first
107    /// per `TaskStore::list`). Omitted = no cap.
108    #[serde(default)]
109    pub limit: Option<usize>,
110}
111
112/// `GET /v1/tasks?limit=N`. Lists every persisted `TaskRecord`, newest first.
113pub async fn tasks_list(
114    State(state): State<AppState>,
115    Query(q): Query<TasksListQuery>,
116) -> Result<Json<Vec<TaskRecord>>, ApiError> {
117    let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
118    if let Some(limit) = q.limit {
119        records.truncate(limit);
120    }
121    Ok(Json(records))
122}
123
124/// Response body for `GET /v1/tasks/:id`.
125#[derive(Debug, Clone, Serialize)]
126pub struct TaskDetailResponse {
127    /// The Task's own record.
128    pub task: TaskRecord,
129    /// Every Run kicked from this Task, oldest first (`RunStore::list_by_task` order).
130    pub runs: Vec<RunRecord>,
131}
132
133/// `GET /v1/tasks/:id`. Returns the `TaskRecord` plus every `RunRecord`
134/// kicked from it (`RunStore::list_by_task`, oldest kick first).
135pub async fn task_get(
136    State(state): State<AppState>,
137    Path(id): Path<String>,
138) -> Result<Json<TaskDetailResponse>, ApiError> {
139    let task_id =
140        TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
141    let task = state
142        .task_store
143        .get(&task_id)
144        .await
145        .map_err(map_task_store_err)?;
146    let runs = state
147        .run_store
148        .list_by_task(&task_id)
149        .await
150        .map_err(ApiError::engine)?;
151    Ok(Json(TaskDetailResponse { task, runs }))
152}
153
154/// Response body for `POST /v1/tasks/:id/runs`.
155#[derive(Debug, Clone, Serialize)]
156pub struct RunKickResponse {
157    /// The re-kicked Task's id (echoes the path param).
158    pub task_id: TaskId,
159    /// The freshly minted Run id for this kick.
160    pub run_id: RunId,
161}
162
163/// `POST /v1/tasks/:id/runs`. Re-kicks an existing Task: reads its stored
164/// `blueprint_ref` / `input_ctx`, mints a fresh `RunId`, dispatches through
165/// `TaskApplication::handle_with_run` via `TaskApplicationInput::automate`
166/// (the unadorned Operator-default path — no per-request Operator override
167/// support here, unlike `POST /v1/tasks`; the stored Task carries no such
168/// preferences) plus a freshly-built `RunContext` (issue #13 run_id
169/// propagation, so this kick's steps get their own `step_entries` trace),
170/// and persists the outcome via [`finalize_run`].
171pub async fn task_rekick(
172    State(state): State<AppState>,
173    Path(id): Path<String>,
174) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
175    let task_id =
176        TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
177    let task = state
178        .task_store
179        .get(&task_id)
180        .await
181        .map_err(map_task_store_err)?;
182
183    let blueprint: mlua_swarm::application::BlueprintRef =
184        serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
185            ApiError::bad_request(format!(
186                "task {task_id}: stored blueprint_ref failed to decode: {e}"
187            ))
188        })?;
189
190    let run_id = RunId::new();
191    let now = now_secs();
192    state
193        .task_store
194        .update_status(&task_id, TaskRecordStatus::Running)
195        .await
196        .map_err(ApiError::engine)?;
197    state
198        .run_store
199        .create(RunRecord {
200            id: run_id.clone(),
201            task_id: task_id.clone(),
202            status: RunStatus::Running,
203            step_entries: Vec::new(),
204            operator_sid: None,
205            result_ref: None,
206            created_at: now,
207            updated_at: now,
208        })
209        .await
210        .map_err(ApiError::engine)?;
211
212    let input = TaskApplicationInput::automate(
213        blueprint,
214        "http-run",
215        Role::Operator,
216        Duration::from_secs(crate::default_run_ttl()),
217        task.input_ctx.clone(),
218    );
219    let run_ctx = RunContext {
220        run_id: run_id.clone(),
221        run_store: state.run_store.clone(),
222    };
223    let outcome = state.task_app.handle_with_run(input, Some(run_ctx)).await;
224    finalize_run(&state, &task_id, &run_id, outcome)
225        .await
226        .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
227
228    Ok((
229        StatusCode::CREATED,
230        Json(RunKickResponse { task_id, run_id }),
231    ))
232}
233
234/// `GET /v1/runs/:id`. Returns a single `RunRecord` (its `step_entries`
235/// trace included).
236pub async fn run_get(
237    State(state): State<AppState>,
238    Path(id): Path<String>,
239) -> Result<Json<RunRecord>, ApiError> {
240    let run_id =
241        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
242    let run = state
243        .run_store
244        .get(&run_id)
245        .await
246        .map_err(map_run_store_err)?;
247    Ok(Json(run))
248}
249
250fn map_task_store_err(e: TaskStoreError) -> ApiError {
251    match e {
252        TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
253        other => ApiError::engine(other),
254    }
255}
256
257fn map_run_store_err(e: RunStoreError) -> ApiError {
258    match e {
259        RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
260        other => ApiError::engine(other),
261    }
262}
263
264// ──────────────────────────────────────────────────────────────────────────
265// UT
266// ──────────────────────────────────────────────────────────────────────────
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use mlua_swarm::application::BlueprintRef;
272    use mlua_swarm::blueprint::{
273        current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
274        CompilerStrategy,
275    };
276    use mlua_swarm::core::config::EngineCfg;
277    use mlua_swarm::core::engine::Engine;
278    use mlua_swarm::store::output::InMemoryOutputStore;
279    use mlua_swarm::store::run::InMemoryRunStore;
280    use mlua_swarm::store::task::InMemoryTaskStore;
281    use std::collections::HashMap;
282    use std::sync::Arc;
283    use tokio::sync::Mutex;
284
285    /// A single-step flow.ir Blueprint that always succeeds: `Step { ref:
286    /// "identity", in: lit("hello"), out: $.out }` against the baseline
287    /// `RustFn` identity worker (same shape as `seed_blueprint` in
288    /// `mlua-swarm-cli`'s `serve.rs`, self-contained here rather than
289    /// importing a binary crate).
290    fn identity_blueprint() -> Blueprint {
291        Blueprint {
292            schema_version: current_schema_version(),
293            id: "tasks-test-bp".into(),
294            flow: serde_json::from_value(serde_json::json!({
295                "kind": "step",
296                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
297                "in": {"op": "lit", "value": "hello"},
298                "out": {"op": "path", "at": "$.out"},
299            }))
300            .expect("flow parse"),
301            agents: vec![AgentDef {
302                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
303                kind: AgentKind::RustFn,
304                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
305                profile: None,
306                meta: None,
307            }],
308            operators: vec![],
309            hints: CompilerHints::default(),
310            strategy: CompilerStrategy::default(),
311            metadata: BlueprintMetadata::default(),
312            spawner_hints: Default::default(),
313            default_agent_kind: AgentKind::Operator,
314            default_operator_kind: None,
315        }
316    }
317
318    /// Minimal `AppState` for handler-level tests — mirrors the construction
319    /// `build_router_full` does internally, but skips the `Router` wrapper so
320    /// tests can call handler functions directly (this crate's established
321    /// unit-test convention; see e.g. `operator_ws::login`'s tests).
322    fn test_state() -> AppState {
323        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
324        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
325        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
326        AppState {
327            engine,
328            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
329            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
330            ws_operator_factory: None,
331            data_store: Arc::new(InMemoryOutputStore::new()),
332            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
333            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
334            task_store: Arc::new(InMemoryTaskStore::new()),
335            run_store: Arc::new(InMemoryRunStore::new()),
336            base_url: None,
337        }
338    }
339
340    fn post_tasks_req(goal: &str) -> crate::FlowTasksReq {
341        crate::FlowTasksReq {
342            blueprint: BlueprintRef::Inline {
343                value: Box::new(identity_blueprint()),
344            },
345            init_ctx: serde_json::json!({"in": "hello"}),
346            ttl_secs: None,
347            operator: None,
348            operator_sid: None,
349            goal: Some(goal.to_string()),
350        }
351    }
352
353    #[test]
354    fn task_id_serializes_as_bare_string() {
355        // Sanity check for the newtype-struct transparency relied on
356        // throughout this module's response shapes (`TaskId` / `RunId`
357        // serialize as plain JSON strings, not `{"0": "..."}`).
358        let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
359        assert_eq!(v, serde_json::json!("T-abc"));
360    }
361
362    #[tokio::test]
363    async fn post_then_get_drill_down() {
364        let state = test_state();
365
366        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
367            .await
368            .expect("tasks_start")
369            .0;
370        let task_id = posted.task_id.clone();
371        let run_id = posted.run_id.clone();
372
373        // GET /v1/tasks lists it.
374        let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
375            .await
376            .expect("tasks_list")
377            .0;
378        assert!(
379            list.iter().any(|t| t.id == task_id),
380            "task {task_id} missing from list of {} tasks",
381            list.len()
382        );
383
384        // GET /v1/tasks/:id drills down to the Task + its Run.
385        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
386            .await
387            .expect("task_get")
388            .0;
389        assert_eq!(detail.task.id, task_id);
390        assert_eq!(detail.task.goal, "smoke goal");
391        assert_eq!(detail.task.status, TaskRecordStatus::Done);
392        assert_eq!(detail.runs.len(), 1);
393        assert_eq!(detail.runs[0].id, run_id);
394        assert_eq!(detail.runs[0].status, RunStatus::Done);
395
396        // GET /v1/runs/:id returns the same Run directly.
397        let run = run_get(State(state.clone()), Path(run_id.to_string()))
398            .await
399            .expect("run_get")
400            .0;
401        assert_eq!(run.id, run_id);
402        assert_eq!(run.task_id, task_id);
403        assert_eq!(run.result_ref, Some(posted.final_ctx));
404
405        // issue #13 run_id propagation: `POST /v1/tasks` (`run_flow_form`)
406        // wires a `RunContext` into `TaskApplication::handle_with_run`, so
407        // the single dispatched step must be traced into `step_entries`.
408        assert_eq!(
409            run.step_entries.len(),
410            1,
411            "expected one step_entry for the 1-step identity Blueprint, got {:?}",
412            run.step_entries
413        );
414        assert_eq!(
415            run.step_entries[0].step_ref,
416            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
417        );
418        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
419    }
420
421    #[tokio::test]
422    async fn rekick_adds_a_second_run_to_the_same_task() {
423        let state = test_state();
424        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
425            .await
426            .expect("tasks_start")
427            .0;
428        let task_id = posted.task_id.clone();
429        let first_run_id = posted.run_id.clone();
430
431        let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()))
432            .await
433            .expect("task_rekick");
434        assert_eq!(status, StatusCode::CREATED);
435        let second_run_id = rekicked.0.run_id.clone();
436        assert_ne!(first_run_id, second_run_id);
437
438        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
439            .await
440            .expect("task_get")
441            .0;
442        assert_eq!(
443            detail.runs.len(),
444            2,
445            "expected 2 runs, got {:?}",
446            detail.runs
447        );
448        let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
449        assert!(ids.contains(&&first_run_id));
450        assert!(ids.contains(&&second_run_id));
451
452        // issue #13 run_id propagation: each kick's own `EngineDispatcher`
453        // (built fresh per `TaskApplication::handle_with_run` call) must
454        // trace its own dispatched step into its own `RunRecord` —
455        // independent `step_entries`, not shared/accumulated across kicks.
456        let first_run = detail
457            .runs
458            .iter()
459            .find(|r| r.id == first_run_id)
460            .expect("first run present in detail.runs");
461        let second_run = detail
462            .runs
463            .iter()
464            .find(|r| r.id == second_run_id)
465            .expect("second run present in detail.runs");
466        assert_eq!(
467            first_run.step_entries.len(),
468            1,
469            "first run step_entries: {:?}",
470            first_run.step_entries
471        );
472        assert_eq!(
473            second_run.step_entries.len(),
474            1,
475            "second run step_entries: {:?}",
476            second_run.step_entries
477        );
478        assert_eq!(
479            first_run.step_entries[0].step_ref,
480            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
481        );
482        assert_eq!(
483            second_run.step_entries[0].step_ref,
484            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
485        );
486        assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
487        assert_eq!(
488            second_run.step_entries[0].status,
489            Some("passed".to_string())
490        );
491        assert_ne!(
492            first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
493            "each kick dispatches its own StepId — runs must not share step_entries"
494        );
495    }
496
497    #[tokio::test]
498    async fn rekick_unknown_task_returns_404() {
499        let state = test_state();
500        // `.expect_err()` needs the Ok variant to be `Debug`; `Json<T>`'s
501        // `Debug` impl is not guaranteed for every `T` across axum versions,
502        // so a plain match sidesteps that bound entirely.
503        match task_rekick(State(state), Path("T-does-not-exist".to_string())).await {
504            Ok(_) => panic!("expected 404 for an unknown task"),
505            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
506        }
507    }
508
509    #[tokio::test]
510    async fn run_get_unknown_id_returns_404() {
511        let state = test_state();
512        match run_get(State(state), Path("R-does-not-exist".to_string())).await {
513            Ok(_) => panic!("expected 404 for an unknown run"),
514            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
515        }
516    }
517
518    #[tokio::test]
519    async fn task_get_unknown_id_returns_404() {
520        let state = test_state();
521        match task_get(State(state), Path("T-does-not-exist".to_string())).await {
522            Ok(_) => panic!("expected 404 for an unknown task"),
523            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
524        }
525    }
526}