@frockbot/computer-core 0.0.0 → 0.1.1
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/package.json +22 -6
- package/src/core.ts +990 -0
- package/src/host-protocol.test.ts +69 -0
- package/src/host-protocol.ts +369 -0
- package/src/index.test.ts +357 -0
- package/tsconfig.json +13 -0
- package/README.md +0 -3
package/src/core.ts
ADDED
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
import {
|
|
2
|
+
workspaceRootKeyV1,
|
|
3
|
+
type WorkspaceFilesV1,
|
|
4
|
+
type WorkspaceRootKindV1,
|
|
5
|
+
type WorkspaceGenerationsV1,
|
|
6
|
+
type WorkspaceRootV1,
|
|
7
|
+
type WorkspaceSyncEffectsV1,
|
|
8
|
+
} from "@frockbot/kernel-contracts";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { type Context, Service } from "cordis";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A stable, provider-neutral directory name for one Bot inside a User-scoped
|
|
14
|
+
* durable root.
|
|
15
|
+
*
|
|
16
|
+
* A Package that files something per Bot under a root the whole User shares
|
|
17
|
+
* needs a name that is the same on every Computer and on every provider, so it
|
|
18
|
+
* is derived here rather than borrowed from whichever provider happens to be
|
|
19
|
+
* mounted. It is a path segment, never an identity: the writer of a file is
|
|
20
|
+
* what the generation records.
|
|
21
|
+
*/
|
|
22
|
+
export function computerBotPathKeyV1(botId: string): string {
|
|
23
|
+
const id = botId.trim();
|
|
24
|
+
if (!id) throw new Error("Computer Bot id must be non-empty");
|
|
25
|
+
const slug = id
|
|
26
|
+
.normalize("NFKD")
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
29
|
+
.replace(/^-+|-+$/g, "")
|
|
30
|
+
.slice(0, 28);
|
|
31
|
+
const digest = createHash("sha256").update(id).digest("hex").slice(0, 12);
|
|
32
|
+
return `${slug || "bot"}-${digest}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ComputerErrorCode =
|
|
36
|
+
| "not-assigned"
|
|
37
|
+
| "provider-unavailable"
|
|
38
|
+
| "capability-unavailable"
|
|
39
|
+
| "stale-assignment"
|
|
40
|
+
| "human-control-active"
|
|
41
|
+
| "updating"
|
|
42
|
+
| "invalid-request"
|
|
43
|
+
| "conflict"
|
|
44
|
+
| "limit-exceeded"
|
|
45
|
+
| "aborted"
|
|
46
|
+
| "provider-failure";
|
|
47
|
+
|
|
48
|
+
export class ComputerError extends Error {
|
|
49
|
+
constructor(
|
|
50
|
+
readonly code: ComputerErrorCode,
|
|
51
|
+
message: string,
|
|
52
|
+
readonly retryable = false,
|
|
53
|
+
options?: ErrorOptions,
|
|
54
|
+
) {
|
|
55
|
+
super(message, options);
|
|
56
|
+
this.name = "ComputerError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The provisioning key of a Computer. "One Computer serves all of a User's
|
|
62
|
+
* Bots" (ADR 0012), so a Computer is identified by its User and by nothing
|
|
63
|
+
* else. Provisioning, hibernation, the browser profile, and the Workspace are
|
|
64
|
+
* all properties of this identity.
|
|
65
|
+
*/
|
|
66
|
+
export interface ComputerIdentityV1 {
|
|
67
|
+
userId: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One Bot as a tenant of its User's Computer. "each Bot receives its own
|
|
72
|
+
* directories and desktop on it, and all Bots share the User's browser
|
|
73
|
+
* profile." Separation between tenants is organizational, not a security
|
|
74
|
+
* boundary — `directory` and `display` are conventions the Computer provider
|
|
75
|
+
* Package enforces, never isolation the caller may rely on.
|
|
76
|
+
*
|
|
77
|
+
* A caller supplies `botId`; a provider answers on its handle with the
|
|
78
|
+
* `directory` and `display` it resolved for that tenant.
|
|
79
|
+
*/
|
|
80
|
+
export interface ComputerTenantV1 {
|
|
81
|
+
botId: string;
|
|
82
|
+
/** The tenant's directory tree, relative to the Workspace root. */
|
|
83
|
+
directory?: string;
|
|
84
|
+
/** The tenant's desktop, when the provider offers one. */
|
|
85
|
+
display?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The assignment key. One Computer per User means one key per User: two Bots
|
|
90
|
+
* of one User resolve to one assignment and one generation.
|
|
91
|
+
*/
|
|
92
|
+
export function computerIdentityKeyV1(identity: ComputerIdentityV1): string {
|
|
93
|
+
const userId = identity.userId.trim();
|
|
94
|
+
if (!userId) {
|
|
95
|
+
throw new ComputerError(
|
|
96
|
+
"invalid-request",
|
|
97
|
+
"Computer identity requires a non-empty userId",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return encodeURIComponent(userId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Validates the tenant making a call and returns its normalized Bot id. */
|
|
104
|
+
export function computerTenantBotIdV1(tenant: ComputerTenantV1): string {
|
|
105
|
+
const botId = tenant.botId.trim();
|
|
106
|
+
if (!botId) {
|
|
107
|
+
throw new ComputerError(
|
|
108
|
+
"invalid-request",
|
|
109
|
+
"Computer tenant requires a non-empty botId",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return botId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One durable root a Computer Package's Workspace layout declares: "durable
|
|
117
|
+
* roots, declared by the Computer Package's Workspace layout and by Package
|
|
118
|
+
* manifests, survive hibernation, cold start, host migration, and image
|
|
119
|
+
* rebuild; everything else on the Computer may be lost."
|
|
120
|
+
*
|
|
121
|
+
* `kind` is the kernel's `WorkspaceRootKindV1`, so the Computer Package, the
|
|
122
|
+
* Skills loader, and the Memory Package all name the same roots. `access` is
|
|
123
|
+
* how the Computer presents the root: Memory roots are `read-only` there
|
|
124
|
+
* because the Memory Package is their single writer (ADR 0013).
|
|
125
|
+
*
|
|
126
|
+
* `mountPath` is a template. Three placeholders are substituted:
|
|
127
|
+
* `{bot}` — the provider's directory key for the tenant Bot;
|
|
128
|
+
* `{package}` — a `package-declared` root's Package id, made path-safe;
|
|
129
|
+
* `{root}` — a `package-declared` root's `rootId`.
|
|
130
|
+
*/
|
|
131
|
+
export interface WorkspaceRootDeclarationV1 {
|
|
132
|
+
kind: WorkspaceRootKindV1;
|
|
133
|
+
/** Present only when the declaration covers one `package-declared` rootId. */
|
|
134
|
+
rootId?: string;
|
|
135
|
+
scope: "user" | "bot";
|
|
136
|
+
/** Absolute path template on the Computer where the root is mounted. */
|
|
137
|
+
mountPath: string;
|
|
138
|
+
access: "read-write" | "read-only";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The durable roots one Computer Package declares for a User's Computer. */
|
|
142
|
+
export interface WorkspaceLayoutV1 {
|
|
143
|
+
schemaVersion: 1;
|
|
144
|
+
/** The Workspace root on the Computer, e.g. `/home/box`. */
|
|
145
|
+
home: string;
|
|
146
|
+
roots: WorkspaceRootDeclarationV1[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The declaration governing one root, or `undefined` when none does. */
|
|
150
|
+
export function workspaceRootDeclarationV1(
|
|
151
|
+
layout: WorkspaceLayoutV1,
|
|
152
|
+
root: WorkspaceRootV1,
|
|
153
|
+
): WorkspaceRootDeclarationV1 | undefined {
|
|
154
|
+
return layout.roots.find(
|
|
155
|
+
(declaration) =>
|
|
156
|
+
declaration.kind === root.kind &&
|
|
157
|
+
(declaration.rootId === undefined ||
|
|
158
|
+
(root.kind === "package-declared" &&
|
|
159
|
+
declaration.rootId === root.rootId)),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pathSafe(value: string): string {
|
|
164
|
+
return (
|
|
165
|
+
value
|
|
166
|
+
.normalize("NFKD")
|
|
167
|
+
.toLowerCase()
|
|
168
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
169
|
+
.replace(/^-+|-+$/g, "")
|
|
170
|
+
.slice(0, 64) || "unnamed"
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolves one durable root to its absolute mount path on the Computer.
|
|
176
|
+
*
|
|
177
|
+
* `botDirectoryKey` maps a Bot id to the provider's own directory key. It is
|
|
178
|
+
* applied to the *root's* owner, never to the caller: Bots of one User share
|
|
179
|
+
* one Computer and may read each other's Workspace files, so the mount of
|
|
180
|
+
* another Bot's root is that Bot's directory, not the reader's.
|
|
181
|
+
*
|
|
182
|
+
* No caller outside a Computer Package ever sees a mount path.
|
|
183
|
+
*/
|
|
184
|
+
export function workspaceMountPathV1(
|
|
185
|
+
layout: WorkspaceLayoutV1,
|
|
186
|
+
root: WorkspaceRootV1,
|
|
187
|
+
botDirectoryKey?: (botId: string) => string,
|
|
188
|
+
): string {
|
|
189
|
+
const declaration = workspaceRootDeclarationV1(layout, root);
|
|
190
|
+
if (!declaration) {
|
|
191
|
+
throw new ComputerError(
|
|
192
|
+
"capability-unavailable",
|
|
193
|
+
`This Computer declares no durable root for ${workspaceRootKeyV1(root)}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const resolved = declaration.mountPath
|
|
197
|
+
.replace("{bot}", () => {
|
|
198
|
+
if (!botDirectoryKey || !("botId" in root)) {
|
|
199
|
+
throw new ComputerError(
|
|
200
|
+
"invalid-request",
|
|
201
|
+
`A Bot-scoped durable root needs a Bot: ${workspaceRootKeyV1(root)}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return botDirectoryKey(root.botId);
|
|
205
|
+
})
|
|
206
|
+
.replace("{package}", () =>
|
|
207
|
+
root.kind === "package-declared" ? pathSafe(root.packageId) : "",
|
|
208
|
+
)
|
|
209
|
+
.replace("{root}", () =>
|
|
210
|
+
root.kind === "package-declared" ? root.rootId : "",
|
|
211
|
+
);
|
|
212
|
+
if (
|
|
213
|
+
!resolved.startsWith("/") ||
|
|
214
|
+
resolved.includes("//") ||
|
|
215
|
+
resolved.split("/").some((segment) => segment === "." || segment === "..")
|
|
216
|
+
) {
|
|
217
|
+
throw new ComputerError(
|
|
218
|
+
"provider-failure",
|
|
219
|
+
`Computer mount path is not a normalized absolute path: ${resolved}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return resolved;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface ComputerAssignment {
|
|
226
|
+
providerId: string;
|
|
227
|
+
generation: number;
|
|
228
|
+
configuration?: unknown;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
|
232
|
+
|
|
233
|
+
export function normalizeComputerPath(path: string): string {
|
|
234
|
+
const normalized = path.trim();
|
|
235
|
+
const segments = normalized.split("/");
|
|
236
|
+
if (
|
|
237
|
+
!normalized ||
|
|
238
|
+
normalized !== path ||
|
|
239
|
+
normalized.startsWith("/") ||
|
|
240
|
+
normalized.includes("\\") ||
|
|
241
|
+
CONTROL_CHARACTERS.test(normalized) ||
|
|
242
|
+
segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
243
|
+
) {
|
|
244
|
+
throw new ComputerError(
|
|
245
|
+
"invalid-request",
|
|
246
|
+
`Invalid relative Computer path: ${JSON.stringify(path)}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return normalized;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface ComputerOperationOptions {
|
|
253
|
+
signal?: AbortSignal;
|
|
254
|
+
effectId?: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The Computer's Workspace surface.
|
|
259
|
+
*
|
|
260
|
+
* It *is* `WorkspaceFilesV1` — the narrow file interface the kernel declares —
|
|
261
|
+
* addressed by `WorkspacePathV1`, so a durable root is named by kind and owner
|
|
262
|
+
* and never by an absolute path on the Computer. `layout` is where mount paths
|
|
263
|
+
* live, and the only place they live.
|
|
264
|
+
*
|
|
265
|
+
* Memory roots are read-only here: `write` and `delete` answer `refused`,
|
|
266
|
+
* because "The Memory Package is the single writer of Memory roots ... the
|
|
267
|
+
* Workspace presents Memory roots read-only through the durable-root sync."
|
|
268
|
+
*/
|
|
269
|
+
export interface ComputerWorkspace extends WorkspaceFilesV1 {
|
|
270
|
+
readonly layout: WorkspaceLayoutV1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface ComputerExecRequest {
|
|
274
|
+
executable: string;
|
|
275
|
+
args?: string[];
|
|
276
|
+
cwd?: string;
|
|
277
|
+
env?: Record<string, string>;
|
|
278
|
+
stdin?: Uint8Array;
|
|
279
|
+
timeoutMs?: number;
|
|
280
|
+
maxOutputBytes?: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface ComputerExecResult {
|
|
284
|
+
exitCode: number | null;
|
|
285
|
+
signal?: string;
|
|
286
|
+
stdout: Uint8Array;
|
|
287
|
+
stderr: Uint8Array;
|
|
288
|
+
outputTruncated: boolean;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface ComputerExec {
|
|
292
|
+
execute(
|
|
293
|
+
request: ComputerExecRequest,
|
|
294
|
+
options?: ComputerOperationOptions,
|
|
295
|
+
): Promise<ComputerExecResult>;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export type ComputerBrowserAction =
|
|
299
|
+
| { type: "snapshot" }
|
|
300
|
+
| { type: "navigate"; url: string }
|
|
301
|
+
| { type: "click"; role: string; name: string; exact?: boolean }
|
|
302
|
+
| { type: "fill"; label: string; text: string; exact?: boolean }
|
|
303
|
+
| { type: "press"; key: string }
|
|
304
|
+
| { type: "wait"; milliseconds: number };
|
|
305
|
+
|
|
306
|
+
export interface ComputerBrowserState {
|
|
307
|
+
url?: string;
|
|
308
|
+
title?: string;
|
|
309
|
+
accessibilitySnapshot: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface ComputerBrowser {
|
|
313
|
+
perform(
|
|
314
|
+
action: ComputerBrowserAction,
|
|
315
|
+
options?: ComputerOperationOptions,
|
|
316
|
+
): Promise<ComputerBrowserState>;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** What the Computer says about one background process right now. */
|
|
320
|
+
export interface ComputerBackgroundStateV1 {
|
|
321
|
+
/** True while the Computer still holds a live process for the pid. */
|
|
322
|
+
alive: boolean;
|
|
323
|
+
/** The exit code the process recorded, when it recorded one. */
|
|
324
|
+
exitCode?: number;
|
|
325
|
+
/** The bounded head-and-tail of its log. */
|
|
326
|
+
logTail: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface ComputerBackgroundLaunchV1 {
|
|
330
|
+
processId: string;
|
|
331
|
+
command: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface ComputerBackgroundLaunchedV1 {
|
|
335
|
+
pid: number;
|
|
336
|
+
logPath: string;
|
|
337
|
+
/** The Computer's provisioning generation the launch happened under. */
|
|
338
|
+
generation: number;
|
|
339
|
+
cwd: string;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Processes that outlive the Turn that started them.
|
|
344
|
+
*
|
|
345
|
+
* Deliberately narrow: launch, look, read, end. Nothing here keeps a Computer
|
|
346
|
+
* awake — "The Computer wakes only when a Bot uses it" — so a process whose
|
|
347
|
+
* Computer hibernated is answered `unknown` by the caller that holds its
|
|
348
|
+
* record, never reported as running.
|
|
349
|
+
*/
|
|
350
|
+
export interface ComputerBackgroundProcessesV1 {
|
|
351
|
+
launch(
|
|
352
|
+
request: ComputerBackgroundLaunchV1,
|
|
353
|
+
options?: ComputerOperationOptions,
|
|
354
|
+
): Promise<ComputerBackgroundLaunchedV1>;
|
|
355
|
+
inspect(
|
|
356
|
+
processId: string,
|
|
357
|
+
options?: ComputerOperationOptions & { tailBytes?: number },
|
|
358
|
+
): Promise<ComputerBackgroundStateV1>;
|
|
359
|
+
/** Ends the process group: TERM, then KILL after a grace. */
|
|
360
|
+
stop(
|
|
361
|
+
processId: string,
|
|
362
|
+
options?: ComputerOperationOptions,
|
|
363
|
+
): Promise<ComputerBackgroundStateV1>;
|
|
364
|
+
/** The Computer's provisioning generation, as the host last reported it. */
|
|
365
|
+
generation(options?: ComputerOperationOptions): Promise<number>;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** One capture of the Bot's own desktop on its Computer. */
|
|
369
|
+
export interface ComputerScreenshotV1 {
|
|
370
|
+
bytes: Uint8Array;
|
|
371
|
+
mediaType: "image/png";
|
|
372
|
+
/** The X display the capture came from. */
|
|
373
|
+
display: string;
|
|
374
|
+
capturedAt: string;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Captures the Bot's own desktop. Read-only by declaration: it observes the
|
|
379
|
+
* Computer and changes nothing on it, so it records no durable intent — but it
|
|
380
|
+
* is refused while a human holds the takeover lease, because during a takeover
|
|
381
|
+
* the screen is the human's.
|
|
382
|
+
*/
|
|
383
|
+
export interface ComputerScreenshotCapabilityV1 {
|
|
384
|
+
capture(options?: ComputerOperationOptions): Promise<ComputerScreenshotV1>;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** One thing box-doctor looked at, and what it saw. */
|
|
388
|
+
export interface ComputerDoctorCheckV1 {
|
|
389
|
+
name: string;
|
|
390
|
+
status: "pass" | "fail";
|
|
391
|
+
detail: string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* What the Computer's browser announces itself as (parity row 34b).
|
|
396
|
+
*
|
|
397
|
+
* Recorded rather than governed: GrokBot pins the User-Agent and rotates
|
|
398
|
+
* per-site fingerprint profiles, and the register declines both. What is kept
|
|
399
|
+
* is the measurement, because "does our browser announce itself as a robot"
|
|
400
|
+
* was an assumption nobody had checked. `brands` is
|
|
401
|
+
* `navigator.userAgentData.brands` rendered `<brand>/<version>`, empty on a
|
|
402
|
+
* browser that does not expose it.
|
|
403
|
+
*/
|
|
404
|
+
export interface ComputerBrowserIdentityV1 {
|
|
405
|
+
userAgent: string;
|
|
406
|
+
webdriver: boolean;
|
|
407
|
+
brands: string[];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* One run of the Computer's self-check (parity row 27).
|
|
412
|
+
*
|
|
413
|
+
* `generation` is the Computer's provisioning generation as the host reported
|
|
414
|
+
* it, so a report read later says which Computer it describes — a report from
|
|
415
|
+
* before a reprovisioning is history, not a current answer.
|
|
416
|
+
*
|
|
417
|
+
* `browserIdentity` is absent whenever nothing was measured — no browser was
|
|
418
|
+
* running for this tenant, or the one that was did not answer — which is a
|
|
419
|
+
* different fact from a browser that presented no tells, and the two are kept
|
|
420
|
+
* apart rather than collapsed into an empty measurement.
|
|
421
|
+
*/
|
|
422
|
+
export interface ComputerDoctorReportV1 {
|
|
423
|
+
schemaVersion: 2;
|
|
424
|
+
generation: number;
|
|
425
|
+
capturedAt: string;
|
|
426
|
+
checks: ComputerDoctorCheckV1[];
|
|
427
|
+
browserIdentity?: ComputerBrowserIdentityV1;
|
|
428
|
+
summary: string;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Decodes one report at the seam it crosses: the Computer's stdout.
|
|
433
|
+
*
|
|
434
|
+
* Exact-field and unversioned-migration-free, like every other decoder here. A
|
|
435
|
+
* report that does not decode is a Computer that answered something else, and
|
|
436
|
+
* the caller says so rather than guessing at half a report.
|
|
437
|
+
*/
|
|
438
|
+
export function decodeComputerDoctorReportV1(
|
|
439
|
+
value: unknown,
|
|
440
|
+
): ComputerDoctorReportV1 | undefined {
|
|
441
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
442
|
+
const record = value as Record<string, unknown>;
|
|
443
|
+
if (record.schemaVersion !== 2) return undefined;
|
|
444
|
+
const { generation, capturedAt, checks, browserIdentity, summary } = record;
|
|
445
|
+
if (typeof generation !== "number" || !Number.isSafeInteger(generation)) {
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
if (typeof capturedAt !== "string" || !capturedAt) return undefined;
|
|
449
|
+
if (typeof summary !== "string" || !summary) return undefined;
|
|
450
|
+
if (!Array.isArray(checks)) return undefined;
|
|
451
|
+
const decoded: ComputerDoctorCheckV1[] = [];
|
|
452
|
+
for (const entry of checks) {
|
|
453
|
+
if (typeof entry !== "object" || entry === null) return undefined;
|
|
454
|
+
const check = entry as Record<string, unknown>;
|
|
455
|
+
if (typeof check.name !== "string" || !check.name) return undefined;
|
|
456
|
+
if (check.status !== "pass" && check.status !== "fail") return undefined;
|
|
457
|
+
if (typeof check.detail !== "string") return undefined;
|
|
458
|
+
decoded.push({
|
|
459
|
+
name: check.name,
|
|
460
|
+
status: check.status,
|
|
461
|
+
detail: check.detail,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
if (decoded.length === 0) return undefined;
|
|
465
|
+
const identity = decodeComputerBrowserIdentityV1(browserIdentity);
|
|
466
|
+
if (identity === "invalid") return undefined;
|
|
467
|
+
return {
|
|
468
|
+
schemaVersion: 2,
|
|
469
|
+
generation,
|
|
470
|
+
capturedAt,
|
|
471
|
+
checks: decoded,
|
|
472
|
+
...(identity ? { browserIdentity: identity } : {}),
|
|
473
|
+
summary,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Decodes the browser measurement, or says the report is not one.
|
|
479
|
+
*
|
|
480
|
+
* `null` and an absent field are both "nothing was measured" — the script
|
|
481
|
+
* prints `null` there rather than omitting the key, because a fixed shape is
|
|
482
|
+
* one fewer thing for a shell to get wrong. Anything else that is not this
|
|
483
|
+
* exact shape fails the whole report, like every other field here.
|
|
484
|
+
*/
|
|
485
|
+
function decodeComputerBrowserIdentityV1(
|
|
486
|
+
value: unknown,
|
|
487
|
+
): ComputerBrowserIdentityV1 | undefined | "invalid" {
|
|
488
|
+
if (value === undefined || value === null) return undefined;
|
|
489
|
+
if (typeof value !== "object") return "invalid";
|
|
490
|
+
const record = value as Record<string, unknown>;
|
|
491
|
+
const { userAgent, webdriver, brands } = record;
|
|
492
|
+
if (typeof userAgent !== "string" || !userAgent) return "invalid";
|
|
493
|
+
if (typeof webdriver !== "boolean") return "invalid";
|
|
494
|
+
if (!Array.isArray(brands)) return "invalid";
|
|
495
|
+
const decoded: string[] = [];
|
|
496
|
+
for (const brand of brands) {
|
|
497
|
+
if (typeof brand !== "string") return "invalid";
|
|
498
|
+
decoded.push(brand);
|
|
499
|
+
}
|
|
500
|
+
return { userAgent, webdriver, brands: decoded };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Runs the Computer's self-check and answers a report.
|
|
505
|
+
*
|
|
506
|
+
* Read-only by declaration: every check reads and none repairs, so it records
|
|
507
|
+
* no durable intent. Unlike a screenshot it is *not* refused during a human
|
|
508
|
+
* takeover — a Computer a human is holding is exactly a Computer somebody may
|
|
509
|
+
* need to ask what is wrong with.
|
|
510
|
+
*/
|
|
511
|
+
export interface ComputerDoctorCapabilityV1 {
|
|
512
|
+
run(options?: ComputerOperationOptions): Promise<ComputerDoctorReportV1>;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export interface ComputerViewerSession {
|
|
516
|
+
id: string;
|
|
517
|
+
url: string;
|
|
518
|
+
expiresAt?: string;
|
|
519
|
+
/** Provider progress from the wake that minted this viewer, when there was any. */
|
|
520
|
+
message?: string;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export interface ComputerViewer {
|
|
524
|
+
open(options?: ComputerOperationOptions): Promise<ComputerViewerSession>;
|
|
525
|
+
renew(
|
|
526
|
+
sessionId: string,
|
|
527
|
+
options?: ComputerOperationOptions,
|
|
528
|
+
): Promise<ComputerViewerSession>;
|
|
529
|
+
revoke(sessionId: string, options?: ComputerOperationOptions): Promise<void>;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Wakes and provisions a Computer, attaches the Bot tenant, and mints the
|
|
534
|
+
* viewer session that proves the connection is usable.
|
|
535
|
+
*
|
|
536
|
+
* This is one provider-neutral effect because some providers perform those
|
|
537
|
+
* operations atomically. The caller records one intent before invoking it;
|
|
538
|
+
* provider-specific viewer transport remains behind the Computer adapter.
|
|
539
|
+
*/
|
|
540
|
+
export interface ComputerPresence {
|
|
541
|
+
connect(options?: ComputerOperationOptions): Promise<ComputerViewerSession>;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export interface ComputerControlLease {
|
|
545
|
+
id: string;
|
|
546
|
+
expiresAt: string;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* What a control lease covers.
|
|
551
|
+
*
|
|
552
|
+
* `bot` is the legacy lease on one tenant's own desktop slot. `desktop-gui`
|
|
553
|
+
* is User-wide: one Computer serves all of a User's Bots and there is one
|
|
554
|
+
* screen on it, so serializing GUI work means holding the *box*, not a tenant
|
|
555
|
+
* directory. Human takeover and a `computerUse` subagent both hold it, which
|
|
556
|
+
* is why neither can drive the shared screen while the other is active.
|
|
557
|
+
*/
|
|
558
|
+
export type ComputerControlScopeV1 = "bot" | "desktop-gui";
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Who and what a lease is taken for. Absent is retained for legacy provider
|
|
562
|
+
* callers; a human session and `computerUse` name `desktop-gui` explicitly.
|
|
563
|
+
*/
|
|
564
|
+
export interface ComputerControlRequestV1 {
|
|
565
|
+
scope?: ComputerControlScopeV1;
|
|
566
|
+
/**
|
|
567
|
+
* The lease owner the host serializes on. Naming it is what lets a refusal
|
|
568
|
+
* say *which* holder has the desktop, and what lets a lease outlive the
|
|
569
|
+
* process that took it — a Durable Object that is evicted mid-task still
|
|
570
|
+
* releases the lease it recorded, because the owner is in the record.
|
|
571
|
+
*/
|
|
572
|
+
ownerId?: string;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export interface ComputerControl {
|
|
576
|
+
acquire(
|
|
577
|
+
request?: ComputerControlRequestV1,
|
|
578
|
+
options?: ComputerOperationOptions,
|
|
579
|
+
): Promise<ComputerControlLease>;
|
|
580
|
+
renew(
|
|
581
|
+
lease: ComputerControlLease,
|
|
582
|
+
request?: ComputerControlRequestV1,
|
|
583
|
+
options?: ComputerOperationOptions,
|
|
584
|
+
): Promise<ComputerControlLease>;
|
|
585
|
+
release(
|
|
586
|
+
lease: ComputerControlLease,
|
|
587
|
+
request?: ComputerControlRequestV1,
|
|
588
|
+
options?: ComputerOperationOptions,
|
|
589
|
+
): Promise<void>;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Why one run of the durable-root sync happened. */
|
|
593
|
+
export type ComputerSyncReasonV1 = "open" | "signal" | "turn-end";
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* What one sync run moved, flattened to counts.
|
|
597
|
+
*
|
|
598
|
+
* The provider-neutral answer is deliberately small: a caller outside the
|
|
599
|
+
* Computer Package decides nothing from a sync report except what to record,
|
|
600
|
+
* and the detailed report (which paths, which conflicting generations) belongs
|
|
601
|
+
* to the provider that produced it and to the durable generation records.
|
|
602
|
+
*
|
|
603
|
+
* There is no failure branch. "Connections to the Computer are expected to
|
|
604
|
+
* drop on every pause; every Computer client reconnects and resumes rather
|
|
605
|
+
* than treating a dropped connection as failure" — so an unreachable Computer
|
|
606
|
+
* answers `unavailable` and a Turn continues.
|
|
607
|
+
*/
|
|
608
|
+
export interface ComputerSyncSummaryV1 {
|
|
609
|
+
status: "ok" | "unavailable" | "refused" | "skipped";
|
|
610
|
+
/** Human-readable reason, empty when the run had nothing to say. */
|
|
611
|
+
detail: string;
|
|
612
|
+
pulled: number;
|
|
613
|
+
pushed: number;
|
|
614
|
+
restored: number;
|
|
615
|
+
removed: number;
|
|
616
|
+
adopted: number;
|
|
617
|
+
conflicts: number;
|
|
618
|
+
failures: number;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function computerSyncSummaryV1(
|
|
622
|
+
status: ComputerSyncSummaryV1["status"],
|
|
623
|
+
detail = "",
|
|
624
|
+
): ComputerSyncSummaryV1 {
|
|
625
|
+
return {
|
|
626
|
+
status,
|
|
627
|
+
detail: detail.slice(0, 512),
|
|
628
|
+
pulled: 0,
|
|
629
|
+
pushed: 0,
|
|
630
|
+
restored: 0,
|
|
631
|
+
removed: 0,
|
|
632
|
+
adopted: 0,
|
|
633
|
+
conflicts: 0,
|
|
634
|
+
failures: 0,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The durable-root sync of ADR 0013, as the provider-neutral Computer
|
|
640
|
+
* interface exposes it. "Bots invoke Computers only through the
|
|
641
|
+
* provider-neutral Computer interface", so the Package that gives a Bot its
|
|
642
|
+
* Computer tools reaches the sync here and never through a provider type.
|
|
643
|
+
*
|
|
644
|
+
* A `sync` is present only on a Computer that is already open for a Bot.
|
|
645
|
+
* Reconciling is therefore never a reason to wake a Computer: "The Agent loop,
|
|
646
|
+
* Memory, Skills, Package composition, and Routines function correctly while
|
|
647
|
+
* the Computer is hibernated and do not wake it", and the object-storage side
|
|
648
|
+
* stays authoritative while it sleeps.
|
|
649
|
+
*/
|
|
650
|
+
export interface ComputerSyncV1 {
|
|
651
|
+
/** Reconciles every declared durable root. Never throws. */
|
|
652
|
+
reconcile(
|
|
653
|
+
reason: ComputerSyncReasonV1,
|
|
654
|
+
options?: ComputerOperationOptions,
|
|
655
|
+
): Promise<ComputerSyncSummaryV1>;
|
|
656
|
+
/**
|
|
657
|
+
* The Computer-side watcher's change signal, or `undefined` when it cannot
|
|
658
|
+
* be read. A caller reconciles again when this changes, rather than scanning
|
|
659
|
+
* every root on every tool call.
|
|
660
|
+
*/
|
|
661
|
+
signal(options?: ComputerOperationOptions): Promise<string | undefined>;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* What a host supplies so a Computer Package can build the sync: the
|
|
666
|
+
* object-storage side of the durable roots, and the Durable Object records the
|
|
667
|
+
* push depends on. A provider that receives none simply has no `sync` on its
|
|
668
|
+
* handle, and the Computer's durable roots then live on the Computer alone.
|
|
669
|
+
*
|
|
670
|
+
* Every member is authority the host owns. The Computer Package holds none of
|
|
671
|
+
* it: it drives the reconciliation and records nothing itself.
|
|
672
|
+
*/
|
|
673
|
+
export interface ComputerSyncHostV1 {
|
|
674
|
+
/** The durable roots in object storage, built with the `sync` surface. */
|
|
675
|
+
store: WorkspaceFilesV1;
|
|
676
|
+
/** Where a push records its intent, in the Bot's Durable Object. */
|
|
677
|
+
effects?: WorkspaceSyncEffectsV1;
|
|
678
|
+
/** The owning object's generation ledger, read to recover a removal writer. */
|
|
679
|
+
generations?: WorkspaceGenerationsV1;
|
|
680
|
+
// There is deliberately no writer here. "A file that reaches a durable root
|
|
681
|
+
// without passing through the Workspace file surface (a shell write on the
|
|
682
|
+
// Computer) is mirrored to object storage by the sync with an unattributed
|
|
683
|
+
// writer": one Computer serves all of a User's Bots, so no host can say
|
|
684
|
+
// which Bot's process wrote a file, and a sync that named the Turn's Bot
|
|
685
|
+
// would be recording a guess as provenance.
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* One open Computer, addressed by the User whose Computer it is and by the Bot
|
|
690
|
+
* tenant that opened it. The provider answers with the tenant's resolved
|
|
691
|
+
* directory and desktop.
|
|
692
|
+
*/
|
|
693
|
+
export interface ComputerHandle {
|
|
694
|
+
assignment: ComputerAssignment;
|
|
695
|
+
identity: ComputerIdentityV1;
|
|
696
|
+
tenant: ComputerTenantV1;
|
|
697
|
+
workspace?: ComputerWorkspace;
|
|
698
|
+
/** The durable-root sync, when the host supplied its object-storage side. */
|
|
699
|
+
sync?: ComputerSyncV1;
|
|
700
|
+
exec?: ComputerExec;
|
|
701
|
+
browser?: ComputerBrowser;
|
|
702
|
+
screenshot?: ComputerScreenshotCapabilityV1;
|
|
703
|
+
processes?: ComputerBackgroundProcessesV1;
|
|
704
|
+
/** The Computer's self-check, when the provider ships one. */
|
|
705
|
+
doctor?: ComputerDoctorCapabilityV1;
|
|
706
|
+
presence?: ComputerPresence;
|
|
707
|
+
viewer?: ComputerViewer;
|
|
708
|
+
control?: ComputerControl;
|
|
709
|
+
close(): Promise<void>;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export interface ComputerProvider {
|
|
713
|
+
id: string;
|
|
714
|
+
/**
|
|
715
|
+
* The durable roots this provider guarantees. Absent when a provider
|
|
716
|
+
* declares no durable root.
|
|
717
|
+
*/
|
|
718
|
+
workspaceLayout?: WorkspaceLayoutV1;
|
|
719
|
+
/**
|
|
720
|
+
* Provisions the User's Computer when needed and attaches one Bot tenant to
|
|
721
|
+
* it. The split arguments are ADR 0012 in a signature: `identity` is the
|
|
722
|
+
* provisioning key, `tenant` is the caller, and a provider can finally tell
|
|
723
|
+
* "provision the Computer" from "attach this tenant".
|
|
724
|
+
*/
|
|
725
|
+
open(
|
|
726
|
+
identity: ComputerIdentityV1,
|
|
727
|
+
tenant: ComputerTenantV1,
|
|
728
|
+
assignment: ComputerAssignment,
|
|
729
|
+
options?: ComputerOperationOptions,
|
|
730
|
+
): Promise<ComputerHandle>;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function guardedOperation<T>(
|
|
734
|
+
assertCurrent: () => void,
|
|
735
|
+
operation: () => Promise<T>,
|
|
736
|
+
): Promise<T> {
|
|
737
|
+
try {
|
|
738
|
+
assertCurrent();
|
|
739
|
+
return operation();
|
|
740
|
+
} catch (error) {
|
|
741
|
+
return Promise.reject(error);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function guardedFiles(
|
|
746
|
+
files: WorkspaceFilesV1,
|
|
747
|
+
assertCurrent: () => void,
|
|
748
|
+
): WorkspaceFilesV1 {
|
|
749
|
+
return {
|
|
750
|
+
read: (path) => guardedOperation(assertCurrent, () => files.read(path)),
|
|
751
|
+
list: (request) =>
|
|
752
|
+
guardedOperation(assertCurrent, () => files.list(request)),
|
|
753
|
+
stat: (path) => guardedOperation(assertCurrent, () => files.stat(path)),
|
|
754
|
+
write: (request) =>
|
|
755
|
+
guardedOperation(assertCurrent, () => files.write(request)),
|
|
756
|
+
delete: (request) =>
|
|
757
|
+
guardedOperation(assertCurrent, () => files.delete(request)),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function guardedWorkspace(
|
|
762
|
+
workspace: ComputerWorkspace,
|
|
763
|
+
assertCurrent: () => void,
|
|
764
|
+
): ComputerWorkspace {
|
|
765
|
+
return {
|
|
766
|
+
...guardedFiles(workspace, assertCurrent),
|
|
767
|
+
layout: workspace.layout,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function guardedHandle(
|
|
772
|
+
handle: ComputerHandle,
|
|
773
|
+
assertCurrent: () => void,
|
|
774
|
+
): ComputerHandle {
|
|
775
|
+
const {
|
|
776
|
+
workspace,
|
|
777
|
+
sync,
|
|
778
|
+
exec,
|
|
779
|
+
browser,
|
|
780
|
+
screenshot,
|
|
781
|
+
processes,
|
|
782
|
+
doctor,
|
|
783
|
+
presence,
|
|
784
|
+
viewer,
|
|
785
|
+
control,
|
|
786
|
+
} = handle;
|
|
787
|
+
return {
|
|
788
|
+
assignment: handle.assignment,
|
|
789
|
+
identity: handle.identity,
|
|
790
|
+
tenant: handle.tenant,
|
|
791
|
+
workspace: workspace
|
|
792
|
+
? guardedWorkspace(workspace, assertCurrent)
|
|
793
|
+
: undefined,
|
|
794
|
+
sync: sync
|
|
795
|
+
? {
|
|
796
|
+
reconcile: (reason, options) =>
|
|
797
|
+
guardedOperation(assertCurrent, () =>
|
|
798
|
+
sync.reconcile(reason, options),
|
|
799
|
+
),
|
|
800
|
+
signal: (options) =>
|
|
801
|
+
guardedOperation(assertCurrent, () => sync.signal(options)),
|
|
802
|
+
}
|
|
803
|
+
: undefined,
|
|
804
|
+
exec: exec
|
|
805
|
+
? {
|
|
806
|
+
execute: (request, options) =>
|
|
807
|
+
guardedOperation(assertCurrent, () =>
|
|
808
|
+
exec.execute(request, options),
|
|
809
|
+
),
|
|
810
|
+
}
|
|
811
|
+
: undefined,
|
|
812
|
+
browser: browser
|
|
813
|
+
? {
|
|
814
|
+
perform: (action, options) =>
|
|
815
|
+
guardedOperation(assertCurrent, () =>
|
|
816
|
+
browser.perform(action, options),
|
|
817
|
+
),
|
|
818
|
+
}
|
|
819
|
+
: undefined,
|
|
820
|
+
screenshot: screenshot
|
|
821
|
+
? {
|
|
822
|
+
capture: (options) =>
|
|
823
|
+
guardedOperation(assertCurrent, () => screenshot.capture(options)),
|
|
824
|
+
}
|
|
825
|
+
: undefined,
|
|
826
|
+
processes: processes
|
|
827
|
+
? {
|
|
828
|
+
launch: (request, options) =>
|
|
829
|
+
guardedOperation(assertCurrent, () =>
|
|
830
|
+
processes.launch(request, options),
|
|
831
|
+
),
|
|
832
|
+
inspect: (processId, options) =>
|
|
833
|
+
guardedOperation(assertCurrent, () =>
|
|
834
|
+
processes.inspect(processId, options),
|
|
835
|
+
),
|
|
836
|
+
stop: (processId, options) =>
|
|
837
|
+
guardedOperation(assertCurrent, () =>
|
|
838
|
+
processes.stop(processId, options),
|
|
839
|
+
),
|
|
840
|
+
generation: (options) =>
|
|
841
|
+
guardedOperation(assertCurrent, () =>
|
|
842
|
+
processes.generation(options),
|
|
843
|
+
),
|
|
844
|
+
}
|
|
845
|
+
: undefined,
|
|
846
|
+
doctor: doctor
|
|
847
|
+
? {
|
|
848
|
+
run: (options) =>
|
|
849
|
+
guardedOperation(assertCurrent, () => doctor.run(options)),
|
|
850
|
+
}
|
|
851
|
+
: undefined,
|
|
852
|
+
presence: presence
|
|
853
|
+
? {
|
|
854
|
+
connect: (options) =>
|
|
855
|
+
guardedOperation(assertCurrent, () => presence.connect(options)),
|
|
856
|
+
}
|
|
857
|
+
: undefined,
|
|
858
|
+
viewer: viewer
|
|
859
|
+
? {
|
|
860
|
+
open: (options) =>
|
|
861
|
+
guardedOperation(assertCurrent, () => viewer.open(options)),
|
|
862
|
+
renew: (sessionId, options) =>
|
|
863
|
+
guardedOperation(assertCurrent, () =>
|
|
864
|
+
viewer.renew(sessionId, options),
|
|
865
|
+
),
|
|
866
|
+
revoke: (sessionId, options) =>
|
|
867
|
+
guardedOperation(assertCurrent, () =>
|
|
868
|
+
viewer.revoke(sessionId, options),
|
|
869
|
+
),
|
|
870
|
+
}
|
|
871
|
+
: undefined,
|
|
872
|
+
control: control
|
|
873
|
+
? {
|
|
874
|
+
acquire: (request, options) =>
|
|
875
|
+
guardedOperation(assertCurrent, () =>
|
|
876
|
+
control.acquire(request, options),
|
|
877
|
+
),
|
|
878
|
+
renew: (lease, request, options) =>
|
|
879
|
+
guardedOperation(assertCurrent, () =>
|
|
880
|
+
control.renew(lease, request, options),
|
|
881
|
+
),
|
|
882
|
+
release: (lease, request, options) =>
|
|
883
|
+
guardedOperation(assertCurrent, () =>
|
|
884
|
+
control.release(lease, request, options),
|
|
885
|
+
),
|
|
886
|
+
}
|
|
887
|
+
: undefined,
|
|
888
|
+
close: () => handle.close(),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The Computer assignments of the resident application, keyed per User.
|
|
894
|
+
*
|
|
895
|
+
* "The User's Durable Object is the authority for everything User-scoped:
|
|
896
|
+
* ... the Computer assignment" — so the assignment map is keyed by
|
|
897
|
+
* `ComputerIdentityV1` alone. Two Bots of one User share one assignment, one
|
|
898
|
+
* generation, and one provider Computer; each is a tenant on it.
|
|
899
|
+
*/
|
|
900
|
+
export class ComputerRegistry extends Service {
|
|
901
|
+
private readonly providers = new Map<string, ComputerProvider>();
|
|
902
|
+
private readonly assignments = new Map<string, ComputerAssignment>();
|
|
903
|
+
|
|
904
|
+
constructor(ctx: Context) {
|
|
905
|
+
super(ctx, "computers");
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
register(provider: ComputerProvider): () => void {
|
|
909
|
+
const id = provider.id.trim();
|
|
910
|
+
if (!id) throw new Error("Computer provider id must be non-empty");
|
|
911
|
+
if (this.providers.has(id)) {
|
|
912
|
+
throw new Error(`Computer provider "${id}" is already registered`);
|
|
913
|
+
}
|
|
914
|
+
this.providers.set(id, provider);
|
|
915
|
+
return () => {
|
|
916
|
+
if (this.providers.get(id) === provider) this.providers.delete(id);
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
assign(
|
|
921
|
+
identity: ComputerIdentityV1,
|
|
922
|
+
providerId: string,
|
|
923
|
+
configuration?: unknown,
|
|
924
|
+
): ComputerAssignment {
|
|
925
|
+
const key = computerIdentityKeyV1(identity);
|
|
926
|
+
const normalizedProviderId = providerId.trim();
|
|
927
|
+
if (!this.providers.has(normalizedProviderId)) {
|
|
928
|
+
throw new ComputerError(
|
|
929
|
+
"provider-unavailable",
|
|
930
|
+
`Computer provider "${normalizedProviderId}" is unavailable`,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
const previous = this.assignments.get(key);
|
|
934
|
+
const assignment = {
|
|
935
|
+
providerId: normalizedProviderId,
|
|
936
|
+
generation: (previous?.generation ?? 0) + 1,
|
|
937
|
+
configuration,
|
|
938
|
+
} satisfies ComputerAssignment;
|
|
939
|
+
this.assignments.set(key, assignment);
|
|
940
|
+
return assignment;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
assignment(identity: ComputerIdentityV1): ComputerAssignment | undefined {
|
|
944
|
+
return this.assignments.get(computerIdentityKeyV1(identity));
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async open(
|
|
948
|
+
identity: ComputerIdentityV1,
|
|
949
|
+
tenant: ComputerTenantV1,
|
|
950
|
+
options?: ComputerOperationOptions,
|
|
951
|
+
): Promise<ComputerHandle> {
|
|
952
|
+
options?.signal?.throwIfAborted();
|
|
953
|
+
const key = computerIdentityKeyV1(identity);
|
|
954
|
+
computerTenantBotIdV1(tenant);
|
|
955
|
+
const assignment = this.assignments.get(key);
|
|
956
|
+
if (!assignment) {
|
|
957
|
+
throw new ComputerError(
|
|
958
|
+
"not-assigned",
|
|
959
|
+
`User "${identity.userId}" has no Computer assignment`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
const provider = this.providers.get(assignment.providerId);
|
|
963
|
+
if (!provider) {
|
|
964
|
+
throw new ComputerError(
|
|
965
|
+
"provider-unavailable",
|
|
966
|
+
`Computer provider "${assignment.providerId}" is unavailable`,
|
|
967
|
+
true,
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
const handle = await provider.open(identity, tenant, assignment, options);
|
|
971
|
+
return guardedHandle(handle, () => {
|
|
972
|
+
const current = this.assignments.get(key);
|
|
973
|
+
if (
|
|
974
|
+
current?.providerId !== assignment.providerId ||
|
|
975
|
+
current.generation !== assignment.generation
|
|
976
|
+
) {
|
|
977
|
+
throw new ComputerError(
|
|
978
|
+
"stale-assignment",
|
|
979
|
+
`Computer assignment for User "${identity.userId}" changed`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
declare module "cordis" {
|
|
987
|
+
interface Context {
|
|
988
|
+
computers: ComputerRegistry;
|
|
989
|
+
}
|
|
990
|
+
}
|