@crvouga/atlas 1.9.0 → 1.11.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 (46) 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 +1 -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 +38 -0
  20. package/docs/server.md +41 -0
  21. package/docs/ui.md +67 -1
  22. package/llms-full.txt +269 -2
  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 +6 -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 +3 -2
  32. package/packages/visualizer/src/evidence/behavior-panel.tsx +11 -1
  33. package/packages/visualizer/src/evidence/feature-page.tsx +25 -4
  34. package/packages/visualizer/src/explorer/ArtifactsView.tsx +26 -2
  35. package/packages/visualizer/src/explorer/Explorer.tsx +7 -2
  36. package/packages/visualizer/src/explorer/RecordingTextSearch.tsx +355 -0
  37. package/packages/visualizer/src/explorer/RunsView.tsx +11 -1
  38. package/packages/visualizer/src/explorer/collection-model.test.ts +31 -0
  39. package/packages/visualizer/src/explorer/collection-model.ts +45 -0
  40. package/packages/visualizer/src/explorer/collections.tsx +513 -0
  41. package/packages/visualizer/src/explorer/playlist-entries.test.ts +51 -0
  42. package/packages/visualizer/src/explorer/playlist-entries.ts +163 -0
  43. package/packages/visualizer/src/explorer/playlist.tsx +500 -0
  44. package/packages/visualizer/src/explorer/recording-text.test.ts +149 -0
  45. package/packages/visualizer/src/explorer/recording-text.ts +490 -0
  46. package/packages/visualizer/src/explorer/search.ts +10 -2
@@ -240,6 +240,156 @@ export const ArtifactTagsSchema = z.looseObject({
240
240
  schemaVersion: z.literal(1),
241
241
  entries: z.array(ArtifactTagEntrySchema)
242
242
  });
243
+ /** 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. */
244
+ export const ReviewSourceSchema = z.looseObject({
245
+ layer: z.string().regex(/^[0-9a-f]{64}$/).describe('The id of the archived layer that recorded the result'),
246
+ check: z.string().min(1).max(1024).describe('The check\'s id in that layer'),
247
+ kind: z.string().min(1).max(256).optional().describe('The kind of the part holding the check, when the same id exists in several parts'),
248
+ recording: z.string().min(1).max(2048).optional().describe('One recording of the result (`VideoRef.recording`: `sha256:…`, a bundle key or an address). Absent: the result as a whole')
249
+ });
250
+ /** One entry of a request to publish a collection: a source and the curator\'s caption for it. */
251
+ export const ReviewEntryInputSchema = z.looseObject({
252
+ ...ReviewSourceSchema.shape,
253
+ caption: z.string().max(500).optional().describe('What the curator wants the reviewer to look at in this entry')
254
+ });
255
+ /** A gap the curator names: something the review should show and no recording does. It sits after the first `after` entries (0 or absent: before the first). */
256
+ export const ReviewGapInputSchema = z.looseObject({
257
+ note: z.string().min(1).max(2000).describe('What is missing or unclear'),
258
+ after: z.number().int().min(0).optional().describe('Place the gap after this many entries (0 or absent: before the first)')
259
+ });
260
+ /**
261
+ * The body of `POST /api/reports/review-collections`: a new collection, or, with `basedOn`, an explicit new revision of
262
+ * one. At least one entry or gap. The server records which sources the entries are now; it never alters the request.
263
+ */
264
+ export const ReviewCollectionRequestSchema = z.looseObject({
265
+ title: z.string().min(1).max(200),
266
+ purpose: z.string().max(2000).optional().describe('Why this review exists'),
267
+ audience: z.string().max(500).optional().describe('Who should review it'),
268
+ tags: z.array(z.string()).max(20).optional().describe('Tags of the collection, such as `demo` (normalized like artifact tags)'),
269
+ entries: z.array(ReviewEntryInputSchema).max(500).optional().describe('Ordered. The order is the order of the review; it is never re-sorted by time or status'),
270
+ gaps: z.array(ReviewGapInputSchema).max(100).optional(),
271
+ frozenFrom: z.string().max(4000).optional().describe('The live filtered view the entries were frozen from, as its address query (informational; the entries are what count)'),
272
+ basedOn: z.looseObject({ id: z.string(), revision: z.number().int().min(1) }).optional().describe('Publish a new revision of this collection. `revision` must be its newest, else 409')
273
+ });
274
+ /** What the server recorded about the source of an entry at the moment it was frozen. */
275
+ export const ReviewSnapshotSchema = z.looseObject({
276
+ title: z.string(),
277
+ specPath: z.string().nullable(),
278
+ status: EvidenceStatusSchema.describe('The result\'s recorded status, as that layer recorded it'),
279
+ recordedAtMs: z.number(),
280
+ environment: z.string().nullable(),
281
+ producer: z.string(),
282
+ branch: z.string().nullable(),
283
+ sha: z.string().nullable(),
284
+ run: z.string().describe('The key of the run it belongs to'),
285
+ runUrl: z.string().nullable(),
286
+ backend: z.string().nullable().describe('The backend the layer was read from'),
287
+ testKey: z.string().describe('The test\'s key, as `GET /api/reports/tests` lists it'),
288
+ attemptStatuses: z.array(EvidenceStatusSchema).optional().describe('The status of each recorded try, in order, when the check was retried. Never collapsed to the last'),
289
+ videos: z.array(VideoRefSchema).optional().describe('Its references to videos when it was frozen')
290
+ });
291
+ /** One entry of a frozen collection. */
292
+ export const ReviewEntrySchema = z.looseObject({
293
+ id: z.string().describe('Stable within the collection; notes and reviewed marks refer to it'),
294
+ source: ReviewSourceSchema,
295
+ caption: z.string().optional(),
296
+ snapshot: ReviewSnapshotSchema
297
+ });
298
+ /** A gap item of a collection. */
299
+ export const ReviewGapSchema = z.looseObject({
300
+ id: z.string(),
301
+ note: z.string(),
302
+ after: z.number().int().nonnegative().describe('It sits after this many entries')
303
+ });
304
+ /**
305
+ * One revision of a review collection, as stored: immutable. It is curation, not a test run: it has no status of its own,
306
+ * carries no check, and its entries are references to results that other runs recorded. Entries from different
307
+ * runs are never one execution.
308
+ */
309
+ export const ReviewCollectionSchema = z.looseObject({
310
+ schemaVersion: z.literal(1),
311
+ id: z.string().describe('The collection\'s stable id, in its share URL'),
312
+ revision: z.number().int().min(1).describe('1 for the first; every change is a new revision'),
313
+ title: z.string(),
314
+ purpose: z.string().optional(),
315
+ audience: z.string().optional(),
316
+ tags: z.array(z.string()),
317
+ entries: z.array(ReviewEntrySchema),
318
+ gaps: z.array(ReviewGapSchema),
319
+ frozenFrom: z.string().optional(),
320
+ basedOn: z.looseObject({ id: z.string(), revision: z.number().int().min(1) }).optional(),
321
+ createdBy: z.string().describe('The name of the token that published it (never the token)'),
322
+ createdAtMs: z.number().describe('When the server recorded it, epoch milliseconds')
323
+ });
324
+ /** One collection in the list: its newest revision in brief. */
325
+ export const ReviewCollectionSummarySchema = z.looseObject({
326
+ id: z.string(), revision: z.number().int().min(1), revisions: z.number().int().min(1).describe('How many revisions exist'),
327
+ title: z.string(), purpose: z.string().optional(), audience: z.string().optional(), tags: z.array(z.string()),
328
+ entries: z.number().int().nonnegative(), gaps: z.number().int().nonnegative(), createdBy: z.string(), createdAtMs: z.number()
329
+ });
330
+ /** `GET /api/reports/review-collections`: the newest revision of every collection, newest first. */
331
+ export const ReviewCollectionListSchema = z.looseObject({
332
+ schemaVersion: z.literal(1),
333
+ collections: z.array(ReviewCollectionSummarySchema)
334
+ });
335
+ /** The body of `POST /api/reports/review-collections/reviews`: a note, a time-coded gap note, or a reviewed marker on one entry. Curation only: it changes no outcome. */
336
+ export const ReviewNoteInputSchema = z.looseObject({
337
+ collection: z.string().describe('The collection id'),
338
+ revision: z.number().int().min(1),
339
+ entry: z.string().optional().describe('The entry\'s id; required for `reviewed`, `unreviewed` and a timecode'),
340
+ kind: z.enum(['note', 'gap', 'reviewed', 'unreviewed']),
341
+ text: z.string().max(4000).optional().describe('Required for `note` and `gap`'),
342
+ timecodeMs: z.number().int().min(0).optional().describe('A moment in the entry\'s recording, in milliseconds')
343
+ });
344
+ /** One recorded review note. Entries are never changed or removed: they are the audit trail. */
345
+ export const ReviewNoteSchema = z.looseObject({
346
+ id: z.string().describe('Unique and time-ordered'),
347
+ collection: z.string(), revision: z.number().int().min(1),
348
+ entry: z.string().optional(),
349
+ kind: z.enum(['note', 'gap', 'reviewed', 'unreviewed']),
350
+ text: z.string().optional(),
351
+ timecodeMs: z.number().optional(),
352
+ by: z.string().describe('The name of the token that made it (never the token)'),
353
+ atMs: z.number()
354
+ });
355
+ /** How an entry\'s source stands now: `available` (the layer and the result are in the report), `unverified` (the layer is held but the result is outside the report\'s history window) or `unavailable` (the layer is gone). */
356
+ export const ReviewAvailabilitySchema = z.looseObject({
357
+ entry: z.string(),
358
+ state: z.enum(['available', 'unverified', 'unavailable']),
359
+ reason: z.string().optional().describe('Why it is not `available`, in words')
360
+ });
361
+ /** `GET /api/reports/review-collections/detail`: one revision, how each source stands now, and the review notes. */
362
+ export const ReviewCollectionDetailSchema = z.looseObject({
363
+ schemaVersion: z.literal(1),
364
+ collection: ReviewCollectionSchema,
365
+ revisions: z.array(z.number().int().min(1)).describe('Every revision that exists, oldest first'),
366
+ availability: z.array(ReviewAvailabilitySchema),
367
+ reviews: z.array(ReviewNoteSchema).describe('Notes and marks of this revision, oldest first'),
368
+ reviewed: z.record(z.string(), z.looseObject({ by: z.string(), atMs: z.number() })).describe('Entries whose latest mark is `reviewed`, with who and when')
369
+ });
370
+ /** One file in an export manifest. */
371
+ export const ReviewManifestFileSchema = z.looseObject({
372
+ name: z.string().describe('The attachment\'s name as recorded'),
373
+ safeName: z.string().describe('A name safe to write to a file system: letters, digits, dot, dash and underscore'),
374
+ path: z.string().describe('Where a packer should put it: `files/<entry position>-<safeName>`'),
375
+ recording: z.string().optional(),
376
+ sha256: z.string().nullable().describe('The digest of the bytes when the report recorded one, else null: unknown, not verified'),
377
+ missing: z.boolean().describe('True when the source cannot be read now')
378
+ });
379
+ /** `GET /api/reports/review-collections/export`: a portable index of one revision. It lists sources and files; it contains no bytes. */
380
+ export const ReviewManifestSchema = z.looseObject({
381
+ schemaVersion: z.literal(1),
382
+ generatedAtMs: z.number(),
383
+ collection: z.looseObject({ id: z.string(), revision: z.number().int().min(1), title: z.string(), purpose: z.string().optional(), audience: z.string().optional(), tags: z.array(z.string()), createdBy: z.string(), createdAtMs: z.number() }),
384
+ entries: z.array(z.looseObject({
385
+ id: z.string(), position: z.number().int().min(1), caption: z.string().optional(), source: ReviewSourceSchema, snapshot: ReviewSnapshotSchema,
386
+ state: z.enum(['available', 'unverified', 'unavailable']), reason: z.string().optional(), reviewed: z.boolean(),
387
+ files: z.array(ReviewManifestFileSchema)
388
+ })),
389
+ gaps: z.array(ReviewGapSchema),
390
+ missing: z.array(z.looseObject({ entry: z.string(), reason: z.string() })).describe('Entries whose sources cannot be read now, and every file that is missing'),
391
+ notes: z.string()
392
+ });
243
393
  /** The data of the `hello` event: sent first on `/api/events`. */
244
394
  export const HelloEventSchema = z.looseObject({ version: z.string(), ready: z.boolean(), time: z.number(), heartbeatMs: z.number().positive().optional() });
245
395
  /** The data of the `report` event: layers arrived. Enough to say who sent what, without asking. */
@@ -490,7 +640,8 @@ export const ArtifactResultSchema = z.looseObject({
490
640
  tags: z.array(z.string()).describe('Tags of its check and run, which its attachments inherit'),
491
641
  types: z.array(z.string()).describe('Media types (roles) of its attachments; `video` only for a playable recording'),
492
642
  labels: z.record(z.string(), z.string()),
493
- videos: z.array(VideoRefSchema).optional()
643
+ videos: z.array(VideoRefSchema).optional(),
644
+ attemptStatuses: z.array(EvidenceStatusSchema).optional().describe('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')
494
645
  });
495
646
  /** `GET /api/reports/artifacts`: recorded results that carry evidence, newest first, with the facets of the query. */
496
647
  export const ArtifactListSchema = z.looseObject({
@@ -42,6 +42,7 @@ import { Refusal } from './validate.js';
42
42
  import { createAuth } from './auth.js';
43
43
  import { EventHub } from './events.js';
44
44
  import { projectServer } from './projects.js';
45
+ import { mountReviewCollections } from './review-collections.js';
45
46
  import { readLimited, storeApi } from './store-api.js';
46
47
  const gzipAsync = promisify(gzip);
47
48
  const brotliAsync = promisify(brotliCompress);
@@ -258,7 +259,7 @@ function createProjectApplication(options) {
258
259
  name: 'atlas', version: options.version ?? null, role: identity?.role ?? 'read', open: auth.open,
259
260
  write: options.write, ready: index.ready, viewers: events.count, time: now(),
260
261
  backends: snapshot.backends.map(({ id, label, available }) => ({ id, label, available, write: id === options.write })),
261
- ...(({ 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 }
262
+ ...(({ 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 }
262
263
  });
263
264
  });
264
265
  app.get('/api/live', async (c) => c.json({ runs: await live.list() }, 200, { 'cache-control': 'no-store' }));
@@ -543,6 +544,8 @@ function createProjectApplication(options) {
543
544
  throw error;
544
545
  }
545
546
  });
547
+ // Review collections: immutable, curated references to recorded results, with reviewers' notes kept apart.
548
+ mountReviewCollections(app, { store: tagStore, stores, auth, index, now, maxBytes: limits.json, unauthorized });
546
549
  app.route('/api/reports', layers);
547
550
  // Upload: the store protocol, and a simple JSON way in for anything that can make a request.
548
551
  app.route('/api/store', storeApi({
@@ -14,7 +14,7 @@
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, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema } 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, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema, ReviewCollectionRequestSchema, ReviewCollectionSchema, ReviewCollectionListSchema, ReviewCollectionDetailSchema, ReviewNoteInputSchema, ReviewNoteSchema, ReviewManifestSchema } 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
20
  export const API_VERSION = '1.5.0';
@@ -37,6 +37,7 @@ const COMPONENTS = {
37
37
  StoreListing: [StoreListingSchema, 'output'], StoreWriteResult: [StoreWriteResultSchema, 'output'],
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
+ ReviewCollectionRequest: [ReviewCollectionRequestSchema, 'input'], ReviewCollection: [ReviewCollectionSchema, 'output'], ReviewCollectionList: [ReviewCollectionListSchema, 'output'], ReviewCollectionDetail: [ReviewCollectionDetailSchema, 'output'], ReviewNoteInput: [ReviewNoteInputSchema, 'input'], ReviewNote: [ReviewNoteSchema, 'output'], ReviewManifest: [ReviewManifestSchema, 'output'],
40
41
  TestCatalog: [TestCatalogSchema, 'output'], CatalogHealth: [CatalogHealthSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], TestReliability: [TestReliabilitySchema, 'output'], RunList: [RunListSchema, 'output'], ArtifactList: [ArtifactListSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
41
42
  SpecIndex: [SpecIndexSchema, 'output'], ReportSources: [ReportSourcesSchema, 'output'], RunsIndex: [RunsIndexSchema, 'output'],
42
43
  RunEvent: [LiveRunSchema, 'output'], HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
@@ -364,6 +365,50 @@ const BASE_OPERATIONS = [
364
365
  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') },
365
366
  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.') }
366
367
  },
368
+ {
369
+ route: 'GET /api/reports/review-collections', path: '/api/reports/review-collections', operationId: 'listReviewCollections', tag: 'Report', role: 'read',
370
+ summary: 'Review collections: curated sets of recordings, newest first',
371
+ description: 'The newest revision of every review collection, in brief: id, revision and how many exist, title, purpose, audience, tags, how many entries and gaps it has, who published it and when. A collection is curation kept beside the report; it is not a layer, a check or a run, and it has no status. Open one with `/api/reports/review-collections/detail?id=`. Its share URL is the viewer\'s `?tab=collections&collection=<id>` and needs the same read access as the rest of the report.',
372
+ responses: { '200': json('The collections.', 'ReviewCollectionList'), '401': UNAUTHORIZED }
373
+ },
374
+ {
375
+ route: 'POST /api/reports/review-collections', path: '/api/reports/review-collections', operationId: 'publishReviewCollection', tag: 'Report', role: 'publish',
376
+ summary: 'Publish an immutable review collection (or a new revision of one)',
377
+ description: [
378
+ 'Freezes an ordered set of references to recorded results as one named collection, recorded under the name of the publish (or admin) token that sends it. Take each entry\'s `layer` and `check` (and `kind`) from `GET /api/reports/artifacts`, and optionally one of its recordings (`videos[].recording`). The server resolves every entry to the result the report holds and records what the report says about it (status, time, run, commit, environment, tries, videos): the request does not claim them. An entry it cannot resolve refuses the whole request (`422`, naming the entry). The order is yours and is never re-sorted; failed, rejected and incomplete results stay in it beside a later passing replay.',
379
+ '',
380
+ 'Entries from different runs are never one execution: each keeps its own layer, run and status. A collection has no status of its own, adds no check and cannot change any result or winner. A revision is immutable. To change a collection, publish a new revision with `basedOn: { id, revision }` naming the newest revision (`409` otherwise); the earlier ones stay readable. Notes and reviewed marks are separate (`/reviews`). A reader-only token, a browser sign-in box and an anonymous reader are refused; nothing is shared publicly by default.'
381
+ ].join('\n'),
382
+ body: { description: 'The title, optional purpose, audience and tags, the ordered entries, optional gaps, and `basedOn` for a new revision. At most 500 entries and 100 gaps.', contentType: 'application/json', required: true, schema: ref('ReviewCollectionRequest') },
383
+ responses: { '201': json('The recorded revision.', 'ReviewCollection'), '401': UNAUTHORIZED, '403': FORBIDDEN, '404': problem('`basedOn` names no collection.'), '409': problem('`basedOn.revision` is not the newest.'), '413': problem('Request too large.'), '422': problem('Not a collection, or an entry the report does not hold.'), '503': NOT_READY }
384
+ },
385
+ {
386
+ route: 'GET /api/reports/review-collections/detail', path: '/api/reports/review-collections/detail', operationId: 'getReviewCollection', tag: 'Report', role: 'read',
387
+ summary: 'One review collection revision with how its sources stand now',
388
+ description: 'The revision (the newest without `revision`), every revision number, the review notes and reviewed marks of that revision, and for each entry `availability`: `available` (its layer is held and the report lists the result), `unverified` (the layer is held but the report no longer lists the result or recording) or `unavailable` (the layer is gone, for example deleted by garbage collection), with the reason. An entry whose source is not available stays in its place with its frozen snapshot; it is never removed or replaced. Entries keep the status each layer recorded, with their own times, in the order the curator chose. A reviewed mark is curation: it changes no outcome and is not an approval.',
389
+ parameters: [
390
+ { name: 'id', in: 'query', required: true, description: 'A collection id from the list.', schema: str, example: 'rc-0123456789abcdef' },
391
+ { name: 'revision', in: 'query', description: 'A revision number. Default: the newest.', schema: { type: 'integer', minimum: 1 } }
392
+ ],
393
+ responses: { '200': json('The revision.', 'ReviewCollectionDetail'), '400': problem('No `id`, or a bad `revision`.'), '401': UNAUTHORIZED, '404': problem('No such collection or revision.') }
394
+ },
395
+ {
396
+ route: 'POST /api/reports/review-collections/reviews', path: '/api/reports/review-collections/reviews', operationId: 'addReviewNote', tag: 'Report', role: 'publish',
397
+ summary: 'Add a note, a time-coded gap note or a reviewed mark to a collection entry',
398
+ description: 'Records one immutable entry, by the name of the publish (or admin) token and the server\'s time: a `note` or a `gap` (text required; with `entry` and optionally `timecodeMs`, a moment in that entry\'s recording), or a `reviewed` / `unreviewed` mark for one entry. It changes no check outcome, no layer, no collection revision and no report winner; a mark is not an acceptance sign-off. The notes of a revision are in its `detail`.',
399
+ body: { description: 'The collection and revision, optionally the entry, the kind and its text.', contentType: 'application/json', required: true, schema: ref('ReviewNoteInput') },
400
+ responses: { '201': json('The recorded note.', 'ReviewNote'), '401': UNAUTHORIZED, '403': FORBIDDEN, '404': problem('No such collection or revision.'), '413': problem('Request too large.'), '422': problem('Not a note: missing text, an unknown entry, or a mark without an entry.') }
401
+ },
402
+ {
403
+ route: 'GET /api/reports/review-collections/export', path: '/api/reports/review-collections/export', operationId: 'exportReviewCollection', tag: 'Report', role: 'read',
404
+ summary: 'Portable index (manifest) of one review collection revision',
405
+ description: 'A manifest an archive can be built from: the collection, each entry with its exact source (layer, check, kind, recording), the status and run it was recorded with, how it stands now, whether it is marked reviewed, and the files it references with their name as recorded, a safe name and path (`files/<position>-<safeName>`), and the recorded `sha256` when the report holds one (`null` is unknown, not verified). A source that cannot be read now is listed under `missing` and its files are marked `missing`; nothing is left out silently. The manifest contains no bytes; recordings are fetched from the attachment routes, which keep their own access control. Sent as an attachment.',
406
+ parameters: [
407
+ { name: 'id', in: 'query', required: true, description: 'A collection id.', schema: str },
408
+ { name: 'revision', in: 'query', description: 'A revision number. Default: the newest.', schema: { type: 'integer', minimum: 1 } }
409
+ ],
410
+ responses: { '200': json('The manifest.', 'ReviewManifest'), '400': problem('No `id`, or a bad `revision`.'), '401': UNAUTHORIZED, '404': problem('No such collection or revision.') }
411
+ },
367
412
  {
368
413
  route: 'GET /api/reports/runs', path: '/api/reports/runs', operationId: 'getReportRuns', tag: 'Report', role: 'read',
369
414
  summary: 'The runs that recorded the report',
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @module Review collection routes
3
+ *
4
+ * `/api/reports/review-collections`: publish an immutable, named, ordered collection of references to recorded results
5
+ * (with notes and gaps), read it back with how every source stands now, add reviewers' notes and "reviewed" marks, and
6
+ * export a portable manifest. A collection is curation kept beside the report under `curation/`: it is never a layer, a
7
+ * check or a run, changes no status, slot or winner, and never alters what is uploaded. Reading needs a reader (the
8
+ * same as the rest of the report, so nothing becomes public by sharing a link); publishing and noting need a Bearer
9
+ * token with the `publish` role, recorded under that token's name. Recordings are still served only by the attachment
10
+ * routes, under their own access control.
11
+ *
12
+ * @see docs/server.md#review-collections
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 review collection routes need from the server around them. */
19
+ export type ReviewCollectionDeps = {
20
+ /** Where collections and notes are kept: the write backend's store. */
21
+ readonly store: ObjectStore;
22
+ /** Every backend's store, to tell whether a layer is still held anywhere. */
23
+ readonly stores: readonly ObjectStore[];
24
+ readonly auth: Auth;
25
+ readonly index: ReportIndex;
26
+ readonly now: () => number;
27
+ /** The largest request body, in bytes. */
28
+ readonly maxBytes: number;
29
+ readonly unauthorized: (verdict: Extract<ReturnType<Auth['check']>, {
30
+ ok: false;
31
+ }>) => Response;
32
+ };
33
+ /**
34
+ * Registers the review collection routes on `app`:
35
+ * `GET /api/reports/review-collections`, `POST /api/reports/review-collections`,
36
+ * `GET /api/reports/review-collections/detail`, `POST /api/reports/review-collections/reviews` and
37
+ * `GET /api/reports/review-collections/export`.
38
+ */
39
+ export declare function mountReviewCollections(app: Hono, deps: ReviewCollectionDeps): void;
@@ -0,0 +1,179 @@
1
+ import { eachLimited } from '../../reports/src/completion-order.js';
2
+ import { layerKey } from '../../reports/src/layer-store.js';
3
+ import { availabilityOf, foldReviewed, manifestOf } from '../../reports/src/review-collection.js';
4
+ import { listReviewCollections, readReviewCollection, readReviewNotes, recordReviewCollection, recordReviewNote, ReviewCollectionError } from '../../reports/src/review-collection-store.js';
5
+ import { problemResponse } from './problem.js';
6
+ import { readLimited } from './store-api.js';
7
+ import { Refusal } from './validate.js';
8
+ const NO_STORE = { 'cache-control': 'no-store' };
9
+ const HELD_MS = 30_000;
10
+ const LIST_MS = 5_000;
11
+ /**
12
+ * Registers the review collection routes on `app`:
13
+ * `GET /api/reports/review-collections`, `POST /api/reports/review-collections`,
14
+ * `GET /api/reports/review-collections/detail`, `POST /api/reports/review-collections/reviews` and
15
+ * `GET /api/reports/review-collections/export`.
16
+ */
17
+ export function mountReviewCollections(app, deps) {
18
+ const { store, stores, auth, index, now } = deps;
19
+ // One publisher at a time in this process, so the check that a revision is free and the write of it are one step.
20
+ let queue = Promise.resolve();
21
+ const exclusive = (work) => {
22
+ const run = queue.then(work, work);
23
+ queue = run.catch(() => undefined);
24
+ return run;
25
+ };
26
+ let lastAt = 0;
27
+ const stamp = () => (lastAt = Math.max(now(), lastAt + 1));
28
+ // The report's recorded results by layer and check, rebuilt only when the history changes.
29
+ let located = null;
30
+ const find = (layer, check, kind) => {
31
+ if (!located || located.rev !== index.history.rev) {
32
+ const map = new Map();
33
+ for (const { result } of index.history.evidenceResults()) {
34
+ const key = `${result.layer}\u0000${result.id}`;
35
+ const list = map.get(key);
36
+ if (list)
37
+ list.push(result);
38
+ else
39
+ map.set(key, [result]);
40
+ }
41
+ located = { rev: index.history.rev, map };
42
+ }
43
+ const all = located.map.get(`${layer}\u0000${check}`) ?? [];
44
+ return kind === undefined ? all : all.filter(result => result.kind === kind);
45
+ };
46
+ const held = new Map();
47
+ const holdsLayers = async (layers) => {
48
+ const out = new Set();
49
+ const ask = layers.filter(layer => {
50
+ const known = held.get(layer);
51
+ if (known && now() - known.at < HELD_MS) {
52
+ if (known.value)
53
+ out.add(layer);
54
+ return false;
55
+ }
56
+ return true;
57
+ });
58
+ await eachLimited(ask, 8, async (layer) => {
59
+ let value = false;
60
+ for (const each of stores) {
61
+ if (await each.head(layerKey(layer)).catch(() => false)) {
62
+ value = true;
63
+ break;
64
+ }
65
+ }
66
+ if (held.size >= 4096)
67
+ held.delete(held.keys().next().value);
68
+ held.set(layer, { at: now(), value });
69
+ if (value)
70
+ out.add(layer);
71
+ });
72
+ return out;
73
+ };
74
+ let listing = null;
75
+ const list = async () => {
76
+ if (!listing || now() - listing.at > LIST_MS)
77
+ listing = { at: now(), value: await listReviewCollections(store) };
78
+ return listing.value;
79
+ };
80
+ const problem = (error) => {
81
+ if (error instanceof ReviewCollectionError || error instanceof Refusal)
82
+ return problemResponse(error.status, error.message);
83
+ return null;
84
+ };
85
+ const standing = async (id, revision) => {
86
+ const found = await readReviewCollection(store, id, revision);
87
+ if (!found)
88
+ return null;
89
+ const notes = (await readReviewNotes(store, id)).filter(note => note.revision === found.collection.revision);
90
+ const present = await holdsLayers([...new Set(found.collection.entries.map(entry => entry.source.layer))]);
91
+ const availability = availabilityOf(found.collection, layer => present.has(layer), find);
92
+ return { ...found, notes, availability, reviewed: foldReviewed(notes) };
93
+ };
94
+ const wanted = (c) => {
95
+ const id = c.req.query('id');
96
+ if (!id)
97
+ return problemResponse(400, 'Name the collection with ?id=, an id from GET /api/reports/review-collections.');
98
+ const raw = c.req.query('revision');
99
+ if (raw !== undefined && !/^[1-9]\d{0,5}$/.test(raw))
100
+ return problemResponse(400, '?revision= is a whole number of 1 or more.');
101
+ return { id, revision: raw === undefined ? undefined : Number(raw) };
102
+ };
103
+ app.get('/api/reports/review-collections', async (c) => {
104
+ const collections = (await list()).map(({ collection, revisions }) => ({
105
+ id: collection.id, revision: collection.revision, revisions, title: collection.title,
106
+ ...(collection.purpose === undefined ? {} : { purpose: collection.purpose }), ...(collection.audience === undefined ? {} : { audience: collection.audience }),
107
+ tags: collection.tags, entries: collection.entries.length, gaps: collection.gaps.length, createdBy: collection.createdBy, createdAtMs: collection.createdAtMs
108
+ }));
109
+ return c.json({ schemaVersion: 1, collections }, 200, NO_STORE);
110
+ });
111
+ app.post('/api/reports/review-collections', async (c) => {
112
+ const verdict = auth.check(c.req.raw, 'publish');
113
+ if (!verdict.ok)
114
+ return deps.unauthorized(verdict);
115
+ try {
116
+ const bytes = await readLimited(c.req.raw, Math.min(deps.maxBytes, 2 * 1024 * 1024));
117
+ let input;
118
+ try {
119
+ input = JSON.parse(new TextDecoder().decode(bytes));
120
+ }
121
+ catch {
122
+ return problemResponse(422, 'The body is not JSON.');
123
+ }
124
+ if (!index.ready)
125
+ return problemResponse(503, 'Atlas is still reading its backends; try again in a moment.', { 'retry-after': '1' });
126
+ const collection = await exclusive(() => recordReviewCollection(store, input, verdict.identity.name, stamp(), find));
127
+ listing = null;
128
+ return c.json(collection, 201, NO_STORE);
129
+ }
130
+ catch (error) {
131
+ const answered = problem(error);
132
+ if (answered)
133
+ return answered;
134
+ throw error;
135
+ }
136
+ });
137
+ app.get('/api/reports/review-collections/detail', async (c) => {
138
+ const asked = wanted(c);
139
+ if (asked instanceof Response)
140
+ return asked;
141
+ const found = await standing(asked.id, asked.revision);
142
+ if (!found)
143
+ return problemResponse(404, 'No such collection or revision.');
144
+ return c.json({ schemaVersion: 1, collection: found.collection, revisions: found.revisions, availability: found.availability, reviews: found.notes, reviewed: found.reviewed }, 200, NO_STORE);
145
+ });
146
+ app.post('/api/reports/review-collections/reviews', async (c) => {
147
+ const verdict = auth.check(c.req.raw, 'publish');
148
+ if (!verdict.ok)
149
+ return deps.unauthorized(verdict);
150
+ try {
151
+ const bytes = await readLimited(c.req.raw, Math.min(deps.maxBytes, 64 * 1024));
152
+ let input;
153
+ try {
154
+ input = JSON.parse(new TextDecoder().decode(bytes));
155
+ }
156
+ catch {
157
+ return problemResponse(422, 'The body is not JSON.');
158
+ }
159
+ const note = await recordReviewNote(store, input, verdict.identity.name, stamp());
160
+ return c.json(note, 201, NO_STORE);
161
+ }
162
+ catch (error) {
163
+ const answered = problem(error);
164
+ if (answered)
165
+ return answered;
166
+ throw error;
167
+ }
168
+ });
169
+ app.get('/api/reports/review-collections/export', async (c) => {
170
+ const asked = wanted(c);
171
+ if (asked instanceof Response)
172
+ return asked;
173
+ const found = await standing(asked.id, asked.revision);
174
+ if (!found)
175
+ return problemResponse(404, 'No such collection or revision.');
176
+ const manifest = manifestOf(found.collection, found.availability, found.reviewed, now());
177
+ return c.json(manifest, 200, { ...NO_STORE, 'content-disposition': `attachment; filename="review-${found.collection.id}-r${found.collection.revision}.json"` });
178
+ });
179
+ }
package/docs/extending.md CHANGED
@@ -30,7 +30,7 @@ carry any of these. Every one is optional.
30
30
  | `annotations[]` | Notes about the result: a warning, a deprecation, an accessibility finding, a coverage figure. |
31
31
  | `attempts[]` | Every try, when the check was retried. |
32
32
  | `tags[]` | Free labels to filter by: `smoke`, `payments`. |
33
- | `labels{}` | Facts about the result that are not its identity: `owner`, `severity`, `browser`, `build`. |
33
+ | `labels{}` | Facts about the result that are not its identity: `owner`, `severity`, `browser`, `build`. The viewer reads `build`, `platform`, `applies-to` and `applies-to-by` to tell whether evidence applies to a release (see [the viewer conventions](ui.md#behaviors-read-watch-inspect)). |
34
34
  | `scope{}` | What the evidence does and does not show: surface, device kind, synthetic data, scripted dependencies, audio, pacing, what was asserted and its limits. Unknown when absent. See [Evidence scope](rich-evidence.md#evidence-scope). |
35
35
  | `links[]` | Where to read more: the CI run, the issue, the trace client. |
36
36
  | `extensions{}` | Structured data of your own or a tool's, under a name you own. |