@frockbot/workspace-store 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.
@@ -0,0 +1,984 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ isLoadableSkillSourceV1,
4
+ isWorkspaceConflictV1,
5
+ memoryShardPathV1,
6
+ workspaceMemoryProjectionV1,
7
+ type WorkspaceFilesV1,
8
+ type WorkspaceRootV1,
9
+ type WorkspaceWriterV1,
10
+ } from "@frockbot/kernel-contracts";
11
+ import {
12
+ createInMemoryObjectBucketV1,
13
+ createInMemoryWorkspaceGenerationsV1,
14
+ type InMemoryObjectBucketV1,
15
+ type InMemoryWorkspaceGenerationsV1,
16
+ } from "./testing.js";
17
+ import { createObjectWorkspaceFilesV1, gcTombstoneMarkersV1 } from "./store.js";
18
+ import { workspaceConflictKeyV1, workspaceObjectKeyV1 } from "./keys.js";
19
+
20
+ const USER = "user-1";
21
+ const INSTRUCTIONS: WorkspaceRootV1 = {
22
+ kind: "bot-instructions",
23
+ userId: USER,
24
+ botId: "bot-1",
25
+ };
26
+ const USER_MEMORY: WorkspaceRootV1 = { kind: "user-memory", userId: USER };
27
+ const PROJECT_MEMORY: WorkspaceRootV1 = {
28
+ kind: "project-memory",
29
+ userId: USER,
30
+ projectId: "school-run",
31
+ };
32
+ const BOT_MEMORY: WorkspaceRootV1 = {
33
+ kind: "bot-memory",
34
+ userId: USER,
35
+ botId: "bot-1",
36
+ };
37
+
38
+ function bot(botId: string): WorkspaceWriterV1 {
39
+ return {
40
+ kind: "bot",
41
+ botId,
42
+ sessionId: `${USER}:${botId}`,
43
+ turnId: "turn-1",
44
+ runId: "run-1",
45
+ };
46
+ }
47
+
48
+ const user: WorkspaceWriterV1 = { kind: "user", userId: USER };
49
+ const firstParty: WorkspaceWriterV1 = {
50
+ kind: "first-party",
51
+ packageId: "skills",
52
+ };
53
+
54
+ interface Harness {
55
+ files: WorkspaceFilesV1;
56
+ memory: WorkspaceFilesV1;
57
+ sync: WorkspaceFilesV1;
58
+ bucket: InMemoryObjectBucketV1;
59
+ generations: InMemoryWorkspaceGenerationsV1;
60
+ }
61
+
62
+ function harness(): Harness {
63
+ let ticks = 0;
64
+ const clock = () => new Date(1_800_000_000_000 + ticks++ * 1000);
65
+ const bucket = createInMemoryObjectBucketV1(clock);
66
+ const generations = createInMemoryWorkspaceGenerationsV1(clock);
67
+ return {
68
+ bucket,
69
+ generations,
70
+ files: createObjectWorkspaceFilesV1({
71
+ bucket,
72
+ generations,
73
+ clock,
74
+ owner: { userId: USER },
75
+ }),
76
+ memory: createObjectWorkspaceFilesV1({
77
+ bucket,
78
+ generations,
79
+ clock,
80
+ owner: { userId: USER },
81
+ surface: "memory",
82
+ }),
83
+ sync: createObjectWorkspaceFilesV1({
84
+ bucket,
85
+ generations,
86
+ clock,
87
+ owner: { userId: USER },
88
+ surface: "sync",
89
+ }),
90
+ };
91
+ }
92
+
93
+ function bytes(text: string): Uint8Array {
94
+ return new TextEncoder().encode(text);
95
+ }
96
+
97
+ function text(value: Uint8Array): string {
98
+ return new TextDecoder().decode(value);
99
+ }
100
+
101
+ describe("object keys and recorded generations", () => {
102
+ test("a write lands under its root key and records its writer", async () => {
103
+ const { files, bucket, generations } = harness();
104
+ const written = await files.write({
105
+ path: { root: INSTRUCTIONS, path: "skills/deploy/SKILL.md" },
106
+ bytes: bytes("# Deploy"),
107
+ writer: bot("bot-1"),
108
+ expectedGenerationId: null,
109
+ });
110
+
111
+ expect(written.status).toBe("ok");
112
+ expect(bucket.keys()).toEqual([
113
+ "workspace/bot-instructions:user-1:bot-1/skills/deploy/SKILL.md",
114
+ ]);
115
+ expect(workspaceObjectKeyV1(INSTRUCTIONS, "skills/deploy/SKILL.md")).toBe(
116
+ "workspace/bot-instructions:user-1:bot-1/skills/deploy/SKILL.md",
117
+ );
118
+
119
+ const read = await files.read({
120
+ root: INSTRUCTIONS,
121
+ path: "skills/deploy/SKILL.md",
122
+ });
123
+ expect(read.status).toBe("ok");
124
+ if (read.status !== "ok") return;
125
+ expect(text(read.file.bytes)).toBe("# Deploy");
126
+ expect(read.file.generation.writer).toEqual(bot("bot-1"));
127
+
128
+ const recorded = await generations.current(
129
+ INSTRUCTIONS,
130
+ "skills/deploy/SKILL.md",
131
+ );
132
+ expect(recorded?.generation.generationId).toBe(
133
+ read.file.generation.generationId,
134
+ );
135
+ expect(recorded?.etag).toBeTruthy();
136
+ });
137
+
138
+ test("a file written straight into the bucket has no recorded writer", async () => {
139
+ const { files, bucket } = harness();
140
+ await bucket.put(
141
+ workspaceObjectKeyV1(INSTRUCTIONS, "skills/rogue/SKILL.md"),
142
+ bytes("# Rogue"),
143
+ );
144
+
145
+ const read = await files.read({
146
+ root: INSTRUCTIONS,
147
+ path: "skills/rogue/SKILL.md",
148
+ });
149
+ expect(read.status).toBe("ok");
150
+ if (read.status !== "ok") return;
151
+ expect(read.file.generation.writer).toEqual({ kind: "unattributed" });
152
+ });
153
+
154
+ test("stat and read agree, and a missing file is not-found", async () => {
155
+ const { files } = harness();
156
+ await files.write({
157
+ path: { root: INSTRUCTIONS, path: "notes.md" },
158
+ bytes: bytes("hello"),
159
+ writer: user,
160
+ expectedGenerationId: null,
161
+ });
162
+ const stat = await files.stat({ root: INSTRUCTIONS, path: "notes.md" });
163
+ expect(stat.status).toBe("ok");
164
+ if (stat.status !== "ok") return;
165
+ expect(stat.entry.generation.size).toBe(5);
166
+ expect(
167
+ await files.stat({ root: INSTRUCTIONS, path: "absent.md" }),
168
+ ).toMatchObject({ status: "not-found" });
169
+ expect(
170
+ await files.read({ root: INSTRUCTIONS, path: "absent.md" }),
171
+ ).toMatchObject({ status: "not-found" });
172
+ });
173
+ });
174
+
175
+ describe("conditional writes preserve the loser", () => {
176
+ test("a stale expected generation conflicts and both generations survive", async () => {
177
+ const { files, bucket, generations } = harness();
178
+ const first = await files.write({
179
+ path: { root: INSTRUCTIONS, path: "notes.md" },
180
+ bytes: bytes("first"),
181
+ writer: user,
182
+ expectedGenerationId: null,
183
+ });
184
+ expect(first.status).toBe("ok");
185
+ if (first.status !== "ok") return;
186
+ const second = await files.write({
187
+ path: { root: INSTRUCTIONS, path: "notes.md" },
188
+ bytes: bytes("second"),
189
+ writer: user,
190
+ expectedGenerationId: first.generation.generationId,
191
+ });
192
+ expect(second.status).toBe("ok");
193
+
194
+ const stale = await files.write({
195
+ path: { root: INSTRUCTIONS, path: "notes.md" },
196
+ bytes: bytes("stale"),
197
+ writer: bot("bot-1"),
198
+ expectedGenerationId: first.generation.generationId,
199
+ });
200
+
201
+ expect(stale.status).toBe("conflict");
202
+ if (!isWorkspaceConflictV1(stale)) return;
203
+ expect(stale.current?.generationId).toBe(
204
+ second.status === "ok" ? second.generation.generationId : "",
205
+ );
206
+ expect(stale.preserved?.conflictsWith).toBe(stale.current?.generationId);
207
+
208
+ // The winner is untouched, and the loser is preserved beside it.
209
+ const read = await files.read({ root: INSTRUCTIONS, path: "notes.md" });
210
+ expect(read.status === "ok" && text(read.file.bytes)).toBe("second");
211
+ const conflictKey = workspaceConflictKeyV1(
212
+ INSTRUCTIONS,
213
+ "notes.md",
214
+ stale.preserved?.generationId ?? "",
215
+ );
216
+ expect(bucket.keys()).toContain(conflictKey);
217
+ const preserved = await bucket.get(conflictKey);
218
+ expect(text((await preserved?.bytes()) ?? new Uint8Array())).toBe("stale");
219
+
220
+ const recorded = await generations.conflicts(INSTRUCTIONS, "notes.md");
221
+ expect(recorded).toHaveLength(1);
222
+ expect(recorded[0]?.conflictKey).toBe(conflictKey);
223
+ expect(recorded[0]?.generation.conflictsWith).toBe(
224
+ stale.current?.generationId,
225
+ );
226
+ });
227
+
228
+ test("asserting absence over an existing file conflicts, never overwrites", async () => {
229
+ const { files } = harness();
230
+ await files.write({
231
+ path: { root: INSTRUCTIONS, path: "notes.md" },
232
+ bytes: bytes("first"),
233
+ writer: user,
234
+ expectedGenerationId: null,
235
+ });
236
+
237
+ const clash = await files.write({
238
+ path: { root: INSTRUCTIONS, path: "notes.md" },
239
+ bytes: bytes("clash"),
240
+ writer: user,
241
+ expectedGenerationId: null,
242
+ });
243
+
244
+ expect(clash.status).toBe("conflict");
245
+ const read = await files.read({ root: INSTRUCTIONS, path: "notes.md" });
246
+ expect(read.status === "ok" && text(read.file.bytes)).toBe("first");
247
+ });
248
+
249
+ test("a preserved losing write is never listed as a file", async () => {
250
+ const { files } = harness();
251
+ await files.write({
252
+ path: { root: INSTRUCTIONS, path: "notes.md" },
253
+ bytes: bytes("first"),
254
+ writer: user,
255
+ expectedGenerationId: null,
256
+ });
257
+ await files.write({
258
+ path: { root: INSTRUCTIONS, path: "notes.md" },
259
+ bytes: bytes("loser"),
260
+ writer: user,
261
+ expectedGenerationId: null,
262
+ });
263
+
264
+ const listed = await files.list({ root: INSTRUCTIONS });
265
+ expect(listed.status).toBe("ok");
266
+ if (listed.status !== "ok") return;
267
+ expect(listed.entries.map((entry) => entry.path.path)).toEqual([
268
+ "notes.md",
269
+ ]);
270
+ });
271
+ });
272
+
273
+ describe("a delete leaves a durable tombstone", () => {
274
+ test("the file is gone and the removal is recorded with its writer", async () => {
275
+ const { files, bucket, generations } = harness();
276
+ const written = await files.write({
277
+ path: { root: INSTRUCTIONS, path: "notes.md" },
278
+ bytes: bytes("first"),
279
+ writer: user,
280
+ expectedGenerationId: null,
281
+ });
282
+ expect(written.status).toBe("ok");
283
+ if (written.status !== "ok") return;
284
+
285
+ const removed = await files.delete({
286
+ path: { root: INSTRUCTIONS, path: "notes.md" },
287
+ writer: user,
288
+ expectedGenerationId: written.generation.generationId,
289
+ });
290
+
291
+ expect(removed.status).toBe("ok");
292
+ // The marker stands in the file's place; it is an absence everywhere the
293
+ // interface reads, and only `gcTombstoneMarkersV1` removes it.
294
+ expect(bucket.keys()).toEqual([
295
+ workspaceObjectKeyV1(INSTRUCTIONS, "notes.md"),
296
+ ]);
297
+ expect(
298
+ await files.read({ root: INSTRUCTIONS, path: "notes.md" }),
299
+ ).toMatchObject({ status: "not-found" });
300
+ const tombstone = await generations.current(INSTRUCTIONS, "notes.md");
301
+ expect(tombstone?.deleted).toBe(true);
302
+ expect(tombstone?.generation.writer).toEqual(user);
303
+ expect(tombstone?.generation.size).toBe(0);
304
+ expect(generations.tombstones()).toHaveLength(1);
305
+ });
306
+
307
+ test("a stale delete conflicts, and a missing file is not-found", async () => {
308
+ const { files } = harness();
309
+ const written = await files.write({
310
+ path: { root: INSTRUCTIONS, path: "notes.md" },
311
+ bytes: bytes("first"),
312
+ writer: user,
313
+ expectedGenerationId: null,
314
+ });
315
+ expect(written.status).toBe("ok");
316
+
317
+ expect(
318
+ await files.delete({
319
+ path: { root: INSTRUCTIONS, path: "notes.md" },
320
+ writer: user,
321
+ expectedGenerationId: "not-the-current-one",
322
+ }),
323
+ ).toMatchObject({ status: "conflict" });
324
+ expect(
325
+ await files.delete({
326
+ path: { root: INSTRUCTIONS, path: "absent.md" },
327
+ writer: user,
328
+ expectedGenerationId: "whatever",
329
+ }),
330
+ ).toMatchObject({ status: "not-found" });
331
+ });
332
+
333
+ test("a tombstoned path is written again by asserting absence", async () => {
334
+ const { files } = harness();
335
+ const written = await files.write({
336
+ path: { root: INSTRUCTIONS, path: "notes.md" },
337
+ bytes: bytes("first"),
338
+ writer: user,
339
+ expectedGenerationId: null,
340
+ });
341
+ if (written.status !== "ok") throw new Error("write failed");
342
+ await files.delete({
343
+ path: { root: INSTRUCTIONS, path: "notes.md" },
344
+ writer: user,
345
+ expectedGenerationId: written.generation.generationId,
346
+ });
347
+
348
+ const again = await files.write({
349
+ path: { root: INSTRUCTIONS, path: "notes.md" },
350
+ bytes: bytes("again"),
351
+ writer: user,
352
+ expectedGenerationId: null,
353
+ });
354
+ expect(again.status).toBe("ok");
355
+ });
356
+ });
357
+
358
+ describe("who may write", () => {
359
+ test("an unattributed writer is refused, whatever the root", async () => {
360
+ const { files, memory } = harness();
361
+ expect(
362
+ await files.write({
363
+ path: { root: INSTRUCTIONS, path: "notes.md" },
364
+ bytes: bytes("x"),
365
+ writer: { kind: "unattributed" },
366
+ expectedGenerationId: null,
367
+ }),
368
+ ).toMatchObject({ status: "refused" });
369
+ expect(
370
+ await memory.write({
371
+ path: memoryShardPathV1(USER_MEMORY, "bot-1", "profile.md"),
372
+ bytes: bytes("x"),
373
+ writer: { kind: "unattributed" },
374
+ expectedGenerationId: null,
375
+ }),
376
+ ).toMatchObject({ status: "refused" });
377
+ });
378
+
379
+ test("a first-party Package writes neither an instruction nor a Memory root", async () => {
380
+ const { files, memory } = harness();
381
+ expect(
382
+ await files.write({
383
+ path: { root: INSTRUCTIONS, path: "skills/a/SKILL.md" },
384
+ bytes: bytes("x"),
385
+ writer: firstParty,
386
+ expectedGenerationId: null,
387
+ }),
388
+ ).toMatchObject({ status: "refused" });
389
+ expect(
390
+ await memory.write({
391
+ path: { root: BOT_MEMORY, path: "profile.md" },
392
+ bytes: bytes("x"),
393
+ writer: firstParty,
394
+ expectedGenerationId: null,
395
+ }),
396
+ ).toMatchObject({ status: "refused" });
397
+ });
398
+
399
+ test("another Bot may not write this Bot's instruction root", async () => {
400
+ const { files } = harness();
401
+ expect(
402
+ await files.write({
403
+ path: { root: INSTRUCTIONS, path: "skills/a/SKILL.md" },
404
+ bytes: bytes("x"),
405
+ writer: bot("bot-2"),
406
+ expectedGenerationId: null,
407
+ }),
408
+ ).toMatchObject({ status: "refused" });
409
+ });
410
+
411
+ test("the kernel surface refuses every Memory root and the Memory surface refuses every other", async () => {
412
+ const { files, memory } = harness();
413
+ for (const root of [BOT_MEMORY, USER_MEMORY, PROJECT_MEMORY]) {
414
+ expect(
415
+ await files.write({
416
+ path: memoryShardPathV1(root, "bot-1", "profile.md"),
417
+ bytes: bytes("x"),
418
+ writer: bot("bot-1"),
419
+ expectedGenerationId: null,
420
+ }),
421
+ ).toMatchObject({ status: "refused" });
422
+ }
423
+ expect(
424
+ await memory.write({
425
+ path: { root: INSTRUCTIONS, path: "skills/a/SKILL.md" },
426
+ bytes: bytes("x"),
427
+ writer: bot("bot-1"),
428
+ expectedGenerationId: null,
429
+ }),
430
+ ).toMatchObject({ status: "refused" });
431
+ });
432
+
433
+ // ADR 0013: the Computer-side sync mirrors a durable root. It never writes a
434
+ // Memory root — that would give the root a second writer — and it reads
435
+ // every root, because a Memory root has to be readable to be presented
436
+ // read-only on the Computer.
437
+ test("the sync surface reads every root and writes no Memory root", async () => {
438
+ const { sync, memory } = harness();
439
+ const written = await memory.write({
440
+ path: memoryShardPathV1(USER_MEMORY, "bot-1", "profile.md"),
441
+ bytes: bytes("fact"),
442
+ writer: bot("bot-1"),
443
+ expectedGenerationId: null,
444
+ });
445
+ expect(written.status).toBe("ok");
446
+
447
+ expect(
448
+ await sync.read(memoryShardPathV1(USER_MEMORY, "bot-1", "profile.md")),
449
+ ).toMatchObject({ status: "ok" });
450
+ for (const root of [BOT_MEMORY, USER_MEMORY, PROJECT_MEMORY]) {
451
+ expect(
452
+ await sync.write({
453
+ path: memoryShardPathV1(root, "bot-1", "profile.md"),
454
+ bytes: bytes("x"),
455
+ writer: bot("bot-1"),
456
+ expectedGenerationId: null,
457
+ }),
458
+ ).toMatchObject({ status: "refused" });
459
+ }
460
+ });
461
+
462
+ // The one caller that may carry `unattributed`: a shell wrote the file on
463
+ // the Computer, so nothing recorded who wrote it, and the alternative to
464
+ // recording that truthfully is losing a durable-root file. It carries no
465
+ // authority — `isLoadableSkillSourceV1` refuses it.
466
+ test("the sync surface mirrors an unattributed file, and no other surface may", async () => {
467
+ const { sync, files, memory } = harness();
468
+ const unattributed: WorkspaceWriterV1 = { kind: "unattributed" };
469
+
470
+ expect(
471
+ await sync.write({
472
+ path: { root: INSTRUCTIONS, path: "skills/shell/SKILL.md" },
473
+ bytes: bytes("# from a shell"),
474
+ writer: unattributed,
475
+ expectedGenerationId: null,
476
+ }),
477
+ ).toMatchObject({ status: "ok" });
478
+ expect(
479
+ await files.write({
480
+ path: { root: INSTRUCTIONS, path: "skills/other/SKILL.md" },
481
+ bytes: bytes("x"),
482
+ writer: unattributed,
483
+ expectedGenerationId: null,
484
+ }),
485
+ ).toMatchObject({ status: "refused" });
486
+ expect(
487
+ await memory.write({
488
+ path: memoryShardPathV1(USER_MEMORY, "bot-1", "profile.md"),
489
+ bytes: bytes("x"),
490
+ writer: unattributed,
491
+ expectedGenerationId: null,
492
+ }),
493
+ ).toMatchObject({ status: "refused" });
494
+
495
+ const read = await files.read({
496
+ root: INSTRUCTIONS,
497
+ path: "skills/shell/SKILL.md",
498
+ });
499
+ if (read.status !== "ok") throw new Error(read.reason);
500
+ expect(read.file.generation.writer).toEqual(unattributed);
501
+ expect(
502
+ isLoadableSkillSourceV1(
503
+ {
504
+ path: { root: INSTRUCTIONS, path: "skills/shell/SKILL.md" },
505
+ writer: read.file.generation.writer,
506
+ generation: read.file.generation,
507
+ },
508
+ { botId: "bot-1", userId: USER },
509
+ ),
510
+ ).toBe(false);
511
+ });
512
+
513
+ test("another User's root is refused outright", async () => {
514
+ const { files } = harness();
515
+ expect(
516
+ await files.read({
517
+ root: { kind: "bot-instructions", userId: "user-2", botId: "bot-1" },
518
+ path: "notes.md",
519
+ }),
520
+ ).toMatchObject({ status: "refused" });
521
+ });
522
+
523
+ test("a file beyond the contract byte bound is refused", async () => {
524
+ const { files } = harness();
525
+ expect(
526
+ await files.write({
527
+ path: { root: INSTRUCTIONS, path: "big.md" },
528
+ bytes: new Uint8Array(1_048_577),
529
+ writer: user,
530
+ expectedGenerationId: null,
531
+ }),
532
+ ).toMatchObject({ status: "refused" });
533
+ });
534
+ });
535
+
536
+ describe("shared Memory tiers are sharded per writing Bot", () => {
537
+ test("a Bot writes its own shard and no other", async () => {
538
+ const { memory } = harness();
539
+ expect(
540
+ await memory.write({
541
+ path: memoryShardPathV1(USER_MEMORY, "bot-1", "profile.md"),
542
+ bytes: bytes("- (2026-08-31) a fact"),
543
+ writer: bot("bot-1"),
544
+ expectedGenerationId: null,
545
+ }),
546
+ ).toMatchObject({ status: "ok" });
547
+ expect(
548
+ await memory.write({
549
+ path: memoryShardPathV1(USER_MEMORY, "bot-2", "profile.md"),
550
+ bytes: bytes("- (2026-08-31) not mine to write"),
551
+ writer: bot("bot-1"),
552
+ expectedGenerationId: null,
553
+ }),
554
+ ).toMatchObject({ status: "refused" });
555
+ // An unsharded path in a shared root belongs to no Bot.
556
+ expect(
557
+ await memory.write({
558
+ path: { root: USER_MEMORY, path: "profile.md" },
559
+ bytes: bytes("x"),
560
+ writer: bot("bot-1"),
561
+ expectedGenerationId: null,
562
+ }),
563
+ ).toMatchObject({ status: "refused" });
564
+ });
565
+
566
+ test("the User may write any shard of their own Project Memory", async () => {
567
+ const { memory } = harness();
568
+ expect(
569
+ await memory.write({
570
+ path: memoryShardPathV1(PROJECT_MEMORY, "bot-9", "profile.md"),
571
+ bytes: bytes("corrected"),
572
+ writer: user,
573
+ expectedGenerationId: null,
574
+ }),
575
+ ).toMatchObject({ status: "ok" });
576
+ });
577
+
578
+ test("a listing with no shard merges every Bot's shard", async () => {
579
+ const { memory } = harness();
580
+ for (const botId of ["bot-1", "bot-10", "bot-2"]) {
581
+ const written = await memory.write({
582
+ path: memoryShardPathV1(USER_MEMORY, botId, "profile.md"),
583
+ bytes: bytes(`from ${botId}`),
584
+ writer: bot(botId),
585
+ expectedGenerationId: null,
586
+ });
587
+ expect(written.status).toBe("ok");
588
+ }
589
+
590
+ const merged = await memory.list({ root: USER_MEMORY });
591
+ expect(merged.status).toBe("ok");
592
+ if (merged.status !== "ok") return;
593
+ expect(merged.entries.map((entry) => entry.path.path).sort()).toEqual([
594
+ "by-agent/bot-1/profile.md",
595
+ "by-agent/bot-10/profile.md",
596
+ "by-agent/bot-2/profile.md",
597
+ ]);
598
+ // Every shared fact records which Bot learned it.
599
+ expect(
600
+ merged.entries.every((entry) => entry.generation.writer.kind === "bot"),
601
+ ).toBe(true);
602
+ });
603
+
604
+ test("a listing of one shard returns that shard alone", async () => {
605
+ const { memory } = harness();
606
+ for (const botId of ["bot-1", "bot-10"]) {
607
+ await memory.write({
608
+ path: memoryShardPathV1(USER_MEMORY, botId, "profile.md"),
609
+ bytes: bytes(`from ${botId}`),
610
+ writer: bot(botId),
611
+ expectedGenerationId: null,
612
+ });
613
+ }
614
+
615
+ const shard = await memory.list({
616
+ root: USER_MEMORY,
617
+ prefix: "by-agent/bot-1",
618
+ });
619
+ expect(shard.status).toBe("ok");
620
+ if (shard.status !== "ok") return;
621
+ expect(shard.entries.map((entry) => entry.path.path)).toEqual([
622
+ "by-agent/bot-1/profile.md",
623
+ ]);
624
+ });
625
+
626
+ test("the Memory projection of the store exposes no write path", () => {
627
+ const { memory } = harness();
628
+ const projection: Record<string, unknown> = workspaceMemoryProjectionV1(
629
+ memory,
630
+ ) as unknown as Record<string, unknown>;
631
+ expect(Object.keys(projection).sort()).toEqual(["list", "read", "stat"]);
632
+ expect(projection.write).toBe(undefined);
633
+ expect(projection.delete).toBe(undefined);
634
+ });
635
+ });
636
+
637
+ describe("listing bounds", () => {
638
+ test("pages through a root with a cursor", async () => {
639
+ const { files } = harness();
640
+ for (const name of ["a.md", "b.md", "c.md"]) {
641
+ await files.write({
642
+ path: { root: INSTRUCTIONS, path: name },
643
+ bytes: bytes(name),
644
+ writer: user,
645
+ expectedGenerationId: null,
646
+ });
647
+ }
648
+
649
+ const first = await files.list({ root: INSTRUCTIONS, limit: 2 });
650
+ expect(first.status).toBe("ok");
651
+ if (first.status !== "ok") return;
652
+ expect(first.entries).toHaveLength(2);
653
+ expect(first.cursor).toBeTruthy();
654
+
655
+ const rest = await files.list({
656
+ root: INSTRUCTIONS,
657
+ limit: 2,
658
+ cursor: first.cursor,
659
+ });
660
+ expect(rest.status).toBe("ok");
661
+ if (rest.status !== "ok") return;
662
+ expect(rest.entries.map((entry) => entry.path.path)).toEqual(["c.md"]);
663
+ expect(rest.cursor).toBe(undefined);
664
+ });
665
+
666
+ test("a traversing path or prefix is refused, never normalized", async () => {
667
+ const { files } = harness();
668
+ expect(
669
+ await files.list({ root: INSTRUCTIONS, prefix: "../elsewhere" }),
670
+ ).toMatchObject({ status: "refused" });
671
+ expect(
672
+ await files.read({ root: INSTRUCTIONS, path: "../elsewhere.md" }),
673
+ ).toMatchObject({ status: "refused" });
674
+ });
675
+ });
676
+
677
+ describe("an unrecorded file is still overwritable by the writer that read it", () => {
678
+ test("a write whose ledger record never landed is overwritten by the generation read returned", async () => {
679
+ const { files, generations } = harness();
680
+ // The `record` that follows a `put` fails once: the bytes land, and the
681
+ // only place their generation exists is beside them in the object store.
682
+ const record = generations.record;
683
+ let failed = false;
684
+ generations.record = (entry) => {
685
+ if (failed) return record(entry);
686
+ failed = true;
687
+ return Promise.reject(new Error("the ledger is unreachable"));
688
+ };
689
+
690
+ const first = await files.write({
691
+ path: { root: INSTRUCTIONS, path: "notes.md" },
692
+ bytes: bytes("first"),
693
+ writer: user,
694
+ expectedGenerationId: null,
695
+ });
696
+ expect(first.status).toBe("unavailable");
697
+ expect(await generations.current(INSTRUCTIONS, "notes.md")).toBeUndefined();
698
+
699
+ // The file reads back with the generation its writer minted…
700
+ const read = await files.read({ root: INSTRUCTIONS, path: "notes.md" });
701
+ expect(read.status).toBe("ok");
702
+ if (read.status !== "ok") return;
703
+ expect(read.file.generation.writer).toEqual(user);
704
+ // …and reading it repairs the ledger rather than leaving it wedged.
705
+ expect(
706
+ (await generations.current(INSTRUCTIONS, "notes.md"))?.generation
707
+ .generationId,
708
+ ).toBe(read.file.generation.generationId);
709
+
710
+ // A writer that passes exactly the generation it read wins.
711
+ const second = await files.write({
712
+ path: { root: INSTRUCTIONS, path: "notes.md" },
713
+ bytes: bytes("second"),
714
+ writer: user,
715
+ expectedGenerationId: read.file.generation.generationId,
716
+ });
717
+ expect(second.status).toBe("ok");
718
+ expect(
719
+ await files.read({ root: INSTRUCTIONS, path: "notes.md" }),
720
+ ).toMatchObject({ status: "ok" });
721
+ const after = await files.read({ root: INSTRUCTIONS, path: "notes.md" });
722
+ if (after.status !== "ok") return;
723
+ expect(text(after.file.bytes)).toBe("second");
724
+ });
725
+
726
+ test("an object mirrored with its generation in metadata and no record is overwritable too", async () => {
727
+ const { files, sync, generations } = harness();
728
+ // The Computer-side sync mirrored a file straight into object storage.
729
+ const mirrored = await sync.write({
730
+ path: { root: INSTRUCTIONS, path: "notes.md" },
731
+ bytes: bytes("mirrored"),
732
+ writer: { kind: "unattributed" },
733
+ expectedGenerationId: null,
734
+ });
735
+ expect(mirrored.status).toBe("ok");
736
+ if (mirrored.status !== "ok") return;
737
+ // …and the ledger lost the record, as an evicted-then-restored object
738
+ // would if its `record` never landed.
739
+ await generations.record({
740
+ schemaVersion: 1,
741
+ root: INSTRUCTIONS,
742
+ path: "notes.md",
743
+ generation: mirrored.generation,
744
+ etag: "gone",
745
+ });
746
+
747
+ const stat = await files.stat({ root: INSTRUCTIONS, path: "notes.md" });
748
+ expect(stat.status).toBe("ok");
749
+ if (stat.status !== "ok") return;
750
+ const written = await files.write({
751
+ path: { root: INSTRUCTIONS, path: "notes.md" },
752
+ bytes: bytes("authored"),
753
+ writer: user,
754
+ expectedGenerationId: stat.entry.generation.generationId,
755
+ });
756
+ expect(written.status).toBe("ok");
757
+ });
758
+ });
759
+
760
+ describe("a delete is fenced, never a read-then-unconditional-delete", () => {
761
+ test("a write landing between the head and the fence wins, and the deletion is preserved", async () => {
762
+ const { files, bucket, generations } = harness();
763
+ const first = await files.write({
764
+ path: { root: INSTRUCTIONS, path: "notes.md" },
765
+ bytes: bytes("first"),
766
+ writer: user,
767
+ expectedGenerationId: null,
768
+ });
769
+ expect(first.status).toBe("ok");
770
+ if (first.status !== "ok") return;
771
+
772
+ // The race: a write lands the instant the delete has read the head it
773
+ // will condition on. An unconditional delete would destroy it.
774
+ const key = workspaceObjectKeyV1(INSTRUCTIONS, "notes.md");
775
+ const head = bucket.head;
776
+ let raced = false;
777
+ let racing: Awaited<ReturnType<WorkspaceFilesV1["write"]>> | undefined;
778
+ bucket.head = async (probed: string) => {
779
+ const answer = await head(probed);
780
+ if (!raced && probed === key) {
781
+ raced = true;
782
+ racing = await files.write({
783
+ path: { root: INSTRUCTIONS, path: "notes.md" },
784
+ bytes: bytes("racing"),
785
+ writer: bot("bot-1"),
786
+ expectedGenerationId: first.generation.generationId,
787
+ });
788
+ }
789
+ return answer;
790
+ };
791
+
792
+ const removed = await files.delete({
793
+ path: { root: INSTRUCTIONS, path: "notes.md" },
794
+ writer: user,
795
+ expectedGenerationId: first.generation.generationId,
796
+ });
797
+ bucket.head = head;
798
+
799
+ expect(racing?.status).toBe("ok");
800
+ if (racing?.status !== "ok") return;
801
+ expect(removed.status).toBe("conflict");
802
+ if (!isWorkspaceConflictV1(removed)) return;
803
+
804
+ // The racing write survives, untouched by the delete.
805
+ const survivor = await files.read({ root: INSTRUCTIONS, path: "notes.md" });
806
+ expect(survivor.status).toBe("ok");
807
+ if (survivor.status !== "ok") return;
808
+ expect(text(survivor.file.bytes)).toBe("racing");
809
+
810
+ // Both generations are surfaced, and the losing deletion is preserved.
811
+ expect(removed.current?.generationId).toBe(racing.generation.generationId);
812
+ const preserved = removed.preserved;
813
+ expect(preserved).toBeDefined();
814
+ if (!preserved) return;
815
+ expect(preserved.conflictsWith).toBe(racing.generation.generationId);
816
+ const conflicts = await generations.conflicts(INSTRUCTIONS, "notes.md");
817
+ expect(conflicts).toHaveLength(1);
818
+ expect(conflicts[0]?.generation.generationId).toBe(preserved.generationId);
819
+ expect(conflicts[0]?.generation.writer).toEqual(user);
820
+ // No tombstone was recorded: nothing was deleted.
821
+ expect(generations.tombstones()).toEqual([]);
822
+ });
823
+
824
+ test("the tombstone marker reads as absence and does not block a create", async () => {
825
+ const { files, bucket } = harness();
826
+ const written = await files.write({
827
+ path: { root: INSTRUCTIONS, path: "notes.md" },
828
+ bytes: bytes("first"),
829
+ writer: user,
830
+ expectedGenerationId: null,
831
+ });
832
+ if (written.status !== "ok") return;
833
+
834
+ const removed = await files.delete({
835
+ path: { root: INSTRUCTIONS, path: "notes.md" },
836
+ writer: user,
837
+ expectedGenerationId: written.generation.generationId,
838
+ });
839
+ expect(removed.status).toBe("ok");
840
+
841
+ // The marker is still in the bucket, and it is an absence everywhere.
842
+ expect(bucket.keys()).toEqual([
843
+ workspaceObjectKeyV1(INSTRUCTIONS, "notes.md"),
844
+ ]);
845
+ expect(
846
+ await files.read({ root: INSTRUCTIONS, path: "notes.md" }),
847
+ ).toMatchObject({ status: "not-found" });
848
+ expect(
849
+ await files.stat({ root: INSTRUCTIONS, path: "notes.md" }),
850
+ ).toMatchObject({ status: "not-found" });
851
+ const listed = await files.list({ root: INSTRUCTIONS });
852
+ expect(listed.status).toBe("ok");
853
+ if (listed.status !== "ok") return;
854
+ expect(listed.entries).toEqual([]);
855
+ expect(
856
+ await files.delete({
857
+ path: { root: INSTRUCTIONS, path: "notes.md" },
858
+ writer: user,
859
+ expectedGenerationId: written.generation.generationId,
860
+ }),
861
+ ).toMatchObject({ status: "not-found" });
862
+
863
+ // And a writer asserting absence still creates the file.
864
+ const again = await files.write({
865
+ path: { root: INSTRUCTIONS, path: "notes.md" },
866
+ bytes: bytes("again"),
867
+ writer: user,
868
+ expectedGenerationId: null,
869
+ });
870
+ expect(again.status).toBe("ok");
871
+ });
872
+ });
873
+
874
+ describe("the tombstone marker is collected out of band, never swept", () => {
875
+ test("a create conditioned on the marker between the fence and the tombstone survives", async () => {
876
+ const { files, bucket, generations } = harness();
877
+ const path = { root: INSTRUCTIONS, path: "notes.md" };
878
+ const written = await files.write({
879
+ path,
880
+ bytes: bytes("first"),
881
+ writer: user,
882
+ expectedGenerationId: null,
883
+ });
884
+ if (written.status !== "ok") throw new Error("write failed");
885
+
886
+ // The race the sweep used to lose: a create reads the marker the fence
887
+ // just wrote, conditions on its ETag, and wins — after the fence, before
888
+ // the delete finishes. An unconditional sweep would erase those bytes.
889
+ const record = generations.tombstone;
890
+ let racing: Awaited<ReturnType<WorkspaceFilesV1["write"]>> | undefined;
891
+ generations.tombstone = async (entry) => {
892
+ await record(entry);
893
+ racing ??= await files.write({
894
+ path,
895
+ bytes: bytes("recreated"),
896
+ writer: user,
897
+ expectedGenerationId: null,
898
+ });
899
+ };
900
+ const removed = await files.delete({
901
+ path,
902
+ writer: user,
903
+ expectedGenerationId: written.generation.generationId,
904
+ });
905
+ generations.tombstone = record;
906
+
907
+ expect(removed.status).toBe("ok");
908
+ expect(racing?.status).toBe("ok");
909
+ const read = await files.read(path);
910
+ expect(read.status).toBe("ok");
911
+ if (read.status !== "ok") return;
912
+ expect(text(read.file.bytes)).toBe("recreated");
913
+ expect(bucket.keys()).toEqual([
914
+ workspaceObjectKeyV1(INSTRUCTIONS, "notes.md"),
915
+ ]);
916
+ });
917
+
918
+ test("the collector removes only old markers, and never a file", async () => {
919
+ const { files, bucket } = harness();
920
+ const kept = await files.write({
921
+ path: { root: INSTRUCTIONS, path: "kept.md" },
922
+ bytes: bytes("kept"),
923
+ writer: user,
924
+ expectedGenerationId: null,
925
+ });
926
+ if (kept.status !== "ok") throw new Error("write failed");
927
+
928
+ const stale = await files.write({
929
+ path: { root: INSTRUCTIONS, path: "stale.md" },
930
+ bytes: bytes("stale"),
931
+ writer: user,
932
+ expectedGenerationId: null,
933
+ });
934
+ if (stale.status !== "ok") throw new Error("write failed");
935
+ await files.delete({
936
+ path: { root: INSTRUCTIONS, path: "stale.md" },
937
+ writer: user,
938
+ expectedGenerationId: stale.generation.generationId,
939
+ });
940
+
941
+ const recent = await files.write({
942
+ path: { root: INSTRUCTIONS, path: "recent.md" },
943
+ bytes: bytes("recent"),
944
+ writer: user,
945
+ expectedGenerationId: null,
946
+ });
947
+ if (recent.status !== "ok") throw new Error("write failed");
948
+ await files.delete({
949
+ path: { root: INSTRUCTIONS, path: "recent.md" },
950
+ writer: user,
951
+ expectedGenerationId: recent.generation.generationId,
952
+ });
953
+
954
+ const recentKey = workspaceObjectKeyV1(INSTRUCTIONS, "recent.md");
955
+ const marker = await bucket.head(recentKey);
956
+ if (!marker) throw new Error("the recent marker is missing");
957
+
958
+ const report = await gcTombstoneMarkersV1({
959
+ bucket,
960
+ olderThan: marker.uploaded,
961
+ });
962
+
963
+ expect(report.collected).toBe(1);
964
+ expect(report.skipped).toBe(1);
965
+ expect(bucket.keys()).toEqual([
966
+ workspaceObjectKeyV1(INSTRUCTIONS, "kept.md"),
967
+ recentKey,
968
+ ]);
969
+ // The file is still exactly the file, untouched by the collector.
970
+ const read = await files.read({ root: INSTRUCTIONS, path: "kept.md" });
971
+ expect(read.status).toBe("ok");
972
+ if (read.status !== "ok") return;
973
+ expect(text(read.file.bytes)).toBe("kept");
974
+ // And the collected path is creatable again by asserting absence.
975
+ expect(
976
+ await files.write({
977
+ path: { root: INSTRUCTIONS, path: "stale.md" },
978
+ bytes: bytes("again"),
979
+ writer: user,
980
+ expectedGenerationId: null,
981
+ }),
982
+ ).toMatchObject({ status: "ok" });
983
+ });
984
+ });