@crvouga/atlas 1.10.0 → 1.12.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 (51) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/dist/reports/src/review-collection-store.d.ts +47 -0
  3. package/dist/reports/src/review-collection-store.js +214 -0
  4. package/dist/reports/src/review-collection.d.ts +88 -0
  5. package/dist/reports/src/review-collection.js +144 -0
  6. package/dist/reports/src/test-catalog.d.ts +2 -0
  7. package/dist/reports/src/test-catalog.js +1 -1
  8. package/dist/schema/src/api.d.ts +574 -0
  9. package/dist/schema/src/api.js +152 -1
  10. package/dist/server/src/app.js +4 -1
  11. package/dist/server/src/openapi.js +46 -1
  12. package/dist/server/src/review-collections.d.ts +39 -0
  13. package/dist/server/src/review-collections.js +179 -0
  14. package/docs/extending.md +8 -1
  15. package/docs/reference/http-api.md +122 -0
  16. package/docs/reference/reports.md +294 -0
  17. package/docs/reference/schema.md +131 -0
  18. package/docs/reference/server.md +44 -0
  19. package/docs/rich-evidence.md +77 -0
  20. package/docs/server.md +41 -0
  21. package/docs/ui.md +67 -0
  22. package/llms-full.txt +315 -1
  23. package/package.json +1 -1
  24. package/packages/schema/schemas/review-collection.schema.json +398 -0
  25. package/packages/schema/schemas/review-manifest.schema.json +475 -0
  26. package/packages/server/openapi.json +6729 -4084
  27. package/packages/visualizer/src/app/router.tsx +9 -1
  28. package/packages/visualizer/src/evidence/applicability-view.tsx +233 -0
  29. package/packages/visualizer/src/evidence/applicability.test.ts +131 -0
  30. package/packages/visualizer/src/evidence/applicability.ts +241 -0
  31. package/packages/visualizer/src/evidence/artifact-tags.tsx +2 -1
  32. package/packages/visualizer/src/evidence/artifacts.tsx +88 -42
  33. package/packages/visualizer/src/evidence/behavior-panel.tsx +55 -26
  34. package/packages/visualizer/src/evidence/feature-page.tsx +25 -4
  35. package/packages/visualizer/src/evidence/media-timing.test.ts +85 -0
  36. package/packages/visualizer/src/evidence/media-timing.ts +150 -0
  37. package/packages/visualizer/src/evidence/moments.tsx +457 -0
  38. package/packages/visualizer/src/evidence/outcome-summary.tsx +27 -0
  39. package/packages/visualizer/src/evidence/outcome.ts +7 -2
  40. package/packages/visualizer/src/explorer/ArtifactsView.tsx +19 -5
  41. package/packages/visualizer/src/explorer/Explorer.tsx +7 -2
  42. package/packages/visualizer/src/explorer/RecordingTextSearch.tsx +355 -0
  43. package/packages/visualizer/src/explorer/TestDetail.tsx +2 -2
  44. package/packages/visualizer/src/explorer/collection-model.test.ts +31 -0
  45. package/packages/visualizer/src/explorer/collection-model.ts +45 -0
  46. package/packages/visualizer/src/explorer/collections.tsx +513 -0
  47. package/packages/visualizer/src/explorer/playlist-entries.ts +29 -0
  48. package/packages/visualizer/src/explorer/playlist.tsx +164 -37
  49. package/packages/visualizer/src/explorer/recording-text.test.ts +149 -0
  50. package/packages/visualizer/src/explorer/recording-text.ts +490 -0
  51. package/packages/visualizer/src/explorer/search.ts +17 -3
package/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.10.0 (2026-10-29)
3
+ ## 1.12.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - seek recordings from scenario steps and share a moment ([6071cd9](https://github.com/crvouga/atlas/commit/6071cd90e8ece66deb384b037a25ee51381283ed))
8
+
9
+ ## 1.11.0 (2026-10-28)
10
+
11
+ ### Features
12
+
13
+ - publish immutable review collections with videos, notes and gaps ([00ef474](https://github.com/crvouga/atlas/commit/00ef4741cecd70be1dc06ae3d7f690717e25cd6d))
14
+ - search recorded speech and screen text with timestamped evidence matches ([a5874d8](https://github.com/crvouga/atlas/commit/a5874d87d4cfe0fdfa7996b5e06fc613940a1211))
15
+ - show whether recorded evidence applies to the selected behavior and release ([550435d](https://github.com/crvouga/atlas/commit/550435dff15524866912076d69889749a6a9dae3))
16
+
17
+ ## 1.10.0 (2026-10-28)
4
18
 
5
19
  ### Features
6
20
 
@@ -0,0 +1,47 @@
1
+ import type { ObjectStore } from './object-store.js';
2
+ import { type ReviewCollection, type ReviewNote } from './review-collection.js';
3
+ import type { ArtifactResult } from './test-catalog.js';
4
+ /** The request was not acceptable. `status` is the HTTP status to answer: 404 (no such collection), 409 (not the newest revision) or 422. */
5
+ export declare class ReviewCollectionError extends Error {
6
+ readonly status: 404 | 409 | 422;
7
+ constructor(status: 404 | 409 | 422, message: string);
8
+ }
9
+ /** Finds the recorded results of a layer's check (all kinds when `kind` is undefined). */
10
+ export type ResultFinder = (layer: string, check: string, kind: string | undefined) => readonly ArtifactResult[];
11
+ /**
12
+ * The revisions that exist for a collection id, oldest first. Empty when there is none. Transport errors throw.
13
+ */
14
+ export declare function reviewRevisions(store: ObjectStore, id: string): Promise<number[]>;
15
+ /**
16
+ * One revision of a collection (the newest when `revision` is absent), or `null` when there is none or it cannot be read
17
+ * as a collection (unknown, never guessed at). Transport errors throw.
18
+ */
19
+ export declare function readReviewCollection(store: ObjectStore, id: string, revision?: number): Promise<{
20
+ readonly collection: ReviewCollection;
21
+ readonly revisions: number[];
22
+ } | null>;
23
+ /**
24
+ * Validates a request (`ReviewCollectionRequestSchema`) and records it as a new immutable revision, by `by` at `atMs`.
25
+ * Without `basedOn` it is a new collection with a new id; with it, revision `basedOn.revision + 1` of that collection,
26
+ * and `basedOn.revision` must be the newest (409 otherwise) so two curators never silently overwrite each other.
27
+ *
28
+ * Every entry is resolved with `find`: it must name a result the report holds (and, when it names a recording, one of
29
+ * that result's recordings) or the whole request is refused with 422 naming the entry. The snapshot recorded for an entry
30
+ * is what the report says about that result, not what the request claims. Entries are kept in the order given and a
31
+ * source may appear once. Throws {@link ReviewCollectionError}; a revision that already exists is never overwritten.
32
+ */
33
+ export declare function recordReviewCollection(store: ObjectStore, input: unknown, by: string, atMs: number, find: ResultFinder): Promise<ReviewCollection>;
34
+ /** The newest revision of every collection in `store`, newest first. One that cannot be read as a collection is skipped. Transport errors throw. */
35
+ export declare function listReviewCollections(store: ObjectStore): Promise<{
36
+ readonly collection: ReviewCollection;
37
+ readonly revisions: number;
38
+ }[]>;
39
+ /**
40
+ * Validates a note (`ReviewNoteInputSchema`) against the revision it is about and records it as a new object, by `by` at
41
+ * `atMs`. `note` and `gap` need text; `reviewed` and `unreviewed` need an entry; an entry must be one of the revision's;
42
+ * a timecode needs an entry. Throws {@link ReviewCollectionError} (404 for no such collection or revision). It records
43
+ * curation only: no layer, result or collection revision changes.
44
+ */
45
+ export declare function recordReviewNote(store: ObjectStore, input: unknown, by: string, atMs: number): Promise<ReviewNote>;
46
+ /** Every recorded note of a collection (all revisions), oldest first. One that cannot be read as a note is skipped. Transport errors throw. */
47
+ export declare function readReviewNotes(store: ObjectStore, id: string): Promise<ReviewNote[]>;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @module Review collections in a store
3
+ *
4
+ * Where review collections are kept: one immutable object per revision under `curation/review-collections/<id>/`, and
5
+ * one small immutable object per reviewer's note under `curation/review-notes/<id>/`, named by time and a random id so
6
+ * editors never overwrite one another. Nothing here is ever changed or deleted by the server, and nothing here touches
7
+ * the layers the entries point at: a collection is curation beside the report, not a layer and not a test run.
8
+ *
9
+ * Publishing refuses; it never alters. A request the server cannot resolve to exact, held results is refused with
10
+ * the entry that failed, and what is recorded is what was asked for, plus what the report said about each source.
11
+ *
12
+ * @see docs/server.md#review-collections
13
+ */
14
+ import { ReviewCollectionRequestSchema, ReviewCollectionSchema, ReviewNoteInputSchema, ReviewNoteSchema } from '../../schema/src/api.js';
15
+ import { normalizeTag, normalizeTags } from './artifact-tags.js';
16
+ import { readUtf8, writeUtf8 } from './object-io.js';
17
+ import { REVIEW_COLLECTIONS_PREFIX, REVIEW_ID, REVIEW_NOTES_PREFIX, reviewCollectionKey, sameSource, snapshotOf } from './review-collection.js';
18
+ /** The request was not acceptable. `status` is the HTTP status to answer: 404 (no such collection), 409 (not the newest revision) or 422. */
19
+ export class ReviewCollectionError extends Error {
20
+ status;
21
+ constructor(status, message) {
22
+ super(message);
23
+ this.status = status;
24
+ }
25
+ }
26
+ const KEY = /^curation\/review-collections\/([a-z0-9][a-z0-9-]{7,39})\/r(\d{6})\.json$/;
27
+ const first = (error) => `${error.issues[0]?.path.join('.') || '(body)'}: ${error.issues[0]?.message ?? 'invalid'}`;
28
+ /** A fresh collection id: `rc-` and 16 random hex digits. */
29
+ function newId() {
30
+ return `rc-${globalThis.crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`;
31
+ }
32
+ /**
33
+ * The revisions that exist for a collection id, oldest first. Empty when there is none. Transport errors throw.
34
+ */
35
+ export async function reviewRevisions(store, id) {
36
+ if (!REVIEW_ID.test(id))
37
+ return [];
38
+ const keys = await store.listKeys(`${REVIEW_COLLECTIONS_PREFIX}${id}/`);
39
+ return keys.flatMap((key) => {
40
+ const match = KEY.exec(key);
41
+ return match ? [Number(match[2])] : [];
42
+ }).sort((a, b) => a - b);
43
+ }
44
+ /**
45
+ * One revision of a collection (the newest when `revision` is absent), or `null` when there is none or it cannot be read
46
+ * as a collection (unknown, never guessed at). Transport errors throw.
47
+ */
48
+ export async function readReviewCollection(store, id, revision) {
49
+ const revisions = await reviewRevisions(store, id);
50
+ const wanted = revision ?? revisions.at(-1);
51
+ if (wanted === undefined || !revisions.includes(wanted))
52
+ return null;
53
+ const text = await readUtf8(store, reviewCollectionKey(id, wanted));
54
+ if (text === null)
55
+ return null;
56
+ try {
57
+ const parsed = ReviewCollectionSchema.safeParse(JSON.parse(text));
58
+ return parsed.success ? { collection: parsed.data, revisions } : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * Validates a request (`ReviewCollectionRequestSchema`) and records it as a new immutable revision, by `by` at `atMs`.
66
+ * Without `basedOn` it is a new collection with a new id; with it, revision `basedOn.revision + 1` of that collection,
67
+ * and `basedOn.revision` must be the newest (409 otherwise) so two curators never silently overwrite each other.
68
+ *
69
+ * Every entry is resolved with `find`: it must name a result the report holds (and, when it names a recording, one of
70
+ * that result's recordings) or the whole request is refused with 422 naming the entry. The snapshot recorded for an entry
71
+ * is what the report says about that result, not what the request claims. Entries are kept in the order given and a
72
+ * source may appear once. Throws {@link ReviewCollectionError}; a revision that already exists is never overwritten.
73
+ */
74
+ export async function recordReviewCollection(store, input, by, atMs, find) {
75
+ const parsed = ReviewCollectionRequestSchema.safeParse(input);
76
+ if (!parsed.success)
77
+ throw new ReviewCollectionError(422, `Not a review collection: ${first(parsed.error)}`);
78
+ const request = parsed.data;
79
+ const given = request.entries ?? [];
80
+ const gapsGiven = request.gaps ?? [];
81
+ if (given.length === 0 && gapsGiven.length === 0)
82
+ throw new ReviewCollectionError(422, 'Name at least one entry or one gap.');
83
+ const tagInputs = request.tags ?? [];
84
+ if (tagInputs.some((tag) => normalizeTag(tag) === null))
85
+ throw new ReviewCollectionError(422, 'A tag is empty or longer than 64 characters.');
86
+ if (gapsGiven.some((gap) => (gap.after ?? 0) > given.length))
87
+ throw new ReviewCollectionError(422, 'A gap is placed after more entries than the collection has.');
88
+ let id;
89
+ let revision = 1;
90
+ if (request.basedOn) {
91
+ id = request.basedOn.id;
92
+ const existing = await reviewRevisions(store, id);
93
+ if (existing.length === 0)
94
+ throw new ReviewCollectionError(404, 'No collection has that id.');
95
+ const newest = existing.at(-1);
96
+ if (request.basedOn.revision !== newest)
97
+ throw new ReviewCollectionError(409, `Revision ${request.basedOn.revision} is not the newest: revision ${newest} exists. Base the new revision on that one.`);
98
+ revision = newest + 1;
99
+ }
100
+ else {
101
+ id = newId();
102
+ }
103
+ const entries = [];
104
+ for (const [index, item] of given.entries()) {
105
+ const label = `Entry ${index + 1}`;
106
+ const found = find(item.layer, item.check, item.kind);
107
+ if (found.length === 0)
108
+ throw new ReviewCollectionError(422, `${label}: the report holds no result "${item.check}" in layer ${item.layer.slice(0, 12)}. Take the layer and check from GET /api/reports/artifacts.`);
109
+ if (found.length > 1)
110
+ throw new ReviewCollectionError(422, `${label}: the check "${item.check}" exists in several parts of that layer (${found.map((result) => result.kind).join(', ')}). Name the kind.`);
111
+ const result = found[0];
112
+ if (item.recording !== undefined && !result.videos?.some((video) => video.available && video.recording === item.recording)) {
113
+ throw new ReviewCollectionError(422, `${label}: that result has no such recording.`);
114
+ }
115
+ const source = { layer: item.layer, check: item.check, kind: result.kind, ...(item.recording === undefined ? {} : { recording: item.recording }) };
116
+ if (entries.some((entry) => sameSource(entry.source, source)))
117
+ throw new ReviewCollectionError(422, `${label}: the same source is already in the collection.`);
118
+ entries.push({ id: `e${index + 1}`, source, ...(item.caption ? { caption: item.caption } : {}), snapshot: snapshotOf(result) });
119
+ }
120
+ const collection = {
121
+ schemaVersion: 1, id, revision, title: request.title.trim(),
122
+ ...(request.purpose?.trim() ? { purpose: request.purpose.trim() } : {}),
123
+ ...(request.audience?.trim() ? { audience: request.audience.trim() } : {}),
124
+ tags: normalizeTags(tagInputs), entries,
125
+ gaps: gapsGiven.map((gap, index) => ({ id: `g${index + 1}`, note: gap.note.trim(), after: gap.after ?? 0 })),
126
+ ...(request.frozenFrom ? { frozenFrom: request.frozenFrom } : {}),
127
+ ...(request.basedOn ? { basedOn: { id: request.basedOn.id, revision: request.basedOn.revision } } : {}),
128
+ createdBy: by, createdAtMs: atMs
129
+ };
130
+ if (!collection.title)
131
+ throw new ReviewCollectionError(422, 'The title is empty.');
132
+ const key = reviewCollectionKey(id, revision);
133
+ // A revision is never overwritten. (A store has no conditional write: two simultaneous requests for the same
134
+ // revision can still race, which is why a new revision must name the newest one it is based on.)
135
+ if (await store.head(key))
136
+ throw new ReviewCollectionError(409, `Revision ${revision} already exists.`);
137
+ await writeUtf8(store, key, JSON.stringify(collection), 'application/json');
138
+ return collection;
139
+ }
140
+ /** The newest revision of every collection in `store`, newest first. One that cannot be read as a collection is skipped. Transport errors throw. */
141
+ export async function listReviewCollections(store) {
142
+ const keys = await store.listKeys(REVIEW_COLLECTIONS_PREFIX);
143
+ const byId = new Map();
144
+ for (const key of keys) {
145
+ const match = KEY.exec(key);
146
+ if (match)
147
+ byId.set(match[1], [...(byId.get(match[1]) ?? []), Number(match[2])]);
148
+ }
149
+ const read = await Promise.all([...byId].map(async ([id, revisions]) => {
150
+ const newest = Math.max(...revisions);
151
+ const text = await readUtf8(store, reviewCollectionKey(id, newest));
152
+ if (text === null)
153
+ return [];
154
+ try {
155
+ const parsed = ReviewCollectionSchema.safeParse(JSON.parse(text));
156
+ return parsed.success ? [{ collection: parsed.data, revisions: revisions.length }] : [];
157
+ }
158
+ catch {
159
+ return [];
160
+ }
161
+ }));
162
+ return read.flat().sort((a, b) => b.collection.createdAtMs - a.collection.createdAtMs || (a.collection.id < b.collection.id ? -1 : 1));
163
+ }
164
+ /**
165
+ * Validates a note (`ReviewNoteInputSchema`) against the revision it is about and records it as a new object, by `by` at
166
+ * `atMs`. `note` and `gap` need text; `reviewed` and `unreviewed` need an entry; an entry must be one of the revision's;
167
+ * a timecode needs an entry. Throws {@link ReviewCollectionError} (404 for no such collection or revision). It records
168
+ * curation only: no layer, result or collection revision changes.
169
+ */
170
+ export async function recordReviewNote(store, input, by, atMs) {
171
+ const parsed = ReviewNoteInputSchema.safeParse(input);
172
+ if (!parsed.success)
173
+ throw new ReviewCollectionError(422, `Not a review note: ${first(parsed.error)}`);
174
+ const { collection: id, revision, entry, kind, text, timecodeMs } = parsed.data;
175
+ if (!REVIEW_ID.test(id))
176
+ throw new ReviewCollectionError(404, 'No collection has that id.');
177
+ const found = await readReviewCollection(store, id, revision);
178
+ if (!found)
179
+ throw new ReviewCollectionError(404, 'No such collection revision.');
180
+ if (entry !== undefined && !found.collection.entries.some((item) => item.id === entry))
181
+ throw new ReviewCollectionError(422, `Entry "${entry}" is not in revision ${revision}.`);
182
+ if ((kind === 'reviewed' || kind === 'unreviewed') && entry === undefined)
183
+ throw new ReviewCollectionError(422, `A "${kind}" mark names the entry.`);
184
+ if ((kind === 'note' || kind === 'gap') && !text?.trim())
185
+ throw new ReviewCollectionError(422, `A ${kind} needs text.`);
186
+ if (timecodeMs !== undefined && entry === undefined)
187
+ throw new ReviewCollectionError(422, 'A timecode names the entry whose recording it is a moment of.');
188
+ const noteId = `${String(atMs).padStart(16, '0')}-${globalThis.crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
189
+ const note = {
190
+ id: noteId, collection: id, revision, ...(entry === undefined ? {} : { entry }), kind,
191
+ ...(text?.trim() ? { text: text.trim() } : {}), ...(timecodeMs === undefined ? {} : { timecodeMs }), by, atMs
192
+ };
193
+ await writeUtf8(store, `${REVIEW_NOTES_PREFIX}${id}/${noteId}.json`, JSON.stringify({ schemaVersion: 1, ...note }), 'application/json');
194
+ return note;
195
+ }
196
+ /** Every recorded note of a collection (all revisions), oldest first. One that cannot be read as a note is skipped. Transport errors throw. */
197
+ export async function readReviewNotes(store, id) {
198
+ if (!REVIEW_ID.test(id))
199
+ return [];
200
+ const keys = (await store.listKeys(`${REVIEW_NOTES_PREFIX}${id}/`)).filter((key) => key.endsWith('.json'));
201
+ const read = await Promise.all(keys.map(async (key) => {
202
+ const text = await readUtf8(store, key);
203
+ if (text === null)
204
+ return [];
205
+ try {
206
+ const parsed = ReviewNoteSchema.safeParse(JSON.parse(text));
207
+ return parsed.success ? [parsed.data] : [];
208
+ }
209
+ catch {
210
+ return [];
211
+ }
212
+ }));
213
+ return read.flat().sort((a, b) => a.atMs - b.atMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
214
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @module Review collections
3
+ *
4
+ * A review collection is a named, curated, ordered set of references to results that other runs recorded: a few
5
+ * recordings from several executions, a note on what each is for, the gaps nobody recorded yet. It is curation, not a
6
+ * test run. It has no status of its own, carries no check, never decides a result or a winner, and never claims its
7
+ * entries are one execution: each entry keeps the layer, run, revision and status it was recorded with. A revision is
8
+ * immutable; changing a collection is publishing a new revision, explicitly. Reviewers' notes and "reviewed" marks live
9
+ * apart, as an append-only list with who and when, and are not outcomes.
10
+ *
11
+ * This module is pure and safe in a browser: how an entry is frozen from the result it points at, how an entry stands
12
+ * now (available, unverified, unavailable), how marks fold, and the portable manifest. The store is
13
+ * `review-collection-store.ts`.
14
+ *
15
+ * @see docs/server.md#review-collections
16
+ */
17
+ import type { z } from 'zod/v4';
18
+ import type { ReviewAvailabilitySchema, ReviewCollectionDetailSchema, ReviewCollectionListSchema, ReviewCollectionSchema, ReviewEntrySchema, ReviewManifestSchema, ReviewNoteSchema, ReviewSnapshotSchema, ReviewSourceSchema } from '../../schema/src/api.js';
19
+ import type { ArtifactResult } from './test-catalog.js';
20
+ /** The exact result (and optionally one recording of it) an entry points at. */
21
+ export type ReviewSource = z.infer<typeof ReviewSourceSchema>;
22
+ /** What was recorded about a source when it was frozen. */
23
+ export type ReviewSnapshot = z.infer<typeof ReviewSnapshotSchema>;
24
+ /** One entry of a collection revision. */
25
+ export type ReviewEntry = z.infer<typeof ReviewEntrySchema>;
26
+ /** One immutable revision of a collection. */
27
+ export type ReviewCollection = z.infer<typeof ReviewCollectionSchema>;
28
+ /** A recorded note, time-coded gap note or reviewed mark. */
29
+ export type ReviewNote = z.infer<typeof ReviewNoteSchema>;
30
+ /** How an entry's source stands now. */
31
+ export type ReviewAvailability = z.infer<typeof ReviewAvailabilitySchema>;
32
+ /** `GET /api/reports/review-collections/detail`: one revision, how its sources stand now, and its notes. */
33
+ export type ReviewCollectionDetail = z.infer<typeof ReviewCollectionDetailSchema>;
34
+ /** `GET /api/reports/review-collections`: the newest revision of every collection. */
35
+ export type ReviewCollectionList = z.infer<typeof ReviewCollectionListSchema>;
36
+ /** The portable index of one revision. */
37
+ export type ReviewManifest = z.infer<typeof ReviewManifestSchema>;
38
+ /** Where the revisions of collections are kept in a store: `<prefix><id>/r<revision, six digits>.json`. */
39
+ export declare const REVIEW_COLLECTIONS_PREFIX = "curation/review-collections/";
40
+ /** Where reviewers' notes are kept: `<prefix><collection id>/<time>-<random>.json`, one object each. */
41
+ export declare const REVIEW_NOTES_PREFIX = "curation/review-notes/";
42
+ /** A collection id as the server mints it and the address carries it. Anything else is not an id (and never reaches a store key). */
43
+ export declare const REVIEW_ID: RegExp;
44
+ /** The store key of one revision of a collection. */
45
+ export declare function reviewCollectionKey(id: string, revision: number): string;
46
+ /** Whether two sources name the same result and recording. `kind` counts only when both have one. */
47
+ export declare function sameSource(a: ReviewSource, b: ReviewSource): boolean;
48
+ /**
49
+ * What is recorded about a result when an entry is frozen: its own status, time, run, commit and videos, as the layer
50
+ * recorded them. Nothing here is the collection's opinion, and none of it is ever updated: a later run does not change it.
51
+ */
52
+ export declare function snapshotOf(result: ArtifactResult): ReviewSnapshot;
53
+ /**
54
+ * How the sources of a collection stand now. `layerHeld` says whether a layer is still in a backend; `results` finds the
55
+ * results the report holds. A layer that is gone makes its entries `unavailable`; a layer that is held while the report
56
+ * no longer lists the result (or the recording) is `unverified`, not available and not gone. Every entry keeps its place
57
+ * and its explanation: nothing is removed or replaced.
58
+ */
59
+ export declare function availabilityOf(collection: Pick<ReviewCollection, 'entries'>, layerHeld: (layer: string) => boolean, results: (layer: string, check: string, kind: string | undefined) => readonly ArtifactResult[]): ReviewAvailability[];
60
+ /**
61
+ * Folds the notes into the reviewed marks that stand now: for each entry the newest `reviewed` or `unreviewed` decides
62
+ * (ties in time go to the later id). Only entries currently `reviewed` are returned, with who and when. A mark is
63
+ * curation: it is not a check outcome and does not mean anyone accepted the behavior.
64
+ */
65
+ export declare function foldReviewed(notes: readonly ReviewNote[]): Record<string, {
66
+ readonly by: string;
67
+ readonly atMs: number;
68
+ }>;
69
+ /**
70
+ * A name that is safe to write to any file system: the last path segment only, letters, digits, dot, dash and
71
+ * underscore (everything else becomes `_`), no leading dot, at most 100 characters, never empty.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * safeFileName('../../etc/pass wd.webm'); // 'pass_wd.webm'
76
+ * ```
77
+ */
78
+ export declare function safeFileName(name: string): string;
79
+ /** The digest in a recording identity (`sha256:<64 hex>`), or null when the identity is a bundle key or address: the bytes' hash is then not known. */
80
+ export declare function digestOf(recording: string | undefined): string | null;
81
+ /**
82
+ * The portable index of one revision: every entry with where its result came from, how it stands now, whether it was
83
+ * marked reviewed, and the files (recordings) it references, each with its recorded digest when there is one and a name
84
+ * that is safe to write. A file whose source cannot be read now is listed as `missing`, with the reason under `missing`
85
+ * of the manifest, never left out. The manifest has no bytes; it is what a packer needs to build an archive that says
86
+ * what it lacks.
87
+ */
88
+ export declare function manifestOf(collection: ReviewCollection, availability: readonly ReviewAvailability[], reviewed: Readonly<Record<string, unknown>>, generatedAtMs: number): ReviewManifest;
@@ -0,0 +1,144 @@
1
+ /** Where the revisions of collections are kept in a store: `<prefix><id>/r<revision, six digits>.json`. */
2
+ export const REVIEW_COLLECTIONS_PREFIX = 'curation/review-collections/';
3
+ /** Where reviewers' notes are kept: `<prefix><collection id>/<time>-<random>.json`, one object each. */
4
+ export const REVIEW_NOTES_PREFIX = 'curation/review-notes/';
5
+ /** A collection id as the server mints it and the address carries it. Anything else is not an id (and never reaches a store key). */
6
+ export const REVIEW_ID = /^[a-z0-9][a-z0-9-]{7,39}$/;
7
+ /** The store key of one revision of a collection. */
8
+ export function reviewCollectionKey(id, revision) {
9
+ return `${REVIEW_COLLECTIONS_PREFIX}${id}/r${String(revision).padStart(6, '0')}.json`;
10
+ }
11
+ /** Whether two sources name the same result and recording. `kind` counts only when both have one. */
12
+ export function sameSource(a, b) {
13
+ return a.layer === b.layer && a.check === b.check && (a.kind ?? '') === (b.kind ?? '') && (a.recording ?? '') === (b.recording ?? '');
14
+ }
15
+ /**
16
+ * What is recorded about a result when an entry is frozen: its own status, time, run, commit and videos, as the layer
17
+ * recorded them. Nothing here is the collection's opinion, and none of it is ever updated: a later run does not change it.
18
+ */
19
+ export function snapshotOf(result) {
20
+ return {
21
+ title: result.title,
22
+ specPath: result.specPath,
23
+ status: result.status,
24
+ recordedAtMs: result.at,
25
+ environment: result.environment,
26
+ producer: result.producer,
27
+ branch: result.branch,
28
+ sha: result.sha,
29
+ run: result.run,
30
+ runUrl: result.runUrl,
31
+ backend: result.backend,
32
+ testKey: result.key,
33
+ ...(result.attemptStatuses ? { attemptStatuses: [...result.attemptStatuses] } : {}),
34
+ ...(result.videos ? { videos: structuredClone(result.videos) } : {})
35
+ };
36
+ }
37
+ /**
38
+ * How the sources of a collection stand now. `layerHeld` says whether a layer is still in a backend; `results` finds the
39
+ * results the report holds. A layer that is gone makes its entries `unavailable`; a layer that is held while the report
40
+ * no longer lists the result (or the recording) is `unverified`, not available and not gone. Every entry keeps its place
41
+ * and its explanation: nothing is removed or replaced.
42
+ */
43
+ export function availabilityOf(collection, layerHeld, results) {
44
+ return collection.entries.map((entry) => {
45
+ const { source } = entry;
46
+ if (!layerHeld(source.layer)) {
47
+ return { entry: entry.id, state: 'unavailable', reason: `The layer ${source.layer.slice(0, 12)} that recorded this is no longer in any backend (it may have been deleted by garbage collection). The entry is kept as it was frozen.` };
48
+ }
49
+ const found = results(source.layer, source.check, source.kind);
50
+ if (found.length === 0) {
51
+ return { entry: entry.id, state: 'unverified', reason: 'The layer is held, but the report no longer lists this result (it is outside the history the report keeps), so it cannot be checked now.' };
52
+ }
53
+ if (source.recording !== undefined && !found.some((result) => result.videos?.some((video) => video.available && video.recording === source.recording))) {
54
+ return { entry: entry.id, state: 'unverified', reason: 'The result is listed, but the recording this entry points at is not among its recordings any more.' };
55
+ }
56
+ return { entry: entry.id, state: 'available' };
57
+ });
58
+ }
59
+ /**
60
+ * Folds the notes into the reviewed marks that stand now: for each entry the newest `reviewed` or `unreviewed` decides
61
+ * (ties in time go to the later id). Only entries currently `reviewed` are returned, with who and when. A mark is
62
+ * curation: it is not a check outcome and does not mean anyone accepted the behavior.
63
+ */
64
+ export function foldReviewed(notes) {
65
+ const latest = new Map();
66
+ const ordered = [...notes].sort((a, b) => a.atMs - b.atMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
67
+ for (const note of ordered)
68
+ if (note.entry !== undefined && (note.kind === 'reviewed' || note.kind === 'unreviewed'))
69
+ latest.set(note.entry, note);
70
+ const out = {};
71
+ for (const [entry, note] of latest)
72
+ if (note.kind === 'reviewed')
73
+ out[entry] = { by: note.by, atMs: note.atMs };
74
+ return out;
75
+ }
76
+ /**
77
+ * A name that is safe to write to any file system: the last path segment only, letters, digits, dot, dash and
78
+ * underscore (everything else becomes `_`), no leading dot, at most 100 characters, never empty.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * safeFileName('../../etc/pass wd.webm'); // 'pass_wd.webm'
83
+ * ```
84
+ */
85
+ export function safeFileName(name) {
86
+ const last = name.normalize('NFC').split(/[\\/]/).filter(Boolean).at(-1) ?? '';
87
+ const cleaned = last.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '').slice(0, 100);
88
+ return cleaned.length > 0 ? cleaned : 'file';
89
+ }
90
+ /** The digest in a recording identity (`sha256:<64 hex>`), or null when the identity is a bundle key or address: the bytes' hash is then not known. */
91
+ export function digestOf(recording) {
92
+ const match = recording === undefined ? null : /^sha256:([0-9a-f]{64})$/.exec(recording);
93
+ return match ? match[1] : null;
94
+ }
95
+ /**
96
+ * The portable index of one revision: every entry with where its result came from, how it stands now, whether it was
97
+ * marked reviewed, and the files (recordings) it references, each with its recorded digest when there is one and a name
98
+ * that is safe to write. A file whose source cannot be read now is listed as `missing`, with the reason under `missing`
99
+ * of the manifest, never left out. The manifest has no bytes; it is what a packer needs to build an archive that says
100
+ * what it lacks.
101
+ */
102
+ export function manifestOf(collection, availability, reviewed, generatedAtMs) {
103
+ const states = new Map(availability.map((item) => [item.entry, item]));
104
+ const missing = [];
105
+ const entries = collection.entries.map((entry, index) => {
106
+ const position = index + 1;
107
+ const state = states.get(entry.id) ?? { entry: entry.id, state: 'unverified', reason: 'Not checked.' };
108
+ const wanted = (entry.snapshot.videos ?? []).filter((video) => entry.source.recording === undefined || video.recording === entry.source.recording);
109
+ const seen = new Set();
110
+ const files = wanted.flatMap((video) => {
111
+ const identity = video.recording ?? `unavailable:${video.name}`;
112
+ if (seen.has(identity))
113
+ return [];
114
+ seen.add(identity);
115
+ const safeName = safeFileName(video.name);
116
+ const gone = state.state === 'unavailable' || !video.available;
117
+ return [{
118
+ name: video.name, safeName, path: `files/${String(position).padStart(3, '0')}-${safeName}`,
119
+ ...(video.recording === undefined ? {} : { recording: video.recording }),
120
+ sha256: digestOf(video.recording), missing: gone
121
+ }];
122
+ });
123
+ if (state.state === 'unavailable')
124
+ missing.push({ entry: entry.id, reason: state.reason ?? 'The source is gone.' });
125
+ else
126
+ for (const file of files)
127
+ if (file.missing)
128
+ missing.push({ entry: entry.id, reason: `${file.name}: the recording names no bytes.` });
129
+ return {
130
+ id: entry.id, position, ...(entry.caption === undefined ? {} : { caption: entry.caption }), source: entry.source, snapshot: entry.snapshot,
131
+ state: state.state, ...(state.reason === undefined ? {} : { reason: state.reason }), reviewed: entry.id in reviewed, files
132
+ };
133
+ });
134
+ return {
135
+ schemaVersion: 1, generatedAtMs,
136
+ collection: {
137
+ id: collection.id, revision: collection.revision, title: collection.title,
138
+ ...(collection.purpose === undefined ? {} : { purpose: collection.purpose }), ...(collection.audience === undefined ? {} : { audience: collection.audience }),
139
+ tags: collection.tags, createdBy: collection.createdBy, createdAtMs: collection.createdAtMs
140
+ },
141
+ entries, gaps: collection.gaps, missing,
142
+ notes: 'A review collection is curation. Its entries come from the results named in each source, possibly from several runs; they are not one execution. Statuses are those the layers recorded. A reviewed mark is not an outcome and not an approval. This manifest lists files and holds no bytes; a file with a null digest has no recorded hash and is unverified.'
143
+ };
144
+ }
@@ -390,6 +390,8 @@ export type ArtifactResult = {
390
390
  readonly types: readonly string[];
391
391
  readonly labels: Readonly<Record<string, string>>;
392
392
  readonly videos?: readonly VideoRef[];
393
+ /** The status of each recorded try in order, when the check was retried. Absent when no try was recorded: a first try is then unknown, never assumed to have passed. */
394
+ readonly attemptStatuses?: readonly EvidenceStatus[];
393
395
  };
394
396
  /** `GET /api/reports/artifacts`: the recorded results that carry evidence and match a query, newest first. */
395
397
  export type ArtifactList = {
@@ -288,7 +288,7 @@ export class TestHistory {
288
288
  const result = {
289
289
  key: i.slot, id: i.id, title: i.title, specPath: i.specPath, domain: null, at: o.at, status: held.s, environment: o.environment, producer: o.producer, kind: held.k,
290
290
  parameters: o.parameters, branch: o.branch, sha: o.sha, run: o.run, runUrl: o.runUrl, layer: o.layer, backend: o.backend, attachments: e.attachments,
291
- artifactTags, tags: [...e.tags], types, labels: e.labels, ...(e.videos ? { videos: e.videos } : {}),
291
+ artifactTags, tags: [...e.tags], types, labels: e.labels, ...(e.videos ? { videos: e.videos } : {}), ...(e.attemptStatuses ? { attemptStatuses: e.attemptStatuses } : {}),
292
292
  };
293
293
  const media = [
294
294
  ...types.filter(type => type !== 'video').map(type => ({ type, tags: artifactTags })),