roots: degrade unreadable meta entities to a marker instead of a 500
commit 8d9418f
roots: degrade unreadable meta entities to a marker instead of a 500
refs/meta/toolchains/{rust,sccache,zig} in this very repository hold an
older pre-redo schema (recipe stored as a tree, no name entry) that today’s
ents_kiln::Toolchain no longer matches, so GET /toolchains and
GET /toolchains/{name} surfaced facet-git-tree’s read failure as a 500
instead of a page. Every meta page family now catches a per-entity
deserialize failure and renders a muted "unreadable — written by an older
schema" marker (plus the underlying error on the show page) instead,
leaving the rest of the listing and the id link intact.
roots: change render::list_table to take a per-row Result and mark unreadable rows
roots: add render::unreadable for a show page’s marker card
roots: apply the per-entity Result pattern to members/effects/redactions read_all
roots: resolve every toolchain during GET /toolchains to mark unreadable entries
roots: distinguish a missing toolchain ref (404) from an unreadable one (marker) on GET /toolchains/{name}
Assisted-by: Claude:claude-sonnet-5
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/ents-web/src/render.rs
@@ -111,31 +111,51 @@
}
/// A table listing `rows`, one row per `(id, entity)` pair, columns taken
-/// from the entity's own reflected field names -- the generic "list" page
-/// every kernel entity this crate exposes uses.
+/// from the first successfully-read entity's own reflected field names --
+/// the generic "list" page every kernel entity this crate exposes uses.
///
/// `id_header` names the leading column holding each entry's key (a
/// username, an effect name, a redaction id -- whatever names the ref this
/// listing was read from, which is never itself a field on the entity).
///
+/// `entity` is `Err(detail)` for a ref whose stored tree this build's
+/// `#[derive(Facet)]` shape could not read back (written by an older or
+/// unrelated schema): that row still shows its id (linked, exactly like a
+/// readable row, so its own show page -- which renders the same marker,
+/// plus `detail` -- is reachable) and a single muted "unreadable" cell
+/// spanning the rest of the row, rather than being dropped or failing the
+/// whole list. This is this crate's graceful-degradation stance applied
+/// per entity: a reader surfaces a marker, never an error, for one entry
+/// this build can no longer speak the schema of.
+///
/// # Examples
///
/// ```
/// use ents_model::{Member, Provenance};
///
-/// let rows = vec![("jdc".to_owned(), Member::new("key-a", Provenance::AdminRegistered))];
-/// let markup = ents_web::render::list_table(&rows, "username", |id| format!("/members/{id}"));
-/// assert!(markup.into_string().contains("jdc"));
+/// let rows = vec![
+/// ("jdc".to_owned(), Ok(Member::new("key-a", Provenance::AdminRegistered))),
+/// ("legacy".to_owned(), Err("object ... is not a blob".to_owned())),
+/// ];
+/// let rendered = ents_web::render::list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
+/// assert!(rendered.contains("jdc"));
+/// assert!(rendered.contains("legacy"));
+/// assert!(rendered.contains("unreadable"));
/// ```
#[must_use]
pub fn list_table<T: Facet<'static>>(
- rows: &[(String, T)],
+ rows: &[(String, Result<T, String>)],
id_header: &str,
href_for: impl Fn(&str) -> String,
) -> Markup {
let field_names: Vec<&'static str> = rows
- .first()
- .map(|(_, entity)| fields(entity).into_iter().map(|(name, _)| name).collect())
+ .iter()
+ .find_map(|(_, entity)| {
+ entity
+ .as_ref()
+ .ok()
+ .map(|entity| fields(entity).into_iter().map(|(name, _)| name).collect())
+ })
.unwrap_or_default();
html! {
div.card {
@@ -150,11 +170,19 @@
}
tbody {
@for (id, entity) in rows {
- tr {
- td { a href=(href_for(id)) { (id) } }
- @for (_, rendered) in fields(entity) {
- td { (rendered) }
- }
+ @match entity {
+ Ok(entity) => tr {
+ td { a href=(href_for(id)) { (id) } }
+ @for (_, rendered) in fields(entity) {
+ td { (rendered) }
+ }
+ },
+ Err(_) => tr.unreadable {
+ td { a href=(href_for(id)) { (id) } }
+ td colspan=(field_names.len().max(1).to_string()) {
+ "unreadable \u{2014} written by an older schema"
+ }
+ },
}
}
}
@@ -163,6 +191,35 @@
}
}
+/// A muted marker card for one entity this crate could not reflect -- the
+/// `GET /{family}/{id}` show-page counterpart to [`list_table`]'s per-row
+/// marker: the same "unreadable" note, plus `detail` (the underlying
+/// deserialization error) rendered verbatim in muted monospace, so an
+/// operator can diagnose the schema mismatch without leaving the browser.
+/// Never a 500 -- reading an older or unrelated schema's tree degrades to
+/// this card, exactly as [`list_table`] degrades one row of a listing.
+///
+/// # Examples
+///
+/// ```
+/// let rendered = ents_web::render::unreadable("object ... is not a blob").into_string();
+/// assert!(rendered.contains("unreadable"));
+/// assert!(rendered.contains("is not a blob"));
+/// ```
+#[must_use]
+pub fn unreadable(detail: &str) -> Markup {
+ html! {
+ div.card {
+ div.card-row.unreadable {
+ span { "unreadable \u{2014} written by an older schema" }
+ }
+ div.card-row {
+ code.unreadable-detail { (detail) }
+ }
+ }
+ }
+}
+
/// A list of plain strings with no reflected entity behind them (inbox
/// entries, toolchain names) -- deliberately not the [`fields`] mechanism,
/// since there is no struct to reflect over, only a bare list of ids.
@@ -250,14 +307,42 @@
#[rstest]
// @relation(roots.web-agnostic, scope=function, role=Verifies)
- fn list_table_derives_its_columns_from_the_first_rows_own_shape() {
+ fn list_table_derives_its_columns_from_the_first_readable_rows_own_shape() {
let rows = vec![(
"jdc".to_owned(),
- Member::new("key", Provenance::AdminRegistered),
+ Ok(Member::new("key", Provenance::AdminRegistered)),
)];
let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
assert!(markup.contains("username"));
assert!(markup.contains("key"));
assert!(markup.contains("jdc"));
}
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn list_table_marks_an_unreadable_row_but_still_lists_a_readable_one() {
+ let rows = vec![
+ (
+ "jdc".to_owned(),
+ Ok(Member::new("key", Provenance::AdminRegistered)),
+ ),
+ (
+ "legacy".to_owned(),
+ Err("object ... is not a blob".to_owned()),
+ ),
+ ];
+ let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
+ assert!(markup.contains("jdc"));
+ assert!(markup.contains("legacy"));
+ assert!(markup.contains("unreadable"));
+ assert!(markup.contains(r#"href="/members/legacy""#));
+ }
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn unreadable_card_shows_the_underlying_error() {
+ let markup = unreadable("object deadbeef is not a blob").into_string();
+ assert!(markup.contains("unreadable"));
+ assert!(markup.contains("object deadbeef is not a blob"));
+ }
}
crates/cli/ents-web/tests/router.rs
@@ -11,12 +11,17 @@
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
+use ents_kiln::Toolchain;
use ents_model::{Account, MemberId};
use ents_receive::{Mode, NullEventSink};
-use ents_testutil::{Keypair, MemRefStore, ObjectStore};
+use ents_testutil::{
+ CommitSpec, Keypair, MemRefStore, ObjectStore, write_commit, write_meta_entity,
+};
use ents_web::identity::SigningIdentity;
use ents_web::state::AppState;
use gix::bstr::ByteSlice as _;
+use gix_object::tree::{Entry, EntryKind};
+use gix_object::{Kind, Tree, Write as _};
use http_body_util::BodyExt as _;
use tower::ServiceExt as _;
@@ -79,6 +84,75 @@
))
}
+/// Like [`build_state`], but `refs`/`objects` are already populated --
+/// what the toolchain-marker tests below use to seed a ref store directly
+/// with plain `gix_object` writes (a wrong-shape tree no `ents-kiln`
+/// helper would ever produce), rather than through a signed write path.
+fn build_state_with(
+ identity: FixtureIdentity,
+ refs: MemRefStore,
+ objects: ObjectStore,
+) -> Arc<AppState<ObjectStore>> {
+ Arc::new(AppState::new(
+ Box::new(refs),
+ objects,
+ Box::new(NullEventSink),
+ Mode::Advisory,
+ Box::new(identity),
+ std::env::temp_dir(),
+ ))
+}
+
+/// Land a `refs/meta/toolchains/<name>` ref pointing at a tree shaped like
+/// the pre-redo `git_toolchain::Bin` schema this repository's own
+/// `refs/meta/toolchains/{rust,sccache,zig}` still carry: a `recipe` entry
+/// that is itself a tree, not the blob today's `ents_kiln::Toolchain::recipe:
+/// String` expects -- `facet_git_tree::deserialize` reads `recipe` as a
+/// scalar (a blob) and fails with `NotABlob` on exactly this shape, the
+/// same failure `git ents serve` hits reading this repository's own real
+/// legacy toolchain refs (piece 1's bug report). Built from plain
+/// `gix_object` writes, not `ents_kiln::toolchain::import` (which only ever
+/// writes today's shape) or `write_meta_entity` (which only ever writes a
+/// value that already round-trips through `facet_git_tree`).
+fn write_legacy_toolchain(refs: &MemRefStore, objects: &ObjectStore, name: &str) {
+ let name_blob = objects
+ .write_buf(Kind::Blob, name.as_bytes())
+ .expect("write");
+ let recipe_tree = objects
+ .write(&Tree {
+ entries: Vec::new(),
+ })
+ .expect("write");
+ let mut entries = vec![
+ Entry {
+ mode: EntryKind::Blob.into(),
+ filename: "name".into(),
+ oid: name_blob,
+ },
+ Entry {
+ mode: EntryKind::Tree.into(),
+ filename: "recipe".into(),
+ oid: recipe_tree,
+ },
+ ];
+ entries.sort();
+ let tree = objects.write(&Tree { entries }).expect("write");
+ let tip = write_commit(
+ objects,
+ &CommitSpec {
+ tree,
+ parents: Vec::new(),
+ message: format!("legacy toolchain {name}"),
+ seconds: 100,
+ },
+ None,
+ );
+ let refname: gix::refs::FullName = format!("refs/meta/toolchains/{name}")
+ .try_into()
+ .expect("valid refname");
+ refs.set(refname.as_ref(), tip);
+}
+
/// Initialize a real git repository at a fresh tempdir, seed it with
/// `files` (path, contents), and commit them on `HEAD` -- what
/// `crate::pages::files`'s tests below browse.
@@ -1286,3 +1360,97 @@
let body = String::from_utf8(body.to_vec()).expect("utf8 html");
assert!(body.contains("No matches"));
}
+
+/// `GET /toolchains` lists a toolchain written by an older schema (piece
+/// 1's bug: this repository's own `refs/meta/toolchains/{rust,sccache,zig}`
+/// still carry it) as a muted marker row, never a 500 -- and a good
+/// toolchain alongside it still lists and links normally.
+#[tokio::test]
+async fn toolchains_list_marks_a_legacy_entry_but_still_lists_a_good_one() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let name: gix::refs::FullName = "refs/meta/toolchains/good".try_into().expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ name,
+ &Toolchain {
+ name: "good".to_owned(),
+ recipe: "embedded 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n".to_owned(),
+ },
+ None,
+ 100,
+ );
+ write_legacy_toolchain(&refs, &objects, "legacy");
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/toolchains")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains(r#"href="/toolchains/good""#));
+ assert!(body.contains(r#"href="/toolchains/legacy""#));
+ assert!(body.contains("unreadable"));
+}
+
+/// `GET /toolchains/{name}` on a legacy-schema entry renders the marker
+/// card (with the underlying error) rather than a 500.
+#[tokio::test]
+async fn toolchain_show_on_a_legacy_entry_renders_a_marker_not_a_500() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ write_legacy_toolchain(&refs, &objects, "legacy");
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/toolchains/legacy")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("unreadable"));
+ assert!(
+ body.contains("is not a blob"),
+ "the underlying facet-git-tree error renders verbatim: {body}"
+ );
+}
crates/cli/ents-web/src/assets/ents.css
@@ -209,6 +209,23 @@
.string-list a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); }
.string-list a:hover { color: var(--color-accent); }
+/* A per-entity read failure (an older or unrelated schema's tree shape),
+ * on a list row (`entity-list`/`string-list`) or a show page's own card
+ * (`crate::render::unreadable`) -- graceful degradation, never a 500. */
+.entity-list tr.unreadable td, .string-list .unreadable, .card-row.unreadable span {
+ color: var(--color-text-muted);
+ font-style: italic;
+}
+.unreadable-detail {
+ display: block;
+ margin-top: .2rem;
+ color: var(--color-text-muted);
+ font-style: normal;
+ font-family: var(--font-mono);
+ font-size: .78rem;
+ word-break: break-all;
+}
+
/* Bare `dl`/`ul`/`form` markup a custom page (toolchains, comments) emits
* directly, styled the same as the generic views above so a page needs no
* per-page CSS hook to look consistent. */
crates/cli/ents-web/src/pages/effects.rs
@@ -40,7 +40,12 @@
///
/// # Errors
///
-/// [`Error::NotFound`] if `name` has no effect ref.
+/// [`Error::NotFound`] if `name` has no effect ref at all -- an effect ref
+/// that exists but whose stored tree does not match this build's
+/// [`Effect`] shape degrades to [`crate::render::unreadable`] instead
+/// (`roots.web-agnostic`'s graceful-degradation stance); the trigger-query
+/// parse check is skipped in that case, since there is no [`Effect`] to
+/// check.
pub async fn show<O>(
State(state): State<Arc<AppState<O>>>,
Path(name): Path<String>,
@@ -54,23 +59,35 @@
.ok_or_else(|| Error::NotFound {
what: format!("effect {name}"),
})?;
- let query_status = match effect.trigger.parse::<Query>() {
- Ok(_) => "parses".to_owned(),
- Err(error) => format!("does not parse: {error}"),
+ let body = match effect {
+ Ok(effect) => {
+ let query_status = match effect.trigger.parse::<Query>() {
+ Ok(_) => "parses".to_owned(),
+ Err(error) => format!("does not parse: {error}"),
+ };
+ html! {
+ (crate::render::view(&effect))
+ p { "trigger query: " (query_status) }
+ }
+ }
+ Err(detail) => crate::render::unreadable(&detail),
};
Ok(super::layout_meta(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
"/effects",
&name,
- html! {
- (crate::render::view(&effect))
- p { "trigger query: " (query_status) }
- },
+ body,
))
}
-fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Effect)>> {
+/// Every `refs/meta/effects/*` ref, with its tip's tree deserialized as an
+/// [`Effect`] -- `Err(detail)` for a ref this build's `#[derive(Facet)]`
+/// shape could not read back, kept in the listing rather than dropped (see
+/// `crate::pages::members::read_all`'s identical rationale).
+fn read_all<O: Find>(
+ state: &AppState<O>,
+) -> Result<Vec<(String, std::result::Result<Effect, String>)>> {
let mut out = Vec::new();
for entry in state.refs.iter_prefix("refs/meta/effects/")? {
let (name, tip) = entry?;
@@ -78,10 +95,13 @@
let Some(id) = path.strip_prefix("refs/meta/effects/") else {
continue;
};
- let tree = super::commit_tree(&*state.objects(), tip)?;
- if let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, &*state.objects()) {
- out.push((id.to_owned(), effect));
- }
+ let effect = super::commit_tree(&*state.objects(), tip)
+ .map_err(|error| error.to_string())
+ .and_then(|tree| {
+ facet_git_tree::deserialize::<Effect>(&tree, &*state.objects())
+ .map_err(|error| error.to_string())
+ });
+ out.push((id.to_owned(), effect));
}
Ok(out)
}
crates/cli/ents-web/src/pages/members.rs
@@ -36,7 +36,10 @@
///
/// # Errors
///
-/// [`Error::NotFound`] if `username` has no member ref.
+/// [`Error::NotFound`] if `username` has no member ref at all -- a member
+/// ref that exists but whose stored tree does not match this build's
+/// [`Member`] shape degrades to [`crate::render::unreadable`] instead
+/// (`roots.web-agnostic`'s graceful-degradation stance).
pub async fn show<O>(
State(state): State<Arc<AppState<O>>>,
Path(username): Path<String>,
@@ -50,16 +53,29 @@
.ok_or_else(|| Error::NotFound {
what: format!("member {username}"),
})?;
+ let body = match member {
+ Ok(member) => crate::render::view(&member),
+ Err(detail) => crate::render::unreadable(&detail),
+ };
Ok(super::layout_meta(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
"/members",
&username,
- crate::render::view(&member),
+ body,
))
}
-fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Member)>> {
+/// Every `refs/meta/member/*` ref, with its tip's tree deserialized as a
+/// [`Member`] -- `Err(detail)` for a ref this build's `#[derive(Facet)]`
+/// shape could not read back, kept in the listing (not dropped) so
+/// [`list`]/[`show`] can render it as a marker rather than silently
+/// omitting it (`roots.web-agnostic`: a reader surfaces a marker, never an
+/// error or a silent gap, for one entity written by a schema this build no
+/// longer speaks).
+fn read_all<O: Find>(
+ state: &AppState<O>,
+) -> Result<Vec<(String, std::result::Result<Member, String>)>> {
let mut out = Vec::new();
for entry in state.refs.iter_prefix("refs/meta/member/")? {
let (name, tip) = entry?;
@@ -67,10 +83,13 @@
let Some(username) = path.strip_prefix("refs/meta/member/") else {
continue;
};
- let tree = super::commit_tree(&*state.objects(), tip)?;
- if let Ok(member) = facet_git_tree::deserialize::<Member>(&tree, &*state.objects()) {
- out.push((username.to_owned(), member));
- }
+ let member = super::commit_tree(&*state.objects(), tip)
+ .map_err(|error| error.to_string())
+ .and_then(|tree| {
+ facet_git_tree::deserialize::<Member>(&tree, &*state.objects())
+ .map_err(|error| error.to_string())
+ });
+ out.push((username.to_owned(), member));
}
Ok(out)
}
crates/cli/ents-web/src/pages/redactions.rs
@@ -35,7 +35,10 @@
///
/// # Errors
///
-/// [`Error::NotFound`] if `id` has no redaction ref.
+/// [`Error::NotFound`] if `id` has no redaction ref at all -- a redaction
+/// ref that exists but whose stored tree does not match this build's
+/// [`Redaction`] shape degrades to [`crate::render::unreadable`] instead
+/// (`roots.web-agnostic`'s graceful-degradation stance).
pub async fn show<O>(
State(state): State<Arc<AppState<O>>>,
Path(id): Path<String>,
@@ -49,16 +52,27 @@
.ok_or_else(|| Error::NotFound {
what: format!("redaction {id}"),
})?;
+ let body = match redaction {
+ Ok(redaction) => crate::render::view(&redaction),
+ Err(detail) => crate::render::unreadable(&detail),
+ };
Ok(super::layout_meta(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
"/redactions",
&id,
- crate::render::view(&redaction),
+ body,
))
}
-fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Redaction)>> {
+/// Every `refs/meta/redactions/*` ref, with its tip's tree deserialized as
+/// a [`Redaction`] -- `Err(detail)` for a ref this build's
+/// `#[derive(Facet)]` shape could not read back, kept in the listing
+/// rather than dropped (see `crate::pages::members::read_all`'s identical
+/// rationale).
+fn read_all<O: Find>(
+ state: &AppState<O>,
+) -> Result<Vec<(String, std::result::Result<Redaction, String>)>> {
let mut out = Vec::new();
for entry in state.refs.iter_prefix("refs/meta/redactions/")? {
let (name, tip) = entry?;
@@ -66,10 +80,13 @@
let Some(id) = path.strip_prefix("refs/meta/redactions/") else {
continue;
};
- let tree = super::commit_tree(&*state.objects(), tip)?;
- if let Ok(redaction) = facet_git_tree::deserialize::<Redaction>(&tree, &*state.objects()) {
- out.push((id.to_owned(), redaction));
- }
+ let redaction = super::commit_tree(&*state.objects(), tip)
+ .map_err(|error| error.to_string())
+ .and_then(|tree| {
+ facet_git_tree::deserialize::<Redaction>(&tree, &*state.objects())
+ .map_err(|error| error.to_string())
+ });
+ out.push((id.to_owned(), redaction));
}
Ok(out)
}
crates/cli/ents-web/src/pages/toolchains.rs
@@ -15,11 +15,21 @@
use gix_object::{Find, Write};
use maud::html;
-use crate::error::Result;
+use crate::error::{Error, Result};
use crate::state::AppState;
/// `GET /toolchains`.
///
+/// Every name resolves its own recipe (`toolchain::view`) so a name whose
+/// stored tree does not match this build's [`ents_kiln::Toolchain`]/
+/// [`ents_kiln::Recipe`] shape (written by an older schema) renders here as
+/// a muted marker rather than a working link -- the same per-entity
+/// graceful-degradation stance [`crate::render::list_table`] takes for the
+/// other meta families, hand-rolled here since [`toolchain::list`] itself
+/// only enumerates ref names, with no reflected entity for
+/// [`crate::render`]'s generic machinery to walk (this page family's own
+/// top-level doc).
+///
/// # Errors
///
/// Propagates a ref-store read failure.
@@ -28,12 +38,34 @@
O: Find + Write + Send + 'static,
{
let names = toolchain::list(state.refs.as_ref())?;
+ let rows: Vec<(String, Option<String>)> = names
+ .into_iter()
+ .map(|name| {
+ let detail = toolchain::view(state.refs.as_ref(), &*state.objects(), &name)
+ .err()
+ .map(|error| error.to_string());
+ (name, detail)
+ })
+ .collect();
Ok(super::layout_meta(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
"/toolchains",
"toolchains",
- crate::render::string_list(&names, |name| format!("/toolchains/{name}")),
+ html! {
+ div.card {
+ ul.string-list {
+ @for (name, detail) in &rows {
+ li {
+ a href=(format!("/toolchains/{name}")) { (name) }
+ @if detail.is_some() {
+ span.unreadable { "unreadable \u{2014} written by an older schema" }
+ }
+ }
+ }
+ }
+ }
+ },
))
}
@@ -42,8 +74,15 @@
///
/// # Errors
///
-/// Propagates an `ents-kiln` lookup failure (wrapped as
-/// [`crate::Error::Effect`]) if `name` has no toolchain ref.
+/// [`Error::NotFound`] if `name` has no toolchain ref at all
+/// ([`ents_effect::Error::UnknownToolchain`]) -- a toolchain ref that
+/// exists but whose stored tree does not match this build's
+/// [`ents_kiln::Toolchain`]/[`ents_kiln::Recipe`] shape degrades to
+/// [`crate::render::unreadable`] instead (`roots.web-agnostic`'s
+/// graceful-degradation stance). The import log is best-effort once the
+/// recipe itself reads back: a log entry this build cannot decode renders
+/// as an empty log rather than failing the whole page, since the recipe is
+/// this page's primary content.
pub async fn show<O>(
State(state): State<Arc<AppState<O>>>,
Path(name): Path<String>,
@@ -51,24 +90,35 @@
where
O: Find + Write + Send + 'static,
{
- let (toolchain, recipe) = toolchain::view(state.refs.as_ref(), &*state.objects(), &name)?;
- let log = toolchain::log(state.refs.as_ref(), &*state.objects(), &name)?;
+ let body = match toolchain::view(state.refs.as_ref(), &*state.objects(), &name) {
+ Ok((toolchain, recipe)) => {
+ let log =
+ toolchain::log(state.refs.as_ref(), &*state.objects(), &name).unwrap_or_default();
+ html! {
+ dl {
+ dt { "name" } dd { (toolchain.name) }
+ dt { "recipe" } dd { (format!("{recipe:?}")) }
+ }
+ h2 { "import log" }
+ ul {
+ @for oid in &log {
+ li { (oid.to_string()) }
+ }
+ }
+ }
+ }
+ Err(ents_effect::Error::UnknownToolchain(_)) => {
+ return Err(Error::NotFound {
+ what: format!("toolchain {name}"),
+ });
+ }
+ Err(error) => crate::render::unreadable(&error.to_string()),
+ };
Ok(super::layout_meta(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
"/toolchains",
&name,
- html! {
- dl {
- dt { "name" } dd { (toolchain.name) }
- dt { "recipe" } dd { (format!("{recipe:?}")) }
- }
- h2 { "import log" }
- ul {
- @for oid in &log {
- li { (oid.to_string()) }
- }
- }
- },
+ body,
))
}