@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/frockbot.json +16 -0
- package/package.json +42 -6
- package/src/agent.ts +6 -0
- package/src/computer.test.ts +562 -0
- package/src/computer.ts +1394 -0
- package/src/doctor.test.ts +154 -0
- package/src/host-client.test.ts +618 -0
- package/src/host-client.ts +794 -0
- package/src/host-double.ts +268 -0
- package/src/host.ts +331 -0
- package/src/index.ts +7 -0
- package/src/manifest.ts +3 -0
- package/src/processes.test.ts +176 -0
- package/src/provider.ts +591 -0
- package/src/screenshot.test.ts +145 -0
- package/src/sync.test.ts +1060 -0
- package/src/sync.ts +1275 -0
- package/src/workspace.test.ts +928 -0
- package/src/workspace.ts +782 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
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
|
+
}
|