@crvouga/atlas 1.18.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/reports/src/evidence-detail.js +13 -3
  3. package/dist/reports/src/evidence-types.d.ts +4 -0
  4. package/dist/schema/src/api.d.ts +105 -0
  5. package/dist/schema/src/api.js +35 -0
  6. package/dist/server/src/app.js +3 -1
  7. package/dist/server/src/openapi.d.ts +1 -1
  8. package/dist/server/src/openapi.js +24 -2
  9. package/dist/server/src/recording-verification.d.ts +34 -0
  10. package/dist/server/src/recording-verification.js +118 -0
  11. package/docs/reference/http-api.md +52 -0
  12. package/docs/reference/reports.md +4 -0
  13. package/docs/reference/schema.md +41 -0
  14. package/docs/reference/server.md +39 -0
  15. package/docs/rich-evidence.md +13 -0
  16. package/docs/server.md +12 -0
  17. package/docs/ui.md +40 -1
  18. package/llms-full.txt +117 -1
  19. package/package.json +1 -1
  20. package/packages/schema/schemas/effective-delta.schema.json +18 -0
  21. package/packages/schema/schemas/effective-evidence.schema.json +18 -0
  22. package/packages/schema/schemas/evidence-page.schema.json +9 -0
  23. package/packages/schema/schemas/layer-rows.schema.json +9 -0
  24. package/packages/schema/schemas/report-run.schema.json +9 -0
  25. package/packages/schema/schemas/review-collection.schema.json +9 -0
  26. package/packages/schema/schemas/review-manifest.schema.json +9 -0
  27. package/packages/schema/schemas/test-catalog.schema.json +9 -0
  28. package/packages/schema/schemas/test-history.schema.json +18 -0
  29. package/packages/server/openapi.json +653 -1
  30. package/packages/visualizer/src/app/AppShell.tsx +2 -2
  31. package/packages/visualizer/src/app/router.tsx +25 -1
  32. package/packages/visualizer/src/components/Header.tsx +15 -4
  33. package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
  34. package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
  35. package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
  36. package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
  37. package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
  38. package/packages/visualizer/src/evidence/behavior.ts +32 -1
  39. package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
  40. package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
  41. package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
  42. package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
  43. package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
  44. package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
  45. package/packages/visualizer/src/explorer/ArtifactsView.tsx +4 -4
  46. package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
  47. package/packages/visualizer/src/explorer/RunsView.tsx +36 -10
  48. package/packages/visualizer/src/explorer/catalog.tsx +24 -1
  49. package/packages/visualizer/src/explorer/collections.tsx +5 -5
  50. package/packages/visualizer/src/explorer/navigate.ts +17 -0
  51. package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
  52. package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
  53. package/packages/visualizer/src/explorer/playlist.tsx +43 -8
  54. package/packages/visualizer/src/explorer/recording-verification.ts +58 -0
  55. package/packages/visualizer/src/explorer/search.ts +7 -1
  56. package/packages/visualizer/src/find/Finder.tsx +1 -62
package/CHANGELOG.md CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.18.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)
10
+
11
+ ### Features
12
+
13
+ - add a project-wide behavior index, a read-and-watch workspace and a scoped summary ([bb2d270](https://github.com/crvouga/atlas/commit/bb2d270fcfc2374c316db804e80af67885bdd8ee))
14
+ - restore the watch playlist from the address and show clip posters and lengths ([2dfc480](https://github.com/crvouga/atlas/commit/2dfc4806a72621f67f304cd9fda7619a9dbbea3f))
15
+
16
+ ## 1.18.0 (2026-10-28)
4
17
 
5
18
  ### Features
6
19
 
@@ -62,7 +62,7 @@ function summarize(holders, owner, inherited, firstIsParent = false, unstored) {
62
62
  for (const tag of normalizeTags(item.tags))
63
63
  artifactTags.add(tag);
64
64
  roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
65
- const video = videoOf(item, by, where, effectiveScope(item.scope, ...scopes), unstored);
65
+ const video = videoOf(item, by, where, effectiveScope(item.scope, ...scopes), unstored, list);
66
66
  if (video)
67
67
  videos.push(video);
68
68
  }
@@ -110,14 +110,24 @@ export function isVideoAttachment(attachment) {
110
110
  export function attachmentIdentity(item) {
111
111
  return item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
112
112
  }
113
- function videoOf(item, owner, where, scope, unstored) {
113
+ /** The bundle key of the image an attachment names as its poster among its siblings, when the layer's bundle holds it. */
114
+ function posterKey(item, siblings, unstored) {
115
+ if (item.poster === undefined)
116
+ return undefined;
117
+ const poster = siblings?.find(other => other !== item && other.id === item.poster);
118
+ if (!poster?.bundleKey || unstored?.has(poster.bundleKey))
119
+ return undefined;
120
+ return (poster.contentType ?? contentTypeOf(poster.name)).toLowerCase().startsWith('image/') ? poster.bundleKey : undefined;
121
+ }
122
+ function videoOf(item, owner, where, scope, unstored, siblings) {
114
123
  if (!isVideoAttachment(item))
115
124
  return undefined;
116
125
  // A bundle key the layer's bundle does not hold names bytes that are not there: only an address or inline text can still carry them, and the digest of lost bytes identifies nothing playable.
117
126
  const lost = item.bundleKey !== undefined && unstored?.has(item.bundleKey) === true;
118
127
  const recording = lost ? attachmentIdentity({ name: item.name, uri: item.uri, text: item.text }) : attachmentIdentity(item);
119
128
  const tags = normalizeTags(item.tags);
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 } : {}) };
129
+ const poster = posterKey(item, siblings, unstored);
130
+ return { name: item.name, owner, ...where, ...(tags.length ? { tags } : {}), ...(recording === undefined ? {} : { recording }), available: recording !== undefined, ...(lost && recording === undefined ? { reason: 'not-in-bundle' } : {}), ...(item.durationMs !== undefined && Number.isFinite(item.durationMs) ? { durationMs: item.durationMs } : {}), ...(poster === undefined ? {} : { poster }), ...(scope ? { scope } : {}) };
121
131
  }
122
132
  /**
123
133
  * Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
@@ -60,6 +60,10 @@ export type VideoRef = {
60
60
  readonly available: boolean;
61
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
62
  readonly reason?: 'not-in-bundle';
63
+ /** How long the recording is, in milliseconds, as its producer declared (`Attachment.durationMs`): a hint to show before the file is opened, never measured here and never a latency. Absent when none was declared, and in documents of older servers. */
64
+ readonly durationMs?: number;
65
+ /** The bundle key, in the same layer, of the image the producer named as this recording's poster (`Attachment.poster`): a hint to show before the file is opened. Only an image the layer's bundle holds; absent when there is none, and in documents of older servers. */
66
+ readonly poster?: string;
63
67
  /** 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. */
64
68
  readonly scope?: EvidenceScope;
65
69
  };
@@ -91,6 +91,8 @@ export declare const VideoRefSchema: z.ZodObject<{
91
91
  recording: z.ZodOptional<z.ZodString>;
92
92
  available: z.ZodBoolean;
93
93
  reason: z.ZodOptional<z.ZodString>;
94
+ durationMs: z.ZodOptional<z.ZodNumber>;
95
+ poster: z.ZodOptional<z.ZodString>;
94
96
  scope: z.ZodOptional<z.ZodObject<{
95
97
  surface: z.ZodOptional<z.ZodString>;
96
98
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -144,6 +146,8 @@ export declare const EvidenceSummarySchema: z.ZodObject<{
144
146
  recording: z.ZodOptional<z.ZodString>;
145
147
  available: z.ZodBoolean;
146
148
  reason: z.ZodOptional<z.ZodString>;
149
+ durationMs: z.ZodOptional<z.ZodNumber>;
150
+ poster: z.ZodOptional<z.ZodString>;
147
151
  scope: z.ZodOptional<z.ZodObject<{
148
152
  surface: z.ZodOptional<z.ZodString>;
149
153
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -221,6 +225,8 @@ export declare const LayerHeaderSchema: z.ZodObject<{
221
225
  recording: z.ZodOptional<z.ZodString>;
222
226
  available: z.ZodBoolean;
223
227
  reason: z.ZodOptional<z.ZodString>;
228
+ durationMs: z.ZodOptional<z.ZodNumber>;
229
+ poster: z.ZodOptional<z.ZodString>;
224
230
  scope: z.ZodOptional<z.ZodObject<{
225
231
  surface: z.ZodOptional<z.ZodString>;
226
232
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -309,6 +315,8 @@ export declare const LayerRowSchema: z.ZodObject<{
309
315
  recording: z.ZodOptional<z.ZodString>;
310
316
  available: z.ZodBoolean;
311
317
  reason: z.ZodOptional<z.ZodString>;
318
+ durationMs: z.ZodOptional<z.ZodNumber>;
319
+ poster: z.ZodOptional<z.ZodString>;
312
320
  scope: z.ZodOptional<z.ZodObject<{
313
321
  surface: z.ZodOptional<z.ZodString>;
314
322
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -415,6 +423,8 @@ export declare const LayerRowsSchema: z.ZodObject<{
415
423
  recording: z.ZodOptional<z.ZodString>;
416
424
  available: z.ZodBoolean;
417
425
  reason: z.ZodOptional<z.ZodString>;
426
+ durationMs: z.ZodOptional<z.ZodNumber>;
427
+ poster: z.ZodOptional<z.ZodString>;
418
428
  scope: z.ZodOptional<z.ZodObject<{
419
429
  surface: z.ZodOptional<z.ZodString>;
420
430
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -523,6 +533,8 @@ export declare const LayerEvidenceSchema: z.ZodObject<{
523
533
  recording: z.ZodOptional<z.ZodString>;
524
534
  available: z.ZodBoolean;
525
535
  reason: z.ZodOptional<z.ZodString>;
536
+ durationMs: z.ZodOptional<z.ZodNumber>;
537
+ poster: z.ZodOptional<z.ZodString>;
526
538
  scope: z.ZodOptional<z.ZodObject<{
527
539
  surface: z.ZodOptional<z.ZodString>;
528
540
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -625,6 +637,8 @@ export declare const LayerEvidenceSchema: z.ZodObject<{
625
637
  recording: z.ZodOptional<z.ZodString>;
626
638
  available: z.ZodBoolean;
627
639
  reason: z.ZodOptional<z.ZodString>;
640
+ durationMs: z.ZodOptional<z.ZodNumber>;
641
+ poster: z.ZodOptional<z.ZodString>;
628
642
  scope: z.ZodOptional<z.ZodObject<{
629
643
  surface: z.ZodOptional<z.ZodString>;
630
644
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -722,6 +736,8 @@ export declare const EffectiveEvidenceSchema: z.ZodObject<{
722
736
  recording: z.ZodOptional<z.ZodString>;
723
737
  available: z.ZodBoolean;
724
738
  reason: z.ZodOptional<z.ZodString>;
739
+ durationMs: z.ZodOptional<z.ZodNumber>;
740
+ poster: z.ZodOptional<z.ZodString>;
725
741
  scope: z.ZodOptional<z.ZodObject<{
726
742
  surface: z.ZodOptional<z.ZodString>;
727
743
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -824,6 +840,8 @@ export declare const EffectiveEvidenceSchema: z.ZodObject<{
824
840
  recording: z.ZodOptional<z.ZodString>;
825
841
  available: z.ZodBoolean;
826
842
  reason: z.ZodOptional<z.ZodString>;
843
+ durationMs: z.ZodOptional<z.ZodNumber>;
844
+ poster: z.ZodOptional<z.ZodString>;
827
845
  scope: z.ZodOptional<z.ZodObject<{
828
846
  surface: z.ZodOptional<z.ZodString>;
829
847
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -942,6 +960,8 @@ export declare const EffectiveDeltaSchema: z.ZodObject<{
942
960
  recording: z.ZodOptional<z.ZodString>;
943
961
  available: z.ZodBoolean;
944
962
  reason: z.ZodOptional<z.ZodString>;
963
+ durationMs: z.ZodOptional<z.ZodNumber>;
964
+ poster: z.ZodOptional<z.ZodString>;
945
965
  scope: z.ZodOptional<z.ZodObject<{
946
966
  surface: z.ZodOptional<z.ZodString>;
947
967
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -1044,6 +1064,8 @@ export declare const EffectiveDeltaSchema: z.ZodObject<{
1044
1064
  recording: z.ZodOptional<z.ZodString>;
1045
1065
  available: z.ZodBoolean;
1046
1066
  reason: z.ZodOptional<z.ZodString>;
1067
+ durationMs: z.ZodOptional<z.ZodNumber>;
1068
+ poster: z.ZodOptional<z.ZodString>;
1047
1069
  scope: z.ZodOptional<z.ZodObject<{
1048
1070
  surface: z.ZodOptional<z.ZodString>;
1049
1071
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -1154,6 +1176,8 @@ export declare const EvidencePageSchema: z.ZodObject<{
1154
1176
  recording: z.ZodOptional<z.ZodString>;
1155
1177
  available: z.ZodBoolean;
1156
1178
  reason: z.ZodOptional<z.ZodString>;
1179
+ durationMs: z.ZodOptional<z.ZodNumber>;
1180
+ poster: z.ZodOptional<z.ZodString>;
1157
1181
  scope: z.ZodOptional<z.ZodObject<{
1158
1182
  surface: z.ZodOptional<z.ZodString>;
1159
1183
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -1655,6 +1679,61 @@ export declare const ArtifactTagsSchema: z.ZodObject<{
1655
1679
  atMs: z.ZodNumber;
1656
1680
  }, z.core.$loose>>;
1657
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>;
1658
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. */
1659
1738
  export declare const ReviewSourceSchema: z.ZodObject<{
1660
1739
  layer: z.ZodString;
@@ -1735,6 +1814,8 @@ export declare const ReviewSnapshotSchema: z.ZodObject<{
1735
1814
  recording: z.ZodOptional<z.ZodString>;
1736
1815
  available: z.ZodBoolean;
1737
1816
  reason: z.ZodOptional<z.ZodString>;
1817
+ durationMs: z.ZodOptional<z.ZodNumber>;
1818
+ poster: z.ZodOptional<z.ZodString>;
1738
1819
  scope: z.ZodOptional<z.ZodObject<{
1739
1820
  surface: z.ZodOptional<z.ZodString>;
1740
1821
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -1799,6 +1880,8 @@ export declare const ReviewEntrySchema: z.ZodObject<{
1799
1880
  recording: z.ZodOptional<z.ZodString>;
1800
1881
  available: z.ZodBoolean;
1801
1882
  reason: z.ZodOptional<z.ZodString>;
1883
+ durationMs: z.ZodOptional<z.ZodNumber>;
1884
+ poster: z.ZodOptional<z.ZodString>;
1802
1885
  scope: z.ZodOptional<z.ZodObject<{
1803
1886
  surface: z.ZodOptional<z.ZodString>;
1804
1887
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -1882,6 +1965,8 @@ export declare const ReviewCollectionSchema: z.ZodObject<{
1882
1965
  recording: z.ZodOptional<z.ZodString>;
1883
1966
  available: z.ZodBoolean;
1884
1967
  reason: z.ZodOptional<z.ZodString>;
1968
+ durationMs: z.ZodOptional<z.ZodNumber>;
1969
+ poster: z.ZodOptional<z.ZodString>;
1885
1970
  scope: z.ZodOptional<z.ZodObject<{
1886
1971
  surface: z.ZodOptional<z.ZodString>;
1887
1972
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -2042,6 +2127,8 @@ export declare const ReviewCollectionDetailSchema: z.ZodObject<{
2042
2127
  recording: z.ZodOptional<z.ZodString>;
2043
2128
  available: z.ZodBoolean;
2044
2129
  reason: z.ZodOptional<z.ZodString>;
2130
+ durationMs: z.ZodOptional<z.ZodNumber>;
2131
+ poster: z.ZodOptional<z.ZodString>;
2045
2132
  scope: z.ZodOptional<z.ZodObject<{
2046
2133
  surface: z.ZodOptional<z.ZodString>;
2047
2134
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -2174,6 +2261,8 @@ export declare const ReviewManifestSchema: z.ZodObject<{
2174
2261
  recording: z.ZodOptional<z.ZodString>;
2175
2262
  available: z.ZodBoolean;
2176
2263
  reason: z.ZodOptional<z.ZodString>;
2264
+ durationMs: z.ZodOptional<z.ZodNumber>;
2265
+ poster: z.ZodOptional<z.ZodString>;
2177
2266
  scope: z.ZodOptional<z.ZodObject<{
2178
2267
  surface: z.ZodOptional<z.ZodString>;
2179
2268
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -2303,6 +2392,8 @@ export declare const TestResultEntrySchema: z.ZodObject<{
2303
2392
  recording: z.ZodOptional<z.ZodString>;
2304
2393
  available: z.ZodBoolean;
2305
2394
  reason: z.ZodOptional<z.ZodString>;
2395
+ durationMs: z.ZodOptional<z.ZodNumber>;
2396
+ poster: z.ZodOptional<z.ZodString>;
2306
2397
  scope: z.ZodOptional<z.ZodObject<{
2307
2398
  surface: z.ZodOptional<z.ZodString>;
2308
2399
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -2414,6 +2505,8 @@ export declare const TestSummarySchema: z.ZodObject<{
2414
2505
  recording: z.ZodOptional<z.ZodString>;
2415
2506
  available: z.ZodBoolean;
2416
2507
  reason: z.ZodOptional<z.ZodString>;
2508
+ durationMs: z.ZodOptional<z.ZodNumber>;
2509
+ poster: z.ZodOptional<z.ZodString>;
2417
2510
  scope: z.ZodOptional<z.ZodObject<{
2418
2511
  surface: z.ZodOptional<z.ZodString>;
2419
2512
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -2721,6 +2814,8 @@ export declare const TestCatalogSchema: z.ZodObject<{
2721
2814
  recording: z.ZodOptional<z.ZodString>;
2722
2815
  available: z.ZodBoolean;
2723
2816
  reason: z.ZodOptional<z.ZodString>;
2817
+ durationMs: z.ZodOptional<z.ZodNumber>;
2818
+ poster: z.ZodOptional<z.ZodString>;
2724
2819
  scope: z.ZodOptional<z.ZodObject<{
2725
2820
  surface: z.ZodOptional<z.ZodString>;
2726
2821
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -2960,6 +3055,8 @@ export declare const TestHistorySchema: z.ZodObject<{
2960
3055
  recording: z.ZodOptional<z.ZodString>;
2961
3056
  available: z.ZodBoolean;
2962
3057
  reason: z.ZodOptional<z.ZodString>;
3058
+ durationMs: z.ZodOptional<z.ZodNumber>;
3059
+ poster: z.ZodOptional<z.ZodString>;
2963
3060
  scope: z.ZodOptional<z.ZodObject<{
2964
3061
  surface: z.ZodOptional<z.ZodString>;
2965
3062
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -3089,6 +3186,8 @@ export declare const TestHistorySchema: z.ZodObject<{
3089
3186
  recording: z.ZodOptional<z.ZodString>;
3090
3187
  available: z.ZodBoolean;
3091
3188
  reason: z.ZodOptional<z.ZodString>;
3189
+ durationMs: z.ZodOptional<z.ZodNumber>;
3190
+ poster: z.ZodOptional<z.ZodString>;
3092
3191
  scope: z.ZodOptional<z.ZodObject<{
3093
3192
  surface: z.ZodOptional<z.ZodString>;
3094
3193
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -3497,6 +3596,8 @@ export declare const ArtifactResultSchema: z.ZodObject<{
3497
3596
  recording: z.ZodOptional<z.ZodString>;
3498
3597
  available: z.ZodBoolean;
3499
3598
  reason: z.ZodOptional<z.ZodString>;
3599
+ durationMs: z.ZodOptional<z.ZodNumber>;
3600
+ poster: z.ZodOptional<z.ZodString>;
3500
3601
  scope: z.ZodOptional<z.ZodObject<{
3501
3602
  surface: z.ZodOptional<z.ZodString>;
3502
3603
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -3568,6 +3669,8 @@ export declare const ArtifactListSchema: z.ZodObject<{
3568
3669
  recording: z.ZodOptional<z.ZodString>;
3569
3670
  available: z.ZodBoolean;
3570
3671
  reason: z.ZodOptional<z.ZodString>;
3672
+ durationMs: z.ZodOptional<z.ZodNumber>;
3673
+ poster: z.ZodOptional<z.ZodString>;
3571
3674
  scope: z.ZodOptional<z.ZodObject<{
3572
3675
  surface: z.ZodOptional<z.ZodString>;
3573
3676
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -3722,6 +3825,8 @@ export declare const RunDetailSchema: z.ZodObject<{
3722
3825
  recording: z.ZodOptional<z.ZodString>;
3723
3826
  available: z.ZodBoolean;
3724
3827
  reason: z.ZodOptional<z.ZodString>;
3828
+ durationMs: z.ZodOptional<z.ZodNumber>;
3829
+ poster: z.ZodOptional<z.ZodString>;
3725
3830
  scope: z.ZodOptional<z.ZodObject<{
3726
3831
  surface: z.ZodOptional<z.ZodString>;
3727
3832
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -72,6 +72,8 @@ export const VideoRefSchema = z.looseObject({
72
72
  recording: z.string().optional().describe('What identifies the bytes; references with the same value are one recording. Absent when it cannot be played'),
73
73
  available: z.boolean().describe('False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer\'s own bundle does not hold'),
74
74
  reason: z.string().optional().describe('Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable'),
75
+ durationMs: z.number().nonnegative().optional().describe('How long the recording is, in milliseconds, as its producer declared it: a hint to show before the file is opened, never a latency. Absent when none was declared, and in documents of older servers'),
76
+ poster: z.string().optional().describe('The bundle key, in the same layer, of the image the producer named as this recording\'s poster: a hint to show before the file is opened. Absent when there is none or the layer\'s bundle does not hold it, and in documents of older servers'),
75
77
  scope: EvidenceScopeSchema.optional().describe('What this recording shows as its producer declared it: its own `scope`, else its attempt\'s, check\'s, part\'s or layer\'s. Absent: the scope is unknown, never live, physical or audible')
76
78
  });
77
79
  /** Video references as counts: how many are referenced, how many distinct recordings can be played, how many cannot. */
@@ -252,6 +254,39 @@ export const ArtifactTagsSchema = z.looseObject({
252
254
  schemaVersion: z.literal(1),
253
255
  entries: z.array(ArtifactTagEntrySchema)
254
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
+ });
255
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. */
256
291
  export const ReviewSourceSchema = z.looseObject({
257
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
+ }