@fleetless/contracts 1.0.0 → 1.0.3
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 +97 -2
- package/CODE_OF_CONDUCT.md +83 -0
- package/CONTRIBUTING.md +136 -0
- package/README.md +49 -13
- package/SECURITY.md +55 -0
- package/artifacts/openapi.json +11 -11
- package/artifacts/routes.json +12 -12
- package/artifacts/schema/create-app-oidc-provider-request.schema.json +1 -1
- package/dist/alerts.d.ts +23 -28
- package/dist/alerts.js +23 -29
- package/dist/app-users.d.ts +18 -19
- package/dist/app-users.js +18 -20
- package/dist/apps.d.ts +21 -25
- package/dist/apps.js +42 -52
- package/dist/assets.d.ts +70 -132
- package/dist/assets.js +130 -223
- package/dist/audit.d.ts +14 -15
- package/dist/audit.js +28 -55
- package/dist/client-auth.d.ts +9 -9
- package/dist/client-auth.js +8 -9
- package/dist/common.d.ts +29 -37
- package/dist/common.js +28 -37
- package/dist/config-issues.d.ts +23 -25
- package/dist/config-issues.js +17 -17
- package/dist/config.d.ts +37 -44
- package/dist/config.js +145 -187
- package/dist/errors.d.ts +4 -3
- package/dist/errors.js +83 -116
- package/dist/identity.d.ts +24 -27
- package/dist/identity.js +23 -27
- package/dist/index.d.ts +4 -4
- package/dist/index.js +14 -15
- package/dist/introspection.d.ts +7 -6
- package/dist/introspection.js +6 -6
- package/dist/jobs.d.ts +16 -16
- package/dist/jobs.js +24 -29
- package/dist/mcp.d.ts +14 -15
- package/dist/mcp.js +12 -14
- package/dist/oauth.d.ts +21 -27
- package/dist/oauth.js +33 -43
- package/dist/protocol.d.ts +51 -62
- package/dist/protocol.js +107 -139
- package/dist/realtime.d.ts +53 -68
- package/dist/realtime.js +78 -104
- package/dist/rest.d.ts +183 -244
- package/dist/rest.js +305 -399
- package/dist/routes.d.ts +4 -3
- package/dist/routes.js +33 -32
- package/package.json +12 -7
package/dist/rest.js
CHANGED
|
@@ -6,8 +6,8 @@ import { configState, rateThrottleHz, robotConfigDoc, snapshotIntervalSeconds, v
|
|
|
6
6
|
import { rosGraph, typeDefinition } from './introspection.js';
|
|
7
7
|
import { job } from './jobs.js';
|
|
8
8
|
/**
|
|
9
|
-
* REST shapes of the robot resource
|
|
10
|
-
*
|
|
9
|
+
* REST shapes of the robot resource: create, list, get, and the built-in
|
|
10
|
+
* `bridge_state` datapoint read.
|
|
11
11
|
*/
|
|
12
12
|
export const robot = z.object({
|
|
13
13
|
id: z.uuid().meta({
|
|
@@ -30,8 +30,8 @@ export const createRobotRequest = z.object({
|
|
|
30
30
|
name: z.string().min(1).max(63),
|
|
31
31
|
});
|
|
32
32
|
/**
|
|
33
|
-
* The robot token binds one bridge to one robot
|
|
34
|
-
*
|
|
33
|
+
* The robot token binds one bridge to one robot. It is returned exactly once,
|
|
34
|
+
* here; the cloud stores only a hash of it.
|
|
35
35
|
*/
|
|
36
36
|
export const robotToken = z.string().regex(/^frt_[0-9a-f]{32}$/);
|
|
37
37
|
export const createRobotResponse = z.object({
|
|
@@ -39,10 +39,10 @@ export const createRobotResponse = z.object({
|
|
|
39
39
|
token: robotToken,
|
|
40
40
|
});
|
|
41
41
|
/**
|
|
42
|
-
* How many things a robot exposes, per kind
|
|
42
|
+
* How many things a robot exposes, per kind.
|
|
43
43
|
*
|
|
44
|
-
* **Five numbers, never a sum.** `robotDeletionSummary.slug_count`
|
|
45
|
-
*
|
|
44
|
+
* **Five numbers, never a sum.** `robotDeletionSummary.slug_count` makes the
|
|
45
|
+
* same call for the same reason: fold cameras in and the sentence "this deletes
|
|
46
46
|
* N slugs and M cameras" counts them twice. A list row has the same problem.
|
|
47
47
|
*
|
|
48
48
|
* **Counted from the published configuration, and excluding the built-ins.**
|
|
@@ -79,7 +79,7 @@ export const robotListResponse = z.object({
|
|
|
79
79
|
});
|
|
80
80
|
/**
|
|
81
81
|
* The REST read of one datapoint. For bridge-captured data `timestamp_ms`
|
|
82
|
-
* is the capture time at the bridge
|
|
82
|
+
* is the capture time at the bridge; for the cloud-observed
|
|
83
83
|
* built-in `bridge_state` it is the time the cloud observed the state.
|
|
84
84
|
*/
|
|
85
85
|
export const datapointValue = z.object({
|
|
@@ -91,15 +91,14 @@ export const datapointValue = z.object({
|
|
|
91
91
|
description: 'When the value was captured, as a unix timestamp in milliseconds. This is the **bridge\'s capture time**, never the time the cloud received it — the one exception is the built-in `bridge_state`, which the cloud observes by construction.',
|
|
92
92
|
}),
|
|
93
93
|
});
|
|
94
|
-
/*
|
|
94
|
+
/* ------------------------------------------------------------------------
|
|
95
95
|
* Exposure: the configuration resource, introspection, types, and the
|
|
96
|
-
* datapoint surface generated from the published configuration
|
|
97
|
-
* §11.2).
|
|
96
|
+
* datapoint surface generated from the published configuration.
|
|
98
97
|
*/
|
|
99
98
|
/**
|
|
100
99
|
* One robot in full: what the list shows, plus what only the detail view
|
|
101
100
|
* needs — which bridge build is connected, why the last hello was refused,
|
|
102
|
-
* and where the configuration stands
|
|
101
|
+
* and where the configuration stands.
|
|
103
102
|
*/
|
|
104
103
|
export const robotDetailResponse = z.object({
|
|
105
104
|
...robotListItem.shape,
|
|
@@ -163,19 +162,16 @@ export const configDraftResponse = z.object({
|
|
|
163
162
|
* the server parses it, and there is exactly one account of what the
|
|
164
163
|
* configuration says.
|
|
165
164
|
*
|
|
166
|
-
* It also settles who owns parsing
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* The pair that used to be called a defect — a stored source that does not
|
|
177
|
-
* parse to its stored document — is now a **represented state**: no document at
|
|
178
|
-
* all. See `configDraftResponse` above.
|
|
165
|
+
* It also settles who owns parsing. The **server** refuses text that is not
|
|
166
|
+
* valid YAML, with the line and column, and stores everything else — including
|
|
167
|
+
* valid YAML that is not a fleetless document, which comes back with
|
|
168
|
+
* `doc: null` and its issues. An editor may check as you type so the answer is
|
|
169
|
+
* immediate; the server checks because it is the one that decides. Two checks
|
|
170
|
+
* of one question, and the server's is the one that binds.
|
|
171
|
+
*
|
|
172
|
+
* A stored source that does not parse to a document is therefore a
|
|
173
|
+
* **represented state**, not an error: no document at all. See
|
|
174
|
+
* `configDraftResponse` above.
|
|
179
175
|
*/
|
|
180
176
|
export const putConfigDraftRequest = z.object({ source: z.string().max(1_000_000) });
|
|
181
177
|
/** Publishing freezes the draft into the next immutable version. */
|
|
@@ -227,11 +223,11 @@ export const fetchTypesResponse = z.object({
|
|
|
227
223
|
/**
|
|
228
224
|
* What a client can read on this robot: the built-ins plus everything the
|
|
229
225
|
* published configuration exposes. This is the seed of the generated
|
|
230
|
-
* per-robot API
|
|
226
|
+
* per-robot API.
|
|
231
227
|
*
|
|
232
|
-
* **The OpenAPI rendering
|
|
233
|
-
*
|
|
234
|
-
* `scripts/export-schemas.ts
|
|
228
|
+
* **The OpenAPI rendering is `artifacts/openapi.json`**, derived from the
|
|
229
|
+
* route manifest in `routes.ts` and these schemas by
|
|
230
|
+
* `scripts/export-schemas.ts`.
|
|
235
231
|
*/
|
|
236
232
|
export const datapointDescriptor = z.object({
|
|
237
233
|
slug: slug.meta({ description: 'The name a client reads this datapoint by.' }),
|
|
@@ -258,8 +254,8 @@ export const datapointListResponse = z.object({
|
|
|
258
254
|
}),
|
|
259
255
|
});
|
|
260
256
|
/**
|
|
261
|
-
* The built-in `robot_details` datapoint
|
|
262
|
-
*
|
|
257
|
+
* The built-in `robot_details` datapoint: static properties the developer
|
|
258
|
+
* maintains. Bounded so one robot cannot become a document store.
|
|
263
259
|
*/
|
|
264
260
|
export const robotDetailsDoc = z.record(z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/), z.union([z.string().max(4096), z.number(), z.boolean(), z.array(z.unknown()), z.record(z.string(), z.unknown())]));
|
|
265
261
|
/** What `PUT /api/robots/:id/details` answers: the stored document, which is the one that was sent. */
|
|
@@ -269,8 +265,8 @@ export const putRobotDetailsResponse = z.object({
|
|
|
269
265
|
}),
|
|
270
266
|
});
|
|
271
267
|
export const putRobotDetailsRequest = z.object({ details: robotDetailsDoc });
|
|
272
|
-
/*
|
|
273
|
-
* The command surface
|
|
268
|
+
/* ------------------------------------------------------------------------
|
|
269
|
+
* The command surface, and what a role may be granted.
|
|
274
270
|
*
|
|
275
271
|
* The routes, written down because cloud, console and SDK each need them and
|
|
276
272
|
* a body schema does not imply a path:
|
|
@@ -284,7 +280,7 @@ export const putRobotDetailsRequest = z.object({ details: robotDetailsDoc });
|
|
|
284
280
|
* | `GET /api/robots/:id/exposures` | — | `exposureListResponse` |
|
|
285
281
|
*
|
|
286
282
|
* **Commands are addressed by slug, never by kind.** Slugs are one namespace
|
|
287
|
-
* across all kinds
|
|
283
|
+
* across all kinds and a role grant is `{robot, slug}` with no kind in
|
|
288
284
|
* it — so a path segment naming the kind would demand a fact the permission
|
|
289
285
|
* model deliberately does not carry. The cloud already knows from the
|
|
290
286
|
* published configuration whether a slug is an action or a service; a caller
|
|
@@ -297,7 +293,7 @@ export const putRobotDetailsRequest = z.object({ details: robotDetailsDoc });
|
|
|
297
293
|
* job and so is not under `/jobs`.
|
|
298
294
|
*/
|
|
299
295
|
/**
|
|
300
|
-
* Invoke an action or call a service; parameters by field path
|
|
296
|
+
* Invoke an action or call a service; parameters by field path.
|
|
301
297
|
*
|
|
302
298
|
* Flat, keyed by `parameterSpec.name` — see `cloudInvoke.params` for why the
|
|
303
299
|
* flat form is the one that makes a refusal legible.
|
|
@@ -307,7 +303,7 @@ export const invokeRequest = z.object({
|
|
|
307
303
|
description: 'The values this call needs, keyed by **parameter name** rather than by field path — so a name survives the field moving inside the message. Every parameter without a default must be present, and the bounds the configuration declares are enforced in the cloud, before anything reaches the robot.',
|
|
308
304
|
}),
|
|
309
305
|
/**
|
|
310
|
-
* How long **this call** is worth waiting for, in milliseconds
|
|
306
|
+
* How long **this call** is worth waiting for, in milliseconds.
|
|
311
307
|
*
|
|
312
308
|
* **Absent means `DEFAULT_PATIENCE_MS`** — today's behaviour, unchanged, for
|
|
313
309
|
* every caller who does not care. It is optional because most callers have
|
|
@@ -337,37 +333,28 @@ export const invokeRequest = z.object({
|
|
|
337
333
|
}),
|
|
338
334
|
});
|
|
339
335
|
/**
|
|
340
|
-
* The answer to an invoke. The job id is informative
|
|
341
|
-
*
|
|
336
|
+
* The answer to an invoke. The job id is informative: state is observed by
|
|
337
|
+
* slug afterwards, over polling or a subscription.
|
|
342
338
|
*/
|
|
343
339
|
/**
|
|
344
|
-
* The body of a cancel
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
* That is not politeness, it is the W5 defect: a bodyless `POST` carrying
|
|
349
|
-
* `content-type: application/json` was rejected outright, which made
|
|
350
|
-
* `cameras.live()` unreachable through the SDK and took `cancel`, publish,
|
|
351
|
-
* restore, key rotation and member removal with it — unnoticed since W4. A
|
|
352
|
-
* schema that demands a body would reintroduce it on the one verb that stops
|
|
353
|
-
* a machine.
|
|
340
|
+
* The body of a cancel. **Every field optional, and the body itself may be
|
|
341
|
+
* absent** — `POST .../cancel` takes no body at all in its simplest form, and
|
|
342
|
+
* a schema that demanded one would break every caller on the one verb that
|
|
343
|
+
* stops a machine.
|
|
354
344
|
*
|
|
355
345
|
* **`.strict()`, and that is the whole point of the shape.** A plain object
|
|
356
346
|
* strips unknown keys, so a caller who *means* to name a job and misspells the
|
|
357
347
|
* field — `jobId` for `job_id` — has their id silently removed and gets the
|
|
358
348
|
* **slug-wide** cancel instead: the most destructive reading of a request they
|
|
359
|
-
* did not make.
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* fails *safe* on a typo — a misspelled `force` simply does not force.
|
|
363
|
-
* Stripping here fails unsafe, so unknown keys are refused instead.
|
|
349
|
+
* did not make. A `?force=true` flag fails *safe* on a typo, because a
|
|
350
|
+
* misspelled `force` simply does not force. Stripping here fails unsafe, so
|
|
351
|
+
* unknown keys are refused instead.
|
|
364
352
|
*
|
|
365
353
|
* `job_id` absent and `job_id: null` mean the **same** thing here, and that is
|
|
366
|
-
* deliberate: over REST an absent body is how
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
* omission is a bug.
|
|
354
|
+
* deliberate: over REST an absent body is how a caller says "cancel whatever is
|
|
355
|
+
* running". On the socket, `clientCancel.job_id` is required-and-nullable
|
|
356
|
+
* instead, because a frame is assembled fresh by a client that knows this
|
|
357
|
+
* contract — there, `null` is a decision and an omission is a bug.
|
|
371
358
|
*/
|
|
372
359
|
export const cancelRequest = z.object({
|
|
373
360
|
job_id: z.uuid().nullable().optional().meta({
|
|
@@ -375,18 +362,17 @@ export const cancelRequest = z.object({
|
|
|
375
362
|
}),
|
|
376
363
|
}).strict();
|
|
377
364
|
/**
|
|
378
|
-
* The query of a live release
|
|
365
|
+
* The query of a live release: `DELETE .../live?session_id=<uuid>`.
|
|
379
366
|
*
|
|
380
367
|
* A query parameter rather than a body, following `?force=true` on robot
|
|
381
|
-
* deletion
|
|
382
|
-
* more fact". A body on a DELETE is carried inconsistently by proxies and by
|
|
368
|
+
* deletion. A body on a DELETE is carried inconsistently by proxies and by
|
|
383
369
|
* `fetch` itself, and this call runs from a browser tab that is often closing.
|
|
384
370
|
*
|
|
385
371
|
* **`.strict()`, for the reason `cancelRequest` is** — `?sessionid=` instead of
|
|
386
|
-
* `?session_id=`
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
372
|
+
* `?session_id=` would release **every** one of an identity's holds and strand
|
|
373
|
+
* its other tabs, which is precisely what this field exists to prevent. A
|
|
374
|
+
* refused typo costs a round trip; a stripped one stops a robot somebody else
|
|
375
|
+
* is watching.
|
|
390
376
|
*
|
|
391
377
|
* Absent means today's meaning: release **all** of this identity's holds on
|
|
392
378
|
* this camera. A client that has lost its id, or is going away entirely, still
|
|
@@ -430,7 +416,7 @@ export const serviceCallResponse = z.object({
|
|
|
430
416
|
* **This union exists so the route can name a response at all.** The entry
|
|
431
417
|
* carried `response: null` while the handler demonstrably answers something,
|
|
432
418
|
* which reads in the generated reference as *this route returns nothing* —
|
|
433
|
-
*
|
|
419
|
+
* a documented absence. A `null` there should
|
|
434
420
|
* mean `204`, and on this route it did not.
|
|
435
421
|
*/
|
|
436
422
|
export const invokeOrServiceResponse = z.union([invokeResponse, serviceCallResponse]);
|
|
@@ -443,14 +429,12 @@ export const publishRequest = z.object({
|
|
|
443
429
|
* The **most recent** job on a slug — running or already finished — or null
|
|
444
430
|
* only when nothing has ever run there.
|
|
445
431
|
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
* job is *said out loud* held for subscribers and silently did not hold for
|
|
453
|
-
* anyone polling. It is also the recovery `command_outcome_unknown` points
|
|
432
|
+
* A slug can be observed two equally valid ways, by polling this route or by
|
|
433
|
+
* subscribing. A route that forgot a job the moment it settled would let a
|
|
434
|
+
* poller see only `running`, then `null`: succeeded, failed, cancelled, `lost`
|
|
435
|
+
* and never-invoked would all become the same answer, and the promise that a
|
|
436
|
+
* lost job is said out loud would hold for subscribers and silently not hold
|
|
437
|
+
* for anyone polling. It is also the recovery `command_outcome_unknown` points
|
|
454
438
|
* a caller to.
|
|
455
439
|
*
|
|
456
440
|
* Read `job.state` to tell a live job from a finished one; that is what the
|
|
@@ -463,20 +447,18 @@ export const jobResponse = z.object({
|
|
|
463
447
|
});
|
|
464
448
|
/**
|
|
465
449
|
* Every job the platform currently believes this robot has — `GET
|
|
466
|
-
* /api/robots/:id/jobs
|
|
450
|
+
* /api/robots/:id/jobs`.
|
|
467
451
|
*
|
|
468
452
|
* `jobResponse` answers "what is on this slug", which requires knowing the
|
|
469
|
-
* slug first.
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
* cloud that has just reconciled a robot's `hello.active_jobs` has exactly
|
|
479
|
-
* this list and, until now, no way to say it out loud.
|
|
453
|
+
* slug first. Two kinds of job break that assumption: a reconnecting bridge
|
|
454
|
+
* can name a job the cloud has **no row for**, and the cloud adopts it; and a
|
|
455
|
+
* configuration change can leave a job on a slug the document no longer
|
|
456
|
+
* contains. Both are jobs nobody can ask about, because asking requires
|
|
457
|
+
* already knowing what to ask for.
|
|
458
|
+
*
|
|
459
|
+
* So this route answers the question the per-slug route cannot: not "is
|
|
460
|
+
* something running here", but "what is this robot doing". A cloud that has
|
|
461
|
+
* just reconciled a robot's `hello.active_jobs` has exactly this list.
|
|
480
462
|
*
|
|
481
463
|
* The array is ordered newest first and is **never null**: a robot doing
|
|
482
464
|
* nothing answers `{ jobs: [] }`. "Nothing is running" and "we did not look"
|
|
@@ -484,27 +466,23 @@ export const jobResponse = z.object({
|
|
|
484
466
|
* distinction `robotDeletionSummary` was made all-required for.
|
|
485
467
|
*
|
|
486
468
|
* **At most one entry per slug: the current job there, exactly what
|
|
487
|
-
* `jobResponse` would answer for that slug.** This is not a history endpoint
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
* console page carried a robot's entire past, and the one thing it exists to
|
|
493
|
-
* answer — *what is this robot doing* — would have been the first line of a
|
|
494
|
-
* scroll.
|
|
469
|
+
* `jobResponse` would answer for that slug.** This is not a history endpoint.
|
|
470
|
+
* Returning every job a registry still holds is unbounded in both count and
|
|
471
|
+
* payload for a robot that has been working all day, and the one thing this
|
|
472
|
+
* route exists to answer — *what is this robot doing* — would be the first
|
|
473
|
+
* line of a scroll. The durable history has its own routes.
|
|
495
474
|
*
|
|
496
475
|
* A settled job stays visible as its slug's current entry until something
|
|
497
476
|
* else runs there, which is what makes a job that just failed still findable.
|
|
498
477
|
* Read `state` to tell a live one from a finished one, exactly as with
|
|
499
478
|
* `jobResponse`.
|
|
500
479
|
*/
|
|
501
|
-
/*
|
|
502
|
-
* Identity
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
* nine paths independently from one implementation.
|
|
480
|
+
/* ------------------------------------------------------------------------
|
|
481
|
+
* Identity. Written down here for the same reason the command routes are:
|
|
482
|
+
* **a body schema does not imply a path**, and every consumer would otherwise
|
|
483
|
+
* derive nine paths independently from one implementation.
|
|
506
484
|
*
|
|
507
|
-
* **Two identity spaces, two prefixes
|
|
485
|
+
* **Two identity spaces, two prefixes.** The
|
|
508
486
|
* `/api/org/` vs `/api/end-users/` split this table once insisted on, and the
|
|
509
487
|
* one pool that replaced it, are both gone. `/api/org/users` is the **team**:
|
|
510
488
|
* Fleetless users, console access, a tier each. An app's users live under
|
|
@@ -533,22 +511,18 @@ export const jobResponse = z.object({
|
|
|
533
511
|
* model survives in production while the contract says otherwise.
|
|
534
512
|
*
|
|
535
513
|
* **`POST /api/auth/password/reset` answers `202` for every well-formed
|
|
536
|
-
* address**, known or not. It is the one route where
|
|
537
|
-
*
|
|
538
|
-
* timing difference between the two cases is an
|
|
539
|
-
* Note *timing* — a route that only sends mail for
|
|
540
|
-
* become measurably faster for an unknown one. Email is
|
|
541
|
-
*
|
|
542
|
-
* route mails the one match, if any
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
* is no reset path"* — and a console user needs the same thing; the first
|
|
549
|
-
* version of this block gave the routes only one prefix, which would have
|
|
550
|
-
* shipped the wave's named item for the wrong principal. `passwordChangeRequest`
|
|
551
|
-
* is shared because the operation is identical; the **prefix** is what says
|
|
514
|
+
* address**, known or not. It is the one route where saying nothing about
|
|
515
|
+
* whether an account exists is not a preference but the entire point: any
|
|
516
|
+
* status, body or timing difference between the two cases is an
|
|
517
|
+
* account-enumeration oracle. Note *timing* — a route that only sends mail for
|
|
518
|
+
* a real address must not become measurably faster for an unknown one. Email is
|
|
519
|
+
* **globally unique**, so a bare address names at most one account and the
|
|
520
|
+
* route mails the one match, if any. See `passwordResetRequest`.
|
|
521
|
+
*
|
|
522
|
+
* **Both surfaces get the password routes, mirrored.** An end user and a
|
|
523
|
+
* console user each need a way to change and to reset a password.
|
|
524
|
+
* `passwordChangeRequest` is shared because the operation is identical; the
|
|
525
|
+
* **prefix** is what says
|
|
552
526
|
* which session is being spent, exactly as it does for `login`. The two *reset*
|
|
553
527
|
* requests are separate shapes rather than one, because the surfaces identify a
|
|
554
528
|
* person differently: a Fleetless user by a globally unique address, an app
|
|
@@ -561,19 +535,18 @@ export const jobResponse = z.object({
|
|
|
561
535
|
* Re-issuing is the honest way to keep the promise: revoke everything, hand the
|
|
562
536
|
* caller a new pair. Anything else means the caller keeps working until their
|
|
563
537
|
* access token expires and is then silently logged out, which is
|
|
564
|
-
* indistinguishable from the change having failed
|
|
565
|
-
*
|
|
566
|
-
* **Every link
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
* sent there answers `token_spent` forever. A URL that does not say which
|
|
538
|
+
* indistinguishable from the change having failed.
|
|
539
|
+
*
|
|
540
|
+
* **Every mailed link must carry what the page needs to act on it.** The
|
|
541
|
+
* failure mode is a link mailed to a page that cannot handle it: a reset link
|
|
542
|
+
* pointing at the *request* page, an accept link pointing at a `404`. Worse and
|
|
543
|
+
* quieter: **both surfaces mailing the identical reset URL**, when the page it
|
|
544
|
+
* points at posts to only one of the two routes, so the other surface's token
|
|
545
|
+
* answers `token_spent` forever. A URL that does not say which
|
|
573
546
|
* surface minted it cannot be routed correctly by anything.
|
|
574
547
|
*
|
|
575
548
|
* So the link shapes are fixed here rather than in whichever repo builds them.
|
|
576
|
-
* **They moved to the auth portal
|
|
549
|
+
* **They moved to the auth portal**: the
|
|
577
550
|
* console serves no credential page at all any more, and `{portal}` is the
|
|
578
551
|
* cloud's `AUTH_PUBLIC_URL` — `auth.fleetless.dev` where the deployment has
|
|
579
552
|
* that vhost, the cloud's own base where it does not, since the cloud renders
|
|
@@ -585,7 +558,7 @@ export const jobResponse = z.object({
|
|
|
585
558
|
* | team invitation | `{portal}/accept-invite/{token}` |
|
|
586
559
|
*
|
|
587
560
|
* **An app user's links are not in this table, and cannot be** (2026-09-05,
|
|
588
|
-
*
|
|
561
|
+
* Fleetless renders an app user no page, so there is no `{portal}` path
|
|
589
562
|
* to name: the link points into the **developer's own app**, at the template
|
|
590
563
|
* they configured (`appAuthConfig.invite_url`, `verify_url`, `reset_url`), with
|
|
591
564
|
* the token substituted for `{token}`. That is why those fields are validated
|
|
@@ -596,14 +569,13 @@ export const jobResponse = z.object({
|
|
|
596
569
|
* not a row to restore"*. It was designed; the answer was that the row belongs
|
|
597
570
|
* to the developer and not to this table.
|
|
598
571
|
*
|
|
599
|
-
* The strings themselves live
|
|
572
|
+
* The strings themselves live server-side, read by the
|
|
600
573
|
* route that serves each page AND by the builder that mails it — one constant,
|
|
601
574
|
* because the defect this table records happened again after it was written:
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
* it because nothing shared a string.
|
|
575
|
+
* the mailer, the page and this table can each spell a path differently, and
|
|
576
|
+
* nothing catches it because nothing shares a string.
|
|
605
577
|
*
|
|
606
|
-
* ##
|
|
578
|
+
* ## The asset store
|
|
607
579
|
*
|
|
608
580
|
* | route | who | role capability |
|
|
609
581
|
* |---|---|---|
|
|
@@ -615,47 +587,34 @@ export const jobResponse = z.object({
|
|
|
615
587
|
* | `GET /api/robots/{id}/assets/sync/{syncId}` | developer | — |
|
|
616
588
|
* | `POST /api/bridge/assets` | robot token, per sync | — |
|
|
617
589
|
*
|
|
618
|
-
* **The read routes are dual-mode
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
* could never fetch a URDF — which is §4.6's entire "Clients: `GET .../urdf`"
|
|
624
|
-
* story, and the audience the asset store exists for.
|
|
590
|
+
* **The read routes are dual-mode.** `assets` is an *app-role* capability, and
|
|
591
|
+
* developers are not in any app's role system at all — two identity spaces, and
|
|
592
|
+
* a credential from one never authenticates the other. Reading the table as
|
|
593
|
+
* developer-only would mean an end user could never fetch a URDF, which is the
|
|
594
|
+
* audience the asset store exists for.
|
|
625
595
|
*
|
|
626
596
|
* So: a developer reaches the robot because it belongs to their org; an end
|
|
627
|
-
* user reaches it when their role grants `assets`.
|
|
628
|
-
* reading §3.3 against this table before anything was built on it — the second
|
|
629
|
-
* time in two waves that this one check has caught a delta placing a feature
|
|
630
|
-
* in the wrong identity space.
|
|
597
|
+
* user reaches it when their role grants `assets`.
|
|
631
598
|
*
|
|
632
599
|
* **The rewritten mesh URIs in a served URDF are absolute, not
|
|
633
600
|
* root-relative.** A relative URL resolves against *the consumer's* origin,
|
|
634
601
|
* and the consumers here are apps on other domains — so `/api/robots/…` would
|
|
635
|
-
* 404 against the customer's own site.
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
* in her mission's route table but in neither her mission nor the gate.
|
|
652
|
-
*
|
|
653
|
-
* Reading is a role capability; **changing the store is Owner-tier**, matching
|
|
654
|
-
* W6c's reading of §3.1 — a sync spends the org's asset quota and a deletion
|
|
655
|
-
* breaks every app rendering that robot, so neither is a Member's to do.
|
|
656
|
-
*
|
|
657
|
-
* **`GET .../assets/missing` shipped undocumented for a whole wave and is the
|
|
658
|
-
* sole producer of `asset_missing` (W7a, Momus-W7 M5).** It never succeeds,
|
|
602
|
+
* 404 against the customer's own site. **A URL handed to somebody else's
|
|
603
|
+
* browser must never be relative to ours.**
|
|
604
|
+
*
|
|
605
|
+
* **There is no per-asset `DELETE`, and its absence is the design.** Symmetry
|
|
606
|
+
* is not a reason. Assets are immutable and content-addressed, and the
|
|
607
|
+
* operation a developer actually performs is *the URDF changed, sync again*: a
|
|
608
|
+
* **re-sync reconciles**, so assets the new URDF no longer references stop
|
|
609
|
+
* belonging to that robot. One mechanism instead of two, and robot deletion is
|
|
610
|
+
* already a cascade.
|
|
611
|
+
*
|
|
612
|
+
* Reading is a role capability; **changing the store is Owner-tier** — a sync
|
|
613
|
+
* spends the org's asset quota and a deletion breaks every app rendering that
|
|
614
|
+
* robot, so neither is a Member's to do.
|
|
615
|
+
*
|
|
616
|
+
* **`GET .../assets/missing` is the sole producer of `asset_missing`.** It
|
|
617
|
+
* never succeeds,
|
|
659
618
|
* and that is what it is for: when the served URDF is rewritten, a reference
|
|
660
619
|
* the store cannot answer has to be rewritten into *something*, and a URL that
|
|
661
620
|
* 404s `asset_missing` naming the reference is the only option that leaves the
|
|
@@ -684,8 +643,8 @@ export const jobResponse = z.object({
|
|
|
684
643
|
* credential over HTTP**. Everything the bridge does today goes over the
|
|
685
644
|
* WebSocket, so this is new surface, not a variation of something existing —
|
|
686
645
|
* and it accepts bodies far larger than any other route on the platform. It is
|
|
687
|
-
* where a rate limit and a size ceiling matter most, and where
|
|
688
|
-
* applies: the refusal must precede the work, not follow it.
|
|
646
|
+
* where a rate limit and a size ceiling matter most, and where the general
|
|
647
|
+
* rule applies hardest: the refusal must precede the work, not follow it.
|
|
689
648
|
*
|
|
690
649
|
* The end-user links carry `app_identifier` because the page cannot act
|
|
691
650
|
* without it: `clientPasswordResetRequest` requires it, and an end user is
|
|
@@ -693,17 +652,16 @@ export const jobResponse = z.object({
|
|
|
693
652
|
* not enough, and a page that guesses the app is a page that guesses wrong.
|
|
694
653
|
*
|
|
695
654
|
* **This is a stopgap and should be named as one.** An app's users landing on
|
|
696
|
-
*
|
|
697
|
-
* and an app has no configured base URL to send them to.
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
* **`DELETE /api/org/members/:id` is not a row deletion.**
|
|
702
|
-
*
|
|
703
|
-
* is not built. `revokeSessionsForSubject` is already wired.
|
|
655
|
+
* the Fleetless console to reset a password is wrong — the page belongs to the
|
|
656
|
+
* app, and an app has no configured base URL to send them to yet. Until it
|
|
657
|
+
* does, the console hosts both, and the URL carries the app so that moving it
|
|
658
|
+
* later is a redirect rather than a redesign.
|
|
659
|
+
*
|
|
660
|
+
* **`DELETE /api/org/members/:id` is not a row deletion.** A token minted
|
|
661
|
+
* before the removal must stop working; sessions are revoked for the subject.
|
|
704
662
|
*/
|
|
705
663
|
/**
|
|
706
|
-
* What a `rate_limited` refusal tells the caller
|
|
664
|
+
* What a `rate_limited` refusal tells the caller.
|
|
707
665
|
*
|
|
708
666
|
* One number, and it is the only one that matters: **when to come back.** A
|
|
709
667
|
* limit that says "too many" without saying "in 800 ms" produces a client that
|
|
@@ -719,11 +677,10 @@ export const rateLimitDetails = z.object({
|
|
|
719
677
|
});
|
|
720
678
|
/**
|
|
721
679
|
* Every job this robot's registry currently holds, **ordered newest first by
|
|
722
|
-
* `started_at`, with `seq` as the tiebreaker
|
|
680
|
+
* `started_at`, with `seq` as the tiebreaker**.
|
|
723
681
|
*
|
|
724
|
-
* The
|
|
725
|
-
*
|
|
726
|
-
* twice over:
|
|
682
|
+
* The tiebreaker is named rather than left implicit, because it matters twice
|
|
683
|
+
* over:
|
|
727
684
|
*
|
|
728
685
|
* 1. **`started_at` alone is not a total order.** Two jobs minted in the same
|
|
729
686
|
* millisecond sorted against each other arbitrarily — differently on each
|
|
@@ -747,9 +704,9 @@ export const robotJobsResponse = z.object({
|
|
|
747
704
|
/**
|
|
748
705
|
* Every slug of a robot that a role can be granted, **with its kind**.
|
|
749
706
|
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
752
|
-
*
|
|
707
|
+
* A roles matrix built against the datapoint list alone cannot grant an
|
|
708
|
+
* action, a service or a publisher. One list that names every kind, with its
|
|
709
|
+
* kind, is what a matrix needs.
|
|
753
710
|
*/
|
|
754
711
|
export const exposure = z.object({
|
|
755
712
|
slug,
|
|
@@ -759,8 +716,8 @@ export const exposure = z.object({
|
|
|
759
716
|
export const exposureListResponse = z.object({
|
|
760
717
|
exposures: z.array(exposure),
|
|
761
718
|
});
|
|
762
|
-
/*
|
|
763
|
-
* Cameras
|
|
719
|
+
/* ------------------------------------------------------------------------
|
|
720
|
+
* Cameras. Routes, written down as the command routes are:
|
|
764
721
|
*
|
|
765
722
|
* | route | answers |
|
|
766
723
|
* |---|---|
|
|
@@ -791,36 +748,27 @@ export const SNAPSHOT_HEADERS = {
|
|
|
791
748
|
height: 'x-fleetless-height',
|
|
792
749
|
};
|
|
793
750
|
/**
|
|
794
|
-
* The metadata an asset upload carries beside its raw body
|
|
795
|
-
*
|
|
796
|
-
* Here rather than as a convention documented on both sides, and the reason is
|
|
797
|
-
* a scar. W5 shipped `x-fleetless-*` headers the CORS policy did not expose,
|
|
798
|
-
* so `age_ms` was `null` in **every** browser while the SDK documented `null`
|
|
799
|
-
* as "nothing captured yet" — a fresh frame reporting as no snapshot at all,
|
|
800
|
-
* invisible to three test suites because none of them was a browser. And W6b
|
|
801
|
-
* found the general form: three repos agreeing with each other about a payload
|
|
802
|
-
* none of them exchanged, each right in its own tests.
|
|
751
|
+
* The metadata an asset upload carries beside its raw body.
|
|
803
752
|
*
|
|
804
|
-
*
|
|
805
|
-
* A
|
|
806
|
-
*
|
|
753
|
+
* Written here rather than left as a convention each side documents for
|
|
754
|
+
* itself. **A string shared by two implementations and defined in both is a
|
|
755
|
+
* string that drifts**, and a header is the easiest place for that to happen
|
|
756
|
+
* unnoticed: a zod schema cannot validate one, which is an argument for
|
|
757
|
+
* writing the names down once, not an argument for writing them down twice.
|
|
807
758
|
*
|
|
808
759
|
* `name` is the `package://` URI verbatim for a mesh — the same string
|
|
809
760
|
* `asset.name` stores, and the same one `urdfCompleteness.missing` reports, so
|
|
810
761
|
* a failed upload and a missing mesh can be matched by eye.
|
|
811
762
|
*/
|
|
812
763
|
/**
|
|
813
|
-
* **`name` travels percent-encoded
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
* cost a developer every mesh after it in that sync, with no cause on the
|
|
822
|
-
* wire. R6 made it ordinary rather than exotic: `.dae` internal names come
|
|
823
|
-
* from 3D-authoring tools, where non-ASCII is Tuesday.
|
|
764
|
+
* **`name` travels percent-encoded in a second header.**
|
|
765
|
+
*
|
|
766
|
+
* HTTP header values are latin-1. A texture called `textures/日本語.png` cannot
|
|
767
|
+
* be put in one at all: in Python it raises a `UnicodeEncodeError` inside
|
|
768
|
+
* `urllib` — a `ValueError`, caught by neither `HTTPError` nor `URLError` — so
|
|
769
|
+
* a single non-ASCII filename can fail an entire sync with no cause on the
|
|
770
|
+
* wire. Non-ASCII names are ordinary rather than exotic, because `.dae`
|
|
771
|
+
* internal names come from 3D-authoring tools.
|
|
824
772
|
*
|
|
825
773
|
* The encoding is not invented here. **`GET .../assets/missing?name=` already
|
|
826
774
|
* carries this exact string percent-encoded**, because a query parameter is
|
|
@@ -828,21 +776,19 @@ export const SNAPSHOT_HEADERS = {
|
|
|
828
776
|
* answered.
|
|
829
777
|
*
|
|
830
778
|
* **It is a SECOND header, and that is the whole design rather than a
|
|
831
|
-
* detail.**
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
* reading the contract rather than the code).
|
|
779
|
+
* detail.** Overloading `name` itself — the producer encodes, the store
|
|
780
|
+
* decodes — decodes identically for every name without a `%`, so an older
|
|
781
|
+
* producer and a newer store agree by luck right up until a name contains
|
|
782
|
+
* `%2f`, which the store would then silently turn into a `/`. A wire change
|
|
783
|
+
* whose breakage is invisible in the common case and silent in the uncommon
|
|
784
|
+
* one is the worst of both.
|
|
838
785
|
*
|
|
839
786
|
* So `name` keeps meaning exactly what it always meant, and `nameEncoded`
|
|
840
787
|
* carries the percent-encoded UTF-8 form. **The store prefers `nameEncoded`
|
|
841
788
|
* when present and uses `name` otherwise**, so:
|
|
842
789
|
*
|
|
843
|
-
* -
|
|
844
|
-
* - a
|
|
845
|
-
* intact for the first time;
|
|
790
|
+
* - a producer that sends only `name` behaves exactly as it always did;
|
|
791
|
+
* - a producer that sends both can carry a name latin-1 cannot express;
|
|
846
792
|
* - no value is ever ambiguous about which encoding it is in.
|
|
847
793
|
*
|
|
848
794
|
* A producer that can send `nameEncoded` should send both, so a store older
|
|
@@ -855,23 +801,22 @@ export const ASSET_UPLOAD_HEADERS = {
|
|
|
855
801
|
nameEncoded: 'x-fleetless-asset-name-encoded',
|
|
856
802
|
syncId: 'x-fleetless-sync-id',
|
|
857
803
|
/**
|
|
858
|
-
* **
|
|
859
|
-
*
|
|
804
|
+
* **The announced size, and it is what makes `asset_too_large` reachable at
|
|
805
|
+
* all.**
|
|
860
806
|
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
* Erzeuger (Momus-W7, M1, an den echten Routenoptionen reproduziert).
|
|
807
|
+
* A server-side body limit is applied by the content-type parser, before the
|
|
808
|
+
* handler runs, so an oversized upload can only be refused with a bare
|
|
809
|
+
* `413` carrying neither `limit_bytes` nor `size_bytes` — and the structured
|
|
810
|
+
* refusal `assetTooLargeDetails` describes would have no producer.
|
|
866
811
|
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
812
|
+
* With the size announced in a header the refusal can be made where it can
|
|
813
|
+
* say something: before a byte is buffered, with both numbers. It also lets
|
|
814
|
+
* a producer discover its own limit without first reading the whole file
|
|
815
|
+
* into memory.
|
|
871
816
|
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
817
|
+
* The header is an **announcement, not a proof**: a sender can lie. The
|
|
818
|
+
* ceiling still applies to the body — this does not replace enforcement, it
|
|
819
|
+
* only makes the refusal answerable.
|
|
875
820
|
*/
|
|
876
821
|
size: 'x-fleetless-asset-size',
|
|
877
822
|
};
|
|
@@ -906,7 +851,7 @@ export const cameraListResponse = z.object({
|
|
|
906
851
|
* What a viewer needs to join, and **what it costs them to hold**.
|
|
907
852
|
*
|
|
908
853
|
* `POST` takes a refcount hold and `DELETE` releases it; the first hold
|
|
909
|
-
* starts the robot publishing and the last release stops it
|
|
854
|
+
* starts the robot publishing and the last release stops it. A client
|
|
910
855
|
* that forgets to release keeps a robot streaming to nobody, so the SDK hands
|
|
911
856
|
* back a `release()` rather than a bare token.
|
|
912
857
|
*
|
|
@@ -922,16 +867,14 @@ export const cameraListResponse = z.object({
|
|
|
922
867
|
*/
|
|
923
868
|
export const liveSessionResponse = z.object({
|
|
924
869
|
/**
|
|
925
|
-
* This viewer's hold, and the **only** thing `DELETE` should be given
|
|
926
|
-
* (W6b).
|
|
870
|
+
* This viewer's hold, and the **only** thing `DELETE` should be given.
|
|
927
871
|
*
|
|
928
|
-
* A hold
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
* connection — the token is checked at join and never again —
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
* this project rejects everywhere else.
|
|
872
|
+
* A hold addressed by `{identity, robot, slug}` alone would make two tabs of
|
|
873
|
+
* one logged-in user a single hold as far as the refcount can see. Closing
|
|
874
|
+
* either tab would release it, and the surviving tab would keep its LiveKit
|
|
875
|
+
* connection — the token is checked at join and never again — rendering a
|
|
876
|
+
* video the robot had already stopped producing. A frozen picture is not an
|
|
877
|
+
* ended session.
|
|
935
878
|
*
|
|
936
879
|
* `DELETE` without a session id keeps today's meaning — *release my holds
|
|
937
880
|
* on this camera* — because an SDK that has lost its id, or a client that
|
|
@@ -964,8 +907,8 @@ export const liveSessionResponse = z.object({
|
|
|
964
907
|
* which polls continuously while a tab is open.
|
|
965
908
|
*
|
|
966
909
|
* `age_ms` is not a convenience: a cached frame served without its age is
|
|
967
|
-
* indistinguishable from a live one, and
|
|
968
|
-
*
|
|
910
|
+
* indistinguishable from a live one, and snapshots are deliberately cheap and
|
|
911
|
+
* therefore deliberately old. `null` values mean nothing has been
|
|
969
912
|
* captured yet — which is an answer, not an error.
|
|
970
913
|
*/
|
|
971
914
|
export const snapshotMetaResponse = z.object({
|
|
@@ -987,31 +930,26 @@ export const snapshotMetaResponse = z.object({
|
|
|
987
930
|
}),
|
|
988
931
|
});
|
|
989
932
|
// ---------------------------------------------------------------------------
|
|
990
|
-
//
|
|
933
|
+
// Retention, history and org quotas
|
|
991
934
|
// ---------------------------------------------------------------------------
|
|
992
935
|
/**
|
|
993
|
-
* **Both history shapes answer the same boundary the same way: `[from, to)
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
* This is a statement about behaviour, not a field — nothing in the shapes
|
|
1009
|
-
* below can enforce it. It is written here because this is the one place both
|
|
1010
|
-
* shapes are defined together, and the cloud's `history-store` and the SDK's
|
|
1011
|
-
* README are the two places that have to agree with it.
|
|
936
|
+
* **Both history shapes answer the same boundary the same way: `[from, to)`.**
|
|
937
|
+
*
|
|
938
|
+
* Half-open, because it is the only rule under which **adjacent windows tile
|
|
939
|
+
* without overlap**: `[0,10)` then `[10,20)` covers every instant once. With an
|
|
940
|
+
* inclusive upper bound a sample at exactly `10` belongs to both windows, and
|
|
941
|
+
* any consumer summing them counts it twice.
|
|
942
|
+
*
|
|
943
|
+
* Two shapes that answered it differently would give opposite results for a
|
|
944
|
+
* point landing exactly on `to` — same range, same data — and the difference
|
|
945
|
+
* renders as a gap in one of the two.
|
|
946
|
+
*
|
|
947
|
+
* This is a statement about behaviour, not a field: nothing in the shapes below
|
|
948
|
+
* can enforce it. It is written here because this is the one place both shapes
|
|
949
|
+
* are defined together.
|
|
1012
950
|
*/
|
|
1013
951
|
/**
|
|
1014
|
-
* A history query
|
|
952
|
+
* A history query. `from`/`to` accept **either** a relative expression
|
|
1015
953
|
* (`now-30s`, `now-5m`, `now-1h`) **or** absolute unix milliseconds, because
|
|
1016
954
|
* a chart asks the first way and a report asks the second, and making a
|
|
1017
955
|
* client convert is making it guess our clock.
|
|
@@ -1037,26 +975,23 @@ export const historyQuery = z.object({
|
|
|
1037
975
|
description: 'A dotted path to a numeric field inside an object value, such as `pose.x`. Without it the datapoint\'s value is used whole, which only works when it is already a number.',
|
|
1038
976
|
}),
|
|
1039
977
|
/**
|
|
1040
|
-
* **A union whose input branch IS the wire, not a coercion
|
|
978
|
+
* **A union whose input branch IS the wire, not a coercion.**
|
|
1041
979
|
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
* a coercion's **result** in either `io` mode, so `io: 'input'` and
|
|
1047
|
-
* `io: 'output'` both emit `{"type":"integer"}` — an artifact describing a
|
|
980
|
+
* The schema describes a **query string**, where every value arrives as
|
|
981
|
+
* text. `z.coerce.number()` would read it, but a coercion cannot be
|
|
982
|
+
* *published*: zod renders a coercion's **result** in either `io` mode, so
|
|
983
|
+
* input and output both emit `{"type":"integer"}` — an artifact describing a
|
|
1048
984
|
* shape a query string can never carry. Anyone validating a real request
|
|
1049
985
|
* against it rejects every one that sets `limit`.
|
|
1050
986
|
*
|
|
1051
|
-
* That is a
|
|
1052
|
-
*
|
|
1053
|
-
* remedy for both and has been corrected.
|
|
987
|
+
* That is a different problem from `.default()` publishing as required,
|
|
988
|
+
* which input-mode export genuinely does fix.
|
|
1054
989
|
*
|
|
1055
|
-
* A union states both truths honestly: the wire carries a numeric string,
|
|
1056
|
-
*
|
|
990
|
+
* A union states both truths honestly: the wire carries a numeric string, a
|
|
991
|
+
* programmatic caller may pass a number, and the artifact can render the
|
|
1057
992
|
* input branch because there is one to render.
|
|
1058
993
|
*
|
|
1059
|
-
* **What the artifact
|
|
994
|
+
* **What the artifact does not say, named here rather than left silent.**
|
|
1060
995
|
* The `1..10000` bound lives in the `.pipe()`, which is the *output* half, so
|
|
1061
996
|
* no input-mode artifact can express it as a constraint: the published shape
|
|
1062
997
|
* is `^\d{1,5}$` or a bare integer, and five digits is a weak echo of the
|
|
@@ -1064,18 +999,17 @@ export const historyQuery = z.object({
|
|
|
1064
999
|
* parsing, not by the shape of the text — but it is a **reduction**, and an
|
|
1065
1000
|
* artifact that stops naming a bound reads as if there were none.
|
|
1066
1001
|
*
|
|
1067
|
-
* So both branches carry the number in a `.describe()
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
* than closed.
|
|
1002
|
+
* So both branches carry the number in a `.describe()`. It is **not** a
|
|
1003
|
+
* constraint and nothing validates against it; it means a generator, or a
|
|
1004
|
+
* person reading only the published schema, sees the actual ceiling instead
|
|
1005
|
+
* of nothing. The gap is narrowed and named rather than closed.
|
|
1072
1006
|
*/
|
|
1073
1007
|
limit: z
|
|
1074
1008
|
.union([
|
|
1075
1009
|
z
|
|
1076
1010
|
.string()
|
|
1077
1011
|
.regex(/^\d{1,5}$/)
|
|
1078
|
-
// **The description carries the number the shape cannot** (
|
|
1012
|
+
// **The description carries the number the shape cannot** (see the
|
|
1079
1013
|
// proposal). Five digits is the regex's bound, not the contract's; the
|
|
1080
1014
|
// real ceiling lives in the `.pipe()` below and therefore cannot appear
|
|
1081
1015
|
// in an input-mode artifact. This does not close that gap and does not
|
|
@@ -1095,7 +1029,7 @@ export const historyQuery = z.object({
|
|
|
1095
1029
|
}),
|
|
1096
1030
|
});
|
|
1097
1031
|
/**
|
|
1098
|
-
* Raw samples. `timestamp_ms` is the **bridge's capture time**
|
|
1032
|
+
* Raw samples. `timestamp_ms` is the **bridge's capture time** — the
|
|
1099
1033
|
* same instant the live value carried, so a recorded point and a live one can
|
|
1100
1034
|
* be placed on one axis without apology.
|
|
1101
1035
|
*
|
|
@@ -1149,9 +1083,8 @@ export const historySamplesResponse = z.object({
|
|
|
1149
1083
|
* inspection.
|
|
1150
1084
|
*
|
|
1151
1085
|
* `sample_count` exists because an empty bucket and a bucket whose average is
|
|
1152
|
-
* zero are different facts.
|
|
1153
|
-
*
|
|
1154
|
-
* product to draw a gap as a line.
|
|
1086
|
+
* zero are different facts. When two facts share one representation, a chart
|
|
1087
|
+
* is the easiest place to draw a gap as a line.
|
|
1155
1088
|
*/
|
|
1156
1089
|
export const historyBucketsResponse = z.object({
|
|
1157
1090
|
slug: slug.meta({ description: 'The datapoint these buckets summarise.' }),
|
|
@@ -1233,7 +1166,7 @@ export const historyBucketsResponse = z.object({
|
|
|
1233
1166
|
*/
|
|
1234
1167
|
export const historyResponse = z.union([historySamplesResponse, historyBucketsResponse]);
|
|
1235
1168
|
/**
|
|
1236
|
-
*
|
|
1169
|
+
* Deletion, and the one channel that reports health.
|
|
1237
1170
|
*
|
|
1238
1171
|
* | Route | Body | Answer |
|
|
1239
1172
|
* |---|---|---|
|
|
@@ -1259,24 +1192,17 @@ export const historyResponse = z.union([historySamplesResponse, historyBucketsRe
|
|
|
1259
1192
|
* takes an optional `robot_id` filter rather than living at a per-robot
|
|
1260
1193
|
* path.
|
|
1261
1194
|
*
|
|
1262
|
-
* The
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
* So: same scope, one route, and `?robot_id=` for the narrow question. The
|
|
1272
|
-
* cloud owner proposed this while unblocking the console, and was right.
|
|
1273
|
-
*
|
|
1274
|
-
* This table was missing from the first W6a delta, and a teammate had to ask
|
|
1275
|
-
* three separate people for the paths — which is how a route becomes a fact
|
|
1276
|
-
* that lives only in an inbox.
|
|
1195
|
+
* The per-robot reading is the tempting one and it is wrong: a health list is
|
|
1196
|
+
* rendered for every robot at once, and a per-robot path makes that N requests
|
|
1197
|
+
* to draw one screen — while the event that keeps it fresh arrives org-wide
|
|
1198
|
+
* anyway. A snapshot and a channel that disagree about scope are not two halves
|
|
1199
|
+
* of one thing; they are two things every consumer has to reconcile, separately,
|
|
1200
|
+
* forever.
|
|
1201
|
+
*
|
|
1202
|
+
* So: same scope, one route, and `?robot_id=` for the narrow question.
|
|
1277
1203
|
*/
|
|
1278
1204
|
/**
|
|
1279
|
-
* What a `robot.deleted` audit event carries
|
|
1205
|
+
* What a `robot.deleted` audit event carries.
|
|
1280
1206
|
*
|
|
1281
1207
|
* A deletion record that says only *that* something was destroyed is a
|
|
1282
1208
|
* receipt for an unknown amount. This names it: how many configured slugs,
|
|
@@ -1296,8 +1222,7 @@ export const robotDeletionSummary = z.object({
|
|
|
1296
1222
|
* console renders them in one sentence: *"this deletes N published slugs …
|
|
1297
1223
|
* and M cameras"*. With cameras inside `slug_count` that sentence counts
|
|
1298
1224
|
* them twice, on the one screen whose whole justification is naming what an
|
|
1299
|
-
* irreversible click destroys
|
|
1300
|
-
* five and the console then added the cameras again).
|
|
1225
|
+
* irreversible click destroys.
|
|
1301
1226
|
*
|
|
1302
1227
|
* A draft is destroyed too and is described by `had_unpublished_draft`
|
|
1303
1228
|
* rather than by either of these: describing three things with two numbers
|
|
@@ -1308,7 +1233,7 @@ export const robotDeletionSummary = z.object({
|
|
|
1308
1233
|
bytes_freed: z.number().int().nonnegative(),
|
|
1309
1234
|
cameras: z.array(slug),
|
|
1310
1235
|
/**
|
|
1311
|
-
* Assets destroyed with the robot
|
|
1236
|
+
* Assets destroyed with the robot, and **`asset_bytes_freed` is what
|
|
1312
1237
|
* this org actually gets back** — not the sum of the assets' sizes.
|
|
1313
1238
|
*
|
|
1314
1239
|
* Storage is content-addressed, so a mesh two robots share survives the
|
|
@@ -1392,17 +1317,16 @@ export const robotDeleteQuery = z
|
|
|
1392
1317
|
})
|
|
1393
1318
|
.meta({ description: 'The one optional parameter of `DELETE /api/robots/:id`, and it is the difference between a refusal and a cascade. It accepts the exact string `true`, or its own absence, and refuses everything else.' });
|
|
1394
1319
|
/**
|
|
1395
|
-
* The seven health states, declared **once
|
|
1320
|
+
* The seven health states, declared **once**.
|
|
1396
1321
|
*
|
|
1397
|
-
* `resourceHealthState` and `resourceHealthEvent` are the snapshot and the
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
* to add it in both places, on the wave's last contract commit.
|
|
1322
|
+
* `resourceHealthState` and `resourceHealthEvent` are the snapshot and the push
|
|
1323
|
+
* of the same thing. Writing the values out twice publishes two independent
|
|
1324
|
+
* artifacts with no `$ref` between them, kept in step only by whoever
|
|
1325
|
+
* remembers to edit both.
|
|
1402
1326
|
*
|
|
1403
|
-
* One concept rendering as two artifacts that nothing keeps in step is its
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
1327
|
+
* One concept rendering as two artifacts that nothing keeps in step is its own
|
|
1328
|
+
* class of artifact-versus-source defect, distinct from `.default()` publishing
|
|
1329
|
+
* as `required` and from a coercion's unrepresentable input.
|
|
1406
1330
|
*/
|
|
1407
1331
|
export const RESOURCE_HEALTH_STATES = [
|
|
1408
1332
|
'ok',
|
|
@@ -1414,7 +1338,7 @@ export const RESOURCE_HEALTH_STATES = [
|
|
|
1414
1338
|
'unreadable_credential',
|
|
1415
1339
|
/**
|
|
1416
1340
|
* A camera names a credential that **does not exist** in this org — deleted,
|
|
1417
|
-
* mistyped, or belonging to somebody else
|
|
1341
|
+
* mistyped, or belonging to somebody else.
|
|
1418
1342
|
*
|
|
1419
1343
|
* Separate from `unreadable_credential` because that one asserts a
|
|
1420
1344
|
* decryption that was attempted and failed, and here nothing was ever
|
|
@@ -1424,11 +1348,11 @@ export const RESOURCE_HEALTH_STATES = [
|
|
|
1424
1348
|
* — a different fact with a different fix.
|
|
1425
1349
|
*
|
|
1426
1350
|
* **Retiring with the credential store**, and not live behaviour to build
|
|
1427
|
-
* against. Its one producer
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
1431
|
-
*
|
|
1351
|
+
* against. Its one producer tolerated an unresolved `credentials_ref` at
|
|
1352
|
+
* publish time; that field is gone, so nothing emits this today. It is kept
|
|
1353
|
+
* only until the credential store is removed, which takes these three
|
|
1354
|
+
* credential states with it — `unreadable_credential` and the `readable` fact
|
|
1355
|
+
* on `credentialSummary` go the same way.
|
|
1432
1356
|
*/
|
|
1433
1357
|
'credential_missing',
|
|
1434
1358
|
/** A configuration change stopped this stream, deliberately. */
|
|
@@ -1449,26 +1373,22 @@ export const RESOURCE_HEALTH_STATES = [
|
|
|
1449
1373
|
];
|
|
1450
1374
|
/**
|
|
1451
1375
|
* The health of one thing a developer configured, as the platform currently
|
|
1452
|
-
* sees it
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1456
|
-
*
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
* `reason` is for a human and is **never** built from an exception message:
|
|
1469
|
-
* W6 found a camera password in a log through `log.exception`, and again in
|
|
1470
|
-
* `LiveStartError`'s message, which travels to the cloud on this very path.
|
|
1471
|
-
* Type names and fixed strings only.
|
|
1376
|
+
* sees it.
|
|
1377
|
+
*
|
|
1378
|
+
* It exists because nothing else carries the state of a camera, a source or a
|
|
1379
|
+
* credential to a developer who is not, at that exact moment, pressing a
|
|
1380
|
+
* button. A publish failure after the `201` reaches no one; a source whose
|
|
1381
|
+
* password is wrong fails at config-apply time with nobody watching and stays
|
|
1382
|
+
* silent until someone presses "Go live" days later; a viewer cannot learn
|
|
1383
|
+
* *why* a stream ended; an undecryptable credential reports as healthy.
|
|
1384
|
+
*
|
|
1385
|
+
* One shape rather than a field per symptom, because a failure written into a
|
|
1386
|
+
* state field the platform writes and reads in one place is a dead end.
|
|
1387
|
+
*
|
|
1388
|
+
* `reason` is for a human and is **never** built from an exception message: a
|
|
1389
|
+
* camera password reaches a log that way, and an exception message from a
|
|
1390
|
+
* failing stream travels to the cloud on this very path. Type names and fixed
|
|
1391
|
+
* strings only.
|
|
1472
1392
|
*/
|
|
1473
1393
|
export const resourceHealthState = z.object({
|
|
1474
1394
|
robot_id: z.uuid(),
|
|
@@ -1476,18 +1396,17 @@ export const resourceHealthState = z.object({
|
|
|
1476
1396
|
/** The camera slug, or the credential name. */
|
|
1477
1397
|
ref: z.string().min(1).max(64),
|
|
1478
1398
|
/**
|
|
1479
|
-
* **Which of two questions this entry answers
|
|
1399
|
+
* **Which of two questions this entry answers.**
|
|
1480
1400
|
*
|
|
1481
1401
|
* `'source'` — can the source be read at all? (`unreachable`, `auth_failed`,
|
|
1482
1402
|
* `unreadable_credential`, `missing_credential`, `ok`, …)
|
|
1483
1403
|
* `'publish'` — given a readable source, did publishing to LiveKit work?
|
|
1484
1404
|
*
|
|
1485
|
-
*
|
|
1486
|
-
* one flat `state`, in which `publish_failed`
|
|
1487
|
-
* and every other value
|
|
1488
|
-
* same field, two questions
|
|
1489
|
-
*
|
|
1490
|
-
* guaranteed, on every reconnect, for any camera with an active viewer.
|
|
1405
|
+
* Without the facet both answers land in one entry keyed
|
|
1406
|
+
* `${robot} ${kind} ${ref}` with one flat `state`, in which `publish_failed`
|
|
1407
|
+
* answers *"can we publish"* and every other value answers *"can the source
|
|
1408
|
+
* be read"* — same key, same field, two questions, each overwriting the
|
|
1409
|
+
* other.
|
|
1491
1410
|
*
|
|
1492
1411
|
* The facet is part of the entry's identity: a camera can perfectly well be
|
|
1493
1412
|
* readable and unpublishable at the same moment, and that pair is exactly
|
|
@@ -1507,14 +1426,8 @@ export const resourceHealthState = z.object({
|
|
|
1507
1426
|
/**
|
|
1508
1427
|
* The current state of everything in the **org**.
|
|
1509
1428
|
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
* paragraph explaining why the per-robot reading was wrong — while the schema
|
|
1513
|
-
* it describes still said the old thing. Cloud, console and SDK all implement
|
|
1514
|
-
* org-wide correctly; contracts was the only place still saying otherwise,
|
|
1515
|
-
* and it is the first place a fourth consumer reads.
|
|
1516
|
-
*
|
|
1517
|
-
* A channel with no snapshot cannot answer "what is the state now?" for a
|
|
1429
|
+
* Org-wide, not per robot — the route table above explains why. A channel
|
|
1430
|
+
* with no snapshot cannot answer "what is the state now?" for a
|
|
1518
1431
|
* page that just loaded — it can only report the next change, which may be
|
|
1519
1432
|
* hours away. Both halves or neither.
|
|
1520
1433
|
*/
|
|
@@ -1536,9 +1449,9 @@ export const orgHealthQuery = z
|
|
|
1536
1449
|
})
|
|
1537
1450
|
.meta({ description: 'The optional robot filter of `GET /api/org/health`.' });
|
|
1538
1451
|
/**
|
|
1539
|
-
* Org protection quotas
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1452
|
+
* Org protection quotas — generous, server-side adjustable, visible in
|
|
1453
|
+
* settings. Protection against runaway use, not a business model; a later one
|
|
1454
|
+
* docks onto the same dials.
|
|
1542
1455
|
*/
|
|
1543
1456
|
export const orgQuotas = z.object({
|
|
1544
1457
|
max_robots: z.number().int().positive(),
|
|
@@ -1548,30 +1461,28 @@ export const orgQuotas = z.object({
|
|
|
1548
1461
|
max_retention_writes_per_minute: z.number().int().nonnegative(),
|
|
1549
1462
|
max_realtime_connections: z.number().int().positive(),
|
|
1550
1463
|
/**
|
|
1551
|
-
* Asset storage
|
|
1552
|
-
*
|
|
1553
|
-
*
|
|
1554
|
-
*
|
|
1464
|
+
* Asset storage — **its own dial, not part of `max_retention_bytes`.** A
|
|
1465
|
+
* sync grows storage in jumps and time series grow steadily; one dial would
|
|
1466
|
+
* let the first crowd out the second, and the org that hit its limit would be
|
|
1467
|
+
* told to look at the wrong thing.
|
|
1555
1468
|
*
|
|
1556
1469
|
* **Counted per distinct blob *this org references* — not per asset row, and
|
|
1557
|
-
* not per object the platform stores on its behalf
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1470
|
+
* not per object the platform stores on its behalf.** The two readings are
|
|
1471
|
+
* indistinguishable from the number alone and a customer is entitled to know
|
|
1472
|
+
* which one they are being charged for.
|
|
1560
1473
|
*
|
|
1561
1474
|
* Within an org, sharing is free: two robots referencing the same mesh cost
|
|
1562
1475
|
* one copy, which is what dedup means to a customer, and anything else
|
|
1563
1476
|
* charges an org twice for a fleet of identical robots — the normal case.
|
|
1564
1477
|
*
|
|
1565
|
-
* **Across orgs, sharing is not free
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
* first org to sync a blob pay for it forever while every later org stored
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1573
|
-
* which nobody can predict. Measured before the change: 342 bytes held by an
|
|
1574
|
-
* org owning no assets, with no operation able to free them.
|
|
1478
|
+
* **Across orgs, sharing is not free.** Storage stays globally
|
|
1479
|
+
* content-addressed (one object per sha256; that efficiency is real), but
|
|
1480
|
+
* accounting is per-org: an org is charged for each distinct blob it
|
|
1481
|
+
* references and credited when its own last reference goes, whether or not
|
|
1482
|
+
* the blob survives for somebody else. Global refcounting would make the
|
|
1483
|
+
* first org to sync a blob pay for it forever while every later org stored it
|
|
1484
|
+
* free — a quota evadable by anyone whose mesh someone else had already
|
|
1485
|
+
* uploaded, and an org's own number would depend on who got there first.
|
|
1575
1486
|
*/
|
|
1576
1487
|
max_asset_storage_bytes: z.number().int().nonnegative(),
|
|
1577
1488
|
});
|
|
@@ -1583,8 +1494,7 @@ export const orgQuotas = z.object({
|
|
|
1583
1494
|
* `positive()` because a quota of zero would forbid everything, but a
|
|
1584
1495
|
* **usage** of zero is the honest answer for every org on the day it signs
|
|
1585
1496
|
* up. Reusing one schema for a limit and a measurement is the same mistake as
|
|
1586
|
-
* letting an empty bucket and a zero average share a representation
|
|
1587
|
-
* this wave spent a lot of care avoiding one layer up.
|
|
1497
|
+
* letting an empty bucket and a zero average share a representation.
|
|
1588
1498
|
*
|
|
1589
1499
|
* Every field is optional because a quota we do not measure must be
|
|
1590
1500
|
* **absent**, never reported as `0` — "not measured" and "measured as zero"
|
|
@@ -1625,11 +1535,9 @@ export const BRIDGE_LATENCY_RETENTION_DAYS = 7;
|
|
|
1625
1535
|
* | `GET /api/org/latency` | `orgLatencyQuery` | `orgLatencyResponse` — one series per robot, truncation named |
|
|
1626
1536
|
* | `GET /api/robots/:id/jobs/history` | `jobRunQuery` | `jobRunListResponse` — the same read, robot-scoped, developers **and** clients |
|
|
1627
1537
|
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
* reason. The shapes landed one wave before the routes did, so this table is
|
|
1632
|
-
* the only place the two halves meet.
|
|
1538
|
+
* The paths are written down beside the shapes, as in
|
|
1539
|
+
* `robotDeletionSummary`'s neighbouring table: a shape whose route is not
|
|
1540
|
+
* named here is a fact that lives only in somebody's memory.
|
|
1633
1541
|
*
|
|
1634
1542
|
* Three things about them are worth stating rather than inferring:
|
|
1635
1543
|
*
|
|
@@ -1721,7 +1629,7 @@ export const robotLatencySeries = z.object({
|
|
|
1721
1629
|
export const orgLatencyQuery = z
|
|
1722
1630
|
.object({
|
|
1723
1631
|
from_ms: wireTimestampMs,
|
|
1724
|
-
/** Exclusive — half-open `[from, to)`, the convention every other query here already follows
|
|
1632
|
+
/** Exclusive — half-open `[from, to)`, the convention every other query here already follows. */
|
|
1725
1633
|
to_ms: wireTimestampMs,
|
|
1726
1634
|
/**
|
|
1727
1635
|
* One robot's own sparkline. `z.uuid()`, because the column is one —
|
|
@@ -1775,7 +1683,7 @@ export const orgLatencyResponse = z.object({
|
|
|
1775
1683
|
*/
|
|
1776
1684
|
export const USAGE_WINDOW_MAX_DAYS = 366;
|
|
1777
1685
|
/**
|
|
1778
|
-
* The five things the meter records
|
|
1686
|
+
* The five things the meter records.
|
|
1779
1687
|
*
|
|
1780
1688
|
* Storage is two metrics and not one summed byte count, for
|
|
1781
1689
|
* `org_quotas.max_asset_storage_bytes`'s own reason applied to billing: a sync
|
|
@@ -1816,7 +1724,7 @@ export const usageDay = z
|
|
|
1816
1724
|
}, { message: 'must be a UTC calendar day, YYYY-MM-DD' });
|
|
1817
1725
|
/**
|
|
1818
1726
|
* **The window is inclusive at both ends**, unlike every millisecond window in
|
|
1819
|
-
* this file (`from_ms`/`to_ms`, half-open
|
|
1727
|
+
* this file (`from_ms`/`to_ms`, which are half-open).
|
|
1820
1728
|
*
|
|
1821
1729
|
* That inconsistency is deliberate and is stated here rather than left to be
|
|
1822
1730
|
* discovered: a calendar day is a unit, not an instant, and a person asking for
|
|
@@ -1855,7 +1763,7 @@ export const orgUsageQuery = z
|
|
|
1855
1763
|
/**
|
|
1856
1764
|
* One day's reading for one metric.
|
|
1857
1765
|
*
|
|
1858
|
-
* **`app_id` is `null` when the consumer is the org itself
|
|
1766
|
+
* **`app_id` is `null` when the consumer is the org itself**, and
|
|
1859
1767
|
* what that `null` means for billing depends on the *metric*, not on
|
|
1860
1768
|
* `app_id` alone. `api_calls` and `live_session_ms` are attributable to an
|
|
1861
1769
|
* app: a `null` app_id on those two is the developer console's own traffic,
|
|
@@ -1884,9 +1792,8 @@ export const orgUsageQuery = z
|
|
|
1884
1792
|
* not hold at all: everything counted since the last successful flush is
|
|
1885
1793
|
* held in memory, deliberately uncapped, and a `kill -9` loses all of it.
|
|
1886
1794
|
* The trade is intentional (dropping billing data to bound process memory is
|
|
1887
|
-
* the worse half of it), but "at most one interval" describes a platform
|
|
1888
|
-
*
|
|
1889
|
-
* sentence used to say "never more", and it was false.
|
|
1795
|
+
* the worse half of it), but "at most one interval" describes a platform whose
|
|
1796
|
+
* writes are landing, not a guarantee that survives an outage.
|
|
1890
1797
|
*
|
|
1891
1798
|
* A row the database rejects **permanently** — most concretely one whose org
|
|
1892
1799
|
* has been deleted since the count, since a usage row's `org_id` is `ON
|
|
@@ -1949,11 +1856,10 @@ export const renameSlugResponse = z.object({
|
|
|
1949
1856
|
* `GET /api/robots/:id/config/slug-usage/:slug` — what a rename would touch;
|
|
1950
1857
|
* feeds the console's confirm dialog.
|
|
1951
1858
|
*
|
|
1952
|
-
* `alert_count`
|
|
1953
|
-
*
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
* developer a smaller blast radius than the rename actually has.
|
|
1859
|
+
* `alert_count` is part of the atomic rename transaction alongside grants and
|
|
1860
|
+
* history: alerts are keyed by `(robot_id, slug)` too, and a rename that
|
|
1861
|
+
* silently moved the alert row while the usage preview stayed silent about it
|
|
1862
|
+
* would show a developer a smaller blast radius than the rename actually has.
|
|
1957
1863
|
*/
|
|
1958
1864
|
export const slugUsageResponse = z.object({
|
|
1959
1865
|
grant_count: z.number().int().nonnegative(),
|