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::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    /// Pack root directory containing pack.yaml.
39    #[arg(long = "in", value_name = "DIR", default_value = ".")]
40    pub input: PathBuf,
41
42    /// Output path for pack.lock.cbor (default: pack.lock.cbor under pack root).
43    #[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                // Store the path relative to the pack root so the lock is portable
146                // across machines/CI. An absolute file:// path baked here only
147                // resolves on the box that ran `resolve`; the read side
148                // (build.rs::local_component_artifact) roots relative refs at the
149                // pack dir and leaves absolute legacy refs untouched.
150                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                // ext:// refs carry the raw ref string; digest is deferred to PackResolver.
163                (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                // Temporary compat fallback: keep resolve/build working for components
254                // whose state-store import still mismatches host stubs (19 vs 18 fields).
255                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
309/// Delegates to the single source of truth in `build` so resolve and build
310/// agree on which flow nodes are runner builtins (incl. `dw.agent` and the
311/// other dispatch kinds), rather than maintaining a second list that can drift.
312fn 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            // Root the (possibly relative) portable lock ref at the pack dir so
344            // validation resolves it; absolute legacy refs are left untouched by join().
345            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 any remaining imports (secrets-store, http-client, interfaces-types,
439    // etc.) that the component needs but describe() never calls at runtime.
440    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}