Skip to main content

packc/
flow_resolve.rs

1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow};
8use greentic_flow::resolve_summary::write_flow_resolve_summary_for_flow;
9use greentic_types::ComponentId;
10use greentic_types::Flow;
11use greentic_types::error::ErrorCode;
12use greentic_types::flow_resolve::{
13    ComponentSourceRefV1, FlowResolveV1, read_flow_resolve, sidecar_path_for_flow,
14    write_flow_resolve,
15};
16use greentic_types::flow_resolve_summary::{
17    FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION, FlowResolveSummaryManifestV1,
18    FlowResolveSummarySourceRefV1, FlowResolveSummaryV1, NodeResolveSummaryV1,
19    read_flow_resolve_summary, resolve_summary_path_for_flow, write_flow_resolve_summary,
20};
21use semver::Version;
22use sha2::{Digest, Sha256};
23
24use crate::config::FlowConfig;
25
26#[derive(Clone, Debug)]
27pub struct FlowResolveSidecar {
28    pub flow_id: String,
29    pub flow_path: PathBuf,
30    pub sidecar_path: PathBuf,
31    pub document: Option<FlowResolveV1>,
32    pub warning: Option<String>,
33}
34
35/// Discover flow resolve sidecars for the configured flows.
36///
37/// Missing or unreadable sidecars produce warnings but do not fail.
38pub fn discover_flow_resolves(pack_dir: &Path, flows: &[FlowConfig]) -> Vec<FlowResolveSidecar> {
39    flows
40        .iter()
41        .map(|flow| {
42            let flow_path = if flow.file.is_absolute() {
43                flow.file.clone()
44            } else {
45                pack_dir.join(&flow.file)
46            };
47            let sidecar_path = sidecar_path_for_flow(&flow_path);
48
49            let (document, warning) = match read_flow_resolve(&sidecar_path) {
50                Ok(doc) => (Some(doc), None),
51                Err(err) if err.code == ErrorCode::NotFound => (
52                    None,
53                    Some(format!(
54                        "flow resolve sidecar missing for {} ({})",
55                        flow.id,
56                        sidecar_path.display()
57                    )),
58                ),
59                Err(err) => (
60                    None,
61                    Some(format!(
62                        "failed to read flow resolve sidecar for {}: {}",
63                        flow.id, err
64                    )),
65                ),
66            };
67
68            FlowResolveSidecar {
69                flow_id: flow.id.clone(),
70                flow_path,
71                sidecar_path,
72                document,
73                warning,
74            }
75        })
76        .collect()
77}
78
79/// Ensure a flow resolve summary exists, generating it from the resolve sidecar if missing.
80pub fn load_flow_resolve_summary(
81    pack_dir: &Path,
82    flow: &FlowConfig,
83    compiled: &Flow,
84) -> Result<FlowResolveSummaryV1> {
85    let flow_path = resolve_flow_path(pack_dir, flow);
86    let summary = read_or_write_flow_resolve_summary(&flow_path, flow)?;
87    enforce_summary_mappings(flow, compiled, &summary, &flow_path)?;
88    Ok(summary)
89}
90
91/// Read or generate a flow resolve summary for a flow (no node enforcement).
92pub fn read_flow_resolve_summary_for_flow(
93    pack_dir: &Path,
94    flow: &FlowConfig,
95) -> Result<FlowResolveSummaryV1> {
96    let flow_path = resolve_flow_path(pack_dir, flow);
97    read_or_write_flow_resolve_summary(&flow_path, flow)
98}
99
100/// Ensure a resolve sidecar exists for a flow and optionally enforce node mappings.
101///
102/// When the sidecar is missing, an empty document is created. Missing node mappings
103/// emit warnings, or become errors when `strict` is set.
104pub fn ensure_sidecar_exists(
105    pack_dir: &Path,
106    flow: &FlowConfig,
107    compiled: &Flow,
108    strict: bool,
109) -> Result<()> {
110    let flow_path = if flow.file.is_absolute() {
111        flow.file.clone()
112    } else {
113        pack_dir.join(&flow.file)
114    };
115    let sidecar_path = sidecar_path_for_flow(&flow_path);
116
117    let doc = match read_flow_resolve(&sidecar_path) {
118        Ok(doc) => doc,
119        Err(err) if err.code == ErrorCode::NotFound => {
120            let doc = FlowResolveV1 {
121                schema_version: 1,
122                flow: flow.file.to_string_lossy().into_owned(),
123                nodes: BTreeMap::new(),
124            };
125            if let Some(parent) = sidecar_path.parent() {
126                fs::create_dir_all(parent)
127                    .with_context(|| format!("failed to create {}", parent.display()))?;
128            }
129            write_flow_resolve(&sidecar_path, &doc)
130                .with_context(|| format!("failed to write {}", sidecar_path.display()))?;
131            doc
132        }
133        Err(err) => {
134            return Err(anyhow!(
135                "failed to read flow resolve sidecar for {}: {}",
136                flow.id,
137                err
138            ));
139        }
140    };
141
142    let missing = missing_node_mappings(compiled, &doc);
143    if !missing.is_empty() {
144        if strict {
145            anyhow::bail!(
146                "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
147                flow.id,
148                missing.join(", "),
149                sidecar_path.display()
150            );
151        } else {
152            eprintln!(
153                "warning: flow {} has no resolve entries for nodes {} ({}); add mappings to the sidecar and rerun `greentic-pack resolve`",
154                flow.id,
155                missing.join(", "),
156                sidecar_path.display()
157            );
158        }
159    }
160
161    Ok(())
162}
163
164/// Require that a resolve sidecar exists and covers every node in the compiled flow.
165pub fn enforce_sidecar_mappings(pack_dir: &Path, flow: &FlowConfig, compiled: &Flow) -> Result<()> {
166    let flow_path = resolve_flow_path(pack_dir, flow);
167    let sidecar_path = sidecar_path_for_flow(&flow_path);
168    let doc = read_flow_resolve(&sidecar_path).map_err(|err| {
169        anyhow!(
170            "flow {} requires a resolve sidecar; expected {}: {}",
171            flow.id,
172            sidecar_path.display(),
173            err
174        )
175    })?;
176
177    let missing = missing_node_mappings(compiled, &doc);
178    if !missing.is_empty() {
179        anyhow::bail!(
180            "flow {} is missing resolve entries for nodes {} (sidecar {}). Add mappings to the sidecar, then rerun `greentic-pack resolve` followed by `greentic-pack build`.",
181            flow.id,
182            missing.join(", "),
183            sidecar_path.display()
184        );
185    }
186
187    Ok(())
188}
189
190/// Compute which nodes in a flow lack resolve entries.
191pub fn missing_node_mappings(flow: &Flow, doc: &FlowResolveV1) -> Vec<String> {
192    flow.nodes
193        .keys()
194        .filter_map(|node| {
195            let id = node.to_string();
196            if doc.nodes.contains_key(id.as_str()) {
197                None
198            } else {
199                Some(id)
200            }
201        })
202        .collect()
203}
204
205fn resolve_flow_path(pack_dir: &Path, flow: &FlowConfig) -> PathBuf {
206    if flow.file.is_absolute() {
207        flow.file.clone()
208    } else {
209        pack_dir.join(&flow.file)
210    }
211}
212
213fn read_or_write_flow_resolve_summary(
214    flow_path: &Path,
215    flow: &FlowConfig,
216) -> Result<FlowResolveSummaryV1> {
217    let summary_path = resolve_summary_path_for_flow(flow_path);
218    if !summary_path.exists() {
219        let sidecar_path = sidecar_path_for_flow(flow_path);
220        let sidecar = read_flow_resolve(&sidecar_path).map_err(|err| {
221            anyhow!(
222                "flow {} requires a resolve sidecar to generate summary; expected {}: {}",
223                flow.id,
224                sidecar_path.display(),
225                err
226            )
227        })?;
228        write_flow_resolve_summary_safe(flow_path, &sidecar).with_context(|| {
229            format!(
230                "failed to generate flow resolve summary for {}",
231                flow_path.display()
232            )
233        })?;
234    }
235
236    read_flow_resolve_summary(&summary_path).map_err(|err| {
237        anyhow!(
238            "failed to read flow resolve summary for {}: {}",
239            flow.id,
240            err
241        )
242    })
243}
244
245fn write_flow_resolve_summary_safe(flow_path: &Path, sidecar: &FlowResolveV1) -> Result<PathBuf> {
246    let result = if tokio::runtime::Handle::try_current().is_ok() {
247        let flow_path = flow_path.to_path_buf();
248        let sidecar = sidecar.clone();
249        let join =
250            std::thread::spawn(move || write_flow_resolve_summary_for_flow(&flow_path, &sidecar));
251        join.join()
252            .map_err(|_| anyhow!("flow resolve summary generation panicked"))?
253    } else {
254        write_flow_resolve_summary_for_flow(flow_path, sidecar)
255    };
256
257    match result {
258        Ok(path) => Ok(path),
259        Err(err) => {
260            if sidecar
261                .nodes
262                .values()
263                .all(|node| matches!(node.source, ComponentSourceRefV1::Local { .. }))
264            {
265                let summary = build_flow_resolve_summary_fallback(flow_path, sidecar)?;
266                let summary_path = resolve_summary_path_for_flow(flow_path);
267                write_flow_resolve_summary(&summary_path, &summary)
268                    .map_err(|e| anyhow!(e.to_string()))?;
269                return Ok(summary_path);
270            }
271            Err(err)
272        }
273    }
274}
275
276fn enforce_summary_mappings(
277    flow: &FlowConfig,
278    compiled: &Flow,
279    summary: &FlowResolveSummaryV1,
280    flow_path: &Path,
281) -> Result<()> {
282    let missing = missing_summary_node_mappings(compiled, summary);
283    if !missing.is_empty() {
284        let summary_path = resolve_summary_path_for_flow(flow_path);
285        anyhow::bail!(
286            "flow {} is missing resolve summary entries for nodes {} (summary {}). Regenerate the summary and rerun build.",
287            flow.id,
288            missing.join(", "),
289            summary_path.display()
290        );
291    }
292    Ok(())
293}
294
295fn missing_summary_node_mappings(flow: &Flow, doc: &FlowResolveSummaryV1) -> Vec<String> {
296    flow.nodes
297        .keys()
298        .filter_map(|node| {
299            let id = node.to_string();
300            if doc.nodes.contains_key(id.as_str()) {
301                None
302            } else {
303                Some(id)
304            }
305        })
306        .collect()
307}
308
309fn build_flow_resolve_summary_fallback(
310    flow_path: &Path,
311    sidecar: &FlowResolveV1,
312) -> Result<FlowResolveSummaryV1> {
313    let mut nodes = BTreeMap::new();
314    for (node_id, entry) in &sidecar.nodes {
315        let summary = summarize_node_fallback(flow_path, node_id, &entry.source)?;
316        nodes.insert(node_id.clone(), summary);
317    }
318    Ok(FlowResolveSummaryV1 {
319        schema_version: FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION,
320        flow: flow_name_from_path(flow_path),
321        nodes,
322    })
323}
324
325fn summarize_node_fallback(
326    flow_path: &Path,
327    node_id: &str,
328    source: &ComponentSourceRefV1,
329) -> Result<NodeResolveSummaryV1> {
330    let ComponentSourceRefV1::Local { path, .. } = source else {
331        anyhow::bail!(
332            "flow resolve fallback only supports local sources (node {})",
333            node_id
334        );
335    };
336    let source_ref = FlowResolveSummarySourceRefV1::Local {
337        path: strip_file_uri_prefix(path).to_string(),
338    };
339    let wasm_path = local_path_from_sidecar(path, flow_path);
340    let digest = compute_sha256(&wasm_path)?;
341    let manifest_path = find_manifest_for_wasm_loose(&wasm_path).with_context(|| {
342        format!(
343            "component.manifest.json not found for node '{}' ({})",
344            node_id,
345            wasm_path.display()
346        )
347    })?;
348    let (component_id, manifest) = read_manifest_metadata(&manifest_path).with_context(|| {
349        format!(
350            "failed to read component.manifest.json for node '{}' ({})",
351            node_id,
352            manifest_path.display()
353        )
354    })?;
355
356    Ok(NodeResolveSummaryV1 {
357        component_id,
358        source: source_ref,
359        digest,
360        manifest,
361    })
362}
363
364fn find_manifest_for_wasm_loose(wasm_path: &Path) -> Result<PathBuf> {
365    let wasm_abs = fs::canonicalize(wasm_path)
366        .with_context(|| format!("resolve wasm path {}", wasm_path.display()))?;
367    let mut current = wasm_abs.parent();
368    let mut fallback = None;
369    while let Some(dir) = current {
370        let candidate = dir.join("component.manifest.json");
371        if candidate.exists() {
372            if manifest_matches_wasm_loose(&candidate, &wasm_abs)? {
373                return Ok(candidate);
374            }
375            if fallback.is_none() {
376                fallback = Some(candidate);
377            }
378        }
379        current = dir.parent();
380    }
381
382    if let Some(candidate) = fallback {
383        return Ok(candidate);
384    }
385
386    anyhow::bail!(
387        "component.manifest.json not found for wasm {}",
388        wasm_abs.display()
389    );
390}
391
392fn manifest_matches_wasm_loose(manifest_path: &Path, wasm_abs: &Path) -> Result<bool> {
393    let raw = fs::read_to_string(manifest_path)
394        .with_context(|| format!("read {}", manifest_path.display()))?;
395    let json: serde_json::Value =
396        serde_json::from_str(&raw).context("parse component.manifest.json")?;
397    let Some(rel) = json
398        .get("artifacts")
399        .and_then(|v| v.get("component_wasm"))
400        .and_then(|v| v.as_str())
401    else {
402        return Ok(false);
403    };
404    let manifest_dir = manifest_path
405        .parent()
406        .ok_or_else(|| anyhow!("manifest path {} has no parent", manifest_path.display()))?;
407    let sanitized = strip_file_uri_prefix(rel);
408    let Ok(abs) = fs::canonicalize(manifest_dir.join(sanitized)) else {
409        return Ok(false);
410    };
411    Ok(abs == *wasm_abs)
412}
413
414fn read_manifest_metadata(
415    manifest_path: &Path,
416) -> Result<(ComponentId, Option<FlowResolveSummaryManifestV1>)> {
417    let raw = fs::read_to_string(manifest_path)
418        .with_context(|| format!("read {}", manifest_path.display()))?;
419    let json: serde_json::Value =
420        serde_json::from_str(&raw).context("parse component.manifest.json")?;
421    let id = json
422        .get("id")
423        .and_then(|v| v.as_str())
424        .ok_or_else(|| anyhow!("manifest missing id"))?;
425    let component_id =
426        ComponentId::new(id).with_context(|| format!("invalid component id {}", id))?;
427    let world = json.get("world").and_then(|v| v.as_str());
428    let version = json.get("version").and_then(|v| v.as_str());
429    let manifest = match (world, version) {
430        (Some(world), Some(version)) => {
431            let parsed = Version::parse(version)
432                .with_context(|| format!("invalid semver version {}", version))?;
433            Some(FlowResolveSummaryManifestV1 {
434                world: world.to_string(),
435                version: parsed,
436            })
437        }
438        _ => None,
439    };
440    Ok((component_id, manifest))
441}
442
443fn flow_name_from_path(flow_path: &Path) -> String {
444    flow_path
445        .file_name()
446        .map(|name| name.to_string_lossy().to_string())
447        .unwrap_or_else(|| "flow.ygtc".to_string())
448}
449
450pub(crate) fn strip_file_uri_prefix(path: &str) -> &str {
451    path.strip_prefix("file://")
452        .or_else(|| path.strip_prefix("file:/"))
453        .or_else(|| path.strip_prefix("file:"))
454        .unwrap_or(path)
455}
456
457fn local_path_from_sidecar(path: &str, flow_path: &Path) -> PathBuf {
458    let trimmed = strip_file_uri_prefix(path);
459    let raw = PathBuf::from(trimmed);
460    if raw.is_absolute() {
461        raw
462    } else {
463        flow_path
464            .parent()
465            .unwrap_or_else(|| Path::new("."))
466            .join(raw)
467    }
468}
469
470fn compute_sha256(path: &Path) -> Result<String> {
471    let bytes = fs::read(path).with_context(|| format!("read wasm at {}", path.display()))?;
472    let mut sha = Sha256::new();
473    sha.update(bytes);
474    Ok(format!("sha256:{:x}", sha.finalize()))
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use serde_json::json;
481    use std::fs;
482    use tempfile::tempdir;
483
484    #[test]
485    fn strip_file_uri_prefix_removes_scheme_variants() {
486        assert_eq!(strip_file_uri_prefix("file:///tmp/foo"), "/tmp/foo");
487        assert_eq!(strip_file_uri_prefix("file:/tmp/foo"), "tmp/foo");
488        assert_eq!(strip_file_uri_prefix("file://bar/baz"), "bar/baz");
489        assert_eq!(strip_file_uri_prefix("file:relative/path"), "relative/path");
490        assert_eq!(
491            strip_file_uri_prefix("../components/foo"),
492            "../components/foo"
493        );
494    }
495
496    #[test]
497    fn manifest_matches_wasm_loose_handles_relative_file_uri_paths() {
498        let temp = tempdir().expect("alloc temp dir");
499        let components = temp.path().join("components");
500        fs::create_dir_all(&components).expect("create components dir");
501        let wasm_path = components.join("component.wasm");
502        fs::write(&wasm_path, b"wasm-bytes").expect("write wasm");
503        let manifest_path = components.join("component.manifest.json");
504        let manifest = json!({
505            "artifacts": {
506                "component_wasm": "file://component.wasm"
507            }
508        });
509        fs::write(
510            &manifest_path,
511            serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
512        )
513        .expect("write manifest");
514        let wasm_abs = fs::canonicalize(&wasm_path).expect("canonicalize wasm");
515        assert!(manifest_matches_wasm_loose(&manifest_path, &wasm_abs).expect("manifest lookup"));
516
517        let parent_manifest = json!({
518            "artifacts": {
519                "component_wasm": "file://../component.wasm"
520            }
521        });
522        let parent_dir = components.join("child");
523        fs::create_dir_all(&parent_dir).expect("create child dir");
524        let child_manifest_path = parent_dir.join("component.manifest.json");
525        fs::write(
526            &child_manifest_path,
527            serde_json::to_vec_pretty(&parent_manifest).unwrap(),
528        )
529        .expect("write child manifest");
530        assert!(
531            manifest_matches_wasm_loose(&child_manifest_path, &wasm_abs)
532                .expect("manifest matches child")
533        );
534    }
535
536    #[test]
537    fn manifest_matches_wasm_loose_handles_absolute_file_uri_paths() {
538        let temp = tempdir().expect("alloc temp dir");
539        let components = temp.path().join("components");
540        fs::create_dir_all(&components).expect("create components dir");
541        let wasm_path = components.join("component.wasm");
542        fs::write(&wasm_path, b"bytes").expect("write wasm");
543        let wasm_abs = fs::canonicalize(&wasm_path).expect("canonicalize wasm");
544        let manifest_path = components.join("component.manifest.json");
545        let manifest = json!({
546            "artifacts": {
547                "component_wasm": format!("file://{}", wasm_abs.display())
548            }
549        });
550        fs::write(
551            &manifest_path,
552            serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
553        )
554        .expect("write manifest");
555        assert!(manifest_matches_wasm_loose(&manifest_path, &wasm_abs).expect("manifest lookup"));
556    }
557}