@crvouga/atlas 1.19.0 → 1.20.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.19.0 (2026-10-29)
3
+ ## 1.20.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - mark stored-then-unavailable recordings in the Runs tab from a server read-back ([8c9370a](https://github.com/crvouga/atlas/commit/8c9370aecf67e2b2e690ba25ed512be36a137875))
8
+
9
+ ## 1.19.0 (2026-10-28)
4
10
 
5
11
  ### Features
6
12
 
@@ -1679,6 +1679,61 @@ export declare const ArtifactTagsSchema: z.ZodObject<{
1679
1679
  atMs: z.ZodNumber;
1680
1680
  }, z.core.$loose>>;
1681
1681
  }, z.core.$loose>;
1682
+ /** One stored video that failed the server's read-back: which check holds it and what is wrong with it. */
1683
+ export declare const StoredRecordingGapSchema: z.ZodObject<{
1684
+ check: z.ZodNullable<z.ZodString>;
1685
+ name: z.ZodString;
1686
+ bundleKey: z.ZodString;
1687
+ problem: z.ZodString;
1688
+ reason: z.ZodString;
1689
+ }, z.core.$loose>;
1690
+ /** What reading back the stored videos of one layer found. */
1691
+ export declare const LayerRecordingCheckSchema: z.ZodObject<{
1692
+ layer: z.ZodString;
1693
+ run: z.ZodString;
1694
+ verifiedAtMs: z.ZodNumber;
1695
+ checked: z.ZodNumber;
1696
+ ok: z.ZodNumber;
1697
+ referenced: z.ZodNumber;
1698
+ gaps: z.ZodArray<z.ZodObject<{
1699
+ check: z.ZodNullable<z.ZodString>;
1700
+ name: z.ZodString;
1701
+ bundleKey: z.ZodString;
1702
+ problem: z.ZodString;
1703
+ reason: z.ZodString;
1704
+ }, z.core.$loose>>;
1705
+ }, z.core.$loose>;
1706
+ /**
1707
+ * `GET` and `POST /api/reports/recordings/verification`: the newest read-back the server did of stored videos, per layer.
1708
+ * A layer that was never read back is absent, which means "not verified", never "fine".
1709
+ */
1710
+ export declare const RecordingVerificationSchema: z.ZodObject<{
1711
+ schemaVersion: z.ZodLiteral<1>;
1712
+ layers: z.ZodArray<z.ZodObject<{
1713
+ layer: z.ZodString;
1714
+ run: z.ZodString;
1715
+ verifiedAtMs: z.ZodNumber;
1716
+ checked: z.ZodNumber;
1717
+ ok: z.ZodNumber;
1718
+ referenced: z.ZodNumber;
1719
+ gaps: z.ZodArray<z.ZodObject<{
1720
+ check: z.ZodNullable<z.ZodString>;
1721
+ name: z.ZodString;
1722
+ bundleKey: z.ZodString;
1723
+ problem: z.ZodString;
1724
+ reason: z.ZodString;
1725
+ }, z.core.$loose>>;
1726
+ }, z.core.$loose>>;
1727
+ notices: z.ZodArray<z.ZodString>;
1728
+ verified: z.ZodOptional<z.ZodNumber>;
1729
+ }, z.core.$loose>;
1730
+ /** The document of {@link RecordingVerificationSchema}, as a type. */
1731
+ export type RecordingVerification = z.infer<typeof RecordingVerificationSchema>;
1732
+ /** The body of `POST /api/reports/recordings/verification`. Both fields are optional; an empty body reads back the newest layers. */
1733
+ export declare const RecordingVerifyRequestSchema: z.ZodObject<{
1734
+ layers: z.ZodOptional<z.ZodArray<z.ZodString>>;
1735
+ limit: z.ZodOptional<z.ZodNumber>;
1736
+ }, z.core.$loose>;
1682
1737
  /** A source a review collection points at: one recorded result of one archived layer, and optionally one of its recordings. Exact and immutable: a layer never changes. */
1683
1738
  export declare const ReviewSourceSchema: z.ZodObject<{
1684
1739
  layer: z.ZodString;
@@ -254,6 +254,39 @@ export const ArtifactTagsSchema = z.looseObject({
254
254
  schemaVersion: z.literal(1),
255
255
  entries: z.array(ArtifactTagEntrySchema)
256
256
  });
257
+ /** One stored video that failed the server's read-back: which check holds it and what is wrong with it. */
258
+ export const StoredRecordingGapSchema = z.looseObject({
259
+ check: z.string().nullable().describe('The id of the check that holds the video, or null for a video attached to the run or a part'),
260
+ name: z.string().describe('The attachment\'s name'),
261
+ bundleKey: z.string().describe('The key the layer names the file by'),
262
+ problem: z.string().describe('`not-in-bundle`, `blob-missing`, `unreadable`, `size-mismatch`, `checksum-mismatch` or `undecodable`. An open vocabulary: treat an unknown value as a gap'),
263
+ reason: z.string().describe('A short explanation without secrets or paths')
264
+ });
265
+ /** What reading back the stored videos of one layer found. */
266
+ export const LayerRecordingCheckSchema = z.looseObject({
267
+ layer: z.string().describe('The layer id'),
268
+ run: z.string().describe('The key of the run the layer belongs to (a `RunSummary.key`)'),
269
+ verifiedAtMs: z.number().describe('When the server read the videos back, epoch milliseconds'),
270
+ checked: z.number().int().describe('Videos read back: those that name a bundle key'),
271
+ ok: z.number().int().describe('Of those, the ones that passed every check'),
272
+ referenced: z.number().int().describe('Videos given only by address, which Atlas cannot read back and so does not vouch for'),
273
+ gaps: z.array(StoredRecordingGapSchema).describe('The videos that did not pass. Never a test result: statuses are unchanged')
274
+ });
275
+ /**
276
+ * `GET` and `POST /api/reports/recordings/verification`: the newest read-back the server did of stored videos, per layer.
277
+ * A layer that was never read back is absent, which means "not verified", never "fine".
278
+ */
279
+ export const RecordingVerificationSchema = z.looseObject({
280
+ schemaVersion: z.literal(1),
281
+ layers: z.array(LayerRecordingCheckSchema).describe('Layers the server has read back and still holds, newest first'),
282
+ notices: z.array(z.string()).describe('Layers the last pass could not read at all: their recordings are unknown, not verified'),
283
+ verified: z.number().int().optional().describe('POST only: how many layers this pass read back')
284
+ });
285
+ /** The body of `POST /api/reports/recordings/verification`. Both fields are optional; an empty body reads back the newest layers. */
286
+ export const RecordingVerifyRequestSchema = z.looseObject({
287
+ layers: z.array(z.string().regex(/^[0-9a-f]{7,64}$/i)).max(50).optional().describe('Only layers whose id starts with one of these (at least 7 hex digits)'),
288
+ limit: z.number().int().min(1).max(200).optional().describe('How many of the newest layers to read back. Default 20, at most 200')
289
+ });
257
290
  /** A source a review collection points at: one recorded result of one archived layer, and optionally one of its recordings. Exact and immutable: a layer never changes. */
258
291
  export const ReviewSourceSchema = z.looseObject({
259
292
  layer: z.string().regex(/^[0-9a-f]{64}$/).describe('The id of the archived layer that recorded the result'),
@@ -44,6 +44,7 @@ import { createAuth } from './auth.js';
44
44
  import { EventHub } from './events.js';
45
45
  import { projectServer } from './projects.js';
46
46
  import { mountReviewCollections } from './review-collections.js';
47
+ import { mountRecordingVerification } from './recording-verification.js';
47
48
  import { readLimited, storeApi } from './store-api.js';
48
49
  const gzipAsync = promisify(gzip);
49
50
  const brotliAsync = promisify(brotliCompress);
@@ -260,7 +261,7 @@ function createProjectApplication(options) {
260
261
  name: 'atlas', version: options.version ?? null, role: identity?.role ?? 'read', open: auth.open,
261
262
  write: options.write, ready: index.ready, viewers: events.count, time: now(),
262
263
  backends: snapshot.backends.map(({ id, label, available }) => ({ id, label, available, write: id === options.write })),
263
- ...(({ backends: _unused, ...rest }) => rest)(index.stats()), capabilities: { events: true, ingest: true, store: true, checks: true, status: true, effective: true, live: true, tests: true, artifactTags: true, artifactSearch: true, reviewCollections: true }
264
+ ...(({ backends: _unused, ...rest }) => rest)(index.stats()), capabilities: { events: true, ingest: true, store: true, checks: true, status: true, effective: true, live: true, tests: true, artifactTags: true, artifactSearch: true, reviewCollections: true, recordingVerification: true }
264
265
  });
265
266
  });
266
267
  app.get('/api/live', async (c) => c.json({ runs: await live.list() }, 200, { 'cache-control': 'no-store' }));
@@ -565,6 +566,7 @@ function createProjectApplication(options) {
565
566
  });
566
567
  // Review collections: immutable, curated references to recorded results, with reviewers' notes kept apart.
567
568
  mountReviewCollections(app, { store: tagStore, stores, auth, index, now, maxBytes: limits.json, unauthorized });
569
+ mountRecordingVerification(app, { stores, auth, index, now, unauthorized });
568
570
  app.route('/api/reports', layers);
569
571
  // Upload: the store protocol, and a simple JSON way in for anything that can make a request.
570
572
  app.route('/api/store', storeApi({
@@ -1,5 +1,5 @@
1
1
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
2
- export declare const API_VERSION = "1.6.0";
2
+ export declare const API_VERSION = "1.7.0";
3
3
  /** Who may call an operation: `none` (anyone), or the least role that may (`read` < `critic` < `publish` < `admin`). */
4
4
  export type Role = 'none' | 'read' | 'critic' | 'publish' | 'admin';
5
5
  type JsonSchema = Record<string, unknown>;
@@ -14,10 +14,10 @@
14
14
  */
15
15
  import { z } from 'zod/v4';
16
16
  import { ProjectsSchema } from '../../schema/src/project.js';
17
- import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, TestReliabilitySchema, RunListSchema, RunDetailSchema, ArtifactListSchema, UiCatalogSchema, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema, ReviewCollectionRequestSchema, ReviewCollectionSchema, ReviewCollectionListSchema, ReviewCollectionDetailSchema, ReviewNoteInputSchema, ReviewNoteSchema, ReviewManifestSchema } from '../../schema/src/api.js';
17
+ import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, TestReliabilitySchema, RunListSchema, RunDetailSchema, ArtifactListSchema, UiCatalogSchema, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema, ReviewCollectionRequestSchema, ReviewCollectionSchema, ReviewCollectionListSchema, ReviewCollectionDetailSchema, ReviewNoteInputSchema, ReviewNoteSchema, ReviewManifestSchema, RecordingVerificationSchema, RecordingVerifyRequestSchema } from '../../schema/src/api.js';
18
18
  import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, GcPolicySchema, GcSnapshotSchema, GcRunResultSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
19
19
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
20
- export const API_VERSION = '1.6.0';
20
+ export const API_VERSION = '1.7.0';
21
21
  /** The JSON Schema of a Zod schema, as OpenAPI 3.1 embeds it. `output` for what the server sends, `input` for what it accepts. */
22
22
  function schemaOf(schema, io = 'output') {
23
23
  const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { target: 'draft-2020-12', unrepresentable: 'any', io });
@@ -38,6 +38,7 @@ const COMPONENTS = {
38
38
  Outline: [OutlineSchema, 'output'], EvidencePage: [EvidencePageSchema, 'output'], LayerRows: [LayerRowsSchema, 'output'], CheckDetail: [CheckDetailSchema, 'output'],
39
39
  ArtifactTagEdit: [ArtifactTagEditSchema, 'input'], ArtifactTagEntry: [ArtifactTagEntrySchema, 'output'], ArtifactTags: [ArtifactTagsSchema, 'output'],
40
40
  ReviewCollectionRequest: [ReviewCollectionRequestSchema, 'input'], ReviewCollection: [ReviewCollectionSchema, 'output'], ReviewCollectionList: [ReviewCollectionListSchema, 'output'], ReviewCollectionDetail: [ReviewCollectionDetailSchema, 'output'], ReviewNoteInput: [ReviewNoteInputSchema, 'input'], ReviewNote: [ReviewNoteSchema, 'output'], ReviewManifest: [ReviewManifestSchema, 'output'],
41
+ RecordingVerification: [RecordingVerificationSchema, 'output'], RecordingVerifyRequest: [RecordingVerifyRequestSchema, 'input'],
41
42
  TestCatalog: [TestCatalogSchema, 'output'], CatalogHealth: [CatalogHealthSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], TestReliability: [TestReliabilitySchema, 'output'], RunList: [RunListSchema, 'output'], ArtifactList: [ArtifactListSchema, 'output'], UiCatalog: [UiCatalogSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
42
43
  SpecIndex: [SpecIndexSchema, 'output'], ReportSources: [ReportSourcesSchema, 'output'], RunsIndex: [RunsIndexSchema, 'output'],
43
44
  RunEvent: [LiveRunSchema, 'output'], HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
@@ -367,6 +368,27 @@ const BASE_OPERATIONS = [
367
368
  body: { description: 'The attachment and the tags to add and remove. At most 20 of each, 64 KiB.', contentType: 'application/json', required: true, schema: ref('ArtifactTagEdit') },
368
369
  responses: { '201': json('The recorded entry.', 'ArtifactTagEntry'), '401': UNAUTHORIZED, '403': FORBIDDEN, '413': problem('Request too large.'), '422': problem('Not a tag edit: a bad target, an empty or over-long tag, or nothing to change.') }
369
370
  },
371
+ {
372
+ route: 'GET /api/reports/recordings/verification', path: '/api/reports/recordings/verification', operationId: 'getRecordingVerification', tag: 'Report', role: 'read',
373
+ summary: 'Which stored recordings the server last read back, and which were gone or damaged',
374
+ description: [
375
+ 'The newest read-back the server did of stored videos, per layer: how many it checked, how many passed and the `gaps` (a blob that is gone, bytes of another size or hash than were published, or a file that does not begin like its container), each naming the check that holds the video. `run` is the key of the run the layer belongs to (a `RunSummary.key`), so a viewer can mark that run\'s recordings as stored-then-unavailable beside its test results.',
376
+ '',
377
+ 'This route never reads a blob: it returns what a publish token\'s `POST` found earlier. A layer that was never read back is absent, which means "not verified", not "fine"; the result is kept in the server\'s memory only, so a restart empties it. Only layers a store still holds are listed (one removed by garbage collection drops out). No status, slot or winner depends on it. It checks stored bytes, not browser playback. Narrow with `?run=` (a run key) and `?layer=` (an id prefix of at least 7 hex digits, repeatable).'
378
+ ].join('\n'),
379
+ parameters: [
380
+ { name: 'run', in: 'query', description: 'Only layers of this run (a key from `GET /api/reports/runs`).', schema: str },
381
+ { name: 'layer', in: 'query', description: 'Only layers whose id starts with this prefix. Repeatable or comma-separated.', schema: str }
382
+ ],
383
+ responses: { '200': json('The held read-backs.', 'RecordingVerification'), '401': UNAUTHORIZED }
384
+ },
385
+ {
386
+ route: 'POST /api/reports/recordings/verification', path: '/api/reports/recordings/verification', operationId: 'verifyRecordings', tag: 'Report', role: 'publish',
387
+ summary: 'Read stored recordings back and check them (publish token)',
388
+ description: 'Reads the videos of the newest layers (`limit`, default 20, at most 200; or the layers `layers` names) back from storage and checks that each is still stored, still has the size and SHA-256 published, and still begins like its media type\'s container. This reads blobs, so it needs a publish (or admin) token in a Bearer header; a reader-only token, a browser sign-in box and an anonymous reader are refused. One read-back runs at a time (`409` otherwise). It writes nothing to storage and changes no status. The answer is the same document `GET` returns, with `verified`: how many layers this pass read back.',
389
+ body: { description: 'Optional `layers` (id prefixes) and `limit`. An empty body reads back the newest layers.', contentType: 'application/json', required: false, schema: ref('RecordingVerifyRequest') },
390
+ responses: { '200': json('The read-backs now held.', 'RecordingVerification'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('A read-back is already running.'), '413': problem('Request too large.'), '422': problem('Not a verification request.'), '502': problem('A store could not be read.'), '503': NOT_READY }
391
+ },
370
392
  {
371
393
  route: 'GET /api/reports/review-collections', path: '/api/reports/review-collections', operationId: 'listReviewCollections', tag: 'Report', role: 'read',
372
394
  summary: 'Review collections: curated sets of recordings, newest first',
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @module Recording verification routes
3
+ *
4
+ * `/api/reports/recordings/verification`: whether the videos a run published can still be read back from storage as
5
+ * they were published, apart from whether the run's tests passed. A viewer reads the newest result with a read token
6
+ * (`GET`, which never touches a blob). A publish token starts a read-back (`POST`), which does read blobs and so is
7
+ * bounded: the newest 20 layers unless asked for more, at most 200, one pass at a time. The result is kept in this
8
+ * process only (it is derived, not state: a restart means "not verified yet", never "fine"), and only for layers the
9
+ * store still holds. It adds nothing to any layer and changes no status, slot or winner. This is the same check as
10
+ * `atlas report verify-recordings`, and as that command it checks stored bytes, not browser playback.
11
+ *
12
+ * @see docs/rich-evidence.md#recording-completeness
13
+ */
14
+ import type { Hono } from 'hono';
15
+ import type { ObjectStore } from '../../reports/src/object-store.js';
16
+ import type { ReportIndex } from '../../reports/src/report-index.js';
17
+ import type { Auth } from './auth.js';
18
+ /** What the verification routes need from the server around them. */
19
+ export type RecordingVerificationDeps = {
20
+ /** Every backend's store: a layer is read back from the store that holds it. */
21
+ readonly stores: readonly ObjectStore[];
22
+ readonly auth: Auth;
23
+ readonly index: ReportIndex;
24
+ readonly now: () => number;
25
+ readonly unauthorized: (verdict: Extract<ReturnType<Auth['check']>, {
26
+ ok: false;
27
+ }>) => Response;
28
+ };
29
+ /**
30
+ * Registers `GET` and `POST /api/reports/recordings/verification` on `app`. The result of the last read-back of each
31
+ * layer is held in memory, replaced by a newer one, and served only for layers a store still holds. A layer held by
32
+ * two backends keeps the reading with more gaps, so a copy that lost its blobs is never hidden by one that did not.
33
+ */
34
+ export declare function mountRecordingVerification(app: Hono, deps: RecordingVerificationDeps): void;
@@ -0,0 +1,118 @@
1
+ import { RecordingVerifyRequestSchema } from '../../schema/src/api.js';
2
+ import { eachLimited } from '../../reports/src/completion-order.js';
3
+ import { layerKey } from '../../reports/src/layer-store.js';
4
+ import { verifyStoreRecordings } from '../../reports/src/recording-verify.js';
5
+ import { runKey } from '../../reports/src/test-catalog.js';
6
+ import { problemResponse } from './problem.js';
7
+ import { readLimited } from './store-api.js';
8
+ import { Refusal } from './validate.js';
9
+ const NO_STORE = { 'cache-control': 'no-store' };
10
+ const HELD_MS = 30_000;
11
+ const DEFAULT_LIMIT = 20;
12
+ const MAX_LIMIT = 200;
13
+ /**
14
+ * Registers `GET` and `POST /api/reports/recordings/verification` on `app`. The result of the last read-back of each
15
+ * layer is held in memory, replaced by a newer one, and served only for layers a store still holds. A layer held by
16
+ * two backends keeps the reading with more gaps, so a copy that lost its blobs is never hidden by one that did not.
17
+ */
18
+ export function mountRecordingVerification(app, deps) {
19
+ const { stores, auth, index, now } = deps;
20
+ const held = new Map();
21
+ let notices = [];
22
+ let running = false;
23
+ // Whether a layer's manifest is still in some store (garbage collection removes superseded layers), asked at most every
24
+ // HELD_MS. Only a `head` of the manifest: no recording is read.
25
+ const present = new Map();
26
+ const holds = async (ids) => {
27
+ const out = new Set();
28
+ await eachLimited(ids, 8, async (id) => {
29
+ const known = present.get(id);
30
+ let value = known !== undefined && now() - known.at < HELD_MS ? known.value : undefined;
31
+ if (value === undefined) {
32
+ value = false;
33
+ for (const store of stores) {
34
+ if (await store.head(layerKey(id)).catch(() => false)) {
35
+ value = true;
36
+ break;
37
+ }
38
+ }
39
+ present.set(id, { at: now(), value });
40
+ }
41
+ if (value)
42
+ out.add(id);
43
+ });
44
+ return out;
45
+ };
46
+ const document = async (request, extra = {}) => {
47
+ const search = new URL(request.url).searchParams;
48
+ const run = search.get('run');
49
+ const prefixes = search.getAll('layer').flatMap(value => value.split(',')).map(value => value.trim().toLowerCase()).filter(Boolean);
50
+ const wanted = [...held.values()].filter(entry => (!run || entry.run === run) && (!prefixes.length || prefixes.some(prefix => entry.layer.startsWith(prefix))));
51
+ const still = await holds(wanted.map(entry => entry.layer));
52
+ const layers = wanted.filter(entry => still.has(entry.layer))
53
+ .sort((a, b) => b.verifiedAtMs - a.verifiedAtMs || (a.layer < b.layer ? -1 : 1));
54
+ return { schemaVersion: 1, layers, notices, ...extra };
55
+ };
56
+ app.get('/api/reports/recordings/verification', async (c) => c.json(await document(c.req.raw), 200, NO_STORE));
57
+ app.post('/api/reports/recordings/verification', async (c) => {
58
+ const verdict = auth.check(c.req.raw, 'publish');
59
+ if (!verdict.ok)
60
+ return deps.unauthorized(verdict);
61
+ if (!index.ready)
62
+ return problemResponse(503, 'The report is still loading. Try again in a moment.', { 'retry-after': '2' });
63
+ let input = {};
64
+ try {
65
+ const bytes = await readLimited(c.req.raw, 16 * 1024);
66
+ if (bytes.length) {
67
+ try {
68
+ input = JSON.parse(new TextDecoder().decode(bytes));
69
+ }
70
+ catch {
71
+ return problemResponse(422, 'The body is not JSON.');
72
+ }
73
+ }
74
+ }
75
+ catch (error) {
76
+ if (error instanceof Refusal)
77
+ return problemResponse(error.status, error.message);
78
+ throw error;
79
+ }
80
+ const parsed = RecordingVerifyRequestSchema.safeParse(input);
81
+ if (!parsed.success)
82
+ return problemResponse(422, `Not a verification request: ${parsed.error.issues[0]?.message ?? 'invalid'}.`);
83
+ if (running)
84
+ return problemResponse(409, 'A read-back is already running. Read the result with GET when it finishes.', { 'retry-after': '5' });
85
+ running = true;
86
+ try {
87
+ const limit = Math.min(MAX_LIMIT, parsed.data.limit ?? DEFAULT_LIMIT);
88
+ const runs = new Map();
89
+ const found = [];
90
+ const lost = [];
91
+ const at = now();
92
+ for (const store of stores) {
93
+ const result = await verifyStoreRecordings(store, {
94
+ ...(parsed.data.layers?.length ? { ids: parsed.data.layers } : {}), limit,
95
+ include: layer => { runs.set(layer.id, runKey(layer)); return true; }
96
+ });
97
+ lost.push(...result.notices);
98
+ for (const layer of result.layers)
99
+ found.push({ ...layer, run: runs.get(layer.layer) ?? '', verifiedAtMs: at });
100
+ }
101
+ // A layer held by two backends keeps the copy with more gaps; a newer pass replaces an older one.
102
+ const fresh = new Map();
103
+ for (const entry of found)
104
+ if ((fresh.get(entry.layer)?.gaps.length ?? -1) < entry.gaps.length || !fresh.has(entry.layer))
105
+ fresh.set(entry.layer, entry);
106
+ for (const [layer, entry] of fresh)
107
+ held.set(layer, entry);
108
+ notices = [...new Set(lost)];
109
+ return c.json(await document(c.req.raw, { verified: fresh.size }), 200, NO_STORE);
110
+ }
111
+ catch (error) {
112
+ return problemResponse(502, `The store could not be read: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
113
+ }
114
+ finally {
115
+ running = false;
116
+ }
117
+ });
118
+ }
@@ -694,6 +694,58 @@ Scoped to one project. The same project may be selected on the unprefixed API us
694
694
 
695
695
  Responses: `201` The recorded entry.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `413` Request too large.; `422` Not a tag edit: a bad target, an empty or over-long tag, or nothing to change.
696
696
 
697
+ ### Which stored recordings the server last read back, and which were gone or damaged
698
+
699
+ `GET /api/reports/recordings/verification` · role: read
700
+
701
+ The newest read-back the server did of stored videos, per layer: how many it checked, how many passed and the `gaps` (a blob that is gone, bytes of another size or hash than were published, or a file that does not begin like its container), each naming the check that holds the video. `run` is the key of the run the layer belongs to (a `RunSummary.key`), so a viewer can mark that run's recordings as stored-then-unavailable beside its test results.
702
+
703
+ This route never reads a blob: it returns what a publish token's `POST` found earlier. A layer that was never read back is absent, which means "not verified", not "fine"; the result is kept in the server's memory only, so a restart empties it. Only layers a store still holds are listed (one removed by garbage collection drops out). No status, slot or winner depends on it. It checks stored bytes, not browser playback. Narrow with `?run=` (a run key) and `?layer=` (an id prefix of at least 7 hex digits, repeatable).
704
+
705
+ - `run` (query): Only layers of this run (a key from `GET /api/reports/runs`).
706
+ - `layer` (query): Only layers whose id starts with this prefix. Repeatable or comma-separated.
707
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
708
+
709
+ Responses: `200` The held read-backs.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.
710
+
711
+ ### Which stored recordings the server last read back, and which were gone or damaged
712
+
713
+ `GET /api/projects/{project}/reports/recordings/verification` · role: read
714
+
715
+ The newest read-back the server did of stored videos, per layer: how many it checked, how many passed and the `gaps` (a blob that is gone, bytes of another size or hash than were published, or a file that does not begin like its container), each naming the check that holds the video. `run` is the key of the run the layer belongs to (a `RunSummary.key`), so a viewer can mark that run's recordings as stored-then-unavailable beside its test results.
716
+
717
+ This route never reads a blob: it returns what a publish token's `POST` found earlier. A layer that was never read back is absent, which means "not verified", not "fine"; the result is kept in the server's memory only, so a restart empties it. Only layers a store still holds are listed (one removed by garbage collection drops out). No status, slot or winner depends on it. It checks stored bytes, not browser playback. Narrow with `?run=` (a run key) and `?layer=` (an id prefix of at least 7 hex digits, repeatable).
718
+
719
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
720
+
721
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
722
+ - `run` (query): Only layers of this run (a key from `GET /api/reports/runs`).
723
+ - `layer` (query): Only layers whose id starts with this prefix. Repeatable or comma-separated.
724
+
725
+ Responses: `200` The held read-backs.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.
726
+
727
+ ### Read stored recordings back and check them (publish token)
728
+
729
+ `POST /api/reports/recordings/verification` · role: publish
730
+
731
+ Reads the videos of the newest layers (`limit`, default 20, at most 200; or the layers `layers` names) back from storage and checks that each is still stored, still has the size and SHA-256 published, and still begins like its media type's container. This reads blobs, so it needs a publish (or admin) token in a Bearer header; a reader-only token, a browser sign-in box and an anonymous reader are refused. One read-back runs at a time (`409` otherwise). It writes nothing to storage and changes no status. The answer is the same document `GET` returns, with `verified`: how many layers this pass read back.
732
+
733
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
734
+
735
+ Responses: `200` The read-backs now held.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `409` A read-back is already running.; `413` Request too large.; `422` Not a verification request.; `502` A store could not be read.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
736
+
737
+ ### Read stored recordings back and check them (publish token)
738
+
739
+ `POST /api/projects/{project}/reports/recordings/verification` · role: publish
740
+
741
+ Reads the videos of the newest layers (`limit`, default 20, at most 200; or the layers `layers` names) back from storage and checks that each is still stored, still has the size and SHA-256 published, and still begins like its media type's container. This reads blobs, so it needs a publish (or admin) token in a Bearer header; a reader-only token, a browser sign-in box and an anonymous reader are refused. One read-back runs at a time (`409` otherwise). It writes nothing to storage and changes no status. The answer is the same document `GET` returns, with `verified`: how many layers this pass read back.
742
+
743
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
744
+
745
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
746
+
747
+ Responses: `200` The read-backs now held.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `409` A read-back is already running.; `413` Request too large.; `422` Not a verification request.; `502` A store could not be read.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
748
+
697
749
  ### Review collections: curated sets of recordings, newest first
698
750
 
699
751
  `GET /api/reports/review-collections` · role: read
@@ -233,6 +233,47 @@ export const ArtifactTagsSchema = …
233
233
 
234
234
  `GET /api/reports/artifact-tags`: every recorded edit, oldest first. Fold them with `foldArtifactTags`.
235
235
 
236
+ #### `StoredRecordingGapSchema`
237
+
238
+ ```ts
239
+ export const StoredRecordingGapSchema = …
240
+ ```
241
+
242
+ One stored video that failed the server's read-back: which check holds it and what is wrong with it.
243
+
244
+ #### `LayerRecordingCheckSchema`
245
+
246
+ ```ts
247
+ export const LayerRecordingCheckSchema = …
248
+ ```
249
+
250
+ What reading back the stored videos of one layer found.
251
+
252
+ #### `RecordingVerificationSchema`
253
+
254
+ ```ts
255
+ export const RecordingVerificationSchema = …
256
+ ```
257
+
258
+ `GET` and `POST /api/reports/recordings/verification`: the newest read-back the server did of stored videos, per layer.
259
+ A layer that was never read back is absent, which means "not verified", never "fine".
260
+
261
+ #### `RecordingVerification`
262
+
263
+ ```ts
264
+ export type RecordingVerification = z.infer<typeof RecordingVerificationSchema>;
265
+ ```
266
+
267
+ The document of {@link RecordingVerificationSchema}, as a type.
268
+
269
+ #### `RecordingVerifyRequestSchema`
270
+
271
+ ```ts
272
+ export const RecordingVerifyRequestSchema = …
273
+ ```
274
+
275
+ The body of `POST /api/reports/recordings/verification`. Both fields are optional; an empty body reads back the newest layers.
276
+
236
277
  #### `ReviewSourceSchema`
237
278
 
238
279
  ```ts
@@ -627,6 +627,45 @@ export function projectServer(options: AtlasServerOptions, create: (options: Atl
627
627
 
628
628
  Wrap the single-project protocol in project selection. Auth, upload validation and composition stay identical.
629
629
 
630
+ ### Recording verification routes
631
+
632
+ Source: `packages/server/src/recording-verification.ts`
633
+
634
+ `/api/reports/recordings/verification`: whether the videos a run published can still be read back from storage as
635
+ they were published, apart from whether the run's tests passed. A viewer reads the newest result with a read token
636
+ (`GET`, which never touches a blob). A publish token starts a read-back (`POST`), which does read blobs and so is
637
+ bounded: the newest 20 layers unless asked for more, at most 200, one pass at a time. The result is kept in this
638
+ process only (it is derived, not state: a restart means "not verified yet", never "fine"), and only for layers the
639
+ store still holds. It adds nothing to any layer and changes no status, slot or winner. This is the same check as
640
+ `atlas report verify-recordings`, and as that command it checks stored bytes, not browser playback.
641
+
642
+ See docs/rich-evidence.md#recording-completeness
643
+
644
+ #### `RecordingVerificationDeps`
645
+
646
+ ```ts
647
+ export type RecordingVerificationDeps = {
648
+ /** Every backend's store: a layer is read back from the store that holds it. */
649
+ readonly stores: readonly ObjectStore[];
650
+ readonly auth: Auth;
651
+ readonly index: ReportIndex;
652
+ readonly now: () => number;
653
+ readonly unauthorized: (verdict: Extract<ReturnType<Auth['check']>, { ok: false }>) => Response;
654
+ };
655
+ ```
656
+
657
+ What the verification routes need from the server around them.
658
+
659
+ #### `mountRecordingVerification`
660
+
661
+ ```ts
662
+ export function mountRecordingVerification(app: Hono, deps: RecordingVerificationDeps): void
663
+ ```
664
+
665
+ Registers `GET` and `POST /api/reports/recordings/verification` on `app`. The result of the last read-back of each
666
+ layer is held in memory, replaced by a newer one, and served only for layers a store still holds. A layer held by
667
+ two backends keeps the reading with more gaps, so a copy that lost its blobs is never hidden by one that did not.
668
+
630
669
  ### Review collection routes
631
670
 
632
671
  Source: `packages/server/src/review-collections.ts`
@@ -504,6 +504,18 @@ two ways without touching a check's result:
504
504
  `--format json` prints the `verify-recordings-result` document. `--layer <id>` and `--limit <n>` narrow the work, since
505
505
  it reads blobs. A store that cannot answer is `unreadable`, never verified. Test results are unchanged either way.
506
506
 
507
+ - **In the viewer.** A publish token starts the same read-back on the server with
508
+ `POST /api/reports/recordings/verification` (body optional: `{ "layers": ["<id prefix>"], "limit": 20 }`; the newest
509
+ 20 layers by default, at most 200, one read-back at a time; a reader token, a browser sign-in box or an anonymous
510
+ caller is refused because it reads blobs). Anyone who can read the report then sees the result with
511
+ `GET /api/reports/recordings/verification` (which reads no blob): per layer, its run, how many videos were read back, how
512
+ many passed and each gap. The Runs tab and an opened run show `Recording unavailable: 1 of 3 stored videos failed the
513
+ read-back (1 file gone from storage)` beside the green outcomes, or `3 stored videos verified`, and a run whose stored
514
+ videos all failed offers no Watch action. A run whose layers were never read back shows nothing: unknown is not
515
+ verified. The result lives in the server's memory only, so a restart empties it, and a layer that garbage collection
516
+ removed drops out. An older server has no such route and the viewer then shows nothing. No status, slot or winner is
517
+ affected; `capabilities.recordingVerification` in `GET /api/server` says a server offers it.
518
+
507
519
  Both are statements about stored bytes. Atlas does not check that a stored video plays in a given browser (a valid header
508
520
  can still have an unsupported codec or be truncated), and acknowledging an upload is not playback. Videos given only by
509
521
  `uri` are counted as referenced and not checked.
package/docs/server.md CHANGED
@@ -338,6 +338,14 @@ edit, and the server's time. Nothing about the layer, its results or the report'
338
338
  See [Tagging artifacts and demos](rich-evidence.md#tagging-artifacts-and-demos) for the tag rules. Reviewer tags are
339
339
  shown in the viewer's evidence; the tests API (`artifactTag`) searches the producers' tags.
340
340
 
341
+ ## Reading recordings back
342
+
343
+ `POST /api/reports/recordings/verification` (publish, `Authorization: Bearer`) reads the stored videos of the newest layers
344
+ back and checks them; `GET` (read) returns the newest result per layer without reading any blob, so the viewer can mark a
345
+ run whose recording was stored and then lost, apart from its test results. The result is in memory only and layers
346
+ that were never read back are absent (unknown, never "fine"). See
347
+ [Is the recording still there?](rich-evidence.md#is-the-recording-still-there) for what is checked and what is not.
348
+
341
349
  ## Review collections
342
350
 
343
351
  A team can publish a named, curated review of a few behaviors: recordings from several explicit executions, notes on
@@ -517,6 +525,10 @@ curl "$ATLAS_SERVER_URL/api/projects/$ATLAS_PROJECT/reports/tests?format=markdow
517
525
  | `GET /api/projects/{project}/reports/artifact-tags` | read | Reviewer tags of attachments: every recorded edit. |
518
526
  | `POST /api/reports/artifact-tags` | publish | Tag or untag one attachment after upload. |
519
527
  | `POST /api/projects/{project}/reports/artifact-tags` | publish | Tag or untag one attachment after upload. |
528
+ | `GET /api/reports/recordings/verification` | read | Which stored recordings the server last read back, and which were gone or damaged. |
529
+ | `GET /api/projects/{project}/reports/recordings/verification` | read | Which stored recordings the server last read back, and which were gone or damaged. |
530
+ | `POST /api/reports/recordings/verification` | publish | Read stored recordings back and check them (publish token). |
531
+ | `POST /api/projects/{project}/reports/recordings/verification` | publish | Read stored recordings back and check them (publish token). |
520
532
  | `GET /api/reports/review-collections` | read | Review collections: curated sets of recordings, newest first. |
521
533
  | `GET /api/projects/{project}/reports/review-collections` | read | Review collections: curated sets of recordings, newest first. |
522
534
  | `POST /api/reports/review-collections` | publish | Publish an immutable review collection (or a new revision of one). |