@crvouga/atlas 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/playwright-reporter/src/index.d.ts +11 -0
  3. package/dist/playwright-reporter/src/index.js +4 -2
  4. package/dist/reports/src/artifact-tag-store.d.ts +20 -0
  5. package/dist/reports/src/artifact-tag-store.js +70 -0
  6. package/dist/reports/src/artifact-tags.d.ts +96 -0
  7. package/dist/reports/src/artifact-tags.js +96 -0
  8. package/dist/reports/src/evidence-detail.d.ts +1 -1
  9. package/dist/reports/src/evidence-detail.js +6 -2
  10. package/dist/reports/src/evidence-types.d.ts +5 -0
  11. package/dist/reports/src/layer-merge.d.ts +1 -1
  12. package/dist/reports/src/layer-merge.js +3 -2
  13. package/dist/reports/src/layer-types.d.ts +3 -0
  14. package/dist/reports/src/publish-checks.js +1 -0
  15. package/dist/reports/src/report-cli.d.ts +1 -1
  16. package/dist/reports/src/report-cli.js +30 -2
  17. package/dist/reports/src/reporter.d.ts +1 -1
  18. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  19. package/dist/reports/src/reports-route.d.ts +8 -0
  20. package/dist/reports/src/test-catalog.d.ts +31 -27
  21. package/dist/reports/src/test-catalog.js +73 -12
  22. package/dist/schema/src/api.d.ts +128 -1
  23. package/dist/schema/src/api.js +38 -4
  24. package/dist/schema/src/layer.d.ts +59 -0
  25. package/dist/schema/src/layer.js +28 -1
  26. package/dist/server/src/app.js +32 -1
  27. package/dist/server/src/openapi.d.ts +1 -1
  28. package/dist/server/src/openapi.js +18 -2
  29. package/docs/extending.md +3 -0
  30. package/docs/integrating.md +8 -0
  31. package/docs/reference/cli.md +7 -0
  32. package/docs/reference/http-api.md +48 -0
  33. package/docs/reference/playwright-reporter.md +6 -6
  34. package/docs/reference/reports.md +235 -8
  35. package/docs/reference/schema.md +52 -1
  36. package/docs/report-contract.md +1 -0
  37. package/docs/rich-evidence.md +28 -0
  38. package/docs/server.md +24 -0
  39. package/llms-full.txt +119 -0
  40. package/package.json +1 -1
  41. package/packages/schema/schemas/check-detail.schema.json +28 -0
  42. package/packages/schema/schemas/effective-delta.schema.json +44 -0
  43. package/packages/schema/schemas/effective-evidence.schema.json +44 -0
  44. package/packages/schema/schemas/evidence-page.schema.json +37 -0
  45. package/packages/schema/schemas/layer-rows.schema.json +7 -0
  46. package/packages/schema/schemas/layer.schema.json +100 -0
  47. package/packages/schema/schemas/publish-checks.schema.json +65 -0
  48. package/packages/schema/schemas/report-run.schema.json +17 -1
  49. package/packages/schema/schemas/report-runs.schema.json +17 -1
  50. package/packages/schema/schemas/test-catalog.schema.json +42 -0
  51. package/packages/schema/schemas/test-history.schema.json +28 -0
  52. package/packages/server/openapi.json +886 -3
  53. package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
  54. package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
  55. package/packages/visualizer/src/explorer/RunsView.tsx +23 -4
  56. package/packages/visualizer/src/explorer/parts.tsx +2 -1
@@ -60,6 +60,20 @@ export type CheckStatus = z.infer<typeof CheckStatusSchema>;
60
60
  * changes only with a new `schemaVersion`.
61
61
  */
62
62
  export declare const FRESHNESS: readonly ["unknown", "modified"];
63
+ /**
64
+ * The execution a layer belongs to, named by the producer: one invocation of a local or manual run, which several
65
+ * layers (shards, batches, producers) may share. Presentation and history grouping only: it never takes part in the
66
+ * slot a result fills, in which result wins, or in a status. Absent, a run is told by its CI run URL, else `branch@sha`.
67
+ * Within one project, layers that carry the same `id` are one run; a later invocation gets another `id`.
68
+ */
69
+ export declare const LayerRunSchema: z.ZodObject<{
70
+ id: z.ZodString;
71
+ title: z.ZodOptional<z.ZodString>;
72
+ startedAtMs: z.ZodOptional<z.ZodNumber>;
73
+ endedAtMs: z.ZodOptional<z.ZodNumber>;
74
+ }, z.core.$loose>;
75
+ /** The execution of a layer, parsed from `LayerRunSchema`. */
76
+ export type LayerRun = z.output<typeof LayerRunSchema>;
63
77
  /**
64
78
  * A number a check measured, such as a latency or a size, with its unit and an optional allowance. A measurement over
65
79
  * its `limit` is reported and never changes the check's status.
@@ -107,6 +121,7 @@ export declare const AttachmentSchema: z.ZodObject<{
107
121
  height: z.ZodOptional<z.ZodNumber>;
108
122
  durationMs: z.ZodOptional<z.ZodNumber>;
109
123
  description: z.ZodOptional<z.ZodString>;
124
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
110
125
  at: z.ZodOptional<z.ZodObject<{
111
126
  step: z.ZodOptional<z.ZodString>;
112
127
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -167,6 +182,7 @@ export declare const StepSchema: z.ZodObject<{
167
182
  height: z.ZodOptional<z.ZodNumber>;
168
183
  durationMs: z.ZodOptional<z.ZodNumber>;
169
184
  description: z.ZodOptional<z.ZodString>;
185
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
170
186
  at: z.ZodOptional<z.ZodObject<{
171
187
  step: z.ZodOptional<z.ZodString>;
172
188
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -234,6 +250,7 @@ export declare const AttemptSchema: z.ZodObject<{
234
250
  height: z.ZodOptional<z.ZodNumber>;
235
251
  durationMs: z.ZodOptional<z.ZodNumber>;
236
252
  description: z.ZodOptional<z.ZodString>;
253
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
237
254
  at: z.ZodOptional<z.ZodObject<{
238
255
  step: z.ZodOptional<z.ZodString>;
239
256
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -278,6 +295,7 @@ export declare const AttemptSchema: z.ZodObject<{
278
295
  height: z.ZodOptional<z.ZodNumber>;
279
296
  durationMs: z.ZodOptional<z.ZodNumber>;
280
297
  description: z.ZodOptional<z.ZodString>;
298
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
281
299
  at: z.ZodOptional<z.ZodObject<{
282
300
  step: z.ZodOptional<z.ZodString>;
283
301
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -341,6 +359,7 @@ export declare const CheckSchema: z.ZodObject<{
341
359
  height: z.ZodOptional<z.ZodNumber>;
342
360
  durationMs: z.ZodOptional<z.ZodNumber>;
343
361
  description: z.ZodOptional<z.ZodString>;
362
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
344
363
  at: z.ZodOptional<z.ZodObject<{
345
364
  step: z.ZodOptional<z.ZodString>;
346
365
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -405,6 +424,7 @@ export declare const CheckSchema: z.ZodObject<{
405
424
  height: z.ZodOptional<z.ZodNumber>;
406
425
  durationMs: z.ZodOptional<z.ZodNumber>;
407
426
  description: z.ZodOptional<z.ZodString>;
427
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
408
428
  at: z.ZodOptional<z.ZodObject<{
409
429
  step: z.ZodOptional<z.ZodString>;
410
430
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -449,6 +469,7 @@ export declare const CheckSchema: z.ZodObject<{
449
469
  height: z.ZodOptional<z.ZodNumber>;
450
470
  durationMs: z.ZodOptional<z.ZodNumber>;
451
471
  description: z.ZodOptional<z.ZodString>;
472
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
452
473
  at: z.ZodOptional<z.ZodObject<{
453
474
  step: z.ZodOptional<z.ZodString>;
454
475
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -500,6 +521,7 @@ export declare const CheckSchema: z.ZodObject<{
500
521
  height: z.ZodOptional<z.ZodNumber>;
501
522
  durationMs: z.ZodOptional<z.ZodNumber>;
502
523
  description: z.ZodOptional<z.ZodString>;
524
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
503
525
  at: z.ZodOptional<z.ZodObject<{
504
526
  step: z.ZodOptional<z.ZodString>;
505
527
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -586,6 +608,7 @@ export declare const ReportPartSchema: z.ZodObject<{
586
608
  height: z.ZodOptional<z.ZodNumber>;
587
609
  durationMs: z.ZodOptional<z.ZodNumber>;
588
610
  description: z.ZodOptional<z.ZodString>;
611
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
589
612
  at: z.ZodOptional<z.ZodObject<{
590
613
  step: z.ZodOptional<z.ZodString>;
591
614
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -649,6 +672,7 @@ export declare const ReportPartSchema: z.ZodObject<{
649
672
  height: z.ZodOptional<z.ZodNumber>;
650
673
  durationMs: z.ZodOptional<z.ZodNumber>;
651
674
  description: z.ZodOptional<z.ZodString>;
675
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
652
676
  at: z.ZodOptional<z.ZodObject<{
653
677
  step: z.ZodOptional<z.ZodString>;
654
678
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -713,6 +737,7 @@ export declare const ReportPartSchema: z.ZodObject<{
713
737
  height: z.ZodOptional<z.ZodNumber>;
714
738
  durationMs: z.ZodOptional<z.ZodNumber>;
715
739
  description: z.ZodOptional<z.ZodString>;
740
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
716
741
  at: z.ZodOptional<z.ZodObject<{
717
742
  step: z.ZodOptional<z.ZodString>;
718
743
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -757,6 +782,7 @@ export declare const ReportPartSchema: z.ZodObject<{
757
782
  height: z.ZodOptional<z.ZodNumber>;
758
783
  durationMs: z.ZodOptional<z.ZodNumber>;
759
784
  description: z.ZodOptional<z.ZodString>;
785
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
760
786
  at: z.ZodOptional<z.ZodObject<{
761
787
  step: z.ZodOptional<z.ZodString>;
762
788
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -808,6 +834,7 @@ export declare const ReportPartSchema: z.ZodObject<{
808
834
  height: z.ZodOptional<z.ZodNumber>;
809
835
  durationMs: z.ZodOptional<z.ZodNumber>;
810
836
  description: z.ZodOptional<z.ZodString>;
837
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
811
838
  at: z.ZodOptional<z.ZodObject<{
812
839
  step: z.ZodOptional<z.ZodString>;
813
840
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -896,6 +923,7 @@ export declare const LayerSourceSchema: z.ZodObject<{
896
923
  height: z.ZodOptional<z.ZodNumber>;
897
924
  durationMs: z.ZodOptional<z.ZodNumber>;
898
925
  description: z.ZodOptional<z.ZodString>;
926
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
899
927
  at: z.ZodOptional<z.ZodObject<{
900
928
  step: z.ZodOptional<z.ZodString>;
901
929
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -960,6 +988,7 @@ export declare const LayerSourceSchema: z.ZodObject<{
960
988
  height: z.ZodOptional<z.ZodNumber>;
961
989
  durationMs: z.ZodOptional<z.ZodNumber>;
962
990
  description: z.ZodOptional<z.ZodString>;
991
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
963
992
  at: z.ZodOptional<z.ZodObject<{
964
993
  step: z.ZodOptional<z.ZodString>;
965
994
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1004,6 +1033,7 @@ export declare const LayerSourceSchema: z.ZodObject<{
1004
1033
  height: z.ZodOptional<z.ZodNumber>;
1005
1034
  durationMs: z.ZodOptional<z.ZodNumber>;
1006
1035
  description: z.ZodOptional<z.ZodString>;
1036
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1007
1037
  at: z.ZodOptional<z.ZodObject<{
1008
1038
  step: z.ZodOptional<z.ZodString>;
1009
1039
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1055,6 +1085,7 @@ export declare const LayerSourceSchema: z.ZodObject<{
1055
1085
  height: z.ZodOptional<z.ZodNumber>;
1056
1086
  durationMs: z.ZodOptional<z.ZodNumber>;
1057
1087
  description: z.ZodOptional<z.ZodString>;
1088
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1058
1089
  at: z.ZodOptional<z.ZodObject<{
1059
1090
  step: z.ZodOptional<z.ZodString>;
1060
1091
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1169,6 +1200,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1169
1200
  height: z.ZodOptional<z.ZodNumber>;
1170
1201
  durationMs: z.ZodOptional<z.ZodNumber>;
1171
1202
  description: z.ZodOptional<z.ZodString>;
1203
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1172
1204
  at: z.ZodOptional<z.ZodObject<{
1173
1205
  step: z.ZodOptional<z.ZodString>;
1174
1206
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1211,6 +1243,12 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1211
1243
  unknown: "unknown";
1212
1244
  modified: "modified";
1213
1245
  }>>;
1246
+ run: z.ZodOptional<z.ZodObject<{
1247
+ id: z.ZodString;
1248
+ title: z.ZodOptional<z.ZodString>;
1249
+ startedAtMs: z.ZodOptional<z.ZodNumber>;
1250
+ endedAtMs: z.ZodOptional<z.ZodNumber>;
1251
+ }, z.core.$loose>>;
1214
1252
  specs: z.ZodDefault<z.ZodArray<z.ZodObject<{
1215
1253
  path: z.ZodString;
1216
1254
  domain: z.ZodString;
@@ -1240,6 +1278,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1240
1278
  height: z.ZodOptional<z.ZodNumber>;
1241
1279
  durationMs: z.ZodOptional<z.ZodNumber>;
1242
1280
  description: z.ZodOptional<z.ZodString>;
1281
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1243
1282
  at: z.ZodOptional<z.ZodObject<{
1244
1283
  step: z.ZodOptional<z.ZodString>;
1245
1284
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1303,6 +1342,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1303
1342
  height: z.ZodOptional<z.ZodNumber>;
1304
1343
  durationMs: z.ZodOptional<z.ZodNumber>;
1305
1344
  description: z.ZodOptional<z.ZodString>;
1345
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1306
1346
  at: z.ZodOptional<z.ZodObject<{
1307
1347
  step: z.ZodOptional<z.ZodString>;
1308
1348
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1367,6 +1407,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1367
1407
  height: z.ZodOptional<z.ZodNumber>;
1368
1408
  durationMs: z.ZodOptional<z.ZodNumber>;
1369
1409
  description: z.ZodOptional<z.ZodString>;
1410
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1370
1411
  at: z.ZodOptional<z.ZodObject<{
1371
1412
  step: z.ZodOptional<z.ZodString>;
1372
1413
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1411,6 +1452,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1411
1452
  height: z.ZodOptional<z.ZodNumber>;
1412
1453
  durationMs: z.ZodOptional<z.ZodNumber>;
1413
1454
  description: z.ZodOptional<z.ZodString>;
1455
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1414
1456
  at: z.ZodOptional<z.ZodObject<{
1415
1457
  step: z.ZodOptional<z.ZodString>;
1416
1458
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1462,6 +1504,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1462
1504
  height: z.ZodOptional<z.ZodNumber>;
1463
1505
  durationMs: z.ZodOptional<z.ZodNumber>;
1464
1506
  description: z.ZodOptional<z.ZodString>;
1507
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1465
1508
  at: z.ZodOptional<z.ZodObject<{
1466
1509
  step: z.ZodOptional<z.ZodString>;
1467
1510
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1549,6 +1592,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1549
1592
  height: z.ZodOptional<z.ZodNumber>;
1550
1593
  durationMs: z.ZodOptional<z.ZodNumber>;
1551
1594
  description: z.ZodOptional<z.ZodString>;
1595
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1552
1596
  at: z.ZodOptional<z.ZodObject<{
1553
1597
  step: z.ZodOptional<z.ZodString>;
1554
1598
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1613,6 +1657,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1613
1657
  height: z.ZodOptional<z.ZodNumber>;
1614
1658
  durationMs: z.ZodOptional<z.ZodNumber>;
1615
1659
  description: z.ZodOptional<z.ZodString>;
1660
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1616
1661
  at: z.ZodOptional<z.ZodObject<{
1617
1662
  step: z.ZodOptional<z.ZodString>;
1618
1663
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1657,6 +1702,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1657
1702
  height: z.ZodOptional<z.ZodNumber>;
1658
1703
  durationMs: z.ZodOptional<z.ZodNumber>;
1659
1704
  description: z.ZodOptional<z.ZodString>;
1705
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1660
1706
  at: z.ZodOptional<z.ZodObject<{
1661
1707
  step: z.ZodOptional<z.ZodString>;
1662
1708
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1708,6 +1754,7 @@ export declare const LayerDocumentSchema: z.ZodObject<{
1708
1754
  height: z.ZodOptional<z.ZodNumber>;
1709
1755
  durationMs: z.ZodOptional<z.ZodNumber>;
1710
1756
  description: z.ZodOptional<z.ZodString>;
1757
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1711
1758
  at: z.ZodOptional<z.ZodObject<{
1712
1759
  step: z.ZodOptional<z.ZodString>;
1713
1760
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1835,6 +1882,7 @@ export declare const AttachmentInputSchema: z.ZodObject<{
1835
1882
  height: z.ZodOptional<z.ZodNumber>;
1836
1883
  durationMs: z.ZodOptional<z.ZodNumber>;
1837
1884
  description: z.ZodOptional<z.ZodString>;
1885
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1838
1886
  at: z.ZodOptional<z.ZodObject<{
1839
1887
  step: z.ZodOptional<z.ZodString>;
1840
1888
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1915,6 +1963,7 @@ export declare const PublishChecksSchema: z.ZodObject<{
1915
1963
  height: z.ZodOptional<z.ZodNumber>;
1916
1964
  durationMs: z.ZodOptional<z.ZodNumber>;
1917
1965
  description: z.ZodOptional<z.ZodString>;
1966
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1918
1967
  at: z.ZodOptional<z.ZodObject<{
1919
1968
  step: z.ZodOptional<z.ZodString>;
1920
1969
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -1970,6 +2019,7 @@ export declare const PublishChecksSchema: z.ZodObject<{
1970
2019
  height: z.ZodOptional<z.ZodNumber>;
1971
2020
  durationMs: z.ZodOptional<z.ZodNumber>;
1972
2021
  description: z.ZodOptional<z.ZodString>;
2022
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
1973
2023
  at: z.ZodOptional<z.ZodObject<{
1974
2024
  step: z.ZodOptional<z.ZodString>;
1975
2025
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -2058,6 +2108,7 @@ export declare const PublishChecksSchema: z.ZodObject<{
2058
2108
  height: z.ZodOptional<z.ZodNumber>;
2059
2109
  durationMs: z.ZodOptional<z.ZodNumber>;
2060
2110
  description: z.ZodOptional<z.ZodString>;
2111
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
2061
2112
  at: z.ZodOptional<z.ZodObject<{
2062
2113
  step: z.ZodOptional<z.ZodString>;
2063
2114
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -2084,6 +2135,7 @@ export declare const PublishChecksSchema: z.ZodObject<{
2084
2135
  height: z.ZodOptional<z.ZodNumber>;
2085
2136
  durationMs: z.ZodOptional<z.ZodNumber>;
2086
2137
  description: z.ZodOptional<z.ZodString>;
2138
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
2087
2139
  at: z.ZodOptional<z.ZodObject<{
2088
2140
  step: z.ZodOptional<z.ZodString>;
2089
2141
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -2109,6 +2161,12 @@ export declare const PublishChecksSchema: z.ZodObject<{
2109
2161
  unknown: "unknown";
2110
2162
  modified: "modified";
2111
2163
  }>>;
2164
+ run: z.ZodOptional<z.ZodObject<{
2165
+ id: z.ZodString;
2166
+ title: z.ZodOptional<z.ZodString>;
2167
+ startedAtMs: z.ZodOptional<z.ZodNumber>;
2168
+ endedAtMs: z.ZodOptional<z.ZodNumber>;
2169
+ }, z.core.$loose>>;
2112
2170
  specs: z.ZodDefault<z.ZodArray<z.ZodObject<{
2113
2171
  path: z.ZodString;
2114
2172
  domain: z.ZodString;
@@ -2137,6 +2195,7 @@ export declare const PublishChecksSchema: z.ZodObject<{
2137
2195
  height: z.ZodOptional<z.ZodNumber>;
2138
2196
  durationMs: z.ZodOptional<z.ZodNumber>;
2139
2197
  description: z.ZodOptional<z.ZodString>;
2198
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
2140
2199
  at: z.ZodOptional<z.ZodObject<{
2141
2200
  step: z.ZodOptional<z.ZodString>;
2142
2201
  offsetMs: z.ZodOptional<z.ZodNumber>;
@@ -50,6 +50,18 @@ export const CheckStatusSchema = z.enum(CHECK_STATUSES);
50
50
  * changes only with a new `schemaVersion`.
51
51
  */
52
52
  export const FRESHNESS = ['unknown', 'modified'];
53
+ /**
54
+ * The execution a layer belongs to, named by the producer: one invocation of a local or manual run, which several
55
+ * layers (shards, batches, producers) may share. Presentation and history grouping only: it never takes part in the
56
+ * slot a result fills, in which result wins, or in a status. Absent, a run is told by its CI run URL, else `branch@sha`.
57
+ * Within one project, layers that carry the same `id` are one run; a later invocation gets another `id`.
58
+ */
59
+ export const LayerRunSchema = z.looseObject({
60
+ id: z.string().min(1).max(256).describe('The execution\'s identity within a project, chosen by the producer: a UUID, a timestamped name. Layers with the same id are one run; it never changes after it is recorded'),
61
+ title: z.string().optional().describe('A human name for the run. Presentation only: it is not part of the identity and does not hide the commit or `freshness`'),
62
+ startedAtMs: z.number().nonnegative().optional().describe('When the execution started, epoch milliseconds'),
63
+ endedAtMs: z.number().nonnegative().optional().describe('When the execution ended, epoch milliseconds')
64
+ });
53
65
  /**
54
66
  * A number a check measured, such as a latency or a size, with its unit and an optional allowance. A measurement over
55
67
  * its `limit` is reported and never changes the check's status.
@@ -97,6 +109,7 @@ export const AttachmentSchema = z.looseObject({
97
109
  height: z.number().nonnegative().optional(),
98
110
  durationMs: z.number().nonnegative().optional().describe('For video and audio'),
99
111
  description: z.string().optional(),
112
+ tags: z.array(z.string()).optional().describe('Free labels of this one attachment, such as `demo`: shown as `#demo`, searchable, and normalized by `normalizeTag` (a leading `#` and letter case never matter). Unlike the tags of a check they say nothing about its result'),
100
113
  at: MomentSchema.optional(),
101
114
  poster: z.string().optional().describe('The `id` of an attachment that stands for this one before it is opened'),
102
115
  captions: z.array(z.string()).optional().describe('The `id`s of attachments (WebVTT, SRT) that caption this one'),
@@ -250,6 +263,7 @@ export const LayerDocumentSchema = z.looseObject({
250
263
  environment: z.string().nullable().optional().describe('The client that was checked: desktop-web, mobile-web, ios, android, or any other lowercase name'),
251
264
  parameters: z.record(z.string(), z.string()).default({}).describe('Other choices that make a result its own slot, such as a device size'),
252
265
  freshness: z.enum(FRESHNESS).optional().describe('`modified`: recorded from a checkout with uncommitted changes. `unknown`: origin not captured'),
266
+ run: LayerRunSchema.optional().describe('The execution this layer belongs to, when the producer names one (a local or manual run without a CI run URL). Grouping and display only; it never partitions a report or decides a result'),
253
267
  specs: z.array(SpecEntrySchema).default([]),
254
268
  parts: z.array(ReportPartSchema),
255
269
  source: LayerSourceSchema.nullable().default(null),
@@ -272,6 +286,15 @@ function lenientList(value, schema, prepare = (item) => item) {
272
286
  });
273
287
  }
274
288
  const object = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null);
289
+ /** An attachment whose `tags` cannot be read keeps its file: only the tags that are strings are kept. */
290
+ function lenientAttachment(item) {
291
+ const attachment = object(item);
292
+ if (!attachment || !('tags' in attachment))
293
+ return item;
294
+ const { tags, ...rest } = attachment;
295
+ const kept = lenientList(tags, z.string());
296
+ return kept ? { ...rest, tags: kept } : rest;
297
+ }
275
298
  function lenientEvidence(holder, nested = []) {
276
299
  const out = { ...holder };
277
300
  const set = (key, value) => { if (value === undefined)
@@ -283,7 +306,7 @@ function lenientEvidence(holder, nested = []) {
283
306
  if ('links' in out)
284
307
  set('links', lenientList(out.links, LinkSchema));
285
308
  if ('attachments' in out)
286
- set('attachments', lenientList(out.attachments, AttachmentSchema));
309
+ set('attachments', lenientList(out.attachments, AttachmentSchema, lenientAttachment));
287
310
  if ('annotations' in out)
288
311
  set('annotations', lenientList(out.annotations, AnnotationSchema));
289
312
  if ('labels' in out)
@@ -298,6 +321,9 @@ function lenientEvidence(holder, nested = []) {
298
321
  }
299
322
  function lenientLayer(raw) {
300
323
  const layer = lenientEvidence(raw);
324
+ // The execution only groups and labels: one that cannot be read leaves the layer to the documented grouping, never refuses it.
325
+ if ('run' in layer && !LayerRunSchema.safeParse(layer.run).success)
326
+ delete layer.run;
301
327
  if (Array.isArray(layer.parts)) {
302
328
  layer.parts = layer.parts.map((part) => {
303
329
  const found = object(part);
@@ -384,6 +410,7 @@ export const PublishChecksSchema = z.looseObject({
384
410
  environment: z.string().nullable().optional(),
385
411
  parameters: z.record(z.string(), z.string()).default({}),
386
412
  freshness: z.enum(FRESHNESS).optional(),
413
+ run: LayerRunSchema.optional().describe('Name the execution these checks belong to, so repeated local runs at one commit stay separate runs and shards of one execution stay together'),
387
414
  specs: z.array(SpecEntrySchema).default([]),
388
415
  tags: evidenceShape.tags,
389
416
  labels: evidenceShape.labels,
@@ -20,6 +20,7 @@ import { combineBackends, summarize } from '../../reports/src/compose.js';
20
20
  import { combineEvidence } from '../../reports/src/layer-merge.js';
21
21
  import { layerCatalogKey } from '../../reports/src/layer-store.js';
22
22
  import { createLayersApp } from '../../reports/src/layers-route.js';
23
+ import { readArtifactTagEntries, recordArtifactTags, ArtifactTagError } from '../../reports/src/artifact-tag-store.js';
23
24
  import { publishChecks } from '../../reports/src/publish-checks.js';
24
25
  import { reportStatus } from '../../reports/src/report-status.js';
25
26
  import { ReportIndex } from '../../reports/src/report-index.js';
@@ -254,7 +255,7 @@ function createProjectApplication(options) {
254
255
  name: 'atlas', version: options.version ?? null, role: identity?.role ?? 'read', open: auth.open,
255
256
  write: options.write, ready: index.ready, viewers: events.count, time: now(),
256
257
  backends: snapshot.backends.map(({ id, label, available }) => ({ id, label, available, write: id === options.write })),
257
- ...(({ backends: _unused, ...rest }) => rest)(index.stats()), capabilities: { events: true, ingest: true, store: true, checks: true, status: true, effective: true, live: true, tests: true }
258
+ ...(({ 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 }
258
259
  });
259
260
  });
260
261
  app.get('/api/live', async (c) => c.json({ runs: await live.list() }, 200, { 'cache-control': 'no-store' }));
@@ -440,6 +441,36 @@ function createProjectApplication(options) {
440
441
  return problemResponse(404, 'No run has that key.');
441
442
  return answer(c, found.version, () => document, () => runMarkdown(document), `run|${key}|${wantsMarkdown(c)}`);
442
443
  });
444
+ // Reviewer tags for attachments: an audit trail of edits kept beside the layers, which they never change. Read
445
+ // with a read token; an edit needs a publish token in a Bearer header, and is recorded under that token's name.
446
+ const tagStore = options.liveStore ?? writeBackend.store;
447
+ // Edits are ordered by time, so two in one millisecond are told apart by moving the later one on.
448
+ let lastTagAt = 0;
449
+ const artifactTags = cached(() => readArtifactTagEntries(tagStore), 5_000);
450
+ app.get('/api/reports/artifact-tags', async (c) => c.json({ schemaVersion: 1, entries: await artifactTags() }, 200, { 'cache-control': 'no-store' }));
451
+ app.post('/api/reports/artifact-tags', async (c) => {
452
+ const verdict = auth.check(c.req.raw, 'publish');
453
+ if (!verdict.ok)
454
+ return unauthorized(verdict);
455
+ try {
456
+ const bytes = await readLimited(c.req.raw, Math.min(limits.json, 64 * 1024));
457
+ let input;
458
+ try {
459
+ input = JSON.parse(new TextDecoder().decode(bytes));
460
+ }
461
+ catch {
462
+ return problemResponse(422, 'The body is not JSON.');
463
+ }
464
+ const entry = await recordArtifactTags(tagStore, input, verdict.identity.name, lastTagAt = Math.max(now(), lastTagAt + 1));
465
+ await artifactTags.invalidate();
466
+ return c.json(entry, 201, { 'cache-control': 'no-store' });
467
+ }
468
+ catch (error) {
469
+ if (error instanceof ArtifactTagError || error instanceof Refusal)
470
+ return problemResponse(error.status, error.message);
471
+ throw error;
472
+ }
473
+ });
443
474
  app.route('/api/reports', layers);
444
475
  // Upload: the store protocol, and a simple JSON way in for anything that can make a request.
445
476
  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.4.0";
2
+ export declare const API_VERSION = "1.5.0";
3
3
  /** Who may call an operation: `none` (anyone), or the least role that may (`read` < `publish` < `admin`). */
4
4
  export type Role = 'none' | 'read' | '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, RunListSchema, RunDetailSchema } 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, RunListSchema, RunDetailSchema, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema } from '../../schema/src/api.js';
18
18
  import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, 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.4.0';
20
+ export const API_VERSION = '1.5.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 });
@@ -35,6 +35,7 @@ const COMPONENTS = {
35
35
  PublishChecks: [PublishChecksSchema.partial({ recordedAtMs: true }), 'input'], ChecksResult: [ChecksResultSchema, 'output'],
36
36
  StoreListing: [StoreListingSchema, 'output'], StoreWriteResult: [StoreWriteResultSchema, 'output'],
37
37
  Outline: [OutlineSchema, 'output'], EvidencePage: [EvidencePageSchema, 'output'], LayerRows: [LayerRowsSchema, 'output'], CheckDetail: [CheckDetailSchema, 'output'],
38
+ ArtifactTagEdit: [ArtifactTagEditSchema, 'input'], ArtifactTagEntry: [ArtifactTagEntrySchema, 'output'], ArtifactTags: [ArtifactTagsSchema, 'output'],
38
39
  TestCatalog: [TestCatalogSchema, 'output'], CatalogHealth: [CatalogHealthSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], RunList: [RunListSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
39
40
  SpecIndex: [SpecIndexSchema, 'output'], ReportSources: [ReportSourcesSchema, 'output'], RunsIndex: [RunsIndexSchema, 'output'],
40
41
  RunEvent: [LiveRunSchema, 'output'], HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
@@ -271,6 +272,8 @@ const BASE_OPERATIONS = [
271
272
  { name: 'domain', in: 'query', description: 'Only tests whose spec is in this domain (`(no spec)` for none). Repeatable or comma-separated.', schema: str },
272
273
  { name: 'spec', in: 'query', description: 'Only tests of this spec path. Repeatable or comma-separated.', schema: str, example: 'specs/checkout/pay.feature' },
273
274
  { name: 'tag', in: 'query', description: 'Only tests with this tag. Repeatable or comma-separated.', schema: str },
275
+ { name: 'artifactTag', in: 'query', description: 'Only tests with an attachment tagged this (`demo` and `#demo` are the same tag), not counting tags inherited from a check or run: the exact artifact query. `inherited=1` widens it to the tags inherited from the check and run. Repeatable or comma-separated.', schema: str, example: 'demo' },
276
+ { name: 'inherited', in: 'query', description: 'With `artifactTag`: `1` also matches the tags an attachment inherits from its check and run, so a recording without a tag of its own under a run tagged `demo` is found. Without it the query is exact.', schema: { type: 'string', enum: ['1', 'true'] } },
274
277
  { name: 'run', in: 'query', description: 'Only tests recorded in this run (a `key` from `/api/reports/runs`).', schema: str },
275
278
  { name: 'sort', in: 'query', description: 'The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.', schema: { type: 'string', enum: ['spec', 'title', 'recent', 'slowest', 'failures', 'flaky', 'runs'] } },
276
279
  { name: 'offset', in: 'query', description: 'Skip this many matching tests.', schema: { type: 'integer', minimum: 0 } },
@@ -305,6 +308,19 @@ const BASE_OPERATIONS = [
305
308
  ],
306
309
  responses: { '200': json('The test and its history.', 'TestHistory'), '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '400': problem('No `key`.'), '401': UNAUTHORIZED, '404': problem('No test in the report has that key.'), '503': NOT_READY }
307
310
  },
311
+ {
312
+ route: 'GET /api/reports/artifact-tags', path: '/api/reports/artifact-tags', operationId: 'getArtifactTags', tag: 'Report', role: 'read',
313
+ summary: 'Reviewer tags of attachments: every recorded edit',
314
+ description: 'The audit trail of tags that reviewers added to and removed from attachments after upload, oldest first. Each entry says which attachment (`target`), which normalized tags were added and removed, the name of the token that made the edit (`by`, never the token) and when (`atMs`). The layers themselves are never rewritten, so a producer\'s own `tags` on an attachment stay as uploaded; fold the entries (oldest first, the last edit of a tag wins) to get the reviewer tags that stand now. Tags are normalized: `#Demo` and `demo` are `demo`.',
315
+ responses: { '200': json('The edits.', 'ArtifactTags'), '401': UNAUTHORIZED }
316
+ },
317
+ {
318
+ route: 'POST /api/reports/artifact-tags', path: '/api/reports/artifact-tags', operationId: 'editArtifactTags', tag: 'Report', role: 'publish',
319
+ summary: 'Tag or untag one attachment after upload',
320
+ description: 'Records one edit of the reviewer tags of one attachment, by the name of the publish (or admin) token that sends it. It adds an immutable entry to the audit trail and changes nothing else: not the layer, not the movie, not any result. The tags it removes are reviewer tags only; a producer\'s tags are part of the layer and stay. A reader-only token, a browser sign-in box and an anonymous reader are refused. Name the attachment as `artifactRef` does (`id:<id>`, else `key:<bundleKey>`, else `name:<name>`).',
321
+ 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') },
322
+ 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.') }
323
+ },
308
324
  {
309
325
  route: 'GET /api/reports/runs', path: '/api/reports/runs', operationId: 'getReportRuns', tag: 'Report', role: 'read',
310
326
  summary: 'The runs that recorded the report',
package/docs/extending.md CHANGED
@@ -35,6 +35,9 @@ carry any of these. Every one is optional.
35
35
  | `extensions{}` | Structured data of your own or a tool's, under a name you own. |
36
36
  | `measurements[]` | Numbers, with units and an allowance (already part of the contract). |
37
37
 
38
+ An attachment may carry its own `tags[]` (`demo`): shown as `#demo` and searchable; see
39
+ [Tagging artifacts and demos](rich-evidence.md#tagging-artifacts-and-demos).
40
+
38
41
  On a **layer** the same fields describe the whole run: its CI log, a coverage report, an archive of traces, the
39
42
  build number, a link to the pipeline.
40
43
 
@@ -95,6 +95,14 @@ first commit, they are `null`: that is provenance missing, not an error, and nev
95
95
  result wins. `--param key=value` (repeatable) adds a parameter that makes a result its own slot, such
96
96
  as `--param device=iphone-15`.
97
97
 
98
+ Without a CI run URL, layers recorded at one branch and commit group into one run, so repeating a local demo
99
+ looks like one execution. To keep invocations apart, name each one: `--run-id <id>` (or `ATLAS_RUN_ID`), with
100
+ `--run-title <title>` (or `ATLAS_RUN_TITLE`) for a human name and `--run-started-at` / `--run-ended-at` for when
101
+ it ran. Every shard or batch of one invocation passes the same id and is one run; the next invocation passes a new
102
+ one. A run recorded from a checkout with uncommitted changes keeps saying so beside its commit. The id only
103
+ groups: it is not part of which result a check fills or which wins, and a producer that never passes it keeps
104
+ the CI-run-URL, then `branch@sha`, grouping.
105
+
98
106
  The time the checks ran comes from the report (CTRF `summary.stop`, a JUnit `timestamp`, a manifest's
99
107
  `finishedAt`), or failing that from the report file's modification time, in milliseconds. Publish in
100
108
  the same job that wrote the report and that is the time it ran. If you copy the report first, pass
@@ -86,12 +86,14 @@ atlas report — layered evidence for any kind of spec
86
86
  atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]
87
87
  ( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )
88
88
  [--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]
89
+ [--run-id <id>] [--run-title <t>] [--run-started-at <iso|ms>] [--run-ended-at <iso|ms>]
89
90
  [--spec <path> | --specs <dir>] [--upload-run]
90
91
  [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]
91
92
  [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...
92
93
  [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]
93
94
  atlas report stream [--store <dir|s3://…|https://atlas.example.com>] --producer <name> [--environment <name>]
94
95
  [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...
96
+ [--run-id <id>] [--run-title <t>]
95
97
  [--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)
96
98
  atlas report status [--store <dir|s3://…|id=url>]... [--backends <module>]
97
99
  [--specs <dir>] [--format text|json|markdown]
@@ -112,6 +114,11 @@ atlas report — layered evidence for any kind of spec
112
114
  --specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or
113
115
  file name the report gives; also publishes the specs themselves, so the
114
116
  node can show them without a checkout
117
+ --run-id <id> name this execution (a local or manual run with no CI run URL; also ATLAS_RUN_ID).
118
+ Layers that share an id are one run, however many shards or batches uploaded them;
119
+ another invocation at the same commit gets another id. Never part of what a check is
120
+ --run-title <t> a human name for the run (also ATLAS_RUN_TITLE); --run-started-at and --run-ended-at
121
+ record when it ran. Without --run-id a run is its CI run URL, else branch@sha
115
122
  --upload-run with --manifest: also upload the run's screenshots and clips for the viewer
116
123
  --attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive
117
124
  (screenshots, videos and traces of a single test go in its CTRF or checks file)
@@ -467,6 +467,8 @@ Send `format=markdown` (or `Accept: text/markdown`) for the same as a Markdown r
467
467
  - `domain` (query): Only tests whose spec is in this domain (`(no spec)` for none). Repeatable or comma-separated.
468
468
  - `spec` (query): Only tests of this spec path. Repeatable or comma-separated.
469
469
  - `tag` (query): Only tests with this tag. Repeatable or comma-separated.
470
+ - `artifactTag` (query): Only tests with an attachment tagged this (`demo` and `#demo` are the same tag), not counting tags inherited from a check or run: the exact artifact query. `inherited=1` widens it to the tags inherited from the check and run. Repeatable or comma-separated.
471
+ - `inherited` (query): With `artifactTag`: `1` also matches the tags an attachment inherits from its check and run, so a recording without a tag of its own under a run tagged `demo` is found. Without it the query is exact.
470
472
  - `run` (query): Only tests recorded in this run (a `key` from `/api/reports/runs`).
471
473
  - `sort` (query): The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.
472
474
  - `offset` (query): Skip this many matching tests.
@@ -496,6 +498,8 @@ Scoped to one project. The same project may be selected on the unprefixed API us
496
498
  - `domain` (query): Only tests whose spec is in this domain (`(no spec)` for none). Repeatable or comma-separated.
497
499
  - `spec` (query): Only tests of this spec path. Repeatable or comma-separated.
498
500
  - `tag` (query): Only tests with this tag. Repeatable or comma-separated.
501
+ - `artifactTag` (query): Only tests with an attachment tagged this (`demo` and `#demo` are the same tag), not counting tags inherited from a check or run: the exact artifact query. `inherited=1` widens it to the tags inherited from the check and run. Repeatable or comma-separated.
502
+ - `inherited` (query): With `artifactTag`: `1` also matches the tags an attachment inherits from its check and run, so a recording without a tag of its own under a run tagged `demo` is found. Without it the query is exact.
499
503
  - `run` (query): Only tests recorded in this run (a `key` from `/api/reports/runs`).
500
504
  - `sort` (query): The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.
501
505
  - `offset` (query): Skip this many matching tests.
@@ -564,6 +568,50 @@ Scoped to one project. The same project may be selected on the unprefixed API us
564
568
 
565
569
  Responses: `200` The test and its history.; `304` Unchanged since the `If-None-Match` ETag.; `400` No `key`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No test in the report has that key.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
566
570
 
571
+ ### Reviewer tags of attachments: every recorded edit
572
+
573
+ `GET /api/reports/artifact-tags` · role: read
574
+
575
+ The audit trail of tags that reviewers added to and removed from attachments after upload, oldest first. Each entry says which attachment (`target`), which normalized tags were added and removed, the name of the token that made the edit (`by`, never the token) and when (`atMs`). The layers themselves are never rewritten, so a producer's own `tags` on an attachment stay as uploaded; fold the entries (oldest first, the last edit of a tag wins) to get the reviewer tags that stand now. Tags are normalized: `#Demo` and `demo` are `demo`.
576
+
577
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
578
+
579
+ Responses: `200` The edits.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.
580
+
581
+ ### Reviewer tags of attachments: every recorded edit
582
+
583
+ `GET /api/projects/{project}/reports/artifact-tags` · role: read
584
+
585
+ The audit trail of tags that reviewers added to and removed from attachments after upload, oldest first. Each entry says which attachment (`target`), which normalized tags were added and removed, the name of the token that made the edit (`by`, never the token) and when (`atMs`). The layers themselves are never rewritten, so a producer's own `tags` on an attachment stay as uploaded; fold the entries (oldest first, the last edit of a tag wins) to get the reviewer tags that stand now. Tags are normalized: `#Demo` and `demo` are `demo`.
586
+
587
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
588
+
589
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
590
+
591
+ Responses: `200` The edits.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.
592
+
593
+ ### Tag or untag one attachment after upload
594
+
595
+ `POST /api/reports/artifact-tags` · role: publish
596
+
597
+ Records one edit of the reviewer tags of one attachment, by the name of the publish (or admin) token that sends it. It adds an immutable entry to the audit trail and changes nothing else: not the layer, not the movie, not any result. The tags it removes are reviewer tags only; a producer's tags are part of the layer and stay. A reader-only token, a browser sign-in box and an anonymous reader are refused. Name the attachment as `artifactRef` does (`id:<id>`, else `key:<bundleKey>`, else `name:<name>`).
598
+
599
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
600
+
601
+ Responses: `201` The recorded entry.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `413` Request too large.; `422` Not a tag edit: a bad target, an empty or over-long tag, or nothing to change.
602
+
603
+ ### Tag or untag one attachment after upload
604
+
605
+ `POST /api/projects/{project}/reports/artifact-tags` · role: publish
606
+
607
+ Records one edit of the reviewer tags of one attachment, by the name of the publish (or admin) token that sends it. It adds an immutable entry to the audit trail and changes nothing else: not the layer, not the movie, not any result. The tags it removes are reviewer tags only; a producer's tags are part of the layer and stay. A reader-only token, a browser sign-in box and an anonymous reader are refused. Name the attachment as `artifactRef` does (`id:<id>`, else `key:<bundleKey>`, else `name:<name>`).
608
+
609
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
610
+
611
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
612
+
613
+ Responses: `201` The recorded entry.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `413` Request too large.; `422` Not a tag edit: a bad target, an empty or over-long tag, or nothing to change.
614
+
567
615
  ### The runs that recorded the report
568
616
 
569
617
  `GET /api/reports/runs` · role: read