1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "kind", rename_all = "snake_case")]
30pub enum BlueprintRef {
31 Inline {
34 value: Box<Blueprint>,
36 },
37 Id {
39 id: BlueprintId,
41 #[serde(default)]
43 version: VersionSelector,
44 },
45}
46
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum VersionSelector {
51 #[default]
53 Latest,
54 Fixed {
56 value: BlueprintVersion,
58 },
59 SemverReq {
62 req: semver::VersionReq,
65 },
66}
67
68#[derive(Debug, Clone)]
71pub struct TaskApplicationInput {
72 pub blueprint: BlueprintRef,
75 pub operator_id: String,
77 pub role: Role,
79 pub ttl: Duration,
81 pub init_ctx: Value,
83 pub operator_kind: Option<crate::core::ctx::OperatorKind>,
92 pub bridge_id: Option<String>,
96 pub hook_id: Option<String>,
99 pub operator_backend_id: Option<String>,
102 pub operator_pin: Option<String>,
108 pub operator_kind_overrides: HashMap<String, OperatorKind>,
113 pub task_input: Option<TaskInputSpec>,
121 pub check_policy: Option<CheckPolicy>,
128}
129
130impl TaskApplicationInput {
131 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#[derive(Debug, Clone)]
165pub struct TaskApplicationOutput {
166 pub token: CapToken,
168 pub final_ctx: Value,
170 pub bound_version: Option<BlueprintVersion>,
173}
174
175#[derive(Debug, Error)]
178pub enum TaskApplicationError {
179 #[error("store not configured (BlueprintRef::Id requires store)")]
182 NoStore,
183 #[error("store: {0}")]
185 Store(#[from] BlueprintStoreError),
186 #[error("launch: {0}")]
188 Launch(#[from] TaskLaunchError),
189 #[error("invalid semver version_label {label:?}: {source}")]
191 InvalidSemver {
192 label: String,
194 #[source]
196 source: semver::Error,
197 },
198 #[error("no version matches semver req: {req}")]
200 NoMatchingVersion {
201 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
220pub struct TaskApplication {
223 launch: Arc<TaskLaunchService>,
224 store: Option<Arc<dyn BlueprintStore>>,
227}
228
229impl TaskApplication {
230 pub fn new(launch: Arc<TaskLaunchService>, store: Arc<dyn BlueprintStore>) -> Self {
233 Self {
234 launch,
235 store: Some(store),
236 }
237 }
238
239 pub fn new_inline_only(launch: Arc<TaskLaunchService>) -> Self {
243 Self {
244 launch,
245 store: None,
246 }
247 }
248
249 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 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 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 async fn handle(&self, input: Self::Input) -> Result<Self::Output, Self::Error> {
362 self.handle_with_run(input, None).await
363 }
364}
365
366#[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 },
434 spawner_hints: Default::default(),
435 default_agent_kind: AgentKind::Operator,
436 default_operator_kind: None,
437 default_init_ctx: None,
438 default_agent_ctx: None,
439 default_context_policy: None,
440 projection_placement: None,
441 audits: vec![],
442 degradation_policy: None,
443 runners: vec![],
444 default_runner: None,
445 subprocesses: vec![],
446 check_policy: None,
447 blueprint_ref_includes: Vec::new(),
448 }
449 }
450
451 fn build_app_with_store() -> (TaskApplication, Arc<dyn BlueprintStore>) {
452 let reg = SpawnerRegistry::new();
453 let compiler = Compiler::new(reg);
454 let engine = Engine::new(EngineCfg::default());
455 let launch = Arc::new(TaskLaunchService::new(engine, compiler));
456 let store: Arc<dyn BlueprintStore> = Arc::new(InMemoryBlueprintStore::new());
457 (TaskApplication::new(launch, store.clone()), store)
458 }
459
460 fn build_app_inline_only() -> TaskApplication {
461 let reg = SpawnerRegistry::new();
462 let compiler = Compiler::new(reg);
463 let engine = Engine::new(EngineCfg::default());
464 let launch = Arc::new(TaskLaunchService::new(engine, compiler));
465 TaskApplication::new_inline_only(launch)
466 }
467
468 async fn seed(store: &Arc<dyn BlueprintStore>, bp: &Blueprint) -> BlueprintVersion {
469 let id = bp.id.clone();
470 let v = blueprint_version(bp).expect("hash");
471 store
472 .write_new(&id, bp, &[], CommitMetadata::seed(id.clone(), v, 0))
473 .await
474 .expect("seed");
475 v
476 }
477
478 #[test]
479 fn automate_helper_sets_defaults() {
480 let input = TaskApplicationInput::automate(
481 BlueprintRef::Inline {
482 value: Box::new(empty_bp()),
483 },
484 "op-1",
485 Role::Operator,
486 Duration::from_secs(10),
487 serde_json::json!({}),
488 );
489 assert!(
490 input.operator_kind.is_none(),
491 "automate() leaves the Runtime Global tier unspecified (None), \
492 not an explicit Some(Automate) override"
493 );
494 assert!(input.bridge_id.is_none());
495 assert!(input.hook_id.is_none());
496 assert_eq!(input.operator_id, "op-1");
497 }
498
499 #[test]
500 fn struct_literal_allows_callback_ids() {
501 let input = TaskApplicationInput {
502 blueprint: BlueprintRef::Inline {
503 value: Box::new(empty_bp()),
504 },
505 operator_id: "op-2".into(),
506 role: Role::Operator,
507 ttl: Duration::from_secs(5),
508 init_ctx: serde_json::json!({}),
509 operator_kind: Some(OperatorKind::MainAi),
510 bridge_id: Some("br-x".into()),
511 hook_id: Some("hk-y".into()),
512 operator_backend_id: None,
513 operator_pin: None,
514 operator_kind_overrides: HashMap::new(),
515 task_input: None,
516 check_policy: None,
517 };
518 assert!(matches!(input.operator_kind, Some(OperatorKind::MainAi)));
519 assert_eq!(input.bridge_id.as_deref(), Some("br-x"));
520 assert_eq!(input.hook_id.as_deref(), Some("hk-y"));
521 }
522
523 #[tokio::test]
528 async fn resolve_inline_returns_bp_and_no_version() {
529 let app = build_app_inline_only();
530 let bp = empty_bp();
531 let (got, ver) = app
532 .resolve(&BlueprintRef::Inline {
533 value: Box::new(bp.clone()),
534 })
535 .await
536 .expect("resolve inline ok");
537 assert_eq!(got.id, bp.id);
538 assert!(ver.is_none(), "the Inline path yields bound_version=None");
539 }
540
541 #[tokio::test]
542 async fn resolve_id_latest_returns_bp_and_version() {
543 let (app, store) = build_app_with_store();
544 let bp = bp_with_label("rid-latest", Some("0.1.0"));
545 let v = seed(&store, &bp).await;
546 let (got, ver) = app
547 .resolve(&BlueprintRef::Id {
548 id: bp.id.clone(),
549 version: VersionSelector::Latest,
550 })
551 .await
552 .expect("resolve id latest ok");
553 assert_eq!(got.id, bp.id);
554 assert_eq!(ver, Some(v), "Latest = seed version");
555 }
556
557 #[tokio::test]
558 async fn resolve_id_fixed_picks_exact_version() {
559 let (app, store) = build_app_with_store();
560 let id = "rid-fixed";
561 let bp1 = bp_with_label(id, Some("1.0.0"));
562 let bp2 = bp_with_label(id, Some("2.0.0"));
563 let v1 = seed(&store, &bp1).await;
564 let _v2 = seed(&store, &bp2).await;
565 let (got, ver) = app
566 .resolve(&BlueprintRef::Id {
567 id: BlueprintId::new(id),
568 version: VersionSelector::Fixed { value: v1 },
569 })
570 .await
571 .expect("resolve id fixed ok");
572 assert_eq!(ver, Some(v1));
573 assert_eq!(
574 got.metadata.version_label.as_deref(),
575 Some("1.0.0"),
576 "Fixed{{v1}} resolves to v1 = 1.0.0"
577 );
578 }
579
580 #[tokio::test]
581 async fn resolve_id_semver_picks_highest_matching() {
582 let (app, store) = build_app_with_store();
583 let id = "rid-semver";
584 let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
585 let _ = seed(&store, &bp_with_label(id, Some("1.2.0"))).await;
586 let _ = seed(&store, &bp_with_label(id, Some("2.0.0"))).await;
587 let req = semver::VersionReq::parse("^1").expect("req");
588 let (got, ver) = app
589 .resolve(&BlueprintRef::Id {
590 id: BlueprintId::new(id),
591 version: VersionSelector::SemverReq { req },
592 })
593 .await
594 .expect("resolve semver ok");
595 assert!(ver.is_some());
596 assert_eq!(
597 got.metadata.version_label.as_deref(),
598 Some("1.2.0"),
599 "^1 max = 1.2.0 (2.0.0 is out of range; 1.0.0 is lower)"
600 );
601 }
602
603 #[tokio::test]
604 async fn resolve_id_semver_no_match_errs() {
605 let (app, store) = build_app_with_store();
606 let id = "rid-semver-nomatch";
607 let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
608 let req = semver::VersionReq::parse("^3").expect("req");
609 let err = app
610 .resolve(&BlueprintRef::Id {
611 id: BlueprintId::new(id),
612 version: VersionSelector::SemverReq { req },
613 })
614 .await
615 .expect_err("expected NoMatchingVersion");
616 match err {
617 TaskApplicationError::NoMatchingVersion { req } => {
618 assert!(req.contains("^3"), "req string carry: {req}");
619 }
620 other => panic!("expected NoMatchingVersion, got {other:?}"),
621 }
622 }
623
624 #[tokio::test]
625 async fn resolve_id_semver_invalid_label_errs() {
626 let (app, store) = build_app_with_store();
627 let id = "rid-semver-bad";
628 let _ = seed(&store, &bp_with_label(id, Some("not-semver"))).await;
629 let req = semver::VersionReq::parse("^1").expect("req");
630 let err = app
631 .resolve(&BlueprintRef::Id {
632 id: BlueprintId::new(id),
633 version: VersionSelector::SemverReq { req },
634 })
635 .await
636 .expect_err("expected InvalidSemver");
637 match err {
638 TaskApplicationError::InvalidSemver { label, .. } => {
639 assert_eq!(label, "not-semver");
640 }
641 other => panic!("expected InvalidSemver, got {other:?}"),
642 }
643 }
644
645 #[tokio::test]
646 async fn resolve_id_without_store_errs_no_store() {
647 let app = build_app_inline_only();
648 let err = app
649 .resolve(&BlueprintRef::Id {
650 id: BlueprintId::new("anything"),
651 version: VersionSelector::Latest,
652 })
653 .await
654 .expect_err("expected NoStore");
655 assert!(matches!(err, TaskApplicationError::NoStore), "got {err:?}");
656 }
657
658 #[tokio::test]
659 async fn resolve_id_not_found_errs_store() {
660 let (app, _store) = build_app_with_store();
661 let err = app
662 .resolve(&BlueprintRef::Id {
663 id: BlueprintId::new("never-seeded"),
664 version: VersionSelector::Latest,
665 })
666 .await
667 .expect_err("expected Store(IdNotFound|HeadEmpty)");
668 match err {
669 TaskApplicationError::Store(
670 BlueprintStoreError::IdNotFound(_) | BlueprintStoreError::HeadEmpty(_),
671 ) => {}
672 other => panic!("expected Store(IdNotFound|HeadEmpty), got {other:?}"),
673 }
674 }
675}