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 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 #[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}