Skip to main content

packc/cli/
resolve.rs

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