1use super::semver_resolve::SemverResolveError;
9use super::Application;
10use crate::blueprint::store::{BlueprintId, BlueprintStore, BlueprintStoreError, BlueprintVersion};
11use crate::blueprint::Blueprint;
12use crate::core::ctx::OperatorKind;
13use crate::service::{
14 TaskInputSpec, TaskLaunchError, TaskLaunchInput, TaskLaunchOutput, TaskLaunchService,
15};
16use crate::store::run::RunContext;
17use crate::types::{CapToken, Role};
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24use thiserror::Error;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum BlueprintRef {
30 Inline {
33 value: Box<Blueprint>,
35 },
36 Id {
38 id: BlueprintId,
40 #[serde(default)]
42 version: VersionSelector,
43 },
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48#[serde(tag = "kind", rename_all = "snake_case")]
49pub enum VersionSelector {
50 #[default]
52 Latest,
53 Fixed {
55 value: BlueprintVersion,
57 },
58 SemverReq {
61 req: semver::VersionReq,
64 },
65}
66
67#[derive(Debug, Clone)]
70pub struct TaskApplicationInput {
71 pub blueprint: BlueprintRef,
74 pub operator_id: String,
76 pub role: Role,
78 pub ttl: Duration,
80 pub init_ctx: Value,
82 pub operator_kind: Option<crate::core::ctx::OperatorKind>,
91 pub bridge_id: Option<String>,
95 pub hook_id: Option<String>,
98 pub operator_backend_id: Option<String>,
101 pub operator_kind_overrides: HashMap<String, OperatorKind>,
106 pub task_input: Option<TaskInputSpec>,
114}
115
116impl TaskApplicationInput {
117 pub fn automate(
125 blueprint: BlueprintRef,
126 operator_id: impl Into<String>,
127 role: Role,
128 ttl: Duration,
129 init_ctx: Value,
130 ) -> Self {
131 Self {
132 blueprint,
133 operator_id: operator_id.into(),
134 role,
135 ttl,
136 init_ctx,
137 operator_kind: None,
138 bridge_id: None,
139 hook_id: None,
140 operator_backend_id: None,
141 operator_kind_overrides: HashMap::new(),
142 task_input: None,
143 }
144 }
145}
146
147#[derive(Debug, Clone)]
149pub struct TaskApplicationOutput {
150 pub token: CapToken,
152 pub final_ctx: Value,
154 pub bound_version: Option<BlueprintVersion>,
157}
158
159#[derive(Debug, Error)]
162pub enum TaskApplicationError {
163 #[error("store not configured (BlueprintRef::Id requires store)")]
166 NoStore,
167 #[error("store: {0}")]
169 Store(#[from] BlueprintStoreError),
170 #[error("launch: {0}")]
172 Launch(#[from] TaskLaunchError),
173 #[error("invalid semver version_label {label:?}: {source}")]
175 InvalidSemver {
176 label: String,
178 #[source]
180 source: semver::Error,
181 },
182 #[error("no version matches semver req: {req}")]
184 NoMatchingVersion {
185 req: String,
187 },
188}
189
190impl From<SemverResolveError> for TaskApplicationError {
191 fn from(e: SemverResolveError) -> Self {
192 match e {
193 SemverResolveError::Store(e) => TaskApplicationError::Store(e),
194 SemverResolveError::InvalidSemver { label, source } => {
195 TaskApplicationError::InvalidSemver { label, source }
196 }
197 SemverResolveError::NoMatchingVersion { req } => {
198 TaskApplicationError::NoMatchingVersion { req }
199 }
200 }
201 }
202}
203
204pub struct TaskApplication {
207 launch: Arc<TaskLaunchService>,
208 store: Option<Arc<dyn BlueprintStore>>,
211}
212
213impl TaskApplication {
214 pub fn new(launch: Arc<TaskLaunchService>, store: Arc<dyn BlueprintStore>) -> Self {
217 Self {
218 launch,
219 store: Some(store),
220 }
221 }
222
223 pub fn new_inline_only(launch: Arc<TaskLaunchService>) -> Self {
227 Self {
228 launch,
229 store: None,
230 }
231 }
232
233 pub async fn resolve(
236 &self,
237 bp_ref: &BlueprintRef,
238 ) -> Result<(Blueprint, Option<BlueprintVersion>), TaskApplicationError> {
239 match bp_ref {
240 BlueprintRef::Inline { value } => Ok((value.as_ref().clone(), None)),
241 BlueprintRef::Id { id, version } => {
242 let store = self.store.as_ref().ok_or(TaskApplicationError::NoStore)?;
243 let bp_id = id.clone();
244 let traced = match version {
245 VersionSelector::Latest => store.read_head(&bp_id).await?,
246 VersionSelector::Fixed { value } => store.read_version(&bp_id, *value).await?,
247 VersionSelector::SemverReq { req } => {
248 let v = super::semver_resolve::resolve_semver(store.as_ref(), &bp_id, req)
249 .await?;
250 store.read_version(&bp_id, v).await?
251 }
252 };
253 let ver = traced.trace.version;
254 Ok((traced.value, Some(ver)))
255 }
256 }
257 }
258
259 pub async fn handle_with_run(
271 &self,
272 input: TaskApplicationInput,
273 run_ctx: Option<RunContext>,
274 ) -> Result<TaskApplicationOutput, TaskApplicationError> {
275 let (blueprint, bound_version) = self.resolve(&input.blueprint).await?;
276 let TaskLaunchOutput { token, final_ctx } = self
277 .launch
278 .launch(TaskLaunchInput {
279 blueprint,
280 operator_id: input.operator_id,
281 role: input.role,
282 ttl: input.ttl,
283 operator_kind: input.operator_kind,
284 bridge_id: input.bridge_id,
285 hook_id: input.hook_id,
286 operator_backend_id: input.operator_backend_id,
287 operator_kind_overrides: input.operator_kind_overrides,
288 init_ctx: input.init_ctx,
289 run_ctx,
290 task_input: input.task_input,
291 })
292 .await?;
293 Ok(TaskApplicationOutput {
294 token,
295 final_ctx,
296 bound_version,
297 })
298 }
299}
300
301#[async_trait]
302impl Application for TaskApplication {
303 type Input = TaskApplicationInput;
304 type Output = TaskApplicationOutput;
305 type Error = TaskApplicationError;
306
307 fn name(&self) -> &str {
308 "task"
309 }
310
311 async fn handle(&self, input: Self::Input) -> Result<Self::Output, Self::Error> {
317 self.handle_with_run(input, None).await
318 }
319}
320
321#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::blueprint::compiler::{Compiler, SpawnerRegistry};
329 use crate::blueprint::store::{
330 blueprint_version, BlueprintId, BlueprintStore, BlueprintStoreError, CommitMetadata,
331 InMemoryBlueprintStore,
332 };
333 use crate::blueprint::{
334 current_schema_version, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
335 CompilerStrategy,
336 };
337 use crate::core::config::EngineCfg;
338 use crate::core::ctx::OperatorKind;
339 use crate::core::engine::Engine;
340 use mlua_flow_ir::Node as FlowNode;
341
342 fn empty_bp() -> Blueprint {
343 Blueprint {
344 schema_version: current_schema_version(),
345 id: "ut-bp".into(),
346 flow: FlowNode::Seq { children: vec![] },
347 agents: vec![],
348 operators: vec![],
349 metas: vec![],
350 hints: CompilerHints::default(),
351 strategy: CompilerStrategy::default(),
352 metadata: BlueprintMetadata::default(),
353 spawner_hints: Default::default(),
354 default_agent_kind: AgentKind::Operator,
355 default_operator_kind: None,
356 default_init_ctx: None,
357 default_agent_ctx: None,
358 default_context_policy: None,
359 projection_placement: None,
360 audits: vec![],
361 degradation_policy: None,
362 runners: vec![],
363 default_runner: None,
364 }
365 }
366
367 fn bp_with_label(id: &str, version_label: Option<&str>) -> Blueprint {
368 Blueprint {
369 schema_version: current_schema_version(),
370 id: id.into(),
371 flow: FlowNode::Seq { children: vec![] },
372 agents: vec![],
373 operators: vec![],
374 metas: vec![],
375 hints: CompilerHints::default(),
376 strategy: CompilerStrategy::default(),
377 metadata: BlueprintMetadata {
378 description: None,
379 origin: Default::default(),
380 tags: vec![],
381 version_label: version_label.map(|s| s.to_string()),
382 project_name_alias: None,
383 default_run_ttl_secs: None,
384 },
385 spawner_hints: Default::default(),
386 default_agent_kind: AgentKind::Operator,
387 default_operator_kind: None,
388 default_init_ctx: None,
389 default_agent_ctx: None,
390 default_context_policy: None,
391 projection_placement: None,
392 audits: vec![],
393 degradation_policy: None,
394 runners: vec![],
395 default_runner: None,
396 }
397 }
398
399 fn build_app_with_store() -> (TaskApplication, Arc<dyn BlueprintStore>) {
400 let reg = SpawnerRegistry::new();
401 let compiler = Compiler::new(reg);
402 let engine = Engine::new(EngineCfg::default());
403 let launch = Arc::new(TaskLaunchService::new(engine, compiler));
404 let store: Arc<dyn BlueprintStore> = Arc::new(InMemoryBlueprintStore::new());
405 (TaskApplication::new(launch, store.clone()), store)
406 }
407
408 fn build_app_inline_only() -> TaskApplication {
409 let reg = SpawnerRegistry::new();
410 let compiler = Compiler::new(reg);
411 let engine = Engine::new(EngineCfg::default());
412 let launch = Arc::new(TaskLaunchService::new(engine, compiler));
413 TaskApplication::new_inline_only(launch)
414 }
415
416 async fn seed(store: &Arc<dyn BlueprintStore>, bp: &Blueprint) -> BlueprintVersion {
417 let id = bp.id.clone();
418 let v = blueprint_version(bp).expect("hash");
419 store
420 .write_new(&id, bp, &[], CommitMetadata::seed(id.clone(), v, 0))
421 .await
422 .expect("seed");
423 v
424 }
425
426 #[test]
427 fn automate_helper_sets_defaults() {
428 let input = TaskApplicationInput::automate(
429 BlueprintRef::Inline {
430 value: Box::new(empty_bp()),
431 },
432 "op-1",
433 Role::Operator,
434 Duration::from_secs(10),
435 serde_json::json!({}),
436 );
437 assert!(
438 input.operator_kind.is_none(),
439 "automate() leaves the Runtime Global tier unspecified (None), \
440 not an explicit Some(Automate) override"
441 );
442 assert!(input.bridge_id.is_none());
443 assert!(input.hook_id.is_none());
444 assert_eq!(input.operator_id, "op-1");
445 }
446
447 #[test]
448 fn struct_literal_allows_callback_ids() {
449 let input = TaskApplicationInput {
450 blueprint: BlueprintRef::Inline {
451 value: Box::new(empty_bp()),
452 },
453 operator_id: "op-2".into(),
454 role: Role::Operator,
455 ttl: Duration::from_secs(5),
456 init_ctx: serde_json::json!({}),
457 operator_kind: Some(OperatorKind::MainAi),
458 bridge_id: Some("br-x".into()),
459 hook_id: Some("hk-y".into()),
460 operator_backend_id: None,
461 operator_kind_overrides: HashMap::new(),
462 task_input: None,
463 };
464 assert!(matches!(input.operator_kind, Some(OperatorKind::MainAi)));
465 assert_eq!(input.bridge_id.as_deref(), Some("br-x"));
466 assert_eq!(input.hook_id.as_deref(), Some("hk-y"));
467 }
468
469 #[tokio::test]
474 async fn resolve_inline_returns_bp_and_no_version() {
475 let app = build_app_inline_only();
476 let bp = empty_bp();
477 let (got, ver) = app
478 .resolve(&BlueprintRef::Inline {
479 value: Box::new(bp.clone()),
480 })
481 .await
482 .expect("resolve inline ok");
483 assert_eq!(got.id, bp.id);
484 assert!(ver.is_none(), "the Inline path yields bound_version=None");
485 }
486
487 #[tokio::test]
488 async fn resolve_id_latest_returns_bp_and_version() {
489 let (app, store) = build_app_with_store();
490 let bp = bp_with_label("rid-latest", Some("0.1.0"));
491 let v = seed(&store, &bp).await;
492 let (got, ver) = app
493 .resolve(&BlueprintRef::Id {
494 id: bp.id.clone(),
495 version: VersionSelector::Latest,
496 })
497 .await
498 .expect("resolve id latest ok");
499 assert_eq!(got.id, bp.id);
500 assert_eq!(ver, Some(v), "Latest = seed version");
501 }
502
503 #[tokio::test]
504 async fn resolve_id_fixed_picks_exact_version() {
505 let (app, store) = build_app_with_store();
506 let id = "rid-fixed";
507 let bp1 = bp_with_label(id, Some("1.0.0"));
508 let bp2 = bp_with_label(id, Some("2.0.0"));
509 let v1 = seed(&store, &bp1).await;
510 let _v2 = seed(&store, &bp2).await;
511 let (got, ver) = app
512 .resolve(&BlueprintRef::Id {
513 id: BlueprintId::new(id),
514 version: VersionSelector::Fixed { value: v1 },
515 })
516 .await
517 .expect("resolve id fixed ok");
518 assert_eq!(ver, Some(v1));
519 assert_eq!(
520 got.metadata.version_label.as_deref(),
521 Some("1.0.0"),
522 "Fixed{{v1}} resolves to v1 = 1.0.0"
523 );
524 }
525
526 #[tokio::test]
527 async fn resolve_id_semver_picks_highest_matching() {
528 let (app, store) = build_app_with_store();
529 let id = "rid-semver";
530 let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
531 let _ = seed(&store, &bp_with_label(id, Some("1.2.0"))).await;
532 let _ = seed(&store, &bp_with_label(id, Some("2.0.0"))).await;
533 let req = semver::VersionReq::parse("^1").expect("req");
534 let (got, ver) = app
535 .resolve(&BlueprintRef::Id {
536 id: BlueprintId::new(id),
537 version: VersionSelector::SemverReq { req },
538 })
539 .await
540 .expect("resolve semver ok");
541 assert!(ver.is_some());
542 assert_eq!(
543 got.metadata.version_label.as_deref(),
544 Some("1.2.0"),
545 "^1 max = 1.2.0 (2.0.0 is out of range; 1.0.0 is lower)"
546 );
547 }
548
549 #[tokio::test]
550 async fn resolve_id_semver_no_match_errs() {
551 let (app, store) = build_app_with_store();
552 let id = "rid-semver-nomatch";
553 let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
554 let req = semver::VersionReq::parse("^3").expect("req");
555 let err = app
556 .resolve(&BlueprintRef::Id {
557 id: BlueprintId::new(id),
558 version: VersionSelector::SemverReq { req },
559 })
560 .await
561 .expect_err("expected NoMatchingVersion");
562 match err {
563 TaskApplicationError::NoMatchingVersion { req } => {
564 assert!(req.contains("^3"), "req string carry: {req}");
565 }
566 other => panic!("expected NoMatchingVersion, got {other:?}"),
567 }
568 }
569
570 #[tokio::test]
571 async fn resolve_id_semver_invalid_label_errs() {
572 let (app, store) = build_app_with_store();
573 let id = "rid-semver-bad";
574 let _ = seed(&store, &bp_with_label(id, Some("not-semver"))).await;
575 let req = semver::VersionReq::parse("^1").expect("req");
576 let err = app
577 .resolve(&BlueprintRef::Id {
578 id: BlueprintId::new(id),
579 version: VersionSelector::SemverReq { req },
580 })
581 .await
582 .expect_err("expected InvalidSemver");
583 match err {
584 TaskApplicationError::InvalidSemver { label, .. } => {
585 assert_eq!(label, "not-semver");
586 }
587 other => panic!("expected InvalidSemver, got {other:?}"),
588 }
589 }
590
591 #[tokio::test]
592 async fn resolve_id_without_store_errs_no_store() {
593 let app = build_app_inline_only();
594 let err = app
595 .resolve(&BlueprintRef::Id {
596 id: BlueprintId::new("anything"),
597 version: VersionSelector::Latest,
598 })
599 .await
600 .expect_err("expected NoStore");
601 assert!(matches!(err, TaskApplicationError::NoStore), "got {err:?}");
602 }
603
604 #[tokio::test]
605 async fn resolve_id_not_found_errs_store() {
606 let (app, _store) = build_app_with_store();
607 let err = app
608 .resolve(&BlueprintRef::Id {
609 id: BlueprintId::new("never-seeded"),
610 version: VersionSelector::Latest,
611 })
612 .await
613 .expect_err("expected Store(IdNotFound|HeadEmpty)");
614 match err {
615 TaskApplicationError::Store(
616 BlueprintStoreError::IdNotFound(_) | BlueprintStoreError::HeadEmpty(_),
617 ) => {}
618 other => panic!("expected Store(IdNotFound|HeadEmpty), got {other:?}"),
619 }
620 }
621}