@crvouga/atlas 0.17.0 → 1.1.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 (70) 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/garbage.d.ts +39 -0
  6. package/dist/reports/src/garbage.js +82 -0
  7. package/dist/reports/src/report-index.d.ts +6 -0
  8. package/dist/reports/src/report-index.js +14 -0
  9. package/dist/schema/src/index.d.ts +1 -0
  10. package/dist/schema/src/index.js +1 -0
  11. package/dist/schema/src/server-config.d.ts +7 -49
  12. package/dist/schema/src/server-config.js +8 -21
  13. package/dist/schema/src/user-state.d.ts +115 -0
  14. package/dist/schema/src/user-state.js +54 -0
  15. package/dist/server/src/app.d.ts +16 -2
  16. package/dist/server/src/app.js +62 -0
  17. package/dist/server/src/config.d.ts +16 -21
  18. package/dist/server/src/config.js +48 -57
  19. package/dist/server/src/dev.d.ts +1 -1
  20. package/dist/server/src/dev.js +20 -7
  21. package/dist/server/src/index.d.ts +5 -1
  22. package/dist/server/src/index.js +5 -1
  23. package/dist/server/src/main.d.ts +23 -7
  24. package/dist/server/src/main.js +72 -26
  25. package/dist/server/src/openapi.js +24 -5
  26. package/dist/server/src/projects.js +89 -14
  27. package/dist/server/src/settings.d.ts +23 -7
  28. package/dist/server/src/settings.js +50 -60
  29. package/dist/server/src/storage.d.ts +68 -0
  30. package/dist/server/src/storage.js +125 -0
  31. package/dist/server/src/user-state.d.ts +31 -0
  32. package/dist/server/src/user-state.js +74 -0
  33. package/docs/agents.md +1 -1
  34. package/docs/deploying.md +38 -39
  35. package/docs/reference/cli.md +12 -7
  36. package/docs/reference/http-api.md +30 -2
  37. package/docs/reference/reports.md +71 -0
  38. package/docs/reference/schema.md +102 -16
  39. package/docs/reference/server.md +263 -41
  40. package/docs/report-backends.md +0 -144
  41. package/docs/report-contract.md +1 -1
  42. package/docs/runners.md +1 -1
  43. package/docs/server.md +141 -36
  44. package/docs/statecharts.md +1 -1
  45. package/docs/ui.md +8 -1
  46. package/docs/workspaces.md +5 -2
  47. package/llms-full.txt +238 -234
  48. package/llms.txt +1 -1
  49. package/package.json +2 -2
  50. package/packages/reports/STORAGE.md +1 -1
  51. package/packages/schema/schemas/gc-policy.schema.json +36 -0
  52. package/packages/schema/schemas/gc-run-result.schema.json +106 -0
  53. package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
  54. package/packages/schema/schemas/server-config.schema.json +0 -41
  55. package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
  56. package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
  57. package/packages/schema/schemas/user-state.schema.json +140 -0
  58. package/packages/server/README.md +11 -6
  59. package/packages/server/openapi.json +510 -126
  60. package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
  61. package/packages/visualizer/src/evidence/check-detail.tsx +6 -1
  62. package/packages/visualizer/src/evidence/evidence-context.tsx +9 -2
  63. package/packages/visualizer/src/evidence/load-state.tsx +65 -0
  64. package/packages/visualizer/src/evidence/overview.tsx +4 -6
  65. package/packages/visualizer/src/evidence/recording-page.tsx +17 -7
  66. package/packages/visualizer/src/evidence/spec-header.tsx +8 -6
  67. package/packages/visualizer/src/evidence/spec-page.tsx +98 -15
  68. package/packages/visualizer/src/find/Finder.tsx +45 -7
  69. package/packages/visualizer/src/spec-kind.test.ts +14 -1
  70. package/packages/visualizer/src/spec-kind.ts +28 -2
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @module Required S3 storage configuration
3
+ *
4
+ * The Atlas server keeps ALL reports and ALL user state (the garbage collection policy and saved settings) in
5
+ * S3-compatible object storage, and nowhere else: no local folder, no memory, no database. So the storage settings
6
+ * are required, come only from environment variables (they hold secrets), and have no defaults for where or as whom
7
+ * to connect. `resolveS3Config` checks every variable in one pass and reports all the problems together;
8
+ * `startupFailure` renders them as a banner that cannot be missed. `S3_VARIABLES` is the single description of the
9
+ * variables: the banner, `.env.example` and the documentation table are all made from it.
10
+ *
11
+ * @see docs/server.md#required-s3-storage
12
+ */
13
+ type Env = Readonly<Record<string, string | undefined>>;
14
+ /** One environment variable of the storage configuration. */
15
+ export type S3Variable = {
16
+ readonly name: string;
17
+ readonly required: boolean;
18
+ /** Shown in the banner and the documentation. Never a real secret. */
19
+ readonly meaning: string;
20
+ readonly example: string;
21
+ /** Secrets are never printed, not even when invalid. */
22
+ readonly secret?: true;
23
+ /** Returns what is wrong with a value that is set, or null when it is fine. */
24
+ readonly check: (value: string) => string | null;
25
+ };
26
+ /** Every variable that configures storage, required ones first. */
27
+ export declare const S3_VARIABLES: readonly S3Variable[];
28
+ /** The storage settings once checked: everything needed to open the bucket, with credentials spelled out. */
29
+ export type S3Config = {
30
+ readonly endpoint: string;
31
+ readonly bucket: string;
32
+ readonly region: string;
33
+ readonly prefix: string;
34
+ readonly pathStyle: boolean;
35
+ readonly accessKeyId: string;
36
+ readonly secretAccessKey: string;
37
+ readonly sessionToken?: string;
38
+ };
39
+ /** The state of one variable after checking, for the banner. */
40
+ export type S3Finding = {
41
+ readonly variable: S3Variable;
42
+ readonly status: 'ok' | 'missing' | 'invalid' | 'unset';
43
+ readonly problem?: string;
44
+ };
45
+ /** The storage configuration could not be used. `findings` has every variable, not only the first problem. */
46
+ export declare class S3ConfigError extends Error {
47
+ readonly findings: readonly S3Finding[];
48
+ readonly reason: string;
49
+ constructor(findings: readonly S3Finding[], reason?: string);
50
+ }
51
+ /** Looks at every storage variable and returns what each is. Nothing here throws. */
52
+ export declare function inspectS3Env(env: Env): readonly S3Finding[];
53
+ /**
54
+ * The storage settings from the environment. Throws {@link S3ConfigError}, whose message lists every variable and
55
+ * marks each missing or invalid one, when any required variable is absent or any set variable is malformed.
56
+ *
57
+ * @example
58
+ * const s3 = resolveS3Config(process.env);
59
+ */
60
+ export declare function resolveS3Config(env: Env): S3Config;
61
+ /** Where the storage is, without a secret: what the log may say. */
62
+ export declare const describeS3: (config: S3Config) => string;
63
+ /**
64
+ * The banner printed when the server cannot start because of storage: why storage is required, then EVERY variable by
65
+ * name with what is wrong with it. Values of secrets are never printed.
66
+ */
67
+ export declare function startupFailure(findings: readonly S3Finding[], reason: string, detail?: string): string;
68
+ export {};
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @module Required S3 storage configuration
3
+ *
4
+ * The Atlas server keeps ALL reports and ALL user state (the garbage collection policy and saved settings) in
5
+ * S3-compatible object storage, and nowhere else: no local folder, no memory, no database. So the storage settings
6
+ * are required, come only from environment variables (they hold secrets), and have no defaults for where or as whom
7
+ * to connect. `resolveS3Config` checks every variable in one pass and reports all the problems together;
8
+ * `startupFailure` renders them as a banner that cannot be missed. `S3_VARIABLES` is the single description of the
9
+ * variables: the banner, `.env.example` and the documentation table are all made from it.
10
+ *
11
+ * @see docs/server.md#required-s3-storage
12
+ */
13
+ const hostOf = (value) => {
14
+ try {
15
+ const url = new URL(value);
16
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
17
+ return 'must start with https:// (or http:// for a local S3 server)';
18
+ if (url.username || url.password)
19
+ return 'must not contain a user name or password; credentials have their own variables';
20
+ if (url.search || url.hash)
21
+ return 'must be just the address, with no query or fragment';
22
+ return null;
23
+ }
24
+ catch {
25
+ return 'is not a URL; write it like https://s3.eu-west-1.amazonaws.com';
26
+ }
27
+ };
28
+ /** Every variable that configures storage, required ones first. */
29
+ export const S3_VARIABLES = [
30
+ { name: 'ATLAS_S3_ENDPOINT', required: true, meaning: 'S3 API endpoint of the object storage service', example: 'https://s3.eu-west-1.amazonaws.com', check: hostOf },
31
+ { name: 'ATLAS_S3_BUCKET', required: true, meaning: 'Bucket that holds reports and user state; it must already exist', example: 'my-atlas-reports',
32
+ check: value => (/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value) ? null : 'must be 3-63 lowercase letters, digits, dots or hyphens, starting and ending with a letter or digit') },
33
+ { name: 'ATLAS_S3_REGION', required: true, meaning: 'Region of the bucket (`auto` for Cloudflare R2, `us-east-1` for most self-hosted servers)', example: 'eu-west-1',
34
+ check: value => (/^[a-z0-9][a-z0-9-]*$/.test(value) ? null : 'must be lowercase letters, digits and hyphens, like eu-west-1') },
35
+ { name: 'ATLAS_S3_ACCESS_KEY_ID', required: true, meaning: 'Access key id of an account that may list, read, write and delete in the bucket', example: 'AKIAEXAMPLE', secret: true,
36
+ check: value => (/^\S+$/.test(value) ? null : 'must not contain spaces or line breaks') },
37
+ { name: 'ATLAS_S3_SECRET_ACCESS_KEY', required: true, meaning: 'Secret key that goes with the access key id', example: 'change-me', secret: true,
38
+ check: value => (/^\S+$/.test(value) ? null : 'must not contain spaces or line breaks') },
39
+ { name: 'ATLAS_S3_PREFIX', required: false, meaning: 'Folder inside the bucket for everything Atlas writes (default: the bucket root)', example: 'atlas',
40
+ check: value => (/^[\w./-]+$/.test(value) && !value.split('/').includes('..') ? null : 'may contain only letters, digits, `.`, `_`, `-` and `/`, and no `..`') },
41
+ { name: 'ATLAS_S3_PATH_STYLE', required: false, meaning: 'Address the bucket as endpoint/bucket/key (default: yes; set 0 for virtual-hosted style)', example: '1',
42
+ check: value => (['1', '0', 'true', 'false'].includes(value) ? null : 'must be 1, 0, true or false') },
43
+ { name: 'ATLAS_S3_SESSION_TOKEN', required: false, meaning: 'Session token, only for temporary credentials', example: '', secret: true,
44
+ check: value => (/^\S+$/.test(value) ? null : 'must not contain spaces or line breaks') }
45
+ ];
46
+ /** The storage configuration could not be used. `findings` has every variable, not only the first problem. */
47
+ export class S3ConfigError extends Error {
48
+ findings;
49
+ reason;
50
+ constructor(findings, reason = 'the S3 configuration is missing or invalid') {
51
+ super(startupFailure(findings, reason));
52
+ this.findings = findings;
53
+ this.reason = reason;
54
+ this.name = 'S3ConfigError';
55
+ }
56
+ }
57
+ /** Looks at every storage variable and returns what each is. Nothing here throws. */
58
+ export function inspectS3Env(env) {
59
+ return S3_VARIABLES.map(variable => {
60
+ const raw = env[variable.name];
61
+ if (raw === undefined || raw.trim() === '')
62
+ return { variable, status: variable.required ? 'missing' : 'unset' };
63
+ const problem = variable.check(raw.trim());
64
+ return problem ? { variable, status: 'invalid', problem } : { variable, status: 'ok' };
65
+ });
66
+ }
67
+ /**
68
+ * The storage settings from the environment. Throws {@link S3ConfigError}, whose message lists every variable and
69
+ * marks each missing or invalid one, when any required variable is absent or any set variable is malformed.
70
+ *
71
+ * @example
72
+ * const s3 = resolveS3Config(process.env);
73
+ */
74
+ export function resolveS3Config(env) {
75
+ const findings = inspectS3Env(env);
76
+ if (findings.some(finding => finding.status === 'missing' || finding.status === 'invalid'))
77
+ throw new S3ConfigError(findings);
78
+ const value = (name) => env[name]?.trim() ?? '';
79
+ const style = value('ATLAS_S3_PATH_STYLE');
80
+ return {
81
+ endpoint: value('ATLAS_S3_ENDPOINT').replace(/\/+$/, ''), bucket: value('ATLAS_S3_BUCKET'), region: value('ATLAS_S3_REGION'),
82
+ prefix: value('ATLAS_S3_PREFIX').replace(/^\/+|\/+$/g, ''), pathStyle: style === '' ? true : style === '1' || style === 'true',
83
+ accessKeyId: value('ATLAS_S3_ACCESS_KEY_ID'), secretAccessKey: value('ATLAS_S3_SECRET_ACCESS_KEY'),
84
+ ...(value('ATLAS_S3_SESSION_TOKEN') ? { sessionToken: value('ATLAS_S3_SESSION_TOKEN') } : {})
85
+ };
86
+ }
87
+ /** Where the storage is, without a secret: what the log may say. */
88
+ export const describeS3 = (config) => `s3://${config.bucket}/${config.prefix ? `${config.prefix}/` : ''} on ${new URL(config.endpoint).host} (${config.region})`;
89
+ const RULE = '='.repeat(78);
90
+ /**
91
+ * The banner printed when the server cannot start because of storage: why storage is required, then EVERY variable by
92
+ * name with what is wrong with it. Values of secrets are never printed.
93
+ */
94
+ export function startupFailure(findings, reason, detail) {
95
+ const width = Math.max(...findings.map(finding => finding.variable.name.length));
96
+ const mark = (finding) => finding.status === 'missing' ? '[MISSING]' : finding.status === 'invalid' ? '[INVALID]' : finding.status === 'ok' ? '[ok] ' : '[not set]';
97
+ const line = (finding) => {
98
+ const { variable } = finding;
99
+ const note = finding.status === 'invalid' ? `${finding.problem}` : variable.meaning;
100
+ return ` ${mark(finding)} ${variable.name.padEnd(width)} ${note}`;
101
+ };
102
+ const required = findings.filter(finding => finding.variable.required);
103
+ const optional = findings.filter(finding => !finding.variable.required);
104
+ const bad = findings.filter(finding => finding.status === 'missing' || finding.status === 'invalid').map(finding => finding.variable.name);
105
+ return [
106
+ '', RULE,
107
+ ' ATLAS CANNOT START: S3 STORAGE IS NOT USABLE',
108
+ RULE,
109
+ ` Why: ${reason}.`,
110
+ ' Reason: Atlas stores ALL reports and ALL user state (the garbage collection policy and saved',
111
+ ' settings) in S3-compatible object storage, and nowhere else. There is no local-disk,',
112
+ ' in-memory or database fallback, so the server refuses to start without working S3.',
113
+ ...(detail ? ['', ` S3 said: ${detail}`] : []),
114
+ '',
115
+ ' Required environment variables:',
116
+ ...required.map(line),
117
+ '',
118
+ ' Optional environment variables:',
119
+ ...optional.map(line),
120
+ '',
121
+ bad.length ? ` Fix these ${bad.length}: ${bad.join(', ')}` : ' Every variable is well-formed, so the values themselves are wrong; check them against the storage service.',
122
+ ' See .env.example for a complete example and docs/server.md#required-s3-storage.',
123
+ ' Exiting with status 2.', RULE, ''
124
+ ].join('\n');
125
+ }
@@ -0,0 +1,31 @@
1
+ import type { ObjectStore } from '../../reports/src/object-store.js';
2
+ import { type UserState } from '../../schema/src/user-state.js';
3
+ /** Where the user state lives, relative to the store (the bucket plus `ATLAS_S3_PREFIX`). */
4
+ export declare const USER_STATE_KEY = "server/state.json";
5
+ /** The user state as stored: the document, its `revision` and whether the object `exists` yet. */
6
+ export type StoredUserState = {
7
+ readonly state: UserState;
8
+ readonly revision: string;
9
+ readonly exists: boolean;
10
+ };
11
+ /** Raised when the stored user state cannot be trusted. The message says where it is and what to do. */
12
+ export declare class UserStateError extends Error {
13
+ }
14
+ /** The user state of one server: read it, or change it against the revision that was read. */
15
+ export type UserStateStore = {
16
+ /** Reads the document. Throws {@link UserStateError} when it exists but is unreadable or invalid. */
17
+ read(): Promise<StoredUserState>;
18
+ /**
19
+ * Applies `change` to the stored state and writes the result, if `revision` is still the stored one. Throws a
20
+ * `UserStateError` starting with "Settings changed" for a stale revision.
21
+ */
22
+ update(revision: string, change: (state: UserState) => UserState): Promise<StoredUserState>;
23
+ };
24
+ /**
25
+ * The user state kept in `store` at {@link USER_STATE_KEY}.
26
+ *
27
+ * @example
28
+ * const userState = userStateStore(s3Backend.store);
29
+ * const { state } = await userState.read();
30
+ */
31
+ export declare function userStateStore(store: ObjectStore): UserStateStore;
@@ -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
@@ -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