@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 1.0.0 (2026-10-28)
4
+
5
+ ### ⚠️ Breaking changes
6
+
7
+ - store all reports and user state in S3 only, with a GC policy set in the UI (#51) ([157ea67](https://github.com/crvouga/atlas/commit/157ea6734439f9d890b3de992aad72f4bca2d597))
8
+
9
+ ## 0.17.0 (2026-10-28)
10
+
11
+ ### Features
12
+
13
+ - report recording completeness separately from test success ([a8dd9d4](https://github.com/crvouga/atlas/commit/a8dd9d4b12d0c9cfbb7cb94c48d571e4aa5050ef))
14
+
3
15
  ## 0.16.0 (2026-10-28)
4
16
 
5
17
  ### Features
package/README.md CHANGED
@@ -18,7 +18,7 @@ backends, and tells you in one command whether the product still does what it wa
18
18
  versioned layer format with a golden fixture, and an `ObjectStore` interface any bucket fits.
19
19
  - **One node, many projects.** `atlas server` runs a Bun API and serves the Atlas client from the same host. Reports, specs, recordings and live progress are partitioned by project slug.
20
20
  Every laptop and CI job sends results to it as they finish (`atlas report publish`, or `atlas report stream` while
21
- a suite runs), it keeps them on its own disk (or in S3, R2, MinIO), and every client sees the combined report
21
+ a suite runs), it keeps them in an S3-compatible bucket (AWS S3, R2, MinIO; required, nothing else is used), and every client sees the combined report
22
22
  update live, receiving only what changed ([the server](docs/server.md), [deploying](docs/deploying.md)).
23
23
  - **Fast and fail-closed.** Publishing is two small writes. A thousand layers of a hundred checks
24
24
  combine in about a second. An unreadable layer, a downed backend or a check still running is never
@@ -67,7 +67,7 @@ bun atlas # starts the project-agnostic Atlas node at http://localhost:
67
67
  bun example:run # runs tests and streams results, current steps and progress as they execute
68
68
  ```
69
69
 
70
- The server starts independently with data in `./atlas-data`; it never loads example specs or reports. `bun example:run` publishes the Todo example under `todo-app`. Select a project using the top-right picker, then use the spec dropdown breadcrumbs to navigate. Other projects set `ATLAS_SERVER_URL`, `ATLAS_PROJECT` (for example `checkout-service`) and their publish token. Every uploader talks to the node; storage credentials stay on the server.
70
+ The server starts independently; it never loads example specs or reports. **It needs S3-compatible storage** (see [Required S3 storage](#required-s3-storage)): copy `.env.example` to `.env` and fill in the `ATLAS_S3_*` variables first, or it exits at once and lists every one that is missing. `bun example:run` publishes the Todo example under `todo-app`. Select a project using the top-right picker, then use the spec dropdown breadcrumbs to navigate. Other projects set `ATLAS_SERVER_URL`, `ATLAS_PROJECT` (for example `checkout-service`) and their publish token. Every uploader talks to the node; storage credentials stay on the server.
71
71
 
72
72
  Every Gherkin scenario runs, including sharing and renaming. Test reports show running spinners,
73
73
  live step logs and a Running status filter. Producers send heartbeats every
@@ -88,8 +88,11 @@ or package installation. [`latest` on GHCR](https://github.com/users/crvouga/pac
88
88
  the newest checked `main` image, independently of npm releases.
89
89
 
90
90
  1. Leave the start command unset. Add a long random secret as `ATLAS_TOKEN` (at least 12 characters;
91
- use 48 or more) and set `RAILWAY_RUN_UID=0` for [Railway volume permissions](https://docs.railway.com/volumes#permissions).
92
- 2. Attach a persistent volume at `/data` and keep one replica so reports and server settings survive updates.
91
+ use 48 or more).
92
+ 2. Add the five required storage variables (`ATLAS_S3_ENDPOINT`, `ATLAS_S3_BUCKET`, `ATLAS_S3_REGION`,
93
+ `ATLAS_S3_ACCESS_KEY_ID`, `ATLAS_S3_SECRET_ACCESS_KEY`; see [Required S3 storage](#required-s3-storage)) for a
94
+ bucket you own, and keep one replica. All reports and settings live in that bucket, so no volume is needed and
95
+ the service refuses to start, listing what is missing, without them.
93
96
  3. Set the healthcheck path to `/readyz` and generate a public domain. Railway supplies `PORT`;
94
97
  leave `ATLAS_PORT` unset. Open the domain and sign in with the token as the password (any username).
95
98
  4. Enable **Settings → Source → Configure Auto Updates → Anytime** to follow `latest`.
@@ -100,7 +103,7 @@ For the exact image from a run, open **[CI](https://github.com/crvouga/atlas/act
100
103
  run → Summary → Atlas CI**. The summary includes a copyable full commit tag, the immutable registry digest,
101
104
  whether that run updated `latest`, all job results, npm release details and artifact links. Use the commit
102
105
  tag to pin or roll back a deployment. The [deployment guide](docs/deploying.md#run-it) covers optional reader
103
- and publisher tokens, bucket storage and sending results from your projects.
106
+ and publisher tokens, the S3 bucket and sending results from your projects.
104
107
 
105
108
  ### Deploy without authentication
106
109
 
@@ -231,8 +234,8 @@ flag).
231
234
 
232
235
  **The server** is `atlas serve`: one process that serves the client, takes in results and streams the report to
233
236
  every client. Point `ATLAS_STORE` at it and every developer's laptop and every CI job sends results with the same
234
- commands that write to a folder; everyone then looks at one report, live. Results are kept in its data folder
235
- unless you choose a bucket. `atlas dev` runs the same server with the client's dev server in front, so
237
+ commands that write to a folder; everyone then looks at one report, live. Results are kept in an S3-compatible bucket
238
+ and nowhere else ([required storage](#required-s3-storage)). `atlas dev` runs the same server with the client's dev server in front, so
236
239
  development is one host too. See [the server](docs/server.md) and
237
240
  [running Atlas for a project](docs/deploying.md).
238
241
 
@@ -240,7 +243,7 @@ Deploy the server using the [Railway setup above](#deploy-to-railway-without-cod
240
243
  [deployment guide](docs/deploying.md#run-it) for other hosts.
241
244
 
242
245
  ```sh
243
- atlas serve # the project's server, at :8080 (ATLAS_TOKEN says who may upload)
246
+ atlas serve # the project's server, at :8080 (needs ATLAS_S3_*; ATLAS_TOKEN says who may upload)
244
247
 
245
248
  export ATLAS_STORE=https://atlas.example.com ATLAS_TOKEN=… # on every laptop and CI job
246
249
  atlas report publish --producer unit --junit out.xml --specs ./specs # when a suite is done
@@ -248,6 +251,47 @@ my-suite --json | atlas report stream --producer e2e --environment ios # or as
248
251
  atlas report status --branch main --ignore-modified # the gate, answered by the server
249
252
  ```
250
253
 
254
+ ### Required S3 storage
255
+
256
+ The Atlas server keeps **all reports and all user state in S3-compatible object storage, and nowhere else**: no local
257
+ disk, no memory, no database, no browser storage. *User state* is one JSON document in the bucket,
258
+ `server/state.json`, that holds the **garbage collection policy** (defined in the Atlas UI at `/admin`) and the
259
+ settings saved there. The server reads the policy from that document each time it collects. Until someone saves a
260
+ policy, garbage collection is off and nothing is ever deleted.
261
+
262
+ Because of that, the storage settings are **required environment variables with no defaults** (see
263
+ [`.env.example`](https://github.com/crvouga/atlas/blob/main/.env.example)), and the server **refuses to start** (exit status 2) without them:
264
+
265
+ | Variable | Required | Meaning |
266
+ | --- | --- | --- |
267
+ | `ATLAS_S3_ENDPOINT` | yes | S3 API endpoint, e.g. `https://s3.eu-west-1.amazonaws.com` |
268
+ | `ATLAS_S3_BUCKET` | yes | Bucket for reports and user state; it must already exist |
269
+ | `ATLAS_S3_REGION` | yes | Bucket region (`auto` for Cloudflare R2) |
270
+ | `ATLAS_S3_ACCESS_KEY_ID` | yes | Access key of an account that may list, read, write and delete in the bucket |
271
+ | `ATLAS_S3_SECRET_ACCESS_KEY` | yes | Its secret key |
272
+ | `ATLAS_S3_PREFIX` | no | Folder inside the bucket (default: the bucket root) |
273
+ | `ATLAS_S3_PATH_STYLE` | no | `1` (default) for `endpoint/bucket/key`, `0` for virtual-hosted style |
274
+ | `ATLAS_S3_SESSION_TOKEN` | no | Only for temporary credentials |
275
+
276
+ If a variable is missing or malformed, or S3 rejects the settings, Atlas lists **every** variable with `[MISSING]`
277
+ or `[INVALID]` next to the ones that are wrong, says why storage is required, and exits non-zero:
278
+
279
+ ```text
280
+ ==============================================================================
281
+ ATLAS CANNOT START: S3 STORAGE IS NOT USABLE
282
+ ==============================================================================
283
+ Why: the S3 configuration is missing or invalid.
284
+ Reason: Atlas stores ALL reports and ALL user state (the garbage collection policy and saved
285
+ settings) in S3-compatible object storage, and nowhere else. …
286
+ Required environment variables:
287
+ [MISSING] ATLAS_S3_ENDPOINT S3 API endpoint of the object storage service
288
+ [ok] ATLAS_S3_BUCKET Bucket that holds reports and user state; it must already exist
289
+ …
290
+ ```
291
+
292
+ See [the server guide](docs/server.md#required-s3-storage) for the details and
293
+ [user state and garbage collection](docs/server.md#user-state-and-garbage-collection) for how the policy works.
294
+
251
295
  **The client** shows each spec (Gherkin, Quint, DMN, Alloy, OpenSLO, and the rest by file type) with
252
296
  its combined result per environment and the layer behind it. See
253
297
  [integrating Atlas](docs/integrating.md#6-browse-it).
@@ -9,6 +9,6 @@
9
9
  * @see docs/reference/cli.md
10
10
  */
11
11
  /** The text `atlas --help` prints: every command, and the flags of the statechart commands. */
12
- export declare const USAGE = "atlas \u2014 executable statecharts\n\n atlas lint [--config atlas.config.ts | --specs <dir>] [--require-implementations]\n atlas plan [--config atlas.config.ts] [--state <name>]\n atlas run [--config atlas.config.ts] [--mode fast|showcase] [--workers n] [--state <name>]\n [--seed <name>]... [--paths id,id] [--shard i/n] [--rerun-failed | --reuse <run-dir>]\n [--no-retry] [--output <dir>]\n atlas merge [--config atlas.config.ts] [--output <dir>] <run-dir> <run-dir>...\n atlas export --specs <dir> --format scxml|mermaid|markdown|json [--out <file>]\n atlas server [--data <dir>] [--port 8080] [--store <url>]... one Atlas node: API, projects and client\n atlas client build [--no-data] [--out <dir>] build the project-agnostic Atlas client\n atlas dev [--port 5180] develop the Atlas server and client on one host\n atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)\n atlas report publish|stream|status|merge|doctor|policy \u2026 layered evidence for any kind of spec (atlas report --help)\n\n --workers path attempts that run at once (default: one per CPU, capped by the driver)\n --seed only paths that start from this seed (\"start\" for paths setup starts); repeatable\n --shard 2/4 run the second quarter of the plan; merge the shards' runs afterwards\n --rerun-failed keep the newest run's passed paths and run only the rest\n";
12
+ export declare const USAGE = "atlas \u2014 executable statecharts\n\n atlas lint [--config atlas.config.ts | --specs <dir>] [--require-implementations]\n atlas plan [--config atlas.config.ts] [--state <name>]\n atlas run [--config atlas.config.ts] [--mode fast|showcase] [--workers n] [--state <name>]\n [--seed <name>]... [--paths id,id] [--shard i/n] [--rerun-failed | --reuse <run-dir>]\n [--no-retry] [--output <dir>]\n atlas merge [--config atlas.config.ts] [--output <dir>] <run-dir> <run-dir>...\n atlas export --specs <dir> --format scxml|mermaid|markdown|json [--out <file>]\n atlas server [--port 8080] one Atlas node: API, projects and client; needs ATLAS_S3_* (atlas serve --help)\n atlas client build [--no-data] [--out <dir>] build the project-agnostic Atlas client\n atlas dev [--port 5180] develop the Atlas server and client on one host\n atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)\n atlas report publish|stream|status|merge|doctor|policy \u2026 layered evidence for any kind of spec (atlas report --help)\n\n --workers path attempts that run at once (default: one per CPU, capped by the driver)\n --seed only paths that start from this seed (\"start\" for paths setup starts); repeatable\n --shard 2/4 run the second quarter of the plan; merge the shards' runs afterwards\n --rerun-failed keep the newest run's passed paths and run only the rest\n";
13
13
  /** Runs `atlas` with `argv` (default: the process arguments) and returns the exit code. Throws for a command that is wrong, which the `atlas` binary reports as a message and exit code 2. */
14
14
  export declare function main(argv?: string[]): Promise<number>;
@@ -28,7 +28,7 @@ export const USAGE = `atlas — executable statecharts
28
28
  [--no-retry] [--output <dir>]
29
29
  atlas merge [--config atlas.config.ts] [--output <dir>] <run-dir> <run-dir>...
30
30
  atlas export --specs <dir> --format scxml|mermaid|markdown|json [--out <file>]
31
- atlas server [--data <dir>] [--port 8080] [--store <url>]... one Atlas node: API, projects and client
31
+ atlas server [--port 8080] one Atlas node: API, projects and client; needs ATLAS_S3_* (atlas serve --help)
32
32
  atlas client build [--no-data] [--out <dir>] build the project-agnostic Atlas client
33
33
  atlas dev [--port 5180] develop the Atlas server and client on one host
34
34
  atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)
@@ -34,7 +34,9 @@ export type ReadOptions = {
34
34
  * attachment gave none.
35
35
  *
36
36
  * A file that is missing, not allowed or too large is left out with a warning: evidence that could not be
37
- * found never stops the results from being published. Attachments without a `path` are kept untouched.
37
+ * found never stops the results from being published. The exception in kind is a video of a check that declared
38
+ * `recordingExpectation: "required"`: it stays as an attachment without bytes whose `omitted` says why (`not-found`,
39
+ * `not-allowed`, `too-large`, `over-budget`), so the recording receipt can name the gap. Attachments without a `path` are kept untouched.
38
40
  *
39
41
  * @param holder A check, step, attempt or any object that may have `attachments`, `steps` and `attempts`.
40
42
  * @param roots Folders a relative path is resolved against (in order), and the only folders an untrusted path may name.
@@ -11,13 +11,16 @@
11
11
  import { readFile, realpath, stat } from 'node:fs/promises';
12
12
  import path from 'node:path';
13
13
  import { contentTypeOf } from './media-types.js';
14
+ import { isVideoAttachment } from './recording-receipt.js';
14
15
  /**
15
16
  * Reads the files that attachments name (`path`), at every level (the holder, its `steps` and its `attempts`,
16
17
  * recursively), and hands their bytes over as `data` with a `contentType` guessed from the file name when the
17
18
  * attachment gave none.
18
19
  *
19
20
  * A file that is missing, not allowed or too large is left out with a warning: evidence that could not be
20
- * found never stops the results from being published. Attachments without a `path` are kept untouched.
21
+ * found never stops the results from being published. The exception in kind is a video of a check that declared
22
+ * `recordingExpectation: "required"`: it stays as an attachment without bytes whose `omitted` says why (`not-found`,
23
+ * `not-allowed`, `too-large`, `over-budget`), so the recording receipt can name the gap. Attachments without a `path` are kept untouched.
21
24
  *
22
25
  * @param holder A check, step, attempt or any object that may have `attachments`, `steps` and `attempts`.
23
26
  * @param roots Folders a relative path is resolved against (in order), and the only folders an untrusted path may name.
@@ -27,7 +30,7 @@ export async function readAttachmentFiles(holder, roots, options = {}) {
27
30
  const warnings = [];
28
31
  const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
29
32
  let attached = 0, bytes = 0;
30
- async function walk(value) {
33
+ async function walk(value, expectsRecording = value.recordingExpectation === 'required') {
31
34
  const out = { ...value };
32
35
  if (Array.isArray(out.attachments)) {
33
36
  const kept = [];
@@ -40,17 +43,24 @@ export async function readAttachmentFiles(holder, roots, options = {}) {
40
43
  const name = String(given.name ?? file);
41
44
  const { path: _path, ...rest } = given;
42
45
  const found = await locate(file, roots, options.trusted === true);
46
+ // A video that a check was expected to have stays in the layer as metadata with the reason it was left out
47
+ // (never the path), so the recording receipt can name the gap instead of the file vanishing.
48
+ const left = (reason) => { if (expectsRecording && isVideoAttachment({ ...rest, contentType: rest.contentType ?? contentTypeOf(name) }))
49
+ kept.push({ ...rest, omitted: reason }); };
43
50
  if (found.problem) {
44
51
  warnings.push(`${name}: ${found.problem}`);
52
+ left(found.problem.startsWith('file not found') ? 'not-found' : 'not-allowed');
45
53
  continue;
46
54
  }
47
55
  const size = (await stat(found.file)).size;
48
56
  if (size > maxBytes) {
49
57
  warnings.push(`${name}: ${(size / 1024 / 1024).toFixed(0)} MiB is over the limit of ${Math.round(maxBytes / 1024 / 1024)} MiB`);
58
+ left('too-large');
50
59
  continue;
51
60
  }
52
61
  if (options.budget && size > options.budget.remaining) {
53
62
  warnings.push(`${name}: the total size of attachments would pass the budget (raise --max-total-attachment-mb, or attach a uri)`);
63
+ left('over-budget');
54
64
  continue;
55
65
  }
56
66
  if (options.budget)
@@ -64,13 +74,13 @@ export async function readAttachmentFiles(holder, roots, options = {}) {
64
74
  if (Array.isArray(out.steps)) {
65
75
  const steps = [];
66
76
  for (const step of out.steps)
67
- steps.push(await walk(step));
77
+ steps.push(await walk(step, expectsRecording));
68
78
  out.steps = steps;
69
79
  }
70
80
  if (Array.isArray(out.attempts)) {
71
81
  const attempts = [];
72
82
  for (const attempt of out.attempts)
73
- attempts.push(await walk(attempt));
83
+ attempts.push(await walk(attempt, expectsRecording));
74
84
  out.attempts = attempts;
75
85
  }
76
86
  return out;
@@ -123,6 +123,23 @@ export type LayerHeader = {
123
123
  readonly execution: string;
124
124
  /** What the run as a whole carries: its logs, links and labels. */
125
125
  readonly evidence?: EvidenceSummary;
126
+ /** 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. */
127
+ readonly recordings?: RecordingCounts;
128
+ };
129
+ /** How many recordings a layer expected and what became of them (see `RecordingReceipt`). */
130
+ export type RecordingCounts = {
131
+ /** Checks that required a recording: `uploaded + referenced + skipped + unresolved`. */
132
+ readonly expected: number;
133
+ /** Stored in the layer, with a checksum. */
134
+ readonly uploaded: number;
135
+ /** Given only by `uri`: Atlas cannot vouch for it. */
136
+ readonly referenced: number;
137
+ /** Captured but left out, with a reason (too large, not allowed…). */
138
+ readonly skipped: number;
139
+ /** No video was attached at all. */
140
+ readonly unresolved: number;
141
+ /** Intentionally unrecorded checks: neither expected nor missing. */
142
+ readonly notApplicable: number;
126
143
  };
127
144
  /** One check of one report part. */
128
145
  export type LayerRow = {
@@ -145,6 +162,11 @@ export type LayerRow = {
145
162
  readonly durationMs: number | null;
146
163
  /** What this result carries beyond its outcome. Absent when nothing. */
147
164
  readonly evidence?: EvidenceSummary;
165
+ /** 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`. */
166
+ readonly recording?: {
167
+ readonly state: string;
168
+ readonly reason?: string;
169
+ };
148
170
  };
149
171
  /** The checks of one layer, read separately from its header. */
150
172
  export type LayerRows = {
@@ -0,0 +1,39 @@
1
+ import type { ObjectStore } from './object-store.js';
2
+ import type { GcPolicy } from '../../schema/src/user-state.js';
3
+ /** One layer the policy would delete. */
4
+ export type GcCandidate = {
5
+ readonly id: string;
6
+ readonly catalogKey: string;
7
+ readonly recordedAtMs: number;
8
+ };
9
+ /** What a policy would delete from a store right now. */
10
+ export type GcPlan = {
11
+ /** Layers to delete, oldest first. */
12
+ readonly candidates: readonly GcCandidate[];
13
+ /** Layers in the catalog that stay. */
14
+ readonly kept: number;
15
+ };
16
+ /** The outcome of applying a plan. */
17
+ export type GcOutcome = {
18
+ readonly deletedLayers: number;
19
+ readonly deletedBlobs: number;
20
+ };
21
+ /**
22
+ * Decides which layers a policy deletes, without deleting anything. `eligible` is the set of layer ids the caller has
23
+ * read and knows no longer decide any result (`ReportIndex.superseded()`); a layer not in it is never a candidate.
24
+ * Lists the catalog only. With `policy.enabled === false` the plan is empty.
25
+ *
26
+ * @example
27
+ * const plan = await planGarbage(store, { policy, nowMs: Date.now(), eligible: index.superseded() });
28
+ */
29
+ export declare function planGarbage(store: ObjectStore, input: {
30
+ readonly policy: GcPolicy;
31
+ readonly nowMs: number;
32
+ readonly eligible: ReadonlySet<string>;
33
+ }): Promise<GcPlan>;
34
+ /**
35
+ * Deletes the layers of a plan, then the blobs only they used. Store errors throw after whatever was already deleted;
36
+ * running the same plan again finishes the job, because every delete is idempotent and the catalog entry (what
37
+ * makes a layer visible) always goes before the content.
38
+ */
39
+ export declare function collectGarbage(store: ObjectStore, plan: GcPlan): Promise<GcOutcome>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @module Layer garbage collection
3
+ *
4
+ * Applies a garbage collection policy (`GcPolicy`, chosen by a person in the Atlas UI and kept in the user state) to the
5
+ * layers in a store. Atlas is otherwise deletion-free, so this is deliberately conservative: a layer is deleted only when
6
+ * (1) the caller says the report has read it and every result it carries has been replaced by a newer layer (`eligible`),
7
+ * (2) it was recorded longer ago than the policy allows, and (3) it is not among the newest `keepLatest` layers. Deleting
8
+ * it therefore cannot change the combined report. Its catalog entry goes first, so nothing visible ever points at a
9
+ * missing manifest; its manifest next; then the captured-file blobs that no remaining layer refers to. If any remaining
10
+ * layer cannot be read, no blob is deleted: what cannot be read is unknown, never unused. Specs, runs and commit
11
+ * inventories are not touched.
12
+ *
13
+ * @see docs/server.md#user-state-and-garbage-collection
14
+ */
15
+ import { eachLimited } from './completion-order.js';
16
+ import { LAYER_INDEX, layerKey, readLayer } from './layer-store.js';
17
+ const DAY_MS = 86_400_000;
18
+ const CATALOG = /^layers\/index\/(\d{16})-([0-9a-f]{64})\.json$/;
19
+ /**
20
+ * Decides which layers a policy deletes, without deleting anything. `eligible` is the set of layer ids the caller has
21
+ * read and knows no longer decide any result (`ReportIndex.superseded()`); a layer not in it is never a candidate.
22
+ * Lists the catalog only. With `policy.enabled === false` the plan is empty.
23
+ *
24
+ * @example
25
+ * const plan = await planGarbage(store, { policy, nowMs: Date.now(), eligible: index.superseded() });
26
+ */
27
+ export async function planGarbage(store, input) {
28
+ const entries = (await store.listKeys(LAYER_INDEX)).flatMap(key => {
29
+ const match = CATALOG.exec(key);
30
+ return match ? [{ id: match[2], catalogKey: key, recordedAtMs: Number.MAX_SAFE_INTEGER - Number(match[1]) }] : [];
31
+ });
32
+ // Catalog keys sort newest first (the time is inverted), so the layers to always keep are the first `keepLatest`.
33
+ entries.sort((a, b) => b.recordedAtMs - a.recordedAtMs || (a.id < b.id ? -1 : 1));
34
+ if (!input.policy.enabled)
35
+ return { candidates: [], kept: entries.length };
36
+ const cutoff = input.nowMs - input.policy.maxAgeDays * DAY_MS;
37
+ const candidates = entries.slice(input.policy.keepLatest).filter(entry => entry.recordedAtMs < cutoff && input.eligible.has(entry.id));
38
+ return { candidates: candidates.reverse(), kept: entries.length - candidates.length };
39
+ }
40
+ /**
41
+ * Deletes the layers of a plan, then the blobs only they used. Store errors throw after whatever was already deleted;
42
+ * running the same plan again finishes the job, because every delete is idempotent and the catalog entry (what
43
+ * makes a layer visible) always goes before the content.
44
+ */
45
+ export async function collectGarbage(store, plan) {
46
+ if (!plan.candidates.length)
47
+ return { deletedLayers: 0, deletedBlobs: 0 };
48
+ const doomed = new Set(plan.candidates.map(candidate => candidate.id));
49
+ // Which blobs the doomed layers used, read before anything is deleted. A layer that cannot be read stays: unknown is not garbage.
50
+ const used = new Map();
51
+ const readable = [];
52
+ await eachLimited(plan.candidates, 16, async (candidate) => {
53
+ const layer = await readLayer(store, candidate.id).catch(() => null);
54
+ if (!layer)
55
+ return;
56
+ used.set(candidate.id, new Set(Object.values(layer.bundle).flatMap(file => file.parts.map(part => part.blob))));
57
+ readable.push(candidate);
58
+ });
59
+ if (!readable.length)
60
+ return { deletedLayers: 0, deletedBlobs: 0 };
61
+ await store.deleteMany(readable.map(candidate => candidate.catalogKey));
62
+ await store.deleteMany(readable.map(candidate => layerKey(candidate.id)));
63
+ const removed = new Set(readable.map(candidate => candidate.id));
64
+ const orphanable = new Set([...removed].flatMap(id => [...used.get(id)]));
65
+ // Blobs that any remaining layer still points at must stay. If a remaining layer cannot be read, keep every blob.
66
+ const remaining = (await store.listKeys(LAYER_INDEX)).flatMap(key => CATALOG.exec(key)?.[2] ?? []).filter(id => !removed.has(id));
67
+ let unreadable = false;
68
+ await eachLimited(remaining, 16, async (id) => {
69
+ const layer = await readLayer(store, id).catch(() => null);
70
+ if (!layer) {
71
+ unreadable = true;
72
+ return;
73
+ }
74
+ for (const file of Object.values(layer.bundle))
75
+ for (const part of file.parts)
76
+ orphanable.delete(part.blob);
77
+ });
78
+ if (unreadable)
79
+ return { deletedLayers: removed.size, deletedBlobs: 0 };
80
+ await store.deleteMany([...orphanable]);
81
+ return { deletedLayers: removed.size, deletedBlobs: orphanable.size };
82
+ }
@@ -74,10 +74,13 @@ export function layerEvidence(summary, backend) {
74
74
  return found;
75
75
  };
76
76
  const rows = [];
77
+ const receipt = summary.recordings;
78
+ const expectations = receipt ? new Map(receipt.items.map(item => [item.check, item])) : null;
77
79
  for (const part of summary.parts) {
78
80
  for (const check of part.checks) {
79
81
  const test = tests.get(check.id);
80
82
  const evidence = summarizeEvidence(check);
83
+ const item = expectations?.get(check.id);
81
84
  rows.push({
82
85
  kind: part.kind,
83
86
  id: check.id,
@@ -90,6 +93,7 @@ export function layerEvidence(summary, backend) {
90
93
  variant: test ? { system: test.system, runtime: test.runtime, seed: test.seed } : {},
91
94
  durationMs: test?.durationMs ?? check.durationMs ?? null,
92
95
  ...(evidence ? { evidence } : {}),
96
+ ...(item ? { recording: { state: item.state, ...(item.reason ? { reason: item.reason } : {}) } } : {}),
93
97
  });
94
98
  }
95
99
  }
@@ -113,6 +117,7 @@ export function layerEvidence(summary, backend) {
113
117
  counts: outcomeCounts(rows),
114
118
  execution: textFingerprint(layerExecutionKey(summary)),
115
119
  ...(layerSummaryEvidence ? { evidence: layerSummaryEvidence } : {}),
120
+ ...(receipt ? { recordings: { expected: receipt.expected, uploaded: receipt.uploaded, referenced: receipt.referenced, skipped: receipt.skipped, unresolved: receipt.unresolved, notApplicable: receipt.notApplicable } } : {}),
116
121
  };
117
122
  const fingerprints = {};
118
123
  for (const spec of summary.specs)
@@ -15,7 +15,7 @@ import type { SourceRecord } from './protocol.js';
15
15
  /** String choices (such as `{ device: 'iphone-15' }`) that make a result its own slot. Compared as sorted key/value pairs. */
16
16
  export type LayerParameters = Readonly<Record<string, string>>;
17
17
  /** What a layer may carry as a whole. All optional and open: see docs/extending.md. */
18
- export type LayerExtras = Partial<Pick<import('../../schema/src/layer.js').LayerDocument, 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'extensions'>>;
18
+ export type LayerExtras = Partial<Pick<import('../../schema/src/layer.js').LayerDocument, 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'extensions' | 'recordings'>>;
19
19
  /** A layer without its captured files: what composition and the viewer's lists read. */
20
20
  export type LayerSummary = LayerExtras & {
21
21
  /** `modified`: recorded from a checkout with uncommitted changes. `unknown`: origin not captured. Absent for a clean checkout. Advisory; never changes a status. */
@@ -15,6 +15,7 @@ import { normalizeEnvironment } from './layer-axes.js';
15
15
  import { publishLayerBundle } from './layer-store.js';
16
16
  import { contentTypeOf } from './media-types.js';
17
17
  import { parseWith } from './parse-with.js';
18
+ import { isVideoAttachment, recordingReceipt } from './recording-receipt.js';
18
19
  import { sha256Hex } from './sigv4.js';
19
20
  /** A small text value stays in the layer's own document; anything longer is stored as a file. */
20
21
  const INLINE_TEXT_BYTES = 8 * 1024;
@@ -28,7 +29,7 @@ function decodeBase64(text) {
28
29
  * screenshot attached ten times is stored once), referred to by `bundleKey`. What a producer wrote as `data`,
29
30
  * `base64` or a long `text` becomes a reference; a short `text` stays inline; a `uri` is left alone.
30
31
  */
31
- async function materialize(holder, entries, limit) {
32
+ async function materialize(holder, entries, limit, expectsRecording = holder.recordingExpectation === 'required') {
32
33
  const out = { ...holder };
33
34
  if (Array.isArray(out.attachments)) {
34
35
  out.attachments = await Promise.all(out.attachments.map(async (given) => {
@@ -41,6 +42,11 @@ async function materialize(holder, entries, limit) {
41
42
  bytes = encoder.encode(text);
42
43
  if (!bytes)
43
44
  return { ...kept, ...(text !== undefined ? { bytes: encoder.encode(text).length } : {}) };
45
+ if (bytes.length > limit && expectsRecording && isVideoAttachment({ ...kept, ...(kept.contentType ? {} : { contentType: contentTypeOf(String(kept.name)) }) })) {
46
+ // A recording the check was expected to have is reported as left out (and why) instead of failing the whole publish.
47
+ const { text: _inline, ...rest } = kept;
48
+ return { ...rest, contentType: kept.contentType ?? contentTypeOf(String(kept.name)), bytes: bytes.length, sha256: await sha256Hex(bytes), omitted: 'too-large' };
49
+ }
44
50
  if (bytes.length > limit)
45
51
  throw new Error(`publishChecks: attachment "${String(kept.name)}" is ${bytes.length} bytes, over the limit of ${limit}. Store it elsewhere and attach its uri`);
46
52
  const sha256 = await sha256Hex(bytes);
@@ -51,9 +57,9 @@ async function materialize(holder, entries, limit) {
51
57
  }));
52
58
  }
53
59
  if (Array.isArray(out.steps))
54
- out.steps = await Promise.all(out.steps.map(step => materialize(step, entries, limit)));
60
+ out.steps = await Promise.all(out.steps.map(step => materialize(step, entries, limit, expectsRecording)));
55
61
  if (Array.isArray(out.attempts))
56
- out.attempts = await Promise.all(out.attempts.map(attempt => materialize(attempt, entries, limit)));
62
+ out.attempts = await Promise.all(out.attempts.map(attempt => materialize(attempt, entries, limit, expectsRecording)));
57
63
  return out;
58
64
  }
59
65
  /**
@@ -81,6 +87,7 @@ export async function publishChecks(store, input, options = {}) {
81
87
  const entries = new Map();
82
88
  const checks = await Promise.all(args.checks.map(check => materialize(check, entries, limit)));
83
89
  const layerEvidence = await materialize({ attachments: args.attachments }, entries, limit);
90
+ const recordings = recordingReceipt(checks);
84
91
  const total = [...entries.values()].reduce((sum, entry) => sum + entry.bytes.length, 0);
85
92
  const totalLimit = options.maxTotalAttachmentBytes ?? 2 * 1024 * 1024 * 1024;
86
93
  if (total > totalLimit)
@@ -110,6 +117,7 @@ export async function publishChecks(store, input, options = {}) {
110
117
  parts: [part],
111
118
  source: null,
112
119
  recording: null,
120
+ ...(recordings ? { recordings } : {}),
113
121
  ...(args.tags?.length ? { tags: args.tags } : {}),
114
122
  ...(args.labels && Object.keys(args.labels).length ? { labels: Object.fromEntries(Object.entries(args.labels).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) } : {}),
115
123
  ...(args.links?.length ? { links: args.links } : {}),
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @module Recording receipts
3
+ *
4
+ * Answers "did the recordings we expected get published?" apart from "did the tests pass?". `recordingReceipt` reads the
5
+ * checks a producer is about to publish (after their files were stored) and describes, per check that declared a
6
+ * `recordingExpectation`, whether its video was uploaded, only referenced, left out (with a reason) or never attached.
7
+ * `recordingCompleteness` reduces the winning results of a report to the figures `atlas report status` prints. Neither
8
+ * reads a clock or the environment, and neither changes a status, a slot or a winner: a receipt describes publication, it
9
+ * does not decide a result. A layer with no receipt is Unknown, never Complete.
10
+ *
11
+ * @see docs/rich-evidence.md#recording-completeness
12
+ */
13
+ import type { RecordingCompleteness, RecordingReceipt } from '../../schema/src/layer.js';
14
+ type Loose = Record<string, unknown>;
15
+ /** Is this attachment a screen recording: a role of `video`, or a `video/*` media type? */
16
+ export declare function isVideoAttachment(attachment: Loose): boolean;
17
+ /**
18
+ * The receipt for the checks of one layer, or `undefined` when no check declares a `recordingExpectation` (the layer then
19
+ * claims nothing). Call it with checks whose attachment bytes are already stored (`bundleKey`, `bytes` and `sha256` set).
20
+ *
21
+ * A `required` check is `uploaded` when any video of it has stored bytes, `referenced` when one has only a `uri`,
22
+ * `skipped` when its videos were left out (the attachment's `omitted` is the reason) and `unresolved` when it has no
23
+ * video at all. A `not-applicable` check is listed but is neither expected nor missing. Items are sorted by check id so the
24
+ * same input is always the same receipt.
25
+ */
26
+ export declare function recordingReceipt(checks: readonly Loose[]): RecordingReceipt | undefined;
27
+ /** One result as {@link recordingCompleteness} reads it. */
28
+ export type RecordingResult = {
29
+ readonly spec: string | null;
30
+ readonly check: string;
31
+ readonly environment: string | null;
32
+ readonly layer: string;
33
+ /** Whether the layer the result came from carried a receipt. */
34
+ readonly hasReceipt: boolean;
35
+ /** The result's item in that receipt, when its check declared an expectation. */
36
+ readonly recording?: {
37
+ readonly state: string;
38
+ readonly reason?: string;
39
+ };
40
+ };
41
+ /**
42
+ * Reduces the winning results of a report to its recording completeness. `incomplete` when any expected recording is
43
+ * skipped or unresolved; otherwise `unknown` when a contributing layer has no receipt (nothing is claimed for it, and a
44
+ * report with no results at all is unknown too); otherwise `complete` when something was expected and `none-expected` when
45
+ * nothing was. Gaps are sorted so the output never depends on read order.
46
+ */
47
+ export declare function recordingCompleteness(results: readonly RecordingResult[]): RecordingCompleteness;
48
+ export {};