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//!   re-resolves the stored `blueprint_ref` (refreshing `Blueprint.default_init_ctx`
8//!   exactly like original launch time — issue #19 ST4), 3-layer-merges it with
9//!   `TaskRecord.input_ctx` and an **optional** [`RunKickRequest`] body's
10//!   `init_ctx_override` (see [`merge_init_ctx_3layer`]), dispatches through
11//!   `TaskApplication::handle_with_run`, and returns the new `{task_id, run_id}`
12//!   pair. A body-less request (or one that omits both fields) preserves the
13//!   pre-#19 rekick behavior byte-for-byte.
14//! - `GET  /v1/runs/:id`       — a single `RunRecord` (`step_entries` trace included).
15//!
16//! `POST /v1/tasks` itself (the flow-eval entry point, `tasks_start` /
17//! `run_flow_form`) stays in `crate::lib` — it is the pre-existing
18//! Operator-inject-aware dispatch path this module's handlers re-kick
19//! through, not a new one. This module owns the read/list/re-kick surface
20//! plus the [`finalize_run`] persistence helper both paths share.
21//!
22//! Authorization follows the same convention as the existing `POST /v1/tasks`
23//! entry: no `Authorization` header is required (the route is open), and the
24//! only Operator-session correlation available is the request-body-level
25//! `operator_sid` (see `crate::TaskLaunchRequest` doc) — this module invents no
26//! new auth mechanism.
27
28use axum::{
29    extract::{Path, Query, State},
30    http::StatusCode,
31    Json,
32};
33use mlua_swarm::application::{TaskApplicationError, TaskApplicationInput, TaskApplicationOutput};
34use mlua_swarm::service::merge_init_ctx_3layer;
35use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStoreError};
36use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
37use mlua_swarm::{Role, RunId, TaskId, TaskInputSpec};
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use std::collections::HashMap;
41use std::time::Duration;
42
43use crate::{ApiError, AppState};
44
45/// Current Unix time in whole seconds. `TaskRecord` / `RunRecord` timestamps
46/// are `u64` seconds (not milliseconds) — see their field docs in
47/// `mlua_swarm::store::task` / `mlua_swarm::store::run`.
48pub(crate) fn now_secs() -> u64 {
49    std::time::SystemTime::now()
50        .duration_since(std::time::UNIX_EPOCH)
51        .map(|d| d.as_secs())
52        .unwrap_or(0)
53}
54
55/// Shared finalize step for a dispatched kick: updates the Run's
56/// `result_ref` + status and the owning Task's coarse status based on the
57/// `TaskApplication::handle_with_run` outcome, then returns that same
58/// outcome unchanged so callers keep shaping their own wire response /
59/// error.
60///
61/// Secondary persistence failures (the store call itself erroring) are
62/// logged via `tracing::warn!` and otherwise swallowed — they must not mask
63/// the primary dispatch outcome the caller already has in hand.
64pub(crate) async fn finalize_run(
65    state: &AppState,
66    task_id: &TaskId,
67    run_id: &RunId,
68    outcome: Result<TaskApplicationOutput, TaskApplicationError>,
69) -> Result<TaskApplicationOutput, TaskApplicationError> {
70    match &outcome {
71        Ok(out) => {
72            if let Err(e) = state
73                .run_store
74                .set_result(run_id, out.final_ctx.clone())
75                .await
76            {
77                tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
78            }
79            if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
80                tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
81            }
82            if let Err(e) = state
83                .task_store
84                .update_status(task_id, TaskRecordStatus::Done)
85                .await
86            {
87                tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
88            }
89        }
90        Err(e) => {
91            if let Err(store_err) = state
92                .run_store
93                .update_status(run_id, RunStatus::Failed)
94                .await
95            {
96                tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
97            }
98            if let Err(store_err) = state
99                .task_store
100                .update_status(task_id, TaskRecordStatus::Failed)
101                .await
102            {
103                tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
104            }
105            tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
106        }
107    }
108    outcome
109}
110
111/// Query params for `GET /v1/tasks`.
112#[derive(Debug, Deserialize, Default)]
113pub struct TasksListQuery {
114    /// Caps the returned list to the first N entries (already newest-first
115    /// per `TaskStore::list`). Omitted = no cap.
116    #[serde(default)]
117    pub limit: Option<usize>,
118}
119
120/// `GET /v1/tasks?limit=N`. Lists every persisted `TaskRecord`, newest first.
121pub async fn tasks_list(
122    State(state): State<AppState>,
123    Query(q): Query<TasksListQuery>,
124) -> Result<Json<Vec<TaskRecord>>, ApiError> {
125    let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
126    if let Some(limit) = q.limit {
127        records.truncate(limit);
128    }
129    Ok(Json(records))
130}
131
132/// Response body for `GET /v1/tasks/:id`.
133#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
134pub struct TaskDetailResponse {
135    /// The Task's own record.
136    pub task: TaskRecord,
137    /// Every Run kicked from this Task, oldest first (`RunStore::list_by_task` order).
138    pub runs: Vec<RunRecord>,
139}
140
141/// `GET /v1/tasks/:id`. Returns the `TaskRecord` plus every `RunRecord`
142/// kicked from it (`RunStore::list_by_task`, oldest kick first).
143pub async fn task_get(
144    State(state): State<AppState>,
145    Path(id): Path<String>,
146) -> Result<Json<TaskDetailResponse>, ApiError> {
147    let task_id =
148        TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
149    let task = state
150        .task_store
151        .get(&task_id)
152        .await
153        .map_err(map_task_store_err)?;
154    let runs = state
155        .run_store
156        .list_by_task(&task_id)
157        .await
158        .map_err(ApiError::engine)?;
159    Ok(Json(TaskDetailResponse { task, runs }))
160}
161
162/// Request body for `POST /v1/tasks/:id/runs` (issue #19 ST4) — every
163/// field is optional, and the body itself is optional (see
164/// [`task_rekick`]'s `Option<Json<Self>>` parameter); a caller that sends
165/// no body, or `{}`, or omits a field gets exactly today's rekick
166/// behavior for that layer.
167#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
168pub struct RunKickRequest {
169    /// Per-Run override for the flow-ir initial ctx. Merged on top of
170    /// `TaskRecord.input_ctx` (itself already merged on top of
171    /// `Blueprint.default_init_ctx` at original launch time) via
172    /// [`merge_init_ctx_3layer`] — Run wins on key collision, same
173    /// shallow-merge / non-Object-fully-replaces rule as every other
174    /// layer in the cascade. `None` (absent field, or no body at all) is
175    /// a no-op: the BP+Task merge alone seeds this kick, identical to
176    /// pre-#19 rekick.
177    #[serde(default)]
178    #[schemars(with = "Option<Value>")]
179    pub init_ctx_override: Option<Value>,
180    /// Per-Run override for the Task-level canonical fields
181    /// (`project_root` / `work_dir` / `task_metadata`). `None` falls back
182    /// to `TaskRecord.task_input_spec` (the spec resolved and snapshotted
183    /// at original `POST /v1/tasks` time); `Some` replaces it wholesale
184    /// for this kick only — the stored `TaskRecord.task_input_spec` is
185    /// never mutated by a rekick.
186    #[serde(default)]
187    pub task_input_override: Option<TaskInputSpec>,
188}
189
190/// Response body for `POST /v1/tasks/:id/runs`.
191#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
192pub struct RunKickResponse {
193    /// The re-kicked Task's id (echoes the path param).
194    #[schemars(with = "String")]
195    pub task_id: TaskId,
196    /// The freshly minted Run id for this kick.
197    #[schemars(with = "String")]
198    pub run_id: RunId,
199}
200
201/// `POST /v1/tasks/:id/runs`. Re-kicks an existing Task: reads its stored
202/// `blueprint_ref`, re-resolves it through [`TaskApplication::resolve`]
203/// (issue #19 ST4 — refreshes `Blueprint.default_init_ctx` exactly like
204/// original launch time, rather than replaying a launch-time-only
205/// snapshot), 3-layer-merges `{bp default, TaskRecord.input_ctx, an
206/// optional per-Run override}` via [`merge_init_ctx_3layer`], resolves the
207/// Task-level canonical fields (`RunKickRequest.task_input_override`,
208/// falling back to `TaskRecord.task_input_spec`), mints a fresh `RunId`,
209/// dispatches through `TaskApplication::handle_with_run` (the unadorned
210/// Operator-default path — no per-request Operator override support here,
211/// unlike `POST /v1/tasks`; the stored Task carries no such preferences)
212/// plus a freshly-built `RunContext` (issue #13 run_id propagation, so
213/// this kick's steps get their own `step_entries` trace), and persists the
214/// outcome via [`finalize_run`].
215///
216/// The body is optional (`Option<Json<RunKickRequest>>`) — no body, or a
217/// body with both fields absent, preserves the pre-#19 rekick behavior
218/// byte-for-byte (`must_not_simplify #3`).
219pub async fn task_rekick(
220    State(state): State<AppState>,
221    Path(id): Path<String>,
222    body: Option<Json<RunKickRequest>>,
223) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
224    let task_id =
225        TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
226    let task = state
227        .task_store
228        .get(&task_id)
229        .await
230        .map_err(map_task_store_err)?;
231
232    let blueprint_ref: mlua_swarm::application::BlueprintRef =
233        serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
234            ApiError::bad_request(format!(
235                "task {task_id}: stored blueprint_ref failed to decode: {e}"
236            ))
237        })?;
238
239    // issue #19 ST4 (must_not_simplify #5): re-resolve the Blueprint the
240    // same way `run_flow_form`'s TTL cascade does, so a store-backed
241    // `BlueprintRef::Id` gets its *current* `default_init_ctx` on every
242    // rekick rather than whatever was true at original launch time. The
243    // Inline path is a pure pass-through, so this is a no-op there.
244    let (resolved_bp, _bound_version) = state
245        .task_app
246        .resolve(&blueprint_ref)
247        .await
248        .map_err(|e| ApiError::bad_request(format!("task {task_id}: bp resolve: {e}")))?;
249
250    let req = body.map(|Json(r)| r).unwrap_or_default();
251
252    let merged_init_ctx = merge_init_ctx_3layer(
253        resolved_bp.default_init_ctx.as_ref(),
254        &task.input_ctx,
255        req.init_ctx_override.as_ref(),
256    );
257
258    // must_not_simplify #4: `task_input_override` wins for this kick only;
259    // falling back to the Task-level snapshot never mutates
260    // `TaskRecord.task_input_spec` itself.
261    let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
262        Some(over) => Some(over),
263        None => task
264            .task_input_spec
265            .as_ref()
266            .map(|v| serde_json::from_value(v.clone()))
267            .transpose()
268            .map_err(|e| {
269                ApiError::bad_request(format!(
270                    "task {task_id}: stored task_input_spec failed to decode: {e}"
271                ))
272            })?,
273    };
274
275    let run_id = RunId::new();
276    let now = now_secs();
277    state
278        .task_store
279        .update_status(&task_id, TaskRecordStatus::Running)
280        .await
281        .map_err(ApiError::engine)?;
282    state
283        .run_store
284        .create(RunRecord {
285            id: run_id.clone(),
286            task_id: task_id.clone(),
287            status: RunStatus::Running,
288            step_entries: Vec::new(),
289            operator_sid: None,
290            result_ref: None,
291            created_at: now,
292            updated_at: now,
293        })
294        .await
295        .map_err(ApiError::engine)?;
296
297    let input = TaskApplicationInput {
298        blueprint: blueprint_ref,
299        operator_id: "http-run".to_string(),
300        role: Role::Operator,
301        ttl: Duration::from_secs(crate::default_run_ttl()),
302        init_ctx: merged_init_ctx,
303        operator_kind: None,
304        bridge_id: None,
305        hook_id: None,
306        operator_backend_id: None,
307        operator_kind_overrides: HashMap::new(),
308        task_input: task_input_spec,
309    };
310    let run_ctx = RunContext {
311        run_id: run_id.clone(),
312        run_store: state.run_store.clone(),
313    };
314    let outcome = state.task_app.handle_with_run(input, Some(run_ctx)).await;
315    finalize_run(&state, &task_id, &run_id, outcome)
316        .await
317        .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
318
319    Ok((
320        StatusCode::CREATED,
321        Json(RunKickResponse { task_id, run_id }),
322    ))
323}
324
325/// `GET /v1/runs/:id`. Returns a single `RunRecord` (its `step_entries`
326/// trace included).
327pub async fn run_get(
328    State(state): State<AppState>,
329    Path(id): Path<String>,
330) -> Result<Json<RunRecord>, ApiError> {
331    let run_id =
332        RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
333    let run = state
334        .run_store
335        .get(&run_id)
336        .await
337        .map_err(map_run_store_err)?;
338    Ok(Json(run))
339}
340
341fn map_task_store_err(e: TaskStoreError) -> ApiError {
342    match e {
343        TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
344        other => ApiError::engine(other),
345    }
346}
347
348fn map_run_store_err(e: RunStoreError) -> ApiError {
349    match e {
350        RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
351        other => ApiError::engine(other),
352    }
353}
354
355// ──────────────────────────────────────────────────────────────────────────
356// UT
357// ──────────────────────────────────────────────────────────────────────────
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use mlua_swarm::application::BlueprintRef;
363    use mlua_swarm::blueprint::{
364        current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
365        CompilerStrategy,
366    };
367    use mlua_swarm::core::config::EngineCfg;
368    use mlua_swarm::core::engine::Engine;
369    use mlua_swarm::store::output::InMemoryOutputStore;
370    use mlua_swarm::store::run::InMemoryRunStore;
371    use mlua_swarm::store::task::InMemoryTaskStore;
372    use std::collections::HashMap;
373    use std::sync::Arc;
374    use tokio::sync::Mutex;
375
376    /// A single-step flow.ir Blueprint that always succeeds: `Step { ref:
377    /// "identity", in: lit("hello"), out: $.out }` against the baseline
378    /// `RustFn` identity worker (same shape as `seed_blueprint` in
379    /// `mlua-swarm-cli`'s `serve.rs`, self-contained here rather than
380    /// importing a binary crate).
381    fn identity_blueprint() -> Blueprint {
382        Blueprint {
383            schema_version: current_schema_version(),
384            id: "tasks-test-bp".into(),
385            flow: serde_json::from_value(serde_json::json!({
386                "kind": "step",
387                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
388                "in": {"op": "lit", "value": "hello"},
389                "out": {"op": "path", "at": "$.out"},
390            }))
391            .expect("flow parse"),
392            agents: vec![AgentDef {
393                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
394                kind: AgentKind::RustFn,
395                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
396                profile: None,
397                meta: None,
398            }],
399            operators: vec![],
400            metas: vec![],
401            hints: CompilerHints::default(),
402            strategy: CompilerStrategy::default(),
403            metadata: BlueprintMetadata::default(),
404            spawner_hints: Default::default(),
405            default_agent_kind: AgentKind::Operator,
406            default_operator_kind: None,
407            default_init_ctx: None,
408            default_agent_ctx: None,
409            default_context_policy: None,
410        }
411    }
412
413    /// Minimal `AppState` for handler-level tests — mirrors the construction
414    /// `build_router_full` does internally, but skips the `Router` wrapper so
415    /// tests can call handler functions directly (this crate's established
416    /// unit-test convention; see e.g. `operator_ws::login`'s tests).
417    fn test_state() -> AppState {
418        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
419        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
420        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
421        AppState {
422            engine,
423            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
424            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
425            ws_operator_factory: None,
426            data_store: Arc::new(InMemoryOutputStore::new()),
427            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
428            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
429            task_store: Arc::new(InMemoryTaskStore::new()),
430            run_store: Arc::new(InMemoryRunStore::new()),
431            base_url: None,
432        }
433    }
434
435    fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
436        crate::TaskLaunchRequest {
437            blueprint: BlueprintRef::Inline {
438                value: Box::new(identity_blueprint()),
439            },
440            init_ctx: serde_json::json!({"in": "hello"}),
441            project_root: None,
442            work_dir: None,
443            task_metadata: None,
444            ttl_secs: None,
445            operator: None,
446            operator_sid: None,
447            goal: Some(goal.to_string()),
448        }
449    }
450
451    #[test]
452    fn task_id_serializes_as_bare_string() {
453        // Sanity check for the newtype-struct transparency relied on
454        // throughout this module's response shapes (`TaskId` / `RunId`
455        // serialize as plain JSON strings, not `{"0": "..."}`).
456        let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
457        assert_eq!(v, serde_json::json!("T-abc"));
458    }
459
460    #[tokio::test]
461    async fn post_then_get_drill_down() {
462        let state = test_state();
463
464        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
465            .await
466            .expect("tasks_start")
467            .0;
468        let task_id = posted.task_id.clone();
469        let run_id = posted.run_id.clone();
470
471        // GET /v1/tasks lists it.
472        let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
473            .await
474            .expect("tasks_list")
475            .0;
476        assert!(
477            list.iter().any(|t| t.id == task_id),
478            "task {task_id} missing from list of {} tasks",
479            list.len()
480        );
481
482        // GET /v1/tasks/:id drills down to the Task + its Run.
483        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
484            .await
485            .expect("task_get")
486            .0;
487        assert_eq!(detail.task.id, task_id);
488        assert_eq!(detail.task.goal, "smoke goal");
489        assert_eq!(detail.task.status, TaskRecordStatus::Done);
490        assert_eq!(detail.runs.len(), 1);
491        assert_eq!(detail.runs[0].id, run_id);
492        assert_eq!(detail.runs[0].status, RunStatus::Done);
493
494        // GET /v1/runs/:id returns the same Run directly.
495        let run = run_get(State(state.clone()), Path(run_id.to_string()))
496            .await
497            .expect("run_get")
498            .0;
499        assert_eq!(run.id, run_id);
500        assert_eq!(run.task_id, task_id);
501        assert_eq!(run.result_ref, Some(posted.final_ctx));
502
503        // issue #13 run_id propagation: `POST /v1/tasks` (`run_flow_form`)
504        // wires a `RunContext` into `TaskApplication::handle_with_run`, so
505        // the single dispatched step must be traced into `step_entries`.
506        assert_eq!(
507            run.step_entries.len(),
508            1,
509            "expected one step_entry for the 1-step identity Blueprint, got {:?}",
510            run.step_entries
511        );
512        assert_eq!(
513            run.step_entries[0].step_ref,
514            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
515        );
516        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
517    }
518
519    #[tokio::test]
520    async fn rekick_adds_a_second_run_to_the_same_task() {
521        let state = test_state();
522        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
523            .await
524            .expect("tasks_start")
525            .0;
526        let task_id = posted.task_id.clone();
527        let first_run_id = posted.run_id.clone();
528
529        let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
530            .await
531            .expect("task_rekick");
532        assert_eq!(status, StatusCode::CREATED);
533        let second_run_id = rekicked.0.run_id.clone();
534        assert_ne!(first_run_id, second_run_id);
535
536        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
537            .await
538            .expect("task_get")
539            .0;
540        assert_eq!(
541            detail.runs.len(),
542            2,
543            "expected 2 runs, got {:?}",
544            detail.runs
545        );
546        let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
547        assert!(ids.contains(&&first_run_id));
548        assert!(ids.contains(&&second_run_id));
549
550        // issue #13 run_id propagation: each kick's own `EngineDispatcher`
551        // (built fresh per `TaskApplication::handle_with_run` call) must
552        // trace its own dispatched step into its own `RunRecord` —
553        // independent `step_entries`, not shared/accumulated across kicks.
554        let first_run = detail
555            .runs
556            .iter()
557            .find(|r| r.id == first_run_id)
558            .expect("first run present in detail.runs");
559        let second_run = detail
560            .runs
561            .iter()
562            .find(|r| r.id == second_run_id)
563            .expect("second run present in detail.runs");
564        assert_eq!(
565            first_run.step_entries.len(),
566            1,
567            "first run step_entries: {:?}",
568            first_run.step_entries
569        );
570        assert_eq!(
571            second_run.step_entries.len(),
572            1,
573            "second run step_entries: {:?}",
574            second_run.step_entries
575        );
576        assert_eq!(
577            first_run.step_entries[0].step_ref,
578            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
579        );
580        assert_eq!(
581            second_run.step_entries[0].step_ref,
582            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
583        );
584        assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
585        assert_eq!(
586            second_run.step_entries[0].status,
587            Some("passed".to_string())
588        );
589        assert_ne!(
590            first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
591            "each kick dispatches its own StepId — runs must not share step_entries"
592        );
593    }
594
595    #[tokio::test]
596    async fn rekick_unknown_task_returns_404() {
597        let state = test_state();
598        // `.expect_err()` needs the Ok variant to be `Debug`; `Json<T>`'s
599        // `Debug` impl is not guaranteed for every `T` across axum versions,
600        // so a plain match sidesteps that bound entirely.
601        match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
602            Ok(_) => panic!("expected 404 for an unknown task"),
603            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
604        }
605    }
606
607    // ──────────────────────────────────────────────────────────────────
608    // issue #19 ST4: `RunKickRequest` (optional body / 3-layer merge)
609    // ──────────────────────────────────────────────────────────────────
610
611    /// A single-step flow.ir Blueprint that echoes `$.greeting` into
612    /// `$.out` — unlike [`identity_blueprint`] (a fixed `lit("hello")`
613    /// input), this one reads its `Step.in` from `ctx`, so it observes
614    /// whichever `init_ctx` layer actually won the merge.
615    fn greeting_blueprint() -> Blueprint {
616        Blueprint {
617            schema_version: current_schema_version(),
618            id: "tasks-test-greeting-bp".into(),
619            flow: serde_json::from_value(serde_json::json!({
620                "kind": "step",
621                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
622                "in": {"op": "path", "at": "$.greeting"},
623                "out": {"op": "path", "at": "$.out"},
624            }))
625            .expect("flow parse"),
626            agents: vec![AgentDef {
627                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
628                kind: AgentKind::RustFn,
629                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
630                profile: None,
631                meta: None,
632            }],
633            operators: vec![],
634            metas: vec![],
635            hints: CompilerHints::default(),
636            strategy: CompilerStrategy::default(),
637            metadata: BlueprintMetadata::default(),
638            spawner_hints: Default::default(),
639            default_agent_kind: AgentKind::Operator,
640            default_operator_kind: None,
641            default_init_ctx: None,
642            default_agent_ctx: None,
643            default_context_policy: None,
644        }
645    }
646
647    fn post_greeting_task_req(
648        greeting: &str,
649        project_root: Option<&str>,
650    ) -> crate::TaskLaunchRequest {
651        crate::TaskLaunchRequest {
652            blueprint: BlueprintRef::Inline {
653                value: Box::new(greeting_blueprint()),
654            },
655            init_ctx: serde_json::json!({ "greeting": greeting }),
656            project_root: project_root.map(str::to_string),
657            work_dir: None,
658            task_metadata: None,
659            ttl_secs: None,
660            operator: None,
661            operator_sid: None,
662            goal: Some("st4 rekick goal".to_string()),
663        }
664    }
665
666    #[tokio::test]
667    async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
668        // must_not_simplify #3: a body-less rekick must behave exactly
669        // like pre-#19 — the Task's own `input_ctx` alone seeds the kick.
670        let state = test_state();
671        let posted = crate::tasks_start(
672            State(state.clone()),
673            Json(post_greeting_task_req("from-task", None)),
674        )
675        .await
676        .expect("tasks_start")
677        .0;
678        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
679
680        let (status, rekicked) =
681            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
682                .await
683                .expect("task_rekick");
684        assert_eq!(status, StatusCode::CREATED);
685
686        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
687            .await
688            .expect("run_get")
689            .0;
690        assert_eq!(
691            run.result_ref.expect("result_ref present")["out"]["echoed"],
692            "from-task"
693        );
694    }
695
696    #[tokio::test]
697    async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
698        let state = test_state();
699        let posted = crate::tasks_start(
700            State(state.clone()),
701            Json(post_greeting_task_req("from-task", None)),
702        )
703        .await
704        .expect("tasks_start")
705        .0;
706        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
707
708        let (status, rekicked) = task_rekick(
709            State(state.clone()),
710            Path(posted.task_id.to_string()),
711            Some(Json(RunKickRequest {
712                init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
713                task_input_override: None,
714            })),
715        )
716        .await
717        .expect("task_rekick");
718        assert_eq!(status, StatusCode::CREATED);
719
720        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
721            .await
722            .expect("run_get")
723            .0;
724        assert_eq!(
725            run.result_ref.expect("result_ref present")["out"]["echoed"],
726            "from-run",
727            "Run's init_ctx_override must win over the stored Task input_ctx"
728        );
729    }
730
731    #[tokio::test]
732    async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
733        // Done Criteria: "Task record が task-level canonical fields を
734        // 保持している時の rekick test". A Task created with
735        // `project_root` set gets a `task_input_spec` snapshot; a
736        // body-less rekick must both dispatch successfully (the stored
737        // spec decodes and resolves without erroring) and leave
738        // `TaskRecord.task_input_spec` untouched (must_not_simplify #4 —
739        // a rekick never mutates the stored Task-level snapshot).
740        let state = test_state();
741        let posted = crate::tasks_start(
742            State(state.clone()),
743            Json(post_greeting_task_req("from-task", Some("/repo"))),
744        )
745        .await
746        .expect("tasks_start")
747        .0;
748
749        let before = state
750            .task_store
751            .get(&posted.task_id)
752            .await
753            .expect("task fetch");
754        let before_spec: Option<TaskInputSpec> = before
755            .task_input_spec
756            .as_ref()
757            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
758        assert_eq!(
759            before_spec,
760            Some(TaskInputSpec {
761                project_root: Some("/repo".to_string()),
762                work_dir: None,
763                task_metadata: None,
764            })
765        );
766
767        let (status, _rekicked) =
768            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
769                .await
770                .expect("task_rekick");
771        assert_eq!(status, StatusCode::CREATED);
772
773        let after = state
774            .task_store
775            .get(&posted.task_id)
776            .await
777            .expect("task fetch");
778        assert_eq!(
779            after.task_input_spec, before.task_input_spec,
780            "rekick must not mutate the stored Task-level task_input_spec snapshot"
781        );
782    }
783
784    #[tokio::test]
785    async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
786        // must_not_simplify #4: `task_input_override` wins for this kick
787        // only — the stored `TaskRecord.task_input_spec` is untouched.
788        let state = test_state();
789        let posted = crate::tasks_start(
790            State(state.clone()),
791            Json(post_greeting_task_req("from-task", Some("/repo"))),
792        )
793        .await
794        .expect("tasks_start")
795        .0;
796
797        let (status, _rekicked) = task_rekick(
798            State(state.clone()),
799            Path(posted.task_id.to_string()),
800            Some(Json(RunKickRequest {
801                init_ctx_override: None,
802                task_input_override: Some(TaskInputSpec {
803                    project_root: Some("/override".to_string()),
804                    work_dir: None,
805                    task_metadata: None,
806                }),
807            })),
808        )
809        .await
810        .expect("task_rekick");
811        assert_eq!(status, StatusCode::CREATED);
812
813        let after = state
814            .task_store
815            .get(&posted.task_id)
816            .await
817            .expect("task fetch");
818        let after_spec: Option<TaskInputSpec> = after
819            .task_input_spec
820            .as_ref()
821            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
822        assert_eq!(
823            after_spec,
824            Some(TaskInputSpec {
825                project_root: Some("/repo".to_string()),
826                work_dir: None,
827                task_metadata: None,
828            }),
829            "a per-Run task_input_override must not leak into the stored TaskRecord"
830        );
831    }
832
833    #[tokio::test]
834    async fn run_get_unknown_id_returns_404() {
835        let state = test_state();
836        match run_get(State(state), Path("R-does-not-exist".to_string())).await {
837            Ok(_) => panic!("expected 404 for an unknown run"),
838            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
839        }
840    }
841
842    #[tokio::test]
843    async fn task_get_unknown_id_returns_404() {
844        let state = test_state();
845        match task_get(State(state), Path("T-does-not-exist".to_string())).await {
846            Ok(_) => panic!("expected 404 for an unknown task"),
847            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
848        }
849    }
850}