Skip to main content

max / makenotwork

bento: serve GET /status.json, per app rather than per newest build Apps become nodes, targets their children, steps conditions, and build/retry declared actions carrying the JSON body each route requires. The read is per app, and that is the point. /state returns only the newest build row across every app, so any later unrelated build hides an earlier app's half-failure completely. /status.json reads each app's latest build separately and cannot do that. Every declared app appears whether or not it has ever been built: an app missing from the surface is indistinguishable from an app that is fine. This closes the observability half of the audit's H2 (partial releases are unblocked and invisible). Targets are independent -- a failed windows target neither stops nor rolls back macOS, and publish is per target with no all-green gate -- so an app whose build finished "ok" without covering every declared target now reports degraded with a release_complete condition naming what failed and what never ran. The other half of H2, actually gating a partial release, is untouched and still open. Action.body earned its place here. Sando's actions are path-based (/promote/{tier}); Bento's take a body ({app, target, version}). Without a body on the declared action the viewer would have needed per-daemon knowledge, which is the exact failure the contract exists to prevent. A partial publish is reported only once something has been published for that version. publish() is a stub no recipe calls, so the releases table is empty everywhere, and a permanently red "0 of 5 published" for a stage that is not wired is how a surface teaches you to stop reading it. When publishing is real, a partial one is visible. Library builds are marked danger, app builds are not: a library build ends at a crates.io publish, which cannot be taken back. Also fixes a pre-existing needless-borrow in runner.rs that failed clippy -D warnings under the current toolchain, unrelated to this change but blocking a clean gate. Note: bento/daemon is not rustfmt-clean and this change does not make it so. Running cargo fmt here reformats 637 lines across eleven files nobody asked to touch, so it is left alone; the new code follows the surrounding style. bento/tui and bento/driver lockfiles are likewise stale and will churn on their next build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-21 23:38 UTC
Commit: be58b6faf66325af7820b7f8562f997263234d51
Parent: f83156b
6 files changed, +650 insertions, -29 deletions
@@ -186,6 +186,7 @@ dependencies = [
186 186 "metrics-exporter-prometheus",
187 187 "ops-core",
188 188 "ops-exec",
189 + "ops-status",
189 190 "reqwest",
190 191 "rhai",
191 192 "semver",
@@ -1430,6 +1431,15 @@ dependencies = [
1430 1431 ]
1431 1432
1432 1433 [[package]]
1434 + name = "ops-status"
1435 + version = "0.1.0"
1436 + dependencies = [
1437 + "chrono",
1438 + "serde",
1439 + "serde_json",
1440 + ]
1441 +
1442 + [[package]]
1433 1443 name = "parking"
1434 1444 version = "2.2.1"
1435 1445 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10,6 +10,7 @@ path = "src/main.rs"
10 10
11 11 [dependencies]
12 12 ops-core = { path = "../../shared/ops-core" }
13 + ops-status = { path = "../../shared/ops-status" }
13 14 ops-exec = { path = "../../shared/ops-exec", features = ["rpc"] }
14 15 axum = { version = "0.8.8", features = ["macros", "ws"] }
15 16 tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "fs", "process", "sync", "io-util"] }
@@ -20,4 +20,5 @@ pub mod retention;
20 20 pub mod routes;
21 21 pub mod runner;
22 22 pub mod state;
23 + pub mod status;
23 24 pub mod topology;
@@ -33,6 +33,7 @@ pub fn router(state: AppState) -> Router {
33 33
34 34 let open = Router::new()
35 35 .route("/state", get(get_state))
36 + .route("/status.json", get(get_status_json))
36 37 .route("/logs/{app}/{version}/{target}/{step}", get(get_step_log))
37 38 .route("/events", get(events_ws));
38 39
@@ -93,31 +94,50 @@ struct StateView {
93 94 build: Option<BuildView>,
94 95 }
95 96
97 + /// One app's latest build, plus what the topology says it ships.
98 + ///
99 + /// `/state` answers with the newest build row across every app, which means a
100 + /// release that half-failed on one app becomes invisible the moment any other
101 + /// app builds. `/status.json` reads per app instead, so nothing can be hidden
102 + /// by a later, unrelated build.
103 + pub(crate) struct AppStatusView {
104 + pub(crate) app: String,
105 + pub(crate) kind: crate::topology::Kind,
106 + /// Every target this app ships, from its in-repo manifest -- including ones
107 + /// the latest build never ran.
108 + pub(crate) declared_targets: Vec<String>,
109 + /// `None` until this app has ever been built.
110 + pub(crate) build: Option<BuildView>,
111 + /// Targets with a `releases` row at the build's version. Empty when
112 + /// publishing is not in play for this app.
113 + pub(crate) published_targets: Vec<String>,
114 + }
115 +
96 116 #[derive(Serialize)]
97 - struct BuildView {
98 - id: i64,
99 - app: String,
100 - version: String,
101 - status: String,
102 - created_at: String,
103 - targets: Vec<TargetView>,
117 + pub(crate) struct BuildView {
118 + pub(crate) id: i64,
119 + pub(crate) app: String,
120 + pub(crate) version: String,
121 + pub(crate) status: String,
122 + pub(crate) created_at: String,
123 + pub(crate) targets: Vec<TargetView>,
104 124 }
105 125
106 126 #[derive(Serialize)]
107 - struct TargetView {
108 - target: String,
109 - status: String,
110 - current_step: Option<String>,
111 - error: Option<String>,
112 - steps: Vec<StepView>,
127 + pub(crate) struct TargetView {
128 + pub(crate) target: String,
129 + pub(crate) status: String,
130 + pub(crate) current_step: Option<String>,
131 + pub(crate) error: Option<String>,
132 + pub(crate) steps: Vec<StepView>,
113 133 }
114 134
115 135 #[derive(Serialize)]
116 - struct StepView {
117 - run_id: i64,
118 - step: String,
119 - status: String,
120 - log_ref: Option<String>,
136 + pub(crate) struct StepView {
137 + pub(crate) run_id: i64,
138 + pub(crate) step: String,
139 + pub(crate) status: String,
140 + pub(crate) log_ref: Option<String>,
121 141 }
122 142
123 143 async fn get_state(State(s): State<AppState>) -> Result<Json<StateView>> {
@@ -130,6 +150,14 @@ async fn get_state(State(s): State<AppState>) -> Result<Json<StateView>> {
130 150 let Some(b) = latest else {
131 151 return Ok(Json(StateView { build: None }));
132 152 };
153 + Ok(Json(StateView { build: Some(build_view(&s, &b).await?) }))
154 + }
155 +
156 + /// Hydrate one `builds` row into its target x step matrix.
157 + ///
158 + /// Shared by `/state` and `/status.json` so the two cannot disagree about what
159 + /// a build looks like.
160 + async fn build_view(s: &AppState, b: &sqlx::sqlite::SqliteRow) -> Result<BuildView> {
133 161 let build_id: i64 = b.get("id");
134 162
135 163 let target_rows = sqlx::query(
@@ -172,16 +200,72 @@ async fn get_state(State(s): State<AppState>) -> Result<Json<StateView>> {
172 200 });
173 201 }
174 202
175 - Ok(Json(StateView {
176 - build: Some(BuildView {
177 - id: build_id,
178 - app: b.get("app"),
179 - version: b.get("version"),
180 - status: b.get("status"),
181 - created_at: b.get("created_at"),
182 - targets,
183 - }),
184 - }))
203 + Ok(BuildView {
204 + id: build_id,
205 + app: b.get("app"),
206 + version: b.get("version"),
207 + status: b.get("status"),
208 + created_at: b.get("created_at"),
209 + targets,
210 + })
211 + }
212 +
213 + /// `GET /status.json` -- every app's latest build, in the shared cross-service
214 + /// payload every operator surface renders. See `crate::status`.
215 + async fn get_status_json(State(s): State<AppState>) -> Result<Json<ops_status::Payload>> {
216 + let view = status_view(&s).await?;
217 + Ok(Json(crate::status::payload(&view, chrono::Utc::now())))
218 + }
219 +
220 + /// One [`AppStatusView`] per app in the topology, name-ordered.
221 + ///
222 + /// Every declared app appears whether or not it has ever been built: an app
223 + /// missing from the surface is indistinguishable from an app that is fine, and
224 + /// the whole point of the viewer is that absence of evidence must be visible.
225 + pub(crate) async fn status_view(s: &AppState) -> Result<Vec<AppStatusView>> {
226 + let mut names: Vec<&String> = s.topo.app.keys().collect();
227 + names.sort();
228 +
229 + let mut apps = Vec::with_capacity(names.len());
230 + for name in names {
231 + let cfg = &s.topo.app[name];
232 +
233 + let latest = sqlx::query(
234 + "SELECT id, app, version, status, created_at FROM builds
235 + WHERE app = ? ORDER BY id DESC LIMIT 1",
236 + )
237 + .bind(name)
238 + .fetch_optional(&s.pool)
239 + .await?;
240 +
241 + let build = match latest {
242 + Some(row) => Some(build_view(s, &row).await?),
243 + None => None,
244 + };
245 +
246 + let published_targets = match &build {
247 + Some(b) => {
248 + sqlx::query_scalar::<_, String>(
249 + "SELECT DISTINCT target FROM releases
250 + WHERE app = ? AND version = ? ORDER BY target",
251 + )
252 + .bind(name)
253 + .bind(&b.version)
254 + .fetch_all(&s.pool)
255 + .await?
256 + }
257 + None => Vec::new(),
258 + };
259 +
260 + apps.push(AppStatusView {
261 + app: name.clone(),
262 + kind: cfg.kind,
263 + declared_targets: cfg.targets.iter().map(ToString::to_string).collect(),
264 + build,
265 + published_targets,
266 + });
267 + }
268 + Ok(apps)
185 269 }
186 270
187 271 #[derive(Deserialize, Default)]
@@ -379,6 +463,31 @@ repo = "{}"
379 463 assert_eq!(body_string(resp).await, r#"{"build":null}"#);
380 464 }
381 465
466 + #[tokio::test]
467 + async fn status_json_lists_every_declared_app_before_any_build() {
468 + // The mapping is tested in `crate::status`. This asserts the route is
469 + // wired and that an app with no build history still reaches the
470 + // surface -- an app missing from the viewer is indistinguishable from
471 + // an app that is fine.
472 + let tmp = tempfile::tempdir().unwrap();
473 + let app = router(test_state(tmp.path()).await);
474 + let resp = app
475 + .oneshot(Request::builder().uri("/status.json").body(Body::empty()).unwrap())
476 + .await
477 + .unwrap();
478 + assert_eq!(resp.status(), StatusCode::OK);
479 +
480 + let payload: ops_status::Payload = serde_json::from_str(&body_string(resp).await).unwrap();
481 + assert_eq!(payload.source, crate::status::SOURCE);
482 + assert_eq!(payload.schema, ops_status::SCHEMA_VERSION);
483 + assert_eq!(payload.validate(), Ok(()));
484 +
485 + let goingson = payload.node("app:goingson").expect("declared app appears");
486 + assert_eq!(goingson.status, ops_status::Status::Pending);
487 + assert!(payload.actions.contains_key("build-goingson"));
488 + assert!(payload.node("target:goingson:linux/x86_64").is_some());
489 + }
490 +
382 491 // ---- CF2: bearer-token auth on build triggers ----
383 492
384 493 #[tokio::test]
@@ -634,7 +634,7 @@ repo = "{}"
634 634 // fw13 builds linux; `prod` is local-but-restart-only (no build/package).
635 635 std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap();
636 636 let topo = Topology::from_str_for_tests(
637 - &r#"
637 + r#"
638 638 [[host]]
639 639 name = "fw13"
640 640 ssh = "local"
@@ -0,0 +1,770 @@
1 + //! Bento's projection onto the shared operator status payload.
2 + //!
3 + //! Spec + rationale: maintainer wiki.
4 + //! <!-- wiki: release-status-payload -->
5 + //!
6 + //! `GET /status.json` serves this. [`payload`] is a pure function of
7 + //! `(apps, now)`, so a fixture renders identically forever and the mapping is
8 + //! testable without a database.
9 + //!
10 + //! # What maps to what
11 + //!
12 + //! | Bento | payload |
13 + //! |---|---|
14 + //! | app | node, `kind = "app"` |
15 + //! | target | node, `kind = "target"`, child of its app |
16 + //! | step | condition on its target |
17 + //! | build / retry | declared action, with its POST body |
18 + //!
19 + //! # Partial releases are the thing this exists to show
20 + //!
21 + //! Bento's targets are independent: a failed `windows/x86_64` neither stops nor
22 + //! rolls back the macOS build, and publishing is per target with no all-green
23 + //! gate. So "the release went out" and "the release went out on three of five
24 + //! platforms" look identical from `/state`, which reports only the newest build
25 + //! row across every app — one unrelated build is enough to hide another app's
26 + //! half-failure entirely.
27 + //!
28 + //! Two things here answer that. The read is per app, so nothing can be masked
29 + //! by a later build. And an app whose build finished without covering every
30 + //! declared target reports `degraded` with a `release_complete` condition
31 + //! naming what is missing, rather than the `ok` that a build-level status alone
32 + //! would imply.
33 +
34 + use chrono::{DateTime, Utc};
35 + use ops_status::{Action, Condition, Field, Method, Node, Payload, Status, Value};
36 + use serde_json::json;
37 +
38 + use crate::routes::{AppStatusView, BuildView, StepView};
39 + use crate::topology::Kind;
40 +
41 + /// The `source` name Bento answers to in a viewer's config.
42 + pub const SOURCE: &str = "bento";
43 +
44 + /// Restate every app's latest build as the shared payload.
45 + ///
46 + /// `now` is an argument rather than read from the clock so the mapping stays
47 + /// pure and snapshot-testable.
48 + pub(crate) fn payload(apps: &[AppStatusView], now: DateTime<Utc>) -> Payload {
49 + let mut payload = Payload::new(SOURCE, now);
50 +
51 + for app in apps {
52 + let (key, action) = build_action(app);
53 + payload.actions.insert(key, action);
54 + payload.nodes.push(app_node(app));
55 +
56 + for target in target_names(app) {
57 + if let Some((key, action)) = retry_action(app, &target) {
58 + payload.actions.insert(key, action);
59 + }
60 + payload.nodes.push(target_node(app, &target));
61 + }
62 + }
63 +
64 + payload
65 + }
66 +
67 + // ---------------------------------------------------------------------------
68 + // Apps
69 + // ---------------------------------------------------------------------------
70 +
71 + fn app_id(app: &AppStatusView) -> String {
72 + format!("app:{}", app.app)
73 + }
74 +
75 + fn app_node(app: &AppStatusView) -> Node {
76 + let status = app_status(app);
77 + let mut fields = Vec::new();
78 + let mut conditions = Vec::new();
79 +
80 + match &app.build {
81 + Some(build) => {
82 + fields.push(Field::new(
83 + "version",
84 + Value::Version {
85 + value: build.version.clone(),
86 + },
87 + ));
88 + if let Some(at) = parse_instant(&build.created_at) {
89 + fields.push(Field::new("started", Value::Instant { value: at }));
90 + }
91 + conditions.push(Condition {
92 + condition_type: "build".into(),
93 + status: run_status(&build.status),
94 + since: parse_instant(&build.created_at),
95 + detail: Some(format!("build {} {}", build.id, build.status)),
96 + });
97 + if let Some(condition) = release_completeness(app, build) {
98 + conditions.push(condition);
99 + }
100 + if let Some(condition) = publish_completeness(app, build) {
101 + conditions.push(condition);
102 + }
103 + }
104 + None => conditions.push(Condition {
105 + condition_type: "build".into(),
106 + status: Status::Pending,
107 + since: None,
108 + detail: Some("never built".into()),
109 + }),
110 + }
111 +
112 + fields.push(Field::new(
113 + "kind",
114 + Value::Text {
115 + value: match app.kind {
116 + Kind::App => "app".into(),
117 + Kind::Library => "library".into(),
118 + },
119 + },
120 + ));
121 +
122 + Node {
123 + id: app_id(app),
124 + kind: "app".into(),
125 + label: app.app.clone(),
126 + status,
127 + fields,
128 + conditions,
129 + children: target_names(app)
130 + .iter()
131 + .map(|t| target_id(app, t))
132 + .collect(),
133 + actions: vec![build_action_key(app)],
134 + }
135 + }
136 +
137 + fn app_status(app: &AppStatusView) -> Status {
138 + let Some(build) = &app.build else {
139 + return Status::Pending;
140 + };
141 + if build.status == "failed" || build.targets.iter().any(|t| t.status == "failed") {
142 + return Status::Failed;
143 + }
144 + match run_status(&build.status) {
145 + Status::Pending => Status::Pending,
146 + // A build the runner called finished, that did not cover every declared
147 + // target, is a partial release wearing a green build status.
148 + Status::Ok if missing_targets(app, build).is_empty() => Status::Ok,
149 + Status::Ok => Status::Degraded,
150 + other => other,
151 + }
152 + }
153 +
154 + /// Declared targets with no run in the latest build.
155 + fn missing_targets(app: &AppStatusView, build: &BuildView) -> Vec<String> {
156 + app.declared_targets
157 + .iter()
158 + .filter(|d| !build.targets.iter().any(|t| &&t.target == d))
159 + .cloned()
160 + .collect()
161 + }
162 +
163 + /// "3 of 5 targets built" — emitted only once the build has settled, since a
164 + /// running build is *supposed* to have targets outstanding.
165 + fn release_completeness(app: &AppStatusView, build: &BuildView) -> Option<Condition> {
166 + if matches!(run_status(&build.status), Status::Pending) {
167 + return None;
168 + }
169 + let missing = missing_targets(app, build);
170 + let failed: Vec<&str> = build
171 + .targets
172 + .iter()
173 + .filter(|t| t.status == "failed")
174 + .map(|t| t.target.as_str())
175 + .collect();
176 + if missing.is_empty() && failed.is_empty() {
177 + return None;
178 + }
179 +
180 + let mut detail = format!(
181 + "{} of {} declared targets built",
182 + app.declared_targets.len() - missing.len(),
183 + app.declared_targets.len()
184 + );
185 + if !failed.is_empty() {
186 + detail.push_str(&format!("; failed: {}", failed.join(", ")));
187 + }
188 + if !missing.is_empty() {
189 + detail.push_str(&format!("; never ran: {}", missing.join(", ")));
190 + }
191 +
192 + Some(Condition {
193 + condition_type: "release_complete".into(),
194 + status: if failed.is_empty() {
195 + Status::Degraded
196 + } else {
197 + Status::Failed
198 + },
199 + since: None,
200 + detail: Some(detail),
201 + })
202 + }
203 +
204 + /// "2 of 5 targets published" — a partial publish is the same failure as a
205 + /// partial build, one stage later.
206 + ///
207 + /// Silent when nothing has been published for this version. Publishing is not
208 + /// wired for every app, and a condition that reads red for a stage an app does
209 + /// not use is how a viewer teaches you to stop reading it.
210 + fn publish_completeness(app: &AppStatusView, build: &BuildView) -> Option<Condition> {
211 + if app.published_targets.is_empty() {
212 + return None;
213 + }
214 + let expected = app.declared_targets.len();
215 + let published = app.published_targets.len();
216 + Some(Condition {
217 + condition_type: "published".into(),
218 + status: if published >= expected {
219 + Status::Ok
220 + } else {
221 + Status::Degraded
222 + },
223 + since: None,
224 + detail: Some(format!(
225 + "{published} of {expected} targets published at {}",
226 + build.version
227 + )),
228 + })
229 + }
230 +
231 + // ---------------------------------------------------------------------------
232 + // Targets
233 + // ---------------------------------------------------------------------------
234 +
235 + /// Declared targets plus any the latest build ran that the manifest no longer
236 + /// lists, so a target dropped from `bento.toml` mid-release does not vanish
237 + /// from the surface while its run is still the newest thing that happened.
238 + fn target_names(app: &AppStatusView) -> Vec<String> {
239 + let mut names = app.declared_targets.clone();
240 + if let Some(build) = &app.build {
241 + for run in &build.targets {
242 + if !names.contains(&run.target) {
243 + names.push(run.target.clone());
244 + }
245 + }
246 + }
247 + names
248 + }
249 +
250 + fn target_id(app: &AppStatusView, target: &str) -> String {
251 + format!("target:{}:{target}", app.app)
252 + }
253 +
254 + fn target_node(app: &AppStatusView, target: &str) -> Node {
255 + let run = app
256 + .build
257 + .as_ref()
258 + .and_then(|b| b.targets.iter().find(|t| t.target == target));
259 +
260 + let status = match run {
261 + Some(run) => run_status(&run.status),
262 + // Declared but not run in the latest build.
263 + None => Status::Pending,
264 + };
265 +
266 + let mut fields = Vec::new();
267 + let mut conditions = Vec::new();
268 +
269 + if let Some(run) = run {
270 + if let Some(build) = &app.build {
271 + fields.push(Field::new(
272 + "version",
273 + Value::Version {
274 + value: build.version.clone(),
275 + },
276 + ));
277 + }
278 + if let Some(step) = &run.current_step {
279 + fields.push(Field::new(
280 + "step",
281 + Value::Text {
282 + value: step.clone(),
283 + },
284 + ));
285 + }
286 + if app.published_targets.iter().any(|t| t == target) {
287 + fields.push(Field::new("published", Value::State { value: Status::Ok }));
288 + }
289 + conditions.extend(run.steps.iter().map(step_condition));
290 + if let Some(error) = &run.error {
291 + conditions.push(Condition {
292 + condition_type: "error".into(),
293 + status: Status::Failed,
294 + since: None,
295 + detail: Some(error.clone()),
296 + });
297 + }
298 + } else {
299 + conditions.push(Condition {
300 + condition_type: "build".into(),
301 + status: Status::Pending,
302 + since: None,
303 + detail: Some(match &app.build {
304 + Some(build) => format!("no run at {}", build.version),
305 + None => "never built".into(),
306 + }),
307 + });
308 + }
309 +
310 + Node {
311 + id: target_id(app, target),
312 + kind: "target".into(),
313 + label: target.to_string(),
314 + status,
315 + fields,
316 + conditions,
317 + children: Vec::new(),
318 + actions: retry_action(app, target)
319 + .map(|(key, _)| vec![key])
320 + .unwrap_or_default(),
321 + }
322 + }
323 +
324 + fn step_condition(step: &StepView) -> Condition {
325 + Condition {
326 + condition_type: step.step.clone(),
327 + status: run_status(&step.status),
328 + since: None,
329 + detail: Some(match step.status.as_str() {
330 + "running" => "running".into(),
331 + other => other.into(),
332 + }),
333 + }
334 + }
335 +
336 + // ---------------------------------------------------------------------------
337 + // Actions
338 + // ---------------------------------------------------------------------------
339 +
340 + /// `POST /build` and `POST /retry` take their arguments in the body, so the
341 + /// declared action carries one. The viewer posts it verbatim and still learns
342 + /// nothing about what an app or a target is.
343 + fn build_action_key(app: &AppStatusView) -> String {
344 + format!("build-{}", app.app)
345 + }
346 +
347 + fn build_action(app: &AppStatusView) -> (String, Action) {
348 + (
349 + build_action_key(app),
350 + Action {
351 + label: format!("Build {}", app.app),
352 + method: Method::Post,
353 + url: "/build".into(),
354 + confirm: true,
355 + // A library build ends at a crates.io publish, which cannot be
356 + // taken back. An app build is recoverable.
357 + danger: matches!(app.kind, Kind::Library),
358 + body: Some(json!({ "app": app.app })),
359 + },
360 + )
361 + }
362 +
363 + /// Retry is offered only where there is a run to retry.
364 + fn retry_action(app: &AppStatusView, target: &str) -> Option<(String, Action)> {
365 + let build = app.build.as_ref()?;
366 + build.targets.iter().find(|t| t.target == target)?;
367 + Some((
368 + format!("retry-{}-{target}", app.app),
369 + Action {
370 + label: format!("Retry {target}"),
371 + method: Method::Post,
372 + url: "/retry".into(),
373 + confirm: true,
374 + danger: matches!(app.kind, Kind::Library),
375 + body: Some(json!({
376 + "app": app.app,
377 + "target": target,
378 + "version": build.version,
379 + })),
380 + },
381 + ))
382 + }
383 +
384 + // ---------------------------------------------------------------------------
385 +
386 + /// `builds`, `target_runs` and `step_runs` share one status vocabulary:
387 + /// `pending | running | ok | failed`. Anything else is drift, and drift should
388 + /// be visible rather than smoothed into "fine".
389 + fn run_status(raw: &str) -> Status {
390 + match raw {
391 + "ok" => Status::Ok,
392 + "failed" => Status::Failed,
393 + "pending" | "running" => Status::Pending,
394 + _ => Status::Unknown,
395 + }
396 + }
397 +
398 + /// A timestamp that fails to parse costs only itself. A viewer that blanks on
399 + /// one malformed row is worse than one missing a tooltip.
400 + fn parse_instant(raw: &str) -> Option<DateTime<Utc>> {
401 + DateTime::parse_from_rfc3339(raw)
402 + .ok()
403 + .map(|d| d.with_timezone(&Utc))
404 + }
405 +
406 + #[cfg(test)]
407 + mod tests {
408 + use super::*;
409 + use crate::routes::TargetView;
410 +
411 + fn now() -> DateTime<Utc> {
412 + "2026-07-21T18:24:39Z".parse().unwrap()
413 + }
414 +
415 + fn step(name: &str, status: &str) -> StepView {
416 + StepView {
417 + run_id: 1,
418 + step: name.into(),
419 + status: status.into(),
420 + log_ref: None,
421 + }
422 + }
423 +
424 + fn target_run(target: &str, status: &str) -> TargetView {
425 + TargetView {
426 + target: target.into(),
427 + status: status.into(),
428 + current_step: None,
429 + error: None,
430 + steps: vec![step("build", status)],
431 + }
432 + }
433 +
434 + fn build(version: &str, status: &str, targets: Vec<TargetView>) -> BuildView {
435 + BuildView {
436 + id: 7,
437 + app: "goingson".into(),
438 + version: version.into(),
439 + status: status.into(),
440 + created_at: "2026-07-21T14:02:00Z".into(),
441 + targets,
442 + }
443 + }
444 +
445 + fn app(declared: &[&str], build: Option<BuildView>) -> AppStatusView {
446 + AppStatusView {
447 + app: "goingson".into(),
448 + kind: Kind::App,
449 + declared_targets: declared.iter().map(|s| (*s).to_string()).collect(),
450 + build,
451 + published_targets: Vec::new(),
452 + }
453 + }
454 +
455 + fn node<'a>(p: &'a Payload, id: &str) -> &'a Node {
456 + p.node(id).unwrap_or_else(|| panic!("no node {id}"))
457 + }
458 +
459 + #[test]
460 + fn a_fully_built_app_is_ok_and_structurally_sound() {
461 + let a = app(
462 + &["linux/x86_64", "macos/aarch64"],
463 + Some(build(
464 + "1.4.0",
465 + "ok",
466 + vec![
467 + target_run("linux/x86_64", "ok"),
468 + target_run("macos/aarch64", "ok"),
469 + ],
470 + )),
471 + );
472 + let p = payload(&[a], now());
473 +
474 + assert_eq!(p.source, SOURCE);
475 + assert_eq!(p.schema, ops_status::SCHEMA_VERSION);
476 + assert_eq!(p.validate(), Ok(()));
477 + assert_eq!(node(&p, "app:goingson").status, Status::Ok);
478 + assert_eq!(node(&p, "target:goingson:linux/x86_64").status, Status::Ok);
479 + assert_eq!(p.worst_status(), Status::Ok);
480 + }
481 +
482 + #[test]
483 + fn a_failed_target_fails_the_app_and_names_itself() {
484 + // The audit's H2: a failed windows target neither stops nor rolls back
485 + // macOS, and nothing surfaced that the release went out partial.
486 + let a = app(
487 + &["linux/x86_64", "windows/x86_64"],
488 + Some(build(
489 + "1.4.0",
490 + "ok",
491 + vec![
492 + target_run("linux/x86_64", "ok"),
493 + target_run("windows/x86_64", "failed"),
494 + ],
495 + )),
496 + );
497 + let p = payload(&[a], now());
498 +
499 + assert_eq!(node(&p, "app:goingson").status, Status::Failed);
500 + assert_eq!(
Lines truncated