Skip to main content

cloud/
asset_status.rs

1//! Build the W193 workspace asset-status report as a `serde_json::Value`
2//! suitable for `cloud.status` RPC responses (R470-T4).
3//!
4//! Shared between the CLI (`yah cloud status --json`) and the camp-socket
5//! `cloud.status` handler so both surfaces return bit-identical JSON.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use crate::app_manifest::{discover_app_manifests, HostContext};
11use crate::asset_journal::{AssetState, AssetStatusEvent};
12use crate::config::CloudConfig;
13
14/// Build the W193 JSON status report.
15///
16/// - `filter_services`: when non-empty, only services whose name is in the
17///   slice are included in the services array. Apps are filtered by whether
18///   their deps resolve into those services.
19/// - `filter_app`: when `Some`, only the named app appears in the apps array.
20///
21/// Returns the JSON value directly (cheap to clone, cheap for callers to embed
22/// in larger responses). Never fails — missing files are silently skipped.
23pub fn build_status_json(
24    workspace_root: &Path,
25    filter_services: &[String],
26    filter_app: Option<&str>,
27) -> serde_json::Value {
28    // 1. Load cloud config for the services list.
29    // R546-B12: every failure below used to be swallowed with `continue`, so a
30    // workspace whose components all failed to parse rendered identically to
31    // one that declared none. Collect them instead and surface them in the
32    // report; see the CLI's `print_asset_status_report` for the same fix on the
33    // human-facing path.
34    let mut problems: Vec<String> = Vec::new();
35
36    let cfg = match CloudConfig::load(workspace_root) {
37        Ok(c) => c,
38        Err(e) => {
39            return empty_report_with_problems(
40                workspace_root,
41                vec![format!("cannot load workspace cloud config: {e}")],
42            )
43        }
44    };
45
46    // 2. Replay the journal.
47    let journal_path = crate::paths::asset_status_journal(workspace_root);
48    let events = replay_last_events(&journal_path);
49
50    // 3. Walk services → static-asset components → workload.toml.
51    let only: std::collections::BTreeSet<&str> =
52        filter_services.iter().map(String::as_str).collect();
53
54    // alias → (service_name, filename)
55    let mut alias_to_asset: HashMap<String, (String, String)> = HashMap::new();
56    // (service_name, component_id, assets, aliases)
57    let mut components: Vec<(
58        String,
59        String,
60        Vec<workload_spec::AssetEntry>,
61        std::collections::BTreeMap<String, String>,
62    )> = Vec::new();
63
64    for (svc_name, svc) in &cfg.services {
65        if !only.is_empty() && !only.contains(svc_name.as_str()) {
66            continue;
67        }
68        for component in &svc.service.components {
69            if component.kind != "static-asset" {
70                continue;
71            }
72            let workload_path = workspace_root.join(&component.path).join("workload.toml");
73            let src = match std::fs::read_to_string(&workload_path) {
74                Ok(s) => s,
75                Err(e) => {
76                    problems.push(format!(
77                        "{svc_name}/{}: cannot read {}: {e}",
78                        component.id,
79                        workload_path.display()
80                    ));
81                    continue;
82                }
83            };
84            // `component.kind` is already checked above, so parse
85            // `StaticAssetWorkload` directly — the envelope would only re-derive
86            // a discriminator this loop already knows. (R546-B7 fixed the
87            // envelope itself; it accepts the flat on-disk shape now, so this is
88            // a shortcut rather than the workaround it started as.)
89            let saw: workload_spec::StaticAssetWorkload = match toml::from_str(&src) {
90                Ok(w) => w,
91                Err(e) => {
92                    problems.push(format!(
93                        "{svc_name}/{}: cannot parse {}: {e}",
94                        component.id,
95                        workload_path.display()
96                    ));
97                    continue;
98                }
99            };
100            for (alias, filename) in &saw.aliases {
101                alias_to_asset.insert(alias.clone(), (svc_name.clone(), filename.clone()));
102            }
103            components.push((
104                svc_name.clone(),
105                component.id.clone(),
106                saw.assets,
107                saw.aliases,
108            ));
109        }
110    }
111
112    // 4. Discover app manifests.
113    let app_manifests = discover_app_manifests(workspace_root).unwrap_or_default();
114    let host_ctx = HostContext::current();
115
116    // 5. Reverse map: (service, filename) → Vec<"app@alias">.
117    let mut consumers_map: HashMap<(String, String), Vec<String>> = HashMap::new();
118    for (_, manifest) in &app_manifests {
119        for dep in &manifest.asset_deps {
120            if let Some((svc, filename)) = alias_to_asset.get(&dep.alias) {
121                consumers_map
122                    .entry((svc.clone(), filename.clone()))
123                    .or_default()
124                    .push(format!("{}@{}", manifest.name, dep.alias));
125            }
126        }
127    }
128
129    // 6. Build services array (grouped by service name).
130    let mut service_map: std::collections::BTreeMap<String, Vec<serde_json::Value>> =
131        std::collections::BTreeMap::new();
132
133    for (svc_name, component_id, assets, _) in &components {
134        let asset_arr: Vec<serde_json::Value> = assets
135            .iter()
136            .map(|entry| {
137                let journal_key = format!("{svc_name}:{}", entry.filename);
138                let (state, bytes, last_reconciled_at) = match events.get(&journal_key) {
139                    Some(ev) => (ev.to, ev.bytes, Some(ev.at)),
140                    None => (infer_state(entry), None, None),
141                };
142                let consumers = consumers_map
143                    .get(&(svc_name.clone(), entry.filename.clone()))
144                    .cloned()
145                    .unwrap_or_default();
146
147                let mut obj = serde_json::json!({
148                    "filename": entry.filename,
149                    "state": serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
150                    "consumers": consumers,
151                });
152                if let Some(b) = bytes {
153                    obj["bytes"] = serde_json::json!(b);
154                }
155                if let Some(t) = last_reconciled_at {
156                    obj["last_reconciled_at"] = serde_json::json!(t.to_rfc3339());
157                }
158                obj
159            })
160            .collect();
161
162        service_map
163            .entry(svc_name.clone())
164            .or_default()
165            .push(serde_json::json!({
166                "id": component_id,
167                "kind": "static-asset",
168                "assets": asset_arr,
169            }));
170    }
171
172    let services_arr: Vec<serde_json::Value> = service_map
173        .into_iter()
174        .map(|(svc, comps)| serde_json::json!({ "service": svc, "components": comps }))
175        .collect();
176
177    // 7. Build apps array.
178    let apps_arr: Vec<serde_json::Value> = app_manifests
179        .iter()
180        .filter(|(_, manifest)| filter_app.map_or(true, |name| manifest.name == name))
181        .map(|(_, manifest)| {
182            let deps: Vec<serde_json::Value> = manifest
183                .asset_deps
184                .iter()
185                .map(|dep| {
186                    let required_here = dep.required_here(&host_ctx);
187                    let state_val = alias_to_asset
188                        .get(&dep.alias)
189                        .map(|(svc, filename)| {
190                            let key = format!("{svc}:{filename}");
191                            events
192                                .get(&key)
193                                .map(|ev| {
194                                    serde_json::to_value(ev.to).unwrap_or(serde_json::Value::Null)
195                                })
196                                .unwrap_or_else(|| {
197                                    // Infer from catalog.
198                                    components
199                                        .iter()
200                                        .find(|(s, _, assets, _)| {
201                                            s == svc
202                                                && assets.iter().any(|a| &a.filename == filename)
203                                        })
204                                        .and_then(|(_, _, assets, _)| {
205                                            assets.iter().find(|a| &a.filename == filename)
206                                        })
207                                        .map(|entry| {
208                                            serde_json::to_value(infer_state(entry))
209                                                .unwrap_or(serde_json::Value::Null)
210                                        })
211                                        .unwrap_or(serde_json::json!("pinned-not-published"))
212                                })
213                        })
214                        .unwrap_or(serde_json::json!("unknown"));
215
216                    let mut obj = serde_json::json!({
217                        "alias": dep.alias,
218                        "state": state_val,
219                        "required_here": required_here,
220                    });
221                    if let Some(p) = &dep.purpose {
222                        obj["purpose"] = serde_json::json!(p);
223                    }
224                    obj
225                })
226                .collect();
227
228            let overall = compute_overall_from_deps(
229                &manifest.asset_deps,
230                &alias_to_asset,
231                &events,
232                &host_ctx,
233                &components,
234            );
235
236            serde_json::json!({
237                "app": manifest.name,
238                "deps": deps,
239                "overall": overall,
240            })
241        })
242        .collect();
243
244    serde_json::json!({
245        "workspace": workspace_root.display().to_string(),
246        "services": services_arr,
247        "apps": apps_arr,
248        "problems": problems,
249    })
250}
251
252// ── Helpers ────────────────────────────────────────────────────────────────
253
254/// R546-B12: an empty report that says WHY it is empty. `problems` is always
255/// present (empty when clean) so a consumer can distinguish "nothing declared"
256/// from "everything declared failed to load".
257fn empty_report_with_problems(workspace_root: &Path, problems: Vec<String>) -> serde_json::Value {
258    serde_json::json!({
259        "workspace": workspace_root.display().to_string(),
260        "services": [],
261        "apps": [],
262        "problems": problems,
263    })
264}
265
266fn replay_last_events(path: &Path) -> HashMap<String, AssetStatusEvent> {
267    let content = match std::fs::read_to_string(path) {
268        Ok(s) => s,
269        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashMap::new(),
270        Err(_) => return HashMap::new(),
271    };
272    let mut map = HashMap::new();
273    for line in content.lines() {
274        let t = line.trim();
275        if t.is_empty() {
276            continue;
277        }
278        if let Ok(ev) = serde_json::from_str::<AssetStatusEvent>(t) {
279            map.insert(ev.asset.clone(), ev);
280        }
281    }
282    map
283}
284
285fn infer_state(entry: &workload_spec::AssetEntry) -> AssetState {
286    if is_zero_hex(&entry.blake3.0) {
287        return AssetState::PlaceholderOutput;
288    }
289    if let Some(derive) = &entry.derive {
290        if is_zero_hex(&derive.fetch.blake3.0) {
291            return AssetState::PlaceholderFetch;
292        }
293    }
294    AssetState::PinnedNotPublished
295}
296
297fn is_zero_hex(hex: &str) -> bool {
298    hex.len() == 64 && hex.bytes().all(|b| b == b'0')
299}
300
301fn compute_overall_from_deps(
302    deps: &[crate::app_manifest::AssetDep],
303    alias_to_asset: &HashMap<String, (String, String)>,
304    events: &HashMap<String, AssetStatusEvent>,
305    host_ctx: &HostContext,
306    components: &[(
307        String,
308        String,
309        Vec<workload_spec::AssetEntry>,
310        std::collections::BTreeMap<String, String>,
311    )],
312) -> &'static str {
313    let required_states: Vec<Option<AssetState>> = deps
314        .iter()
315        .filter(|d| d.required_here(host_ctx))
316        .map(|d| {
317            alias_to_asset.get(&d.alias).map(|(svc, filename)| {
318                let key = format!("{svc}:{filename}");
319                events.get(&key).map(|ev| ev.to).unwrap_or_else(|| {
320                    components
321                        .iter()
322                        .find(|(s, _, assets, _)| {
323                            s == svc && assets.iter().any(|a| &a.filename == filename)
324                        })
325                        .and_then(|(_, _, assets, _)| {
326                            assets.iter().find(|a| &a.filename == filename)
327                        })
328                        .map(infer_state)
329                        .unwrap_or(AssetState::PinnedNotPublished)
330                })
331            })
332        })
333        .collect();
334
335    if required_states.is_empty() {
336        return "green";
337    }
338    for s in &required_states {
339        if matches!(
340            s,
341            Some(AssetState::DriftBucket)
342                | Some(AssetState::DriftUpstream)
343                | Some(AssetState::TransformBroken)
344        ) {
345            return "red";
346        }
347    }
348    for s in &required_states {
349        if !matches!(
350            s,
351            Some(AssetState::Published) | Some(AssetState::NotRequired)
352        ) {
353            return "amber";
354        }
355    }
356    "green"
357}