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 #[arg(long = "in", value_name = "DIR", default_value = ".")]
39 pub input: PathBuf,
40
41 #[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 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_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}