Skip to main content

mlua_swarm/application/
task.rs

1//! `TaskApplication` — the `POST /v1/tasks` entry point.
2//!
3//! Input: `BlueprintRef` (Inline / Id) plus a `TaskSpec`. Output:
4//! `(CapToken, StepId, version)`. Once the Blueprint is resolved, the
5//! engine-side operations (`bind` + `attach` + `start_task`) are
6//! delegated to [`TaskLaunchService`].
7
8use super::semver_resolve::SemverResolveError;
9use super::Application;
10use crate::blueprint::store::{BlueprintId, BlueprintStore, BlueprintStoreError, BlueprintVersion};
11use crate::blueprint::Blueprint;
12use crate::core::ctx::OperatorKind;
13use crate::service::{
14    TaskInputSpec, TaskLaunchError, TaskLaunchInput, TaskLaunchOutput, TaskLaunchService,
15};
16use crate::store::run::RunContext;
17use crate::types::{CapToken, Role};
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24use thiserror::Error;
25
26/// How a task entry says the Blueprint should be resolved.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum BlueprintRef {
30    /// The Blueprint value is embedded directly in the request; no
31    /// store lookup happens.
32    Inline {
33        /// The Blueprint to run as-is.
34        value: Box<Blueprint>,
35    },
36    /// Resolve the Blueprint from the `BlueprintStore` by id.
37    Id {
38        /// The `BlueprintId` to look up in the store.
39        id: BlueprintId,
40        /// Which generation to pick; defaults to `Latest`.
41        #[serde(default)]
42        version: VersionSelector,
43    },
44}
45
46/// How to pick a generation — a `version` inside the store.
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48#[serde(tag = "kind", rename_all = "snake_case")]
49pub enum VersionSelector {
50    /// Use the store's current head version.
51    #[default]
52    Latest,
53    /// Use one exact, previously-committed version.
54    Fixed {
55        /// The exact version to read.
56        value: BlueprintVersion,
57    },
58    /// Scan the store's history and pick the highest version whose
59    /// `BlueprintMetadata.version_label` satisfies `req`.
60    SemverReq {
61        /// The semver requirement every candidate label is matched
62        /// against.
63        req: semver::VersionReq,
64    },
65}
66
67/// Input to [`TaskApplication::handle`] — the `POST /v1/tasks` request
68/// body once decoded.
69#[derive(Debug, Clone)]
70pub struct TaskApplicationInput {
71    /// Accepts both Inline (a Blueprint value directly) and Id
72    /// (store fetch + a `VersionSelector`).
73    pub blueprint: BlueprintRef,
74    /// Caller-supplied id for the Operator that owns this run.
75    pub operator_id: String,
76    /// The Operator's role for this run.
77    pub role: Role,
78    /// How long the attached session is allowed to live.
79    pub ttl: Duration,
80    /// Initial `ctx` for flow.ir `eval`. Read by every `Step.in`.
81    pub init_ctx: Value,
82    /// "Runtime Global" tier of the `OperatorKind` cascade. `Some(_)` is
83    /// always an explicit request — including `Some(OperatorKind::Automate)`
84    /// — that outranks the BP-level tiers (`OperatorDef.kind` /
85    /// `Blueprint.default_operator_kind`); `None` leaves it unspecified so
86    /// those tiers / the final default decide. Under `MainAi` /
87    /// `Composite`, `MainAIMiddleware`'s `spawn_hook` before/after
88    /// callbacks become effective. See
89    /// `crate::core::ctx::collapse_operator_kind`.
90    pub operator_kind: Option<crate::core::ctx::OperatorKind>,
91    /// `SeniorBridge` registry ID. `None` — none in use;
92    /// `Some(id)` — attach a bridge previously registered on the
93    /// engine.
94    pub bridge_id: Option<String>,
95    /// `SpawnHook` registry ID. Same shape as above — attach a hook
96    /// previously registered on the engine.
97    pub hook_id: Option<String>,
98    /// Operator registry ID — used on the path that hands the whole
99    /// spawn off to an external Operator.
100    pub operator_backend_id: Option<String>,
101    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
102    /// cascade — per-agent override, keyed by `AgentDef.name`. Empty by
103    /// default. See `crate::core::ctx::collapse_operator_kind` for the full tier
104    /// list.
105    pub operator_kind_overrides: HashMap<String, OperatorKind>,
106    /// Task-level canonical execution context (issue #19 ST2). When
107    /// `Some`, the resolved sibling fields (`project_root` / `work_dir`
108    /// / `task_metadata`) are threaded down to [`TaskLaunchInput`] and
109    /// consumed by
110    /// [`crate::middleware::task_input::TaskInputMiddleware::new_from_fields`].
111    /// `None` — no Task-level context is layered on the spawner stack
112    /// (default; keeps the wire body opt-in).
113    pub task_input: Option<TaskInputSpec>,
114}
115
116impl TaskApplicationInput {
117    /// Helper for existing callers on the default path — no hooks and no
118    /// per-agent `OperatorKind` overrides. Leaves the "Runtime Global" tier
119    /// unspecified (`None`), so the BP-level tiers / final default
120    /// (`OperatorKind::Automate`) decide — this preserves today's
121    /// behaviour for every existing caller without silently forcing
122    /// `Automate` as an explicit override that would outrank a BP-declared
123    /// `MainAi`/`Composite` kind.
124    pub fn automate(
125        blueprint: BlueprintRef,
126        operator_id: impl Into<String>,
127        role: Role,
128        ttl: Duration,
129        init_ctx: Value,
130    ) -> Self {
131        Self {
132            blueprint,
133            operator_id: operator_id.into(),
134            role,
135            ttl,
136            init_ctx,
137            operator_kind: None,
138            bridge_id: None,
139            hook_id: None,
140            operator_backend_id: None,
141            operator_kind_overrides: HashMap::new(),
142            task_input: None,
143        }
144    }
145}
146
147/// Result of a successful [`TaskApplication::handle`] call.
148#[derive(Debug, Clone)]
149pub struct TaskApplicationOutput {
150    /// The capability token for the attached session.
151    pub token: CapToken,
152    /// The final `ctx` after the flow ran to completion.
153    pub final_ctx: Value,
154    /// Only `Some` when resolution went through the store
155    /// (`BlueprintRef::Id`); `None` on the Inline path.
156    pub bound_version: Option<BlueprintVersion>,
157}
158
159/// Failure modes of [`TaskApplication::handle`] and
160/// [`TaskApplication::resolve`].
161#[derive(Debug, Error)]
162pub enum TaskApplicationError {
163    /// `BlueprintRef::Id` was used but this `TaskApplication` was
164    /// built via [`TaskApplication::new_inline_only`] (no store).
165    #[error("store not configured (BlueprintRef::Id requires store)")]
166    NoStore,
167    /// The `BlueprintStore` returned an error while resolving the ref.
168    #[error("store: {0}")]
169    Store(#[from] BlueprintStoreError),
170    /// `TaskLaunchService::launch` failed after resolution succeeded.
171    #[error("launch: {0}")]
172    Launch(#[from] TaskLaunchError),
173    /// A stored version's `version_label` is not valid semver.
174    #[error("invalid semver version_label {label:?}: {source}")]
175    InvalidSemver {
176        /// The offending label string.
177        label: String,
178        /// The underlying semver parse error.
179        #[source]
180        source: semver::Error,
181    },
182    /// No stored version's label satisfies the `SemverReq`.
183    #[error("no version matches semver req: {req}")]
184    NoMatchingVersion {
185        /// The requirement string that matched nothing.
186        req: String,
187    },
188}
189
190impl From<SemverResolveError> for TaskApplicationError {
191    fn from(e: SemverResolveError) -> Self {
192        match e {
193            SemverResolveError::Store(e) => TaskApplicationError::Store(e),
194            SemverResolveError::InvalidSemver { label, source } => {
195                TaskApplicationError::InvalidSemver { label, source }
196            }
197            SemverResolveError::NoMatchingVersion { req } => {
198                TaskApplicationError::NoMatchingVersion { req }
199            }
200        }
201    }
202}
203
204/// The `POST /v1/tasks` [`Application`] — resolves a `BlueprintRef` and
205/// runs it to completion through [`TaskLaunchService`].
206pub struct TaskApplication {
207    launch: Arc<TaskLaunchService>,
208    /// Only needed when resolving `BlueprintRef::Id`; `None` in
209    /// Inline-only mode.
210    store: Option<Arc<dyn BlueprintStore>>,
211}
212
213impl TaskApplication {
214    /// Build a `TaskApplication` that can resolve both `Inline` and
215    /// `Id` `BlueprintRef`s (the `Id` path reads through `store`).
216    pub fn new(launch: Arc<TaskLaunchService>, store: Arc<dyn BlueprintStore>) -> Self {
217        Self {
218            launch,
219            store: Some(store),
220        }
221    }
222
223    /// Build a `TaskApplication` restricted to `Inline` `BlueprintRef`s
224    /// — no store is configured, so `Id` resolution always fails with
225    /// `TaskApplicationError::NoStore`.
226    pub fn new_inline_only(launch: Arc<TaskLaunchService>) -> Self {
227        Self {
228            launch,
229            store: None,
230        }
231    }
232
233    /// Resolve a `BlueprintRef` and return the real Blueprint plus,
234    /// when it went through the store, the resolved version.
235    pub async fn resolve(
236        &self,
237        bp_ref: &BlueprintRef,
238    ) -> Result<(Blueprint, Option<BlueprintVersion>), TaskApplicationError> {
239        match bp_ref {
240            BlueprintRef::Inline { value } => Ok((value.as_ref().clone(), None)),
241            BlueprintRef::Id { id, version } => {
242                let store = self.store.as_ref().ok_or(TaskApplicationError::NoStore)?;
243                let bp_id = id.clone();
244                let traced = match version {
245                    VersionSelector::Latest => store.read_head(&bp_id).await?,
246                    VersionSelector::Fixed { value } => store.read_version(&bp_id, *value).await?,
247                    VersionSelector::SemverReq { req } => {
248                        let v = super::semver_resolve::resolve_semver(store.as_ref(), &bp_id, req)
249                            .await?;
250                        store.read_version(&bp_id, v).await?
251                    }
252                };
253                let ver = traced.trace.version;
254                Ok((traced.value, Some(ver)))
255            }
256        }
257    }
258
259    /// Resolve the `BlueprintRef` (Inline / Id) and run the flow to
260    /// completion through `TaskLaunchService::launch`, threading `run_ctx`
261    /// (issue #13 run_id propagation) into the launch input.
262    ///
263    /// [`Application::handle`] delegates here with `run_ctx: None` — a
264    /// separate method rather than a new field on [`TaskApplicationInput`]
265    /// so the pre-existing exhaustive `TaskApplicationInput { .. }` struct
266    /// literal in `mlua-swarm-cli`'s MCP adapter (which has no `run_ctx`)
267    /// keeps compiling unchanged. Server entry points that mint a `RunId`
268    /// up front (`POST /v1/tasks`, `POST /v1/tasks/:id/runs`) call this
269    /// directly with `Some(run_ctx)`.
270    pub async fn handle_with_run(
271        &self,
272        input: TaskApplicationInput,
273        run_ctx: Option<RunContext>,
274    ) -> Result<TaskApplicationOutput, TaskApplicationError> {
275        let (blueprint, bound_version) = self.resolve(&input.blueprint).await?;
276        let TaskLaunchOutput { token, final_ctx } = self
277            .launch
278            .launch(TaskLaunchInput {
279                blueprint,
280                operator_id: input.operator_id,
281                role: input.role,
282                ttl: input.ttl,
283                operator_kind: input.operator_kind,
284                bridge_id: input.bridge_id,
285                hook_id: input.hook_id,
286                operator_backend_id: input.operator_backend_id,
287                operator_kind_overrides: input.operator_kind_overrides,
288                init_ctx: input.init_ctx,
289                run_ctx,
290                task_input: input.task_input,
291            })
292            .await?;
293        Ok(TaskApplicationOutput {
294            token,
295            final_ctx,
296            bound_version,
297        })
298    }
299}
300
301#[async_trait]
302impl Application for TaskApplication {
303    type Input = TaskApplicationInput;
304    type Output = TaskApplicationOutput;
305    type Error = TaskApplicationError;
306
307    fn name(&self) -> &str {
308        "task"
309    }
310
311    /// Resolve the `BlueprintRef` (Inline / Id) and run the flow to
312    /// completion through `TaskLaunchService::launch`. Delegates to
313    /// [`TaskApplication::handle_with_run`] with `run_ctx: None` (no run
314    /// tracing) — callers that need `RunRecord.step_entries` tracing call
315    /// `handle_with_run` directly instead.
316    async fn handle(&self, input: Self::Input) -> Result<Self::Output, Self::Error> {
317        self.handle_with_run(input, None).await
318    }
319}
320
321// ──────────────────────────────────────────────────────────────────────────
322// UT
323// ──────────────────────────────────────────────────────────────────────────
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::blueprint::compiler::{Compiler, SpawnerRegistry};
329    use crate::blueprint::store::{
330        blueprint_version, BlueprintId, BlueprintStore, BlueprintStoreError, CommitMetadata,
331        InMemoryBlueprintStore,
332    };
333    use crate::blueprint::{
334        current_schema_version, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
335        CompilerStrategy,
336    };
337    use crate::core::config::EngineCfg;
338    use crate::core::ctx::OperatorKind;
339    use crate::core::engine::Engine;
340    use mlua_flow_ir::Node as FlowNode;
341
342    fn empty_bp() -> Blueprint {
343        Blueprint {
344            schema_version: current_schema_version(),
345            id: "ut-bp".into(),
346            flow: FlowNode::Seq { children: vec![] },
347            agents: vec![],
348            operators: vec![],
349            metas: vec![],
350            hints: CompilerHints::default(),
351            strategy: CompilerStrategy::default(),
352            metadata: BlueprintMetadata::default(),
353            spawner_hints: Default::default(),
354            default_agent_kind: AgentKind::Operator,
355            default_operator_kind: None,
356            default_init_ctx: None,
357            default_agent_ctx: None,
358            default_context_policy: None,
359            projection_placement: None,
360            audits: vec![],
361            degradation_policy: None,
362        }
363    }
364
365    fn bp_with_label(id: &str, version_label: Option<&str>) -> Blueprint {
366        Blueprint {
367            schema_version: current_schema_version(),
368            id: id.into(),
369            flow: FlowNode::Seq { children: vec![] },
370            agents: vec![],
371            operators: vec![],
372            metas: vec![],
373            hints: CompilerHints::default(),
374            strategy: CompilerStrategy::default(),
375            metadata: BlueprintMetadata {
376                description: None,
377                origin: Default::default(),
378                tags: vec![],
379                version_label: version_label.map(|s| s.to_string()),
380                project_name_alias: None,
381                default_run_ttl_secs: None,
382            },
383            spawner_hints: Default::default(),
384            default_agent_kind: AgentKind::Operator,
385            default_operator_kind: None,
386            default_init_ctx: None,
387            default_agent_ctx: None,
388            default_context_policy: None,
389            projection_placement: None,
390            audits: vec![],
391            degradation_policy: None,
392        }
393    }
394
395    fn build_app_with_store() -> (TaskApplication, Arc<dyn BlueprintStore>) {
396        let reg = SpawnerRegistry::new();
397        let compiler = Compiler::new(reg);
398        let engine = Engine::new(EngineCfg::default());
399        let launch = Arc::new(TaskLaunchService::new(engine, compiler));
400        let store: Arc<dyn BlueprintStore> = Arc::new(InMemoryBlueprintStore::new());
401        (TaskApplication::new(launch, store.clone()), store)
402    }
403
404    fn build_app_inline_only() -> TaskApplication {
405        let reg = SpawnerRegistry::new();
406        let compiler = Compiler::new(reg);
407        let engine = Engine::new(EngineCfg::default());
408        let launch = Arc::new(TaskLaunchService::new(engine, compiler));
409        TaskApplication::new_inline_only(launch)
410    }
411
412    async fn seed(store: &Arc<dyn BlueprintStore>, bp: &Blueprint) -> BlueprintVersion {
413        let id = bp.id.clone();
414        let v = blueprint_version(bp).expect("hash");
415        store
416            .write_new(&id, bp, &[], CommitMetadata::seed(id.clone(), v, 0))
417            .await
418            .expect("seed");
419        v
420    }
421
422    #[test]
423    fn automate_helper_sets_defaults() {
424        let input = TaskApplicationInput::automate(
425            BlueprintRef::Inline {
426                value: Box::new(empty_bp()),
427            },
428            "op-1",
429            Role::Operator,
430            Duration::from_secs(10),
431            serde_json::json!({}),
432        );
433        assert!(
434            input.operator_kind.is_none(),
435            "automate() leaves the Runtime Global tier unspecified (None), \
436             not an explicit Some(Automate) override"
437        );
438        assert!(input.bridge_id.is_none());
439        assert!(input.hook_id.is_none());
440        assert_eq!(input.operator_id, "op-1");
441    }
442
443    #[test]
444    fn struct_literal_allows_callback_ids() {
445        let input = TaskApplicationInput {
446            blueprint: BlueprintRef::Inline {
447                value: Box::new(empty_bp()),
448            },
449            operator_id: "op-2".into(),
450            role: Role::Operator,
451            ttl: Duration::from_secs(5),
452            init_ctx: serde_json::json!({}),
453            operator_kind: Some(OperatorKind::MainAi),
454            bridge_id: Some("br-x".into()),
455            hook_id: Some("hk-y".into()),
456            operator_backend_id: None,
457            operator_kind_overrides: HashMap::new(),
458            task_input: None,
459        };
460        assert!(matches!(input.operator_kind, Some(OperatorKind::MainAi)));
461        assert_eq!(input.bridge_id.as_deref(), Some("br-x"));
462        assert_eq!(input.hook_id.as_deref(), Some("hk-y"));
463    }
464
465    // ──────────────────────────────────────────────────────────────────
466    // resolve / resolve_semver carve
467    // ──────────────────────────────────────────────────────────────────
468
469    #[tokio::test]
470    async fn resolve_inline_returns_bp_and_no_version() {
471        let app = build_app_inline_only();
472        let bp = empty_bp();
473        let (got, ver) = app
474            .resolve(&BlueprintRef::Inline {
475                value: Box::new(bp.clone()),
476            })
477            .await
478            .expect("resolve inline ok");
479        assert_eq!(got.id, bp.id);
480        assert!(ver.is_none(), "the Inline path yields bound_version=None");
481    }
482
483    #[tokio::test]
484    async fn resolve_id_latest_returns_bp_and_version() {
485        let (app, store) = build_app_with_store();
486        let bp = bp_with_label("rid-latest", Some("0.1.0"));
487        let v = seed(&store, &bp).await;
488        let (got, ver) = app
489            .resolve(&BlueprintRef::Id {
490                id: bp.id.clone(),
491                version: VersionSelector::Latest,
492            })
493            .await
494            .expect("resolve id latest ok");
495        assert_eq!(got.id, bp.id);
496        assert_eq!(ver, Some(v), "Latest = seed version");
497    }
498
499    #[tokio::test]
500    async fn resolve_id_fixed_picks_exact_version() {
501        let (app, store) = build_app_with_store();
502        let id = "rid-fixed";
503        let bp1 = bp_with_label(id, Some("1.0.0"));
504        let bp2 = bp_with_label(id, Some("2.0.0"));
505        let v1 = seed(&store, &bp1).await;
506        let _v2 = seed(&store, &bp2).await;
507        let (got, ver) = app
508            .resolve(&BlueprintRef::Id {
509                id: BlueprintId::new(id),
510                version: VersionSelector::Fixed { value: v1 },
511            })
512            .await
513            .expect("resolve id fixed ok");
514        assert_eq!(ver, Some(v1));
515        assert_eq!(
516            got.metadata.version_label.as_deref(),
517            Some("1.0.0"),
518            "Fixed{{v1}} resolves to v1 = 1.0.0"
519        );
520    }
521
522    #[tokio::test]
523    async fn resolve_id_semver_picks_highest_matching() {
524        let (app, store) = build_app_with_store();
525        let id = "rid-semver";
526        let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
527        let _ = seed(&store, &bp_with_label(id, Some("1.2.0"))).await;
528        let _ = seed(&store, &bp_with_label(id, Some("2.0.0"))).await;
529        let req = semver::VersionReq::parse("^1").expect("req");
530        let (got, ver) = app
531            .resolve(&BlueprintRef::Id {
532                id: BlueprintId::new(id),
533                version: VersionSelector::SemverReq { req },
534            })
535            .await
536            .expect("resolve semver ok");
537        assert!(ver.is_some());
538        assert_eq!(
539            got.metadata.version_label.as_deref(),
540            Some("1.2.0"),
541            "^1 max = 1.2.0 (2.0.0 is out of range; 1.0.0 is lower)"
542        );
543    }
544
545    #[tokio::test]
546    async fn resolve_id_semver_no_match_errs() {
547        let (app, store) = build_app_with_store();
548        let id = "rid-semver-nomatch";
549        let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
550        let req = semver::VersionReq::parse("^3").expect("req");
551        let err = app
552            .resolve(&BlueprintRef::Id {
553                id: BlueprintId::new(id),
554                version: VersionSelector::SemverReq { req },
555            })
556            .await
557            .expect_err("expected NoMatchingVersion");
558        match err {
559            TaskApplicationError::NoMatchingVersion { req } => {
560                assert!(req.contains("^3"), "req string carry: {req}");
561            }
562            other => panic!("expected NoMatchingVersion, got {other:?}"),
563        }
564    }
565
566    #[tokio::test]
567    async fn resolve_id_semver_invalid_label_errs() {
568        let (app, store) = build_app_with_store();
569        let id = "rid-semver-bad";
570        let _ = seed(&store, &bp_with_label(id, Some("not-semver"))).await;
571        let req = semver::VersionReq::parse("^1").expect("req");
572        let err = app
573            .resolve(&BlueprintRef::Id {
574                id: BlueprintId::new(id),
575                version: VersionSelector::SemverReq { req },
576            })
577            .await
578            .expect_err("expected InvalidSemver");
579        match err {
580            TaskApplicationError::InvalidSemver { label, .. } => {
581                assert_eq!(label, "not-semver");
582            }
583            other => panic!("expected InvalidSemver, got {other:?}"),
584        }
585    }
586
587    #[tokio::test]
588    async fn resolve_id_without_store_errs_no_store() {
589        let app = build_app_inline_only();
590        let err = app
591            .resolve(&BlueprintRef::Id {
592                id: BlueprintId::new("anything"),
593                version: VersionSelector::Latest,
594            })
595            .await
596            .expect_err("expected NoStore");
597        assert!(matches!(err, TaskApplicationError::NoStore), "got {err:?}");
598    }
599
600    #[tokio::test]
601    async fn resolve_id_not_found_errs_store() {
602        let (app, _store) = build_app_with_store();
603        let err = app
604            .resolve(&BlueprintRef::Id {
605                id: BlueprintId::new("never-seeded"),
606                version: VersionSelector::Latest,
607            })
608            .await
609            .expect_err("expected Store(IdNotFound|HeadEmpty)");
610        match err {
611            TaskApplicationError::Store(
612                BlueprintStoreError::IdNotFound(_) | BlueprintStoreError::HeadEmpty(_),
613            ) => {}
614            other => panic!("expected Store(IdNotFound|HeadEmpty), got {other:?}"),
615        }
616    }
617}