@fleetless/sdk 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/README.md +1217 -0
- package/dist/index.cjs +3662 -0
- package/dist/index.d.cts +1681 -0
- package/dist/index.d.ts +1681 -0
- package/dist/index.js +3631 -0
- package/package.json +42 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,3662 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
FleetlessError: () => FleetlessError,
|
|
24
|
+
InMemoryTokenStore: () => InMemoryTokenStore,
|
|
25
|
+
SDK_ERROR_CODES: () => SDK_ERROR_CODES,
|
|
26
|
+
createClient: () => createClient,
|
|
27
|
+
parameterInvalidDetails: () => parameterInvalidDetails
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/errors.ts
|
|
32
|
+
var SDK_ERROR_CODES = [
|
|
33
|
+
"no_session",
|
|
34
|
+
"no_websocket",
|
|
35
|
+
"unparseable_error",
|
|
36
|
+
"command_timeout",
|
|
37
|
+
"command_outcome_unknown",
|
|
38
|
+
"unexpected_response",
|
|
39
|
+
"invalid_option",
|
|
40
|
+
"untrusted_absolute_url",
|
|
41
|
+
"state_mismatch",
|
|
42
|
+
"no_hosted_login_attempt",
|
|
43
|
+
"no_urdf_synced",
|
|
44
|
+
"aborted"
|
|
45
|
+
];
|
|
46
|
+
var FleetlessError = class extends Error {
|
|
47
|
+
code;
|
|
48
|
+
details;
|
|
49
|
+
status;
|
|
50
|
+
constructor(code, message, options = {}) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "FleetlessError";
|
|
53
|
+
this.code = code;
|
|
54
|
+
this.details = options.details;
|
|
55
|
+
this.status = options.status;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/actions.ts
|
|
60
|
+
function createActionsApi(transport, jobSubscriptions) {
|
|
61
|
+
return {
|
|
62
|
+
async invoke(robotId, slug2, params, options) {
|
|
63
|
+
const result = await transport.invoke(robotId, slug2, params, options);
|
|
64
|
+
if (!result.job) {
|
|
65
|
+
throw new FleetlessError("unexpected_response", `The server accepted the invoke for '${slug2}' but returned no job to track.`);
|
|
66
|
+
}
|
|
67
|
+
return result.job;
|
|
68
|
+
},
|
|
69
|
+
async cancel(robotId, slug2, jobId, options) {
|
|
70
|
+
const result = await transport.cancel(robotId, slug2, jobId, options);
|
|
71
|
+
return result.job;
|
|
72
|
+
},
|
|
73
|
+
subscribe(robotId, slug2, handlers) {
|
|
74
|
+
return jobSubscriptions.subscribe(robotId, slug2, handlers);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/http.ts
|
|
80
|
+
var noCredentials = {
|
|
81
|
+
async token() {
|
|
82
|
+
return null;
|
|
83
|
+
},
|
|
84
|
+
async handleExpired() {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
function mimeFromContentType(headers) {
|
|
89
|
+
const raw = headers.get("content-type");
|
|
90
|
+
if (raw === null) return null;
|
|
91
|
+
return raw.split(";")[0]?.trim() || null;
|
|
92
|
+
}
|
|
93
|
+
function pathSegment(value) {
|
|
94
|
+
return encodeURIComponent(value);
|
|
95
|
+
}
|
|
96
|
+
var HttpClient = class {
|
|
97
|
+
#baseUrl;
|
|
98
|
+
#fetch;
|
|
99
|
+
#credentials;
|
|
100
|
+
constructor(options) {
|
|
101
|
+
this.#baseUrl = options.baseUrl;
|
|
102
|
+
this.#fetch = options.fetch;
|
|
103
|
+
this.#credentials = options.credentials;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Swaps the credential source after construction. Exists to break a
|
|
107
|
+
* construction cycle: a session's `CredentialSource` needs this
|
|
108
|
+
* `HttpClient` to call `/api/client/refresh`, so the client is built
|
|
109
|
+
* first with a placeholder and the real source attached once it exists.
|
|
110
|
+
*/
|
|
111
|
+
setCredentials(credentials) {
|
|
112
|
+
this.#credentials = credentials;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* D8 (W6c): calling `request()` with one of `RequestBodyByRoute`'s
|
|
116
|
+
* literal paths requires `options.body` to match that route's contract
|
|
117
|
+
* type exactly, via `RequestOptionsFor<P>` below.
|
|
118
|
+
*
|
|
119
|
+
* **Deliberately not a pair of overloads** — a first version was:
|
|
120
|
+
* `request<T, P extends keyof RequestBodyByRoute>(path: P, options: {
|
|
121
|
+
* body: RequestBodyByRoute[P] } & ...): Promise<T>` followed by a looser
|
|
122
|
+
* `request<T>(path: string, options?: RequestOptions): Promise<T>`
|
|
123
|
+
* fallback. Verified wrong before it ever landed: TypeScript overload
|
|
124
|
+
* resolution tries each signature in order and silently falls through to
|
|
125
|
+
* the next one on a mismatch — so a call with the WRONG body for a mapped
|
|
126
|
+
* route simply failed to match the strict overload and matched the loose
|
|
127
|
+
* one instead, with **no diagnostic at all**. A single generic signature
|
|
128
|
+
* whose `options` type is *computed* from `P` (below) has no looser
|
|
129
|
+
* sibling to fall back to, so a mismatch has nowhere to go but an error.
|
|
130
|
+
*
|
|
131
|
+
* **`options` has no default value, on purpose.** A default of `{} as
|
|
132
|
+
* RequestOptionsFor<P>` would type-check by construction — the cast
|
|
133
|
+
* bypasses the very check this exists to add, which is the identical
|
|
134
|
+
* "special case that quietly exempts calls from the general rule" shape
|
|
135
|
+
* D7 already found once in this file. Every current call site to a route
|
|
136
|
+
* with no body already passes `{}` explicitly for exactly this reason.
|
|
137
|
+
*/
|
|
138
|
+
async request(path, options) {
|
|
139
|
+
const response = await this.#send(path, options, true);
|
|
140
|
+
const text = await response.text();
|
|
141
|
+
if (text.length === 0) {
|
|
142
|
+
if (options.expectEmptyBody) return void 0;
|
|
143
|
+
throw new FleetlessError(
|
|
144
|
+
"unexpected_response",
|
|
145
|
+
`${options.method ?? "GET"} ${path} answered ok (${response.status}) but the body was empty; this route is expected to always return one.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return JSON.parse(text);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Like `request`, but for an endpoint that answers with raw bytes instead
|
|
152
|
+
* of a JSON body (a camera snapshot, W5) — same auth attachment, same
|
|
153
|
+
* single retry on `token_expired`, same `FleetlessError` on a non-2xx
|
|
154
|
+
* response; only what a *successful* response is made of differs, so both
|
|
155
|
+
* methods share `#send` rather than duplicating that logic.
|
|
156
|
+
*/
|
|
157
|
+
async requestBinary(path, options = {}) {
|
|
158
|
+
const response = await this.#send(path, options, true);
|
|
159
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
160
|
+
return { body, headers: response.headers };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The base URL this client was constructed with — exposed so a caller
|
|
164
|
+
* building a URL that is not itself a `request()`/`requestBinary()` call
|
|
165
|
+
* (today: `auth.beginHostedLogin`'s `/oauth/authorize` link) does not need
|
|
166
|
+
* its own copy of the value `HttpClient` already holds.
|
|
167
|
+
*/
|
|
168
|
+
get baseUrl() {
|
|
169
|
+
return this.#baseUrl;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* `/oauth/token` (RFC 6749 §3.2, §5), and nothing else — deliberately not
|
|
173
|
+
* routed through `request()`/`#send()`, because every difference from
|
|
174
|
+
* those follows from one fact: this is not our own API.
|
|
175
|
+
*
|
|
176
|
+
* - **`application/x-www-form-urlencoded`, never JSON.** A real
|
|
177
|
+
* RFC-compliant client (gate step 4's `mcp-inspector`) sends form
|
|
178
|
+
* fields, not a JSON object — this is not a stylistic choice either side
|
|
179
|
+
* of this wire gets to make.
|
|
180
|
+
* - **Unauthenticated.** `/oauth/token` is a public client's endpoint (no
|
|
181
|
+
* `client_secret` — OAuth 2.1 + PKCE is the defence, same as
|
|
182
|
+
* `dynamicClientRegistrationResponse`'s `token_endpoint_auth_method:
|
|
183
|
+
* 'none'`), so there is no bearer credential to attach and no
|
|
184
|
+
* `token_expired` retry to apply — there is no token yet to expire.
|
|
185
|
+
* - **A different error dialect.** `oauth.ts`'s own doc comment says the
|
|
186
|
+
* OAuth/`apiError` split is "by audience, not by accident"; folding this
|
|
187
|
+
* into `request()`'s error handling would be the identical mistake one
|
|
188
|
+
* level down. A failure here answers `{ error, error_description }`
|
|
189
|
+
* (RFC 6749 §5.2), never `{ code, message }`. `error` becomes this
|
|
190
|
+
* `FleetlessError`'s `.code` directly — an OAuth error code (e.g.
|
|
191
|
+
* `invalid_grant`) IS the stable, branch-on value here, exactly as
|
|
192
|
+
* `apiError`'s `code` is for every other route.
|
|
193
|
+
* - **Exactly one `fetch()`, always.** No retry, no timeout-and-resend, no
|
|
194
|
+
* `signal` parameter at all — unlike `request()`/`requestBinary()`,
|
|
195
|
+
* nothing here can leave a second request in flight behind the
|
|
196
|
+
* caller's back. That matters specifically for the authorization-code
|
|
197
|
+
* grant: a second presentation of the same `code` revokes the whole
|
|
198
|
+
* token family server-side (André, 2026-08-18), so a caller-level retry
|
|
199
|
+
* racing an in-flight `completeHostedLogin()` is a real hazard this
|
|
200
|
+
* method cannot protect against on its own — see that method's own doc
|
|
201
|
+
* comment for the consequence.
|
|
202
|
+
*/
|
|
203
|
+
async requestOAuth(path, formBody) {
|
|
204
|
+
const url = `${this.#baseUrl}${path}`;
|
|
205
|
+
const response = await this.#fetch(url, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
208
|
+
body: new URLSearchParams(formBody).toString()
|
|
209
|
+
});
|
|
210
|
+
const text = await response.text();
|
|
211
|
+
if (response.ok) {
|
|
212
|
+
if (text.length === 0) {
|
|
213
|
+
throw new FleetlessError("unexpected_response", `POST ${path} answered ok (${response.status}) but the body was empty.`);
|
|
214
|
+
}
|
|
215
|
+
return JSON.parse(text);
|
|
216
|
+
}
|
|
217
|
+
const body = safeJsonParse(text);
|
|
218
|
+
const code = typeof body?.error === "string" ? body.error : "unparseable_error";
|
|
219
|
+
const message = typeof body?.error_description === "string" ? body.error_description : response.statusText || "OAuth request failed";
|
|
220
|
+
throw new FleetlessError(code, message, { status: response.status });
|
|
221
|
+
}
|
|
222
|
+
/** Fetches with credentials attached and the token_expired retry applied; returns the raw successful `Response`, or throws `FleetlessError`. */
|
|
223
|
+
async #send(path, options, allowRetry) {
|
|
224
|
+
const isAbsolute = /^https?:\/\//.test(path);
|
|
225
|
+
const url = isAbsolute ? path : `${this.#baseUrl}${path}`;
|
|
226
|
+
if (isAbsolute && new URL(url).origin !== new URL(this.#baseUrl).origin) {
|
|
227
|
+
throw new FleetlessError(
|
|
228
|
+
"untrusted_absolute_url",
|
|
229
|
+
`Refusing to fetch '${url}': its origin does not match this client's own API (${this.#baseUrl}). This SDK only attaches credentials to requests aimed at the API it was configured with.`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const headers = {};
|
|
233
|
+
if (options.body !== void 0) headers["content-type"] = "application/json";
|
|
234
|
+
if (!options.skipAuth) {
|
|
235
|
+
const token = await this.#credentials.token();
|
|
236
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
237
|
+
}
|
|
238
|
+
const response = await this.#fetch(url, {
|
|
239
|
+
method: options.method ?? "GET",
|
|
240
|
+
headers,
|
|
241
|
+
body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
|
|
242
|
+
signal: options.signal
|
|
243
|
+
});
|
|
244
|
+
if (response.ok) return response;
|
|
245
|
+
const body = await safeJson(response);
|
|
246
|
+
const code = typeof body?.code === "string" ? body.code : "unparseable_error";
|
|
247
|
+
const message = typeof body?.message === "string" ? body.message : response.statusText || "Request failed";
|
|
248
|
+
if (!options.skipAuth && allowRetry && code === "token_expired") {
|
|
249
|
+
let canRetry;
|
|
250
|
+
try {
|
|
251
|
+
canRetry = await this.#credentials.handleExpired();
|
|
252
|
+
} catch (refreshError) {
|
|
253
|
+
if (refreshError instanceof FleetlessError) throw refreshError;
|
|
254
|
+
throw new FleetlessError(code, message, { details: body?.details, status: response.status });
|
|
255
|
+
}
|
|
256
|
+
if (canRetry) return this.#send(path, options, false);
|
|
257
|
+
}
|
|
258
|
+
throw new FleetlessError(code, message, { details: body?.details, status: response.status });
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
async function safeJson(response) {
|
|
262
|
+
try {
|
|
263
|
+
return await response.json();
|
|
264
|
+
} catch {
|
|
265
|
+
return void 0;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function safeJsonParse(text) {
|
|
269
|
+
try {
|
|
270
|
+
return JSON.parse(text);
|
|
271
|
+
} catch {
|
|
272
|
+
return void 0;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/assets.ts
|
|
277
|
+
var DEFAULT_MESH_LOADER_TIMEOUT_MS = 3e4;
|
|
278
|
+
var DEFAULT_SCENE_LOAD_CONCURRENCY = 6;
|
|
279
|
+
var managersWithPreparedUrdfScene = /* @__PURE__ */ new WeakSet();
|
|
280
|
+
function urlModifierKeysFor(name) {
|
|
281
|
+
const prefix = "package://";
|
|
282
|
+
if (!name.startsWith(prefix)) return [name];
|
|
283
|
+
return [name, `/${name.slice(prefix.length)}`];
|
|
284
|
+
}
|
|
285
|
+
function normalizedUrlModifierKey(url) {
|
|
286
|
+
if (url.startsWith("package://") || /^[a-z][a-z0-9+.-]*:/i.test(url)) return url;
|
|
287
|
+
try {
|
|
288
|
+
return new URL(url, "file:///").pathname;
|
|
289
|
+
} catch {
|
|
290
|
+
return url;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function packageNameOf(name) {
|
|
294
|
+
const prefix = "package://";
|
|
295
|
+
if (!name.startsWith(prefix)) return null;
|
|
296
|
+
const rest = name.slice(prefix.length);
|
|
297
|
+
const slash = rest.indexOf("/");
|
|
298
|
+
return slash === -1 ? rest : rest.slice(0, slash);
|
|
299
|
+
}
|
|
300
|
+
function isOwnedReference(url, knownPackages) {
|
|
301
|
+
if (url.startsWith("package://")) return true;
|
|
302
|
+
const match = /^\/([^/]+)\//.exec(url);
|
|
303
|
+
return match !== null && knownPackages.has(match[1]);
|
|
304
|
+
}
|
|
305
|
+
function isNetworkFetchableAbsoluteUrl(url) {
|
|
306
|
+
return /^https?:\/\//i.test(url);
|
|
307
|
+
}
|
|
308
|
+
function isRenderKind(kind) {
|
|
309
|
+
switch (kind) {
|
|
310
|
+
case "mesh":
|
|
311
|
+
case "texture":
|
|
312
|
+
return true;
|
|
313
|
+
case "urdf":
|
|
314
|
+
case "other":
|
|
315
|
+
return false;
|
|
316
|
+
default: {
|
|
317
|
+
const exhaustive = kind;
|
|
318
|
+
return exhaustive;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async function forEachWithConcurrency(items, limit, fn) {
|
|
323
|
+
let next = 0;
|
|
324
|
+
let failed = false;
|
|
325
|
+
let firstError;
|
|
326
|
+
async function worker() {
|
|
327
|
+
for (; ; ) {
|
|
328
|
+
const index = next++;
|
|
329
|
+
if (index >= items.length) return;
|
|
330
|
+
try {
|
|
331
|
+
await fn(items[index]);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (!failed) {
|
|
334
|
+
failed = true;
|
|
335
|
+
firstError = error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
341
|
+
if (failed) throw firstError;
|
|
342
|
+
}
|
|
343
|
+
function createAssetsApi(http) {
|
|
344
|
+
async function list(robotId, signal) {
|
|
345
|
+
const response = await http.request(`/api/robots/${pathSegment(robotId)}/assets`, { signal });
|
|
346
|
+
return response;
|
|
347
|
+
}
|
|
348
|
+
async function get(robotId, assetId, signal) {
|
|
349
|
+
const { body, headers } = await http.requestBinary(`/api/robots/${pathSegment(robotId)}/assets/${pathSegment(assetId)}`, { signal });
|
|
350
|
+
return { body, mime: mimeFromContentType(headers) };
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
list,
|
|
354
|
+
get,
|
|
355
|
+
async urdf(robotId) {
|
|
356
|
+
const { body } = await http.requestBinary(`/api/robots/${pathSegment(robotId)}/urdf`);
|
|
357
|
+
return new TextDecoder().decode(body);
|
|
358
|
+
},
|
|
359
|
+
createMeshLoader(robotId, delegate, options = {}) {
|
|
360
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_MESH_LOADER_TIMEOUT_MS;
|
|
361
|
+
return (path, manager, material, onComplete) => {
|
|
362
|
+
let settled = false;
|
|
363
|
+
let objectUrl = null;
|
|
364
|
+
const finish = (obj, err) => {
|
|
365
|
+
if (settled) return;
|
|
366
|
+
settled = true;
|
|
367
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
368
|
+
onComplete(obj, err);
|
|
369
|
+
};
|
|
370
|
+
if (typeof manager === "object" && manager !== null && managersWithPreparedUrdfScene.has(manager)) {
|
|
371
|
+
finish(
|
|
372
|
+
null,
|
|
373
|
+
new Error(
|
|
374
|
+
`createMeshLoader and prepareUrdfScene were both installed on the same LoadingManager for robot ${robotId}. Use one or the other on a given manager, not both \u2014 see the README.`
|
|
375
|
+
)
|
|
376
|
+
);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const timer = setTimeout(() => {
|
|
380
|
+
finish(null, new Error(`Mesh '${path}' on robot ${robotId} did not finish loading within ${timeoutMs}ms.`));
|
|
381
|
+
}, timeoutMs);
|
|
382
|
+
http.requestBinary(path).then(
|
|
383
|
+
({ body, headers }) => {
|
|
384
|
+
if (settled) return;
|
|
385
|
+
const mime = mimeFromContentType(headers) ?? "application/octet-stream";
|
|
386
|
+
objectUrl = URL.createObjectURL(new Blob([body], { type: mime }));
|
|
387
|
+
try {
|
|
388
|
+
delegate(objectUrl, manager, material, (obj, err) => {
|
|
389
|
+
clearTimeout(timer);
|
|
390
|
+
finish(obj, err);
|
|
391
|
+
});
|
|
392
|
+
} catch (delegateError) {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
finish(null, delegateError instanceof Error ? delegateError : new Error(String(delegateError)));
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
(fetchError) => {
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
finish(null, fetchError instanceof Error ? fetchError : new Error(String(fetchError)));
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
};
|
|
403
|
+
},
|
|
404
|
+
async prepareUrdfScene(robotId, manager, options = {}) {
|
|
405
|
+
const concurrency = options.concurrency ?? DEFAULT_SCENE_LOAD_CONCURRENCY;
|
|
406
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
407
|
+
throw new FleetlessError(
|
|
408
|
+
"invalid_option",
|
|
409
|
+
`prepareUrdfScene: options.concurrency must be a positive integer, got ${concurrency}.`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
const signal = options.signal;
|
|
413
|
+
const aborted = () => new FleetlessError("aborted", `prepareUrdfScene for robot ${robotId} was aborted.`);
|
|
414
|
+
if (signal?.aborted) throw aborted();
|
|
415
|
+
const listResponse = await list(robotId, signal).catch((error) => {
|
|
416
|
+
throw signal?.aborted ? aborted() : error;
|
|
417
|
+
});
|
|
418
|
+
const urdfAsset = listResponse.assets.find((asset2) => asset2.kind === "urdf");
|
|
419
|
+
if (!urdfAsset) {
|
|
420
|
+
throw new FleetlessError(
|
|
421
|
+
"no_urdf_synced",
|
|
422
|
+
`Robot ${robotId} has no synced URDF \u2014 assets.list() has no 'urdf'-kind row. Sync one first (console, Owner-tier).`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const { body: urdfBytes } = await get(robotId, urdfAsset.id, signal).catch((error) => {
|
|
426
|
+
throw signal?.aborted ? aborted() : error;
|
|
427
|
+
});
|
|
428
|
+
const urdfText = new TextDecoder().decode(urdfBytes);
|
|
429
|
+
const renderAssets = listResponse.assets.filter((asset2) => isRenderKind(asset2.kind));
|
|
430
|
+
const knownPackages = /* @__PURE__ */ new Set();
|
|
431
|
+
for (const asset2 of renderAssets) {
|
|
432
|
+
const pkg = packageNameOf(asset2.name);
|
|
433
|
+
if (pkg) knownPackages.add(pkg);
|
|
434
|
+
}
|
|
435
|
+
for (const ref of listResponse.urdf.missing) {
|
|
436
|
+
const pkg = packageNameOf(ref);
|
|
437
|
+
if (pkg) knownPackages.add(pkg);
|
|
438
|
+
}
|
|
439
|
+
const refusedObjectUrl = URL.createObjectURL(new Blob([]));
|
|
440
|
+
const objectUrls = [];
|
|
441
|
+
const nameToObjectUrl = /* @__PURE__ */ new Map();
|
|
442
|
+
try {
|
|
443
|
+
await forEachWithConcurrency(renderAssets, concurrency, async (asset2) => {
|
|
444
|
+
const { body, mime } = await get(robotId, asset2.id, signal);
|
|
445
|
+
const objectUrl = URL.createObjectURL(new Blob([body], { type: mime ?? "application/octet-stream" }));
|
|
446
|
+
objectUrls.push(objectUrl);
|
|
447
|
+
for (const key of urlModifierKeysFor(asset2.name)) nameToObjectUrl.set(key, objectUrl);
|
|
448
|
+
});
|
|
449
|
+
} catch (error) {
|
|
450
|
+
for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl);
|
|
451
|
+
throw signal?.aborted ? aborted() : error;
|
|
452
|
+
}
|
|
453
|
+
if (signal?.aborted) {
|
|
454
|
+
for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl);
|
|
455
|
+
throw aborted();
|
|
456
|
+
}
|
|
457
|
+
if (typeof manager === "object" && manager !== null) managersWithPreparedUrdfScene.add(manager);
|
|
458
|
+
manager.setURLModifier((url) => {
|
|
459
|
+
const direct = nameToObjectUrl.get(url);
|
|
460
|
+
if (direct) return direct;
|
|
461
|
+
if (!isOwnedReference(url, knownPackages)) {
|
|
462
|
+
return isNetworkFetchableAbsoluteUrl(url) ? refusedObjectUrl : url;
|
|
463
|
+
}
|
|
464
|
+
return nameToObjectUrl.get(normalizedUrlModifierKey(url)) ?? refusedObjectUrl;
|
|
465
|
+
});
|
|
466
|
+
let disposed = false;
|
|
467
|
+
return {
|
|
468
|
+
urdfText,
|
|
469
|
+
missing: listResponse.urdf.missing,
|
|
470
|
+
dispose() {
|
|
471
|
+
if (disposed) return;
|
|
472
|
+
disposed = true;
|
|
473
|
+
for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl);
|
|
474
|
+
nameToObjectUrl.clear();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/common.js
|
|
482
|
+
var import_zod = require("zod");
|
|
483
|
+
var slug = import_zod.z.string().min(2).max(63).regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/);
|
|
484
|
+
var rosName = import_zod.z.string().max(255).regex(/^\/[A-Za-z_][A-Za-z0-9_]*(?:\/[A-Za-z_][A-Za-z0-9_]*)*$/);
|
|
485
|
+
var rosTypeName = import_zod.z.string().max(255).regex(/^[a-z][a-z0-9_]*\/(?:msg|srv|action)\/[A-Za-z][A-Za-z0-9]*$/);
|
|
486
|
+
var fieldPath = import_zod.z.string().max(255).regex(/^[a-z_][a-z0-9_]*(?:\[\d+\])*(?:\.[a-z_][a-z0-9_]*(?:\[\d+\])*)*$/);
|
|
487
|
+
|
|
488
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/mcp.js
|
|
489
|
+
var import_zod2 = require("zod");
|
|
490
|
+
var mcpToolKind = import_zod2.z.enum(["datapoint", "service", "action", "publisher", "camera"]);
|
|
491
|
+
var MCP_TOOL_NAME_MAX = 128;
|
|
492
|
+
var mcpToolNamePattern = /^[a-z0-9][a-z0-9_-]*$/;
|
|
493
|
+
var mcpToolPreview = import_zod2.z.object({
|
|
494
|
+
name: import_zod2.z.string().min(1).max(MCP_TOOL_NAME_MAX).regex(mcpToolNamePattern),
|
|
495
|
+
/** The human-readable label — where the robot's actual name goes. */
|
|
496
|
+
title: import_zod2.z.string().min(1).max(200),
|
|
497
|
+
/**
|
|
498
|
+
* **Four thousand, not two thousand, and the difference is the point.**
|
|
499
|
+
*
|
|
500
|
+
* `serviceDescription` bounds what a *developer writes* at 2000. This bounds
|
|
501
|
+
* what the *generator produces*, which is that text **plus** what it folds
|
|
502
|
+
* in — a datapoint's `Unit:` and `Plausible range:`, a camera's fixed
|
|
503
|
+
* sentence about snapshots. Measured by Kassandra-W7c and Momus-W7c
|
|
504
|
+
* independently: a maximal description came back at 2036–2068 characters
|
|
505
|
+
* against a 2000 bound, so the cloud served a document its own contract
|
|
506
|
+
* rejected — silently, because the route returns a typed literal without
|
|
507
|
+
* parsing it.
|
|
508
|
+
*
|
|
509
|
+
* **Do not "tidy" these two numbers into agreement.** They describe
|
|
510
|
+
* different things, and making them equal reintroduces the defect: either
|
|
511
|
+
* the generator truncates a developer's own words, or the response
|
|
512
|
+
* overflows again. The gap is the room the generator needs.
|
|
513
|
+
*/
|
|
514
|
+
description: import_zod2.z.string().min(1).max(4e3),
|
|
515
|
+
robot_id: import_zod2.z.uuid(),
|
|
516
|
+
slug,
|
|
517
|
+
kind: mcpToolKind,
|
|
518
|
+
input_schema: import_zod2.z.unknown()
|
|
519
|
+
});
|
|
520
|
+
var mcpOmission = import_zod2.z.object({
|
|
521
|
+
robot_id: import_zod2.z.uuid(),
|
|
522
|
+
slug,
|
|
523
|
+
/** One of `MCP_OMISSION_REASONS`; the wire allows any string, as with `ERROR_CODES`. */
|
|
524
|
+
reason: import_zod2.z.string().min(1),
|
|
525
|
+
message: import_zod2.z.string().min(1)
|
|
526
|
+
});
|
|
527
|
+
var mcpToolPreviewResponse = import_zod2.z.object({
|
|
528
|
+
role_id: import_zod2.z.uuid(),
|
|
529
|
+
tools: import_zod2.z.array(mcpToolPreview).max(500),
|
|
530
|
+
omitted: import_zod2.z.array(mcpOmission).max(500)
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/protocol.js
|
|
534
|
+
var import_zod7 = require("zod");
|
|
535
|
+
|
|
536
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/assets.js
|
|
537
|
+
var import_zod3 = require("zod");
|
|
538
|
+
var assetKind = import_zod3.z.enum(["urdf", "mesh", "texture", "other"]);
|
|
539
|
+
var asset = import_zod3.z.object({
|
|
540
|
+
id: import_zod3.z.uuid(),
|
|
541
|
+
robot_id: import_zod3.z.uuid(),
|
|
542
|
+
kind: assetKind,
|
|
543
|
+
/**
|
|
544
|
+
* What the robot called it — for a mesh, the `package://` URI the URDF
|
|
545
|
+
* references, verbatim. That is the only string a developer can match
|
|
546
|
+
* against their own workspace, and matching is the whole job when a sync
|
|
547
|
+
* comes back incomplete.
|
|
548
|
+
*
|
|
549
|
+
* **The naming rule for a file nothing in the URDF names (W7a, D2).** A
|
|
550
|
+
* `.dae` carries its own image references — `<init_from>textures/skin.png`
|
|
551
|
+
* — resolved by the renderer against *the `.dae`'s own directory*, and no
|
|
552
|
+
* `package://` URI for them appears anywhere in the URDF. The rule is:
|
|
553
|
+
*
|
|
554
|
+
* name = the .dae's package:// URI, directory part,
|
|
555
|
+
* joined with the internal reference, normalized.
|
|
556
|
+
*
|
|
557
|
+
* So `package://rx1_description/meshes/arm.dae` referencing
|
|
558
|
+
* `textures/skin.png` uploads as
|
|
559
|
+
* `package://rx1_description/meshes/textures/skin.png`.
|
|
560
|
+
*
|
|
561
|
+
* **This is the design's single point of failure and it is stated before
|
|
562
|
+
* anything is built against it.** three.js resolves that internal reference
|
|
563
|
+
* relative to wherever it loaded the `.dae` from and asks the loading
|
|
564
|
+
* manager for the result; the client can only answer if the asset's name
|
|
565
|
+
* still carries the same **relative tail** (`textures/skin.png`) that the
|
|
566
|
+
* `.dae` asked for. Normalizing into a `package://` URI preserves that tail
|
|
567
|
+
* exactly, keeps every name in one namespace a developer already reads, and
|
|
568
|
+
* keeps `urdfCompleteness.missing` meaningful for files the URDF never
|
|
569
|
+
* mentioned.
|
|
570
|
+
*
|
|
571
|
+
* A reference that escapes its package (`../../etc/passwd`) is **not**
|
|
572
|
+
* renamed into something harmless — it is refused at the producer, by the
|
|
573
|
+
* same containment check W7's K2 fix applied to `package://` resolution.
|
|
574
|
+
* Two identical rules, one of which is enforced and one of which is
|
|
575
|
+
* documented, is how W7's traversal happened in the first place.
|
|
576
|
+
*/
|
|
577
|
+
name: import_zod3.z.string().min(1).max(500),
|
|
578
|
+
media_type: import_zod3.z.string().min(1).max(120),
|
|
579
|
+
size_bytes: import_zod3.z.number().int().nonnegative(),
|
|
580
|
+
/**
|
|
581
|
+
* The content hash, and the reason two robots sharing a mesh cost one copy.
|
|
582
|
+
*
|
|
583
|
+
* Exposed rather than kept internal because it is the only way a client can
|
|
584
|
+
* tell "this is the same mesh I already have" across robots — and a 3D view
|
|
585
|
+
* that re-downloads an identical arm for every robot in a fleet is the
|
|
586
|
+
* predictable failure of a store that hides it.
|
|
587
|
+
*/
|
|
588
|
+
sha256: import_zod3.z.string().regex(/^[a-f0-9]{64}$/),
|
|
589
|
+
created_at: import_zod3.z.iso.datetime()
|
|
590
|
+
});
|
|
591
|
+
var urdfCompleteness = import_zod3.z.object({
|
|
592
|
+
/** Whether a URDF has been synced at all. Availability is a different question. */
|
|
593
|
+
present: import_zod3.z.boolean(),
|
|
594
|
+
/** How many distinct meshes the URDF references. */
|
|
595
|
+
mesh_count: import_zod3.z.number().int().nonnegative(),
|
|
596
|
+
missing: import_zod3.z.array(import_zod3.z.string().min(1))
|
|
597
|
+
});
|
|
598
|
+
var assetListResponse = import_zod3.z.object({
|
|
599
|
+
assets: import_zod3.z.array(asset),
|
|
600
|
+
urdf: urdfCompleteness,
|
|
601
|
+
/**
|
|
602
|
+
* What the connected bridge says it *could* transfer, which is deliberately
|
|
603
|
+
* separate from what has been transferred (§4.6: the bridge "meldet nur
|
|
604
|
+
* Verfügbarkeit"). `null` when no bridge is connected — distinct from
|
|
605
|
+
* `false`, because "no robot is online to ask" and "the robot has no URDF"
|
|
606
|
+
* send a developer to two different places.
|
|
607
|
+
*
|
|
608
|
+
* **All three states are reachable as of W7a (R7).** They were not: the bridge
|
|
609
|
+
* used to report availability from a subscription callback, which fires only
|
|
610
|
+
* when a publisher *sends* something, so it could notice presence and never
|
|
611
|
+
* absence — a robot that lost its URDF left the cloud holding the last thing
|
|
612
|
+
* it heard, forever, and `true` was sticky. The fix is an **active**
|
|
613
|
+
* `count_publishers` query on the bridge's own timer.
|
|
614
|
+
*
|
|
615
|
+
* **What a consumer still needs to know is the clock, not the gap.** An
|
|
616
|
+
* ungraceful loss — the publisher process killed rather than shut down — is
|
|
617
|
+
* noticed on **DDS's liveliness timeout**, not on the bridge's check
|
|
618
|
+
* interval. Measured against a real bridge: ~1.6 s when the publisher calls
|
|
619
|
+
* `destroy_node()`, **~19 s when it is `SIGKILL`ed**. So `true` can outlive
|
|
620
|
+
* the truth by some seconds after a crash, and no amount of polling on our
|
|
621
|
+
* side shortens it.
|
|
622
|
+
*
|
|
623
|
+
* The sticky-`true` gap was found by Rosie-W7 checking her own work against
|
|
624
|
+
* the camera-health row of identical shape; the DDS clock was measured by
|
|
625
|
+
* Rosie-W7a closing it, and this comment was still describing the gap a wave
|
|
626
|
+
* after it was fixed (Momus-W7a, W7a review).
|
|
627
|
+
*/
|
|
628
|
+
urdf_available: import_zod3.z.boolean().nullable()
|
|
629
|
+
});
|
|
630
|
+
var assetSyncRequest = import_zod3.z.object({
|
|
631
|
+
source: import_zod3.z.enum(["bridge"])
|
|
632
|
+
}).strict();
|
|
633
|
+
var assetSyncResponse = import_zod3.z.object({
|
|
634
|
+
sync_id: import_zod3.z.uuid()
|
|
635
|
+
});
|
|
636
|
+
var assetFailureKind = import_zod3.z.enum(["unresolvable", "upload_failed", "refused"]);
|
|
637
|
+
var assetFailure = import_zod3.z.object({
|
|
638
|
+
/**
|
|
639
|
+
* What could not be provided, verbatim — the same string `asset.name` would
|
|
640
|
+
* have stored and `urdfCompleteness.missing` reports, so a developer can
|
|
641
|
+
* match it against their own workspace by eye. For a URDF upload failure it
|
|
642
|
+
* is `URDF_ASSET_NAME`, which is **not** a mesh URI: a consumer rendering
|
|
643
|
+
* this list must not assume every entry is one.
|
|
644
|
+
*/
|
|
645
|
+
reference: import_zod3.z.string().min(1).max(500),
|
|
646
|
+
kind: assetFailureKind
|
|
647
|
+
});
|
|
648
|
+
var assetSyncState = import_zod3.z.enum(["running", "succeeded", "failed"]);
|
|
649
|
+
var assetSyncStatus = import_zod3.z.object({
|
|
650
|
+
sync_id: import_zod3.z.uuid(),
|
|
651
|
+
robot_id: import_zod3.z.uuid(),
|
|
652
|
+
state: assetSyncState,
|
|
653
|
+
done: import_zod3.z.number().int().nonnegative(),
|
|
654
|
+
total: import_zod3.z.number().int().nonnegative(),
|
|
655
|
+
/**
|
|
656
|
+
* **Every entry says *why*, because reconciliation could not work without
|
|
657
|
+
* it and a developer could not read it without it** (W7a review, André's
|
|
658
|
+
* decision to fix rather than defer).
|
|
659
|
+
*
|
|
660
|
+
* It was a flat `string[]`, and **six producers wrote three different facts
|
|
661
|
+
* into it indistinguishably**: a reference that resolves to nothing in the
|
|
662
|
+
* workspace, a file that exists and whose transfer failed, and — since R9's
|
|
663
|
+
* ceiling — one that was never attempted at all. The cost was paid twice
|
|
664
|
+
* over. Reconciliation cannot tell *"no longer referenced"* from
|
|
665
|
+
* *"referenced and not delivered"*, so N14 had to decline reconciling **any**
|
|
666
|
+
* partial sync, leaving legitimately-removed assets stored and charged until
|
|
667
|
+
* the next clean one. And the console prints the whole array under *"these
|
|
668
|
+
* meshes could not be resolved"*, so a URDF upload failure — which arrives
|
|
669
|
+
* as the literal `robot_description` — is shown to a developer as a mesh
|
|
670
|
+
* they should go and find.
|
|
671
|
+
*
|
|
672
|
+
* `unresolvable` is the only kind reconciliation may drop: it is the only
|
|
673
|
+
* one that means *this will not come back*. `upload_failed` and `refused`
|
|
674
|
+
* both mean *we meant to provide this and did not*, which is the distinction
|
|
675
|
+
* the union needs and the field could not carry.
|
|
676
|
+
*
|
|
677
|
+
* **Bounded, and the bound is a rule this file already wrote down one field
|
|
678
|
+
* over** (Kassandra-W7a, W7a review). `asset.name` is `max(500)`; the same
|
|
679
|
+
* names travelling here had no per-entry cap and no array cap at all.
|
|
680
|
+
*
|
|
681
|
+
* Why it matters became reachable in W7a. Before R6 an unresolvable
|
|
682
|
+
* reference was silently dropped, so this array could only grow with files
|
|
683
|
+
* that existed and failed to upload — bounded by the workspace. Once a
|
|
684
|
+
* `.dae`'s internal references are reported, **one mesh reference expands
|
|
685
|
+
* into a list bounded only by that file's own text.** Measured: a
|
|
686
|
+
* 2,120,745-byte `.dae` with 17,331 unresolvable `<init_from>` refs produces
|
|
687
|
+
* a terminal frame of 2,097,184 bytes — **32 bytes over
|
|
688
|
+
* `MAX_WS_PAYLOAD_BYTES`** — and `ws` enforces `maxPayload` before the frame
|
|
689
|
+
* is delivered, so the outcome is not a dropped frame but **the robot's
|
|
690
|
+
* socket closed, mid-sync, by a file in its own workspace.**
|
|
691
|
+
*
|
|
692
|
+
* A producer that hits its own ceiling reports **one** entry saying so
|
|
693
|
+
* rather than growing the list — the discipline gate step 5 already demands
|
|
694
|
+
* of the upload rate limit: *fail naming the limit, rather than silently
|
|
695
|
+
* reporting resolvable meshes as missing.*
|
|
696
|
+
*
|
|
697
|
+
* 1000 x 500 bytes is ~0.5 MiB of names, comfortably inside a 2 MiB frame.
|
|
698
|
+
*/
|
|
699
|
+
failed: import_zod3.z.array(assetFailure).max(1e3),
|
|
700
|
+
/** Why the sync ended as it did, when that is not a per-URI fact. */
|
|
701
|
+
reason: import_zod3.z.string().min(1).nullable(),
|
|
702
|
+
started_at: import_zod3.z.iso.datetime(),
|
|
703
|
+
updated_at: import_zod3.z.iso.datetime()
|
|
704
|
+
});
|
|
705
|
+
var assetTooLargeDetails = import_zod3.z.object({
|
|
706
|
+
limit_bytes: import_zod3.z.number().int().positive(),
|
|
707
|
+
size_bytes: import_zod3.z.number().int().positive()
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/config.js
|
|
711
|
+
var import_zod4 = require("zod");
|
|
712
|
+
var valueRule = import_zod4.z.object({
|
|
713
|
+
min: import_zod4.z.number().optional(),
|
|
714
|
+
max: import_zod4.z.number().optional(),
|
|
715
|
+
enum: import_zod4.z.array(import_zod4.z.union([import_zod4.z.string(), import_zod4.z.number()])).min(1).optional(),
|
|
716
|
+
pattern: import_zod4.z.string().optional(),
|
|
717
|
+
required: import_zod4.z.boolean().optional()
|
|
718
|
+
});
|
|
719
|
+
var serviceDescription = import_zod4.z.string().min(1).max(2e3).optional();
|
|
720
|
+
var parameterDescription = import_zod4.z.string().min(1).max(500).optional();
|
|
721
|
+
var parameterSpec = import_zod4.z.object({
|
|
722
|
+
/** Field path into the ROS request/goal/message — same grammar as a datapoint's. */
|
|
723
|
+
name: fieldPath,
|
|
724
|
+
/** The ROS type, for the console to render an input the developer recognises. */
|
|
725
|
+
type: import_zod4.z.string().min(1).max(255),
|
|
726
|
+
rule: valueRule,
|
|
727
|
+
description: parameterDescription
|
|
728
|
+
});
|
|
729
|
+
var datapointRate = import_zod4.z.discriminatedUnion("mode", [
|
|
730
|
+
import_zod4.z.object({ mode: import_zod4.z.literal("max_hz"), hz: import_zod4.z.number().positive().max(100) }),
|
|
731
|
+
import_zod4.z.object({ mode: import_zod4.z.literal("on_change") })
|
|
732
|
+
]);
|
|
733
|
+
var datapointRange = import_zod4.z.object({
|
|
734
|
+
min: import_zod4.z.number().nullable(),
|
|
735
|
+
max: import_zod4.z.number().nullable()
|
|
736
|
+
});
|
|
737
|
+
var datapointConfig = import_zod4.z.object({
|
|
738
|
+
slug,
|
|
739
|
+
topic: rosName,
|
|
740
|
+
type: rosTypeName,
|
|
741
|
+
field: fieldPath.nullable(),
|
|
742
|
+
rate: datapointRate,
|
|
743
|
+
unit: import_zod4.z.string().max(32).nullable(),
|
|
744
|
+
scale: import_zod4.z.number().nullable(),
|
|
745
|
+
offset: import_zod4.z.number().nullable(),
|
|
746
|
+
range: datapointRange.nullable(),
|
|
747
|
+
description: serviceDescription,
|
|
748
|
+
/**
|
|
749
|
+
* Record this datapoint (spec §8). Recorded values go to the time-series
|
|
750
|
+
* store and are queryable through the history API; everything else is
|
|
751
|
+
* live-only and leaves no trace.
|
|
752
|
+
*
|
|
753
|
+
* **Exactly one representation of "not recorded": `false`.** W5 reserved
|
|
754
|
+
* this field as `z.null().optional()`, so stored documents may carry
|
|
755
|
+
* `retention: null` — the cloud normalises that to `false` on read rather
|
|
756
|
+
* than the contract accepting both, because two spellings of one fact is
|
|
757
|
+
* the defect this project has spent two waves removing.
|
|
758
|
+
*
|
|
759
|
+
* Defaulted so a document written before W6 still parses. Note that
|
|
760
|
+
* `.default()` publishes as `required` in the generated artifact — the
|
|
761
|
+
* fourth instance, deferred to W7 with the fix identified
|
|
762
|
+
* (`io: 'input'`, split per schema).
|
|
763
|
+
*/
|
|
764
|
+
retention: import_zod4.z.boolean().default(false),
|
|
765
|
+
/**
|
|
766
|
+
* What happens to this datapoint's values while the bridge is disconnected
|
|
767
|
+
* (spec §6.3). Buffered values are backfilled after reconnect — **after**
|
|
768
|
+
* live telemetry and job results, at a limited rate, so closing a gap can
|
|
769
|
+
* never delay what is happening now. An unbuffered datapoint simply has a
|
|
770
|
+
* gap, which is an honest answer and often the right one.
|
|
771
|
+
*/
|
|
772
|
+
buffer: import_zod4.z.object({
|
|
773
|
+
enabled: import_zod4.z.boolean(),
|
|
774
|
+
/**
|
|
775
|
+
* Zero is legal and means "no depth" — it is what a disabled buffer
|
|
776
|
+
* carries. The invariant that matters is stated below: *enabled*
|
|
777
|
+
* implies a depth greater than zero.
|
|
778
|
+
*/
|
|
779
|
+
max_values: import_zod4.z.number().int().nonnegative().max(1e5)
|
|
780
|
+
}).refine((b) => !b.enabled || b.max_values > 0, {
|
|
781
|
+
message: "an enabled buffer needs max_values > 0",
|
|
782
|
+
path: ["max_values"]
|
|
783
|
+
}).default({ enabled: false, max_values: 0 })
|
|
784
|
+
});
|
|
785
|
+
var actionConfig = import_zod4.z.object({
|
|
786
|
+
slug,
|
|
787
|
+
ros_name: rosName,
|
|
788
|
+
type: rosTypeName,
|
|
789
|
+
parameters: import_zod4.z.array(parameterSpec).max(50),
|
|
790
|
+
description: serviceDescription
|
|
791
|
+
});
|
|
792
|
+
var serviceConfig = import_zod4.z.object({
|
|
793
|
+
slug,
|
|
794
|
+
ros_name: rosName,
|
|
795
|
+
type: rosTypeName,
|
|
796
|
+
parameters: import_zod4.z.array(parameterSpec).max(50),
|
|
797
|
+
description: serviceDescription
|
|
798
|
+
});
|
|
799
|
+
var publisherConfig = import_zod4.z.object({
|
|
800
|
+
slug,
|
|
801
|
+
topic: rosName,
|
|
802
|
+
type: rosTypeName,
|
|
803
|
+
parameters: import_zod4.z.array(parameterSpec).max(50),
|
|
804
|
+
timeout_ms: import_zod4.z.number().int().positive().max(6e4),
|
|
805
|
+
/** The message the bridge publishes on timeout. Shape is the ROS type's. */
|
|
806
|
+
failsafe: import_zod4.z.unknown(),
|
|
807
|
+
quiet_timeout_ms: import_zod4.z.number().int().nonnegative().max(6e5),
|
|
808
|
+
description: serviceDescription
|
|
809
|
+
});
|
|
810
|
+
var credentialRef = import_zod4.z.string().min(1).max(64);
|
|
811
|
+
var cameraSource = import_zod4.z.discriminatedUnion("kind", [
|
|
812
|
+
import_zod4.z.object({
|
|
813
|
+
kind: import_zod4.z.literal("ros"),
|
|
814
|
+
topic: rosName,
|
|
815
|
+
/** `sensor_msgs/msg/Image` or `sensor_msgs/msg/CompressedImage`. */
|
|
816
|
+
type: rosTypeName
|
|
817
|
+
}),
|
|
818
|
+
import_zod4.z.object({
|
|
819
|
+
kind: import_zod4.z.literal("rtsp"),
|
|
820
|
+
/**
|
|
821
|
+
* Scheme-constrained deliberately. The playbook drafted `z.string().url()`
|
|
822
|
+
* here and the shipped contract was `z.string().min(1).max(2048)` — nobody
|
|
823
|
+
* recorded the change, and the W6 review found the consequence: the bridge
|
|
824
|
+
* opens these with libraries that honour `file:` and `ftp:`, so an
|
|
825
|
+
* unconstrained URL turns a configuration document into an arbitrary
|
|
826
|
+
* local-file read on the robot, with the two distinct failure codes
|
|
827
|
+
* doubling as a file-existence oracle. Spec §7.6 is ROS-pure exposure with
|
|
828
|
+
* no shell or http features; that rule came back by omission rather than
|
|
829
|
+
* by intent. The bridge re-checks this too — a robot must not become a
|
|
830
|
+
* file server because a validator changed.
|
|
831
|
+
*/
|
|
832
|
+
url: import_zod4.z.string().min(1).max(2048).regex(/^rtsps?:\/\//i, "must be an rtsp:// or rtsps:// URL"),
|
|
833
|
+
/** TCP by default: UDP loses frames on a congested link, silently. */
|
|
834
|
+
transport: import_zod4.z.enum(["tcp", "udp"]).default("tcp"),
|
|
835
|
+
credentials_ref: credentialRef.nullable().default(null)
|
|
836
|
+
}),
|
|
837
|
+
import_zod4.z.object({
|
|
838
|
+
kind: import_zod4.z.literal("mjpeg"),
|
|
839
|
+
/** `http:`/`https:` only — see the `rtsp` variant above for why. */
|
|
840
|
+
url: import_zod4.z.string().min(1).max(2048).regex(/^https?:\/\//i, "must be an http:// or https:// URL"),
|
|
841
|
+
credentials_ref: credentialRef.nullable().default(null)
|
|
842
|
+
}),
|
|
843
|
+
import_zod4.z.object({
|
|
844
|
+
kind: import_zod4.z.literal("v4l2"),
|
|
845
|
+
/**
|
|
846
|
+
* e.g. `/dev/video0`, or a stable `/dev/v4l/by-id/...` symlink. Resolved
|
|
847
|
+
* on the robot, never by the cloud.
|
|
848
|
+
*
|
|
849
|
+
* Constrained to `/dev/` for the same reason the `rtsp` and `mjpeg` URLs
|
|
850
|
+
* are constrained to their schemes, and it was missed the first time
|
|
851
|
+
* (Momus, W6 verification). The device string reaches
|
|
852
|
+
* `cv2.VideoCapture(device)` on the robot, and OpenCV does not restrict
|
|
853
|
+
* itself to devices: measured on cv2 4.5.4, an ordinary local video file
|
|
854
|
+
* opens and its pixels are published to the cloud, and so does
|
|
855
|
+
* `http://127.0.0.1:8899/secret.jpg`. Unconstrained, this field is an
|
|
856
|
+
* arbitrary local-file read *and* an outbound fetch from inside the robot
|
|
857
|
+
* — the §7.6 violation closed for the other two source kinds, reachable
|
|
858
|
+
* through the fourth, because "it is just a device path" read like a
|
|
859
|
+
* reason not to check.
|
|
860
|
+
*
|
|
861
|
+
* Narrower than the URL hole in one respect worth recording: a non-media
|
|
862
|
+
* file and a missing file both fail to open, so this branch never worked
|
|
863
|
+
* as a file-existence oracle.
|
|
864
|
+
*
|
|
865
|
+
* The bridge re-derives this constraint rather than trusting the wire
|
|
866
|
+
* (`validate_device_path`), exactly as it re-derives the URL scheme.
|
|
867
|
+
*/
|
|
868
|
+
device: import_zod4.z.string().min(1).max(128).regex(/^\/dev\/[A-Za-z0-9][A-Za-z0-9._/-]*$/, "must be a device path under /dev/").refine((v) => !v.split("/").includes(".."), "must not contain a `..` path segment").refine((v) => !v.endsWith("/"), "must name a device, not a directory")
|
|
869
|
+
})
|
|
870
|
+
]);
|
|
871
|
+
var cameraConfig = import_zod4.z.object({
|
|
872
|
+
slug,
|
|
873
|
+
source: cameraSource,
|
|
874
|
+
width: import_zod4.z.number().int().positive().max(7680),
|
|
875
|
+
height: import_zod4.z.number().int().positive().max(4320),
|
|
876
|
+
fps: import_zod4.z.number().int().positive().max(60),
|
|
877
|
+
bitrate_kbps: import_zod4.z.number().int().positive().max(5e4),
|
|
878
|
+
/**
|
|
879
|
+
* How often a snapshot is captured. Bounded below at one second because a
|
|
880
|
+
* snapshot is the *cheap* mode — a developer who wants motion wants live,
|
|
881
|
+
* and an interval faster than this is a live stream wearing a disguise.
|
|
882
|
+
*/
|
|
883
|
+
snapshot_interval_ms: import_zod4.z.number().int().min(1e3).max(36e5),
|
|
884
|
+
description: serviceDescription
|
|
885
|
+
});
|
|
886
|
+
var robotConfigDoc = import_zod4.z.object({
|
|
887
|
+
datapoints: import_zod4.z.array(datapointConfig).max(200),
|
|
888
|
+
/**
|
|
889
|
+
* The three kinds W4 adds default to empty so that **every configuration
|
|
890
|
+
* published before W4 still parses**. Stored documents are jsonb; a
|
|
891
|
+
* required field here would have invalidated live robots' published
|
|
892
|
+
* versions on the first read after deploy.
|
|
893
|
+
*/
|
|
894
|
+
actions: import_zod4.z.array(actionConfig).max(200).default([]),
|
|
895
|
+
services: import_zod4.z.array(serviceConfig).max(200).default([]),
|
|
896
|
+
publishers: import_zod4.z.array(publisherConfig).max(200).default([]),
|
|
897
|
+
/** W5, defaulted for the same reason the W4 kinds were: stored jsonb. */
|
|
898
|
+
cameras: import_zod4.z.array(cameraConfig).max(50).default([])
|
|
899
|
+
});
|
|
900
|
+
var validationIssue = import_zod4.z.object({
|
|
901
|
+
path: import_zod4.z.string().min(1),
|
|
902
|
+
slug: import_zod4.z.string().nullable(),
|
|
903
|
+
code: import_zod4.z.string().min(1),
|
|
904
|
+
message: import_zod4.z.string().min(1),
|
|
905
|
+
severity: import_zod4.z.enum(["error", "warning"])
|
|
906
|
+
});
|
|
907
|
+
var configState = import_zod4.z.object({
|
|
908
|
+
published_version: import_zod4.z.number().int().positive().nullable(),
|
|
909
|
+
published_at: import_zod4.z.iso.datetime().nullable(),
|
|
910
|
+
draft_updated_at: import_zod4.z.iso.datetime().nullable(),
|
|
911
|
+
applied_version: import_zod4.z.number().int().nonnegative().nullable(),
|
|
912
|
+
applied_ok: import_zod4.z.boolean().nullable(),
|
|
913
|
+
applied_errors: import_zod4.z.array(import_zod4.z.object({ slug: import_zod4.z.string(), message: import_zod4.z.string() })).nullable()
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/introspection.js
|
|
917
|
+
var import_zod5 = require("zod");
|
|
918
|
+
var rosGraphEntry = import_zod5.z.object({
|
|
919
|
+
name: rosName,
|
|
920
|
+
types: import_zod5.z.array(rosTypeName).min(1)
|
|
921
|
+
});
|
|
922
|
+
var rosGraph = import_zod5.z.object({
|
|
923
|
+
topics: import_zod5.z.array(rosGraphEntry),
|
|
924
|
+
services: import_zod5.z.array(rosGraphEntry),
|
|
925
|
+
actions: import_zod5.z.array(rosGraphEntry),
|
|
926
|
+
captured_at_ms: import_zod5.z.number().int().nonnegative()
|
|
927
|
+
});
|
|
928
|
+
var typeField = import_zod5.z.lazy(() => import_zod5.z.object({
|
|
929
|
+
name: import_zod5.z.string().min(1).max(128),
|
|
930
|
+
type: import_zod5.z.string().min(1).max(255),
|
|
931
|
+
array: import_zod5.z.boolean(),
|
|
932
|
+
fields: import_zod5.z.array(typeField).nullable()
|
|
933
|
+
}));
|
|
934
|
+
var typeDefinition = import_zod5.z.discriminatedUnion("kind", [
|
|
935
|
+
import_zod5.z.object({
|
|
936
|
+
name: rosTypeName,
|
|
937
|
+
kind: import_zod5.z.literal("msg"),
|
|
938
|
+
fields: import_zod5.z.array(typeField)
|
|
939
|
+
}),
|
|
940
|
+
import_zod5.z.object({
|
|
941
|
+
name: rosTypeName,
|
|
942
|
+
kind: import_zod5.z.literal("srv"),
|
|
943
|
+
request: import_zod5.z.array(typeField),
|
|
944
|
+
response: import_zod5.z.array(typeField)
|
|
945
|
+
}),
|
|
946
|
+
import_zod5.z.object({
|
|
947
|
+
name: rosTypeName,
|
|
948
|
+
kind: import_zod5.z.literal("action"),
|
|
949
|
+
goal: import_zod5.z.array(typeField),
|
|
950
|
+
result: import_zod5.z.array(typeField),
|
|
951
|
+
feedback: import_zod5.z.array(typeField)
|
|
952
|
+
})
|
|
953
|
+
]);
|
|
954
|
+
|
|
955
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/jobs.js
|
|
956
|
+
var import_zod6 = require("zod");
|
|
957
|
+
var jobState = import_zod6.z.enum(["running", "succeeded", "failed", "cancelled", "lost"]);
|
|
958
|
+
var job = import_zod6.z.object({
|
|
959
|
+
id: import_zod6.z.uuid(),
|
|
960
|
+
robot_id: import_zod6.z.uuid(),
|
|
961
|
+
slug,
|
|
962
|
+
state: jobState,
|
|
963
|
+
started_at: import_zod6.z.iso.datetime(),
|
|
964
|
+
updated_at: import_zod6.z.iso.datetime(),
|
|
965
|
+
/**
|
|
966
|
+
* A monotonic counter, ascending in mint order (W7), and the **named**
|
|
967
|
+
* tiebreaker for any listing that claims an order.
|
|
968
|
+
*
|
|
969
|
+
* `started_at` is not a total order: two jobs minted in the same millisecond
|
|
970
|
+
* sort against each other arbitrarily, and arbitrarily means *differently on
|
|
971
|
+
* each query* — so `GET /api/robots/:id/jobs`, which documents "newest
|
|
972
|
+
* first", can show one twice and the other not at all. Exactly the defect
|
|
973
|
+
* `auditEvent.seq` was added for in W6b, in a route the same wave shipped.
|
|
974
|
+
*
|
|
975
|
+
* **Scoped honestly: per cloud process, per run.** Job state lives in memory
|
|
976
|
+
* (§6.1 — that is why `lost` exists at all), so this counter restarts when
|
|
977
|
+
* the cloud does, alongside the jobs it orders. Sound, because it only ever
|
|
978
|
+
* orders jobs that coexist in one registry — and stated, because a reader
|
|
979
|
+
* who assumed `auditEvent.seq`'s durable semantics would be wrong.
|
|
980
|
+
*/
|
|
981
|
+
seq: import_zod6.z.number().int().positive(),
|
|
982
|
+
/** Present once the job succeeded; shape is the ROS result's. */
|
|
983
|
+
result: import_zod6.z.unknown().nullable(),
|
|
984
|
+
/**
|
|
985
|
+
* Present on `failed`; a human message, plus a code where one exists.
|
|
986
|
+
*
|
|
987
|
+
* `details` exists because a refusal that carries only prose forces every
|
|
988
|
+
* consumer to parse it. W6b shipped `job_queue_full` with a documented
|
|
989
|
+
* `{limit, queued}` payload and **nowhere to put it**: the bridge reports a
|
|
990
|
+
* full queue as a job error, this shape had no `details`, and so the numbers
|
|
991
|
+
* were formatted into the message and lost. The console then rendered a
|
|
992
|
+
* "wait for one of N to finish" alert from a shape nothing in the system
|
|
993
|
+
* produced, and its test built that shape by hand — three repos agreeing
|
|
994
|
+
* with each other about a payload none of them exchanged (Momus, W6b
|
|
995
|
+
* review).
|
|
996
|
+
*
|
|
997
|
+
* Optional, because most job errors have nothing structured to add. Where a
|
|
998
|
+
* code has a documented payload — `job_queue_full` has
|
|
999
|
+
* `jobQueueFullDetails` — it belongs here, not in the sentence.
|
|
1000
|
+
*/
|
|
1001
|
+
error: import_zod6.z.object({
|
|
1002
|
+
code: import_zod6.z.string().min(1),
|
|
1003
|
+
message: import_zod6.z.string().min(1),
|
|
1004
|
+
details: import_zod6.z.unknown().optional()
|
|
1005
|
+
}).nullable()
|
|
1006
|
+
});
|
|
1007
|
+
var jobEvent = import_zod6.z.object({
|
|
1008
|
+
type: import_zod6.z.literal("job"),
|
|
1009
|
+
robot_id: import_zod6.z.uuid(),
|
|
1010
|
+
slug,
|
|
1011
|
+
job,
|
|
1012
|
+
/** Action feedback, if this update carries any. */
|
|
1013
|
+
feedback: import_zod6.z.unknown().nullable(),
|
|
1014
|
+
/** 0..1 when the action reports progress; null when it does not. */
|
|
1015
|
+
progress: import_zod6.z.number().min(0).max(1).nullable(),
|
|
1016
|
+
timestamp_ms: import_zod6.z.number().int().nonnegative()
|
|
1017
|
+
});
|
|
1018
|
+
var busyDetails = import_zod6.z.object({
|
|
1019
|
+
running: job
|
|
1020
|
+
});
|
|
1021
|
+
var publisherBusyDetails = import_zod6.z.object({
|
|
1022
|
+
/** The configured silence a holder must leave before anyone else may publish. */
|
|
1023
|
+
quiet_timeout_ms: import_zod6.z.number().int().nonnegative(),
|
|
1024
|
+
/** How much of that silence is still outstanding, now. */
|
|
1025
|
+
retry_after_ms: import_zod6.z.number().int().nonnegative()
|
|
1026
|
+
});
|
|
1027
|
+
var jobQueueFullDetails = import_zod6.z.object({
|
|
1028
|
+
/** The bridge's bound on queued jobs. */
|
|
1029
|
+
limit: import_zod6.z.number().int().positive(),
|
|
1030
|
+
/** How many are queued right now — `>= limit` when this refusal is sent. */
|
|
1031
|
+
queued: import_zod6.z.number().int().nonnegative()
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/protocol.js
|
|
1035
|
+
var MAX_PATIENCE_MS = 12e4;
|
|
1036
|
+
var MIN_PATIENCE_MS = 1e3;
|
|
1037
|
+
var activeJob = import_zod7.z.object({
|
|
1038
|
+
job_id: import_zod7.z.uuid(),
|
|
1039
|
+
slug,
|
|
1040
|
+
state: jobState
|
|
1041
|
+
});
|
|
1042
|
+
var bridgeHello = import_zod7.z.object({
|
|
1043
|
+
type: import_zod7.z.literal("hello"),
|
|
1044
|
+
protocol_version: import_zod7.z.number().int().positive(),
|
|
1045
|
+
token: import_zod7.z.string().min(1),
|
|
1046
|
+
bridge_version: import_zod7.z.string().min(1),
|
|
1047
|
+
/**
|
|
1048
|
+
* Every job this bridge still knows about, right now (spec §6.1, W4).
|
|
1049
|
+
*
|
|
1050
|
+
* A reconnect and a restart look **identical** on the wire otherwise: same
|
|
1051
|
+
* token, same version, same frame. But they must end differently — after a
|
|
1052
|
+
* dropped connection the running jobs are still running, after a restart
|
|
1053
|
+
* their results are gone forever. Asking the bridge to enumerate what it
|
|
1054
|
+
* still has settles it without either side guessing: the cloud marks every
|
|
1055
|
+
* job it believes is running on this robot and that is *not* named here as
|
|
1056
|
+
* `lost`.
|
|
1057
|
+
*
|
|
1058
|
+
* This deliberately needs no persistence at the bridge. A live process
|
|
1059
|
+
* lists its live jobs; a process that just started lists none, because it
|
|
1060
|
+
* has none — which is exactly the truth the cloud needs. A breadcrumb file
|
|
1061
|
+
* would only add a window in which the crash beat the write.
|
|
1062
|
+
*
|
|
1063
|
+
* Defaulted so pre-W4 bridges still parse; they had no jobs, so the empty
|
|
1064
|
+
* list is also the correct answer for them.
|
|
1065
|
+
*
|
|
1066
|
+
* **Renamed from `active_job_ids` in W6b**, when the entries stopped being
|
|
1067
|
+
* ids. A field called `_ids` holding objects is the shape this project has
|
|
1068
|
+
* repeatedly been caught by — a name that describes what the field used to
|
|
1069
|
+
* carry, kept because renaming looked like churn. Nothing is deployed yet
|
|
1070
|
+
* (W8 is the first deployment), so the old name is gone rather than
|
|
1071
|
+
* accepted alongside the new one: two accepted spellings would have to be
|
|
1072
|
+
* supported and reconciled forever, and nobody is asking for that.
|
|
1073
|
+
*/
|
|
1074
|
+
active_jobs: import_zod7.z.array(activeJob).max(500).default([])
|
|
1075
|
+
});
|
|
1076
|
+
var cloudHelloOk = import_zod7.z.object({
|
|
1077
|
+
type: import_zod7.z.literal("hello_ok"),
|
|
1078
|
+
robot_id: import_zod7.z.uuid()
|
|
1079
|
+
});
|
|
1080
|
+
var cloudHelloError = import_zod7.z.object({
|
|
1081
|
+
type: import_zod7.z.literal("hello_error"),
|
|
1082
|
+
code: import_zod7.z.string().min(1),
|
|
1083
|
+
message: import_zod7.z.string().min(1)
|
|
1084
|
+
});
|
|
1085
|
+
var datapointFrame = import_zod7.z.object({
|
|
1086
|
+
type: import_zod7.z.literal("datapoint"),
|
|
1087
|
+
slug,
|
|
1088
|
+
value: import_zod7.z.unknown(),
|
|
1089
|
+
timestamp_ms: import_zod7.z.number().int().nonnegative()
|
|
1090
|
+
});
|
|
1091
|
+
var cloudPing = import_zod7.z.object({
|
|
1092
|
+
type: import_zod7.z.literal("ping"),
|
|
1093
|
+
ts_ms: import_zod7.z.number().int().nonnegative()
|
|
1094
|
+
});
|
|
1095
|
+
var bridgePong = import_zod7.z.object({
|
|
1096
|
+
type: import_zod7.z.literal("pong"),
|
|
1097
|
+
ts_ms: import_zod7.z.number().int().nonnegative()
|
|
1098
|
+
});
|
|
1099
|
+
var cloudConfig = import_zod7.z.object({
|
|
1100
|
+
type: import_zod7.z.literal("config"),
|
|
1101
|
+
version: import_zod7.z.number().int().nonnegative(),
|
|
1102
|
+
doc: robotConfigDoc,
|
|
1103
|
+
credentials: import_zod7.z.record(credentialRef, import_zod7.z.object({ username: import_zod7.z.string(), password: import_zod7.z.string() })).default({})
|
|
1104
|
+
});
|
|
1105
|
+
var bridgeConfigApplied = import_zod7.z.object({
|
|
1106
|
+
type: import_zod7.z.literal("config_applied"),
|
|
1107
|
+
version: import_zod7.z.number().int().nonnegative(),
|
|
1108
|
+
ok: import_zod7.z.boolean(),
|
|
1109
|
+
errors: import_zod7.z.array(import_zod7.z.object({ slug: import_zod7.z.string(), message: import_zod7.z.string().min(1) }))
|
|
1110
|
+
});
|
|
1111
|
+
var cloudInvoke = import_zod7.z.object({
|
|
1112
|
+
type: import_zod7.z.literal("invoke"),
|
|
1113
|
+
job_id: import_zod7.z.uuid(),
|
|
1114
|
+
slug,
|
|
1115
|
+
/**
|
|
1116
|
+
* Already validated against §4.4 rules; the bridge validates structurally.
|
|
1117
|
+
*
|
|
1118
|
+
* **Flat, keyed by `parameterSpec.name`** — `{"target_pose.position.x": 1}`,
|
|
1119
|
+
* not a nested message tree. Three things follow from that and none of them
|
|
1120
|
+
* survive the nested form: the key a caller sends is the key a rule names,
|
|
1121
|
+
* so a `parameter_invalid` can report a `field` the caller can actually
|
|
1122
|
+
* find; the console binds one form input per spec; and a goal field that no
|
|
1123
|
+
* `parameterSpec` declares simply cannot be set, which is what §4.4 means by
|
|
1124
|
+
* the developer deciding what a client may pass. The bridge unflattens once,
|
|
1125
|
+
* on the way into the ROS goal or request.
|
|
1126
|
+
*/
|
|
1127
|
+
params: import_zod7.z.record(import_zod7.z.string(), import_zod7.z.unknown()),
|
|
1128
|
+
/**
|
|
1129
|
+
* How long this one call is worth waiting for (W6b), already resolved by
|
|
1130
|
+
* the cloud — the caller's `invokeRequest.patience_ms`, or
|
|
1131
|
+
* `DEFAULT_PATIENCE_MS` when they named none.
|
|
1132
|
+
*
|
|
1133
|
+
* **Required here, optional at REST**, deliberately. At the REST edge an
|
|
1134
|
+
* absent value is a caller who did not care and gets the default. By the
|
|
1135
|
+
* time the frame is on this socket somebody has decided, and the bridge
|
|
1136
|
+
* must never be in the position of picking a number the cloud is already
|
|
1137
|
+
* counting against — which is what two independent 15 s constants meant in
|
|
1138
|
+
* practice: a bridge that gave up at 15.0 s and a cloud that gave up at
|
|
1139
|
+
* 15.0 s, agreeing only by accident, with no way to tell whose deadline a
|
|
1140
|
+
* caller had actually hit.
|
|
1141
|
+
*/
|
|
1142
|
+
patience_ms: import_zod7.z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS)
|
|
1143
|
+
});
|
|
1144
|
+
var cloudCancel = import_zod7.z.object({
|
|
1145
|
+
type: import_zod7.z.literal("cancel"),
|
|
1146
|
+
slug,
|
|
1147
|
+
job_id: import_zod7.z.uuid().nullable()
|
|
1148
|
+
});
|
|
1149
|
+
var cloudPublish = import_zod7.z.object({
|
|
1150
|
+
type: import_zod7.z.literal("publish"),
|
|
1151
|
+
slug,
|
|
1152
|
+
/**
|
|
1153
|
+
* Flat and keyed by `parameterSpec.name`, exactly like `cloudInvoke.params`
|
|
1154
|
+
* — a publisher carries parameter specs and the same §4.4 validation, so it
|
|
1155
|
+
* must carry the same shape. Note this is *not* the shape of
|
|
1156
|
+
* `publisherConfig.failsafe`, which is a complete nested ROS message: the
|
|
1157
|
+
* failsafe is authored once by the developer against the type, never sent
|
|
1158
|
+
* by a caller and never rule-checked per field.
|
|
1159
|
+
*/
|
|
1160
|
+
message: import_zod7.z.record(import_zod7.z.string(), import_zod7.z.unknown())
|
|
1161
|
+
});
|
|
1162
|
+
var bridgeJobUpdate = import_zod7.z.object({
|
|
1163
|
+
type: import_zod7.z.literal("job_update"),
|
|
1164
|
+
job_id: import_zod7.z.uuid(),
|
|
1165
|
+
slug,
|
|
1166
|
+
state: jobState,
|
|
1167
|
+
feedback: import_zod7.z.unknown().nullable(),
|
|
1168
|
+
progress: import_zod7.z.number().min(0).max(1).nullable(),
|
|
1169
|
+
result: import_zod7.z.unknown().nullable(),
|
|
1170
|
+
/** Same shape as `job.error`, `details` included — see `jobs.ts`. */
|
|
1171
|
+
error: import_zod7.z.object({ code: import_zod7.z.string().min(1), message: import_zod7.z.string().min(1), details: import_zod7.z.unknown().optional() }).nullable(),
|
|
1172
|
+
timestamp_ms: import_zod7.z.number().int().nonnegative()
|
|
1173
|
+
});
|
|
1174
|
+
var bridgeJobLost = import_zod7.z.object({
|
|
1175
|
+
type: import_zod7.z.literal("job_lost"),
|
|
1176
|
+
job_ids: import_zod7.z.array(import_zod7.z.uuid())
|
|
1177
|
+
});
|
|
1178
|
+
var cloudIntrospectRequest = import_zod7.z.object({
|
|
1179
|
+
type: import_zod7.z.literal("introspect_request"),
|
|
1180
|
+
request_id: import_zod7.z.string().min(1).max(64)
|
|
1181
|
+
});
|
|
1182
|
+
var bridgeIntrospect = import_zod7.z.object({
|
|
1183
|
+
type: import_zod7.z.literal("introspect"),
|
|
1184
|
+
request_id: import_zod7.z.string().min(1).max(64),
|
|
1185
|
+
graph: rosGraph
|
|
1186
|
+
});
|
|
1187
|
+
var cloudTypeRequest = import_zod7.z.object({
|
|
1188
|
+
type: import_zod7.z.literal("type_request"),
|
|
1189
|
+
request_id: import_zod7.z.string().min(1).max(64),
|
|
1190
|
+
type_names: import_zod7.z.array(rosTypeName).min(1).max(50)
|
|
1191
|
+
});
|
|
1192
|
+
var bridgeTypeDefinitions = import_zod7.z.object({
|
|
1193
|
+
type: import_zod7.z.literal("type_definitions"),
|
|
1194
|
+
request_id: import_zod7.z.string().min(1).max(64),
|
|
1195
|
+
definitions: import_zod7.z.array(typeDefinition),
|
|
1196
|
+
unresolved: import_zod7.z.array(import_zod7.z.string())
|
|
1197
|
+
});
|
|
1198
|
+
var bridgeState = import_zod7.z.object({
|
|
1199
|
+
online: import_zod7.z.boolean(),
|
|
1200
|
+
latency_ms: import_zod7.z.number().nonnegative().nullable()
|
|
1201
|
+
});
|
|
1202
|
+
var snapshotHeader = import_zod7.z.object({
|
|
1203
|
+
type: import_zod7.z.literal("snapshot"),
|
|
1204
|
+
slug,
|
|
1205
|
+
/** `image/jpeg` in practice; stated so nothing has to sniff the bytes. */
|
|
1206
|
+
mime: import_zod7.z.string().min(1).max(64),
|
|
1207
|
+
width: import_zod7.z.number().int().positive(),
|
|
1208
|
+
height: import_zod7.z.number().int().positive(),
|
|
1209
|
+
timestamp_ms: import_zod7.z.number().int().nonnegative()
|
|
1210
|
+
});
|
|
1211
|
+
var cloudCameraStart = import_zod7.z.object({
|
|
1212
|
+
type: import_zod7.z.literal("camera_start"),
|
|
1213
|
+
slug,
|
|
1214
|
+
url: import_zod7.z.string().min(1),
|
|
1215
|
+
room: import_zod7.z.string().min(1),
|
|
1216
|
+
token: import_zod7.z.string().min(1),
|
|
1217
|
+
/**
|
|
1218
|
+
* Names **this attempt** (W6b), and is echoed in the `camera_state` that
|
|
1219
|
+
* answers it.
|
|
1220
|
+
*
|
|
1221
|
+
* W6a gave `camera_state` a `cause` and said in the same comment that a
|
|
1222
|
+
* cause is not a correlation. This is the other half. Start a camera, have
|
|
1223
|
+
* it fail slowly, start it again: the first attempt's failure arrives while
|
|
1224
|
+
* the second is in flight, matches on slug, and resolves the attempt it
|
|
1225
|
+
* knows nothing about. The viewer is then told the running stream failed,
|
|
1226
|
+
* for a reason belonging to an attempt that is already over.
|
|
1227
|
+
*/
|
|
1228
|
+
request_id: import_zod7.z.string().min(1).max(64)
|
|
1229
|
+
});
|
|
1230
|
+
var bridgeAssetsAvailable = import_zod7.z.object({
|
|
1231
|
+
type: import_zod7.z.literal("assets_available"),
|
|
1232
|
+
/** Whether `/robot_description` (or the configured source) yielded a URDF. */
|
|
1233
|
+
urdf: import_zod7.z.boolean(),
|
|
1234
|
+
/**
|
|
1235
|
+
* Every `package://` URI the URDF references, verbatim and unresolved —
|
|
1236
|
+
* including the ones this bridge cannot find in its workspace. Reporting
|
|
1237
|
+
* only the resolvable ones would make an incomplete workspace look like a
|
|
1238
|
+
* complete robot, and the cloud would have nothing to show as missing.
|
|
1239
|
+
*/
|
|
1240
|
+
meshes: import_zod7.z.array(import_zod7.z.string().min(1))
|
|
1241
|
+
});
|
|
1242
|
+
var cloudAssetRequest = import_zod7.z.object({
|
|
1243
|
+
type: import_zod7.z.literal("asset_request"),
|
|
1244
|
+
sync_id: import_zod7.z.uuid(),
|
|
1245
|
+
upload_url: import_zod7.z.url(),
|
|
1246
|
+
token: import_zod7.z.string().min(1),
|
|
1247
|
+
/** Which URIs to send. Empty means the URDF only. */
|
|
1248
|
+
meshes: import_zod7.z.array(import_zod7.z.string().min(1))
|
|
1249
|
+
});
|
|
1250
|
+
var bridgeAssetProgress = import_zod7.z.object({
|
|
1251
|
+
type: import_zod7.z.literal("asset_progress"),
|
|
1252
|
+
sync_id: import_zod7.z.uuid(),
|
|
1253
|
+
done: import_zod7.z.number().int().nonnegative(),
|
|
1254
|
+
total: import_zod7.z.number().int().nonnegative(),
|
|
1255
|
+
/**
|
|
1256
|
+
* **Each entry says why** — see `assetFailure` in `assets.ts` for the three
|
|
1257
|
+
* kinds and why one word was not enough. The bound is `assets.ts`'s too: a
|
|
1258
|
+
* `.dae` with 17,331 unresolvable internal references produced a frame 32
|
|
1259
|
+
* bytes over `MAX_WS_PAYLOAD_BYTES`, and `ws` enforces that **before**
|
|
1260
|
+
* delivery — so the outcome was the robot's own socket closed, mid-sync, by
|
|
1261
|
+
* a file in its workspace (Kassandra-W7a). A producer at its own ceiling
|
|
1262
|
+
* reports **one** `refused` entry naming the file, not one per reference.
|
|
1263
|
+
*/
|
|
1264
|
+
failed: import_zod7.z.array(assetFailure).max(1e3),
|
|
1265
|
+
/**
|
|
1266
|
+
* Three values, because a boolean `finished` had nowhere to put a refusal.
|
|
1267
|
+
*
|
|
1268
|
+
* A second `asset_request` arriving while one is in flight has to be
|
|
1269
|
+
* answered with something. The bridge's guard is a backstop — the cloud owns
|
|
1270
|
+
* sync lifecycle and refuses a concurrent one first — but a backstop that
|
|
1271
|
+
* answers with silence is a backstop nobody can debug, and the alternative
|
|
1272
|
+
* on the table was to report every requested URI in `failed`. That would
|
|
1273
|
+
* have made `failed` mean two different things at once — *could not be
|
|
1274
|
+
* resolved* and *was never attempted* — which is the one-field-two-facts
|
|
1275
|
+
* defect this project has now split five times (`set`/`readable`,
|
|
1276
|
+
* `truncated`/`truncated_by`, `value`/`sample_count`, `publishing`/`cause`,
|
|
1277
|
+
* and camera health's own).
|
|
1278
|
+
*
|
|
1279
|
+
* So: `running` while work is happening, `finished` when the bridge will
|
|
1280
|
+
* send no more for this sync, `refused_busy` when it never started because
|
|
1281
|
+
* another sync was in flight. `failed` keeps its single meaning.
|
|
1282
|
+
*
|
|
1283
|
+
* Raised by Rosie-W7, who found the gap by asking what a second request
|
|
1284
|
+
* should do rather than picking the silent option.
|
|
1285
|
+
*/
|
|
1286
|
+
state: import_zod7.z.enum(["running", "finished", "refused_busy"])
|
|
1287
|
+
});
|
|
1288
|
+
var cloudCameraStop = import_zod7.z.object({
|
|
1289
|
+
type: import_zod7.z.literal("camera_stop"),
|
|
1290
|
+
slug,
|
|
1291
|
+
/** Names this stop, echoed by the `camera_state` that answers it — see `cloudCameraStart.request_id`. */
|
|
1292
|
+
request_id: import_zod7.z.string().min(1).max(64)
|
|
1293
|
+
});
|
|
1294
|
+
var bridgeCameraState = import_zod7.z.object({
|
|
1295
|
+
type: import_zod7.z.literal("camera_state"),
|
|
1296
|
+
slug,
|
|
1297
|
+
publishing: import_zod7.z.boolean(),
|
|
1298
|
+
error: import_zod7.z.object({ code: import_zod7.z.string().min(1), message: import_zod7.z.string().min(1) }).nullable(),
|
|
1299
|
+
/**
|
|
1300
|
+
* Why this frame was sent (W6a).
|
|
1301
|
+
*
|
|
1302
|
+
* Without it, `{publishing: false, error: null}` is sent for **three
|
|
1303
|
+
* different things** — an answer to `camera_stop`, a stream stopped by a
|
|
1304
|
+
* configuration change, and a source that recovered — and the cloud can
|
|
1305
|
+
* only tell them apart by remembering what it saw before. Deriving a cause
|
|
1306
|
+
* from remembered state is precisely the inference this project keeps
|
|
1307
|
+
* finding to be wrong, and W6a exists because four failures had been
|
|
1308
|
+
* sharing one silence.
|
|
1309
|
+
*
|
|
1310
|
+
* `'command'` this frame answers a `camera_start` / `camera_stop`.
|
|
1311
|
+
* `'source'` unsolicited: the source's own health changed, whether or
|
|
1312
|
+
* not anybody is watching. This is the frame that makes a
|
|
1313
|
+
* wrong password visible without a viewer.
|
|
1314
|
+
* `'config_change'` a configuration change stopped this stream. Not a
|
|
1315
|
+
* failure, and it must not be logged as one.
|
|
1316
|
+
* `'live_lost'` publishing ended unexpectedly after it had started.
|
|
1317
|
+
*
|
|
1318
|
+
* Note it does **not** answer "which attempt is this?" — `camera_state`
|
|
1319
|
+
* still has no request id, and that remains a named deferral in cluster C.
|
|
1320
|
+
* `cause` says what kind of event this is; correlation is a separate fact
|
|
1321
|
+
* and giving one field both jobs would be the same mistake again.
|
|
1322
|
+
*
|
|
1323
|
+
* Required, not optional: an absent cause would default to the reading
|
|
1324
|
+
* somebody happens to assume, and every frame's sender knows its own
|
|
1325
|
+
* reason. Old bridges fail validation on this frame — acceptable while
|
|
1326
|
+
* nothing is deployed, and W8 is the first deployment.
|
|
1327
|
+
*/
|
|
1328
|
+
cause: import_zod7.z.enum(["command", "source", "config_change", "live_lost"]),
|
|
1329
|
+
/**
|
|
1330
|
+
* When the **robot** observed this state — bridge capture time, never
|
|
1331
|
+
* receive time, the same discipline `timestamp_ms` follows for samples
|
|
1332
|
+
* (spec §6.3).
|
|
1333
|
+
*
|
|
1334
|
+
* It exists because the cloud stamped `resourceHealthState.changed_at_ms`
|
|
1335
|
+
* with its own `Date.now()`, and a **restatement** is by definition an old
|
|
1336
|
+
* state re-sent into an empty map. So after a cloud restart every failure —
|
|
1337
|
+
* including one from yesterday — was dated to the restart, in the one
|
|
1338
|
+
* scenario `changed_at_ms`'s own doc comment was written for: *"a page that
|
|
1339
|
+
* loads late must be able to tell a failure from a minute ago from one from
|
|
1340
|
+
* yesterday"*.
|
|
1341
|
+
*
|
|
1342
|
+
* On a restatement this carries **when the state was first observed**, not
|
|
1343
|
+
* when the frame was sent. A bridge that re-states a failure it has held for
|
|
1344
|
+
* an hour says so.
|
|
1345
|
+
*/
|
|
1346
|
+
observed_at_ms: import_zod7.z.number().int().nonnegative(),
|
|
1347
|
+
/**
|
|
1348
|
+
* Which request this frame answers (W6b), or `null` when it answers none.
|
|
1349
|
+
*
|
|
1350
|
+
* `null` is not a gap and must not be treated as one: a `cause: 'source'`
|
|
1351
|
+
* frame — the unsolicited health report that makes a wrong password visible
|
|
1352
|
+
* with nobody watching — answers no request by definition, and so does a
|
|
1353
|
+
* `config_change` stop. Those are the majority of frames on a healthy
|
|
1354
|
+
* system.
|
|
1355
|
+
*
|
|
1356
|
+
* A frame with `cause: 'command'` carries the `request_id` of the
|
|
1357
|
+
* `camera_start` or `camera_stop` it answers. **The cloud resolves a
|
|
1358
|
+
* pending attempt only on a matching id**, and drops a `command` frame
|
|
1359
|
+
* whose id it no longer recognises rather than applying it to whatever is
|
|
1360
|
+
* pending — a late answer to a cancelled attempt is stale, not current.
|
|
1361
|
+
*
|
|
1362
|
+
* **The pairing rule is not in this schema, deliberately.** "Non-null iff
|
|
1363
|
+
* `cause === 'command'`" is a cross-field constraint; a zod `.refine()`
|
|
1364
|
+
* would express it at runtime and then **disappear** from the generated
|
|
1365
|
+
* JSON Schema, which is what the bridge vendors. The cloud would reject
|
|
1366
|
+
* frames the bridge had validated as correct — the same artifact/runtime
|
|
1367
|
+
* divergence that `.default()` publishing as `required` has produced four
|
|
1368
|
+
* times in this project, only pointing the other way. The rule is enforced
|
|
1369
|
+
* where the correlation is used, in the cloud's bridge frame handler, and
|
|
1370
|
+
* stated here so nobody has to derive it from that code.
|
|
1371
|
+
*/
|
|
1372
|
+
request_id: import_zod7.z.string().min(1).max(64).nullable()
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/rest.js
|
|
1376
|
+
var import_zod8 = require("zod");
|
|
1377
|
+
var robot = import_zod8.z.object({
|
|
1378
|
+
id: import_zod8.z.uuid(),
|
|
1379
|
+
name: import_zod8.z.string().min(1).max(63),
|
|
1380
|
+
created_at: import_zod8.z.iso.datetime()
|
|
1381
|
+
});
|
|
1382
|
+
var createRobotRequest = import_zod8.z.object({
|
|
1383
|
+
name: import_zod8.z.string().min(1).max(63)
|
|
1384
|
+
});
|
|
1385
|
+
var robotToken = import_zod8.z.string().regex(/^frt_[0-9a-f]{32}$/);
|
|
1386
|
+
var createRobotResponse = import_zod8.z.object({
|
|
1387
|
+
robot,
|
|
1388
|
+
token: robotToken
|
|
1389
|
+
});
|
|
1390
|
+
var robotListItem = import_zod8.z.object({
|
|
1391
|
+
...robot.shape,
|
|
1392
|
+
bridge_state: bridgeState
|
|
1393
|
+
});
|
|
1394
|
+
var robotListResponse = import_zod8.z.object({
|
|
1395
|
+
robots: import_zod8.z.array(robotListItem)
|
|
1396
|
+
});
|
|
1397
|
+
var datapointValue = import_zod8.z.object({
|
|
1398
|
+
slug,
|
|
1399
|
+
value: import_zod8.z.unknown(),
|
|
1400
|
+
timestamp_ms: import_zod8.z.number().int().nonnegative()
|
|
1401
|
+
});
|
|
1402
|
+
var robotDetailResponse = import_zod8.z.object({
|
|
1403
|
+
...robotListItem.shape,
|
|
1404
|
+
bridge_version: import_zod8.z.string().min(1).nullable(),
|
|
1405
|
+
last_hello_error: import_zod8.z.object({
|
|
1406
|
+
code: import_zod8.z.string().min(1),
|
|
1407
|
+
message: import_zod8.z.string().min(1),
|
|
1408
|
+
at: import_zod8.z.iso.datetime()
|
|
1409
|
+
}).nullable(),
|
|
1410
|
+
config: configState
|
|
1411
|
+
});
|
|
1412
|
+
var configDraftResponse = import_zod8.z.object({
|
|
1413
|
+
doc: robotConfigDoc,
|
|
1414
|
+
updated_at: import_zod8.z.iso.datetime().nullable(),
|
|
1415
|
+
issues: import_zod8.z.array(validationIssue)
|
|
1416
|
+
});
|
|
1417
|
+
var putConfigDraftRequest = import_zod8.z.object({ doc: robotConfigDoc });
|
|
1418
|
+
var publishConfigResponse = import_zod8.z.object({
|
|
1419
|
+
version: import_zod8.z.number().int().positive(),
|
|
1420
|
+
published_at: import_zod8.z.iso.datetime()
|
|
1421
|
+
});
|
|
1422
|
+
var configVersionsResponse = import_zod8.z.object({
|
|
1423
|
+
versions: import_zod8.z.array(import_zod8.z.object({
|
|
1424
|
+
version: import_zod8.z.number().int().positive(),
|
|
1425
|
+
published_at: import_zod8.z.iso.datetime()
|
|
1426
|
+
}))
|
|
1427
|
+
});
|
|
1428
|
+
var configVersionResponse = import_zod8.z.object({
|
|
1429
|
+
version: import_zod8.z.number().int().positive(),
|
|
1430
|
+
published_at: import_zod8.z.iso.datetime(),
|
|
1431
|
+
doc: robotConfigDoc
|
|
1432
|
+
});
|
|
1433
|
+
var introspectionResponse = import_zod8.z.object({
|
|
1434
|
+
graph: rosGraph,
|
|
1435
|
+
fetched_at: import_zod8.z.iso.datetime(),
|
|
1436
|
+
stale: import_zod8.z.boolean()
|
|
1437
|
+
});
|
|
1438
|
+
var typesResponse = import_zod8.z.object({
|
|
1439
|
+
types: import_zod8.z.array(typeDefinition)
|
|
1440
|
+
});
|
|
1441
|
+
var fetchTypesRequest = import_zod8.z.object({
|
|
1442
|
+
type_names: import_zod8.z.array(rosTypeName).min(1).max(50)
|
|
1443
|
+
});
|
|
1444
|
+
var fetchTypesResponse = import_zod8.z.object({
|
|
1445
|
+
types: import_zod8.z.array(typeDefinition),
|
|
1446
|
+
unresolved: import_zod8.z.array(import_zod8.z.string())
|
|
1447
|
+
});
|
|
1448
|
+
var datapointDescriptor = import_zod8.z.object({
|
|
1449
|
+
slug,
|
|
1450
|
+
builtin: import_zod8.z.boolean(),
|
|
1451
|
+
unit: import_zod8.z.string().nullable(),
|
|
1452
|
+
range: datapointRange.nullable(),
|
|
1453
|
+
rate: datapointRate.nullable()
|
|
1454
|
+
});
|
|
1455
|
+
var datapointListResponse = import_zod8.z.object({
|
|
1456
|
+
datapoints: import_zod8.z.array(datapointDescriptor)
|
|
1457
|
+
});
|
|
1458
|
+
var robotDetailsDoc = import_zod8.z.record(import_zod8.z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/), import_zod8.z.union([import_zod8.z.string().max(4096), import_zod8.z.number(), import_zod8.z.boolean(), import_zod8.z.array(import_zod8.z.unknown()), import_zod8.z.record(import_zod8.z.string(), import_zod8.z.unknown())]));
|
|
1459
|
+
var putRobotDetailsRequest = import_zod8.z.object({ details: robotDetailsDoc });
|
|
1460
|
+
var invokeRequest = import_zod8.z.object({
|
|
1461
|
+
params: import_zod8.z.record(import_zod8.z.string(), import_zod8.z.unknown()),
|
|
1462
|
+
/**
|
|
1463
|
+
* How long **this call** is worth waiting for, in milliseconds (W6b).
|
|
1464
|
+
*
|
|
1465
|
+
* **Absent means `DEFAULT_PATIENCE_MS`** — today's behaviour, unchanged, for
|
|
1466
|
+
* every caller who does not care. It is optional because most callers have
|
|
1467
|
+
* no opinion, and forcing one on them would mean every SDK example carries a
|
|
1468
|
+
* number its author guessed.
|
|
1469
|
+
*
|
|
1470
|
+
* It exists because patience was a **server constant** and could therefore
|
|
1471
|
+
* only ever be wrong in one of two directions at a time: long enough for a
|
|
1472
|
+
* planner meant a dead service also took that long to report, and short
|
|
1473
|
+
* enough for a snappy lookup meant a legitimate slow job was reported as
|
|
1474
|
+
* `bridge_timeout` — a healthy robot, described as broken, with nothing the
|
|
1475
|
+
* caller could do about it.
|
|
1476
|
+
*
|
|
1477
|
+
* The number travels with the call to the bridge (`cloudInvoke.patience_ms`)
|
|
1478
|
+
* so that **one** deadline governs both sides. Capped at
|
|
1479
|
+
* `MAX_PATIENCE_MS`; above that the call is refused with
|
|
1480
|
+
* `validation_error` rather than silently clamped, because a caller who
|
|
1481
|
+
* asked for ten minutes and was quietly given two would read the timeout as
|
|
1482
|
+
* the robot's failure.
|
|
1483
|
+
*
|
|
1484
|
+
* For a service call this is the whole wait. For an action it bounds goal
|
|
1485
|
+
* *acceptance* — once a goal is accepted the job runs as long as it runs,
|
|
1486
|
+
* and is observed, not awaited.
|
|
1487
|
+
*/
|
|
1488
|
+
patience_ms: import_zod8.z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS).optional()
|
|
1489
|
+
});
|
|
1490
|
+
var cancelRequest = import_zod8.z.object({
|
|
1491
|
+
job_id: import_zod8.z.uuid().nullable().optional()
|
|
1492
|
+
}).strict();
|
|
1493
|
+
var releaseLiveQuery = import_zod8.z.object({
|
|
1494
|
+
session_id: import_zod8.z.uuid().optional()
|
|
1495
|
+
}).strict();
|
|
1496
|
+
var invokeResponse = import_zod8.z.object({
|
|
1497
|
+
job,
|
|
1498
|
+
/** The slug's kind — see `commandResult.kind` for why the caller needs it. */
|
|
1499
|
+
kind: import_zod8.z.enum(["action", "service"])
|
|
1500
|
+
});
|
|
1501
|
+
var serviceCallResponse = import_zod8.z.object({
|
|
1502
|
+
result: import_zod8.z.unknown()
|
|
1503
|
+
});
|
|
1504
|
+
var publishRequest = import_zod8.z.object({
|
|
1505
|
+
message: import_zod8.z.record(import_zod8.z.string(), import_zod8.z.unknown())
|
|
1506
|
+
});
|
|
1507
|
+
var jobResponse = import_zod8.z.object({ job: job.nullable() });
|
|
1508
|
+
var rateLimitDetails = import_zod8.z.object({
|
|
1509
|
+
retry_after_ms: import_zod8.z.number().int().nonnegative()
|
|
1510
|
+
});
|
|
1511
|
+
var robotJobsResponse = import_zod8.z.object({
|
|
1512
|
+
jobs: import_zod8.z.array(job)
|
|
1513
|
+
});
|
|
1514
|
+
var exposure = import_zod8.z.object({
|
|
1515
|
+
slug,
|
|
1516
|
+
kind: import_zod8.z.enum(["datapoint", "action", "service", "publisher", "camera"]),
|
|
1517
|
+
builtin: import_zod8.z.boolean()
|
|
1518
|
+
});
|
|
1519
|
+
var exposureListResponse = import_zod8.z.object({
|
|
1520
|
+
exposures: import_zod8.z.array(exposure)
|
|
1521
|
+
});
|
|
1522
|
+
var SNAPSHOT_HEADERS = {
|
|
1523
|
+
ageMs: "x-fleetless-age-ms",
|
|
1524
|
+
timestampMs: "x-fleetless-timestamp-ms",
|
|
1525
|
+
width: "x-fleetless-width",
|
|
1526
|
+
height: "x-fleetless-height"
|
|
1527
|
+
};
|
|
1528
|
+
var cameraDescriptor = import_zod8.z.object({
|
|
1529
|
+
slug,
|
|
1530
|
+
width: import_zod8.z.number().int().positive(),
|
|
1531
|
+
height: import_zod8.z.number().int().positive(),
|
|
1532
|
+
fps: import_zod8.z.number().int().positive(),
|
|
1533
|
+
snapshot_interval_ms: import_zod8.z.number().int().positive()
|
|
1534
|
+
});
|
|
1535
|
+
var cameraListResponse = import_zod8.z.object({ cameras: import_zod8.z.array(cameraDescriptor) });
|
|
1536
|
+
var liveSessionResponse = import_zod8.z.object({
|
|
1537
|
+
/**
|
|
1538
|
+
* This viewer's hold, and the **only** thing `DELETE` should be given
|
|
1539
|
+
* (W6b).
|
|
1540
|
+
*
|
|
1541
|
+
* A hold was addressed by `{identity, robot, slug}` and nothing else, so
|
|
1542
|
+
* two tabs of one logged-in user were one hold as far as the refcount could
|
|
1543
|
+
* see. Closing either tab released it: the second tab kept its LiveKit
|
|
1544
|
+
* connection — the token is checked at join and never again — and went on
|
|
1545
|
+
* rendering a video that the robot had already stopped producing. The
|
|
1546
|
+
* viewer sees a frozen picture, not an ended session, which is the failure
|
|
1547
|
+
* this project rejects everywhere else.
|
|
1548
|
+
*
|
|
1549
|
+
* `DELETE` without a session id keeps today's meaning — *release my holds
|
|
1550
|
+
* on this camera* — because an SDK that has lost its id, or a client that
|
|
1551
|
+
* is going away entirely, still needs a way to let go. It is the blunt
|
|
1552
|
+
* form, and it is the one that strands other tabs; new callers pass the id.
|
|
1553
|
+
*/
|
|
1554
|
+
session_id: import_zod8.z.uuid(),
|
|
1555
|
+
url: import_zod8.z.string().min(1),
|
|
1556
|
+
room: import_zod8.z.string().min(1),
|
|
1557
|
+
token: import_zod8.z.string().min(1),
|
|
1558
|
+
expires_at: import_zod8.z.iso.datetime()
|
|
1559
|
+
});
|
|
1560
|
+
var snapshotMetaResponse = import_zod8.z.object({
|
|
1561
|
+
slug,
|
|
1562
|
+
timestamp_ms: import_zod8.z.number().int().nonnegative().nullable(),
|
|
1563
|
+
age_ms: import_zod8.z.number().int().nonnegative().nullable(),
|
|
1564
|
+
width: import_zod8.z.number().int().positive().nullable(),
|
|
1565
|
+
height: import_zod8.z.number().int().positive().nullable(),
|
|
1566
|
+
mime: import_zod8.z.string().nullable()
|
|
1567
|
+
});
|
|
1568
|
+
var historyQuery = import_zod8.z.object({
|
|
1569
|
+
from: import_zod8.z.string().min(1).max(32),
|
|
1570
|
+
/** Defaults to now. */
|
|
1571
|
+
to: import_zod8.z.string().min(1).max(32).optional(),
|
|
1572
|
+
/** Bucket width, e.g. `10s`, `1m`. Absent means raw samples. */
|
|
1573
|
+
window: import_zod8.z.string().min(2).max(16).optional(),
|
|
1574
|
+
agg: import_zod8.z.enum(["min", "max", "avg"]).optional(),
|
|
1575
|
+
/** A numeric field inside an object value, e.g. `pose.x` (§4.4 paths). */
|
|
1576
|
+
field: import_zod8.z.string().min(1).max(128).optional(),
|
|
1577
|
+
/**
|
|
1578
|
+
* `z.coerce` because this schema describes a **query string**, where every
|
|
1579
|
+
* value arrives as text. A bare `z.number()` would make each route coerce
|
|
1580
|
+
* `limit` by hand before parsing — Nimbus had to, and flagged that the next
|
|
1581
|
+
* query-taking route would have to as well. A schema that does not match
|
|
1582
|
+
* the wire it describes exports its problem to every consumer.
|
|
1583
|
+
*/
|
|
1584
|
+
limit: import_zod8.z.coerce.number().int().positive().max(1e4).optional()
|
|
1585
|
+
});
|
|
1586
|
+
var historySamplesResponse = import_zod8.z.object({
|
|
1587
|
+
slug,
|
|
1588
|
+
kind: import_zod8.z.literal("samples"),
|
|
1589
|
+
samples: import_zod8.z.array(import_zod8.z.object({ timestamp_ms: import_zod8.z.number().int().nonnegative(), value: import_zod8.z.unknown() })),
|
|
1590
|
+
truncated: import_zod8.z.boolean(),
|
|
1591
|
+
/**
|
|
1592
|
+
* Why it was cut, `null` when it was not — because the two causes have
|
|
1593
|
+
* **different remedies** and a single boolean cannot tell them apart:
|
|
1594
|
+
*
|
|
1595
|
+
* `'limit'` too many rows. Raise `limit` (up to 10 000).
|
|
1596
|
+
* `'bytes'` the rows are large. Raising `limit` will not help — narrow
|
|
1597
|
+
* the range, or name a numeric `field` so whole messages are
|
|
1598
|
+
* not carried.
|
|
1599
|
+
*
|
|
1600
|
+
* A caller cannot derive this: comparing `samples.length` against `limit`
|
|
1601
|
+
* only works if they sent one, and the server's default is not in the
|
|
1602
|
+
* response. So without this field, "raise the limit" is the natural next
|
|
1603
|
+
* move in both cases, and in the second it changes nothing.
|
|
1604
|
+
*
|
|
1605
|
+
* Nullable rather than optional on purpose: `.default()` publishes as
|
|
1606
|
+
* `required` in the JSON Schema artifacts, which is the contradiction this
|
|
1607
|
+
* project has now hit five times.
|
|
1608
|
+
*/
|
|
1609
|
+
truncated_by: import_zod8.z.enum(["limit", "bytes"]).nullable()
|
|
1610
|
+
});
|
|
1611
|
+
var historyBucketsResponse = import_zod8.z.object({
|
|
1612
|
+
slug,
|
|
1613
|
+
kind: import_zod8.z.literal("buckets"),
|
|
1614
|
+
window_ms: import_zod8.z.number().int().positive(),
|
|
1615
|
+
agg: import_zod8.z.enum(["min", "max", "avg"]),
|
|
1616
|
+
buckets: import_zod8.z.array(import_zod8.z.object({
|
|
1617
|
+
bucket_start_ms: import_zod8.z.number().int().nonnegative(),
|
|
1618
|
+
/**
|
|
1619
|
+
* The aggregate over this bucket's **numeric** samples — or `null` when
|
|
1620
|
+
* none of them were numeric, which is **not** the same as the bucket
|
|
1621
|
+
* being empty. `sample_count` is the field that separates those facts:
|
|
1622
|
+
*
|
|
1623
|
+
* value: null, sample_count: 0 nothing was recorded — a gap
|
|
1624
|
+
* value: null, sample_count: 3 three samples, none of them numeric
|
|
1625
|
+
* value: 0, sample_count: 3 three samples, and the average is zero
|
|
1626
|
+
*
|
|
1627
|
+
* A chart must draw the first as a break in the line and must **not**
|
|
1628
|
+
* draw the second as one: data exists there, it simply has no height.
|
|
1629
|
+
*
|
|
1630
|
+
* This sentence previously read "`null` only ever means 'no samples in
|
|
1631
|
+
* this bucket'", and the implementation counted numeric contributors,
|
|
1632
|
+
* so the second row above was indistinguishable from the first and the
|
|
1633
|
+
* console rendered "empty — no samples" over live data.
|
|
1634
|
+
*/
|
|
1635
|
+
value: import_zod8.z.number().nullable(),
|
|
1636
|
+
/**
|
|
1637
|
+
* Every sample that landed in this bucket and inside the queried range,
|
|
1638
|
+
* whether or not it contributed to `value` — which is the point of the
|
|
1639
|
+
* field, since only a count of *all* samples can prove a bucket empty
|
|
1640
|
+
* rather than merely unplottable.
|
|
1641
|
+
*
|
|
1642
|
+
* Two consequences, stated rather than left to be discovered:
|
|
1643
|
+
*
|
|
1644
|
+
* - `value` is not an average *of* `sample_count` samples when a
|
|
1645
|
+
* datapoint's values are mixed, so **`value * sample_count` is not a
|
|
1646
|
+
* sum**.
|
|
1647
|
+
* - On a first or last bucket the count reflects the **range**, not the
|
|
1648
|
+
* bucket: an edge bucket can begin before `from` or extend past `to`,
|
|
1649
|
+
* and only in-range samples are counted. A low edge count is a
|
|
1650
|
+
* boundary effect, not a quiet period.
|
|
1651
|
+
*/
|
|
1652
|
+
sample_count: import_zod8.z.number().int().nonnegative()
|
|
1653
|
+
}))
|
|
1654
|
+
});
|
|
1655
|
+
var robotDeletionSummary = import_zod8.z.object({
|
|
1656
|
+
/**
|
|
1657
|
+
* Datapoints, actions, services and publishers in the **published**
|
|
1658
|
+
* configuration — what the robot was actually running. **Cameras are not
|
|
1659
|
+
* counted here**; they are the `cameras` array below.
|
|
1660
|
+
*
|
|
1661
|
+
* The split has to be stated because the summary carries both, and the
|
|
1662
|
+
* console renders them in one sentence: *"this deletes N published slugs …
|
|
1663
|
+
* and M cameras"*. With cameras inside `slug_count` that sentence counts
|
|
1664
|
+
* them twice, on the one screen whose whole justification is naming what an
|
|
1665
|
+
* irreversible click destroys (Momus, W6a review — the cloud summed all
|
|
1666
|
+
* five and the console then added the cameras again).
|
|
1667
|
+
*
|
|
1668
|
+
* A draft is destroyed too and is described by `had_unpublished_draft`
|
|
1669
|
+
* rather than by either of these: describing three things with two numbers
|
|
1670
|
+
* would make each of them mean something else.
|
|
1671
|
+
*/
|
|
1672
|
+
slug_count: import_zod8.z.number().int().nonnegative(),
|
|
1673
|
+
sample_rows: import_zod8.z.number().int().nonnegative(),
|
|
1674
|
+
bytes_freed: import_zod8.z.number().int().nonnegative(),
|
|
1675
|
+
cameras: import_zod8.z.array(slug),
|
|
1676
|
+
/**
|
|
1677
|
+
* Assets destroyed with the robot (W7), and **`asset_bytes_freed` is what
|
|
1678
|
+
* this org actually gets back** — not the sum of the assets' sizes.
|
|
1679
|
+
*
|
|
1680
|
+
* Storage is content-addressed, so a mesh two robots share survives the
|
|
1681
|
+
* deletion of one of them and frees nothing. Reporting the total would tell
|
|
1682
|
+
* a developer they are about to recover 400 MB and hand back 4, on the one
|
|
1683
|
+
* screen whose entire justification is naming what an irreversible click
|
|
1684
|
+
* destroys. Same reasoning that keeps `cameras` out of `slug_count`: this
|
|
1685
|
+
* summary is read aloud to a human, and a number that is nearly right is
|
|
1686
|
+
* worse here than an absent one.
|
|
1687
|
+
*
|
|
1688
|
+
* `asset_count` is the plain count of the robot's asset rows, all of which
|
|
1689
|
+
* do go away.
|
|
1690
|
+
*/
|
|
1691
|
+
asset_count: import_zod8.z.number().int().nonnegative(),
|
|
1692
|
+
asset_bytes_freed: import_zod8.z.number().int().nonnegative(),
|
|
1693
|
+
had_live_session: import_zod8.z.boolean(),
|
|
1694
|
+
/**
|
|
1695
|
+
* Whether an unpublished draft went with it — separately, because the
|
|
1696
|
+
* counts above deliberately do not include it and a record that silently
|
|
1697
|
+
* omitted the draft would be a receipt for less than was destroyed.
|
|
1698
|
+
*
|
|
1699
|
+
* `true` also covers the robot that was configured but never published:
|
|
1700
|
+
* there the counts are zero and this is the only field saying anything
|
|
1701
|
+
* was there at all.
|
|
1702
|
+
*/
|
|
1703
|
+
had_unpublished_draft: import_zod8.z.boolean()
|
|
1704
|
+
});
|
|
1705
|
+
var RESOURCE_HEALTH_STATES = [
|
|
1706
|
+
"ok",
|
|
1707
|
+
/** The host did not answer. Not the same as refusing the password. */
|
|
1708
|
+
"unreachable",
|
|
1709
|
+
/** The host answered and rejected the credentials. */
|
|
1710
|
+
"auth_failed",
|
|
1711
|
+
/** The stored password cannot be decrypted — see `credentialSummary.readable`. */
|
|
1712
|
+
"unreadable_credential",
|
|
1713
|
+
/**
|
|
1714
|
+
* A camera names a credential that **does not exist** in this org — deleted,
|
|
1715
|
+
* mistyped, or belonging to somebody else (W6a review).
|
|
1716
|
+
*
|
|
1717
|
+
* Separate from `unreadable_credential` because that one asserts a
|
|
1718
|
+
* decryption that was attempted and failed, and here nothing was ever
|
|
1719
|
+
* encrypted: the developer is sent to a page where the credential is not
|
|
1720
|
+
* listed at all, to rotate something that is not there. And separate from
|
|
1721
|
+
* `unknown`, which means "the robot reported a failure we cannot classify"
|
|
1722
|
+
* — a different fact with a different fix.
|
|
1723
|
+
*
|
|
1724
|
+
* It is reachable by a typo: `cloud-config-frame.ts` deliberately tolerates
|
|
1725
|
+
* an unresolved `credentials_ref` at publish time, so this is an ordinary
|
|
1726
|
+
* developer mistake rather than an edge case.
|
|
1727
|
+
*/
|
|
1728
|
+
"credential_missing",
|
|
1729
|
+
/** A configuration change stopped this stream, deliberately. */
|
|
1730
|
+
"stopped_by_config_change",
|
|
1731
|
+
/** Publishing failed after the session was already granted. */
|
|
1732
|
+
"publish_failed",
|
|
1733
|
+
/**
|
|
1734
|
+
* Something is wrong and this platform cannot say what.
|
|
1735
|
+
*
|
|
1736
|
+
* The alternative was worse. A bridge error code the mapping table does not
|
|
1737
|
+
* know had two possible fallbacks: report `ok`, which hides a real failure,
|
|
1738
|
+
* or fold it into `unreachable`, which **asserts a cause nobody
|
|
1739
|
+
* established** — sending a developer to check a network when the problem
|
|
1740
|
+
* may be a password. The map falls back here and logs the unmapped code
|
|
1741
|
+
* loudly, so the gap in the table is visible instead of confident.
|
|
1742
|
+
*/
|
|
1743
|
+
"unknown"
|
|
1744
|
+
];
|
|
1745
|
+
var resourceHealthState = import_zod8.z.object({
|
|
1746
|
+
robot_id: import_zod8.z.uuid(),
|
|
1747
|
+
kind: import_zod8.z.enum(["camera", "credential"]),
|
|
1748
|
+
/** The camera slug, or the credential name. */
|
|
1749
|
+
ref: import_zod8.z.string().min(1).max(64),
|
|
1750
|
+
state: import_zod8.z.enum(RESOURCE_HEALTH_STATES),
|
|
1751
|
+
/** A short human-readable reason, or `null`. Never an exception message. */
|
|
1752
|
+
reason: import_zod8.z.string().max(200).nullable(),
|
|
1753
|
+
/**
|
|
1754
|
+
* When this state was entered — not when it was sent. A page that loads
|
|
1755
|
+
* late must be able to tell a failure from a minute ago from one from
|
|
1756
|
+
* yesterday, and a state with only a send time cannot.
|
|
1757
|
+
*/
|
|
1758
|
+
changed_at_ms: import_zod8.z.number().int().nonnegative()
|
|
1759
|
+
});
|
|
1760
|
+
var resourceHealthListResponse = import_zod8.z.object({
|
|
1761
|
+
resources: import_zod8.z.array(resourceHealthState)
|
|
1762
|
+
});
|
|
1763
|
+
var orgQuotas = import_zod8.z.object({
|
|
1764
|
+
max_robots: import_zod8.z.number().int().positive(),
|
|
1765
|
+
max_apps: import_zod8.z.number().int().positive(),
|
|
1766
|
+
max_end_users: import_zod8.z.number().int().positive(),
|
|
1767
|
+
max_retention_bytes: import_zod8.z.number().int().nonnegative(),
|
|
1768
|
+
max_retention_writes_per_minute: import_zod8.z.number().int().nonnegative(),
|
|
1769
|
+
max_realtime_connections: import_zod8.z.number().int().positive(),
|
|
1770
|
+
/**
|
|
1771
|
+
* Asset storage (§4.6, W7) — **its own dial, not part of
|
|
1772
|
+
* `max_retention_bytes`.** A sync grows storage in jumps and time series
|
|
1773
|
+
* grow steadily; one dial would let the first crowd out the second, and the
|
|
1774
|
+
* org that hit its limit would be told to look at the wrong thing.
|
|
1775
|
+
*
|
|
1776
|
+
* **Counted per distinct blob *this org references* — not per asset row, and
|
|
1777
|
+
* not per object the platform stores on its behalf (W7a, D1).** The two
|
|
1778
|
+
* readings are indistinguishable from the number alone and a customer is
|
|
1779
|
+
* entitled to know which one they are being charged for.
|
|
1780
|
+
*
|
|
1781
|
+
* Within an org, sharing is free: two robots referencing the same mesh cost
|
|
1782
|
+
* one copy, which is what dedup means to a customer, and anything else
|
|
1783
|
+
* charges an org twice for a fleet of identical robots — the normal case.
|
|
1784
|
+
*
|
|
1785
|
+
* **Across orgs, sharing is not free, and W7 shipped the opposite.** Storage
|
|
1786
|
+
* stays globally content-addressed (one object per sha256; that efficiency
|
|
1787
|
+
* is real), but accounting is per-org: an org is charged for each distinct
|
|
1788
|
+
* blob it references and credited when its own last reference goes, whether
|
|
1789
|
+
* or not the blob survives for somebody else. Global refcounting made the
|
|
1790
|
+
* first org to sync a blob pay for it forever while every later org stored
|
|
1791
|
+
* it free — so the quota was evadable by anyone whose mesh someone else had
|
|
1792
|
+
* already uploaded, and an org's own number depended on who got there first,
|
|
1793
|
+
* which nobody can predict. Measured before the change: 342 bytes held by an
|
|
1794
|
+
* org owning no assets, with no operation able to free them.
|
|
1795
|
+
*/
|
|
1796
|
+
max_asset_storage_bytes: import_zod8.z.number().int().nonnegative()
|
|
1797
|
+
});
|
|
1798
|
+
var orgQuotaUsageCounts = import_zod8.z.object({
|
|
1799
|
+
max_robots: import_zod8.z.number().int().nonnegative(),
|
|
1800
|
+
max_apps: import_zod8.z.number().int().nonnegative(),
|
|
1801
|
+
max_end_users: import_zod8.z.number().int().nonnegative(),
|
|
1802
|
+
max_retention_bytes: import_zod8.z.number().int().nonnegative(),
|
|
1803
|
+
max_asset_storage_bytes: import_zod8.z.number().int().nonnegative(),
|
|
1804
|
+
max_retention_writes_per_minute: import_zod8.z.number().int().nonnegative(),
|
|
1805
|
+
max_realtime_connections: import_zod8.z.number().int().nonnegative()
|
|
1806
|
+
}).partial();
|
|
1807
|
+
var orgQuotaUsage = import_zod8.z.object({ quotas: orgQuotas, usage: orgQuotaUsageCounts });
|
|
1808
|
+
var credentialSummary = import_zod8.z.object({
|
|
1809
|
+
name: import_zod8.z.string().min(1).max(64),
|
|
1810
|
+
username: import_zod8.z.string().nullable(),
|
|
1811
|
+
/**
|
|
1812
|
+
* Whether a password has ever been stored for this name.
|
|
1813
|
+
*
|
|
1814
|
+
* **Always `true` today**, and stated so rather than left to be inferred:
|
|
1815
|
+
* `credentialWriteRequest` requires a non-empty password, so no row can
|
|
1816
|
+
* exist without one, and both write paths set this literally. A consumer
|
|
1817
|
+
* branching on `set === false` is writing dead code — the SDK README
|
|
1818
|
+
* currently teaches exactly that (Momus, W6a review).
|
|
1819
|
+
*
|
|
1820
|
+
* The field is kept because the fact it names is the one `readable`
|
|
1821
|
+
* qualifies, and because a username-only credential is a plausible future
|
|
1822
|
+
* shape. If that never arrives, this should be removed rather than left as
|
|
1823
|
+
* a permanent constant wearing the costume of a question.
|
|
1824
|
+
*/
|
|
1825
|
+
set: import_zod8.z.boolean(),
|
|
1826
|
+
/**
|
|
1827
|
+
* Whether that password can still be **decrypted** — a different fact from
|
|
1828
|
+
* `set`, and deliberately a second field rather than a tri-state on the
|
|
1829
|
+
* first (W6a).
|
|
1830
|
+
*
|
|
1831
|
+
* They come apart when `CAMERA_CREDENTIALS_KEY` is rotated, unset or wrong,
|
|
1832
|
+
* or when a row is corrupt. W6 made that survivable: one unreadable
|
|
1833
|
+
* credential costs the cameras that reference it instead of taking the
|
|
1834
|
+
* robot offline. But the surviving failure was **invisible** — this route
|
|
1835
|
+
* answered `set: true` with `used_by` naming the dependent camera, for a
|
|
1836
|
+
* credential that ships as `credentials: {}` on every config frame, and the
|
|
1837
|
+
* only evidence was a server log no developer can read.
|
|
1838
|
+
*
|
|
1839
|
+
* `set: true, readable: false` is therefore the shape that says "a password
|
|
1840
|
+
* is stored and this platform can no longer use it" — which is a thing to
|
|
1841
|
+
* act on, and nothing else in the API could say it.
|
|
1842
|
+
*/
|
|
1843
|
+
readable: import_zod8.z.boolean(),
|
|
1844
|
+
used_by: import_zod8.z.array(import_zod8.z.object({ robot_id: import_zod8.z.uuid(), slug }))
|
|
1845
|
+
});
|
|
1846
|
+
var credentialListResponse = import_zod8.z.object({ credentials: import_zod8.z.array(credentialSummary) });
|
|
1847
|
+
var credentialWriteRequest = import_zod8.z.object({
|
|
1848
|
+
username: import_zod8.z.string().min(1).max(128),
|
|
1849
|
+
password: import_zod8.z.string().min(1).max(512)
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/realtime.js
|
|
1853
|
+
var import_zod11 = require("zod");
|
|
1854
|
+
|
|
1855
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/client-auth.js
|
|
1856
|
+
var import_zod10 = require("zod");
|
|
1857
|
+
|
|
1858
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/apps.js
|
|
1859
|
+
var import_zod9 = require("zod");
|
|
1860
|
+
var appIdentifier = slug;
|
|
1861
|
+
var app = import_zod9.z.object({
|
|
1862
|
+
id: import_zod9.z.uuid(),
|
|
1863
|
+
org_id: import_zod9.z.uuid(),
|
|
1864
|
+
name: import_zod9.z.string().min(1).max(120),
|
|
1865
|
+
identifier: appIdentifier,
|
|
1866
|
+
/** Robots are referenced individually; tags never grant rights (§12.2). */
|
|
1867
|
+
robot_ids: import_zod9.z.array(import_zod9.z.uuid()),
|
|
1868
|
+
/**
|
|
1869
|
+
* Whether this app accepts **self-registering** OAuth clients (RFC 7591).
|
|
1870
|
+
*
|
|
1871
|
+
* Off by default and per app, because a normal app has no reason to accept
|
|
1872
|
+
* them: its own client is registered by the developer with known redirect
|
|
1873
|
+
* URIs. An MCP app does, because the end user *"trägt nur die URL ein"*
|
|
1874
|
+
* (§17) and the client registers itself — nobody vetted it, and
|
|
1875
|
+
* `POST /oauth/register` is therefore an **unauthenticated write endpoint**
|
|
1876
|
+
* standing in front of tools that move a physical robot.
|
|
1877
|
+
*
|
|
1878
|
+
* The flag is app state rather than a deployment setting so that turning it
|
|
1879
|
+
* on is a decision somebody made about one app, visible in the console and
|
|
1880
|
+
* in the audit log. It is also the precursor of W7c's MCP-App kind — the
|
|
1881
|
+
* model grows here rather than being retrofitted around it.
|
|
1882
|
+
*/
|
|
1883
|
+
accepts_dynamic_clients: import_zod9.z.boolean(),
|
|
1884
|
+
/**
|
|
1885
|
+
* Whether this app serves a remote MCP server at `/mcp/<identifier>` (§17).
|
|
1886
|
+
*
|
|
1887
|
+
* **A switch, not an app kind.** §2 and §17 called the MCP app *"eine eigene
|
|
1888
|
+
* App-Art"*; André decided on 2026-08-18 that it is a per-app switch the
|
|
1889
|
+
* developer flips in the console, and §17 was reworded in the same wave
|
|
1890
|
+
* rather than left contradicting this field. The model grows here — which
|
|
1891
|
+
* is what the comment on `accepts_dynamic_clients` above predicted it would
|
|
1892
|
+
* do, one wave before there was anything to add.
|
|
1893
|
+
*
|
|
1894
|
+
* The two switches are related and not the same. `accepts_dynamic_clients`
|
|
1895
|
+
* decides whether a client may **register itself**; this one decides whether
|
|
1896
|
+
* there is anything for it to reach. An MCP app will usually want both,
|
|
1897
|
+
* because §17's end user *"trägt nur die URL ein"* and the AI tool registers
|
|
1898
|
+
* itself — but a developer who registers their own MCP client by hand wants
|
|
1899
|
+
* exactly this one, and coupling them would take that away.
|
|
1900
|
+
*
|
|
1901
|
+
* **Off means off at the metadata too.** With this false, `/mcp/<app>`
|
|
1902
|
+
* answers `404` and so do its discovery documents. A resource that is
|
|
1903
|
+
* advertised and not served sends a conforming client through the whole
|
|
1904
|
+
* discovery chain to a door that is not there — and W7b spent a wave making
|
|
1905
|
+
* that chain walkable.
|
|
1906
|
+
*/
|
|
1907
|
+
mcp_enabled: import_zod9.z.boolean(),
|
|
1908
|
+
created_at: import_zod9.z.iso.datetime()
|
|
1909
|
+
});
|
|
1910
|
+
var createAppRequest = import_zod9.z.object({
|
|
1911
|
+
name: import_zod9.z.string().min(1).max(120),
|
|
1912
|
+
identifier: appIdentifier,
|
|
1913
|
+
robot_ids: import_zod9.z.array(import_zod9.z.uuid()).optional(),
|
|
1914
|
+
/** Optional, defaulting to `false` — same reasoning as `robot_ids` above: setting it at creation is the obvious operation, and refusing it here would make a `.strict()` request reject the field the caller can plainly see on `app`. */
|
|
1915
|
+
accepts_dynamic_clients: import_zod9.z.boolean().optional(),
|
|
1916
|
+
/**
|
|
1917
|
+
* **W7c's playbook said this field would not be accepted here, and the
|
|
1918
|
+
* sentence above is why that was wrong.** The argument for refusing it was
|
|
1919
|
+
* W7's `robot_ids` finding — a create shape that silently drops a field cost
|
|
1920
|
+
* two people a day each. But that finding was closed by *accepting* the
|
|
1921
|
+
* field, not by refusing it, and this request is `.strict()`: refusing
|
|
1922
|
+
* `mcp_enabled` would make it `400` on a field the caller can plainly see on
|
|
1923
|
+
* `app`, which is the exact shape the line above rejects. One rule, both
|
|
1924
|
+
* switches.
|
|
1925
|
+
*/
|
|
1926
|
+
mcp_enabled: import_zod9.z.boolean().optional()
|
|
1927
|
+
}).strict();
|
|
1928
|
+
var updateAppRequest = import_zod9.z.object({
|
|
1929
|
+
name: import_zod9.z.string().min(1).max(120).optional(),
|
|
1930
|
+
robot_ids: import_zod9.z.array(import_zod9.z.uuid()).optional(),
|
|
1931
|
+
accepts_dynamic_clients: import_zod9.z.boolean().optional(),
|
|
1932
|
+
mcp_enabled: import_zod9.z.boolean().optional()
|
|
1933
|
+
});
|
|
1934
|
+
var serverKeyToken = import_zod9.z.string().regex(/^flk_[0-9a-f]{32}$/);
|
|
1935
|
+
var serverKey = import_zod9.z.object({
|
|
1936
|
+
id: import_zod9.z.uuid(),
|
|
1937
|
+
app_id: import_zod9.z.uuid(),
|
|
1938
|
+
name: import_zod9.z.string().min(1).max(120),
|
|
1939
|
+
created_at: import_zod9.z.iso.datetime(),
|
|
1940
|
+
/** Null until first use — the cheapest way to spot a key nobody needs. */
|
|
1941
|
+
last_used_at: import_zod9.z.iso.datetime().nullable()
|
|
1942
|
+
});
|
|
1943
|
+
var createServerKeyResponse = import_zod9.z.object({
|
|
1944
|
+
server_key: serverKey,
|
|
1945
|
+
key: serverKeyToken
|
|
1946
|
+
});
|
|
1947
|
+
var role = import_zod9.z.object({
|
|
1948
|
+
id: import_zod9.z.uuid(),
|
|
1949
|
+
app_id: import_zod9.z.uuid(),
|
|
1950
|
+
name: import_zod9.z.string().min(1).max(60),
|
|
1951
|
+
builtin: import_zod9.z.boolean()
|
|
1952
|
+
});
|
|
1953
|
+
var rolePermissions = import_zod9.z.object({
|
|
1954
|
+
role_id: import_zod9.z.uuid(),
|
|
1955
|
+
/**
|
|
1956
|
+
* **A slug is unique per robot across ALL service kinds** (spec §4.1:
|
|
1957
|
+
* "Jeder Dienst erhält einen Slug" — one namespace, not one per kind), and
|
|
1958
|
+
* the cloud's config validation enforces that with a kind-agnostic
|
|
1959
|
+
* collection pass. That is why this list carries slugs and not
|
|
1960
|
+
* (kind, slug) pairs: when W4 adds actions, services and publishers, a
|
|
1961
|
+
* grant keeps meaning exactly what it means today, and this shape does not
|
|
1962
|
+
* change. What W4 does need is an endpoint that lists every *grantable*
|
|
1963
|
+
* slug of a robot with its kind, so the console's matrix can offer them —
|
|
1964
|
+
* today it enumerates datapoints only, which is the seam that would
|
|
1965
|
+
* otherwise force a rebuild.
|
|
1966
|
+
*/
|
|
1967
|
+
grants: import_zod9.z.array(import_zod9.z.object({
|
|
1968
|
+
robot_id: import_zod9.z.uuid(),
|
|
1969
|
+
slugs: import_zod9.z.array(slug)
|
|
1970
|
+
})),
|
|
1971
|
+
/**
|
|
1972
|
+
* App-wide abilities a role grants, as opposed to per-slug grants above.
|
|
1973
|
+
*
|
|
1974
|
+
* **A capability here is a promise, and two of them have not been kept.**
|
|
1975
|
+
* `action_history` and `presence` have been gated by this object since W4
|
|
1976
|
+
* and are implemented nowhere — no route, no SDK method, no realtime frame
|
|
1977
|
+
* (register row 8). A console can therefore switch them on and nothing
|
|
1978
|
+
* changes, which is worse than their absence: the developer believes they
|
|
1979
|
+
* granted something.
|
|
1980
|
+
*
|
|
1981
|
+
* `assets` (W7) must not become the third. It gates §4.6's asset store,
|
|
1982
|
+
* which is not covered by `grants` because **assets are not slugs** — and it
|
|
1983
|
+
* is its own decision rather than a side effect of reaching the robot,
|
|
1984
|
+
* because a mesh set gives away the machine's build.
|
|
1985
|
+
*/
|
|
1986
|
+
capabilities: import_zod9.z.object({
|
|
1987
|
+
action_history: import_zod9.z.boolean(),
|
|
1988
|
+
presence: import_zod9.z.boolean(),
|
|
1989
|
+
assets: import_zod9.z.boolean()
|
|
1990
|
+
})
|
|
1991
|
+
});
|
|
1992
|
+
var appMembership = import_zod9.z.object({
|
|
1993
|
+
end_user_id: import_zod9.z.uuid(),
|
|
1994
|
+
app_id: import_zod9.z.uuid(),
|
|
1995
|
+
role_id: import_zod9.z.uuid()
|
|
1996
|
+
});
|
|
1997
|
+
var brandingConfig = import_zod9.z.object({
|
|
1998
|
+
/** `#rrggbb`, lowercase — one canonical spelling so two configs that look identical are identical. */
|
|
1999
|
+
primary_color: import_zod9.z.string().regex(/^#[0-9a-f]{6}$/, "primary_color must be lowercase #rrggbb"),
|
|
2000
|
+
/**
|
|
2001
|
+
* 256 KiB of raw image at most. Base64 costs 4 bytes per 3, so the encoded
|
|
2002
|
+
* ceiling is stated here in encoded characters — the unit the validator can
|
|
2003
|
+
* actually count, rather than one it would have to infer.
|
|
2004
|
+
*/
|
|
2005
|
+
logo_data_uri: import_zod9.z.string().max(349528).regex(/^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+={0,2}$/, "logo must be a base64 data URI of image/png or image/jpeg").optional(),
|
|
2006
|
+
footer_text: import_zod9.z.string().min(1).max(200).optional()
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/client-auth.js
|
|
2010
|
+
var clientLoginRequest = import_zod10.z.object({
|
|
2011
|
+
app_identifier: appIdentifier,
|
|
2012
|
+
email: import_zod10.z.email(),
|
|
2013
|
+
password: import_zod10.z.string().min(1)
|
|
2014
|
+
});
|
|
2015
|
+
var clientRefreshRequest = import_zod10.z.object({
|
|
2016
|
+
refresh_token: import_zod10.z.string().min(1)
|
|
2017
|
+
});
|
|
2018
|
+
var clientLogoutRequest = import_zod10.z.object({
|
|
2019
|
+
refresh_token: import_zod10.z.string().min(1)
|
|
2020
|
+
});
|
|
2021
|
+
var clientIdentity = import_zod10.z.object({
|
|
2022
|
+
kind: import_zod10.z.enum(["developer", "end_user", "server_key"]),
|
|
2023
|
+
developer_id: import_zod10.z.uuid().nullable(),
|
|
2024
|
+
end_user_id: import_zod10.z.uuid().nullable(),
|
|
2025
|
+
server_key_id: import_zod10.z.uuid().nullable(),
|
|
2026
|
+
app_id: import_zod10.z.uuid().nullable(),
|
|
2027
|
+
role_id: import_zod10.z.uuid().nullable(),
|
|
2028
|
+
email: import_zod10.z.email().nullable()
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/realtime.js
|
|
2032
|
+
var clientAuth = import_zod11.z.object({
|
|
2033
|
+
type: import_zod11.z.literal("auth"),
|
|
2034
|
+
token: import_zod11.z.string().min(1)
|
|
2035
|
+
});
|
|
2036
|
+
var authOk = import_zod11.z.object({
|
|
2037
|
+
type: import_zod11.z.literal("auth_ok"),
|
|
2038
|
+
identity: clientIdentity
|
|
2039
|
+
});
|
|
2040
|
+
var authError = import_zod11.z.object({
|
|
2041
|
+
type: import_zod11.z.literal("auth_error"),
|
|
2042
|
+
code: import_zod11.z.string().min(1),
|
|
2043
|
+
message: import_zod11.z.string().min(1)
|
|
2044
|
+
});
|
|
2045
|
+
var clientInvoke = import_zod11.z.object({
|
|
2046
|
+
type: import_zod11.z.literal("invoke"),
|
|
2047
|
+
request_id: import_zod11.z.string().min(1).max(64),
|
|
2048
|
+
robot_id: import_zod11.z.uuid(),
|
|
2049
|
+
slug,
|
|
2050
|
+
/** Parameters by field path, validated against the config's rules (§4.4). */
|
|
2051
|
+
params: import_zod11.z.record(import_zod11.z.string(), import_zod11.z.unknown()),
|
|
2052
|
+
/**
|
|
2053
|
+
* How long this one call is worth waiting for (W6b) — the same field,
|
|
2054
|
+
* meaning and cap as `invokeRequest.patience_ms`; absent means
|
|
2055
|
+
* `DEFAULT_PATIENCE_MS`.
|
|
2056
|
+
*
|
|
2057
|
+
* It is here because **§11.1 parity is a rule, not a preference**: what REST
|
|
2058
|
+
* can do travels over this socket. The first version of this delta gave
|
|
2059
|
+
* `patience_ms` to the REST body only — and the SDK invokes exclusively over
|
|
2060
|
+
* the realtime channel, so the field would have been unreachable for every
|
|
2061
|
+
* SDK caller while appearing in the documentation. W6a shipped four SDK
|
|
2062
|
+
* methods no SDK caller could invoke; this is the same defect caught before
|
|
2063
|
+
* it shipped, by the SDK owner rather than by a reviewer.
|
|
2064
|
+
*/
|
|
2065
|
+
patience_ms: import_zod11.z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS).optional()
|
|
2066
|
+
});
|
|
2067
|
+
var clientCancel = import_zod11.z.object({
|
|
2068
|
+
type: import_zod11.z.literal("cancel"),
|
|
2069
|
+
request_id: import_zod11.z.string().min(1).max(64),
|
|
2070
|
+
robot_id: import_zod11.z.uuid(),
|
|
2071
|
+
/** Which slug — required, and the only address a cancel had until W6b. */
|
|
2072
|
+
slug,
|
|
2073
|
+
/**
|
|
2074
|
+
* Which job on that slug (W6b), or `null` for *whatever is running there*.
|
|
2075
|
+
*
|
|
2076
|
+
* The two are different requests and both are legitimate. An operator
|
|
2077
|
+
* hitting a stop button means the second: stop the machine, whatever it is
|
|
2078
|
+
* doing. A client cancelling the job it started means the first — and until
|
|
2079
|
+
* this field existed it could not say so, so a cancel that arrived just
|
|
2080
|
+
* after its own job ended stopped the next caller's job instead. Same slug,
|
|
2081
|
+
* same wire frame, entirely different machine behaviour, and nothing in the
|
|
2082
|
+
* protocol able to tell them apart.
|
|
2083
|
+
*
|
|
2084
|
+
* A named id that is not running answers `not_found` rather than falling
|
|
2085
|
+
* back to the slug. Falling back would be the platform deciding that the
|
|
2086
|
+
* caller did not really mean the id they typed.
|
|
2087
|
+
*/
|
|
2088
|
+
job_id: import_zod11.z.uuid().nullable()
|
|
2089
|
+
});
|
|
2090
|
+
var clientPublish = import_zod11.z.object({
|
|
2091
|
+
type: import_zod11.z.literal("publish"),
|
|
2092
|
+
request_id: import_zod11.z.string().min(1).max(64),
|
|
2093
|
+
robot_id: import_zod11.z.uuid(),
|
|
2094
|
+
slug,
|
|
2095
|
+
message: import_zod11.z.record(import_zod11.z.string(), import_zod11.z.unknown())
|
|
2096
|
+
});
|
|
2097
|
+
var commandResult = import_zod11.z.object({
|
|
2098
|
+
type: import_zod11.z.literal("command_result"),
|
|
2099
|
+
request_id: import_zod11.z.string().min(1).max(64),
|
|
2100
|
+
ok: import_zod11.z.boolean(),
|
|
2101
|
+
/**
|
|
2102
|
+
* The job this reply is *about* — which is not always the caller's own.
|
|
2103
|
+
*
|
|
2104
|
+
* - `ok:true` on an invoke or a call: the job that was just created.
|
|
2105
|
+
* - `ok:true` on a cancel: the job the cancel was sent to.
|
|
2106
|
+
* - `ok:false, code:'busy'`: **the job that is already running** — the
|
|
2107
|
+
* caller has none. This is the §11.3 "inkl. Information, was läuft", and
|
|
2108
|
+
* it is the whole reason a busy refusal is useful: the caller learns
|
|
2109
|
+
* whether to wait or to give up (see `busyDetails`).
|
|
2110
|
+
* - any other refusal: `null`.
|
|
2111
|
+
*/
|
|
2112
|
+
job: job.nullable(),
|
|
2113
|
+
/**
|
|
2114
|
+
* The **kind** of the slug this command addressed, when the slug resolved.
|
|
2115
|
+
*
|
|
2116
|
+
* Invoke and call share a route on purpose — both create the job on the
|
|
2117
|
+
* slug — but a *client* library distinguishes them: `services.call` waits
|
|
2118
|
+
* for a terminal state, `actions.invoke` does not. Without the kind coming
|
|
2119
|
+
* back, calling a service helper on an action slug **starts the real action
|
|
2120
|
+
* on the robot** and then blames the robot for not finishing, half a minute
|
|
2121
|
+
* later. Returning the kind lets a client refuse its own mistake at once,
|
|
2122
|
+
* instead of reporting it as the machine's.
|
|
2123
|
+
*/
|
|
2124
|
+
kind: import_zod11.z.enum(["datapoint", "action", "service", "publisher", "camera"]).nullable(),
|
|
2125
|
+
code: import_zod11.z.string().nullable(),
|
|
2126
|
+
message: import_zod11.z.string().nullable(),
|
|
2127
|
+
/**
|
|
2128
|
+
* The same payload the REST envelope carries in `apiError.details` — for
|
|
2129
|
+
* `parameter_invalid`, a `parameterInvalidDetails`.
|
|
2130
|
+
*
|
|
2131
|
+
* Added because it was missing, and its absence quietly broke §11.1: this
|
|
2132
|
+
* socket is supposed to do *everything* REST can do, but a
|
|
2133
|
+
* `parameter_invalid` arriving here had nowhere to put its violations, so
|
|
2134
|
+
* the same refusal was actionable over HTTP and opaque over the socket.
|
|
2135
|
+
* A client cannot bind an error to the input that caused it from a code
|
|
2136
|
+
* alone — which is the entire point of the flat parameter shape.
|
|
2137
|
+
*/
|
|
2138
|
+
details: import_zod11.z.unknown().optional()
|
|
2139
|
+
});
|
|
2140
|
+
var errorFrame = import_zod11.z.object({
|
|
2141
|
+
type: import_zod11.z.literal("error"),
|
|
2142
|
+
code: import_zod11.z.string().min(1),
|
|
2143
|
+
message: import_zod11.z.string().min(1)
|
|
2144
|
+
});
|
|
2145
|
+
var clientSubscribe = import_zod11.z.object({
|
|
2146
|
+
type: import_zod11.z.literal("subscribe"),
|
|
2147
|
+
robot_id: import_zod11.z.uuid(),
|
|
2148
|
+
slug,
|
|
2149
|
+
/**
|
|
2150
|
+
* What the subscriber expects, and how it wants it (W5).
|
|
2151
|
+
*
|
|
2152
|
+
* `kind` lets the server answer **`wrong_kind`** instead of accepting a
|
|
2153
|
+
* subscribe the client will then filter to silence — and silence is
|
|
2154
|
+
* indistinguishable from an idle slug, so it tells a developer nothing.
|
|
2155
|
+
* Optional, so an older client that omits it keeps today's behaviour.
|
|
2156
|
+
*
|
|
2157
|
+
* `options` is where a camera says what it wants; a datapoint needs none.
|
|
2158
|
+
* It exists now rather than later because adding a field to a frame three
|
|
2159
|
+
* repos parse is cheap once and expensive twice.
|
|
2160
|
+
*/
|
|
2161
|
+
/**
|
|
2162
|
+
* `publisher` is here even though a publisher is not subscribable: a client
|
|
2163
|
+
* that models the five grantable kinds and honestly names one gets the
|
|
2164
|
+
* informative `not_subscribable` the cloud already computes, instead of a
|
|
2165
|
+
* `validation_error` reciting an enum. Refusing the *word* rather than the
|
|
2166
|
+
* request was the same mistake as the silent wrong-verb subscribe this
|
|
2167
|
+
* field was added to fix.
|
|
2168
|
+
*/
|
|
2169
|
+
kind: import_zod11.z.enum(["datapoint", "action", "service", "publisher", "camera"]).optional(),
|
|
2170
|
+
options: import_zod11.z.record(import_zod11.z.string(), import_zod11.z.unknown()).optional()
|
|
2171
|
+
});
|
|
2172
|
+
var clientUnsubscribe = import_zod11.z.object({
|
|
2173
|
+
type: import_zod11.z.literal("unsubscribe"),
|
|
2174
|
+
robot_id: import_zod11.z.uuid(),
|
|
2175
|
+
slug
|
|
2176
|
+
});
|
|
2177
|
+
var subscribeError = import_zod11.z.object({
|
|
2178
|
+
type: import_zod11.z.literal("subscribe_error"),
|
|
2179
|
+
robot_id: import_zod11.z.string(),
|
|
2180
|
+
slug: import_zod11.z.string(),
|
|
2181
|
+
code: import_zod11.z.string().min(1),
|
|
2182
|
+
message: import_zod11.z.string().min(1)
|
|
2183
|
+
});
|
|
2184
|
+
var datapointEvent = import_zod11.z.object({
|
|
2185
|
+
type: import_zod11.z.literal("datapoint"),
|
|
2186
|
+
robot_id: import_zod11.z.uuid(),
|
|
2187
|
+
slug,
|
|
2188
|
+
value: import_zod11.z.unknown(),
|
|
2189
|
+
timestamp_ms: import_zod11.z.number().int().nonnegative()
|
|
2190
|
+
});
|
|
2191
|
+
var resourceHealthEvent = import_zod11.z.object({
|
|
2192
|
+
type: import_zod11.z.literal("resource_health"),
|
|
2193
|
+
robot_id: import_zod11.z.uuid(),
|
|
2194
|
+
kind: import_zod11.z.enum(["camera", "credential"]),
|
|
2195
|
+
ref: import_zod11.z.string().min(1).max(64),
|
|
2196
|
+
state: import_zod11.z.enum(RESOURCE_HEALTH_STATES),
|
|
2197
|
+
reason: import_zod11.z.string().max(200).nullable(),
|
|
2198
|
+
changed_at_ms: import_zod11.z.number().int().nonnegative()
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/identity.js
|
|
2202
|
+
var import_zod12 = require("zod");
|
|
2203
|
+
var password = import_zod12.z.string().min(12).max(256);
|
|
2204
|
+
var orgMemberRole = import_zod12.z.enum(["owner", "member"]);
|
|
2205
|
+
var org = import_zod12.z.object({
|
|
2206
|
+
id: import_zod12.z.uuid(),
|
|
2207
|
+
name: import_zod12.z.string().min(1).max(120),
|
|
2208
|
+
created_at: import_zod12.z.iso.datetime()
|
|
2209
|
+
});
|
|
2210
|
+
var orgMember = import_zod12.z.object({
|
|
2211
|
+
id: import_zod12.z.uuid(),
|
|
2212
|
+
org_id: import_zod12.z.uuid(),
|
|
2213
|
+
email: import_zod12.z.email(),
|
|
2214
|
+
role: orgMemberRole,
|
|
2215
|
+
created_at: import_zod12.z.iso.datetime()
|
|
2216
|
+
});
|
|
2217
|
+
var sessionTokens = import_zod12.z.object({
|
|
2218
|
+
access_token: import_zod12.z.string().min(1),
|
|
2219
|
+
refresh_token: import_zod12.z.string().min(1),
|
|
2220
|
+
expires_in: import_zod12.z.number().int().positive()
|
|
2221
|
+
});
|
|
2222
|
+
var refreshRequest = import_zod12.z.object({ refresh_token: import_zod12.z.string().min(1) });
|
|
2223
|
+
var signUpRequest = import_zod12.z.object({
|
|
2224
|
+
org_name: import_zod12.z.string().min(1).max(120),
|
|
2225
|
+
email: import_zod12.z.email(),
|
|
2226
|
+
password
|
|
2227
|
+
});
|
|
2228
|
+
var signUpResponse = import_zod12.z.object({
|
|
2229
|
+
org,
|
|
2230
|
+
member: orgMember,
|
|
2231
|
+
tokens: sessionTokens
|
|
2232
|
+
});
|
|
2233
|
+
var developerLoginRequest = import_zod12.z.object({
|
|
2234
|
+
email: import_zod12.z.email(),
|
|
2235
|
+
password: import_zod12.z.string().min(1)
|
|
2236
|
+
});
|
|
2237
|
+
var endUser = import_zod12.z.object({
|
|
2238
|
+
id: import_zod12.z.uuid(),
|
|
2239
|
+
org_id: import_zod12.z.uuid(),
|
|
2240
|
+
email: import_zod12.z.email(),
|
|
2241
|
+
status: import_zod12.z.enum(["invited", "active", "blocked"]),
|
|
2242
|
+
created_at: import_zod12.z.iso.datetime()
|
|
2243
|
+
});
|
|
2244
|
+
var mailStatus = import_zod12.z.enum(["sent", "not_configured", "failed"]);
|
|
2245
|
+
var invitation = import_zod12.z.object({
|
|
2246
|
+
id: import_zod12.z.uuid(),
|
|
2247
|
+
email: import_zod12.z.email(),
|
|
2248
|
+
app_id: import_zod12.z.uuid(),
|
|
2249
|
+
role_id: import_zod12.z.uuid(),
|
|
2250
|
+
expires_at: import_zod12.z.iso.datetime(),
|
|
2251
|
+
accept_url: import_zod12.z.url(),
|
|
2252
|
+
/** Replaces `mail_sent: boolean` — see `mailStatus` for why one bit was not enough. */
|
|
2253
|
+
mail: mailStatus
|
|
2254
|
+
});
|
|
2255
|
+
var createInvitationRequest = import_zod12.z.object({
|
|
2256
|
+
email: import_zod12.z.email(),
|
|
2257
|
+
app_id: import_zod12.z.uuid(),
|
|
2258
|
+
role_id: import_zod12.z.uuid(),
|
|
2259
|
+
send_mail: import_zod12.z.boolean()
|
|
2260
|
+
});
|
|
2261
|
+
var acceptInvitationRequest = import_zod12.z.object({
|
|
2262
|
+
token: import_zod12.z.string().min(1),
|
|
2263
|
+
password
|
|
2264
|
+
});
|
|
2265
|
+
var createDeveloperInvitationRequest = import_zod12.z.object({
|
|
2266
|
+
email: import_zod12.z.email(),
|
|
2267
|
+
role: orgMemberRole,
|
|
2268
|
+
send_mail: import_zod12.z.boolean()
|
|
2269
|
+
});
|
|
2270
|
+
var developerInvitation = import_zod12.z.object({
|
|
2271
|
+
id: import_zod12.z.uuid(),
|
|
2272
|
+
email: import_zod12.z.email(),
|
|
2273
|
+
role: orgMemberRole,
|
|
2274
|
+
expires_at: import_zod12.z.iso.datetime(),
|
|
2275
|
+
accept_url: import_zod12.z.url(),
|
|
2276
|
+
mail: mailStatus
|
|
2277
|
+
});
|
|
2278
|
+
var pendingDeveloperInvitation = import_zod12.z.object({
|
|
2279
|
+
id: import_zod12.z.uuid(),
|
|
2280
|
+
email: import_zod12.z.email(),
|
|
2281
|
+
role: orgMemberRole,
|
|
2282
|
+
expires_at: import_zod12.z.iso.datetime()
|
|
2283
|
+
});
|
|
2284
|
+
var developerInvitationListResponse = import_zod12.z.object({
|
|
2285
|
+
/** Pending only. An accepted invitation is history, not something to revoke. */
|
|
2286
|
+
invitations: import_zod12.z.array(pendingDeveloperInvitation)
|
|
2287
|
+
});
|
|
2288
|
+
var acceptDeveloperInvitationRequest = import_zod12.z.object({
|
|
2289
|
+
token: import_zod12.z.string().min(1),
|
|
2290
|
+
password
|
|
2291
|
+
});
|
|
2292
|
+
var tierRequiredDetails = import_zod12.z.object({
|
|
2293
|
+
required: orgMemberRole,
|
|
2294
|
+
/** The caller's own tier — theirs to know, and it is what makes the message actionable. */
|
|
2295
|
+
actual: orgMemberRole
|
|
2296
|
+
});
|
|
2297
|
+
var selfRegistration = import_zod12.z.object({
|
|
2298
|
+
enabled: import_zod12.z.boolean(),
|
|
2299
|
+
/**
|
|
2300
|
+
* Accept any address. Deliberately its own flag rather than a magic value
|
|
2301
|
+
* in `domains`, so "open to everyone" is something an app owner has to say,
|
|
2302
|
+
* not something that falls out of leaving a list empty.
|
|
2303
|
+
*/
|
|
2304
|
+
all_domains: import_zod12.z.boolean(),
|
|
2305
|
+
/** Lower-case bare domains, no `@`: `['dehne-robotik.de']`. Ignored when `all_domains`. */
|
|
2306
|
+
domains: import_zod12.z.array(import_zod12.z.string().min(1).max(253)),
|
|
2307
|
+
/** The role every self-registered member of this pool receives (§3.2: exactly one per app). */
|
|
2308
|
+
role_id: import_zod12.z.uuid()
|
|
2309
|
+
});
|
|
2310
|
+
var clientRegisterRequest = import_zod12.z.object({
|
|
2311
|
+
app_identifier: appIdentifier,
|
|
2312
|
+
email: import_zod12.z.email(),
|
|
2313
|
+
password
|
|
2314
|
+
});
|
|
2315
|
+
var clientRegisterResponse = import_zod12.z.object({
|
|
2316
|
+
mail: mailStatus
|
|
2317
|
+
});
|
|
2318
|
+
var clientRegisterConfirm = import_zod12.z.object({
|
|
2319
|
+
token: import_zod12.z.string().min(1)
|
|
2320
|
+
});
|
|
2321
|
+
var passwordChangeRequest = import_zod12.z.object({
|
|
2322
|
+
current_password: import_zod12.z.string().min(1),
|
|
2323
|
+
new_password: password
|
|
2324
|
+
});
|
|
2325
|
+
var passwordResetRequest = import_zod12.z.object({
|
|
2326
|
+
email: import_zod12.z.email()
|
|
2327
|
+
});
|
|
2328
|
+
var clientPasswordResetRequest = import_zod12.z.object({
|
|
2329
|
+
app_identifier: appIdentifier,
|
|
2330
|
+
email: import_zod12.z.email()
|
|
2331
|
+
});
|
|
2332
|
+
var passwordResetConfirm = import_zod12.z.object({
|
|
2333
|
+
token: import_zod12.z.string().min(1),
|
|
2334
|
+
new_password: password
|
|
2335
|
+
});
|
|
2336
|
+
var idpClaimMapping = import_zod12.z.object({
|
|
2337
|
+
/** Which claim is the stable identity. `sub` unless the developer knows better. */
|
|
2338
|
+
subject_claim: import_zod12.z.string().min(1).max(100).default("sub"),
|
|
2339
|
+
email_claim: import_zod12.z.string().min(1).max(100).default("email")
|
|
2340
|
+
});
|
|
2341
|
+
var idpIssuer = import_zod12.z.url().max(500).refine((v) => {
|
|
2342
|
+
let url;
|
|
2343
|
+
try {
|
|
2344
|
+
url = new URL(v);
|
|
2345
|
+
} catch {
|
|
2346
|
+
return false;
|
|
2347
|
+
}
|
|
2348
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
2349
|
+
return false;
|
|
2350
|
+
if (url.username !== "" || url.password !== "")
|
|
2351
|
+
return false;
|
|
2352
|
+
if (url.hash !== "" || url.search !== "")
|
|
2353
|
+
return false;
|
|
2354
|
+
return url.hostname.length > 0;
|
|
2355
|
+
}, { message: "issuer must be an http(s) URL with no credentials, query or fragment" });
|
|
2356
|
+
var idpConfig = import_zod12.z.object({
|
|
2357
|
+
app_id: import_zod12.z.uuid(),
|
|
2358
|
+
issuer: idpIssuer,
|
|
2359
|
+
client_id: import_zod12.z.string().min(1).max(200),
|
|
2360
|
+
scopes: import_zod12.z.array(import_zod12.z.string().min(1).max(60)).min(1).max(20),
|
|
2361
|
+
claims: idpClaimMapping,
|
|
2362
|
+
link_verified_emails: import_zod12.z.boolean(),
|
|
2363
|
+
/** Never the secret itself — see `idpConfigRequest`. */
|
|
2364
|
+
has_client_secret: import_zod12.z.boolean(),
|
|
2365
|
+
updated_at: import_zod12.z.iso.datetime()
|
|
2366
|
+
});
|
|
2367
|
+
var idpConfigRequest = import_zod12.z.object({
|
|
2368
|
+
issuer: idpIssuer,
|
|
2369
|
+
client_id: import_zod12.z.string().min(1).max(200),
|
|
2370
|
+
client_secret: import_zod12.z.string().min(1).max(500).optional(),
|
|
2371
|
+
scopes: import_zod12.z.array(import_zod12.z.string().min(1).max(60)).min(1).max(20),
|
|
2372
|
+
claims: idpClaimMapping.optional(),
|
|
2373
|
+
link_verified_emails: import_zod12.z.boolean()
|
|
2374
|
+
}).strict();
|
|
2375
|
+
|
|
2376
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/audit.js
|
|
2377
|
+
var import_zod13 = require("zod");
|
|
2378
|
+
var auditActor = import_zod13.z.object({
|
|
2379
|
+
kind: import_zod13.z.enum(["developer", "end_user", "server_key", "bridge"]),
|
|
2380
|
+
id: import_zod13.z.uuid(),
|
|
2381
|
+
label: import_zod13.z.string().min(1).max(200)
|
|
2382
|
+
});
|
|
2383
|
+
var auditEvent = import_zod13.z.object({
|
|
2384
|
+
id: import_zod13.z.uuid(),
|
|
2385
|
+
org_id: import_zod13.z.uuid(),
|
|
2386
|
+
at: import_zod13.z.iso.datetime(),
|
|
2387
|
+
/**
|
|
2388
|
+
* A monotonic counter, ascending in write order, unique across the log
|
|
2389
|
+
* (W6b).
|
|
2390
|
+
*
|
|
2391
|
+
* `at` is not a total order. Two events written in the same millisecond —
|
|
2392
|
+
* a login and the config publish it enables, a cascade writing several
|
|
2393
|
+
* rows — sort against each other arbitrarily, and "arbitrarily" means
|
|
2394
|
+
* *differently on each query*. A reader paging through "newest first" can
|
|
2395
|
+
* therefore see one of them twice and the other not at all, which is the
|
|
2396
|
+
* one failure mode an audit log may not have: a record that is present and
|
|
2397
|
+
* invisible.
|
|
2398
|
+
*
|
|
2399
|
+
* It is also the only correct **cursor** for paging this log, for the same
|
|
2400
|
+
* reason: a cursor that is not unique either skips rows or repeats them at
|
|
2401
|
+
* every page boundary. No cursor parameter exists on `GET /api/audit` yet —
|
|
2402
|
+
* the route returns the whole log — and that is stated here rather than
|
|
2403
|
+
* implied, because a contract that describes a capability the API does not
|
|
2404
|
+
* have is the defect this project keeps finding. When paging is added it
|
|
2405
|
+
* uses this field; nothing else in this shape can carry it.
|
|
2406
|
+
*
|
|
2407
|
+
* Required, not optional: an event without a sequence cannot be ordered
|
|
2408
|
+
* against one that has it, and a log with two orderings has none.
|
|
2409
|
+
*/
|
|
2410
|
+
seq: import_zod13.z.number().int().positive(),
|
|
2411
|
+
actor: auditActor,
|
|
2412
|
+
/** Stable dotted name, e.g. `end_user.invited`, `config.published`. */
|
|
2413
|
+
action: import_zod13.z.string().min(1).max(80),
|
|
2414
|
+
/**
|
|
2415
|
+
* What the action was about, if anything — a robot, an app, a user. Free
|
|
2416
|
+
* of ids the console cannot resolve: carry the label with it.
|
|
2417
|
+
*/
|
|
2418
|
+
target: import_zod13.z.object({
|
|
2419
|
+
kind: import_zod13.z.string().min(1).max(40),
|
|
2420
|
+
id: import_zod13.z.string().min(1),
|
|
2421
|
+
label: import_zod13.z.string().min(1).max(200)
|
|
2422
|
+
}).nullable(),
|
|
2423
|
+
/** Action-specific extras. Never credentials, never tokens. */
|
|
2424
|
+
details: import_zod13.z.record(import_zod13.z.string(), import_zod13.z.unknown()).nullable()
|
|
2425
|
+
});
|
|
2426
|
+
var auditListResponse = import_zod13.z.object({
|
|
2427
|
+
events: import_zod13.z.array(auditEvent)
|
|
2428
|
+
});
|
|
2429
|
+
|
|
2430
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/errors.js
|
|
2431
|
+
var import_zod14 = require("zod");
|
|
2432
|
+
var apiError = import_zod14.z.object({
|
|
2433
|
+
code: import_zod14.z.string().min(1),
|
|
2434
|
+
message: import_zod14.z.string().min(1),
|
|
2435
|
+
details: import_zod14.z.unknown().optional()
|
|
2436
|
+
});
|
|
2437
|
+
var parameterViolation = import_zod14.z.object({
|
|
2438
|
+
field: import_zod14.z.string().min(1),
|
|
2439
|
+
/** Which rule failed — `min`, `max`, `enum`, `pattern`, `required`, `undeclared`. */
|
|
2440
|
+
rule: import_zod14.z.string().min(1),
|
|
2441
|
+
message: import_zod14.z.string().min(1)
|
|
2442
|
+
});
|
|
2443
|
+
var parameterInvalidDetails = import_zod14.z.object({
|
|
2444
|
+
violations: import_zod14.z.array(parameterViolation).min(1)
|
|
2445
|
+
});
|
|
2446
|
+
|
|
2447
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_367ffc97d258be510904ed62d5d477a4/node_modules/@fleetless/contracts/dist/oauth.js
|
|
2448
|
+
var import_zod15 = require("zod");
|
|
2449
|
+
var oauthErrorCode = import_zod15.z.enum([
|
|
2450
|
+
"invalid_request",
|
|
2451
|
+
"invalid_client",
|
|
2452
|
+
"invalid_grant",
|
|
2453
|
+
"unauthorized_client",
|
|
2454
|
+
"unsupported_grant_type",
|
|
2455
|
+
"invalid_scope",
|
|
2456
|
+
"access_denied",
|
|
2457
|
+
"server_error",
|
|
2458
|
+
"temporarily_unavailable",
|
|
2459
|
+
/** RFC 8707: the `resource` named is not one this server issues tokens for. */
|
|
2460
|
+
"invalid_target"
|
|
2461
|
+
]);
|
|
2462
|
+
var oauthError = import_zod15.z.object({
|
|
2463
|
+
error: oauthErrorCode,
|
|
2464
|
+
error_description: import_zod15.z.string().min(1).max(500).optional(),
|
|
2465
|
+
/** Echoed back per RFC 6749 §4.1.2.1 so a client can match the response. */
|
|
2466
|
+
state: import_zod15.z.string().min(1).max(500).optional(),
|
|
2467
|
+
/**
|
|
2468
|
+
* **A Fleetless reason carried inside a standard envelope, and it exists
|
|
2469
|
+
* because the alternative lost a distinction.**
|
|
2470
|
+
*
|
|
2471
|
+
* Two policy refusals at `/oauth/register` — the app has not opted in, and
|
|
2472
|
+
* the app's client ceiling is full — both map to RFC 6749's `access_denied`,
|
|
2473
|
+
* which is the honest standard code for either. Answering with only that
|
|
2474
|
+
* makes the two indistinguishable to the caller, and *a field that cannot
|
|
2475
|
+
* express a distinction produces a workaround somewhere else*. Answering in
|
|
2476
|
+
* `apiError` instead would keep the distinction and hand an RFC-compliant
|
|
2477
|
+
* client a body it cannot parse — which is the conformance this wave exists
|
|
2478
|
+
* to provide.
|
|
2479
|
+
*
|
|
2480
|
+
* So both: `error` is what a standard client reads, `fleetless_code` is what
|
|
2481
|
+
* our own tooling switches on. RFC 6749 §5.2 permits additional members, and
|
|
2482
|
+
* a client that ignores this one still behaves correctly.
|
|
2483
|
+
*/
|
|
2484
|
+
fleetless_code: import_zod15.z.string().min(1).max(60).optional()
|
|
2485
|
+
});
|
|
2486
|
+
var oauthClientRegistration = import_zod15.z.enum(["developer", "dynamic"]);
|
|
2487
|
+
var redirectUri = import_zod15.z.string().min(1).max(2e3).refine((v) => {
|
|
2488
|
+
let url;
|
|
2489
|
+
try {
|
|
2490
|
+
url = new URL(v);
|
|
2491
|
+
} catch {
|
|
2492
|
+
return false;
|
|
2493
|
+
}
|
|
2494
|
+
if (url.hash !== "")
|
|
2495
|
+
return false;
|
|
2496
|
+
if (url.protocol === "https:")
|
|
2497
|
+
return url.hostname.length > 0;
|
|
2498
|
+
if (url.protocol === "http:")
|
|
2499
|
+
return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
2500
|
+
return false;
|
|
2501
|
+
}, { message: "redirect_uri must be an https URL, or http on an explicit loopback address, and carry no fragment" });
|
|
2502
|
+
var codeChallengeMethod = import_zod15.z.enum(["S256"]);
|
|
2503
|
+
var oauthClient = import_zod15.z.object({
|
|
2504
|
+
id: import_zod15.z.uuid(),
|
|
2505
|
+
app_id: import_zod15.z.uuid(),
|
|
2506
|
+
/** The `client_id` on the wire — opaque, and not the app identifier. */
|
|
2507
|
+
client_id: import_zod15.z.string().min(1).max(200),
|
|
2508
|
+
client_name: import_zod15.z.string().min(1).max(200),
|
|
2509
|
+
registration: oauthClientRegistration,
|
|
2510
|
+
redirect_uris: import_zod15.z.array(redirectUri).min(1).max(20),
|
|
2511
|
+
created_at: import_zod15.z.iso.datetime(),
|
|
2512
|
+
/**
|
|
2513
|
+
* **Only `dynamic` clients expire, and the field is nullable rather than
|
|
2514
|
+
* absent so a consumer must decide what it means.** A self-registered client
|
|
2515
|
+
* that never completes a flow is an unauthenticated write somebody left
|
|
2516
|
+
* behind; a developer's own client is a configured thing that should not
|
|
2517
|
+
* vanish under them.
|
|
2518
|
+
*/
|
|
2519
|
+
expires_at: import_zod15.z.iso.datetime().nullable(),
|
|
2520
|
+
last_used_at: import_zod15.z.iso.datetime().nullable()
|
|
2521
|
+
});
|
|
2522
|
+
var dynamicClientRegistrationRequest = import_zod15.z.object({
|
|
2523
|
+
client_name: import_zod15.z.string().min(1).max(200),
|
|
2524
|
+
redirect_uris: import_zod15.z.array(redirectUri).min(1).max(20),
|
|
2525
|
+
/** Accepted and echoed for conformance; this server issues only this pair. */
|
|
2526
|
+
grant_types: import_zod15.z.array(import_zod15.z.enum(["authorization_code", "refresh_token"])).optional(),
|
|
2527
|
+
response_types: import_zod15.z.array(import_zod15.z.enum(["code"])).optional(),
|
|
2528
|
+
/** RFC 7591 allows `none` for public clients; OAuth 2.1 + PKCE is the defence. */
|
|
2529
|
+
token_endpoint_auth_method: import_zod15.z.enum(["none"]).optional(),
|
|
2530
|
+
scope: import_zod15.z.string().max(500).optional()
|
|
2531
|
+
}).strict();
|
|
2532
|
+
var dynamicClientRegistrationResponse = import_zod15.z.object({
|
|
2533
|
+
client_id: import_zod15.z.string().min(1).max(200),
|
|
2534
|
+
client_name: import_zod15.z.string().min(1).max(200),
|
|
2535
|
+
redirect_uris: import_zod15.z.array(redirectUri),
|
|
2536
|
+
grant_types: import_zod15.z.array(import_zod15.z.string()),
|
|
2537
|
+
response_types: import_zod15.z.array(import_zod15.z.string()),
|
|
2538
|
+
token_endpoint_auth_method: import_zod15.z.literal("none"),
|
|
2539
|
+
client_id_issued_at: import_zod15.z.number().int().nonnegative(),
|
|
2540
|
+
/** Seconds since the epoch, per RFC 7591. `0` would mean "never expires". */
|
|
2541
|
+
client_secret_expires_at: import_zod15.z.literal(0)
|
|
2542
|
+
});
|
|
2543
|
+
var oauthTokenRequest = import_zod15.z.discriminatedUnion("grant_type", [
|
|
2544
|
+
import_zod15.z.object({
|
|
2545
|
+
grant_type: import_zod15.z.literal("authorization_code"),
|
|
2546
|
+
code: import_zod15.z.string().min(1).max(500),
|
|
2547
|
+
redirect_uri: redirectUri,
|
|
2548
|
+
client_id: import_zod15.z.string().min(1).max(200),
|
|
2549
|
+
code_verifier: import_zod15.z.string().regex(/^[A-Za-z0-9\-._~]{43,128}$/, "code_verifier must be 43-128 unreserved characters (RFC 7636 \xA74.1)"),
|
|
2550
|
+
resource: import_zod15.z.url().optional()
|
|
2551
|
+
}),
|
|
2552
|
+
import_zod15.z.object({
|
|
2553
|
+
grant_type: import_zod15.z.literal("refresh_token"),
|
|
2554
|
+
refresh_token: import_zod15.z.string().min(1).max(500),
|
|
2555
|
+
client_id: import_zod15.z.string().min(1).max(200),
|
|
2556
|
+
resource: import_zod15.z.url().optional(),
|
|
2557
|
+
/** RFC 6749 §6 — a refresh may narrow scope, never widen it. */
|
|
2558
|
+
scope: import_zod15.z.string().max(500).optional()
|
|
2559
|
+
})
|
|
2560
|
+
]);
|
|
2561
|
+
var oauthTokenResponse = import_zod15.z.object({
|
|
2562
|
+
access_token: import_zod15.z.string().min(1),
|
|
2563
|
+
token_type: import_zod15.z.literal("Bearer"),
|
|
2564
|
+
/** Seconds, per RFC 6749 §5.1 — not a timestamp, and not milliseconds. */
|
|
2565
|
+
expires_in: import_zod15.z.number().int().positive(),
|
|
2566
|
+
refresh_token: import_zod15.z.string().min(1).optional(),
|
|
2567
|
+
scope: import_zod15.z.string().max(500).optional()
|
|
2568
|
+
});
|
|
2569
|
+
var authorizationServerMetadata = import_zod15.z.object({
|
|
2570
|
+
issuer: import_zod15.z.url(),
|
|
2571
|
+
authorization_endpoint: import_zod15.z.url(),
|
|
2572
|
+
token_endpoint: import_zod15.z.url(),
|
|
2573
|
+
registration_endpoint: import_zod15.z.url().optional(),
|
|
2574
|
+
response_types_supported: import_zod15.z.array(import_zod15.z.literal("code")),
|
|
2575
|
+
grant_types_supported: import_zod15.z.array(import_zod15.z.enum(["authorization_code", "refresh_token"])),
|
|
2576
|
+
code_challenge_methods_supported: import_zod15.z.array(codeChallengeMethod),
|
|
2577
|
+
token_endpoint_auth_methods_supported: import_zod15.z.array(import_zod15.z.literal("none")),
|
|
2578
|
+
scopes_supported: import_zod15.z.array(import_zod15.z.string()).optional()
|
|
2579
|
+
});
|
|
2580
|
+
var protectedResourceMetadata = import_zod15.z.object({
|
|
2581
|
+
resource: import_zod15.z.url(),
|
|
2582
|
+
authorization_servers: import_zod15.z.array(import_zod15.z.url()).min(1),
|
|
2583
|
+
bearer_methods_supported: import_zod15.z.array(import_zod15.z.literal("header")),
|
|
2584
|
+
scopes_supported: import_zod15.z.array(import_zod15.z.string()).optional()
|
|
2585
|
+
});
|
|
2586
|
+
var consentGrant = import_zod15.z.object({
|
|
2587
|
+
client_id: import_zod15.z.string().min(1).max(200),
|
|
2588
|
+
app_id: import_zod15.z.uuid(),
|
|
2589
|
+
end_user_id: import_zod15.z.uuid(),
|
|
2590
|
+
role_id: import_zod15.z.uuid(),
|
|
2591
|
+
scope: import_zod15.z.string().max(500),
|
|
2592
|
+
granted_at: import_zod15.z.iso.datetime()
|
|
2593
|
+
});
|
|
2594
|
+
var oauthInteraction = import_zod15.z.object({
|
|
2595
|
+
interaction_id: import_zod15.z.string().min(1).max(200),
|
|
2596
|
+
app_name: import_zod15.z.string().min(1).max(120),
|
|
2597
|
+
idp: import_zod15.z.object({ button_label: import_zod15.z.string().min(1).max(60) }).nullable()
|
|
2598
|
+
});
|
|
2599
|
+
var oauthConsentInteraction = import_zod15.z.object({
|
|
2600
|
+
interaction_id: import_zod15.z.string().min(1).max(200),
|
|
2601
|
+
app_name: import_zod15.z.string().min(1).max(120),
|
|
2602
|
+
client_name: import_zod15.z.string().min(1).max(200),
|
|
2603
|
+
registration: oauthClientRegistration,
|
|
2604
|
+
role_name: import_zod15.z.string().min(1).max(120),
|
|
2605
|
+
scope: import_zod15.z.string().max(500)
|
|
2606
|
+
});
|
|
2607
|
+
var oauthLoginRequest = import_zod15.z.object({
|
|
2608
|
+
interaction_id: import_zod15.z.string().min(1).max(200),
|
|
2609
|
+
email: import_zod15.z.email(),
|
|
2610
|
+
password: import_zod15.z.string().min(1)
|
|
2611
|
+
}).strict();
|
|
2612
|
+
var oauthRedirectResponse = import_zod15.z.object({
|
|
2613
|
+
redirect_to: import_zod15.z.string().min(1).max(2e3)
|
|
2614
|
+
});
|
|
2615
|
+
var consentDecision = import_zod15.z.object({
|
|
2616
|
+
/** The opaque handle the authorize step handed the consent screen. */
|
|
2617
|
+
interaction_id: import_zod15.z.string().min(1).max(200),
|
|
2618
|
+
approved: import_zod15.z.boolean()
|
|
2619
|
+
});
|
|
2620
|
+
var OAUTH_PATHS = {
|
|
2621
|
+
authorizationServerMetadata: "/.well-known/oauth-authorization-server",
|
|
2622
|
+
protectedResourceMetadata: "/.well-known/oauth-protected-resource",
|
|
2623
|
+
authorize: "/oauth/authorize",
|
|
2624
|
+
token: "/oauth/token",
|
|
2625
|
+
register: "/oauth/register",
|
|
2626
|
+
/**
|
|
2627
|
+
* **One path, both verbs** — `GET` serves the consent page, `POST` accepts a
|
|
2628
|
+
* `consentDecision`. Decided 2026-08-18 rather than left to be inferred:
|
|
2629
|
+
* Eve-W7b asked whether the page's own GET route was this path or another,
|
|
2630
|
+
* which is the right question and had no answer anywhere.
|
|
2631
|
+
*
|
|
2632
|
+
* One entry means the console and the cloud cannot drift apart on it, which
|
|
2633
|
+
* is the failure W7a paid for when five hand-written copies of `assetKind`
|
|
2634
|
+
* and a header name crossed the TypeScript/Python line. Note that the two
|
|
2635
|
+
* verbs answer differently: the page is HTML, and a failed `POST` answers
|
|
2636
|
+
* `apiError` — see the dialect note at the top of this file, which names
|
|
2637
|
+
* this path as the exception the prefix will mislead you about.
|
|
2638
|
+
*/
|
|
2639
|
+
consent: "/oauth/consent",
|
|
2640
|
+
/**
|
|
2641
|
+
* The two federation legs. **Server-owned redirect targets a client never
|
|
2642
|
+
* constructs** — the same category as `authorize`, `token` and `register`,
|
|
2643
|
+
* and the reason they belong here rather than as literals.
|
|
2644
|
+
*
|
|
2645
|
+
* Kassandra-W7b found `/oauth/idp-start` written as a literal in
|
|
2646
|
+
* `cloud/src/routes/oauth-federation.ts` **and** in
|
|
2647
|
+
* `console/oauth-pages/login/src/App.vue` — two repos agreeing on a string
|
|
2648
|
+
* with nothing shared between them. Worse than the `/idp` versus
|
|
2649
|
+
* `/idp-config` mismatch this wave already met, because the console half
|
|
2650
|
+
* ships as a **committed artifact**: the drift would survive a re-pin and an
|
|
2651
|
+
* install, and the symptom is a federation button that navigates to a 404,
|
|
2652
|
+
* invisible to both suites.
|
|
2653
|
+
*
|
|
2654
|
+
* `OAUTH_PATHS` was created in this wave with a doc comment citing W7a's
|
|
2655
|
+
* five hand-written copies of `assetKind`. The rule was applied to `login`
|
|
2656
|
+
* and `consent` and stopped there.
|
|
2657
|
+
*/
|
|
2658
|
+
idpStart: "/oauth/idp-start",
|
|
2659
|
+
idpCallback: "/oauth/idp-callback",
|
|
2660
|
+
/**
|
|
2661
|
+
* The console-built page the cloud serves from its own origin (see §3.4) —
|
|
2662
|
+
* and, like `consent` above, **one path with both verbs**: `GET` serves the
|
|
2663
|
+
* page, `POST` accepts an `oauthLoginRequest` and answers an
|
|
2664
|
+
* `oauthLoginResponse`.
|
|
2665
|
+
*
|
|
2666
|
+
* Decided 2026-08-18. Nimbus-W7b proposed a separate `POST /oauth/login`,
|
|
2667
|
+
* which would work; one path is chosen for the same reason `consent` has
|
|
2668
|
+
* one — the page posts to its own URL, so there is no second string for two
|
|
2669
|
+
* repos to disagree about, and this wave has already produced one live
|
|
2670
|
+
* mismatch of exactly that kind (`/idp` versus `/idp-config`, caught by
|
|
2671
|
+
* comparing repos rather than by either suite).
|
|
2672
|
+
*
|
|
2673
|
+
* A failed `POST` answers `apiError`, not `oauthError`: the caller is our own
|
|
2674
|
+
* page, not a standard client. See the dialect note at the top of this file.
|
|
2675
|
+
*/
|
|
2676
|
+
login: "/login"
|
|
2677
|
+
};
|
|
2678
|
+
|
|
2679
|
+
// src/pkce.ts
|
|
2680
|
+
function requireCrypto() {
|
|
2681
|
+
const c = globalThis.crypto;
|
|
2682
|
+
if (!c?.subtle) {
|
|
2683
|
+
throw new Error(
|
|
2684
|
+
"auth.beginHostedLogin/completeHostedLogin need the Web Crypto API (globalThis.crypto.subtle), which is not available in this environment."
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
return c;
|
|
2688
|
+
}
|
|
2689
|
+
function toBase64Url(bytes) {
|
|
2690
|
+
let binary = "";
|
|
2691
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
2692
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2693
|
+
}
|
|
2694
|
+
function randomBase64Url(byteLength) {
|
|
2695
|
+
const bytes = new Uint8Array(byteLength);
|
|
2696
|
+
requireCrypto().getRandomValues(bytes);
|
|
2697
|
+
return toBase64Url(bytes);
|
|
2698
|
+
}
|
|
2699
|
+
function generateCodeVerifier() {
|
|
2700
|
+
return randomBase64Url(32);
|
|
2701
|
+
}
|
|
2702
|
+
function generateState() {
|
|
2703
|
+
return randomBase64Url(32);
|
|
2704
|
+
}
|
|
2705
|
+
async function computeCodeChallenge(codeVerifier) {
|
|
2706
|
+
const digest = await requireCrypto().subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
2707
|
+
return toBase64Url(new Uint8Array(digest));
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// src/auth.ts
|
|
2711
|
+
var SessionCredentials = class {
|
|
2712
|
+
constructor(http, tokenStore) {
|
|
2713
|
+
this.http = http;
|
|
2714
|
+
this.tokenStore = tokenStore;
|
|
2715
|
+
}
|
|
2716
|
+
http;
|
|
2717
|
+
tokenStore;
|
|
2718
|
+
#refreshing = null;
|
|
2719
|
+
async token() {
|
|
2720
|
+
const session = await this.tokenStore.load();
|
|
2721
|
+
return session ? session.access_token : null;
|
|
2722
|
+
}
|
|
2723
|
+
async handleExpired() {
|
|
2724
|
+
await this.#ensureRefreshed();
|
|
2725
|
+
return true;
|
|
2726
|
+
}
|
|
2727
|
+
/** Single-flight — a refresh already in progress is awaited, never duplicated. */
|
|
2728
|
+
#ensureRefreshed() {
|
|
2729
|
+
if (this.#refreshing) return this.#refreshing;
|
|
2730
|
+
const attempt = this.#refresh().finally(() => {
|
|
2731
|
+
this.#refreshing = null;
|
|
2732
|
+
});
|
|
2733
|
+
this.#refreshing = attempt;
|
|
2734
|
+
return attempt;
|
|
2735
|
+
}
|
|
2736
|
+
async #refresh() {
|
|
2737
|
+
const current = await this.tokenStore.load();
|
|
2738
|
+
if (!current) throw new FleetlessError("no_session", "No session to refresh.");
|
|
2739
|
+
const body = { refresh_token: current.refresh_token };
|
|
2740
|
+
const tokens = await this.http.request("/api/client/refresh", {
|
|
2741
|
+
method: "POST",
|
|
2742
|
+
skipAuth: true,
|
|
2743
|
+
body
|
|
2744
|
+
});
|
|
2745
|
+
await this.tokenStore.save(tokens);
|
|
2746
|
+
return tokens;
|
|
2747
|
+
}
|
|
2748
|
+
};
|
|
2749
|
+
var ServerKeyCredentials = class {
|
|
2750
|
+
constructor(serverKey2) {
|
|
2751
|
+
this.serverKey = serverKey2;
|
|
2752
|
+
}
|
|
2753
|
+
serverKey;
|
|
2754
|
+
async token() {
|
|
2755
|
+
return this.serverKey;
|
|
2756
|
+
}
|
|
2757
|
+
async handleExpired() {
|
|
2758
|
+
return false;
|
|
2759
|
+
}
|
|
2760
|
+
};
|
|
2761
|
+
function createPasswordResetMethods(http, appIdentifier2) {
|
|
2762
|
+
return {
|
|
2763
|
+
async requestPasswordReset(email) {
|
|
2764
|
+
const body = { app_identifier: appIdentifier2, email };
|
|
2765
|
+
await http.request("/api/client/password/reset", { method: "POST", skipAuth: true, body, expectEmptyBody: true });
|
|
2766
|
+
},
|
|
2767
|
+
async confirmPasswordReset(token, newPassword) {
|
|
2768
|
+
const body = { token, new_password: newPassword };
|
|
2769
|
+
await http.request("/api/client/password/reset/confirm", { method: "POST", skipAuth: true, body, expectEmptyBody: true });
|
|
2770
|
+
}
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
function createSessionAuth(http, tokenStore, appIdentifier2) {
|
|
2774
|
+
return {
|
|
2775
|
+
async login(email, password2) {
|
|
2776
|
+
const body = { app_identifier: appIdentifier2, email, password: password2 };
|
|
2777
|
+
const tokens = await http.request("/api/client/login", { method: "POST", skipAuth: true, body });
|
|
2778
|
+
await tokenStore.save(tokens);
|
|
2779
|
+
},
|
|
2780
|
+
async beginHostedLogin(options) {
|
|
2781
|
+
const state = generateState();
|
|
2782
|
+
const codeVerifier = generateCodeVerifier();
|
|
2783
|
+
const codeChallenge = await computeCodeChallenge(codeVerifier);
|
|
2784
|
+
const url = new URL(`${http.baseUrl}${OAUTH_PATHS.authorize}`);
|
|
2785
|
+
url.searchParams.set("response_type", "code");
|
|
2786
|
+
url.searchParams.set("client_id", options.clientId);
|
|
2787
|
+
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
2788
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
2789
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
2790
|
+
url.searchParams.set("state", state);
|
|
2791
|
+
if (options.scope !== void 0) url.searchParams.set("scope", options.scope);
|
|
2792
|
+
if (options.resource !== void 0) url.searchParams.set("resource", options.resource);
|
|
2793
|
+
return { url: url.toString(), state, codeVerifier };
|
|
2794
|
+
},
|
|
2795
|
+
async completeHostedLogin(options) {
|
|
2796
|
+
if (!options.expectedState) {
|
|
2797
|
+
throw new FleetlessError(
|
|
2798
|
+
"no_hosted_login_attempt",
|
|
2799
|
+
"completeHostedLogin: expectedState is empty \u2014 nothing was persisted for this attempt. This usually means the callback landed in a different tab or window than the one that called beginHostedLogin, the session was restored, or storage was cleared in between \u2014 not necessarily an attack. Check what beginHostedLogin returned and how your app persisted it."
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
if (!options.state || options.state !== options.expectedState) {
|
|
2803
|
+
throw new FleetlessError(
|
|
2804
|
+
"state_mismatch",
|
|
2805
|
+
"completeHostedLogin: the redirect's state does not match the state beginHostedLogin generated for this attempt \u2014 refusing to complete a login this client did not start (RFC 6749 \xA710.12)."
|
|
2806
|
+
);
|
|
2807
|
+
}
|
|
2808
|
+
const raw = await http.requestOAuth(OAUTH_PATHS.token, {
|
|
2809
|
+
grant_type: "authorization_code",
|
|
2810
|
+
code: options.code,
|
|
2811
|
+
redirect_uri: options.redirectUri,
|
|
2812
|
+
client_id: options.clientId,
|
|
2813
|
+
code_verifier: options.codeVerifier,
|
|
2814
|
+
...options.resource !== void 0 ? { resource: options.resource } : {}
|
|
2815
|
+
});
|
|
2816
|
+
if (raw.refresh_token === void 0) {
|
|
2817
|
+
throw new FleetlessError(
|
|
2818
|
+
"unexpected_response",
|
|
2819
|
+
"POST /oauth/token answered ok but did not include a refresh_token; a hosted-login session cannot silently refresh without one."
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2822
|
+
const tokens = { access_token: raw.access_token, refresh_token: raw.refresh_token, expires_in: raw.expires_in };
|
|
2823
|
+
await tokenStore.save(tokens);
|
|
2824
|
+
},
|
|
2825
|
+
async logout() {
|
|
2826
|
+
const session = await tokenStore.load();
|
|
2827
|
+
let revoked = true;
|
|
2828
|
+
if (session) {
|
|
2829
|
+
const body = { refresh_token: session.refresh_token };
|
|
2830
|
+
revoked = await http.request("/api/client/logout", { method: "POST", skipAuth: true, body, expectEmptyBody: true }).then(() => true).catch(() => false);
|
|
2831
|
+
}
|
|
2832
|
+
await tokenStore.save(null);
|
|
2833
|
+
return { revoked };
|
|
2834
|
+
},
|
|
2835
|
+
async me() {
|
|
2836
|
+
return http.request("/api/client/me", {});
|
|
2837
|
+
},
|
|
2838
|
+
async register(email, password2) {
|
|
2839
|
+
const body = { app_identifier: appIdentifier2, email, password: password2 };
|
|
2840
|
+
return http.request("/api/client/register", { method: "POST", skipAuth: true, body });
|
|
2841
|
+
},
|
|
2842
|
+
async confirmRegistration(token) {
|
|
2843
|
+
const body = { token };
|
|
2844
|
+
const tokens = await http.request("/api/client/register/confirm", { method: "POST", skipAuth: true, body });
|
|
2845
|
+
await tokenStore.save(tokens);
|
|
2846
|
+
},
|
|
2847
|
+
async changePassword(currentPassword, newPassword) {
|
|
2848
|
+
const body = { current_password: currentPassword, new_password: newPassword };
|
|
2849
|
+
const tokens = await http.request("/api/client/password/change", { method: "POST", body });
|
|
2850
|
+
await tokenStore.save(tokens);
|
|
2851
|
+
},
|
|
2852
|
+
...createPasswordResetMethods(http, appIdentifier2)
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
function createServerKeyAuth(http, appIdentifier2) {
|
|
2856
|
+
return {
|
|
2857
|
+
async login() {
|
|
2858
|
+
throw new Error("auth.login is not available on a client constructed with a serverKey.");
|
|
2859
|
+
},
|
|
2860
|
+
async beginHostedLogin() {
|
|
2861
|
+
throw new Error("auth.beginHostedLogin is not available on a client constructed with a serverKey.");
|
|
2862
|
+
},
|
|
2863
|
+
async completeHostedLogin() {
|
|
2864
|
+
throw new Error("auth.completeHostedLogin is not available on a client constructed with a serverKey.");
|
|
2865
|
+
},
|
|
2866
|
+
async logout() {
|
|
2867
|
+
throw new Error("auth.logout is not available on a client constructed with a serverKey.");
|
|
2868
|
+
},
|
|
2869
|
+
async me() {
|
|
2870
|
+
return http.request("/api/client/me", {});
|
|
2871
|
+
},
|
|
2872
|
+
async register() {
|
|
2873
|
+
throw new Error("auth.register is not available on a client constructed with a serverKey.");
|
|
2874
|
+
},
|
|
2875
|
+
async confirmRegistration() {
|
|
2876
|
+
throw new Error("auth.confirmRegistration is not available on a client constructed with a serverKey.");
|
|
2877
|
+
},
|
|
2878
|
+
async changePassword() {
|
|
2879
|
+
throw new Error("auth.changePassword is not available on a client constructed with a serverKey.");
|
|
2880
|
+
},
|
|
2881
|
+
...createPasswordResetMethods(http, appIdentifier2)
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
// src/cameras.ts
|
|
2886
|
+
var EMPTY_SNAPSHOT_META = { mime: null, width: null, height: null, timestamp_ms: null, age_ms: null };
|
|
2887
|
+
function headerNumber(headers, name) {
|
|
2888
|
+
const raw = headers.get(name);
|
|
2889
|
+
if (raw === null) return null;
|
|
2890
|
+
const value = Number(raw);
|
|
2891
|
+
return Number.isFinite(value) ? value : null;
|
|
2892
|
+
}
|
|
2893
|
+
function createCamerasApi(http) {
|
|
2894
|
+
return {
|
|
2895
|
+
async list(robotId) {
|
|
2896
|
+
const response = await http.request(`/api/robots/${pathSegment(robotId)}/cameras`, {});
|
|
2897
|
+
return response.cameras;
|
|
2898
|
+
},
|
|
2899
|
+
async snapshot(robotId, slug2) {
|
|
2900
|
+
try {
|
|
2901
|
+
const { body, headers } = await http.requestBinary(`/api/robots/${pathSegment(robotId)}/cameras/${pathSegment(slug2)}/snapshot`);
|
|
2902
|
+
return {
|
|
2903
|
+
image: body,
|
|
2904
|
+
mime: mimeFromContentType(headers),
|
|
2905
|
+
width: headerNumber(headers, SNAPSHOT_HEADERS.width),
|
|
2906
|
+
height: headerNumber(headers, SNAPSHOT_HEADERS.height),
|
|
2907
|
+
timestamp_ms: headerNumber(headers, SNAPSHOT_HEADERS.timestampMs),
|
|
2908
|
+
age_ms: headerNumber(headers, SNAPSHOT_HEADERS.ageMs)
|
|
2909
|
+
};
|
|
2910
|
+
} catch (error) {
|
|
2911
|
+
if (error instanceof FleetlessError && error.code === "no_snapshot_yet") return { image: null, ...EMPTY_SNAPSHOT_META };
|
|
2912
|
+
throw error;
|
|
2913
|
+
}
|
|
2914
|
+
},
|
|
2915
|
+
async snapshotMeta(robotId, slug2) {
|
|
2916
|
+
try {
|
|
2917
|
+
const response = await http.request(`/api/robots/${pathSegment(robotId)}/cameras/${pathSegment(slug2)}/snapshot/meta`, {});
|
|
2918
|
+
return {
|
|
2919
|
+
mime: response.mime,
|
|
2920
|
+
width: response.width,
|
|
2921
|
+
height: response.height,
|
|
2922
|
+
timestamp_ms: response.timestamp_ms,
|
|
2923
|
+
age_ms: response.age_ms
|
|
2924
|
+
};
|
|
2925
|
+
} catch (error) {
|
|
2926
|
+
if (error instanceof FleetlessError && error.code === "no_snapshot_yet") return { ...EMPTY_SNAPSHOT_META };
|
|
2927
|
+
throw error;
|
|
2928
|
+
}
|
|
2929
|
+
},
|
|
2930
|
+
async live(robotId, slug2) {
|
|
2931
|
+
const response = await http.request(`/api/robots/${pathSegment(robotId)}/cameras/${pathSegment(slug2)}/live`, {
|
|
2932
|
+
method: "POST"
|
|
2933
|
+
});
|
|
2934
|
+
const releasePath = `/api/robots/${pathSegment(robotId)}/cameras/${pathSegment(slug2)}/live?${new URLSearchParams({ session_id: response.session_id })}`;
|
|
2935
|
+
let releasePromise = null;
|
|
2936
|
+
const release = () => {
|
|
2937
|
+
if (!releasePromise) {
|
|
2938
|
+
releasePromise = http.request(releasePath, { method: "DELETE", expectEmptyBody: true }).then(() => void 0).catch(() => void 0);
|
|
2939
|
+
}
|
|
2940
|
+
return releasePromise;
|
|
2941
|
+
};
|
|
2942
|
+
return {
|
|
2943
|
+
session_id: response.session_id,
|
|
2944
|
+
url: response.url,
|
|
2945
|
+
room: response.room,
|
|
2946
|
+
token: response.token,
|
|
2947
|
+
expires_at: response.expires_at,
|
|
2948
|
+
release
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
// src/commands.ts
|
|
2955
|
+
function generateRequestId() {
|
|
2956
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
2957
|
+
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
2958
|
+
}
|
|
2959
|
+
var DEFAULT_COMMAND_TIMEOUT_MS = 1e4;
|
|
2960
|
+
var LOCAL_WAIT_MARGIN_MS = 5e3;
|
|
2961
|
+
function resolveLocalWaitMs(options, defaultMs) {
|
|
2962
|
+
const { timeoutMs, patienceMs } = options;
|
|
2963
|
+
if (timeoutMs !== void 0 && patienceMs !== void 0 && timeoutMs < patienceMs) {
|
|
2964
|
+
throw new FleetlessError(
|
|
2965
|
+
"invalid_option",
|
|
2966
|
+
`timeoutMs (${timeoutMs}ms) is shorter than patienceMs (${patienceMs}ms) \u2014 the SDK would give up locally before the platform's own patience runs out, and report command_timeout for a call the platform never actually refused. Raise timeoutMs above patienceMs, or omit timeoutMs to have it derived automatically.`
|
|
2967
|
+
);
|
|
2968
|
+
}
|
|
2969
|
+
if (timeoutMs !== void 0) return timeoutMs;
|
|
2970
|
+
if (patienceMs !== void 0) return patienceMs + LOCAL_WAIT_MARGIN_MS;
|
|
2971
|
+
return defaultMs;
|
|
2972
|
+
}
|
|
2973
|
+
function sendCommand(channel, frame, options = {}) {
|
|
2974
|
+
channel.connect();
|
|
2975
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
2976
|
+
return new Promise((resolve, reject) => {
|
|
2977
|
+
let settled = false;
|
|
2978
|
+
const cleanups = [];
|
|
2979
|
+
const cleanup = () => {
|
|
2980
|
+
for (const fn of cleanups) fn();
|
|
2981
|
+
};
|
|
2982
|
+
const settle = (fn) => {
|
|
2983
|
+
if (settled) return;
|
|
2984
|
+
settled = true;
|
|
2985
|
+
cleanup();
|
|
2986
|
+
fn();
|
|
2987
|
+
};
|
|
2988
|
+
const timer = setTimeout(() => {
|
|
2989
|
+
settle(
|
|
2990
|
+
() => reject(
|
|
2991
|
+
new FleetlessError(
|
|
2992
|
+
"command_timeout",
|
|
2993
|
+
`Command '${frame.type}' (request_id=${frame.request_id}) timed out after ${timeoutMs}ms without a reply.`
|
|
2994
|
+
)
|
|
2995
|
+
)
|
|
2996
|
+
);
|
|
2997
|
+
}, timeoutMs);
|
|
2998
|
+
cleanups.push(() => clearTimeout(timer));
|
|
2999
|
+
cleanups.push(
|
|
3000
|
+
channel.on("command_result", (raw) => {
|
|
3001
|
+
const result = raw;
|
|
3002
|
+
if (result.request_id !== frame.request_id) return;
|
|
3003
|
+
settle(() => {
|
|
3004
|
+
if (result.ok) {
|
|
3005
|
+
resolve(result);
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
const code = result.code ?? "command_failed";
|
|
3009
|
+
const details = code === "busy" && result.job ? { running: result.job } : result.details;
|
|
3010
|
+
reject(new FleetlessError(code, result.message ?? "The command was refused.", details !== void 0 ? { details } : void 0));
|
|
3011
|
+
});
|
|
3012
|
+
})
|
|
3013
|
+
);
|
|
3014
|
+
cleanups.push(
|
|
3015
|
+
channel.onAuthFailed((error) => {
|
|
3016
|
+
settle(() => reject(error));
|
|
3017
|
+
})
|
|
3018
|
+
);
|
|
3019
|
+
const watchForDeadConnection = (epochAtSend) => {
|
|
3020
|
+
cleanups.push(
|
|
3021
|
+
channel.onReady(() => {
|
|
3022
|
+
if (channel.connectionEpoch === epochAtSend) return;
|
|
3023
|
+
settle(
|
|
3024
|
+
() => reject(
|
|
3025
|
+
new FleetlessError(
|
|
3026
|
+
"command_outcome_unknown",
|
|
3027
|
+
`The realtime connection was re-established before a reply to '${frame.type}' (request_id=${frame.request_id}) arrived. The command may or may not have run \u2014 read the job by slug (e.g. actions.subscribe) rather than retrying blindly.`
|
|
3028
|
+
)
|
|
3029
|
+
)
|
|
3030
|
+
);
|
|
3031
|
+
})
|
|
3032
|
+
);
|
|
3033
|
+
};
|
|
3034
|
+
if (channel.isReady) {
|
|
3035
|
+
const epoch = channel.connectionEpoch;
|
|
3036
|
+
channel.send(frame);
|
|
3037
|
+
watchForDeadConnection(epoch);
|
|
3038
|
+
} else {
|
|
3039
|
+
const unlistenReady = channel.onReady(() => {
|
|
3040
|
+
unlistenReady();
|
|
3041
|
+
const epoch = channel.connectionEpoch;
|
|
3042
|
+
channel.send(frame);
|
|
3043
|
+
watchForDeadConnection(epoch);
|
|
3044
|
+
});
|
|
3045
|
+
cleanups.push(unlistenReady);
|
|
3046
|
+
}
|
|
3047
|
+
});
|
|
3048
|
+
}
|
|
3049
|
+
function assertValidJobId(jobId) {
|
|
3050
|
+
if (jobId === void 0 || jobId === null || typeof jobId === "string") return;
|
|
3051
|
+
throw new FleetlessError(
|
|
3052
|
+
"invalid_option",
|
|
3053
|
+
`cancel()'s third argument must be a job id (string), null, or omitted \u2014 got ${typeof jobId === "object" ? "an object" : typeof jobId}. If you are passing an options object (e.g. {timeoutMs}) as the third argument, note the signature changed in this release: cancel(robotId, slug) is unchanged, but a third positional argument is now the job id to cancel and options moved to a fourth argument \u2014 cancel(robotId, slug, jobId, options). See the README's Actions section.`
|
|
3054
|
+
);
|
|
3055
|
+
}
|
|
3056
|
+
function createRealtimeCommandTransport(channel) {
|
|
3057
|
+
return {
|
|
3058
|
+
// Declared `async` deliberately, unlike `cancel`/`publish` below: it is
|
|
3059
|
+
// the only one of the three that can refuse *before* sending anything
|
|
3060
|
+
// (resolveLocalWaitMs's `invalid_option`, D3a), and every caller of
|
|
3061
|
+
// this interface — starting with this file's own `sendCommand` callers
|
|
3062
|
+
// — is entitled to assume `CommandTransport.invoke` always returns a
|
|
3063
|
+
// promise rather than throwing synchronously. Without `async` here, a
|
|
3064
|
+
// synchronous throw from `resolveLocalWaitMs` would escape as a thrown
|
|
3065
|
+
// exception instead of a rejection, breaking that assumption for any
|
|
3066
|
+
// caller that isn't itself inside an `async` function (e.g. a test
|
|
3067
|
+
// calling this transport directly).
|
|
3068
|
+
async invoke(robotId, slug2, params, options) {
|
|
3069
|
+
const timeoutMs = resolveLocalWaitMs(options ?? {}, DEFAULT_COMMAND_TIMEOUT_MS);
|
|
3070
|
+
const frame = {
|
|
3071
|
+
type: "invoke",
|
|
3072
|
+
request_id: generateRequestId(),
|
|
3073
|
+
robot_id: robotId,
|
|
3074
|
+
slug: slug2,
|
|
3075
|
+
params,
|
|
3076
|
+
patience_ms: options?.patienceMs
|
|
3077
|
+
};
|
|
3078
|
+
return sendCommand(channel, frame, { timeoutMs });
|
|
3079
|
+
},
|
|
3080
|
+
// Declared `async` for the same reason `invoke` above is (D3a, D6):
|
|
3081
|
+
// `assertValidJobId` can throw before a frame is ever built, and a
|
|
3082
|
+
// caller of this interface is entitled to a rejected promise, never a
|
|
3083
|
+
// thrown exception.
|
|
3084
|
+
async cancel(robotId, slug2, jobId, options) {
|
|
3085
|
+
assertValidJobId(jobId);
|
|
3086
|
+
const frame = { type: "cancel", request_id: generateRequestId(), robot_id: robotId, slug: slug2, job_id: jobId ?? null };
|
|
3087
|
+
return sendCommand(channel, frame, options);
|
|
3088
|
+
},
|
|
3089
|
+
publish(robotId, slug2, message, options) {
|
|
3090
|
+
const frame = { type: "publish", request_id: generateRequestId(), robot_id: robotId, slug: slug2, message };
|
|
3091
|
+
return sendCommand(channel, frame, options);
|
|
3092
|
+
}
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
// src/datapoints.ts
|
|
3097
|
+
function keyOf(robotId, slug2) {
|
|
3098
|
+
return `${robotId} ${slug2}`;
|
|
3099
|
+
}
|
|
3100
|
+
function createKeyState(channel, slugSubscriptions, robotId, slug2) {
|
|
3101
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
3102
|
+
const unlistenEvent = channel.on("datapoint", (frame) => {
|
|
3103
|
+
const event = frame;
|
|
3104
|
+
if (event.robot_id !== robotId || event.slug !== slug2) return;
|
|
3105
|
+
for (const handler of handlers) handler.onEvent(event);
|
|
3106
|
+
});
|
|
3107
|
+
const slugHandle = slugSubscriptions.acquire(robotId, slug2, {
|
|
3108
|
+
onSubscribeError(error) {
|
|
3109
|
+
for (const handler of handlers) handler.onError?.(error);
|
|
3110
|
+
},
|
|
3111
|
+
onAuthFailed(error) {
|
|
3112
|
+
for (const handler of handlers) handler.onError?.(error);
|
|
3113
|
+
}
|
|
3114
|
+
});
|
|
3115
|
+
return { handlers, unlistenEvent, slugHandle };
|
|
3116
|
+
}
|
|
3117
|
+
function historyQueryString(options) {
|
|
3118
|
+
const params = new URLSearchParams();
|
|
3119
|
+
params.set("from", options.from);
|
|
3120
|
+
if (options.to !== void 0) params.set("to", options.to);
|
|
3121
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
3122
|
+
if (options.aggregate) {
|
|
3123
|
+
params.set("window", options.aggregate.window);
|
|
3124
|
+
params.set("agg", options.aggregate.agg);
|
|
3125
|
+
if (options.aggregate.field !== void 0) params.set("field", options.aggregate.field);
|
|
3126
|
+
}
|
|
3127
|
+
return params.toString();
|
|
3128
|
+
}
|
|
3129
|
+
function createDatapointsApi(http, channel, slugSubscriptions) {
|
|
3130
|
+
const registry = /* @__PURE__ */ new Map();
|
|
3131
|
+
async function history(robotId, slug2, options) {
|
|
3132
|
+
const query = historyQueryString(options);
|
|
3133
|
+
const result = await http.request(
|
|
3134
|
+
`/api/robots/${pathSegment(robotId)}/datapoints/${pathSegment(slug2)}/history?${query}`,
|
|
3135
|
+
{}
|
|
3136
|
+
);
|
|
3137
|
+
return result;
|
|
3138
|
+
}
|
|
3139
|
+
return {
|
|
3140
|
+
async get(robotId, slug2) {
|
|
3141
|
+
const value = await http.request(`/api/robots/${pathSegment(robotId)}/datapoints/${pathSegment(slug2)}`, {});
|
|
3142
|
+
return value;
|
|
3143
|
+
},
|
|
3144
|
+
history,
|
|
3145
|
+
subscribe(robotId, slug2, handlers) {
|
|
3146
|
+
const key = keyOf(robotId, slug2);
|
|
3147
|
+
let state = registry.get(key);
|
|
3148
|
+
if (!state) {
|
|
3149
|
+
state = createKeyState(channel, slugSubscriptions, robotId, slug2);
|
|
3150
|
+
registry.set(key, state);
|
|
3151
|
+
}
|
|
3152
|
+
state.handlers.add(handlers);
|
|
3153
|
+
let active = true;
|
|
3154
|
+
return {
|
|
3155
|
+
unsubscribe() {
|
|
3156
|
+
if (!active) return;
|
|
3157
|
+
active = false;
|
|
3158
|
+
const current = registry.get(key);
|
|
3159
|
+
if (!current) return;
|
|
3160
|
+
current.handlers.delete(handlers);
|
|
3161
|
+
if (current.handlers.size > 0) return;
|
|
3162
|
+
current.unlistenEvent();
|
|
3163
|
+
current.slugHandle.release();
|
|
3164
|
+
registry.delete(key);
|
|
3165
|
+
}
|
|
3166
|
+
};
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
// src/job-subscriptions.ts
|
|
3172
|
+
function keyOf2(robotId, slug2) {
|
|
3173
|
+
return `${robotId} ${slug2}`;
|
|
3174
|
+
}
|
|
3175
|
+
function createKeyState2(channel, slugSubscriptions, robotId, slug2) {
|
|
3176
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
3177
|
+
const unlistenEvent = channel.on("job", (frame) => {
|
|
3178
|
+
const event = frame;
|
|
3179
|
+
if (event.robot_id !== robotId || event.slug !== slug2) return;
|
|
3180
|
+
for (const handler of handlers) handler.onJob(event);
|
|
3181
|
+
});
|
|
3182
|
+
const slugHandle = slugSubscriptions.acquire(robotId, slug2, {
|
|
3183
|
+
onSubscribeError(error) {
|
|
3184
|
+
for (const handler of handlers) handler.onError?.(error);
|
|
3185
|
+
},
|
|
3186
|
+
onAuthFailed(error) {
|
|
3187
|
+
for (const handler of handlers) handler.onError?.(error);
|
|
3188
|
+
}
|
|
3189
|
+
});
|
|
3190
|
+
return { handlers, unlistenEvent, slugHandle };
|
|
3191
|
+
}
|
|
3192
|
+
function createJobSubscriptions(channel, slugSubscriptions) {
|
|
3193
|
+
const registry = /* @__PURE__ */ new Map();
|
|
3194
|
+
return {
|
|
3195
|
+
subscribe(robotId, slug2, handlers) {
|
|
3196
|
+
const key = keyOf2(robotId, slug2);
|
|
3197
|
+
let state = registry.get(key);
|
|
3198
|
+
if (!state) {
|
|
3199
|
+
state = createKeyState2(channel, slugSubscriptions, robotId, slug2);
|
|
3200
|
+
registry.set(key, state);
|
|
3201
|
+
}
|
|
3202
|
+
state.handlers.add(handlers);
|
|
3203
|
+
let active = true;
|
|
3204
|
+
return {
|
|
3205
|
+
unsubscribe() {
|
|
3206
|
+
if (!active) return;
|
|
3207
|
+
active = false;
|
|
3208
|
+
const current = registry.get(key);
|
|
3209
|
+
if (!current) return;
|
|
3210
|
+
current.handlers.delete(handlers);
|
|
3211
|
+
if (current.handlers.size > 0) return;
|
|
3212
|
+
current.unlistenEvent();
|
|
3213
|
+
current.slugHandle.release();
|
|
3214
|
+
registry.delete(key);
|
|
3215
|
+
}
|
|
3216
|
+
};
|
|
3217
|
+
}
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
// src/jobs.ts
|
|
3222
|
+
function createJobsApi(http) {
|
|
3223
|
+
return {
|
|
3224
|
+
async list(robotId) {
|
|
3225
|
+
const response = await http.request(`/api/robots/${pathSegment(robotId)}/jobs`, {});
|
|
3226
|
+
return response.jobs;
|
|
3227
|
+
}
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
// src/publishers.ts
|
|
3232
|
+
function createPublishersApi(transport) {
|
|
3233
|
+
return {
|
|
3234
|
+
async publish(robotId, slug2, message, options) {
|
|
3235
|
+
await transport.publish(robotId, slug2, message, options);
|
|
3236
|
+
}
|
|
3237
|
+
};
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// src/realtime.ts
|
|
3241
|
+
var WS_OPEN = 1;
|
|
3242
|
+
var RealtimeChannel = class {
|
|
3243
|
+
#options;
|
|
3244
|
+
#socket = null;
|
|
3245
|
+
#opening = false;
|
|
3246
|
+
#authenticated = false;
|
|
3247
|
+
#closedByCaller = false;
|
|
3248
|
+
#backoffAttempt = 0;
|
|
3249
|
+
#reconnectTimer = null;
|
|
3250
|
+
#reauthAttempted = false;
|
|
3251
|
+
#identity = null;
|
|
3252
|
+
#connectionEpoch = 0;
|
|
3253
|
+
#frameListeners = /* @__PURE__ */ new Map();
|
|
3254
|
+
#readyListeners = /* @__PURE__ */ new Set();
|
|
3255
|
+
#authFailedListeners = /* @__PURE__ */ new Set();
|
|
3256
|
+
constructor(options) {
|
|
3257
|
+
this.#options = options;
|
|
3258
|
+
}
|
|
3259
|
+
get isReady() {
|
|
3260
|
+
return this.#authenticated;
|
|
3261
|
+
}
|
|
3262
|
+
get identity() {
|
|
3263
|
+
return this.#identity;
|
|
3264
|
+
}
|
|
3265
|
+
/**
|
|
3266
|
+
* Increments on every successful authentication (first connect and every
|
|
3267
|
+
* reconnect). A command sent on one physical socket can only ever be
|
|
3268
|
+
* answered on that socket — comparing the epoch captured at send time
|
|
3269
|
+
* against the current one is how a caller (see `commands.ts`) tells "still
|
|
3270
|
+
* waiting on the connection it was sent over" from "that connection is
|
|
3271
|
+
* gone and a new one has taken its place", the moment it happens rather
|
|
3272
|
+
* than after a timeout elapses.
|
|
3273
|
+
*/
|
|
3274
|
+
get connectionEpoch() {
|
|
3275
|
+
return this.#connectionEpoch;
|
|
3276
|
+
}
|
|
3277
|
+
/**
|
|
3278
|
+
* Opens the socket if not already open/opening. Idempotent.
|
|
3279
|
+
*
|
|
3280
|
+
* `#opening` closes a real race: `#open()` is async and does not assign
|
|
3281
|
+
* `#socket` until *after* `await credentials.token()` — so two `connect()`
|
|
3282
|
+
* calls issued before that await settles (e.g. a `subscribe()` and a
|
|
3283
|
+
* command in the same tick, or two calls in a `Promise.all`) both used to
|
|
3284
|
+
* see `#socket` as unset and both proceed, opening two physical sockets.
|
|
3285
|
+
* Each socket's own `auth_ok` bumps `connectionEpoch`, so the second
|
|
3286
|
+
* socket authenticating made `sendCommand`'s dead-connection watch treat
|
|
3287
|
+
* the *first* socket — which was fine, and may already have carried a
|
|
3288
|
+
* command — as replaced, failing it `command_outcome_unknown` for a
|
|
3289
|
+
* connection that never actually died. The orphaned first socket was also
|
|
3290
|
+
* never closed: `close()`/`logout()` only ever reach whichever socket
|
|
3291
|
+
* `#socket` currently points to. `#opening` is set as the very first
|
|
3292
|
+
* statement inside `#open()` (not here — `#open()` is also called
|
|
3293
|
+
* directly from the reconnect timer and the re-auth retry, and both need
|
|
3294
|
+
* the same protection against a `connect()` landing in their own async
|
|
3295
|
+
* gap), so it is already true by the time any *synchronous* second caller
|
|
3296
|
+
* runs, because `void this.#open()` executes an async function's body up
|
|
3297
|
+
* to its first `await` immediately, not on a later microtask.
|
|
3298
|
+
*/
|
|
3299
|
+
connect() {
|
|
3300
|
+
if (this.#socket || this.#reconnectTimer || this.#opening) return;
|
|
3301
|
+
this.#closedByCaller = false;
|
|
3302
|
+
void this.#open();
|
|
3303
|
+
}
|
|
3304
|
+
/**
|
|
3305
|
+
* Sends a frame only once the current socket has actually authenticated —
|
|
3306
|
+
* otherwise a silent no-op (nothing server-side to address). Checking the
|
|
3307
|
+
* socket's own `readyState` in addition to the channel-level
|
|
3308
|
+
* `#authenticated` flag is deliberate belt-and-suspenders: `#authenticated`
|
|
3309
|
+
* is set by whichever socket's `auth_ok` arrives, so if `#socket` were ever
|
|
3310
|
+
* reassigned without a matching reset (the `#opening` race above was one
|
|
3311
|
+
* way that could happen), a frame could otherwise be sent into a socket
|
|
3312
|
+
* that never authenticated at all.
|
|
3313
|
+
*/
|
|
3314
|
+
send(frame) {
|
|
3315
|
+
if (this.#authenticated && this.#socket?.readyState === WS_OPEN) this.#socket.send(JSON.stringify(frame));
|
|
3316
|
+
}
|
|
3317
|
+
/** Registers a listener for one frame `type` (e.g. `'datapoint'`, `'subscribe_error'`). */
|
|
3318
|
+
on(type, handler) {
|
|
3319
|
+
let set = this.#frameListeners.get(type);
|
|
3320
|
+
if (!set) {
|
|
3321
|
+
set = /* @__PURE__ */ new Set();
|
|
3322
|
+
this.#frameListeners.set(type, set);
|
|
3323
|
+
}
|
|
3324
|
+
set.add(handler);
|
|
3325
|
+
return () => set.delete(handler);
|
|
3326
|
+
}
|
|
3327
|
+
/**
|
|
3328
|
+
* Fires once now if the channel is already authenticated, and again after
|
|
3329
|
+
* every future (re)authentication. This is how a subscription (re)sends
|
|
3330
|
+
* its `subscribe` frame without special-casing "already connected" itself.
|
|
3331
|
+
*/
|
|
3332
|
+
onReady(fn) {
|
|
3333
|
+
this.#readyListeners.add(fn);
|
|
3334
|
+
if (this.#authenticated) queueMicrotask(fn);
|
|
3335
|
+
return () => this.#readyListeners.delete(fn);
|
|
3336
|
+
}
|
|
3337
|
+
/** Fires when authentication fails in a way that will not be retried (see `#handleAuthError`). */
|
|
3338
|
+
onAuthFailed(fn) {
|
|
3339
|
+
this.#authFailedListeners.add(fn);
|
|
3340
|
+
return () => this.#authFailedListeners.delete(fn);
|
|
3341
|
+
}
|
|
3342
|
+
/** Closes the socket and stops reconnecting. */
|
|
3343
|
+
close() {
|
|
3344
|
+
this.#closedByCaller = true;
|
|
3345
|
+
if (this.#reconnectTimer) {
|
|
3346
|
+
clearTimeout(this.#reconnectTimer);
|
|
3347
|
+
this.#reconnectTimer = null;
|
|
3348
|
+
}
|
|
3349
|
+
this.#authenticated = false;
|
|
3350
|
+
this.#socket?.close();
|
|
3351
|
+
this.#socket = null;
|
|
3352
|
+
}
|
|
3353
|
+
async #open() {
|
|
3354
|
+
this.#opening = true;
|
|
3355
|
+
const WebSocketCtor = this.#options.WebSocket;
|
|
3356
|
+
if (!WebSocketCtor) {
|
|
3357
|
+
this.#opening = false;
|
|
3358
|
+
this.#failAuth(new FleetlessError("no_websocket", "No WebSocket implementation is available in this environment."));
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
3361
|
+
let token;
|
|
3362
|
+
try {
|
|
3363
|
+
token = await this.#options.credentials.token();
|
|
3364
|
+
} catch (error) {
|
|
3365
|
+
this.#opening = false;
|
|
3366
|
+
this.#failAuth(error instanceof FleetlessError ? error : new FleetlessError("no_session", "Could not obtain a credential."));
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
if (!token) {
|
|
3370
|
+
this.#opening = false;
|
|
3371
|
+
this.#failAuth(new FleetlessError("no_session", "Not authenticated \u2014 call auth.login() before subscribing."));
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
const socket = new WebSocketCtor(this.#options.url);
|
|
3375
|
+
this.#socket = socket;
|
|
3376
|
+
this.#opening = false;
|
|
3377
|
+
this.#authenticated = false;
|
|
3378
|
+
socket.onopen = () => {
|
|
3379
|
+
const authFrame = { type: "auth", token };
|
|
3380
|
+
socket.send(JSON.stringify(authFrame));
|
|
3381
|
+
};
|
|
3382
|
+
socket.onmessage = (event) => this.#handleMessage(event.data);
|
|
3383
|
+
socket.onclose = () => this.#handleClose();
|
|
3384
|
+
socket.onerror = () => {
|
|
3385
|
+
};
|
|
3386
|
+
}
|
|
3387
|
+
#handleMessage(raw) {
|
|
3388
|
+
let frame;
|
|
3389
|
+
try {
|
|
3390
|
+
frame = JSON.parse(raw);
|
|
3391
|
+
} catch {
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
if (typeof frame?.type !== "string") return;
|
|
3395
|
+
if (frame.type === "auth_ok") {
|
|
3396
|
+
this.#handleAuthOk(frame);
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
if (frame.type === "auth_error") {
|
|
3400
|
+
void this.#handleAuthError(frame);
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
3403
|
+
this.#frameListeners.get(frame.type)?.forEach((handler) => handler(frame));
|
|
3404
|
+
}
|
|
3405
|
+
#handleAuthOk(frame) {
|
|
3406
|
+
this.#authenticated = true;
|
|
3407
|
+
this.#identity = frame.identity;
|
|
3408
|
+
this.#backoffAttempt = 0;
|
|
3409
|
+
this.#reauthAttempted = false;
|
|
3410
|
+
this.#connectionEpoch += 1;
|
|
3411
|
+
this.#readyListeners.forEach((fn) => fn());
|
|
3412
|
+
}
|
|
3413
|
+
async #handleAuthError(frame) {
|
|
3414
|
+
if (frame.code === "token_expired" && !this.#reauthAttempted) {
|
|
3415
|
+
this.#reauthAttempted = true;
|
|
3416
|
+
try {
|
|
3417
|
+
await this.#options.credentials.handleExpired();
|
|
3418
|
+
this.#socket?.close();
|
|
3419
|
+
this.#socket = null;
|
|
3420
|
+
void this.#open();
|
|
3421
|
+
return;
|
|
3422
|
+
} catch (refreshError) {
|
|
3423
|
+
this.#failAuth(refreshError instanceof FleetlessError ? refreshError : new FleetlessError(frame.code, frame.message));
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
this.#failAuth(new FleetlessError(frame.code, frame.message));
|
|
3428
|
+
}
|
|
3429
|
+
#failAuth(error) {
|
|
3430
|
+
this.#closedByCaller = true;
|
|
3431
|
+
this.#socket?.close();
|
|
3432
|
+
this.#socket = null;
|
|
3433
|
+
this.#authenticated = false;
|
|
3434
|
+
this.#authFailedListeners.forEach((fn) => fn(error));
|
|
3435
|
+
}
|
|
3436
|
+
#handleClose() {
|
|
3437
|
+
this.#authenticated = false;
|
|
3438
|
+
this.#socket = null;
|
|
3439
|
+
if (this.#closedByCaller) return;
|
|
3440
|
+
this.#scheduleReconnect();
|
|
3441
|
+
}
|
|
3442
|
+
#scheduleReconnect() {
|
|
3443
|
+
const initial = this.#options.initialBackoffMs ?? 500;
|
|
3444
|
+
const max = this.#options.maxBackoffMs ?? 3e4;
|
|
3445
|
+
const delay = Math.min(initial * 2 ** this.#backoffAttempt, max);
|
|
3446
|
+
this.#backoffAttempt += 1;
|
|
3447
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
3448
|
+
this.#reconnectTimer = null;
|
|
3449
|
+
void this.#open();
|
|
3450
|
+
}, delay);
|
|
3451
|
+
}
|
|
3452
|
+
};
|
|
3453
|
+
|
|
3454
|
+
// src/services.ts
|
|
3455
|
+
var DEFAULT_RESULT_TIMEOUT_MS = 3e4;
|
|
3456
|
+
function isTerminal(state) {
|
|
3457
|
+
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "lost";
|
|
3458
|
+
}
|
|
3459
|
+
function unwrap(job2) {
|
|
3460
|
+
if (job2.state === "succeeded") return job2.result;
|
|
3461
|
+
const error = job2.error;
|
|
3462
|
+
throw new FleetlessError(
|
|
3463
|
+
error?.code ?? job2.state,
|
|
3464
|
+
error?.message ?? `The service call ended in state '${job2.state}'.`,
|
|
3465
|
+
error?.details !== void 0 ? { details: error.details } : void 0
|
|
3466
|
+
);
|
|
3467
|
+
}
|
|
3468
|
+
function createServicesApi(transport, jobSubscriptions) {
|
|
3469
|
+
return {
|
|
3470
|
+
async call(robotId, slug2, params, options = {}) {
|
|
3471
|
+
const timeoutMs = resolveLocalWaitMs(options, DEFAULT_RESULT_TIMEOUT_MS);
|
|
3472
|
+
const deadline = Date.now() + timeoutMs;
|
|
3473
|
+
const initial = await transport.invoke(robotId, slug2, params, { timeoutMs, patienceMs: options.patienceMs });
|
|
3474
|
+
const initialJob = initial.job;
|
|
3475
|
+
if (!initialJob) {
|
|
3476
|
+
throw new FleetlessError("unexpected_response", `The server accepted the call to '${slug2}' but returned no job to track.`);
|
|
3477
|
+
}
|
|
3478
|
+
if (isTerminal(initialJob.state)) return unwrap(initialJob);
|
|
3479
|
+
const remainingMs = Math.max(0, deadline - Date.now());
|
|
3480
|
+
if (remainingMs === 0) {
|
|
3481
|
+
throw new FleetlessError("command_timeout", `Service '${slug2}' did not reach a terminal state within ${timeoutMs}ms.`);
|
|
3482
|
+
}
|
|
3483
|
+
return new Promise((resolve, reject) => {
|
|
3484
|
+
let settled = false;
|
|
3485
|
+
const timer = setTimeout(() => {
|
|
3486
|
+
settle(
|
|
3487
|
+
() => reject(new FleetlessError("command_timeout", `Service '${slug2}' did not reach a terminal state within ${timeoutMs}ms.`))
|
|
3488
|
+
);
|
|
3489
|
+
}, remainingMs);
|
|
3490
|
+
const subscription = jobSubscriptions.subscribe(robotId, slug2, {
|
|
3491
|
+
onJob(event) {
|
|
3492
|
+
if (event.job.id !== initialJob.id || !isTerminal(event.job.state)) return;
|
|
3493
|
+
settle(() => {
|
|
3494
|
+
try {
|
|
3495
|
+
resolve(unwrap(event.job));
|
|
3496
|
+
} catch (error) {
|
|
3497
|
+
reject(error);
|
|
3498
|
+
}
|
|
3499
|
+
});
|
|
3500
|
+
},
|
|
3501
|
+
onError(error) {
|
|
3502
|
+
settle(() => reject(error));
|
|
3503
|
+
}
|
|
3504
|
+
});
|
|
3505
|
+
function settle(fn) {
|
|
3506
|
+
if (settled) return;
|
|
3507
|
+
settled = true;
|
|
3508
|
+
clearTimeout(timer);
|
|
3509
|
+
subscription.unsubscribe();
|
|
3510
|
+
fn();
|
|
3511
|
+
}
|
|
3512
|
+
});
|
|
3513
|
+
}
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
// src/slug-subscriptions.ts
|
|
3518
|
+
function keyOf3(robotId, slug2) {
|
|
3519
|
+
return `${robotId} ${slug2}`;
|
|
3520
|
+
}
|
|
3521
|
+
function createSlugSubscriptions(channel) {
|
|
3522
|
+
const registry = /* @__PURE__ */ new Map();
|
|
3523
|
+
channel.on("subscribe_error", (frame) => {
|
|
3524
|
+
const error = frame;
|
|
3525
|
+
const state = registry.get(keyOf3(error.robot_id, error.slug));
|
|
3526
|
+
if (!state) return;
|
|
3527
|
+
const fleetlessError = new FleetlessError(error.code, error.message);
|
|
3528
|
+
for (const callbacks of state.callbacks) callbacks.onSubscribeError(fleetlessError);
|
|
3529
|
+
});
|
|
3530
|
+
channel.onAuthFailed((error) => {
|
|
3531
|
+
for (const state of registry.values()) {
|
|
3532
|
+
for (const callbacks of state.callbacks) callbacks.onAuthFailed(error);
|
|
3533
|
+
}
|
|
3534
|
+
});
|
|
3535
|
+
return {
|
|
3536
|
+
acquire(robotId, slug2, callbacks) {
|
|
3537
|
+
const key = keyOf3(robotId, slug2);
|
|
3538
|
+
let state = registry.get(key);
|
|
3539
|
+
if (!state) {
|
|
3540
|
+
const unlistenReady = channel.onReady(() => {
|
|
3541
|
+
const frame = { type: "subscribe", robot_id: robotId, slug: slug2 };
|
|
3542
|
+
channel.send(frame);
|
|
3543
|
+
});
|
|
3544
|
+
state = { count: 0, callbacks: /* @__PURE__ */ new Set(), unlistenReady };
|
|
3545
|
+
registry.set(key, state);
|
|
3546
|
+
}
|
|
3547
|
+
state.count += 1;
|
|
3548
|
+
state.callbacks.add(callbacks);
|
|
3549
|
+
channel.connect();
|
|
3550
|
+
let released = false;
|
|
3551
|
+
return {
|
|
3552
|
+
release() {
|
|
3553
|
+
if (released) return;
|
|
3554
|
+
released = true;
|
|
3555
|
+
const current = registry.get(key);
|
|
3556
|
+
if (!current) return;
|
|
3557
|
+
current.callbacks.delete(callbacks);
|
|
3558
|
+
current.count -= 1;
|
|
3559
|
+
if (current.count > 0) return;
|
|
3560
|
+
current.unlistenReady();
|
|
3561
|
+
registry.delete(key);
|
|
3562
|
+
const frame = { type: "unsubscribe", robot_id: robotId, slug: slug2 };
|
|
3563
|
+
channel.send(frame);
|
|
3564
|
+
}
|
|
3565
|
+
};
|
|
3566
|
+
}
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
// src/token-store.ts
|
|
3571
|
+
var InMemoryTokenStore = class {
|
|
3572
|
+
#session = null;
|
|
3573
|
+
load() {
|
|
3574
|
+
return this.#session;
|
|
3575
|
+
}
|
|
3576
|
+
save(session) {
|
|
3577
|
+
this.#session = session;
|
|
3578
|
+
}
|
|
3579
|
+
};
|
|
3580
|
+
|
|
3581
|
+
// src/client.ts
|
|
3582
|
+
function createClient(options) {
|
|
3583
|
+
if (options.serverKey !== void 0 && options.tokenStore !== void 0) {
|
|
3584
|
+
throw new Error(
|
|
3585
|
+
"createClient: pass either `tokenStore` (end-user login) or `serverKey` (a server-side caller), not both."
|
|
3586
|
+
);
|
|
3587
|
+
}
|
|
3588
|
+
const config = Object.freeze({
|
|
3589
|
+
apiUrl: options.apiUrl,
|
|
3590
|
+
appIdentifier: options.appIdentifier,
|
|
3591
|
+
realtimeUrl: options.realtimeUrl ?? deriveRealtimeUrl(options.apiUrl)
|
|
3592
|
+
});
|
|
3593
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
3594
|
+
if (!fetchImpl) {
|
|
3595
|
+
throw new Error("createClient: no global `fetch` is available in this environment \u2014 pass one explicitly.");
|
|
3596
|
+
}
|
|
3597
|
+
const webSocketImpl = options.WebSocket ?? globalThis.WebSocket;
|
|
3598
|
+
let auth;
|
|
3599
|
+
let http;
|
|
3600
|
+
let credentials;
|
|
3601
|
+
if (options.serverKey !== void 0) {
|
|
3602
|
+
credentials = new ServerKeyCredentials(options.serverKey);
|
|
3603
|
+
http = new HttpClient({ baseUrl: config.apiUrl, fetch: fetchImpl, credentials });
|
|
3604
|
+
auth = createServerKeyAuth(http, config.appIdentifier);
|
|
3605
|
+
} else {
|
|
3606
|
+
const tokenStore = options.tokenStore ?? new InMemoryTokenStore();
|
|
3607
|
+
http = new HttpClient({ baseUrl: config.apiUrl, fetch: fetchImpl, credentials: noCredentials });
|
|
3608
|
+
credentials = new SessionCredentials(http, tokenStore);
|
|
3609
|
+
http.setCredentials(credentials);
|
|
3610
|
+
auth = createSessionAuth(http, tokenStore, config.appIdentifier);
|
|
3611
|
+
}
|
|
3612
|
+
const channel = new RealtimeChannel({ url: config.realtimeUrl, WebSocket: webSocketImpl, credentials });
|
|
3613
|
+
const slugSubscriptions = createSlugSubscriptions(channel);
|
|
3614
|
+
const datapoints = createDatapointsApi(http, channel, slugSubscriptions);
|
|
3615
|
+
const commandTransport = createRealtimeCommandTransport(channel);
|
|
3616
|
+
const jobSubscriptions = createJobSubscriptions(channel, slugSubscriptions);
|
|
3617
|
+
const actions = createActionsApi(commandTransport, jobSubscriptions);
|
|
3618
|
+
const services = createServicesApi(commandTransport, jobSubscriptions);
|
|
3619
|
+
const publishers = createPublishersApi(commandTransport);
|
|
3620
|
+
const cameras = createCamerasApi(http);
|
|
3621
|
+
const jobs = createJobsApi(http);
|
|
3622
|
+
const assets = createAssetsApi(http);
|
|
3623
|
+
if (options.serverKey === void 0) {
|
|
3624
|
+
const baseLogout = auth.logout.bind(auth);
|
|
3625
|
+
auth = {
|
|
3626
|
+
...auth,
|
|
3627
|
+
async logout() {
|
|
3628
|
+
const result = await baseLogout();
|
|
3629
|
+
channel.close();
|
|
3630
|
+
return result;
|
|
3631
|
+
}
|
|
3632
|
+
};
|
|
3633
|
+
}
|
|
3634
|
+
return {
|
|
3635
|
+
config,
|
|
3636
|
+
auth,
|
|
3637
|
+
datapoints,
|
|
3638
|
+
actions,
|
|
3639
|
+
services,
|
|
3640
|
+
publishers,
|
|
3641
|
+
cameras,
|
|
3642
|
+
jobs,
|
|
3643
|
+
assets,
|
|
3644
|
+
close() {
|
|
3645
|
+
channel.close();
|
|
3646
|
+
}
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3649
|
+
function deriveRealtimeUrl(apiUrl) {
|
|
3650
|
+
const url = new URL(apiUrl);
|
|
3651
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
3652
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/realtime`;
|
|
3653
|
+
return url.toString();
|
|
3654
|
+
}
|
|
3655
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3656
|
+
0 && (module.exports = {
|
|
3657
|
+
FleetlessError,
|
|
3658
|
+
InMemoryTokenStore,
|
|
3659
|
+
SDK_ERROR_CODES,
|
|
3660
|
+
createClient,
|
|
3661
|
+
parameterInvalidDetails
|
|
3662
|
+
});
|