@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/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
+ }