Skip to main content

max / makenotwork

25.4 KB · 600 lines History Blame Raw
1 //! Typed gate outcomes.
2 //!
3 //! Replaces the `(passed: bool, detail: Option<String>)` pair on
4 //! `GateOutcome`. The point is to push failure classification into the
5 //! type itself: a `GateFailure::MigrationDrift { migration }` is what it
6 //! says, not a string the operator has to parse. See
7 //! `plans/observability.md` for the full argument.
8 //!
9 //! The variants here describe what the gate runner actually observed.
10 //! Mapping raw process output (stderr tails, exit codes) to these
11 //! variants is the classifier's job — `classify.rs`.
12
13 use crate::domain::{GateKind, Version};
14 use chrono::{DateTime, Utc};
15 use serde::{Deserialize, Serialize};
16
17 /// A gate's result, persisted to `gate_runs.outcome_json` and emitted
18 /// over WS in `GateDone`.
19 #[derive(Debug, Clone, Serialize, Deserialize)]
20 pub struct GateOutcome {
21 pub status: GateStatus,
22 /// Relative path under `cfg.logs_root` to the persisted stdout/stderr
23 /// for this run. `None` for gates that don't produce process output
24 /// (burn_in, manual_confirm).
25 #[serde(skip_serializing_if = "Option::is_none", default)]
26 pub log_ref: Option<LogRef>,
27 }
28
29 impl GateOutcome {
30 pub fn passed(note: PassNote) -> Self {
31 Self {
32 status: GateStatus::Passed { note },
33 log_ref: None,
34 }
35 }
36 pub fn failed(failure: GateFailure) -> Self {
37 Self {
38 status: GateStatus::Failed { failure },
39 log_ref: None,
40 }
41 }
42 pub fn blocked(blocker: GateBlocker) -> Self {
43 Self {
44 status: GateStatus::Blocked { blocker },
45 log_ref: None,
46 }
47 }
48 #[must_use]
49 pub fn with_log_ref(mut self, log_ref: LogRef) -> Self {
50 self.log_ref = Some(log_ref);
51 self
52 }
53
54 /// True iff the gate ran and succeeded. `Blocked` is not passing:
55 /// the gate has not satisfied the pipeline, the operator just owes
56 /// it a precondition.
57 pub fn is_passed(&self) -> bool {
58 matches!(self.status, GateStatus::Passed { .. })
59 }
60
61 /// The high-level status word for the `gate_runs.status` column.
62 pub fn status_str(&self) -> &'static str {
63 match self.status {
64 GateStatus::Passed { .. } => "passed",
65 GateStatus::Failed { .. } => "failed",
66 GateStatus::Blocked { .. } => "blocked",
67 }
68 }
69 }
70
71 #[derive(Debug, Clone, Serialize, Deserialize)]
72 #[serde(tag = "kind", rename_all = "snake_case")]
73 pub enum GateStatus {
74 /// Gate ran and succeeded. The note carries gate-specific evidence
75 /// (e.g. `TestsPassed { duration_s }`).
76 Passed { note: PassNote },
77 /// Gate ran and failed. Two-layer tag: outer `kind = "failed"`, inner
78 /// `failure.kind` names the classified variant. If no classifier
79 /// matched, that's `unclassified`.
80 Failed { failure: GateFailure },
81 /// Gate cannot run yet. Burn-in clock not started, scratch DB not
82 /// configured, backup missing — pre-conditions the operator can fix
83 /// out of band. Distinguished from `Failed` so the TUI can render
84 /// these yellow rather than red.
85 Blocked { blocker: GateBlocker },
86 }
87
88 #[derive(Debug, Clone, Serialize, Deserialize)]
89 #[serde(tag = "kind", rename_all = "snake_case")]
90 pub enum PassNote {
91 /// `boot_smoke` / `code_smoke` — the binary came up and served `GET /health`
92 /// (readiness, not just liveness). `after_ms` is how long until the first
93 /// good probe. For `code_smoke` this is a probe of the real server booted
94 /// against a freshly migrated + seeded throwaway DB; for `boot_smoke` it is
95 /// the minimal no-DB smoke server.
96 HealthyProbe { after_ms: u32 },
97 /// `burn_in` — the configured number of hours have elapsed since
98 /// the gate's clock started.
99 BurnInElapsed { hours: u32 },
100 /// `migration_dry_run` — scratch DB restored from `backup_path` and
101 /// every migration ran without error. `checks` names each configured
102 /// migrations dir that passed; it is empty on rows written before the gate
103 /// ran more than the server's, and `backup_path` is the first check's dump.
104 Migrated {
105 backup_path: String,
106 #[serde(default)]
107 checks: Vec<String>,
108 },
109 /// `cargo_test` — `cargo test --release` exited 0.
110 TestsPassed { duration_s: u32 },
111 /// `manual_confirm` — an operator inserted a passing row out-of-band.
112 OperatorConfirmed { at: DateTime<Utc> },
113 /// `node_health` — every deployed node's service was active and (where a
114 /// `health_url` is configured) served its readiness probe. `nodes` is how
115 /// many nodes were verified.
116 NodesHealthy { nodes: u32 },
117 /// Every checked page loaded with no exception and every island ran.
118 PagesClean { base: String },
119 /// Legacy rows backfilled from the pre-typed schema. Carries the
120 /// original `detail` string so nothing is lost.
121 Legacy { text: String },
122 }
123
124 impl PassNote {
125 pub fn summary(&self) -> String {
126 match self {
127 PassNote::HealthyProbe { after_ms } => format!("served /health in {after_ms}ms"),
128 PassNote::BurnInElapsed { hours } => format!("{hours} hours elapsed"),
129 PassNote::Migrated {
130 backup_path,
131 checks,
132 } => match checks.len() {
133 // A pre-list row, or the single-check case: keep the wording the
134 // operator surface has always shown.
135 0 | 1 => format!("restored {backup_path} + migrated"),
136 n => format!("restored + migrated {n} databases: {}", checks.join(", ")),
137 },
138 PassNote::TestsPassed { duration_s } => format!("tests passed in {duration_s}s"),
139 PassNote::OperatorConfirmed { at } => format!("operator confirmed at {at}"),
140 PassNote::NodesHealthy { nodes } => format!("{nodes} node(s) healthy"),
141 PassNote::PagesClean { base } => format!("pages clean at {base}"),
142 PassNote::Legacy { text } => text.clone(),
143 }
144 }
145 }
146
147 #[derive(Debug, Clone, Serialize, Deserialize)]
148 #[serde(tag = "kind", rename_all = "snake_case")]
149 pub enum GateBlocker {
150 /// `burn_in`: the tier's `tier_state.burn_in_started_at` is NULL.
151 BurnInClockNotStarted,
152 /// `burn_in`: clock running but not enough time elapsed yet.
153 BurnInRemaining {
154 hours_remaining: u32,
155 hours_total: u32,
156 },
157 /// `manual_confirm`: no out-of-band passing row exists for this
158 /// (tier, version).
159 AwaitingOperatorConfirmation,
160 /// `migration_dry_run`: no row in `backups` for the named dump to restore
161 /// from. `check` is the migrations dir that wanted it. Both fields default
162 /// to empty on rows written before the gate took a list of checks.
163 NoBackupAvailable {
164 #[serde(default)]
165 check: String,
166 #[serde(default)]
167 backup: String,
168 },
169 /// `migration_dry_run`: the newest `backups` row for this check's dump is
170 /// older than `cfg.backup_max_age_hours`. Restoring it would dry-run the
171 /// migrations against a schema prod has since moved past, which passes green
172 /// while proving nothing — so the gate blocks instead.
173 BackupStale {
174 age_hours: i64,
175 max_age_hours: u32,
176 #[serde(default)]
177 check: String,
178 },
179 /// `migration_dry_run` / `boot_smoke` / `cargo_test`: daemon config
180 /// has no `scratch_db_url`.
181 ScratchDbUrlUnset,
182 /// `boot_smoke`: no `artifact_path` in `versions` for this version.
183 ArtifactMissing { version: Version },
184 /// `node_health`: the gate ran with no nodes to probe (a serving tier should
185 /// always have nodes; this fails closed so a misconfigured tier can't pass).
186 NoNodesToProbe,
187 /// A gate needs a config value the product did not set.
188 NotConfigured { what: String },
189 }
190
191 impl GateBlocker {
192 pub fn summary(&self) -> String {
193 match self {
194 GateBlocker::BurnInClockNotStarted => "burn-in clock not started".into(),
195 GateBlocker::BurnInRemaining {
196 hours_remaining,
197 hours_total,
198 } => format!("{hours_remaining} hours remaining of {hours_total}"),
199 GateBlocker::AwaitingOperatorConfirmation => "waiting on operator confirmation".into(),
200 GateBlocker::NoBackupAvailable { backup, .. } => {
201 let which = if backup.is_empty() {
202 String::new()
203 } else {
204 format!("{backup} ")
205 };
206 format!("no {which}backup fetched; call /backup/fetch first")
207 }
208 GateBlocker::BackupStale {
209 age_hours,
210 max_age_hours,
211 check,
212 } => {
213 let which = if check.is_empty() {
214 String::new()
215 } else {
216 format!(" for {check}")
217 };
218 format!(
219 "backup{which} is {age_hours}h old (max {max_age_hours}h); re-run \
220 /backup/fetch"
221 )
222 }
223 GateBlocker::ScratchDbUrlUnset => "scratch_db_url unset in daemon config".into(),
224 GateBlocker::ArtifactMissing { version } => {
225 format!("no artifact for version {version}")
226 }
227 GateBlocker::NoNodesToProbe => "node_health has no nodes to probe".into(),
228 GateBlocker::NotConfigured { what } => format!("{what} is not configured"),
229 }
230 }
231 }
232
233 #[derive(Debug, Clone, Serialize, Deserialize)]
234 #[serde(tag = "kind", rename_all = "snake_case")]
235 pub enum GateFailure {
236 /// `cargo_test` exited non-zero. `failed_count` may be 0 if the
237 /// classifier couldn't parse the count (e.g. compile error).
238 /// `first_failed` is the first failing test's name; `first_panic` is the
239 /// first panic *message* (root cause), chosen to skip the "Once instance
240 /// has previously been poisoned" cascade so 800 poisoned tests don't bury
241 /// the one real panic that poisoned them.
242 CargoTest {
243 failed_count: u32,
244 first_failed: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 first_panic: Option<String>,
247 },
248 /// `cargo_test` fast pre-gate (`cargo test --no-run`): the test
249 /// targets failed to compile, so no tests ran. `first_error` is the
250 /// headline diagnostic (e.g. `error[E0063]: missing field
251 /// user_pages_host`) and `error_count` is cargo's "N previous errors".
252 /// Distinct from `CargoTest` so a test-only-target compile break reads
253 /// as a build error, not "0 tests failed".
254 CompileError {
255 error_count: u32,
256 first_error: Option<String>,
257 },
258 /// `migration_dry_run`: a migration that was previously applied is
259 /// no longer present in the resolved migrations directory.
260 MigrationDrift { migration: String },
261 /// `migration_dry_run`: a migration that was previously applied has
262 /// been modified (checksum mismatch).
263 MigrationModified { migration: String },
264 /// `migration_dry_run`: postgres rejected a migration's SQL.
265 MigrationSqlError {
266 migration: String,
267 sqlstate: Option<String>,
268 },
269 /// `migration_dry_run`: scratch DB reset or dump restore failed.
270 RestoreFailed { reason: String },
271 /// `boot_smoke`: binary exited with a non-zero status during the
272 /// smoke window. Most likely a panic; `exit_code` carries the OS
273 /// status when one is available.
274 BootPanic { exit_code: Option<i32> },
275 /// `boot_smoke`: binary exited 0 before the smoke window elapsed.
276 BootExitedEarly { exit_code: Option<i32> },
277 /// `boot_smoke`: binary stayed up but never served `GET /health` 200 within
278 /// the smoke window — started but not ready. `last_error` is the final probe
279 /// error (connection refused, non-200, timeout).
280 BootHealthProbeFailed { last_error: String },
281 /// `node_health`: a deployed node's service was not active, or did not serve
282 /// its `health_url`, within the probe window. `node` is the failing node;
283 /// `detail` carries the probe's stderr/reason.
284 NodeUnhealthy { node: String, detail: String },
285 /// A page loaded but its JavaScript did not run, or an expectation failed.
286 PagesBroken { base: String, detail: String },
287 /// `code_smoke`: creating or dropping the throwaway smoke DB failed (a
288 /// postgres/cluster problem, not the built code). `reason` carries the DB
289 /// error. Kept distinct from the boot/seed failures so an environment issue
290 /// here isn't misread as unsound code.
291 CodeSmokeSetup { reason: String },
292 /// `code_smoke`: the `--seed-examples` run (which migrates the empty DB from
293 /// scratch, then seeds the example catalog, then exits) returned non-zero —
294 /// a broken migration, a seed error, or a config-load failure. The persisted
295 /// gate log carries the process output; `exit_code` is the OS status.
296 CodeSmokeSeed { exit_code: Option<i32> },
297 /// `code_smoke`: the DB-free `MNW_CHECK_DOCS` run found broken internal docs
298 /// links (an internal `[..](x.md)` resolving to a slug no page serves — a
299 /// live 404 waiting in the public docs). Runs first, before the throwaway
300 /// DB is even created, so a rotted link fails cheaply. `broken` is the
301 /// reported count (0 if the sentinel could not be parsed); the gate log
302 /// carries the offending source→target lines.
303 CodeSmokeDocs { broken: u32 },
304 /// `code_smoke`: the booted server answered `GET /health` but never emitted
305 /// its `listening` startup log. The gate asserts both signals — the bind
306 /// readiness log and the health probe — so a missing log line fails even
307 /// when the probe passes.
308 CodeSmokeNoListeningLog,
309 /// `code_smoke`: a configured `frontend_build` failed to compile. `dir` is
310 /// the npm project under the worktree; `exit_code` is npm's status. This is
311 /// the failure both MNW build scripts downgrade to a `cargo::warning` on
312 /// purpose (so a broken chat widget cannot stop the forum compiling) — which
313 /// means this gate is the only place it is ever fatal, and the only thing
314 /// standing between a `tsc` error and a deploy that rsyncs the previous
315 /// build's `static/dist/`.
316 CodeSmokeFrontend { dir: String, exit_code: Option<i32> },
317 /// `cargo_test` / `boot_smoke`: tokio could not spawn the child.
318 SpawnFailed { message: String },
319 /// Gate took longer than the configured ceiling.
320 Timeout { gate: GateKind, after_s: u32 },
321 /// The gate reads a source checkout and this run has none, because the
322 /// artifact was built elsewhere and handed to Sando (wiki
323 /// [[sando-bento-boundary]]). Not a failure of the artifact: a failure of
324 /// the tier's gate list, which is asking Sando to re-prove something about
325 /// bytes it did not compile. Either the gate belongs to the builder, or the
326 /// bundle needs to carry what the gate reads.
327 NeedsSource { gate: GateKind, artifact: String },
328 /// Classifier could not match the output to any known variant. The
329 /// `log_ref` on the enclosing `GateOutcome` is the diagnostic path.
330 Unclassified { legacy_detail: Option<String> },
331 }
332
333 impl GateFailure {
334 pub fn summary(&self) -> String {
335 match self {
336 // The panic message is the diagnostic; prefer it over the test name.
337 GateFailure::CargoTest {
338 failed_count,
339 first_panic: Some(p),
340 ..
341 } => format!("{failed_count} test(s) failed; first panic: {p}"),
342 GateFailure::CargoTest {
343 failed_count,
344 first_failed: Some(name),
345 first_panic: None,
346 } => format!("{failed_count} test(s) failed; first: {name}"),
347 GateFailure::CargoTest {
348 failed_count,
349 first_failed: None,
350 first_panic: None,
351 } => format!("{failed_count} test(s) failed"),
352 GateFailure::CompileError {
353 error_count,
354 first_error: Some(e),
355 } => format!("compile failed ({error_count} error(s)); first: {e}"),
356 GateFailure::CompileError {
357 error_count,
358 first_error: None,
359 } => format!("compile failed ({error_count} error(s))"),
360 GateFailure::MigrationDrift { migration } => {
361 format!("migration {migration} previously applied but missing")
362 }
363 GateFailure::MigrationModified { migration } => {
364 format!("migration {migration} previously applied but modified")
365 }
366 GateFailure::MigrationSqlError {
367 migration,
368 sqlstate: Some(s),
369 } => format!("migration {migration} sql error ({s})"),
370 GateFailure::MigrationSqlError {
371 migration,
372 sqlstate: None,
373 } => format!("migration {migration} sql error"),
374 GateFailure::RestoreFailed { reason } => format!("restore: {reason}"),
375 GateFailure::BootPanic { exit_code: Some(c) } => format!("binary panicked: exit {c}"),
376 GateFailure::BootPanic { exit_code: None } => "binary panicked".into(),
377 GateFailure::BootExitedEarly { exit_code: Some(c) } => {
378 format!("binary exited early: exit {c}")
379 }
380 GateFailure::BootExitedEarly { exit_code: None } => "binary exited early".into(),
381 GateFailure::BootHealthProbeFailed { last_error } => {
382 format!("started but never served /health: {last_error}")
383 }
384 GateFailure::NodeUnhealthy { node, detail } => {
385 format!("node {node} unhealthy: {detail}")
386 }
387 GateFailure::PagesBroken { base, detail } => {
388 format!("page smoke failed at {base}: {detail}")
389 }
390 GateFailure::CodeSmokeSetup { reason } => format!("code smoke db setup: {reason}"),
391 GateFailure::CodeSmokeSeed { exit_code: Some(c) } => {
392 format!("migrate+seed run failed: exit {c}")
393 }
394 GateFailure::CodeSmokeSeed { exit_code: None } => "migrate+seed run failed".into(),
395 GateFailure::CodeSmokeDocs { broken: 0 } => "broken internal docs link(s)".into(),
396 GateFailure::CodeSmokeDocs { broken } => {
397 format!("{broken} broken internal docs link(s)")
398 }
399 GateFailure::CodeSmokeNoListeningLog => {
400 "served /health but never logged 'listening'".into()
401 }
402 GateFailure::CodeSmokeFrontend { dir, exit_code } => match exit_code {
403 Some(c) => format!("frontend build failed in {dir}: exit {c}"),
404 None => format!("frontend build failed in {dir}"),
405 },
406 GateFailure::NeedsSource { gate, artifact } => format!(
407 "{gate} needs a source checkout; this artifact was built elsewhere ({artifact})"
408 ),
409 GateFailure::SpawnFailed { message } => format!("spawn: {message}"),
410 GateFailure::Timeout { gate, after_s } => format!("{gate} timed out after {after_s}s"),
411 GateFailure::Unclassified {
412 legacy_detail: Some(d),
413 } => d.clone(),
414 GateFailure::Unclassified {
415 legacy_detail: None,
416 } => "unclassified failure".into(),
417 }
418 }
419 }
420
421 // ---------------------------------------------------------------------
422 // Deploy outcomes (step 7)
423 // ---------------------------------------------------------------------
424
425 /// Typed outcome of one node-deploy attempt. Stored as `outcome_json` in
426 /// the `deploys` table and emitted in `Event::DeployFailed` so consumers
427 /// can distinguish a node-unreachable error (operator: check the box)
428 /// from rsync mid-transfer corruption (operator: check disk/network).
429 #[derive(Debug, Clone, Serialize, Deserialize)]
430 pub struct DeployOutcome {
431 pub status: DeployStatus,
432 }
433
434 impl DeployOutcome {
435 pub fn ok() -> Self {
436 Self {
437 status: DeployStatus::Ok,
438 }
439 }
440 pub fn failed(failure: DeployFailureKind) -> Self {
441 Self {
442 status: DeployStatus::Failed { failure },
443 }
444 }
445 pub fn in_progress() -> Self {
446 Self {
447 status: DeployStatus::InProgress,
448 }
449 }
450
451 /// `'in_progress' | 'ok' | 'failed'` — the value of the legacy
452 /// `deploys.outcome` column.
453 pub fn status_str(&self) -> &'static str {
454 match self.status {
455 DeployStatus::InProgress => "in_progress",
456 DeployStatus::Ok => "ok",
457 DeployStatus::Failed { .. } => "failed",
458 }
459 }
460 }
461
462 #[derive(Debug, Clone, Serialize, Deserialize)]
463 #[serde(tag = "kind", rename_all = "snake_case")]
464 pub enum DeployStatus {
465 InProgress,
466 Ok,
467 Failed { failure: DeployFailureKind },
468 }
469
470 #[derive(Debug, Clone, Serialize, Deserialize)]
471 #[serde(tag = "kind", rename_all = "snake_case")]
472 pub enum DeployFailureKind {
473 /// SSH to the node failed before any state changed. Typically a dead
474 /// host, network partition, or stale known_hosts.
475 NodeUnreachable { detail: String },
476 /// rsync exited non-zero mid-transfer. The on-target release dir may
477 /// be partially populated, but the `current` symlink is untouched.
478 RsyncFailed { detail: String },
479 /// Files copied successfully but the atomic symlink swap step
480 /// failed. The new release is on disk; the service is still running
481 /// the old one.
482 SymlinkSwapFailed { detail: String },
483 /// Symlink swapped but `systemctl reload-or-restart` returned
484 /// non-zero. The new code is current but the service may have
485 /// crashed on startup.
486 ServiceRestartFailed { detail: String },
487 /// Classifier couldn't match the error to a known variant. The full
488 /// anyhow chain is in `detail`.
489 Unclassified { detail: String },
490 }
491
492 impl DeployFailureKind {
493 pub fn summary(&self) -> String {
494 match self {
495 DeployFailureKind::NodeUnreachable { detail } => format!("node unreachable: {detail}"),
496 DeployFailureKind::RsyncFailed { detail } => format!("rsync: {detail}"),
497 DeployFailureKind::SymlinkSwapFailed { detail } => format!("symlink swap: {detail}"),
498 DeployFailureKind::ServiceRestartFailed { detail } => {
499 format!("service restart: {detail}")
500 }
501 DeployFailureKind::Unclassified { detail } => detail.chars().take(200).collect(),
502 }
503 }
504 }
505
506 /// Pointer to the on-disk gate log: a path relative to `cfg.logs_root`
507 /// of the form `<version>/<gate_kind>.log`. Stored in `gate_runs.log_ref`
508 /// and surfaced in `/state` so the TUI/operator can request the full
509 /// tail via `GET /logs/<version>/<gate>` only when needed.
510 #[derive(Debug, Clone, Serialize, Deserialize)]
511 #[serde(transparent)]
512 pub struct LogRef(pub String);
513
514 impl LogRef {
515 pub fn new(version: &Version, gate: GateKind) -> Self {
516 Self(format!("{}/{}.log", version, gate.as_str()))
517 }
518 pub fn as_str(&self) -> &str {
519 &self.0
520 }
521 }
522
523 impl std::fmt::Display for LogRef {
524 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
525 self.0.fmt(f)
526 }
527 }
528
529 #[cfg(test)]
530 mod tests {
531 use super::*;
532
533 #[test]
534 fn outcome_serialization_is_two_layer_tagged() {
535 let o = GateOutcome::failed(GateFailure::MigrationDrift {
536 migration: "0047_widgets".into(),
537 });
538 let v: serde_json::Value = serde_json::to_value(&o).unwrap();
539 assert_eq!(v["status"]["kind"], "failed");
540 assert_eq!(v["status"]["failure"]["kind"], "migration_drift");
541 assert_eq!(v["status"]["failure"]["migration"], "0047_widgets");
542 }
543
544 #[test]
545 fn outcome_round_trips_through_json() {
546 let o = GateOutcome::passed(PassNote::TestsPassed { duration_s: 42 });
547 let s = serde_json::to_string(&o).unwrap();
548 let back: GateOutcome = serde_json::from_str(&s).unwrap();
549 assert!(back.is_passed());
550 assert_eq!(back.status_str(), "passed");
551 }
552
553 #[test]
554 fn timeout_failure_renders_and_is_not_passed() {
555 // Timeout is now a live failure (cargo_test / migration_dry_run ceilings),
556 // not an aspirational variant — keep it mapped in summary() and serde.
557 let o = GateOutcome::failed(GateFailure::Timeout {
558 gate: GateKind::CargoTest,
559 after_s: 2400,
560 });
561 assert!(!o.is_passed());
562 let v: serde_json::Value = serde_json::to_value(&o).unwrap();
563 assert_eq!(v["status"]["failure"]["kind"], "timeout");
564 assert!(
565 matches!(&o.status, GateStatus::Failed { failure } if failure.summary().contains("timed out"))
566 );
567 }
568
569 #[test]
570 fn blocked_is_not_passed() {
571 let o = GateOutcome::blocked(GateBlocker::BurnInClockNotStarted);
572 assert!(!o.is_passed());
573 assert_eq!(o.status_str(), "blocked");
574 }
575
576 #[test]
577 fn log_ref_construction_matches_disk_layout() {
578 let v: Version = "0.9.6".parse().unwrap();
579 let lr = LogRef::new(&v, GateKind::CargoTest);
580 assert_eq!(lr.as_str(), "0.9.6/cargo_test.log");
581 }
582
583 #[test]
584 fn unclassified_preserves_legacy_detail() {
585 let o = GateOutcome::failed(GateFailure::Unclassified {
586 legacy_detail: Some(
587 "binary exited early: exit status: 101\n==== stdout ====\n...".into(),
588 ),
589 });
590 let v: serde_json::Value = serde_json::to_value(&o).unwrap();
591 assert_eq!(v["status"]["failure"]["kind"], "unclassified");
592 assert!(
593 v["status"]["failure"]["legacy_detail"]
594 .as_str()
595 .unwrap()
596 .contains("exit status: 101")
597 );
598 }
599 }
600