@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.test.ts
ADDED
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
isLoadableSkillSourceV1,
|
|
7
|
+
type WorkspaceFilesV1,
|
|
8
|
+
type WorkspaceSyncEffectV1,
|
|
9
|
+
type WorkspaceSyncEffectsV1,
|
|
10
|
+
type WorkspaceGenerationV1,
|
|
11
|
+
type WorkspaceRootV1,
|
|
12
|
+
type WorkspaceWriterV1,
|
|
13
|
+
} from "@frockbot/kernel-contracts";
|
|
14
|
+
import { createObjectWorkspaceFilesV1 } from "@frockbot/workspace-store";
|
|
15
|
+
import {
|
|
16
|
+
createInMemoryObjectBucketV1,
|
|
17
|
+
createInMemoryWorkspaceGenerationsV1,
|
|
18
|
+
} from "@frockbot/workspace-store/testing";
|
|
19
|
+
import { computerBotKey, FlySpriteComputer } from "./computer.ts";
|
|
20
|
+
import { FakeComputerHost, type FakeComputerRunV1 } from "./host-double.ts";
|
|
21
|
+
import { FLY_WORKSPACE_LAYOUT, FlySpriteComputerProvider } from "./provider.ts";
|
|
22
|
+
import {
|
|
23
|
+
createFlySpriteSyncV1,
|
|
24
|
+
declaredWorkspaceRootsV1,
|
|
25
|
+
type WorkspaceSyncReportV1,
|
|
26
|
+
} from "./sync.ts";
|
|
27
|
+
import { WORKSPACE_EMPTY_SHA256 } from "./workspace.ts";
|
|
28
|
+
|
|
29
|
+
const USER = "owner";
|
|
30
|
+
const BOT = "health";
|
|
31
|
+
const encoder = new TextEncoder();
|
|
32
|
+
const decoder = new TextDecoder();
|
|
33
|
+
|
|
34
|
+
const skillsRoot: WorkspaceRootV1 = {
|
|
35
|
+
kind: "bot-instructions",
|
|
36
|
+
userId: USER,
|
|
37
|
+
botId: BOT,
|
|
38
|
+
};
|
|
39
|
+
const botMemoryRoot: WorkspaceRootV1 = {
|
|
40
|
+
kind: "bot-memory",
|
|
41
|
+
userId: USER,
|
|
42
|
+
botId: BOT,
|
|
43
|
+
};
|
|
44
|
+
const userMemoryRoot: WorkspaceRootV1 = { kind: "user-memory", userId: USER };
|
|
45
|
+
const userSkillsRoot: WorkspaceRootV1 = {
|
|
46
|
+
kind: "user-instructions",
|
|
47
|
+
userId: USER,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const BOT_WRITER: WorkspaceWriterV1 = {
|
|
51
|
+
kind: "bot",
|
|
52
|
+
botId: BOT,
|
|
53
|
+
sessionId: "session-1",
|
|
54
|
+
turnId: "turn-1",
|
|
55
|
+
runId: "run-1",
|
|
56
|
+
};
|
|
57
|
+
const USER_WRITER: WorkspaceWriterV1 = { kind: "user", userId: USER };
|
|
58
|
+
|
|
59
|
+
function sha256(bytes: Uint8Array): string {
|
|
60
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function quoted(shell: string, name: string): string | undefined {
|
|
64
|
+
return new RegExp(`${name}='([^']*)'`).exec(shell)?.[1];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function payload(shell: string, target: string): string | undefined {
|
|
68
|
+
return new RegExp(`printf %s '([^']*)' \\| base64 -d > "\\$${target}"`).exec(
|
|
69
|
+
shell,
|
|
70
|
+
)?.[1];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The Computer's durable filesystem as an in-memory map, interpreting the
|
|
75
|
+
* shell the sync emits rather than running it — the scripts are GNU coreutils
|
|
76
|
+
* and the test host is not. Every path the sync touches is an absolute path in
|
|
77
|
+
* `files`, exactly as it would be on the Sprite.
|
|
78
|
+
*
|
|
79
|
+
* It is the runner behind a `FakeComputerHost` (ADR 0004): the sync's bash now
|
|
80
|
+
* travels to the shared host on a command's stdin rather than on a Sprite
|
|
81
|
+
* argv, so the same script text arrives here as one string.
|
|
82
|
+
*/
|
|
83
|
+
class FakeSyncSprite {
|
|
84
|
+
readonly files = new Map<string, Uint8Array>();
|
|
85
|
+
/** Set to fail every storage call, as a paused Sprite does. */
|
|
86
|
+
paused = false;
|
|
87
|
+
/** Drops the next sidecar write, as a pause between store and Computer does. */
|
|
88
|
+
dropNextMaterialize = false;
|
|
89
|
+
|
|
90
|
+
/** Runs one script for the host double. */
|
|
91
|
+
readonly run = (script: string): FakeComputerRunV1 => {
|
|
92
|
+
// A paused Sprite answers nothing, and the host reports the failed exit;
|
|
93
|
+
// the provider turns that into `Sprite storage operation failed: …`.
|
|
94
|
+
if (this.paused) return { exitCode: 1, stderr: "Sprite is paused" };
|
|
95
|
+
return { stdout: this.interpret(script) };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Writes a file the way a shell command on the Computer would: no sidecar. */
|
|
99
|
+
shellWrite(root: string, relative: string, text: string): void {
|
|
100
|
+
this.files.set(`${root}/${relative}`, encoder.encode(text));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
shellRemove(root: string, relative: string): void {
|
|
104
|
+
this.files.delete(`${root}/${relative}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
text(path: string): string | undefined {
|
|
108
|
+
const bytes = this.files.get(path);
|
|
109
|
+
return bytes ? decoder.decode(bytes) : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
keys(prefix = ""): string[] {
|
|
113
|
+
return [...this.files.keys()]
|
|
114
|
+
.filter((key) => key.startsWith(prefix))
|
|
115
|
+
.sort();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private interpret(shell: string): string {
|
|
119
|
+
const root = quoted(shell, "ROOT");
|
|
120
|
+
const relative = quoted(shell, "REL");
|
|
121
|
+
if (shell.includes('printf "F\\t')) return this.scan(root ?? "");
|
|
122
|
+
if (root && relative) {
|
|
123
|
+
if (shell.includes("__SYNCED__")) {
|
|
124
|
+
if (this.dropNextMaterialize) {
|
|
125
|
+
this.dropNextMaterialize = false;
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
return this.materialize(root, relative, shell);
|
|
129
|
+
}
|
|
130
|
+
if (shell.includes("__REMOVED__"))
|
|
131
|
+
return this.remove(root, relative, shell);
|
|
132
|
+
if (shell.includes("__FORGOTTEN__")) return this.forget(root, relative);
|
|
133
|
+
if (shell.includes("__PRESERVED__"))
|
|
134
|
+
return this.preserve(root, relative, shell);
|
|
135
|
+
return this.read(`${root}/${relative}`);
|
|
136
|
+
}
|
|
137
|
+
const note = quoted(shell, "NOTE");
|
|
138
|
+
if (note) {
|
|
139
|
+
if (shell.includes("__NOTED__")) {
|
|
140
|
+
const bytes = payload(shell, "NOTE") ?? "";
|
|
141
|
+
this.files.set(note, Uint8Array.from(Buffer.from(bytes, "base64")));
|
|
142
|
+
return "__NOTED__\n";
|
|
143
|
+
}
|
|
144
|
+
if (shell.includes("__CLEARED__")) {
|
|
145
|
+
this.files.delete(note);
|
|
146
|
+
return "__CLEARED__\n";
|
|
147
|
+
}
|
|
148
|
+
return this.read(note);
|
|
149
|
+
}
|
|
150
|
+
const signal = quoted(shell, "SIGNAL");
|
|
151
|
+
if (signal) return this.read(signal, false);
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private read(path: string, base64 = true): string {
|
|
156
|
+
const bytes = this.files.get(path);
|
|
157
|
+
if (!bytes) return "__MISSING__\n";
|
|
158
|
+
return base64
|
|
159
|
+
? `${Buffer.from(bytes).toString("base64")}\n`
|
|
160
|
+
: `${decoder.decode(bytes)}\n`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private scan(root: string): string {
|
|
164
|
+
const rows: string[] = [];
|
|
165
|
+
const generations = `${root}/.frockbot-generations/`;
|
|
166
|
+
const graves = `${root}/.frockbot-sync/tombstones/`;
|
|
167
|
+
for (const [path, bytes] of [...this.files].sort()) {
|
|
168
|
+
if (!path.startsWith(`${root}/`)) continue;
|
|
169
|
+
const relative = path.slice(root.length + 1);
|
|
170
|
+
if (relative.startsWith(".frockbot-")) continue;
|
|
171
|
+
const meta = this.files.get(`${generations}${relative}`);
|
|
172
|
+
rows.push(
|
|
173
|
+
[
|
|
174
|
+
"F",
|
|
175
|
+
Buffer.from(relative).toString("base64"),
|
|
176
|
+
meta ? Buffer.from(meta).toString("base64") : "",
|
|
177
|
+
sha256(bytes),
|
|
178
|
+
String(bytes.byteLength),
|
|
179
|
+
].join("\t"),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
for (const [path, bytes] of [...this.files].sort()) {
|
|
183
|
+
if (path.startsWith(graves)) {
|
|
184
|
+
rows.push(
|
|
185
|
+
[
|
|
186
|
+
"T",
|
|
187
|
+
Buffer.from(path.slice(graves.length)).toString("base64"),
|
|
188
|
+
Buffer.from(bytes).toString("base64"),
|
|
189
|
+
].join("\t"),
|
|
190
|
+
);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!path.startsWith(generations)) continue;
|
|
194
|
+
const relative = path.slice(generations.length);
|
|
195
|
+
if (this.files.has(`${root}/${relative}`)) continue;
|
|
196
|
+
rows.push(
|
|
197
|
+
[
|
|
198
|
+
"S",
|
|
199
|
+
Buffer.from(relative).toString("base64"),
|
|
200
|
+
Buffer.from(bytes).toString("base64"),
|
|
201
|
+
].join("\t"),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return rows.length ? `${rows.join("\n")}\n` : "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private materialize(root: string, relative: string, shell: string): string {
|
|
208
|
+
const bytes = payload(shell, "TMP") ?? "";
|
|
209
|
+
const meta = payload(shell, "MTMP") ?? "";
|
|
210
|
+
this.files.set(
|
|
211
|
+
`${root}/${relative}`,
|
|
212
|
+
Uint8Array.from(Buffer.from(bytes, "base64")),
|
|
213
|
+
);
|
|
214
|
+
this.files.set(
|
|
215
|
+
`${root}/.frockbot-generations/${relative}`,
|
|
216
|
+
Uint8Array.from(Buffer.from(meta, "base64")),
|
|
217
|
+
);
|
|
218
|
+
this.files.delete(`${root}/.frockbot-sync/tombstones/${relative}`);
|
|
219
|
+
return "__SYNCED__\n";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private remove(root: string, relative: string, shell: string): string {
|
|
223
|
+
const record = payload(shell, "GTMP") ?? "";
|
|
224
|
+
this.files.delete(`${root}/${relative}`);
|
|
225
|
+
this.files.delete(`${root}/.frockbot-generations/${relative}`);
|
|
226
|
+
this.files.set(
|
|
227
|
+
`${root}/.frockbot-sync/tombstones/${relative}`,
|
|
228
|
+
Uint8Array.from(Buffer.from(record, "base64")),
|
|
229
|
+
);
|
|
230
|
+
return "__REMOVED__\n";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private forget(root: string, relative: string): string {
|
|
234
|
+
this.files.delete(`${root}/.frockbot-sync/tombstones/${relative}`);
|
|
235
|
+
if (!this.files.has(`${root}/${relative}`)) {
|
|
236
|
+
this.files.delete(`${root}/.frockbot-generations/${relative}`);
|
|
237
|
+
}
|
|
238
|
+
return "__FORGOTTEN__\n";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private preserve(root: string, relative: string, shell: string): string {
|
|
242
|
+
const generationId =
|
|
243
|
+
/conflicts\/\$REL\/([^"]*)"/.exec(shell)?.[1] ?? "unknown";
|
|
244
|
+
const bytes = payload(shell, "KEPT") ?? "";
|
|
245
|
+
this.files.set(
|
|
246
|
+
`${root}/.frockbot-sync/conflicts/${relative}/${generationId}`,
|
|
247
|
+
Uint8Array.from(Buffer.from(bytes, "base64")),
|
|
248
|
+
);
|
|
249
|
+
return "__PRESERVED__\n";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One Computer whose disk is `sprite`, reached through the shared host. */
|
|
254
|
+
function attach(sprite: FakeSyncSprite): FlySpriteComputer {
|
|
255
|
+
return new FlySpriteComputer({
|
|
256
|
+
identity: { userId: "sync-user" },
|
|
257
|
+
host: new FakeComputerHost(sprite.run).factory,
|
|
258
|
+
spriteName: "frockbot-test",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const MOUNTS = {
|
|
263
|
+
skills: `/home/box/agent-data/agents/${computerBotKey(BOT)}/skills`,
|
|
264
|
+
userSkills: "/home/box/agent-data/workflows",
|
|
265
|
+
botMemory: `/home/box/agent-data/agents/${computerBotKey(BOT)}/memory`,
|
|
266
|
+
userMemory: "/home/box/agent-data/user-memory",
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
interface Harness {
|
|
270
|
+
sprite: FakeSyncSprite;
|
|
271
|
+
store: WorkspaceFilesV1;
|
|
272
|
+
memory: WorkspaceFilesV1;
|
|
273
|
+
generations: ReturnType<typeof createInMemoryWorkspaceGenerationsV1>;
|
|
274
|
+
bucket: ReturnType<typeof createInMemoryObjectBucketV1>;
|
|
275
|
+
sync: () => Promise<WorkspaceSyncReportV1>;
|
|
276
|
+
agent: ReturnType<typeof createFlySpriteSyncV1>;
|
|
277
|
+
roots: WorkspaceRootV1[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function harness(
|
|
281
|
+
options: {
|
|
282
|
+
store?: (files: WorkspaceFilesV1) => WorkspaceFilesV1;
|
|
283
|
+
} = {},
|
|
284
|
+
): Harness {
|
|
285
|
+
const bucket = createInMemoryObjectBucketV1();
|
|
286
|
+
const generations = createInMemoryWorkspaceGenerationsV1();
|
|
287
|
+
const owner = { userId: USER };
|
|
288
|
+
const store = createObjectWorkspaceFilesV1({
|
|
289
|
+
bucket,
|
|
290
|
+
generations,
|
|
291
|
+
owner,
|
|
292
|
+
surface: "sync",
|
|
293
|
+
});
|
|
294
|
+
const memory = createObjectWorkspaceFilesV1({
|
|
295
|
+
bucket,
|
|
296
|
+
generations,
|
|
297
|
+
owner,
|
|
298
|
+
surface: "memory",
|
|
299
|
+
});
|
|
300
|
+
const sprite = new FakeSyncSprite();
|
|
301
|
+
const computer = attach(sprite).bot(BOT);
|
|
302
|
+
const roots = declaredWorkspaceRootsV1(FLY_WORKSPACE_LAYOUT, {
|
|
303
|
+
userId: USER,
|
|
304
|
+
botIds: [BOT],
|
|
305
|
+
});
|
|
306
|
+
const agent = createFlySpriteSyncV1({
|
|
307
|
+
computer,
|
|
308
|
+
layout: FLY_WORKSPACE_LAYOUT,
|
|
309
|
+
userId: USER,
|
|
310
|
+
botDirectoryKey: computerBotKey,
|
|
311
|
+
store: options.store ? options.store(store) : store,
|
|
312
|
+
generations,
|
|
313
|
+
roots,
|
|
314
|
+
});
|
|
315
|
+
return {
|
|
316
|
+
sprite,
|
|
317
|
+
store,
|
|
318
|
+
memory,
|
|
319
|
+
generations,
|
|
320
|
+
bucket,
|
|
321
|
+
roots,
|
|
322
|
+
agent,
|
|
323
|
+
sync: () => agent.sync(),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Removes a file the way the Workspace file surface's `delete` does — the file
|
|
329
|
+
* and its sidecar gone, a tombstone record left behind whose first line is the
|
|
330
|
+
* generation the removal superseded. A shell can write exactly this too, which
|
|
331
|
+
* is the point of the tests that use it.
|
|
332
|
+
*/
|
|
333
|
+
function shellTombstone(
|
|
334
|
+
sprite: FakeSyncSprite,
|
|
335
|
+
mount: string,
|
|
336
|
+
relative: string,
|
|
337
|
+
supersedes: string,
|
|
338
|
+
tombstone: WorkspaceGenerationV1,
|
|
339
|
+
): void {
|
|
340
|
+
sprite.files.delete(`${mount}/${relative}`);
|
|
341
|
+
sprite.files.delete(`${mount}/.frockbot-generations/${relative}`);
|
|
342
|
+
sprite.files.set(
|
|
343
|
+
`${mount}/.frockbot-sync/tombstones/${relative}`,
|
|
344
|
+
encoder.encode(`${supersedes}\n${JSON.stringify(tombstone)}`),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function writeToStore(
|
|
349
|
+
files: WorkspaceFilesV1,
|
|
350
|
+
root: WorkspaceRootV1,
|
|
351
|
+
path: string,
|
|
352
|
+
text: string,
|
|
353
|
+
writer: WorkspaceWriterV1,
|
|
354
|
+
expected: string | null = null,
|
|
355
|
+
): Promise<WorkspaceGenerationV1> {
|
|
356
|
+
const outcome = await files.write({
|
|
357
|
+
path: { root, path },
|
|
358
|
+
bytes: encoder.encode(text),
|
|
359
|
+
writer,
|
|
360
|
+
expectedGenerationId: expected,
|
|
361
|
+
});
|
|
362
|
+
if (outcome.status !== "ok") throw new Error(outcome.reason);
|
|
363
|
+
return outcome.generation;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
describe("the durable-root sync, store to Computer", () => {
|
|
367
|
+
// Constitution — Computer and Workspace: "durable roots ... survive
|
|
368
|
+
// hibernation, cold start, host migration, and image rebuild."
|
|
369
|
+
test("a cold start with an empty disk repopulates every declared root", async () => {
|
|
370
|
+
const { sprite, store, memory, sync, roots } = harness();
|
|
371
|
+
|
|
372
|
+
await writeToStore(
|
|
373
|
+
store,
|
|
374
|
+
skillsRoot,
|
|
375
|
+
"deploy/SKILL.md",
|
|
376
|
+
"# deploy",
|
|
377
|
+
BOT_WRITER,
|
|
378
|
+
);
|
|
379
|
+
await writeToStore(
|
|
380
|
+
memory,
|
|
381
|
+
botMemoryRoot,
|
|
382
|
+
"profile.md",
|
|
383
|
+
"likes tea",
|
|
384
|
+
BOT_WRITER,
|
|
385
|
+
);
|
|
386
|
+
await writeToStore(
|
|
387
|
+
memory,
|
|
388
|
+
userMemoryRoot,
|
|
389
|
+
`by-agent/${BOT}/profile.md`,
|
|
390
|
+
"user fact",
|
|
391
|
+
BOT_WRITER,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
expect(sprite.files.size).toBe(0);
|
|
395
|
+
const report = await sync();
|
|
396
|
+
|
|
397
|
+
expect(report.failures).toEqual([]);
|
|
398
|
+
// The Bot's instruction root and Bot Memory root, the User-global
|
|
399
|
+
// instruction root, and User Memory.
|
|
400
|
+
expect(roots).toHaveLength(4);
|
|
401
|
+
expect(sprite.text(`${MOUNTS.skills}/deploy/SKILL.md`)).toBe("# deploy");
|
|
402
|
+
expect(sprite.text(`${MOUNTS.botMemory}/profile.md`)).toBe("likes tea");
|
|
403
|
+
expect(sprite.text(`${MOUNTS.userMemory}/by-agent/${BOT}/profile.md`)).toBe(
|
|
404
|
+
"user fact",
|
|
405
|
+
);
|
|
406
|
+
expect(report.roots.flatMap((entry) => entry.pulled)).toEqual([
|
|
407
|
+
"deploy/SKILL.md",
|
|
408
|
+
"profile.md",
|
|
409
|
+
`by-agent/${BOT}/profile.md`,
|
|
410
|
+
]);
|
|
411
|
+
|
|
412
|
+
// A second run with nothing changed moves nothing: the sidecar records the
|
|
413
|
+
// generation, so "already here" is a comparison and not a re-download.
|
|
414
|
+
const again = await sync();
|
|
415
|
+
expect(again.roots.flatMap((entry) => entry.pulled)).toEqual([]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Constitution — Minimal kernel: "Only Skills under the Bot's own instruction
|
|
419
|
+
// root, written under the Bot's own authority or its User's, are loaded as
|
|
420
|
+
// instructions." A materialized Skill keeps the writer the store recorded,
|
|
421
|
+
// so the loader's predicate can still answer.
|
|
422
|
+
test("a Skill written through the store appears under the instruction root with its writer", async () => {
|
|
423
|
+
const { sprite, store, sync } = harness();
|
|
424
|
+
const generation = await writeToStore(
|
|
425
|
+
store,
|
|
426
|
+
skillsRoot,
|
|
427
|
+
"deploy/SKILL.md",
|
|
428
|
+
"---\nname: deploy\n---\n",
|
|
429
|
+
BOT_WRITER,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
await sync();
|
|
433
|
+
|
|
434
|
+
const meta = sprite.text(
|
|
435
|
+
`${MOUNTS.skills}/.frockbot-generations/deploy/SKILL.md`,
|
|
436
|
+
);
|
|
437
|
+
expect(meta?.split("\n")[0]).toBe(generation.generationId);
|
|
438
|
+
const recorded = JSON.parse(
|
|
439
|
+
meta?.slice(meta.indexOf("\n") + 1) ?? "{}",
|
|
440
|
+
) as WorkspaceGenerationV1;
|
|
441
|
+
expect(recorded.writer).toEqual(BOT_WRITER);
|
|
442
|
+
expect(
|
|
443
|
+
isLoadableSkillSourceV1(
|
|
444
|
+
{
|
|
445
|
+
path: { root: skillsRoot, path: "deploy/SKILL.md" },
|
|
446
|
+
writer: recorded.writer,
|
|
447
|
+
generation: recorded,
|
|
448
|
+
},
|
|
449
|
+
{ botId: BOT, userId: USER },
|
|
450
|
+
),
|
|
451
|
+
).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ADR 0013: "Deleting a file removes the object and records a tombstone
|
|
455
|
+
// generation" — and the removal reaches the Computer rather than the file
|
|
456
|
+
// quietly reappearing on the next pull.
|
|
457
|
+
test("a delete in the store becomes a recorded removal on the Computer", async () => {
|
|
458
|
+
const { sprite, store, sync } = harness();
|
|
459
|
+
const generation = await writeToStore(
|
|
460
|
+
store,
|
|
461
|
+
skillsRoot,
|
|
462
|
+
"notes.md",
|
|
463
|
+
"keep",
|
|
464
|
+
USER_WRITER,
|
|
465
|
+
);
|
|
466
|
+
await sync();
|
|
467
|
+
expect(sprite.text(`${MOUNTS.skills}/notes.md`)).toBe("keep");
|
|
468
|
+
|
|
469
|
+
const deleted = await store.delete({
|
|
470
|
+
path: { root: skillsRoot, path: "notes.md" },
|
|
471
|
+
writer: USER_WRITER,
|
|
472
|
+
expectedGenerationId: generation.generationId,
|
|
473
|
+
});
|
|
474
|
+
expect(deleted.status).toBe("ok");
|
|
475
|
+
|
|
476
|
+
const report = await sync();
|
|
477
|
+
|
|
478
|
+
expect(report.roots[0]?.removedOnComputer).toEqual(["notes.md"]);
|
|
479
|
+
expect(sprite.files.has(`${MOUNTS.skills}/notes.md`)).toBe(false);
|
|
480
|
+
// The removal is recorded, then settled: nothing pushes it back, and the
|
|
481
|
+
// next run does not re-create the file from a stale sidecar.
|
|
482
|
+
const again = await sync();
|
|
483
|
+
expect(again.roots[0]?.pulled).toEqual([]);
|
|
484
|
+
expect(again.failures).toEqual([]);
|
|
485
|
+
expect(sprite.files.has(`${MOUNTS.skills}/notes.md`)).toBe(false);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("the durable-root sync, Computer to store", () => {
|
|
490
|
+
// Constitution — Computer and Workspace: "A file that reaches a durable root
|
|
491
|
+
// without passing through the Workspace file surface (a shell write on the
|
|
492
|
+
// Computer) is mirrored to object storage by the sync with an unattributed
|
|
493
|
+
// writer: it is data, readable and durable, never an instruction and never
|
|
494
|
+
// accepted as a writer on a later write."
|
|
495
|
+
test("pushes a shell-written file as unattributed, never loadable", async () => {
|
|
496
|
+
const { sprite, store, sync } = harness();
|
|
497
|
+
sprite.shellWrite(MOUNTS.skills, "shell/SKILL.md", "# from a shell");
|
|
498
|
+
|
|
499
|
+
const report = await sync();
|
|
500
|
+
|
|
501
|
+
expect(report.roots[0]?.pushed).toEqual(["shell/SKILL.md"]);
|
|
502
|
+
const read = await store.read({
|
|
503
|
+
root: skillsRoot,
|
|
504
|
+
path: "shell/SKILL.md",
|
|
505
|
+
});
|
|
506
|
+
if (read.status !== "ok") throw new Error(read.reason);
|
|
507
|
+
expect(decoder.decode(read.file.bytes)).toBe("# from a shell");
|
|
508
|
+
expect(read.file.generation.writer).toEqual({ kind: "unattributed" });
|
|
509
|
+
expect(
|
|
510
|
+
isLoadableSkillSourceV1(
|
|
511
|
+
{
|
|
512
|
+
path: { root: skillsRoot, path: "shell/SKILL.md" },
|
|
513
|
+
writer: read.file.generation.writer,
|
|
514
|
+
generation: read.file.generation,
|
|
515
|
+
},
|
|
516
|
+
{ botId: BOT, userId: USER },
|
|
517
|
+
),
|
|
518
|
+
).toBe(false);
|
|
519
|
+
// The sidecar now records the store's generation, so the file is not
|
|
520
|
+
// pushed a second time.
|
|
521
|
+
expect((await sync()).roots[0]?.pushed).toEqual([]);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("a removal on the Computer becomes a delete in the store, recorded", async () => {
|
|
525
|
+
const { sprite, store, generations, sync } = harness();
|
|
526
|
+
await writeToStore(store, skillsRoot, "notes.md", "keep", USER_WRITER);
|
|
527
|
+
await sync();
|
|
528
|
+
|
|
529
|
+
sprite.shellRemove(MOUNTS.skills, "notes.md");
|
|
530
|
+
const report = await sync();
|
|
531
|
+
|
|
532
|
+
expect(report.roots[0]?.removedInStore).toEqual(["notes.md"]);
|
|
533
|
+
expect(
|
|
534
|
+
(await store.read({ root: skillsRoot, path: "notes.md" })).status,
|
|
535
|
+
).toBe("not-found");
|
|
536
|
+
// A shell removed it, so nothing recorded who did: the removal is durable
|
|
537
|
+
// and unattributed, never attributed to the Bot whose Turn happened to be
|
|
538
|
+
// running.
|
|
539
|
+
expect(
|
|
540
|
+
generations
|
|
541
|
+
.tombstones()
|
|
542
|
+
.map((entry) => [entry.path, entry.generation.writer]),
|
|
543
|
+
).toEqual([["notes.md", { kind: "unattributed" }]]);
|
|
544
|
+
// The removal is settled on both sides; nothing resurrects the file.
|
|
545
|
+
expect((await sync()).failures).toEqual([]);
|
|
546
|
+
expect(sprite.files.has(`${MOUNTS.skills}/notes.md`)).toBe(false);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// A tombstone lives in an ordinary directory on the Computer, so a shell can
|
|
550
|
+
// write one naming any writer it likes, copying the superseded generation id
|
|
551
|
+
// out of the sidecar beside it. A removal therefore reaches the store with no
|
|
552
|
+
// writer at all — the constitution's answer for anything that did not pass
|
|
553
|
+
// through the Workspace file surface — and only on a non-Memory root.
|
|
554
|
+
test("pushes a Computer-side tombstone's removal as an unattributed delete", async () => {
|
|
555
|
+
const { sprite, store, generations, sync } = harness();
|
|
556
|
+
const generation = await writeToStore(
|
|
557
|
+
store,
|
|
558
|
+
skillsRoot,
|
|
559
|
+
"notes.md",
|
|
560
|
+
"keep",
|
|
561
|
+
BOT_WRITER,
|
|
562
|
+
);
|
|
563
|
+
await sync();
|
|
564
|
+
|
|
565
|
+
// The record a shell can write, and the record the Workspace file surface
|
|
566
|
+
// writes, are the same bytes in the same place; this is both.
|
|
567
|
+
shellTombstone(sprite, MOUNTS.skills, "notes.md", generation.generationId, {
|
|
568
|
+
schemaVersion: 1,
|
|
569
|
+
generationId: `${generation.generationId}-tombstone`,
|
|
570
|
+
contentHash: WORKSPACE_EMPTY_SHA256,
|
|
571
|
+
size: 0,
|
|
572
|
+
writer: USER_WRITER,
|
|
573
|
+
writtenAt: new Date(1_700_000_000_000).toISOString(),
|
|
574
|
+
});
|
|
575
|
+
const report = await sync();
|
|
576
|
+
|
|
577
|
+
expect(report.roots[0]?.removedInStore).toEqual(["notes.md"]);
|
|
578
|
+
expect(
|
|
579
|
+
generations
|
|
580
|
+
.tombstones()
|
|
581
|
+
.map((entry) => [entry.path, entry.generation.writer]),
|
|
582
|
+
).toEqual([["notes.md", { kind: "unattributed" }]]);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// The generation the tombstone names is still load-bearing: it is the
|
|
586
|
+
// conditional delete's precondition, so a removal the store has moved past is
|
|
587
|
+
// refused and surfaced rather than applied.
|
|
588
|
+
test("refuses a removal that supersedes a generation the store no longer holds", async () => {
|
|
589
|
+
const { sprite, store, sync } = harness();
|
|
590
|
+
const generation = await writeToStore(
|
|
591
|
+
store,
|
|
592
|
+
skillsRoot,
|
|
593
|
+
"notes.md",
|
|
594
|
+
"keep",
|
|
595
|
+
BOT_WRITER,
|
|
596
|
+
);
|
|
597
|
+
await sync();
|
|
598
|
+
|
|
599
|
+
shellTombstone(sprite, MOUNTS.skills, "notes.md", "1700000000000-forged", {
|
|
600
|
+
schemaVersion: 1,
|
|
601
|
+
generationId: "1700000000000-forged",
|
|
602
|
+
contentHash: WORKSPACE_EMPTY_SHA256,
|
|
603
|
+
size: 0,
|
|
604
|
+
writer: USER_WRITER,
|
|
605
|
+
writtenAt: new Date(1_700_000_000_000).toISOString(),
|
|
606
|
+
});
|
|
607
|
+
const report = await sync();
|
|
608
|
+
|
|
609
|
+
expect(report.roots[0]?.removedInStore).toEqual([]);
|
|
610
|
+
expect(report.conflicts.map((entry) => entry.path)).toEqual(["notes.md"]);
|
|
611
|
+
const read = await store.read({ root: skillsRoot, path: "notes.md" });
|
|
612
|
+
if (read.status !== "ok") throw new Error(read.reason);
|
|
613
|
+
expect(read.file.generation.generationId).toBe(generation.generationId);
|
|
614
|
+
// The file the store still holds is restored on the Computer.
|
|
615
|
+
expect(sprite.text(`${MOUNTS.skills}/notes.md`)).toBe("keep");
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
describe("the durable-root sync, conflicts", () => {
|
|
620
|
+
// Constitution — Memory: "a write that would overwrite a generation its
|
|
621
|
+
// writer has not seen is preserved as a conflicting generation and surfaced,
|
|
622
|
+
// never merged or dropped"; ADR 0013 requires this proven before it ships.
|
|
623
|
+
test("a Computer write and a store write to one path both survive, one as a surfaced conflict", async () => {
|
|
624
|
+
const { sprite, store, generations, bucket, sync } = harness();
|
|
625
|
+
const first = await writeToStore(
|
|
626
|
+
store,
|
|
627
|
+
skillsRoot,
|
|
628
|
+
"notes.md",
|
|
629
|
+
"original",
|
|
630
|
+
USER_WRITER,
|
|
631
|
+
);
|
|
632
|
+
await sync();
|
|
633
|
+
|
|
634
|
+
// Both sides move, neither having seen the other.
|
|
635
|
+
sprite.shellWrite(MOUNTS.skills, "notes.md", "computer edit");
|
|
636
|
+
const winner = await writeToStore(
|
|
637
|
+
store,
|
|
638
|
+
skillsRoot,
|
|
639
|
+
"notes.md",
|
|
640
|
+
"store edit",
|
|
641
|
+
USER_WRITER,
|
|
642
|
+
first.generationId,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
const report = await sync();
|
|
646
|
+
|
|
647
|
+
expect(report.conflicts).toHaveLength(1);
|
|
648
|
+
const conflict = report.conflicts[0];
|
|
649
|
+
expect(conflict?.path).toBe("notes.md");
|
|
650
|
+
expect(conflict?.current?.generationId).toBe(winner.generationId);
|
|
651
|
+
expect(conflict?.preserved?.conflictsWith).toBe(winner.generationId);
|
|
652
|
+
|
|
653
|
+
// Both generations survive. The winner holds the file on both sides.
|
|
654
|
+
const read = await store.read({ root: skillsRoot, path: "notes.md" });
|
|
655
|
+
if (read.status !== "ok") throw new Error(read.reason);
|
|
656
|
+
expect(decoder.decode(read.file.bytes)).toBe("store edit");
|
|
657
|
+
expect(sprite.text(`${MOUNTS.skills}/notes.md`)).toBe("store edit");
|
|
658
|
+
|
|
659
|
+
// The loser is preserved in object storage under its conflict key, in the
|
|
660
|
+
// Durable Object's ledger, and on the Computer beside the winner.
|
|
661
|
+
const preservedKey = bucket
|
|
662
|
+
.keys()
|
|
663
|
+
.find((key) => key.includes("notes.md.conflict/"));
|
|
664
|
+
expect(preservedKey).toBeDefined();
|
|
665
|
+
const preserved = await generations.conflicts(skillsRoot, "notes.md");
|
|
666
|
+
expect(preserved.map((entry) => entry.generation.contentHash)).toEqual([
|
|
667
|
+
sha256(encoder.encode("computer edit")),
|
|
668
|
+
]);
|
|
669
|
+
const kept = sprite.keys(
|
|
670
|
+
`${MOUNTS.skills}/.frockbot-sync/conflicts/notes.md/`,
|
|
671
|
+
);
|
|
672
|
+
expect(kept).toHaveLength(1);
|
|
673
|
+
expect(sprite.text(kept[0] ?? "")).toBe("computer edit");
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
describe("the durable-root sync, Memory roots", () => {
|
|
678
|
+
// Constitution — Memory: "The Memory Package is the single writer of Memory
|
|
679
|
+
// roots ... the Workspace presents Memory roots read-only through the
|
|
680
|
+
// durable-root sync."
|
|
681
|
+
test("a Memory file changed on the Computer is never pushed and is restored", async () => {
|
|
682
|
+
const { sprite, memory, store, sync } = harness();
|
|
683
|
+
const generation = await writeToStore(
|
|
684
|
+
memory,
|
|
685
|
+
botMemoryRoot,
|
|
686
|
+
"profile.md",
|
|
687
|
+
"likes tea",
|
|
688
|
+
BOT_WRITER,
|
|
689
|
+
);
|
|
690
|
+
await sync();
|
|
691
|
+
|
|
692
|
+
sprite.shellWrite(MOUNTS.botMemory, "profile.md", "likes coffee");
|
|
693
|
+
const report = await sync();
|
|
694
|
+
|
|
695
|
+
const memoryReport = report.roots.find(
|
|
696
|
+
(entry) => entry.root.kind === "bot-memory",
|
|
697
|
+
);
|
|
698
|
+
expect(memoryReport?.pushed).toEqual([]);
|
|
699
|
+
expect(memoryReport?.restored).toEqual(["profile.md"]);
|
|
700
|
+
expect(sprite.text(`${MOUNTS.botMemory}/profile.md`)).toBe("likes tea");
|
|
701
|
+
const read = await memory.read({ root: botMemoryRoot, path: "profile.md" });
|
|
702
|
+
if (read.status !== "ok") throw new Error(read.reason);
|
|
703
|
+
expect(decoder.decode(read.file.bytes)).toBe("likes tea");
|
|
704
|
+
expect(read.file.generation.generationId).toBe(generation.generationId);
|
|
705
|
+
|
|
706
|
+
// The sync surface refuses a Memory write outright, so there is no path by
|
|
707
|
+
// which the Computer could have become a second writer.
|
|
708
|
+
const refused = await store.write({
|
|
709
|
+
path: { root: botMemoryRoot, path: "profile.md" },
|
|
710
|
+
bytes: encoder.encode("likes coffee"),
|
|
711
|
+
writer: BOT_WRITER,
|
|
712
|
+
expectedGenerationId: generation.generationId,
|
|
713
|
+
});
|
|
714
|
+
expect(refused.status).toBe("refused");
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// ADR 0016 extends exactly this rule to the User-global instruction root:
|
|
718
|
+
// the Skills Package is its single writer, so the Computer presents it
|
|
719
|
+
// read-only and the sync only materializes it.
|
|
720
|
+
test("a User-global Skill changed on the Computer is never pushed and is restored", async () => {
|
|
721
|
+
const { sprite, store, sync } = harness();
|
|
722
|
+
const generation = await writeToStore(
|
|
723
|
+
store,
|
|
724
|
+
userSkillsRoot,
|
|
725
|
+
"standup/SKILL.md",
|
|
726
|
+
"# standup",
|
|
727
|
+
USER_WRITER,
|
|
728
|
+
);
|
|
729
|
+
await sync();
|
|
730
|
+
expect(sprite.text(`${MOUNTS.userSkills}/standup/SKILL.md`)).toBe(
|
|
731
|
+
"# standup",
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
sprite.shellWrite(MOUNTS.userSkills, "standup/SKILL.md", "# tampered");
|
|
735
|
+
const report = await sync();
|
|
736
|
+
|
|
737
|
+
const userSkills = report.roots.find(
|
|
738
|
+
(entry) => entry.root.kind === "user-instructions",
|
|
739
|
+
);
|
|
740
|
+
expect(userSkills?.pushed).toEqual([]);
|
|
741
|
+
expect(userSkills?.restored).toEqual(["standup/SKILL.md"]);
|
|
742
|
+
expect(sprite.text(`${MOUNTS.userSkills}/standup/SKILL.md`)).toBe(
|
|
743
|
+
"# standup",
|
|
744
|
+
);
|
|
745
|
+
const read = await store.read({
|
|
746
|
+
root: userSkillsRoot,
|
|
747
|
+
path: "standup/SKILL.md",
|
|
748
|
+
});
|
|
749
|
+
if (read.status !== "ok") throw new Error(read.reason);
|
|
750
|
+
expect(decoder.decode(read.file.bytes)).toBe("# standup");
|
|
751
|
+
expect(read.file.generation.generationId).toBe(generation.generationId);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("a Memory file removed on the Computer is restored, never deleted in the store", async () => {
|
|
755
|
+
const { sprite, memory, sync } = harness();
|
|
756
|
+
await writeToStore(
|
|
757
|
+
memory,
|
|
758
|
+
botMemoryRoot,
|
|
759
|
+
"profile.md",
|
|
760
|
+
"likes tea",
|
|
761
|
+
BOT_WRITER,
|
|
762
|
+
);
|
|
763
|
+
await sync();
|
|
764
|
+
|
|
765
|
+
sprite.shellRemove(MOUNTS.botMemory, "profile.md");
|
|
766
|
+
await sync();
|
|
767
|
+
|
|
768
|
+
expect(sprite.text(`${MOUNTS.botMemory}/profile.md`)).toBe("likes tea");
|
|
769
|
+
expect(
|
|
770
|
+
(await memory.read({ root: botMemoryRoot, path: "profile.md" })).status,
|
|
771
|
+
).toBe("ok");
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
describe("the durable-root sync, pauses", () => {
|
|
776
|
+
// Constitution — Computer and Workspace: "Connections to the Computer are
|
|
777
|
+
// expected to drop on every pause; every Computer client reconnects and
|
|
778
|
+
// resumes rather than treating a dropped connection as failure." § Durable
|
|
779
|
+
// effects: "Recovery never silently duplicates ... effects."
|
|
780
|
+
test("a push interrupted by a pause resumes without writing a second generation", async () => {
|
|
781
|
+
let dropAfterWrite = true;
|
|
782
|
+
const { sprite, store, generations, bucket, sync } = harness({
|
|
783
|
+
// The write lands and the answer never arrives: the Sprite paused, and
|
|
784
|
+
// the connection carrying the outcome dropped.
|
|
785
|
+
store: (files) => ({
|
|
786
|
+
read: (path) => files.read(path),
|
|
787
|
+
list: (request) => files.list(request),
|
|
788
|
+
stat: (path) => files.stat(path),
|
|
789
|
+
delete: (request) => files.delete(request),
|
|
790
|
+
write: async (request) => {
|
|
791
|
+
const outcome = await files.write(request);
|
|
792
|
+
if (dropAfterWrite) {
|
|
793
|
+
dropAfterWrite = false;
|
|
794
|
+
throw new Error("connection reset by pause");
|
|
795
|
+
}
|
|
796
|
+
return outcome;
|
|
797
|
+
},
|
|
798
|
+
}),
|
|
799
|
+
});
|
|
800
|
+
sprite.shellWrite(MOUNTS.skills, "notes.md", "half sent");
|
|
801
|
+
|
|
802
|
+
const interrupted = await sync();
|
|
803
|
+
expect(interrupted.failures.map((entry) => entry.status)).toEqual([
|
|
804
|
+
"unavailable",
|
|
805
|
+
]);
|
|
806
|
+
// The intent is recorded in the Workspace and left unsettled, which is how
|
|
807
|
+
// recovery knows to read the outcome rather than repeat the effect.
|
|
808
|
+
expect(sprite.keys("/home/box/.frockbot/sync/effects/")).toHaveLength(1);
|
|
809
|
+
|
|
810
|
+
const resumed = await sync();
|
|
811
|
+
|
|
812
|
+
expect(resumed.failures).toEqual([]);
|
|
813
|
+
expect(resumed.roots[0]?.adopted).toEqual(["notes.md"]);
|
|
814
|
+
expect(resumed.roots[0]?.pushed).toEqual([]);
|
|
815
|
+
expect(resumed.conflicts).toEqual([]);
|
|
816
|
+
// Exactly one generation exists for the path: no duplicate, no conflict
|
|
817
|
+
// object beside it.
|
|
818
|
+
expect(
|
|
819
|
+
bucket.keys().filter((key) => key.includes("notes.md")),
|
|
820
|
+
).toHaveLength(1);
|
|
821
|
+
expect(await generations.conflicts(skillsRoot, "notes.md")).toEqual([]);
|
|
822
|
+
expect(sprite.keys("/home/box/.frockbot/sync/effects/")).toHaveLength(0);
|
|
823
|
+
const stat = await store.stat({ root: skillsRoot, path: "notes.md" });
|
|
824
|
+
if (stat.status !== "ok") throw new Error(stat.reason);
|
|
825
|
+
expect(stat.entry.generation.writer).toEqual({ kind: "unattributed" });
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// § Durable effects: "Recovery never silently duplicates ... effects." The
|
|
829
|
+
// push is not done when the store answers: the Computer still has no sidecar
|
|
830
|
+
// for the bytes it holds. Settling the intent there would leave the next run
|
|
831
|
+
// pushing the same file against `null` and colliding with the generation it
|
|
832
|
+
// had itself just written — a conflict nobody caused.
|
|
833
|
+
test("a push interrupted before its sidecar produces one generation and no conflict", async () => {
|
|
834
|
+
const { sprite, store, generations, bucket, sync } = harness();
|
|
835
|
+
sprite.shellWrite(MOUNTS.skills, "notes.md", "half sent");
|
|
836
|
+
// The store takes the bytes; the Sprite pauses before the sidecar lands.
|
|
837
|
+
sprite.dropNextMaterialize = true;
|
|
838
|
+
|
|
839
|
+
const interrupted = await sync();
|
|
840
|
+
expect(interrupted.failures.map((entry) => entry.status)).toEqual([
|
|
841
|
+
"unavailable",
|
|
842
|
+
]);
|
|
843
|
+
expect(interrupted.conflicts).toEqual([]);
|
|
844
|
+
expect(sprite.keys("/home/box/.frockbot/sync/effects/")).toHaveLength(1);
|
|
845
|
+
|
|
846
|
+
const resumed = await sync();
|
|
847
|
+
|
|
848
|
+
expect(resumed.conflicts).toEqual([]);
|
|
849
|
+
expect(resumed.failures).toEqual([]);
|
|
850
|
+
expect(resumed.roots[0]?.adopted).toEqual(["notes.md"]);
|
|
851
|
+
expect(
|
|
852
|
+
bucket.keys().filter((key) => key.includes("notes.md")),
|
|
853
|
+
).toHaveLength(1);
|
|
854
|
+
expect(await generations.conflicts(skillsRoot, "notes.md")).toEqual([]);
|
|
855
|
+
expect(sprite.keys("/home/box/.frockbot/sync/effects/")).toHaveLength(0);
|
|
856
|
+
const stat = await store.stat({ root: skillsRoot, path: "notes.md" });
|
|
857
|
+
if (stat.status !== "ok") throw new Error(stat.reason);
|
|
858
|
+
expect(stat.entry.generation.writer).toEqual({ kind: "unattributed" });
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// "every Computer client reconnects and resumes rather than treating a
|
|
862
|
+
// dropped connection as failure": a paused Sprite is `unavailable`, an
|
|
863
|
+
// ordinary answer, and the next run completes the work.
|
|
864
|
+
test("a paused Sprite answers unavailable and the next run completes the sync", async () => {
|
|
865
|
+
const { sprite, store, sync } = harness();
|
|
866
|
+
await writeToStore(store, skillsRoot, "notes.md", "keep", USER_WRITER);
|
|
867
|
+
|
|
868
|
+
sprite.paused = true;
|
|
869
|
+
const paused = await sync();
|
|
870
|
+
expect(paused.failures.map((entry) => entry.status)).toEqual([
|
|
871
|
+
"unavailable",
|
|
872
|
+
"unavailable",
|
|
873
|
+
"unavailable",
|
|
874
|
+
"unavailable",
|
|
875
|
+
]);
|
|
876
|
+
|
|
877
|
+
sprite.paused = false;
|
|
878
|
+
const resumed = await sync();
|
|
879
|
+
expect(resumed.failures).toEqual([]);
|
|
880
|
+
expect(sprite.text(`${MOUNTS.skills}/notes.md`)).toBe("keep");
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
describe("the on-Sprite sync service", () => {
|
|
885
|
+
// Constitution — Computer and Workspace: "Only Computer-provider-declared
|
|
886
|
+
// services may be reattached; other processes are assumed dead after a cold
|
|
887
|
+
// pause." The claim that `WORKSPACE_SYNC_SERVICE` is *declared* no longer has
|
|
888
|
+
// a subject in this Package: after ADR 0004 the provider never creates a
|
|
889
|
+
// service — the shared Computer host owns the Sprite's service declarations,
|
|
890
|
+
// and this suite's host double answers `open` without one. The test that
|
|
891
|
+
// asserted it against a `FakeSyncSprite.createService` recorder is gone with
|
|
892
|
+
// the SDK it drove; the declaration belongs to the host's own suite now.
|
|
893
|
+
|
|
894
|
+
test("its change signal is what a caller polls to decide when to sync", async () => {
|
|
895
|
+
const { sprite, agent } = harness();
|
|
896
|
+
|
|
897
|
+
expect(await agent.signal()).toEqual({ status: "ok" });
|
|
898
|
+
sprite.files.set("/home/box/.frockbot/sync/signal", encoder.encode("7\n"));
|
|
899
|
+
expect(await agent.signal()).toEqual({ status: "ok", text: "7" });
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// The sync as a Bot reaches it: `handle.sync` on the provider-neutral Computer
|
|
904
|
+
// interface, built from the host seam the Bot Durable Object supplies. These
|
|
905
|
+
// are the provider half of the caller wiring — the Computer Package decides
|
|
906
|
+
// *when* (`packages/plugin-computer/src/sync.test.ts`), and here is what
|
|
907
|
+
// actually happens against a Sprite when it does.
|
|
908
|
+
describe("the durable-root sync on the Computer handle", () => {
|
|
909
|
+
interface EffectLog {
|
|
910
|
+
effects: WorkspaceSyncEffectsV1;
|
|
911
|
+
/** Every intent and settlement, in order, interleaved with store writes. */
|
|
912
|
+
log: string[];
|
|
913
|
+
pending: Map<string, WorkspaceSyncEffectV1>;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function recordingEffects(log: string[]): EffectLog {
|
|
917
|
+
const pending = new Map<string, WorkspaceSyncEffectV1>();
|
|
918
|
+
return {
|
|
919
|
+
log,
|
|
920
|
+
pending,
|
|
921
|
+
effects: {
|
|
922
|
+
intent: (effect) => {
|
|
923
|
+
pending.set(effect.effectId, effect);
|
|
924
|
+
log.push(`intent:${effect.kind}:${effect.path}`);
|
|
925
|
+
return Promise.resolve();
|
|
926
|
+
},
|
|
927
|
+
settle: (effect) => {
|
|
928
|
+
pending.delete(effect.effectId);
|
|
929
|
+
log.push(`settle:${effect.kind}:${effect.path}`);
|
|
930
|
+
return Promise.resolve();
|
|
931
|
+
},
|
|
932
|
+
pending: (effectId) => Promise.resolve(pending.get(effectId)),
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function providerHarness() {
|
|
938
|
+
const bucket = createInMemoryObjectBucketV1();
|
|
939
|
+
const generations = createInMemoryWorkspaceGenerationsV1();
|
|
940
|
+
const owner = { userId: USER };
|
|
941
|
+
const store = createObjectWorkspaceFilesV1({
|
|
942
|
+
bucket,
|
|
943
|
+
generations,
|
|
944
|
+
owner,
|
|
945
|
+
surface: "sync",
|
|
946
|
+
});
|
|
947
|
+
const log: string[] = [];
|
|
948
|
+
const effects = recordingEffects(log);
|
|
949
|
+
const sprite = new FakeSyncSprite();
|
|
950
|
+
const computer = attach(sprite);
|
|
951
|
+
const provider = new FlySpriteComputerProvider(computer, undefined, {
|
|
952
|
+
// Every write the store takes is announced, so "intent before the push"
|
|
953
|
+
// is an ordering claim about this one list.
|
|
954
|
+
store: {
|
|
955
|
+
read: (path) => store.read(path),
|
|
956
|
+
list: (request) => store.list(request),
|
|
957
|
+
stat: (path) => store.stat(path),
|
|
958
|
+
write: (request) => {
|
|
959
|
+
log.push(`write:${request.path.path}`);
|
|
960
|
+
return store.write(request);
|
|
961
|
+
},
|
|
962
|
+
delete: (request) => {
|
|
963
|
+
log.push(`delete:${request.path.path}`);
|
|
964
|
+
return store.delete(request);
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
effects: effects.effects,
|
|
968
|
+
generations,
|
|
969
|
+
});
|
|
970
|
+
const open = () =>
|
|
971
|
+
provider.open(
|
|
972
|
+
{ userId: USER },
|
|
973
|
+
{ botId: BOT },
|
|
974
|
+
{ providerId: "fly-sprite", generation: 1 },
|
|
975
|
+
);
|
|
976
|
+
return { sprite, store, generations, effects, provider, open };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
test("pulls the store's durable roots onto the Computer before the Bot's first use", async () => {
|
|
980
|
+
const { sprite, store, open } = providerHarness();
|
|
981
|
+
await writeToStore(
|
|
982
|
+
store,
|
|
983
|
+
skillsRoot,
|
|
984
|
+
"deploy/SKILL.md",
|
|
985
|
+
"# deploy",
|
|
986
|
+
BOT_WRITER,
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
const handle = await open();
|
|
990
|
+
expect(handle.sync).toBeDefined();
|
|
991
|
+
const summary = await handle.sync!.reconcile("open");
|
|
992
|
+
|
|
993
|
+
expect(summary.status).toBe("ok");
|
|
994
|
+
expect(summary.pulled).toBe(1);
|
|
995
|
+
expect(sprite.text(`${MOUNTS.skills}/deploy/SKILL.md`)).toBe("# deploy");
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test("pushes a shell write after the Turn, recording its intent before the write", async () => {
|
|
999
|
+
const { sprite, store, effects, open } = providerHarness();
|
|
1000
|
+
const handle = await open();
|
|
1001
|
+
await handle.sync!.reconcile("open");
|
|
1002
|
+
sprite.shellWrite(MOUNTS.skills, "notes.md", "written by a shell");
|
|
1003
|
+
|
|
1004
|
+
const summary = await handle.sync!.reconcile("turn-end");
|
|
1005
|
+
|
|
1006
|
+
expect(summary.status).toBe("ok");
|
|
1007
|
+
expect(summary.pushed).toBe(1);
|
|
1008
|
+
// Intent, then the write, then settlement: the order § Durable effects
|
|
1009
|
+
// requires, so an interrupted push is read back rather than repeated.
|
|
1010
|
+
expect(effects.log).toEqual([
|
|
1011
|
+
"intent:push:notes.md",
|
|
1012
|
+
"write:notes.md",
|
|
1013
|
+
"settle:push:notes.md",
|
|
1014
|
+
]);
|
|
1015
|
+
expect(effects.pending.size).toBe(0);
|
|
1016
|
+
const stored = await store.read({ root: skillsRoot, path: "notes.md" });
|
|
1017
|
+
expect(stored).toMatchObject({ status: "ok" });
|
|
1018
|
+
if (stored.status === "ok") {
|
|
1019
|
+
expect(decoder.decode(stored.file.bytes)).toBe("written by a shell");
|
|
1020
|
+
// A Turn was open, and it is still not evidence: one Computer serves all
|
|
1021
|
+
// of a User's Bots, so nothing here knows which process wrote the file.
|
|
1022
|
+
expect(stored.file.generation.writer).toEqual({ kind: "unattributed" });
|
|
1023
|
+
expect(
|
|
1024
|
+
isLoadableSkillSourceV1(
|
|
1025
|
+
{
|
|
1026
|
+
path: { root: skillsRoot, path: "notes.md" },
|
|
1027
|
+
writer: stored.file.generation.writer,
|
|
1028
|
+
generation: stored.file.generation,
|
|
1029
|
+
},
|
|
1030
|
+
{ botId: BOT, userId: USER },
|
|
1031
|
+
),
|
|
1032
|
+
).toBe(false);
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test("answers unavailable rather than throwing when the Sprite is paused", async () => {
|
|
1037
|
+
const { sprite, open } = providerHarness();
|
|
1038
|
+
const handle = await open();
|
|
1039
|
+
sprite.paused = true;
|
|
1040
|
+
|
|
1041
|
+
const summary = await handle.sync!.reconcile("turn-end");
|
|
1042
|
+
|
|
1043
|
+
expect(summary.status).toBe("unavailable");
|
|
1044
|
+
expect(summary.failures).toBeGreaterThan(0);
|
|
1045
|
+
expect(await handle.sync!.signal()).toBeUndefined();
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
test("carries no sync at all when the host supplies no object-storage side", async () => {
|
|
1049
|
+
const sprite = new FakeSyncSprite();
|
|
1050
|
+
const provider = new FlySpriteComputerProvider(attach(sprite));
|
|
1051
|
+
|
|
1052
|
+
const handle = await provider.open(
|
|
1053
|
+
{ userId: USER },
|
|
1054
|
+
{ botId: BOT },
|
|
1055
|
+
{ providerId: "fly-sprite", generation: 1 },
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
expect(handle.sync).toBeUndefined();
|
|
1059
|
+
});
|
|
1060
|
+
});
|