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