@frockbot/plugin-fly-sprite 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/sync.ts ADDED
@@ -0,0 +1,1275 @@
1
+ // The Computer-side half of ADR 0013: the durable roots on the Workspace and
2
+ // the durable roots in object storage are one set of files.
3
+ //
4
+ // **Mechanism, and why it is not a FUSE mount.** The ADR's prior art
5
+ // (`docs/research/zerobsai-memory-sandbox.md`) mounts an R2 bucket into the
6
+ // sandbox with tigrisfs. That cannot be what FrockBot does, for three reasons
7
+ // that are constitutional rather than aesthetic:
8
+ //
9
+ // 1. "No secret lives on the Workspace except the User's browser profile." A
10
+ // FUSE mount needs object-storage credentials *inside* the Computer. There
11
+ // is no way to mount a bucket without giving the Workspace a key to it.
12
+ // 2. "a write that would overwrite a generation its writer has not seen is
13
+ // preserved as a conflicting generation and surfaced, never merged or
14
+ // dropped; last-writer-wins is prohibited." A filesystem write has no
15
+ // `If-Match` and no losing-writer branch: tigrisfs is last-writer-wins by
16
+ // construction, which is the one rule the ADR names as prohibited.
17
+ // 3. "every write to a durable root records its writer." A `write(2)` carries
18
+ // no writer, so a mount cannot record one.
19
+ //
20
+ // So the sync is an agent, not a mount. It runs where the credentials and the
21
+ // generation ledger already are — the backend — and reaches the Computer only
22
+ // through the Sprite's storage exec path, which is the same path
23
+ // `FlyWorkspaceFiles` uses. Two consequences follow, and both are wanted: the
24
+ // object-storage side keeps working with the Computer hibernated (the store is
25
+ // a `WorkspaceFilesV1` in its own right, and Memory and Skills read it without
26
+ // waking anything), and nothing on the Computer holds a credential.
27
+ //
28
+ // On the Sprite there is one small provider-declared service,
29
+ // `WORKSPACE_SYNC_SERVICE`. It holds no credentials and does no network: it
30
+ // watches the declared roots and bumps a change signal, so the agent can tell
31
+ // "something changed while I was away" from "nothing to do" without scanning
32
+ // every root on every Turn. It is a *service* rather than a background process
33
+ // because "Only Computer-provider-declared services may be reattached; other
34
+ // processes are assumed dead after a cold pause."
35
+ //
36
+ // **What the agent does per root.**
37
+ //
38
+ // push (non-Memory roots only)
39
+ // a file whose bytes no longer match its generation sidecar, or which has
40
+ // no sidecar at all, is a Computer-side write. It is pushed to the store
41
+ // with `expectedGenerationId` set to the generation the sidecar last saw,
42
+ // so the store's conditional write decides it. A losing write is preserved
43
+ // by the store under its conflict key *and* on the Computer under
44
+ // `.frockbot-sync/conflicts/`, and surfaced in the report — both
45
+ // generations survive, neither is merged.
46
+ // pull (every declared root)
47
+ // a store generation the Computer has not got is materialized at its mount
48
+ // path together with its sidecar, so `generationOf` answers with the
49
+ // writer the store recorded. Memory roots are pull-only: a Memory file
50
+ // edited on the Computer is never pushed, it is restored.
51
+ // tombstones
52
+ // a delete on either side becomes a removal on the other, recorded. A
53
+ // Computer-side delete leaves `.frockbot-sync/tombstones/<rel>` naming the
54
+ // generation it superseded; a store-side delete removes the file on the
55
+ // Computer and leaves the same record. Neither side ever reads an absence
56
+ // as "a file I never had" and silently re-creates it. A tombstone is a
57
+ // file in an ordinary directory a shell can write, so a Computer-side
58
+ // removal carries no writer at all: it is pushed as an unattributed
59
+ // delete, and only on a non-Memory root, where the store accepts one. The
60
+ // generation it names is still the conditional delete's precondition, so a
61
+ // removal the store has moved past is refused and surfaced.
62
+ //
63
+ // **Writer attribution.** A file the sync finds with no valid sidecar was
64
+ // written by a shell on the Computer, so nothing recorded who wrote it, and it
65
+ // is pushed as `{ kind: "unattributed" }`. That is the constitution's own
66
+ // sentence — "A file that reaches a durable root without passing through the
67
+ // Workspace file surface (a shell write on the Computer) is mirrored to object
68
+ // storage by the sync with an unattributed writer" — and it is also the only
69
+ // honest answer: one Computer serves all of a User's Bots, so the sync cannot
70
+ // know which Bot's process wrote a file, and the Turn that happens to be
71
+ // running is a coincidence rather than evidence. A Bot that means to author a
72
+ // Skill writes it through the Workspace file surface, which records real
73
+ // provenance. The Skills loader refuses `unattributed`, so a shell-written
74
+ // file is durable data and never an instruction.
75
+ //
76
+ // **Effect identifiers.** "A mutation ... records intent and an effect
77
+ // identifier in the Bot's Durable Object and in the Workspace before it runs,
78
+ // so recovery can read its outcome or classify it as unknown without repeating
79
+ // it." Every push records its intent before the write, against a deterministic
80
+ // effect id derived from the root, the path, the bytes, and the generation the
81
+ // writer had seen. Connections to the Computer drop on every pause, so a push
82
+ // that never reported back is ordinary: the next run finds the unsettled
83
+ // intent, reads what the store actually holds, and adopts the generation
84
+ // instead of writing a second one.
85
+ import { createHash } from "node:crypto";
86
+ import {
87
+ workspaceMountPathV1,
88
+ type WorkspaceLayoutV1,
89
+ } from "@frockbot/computer-core";
90
+ import {
91
+ decodeWorkspaceGenerationV1,
92
+ isWorkspaceComputerReadOnlyRootV1,
93
+ normalizeWorkspaceRelativePathV1,
94
+ workspaceRootKeyV1,
95
+ type WorkspaceFailureV1,
96
+ type WorkspaceFilesV1,
97
+ type WorkspaceGenerationV1,
98
+ type WorkspaceGenerationsV1,
99
+ type WorkspaceRootV1,
100
+ type WorkspaceSyncEffectsV1,
101
+ type WorkspaceSyncEffectV1,
102
+ } from "@frockbot/kernel-contracts";
103
+ import type { FlySpriteAgentComputer } from "./computer.js";
104
+ import {
105
+ SYNC_CONFLICTS_DIR,
106
+ SYNC_TOMBSTONES_DIR,
107
+ WORKSPACE_EMPTY_SHA256,
108
+ WORKSPACE_GENERATIONS_DIR,
109
+ WORKSPACE_SYNC_DIR,
110
+ } from "./workspace.js";
111
+
112
+ /** Where the sync keeps notes that are not scoped to one root. */
113
+ const SYNC_NOTES_DIR = ".frockbot/sync";
114
+ /** The note kind holding unsettled push intents. */
115
+ const EFFECT_NOTE_KIND = "effects";
116
+ const MAX_STORE_PAGES = 100;
117
+ const STORE_PAGE_LIMIT = 500;
118
+
119
+ function failure(
120
+ status: WorkspaceFailureV1["status"],
121
+ reason: string,
122
+ ): WorkspaceFailureV1 {
123
+ return { status, reason: reason.slice(0, 512) };
124
+ }
125
+
126
+ function shellQuote(value: string): string {
127
+ return `'${value.replaceAll("'", `'\\''`)}'`;
128
+ }
129
+
130
+ function isFailure(value: { status: string }): value is WorkspaceFailureV1 {
131
+ return value.status !== "ok";
132
+ }
133
+
134
+ /** A file the Computer holds under one durable root. */
135
+ export interface ComputerSyncEntryV1 {
136
+ path: string;
137
+ /** sha-256 of the bytes on disk right now. */
138
+ contentHash: string;
139
+ size: number;
140
+ /** The generation its sidecar records, absent when a shell wrote it. */
141
+ recorded?: WorkspaceGenerationV1;
142
+ }
143
+
144
+ /**
145
+ * A file the Computer no longer holds, and the durable evidence of that: the
146
+ * generation the removal superseded.
147
+ *
148
+ * There is deliberately no writer here. A tombstone is a file in an ordinary
149
+ * directory on the Computer, so a shell can write one — copying the superseded
150
+ * generation id straight out of the sidecar beside it — naming any writer it
151
+ * likes. A generation sidecar survives that because the bytes it describes are
152
+ * the proof, and a removal has no bytes; so a Computer-side removal records no
153
+ * writer, and the sync does not carry a claim it cannot check. What
154
+ * `supersedes` buys instead is the conditional delete: a removal that does not
155
+ * name the generation the store holds is refused and surfaced as a conflict.
156
+ */
157
+ export interface ComputerSyncRemovalV1 {
158
+ path: string;
159
+ supersedes?: string;
160
+ }
161
+
162
+ export interface ComputerSyncScanV1 {
163
+ entries: ComputerSyncEntryV1[];
164
+ removed: ComputerSyncRemovalV1[];
165
+ }
166
+
167
+ export type ComputerSyncOutcomeV1 = { status: "ok" } | WorkspaceFailureV1;
168
+ export type ComputerSyncScanOutcomeV1 =
169
+ { status: "ok"; scan: ComputerSyncScanV1 } | WorkspaceFailureV1;
170
+ export type ComputerSyncBytesOutcomeV1 =
171
+ { status: "ok"; bytes: Uint8Array } | WorkspaceFailureV1;
172
+ export type ComputerSyncNoteOutcomeV1 =
173
+ { status: "ok"; text?: string } | WorkspaceFailureV1;
174
+
175
+ /**
176
+ * The Computer half of the sync, as a narrow seam.
177
+ *
178
+ * It is deliberately not `WorkspaceFilesV1`: that interface is for *authoring*
179
+ * a file — it mints a generation and refuses an `unattributed` writer — and
180
+ * the sync authors nothing on the Computer, it materializes generations the
181
+ * store already recorded. Keeping the two apart is what stops the sync from
182
+ * being a second writer with an opinion.
183
+ */
184
+ export interface ComputerSyncSurfaceV1 {
185
+ /** Every file and every recorded removal under one durable root. */
186
+ scan(root: WorkspaceRootV1): Promise<ComputerSyncScanOutcomeV1>;
187
+ read(
188
+ root: WorkspaceRootV1,
189
+ path: string,
190
+ ): Promise<ComputerSyncBytesOutcomeV1>;
191
+ /** Writes bytes and the generation sidecar that attributes them. */
192
+ materialize(
193
+ root: WorkspaceRootV1,
194
+ path: string,
195
+ bytes: Uint8Array,
196
+ generation: WorkspaceGenerationV1,
197
+ ): Promise<ComputerSyncOutcomeV1>;
198
+ /** Removes a file and records the removal durably. */
199
+ remove(
200
+ root: WorkspaceRootV1,
201
+ path: string,
202
+ supersedes: string | undefined,
203
+ tombstone: WorkspaceGenerationV1,
204
+ ): Promise<ComputerSyncOutcomeV1>;
205
+ /** Clears a removal record once the store has accepted it. */
206
+ forget(root: WorkspaceRootV1, path: string): Promise<ComputerSyncOutcomeV1>;
207
+ /** Keeps a losing write on the Computer beside the winner. */
208
+ preserve(
209
+ root: WorkspaceRootV1,
210
+ path: string,
211
+ bytes: Uint8Array,
212
+ generation: WorkspaceGenerationV1,
213
+ ): Promise<ComputerSyncOutcomeV1>;
214
+ note(kind: string, id: string, text: string): Promise<ComputerSyncOutcomeV1>;
215
+ readNote(kind: string, id: string): Promise<ComputerSyncNoteOutcomeV1>;
216
+ clearNote(kind: string, id: string): Promise<ComputerSyncOutcomeV1>;
217
+ /** The change signal the on-Sprite service maintains. */
218
+ signal(): Promise<ComputerSyncNoteOutcomeV1>;
219
+ }
220
+
221
+ /**
222
+ * Where a push records its intent, and the record it writes. Both are declared
223
+ * by the kernel (`@frockbot/kernel-contracts`), because the Bot's Durable
224
+ * Object implements the interface and a Package may not declare what an
225
+ * authority must store. `createWorkspaceSidecarEffectsV1` below is the
226
+ * Workspace half, which § Durable effects also allows ("in the Bot's Durable
227
+ * Object **and** in the Workspace").
228
+ */
229
+ export type {
230
+ WorkspaceSyncEffectV1,
231
+ WorkspaceSyncEffectsV1,
232
+ } from "@frockbot/kernel-contracts";
233
+
234
+ /** A conflict the sync preserved and is surfacing. */
235
+ export interface WorkspaceSyncConflictV1 {
236
+ root: WorkspaceRootV1;
237
+ path: string;
238
+ reason: string;
239
+ /** The generation that holds the file now. */
240
+ current?: WorkspaceGenerationV1;
241
+ /** The losing write, preserved in the store and on the Computer. */
242
+ preserved?: WorkspaceGenerationV1;
243
+ }
244
+
245
+ export interface WorkspaceSyncFailureV1 extends WorkspaceFailureV1 {
246
+ root: WorkspaceRootV1;
247
+ path?: string;
248
+ }
249
+
250
+ export interface WorkspaceSyncRootReportV1 {
251
+ root: WorkspaceRootV1;
252
+ /** Store generations materialized on the Computer. */
253
+ pulled: string[];
254
+ /** Computer writes accepted by the store. */
255
+ pushed: string[];
256
+ /** Memory-root files put back from the store after a Computer-side edit. */
257
+ restored: string[];
258
+ /** Files removed on the Computer because the store no longer holds them. */
259
+ removedOnComputer: string[];
260
+ /** Files removed in the store because the Computer recorded a removal. */
261
+ removedInStore: string[];
262
+ /** Pushes a previous run had already applied, adopted rather than repeated. */
263
+ adopted: string[];
264
+ conflicts: WorkspaceSyncConflictV1[];
265
+ failures: WorkspaceSyncFailureV1[];
266
+ }
267
+
268
+ export interface WorkspaceSyncReportV1 {
269
+ roots: WorkspaceSyncRootReportV1[];
270
+ conflicts: WorkspaceSyncConflictV1[];
271
+ failures: WorkspaceSyncFailureV1[];
272
+ }
273
+
274
+ export interface WorkspaceRootSyncOptionsV1 {
275
+ /** The object-storage side, built with `surface: "sync"`. */
276
+ store: WorkspaceFilesV1;
277
+ /** The Computer side. */
278
+ computer: ComputerSyncSurfaceV1;
279
+ /** The declared durable roots this Computer serves. */
280
+ roots: WorkspaceRootV1[];
281
+ /** Where a push records its intent; the Workspace sidecar when absent. */
282
+ effects?: WorkspaceSyncEffectsV1;
283
+ /**
284
+ * The owning Durable Object's ledger, when it is reachable. It is read, never
285
+ * written: a store-side delete is a tombstone record there, and object
286
+ * storage forgets the key, so this is the only place the writer of a removal
287
+ * can be recovered.
288
+ */
289
+ generations?: WorkspaceGenerationsV1;
290
+ clock?: () => Date;
291
+ }
292
+
293
+ export interface WorkspaceRootSyncV1 {
294
+ /** Reconciles every declared root. */
295
+ sync(): Promise<WorkspaceSyncReportV1>;
296
+ syncRoot(root: WorkspaceRootV1): Promise<WorkspaceSyncRootReportV1>;
297
+ /**
298
+ * The on-Sprite watcher's change signal. A caller runs the sync on wake and
299
+ * whenever this changes, rather than scanning every root every Turn.
300
+ */
301
+ signal(): Promise<ComputerSyncNoteOutcomeV1>;
302
+ }
303
+
304
+ /** A deterministic effect id: the same pending push resolves to the same key. */
305
+ export function workspaceSyncEffectIdV1(
306
+ root: WorkspaceRootV1,
307
+ path: string,
308
+ kind: "push" | "remove",
309
+ contentHash: string,
310
+ expectedGenerationId: string | null,
311
+ ): string {
312
+ const digest = createHash("sha256")
313
+ .update(
314
+ JSON.stringify([
315
+ workspaceRootKeyV1(root),
316
+ path,
317
+ kind,
318
+ contentHash,
319
+ expectedGenerationId,
320
+ ]),
321
+ )
322
+ .digest("hex");
323
+ return `workspace-sync-${digest.slice(0, 32)}`;
324
+ }
325
+
326
+ function emptyReport(root: WorkspaceRootV1): WorkspaceSyncRootReportV1 {
327
+ return {
328
+ root,
329
+ pulled: [],
330
+ pushed: [],
331
+ restored: [],
332
+ removedOnComputer: [],
333
+ removedInStore: [],
334
+ adopted: [],
335
+ conflicts: [],
336
+ failures: [],
337
+ };
338
+ }
339
+
340
+ class WorkspaceRootSync implements WorkspaceRootSyncV1 {
341
+ private readonly effects: WorkspaceSyncEffectsV1;
342
+ private readonly clock: () => Date;
343
+
344
+ constructor(private readonly options: WorkspaceRootSyncOptionsV1) {
345
+ this.effects =
346
+ options.effects ?? createWorkspaceSidecarEffectsV1(options.computer);
347
+ this.clock = options.clock ?? (() => new Date());
348
+ }
349
+
350
+ signal(): Promise<ComputerSyncNoteOutcomeV1> {
351
+ return this.options.computer.signal();
352
+ }
353
+
354
+ async sync(): Promise<WorkspaceSyncReportV1> {
355
+ const roots: WorkspaceSyncRootReportV1[] = [];
356
+ for (const root of this.options.roots) {
357
+ roots.push(await this.syncRoot(root));
358
+ }
359
+ return {
360
+ roots,
361
+ conflicts: roots.flatMap((report) => report.conflicts),
362
+ failures: roots.flatMap((report) => report.failures),
363
+ };
364
+ }
365
+
366
+ async syncRoot(root: WorkspaceRootV1): Promise<WorkspaceSyncRootReportV1> {
367
+ const report = emptyReport(root);
368
+ const scanned = await this.options.computer.scan(root);
369
+ if (isFailure(scanned)) {
370
+ report.failures.push({ ...scanned, root });
371
+ return report;
372
+ }
373
+ const stored = await this.listStore(root);
374
+ if (isFailure(stored)) {
375
+ report.failures.push({ ...stored, root });
376
+ return report;
377
+ }
378
+ const local = new Map(
379
+ scanned.scan.entries.map((entry) => [entry.path, entry] as const),
380
+ );
381
+ const settled = new Set<string>();
382
+ const conflicted = new Set<string>();
383
+ const held = new Set<string>();
384
+ // Memory roots and the User-global instruction root are presented
385
+ // read-only on the Computer: object storage is their single writer, so
386
+ // this sync materializes them and never pushes out of them (ADR 0013,
387
+ // ADR 0016).
388
+ const readOnlyOnComputer = isWorkspaceComputerReadOnlyRootV1(root);
389
+
390
+ if (!readOnlyOnComputer) {
391
+ // Push first: a Computer-side write must reach the store's conditional
392
+ // write before the pull could overwrite it.
393
+ for (const entry of scanned.scan.entries) {
394
+ if (this.clean(entry)) continue;
395
+ const pushed = await this.push(root, entry, report);
396
+ if (pushed === "pushed") settled.add(entry.path);
397
+ else if (pushed === "conflict") conflicted.add(entry.path);
398
+ else held.add(entry.path);
399
+ }
400
+ for (const removal of scanned.scan.removed) {
401
+ await this.pushRemoval(root, removal, stored, report);
402
+ }
403
+ } else {
404
+ // A Memory root is written only by the Memory Package, and the
405
+ // User-global instruction root only by the Skills Package, both through
406
+ // object storage. A removal recorded here is a Computer-side edit of a
407
+ // read-only presentation: it is never pushed, and the pull below
408
+ // restores the file.
409
+ for (const removal of scanned.scan.removed) {
410
+ await this.options.computer.forget(root, removal.path);
411
+ }
412
+ }
413
+
414
+ for (const [path, generation] of stored.generations) {
415
+ // A Computer-side write that has not reached the store is never
416
+ // overwritten by the pull; leaving it is what "never dropped" means.
417
+ if (held.has(path) || settled.has(path)) continue;
418
+ const entry = local.get(path);
419
+ if (
420
+ !conflicted.has(path) &&
421
+ entry &&
422
+ this.clean(entry) &&
423
+ entry.recorded?.generationId === generation.generationId
424
+ ) {
425
+ continue;
426
+ }
427
+ const materialized = await this.materialize(root, path);
428
+ if (materialized) {
429
+ report.failures.push({ ...materialized, root, path });
430
+ continue;
431
+ }
432
+ if (entry && (readOnlyOnComputer || conflicted.has(path)))
433
+ report.restored.push(path);
434
+ else report.pulled.push(path);
435
+ }
436
+
437
+ for (const entry of scanned.scan.entries) {
438
+ if (stored.generations.has(entry.path)) continue;
439
+ if (held.has(entry.path) || settled.has(entry.path)) continue;
440
+ if (conflicted.has(entry.path)) continue;
441
+ if (!entry.recorded) continue;
442
+ // The Computer holds a file the store recorded and no longer holds: a
443
+ // delete happened there. It becomes a removal here, recorded, never a
444
+ // silent overwrite.
445
+ const removed = await this.removeLocally(root, entry, report);
446
+ if (removed) report.removedOnComputer.push(entry.path);
447
+ }
448
+ return report;
449
+ }
450
+
451
+ /** True when the file's bytes are still the ones its sidecar attributes. */
452
+ private clean(entry: ComputerSyncEntryV1): boolean {
453
+ return (
454
+ entry.recorded !== undefined &&
455
+ entry.contentHash === entry.recorded.contentHash
456
+ );
457
+ }
458
+
459
+ private async listStore(
460
+ root: WorkspaceRootV1,
461
+ ): Promise<
462
+ | { status: "ok"; generations: Map<string, WorkspaceGenerationV1> }
463
+ | WorkspaceFailureV1
464
+ > {
465
+ const generations = new Map<string, WorkspaceGenerationV1>();
466
+ let cursor: string | undefined;
467
+ for (let page = 0; page < MAX_STORE_PAGES; page += 1) {
468
+ const listed = await this.options.store.list({
469
+ root,
470
+ limit: STORE_PAGE_LIMIT,
471
+ ...(cursor ? { cursor } : {}),
472
+ });
473
+ if (listed.status !== "ok") return listed;
474
+ for (const entry of listed.entries) {
475
+ generations.set(entry.path.path, entry.generation);
476
+ }
477
+ if (!listed.cursor) return { status: "ok", generations };
478
+ cursor = listed.cursor;
479
+ }
480
+ return failure("unavailable", "Durable root listing did not terminate");
481
+ }
482
+
483
+ /**
484
+ * Pushes one Computer-side write. `"held"` means the store did not take it,
485
+ * so the Computer's bytes stay exactly where they are.
486
+ */
487
+ private async push(
488
+ root: WorkspaceRootV1,
489
+ entry: ComputerSyncEntryV1,
490
+ report: WorkspaceSyncRootReportV1,
491
+ ): Promise<"pushed" | "conflict" | "held"> {
492
+ const bytes = await this.options.computer.read(root, entry.path);
493
+ if (isFailure(bytes)) {
494
+ report.failures.push({ ...bytes, root, path: entry.path });
495
+ return "held";
496
+ }
497
+ const expected = entry.recorded?.generationId ?? null;
498
+ const effect: WorkspaceSyncEffectV1 = {
499
+ effectId: workspaceSyncEffectIdV1(
500
+ root,
501
+ entry.path,
502
+ "push",
503
+ entry.contentHash,
504
+ expected,
505
+ ),
506
+ root,
507
+ path: entry.path,
508
+ kind: "push",
509
+ contentHash: entry.contentHash,
510
+ expectedGenerationId: expected,
511
+ at: this.clock().toISOString(),
512
+ };
513
+ // Recovery, before the effect: an intent that never reported back is
514
+ // ordinary — connections to the Computer drop on every pause — so read
515
+ // what the store holds and adopt it rather than writing again.
516
+ const pending = await this.pendingEffect(effect);
517
+ if (pending) {
518
+ const adopted = await this.adopt(root, entry, bytes.bytes, report);
519
+ if (adopted) {
520
+ await this.effects.settle(effect);
521
+ return "pushed";
522
+ }
523
+ }
524
+ await this.effects.intent(effect);
525
+ let outcome;
526
+ try {
527
+ outcome = await this.options.store.write({
528
+ path: { root, path: entry.path },
529
+ bytes: bytes.bytes,
530
+ // Nothing on the Computer recorded who wrote these bytes, so nothing
531
+ // here may claim one. See **Writer attribution** above.
532
+ writer: { kind: "unattributed" },
533
+ expectedGenerationId: expected,
534
+ });
535
+ } catch (error) {
536
+ // The intent stays unsettled on purpose: the next run reads what the
537
+ // store holds rather than writing a second generation.
538
+ report.failures.push({
539
+ status: "unavailable",
540
+ reason: error instanceof Error ? error.message : String(error),
541
+ root,
542
+ path: entry.path,
543
+ });
544
+ return "held";
545
+ }
546
+ if (outcome.status === "ok") {
547
+ const sidecar = await this.options.computer.materialize(
548
+ root,
549
+ entry.path,
550
+ bytes.bytes,
551
+ outcome.generation,
552
+ );
553
+ if (sidecar.status !== "ok") {
554
+ // The intent stays unsettled on purpose. The store took the bytes but
555
+ // the Computer has no sidecar for them, so the next run would see an
556
+ // unrecorded file and push it again against `null` — a conflict the
557
+ // store would be right to raise and nobody caused. Leaving the intent
558
+ // is what makes that next run adopt the generation instead.
559
+ report.failures.push({ ...sidecar, root, path: entry.path });
560
+ return "held";
561
+ }
562
+ await this.effects.settle(effect);
563
+ report.pushed.push(entry.path);
564
+ return "pushed";
565
+ }
566
+ await this.effects.settle(effect);
567
+ if (outcome.status === "conflict") {
568
+ const conflict: WorkspaceSyncConflictV1 = {
569
+ root,
570
+ path: entry.path,
571
+ reason: outcome.reason,
572
+ ...("current" in outcome && outcome.current
573
+ ? { current: outcome.current }
574
+ : {}),
575
+ ...("preserved" in outcome && outcome.preserved
576
+ ? { preserved: outcome.preserved }
577
+ : {}),
578
+ };
579
+ report.conflicts.push(conflict);
580
+ if (conflict.preserved) {
581
+ await this.options.computer.preserve(
582
+ root,
583
+ entry.path,
584
+ bytes.bytes,
585
+ conflict.preserved,
586
+ );
587
+ }
588
+ // The winner is materialized by the pull below; leaving the loser at the
589
+ // path would be a silent merge of two generations into one file.
590
+ return "conflict";
591
+ }
592
+ report.failures.push({ ...outcome, root, path: entry.path });
593
+ return "held";
594
+ }
595
+
596
+ private async pendingEffect(effect: WorkspaceSyncEffectV1): Promise<boolean> {
597
+ try {
598
+ return (await this.effects.pending(effect.effectId)) !== undefined;
599
+ } catch {
600
+ return false;
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Adopts a generation the store already holds for exactly these bytes. This
606
+ * is the reconciliation half of an effect identifier: the outcome is read,
607
+ * never repeated.
608
+ */
609
+ private async adopt(
610
+ root: WorkspaceRootV1,
611
+ entry: ComputerSyncEntryV1,
612
+ bytes: Uint8Array,
613
+ report: WorkspaceSyncRootReportV1,
614
+ ): Promise<boolean> {
615
+ const current = await this.options.store.stat({ root, path: entry.path });
616
+ if (current.status !== "ok") return false;
617
+ if (current.entry.generation.contentHash !== entry.contentHash)
618
+ return false;
619
+ const sidecar = await this.options.computer.materialize(
620
+ root,
621
+ entry.path,
622
+ bytes,
623
+ current.entry.generation,
624
+ );
625
+ if (sidecar.status !== "ok") return false;
626
+ report.adopted.push(entry.path);
627
+ return true;
628
+ }
629
+
630
+ private async pushRemoval(
631
+ root: WorkspaceRootV1,
632
+ removal: ComputerSyncRemovalV1,
633
+ stored: { generations: Map<string, WorkspaceGenerationV1> },
634
+ report: WorkspaceSyncRootReportV1,
635
+ ): Promise<void> {
636
+ const held = stored.generations.get(removal.path);
637
+ if (!held) {
638
+ // Both sides agree the file is gone; the record has done its work.
639
+ await this.options.computer.forget(root, removal.path);
640
+ return;
641
+ }
642
+ const expected = removal.supersedes ?? held.generationId;
643
+ const effect: WorkspaceSyncEffectV1 = {
644
+ effectId: workspaceSyncEffectIdV1(
645
+ root,
646
+ removal.path,
647
+ "remove",
648
+ WORKSPACE_EMPTY_SHA256,
649
+ expected,
650
+ ),
651
+ root,
652
+ path: removal.path,
653
+ kind: "remove",
654
+ contentHash: WORKSPACE_EMPTY_SHA256,
655
+ expectedGenerationId: expected,
656
+ at: this.clock().toISOString(),
657
+ };
658
+ await this.effects.intent(effect);
659
+ let outcome;
660
+ try {
661
+ outcome = await this.options.store.delete({
662
+ path: { root, path: removal.path },
663
+ // Nothing on the Computer can prove who removed the file; see
664
+ // `ComputerSyncRemovalV1`.
665
+ writer: { kind: "unattributed" },
666
+ expectedGenerationId: expected,
667
+ });
668
+ } catch (error) {
669
+ report.failures.push({
670
+ status: "unavailable",
671
+ reason: error instanceof Error ? error.message : String(error),
672
+ root,
673
+ path: removal.path,
674
+ });
675
+ return;
676
+ }
677
+ await this.effects.settle(effect);
678
+ if (outcome.status === "ok" || outcome.status === "not-found") {
679
+ stored.generations.delete(removal.path);
680
+ await this.options.computer.forget(root, removal.path);
681
+ report.removedInStore.push(removal.path);
682
+ return;
683
+ }
684
+ if (outcome.status === "conflict") {
685
+ report.conflicts.push({
686
+ root,
687
+ path: removal.path,
688
+ reason: outcome.reason,
689
+ ...("current" in outcome && outcome.current
690
+ ? { current: outcome.current }
691
+ : {}),
692
+ });
693
+ // The store moved on after the removal was recorded. The pull restores
694
+ // the file; the removal is surfaced rather than applied.
695
+ await this.options.computer.forget(root, removal.path);
696
+ return;
697
+ }
698
+ report.failures.push({ ...outcome, root, path: removal.path });
699
+ }
700
+
701
+ /**
702
+ * Materializes one store generation on the Computer, sidecar included, so
703
+ * `generationOf` there answers with the writer the store recorded rather
704
+ * than `unattributed`.
705
+ */
706
+ private async materialize(
707
+ root: WorkspaceRootV1,
708
+ path: string,
709
+ ): Promise<WorkspaceFailureV1 | undefined> {
710
+ const read = await this.options.store.read({ root, path });
711
+ if (read.status !== "ok") return read;
712
+ const written = await this.options.computer.materialize(
713
+ root,
714
+ path,
715
+ read.file.bytes,
716
+ read.file.generation,
717
+ );
718
+ if (written.status !== "ok") return written;
719
+ return undefined;
720
+ }
721
+
722
+ private async removeLocally(
723
+ root: WorkspaceRootV1,
724
+ entry: ComputerSyncEntryV1,
725
+ report: WorkspaceSyncRootReportV1,
726
+ ): Promise<boolean> {
727
+ const tombstone = await this.storeTombstone(root, entry);
728
+ const removed = await this.options.computer.remove(
729
+ root,
730
+ entry.path,
731
+ entry.recorded?.generationId,
732
+ tombstone,
733
+ );
734
+ if (removed.status !== "ok") {
735
+ report.failures.push({ ...removed, root, path: entry.path });
736
+ return false;
737
+ }
738
+ // The removal is now mirrored on both sides; the record would otherwise be
739
+ // pushed back to the store as a second delete.
740
+ await this.options.computer.forget(root, entry.path);
741
+ return true;
742
+ }
743
+
744
+ /**
745
+ * The tombstone generation to record on the Computer for a store-side
746
+ * delete. The ledger holds the writer when the Durable Object is reachable;
747
+ * otherwise the removal is recorded with no writer, which is the truth.
748
+ */
749
+ private async storeTombstone(
750
+ root: WorkspaceRootV1,
751
+ entry: ComputerSyncEntryV1,
752
+ ): Promise<WorkspaceGenerationV1> {
753
+ if (this.options.generations) {
754
+ try {
755
+ const record = await this.options.generations.current(root, entry.path);
756
+ if (record?.deleted) return record.generation;
757
+ } catch {
758
+ // The ledger is unreachable; record the removal without a writer.
759
+ }
760
+ }
761
+ const at = this.clock();
762
+ return {
763
+ schemaVersion: 1,
764
+ generationId: `${at.getTime().toString().padStart(15, "0")}-sync`,
765
+ contentHash: WORKSPACE_EMPTY_SHA256,
766
+ size: 0,
767
+ writer: { kind: "unattributed" },
768
+ writtenAt: at.toISOString(),
769
+ };
770
+ }
771
+ }
772
+
773
+ /** The durable-root sync of ADR 0013, Computer side. */
774
+ export function createWorkspaceRootSyncV1(
775
+ options: WorkspaceRootSyncOptionsV1,
776
+ ): WorkspaceRootSyncV1 {
777
+ return new WorkspaceRootSync(options);
778
+ }
779
+
780
+ /**
781
+ * Push intents recorded in the Workspace rather than in a Durable Object. §
782
+ * Durable effects wants both; this is the half that is always reachable while
783
+ * the Computer is awake, and the half a sync driven from outside a Bot's
784
+ * Durable Object has.
785
+ */
786
+ export function createWorkspaceSidecarEffectsV1(
787
+ computer: ComputerSyncSurfaceV1,
788
+ ): WorkspaceSyncEffectsV1 {
789
+ return {
790
+ async intent(effect) {
791
+ await computer.note(
792
+ EFFECT_NOTE_KIND,
793
+ effect.effectId,
794
+ JSON.stringify(effect),
795
+ );
796
+ },
797
+ async settle(effect) {
798
+ await computer.clearNote(EFFECT_NOTE_KIND, effect.effectId);
799
+ },
800
+ async pending(effectId) {
801
+ const note = await computer.readNote(EFFECT_NOTE_KIND, effectId);
802
+ if (note.status !== "ok" || !note.text) return undefined;
803
+ try {
804
+ return JSON.parse(note.text) as WorkspaceSyncEffectV1;
805
+ } catch {
806
+ return undefined;
807
+ }
808
+ },
809
+ };
810
+ }
811
+
812
+ export interface FlySpriteSyncSurfaceOptions {
813
+ computer: FlySpriteAgentComputer;
814
+ layout: WorkspaceLayoutV1;
815
+ userId: string;
816
+ botDirectoryKey: (botId: string) => string;
817
+ }
818
+
819
+ /**
820
+ * `ComputerSyncSurfaceV1` over one Fly Sprite, through the same storage exec
821
+ * path `FlyWorkspaceFiles` uses. Every failure is a declared variant: the
822
+ * Sprite pauses, and a dropped connection is an ordinary answer the sync
823
+ * resumes from rather than a failure.
824
+ */
825
+ export class FlySpriteSyncSurface implements ComputerSyncSurfaceV1 {
826
+ constructor(private readonly options: FlySpriteSyncSurfaceOptions) {}
827
+
828
+ private mount(root: WorkspaceRootV1): string | WorkspaceFailureV1 {
829
+ if (root.userId !== this.options.userId) {
830
+ return failure(
831
+ "refused",
832
+ "This Computer belongs to a different User's Workspace",
833
+ );
834
+ }
835
+ try {
836
+ return workspaceMountPathV1(
837
+ this.options.layout,
838
+ root,
839
+ this.options.botDirectoryKey,
840
+ );
841
+ } catch (error) {
842
+ return failure(
843
+ "not-found",
844
+ error instanceof Error ? error.message : String(error),
845
+ );
846
+ }
847
+ }
848
+
849
+ private async run(script: string): Promise<string | WorkspaceFailureV1> {
850
+ try {
851
+ return await this.options.computer.runStorage(
852
+ script,
853
+ new AbortController().signal,
854
+ );
855
+ } catch (error) {
856
+ return failure(
857
+ "unavailable",
858
+ error instanceof Error ? error.message : String(error),
859
+ );
860
+ }
861
+ }
862
+
863
+ private relative(path: string): string | WorkspaceFailureV1 {
864
+ try {
865
+ return normalizeWorkspaceRelativePathV1(path);
866
+ } catch (error) {
867
+ return failure(
868
+ "refused",
869
+ error instanceof Error ? error.message : String(error),
870
+ );
871
+ }
872
+ }
873
+
874
+ private decodeMeta(encoded: string): WorkspaceGenerationV1 | undefined {
875
+ if (!encoded) return undefined;
876
+ try {
877
+ const text = Buffer.from(encoded, "base64").toString("utf8");
878
+ const body = text.slice(text.indexOf("\n") + 1);
879
+ return decodeWorkspaceGenerationV1(JSON.parse(body));
880
+ } catch {
881
+ return undefined;
882
+ }
883
+ }
884
+
885
+ private encodeMeta(generation: WorkspaceGenerationV1): string {
886
+ return Buffer.from(
887
+ `${generation.generationId}\n${JSON.stringify(generation)}`,
888
+ ).toString("base64");
889
+ }
890
+
891
+ async scan(root: WorkspaceRootV1): Promise<ComputerSyncScanOutcomeV1> {
892
+ const mount = this.mount(root);
893
+ if (typeof mount !== "string") return mount;
894
+ const script = [
895
+ `ROOT=${shellQuote(mount)}`,
896
+ `mkdir -p "$ROOT" "$ROOT/${WORKSPACE_GENERATIONS_DIR}" "$ROOT/${WORKSPACE_SYNC_DIR}/${SYNC_TOMBSTONES_DIR}"`,
897
+ `find "$ROOT" -type f ! -path "$ROOT/${WORKSPACE_GENERATIONS_DIR}/*" ! -path "$ROOT/${WORKSPACE_SYNC_DIR}/*" ! -path "$ROOT/.frockbot-locks/*" -print0 | sort -z | while IFS= read -r -d "" FILE; do`,
898
+ ' REL=${FILE#"$ROOT"/}',
899
+ ` META="$ROOT/${WORKSPACE_GENERATIONS_DIR}/$REL"`,
900
+ ' printf "F\\t%s\\t%s\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$({ cat "$META" 2>/dev/null || printf \'\'; } | base64 -w0)" "$(sha256sum "$FILE" | cut -d" " -f1)" "$(stat -c %s "$FILE")"',
901
+ "done",
902
+ `GRAVES="$ROOT/${WORKSPACE_SYNC_DIR}/${SYNC_TOMBSTONES_DIR}"`,
903
+ 'find "$GRAVES" -type f -print0 | sort -z | while IFS= read -r -d "" FILE; do',
904
+ ' REL=${FILE#"$GRAVES"/}',
905
+ ' printf "T\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$(base64 -w0 "$FILE")"',
906
+ "done",
907
+ `METAS="$ROOT/${WORKSPACE_GENERATIONS_DIR}"`,
908
+ 'find "$METAS" -type f -print0 | sort -z | while IFS= read -r -d "" FILE; do',
909
+ ' REL=${FILE#"$METAS"/}',
910
+ ' if [ -f "$ROOT/$REL" ]; then continue; fi',
911
+ ' printf "S\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$(base64 -w0 "$FILE")"',
912
+ "done",
913
+ ].join("\n");
914
+ const output = await this.run(script);
915
+ if (typeof output !== "string") return output;
916
+ const entries: ComputerSyncEntryV1[] = [];
917
+ const removed = new Map<string, ComputerSyncRemovalV1>();
918
+ for (const row of output.split("\n")) {
919
+ if (!row.trim()) continue;
920
+ const [tag, encodedPath, second = "", third = "", fourth = ""] =
921
+ row.split("\t");
922
+ if (!encodedPath) continue;
923
+ const path = Buffer.from(encodedPath, "base64").toString("utf8");
924
+ const relative = this.relative(path);
925
+ if (typeof relative !== "string") continue;
926
+ if (tag === "F") {
927
+ const recorded = this.decodeMeta(second.trim());
928
+ entries.push({
929
+ path: relative,
930
+ contentHash: third.trim(),
931
+ size: Number(fourth.trim()),
932
+ ...(recorded ? { recorded } : {}),
933
+ });
934
+ continue;
935
+ }
936
+ if (tag === "T") {
937
+ // Only the superseded generation id is read. The record's own writer
938
+ // is not: see `ComputerSyncRemovalV1`.
939
+ const text = Buffer.from(second.trim(), "base64").toString("utf8");
940
+ const supersedes = text.slice(0, text.indexOf("\n")).trim();
941
+ removed.set(relative, {
942
+ path: relative,
943
+ ...(supersedes && supersedes !== "null" ? { supersedes } : {}),
944
+ });
945
+ continue;
946
+ }
947
+ if (tag === "S" && !removed.has(relative)) {
948
+ // A sidecar with no file: a shell removed the file it described.
949
+ const recorded = this.decodeMeta(second.trim());
950
+ removed.set(relative, {
951
+ path: relative,
952
+ ...(recorded ? { supersedes: recorded.generationId } : {}),
953
+ });
954
+ }
955
+ }
956
+ return {
957
+ status: "ok",
958
+ scan: { entries, removed: [...removed.values()] },
959
+ };
960
+ }
961
+
962
+ async read(
963
+ root: WorkspaceRootV1,
964
+ path: string,
965
+ ): Promise<ComputerSyncBytesOutcomeV1> {
966
+ const mount = this.mount(root);
967
+ if (typeof mount !== "string") return mount;
968
+ const relative = this.relative(path);
969
+ if (typeof relative !== "string") return relative;
970
+ const script = [
971
+ `ROOT=${shellQuote(mount)}`,
972
+ `REL=${shellQuote(relative)}`,
973
+ 'if [ ! -f "$ROOT/$REL" ]; then echo __MISSING__; exit 0; fi',
974
+ 'base64 -w0 "$ROOT/$REL"; echo',
975
+ ].join("\n");
976
+ const output = await this.run(script);
977
+ if (typeof output !== "string") return output;
978
+ if (output.includes("__MISSING__")) {
979
+ return failure("not-found", `No such Workspace file: ${relative}`);
980
+ }
981
+ return {
982
+ status: "ok",
983
+ bytes: Uint8Array.from(Buffer.from(output.trim(), "base64")),
984
+ };
985
+ }
986
+
987
+ async materialize(
988
+ root: WorkspaceRootV1,
989
+ path: string,
990
+ bytes: Uint8Array,
991
+ generation: WorkspaceGenerationV1,
992
+ ): Promise<ComputerSyncOutcomeV1> {
993
+ const mount = this.mount(root);
994
+ if (typeof mount !== "string") return mount;
995
+ const relative = this.relative(path);
996
+ if (typeof relative !== "string") return relative;
997
+ const script = [
998
+ "set -eu",
999
+ `ROOT=${shellQuote(mount)}`,
1000
+ `REL=${shellQuote(relative)}`,
1001
+ 'TARGET="$ROOT/$REL"',
1002
+ `META="$ROOT/${WORKSPACE_GENERATIONS_DIR}/$REL"`,
1003
+ `GRAVE="$ROOT/${WORKSPACE_SYNC_DIR}/${SYNC_TOMBSTONES_DIR}/$REL"`,
1004
+ 'mkdir -p "$(dirname "$TARGET")" "$(dirname "$META")"',
1005
+ 'TMP=$(mktemp "${TARGET}.XXXXXX")',
1006
+ `printf %s ${shellQuote(Buffer.from(bytes).toString("base64"))} | base64 -d > "$TMP"`,
1007
+ 'chmod 600 "$TMP"',
1008
+ 'mv "$TMP" "$TARGET"',
1009
+ 'MTMP=$(mktemp "${META}.XXXXXX")',
1010
+ `printf %s ${shellQuote(this.encodeMeta(generation))} | base64 -d > "$MTMP"`,
1011
+ 'chmod 600 "$MTMP"',
1012
+ 'mv "$MTMP" "$META"',
1013
+ 'rm -f "$GRAVE"',
1014
+ "echo __SYNCED__",
1015
+ ].join("\n");
1016
+ const output = await this.run(script);
1017
+ if (typeof output !== "string") return output;
1018
+ if (!output.includes("__SYNCED__")) {
1019
+ return failure("unavailable", "Invalid Fly Workspace sync response");
1020
+ }
1021
+ return { status: "ok" };
1022
+ }
1023
+
1024
+ async remove(
1025
+ root: WorkspaceRootV1,
1026
+ path: string,
1027
+ supersedes: string | undefined,
1028
+ tombstone: WorkspaceGenerationV1,
1029
+ ): Promise<ComputerSyncOutcomeV1> {
1030
+ const mount = this.mount(root);
1031
+ if (typeof mount !== "string") return mount;
1032
+ const relative = this.relative(path);
1033
+ if (typeof relative !== "string") return relative;
1034
+ const record = Buffer.from(
1035
+ `${supersedes ?? ""}\n${JSON.stringify(tombstone)}`,
1036
+ ).toString("base64");
1037
+ const script = [
1038
+ "set -eu",
1039
+ `ROOT=${shellQuote(mount)}`,
1040
+ `REL=${shellQuote(relative)}`,
1041
+ 'TARGET="$ROOT/$REL"',
1042
+ `META="$ROOT/${WORKSPACE_GENERATIONS_DIR}/$REL"`,
1043
+ `GRAVE="$ROOT/${WORKSPACE_SYNC_DIR}/${SYNC_TOMBSTONES_DIR}/$REL"`,
1044
+ 'mkdir -p "$(dirname "$GRAVE")"',
1045
+ 'rm -f "$TARGET" "$META"',
1046
+ 'GTMP=$(mktemp "${GRAVE}.XXXXXX")',
1047
+ `printf %s ${shellQuote(record)} | base64 -d > "$GTMP"`,
1048
+ 'chmod 600 "$GTMP"',
1049
+ 'mv "$GTMP" "$GRAVE"',
1050
+ "echo __REMOVED__",
1051
+ ].join("\n");
1052
+ const output = await this.run(script);
1053
+ if (typeof output !== "string") return output;
1054
+ if (!output.includes("__REMOVED__")) {
1055
+ return failure("unavailable", "Invalid Fly Workspace sync response");
1056
+ }
1057
+ return { status: "ok" };
1058
+ }
1059
+
1060
+ async forget(
1061
+ root: WorkspaceRootV1,
1062
+ path: string,
1063
+ ): Promise<ComputerSyncOutcomeV1> {
1064
+ const mount = this.mount(root);
1065
+ if (typeof mount !== "string") return mount;
1066
+ const relative = this.relative(path);
1067
+ if (typeof relative !== "string") return relative;
1068
+ const script = [
1069
+ `ROOT=${shellQuote(mount)}`,
1070
+ `REL=${shellQuote(relative)}`,
1071
+ `rm -f "$ROOT/${WORKSPACE_SYNC_DIR}/${SYNC_TOMBSTONES_DIR}/$REL"`,
1072
+ // The sidecar of a file that is gone is itself a removal record, so it
1073
+ // goes with the tombstone and not before it.
1074
+ `if [ ! -f "$ROOT/$REL" ]; then rm -f "$ROOT/${WORKSPACE_GENERATIONS_DIR}/$REL"; fi`,
1075
+ "echo __FORGOTTEN__",
1076
+ ].join("\n");
1077
+ const output = await this.run(script);
1078
+ if (typeof output !== "string") return output;
1079
+ return { status: "ok" };
1080
+ }
1081
+
1082
+ async preserve(
1083
+ root: WorkspaceRootV1,
1084
+ path: string,
1085
+ bytes: Uint8Array,
1086
+ generation: WorkspaceGenerationV1,
1087
+ ): Promise<ComputerSyncOutcomeV1> {
1088
+ const mount = this.mount(root);
1089
+ if (typeof mount !== "string") return mount;
1090
+ const relative = this.relative(path);
1091
+ if (typeof relative !== "string") return relative;
1092
+ const script = [
1093
+ "set -eu",
1094
+ `ROOT=${shellQuote(mount)}`,
1095
+ `REL=${shellQuote(relative)}`,
1096
+ `KEPT="$ROOT/${WORKSPACE_SYNC_DIR}/${SYNC_CONFLICTS_DIR}/$REL/${generation.generationId}"`,
1097
+ 'mkdir -p "$(dirname "$KEPT")"',
1098
+ `printf %s ${shellQuote(Buffer.from(bytes).toString("base64"))} | base64 -d > "$KEPT"`,
1099
+ 'chmod 600 "$KEPT"',
1100
+ "echo __PRESERVED__",
1101
+ ].join("\n");
1102
+ const output = await this.run(script);
1103
+ if (typeof output !== "string") return output;
1104
+ if (!output.includes("__PRESERVED__")) {
1105
+ return failure("unavailable", "Invalid Fly Workspace sync response");
1106
+ }
1107
+ return { status: "ok" };
1108
+ }
1109
+
1110
+ private notePath(kind: string, id: string): string | WorkspaceFailureV1 {
1111
+ const relative = this.relative(`${kind}/${id}`);
1112
+ if (typeof relative !== "string") return relative;
1113
+ return `${this.options.layout.home}/${SYNC_NOTES_DIR}/${relative}`;
1114
+ }
1115
+
1116
+ async note(
1117
+ kind: string,
1118
+ id: string,
1119
+ text: string,
1120
+ ): Promise<ComputerSyncOutcomeV1> {
1121
+ const path = this.notePath(kind, id);
1122
+ if (typeof path !== "string") return path;
1123
+ const script = [
1124
+ "set -eu",
1125
+ `NOTE=${shellQuote(path)}`,
1126
+ 'mkdir -p "$(dirname "$NOTE")"',
1127
+ `printf %s ${shellQuote(Buffer.from(text).toString("base64"))} | base64 -d > "$NOTE"`,
1128
+ 'chmod 600 "$NOTE"',
1129
+ "echo __NOTED__",
1130
+ ].join("\n");
1131
+ const output = await this.run(script);
1132
+ if (typeof output !== "string") return output;
1133
+ return { status: "ok" };
1134
+ }
1135
+
1136
+ async readNote(kind: string, id: string): Promise<ComputerSyncNoteOutcomeV1> {
1137
+ const path = this.notePath(kind, id);
1138
+ if (typeof path !== "string") return path;
1139
+ const script = [
1140
+ `NOTE=${shellQuote(path)}`,
1141
+ 'if [ ! -f "$NOTE" ]; then echo __MISSING__; exit 0; fi',
1142
+ 'base64 -w0 "$NOTE"; echo',
1143
+ ].join("\n");
1144
+ const output = await this.run(script);
1145
+ if (typeof output !== "string") return output;
1146
+ if (output.includes("__MISSING__")) return { status: "ok" };
1147
+ return {
1148
+ status: "ok",
1149
+ text: Buffer.from(output.trim(), "base64").toString("utf8"),
1150
+ };
1151
+ }
1152
+
1153
+ async clearNote(kind: string, id: string): Promise<ComputerSyncOutcomeV1> {
1154
+ const path = this.notePath(kind, id);
1155
+ if (typeof path !== "string") return path;
1156
+ const output = await this.run(
1157
+ [`NOTE=${shellQuote(path)}`, 'rm -f "$NOTE"', "echo __CLEARED__"].join(
1158
+ "\n",
1159
+ ),
1160
+ );
1161
+ if (typeof output !== "string") return output;
1162
+ return { status: "ok" };
1163
+ }
1164
+
1165
+ async signal(): Promise<ComputerSyncNoteOutcomeV1> {
1166
+ const script = [
1167
+ `SIGNAL=${shellQuote(`${this.options.layout.home}/${SYNC_NOTES_DIR}/signal`)}`,
1168
+ 'if [ ! -f "$SIGNAL" ]; then echo __MISSING__; exit 0; fi',
1169
+ 'cat "$SIGNAL"',
1170
+ ].join("\n");
1171
+ const output = await this.run(script);
1172
+ if (typeof output !== "string") return output;
1173
+ if (output.includes("__MISSING__")) return { status: "ok" };
1174
+ return { status: "ok", text: output.trim() };
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * The durable roots one Computer's layout declares for a User and the Bots
1180
+ * that are tenants on it. `package-declared` roots are named by Package and
1181
+ * root id rather than by the layout template, so a caller that has installed
1182
+ * Packages supplies them.
1183
+ */
1184
+ export function declaredWorkspaceRootsV1(
1185
+ layout: WorkspaceLayoutV1,
1186
+ owner: {
1187
+ userId: string;
1188
+ botIds: readonly string[];
1189
+ projectIds?: readonly string[];
1190
+ packageRoots?: readonly { packageId: string; rootId: string }[];
1191
+ },
1192
+ ): WorkspaceRootV1[] {
1193
+ const roots: WorkspaceRootV1[] = [];
1194
+ const declares = (kind: WorkspaceRootV1["kind"]): boolean =>
1195
+ layout.roots.some((declaration) => declaration.kind === kind);
1196
+ for (const botId of owner.botIds) {
1197
+ if (declares("bot-instructions")) {
1198
+ roots.push({ kind: "bot-instructions", userId: owner.userId, botId });
1199
+ }
1200
+ if (declares("bot-memory")) {
1201
+ roots.push({ kind: "bot-memory", userId: owner.userId, botId });
1202
+ }
1203
+ }
1204
+ if (declares("user-instructions")) {
1205
+ roots.push({ kind: "user-instructions", userId: owner.userId });
1206
+ }
1207
+ if (declares("user-memory")) {
1208
+ roots.push({ kind: "user-memory", userId: owner.userId });
1209
+ }
1210
+ if (declares("project-memory")) {
1211
+ for (const projectId of owner.projectIds ?? []) {
1212
+ roots.push({ kind: "project-memory", userId: owner.userId, projectId });
1213
+ }
1214
+ }
1215
+ if (declares("package-declared")) {
1216
+ for (const declared of owner.packageRoots ?? []) {
1217
+ roots.push({
1218
+ kind: "package-declared",
1219
+ userId: owner.userId,
1220
+ packageId: declared.packageId,
1221
+ rootId: declared.rootId,
1222
+ });
1223
+ }
1224
+ }
1225
+ return roots;
1226
+ }
1227
+
1228
+ export interface FlySpriteSyncOptionsV1 extends Omit<
1229
+ WorkspaceRootSyncOptionsV1,
1230
+ "computer" | "roots"
1231
+ > {
1232
+ computer: FlySpriteAgentComputer;
1233
+ layout: WorkspaceLayoutV1;
1234
+ userId: string;
1235
+ botDirectoryKey: (botId: string) => string;
1236
+ /** Every root this Computer syncs; the layout's own roots when absent. */
1237
+ roots?: WorkspaceRootV1[];
1238
+ botIds?: readonly string[];
1239
+ projectIds?: readonly string[];
1240
+ packageRoots?: readonly { packageId: string; rootId: string }[];
1241
+ }
1242
+
1243
+ /** The durable-root sync, wired to one Fly Sprite. */
1244
+ export function createFlySpriteSyncV1(
1245
+ options: FlySpriteSyncOptionsV1,
1246
+ ): WorkspaceRootSyncV1 {
1247
+ const {
1248
+ computer,
1249
+ layout,
1250
+ userId,
1251
+ botDirectoryKey,
1252
+ roots,
1253
+ botIds,
1254
+ projectIds,
1255
+ packageRoots,
1256
+ ...rest
1257
+ } = options;
1258
+ return createWorkspaceRootSyncV1({
1259
+ ...rest,
1260
+ computer: new FlySpriteSyncSurface({
1261
+ computer,
1262
+ layout,
1263
+ userId,
1264
+ botDirectoryKey,
1265
+ }),
1266
+ roots:
1267
+ roots ??
1268
+ declaredWorkspaceRootsV1(layout, {
1269
+ userId,
1270
+ botIds: botIds ?? [computer.botId],
1271
+ ...(projectIds ? { projectIds } : {}),
1272
+ ...(packageRoots ? { packageRoots } : {}),
1273
+ }),
1274
+ });
1275
+ }