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;
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                (
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                // ext:// refs carry the raw ref string; digest is deferred to PackResolver.
157                (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                // Temporary compat fallback: keep resolve/build working for components
248                // whose state-store import still mismatches host stubs (19 vs 18 fields).
249                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
303/// Delegates to the single source of truth in `build` so resolve and build
304/// agree on which flow nodes are runner builtins (incl. `dw.agent` and the
305/// other dispatch kinds), rather than maintaining a second list that can drift.
306fn 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 any remaining imports (secrets-store, http-client, interfaces-types,
422    // etc.) that the component needs but describe() never calls at runtime.
423    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}