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_kind_overrides: HashMap<String, OperatorKind>,
107 pub task_input: Option<TaskInputSpec>,
115 pub check_policy: Option<CheckPolicy>,
122}
123
124impl TaskApplicationInput {
125 pub fn automate(
133 blueprint: BlueprintRef,
134 operator_id: impl Into<String>,
135 role: Role,
136 ttl: Duration,
137 init_ctx: Value,
138 ) -> Self {
139 Self {
140 blueprint,
141 operator_id: operator_id.into(),
142 role,
143 ttl,
144 init_ctx,
145 operator_kind: None,
146 bridge_id: None,
147 hook_id: None,
148 operator_backend_id: None,
149 operator_kind_overrides: HashMap::new(),
150 task_input: None,
151 check_policy: None,
152 }
153 }
154}
155
156#[derive(Debug, Clone)]
158pub struct TaskApplicationOutput {
159 pub token: CapToken,
161 pub final_ctx: Value,
163 pub bound_version: Option<BlueprintVersion>,
166}
167
168#[derive(Debug, Error)]
171pub enum TaskApplicationError {
172 #[error("store not configured (BlueprintRef::Id requires store)")]
175 NoStore,
176 #[error("store: {0}")]
178 Store(#[from] BlueprintStoreError),
179 #[error("launch: {0}")]
181 Launch(#[from] TaskLaunchError),
182 #[error("invalid semver version_label {label:?}: {source}")]
184 InvalidSemver {
185 label: String,
187 #[source]
189 source: semver::Error,
190 },
191 #[error("no version matches semver req: {req}")]
193 NoMatchingVersion {
194 req: String,
196 },
197}
198
199impl From<SemverResolveError> for TaskApplicationError {
200 fn from(e: SemverResolveError) -> Self {
201 match e {
202 SemverResolveError::Store(e) => TaskApplicationError::Store(e),
203 SemverResolveError::InvalidSemver { label, source } => {
204 TaskApplicationError::InvalidSemver { label, source }
205 }
206 SemverResolveError::NoMatchingVersion { req } => {
207 TaskApplicationError::NoMatchingVersion { req }
208 }
209 }
210 }
211}
212
213pub struct TaskApplication {
216 launch: Arc<TaskLaunchService>,
217 store: Option<Arc<dyn BlueprintStore>>,
220}
221
222impl TaskApplication {
223 pub fn new(launch: Arc<TaskLaunchService>, store: Arc<dyn BlueprintStore>) -> Self {
226 Self {
227 launch,
228 store: Some(store),
229 }
230 }
231
232 pub fn new_inline_only(launch: Arc<TaskLaunchService>) -> Self {
236 Self {
237 launch,
238 store: None,
239 }
240 }
241
242 pub async fn resolve(
245 &self,
246 bp_ref: &BlueprintRef,
247 ) -> Result<(Blueprint, Option<BlueprintVersion>), TaskApplicationError> {
248 match bp_ref {
249 BlueprintRef::Inline { value } => Ok((value.as_ref().clone(), None)),
250 BlueprintRef::Id { id, version } => {
251 let store = self.store.as_ref().ok_or(TaskApplicationError::NoStore)?;
252 let bp_id = id.clone();
253 let traced = match version {
254 VersionSelector::Latest => store.read_head(&bp_id).await?,
255 VersionSelector::Fixed { value } => store.read_version(&bp_id, *value).await?,
256 VersionSelector::SemverReq { req } => {
257 let v = super::semver_resolve::resolve_semver(store.as_ref(), &bp_id, req)
258 .await?;
259 store.read_version(&bp_id, v).await?
260 }
261 };
262 let ver = traced.trace.version;
263 Ok((traced.value, Some(ver)))
264 }
265 }
266 }
267
268 pub async fn precompile(&self, bp_ref: &BlueprintRef) -> Result<(), TaskApplicationError> {
287 let (bp, _v) = self.resolve(bp_ref).await?;
288 self.launch
289 .compiler()
290 .compile(&bp)
291 .map_err(TaskLaunchError::from)?;
292 Ok(())
293 }
294
295 pub async fn handle_with_run(
307 &self,
308 input: TaskApplicationInput,
309 run_ctx: Option<RunContext>,
310 ) -> Result<TaskApplicationOutput, TaskApplicationError> {
311 let (blueprint, bound_version) = self.resolve(&input.blueprint).await?;
312 let TaskLaunchOutput { token, final_ctx } = self
313 .launch
314 .launch(TaskLaunchInput {
315 blueprint,
316 operator_id: input.operator_id,
317 role: input.role,
318 ttl: input.ttl,
319 operator_kind: input.operator_kind,
320 bridge_id: input.bridge_id,
321 hook_id: input.hook_id,
322 operator_backend_id: input.operator_backend_id,
323 operator_kind_overrides: input.operator_kind_overrides,
324 init_ctx: input.init_ctx,
325 run_ctx,
326 task_input: input.task_input,
327 check_policy: input.check_policy,
328 })
329 .await?;
330 Ok(TaskApplicationOutput {
331 token,
332 final_ctx,
333 bound_version,
334 })
335 }
336}
337
338#[async_trait]
339impl Application for TaskApplication {
340 type Input = TaskApplicationInput;
341 type Output = TaskApplicationOutput;
342 type Error = TaskApplicationError;
343
344 fn name(&self) -> &str {
345 "task"
346 }
347
348 async fn handle(&self, input: Self::Input) -> Result<Self::Output, Self::Error> {
354 self.handle_with_run(input, None).await
355 }
356}
357
358#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::blueprint::compiler::{Compiler, SpawnerRegistry};
366 use crate::blueprint::store::{
367 blueprint_version, BlueprintId, BlueprintStore, BlueprintStoreError, CommitMetadata,
368 InMemoryBlueprintStore,
369 };
370 use crate::blueprint::{
371 current_schema_version, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
372 CompilerStrategy,
373 };
374 use crate::core::config::EngineCfg;
375 use crate::core::ctx::OperatorKind;
376 use crate::core::engine::Engine;
377 use mlua_flow_ir::Node as FlowNode;
378
379 fn empty_bp() -> Blueprint {
380 Blueprint {
381 schema_version: current_schema_version(),
382 id: "ut-bp".into(),
383 flow: FlowNode::Seq { children: vec![] },
384 agents: vec![],
385 operators: vec![],
386 metas: vec![],
387 hints: CompilerHints::default(),
388 strategy: CompilerStrategy::default(),
389 metadata: BlueprintMetadata::default(),
390 spawner_hints: Default::default(),
391 default_agent_kind: AgentKind::Operator,
392 default_operator_kind: None,
393 default_init_ctx: None,
394 default_agent_ctx: None,
395 default_context_policy: None,
396 projection_placement: None,
397 audits: vec![],
398 degradation_policy: None,
399 runners: vec![],
400 default_runner: None,
401 subprocesses: vec![],
402 check_policy: None,
403 blueprint_ref_includes: Vec::new(),
404 }
405 }
406
407 fn bp_with_label(id: &str, version_label: Option<&str>) -> Blueprint {
408 Blueprint {
409 schema_version: current_schema_version(),
410 id: id.into(),
411 flow: FlowNode::Seq { children: vec![] },
412 agents: vec![],
413 operators: vec![],
414 metas: vec![],
415 hints: CompilerHints::default(),
416 strategy: CompilerStrategy::default(),
417 metadata: BlueprintMetadata {
418 description: None,
419 origin: Default::default(),
420 tags: vec![],
421 version_label: version_label.map(|s| s.to_string()),
422 project_name_alias: None,
423 default_run_ttl_secs: None,
424 strict_verdict_handling: None,
425 },
426 spawner_hints: Default::default(),
427 default_agent_kind: AgentKind::Operator,
428 default_operator_kind: None,
429 default_init_ctx: None,
430 default_agent_ctx: None,
431 default_context_policy: None,
432 projection_placement: None,
433 audits: vec![],
434 degradation_policy: None,
435 runners: vec![],
436 default_runner: None,
437 subprocesses: vec![],
438 check_policy: None,
439 blueprint_ref_includes: Vec::new(),
440 }
441 }
442
443 fn build_app_with_store() -> (TaskApplication, Arc<dyn BlueprintStore>) {
444 let reg = SpawnerRegistry::new();
445 let compiler = Compiler::new(reg);
446 let engine = Engine::new(EngineCfg::default());
447 let launch = Arc::new(TaskLaunchService::new(engine, compiler));
448 let store: Arc<dyn BlueprintStore> = Arc::new(InMemoryBlueprintStore::new());
449 (TaskApplication::new(launch, store.clone()), store)
450 }
451
452 fn build_app_inline_only() -> TaskApplication {
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 TaskApplication::new_inline_only(launch)
458 }
459
460 async fn seed(store: &Arc<dyn BlueprintStore>, bp: &Blueprint) -> BlueprintVersion {
461 let id = bp.id.clone();
462 let v = blueprint_version(bp).expect("hash");
463 store
464 .write_new(&id, bp, &[], CommitMetadata::seed(id.clone(), v, 0))
465 .await
466 .expect("seed");
467 v
468 }
469
470 #[test]
471 fn automate_helper_sets_defaults() {
472 let input = TaskApplicationInput::automate(
473 BlueprintRef::Inline {
474 value: Box::new(empty_bp()),
475 },
476 "op-1",
477 Role::Operator,
478 Duration::from_secs(10),
479 serde_json::json!({}),
480 );
481 assert!(
482 input.operator_kind.is_none(),
483 "automate() leaves the Runtime Global tier unspecified (None), \
484 not an explicit Some(Automate) override"
485 );
486 assert!(input.bridge_id.is_none());
487 assert!(input.hook_id.is_none());
488 assert_eq!(input.operator_id, "op-1");
489 }
490
491 #[test]
492 fn struct_literal_allows_callback_ids() {
493 let input = TaskApplicationInput {
494 blueprint: BlueprintRef::Inline {
495 value: Box::new(empty_bp()),
496 },
497 operator_id: "op-2".into(),
498 role: Role::Operator,
499 ttl: Duration::from_secs(5),
500 init_ctx: serde_json::json!({}),
501 operator_kind: Some(OperatorKind::MainAi),
502 bridge_id: Some("br-x".into()),
503 hook_id: Some("hk-y".into()),
504 operator_backend_id: None,
505 operator_kind_overrides: HashMap::new(),
506 task_input: None,
507 check_policy: None,
508 };
509 assert!(matches!(input.operator_kind, Some(OperatorKind::MainAi)));
510 assert_eq!(input.bridge_id.as_deref(), Some("br-x"));
511 assert_eq!(input.hook_id.as_deref(), Some("hk-y"));
512 }
513
514 #[tokio::test]
519 async fn resolve_inline_returns_bp_and_no_version() {
520 let app = build_app_inline_only();
521 let bp = empty_bp();
522 let (got, ver) = app
523 .resolve(&BlueprintRef::Inline {
524 value: Box::new(bp.clone()),
525 })
526 .await
527 .expect("resolve inline ok");
528 assert_eq!(got.id, bp.id);
529 assert!(ver.is_none(), "the Inline path yields bound_version=None");
530 }
531
532 #[tokio::test]
533 async fn resolve_id_latest_returns_bp_and_version() {
534 let (app, store) = build_app_with_store();
535 let bp = bp_with_label("rid-latest", Some("0.1.0"));
536 let v = seed(&store, &bp).await;
537 let (got, ver) = app
538 .resolve(&BlueprintRef::Id {
539 id: bp.id.clone(),
540 version: VersionSelector::Latest,
541 })
542 .await
543 .expect("resolve id latest ok");
544 assert_eq!(got.id, bp.id);
545 assert_eq!(ver, Some(v), "Latest = seed version");
546 }
547
548 #[tokio::test]
549 async fn resolve_id_fixed_picks_exact_version() {
550 let (app, store) = build_app_with_store();
551 let id = "rid-fixed";
552 let bp1 = bp_with_label(id, Some("1.0.0"));
553 let bp2 = bp_with_label(id, Some("2.0.0"));
554 let v1 = seed(&store, &bp1).await;
555 let _v2 = seed(&store, &bp2).await;
556 let (got, ver) = app
557 .resolve(&BlueprintRef::Id {
558 id: BlueprintId::new(id),
559 version: VersionSelector::Fixed { value: v1 },
560 })
561 .await
562 .expect("resolve id fixed ok");
563 assert_eq!(ver, Some(v1));
564 assert_eq!(
565 got.metadata.version_label.as_deref(),
566 Some("1.0.0"),
567 "Fixed{{v1}} resolves to v1 = 1.0.0"
568 );
569 }
570
571 #[tokio::test]
572 async fn resolve_id_semver_picks_highest_matching() {
573 let (app, store) = build_app_with_store();
574 let id = "rid-semver";
575 let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
576 let _ = seed(&store, &bp_with_label(id, Some("1.2.0"))).await;
577 let _ = seed(&store, &bp_with_label(id, Some("2.0.0"))).await;
578 let req = semver::VersionReq::parse("^1").expect("req");
579 let (got, ver) = app
580 .resolve(&BlueprintRef::Id {
581 id: BlueprintId::new(id),
582 version: VersionSelector::SemverReq { req },
583 })
584 .await
585 .expect("resolve semver ok");
586 assert!(ver.is_some());
587 assert_eq!(
588 got.metadata.version_label.as_deref(),
589 Some("1.2.0"),
590 "^1 max = 1.2.0 (2.0.0 is out of range; 1.0.0 is lower)"
591 );
592 }
593
594 #[tokio::test]
595 async fn resolve_id_semver_no_match_errs() {
596 let (app, store) = build_app_with_store();
597 let id = "rid-semver-nomatch";
598 let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
599 let req = semver::VersionReq::parse("^3").expect("req");
600 let err = app
601 .resolve(&BlueprintRef::Id {
602 id: BlueprintId::new(id),
603 version: VersionSelector::SemverReq { req },
604 })
605 .await
606 .expect_err("expected NoMatchingVersion");
607 match err {
608 TaskApplicationError::NoMatchingVersion { req } => {
609 assert!(req.contains("^3"), "req string carry: {req}");
610 }
611 other => panic!("expected NoMatchingVersion, got {other:?}"),
612 }
613 }
614
615 #[tokio::test]
616 async fn resolve_id_semver_invalid_label_errs() {
617 let (app, store) = build_app_with_store();
618 let id = "rid-semver-bad";
619 let _ = seed(&store, &bp_with_label(id, Some("not-semver"))).await;
620 let req = semver::VersionReq::parse("^1").expect("req");
621 let err = app
622 .resolve(&BlueprintRef::Id {
623 id: BlueprintId::new(id),
624 version: VersionSelector::SemverReq { req },
625 })
626 .await
627 .expect_err("expected InvalidSemver");
628 match err {
629 TaskApplicationError::InvalidSemver { label, .. } => {
630 assert_eq!(label, "not-semver");
631 }
632 other => panic!("expected InvalidSemver, got {other:?}"),
633 }
634 }
635
636 #[tokio::test]
637 async fn resolve_id_without_store_errs_no_store() {
638 let app = build_app_inline_only();
639 let err = app
640 .resolve(&BlueprintRef::Id {
641 id: BlueprintId::new("anything"),
642 version: VersionSelector::Latest,
643 })
644 .await
645 .expect_err("expected NoStore");
646 assert!(matches!(err, TaskApplicationError::NoStore), "got {err:?}");
647 }
648
649 #[tokio::test]
650 async fn resolve_id_not_found_errs_store() {
651 let (app, _store) = build_app_with_store();
652 let err = app
653 .resolve(&BlueprintRef::Id {
654 id: BlueprintId::new("never-seeded"),
655 version: VersionSelector::Latest,
656 })
657 .await
658 .expect_err("expected Store(IdNotFound|HeadEmpty)");
659 match err {
660 TaskApplicationError::Store(
661 BlueprintStoreError::IdNotFound(_) | BlueprintStoreError::HeadEmpty(_),
662 ) => {}
663 other => panic!("expected Store(IdNotFound|HeadEmpty), got {other:?}"),
664 }
665 }
666}