@crvouga/atlas 0.16.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +52 -8
- package/dist/core/src/cli.d.ts +1 -1
- package/dist/core/src/cli.js +1 -1
- package/dist/reports/src/attachment-files.d.ts +3 -1
- package/dist/reports/src/attachment-files.js +14 -4
- package/dist/reports/src/evidence-types.d.ts +22 -0
- package/dist/reports/src/garbage.d.ts +39 -0
- package/dist/reports/src/garbage.js +82 -0
- package/dist/reports/src/layer-merge.js +5 -0
- package/dist/reports/src/layer-types.d.ts +1 -1
- package/dist/reports/src/publish-checks.js +11 -3
- package/dist/reports/src/recording-receipt.d.ts +48 -0
- package/dist/reports/src/recording-receipt.js +103 -0
- package/dist/reports/src/report-cli.d.ts +1 -1
- package/dist/reports/src/report-cli.js +20 -2
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +14 -0
- package/dist/reports/src/report-status.d.ts +2 -0
- package/dist/reports/src/report-status.js +17 -1
- package/dist/reports/src/reports-explorer-route.d.ts +8 -0
- package/dist/reports/src/reports-route.d.ts +8 -0
- package/dist/schema/src/api.d.ts +65 -0
- package/dist/schema/src/api.js +7 -2
- package/dist/schema/src/cli-results.d.ts +107 -2
- package/dist/schema/src/cli-results.js +11 -3
- package/dist/schema/src/index.d.ts +1 -0
- package/dist/schema/src/index.js +1 -0
- package/dist/schema/src/layer.d.ts +165 -0
- package/dist/schema/src/layer.js +72 -1
- package/dist/schema/src/server-config.d.ts +7 -49
- package/dist/schema/src/server-config.js +8 -21
- package/dist/schema/src/user-state.d.ts +115 -0
- package/dist/schema/src/user-state.js +54 -0
- package/dist/server/src/app.d.ts +16 -2
- package/dist/server/src/app.js +62 -0
- package/dist/server/src/config.d.ts +16 -21
- package/dist/server/src/config.js +48 -57
- package/dist/server/src/dev.d.ts +1 -1
- package/dist/server/src/dev.js +20 -7
- package/dist/server/src/index.d.ts +5 -1
- package/dist/server/src/index.js +5 -1
- package/dist/server/src/main.d.ts +23 -7
- package/dist/server/src/main.js +72 -26
- package/dist/server/src/openapi.js +24 -5
- package/dist/server/src/projects.js +89 -14
- package/dist/server/src/settings.d.ts +23 -7
- package/dist/server/src/settings.js +50 -60
- package/dist/server/src/storage.d.ts +68 -0
- package/dist/server/src/storage.js +125 -0
- package/dist/server/src/user-state.d.ts +31 -0
- package/dist/server/src/user-state.js +74 -0
- package/docs/agents.md +1 -1
- package/docs/deploying.md +38 -39
- package/docs/reference/cli.md +17 -7
- package/docs/reference/http-api.md +30 -2
- package/docs/reference/reports.md +165 -2
- package/docs/reference/schema.md +161 -16
- package/docs/reference/server.md +263 -41
- package/docs/report-backends.md +0 -144
- package/docs/report-contract.md +1 -1
- package/docs/rich-evidence.md +29 -0
- package/docs/runners.md +1 -1
- package/docs/server.md +141 -36
- package/docs/statecharts.md +1 -1
- package/docs/ui.md +1 -1
- package/docs/workspaces.md +5 -2
- package/llms-full.txt +265 -234
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/packages/reports/STORAGE.md +1 -1
- package/packages/schema/schemas/check-detail.schema.json +20 -0
- package/packages/schema/schemas/effective-delta.schema.json +61 -0
- package/packages/schema/schemas/effective-evidence.schema.json +61 -0
- package/packages/schema/schemas/evidence-page.schema.json +45 -0
- package/packages/schema/schemas/gc-policy.schema.json +36 -0
- package/packages/schema/schemas/gc-run-result.schema.json +106 -0
- package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
- package/packages/schema/schemas/layer-rows.schema.json +16 -0
- package/packages/schema/schemas/layer.schema.json +153 -0
- package/packages/schema/schemas/publish-checks.schema.json +24 -0
- package/packages/schema/schemas/publish-result.schema.json +161 -1
- package/packages/schema/schemas/report-status.schema.json +104 -0
- package/packages/schema/schemas/server-config.schema.json +0 -41
- package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
- package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
- package/packages/schema/schemas/user-state.schema.json +140 -0
- package/packages/server/README.md +11 -6
- package/packages/server/openapi.json +843 -128
- package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
- package/packages/visualizer/src/evidence/marks.tsx +16 -0
package/dist/schema/src/layer.js
CHANGED
|
@@ -109,12 +109,53 @@ export const AttachmentSchema = z.looseObject({
|
|
|
109
109
|
height: z.number().nonnegative().optional(),
|
|
110
110
|
durationMs: z.number().nonnegative().optional().describe('For video and audio'),
|
|
111
111
|
description: z.string().optional(),
|
|
112
|
+
omitted: z.string().optional().describe('Why the producer did not include its bytes, in an open vocabulary: too-large, not-found, not-allowed, over-budget, policy… Present on a metadata-only attachment, so a reader can tell "left out" from "never recorded"'),
|
|
112
113
|
tags: z.array(z.string()).optional().describe('Free labels of this one attachment, such as `demo`: shown as `#demo`, searchable, and normalized by `normalizeTag` (a leading `#` and letter case never matter). Unlike the tags of a check they say nothing about its result'),
|
|
113
114
|
at: MomentSchema.optional(),
|
|
114
115
|
poster: z.string().optional().describe('The `id` of an attachment that stands for this one before it is opened'),
|
|
115
116
|
captions: z.array(z.string()).optional().describe('The `id`s of attachments (WebVTT, SRT) that caption this one'),
|
|
116
117
|
extensions: ExtensionsSchema.optional()
|
|
117
118
|
});
|
|
119
|
+
/**
|
|
120
|
+
* Whether a check is expected to have a screen recording (a video). `required`: the producer expects one, and the layer's
|
|
121
|
+
* recording receipt says whether it arrived. `not-applicable`: the check is intentionally unrecorded (an API-only check),
|
|
122
|
+
* so it counts as neither a recorded pass nor a missing video. An open string: a reader treats any other value, and its
|
|
123
|
+
* absence, as no expectation.
|
|
124
|
+
*/
|
|
125
|
+
export const RECORDING_EXPECTATIONS = ['required', 'not-applicable'];
|
|
126
|
+
/**
|
|
127
|
+
* The state of one check's recording in a receipt, in an open vocabulary. Conventions: `uploaded` (the video's bytes are in
|
|
128
|
+
* the layer, with a checksum), `referenced` (only a `uri` was given: Atlas cannot vouch for it), `skipped` (the video was
|
|
129
|
+
* captured but its bytes were left out, with a reason), `unresolved` (no video was attached at all), `not-applicable`
|
|
130
|
+
* (intentionally unrecorded). A reader shows a state it does not know as it is and never counts it as uploaded.
|
|
131
|
+
*/
|
|
132
|
+
export const RecordingItemSchema = z.looseObject({
|
|
133
|
+
check: z.string().min(1).describe('The id of the check'),
|
|
134
|
+
state: z.string().min(1),
|
|
135
|
+
reason: z.string().optional().describe('Why the recording is not uploaded. Never contains a path, a token or payload data'),
|
|
136
|
+
files: z.array(z.looseObject({
|
|
137
|
+
name: z.string(),
|
|
138
|
+
state: z.string().optional(),
|
|
139
|
+
bytes: z.number().int().nonnegative().optional(),
|
|
140
|
+
sha256: z.string().optional().describe('The checksum of the stored bytes: proof the layer holds this exact file'),
|
|
141
|
+
reason: z.string().optional()
|
|
142
|
+
})).optional().describe('Every video attachment of the check, at any level')
|
|
143
|
+
});
|
|
144
|
+
/**
|
|
145
|
+
* What a layer's producer published about the recordings it expected: counts and one item per check that expected a
|
|
146
|
+
* recording (or declared itself not applicable). Written by `publishChecks` when any check declares a
|
|
147
|
+
* `recordingExpectation`; a layer without it says nothing, which a reader reports as Unknown, never Complete. It only
|
|
148
|
+
* describes publication: it never changes a check's status, its slot or which result wins.
|
|
149
|
+
*/
|
|
150
|
+
export const RecordingReceiptSchema = z.looseObject({
|
|
151
|
+
expected: z.number().int().nonnegative().describe('Checks that required a recording: uploaded + referenced + skipped + unresolved'),
|
|
152
|
+
uploaded: z.number().int().nonnegative(),
|
|
153
|
+
referenced: z.number().int().nonnegative(),
|
|
154
|
+
skipped: z.number().int().nonnegative(),
|
|
155
|
+
unresolved: z.number().int().nonnegative(),
|
|
156
|
+
notApplicable: z.number().int().nonnegative().describe('Checks intentionally unrecorded. They are not expected and not missing'),
|
|
157
|
+
items: z.array(RecordingItemSchema)
|
|
158
|
+
});
|
|
118
159
|
/** A note about a result: a warning, a deprecation, a coverage figure, an accessibility finding, anything. */
|
|
119
160
|
export const AnnotationSchema = z.looseObject({
|
|
120
161
|
type: z.string().min(1).describe('An open vocabulary: note, warning, deprecation, issue, accessibility, coverage…'),
|
|
@@ -178,6 +219,7 @@ export const CheckSchema = z.looseObject({
|
|
|
178
219
|
specPath: z.string().optional().describe('The spec this check belongs to, relative to the repository, such as specs/join.feature'),
|
|
179
220
|
measurements: z.array(MeasurementSchema).default([]),
|
|
180
221
|
durationMs: z.number().nonnegative().optional().describe('How long the check took, when the producer measured it'),
|
|
222
|
+
recordingExpectation: z.string().optional().describe('`required`: expect a video of this check, and report whether it was published. `not-applicable`: intentionally unrecorded. Absent: no expectation. Never changes the check\'s status'),
|
|
181
223
|
...evidenceShape,
|
|
182
224
|
steps: z.array(StepSchema).optional().describe("The check's timeline"),
|
|
183
225
|
attempts: z.array(AttemptSchema).optional().describe('Every try, when the check was retried')
|
|
@@ -269,6 +311,7 @@ export const LayerDocumentSchema = z.looseObject({
|
|
|
269
311
|
source: LayerSourceSchema.nullable().default(null),
|
|
270
312
|
recording: RecordingAddressSchema.nullable().default(null),
|
|
271
313
|
bundle: BundleIndexSchema.default({}),
|
|
314
|
+
recordings: RecordingReceiptSchema.optional().describe('Which expected recordings were published. Absent: the producer expected none, or predates this field; a reader reports Unknown'),
|
|
272
315
|
...evidenceShape
|
|
273
316
|
});
|
|
274
317
|
/**
|
|
@@ -321,6 +364,14 @@ function lenientEvidence(holder, nested = []) {
|
|
|
321
364
|
}
|
|
322
365
|
function lenientLayer(raw) {
|
|
323
366
|
const layer = lenientEvidence(raw);
|
|
367
|
+
// A receipt a reader cannot read is no receipt: recording completeness is then Unknown, and the layer is kept.
|
|
368
|
+
if ('recordings' in layer) {
|
|
369
|
+
const receipt = RecordingReceiptSchema.safeParse(layer.recordings);
|
|
370
|
+
if (receipt.success)
|
|
371
|
+
layer.recordings = receipt.data;
|
|
372
|
+
else
|
|
373
|
+
delete layer.recordings;
|
|
374
|
+
}
|
|
324
375
|
// The execution only groups and labels: one that cannot be read leaves the layer to the documented grouping, never refuses it.
|
|
325
376
|
if ('run' in layer && !LayerRunSchema.safeParse(layer.run).success)
|
|
326
377
|
delete layer.run;
|
|
@@ -426,6 +477,25 @@ const CountsSchema = z.object({
|
|
|
426
477
|
'not-run': z.number().int().nonnegative(),
|
|
427
478
|
total: z.number().int().nonnegative()
|
|
428
479
|
});
|
|
480
|
+
/**
|
|
481
|
+
* Recording completeness across the winning results of a report: `complete` (every expected recording was published),
|
|
482
|
+
* `incomplete` (at least one is skipped or unresolved), `none-expected` (every layer declared its expectations, and none
|
|
483
|
+
* required a recording) or `unknown` (a layer has no receipt, so nothing is claimed). Independent of the checks' outcomes.
|
|
484
|
+
*/
|
|
485
|
+
export const RecordingCompletenessSchema = z.looseObject({
|
|
486
|
+
state: z.enum(['complete', 'incomplete', 'none-expected', 'unknown']),
|
|
487
|
+
expected: z.number().int().nonnegative(),
|
|
488
|
+
uploaded: z.number().int().nonnegative(),
|
|
489
|
+
referenced: z.number().int().nonnegative(),
|
|
490
|
+
skipped: z.number().int().nonnegative(),
|
|
491
|
+
unresolved: z.number().int().nonnegative(),
|
|
492
|
+
notApplicable: z.number().int().nonnegative(),
|
|
493
|
+
layersWithoutReceipt: z.number().int().nonnegative().describe('Layers that contribute results but declared no recording expectations: their completeness is unknown'),
|
|
494
|
+
gaps: z.array(z.looseObject({
|
|
495
|
+
spec: z.string().nullable(), check: z.string(), layer: z.string(), environment: z.string().nullable(),
|
|
496
|
+
state: z.string(), reason: z.string().optional()
|
|
497
|
+
})).describe('Each expected recording that is skipped or unresolved')
|
|
498
|
+
});
|
|
429
499
|
/** The `schemaVersion` of the `ReportStatus` JSON, currently 1. */
|
|
430
500
|
export const REPORT_STATUS_SCHEMA_VERSION = 1;
|
|
431
501
|
/**
|
|
@@ -463,7 +533,8 @@ export const ReportStatusSchema = z.looseObject({
|
|
|
463
533
|
backend: z.string(),
|
|
464
534
|
runUrl: z.string().nullable(),
|
|
465
535
|
detail: z.string()
|
|
466
|
-
})).describe('Each slot whose newest completed result failed, or that is still running')
|
|
536
|
+
})).describe('Each slot whose newest completed result failed, or that is still running'),
|
|
537
|
+
recordings: RecordingCompletenessSchema.optional().describe('Whether the recordings the producers expected were published, separately from `ok`. It never changes `ok`')
|
|
467
538
|
});
|
|
468
539
|
/**
|
|
469
540
|
* The specs of one version of a project, as published: what the report is laid over. Layers of
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* @module Server configuration schema
|
|
3
3
|
*
|
|
4
4
|
* The schema of `atlas.server.json`, the file that configures `atlas serve` and `atlas dev`: where to listen, which
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* a
|
|
5
|
+
* bearer tokens may read, publish or administer, and limits. Where reports are kept is not here: the server stores
|
|
6
|
+
* everything in S3-compatible storage named by required `ATLAS_S3_*` environment variables, never in this file.
|
|
7
|
+
* Every field has a default. Unlike the report format, unknown keys are an error, so a typo (or a removed `data`,
|
|
8
|
+
* `backends` or `write` key) is reported instead of ignored. Flags and environment variables override the file.
|
|
8
9
|
*
|
|
9
10
|
* @see docs/deploying.md
|
|
10
11
|
* @see docs/server.md
|
|
@@ -18,16 +19,6 @@ export declare const ServerRoleSchema: z.ZodEnum<{
|
|
|
18
19
|
}>;
|
|
19
20
|
/** One of the roles of `ServerRoleSchema`. */
|
|
20
21
|
export type ServerRole = z.infer<typeof ServerRoleSchema>;
|
|
21
|
-
/**
|
|
22
|
-
* A further store the server reads together with its data folder, and optionally writes to: an id, a label and a
|
|
23
|
-
* location URL.
|
|
24
|
-
*/
|
|
25
|
-
export declare const ServerBackendSchema: z.ZodObject<{
|
|
26
|
-
id: z.ZodString;
|
|
27
|
-
label: z.ZodOptional<z.ZodString>;
|
|
28
|
-
url: z.ZodString;
|
|
29
|
-
write: z.ZodOptional<z.ZodBoolean>;
|
|
30
|
-
}, z.core.$strict>;
|
|
31
22
|
/**
|
|
32
23
|
* A bearer token: a name for logs, a role, and the token itself or the name of the environment variable that holds it
|
|
33
24
|
* (exactly one of the two).
|
|
@@ -43,21 +34,12 @@ export declare const ServerTokenSchema: z.ZodObject<{
|
|
|
43
34
|
tokenEnv: z.ZodOptional<z.ZodString>;
|
|
44
35
|
}, z.core.$strict>;
|
|
45
36
|
/**
|
|
46
|
-
* The whole `atlas.server.json`: listening address,
|
|
47
|
-
* viewer to serve, which layers take part in the report, refresh and heartbeat intervals, request limits and logging.
|
|
37
|
+
* The whole `atlas.server.json`: listening address, authentication, the viewer to serve, which layers take part in the report, refresh and heartbeat intervals, request limits and logging.
|
|
48
38
|
*/
|
|
49
39
|
export declare const ServerConfigSchema: z.ZodObject<{
|
|
50
40
|
$schema: z.ZodOptional<z.ZodString>;
|
|
51
41
|
host: z.ZodDefault<z.ZodString>;
|
|
52
42
|
port: z.ZodDefault<z.ZodNumber>;
|
|
53
|
-
data: z.ZodDefault<z.ZodString>;
|
|
54
|
-
backends: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
55
|
-
id: z.ZodString;
|
|
56
|
-
label: z.ZodOptional<z.ZodString>;
|
|
57
|
-
url: z.ZodString;
|
|
58
|
-
write: z.ZodOptional<z.ZodBoolean>;
|
|
59
|
-
}, z.core.$strict>>>;
|
|
60
|
-
write: z.ZodOptional<z.ZodString>;
|
|
61
43
|
auth: z.ZodDefault<z.ZodObject<{
|
|
62
44
|
tokens: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
63
45
|
name: z.ZodString;
|
|
@@ -93,17 +75,9 @@ export declare const ServerConfigSchema: z.ZodObject<{
|
|
|
93
75
|
export type ServerConfig = z.output<typeof ServerConfigSchema>;
|
|
94
76
|
/** A server configuration as written, before defaults are applied. */
|
|
95
77
|
export type ServerConfigInput = z.input<typeof ServerConfigSchema>;
|
|
96
|
-
/**
|
|
78
|
+
/** Report and live-run settings editable by an admin. Storage, authentication, listeners and the served site stay deployment-owned. Replaces the saved portion of the user state; omitted fields use defaults after restart. */
|
|
97
79
|
export declare const ServerEditableSettingsSchema: z.ZodObject<{
|
|
98
|
-
data: z.ZodOptional<z.ZodDefault<z.ZodString>>;
|
|
99
|
-
backends: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
100
|
-
id: z.ZodString;
|
|
101
|
-
label: z.ZodOptional<z.ZodString>;
|
|
102
|
-
url: z.ZodString;
|
|
103
|
-
write: z.ZodOptional<z.ZodBoolean>;
|
|
104
|
-
}, z.core.$strict>>>>;
|
|
105
80
|
log: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
|
|
106
|
-
write: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
107
81
|
outlineBranch: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
108
82
|
include: z.ZodOptional<z.ZodDefault<z.ZodObject<{
|
|
109
83
|
branches: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -119,19 +93,11 @@ export declare const ServerEditableSettingsSchema: z.ZodObject<{
|
|
|
119
93
|
runStallSeconds: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
120
94
|
heartbeatSeconds: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
121
95
|
}, z.core.$strict>;
|
|
122
|
-
/** The settings API response:
|
|
96
|
+
/** The settings API response: editable configuration saved in the user state in S3, active values, concurrency revision and restart/precedence guidance. Authentication token settings are excluded. */
|
|
123
97
|
export declare const ServerSettingsSnapshotSchema: z.ZodObject<{
|
|
124
98
|
revision: z.ZodString;
|
|
125
99
|
settings: z.ZodObject<{
|
|
126
|
-
data: z.ZodOptional<z.ZodDefault<z.ZodString>>;
|
|
127
|
-
backends: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
128
|
-
id: z.ZodString;
|
|
129
|
-
label: z.ZodOptional<z.ZodString>;
|
|
130
|
-
url: z.ZodString;
|
|
131
|
-
write: z.ZodOptional<z.ZodBoolean>;
|
|
132
|
-
}, z.core.$strict>>>>;
|
|
133
100
|
log: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
|
|
134
|
-
write: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
135
101
|
outlineBranch: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
136
102
|
include: z.ZodOptional<z.ZodDefault<z.ZodObject<{
|
|
137
103
|
branches: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -148,15 +114,7 @@ export declare const ServerSettingsSnapshotSchema: z.ZodObject<{
|
|
|
148
114
|
heartbeatSeconds: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
149
115
|
}, z.core.$strict>;
|
|
150
116
|
active: z.ZodObject<{
|
|
151
|
-
data: z.ZodOptional<z.ZodDefault<z.ZodString>>;
|
|
152
|
-
backends: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
153
|
-
id: z.ZodString;
|
|
154
|
-
label: z.ZodOptional<z.ZodString>;
|
|
155
|
-
url: z.ZodString;
|
|
156
|
-
write: z.ZodOptional<z.ZodBoolean>;
|
|
157
|
-
}, z.core.$strict>>>>;
|
|
158
117
|
log: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
|
|
159
|
-
write: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
160
118
|
outlineBranch: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
161
119
|
include: z.ZodOptional<z.ZodDefault<z.ZodObject<{
|
|
162
120
|
branches: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* @module Server configuration schema
|
|
3
3
|
*
|
|
4
4
|
* The schema of `atlas.server.json`, the file that configures `atlas serve` and `atlas dev`: where to listen, which
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* a
|
|
5
|
+
* bearer tokens may read, publish or administer, and limits. Where reports are kept is not here: the server stores
|
|
6
|
+
* everything in S3-compatible storage named by required `ATLAS_S3_*` environment variables, never in this file.
|
|
7
|
+
* Every field has a default. Unlike the report format, unknown keys are an error, so a typo (or a removed `data`,
|
|
8
|
+
* `backends` or `write` key) is reported instead of ignored. Flags and environment variables override the file.
|
|
8
9
|
*
|
|
9
10
|
* @see docs/deploying.md
|
|
10
11
|
* @see docs/server.md
|
|
@@ -12,16 +13,6 @@
|
|
|
12
13
|
import { z } from 'zod/v4';
|
|
13
14
|
/** What a bearer token may do: `read` (look), `publish` (also upload) or `admin` (also remove scratch objects). */
|
|
14
15
|
export const ServerRoleSchema = z.enum(['read', 'publish', 'admin']);
|
|
15
|
-
/**
|
|
16
|
-
* A further store the server reads together with its data folder, and optionally writes to: an id, a label and a
|
|
17
|
-
* location URL.
|
|
18
|
-
*/
|
|
19
|
-
export const ServerBackendSchema = z.strictObject({
|
|
20
|
-
id: z.string().regex(/^[a-z0-9-]+$/).describe('Lowercase letters, digits and hyphens. Names where a result came from'),
|
|
21
|
-
label: z.string().optional(),
|
|
22
|
-
url: z.string().min(1).describe('A folder, s3://bucket/prefix, r2://, gs://, or another Atlas server (https://…)'),
|
|
23
|
-
write: z.boolean().optional().describe('Uploads go here instead of the data folder. At most one backend is the write target')
|
|
24
|
-
});
|
|
25
16
|
/**
|
|
26
17
|
* A bearer token: a name for logs, a role, and the token itself or the name of the environment variable that holds it
|
|
27
18
|
* (exactly one of the two).
|
|
@@ -35,16 +26,12 @@ export const ServerTokenSchema = z
|
|
|
35
26
|
})
|
|
36
27
|
.refine((value) => (value.token === undefined) !== (value.tokenEnv === undefined), { message: 'Give exactly one of token or tokenEnv' });
|
|
37
28
|
/**
|
|
38
|
-
* The whole `atlas.server.json`: listening address,
|
|
39
|
-
* viewer to serve, which layers take part in the report, refresh and heartbeat intervals, request limits and logging.
|
|
29
|
+
* The whole `atlas.server.json`: listening address, authentication, the viewer to serve, which layers take part in the report, refresh and heartbeat intervals, request limits and logging.
|
|
40
30
|
*/
|
|
41
31
|
export const ServerConfigSchema = z.strictObject({
|
|
42
32
|
$schema: z.string().optional(),
|
|
43
33
|
host: z.string().default('127.0.0.1').describe('Where to listen. 127.0.0.1 keeps it on this machine; 0.0.0.0 is every interface and needs tokens (or `auth.insecure`)'),
|
|
44
34
|
port: z.number().int().min(0).max(65535).default(8080),
|
|
45
|
-
data: z.string().default('./atlas-data').describe('The folder that is the default backend and the default write target: layers, specs and runs live here'),
|
|
46
|
-
backends: z.array(ServerBackendSchema).default([]).describe('Further backends, read together with the data folder: S3, another server, a device lab'),
|
|
47
|
-
write: z.string().optional().describe('The id of the backend uploads go to. Default: the data folder'),
|
|
48
35
|
auth: z
|
|
49
36
|
.strictObject({
|
|
50
37
|
tokens: z.array(ServerTokenSchema).default([]),
|
|
@@ -73,9 +60,9 @@ export const ServerConfigSchema = z.strictObject({
|
|
|
73
60
|
heartbeatSeconds: z.number().min(1).default(15).describe('How often a live connection is touched, so proxies keep it open'),
|
|
74
61
|
log: z.boolean().default(true).describe('One JSON line per request')
|
|
75
62
|
});
|
|
76
|
-
/**
|
|
77
|
-
export const ServerEditableSettingsSchema = ServerConfigSchema.pick({
|
|
78
|
-
/** The settings API response:
|
|
63
|
+
/** Report and live-run settings editable by an admin. Storage, authentication, listeners and the served site stay deployment-owned. Replaces the saved portion of the user state; omitted fields use defaults after restart. */
|
|
64
|
+
export const ServerEditableSettingsSchema = ServerConfigSchema.pick({ outlineBranch: true, include: true, refreshSeconds: true, limits: true, heartbeatSeconds: true, runTimeoutSeconds: true, runStallSeconds: true, log: true }).partial();
|
|
65
|
+
/** The settings API response: editable configuration saved in the user state in S3, active values, concurrency revision and restart/precedence guidance. Authentication token settings are excluded. */
|
|
79
66
|
export const ServerSettingsSnapshotSchema = z.looseObject({
|
|
80
67
|
revision: z.string().regex(/^[a-f0-9]{64}$/),
|
|
81
68
|
settings: ServerEditableSettingsSchema,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module User state schema
|
|
3
|
+
*
|
|
4
|
+
* The one JSON document that holds everything a person using Atlas has chosen, as opposed to what an operator deployed:
|
|
5
|
+
* the garbage collection policy and the saved server settings. The server keeps it in S3-compatible object storage
|
|
6
|
+
* (`server/state.json` under the configured prefix) and nowhere else: not in a file, a database or the browser. A
|
|
7
|
+
* document that does not exist yet means nothing has been chosen; a document that cannot be read is an error, never
|
|
8
|
+
* an empty state. Unknown keys are kept, so an older server does not drop what a newer one wrote.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/server.md#user-state-and-garbage-collection
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod/v4';
|
|
13
|
+
/**
|
|
14
|
+
* The garbage collection policy: which report layers the server may delete from S3, and when. Only layers that the
|
|
15
|
+
* report has loaded and that no longer decide any result (every check they carry has since been superseded by a newer
|
|
16
|
+
* layer) are ever candidates, and the newest `keepLatest` layers are always kept. With `enabled: false` nothing is deleted.
|
|
17
|
+
*/
|
|
18
|
+
export declare const GcPolicySchema: z.ZodObject<{
|
|
19
|
+
enabled: z.ZodBoolean;
|
|
20
|
+
maxAgeDays: z.ZodNumber;
|
|
21
|
+
keepLatest: z.ZodDefault<z.ZodNumber>;
|
|
22
|
+
intervalHours: z.ZodDefault<z.ZodNumber>;
|
|
23
|
+
}, z.core.$strict>;
|
|
24
|
+
/** A garbage collection policy as stored, defaults applied. */
|
|
25
|
+
export type GcPolicy = z.output<typeof GcPolicySchema>;
|
|
26
|
+
/** A garbage collection policy as written, before defaults are applied. */
|
|
27
|
+
export type GcPolicyInput = z.input<typeof GcPolicySchema>;
|
|
28
|
+
/** What one pass of garbage collection found and did, for one project. */
|
|
29
|
+
export declare const GcRunSchema: z.ZodObject<{
|
|
30
|
+
project: z.ZodString;
|
|
31
|
+
dryRun: z.ZodBoolean;
|
|
32
|
+
at: z.ZodString;
|
|
33
|
+
candidates: z.ZodNumber;
|
|
34
|
+
deletedLayers: z.ZodNumber;
|
|
35
|
+
deletedBlobs: z.ZodNumber;
|
|
36
|
+
keptLayers: z.ZodNumber;
|
|
37
|
+
error: z.ZodOptional<z.ZodString>;
|
|
38
|
+
}, z.core.$loose>;
|
|
39
|
+
/** One pass of garbage collection, see {@link GcRunSchema}. */
|
|
40
|
+
export type GcRun = z.output<typeof GcRunSchema>;
|
|
41
|
+
/** The user state document stored in S3. Both parts are optional: absent means "never chosen". */
|
|
42
|
+
export declare const UserStateSchema: z.ZodObject<{
|
|
43
|
+
schemaVersion: z.ZodLiteral<1>;
|
|
44
|
+
gc: z.ZodOptional<z.ZodObject<{
|
|
45
|
+
enabled: z.ZodBoolean;
|
|
46
|
+
maxAgeDays: z.ZodNumber;
|
|
47
|
+
keepLatest: z.ZodDefault<z.ZodNumber>;
|
|
48
|
+
intervalHours: z.ZodDefault<z.ZodNumber>;
|
|
49
|
+
}, z.core.$strict>>;
|
|
50
|
+
settings: z.ZodOptional<z.ZodObject<{
|
|
51
|
+
log: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
|
|
52
|
+
outlineBranch: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
53
|
+
include: z.ZodOptional<z.ZodDefault<z.ZodObject<{
|
|
54
|
+
branches: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
55
|
+
ignoreModified: z.ZodOptional<z.ZodBoolean>;
|
|
56
|
+
}, z.core.$strict>>>;
|
|
57
|
+
refreshSeconds: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
58
|
+
limits: z.ZodOptional<z.ZodDefault<z.ZodObject<{
|
|
59
|
+
maxObjectMiB: z.ZodDefault<z.ZodNumber>;
|
|
60
|
+
maxJsonMiB: z.ZodDefault<z.ZodNumber>;
|
|
61
|
+
maxStreams: z.ZodDefault<z.ZodNumber>;
|
|
62
|
+
}, z.core.$strict>>>;
|
|
63
|
+
runTimeoutSeconds: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
64
|
+
runStallSeconds: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
65
|
+
heartbeatSeconds: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
66
|
+
}, z.core.$strict>>;
|
|
67
|
+
}, z.core.$loose>;
|
|
68
|
+
/** The user state document, see {@link UserStateSchema}. */
|
|
69
|
+
export type UserState = z.output<typeof UserStateSchema>;
|
|
70
|
+
/**
|
|
71
|
+
* `GET /api/settings/gc` and `PUT /api/settings/gc`: the policy as saved in S3 (`null` when none has been saved), the
|
|
72
|
+
* revision to send back in `If-Match`, and the result of the last pass this server ran.
|
|
73
|
+
*/
|
|
74
|
+
export declare const GcSnapshotSchema: z.ZodObject<{
|
|
75
|
+
revision: z.ZodString;
|
|
76
|
+
policy: z.ZodNullable<z.ZodObject<{
|
|
77
|
+
enabled: z.ZodBoolean;
|
|
78
|
+
maxAgeDays: z.ZodNumber;
|
|
79
|
+
keepLatest: z.ZodDefault<z.ZodNumber>;
|
|
80
|
+
intervalHours: z.ZodDefault<z.ZodNumber>;
|
|
81
|
+
}, z.core.$strict>>;
|
|
82
|
+
saved: z.ZodBoolean;
|
|
83
|
+
lastRuns: z.ZodArray<z.ZodObject<{
|
|
84
|
+
project: z.ZodString;
|
|
85
|
+
dryRun: z.ZodBoolean;
|
|
86
|
+
at: z.ZodString;
|
|
87
|
+
candidates: z.ZodNumber;
|
|
88
|
+
deletedLayers: z.ZodNumber;
|
|
89
|
+
deletedBlobs: z.ZodNumber;
|
|
90
|
+
keptLayers: z.ZodNumber;
|
|
91
|
+
error: z.ZodOptional<z.ZodString>;
|
|
92
|
+
}, z.core.$loose>>;
|
|
93
|
+
stateKey: z.ZodString;
|
|
94
|
+
}, z.core.$loose>;
|
|
95
|
+
/** The garbage collection settings response, see {@link GcSnapshotSchema}. */
|
|
96
|
+
export type GcSnapshot = z.output<typeof GcSnapshotSchema>;
|
|
97
|
+
/** `POST /api/settings/gc/run`: the passes that ran, one per project. */
|
|
98
|
+
export declare const GcRunResultSchema: z.ZodObject<{
|
|
99
|
+
runs: z.ZodArray<z.ZodObject<{
|
|
100
|
+
project: z.ZodString;
|
|
101
|
+
dryRun: z.ZodBoolean;
|
|
102
|
+
at: z.ZodString;
|
|
103
|
+
candidates: z.ZodNumber;
|
|
104
|
+
deletedLayers: z.ZodNumber;
|
|
105
|
+
deletedBlobs: z.ZodNumber;
|
|
106
|
+
keptLayers: z.ZodNumber;
|
|
107
|
+
error: z.ZodOptional<z.ZodString>;
|
|
108
|
+
}, z.core.$loose>>;
|
|
109
|
+
policy: z.ZodNullable<z.ZodObject<{
|
|
110
|
+
enabled: z.ZodBoolean;
|
|
111
|
+
maxAgeDays: z.ZodNumber;
|
|
112
|
+
keepLatest: z.ZodDefault<z.ZodNumber>;
|
|
113
|
+
intervalHours: z.ZodDefault<z.ZodNumber>;
|
|
114
|
+
}, z.core.$strict>>;
|
|
115
|
+
}, z.core.$loose>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module User state schema
|
|
3
|
+
*
|
|
4
|
+
* The one JSON document that holds everything a person using Atlas has chosen, as opposed to what an operator deployed:
|
|
5
|
+
* the garbage collection policy and the saved server settings. The server keeps it in S3-compatible object storage
|
|
6
|
+
* (`server/state.json` under the configured prefix) and nowhere else: not in a file, a database or the browser. A
|
|
7
|
+
* document that does not exist yet means nothing has been chosen; a document that cannot be read is an error, never
|
|
8
|
+
* an empty state. Unknown keys are kept, so an older server does not drop what a newer one wrote.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/server.md#user-state-and-garbage-collection
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod/v4';
|
|
13
|
+
import { ServerEditableSettingsSchema } from './server-config.js';
|
|
14
|
+
/**
|
|
15
|
+
* The garbage collection policy: which report layers the server may delete from S3, and when. Only layers that the
|
|
16
|
+
* report has loaded and that no longer decide any result (every check they carry has since been superseded by a newer
|
|
17
|
+
* layer) are ever candidates, and the newest `keepLatest` layers are always kept. With `enabled: false` nothing is deleted.
|
|
18
|
+
*/
|
|
19
|
+
export const GcPolicySchema = z.strictObject({
|
|
20
|
+
enabled: z.boolean().describe('Delete eligible layers on a schedule. `false` keeps the policy but deletes nothing'),
|
|
21
|
+
maxAgeDays: z.number().int().min(1).max(3650).describe('A superseded layer is eligible once it was recorded more than this many days ago'),
|
|
22
|
+
keepLatest: z.number().int().min(0).max(1_000_000).default(20).describe('Always keep this many of the newest layers, however old, so recent history stays'),
|
|
23
|
+
intervalHours: z.number().min(1).max(720).default(24).describe('How often the server applies the policy')
|
|
24
|
+
});
|
|
25
|
+
/** What one pass of garbage collection found and did, for one project. */
|
|
26
|
+
export const GcRunSchema = z.looseObject({
|
|
27
|
+
project: z.string(),
|
|
28
|
+
dryRun: z.boolean(),
|
|
29
|
+
at: z.string().describe('When the pass finished, ISO 8601'),
|
|
30
|
+
candidates: z.number().int().min(0).describe('Layers the policy would delete'),
|
|
31
|
+
deletedLayers: z.number().int().min(0),
|
|
32
|
+
deletedBlobs: z.number().int().min(0),
|
|
33
|
+
keptLayers: z.number().int().min(0),
|
|
34
|
+
error: z.string().optional().describe('Why the pass stopped early, when it did')
|
|
35
|
+
});
|
|
36
|
+
/** The user state document stored in S3. Both parts are optional: absent means "never chosen". */
|
|
37
|
+
export const UserStateSchema = z.looseObject({
|
|
38
|
+
schemaVersion: z.literal(1),
|
|
39
|
+
gc: GcPolicySchema.optional().describe('The garbage collection policy. Absent until someone saves one: then nothing is deleted'),
|
|
40
|
+
settings: ServerEditableSettingsSchema.optional().describe('Report and live-run settings saved from the settings page, applied at the next start')
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* `GET /api/settings/gc` and `PUT /api/settings/gc`: the policy as saved in S3 (`null` when none has been saved), the
|
|
44
|
+
* revision to send back in `If-Match`, and the result of the last pass this server ran.
|
|
45
|
+
*/
|
|
46
|
+
export const GcSnapshotSchema = z.looseObject({
|
|
47
|
+
revision: z.string().regex(/^[a-f0-9]{64}$/),
|
|
48
|
+
policy: GcPolicySchema.nullable(),
|
|
49
|
+
saved: z.boolean().describe('Whether a policy exists in S3. `false` means garbage collection is off: nothing is deleted'),
|
|
50
|
+
lastRuns: z.array(GcRunSchema).describe('The most recent pass over each project, newest first; empty before the first pass'),
|
|
51
|
+
stateKey: z.string().describe('Where the user state lives in the bucket, relative to the configured prefix')
|
|
52
|
+
});
|
|
53
|
+
/** `POST /api/settings/gc/run`: the passes that ran, one per project. */
|
|
54
|
+
export const GcRunResultSchema = z.looseObject({ runs: z.array(GcRunSchema), policy: GcPolicySchema.nullable() });
|
package/dist/server/src/app.d.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { type BackendInput } from '../../reports/src/compose.js';
|
|
|
13
13
|
import type { LayerHeader } from '../../reports/src/evidence-types.js';
|
|
14
14
|
import type { ObjectStore } from '../../reports/src/object-store.js';
|
|
15
15
|
import { ReportIndex } from '../../reports/src/report-index.js';
|
|
16
|
-
import { type ServerSettings } from './settings.js';
|
|
16
|
+
import { type GcSettings, type ServerSettings } from './settings.js';
|
|
17
|
+
import type { GcPolicy, GcRun } from '../../schema/src/user-state.js';
|
|
17
18
|
import type { ResolvedToken } from './config.js';
|
|
18
19
|
import { EventHub } from './events.js';
|
|
19
20
|
/** What the server is built from: its `backends` (read together) and which one takes uploads (`write`), `tokens`, the built `site`, the report's filters (`include`, `outlineBranch`), `limits`, timings, `log`, and `now` for tests. */
|
|
@@ -44,8 +45,12 @@ export type AtlasServerOptions = {
|
|
|
44
45
|
readonly log?: (line: Record<string, unknown>) => void;
|
|
45
46
|
readonly now?: () => number;
|
|
46
47
|
readonly version?: string;
|
|
47
|
-
/** Admin configuration persistence, supplied by atlas serve. Changes apply on restart. */
|
|
48
|
+
/** Admin configuration persistence, supplied by atlas serve; kept in S3. Changes apply on restart. */
|
|
48
49
|
readonly settings?: ServerSettings;
|
|
50
|
+
/** The garbage collection policy, kept in S3 and read from there each time the server collects. Supplied by atlas serve. */
|
|
51
|
+
readonly gc?: GcSettings;
|
|
52
|
+
/** How often (ms) the server checks whether a garbage collection pass is due. Default 60 000; for tests. */
|
|
53
|
+
readonly gcCheckMs?: number;
|
|
49
54
|
/** Extra runtime configuration written into the page. */
|
|
50
55
|
readonly page?: Record<string, unknown>;
|
|
51
56
|
};
|
|
@@ -61,6 +66,15 @@ export type AtlasServer = {
|
|
|
61
66
|
}[];
|
|
62
67
|
/** Resolves when the first pass over the backends is done. */
|
|
63
68
|
readonly ready: Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Apply a garbage collection policy to this project's upload backend. Deletes only layers the report has read and
|
|
71
|
+
* that no longer decide any result, older than the policy allows and beyond its `keepLatest`. With `dryRun` it only
|
|
72
|
+
* counts. Never throws: a failure is the `error` of the returned run.
|
|
73
|
+
*/
|
|
74
|
+
collectGarbage(policy: GcPolicy, options: {
|
|
75
|
+
readonly dryRun: boolean;
|
|
76
|
+
readonly project: string;
|
|
77
|
+
}): Promise<GcRun>;
|
|
64
78
|
/** Stop refreshing and end every live connection. */
|
|
65
79
|
close(): void;
|
|
66
80
|
};
|
package/dist/server/src/app.js
CHANGED
|
@@ -35,6 +35,7 @@ import { LiveRunStartSchema, LiveRunUpdateSchema } from '../../schema/src/live-r
|
|
|
35
35
|
import { LiveRuns, LiveRunError } from './live-runs.js';
|
|
36
36
|
import { ConfigError } from './config.js';
|
|
37
37
|
import { renderSettingsPage } from './settings.js';
|
|
38
|
+
import { collectGarbage, planGarbage } from '../../reports/src/garbage.js';
|
|
38
39
|
import { Refusal } from './validate.js';
|
|
39
40
|
import { createAuth } from './auth.js';
|
|
40
41
|
import { EventHub } from './events.js';
|
|
@@ -340,6 +341,50 @@ function createProjectApplication(options) {
|
|
|
340
341
|
throw error;
|
|
341
342
|
}
|
|
342
343
|
});
|
|
344
|
+
// The garbage collection policy: its own document in the user state, saved in S3 with the same revision rules.
|
|
345
|
+
app.get('/api/settings/gc', async (c) => {
|
|
346
|
+
const verdict = auth.check(c.req.raw, 'admin');
|
|
347
|
+
if (!verdict.ok)
|
|
348
|
+
return unauthorized(verdict);
|
|
349
|
+
if (!options.gc)
|
|
350
|
+
return problemResponse(503, 'Garbage collection settings are managed by the embedding application.');
|
|
351
|
+
try {
|
|
352
|
+
return c.json(await options.gc.read(), 200, { 'cache-control': 'no-store' });
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
if (error instanceof ConfigError)
|
|
356
|
+
return problemResponse(503, error.message);
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
app.put('/api/settings/gc', async (c) => {
|
|
361
|
+
const verdict = auth.check(c.req.raw, 'admin');
|
|
362
|
+
if (!verdict.ok)
|
|
363
|
+
return unauthorized(verdict);
|
|
364
|
+
if (!options.gc)
|
|
365
|
+
return problemResponse(503, 'Garbage collection settings are managed by the embedding application.');
|
|
366
|
+
const revision = /^"([a-f0-9]{64})"$/.exec(c.req.header('if-match') ?? '')?.[1];
|
|
367
|
+
if (!revision)
|
|
368
|
+
return problemResponse(428, 'Load the policy first and send its revision in If-Match.');
|
|
369
|
+
try {
|
|
370
|
+
const bytes = await readLimited(c.req.raw, Math.min(limits.json, 16 * 1024));
|
|
371
|
+
let input;
|
|
372
|
+
try {
|
|
373
|
+
input = JSON.parse(new TextDecoder().decode(bytes));
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return problemResponse(422, 'The body is not JSON.');
|
|
377
|
+
}
|
|
378
|
+
return c.json(await options.gc.save(input, revision), 200, { 'cache-control': 'no-store' });
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (error instanceof ConfigError)
|
|
382
|
+
return problemResponse(error.message.startsWith('Settings changed') ? 409 : 422, error.message);
|
|
383
|
+
if (error instanceof Refusal)
|
|
384
|
+
return problemResponse(error.status, error.message);
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
});
|
|
343
388
|
// Live updates: told when the report changes, and when specs or runs do.
|
|
344
389
|
app.get('/api/events', c => events.stream(c.req.raw, () => ({ version: index.snapshot().version, ready: index.ready, time: now(), heartbeatMs: options.heartbeatMs ?? 15_000 })));
|
|
345
390
|
// The report as it stands: one request, only what wins. Everything else about a layer is history.
|
|
@@ -665,11 +710,28 @@ function createProjectApplication(options) {
|
|
|
665
710
|
options.log?.({ at: new Date(now()).toISOString(), level: 'error', path: new URL(c.req.url).pathname, error: error instanceof Error ? error.message : String(error) });
|
|
666
711
|
return c.json({ error: 'Something went wrong on the server. It has been logged.' }, 500);
|
|
667
712
|
});
|
|
713
|
+
async function collect(policy, run) {
|
|
714
|
+
const base = { project: run.project, dryRun: run.dryRun };
|
|
715
|
+
try {
|
|
716
|
+
// Look at the store once more first, so "superseded" is judged on everything uploaded so far.
|
|
717
|
+
await index.refreshNow();
|
|
718
|
+
const plan = await planGarbage(writeBackend.store, { policy, nowMs: now(), eligible: index.superseded() });
|
|
719
|
+
const outcome = run.dryRun ? { deletedLayers: 0, deletedBlobs: 0 } : await collectGarbage(writeBackend.store, plan);
|
|
720
|
+
if (outcome.deletedLayers)
|
|
721
|
+
options.log?.({ at: new Date(now()).toISOString(), level: 'info', gc: run.project, deletedLayers: outcome.deletedLayers, deletedBlobs: outcome.deletedBlobs });
|
|
722
|
+
return { ...base, at: new Date(now()).toISOString(), candidates: plan.candidates.length, ...outcome, keptLayers: plan.kept };
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
options.log?.({ at: new Date(now()).toISOString(), level: 'error', gc: run.project, error: String(error) });
|
|
726
|
+
return { ...base, at: new Date(now()).toISOString(), candidates: 0, deletedLayers: 0, deletedBlobs: 0, keptLayers: 0, error: error instanceof Error ? error.message : String(error) };
|
|
727
|
+
}
|
|
728
|
+
}
|
|
668
729
|
return {
|
|
669
730
|
fetch: async (request) => app.fetch(request),
|
|
670
731
|
routes: app.routes.map(({ method, path }) => ({ method, path })),
|
|
671
732
|
index,
|
|
672
733
|
events,
|
|
734
|
+
collectGarbage: collect,
|
|
673
735
|
ready: index.start(options.refreshMs ?? 10_000),
|
|
674
736
|
close: () => { clearInterval(leaseTimer); stopListening(); index.stop(); events.close(); }
|
|
675
737
|
};
|