@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 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 up to 512 characters (no lone surrogate) the server never
42
- reads. Each folds into its row of the token's repository and ref:
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 (both up to 512
63
- characters), `outcome` one of:
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
- A server takes both at their limits, each hash 512 characters: an
94
- upload body up to 17 960 100 bytes, a query's up to 6 010 000. Past
95
- that, `413`.
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. On
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.652",
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.652"
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 URL clients reach this server at
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(`set AUDIENCE: the aud a CI token names, the URL clients reach this server at\n${USAGE}`)
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} strings`)
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
- // A lone surrogate is stored as bytes that read back as U+FFFD: the row
175
- // answers to a hash no client asked for.
176
- const isOpaque = (s: unknown): s is string =>
177
- typeof s === 'string' && s.length > 0 && s.length <= MAX_STRING && !/\p{Cs}/u.test(s)
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: a string of up to ${MAX_STRING} characters`)
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: a string of up to ${MAX_STRING} characters`)
249
- if (!isOpaque(key)) bad(`key: a string of up to ${MAX_STRING} characters`)
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)