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