zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! What a package's nodes can reach, derived rather than declared.
//!
//! The safety review used to guess this from the node kind string. A kind is a
//! name, and a name is not evidence: `contains("pg")` matches any kind with
//! those two letters anywhere. This module answers the same question from the
//! node catalog instead, which is the only place that knows what a node does.
//!
//! Nothing in a package is taken at its word. A native node's capabilities are
//! a compile-time constant of this build. A composite node's are the union of
//! the nodes its function pipelines compose, so a bundle cannot understate them
//! without also removing the nodes. A WASM node's are empty, and empty is a
//! fact about [`crate::pipeline::engines::wasm_host`] rather than a claim the
//! bundle made.

use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;

use serde_json::Value;

use crate::pipeline::model::NodeCapability;
use crate::platform::model::MultiNodePackageDefinition;

/// Whether a `kind` value names a node.
///
/// A pipeline document nests node kinds under a `kind` key, and so does the
/// contract envelope around it -- whose own `kind` is `Pipeline`. A node kind
/// is lowercase dotted segments (`fs.image.thumbnail`) and an envelope kind is
/// one PascalCase word, which is what separates the two without this module
/// having to know which nesting a given document uses.
fn is_node_kind(kind: &str) -> bool {
    kind.contains('.')
        && kind.split('.').all(|segment| {
            !segment.is_empty()
                && segment
                    .bytes()
                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
        })
}

/// Every node kind a pipeline document names, at any nesting.
pub fn node_kinds_in_pipeline(value: &Value) -> BTreeSet<String> {
    let mut found = BTreeSet::new();
    collect_node_kinds(value, &mut found);
    found
}

fn collect_node_kinds(value: &Value, out: &mut BTreeSet<String>) {
    match value {
        Value::Object(map) => {
            if let Some(kind) = map
                .get("kind")
                .and_then(Value::as_str)
                .filter(|kind| is_node_kind(kind))
            {
                out.insert(kind.to_string());
            }
            for child in map.values() {
                collect_node_kinds(child, out);
            }
        }
        Value::Array(items) => {
            for item in items {
                collect_node_kinds(item, out);
            }
        }
        _ => {}
    }
}

/// What every node kind this build can run without installing anything can do.
///
/// Two sources, both fixed at compile time: the native catalog, and the
/// composite bundles baked into the binary. The embedded bundles are already
/// derived by the registry that loads them, so this map never derives the same
/// bundle twice and never disagrees with the runtime about one.
pub fn known_node_capabilities() -> &'static BTreeMap<String, BTreeSet<NodeCapability>> {
    static KNOWN: LazyLock<BTreeMap<String, BTreeSet<NodeCapability>>> = LazyLock::new(|| {
        let mut known = crate::pipeline::nodes::native_node_capabilities().clone();
        for definition in
            crate::platform::services::NodeRegistryService::embedded_official_definitions()
        {
            known.insert(
                definition.kind,
                definition.capabilities.into_iter().collect(),
            );
        }
        known
    });
    &KNOWN
}

/// What each node a bundle declares can do, and what could not be resolved.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DerivedBundleCapabilities {
    /// Node kind the bundle declares, mapped to what it can reach.
    pub nodes: BTreeMap<String, BTreeSet<NodeCapability>>,
    /// Kinds the bundle's function pipelines name that neither this build nor
    /// the bundle itself provides.
    ///
    /// These are the nodes the derivation could not see through. They are a
    /// finding rather than an absence: a set that silently skipped them would
    /// understate the bundle, which is the failure this module exists to avoid.
    pub unresolved: BTreeSet<String>,
}

/// Derives every declared node's capabilities from the pipelines it composes.
///
/// `function_graph` reads one package-relative function pipeline, returning
/// `None` when the package does not carry it. A function that cannot be read
/// contributes nothing and is not guessed at -- the caller sees the missing
/// file as its own finding.
pub fn derive_bundle_capabilities(
    package: &MultiNodePackageDefinition,
    function_graph: impl Fn(&str) -> Option<Value>,
    known: &BTreeMap<String, BTreeSet<NodeCapability>>,
) -> DerivedBundleCapabilities {
    // Read each function once. Several nodes may share one, and a lifecycle
    // hook is just another entry in the same map.
    let composed_by_function: BTreeMap<String, BTreeSet<String>> = package
        .functions
        .iter()
        .filter_map(|(name, rel_path)| {
            let graph = function_graph(rel_path)?;
            Some((name.clone(), node_kinds_in_pipeline(&graph)))
        })
        .collect();

    let mut composed: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for node in &package.nodes {
        let mut kinds = BTreeSet::new();
        for binding in node.run.iter().chain(
            node.lifecycle
                .iter()
                .flat_map(|lifecycle| lifecycle.on_activate.iter().chain(&lifecycle.on_deactivate)),
        ) {
            // A WASM binding names a module, not a function, and a module gets
            // no host imports at all -- so it composes nothing and contributes
            // nothing. See `NodeCapability` for why that is a property of the
            // host rather than a claim of the bundle's.
            if let Some(function) = binding.function.as_deref()
                && let Some(inner) = composed_by_function.get(function)
            {
                kinds.extend(inner.iter().cloned());
            }
        }
        composed.insert(node.kind.clone(), kinds);
    }

    let mut derived: BTreeMap<String, BTreeSet<NodeCapability>> = composed
        .keys()
        .map(|kind| (kind.clone(), BTreeSet::new()))
        .collect();
    let mut unresolved = BTreeSet::new();

    // A bundle node may compose another node from the same bundle, so one pass
    // can read a set that is still empty. Sets only ever grow and are bounded,
    // so repeating until nothing changes terminates -- and it terminates on a
    // cycle too, which a recursive walk would not.
    loop {
        let mut changed = false;
        for (kind, inner_kinds) in &composed {
            let mut union = BTreeSet::new();
            for inner in inner_kinds {
                if let Some(capabilities) = known.get(inner) {
                    union.extend(capabilities.iter().copied());
                } else if let Some(capabilities) = derived.get(inner) {
                    union.extend(capabilities.iter().copied());
                } else {
                    unresolved.insert(inner.clone());
                }
            }
            let entry = derived.entry(kind.clone()).or_default();
            if !union.is_subset(entry) {
                entry.extend(union);
                changed = true;
            }
        }
        if !changed {
            break;
        }
    }

    DerivedBundleCapabilities {
        nodes: derived,
        unresolved,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn package(spec: Value) -> MultiNodePackageDefinition {
        serde_json::from_value(spec).expect("package spec")
    }

    fn graph(kinds: &[&str]) -> Value {
        json!({
            "apiVersion": "zebflow.com/v1",
            "kind": "Pipeline",
            "metadata": {"name": "fn"},
            "spec": {
                "id": "fn",
                "nodes": kinds
                    .iter()
                    .enumerate()
                    .map(|(index, kind)| json!({"id": format!("n{index}"), "kind": kind}))
                    .collect::<Vec<_>>(),
                "edges": [],
            }
        })
    }

    /// The envelope's own `kind` is `Pipeline`, and a node kind is not.
    #[test]
    fn only_node_kinds_are_read_out_of_a_pipeline_document() {
        let kinds = node_kinds_in_pipeline(&graph(&["trigger.function", "http.response.fetch"]));
        assert_eq!(
            kinds,
            BTreeSet::from([
                "http.response.fetch".to_string(),
                "trigger.function".to_string()
            ])
        );
    }

    /// The headline claim: a composite is exactly the union of what it composes,
    /// and it composed nothing it did not name.
    #[test]
    fn a_composite_is_the_union_of_the_nodes_it_composes() {
        let spec = package(json!({
            "package": "acme",
            "version": "1.0.0",
            "title": "Acme",
            "functions": {"main": "functions/main.zf.json"},
            "nodes": [{
                "kind": "x.acme.record.sync",
                "title": "Sync",
                "run": {"function": "main"},
                "definition": {}
            }]
        }));
        let known = BTreeMap::from([
            (
                "http.response.fetch".to_string(),
                BTreeSet::from([NodeCapability::Network]),
            ),
            (
                "fs.file.put".to_string(),
                BTreeSet::from([NodeCapability::Filesystem]),
            ),
            ("trigger.function".to_string(), BTreeSet::new()),
        ]);

        let derived = derive_bundle_capabilities(
            &spec,
            |rel| {
                (rel == "functions/main.zf.json")
                    .then(|| graph(&["trigger.function", "http.response.fetch", "fs.file.put"]))
            },
            &known,
        );

        assert_eq!(
            derived.nodes["x.acme.record.sync"],
            BTreeSet::from([NodeCapability::Network, NodeCapability::Filesystem])
        );
        assert!(derived.unresolved.is_empty());
    }

    /// A WASM node names a module rather than a function, so there is no graph
    /// to union and the host grants it no imports. Empty is the answer, and it
    /// is the answer for every WASM node in every bundle.
    #[test]
    fn a_wasm_node_derives_no_capability_at_all() {
        let spec = package(json!({
            "package": "acme",
            "version": "1.0.0",
            "title": "Acme",
            "modules": {"core": {"path": "wasm/core.wasm", "abi": "zebflow-wasm-json-v1"}},
            "nodes": [{
                "kind": "x.acme.data.crunch",
                "title": "Crunch",
                "run": {"module": "core", "export": "run"},
                "definition": {}
            }]
        }));

        let derived = derive_bundle_capabilities(&spec, |_| None, &BTreeMap::new());

        assert!(derived.nodes["x.acme.data.crunch"].is_empty());
        assert!(derived.unresolved.is_empty());
    }

    /// A node composing another node of the same bundle inherits through it,
    /// and a cycle between two of them still terminates.
    #[test]
    fn a_composite_inherits_through_another_node_of_its_own_bundle() {
        let spec = package(json!({
            "package": "acme",
            "version": "1.0.0",
            "title": "Acme",
            "functions": {
                "outer": "functions/outer.zf.json",
                "inner": "functions/inner.zf.json"
            },
            "nodes": [
                {"kind": "x.acme.outer.call", "title": "Outer",
                 "run": {"function": "outer"}, "definition": {}},
                {"kind": "x.acme.inner.call", "title": "Inner",
                 "run": {"function": "inner"}, "definition": {}}
            ]
        }));
        let known = BTreeMap::from([(
            "postgres.query.run".to_string(),
            BTreeSet::from([NodeCapability::Database, NodeCapability::Credential]),
        )]);

        let derived = derive_bundle_capabilities(
            &spec,
            |rel| match rel {
                // The cycle is deliberate: outer names inner and inner names
                // outer back.
                "functions/outer.zf.json" => Some(graph(&["x.acme.inner.call"])),
                "functions/inner.zf.json" => Some(graph(&["postgres.query.run", "x.acme.outer.call"])),
                _ => None,
            },
            &known,
        );

        let expected = BTreeSet::from([NodeCapability::Database, NodeCapability::Credential]);
        assert_eq!(derived.nodes["x.acme.outer.call"], expected);
        assert_eq!(derived.nodes["x.acme.inner.call"], expected);
    }

    /// A kind neither this build nor the bundle provides is reported, not
    /// skipped: a silently dropped node understates what the bundle does.
    #[test]
    fn a_kind_nothing_provides_is_reported_as_unresolved() {
        let spec = package(json!({
            "package": "acme",
            "version": "1.0.0",
            "title": "Acme",
            "functions": {"main": "functions/main.zf.json"},
            "nodes": [{
                "kind": "x.acme.record.sync",
                "title": "Sync",
                "run": {"function": "main"},
                "definition": {}
            }]
        }));

        let derived = derive_bundle_capabilities(
            &spec,
            |_| Some(graph(&["x.elsewhere.thing.run"])),
            &BTreeMap::new(),
        );

        assert_eq!(
            derived.unresolved,
            BTreeSet::from(["x.elsewhere.thing.run".to_string()])
        );
        assert!(derived.nodes["x.acme.record.sync"].is_empty());
    }

    /// The bundles this binary ships are the derivation's real input, and the
    /// embedding node is exactly the case a name could never have revealed: its
    /// only outbound call is a URL assembled from a credential placeholder.
    #[test]
    fn an_embedded_composite_reports_what_its_function_pipeline_reaches() {
        let embedding = known_node_capabilities()
            .get("ai.embedding.generate")
            .expect("the openai-embedding bundle is embedded in this binary");

        assert!(
            embedding.contains(&NodeCapability::Network),
            "{embedding:?}"
        );
        assert!(
            embedding.contains(&NodeCapability::Credential),
            "{embedding:?}"
        );
        assert!(
            embedding.contains(&NodeCapability::Process),
            "{embedding:?}"
        );
    }

    /// Every kind the engine can dispatch natively has an answer, so the review
    /// never has to fall back to guessing for a node this build ships.
    #[test]
    fn every_native_kind_has_a_capability_answer() {
        let known = known_node_capabilities();
        for definition in crate::pipeline::nodes::builtin_node_definitions() {
            assert!(
                known.contains_key(&definition.kind),
                "{} has no capability entry",
                definition.kind
            );
        }
    }
}