Skip to main content

max / makenotwork

23.8 KB · 645 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 (the 0.10.2 incident). 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 — the highest-value bit — 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 legacy/pre-identity
88 /// callers writes NULL — treated as "no recorded build" downstream, with the
89 /// 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 // Gates are keyed by (app, tier, version); a build run drives the `host` tier.
296 // Latest row per gate_kind, matching `/state`'s per-tier query shape.
297 let gates: Vec<RunGateView> = if let Some(ver) = version.as_deref() {
298 sqlx::query(
299 "SELECT gate_kind, status, log_ref
300 FROM gate_runs g
301 WHERE app = ?1 AND tier = 'host' AND version = ?2
302 AND id = (SELECT MAX(id) FROM gate_runs
303 WHERE app = ?1 AND tier = 'host' AND version = ?2
304 AND gate_kind = g.gate_kind)
305 ORDER BY gate_kind",
306 )
307 .bind(&app)
308 .bind(ver)
309 .fetch_all(pool)
310 .await?
311 .into_iter()
312 .map(|gr| RunGateView {
313 kind: gr.get("gate_kind"),
314 status: gr.get("status"),
315 log_ref: gr.get("log_ref"),
316 })
317 .collect()
318 } else {
319 Vec::new()
320 };
321
322 Ok(Some(RunView {
323 run_id: row.get("id"),
324 sha: row.get("sha"),
325 version,
326 phase: row.get("phase"),
327 result: row.get("result"),
328 started_at: row.get("started_at"),
329 finished_at: row.get("finished_at"),
330 failure_summary: row.get("failure_summary"),
331 gates,
332 }))
333 }
334
335 /// Compact view of the latest build run for `/state`'s liveness line.
336 #[derive(Debug, Serialize)]
337 pub struct BuildSummary {
338 pub run_id: i64,
339 pub sha: String,
340 pub version: Option<String>,
341 pub phase: String,
342 pub result: String,
343 pub failure_summary: Option<String>,
344 /// Seconds from start to finish (or to now while building). Lets a
345 /// `/state` poller show "building <ver>, phase=<x>, elapsed Ns" instead of
346 /// a version frozen at the last success for the whole ~10-min build.
347 pub elapsed_s: i64,
348 }
349
350 /// The most recent build run for `app`, for `/state`. `None` until that
351 /// product's first `/rebuild`.
352 pub async fn latest_summary(pool: &SqlitePool, app: &AppId) -> Result<Option<BuildSummary>> {
353 let Some(row) = sqlx::query(
354 "SELECT id, sha, version, phase, result, failure_summary, started_at, finished_at
355 FROM build_runs WHERE app = ? ORDER BY id DESC LIMIT 1",
356 )
357 .bind(app)
358 .fetch_optional(pool)
359 .await?
360 else {
361 return Ok(None);
362 };
363 let started_at: String = row.get("started_at");
364 let finished_at: Option<String> = row.get("finished_at");
365 Ok(Some(BuildSummary {
366 run_id: row.get("id"),
367 sha: row.get("sha"),
368 version: row.get("version"),
369 phase: row.get("phase"),
370 result: row.get("result"),
371 failure_summary: row.get("failure_summary"),
372 elapsed_s: elapsed_seconds(&started_at, finished_at.as_deref()),
373 }))
374 }
375
376 /// Seconds between an rfc3339 `started_at` and (`finished_at` or now), clamped
377 /// at 0. A parse failure yields 0 rather than erroring the whole `/state` call.
378 fn elapsed_seconds(started_at: &str, finished_at: Option<&str>) -> i64 {
379 let Ok(start) = chrono::DateTime::parse_from_rfc3339(started_at) else {
380 return 0;
381 };
382 let end = match finished_at {
383 Some(f) => chrono::DateTime::parse_from_rfc3339(f)
384 .map_or_else(|_| Utc::now(), |d| d.with_timezone(&Utc)),
385 None => Utc::now(),
386 };
387 (end - start.with_timezone(&Utc)).num_seconds().max(0)
388 }
389
390 /// The summary of the first failed gate for `version` on the host tier, if
391 /// any — used by the build pipeline to populate `failure_summary` when
392 /// `run_all` reports a red pipeline. Reads the typed `outcome_json` so the
393 /// stored headline matches what the TUI renders.
394 pub async fn first_failed_gate_summary(
395 pool: &SqlitePool,
396 app: &AppId,
397 version: &Version,
398 ) -> Option<String> {
399 let row = sqlx::query(
400 "SELECT gate_kind, outcome_json FROM gate_runs
401 WHERE app = ? AND tier = 'host' AND version = ? AND status = 'failed'
402 ORDER BY id ASC LIMIT 1",
403 )
404 .bind(app)
405 .bind(version.to_string())
406 .fetch_optional(pool)
407 .await
408 .ok()
409 .flatten()?;
410 let kind: String = row.get("gate_kind");
411 let outcome_json: Option<String> = row.get("outcome_json");
412 let summary = outcome_json
413 .and_then(|s| serde_json::from_str::<crate::outcome::GateOutcome>(&s).ok())
414 .map_or_else(
415 || "gate failed".to_string(),
416 |o| match o.status {
417 crate::outcome::GateStatus::Failed { failure } => failure.summary(),
418 other => format!("{other:?}"),
419 },
420 );
421 Some(format!("{kind}: {summary}"))
422 }
423
424 #[cfg(test)]
425 mod tests {
426 use super::*;
427 use sqlx::sqlite::SqlitePoolOptions;
428
429 async fn pool() -> SqlitePool {
430 let pool = SqlitePoolOptions::new()
431 .max_connections(1)
432 .connect("sqlite::memory:")
433 .await
434 .unwrap();
435 crate::db::migrate(&pool).await.unwrap();
436 pool
437 }
438
439 /// Two products do not see each other's builds or tier state.
440 ///
441 /// The whole point of the app dimension, in one test. Before it, every one
442 /// of these reads answered from a single global pile: pom's `/state` would
443 /// report MNW's latest build, and advancing pom's `host` tier would move
444 /// MNW's — silently, because a shared row looks exactly like a correct one.
445 #[tokio::test]
446 async fn one_apps_state_is_invisible_to_another() {
447 let pool = pool().await;
448 let mnw = AppId::new("mnw");
449 let pom = AppId::new("pom");
450
451 // Each product needs its own tier and version rows to advance against.
452 for app in [&mnw, &pom] {
453 sqlx::query("INSERT INTO tiers (app, name, ord) VALUES (?, 'host', 0)")
454 .bind(app)
455 .execute(&pool)
456 .await
457 .unwrap();
458 sqlx::query("INSERT INTO tier_state (app, tier) VALUES (?, 'host')")
459 .bind(app)
460 .execute(&pool)
461 .await
462 .unwrap();
463 sqlx::query(
464 "INSERT INTO versions (app, version, git_sha, built_at, artifact_path)
465 VALUES (?, '1.0.0', 'sha', '2026-08-06T00:00:00Z', '/r')",
466 )
467 .bind(app)
468 .execute(&pool)
469 .await
470 .unwrap();
471 }
472
473 let mnw_run = create(&pool, &mnw, "aaaaaaa").await.unwrap();
474 let pom_run = create(&pool, &pom, "bbbbbbb").await.unwrap();
475
476 // Latest build is per product, not "whichever ran last".
477 assert_eq!(
478 latest_summary(&pool, &mnw).await.unwrap().unwrap().sha,
479 "aaaaaaa"
480 );
481 assert_eq!(
482 latest_summary(&pool, &pom).await.unwrap().unwrap().sha,
483 "bbbbbbb",
484 "pom's newest build is pom's, even though mnw's is older"
485 );
486
487 // Advancing one product's `host` tier leaves the other's alone.
488 let v = Version::parse("1.0.0").unwrap();
489 advance_tier(&pool, &pom, "host", &v, Some(pom_run.0))
490 .await
491 .unwrap();
492 let mnw_current: Option<String> =
493 sqlx::query_scalar("SELECT current_version FROM tier_state WHERE app = 'mnw'")
494 .fetch_one(&pool)
495 .await
496 .unwrap();
497 assert_eq!(
498 mnw_current, None,
499 "advancing pom's host tier must not advance mnw's"
500 );
501
502 // And a run resolves its gates through its own product.
503 assert_eq!(get(&pool, mnw_run).await.unwrap().unwrap().sha, "aaaaaaa");
504 }
505
506 #[tokio::test]
507 async fn create_then_get_roundtrips_building() {
508 let pool = pool().await;
509 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
510 let v = get(&pool, id).await.unwrap().expect("run exists");
511 assert_eq!(v.sha, "abc1234");
512 assert_eq!(v.result, "building");
513 assert_eq!(v.phase, "queued");
514 assert!(v.version.is_none());
515 assert!(v.gates.is_empty());
516 assert!(v.failure_summary.is_none());
517 }
518
519 #[tokio::test]
520 async fn recover_orphaned_running_settles_building_runs() {
521 let pool = pool().await;
522 // Two in-flight runs (as if the daemon died mid-build) + one already
523 // settled, which must be left untouched.
524 let run_a = create(&pool, &AppId::default(), "aaaaaaa").await.unwrap();
525 let run_b = create(&pool, &AppId::default(), "bbbbbbb").await.unwrap();
526 let run_c = create(&pool, &AppId::default(), "ccccccc").await.unwrap();
527 mark_passed(&pool, run_c).await.unwrap();
528
529 let reconciled = recover_orphaned_running(&pool).await.unwrap();
530 assert_eq!(reconciled, 2, "both 'building' runs reconciled");
531
532 for id in [run_a, run_b] {
533 let rec = get(&pool, id).await.unwrap().unwrap();
534 assert_eq!(rec.result, "aborted");
535 assert_eq!(rec.phase, "done");
536 assert!(rec.finished_at.is_some());
537 assert_eq!(
538 rec.failure_summary.as_deref(),
539 Some("daemon restarted mid-build")
540 );
541 }
542 // The already-settled run is unchanged.
543 assert_eq!(get(&pool, run_c).await.unwrap().unwrap().result, "passed");
544
545 // Idempotent: a second pass finds nothing to do.
546 assert_eq!(recover_orphaned_running(&pool).await.unwrap(), 0);
547 }
548
549 #[tokio::test]
550 async fn phase_and_version_advance_then_pass() {
551 let pool = pool().await;
552 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
553 set_phase(&pool, id, Phase::Compiling).await.unwrap();
554 let ver: Version = "0.10.2".parse().unwrap();
555 set_version(&pool, id, &ver).await.unwrap();
556 mark_passed(&pool, id).await.unwrap();
557
558 let v = get(&pool, id).await.unwrap().unwrap();
559 assert_eq!(v.result, "passed");
560 assert_eq!(v.phase, "done");
561 assert_eq!(v.version.as_deref(), Some("0.10.2"));
562 assert!(v.finished_at.is_some());
563 }
564
565 #[tokio::test]
566 async fn first_terminal_write_wins() {
567 let pool = pool().await;
568 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
569 mark_failed(&pool, id, "error[E0063]: missing field user_pages_host")
570 .await
571 .unwrap();
572 // A later pass attempt (e.g. the task catch racing a build-step error)
573 // must not overwrite the recorded failure.
574 mark_passed(&pool, id).await.unwrap();
575 // And a second failure summary doesn't clobber the first.
576 mark_failed(&pool, id, "something else").await.unwrap();
577
578 let v = get(&pool, id).await.unwrap().unwrap();
579 assert_eq!(v.result, "failed");
580 assert_eq!(
581 v.failure_summary.as_deref(),
582 Some("error[E0063]: missing field user_pages_host")
583 );
584 }
585
586 #[tokio::test]
587 async fn phase_write_after_terminal_is_noop() {
588 let pool = pool().await;
589 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
590 mark_passed(&pool, id).await.unwrap();
591 set_phase(&pool, id, Phase::Gating).await.unwrap();
592 let v = get(&pool, id).await.unwrap().unwrap();
593 assert_eq!(
594 v.phase, "done",
595 "a late phase write must not move a finished run"
596 );
597 }
598
599 #[test]
600 fn elapsed_seconds_uses_finished_when_present() {
601 // Both timestamps present → exact span, no wall-clock dependency.
602 let s = elapsed_seconds("2026-06-13T00:00:00Z", Some("2026-06-13T00:02:05Z"));
603 assert_eq!(s, 125);
604 // Unparseable start → 0, never a panic / negative.
605 assert_eq!(elapsed_seconds("not-a-date", None), 0);
606 }
607
608 #[tokio::test]
609 async fn latest_summary_reports_most_recent_run() {
610 let pool = pool().await;
611 assert!(
612 latest_summary(&pool, &AppId::default())
613 .await
614 .unwrap()
615 .is_none()
616 );
617 let _old = create(&pool, &AppId::default(), "old1234").await.unwrap();
618 let new = create(&pool, &AppId::default(), "new5678").await.unwrap();
619 set_phase(&pool, new, Phase::Compiling).await.unwrap();
620 let sum = latest_summary(&pool, &AppId::default())
621 .await
622 .unwrap()
623 .expect("a run exists");
624 assert_eq!(sum.run_id, new.0);
625 assert_eq!(sum.sha, "new5678");
626 assert_eq!(sum.phase, "compiling");
627 assert_eq!(sum.result, "building");
628 }
629
630 #[tokio::test]
631 async fn get_unknown_id_is_none() {
632 let pool = pool().await;
633 assert!(get(&pool, RunId(999)).await.unwrap().is_none());
634 }
635
636 #[tokio::test]
637 async fn failure_summary_is_bounded() {
638 let pool = pool().await;
639 let id = create(&pool, &AppId::default(), "abc1234").await.unwrap();
640 mark_failed(&pool, id, &"x".repeat(5_000)).await.unwrap();
641 let v = get(&pool, id).await.unwrap().unwrap();
642 assert!(v.failure_summary.unwrap().len() <= 600);
643 }
644 }
645