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