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