@vzn/vx-history-server 0.0.652 → 0.0.653
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/PROTOCOL.md +13 -7
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/bun.ts +4 -2
- package/src/server.ts +29 -8
package/PROTOCOL.md
CHANGED
|
@@ -38,8 +38,9 @@ an upload with none is `403`.
|
|
|
38
38
|
`POST /v1/timings/upload` (a token that writes) with
|
|
39
39
|
`{ "timings": [{ "hash", "durationMs", "cores"?, "rssBytes"? }] }`, up to
|
|
40
40
|
10 000, each task once: `204`. `hash` names the task's definition, a
|
|
41
|
-
string of
|
|
42
|
-
|
|
41
|
+
string the server never reads of 1 to 512 ASCII characters, none a
|
|
42
|
+
space, control, `"` or `\`: each is one byte in JSON, which the body
|
|
43
|
+
limits count on. Each folds into its row of the token's repository and ref:
|
|
43
44
|
|
|
44
45
|
- `durationMs` and `cores`: `old × 0.7 + new × 0.3`. A median needs the
|
|
45
46
|
samples and a mean never forgets; this follows a task that got slower
|
|
@@ -59,8 +60,8 @@ the row's. A row not reported for `TIMING_DAYS` (30) is deleted.
|
|
|
59
60
|
|
|
60
61
|
An upload may also carry `"outcomes": [{ "hash", "key", "outcome" }]`,
|
|
61
62
|
up to 10 000, each task and key once: every cached task the run ran.
|
|
62
|
-
`hash` is the task's definition, `key` its cache key (
|
|
63
|
-
|
|
63
|
+
`hash` is the task's definition, `key` its cache key (each as `hash`
|
|
64
|
+
above), `outcome` one of:
|
|
64
65
|
|
|
65
66
|
- `pass`, `fail`: how it ended.
|
|
66
67
|
- `flaky`: it passed on a retry, so the run saw it flake.
|
|
@@ -90,9 +91,14 @@ flaky, by any ref:
|
|
|
90
91
|
## Limits and errors
|
|
91
92
|
|
|
92
93
|
`GET /v1/capabilities`: `{ "maxUploadTimings": 10000, "maxQueryHashes": 10000, "maxQueryRefs": 8 }`.
|
|
93
|
-
|
|
94
|
-
upload
|
|
95
|
-
|
|
94
|
+
Every `/v1/timings` answer, a refusal too, names them as
|
|
95
|
+
`vx-max-upload-timings`, `vx-max-query-hashes` and `vx-max-query-refs`,
|
|
96
|
+
so a client need not ask them apart: vx sends a run's query and upload
|
|
97
|
+
whole, and one refused for its size again in parts of the limit its
|
|
98
|
+
refusal names.
|
|
99
|
+
A server takes both at their limits, each hash and key 512 characters
|
|
100
|
+
and every ref 512: an upload body up to 17 960 100 bytes, a query's up
|
|
101
|
+
to 6 010 000. Past that, `413`.
|
|
96
102
|
|
|
97
103
|
Every refusal is `{ code, message }`, the message saying what to fix. A
|
|
98
104
|
JSON body without `Content-Type: application/json` is `400`, an unknown
|
package/README.md
CHANGED
|
@@ -30,7 +30,8 @@ sends one query: its repository, the refs it reads (its branch, then the
|
|
|
30
30
|
branch it merges into, then the default branch) and every task hash;
|
|
31
31
|
the server answers each from the first ref that holds it. A token of
|
|
32
32
|
another repository than the query names is refused. `AUDIENCE` is the
|
|
33
|
-
`aud` a token must name: the origin clients reach the server at
|
|
33
|
+
`aud` a token must name: the origin clients reach the server at (a URL
|
|
34
|
+
is read as its origin; one that is no URL stops the server). On
|
|
34
35
|
Workers, unset, it is that origin (Cloudflare routes only the Worker's
|
|
35
36
|
own hostnames to it); on Bun and Docker it is required, since there the
|
|
36
37
|
origin is the request's `Host` header, which a client picks.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-history-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.653",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "The task timings a vx scheduler asks for, one folded row per task, that you host: on Bun with SQLite, on Cloudflare Workers with D1, or as a Docker image.",
|
|
6
6
|
"keywords": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"miniflare: tests/worker.test.ts runs src/worker.ts in workerd with D1, as Workers deploy it."
|
|
46
46
|
],
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@vzn/vx-devtools": "0.0.
|
|
48
|
+
"@vzn/vx-devtools": "0.0.653"
|
|
49
49
|
},
|
|
50
50
|
"repository": {
|
|
51
51
|
"type": "git",
|
package/src/bun.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type Db, sqliteStore } from './store.js'
|
|
|
12
12
|
const USAGE = `usage: vx-history-server [--db vx-history.sqlite] [--port 8080] [--host 0.0.0.0]
|
|
13
13
|
ALLOW who may take part, space separated, each issuer:owner/repo:workflow
|
|
14
14
|
(README.md § CI tokens); only their jobs' OIDC tokens are taken
|
|
15
|
-
AUDIENCE the aud a CI token names: the
|
|
15
|
+
AUDIENCE the aud a CI token names: the origin clients reach this server at
|
|
16
16
|
READ_ACCESS public: a request with no token reads a repository ALLOW names
|
|
17
17
|
TIMING_DAYS days a task's timings outlive its last report (default 30)`
|
|
18
18
|
|
|
@@ -78,7 +78,9 @@ function main(argv: string[]): void {
|
|
|
78
78
|
// header, which the client picks. Workers know their own hostnames.
|
|
79
79
|
const audience = env.AUDIENCE?.trim() ?? ''
|
|
80
80
|
if (audience === '')
|
|
81
|
-
fail(
|
|
81
|
+
fail(
|
|
82
|
+
`set AUDIENCE: the aud a CI token names, the origin clients reach this server at\n${USAGE}`,
|
|
83
|
+
)
|
|
82
84
|
const handle = createHistoryServer({
|
|
83
85
|
store: sqliteStore(db),
|
|
84
86
|
authorize: oidcTokenAuth({
|
package/src/server.ts
CHANGED
|
@@ -99,7 +99,7 @@ export function createHistoryServer(opts: ServerOptions): (req: Request) => Prom
|
|
|
99
99
|
hashes.length > MAX_QUERY_HASHES ||
|
|
100
100
|
!hashes.every((h) => isOpaque(h))
|
|
101
101
|
) {
|
|
102
|
-
throw new Refusal(400, `hashes: a list of up to ${MAX_QUERY_HASHES}
|
|
102
|
+
throw new Refusal(400, `hashes: a list of up to ${MAX_QUERY_HASHES}, each ${OPAQUE_IS}`)
|
|
103
103
|
}
|
|
104
104
|
const repo = (await authorize(req, { issuer, repository }))!
|
|
105
105
|
await schema()
|
|
@@ -133,6 +133,15 @@ export function createHistoryServer(opts: ServerOptions): (req: Request) => Prom
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
return async (req) => {
|
|
136
|
+
const res = await answer(req)
|
|
137
|
+
// Every /v1/timings answer, a refusal too: a client learns the limits
|
|
138
|
+
// from its first request, and was asked none apart (2026-11-06).
|
|
139
|
+
if (new URL(req.url).pathname.startsWith('/v1/timings/'))
|
|
140
|
+
for (const [header, value] of limitHeaders) res.headers.set(header, value)
|
|
141
|
+
return res
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function answer(req: Request): Promise<Response> {
|
|
136
145
|
const path = new URL(req.url).pathname
|
|
137
146
|
try {
|
|
138
147
|
const search = new URL(req.url).search
|
|
@@ -168,13 +177,25 @@ export function createHistoryServer(opts: ServerOptions): (req: Request) => Prom
|
|
|
168
177
|
}
|
|
169
178
|
}
|
|
170
179
|
|
|
180
|
+
/** The limits as every `/v1/timings` answer names them (PROTOCOL.md § Limits and errors). */
|
|
181
|
+
const limitHeaders = [
|
|
182
|
+
['vx-max-upload-timings', String(MAX_UPLOAD_TIMINGS)],
|
|
183
|
+
['vx-max-query-hashes', String(MAX_QUERY_HASHES)],
|
|
184
|
+
['vx-max-query-refs', String(MAX_QUERY_REFS)],
|
|
185
|
+
] as const
|
|
186
|
+
|
|
171
187
|
const field = (body: unknown, key: string): unknown =>
|
|
172
188
|
typeof body === 'object' && body !== null ? (body as Record<string, unknown>)[key] : undefined
|
|
173
189
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Each character one byte in JSON, so the body limits hold `MAX_STRING`
|
|
192
|
+
* of them: 10 000 hashes of 512 `é` (two bytes each) were a 413 at the
|
|
193
|
+
* limits the protocol promised, and a control is six (`\u0001`).
|
|
194
|
+
*/
|
|
195
|
+
const OPAQUE = new RegExp(`^[\\x21\\x23-\\x5b\\x5d-\\x7e]{1,${MAX_STRING}}$`)
|
|
196
|
+
const OPAQUE_IS = `1 to ${MAX_STRING} ASCII characters, none a space, control, " or \\`
|
|
197
|
+
|
|
198
|
+
const isOpaque = (s: unknown): s is string => typeof s === 'string' && OPAQUE.test(s)
|
|
178
199
|
|
|
179
200
|
const isCount = (n: unknown): n is number =>
|
|
180
201
|
typeof n === 'number' && Number.isSafeInteger(n) && n >= 0
|
|
@@ -209,7 +230,7 @@ function parseReports(body: unknown): Report[] {
|
|
|
209
230
|
const durationMs = field(x, 'durationMs')
|
|
210
231
|
const cores = field(x, 'cores')
|
|
211
232
|
const rssBytes = field(x, 'rssBytes')
|
|
212
|
-
if (!isOpaque(hash)) bad(`hash:
|
|
233
|
+
if (!isOpaque(hash)) bad(`hash: ${OPAQUE_IS}`)
|
|
213
234
|
// One row per task: a second report of it in one upload would fold twice.
|
|
214
235
|
if (seen.has(hash as string)) bad('hash: each task once')
|
|
215
236
|
seen.add(hash as string)
|
|
@@ -245,8 +266,8 @@ function parseOutcomes(body: unknown): Outcome[] {
|
|
|
245
266
|
const hash = field(x, 'hash')
|
|
246
267
|
const key = field(x, 'key')
|
|
247
268
|
const outcome = field(x, 'outcome')
|
|
248
|
-
if (!isOpaque(hash)) bad(`hash:
|
|
249
|
-
if (!isOpaque(key)) bad(`key:
|
|
269
|
+
if (!isOpaque(hash)) bad(`hash: ${OPAQUE_IS}`)
|
|
270
|
+
if (!isOpaque(key)) bad(`key: ${OPAQUE_IS}`)
|
|
250
271
|
const id = `${hash as string}\0${key as string}`
|
|
251
272
|
if (seen.has(id)) bad('key: each task and key once')
|
|
252
273
|
seen.add(id)
|