@crvouga/atlas 0.5.0 → 0.6.1

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 (148) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +51 -16
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +8 -3
  5. package/dist/core/src/config.d.ts +7 -0
  6. package/dist/core/src/config.js +48 -14
  7. package/dist/core/src/run/execute.d.ts +6 -0
  8. package/dist/core/src/run/execute.js +4 -2
  9. package/dist/reports/src/doctor.d.ts +1 -1
  10. package/dist/reports/src/doctor.js +3 -1
  11. package/dist/reports/src/http-store.d.ts +4 -0
  12. package/dist/reports/src/http-store.js +6 -3
  13. package/dist/reports/src/init.js +24 -16
  14. package/dist/reports/src/live-reporter.d.ts +34 -0
  15. package/dist/reports/src/live-reporter.js +122 -0
  16. package/dist/reports/src/package-managers.d.ts +35 -0
  17. package/dist/reports/src/package-managers.js +23 -0
  18. package/dist/reports/src/report-cli.d.ts +1 -1
  19. package/dist/reports/src/report-cli.js +3 -2
  20. package/dist/reports/src/store-url.js +1 -1
  21. package/dist/runners/src/codecept.d.ts +14 -0
  22. package/dist/runners/src/codecept.js +16 -0
  23. package/dist/runners/src/jest.d.ts +50 -0
  24. package/dist/runners/src/jest.js +52 -0
  25. package/dist/runners/src/mocha.d.ts +40 -0
  26. package/dist/runners/src/mocha.js +39 -0
  27. package/dist/runners/src/node-test.d.ts +28 -0
  28. package/dist/runners/src/node-test.js +44 -0
  29. package/dist/runners/src/publish.d.ts +30 -0
  30. package/dist/runners/src/publish.js +68 -0
  31. package/dist/schema/src/api.d.ts +1 -0
  32. package/dist/schema/src/api.js +1 -1
  33. package/dist/schema/src/cli-results.d.ts +233 -2
  34. package/dist/schema/src/cli-results.js +14 -0
  35. package/dist/schema/src/index.d.ts +2 -0
  36. package/dist/schema/src/index.js +2 -0
  37. package/dist/schema/src/live-run.d.ts +123 -0
  38. package/dist/schema/src/live-run.js +30 -0
  39. package/dist/schema/src/manifest.d.ts +2 -2
  40. package/dist/schema/src/project.d.ts +20 -0
  41. package/dist/schema/src/project.js +10 -0
  42. package/dist/schema/src/report-sources.d.ts +1 -0
  43. package/dist/schema/src/report-sources.js +2 -1
  44. package/dist/schema/src/runs-index.d.ts +1 -1
  45. package/dist/schema/src/server-config.d.ts +84 -0
  46. package/dist/schema/src/server-config.js +12 -0
  47. package/dist/server/src/app.d.ts +10 -0
  48. package/dist/server/src/app.js +148 -10
  49. package/dist/server/src/config.d.ts +1 -1
  50. package/dist/server/src/config.js +20 -3
  51. package/dist/server/src/dev.d.ts +3 -3
  52. package/dist/server/src/dev.js +22 -13
  53. package/dist/server/src/events.d.ts +3 -3
  54. package/dist/server/src/events.js +1 -1
  55. package/dist/server/src/index.d.ts +2 -0
  56. package/dist/server/src/index.js +2 -0
  57. package/dist/server/src/live-runs.d.ts +52 -0
  58. package/dist/server/src/live-runs.js +229 -0
  59. package/dist/server/src/llms.js +17 -8
  60. package/dist/server/src/main.d.ts +1 -1
  61. package/dist/server/src/main.js +7 -5
  62. package/dist/server/src/openapi.d.ts +2 -5
  63. package/dist/server/src/openapi.js +71 -7
  64. package/dist/server/src/projects.d.ts +3 -0
  65. package/dist/server/src/projects.js +154 -0
  66. package/dist/server/src/settings-page.d.ts +9 -0
  67. package/dist/server/src/settings-page.js +93 -0
  68. package/dist/server/src/settings.d.ts +23 -0
  69. package/dist/server/src/settings.js +72 -0
  70. package/dist/server/src/site.d.ts +8 -6
  71. package/dist/server/src/site.js +45 -10
  72. package/dist/server/src/store-api.js +43 -22
  73. package/docs/agents.md +166 -15
  74. package/docs/deploying.md +24 -20
  75. package/docs/dogfooding.md +7 -3
  76. package/docs/extending.md +3 -3
  77. package/docs/integrating.md +19 -11
  78. package/docs/quickstart.md +81 -0
  79. package/docs/reference/cli.md +9 -7
  80. package/docs/reference/core.md +6 -0
  81. package/docs/reference/http-api.md +498 -2
  82. package/docs/reference/reports.md +84 -1
  83. package/docs/reference/runners.md +169 -0
  84. package/docs/reference/schema.md +120 -0
  85. package/docs/reference/server.md +154 -14
  86. package/docs/report-backends.md +8 -8
  87. package/docs/report-contract.md +7 -7
  88. package/docs/reporting-issues.md +3 -3
  89. package/docs/runners.md +69 -0
  90. package/docs/server.md +155 -25
  91. package/docs/statecharts.md +22 -20
  92. package/llms-full.txt +1079 -126
  93. package/llms.txt +7 -4
  94. package/package.json +33 -9
  95. package/packages/core/README.md +3 -1
  96. package/packages/runners/README.md +6 -0
  97. package/packages/runners/compat/codecept.cjs +2 -0
  98. package/packages/runners/compat/jest.cjs +2 -0
  99. package/packages/runners/compat/mocha.cjs +2 -0
  100. package/packages/schema/schemas/atlas-change.schema.json +4 -0
  101. package/packages/schema/schemas/hello-event.schema.json +4 -0
  102. package/packages/schema/schemas/live-run-start.schema.json +42 -0
  103. package/packages/schema/schemas/live-run-update.schema.json +93 -0
  104. package/packages/schema/schemas/live-run.schema.json +225 -0
  105. package/packages/schema/schemas/project-slug.schema.json +8 -0
  106. package/packages/schema/schemas/project.schema.json +22 -0
  107. package/packages/schema/schemas/projects.schema.json +33 -0
  108. package/packages/schema/schemas/server-config.schema.json +14 -0
  109. package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
  110. package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -0
  111. package/packages/server/openapi.json +5101 -779
  112. package/packages/visualizer/README.md +13 -13
  113. package/packages/visualizer/bin/atlas-client.mjs +176 -0
  114. package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
  115. package/packages/visualizer/src/app/AppShell.tsx +26 -17
  116. package/packages/visualizer/src/app/report-sources.tsx +2 -1
  117. package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
  118. package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
  119. package/packages/visualizer/src/components/Header.module.css +101 -227
  120. package/packages/visualizer/src/components/Header.tsx +79 -41
  121. package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
  122. package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
  123. package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
  124. package/packages/visualizer/src/data/queries.ts +14 -7
  125. package/packages/visualizer/src/evidence/api.ts +2 -1
  126. package/packages/visualizer/src/evidence/check-detail.tsx +19 -4
  127. package/packages/visualizer/src/evidence/document-page.tsx +6 -2
  128. package/packages/visualizer/src/evidence/evidence-context.tsx +17 -12
  129. package/packages/visualizer/src/evidence/evidence.module.css +11 -1
  130. package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
  131. package/packages/visualizer/src/evidence/model-page.tsx +6 -2
  132. package/packages/visualizer/src/evidence/project-home.tsx +45 -0
  133. package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
  134. package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
  135. package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
  136. package/packages/visualizer/src/live/live-query.ts +21 -0
  137. package/packages/visualizer/src/live/live-runs.tsx +69 -0
  138. package/packages/visualizer/src/live/live-stream.ts +30 -9
  139. package/packages/visualizer/src/live/report-progress.ts +62 -0
  140. package/packages/visualizer/src/live/test-progress.tsx +34 -0
  141. package/packages/visualizer/src/runtime.ts +17 -1
  142. package/packages/visualizer/src/state/ui-store.ts +3 -2
  143. package/packages/visualizer/src/views/ChartPage.tsx +2 -0
  144. package/packages/visualizer/src/views/ChartTests.tsx +43 -0
  145. package/packages/visualizer/src/views/SidePanel.tsx +2 -0
  146. package/packages/visualizer/vite/atlas-files.test.ts +4 -4
  147. package/packages/visualizer/vite/atlas-files.ts +1 -5
  148. package/packages/visualizer/vite/atlas-server-proxy.ts +2 -1
@@ -1,23 +1,24 @@
1
1
  /**
2
2
  * @module `atlas dev`
3
3
  *
4
- * Runs the same server as `atlas serve` on a port of its own, with the viewer's Vite dev server in front of it. Vite proxies
4
+ * Runs the same server as `atlas serve` on a port of its own, with the client's Vite dev server in front of it. Vite proxies
5
5
  * `/api` (server-sent events included) to the server and writes the same address book into the page, so the browser, the
6
- * uploaders and the viewer all use one host, exactly as in production, and a change to the viewer shows at once.
6
+ * uploaders and the client all use one host, exactly as in production, and a change to the client shows at once.
7
7
  */
8
8
  import path from 'node:path';
9
9
  import { openConfiguredBackends, serverOptionsOf } from './main.js';
10
10
  import { createAtlasServer } from './app.js';
11
11
  import { ConfigError, loadServerConfig, SERVER_USAGE } from './config.js';
12
12
  import { listen } from './host.js';
13
+ import { fileServerSettings } from './settings.js';
13
14
  import { visualizerRoot } from './site.js';
14
15
  /** The text `atlas dev --help` prints. Also the source of the `atlas dev` section of `docs/reference/cli.md`. */
15
- export const DEV_USAGE = `atlas dev: the Atlas server and the viewer, for working on either
16
+ export const DEV_USAGE = `atlas dev: the Atlas server and the client, for working on either
16
17
 
17
18
  atlas dev [--port 5180] [--host 127.0.0.1] [--data <dir>] [--store <url|id=url>]... [--config atlas.server.json]
18
19
 
19
- Starts the server (API only, on a port of its own) and the viewer's dev server, which proxies /api to it, so the
20
- browser talks to one host and a change to the viewer shows at once. In production \`atlas serve\` does both from
20
+ Starts the server (API only, on a port of its own) and the client's dev server, which proxies /api to it, so the
21
+ browser talks to one host and a change to the client shows at once. In production \`atlas serve\` does both from
21
22
  one process.
22
23
  `;
23
24
  /** `atlas dev`. */
@@ -30,16 +31,16 @@ export async function devMain(argv, io) {
30
31
  }
31
32
  const root = visualizerRoot();
32
33
  if (!root) {
33
- io.out('atlas dev: the viewer is not installed alongside the server.\n');
34
+ io.out('atlas dev: the client is not installed alongside the server.\n');
34
35
  return 2;
35
36
  }
36
- // --port is the one the browser uses (the viewer's); the API takes another that nobody needs to know.
37
+ // --port is the one the browser uses (the client's); the API takes another that nobody needs to know.
37
38
  const flagged = argv.findIndex(item => item === '--port');
38
39
  const publicPort = flagged >= 0 ? Number(argv[flagged + 1]) : Number(env.ATLAS_PORT ?? env.PORT ?? 5180);
39
40
  const forServer = argv.filter((_, i) => i !== flagged && i !== flagged + 1 || flagged < 0);
40
41
  let config;
41
42
  try {
42
- // The API itself listens on a port of its own on this machine; what the user said about --host is for the viewer, the one door.
43
+ // The API itself listens on a port of its own on this machine; what the user said about --host is for the client, the one door.
43
44
  config = loadServerConfig([...forServer, '--port', '0'], { ...env, PORT: undefined, ATLAS_PORT: undefined }, cwd);
44
45
  }
45
46
  catch (error) {
@@ -50,10 +51,10 @@ export async function devMain(argv, io) {
50
51
  throw error;
51
52
  }
52
53
  const backends = openConfiguredBackends(config, env, cwd);
53
- const api = createAtlasServer(serverOptionsOf(config, backends, line => io.out(`${JSON.stringify(line)}\n`)));
54
- const running = await listen(api.fetch, { port: 0, host: '127.0.0.1', maxBodyBytes: config.limits.maxObjectMiB * 1024 * 1024 });
54
+ const api = createAtlasServer({ ...serverOptionsOf(config, backends, line => io.out(`${JSON.stringify(line)}\n`)), settings: fileServerSettings(config, forServer, env, cwd) });
55
+ const running = await listen(api.fetch, { port: 0, host: '127.0.0.1', maxBodyBytes: Math.max(config.limits.maxObjectMiB, config.limits.maxJsonMiB) * 1024 * 1024 });
55
56
  await api.ready;
56
- // The viewer's config reads this to proxy /api, and to write the same page config a server would.
57
+ // The client's config reads this to proxy /api, and to write the same page config a server would.
57
58
  process.env.ATLAS_API_URL = running.url.replace('localhost', '127.0.0.1');
58
59
  process.env.INIT_CWD = cwd;
59
60
  const { createServer } = await import('vite');
@@ -61,9 +62,17 @@ export async function devMain(argv, io) {
61
62
  configFile: path.join(root, 'vite.config.ts'),
62
63
  configLoader: 'runner',
63
64
  root,
64
- server: { port: publicPort, host: config.host, strictPort: false }
65
+ server: { port: publicPort, host: config.host, strictPort: true }
65
66
  });
66
- await vite.listen();
67
+ try {
68
+ await vite.listen();
69
+ }
70
+ catch (error) {
71
+ api.close();
72
+ await vite.close();
73
+ await running.close(2_000);
74
+ throw error;
75
+ }
67
76
  vite.printUrls();
68
77
  io.out(`Data in ${path.relative(cwd, config.data) || config.data}. Publish with: ATLAS_STORE=${vite.resolvedUrls?.local[0] ?? `http://localhost:${publicPort}`} atlas report publish …\n`);
69
78
  await new Promise(resolve => {
@@ -2,15 +2,15 @@
2
2
  * @module Server-sent events
3
3
  *
4
4
  * What the server tells every connected viewer the moment it happens. A viewer that misses some (a dropped connection, a
5
- * laptop that slept) loses nothing: events say "something changed", the state is always fetched, and a viewer reads again
5
+ * laptop that slept) loses nothing: report events announce changes, run events carry bounded progress snapshots, and a viewer reads authoritative state again
6
6
  * when it reconnects. `EventHub` encodes each event once for all viewers, sends a named `ping` every heartbeat so proxies
7
7
  * keep the connection and a quiet server is told apart from a dead one, refuses past a maximum number of viewers (they poll
8
8
  * instead), and lets go of a viewer that has stopped reading rather than hold its backlog.
9
9
  *
10
10
  * @see docs/server.md#streaming
11
11
  */
12
- /** The events the server publishes: `hello` (sent first to a new viewer), `report` (layers arrived) and `atlas:change` (specs or runs changed). `ping` is the heartbeat, sent by the hub itself. */
13
- export type EventName = 'hello' | 'report' | 'atlas:change';
12
+ /** The events the server publishes: `hello` (sent first to a new viewer), `report` (layers arrived) `atlas:change` (specs or runs changed), `run` (a bounded live lease snapshot), and `projects` (project discovery changed). `ping` is the heartbeat, sent by the hub itself. */
13
+ export type EventName = 'hello' | 'report' | 'atlas:change' | 'run' | 'projects';
14
14
  /** The viewers connected to `/api/events` and how to reach them all. One hub per server. */
15
15
  export declare class EventHub {
16
16
  #private;
@@ -2,7 +2,7 @@
2
2
  * @module Server-sent events
3
3
  *
4
4
  * What the server tells every connected viewer the moment it happens. A viewer that misses some (a dropped connection, a
5
- * laptop that slept) loses nothing: events say "something changed", the state is always fetched, and a viewer reads again
5
+ * laptop that slept) loses nothing: report events announce changes, run events carry bounded progress snapshots, and a viewer reads authoritative state again
6
6
  * when it reconnects. `EventHub` encodes each event once for all viewers, sends a named `ping` every heartbeat so proxies
7
7
  * keep the connection and a quiet server is told apart from a dead one, refuses past a maximum number of viewers (they poll
8
8
  * instead), and lets go of a viewer that has stopped reading rather than hold its backlog.
@@ -14,3 +14,5 @@ export { EventHub, type EventName } from './events.js';
14
14
  export { listen, type RunningServer } from './host.js';
15
15
  export { serveMain } from './main.js';
16
16
  export { classify, validateUpload, validKey, Refusal } from './validate.js';
17
+ export { fileServerSettings, type ServerSettings, type SettingsSnapshot } from './settings.js';
18
+ export { LiveRuns, LiveRunError } from './live-runs.js';
@@ -14,3 +14,5 @@ export { EventHub } from './events.js';
14
14
  export { listen } from './host.js';
15
15
  export { serveMain } from './main.js';
16
16
  export { classify, validateUpload, validKey, Refusal } from './validate.js';
17
+ export { fileServerSettings } from './settings.js';
18
+ export { LiveRuns, LiveRunError } from './live-runs.js';
@@ -0,0 +1,52 @@
1
+ import type { z } from 'zod/v4';
2
+ import { type LiveRun, type LiveRunStartSchema, type LiveRunUpdateSchema } from '../../schema/src/live-run.js';
3
+ import type { ObjectStore } from '../../reports/src/object-store.js';
4
+ import type { ReportLayer } from '../../reports/src/layer-types.js';
5
+ type Start = z.output<typeof LiveRunStartSchema>;
6
+ type Update = z.output<typeof LiveRunUpdateSchema>;
7
+ /** A refused run update: 403 another publisher, 404 unknown run, 409 stale/terminal run, or 429 capacity. */
8
+ export declare class LiveRunError extends Error {
9
+ readonly status: 403 | 404 | 409 | 429;
10
+ constructor(status: 403 | 404 | 409 | 429, message: string);
11
+ }
12
+ /**
13
+ * One server's live feed. Heartbeats never create result layers. Persisted summaries contain at most 20 recent
14
+ * checks and 10 active checks; completed results live in ordinary immutable layers. Leases expire by server time.
15
+ * @example
16
+ * const live = new LiveRuns({ store, timeoutMs: 30_000, stalledMs: 900_000, onChange: () => {}, onLayer: async () => {} });
17
+ */
18
+ export declare class LiveRuns {
19
+ private readonly options;
20
+ private readonly held;
21
+ private readonly attempts;
22
+ private readonly queues;
23
+ /** Resolves after restoring summaries and interrupting leases left by a previous server process. */
24
+ readonly ready: Promise<void>;
25
+ private readonly now;
26
+ constructor(options: {
27
+ store: ObjectStore;
28
+ timeoutMs: number;
29
+ stalledMs: number;
30
+ now?: () => number;
31
+ onChange: (run: LiveRun) => void;
32
+ onLayer: (key: string, layer: ReportLayer) => Promise<void>;
33
+ });
34
+ private restore;
35
+ private save;
36
+ private locked;
37
+ private state;
38
+ private interrupt;
39
+ private expired;
40
+ private requireLive;
41
+ /** Read the most recent 50 summaries. Progress detail and counters are already bounded and cached. */
42
+ list(): Promise<LiveRun[]>;
43
+ /** Register one producer lease. Retrying an identical start is idempotent. */
44
+ start(input: Start, owner: string): Promise<LiveRun>;
45
+ /** Renew liveness by server receipt time, independently of whether a test made progress. */
46
+ heartbeat(id: string, owner: string): Promise<void>;
47
+ /** Apply one ordered progress batch and publish its completed checks before acknowledging it. */
48
+ update(id: string, input: Update, owner: string): Promise<LiveRun>;
49
+ /** Expire silent producers and optional progress stalls. Heartbeats cannot revive an expired run. */
50
+ sweep(): Promise<void>;
51
+ }
52
+ export {};
@@ -0,0 +1,229 @@
1
+ /** @module Live run leases
2
+ * Tracks producer heartbeats independently of progress, publishes completed checks incrementally, and persists
3
+ * bounded run summaries. A server restart interrupts outstanding leases rather than inventing continued progress.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { LiveRunSchema } from '../../schema/src/live-run.js';
7
+ import { readUtf8, writeUtf8 } from '../../reports/src/object-io.js';
8
+ import { publishChecks } from '../../reports/src/publish-checks.js';
9
+ import { layerCatalogKey } from '../../reports/src/layer-store.js';
10
+ const PREFIX = 'server/live/';
11
+ const keyOf = (run) => `${PREFIX}${String(Number.MAX_SAFE_INTEGER - run.startedAtMs).padStart(16, '0')}-${run.id}.json`;
12
+ /** A refused run update: 403 another publisher, 404 unknown run, 409 stale/terminal run, or 429 capacity. */
13
+ export class LiveRunError extends Error {
14
+ status;
15
+ constructor(status, message) {
16
+ super(message);
17
+ this.status = status;
18
+ }
19
+ }
20
+ /**
21
+ * One server's live feed. Heartbeats never create result layers. Persisted summaries contain at most 20 recent
22
+ * checks and 10 active checks; completed results live in ordinary immutable layers. Leases expire by server time.
23
+ * @example
24
+ * const live = new LiveRuns({ store, timeoutMs: 30_000, stalledMs: 900_000, onChange: () => {}, onLayer: async () => {} });
25
+ */
26
+ export class LiveRuns {
27
+ options;
28
+ held = new Map();
29
+ attempts = new Map();
30
+ queues = new Map();
31
+ /** Resolves after restoring summaries and interrupting leases left by a previous server process. */
32
+ ready;
33
+ now;
34
+ constructor(options) {
35
+ this.options = options;
36
+ this.now = options.now ?? Date.now;
37
+ this.ready = this.restore();
38
+ // Requests await ready and surface failures; no unhandled rejection while a backend is unavailable.
39
+ void this.ready.catch(() => undefined);
40
+ }
41
+ async restore() {
42
+ const keys = (await this.options.store.listKeys(PREFIX)).slice(0, 200);
43
+ for (const key of keys) {
44
+ const text = await readUtf8(this.options.store, key);
45
+ if (text === null)
46
+ continue;
47
+ const stored = JSON.parse(text);
48
+ const run = LiveRunSchema.parse(stored.run);
49
+ const state = { run, owner: stored.owner, digest: stored.digest, states: new Map() };
50
+ this.held.set(run.id, state);
51
+ if (run.status === 'running')
52
+ await this.interrupt(state, 'The Atlas server restarted before the run completed.');
53
+ }
54
+ }
55
+ async save(state) {
56
+ await writeUtf8(this.options.store, keyOf(state.run), JSON.stringify({ run: state.run, owner: state.owner, digest: state.digest }), 'application/json');
57
+ }
58
+ locked(id, action) {
59
+ const next = (this.queues.get(id) ?? Promise.resolve()).catch(() => { }).then(action);
60
+ this.queues.set(id, next);
61
+ return next.finally(() => { if (this.queues.get(id) === next)
62
+ this.queues.delete(id); });
63
+ }
64
+ state(id, owner) {
65
+ const held = this.held.get(id);
66
+ if (!held)
67
+ throw new LiveRunError(404, 'Unknown run.');
68
+ if (held.owner !== owner)
69
+ throw new LiveRunError(403, 'This run belongs to another publisher.');
70
+ return held;
71
+ }
72
+ async interrupt(state, reason) {
73
+ const next = { ...state, states: new Map(), run: { ...state.run, status: 'interrupted', reason, finishedAtMs: this.now(), running: 0,
74
+ current: state.run.current.map(check => ({ ...check, status: 'not-run', detail: reason })) } };
75
+ await this.save(next);
76
+ this.held.set(next.run.id, next);
77
+ this.options.onChange(next.run);
78
+ }
79
+ expired(run) {
80
+ if (this.now() - run.lastHeartbeatAtMs >= this.options.timeoutMs)
81
+ return 'Producer heartbeat timed out. The run may have exited or lost its connection.';
82
+ if (this.options.stalledMs > 0 && this.now() - run.lastProgressAtMs >= this.options.stalledMs)
83
+ return 'No test progress arrived before the configured stall timeout.';
84
+ return undefined;
85
+ }
86
+ async requireLive(state) {
87
+ if (state.run.status !== 'running')
88
+ throw new LiveRunError(409, 'This run has ended.');
89
+ const reason = this.expired(state.run);
90
+ if (reason) {
91
+ await this.interrupt(state, reason);
92
+ throw new LiveRunError(409, reason);
93
+ }
94
+ }
95
+ /** Read the most recent 50 summaries. Progress detail and counters are already bounded and cached. */
96
+ async list() {
97
+ await this.ready;
98
+ return [...this.held.values()].map(state => state.run).sort((a, b) => b.startedAtMs - a.startedAtMs || a.id.localeCompare(b.id)).slice(0, 50);
99
+ }
100
+ /** Register one producer lease. Retrying an identical start is idempotent. */
101
+ async start(input, owner) {
102
+ await this.ready;
103
+ return this.locked(input.id, async () => {
104
+ const prior = this.held.get(input.id);
105
+ if (prior) {
106
+ const found = this.state(input.id, owner);
107
+ if (Object.entries(input).some(([key, value]) => found.run[key] !== value))
108
+ throw new LiveRunError(409, 'A run id cannot be reused with another identity.');
109
+ return found.run;
110
+ }
111
+ if ([...this.held.values()].filter(state => state.run.status === 'running').length >= 100)
112
+ throw new LiveRunError(429, 'Too many live runs.');
113
+ const now = this.now();
114
+ const state = { owner, digest: '', states: new Map(), run: { ...input, status: 'running', sequence: 0, startedAtMs: now, lastHeartbeatAtMs: now, lastProgressAtMs: now, completed: 0, passed: 0, failed: 0, notRun: 0, running: 0, current: [], recent: [] } };
115
+ await this.save(state);
116
+ this.held.set(input.id, state);
117
+ // Retain bounded terminal history in memory; durable summaries remain in the configured backend.
118
+ if (this.held.size > 200) {
119
+ const old = [...this.held.values()].filter(value => value.run.status !== 'running').sort((a, b) => a.run.startedAtMs - b.run.startedAtMs)[0];
120
+ if (old)
121
+ this.held.delete(old.run.id);
122
+ }
123
+ this.options.onChange(state.run);
124
+ return state.run;
125
+ });
126
+ }
127
+ /** Renew liveness by server receipt time, independently of whether a test made progress. */
128
+ async heartbeat(id, owner) {
129
+ await this.ready;
130
+ await this.locked(id, async () => {
131
+ const state = this.state(id, owner);
132
+ await this.requireLive(state);
133
+ const next = { ...state, run: { ...state.run, lastHeartbeatAtMs: this.now() } };
134
+ await this.save(next);
135
+ this.held.set(id, next);
136
+ this.options.onChange(next.run);
137
+ });
138
+ }
139
+ /** Apply one ordered progress batch and publish its completed checks before acknowledging it. */
140
+ async update(id, input, owner) {
141
+ await this.ready;
142
+ return this.locked(id, async () => {
143
+ const state = this.state(id, owner);
144
+ const digest = createHash('sha256').update(JSON.stringify(input)).digest('hex');
145
+ if (input.sequence === state.run.sequence && digest === state.digest)
146
+ return state.run;
147
+ await this.requireLive(state);
148
+ if (input.sequence !== state.run.sequence + 1)
149
+ throw new LiveRunError(409, 'This run ended or its progress sequence is stale.');
150
+ if (state.states.size + input.checks.filter(check => !state.states.has(check.id)).length > 100_000)
151
+ throw new LiveRunError(429, 'A live run may track at most 100000 check identities.');
152
+ const priorAttempt = this.attempts.get(id);
153
+ if (priorAttempt && (priorAttempt.sequence !== input.sequence || priorAttempt.digest !== digest))
154
+ throw new LiveRunError(409, 'Retry the pending sequence with identical content.');
155
+ const now = priorAttempt?.at ?? Math.max(this.now(), state.run.lastProgressAtMs + 1);
156
+ const states = new Map(state.states);
157
+ const run = { ...state.run, sequence: input.sequence, lastHeartbeatAtMs: now, ...(input.checks.length ? { lastProgressAtMs: now } : {}) };
158
+ const current = new Map(run.current.map(check => [check.id, check]));
159
+ for (const check of input.checks) {
160
+ const prior = states.get(check.id);
161
+ if (prior === 'running')
162
+ run.running--;
163
+ else if (prior) {
164
+ run.completed--;
165
+ if (prior === 'passed')
166
+ run.passed--;
167
+ else if (prior === 'failed')
168
+ run.failed--;
169
+ else
170
+ run.notRun--;
171
+ }
172
+ states.set(check.id, check.status);
173
+ if (check.status === 'running') {
174
+ run.running++;
175
+ current.set(check.id, check);
176
+ }
177
+ else {
178
+ run.completed++;
179
+ if (check.status === 'passed')
180
+ run.passed++;
181
+ else if (check.status === 'failed')
182
+ run.failed++;
183
+ else
184
+ run.notRun++;
185
+ current.delete(check.id);
186
+ }
187
+ }
188
+ run.current = [...current.values()].slice(-10);
189
+ run.recent = [...input.checks].reverse().concat(run.recent.filter(check => !input.checks.some(next => next.id === check.id))).slice(0, 20);
190
+ if (input.finish) {
191
+ if (input.finish === 'completed' && run.running)
192
+ throw new LiveRunError(409, 'Finish running checks before completing the run.');
193
+ run.status = input.finish;
194
+ run.finishedAtMs = now;
195
+ run.reason = input.reason;
196
+ if (input.finish === 'interrupted') {
197
+ run.running = 0;
198
+ run.current = run.current.map(check => ({ ...check, status: 'not-run', detail: input.reason ?? 'Run interrupted.' }));
199
+ }
200
+ states.clear();
201
+ }
202
+ const finished = input.checks.filter(check => check.status !== 'running');
203
+ this.attempts.set(id, { sequence: input.sequence, digest, at: now });
204
+ if (finished.length) {
205
+ const layer = await publishChecks(this.options.store, { producer: state.run.producer, environment: state.run.environment, branch: state.run.branch, sha: state.run.sha, recordedAtMs: now,
206
+ labels: { 'atlas.run': id }, checks: finished });
207
+ await this.options.onLayer(layerCatalogKey(layer.recordedAtMs, layer.id), layer);
208
+ }
209
+ const next = { ...state, states, run, digest };
210
+ await this.save(next);
211
+ this.held.set(id, next);
212
+ this.attempts.delete(id);
213
+ this.options.onChange(run);
214
+ return run;
215
+ });
216
+ }
217
+ /** Expire silent producers and optional progress stalls. Heartbeats cannot revive an expired run. */
218
+ async sweep() {
219
+ await this.ready;
220
+ await Promise.all([...this.held.keys()].map(id => this.locked(id, async () => {
221
+ const state = this.held.get(id);
222
+ if (state.run.status !== 'running')
223
+ return;
224
+ const reason = this.expired(state.run);
225
+ if (reason)
226
+ await this.interrupt(state, reason);
227
+ })));
228
+ }
229
+ }
@@ -6,6 +6,7 @@
6
6
  * address, whether it needs a token), so what it says is true of the server that says it, and it points at the
7
7
  * machine-readable description (`/api/openapi.json`) rather than repeating it.
8
8
  */
9
+ import { packageManagerTable } from '../../reports/src/package-managers.js';
9
10
  /** The guide for one server, Markdown. Stable in structure: an agent may read it by its headings. */
10
11
  export function llmsTxt(context) {
11
12
  const { url } = context;
@@ -28,27 +29,35 @@ ${context.open
28
29
 
29
30
  ## Integrate a project (run in the project's repository)
30
31
 
32
+ Install \`@crvouga/atlas\` with the project's existing package manager first. The commands below use Bun;
33
+ substitute the executor for your package manager. Node also runs the CLI; Bun is optional. Read the installed
34
+ \`docs/agents.md\` for the supported runtime versions and full integration playbook.
35
+
36
+ ${packageManagerTable()}
37
+
31
38
  \`\`\`sh
39
+ bun add -d @crvouga/atlas
32
40
  export ATLAS_STORE=${url}${context.open ? '' : '\nexport ATLAS_TOKEN=<a publish token>'}
33
- bunx @crvouga/atlas init --server ${url} # detects test runners and specs, prints a plan (add --json for a machine-readable one)
34
- bunx @crvouga/atlas init --server ${url} --write # writes the CI workflow and the AGENTS.md section the plan names
35
- bunx @crvouga/atlas report doctor # proves the server can be written to and read
41
+ export ATLAS_PROJECT=checkout-service # choose the same stable slug locally and in CI
42
+ bunx atlas init --server ${url} # detects test runners and specs, prints a plan (add --json for a machine-readable one)
43
+ bunx atlas init --server ${url} --write # writes the CI workflow and the AGENTS.md section the plan names
44
+ bunx atlas report doctor # proves the server can be written to and read
36
45
  \`\`\`
37
46
 
38
47
  After each suite, send its results (a JUnit or CTRF file, or JSON lines as checks finish):
39
48
 
40
49
  \`\`\`sh
41
- bunx @crvouga/atlas report publish --producer <suite> --junit out.xml --specs ./specs --environment <env>
42
- my-suite --json | bunx @crvouga/atlas report stream --producer <suite> --environment <env>
50
+ bunx atlas report publish --producer <suite> --junit out.xml --specs ./specs --environment <env>
51
+ my-suite --json | bunx atlas report stream --producer <suite> --environment <env>
43
52
  \`\`\`
44
53
 
45
- Or with no tooling at all: \`POST ${url}/api/checks\` with \`{"producer":"smoke","checks":[{"id":"login","title":"can log in","status":"passed"}]}\`.
54
+ Or with no tooling at all: \`POST ${url}/api/checks?project=<slug>\` with \`{"producer":"smoke","checks":[{"id":"login","title":"can log in","status":"passed"}]}\`.
46
55
 
47
56
  ## Ask whether it passes
48
57
 
49
58
  \`\`\`sh
50
- bunx @crvouga/atlas report status --format json --branch main --ignore-modified # exit 0 pass, 1 does not, 2 wrong command
51
- curl ${context.open ? '' : '-H "authorization: Bearer $ATLAS_TOKEN" '}${url}/api/status # the same verdict; header X-Atlas-Ok: true|false
59
+ bunx atlas report status --format json --branch main --ignore-modified # exit 0 pass, 1 does not, 2 wrong command
60
+ curl ${context.open ? '' : '-H "authorization: Bearer $ATLAS_TOKEN" '}"${url}/api/status?project=$ATLAS_PROJECT" # the same verdict; header X-Atlas-Ok: true|false
52
61
  \`\`\`
53
62
 
54
63
  ## Rules worth knowing
@@ -2,7 +2,7 @@
2
2
  * @module `atlas serve`
3
3
  *
4
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 viewer, listens at once (so a liveness probe finds the
5
+ * backend can be written to before listening, finds or builds the client, listens at once (so a liveness probe finds the
6
6
  * process while a large backend is still being read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared
7
7
  * with `atlas dev`, so the two run the same server.
8
8
  *
@@ -2,7 +2,7 @@
2
2
  * @module `atlas serve`
3
3
  *
4
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 viewer, listens at once (so a liveness probe finds the
5
+ * backend can be written to before listening, finds or builds the client, listens at once (so a liveness probe finds the
6
6
  * process while a large backend is still being read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared
7
7
  * with `atlas dev`, so the two run the same server.
8
8
  *
@@ -14,6 +14,7 @@ import { openStore } from '../../reports/src/store-url.js';
14
14
  import { createAtlasServer } from './app.js';
15
15
  import { ConfigError, loadServerConfig, SERVER_USAGE } from './config.js';
16
16
  import { listen } from './host.js';
17
+ import { fileServerSettings } from './settings.js';
17
18
  import { findSite } from './site.js';
18
19
  /** Open every backend of a configuration. The data folder is made if it is not there. */
19
20
  export function openConfiguredBackends(config, env, cwd) {
@@ -45,9 +46,10 @@ export function serverOptionsOf(config, backends, log) {
45
46
  ? (layer) => (!branches?.length || (layer.branch !== null && branches.includes(layer.branch))) && (!config.include.ignoreModified || layer.freshness === undefined)
46
47
  : undefined;
47
48
  return {
48
- backends, write: config.writeId, tokens: config.tokens, anonymousRead: config.anonymousRead,
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,
49
51
  ...(config.outlineBranch ? { outlineBranch: config.outlineBranch } : {}), ...(include ? { include } : {}),
50
- refreshMs: config.refreshSeconds * 1000, limits: config.limits, heartbeatMs: config.heartbeatSeconds * 1000,
52
+ runTimeoutMs: config.runTimeoutSeconds * 1000, runStallMs: config.runStallSeconds * 1000, refreshMs: config.refreshSeconds * 1000, limits: config.limits, heartbeatMs: config.heartbeatSeconds * 1000,
51
53
  ...(config.log && log ? { log } : {})
52
54
  };
53
55
  }
@@ -80,8 +82,8 @@ export async function serveMain(argv, io) {
80
82
  }
81
83
  const site = await findSite(config.site, cwd, io.out);
82
84
  if (!site)
83
- io.out('No viewer to serve: build one with `atlas-visualizer build --no-data --out atlas-site`. The API is served meanwhile.\n');
84
- const server = createAtlasServer({ ...serverOptionsOf(config, backends, line => io.out(`${JSON.stringify(line)}\n`)), ...(site ? { site } : {}) });
85
+ 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 } : {}) });
85
87
  // Listen at once: the first pass over a large backend can take a while, and a liveness probe must find the
86
88
  // process meanwhile. /readyz is 503 until there is a report to show.
87
89
  const running = await listen(server.fetch, { port: config.port, host: config.host, maxBodyBytes: Math.max(config.limits.maxObjectMiB, config.limits.maxJsonMiB) * 1024 * 1024 });
@@ -1,5 +1,5 @@
1
1
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
2
- export declare const API_VERSION = "1.0.0";
2
+ export declare const API_VERSION = "1.3.0";
3
3
  /** Who may call an operation: `none` (anyone), or the least role that may (`read` < `publish` < `admin`). */
4
4
  export type Role = 'none' | 'read' | 'publish' | 'admin';
5
5
  type JsonSchema = Record<string, unknown>;
@@ -42,10 +42,7 @@ export type Operation = {
42
42
  readonly body?: Body;
43
43
  readonly responses: Readonly<Record<string, Response>>;
44
44
  };
45
- /**
46
- * Every operation of the API. The order is the order of the document. The descriptions are the documentation an
47
- * agent reads, so they say what to send, what comes back, and what to do when it does not work.
48
- */
45
+ /** Every node operation and the same stable report protocol inside each project namespace. */
49
46
  export declare const OPERATIONS: readonly Operation[];
50
47
  /**
51
48
  * Routes the server registers that are not operations of this API, each with the reason. The coverage test skips