@crvouga/atlas 1.8.0 → 1.10.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 (38) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/reports/src/evidence-detail.js +6 -1
  3. package/dist/reports/src/evidence-types.d.ts +2 -0
  4. package/dist/reports/src/reliability.d.ts +181 -0
  5. package/dist/reports/src/reliability.js +294 -0
  6. package/dist/reports/src/report-index.js +1 -1
  7. package/dist/reports/src/test-catalog.d.ts +8 -2
  8. package/dist/reports/src/test-catalog.js +10 -5
  9. package/dist/schema/src/api.d.ts +226 -0
  10. package/dist/schema/src/api.js +51 -0
  11. package/dist/server/src/app.js +15 -0
  12. package/dist/server/src/llms.js +1 -0
  13. package/dist/server/src/openapi.js +25 -2
  14. package/docs/reference/http-api.md +48 -0
  15. package/docs/reference/reports.md +272 -1
  16. package/docs/reference/schema.md +11 -0
  17. package/docs/server.md +18 -0
  18. package/docs/ui.md +32 -1
  19. package/llms-full.txt +98 -1
  20. package/package.json +1 -1
  21. package/packages/schema/schemas/effective-delta.schema.json +26 -0
  22. package/packages/schema/schemas/effective-evidence.schema.json +26 -0
  23. package/packages/schema/schemas/evidence-page.schema.json +13 -0
  24. package/packages/schema/schemas/layer-rows.schema.json +13 -0
  25. package/packages/schema/schemas/test-catalog.schema.json +17 -0
  26. package/packages/schema/schemas/test-history.schema.json +34 -0
  27. package/packages/schema/schemas/test-reliability.schema.json +629 -0
  28. package/packages/server/openapi.json +1722 -568
  29. package/packages/visualizer/src/evidence/artifact-tags.tsx +1 -1
  30. package/packages/visualizer/src/explorer/ArtifactsView.tsx +11 -1
  31. package/packages/visualizer/src/explorer/Reliability.tsx +337 -0
  32. package/packages/visualizer/src/explorer/RunsView.tsx +11 -1
  33. package/packages/visualizer/src/explorer/TestDetail.tsx +3 -0
  34. package/packages/visualizer/src/explorer/catalog.tsx +1 -1
  35. package/packages/visualizer/src/explorer/playlist-entries.test.ts +51 -0
  36. package/packages/visualizer/src/explorer/playlist-entries.ts +134 -0
  37. package/packages/visualizer/src/explorer/playlist.tsx +441 -0
  38. package/packages/visualizer/src/explorer/search.ts +36 -0
@@ -0,0 +1,441 @@
1
+ /**
2
+ * @module Watch playlist
3
+ *
4
+ * A watch session over a filtered set of recorded results: the player and the plain-language scenario beside an ordered
5
+ * list of every result in the set. A failed result stays in the list with its outcome, and a result with no recording,
6
+ * an API-only one or one whose file is missing says so in words instead of being left out or shown as demonstrated.
7
+ *
8
+ * Only the clip on screen is read: the list is made of the metadata the catalog already sent, the current result's
9
+ * evidence is read when it is selected, and its video is loaded with `preload="metadata"`, so opening the playlist of a
10
+ * large library downloads no recordings in bulk. There is one `<video>`; selecting another clip replaces it and keeps
11
+ * the position of the one left, so returning resumes there. A clip that will not load (or whose autoplay the browser
12
+ * blocked) stays in the list and offers Retry, Play and Next. Recordings are always labelled illustrative: playback
13
+ * length and speed are those of the recording, never production latency.
14
+ */
15
+ import { attachmentIdentity, isVideoAttachment, type CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
16
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
17
+ import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
18
+ import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
19
+ import { IconAlertTriangle, IconPlayerPlay, IconPlayerSkipBack, IconPlayerSkipForward } from '@tabler/icons-react';
20
+ import { useQuery } from '@tanstack/react-query';
21
+ import { Link } from '@tanstack/react-router';
22
+ import { useEffect, useMemo, useRef, useState } from 'react';
23
+
24
+ import { reportJson } from '../evidence/api';
25
+ import { attachmentSource } from '../evidence/artifacts';
26
+ import { everyAttachment } from '../evidence/artifact-tags';
27
+ import { ScopeNotice } from '../evidence/evidence-scope';
28
+ import { duration, exactTime, plural, statusWord } from '../evidence/format';
29
+ import { StatusMark } from '../evidence/marks';
30
+ import { patched } from './navigate';
31
+ import { playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
32
+
33
+ /** Entries listed before "more"; the selected entry is always listed. */
34
+ const LISTED = 50;
35
+ /** Playback speeds offered. */
36
+ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
37
+ /** Step names are shown with their Gherkin keyword when they have one. */
38
+ const KEYWORDS = new Set(['given', 'when', 'then', 'and', 'but']);
39
+
40
+ /** The evidence of one entry's check, read when the entry is on screen. */
41
+ function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
42
+ return useQuery({
43
+ queryKey: ['evidence-detail', entry.backend ?? '', entry.layer, entry.kind ?? '', entry.check],
44
+ queryFn: ({ signal }) =>
45
+ reportJson<CheckDetail>(
46
+ `/evidence/detail/${encodeURIComponent(entry.backend!)}/${encodeURIComponent(entry.layer)}?check=${encodeURIComponent(entry.check)}${entry.kind ? `&kind=${encodeURIComponent(entry.kind)}` : ''}`,
47
+ signal
48
+ ),
49
+ enabled: enabled && entry.backend !== null && /^[0-9a-f]{64}$/.test(entry.layer),
50
+ staleTime: Number.POSITIVE_INFINITY,
51
+ retry: false
52
+ });
53
+ }
54
+
55
+ /** The scenario in words: the steps the producer recorded, with the Gherkin keyword where it gave one. */
56
+ function Scenario({ entry, detail }: { entry: PlaylistEntry; detail: CheckDetail | undefined }) {
57
+ const steps = detail?.check.steps ?? [];
58
+ return (
59
+ <Stack gap="xs" component="section" aria-label="Scenario">
60
+ <Title order={3}>{entry.title}</Title>
61
+ {steps.length > 0 && (
62
+ <List type="ordered" spacing="xs" aria-label="Steps">
63
+ {steps.map((step, index) => (
64
+ <List.Item key={`${step.id ?? step.name}:${index}`}>
65
+ {step.kind && KEYWORDS.has(step.kind.toLowerCase()) ? `${step.kind.charAt(0).toUpperCase()}${step.kind.slice(1).toLowerCase()} ` : ''}
66
+ {step.name}
67
+ {step.status && step.status !== 'passed' ? ` (${step.status})` : ''}
68
+ </List.Item>
69
+ ))}
70
+ </List>
71
+ )}
72
+ {entry.status === 'failed' && detail?.check.detail && (
73
+ <Alert color="red" title="Why it failed" role="status">
74
+ <Text component="div" lineClamp={4}>
75
+ {detail.check.detail}
76
+ </Text>
77
+ </Alert>
78
+ )}
79
+ </Stack>
80
+ );
81
+ }
82
+
83
+ /** Who recorded the entry and what it carries, in one line each. */
84
+ function Facts({ entry }: { entry: PlaylistEntry }) {
85
+ return (
86
+ <Stack gap="xs" aria-label="Recording facts">
87
+ <Group gap="xs" wrap="wrap">
88
+ <StatusMark status={entry.status} size={14} />
89
+ <Text span inherit fw={600} data-outcome={entry.status}>
90
+ {statusWord(entry.status)}
91
+ </Text>
92
+ <Text span inherit c="dimmed">
93
+ {[environmentLabel(entry.environment), entry.producer, `recorded ${exactTime(entry.at)}`].join(' · ')}
94
+ </Text>
95
+ </Group>
96
+ {entry.tags.length > 0 && (
97
+ <Group gap="xs" aria-label="Tags">
98
+ {entry.tags.map((tag) => (
99
+ <Badge key={tag} variant="light" tt="none">
100
+ #{tag}
101
+ </Badge>
102
+ ))}
103
+ </Group>
104
+ )}
105
+ {entry.specPath && (
106
+ <Text component="div" c="dimmed">
107
+ Spec <Code>{entry.specPath}</Code>
108
+ </Text>
109
+ )}
110
+ </Stack>
111
+ );
112
+ }
113
+
114
+ /** The one player. It is keyed by its entry, so a new clip is a fresh element and the previous one is gone. */
115
+ function Clip({
116
+ entry,
117
+ speed,
118
+ autoPlay,
119
+ positions,
120
+ onEnded,
121
+ onFailed,
122
+ onNext,
123
+ onClose
124
+ }: {
125
+ entry: PlaylistEntry;
126
+ speed: number;
127
+ autoPlay: boolean;
128
+ positions: Map<string, number>;
129
+ onEnded: () => PlaylistEntry | null;
130
+ onFailed: (id: string) => void;
131
+ onNext: (() => void) | null;
132
+ onClose: () => void;
133
+ }) {
134
+ const state = recordingState(entry);
135
+ const detail = useEntryDetail(entry, state === 'playable');
136
+ const video = useRef<HTMLVideoElement>(null);
137
+ const [failed, setFailed] = useState(false);
138
+ const [blocked, setBlocked] = useState(false);
139
+ const [attempt, setAttempt] = useState(0);
140
+ const chosen = playableVideo(entry);
141
+ const attachments = useMemo(() => (detail.data ? everyAttachment(detail.data.check) : []), [detail.data]);
142
+ const movie = useMemo(() => {
143
+ const videos = attachments.filter(isVideoAttachment);
144
+ return videos.find((item) => attachmentIdentity(item) === chosen?.recording) ?? videos[0];
145
+ }, [attachments, chosen]);
146
+ const captions = useMemo(
147
+ () => attachments.filter((item) => (item.contentType ?? contentTypeOf(item.name)).toLowerCase().startsWith('text/vtt')),
148
+ [attachments]
149
+ );
150
+ const url = movie ? attachmentSource(movie, entry.layer).url : null;
151
+
152
+ useEffect(() => {
153
+ if (video.current) video.current.playbackRate = speed;
154
+ }, [speed]);
155
+
156
+ const play = () => {
157
+ const element = video.current;
158
+ if (!element) return;
159
+ setBlocked(false);
160
+ // `play()` rejects when the browser blocks it; a browser without a promise has nothing to catch.
161
+ const started = element.play() as Promise<void> | undefined;
162
+ started?.catch(() => setBlocked(true));
163
+ };
164
+
165
+ const developer = (
166
+ <Anchor
167
+ renderRoot={(props) => (
168
+ <Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
169
+ )}
170
+ >
171
+ Developer details
172
+ </Anchor>
173
+ );
174
+
175
+ let body;
176
+ if (state !== 'playable') {
177
+ body = (
178
+ <Alert color="orange" title={RECORDING_STATE_TEXT[state].label} icon={<IconAlertTriangle size={16} aria-hidden="true" />} role="status" data-recording-state={state}>
179
+ {RECORDING_STATE_TEXT[state].meaning}
180
+ </Alert>
181
+ );
182
+ } else if (detail.isPending) {
183
+ body = (
184
+ <Text role="status" component="div" c="dimmed">
185
+ Loading the recording…
186
+ </Text>
187
+ );
188
+ } else if (detail.isError || !url) {
189
+ body = (
190
+ <Alert role="alert" color="orange" title="This recording cannot be played" data-clip-failed>
191
+ <Stack gap="xs" align="flex-start">
192
+ <Text component="div">{detail.isError ? 'Its evidence could not be read.' : 'The result names a recording, but no file can be played from here.'}</Text>
193
+ <Group gap="xs">
194
+ {detail.isError && (
195
+ <Button type="button" variant="light" onClick={() => void detail.refetch()}>
196
+ Try again
197
+ </Button>
198
+ )}
199
+ {onNext && (
200
+ <Button type="button" variant="light" onClick={onNext}>
201
+ Next clip
202
+ </Button>
203
+ )}
204
+ </Group>
205
+ </Stack>
206
+ </Alert>
207
+ );
208
+ } else {
209
+ body = (
210
+ <Stack gap="xs">
211
+ <ScopeNotice scope={chosen?.scope} name={movie!.name} />
212
+ <Box
213
+ key={attempt}
214
+ component="video"
215
+ ref={video}
216
+ src={url}
217
+ controls
218
+ preload="metadata"
219
+ aria-label={`Recording: ${movie!.description ?? movie!.name}`}
220
+ maw="100%"
221
+ w="100%"
222
+ mah="70vh"
223
+ data-playing={entry.id}
224
+ onLoadedMetadata={() => {
225
+ const element = video.current!;
226
+ element.playbackRate = speed;
227
+ const saved = positions.get(entry.id);
228
+ if (saved !== undefined && saved < (element.duration || Number.POSITIVE_INFINITY)) element.currentTime = saved;
229
+ if (autoPlay) play();
230
+ }}
231
+ onTimeUpdate={() => positions.set(entry.id, video.current!.currentTime)}
232
+ onEnded={() => {
233
+ positions.delete(entry.id);
234
+ const next = onEnded();
235
+ if (next && next.id === entry.id && video.current) {
236
+ video.current.currentTime = 0;
237
+ play();
238
+ }
239
+ }}
240
+ onError={() => {
241
+ setFailed(true);
242
+ onFailed(entry.id);
243
+ }}
244
+ >
245
+ {captions.map((caption) => {
246
+ const source = attachmentSource(caption, entry.layer).url;
247
+ return source ? <track key={source} kind="captions" src={source} label={caption.description ?? caption.name} /> : null;
248
+ })}
249
+ </Box>
250
+ {movie!.durationMs ? (
251
+ <Text component="div" c="dimmed">
252
+ Length of the recording: {duration(movie!.durationMs)}
253
+ </Text>
254
+ ) : null}
255
+ {captions.length > 0 && (
256
+ <Text component="div" c="dimmed">
257
+ Captions are offered in the player's captions control: {captions.map((caption) => caption.description ?? caption.name).join(', ')}.
258
+ </Text>
259
+ )}
260
+ {blocked && (
261
+ <Alert color="blue" role="status" title="Autoplay was blocked" data-autoplay-blocked>
262
+ <Stack gap="xs" align="flex-start">
263
+ <Text component="div">The browser did not start this clip by itself. Play it to continue.</Text>
264
+ <Button type="button" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={play}>
265
+ Play
266
+ </Button>
267
+ </Stack>
268
+ </Alert>
269
+ )}
270
+ {failed && (
271
+ <Alert color="orange" role="alert" title="This video cannot be played" data-clip-failed>
272
+ <Stack gap="xs" align="flex-start">
273
+ <Text component="div">The file could not be loaded, or this browser cannot play its format. It stays in the playlist.</Text>
274
+ <Group gap="xs">
275
+ <Button
276
+ type="button"
277
+ variant="light"
278
+ onClick={() => {
279
+ setFailed(false);
280
+ setAttempt(attempt + 1);
281
+ }}
282
+ >
283
+ Retry
284
+ </Button>
285
+ {onNext && (
286
+ <Button type="button" variant="light" onClick={onNext}>
287
+ Next clip
288
+ </Button>
289
+ )}
290
+ </Group>
291
+ </Stack>
292
+ </Alert>
293
+ )}
294
+ </Stack>
295
+ );
296
+ }
297
+ return (
298
+ <Stack gap="md" miw={0} aria-label={`Now showing ${entry.title}`} component="section" data-clip={entry.id}>
299
+ {body}
300
+ <Facts entry={entry} />
301
+ <Scenario entry={entry} detail={detail.data} />
302
+ <Text component="div" c="dimmed">
303
+ Illustrative recording of one run. Its length and speed are those of the recording, not production latency, and it does not approve the behavior for production.
304
+ </Text>
305
+ {developer}
306
+ </Stack>
307
+ );
308
+ }
309
+
310
+ /**
311
+ * The watch session for `entries`: opens as a modal over the explorer. `total` is how many results matched when the list
312
+ * holds only the newest of them, so the session says it is not the whole set. Closing it discards positions.
313
+ */
314
+ export function WatchPlaylist({ entries, total, onClose }: { entries: readonly PlaylistEntry[]; total?: number; onClose: () => void }) {
315
+ const first = entries.find((entry) => recordingState(entry) === 'playable') ?? entries[0];
316
+ const [chosen, setChosen] = useState<string | null>(first?.id ?? null);
317
+ const [autoPlay, setAutoPlay] = useState(false);
318
+ const [speed, setSpeed] = useState<number>(1);
319
+ const [advance, setAdvance] = useState(true);
320
+ const [repeat, setRepeat] = useState(false);
321
+ const [failedIds, setFailedIds] = useState<ReadonlySet<string>>(new Set());
322
+ const [shown, setShown] = useState(LISTED);
323
+ const positions = useRef(new Map<string, number>());
324
+ const active = entries.find((entry) => entry.id === chosen) ?? first ?? null;
325
+ const counts = useMemo(() => playlistCounts(entries), [entries]);
326
+ const activeAt = active ? entries.indexOf(active) : -1;
327
+ const next = stepFrom(entries, active?.id ?? null, 1, repeat);
328
+ const previous = stepFrom(entries, active?.id ?? null, -1, repeat);
329
+ const select = (entry: PlaylistEntry, play: boolean) => {
330
+ setAutoPlay(play);
331
+ setChosen(entry.id);
332
+ };
333
+ const listed = entries.slice(0, Math.max(shown, activeAt + 1));
334
+ return (
335
+ <Modal opened onClose={onClose} title="Watch recordings" size="xl" centered>
336
+ <Stack gap="md">
337
+ <Text role="status" component="div" data-playlist-summary>
338
+ {plural(entries.length, 'result', 'results')}
339
+ {total !== undefined && total > entries.length ? ` (the newest of ${total.toLocaleString()} matching)` : ''}: {counts.playable} with a recording
340
+ {counts.unplayable > 0 ? `, ${counts.unplayable} whose recording is not available` : ''}
341
+ {counts['api-only'] > 0 ? `, ${counts['api-only']} API only` : ''}
342
+ {counts.none > 0 ? `, ${counts.none} with no recording` : ''}. {plural(counts.failed, 'failed result', 'failed results')}
343
+ {counts.failed > 0 ? ' kept in the playlist' : ''}.
344
+ </Text>
345
+ {entries.length === 0 && (
346
+ <Text component="div" c="dimmed">
347
+ No result is in this set, so there is nothing to watch.
348
+ </Text>
349
+ )}
350
+ {active && (
351
+ <Group role="group" aria-label="Playlist controls" align="flex-end" gap="md" wrap="wrap">
352
+ <Button type="button" variant="default" leftSection={<IconPlayerSkipBack size={16} aria-hidden="true" />} disabled={!previous} onClick={() => previous && select(previous, true)}>
353
+ Previous clip
354
+ </Button>
355
+ <Button type="button" variant="default" rightSection={<IconPlayerSkipForward size={16} aria-hidden="true" />} disabled={!next} onClick={() => next && select(next, true)}>
356
+ Next clip
357
+ </Button>
358
+ <NativeSelect
359
+ label="Playback speed"
360
+ value={String(speed)}
361
+ onChange={(event) => setSpeed(Number(event.currentTarget.value))}
362
+ data={SPEEDS.map((value) => ({ value: String(value), label: `${value}×` }))}
363
+ />
364
+ <Switch label="Play the next clip when one ends" checked={advance} onChange={(event) => setAdvance(event.currentTarget.checked)} />
365
+ <Switch label="Repeat" description="Start over after the last clip, or replay a clip when not advancing" checked={repeat} onChange={(event) => setRepeat(event.currentTarget.checked)} />
366
+ </Group>
367
+ )}
368
+ {active && (
369
+ <Text c="dimmed" component="div" size="sm">
370
+ Previous and next skip results with nothing to play; select one in the list to see why.
371
+ </Text>
372
+ )}
373
+ <Grid gap="md">
374
+ <Grid.Col span={{ base: 12, md: 8 }}>
375
+ {active && (
376
+ <Clip
377
+ key={active.id}
378
+ entry={active}
379
+ speed={speed}
380
+ autoPlay={autoPlay}
381
+ positions={positions.current}
382
+ onEnded={() => {
383
+ const target = advance ? stepFrom(entries, active.id, 1, repeat) : repeat ? active : null;
384
+ if (target && target.id !== active.id) select(target, true);
385
+ return target;
386
+ }}
387
+ onFailed={(id) => setFailedIds((held) => new Set(held).add(id))}
388
+ onNext={next ? () => select(next, true) : null}
389
+ onClose={onClose}
390
+ />
391
+ )}
392
+ </Grid.Col>
393
+ <Grid.Col span={{ base: 12, md: 4 }}>
394
+ <Stack gap="xs" component="section" aria-label="Playlist">
395
+ <Title order={3}>Playlist</Title>
396
+ <List spacing="xs" listStyleType="none">
397
+ {listed.map((entry, index) => {
398
+ const state = recordingState(entry);
399
+ const here = entry.id === active?.id;
400
+ return (
401
+ <List.Item key={entry.id} data-entry={entry.id} data-status={entry.status} data-recording-state={state}>
402
+ <Button
403
+ type="button"
404
+ variant={here ? 'light' : 'subtle'}
405
+ fullWidth
406
+ h="auto"
407
+ py="xs"
408
+ justify="flex-start"
409
+ aria-current={here ? 'true' : undefined}
410
+ aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}`}
411
+ onClick={() => select(entry, state === 'playable')}
412
+ >
413
+ <Flex component="span" gap="xs" wrap="nowrap" align="flex-start">
414
+ <StatusMark status={entry.status} size={14} />
415
+ <Flex component="span" direction="column" align="flex-start">
416
+ <Text span inherit ta="left">
417
+ {entry.title}
418
+ </Text>
419
+ <Text span inherit c="dimmed" size="xs" ta="left">
420
+ {statusWord(entry.status)} · {environmentLabel(entry.environment)} ·{' '}
421
+ {failedIds.has(entry.id) ? 'Could not be loaded' : RECORDING_STATE_TEXT[state].label}
422
+ </Text>
423
+ </Flex>
424
+ </Flex>
425
+ </Button>
426
+ </List.Item>
427
+ );
428
+ })}
429
+ </List>
430
+ {entries.length > listed.length && (
431
+ <Button type="button" variant="subtle" onClick={() => setShown(listed.length + LISTED)}>
432
+ Show more ({entries.length - listed.length} more)
433
+ </Button>
434
+ )}
435
+ </Stack>
436
+ </Grid.Col>
437
+ </Grid>
438
+ </Stack>
439
+ </Modal>
440
+ );
441
+ }
@@ -10,6 +10,7 @@
10
10
  * `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
11
11
  */
12
12
  import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
13
+ import type { ReliabilityScope } from '@crvouga/atlas/reports/reliability';
13
14
  import { TEST_SORTS, type TestQuery } from '@crvouga/atlas/reports/test-catalog';
14
15
  import { z } from 'zod/v4';
15
16
 
@@ -71,6 +72,18 @@ export const ExplorerSearchFields = {
71
72
  source: text,
72
73
  /** With `openRun` and `layer`: the one file of that layer (an `artifactRef`: `id:…`, `key:…` or `name:…`) the run's Artifacts select. */
73
74
  artifact: text,
75
+ /** The scope of the open test's reliability figure: environments, producers, branches, `key=value` parameters. */
76
+ relEnv: text,
77
+ relProducer: text,
78
+ relBranch: text,
79
+ relParam: text,
80
+ /** The reliability window, as `since` and `until` (`YYYY-MM-DD`, local time). */
81
+ relSince: text,
82
+ relUntil: text,
83
+ /** `any`: count every spec revision. Otherwise only the current one is counted. */
84
+ relRevision: z.enum(['any']).optional().catch(undefined),
85
+ /** The reliability's independent executions (its denominator) are open. */
86
+ relOpen: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
74
87
  /** Only the runs, and the results of an opened run, that have a playable video recording. */
75
88
  video: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined)
76
89
  };
@@ -102,6 +115,14 @@ export type ExplorerSearch = {
102
115
  readonly layer?: string;
103
116
  readonly source?: string;
104
117
  readonly artifact?: string;
118
+ readonly relEnv?: string;
119
+ readonly relProducer?: string;
120
+ readonly relBranch?: string;
121
+ readonly relParam?: string;
122
+ readonly relSince?: string;
123
+ readonly relUntil?: string;
124
+ readonly relRevision?: 'any';
125
+ readonly relOpen?: true;
105
126
  readonly video?: true;
106
127
  };
107
128
 
@@ -153,6 +174,21 @@ export function narrowed(search: ExplorerSearch): boolean {
153
174
  return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun || search.video);
154
175
  }
155
176
 
177
+ /** The reliability scope an address asks for, in the shape `reliability` reads: days are the viewer's local days. */
178
+ export function reliabilityScopeOf(search: ExplorerSearch): ReliabilityScope {
179
+ const list = (value: string | undefined) => (values(value).length ? values(value) : undefined);
180
+ const parts = {
181
+ environment: list(search.relEnv),
182
+ producer: list(search.relProducer),
183
+ branch: list(search.relBranch),
184
+ parameter: list(search.relParam),
185
+ ...(search.relRevision ? { revision: search.relRevision } : {}),
186
+ since: dayMs(search.relSince, false),
187
+ until: dayMs(search.relUntil, true)
188
+ };
189
+ return Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined)) as ReliabilityScope;
190
+ }
191
+
156
192
  /** The filters of the Runs and Artifacts tabs, in the address. */
157
193
  export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
158
194