@fleetless/contracts 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 +26 -0
- package/LICENSE +202 -0
- package/NOTICE +17 -0
- package/README.md +88 -0
- package/artifacts/constants.json +24 -0
- package/artifacts/openapi.json +17219 -0
- package/artifacts/routes.json +4605 -0
- package/artifacts/schema/accept-team-invite-request.schema.json +22 -0
- package/artifacts/schema/action-config.schema.json +198 -0
- package/artifacts/schema/alert-list-response.schema.json +172 -0
- package/artifacts/schema/api-error.schema.json +20 -0
- package/artifacts/schema/app-auth-config.schema.json +106 -0
- package/artifacts/schema/app-invitation-list-response.schema.json +57 -0
- package/artifacts/schema/app-invitation.schema.json +69 -0
- package/artifacts/schema/app-list-response.schema.json +82 -0
- package/artifacts/schema/app-mail-template-list-response.schema.json +68 -0
- package/artifacts/schema/app-mail-template.schema.json +54 -0
- package/artifacts/schema/app-oidc-provider-list-response.schema.json +93 -0
- package/artifacts/schema/app-oidc-provider.schema.json +80 -0
- package/artifacts/schema/app-user-list-response.schema.json +111 -0
- package/artifacts/schema/app-user.schema.json +98 -0
- package/artifacts/schema/app.schema.json +69 -0
- package/artifacts/schema/apply-error.schema.json +41 -0
- package/artifacts/schema/asset-list-response.schema.json +288 -0
- package/artifacts/schema/asset-sync-request.schema.json +17 -0
- package/artifacts/schema/asset-sync-response.schema.json +16 -0
- package/artifacts/schema/asset-sync-status.schema.json +136 -0
- package/artifacts/schema/asset.schema.json +68 -0
- package/artifacts/schema/audit-actor.schema.json +32 -0
- package/artifacts/schema/audit-event.schema.json +119 -0
- package/artifacts/schema/audit-list-response.schema.json +144 -0
- package/artifacts/schema/audit-query.schema.json +79 -0
- package/artifacts/schema/auth-error.schema.json +23 -0
- package/artifacts/schema/auth-me-response.schema.json +99 -0
- package/artifacts/schema/auth-ok.schema.json +115 -0
- package/artifacts/schema/authorization-server-metadata.schema.json +80 -0
- package/artifacts/schema/bridge-asset-progress.schema.json +99 -0
- package/artifacts/schema/bridge-assets-available.schema.json +25 -0
- package/artifacts/schema/bridge-camera-state.schema.json +78 -0
- package/artifacts/schema/bridge-config-applied.schema.json +67 -0
- package/artifacts/schema/bridge-hello.schema.json +65 -0
- package/artifacts/schema/bridge-introspect.schema.json +114 -0
- package/artifacts/schema/bridge-job-lost.schema.json +22 -0
- package/artifacts/schema/bridge-job-update.schema.json +100 -0
- package/artifacts/schema/bridge-pong.schema.json +19 -0
- package/artifacts/schema/bridge-pressure.schema.json +292 -0
- package/artifacts/schema/bridge-state.schema.json +24 -0
- package/artifacts/schema/bridge-type-definitions.schema.json +169 -0
- package/artifacts/schema/busy-details.schema.json +115 -0
- package/artifacts/schema/camera-descriptor.schema.json +45 -0
- package/artifacts/schema/camera-list-response.schema.json +58 -0
- package/artifacts/schema/camera-source.schema.json +240 -0
- package/artifacts/schema/cancel-request.schema.json +20 -0
- package/artifacts/schema/client-accept-invitation-request.schema.json +35 -0
- package/artifacts/schema/client-auth.schema.json +18 -0
- package/artifacts/schema/client-cancel.schema.json +45 -0
- package/artifacts/schema/client-identity.schema.json +103 -0
- package/artifacts/schema/client-invoke.schema.json +45 -0
- package/artifacts/schema/client-login-request.schema.json +29 -0
- package/artifacts/schema/client-logout-request.schema.json +14 -0
- package/artifacts/schema/client-mcp-interaction-decision-response.schema.json +15 -0
- package/artifacts/schema/client-mcp-interaction.schema.json +59 -0
- package/artifacts/schema/client-oidc-callback-query.schema.json +28 -0
- package/artifacts/schema/client-oidc-exchange-request.schema.json +21 -0
- package/artifacts/schema/client-oidc-start-query.schema.json +36 -0
- package/artifacts/schema/client-password-reset-confirm-request.schema.json +22 -0
- package/artifacts/schema/client-password-reset-request.schema.json +24 -0
- package/artifacts/schema/client-provider-list-query.schema.json +17 -0
- package/artifacts/schema/client-provider-list-response.schema.json +34 -0
- package/artifacts/schema/client-publish.schema.json +40 -0
- package/artifacts/schema/client-refresh-request.schema.json +14 -0
- package/artifacts/schema/client-register-request.schema.json +44 -0
- package/artifacts/schema/client-resend-verification-request.schema.json +24 -0
- package/artifacts/schema/client-subscribe.schema.json +43 -0
- package/artifacts/schema/client-unsubscribe.schema.json +26 -0
- package/artifacts/schema/client-verify-email-request.schema.json +15 -0
- package/artifacts/schema/cloud-asset-request.schema.json +37 -0
- package/artifacts/schema/cloud-camera-start.schema.json +41 -0
- package/artifacts/schema/cloud-camera-stop.schema.json +26 -0
- package/artifacts/schema/cloud-cancel.schema.json +33 -0
- package/artifacts/schema/cloud-config.schema.json +1635 -0
- package/artifacts/schema/cloud-hello-error.schema.json +23 -0
- package/artifacts/schema/cloud-hello-ok.schema.json +19 -0
- package/artifacts/schema/cloud-introspect-request.schema.json +19 -0
- package/artifacts/schema/cloud-invoke.schema.json +40 -0
- package/artifacts/schema/cloud-ping.schema.json +19 -0
- package/artifacts/schema/cloud-publish.schema.json +28 -0
- package/artifacts/schema/cloud-type-request.schema.json +30 -0
- package/artifacts/schema/command-result.schema.json +175 -0
- package/artifacts/schema/config-draft-response.schema.json +1695 -0
- package/artifacts/schema/config-state.schema.json +124 -0
- package/artifacts/schema/config-version-response.schema.json +1641 -0
- package/artifacts/schema/config-versions-response.schema.json +33 -0
- package/artifacts/schema/create-app-invitation-request.schema.json +40 -0
- package/artifacts/schema/create-app-oidc-provider-request.schema.json +70 -0
- package/artifacts/schema/create-app-request.schema.json +30 -0
- package/artifacts/schema/create-app-user-request.schema.json +42 -0
- package/artifacts/schema/create-robot-request.schema.json +14 -0
- package/artifacts/schema/create-robot-response.schema.json +44 -0
- package/artifacts/schema/create-server-key-response.schema.json +65 -0
- package/artifacts/schema/create-team-invite-request.schema.json +43 -0
- package/artifacts/schema/datapoint-alert-row.schema.json +160 -0
- package/artifacts/schema/datapoint-config.schema.json +366 -0
- package/artifacts/schema/datapoint-display.schema.json +31 -0
- package/artifacts/schema/datapoint-event.schema.json +34 -0
- package/artifacts/schema/datapoint-frame.schema.json +28 -0
- package/artifacts/schema/datapoint-list-response.schema.json +61 -0
- package/artifacts/schema/datapoint-value.schema.json +28 -0
- package/artifacts/schema/developer-login-request.schema.json +19 -0
- package/artifacts/schema/dynamic-client-registration-request.schema.json +60 -0
- package/artifacts/schema/dynamic-client-registration-response.schema.json +68 -0
- package/artifacts/schema/error-frame.schema.json +23 -0
- package/artifacts/schema/exposure-counts.schema.json +39 -0
- package/artifacts/schema/exposure-list-response.schema.json +43 -0
- package/artifacts/schema/fetch-types-request.schema.json +19 -0
- package/artifacts/schema/fetch-types-response.schema.json +163 -0
- package/artifacts/schema/fleetless-user-list-response.schema.json +73 -0
- package/artifacts/schema/fleetless-user.schema.json +60 -0
- package/artifacts/schema/history-buckets-response.schema.json +79 -0
- package/artifacts/schema/history-query.schema.json +58 -0
- package/artifacts/schema/history-response.schema.json +150 -0
- package/artifacts/schema/history-samples-response.schema.json +68 -0
- package/artifacts/schema/introspection-response.schema.json +118 -0
- package/artifacts/schema/invoke-or-service-response.schema.json +141 -0
- package/artifacts/schema/invoke-request.schema.json +23 -0
- package/artifacts/schema/invoke-response.schema.json +125 -0
- package/artifacts/schema/job-actor.schema.json +34 -0
- package/artifacts/schema/job-event.schema.json +158 -0
- package/artifacts/schema/job-response.schema.json +123 -0
- package/artifacts/schema/job-run-list-response.schema.json +222 -0
- package/artifacts/schema/job-run-query.schema.json +95 -0
- package/artifacts/schema/job-run-summary-query.schema.json +23 -0
- package/artifacts/schema/job-run-summary.schema.json +33 -0
- package/artifacts/schema/job-run.schema.json +195 -0
- package/artifacts/schema/job-state.schema.json +11 -0
- package/artifacts/schema/job.schema.json +106 -0
- package/artifacts/schema/latency-bucket.schema.json +63 -0
- package/artifacts/schema/live-session-response.schema.json +41 -0
- package/artifacts/schema/mail-outcome.schema.json +20 -0
- package/artifacts/schema/mail-template-preview-request.schema.json +35 -0
- package/artifacts/schema/mail-template-preview-response.schema.json +31 -0
- package/artifacts/schema/mail-template-problem-details.schema.json +24 -0
- package/artifacts/schema/mcp-consent-grant-list-response.schema.json +52 -0
- package/artifacts/schema/mcp-consent-grant.schema.json +39 -0
- package/artifacts/schema/mcp-robot-datasheet.schema.json +115 -0
- package/artifacts/schema/mcp-role-preview-response.schema.json +134 -0
- package/artifacts/schema/missing-asset-query.schema.json +11 -0
- package/artifacts/schema/oauth-authorize-query.schema.json +47 -0
- package/artifacts/schema/oauth-redirect-response.schema.json +15 -0
- package/artifacts/schema/oauth-token-request.schema.json +47 -0
- package/artifacts/schema/oauth-token-response.schema.json +38 -0
- package/artifacts/schema/org-alerts-query.schema.json +15 -0
- package/artifacts/schema/org-event-dropped.schema.json +26 -0
- package/artifacts/schema/org-event-replay.schema.json +97 -0
- package/artifacts/schema/org-event-subscribe.schema.json +14 -0
- package/artifacts/schema/org-event-unsubscribe.schema.json +14 -0
- package/artifacts/schema/org-event.schema.json +75 -0
- package/artifacts/schema/org-firing-alerts-response.schema.json +178 -0
- package/artifacts/schema/org-health-query.schema.json +13 -0
- package/artifacts/schema/org-latency-query.schema.json +42 -0
- package/artifacts/schema/org-latency-response.schema.json +124 -0
- package/artifacts/schema/org-quota-usage-counts.schema.json +42 -0
- package/artifacts/schema/org-quota-usage.schema.json +102 -0
- package/artifacts/schema/org-quotas.schema.json +51 -0
- package/artifacts/schema/org-usage-query.schema.json +19 -0
- package/artifacts/schema/org-usage-response.schema.json +77 -0
- package/artifacts/schema/org.schema.json +30 -0
- package/artifacts/schema/parameter-invalid-details.schema.json +37 -0
- package/artifacts/schema/parameter-spec.schema.json +120 -0
- package/artifacts/schema/parameter-violation.schema.json +24 -0
- package/artifacts/schema/password-change-request.schema.json +21 -0
- package/artifacts/schema/password-reset-confirm.schema.json +19 -0
- package/artifacts/schema/password-reset-request.schema.json +14 -0
- package/artifacts/schema/patch-app-oidc-provider-request.schema.json +50 -0
- package/artifacts/schema/patch-app-user-request.schema.json +34 -0
- package/artifacts/schema/patch-auth-me-request.schema.json +22 -0
- package/artifacts/schema/patch-fleetless-user-request.schema.json +20 -0
- package/artifacts/schema/patch-org-request.schema.json +15 -0
- package/artifacts/schema/patch-org-response.schema.json +40 -0
- package/artifacts/schema/patch-robot-request.schema.json +15 -0
- package/artifacts/schema/patch-robot-response.schema.json +40 -0
- package/artifacts/schema/pending-team-invite-list-response.schema.json +52 -0
- package/artifacts/schema/pending-team-invite.schema.json +39 -0
- package/artifacts/schema/protected-resource-metadata.schema.json +41 -0
- package/artifacts/schema/publish-config-response.schema.json +21 -0
- package/artifacts/schema/publish-request.schema.json +17 -0
- package/artifacts/schema/publisher-config.schema.json +285 -0
- package/artifacts/schema/put-app-auth-config-request.schema.json +93 -0
- package/artifacts/schema/put-app-mail-template-request.schema.json +35 -0
- package/artifacts/schema/put-config-draft-request.schema.json +13 -0
- package/artifacts/schema/put-datapoint-display-request.schema.json +31 -0
- package/artifacts/schema/put-robot-details-request.schema.json +41 -0
- package/artifacts/schema/put-robot-details-response.schema.json +43 -0
- package/artifacts/schema/rate-limit-details.schema.json +15 -0
- package/artifacts/schema/refresh-request.schema.json +13 -0
- package/artifacts/schema/release-live-query.schema.json +13 -0
- package/artifacts/schema/rename-slug-request.schema.json +23 -0
- package/artifacts/schema/rename-slug-response.schema.json +24 -0
- package/artifacts/schema/resource-health-event.schema.json +72 -0
- package/artifacts/schema/resource-health-list-response.schema.json +80 -0
- package/artifacts/schema/resource-health-state.schema.json +68 -0
- package/artifacts/schema/robot-config-doc.schema.json +1616 -0
- package/artifacts/schema/robot-delete-query.schema.json +12 -0
- package/artifacts/schema/robot-deletion-summary.schema.json +63 -0
- package/artifacts/schema/robot-detail-response.schema.json +262 -0
- package/artifacts/schema/robot-details-doc.schema.json +33 -0
- package/artifacts/schema/robot-jobs-response.schema.json +119 -0
- package/artifacts/schema/robot-latency-series.schema.json +81 -0
- package/artifacts/schema/robot-list-item.schema.json +94 -0
- package/artifacts/schema/robot-list-response.schema.json +106 -0
- package/artifacts/schema/robot.schema.json +30 -0
- package/artifacts/schema/role-list-response.schema.json +48 -0
- package/artifacts/schema/role-permissions.schema.json +61 -0
- package/artifacts/schema/role.schema.json +35 -0
- package/artifacts/schema/ros-graph.schema.json +99 -0
- package/artifacts/schema/server-key-list-response.schema.json +64 -0
- package/artifacts/schema/server-key.schema.json +51 -0
- package/artifacts/schema/service-call-response.schema.json +13 -0
- package/artifacts/schema/service-config.schema.json +198 -0
- package/artifacts/schema/session-tokens.schema.json +28 -0
- package/artifacts/schema/sign-up-request.schema.json +26 -0
- package/artifacts/schema/sign-up-response.schema.json +127 -0
- package/artifacts/schema/slug-usage-response.schema.json +32 -0
- package/artifacts/schema/snapshot-header.schema.json +44 -0
- package/artifacts/schema/snapshot-meta-response.schema.json +85 -0
- package/artifacts/schema/subscribe-error.schema.json +31 -0
- package/artifacts/schema/team-invite.schema.json +57 -0
- package/artifacts/schema/tier-change-request.schema.json +17 -0
- package/artifacts/schema/type-definition.schema.json +144 -0
- package/artifacts/schema/types-response.schema.json +156 -0
- package/artifacts/schema/update-app-request.schema.json +32 -0
- package/artifacts/schema/urdf-completeness.schema.json +50 -0
- package/artifacts/schema/validation-issue.schema.json +43 -0
- package/artifacts/schema/waitlist-request.schema.json +15 -0
- package/artifacts/schema-outgoing/bridge-asset-progress.schema.json +102 -0
- package/artifacts/schema-outgoing/bridge-assets-available.schema.json +26 -0
- package/artifacts/schema-outgoing/bridge-camera-state.schema.json +80 -0
- package/artifacts/schema-outgoing/bridge-config-applied.schema.json +69 -0
- package/artifacts/schema-outgoing/bridge-hello.schema.json +68 -0
- package/artifacts/schema-outgoing/bridge-introspect.schema.json +119 -0
- package/artifacts/schema-outgoing/bridge-job-lost.schema.json +23 -0
- package/artifacts/schema-outgoing/bridge-job-update.schema.json +102 -0
- package/artifacts/schema-outgoing/bridge-pong.schema.json +20 -0
- package/artifacts/schema-outgoing/bridge-type-definitions.schema.json +174 -0
- package/artifacts/schema-outgoing/datapoint-frame.schema.json +29 -0
- package/artifacts/schema-outgoing/snapshot-header.schema.json +45 -0
- package/dist/alerts.d.ts +255 -0
- package/dist/alerts.js +193 -0
- package/dist/app-users.d.ts +606 -0
- package/dist/app-users.js +696 -0
- package/dist/apps.d.ts +175 -0
- package/dist/apps.js +267 -0
- package/dist/assets.d.ts +434 -0
- package/dist/assets.js +546 -0
- package/dist/audit.d.ts +129 -0
- package/dist/audit.js +238 -0
- package/dist/client-auth.d.ts +409 -0
- package/dist/client-auth.js +487 -0
- package/dist/common.d.ts +186 -0
- package/dist/common.js +199 -0
- package/dist/config-issues.d.ts +175 -0
- package/dist/config-issues.js +339 -0
- package/dist/config.d.ts +862 -0
- package/dist/config.js +1988 -0
- package/dist/errors.d.ts +52 -0
- package/dist/errors.js +786 -0
- package/dist/identity.d.ts +549 -0
- package/dist/identity.js +503 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/introspection.d.ts +99 -0
- package/dist/introspection.js +97 -0
- package/dist/jobs.d.ts +334 -0
- package/dist/jobs.js +345 -0
- package/dist/mcp.d.ts +239 -0
- package/dist/mcp.js +153 -0
- package/dist/oauth.d.ts +344 -0
- package/dist/oauth.js +488 -0
- package/dist/protocol.d.ts +781 -0
- package/dist/protocol.js +715 -0
- package/dist/realtime.d.ts +494 -0
- package/dist/realtime.js +512 -0
- package/dist/rest.d.ts +1989 -0
- package/dist/rest.js +1963 -0
- package/dist/routes.d.ts +94 -0
- package/dist/routes.js +2298 -0
- package/package.json +61 -0
package/dist/rest.js
ADDED
|
@@ -0,0 +1,1963 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { bridgeState, MAX_PATIENCE_MS, MIN_PATIENCE_MS } from './protocol.js';
|
|
4
|
+
import { slug, rosTypeName, wireTimestampMs } from './common.js';
|
|
5
|
+
import { configState, rateThrottleHz, robotConfigDoc, snapshotIntervalSeconds, validationIssue } from './config.js';
|
|
6
|
+
import { rosGraph, typeDefinition } from './introspection.js';
|
|
7
|
+
import { job } from './jobs.js';
|
|
8
|
+
/**
|
|
9
|
+
* REST shapes of the robot resource (spec §11.1). W1 scope: create, list,
|
|
10
|
+
* get, and the built-in `bridge_state` datapoint read.
|
|
11
|
+
*/
|
|
12
|
+
export const robot = z.object({
|
|
13
|
+
id: z.uuid().meta({
|
|
14
|
+
description: 'The robot, and what every robot-scoped route takes as its `:id`.',
|
|
15
|
+
}),
|
|
16
|
+
name: z.string().min(1).max(63).meta({
|
|
17
|
+
description: 'The robot\'s display name, at most 63 characters. Free text, changed through `PATCH /api/robots/:id`.',
|
|
18
|
+
}),
|
|
19
|
+
created_at: z.iso.datetime().meta({
|
|
20
|
+
description: 'When the robot was created, as an ISO 8601 timestamp.',
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
/** What `PATCH /api/robots/:id` answers: the robot as it now stands. */
|
|
24
|
+
export const patchRobotResponse = z.object({
|
|
25
|
+
robot: robot.meta({
|
|
26
|
+
description: 'The robot as it now stands, after the patch was applied. The whole resource comes back, not only the fields that changed.',
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
export const createRobotRequest = z.object({
|
|
30
|
+
name: z.string().min(1).max(63),
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* The robot token binds one bridge to one robot (spec §5). It is returned
|
|
34
|
+
* exactly once, here; the cloud stores only a hash of it.
|
|
35
|
+
*/
|
|
36
|
+
export const robotToken = z.string().regex(/^frt_[0-9a-f]{32}$/);
|
|
37
|
+
export const createRobotResponse = z.object({
|
|
38
|
+
robot,
|
|
39
|
+
token: robotToken,
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* How many things a robot exposes, per kind (spec `2026-08-21-exposure-and-revoke-design` D1).
|
|
43
|
+
*
|
|
44
|
+
* **Five numbers, never a sum.** `robotDeletionSummary.slug_count` already made
|
|
45
|
+
* this call and wrote down why: fold cameras in and the sentence "this deletes
|
|
46
|
+
* N slugs and M cameras" counts them twice. A list row has the same problem.
|
|
47
|
+
*
|
|
48
|
+
* **Counted from the published configuration, and excluding the built-ins.**
|
|
49
|
+
* `GET /api/robots/:id/exposures` answers *which* slugs and prepends the
|
|
50
|
+
* three built-in datapoints — `bridge_state`, `robot_details` and
|
|
51
|
+
* `bridge_pressure` — as `builtin: true`; this answers *how many* and counts
|
|
52
|
+
* only what somebody configured. So a robot with an empty published config
|
|
53
|
+
* reports `datapoints: 0` here and three entries there. That is intentional,
|
|
54
|
+
* and it is written on both sides so the disagreement is never mistaken for a
|
|
55
|
+
* bug.
|
|
56
|
+
*
|
|
57
|
+
* The number is "three" and not "two" as of `bridge_pressure`; the cloud
|
|
58
|
+
* builds that prefix from `PLANE_BUILTIN_DATAPOINTS` rather than a literal,
|
|
59
|
+
* so a further built-in moves this count again. Read the count off that set,
|
|
60
|
+
* not off this sentence, before filing the bug this comment exists to
|
|
61
|
+
* prevent.
|
|
62
|
+
*/
|
|
63
|
+
export const exposureCounts = z.object({
|
|
64
|
+
datapoints: z.number().int().nonnegative(),
|
|
65
|
+
actions: z.number().int().nonnegative(),
|
|
66
|
+
services: z.number().int().nonnegative(),
|
|
67
|
+
publishers: z.number().int().nonnegative(),
|
|
68
|
+
cameras: z.number().int().nonnegative(),
|
|
69
|
+
});
|
|
70
|
+
/** A robot as listed, with its current built-in `bridge_state`. */
|
|
71
|
+
export const robotListItem = z.object({
|
|
72
|
+
...robot.shape,
|
|
73
|
+
bridge_state: bridgeState,
|
|
74
|
+
/** Required, not optional: "we did not look" and "it exposes nothing" must not render the same. */
|
|
75
|
+
exposes: exposureCounts,
|
|
76
|
+
});
|
|
77
|
+
export const robotListResponse = z.object({
|
|
78
|
+
robots: z.array(robotListItem),
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* The REST read of one datapoint. For bridge-captured data `timestamp_ms`
|
|
82
|
+
* is the capture time at the bridge (spec §6.3); for the cloud-observed
|
|
83
|
+
* built-in `bridge_state` it is the time the cloud observed the state.
|
|
84
|
+
*/
|
|
85
|
+
export const datapointValue = z.object({
|
|
86
|
+
slug: slug.meta({ description: 'The datapoint this value belongs to.' }),
|
|
87
|
+
value: z.unknown().meta({
|
|
88
|
+
description: 'The value itself, shaped by the datapoint: a number, a boolean, a string, or the whole ROS message where the configuration names no field inside it. Any `scale` and `offset` the configuration declares have already been applied, at the robot.',
|
|
89
|
+
}),
|
|
90
|
+
timestamp_ms: z.number().int().nonnegative().meta({
|
|
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
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
/* ------------------------------------------------------------------ W2 --
|
|
95
|
+
* Exposure: the configuration resource, introspection, types, and the
|
|
96
|
+
* datapoint surface generated from the published configuration (spec §4,
|
|
97
|
+
* §11.2).
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* One robot in full: what the list shows, plus what only the detail view
|
|
101
|
+
* needs — which bridge build is connected, why the last hello was refused,
|
|
102
|
+
* and where the configuration stands (spec §15.2, tab 1).
|
|
103
|
+
*/
|
|
104
|
+
export const robotDetailResponse = z.object({
|
|
105
|
+
...robotListItem.shape,
|
|
106
|
+
bridge_version: z.string().min(1).nullable(),
|
|
107
|
+
/**
|
|
108
|
+
* Cleared (set back to null) by the next successful hello from this
|
|
109
|
+
* robot's bridge — a warning that outlives the condition it warns
|
|
110
|
+
* about would be read as current state, and was.
|
|
111
|
+
*/
|
|
112
|
+
last_hello_error: z
|
|
113
|
+
.object({
|
|
114
|
+
code: z.string().min(1),
|
|
115
|
+
message: z.string().min(1),
|
|
116
|
+
at: z.iso.datetime(),
|
|
117
|
+
})
|
|
118
|
+
.nullable(),
|
|
119
|
+
config: configState,
|
|
120
|
+
});
|
|
121
|
+
/**
|
|
122
|
+
* The editable configuration. `issues` is recomputed on every read and
|
|
123
|
+
* write, so the editor never has to guess whether it may publish.
|
|
124
|
+
*
|
|
125
|
+
* **`source` is the author's text and `doc` is what it parses to.** Both are
|
|
126
|
+
* sent because they answer different questions: an editor renders the text a
|
|
127
|
+
* developer wrote, comments and key order intact, while every other consumer —
|
|
128
|
+
* the robot page, the MCP tools, the bridge frame — reads the parsed document
|
|
129
|
+
* and should never have to parse YAML to do it.
|
|
130
|
+
*
|
|
131
|
+
* **`doc` is null when the text is valid YAML but not a fleetless document.**
|
|
132
|
+
* A draft is saved whenever it parses as YAML; publish is the gate that asks
|
|
133
|
+
* for a document. So a stored draft can genuinely have no document, and `null`
|
|
134
|
+
* says exactly that: *this text does not currently parse to a configuration*.
|
|
135
|
+
* It does **not** mean "nothing is configured" — the last published version is
|
|
136
|
+
* untouched — and a reader that renders a tree from `doc` has to tell those two
|
|
137
|
+
* apart before it draws anything.
|
|
138
|
+
*
|
|
139
|
+
* The alternative was to put the raw parsed YAML value in `doc`. It was
|
|
140
|
+
* rejected because a reader could then no longer tell whether what it holds is
|
|
141
|
+
* a document: every consumer would have to re-validate to find out, and the one
|
|
142
|
+
* that forgot would render a stranger's mapping as a configuration. `null`
|
|
143
|
+
* forces the question at the point of reading.
|
|
144
|
+
*
|
|
145
|
+
* `source` is never null, and that is what makes the pair `doc: null,
|
|
146
|
+
* source: null` unrepresentable here rather than merely discouraged. A draft
|
|
147
|
+
* exists from the moment a robot does, before anyone has typed anything; for
|
|
148
|
+
* that one the server renders the document instead, so a reader always has text
|
|
149
|
+
* to show and — when there is no document — always has the text that failed to
|
|
150
|
+
* become one.
|
|
151
|
+
*/
|
|
152
|
+
export const configDraftResponse = z.object({
|
|
153
|
+
doc: robotConfigDoc.nullable(),
|
|
154
|
+
source: z.string(),
|
|
155
|
+
updated_at: z.iso.datetime().nullable(),
|
|
156
|
+
issues: z.array(validationIssue),
|
|
157
|
+
});
|
|
158
|
+
/**
|
|
159
|
+
* A write carries the **text only**, and that is the point.
|
|
160
|
+
*
|
|
161
|
+
* If it carried both the text and the parsed document, the two could
|
|
162
|
+
* disagree. Sending only the source makes that unrepresentable on the wire:
|
|
163
|
+
* the server parses it, and there is exactly one account of what the
|
|
164
|
+
* configuration says.
|
|
165
|
+
*
|
|
166
|
+
* It also settles who owns parsing, and **FL-005 D2 moved that line**. The
|
|
167
|
+
* sentence here used to read that the console refuses unparsable YAML before it
|
|
168
|
+
* sends, so a syntax error never reaches the server. That is no longer the
|
|
169
|
+
* rule: the **server** refuses text that is not valid YAML, with the line and
|
|
170
|
+
* column, and stores everything else — including valid YAML that is not a
|
|
171
|
+
* fleetless document, which comes back with `doc: null` and its issues. The
|
|
172
|
+
* console checks as you type so the answer is immediate; the server checks
|
|
173
|
+
* because it is the one that decides. Two checks of one question, and the
|
|
174
|
+
* server's is the one that binds.
|
|
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.
|
|
179
|
+
*/
|
|
180
|
+
export const putConfigDraftRequest = z.object({ source: z.string().max(1_000_000) });
|
|
181
|
+
/** Publishing freezes the draft into the next immutable version. */
|
|
182
|
+
export const publishConfigResponse = z.object({
|
|
183
|
+
version: z.number().int().positive(),
|
|
184
|
+
published_at: z.iso.datetime(),
|
|
185
|
+
});
|
|
186
|
+
export const configVersionsResponse = z.object({
|
|
187
|
+
versions: z.array(z.object({
|
|
188
|
+
version: z.number().int().positive(),
|
|
189
|
+
published_at: z.iso.datetime(),
|
|
190
|
+
})),
|
|
191
|
+
});
|
|
192
|
+
/**
|
|
193
|
+
* One published version, with the text it was published from.
|
|
194
|
+
*
|
|
195
|
+
* The text is what makes a version diff readable and a restore honest: a
|
|
196
|
+
* restore that returned only the document would hand back a configuration
|
|
197
|
+
* stripped of every comment the author wrote, which is the loss this format
|
|
198
|
+
* exists to prevent.
|
|
199
|
+
*/
|
|
200
|
+
export const configVersionResponse = z.object({
|
|
201
|
+
version: z.number().int().positive(),
|
|
202
|
+
published_at: z.iso.datetime(),
|
|
203
|
+
doc: robotConfigDoc,
|
|
204
|
+
source: z.string(),
|
|
205
|
+
});
|
|
206
|
+
/**
|
|
207
|
+
* The cached ROS graph. It survives the bridge going offline on purpose —
|
|
208
|
+
* a developer keeps configuring while the robot is off; `stale` says the
|
|
209
|
+
* bridge is not connected right now, `fetched_at` how old the picture is.
|
|
210
|
+
*/
|
|
211
|
+
export const introspectionResponse = z.object({
|
|
212
|
+
graph: rosGraph,
|
|
213
|
+
fetched_at: z.iso.datetime(),
|
|
214
|
+
stale: z.boolean(),
|
|
215
|
+
});
|
|
216
|
+
export const typesResponse = z.object({
|
|
217
|
+
types: z.array(typeDefinition),
|
|
218
|
+
});
|
|
219
|
+
/** Fetch (and store) type definitions for this robot from its bridge. */
|
|
220
|
+
export const fetchTypesRequest = z.object({
|
|
221
|
+
type_names: z.array(rosTypeName).min(1).max(50),
|
|
222
|
+
});
|
|
223
|
+
export const fetchTypesResponse = z.object({
|
|
224
|
+
types: z.array(typeDefinition),
|
|
225
|
+
unresolved: z.array(z.string()),
|
|
226
|
+
});
|
|
227
|
+
/**
|
|
228
|
+
* What a client can read on this robot: the built-ins plus everything the
|
|
229
|
+
* published configuration exposes. This is the seed of the generated
|
|
230
|
+
* per-robot API (§11.2).
|
|
231
|
+
*
|
|
232
|
+
* **The OpenAPI rendering exists since the route manifest (`routes.ts`):
|
|
233
|
+
* `artifacts/openapi.json`, derived from the manifest and these schemas by
|
|
234
|
+
* `scripts/export-schemas.ts`.**
|
|
235
|
+
*/
|
|
236
|
+
export const datapointDescriptor = z.object({
|
|
237
|
+
slug: slug.meta({ description: 'The name a client reads this datapoint by.' }),
|
|
238
|
+
builtin: z.boolean().meta({
|
|
239
|
+
description: '`true` for the datapoints every robot has — `bridge_state`, `robot_details` and `bridge_pressure` — and `false` for everything the published configuration adds.',
|
|
240
|
+
}),
|
|
241
|
+
unit: z.string().nullable().meta({
|
|
242
|
+
description: 'The unit the value carries **after** any scale and offset, shown beside the number so nobody has to guess whether `15` means percent, volts or minutes. `null` when the configuration names none.',
|
|
243
|
+
}),
|
|
244
|
+
/**
|
|
245
|
+
* `null` for a built-in and for a datapoint published with no throttle —
|
|
246
|
+
* the same "no ceiling configured" fact `datapointConfig.rate_throttle_hz`
|
|
247
|
+
* itself carries as `0` or absence, just re-spelled nullable rather than
|
|
248
|
+
* optional because this shape is a read response, not a document a caller
|
|
249
|
+
* writes. Reuses `rateThrottleHz` so the 20 Hz ceiling is written once.
|
|
250
|
+
*/
|
|
251
|
+
rate_throttle_hz: rateThrottleHz.nullable().meta({
|
|
252
|
+
description: 'The ceiling on how often this datapoint is sent, in hertz. `null` means no ceiling is configured, which is also the answer for every built-in. A ceiling, not a clock: a slow topic stays slow and no value is repeated to manufacture a rate.',
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
export const datapointListResponse = z.object({
|
|
256
|
+
datapoints: z.array(datapointDescriptor).meta({
|
|
257
|
+
description: 'Everything a client may read on this robot: the three built-ins, plus every datapoint the published configuration exposes and the caller\'s role grants.',
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
/**
|
|
261
|
+
* The built-in `robot_details` datapoint (spec §4.3): static properties the
|
|
262
|
+
* developer maintains. Bounded so one robot cannot become a document store.
|
|
263
|
+
*/
|
|
264
|
+
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
|
+
/** What `PUT /api/robots/:id/details` answers: the stored document, which is the one that was sent. */
|
|
266
|
+
export const putRobotDetailsResponse = z.object({
|
|
267
|
+
details: robotDetailsDoc.meta({
|
|
268
|
+
description: 'The stored `robot_details` document, which is the one that was just sent — this route **replaces** the document rather than merging into it. Keys are the developer\'s own, lowercase and at most 64 characters; a value is a string of at most 4096 characters, a number, a boolean, an array or an object.',
|
|
269
|
+
}),
|
|
270
|
+
});
|
|
271
|
+
export const putRobotDetailsRequest = z.object({ details: robotDetailsDoc });
|
|
272
|
+
/* ------------------------------------------------------------------ W4 --
|
|
273
|
+
* The command surface (spec §11.1, §11.3) and what a role may be granted.
|
|
274
|
+
*
|
|
275
|
+
* The routes, written down because cloud, console and SDK each need them and
|
|
276
|
+
* a body schema does not imply a path:
|
|
277
|
+
*
|
|
278
|
+
* | route | body | answers |
|
|
279
|
+
* |---|---|---|
|
|
280
|
+
* | `POST /api/robots/:id/jobs/:slug` | `invokeRequest` | `invokeResponse` (action) or `serviceCallResponse` (service) |
|
|
281
|
+
* | `GET /api/robots/:id/jobs/:slug` | — | `jobResponse` (the current job, or null) |
|
|
282
|
+
* | `POST /api/robots/:id/jobs/:slug/cancel` | `cancelRequest` | `jobResponse` |
|
|
283
|
+
* | `POST /api/robots/:id/publishers/:slug` | `publishRequest`| 204 |
|
|
284
|
+
* | `GET /api/robots/:id/exposures` | — | `exposureListResponse` |
|
|
285
|
+
*
|
|
286
|
+
* **Commands are addressed by slug, never by kind.** Slugs are one namespace
|
|
287
|
+
* across all kinds (§4.1) and a role grant is `{robot, slug}` with no kind in
|
|
288
|
+
* it — so a path segment naming the kind would demand a fact the permission
|
|
289
|
+
* model deliberately does not carry. The cloud already knows from the
|
|
290
|
+
* published configuration whether a slug is an action or a service; a caller
|
|
291
|
+
* who wants to know asks `/exposures`.
|
|
292
|
+
*
|
|
293
|
+
* That is also why invoking and calling are the same route: both create the
|
|
294
|
+
* job on that slug. They differ only in what the cloud waits for before it
|
|
295
|
+
* answers — a service call awaits the terminal update and returns the result
|
|
296
|
+
* inline, an action returns as soon as the job exists. Publishing is not a
|
|
297
|
+
* job and so is not under `/jobs`.
|
|
298
|
+
*/
|
|
299
|
+
/**
|
|
300
|
+
* Invoke an action or call a service; parameters by field path (§4.4).
|
|
301
|
+
*
|
|
302
|
+
* Flat, keyed by `parameterSpec.name` — see `cloudInvoke.params` for why the
|
|
303
|
+
* flat form is the one that makes a refusal legible.
|
|
304
|
+
*/
|
|
305
|
+
export const invokeRequest = z.object({
|
|
306
|
+
params: z.record(z.string(), z.unknown()).meta({
|
|
307
|
+
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
|
+
}),
|
|
309
|
+
/**
|
|
310
|
+
* How long **this call** is worth waiting for, in milliseconds (W6b).
|
|
311
|
+
*
|
|
312
|
+
* **Absent means `DEFAULT_PATIENCE_MS`** — today's behaviour, unchanged, for
|
|
313
|
+
* every caller who does not care. It is optional because most callers have
|
|
314
|
+
* no opinion, and forcing one on them would mean every SDK example carries a
|
|
315
|
+
* number its author guessed.
|
|
316
|
+
*
|
|
317
|
+
* It exists because patience was a **server constant** and could therefore
|
|
318
|
+
* only ever be wrong in one of two directions at a time: long enough for a
|
|
319
|
+
* planner meant a dead service also took that long to report, and short
|
|
320
|
+
* enough for a snappy lookup meant a legitimate slow job was reported as
|
|
321
|
+
* `bridge_timeout` — a healthy robot, described as broken, with nothing the
|
|
322
|
+
* caller could do about it.
|
|
323
|
+
*
|
|
324
|
+
* The number travels with the call to the bridge (`cloudInvoke.patience_ms`)
|
|
325
|
+
* so that **one** deadline governs both sides. Capped at
|
|
326
|
+
* `MAX_PATIENCE_MS`; above that the call is refused with
|
|
327
|
+
* `validation_error` rather than silently clamped, because a caller who
|
|
328
|
+
* asked for ten minutes and was quietly given two would read the timeout as
|
|
329
|
+
* the robot's failure.
|
|
330
|
+
*
|
|
331
|
+
* For a service call this is the whole wait. For an action it bounds goal
|
|
332
|
+
* *acceptance* — once a goal is accepted the job runs as long as it runs,
|
|
333
|
+
* and is observed, not awaited.
|
|
334
|
+
*/
|
|
335
|
+
patience_ms: z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS).optional().meta({
|
|
336
|
+
description: 'How long **this call** is worth waiting for, in milliseconds; absent means the platform default. The number travels to the robot too, so one deadline governs both sides. Above the maximum the call is refused rather than quietly clamped, because a caller given less than they asked for would read the timeout as the robot\'s failure.',
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
/**
|
|
340
|
+
* The answer to an invoke. The job id is informative (§11.3): state is
|
|
341
|
+
* observed by slug afterwards, over polling or a subscription.
|
|
342
|
+
*/
|
|
343
|
+
/**
|
|
344
|
+
* The body of a cancel (W6b). **Every field optional, and the body itself may
|
|
345
|
+
* be absent** — `POST .../cancel` was bodyless before this wave and every
|
|
346
|
+
* existing caller still sends nothing.
|
|
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.
|
|
354
|
+
*
|
|
355
|
+
* **`.strict()`, and that is the whole point of the shape.** A plain object
|
|
356
|
+
* strips unknown keys, so a caller who *means* to name a job and misspells the
|
|
357
|
+
* field — `jobId` for `job_id` — has their id silently removed and gets the
|
|
358
|
+
* **slug-wide** cancel instead: the most destructive reading of a request they
|
|
359
|
+
* did not make. Measured in W6b's review: `{"jobId": "<some other job>"}`
|
|
360
|
+
* answered `200` and stopped the job that was actually running, which nobody
|
|
361
|
+
* had named. The `?force=true` precedent this route's design borrowed from
|
|
362
|
+
* fails *safe* on a typo — a misspelled `force` simply does not force.
|
|
363
|
+
* Stripping here fails unsafe, so unknown keys are refused instead.
|
|
364
|
+
*
|
|
365
|
+
* `job_id` absent and `job_id: null` mean the **same** thing here, and that is
|
|
366
|
+
* deliberate: over REST an absent body is how every caller written before this
|
|
367
|
+
* wave says "cancel whatever is running". On the socket, `clientCancel.job_id`
|
|
368
|
+
* is required-and-nullable instead, because a frame is assembled fresh by a
|
|
369
|
+
* client that has already been updated — there, `null` is a decision and an
|
|
370
|
+
* omission is a bug.
|
|
371
|
+
*/
|
|
372
|
+
export const cancelRequest = z.object({
|
|
373
|
+
job_id: z.uuid().nullable().optional().meta({
|
|
374
|
+
description: 'The one job to stop. Absent or `null` cancels whatever is currently running on the slug, which is what every caller written before this field existed means. Unknown keys are refused rather than stripped, so a misspelling cannot silently become the slug-wide cancel.',
|
|
375
|
+
}),
|
|
376
|
+
}).strict();
|
|
377
|
+
/**
|
|
378
|
+
* The query of a live release (W6b): `DELETE .../live?session_id=<uuid>`.
|
|
379
|
+
*
|
|
380
|
+
* A query parameter rather than a body, following `?force=true` on robot
|
|
381
|
+
* deletion — the precedent this repo already set for "a DELETE that needs one
|
|
382
|
+
* more fact". A body on a DELETE is carried inconsistently by proxies and by
|
|
383
|
+
* `fetch` itself, and this call runs from a browser tab that is often closing.
|
|
384
|
+
*
|
|
385
|
+
* **`.strict()`, for the reason `cancelRequest` is** — `?sessionid=` instead of
|
|
386
|
+
* `?session_id=` was measured releasing **both** of an identity's holds and
|
|
387
|
+
* stranding the other tab, which is precisely the defect this field was added
|
|
388
|
+
* to remove. A refused typo costs a round trip; a stripped one stops a robot
|
|
389
|
+
* somebody else is watching.
|
|
390
|
+
*
|
|
391
|
+
* Absent means today's meaning: release **all** of this identity's holds on
|
|
392
|
+
* this camera. A client that has lost its id, or is going away entirely, still
|
|
393
|
+
* needs a way to let go — it is the blunt form, and it is the one that strands
|
|
394
|
+
* the identity's other tabs.
|
|
395
|
+
*/
|
|
396
|
+
export const releaseLiveQuery = z.object({
|
|
397
|
+
session_id: z.uuid().optional().meta({
|
|
398
|
+
description: 'The one hold to release, as the live session returned it. Absent releases **all** of this identity\'s holds on this camera — the blunt form, still needed by a client that has lost its id or is going away, and the one that strands the identity\'s other tabs.',
|
|
399
|
+
}),
|
|
400
|
+
}).strict();
|
|
401
|
+
export const invokeResponse = z.object({
|
|
402
|
+
job: job.meta({
|
|
403
|
+
description: 'The job that now exists on this slug. It is returned as soon as the goal is accepted, so `state` is `running` here — the outcome is observed afterwards, by slug, over polling or a subscription.',
|
|
404
|
+
}),
|
|
405
|
+
/** The slug's kind — see `commandResult.kind` for why the caller needs it. */
|
|
406
|
+
kind: z.enum(['action', 'service']).meta({
|
|
407
|
+
description: 'Always `action` in this shape. A caller sends the same request for both kinds and cannot tell from a role grant which it invoked, so the answer says which it was rather than leaving it to be inferred from the shape.',
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
/** A service call answers with its result directly — no job to observe. */
|
|
411
|
+
export const serviceCallResponse = z.object({
|
|
412
|
+
result: z.unknown().meta({
|
|
413
|
+
description: 'What the service returned, shaped by the ROS service itself. A service call is awaited to completion, so there is no job to observe afterwards and no id to hold on to.',
|
|
414
|
+
}),
|
|
415
|
+
});
|
|
416
|
+
/**
|
|
417
|
+
* **What `POST /api/robots/:id/jobs/:slug` answers, which is one of two
|
|
418
|
+
* shapes.**
|
|
419
|
+
*
|
|
420
|
+
* One route serves both kinds, because a path segment naming the kind would
|
|
421
|
+
* demand a fact a role grant does not carry. **The slug's kind decides, and
|
|
422
|
+
* nothing in the request does**: an *action* answers `202` with an
|
|
423
|
+
* `invokeResponse` the moment the job exists, a *service* answers `200` with
|
|
424
|
+
* a `serviceCallResponse` once the result is in. They differ only in what the
|
|
425
|
+
* cloud waits for before it answers.
|
|
426
|
+
*
|
|
427
|
+
* The two are told apart without inspecting the status code: `invokeResponse`
|
|
428
|
+
* carries `kind` and `job`, `serviceCallResponse` carries `result` alone.
|
|
429
|
+
*
|
|
430
|
+
* **This union exists so the route can name a response at all.** The entry
|
|
431
|
+
* carried `response: null` while the handler demonstrably answers something,
|
|
432
|
+
* which reads in the generated reference as *this route returns nothing* —
|
|
433
|
+
* the documented absence this project keeps paying for. A `null` there should
|
|
434
|
+
* mean `204`, and on this route it did not.
|
|
435
|
+
*/
|
|
436
|
+
export const invokeOrServiceResponse = z.union([invokeResponse, serviceCallResponse]);
|
|
437
|
+
export const publishRequest = z.object({
|
|
438
|
+
message: z.record(z.string(), z.unknown()).meta({
|
|
439
|
+
description: 'The values to publish, keyed by the **parameter names** the publisher declares — the same flat form an invoke takes for `params`. They are checked against the declared bounds in the cloud before anything reaches the robot, and a slug another caller is still holding is refused with the remaining wait.',
|
|
440
|
+
}),
|
|
441
|
+
});
|
|
442
|
+
/**
|
|
443
|
+
* The **most recent** job on a slug — running or already finished — or null
|
|
444
|
+
* only when nothing has ever run there.
|
|
445
|
+
*
|
|
446
|
+
* It said "the job currently running" until W4's review, and that quietly
|
|
447
|
+
* made §11.3's first sentence false. The spec offers two equal ways to
|
|
448
|
+
* observe a slug — *"Polling (REST) oder Subscription (Realtime)"* — but a
|
|
449
|
+
* route that forgets a job the moment it settles lets a poller see only
|
|
450
|
+
* `running`, then `null`. Succeeded, failed, cancelled, `lost` and
|
|
451
|
+
* never-invoked all become the same answer, so §6.1's promise that a lost
|
|
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
|
|
454
|
+
* a caller to.
|
|
455
|
+
*
|
|
456
|
+
* Read `job.state` to tell a live job from a finished one; that is what the
|
|
457
|
+
* field is for.
|
|
458
|
+
*/
|
|
459
|
+
export const jobResponse = z.object({
|
|
460
|
+
job: job.nullable().meta({
|
|
461
|
+
description: 'The **most recent** job on this slug, running or already finished, and `null` only when nothing has ever run there. Read `state` to tell a live job from a settled one: a route that forgot a job the moment it settled would let a poller see `running` and then nothing.',
|
|
462
|
+
}),
|
|
463
|
+
});
|
|
464
|
+
/**
|
|
465
|
+
* Every job the platform currently believes this robot has — `GET
|
|
466
|
+
* /api/robots/:id/jobs` (W6b).
|
|
467
|
+
*
|
|
468
|
+
* `jobResponse` answers "what is on this slug", which requires knowing the
|
|
469
|
+
* slug first. That was enough while a job could only exist on a slug the
|
|
470
|
+
* published configuration named. W6b breaks that assumption twice: a
|
|
471
|
+
* reconnecting bridge can name a job the cloud has **no row for** and the
|
|
472
|
+
* cloud adopts it, and a configuration change can leave a job on a slug the
|
|
473
|
+
* document no longer contains. Both are jobs nobody can ask about, because
|
|
474
|
+
* asking requires already knowing what to ask for.
|
|
475
|
+
*
|
|
476
|
+
* So this route exists to answer the question the per-slug route cannot: not
|
|
477
|
+
* "is something running here", but "what is this robot doing". A restarted
|
|
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.
|
|
480
|
+
*
|
|
481
|
+
* The array is ordered newest first and is **never null**: a robot doing
|
|
482
|
+
* nothing answers `{ jobs: [] }`. "Nothing is running" and "we did not look"
|
|
483
|
+
* are different facts, and a nullable list would merge them — the same
|
|
484
|
+
* distinction `robotDeletionSummary` was made all-required for.
|
|
485
|
+
*
|
|
486
|
+
* **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
|
+
* and must not become one. The first implementation returned every job the
|
|
489
|
+
* registry still held — six rows and four complete Fibonacci results after a
|
|
490
|
+
* few minutes of gate traffic, and unbounded in both count and payload for a
|
|
491
|
+
* robot that has been working all day. The list would have grown until a
|
|
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.
|
|
495
|
+
*
|
|
496
|
+
* A settled job stays visible as its slug's current entry until something
|
|
497
|
+
* else runs there, which is what makes a job that just failed still findable.
|
|
498
|
+
* Read `state` to tell a live one from a finished one, exactly as with
|
|
499
|
+
* `jobResponse`.
|
|
500
|
+
*/
|
|
501
|
+
/* ------------------------------------------------------------------ W6c --
|
|
502
|
+
* Identity, rewritten by the 2026-08-29 org-central redesign (D1/D2/D6).
|
|
503
|
+
* Written down here for the same reason the W4 command routes were: **a body
|
|
504
|
+
* schema does not imply a path**, and three consumers were about to derive
|
|
505
|
+
* nine paths independently from one implementation.
|
|
506
|
+
*
|
|
507
|
+
* **Two identity spaces, two prefixes** (2026-09-05 app-user-auth, D1). The
|
|
508
|
+
* `/api/org/` vs `/api/end-users/` split this table once insisted on, and the
|
|
509
|
+
* one pool that replaced it, are both gone. `/api/org/users` is the **team**:
|
|
510
|
+
* Fleetless users, console access, a tier each. An app's users live under
|
|
511
|
+
* `/api/apps/:id/users` and authenticate through `/api/client/`, and nothing
|
|
512
|
+
* joins the two — a credential from one never authenticates the other, and the
|
|
513
|
+
* same address in both is two unrelated accounts.
|
|
514
|
+
*
|
|
515
|
+
* **`src/routes.ts` is the manifest of record, and this table was not.** Every
|
|
516
|
+
* route, its schemas, its `errors` list and its guard are declared there, and
|
|
517
|
+
* the cloud's `route-manifest.test.ts` asserts set equality with the running
|
|
518
|
+
* server in both directions. This block kept a hand-written copy beside it and
|
|
519
|
+
* the copy drifted: it went on describing groups, assignments, a group's OIDC
|
|
520
|
+
* provider and the app OAuth flow after each was deleted. The table is removed
|
|
521
|
+
* rather than re-typed, because a second list is how the first one stops being
|
|
522
|
+
* read.
|
|
523
|
+
*
|
|
524
|
+
* What stays here is the *reasoning* the manifest has no field for. Each
|
|
525
|
+
* paragraph below is a decision, not a route listing.
|
|
526
|
+
*
|
|
527
|
+
* **Deleted with no successor, listed so a consumer looking for them finds the
|
|
528
|
+
* reason rather than a `404`:** every `/api/org/groups*` route, the per-user
|
|
529
|
+
* `assignments`, `move-group` and `usage` routes, `/api/org/federation`, the
|
|
530
|
+
* app's `group`, `group-usage`, `branding` and `oauth-clients` routes, the whole
|
|
531
|
+
* app OAuth sign-in flow (`/oauth/*` and `/login`), and `/api/client/grants*`.
|
|
532
|
+
* There are no compatibility aliases, because an alias here is how a deleted
|
|
533
|
+
* model survives in production while the contract says otherwise.
|
|
534
|
+
*
|
|
535
|
+
* **`POST /api/auth/password/reset` answers `202` for every well-formed
|
|
536
|
+
* address**, known or not. It is the one route where §3.3's silence about
|
|
537
|
+
* existence is not a preference but the entire point: any status, body or
|
|
538
|
+
* timing difference between the two cases is an account-enumeration oracle.
|
|
539
|
+
* Note *timing* — a route that only sends mail for a real address must not
|
|
540
|
+
* become measurably faster for an unknown one. Email is **globally unique**
|
|
541
|
+
* (Andre, 2026-08-29), so a bare address names at most one account and the
|
|
542
|
+
* route mails the one match, if any; the per-org detour the 2026-08-29
|
|
543
|
+
* redesign briefly took (multi-candidate verify on login, mail-every-match on
|
|
544
|
+
* reset) is retired, with no shape change. See `passwordResetRequest`.
|
|
545
|
+
*
|
|
546
|
+
* **Both surfaces get the password routes, mirrored.** Cluster D named the app
|
|
547
|
+
* user explicitly — *"an end user cannot change their own password, and there
|
|
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
|
|
552
|
+
* which session is being spent, exactly as it does for `login`. The two *reset*
|
|
553
|
+
* requests are separate shapes rather than one, because the surfaces identify a
|
|
554
|
+
* person differently: a Fleetless user by a globally unique address, an app
|
|
555
|
+
* user by app **and** address.
|
|
556
|
+
*
|
|
557
|
+
* **A password change answers with fresh `sessionTokens`, not `204`.** The
|
|
558
|
+
* promise is that the session which made the change survives while every other
|
|
559
|
+
* one dies — and `passwordChangeRequest` carries nothing that identifies the
|
|
560
|
+
* caller's refresh family, so a route given only that shape cannot spare one.
|
|
561
|
+
* Re-issuing is the honest way to keep the promise: revoke everything, hand the
|
|
562
|
+
* caller a new pair. Anything else means the caller keeps working until their
|
|
563
|
+
* access token expires and is then silently logged out, which is
|
|
564
|
+
* indistinguishable from the change having failed (Nimbus-W6c).
|
|
565
|
+
*
|
|
566
|
+
* **Every link this wave mails must carry what the page needs to act on it.**
|
|
567
|
+
* Three things were mailed to pages that could not handle them — a reset link
|
|
568
|
+
* to the *request* page, an accept link to a `404`, a register confirmation to
|
|
569
|
+
* a redirect (Kassandra-W6c). Fixing the paths alone would have left the defect
|
|
570
|
+
* underneath: **both surfaces mailed the identical reset URL**, and the
|
|
571
|
+
* console's confirm page posts to the console route, so an app user's token
|
|
572
|
+
* sent there answers `token_spent` forever. A URL that does not say which
|
|
573
|
+
* surface minted it cannot be routed correctly by anything.
|
|
574
|
+
*
|
|
575
|
+
* So the link shapes are fixed here rather than in whichever repo builds them.
|
|
576
|
+
* **They moved to the auth portal** (auth-portal spec `2026-08-30`, D-A1): the
|
|
577
|
+
* console serves no credential page at all any more, and `{portal}` is the
|
|
578
|
+
* cloud's `AUTH_PUBLIC_URL` — `auth.fleetless.dev` where the deployment has
|
|
579
|
+
* that vhost, the cloud's own base where it does not, since the cloud renders
|
|
580
|
+
* these pages itself either way.
|
|
581
|
+
*
|
|
582
|
+
* | purpose | URL |
|
|
583
|
+
* |---|---|
|
|
584
|
+
* | password reset, Fleetless user | `{portal}/reset-password/{token}` |
|
|
585
|
+
* | team invitation | `{portal}/accept-invite/{token}` |
|
|
586
|
+
*
|
|
587
|
+
* **An app user's links are not in this table, and cannot be** (2026-09-05,
|
|
588
|
+
* D2/D5). Fleetless renders an app user no page, so there is no `{portal}` path
|
|
589
|
+
* to name: the link points into the **developer's own app**, at the template
|
|
590
|
+
* they configured (`appAuthConfig.invite_url`, `verify_url`, `reset_url`), with
|
|
591
|
+
* the token substituted for `{token}`. That is why those fields are validated
|
|
592
|
+
* as templates rather than as URLs, and why an app with none configured is
|
|
593
|
+
* refused a `send_mail` instead of being mailed a link to nowhere.
|
|
594
|
+
*
|
|
595
|
+
* The paragraph this replaces said an app-user reset *"is a feature to design,
|
|
596
|
+
* not a row to restore"*. It was designed; the answer was that the row belongs
|
|
597
|
+
* to the developer and not to this table.
|
|
598
|
+
*
|
|
599
|
+
* The strings themselves live in `cloud/src/portal-paths.ts`, read by the
|
|
600
|
+
* route that serves each page AND by the builder that mails it — one constant,
|
|
601
|
+
* because the defect this table records happened again after it was written:
|
|
602
|
+
* `buildAcceptUrl` mailed `{console}/accept-invite/{token}` while the console
|
|
603
|
+
* served `/invite/{token}`, and this table said a third thing. Nothing caught
|
|
604
|
+
* it because nothing shared a string.
|
|
605
|
+
*
|
|
606
|
+
* ## W7 — the asset store (§4.6)
|
|
607
|
+
*
|
|
608
|
+
* | route | who | role capability |
|
|
609
|
+
* |---|---|---|
|
|
610
|
+
* | `GET /api/robots/{id}/assets` | developer **or** end user | `assets`, end users only |
|
|
611
|
+
* | `GET /api/robots/{id}/assets/{assetId}` | developer **or** end user | `assets`, end users only |
|
|
612
|
+
* | `GET /api/robots/{id}/assets/missing?name=` | developer **or** end user | `assets`, end users only |
|
|
613
|
+
* | `GET /api/robots/{id}/urdf` | developer **or** end user | `assets`, end users only |
|
|
614
|
+
* | `POST /api/robots/{id}/assets/sync` | developer, **Owner** tier | — |
|
|
615
|
+
* | `GET /api/robots/{id}/assets/sync/{syncId}` | developer | — |
|
|
616
|
+
* | `POST /api/bridge/assets` | robot token, per sync | — |
|
|
617
|
+
*
|
|
618
|
+
* **The read routes are dual-mode, and the first version of this table said
|
|
619
|
+
* `developer` for all three — contradicting the sentence that followed it.**
|
|
620
|
+
* `assets` is an *app-role* capability (§3.3), and developers are not in any
|
|
621
|
+
* app's role system at all (§3.1/§3.4: two identity spaces, and a credential
|
|
622
|
+
* from one never authenticates the other). Enforced literally, an end user
|
|
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.
|
|
625
|
+
*
|
|
626
|
+
* So: a developer reaches the robot because it belongs to their org; an end
|
|
627
|
+
* user reaches it when their role grants `assets`. Caught by Threepio-W7
|
|
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.
|
|
631
|
+
*
|
|
632
|
+
* **The rewritten mesh URIs in a served URDF are absolute, not
|
|
633
|
+
* root-relative.** A relative URL resolves against *the consumer's* origin,
|
|
634
|
+
* and the consumers here are apps on other domains — so `/api/robots/…` would
|
|
635
|
+
* 404 against the customer's own site. This is the same mistake as W5's
|
|
636
|
+
* `LIVEKIT_URL=localhost`, which was handed to a viewer's browser and cost an
|
|
637
|
+
* afternoon: **a URL we hand to somebody else's browser must never be relative
|
|
638
|
+
* to ours.** Raised by Data-W7 asking which it was rather than assuming.
|
|
639
|
+
*
|
|
640
|
+
* **There is no per-asset `DELETE`, and its absence is the design.** The first
|
|
641
|
+
* version of this table had one, for symmetry — which is not a reason. Assets
|
|
642
|
+
* are immutable and content-addressed, and the operation a developer actually
|
|
643
|
+
* performs is *the URDF changed, sync again*: a **re-sync reconciles**, so
|
|
644
|
+
* assets the new URDF no longer references stop belonging to that robot. One
|
|
645
|
+
* mechanism instead of two. Robot deletion is already covered by W6a's
|
|
646
|
+
* cascade.
|
|
647
|
+
*
|
|
648
|
+
* Left in, it would have been a route with no console, no SDK method and no
|
|
649
|
+
* gate step — register row 8's third instance, in the wave whose own contracts
|
|
650
|
+
* file warns about the first two by name. Caught by Eve-W7 asking why it was
|
|
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,
|
|
659
|
+
* and that is what it is for: when the served URDF is rewritten, a reference
|
|
660
|
+
* the store cannot answer has to be rewritten into *something*, and a URL that
|
|
661
|
+
* 404s `asset_missing` naming the reference is the only option that leaves the
|
|
662
|
+
* renderer's own error legible. The alternatives are worse — leaving the
|
|
663
|
+
* `package://` URI in place hands a browser a scheme it cannot fetch, and
|
|
664
|
+
* dropping the element silently deletes a limb.
|
|
665
|
+
*
|
|
666
|
+
* `?name=` is that reference, verbatim and URL-encoded: the same string
|
|
667
|
+
* `asset.name` stores and `urdfCompleteness.missing` reports, so what a
|
|
668
|
+
* developer sees in a failed network request matches what the completeness
|
|
669
|
+
* list told them to go fix. `asset_missing` is deliberately not `not_found`:
|
|
670
|
+
* "this robot does not exist" and "this mesh was never synced" send a
|
|
671
|
+
* developer to two different places.
|
|
672
|
+
*
|
|
673
|
+
* A route with a producer, a consumer and no entry in this table is how an
|
|
674
|
+
* error code ends up with no documented way to provoke it.
|
|
675
|
+
*
|
|
676
|
+
* `GET .../assets/{assetId}` answers **bytes**, not JSON, with
|
|
677
|
+
* `Cache-Control: private, immutable` and never `public`: a shared cache must
|
|
678
|
+
* not be invited to store a response to an authorized request. It is the one
|
|
679
|
+
* route in this API whose body is not an `apiError` on failure — a client
|
|
680
|
+
* fetching bytes must still be able to branch, so failures answer the normal
|
|
681
|
+
* envelope with `content-type: application/json`.
|
|
682
|
+
*
|
|
683
|
+
* `POST /api/bridge/assets` is the **first route authenticated by the robot
|
|
684
|
+
* credential over HTTP**. Everything the bridge does today goes over the
|
|
685
|
+
* WebSocket, so this is new surface, not a variation of something existing —
|
|
686
|
+
* 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 W6c's own rule
|
|
688
|
+
* applies: the refusal must precede the work, not follow it.
|
|
689
|
+
*
|
|
690
|
+
* The end-user links carry `app_identifier` because the page cannot act
|
|
691
|
+
* without it: `clientPasswordResetRequest` requires it, and an end user is
|
|
692
|
+
* identified by **app and address**, never address alone. The token alone is
|
|
693
|
+
* not enough, and a page that guesses the app is a page that guesses wrong.
|
|
694
|
+
*
|
|
695
|
+
* **This is a stopgap and should be named as one.** An app's users landing on
|
|
696
|
+
* *our console* to reset a password is wrong — the page belongs to the app,
|
|
697
|
+
* and an app has no configured base URL to send them to. Registered for W7;
|
|
698
|
+
* until then the console hosts both, and the URL carries the app so that
|
|
699
|
+
* moving it later is a redirect rather than a redesign.
|
|
700
|
+
*
|
|
701
|
+
* **`DELETE /api/org/members/:id` is not a row deletion.** Gate step 3 takes a
|
|
702
|
+
* token minted before the removal and uses it; if it still works, the feature
|
|
703
|
+
* is not built. `revokeSessionsForSubject` is already wired.
|
|
704
|
+
*/
|
|
705
|
+
/**
|
|
706
|
+
* What a `rate_limited` refusal tells the caller (W6c).
|
|
707
|
+
*
|
|
708
|
+
* One number, and it is the only one that matters: **when to come back.** A
|
|
709
|
+
* limit that says "too many" without saying "in 800 ms" produces a client that
|
|
710
|
+
* retries immediately, which is the behaviour the limit exists to stop — so
|
|
711
|
+
* omitting it would make the refusal part of the attack.
|
|
712
|
+
*
|
|
713
|
+
* Deliberately **not** carrying the limit, the window, or how many attempts
|
|
714
|
+
* remain: those describe the defence to whoever is probing it, and none of
|
|
715
|
+
* them changes what an honest caller does.
|
|
716
|
+
*/
|
|
717
|
+
export const rateLimitDetails = z.object({
|
|
718
|
+
retry_after_ms: z.number().int().nonnegative(),
|
|
719
|
+
});
|
|
720
|
+
/**
|
|
721
|
+
* Every job this robot's registry currently holds, **ordered newest first by
|
|
722
|
+
* `started_at`, with `seq` as the tiebreaker** (W7, register rows 2j and 2l).
|
|
723
|
+
*
|
|
724
|
+
* The field is named because the previous version of this comment claimed an
|
|
725
|
+
* order without saying what produced it, and the answer turned out to matter
|
|
726
|
+
* twice over:
|
|
727
|
+
*
|
|
728
|
+
* 1. **`started_at` alone is not a total order.** Two jobs minted in the same
|
|
729
|
+
* millisecond sorted against each other arbitrarily — differently on each
|
|
730
|
+
* query — so a reader could see one twice and the other not at all. `seq`
|
|
731
|
+
* is monotonic in mint order and settles it. Note its scope, which is in
|
|
732
|
+
* `job.seq`'s own comment: per cloud process, per run, because job state
|
|
733
|
+
* lives in memory and the counter restarts with the registry it orders.
|
|
734
|
+
* 2. **For an adopted job, `started_at` is adoption time, not the real
|
|
735
|
+
* start.** The cloud learns of it at `hello`, having never minted it, and
|
|
736
|
+
* has no other honest value to put there. So this list is newest-*known*
|
|
737
|
+
* first, and a job the robot has been running for an hour can sit above one
|
|
738
|
+
* started a minute ago. Stated rather than smoothed over: the console's own
|
|
739
|
+
* "Known running since" wording exists for the same reason, and a contract
|
|
740
|
+
* that quietly implies otherwise would send somebody to debug the sort.
|
|
741
|
+
*/
|
|
742
|
+
export const robotJobsResponse = z.object({
|
|
743
|
+
jobs: z.array(job).meta({
|
|
744
|
+
description: 'At most one entry per slug — the current job there — ordered newest **known** first, and never `null`: a robot doing nothing answers an empty array. This is not a history endpoint. For an adopted job `started_at` is adoption time, so a job that has been running for an hour can sit above one started a minute ago.',
|
|
745
|
+
}),
|
|
746
|
+
});
|
|
747
|
+
/**
|
|
748
|
+
* Every slug of a robot that a role can be granted, **with its kind**.
|
|
749
|
+
*
|
|
750
|
+
* The roles matrix was built in W3 against the datapoint list, which was the
|
|
751
|
+
* only kind that existed. With four kinds it needs one list that names them,
|
|
752
|
+
* or the matrix silently cannot grant an action.
|
|
753
|
+
*/
|
|
754
|
+
export const exposure = z.object({
|
|
755
|
+
slug,
|
|
756
|
+
kind: z.enum(['datapoint', 'action', 'service', 'publisher', 'camera']),
|
|
757
|
+
builtin: z.boolean(),
|
|
758
|
+
});
|
|
759
|
+
export const exposureListResponse = z.object({
|
|
760
|
+
exposures: z.array(exposure),
|
|
761
|
+
});
|
|
762
|
+
/* ------------------------------------------------------------------ W5 --
|
|
763
|
+
* Cameras (spec §10). Routes, written down as the W4 command routes are:
|
|
764
|
+
*
|
|
765
|
+
* | route | answers |
|
|
766
|
+
* |---|---|
|
|
767
|
+
* | `GET /api/robots/:id/cameras` | `cameraListResponse` |
|
|
768
|
+
* | `GET /api/robots/:id/cameras/:slug/snapshot` | the image bytes, plus the headers below |
|
|
769
|
+
* | `GET /api/robots/:id/cameras/:slug/snapshot/meta`| `snapshotMetaResponse` — age without the bytes |
|
|
770
|
+
* | `POST /api/robots/:id/cameras/:slug/live` | `liveSessionResponse` — takes a refcount hold |
|
|
771
|
+
* | `DELETE /api/robots/:id/cameras/:slug/live?session_id=` | 204 — releases **that** hold; without the parameter, all of this identity's holds on the camera |
|
|
772
|
+
*/
|
|
773
|
+
/**
|
|
774
|
+
* The response headers a binary snapshot carries, named here so the cloud and
|
|
775
|
+
* every client agree without negotiating:
|
|
776
|
+
*
|
|
777
|
+
* - `Content-Type` — the image's mime, standard rather than invented.
|
|
778
|
+
* - `X-Fleetless-Age-Ms` — how old the frame is, **computed by the cloud**.
|
|
779
|
+
* - `X-Fleetless-Timestamp-Ms`— the bridge's capture time.
|
|
780
|
+
* - `X-Fleetless-Width` / `X-Fleetless-Height`.
|
|
781
|
+
*
|
|
782
|
+
* A client must take `age_ms` from the header and **never** recompute it as
|
|
783
|
+
* `Date.now() - timestamp_ms`: the cloud is the one clock that knows how long
|
|
784
|
+
* it has actually been holding the frame, and recomputing reintroduces the
|
|
785
|
+
* viewer's clock skew as a source of lying about freshness.
|
|
786
|
+
*/
|
|
787
|
+
export const SNAPSHOT_HEADERS = {
|
|
788
|
+
ageMs: 'x-fleetless-age-ms',
|
|
789
|
+
timestampMs: 'x-fleetless-timestamp-ms',
|
|
790
|
+
width: 'x-fleetless-width',
|
|
791
|
+
height: 'x-fleetless-height',
|
|
792
|
+
};
|
|
793
|
+
/**
|
|
794
|
+
* The metadata an asset upload carries beside its raw body (W7).
|
|
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.
|
|
803
|
+
*
|
|
804
|
+
* **A string shared by two repos and defined in both is a string that drifts.**
|
|
805
|
+
* A zod schema cannot validate a header, which is an argument for writing the
|
|
806
|
+
* names down once, not an argument for writing them down twice.
|
|
807
|
+
*
|
|
808
|
+
* `name` is the `package://` URI verbatim for a mesh — the same string
|
|
809
|
+
* `asset.name` stores, and the same one `urdfCompleteness.missing` reports, so
|
|
810
|
+
* a failed upload and a missing mesh can be matched by eye.
|
|
811
|
+
*/
|
|
812
|
+
/**
|
|
813
|
+
* **`name` travels percent-encoded, and that is a fix rather than a
|
|
814
|
+
* convention** (W7a review, André's decision to fix rather than defer).
|
|
815
|
+
*
|
|
816
|
+
* HTTP header values are latin-1 (`http.client` in Python, and the same is
|
|
817
|
+
* true on the other side). So a texture called `textures/日本語.png` raised a
|
|
818
|
+
* `UnicodeEncodeError` **inside `urllib`** — a `ValueError`, caught by neither
|
|
819
|
+
* `HTTPError` nor `URLError` — which propagated to the sync's broad handler
|
|
820
|
+
* and marked **everything still remaining** as failed. One non-ASCII filename
|
|
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.
|
|
824
|
+
*
|
|
825
|
+
* The encoding is not invented here. **`GET .../assets/missing?name=` already
|
|
826
|
+
* carries this exact string percent-encoded**, because a query parameter is
|
|
827
|
+
* percent-encoded by definition — same value, same wire, question already
|
|
828
|
+
* answered.
|
|
829
|
+
*
|
|
830
|
+
* **It is a SECOND header, and that is the whole design rather than a
|
|
831
|
+
* detail.** The first version overloaded `name` itself: the producer would
|
|
832
|
+
* encode, the store would `decodeURIComponent`. That decodes identically for
|
|
833
|
+
* every name without a `%`, so an **older bridge and a newer cloud agree by
|
|
834
|
+
* luck** — right up until a name contains `%2f`, which the store would then
|
|
835
|
+
* silently turn into a `/`. A wire change whose breakage is invisible in the
|
|
836
|
+
* common case and silent in the uncommon one is the worst of both (Argus-W7a,
|
|
837
|
+
* reading the contract rather than the code).
|
|
838
|
+
*
|
|
839
|
+
* So `name` keeps meaning exactly what it always meant, and `nameEncoded`
|
|
840
|
+
* carries the percent-encoded UTF-8 form. **The store prefers `nameEncoded`
|
|
841
|
+
* when present and uses `name` otherwise**, so:
|
|
842
|
+
*
|
|
843
|
+
* - an older bridge sends only `name` and behaves exactly as before;
|
|
844
|
+
* - a newer bridge sends both, and a name it cannot express in latin-1 travels
|
|
845
|
+
* intact for the first time;
|
|
846
|
+
* - no value is ever ambiguous about which encoding it is in.
|
|
847
|
+
*
|
|
848
|
+
* A producer that can send `nameEncoded` should send both, so a store older
|
|
849
|
+
* than this contract keeps working too. Agreement by construction, not by the
|
|
850
|
+
* absence of a `%`.
|
|
851
|
+
*/
|
|
852
|
+
export const ASSET_UPLOAD_HEADERS = {
|
|
853
|
+
kind: 'x-fleetless-asset-kind',
|
|
854
|
+
name: 'x-fleetless-asset-name',
|
|
855
|
+
nameEncoded: 'x-fleetless-asset-name-encoded',
|
|
856
|
+
syncId: 'x-fleetless-sync-id',
|
|
857
|
+
/**
|
|
858
|
+
* **Die angekündigte Größe, und sie ist der Grund, warum `asset_too_large`
|
|
859
|
+
* überhaupt entstehen kann (W9b, DEF-116).**
|
|
860
|
+
*
|
|
861
|
+
* Fastifys `bodyLimit` greift im Content-Type-Parser, also **vor** dem
|
|
862
|
+
* Handler — eine zu große Datei bekam damit ein blankes `413 bad_request`
|
|
863
|
+
* ohne `limit_bytes` und ohne `size_bytes`, und der strukturierte Fehlercode,
|
|
864
|
+
* den `assetTooLargeDetails` beschreibt, hatte schlicht keinen erreichbaren
|
|
865
|
+
* Erzeuger (Momus-W7, M1, an den echten Routenoptionen reproduziert).
|
|
866
|
+
*
|
|
867
|
+
* Mit einer angekündigten Größe im Kopf kann die Ablehnung dort entstehen,
|
|
868
|
+
* wo sie etwas sagen kann: bevor ein Byte gepuffert ist, mit beiden Zahlen.
|
|
869
|
+
* Und die Bridge erfährt ihre Grenze, ohne 194 MB zu lesen, um sie zu
|
|
870
|
+
* entdecken — was am 2026-08-18 auf rx1 genau so ausging (DEF-148).
|
|
871
|
+
*
|
|
872
|
+
* Der Kopf ist eine **Ankündigung, kein Beweis**: Ein Absender kann lügen.
|
|
873
|
+
* Der Deckel gilt weiterhin auch am Körper — dies ersetzt die Durchsetzung
|
|
874
|
+
* nicht, es macht die Absage nur beantwortbar.
|
|
875
|
+
*/
|
|
876
|
+
size: 'x-fleetless-asset-size',
|
|
877
|
+
};
|
|
878
|
+
export const cameraDescriptor = z.object({
|
|
879
|
+
slug: slug.meta({ description: 'The name a client addresses this camera by.' }),
|
|
880
|
+
width: z.number().int().positive().meta({
|
|
881
|
+
description: 'Frame width in pixels, as the published configuration declares it.',
|
|
882
|
+
}),
|
|
883
|
+
height: z.number().int().positive().meta({
|
|
884
|
+
description: 'Frame height in pixels, as the published configuration declares it.',
|
|
885
|
+
}),
|
|
886
|
+
fps: z.number().int().positive().meta({
|
|
887
|
+
description: 'How many frames per second the camera is configured to publish while somebody is watching live.',
|
|
888
|
+
}),
|
|
889
|
+
/**
|
|
890
|
+
* Seconds, as the document spells it, reusing `snapshotIntervalSeconds` so
|
|
891
|
+
* the 1–3600 bound is written once. It was `snapshot_interval_ms` after the
|
|
892
|
+
* document moved to seconds, which left the cloud converting the unit on
|
|
893
|
+
* this descriptor and not on `datapointDescriptor` beside it — the same
|
|
894
|
+
* drift `rateThrottleHz` was extracted to stop.
|
|
895
|
+
*/
|
|
896
|
+
snapshot_interval_seconds: snapshotIntervalSeconds.meta({
|
|
897
|
+
description: 'How often a still frame is captured for the cheap snapshot reads, in seconds, between `1` and `3600`. Independent of `fps`, which is about live video.',
|
|
898
|
+
}),
|
|
899
|
+
});
|
|
900
|
+
export const cameraListResponse = z.object({
|
|
901
|
+
cameras: z.array(cameraDescriptor).meta({
|
|
902
|
+
description: 'Every camera the published configuration exposes on this robot **and** the caller\'s role grants. A developer sees all of them; an end user sees what their role allows.',
|
|
903
|
+
}),
|
|
904
|
+
});
|
|
905
|
+
/**
|
|
906
|
+
* What a viewer needs to join, and **what it costs them to hold**.
|
|
907
|
+
*
|
|
908
|
+
* `POST` takes a refcount hold and `DELETE` releases it; the first hold
|
|
909
|
+
* starts the robot publishing and the last release stops it (§10). A client
|
|
910
|
+
* that forgets to release keeps a robot streaming to nobody, so the SDK hands
|
|
911
|
+
* back a `release()` rather than a bare token.
|
|
912
|
+
*
|
|
913
|
+
* **`expires_at` is a join deadline, not a session backstop.** A LiveKit
|
|
914
|
+
* token is checked when a participant connects and not again afterwards, so a
|
|
915
|
+
* viewer who has already joined keeps receiving video straight past this
|
|
916
|
+
* moment. Do not design cleanup around it. What actually ends a session is
|
|
917
|
+
* `release()` together with disconnecting the room, the cloud reconciling the
|
|
918
|
+
* hold away against LiveKit's real participants, or a revocation kicking the
|
|
919
|
+
* participant out. This comment previously claimed the opposite and the SDK
|
|
920
|
+
* inherited the claim from here — a developer reading it would reasonably
|
|
921
|
+
* have skipped cleanup on purpose.
|
|
922
|
+
*/
|
|
923
|
+
export const liveSessionResponse = z.object({
|
|
924
|
+
/**
|
|
925
|
+
* This viewer's hold, and the **only** thing `DELETE` should be given
|
|
926
|
+
* (W6b).
|
|
927
|
+
*
|
|
928
|
+
* A hold was addressed by `{identity, robot, slug}` and nothing else, so
|
|
929
|
+
* two tabs of one logged-in user were one hold as far as the refcount could
|
|
930
|
+
* see. Closing either tab released it: the second tab kept its LiveKit
|
|
931
|
+
* connection — the token is checked at join and never again — and went on
|
|
932
|
+
* rendering a video that the robot had already stopped producing. The
|
|
933
|
+
* viewer sees a frozen picture, not an ended session, which is the failure
|
|
934
|
+
* this project rejects everywhere else.
|
|
935
|
+
*
|
|
936
|
+
* `DELETE` without a session id keeps today's meaning — *release my holds
|
|
937
|
+
* on this camera* — because an SDK that has lost its id, or a client that
|
|
938
|
+
* is going away entirely, still needs a way to let go. It is the blunt
|
|
939
|
+
* form, and it is the one that strands other tabs; new callers pass the id.
|
|
940
|
+
*/
|
|
941
|
+
session_id: z.uuid().meta({
|
|
942
|
+
description: 'This viewer\'s hold, and the only thing a release should be given. Two tabs of one logged-in user are two holds; releasing without an id lets go of both and leaves the other tab rendering a stream the robot has already stopped producing.',
|
|
943
|
+
}),
|
|
944
|
+
url: z.string().min(1).meta({
|
|
945
|
+
description: 'The LiveKit server to connect to, as a WebSocket URL.',
|
|
946
|
+
}),
|
|
947
|
+
room: z.string().min(1).meta({
|
|
948
|
+
description: 'The LiveKit room carrying this camera. Every viewer of one camera on one robot joins the same room, which is what makes the refcount hold meaningful.',
|
|
949
|
+
}),
|
|
950
|
+
token: z.string().min(1).meta({
|
|
951
|
+
description: 'The LiveKit access token to join `room` with. It is checked when the participant connects and **not again afterwards** — which is not the same as irrevocable: the cloud can still disconnect a participant after the fact, and does when membership, a role or a key changes.',
|
|
952
|
+
}),
|
|
953
|
+
expires_at: z.iso.datetime().meta({
|
|
954
|
+
description: 'The deadline for **joining**, as an ISO 8601 timestamp — not a session backstop. A viewer who has already joined keeps receiving video past this moment, so cleanup belongs in an explicit release, never in a timer built on this value.',
|
|
955
|
+
}),
|
|
956
|
+
});
|
|
957
|
+
/**
|
|
958
|
+
* The snapshot read **without the bytes**.
|
|
959
|
+
*
|
|
960
|
+
* A viewer polling at the camera's interval otherwise re-downloads a whole
|
|
961
|
+
* image to discover whether a new one exists. This is the cheap question —
|
|
962
|
+
* *how old is what you have?* — so a client can fetch pixels only when the
|
|
963
|
+
* timestamp actually moved. It matters most on the console's snapshot view,
|
|
964
|
+
* which polls continuously while a tab is open.
|
|
965
|
+
*
|
|
966
|
+
* `age_ms` is not a convenience: a cached frame served without its age is
|
|
967
|
+
* indistinguishable from a live one, and §10 makes snapshots deliberately
|
|
968
|
+
* cheap and therefore deliberately old. `null` values mean nothing has been
|
|
969
|
+
* captured yet — which is an answer, not an error.
|
|
970
|
+
*/
|
|
971
|
+
export const snapshotMetaResponse = z.object({
|
|
972
|
+
slug: slug.meta({ description: 'The camera this snapshot belongs to.' }),
|
|
973
|
+
timestamp_ms: z.number().int().nonnegative().nullable().meta({
|
|
974
|
+
description: 'When the stored frame was captured, as a unix timestamp in milliseconds. `null` means nothing has been captured yet, which is an answer rather than an error.',
|
|
975
|
+
}),
|
|
976
|
+
age_ms: z.number().int().nonnegative().nullable().meta({
|
|
977
|
+
description: 'How old the stored frame is right now, in milliseconds; `null` when there is none. Snapshots are deliberately cheap and therefore deliberately old, and a cached frame served without its age is indistinguishable from a live one.',
|
|
978
|
+
}),
|
|
979
|
+
width: z.number().int().positive().nullable().meta({
|
|
980
|
+
description: 'Width of the stored frame in pixels, or `null` when nothing has been captured yet.',
|
|
981
|
+
}),
|
|
982
|
+
height: z.number().int().positive().nullable().meta({
|
|
983
|
+
description: 'Height of the stored frame in pixels, or `null` when nothing has been captured yet.',
|
|
984
|
+
}),
|
|
985
|
+
mime: z.string().nullable().meta({
|
|
986
|
+
description: 'The media type of the stored frame, such as `image/jpeg`, or `null` when nothing has been captured yet.',
|
|
987
|
+
}),
|
|
988
|
+
});
|
|
989
|
+
// ---------------------------------------------------------------------------
|
|
990
|
+
// W6 — retention, history and org quotas (§8, §12.4)
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
/**
|
|
993
|
+
* **Both history shapes answer the same boundary the same way: `[from, to)`
|
|
994
|
+
* (W9d, DEF-062 — decision pre-made at the W6 boundary so no wave
|
|
995
|
+
* re-litigates it).**
|
|
996
|
+
*
|
|
997
|
+
* They did not. `samples` was inclusive of `to`, `buckets` exclusive — same
|
|
998
|
+
* range, same data, opposite answers for a point landing exactly on `to`, and
|
|
999
|
+
* the buckets answer rendered as a gap tooltipped *"empty — no samples"*.
|
|
1000
|
+
* `sdk/README.md` documented the inclusive notation for the half-open path,
|
|
1001
|
+
* so it was wrong for one of the two whichever way you read it.
|
|
1002
|
+
*
|
|
1003
|
+
* Half-open wins because it is the only rule under which **adjacent windows
|
|
1004
|
+
* tile without overlap**: `[0,10)` then `[10,20)` covers every instant once.
|
|
1005
|
+
* With an inclusive upper bound a sample at exactly `10` belongs to both
|
|
1006
|
+
* windows, and any consumer summing them counts it twice.
|
|
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.
|
|
1012
|
+
*/
|
|
1013
|
+
/**
|
|
1014
|
+
* A history query (§8). `from`/`to` accept **either** a relative expression
|
|
1015
|
+
* (`now-30s`, `now-5m`, `now-1h`) **or** absolute unix milliseconds, because
|
|
1016
|
+
* a chart asks the first way and a report asks the second, and making a
|
|
1017
|
+
* client convert is making it guess our clock.
|
|
1018
|
+
*
|
|
1019
|
+
* `window` without `agg` is meaningless and `agg` without `window` is
|
|
1020
|
+
* ambiguous — both are refused rather than assigned a default, since a
|
|
1021
|
+
* silently chosen aggregation is a chart that lies quietly.
|
|
1022
|
+
*/
|
|
1023
|
+
export const historyQuery = z.object({
|
|
1024
|
+
from: z.string().min(1).max(32).meta({
|
|
1025
|
+
description: 'The start of the window: either a relative expression — `now-30s`, `now-5m`, `now-1h` — or absolute unix milliseconds. A chart asks the first way and a report asks the second, and making a client convert would be making it guess our clock.',
|
|
1026
|
+
}),
|
|
1027
|
+
to: z.string().min(1).max(32).optional().meta({
|
|
1028
|
+
description: 'The end of the window, in the same two spellings as `from`; absent means now. The window is half-open, `[from, to)`, so a sample landing exactly on `to` belongs to the next window and adjacent windows tile without double-counting.',
|
|
1029
|
+
}),
|
|
1030
|
+
window: z.string().min(2).max(16).optional().meta({
|
|
1031
|
+
description: 'The bucket width, such as `10s` or `1m`. Absent means raw samples. It is meaningless without `agg`, and the pair is refused apart rather than defaulted — a silently chosen aggregation is a chart that lies quietly.',
|
|
1032
|
+
}),
|
|
1033
|
+
agg: z.enum(['min', 'max', 'avg']).optional().meta({
|
|
1034
|
+
description: 'How each bucket reduces the samples inside it. Valid only together with `window`.',
|
|
1035
|
+
}),
|
|
1036
|
+
field: z.string().min(1).max(128).optional().meta({
|
|
1037
|
+
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
|
+
}),
|
|
1039
|
+
/**
|
|
1040
|
+
* **A union whose input branch IS the wire, not a coercion (W9d, DEF-059).**
|
|
1041
|
+
*
|
|
1042
|
+
* This was `z.coerce.number()`, for a good reason that stayed true: the
|
|
1043
|
+
* schema describes a **query string**, where every value arrives as text,
|
|
1044
|
+
* and a bare `z.number()` would make each route coerce by hand. What was
|
|
1045
|
+
* measured afterwards is that a coercion cannot be *published*: zod renders
|
|
1046
|
+
* a coercion's **result** in either `io` mode, so `io: 'input'` and
|
|
1047
|
+
* `io: 'output'` both emit `{"type":"integer"}` — an artifact describing a
|
|
1048
|
+
* shape a query string can never carry. Anyone validating a real request
|
|
1049
|
+
* against it rejects every one that sets `limit`.
|
|
1050
|
+
*
|
|
1051
|
+
* That is a **different** defect from the `.default()` class, which
|
|
1052
|
+
* `io: 'input'` genuinely does fix; `export-schemas.ts` once claimed one
|
|
1053
|
+
* remedy for both and has been corrected.
|
|
1054
|
+
*
|
|
1055
|
+
* A union states both truths honestly: the wire carries a numeric string,
|
|
1056
|
+
* a programmatic caller may pass a number, and the artifact can render the
|
|
1057
|
+
* input branch because there is one to render.
|
|
1058
|
+
*
|
|
1059
|
+
* **What the artifact no longer says, named here rather than left silent.**
|
|
1060
|
+
* The `1..10000` bound lives in the `.pipe()`, which is the *output* half, so
|
|
1061
|
+
* no input-mode artifact can express it as a constraint: the published shape
|
|
1062
|
+
* is `^\d{1,5}$` or a bare integer, and five digits is a weak echo of the
|
|
1063
|
+
* real ceiling. That is honest about the wire — the bound is enforced after
|
|
1064
|
+
* parsing, not by the shape of the text — but it is a **reduction**, and an
|
|
1065
|
+
* artifact that stops naming a bound reads as if there were none.
|
|
1066
|
+
*
|
|
1067
|
+
* So both branches carry the number in a `.describe()` (Nimbus-W9d's
|
|
1068
|
+
* proposal). It is **not** a constraint and nothing validates against it; it
|
|
1069
|
+
* means a generator, or a person reading only the published schema, sees the
|
|
1070
|
+
* actual ceiling instead of nothing. The gap is narrowed and named rather
|
|
1071
|
+
* than closed.
|
|
1072
|
+
*/
|
|
1073
|
+
limit: z
|
|
1074
|
+
.union([
|
|
1075
|
+
z
|
|
1076
|
+
.string()
|
|
1077
|
+
.regex(/^\d{1,5}$/)
|
|
1078
|
+
// **The description carries the number the shape cannot** (Nimbus-W9d's
|
|
1079
|
+
// proposal). Five digits is the regex's bound, not the contract's; the
|
|
1080
|
+
// real ceiling lives in the `.pipe()` below and therefore cannot appear
|
|
1081
|
+
// in an input-mode artifact. This does not close that gap and does not
|
|
1082
|
+
// claim to — it means a generator, or a person reading only the
|
|
1083
|
+
// published schema, sees the actual number instead of none at all.
|
|
1084
|
+
.describe('Positive integer, 1-10000. The pattern only bounds digit count; the real ceiling is enforced after parsing.'),
|
|
1085
|
+
// The same sentence on the numeric branch, for the same reason and one
|
|
1086
|
+
// that is arguably stronger: without it the artifact publishes the full
|
|
1087
|
+
// safe-integer range, which reads as *nine quadrillion is fine*.
|
|
1088
|
+
z.number().int().describe('Positive integer, 1-10000. The ceiling is enforced after parsing, not by this type.'),
|
|
1089
|
+
])
|
|
1090
|
+
.transform((v) => Number(v))
|
|
1091
|
+
.pipe(z.number().int().positive().max(10_000))
|
|
1092
|
+
.optional()
|
|
1093
|
+
.meta({
|
|
1094
|
+
description: 'The most samples or buckets to return, from `1` to `10000`. It arrives as text on the query string, so both a numeric string and a number are accepted; the ceiling is enforced after parsing rather than by the published shape.',
|
|
1095
|
+
}),
|
|
1096
|
+
});
|
|
1097
|
+
/**
|
|
1098
|
+
* Raw samples. `timestamp_ms` is the **bridge's capture time** (§6.3) — the
|
|
1099
|
+
* same instant the live value carried, so a recorded point and a live one can
|
|
1100
|
+
* be placed on one axis without apology.
|
|
1101
|
+
*
|
|
1102
|
+
* `truncated` says the response was cut short. A short array that does not
|
|
1103
|
+
* admit it is indistinguishable from a quiet period, and the two lead a
|
|
1104
|
+
* developer to opposite conclusions.
|
|
1105
|
+
*/
|
|
1106
|
+
export const historySamplesResponse = z.object({
|
|
1107
|
+
slug: slug.meta({ description: 'The datapoint these samples belong to.' }),
|
|
1108
|
+
kind: z.literal('samples').meta({
|
|
1109
|
+
description: 'Says this is the raw-sample shape, which the query asked for by omitting `window`. A client reads this rather than inspecting which fields arrived.',
|
|
1110
|
+
}),
|
|
1111
|
+
samples: z.array(z.object({
|
|
1112
|
+
timestamp_ms: z.number().int().nonnegative().meta({
|
|
1113
|
+
description: 'When the sample was captured, as a unix timestamp in milliseconds. It is the **bridge\'s capture time** — the same instant the live value carried, so a recorded point and a live one sit on one axis without apology.',
|
|
1114
|
+
}),
|
|
1115
|
+
value: z.unknown().meta({
|
|
1116
|
+
description: 'The value as it was stored, shaped by the datapoint. A `field` in the query narrows a message down to one number; without one the whole stored value comes back.',
|
|
1117
|
+
}),
|
|
1118
|
+
})).meta({
|
|
1119
|
+
description: 'The samples in the queried window, oldest first. The window is half-open, `[from, to)`, so a sample landing exactly on `to` belongs to the next window.',
|
|
1120
|
+
}),
|
|
1121
|
+
truncated: z.boolean().meta({
|
|
1122
|
+
description: 'Whether the response was cut short. A short array that does not admit it is indistinguishable from a quiet period, and the two lead a developer to opposite conclusions.',
|
|
1123
|
+
}),
|
|
1124
|
+
/**
|
|
1125
|
+
* Why it was cut, `null` when it was not — because the two causes have
|
|
1126
|
+
* **different remedies** and a single boolean cannot tell them apart:
|
|
1127
|
+
*
|
|
1128
|
+
* `'limit'` too many rows. Raise `limit` (up to 10 000).
|
|
1129
|
+
* `'bytes'` the rows are large. Raising `limit` will not help — narrow
|
|
1130
|
+
* the range, or name a numeric `field` so whole messages are
|
|
1131
|
+
* not carried.
|
|
1132
|
+
*
|
|
1133
|
+
* A caller cannot derive this: comparing `samples.length` against `limit`
|
|
1134
|
+
* only works if they sent one, and the server's default is not in the
|
|
1135
|
+
* response. So without this field, "raise the limit" is the natural next
|
|
1136
|
+
* move in both cases, and in the second it changes nothing.
|
|
1137
|
+
*
|
|
1138
|
+
* Nullable rather than optional on purpose: `.default()` publishes as
|
|
1139
|
+
* `required` in the JSON Schema artifacts, which is the contradiction this
|
|
1140
|
+
* project has now hit five times.
|
|
1141
|
+
*/
|
|
1142
|
+
truncated_by: z.enum(['limit', 'bytes']).nullable().meta({
|
|
1143
|
+
description: 'Why it was cut, and `null` when it was not — the two causes have **different remedies** and one boolean cannot tell them apart. `limit` means too many rows, so raising `limit` helps. `bytes` means the rows are large, so raising `limit` changes nothing: narrow the range, or name a numeric `field` so whole messages are not carried.',
|
|
1144
|
+
}),
|
|
1145
|
+
});
|
|
1146
|
+
/**
|
|
1147
|
+
* Aggregated buckets — a **separate shape**, not the samples shape with nulls
|
|
1148
|
+
* in it, so a client knows by type what it received rather than by
|
|
1149
|
+
* inspection.
|
|
1150
|
+
*
|
|
1151
|
+
* `sample_count` exists because an empty bucket and a bucket whose average is
|
|
1152
|
+
* zero are different facts. W5 established at some cost what happens when two
|
|
1153
|
+
* facts share one representation, and a chart is the easiest place in this
|
|
1154
|
+
* product to draw a gap as a line.
|
|
1155
|
+
*/
|
|
1156
|
+
export const historyBucketsResponse = z.object({
|
|
1157
|
+
slug: slug.meta({ description: 'The datapoint these buckets summarise.' }),
|
|
1158
|
+
kind: z.literal('buckets').meta({
|
|
1159
|
+
description: 'Says this is the aggregated shape, which the query asked for by naming a `window`. A separate shape rather than the sample shape with nulls in it, so a client knows by type what it received rather than by inspection.',
|
|
1160
|
+
}),
|
|
1161
|
+
window_ms: z.number().int().positive().meta({
|
|
1162
|
+
description: 'The bucket width actually used, in milliseconds — the query\'s `window` resolved to a number, so a rendered chart can say what it is drawing without re-parsing the expression it sent.',
|
|
1163
|
+
}),
|
|
1164
|
+
agg: z.enum(['min', 'max', 'avg']).meta({
|
|
1165
|
+
description: 'How each bucket reduced the samples inside it, echoed back from the query.',
|
|
1166
|
+
}),
|
|
1167
|
+
buckets: z.array(z.object({
|
|
1168
|
+
bucket_start_ms: z.number().int().nonnegative().meta({
|
|
1169
|
+
description: 'The instant this bucket opens, as a unix timestamp in milliseconds. Buckets are half-open and `window_ms` wide, so this one covers up to but not including `bucket_start_ms + window_ms`.',
|
|
1170
|
+
}),
|
|
1171
|
+
/**
|
|
1172
|
+
* The aggregate over this bucket's **numeric** samples — or `null` when
|
|
1173
|
+
* none of them were numeric, which is **not** the same as the bucket
|
|
1174
|
+
* being empty. `sample_count` is the field that separates those facts:
|
|
1175
|
+
*
|
|
1176
|
+
* value: null, sample_count: 0 nothing was recorded — a gap
|
|
1177
|
+
* value: null, sample_count: 3 three samples, none of them numeric
|
|
1178
|
+
* value: 0, sample_count: 3 three samples, and the average is zero
|
|
1179
|
+
*
|
|
1180
|
+
* A chart must draw the first as a break in the line and must **not**
|
|
1181
|
+
* draw the second as one: data exists there, it simply has no height.
|
|
1182
|
+
*
|
|
1183
|
+
* This sentence previously read "`null` only ever means 'no samples in
|
|
1184
|
+
* this bucket'", and the implementation counted numeric contributors,
|
|
1185
|
+
* so the second row above was indistinguishable from the first and the
|
|
1186
|
+
* console rendered "empty — no samples" over live data.
|
|
1187
|
+
*/
|
|
1188
|
+
value: z.number().nullable().meta({
|
|
1189
|
+
description: 'The aggregate over this bucket\'s **numeric** samples, or `null` when none of them were numeric — which is **not** the same as the bucket being empty. `sample_count` separates those: `null` with a count of `0` is a gap a chart should draw as a break, `null` with a count above `0` is data that simply has no height.',
|
|
1190
|
+
}),
|
|
1191
|
+
/**
|
|
1192
|
+
* Every sample that landed in this bucket and inside the queried range,
|
|
1193
|
+
* whether or not it contributed to `value` — which is the point of the
|
|
1194
|
+
* field, since only a count of *all* samples can prove a bucket empty
|
|
1195
|
+
* rather than merely unplottable.
|
|
1196
|
+
*
|
|
1197
|
+
* Two consequences, stated rather than left to be discovered:
|
|
1198
|
+
*
|
|
1199
|
+
* - `value` is not an average *of* `sample_count` samples when a
|
|
1200
|
+
* datapoint's values are mixed, so **`value * sample_count` is not a
|
|
1201
|
+
* sum**.
|
|
1202
|
+
* - On a first or last bucket the count reflects the **range**, not the
|
|
1203
|
+
* bucket: an edge bucket can begin before `from` or extend past `to`,
|
|
1204
|
+
* and only in-range samples are counted. A low edge count is a
|
|
1205
|
+
* boundary effect, not a quiet period.
|
|
1206
|
+
*/
|
|
1207
|
+
sample_count: z.number().int().nonnegative().meta({
|
|
1208
|
+
description: 'Every sample that landed in this bucket and inside the queried range, whether or not it contributed to `value` — only a count of *all* samples can prove a bucket empty rather than merely unplottable. Two consequences: `value * sample_count` is **not** a sum, and on a first or last bucket the count reflects the range rather than the bucket, so a low edge count is a boundary effect and not a quiet period.',
|
|
1209
|
+
}),
|
|
1210
|
+
})).meta({
|
|
1211
|
+
description: 'The buckets covering the queried window, oldest first. A range and window that would produce more than `limit` buckets is refused before the query runs, because this shape carries no `truncated` field and a refusal is then the only honest answer.',
|
|
1212
|
+
}),
|
|
1213
|
+
});
|
|
1214
|
+
/**
|
|
1215
|
+
* **What `GET /api/robots/:id/datapoints/:slug/history` answers, which is one
|
|
1216
|
+
* of two shapes.**
|
|
1217
|
+
*
|
|
1218
|
+
* **The query decides, and only the query**: without `window` it is a
|
|
1219
|
+
* `historySamplesResponse`, with one it is a `historyBucketsResponse`.
|
|
1220
|
+
* `window` and `agg` must be given together or not at all — one without the
|
|
1221
|
+
* other is refused rather than defaulted, since a silently chosen aggregation
|
|
1222
|
+
* is a chart that lies quietly.
|
|
1223
|
+
*
|
|
1224
|
+
* Told apart by `kind`, which is `'samples'` or `'buckets'`, so a client
|
|
1225
|
+
* branches on a field rather than on which other fields happen to be present.
|
|
1226
|
+
* The two are deliberately not one shape with nullable halves: an aggregate
|
|
1227
|
+
* and a raw reading answer different questions, and `sample_count` exists on
|
|
1228
|
+
* only one of them.
|
|
1229
|
+
*
|
|
1230
|
+
* **This union exists so the route can name a response at all.** The entry
|
|
1231
|
+
* carried `response: null` while the handler demonstrably answers something,
|
|
1232
|
+
* which reads in the generated reference as *this route returns nothing*.
|
|
1233
|
+
*/
|
|
1234
|
+
export const historyResponse = z.union([historySamplesResponse, historyBucketsResponse]);
|
|
1235
|
+
/**
|
|
1236
|
+
* W6a — deletion, and the one channel that reports health.
|
|
1237
|
+
*
|
|
1238
|
+
* | Route | Body | Answer |
|
|
1239
|
+
* |---|---|---|
|
|
1240
|
+
* | `DELETE /api/robots/:id` | — | `204`. `?force=true` to proceed while a live session is open; without it, `409 robot_in_use` |
|
|
1241
|
+
* | `GET /api/robots/:id/deletion-preview` | — | `robotDeletionSummary` — the same shape the audit event carries |
|
|
1242
|
+
* | `GET /api/org/health` | — | `resourceHealthListResponse`; `?robot_id=` narrows it to one robot |
|
|
1243
|
+
*
|
|
1244
|
+
* Plus `resourceHealthEvent`, pushed on the **developer** realtime socket
|
|
1245
|
+
* and scoped to the org — not to a subscription, because its job is to reach
|
|
1246
|
+
* somebody who is *not* looking at the thing that broke.
|
|
1247
|
+
*
|
|
1248
|
+
* Two of these paths are worth stating rather than inferring:
|
|
1249
|
+
*
|
|
1250
|
+
* **The preview exists because a confirmation must be able to name what it
|
|
1251
|
+
* destroys.** `DELETE` answers `204` with no body, so the counts only ever
|
|
1252
|
+
* appear on the audit event — written *after* the irreversible click. A
|
|
1253
|
+
* dialog built on that can say nothing better than "are you sure?". The
|
|
1254
|
+
* preview returns the *same shape* as the audit record on purpose: the
|
|
1255
|
+
* warning and the receipt then agree by construction, and a disagreement
|
|
1256
|
+
* between them is a real finding rather than two estimates drifting.
|
|
1257
|
+
*
|
|
1258
|
+
* **The snapshot and the event share the org's scope**, and the snapshot
|
|
1259
|
+
* takes an optional `robot_id` filter rather than living at a per-robot
|
|
1260
|
+
* path.
|
|
1261
|
+
*
|
|
1262
|
+
* The first version of this table said the opposite, with a justification
|
|
1263
|
+
* that sounded right and was incomplete: it reasoned only from a page that
|
|
1264
|
+
* has just opened one robot. But the console shows health on the **robot
|
|
1265
|
+
* list** too, and a per-robot path makes that N requests to render one
|
|
1266
|
+
* screen — while the event that must keep it fresh arrives org-wide anyway.
|
|
1267
|
+
* A snapshot and a channel that disagree about scope are not two halves of
|
|
1268
|
+
* one thing; they are two things that have to be reconciled by every
|
|
1269
|
+
* consumer, separately, forever.
|
|
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.
|
|
1277
|
+
*/
|
|
1278
|
+
/**
|
|
1279
|
+
* What a `robot.deleted` audit event carries (W6a).
|
|
1280
|
+
*
|
|
1281
|
+
* A deletion record that says only *that* something was destroyed is a
|
|
1282
|
+
* receipt for an unknown amount. This names it: how many configured slugs,
|
|
1283
|
+
* how many stored samples, how many bytes that freed against the retention
|
|
1284
|
+
* quota, which cameras existed, how much attributed run history went with
|
|
1285
|
+
* it, and whether somebody was watching at the time. Those are the questions
|
|
1286
|
+
* asked afterwards, and afterwards is the one moment the data cannot be
|
|
1287
|
+
* consulted.
|
|
1288
|
+
*/
|
|
1289
|
+
export const robotDeletionSummary = z.object({
|
|
1290
|
+
/**
|
|
1291
|
+
* Datapoints, actions, services and publishers in the **published**
|
|
1292
|
+
* configuration — what the robot was actually running. **Cameras are not
|
|
1293
|
+
* counted here**; they are the `cameras` array below.
|
|
1294
|
+
*
|
|
1295
|
+
* The split has to be stated because the summary carries both, and the
|
|
1296
|
+
* console renders them in one sentence: *"this deletes N published slugs …
|
|
1297
|
+
* and M cameras"*. With cameras inside `slug_count` that sentence counts
|
|
1298
|
+
* them twice, on the one screen whose whole justification is naming what an
|
|
1299
|
+
* irreversible click destroys (Momus, W6a review — the cloud summed all
|
|
1300
|
+
* five and the console then added the cameras again).
|
|
1301
|
+
*
|
|
1302
|
+
* A draft is destroyed too and is described by `had_unpublished_draft`
|
|
1303
|
+
* rather than by either of these: describing three things with two numbers
|
|
1304
|
+
* would make each of them mean something else.
|
|
1305
|
+
*/
|
|
1306
|
+
slug_count: z.number().int().nonnegative(),
|
|
1307
|
+
sample_rows: z.number().int().nonnegative(),
|
|
1308
|
+
bytes_freed: z.number().int().nonnegative(),
|
|
1309
|
+
cameras: z.array(slug),
|
|
1310
|
+
/**
|
|
1311
|
+
* Assets destroyed with the robot (W7), and **`asset_bytes_freed` is what
|
|
1312
|
+
* this org actually gets back** — not the sum of the assets' sizes.
|
|
1313
|
+
*
|
|
1314
|
+
* Storage is content-addressed, so a mesh two robots share survives the
|
|
1315
|
+
* deletion of one of them and frees nothing. Reporting the total would tell
|
|
1316
|
+
* a developer they are about to recover 400 MB and hand back 4, on the one
|
|
1317
|
+
* screen whose entire justification is naming what an irreversible click
|
|
1318
|
+
* destroys. Same reasoning that keeps `cameras` out of `slug_count`: this
|
|
1319
|
+
* summary is read aloud to a human, and a number that is nearly right is
|
|
1320
|
+
* worse here than an absent one.
|
|
1321
|
+
*
|
|
1322
|
+
* `asset_count` is the plain count of the robot's asset rows, all of which
|
|
1323
|
+
* do go away.
|
|
1324
|
+
*/
|
|
1325
|
+
asset_count: z.number().int().nonnegative(),
|
|
1326
|
+
asset_bytes_freed: z.number().int().nonnegative(),
|
|
1327
|
+
/**
|
|
1328
|
+
* How many rows of run history go with the robot — every recorded
|
|
1329
|
+
* invocation of one of its actions or services, up to
|
|
1330
|
+
* `JOB_RUN_RETENTION_DAYS`.
|
|
1331
|
+
*
|
|
1332
|
+
* Its own number, never folded into `slug_count`, for the same reason
|
|
1333
|
+
* `cameras` is not: `slug_count` counts *configuration* — what the robot
|
|
1334
|
+
* was set up to do — and this counts *what was actually done*, over as
|
|
1335
|
+
* much as 90 days. One robot with four slugs can carry forty thousand
|
|
1336
|
+
* runs, and a sentence that added them would describe two unrelated
|
|
1337
|
+
* magnitudes with one number on the one screen whose entire justification
|
|
1338
|
+
* is naming what an irreversible click destroys.
|
|
1339
|
+
*
|
|
1340
|
+
* It is also the only field here that names *people*: a run row carries
|
|
1341
|
+
* the `jobActor` who invoked it — a developer's or end user's email,
|
|
1342
|
+
* snapshotted at invoke time. So this deletion destroys attributed history
|
|
1343
|
+
* of who asked the machine to do what, which is a different kind of loss
|
|
1344
|
+
* from a count of sample rows and deserves to be said out loud rather than
|
|
1345
|
+
* inferred.
|
|
1346
|
+
*
|
|
1347
|
+
* **Bridge latency buckets are deliberately not counted here, and this is
|
|
1348
|
+
* the note saying so** rather than leaving the asymmetry to be
|
|
1349
|
+
* rediscovered as an omission. They are platform telemetry with a seven-day
|
|
1350
|
+
* life (`BRIDGE_LATENCY_RETENTION_DAYS`), produced by the cloud's own
|
|
1351
|
+
* pinging rather than by anything the developer did, counted against no
|
|
1352
|
+
* retention quota, and worth nothing to anybody after the robot is gone.
|
|
1353
|
+
* This summary is read aloud to a human deciding whether to click, and its
|
|
1354
|
+
* value comes from naming what the *developer* loses; a number for
|
|
1355
|
+
* telemetry they never asked for and cannot use would dilute exactly that.
|
|
1356
|
+
*/
|
|
1357
|
+
job_run_count: z.number().int().nonnegative(),
|
|
1358
|
+
had_live_session: z.boolean(),
|
|
1359
|
+
/**
|
|
1360
|
+
* Whether an unpublished draft went with it — separately, because the
|
|
1361
|
+
* counts above deliberately do not include it and a record that silently
|
|
1362
|
+
* omitted the draft would be a receipt for less than was destroyed.
|
|
1363
|
+
*
|
|
1364
|
+
* `true` also covers the robot that was configured but never published:
|
|
1365
|
+
* there the counts are zero and this is the only field saying anything
|
|
1366
|
+
* was there at all.
|
|
1367
|
+
*/
|
|
1368
|
+
had_unpublished_draft: z.boolean(),
|
|
1369
|
+
});
|
|
1370
|
+
/**
|
|
1371
|
+
* The query of `DELETE /api/robots/:id`.
|
|
1372
|
+
*
|
|
1373
|
+
* **`force=true` or nothing, and every other value is refused.** The handler
|
|
1374
|
+
* parses the query with this schema and answers `400 validation_error` on
|
|
1375
|
+
* anything else, so `?force=1` and `?force=TRUE` are neither forced nor
|
|
1376
|
+
* quietly un-forced. That is the whole point of the strictness: silently
|
|
1377
|
+
* false was the worst answer available, because a caller who believes they
|
|
1378
|
+
* authorised a cascade and did not then gets a `409` naming the very flag
|
|
1379
|
+
* they passed, and cannot tell which of the two happened.
|
|
1380
|
+
*
|
|
1381
|
+
* Declared as the literal string because it is the only value that does
|
|
1382
|
+
* anything — a `z.boolean()` here would describe a wire shape a query string
|
|
1383
|
+
* cannot carry, and a `z.string()` would document nothing. The MCP door takes
|
|
1384
|
+
* a real boolean and cannot express the ambiguity at all, so the two are one
|
|
1385
|
+
* policy in two vocabularies rather than two policies.
|
|
1386
|
+
*/
|
|
1387
|
+
export const robotDeleteQuery = z
|
|
1388
|
+
.object({
|
|
1389
|
+
force: z.literal('true').optional().meta({
|
|
1390
|
+
description: 'Pass `true` to delete a robot that has a live session open; without it that is `409 robot_in_use`. **`true` and nothing else** — any other value is `400 validation_error`, reported against the field `force` with rule `invalid_value`, so a caller is never left believing they forced a deletion they did not. The deletion is a full cascade, which is why saying it is the whole decision.',
|
|
1391
|
+
}),
|
|
1392
|
+
})
|
|
1393
|
+
.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
|
+
/**
|
|
1395
|
+
* The seven health states, declared **once** (W6a review).
|
|
1396
|
+
*
|
|
1397
|
+
* `resourceHealthState` and `resourceHealthEvent` are the snapshot and the
|
|
1398
|
+
* push of the same thing, and they had the same seven values written out
|
|
1399
|
+
* twice, linked by nothing — the artifacts published two independent copies
|
|
1400
|
+
* with no `$ref`. They agreed only because whoever added `unknown` remembered
|
|
1401
|
+
* to add it in both places, on the wave's last contract commit.
|
|
1402
|
+
*
|
|
1403
|
+
* One concept rendering as two artifacts that nothing keeps in step is its
|
|
1404
|
+
* own class of artifact-versus-source defect, distinct from `.default()`
|
|
1405
|
+
* publishing as `required` and from `z.coerce`'s unrepresentable input.
|
|
1406
|
+
*/
|
|
1407
|
+
export const RESOURCE_HEALTH_STATES = [
|
|
1408
|
+
'ok',
|
|
1409
|
+
/** The host did not answer. Not the same as refusing the password. */
|
|
1410
|
+
'unreachable',
|
|
1411
|
+
/** The host answered and rejected the credentials. */
|
|
1412
|
+
'auth_failed',
|
|
1413
|
+
/** The stored password cannot be decrypted — see `credentialSummary.readable`. */
|
|
1414
|
+
'unreadable_credential',
|
|
1415
|
+
/**
|
|
1416
|
+
* A camera names a credential that **does not exist** in this org — deleted,
|
|
1417
|
+
* mistyped, or belonging to somebody else (W6a review).
|
|
1418
|
+
*
|
|
1419
|
+
* Separate from `unreadable_credential` because that one asserts a
|
|
1420
|
+
* decryption that was attempted and failed, and here nothing was ever
|
|
1421
|
+
* encrypted: the developer is sent to a page where the credential is not
|
|
1422
|
+
* listed at all, to rotate something that is not there. And separate from
|
|
1423
|
+
* `unknown`, which means "the robot reported a failure we cannot classify"
|
|
1424
|
+
* — a different fact with a different fix.
|
|
1425
|
+
*
|
|
1426
|
+
* **Retiring with the credential store**, and not live behaviour to build
|
|
1427
|
+
* against. Its one producer was `cloud-config-frame.ts` tolerating an
|
|
1428
|
+
* unresolved `credentials_ref` at publish time; FL-002 deleted that field,
|
|
1429
|
+
* so nothing emits this today. It is kept only until the wave that removes
|
|
1430
|
+
* the store also removes these three credential states — `unreadable_credential`
|
|
1431
|
+
* and the `readable` fact on `credentialSummary` go the same way.
|
|
1432
|
+
*/
|
|
1433
|
+
'credential_missing',
|
|
1434
|
+
/** A configuration change stopped this stream, deliberately. */
|
|
1435
|
+
'stopped_by_config_change',
|
|
1436
|
+
/** Publishing failed after the session was already granted. */
|
|
1437
|
+
'publish_failed',
|
|
1438
|
+
/**
|
|
1439
|
+
* Something is wrong and this platform cannot say what.
|
|
1440
|
+
*
|
|
1441
|
+
* The alternative was worse. A bridge error code the mapping table does not
|
|
1442
|
+
* know had two possible fallbacks: report `ok`, which hides a real failure,
|
|
1443
|
+
* or fold it into `unreachable`, which **asserts a cause nobody
|
|
1444
|
+
* established** — sending a developer to check a network when the problem
|
|
1445
|
+
* may be a password. The map falls back here and logs the unmapped code
|
|
1446
|
+
* loudly, so the gap in the table is visible instead of confident.
|
|
1447
|
+
*/
|
|
1448
|
+
'unknown',
|
|
1449
|
+
];
|
|
1450
|
+
/**
|
|
1451
|
+
* The health of one thing a developer configured, as the platform currently
|
|
1452
|
+
* sees it (W6a).
|
|
1453
|
+
*
|
|
1454
|
+
* This exists because four separate findings turned out to be one absence:
|
|
1455
|
+
* nothing carried the state of a camera, a source or a credential to a
|
|
1456
|
+
* developer who was not, at that exact moment, pressing a button. A publish
|
|
1457
|
+
* failure after the `201` never reached the viewer holding the token; a
|
|
1458
|
+
* source whose password was wrong failed at config-apply time with nobody
|
|
1459
|
+
* watching and stayed silent until someone pressed "Go live" days later; a
|
|
1460
|
+
* viewer could not learn *why* a stream ended, so the console had to offer
|
|
1461
|
+
* two possibilities and rank neither; and an undecryptable credential
|
|
1462
|
+
* reported as healthy.
|
|
1463
|
+
*
|
|
1464
|
+
* One shape, because four patches against four symptoms is how W5 nearly
|
|
1465
|
+
* wrote a failure report into `publishState` — a field the cloud writes and
|
|
1466
|
+
* reads in exactly one place, which would have been a dead end.
|
|
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.
|
|
1472
|
+
*/
|
|
1473
|
+
export const resourceHealthState = z.object({
|
|
1474
|
+
robot_id: z.uuid(),
|
|
1475
|
+
kind: z.enum(['camera']),
|
|
1476
|
+
/** The camera slug, or the credential name. */
|
|
1477
|
+
ref: z.string().min(1).max(64),
|
|
1478
|
+
/**
|
|
1479
|
+
* **Which of two questions this entry answers (W9a, DEF-072).**
|
|
1480
|
+
*
|
|
1481
|
+
* `'source'` — can the source be read at all? (`unreachable`, `auth_failed`,
|
|
1482
|
+
* `unreadable_credential`, `missing_credential`, `ok`, …)
|
|
1483
|
+
* `'publish'` — given a readable source, did publishing to LiveKit work?
|
|
1484
|
+
*
|
|
1485
|
+
* Before this, both went into one entry keyed `${robot} ${kind} ${ref}` with
|
|
1486
|
+
* one flat `state`, in which `publish_failed` answered *"can we publish"*
|
|
1487
|
+
* and every other value answered *"can the source be read"* — **same key,
|
|
1488
|
+
* same field, two questions**, so each overwrote the other. The conflation
|
|
1489
|
+
* was once an occasional race; W6a's reconnect restatement made it
|
|
1490
|
+
* guaranteed, on every reconnect, for any camera with an active viewer.
|
|
1491
|
+
*
|
|
1492
|
+
* The facet is part of the entry's identity: a camera can perfectly well be
|
|
1493
|
+
* readable and unpublishable at the same moment, and that pair is exactly
|
|
1494
|
+
* what a developer needs to see rather than whichever fact arrived last.
|
|
1495
|
+
*/
|
|
1496
|
+
facet: z.enum(['source', 'publish']),
|
|
1497
|
+
state: z.enum(RESOURCE_HEALTH_STATES),
|
|
1498
|
+
/** A short human-readable reason, or `null`. Never an exception message. */
|
|
1499
|
+
reason: z.string().max(200).nullable(),
|
|
1500
|
+
/**
|
|
1501
|
+
* When this state was entered — not when it was sent. A page that loads
|
|
1502
|
+
* late must be able to tell a failure from a minute ago from one from
|
|
1503
|
+
* yesterday, and a state with only a send time cannot.
|
|
1504
|
+
*/
|
|
1505
|
+
changed_at_ms: z.number().int().nonnegative(),
|
|
1506
|
+
});
|
|
1507
|
+
/**
|
|
1508
|
+
* The current state of everything in the **org**.
|
|
1509
|
+
*
|
|
1510
|
+
* This doc said "on one robot" until the W6a review found it: the route moved
|
|
1511
|
+
* to org scope in `2bb67c5` and the route table forty lines above spends a
|
|
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
|
|
1518
|
+
* page that just loaded — it can only report the next change, which may be
|
|
1519
|
+
* hours away. Both halves or neither.
|
|
1520
|
+
*/
|
|
1521
|
+
export const resourceHealthListResponse = z.object({
|
|
1522
|
+
resources: z.array(resourceHealthState),
|
|
1523
|
+
});
|
|
1524
|
+
/**
|
|
1525
|
+
* The query of `GET /api/org/health`: optionally one robot instead of the org.
|
|
1526
|
+
*
|
|
1527
|
+
* The narrowing lives in a query rather than at a per-robot path because the
|
|
1528
|
+
* console shows health on the robot list too, and a per-robot path would make
|
|
1529
|
+
* that N requests to render one screen.
|
|
1530
|
+
*/
|
|
1531
|
+
export const orgHealthQuery = z
|
|
1532
|
+
.object({
|
|
1533
|
+
robot_id: z.uuid().optional().meta({
|
|
1534
|
+
description: 'Narrows the report to one robot. Omit it for every robot in the org. Malformed is `400 invalid_uuid` and a robot of another org is `404 not_found` — the same two answers an MCP caller gets, because the check lives in the shared service rather than on the route.',
|
|
1535
|
+
}),
|
|
1536
|
+
})
|
|
1537
|
+
.meta({ description: 'The optional robot filter of `GET /api/org/health`.' });
|
|
1538
|
+
/**
|
|
1539
|
+
* Org protection quotas (§12.4) — generous, server-side adjustable, visible
|
|
1540
|
+
* in Settings. Protection against runaway use, not a business model; a later
|
|
1541
|
+
* one docks onto the same dials.
|
|
1542
|
+
*/
|
|
1543
|
+
export const orgQuotas = z.object({
|
|
1544
|
+
max_robots: z.number().int().positive(),
|
|
1545
|
+
max_apps: z.number().int().positive(),
|
|
1546
|
+
max_end_users: z.number().int().positive(),
|
|
1547
|
+
max_retention_bytes: z.number().int().nonnegative(),
|
|
1548
|
+
max_retention_writes_per_minute: z.number().int().nonnegative(),
|
|
1549
|
+
max_realtime_connections: z.number().int().positive(),
|
|
1550
|
+
/**
|
|
1551
|
+
* Asset storage (§4.6, W7) — **its own dial, not part of
|
|
1552
|
+
* `max_retention_bytes`.** A sync grows storage in jumps and time series
|
|
1553
|
+
* grow steadily; one dial would let the first crowd out the second, and the
|
|
1554
|
+
* org that hit its limit would be told to look at the wrong thing.
|
|
1555
|
+
*
|
|
1556
|
+
* **Counted per distinct blob *this org references* — not per asset row, and
|
|
1557
|
+
* not per object the platform stores on its behalf (W7a, D1).** The two
|
|
1558
|
+
* readings are indistinguishable from the number alone and a customer is
|
|
1559
|
+
* entitled to know which one they are being charged for.
|
|
1560
|
+
*
|
|
1561
|
+
* Within an org, sharing is free: two robots referencing the same mesh cost
|
|
1562
|
+
* one copy, which is what dedup means to a customer, and anything else
|
|
1563
|
+
* charges an org twice for a fleet of identical robots — the normal case.
|
|
1564
|
+
*
|
|
1565
|
+
* **Across orgs, sharing is not free, and W7 shipped the opposite.** Storage
|
|
1566
|
+
* stays globally content-addressed (one object per sha256; that efficiency
|
|
1567
|
+
* is real), but accounting is per-org: an org is charged for each distinct
|
|
1568
|
+
* blob it references and credited when its own last reference goes, whether
|
|
1569
|
+
* or not the blob survives for somebody else. Global refcounting made the
|
|
1570
|
+
* first org to sync a blob pay for it forever while every later org stored
|
|
1571
|
+
* it free — so the quota was evadable by anyone whose mesh someone else had
|
|
1572
|
+
* already uploaded, and an org's own number depended on who got there first,
|
|
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.
|
|
1575
|
+
*/
|
|
1576
|
+
max_asset_storage_bytes: z.number().int().nonnegative(),
|
|
1577
|
+
});
|
|
1578
|
+
/**
|
|
1579
|
+
* What an org is **actually using**, per quota.
|
|
1580
|
+
*
|
|
1581
|
+
* A separate shape rather than `orgQuotas.partial()`, which is what this was
|
|
1582
|
+
* first — and that was wrong in a way its own tests caught: a limit is
|
|
1583
|
+
* `positive()` because a quota of zero would forbid everything, but a
|
|
1584
|
+
* **usage** of zero is the honest answer for every org on the day it signs
|
|
1585
|
+
* 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, which
|
|
1587
|
+
* this wave spent a lot of care avoiding one layer up.
|
|
1588
|
+
*
|
|
1589
|
+
* Every field is optional because a quota we do not measure must be
|
|
1590
|
+
* **absent**, never reported as `0` — "not measured" and "measured as zero"
|
|
1591
|
+
* are different facts, and a dashboard that renders the first as the second
|
|
1592
|
+
* is lying quietly.
|
|
1593
|
+
*/
|
|
1594
|
+
export const orgQuotaUsageCounts = z.object({
|
|
1595
|
+
max_robots: z.number().int().nonnegative(),
|
|
1596
|
+
max_apps: z.number().int().nonnegative(),
|
|
1597
|
+
max_end_users: z.number().int().nonnegative(),
|
|
1598
|
+
max_retention_bytes: z.number().int().nonnegative(),
|
|
1599
|
+
max_asset_storage_bytes: z.number().int().nonnegative(),
|
|
1600
|
+
max_retention_writes_per_minute: z.number().int().nonnegative(),
|
|
1601
|
+
max_realtime_connections: z.number().int().nonnegative(),
|
|
1602
|
+
}).partial();
|
|
1603
|
+
/** Limits beside what is actually used — a limit alone tells nobody where they stand. */
|
|
1604
|
+
export const orgQuotaUsage = z.object({ quotas: orgQuotas, usage: orgQuotaUsageCounts });
|
|
1605
|
+
/** One bucket is one minute. Stated here so the cloud and any client agree without guessing. */
|
|
1606
|
+
export const LATENCY_BUCKET_MS = 60_000;
|
|
1607
|
+
/**
|
|
1608
|
+
* Latency buckets are **platform telemetry, not a customer datapoint**, and
|
|
1609
|
+
* this short retention is why that distinction was worth making: the cloud
|
|
1610
|
+
* pings every bridge every 2 seconds, ~43 200 measurements per robot per day,
|
|
1611
|
+
* and a sparkline needs about 60 points per hour. Seven days is generous for
|
|
1612
|
+
* what reads it and costs the org's retention quota nothing, because it is not
|
|
1613
|
+
* counted against it.
|
|
1614
|
+
*/
|
|
1615
|
+
export const BRIDGE_LATENCY_RETENTION_DAYS = 7;
|
|
1616
|
+
/**
|
|
1617
|
+
* Every read of the durable run history and the latency buckets: the three
|
|
1618
|
+
* org-wide ones the fleet overview is built on, and the one robot-scoped door
|
|
1619
|
+
* a client app has into the same table.
|
|
1620
|
+
*
|
|
1621
|
+
* | Route | Query | Answer |
|
|
1622
|
+
* |---|---|---|
|
|
1623
|
+
* | `GET /api/org/jobs` | `jobRunQuery` | `jobRunListResponse` — newest first, cursor-paged over the durable `seq` |
|
|
1624
|
+
* | `GET /api/org/jobs/summary` | `jobRunSummaryQuery` | `jobRunSummary` — three numbers over the window the caller named |
|
|
1625
|
+
* | `GET /api/org/latency` | `orgLatencyQuery` | `orgLatencyResponse` — one series per robot, truncation named |
|
|
1626
|
+
* | `GET /api/robots/:id/jobs/history` | `jobRunQuery` | `jobRunListResponse` — the same read, robot-scoped, developers **and** clients |
|
|
1627
|
+
*
|
|
1628
|
+
* **Written down here because the last time a delta shipped shapes without
|
|
1629
|
+
* their paths, a teammate had to ask three separate people** — see
|
|
1630
|
+
* `robotDeletionSummary`'s neighbouring table, which exists for exactly that
|
|
1631
|
+
* reason. The shapes landed one wave before the routes did, so this table is
|
|
1632
|
+
* the only place the two halves meet.
|
|
1633
|
+
*
|
|
1634
|
+
* Three things about them are worth stating rather than inferring:
|
|
1635
|
+
*
|
|
1636
|
+
* **The three `/api/org/…` reads are org-wide, and `?robot_id=` narrows
|
|
1637
|
+
* them** — the same choice `GET /api/org/health` already made, for the same
|
|
1638
|
+
* reason: the overview screen shows every robot at once, and a per-robot path
|
|
1639
|
+
* would make one screen N requests.
|
|
1640
|
+
*
|
|
1641
|
+
* **Those three are developer-only, and that is a property of their scope,
|
|
1642
|
+
* not of the data.** An org-wide read has no client meaning: an end user is
|
|
1643
|
+
* scoped to the robots their app assigns, never to an org.
|
|
1644
|
+
*
|
|
1645
|
+
* **The client-facing read of the same table is
|
|
1646
|
+
* `GET /api/robots/:id/jobs/history`** — robot-scoped, one route for
|
|
1647
|
+
* developers and clients like every other robot-scoped read (`.../jobs`,
|
|
1648
|
+
* `.../assets`, `.../datapoints`), never a parallel `/api/client/…` twin. An
|
|
1649
|
+
* end user reaches it only when their role's `capabilities.action_history`
|
|
1650
|
+
* says so — otherwise `403 capability_required`, naming the capability — and
|
|
1651
|
+
* sees only runs on slugs their role grants. On this route `?robot_id=` is
|
|
1652
|
+
* not a filter: the path already names the robot, and a query naming a
|
|
1653
|
+
* different one is refused rather than quietly answered about the path's.
|
|
1654
|
+
*
|
|
1655
|
+
* **It discloses the actor, and that is what a developer weighs before
|
|
1656
|
+
* granting the capability.** A `jobRun` names who invoked it — `jobActor`
|
|
1657
|
+
* carries an email — so an end user reading a robot's history learns which
|
|
1658
|
+
* other people have been driving that machine. Robot scope plus a role
|
|
1659
|
+
* capability is what makes that a decision a developer takes per role,
|
|
1660
|
+
* instead of something every session gets: an end-user-facing
|
|
1661
|
+
* `GET /api/org/jobs` would have handed over the whole org's actors with no
|
|
1662
|
+
* such decision anywhere, which is why there is none.
|
|
1663
|
+
*
|
|
1664
|
+
* **A page can be shorter than `limit` while `next_cursor` is non-null**, on
|
|
1665
|
+
* the robot-scoped route specifically: the slug filter is applied to the
|
|
1666
|
+
* page the store returned, so a role granting one slug in ten sees thin — and
|
|
1667
|
+
* sometimes empty — pages. That is what `jobRunListResponse.next_cursor`'s
|
|
1668
|
+
* own doc comment means by a promise rather than an observation; a client
|
|
1669
|
+
* keeps reading until it is null.
|
|
1670
|
+
*
|
|
1671
|
+
* **Neither window is optional, and neither has a default.** A summary over
|
|
1672
|
+
* an unnamed window is a number nobody can reproduce; an unbounded latency
|
|
1673
|
+
* window is a response size chosen by whoever forgot to pass one. Each
|
|
1674
|
+
* query's own doc comment says which of those two reasons applies to it.
|
|
1675
|
+
*/
|
|
1676
|
+
/** Seven days x 1440 buckets x N robots is otherwise an unbounded response. */
|
|
1677
|
+
export const MAX_LATENCY_BUCKETS_PER_RESPONSE = 20_000;
|
|
1678
|
+
export const latencyBucket = z.object({
|
|
1679
|
+
/** Truncated to the minute. */
|
|
1680
|
+
bucket_at: z.iso.datetime(),
|
|
1681
|
+
/**
|
|
1682
|
+
* `null` exactly when `samples` is 0. A minute in which the robot was offline
|
|
1683
|
+
* throughout has **no** latency; writing `0` would put the number meaning
|
|
1684
|
+
* "perfectly fast" into the state meaning "not there at all".
|
|
1685
|
+
*/
|
|
1686
|
+
min_ms: z.number().nonnegative().nullable(),
|
|
1687
|
+
avg_ms: z.number().nonnegative().nullable(),
|
|
1688
|
+
max_ms: z.number().nonnegative().nullable(),
|
|
1689
|
+
samples: z.number().int().nonnegative(),
|
|
1690
|
+
/**
|
|
1691
|
+
* Milliseconds of this bucket the cloud held the robot online.
|
|
1692
|
+
*
|
|
1693
|
+
* A duration and **not a ratio**: a ratio needs a denominator, and here that
|
|
1694
|
+
* would be expected pings per minute — `pingIntervalMs`, which is
|
|
1695
|
+
* configurable and is shrunk in tests. A stored value whose meaning depends
|
|
1696
|
+
* on a configuration variable is not comparable across the time it is stored
|
|
1697
|
+
* for. A client divides by `LATENCY_BUCKET_MS` if it wants a fraction.
|
|
1698
|
+
*/
|
|
1699
|
+
online_ms: z.number().int().min(0).max(LATENCY_BUCKET_MS),
|
|
1700
|
+
});
|
|
1701
|
+
export const robotLatencySeries = z.object({
|
|
1702
|
+
robot_id: z.uuid(),
|
|
1703
|
+
buckets: z.array(latencyBucket),
|
|
1704
|
+
});
|
|
1705
|
+
/**
|
|
1706
|
+
* `GET /api/org/latency`'s query.
|
|
1707
|
+
*
|
|
1708
|
+
* **Both bounds are required**, for a reason narrower than
|
|
1709
|
+
* `jobRunSummaryQuery`'s: this table holds a bucket per robot per minute for
|
|
1710
|
+
* `BRIDGE_LATENCY_RETENTION_DAYS`, so "everything" is up to 10 080 rows per
|
|
1711
|
+
* robot, and a default window would be a response size chosen by whoever
|
|
1712
|
+
* forgot to pass one. `MAX_LATENCY_BUCKETS_PER_RESPONSE` still bounds the
|
|
1713
|
+
* answer; required bounds are what let a caller decide *which* buckets they
|
|
1714
|
+
* get instead of discovering the ceiling ate the ones they wanted.
|
|
1715
|
+
*
|
|
1716
|
+
* `wireTimestampMs` rather than a plain integer, for its own documented
|
|
1717
|
+
* reason: the union's input branch is what a query string actually carries,
|
|
1718
|
+
* and the year bound is what keeps `253402300800000` from reaching the
|
|
1719
|
+
* Postgres bind path as a `500` where a `400` belongs.
|
|
1720
|
+
*/
|
|
1721
|
+
export const orgLatencyQuery = z
|
|
1722
|
+
.object({
|
|
1723
|
+
from_ms: wireTimestampMs,
|
|
1724
|
+
/** Exclusive — half-open `[from, to)`, the convention every other query here already follows (DEF-062). */
|
|
1725
|
+
to_ms: wireTimestampMs,
|
|
1726
|
+
/**
|
|
1727
|
+
* One robot's own sparkline. `z.uuid()`, because the column is one —
|
|
1728
|
+
* the same fix in the same place `auditQuery.actor_id` documents at
|
|
1729
|
+
* length: a non-uuid reaching Postgres as a uuid parameter answers
|
|
1730
|
+
* `500 internal_error`, and a 500 explains nothing.
|
|
1731
|
+
*/
|
|
1732
|
+
robot_id: z.uuid().optional(),
|
|
1733
|
+
})
|
|
1734
|
+
.strict()
|
|
1735
|
+
/**
|
|
1736
|
+
* Refused here rather than in the route, so an inverted window comes back
|
|
1737
|
+
* as part of the same `validation_error` every other bad parameter
|
|
1738
|
+
* produces. Strict, not `<=`: an empty half-open window is a query with no
|
|
1739
|
+
* answer, and a caller who asked for one has made a mistake worth being
|
|
1740
|
+
* told about rather than being handed an empty series that reads like a
|
|
1741
|
+
* quiet robot.
|
|
1742
|
+
*
|
|
1743
|
+
* **The published artifact cannot express this**, and that is worth saying
|
|
1744
|
+
* out loud rather than leaving a reader to assume the JSON Schema is the
|
|
1745
|
+
* whole contract: a cross-field comparison has no JSON Schema rendering, so
|
|
1746
|
+
* `org-latency-query.schema.json` describes two independent integers and
|
|
1747
|
+
* validates an inverted window happily. The cloud is the only enforcement
|
|
1748
|
+
* point for the ordering; a generated client that validates against the
|
|
1749
|
+
* artifact alone will get a `400` from the route it did not predict, which
|
|
1750
|
+
* is the correct outcome and not a drift bug.
|
|
1751
|
+
*/
|
|
1752
|
+
.refine((query) => query.from_ms < query.to_ms, {
|
|
1753
|
+
message: 'from_ms must be strictly before to_ms',
|
|
1754
|
+
path: ['from_ms'],
|
|
1755
|
+
});
|
|
1756
|
+
export const orgLatencyResponse = z.object({
|
|
1757
|
+
series: z.array(robotLatencySeries),
|
|
1758
|
+
from_ms: z.number().int().nonnegative(),
|
|
1759
|
+
to_ms: z.number().int().nonnegative(),
|
|
1760
|
+
truncated: z.boolean(),
|
|
1761
|
+
/**
|
|
1762
|
+
* Which ceiling cut the response short, `null` when nothing did — borrowed
|
|
1763
|
+
* from `historySamplesResponse.truncated_by` rather than invented a second
|
|
1764
|
+
* time, for its reason: one boolean cannot carry two different remedies.
|
|
1765
|
+
*/
|
|
1766
|
+
truncated_by: z.enum(['limit', 'bytes']).nullable(),
|
|
1767
|
+
});
|
|
1768
|
+
/**
|
|
1769
|
+
* How long a usage window may be, in days. **Refused above this, not capped** —
|
|
1770
|
+
* the rule `jobRunQuery.limit` already states: a caller who asked for more than
|
|
1771
|
+
* the platform will answer is owed a `400` naming the field, not a quietly
|
|
1772
|
+
* shorter answer they will mistake for the whole picture.
|
|
1773
|
+
*
|
|
1774
|
+
* 366 rather than 365, so "the last full year" is expressible in a leap year.
|
|
1775
|
+
*/
|
|
1776
|
+
export const USAGE_WINDOW_MAX_DAYS = 366;
|
|
1777
|
+
/**
|
|
1778
|
+
* The five things the meter records (spec D1).
|
|
1779
|
+
*
|
|
1780
|
+
* Storage is two metrics and not one summed byte count, for
|
|
1781
|
+
* `org_quotas.max_asset_storage_bytes`'s own reason applied to billing: a sync
|
|
1782
|
+
* grows storage in jumps and time series grow steadily, and one number would
|
|
1783
|
+
* let the first crowd out the second on the invoice the same way it would on
|
|
1784
|
+
* the quota.
|
|
1785
|
+
*/
|
|
1786
|
+
export const usageMetric = z.enum(['api_calls', 'live_session_ms', 'retention_bytes', 'asset_bytes', 'robot_online_ms']);
|
|
1787
|
+
/**
|
|
1788
|
+
* A UTC calendar day, `YYYY-MM-DD`.
|
|
1789
|
+
*
|
|
1790
|
+
* A string and not a millisecond instant, because the thing being described is
|
|
1791
|
+
* a day and not a moment: a `Date` here would carry a time and a zone the
|
|
1792
|
+
* column does not have, and every bug in this area starts with one being
|
|
1793
|
+
* silently converted.
|
|
1794
|
+
*
|
|
1795
|
+
* **The regex checks shape, not validity** — `2026-13-45` and `2026-02-30`
|
|
1796
|
+
* both match `\d{4}-\d{2}-\d{2}$` — so the `.refine()` below round-trips the
|
|
1797
|
+
* string through `Date`'s UTC parser and rejects anything that does not come
|
|
1798
|
+
* back unchanged: `2026-13-45` parses to `Invalid Date`, and `2026-02-30`
|
|
1799
|
+
* (which `Date` rolls over rather than rejects) comes back as `2026-03-02`,
|
|
1800
|
+
* a mismatch either way. Same defect class as `auditQuery.from_ms`'s
|
|
1801
|
+
* `253402300800000`: a value that is the right *shape* reaching the Postgres
|
|
1802
|
+
* bind path for a `date` column and answering `500` where `400` belongs.
|
|
1803
|
+
*
|
|
1804
|
+
* **What the published artifact does not say:** `wireTimestampMs`'s own
|
|
1805
|
+
* note applies unchanged — a `.refine()` has no JSON Schema rendering, so
|
|
1806
|
+
* `org-usage-query.schema.json` shows only the shape-checking `pattern` and
|
|
1807
|
+
* a generated client that validates against the artifact alone will believe
|
|
1808
|
+
* `2026-02-30` is acceptable. The runtime is the authority for this field.
|
|
1809
|
+
*/
|
|
1810
|
+
export const usageDay = z
|
|
1811
|
+
.string()
|
|
1812
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, 'must be a UTC calendar day, YYYY-MM-DD')
|
|
1813
|
+
.refine((day) => {
|
|
1814
|
+
const parsed = new Date(`${day}T00:00:00.000Z`);
|
|
1815
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === day;
|
|
1816
|
+
}, { message: 'must be a UTC calendar day, YYYY-MM-DD' });
|
|
1817
|
+
/**
|
|
1818
|
+
* **The window is inclusive at both ends**, unlike every millisecond window in
|
|
1819
|
+
* this file (`from_ms`/`to_ms`, half-open per DEF-062).
|
|
1820
|
+
*
|
|
1821
|
+
* That inconsistency is deliberate and is stated here rather than left to be
|
|
1822
|
+
* discovered: a calendar day is a unit, not an instant, and a person asking for
|
|
1823
|
+
* July will write `from_day=2026-07-01&to_day=2026-07-31`. A half-open day
|
|
1824
|
+
* window would silently drop the 31st.
|
|
1825
|
+
*
|
|
1826
|
+
* Both parameters are required and have no default — the rule `/api/org/latency`
|
|
1827
|
+
* and `/api/org/jobs/summary` already follow. "This month" is a question only
|
|
1828
|
+
* the caller's calendar can answer, and a default window would be a query size
|
|
1829
|
+
* chosen by whoever forgot to pass one.
|
|
1830
|
+
*
|
|
1831
|
+
* **The published artifact cannot express any of this**, and that is worth
|
|
1832
|
+
* saying out loud rather than leaving a reader to assume the JSON Schema is
|
|
1833
|
+
* the whole contract, for `orgLatencyQuery`'s own reason: a cross-field
|
|
1834
|
+
* comparison has no JSON Schema rendering, so `org-usage-query.schema.json`
|
|
1835
|
+
* describes two independent pattern-matched strings and validates an
|
|
1836
|
+
* inverted window happily — the cloud is the only enforcement point for the
|
|
1837
|
+
* ordering. The artifact is equally silent about the inclusivity called out
|
|
1838
|
+
* above: nothing in the shape distinguishes an inclusive day window from a
|
|
1839
|
+
* half-open one, that is a fact about behaviour, not a field (the same gap
|
|
1840
|
+
* `historyQuery`/`historyBucketsResponse` name for their own half-open
|
|
1841
|
+
* boundary). And it says nothing about `USAGE_WINDOW_MAX_DAYS` at all — the
|
|
1842
|
+
* constant is not wired into this schema as a check on the span between
|
|
1843
|
+
* `from_day` and `to_day`; the cloud route is where a caller who asked for
|
|
1844
|
+
* more than the ceiling is refused, so a generated client validating against
|
|
1845
|
+
* the artifact alone can build a five-year window and get a `400` from the
|
|
1846
|
+
* route it did not predict.
|
|
1847
|
+
*/
|
|
1848
|
+
export const orgUsageQuery = z
|
|
1849
|
+
.object({ from_day: usageDay, to_day: usageDay })
|
|
1850
|
+
.strict()
|
|
1851
|
+
.refine((query) => query.from_day <= query.to_day, {
|
|
1852
|
+
message: 'from_day must not be after to_day',
|
|
1853
|
+
path: ['from_day'],
|
|
1854
|
+
});
|
|
1855
|
+
/**
|
|
1856
|
+
* One day's reading for one metric.
|
|
1857
|
+
*
|
|
1858
|
+
* **`app_id` is `null` when the consumer is the org itself** (spec D2), and
|
|
1859
|
+
* what that `null` means for billing depends on the *metric*, not on
|
|
1860
|
+
* `app_id` alone. `api_calls` and `live_session_ms` are attributable to an
|
|
1861
|
+
* app: a `null` app_id on those two is the developer console's own traffic,
|
|
1862
|
+
* deliberately *not* billable. `retention_bytes`, `asset_bytes` and
|
|
1863
|
+
* `robot_online_ms` have no app dimension at all — every row for those three
|
|
1864
|
+
* carries `app_id: null` unconditionally, and every one is billable org-level
|
|
1865
|
+
* consumption. **A reader must check `metric` before treating `app_id ===
|
|
1866
|
+
* null` as "not billable"** — for three of the five metrics that reading is
|
|
1867
|
+
* always wrong.
|
|
1868
|
+
*
|
|
1869
|
+
* `app_name` is `null` whenever `app_id` is, and also when the app has since
|
|
1870
|
+
* been deleted — usage outlives the app it was attributed to, because an org
|
|
1871
|
+
* still owes for what it used. A UUID alone on an invoice line helps nobody,
|
|
1872
|
+
* and a copy of the name stored on every row would be a second truth that
|
|
1873
|
+
* drifts on the first rename.
|
|
1874
|
+
*
|
|
1875
|
+
* **What this number cannot promise**, and the bound is conditional rather
|
|
1876
|
+
* than flat. `api_calls` and `live_session_ms` are aggregated in memory and
|
|
1877
|
+
* written every 30 seconds.
|
|
1878
|
+
*
|
|
1879
|
+
* *While those writes are landing*, a `kill -9` loses up to 30 seconds of
|
|
1880
|
+
* counting — never more, and never against the caller, since an unflushed
|
|
1881
|
+
* count is simply not billed.
|
|
1882
|
+
*
|
|
1883
|
+
* *While they are failing* — an unreachable database, say — that bound does
|
|
1884
|
+
* not hold at all: everything counted since the last successful flush is
|
|
1885
|
+
* held in memory, deliberately uncapped, and a `kill -9` loses all of it.
|
|
1886
|
+
* 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
|
+
* whose writes are landing, not a guarantee that survives an outage. This
|
|
1889
|
+
* sentence used to say "never more", and it was false.
|
|
1890
|
+
*
|
|
1891
|
+
* A row the database rejects **permanently** — most concretely one whose org
|
|
1892
|
+
* has been deleted since the count, since a usage row's `org_id` is `ON
|
|
1893
|
+
* DELETE NO ACTION` — is written off instead: given up on, reported with a
|
|
1894
|
+
* count, and never billed. That is a deliberate loss, and it is the smaller
|
|
1895
|
+
* one. Before it, a single such row failed the whole batched write on every
|
|
1896
|
+
* retry, forever, and stopped `api_calls` and `live_session_ms` reaching the
|
|
1897
|
+
* database for **every** org on the platform.
|
|
1898
|
+
*
|
|
1899
|
+
* A graceful shutdown loses nothing **provided its final flush succeeds**.
|
|
1900
|
+
* If that write fails, the process reports how many rows it is carrying and
|
|
1901
|
+
* exits carrying them — there is no second attempt, because there is no
|
|
1902
|
+
* longer a process to make one.
|
|
1903
|
+
*
|
|
1904
|
+
* The other three metrics never travel this path. They are sampled from
|
|
1905
|
+
* other tables on their own timer and can lag; what a missed sample costs,
|
|
1906
|
+
* per metric, is in the docs' `/api/org/usage` notes.
|
|
1907
|
+
*/
|
|
1908
|
+
export const usageRow = z.object({
|
|
1909
|
+
app_id: z.uuid().nullable(),
|
|
1910
|
+
app_name: z.string().nullable(),
|
|
1911
|
+
metric: usageMetric,
|
|
1912
|
+
day: usageDay,
|
|
1913
|
+
value: z.number().int().nonnegative(),
|
|
1914
|
+
});
|
|
1915
|
+
/** The window is echoed back for `orgLatencyResponse`'s reason: a rendered total has to be able to say which window it describes. */
|
|
1916
|
+
export const orgUsageResponse = z.object({
|
|
1917
|
+
rows: z.array(usageRow),
|
|
1918
|
+
from_day: usageDay,
|
|
1919
|
+
to_day: usageDay,
|
|
1920
|
+
});
|
|
1921
|
+
/** `PATCH /api/robots/:id` — rename the robot. Display-only: nothing references robot names. */
|
|
1922
|
+
export const patchRobotRequest = z.object({ name: z.string().min(1).max(63) }).strict();
|
|
1923
|
+
/**
|
|
1924
|
+
* `POST /api/robots/:id/config/rename-slug` — atomic server-side rename:
|
|
1925
|
+
* rewrites the **draft** config, every app-role grant carrying
|
|
1926
|
+
* `{robot_id, from}`, and the recorded history rows, in one transaction.
|
|
1927
|
+
* Job runs and audit events keep the old slug as historical fact. The
|
|
1928
|
+
* published config is immutable, so the caller must publish afterwards
|
|
1929
|
+
* (`requires_publish`); samples arriving between rename and the applied
|
|
1930
|
+
* publish still land under the old slug — named residual, not migrated.
|
|
1931
|
+
* Second residual in that same window: grants and the draft already name
|
|
1932
|
+
* `to`, but the still-published config exposes only `from` until the
|
|
1933
|
+
* publish lands — an end user's app has no working name for the datapoint
|
|
1934
|
+
* at all for however long that gap lasts, since `to` isn't published yet
|
|
1935
|
+
* and `from` no longer has a grant behind it. The console must publish
|
|
1936
|
+
* immediately after a rename to keep this window short; nothing server-side
|
|
1937
|
+
* closes it.
|
|
1938
|
+
* This schema only enforces slug *shape*; whether `to` is reserved or
|
|
1939
|
+
* already in use on this robot is checked once, behind the cloud's
|
|
1940
|
+
* `validation.ts` door — one door, not a second copy of that rule here.
|
|
1941
|
+
*/
|
|
1942
|
+
export const renameSlugRequest = z.object({ from: slug, to: slug }).strict();
|
|
1943
|
+
export const renameSlugResponse = z.object({
|
|
1944
|
+
rewritten_grants: z.number().int().nonnegative(),
|
|
1945
|
+
history_moved: z.boolean(),
|
|
1946
|
+
requires_publish: z.literal(true)
|
|
1947
|
+
});
|
|
1948
|
+
/**
|
|
1949
|
+
* `GET /api/robots/:id/config/slug-usage/:slug` — what a rename would touch;
|
|
1950
|
+
* feeds the console's confirm dialog.
|
|
1951
|
+
*
|
|
1952
|
+
* `alert_count` (spec `2026-08-28-alerts-and-datapoint-modal-design`, D5)
|
|
1953
|
+
* joined the atomic rename transaction alongside grants and history: alerts
|
|
1954
|
+
* are keyed by `(robot_id, slug)` too, and a rename that silently moved the
|
|
1955
|
+
* alert row while the usage preview stayed silent about it would show a
|
|
1956
|
+
* developer a smaller blast radius than the rename actually has.
|
|
1957
|
+
*/
|
|
1958
|
+
export const slugUsageResponse = z.object({
|
|
1959
|
+
grant_count: z.number().int().nonnegative(),
|
|
1960
|
+
app_identifiers: z.array(z.string()),
|
|
1961
|
+
has_recorded_history: z.boolean(),
|
|
1962
|
+
alert_count: z.number().int().nonnegative(),
|
|
1963
|
+
});
|