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::{TaskLaunchError, TaskLaunchInput, TaskLaunchOutput, TaskLaunchService};
14use crate::store::run::RunContext;
15use crate::types::{CapToken, Role};
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::collections::HashMap;
20use std::sync::Arc;
21use std::time::Duration;
22use thiserror::Error;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(tag = "kind", rename_all = "snake_case")]
27pub enum BlueprintRef {
28 Inline {
31 value: Box<Blueprint>,
33 },
34 Id {
36 id: BlueprintId,
38 #[serde(default)]
40 version: VersionSelector,
41 },
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46#[serde(tag = "kind", rename_all = "snake_case")]
47pub enum VersionSelector {
48 #[default]
50 Latest,
51 Fixed {
53 value: BlueprintVersion,
55 },
56 SemverReq {
59 req: semver::VersionReq,
62 },
63}
64
65#[derive(Debug, Clone)]
68pub struct TaskApplicationInput {
69 pub blueprint: BlueprintRef,
72 pub operator_id: String,
74 pub role: Role,
76 pub ttl: Duration,
78 pub init_ctx: Value,
80 pub operator_kind: Option<crate::core::ctx::OperatorKind>,
89 pub bridge_id: Option<String>,
93 pub hook_id: Option<String>,
96 pub operator_backend_id: Option<String>,
99 pub operator_kind_overrides: HashMap<String, OperatorKind>,
104}
105
106impl TaskApplicationInput {
107 pub fn automate(
115 blueprint: BlueprintRef,
116 operator_id: impl Into<String>,
117 role: Role,
118 ttl: Duration,
119 init_ctx: Value,
120 ) -> Self {
121 Self {
122 blueprint,
123 operator_id: operator_id.into(),
124 role,
125 ttl,
126 init_ctx,
127 operator_kind: None,
128 bridge_id: None,
129 hook_id: None,
130 operator_backend_id: None,
131 operator_kind_overrides: HashMap::new(),
132 }
133 }
134}
135
136#[derive(Debug, Clone)]
138pub struct TaskApplicationOutput {
139 pub token: CapToken,
141 pub final_ctx: Value,
143 pub bound_version: Option<BlueprintVersion>,
146}
147
148#[derive(Debug, Error)]
151pub enum TaskApplicationError {
152 #[error("store not configured (BlueprintRef::Id requires store)")]
155 NoStore,
156 #[error("store: {0}")]
158 Store(#[from] BlueprintStoreError),
159 #[error("launch: {0}")]
161 Launch(#[from] TaskLaunchError),
162 #[error("invalid semver version_label {label:?}: {source}")]
164 InvalidSemver {
165 label: String,
167 #[source]
169 source: semver::Error,
170 },
171 #[error("no version matches semver req: {req}")]
173 NoMatchingVersion {
174 req: String,
176 },
177}
178
179impl From<SemverResolveError> for TaskApplicationError {
180 fn from(e: SemverResolveError) -> Self {
181 match e {
182 SemverResolveError::Store(e) => TaskApplicationError::Store(e),
183 SemverResolveError::InvalidSemver { label, source } => {
184 TaskApplicationError::InvalidSemver { label, source }
185 }
186 SemverResolveError::NoMatchingVersion { req } => {
187 TaskApplicationError::NoMatchingVersion { req }
188 }
189 }
190 }
191}
192
193pub struct TaskApplication {
196 launch: Arc<TaskLaunchService>,
197 store: Option<Arc<dyn BlueprintStore>>,
200}
201
202impl TaskApplication {
203 pub fn new(launch: Arc<TaskLaunchService>, store: Arc<dyn BlueprintStore>) -> Self {
206 Self {
207 launch,
208 store: Some(store),
209 }
210 }
211
212 pub fn new_inline_only(launch: Arc<TaskLaunchService>) -> Self {
216 Self {
217 launch,
218 store: None,
219 }
220 }
221
222 pub async fn resolve(
225 &self,
226 bp_ref: &BlueprintRef,
227 ) -> Result<(Blueprint, Option<BlueprintVersion>), TaskApplicationError> {
228 match bp_ref {
229 BlueprintRef::Inline { value } => Ok((value.as_ref().clone(), None)),
230 BlueprintRef::Id { id, version } => {
231 let store = self.store.as_ref().ok_or(TaskApplicationError::NoStore)?;
232 let bp_id = id.clone();
233 let traced = match version {
234 VersionSelector::Latest => store.read_head(&bp_id).await?,
235 VersionSelector::Fixed { value } => store.read_version(&bp_id, *value).await?,
236 VersionSelector::SemverReq { req } => {
237 let v = super::semver_resolve::resolve_semver(store.as_ref(), &bp_id, req)
238 .await?;
239 store.read_version(&bp_id, v).await?
240 }
241 };
242 let ver = traced.trace.version;
243 Ok((traced.value, Some(ver)))
244 }
245 }
246 }
247
248 pub async fn handle_with_run(
260 &self,
261 input: TaskApplicationInput,
262 run_ctx: Option<RunContext>,
263 ) -> Result<TaskApplicationOutput, TaskApplicationError> {
264 let (blueprint, bound_version) = self.resolve(&input.blueprint).await?;
265 let TaskLaunchOutput { token, final_ctx } = self
266 .launch
267 .launch(TaskLaunchInput {
268 blueprint,
269 operator_id: input.operator_id,
270 role: input.role,
271 ttl: input.ttl,
272 operator_kind: input.operator_kind,
273 bridge_id: input.bridge_id,
274 hook_id: input.hook_id,
275 operator_backend_id: input.operator_backend_id,
276 operator_kind_overrides: input.operator_kind_overrides,
277 init_ctx: input.init_ctx,
278 run_ctx,
279 })
280 .await?;
281 Ok(TaskApplicationOutput {
282 token,
283 final_ctx,
284 bound_version,
285 })
286 }
287}
288
289#[async_trait]
290impl Application for TaskApplication {
291 type Input = TaskApplicationInput;
292 type Output = TaskApplicationOutput;
293 type Error = TaskApplicationError;
294
295 fn name(&self) -> &str {
296 "task"
297 }
298
299 async fn handle(&self, input: Self::Input) -> Result<Self::Output, Self::Error> {
305 self.handle_with_run(input, None).await
306 }
307}
308
309#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::blueprint::compiler::{Compiler, SpawnerRegistry};
317 use crate::blueprint::store::{
318 blueprint_version, BlueprintId, BlueprintStore, BlueprintStoreError, CommitMetadata,
319 InMemoryBlueprintStore,
320 };
321 use crate::blueprint::{
322 current_schema_version, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
323 CompilerStrategy,
324 };
325 use crate::core::config::EngineCfg;
326 use crate::core::ctx::OperatorKind;
327 use crate::core::engine::Engine;
328 use mlua_flow_ir::Node as FlowNode;
329
330 fn empty_bp() -> Blueprint {
331 Blueprint {
332 schema_version: current_schema_version(),
333 id: "ut-bp".into(),
334 flow: FlowNode::Seq { children: vec![] },
335 agents: vec![],
336 operators: vec![],
337 hints: CompilerHints::default(),
338 strategy: CompilerStrategy::default(),
339 metadata: BlueprintMetadata::default(),
340 spawner_hints: Default::default(),
341 default_agent_kind: AgentKind::Operator,
342 default_operator_kind: None,
343 }
344 }
345
346 fn bp_with_label(id: &str, version_label: Option<&str>) -> Blueprint {
347 Blueprint {
348 schema_version: current_schema_version(),
349 id: id.into(),
350 flow: FlowNode::Seq { children: vec![] },
351 agents: vec![],
352 operators: vec![],
353 hints: CompilerHints::default(),
354 strategy: CompilerStrategy::default(),
355 metadata: BlueprintMetadata {
356 description: None,
357 origin: Default::default(),
358 tags: vec![],
359 version_label: version_label.map(|s| s.to_string()),
360 project_name_alias: None,
361 default_run_ttl_secs: None,
362 },
363 spawner_hints: Default::default(),
364 default_agent_kind: AgentKind::Operator,
365 default_operator_kind: None,
366 }
367 }
368
369 fn build_app_with_store() -> (TaskApplication, Arc<dyn BlueprintStore>) {
370 let reg = SpawnerRegistry::new();
371 let compiler = Compiler::new(reg);
372 let engine = Engine::new(EngineCfg::default());
373 let launch = Arc::new(TaskLaunchService::new(engine, compiler));
374 let store: Arc<dyn BlueprintStore> = Arc::new(InMemoryBlueprintStore::new());
375 (TaskApplication::new(launch, store.clone()), store)
376 }
377
378 fn build_app_inline_only() -> TaskApplication {
379 let reg = SpawnerRegistry::new();
380 let compiler = Compiler::new(reg);
381 let engine = Engine::new(EngineCfg::default());
382 let launch = Arc::new(TaskLaunchService::new(engine, compiler));
383 TaskApplication::new_inline_only(launch)
384 }
385
386 async fn seed(store: &Arc<dyn BlueprintStore>, bp: &Blueprint) -> BlueprintVersion {
387 let id = bp.id.clone();
388 let v = blueprint_version(bp).expect("hash");
389 store
390 .write_new(&id, bp, &[], CommitMetadata::seed(id.clone(), v, 0))
391 .await
392 .expect("seed");
393 v
394 }
395
396 #[test]
397 fn automate_helper_sets_defaults() {
398 let input = TaskApplicationInput::automate(
399 BlueprintRef::Inline {
400 value: Box::new(empty_bp()),
401 },
402 "op-1",
403 Role::Operator,
404 Duration::from_secs(10),
405 serde_json::json!({}),
406 );
407 assert!(
408 input.operator_kind.is_none(),
409 "automate() leaves the Runtime Global tier unspecified (None), \
410 not an explicit Some(Automate) override"
411 );
412 assert!(input.bridge_id.is_none());
413 assert!(input.hook_id.is_none());
414 assert_eq!(input.operator_id, "op-1");
415 }
416
417 #[test]
418 fn struct_literal_allows_callback_ids() {
419 let input = TaskApplicationInput {
420 blueprint: BlueprintRef::Inline {
421 value: Box::new(empty_bp()),
422 },
423 operator_id: "op-2".into(),
424 role: Role::Operator,
425 ttl: Duration::from_secs(5),
426 init_ctx: serde_json::json!({}),
427 operator_kind: Some(OperatorKind::MainAi),
428 bridge_id: Some("br-x".into()),
429 hook_id: Some("hk-y".into()),
430 operator_backend_id: None,
431 operator_kind_overrides: HashMap::new(),
432 };
433 assert!(matches!(input.operator_kind, Some(OperatorKind::MainAi)));
434 assert_eq!(input.bridge_id.as_deref(), Some("br-x"));
435 assert_eq!(input.hook_id.as_deref(), Some("hk-y"));
436 }
437
438 #[tokio::test]
443 async fn resolve_inline_returns_bp_and_no_version() {
444 let app = build_app_inline_only();
445 let bp = empty_bp();
446 let (got, ver) = app
447 .resolve(&BlueprintRef::Inline {
448 value: Box::new(bp.clone()),
449 })
450 .await
451 .expect("resolve inline ok");
452 assert_eq!(got.id, bp.id);
453 assert!(ver.is_none(), "the Inline path yields bound_version=None");
454 }
455
456 #[tokio::test]
457 async fn resolve_id_latest_returns_bp_and_version() {
458 let (app, store) = build_app_with_store();
459 let bp = bp_with_label("rid-latest", Some("0.1.0"));
460 let v = seed(&store, &bp).await;
461 let (got, ver) = app
462 .resolve(&BlueprintRef::Id {
463 id: bp.id.clone(),
464 version: VersionSelector::Latest,
465 })
466 .await
467 .expect("resolve id latest ok");
468 assert_eq!(got.id, bp.id);
469 assert_eq!(ver, Some(v), "Latest = seed version");
470 }
471
472 #[tokio::test]
473 async fn resolve_id_fixed_picks_exact_version() {
474 let (app, store) = build_app_with_store();
475 let id = "rid-fixed";
476 let bp1 = bp_with_label(id, Some("1.0.0"));
477 let bp2 = bp_with_label(id, Some("2.0.0"));
478 let v1 = seed(&store, &bp1).await;
479 let _v2 = seed(&store, &bp2).await;
480 let (got, ver) = app
481 .resolve(&BlueprintRef::Id {
482 id: BlueprintId::new(id),
483 version: VersionSelector::Fixed { value: v1 },
484 })
485 .await
486 .expect("resolve id fixed ok");
487 assert_eq!(ver, Some(v1));
488 assert_eq!(
489 got.metadata.version_label.as_deref(),
490 Some("1.0.0"),
491 "Fixed{{v1}} resolves to v1 = 1.0.0"
492 );
493 }
494
495 #[tokio::test]
496 async fn resolve_id_semver_picks_highest_matching() {
497 let (app, store) = build_app_with_store();
498 let id = "rid-semver";
499 let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
500 let _ = seed(&store, &bp_with_label(id, Some("1.2.0"))).await;
501 let _ = seed(&store, &bp_with_label(id, Some("2.0.0"))).await;
502 let req = semver::VersionReq::parse("^1").expect("req");
503 let (got, ver) = app
504 .resolve(&BlueprintRef::Id {
505 id: BlueprintId::new(id),
506 version: VersionSelector::SemverReq { req },
507 })
508 .await
509 .expect("resolve semver ok");
510 assert!(ver.is_some());
511 assert_eq!(
512 got.metadata.version_label.as_deref(),
513 Some("1.2.0"),
514 "^1 max = 1.2.0 (2.0.0 is out of range; 1.0.0 is lower)"
515 );
516 }
517
518 #[tokio::test]
519 async fn resolve_id_semver_no_match_errs() {
520 let (app, store) = build_app_with_store();
521 let id = "rid-semver-nomatch";
522 let _ = seed(&store, &bp_with_label(id, Some("1.0.0"))).await;
523 let req = semver::VersionReq::parse("^3").expect("req");
524 let err = app
525 .resolve(&BlueprintRef::Id {
526 id: BlueprintId::new(id),
527 version: VersionSelector::SemverReq { req },
528 })
529 .await
530 .expect_err("expected NoMatchingVersion");
531 match err {
532 TaskApplicationError::NoMatchingVersion { req } => {
533 assert!(req.contains("^3"), "req string carry: {req}");
534 }
535 other => panic!("expected NoMatchingVersion, got {other:?}"),
536 }
537 }
538
539 #[tokio::test]
540 async fn resolve_id_semver_invalid_label_errs() {
541 let (app, store) = build_app_with_store();
542 let id = "rid-semver-bad";
543 let _ = seed(&store, &bp_with_label(id, Some("not-semver"))).await;
544 let req = semver::VersionReq::parse("^1").expect("req");
545 let err = app
546 .resolve(&BlueprintRef::Id {
547 id: BlueprintId::new(id),
548 version: VersionSelector::SemverReq { req },
549 })
550 .await
551 .expect_err("expected InvalidSemver");
552 match err {
553 TaskApplicationError::InvalidSemver { label, .. } => {
554 assert_eq!(label, "not-semver");
555 }
556 other => panic!("expected InvalidSemver, got {other:?}"),
557 }
558 }
559
560 #[tokio::test]
561 async fn resolve_id_without_store_errs_no_store() {
562 let app = build_app_inline_only();
563 let err = app
564 .resolve(&BlueprintRef::Id {
565 id: BlueprintId::new("anything"),
566 version: VersionSelector::Latest,
567 })
568 .await
569 .expect_err("expected NoStore");
570 assert!(matches!(err, TaskApplicationError::NoStore), "got {err:?}");
571 }
572
573 #[tokio::test]
574 async fn resolve_id_not_found_errs_store() {
575 let (app, _store) = build_app_with_store();
576 let err = app
577 .resolve(&BlueprintRef::Id {
578 id: BlueprintId::new("never-seeded"),
579 version: VersionSelector::Latest,
580 })
581 .await
582 .expect_err("expected Store(IdNotFound|HeadEmpty)");
583 match err {
584 TaskApplicationError::Store(
585 BlueprintStoreError::IdNotFound(_) | BlueprintStoreError::HeadEmpty(_),
586 ) => {}
587 other => panic!("expected Store(IdNotFound|HeadEmpty), got {other:?}"),
588 }
589 }
590}