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.
191///
192/// Builtin nodes (`emit.*`, `dw.agent[.x]`, `dw.agent_graph[.x]`,
193/// `session.wait`, `flow.call`, `provider.invoke`, `{sorla,operala,agentic}.call`)
194/// are executed by the runner engine and resolve to no pack component, so they
195/// never require a resolve-sidecar mapping and are excluded here.
196pub fn missing_node_mappings(flow: &Flow, doc: &FlowResolveV1) -> Vec<String> {
197    flow.nodes
198        .iter()
199        .filter(|(_, def)| !crate::build::node_is_builtin(def))
200        .filter_map(|(node, _)| {
201            let id = node.to_string();
202            if doc.nodes.contains_key(id.as_str()) {
203                None
204            } else {
205                Some(id)
206            }
207        })
208        .collect()
209}
210
211fn resolve_flow_path(pack_dir: &Path, flow: &FlowConfig) -> PathBuf {
212    if flow.file.is_absolute() {
213        flow.file.clone()
214    } else {
215        pack_dir.join(&flow.file)
216    }
217}
218
219fn read_or_write_flow_resolve_summary(
220    flow_path: &Path,
221    flow: &FlowConfig,
222) -> Result<FlowResolveSummaryV1> {
223    let summary_path = resolve_summary_path_for_flow(flow_path);
224    if !summary_path.exists() {
225        let sidecar_path = sidecar_path_for_flow(flow_path);
226        let sidecar = read_flow_resolve(&sidecar_path).map_err(|err| {
227            anyhow!(
228                "flow {} requires a resolve sidecar to generate summary; expected {}: {}",
229                flow.id,
230                sidecar_path.display(),
231                err
232            )
233        })?;
234        write_flow_resolve_summary_safe(flow_path, &sidecar).with_context(|| {
235            format!(
236                "failed to generate flow resolve summary for {}",
237                flow_path.display()
238            )
239        })?;
240    }
241
242    read_flow_resolve_summary(&summary_path).map_err(|err| {
243        anyhow!(
244            "failed to read flow resolve summary for {}: {}",
245            flow.id,
246            err
247        )
248    })
249}
250
251fn write_flow_resolve_summary_safe(flow_path: &Path, sidecar: &FlowResolveV1) -> Result<PathBuf> {
252    let result = if tokio::runtime::Handle::try_current().is_ok() {
253        let flow_path = flow_path.to_path_buf();
254        let sidecar = sidecar.clone();
255        let join =
256            std::thread::spawn(move || write_flow_resolve_summary_for_flow(&flow_path, &sidecar));
257        join.join()
258            .map_err(|_| anyhow!("flow resolve summary generation panicked"))?
259    } else {
260        write_flow_resolve_summary_for_flow(flow_path, sidecar)
261    };
262
263    match result {
264        Ok(path) => Ok(path),
265        Err(err) => {
266            if sidecar
267                .nodes
268                .values()
269                .all(|node| matches!(node.source, ComponentSourceRefV1::Local { .. }))
270            {
271                let summary = build_flow_resolve_summary_fallback(flow_path, sidecar)?;
272                let summary_path = resolve_summary_path_for_flow(flow_path);
273                write_flow_resolve_summary(&summary_path, &summary)
274                    .map_err(|e| anyhow!(e.to_string()))?;
275                return Ok(summary_path);
276            }
277            Err(err)
278        }
279    }
280}
281
282fn enforce_summary_mappings(
283    flow: &FlowConfig,
284    compiled: &Flow,
285    summary: &FlowResolveSummaryV1,
286    flow_path: &Path,
287) -> Result<()> {
288    let missing = missing_summary_node_mappings(compiled, summary);
289    if !missing.is_empty() {
290        let summary_path = resolve_summary_path_for_flow(flow_path);
291        anyhow::bail!(
292            "flow {} is missing resolve summary entries for nodes {} (summary {}). Regenerate the summary and rerun build.",
293            flow.id,
294            missing.join(", "),
295            summary_path.display()
296        );
297    }
298    Ok(())
299}
300
301fn missing_summary_node_mappings(flow: &Flow, doc: &FlowResolveSummaryV1) -> Vec<String> {
302    // Builtin nodes resolve to no pack component (see `missing_node_mappings`),
303    // so they need no resolve-summary entry either.
304    flow.nodes
305        .iter()
306        .filter(|(_, def)| !crate::build::node_is_builtin(def))
307        .filter_map(|(node, _)| {
308            let id = node.to_string();
309            if doc.nodes.contains_key(id.as_str()) {
310                None
311            } else {
312                Some(id)
313            }
314        })
315        .collect()
316}
317
318fn build_flow_resolve_summary_fallback(
319    flow_path: &Path,
320    sidecar: &FlowResolveV1,
321) -> Result<FlowResolveSummaryV1> {
322    let mut nodes = BTreeMap::new();
323    for (node_id, entry) in &sidecar.nodes {
324        let summary = summarize_node_fallback(flow_path, node_id, &entry.source)?;
325        nodes.insert(node_id.clone(), summary);
326    }
327    Ok(FlowResolveSummaryV1 {
328        schema_version: FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION,
329        flow: flow_name_from_path(flow_path),
330        nodes,
331    })
332}
333
334fn summarize_node_fallback(
335    flow_path: &Path,
336    node_id: &str,
337    source: &ComponentSourceRefV1,
338) -> Result<NodeResolveSummaryV1> {
339    let ComponentSourceRefV1::Local { path, .. } = source else {
340        anyhow::bail!(
341            "flow resolve fallback only supports local sources (node {})",
342            node_id
343        );
344    };
345    let source_ref = FlowResolveSummarySourceRefV1::Local {
346        path: strip_file_uri_prefix(path).to_string(),
347    };
348    let wasm_path = local_path_from_sidecar(path, flow_path);
349    let digest = compute_sha256(&wasm_path)?;
350    let manifest_path = find_manifest_for_wasm_loose(&wasm_path).with_context(|| {
351        format!(
352            "component.manifest.json not found for node '{}' ({})",
353            node_id,
354            wasm_path.display()
355        )
356    })?;
357    let (component_id, manifest) = read_manifest_metadata(&manifest_path).with_context(|| {
358        format!(
359            "failed to read component.manifest.json for node '{}' ({})",
360            node_id,
361            manifest_path.display()
362        )
363    })?;
364
365    Ok(NodeResolveSummaryV1 {
366        component_id,
367        source: source_ref,
368        digest,
369        manifest,
370    })
371}
372
373fn find_manifest_for_wasm_loose(wasm_path: &Path) -> Result<PathBuf> {
374    let wasm_abs = fs::canonicalize(wasm_path)
375        .with_context(|| format!("resolve wasm path {}", wasm_path.display()))?;
376    let mut current = wasm_abs.parent();
377    let mut fallback = None;
378    while let Some(dir) = current {
379        let candidate = dir.join("component.manifest.json");
380        if candidate.exists() {
381            if manifest_matches_wasm_loose(&candidate, &wasm_abs)? {
382                return Ok(candidate);
383            }
384            if fallback.is_none() {
385                fallback = Some(candidate);
386            }
387        }
388        current = dir.parent();
389    }
390
391    if let Some(candidate) = fallback {
392        return Ok(candidate);
393    }
394
395    anyhow::bail!(
396        "component.manifest.json not found for wasm {}",
397        wasm_abs.display()
398    );
399}
400
401fn manifest_matches_wasm_loose(manifest_path: &Path, wasm_abs: &Path) -> Result<bool> {
402    let raw = fs::read_to_string(manifest_path)
403        .with_context(|| format!("read {}", manifest_path.display()))?;
404    let json: serde_json::Value =
405        serde_json::from_str(&raw).context("parse component.manifest.json")?;
406    let Some(rel) = json
407        .get("artifacts")
408        .and_then(|v| v.get("component_wasm"))
409        .and_then(|v| v.as_str())
410    else {
411        return Ok(false);
412    };
413    let manifest_dir = manifest_path
414        .parent()
415        .ok_or_else(|| anyhow!("manifest path {} has no parent", manifest_path.display()))?;
416    let sanitized = strip_file_uri_prefix(rel);
417    let Ok(abs) = fs::canonicalize(manifest_dir.join(sanitized)) else {
418        return Ok(false);
419    };
420    Ok(abs == *wasm_abs)
421}
422
423fn read_manifest_metadata(
424    manifest_path: &Path,
425) -> Result<(ComponentId, Option<FlowResolveSummaryManifestV1>)> {
426    let raw = fs::read_to_string(manifest_path)
427        .with_context(|| format!("read {}", manifest_path.display()))?;
428    let json: serde_json::Value =
429        serde_json::from_str(&raw).context("parse component.manifest.json")?;
430    let id = json
431        .get("id")
432        .and_then(|v| v.as_str())
433        .ok_or_else(|| anyhow!("manifest missing id"))?;
434    let component_id =
435        ComponentId::new(id).with_context(|| format!("invalid component id {}", id))?;
436    let world = json.get("world").and_then(|v| v.as_str());
437    let version = json.get("version").and_then(|v| v.as_str());
438    let manifest = match (world, version) {
439        (Some(world), Some(version)) => {
440            let parsed = Version::parse(version)
441                .with_context(|| format!("invalid semver version {}", version))?;
442            Some(FlowResolveSummaryManifestV1 {
443                world: world.to_string(),
444                version: parsed,
445            })
446        }
447        _ => None,
448    };
449    Ok((component_id, manifest))
450}
451
452fn flow_name_from_path(flow_path: &Path) -> String {
453    flow_path
454        .file_name()
455        .map(|name| name.to_string_lossy().to_string())
456        .unwrap_or_else(|| "flow.ygtc".to_string())
457}
458
459pub(crate) fn strip_file_uri_prefix(path: &str) -> &str {
460    path.strip_prefix("file://")
461        .or_else(|| path.strip_prefix("file:/"))
462        .or_else(|| path.strip_prefix("file:"))
463        .unwrap_or(path)
464}
465
466fn local_path_from_sidecar(path: &str, flow_path: &Path) -> PathBuf {
467    let trimmed = strip_file_uri_prefix(path);
468    let raw = PathBuf::from(trimmed);
469    if raw.is_absolute() {
470        raw
471    } else {
472        flow_path
473            .parent()
474            .unwrap_or_else(|| Path::new("."))
475            .join(raw)
476    }
477}
478
479fn compute_sha256(path: &Path) -> Result<String> {
480    let bytes = fs::read(path).with_context(|| format!("read wasm at {}", path.display()))?;
481    let mut sha = Sha256::new();
482    sha.update(bytes);
483    Ok(format!("sha256:{}", hex::encode(sha.finalize())))
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use serde_json::json;
490    use std::fs;
491    use tempfile::tempdir;
492
493    #[test]
494    fn strip_file_uri_prefix_removes_scheme_variants() {
495        assert_eq!(strip_file_uri_prefix("file:///tmp/foo"), "/tmp/foo");
496        assert_eq!(strip_file_uri_prefix("file:/tmp/foo"), "tmp/foo");
497        assert_eq!(strip_file_uri_prefix("file://bar/baz"), "bar/baz");
498        assert_eq!(strip_file_uri_prefix("file:relative/path"), "relative/path");
499        assert_eq!(
500            strip_file_uri_prefix("../components/foo"),
501            "../components/foo"
502        );
503    }
504
505    #[test]
506    fn manifest_matches_wasm_loose_handles_relative_file_uri_paths() {
507        let temp = tempdir().expect("alloc temp dir");
508        let components = temp.path().join("components");
509        fs::create_dir_all(&components).expect("create components dir");
510        let wasm_path = components.join("component.wasm");
511        fs::write(&wasm_path, b"wasm-bytes").expect("write wasm");
512        let manifest_path = components.join("component.manifest.json");
513        let manifest = json!({
514            "artifacts": {
515                "component_wasm": "file://component.wasm"
516            }
517        });
518        fs::write(
519            &manifest_path,
520            serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
521        )
522        .expect("write manifest");
523        let wasm_abs = fs::canonicalize(&wasm_path).expect("canonicalize wasm");
524        assert!(manifest_matches_wasm_loose(&manifest_path, &wasm_abs).expect("manifest lookup"));
525
526        let parent_manifest = json!({
527            "artifacts": {
528                "component_wasm": "file://../component.wasm"
529            }
530        });
531        let parent_dir = components.join("child");
532        fs::create_dir_all(&parent_dir).expect("create child dir");
533        let child_manifest_path = parent_dir.join("component.manifest.json");
534        fs::write(
535            &child_manifest_path,
536            serde_json::to_vec_pretty(&parent_manifest).unwrap(),
537        )
538        .expect("write child manifest");
539        assert!(
540            manifest_matches_wasm_loose(&child_manifest_path, &wasm_abs)
541                .expect("manifest matches child")
542        );
543    }
544
545    #[test]
546    fn manifest_matches_wasm_loose_handles_absolute_file_uri_paths() {
547        let temp = tempdir().expect("alloc temp dir");
548        let components = temp.path().join("components");
549        fs::create_dir_all(&components).expect("create components dir");
550        let wasm_path = components.join("component.wasm");
551        fs::write(&wasm_path, b"bytes").expect("write wasm");
552        let wasm_abs = fs::canonicalize(&wasm_path).expect("canonicalize wasm");
553        let manifest_path = components.join("component.manifest.json");
554        let manifest = json!({
555            "artifacts": {
556                "component_wasm": format!("file://{}", wasm_abs.display())
557            }
558        });
559        fs::write(
560            &manifest_path,
561            serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
562        )
563        .expect("write manifest");
564        assert!(manifest_matches_wasm_loose(&manifest_path, &wasm_abs).expect("manifest lookup"));
565    }
566}