@crvouga/atlas 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/reports/src/artifact-query.d.ts +14 -2
  3. package/dist/reports/src/artifact-query.js +13 -4
  4. package/dist/reports/src/evidence-detail.js +7 -4
  5. package/dist/reports/src/evidence-types.d.ts +4 -0
  6. package/dist/reports/src/recording-receipt.d.ts +23 -0
  7. package/dist/reports/src/recording-receipt.js +16 -0
  8. package/dist/reports/src/recording-verify.d.ts +74 -0
  9. package/dist/reports/src/recording-verify.js +142 -0
  10. package/dist/reports/src/report-cli.d.ts +2 -2
  11. package/dist/reports/src/report-cli.js +53 -3
  12. package/dist/reports/src/test-catalog.d.ts +7 -1
  13. package/dist/reports/src/test-catalog.js +10 -4
  14. package/dist/schema/src/api.d.ts +84 -0
  15. package/dist/schema/src/api.js +11 -2
  16. package/dist/schema/src/cli-results.d.ts +51 -1
  17. package/dist/schema/src/cli-results.js +25 -1
  18. package/dist/server/src/openapi.js +1 -0
  19. package/docs/agents.md +1 -0
  20. package/docs/reference/cli.md +11 -2
  21. package/docs/reference/http-api.md +2 -0
  22. package/docs/reference/reports.md +185 -9
  23. package/docs/reference/schema.md +19 -1
  24. package/docs/rich-evidence.md +32 -5
  25. package/docs/server.md +1 -1
  26. package/llms-full.txt +47 -8
  27. package/package.json +1 -1
  28. package/packages/schema/schemas/effective-delta.schema.json +8 -0
  29. package/packages/schema/schemas/effective-evidence.schema.json +8 -0
  30. package/packages/schema/schemas/evidence-page.schema.json +4 -0
  31. package/packages/schema/schemas/layer-rows.schema.json +4 -0
  32. package/packages/schema/schemas/report-run.schema.json +74 -0
  33. package/packages/schema/schemas/report-runs.schema.json +86 -0
  34. package/packages/schema/schemas/review-collection.schema.json +4 -0
  35. package/packages/schema/schemas/review-manifest.schema.json +4 -0
  36. package/packages/schema/schemas/test-catalog.schema.json +4 -0
  37. package/packages/schema/schemas/test-history.schema.json +8 -0
  38. package/packages/schema/schemas/verify-recordings-result.schema.json +97 -0
  39. package/packages/server/openapi.json +252 -0
  40. package/packages/visualizer/src/explorer/ArtifactsView.tsx +3 -2
  41. package/packages/visualizer/src/explorer/RunsView.tsx +28 -3
  42. package/packages/visualizer/src/explorer/filters.tsx +11 -3
  43. package/packages/visualizer/src/explorer/search.ts +6 -2
package/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.17.0 (2026-10-29)
3
+ ## 1.18.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - verify stored recordings and filter runs by recording completeness ([576d9ea](https://github.com/crvouga/atlas/commit/576d9ea3f8b1abfa45906e71c1cf329cae75d303))
8
+
9
+ ## 1.17.0 (2026-10-28)
4
10
 
5
11
  ### Features
6
12
 
@@ -4,7 +4,7 @@ export type QueryFacetValue = {
4
4
  readonly count: number;
5
5
  };
6
6
  /** The dimensions a query narrows by, with their address parameters. */
7
- export declare const QUERY_DIMENSIONS: readonly ["artifactTag", "type", "label", "environment", "producer", "branch", "sha", "outcome", "domain", "spec"];
7
+ export declare const QUERY_DIMENSIONS: readonly ["artifactTag", "type", "label", "environment", "producer", "branch", "sha", "outcome", "domain", "spec", "recordings"];
8
8
  /** One of {@link QUERY_DIMENSIONS}. */
9
9
  export type QueryDimension = (typeof QUERY_DIMENSIONS)[number];
10
10
  /** What a query asks for. Every field is optional; nothing asked is everything. See the module comment for the semantics. */
@@ -31,6 +31,12 @@ export type ArtifactFilter = {
31
31
  readonly domain?: readonly string[];
32
32
  /** Spec paths. Not applicable to runs. */
33
33
  readonly spec?: readonly string[];
34
+ /**
35
+ * Recording completeness of a run: `complete`, `incomplete`, `none-expected` or `unknown` (a run whose layers declared no
36
+ * recording expectations is `unknown`, never `complete`). Answered from the layers' receipts alone, without reading any
37
+ * recording. Applies to runs only; it is independent of `outcome`.
38
+ */
39
+ readonly recordings?: readonly string[];
34
40
  /** Only records at or after this time (epoch ms): a result's recording time, a run's end. */
35
41
  readonly since?: number;
36
42
  /** Only records at or before this time (epoch ms). */
@@ -65,6 +71,8 @@ export type FilterFields = {
65
71
  /** Absent when the dimension does not apply (runs have no domain). */
66
72
  readonly domains?: readonly string[];
67
73
  readonly specs?: readonly string[];
74
+ /** The recording completeness of a run (one value). Absent when the dimension does not apply (results). */
75
+ readonly recordings?: readonly string[];
68
76
  readonly at: number;
69
77
  };
70
78
  /** The facets of a query: for each dimension, its values with disjunctive counts (see the module comment). */
@@ -78,6 +86,8 @@ export type QueryFacets = {
78
86
  readonly branches: readonly QueryFacetValue[];
79
87
  readonly outcomes: readonly QueryFacetValue[];
80
88
  readonly domains?: readonly QueryFacetValue[];
89
+ /** Runs only: `complete`, `incomplete`, `none-expected` and `unknown`, with how many runs have each. */
90
+ readonly recordings?: readonly QueryFacetValue[];
81
91
  };
82
92
  /**
83
93
  * Splits a comma-separated list from an address: `a,b` is two values; `\,` is a comma and `\\` a backslash inside a
@@ -102,9 +112,11 @@ export declare function parseArtifactFilter(params: URLSearchParams): ArtifactFi
102
112
  export declare function filterParams(filter: ArtifactFilter): URLSearchParams;
103
113
  /** Which filters a query uses, by the names an address gives them. */
104
114
  export declare function usedFilters(filter: ArtifactFilter): FilterName[];
115
+ /** Every filter in `filter` that may be applied to a recorded result, as the artifacts query applies them (no recording completeness, which is a property of a run). */
116
+ export declare function forArtifacts(filter: ArtifactFilter): ArtifactFilter;
105
117
  /** Whether a query narrows anything. */
106
118
  export declare function isFiltered(filter: ArtifactFilter): boolean;
107
- /** Every filter in `filter` that may be applied to a record with these fields, as the runs query applies them (no domain or spec). */
119
+ /** Every filter in `filter` that may be applied to a run, as the runs query applies them (no domain or spec). */
108
120
  export declare function forRuns(filter: ArtifactFilter): ArtifactFilter;
109
121
  /**
110
122
  * Whether a record satisfies a query. `skip` leaves one dimension out: what {@link facetsOf} uses to count the values of
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * One shareable query for the runs of a project and for the recorded results that carry evidence (recordings,
5
5
  * screenshots, traces): by artifact tag, label, media type, feature (domain, spec), environment or device, producer,
6
- * branch, revision, outcome and time. The same rules answer `GET /api/reports/runs`, `GET /api/reports/artifacts`
6
+ * branch, revision, outcome, time and (runs only) recording completeness. The same rules answer `GET /api/reports/runs`, `GET /api/reports/artifacts`
7
7
  * and the viewer's Runs and Artifacts views, so a link means the same thing everywhere.
8
8
  *
9
9
  * The semantics are fixed, not inferred:
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { normalizeTag } from './artifact-tags.js';
28
28
  /** The dimensions a query narrows by, with their address parameters. */
29
- export const QUERY_DIMENSIONS = ['artifactTag', 'type', 'label', 'environment', 'producer', 'branch', 'sha', 'outcome', 'domain', 'spec'];
29
+ export const QUERY_DIMENSIONS = ['artifactTag', 'type', 'label', 'environment', 'producer', 'branch', 'sha', 'outcome', 'domain', 'spec', 'recordings'];
30
30
  /**
31
31
  * Splits a comma-separated list from an address: `a,b` is two values; `\,` is a comma and `\\` a backslash inside a
32
32
  * value. Values are trimmed and empty ones dropped.
@@ -133,11 +133,16 @@ export function usedFilters(filter) {
133
133
  used.push('video');
134
134
  return used;
135
135
  }
136
+ /** Every filter in `filter` that may be applied to a recorded result, as the artifacts query applies them (no recording completeness, which is a property of a run). */
137
+ export function forArtifacts(filter) {
138
+ const { recordings: _recordings, ...rest } = filter;
139
+ return rest;
140
+ }
136
141
  /** Whether a query narrows anything. */
137
142
  export function isFiltered(filter) {
138
143
  return usedFilters(filter).some(name => name !== 'inherited');
139
144
  }
140
- /** Every filter in `filter` that may be applied to a record with these fields, as the runs query applies them (no domain or spec). */
145
+ /** Every filter in `filter` that may be applied to a run, as the runs query applies them (no domain or spec). */
141
146
  export function forRuns(filter) {
142
147
  const { domain: _domain, spec: _spec, ...rest } = filter;
143
148
  return rest;
@@ -186,6 +191,8 @@ export function matchesFilter(fields, filter, skip) {
186
191
  return false;
187
192
  if (skip !== 'spec' && filter.spec?.length && fields.specs && !filter.spec.some(value => fields.specs.includes(value)))
188
193
  return false;
194
+ if (skip !== 'recordings' && filter.recordings?.length && fields.recordings && !filter.recordings.some(value => fields.recordings.includes(value)))
195
+ return false;
189
196
  if (filter.since !== undefined && fields.at < filter.since)
190
197
  return false;
191
198
  if (filter.until !== undefined && fields.at > filter.until)
@@ -213,6 +220,7 @@ export function facetsOf(records, filter) {
213
220
  const dimension = (skip, pick, chosen) => withChosen(tally(scope(skip).map(pick)), chosen);
214
221
  const normalTags = (filter.artifactTag ?? []).map(tag => normalizeTag(tag)).filter((tag) => tag !== null);
215
222
  const domains = records.some(record => record.domains);
223
+ const recordings = records.some(record => record.recordings);
216
224
  return {
217
225
  artifactTags: dimension('artifactTag', fields => fields.artifactTags, normalTags),
218
226
  types: dimension('type', fields => fields.types, filter.type),
@@ -221,7 +229,8 @@ export function facetsOf(records, filter) {
221
229
  producers: dimension('producer', fields => fields.producers, filter.producer),
222
230
  branches: dimension('branch', fields => fields.branches, filter.branch),
223
231
  outcomes: dimension('outcome', fields => fields.outcomes, filter.outcome),
224
- ...(domains ? { domains: dimension('domain', fields => fields.domains ?? [], filter.domain) } : {})
232
+ ...(domains ? { domains: dimension('domain', fields => fields.domains ?? [], filter.domain) } : {}),
233
+ ...(recordings ? { recordings: dimension('recordings', fields => fields.recordings ?? [], filter.recordings) } : {})
225
234
  };
226
235
  }
227
236
  /** The outcome of a run from its results by status: `failed`, else `running`, else `passed`, else `not-run`. */
@@ -117,7 +117,7 @@ function videoOf(item, owner, where, scope, unstored) {
117
117
  const lost = item.bundleKey !== undefined && unstored?.has(item.bundleKey) === true;
118
118
  const recording = lost ? attachmentIdentity({ name: item.name, uri: item.uri, text: item.text }) : attachmentIdentity(item);
119
119
  const tags = normalizeTags(item.tags);
120
- return { name: item.name, owner, ...where, ...(tags.length ? { tags } : {}), ...(recording === undefined ? {} : { recording }), available: recording !== undefined, ...(scope ? { scope } : {}) };
120
+ return { name: item.name, owner, ...where, ...(tags.length ? { tags } : {}), ...(recording === undefined ? {} : { recording }), available: recording !== undefined, ...(lost && recording === undefined ? { reason: 'not-in-bundle' } : {}), ...(scope ? { scope } : {}) };
121
121
  }
122
122
  /**
123
123
  * Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
@@ -127,14 +127,17 @@ export function videoCounts(videos) {
127
127
  if (!videos?.length)
128
128
  return undefined;
129
129
  const distinct = new Set();
130
- let unavailable = 0;
130
+ let unavailable = 0, missing = 0;
131
131
  for (const video of videos) {
132
132
  if (video.available && video.recording !== undefined)
133
133
  distinct.add(video.recording);
134
- else
134
+ else {
135
135
  unavailable += 1;
136
+ if (video.reason === 'not-in-bundle')
137
+ missing += 1;
138
+ }
136
139
  }
137
- return { references: videos.length, recordings: distinct.size, unavailable };
140
+ return { references: videos.length, recordings: distinct.size, unavailable, ...(missing ? { missing } : {}) };
138
141
  }
139
142
  /** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
140
143
  export function roleOfType(contentType) {
@@ -58,6 +58,8 @@ export type VideoRef = {
58
58
  readonly recording?: string;
59
59
  /** False when the reference names no bytes at all (no bundle key, address or inline text), or a bundle key that the layer's own bundle does not hold, so it can never be played. */
60
60
  readonly available: boolean;
61
+ /** Why it is unavailable, when the report can tell: `not-in-bundle` names a file the layer should hold (its bundle key) but does not, a recording that was acknowledged and is no longer there. Absent otherwise, and for an available video. An open vocabulary: treat an unknown reason as plain unavailable. */
62
+ readonly reason?: 'not-in-bundle';
61
63
  /** What this recording shows, as declared for it: its own `scope`, else its attempt's, check's, part's or layer's. Absent when none declared one: its scope is unknown, not live, physical or audible. */
62
64
  readonly scope?: EvidenceScope;
63
65
  };
@@ -69,6 +71,8 @@ export type VideoCounts = {
69
71
  readonly recordings: number;
70
72
  /** References that name no bytes, or bytes the layer does not hold. A report that predates availability metadata has none of these and none of the others. */
71
73
  readonly unavailable: number;
74
+ /** Of `unavailable`, those that name a file the layer's bundle does not hold (stored, then gone). Absent when there are none. */
75
+ readonly missing?: number;
72
76
  };
73
77
  /**
74
78
  * What a result (or a run) carries beyond its outcome, as counts and short facts: attachments, steps,
@@ -11,6 +11,7 @@
11
11
  * @see docs/rich-evidence.md#recording-completeness
12
12
  */
13
13
  import type { RecordingCompleteness, RecordingReceipt } from '../../schema/src/layer.js';
14
+ import type { RecordingCounts } from './evidence-types.js';
14
15
  type Loose = Record<string, unknown>;
15
16
  /** Is this attachment a screen recording: a role of `video`, or a `video/*` media type? */
16
17
  export declare function isVideoAttachment(attachment: Loose): boolean;
@@ -45,4 +46,26 @@ export type RecordingResult = {
45
46
  * nothing was. Gaps are sorted so the output never depends on read order.
46
47
  */
47
48
  export declare function recordingCompleteness(results: readonly RecordingResult[]): RecordingCompleteness;
49
+ /** A run's recording completeness, from the receipts of its layers (see {@link runRecordings}). */
50
+ export type RunRecordings = {
51
+ /** `incomplete`: a recording is skipped or unresolved. `unknown`: no gap is known, but a layer declared no expectations. `complete`: everything expected was published. `none-expected`: every layer declared expectations and none required a recording. */
52
+ readonly state: 'complete' | 'incomplete' | 'none-expected' | 'unknown';
53
+ /** Recordings the layers expected. */
54
+ readonly expected: number;
55
+ /** Of those, uploaded or referenced by address. */
56
+ readonly published: number;
57
+ readonly skipped: number;
58
+ readonly unresolved: number;
59
+ /** Intentionally unrecorded checks: neither expected nor missing. */
60
+ readonly notApplicable: number;
61
+ /** Layers of the run that declared no expectations: nothing is claimed for them. */
62
+ readonly layersWithoutReceipt: number;
63
+ };
64
+ /**
65
+ * The recording completeness of a run, from the `recordings` counts of each of its layers (`undefined` for a layer that
66
+ * has no receipt). `undefined` when no layer has a receipt: the run claims nothing, which a reader treats as `unknown`.
67
+ * A gap always wins (`incomplete`); otherwise a layer without a receipt keeps the run `unknown`, never `complete`. Reads
68
+ * only the counts, never a recording, and never changes a status.
69
+ */
70
+ export declare function runRecordings(layers: readonly (RecordingCounts | undefined)[]): RunRecordings | undefined;
48
71
  export {};
@@ -101,3 +101,19 @@ export function recordingCompleteness(results) {
101
101
  const state = gaps.length ? 'incomplete' : without.size || !results.length ? 'unknown' : count.expected ? 'complete' : 'none-expected';
102
102
  return { state, ...count, layersWithoutReceipt: without.size, gaps };
103
103
  }
104
+ /**
105
+ * The recording completeness of a run, from the `recordings` counts of each of its layers (`undefined` for a layer that
106
+ * has no receipt). `undefined` when no layer has a receipt: the run claims nothing, which a reader treats as `unknown`.
107
+ * A gap always wins (`incomplete`); otherwise a layer without a receipt keeps the run `unknown`, never `complete`. Reads
108
+ * only the counts, never a recording, and never changes a status.
109
+ */
110
+ export function runRecordings(layers) {
111
+ const receipts = layers.filter((layer) => layer !== undefined);
112
+ if (!receipts.length)
113
+ return undefined;
114
+ const sum = (pick) => receipts.reduce((total, counts) => total + pick(counts), 0);
115
+ const skipped = sum(counts => counts.skipped), unresolved = sum(counts => counts.unresolved), expected = sum(counts => counts.expected);
116
+ const layersWithoutReceipt = layers.length - receipts.length;
117
+ const state = skipped + unresolved > 0 ? 'incomplete' : layersWithoutReceipt > 0 ? 'unknown' : expected > 0 ? 'complete' : 'none-expected';
118
+ return { state, expected, published: sum(counts => counts.uploaded + counts.referenced), skipped, unresolved, notApplicable: sum(counts => counts.notApplicable), layersWithoutReceipt };
119
+ }
@@ -0,0 +1,74 @@
1
+ import type { ObjectStore } from './object-store.js';
2
+ import type { ReportLayer } from './layer-types.js';
3
+ /** What is wrong with a stored recording. An open vocabulary: a reader treats an unknown problem as a gap. */
4
+ export type RecordingProblem =
5
+ /** The layer names the file but its bundle does not list it: stored, then gone (or never arrived). */
6
+ 'not-in-bundle'
7
+ /** The bundle lists the file but a blob holding its bytes is no longer in the store (a 404 when played). */
8
+ | 'blob-missing'
9
+ /** The store could not be read just now. Not proof the bytes are gone, and not a pass. */
10
+ | 'unreadable'
11
+ /** The stored bytes are not as many as were recorded. */
12
+ | 'size-mismatch'
13
+ /** The stored bytes do not hash to the SHA-256 recorded at upload. */
14
+ | 'checksum-mismatch'
15
+ /** The stored bytes do not begin like the container their media type names, or are empty: incompatible or corrupt. */
16
+ | 'undecodable';
17
+ /** One video of a layer that failed verification. */
18
+ export type RecordingGap = {
19
+ /** The check that holds the video, or `null` for a video attached to the layer or a part. */
20
+ readonly check: string | null;
21
+ /** The attachment's name. */
22
+ readonly name: string;
23
+ readonly bundleKey: string;
24
+ readonly problem: RecordingProblem;
25
+ /** A short, non-secret explanation: sizes or the container found. */
26
+ readonly reason: string;
27
+ };
28
+ /** What verifying one layer found. */
29
+ export type LayerRecordingVerification = {
30
+ readonly layer: string;
31
+ /** Videos read back from the store: those that name a bundle key. */
32
+ readonly checked: number;
33
+ /** Of those, the ones that passed every check. */
34
+ readonly ok: number;
35
+ /** Videos given only by `uri`, which Atlas cannot read back. */
36
+ readonly referenced: number;
37
+ readonly gaps: readonly RecordingGap[];
38
+ };
39
+ /**
40
+ * Whether the first bytes of a stored video are the container its media type names, as a reason it is not, or `null` when
41
+ * it looks right or the type is one this check has no signature for (then nothing is claimed). Recognizes WebM and
42
+ * Matroska (EBML), MP4, QuickTime and 3GPP (an ISO box), Ogg and AVI. An empty file is never a video.
43
+ */
44
+ export declare function containerProblem(bytes: Uint8Array, contentType: string): string | null;
45
+ /**
46
+ * Reads back every video of `layer` that is kept in its bundle and reports the ones that are no longer retrievable or no
47
+ * longer look like what was published (see {@link RecordingProblem}). Each blob is read once however many checks
48
+ * reference it. A store that fails to answer is `unreadable`, never a pass. It only reads: nothing is written, and no
49
+ * status changes.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * const layer = await readLayer(store, id);
54
+ * const { gaps } = await verifyLayerRecordings(store, layer!);
55
+ * gaps.map(gap => `${gap.check}: ${gap.problem}`);
56
+ * ```
57
+ */
58
+ export declare function verifyLayerRecordings(store: ObjectStore, layer: ReportLayer): Promise<LayerRecordingVerification>;
59
+ /** What verifying the layers of one store found. */
60
+ export type StoreRecordingVerification = {
61
+ readonly layers: readonly LayerRecordingVerification[];
62
+ /** Layers that could not be read at all (their manifest is unreadable or gone): their recordings are unknown, not verified. */
63
+ readonly notices: readonly string[];
64
+ };
65
+ /**
66
+ * Verifies the recordings of the layers a store lists, newest first. `ids` narrows to layers whose id starts with one of
67
+ * them (at least 7 hex digits); `limit` keeps only that many of the newest. A layer that cannot be read is a notice, never
68
+ * skipped silently and never counted as verified. A store that cannot be listed rejects.
69
+ */
70
+ export declare function verifyStoreRecordings(store: ObjectStore, options?: {
71
+ readonly ids?: readonly string[];
72
+ readonly limit?: number;
73
+ readonly include?: (layer: ReportLayer) => boolean;
74
+ }): Promise<StoreRecordingVerification>;
@@ -0,0 +1,142 @@
1
+ import { createBundleReader } from './bundle.js';
2
+ import { isVideoAttachment } from './evidence-detail.js';
3
+ import { contentTypeOf } from './media-types.js';
4
+ import { LAYER_INDEX, readLayer } from './layer-store.js';
5
+ import { sha256Hex } from './sigv4.js';
6
+ const MP4_BOXES = new Set(['ftyp', 'moov', 'mdat', 'free', 'wide', 'skip', 'pnot']);
7
+ const ascii = (bytes, from, to) => String.fromCharCode(...bytes.subarray(from, to));
8
+ /**
9
+ * Whether the first bytes of a stored video are the container its media type names, as a reason it is not, or `null` when
10
+ * it looks right or the type is one this check has no signature for (then nothing is claimed). Recognizes WebM and
11
+ * Matroska (EBML), MP4, QuickTime and 3GPP (an ISO box), Ogg and AVI. An empty file is never a video.
12
+ */
13
+ export function containerProblem(bytes, contentType) {
14
+ if (!bytes.length)
15
+ return 'the file is empty';
16
+ const type = contentType.toLowerCase();
17
+ const found = () => `its first bytes are not ${type}`;
18
+ if (type.includes('webm') || type.includes('matroska'))
19
+ return bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3 ? null : found();
20
+ if (type.includes('mp4') || type.includes('quicktime') || type.includes('3gpp') || type.includes('m4v'))
21
+ return bytes.length >= 8 && MP4_BOXES.has(ascii(bytes, 4, 8)) ? null : found();
22
+ if (type.includes('ogg'))
23
+ return ascii(bytes, 0, 4) === 'OggS' ? null : found();
24
+ if (type.includes('msvideo'))
25
+ return ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'AVI ' ? null : found();
26
+ return null;
27
+ }
28
+ /** Every video attachment of a holder, its steps and its attempts. */
29
+ function videosOf(holder, check) {
30
+ const found = [];
31
+ const take = (list) => { for (const attachment of list ?? [])
32
+ if (isVideoAttachment(attachment))
33
+ found.push({ check, attachment }); };
34
+ take(holder.attachments);
35
+ for (const step of holder.steps ?? [])
36
+ take(step.attachments);
37
+ for (const attempt of holder.attempts ?? []) {
38
+ take(attempt.attachments);
39
+ for (const step of attempt.steps ?? [])
40
+ take(step.attachments);
41
+ }
42
+ return found;
43
+ }
44
+ /**
45
+ * Reads back every video of `layer` that is kept in its bundle and reports the ones that are no longer retrievable or no
46
+ * longer look like what was published (see {@link RecordingProblem}). Each blob is read once however many checks
47
+ * reference it. A store that fails to answer is `unreadable`, never a pass. It only reads: nothing is written, and no
48
+ * status changes.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * const layer = await readLayer(store, id);
53
+ * const { gaps } = await verifyLayerRecordings(store, layer!);
54
+ * gaps.map(gap => `${gap.check}: ${gap.problem}`);
55
+ * ```
56
+ */
57
+ export async function verifyLayerRecordings(store, layer) {
58
+ const holders = [...videosOf(layer, null)];
59
+ for (const part of layer.parts) {
60
+ holders.push(...videosOf(part, null));
61
+ for (const check of part.checks)
62
+ holders.push(...videosOf(check, check.id));
63
+ }
64
+ const read = createBundleReader(store, layer.bundle);
65
+ const bytesOf = new Map();
66
+ const load = (key) => {
67
+ let pending = bytesOf.get(key);
68
+ if (!pending) {
69
+ pending = read(key).then(entry => entry ?? 'missing', () => 'unreadable');
70
+ bytesOf.set(key, pending);
71
+ }
72
+ return pending;
73
+ };
74
+ const gaps = [];
75
+ let checked = 0, ok = 0, referenced = 0;
76
+ for (const { check, attachment } of holders) {
77
+ const key = attachment.bundleKey;
78
+ if (key === undefined) {
79
+ if (attachment.uri !== undefined)
80
+ referenced += 1;
81
+ continue;
82
+ }
83
+ checked += 1;
84
+ const gap = (problem, reason) => { gaps.push({ check, name: attachment.name, bundleKey: key, problem, reason }); };
85
+ if (!Object.hasOwn(layer.bundle, key)) {
86
+ gap('not-in-bundle', 'the layer names this file but its bundle does not list it');
87
+ continue;
88
+ }
89
+ const stored = await load(key);
90
+ if (stored === 'unreadable') {
91
+ gap('unreadable', 'the store could not be read');
92
+ continue;
93
+ }
94
+ if (stored === 'missing') {
95
+ gap('blob-missing', 'a blob holding this file is no longer in the store');
96
+ continue;
97
+ }
98
+ if (attachment.bytes !== undefined && attachment.bytes !== stored.bytes.length) {
99
+ gap('size-mismatch', `${stored.bytes.length} bytes stored, ${attachment.bytes} recorded`);
100
+ continue;
101
+ }
102
+ if (attachment.sha256 !== undefined && attachment.sha256 !== await sha256Hex(stored.bytes)) {
103
+ gap('checksum-mismatch', 'the stored bytes do not match the recorded SHA-256');
104
+ continue;
105
+ }
106
+ const problem = containerProblem(stored.bytes, attachment.contentType ?? (stored.contentType || contentTypeOf(attachment.name)));
107
+ if (problem) {
108
+ gap('undecodable', problem);
109
+ continue;
110
+ }
111
+ ok += 1;
112
+ }
113
+ return { layer: layer.id, checked, ok, referenced, gaps };
114
+ }
115
+ /**
116
+ * Verifies the recordings of the layers a store lists, newest first. `ids` narrows to layers whose id starts with one of
117
+ * them (at least 7 hex digits); `limit` keeps only that many of the newest. A layer that cannot be read is a notice, never
118
+ * skipped silently and never counted as verified. A store that cannot be listed rejects.
119
+ */
120
+ export async function verifyStoreRecordings(store, options = {}) {
121
+ const keys = await store.listKeys(LAYER_INDEX);
122
+ const wanted = options.ids?.length ? options.ids.map(id => id.toLowerCase()) : null;
123
+ const ids = keys.flatMap(key => { const id = /-([0-9a-f]{64})\.json$/.exec(key)?.[1]; return id && (!wanted || wanted.some(prefix => id.startsWith(prefix))) ? [id] : []; });
124
+ const layers = [];
125
+ const notices = [];
126
+ for (const id of ids.slice(0, options.limit ?? ids.length)) {
127
+ try {
128
+ const layer = await readLayer(store, id);
129
+ if (!layer) {
130
+ notices.push(`layer ${id.slice(0, 12)} is listed but its manifest is missing`);
131
+ continue;
132
+ }
133
+ if (options.include && !options.include(layer))
134
+ continue;
135
+ layers.push(await verifyLayerRecordings(store, layer));
136
+ }
137
+ catch (error) {
138
+ notices.push(`layer ${id.slice(0, 12)} could not be read: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
139
+ }
140
+ }
141
+ return { layers, notices };
142
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @module The `atlas report` command
3
3
  *
4
- * `reportMain` is `atlas report publish | stream | status | merge | doctor | policy`, written so it can be called from code and
4
+ * `reportMain` is `atlas report publish | stream | status | verify-recordings | merge | doctor | policy`, written so it can be called from code and
5
5
  * tests as well as a terminal: it takes its arguments, environment, working directory, clock, git and standard input as
6
6
  * parameters and returns the exit code. Every command can print a JSON document (`--format json`) instead of prose, and a
7
7
  * command that is wrong throws, which `runReportCommand` turns into a message (or a JSON error) and exit code 2.
@@ -13,7 +13,7 @@
13
13
  * @see docs/agents.md
14
14
  */
15
15
  /** The text `atlas report --help` prints: every subcommand, its flags, what each does and the exit codes. Also the source of the command reference in `docs/reference/cli.md`. */
16
- export declare const REPORT_USAGE = "atlas report \u2014 layered evidence for any kind of spec\n\n atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]\n ( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )\n [--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]\n [--run-id <id>] [--run-title <t>] [--run-started-at <iso|ms>] [--run-ended-at <iso|ms>]\n [--spec <path> | --specs <dir>] [--upload-run]\n [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]\n [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...\n [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]\n [--require-recordings]\n atlas report stream [--store <dir|s3://\u2026|https://atlas.example.com>] --producer <name> [--environment <name>]\n [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...\n [--run-id <id>] [--run-title <t>]\n [--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)\n atlas report status [--store <dir|s3://\u2026|id=url>]... [--backends <module>]\n [--specs <dir>] [--format text|json|markdown]\n [--require-environments a,b] [--require-all-specs]\n [--branch <name>]... [--ignore-modified]\n atlas report merge [--store \u2026]... [--backends <module>] --into <dir|s3://\u2026>\n atlas report doctor [--store \u2026]... [--role publisher|reader|both]\n atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]\n\n A store is a directory or s3://bucket/prefix (add ?region=\u2026&endpoint=\u2026 for R2, MinIO and the\n rest). Select a project on an Atlas node with --project <slug> or ATLAS_PROJECT. This scopes specs, reports and recordings together.\n When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set\n AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with `aws sso login`, or use a role.\n\n publish Write one immutable layer: the checks of one producer, on one environment. Safe to repeat.\n --producer is required, unless the --checks file names one\n --spec <path> every check in the report belongs to this spec (a report per feature file)\n --specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or\n file name the report gives; also publishes the specs themselves, so the\n node can show them without a checkout\n --run-id <id> name this execution (a local or manual run with no CI run URL; also ATLAS_RUN_ID).\n Layers that share an id are one run, however many shards or batches uploaded them;\n another invocation at the same commit gets another id. Never part of what a check is\n --run-title <t> a human name for the run (also ATLAS_RUN_TITLE); --run-started-at and --run-ended-at\n record when it ran. Without --run-id a run is its CI run URL, else branch@sha\n --upload-run with --manifest: also upload the run's screenshots and clips for the viewer\n --attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive\n (screenshots, videos and traces of a single test go in its CTRF or checks file)\n --label k=v a fact about the run, not part of what it checks: build, os, browser\n --link [n=]<url> a link to the run, a dashboard, an issue\n Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own\n folder and each --attach-root, and never from a hidden file or a link out: a report is data from a\n test run, not instructions. One that is missing or not allowed is skipped with a warning.\n --require-recordings exit 1 when a check that declared recordingExpectation \"required\" has no\n uploaded video (absent, left out as too large or not allowed, or refused). The layer is\n still written and the checks keep their results; only this command fails. The receipt of\n what was uploaded (counts, sizes, checksums) is in the JSON result under layer.recordings.\n From a checkout with uncommitted changes the layer is marked \"modified\".\n stream Send checks as they finish: one JSON object per line on standard input (the fields of one\n check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server\n shows the run while it is still going. A check sent again with the same id (running, then\n passed) is replaced by the later. Ends when the input does; exit 1 if anything could not be sent.\n Point it at an Atlas server with --store https://\u2026 and a token in ATLAS_TOKEN.\n status Combine every layer of every store, and exit 1 unless the report passes.\n --specs <dir> lay the results over these spec files (default: the specs in the store)\n --require-all-specs fail while any spec has no results\n --branch <name> only layers recorded on this branch (repeatable)\n --ignore-modified leave out layers from modified checkouts and unknown origins\n merge Copy the layers of several stores into one.\n doctor Check that a store works: credentials, listing, writing, and what it holds.\n policy Print the least-privilege IAM policy for publishers, readers, or both.\n\n --backends <module> A TypeScript or JavaScript module whose default export is a list of\n { id, label?, store } (or a function returning it): a device lab, anything\n that implements ObjectStore.\n\n --format json (alias --json) publish, stream, merge, doctor and status print one JSON document instead of prose\n (schemas: publish-result, stream-result, merge-result, doctor-result, report-status). A command\n that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n A store may be an Atlas server (https://\u2026): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.\n\n Exit codes: 0 ok, 1 the report does not pass (or a check failed), 2 the command was wrong.\n";
16
+ export declare const REPORT_USAGE = "atlas report \u2014 layered evidence for any kind of spec\n\n atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]\n ( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )\n [--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]\n [--run-id <id>] [--run-title <t>] [--run-started-at <iso|ms>] [--run-ended-at <iso|ms>]\n [--spec <path> | --specs <dir>] [--upload-run]\n [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]\n [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...\n [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]\n [--require-recordings]\n atlas report stream [--store <dir|s3://\u2026|https://atlas.example.com>] --producer <name> [--environment <name>]\n [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...\n [--run-id <id>] [--run-title <t>]\n [--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)\n atlas report status [--store <dir|s3://\u2026|id=url>]... [--backends <module>]\n [--specs <dir>] [--format text|json|markdown]\n [--require-environments a,b] [--require-all-specs]\n [--branch <name>]... [--ignore-modified]\n atlas report verify-recordings [--store <dir|s3://\u2026|id=url>]... [--backends <module>] [--layer <id>]...\n [--limit <n>] [--branch <name>]... [--ignore-modified] [--format text|json]\n atlas report merge [--store \u2026]... [--backends <module>] --into <dir|s3://\u2026>\n atlas report doctor [--store \u2026]... [--role publisher|reader|both]\n atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]\n\n A store is a directory or s3://bucket/prefix (add ?region=\u2026&endpoint=\u2026 for R2, MinIO and the\n rest). Select a project on an Atlas node with --project <slug> or ATLAS_PROJECT. This scopes specs, reports and recordings together.\n When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set\n AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with `aws sso login`, or use a role.\n\n publish Write one immutable layer: the checks of one producer, on one environment. Safe to repeat.\n --producer is required, unless the --checks file names one\n --spec <path> every check in the report belongs to this spec (a report per feature file)\n --specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or\n file name the report gives; also publishes the specs themselves, so the\n node can show them without a checkout\n --run-id <id> name this execution (a local or manual run with no CI run URL; also ATLAS_RUN_ID).\n Layers that share an id are one run, however many shards or batches uploaded them;\n another invocation at the same commit gets another id. Never part of what a check is\n --run-title <t> a human name for the run (also ATLAS_RUN_TITLE); --run-started-at and --run-ended-at\n record when it ran. Without --run-id a run is its CI run URL, else branch@sha\n --upload-run with --manifest: also upload the run's screenshots and clips for the viewer\n --attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive\n (screenshots, videos and traces of a single test go in its CTRF or checks file)\n --label k=v a fact about the run, not part of what it checks: build, os, browser\n --link [n=]<url> a link to the run, a dashboard, an issue\n Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own\n folder and each --attach-root, and never from a hidden file or a link out: a report is data from a\n test run, not instructions. One that is missing or not allowed is skipped with a warning.\n --require-recordings exit 1 when a check that declared recordingExpectation \"required\" has no\n uploaded video (absent, left out as too large or not allowed, or refused). The layer is\n still written and the checks keep their results; only this command fails. The receipt of\n what was uploaded (counts, sizes, checksums) is in the JSON result under layer.recordings.\n From a checkout with uncommitted changes the layer is marked \"modified\".\n stream Send checks as they finish: one JSON object per line on standard input (the fields of one\n check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server\n shows the run while it is still going. A check sent again with the same id (running, then\n passed) is replaced by the later. Ends when the input does; exit 1 if anything could not be sent.\n Point it at an Atlas server with --store https://\u2026 and a token in ATLAS_TOKEN.\n status Combine every layer of every store, and exit 1 unless the report passes.\n --specs <dir> lay the results over these spec files (default: the specs in the store)\n --require-all-specs fail while any spec has no results\n --branch <name> only layers recorded on this branch (repeatable)\n --ignore-modified leave out layers from modified checkouts and unknown origins\n verify-recordings Read back the videos the layers published and check they are still there as published: the\n layer still lists the file, its blob is still stored, its size and SHA-256 are the recorded ones, and it\n begins like the container its media type names. Exit 1 when any video is missing, unreadable, corrupt or\n not that container, naming the layer and check; test results are never changed. This checks stored bytes,\n not playback in a browser. Videos given only by address are counted, not checked.\n --layer <id> only the layer whose id starts with this (repeatable; at least 7 digits)\n --limit <n> only the newest n layers\n merge Copy the layers of several stores into one.\n doctor Check that a store works: credentials, listing, writing, and what it holds.\n policy Print the least-privilege IAM policy for publishers, readers, or both.\n\n --backends <module> A TypeScript or JavaScript module whose default export is a list of\n { id, label?, store } (or a function returning it): a device lab, anything\n that implements ObjectStore.\n\n --format json (alias --json) publish, stream, merge, doctor, status and verify-recordings print one JSON document instead of prose\n (schemas: publish-result, stream-result, merge-result, doctor-result, report-status, verify-recordings-result). A command\n that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n A store may be an Atlas server (https://\u2026): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.\n\n Exit codes: 0 ok, 1 the report does not pass (or a check failed), 2 the command was wrong.\n";
17
17
  type Env = Readonly<Record<string, string | undefined>>;
18
18
  type Git = (args: readonly string[]) => string | null;
19
19
  /** What `reportMain` is given besides arguments: where output goes (`out`), and, for tests and embedding, the environment, working directory, git, clock and standard input (`stdin`, where `stream` reads checks from). Defaults are the real ones. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @module The `atlas report` command
3
3
  *
4
- * `reportMain` is `atlas report publish | stream | status | merge | doctor | policy`, written so it can be called from code and
4
+ * `reportMain` is `atlas report publish | stream | status | verify-recordings | merge | doctor | policy`, written so it can be called from code and
5
5
  * tests as well as a terminal: it takes its arguments, environment, working directory, clock, git and standard input as
6
6
  * parameters and returns the exit code. Every command can print a JSON document (`--format json`) instead of prose, and a
7
7
  * command that is wrong throws, which `runReportCommand` turns into a message (or a JSON error) and exit code 2.
@@ -28,6 +28,7 @@ import { parseWith } from './parse-with.js';
28
28
  import { publishChecks } from './publish-checks.js';
29
29
  import { createReporter } from './reporter.js';
30
30
  import { formatStatusMarkdown, formatStatusText, reportStatus } from './report-status.js';
31
+ import { verifyStoreRecordings } from './recording-verify.js';
31
32
  import { publishRunBundle } from './run-bundle.js';
32
33
  import { collectSpecFiles, latestSpecSet, publishSpecSet } from './spec-set.js';
33
34
  import { linkChecksToSpecs } from './spec-link.js';
@@ -52,6 +53,8 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
52
53
  [--specs <dir>] [--format text|json|markdown]
53
54
  [--require-environments a,b] [--require-all-specs]
54
55
  [--branch <name>]... [--ignore-modified]
56
+ atlas report verify-recordings [--store <dir|s3://…|id=url>]... [--backends <module>] [--layer <id>]...
57
+ [--limit <n>] [--branch <name>]... [--ignore-modified] [--format text|json]
55
58
  atlas report merge [--store …]... [--backends <module>] --into <dir|s3://…>
56
59
  atlas report doctor [--store …]... [--role publisher|reader|both]
57
60
  atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]
@@ -95,6 +98,13 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
95
98
  --require-all-specs fail while any spec has no results
96
99
  --branch <name> only layers recorded on this branch (repeatable)
97
100
  --ignore-modified leave out layers from modified checkouts and unknown origins
101
+ verify-recordings Read back the videos the layers published and check they are still there as published: the
102
+ layer still lists the file, its blob is still stored, its size and SHA-256 are the recorded ones, and it
103
+ begins like the container its media type names. Exit 1 when any video is missing, unreadable, corrupt or
104
+ not that container, naming the layer and check; test results are never changed. This checks stored bytes,
105
+ not playback in a browser. Videos given only by address are counted, not checked.
106
+ --layer <id> only the layer whose id starts with this (repeatable; at least 7 digits)
107
+ --limit <n> only the newest n layers
98
108
  merge Copy the layers of several stores into one.
99
109
  doctor Check that a store works: credentials, listing, writing, and what it holds.
100
110
  policy Print the least-privilege IAM policy for publishers, readers, or both.
@@ -103,8 +113,8 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
103
113
  { id, label?, store } (or a function returning it): a device lab, anything
104
114
  that implements ObjectStore.
105
115
 
106
- --format json (alias --json) publish, stream, merge, doctor and status print one JSON document instead of prose
107
- (schemas: publish-result, stream-result, merge-result, doctor-result, report-status). A command
116
+ --format json (alias --json) publish, stream, merge, doctor, status and verify-recordings print one JSON document instead of prose
117
+ (schemas: publish-result, stream-result, merge-result, doctor-result, report-status, verify-recordings-result). A command
108
118
  that is wrong is {"ok":false,"error":{"message":…,"code":"usage"}} on standard error.
109
119
  A store may be an Atlas server (https://…): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.
110
120
 
@@ -475,6 +485,46 @@ export async function reportMain(argv, io = { out: text => process.stdout.write(
475
485
  io.out(format === 'json' ? `${JSON.stringify(status, null, 2)}\n` : format === 'markdown' ? formatStatusMarkdown(status) : formatStatusText(status));
476
486
  return status.ok ? 0 : 1;
477
487
  }
488
+ if (command === 'verify-recordings') {
489
+ const format = value(rest, 'format') ?? (rest.includes('--json') ? 'json' : 'text');
490
+ if (!['text', 'json'].includes(format))
491
+ throw new Error(`--format takes text|json (got "${format}")`);
492
+ const ids = values(rest, 'layer');
493
+ const short = ids.find(id => !/^[0-9a-f]{7,64}$/i.test(id));
494
+ if (short)
495
+ throw new Error(`--layer takes at least 7 hexadecimal digits of a layer id (got "${short}")`);
496
+ const limitText = value(rest, 'limit');
497
+ if (limitText !== undefined && !/^\d+$/.test(limitText))
498
+ throw new Error(`--limit takes a whole number (got "${limitText}")`);
499
+ const include = layerFilter(rest, env);
500
+ let layers = 0, checked = 0, verified = 0, referenced = 0;
501
+ const gaps = [];
502
+ const notices = [];
503
+ for (const backend of await backendsOf(rest, base, env)) {
504
+ const found = await verifyStoreRecordings(backend.store, { ids, ...(limitText !== undefined ? { limit: Number(limitText) } : {}), ...(include ? { include } : {}) });
505
+ for (const layer of found.layers) {
506
+ layers += 1;
507
+ checked += layer.checked;
508
+ verified += layer.ok;
509
+ referenced += layer.referenced;
510
+ gaps.push(...layer.gaps.map(gap => ({ ...gap, layer: layer.layer })));
511
+ }
512
+ notices.push(...found.notices);
513
+ }
514
+ const ok = gaps.length === 0 && notices.length === 0;
515
+ if (format === 'json') {
516
+ io.out(`${JSON.stringify({ ok, layers, checked, verified, referenced, gaps, notices }, null, 2)}\n`);
517
+ return ok ? 0 : 1;
518
+ }
519
+ io.out(`Recordings in ${layers} layer${layers === 1 ? '' : 's'}: ${verified} of ${checked} stored video${checked === 1 ? '' : 's'} verified${referenced ? `, ${referenced} given by address (not checked)` : ''}\n`);
520
+ for (const gap of gaps)
521
+ io.out(`! ${gap.problem}: ${gap.check ?? 'the run'} / ${gap.name} in layer ${gap.layer.slice(0, 12)} (${gap.reason})\n`);
522
+ for (const notice of notices)
523
+ io.out(`! ${notice}\n`);
524
+ if (!ok)
525
+ io.out('Test results are unchanged. Publish the missing recordings again to repair the gaps.\n');
526
+ return ok ? 0 : 1;
527
+ }
478
528
  if (command === 'merge') {
479
529
  const into = value(rest, 'into');
480
530
  if (!into)
@@ -1,6 +1,7 @@
1
1
  import { type ArtifactFilter, type FilterFields, type FilterName, type QueryFacets } from './artifact-query.js';
2
2
  import type { EvidenceMeasurement, EvidenceStatus, SpecEntry } from './explorer-types.js';
3
3
  import type { EvidenceSummary, LayerEvidence, LayerHeader, LayerRow, StatusCounts, VideoCounts, VideoRef } from './evidence-types.js';
4
+ import { type RunRecordings } from './recording-receipt.js';
4
5
  import { RecordedSpecs } from './recorded-specs.js';
5
6
  /** The version of the documents this module writes (`TestCatalog`, `TestHistoryDocument`, `RunList`, `RunDetail`). Only optional fields are added within it. */
6
7
  export declare const TEST_CATALOG_VERSION = 1;
@@ -287,6 +288,11 @@ export type RunSummary = {
287
288
  * and references that cannot be played. Absent when it has none, and in a report that carries no video information.
288
289
  */
289
290
  readonly videos?: VideoCounts;
291
+ /**
292
+ * Whether the recordings its layers expected were published, apart from its outcomes (see `runRecordings`). Absent when
293
+ * none of its layers declared a recording expectation: its completeness is unknown, never complete. Never changes a status.
294
+ */
295
+ readonly recordings?: RunRecordings;
290
296
  /**
291
297
  * Files attached to the run itself, in its layers (and their parts), not to any one check: review movies, archives,
292
298
  * summaries. All kinds, videos included. Absent when none, and in a report that carries no such information.
@@ -556,7 +562,7 @@ export declare function fieldsOfRun(run: RunSummary): FilterFields;
556
562
  * list.results.map(r => [r.title, r.run, r.sha, r.status]);
557
563
  * ```
558
564
  */
559
- export declare function artifactList(history: TestHistory, version: string, query?: ArtifactFilter, page?: {
565
+ export declare function artifactList(history: TestHistory, version: string, asked?: ArtifactFilter, page?: {
560
566
  readonly limit?: number;
561
567
  readonly offset?: number;
562
568
  readonly domainOf?: (specPath: string) => string | undefined;