Skip to main content

max / makenotwork

24.5 KB · 659 lines History Blame Raw
1 //! Build-run tracking: one `build_runs` row per `/rebuild`, updated as the
2 //! pipeline moves through its phases, terminating in passed/failed/aborted.
3 //!
4 //! This is the resource that makes Sando driveable headlessly. `/state` only
5 //! ever reflects the last *successful* deploy, so on a red pipeline a poller
6 //! of `/state` sees stale-green for the whole build. A
7 //! `RunId` returned by `/rebuild` + `GET /runs/{id}` gives a non-TUI caller
8 //! one pollable resource tied to the build it triggered, carrying the phase,
9 //! the per-gate status, and a `failure_summary`
10 //! (first compile error / first failed gate) so the cause is in the API, not
11 //! behind `sudo journalctl`.
12 //!
13 //! Terminal writes (`mark_passed`/`mark_failed`/`mark_aborted`) are guarded on
14 //! `result = 'building'`, so whichever site settles the run first wins: a
15 //! build-step compile error, the first red gate, or the task-level catch for
16 //! pre-build bails. Later writes are silent no-ops.
17
18 use crate::domain::{AppId, RunId, Version};
19 use anyhow::Result;
20 use chrono::Utc;
21 use serde::Serialize;
22 use sqlx::{Row, SqlitePool};
23
24 /// In-flight sub-state. Plain strings in the DB; this enum names the values so
25 /// call sites can't typo them.
26 #[derive(Debug, Clone, Copy)]
27 pub enum Phase {
28 Fetching,
29 Compiling,
30 Staging,
31 Gating,
32 }
33
34 impl Phase {
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Phase::Fetching => "fetching",
38 Phase::Compiling => "compiling",
39 Phase::Staging => "staging",
40 Phase::Gating => "gating",
41 }
42 }
43 }
44
45 /// Insert a fresh `building` run for `app` at `sha` and return its id.
46 pub async fn create(pool: &SqlitePool, app: &AppId, sha: &str) -> Result<RunId> {
47 let id: i64 = sqlx::query_scalar(
48 "INSERT INTO build_runs (app, sha, phase, result, started_at)
49 VALUES (?, ?, 'queued', 'building', ?) RETURNING id",
50 )
51 .bind(app)
52 .bind(sha)
53 .bind(Utc::now().to_rfc3339())
54 .fetch_one(pool)
55 .await?;
56 Ok(RunId(id))
57 }
58
59 /// Advance the in-flight phase. No-op once the run is terminal so a late
60 /// phase write can't resurrect a finished row.
61 pub async fn set_phase(pool: &SqlitePool, run_id: RunId, phase: Phase) -> Result<()> {
62 sqlx::query("UPDATE build_runs SET phase = ? WHERE id = ? AND result = 'building'")
63 .bind(phase.as_str())
64 .bind(run_id.0)
65 .execute(pool)
66 .await?;
67 Ok(())
68 }
69
70 /// Forward-advance a tier to `version` in a single atomic UPDATE. `previous_version`
71 /// is set from the row's *old* `current_version` (SQLite evaluates every RHS against
72 /// the original row), so there is no read-modify-write to lose under concurrency
73 /// (CF3) — no separate SELECT exists to race. `burn_in_started_at = now` starts the
74 /// tier's burn-in clock.
75 ///
76 /// This is the *only* forward-advance writer of `tier_state`. The host build path
77 /// and `/promote` both go through it; keeping the fetch-then-write shape out of the
78 /// codebase is the point (ultra-fuzz Run 2, S1). Callers MUST hold `deploy_lock` so
79 /// the logical advance is serialized against `/rollback`.
80 ///
81 /// Returns the raw `sqlx::Error` so the route layer can map it to its typed
82 /// `Error::Db`; anyhow callers (the build pipeline) get `?`-conversion for free.
83 /// `build_id` is the `build_runs.id` of the build landing on the tier, the
84 /// artifact identity (wiki [[release-artifact-identity]]). It advances in
85 /// lockstep with the version label: `previous_build_id` takes the old
86 /// `current_build_id` in the same self-referential UPDATE, so the build the
87 /// tier just stepped off is the rollback target. `None` for a caller with no
88 /// build identity writes NULL, treated as "no recorded build" downstream, with
89 /// the version-string path as the fallback.
90 ///
91 /// `burn_in_started_at = now` starts the tier's burn-in clock. Because the
92 /// clock resets on every advance and the clock is what burn-in reads, the clock
93 /// always belongs to the build now current on the tier — this is what stops a
94 /// promote from crediting one build with another build's elapsed burn-in.
95 ///
96 /// `advanced_at = now` records when the tier's deployed identity last changed.
97 /// It tracks the burn-in clock on an advance but, unlike it, is never nulled by
98 /// rollback or reset_burn_in — so the startup reconcile can trust it as the
99 /// instant to compare a `deploys` row against (wiki [[sando-overview]], migration
100 /// 009 / [`crate::reconcile`]).
101 pub async fn advance_tier(
102 pool: &SqlitePool,
103 app: &AppId,
104 tier: &str,
105 version: &Version,
106 build_id: Option<i64>,
107 ) -> Result<(), sqlx::Error> {
108 let now = Utc::now().to_rfc3339();
109 sqlx::query(
110 "UPDATE tier_state
111 SET previous_version = current_version,
112 current_version = ?,
113 previous_build_id = current_build_id,
114 current_build_id = ?,
115 burn_in_started_at = ?,
116 advanced_at = ?
117 WHERE app = ? AND tier = ?",
118 )
119 .bind(version)
120 .bind(build_id)
121 .bind(&now)
122 .bind(&now)
123 .bind(app)
124 .bind(tier)
125 .execute(pool)
126 .await?;
127 Ok(())
128 }
129
130 /// Record the version once it's been read from the worktree's Cargo.toml.
131 pub async fn set_version(pool: &SqlitePool, run_id: RunId, version: &Version) -> Result<()> {
132 sqlx::query("UPDATE build_runs SET version = ? WHERE id = ? AND result = 'building'")
133 .bind(version.to_string())
134 .bind(run_id.0)
135 .execute(pool)
136 .await?;
137 Ok(())
138 }
139
140 /// Record the build's content identity once its bundle has been staged and
141 /// hashed: the full 64-hex `bundle_digest` and the `staged_path` it lives at.
142 /// This is what promote/burn-in/retention key on once build id becomes the
143 /// identity (wiki [[release-artifact-identity]]). Guarded on `building` so a
144 /// settled run is never mutated.
145 pub async fn set_identity(
146 pool: &SqlitePool,
147 run_id: RunId,
148 bundle_digest: &str,
149 staged_path: &str,
150 ) -> Result<()> {
151 sqlx::query(
152 "UPDATE build_runs SET bundle_digest = ?, staged_path = ?
153 WHERE id = ? AND result = 'building'",
154 )
155 .bind(bundle_digest)
156 .bind(staged_path)
157 .bind(run_id.0)
158 .execute(pool)
159 .await?;
160 Ok(())
161 }
162
163 /// Record what the bundle runs on.
164 ///
165 /// Separate from [`set_identity`] and not best-effort: the digest identifies the
166 /// bytes, the platform is what makes two bundles of one version distinguishable,
167 /// and a row that lost it holds an artifact no node declaring a platform will
168 /// accept. A dropped write here is a bundle that can be placed nowhere, so the
169 /// caller is told.
170 pub async fn set_platform(
171 pool: &SqlitePool,
172 run_id: RunId,
173 platform: &crate::domain::Platform,
174 ) -> Result<()> {
175 sqlx::query("UPDATE build_runs SET platform = ? WHERE id = ? AND result = 'building'")
176 .bind(platform.to_string())
177 .bind(run_id.0)
178 .execute(pool)
179 .await?;
180 Ok(())
181 }
182
183 /// Settle the run green. First terminal write wins (guarded on `building`).
184 pub async fn mark_passed(pool: &SqlitePool, run_id: RunId) -> Result<()> {
185 sqlx::query(
186 "UPDATE build_runs SET result = 'passed', phase = 'done', finished_at = ?
187 WHERE id = ? AND result = 'building'",
188 )
189 .bind(Utc::now().to_rfc3339())
190 .bind(run_id.0)
191 .execute(pool)
192 .await?;
193 Ok(())
194 }
195
196 /// Settle the run red with a human-readable cause. First terminal write wins,
197 /// so the most specific failure (build compile error, first red gate) recorded
198 /// before the task-level catch is the one that sticks.
199 pub async fn mark_failed(pool: &SqlitePool, run_id: RunId, summary: &str) -> Result<()> {
200 // Bound the stored summary — it's a headline, not the log. The full output
201 // is at the gate's log_ref / journald.
202 let summary: String = summary.chars().take(600).collect();
203 sqlx::query(
204 "UPDATE build_runs SET result = 'failed', phase = 'done', failure_summary = ?, finished_at = ?
205 WHERE id = ? AND result = 'building'",
206 )
207 .bind(&summary)
208 .bind(Utc::now().to_rfc3339())
209 .bind(run_id.0)
210 .execute(pool)
211 .await?;
212 Ok(())
213 }
214
215 /// Settle the run as superseded by a newer `/rebuild`.
216 pub async fn mark_aborted(pool: &SqlitePool, run_id: RunId) -> Result<()> {
217 sqlx::query(
218 "UPDATE build_runs SET result = 'aborted', phase = 'done',
219 failure_summary = 'superseded by a newer /rebuild', finished_at = ?
220 WHERE id = ? AND result = 'building'",
221 )
222 .bind(Utc::now().to_rfc3339())
223 .bind(run_id.0)
224 .execute(pool)
225 .await?;
226 Ok(())
227 }
228
229 /// Settle any `build_runs` left `result = 'building'` by a daemon that died
230 /// mid-build (crash, OOM, `systemctl restart`, a self-update that SIGKILLed an
231 /// in-flight build). Without this they stay `'building'` forever and `/state` +
232 /// `GET /runs/{id}/wait` report an ever-growing elapsed for a build that will
233 /// never settle (the Run-2 SERIOUS-2 gap). Run once at startup before serving.
234 /// Returns the number of orphaned runs reconciled.
235 pub async fn recover_orphaned_running(pool: &SqlitePool) -> Result<u64> {
236 let res = sqlx::query(
237 "UPDATE build_runs SET result = 'aborted', phase = 'done',
238 failure_summary = 'daemon restarted mid-build', finished_at = ?
239 WHERE result = 'building'",
240 )
241 .bind(Utc::now().to_rfc3339())
242 .execute(pool)
243 .await?;
244 Ok(res.rows_affected())
245 }
246
247 /// One gate's status within a run view.
248 #[derive(Debug, Serialize)]
249 pub struct RunGateView {
250 pub kind: String,
251 /// `'passed' | 'failed' | 'blocked'` or NULL while in-flight.
252 pub status: Option<String>,
253 /// Relative path under `cfg.logs_root` for the full byte stream.
254 pub log_ref: Option<String>,
255 }
256
257 /// The `GET /runs/{id}` payload.
258 #[derive(Debug, Serialize)]
259 pub struct RunView {
260 pub run_id: i64,
261 pub sha: String,
262 pub version: Option<String>,
263 pub phase: String,
264 /// `'building' | 'passed' | 'failed' | 'aborted'`.
265 pub result: String,
266 pub started_at: String,
267 pub finished_at: Option<String>,
268 /// Headline cause when `result = 'failed'`: first compile error or first
269 /// red gate. NULL otherwise.
270 pub failure_summary: Option<String>,
271 /// Gates run on the host tier for this run's version, latest row per kind.
272 /// Empty until the run reaches a version + the gating phase.
273 pub gates: Vec<RunGateView>,
274 }
275
276 /// Load a run plus its host-tier gate statuses. `None` if the id is unknown.
277 pub async fn get(pool: &SqlitePool, run_id: RunId) -> Result<Option<RunView>> {
278 let Some(row) = sqlx::query(
279 "SELECT id, app, sha, version, phase, result, started_at, finished_at, failure_summary
280 FROM build_runs WHERE id = ?",
281 )
282 .bind(run_id.0)
283 .fetch_optional(pool)
284 .await?
285 else {
286 return Ok(None);
287 };
288
289 let version: Option<String> = row.get("version");
290 // The run's own app, read from its row rather than passed in: a run id is
291 // unique across products, and the row is the authority on which product it
292 // belongs to. Asking the caller would let a lookup for one product's run
293 // return another's gates.
294 let app: String = row.get("app");
295 // **This run's gates, keyed on this run.** A build run's id IS the build
296 // identity every gate it ran recorded (`gate_runs.build_id`), so asking for
297 // them is an exact question with an exact answer.
298 //
299 // It was keyed on (tier, version) until 2026-08-20, and a rebuild at an
300 // unchanged version is the normal way to retry a red build: runs 60, 61 and
301 // 62 of mnw-server 0.11.20 wrote over each other's rows, and `/runs/62`
302 // answered with whichever run had touched each gate last. Two reads a minute
303 // apart, same run, no promote between them, disagreed about whether
304 // `hardening_test` had passed or not run at all.
305 //
306 // A pre-migration-008 run left rows with a NULL `build_id` and so reports no
307 // gates here. That is the honest answer for a row that never recorded which
308 // build it vouched for, and the same call the migration made in refusing to
309 // backfill one.
310 let gates: Vec<RunGateView> = sqlx::query(
311 "SELECT gate_kind, status, log_ref
312 FROM gate_runs g
313 WHERE app = ?1 AND build_id = ?2
314 AND id = (SELECT MAX(id) FROM gate_runs
315 WHERE app = ?1 AND build_id = ?2
316 AND gate_kind = g.gate_kind)
317 ORDER BY gate_kind",
318 )
319 .bind(&app)
320 .bind(run_id.0)
321 .fetch_all(pool)
322 .await?
323 .into_iter()
324 .map(|gr| RunGateView {
325 kind: gr.get("gate_kind"),
326 status: gr.get("status"),
327 log_ref: gr.get("log_ref"),
328 })
329 .collect();
330
331 Ok(Some(RunView {
332 run_id: row.get("id"),
333 sha: row.get("sha"),
334 version,
335 phase: row.get("phase"),
336 result: row.get("result"),
337 started_at: row.get("started_at"),
338 finished_at: row.get("finished_at"),
339 failure_summary: row.get("failure_summary"),
340 gates,
341 }))
342 }
343
344 /// Compact view of the latest build run for `/state`'s liveness line.
345 #[derive(Debug, Serialize)]
346 pub struct BuildSummary {
347 pub run_id: i64,
348 pub sha: String,
349 pub version: Option<String>,
350 pub phase: String,
351 pub result: String,
352 pub failure_summary: Option<String>,
353 /// Seconds from start to finish (or to now while building). Lets a
354 /// `/state` poller show "building <ver>, phase=<x>, elapsed Ns" instead of
355 /// a version frozen at the last success for the whole ~10-min build.
356 pub elapsed_s: i64,
357 }
358
359 /// The most recent build run for `app`, for `/state`. `None` until that
360 /// product's first `/rebuild`.
361 pub async fn latest_summary(pool: &SqlitePool, app: &AppId) -> Result<Option<BuildSummary>> {
362 let Some(row) = sqlx::query(
363 "SELECT id, sha, version, phase, result, failure_summary, started_at, finished_at
364 FROM build_runs WHERE app = ? ORDER BY id DESC LIMIT 1",
365 )
366 .bind(app)
367 .fetch_optional(pool)
368 .await?
369 else {
370 return Ok(None);
371 };
372 let started_at: String = row.get("started_at");
373 let finished_at: Option<String> = row.get("finished_at");
374 Ok(Some(BuildSummary {
375 run_id: row.get("id"),
376 sha: row.get("sha"),
377 version: row.get("version"),
378 phase: row.get("phase"),
379 result: row.get("result"),
380 failure_summary: row.get("failure_summary"),
381 elapsed_s: elapsed_seconds(&started_at, finished_at.as_deref()),
382 }))
383 }
384
385 /// Seconds between an rfc3339 `started_at` and (`finished_at` or now), clamped
386 /// at 0. A parse failure yields 0 rather than erroring the whole `/state` call.
387 fn elapsed_seconds(started_at: &str, finished_at: Option<&str>) -> i64 {
388 let Ok(start) = chrono::DateTime::parse_from_rfc3339(started_at) else {
389 return 0;
390 };
391 let end = match finished_at {
392 Some(f) => chrono::DateTime::parse_from_rfc3339(f)
393 .map_or_else(|_| Utc::now(), |d| d.with_timezone(&Utc)),
394 None => Utc::now(),
395 };
396 (end - start.with_timezone(&Utc)).num_seconds().max(0)
397 }
398
399 /// The summary of the first gate `run_id` failed, if any. The build pipeline
400 /// uses it to populate `failure_summary` when `run_all` reports a red pipeline.
401 /// Reads the typed `outcome_json` so the stored headline matches what the TUI
402 /// renders.
403 ///
404 /// Keyed on the run, not on (tier, version). Version-keyed, it would read the
405 /// first failure any run of that version had ever recorded, so a rebuild would
406 /// inherit its predecessor's headline and report a failure beside the same
407 /// gate listed as passed.
408 pub async fn first_failed_gate_summary(
409 pool: &SqlitePool,
410 app: &AppId,
411 run_id: RunId,
412 ) -> Option<String> {
413 let row = sqlx::query(
414 "SELECT gate_kind, outcome_json FROM gate_runs
415 WHERE app = ? AND build_id = ? AND status = 'failed'
416 ORDER BY id ASC LIMIT 1",
417 )
418 .bind(app)
419 .bind(run_id.0)
420 .fetch_optional(pool)
421 .await
422 .ok()
423 .flatten()?;
424 let kind: String = row.get("gate_kind");
425 let outcome_json: Option<String> = row.get("outcome_json");
426 let summary = outcome_json
427 .and_then(|s| serde_json::from_str::<crate::outcome::GateOutcome>(&s).ok())
428 .map_or_else(
429 || "gate failed".to_string(),
430 |o| match o.status {
431 crate::outcome::GateStatus::Failed { failure } => failure.summary(),
432 other => format!("{other:?}"),
433 },
434 );
435 Some(format!("{kind}: {summary}"))
436 }
437
438 #[cfg(test)]
439 mod tests {
440 use super::*;
441 use sqlx::sqlite::SqlitePoolOptions;
442
443 async fn pool() -> SqlitePool {
444 let pool = SqlitePoolOptions::new()
445 .max_connections(1)
446 .connect("sqlite::memory:")
447 .await
448 .unwrap();
449 crate::db::migrate(&pool).await.unwrap();
450 pool
451 }
452
453 /// Two products do not see each other's builds or tier state.
454 ///
455 /// The whole point of the app dimension, in one test. Without it these reads
456 /// answer from a single global pile: pom's `/state` reports MNW's latest
457 /// build, and advancing pom's `host` tier moves MNW's, silently, because a
458 /// shared row looks exactly like a correct one.
459 #[tokio::test]
460 async fn one_apps_state_is_invisible_to_another() {
461 let pool = pool().await;
462 let mnw = AppId::new("mnw");
463 let pom = AppId::new("pom");
464
465 // Each product needs its own tier and version rows to advance against.
466 for app in [&mnw, &pom] {
467 sqlx::query("INSERT INTO tiers (app, name, ord) VALUES (?, 'host', 0)")
468 .bind(app)
469 .execute(&pool)
470 .await
471 .unwrap();
472 sqlx::query("INSERT INTO tier_state (app, tier) VALUES (?, 'host')")
473 .bind(app)
474 .execute(&pool)
475 .await
476 .unwrap();
477 sqlx::query(
478 "INSERT INTO versions (app, version, git_sha, built_at, artifact_path)
479 VALUES (?, '1.0.0', 'sha', '2026-08-06T00:00:00Z', '/r')",
480 )
481 .bind(app)
482 .execute(&pool)
483 .await
484 .unwrap();
485 }
486
487 let mnw_run = create(&pool, &mnw, "aaaaaaa").await.unwrap();
488 let pom_run = create(&pool, &pom, "bbbbbbb").await.unwrap();
489
490 // Latest build is per product, not "whichever ran last".
491 assert_eq!(
492 latest_summary(&pool, &mnw).await.unwrap().unwrap().sha,
493 "aaaaaaa"
494 );
495 assert_eq!(
496 latest_summary(&pool, &pom).await.unwrap().unwrap().sha,
497 "bbbbbbb",
498 "pom's newest build is pom's, even though mnw's is older"
499 );
500
501 // Advancing one product's `host` tier leaves the other's alone.
502 let v = Version::parse("1.0.0").unwrap();
503 advance_tier(&pool, &pom, "host", &v, Some(pom_run.0))
504 .await
505 .unwrap();
506 let mnw_current: Option<String> =
507 sqlx::query_scalar("SELECT current_version FROM tier_state WHERE app = 'mnw'")
508 .fetch_one(&pool)
509 .await
510 .unwrap();
511 assert_eq!(
512 mnw_current, None,
513 "advancing pom's host tier must not advance mnw's"
514 );
515
516 // And a run resolves its gates through its own product.
517 assert_eq!(get(&pool, mnw_run).await.unwrap().unwrap().sha, "aaaaaaa");
518 }
519
520 #[tokio::test]
521 async fn create_then_get_roundtrips_building() {
522 let pool = pool().await;
523 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
524 let v = get(&pool, id).await.unwrap().expect("run exists");
525 assert_eq!(v.sha, "abc1234");
526 assert_eq!(v.result, "building");
527 assert_eq!(v.phase, "queued");
528 assert!(v.version.is_none());
529 assert!(v.gates.is_empty());
530 assert!(v.failure_summary.is_none());
531 }
532
533 #[tokio::test]
534 async fn recover_orphaned_running_settles_building_runs() {
535 let pool = pool().await;
536 // Two in-flight runs (as if the daemon died mid-build) + one already
537 // settled, which must be left untouched.
538 let run_a = create(&pool, &AppId::default(), "aaaaaaa").await.unwrap();
539 let run_b = create(&pool, &AppId::default(), "bbbbbbb").await.unwrap();
540 let run_c = create(&pool, &AppId::default(), "ccccccc").await.unwrap();
541 mark_passed(&pool, run_c).await.unwrap();
542
543 let reconciled = recover_orphaned_running(&pool).await.unwrap();
544 assert_eq!(reconciled, 2, "both 'building' runs reconciled");
545
546 for id in [run_a, run_b] {
547 let rec = get(&pool, id).await.unwrap().unwrap();
548 assert_eq!(rec.result, "aborted");
549 assert_eq!(rec.phase, "done");
550 assert!(rec.finished_at.is_some());
551 assert_eq!(
552 rec.failure_summary.as_deref(),
553 Some("daemon restarted mid-build")
554 );
555 }
556 // The already-settled run is unchanged.
557 assert_eq!(get(&pool, run_c).await.unwrap().unwrap().result, "passed");
558
559 // Idempotent: a second pass finds nothing to do.
560 assert_eq!(recover_orphaned_running(&pool).await.unwrap(), 0);
561 }
562
563 #[tokio::test]
564 async fn phase_and_version_advance_then_pass() {
565 let pool = pool().await;
566 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
567 set_phase(&pool, id, Phase::Compiling).await.unwrap();
568 let ver: Version = "0.10.2".parse().unwrap();
569 set_version(&pool, id, &ver).await.unwrap();
570 mark_passed(&pool, id).await.unwrap();
571
572 let v = get(&pool, id).await.unwrap().unwrap();
573 assert_eq!(v.result, "passed");
574 assert_eq!(v.phase, "done");
575 assert_eq!(v.version.as_deref(), Some("0.10.2"));
576 assert!(v.finished_at.is_some());
577 }
578
579 #[tokio::test]
580 async fn first_terminal_write_wins() {
581 let pool = pool().await;
582 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
583 mark_failed(&pool, id, "error[E0063]: missing field user_pages_host")
584 .await
585 .unwrap();
586 // A later pass attempt (e.g. the task catch racing a build-step error)
587 // must not overwrite the recorded failure.
588 mark_passed(&pool, id).await.unwrap();
589 // And a second failure summary doesn't clobber the first.
590 mark_failed(&pool, id, "something else").await.unwrap();
591
592 let v = get(&pool, id).await.unwrap().unwrap();
593 assert_eq!(v.result, "failed");
594 assert_eq!(
595 v.failure_summary.as_deref(),
596 Some("error[E0063]: missing field user_pages_host")
597 );
598 }
599
600 #[tokio::test]
601 async fn phase_write_after_terminal_is_noop() {
602 let pool = pool().await;
603 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
604 mark_passed(&pool, id).await.unwrap();
605 set_phase(&pool, id, Phase::Gating).await.unwrap();
606 let v = get(&pool, id).await.unwrap().unwrap();
607 assert_eq!(
608 v.phase, "done",
609 "a late phase write must not move a finished run"
610 );
611 }
612
613 #[test]
614 fn elapsed_seconds_uses_finished_when_present() {
615 // Both timestamps present → exact span, no wall-clock dependency.
616 let s = elapsed_seconds("2026-06-13T00:00:00Z", Some("2026-06-13T00:02:05Z"));
617 assert_eq!(s, 125);
618 // Unparseable start → 0, never a panic / negative.
619 assert_eq!(elapsed_seconds("not-a-date", None), 0);
620 }
621
622 #[tokio::test]
623 async fn latest_summary_reports_most_recent_run() {
624 let pool = pool().await;
625 assert!(
626 latest_summary(&pool, &AppId::default())
627 .await
628 .unwrap()
629 .is_none()
630 );
631 let _old = create(&pool, &AppId::default(), "old1234").await.unwrap();
632 let new = create(&pool, &AppId::default(), "new5678").await.unwrap();
633 set_phase(&pool, new, Phase::Compiling).await.unwrap();
634 let sum = latest_summary(&pool, &AppId::default())
635 .await
636 .unwrap()
637 .expect("a run exists");
638 assert_eq!(sum.run_id, new.0);
639 assert_eq!(sum.sha, "new5678");
640 assert_eq!(sum.phase, "compiling");
641 assert_eq!(sum.result, "building");
642 }
643
644 #[tokio::test]
645 async fn get_unknown_id_is_none() {
646 let pool = pool().await;
647 assert!(get(&pool, RunId(999)).await.unwrap().is_none());
648 }
649
650 #[tokio::test]
651 async fn failure_summary_is_bounded() {
652 let pool = pool().await;
653 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
654 mark_failed(&pool, id, &"x".repeat(5_000)).await.unwrap();
655 let v = get(&pool, id).await.unwrap().unwrap();
656 assert!(v.failure_summary.unwrap().len() <= 600);
657 }
658 }
659