@crvouga/atlas 0.5.0 → 0.6.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.
- package/CHANGELOG.md +6 -0
- package/README.md +30 -11
- package/dist/core/src/cli.d.ts +1 -1
- package/dist/core/src/cli.js +8 -3
- package/dist/core/src/config.d.ts +7 -0
- package/dist/core/src/config.js +48 -14
- package/dist/core/src/run/execute.d.ts +6 -0
- package/dist/core/src/run/execute.js +4 -2
- package/dist/reports/src/doctor.d.ts +1 -1
- package/dist/reports/src/doctor.js +3 -1
- package/dist/reports/src/http-store.d.ts +4 -0
- package/dist/reports/src/http-store.js +6 -3
- package/dist/reports/src/init.js +6 -1
- package/dist/reports/src/live-reporter.d.ts +34 -0
- package/dist/reports/src/live-reporter.js +122 -0
- package/dist/reports/src/report-cli.d.ts +1 -1
- package/dist/reports/src/report-cli.js +3 -2
- package/dist/reports/src/store-url.js +1 -1
- package/dist/runners/src/codecept.d.ts +14 -0
- package/dist/runners/src/codecept.js +16 -0
- package/dist/runners/src/jest.d.ts +50 -0
- package/dist/runners/src/jest.js +52 -0
- package/dist/runners/src/mocha.d.ts +40 -0
- package/dist/runners/src/mocha.js +39 -0
- package/dist/runners/src/node-test.d.ts +28 -0
- package/dist/runners/src/node-test.js +44 -0
- package/dist/runners/src/publish.d.ts +30 -0
- package/dist/runners/src/publish.js +68 -0
- package/dist/schema/src/api.d.ts +1 -0
- package/dist/schema/src/api.js +1 -1
- package/dist/schema/src/index.d.ts +2 -0
- package/dist/schema/src/index.js +2 -0
- package/dist/schema/src/live-run.d.ts +123 -0
- package/dist/schema/src/live-run.js +30 -0
- package/dist/schema/src/manifest.d.ts +2 -2
- package/dist/schema/src/project.d.ts +20 -0
- package/dist/schema/src/project.js +10 -0
- package/dist/schema/src/report-sources.d.ts +1 -0
- package/dist/schema/src/report-sources.js +2 -1
- package/dist/schema/src/runs-index.d.ts +1 -1
- package/dist/schema/src/server-config.d.ts +84 -0
- package/dist/schema/src/server-config.js +12 -0
- package/dist/server/src/app.d.ts +10 -0
- package/dist/server/src/app.js +148 -10
- package/dist/server/src/config.d.ts +1 -1
- package/dist/server/src/config.js +20 -3
- package/dist/server/src/dev.d.ts +3 -3
- package/dist/server/src/dev.js +22 -13
- package/dist/server/src/events.d.ts +3 -3
- package/dist/server/src/events.js +1 -1
- package/dist/server/src/index.d.ts +2 -0
- package/dist/server/src/index.js +2 -0
- package/dist/server/src/live-runs.d.ts +52 -0
- package/dist/server/src/live-runs.js +229 -0
- package/dist/server/src/main.d.ts +1 -1
- package/dist/server/src/main.js +7 -5
- package/dist/server/src/openapi.d.ts +2 -5
- package/dist/server/src/openapi.js +71 -7
- package/dist/server/src/projects.d.ts +3 -0
- package/dist/server/src/projects.js +154 -0
- package/dist/server/src/settings-page.d.ts +9 -0
- package/dist/server/src/settings-page.js +93 -0
- package/dist/server/src/settings.d.ts +23 -0
- package/dist/server/src/settings.js +72 -0
- package/dist/server/src/site.d.ts +8 -6
- package/dist/server/src/site.js +45 -10
- package/dist/server/src/store-api.js +43 -22
- package/docs/deploying.md +19 -19
- package/docs/dogfooding.md +2 -2
- package/docs/extending.md +3 -3
- package/docs/integrating.md +9 -9
- package/docs/reference/cli.md +9 -7
- package/docs/reference/core.md +6 -0
- package/docs/reference/http-api.md +498 -2
- package/docs/reference/reports.md +55 -1
- package/docs/reference/runners.md +169 -0
- package/docs/reference/schema.md +111 -0
- package/docs/reference/server.md +154 -14
- package/docs/report-backends.md +8 -8
- package/docs/report-contract.md +7 -7
- package/docs/reporting-issues.md +3 -3
- package/docs/runners.md +69 -0
- package/docs/server.md +61 -25
- package/docs/statecharts.md +22 -20
- package/llms-full.txt +713 -105
- package/llms.txt +3 -1
- package/package.json +31 -9
- package/packages/runners/README.md +6 -0
- package/packages/runners/compat/codecept.cjs +2 -0
- package/packages/runners/compat/jest.cjs +2 -0
- package/packages/runners/compat/mocha.cjs +2 -0
- package/packages/schema/schemas/atlas-change.schema.json +4 -0
- package/packages/schema/schemas/hello-event.schema.json +4 -0
- package/packages/schema/schemas/live-run-start.schema.json +42 -0
- package/packages/schema/schemas/live-run-update.schema.json +93 -0
- package/packages/schema/schemas/live-run.schema.json +225 -0
- package/packages/schema/schemas/project-slug.schema.json +8 -0
- package/packages/schema/schemas/project.schema.json +22 -0
- package/packages/schema/schemas/projects.schema.json +33 -0
- package/packages/schema/schemas/server-config.schema.json +14 -0
- package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
- package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -0
- package/packages/server/openapi.json +5101 -779
- package/packages/visualizer/README.md +13 -13
- package/packages/visualizer/bin/atlas-client.mjs +176 -0
- package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
- package/packages/visualizer/src/app/AppShell.tsx +26 -17
- package/packages/visualizer/src/app/report-sources.tsx +2 -1
- package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
- package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
- package/packages/visualizer/src/components/Header.module.css +101 -227
- package/packages/visualizer/src/components/Header.tsx +79 -41
- package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
- package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
- package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
- package/packages/visualizer/src/data/queries.ts +14 -7
- package/packages/visualizer/src/evidence/api.ts +2 -1
- package/packages/visualizer/src/evidence/check-detail.tsx +19 -4
- package/packages/visualizer/src/evidence/document-page.tsx +6 -2
- package/packages/visualizer/src/evidence/evidence-context.tsx +17 -12
- package/packages/visualizer/src/evidence/evidence.module.css +11 -1
- package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
- package/packages/visualizer/src/evidence/model-page.tsx +6 -2
- package/packages/visualizer/src/evidence/project-home.tsx +45 -0
- package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
- package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
- package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
- package/packages/visualizer/src/live/live-query.ts +21 -0
- package/packages/visualizer/src/live/live-runs.tsx +69 -0
- package/packages/visualizer/src/live/live-stream.ts +30 -9
- package/packages/visualizer/src/live/report-progress.ts +62 -0
- package/packages/visualizer/src/live/test-progress.tsx +34 -0
- package/packages/visualizer/src/runtime.ts +17 -1
- package/packages/visualizer/src/state/ui-store.ts +3 -2
- package/packages/visualizer/src/views/ChartPage.tsx +2 -0
- package/packages/visualizer/src/views/ChartTests.tsx +43 -0
- package/packages/visualizer/src/views/SidePanel.tsx +2 -0
- package/packages/visualizer/vite/atlas-files.test.ts +4 -4
- package/packages/visualizer/vite/atlas-files.ts +1 -5
- package/packages/visualizer/vite/atlas-server-proxy.ts +2 -1
package/dist/server/src/dev.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
20
|
-
browser talks to one host and a change to the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
65
|
+
server: { port: publicPort, host: config.host, strictPort: true }
|
|
65
66
|
});
|
|
66
|
-
|
|
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
|
|
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)
|
|
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
|
|
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';
|
package/dist/server/src/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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
|
*
|
package/dist/server/src/main.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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.
|
|
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
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* operations and new events. See AGENTS.md ("The server is a living application").
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod/v4';
|
|
16
|
+
import { ProjectsSchema } from '../../schema/src/project.js';
|
|
16
17
|
import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema } from '../../schema/src/api.js';
|
|
17
|
-
import { AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
|
|
18
|
+
import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
|
|
18
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`. */
|
|
19
|
-
export const API_VERSION = '1.
|
|
20
|
+
export const API_VERSION = '1.3.0';
|
|
20
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. */
|
|
21
22
|
function schemaOf(schema, io = 'output') {
|
|
22
23
|
const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { target: 'draft-2020-12', unrepresentable: 'any', io });
|
|
@@ -27,13 +28,15 @@ function schemaOf(schema, io = 'output') {
|
|
|
27
28
|
* the shape the server sends, `input` the shape it accepts.
|
|
28
29
|
*/
|
|
29
30
|
const COMPONENTS = {
|
|
31
|
+
LiveRun: [LiveRunSchema, 'output'], LiveRunStart: [LiveRunStartSchema, 'input'], LiveRunUpdate: [LiveRunUpdateSchema, 'input'],
|
|
32
|
+
ServerEditableSettings: [ServerEditableSettingsSchema, 'input'], ServerSettingsSnapshot: [ServerSettingsSnapshotSchema, 'output'],
|
|
30
33
|
Problem: [ProblemSchema, 'output'], Health: [HealthSchema, 'output'], Ready: [ReadySchema, 'output'], ServerInfo: [ServerInfoSchema, 'output'],
|
|
31
34
|
EffectiveEvidence: [EffectiveEvidenceSchema, 'output'], EffectiveDelta: [EffectiveDeltaSchema, 'output'], ReportStatus: [ReportStatusSchema, 'output'],
|
|
32
35
|
PublishChecks: [PublishChecksSchema.partial({ recordedAtMs: true }), 'input'], ChecksResult: [ChecksResultSchema, 'output'],
|
|
33
36
|
StoreListing: [StoreListingSchema, 'output'], StoreWriteResult: [StoreWriteResultSchema, 'output'],
|
|
34
37
|
Outline: [OutlineSchema, 'output'], EvidencePage: [EvidencePageSchema, 'output'], LayerRows: [LayerRowsSchema, 'output'], CheckDetail: [CheckDetailSchema, 'output'],
|
|
35
38
|
SpecIndex: [SpecIndexSchema, 'output'], ReportSources: [ReportSourcesSchema, 'output'], RunsIndex: [RunsIndexSchema, 'output'],
|
|
36
|
-
HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
|
|
39
|
+
RunEvent: [LiveRunSchema, 'output'], HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
|
|
37
40
|
};
|
|
38
41
|
const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
39
42
|
const str = { type: 'string' };
|
|
@@ -49,7 +52,54 @@ const LAYER_ID = { name: 'id', in: 'path', required: true, description: 'A layer
|
|
|
49
52
|
* Every operation of the API. The order is the order of the document. The descriptions are the documentation an
|
|
50
53
|
* agent reads, so they say what to send, what comes back, and what to do when it does not work.
|
|
51
54
|
*/
|
|
52
|
-
|
|
55
|
+
const BASE_OPERATIONS = [
|
|
56
|
+
{
|
|
57
|
+
route: 'GET /api/projects', path: '/api/projects', operationId: 'getProjects', tag: 'Projects', role: 'read',
|
|
58
|
+
summary: 'List projects on this Atlas node', description: 'Projects are created on their first authenticated upload. All report, spec, recording and live-run data is isolated by project. Unqualified API calls address the legacy default project. Tokens retain their node-wide role; project partitioning is not a tenant authorization boundary.',
|
|
59
|
+
responses: { '200': { description: 'Available project identities.', contentType: 'application/json', schema: schemaOf(ProjectsSchema) }, '401': UNAUTHORIZED, '503': problem('The project catalog is unavailable.') }
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
route: 'GET /api/live', path: '/api/live', operationId: 'listLiveRuns', tag: 'Live', role: 'read',
|
|
63
|
+
summary: 'Live test progress and recent invocations', description: 'Returns up to 50 recent producer leases, including running, completed and interrupted runs. Counters and recent check/step updates are bounded; authentication credentials are never returned. Consume run events for immediate bounded snapshots; re-fetch after reconnect or atlas:change with scope runs on older servers.',
|
|
64
|
+
responses: { '200': { description: 'Recent live run summaries.', contentType: 'application/json', schema: { type: 'object', properties: { runs: { type: 'array', items: ref('LiveRun') } }, required: ['runs'] } }, '401': UNAUTHORIZED }
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
route: 'POST /api/live/:id', path: '/api/live/{id}', operationId: 'startLiveRun', tag: 'Live', role: 'publish',
|
|
68
|
+
summary: 'Start a live producer lease', description: 'Register a UUID run id and fixed producer identity before tests begin. Retrying the same identity and token is idempotent. The server timestamps receipt. Send heartbeats every 5 seconds and progress as tests execute. The default heartbeat timeout is 30 seconds; a 15-minute progress stall is configurable separately.',
|
|
69
|
+
parameters: [{ name: 'id', in: 'path', required: true, description: 'Producer-generated UUID, also given in the body.', schema: str }],
|
|
70
|
+
body: { description: 'Fixed invocation identity and optional planned test count.', contentType: 'application/json', required: true, schema: ref('LiveRunStart') },
|
|
71
|
+
responses: { '201': json('The registered lease.', 'LiveRun'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('The id already belongs to another invocation.'), '413': problem('Request too large.'), '422': problem('Invalid identity.'), '429': problem('Live run capacity reached.') }
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
route: 'PATCH /api/live/:id', path: '/api/live/{id}', operationId: 'updateLiveRun', tag: 'Live', role: 'publish',
|
|
75
|
+
summary: 'Publish progress or finish an invocation', description: 'Apply ordered check deltas, starting at sequence 1. Retry a sequence with identical content only. Completed results become immutable report layers immediately; running checks remain operational progress and do not replace historical winners. Finish as completed or interrupted. Stale updates cannot revive an expired lease; pending checks never become passes.',
|
|
76
|
+
parameters: [{ name: 'id', in: 'path', required: true, description: 'The UUID of a run started with this publisher token.', schema: str }],
|
|
77
|
+
body: { description: 'At most 1000 unique check updates; optional terminal state and reason.', contentType: 'application/json', required: true, schema: ref('LiveRunUpdate') },
|
|
78
|
+
responses: { '200': json('Updated progress.', 'LiveRun'), '401': UNAUTHORIZED, '403': FORBIDDEN, '404': problem('Unknown run.'), '409': problem('Run ended or sequence is stale.'), '413': problem('Request too large.'), '422': problem('Invalid progress.'), '429': problem('Run check capacity reached.') }
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
route: 'POST /api/live/:id/heartbeat', path: '/api/live/{id}/heartbeat', operationId: 'heartbeatLiveRun', tag: 'Live', role: 'publish',
|
|
82
|
+
summary: 'Renew a producer heartbeat', description: 'Renew a running lease using server receipt time, independently of test progress. No result layers are created. Heartbeats cannot revive terminal runs or bypass the configured progress-stall timeout. The producer token must be the one that started this invocation.',
|
|
83
|
+
parameters: [{ name: 'id', in: 'path', required: true, description: 'The UUID of this producer invocation.', schema: str }],
|
|
84
|
+
responses: { '200': { description: 'Heartbeat received.', contentType: 'application/json', schema: { type: 'object', properties: { ok: { const: true } } } }, '401': UNAUTHORIZED, '403': FORBIDDEN, '404': problem('Unknown run.'), '409': problem('Run ended or its lease expired.'), '422': problem('Invalid run id.') }
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
route: 'GET /admin', path: '/admin', operationId: 'getSettingsPage', tag: 'Operations', role: 'read',
|
|
88
|
+
summary: 'Server settings page', description: 'A standalone settings editor. Supply an admin Bearer token in the page to read or save configuration. The token is held only in memory.',
|
|
89
|
+
responses: { '200': { description: 'Settings editor.', contentType: 'text/html', schema: str }, '401': UNAUTHORIZED }
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
route: 'GET /api/settings', path: '/api/settings', operationId: 'getSettings', tag: 'Operations', role: 'admin',
|
|
93
|
+
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.',
|
|
94
|
+
responses: { '200': json('Settings and revision.', 'ServerSettingsSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '503': problem('Configuration is managed by the embedding application.') }
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
route: 'PUT /api/settings', path: '/api/settings', operationId: 'saveSettings', tag: 'Operations', role: 'admin',
|
|
98
|
+
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.',
|
|
99
|
+
parameters: [{ name: 'If-Match', in: 'header', required: true, description: 'The quoted revision returned by GET /api/settings.', schema: str }],
|
|
100
|
+
body: { description: 'Editable fields from ServerConfig, excluding authentication, host, port and site. Maximum 64 KiB.', contentType: 'application/json', required: true, schema: ref('ServerEditableSettings') },
|
|
101
|
+
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.') }
|
|
102
|
+
},
|
|
53
103
|
{
|
|
54
104
|
route: 'GET /healthz', path: '/healthz', operationId: 'getHealth', tag: 'Operations', role: 'none',
|
|
55
105
|
summary: 'Is the process up',
|
|
@@ -84,13 +134,15 @@ export const OPERATIONS = [
|
|
|
84
134
|
route: 'GET /api/events', path: '/api/events', operationId: 'streamEvents', tag: 'Live', role: 'read',
|
|
85
135
|
summary: 'Server-sent events: results arrived, specs or runs changed',
|
|
86
136
|
description: [
|
|
87
|
-
'A `text/event-stream` that stays open.
|
|
137
|
+
'A `text/event-stream` that stays open. Report events announce changes, and run events carry bounded live progress snapshots; authoritative state can always be fetched, so a client that missed events (a dropped connection) loses nothing: read again on reconnect.',
|
|
88
138
|
'',
|
|
89
139
|
'| event | data |',
|
|
90
140
|
'| --- | --- |',
|
|
91
|
-
'| `hello` | `{ version, ready, time }`, sent first |',
|
|
141
|
+
'| `hello` | `{ version, ready, time, heartbeatMs? }`, sent first |',
|
|
92
142
|
'| `report` | `{ version, totalLayers, added: [{ id, producer, environment, branch, sha, recordedAtMs, counts }], addedTotal }`: layers arrived |',
|
|
93
143
|
'| `atlas:change` | `{ scope: "specs" \\| "runs", files, runIds }`: new specs or runs |',
|
|
144
|
+
'| `run` | A LiveRun snapshot after start, progress, heartbeat, completion, or interruption; contains no producer credentials |',
|
|
145
|
+
'| `projects` | `{}` when the node discovers another project; refresh `/api/projects` |',
|
|
94
146
|
'| `ping` | `{}` every `heartbeatSeconds`, so a quiet server is told apart from a dead connection |',
|
|
95
147
|
'',
|
|
96
148
|
'Past `limits.maxStreams` viewers the answer is `503` with `Retry-After`: poll `/api/reports/evidence/effective` instead. A browser needs no code beyond `new EventSource("/api/events")`; other clients send the token as `Authorization: Bearer`.'
|
|
@@ -295,6 +347,18 @@ export const OPERATIONS = [
|
|
|
295
347
|
responses: { '200': { description: 'The file.', contentType: 'application/octet-stream', schema: { type: 'string', format: 'binary' } }, '401': UNAUTHORIZED, '404': problem('No such run or file.') }
|
|
296
348
|
}
|
|
297
349
|
];
|
|
350
|
+
const NODE_OPERATIONS = new Set(['/api/projects', '/api/settings', '/api/server', '/api/openapi.json']);
|
|
351
|
+
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' };
|
|
352
|
+
/** Every node operation and the same stable report protocol inside each project namespace. */
|
|
353
|
+
export const OPERATIONS = BASE_OPERATIONS.flatMap(operation => {
|
|
354
|
+
if (!operation.path.startsWith('/api/') || NODE_OPERATIONS.has(operation.path))
|
|
355
|
+
return [operation];
|
|
356
|
+
return [{ ...operation, parameters: [...(operation.parameters ?? []), PROJECT_PARAMETER] }, {
|
|
357
|
+
...operation, route: operation.route.replace('/api/', '/api/projects/:project/'), path: operation.path.replace('/api/', '/api/projects/{project}/'),
|
|
358
|
+
operationId: `${operation.operationId}ForProject`, parameters: [{ ...PROJECT_PARAMETER, in: 'path', required: true }, ...(operation.parameters ?? [])],
|
|
359
|
+
description: `${operation.description}\n\nScoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.`
|
|
360
|
+
}];
|
|
361
|
+
});
|
|
298
362
|
/**
|
|
299
363
|
* Routes the server registers that are not operations of this API, each with the reason. The coverage test skips
|
|
300
364
|
* these and only these.
|
|
@@ -341,7 +405,7 @@ export function openApiDocument(options = {}) {
|
|
|
341
405
|
version: API_VERSION,
|
|
342
406
|
summary: 'Take in test results from every environment, keep them, and show everyone the combined report, live.',
|
|
343
407
|
description: [
|
|
344
|
-
'One Atlas
|
|
408
|
+
'One Atlas node serves multiple projects. Clients select a project using ?project=<slug>, X-Atlas-Project, or /api/projects/<slug>/… . Each project has independent specs, layers, recordings, status, and event streams. Within each project, the newest completed result of every check wins its slot. Unqualified requests address the legacy default project.',
|
|
345
409
|
'',
|
|
346
410
|
'**Authentication.** With tokens configured, send `Authorization: Bearer <token>`. Roles: `read` < `publish` < `admin`. Uploading needs a Bearer token; a browser\'s HTTP Basic sign-in (any user name, the token as the password) is accepted for reading only. A server with no tokens is open and listens on this machine only unless told otherwise.',
|
|
347
411
|
'',
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { AtlasServer, AtlasServerOptions } from './app.js';
|
|
2
|
+
/** Wrap the single-project protocol in project selection. Auth, upload validation and composition stay identical. */
|
|
3
|
+
export declare function projectServer(options: AtlasServerOptions, create: (options: AtlasServerOptions) => AtlasServer): AtlasServer;
|