Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/review_classifier/runs.rs

742 lines
27 KiB
Rust

//! `GET /api/review-classifier/runs`: the review classifier's finished runs,
//! try-outs and requested reviews both, newest first.
//!
//! The newest [`MAX_RUNS`] are one JSON array under [`KEY`] in [`BUCKET`],
//! which only this daemon reads or writes. Recording a run is one
//! compare-and-set on that key, as for `schedules::DEFINITIONS_KEY`; one key,
//! because listing a bucket's keys needs a consumer grant this daemon holds
//! for no bucket of its own.
//!
//! Every recorded run is also one `info` event, [`EVENT`], carrying the
//! record's fields, so a run whose write failed is still in the log store.
//! A record holds no diff, no commit text and no token count or cost.
use std::future::Future;
use anyhow::Context as _;
use async_nats::jetstream::kv::Store;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_wire::review_classifier::{Overall, Reply, Row};
use utoipa::ToSchema;
use crate::routes::{AppState, error_problem};
use crate::schedules::{read_with, write};
/// The bucket holding the runs. Agents hold no grant on it.
pub const BUCKET: &str = "review-classifier-runs";
/// The one key in [`BUCKET`].
pub const KEY: &str = "recent";
/// How many runs the list keeps; recording one more drops the oldest.
pub(crate) const MAX_RUNS: usize = 100;
/// How many times a losing writer re-reads and re-applies, as in
/// `schedules::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5;
/// The message of the event [`Runs::record`] logs for every run.
pub(crate) const EVENT: &str = "review classifier run finished";
/// What started a run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Trigger {
/// The swarm UI's try-out page.
TryOut,
/// A review request on the forge.
Review,
}
/// How a run ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum RunOutcome {
/// A review approved the PR.
Approved,
/// A review requested changes.
ChangesRequested,
/// A comment was posted: the request could not be queued, the rules did
/// not load, the PR was unreadable, the run failed, the classifier sent a
/// different number of answers than it was sent classifications, or any
/// classification got none.
NoVerdict,
/// No run started, as every run and wait place was taken; a comment
/// saying so was posted. This build records none; it is here so that a
/// stored record of it still reads.
QueueFull,
/// The review could not be posted.
PostFailed,
/// A try-out the classifier answered.
Done,
/// A try-out that failed.
Failed,
}
/// One finished run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub(crate) struct RunRecord {
/// A try-out's id is the one its POST answered with.
pub(super) id: String,
pub(super) trigger: Trigger,
/// `owner/name`.
pub(super) repo: String,
pub(super) pr: u64,
/// The swarm UI user who started a try-out, or the forge user who
/// requested the review.
pub(super) requester: String,
/// The commit the run read. Absent when the PR was not read.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) head_sha: Option<String>,
/// Unix seconds: a try-out's POST, or a review's start once it had a
/// run place.
pub(super) started_unix: i64,
pub(super) finished_unix: i64,
pub(super) outcome: RunOutcome,
/// Why, for every outcome but `approved`, `changes-requested` and
/// `done`. A review that re-posts an earlier run's verdict names that
/// run here.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) detail: Option<String>,
/// As the try-out GET returns them, when the classifier answered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) rows: Option<Vec<Row>>,
/// As the try-out GET returns it, when the classifier answered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) overall: Option<Overall>,
/// The posted review, when one was posted and the forge named its URL.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) review_url: Option<String>,
}
/// The rows and the overall verdict of `reply`, when the classifier
/// answered.
pub(super) fn answer(reply: &Reply) -> (Option<Vec<Row>>, Option<Overall>) {
match reply {
Reply::Done { rows, overall } => (Some(rows.clone()), Some(*overall)),
Reply::Failed { .. } => (None, None),
}
}
/// One entry of the stored list.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(untagged)]
pub(crate) enum Stored {
Run(RunRecord),
/// A record this build cannot decode, such as one a newer build wrote.
/// Left out of the list but kept, counted toward [`MAX_RUNS`] and written
/// back as it was read, so a newer build reads it again after a
/// roll-forward.
Unreadable(serde_json::Value),
}
/// Where the runs are kept: [`BUCKET`] in production, a fake in the tests.
pub(crate) trait RunDoc {
/// The entries, with the revision to write them back against.
fn read(&self) -> impl Future<Output = anyhow::Result<(Vec<Stored>, Option<u64>)>> + Send;
/// `Ok(false)` when another write landed since `revision`.
fn write(
&self,
runs: Vec<Stored>,
revision: Option<u64>,
) -> impl Future<Output = anyhow::Result<bool>> + Send;
}
impl RunDoc for Store {
async fn read(&self) -> anyhow::Result<(Vec<Stored>, Option<u64>)> {
read_with(self, KEY, |bytes| Ok(decode(bytes))).await
}
async fn write(&self, runs: Vec<Stored>, revision: Option<u64>) -> anyhow::Result<bool> {
write(self, KEY, &runs, revision).await
}
}
/// The entries in `bytes`, the stored document, with a warning when any is
/// [`Stored::Unreadable`]. A document that is not a JSON array reads as no
/// entries, with a warning, and the next write replaces it.
fn decode(bytes: &[u8]) -> Vec<Stored> {
let values: Vec<serde_json::Value> = match serde_json::from_slice(bytes) {
Ok(values) => values,
Err(e) => {
tracing::warn!(
bucket = BUCKET, key = KEY, error = %e,
"the review classifier runs are not decodable; reading them as none"
);
return Vec::new();
}
};
let mut unreadable = 0_usize;
let mut first_error = None;
let entries = values
.into_iter()
.map(|value| match RunRecord::deserialize(&value) {
Ok(run) => Stored::Run(run),
Err(e) => {
unreadable += 1;
first_error.get_or_insert(e);
Stored::Unreadable(value)
}
})
.collect();
if let Some(error) = first_error {
tracing::warn!(
bucket = BUCKET, key = KEY, unreadable, %error,
"review classifier runs this build cannot decode are left out of the list and kept"
);
}
entries
}
/// Put `run` first in `doc`, keeping the newest [`MAX_RUNS`], re-reading and
/// re-applying when another write landed in between.
async fn push(doc: &impl RunDoc, run: &RunRecord) -> anyhow::Result<()> {
for _ in 0..MAX_ATTEMPTS {
let (runs, revision) = doc.read().await?;
if doc.write(newest(run, runs), revision).await? {
return Ok(());
}
tracing::debug!("review classifier runs: write lost a race, retrying");
}
anyhow::bail!("gave up recording the run after {MAX_ATTEMPTS} conflicting writes")
}
/// `runs` with `run` in front, cut to [`MAX_RUNS`] entries.
fn newest(run: &RunRecord, mut runs: Vec<Stored>) -> Vec<Stored> {
runs.insert(0, Stored::Run(run.clone()));
runs.truncate(MAX_RUNS);
runs
}
/// The runs' store, as [`AppState`] holds it.
#[derive(Clone)]
pub(crate) enum Runs {
/// [`BUCKET`] on the swarm queue, when this host has one.
Live(Option<async_nats::Client>),
/// An in-memory document a test reads back.
#[cfg(test)]
Fake(std::sync::Arc<tests::FakeDoc>),
}
/// Why the runs' store could not be reached: always 503.
struct Unreachable(String);
impl Runs {
/// Log `run` and add it to the list. A failed write is logged and
/// changes nothing else.
pub(crate) async fn record(&self, run: RunRecord) {
log(&run);
if let Err(e) = self.push(&run).await {
tracing::warn!(
id = %run.id, error = %format!("{e:#}"),
"storing a review classifier run failed; the run is in this log only"
);
}
}
async fn push(&self, run: &RunRecord) -> anyhow::Result<()> {
match self {
Self::Live(client) => {
let store = open(client.as_ref()).await.map_err(|Unreachable(e)| {
anyhow::anyhow!(e).context(format!("opening the {BUCKET} bucket"))
})?;
push(&store, run).await
}
#[cfg(test)]
Self::Fake(doc) => push(&**doc, run).await,
}
}
/// The newest recorded review of `head` on `repo`'s PR `pr`, if any.
pub(crate) async fn newest_review(
&self,
repo: &str,
pr: u64,
head: &str,
) -> anyhow::Result<Option<RunRecord>> {
let (runs, _) = match self {
Self::Live(client) => {
let store = open(client.as_ref()).await.map_err(|Unreachable(e)| {
anyhow::anyhow!(e).context(format!("opening the {BUCKET} bucket"))
})?;
RunDoc::read(&store).await?
}
#[cfg(test)]
Self::Fake(doc) => doc.read().await?,
};
Ok(runs.into_iter().find_map(|entry| match entry {
Stored::Run(run)
if run.trigger == Trigger::Review
&& run.repo == repo
&& run.pr == pr
&& run.head_sha.as_deref() == Some(head) =>
{
Some(run)
}
_ => None,
}))
}
async fn list(&self) -> Result<Vec<RunRecord>, ProblemDetails> {
let read = match self {
Self::Live(client) => {
let store = open(client.as_ref())
.await
.map_err(|Unreachable(e)| error_problem(StatusCode::SERVICE_UNAVAILABLE, &e))?;
RunDoc::read(&store).await
}
#[cfg(test)]
Self::Fake(doc) => doc.read().await,
};
read.map(|(runs, _)| {
runs.into_iter()
.filter_map(|entry| match entry {
Stored::Run(run) => Some(run),
Stored::Unreadable(_) => None,
})
.collect()
})
.map_err(|e| {
let detail = format!("{e:#}");
tracing::warn!(error = %detail, "reading the review classifier runs failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &detail)
})
}
}
/// Open [`BUCKET`], creating it if nothing has yet.
async fn open(client: Option<&async_nats::Client>) -> Result<Store, Unreachable> {
let client = client
.ok_or_else(|| Unreachable("no swarm queue is configured on this host".to_owned()))?;
swarm_queue_client::ensure_connected(client)
.map_err(|e| Unreachable(swarm_queue_client::chain(&e)))?;
let js = async_nats::jetstream::new(client.clone());
if let Ok(store) = js.get_key_value(BUCKET).await {
return Ok(store);
}
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: BUCKET.to_owned(),
description: "The review classifier's newest finished runs".to_owned(),
history: 1,
..Default::default()
})
.await
.with_context(|| format!("creating the {BUCKET} bucket"))
.map_err(|e| Unreachable(format!("{e:#}")))
}
/// `value` as its one serde word, as the route spells it.
fn word<T: Serialize>(value: &T) -> String {
serde_json::to_value(value)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default()
}
/// The [`EVENT`] for `run`, one field per record field. `rows` is the JSON
/// the route returns for them.
fn log(run: &RunRecord) {
let rows = run
.rows
.as_ref()
.and_then(|rows| serde_json::to_string(rows).ok());
tracing::info!(
id = %run.id,
trigger = %word(&run.trigger),
repo = %run.repo,
pr = run.pr,
requester = %run.requester,
head_sha = run.head_sha.as_deref(),
started_unix = run.started_unix,
finished_unix = run.finished_unix,
outcome = %word(&run.outcome),
detail = run.detail.as_deref(),
rows = rows.as_deref(),
overall = run.overall.as_ref().map(word).as_deref(),
review_url = run.review_url.as_deref(),
"{EVENT}"
);
}
/// List the review classifier's finished runs.
#[utoipa::path(
get,
path = "/api/review-classifier/runs",
responses(
(status = 200, description = "the newest 100 finished runs, try-outs and requested \
reviews, newest first; empty when there are none", body = Vec<RunRecord>),
(status = 503, description = "no swarm queue is configured on this host, it is not \
connected, or the runs' bucket could not be opened (problem+json)", body = String),
(status = 500, description = "the runs could not be read (problem+json)",
body = String),
),
tag = "review-classifier"
)]
pub(crate) async fn get_runs(
State(state): State<AppState>,
) -> Result<Json<Vec<RunRecord>>, ProblemDetails> {
state.runs.list().await.map(Json)
}
#[cfg(test)]
pub(crate) mod tests {
use std::sync::{Arc, Mutex};
use axum::body::Body;
use axum::http::Request;
use swarm_wire::review_classifier::{Polarity, Verdict};
use tower::ServiceExt as _;
use tracing_subscriber::layer::SubscriberExt as _;
use super::*;
/// A document in memory. Each of the first `conflicts` writes loses to a
/// write that lands one run in between; a document set to `fail` fails
/// every read.
#[derive(Default)]
pub(crate) struct FakeDoc {
state: Mutex<(Vec<Stored>, u64)>,
conflicts: Mutex<usize>,
fail: bool,
}
impl FakeDoc {
pub(crate) fn failing() -> Self {
Self {
fail: true,
..Self::default()
}
}
fn conflicting(conflicts: usize) -> Self {
Self {
conflicts: Mutex::new(conflicts),
..Self::default()
}
}
/// The decodable runs, as the list shows them.
pub(crate) fn runs(&self) -> Vec<RunRecord> {
self.entries()
.into_iter()
.filter_map(|entry| match entry {
Stored::Run(run) => Some(run),
Stored::Unreadable(_) => None,
})
.collect()
}
fn entries(&self) -> Vec<Stored> {
self.state.lock().expect("unpoisoned").0.clone()
}
}
impl RunDoc for FakeDoc {
async fn read(&self) -> anyhow::Result<(Vec<Stored>, Option<u64>)> {
anyhow::ensure!(!self.fail, "the store is down");
let (runs, revision) = self.state.lock().expect("unpoisoned").clone();
Ok((runs, Some(revision).filter(|r| *r > 0)))
}
async fn write(&self, runs: Vec<Stored>, revision: Option<u64>) -> anyhow::Result<bool> {
let mut state = self.state.lock().expect("unpoisoned");
let mut conflicts = self.conflicts.lock().expect("unpoisoned");
if *conflicts > 0 {
*conflicts -= 1;
let mut other = record(&format!("other-{conflicts}"));
other.trigger = Trigger::Review;
state.0.insert(0, Stored::Run(other));
state.1 += 1;
}
if revision.unwrap_or(0) != state.1 {
return Ok(false);
}
*state = (runs, state.1 + 1);
Ok(true)
}
}
pub(crate) fn record(id: &str) -> RunRecord {
RunRecord {
id: id.to_owned(),
trigger: Trigger::TryOut,
repo: "o/r".to_owned(),
pr: 7,
requester: "alice".to_owned(),
head_sha: Some("abc123".to_owned()),
started_unix: 1_000,
finished_unix: 1_060,
outcome: RunOutcome::Done,
detail: None,
rows: None,
overall: None,
review_url: None,
}
}
fn ids(runs: &[RunRecord]) -> Vec<&str> {
runs.iter().map(|r| r.id.as_str()).collect()
}
#[tokio::test]
async fn the_newest_hundred_survive_newest_first() {
let doc = FakeDoc::default();
for i in 0..=MAX_RUNS {
push(&doc, &record(&format!("r{i}"))).await.expect("stored");
}
let runs = doc.runs();
assert_eq!(runs.len(), 100);
assert_eq!(runs[0].id, "r100");
assert_eq!(runs[99].id, "r1", "r0, the oldest, is gone");
}
#[tokio::test]
async fn a_write_that_lost_a_race_is_redone_on_the_winner() {
let doc = FakeDoc::conflicting(2);
push(&doc, &record("mine")).await.expect("stored");
assert_eq!(ids(&doc.runs()), ["mine", "other-0", "other-1"]);
let doc = FakeDoc::conflicting(MAX_ATTEMPTS);
let err = push(&doc, &record("mine")).await.expect_err("gives up");
assert!(err.to_string().contains("conflicting writes"), "{err}");
assert!(!ids(&doc.runs()).contains(&"mine"));
}
/// The bucket on a real server: created on first use, written, read back.
#[tokio::test]
async fn runs_round_trip_through_the_bucket() {
let nats = crate::test_nats::TestNats::start();
let runs = Runs::Live(Some(nats.client().await));
runs.record(record("a")).await;
runs.record(record("b")).await;
let listed = runs.list().await.expect("listed");
assert_eq!(ids(&listed), ["b", "a"]);
}
/// A record from a newer build among readable ones, and a document that
/// is no list at all: listing and recording both keep working. The newer
/// record is left out of the list and written back as it was; the
/// non-list document is replaced by one this build decodes.
#[tokio::test]
async fn an_undecodable_record_is_kept_and_a_non_list_replaced() {
let nats = crate::test_nats::TestNats::start();
let client = nats.client().await;
let Ok(store) = open(Some(&client)).await else {
panic!("the bucket did not open");
};
let runs = Runs::Live(Some(client));
let stored = || async {
let entry = store.get(KEY).await.expect("readable").expect("a document");
serde_json::from_slice::<Vec<serde_json::Value>>(&entry).expect("a JSON array")
};
let mut newer = serde_json::to_value(record("newer")).expect("encodes");
newer["outcome"] = "a-later-outcome".into();
let doc = serde_json::json!([record("b"), newer, record("a")]);
store
.put(KEY, doc.to_string().into())
.await
.expect("written");
assert_eq!(ids(&runs.list().await.expect("listed")), ["b", "a"]);
runs.record(record("c")).await;
assert_eq!(ids(&runs.list().await.expect("listed")), ["c", "b", "a"]);
let after = stored().await;
assert_eq!(after.len(), 4, "{after:?}");
assert_eq!(after[2], newer, "kept untouched, in its place");
store
.put(KEY, br#"{"not":"a list"}"#.to_vec().into())
.await
.expect("written");
assert!(runs.list().await.expect("listed").is_empty());
runs.record(record("d")).await;
let after = stored().await;
let decoded: Vec<RunRecord> = serde_json::from_value(after.into()).expect("decodes whole");
assert_eq!(ids(&decoded), ["d"]);
}
#[tokio::test]
async fn an_unreadable_entry_counts_toward_the_hundred() {
let doc = FakeDoc::default();
{
let mut state = doc.state.lock().expect("unpoisoned");
state.0 = (0..MAX_RUNS - 1)
.map(|i| Stored::Run(record(&format!("r{i}"))))
.collect();
state
.0
.insert(50, Stored::Unreadable(serde_json::json!({"from": "later"})));
state.1 = 1;
}
push(&doc, &record("new")).await.expect("stored");
let entries = doc.entries();
assert_eq!(entries.len(), MAX_RUNS);
assert!(entries.iter().any(|e| matches!(e, Stored::Unreadable(_))));
assert_eq!(
doc.runs().len(),
MAX_RUNS - 1,
"the unreadable entry took a run's place"
);
}
#[tokio::test]
async fn the_route_lists_the_runs_newest_first_and_a_down_store_is_an_error() {
let doc = Arc::new(FakeDoc::default());
let mut reviewed = record("b");
reviewed.trigger = Trigger::Review;
reviewed.outcome = RunOutcome::NoVerdict;
reviewed.detail = Some("the rules did not load".to_owned());
reviewed.head_sha = None;
push(&*doc, &record("a")).await.expect("stored");
push(&*doc, &reviewed).await.expect("stored");
let get = |runs: Runs| async move {
let (mut state, _) = crate::routes::tests::state_with_roster();
state.runs = runs;
let res = crate::startup::build_app(state)
.oneshot(
Request::get("/api/review-classifier/runs")
.body(Body::empty())
.expect("a request"),
)
.await
.expect("infallible");
let status = res.status();
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.expect("a body");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("JSON");
(status, body)
};
let (status, body) = get(Runs::Fake(doc)).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body[0]["id"], "b");
assert_eq!(body[0]["trigger"], "review");
assert_eq!(body[0]["outcome"], "no-verdict");
assert_eq!(body[0]["detail"], "the rules did not load");
assert!(body[0].get("head_sha").is_none(), "{body}");
assert_eq!(body[1]["trigger"], "try-out");
assert_eq!(body[1]["outcome"], "done");
let (status, body) = get(Runs::Fake(Arc::new(FakeDoc::failing()))).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{body}");
let (status, body) = get(Runs::Live(None)).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}");
assert_eq!(body["detail"], "no swarm queue is configured on this host");
}
#[test]
fn a_record_carries_no_diff_commit_text_or_cost() {
let mut run = record("a");
run.rows = Some(Vec::new());
run.overall = Some(Overall::Approve);
run.detail = Some("why".to_owned());
run.review_url = Some("https://forge/o/r/pulls/7#issuecomment-1".to_owned());
let value = serde_json::to_value(&run).expect("encodes");
let mut keys: Vec<_> = value.as_object().expect("an object").keys().collect();
keys.sort();
assert_eq!(
keys,
[
"detail",
"finished_unix",
"head_sha",
"id",
"outcome",
"overall",
"pr",
"repo",
"requester",
"review_url",
"rows",
"started_unix",
"trigger",
]
);
}
/// Every field of every event, by name.
#[derive(Clone, Default)]
struct Fields(Arc<Mutex<Vec<(String, String)>>>);
impl tracing::field::Visit for Fields {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0
.lock()
.expect("unpoisoned")
.push((field.name().to_owned(), format!("{value:?}")));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0
.lock()
.expect("unpoisoned")
.push((field.name().to_owned(), value.to_owned()));
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Fields {
fn on_event(
&self,
event: &tracing::Event<'_>,
_: tracing_subscriber::layer::Context<'_, S>,
) {
event.record(&mut self.clone());
}
}
#[test]
fn the_log_event_carries_the_records_fields() {
let mut run = record("a");
run.trigger = Trigger::Review;
run.outcome = RunOutcome::ChangesRequested;
run.rows = Some(vec![Row {
name: "datetime".to_owned(),
polarity: Polarity::Positive,
verdict: Verdict::NoMatch,
certainty: 90,
threshold: 80,
counted: true,
passed: false,
reason: "No zone.".to_owned(),
error: None,
}]);
run.overall = Some(Overall::RequestChanges);
run.review_url = Some("https://forge/o/r/pulls/7#issuecomment-1".to_owned());
let fields = Fields::default();
let subscriber = tracing_subscriber::registry().with(fields.clone());
tracing::subscriber::with_default(subscriber, || log(&run));
let got = fields.0.lock().expect("unpoisoned").clone();
let field = |name: &str| {
got.iter()
.find(|(n, _)| n == name)
.map_or_else(|| panic!("no {name} in {got:?}"), |(_, v)| v.clone())
};
assert_eq!(field("message"), EVENT);
assert_eq!(field("id"), "a");
assert_eq!(field("trigger"), "review");
assert_eq!(field("repo"), "o/r");
assert_eq!(field("pr"), "7");
assert_eq!(field("requester"), "alice");
assert_eq!(field("head_sha"), "abc123");
assert_eq!(field("started_unix"), "1000");
assert_eq!(field("finished_unix"), "1060");
assert_eq!(field("outcome"), "changes-requested");
assert_eq!(field("overall"), "request-changes");
assert_eq!(
field("review_url"),
"https://forge/o/r/pulls/7#issuecomment-1"
);
let rows: Vec<Row> = serde_json::from_str(&field("rows")).expect("the rows as JSON");
assert_eq!(Some(rows), run.rows);
assert!(
!got.iter().any(|(n, _)| n == "detail"),
"an absent field is left out: {got:?}"
);
}
}