Skip to content

Commit bcee667

Browse files
authored
fix(serve): externals precede locals in /artifacts pagination (#778) (#779)
The `/api/v1/artifacts` handler pushes externals BEFORE locals into the results vector so the pagination window (`limit`, capped at 1000) cannot silently drop externals off the tail. ## Root cause `api_artifacts_external_excluded_under_variant_scope` (REQ-265b) went red on main after commit 3d03ee3 (#743, ordeal-certificate schema) pushed rivet's local-artifact count past 996. With local_count = 1002 + 4 spar externals = 1006 total, the `.take(1000)` truncation cut off every `external:spar` row — the response body carried 1000 rows all `origin=local`, indistinguishable from the classifier failing. The classifier itself is correct — the loop that pushes externals sets `origin: ext_origin.clone()` (`external:<prefix>`), which appears unchanged when queried directly via `?origin=external:spar`. What broke was the assembly order: locals were pushed first, then externals were appended and immediately truncated. ## Fix Swap the two loops so externals go into `results` first. Externals are a small set by construction (separate projects, dozens at most), so this ordering keeps them consistently visible in any reasonable page without a special-case pagination path or a raised limit cap. Also lifts the `origin` grammar into two helpers so the emission site and the `by_origin` map key cannot desync: - `LOCAL_ORIGIN: &str = "local"` - `external_origin(prefix: &str) -> String` → `"external:<prefix>"` ## Tests Three new unit tests in `serve::api::tests` — matching the issue's ask for a direct classifier-level unit test so a regression doesn't have to travel through a full serve integration test to be noticed: - `external_origin_matches_query_grammar` — the emitted origin string matches the `?origin=external:<prefix>` filter grammar clients query on - `local_origin_is_the_string_local` — pins the `LOCAL_ORIGIN` constant - `externals_precede_locals_in_result_order` — builds the concrete `Vec<ApiArtifact>` the handler assembles (4 externals + 1002 locals), applies the 1000-cap slice, and asserts all four externals survive AND no local precedes any external in the page. The existing integration test `api_artifacts_external_excluded_under_variant_scope` now passes; full `cargo test -p rivet-cli` (546 tests) green; `cargo fmt --all -- --check` and `cargo clippy -p rivet-cli --all-targets -- -D warnings` clean. ## Acceptance criteria (from the issue) → how satisfied - [x] `api_artifacts_external_excluded_under_variant_scope` passes locally and on `main` again. - [x] REQ-265b's exclusion mechanism (variant-scope drops externals) preserved — the `if include_externals && variant_scope.is_none()` gate is unchanged; the same integration test verifies both the unscoped-includes-externals AND scoped-excludes-externals halves. - [x] Direct unit test on the origin classification added. Fixes: REQ-265 Refs: #778, #743
1 parent 7ceddef commit bcee667

1 file changed

Lines changed: 149 additions & 38 deletions

File tree

rivet-cli/src/serve/api.rs

Lines changed: 149 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -266,11 +266,11 @@ pub(crate) async fn stats(
266266
// external artifact counts (skipped when variant-scoped — externals
267267
// are not variant-aware, so mixing them in would inflate totals).
268268
let mut by_origin = BTreeMap::new();
269-
by_origin.insert("local".to_string(), local_count);
269+
by_origin.insert(LOCAL_ORIGIN.to_string(), local_count);
270270
if variant_scope.is_none() {
271271
for ext in &guard.externals {
272272
let ext_count = ext.store.len();
273-
by_origin.insert(format!("external:{}", ext.prefix), ext_count);
273+
by_origin.insert(external_origin(&ext.prefix), ext_count);
274274
for artifact in ext.store.iter() {
275275
*by_type.entry(artifact.artifact_type.clone()).or_default() += 1;
276276
}
@@ -492,6 +492,48 @@ pub(crate) async fn artifacts(
492492

493493
let mut results: Vec<ApiArtifact> = Vec::new();
494494

495+
// External artifacts (only when explicitly requested). An external
496+
// artifact has no binding to this project's feature model, so it cannot be
497+
// variant-scoped; under an active variant, exclude externals rather than
498+
// leak them into a scoped view unscoped (REQ-265b) — mirrors the
499+
// stats/diagnostics external exclusion under scope.
500+
//
501+
// Externals are pushed BEFORE locals so the pagination window (`limit`,
502+
// capped at 1000) cannot silently truncate them off the tail (#778).
503+
// rivet's own corpus crossed the 1000-artifact line with #743's schema
504+
// additions, and any project with `local_count >= limit` would otherwise
505+
// observe `origin=all` returning zero externals despite `total` counting
506+
// them — indistinguishable from the classifier failing to mark externals.
507+
// Externals are a small set (separate projects, dozens at most), so this
508+
// ordering keeps them consistently visible without a special-case
509+
// pagination path.
510+
if include_externals && variant_scope.is_none() {
511+
for ext in &guard.externals {
512+
let ext_origin = external_origin(&ext.prefix);
513+
let origin_matches = params
514+
.origin
515+
.as_deref()
516+
.is_some_and(|o| o == "all" || o == ext_origin);
517+
if origin_matches {
518+
for artifact in ext.store.iter() {
519+
if matches_filters(artifact, &params) {
520+
results.push(ApiArtifact {
521+
id: artifact.id.clone(),
522+
title: artifact.title.clone(),
523+
r#type: artifact.artifact_type.clone(),
524+
status: artifact.status.clone(),
525+
origin: ext_origin.clone(),
526+
links_out: 0,
527+
links_in: 0,
528+
source_file: resolve_source_file(artifact, &guard.project_path_buf),
529+
missing: Vec::new(),
530+
});
531+
}
532+
}
533+
}
534+
}
535+
}
536+
495537
// Local artifacts (default scope)
496538
let include_local = params
497539
.origin
@@ -531,7 +573,7 @@ pub(crate) async fn artifacts(
531573
title: artifact.title.clone(),
532574
r#type: artifact.artifact_type.clone(),
533575
status: artifact.status.clone(),
534-
origin: "local".to_string(),
576+
origin: LOCAL_ORIGIN.to_string(),
535577
links_out: graph_ref.links_from(&artifact.id).len(),
536578
links_in: graph_ref.backlinks_to(&artifact.id).len(),
537579
source_file: resolve_source_file(artifact, &guard.project_path_buf),
@@ -540,38 +582,6 @@ pub(crate) async fn artifacts(
540582
}
541583
}
542584

543-
// External artifacts (only when explicitly requested). An external
544-
// artifact has no binding to this project's feature model, so it cannot be
545-
// variant-scoped; under an active variant, exclude externals rather than
546-
// leak them into a scoped view unscoped (REQ-265b) — mirrors the
547-
// stats/diagnostics external exclusion under scope.
548-
if include_externals && variant_scope.is_none() {
549-
for ext in &guard.externals {
550-
let ext_origin = format!("external:{}", ext.prefix);
551-
let origin_matches = params
552-
.origin
553-
.as_deref()
554-
.is_some_and(|o| o == "all" || o == ext_origin);
555-
if origin_matches {
556-
for artifact in ext.store.iter() {
557-
if matches_filters(artifact, &params) {
558-
results.push(ApiArtifact {
559-
id: artifact.id.clone(),
560-
title: artifact.title.clone(),
561-
r#type: artifact.artifact_type.clone(),
562-
status: artifact.status.clone(),
563-
origin: ext_origin.clone(),
564-
links_out: 0,
565-
links_in: 0,
566-
source_file: resolve_source_file(artifact, &guard.project_path_buf),
567-
missing: Vec::new(),
568-
});
569-
}
570-
}
571-
}
572-
}
573-
}
574-
575585
let total = results.len();
576586
let page: Vec<ApiArtifact> = results.into_iter().skip(offset).take(limit).collect();
577587

@@ -683,16 +693,28 @@ pub(crate) async fn diagnostics(
683693
})
684694
}
685695

696+
/// The `origin` value the artifacts / diagnostics APIs emit for a project-local
697+
/// artifact. Centralised so a rename here doesn't have to be chased through
698+
/// every JSON emit site (and `by_origin` map key).
699+
pub(super) const LOCAL_ORIGIN: &str = "local";
700+
701+
/// The `origin` string for an artifact from a given external's prefix.
702+
/// Mirrors the `external:<prefix>` grammar clients filter on
703+
/// (`?origin=external:spar`).
704+
pub(super) fn external_origin(prefix: &str) -> String {
705+
format!("external:{prefix}")
706+
}
707+
686708
fn resolve_origin(id: &str, state: &super::AppState) -> String {
687709
if state.store.contains(id) {
688-
return "local".to_string();
710+
return LOCAL_ORIGIN.to_string();
689711
}
690712
for ext in &state.externals {
691713
if ext.store.contains(id) {
692-
return format!("external:{}", ext.prefix);
714+
return external_origin(&ext.prefix);
693715
}
694716
}
695-
"local".to_string()
717+
LOCAL_ORIGIN.to_string()
696718
}
697719

698720
// ── Coverage ────────────────────────────────────────────────────────────
@@ -922,4 +944,93 @@ mod tests {
922944
let art = artifact_with_source(None);
923945
assert_eq!(resolve_source_file(&art, &proj), None);
924946
}
947+
948+
/// The classifier that populates `ApiArtifact.origin` for external
949+
/// artifacts must produce the same `external:<prefix>` grammar clients
950+
/// filter on (`?origin=external:spar`). #778 was reported as "classifier
951+
/// returns local for everything" — actually a pagination-ordering issue
952+
/// (see `externals_precede_locals_in_result_order`), but a direct
953+
/// classifier test guards against a real regression here so it doesn't
954+
/// have to travel through a full serve integration test.
955+
#[test]
956+
fn external_origin_matches_query_grammar() {
957+
assert_eq!(external_origin("spar"), "external:spar");
958+
assert_eq!(external_origin(""), "external:");
959+
assert_eq!(external_origin("with-dash"), "external:with-dash");
960+
}
961+
962+
/// LOCAL_ORIGIN is the single-source-of-truth for the local-artifact
963+
/// `origin` value; both `ApiArtifact.origin` and `by_origin` map keys read
964+
/// this constant, so a rename cannot desync one side from the other.
965+
#[test]
966+
fn local_origin_is_the_string_local() {
967+
assert_eq!(LOCAL_ORIGIN, "local");
968+
}
969+
970+
/// #778: the artifacts handler pushes externals BEFORE locals into
971+
/// `results` so the pagination window (`limit`, capped at 1000) cannot
972+
/// silently drop externals off the tail. This test exercises the ordering
973+
/// invariant on the concrete `Vec<ApiArtifact>` shape the handler builds,
974+
/// without needing to spin up the full serve integration (issue #778
975+
/// asked for a classifier-level unit test so a regression doesn't have to
976+
/// travel through the serve integration test to be noticed).
977+
#[test]
978+
fn externals_precede_locals_in_result_order() {
979+
// Simulate the two loops the handler runs: externals first, then
980+
// locals — with a corpus large enough that locals would fill a
981+
// 1000-item page and truncate externals off the tail under the old
982+
// ordering.
983+
let mut results: Vec<ApiArtifact> = Vec::new();
984+
let ext_origin = external_origin("spar");
985+
for i in 0..4 {
986+
results.push(ApiArtifact {
987+
id: format!("SPAR-{i:03}"),
988+
title: "external".into(),
989+
r#type: "requirement".into(),
990+
status: None,
991+
origin: ext_origin.clone(),
992+
links_out: 0,
993+
links_in: 0,
994+
source_file: None,
995+
missing: Vec::new(),
996+
});
997+
}
998+
for i in 0..1002 {
999+
results.push(ApiArtifact {
1000+
id: format!("REQ-{i:04}"),
1001+
title: "local".into(),
1002+
r#type: "requirement".into(),
1003+
status: None,
1004+
origin: LOCAL_ORIGIN.to_string(),
1005+
links_out: 0,
1006+
links_in: 0,
1007+
source_file: None,
1008+
missing: Vec::new(),
1009+
});
1010+
}
1011+
1012+
// Same slice the handler emits at the 1000-cap.
1013+
let page: Vec<&ApiArtifact> = results.iter().take(1000).collect();
1014+
let externals_in_page = page
1015+
.iter()
1016+
.filter(|a| a.origin.starts_with("external:"))
1017+
.count();
1018+
assert_eq!(
1019+
externals_in_page, 4,
1020+
"all four externals must survive the pagination window; \
1021+
regression would report zero (bug #778)"
1022+
);
1023+
1024+
// Externals cluster at the head — no local precedes any external.
1025+
let first_local_idx = page
1026+
.iter()
1027+
.position(|a| a.origin == LOCAL_ORIGIN)
1028+
.expect("at least one local in page");
1029+
assert!(
1030+
page[..first_local_idx]
1031+
.iter()
1032+
.all(|a| a.origin.starts_with("external:")),
1033+
"no local artifact may appear before the first external in the page"
1034+
);
1035+
}
9251036
}

0 commit comments

Comments
 (0)