1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, btree_map::Entry};
4use std::fs;
5use std::future::Future;
6use std::path::{Path, PathBuf};
7
8use crate::cli::ext_resolver::resolve_ext_component_with_dist;
9use crate::config::load_pack_config;
10use crate::flow_resolve::{
11 ensure_sidecar_exists, read_flow_resolve_summary_for_flow, strip_file_uri_prefix,
12};
13use crate::runtime::RuntimeContext;
14use anyhow::{Context, Result, anyhow, bail};
15use clap::Args;
16use greentic_distributor_client::{DistClient, DistOptions};
17use greentic_flow::compile_ygtc_str;
18use greentic_pack::pack_lock::{LockedComponent, PackLockV1, write_pack_lock};
19use greentic_pack::resolver::{ComponentResolver, ResolveReq, ResolvedComponent};
20use greentic_types::cbor::canonical;
21use greentic_types::flow_resolve_summary::{
22 FlowResolveSummarySourceRefV1, FlowResolveSummaryV1, resolve_summary_path_for_flow,
23 write_flow_resolve_summary,
24};
25use greentic_types::schemas::component::v0_6_0::{ComponentDescribe, schema_hash};
26use hex;
27use sha2::{Digest, Sha256};
28use tokio::runtime::Handle;
29use wasmtime::Engine;
30use wasmtime::component::{Component as WasmtimeComponent, Linker};
31
32use crate::component_host_stubs::{
33 DescribeHostState, add_describe_host_imports, stub_remaining_imports,
34};
35
36#[derive(Debug, Args)]
37pub struct ResolveArgs {
38 #[arg(long = "in", value_name = "DIR", default_value = ".")]
40 pub input: PathBuf,
41
42 #[arg(long = "lock", value_name = "FILE")]
44 pub lock: Option<PathBuf>,
45}
46
47pub async fn handle(args: ResolveArgs, runtime: &RuntimeContext, emit_path: bool) -> Result<()> {
48 let pack_dir = args
49 .input
50 .canonicalize()
51 .with_context(|| format!("failed to resolve pack dir {}", args.input.display()))?;
52 let lock_path = resolve_lock_path(&pack_dir, args.lock.as_deref());
53
54 let config = load_pack_config(&pack_dir)?;
55 let mut entries: BTreeMap<String, LockedComponent> = BTreeMap::new();
56 for flow in &config.flows {
57 let compiled = compile_flow(&pack_dir, flow)?;
58 ensure_sidecar_exists(&pack_dir, flow, &compiled, false)?;
59 let summary = read_flow_resolve_summary_for_flow(&pack_dir, flow)?;
60 collect_from_summary(&pack_dir, flow, &summary, &mut entries)?;
61 }
62
63 let mut id_remap: BTreeMap<String, String> = BTreeMap::new();
64 if !entries.is_empty() {
65 let resolver = PackResolver::new(runtime, pack_dir.clone())?;
66 let engine = Engine::default();
67 let mut rekeyed = BTreeMap::new();
68 for (key, mut component) in entries {
69 populate_component_contract(&engine, &resolver, &mut component).await?;
70 if component.component_id != key {
71 id_remap.insert(key, component.component_id.clone());
72 }
73 rekeyed.insert(component.component_id.clone(), component);
74 }
75 entries = rekeyed;
76 }
77
78 if !id_remap.is_empty() {
79 for flow in &config.flows {
80 let flow_path = flow.file.clone();
81 let summary_path = resolve_summary_path_for_flow(&flow_path);
82 if summary_path.exists() {
83 let mut summary = read_flow_resolve_summary_for_flow(&pack_dir, flow)?;
84 let mut changed = false;
85 for node in summary.nodes.values_mut() {
86 let old_id = node.component_id.as_str().to_string();
87 if let Some(new_id) = id_remap.get(&old_id) {
88 node.component_id = new_id.parse().unwrap_or(node.component_id.clone());
89 changed = true;
90 }
91 }
92 if changed {
93 write_flow_resolve_summary(&summary_path, &summary)
94 .map_err(|e| anyhow!("{e}"))?;
95 }
96 }
97 }
98 }
99
100 let lock = PackLockV1::new(entries);
101 write_pack_lock(&lock_path, &lock)?;
102 if emit_path {
103 eprintln!(
104 "{}",
105 crate::cli_i18n::tf("cli.common.wrote_path", &[&lock_path.display().to_string()])
106 );
107 }
108
109 Ok(())
110}
111
112fn compile_flow(pack_dir: &Path, flow: &crate::config::FlowConfig) -> Result<greentic_types::Flow> {
113 let flow_path = if flow.file.is_absolute() {
114 flow.file.clone()
115 } else {
116 pack_dir.join(&flow.file)
117 };
118 let yaml_src = fs::read_to_string(&flow_path)
119 .with_context(|| format!("failed to read flow {}", flow_path.display()))?;
120 compile_ygtc_str(&yaml_src)
121 .with_context(|| format!("failed to compile flow {}", flow_path.display()))
122}
123
124fn resolve_lock_path(pack_dir: &Path, override_path: Option<&Path>) -> PathBuf {
125 match override_path {
126 Some(path) if path.is_absolute() => path.to_path_buf(),
127 Some(path) => pack_dir.join(path),
128 None => pack_dir.join("pack.lock.cbor"),
129 }
130}
131
132fn collect_from_summary(
133 pack_dir: &Path,
134 flow: &crate::config::FlowConfig,
135 doc: &FlowResolveSummaryV1,
136 out: &mut BTreeMap<String, LockedComponent>,
137) -> Result<()> {
138 let mut seen: BTreeMap<String, LockedComponent> = BTreeMap::new();
139
140 for resolve in doc.nodes.values() {
141 let source_ref = &resolve.source;
142 let (reference, digest) = match source_ref {
143 FlowResolveSummarySourceRefV1::Local { path } => {
144 let abs = normalize_local(pack_dir, flow, path)?;
145 let rel = abs.strip_prefix(pack_dir).unwrap_or(abs.as_path());
151 (
152 format!("file://{}", rel.to_string_lossy()),
153 resolve.digest.clone(),
154 )
155 }
156 FlowResolveSummarySourceRefV1::Oci { .. }
157 | FlowResolveSummarySourceRefV1::Repo { .. }
158 | FlowResolveSummarySourceRefV1::Store { .. } => {
159 (format_reference(source_ref), resolve.digest.clone())
160 }
161 FlowResolveSummarySourceRefV1::Ext { r#ref } => {
162 (r#ref.clone(), resolve.digest.clone())
164 }
165 };
166 let component_id = resolve.component_id.clone();
167 let key = component_id.as_str().to_string();
168 let reference_for_insert = reference.clone();
169 let digest_for_insert = digest.clone();
170 let world = resolve.manifest.as_ref().map(|meta| meta.world.clone());
171 let component_version = resolve
172 .manifest
173 .as_ref()
174 .map(|meta| meta.version.to_string());
175 match seen.entry(key) {
176 Entry::Vacant(entry) => {
177 entry.insert(LockedComponent {
178 component_id: component_id.as_str().to_string(),
179 r#ref: Some(reference_for_insert),
180 abi_version: "0.6.0".to_string(),
181 resolved_digest: digest_for_insert,
182 describe_hash: String::new(),
183 operations: Vec::new(),
184 world,
185 component_version,
186 role: None,
187 });
188 }
189 Entry::Occupied(entry) => {
190 let existing = entry.get();
191 if existing.r#ref.as_deref() != Some(reference.as_str())
192 || existing.resolved_digest != digest
193 {
194 bail!(
195 "component {} resolved by nodes points to different artifacts ({}@{} vs {}@{})",
196 component_id.as_str(),
197 existing.r#ref.as_deref().unwrap_or("unknown-ref"),
198 existing.resolved_digest,
199 reference,
200 digest
201 );
202 }
203 }
204 }
205 }
206
207 out.extend(seen);
208
209 Ok(())
210}
211
212async fn populate_component_contract(
213 engine: &Engine,
214 resolver: &dyn ComponentResolver,
215 component: &mut LockedComponent,
216) -> Result<()> {
217 if is_builtin_component(component.component_id.as_str()) {
218 component.describe_hash = "0".repeat(64);
219 component.operations.clear();
220 component.role = Some("builtin".to_string());
221 if component.component_version.is_none() {
222 component.component_version = Some("0.0.0".to_string());
223 }
224 return Ok(());
225 }
226
227 let reference = component
228 .r#ref
229 .as_ref()
230 .ok_or_else(|| anyhow!("component {} missing ref", component.component_id))?;
231 let resolved = resolver.resolve(ResolveReq {
232 component_id: component.component_id.clone(),
233 reference: reference.clone(),
234 expected_digest: component.resolved_digest.clone(),
235 abi_version: component.abi_version.clone(),
236 world: component.world.clone(),
237 component_version: component.component_version.clone(),
238 })?;
239 let bytes = resolved.bytes;
240 component.resolved_digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
241 let use_describe_cache =
242 std::env::var("GREENTIC_PACK_USE_DESCRIBE_CACHE").is_ok() || cfg!(test);
243 let describe = match describe_component(engine, &bytes) {
244 Ok(describe) => describe,
245 Err(err) => {
246 if let Some(describe) = load_describe_from_cache_path(resolved.source_path.as_deref())?
247 {
248 describe
249 } else if is_state_store_tenant_ctx_abi_mismatch(&err)
250 || is_known_host_linker_gap(&err)
251 || is_missing_descriptor_instance(&err)
252 {
253 component.describe_hash = component
256 .resolved_digest
257 .strip_prefix("sha256:")
258 .unwrap_or(component.resolved_digest.as_str())
259 .to_string();
260 component.operations.clear();
261 component.role = Some("unknown".to_string());
262 if component.component_version.is_none() {
263 component.component_version = Some("0.0.0".to_string());
264 }
265 return Ok(());
266 } else if use_describe_cache {
267 return Err(err).context("describe failed and no describe cache present");
268 } else {
269 return Err(err);
270 }
271 }
272 };
273
274 if describe.info.id != component.component_id {
275 eprintln!(
276 "warning: component {} describe id mismatch (expected {}, got {}); using describe id",
277 component.component_id, component.component_id, describe.info.id
278 );
279 component.component_id = describe.info.id.clone();
280 }
281
282 let describe_hash = compute_describe_hash(&describe)?;
283 let mut operations: Vec<_> = describe
284 .operations
285 .iter()
286 .map(|op| {
287 let hash = schema_hash(&op.input.schema, &op.output.schema, &describe.config_schema)
288 .map_err(|err| anyhow!("schema_hash for {}: {}", op.id, err))?;
289 Ok((op.id.clone(), hash))
290 })
291 .collect::<Result<Vec<_>>>()?
292 .into_iter()
293 .map(
294 |(operation_id, schema_hash)| greentic_pack::pack_lock::LockedOperation {
295 operation_id,
296 schema_hash,
297 },
298 )
299 .collect();
300 operations.sort_by(|a, b| a.operation_id.cmp(&b.operation_id));
301
302 component.describe_hash = describe_hash;
303 component.operations = operations;
304 component.role = Some(describe.info.role);
305 component.component_version = Some(describe.info.version);
306 Ok(())
307}
308
309fn is_builtin_component(component_id: &str) -> bool {
313 crate::build::is_builtin_component_id(component_id)
314}
315
316struct PackResolver {
317 runtime: RuntimeContext,
318 dist: DistClient,
319 pack_dir: PathBuf,
320}
321
322impl PackResolver {
323 fn new(runtime: &RuntimeContext, pack_dir: PathBuf) -> Result<Self> {
324 let dist = DistClient::new(DistOptions {
325 cache_dir: runtime.cache_dir(),
326 allow_tags: true,
327 offline: runtime.network_policy() == crate::runtime::NetworkPolicy::Offline,
328 allow_insecure_local_http: false,
329 ..DistOptions::default()
330 });
331 Ok(Self {
332 runtime: runtime.clone(),
333 dist,
334 pack_dir,
335 })
336 }
337}
338
339impl ComponentResolver for PackResolver {
340 fn resolve(&self, req: ResolveReq) -> Result<ResolvedComponent> {
341 if req.reference.starts_with("file://") {
342 let rel = strip_file_uri_prefix(&req.reference);
343 let path = self.pack_dir.join(rel);
346 let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
347 return Ok(ResolvedComponent {
348 bytes,
349 resolved_digest: req.expected_digest,
350 component_id: req.component_id,
351 abi_version: req.abi_version,
352 world: req.world,
353 component_version: req.component_version,
354 source_path: Some(path),
355 });
356 }
357
358 if req.reference.starts_with("ext://") {
359 let offline = self.runtime.network_policy() == crate::runtime::NetworkPolicy::Offline;
360 let handle = Handle::try_current().ok();
361 let (bytes, verified_digest) = resolve_ext_component_with_dist(
362 &self.pack_dir,
363 &req.reference,
364 &self.runtime.cache_dir(),
365 offline,
366 handle.as_ref(),
367 )
368 .with_context(|| format!("resolve ext:// component ref '{}'", req.reference))?;
369 return Ok(ResolvedComponent {
370 bytes,
371 resolved_digest: verified_digest,
372 component_id: req.component_id,
373 abi_version: req.abi_version,
374 world: req.world,
375 component_version: req.component_version,
376 source_path: None,
377 });
378 }
379
380 let handle =
381 Handle::try_current().context("component resolution requires a Tokio runtime")?;
382 let offline = self.runtime.network_policy() == crate::runtime::NetworkPolicy::Offline;
383 let resolved = if offline {
384 self.dist
385 .open_cached(&req.expected_digest)
386 .map_err(|err| anyhow!("offline cache miss for {}: {}", req.reference, err))?
387 } else {
388 let source = self
389 .dist
390 .parse_source(&req.reference)
391 .map_err(|err| anyhow!("resolve {}: {}", req.reference, err))?;
392 let descriptor = block_on(
393 &handle,
394 self.dist
395 .resolve(source, greentic_distributor_client::ResolvePolicy),
396 )
397 .map_err(|err| anyhow!("resolve {}: {}", req.reference, err))?;
398 block_on(
399 &handle,
400 self.dist
401 .fetch(&descriptor, greentic_distributor_client::CachePolicy),
402 )
403 .map_err(|err| anyhow!("resolve {}: {}", req.reference, err))?
404 };
405 let path = resolved
406 .cache_path
407 .ok_or_else(|| anyhow!("resolved component missing path for {}", req.reference))?;
408 let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
409 Ok(ResolvedComponent {
410 bytes,
411 resolved_digest: req.expected_digest,
412 component_id: req.component_id,
413 abi_version: req.abi_version,
414 world: req.world,
415 component_version: req.component_version,
416 source_path: Some(path),
417 })
418 }
419}
420
421fn block_on<F, T, E>(handle: &Handle, fut: F) -> std::result::Result<T, E>
422where
423 F: Future<Output = std::result::Result<T, E>>,
424{
425 tokio::task::block_in_place(|| handle.block_on(fut))
426}
427
428fn describe_component(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
429 describe_component_untyped(engine, bytes)
430}
431
432fn describe_component_untyped(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
433 let component = WasmtimeComponent::from_binary(engine, bytes)
434 .map_err(|err| anyhow!("decode component bytes: {err}"))?;
435 let mut store = wasmtime::Store::new(engine, DescribeHostState::default());
436 let mut linker = Linker::new(engine);
437 add_describe_host_imports(&mut linker)?;
438 stub_remaining_imports(&mut linker, &component)?;
441 let instance = linker
442 .instantiate(&mut store, &component)
443 .map_err(|err| anyhow!("instantiate component root world: {err}"))?;
444
445 let descriptor = [
446 "component-descriptor",
447 "greentic:component/component-descriptor",
448 "greentic:component/component-descriptor@0.6.0",
449 ]
450 .iter()
451 .find_map(|name| instance.get_export_index(&mut store, None, name))
452 .ok_or_else(|| anyhow!("missing exported descriptor instance"))?;
453 let describe_export = [
454 "describe",
455 "greentic:component/component-descriptor@0.6.0#describe",
456 ]
457 .iter()
458 .find_map(|name| instance.get_export_index(&mut store, Some(&descriptor), name))
459 .ok_or_else(|| anyhow!("missing exported describe function"))?;
460 let describe_func = instance
461 .get_typed_func::<(), (Vec<u8>,)>(&mut store, &describe_export)
462 .map_err(|err| anyhow!("lookup component-descriptor.describe: {err}"))?;
463 let (describe_bytes,) = describe_func
464 .call(&mut store, ())
465 .map_err(|err| anyhow!("call component-descriptor.describe: {err}"))?;
466 canonical::from_cbor(&describe_bytes).context("decode ComponentDescribe")
467}
468
469fn load_describe_from_cache_path(path: Option<&Path>) -> Result<Option<ComponentDescribe>> {
470 let Some(path) = path else {
471 return Ok(None);
472 };
473 let describe_path = PathBuf::from(format!("{}.describe.cbor", path.display()));
474 if !describe_path.exists() {
475 return Ok(None);
476 }
477 let bytes =
478 fs::read(&describe_path).with_context(|| format!("read {}", describe_path.display()))?;
479 canonical::ensure_canonical(&bytes).context("describe cache must be canonical")?;
480 let describe = canonical::from_cbor(&bytes).context("decode ComponentDescribe from cache")?;
481 Ok(Some(describe))
482}
483
484fn compute_describe_hash(describe: &ComponentDescribe) -> Result<String> {
485 let bytes =
486 canonical::to_canonical_cbor_allow_floats(describe).context("canonicalize describe")?;
487 let digest = Sha256::digest(bytes.as_slice());
488 Ok(hex::encode(digest))
489}
490
491fn is_state_store_tenant_ctx_abi_mismatch(err: &anyhow::Error) -> bool {
492 let text = format!("{:#}", err);
493 text.contains("greentic:state/state-store@1.0.0")
494 && text.contains("expected record of 19 fields, found 18 fields")
495}
496
497fn is_known_host_linker_gap(err: &anyhow::Error) -> bool {
498 let text = format!("{:#}", err);
499 let missing_impl = text.contains("matching implementation was not found in the linker");
500 missing_impl
501 && (text.contains("greentic:http/http-client@1.1.0")
502 || text.contains("greentic:http/http-client@1.0.0"))
503}
504
505fn is_missing_descriptor_instance(err: &anyhow::Error) -> bool {
506 format!("{:#}", err).contains("missing exported descriptor instance")
507}
508
509fn normalize_local(
510 pack_dir: &Path,
511 flow: &crate::config::FlowConfig,
512 rel: &str,
513) -> Result<PathBuf> {
514 let flow_path = if flow.file.is_absolute() {
515 flow.file.clone()
516 } else {
517 pack_dir.join(&flow.file)
518 };
519 let parent = flow_path
520 .parent()
521 .ok_or_else(|| anyhow!("flow path {} has no parent", flow_path.display()))?;
522 let rel = strip_file_uri_prefix(rel);
523 Ok(parent.join(rel))
524}
525
526fn format_reference(source: &FlowResolveSummarySourceRefV1) -> String {
527 match source {
528 FlowResolveSummarySourceRefV1::Local { path } => path.clone(),
529 FlowResolveSummarySourceRefV1::Oci { r#ref } => {
530 if r#ref.contains("://") {
531 r#ref.clone()
532 } else {
533 format!("oci://{}", r#ref)
534 }
535 }
536 FlowResolveSummarySourceRefV1::Repo { r#ref } => {
537 if r#ref.contains("://") {
538 r#ref.clone()
539 } else {
540 format!("repo://{}", r#ref)
541 }
542 }
543 FlowResolveSummarySourceRefV1::Store { r#ref } => {
544 if r#ref.contains("://") {
545 r#ref.clone()
546 } else {
547 format!("store://{}", r#ref)
548 }
549 }
550 FlowResolveSummarySourceRefV1::Ext { r#ref } => r#ref.clone(),
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::runtime::resolve_runtime;
558 use greentic_types::ComponentId;
559 use greentic_types::flow_resolve::{read_flow_resolve, sidecar_path_for_flow};
560 use greentic_types::flow_resolve_summary::{
561 FlowResolveSummarySourceRefV1, FlowResolveSummaryV1, NodeResolveSummaryV1,
562 read_flow_resolve_summary, resolve_summary_path_for_flow,
563 };
564 use std::collections::BTreeMap;
565 use std::fs;
566 use std::path::PathBuf;
567 use tempfile::TempDir;
568
569 fn sample_flow() -> crate::config::FlowConfig {
570 crate::config::FlowConfig {
571 id: "meetingPrep".to_string(),
572 file: PathBuf::from("flows/main.ygtc"),
573 tags: Vec::new(),
574 entrypoints: Vec::new(),
575 }
576 }
577
578 #[test]
579 fn collect_from_summary_dedups_duplicate_component_ids() {
580 let flow = sample_flow();
581 let pack_dir = PathBuf::from("/tmp");
582 let component_id =
583 ComponentId::new("ai.greentic.component-adaptive-card").expect("valid component id");
584
585 let mut nodes = BTreeMap::new();
586 for name in ["node_one", "node_two"] {
587 nodes.insert(
588 name.to_string(),
589 NodeResolveSummaryV1 {
590 component_id: component_id.clone(),
591 source: FlowResolveSummarySourceRefV1::Oci {
592 r#ref: "oci://ghcr.io/greenticai/components/component-adaptive-card:stable"
593 .to_string(),
594 },
595 digest: format!("sha256:{}", "a".repeat(64)),
596 manifest: None,
597 },
598 );
599 }
600
601 let summary = FlowResolveSummaryV1 {
602 schema_version: 1,
603 flow: "main.ygtc".to_string(),
604 nodes,
605 };
606
607 let mut entries = BTreeMap::new();
608 collect_from_summary(&pack_dir, &flow, &summary, &mut entries).expect("collect entries");
609
610 assert_eq!(entries.len(), 1);
611 let entry = entries.get(component_id.as_str()).expect("component entry");
612 assert_eq!(entry.component_id, component_id.as_str());
613 }
614
615 #[test]
616 fn collect_from_summary_rejects_conflicting_lock_data() {
617 let flow = sample_flow();
618 let pack_dir = PathBuf::from("/tmp");
619 let component_id =
620 ComponentId::new("ai.greentic.component-adaptive-card").expect("valid component id");
621
622 let mut nodes = BTreeMap::new();
623 nodes.insert(
624 "alpha".to_string(),
625 NodeResolveSummaryV1 {
626 component_id: component_id.clone(),
627 source: FlowResolveSummarySourceRefV1::Oci {
628 r#ref: "oci://ghcr.io/greenticai/components/component-adaptive-card:stable"
629 .to_string(),
630 },
631 digest: format!("sha256:{}", "b".repeat(64)),
632 manifest: None,
633 },
634 );
635 nodes.insert(
636 "beta".to_string(),
637 NodeResolveSummaryV1 {
638 component_id: component_id.clone(),
639 source: FlowResolveSummarySourceRefV1::Oci {
640 r#ref: "oci://ghcr.io/greenticai/components/component-adaptive-card:stable"
641 .to_string(),
642 },
643 digest: format!("sha256:{}", "c".repeat(64)),
644 manifest: None,
645 },
646 );
647
648 let summary = FlowResolveSummaryV1 {
649 schema_version: 1,
650 flow: "main.ygtc".to_string(),
651 nodes,
652 };
653
654 let mut entries = BTreeMap::new();
655 let err = collect_from_summary(&pack_dir, &flow, &summary, &mut entries).unwrap_err();
656 assert!(err.to_string().contains("points to different artifacts"));
657 }
658
659 #[tokio::test]
660 async fn handle_creates_missing_sidecar_before_reading_summary() {
661 let temp = TempDir::new().expect("tempdir");
662 let pack_dir = temp.path().join("pack");
663 fs::create_dir_all(pack_dir.join("flows")).expect("create flows dir");
664 fs::write(
665 pack_dir.join("pack.yaml"),
666 r#"pack_id: dev.local.resolve-sidecar
667version: 0.1.0
668kind: application
669publisher: Test
670components: []
671dependencies: []
672flows:
673- id: main
674 file: flows/main.ygtc
675 tags: [default]
676 entrypoints: [default]
677assets: []
678"#,
679 )
680 .expect("write pack yaml");
681 fs::write(
682 pack_dir.join("flows/main.ygtc"),
683 r#"id: main
684type: messaging
685start: hello
686nodes:
687 hello:
688 handle_message:
689 input: "hi"
690 routing:
691 - out: true
692"#,
693 )
694 .expect("write flow");
695
696 let runtime = resolve_runtime(Some(temp.path()), None, true, None).expect("runtime");
697 handle(
698 ResolveArgs {
699 input: pack_dir.clone(),
700 lock: None,
701 },
702 &runtime,
703 false,
704 )
705 .await
706 .expect("resolve should create sidecar instead of failing");
707
708 let flow_cfg = sample_flow();
709 let sidecar_path = sidecar_path_for_flow(&pack_dir.join(&flow_cfg.file));
710 let summary_path = resolve_summary_path_for_flow(&pack_dir.join(&flow_cfg.file));
711 assert!(
712 sidecar_path.exists(),
713 "resolve should create the missing sidecar"
714 );
715 assert!(
716 summary_path.exists(),
717 "resolve should write a summary for the new sidecar"
718 );
719
720 let sidecar = read_flow_resolve(&sidecar_path).expect("read sidecar");
721 assert!(sidecar.nodes.is_empty(), "new sidecar should start empty");
722
723 let summary = read_flow_resolve_summary(&summary_path).expect("read summary");
724 assert!(
725 summary.nodes.is_empty(),
726 "summary should reflect the empty sidecar"
727 );
728
729 let lock_path = pack_dir.join("pack.lock.cbor");
730 assert!(
731 lock_path.exists(),
732 "resolve should still write pack.lock.cbor"
733 );
734 }
735}