Skip to main content

flow_application/
template_hub.rs

1//! Template Hub - a browsable catalog of downloadable flow templates.
2//!
3//! The Model Hub ([`crate::hub`]) browses + downloads AI-model artifacts; the
4//! Template Hub is its analog for **flow templates**. The catalog is a
5//! checked-in JSON file ([`template_catalog.json`]) shaped as an API-response
6//! envelope `{ "apiVersion": "1", "templates": [ … ] }` - a proof-of-concept of
7//! the future cloud template registry, exactly mirroring the Model Hub's
8//! `hub_catalog.json`. Each entry embeds the full `FlowGraph`; "downloading" an
9//! entry copies that graph into the local template store
10//! (`~/.flow-studio/templates/`), after which it shows up in the top-bar
11//! **Load** modal like any other saved template.
12//!
13//! Editing the JSON is all it takes to add/remove templates - no Rust changes,
14//! and no product-specific templates hardcoded in the codebase.
15
16use std::sync::OnceLock;
17
18use chrono::Utc;
19use flow_domain::graph::FlowGraph;
20use flow_storage::{Store, TemplateMembershipRow};
21use serde::{Deserialize, Serialize};
22
23use crate::templates::{self, TemplateError, TemplateRecord, TemplateSource};
24
25const FALLBACK_VERSION: &str = "0.0.0";
26
27const CATALOG_JSON: &str = include_str!("../../../mocks/template_catalog.json");
28
29/// One catalog entry as stored in `template_catalog.json`: browse metadata plus
30/// the embedded graph used on install.
31#[derive(Debug, Clone, Deserialize)]
32#[serde(rename_all = "camelCase")]
33struct TemplateCatalogEntry {
34    slug: String,
35    name: String,
36    #[serde(default)]
37    description: String,
38    #[serde(default)]
39    tags: Vec<String>,
40    /// Catalog entry version (semantic-ish, dot-separated u32 triple).
41    /// Missing in older catalog snapshots → fall back to `"0.0.0"` so the
42    /// comparator treats them as "always older than the embedded one".
43    #[serde(default = "default_version")]
44    version: String,
45    graph: FlowGraph,
46}
47
48fn default_version() -> String {
49    FALLBACK_VERSION.to_string()
50}
51
52/// API-response envelope: `{ "apiVersion": "1", "templates": [ … ] }`. Mirrors
53/// the shape the future registry service will serve.
54#[derive(Deserialize)]
55struct CatalogEnvelope {
56    #[serde(default)]
57    api_version: String,
58    templates: Vec<TemplateCatalogEntry>,
59}
60
61/// Browse metadata for a hub template (no embedded graph - that's resolved
62/// on install). Serialized to the frontend with camelCase keys.
63#[derive(Debug, Clone, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct TemplateHubEntry {
66    pub slug: String,
67    pub name: String,
68    pub description: String,
69    pub tags: Vec<String>,
70    pub version: String,
71    pub node_count: usize,
72    pub edge_count: usize,
73}
74
75/// Where a hub template currently lives in the local store, plus what
76/// version was last installed. `None` when nothing matches.
77#[derive(Debug, Clone, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct HubInstalledStatus {
80    pub template_slug: String,
81    pub collection_slug: String,
82    pub installed_version: String,
83}
84
85/// One hub entry + install status. The `update_available` flag is
86/// pre-computed server-side so the frontend doesn't need to parse versions.
87#[derive(Debug, Clone, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct TemplateHubEntryWithStatus {
90    pub slug: String,
91    pub name: String,
92    pub description: String,
93    pub tags: Vec<String>,
94    pub version: String,
95    pub node_count: usize,
96    pub edge_count: usize,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub installed_as: Option<HubInstalledStatus>,
99    pub update_available: bool,
100}
101
102/// Parse the embedded catalog once. A malformed JSON file panics at first use
103/// (loud, deterministic - it ships in the binary, so it's a build-time error in
104/// spirit, exactly like `hub_catalog.json`).
105fn entries() -> &'static [TemplateCatalogEntry] {
106    static CATALOG: OnceLock<Vec<TemplateCatalogEntry>> = OnceLock::new();
107    CATALOG
108        .get_or_init(|| {
109            let env: CatalogEnvelope =
110                serde_json::from_str(CATALOG_JSON).expect("template_catalog.json is malformed");
111            let _ = env.api_version;
112            env.templates
113        })
114        .as_slice()
115}
116
117/// Browse list: catalog entries mapped to display metadata (+ node/edge
118/// counts from the embedded graph).
119pub fn catalog() -> Vec<TemplateHubEntry> {
120    entries().iter().map(entry_to_browse).collect()
121}
122
123fn entry_to_browse(e: &TemplateCatalogEntry) -> TemplateHubEntry {
124    TemplateHubEntry {
125        slug: e.slug.clone(),
126        name: e.name.clone(),
127        description: e.description.clone(),
128        tags: e.tags.clone(),
129        version: e.version.clone(),
130        node_count: e.graph.nodes.len(),
131        edge_count: e.graph.edges.len(),
132    }
133}
134
135/// Annotate the catalog with per-entry install status. A template counts
136/// as installed when either:
137/// - a `template_membership` row exists whose `hub_source_slug` matches
138///   the catalog slug, OR
139/// - the canonical file `<templates_dir>/<slug>.flow.json` is on disk.
140///
141/// The on-disk check covers orphan files (DB reset, schema migration,
142/// row hand-deleted) so the Hub row's button flips to Update / Installed
143/// without the user having to click Add first and rely on the heal
144/// path in [`add_to_collection`].
145pub fn catalog_with_status(
146    store: &Store,
147    templates_dir: &std::path::Path,
148) -> Result<Vec<TemplateHubEntryWithStatus>, TemplateError> {
149    let memberships = store.list_template_memberships()?;
150    let mut by_hub: std::collections::HashMap<String, &flow_storage::TemplateMembershipRow> =
151        std::collections::HashMap::new();
152    for row in &memberships {
153        if let Some(hub_slug) = row.hub_source_slug.as_deref() {
154            by_hub.insert(hub_slug.to_string(), row);
155        }
156    }
157    let mut out = Vec::with_capacity(entries().len());
158    for e in entries() {
159        let installed = by_hub
160            .get(&e.slug)
161            .map(|row| HubInstalledStatus {
162                template_slug: row.template_slug.clone(),
163                collection_slug: row.collection_slug.clone(),
164                installed_version: row
165                    .hub_version
166                    .clone()
167                    .unwrap_or_else(|| FALLBACK_VERSION.into()),
168            })
169            .or_else(|| {
170                // Orphan file: present on disk, no row points at it.
171                let file_path = templates_dir.join(format!("{}.flow.json", e.slug));
172                if file_path.exists() {
173                    Some(HubInstalledStatus {
174                        template_slug: e.slug.clone(),
175                        collection_slug: crate::templates::DEFAULT_COLLECTION_SLUG.into(),
176                        installed_version: FALLBACK_VERSION.into(),
177                    })
178                } else {
179                    None
180                }
181            });
182        let update_available = installed
183            .as_ref()
184            .map(|status| {
185                compare_versions(&e.version, &status.installed_version)
186                    == std::cmp::Ordering::Greater
187            })
188            .unwrap_or(false);
189        out.push(TemplateHubEntryWithStatus {
190            slug: e.slug.clone(),
191            name: e.name.clone(),
192            description: e.description.clone(),
193            tags: e.tags.clone(),
194            version: e.version.clone(),
195            node_count: e.graph.nodes.len(),
196            edge_count: e.graph.edges.len(),
197            installed_as: installed,
198            update_available,
199        });
200    }
201    Ok(out)
202}
203
204/// Install a catalog entry into a user-chosen collection.
205///
206/// - When neither the file nor a membership row exists, this is a normal
207///   first install: write the canonical JSON, write the sibling DSL,
208///   upsert the membership row.
209/// - When the file exists AND a membership row exists, refuse - the
210///   caller must explicitly go through [`update_installed`] to overwrite.
211/// - When the file exists but no row references it (the DB was reset,
212///   migrated, or the row was hand-deleted), heal silently: write the
213///   missing row pointing at the on-disk file without overwriting any
214///   user edits. The user clicked Add and now the install is tracked,
215///   which is what they wanted.
216pub fn add_to_collection(
217    templates_dir: &std::path::Path,
218    store: &Store,
219    hub_slug: &str,
220    collection_slug: &str,
221) -> Result<TemplateRecord, TemplateError> {
222    let entry = entries()
223        .iter()
224        .find(|e| e.slug == hub_slug)
225        .ok_or_else(|| TemplateError::NotFound(hub_slug.to_string()))?;
226    let file_path = templates_dir.join(format!("{}.flow.json", entry.slug));
227
228    if file_path.exists() {
229        let already_tracked = store
230            .list_template_memberships()?
231            .into_iter()
232            .any(|r| r.hub_source_slug.as_deref() == Some(entry.slug.as_str()));
233        if already_tracked {
234            return Err(TemplateError::Store(
235                flow_storage::StoreError::Refused(format!(
236                    "template `{}` is already installed; use Update to overwrite",
237                    entry.slug
238                )),
239            ));
240        }
241        return heal_membership(templates_dir, store, entry, collection_slug);
242    }
243
244    let source = TemplateSource {
245        hub_slug: entry.slug.clone(),
246        version: entry.version.clone(),
247        installed_at: Utc::now(),
248    };
249    templates::save_with_slug(
250        templates_dir,
251        store,
252        &entry.slug,
253        &entry.name,
254        &entry.graph,
255        collection_slug,
256        Some(source),
257    )
258}
259
260/// Reconcile an orphan on-disk template (file exists, no membership row)
261/// by writing the row. The file is NOT overwritten - local edits, if any,
262/// are preserved.
263fn heal_membership(
264    templates_dir: &std::path::Path,
265    store: &Store,
266    entry: &TemplateCatalogEntry,
267    collection_slug: &str,
268) -> Result<TemplateRecord, TemplateError> {
269    let installed_at = Utc::now();
270    store.upsert_template_membership(&TemplateMembershipRow {
271        template_slug: entry.slug.clone(),
272        collection_slug: collection_slug.into(),
273        hub_source_slug: Some(entry.slug.clone()),
274        hub_version: Some(entry.version.clone()),
275        installed_at: Some(installed_at),
276    })?;
277    let graph = templates::load(templates_dir, &entry.slug)?;
278    let dsl_path = templates_dir.join(format!("{}.flow", entry.slug));
279    Ok(TemplateRecord {
280        slug: entry.slug.clone(),
281        collection_slug: collection_slug.to_string(),
282        name: entry.name.clone(),
283        node_count: graph.nodes.len(),
284        edge_count: graph.edges.len(),
285        updated_at: installed_at,
286        has_dsl: dsl_path.exists(),
287        source: Some(TemplateSource {
288            hub_slug: entry.slug.clone(),
289            version: entry.version.clone(),
290            installed_at,
291        }),
292    })
293}
294
295/// Overwrite an installed hub template in place with the catalog's current
296/// graph, bumping `hub_version`. When `force` is false and the user has
297/// edited the file (mtime > installed_at), returns a `Refused` error so the
298/// frontend can prompt the user before clobbering local edits.
299pub fn update_installed(
300    templates_dir: &std::path::Path,
301    store: &Store,
302    hub_slug: &str,
303    force: bool,
304) -> Result<TemplateRecord, TemplateError> {
305    let entry = entries()
306        .iter()
307        .find(|e| e.slug == hub_slug)
308        .ok_or_else(|| TemplateError::NotFound(hub_slug.to_string()))?;
309
310    // Locate the existing membership row by hub slug (small scan; the table
311    // never grows past a few dozen rows).
312    let existing_row = store
313        .list_template_memberships()?
314        .into_iter()
315        .find(|r| r.hub_source_slug.as_deref() == Some(hub_slug))
316        .ok_or_else(|| TemplateError::NotFound(format!("hub template `{hub_slug}` not installed")))?;
317
318    if !force {
319        let file_path = templates_dir.join(format!("{}.flow.json", existing_row.template_slug));
320        if let (Ok(meta), Some(installed_at)) =
321            (std::fs::metadata(&file_path), existing_row.installed_at)
322        {
323            if let Ok(modified) = meta.modified() {
324                let modified_dt: chrono::DateTime<Utc> = modified.into();
325                if modified_dt > installed_at {
326                    return Err(TemplateError::Store(flow_storage::StoreError::Refused(
327                        format!(
328                            "template `{}` has local edits since install; pass force=true to overwrite",
329                            existing_row.template_slug
330                        ),
331                    )));
332                }
333            }
334        }
335    }
336
337    let source = TemplateSource {
338        hub_slug: entry.slug.clone(),
339        version: entry.version.clone(),
340        installed_at: Utc::now(),
341    };
342    templates::save_with_slug(
343        templates_dir,
344        store,
345        &existing_row.template_slug,
346        &entry.name,
347        &entry.graph,
348        &existing_row.collection_slug,
349        Some(source),
350    )
351}
352
353/// Tuple compare on the parsed dotted-int triple. Non-parseable segments
354/// degrade to 0 so a malformed catalog entry still compares (against
355/// `0.0.0` it'll come out equal, which is harmless).
356fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
357    let parse = |s: &str| -> (u32, u32, u32) {
358        let mut it = s.split('.').map(|p| p.parse::<u32>().unwrap_or(0));
359        (it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
360    };
361    parse(a).cmp(&parse(b))
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn catalog_parses_and_is_well_formed() {
370        // The shipped catalog is allowed to be empty - vendor-specific
371        // templates ship as Hub catalog data added by operators, not by
372        // this crate. The well-formedness check only kicks in when there
373        // ARE entries.
374        for e in &catalog() {
375            assert!(!e.slug.is_empty());
376            assert!(!e.name.is_empty());
377            assert!(!e.version.is_empty(), "{} has a version", e.slug);
378            assert!(e.node_count > 0, "{} has nodes", e.slug);
379        }
380    }
381
382    fn first_catalog_entry() -> Option<TemplateHubEntry> {
383        catalog().into_iter().next()
384    }
385
386    #[test]
387    fn add_to_collection_writes_a_loadable_local_template() {
388        let Some(first) = first_catalog_entry() else {
389            // No-op on builds that ship an empty catalog; the tests below
390            // exercise behaviour that's only reachable when a hub entry
391            // exists.
392            return;
393        };
394        let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
395        std::fs::create_dir_all(&dir).unwrap();
396
397        let store = flow_storage::Store::open_in_memory().expect("in-memory store");
398        templates::ensure_default(&store).expect("ensure_default");
399        let rec = add_to_collection(&dir, &store, &first.slug, "default").expect("install");
400        assert_eq!(rec.slug, first.slug);
401        assert_eq!(rec.collection_slug, "default");
402        assert!(rec.source.is_some(), "hub install records its source");
403        let graph = templates::load(&dir, &first.slug).expect("loads");
404        assert!(!graph.nodes.is_empty());
405
406        assert!(add_to_collection(&dir, &store, "does-not-exist", "default").is_err());
407
408        let _ = std::fs::remove_dir_all(&dir);
409    }
410
411    #[test]
412    fn add_to_collection_refuses_when_already_installed() {
413        let Some(first) = first_catalog_entry() else { return };
414        let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
415        std::fs::create_dir_all(&dir).unwrap();
416        let store = flow_storage::Store::open_in_memory().unwrap();
417        templates::ensure_default(&store).unwrap();
418        add_to_collection(&dir, &store, &first.slug, "default").unwrap();
419        let err =
420            add_to_collection(&dir, &store, &first.slug, "default").expect_err("re-add refused");
421        assert!(err.to_string().contains("already installed"), "got {err}");
422        let _ = std::fs::remove_dir_all(&dir);
423    }
424
425    #[test]
426    fn catalog_with_status_marks_installed_for_orphan_files_on_disk() {
427        let Some(first) = first_catalog_entry() else { return };
428        let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
429        std::fs::create_dir_all(&dir).unwrap();
430        let store = flow_storage::Store::open_in_memory().unwrap();
431        templates::ensure_default(&store).unwrap();
432
433        // Install, then drop the row to simulate an orphan-file state.
434        add_to_collection(&dir, &store, &first.slug, "default").unwrap();
435        store.delete_template_membership(&first.slug).unwrap();
436
437        // No row, but the file is still on disk - catalog_with_status
438        // must still report this as installed so the Hub row's button
439        // shows Update instead of "Add to collection…".
440        let annotated = catalog_with_status(&store, &dir).unwrap();
441        let our_row = annotated
442            .iter()
443            .find(|e| e.slug == first.slug)
444            .expect("entry");
445        assert!(
446            our_row.installed_as.is_some(),
447            "orphan file should count as installed"
448        );
449
450        let _ = std::fs::remove_dir_all(&dir);
451    }
452
453    #[test]
454    fn add_to_collection_heals_orphan_file_when_membership_row_missing() {
455        let Some(first) = first_catalog_entry() else { return };
456        let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
457        std::fs::create_dir_all(&dir).unwrap();
458        let store = flow_storage::Store::open_in_memory().unwrap();
459        templates::ensure_default(&store).unwrap();
460
461        // First install lays down both the file and the row.
462        add_to_collection(&dir, &store, &first.slug, "default").unwrap();
463        // Simulate the DB being reset / migrated underneath: drop the row
464        // but keep the on-disk file (and a deliberate local edit).
465        store.delete_template_membership(&first.slug).unwrap();
466        let file_path = dir.join(format!("{}.flow.json", first.slug));
467        let original_bytes = std::fs::read(&file_path).unwrap();
468        std::fs::write(&file_path, &original_bytes).unwrap(); // unchanged bytes - proves we don't touch the file
469
470        // Now Add should heal instead of refusing.
471        let rec = add_to_collection(&dir, &store, &first.slug, "default")
472            .expect("orphan file heals into a tracked install");
473        assert_eq!(rec.slug, first.slug);
474        assert!(rec.source.is_some(), "heal sets source provenance");
475        // File contents preserved byte-for-byte.
476        assert_eq!(std::fs::read(&file_path).unwrap(), original_bytes);
477        // Membership row now present.
478        let row = store
479            .get_template_membership(&first.slug)
480            .unwrap()
481            .expect("row");
482        assert_eq!(row.hub_source_slug.as_deref(), Some(first.slug.as_str()));
483
484        let _ = std::fs::remove_dir_all(&dir);
485    }
486
487    #[test]
488    fn catalog_with_status_marks_installed_and_update_available() {
489        let Some(first) = first_catalog_entry() else { return };
490        let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
491        std::fs::create_dir_all(&dir).unwrap();
492        let store = flow_storage::Store::open_in_memory().unwrap();
493        templates::ensure_default(&store).unwrap();
494        add_to_collection(&dir, &store, &first.slug, "default").unwrap();
495
496        // Force the installed version to look older so update_available trips.
497        let mut row = store
498            .get_template_membership(&first.slug)
499            .unwrap()
500            .expect("row");
501        row.hub_version = Some("0.0.1".into());
502        store.upsert_template_membership(&row).unwrap();
503
504        let annotated = catalog_with_status(&store, &dir).unwrap();
505        let our_row = annotated
506            .iter()
507            .find(|e| e.slug == first.slug)
508            .expect("entry");
509        assert!(our_row.installed_as.is_some());
510        assert!(
511            our_row.update_available,
512            "catalog version > 0.0.1 should be update_available"
513        );
514        let _ = std::fs::remove_dir_all(&dir);
515    }
516
517    #[test]
518    fn compare_versions_orders_dotted_triples() {
519        use std::cmp::Ordering::*;
520        assert_eq!(compare_versions("1.0.0", "1.0.0"), Equal);
521        assert_eq!(compare_versions("1.0.1", "1.0.0"), Greater);
522        assert_eq!(compare_versions("0.9.9", "1.0.0"), Less);
523        assert_eq!(compare_versions("2.0.0", "1.99.0"), Greater);
524        assert_eq!(compare_versions("0.0.0", "0.0.0"), Equal);
525        // Missing segments degrade to 0 - non-parseable still compares.
526        assert_eq!(compare_versions("1", "1.0.0"), Equal);
527    }
528}