@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
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @module User state in S3
3
+ *
4
+ * Reads and writes the user state document (`UserStateSchema`: the garbage collection policy and the saved settings) as
5
+ * one JSON object at `server/state.json` in the object store, which in a running server is the S3 bucket. This is the
6
+ * only place user state is kept: not a file, not a database, not the browser. A missing object is an empty state
7
+ * ("nothing chosen yet"); an object that cannot be read or does not match the schema is an error that stops the
8
+ * server from starting and is never overwritten, so a transient or corrupt read can never silently reset anyone's choices.
9
+ * Saving refuses a stale revision (the SHA-256 of the stored text), serialised within this process. An object store has
10
+ * no conditional writes, so two servers saving at the same instant could still race; run one server per bucket.
11
+ *
12
+ * @see docs/server.md#user-state-and-garbage-collection
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ import { readUtf8, writeUtf8 } from '../../reports/src/object-io.js';
16
+ import { UserStateSchema } from '../../schema/src/user-state.js';
17
+ /** Where the user state lives, relative to the store (the bucket plus `ATLAS_S3_PREFIX`). */
18
+ export const USER_STATE_KEY = 'server/state.json';
19
+ /** Raised when the stored user state cannot be trusted. The message says where it is and what to do. */
20
+ export class UserStateError extends Error {
21
+ }
22
+ const digest = (text) => createHash('sha256').update(text).digest('hex');
23
+ /**
24
+ * The user state kept in `store` at {@link USER_STATE_KEY}.
25
+ *
26
+ * @example
27
+ * const userState = userStateStore(s3Backend.store);
28
+ * const { state } = await userState.read();
29
+ */
30
+ export function userStateStore(store) {
31
+ let pending = Promise.resolve();
32
+ const read = async () => {
33
+ let text;
34
+ try {
35
+ text = await readUtf8(store, USER_STATE_KEY);
36
+ }
37
+ catch (error) {
38
+ throw new UserStateError(`The user state ${USER_STATE_KEY} could not be read from S3: ${error instanceof Error ? error.message : String(error)}`);
39
+ }
40
+ if (text === null)
41
+ return { state: { schemaVersion: 1 }, revision: digest(''), exists: false };
42
+ let json;
43
+ try {
44
+ json = JSON.parse(text);
45
+ }
46
+ catch {
47
+ throw new UserStateError(`The user state ${USER_STATE_KEY} in S3 is not valid JSON. Atlas will not overwrite it: fix or delete that object, then restart.`);
48
+ }
49
+ const parsed = UserStateSchema.safeParse(json);
50
+ if (!parsed.success)
51
+ throw new UserStateError(`The user state ${USER_STATE_KEY} in S3 is invalid (${parsed.error.issues.map(issue => `${issue.path.join('.') || '(document)'}: ${issue.message}`).join('; ')}). Atlas will not overwrite it: fix or delete that object, then restart.`);
52
+ // The document as written, once it is known to be valid: defaults are not filled in, so a setting nobody chose stays unchosen.
53
+ return { state: json, revision: digest(text), exists: true };
54
+ };
55
+ return {
56
+ read,
57
+ update(revision, change) {
58
+ const run = pending.then(async () => {
59
+ const held = await read();
60
+ if (held.revision !== revision)
61
+ throw new UserStateError('Settings changed. Reload them before saving.');
62
+ const next = { ...change(held.state), schemaVersion: 1 };
63
+ const checked = UserStateSchema.safeParse(next);
64
+ if (!checked.success)
65
+ throw new UserStateError(checked.error.issues.map(issue => `${issue.path.join('.') || '(document)'}: ${issue.message}`).join('; '));
66
+ const text = `${JSON.stringify(next, null, 2)}\n`;
67
+ await writeUtf8(store, USER_STATE_KEY, text, 'application/json');
68
+ return { state: next, revision: digest(text), exists: true };
69
+ });
70
+ pending = run.catch(() => undefined);
71
+ return run;
72
+ }
73
+ };
74
+ }
package/docs/agents.md CHANGED
@@ -144,7 +144,7 @@ workspace selection and test scripts. An existing `atlas.yml` not generated by A
144
144
  workflows may still need manual integration. Non-JavaScript suites need their language/tool setup in CI.
145
145
 
146
146
  Without a server, start with [local verification](quickstart.md). To run a server later, `atlas serve`
147
- starts one (`http://127.0.0.1:8080`, data in `./atlas-data`); [the server guide](server.md) covers configuration
147
+ starts one (`http://127.0.0.1:8080`; it needs S3-compatible storage configured with the `ATLAS_S3_*` variables, see `.env.example`); [the server guide](server.md) covers configuration
148
148
  and authentication. Keep synthetic failure checks in a scratch store rather than a shared production report.
149
149
 
150
150
  ## What an agent can rely on
package/docs/deploying.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  This is the production setup. A project runs **one Atlas server**. Developers' machines and every CI job send
4
4
  their results to it, as they finish or when they are done, and it shows everyone, developers and the business
5
- alike, the whole project at once, live. It keeps results on its own disk by default, or in an object-store bucket
6
- if you prefer; either way nothing is partitioned by run, branch or person: results are layered. The server's API,
5
+ alike, the whole project at once, live. It keeps results (and all its user state) in an S3-compatible bucket and
6
+ nowhere else; nothing is partitioned by run, branch or person: results are layered. The server's API,
7
7
  configuration and protocol are in **[the Atlas server](server.md)**; this page is the path to production.
8
8
 
9
9
  ```
10
10
  a developer's worktree ─┐ ┌─▶ developers (browser, live)
11
- a CI job, any branch ───┼─ atlas report publish|stream ─▶ SERVER ◀┤ atlas serve: keeps layers in ./atlas-data
12
- a device lab, nightly ──┘ (immutable layers, over HTTP) or S3 ├─▶ business or in a bucket (--write)
11
+ a CI job, any branch ───┼─ atlas report publish|stream ─▶ SERVER ◀┤ atlas serve: keeps layers in S3
12
+ a device lab, nightly ──┘ (immutable layers, over HTTP) ──▶ S3 ├─▶ business (required, nothing else)
13
13
  └─▶ CI gate atlas report status
14
14
  ```
15
15
 
@@ -18,7 +18,7 @@ Why this holds together:
18
18
  - **Publishing never conflicts.** A layer is one immutable file named by its content. Two machines that
19
19
  publish at once write different files; a retry writes the same one. There is no lock, no index to
20
20
  corrupt, nothing to merge.
21
- - **The server is replaceable.** What it knows is its write backend (a folder, or a bucket). Replace it, restore it
21
+ - **The server is replaceable.** What it knows is in its bucket. Replace it, restore it
22
22
  from a backup, or run a copy on a laptop: they show the same report, because a report is a function of the
23
23
  layers ([the contract](report-contract.md)).
24
24
  - **One view.** The newest completed result of every check, in every environment, from anyone. A
@@ -36,13 +36,15 @@ Why this holds together:
36
36
 
37
37
  ## 1. Where results are kept
38
38
 
39
- **By default, on the server's own disk** (`./atlas-data`, or the volume you mount at `ATLAS_DATA`). Nothing else
40
- needs to exist: skip to [section 3](#3-send-results-from-ci-and-from-laptops). Back that folder up like any data
41
- directory; starting a new server on a restored copy shows the same report.
39
+ **The server keeps everything in one S3-compatible bucket** (reports and user state, such as the garbage collection
40
+ policy), set by the required `ATLAS_S3_ENDPOINT`, `ATLAS_S3_BUCKET`, `ATLAS_S3_REGION`, `ATLAS_S3_ACCESS_KEY_ID` and
41
+ `ATLAS_S3_SECRET_ACCESS_KEY` variables ([details](server.md#required-s3-storage), template in
42
+ [`.env.example`](https://github.com/crvouga/atlas/blob/main/.env.example)). Without them it refuses to start, listing
43
+ every variable and what is wrong. There is no disk or database to fall back to, so the server is replaceable:
44
+ a new one started on the same bucket shows the same report.
42
45
 
43
- **To keep results in a bucket instead** (so a replaced server loses nothing, or CI already has bucket access),
44
- name it and make it the place uploads go: `ATLAS_STORE=project=s3://my-bucket/atlas/my-project ATLAS_WRITE=project`.
45
- The rest of this section is that setup. A store is named by a URL. Keys never go in it.
46
+ **Clients and CI** name stores by URL, and a client may publish straight to a bucket or folder as well as to the
47
+ server. A store is named by a URL. Keys never go in it.
46
48
 
47
49
  | Store | URL |
48
50
  | --- | --- |
@@ -190,18 +192,16 @@ A run is uploaded only when its privacy scan passed: the same rule the static cl
190
192
  ## 4. The server
191
193
 
192
194
  ```sh
193
- atlas serve # ATLAS_STORE says where; listens on :8080
195
+ atlas serve # ATLAS_S3_* says where; listens on :8080
194
196
  ```
195
197
 
196
- It serves the client, the report and the project's specs and runs from all configured backends. It also
197
- accepts uploads and announces new results on its event stream. Uploads go to `--data` by default;
198
- use a named `--store project=s3://… --write project` to put uploads in a shared bucket, with publisher permissions.
198
+ It serves the client, the report and the project's specs and runs from its bucket. It also
199
+ accepts uploads and announces new results on its event stream. The bucket's credentials need list, read, write and delete
200
+ permissions (`atlas report policy` prints a least-privilege policy; delete is for the garbage collection you define in the UI).
199
201
 
200
202
  | Flag | Environment | Meaning |
201
203
  | --- | --- | --- |
202
- | `--store <url>` (repeatable) | `ATLAS_STORE` | Where layers are. Several stores are one report: repeat the flag, or separate them in the variable with commas or new lines. |
203
- | `--data <dir>` | `ATLAS_DATA` | Local upload backend. Default `./atlas-data`. |
204
- | `--write <id>` | `ATLAS_WRITE` | Backend that accepts uploads. Default `data`. |
204
+ | | `ATLAS_S3_ENDPOINT`, `ATLAS_S3_BUCKET`, `ATLAS_S3_REGION`, `ATLAS_S3_ACCESS_KEY_ID`, `ATLAS_S3_SECRET_ACCESS_KEY` | **Required.** The S3-compatible bucket that holds all reports and user state. Optional: `ATLAS_S3_PREFIX`, `ATLAS_S3_PATH_STYLE`, `ATLAS_S3_SESSION_TOKEN`. |
205
205
  | `--port <n>` | `ATLAS_PORT` or `PORT` | Default 8080. `0` picks a free one. |
206
206
  | `--host <h>` | `ATLAS_HOST` or `HOST` | Default `127.0.0.1`. Listening beyond this machine requires tokens or `--insecure`. |
207
207
  | `--site <dir>` | `ATLAS_SITE` | The built client. Default: `./atlas-site`, else built once into `~/.cache/atlas/site`. |
@@ -228,9 +228,10 @@ updated `latest`. Pull request builds and failed publications are labelled accor
228
228
  Leave the start command unset: the image includes the server and the prebuilt viewer.
229
229
  2. Add `ATLAS_TOKEN` with a long random value (generate one with `openssl rand -hex 24`). It grants admin
230
230
  access; optionally add separate `ATLAS_PUBLISH_TOKEN` and `ATLAS_READ_TOKEN` for publishers and readers.
231
- 3. Attach a volume at `/data` and set `RAILWAY_RUN_UID=0`: Railway mounts volumes as root, while the image
232
- normally runs as `bun` ([Railway volume permissions](https://docs.railway.com/volumes#permissions)).
233
- Keep one replica. The volume retains reports and server settings when the image is replaced.
231
+ 3. Add the five required storage variables (`ATLAS_S3_ENDPOINT`, `ATLAS_S3_BUCKET`, `ATLAS_S3_REGION`,
232
+ `ATLAS_S3_ACCESS_KEY_ID`, `ATLAS_S3_SECRET_ACCESS_KEY`) for a bucket you own. The service keeps nothing on its own
233
+ disk, so no volume is needed and a replaced image loses nothing; without the variables it exits and lists each one.
234
+ Keep one replica.
234
235
  4. Set the healthcheck path to `/readyz` and generate a public domain. The image listens on `0.0.0.0` and
235
236
  honors Railway's `PORT`; leave `ATLAS_PORT` unset. Open the domain and sign in with your token as the
236
237
  password (any username), or use the viewer's token sign-in.
@@ -244,8 +245,7 @@ Projects publish to that domain with `ATLAS_STORE=https://YOUR-DOMAIN` and their
244
245
  [sending results](#3-send-results-from-ci-and-from-laptops). Hosting Atlas needs no project checkout; publishing
245
246
  your project's test results still needs the integration described there.
246
247
 
247
- For bucket storage instead, set `ATLAS_STORE=project=s3://my-bucket/atlas/my-project`, `ATLAS_WRITE=project`,
248
- and the bucket credentials. Keep the `/data` volume for server settings and local state.
248
+ Then open `/admin` to define the garbage collection policy; until you save one, nothing is deleted.
249
249
 
250
250
  ### Deploy without authentication
251
251
 
@@ -261,12 +261,11 @@ In Railway, create a **Docker image** service with:
261
261
  ghcr.io/crvouga/atlas-insecure:latest
262
262
  ```
263
263
 
264
- Leave the start command unset. Attach a persistent volume at `/data`, set `RAILWAY_RUN_UID=0`, keep one
264
+ Leave the start command unset. Add the required `ATLAS_S3_*` storage variables, keep one
265
265
  replica, set the healthcheck to `/readyz` and generate a public domain. Leave `ATLAS_PORT` unset so Railway's
266
266
  `PORT` is used. Enable **Settings → Source → Configure Auto Updates → Anytime** to follow `latest`.
267
267
  Do not set `ATLAS_TOKEN`, `ATLAS_PUBLISH_TOKEN` or `ATLAS_READ_TOKEN`, or add `auth.tokens` to
268
- `/data/atlas.server.json`: configured tokens still enable authentication. When reusing a volume from an
269
- authenticated deployment, remove its saved auth tokens first. Open the domain directly, including `/admin`
268
+ a configuration file: configured tokens still enable authentication. Open the domain directly, including `/admin`
270
269
  for settings (leave the token field blank and choose **Load settings**).
271
270
 
272
271
  Verify anonymous reads and uploads without an Authorization header:
@@ -287,7 +286,7 @@ leave its `ATLAS_TOKEN` unset. Use the same publishing commands described in
287
286
  For local Docker:
288
287
 
289
288
  ```sh
290
- docker run --pull always -p 8080:8080 -v atlas-data:/data ghcr.io/crvouga/atlas-insecure:latest
289
+ docker run --pull always -p 8080:8080 --env-file .env ghcr.io/crvouga/atlas-insecure:latest
291
290
  ```
292
291
 
293
292
  Both images publish full commit tags for pinning or rollback. A new GHCR package must be made Public
@@ -297,10 +296,11 @@ once before Railway can pull without registry credentials; see
297
296
  ### Docker
298
297
 
299
298
  The same published image works anywhere Docker runs. It already contains the client (the same
300
- for every project: the server tells the page where its data is), and keeps results on the volume at `/data`:
299
+ for every project: the server tells the page where its data is), and keeps nothing on its own disk: it needs the
300
+ `ATLAS_S3_*` variables (copy `.env.example` to `.env` and fill it in):
301
301
 
302
302
  ```sh
303
- docker run --pull always -p 8080:8080 -v atlas-data:/data \
303
+ docker run --pull always -p 8080:8080 --env-file .env \
304
304
  -e ATLAS_TOKEN=$(openssl rand -hex 24) ghcr.io/crvouga/atlas:latest
305
305
  ```
306
306
 
@@ -309,17 +309,15 @@ back, replace `latest` with the full commit SHA. To build from this repository,
309
309
  [`examples/deploy/Dockerfile`](https://github.com/crvouga/atlas/blob/main/examples/deploy/Dockerfile) is the
310
310
  alternative for building from an npm version or a local package tarball.
311
311
 
312
- For a bucket as the system of record add `-e ATLAS_STORE=project=s3://my-bucket/atlas/my-project -e ATLAS_WRITE=project -e AWS_REGION=us-east-1`.
313
- On ECS, EKS or EC2, attach the combined **both** policy (including `server/live/*` for producer leases) to the task, service account or
314
- instance and set no keys.
312
+ The bucket's account needs the combined **both** policy (including `server/live/*` for producer leases and `server/state.json`
313
+ for user state) plus `s3:DeleteObject` for garbage collection. The server does not use ambient AWS roles: pass the keys.
315
314
 
316
315
  **Try it on one machine.** [`examples/deploy/compose.yaml`](https://github.com/crvouga/atlas/blob/main/examples/deploy/compose.yaml)
317
- starts the published image on a volume; `compose.s3.yaml` starts it with an S3 server and a bucket as its storage.
316
+ starts the published image with an S3 server (SeaweedFS) and a bucket as its storage; `compose.s3.yaml` is the same stack without tokens.
318
317
  Publish to `http://localhost:8080` from your project and open it.
319
318
 
320
319
  **Kubernetes.** [`examples/deploy/kubernetes.yaml`](https://github.com/crvouga/atlas/blob/main/examples/deploy/kubernetes.yaml):
321
- one copy (the server is a singleton for the project), results in the bucket, readiness on `/readyz`, an IRSA
322
- service account.
320
+ one copy (the server is a singleton for the project), results in the bucket (keys from a Secret), readiness on `/readyz`.
323
321
 
324
322
  **Without a container.** `bun add -d @crvouga/atlas`, then `bunx atlas client build --no-data --out
325
323
  atlas-site` and `bunx atlas serve`. Everything it can be told is in
@@ -392,12 +390,13 @@ Narrow it by choice, per consumer, never by moving data:
392
390
  listing (one request per thousand layers) runs at the start and every 30 refreshes (five minutes by
393
391
  default), which is also how a layer recorded with an old timestamp is found. One small `GET` per new
394
392
  layer, once.
395
- - **Retention.** Atlas deletes nothing. `atlas report doctor` shows what the bucket holds.
396
- - **Disaster recovery.** The write backend (the data folder, or the bucket) is the only state. Back it up, replicate or version it as you
393
+ - **Retention.** Atlas deletes nothing until you define a garbage collection policy in the UI (`/admin`); it is kept in the bucket as JSON and
394
+ then removes only superseded layers older than you allow ([details](server.md#user-state-and-garbage-collection)). `atlas report doctor` shows what the bucket holds.
395
+ - **Disaster recovery.** The bucket is the only state (reports and user state). Back it up, replicate or version it as you
397
396
  do any data; a server is rebuilt from nothing in the time it takes to read the layers (below). It listens at once
398
397
  and answers `/readyz` 503 until it has, so a liveness probe never kills one that is still reading.
399
- - **A second bucket.** Another team's layers, a device lab, an archive: pass several `--store`s. They are one
400
- report, attributed to the store they came from.
398
+ - **A second bucket.** The server reads one bucket. To combine another team's layers or an archive into the
399
+ report, copy them in with `atlas report merge --into <store>`; clients and `atlas report status` may read several stores.
401
400
 
402
401
  ## 7. Performance
403
402
 
@@ -16,7 +16,7 @@ atlas — executable statecharts
16
16
  [--no-retry] [--output <dir>]
17
17
  atlas merge [--config atlas.config.ts] [--output <dir>] <run-dir> <run-dir>...
18
18
  atlas export --specs <dir> --format scxml|mermaid|markdown|json [--out <file>]
19
- atlas server [--data <dir>] [--port 8080] [--store <url>]... one Atlas node: API, projects and client
19
+ atlas server [--port 8080] one Atlas node: API, projects and client; needs ATLAS_S3_* (atlas serve --help)
20
20
  atlas client build [--no-data] [--out <dir>] build the project-agnostic Atlas client
21
21
  atlas dev [--port 5180] develop the Atlas server and client on one host
22
22
  atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)
@@ -53,14 +53,18 @@ atlas init — integrate a project with an Atlas server
53
53
  ```text
54
54
  atlas serve: the Atlas server
55
55
 
56
- atlas serve [--config atlas.server.json] [--data <dir>] [--port 8080] [--host 127.0.0.1]
57
- [--store <url|id=url>]... [--write <id>] [--site <dir>]
56
+ atlas serve [--config atlas.server.json] [--port 8080] [--host 127.0.0.1] [--site <dir>]
58
57
  [--outline-branch <b>] [--branch <name>]... [--ignore-modified] [--refresh <seconds>]
59
58
  [--anonymous-read] [--insecure] [--no-log]
60
59
 
61
60
  One process serves the client and the API. Test results are uploaded to it from anywhere (laptops, CI) and it
62
- shows everyone the combined report, live. It keeps its data in --data (default ./atlas-data) and can read more
63
- backends (--store): S3, other servers, folders. Uploads go to the data folder unless --write names another.
61
+ shows everyone the combined report, live.
62
+
63
+ Storage is required. ALL reports and ALL user state (the garbage collection policy, saved settings) are kept in
64
+ S3-compatible object storage and nowhere else; there is no local-disk, in-memory or database fallback. Without
65
+ these environment variables the server prints every one of them with what is wrong, and exits with status 2:
66
+ ATLAS_S3_ENDPOINT ATLAS_S3_BUCKET ATLAS_S3_REGION ATLAS_S3_ACCESS_KEY_ID ATLAS_S3_SECRET_ACCESS_KEY
67
+ Optional: ATLAS_S3_PREFIX, ATLAS_S3_PATH_STYLE, ATLAS_S3_SESSION_TOKEN. See .env.example.
64
68
 
65
69
  Tokens (ATLAS_TOKEN admin, ATLAS_PUBLISH_TOKEN, ATLAS_READ_TOKEN, or "auth.tokens" in the config) are required
66
70
  to listen beyond this machine unless --insecure. Every option has an environment variable (docs/deploying.md).
@@ -71,11 +75,12 @@ atlas serve: the Atlas server
71
75
  ```text
72
76
  atlas dev: the Atlas server and the client, for working on either
73
77
 
74
- atlas dev [--port 5180] [--host 127.0.0.1] [--data <dir>] [--store <url|id=url>]... [--config atlas.server.json]
78
+ atlas dev [--port 5180] [--host 127.0.0.1] [--config atlas.server.json]
75
79
 
76
80
  Starts the server (API only, on a port of its own) and the client's dev server, which proxies /api to it, so the
77
81
  browser talks to one host and a change to the client shows at once. In production `atlas serve` does both from
78
- one process.
82
+ one process. Storage is the same as for `atlas serve`: the required ATLAS_S3_* environment variables name the S3
83
+ bucket that holds all reports and user state; without them it exits with status 2 and lists every variable.
79
84
  ```
80
85
 
81
86
  ## atlas report
@@ -91,6 +96,7 @@ atlas report — layered evidence for any kind of spec
91
96
  [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]
92
97
  [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...
93
98
  [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]
99
+ [--require-recordings]
94
100
  atlas report stream [--store <dir|s3://…|https://atlas.example.com>] --producer <name> [--environment <name>]
95
101
  [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...
96
102
  [--run-id <id>] [--run-title <t>]
@@ -127,6 +133,10 @@ atlas report — layered evidence for any kind of spec
127
133
  Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own
128
134
  folder and each --attach-root, and never from a hidden file or a link out: a report is data from a
129
135
  test run, not instructions. One that is missing or not allowed is skipped with a warning.
136
+ --require-recordings exit 1 when a check that declared recordingExpectation "required" has no
137
+ uploaded video (absent, left out as too large or not allowed, or refused). The layer is
138
+ still written and the checks keep their results; only this command fails. The receipt of
139
+ what was uploaded (counts, sizes, checksums) is in the JSON result under layer.recordings.
130
140
  From a checkout with uncommitted changes the layer is marked "modified".
131
141
  stream Send checks as they finish: one JSON object per line on standard input (the fields of one
132
142
  check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server
@@ -116,7 +116,7 @@ Responses: `200` Settings editor.; `401` No token, or an unknown one. Send `Auth
116
116
 
117
117
  `GET /api/settings` · role: admin
118
118
 
119
- Returns editable persisted storage/report settings, active settings, a revision, and configuration precedence. Authentication settings are excluded. Requires an admin Bearer token. Embedded applications may manage settings outside this API.
119
+ Returns the editable report and live-run settings saved in the user state in S3, active settings, a revision, and configuration precedence. Storage and authentication settings are excluded. Requires an admin Bearer token. Embedded applications may manage settings outside this API.
120
120
 
121
121
  Responses: `200` Settings and revision.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `503` Configuration is managed by the embedding application.
122
122
 
@@ -124,12 +124,40 @@ Responses: `200` Settings and revision.; `401` No token, or an unknown one. Send
124
124
 
125
125
  `PUT /api/settings` · role: admin
126
126
 
127
- Replaces editable storage/report settings, preserving authentication and other file fields. Atomically persists a validated configuration; no report data is moved. Environment variables and flags override these values after restart. Requires an admin Bearer token, same-origin requests in browsers, and a current revision.
127
+ Replaces the editable report and live-run settings in the user state in S3 (`server/state.json`), preserving the garbage collection policy and other fields. Persists a validated configuration; no report data is moved. Environment variables and flags override these values after restart. Requires an admin Bearer token, same-origin requests in browsers, and a current revision.
128
128
 
129
129
  - `If-Match` (header, required): The quoted revision returned by GET /api/settings.
130
130
 
131
131
  Responses: `200` Saved settings and new revision. Restart required.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `409` Settings changed; reload before saving.; `413` Settings exceed the body limit.; `422` Invalid configuration.; `428` A quoted settings revision is required.; `503` Configuration is managed by the embedding application.
132
132
 
133
+ ### Read the garbage collection policy
134
+
135
+ `GET /api/settings/gc` · role: admin
136
+
137
+ Returns the policy kept in the user state in S3 (`server/state.json`), or `policy: null, saved: false` when none has been saved yet, in which case garbage collection is off and nothing is deleted. Also returns the revision to send back when saving and the last pass over each project. Requires an admin Bearer token. If the stored user state is unreadable the answer is 503 and the stored object is left untouched.
138
+
139
+ Responses: `200` The policy, its revision and the last passes.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `503` The user state in S3 cannot be read, or settings are managed by the embedding application.
140
+
141
+ ### Save the garbage collection policy
142
+
143
+ `PUT /api/settings/gc` · role: admin
144
+
145
+ Replaces the policy in the user state in S3 and leaves the rest of that document alone. The server reads the policy from S3 each time it collects, so the new policy applies at the next pass, without a restart. Requires an admin Bearer token, same-origin requests in browsers, and the current revision.
146
+
147
+ - `If-Match` (header, required): The quoted revision returned by GET /api/settings/gc.
148
+
149
+ Responses: `200` The saved policy and the new revision.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `409` The state changed; reload before saving.; `413` The body exceeds the limit.; `422` Invalid policy.; `428` A quoted revision is required.; `503` The user state in S3 cannot be read, or settings are managed by the embedding application.
150
+
151
+ ### Collect garbage now, or preview it
152
+
153
+ `POST /api/settings/gc/run` · role: admin
154
+
155
+ Applies the policy saved in S3 to every project now, instead of waiting for the schedule. Only report layers that were read and no longer decide any result, older than the policy allows and beyond its `keepLatest`, are deleted, with the captured files only they used. With `dryRun=1` nothing is deleted and each run reports what it would delete (a disabled policy is previewed as if enabled). Answers 409 when no policy has been saved, and, without `dryRun`, when the saved policy is disabled. Requires an admin Bearer token and a same-origin request in browsers.
156
+
157
+ - `dryRun` (query): `1` or `true` to only count what would be deleted.
158
+
159
+ Responses: `200` One run per project.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `409` No policy has been saved, or it is disabled.
160
+
133
161
  ### Is the process up
134
162
 
135
163
  `GET /healthz` · role: none
@@ -276,7 +276,9 @@ recursively), and hands their bytes over as `data` with a `contentType` guessed
276
276
  attachment gave none.
277
277
 
278
278
  A file that is missing, not allowed or too large is left out with a warning: evidence that could not be
279
- found never stops the results from being published. Attachments without a `path` are kept untouched.
279
+ found never stops the results from being published. The exception in kind is a video of a check that declared
280
+ `recordingExpectation: "required"`: it stays as an attachment without bytes whose `omitted` says why (`not-found`,
281
+ `not-allowed`, `too-large`, `over-budget`), so the recording receipt can name the gap. Attachments without a `path` are kept untouched.
280
282
 
281
283
  - `holder`: A check, step, attempt or any object that may have `attachments`, `steps` and `attempts`.
282
284
 
@@ -1747,11 +1749,34 @@ export type LayerHeader = {
1747
1749
  readonly execution: string;
1748
1750
  /** What the run as a whole carries: its logs, links and labels. */
1749
1751
  readonly evidence?: EvidenceSummary;
1752
+ /** Counts from the layer's recording receipt: how many expected recordings were published. Absent when the layer declared no expectations: its completeness is unknown, not complete. Never changes a status. */
1753
+ readonly recordings?: RecordingCounts;
1750
1754
  };
1751
1755
  ```
1752
1756
 
1753
1757
  A report layer's origin and totals. Its checks are read separately, once.
1754
1758
 
1759
+ #### `RecordingCounts`
1760
+
1761
+ ```ts
1762
+ export type RecordingCounts = {
1763
+ /** Checks that required a recording: `uploaded + referenced + skipped + unresolved`. */
1764
+ readonly expected: number;
1765
+ /** Stored in the layer, with a checksum. */
1766
+ readonly uploaded: number;
1767
+ /** Given only by `uri`: Atlas cannot vouch for it. */
1768
+ readonly referenced: number;
1769
+ /** Captured but left out, with a reason (too large, not allowed…). */
1770
+ readonly skipped: number;
1771
+ /** No video was attached at all. */
1772
+ readonly unresolved: number;
1773
+ /** Intentionally unrecorded checks: neither expected nor missing. */
1774
+ readonly notApplicable: number;
1775
+ };
1776
+ ```
1777
+
1778
+ How many recordings a layer expected and what became of them (see `RecordingReceipt`).
1779
+
1755
1780
  #### `LayerRow`
1756
1781
 
1757
1782
  ```ts
@@ -1775,6 +1800,8 @@ export type LayerRow = {
1775
1800
  readonly durationMs: number | null;
1776
1801
  /** What this result carries beyond its outcome. Absent when nothing. */
1777
1802
  readonly evidence?: EvidenceSummary;
1803
+ /** What became of this check's expected recording: `uploaded`, `referenced`, `skipped`, `unresolved` or `not-applicable`, with a reason when it is not uploaded. Absent when the check declared no expectation. Independent of `status`. */
1804
+ readonly recording?: { readonly state: string; readonly reason?: string };
1778
1805
  };
1779
1806
  ```
1780
1807
 
@@ -2775,6 +2802,76 @@ export function neighborStepFrame(
2775
2802
 
2776
2803
  First frame of the next / previous step (skipping same-step frames).
2777
2804
 
2805
+ ### Layer garbage collection
2806
+
2807
+ Source: `packages/reports/src/garbage.ts`. Import: `@crvouga/atlas/reports/garbage`
2808
+
2809
+ Applies a garbage collection policy (`GcPolicy`, chosen by a person in the Atlas UI and kept in the user state) to the
2810
+ layers in a store. Atlas is otherwise deletion-free, so this is deliberately conservative: a layer is deleted only when
2811
+ (1) the caller says the report has read it and every result it carries has been replaced by a newer layer (`eligible`),
2812
+ (2) it was recorded longer ago than the policy allows, and (3) it is not among the newest `keepLatest` layers. Deleting
2813
+ it therefore cannot change the combined report. Its catalog entry goes first, so nothing visible ever points at a
2814
+ missing manifest; its manifest next; then the captured-file blobs that no remaining layer refers to. If any remaining
2815
+ layer cannot be read, no blob is deleted: what cannot be read is unknown, never unused. Specs, runs and commit
2816
+ inventories are not touched.
2817
+
2818
+ See docs/server.md#user-state-and-garbage-collection
2819
+
2820
+ #### `GcCandidate`
2821
+
2822
+ ```ts
2823
+ export type GcCandidate = { readonly id: string; readonly catalogKey: string; readonly recordedAtMs: number };
2824
+ ```
2825
+
2826
+ One layer the policy would delete.
2827
+
2828
+ #### `GcPlan`
2829
+
2830
+ ```ts
2831
+ export type GcPlan = {
2832
+ /** Layers to delete, oldest first. */
2833
+ readonly candidates: readonly GcCandidate[];
2834
+ /** Layers in the catalog that stay. */
2835
+ readonly kept: number;
2836
+ };
2837
+ ```
2838
+
2839
+ What a policy would delete from a store right now.
2840
+
2841
+ #### `GcOutcome`
2842
+
2843
+ ```ts
2844
+ export type GcOutcome = { readonly deletedLayers: number; readonly deletedBlobs: number };
2845
+ ```
2846
+
2847
+ The outcome of applying a plan.
2848
+
2849
+ #### `planGarbage`
2850
+
2851
+ ```ts
2852
+ export async function planGarbage(store: ObjectStore, input: { readonly policy: GcPolicy; readonly nowMs: number; readonly eligible: ReadonlySet<string> }): Promise<GcPlan>
2853
+ ```
2854
+
2855
+ Decides which layers a policy deletes, without deleting anything. `eligible` is the set of layer ids the caller has
2856
+ read and knows no longer decide any result (`ReportIndex.superseded()`); a layer not in it is never a candidate.
2857
+ Lists the catalog only. With `policy.enabled === false` the plan is empty.
2858
+
2859
+ **Example**
2860
+
2861
+ ```ts
2862
+ const plan = await planGarbage(store, { policy, nowMs: Date.now(), eligible: index.superseded() });
2863
+ ```
2864
+
2865
+ #### `collectGarbage`
2866
+
2867
+ ```ts
2868
+ export async function collectGarbage(store: ObjectStore, plan: GcPlan): Promise<GcOutcome>
2869
+ ```
2870
+
2871
+ Deletes the layers of a plan, then the blobs only they used. Store errors throw after whatever was already deleted;
2872
+ running the same plan again finishes the job, because every delete is idempotent and the catalog entry (what
2873
+ makes a layer visible) always goes before the content.
2874
+
2778
2875
  ### URL hash routes of the reports viewer
2779
2876
 
2780
2877
  Source: `packages/reports/src/hash.ts`. Import: `@crvouga/atlas/reports/hash`
@@ -3983,7 +4080,7 @@ String choices (such as `{ device: 'iphone-15' }`) that make a result its own sl
3983
4080
  #### `LayerExtras`
3984
4081
 
3985
4082
  ```ts
3986
- export type LayerExtras = Partial<Pick<import('../../schema/src/layer').LayerDocument, 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'extensions'>>;
4083
+ export type LayerExtras = Partial<Pick<import('../../schema/src/layer').LayerDocument, 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'extensions' | 'recordings'>>;
3987
4084
  ```
3988
4085
 
3989
4086
  What a layer may carry as a whole. All optional and open: see docs/extending.md.
@@ -5381,6 +5478,69 @@ export function contentTypeForMedia(media: string): string
5381
5478
 
5382
5479
  The media type for a snapshot media kind (`html`, `png`, `json`, `text`, `bytes`, `video`); an unknown kind is `application/octet-stream`.
5383
5480
 
5481
+ ### Recording receipts
5482
+
5483
+ Source: `packages/reports/src/recording-receipt.ts`. Import: `@crvouga/atlas/reports/recording-receipt`
5484
+
5485
+ Answers "did the recordings we expected get published?" apart from "did the tests pass?". `recordingReceipt` reads the
5486
+ checks a producer is about to publish (after their files were stored) and describes, per check that declared a
5487
+ `recordingExpectation`, whether its video was uploaded, only referenced, left out (with a reason) or never attached.
5488
+ `recordingCompleteness` reduces the winning results of a report to the figures `atlas report status` prints. Neither
5489
+ reads a clock or the environment, and neither changes a status, a slot or a winner: a receipt describes publication, it
5490
+ does not decide a result. A layer with no receipt is Unknown, never Complete.
5491
+
5492
+ See docs/rich-evidence.md#recording-completeness
5493
+
5494
+ #### `isVideoAttachment`
5495
+
5496
+ ```ts
5497
+ export function isVideoAttachment(attachment: Loose): boolean
5498
+ ```
5499
+
5500
+ Is this attachment a screen recording: a role of `video`, or a `video/*` media type?
5501
+
5502
+ #### `recordingReceipt`
5503
+
5504
+ ```ts
5505
+ export function recordingReceipt(checks: readonly Loose[]): RecordingReceipt | undefined
5506
+ ```
5507
+
5508
+ The receipt for the checks of one layer, or `undefined` when no check declares a `recordingExpectation` (the layer then
5509
+ claims nothing). Call it with checks whose attachment bytes are already stored (`bundleKey`, `bytes` and `sha256` set).
5510
+
5511
+ A `required` check is `uploaded` when any video of it has stored bytes, `referenced` when one has only a `uri`,
5512
+ `skipped` when its videos were left out (the attachment's `omitted` is the reason) and `unresolved` when it has no
5513
+ video at all. A `not-applicable` check is listed but is neither expected nor missing. Items are sorted by check id so the
5514
+ same input is always the same receipt.
5515
+
5516
+ #### `RecordingResult`
5517
+
5518
+ ```ts
5519
+ export type RecordingResult = {
5520
+ readonly spec: string | null;
5521
+ readonly check: string;
5522
+ readonly environment: string | null;
5523
+ readonly layer: string;
5524
+ /** Whether the layer the result came from carried a receipt. */
5525
+ readonly hasReceipt: boolean;
5526
+ /** The result's item in that receipt, when its check declared an expectation. */
5527
+ readonly recording?: { readonly state: string; readonly reason?: string };
5528
+ };
5529
+ ```
5530
+
5531
+ One result as {@link recordingCompleteness} reads it.
5532
+
5533
+ #### `recordingCompleteness`
5534
+
5535
+ ```ts
5536
+ export function recordingCompleteness(results: readonly RecordingResult[]): RecordingCompleteness
5537
+ ```
5538
+
5539
+ Reduces the winning results of a report to its recording completeness. `incomplete` when any expected recording is
5540
+ skipped or unresolved; otherwise `unknown` when a contributing layer has no receipt (nothing is claimed for it, and a
5541
+ report with no results at all is unknown too); otherwise `complete` when something was expected and `none-expected` when
5542
+ nothing was. Gaps are sorted so the output never depends on read order.
5543
+
5384
5544
  ### Remote commit report
5385
5545
 
5386
5546
  Source: `packages/reports/src/remote-report.ts`. Import: `@crvouga/atlas/reports/remote-report`
@@ -5603,6 +5763,7 @@ It reads only immutable layers (`layers/index/…`), which is what makes "read o
5603
5763
  - `json(): { readonly version: string; readonly text: string }` — The report as JSON text: exactly what `JSON.stringify({ ...snapshot(), active: [] })` writes, put together from the text of each layer, which is written once. `active` is what a store without live runs always sends: none.
5604
5764
  - `delta(since: string): { readonly text: string; readonly version: string } | null` — What differs between the report a viewer holds (named by its version) and the current one: the layers that changed in full, and the ids of those that no longer hold a winner. Null when that version is not remembered, and the viewer should ask for the whole report. See `applyEffectiveDelta`.
5605
5765
  - `get history(): TestHistory { return this.#history; }` — Every recorded result of every test and every run, as layers were folded in: what the test catalog, a test's history and the run list are built from. Read-only for callers; `history.rev` changes whenever a layer is taken in.
5766
+ - `superseded(): ReadonlySet<string>` — The ids of layers this index has read and that no longer decide any result. Removing one from its store cannot change the report (every slot it filled is held by a newer layer), which is what makes it safe for garbage collection to delete. A layer that failed to load, is hidden by `include`, or still holds a result is not here.
5606
5767
  - `stats()` — For `/readyz` and logs.
5607
5768
 
5608
5769
  ### Test loading state
@@ -5756,6 +5917,8 @@ checks (or none completed), failed checks, running checks, specs without results
5756
5917
  each required environment with no checks (the name is normalised, so `iOS` means `ios`), then every notice of the
5757
5918
  report (an unreadable layer or backend). `failing` lists each failed or running winner, in a fixed order that does
5758
5919
  not depend on read order; diagnostic `performance` checks are left out unless nothing else was recorded.
5920
+ `recordings` reports recording completeness of the same results apart from `ok`: an incomplete or unknown
5921
+ completeness never changes the gate (use `atlas report publish --require-recordings` to fail on a missing video).
5759
5922
 
5760
5923
  **Example**
5761
5924