Skip to main content

packc/cli/
resolve.rs

1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, btree_map::Entry};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow, bail};
7use clap::Args;
8use greentic_pack::pack_lock::{LockedComponent, PackLockV1, write_pack_lock};
9use greentic_types::flow_resolve_summary::{FlowResolveSummarySourceRefV1, FlowResolveSummaryV1};
10
11use crate::config::load_pack_config;
12use crate::flow_resolve::{read_flow_resolve_summary_for_flow, strip_file_uri_prefix};
13use crate::runtime::RuntimeContext;
14
15#[derive(Debug, Args)]
16pub struct ResolveArgs {
17    /// Pack root directory containing pack.yaml.
18    #[arg(long = "in", value_name = "DIR", default_value = ".")]
19    pub input: PathBuf,
20
21    /// Output path for pack.lock.json (default: pack.lock.json under pack root).
22    #[arg(long = "lock", value_name = "FILE")]
23    pub lock: Option<PathBuf>,
24}
25
26pub async fn handle(args: ResolveArgs, runtime: &RuntimeContext, emit_path: bool) -> Result<()> {
27    let pack_dir = args
28        .input
29        .canonicalize()
30        .with_context(|| format!("failed to resolve pack dir {}", args.input.display()))?;
31    let lock_path = resolve_lock_path(&pack_dir, args.lock.as_deref());
32
33    let config = load_pack_config(&pack_dir)?;
34    let mut entries: Vec<LockedComponent> = Vec::new();
35    let _ = runtime;
36    for flow in &config.flows {
37        let summary = read_flow_resolve_summary_for_flow(&pack_dir, flow)?;
38        collect_from_summary(&pack_dir, flow, &summary, &mut entries)?;
39    }
40
41    let lock = PackLockV1::new(entries);
42    write_pack_lock(&lock_path, &lock)?;
43    if emit_path {
44        eprintln!("wrote {}", lock_path.display());
45    }
46
47    Ok(())
48}
49
50fn resolve_lock_path(pack_dir: &Path, override_path: Option<&Path>) -> PathBuf {
51    match override_path {
52        Some(path) if path.is_absolute() => path.to_path_buf(),
53        Some(path) => pack_dir.join(path),
54        None => pack_dir.join("pack.lock.json"),
55    }
56}
57
58fn collect_from_summary(
59    pack_dir: &Path,
60    flow: &crate::config::FlowConfig,
61    doc: &FlowResolveSummaryV1,
62    out: &mut Vec<LockedComponent>,
63) -> Result<()> {
64    let mut seen: BTreeMap<String, LockedComponent> = BTreeMap::new();
65
66    for (node, resolve) in &doc.nodes {
67        let name = format!("{}___{node}", flow.id);
68        let source_ref = &resolve.source;
69        let (reference, digest) = match source_ref {
70            FlowResolveSummarySourceRefV1::Local { path } => {
71                let abs = normalize_local(pack_dir, flow, path)?;
72                (
73                    format!("file://{}", abs.to_string_lossy()),
74                    resolve.digest.clone(),
75                )
76            }
77            FlowResolveSummarySourceRefV1::Oci { .. }
78            | FlowResolveSummarySourceRefV1::Repo { .. }
79            | FlowResolveSummarySourceRefV1::Store { .. } => {
80                (format_reference(source_ref), resolve.digest.clone())
81            }
82        };
83        let component_id = resolve.component_id.clone();
84        let key = component_id.as_str().to_string();
85        let name_for_insert = name.clone();
86        let reference_for_insert = reference.clone();
87        let digest_for_insert = digest.clone();
88
89        match seen.entry(key) {
90            Entry::Vacant(entry) => {
91                entry.insert(LockedComponent {
92                    name: name_for_insert,
93                    r#ref: reference_for_insert,
94                    digest: digest_for_insert,
95                    component_id: Some(component_id.clone()),
96                    bundled: false,
97                    bundled_path: None,
98                    wasm_sha256: None,
99                    resolved_digest: None,
100                });
101            }
102            Entry::Occupied(entry) => {
103                let existing = entry.get();
104                if existing.r#ref != reference || existing.digest != digest {
105                    bail!(
106                        "component {} resolved by nodes {} and {} points to different artifacts ({}@{} vs {}@{})",
107                        component_id.as_str(),
108                        existing.name,
109                        name,
110                        existing.r#ref,
111                        existing.digest,
112                        reference,
113                        digest
114                    );
115                }
116            }
117        }
118    }
119
120    out.extend(seen.into_values());
121
122    Ok(())
123}
124
125fn normalize_local(
126    pack_dir: &Path,
127    flow: &crate::config::FlowConfig,
128    rel: &str,
129) -> Result<PathBuf> {
130    let flow_path = if flow.file.is_absolute() {
131        flow.file.clone()
132    } else {
133        pack_dir.join(&flow.file)
134    };
135    let parent = flow_path
136        .parent()
137        .ok_or_else(|| anyhow!("flow path {} has no parent", flow_path.display()))?;
138    let rel = strip_file_uri_prefix(rel);
139    Ok(parent.join(rel))
140}
141
142fn format_reference(source: &FlowResolveSummarySourceRefV1) -> String {
143    match source {
144        FlowResolveSummarySourceRefV1::Local { path } => path.clone(),
145        FlowResolveSummarySourceRefV1::Oci { r#ref } => {
146            if r#ref.contains("://") {
147                r#ref.clone()
148            } else {
149                format!("oci://{}", r#ref)
150            }
151        }
152        FlowResolveSummarySourceRefV1::Repo { r#ref } => {
153            if r#ref.contains("://") {
154                r#ref.clone()
155            } else {
156                format!("repo://{}", r#ref)
157            }
158        }
159        FlowResolveSummarySourceRefV1::Store { r#ref } => {
160            if r#ref.contains("://") {
161                r#ref.clone()
162            } else {
163                format!("store://{}", r#ref)
164            }
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use greentic_types::ComponentId;
173    use greentic_types::flow_resolve_summary::{
174        FlowResolveSummarySourceRefV1, FlowResolveSummaryV1, NodeResolveSummaryV1,
175    };
176    use std::collections::BTreeMap;
177    use std::path::PathBuf;
178
179    fn sample_flow() -> crate::config::FlowConfig {
180        crate::config::FlowConfig {
181            id: "meetingPrep".to_string(),
182            file: PathBuf::from("flows/main.ygtc"),
183            tags: Vec::new(),
184            entrypoints: Vec::new(),
185        }
186    }
187
188    #[test]
189    fn collect_from_summary_dedups_duplicate_component_ids() {
190        let flow = sample_flow();
191        let pack_dir = PathBuf::from("/tmp");
192        let component_id =
193            ComponentId::new("ai.greentic.component-adaptive-card").expect("valid component id");
194
195        let mut nodes = BTreeMap::new();
196        for name in ["node_one", "node_two"] {
197            nodes.insert(
198                name.to_string(),
199                NodeResolveSummaryV1 {
200                    component_id: component_id.clone(),
201                    source: FlowResolveSummarySourceRefV1::Oci {
202                        r#ref:
203                            "oci://ghcr.io/greentic-ai/components/component-adaptive-card:latest"
204                                .to_string(),
205                    },
206                    digest: "sha256:abcd".to_string(),
207                    manifest: None,
208                },
209            );
210        }
211
212        let summary = FlowResolveSummaryV1 {
213            schema_version: 1,
214            flow: "main.ygtc".to_string(),
215            nodes,
216        };
217
218        let mut entries = Vec::new();
219        collect_from_summary(&pack_dir, &flow, &summary, &mut entries).expect("collect entries");
220
221        assert_eq!(entries.len(), 1);
222        let entry = &entries[0];
223        assert_eq!(entry.name, "meetingPrep___node_one");
224        assert_eq!(
225            entry.component_id.as_ref().map(|id| id.as_str()),
226            Some(component_id.as_str())
227        );
228    }
229
230    #[test]
231    fn collect_from_summary_rejects_conflicting_lock_data() {
232        let flow = sample_flow();
233        let pack_dir = PathBuf::from("/tmp");
234        let component_id =
235            ComponentId::new("ai.greentic.component-adaptive-card").expect("valid component id");
236
237        let mut nodes = BTreeMap::new();
238        nodes.insert(
239            "alpha".to_string(),
240            NodeResolveSummaryV1 {
241                component_id: component_id.clone(),
242                source: FlowResolveSummarySourceRefV1::Oci {
243                    r#ref: "oci://ghcr.io/greentic-ai/components/component-adaptive-card:latest"
244                        .to_string(),
245                },
246                digest: "sha256:abcd".to_string(),
247                manifest: None,
248            },
249        );
250        nodes.insert(
251            "beta".to_string(),
252            NodeResolveSummaryV1 {
253                component_id: component_id.clone(),
254                source: FlowResolveSummarySourceRefV1::Oci {
255                    r#ref: "oci://ghcr.io/greentic-ai/components/component-adaptive-card:latest"
256                        .to_string(),
257                },
258                digest: "sha256:dcba".to_string(),
259                manifest: None,
260            },
261        );
262
263        let summary = FlowResolveSummaryV1 {
264            schema_version: 1,
265            flow: "main.ygtc".to_string(),
266            nodes,
267        };
268
269        let mut entries = Vec::new();
270        let err = collect_from_summary(&pack_dir, &flow, &summary, &mut entries).unwrap_err();
271        assert!(err.to_string().contains("points to different artifacts"));
272    }
273}