@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.
- package/CHANGELOG.md +14 -1
- package/dist/reports/src/evidence-detail.js +13 -3
- package/dist/reports/src/evidence-types.d.ts +4 -0
- package/dist/schema/src/api.d.ts +105 -0
- package/dist/schema/src/api.js +35 -0
- package/dist/server/src/app.js +3 -1
- package/dist/server/src/openapi.d.ts +1 -1
- package/dist/server/src/openapi.js +24 -2
- package/dist/server/src/recording-verification.d.ts +34 -0
- package/dist/server/src/recording-verification.js +118 -0
- package/docs/reference/http-api.md +52 -0
- package/docs/reference/reports.md +4 -0
- package/docs/reference/schema.md +41 -0
- package/docs/reference/server.md +39 -0
- package/docs/rich-evidence.md +13 -0
- package/docs/server.md +12 -0
- package/docs/ui.md +40 -1
- package/llms-full.txt +117 -1
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +18 -0
- package/packages/schema/schemas/effective-evidence.schema.json +18 -0
- package/packages/schema/schemas/evidence-page.schema.json +9 -0
- package/packages/schema/schemas/layer-rows.schema.json +9 -0
- package/packages/schema/schemas/report-run.schema.json +9 -0
- package/packages/schema/schemas/review-collection.schema.json +9 -0
- package/packages/schema/schemas/review-manifest.schema.json +9 -0
- package/packages/schema/schemas/test-catalog.schema.json +9 -0
- package/packages/schema/schemas/test-history.schema.json +18 -0
- package/packages/server/openapi.json +653 -1
- package/packages/visualizer/src/app/AppShell.tsx +2 -2
- package/packages/visualizer/src/app/router.tsx +25 -1
- package/packages/visualizer/src/components/Header.tsx +15 -4
- package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
- package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
- package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
- package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
- package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
- package/packages/visualizer/src/evidence/behavior.ts +32 -1
- package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
- package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
- package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
- package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
- package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
- package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +4 -4
- package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
- package/packages/visualizer/src/explorer/RunsView.tsx +36 -10
- package/packages/visualizer/src/explorer/catalog.tsx +24 -1
- package/packages/visualizer/src/explorer/collections.tsx +5 -5
- package/packages/visualizer/src/explorer/navigate.ts +17 -0
- package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
- package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
- package/packages/visualizer/src/explorer/playlist.tsx +43 -8
- package/packages/visualizer/src/explorer/recording-verification.ts +58 -0
- package/packages/visualizer/src/explorer/search.ts +7 -1
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/schema/src/api.d.ts
CHANGED
|
@@ -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>;
|
package/dist/schema/src/api.js
CHANGED
|
@@ -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'),
|
package/dist/server/src/app.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|