@crvouga/atlas 0.16.0 → 1.0.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 (91) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +52 -8
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +1 -1
  5. package/dist/reports/src/attachment-files.d.ts +3 -1
  6. package/dist/reports/src/attachment-files.js +14 -4
  7. package/dist/reports/src/evidence-types.d.ts +22 -0
  8. package/dist/reports/src/garbage.d.ts +39 -0
  9. package/dist/reports/src/garbage.js +82 -0
  10. package/dist/reports/src/layer-merge.js +5 -0
  11. package/dist/reports/src/layer-types.d.ts +1 -1
  12. package/dist/reports/src/publish-checks.js +11 -3
  13. package/dist/reports/src/recording-receipt.d.ts +48 -0
  14. package/dist/reports/src/recording-receipt.js +103 -0
  15. package/dist/reports/src/report-cli.d.ts +1 -1
  16. package/dist/reports/src/report-cli.js +20 -2
  17. package/dist/reports/src/report-index.d.ts +6 -0
  18. package/dist/reports/src/report-index.js +14 -0
  19. package/dist/reports/src/report-status.d.ts +2 -0
  20. package/dist/reports/src/report-status.js +17 -1
  21. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  22. package/dist/reports/src/reports-route.d.ts +8 -0
  23. package/dist/schema/src/api.d.ts +65 -0
  24. package/dist/schema/src/api.js +7 -2
  25. package/dist/schema/src/cli-results.d.ts +107 -2
  26. package/dist/schema/src/cli-results.js +11 -3
  27. package/dist/schema/src/index.d.ts +1 -0
  28. package/dist/schema/src/index.js +1 -0
  29. package/dist/schema/src/layer.d.ts +165 -0
  30. package/dist/schema/src/layer.js +72 -1
  31. package/dist/schema/src/server-config.d.ts +7 -49
  32. package/dist/schema/src/server-config.js +8 -21
  33. package/dist/schema/src/user-state.d.ts +115 -0
  34. package/dist/schema/src/user-state.js +54 -0
  35. package/dist/server/src/app.d.ts +16 -2
  36. package/dist/server/src/app.js +62 -0
  37. package/dist/server/src/config.d.ts +16 -21
  38. package/dist/server/src/config.js +48 -57
  39. package/dist/server/src/dev.d.ts +1 -1
  40. package/dist/server/src/dev.js +20 -7
  41. package/dist/server/src/index.d.ts +5 -1
  42. package/dist/server/src/index.js +5 -1
  43. package/dist/server/src/main.d.ts +23 -7
  44. package/dist/server/src/main.js +72 -26
  45. package/dist/server/src/openapi.js +24 -5
  46. package/dist/server/src/projects.js +89 -14
  47. package/dist/server/src/settings.d.ts +23 -7
  48. package/dist/server/src/settings.js +50 -60
  49. package/dist/server/src/storage.d.ts +68 -0
  50. package/dist/server/src/storage.js +125 -0
  51. package/dist/server/src/user-state.d.ts +31 -0
  52. package/dist/server/src/user-state.js +74 -0
  53. package/docs/agents.md +1 -1
  54. package/docs/deploying.md +38 -39
  55. package/docs/reference/cli.md +17 -7
  56. package/docs/reference/http-api.md +30 -2
  57. package/docs/reference/reports.md +165 -2
  58. package/docs/reference/schema.md +161 -16
  59. package/docs/reference/server.md +263 -41
  60. package/docs/report-backends.md +0 -144
  61. package/docs/report-contract.md +1 -1
  62. package/docs/rich-evidence.md +29 -0
  63. package/docs/runners.md +1 -1
  64. package/docs/server.md +141 -36
  65. package/docs/statecharts.md +1 -1
  66. package/docs/ui.md +1 -1
  67. package/docs/workspaces.md +5 -2
  68. package/llms-full.txt +265 -234
  69. package/llms.txt +1 -1
  70. package/package.json +2 -2
  71. package/packages/reports/STORAGE.md +1 -1
  72. package/packages/schema/schemas/check-detail.schema.json +20 -0
  73. package/packages/schema/schemas/effective-delta.schema.json +61 -0
  74. package/packages/schema/schemas/effective-evidence.schema.json +61 -0
  75. package/packages/schema/schemas/evidence-page.schema.json +45 -0
  76. package/packages/schema/schemas/gc-policy.schema.json +36 -0
  77. package/packages/schema/schemas/gc-run-result.schema.json +106 -0
  78. package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
  79. package/packages/schema/schemas/layer-rows.schema.json +16 -0
  80. package/packages/schema/schemas/layer.schema.json +153 -0
  81. package/packages/schema/schemas/publish-checks.schema.json +24 -0
  82. package/packages/schema/schemas/publish-result.schema.json +161 -1
  83. package/packages/schema/schemas/report-status.schema.json +104 -0
  84. package/packages/schema/schemas/server-config.schema.json +0 -41
  85. package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
  86. package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
  87. package/packages/schema/schemas/user-state.schema.json +140 -0
  88. package/packages/server/README.md +11 -6
  89. package/packages/server/openapi.json +843 -128
  90. package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
  91. package/packages/visualizer/src/evidence/marks.tsx +16 -0
@@ -21,6 +21,8 @@ import {
21
21
  PasswordInput,
22
22
  SimpleGrid,
23
23
  Stack,
24
+ Switch,
25
+ Table,
24
26
  Text,
25
27
  TextInput,
26
28
  Title
@@ -28,7 +30,10 @@ import {
28
30
  import { useState } from 'react';
29
31
 
30
32
  type Settings = Record<string, unknown>;
31
- type Backend = { id: string; label?: string; url: string; write?: boolean };
33
+ type GcPolicy = { enabled: boolean; maxAgeDays: number; keepLatest: number; intervalHours: number };
34
+ type GcRun = { project: string; dryRun: boolean; at: string; candidates: number; deletedLayers: number; deletedBlobs: number; keptLayers: number; error?: string };
35
+ /** What to suggest when no policy has been saved yet. Nothing is deleted until the person saves it with "Enabled" on. */
36
+ const SUGGESTED_POLICY: GcPolicy = { enabled: false, maxAgeDays: 90, keepLatest: 20, intervalHours: 24 };
32
37
  type Field = {
33
38
  id: string;
34
39
  label: string;
@@ -39,9 +44,6 @@ type Field = {
39
44
  type?: 'number' | 'boolean' | 'branches';
40
45
  };
41
46
  const fields: Record<string, Field[]> = {
42
- storage: [
43
- { id: 'data', label: 'Local data folder', path: ['data'], description: 'Relative paths start from the server configuration directory.' }
44
- ],
45
47
  live: [
46
48
  {
47
49
  id: 'runTimeoutSeconds',
@@ -138,15 +140,76 @@ export function SettingsPage() {
138
140
  setJson(JSON.stringify(next, null, 2));
139
141
  setJsonError('');
140
142
  };
141
- const backends = (draft.backends as Backend[] | undefined) ?? [];
142
- const changeBackend = (index: number, patch: Partial<Backend>) =>
143
- change({ ...draft, backends: backends.map((b, i) => (i === index ? { ...b, ...patch } : b)) });
143
+ const [policy, setPolicy] = useState<GcPolicy>(SUGGESTED_POLICY);
144
+ const [policySaved, setPolicySaved] = useState(false);
145
+ const [runs, setRuns] = useState<GcRun[]>([]);
146
+ const [preview, setPreview] = useState<GcRun[]>();
147
+ const authorization = (): Record<string, string> => (token ? { authorization: `Bearer ${token}` } : {});
148
+ const failure = (error: unknown) =>
149
+ setMessage({
150
+ text:
151
+ error instanceof SyntaxError
152
+ ? 'The JSON configuration is invalid. Fix it before saving.'
153
+ : error instanceof Error
154
+ ? error.message
155
+ : String(error),
156
+ error: true
157
+ });
158
+ const readJson = async (response: Response) => {
159
+ const data = await response.json();
160
+ if (!response.ok) {
161
+ if (response.status === 409 || response.status === 428) setStale(true);
162
+ throw new Error(data.detail || data.error || `Request failed (${response.status})`);
163
+ }
164
+ return data;
165
+ };
166
+ const applyGc = (data: { revision: string; policy: GcPolicy | null; saved: boolean; lastRuns: GcRun[] }) => {
167
+ setPolicy(data.policy ?? SUGGESTED_POLICY);
168
+ setPolicySaved(data.saved);
169
+ setRuns(data.lastRuns);
170
+ };
171
+ /** The garbage collection policy lives in the user state in S3: this reads and writes it through the server, never the browser. */
172
+ const gcRequest = async (action: 'save' | 'preview') => {
173
+ if (busy || !revision || (action === 'save' && stale)) return;
174
+ setBusy(true);
175
+ try {
176
+ if (action === 'save') {
177
+ const data = await readJson(
178
+ await fetch('/api/settings/gc', {
179
+ method: 'PUT',
180
+ headers: { ...authorization(), 'content-type': 'application/json', 'if-match': `"${revision}"` },
181
+ body: JSON.stringify(policy),
182
+ cache: 'no-store'
183
+ })
184
+ );
185
+ setRevision(data.revision);
186
+ setStale(false);
187
+ applyGc(data);
188
+ setMessage({
189
+ text: data.policy.enabled
190
+ ? 'Garbage collection policy saved to S3. It applies on the next scheduled pass.'
191
+ : 'Garbage collection policy saved to S3 but disabled: nothing will be deleted.',
192
+ success: true
193
+ });
194
+ } else {
195
+ const data = await readJson(
196
+ await fetch('/api/settings/gc/run?dryRun=1', { method: 'POST', headers: authorization(), cache: 'no-store' })
197
+ );
198
+ setPreview(data.runs);
199
+ setMessage({ text: 'Preview finished. Nothing was deleted.' });
200
+ }
201
+ } catch (error) {
202
+ failure(error);
203
+ } finally {
204
+ setBusy(false);
205
+ }
206
+ };
144
207
  const request = async (method: 'GET' | 'PUT') => {
145
208
  if (busy || (method === 'PUT' && (!revision || stale))) return;
146
209
  setBusy(true);
147
210
  if (method === 'GET') setRevision(undefined);
148
211
  try {
149
- const headers: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
212
+ const headers: Record<string, string> = authorization();
150
213
  let body: string | undefined;
151
214
  if (method === 'PUT') {
152
215
  const parsed = JSON.parse(json);
@@ -155,16 +218,14 @@ export function SettingsPage() {
155
218
  headers['if-match'] = `"${revision}"`;
156
219
  body = JSON.stringify(parsed);
157
220
  }
158
- const response = await fetch('/api/settings', { method, headers, body, cache: 'no-store' });
159
- const data = await response.json();
160
- if (!response.ok) {
161
- if (response.status === 409 || response.status === 428) setStale(true);
162
- throw new Error(data.detail || data.error || `Request failed (${response.status})`);
163
- }
164
- setRevision(data.revision);
221
+ const data = await readJson(await fetch('/api/settings', { method, headers, body, cache: 'no-store' }));
222
+ // After a load, read the policy too. After a save, the revision (of the whole user state) changed under it.
223
+ const gc = method === 'GET' ? await readJson(await fetch('/api/settings/gc', { headers: authorization(), cache: 'no-store' })) : null;
224
+ setRevision(gc ? gc.revision : data.revision);
165
225
  setStale(false);
166
226
  setActive(data.active);
167
227
  change(data.settings);
228
+ if (gc) { applyGc(gc); setPreview(undefined); }
168
229
  setMessage({
169
230
  text:
170
231
  method === 'PUT' || data.restartRequired
@@ -173,15 +234,7 @@ export function SettingsPage() {
173
234
  success: method === 'PUT' || data.restartRequired
174
235
  });
175
236
  } catch (error) {
176
- setMessage({
177
- text:
178
- error instanceof SyntaxError
179
- ? 'The JSON configuration is invalid. Fix it before saving.'
180
- : error instanceof Error
181
- ? error.message
182
- : String(error),
183
- error: true
184
- });
237
+ failure(error);
185
238
  } finally {
186
239
  setBusy(false);
187
240
  }
@@ -285,7 +338,7 @@ export function SettingsPage() {
285
338
  <Box>
286
339
  <Title order={1}>Atlas server settings</Title>
287
340
  <Text c="dimmed" mt="xs">
288
- Configure how this Atlas node stores reports and tracks test runs. These settings apply to every project on this node.
341
+ Define the garbage collection policy and configure how this Atlas node tracks test runs. Everything here is saved to S3 and applies to every project on this node.
289
342
  </Text>
290
343
  </Box>
291
344
  <Fieldset legend={<Group gap="xs">Administrator access {revision && <Badge color="green">Connected</Badge>}</Group>}>
@@ -319,8 +372,101 @@ export function SettingsPage() {
319
372
  <Box id="configuration">
320
373
  <Stack gap="lg">
321
374
  <Alert title="Changes apply after a server restart.">
322
- Environment variables and command-line flags can override saved settings. Current values are shown under each field.
375
+ The configuration file, environment variables and command-line flags can override saved settings. Current values are shown under each field.
323
376
  </Alert>
377
+ <Box
378
+ id="garbage-collection"
379
+ component="form"
380
+ onSubmit={(event) => {
381
+ event.preventDefault();
382
+ void gcRequest('save');
383
+ }}
384
+ >
385
+ <Fieldset legend="Garbage collection" disabled={busy}>
386
+ <Stack>
387
+ {policySaved ? (
388
+ <Text size="sm" c="dimmed">
389
+ This policy is saved in S3 (<Code>server/state.json</Code>) and the server reads it from there each time it collects.
390
+ </Text>
391
+ ) : (
392
+ <Alert id="gc-empty" color="yellow" title="No policy has been saved yet">
393
+ Garbage collection is off and nothing is deleted. The values below are suggestions; they take effect only after you
394
+ turn collection on and save.
395
+ </Alert>
396
+ )}
397
+ <Text size="sm" c="dimmed">
398
+ Only report layers that no longer decide any result (newer layers replaced all their checks) are ever deleted, and never
399
+ the newest layers you choose to keep. Everything else, including specs and runs, is kept.
400
+ </Text>
401
+ <Switch
402
+ id="gc-enabled"
403
+ label="Delete eligible layers on a schedule"
404
+ checked={policy.enabled}
405
+ onChange={(event) => setPolicy({ ...policy, enabled: event.currentTarget.checked })}
406
+ />
407
+ <SimpleGrid cols={{ base: 1, sm: 3 }}>
408
+ <NumberInput
409
+ id="gc-maxAgeDays"
410
+ label="Delete layers older than (days)"
411
+ min={1}
412
+ max={3650}
413
+ allowDecimal={false}
414
+ value={policy.maxAgeDays}
415
+ onChange={(value) => setPolicy({ ...policy, maxAgeDays: Number(value) })}
416
+ />
417
+ <NumberInput
418
+ id="gc-keepLatest"
419
+ label="Always keep the newest layers"
420
+ min={0}
421
+ allowDecimal={false}
422
+ value={policy.keepLatest}
423
+ onChange={(value) => setPolicy({ ...policy, keepLatest: Number(value) })}
424
+ />
425
+ <NumberInput
426
+ id="gc-intervalHours"
427
+ label="Run every (hours)"
428
+ min={1}
429
+ max={720}
430
+ value={policy.intervalHours}
431
+ onChange={(value) => setPolicy({ ...policy, intervalHours: Number(value) })}
432
+ />
433
+ </SimpleGrid>
434
+ <Group>
435
+ <Button id="gc-save" type="submit" variant="filled" loading={busy} disabled={stale}>
436
+ Save garbage collection policy
437
+ </Button>
438
+ <Button id="gc-preview" disabled={!policySaved} loading={busy} onClick={() => void gcRequest('preview')}>
439
+ Preview deletions
440
+ </Button>
441
+ </Group>
442
+ {(preview ?? runs).length > 0 && (
443
+ <Table.ScrollContainer minWidth={480}>
444
+ <Table id="gc-runs">
445
+ <Table.Caption>{preview ? 'Preview (nothing deleted)' : 'Last collection pass per project'}</Table.Caption>
446
+ <Table.Thead>
447
+ <Table.Tr>
448
+ <Table.Th>Project</Table.Th>
449
+ <Table.Th>{preview ? 'Would delete' : 'Deleted layers'}</Table.Th>
450
+ <Table.Th>Kept layers</Table.Th>
451
+ <Table.Th>Result</Table.Th>
452
+ </Table.Tr>
453
+ </Table.Thead>
454
+ <Table.Tbody>
455
+ {(preview ?? runs).map((run) => (
456
+ <Table.Tr key={`${run.project}-${run.at}`}>
457
+ <Table.Td>{run.project}</Table.Td>
458
+ <Table.Td>{preview ? run.candidates : run.deletedLayers}</Table.Td>
459
+ <Table.Td>{run.keptLayers}</Table.Td>
460
+ <Table.Td>{run.error ? `Failed: ${run.error}` : `OK at ${run.at}`}</Table.Td>
461
+ </Table.Tr>
462
+ ))}
463
+ </Table.Tbody>
464
+ </Table>
465
+ </Table.ScrollContainer>
466
+ )}
467
+ </Stack>
468
+ </Fieldset>
469
+ </Box>
324
470
  <Box
325
471
  component="form"
326
472
  onSubmit={(event) => {
@@ -329,67 +475,16 @@ export function SettingsPage() {
329
475
  }}
330
476
  >
331
477
  <Stack gap="lg">
332
- <Fieldset legend="Storage" disabled={busy}>
333
- <Stack>
334
- {fields.storage!.map(input)}
335
- <NativeSelect
336
- id="write"
337
- label="Upload destination"
338
- description={`Current: ${active.write ?? 'data'}. Reports are read from all configured storage locations.`}
339
- value={String(draft.write ?? '')}
340
- onChange={(event) => {
341
- const next = updated(draft, ['write'], event.currentTarget.value || undefined);
342
- change({
343
- ...next,
344
- ...(draft.backends !== undefined ? { backends: backends.map(({ write, ...backend }) => backend) } : {})
345
- });
346
- }}
347
- >
348
- <option value="">Use default destination</option>
349
- <option value="data">Local data folder</option>
350
- {backends.map((backend, i) => (
351
- <option key={i} value={backend.id}>
352
- {backend.label || backend.id}
353
- </option>
354
- ))}
355
- {typeof draft.write === 'string' && draft.write !== 'data' && !backends.some((b) => b.id === draft.write) && (
356
- <option value={draft.write}>{draft.write}</option>
357
- )}
358
- </NativeSelect>
359
- <Title order={3}>Additional storage</Title>
360
- <Text c="dimmed">
361
- Add a folder, S3-compatible bucket, or another Atlas server. Keep credentials in the server environment.
478
+ <Fieldset legend="Storage">
479
+ <Stack gap="xs">
480
+ <Text>
481
+ All reports and all user state, including the garbage collection policy below, are stored in S3-compatible
482
+ object storage and nowhere else. The bucket and credentials are set by the server&apos;s required{' '}
483
+ <Code>ATLAS_S3_*</Code> environment variables and cannot be changed here.
484
+ </Text>
485
+ <Text size="sm" c="dimmed">
486
+ The settings on this page are saved as JSON in the same bucket, in <Code>server/state.json</Code>.
362
487
  </Text>
363
- {!backends.length && <Text c="dimmed">No additional storage configured. Atlas uses its local data folder.</Text>}
364
- {backends.map((backend, i) => (
365
- <Fieldset key={i} legend="Storage location">
366
- <Stack gap="sm">
367
- <TextInput
368
- label="Storage ID"
369
- required
370
- pattern="[a-z0-9-]+"
371
- value={backend.id}
372
- onChange={(event) => changeBackend(i, { id: event.currentTarget.value })}
373
- />
374
- <TextInput
375
- label="Display name (optional)"
376
- value={backend.label ?? ''}
377
- onChange={(event) => changeBackend(i, { label: event.currentTarget.value || undefined })}
378
- />
379
- <TextInput
380
- label="Location"
381
- required
382
- value={backend.url}
383
- placeholder="s3://reports/atlas"
384
- onChange={(event) => changeBackend(i, { url: event.currentTarget.value })}
385
- />
386
- <Button color="red" onClick={() => change({ ...draft, backends: backends.filter((_, at) => at !== i) })}>
387
- Remove
388
- </Button>
389
- </Stack>
390
- </Fieldset>
391
- ))}
392
- <Button onClick={() => change({ ...draft, backends: [...backends, { id: '', url: '' }] })}>Add storage</Button>
393
488
  </Stack>
394
489
  </Fieldset>
395
490
  <Fieldset legend="Live updates" disabled={busy}>
@@ -407,7 +502,7 @@ export function SettingsPage() {
407
502
  <Accordion.Panel>
408
503
  <JsonInput
409
504
  id="settings"
410
- label="Storage and report settings (JSON)"
505
+ label="Report and live-run settings (JSON)"
411
506
  autosize
412
507
  minRows={10}
413
508
  formatOnBlur
@@ -440,7 +535,7 @@ export function SettingsPage() {
440
535
  Save changes
441
536
  </Button>
442
537
  <Text size="xs" c="dimmed">
443
- Saved to <Code>atlas.server.json</Code>. Restart Atlas to apply.
538
+ Saved to S3 (<Code>server/state.json</Code>). Restart Atlas to apply.
444
539
  </Text>
445
540
  </Group>
446
541
  </Stack>
@@ -82,12 +82,28 @@ export function CountLine({ summary, noun, nouns }: { summary: SpecSummary; noun
82
82
  export function backendLabel(backends: readonly BackendInfo[], id: string) {
83
83
  return backends.find((backend) => backend.id === id)?.label ?? id;
84
84
  }
85
+ /**
86
+ * Recording completeness of a layer, apart from its test outcomes: how many expected videos were published. A layer
87
+ * that declared no expectations returns null (its completeness is unknown, so nothing is claimed).
88
+ */
89
+ export function recordingLabel(layer: Pick<LayerHeader, 'recordings'>): { text: string; incomplete: boolean } | null {
90
+ const recordings = layer.recordings;
91
+ if (!recordings || recordings.expected === 0) return null;
92
+ const published = recordings.uploaded + recordings.referenced;
93
+ return { text: `Recordings ${published} of ${recordings.expected}`, incomplete: recordings.skipped + recordings.unresolved > 0 };
94
+ }
85
95
  /** A result's provenance; incomplete origin data is stated explicitly. */
86
96
  export function Origin({ layer, backends, now }: { layer: LayerHeader; backends: readonly BackendInfo[]; now: number }) {
87
97
  const sha = shortSha(layer.sha);
98
+ const recording = recordingLabel(layer);
88
99
  return (
89
100
  <Flex component="span" display="inline-flex" wrap="wrap" gap="xs" align="center" c="dimmed" fz="xs">
90
101
  {layer.live && <Badge size="sm">Running now</Badge>}
102
+ {recording && (
103
+ <Badge size="sm" variant="light" color={recording.incomplete ? 'orange' : 'gray'} title={recording.incomplete ? 'Some expected recordings were not published. The test outcomes are unaffected.' : 'Every expected recording was published.'}>
104
+ {recording.incomplete ? `${recording.text}: incomplete` : recording.text}
105
+ </Badge>
106
+ )}
91
107
  <Text span inherit>
92
108
  {backendLabel(backends, layer.backend)}
93
109
  </Text>