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
341/// `pub(crate)` so `crate::projection`'s `GET /v1/tasks/:id/ctx` handler can
342/// reuse this module's existing-Task-existence-check error mapping (same
343/// 404-vs-500 split `task_get` already applies).
344pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
345    match e {
346        TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
347        other => ApiError::engine(other),
348    }
349}
350
351fn map_run_store_err(e: RunStoreError) -> ApiError {
352    match e {
353        RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
354        other => ApiError::engine(other),
355    }
356}
357
358// ──────────────────────────────────────────────────────────────────────────
359// UT
360// ──────────────────────────────────────────────────────────────────────────
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use mlua_swarm::application::BlueprintRef;
366    use mlua_swarm::blueprint::{
367        current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
368        CompilerStrategy,
369    };
370    use mlua_swarm::core::config::EngineCfg;
371    use mlua_swarm::core::engine::Engine;
372    use mlua_swarm::store::output::InMemoryOutputStore;
373    use mlua_swarm::store::run::InMemoryRunStore;
374    use mlua_swarm::store::task::InMemoryTaskStore;
375    use std::collections::HashMap;
376    use std::sync::Arc;
377    use tokio::sync::Mutex;
378
379    /// A single-step flow.ir Blueprint that always succeeds: `Step { ref:
380    /// "identity", in: lit("hello"), out: $.out }` against the baseline
381    /// `RustFn` identity worker (same shape as `seed_blueprint` in
382    /// `mlua-swarm-cli`'s `serve.rs`, self-contained here rather than
383    /// importing a binary crate).
384    fn identity_blueprint() -> Blueprint {
385        Blueprint {
386            schema_version: current_schema_version(),
387            id: "tasks-test-bp".into(),
388            flow: serde_json::from_value(serde_json::json!({
389                "kind": "step",
390                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
391                "in": {"op": "lit", "value": "hello"},
392                "out": {"op": "path", "at": "$.out"},
393            }))
394            .expect("flow parse"),
395            agents: vec![AgentDef {
396                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
397                kind: AgentKind::RustFn,
398                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
399                profile: None,
400                meta: None,
401            }],
402            operators: vec![],
403            metas: vec![],
404            hints: CompilerHints::default(),
405            strategy: CompilerStrategy::default(),
406            metadata: BlueprintMetadata::default(),
407            spawner_hints: Default::default(),
408            default_agent_kind: AgentKind::Operator,
409            default_operator_kind: None,
410            default_init_ctx: None,
411            default_agent_ctx: None,
412            default_context_policy: None,
413        }
414    }
415
416    /// Minimal `AppState` for handler-level tests — mirrors the construction
417    /// `build_router_full` does internally, but skips the `Router` wrapper so
418    /// tests can call handler functions directly (this crate's established
419    /// unit-test convention; see e.g. `operator_ws::login`'s tests).
420    fn test_state() -> AppState {
421        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
422        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
423        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
424        AppState {
425            engine,
426            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
427            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
428            ws_operator_factory: None,
429            data_store: Arc::new(InMemoryOutputStore::new()),
430            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
431            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
432            task_store: Arc::new(InMemoryTaskStore::new()),
433            run_store: Arc::new(InMemoryRunStore::new()),
434            base_url: None,
435        }
436    }
437
438    fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
439        crate::TaskLaunchRequest {
440            blueprint: BlueprintRef::Inline {
441                value: Box::new(identity_blueprint()),
442            },
443            init_ctx: serde_json::json!({"in": "hello"}),
444            project_root: None,
445            work_dir: None,
446            task_metadata: None,
447            ttl_secs: None,
448            operator: None,
449            operator_sid: None,
450            goal: Some(goal.to_string()),
451        }
452    }
453
454    #[test]
455    fn task_id_serializes_as_bare_string() {
456        // Sanity check for the newtype-struct transparency relied on
457        // throughout this module's response shapes (`TaskId` / `RunId`
458        // serialize as plain JSON strings, not `{"0": "..."}`).
459        let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
460        assert_eq!(v, serde_json::json!("T-abc"));
461    }
462
463    #[tokio::test]
464    async fn post_then_get_drill_down() {
465        let state = test_state();
466
467        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
468            .await
469            .expect("tasks_start")
470            .0;
471        let task_id = posted.task_id.clone();
472        let run_id = posted.run_id.clone();
473
474        // GET /v1/tasks lists it.
475        let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
476            .await
477            .expect("tasks_list")
478            .0;
479        assert!(
480            list.iter().any(|t| t.id == task_id),
481            "task {task_id} missing from list of {} tasks",
482            list.len()
483        );
484
485        // GET /v1/tasks/:id drills down to the Task + its Run.
486        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
487            .await
488            .expect("task_get")
489            .0;
490        assert_eq!(detail.task.id, task_id);
491        assert_eq!(detail.task.goal, "smoke goal");
492        assert_eq!(detail.task.status, TaskRecordStatus::Done);
493        assert_eq!(detail.runs.len(), 1);
494        assert_eq!(detail.runs[0].id, run_id);
495        assert_eq!(detail.runs[0].status, RunStatus::Done);
496
497        // GET /v1/runs/:id returns the same Run directly.
498        let run = run_get(State(state.clone()), Path(run_id.to_string()))
499            .await
500            .expect("run_get")
501            .0;
502        assert_eq!(run.id, run_id);
503        assert_eq!(run.task_id, task_id);
504        assert_eq!(run.result_ref, Some(posted.final_ctx));
505
506        // issue #13 run_id propagation: `POST /v1/tasks` (`run_flow_form`)
507        // wires a `RunContext` into `TaskApplication::handle_with_run`, so
508        // the single dispatched step must be traced into `step_entries`.
509        assert_eq!(
510            run.step_entries.len(),
511            1,
512            "expected one step_entry for the 1-step identity Blueprint, got {:?}",
513            run.step_entries
514        );
515        assert_eq!(
516            run.step_entries[0].step_ref,
517            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
518        );
519        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
520    }
521
522    #[tokio::test]
523    async fn rekick_adds_a_second_run_to_the_same_task() {
524        let state = test_state();
525        let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
526            .await
527            .expect("tasks_start")
528            .0;
529        let task_id = posted.task_id.clone();
530        let first_run_id = posted.run_id.clone();
531
532        let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
533            .await
534            .expect("task_rekick");
535        assert_eq!(status, StatusCode::CREATED);
536        let second_run_id = rekicked.0.run_id.clone();
537        assert_ne!(first_run_id, second_run_id);
538
539        let detail = task_get(State(state.clone()), Path(task_id.to_string()))
540            .await
541            .expect("task_get")
542            .0;
543        assert_eq!(
544            detail.runs.len(),
545            2,
546            "expected 2 runs, got {:?}",
547            detail.runs
548        );
549        let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
550        assert!(ids.contains(&&first_run_id));
551        assert!(ids.contains(&&second_run_id));
552
553        // issue #13 run_id propagation: each kick's own `EngineDispatcher`
554        // (built fresh per `TaskApplication::handle_with_run` call) must
555        // trace its own dispatched step into its own `RunRecord` —
556        // independent `step_entries`, not shared/accumulated across kicks.
557        let first_run = detail
558            .runs
559            .iter()
560            .find(|r| r.id == first_run_id)
561            .expect("first run present in detail.runs");
562        let second_run = detail
563            .runs
564            .iter()
565            .find(|r| r.id == second_run_id)
566            .expect("second run present in detail.runs");
567        assert_eq!(
568            first_run.step_entries.len(),
569            1,
570            "first run step_entries: {:?}",
571            first_run.step_entries
572        );
573        assert_eq!(
574            second_run.step_entries.len(),
575            1,
576            "second run step_entries: {:?}",
577            second_run.step_entries
578        );
579        assert_eq!(
580            first_run.step_entries[0].step_ref,
581            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
582        );
583        assert_eq!(
584            second_run.step_entries[0].step_ref,
585            Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
586        );
587        assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
588        assert_eq!(
589            second_run.step_entries[0].status,
590            Some("passed".to_string())
591        );
592        assert_ne!(
593            first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
594            "each kick dispatches its own StepId — runs must not share step_entries"
595        );
596    }
597
598    #[tokio::test]
599    async fn rekick_unknown_task_returns_404() {
600        let state = test_state();
601        // `.expect_err()` needs the Ok variant to be `Debug`; `Json<T>`'s
602        // `Debug` impl is not guaranteed for every `T` across axum versions,
603        // so a plain match sidesteps that bound entirely.
604        match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
605            Ok(_) => panic!("expected 404 for an unknown task"),
606            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
607        }
608    }
609
610    // ──────────────────────────────────────────────────────────────────
611    // issue #19 ST4: `RunKickRequest` (optional body / 3-layer merge)
612    // ──────────────────────────────────────────────────────────────────
613
614    /// A single-step flow.ir Blueprint that echoes `$.greeting` into
615    /// `$.out` — unlike [`identity_blueprint`] (a fixed `lit("hello")`
616    /// input), this one reads its `Step.in` from `ctx`, so it observes
617    /// whichever `init_ctx` layer actually won the merge.
618    fn greeting_blueprint() -> Blueprint {
619        Blueprint {
620            schema_version: current_schema_version(),
621            id: "tasks-test-greeting-bp".into(),
622            flow: serde_json::from_value(serde_json::json!({
623                "kind": "step",
624                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
625                "in": {"op": "path", "at": "$.greeting"},
626                "out": {"op": "path", "at": "$.out"},
627            }))
628            .expect("flow parse"),
629            agents: vec![AgentDef {
630                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
631                kind: AgentKind::RustFn,
632                spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
633                profile: None,
634                meta: None,
635            }],
636            operators: vec![],
637            metas: vec![],
638            hints: CompilerHints::default(),
639            strategy: CompilerStrategy::default(),
640            metadata: BlueprintMetadata::default(),
641            spawner_hints: Default::default(),
642            default_agent_kind: AgentKind::Operator,
643            default_operator_kind: None,
644            default_init_ctx: None,
645            default_agent_ctx: None,
646            default_context_policy: None,
647        }
648    }
649
650    fn post_greeting_task_req(
651        greeting: &str,
652        project_root: Option<&str>,
653    ) -> crate::TaskLaunchRequest {
654        crate::TaskLaunchRequest {
655            blueprint: BlueprintRef::Inline {
656                value: Box::new(greeting_blueprint()),
657            },
658            init_ctx: serde_json::json!({ "greeting": greeting }),
659            project_root: project_root.map(str::to_string),
660            work_dir: None,
661            task_metadata: None,
662            ttl_secs: None,
663            operator: None,
664            operator_sid: None,
665            goal: Some("st4 rekick goal".to_string()),
666        }
667    }
668
669    #[tokio::test]
670    async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
671        // must_not_simplify #3: a body-less rekick must behave exactly
672        // like pre-#19 — the Task's own `input_ctx` alone seeds the kick.
673        let state = test_state();
674        let posted = crate::tasks_start(
675            State(state.clone()),
676            Json(post_greeting_task_req("from-task", None)),
677        )
678        .await
679        .expect("tasks_start")
680        .0;
681        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
682
683        let (status, rekicked) =
684            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
685                .await
686                .expect("task_rekick");
687        assert_eq!(status, StatusCode::CREATED);
688
689        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
690            .await
691            .expect("run_get")
692            .0;
693        assert_eq!(
694            run.result_ref.expect("result_ref present")["out"]["echoed"],
695            "from-task"
696        );
697    }
698
699    #[tokio::test]
700    async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
701        let state = test_state();
702        let posted = crate::tasks_start(
703            State(state.clone()),
704            Json(post_greeting_task_req("from-task", None)),
705        )
706        .await
707        .expect("tasks_start")
708        .0;
709        assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
710
711        let (status, rekicked) = task_rekick(
712            State(state.clone()),
713            Path(posted.task_id.to_string()),
714            Some(Json(RunKickRequest {
715                init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
716                task_input_override: None,
717            })),
718        )
719        .await
720        .expect("task_rekick");
721        assert_eq!(status, StatusCode::CREATED);
722
723        let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
724            .await
725            .expect("run_get")
726            .0;
727        assert_eq!(
728            run.result_ref.expect("result_ref present")["out"]["echoed"],
729            "from-run",
730            "Run's init_ctx_override must win over the stored Task input_ctx"
731        );
732    }
733
734    #[tokio::test]
735    async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
736        // Done Criteria: "Task record が task-level canonical fields を
737        // 保持している時の rekick test". A Task created with
738        // `project_root` set gets a `task_input_spec` snapshot; a
739        // body-less rekick must both dispatch successfully (the stored
740        // spec decodes and resolves without erroring) and leave
741        // `TaskRecord.task_input_spec` untouched (must_not_simplify #4 —
742        // a rekick never mutates the stored Task-level snapshot).
743        let state = test_state();
744        let posted = crate::tasks_start(
745            State(state.clone()),
746            Json(post_greeting_task_req("from-task", Some("/repo"))),
747        )
748        .await
749        .expect("tasks_start")
750        .0;
751
752        let before = state
753            .task_store
754            .get(&posted.task_id)
755            .await
756            .expect("task fetch");
757        let before_spec: Option<TaskInputSpec> = before
758            .task_input_spec
759            .as_ref()
760            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
761        assert_eq!(
762            before_spec,
763            Some(TaskInputSpec {
764                project_root: Some("/repo".to_string()),
765                work_dir: None,
766                task_metadata: None,
767            })
768        );
769
770        let (status, _rekicked) =
771            task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
772                .await
773                .expect("task_rekick");
774        assert_eq!(status, StatusCode::CREATED);
775
776        let after = state
777            .task_store
778            .get(&posted.task_id)
779            .await
780            .expect("task fetch");
781        assert_eq!(
782            after.task_input_spec, before.task_input_spec,
783            "rekick must not mutate the stored Task-level task_input_spec snapshot"
784        );
785    }
786
787    #[tokio::test]
788    async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
789        // must_not_simplify #4: `task_input_override` wins for this kick
790        // only — the stored `TaskRecord.task_input_spec` is untouched.
791        let state = test_state();
792        let posted = crate::tasks_start(
793            State(state.clone()),
794            Json(post_greeting_task_req("from-task", Some("/repo"))),
795        )
796        .await
797        .expect("tasks_start")
798        .0;
799
800        let (status, _rekicked) = task_rekick(
801            State(state.clone()),
802            Path(posted.task_id.to_string()),
803            Some(Json(RunKickRequest {
804                init_ctx_override: None,
805                task_input_override: Some(TaskInputSpec {
806                    project_root: Some("/override".to_string()),
807                    work_dir: None,
808                    task_metadata: None,
809                }),
810            })),
811        )
812        .await
813        .expect("task_rekick");
814        assert_eq!(status, StatusCode::CREATED);
815
816        let after = state
817            .task_store
818            .get(&posted.task_id)
819            .await
820            .expect("task fetch");
821        let after_spec: Option<TaskInputSpec> = after
822            .task_input_spec
823            .as_ref()
824            .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
825        assert_eq!(
826            after_spec,
827            Some(TaskInputSpec {
828                project_root: Some("/repo".to_string()),
829                work_dir: None,
830                task_metadata: None,
831            }),
832            "a per-Run task_input_override must not leak into the stored TaskRecord"
833        );
834    }
835
836    #[tokio::test]
837    async fn run_get_unknown_id_returns_404() {
838        let state = test_state();
839        match run_get(State(state), Path("R-does-not-exist".to_string())).await {
840            Ok(_) => panic!("expected 404 for an unknown run"),
841            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
842        }
843    }
844
845    #[tokio::test]
846    async fn task_get_unknown_id_returns_404() {
847        let state = test_state();
848        match task_get(State(state), Path("T-does-not-exist".to_string())).await {
849            Ok(_) => panic!("expected 404 for an unknown task"),
850            Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
851        }
852    }
853}