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;
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 (
146 format!("file://{}", abs.to_string_lossy()),
147 resolve.digest.clone(),
148 )
149 }
150 FlowResolveSummarySourceRefV1::Oci { .. }
151 | FlowResolveSummarySourceRefV1::Repo { .. }
152 | FlowResolveSummarySourceRefV1::Store { .. } => {
153 (format_reference(source_ref), resolve.digest.clone())
154 }
155 FlowResolveSummarySourceRefV1::Ext { r#ref } => {
156 (r#ref.clone(), resolve.digest.clone())
158 }
159 };
160 let component_id = resolve.component_id.clone();
161 let key = component_id.as_str().to_string();
162 let reference_for_insert = reference.clone();
163 let digest_for_insert = digest.clone();
164 let world = resolve.manifest.as_ref().map(|meta| meta.world.clone());
165 let component_version = resolve
166 .manifest
167 .as_ref()
168 .map(|meta| meta.version.to_string());
169 match seen.entry(key) {
170 Entry::Vacant(entry) => {
171 entry.insert(LockedComponent {
172 component_id: component_id.as_str().to_string(),
173 r#ref: Some(reference_for_insert),
174 abi_version: "0.6.0".to_string(),
175 resolved_digest: digest_for_insert,
176 describe_hash: String::new(),
177 operations: Vec::new(),
178 world,
179 component_version,
180 role: None,
181 });
182 }
183 Entry::Occupied(entry) => {
184 let existing = entry.get();
185 if existing.r#ref.as_deref() != Some(reference.as_str())
186 || existing.resolved_digest != digest
187 {
188 bail!(
189 "component {} resolved by nodes points to different artifacts ({}@{} vs {}@{})",
190 component_id.as_str(),
191 existing.r#ref.as_deref().unwrap_or("unknown-ref"),
192 existing.resolved_digest,
193 reference,
194 digest
195 );
196 }
197 }
198 }
199 }
200
201 out.extend(seen);
202
203 Ok(())
204}
205
206async fn populate_component_contract(
207 engine: &Engine,
208 resolver: &dyn ComponentResolver,
209 component: &mut LockedComponent,
210) -> Result<()> {
211 if is_builtin_component(component.component_id.as_str()) {
212 component.describe_hash = "0".repeat(64);
213 component.operations.clear();
214 component.role = Some("builtin".to_string());
215 if component.component_version.is_none() {
216 component.component_version = Some("0.0.0".to_string());
217 }
218 return Ok(());
219 }
220
221 let reference = component
222 .r#ref
223 .as_ref()
224 .ok_or_else(|| anyhow!("component {} missing ref", component.component_id))?;
225 let resolved = resolver.resolve(ResolveReq {
226 component_id: component.component_id.clone(),
227 reference: reference.clone(),
228 expected_digest: component.resolved_digest.clone(),
229 abi_version: component.abi_version.clone(),
230 world: component.world.clone(),
231 component_version: component.component_version.clone(),
232 })?;
233 let bytes = resolved.bytes;
234 component.resolved_digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
235 let use_describe_cache =
236 std::env::var("GREENTIC_PACK_USE_DESCRIBE_CACHE").is_ok() || cfg!(test);
237 let describe = match describe_component(engine, &bytes) {
238 Ok(describe) => describe,
239 Err(err) => {
240 if let Some(describe) = load_describe_from_cache_path(resolved.source_path.as_deref())?
241 {
242 describe
243 } else if is_state_store_tenant_ctx_abi_mismatch(&err)
244 || is_known_host_linker_gap(&err)
245 || is_missing_descriptor_instance(&err)
246 {
247 component.describe_hash = component
250 .resolved_digest
251 .strip_prefix("sha256:")
252 .unwrap_or(component.resolved_digest.as_str())
253 .to_string();
254 component.operations.clear();
255 component.role = Some("unknown".to_string());
256 if component.component_version.is_none() {
257 component.component_version = Some("0.0.0".to_string());
258 }
259 return Ok(());
260 } else if use_describe_cache {
261 return Err(err).context("describe failed and no describe cache present");
262 } else {
263 return Err(err);
264 }
265 }
266 };
267
268 if describe.info.id != component.component_id {
269 eprintln!(
270 "warning: component {} describe id mismatch (expected {}, got {}); using describe id",
271 component.component_id, component.component_id, describe.info.id
272 );
273 component.component_id = describe.info.id.clone();
274 }
275
276 let describe_hash = compute_describe_hash(&describe)?;
277 let mut operations: Vec<_> = describe
278 .operations
279 .iter()
280 .map(|op| {
281 let hash = schema_hash(&op.input.schema, &op.output.schema, &describe.config_schema)
282 .map_err(|err| anyhow!("schema_hash for {}: {}", op.id, err))?;
283 Ok((op.id.clone(), hash))
284 })
285 .collect::<Result<Vec<_>>>()?
286 .into_iter()
287 .map(
288 |(operation_id, schema_hash)| greentic_pack::pack_lock::LockedOperation {
289 operation_id,
290 schema_hash,
291 },
292 )
293 .collect();
294 operations.sort_by(|a, b| a.operation_id.cmp(&b.operation_id));
295
296 component.describe_hash = describe_hash;
297 component.operations = operations;
298 component.role = Some(describe.info.role);
299 component.component_version = Some(describe.info.version);
300 Ok(())
301}
302
303fn is_builtin_component(component_id: &str) -> bool {
307 crate::build::is_builtin_component_id(component_id)
308}
309
310struct PackResolver {
311 runtime: RuntimeContext,
312 dist: DistClient,
313 pack_dir: PathBuf,
314}
315
316impl PackResolver {
317 fn new(runtime: &RuntimeContext, pack_dir: PathBuf) -> Result<Self> {
318 let dist = DistClient::new(DistOptions {
319 cache_dir: runtime.cache_dir(),
320 allow_tags: true,
321 offline: runtime.network_policy() == crate::runtime::NetworkPolicy::Offline,
322 allow_insecure_local_http: false,
323 ..DistOptions::default()
324 });
325 Ok(Self {
326 runtime: runtime.clone(),
327 dist,
328 pack_dir,
329 })
330 }
331}
332
333impl ComponentResolver for PackResolver {
334 fn resolve(&self, req: ResolveReq) -> Result<ResolvedComponent> {
335 if req.reference.starts_with("file://") {
336 let path = strip_file_uri_prefix(&req.reference);
337 let bytes = fs::read(path).with_context(|| format!("read {}", path))?;
338 return Ok(ResolvedComponent {
339 bytes,
340 resolved_digest: req.expected_digest,
341 component_id: req.component_id,
342 abi_version: req.abi_version,
343 world: req.world,
344 component_version: req.component_version,
345 source_path: Some(PathBuf::from(path)),
346 });
347 }
348
349 if req.reference.starts_with("ext://") {
350 let (bytes, verified_digest) = resolve_ext_component(&self.pack_dir, &req.reference)
351 .with_context(|| format!("resolve ext:// component ref '{}'", req.reference))?;
352 return Ok(ResolvedComponent {
353 bytes,
354 resolved_digest: verified_digest,
355 component_id: req.component_id,
356 abi_version: req.abi_version,
357 world: req.world,
358 component_version: req.component_version,
359 source_path: None,
360 });
361 }
362
363 let handle =
364 Handle::try_current().context("component resolution requires a Tokio runtime")?;
365 let offline = self.runtime.network_policy() == crate::runtime::NetworkPolicy::Offline;
366 let resolved = if offline {
367 self.dist
368 .open_cached(&req.expected_digest)
369 .map_err(|err| anyhow!("offline cache miss for {}: {}", req.reference, err))?
370 } else {
371 let source = self
372 .dist
373 .parse_source(&req.reference)
374 .map_err(|err| anyhow!("resolve {}: {}", req.reference, err))?;
375 let descriptor = block_on(
376 &handle,
377 self.dist
378 .resolve(source, greentic_distributor_client::ResolvePolicy),
379 )
380 .map_err(|err| anyhow!("resolve {}: {}", req.reference, err))?;
381 block_on(
382 &handle,
383 self.dist
384 .fetch(&descriptor, greentic_distributor_client::CachePolicy),
385 )
386 .map_err(|err| anyhow!("resolve {}: {}", req.reference, err))?
387 };
388 let path = resolved
389 .cache_path
390 .ok_or_else(|| anyhow!("resolved component missing path for {}", req.reference))?;
391 let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
392 Ok(ResolvedComponent {
393 bytes,
394 resolved_digest: req.expected_digest,
395 component_id: req.component_id,
396 abi_version: req.abi_version,
397 world: req.world,
398 component_version: req.component_version,
399 source_path: Some(path),
400 })
401 }
402}
403
404fn block_on<F, T, E>(handle: &Handle, fut: F) -> std::result::Result<T, E>
405where
406 F: Future<Output = std::result::Result<T, E>>,
407{
408 tokio::task::block_in_place(|| handle.block_on(fut))
409}
410
411fn describe_component(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
412 describe_component_untyped(engine, bytes)
413}
414
415fn describe_component_untyped(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
416 let component = WasmtimeComponent::from_binary(engine, bytes)
417 .map_err(|err| anyhow!("decode component bytes: {err}"))?;
418 let mut store = wasmtime::Store::new(engine, DescribeHostState::default());
419 let mut linker = Linker::new(engine);
420 add_describe_host_imports(&mut linker)?;
421 stub_remaining_imports(&mut linker, &component)?;
424 let instance = linker
425 .instantiate(&mut store, &component)
426 .map_err(|err| anyhow!("instantiate component root world: {err}"))?;
427
428 let descriptor = [
429 "component-descriptor",
430 "greentic:component/component-descriptor",
431 "greentic:component/component-descriptor@0.6.0",
432 ]
433 .iter()
434 .find_map(|name| instance.get_export_index(&mut store, None, name))
435 .ok_or_else(|| anyhow!("missing exported descriptor instance"))?;
436 let describe_export = [
437 "describe",
438 "greentic:component/component-descriptor@0.6.0#describe",
439 ]
440 .iter()
441 .find_map(|name| instance.get_export_index(&mut store, Some(&descriptor), name))
442 .ok_or_else(|| anyhow!("missing exported describe function"))?;
443 let describe_func = instance
444 .get_typed_func::<(), (Vec<u8>,)>(&mut store, &describe_export)
445 .map_err(|err| anyhow!("lookup component-descriptor.describe: {err}"))?;
446 let (describe_bytes,) = describe_func
447 .call(&mut store, ())
448 .map_err(|err| anyhow!("call component-descriptor.describe: {err}"))?;
449 canonical::from_cbor(&describe_bytes).context("decode ComponentDescribe")
450}
451
452fn load_describe_from_cache_path(path: Option<&Path>) -> Result<Option<ComponentDescribe>> {
453 let Some(path) = path else {
454 return Ok(None);
455 };
456 let describe_path = PathBuf::from(format!("{}.describe.cbor", path.display()));
457 if !describe_path.exists() {
458 return Ok(None);
459 }
460 let bytes =
461 fs::read(&describe_path).with_context(|| format!("read {}", describe_path.display()))?;
462 canonical::ensure_canonical(&bytes).context("describe cache must be canonical")?;
463 let describe = canonical::from_cbor(&bytes).context("decode ComponentDescribe from cache")?;
464 Ok(Some(describe))
465}
466
467fn compute_describe_hash(describe: &ComponentDescribe) -> Result<String> {
468 let bytes =
469 canonical::to_canonical_cbor_allow_floats(describe).context("canonicalize describe")?;
470 let digest = Sha256::digest(bytes.as_slice());
471 Ok(hex::encode(digest))
472}
473
474fn is_state_store_tenant_ctx_abi_mismatch(err: &anyhow::Error) -> bool {
475 let text = format!("{:#}", err);
476 text.contains("greentic:state/state-store@1.0.0")
477 && text.contains("expected record of 19 fields, found 18 fields")
478}
479
480fn is_known_host_linker_gap(err: &anyhow::Error) -> bool {
481 let text = format!("{:#}", err);
482 let missing_impl = text.contains("matching implementation was not found in the linker");
483 missing_impl
484 && (text.contains("greentic:http/http-client@1.1.0")
485 || text.contains("greentic:http/http-client@1.0.0"))
486}
487
488fn is_missing_descriptor_instance(err: &anyhow::Error) -> bool {
489 format!("{:#}", err).contains("missing exported descriptor instance")
490}
491
492fn normalize_local(
493 pack_dir: &Path,
494 flow: &crate::config::FlowConfig,
495 rel: &str,
496) -> Result<PathBuf> {
497 let flow_path = if flow.file.is_absolute() {
498 flow.file.clone()
499 } else {
500 pack_dir.join(&flow.file)
501 };
502 let parent = flow_path
503 .parent()
504 .ok_or_else(|| anyhow!("flow path {} has no parent", flow_path.display()))?;
505 let rel = strip_file_uri_prefix(rel);
506 Ok(parent.join(rel))
507}
508
509fn format_reference(source: &FlowResolveSummarySourceRefV1) -> String {
510 match source {
511 FlowResolveSummarySourceRefV1::Local { path } => path.clone(),
512 FlowResolveSummarySourceRefV1::Oci { r#ref } => {
513 if r#ref.contains("://") {
514 r#ref.clone()
515 } else {
516 format!("oci://{}", r#ref)
517 }
518 }
519 FlowResolveSummarySourceRefV1::Repo { r#ref } => {
520 if r#ref.contains("://") {
521 r#ref.clone()
522 } else {
523 format!("repo://{}", r#ref)
524 }
525 }
526 FlowResolveSummarySourceRefV1::Store { r#ref } => {
527 if r#ref.contains("://") {
528 r#ref.clone()
529 } else {
530 format!("store://{}", r#ref)
531 }
532 }
533 FlowResolveSummarySourceRefV1::Ext { r#ref } => r#ref.clone(),
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::runtime::resolve_runtime;
541 use greentic_types::ComponentId;
542 use greentic_types::flow_resolve::{read_flow_resolve, sidecar_path_for_flow};
543 use greentic_types::flow_resolve_summary::{
544 FlowResolveSummarySourceRefV1, FlowResolveSummaryV1, NodeResolveSummaryV1,
545 read_flow_resolve_summary, resolve_summary_path_for_flow,
546 };
547 use std::collections::BTreeMap;
548 use std::fs;
549 use std::path::PathBuf;
550 use tempfile::TempDir;
551
552 fn sample_flow() -> crate::config::FlowConfig {
553 crate::config::FlowConfig {
554 id: "meetingPrep".to_string(),
555 file: PathBuf::from("flows/main.ygtc"),
556 tags: Vec::new(),
557 entrypoints: Vec::new(),
558 }
559 }
560
561 #[test]
562 fn collect_from_summary_dedups_duplicate_component_ids() {
563 let flow = sample_flow();
564 let pack_dir = PathBuf::from("/tmp");
565 let component_id =
566 ComponentId::new("ai.greentic.component-adaptive-card").expect("valid component id");
567
568 let mut nodes = BTreeMap::new();
569 for name in ["node_one", "node_two"] {
570 nodes.insert(
571 name.to_string(),
572 NodeResolveSummaryV1 {
573 component_id: component_id.clone(),
574 source: FlowResolveSummarySourceRefV1::Oci {
575 r#ref: "oci://ghcr.io/greenticai/components/component-adaptive-card:stable"
576 .to_string(),
577 },
578 digest: format!("sha256:{}", "a".repeat(64)),
579 manifest: None,
580 },
581 );
582 }
583
584 let summary = FlowResolveSummaryV1 {
585 schema_version: 1,
586 flow: "main.ygtc".to_string(),
587 nodes,
588 };
589
590 let mut entries = BTreeMap::new();
591 collect_from_summary(&pack_dir, &flow, &summary, &mut entries).expect("collect entries");
592
593 assert_eq!(entries.len(), 1);
594 let entry = entries.get(component_id.as_str()).expect("component entry");
595 assert_eq!(entry.component_id, component_id.as_str());
596 }
597
598 #[test]
599 fn collect_from_summary_rejects_conflicting_lock_data() {
600 let flow = sample_flow();
601 let pack_dir = PathBuf::from("/tmp");
602 let component_id =
603 ComponentId::new("ai.greentic.component-adaptive-card").expect("valid component id");
604
605 let mut nodes = BTreeMap::new();
606 nodes.insert(
607 "alpha".to_string(),
608 NodeResolveSummaryV1 {
609 component_id: component_id.clone(),
610 source: FlowResolveSummarySourceRefV1::Oci {
611 r#ref: "oci://ghcr.io/greenticai/components/component-adaptive-card:stable"
612 .to_string(),
613 },
614 digest: format!("sha256:{}", "b".repeat(64)),
615 manifest: None,
616 },
617 );
618 nodes.insert(
619 "beta".to_string(),
620 NodeResolveSummaryV1 {
621 component_id: component_id.clone(),
622 source: FlowResolveSummarySourceRefV1::Oci {
623 r#ref: "oci://ghcr.io/greenticai/components/component-adaptive-card:stable"
624 .to_string(),
625 },
626 digest: format!("sha256:{}", "c".repeat(64)),
627 manifest: None,
628 },
629 );
630
631 let summary = FlowResolveSummaryV1 {
632 schema_version: 1,
633 flow: "main.ygtc".to_string(),
634 nodes,
635 };
636
637 let mut entries = BTreeMap::new();
638 let err = collect_from_summary(&pack_dir, &flow, &summary, &mut entries).unwrap_err();
639 assert!(err.to_string().contains("points to different artifacts"));
640 }
641
642 #[tokio::test]
643 async fn handle_creates_missing_sidecar_before_reading_summary() {
644 let temp = TempDir::new().expect("tempdir");
645 let pack_dir = temp.path().join("pack");
646 fs::create_dir_all(pack_dir.join("flows")).expect("create flows dir");
647 fs::write(
648 pack_dir.join("pack.yaml"),
649 r#"pack_id: dev.local.resolve-sidecar
650version: 0.1.0
651kind: application
652publisher: Test
653components: []
654dependencies: []
655flows:
656- id: main
657 file: flows/main.ygtc
658 tags: [default]
659 entrypoints: [default]
660assets: []
661"#,
662 )
663 .expect("write pack yaml");
664 fs::write(
665 pack_dir.join("flows/main.ygtc"),
666 r#"id: main
667type: messaging
668start: hello
669nodes:
670 hello:
671 handle_message:
672 input: "hi"
673 routing:
674 - out: true
675"#,
676 )
677 .expect("write flow");
678
679 let runtime = resolve_runtime(Some(temp.path()), None, true, None).expect("runtime");
680 handle(
681 ResolveArgs {
682 input: pack_dir.clone(),
683 lock: None,
684 },
685 &runtime,
686 false,
687 )
688 .await
689 .expect("resolve should create sidecar instead of failing");
690
691 let flow_cfg = sample_flow();
692 let sidecar_path = sidecar_path_for_flow(&pack_dir.join(&flow_cfg.file));
693 let summary_path = resolve_summary_path_for_flow(&pack_dir.join(&flow_cfg.file));
694 assert!(
695 sidecar_path.exists(),
696 "resolve should create the missing sidecar"
697 );
698 assert!(
699 summary_path.exists(),
700 "resolve should write a summary for the new sidecar"
701 );
702
703 let sidecar = read_flow_resolve(&sidecar_path).expect("read sidecar");
704 assert!(sidecar.nodes.is_empty(), "new sidecar should start empty");
705
706 let summary = read_flow_resolve_summary(&summary_path).expect("read summary");
707 assert!(
708 summary.nodes.is_empty(),
709 "summary should reflect the empty sidecar"
710 );
711
712 let lock_path = pack_dir.join("pack.lock.cbor");
713 assert!(
714 lock_path.exists(),
715 "resolve should still write pack.lock.cbor"
716 );
717 }
718}