@crvouga/atlas 0.17.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 (60) hide show
  1. package/CHANGELOG.md +6 -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 +1 -1
  46. package/docs/workspaces.md +5 -2
  47. package/llms-full.txt +231 -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
@@ -1,30 +1,46 @@
1
1
  /**
2
2
  * @module `atlas serve`
3
3
  *
4
- * Starts the Atlas server from its configuration: opens every backend (creating the data folder), proves the upload
5
- * backend can be written to before listening, finds or builds the client, listens at once (so a liveness probe finds the
6
- * process while a large backend is still being read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared
7
- * with `atlas dev`, so the two run the same server.
4
+ * Starts the Atlas server from its configuration. Storage comes first and is not optional: the required `ATLAS_S3_*`
5
+ * variables are checked (every problem listed together), the bucket is proven reachable and writable, and the user
6
+ * state (garbage collection policy and saved settings) is read from it, all before anything listens. Any failure prints
7
+ * a banner naming every variable and exits 2; there is no fallback to a local folder, memory or a database. Then it
8
+ * finds or builds the client, listens at once (so a liveness probe finds the process while a large bucket is still being
9
+ * read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared with `atlas dev`, so the two run the same server.
8
10
  *
9
11
  * @see docs/server.md#run-it
10
12
  */
11
13
  import type { BackendInput } from '../../reports/src/compose.js';
12
14
  import { type AtlasServerOptions } from './app.js';
13
15
  import { type ResolvedServerConfig } from './config.js';
16
+ import { type StoredUserState } from './user-state.js';
14
17
  type Env = Readonly<Record<string, string | undefined>>;
15
- /** What `serveMain` and `devMain` are given besides arguments: where output goes, and, for tests and embedding, the environment, working directory and an `AbortSignal` that stops the server (otherwise SIGTERM and SIGINT do). */
18
+ /** What `serveMain` and `devMain` are given besides arguments: where output goes, and, for tests and embedding, the environment, working directory, the `fetch` S3 requests are sent with, and an `AbortSignal` that stops the server (otherwise SIGTERM and SIGINT do). */
16
19
  export type ServeIo = {
17
20
  readonly out: (text: string) => void;
18
21
  readonly env?: Env;
19
22
  readonly cwd?: string;
20
23
  readonly signal?: AbortSignal;
24
+ readonly fetch?: typeof fetch;
21
25
  };
22
- /** Open every backend of a configuration. The data folder is made if it is not there. */
23
- export declare function openConfiguredBackends(config: ResolvedServerConfig, env: Env, cwd: string): (BackendInput & {
26
+ /** The id of the one backend: the S3 bucket. */
27
+ export declare const S3_BACKEND_ID = "s3";
28
+ /** Open the S3 bucket named by a configuration, with exactly the credentials it gives (no ambient AWS lookup). Nothing is contacted yet. */
29
+ export declare function openConfiguredBackends(config: ResolvedServerConfig, fetchImpl?: typeof fetch): (BackendInput & {
24
30
  readonly writable: boolean;
25
31
  })[];
26
32
  /** What `createAtlasServer` is given for a configuration: `atlas serve` and `atlas dev` run the same server. */
27
33
  export declare function serverOptionsOf(config: ResolvedServerConfig, backends: ReturnType<typeof openConfiguredBackends>, log?: (line: Record<string, unknown>) => void): AtlasServerOptions;
34
+ /**
35
+ * Check the storage and read the user state, in that order, with the failure banner for each way it can go wrong.
36
+ * Returns the opened backends and the state, or the text to print before exiting 2.
37
+ */
38
+ export declare function connectStorage(config: ResolvedServerConfig, env: Env, io: Pick<ServeIo, 'fetch'>): Promise<{
39
+ readonly backends: ReturnType<typeof openConfiguredBackends>;
40
+ readonly stored: StoredUserState;
41
+ } | {
42
+ readonly failure: string;
43
+ }>;
28
44
  /** `atlas serve`. Runs until it is told to stop; returns the exit code. */
29
45
  export declare function serveMain(argv: readonly string[], io: ServeIo): Promise<number>;
30
46
  export {};
@@ -1,38 +1,45 @@
1
1
  /**
2
2
  * @module `atlas serve`
3
3
  *
4
- * Starts the Atlas server from its configuration: opens every backend (creating the data folder), proves the upload
5
- * backend can be written to before listening, finds or builds the client, listens at once (so a liveness probe finds the
6
- * process while a large backend is still being read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared
7
- * with `atlas dev`, so the two run the same server.
4
+ * Starts the Atlas server from its configuration. Storage comes first and is not optional: the required `ATLAS_S3_*`
5
+ * variables are checked (every problem listed together), the bucket is proven reachable and writable, and the user
6
+ * state (garbage collection policy and saved settings) is read from it, all before anything listens. Any failure prints
7
+ * a banner naming every variable and exits 2; there is no fallback to a local folder, memory or a database. Then it
8
+ * finds or builds the client, listens at once (so a liveness probe finds the process while a large bucket is still being
9
+ * read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared with `atlas dev`, so the two run the same server.
8
10
  *
9
11
  * @see docs/server.md#run-it
10
12
  */
11
- import { mkdirSync } from 'node:fs';
12
13
  import path from 'node:path';
13
- import { openStore } from '../../reports/src/store-url.js';
14
+ import { s3Store } from '../../reports/src/s3-store.js';
14
15
  import { createAtlasServer } from './app.js';
15
16
  import { ConfigError, loadServerConfig, SERVER_USAGE } from './config.js';
16
17
  import { listen } from './host.js';
17
- import { fileServerSettings } from './settings.js';
18
+ import { storedSettings } from './settings.js';
18
19
  import { findSite } from './site.js';
19
- /** Open every backend of a configuration. The data folder is made if it is not there. */
20
- export function openConfiguredBackends(config, env, cwd) {
21
- return config.backends.map((spec, i) => {
22
- if (i === 0)
23
- mkdirSync(spec.url, { recursive: true });
24
- const opened = openStore(spec.url, { base: cwd, env });
25
- return { id: spec.id, label: spec.label, store: opened.store, writable: spec.id === config.writeId };
20
+ import { describeS3, inspectS3Env, S3ConfigError, startupFailure } from './storage.js';
21
+ import { USER_STATE_KEY, UserStateError, userStateStore } from './user-state.js';
22
+ /** The id of the one backend: the S3 bucket. */
23
+ export const S3_BACKEND_ID = 's3';
24
+ /** Open the S3 bucket named by a configuration, with exactly the credentials it gives (no ambient AWS lookup). Nothing is contacted yet. */
25
+ export function openConfiguredBackends(config, fetchImpl) {
26
+ const { s3 } = config;
27
+ const store = s3Store({
28
+ bucket: s3.bucket, prefix: s3.prefix, region: s3.region, endpoint: s3.endpoint, forcePathStyle: s3.pathStyle,
29
+ credentials: { accessKeyId: s3.accessKeyId, secretAccessKey: s3.secretAccessKey, ...(s3.sessionToken ? { sessionToken: s3.sessionToken } : {}) },
30
+ env: {}, ...(fetchImpl ? { fetch: fetchImpl } : {})
26
31
  });
32
+ return [{ id: S3_BACKEND_ID, label: describeS3(s3), store, writable: true }];
27
33
  }
28
- /** Can the server put things in the place uploads go? Said at the start, not at the first upload. */
29
- async function probeWritable(backend) {
34
+ /** Can the server use the bucket? Said at the start, not at the first upload: list, write, read back and delete. */
35
+ async function probeStorage(backend) {
30
36
  const key = `layers/doctor-${Math.random().toString(36).slice(2, 10)}/probe`;
31
37
  try {
38
+ await backend.store.listKeys('server/');
32
39
  await backend.store.put(key, new TextEncoder().encode('atlas'), 'text/plain');
33
40
  if (!(await backend.store.head(key)))
34
41
  return 'an object was written but could not be found again';
35
- await backend.store.delete(key).catch(() => { });
42
+ await backend.store.delete(key);
36
43
  return null;
37
44
  }
38
45
  catch (error) {
@@ -46,13 +53,35 @@ export function serverOptionsOf(config, backends, log) {
46
53
  ? (layer) => (!branches?.length || (layer.branch !== null && branches.includes(layer.branch))) && (!config.include.ignoreModified || layer.freshness === undefined)
47
54
  : undefined;
48
55
  return {
49
- backends, write: config.writeId,
50
- ...(/^https?:\/\//.test(config.backends.find(backend => backend.id === config.writeId).url) ? { liveStore: backends[0].store } : {}), tokens: config.tokens, anonymousRead: config.anonymousRead,
56
+ backends, write: S3_BACKEND_ID,
57
+ tokens: config.tokens, anonymousRead: config.anonymousRead,
51
58
  ...(config.outlineBranch ? { outlineBranch: config.outlineBranch } : {}), ...(include ? { include } : {}),
52
59
  runTimeoutMs: config.runTimeoutSeconds * 1000, runStallMs: config.runStallSeconds * 1000, refreshMs: config.refreshSeconds * 1000, limits: config.limits, heartbeatMs: config.heartbeatSeconds * 1000,
53
60
  ...(config.log && log ? { log } : {})
54
61
  };
55
62
  }
63
+ /** What the garbage collection policy in S3 means, in one line for the log. */
64
+ const gcLine = (stored) => stored.state.gc
65
+ ? `Garbage collection policy loaded from S3 (${USER_STATE_KEY}): ${stored.state.gc.enabled ? `ON, delete superseded layers older than ${stored.state.gc.maxAgeDays} days, keep the newest ${stored.state.gc.keepLatest}, every ${stored.state.gc.intervalHours} h` : 'OFF (saved but disabled)'}.\n`
66
+ : `No garbage collection policy has been saved in S3 (${USER_STATE_KEY}) yet: garbage collection is OFF and nothing is deleted. Define a policy in the Atlas UI at /admin.\n`;
67
+ /**
68
+ * Check the storage and read the user state, in that order, with the failure banner for each way it can go wrong.
69
+ * Returns the opened backends and the state, or the text to print before exiting 2.
70
+ */
71
+ export async function connectStorage(config, env, io) {
72
+ const backends = openConfiguredBackends(config, io.fetch);
73
+ const problem = await probeStorage(backends[0]);
74
+ if (problem)
75
+ return { failure: startupFailure(inspectS3Env(env), `the S3 storage ${describeS3(config.s3)} cannot be used with these settings`, problem) };
76
+ try {
77
+ return { backends, stored: await userStateStore(backends[0].store).read() };
78
+ }
79
+ catch (error) {
80
+ if (error instanceof UserStateError)
81
+ return { failure: startupFailure(inspectS3Env(env), 'the user state stored in S3 cannot be read', error.message) };
82
+ throw error;
83
+ }
84
+ }
56
85
  /** `atlas serve`. Runs until it is told to stop; returns the exit code. */
57
86
  export async function serveMain(argv, io) {
58
87
  const cwd = io.cwd ?? process.cwd();
@@ -66,24 +95,41 @@ export async function serveMain(argv, io) {
66
95
  config = loadServerConfig(argv, env, cwd);
67
96
  }
68
97
  catch (error) {
98
+ if (error instanceof S3ConfigError) {
99
+ io.out(`${error.message}\n`);
100
+ return 2;
101
+ }
69
102
  if (error instanceof ConfigError) {
70
103
  io.out(`atlas serve: ${error.message}\n`);
71
104
  return 2;
72
105
  }
73
106
  throw error;
74
107
  }
75
- const backends = openConfiguredBackends(config, env, cwd);
76
- const writeBackend = backends.find(backend => backend.writable);
77
- io.out(`${config.configFile ? `Configuration ${path.relative(cwd, config.configFile) || config.configFile}. ` : ''}Backends: ${backends.map(backend => `${backend.id}${backend.writable ? ' (uploads)' : ''}`).join(', ')}\n`);
78
- const problem = await probeWritable(writeBackend);
79
- if (problem) {
80
- io.out(`atlas serve: uploads would go to "${writeBackend.id}" and it cannot be written to: ${problem}\n`);
108
+ const connected = await connectStorage(config, env, io);
109
+ if ('failure' in connected) {
110
+ io.out(`${connected.failure}\n`);
81
111
  return 2;
82
112
  }
113
+ const { backends, stored } = connected;
114
+ if (stored.state.settings) {
115
+ try {
116
+ config = loadServerConfig(argv, env, cwd, undefined, stored.state.settings);
117
+ }
118
+ catch (error) {
119
+ if (error instanceof ConfigError) {
120
+ io.out(`atlas serve: the settings saved in S3 (${USER_STATE_KEY}) do not work with this deployment: ${error.message}\n`);
121
+ return 2;
122
+ }
123
+ throw error;
124
+ }
125
+ }
126
+ io.out(`${config.configFile ? `Configuration ${path.relative(cwd, config.configFile) || config.configFile}. ` : ''}Storage: ${describeS3(config.s3)}. All reports and user state live there.\n`);
127
+ io.out(gcLine(stored));
83
128
  const site = await findSite(config.site, cwd, io.out);
84
129
  if (!site)
85
130
  io.out('No Atlas client to serve: build one with `atlas client build --no-data --out atlas-site`. The API is served meanwhile.\n');
86
- const server = createAtlasServer({ settings: fileServerSettings(config, argv, env, cwd), ...serverOptionsOf(config, backends, line => io.out(`${JSON.stringify(line)}\n`)), ...(site ? { site } : {}) });
131
+ const { settings, gc } = storedSettings(config, argv, env, cwd, userStateStore(backends[0].store));
132
+ const server = createAtlasServer({ settings, gc, ...serverOptionsOf(config, backends, line => io.out(`${JSON.stringify(line)}\n`)), ...(site ? { site } : {}) });
87
133
  // Listen at once: the first pass over a large backend can take a while, and a liveness probe must find the
88
134
  // process meanwhile. /readyz is 503 until there is a report to show.
89
135
  const running = await listen(server.fetch, { port: config.port, host: config.host, maxBodyBytes: Math.max(config.limits.maxObjectMiB, config.limits.maxJsonMiB) * 1024 * 1024 });
@@ -15,7 +15,7 @@
15
15
  import { z } from 'zod/v4';
16
16
  import { ProjectsSchema } from '../../schema/src/project.js';
17
17
  import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, RunListSchema, RunDetailSchema, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema } from '../../schema/src/api.js';
18
- import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
18
+ import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, GcPolicySchema, GcSnapshotSchema, GcRunResultSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
19
19
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
20
20
  export const API_VERSION = '1.5.0';
21
21
  /** The JSON Schema of a Zod schema, as OpenAPI 3.1 embeds it. `output` for what the server sends, `input` for what it accepts. */
@@ -30,6 +30,7 @@ function schemaOf(schema, io = 'output') {
30
30
  const COMPONENTS = {
31
31
  LiveRun: [LiveRunSchema, 'output'], LiveRunStart: [LiveRunStartSchema, 'input'], LiveRunUpdate: [LiveRunUpdateSchema, 'input'],
32
32
  ServerEditableSettings: [ServerEditableSettingsSchema, 'input'], ServerSettingsSnapshot: [ServerSettingsSnapshotSchema, 'output'],
33
+ GcPolicy: [GcPolicySchema, 'input'], GcSnapshot: [GcSnapshotSchema, 'output'], GcRunResult: [GcRunResultSchema, 'output'],
33
34
  Problem: [ProblemSchema, 'output'], Health: [HealthSchema, 'output'], Ready: [ReadySchema, 'output'], ServerInfo: [ServerInfoSchema, 'output'],
34
35
  EffectiveEvidence: [EffectiveEvidenceSchema, 'output'], EffectiveDelta: [EffectiveDeltaSchema, 'output'], ReportStatus: [ReportStatusSchema, 'output'],
35
36
  PublishChecks: [PublishChecksSchema.partial({ recordedAtMs: true }), 'input'], ChecksResult: [ChecksResultSchema, 'output'],
@@ -92,16 +93,34 @@ const BASE_OPERATIONS = [
92
93
  },
93
94
  {
94
95
  route: 'GET /api/settings', path: '/api/settings', operationId: 'getSettings', tag: 'Operations', role: 'admin',
95
- summary: 'Read server settings', description: '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.',
96
+ summary: 'Read server settings', description: '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.',
96
97
  responses: { '200': json('Settings and revision.', 'ServerSettingsSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '503': problem('Configuration is managed by the embedding application.') }
97
98
  },
98
99
  {
99
100
  route: 'PUT /api/settings', path: '/api/settings', operationId: 'saveSettings', tag: 'Operations', role: 'admin',
100
- summary: 'Save settings for the next restart', description: '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.',
101
+ summary: 'Save settings for the next restart', description: '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.',
101
102
  parameters: [{ name: 'If-Match', in: 'header', required: true, description: 'The quoted revision returned by GET /api/settings.', schema: str }],
102
- body: { description: 'Editable fields from ServerConfig, excluding authentication, host, port and site. Maximum 64 KiB.', contentType: 'application/json', required: true, schema: ref('ServerEditableSettings') },
103
+ body: { description: 'Editable fields from ServerConfig, excluding storage, authentication, host, port and site. Maximum 64 KiB.', contentType: 'application/json', required: true, schema: ref('ServerEditableSettings') },
103
104
  responses: { '200': json('Saved settings and new revision. Restart required.', 'ServerSettingsSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('Settings changed; reload before saving.'), '413': problem('Settings exceed the body limit.'), '422': problem('Invalid configuration.'), '428': problem('A quoted settings revision is required.'), '503': problem('Configuration is managed by the embedding application.') }
104
105
  },
106
+ {
107
+ route: 'GET /api/settings/gc', path: '/api/settings/gc', operationId: 'getGarbageCollectionPolicy', tag: 'Operations', role: 'admin',
108
+ summary: 'Read the garbage collection policy', description: '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.',
109
+ responses: { '200': json('The policy, its revision and the last passes.', 'GcSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '503': problem('The user state in S3 cannot be read, or settings are managed by the embedding application.') }
110
+ },
111
+ {
112
+ route: 'PUT /api/settings/gc', path: '/api/settings/gc', operationId: 'saveGarbageCollectionPolicy', tag: 'Operations', role: 'admin',
113
+ summary: 'Save the garbage collection policy', description: '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.',
114
+ parameters: [{ name: 'If-Match', in: 'header', required: true, description: 'The quoted revision returned by GET /api/settings/gc.', schema: str }],
115
+ body: { description: 'The policy. Maximum 16 KiB.', contentType: 'application/json', required: true, schema: ref('GcPolicy') },
116
+ responses: { '200': json('The saved policy and the new revision.', 'GcSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('The state changed; reload before saving.'), '413': problem('The body exceeds the limit.'), '422': problem('Invalid policy.'), '428': problem('A quoted revision is required.'), '503': problem('The user state in S3 cannot be read, or settings are managed by the embedding application.') }
117
+ },
118
+ {
119
+ route: 'POST /api/settings/gc/run', path: '/api/settings/gc/run', operationId: 'runGarbageCollection', tag: 'Operations', role: 'admin',
120
+ summary: 'Collect garbage now, or preview it', description: '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.',
121
+ parameters: [{ name: 'dryRun', in: 'query', required: false, description: '`1` or `true` to only count what would be deleted.', schema: str }],
122
+ responses: { '200': json('One run per project.', 'GcRunResult'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('No policy has been saved, or it is disabled.') }
123
+ },
105
124
  {
106
125
  route: 'GET /healthz', path: '/healthz', operationId: 'getHealth', tag: 'Operations', role: 'none',
107
126
  summary: 'Is the process up',
@@ -437,7 +456,7 @@ const BASE_OPERATIONS = [
437
456
  responses: { '200': { description: 'The file.', contentType: 'application/octet-stream', schema: { type: 'string', format: 'binary' } }, '401': UNAUTHORIZED, '404': problem('No such run or file.') }
438
457
  }
439
458
  ];
440
- const NODE_OPERATIONS = new Set(['/api/projects', '/api/settings', '/api/server', '/api/openapi.json']);
459
+ const NODE_OPERATIONS = new Set(['/api/projects', '/api/settings', '/api/settings/gc', '/api/settings/gc/run', '/api/server', '/api/openapi.json']);
441
460
  const PROJECT_PARAMETER = { name: 'project', in: 'query', description: 'Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.', schema: { type: 'string', pattern: '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', maxLength: 64 }, example: 'todo-app' };
442
461
  /** Every node operation and the same stable report protocol inside each project namespace. */
443
462
  export const OPERATIONS = BASE_OPERATIONS.flatMap(operation => {
@@ -9,7 +9,7 @@ import { createAuth } from './auth.js';
9
9
  import { problemResponse } from './problem.js';
10
10
  const REGISTRY = 'server/projects/';
11
11
  const SCOPED = /^\/api\/projects\/([^/]+)(\/.*)$/;
12
- const GLOBAL = new Set(['/api/settings', '/api/projects', '/api/openapi.json', '/api/server']);
12
+ const GLOBAL = new Set(['/api/settings', '/api/settings/gc', '/api/settings/gc/run', '/api/projects', '/api/openapi.json', '/api/server']);
13
13
  const scopeStore = (store, slug) => typeof store.forProject === 'function'
14
14
  ? store.forProject(slug) : new ObjectStoreWithPrefix(store, `projects/${slug}`);
15
15
  /** Wrap the single-project protocol in project selection. Auth, upload validation and composition stay identical. */
@@ -61,7 +61,71 @@ export function projectServer(options, create) {
61
61
  };
62
62
  const timer = setInterval(expire, 60_000);
63
63
  timer.unref?.();
64
- const routes = [...base.routes, { method: 'GET', path: '/api/projects' }];
64
+ /** The application of one named project, created on first use over a store scoped to it. `null` when too many are active. */
65
+ const ensureChild = (slug) => {
66
+ let child = children.get(slug);
67
+ if (child)
68
+ return child;
69
+ expire();
70
+ if (children.size >= 64)
71
+ return null;
72
+ child = { touched: Date.now(), pending: 0, server: create({ ...options,
73
+ ...(options.log ? { log: line => options.log({ ...line, project: slug }) } : {}),
74
+ backends: options.backends.map(backend => ({ ...backend, store: scopeStore(backend.store, slug) })),
75
+ ...(options.liveStore ? { liveStore: scopeStore(options.liveStore, slug) } : {}), site: undefined, settings: undefined, gc: undefined }) };
76
+ children.set(slug, child);
77
+ return child;
78
+ };
79
+ // Garbage collection. The policy is read from S3 every time, never cached, so a policy saved in the UI is the one applied.
80
+ const clock = options.now ?? Date.now;
81
+ let collecting;
82
+ let lastCollectedAt = 0;
83
+ const collectAll = (policy, dryRun) => {
84
+ collecting ??= (async () => {
85
+ const runs = [];
86
+ await discover().catch(() => { });
87
+ for (const slug of ['default', ...[...known].filter(item => item !== 'default').sort()]) {
88
+ if (closed)
89
+ break;
90
+ if (slug === 'default') {
91
+ runs.push(await base.collectGarbage(policy, { dryRun, project: slug }));
92
+ continue;
93
+ }
94
+ const child = ensureChild(slug);
95
+ if (!child) {
96
+ runs.push({ project: slug, dryRun, at: new Date(clock()).toISOString(), candidates: 0, deletedLayers: 0, deletedBlobs: 0, keptLayers: 0, error: 'Too many active projects; this one was skipped.' });
97
+ continue;
98
+ }
99
+ child.pending++;
100
+ try {
101
+ await child.server.ready;
102
+ runs.push(await child.server.collectGarbage(policy, { dryRun, project: slug }));
103
+ }
104
+ finally {
105
+ child.pending--;
106
+ child.touched = Date.now();
107
+ }
108
+ }
109
+ if (!dryRun)
110
+ options.gc?.record(runs);
111
+ return runs;
112
+ })().finally(() => { collecting = undefined; });
113
+ return collecting;
114
+ };
115
+ const gcTimer = options.gc ? setInterval(() => {
116
+ void (async () => {
117
+ if (closed || collecting)
118
+ return;
119
+ const policy = await options.gc.policy();
120
+ if (!policy?.enabled || clock() - lastCollectedAt < policy.intervalHours * 3_600_000)
121
+ return;
122
+ await base.ready;
123
+ lastCollectedAt = clock();
124
+ await collectAll(policy, false);
125
+ })().catch(error => options.log?.({ level: 'error', gc: 'schedule', error: String(error) }));
126
+ }, options.gcCheckMs ?? 60_000) : undefined;
127
+ gcTimer?.unref?.();
128
+ const routes = [...base.routes, { method: 'GET', path: '/api/projects' }, { method: 'POST', path: '/api/settings/gc/run' }];
65
129
  for (const route of base.routes)
66
130
  if (route.path.startsWith('/api/') && route.path !== '/api/*' && !GLOBAL.has(route.path))
67
131
  routes.push({ method: route.method, path: route.path.replace('/api/', '/api/projects/:project/') });
@@ -71,6 +135,24 @@ export function projectServer(options, create) {
71
135
  if (closed)
72
136
  return problemResponse(503, 'Atlas is stopping.');
73
137
  const url = new URL(request.url);
138
+ if (url.pathname === '/api/settings/gc/run' && request.method === 'POST') {
139
+ const verdict = auth.check(request, 'admin');
140
+ if (!verdict.ok)
141
+ return problemResponse(verdict.status, verdict.message, verdict.status === 401 ? { 'www-authenticate': 'Basic realm="Atlas"' } : {});
142
+ const origin = request.headers.get('origin');
143
+ const site = request.headers.get('sec-fetch-site');
144
+ if (site === 'cross-site' || (origin && origin !== url.origin && site !== 'same-origin'))
145
+ return problemResponse(403, 'Changes must come from this server origin.');
146
+ if (!options.gc)
147
+ return problemResponse(503, 'Garbage collection settings are managed by the embedding application.');
148
+ const policy = await options.gc.policy();
149
+ if (!policy)
150
+ return problemResponse(409, 'No garbage collection policy has been saved yet, so nothing is deleted. Save one first.');
151
+ const dryRun = ['1', 'true'].includes(url.searchParams.get('dryRun') ?? '');
152
+ if (!dryRun && !policy.enabled)
153
+ return problemResponse(409, 'The saved garbage collection policy is disabled. Enable it, or use dryRun to preview.');
154
+ return Response.json({ runs: await collectAll(dryRun ? { ...policy, enabled: true } : policy, dryRun), policy }, { headers: { 'cache-control': 'no-store' } });
155
+ }
74
156
  if (url.pathname === '/api/projects' && request.method === 'GET') {
75
157
  const verdict = auth.check(request, 'read');
76
158
  if (!verdict.ok)
@@ -116,17 +198,9 @@ export function projectServer(options, create) {
116
198
  return problemResponse(404, 'Unknown project. Publish reports with this project slug first.');
117
199
  }
118
200
  }
119
- let child = children.get(slug);
120
- if (!child) {
121
- expire();
122
- if (children.size >= 64)
123
- return problemResponse(429, 'Too many active projects. Retry when another project is idle.');
124
- child = { touched: Date.now(), pending: 0, server: create({ ...options,
125
- ...(options.log ? { log: line => options.log({ ...line, project: slug }) } : {}),
126
- backends: options.backends.map(backend => ({ ...backend, store: scopeStore(backend.store, slug) })),
127
- ...(options.liveStore ? { liveStore: scopeStore(options.liveStore, slug) } : {}), site: undefined, settings: undefined }) };
128
- children.set(slug, child);
129
- }
201
+ const child = ensureChild(slug);
202
+ if (!child)
203
+ return problemResponse(429, 'Too many active projects. Retry when another project is idle.');
130
204
  child.touched = Date.now();
131
205
  child.pending++;
132
206
  try {
@@ -149,6 +223,7 @@ export function projectServer(options, create) {
149
223
  return problemResponse(503, 'The project backend is unavailable. Retry later.');
150
224
  }
151
225
  },
152
- close() { closed = true; clearInterval(timer); base.close(); for (const child of children.values())
226
+ close() { closed = true; clearInterval(timer); if (gcTimer)
227
+ clearInterval(gcTimer); base.close(); for (const child of children.values())
153
228
  child.server.close(); children.clear(); } };
154
229
  }
@@ -1,5 +1,7 @@
1
+ import { type GcPolicy, type GcRun, type GcSnapshot } from '../../schema/src/user-state.js';
1
2
  import { type ResolvedServerConfig } from './config.js';
2
- /** The persisted editable settings, active settings and a revision for optimistic concurrency. Authentication token settings are excluded. */
3
+ import { type UserStateStore } from './user-state.js';
4
+ /** The saved editable settings, the active settings and a revision for optimistic concurrency. Authentication token settings are excluded. */
3
5
  export type SettingsSnapshot = {
4
6
  readonly revision: string;
5
7
  readonly settings: Record<string, unknown>;
@@ -7,17 +9,31 @@ export type SettingsSnapshot = {
7
9
  readonly restartRequired: boolean;
8
10
  readonly precedence: string;
9
11
  };
10
- /** Storage for the admin API; saving is serialized and refuses a stale revision. */
12
+ /** Storage for the admin API; saving is serialised and refuses a stale revision. */
11
13
  export type ServerSettings = {
12
14
  read(): Promise<SettingsSnapshot>;
13
15
  save(settings: unknown, revision: string): Promise<SettingsSnapshot>;
14
16
  };
17
+ /** The garbage collection policy as the admin API and the collector see it. */
18
+ export type GcSettings = {
19
+ /** The policy in S3 with its revision and the last passes. */
20
+ read(): Promise<GcSnapshot>;
21
+ /** Validates and saves a policy; a stale `revision` is refused. */
22
+ save(policy: unknown, revision: string): Promise<GcSnapshot>;
23
+ /** The policy in S3 right now, or `null` when none has been saved. Read from S3 every time. */
24
+ policy(): Promise<GcPolicy | null>;
25
+ /** Remembers the result of a pass, for `read`. */
26
+ record(runs: readonly GcRun[]): void;
27
+ };
15
28
  /**
16
- * Manage the server's configuration file with atomic replacement, preserving authentication and unrelated fields.
17
- * Validates both the file alone and the effective configuration before saving. Never migrates or deletes report data.
18
- * Changes need a server restart; deployment environment variables and flags override the file.
29
+ * The settings and garbage collection policy kept in `userState` (S3). Validates a settings change against the
30
+ * whole deployment (file, environment and flags) before saving, so what is saved can start. Never moves report data.
31
+ *
19
32
  * @example
20
- * const settings = fileServerSettings(config, argv, process.env, process.cwd());
33
+ * const { settings, gc } = storedSettings(config, argv, process.env, process.cwd(), userStateStore(store));
21
34
  */
22
- export declare function fileServerSettings(config: ResolvedServerConfig, argv: readonly string[], env: Readonly<Record<string, string | undefined>>, cwd: string): ServerSettings;
35
+ export declare function storedSettings(config: ResolvedServerConfig, argv: readonly string[], env: Readonly<Record<string, string | undefined>>, cwd: string, userState: UserStateStore): {
36
+ settings: ServerSettings;
37
+ gc: GcSettings;
38
+ };
23
39
  export { SETTINGS_PAGE, renderSettingsPage } from './settings-page.js';
@@ -1,72 +1,62 @@
1
1
  /** @module Persisted server settings
2
- * The admin UI edits storage and report configuration in atlas.server.json. Changes take effect on restart;
3
- * environment variables and flags keep their documented precedence. Authentication is managed outside this UI.
2
+ * The settings page edits two things, and both are kept in the user state document in S3 (`server/state.json`), never on
3
+ * the server's disk: the report and live-run settings (applied at the next start, below the configuration file,
4
+ * the environment and the flags in precedence) and the garbage collection policy (read by the server each time it
5
+ * collects). Storage and authentication are deployment-owned and are not editable here.
4
6
  */
5
- import { createHash, randomUUID } from 'node:crypto';
6
- import { readFile, rename, rm, writeFile } from 'node:fs/promises';
7
- import path from 'node:path';
8
7
  import { ServerEditableSettingsSchema } from '../../schema/src/server-config.js';
9
- import { parseStoreLocation } from '../../reports/src/store-url.js';
8
+ import { GcPolicySchema } from '../../schema/src/user-state.js';
10
9
  import { ConfigError, loadServerConfig } from './config.js';
10
+ import { USER_STATE_KEY, UserStateError } from './user-state.js';
11
11
  const editable = ServerEditableSettingsSchema;
12
- const keys = new Set(Object.keys(editable.shape));
13
- const select = (value) => Object.fromEntries(Object.entries(value).filter(([key]) => keys.has(key)));
14
- const digest = (text) => createHash('sha256').update(text).digest('hex');
12
+ const conflict = (error) => {
13
+ if (error instanceof UserStateError)
14
+ throw new ConfigError(error.message);
15
+ throw error;
16
+ };
15
17
  /**
16
- * Manage the server's configuration file with atomic replacement, preserving authentication and unrelated fields.
17
- * Validates both the file alone and the effective configuration before saving. Never migrates or deletes report data.
18
- * Changes need a server restart; deployment environment variables and flags override the file.
18
+ * The settings and garbage collection policy kept in `userState` (S3). Validates a settings change against the
19
+ * whole deployment (file, environment and flags) before saving, so what is saved can start. Never moves report data.
20
+ *
19
21
  * @example
20
- * const settings = fileServerSettings(config, argv, process.env, process.cwd());
22
+ * const { settings, gc } = storedSettings(config, argv, process.env, process.cwd(), userStateStore(store));
21
23
  */
22
- export function fileServerSettings(config, argv, env, cwd) {
23
- const file = config.configFile ?? path.join(cwd, 'atlas.server.json');
24
- const active = { data: config.data, backends: config.backends.slice(1), write: config.writeId, outlineBranch: config.outlineBranch, include: config.include, refreshSeconds: config.refreshSeconds, limits: config.limits, heartbeatSeconds: config.heartbeatSeconds, runTimeoutSeconds: config.runTimeoutSeconds, runStallSeconds: config.runStallSeconds, log: config.log };
24
+ export function storedSettings(config, argv, env, cwd, userState) {
25
+ const active = { outlineBranch: config.outlineBranch, include: config.include, refreshSeconds: config.refreshSeconds, limits: config.limits, heartbeatSeconds: config.heartbeatSeconds, runTimeoutSeconds: config.runTimeoutSeconds, runStallSeconds: config.runStallSeconds, log: config.log };
25
26
  let saved = false;
26
- let pending = Promise.resolve();
27
- const readText = () => readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT')
28
- return '{}'; throw error; });
29
- const read = async () => {
30
- const text = await readText();
31
- return { revision: digest(text), settings: select(JSON.parse(text)), active,
32
- restartRequired: saved, precedence: 'File settings apply after restart. Environment variables and command-line flags override the file.' };
27
+ let lastRuns = [];
28
+ const snapshot = (stored) => ({
29
+ revision: stored.revision, settings: (stored.state.settings ?? {}), active, restartRequired: saved,
30
+ precedence: `Saved in S3 (${USER_STATE_KEY}) and applied after restart. The configuration file, environment variables and command-line flags override them.`
31
+ });
32
+ const gcSnapshot = (stored) => ({
33
+ revision: stored.revision, policy: stored.state.gc ? GcPolicySchema.parse(stored.state.gc) : null, saved: stored.state.gc !== undefined, lastRuns, stateKey: USER_STATE_KEY
34
+ });
35
+ const settings = {
36
+ read: async () => snapshot(await userState.read().catch(conflict)),
37
+ async save(input, revision) {
38
+ const parsed = editable.safeParse(input);
39
+ if (!parsed.success)
40
+ throw new ConfigError(parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '));
41
+ // Prove the exact deployment (file, environment, flags and these settings) can start before keeping them.
42
+ loadServerConfig(argv, env, cwd, undefined, parsed.data);
43
+ // What the person chose, as sent: validated, but without the defaults filled in, so unchosen settings keep following the defaults.
44
+ const stored = await userState.update(revision, state => ({ ...state, settings: input })).catch(conflict);
45
+ saved = true;
46
+ return snapshot(stored);
47
+ }
33
48
  };
34
- return { read, save(settings, revision) {
35
- const update = pending.then(async () => {
36
- const parsed = editable.safeParse(settings);
37
- if (!parsed.success)
38
- throw new ConfigError(parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '));
39
- const before = await readText();
40
- if (digest(before) !== revision)
41
- throw new ConfigError('Settings changed. Reload them before saving.');
42
- const original = JSON.parse(before);
43
- const merged = { ...Object.fromEntries(Object.entries(original).filter(([key]) => !keys.has(key))), ...parsed.data };
44
- const text = `${JSON.stringify(merged, null, 2)}\n`;
45
- // Prove it is independently usable, then prove the exact deployment (including overrides) can start.
46
- const base = loadServerConfig(['--config', file], env, cwd, () => text);
47
- const effective = loadServerConfig([...argv, '--config', file], env, cwd, () => text);
48
- try {
49
- for (const backend of [...base.backends, ...effective.backends])
50
- parseStoreLocation(backend.url, path.dirname(file), env);
51
- }
52
- catch (error) {
53
- throw new ConfigError(error instanceof Error ? error.message : String(error));
54
- }
55
- const temporary = `${file}.${randomUUID()}.tmp`;
56
- try {
57
- await writeFile(temporary, text, { mode: 0o600, flag: 'wx' });
58
- if (await readText() !== before)
59
- throw new ConfigError('Settings changed. Reload them before saving.');
60
- await rename(temporary, file);
61
- }
62
- finally {
63
- await rm(temporary, { force: true });
64
- }
65
- saved = true;
66
- return read();
67
- });
68
- pending = update.catch(() => undefined);
69
- return update;
70
- } };
49
+ const gc = {
50
+ read: async () => gcSnapshot(await userState.read().catch(conflict)),
51
+ async save(input, revision) {
52
+ const parsed = GcPolicySchema.safeParse(input);
53
+ if (!parsed.success)
54
+ throw new ConfigError(parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '));
55
+ return gcSnapshot(await userState.update(revision, state => ({ ...state, gc: parsed.data })).catch(conflict));
56
+ },
57
+ policy: async () => { const { state } = await userState.read(); return state.gc ? GcPolicySchema.parse(state.gc) : null; },
58
+ record(runs) { lastRuns = [...runs, ...lastRuns.filter(old => !runs.some(run => run.project === old.project))].slice(0, 50); }
59
+ };
60
+ return { settings, gc };
71
61
  }
72
62
  export { SETTINGS_PAGE, renderSettingsPage } from './settings-page.js';