Skip to content

Commit e469a4e

Browse files
author
Chris Huber
committed
fix(runs): bound artifact inventory (#11089)
AI assistance: OpenAI gpt-5.6-sol via OpenCode implemented the bounded, filtered artifact inventory and tests; Chris Huber reviewed and owns this change.
1 parent 010df5d commit e469a4e

11 files changed

Lines changed: 438 additions & 27 deletions

File tree

crates/homeboy-cli/src/commands/runs/artifact_index_tests.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use homeboy::core::observation::{NewRunRecord, ObservationStore, RunStatus};
22
use homeboy::test_support::with_isolated_home;
33
use serde_json::Value;
44

5+
use super::types::RunsArtifactsArgs;
56
use super::{handlers, list_runs, RunsListArgs, RunsOutput};
67

78
struct XdgGuard(Option<String>);
@@ -223,6 +224,90 @@ fn runs_artifacts_surfaces_matrix_summary_from_typed_packets() {
223224
});
224225
}
225226

227+
#[test]
228+
fn runs_artifacts_pages_large_inventory_and_filters_before_rendering() {
229+
with_isolated_home(|home| {
230+
let _xdg = XdgGuard::unset();
231+
let store = ObservationStore::open_initialized().expect("store");
232+
let run = store
233+
.start_run(sample_run(
234+
"bench",
235+
"homeboy",
236+
"inventory-rig",
237+
serde_json::json!({}),
238+
))
239+
.expect("run");
240+
let artifact_path = home.path().join("visual.png");
241+
std::fs::write(&artifact_path, b"png").expect("artifact bytes");
242+
for index in 0..240 {
243+
store
244+
.record_artifact_with_metadata(
245+
&run.id,
246+
if index % 3 == 0 { "visual_diff" } else { "log" },
247+
&artifact_path,
248+
serde_json::json!({
249+
"fixture_id": if index % 3 == 0 { "fixture-89" } else { "other" },
250+
"surface_id": if index % 3 == 0 { "desktop" } else { "other" },
251+
"scenario_id": "visual-regression",
252+
}),
253+
)
254+
.expect("record artifact");
255+
}
256+
257+
let (output, _) = handlers::artifacts_from_args(RunsArtifactsArgs {
258+
run_id: run.id.clone(),
259+
runner: None,
260+
pull: false,
261+
pull_dir: None,
262+
token: None,
263+
kind: None,
264+
mime: None,
265+
original_path: None,
266+
path_suffix: None,
267+
fixture: None,
268+
surface: None,
269+
scenario: None,
270+
name_glob: None,
271+
limit: 50,
272+
offset: 0,
273+
full: false,
274+
})
275+
.expect("bounded listing");
276+
let RunsOutput::Artifacts(output) = output else {
277+
panic!("artifacts output")
278+
};
279+
assert_eq!(output.artifacts.len(), 50);
280+
let page = output.page.expect("page metadata");
281+
assert_eq!(page.total, 240);
282+
assert_eq!(page.next_offset, Some(50));
283+
284+
let (output, _) = handlers::artifacts_from_args(RunsArtifactsArgs {
285+
run_id: run.id,
286+
runner: None,
287+
pull: false,
288+
pull_dir: None,
289+
token: None,
290+
kind: Some("visual_diff".to_string()),
291+
mime: Some("image/png".to_string()),
292+
original_path: None,
293+
path_suffix: Some("visual.png".to_string()),
294+
fixture: Some("fixture-89".to_string()),
295+
surface: Some("desktop".to_string()),
296+
scenario: Some("visual-regression".to_string()),
297+
name_glob: Some("visual_*".to_string()),
298+
limit: 100,
299+
offset: 0,
300+
full: false,
301+
})
302+
.expect("filtered listing");
303+
let RunsOutput::Artifacts(output) = output else {
304+
panic!("artifacts output")
305+
};
306+
assert_eq!(output.artifacts.len(), 80);
307+
assert_eq!(output.page.expect("page metadata").total, 80);
308+
});
309+
}
310+
226311
#[test]
227312
fn runs_artifacts_classifies_persisted_bench_artifact_from_metadata() {
228313
with_isolated_home(|home| {

crates/homeboy-cli/src/commands/runs/handlers.rs

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ use homeboy::core::artifact_address::ArtifactAddress;
1414
use homeboy::core::observation::evidence_report::directory_publication_guidance;
1515
use homeboy::core::observation::runs_service;
1616
use homeboy::core::observation::{
17-
merge_metadata, FindingListFilter, ObservationStore, RunListFilter, RunRecord, RunStatus,
17+
merge_metadata, ArtifactListFilter, FindingListFilter, ObservationStore, RunListFilter,
18+
RunRecord, RunStatus,
1819
};
1920
use homeboy::core::resource_lifecycle_index::resource_lifecycle_index_from_artifacts;
2021
use homeboy::core::validation_progress::ValidationProgressLedger;
@@ -27,11 +28,12 @@ use super::common::{run_summaries_with_artifact_indexes, RunSummary};
2728
use super::types::{
2829
actionable_for_run_detail, actionable_for_run_list, RunDetail, RunsArtifactArgs,
2930
RunsArtifactCommand, RunsArtifactCommandHint, RunsArtifactGetArgs, RunsArtifactGetOutput,
30-
RunsArtifactPathGuide, RunsArtifactPullEntry, RunsArtifactPullSummary, RunsArtifactsArgs,
31-
RunsArtifactsOutput, RunsCancelOutput, RunsDirectoryArtifactPublicationGuidance,
32-
RunsEnvKeyOutput, RunsEnvOutput, RunsEnvSourceLayerOutput, RunsEnvSummary,
33-
RunsFieldSelectionOutput, RunsListArgs, RunsListOutput, RunsOutput, RunsResumePlanOutput,
34-
RunsSelectedField, RunsShowOutput, RunsStaleRunSummary,
31+
RunsArtifactPage, RunsArtifactPathGuide, RunsArtifactPullEntry, RunsArtifactPullSummary,
32+
RunsArtifactsArgs, RunsArtifactsOutput, RunsCancelOutput,
33+
RunsDirectoryArtifactPublicationGuidance, RunsEnvKeyOutput, RunsEnvOutput,
34+
RunsEnvSourceLayerOutput, RunsEnvSummary, RunsFieldSelectionOutput, RunsListArgs,
35+
RunsListOutput, RunsOutput, RunsResumePlanOutput, RunsSelectedField, RunsShowOutput,
36+
RunsStaleRunSummary,
3537
};
3638
use super::{reconcile, remote, remote_artifact, CmdResult};
3739

@@ -567,6 +569,20 @@ pub fn artifacts(run_id: &str) -> CmdResult<RunsOutput> {
567569
runner: None,
568570
pull: false,
569571
pull_dir: None,
572+
token: None,
573+
kind: None,
574+
mime: None,
575+
original_path: None,
576+
path_suffix: None,
577+
fixture: None,
578+
surface: None,
579+
scenario: None,
580+
name_glob: None,
581+
limit: 50,
582+
offset: 0,
583+
// Preserve the direct test helper's historical exhaustive projection;
584+
// the public CLI defaults to the bounded discovery page.
585+
full: true,
570586
})
571587
}
572588

@@ -582,13 +598,61 @@ pub fn artifacts_from_args(args: RunsArtifactsArgs) -> CmdResult<RunsOutput> {
582598
]),
583599
));
584600
}
585-
return remote::runner_artifacts(runner_id, &args.run_id);
601+
return remote::runner_artifacts(runner_id, &args);
586602
}
587603

588604
let store = ObservationStore::open_initialized()?;
589605
let run = runs_service::require_run(&store, &args.run_id)?;
590606
let run_id = run.id;
591-
let artifacts = runs_service::list_artifacts_for_run(&store, &run_id)?;
607+
let filter = ArtifactListFilter {
608+
token: args.token.clone(),
609+
kind: args.kind.clone(),
610+
mime: args.mime.clone(),
611+
original_path: args.original_path.clone(),
612+
path_suffix: args.path_suffix.clone(),
613+
fixture: args.fixture.clone(),
614+
surface: args.surface.clone(),
615+
scenario: args.scenario.clone(),
616+
name_glob: args.name_glob.clone(),
617+
limit: args.limit,
618+
offset: args.offset,
619+
};
620+
let page = if args.full {
621+
None
622+
} else {
623+
Some(store.list_artifacts_page(&run_id, &filter)?)
624+
};
625+
let artifacts = match page.as_ref() {
626+
Some(page) => page.artifacts.clone(),
627+
None => runs_service::list_artifacts_for_run(&store, &run_id)?,
628+
};
629+
if !args.full {
630+
let page = page.expect("bounded artifact page is present");
631+
return Ok((
632+
RunsOutput::Artifacts(RunsArtifactsOutput {
633+
command: "runs.artifacts",
634+
run_id: run_id.clone(),
635+
runner_id: None,
636+
path_guide: RunsArtifactPathGuide::for_listing(&run_id, None),
637+
next_commands: artifact_get_command_hints(&run_id, &artifacts),
638+
artifacts,
639+
page: Some(RunsArtifactPage {
640+
total: page.total,
641+
limit: page.limit,
642+
offset: page.offset,
643+
next_offset: (page.offset + page.artifacts.len() < page.total)
644+
.then_some(page.offset + page.artifacts.len()),
645+
}),
646+
resource_lifecycle_index: None,
647+
directory_publication: Vec::new(),
648+
preview_entrypoints: Vec::new(),
649+
matrix_summary: None,
650+
fuzz_result_envelopes: Vec::new(),
651+
pull: None,
652+
}),
653+
0,
654+
));
655+
}
592656
let preview_entrypoints = artifacts
593657
.iter()
594658
.flat_map(homeboy::core::artifacts::html_preview_entrypoints)
@@ -624,6 +688,7 @@ pub fn artifacts_from_args(args: RunsArtifactsArgs) -> CmdResult<RunsOutput> {
624688
path_guide: RunsArtifactPathGuide::for_listing(&run_id, None),
625689
next_commands: artifact_get_command_hints(&run_id, &artifacts),
626690
artifacts,
691+
page: None,
627692
resource_lifecycle_index,
628693
directory_publication,
629694
preview_entrypoints,
@@ -1389,6 +1454,18 @@ mod pull_tests {
13891454
runner: Some("lab".to_string()),
13901455
pull: true,
13911456
pull_dir: None,
1457+
token: None,
1458+
kind: None,
1459+
mime: None,
1460+
original_path: None,
1461+
path_suffix: None,
1462+
fixture: None,
1463+
surface: None,
1464+
scenario: None,
1465+
name_glob: None,
1466+
limit: 50,
1467+
offset: 0,
1468+
full: false,
13921469
});
13931470
let Err(err) = result else {
13941471
panic!("--pull with --runner should fail");

crates/homeboy-cli/src/commands/runs/remote.rs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use homeboy::core::Error;
88
use homeboy::runner::runners as runner;
99

1010
use super::types::{
11-
actionable_for_run_list, RunsArtifactGetArgs, RunsArtifactPathGuide, RunsArtifactsOutput,
12-
RunsDirectoryArtifactPublicationGuidance,
11+
actionable_for_run_list, RunsArtifactGetArgs, RunsArtifactPage, RunsArtifactPathGuide,
12+
RunsArtifactsArgs, RunsArtifactsOutput, RunsDirectoryArtifactPublicationGuidance,
1313
};
1414
use super::{remote_artifact, CmdResult, RunSummary, RunsListArgs, RunsListOutput, RunsOutput};
1515

@@ -229,12 +229,49 @@ fn active_runner_job_run_summary_if_durable(
229229
})
230230
}
231231

232-
pub fn runner_artifacts(runner_id: &str, run_id: &str) -> CmdResult<RunsOutput> {
232+
pub fn runner_artifacts(runner_id: &str, args: &RunsArtifactsArgs) -> CmdResult<RunsOutput> {
233+
let run_id = &args.run_id;
234+
let mut query = Vec::new();
235+
for (key, value) in [
236+
("token", args.token.as_deref()),
237+
("kind", args.kind.as_deref()),
238+
("mime", args.mime.as_deref()),
239+
("original_path", args.original_path.as_deref()),
240+
("path_suffix", args.path_suffix.as_deref()),
241+
("fixture", args.fixture.as_deref()),
242+
("surface", args.surface.as_deref()),
243+
("scenario", args.scenario.as_deref()),
244+
("name_glob", args.name_glob.as_deref()),
245+
] {
246+
if let Some(value) = value {
247+
query.push(format!("{key}={}", encode_uri_component(value)));
248+
}
249+
}
250+
if !args.full {
251+
query.push(format!("limit={}", args.limit.clamp(1, 1000)));
252+
query.push(format!("offset={}", args.offset.max(0)));
253+
} else {
254+
query.push("full=1".to_string());
255+
}
256+
let query = (!query.is_empty())
257+
.then(|| format!("?{}", query.join("&")))
258+
.unwrap_or_default();
233259
let data = runner::daemon_api_get(
234260
runner_id,
235-
&format!("/runs/{}/artifacts", encode_uri_component(run_id)),
261+
&format!("/runs/{}/artifacts{}", encode_uri_component(run_id), query),
236262
)?;
237263
let artifacts = parse_runner_artifacts(&data)?;
264+
let page = data.get("page").and_then(|page| {
265+
Some(RunsArtifactPage {
266+
total: page.get("total")?.as_u64()? as usize,
267+
limit: page.get("limit")?.as_u64()? as usize,
268+
offset: page.get("offset")?.as_u64()? as usize,
269+
next_offset: page
270+
.get("next_offset")
271+
.and_then(serde_json::Value::as_u64)
272+
.map(|value| value as usize),
273+
})
274+
});
238275
let directory_publication = directory_publication_guidance_for_artifacts(&artifacts);
239276
let resource_lifecycle_index = resource_lifecycle_index_from_artifacts(&artifacts)?;
240277

@@ -245,6 +282,7 @@ pub fn runner_artifacts(runner_id: &str, run_id: &str) -> CmdResult<RunsOutput>
245282
runner_id: Some(runner_id.to_string()),
246283
path_guide: RunsArtifactPathGuide::for_listing(run_id, Some(runner_id)),
247284
artifacts,
285+
page,
248286
next_commands: Vec::new(),
249287
resource_lifecycle_index,
250288
directory_publication,

crates/homeboy-cli/src/commands/runs/types.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,10 @@ pub struct RunsArtifactsOutput {
383383
pub runner_id: Option<String>,
384384
pub path_guide: RunsArtifactPathGuide,
385385
pub artifacts: Vec<ArtifactRecord>,
386+
/// Persisted inventory page details. `--full` omits the limit so legacy
387+
/// callers retain the exhaustive listing contract.
388+
#[serde(skip_serializing_if = "Option::is_none")]
389+
pub page: Option<RunsArtifactPage>,
386390
#[serde(skip_serializing_if = "Option::is_none")]
387391
pub resource_lifecycle_index: Option<ResourceLifecycleIndex>,
388392
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -401,6 +405,14 @@ pub struct RunsArtifactsOutput {
401405
pub pull: Option<RunsArtifactPullSummary>,
402406
}
403407

408+
#[derive(Serialize)]
409+
pub struct RunsArtifactPage {
410+
pub total: usize,
411+
pub limit: usize,
412+
pub offset: usize,
413+
pub next_offset: Option<usize>,
414+
}
415+
404416
#[derive(Serialize)]
405417
pub struct RunsArtifactCommandHint {
406418
pub artifact_id: String,
@@ -504,6 +516,42 @@ pub struct RunsArtifactsArgs {
504516
/// run-scoped path under the operator-local artifact root.
505517
#[arg(long, requires = "pull")]
506518
pub pull_dir: Option<PathBuf>,
519+
/// Exact persisted artifact id token.
520+
#[arg(long)]
521+
pub token: Option<String>,
522+
/// Exact artifact kind.
523+
#[arg(long)]
524+
pub kind: Option<String>,
525+
/// Exact MIME type.
526+
#[arg(long)]
527+
pub mime: Option<String>,
528+
/// Exact original persisted path.
529+
#[arg(long)]
530+
pub original_path: Option<String>,
531+
/// Match a persisted path suffix.
532+
#[arg(long)]
533+
pub path_suffix: Option<String>,
534+
/// Exact `fixture_id` artifact metadata value.
535+
#[arg(long)]
536+
pub fixture: Option<String>,
537+
/// Exact `surface_id` artifact metadata value.
538+
#[arg(long)]
539+
pub surface: Option<String>,
540+
/// Exact `scenario_id` artifact metadata value.
541+
#[arg(long)]
542+
pub scenario: Option<String>,
543+
/// Glob match against the artifact kind/name.
544+
#[arg(long)]
545+
pub name_glob: Option<String>,
546+
/// Maximum records in this page (1-1000).
547+
#[arg(long, default_value_t = 50)]
548+
pub limit: i64,
549+
/// Number of matching records to skip before this page.
550+
#[arg(long, default_value_t = 0)]
551+
pub offset: i64,
552+
/// Retain the exhaustive legacy listing and derived summaries.
553+
#[arg(long)]
554+
pub full: bool,
507555
}
508556

509557
#[derive(Serialize)]

0 commit comments

Comments
 (0)