@crvouga/atlas 0.14.0 → 0.16.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 +12 -0
  2. package/dist/reports/src/artifact-tag-store.d.ts +20 -0
  3. package/dist/reports/src/artifact-tag-store.js +70 -0
  4. package/dist/reports/src/artifact-tags.d.ts +96 -0
  5. package/dist/reports/src/artifact-tags.js +96 -0
  6. package/dist/reports/src/evidence-detail.d.ts +20 -3
  7. package/dist/reports/src/evidence-detail.js +66 -13
  8. package/dist/reports/src/evidence-types.d.ts +33 -0
  9. package/dist/reports/src/layer-merge.js +2 -2
  10. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  11. package/dist/reports/src/reports-route.d.ts +8 -0
  12. package/dist/reports/src/test-catalog.d.ts +35 -26
  13. package/dist/reports/src/test-catalog.js +79 -15
  14. package/dist/schema/src/api.d.ts +246 -0
  15. package/dist/schema/src/api.js +59 -5
  16. package/dist/schema/src/layer.d.ts +33 -0
  17. package/dist/schema/src/layer.js +11 -1
  18. package/dist/server/src/app.js +35 -3
  19. package/dist/server/src/llms.js +2 -1
  20. package/dist/server/src/openapi.d.ts +1 -1
  21. package/dist/server/src/openapi.js +20 -3
  22. package/docs/extending.md +3 -0
  23. package/docs/reference/http-api.md +52 -2
  24. package/docs/reference/reports.md +327 -5
  25. package/docs/reference/schema.md +48 -0
  26. package/docs/rich-evidence.md +28 -0
  27. package/docs/server.md +25 -1
  28. package/llms-full.txt +108 -3
  29. package/package.json +1 -1
  30. package/packages/schema/schemas/check-detail.schema.json +28 -0
  31. package/packages/schema/schemas/effective-delta.schema.json +96 -0
  32. package/packages/schema/schemas/effective-evidence.schema.json +96 -0
  33. package/packages/schema/schemas/evidence-page.schema.json +48 -0
  34. package/packages/schema/schemas/layer-rows.schema.json +48 -0
  35. package/packages/schema/schemas/layer.schema.json +70 -0
  36. package/packages/schema/schemas/publish-checks.schema.json +35 -0
  37. package/packages/schema/schemas/report-run.schema.json +71 -0
  38. package/packages/schema/schemas/report-runs.schema.json +38 -1
  39. package/packages/schema/schemas/test-catalog.schema.json +83 -0
  40. package/packages/schema/schemas/test-history.schema.json +110 -0
  41. package/packages/server/openapi.json +1259 -33
  42. package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
  43. package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
  44. package/packages/visualizer/src/explorer/RunsView.tsx +128 -10
  45. package/packages/visualizer/src/explorer/catalog.tsx +16 -6
  46. package/packages/visualizer/src/explorer/search.ts +4 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 0.16.0 (2026-10-28)
4
+
5
+ ### Features
6
+
7
+ - show video availability and filter runs with recordings ([dece195](https://github.com/crvouga/atlas/commit/dece195ef419a3fc6eaedb643544be29ffb341c3))
8
+
9
+ ## 0.15.0 (2026-10-28)
10
+
11
+ ### Features
12
+
13
+ - tag artifacts and demos with visible, searchable metadata ([833114a](https://github.com/crvouga/atlas/commit/833114ae543fa9a1bfe796b5991611efa02bd098))
14
+
3
15
  ## 0.14.0 (2026-10-28)
4
16
 
5
17
  ### Features
@@ -0,0 +1,20 @@
1
+ import type { ObjectStore } from './object-store.js';
2
+ import { type ArtifactTagEntry } from './artifact-tags.js';
3
+ /** The prefix of every recorded edit in a store. */
4
+ export declare const ARTIFACT_TAGS_PREFIX = "curation/artifact-tags/";
5
+ /** The edit was not acceptable (`status` is the HTTP status to answer). */
6
+ export declare class ArtifactTagError extends Error {
7
+ readonly status: 422;
8
+ constructor(status: 422, message: string);
9
+ }
10
+ /**
11
+ * Validates an edit (`ArtifactTagEditSchema`), normalizes its tags and records it as a new object in `store`, by
12
+ * `by` at `atMs`. Throws {@link ArtifactTagError} for a body that is not an edit, a tag that is not a tag (empty, or
13
+ * over 64 characters) or an edit that changes nothing. Recording the same edit twice records two entries.
14
+ */
15
+ export declare function recordArtifactTags(store: ObjectStore, input: unknown, by: string, atMs: number): Promise<ArtifactTagEntry>;
16
+ /**
17
+ * Every recorded edit in `store`, oldest first. An object that cannot be read as an edit is skipped (unknown, never
18
+ * guessed at). A store with none gives an empty list. Transport errors throw.
19
+ */
20
+ export declare function readArtifactTagEntries(store: ObjectStore): Promise<ArtifactTagEntry[]>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @module Reviewer tags in a store
3
+ *
4
+ * Where a reviewer's tags for attachments are kept: one small immutable object per edit under
5
+ * `curation/artifact-tags/`, named by time and a random id so editors never overwrite one another, and never changed
6
+ * or deleted afterwards. The layers they are about are not touched. The list of edits is the audit trail: who changed
7
+ * which tags of which attachment, and when. `foldArtifactTags` turns it into the tags that stand now.
8
+ *
9
+ * @see docs/server.md#tagging-artifacts
10
+ */
11
+ import { ArtifactTagEntrySchema, ArtifactTagEditSchema } from '../../schema/src/api.js';
12
+ import { readUtf8, writeUtf8 } from './object-io.js';
13
+ import { normalizeTag, normalizeTags } from './artifact-tags.js';
14
+ /** The prefix of every recorded edit in a store. */
15
+ export const ARTIFACT_TAGS_PREFIX = 'curation/artifact-tags/';
16
+ /** The edit was not acceptable (`status` is the HTTP status to answer). */
17
+ export class ArtifactTagError extends Error {
18
+ status;
19
+ constructor(status, message) {
20
+ super(message);
21
+ this.status = status;
22
+ }
23
+ }
24
+ /**
25
+ * Validates an edit (`ArtifactTagEditSchema`), normalizes its tags and records it as a new object in `store`, by
26
+ * `by` at `atMs`. Throws {@link ArtifactTagError} for a body that is not an edit, a tag that is not a tag (empty, or
27
+ * over 64 characters) or an edit that changes nothing. Recording the same edit twice records two entries.
28
+ */
29
+ export async function recordArtifactTags(store, input, by, atMs) {
30
+ const parsed = ArtifactTagEditSchema.safeParse(input);
31
+ if (!parsed.success)
32
+ throw new ArtifactTagError(422, `Not a tag edit: ${parsed.error.issues[0]?.path.join('.') || '(body)'}: ${parsed.error.issues[0]?.message ?? 'invalid'}`);
33
+ const { target, add = [], remove = [] } = parsed.data;
34
+ if ([...add, ...remove].some(tag => normalizeTag(tag) === null))
35
+ throw new ArtifactTagError(422, 'A tag is empty or longer than 64 characters.');
36
+ const added = normalizeTags(add);
37
+ const removed = normalizeTags(remove);
38
+ const entryAdd = added.filter(tag => !removed.includes(tag));
39
+ const entryRemove = removed.filter(tag => !added.includes(tag));
40
+ if (!entryAdd.length && !entryRemove.length)
41
+ throw new ArtifactTagError(422, 'Name at least one tag to add or remove.');
42
+ const id = `${String(atMs).padStart(16, '0')}-${globalThis.crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
43
+ const entry = {
44
+ id, by, atMs,
45
+ target: { layer: target.layer, check: target.check, ...(target.kind === undefined ? {} : { kind: target.kind }), artifact: target.artifact },
46
+ add: entryAdd, remove: entryRemove
47
+ };
48
+ await writeUtf8(store, `${ARTIFACT_TAGS_PREFIX}${id}.json`, JSON.stringify({ schemaVersion: 1, ...entry }), 'application/json');
49
+ return entry;
50
+ }
51
+ /**
52
+ * Every recorded edit in `store`, oldest first. An object that cannot be read as an edit is skipped (unknown, never
53
+ * guessed at). A store with none gives an empty list. Transport errors throw.
54
+ */
55
+ export async function readArtifactTagEntries(store) {
56
+ const keys = (await store.listKeys(ARTIFACT_TAGS_PREFIX)).filter(key => key.endsWith('.json'));
57
+ const read = await Promise.all(keys.map(async (key) => {
58
+ const text = await readUtf8(store, key);
59
+ if (text === null)
60
+ return [];
61
+ try {
62
+ const parsed = ArtifactTagEntrySchema.safeParse(JSON.parse(text));
63
+ return parsed.success ? [parsed.data] : [];
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }));
69
+ return read.flat().sort((a, b) => a.atMs - b.atMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
70
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @module Artifact tags
3
+ *
4
+ * The tags of one attachment (a recording, a screenshot, a trace): how a tag is normalized so `demo` and `#demo`
5
+ * are the same tag, which tags an attachment shows (its producer's, its reviewers', and those it inherits from its
6
+ * check and run), and how a reviewer's edits fold into the tags that stand now. Pure functions, safe in a browser.
7
+ *
8
+ * Tags describe evidence; they never decide a status, a slot or a winner. A producer writes `tags` on the attachment
9
+ * inside an immutable layer. A reviewer's tags live apart, as an append-only list of edits that the server records
10
+ * with who made each and when (`artifact-tag-store.ts`); the layer is never rewritten.
11
+ *
12
+ * @see docs/rich-evidence.md#tagging-artifacts-and-demos
13
+ */
14
+ import type { Attachment } from '../../schema/src/layer.js';
15
+ /** The longest a normalized tag may be, in characters. */
16
+ export declare const MAX_TAG_LENGTH = 64;
17
+ /**
18
+ * Normalizes a tag so equal tags compare equal: Unicode NFC, any leading `#` removed, surrounding whitespace
19
+ * trimmed, letters lower-cased and each run of whitespace inside turned into one `-`. Every other character is kept,
20
+ * so `Smoke`, `#smoke` and ` smoke ` are `smoke`, and `team/payments` or `release=2` stay as they are. Returns
21
+ * `null` for a tag that is empty after that (`''`, `'#'`, `' '`) or longer than {@link MAX_TAG_LENGTH}.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * normalizeTag('#Demo'); // 'demo'
26
+ * normalizeTag('release candidate'); // 'release-candidate'
27
+ * normalizeTag('#'); // null
28
+ * ```
29
+ */
30
+ export declare function normalizeTag(raw: string): string | null;
31
+ /** Normalizes every tag with {@link normalizeTag}, dropping the ones that are not tags and the repeats; the first of each keeps its place. */
32
+ export declare function normalizeTags(raw: Iterable<string> | undefined): string[];
33
+ /** The tag as a person sees it: `#demo`. */
34
+ export declare const displayTag: (tag: string) => string;
35
+ /**
36
+ * How an edit names an attachment: `id:<id>` when it has an `id`, else `key:<bundleKey>` (its bytes), else
37
+ * `name:<name>`. It is stable for a layer, which never changes. Two attachments in one check with the same bytes and
38
+ * no `id` share a reference, and so share reviewer tags; give them an `id` to tag them apart.
39
+ */
40
+ export declare function artifactRef(attachment: Pick<Attachment, 'id' | 'bundleKey' | 'name'>): string;
41
+ /** Where a reviewer's tags are about: one attachment of one check of one archived layer. */
42
+ export type ArtifactTarget = {
43
+ readonly layer: string;
44
+ readonly check: string;
45
+ readonly kind?: string | undefined;
46
+ readonly artifact: string;
47
+ };
48
+ /** One recorded edit. Entries are never changed or removed. */
49
+ export type ArtifactTagEntry = {
50
+ readonly id: string;
51
+ readonly target: ArtifactTarget;
52
+ readonly add: readonly string[];
53
+ readonly remove: readonly string[];
54
+ readonly by: string;
55
+ readonly atMs: number;
56
+ };
57
+ /** A string that is equal for equal targets (the part `kind` is optional and then does not matter). */
58
+ export declare const targetKey: (target: ArtifactTarget) => string;
59
+ /** A tag a reviewer added: who added it last, and when. */
60
+ export type ReviewerTag = {
61
+ readonly tag: string;
62
+ readonly by: string;
63
+ readonly atMs: number;
64
+ };
65
+ /**
66
+ * Folds the recorded edits (in any order) into the reviewer tags that stand now, by target (see {@link targetKey}).
67
+ * Edits apply oldest first, ties by `id`; a tag added, removed and added again stands, with the time and author of
68
+ * the last add. A target whose tags were all removed is absent. Entries that are not understood are ignored.
69
+ */
70
+ export declare function foldArtifactTags(entries: readonly ArtifactTagEntry[]): Map<string, ReviewerTag[]>;
71
+ /** Where a shown tag comes from: the attachment's own producer (`artifact`), a reviewer after the upload (`reviewer`), or the check or run around it (`inherited`). */
72
+ export type TagSource = 'artifact' | 'reviewer' | 'inherited';
73
+ /** One tag an attachment shows, with its source and, for a reviewer's tag, who added it and when. */
74
+ export type ArtifactTag = {
75
+ readonly tag: string;
76
+ readonly source: TagSource;
77
+ readonly by?: string;
78
+ readonly atMs?: number;
79
+ };
80
+ /**
81
+ * The tags one attachment shows, normalized and without repeats: its producer's own (`artifact`), then the
82
+ * reviewers' (`reviewer`), then those of its check and run that it does not have itself (`inherited`). A tag it has
83
+ * more than once is shown once, by the first source above.
84
+ */
85
+ export declare function artifactTagsOf(attachment: Pick<Attachment, 'tags'>, options?: {
86
+ readonly reviewer?: readonly ReviewerTag[];
87
+ readonly inherited?: Iterable<string>;
88
+ }): ArtifactTag[];
89
+ /**
90
+ * Does an attachment match a tag (either spelling)? An exact query (`inherited: false`, the default) matches only the
91
+ * tags the attachment has itself, from its producer or a reviewer; an inherited query also matches the tags it
92
+ * inherits from its check and run. The two differ for a recording that is untagged under a tagged run.
93
+ */
94
+ export declare function hasArtifactTag(tags: readonly ArtifactTag[], wanted: string, options?: {
95
+ readonly inherited?: boolean;
96
+ }): boolean;
@@ -0,0 +1,96 @@
1
+ /** The longest a normalized tag may be, in characters. */
2
+ export const MAX_TAG_LENGTH = 64;
3
+ /**
4
+ * Normalizes a tag so equal tags compare equal: Unicode NFC, any leading `#` removed, surrounding whitespace
5
+ * trimmed, letters lower-cased and each run of whitespace inside turned into one `-`. Every other character is kept,
6
+ * so `Smoke`, `#smoke` and ` smoke ` are `smoke`, and `team/payments` or `release=2` stay as they are. Returns
7
+ * `null` for a tag that is empty after that (`''`, `'#'`, `' '`) or longer than {@link MAX_TAG_LENGTH}.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * normalizeTag('#Demo'); // 'demo'
12
+ * normalizeTag('release candidate'); // 'release-candidate'
13
+ * normalizeTag('#'); // null
14
+ * ```
15
+ */
16
+ export function normalizeTag(raw) {
17
+ if (typeof raw !== 'string')
18
+ return null;
19
+ const tag = raw.normalize('NFC').trim().replace(/^#+/, '').trim().toLowerCase().replace(/\s+/g, '-');
20
+ return tag.length > 0 && tag.length <= MAX_TAG_LENGTH ? tag : null;
21
+ }
22
+ /** Normalizes every tag with {@link normalizeTag}, dropping the ones that are not tags and the repeats; the first of each keeps its place. */
23
+ export function normalizeTags(raw) {
24
+ const seen = new Set();
25
+ for (const value of raw ?? []) {
26
+ const tag = normalizeTag(value);
27
+ if (tag !== null)
28
+ seen.add(tag);
29
+ }
30
+ return [...seen];
31
+ }
32
+ /** The tag as a person sees it: `#demo`. */
33
+ export const displayTag = (tag) => `#${tag}`;
34
+ /**
35
+ * How an edit names an attachment: `id:<id>` when it has an `id`, else `key:<bundleKey>` (its bytes), else
36
+ * `name:<name>`. It is stable for a layer, which never changes. Two attachments in one check with the same bytes and
37
+ * no `id` share a reference, and so share reviewer tags; give them an `id` to tag them apart.
38
+ */
39
+ export function artifactRef(attachment) {
40
+ if (attachment.id)
41
+ return `id:${attachment.id}`;
42
+ if (attachment.bundleKey)
43
+ return `key:${attachment.bundleKey}`;
44
+ return `name:${attachment.name}`;
45
+ }
46
+ /** A string that is equal for equal targets (the part `kind` is optional and then does not matter). */
47
+ export const targetKey = (target) => JSON.stringify([target.layer, target.check, target.kind ?? '', target.artifact]);
48
+ /**
49
+ * Folds the recorded edits (in any order) into the reviewer tags that stand now, by target (see {@link targetKey}).
50
+ * Edits apply oldest first, ties by `id`; a tag added, removed and added again stands, with the time and author of
51
+ * the last add. A target whose tags were all removed is absent. Entries that are not understood are ignored.
52
+ */
53
+ export function foldArtifactTags(entries) {
54
+ const ordered = [...entries].sort((a, b) => a.atMs - b.atMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
55
+ const standing = new Map();
56
+ for (const entry of ordered) {
57
+ const key = targetKey(entry.target);
58
+ const tags = standing.get(key) ?? new Map();
59
+ for (const tag of entry.remove)
60
+ tags.delete(tag);
61
+ for (const tag of entry.add)
62
+ tags.set(tag, { tag, by: entry.by, atMs: entry.atMs });
63
+ standing.set(key, tags);
64
+ }
65
+ const out = new Map();
66
+ for (const [key, tags] of standing)
67
+ if (tags.size)
68
+ out.set(key, [...tags.values()]);
69
+ return out;
70
+ }
71
+ /**
72
+ * The tags one attachment shows, normalized and without repeats: its producer's own (`artifact`), then the
73
+ * reviewers' (`reviewer`), then those of its check and run that it does not have itself (`inherited`). A tag it has
74
+ * more than once is shown once, by the first source above.
75
+ */
76
+ export function artifactTagsOf(attachment, options = {}) {
77
+ const out = new Map();
78
+ for (const tag of normalizeTags(attachment.tags))
79
+ out.set(tag, { tag, source: 'artifact' });
80
+ for (const found of options.reviewer ?? [])
81
+ if (!out.has(found.tag))
82
+ out.set(found.tag, { tag: found.tag, source: 'reviewer', by: found.by, atMs: found.atMs });
83
+ for (const tag of normalizeTags(options.inherited))
84
+ if (!out.has(tag))
85
+ out.set(tag, { tag, source: 'inherited' });
86
+ return [...out.values()];
87
+ }
88
+ /**
89
+ * Does an attachment match a tag (either spelling)? An exact query (`inherited: false`, the default) matches only the
90
+ * tags the attachment has itself, from its producer or a reviewer; an inherited query also matches the tags it
91
+ * inherits from its check and run. The two differ for a recording that is untagged under a tagged run.
92
+ */
93
+ export function hasArtifactTag(tags, wanted, options = {}) {
94
+ const tag = normalizeTag(wanted);
95
+ return tag !== null && tags.some(item => item.tag === tag && (options.inherited === true || item.source !== 'inherited'));
96
+ }
@@ -10,7 +10,7 @@
10
10
  * @see docs/report-contract.md (evidence)
11
11
  */
12
12
  import type { Annotation, Attachment, Attempt, Check, Step } from '../../schema/src/layer.js';
13
- import type { EvidenceSummary } from './evidence-types.js';
13
+ import type { EvidenceSummary, VideoCounts, VideoRef } from './evidence-types.js';
14
14
  /** Anything that may carry evidence: a layer, a part or a check. */
15
15
  export type EvidenceHolder = {
16
16
  readonly tags?: readonly string[];
@@ -33,10 +33,27 @@ export type EvidenceHolder = {
33
33
  *
34
34
  * Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
35
35
  * attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
36
- * label the later holder wins. `roles` are the attachments' roles (or, without one, what their media type or file
37
- * name implies), sorted and distinct.
36
+ * label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
37
+ * name implies), sorted and distinct. `videos` lists every attachment whose effective media type is video, with the
38
+ * check, retry attempt or step that holds it (see {@link isVideoAttachment}).
38
39
  */
39
40
  export declare function summarizeEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
41
+ /**
42
+ * Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
43
+ * part is owned by the `layer`, not by a check.
44
+ */
45
+ export declare function summarizeLayerEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
46
+ /**
47
+ * Whether an attachment is a video, by its effective media type: the producer's `contentType`, else the one its file
48
+ * name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
49
+ * settles a file whose type nothing says (`application/octet-stream`).
50
+ */
51
+ export declare function isVideoAttachment(attachment: Pick<Attachment, 'name' | 'role' | 'contentType'>): boolean;
52
+ /**
53
+ * Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
54
+ * "no video" and "nothing known about video" look the same on the wire.
55
+ */
56
+ export declare function videoCounts(videos: readonly VideoRef[] | undefined): VideoCounts | undefined;
40
57
  /** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
41
58
  export declare function roleOfType(contentType: string | undefined): string;
42
59
  /** All of one result's evidence, read when someone opens it. */
@@ -1,3 +1,4 @@
1
+ import { normalizeTags } from './artifact-tags.js';
1
2
  import { contentTypeOf } from './media-types.js';
2
3
  function count(holder) {
3
4
  return (holder.attachments?.length ?? 0) + (holder.steps?.length ?? 0) + (holder.annotations?.length ?? 0) + (holder.attempts?.length ?? 0) + (holder.tags?.length ?? 0) + (holder.links?.length ?? 0) + Object.keys(holder.labels ?? {}).length + Object.keys(holder.extensions ?? {}).length;
@@ -9,29 +10,49 @@ function count(holder) {
9
10
  *
10
11
  * Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
11
12
  * attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
12
- * label the later holder wins. `roles` are the attachments' roles (or, without one, what their media type or file
13
- * name implies), sorted and distinct.
13
+ * label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
14
+ * name implies), sorted and distinct. `videos` lists every attachment whose effective media type is video, with the
15
+ * check, retry attempt or step that holds it (see {@link isVideoAttachment}).
14
16
  */
15
17
  export function summarizeEvidence(...holders) {
18
+ return summarize(holders, 'check');
19
+ }
20
+ /**
21
+ * Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
22
+ * part is owned by the `layer`, not by a check.
23
+ */
24
+ export function summarizeLayerEvidence(...holders) {
25
+ return summarize(holders, 'layer');
26
+ }
27
+ function summarize(holders, owner) {
16
28
  if (!holders.some(holder => count(holder) > 0))
17
29
  return undefined;
18
30
  const roles = new Set();
19
31
  const tags = new Set();
32
+ const artifactTags = new Set();
20
33
  const labels = {};
34
+ const videos = [];
21
35
  let attachments = 0, steps = 0, annotations = 0, attempts = 0, links = 0;
22
- const attachmentsOf = (list) => { for (const item of list ?? []) {
23
- attachments += 1;
24
- roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
25
- } };
36
+ const attachmentsOf = (list, by, where = {}) => {
37
+ for (const item of list ?? []) {
38
+ attachments += 1;
39
+ for (const tag of normalizeTags(item.tags))
40
+ artifactTags.add(tag);
41
+ roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
42
+ const video = videoOf(item, by, where);
43
+ if (video)
44
+ videos.push(video);
45
+ }
46
+ };
47
+ const stepsOf = (list, attempt) => { for (const step of list ?? [])
48
+ attachmentsOf(step.attachments, 'step', { ...(attempt === undefined ? {} : { attempt }), step: step.name }); };
26
49
  for (const holder of holders) {
27
- attachmentsOf(holder.attachments);
50
+ attachmentsOf(holder.attachments, owner);
28
51
  // Attachments on steps and attempts are part of the check too.
29
- for (const step of holder.steps ?? [])
30
- attachmentsOf(step.attachments);
52
+ stepsOf(holder.steps);
31
53
  for (const attempt of holder.attempts ?? []) {
32
- attachmentsOf(attempt.attachments);
33
- for (const step of attempt.steps ?? [])
34
- attachmentsOf(step.attachments);
54
+ attachmentsOf(attempt.attachments, 'attempt', { attempt: attempt.index });
55
+ stepsOf(attempt.steps, attempt.index);
35
56
  }
36
57
  steps += holder.steps?.length ?? 0;
37
58
  annotations += holder.annotations?.length ?? 0;
@@ -41,7 +62,39 @@ export function summarizeEvidence(...holders) {
41
62
  tags.add(tag);
42
63
  Object.assign(labels, holder.labels);
43
64
  }
44
- return { attachments, steps, annotations, attempts, links, tags: [...tags], labels, roles: [...roles].sort() };
65
+ return { attachments, steps, annotations, attempts, links, tags: [...tags], ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}), labels, roles: [...roles].sort(), ...(videos.length ? { videos } : {}) };
66
+ }
67
+ /**
68
+ * Whether an attachment is a video, by its effective media type: the producer's `contentType`, else the one its file
69
+ * name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
70
+ * settles a file whose type nothing says (`application/octet-stream`).
71
+ */
72
+ export function isVideoAttachment(attachment) {
73
+ const type = (attachment.contentType ?? contentTypeOf(attachment.name)).toLowerCase();
74
+ return type.startsWith('video/') || (attachment.role === 'video' && type.startsWith('application/octet-stream'));
75
+ }
76
+ function videoOf(item, owner, where) {
77
+ if (!isVideoAttachment(item))
78
+ return undefined;
79
+ const recording = item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
80
+ return { name: item.name, owner, ...where, ...(recording === undefined ? {} : { recording }), available: recording !== undefined };
81
+ }
82
+ /**
83
+ * Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
84
+ * "no video" and "nothing known about video" look the same on the wire.
85
+ */
86
+ export function videoCounts(videos) {
87
+ if (!videos?.length)
88
+ return undefined;
89
+ const distinct = new Set();
90
+ let unavailable = 0;
91
+ for (const video of videos) {
92
+ if (video.available && video.recording !== undefined)
93
+ distinct.add(video.recording);
94
+ else
95
+ unavailable += 1;
96
+ }
97
+ return { references: videos.length, recordings: distinct.size, unavailable };
45
98
  }
46
99
  /** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
47
100
  export function roleOfType(contentType) {
@@ -36,6 +36,35 @@ export type BackendInfo = {
36
36
  };
37
37
  /** How many results have each status (`passed`, `failed`, `running`, `not-run`). */
38
38
  export type StatusCounts = Readonly<Record<EvidenceStatus, number>>;
39
+ /** Who holds a recording: the whole layer (or one of its parts), one check, one retry attempt of a check, or one step. */
40
+ export type VideoOwner = 'layer' | 'check' | 'attempt' | 'step';
41
+ /**
42
+ * One reference to a video, found by its effective media type (the producer's `contentType`, else the one the file
43
+ * name implies; a `video` role alone counts only when nothing else says what the file is). A reference is not a
44
+ * recording: the same bytes referenced from three checks are three references and one recording.
45
+ */
46
+ export type VideoRef = {
47
+ /** The attachment's name, for a person. */
48
+ readonly name: string;
49
+ readonly owner: VideoOwner;
50
+ /** The try that holds it, when `owner` is `attempt` or the step is inside one. */
51
+ readonly attempt?: number;
52
+ /** The name of the step that holds it, when `owner` is `step`. */
53
+ readonly step?: string;
54
+ /** What identifies the bytes (digest, bundle key or address); references with the same one are the same recording. Absent when `available` is false. */
55
+ readonly recording?: string;
56
+ /** False when the reference names no bytes at all (no bundle key, address or inline text), so it can never be played. */
57
+ readonly available: boolean;
58
+ };
59
+ /** Video availability as counts: references, distinct playable recordings (deduplicated), and references that cannot be played. */
60
+ export type VideoCounts = {
61
+ /** Every reference to a video, playable or not. */
62
+ readonly references: number;
63
+ /** Distinct playable recordings: identical bytes referenced twice are one. */
64
+ readonly recordings: number;
65
+ /** References that name no bytes. A report that predates availability metadata has none of these and none of the others. */
66
+ readonly unavailable: number;
67
+ };
39
68
  /**
40
69
  * What a result (or a run) carries beyond its outcome, as counts and short facts: attachments, steps,
41
70
  * annotations, retries, tags, labels, links. It is only there when there is something. The evidence itself
@@ -50,9 +79,13 @@ export type EvidenceSummary = {
50
79
  readonly attempts: number;
51
80
  readonly links: number;
52
81
  readonly tags: readonly string[];
82
+ /** The normalized tags of the attachments themselves (`#demo` is `demo`), sorted and distinct. Absent when no attachment is tagged. Unlike `tags` they are never inherited from a check or run. */
83
+ readonly artifactTags?: readonly string[];
53
84
  readonly labels: Readonly<Record<string, string>>;
54
85
  /** The distinct roles of its attachments (or what their media types say), to show what kind of evidence there is. */
55
86
  readonly roles: readonly string[];
87
+ /** Its references to videos, wherever they are held (layer, check, attempt, step). Only there when it has any. */
88
+ readonly videos?: readonly VideoRef[];
56
89
  };
57
90
  /** A report layer's origin and totals. Its checks are read separately, once. */
58
91
  export type LayerHeader = {
@@ -1,4 +1,4 @@
1
- import { summarizeEvidence } from './evidence-detail.js';
1
+ import { summarizeEvidence, summarizeLayerEvidence } from './evidence-detail.js';
2
2
  import { layerPlace } from './layer-axes.js';
3
3
  /**
4
4
  * A short, deterministic fingerprint of a spec's text, shaped `<length>:<hash>:<hash>` (the length in UTF-16 code
@@ -94,7 +94,7 @@ export function layerEvidence(summary, backend) {
94
94
  }
95
95
  }
96
96
  const recording = summary.recording;
97
- const layerSummaryEvidence = summarizeEvidence(summary, ...summary.parts);
97
+ const layerSummaryEvidence = summarizeLayerEvidence(summary, ...summary.parts);
98
98
  const layer = {
99
99
  ...(summary.freshness ? { freshness: summary.freshness } : {}),
100
100
  ...(summary.run ? { run: summary.run } : {}),
@@ -82,6 +82,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
82
82
  height?: number | undefined;
83
83
  durationMs?: number | undefined;
84
84
  description?: string | undefined;
85
+ tags?: string[] | undefined;
85
86
  at?: {
86
87
  [x: string]: import("hono/utils/types").JSONValue;
87
88
  step?: string | undefined;
@@ -125,6 +126,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
125
126
  height?: number | undefined;
126
127
  durationMs?: number | undefined;
127
128
  description?: string | undefined;
129
+ tags?: string[] | undefined;
128
130
  at?: {
129
131
  [x: string]: import("hono/utils/types").JSONValue;
130
132
  step?: string | undefined;
@@ -181,6 +183,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
181
183
  height?: number | undefined;
182
184
  durationMs?: number | undefined;
183
185
  description?: string | undefined;
186
+ tags?: string[] | undefined;
184
187
  at?: {
185
188
  [x: string]: import("hono/utils/types").JSONValue;
186
189
  step?: string | undefined;
@@ -247,6 +250,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
247
250
  height?: number | undefined;
248
251
  durationMs?: number | undefined;
249
252
  description?: string | undefined;
253
+ tags?: string[] | undefined;
250
254
  at?: {
251
255
  [x: string]: import("hono/utils/types").JSONValue;
252
256
  step?: string | undefined;
@@ -352,6 +356,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
352
356
  height?: number | undefined;
353
357
  durationMs?: number | undefined;
354
358
  description?: string | undefined;
359
+ tags?: string[] | undefined;
355
360
  at?: {
356
361
  [x: string]: import("hono/utils/types").JSONValue;
357
362
  step?: string | undefined;
@@ -395,6 +400,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
395
400
  height?: number | undefined;
396
401
  durationMs?: number | undefined;
397
402
  description?: string | undefined;
403
+ tags?: string[] | undefined;
398
404
  at?: {
399
405
  [x: string]: import("hono/utils/types").JSONValue;
400
406
  step?: string | undefined;
@@ -451,6 +457,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
451
457
  height?: number | undefined;
452
458
  durationMs?: number | undefined;
453
459
  description?: string | undefined;
460
+ tags?: string[] | undefined;
454
461
  at?: {
455
462
  [x: string]: import("hono/utils/types").JSONValue;
456
463
  step?: string | undefined;
@@ -517,6 +524,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
517
524
  height?: number | undefined;
518
525
  durationMs?: number | undefined;
519
526
  description?: string | undefined;
527
+ tags?: string[] | undefined;
520
528
  at?: {
521
529
  [x: string]: import("hono/utils/types").JSONValue;
522
530
  step?: string | undefined;
@@ -120,6 +120,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
120
120
  height?: number | undefined;
121
121
  durationMs?: number | undefined;
122
122
  description?: string | undefined;
123
+ tags?: string[] | undefined;
123
124
  at?: {
124
125
  [x: string]: import("hono/utils/types").JSONValue;
125
126
  step?: string | undefined;
@@ -163,6 +164,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
163
164
  height?: number | undefined;
164
165
  durationMs?: number | undefined;
165
166
  description?: string | undefined;
167
+ tags?: string[] | undefined;
166
168
  at?: {
167
169
  [x: string]: import("hono/utils/types").JSONValue;
168
170
  step?: string | undefined;
@@ -219,6 +221,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
219
221
  height?: number | undefined;
220
222
  durationMs?: number | undefined;
221
223
  description?: string | undefined;
224
+ tags?: string[] | undefined;
222
225
  at?: {
223
226
  [x: string]: import("hono/utils/types").JSONValue;
224
227
  step?: string | undefined;
@@ -285,6 +288,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
285
288
  height?: number | undefined;
286
289
  durationMs?: number | undefined;
287
290
  description?: string | undefined;
291
+ tags?: string[] | undefined;
288
292
  at?: {
289
293
  [x: string]: import("hono/utils/types").JSONValue;
290
294
  step?: string | undefined;
@@ -390,6 +394,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
390
394
  height?: number | undefined;
391
395
  durationMs?: number | undefined;
392
396
  description?: string | undefined;
397
+ tags?: string[] | undefined;
393
398
  at?: {
394
399
  [x: string]: import("hono/utils/types").JSONValue;
395
400
  step?: string | undefined;
@@ -433,6 +438,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
433
438
  height?: number | undefined;
434
439
  durationMs?: number | undefined;
435
440
  description?: string | undefined;
441
+ tags?: string[] | undefined;
436
442
  at?: {
437
443
  [x: string]: import("hono/utils/types").JSONValue;
438
444
  step?: string | undefined;
@@ -489,6 +495,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
489
495
  height?: number | undefined;
490
496
  durationMs?: number | undefined;
491
497
  description?: string | undefined;
498
+ tags?: string[] | undefined;
492
499
  at?: {
493
500
  [x: string]: import("hono/utils/types").JSONValue;
494
501
  step?: string | undefined;
@@ -555,6 +562,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
555
562
  height?: number | undefined;
556
563
  durationMs?: number | undefined;
557
564
  description?: string | undefined;
565
+ tags?: string[] | undefined;
558
566
  at?: {
559
567
  [x: string]: import("hono/utils/types").JSONValue;
560
568
  step?: string | undefined;