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