1use 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
14pub fn build_status_json(
24 workspace_root: &Path,
25 filter_services: &[String],
26 filter_app: Option<&str>,
27) -> serde_json::Value {
28 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 let journal_path = crate::paths::asset_status_journal(workspace_root);
48 let events = replay_last_events(&journal_path);
49
50 let only: std::collections::BTreeSet<&str> =
52 filter_services.iter().map(String::as_str).collect();
53
54 let mut alias_to_asset: HashMap<String, (String, String)> = HashMap::new();
56 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 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 let app_manifests = discover_app_manifests(workspace_root).unwrap_or_default();
114 let host_ctx = HostContext::current();
115
116 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 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 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 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
252fn 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}