@fungi.computer/bonsai 0.0.0-stage → 0.1.0

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.
Files changed (133) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +848 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/present.test.ts +27 -0
  90. package/test/read-copy-calls.test.ts +691 -0
  91. package/test/read-copy-composer.test.ts +388 -0
  92. package/test/read-copy-lifetimes.test.ts +214 -0
  93. package/test/read-copy-rpc.config.jsonc +12 -0
  94. package/test/read-copy-rpc.native.mjs +47 -0
  95. package/test/read-copy-rpc.worker.ts +92 -0
  96. package/test/recent-activity.test.ts +62 -0
  97. package/test/repository-deletion.test.ts +500 -0
  98. package/test/seed-resume.test.mjs +91 -0
  99. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  100. package/test/storyboard-save-template.test.mjs +545 -0
  101. package/test/ui-errors.test.ts +976 -0
  102. package/test/workflow-store.test.ts +115 -0
  103. package/tsconfig.json +36 -0
  104. package/turbo.jsonc +25 -0
  105. package/vite.config.ts +19 -0
  106. package/vitest.config.ts +47 -0
  107. package/web/api.ts +224 -0
  108. package/web/app-build-panel.tsx +171 -0
  109. package/web/board.tsx +259 -0
  110. package/web/bonsai.css +642 -0
  111. package/web/change-page.tsx +719 -0
  112. package/web/change-rows.tsx +135 -0
  113. package/web/code-tab.tsx +267 -0
  114. package/web/commit-tree.tsx +152 -0
  115. package/web/format.ts +54 -0
  116. package/web/home-page.tsx +274 -0
  117. package/web/index.html +17 -0
  118. package/web/insight-data.ts +76 -0
  119. package/web/insight-tiles.tsx +534 -0
  120. package/web/insights.tsx +250 -0
  121. package/web/issues-panel.tsx +305 -0
  122. package/web/main.tsx +72 -0
  123. package/web/network-graph.tsx +647 -0
  124. package/web/overview.tsx +339 -0
  125. package/web/pages.tsx +469 -0
  126. package/web/parts.tsx +381 -0
  127. package/web/public/favicon.svg +1 -0
  128. package/web/repo-navigation.tsx +256 -0
  129. package/web/repo-settings.tsx +145 -0
  130. package/web/router.tsx +151 -0
  131. package/web/transport.ts +91 -0
  132. package/web/views.tsx +151 -0
  133. package/web/working-now.tsx +98 -0
@@ -0,0 +1,2714 @@
1
+ import * as z from "zod";
2
+ import { replayKey } from "@fungi.computer/cairn";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { afterEach, expect, it } from "vitest";
5
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
6
+ import { commitFiles } from "@fungi.computer/agar/git-commit";
7
+ import { GitStepMaterialError } from "@fungi.computer/agar/git-steps";
8
+ import { countingGit } from "@fungi.computer/agar/read-copy";
9
+ import { realGit } from "@fungi.computer/agar/test-kit/git";
10
+ import { gitStore } from "../backend/git.js";
11
+ import { BonsaiState, ensureBonsaiTables } from "../backend/state.js";
12
+ import { appBackend } from "../backend/methods.js";
13
+ import { observePush } from "../backend/service.js";
14
+ import { getRepoDetail } from "../backend/repository-copy.js";
15
+ import { readFileCopy } from "../backend/files-copy.js";
16
+ import { refuseBackground } from "../backend/background.js";
17
+ import {
18
+ saveFileEdit,
19
+ executeQueuedFileEdit,
20
+ cleanupSettledFileEdit,
21
+ projectQueuedFileEdit,
22
+ fileEditJobSchema,
23
+ } from "../backend/file-edits.js";
24
+ import {
25
+ actorLane,
26
+ backgroundContext,
27
+ backgroundOriginKey,
28
+ sealBackgroundOrigin,
29
+ admitClaimedOrigin,
30
+ } from "@fungi.computer/puffball/background-replay";
31
+ import {
32
+ Watchdog,
33
+ type ConcurrentWatchdogRuntime,
34
+ } from "@fungi.computer/watchdog";
35
+ import type {
36
+ WatchdogQueuedJob,
37
+ WatchdogRunningJob,
38
+ } from "@fungi.computer/watchdog";
39
+ import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
40
+ import { jsonValue, type JsonValue } from "../shared/contract.js";
41
+ import {
42
+ fileContent,
43
+ fileSaveInput,
44
+ fileSaveResult,
45
+ } from "@fungi.computer/agar/roles/files";
46
+ import * as Schema from "effect/Schema";
47
+ import { appDeclarationSchema } from "@fungi.computer/puffball/app";
48
+ import { buildAppDeclaration } from "@fungi.computer/agar/backend-declaration";
49
+ import { appBackend as bonsaiBackend } from "../backend/methods.js";
50
+ import bonsaiMetadata from "../package.json" with { type: "json" };
51
+ const appDeclaration = buildAppDeclaration(
52
+ bonsaiBackend,
53
+ bonsaiMetadata.fungi.app,
54
+ );
55
+
56
+ const close: (() => void)[] = [];
57
+ afterEach(() => {
58
+ for (const dispose of close.splice(0)) dispose();
59
+ });
60
+ const encode = (text: string) => new TextEncoder().encode(text);
61
+ const member = {
62
+ actor: { kind: "member" as const, memberId: "owner" },
63
+ computerId: null,
64
+ };
65
+ async function sealedWrite(
66
+ caller: AppMethodCallContext,
67
+ method = "files.save",
68
+ ) {
69
+ const key = await backgroundOriginKey("file-sync-native-owner-test");
70
+ const origin = await sealBackgroundOrigin(key, {
71
+ instanceId: "bonsai-test",
72
+ lane: actorLane(caller.actor),
73
+ method,
74
+ });
75
+ return backgroundContext(
76
+ caller,
77
+ [],
78
+ { name: method, effect: "write" },
79
+ origin,
80
+ );
81
+ }
82
+ async function claimedAdmission(caller: AppMethodCallContext = member) {
83
+ const key = await backgroundOriginKey("file-sync-native-owner-test");
84
+ return {
85
+ admit: (origin: Parameters<typeof admitClaimedOrigin>[2]) =>
86
+ admitClaimedOrigin(
87
+ key,
88
+ {
89
+ instanceId: "bonsai-test",
90
+ lane: actorLane(caller.actor),
91
+ admitted: ["files.save"],
92
+ },
93
+ origin,
94
+ ),
95
+ };
96
+ }
97
+ async function fixture() {
98
+ const database = new DatabaseSync(":memory:");
99
+ const owner = fromNodeSqlite(database);
100
+ ensureBonsaiTables(owner.sql);
101
+ const state = new BonsaiState(owner.sql, owner.transactionSync);
102
+ const provider = realGit();
103
+ close.push(() => {
104
+ database.close();
105
+ provider.close();
106
+ });
107
+ await provider.git.create("pond");
108
+ state.registerRepo({
109
+ name: "pond",
110
+ description: null,
111
+ parent: null,
112
+ owner: null,
113
+ });
114
+ const initial = await commitFiles(provider.git, {
115
+ repository: "pond",
116
+ branch: "main",
117
+ expectedHead: null,
118
+ changes: [{ path: "README.md", content: encode("Pond\n") }],
119
+ message: "Plant pond",
120
+ author: { name: "seed", email: "seed@test.invalid" },
121
+ });
122
+ if (initial.kind !== "committed") throw new Error("Fixture did not commit");
123
+ const controller = new AbortController();
124
+ close.push(() => {
125
+ controller.abort();
126
+ });
127
+ const counted = countingGit(provider.git, {
128
+ limit: 10000,
129
+ signal: controller.signal,
130
+ });
131
+ const bonsai = {
132
+ state,
133
+ store: gitStore(counted.git),
134
+ events: { emit: () => Promise.resolve() },
135
+ };
136
+ await getRepoDetail(bonsai, "pond");
137
+ await readFileCopy(bonsai, { repo: "pond", ref: "main", path: "README.md" });
138
+ const call = (
139
+ name: string,
140
+ input: JsonValue,
141
+ caller: AppMethodCallContext = member,
142
+ ) =>
143
+ appBackend
144
+ .call(name, input, { bonsai }, caller)
145
+ .then((value) => jsonValue.parse(value));
146
+ return { provider, counted, bonsai, call, initial: initial.commit };
147
+ }
148
+
149
+ async function claimFileSync(
150
+ f: Awaited<ReturnType<typeof fixture>>,
151
+ signal: AbortSignal,
152
+ ) {
153
+ const background = await claimedAdmission();
154
+ const runtime: ConcurrentWatchdogRuntime = await Watchdog.make({
155
+ owner: f.bonsai.state.backgroundOwner,
156
+ concurrentLane: actorLane(member.actor),
157
+ project: (transition) => {
158
+ projectQueuedFileEdit(f.bonsai, transition);
159
+ },
160
+ isAlive: () => Promise.resolve(false),
161
+ wake: { recompute: () => Promise.resolve() },
162
+ execute: (job) =>
163
+ executeQueuedFileEdit(
164
+ f.bonsai,
165
+ member,
166
+ job,
167
+ runtime.transactional.enqueueAcceptedJob,
168
+ background,
169
+ signal,
170
+ ),
171
+ });
172
+ const tick = await runtime.tickConcurrent();
173
+ if (tick.status === "settled")
174
+ await cleanupSettledFileEdit(f.bonsai, tick.job);
175
+ return tick;
176
+ }
177
+ async function replayFileSync(f: Awaited<ReturnType<typeof fixture>>) {
178
+ for (let frame = 0; frame < 16; frame++) {
179
+ const controller = new AbortController();
180
+ const tick = await claimFileSync(f, controller.signal);
181
+ controller.abort();
182
+ if (tick.status === "no_ready") return;
183
+ if (tick.status !== "settled")
184
+ throw new Error("File replay did not settle");
185
+ }
186
+ throw new Error("File replay did not reach a terminal state");
187
+ }
188
+ it("files.read returns the exact commit and blob baseline from the ready copy", async () => {
189
+ const f = await fixture();
190
+ const before = f.counted.total();
191
+ const result = fileContent.parse(
192
+ await f.call("files.read", {
193
+ repo: "pond",
194
+ ref: "main",
195
+ path: "README.md",
196
+ }),
197
+ );
198
+ expect(result.blob).toMatch(/^[0-9a-f]{40}$/u);
199
+ expect(result).toMatchObject({
200
+ commit: f.initial,
201
+ text: "Pond\n",
202
+ });
203
+ expect(f.counted.total() - before).toBe(0);
204
+ });
205
+
206
+ it("an Agent reads committed typed absence without another member's new-file text or revision", async () => {
207
+ const f = await fixture();
208
+ const input = { repo: "pond", ref: "main", path: "new.txt" };
209
+ const baseline = fileContent.parse(await f.call("files.read", input));
210
+ expect(baseline).toMatchObject({
211
+ commit: f.initial,
212
+ blob: null,
213
+ text: null,
214
+ version: null,
215
+ });
216
+ await f.call(
217
+ "files.save",
218
+ {
219
+ repo: "pond",
220
+ branch: "main",
221
+ path: "new.txt",
222
+ baseCommit: baseline.commit,
223
+ baseBlob: null,
224
+ text: "Member-private new file",
225
+ revision: null,
226
+ },
227
+ await sealedWrite(member),
228
+ );
229
+ expect(await f.call("files.read", input)).toMatchObject({
230
+ version: 1,
231
+ pending: { text: "Member-private new file", revision: 1 },
232
+ });
233
+ const before = f.counted.total();
234
+ const absent = await f.call("files.read", input, {
235
+ actor: { kind: "agent", agentId: "luna" },
236
+ computerId: null,
237
+ });
238
+ expect(absent).toMatchObject({
239
+ commit: f.initial,
240
+ blob: null,
241
+ text: null,
242
+ version: null,
243
+ });
244
+ expect(absent).not.toHaveProperty("pending");
245
+ expect(f.counted.total() - before).toBe(0);
246
+ });
247
+
248
+ it("a member save persists pending text with zero Git calls and only that member can read it", async () => {
249
+ const f = await fixture();
250
+ const baseline = await f.provider.git.readCommit("pond", f.initial);
251
+ if (baseline === null) throw new Error("Missing seed commit");
252
+ const tree = await f.provider.git.readTree("pond", baseline.treeHash);
253
+ const blob = tree?.find((entry) => entry.name === "README.md")?.hash;
254
+ if (blob === undefined) throw new Error("Missing seed blob");
255
+ const before = f.counted.total();
256
+ expect(
257
+ await f.call(
258
+ "files.save",
259
+ {
260
+ repo: "pond",
261
+ branch: "main",
262
+ path: "README.md",
263
+ baseBlob: blob,
264
+ baseCommit: f.initial,
265
+ text: "Edited\n",
266
+ revision: null,
267
+ },
268
+ await sealedWrite(member),
269
+ ),
270
+ ).toMatchObject({ kind: "saved", revision: 1, state: "pending" });
271
+ expect(f.counted.total() - before).toBe(0);
272
+ expect(
273
+ await f.call("files.read", {
274
+ repo: "pond",
275
+ ref: "main",
276
+ path: "README.md",
277
+ }),
278
+ ).toMatchObject({
279
+ pending: { text: "Edited\n", revision: 1, state: "pending" },
280
+ });
281
+ expect(
282
+ await f.call(
283
+ "files.read",
284
+ { repo: "pond", ref: "main", path: "README.md" },
285
+ { ...member, actor: { kind: "member", memberId: "other" } },
286
+ ),
287
+ ).not.toHaveProperty("pending");
288
+ });
289
+
290
+ it("reopening a synced file returns the member row version without exposing it to another caller", async () => {
291
+ const f = await fixture();
292
+ const baseline = fileContent.parse(
293
+ await f.call("files.read", {
294
+ repo: "pond",
295
+ ref: "main",
296
+ path: "README.md",
297
+ }),
298
+ );
299
+ const saved = await f.call(
300
+ "files.save",
301
+ {
302
+ repo: "pond",
303
+ branch: "main",
304
+ path: "README.md",
305
+ baseBlob: baseline.blob,
306
+ baseCommit: baseline.commit,
307
+ text: "Synced first edit\n",
308
+ revision: null,
309
+ },
310
+ await sealedWrite(member),
311
+ );
312
+ expect(saved).toMatchObject({ kind: "saved", revision: 1, state: "pending" });
313
+ await replayFileSync(f);
314
+ expect(
315
+ await f.call("files.sync", {}, await sealedWrite(member, "files.sync")),
316
+ ).toMatchObject({
317
+ settled: [{ result: { kind: "synced", revision: 1 } }],
318
+ more: false,
319
+ });
320
+ const reopened = await f.call("files.read", {
321
+ repo: "pond",
322
+ ref: "main",
323
+ path: "README.md",
324
+ });
325
+ expect(reopened).toMatchObject({ text: "Synced first edit\n", version: 1 });
326
+ expect(reopened).not.toHaveProperty("pending");
327
+ expect(
328
+ await f.call(
329
+ "files.read",
330
+ { repo: "pond", ref: "main", path: "README.md" },
331
+ {
332
+ ...member,
333
+ actor: { kind: "member", memberId: "other" },
334
+ },
335
+ ),
336
+ ).toMatchObject({ text: "Synced first edit\n", version: null });
337
+ const next = fileContent.parse(reopened);
338
+ const before = f.counted.total();
339
+ expect(
340
+ await f.call(
341
+ "files.save",
342
+ {
343
+ repo: "pond",
344
+ branch: "main",
345
+ path: "README.md",
346
+ baseCommit: next.commit,
347
+ baseBlob: next.blob,
348
+ text: "Second edit\n",
349
+ revision: next.version,
350
+ },
351
+ await sealedWrite(member),
352
+ ),
353
+ ).toMatchObject({ kind: "saved", revision: 2, state: "pending" });
354
+ expect(f.counted.total() - before).toBe(0);
355
+ });
356
+
357
+ it.each(["deleted", "new"] as const)(
358
+ "reopens a member's %s pending file at the real branch baseline",
359
+ async (kind) => {
360
+ const f = await fixture();
361
+ const baseline = fileContent.parse(
362
+ await f.call("files.read", {
363
+ repo: "pond",
364
+ ref: "main",
365
+ path: "README.md",
366
+ }),
367
+ );
368
+ const path = kind === "new" ? "NEW.md" : "README.md";
369
+ const before = f.counted.total();
370
+ expect(
371
+ await f.call(
372
+ "files.save",
373
+ {
374
+ repo: "pond",
375
+ branch: "main",
376
+ path,
377
+ baseBlob: kind === "new" ? null : baseline.blob,
378
+ baseCommit: baseline.commit,
379
+ text: "Private saved text\n",
380
+ revision: null,
381
+ },
382
+ await sealedWrite(member),
383
+ ),
384
+ ).toMatchObject({ kind: "saved", revision: 1 });
385
+ expect(f.counted.total() - before).toBe(0);
386
+ let currentCommit = f.initial;
387
+ if (kind === "deleted") {
388
+ const deleted = await commitFiles(f.provider.git, {
389
+ repository: "pond",
390
+ branch: "main",
391
+ expectedHead: f.initial,
392
+ changes: [{ path, content: null }],
393
+ message: "Delete file",
394
+ author: { name: "luna", email: "luna@test.invalid" },
395
+ });
396
+ if (deleted.kind !== "committed")
397
+ throw new Error("Deletion fixture did not commit");
398
+ currentCommit = deleted.commit;
399
+ await observePush(f.bonsai, {
400
+ eventId: "delete-file",
401
+ repository: "pond",
402
+ writer: { kind: "actor", actor: { kind: "agent", agentId: "luna" } },
403
+ updates: [
404
+ { ref: "refs/heads/main", before: f.initial, after: deleted.commit },
405
+ ],
406
+ });
407
+ }
408
+ f.bonsai.state = new BonsaiState(
409
+ f.bonsai.state.backgroundOwner.sql,
410
+ f.bonsai.state.backgroundOwner.transactionSync,
411
+ );
412
+ const reopened = await f.call("files.read", {
413
+ repo: "pond",
414
+ ref: "main",
415
+ path,
416
+ });
417
+ expect(reopened).toMatchObject({
418
+ commit: currentCommit,
419
+ blob: null,
420
+ text: null,
421
+ size: 0,
422
+ binary: false,
423
+ version: 1,
424
+ pending: { text: "Private saved text\n", revision: 1, state: "pending" },
425
+ });
426
+ expect(
427
+ await f.call(
428
+ "files.read",
429
+ { repo: "pond", ref: "main", path },
430
+ {
431
+ ...member,
432
+ actor: { kind: "member", memberId: "other" },
433
+ },
434
+ ),
435
+ ).toMatchObject({
436
+ commit: currentCommit,
437
+ blob: null,
438
+ text: null,
439
+ version: null,
440
+ });
441
+ for (const caller of [
442
+ { actor: { kind: "agent" as const, agentId: "luna" }, computerId: null },
443
+ { actor: { kind: "team-key" as const, keyId: "key" }, computerId: null },
444
+ {
445
+ ...member,
446
+ workflow: {
447
+ workflowId: "flow",
448
+ runId: "run",
449
+ nodeId: "node",
450
+ armedBy: member.actor,
451
+ },
452
+ },
453
+ ]) {
454
+ const committed = await f.call(
455
+ "files.read",
456
+ { repo: "pond", ref: "main", path },
457
+ caller,
458
+ );
459
+ expect(committed).toMatchObject({
460
+ commit: currentCommit,
461
+ blob: null,
462
+ text: null,
463
+ version: null,
464
+ });
465
+ expect(committed).not.toHaveProperty("pending");
466
+ }
467
+ },
468
+ );
469
+
470
+ it("Workflow file writes cannot probe, replace, discard or flush the armer's private row", async () => {
471
+ const f = await fixture();
472
+ const baseline = fileContent.parse(
473
+ await f.call("files.read", {
474
+ repo: "pond",
475
+ ref: "main",
476
+ path: "README.md",
477
+ }),
478
+ );
479
+ const input = {
480
+ repo: "pond",
481
+ branch: "main",
482
+ path: "README.md",
483
+ baseBlob: baseline.blob,
484
+ baseCommit: baseline.commit,
485
+ text: "Private pending secret\n",
486
+ revision: null,
487
+ };
488
+ await f.call("files.save", input, await sealedWrite(member));
489
+ const before = f.counted.total();
490
+ const caller = {
491
+ ...member,
492
+ workflow: {
493
+ workflowId: "flow",
494
+ runId: "run",
495
+ nodeId: "node",
496
+ armedBy: member.actor,
497
+ },
498
+ };
499
+ await expect(f.call("files.sync", {}, caller)).rejects.toMatchObject({
500
+ code: "forbidden",
501
+ });
502
+ for (const revision of [null, 1]) {
503
+ await expect(
504
+ f.call(
505
+ "files.save",
506
+ { ...input, text: "Workflow text\n", revision },
507
+ caller,
508
+ ),
509
+ ).rejects.toMatchObject({ code: "forbidden" });
510
+ }
511
+ await expect(
512
+ f.call(
513
+ "files.discard",
514
+ { repo: "pond", branch: "main", path: "README.md", revision: 1 },
515
+ caller,
516
+ ),
517
+ ).rejects.toMatchObject({ code: "forbidden" });
518
+ expect(f.counted.total() - before).toBe(0);
519
+ expect(
520
+ await f.call("files.read", {
521
+ repo: "pond",
522
+ ref: "main",
523
+ path: "README.md",
524
+ }),
525
+ ).toMatchObject({
526
+ version: 1,
527
+ pending: {
528
+ text: "Private pending secret\n",
529
+ revision: 1,
530
+ state: "pending",
531
+ },
532
+ });
533
+ expect(
534
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
535
+ ).toHaveLength(1);
536
+ // R1 replay is ordinary member attribution with no Workflow and no enqueue eligibility.
537
+ await replayFileSync(f);
538
+ expect(await f.call("files.sync", {}, member)).toMatchObject({
539
+ settled: [{ result: { kind: "synced", revision: 1 } }],
540
+ more: false,
541
+ });
542
+ });
543
+
544
+ it.each(["agent", "team-key"] as const)(
545
+ "%s cannot use any private file-write method",
546
+ async (kind) => {
547
+ const f = await fixture();
548
+ const baseline = fileContent.parse(
549
+ await f.call("files.read", {
550
+ repo: "pond",
551
+ ref: "main",
552
+ path: "README.md",
553
+ }),
554
+ );
555
+ const caller: AppMethodCallContext = {
556
+ computerId: null,
557
+ actor:
558
+ kind === "agent"
559
+ ? { kind: "agent", agentId: "luna" }
560
+ : { kind: "team-key", keyId: "key" },
561
+ };
562
+ const before = f.counted.total();
563
+ await expect(
564
+ f.call(
565
+ "files.save",
566
+ {
567
+ repo: "pond",
568
+ branch: "main",
569
+ path: "README.md",
570
+ baseBlob: baseline.blob,
571
+ baseCommit: baseline.commit,
572
+ text: "Foreign text\n",
573
+ revision: null,
574
+ },
575
+ caller,
576
+ ),
577
+ ).rejects.toMatchObject({ code: "forbidden" });
578
+ await expect(f.call("files.sync", {}, caller)).rejects.toMatchObject({
579
+ code: "forbidden",
580
+ });
581
+ await expect(
582
+ f.call(
583
+ "files.discard",
584
+ { repo: "pond", branch: "main", path: "README.md", revision: 1 },
585
+ caller,
586
+ ),
587
+ ).rejects.toMatchObject({ code: "forbidden" });
588
+ expect(f.counted.total() - before).toBe(0);
589
+ },
590
+ );
591
+
592
+ it.each([false, true])(
593
+ "a stranded lane recovers independently while another lane tick is held=%s",
594
+ async (held) => {
595
+ const f = await fixture();
596
+ const other = {
597
+ actor: { kind: "member" as const, memberId: "other" },
598
+ computerId: null,
599
+ };
600
+ const baseline = fileContent.parse(
601
+ await f.call("files.read", {
602
+ repo: "pond",
603
+ ref: "main",
604
+ path: "README.md",
605
+ }),
606
+ );
607
+ const key = { repo: "pond", branch: "main", path: "README.md" };
608
+ for (const caller of [member, other])
609
+ await f.call(
610
+ "files.save",
611
+ {
612
+ ...key,
613
+ baseCommit: baseline.commit,
614
+ baseBlob: baseline.blob,
615
+ text: `${caller.actor.memberId}\n`,
616
+ revision: null,
617
+ },
618
+ await sealedWrite(caller),
619
+ );
620
+ // A valid persisted claim survives the isolate that previously owned it.
621
+ f.bonsai.state.backgroundOwner.sql.exec(
622
+ "UPDATE watchdog_jobs SET state='running', claim_token='dead-isolate' WHERE lane=? AND state='queued'",
623
+ actorLane(other.actor),
624
+ );
625
+ expect(
626
+ f.bonsai.state.backgroundOwner.sql
627
+ .exec(
628
+ "SELECT state FROM watchdog_jobs WHERE lane=?",
629
+ actorLane(other.actor),
630
+ )
631
+ .toArray(),
632
+ ).toEqual([{ state: "running" }]);
633
+ const arrived = Promise.withResolvers<undefined>();
634
+ const release = Promise.withResolvers<undefined>();
635
+ const binding = f.counted.git;
636
+ let block = held;
637
+ f.bonsai.store = gitStore({
638
+ ...binding,
639
+ refs: async (repo) => {
640
+ if (block) {
641
+ block = false;
642
+ arrived.resolve(undefined);
643
+ await release.promise;
644
+ }
645
+ return binding.refs(repo);
646
+ },
647
+ });
648
+ const aAdmission = await claimedAdmission(member);
649
+ const bAdmission = await claimedAdmission(other);
650
+ let hangingTick: Promise<unknown> | undefined;
651
+ try {
652
+ if (held) {
653
+ hangingTick = appBackend.call(
654
+ "sync",
655
+ {},
656
+ {
657
+ bonsai: f.bonsai,
658
+ env: { BACKGROUND: aAdmission },
659
+ },
660
+ { ...member },
661
+ );
662
+ await arrived.promise;
663
+ }
664
+ for (let frame = 0; frame < 8; frame++) {
665
+ const before = f.counted.total();
666
+ await appBackend.call(
667
+ "sync",
668
+ {},
669
+ {
670
+ bonsai: f.bonsai,
671
+ env: { BACKGROUND: bAdmission },
672
+ },
673
+ { ...other },
674
+ );
675
+ expect(f.counted.total() - before).toBeLessThanOrEqual(1);
676
+ }
677
+ expect(
678
+ f.bonsai.state.fileEdits.read(actorLane(other.actor), key),
679
+ ).toMatchObject({ state: "synced" });
680
+ expect(
681
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), key),
682
+ ).toMatchObject({ state: "pending" });
683
+ expect(
684
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
685
+ ).toHaveLength(2);
686
+ } finally {
687
+ release.resolve(undefined);
688
+ await hangingTick;
689
+ }
690
+ },
691
+ );
692
+
693
+ it.each([
694
+ ["discard", "read"],
695
+ ["save", "read"],
696
+ ["discard", "fetch"],
697
+ ["save", "fetch"],
698
+ ] as const)(
699
+ "%s during sync %s never commits withdrawn private text",
700
+ async (action, stage) => {
701
+ const f = await fixture();
702
+ const baseline = fileContent.parse(
703
+ await f.call("files.read", {
704
+ repo: "pond",
705
+ ref: "main",
706
+ path: "README.md",
707
+ }),
708
+ );
709
+ const input = {
710
+ repo: "pond",
711
+ branch: "main",
712
+ path: "README.md",
713
+ baseBlob: baseline.blob,
714
+ baseCommit: baseline.commit,
715
+ text: "Withdrawn private text\n",
716
+ revision: null,
717
+ };
718
+ await f.call("files.save", input, await sealedWrite(member));
719
+ const arrived = Promise.withResolvers<undefined>();
720
+ const release = Promise.withResolvers<undefined>();
721
+ const binding = f.bonsai.store.binding;
722
+ let held = false;
723
+ f.bonsai.store = gitStore({
724
+ ...binding,
725
+ readBlob: async (repo, hash) => {
726
+ const bytes = await binding.readBlob(repo, hash);
727
+ if (!held && stage === "read") {
728
+ held = true;
729
+ arrived.resolve(undefined);
730
+ await release.promise;
731
+ }
732
+ return bytes;
733
+ },
734
+ exchange: async (repo, request) => {
735
+ const reply = await binding.exchange(repo, request);
736
+ if (
737
+ !held &&
738
+ stage === "fetch" &&
739
+ request.service === "upload-pack" &&
740
+ request.phase === "exchange"
741
+ ) {
742
+ held = true;
743
+ arrived.resolve(undefined);
744
+ await release.promise;
745
+ }
746
+ return reply;
747
+ },
748
+ });
749
+ for (let frame = 0; frame < (stage === "fetch" ? 5 : 3); frame++)
750
+ await claimFileSync(f, new AbortController().signal);
751
+ const syncing = claimFileSync(f, new AbortController().signal);
752
+ try {
753
+ await arrived.promise;
754
+ if (action === "discard") {
755
+ expect(
756
+ await f.call("files.discard", {
757
+ repo: "pond",
758
+ branch: "main",
759
+ path: "README.md",
760
+ revision: 1,
761
+ }),
762
+ ).toEqual({ kind: "discarded" });
763
+ } else {
764
+ expect(
765
+ await f.call(
766
+ "files.save",
767
+ { ...input, text: "Latest saved text\n", revision: 1 },
768
+ await sealedWrite(member),
769
+ ),
770
+ ).toMatchObject({ kind: "saved", revision: 2 });
771
+ }
772
+ release.resolve(undefined);
773
+ await syncing;
774
+ expect(
775
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
776
+ ).toHaveLength(1);
777
+ if (action === "save") await replayFileSync(f);
778
+ const history = await f.provider.git.log("pond", {
779
+ ref: "main",
780
+ limit: 10,
781
+ });
782
+ expect(history).toHaveLength(action === "discard" ? 1 : 2);
783
+ for (const commit of history) {
784
+ const tree = await f.provider.git.readTree("pond", commit.treeHash);
785
+ const blob = tree?.find((entry) => entry.name === "README.md");
786
+ if (blob === undefined) throw new Error("Missing race fixture file");
787
+ const bytes = await f.provider.git.readBlob("pond", blob.hash);
788
+ expect(new TextDecoder().decode(bytes ?? undefined)).not.toBe(
789
+ "Withdrawn private text\n",
790
+ );
791
+ }
792
+ } finally {
793
+ release.resolve(undefined);
794
+ await syncing;
795
+ }
796
+ },
797
+ );
798
+
799
+ it("discard leaves only a receipt hash, not the saved file's private text", async () => {
800
+ const f = await fixture();
801
+ const baseline = fileContent.parse(
802
+ await f.call("files.read", {
803
+ repo: "pond",
804
+ ref: "main",
805
+ path: "README.md",
806
+ }),
807
+ );
808
+ const secret = "Sensitive private file content";
809
+ const caller = await sealedWrite({
810
+ ...member,
811
+ idempotencyKey: "private-file-request",
812
+ });
813
+ const input = {
814
+ repo: "pond",
815
+ branch: "main",
816
+ path: "README.md",
817
+ baseBlob: baseline.blob,
818
+ baseCommit: baseline.commit,
819
+ text: secret,
820
+ revision: null,
821
+ };
822
+ const saved = await f.call("files.save", input, caller);
823
+ expect(await f.call("files.save", input, caller)).toEqual(saved);
824
+ await f.call("files.discard", {
825
+ repo: "pond",
826
+ branch: "main",
827
+ path: "README.md",
828
+ revision: 1,
829
+ });
830
+ // Persisted receipts are the protected representation, not an inspection of a private helper.
831
+ const receipts = f.bonsai.state.backgroundOwner.sql
832
+ .exec("SELECT digest,result FROM receipts")
833
+ .toArray();
834
+ expect(receipts).toHaveLength(1);
835
+ expect(JSON.stringify(receipts)).not.toContain(secret);
836
+ });
837
+
838
+ it("a member's saved-row bound permits replacement and discard without borrowing another member's capacity", async () => {
839
+ const f = await fixture();
840
+ const input = {
841
+ repo: "pond",
842
+ branch: "main",
843
+ baseCommit: f.initial,
844
+ baseBlob: null,
845
+ text: "Private draft",
846
+ revision: null,
847
+ };
848
+ const caller = await sealedWrite(member);
849
+ const before = f.counted.total();
850
+ for (let index = 0; index < 128; index++)
851
+ await f.call(
852
+ "files.save",
853
+ { ...input, path: `draft-${String(index)}.txt` },
854
+ caller,
855
+ );
856
+ await expect(
857
+ f.call("files.save", { ...input, path: "overflow.txt" }, caller),
858
+ ).rejects.toMatchObject({ code: "invalid" });
859
+ expect(
860
+ await f.call(
861
+ "files.save",
862
+ { ...input, path: "draft-0.txt", revision: 1 },
863
+ caller,
864
+ ),
865
+ ).toMatchObject({ kind: "saved" });
866
+ expect(
867
+ await f.call("files.discard", {
868
+ repo: "pond",
869
+ branch: "main",
870
+ path: "draft-1.txt",
871
+ revision: 2,
872
+ }),
873
+ ).toEqual({ kind: "discarded" });
874
+ expect(
875
+ await f.call("files.save", { ...input, path: "overflow.txt" }, caller),
876
+ ).toMatchObject({ kind: "saved" });
877
+ expect(
878
+ await f.call(
879
+ "files.save",
880
+ { ...input, path: "other.txt" },
881
+ await sealedWrite({
882
+ ...member,
883
+ actor: { kind: "member", memberId: "other" },
884
+ }),
885
+ ),
886
+ ).toMatchObject({ kind: "saved" });
887
+ expect(f.counted.total() - before).toBe(0);
888
+ });
889
+
890
+ it("sync refuses a directory target rather than replacing it with private text", async () => {
891
+ const f = await fixture();
892
+ const written = await commitFiles(f.provider.git, {
893
+ repository: "pond",
894
+ branch: "main",
895
+ expectedHead: f.initial,
896
+ changes: [{ path: "folder/child.txt", content: encode("Child") }],
897
+ message: "Add folder",
898
+ author: { name: "seed", email: "seed@test.invalid" },
899
+ });
900
+ if (written.kind !== "committed")
901
+ throw new Error("Missing directory fixture");
902
+ const commit = await f.provider.git.readCommit("pond", written.commit);
903
+ if (commit === null) throw new Error("Missing directory commit");
904
+ const tree = await f.provider.git.readTree("pond", commit.treeHash);
905
+ const folder = tree?.find((entry) => entry.name === "folder");
906
+ if (folder === undefined) throw new Error("Missing directory entry");
907
+ await f.call(
908
+ "files.save",
909
+ {
910
+ repo: "pond",
911
+ branch: "main",
912
+ path: "folder",
913
+ baseCommit: written.commit,
914
+ baseBlob: folder.hash,
915
+ text: "Private directory replacement",
916
+ revision: null,
917
+ },
918
+ await sealedWrite(member),
919
+ );
920
+ await replayFileSync(f);
921
+ expect(
922
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
923
+ ).toHaveLength(2);
924
+ expect(
925
+ f.bonsai.state.fileEdits.pending(actorLane(member.actor), {
926
+ repo: "pond",
927
+ branch: "main",
928
+ path: "folder",
929
+ }),
930
+ ).toMatchObject({ state: "failed", text: "Private directory replacement" });
931
+ });
932
+
933
+ it("failed sync retries reach newer rows and leave access-changed rows refused", async () => {
934
+ const f = await fixture();
935
+ const lane = actorLane(member.actor);
936
+ const origin = (await sealedWrite(member)).backgroundOrigin;
937
+ const paths = ["first.txt", "second.txt", "third.txt", "refused.txt"];
938
+ const keys = paths.map((path) => ({ repo: "pond", branch: "main", path }));
939
+ for (const key of keys) {
940
+ f.bonsai.state.fileEdits.save(
941
+ lane,
942
+ {
943
+ ...key,
944
+ baseCommit: f.initial,
945
+ baseBlob: null,
946
+ text: "Retained text",
947
+ revision: null,
948
+ },
949
+ () => undefined,
950
+ origin,
951
+ );
952
+ const edit = f.bonsai.state.fileEdits.read(lane, key);
953
+ if (edit === null) throw new Error("Missing retry row");
954
+ f.bonsai.state.fileEdits.settle(edit, {
955
+ kind: "failed",
956
+ revision: edit.revision,
957
+ reason: key.path === "refused.txt" ? "access_changed" : "branch_missing",
958
+ });
959
+ }
960
+ const before = f.counted.total();
961
+ await f.call("files.sync", {}, await sealedWrite(member, "files.sync"));
962
+ expect(
963
+ keys.map((key) => f.bonsai.state.fileEdits.read(lane, key)?.state),
964
+ ).toEqual(["pending", "pending", "failed", "failed"]);
965
+ for (const key of keys.slice(0, 2)) {
966
+ const edit = f.bonsai.state.fileEdits.read(lane, key);
967
+ if (edit === null) throw new Error("Missing retry row");
968
+ f.bonsai.state.fileEdits.settle(edit, {
969
+ kind: "failed",
970
+ revision: edit.revision,
971
+ reason: "branch_missing",
972
+ });
973
+ }
974
+ await f.call("files.sync", {}, await sealedWrite(member, "files.sync"));
975
+ expect(
976
+ f.bonsai.state.fileEdits.read(lane, {
977
+ repo: "pond",
978
+ branch: "main",
979
+ path: "third.txt",
980
+ })?.state,
981
+ ).toBe("pending");
982
+ expect(
983
+ f.bonsai.state.fileEdits.read(lane, {
984
+ repo: "pond",
985
+ branch: "main",
986
+ path: "refused.txt",
987
+ }),
988
+ ).toMatchObject({
989
+ state: "failed",
990
+ failure: "access_changed",
991
+ text: "Retained text",
992
+ });
993
+ expect(f.counted.total() - before).toBe(0);
994
+ });
995
+
996
+ it("sync does not re-arm an access-changed edit", async () => {
997
+ const f = await fixture();
998
+ const edit = await queuedEdit(f);
999
+ f.bonsai.state.backgroundOwner.sql.exec(
1000
+ "DELETE FROM repos WHERE name=?",
1001
+ "pond",
1002
+ );
1003
+ expect(await edit.execute()).toEqual({
1004
+ kind: "failed",
1005
+ reason: "access_changed",
1006
+ });
1007
+ const before = f.counted.total();
1008
+ expect(
1009
+ await f.call("files.sync", {}, await sealedWrite(member, "files.sync")),
1010
+ ).toEqual({
1011
+ settled: [
1012
+ {
1013
+ repo: "pond",
1014
+ branch: "main",
1015
+ path: "README.md",
1016
+ result: {
1017
+ kind: "failed",
1018
+ revision: edit.saved.revision,
1019
+ reason: "access_changed",
1020
+ },
1021
+ },
1022
+ ],
1023
+ more: false,
1024
+ });
1025
+ expect(f.counted.total() - before).toBe(0);
1026
+ expect(
1027
+ f.bonsai.state.fileEdits.read(edit.job.lane, edit.input),
1028
+ ).toMatchObject({
1029
+ state: "failed",
1030
+ failure: "access_changed",
1031
+ text: edit.input.text,
1032
+ });
1033
+ });
1034
+
1035
+ async function queuedEdit(
1036
+ f: Awaited<ReturnType<typeof fixture>>,
1037
+ text = "Edited\n",
1038
+ ) {
1039
+ const baseline = fileContent.parse(
1040
+ await f.call("files.read", {
1041
+ repo: "pond",
1042
+ ref: "main",
1043
+ path: "README.md",
1044
+ }),
1045
+ );
1046
+ const input = fileSaveInput.parse({
1047
+ repo: "pond",
1048
+ branch: "main",
1049
+ path: "README.md",
1050
+ baseCommit: baseline.commit,
1051
+ baseBlob: baseline.blob,
1052
+ text,
1053
+ revision: null,
1054
+ });
1055
+ const lane = actorLane(member.actor);
1056
+ const caller = await sealedWrite(member);
1057
+ const saved = f.bonsai.state.fileEdits.save(
1058
+ lane,
1059
+ input,
1060
+ () => undefined,
1061
+ caller.backgroundOrigin,
1062
+ );
1063
+ if (saved.kind !== "saved") throw new Error("Fixture edit did not save");
1064
+ const job: WatchdogRunningJob = {
1065
+ state: "running",
1066
+ jobId: "file-test",
1067
+ queue: "file-test",
1068
+ lane,
1069
+ priority: 0,
1070
+ payload: {
1071
+ kind: "file-edit",
1072
+ repo: input.repo,
1073
+ branch: input.branch,
1074
+ path: input.path,
1075
+ revision: saved.revision,
1076
+ lane,
1077
+ origin: caller.backgroundOrigin,
1078
+ operationKey: replayKey(),
1079
+ timestamp: Math.floor(Date.now() / 1000),
1080
+ },
1081
+ };
1082
+ const successors: WatchdogQueuedJob[] = [];
1083
+ return {
1084
+ input,
1085
+ job,
1086
+ saved,
1087
+ successors,
1088
+ execute: async () => {
1089
+ const runtime = await Watchdog.make({
1090
+ owner: f.bonsai.state.backgroundOwner,
1091
+ concurrentLane: lane,
1092
+ isAlive: () => Promise.resolve(false),
1093
+ wake: { recompute: () => Promise.resolve() },
1094
+ execute: () =>
1095
+ Promise.resolve({
1096
+ kind: "failed" as const,
1097
+ reason: "access_changed",
1098
+ }),
1099
+ });
1100
+ runtime.transactional.enqueueAcceptedJob({ ...job, state: "queued" });
1101
+ await replayFileSync(f);
1102
+ const settled = await runtime.read(job.jobId);
1103
+ if (settled.status !== "found" || settled.job.state !== "settled")
1104
+ throw new Error("Fixture claim did not settle");
1105
+ return settled.job.outcome === "failed"
1106
+ ? { kind: "failed" as const, reason: settled.job.failureReason }
1107
+ : { kind: "completed" as const };
1108
+ },
1109
+ };
1110
+ }
1111
+
1112
+ it("public files.sync requeues a retry without a Git call or a foreground claim", async () => {
1113
+ const f = await fixture();
1114
+ const edit = await queuedEdit(f);
1115
+ const stored = f.bonsai.state.fileEdits.read(edit.job.lane, edit.input);
1116
+ if (stored === null) throw new Error("Missing edit");
1117
+ f.bonsai.state.fileEdits.settle(stored, {
1118
+ kind: "failed",
1119
+ revision: edit.saved.revision,
1120
+ reason: "sync_failed",
1121
+ });
1122
+ const before = f.counted.total();
1123
+ const result = await f.call(
1124
+ "files.sync",
1125
+ {},
1126
+ await sealedWrite(member, "files.sync"),
1127
+ );
1128
+ expect(f.counted.total() - before).toBe(0);
1129
+ expect(result).toEqual({ settled: [], more: true });
1130
+ expect(
1131
+ f.bonsai.state.fileEdits.read(edit.job.lane, edit.input),
1132
+ ).toMatchObject({
1133
+ state: "pending",
1134
+ revision: edit.saved.revision,
1135
+ text: edit.input.text,
1136
+ });
1137
+ expect(
1138
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
1139
+ ).toHaveLength(1);
1140
+ });
1141
+
1142
+ it("sync authors a real commit as the member and replay pushes no second commit", async () => {
1143
+ const f = await fixture();
1144
+ const edit = await queuedEdit(f);
1145
+ expect(await edit.execute()).toEqual({ kind: "completed" });
1146
+ const log = await f.provider.git.log("pond", { ref: "main", limit: 10 });
1147
+ expect(log).toHaveLength(2);
1148
+ expect(log[0]?.author.name).toBe("owner");
1149
+ const before = f.counted.total();
1150
+ expect(await edit.execute()).toEqual({ kind: "completed" });
1151
+ expect(f.counted.total() - before).toBe(0);
1152
+ });
1153
+
1154
+ it.each([
1155
+ ["other.txt", "synced"],
1156
+ ["README.md", "conflict"],
1157
+ ])(
1158
+ "Agent push to %s underneath produces %s without overwriting",
1159
+ async (path, state) => {
1160
+ const f = await fixture();
1161
+ const edit = await queuedEdit(f);
1162
+ const pushed = await commitFiles(f.provider.git, {
1163
+ repository: "pond",
1164
+ branch: "main",
1165
+ expectedHead: f.initial,
1166
+ changes: [{ path, content: encode("Agent change\n") }],
1167
+ message: "Agent work",
1168
+ author: { name: "luna", email: "luna@test.invalid" },
1169
+ });
1170
+ if (pushed.kind !== "committed") throw new Error("Agent fixture failed");
1171
+ await edit.execute();
1172
+ const row = f.bonsai.state.fileEdits.read(edit.job.lane, edit.input);
1173
+ expect(row?.state).toBe(state);
1174
+ const log = await f.provider.git.log("pond", { ref: "main", limit: 10 });
1175
+ expect(log).toHaveLength(state === "synced" ? 3 : 2);
1176
+ if (state === "synced") expect(log[0]?.parents).toEqual([pushed.commit]);
1177
+ else expect(row?.theirs?.commit).toBe(pushed.commit);
1178
+ },
1179
+ );
1180
+
1181
+ it("two tabs conflict on revision and a discarded job never writes after replacement", async () => {
1182
+ const f = await fixture();
1183
+ const edit = await queuedEdit(f);
1184
+ const lane = edit.job.lane;
1185
+ expect(
1186
+ f.bonsai.state.fileEdits.save(lane, edit.input, () => undefined),
1187
+ ).toEqual({
1188
+ kind: "conflict",
1189
+ revision: edit.saved.revision,
1190
+ });
1191
+ expect(
1192
+ f.bonsai.state.fileEdits.discard(lane, {
1193
+ ...edit.input,
1194
+ revision: edit.saved.revision,
1195
+ }),
1196
+ ).toEqual({ kind: "discarded" });
1197
+ const replacement = f.bonsai.state.fileEdits.save(
1198
+ lane,
1199
+ { ...edit.input, revision: edit.saved.revision },
1200
+ () => undefined,
1201
+ );
1202
+ if (replacement.kind !== "saved") throw new Error("Replacement did not save");
1203
+ expect(replacement.revision).toBeGreaterThan(edit.saved.revision);
1204
+ const before = f.counted.total();
1205
+ await edit.execute();
1206
+ expect(f.counted.total() - before).toBe(0);
1207
+ expect(f.bonsai.state.fileEdits.read(lane, edit.input)?.revision).toBe(
1208
+ replacement.revision,
1209
+ );
1210
+ });
1211
+
1212
+ it("another member cannot execute or discard the owner's pending edit", async () => {
1213
+ const f = await fixture();
1214
+ const edit = await queuedEdit(f);
1215
+ const before = f.counted.total();
1216
+ expect(
1217
+ await executeQueuedFileEdit(
1218
+ f.bonsai,
1219
+ { ...member, actor: { kind: "member", memberId: "other" } },
1220
+ edit.job,
1221
+ () => undefined,
1222
+ await claimedAdmission(),
1223
+ new AbortController().signal,
1224
+ ),
1225
+ ).toEqual({ kind: "failed", reason: "access_changed" });
1226
+ expect(
1227
+ f.bonsai.state.fileEdits.discard("member:other", {
1228
+ ...edit.input,
1229
+ revision: edit.saved.revision,
1230
+ }),
1231
+ ).toEqual({ kind: "not_found" });
1232
+ expect(f.counted.total() - before).toBe(0);
1233
+ expect(f.bonsai.state.fileEdits.read(edit.job.lane, edit.input)?.state).toBe(
1234
+ "pending",
1235
+ );
1236
+ });
1237
+
1238
+ it("text already accepted after a crash settles synced without another push", async () => {
1239
+ const f = await fixture();
1240
+ const edit = await queuedEdit(f);
1241
+ const pushed = await commitFiles(f.provider.git, {
1242
+ repository: "pond",
1243
+ branch: "main",
1244
+ expectedHead: f.initial,
1245
+ changes: [{ path: "README.md", content: encode(edit.input.text) }],
1246
+ message: "Already accepted",
1247
+ author: { name: "owner", email: "owner@bonsai.invalid" },
1248
+ });
1249
+ if (pushed.kind !== "committed")
1250
+ throw new Error("Crash fixture did not commit");
1251
+ expect(await edit.execute()).toEqual({ kind: "completed" });
1252
+ expect(
1253
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
1254
+ ).toHaveLength(2);
1255
+ expect(f.bonsai.state.fileEdits.read(edit.job.lane, edit.input)?.state).toBe(
1256
+ "synced",
1257
+ );
1258
+ });
1259
+
1260
+ it("a vanished file-sync frame is recovered through real Watchdog and pushes once", async () => {
1261
+ const f = await fixture();
1262
+ const baseline = fileContent.parse(
1263
+ await f.call("files.read", {
1264
+ repo: "pond",
1265
+ ref: "main",
1266
+ path: "README.md",
1267
+ }),
1268
+ );
1269
+ const context = await sealedWrite(member);
1270
+ const began = Promise.withResolvers<WatchdogRunningJob>();
1271
+ const release = Promise.withResolvers<undefined>();
1272
+ const old = await Watchdog.make({
1273
+ owner: f.bonsai.state.backgroundOwner,
1274
+ concurrentLane: actorLane(member.actor),
1275
+ isAlive: () => Promise.resolve(true),
1276
+ wake: { recompute: () => Promise.resolve() },
1277
+ execute: async (job) => {
1278
+ began.resolve(job);
1279
+ await release.promise;
1280
+ return { kind: "completed" };
1281
+ },
1282
+ });
1283
+ await saveFileEdit(
1284
+ f.bonsai,
1285
+ context,
1286
+ {
1287
+ repo: "pond",
1288
+ branch: "main",
1289
+ path: "README.md",
1290
+ baseBlob: baseline.blob,
1291
+ baseCommit: baseline.commit,
1292
+ text: "After crash\n",
1293
+ revision: null,
1294
+ },
1295
+ old,
1296
+ );
1297
+ const oldTick = old.tickConcurrent();
1298
+ const claimed = await began.promise;
1299
+ const fresh: ConcurrentWatchdogRuntime = await Watchdog.make({
1300
+ owner: f.bonsai.state.backgroundOwner,
1301
+ concurrentLane: actorLane(member.actor),
1302
+ isAlive: () => Promise.resolve(false),
1303
+ wake: { recompute: () => Promise.resolve() },
1304
+ execute: async (job) =>
1305
+ executeQueuedFileEdit(
1306
+ f.bonsai,
1307
+ member,
1308
+ job,
1309
+ fresh.transactional.enqueueAcceptedJob,
1310
+ await claimedAdmission(),
1311
+ new AbortController().signal,
1312
+ ),
1313
+ });
1314
+ try {
1315
+ expect(await fresh.tickConcurrent()).toMatchObject({
1316
+ status: "settled",
1317
+ job: { outcome: "completed" },
1318
+ });
1319
+ await replayFileSync(f);
1320
+ expect(await fresh.read(claimed.jobId)).toMatchObject({
1321
+ status: "found",
1322
+ job: { state: "settled", outcome: "completed" },
1323
+ });
1324
+ expect(
1325
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
1326
+ ).toHaveLength(2);
1327
+ } finally {
1328
+ release.resolve(undefined);
1329
+ await expect(oldTick).rejects.toMatchObject({
1330
+ cause: "settlement_rejected",
1331
+ });
1332
+ }
1333
+ });
1334
+
1335
+ it("public save/sync/discard keep lanes separate and more ignores another member's queued edit", async () => {
1336
+ const f = await fixture();
1337
+ const baseline = fileContent.parse(
1338
+ await f.call("files.read", {
1339
+ repo: "pond",
1340
+ ref: "main",
1341
+ path: "README.md",
1342
+ }),
1343
+ );
1344
+ const input = {
1345
+ repo: "pond",
1346
+ branch: "main",
1347
+ path: "README.md",
1348
+ baseBlob: baseline.blob,
1349
+ baseCommit: baseline.commit,
1350
+ text: "Owner edit\n",
1351
+ revision: null,
1352
+ };
1353
+ const saved = await f.call("files.save", input, await sealedWrite(member));
1354
+ expect(saved).toMatchObject({ kind: "saved", state: "pending" });
1355
+ const other = {
1356
+ ...member,
1357
+ actor: { kind: "member" as const, memberId: "other" },
1358
+ };
1359
+ await f.call(
1360
+ "files.save",
1361
+ { ...input, text: "Other edit\n" },
1362
+ await sealedWrite(other),
1363
+ );
1364
+ expect(
1365
+ await f.call(
1366
+ "files.discard",
1367
+ { repo: "pond", branch: "main", path: "README.md", revision: 1 },
1368
+ other,
1369
+ ),
1370
+ ).toMatchObject({ kind: "conflict" });
1371
+ await replayFileSync(f);
1372
+ const synced = await f.call(
1373
+ "files.sync",
1374
+ {},
1375
+ await sealedWrite(member, "files.sync"),
1376
+ );
1377
+ expect(synced).toMatchObject({
1378
+ settled: [
1379
+ {
1380
+ repo: "pond",
1381
+ branch: "main",
1382
+ path: "README.md",
1383
+ result: { kind: "synced" },
1384
+ },
1385
+ ],
1386
+ more: false,
1387
+ });
1388
+ expect(
1389
+ f.bonsai.state.fileEdits.pending(actorLane(other.actor), input)?.state,
1390
+ ).toBe("pending");
1391
+ expect(
1392
+ await f.call(
1393
+ "files.read",
1394
+ { repo: "pond", ref: "main", path: "README.md" },
1395
+ { actor: { kind: "agent", agentId: "luna" }, computerId: null },
1396
+ ),
1397
+ ).not.toHaveProperty("pending");
1398
+ });
1399
+
1400
+ it("unsealed ordinary saves and Workflow calls cannot create private rows", async () => {
1401
+ const f = await fixture();
1402
+ const baseline = fileContent.parse(
1403
+ await f.call("files.read", {
1404
+ repo: "pond",
1405
+ ref: "main",
1406
+ path: "README.md",
1407
+ }),
1408
+ );
1409
+ const input = {
1410
+ repo: "pond",
1411
+ branch: "main",
1412
+ path: "README.md",
1413
+ baseBlob: baseline.blob,
1414
+ baseCommit: baseline.commit,
1415
+ text: "Foreground\n",
1416
+ revision: null,
1417
+ };
1418
+ const before = f.counted.total();
1419
+ const result = await f.call("files.save", input);
1420
+ expect(f.counted.total() - before).toBe(0);
1421
+ expect(result).toEqual({ kind: "refused", reason: "background-unavailable" });
1422
+ const caller = await sealedWrite({
1423
+ ...member,
1424
+ workflow: {
1425
+ workflowId: "flow",
1426
+ runId: "run",
1427
+ nodeId: "node",
1428
+ armedBy: member.actor,
1429
+ },
1430
+ });
1431
+ const next = fileContent.parse(
1432
+ await f.call("files.read", {
1433
+ repo: "pond",
1434
+ ref: "main",
1435
+ path: "README.md",
1436
+ }),
1437
+ );
1438
+ await expect(
1439
+ f.call(
1440
+ "files.save",
1441
+ {
1442
+ ...input,
1443
+ baseBlob: next.blob,
1444
+ baseCommit: next.commit,
1445
+ text: "Workflow\n",
1446
+ revision: 1,
1447
+ },
1448
+ caller,
1449
+ ),
1450
+ ).rejects.toMatchObject({ code: "forbidden" });
1451
+ expect(f.bonsai.state.fileEdits.hasPending(actorLane(member.actor))).toBe(
1452
+ false,
1453
+ );
1454
+ });
1455
+
1456
+ it("Agents cannot use private file saves even on their own fork", async () => {
1457
+ const f = await fixture();
1458
+ const baseline = fileContent.parse(
1459
+ await f.call("files.read", {
1460
+ repo: "pond",
1461
+ ref: "main",
1462
+ path: "README.md",
1463
+ }),
1464
+ );
1465
+ const input = {
1466
+ repo: "pond",
1467
+ branch: "main",
1468
+ path: "README.md",
1469
+ baseBlob: baseline.blob,
1470
+ baseCommit: baseline.commit,
1471
+ text: "Agent edit\n",
1472
+ revision: null,
1473
+ };
1474
+ const agent = {
1475
+ actor: { kind: "agent" as const, agentId: "luna" },
1476
+ computerId: null,
1477
+ };
1478
+ const before = f.counted.total();
1479
+ await expect(f.call("files.save", input, agent)).rejects.toMatchObject({
1480
+ code: "forbidden",
1481
+ });
1482
+ expect(f.counted.total() - before).toBe(0);
1483
+ await f.provider.git.create("pond--luna");
1484
+ const forkHead = await commitFiles(f.provider.git, {
1485
+ repository: "pond--luna",
1486
+ branch: "main",
1487
+ expectedHead: null,
1488
+ changes: [{ path: "README.md", content: encode("Pond\n") }],
1489
+ message: "Seed registered fork",
1490
+ author: { name: "seed", email: "seed@test.invalid" },
1491
+ });
1492
+ if (forkHead.kind !== "committed")
1493
+ throw new Error("Fork fixture did not commit");
1494
+ f.bonsai.state.registerRepo({
1495
+ name: "pond--luna",
1496
+ description: null,
1497
+ parent: "pond",
1498
+ owner: "luna",
1499
+ });
1500
+ await expect(
1501
+ f.call(
1502
+ "files.save",
1503
+ { ...input, repo: "pond--luna", baseCommit: forkHead.commit },
1504
+ agent,
1505
+ ),
1506
+ ).rejects.toMatchObject({ code: "forbidden" });
1507
+ expect(
1508
+ await f.provider.git.log("pond--luna", { ref: "main", limit: 1 }),
1509
+ ).toMatchObject([{ author: { name: "seed" } }]);
1510
+ });
1511
+
1512
+ it("save refuses binary text and UTF-8 payloads over the byte limit with zero provider calls", async () => {
1513
+ const f = await fixture();
1514
+ const baseline = fileContent.parse(
1515
+ await f.call("files.read", {
1516
+ repo: "pond",
1517
+ ref: "main",
1518
+ path: "README.md",
1519
+ }),
1520
+ );
1521
+ const input = {
1522
+ repo: "pond",
1523
+ branch: "main",
1524
+ path: "README.md",
1525
+ baseBlob: baseline.blob,
1526
+ baseCommit: baseline.commit,
1527
+ revision: null,
1528
+ };
1529
+ const before = f.counted.total();
1530
+ for (const text of ["binary\0text", "🐟".repeat(17000)])
1531
+ await expect(
1532
+ f.call("files.save", { ...input, text }, await sealedWrite(member)),
1533
+ ).rejects.toMatchObject({ code: "invalid" });
1534
+ expect(f.counted.total() - before).toBe(0);
1535
+ });
1536
+
1537
+ it("settled file rows reopen with the revision only in its SQLite column", async () => {
1538
+ const f = await fixture();
1539
+ const edit = await queuedEdit(f);
1540
+ await edit.execute();
1541
+ const rebuilt = new BonsaiState(
1542
+ f.bonsai.state.backgroundOwner.sql,
1543
+ f.bonsai.state.backgroundOwner.transactionSync,
1544
+ );
1545
+ expect(rebuilt.fileEdits.read(edit.job.lane, edit.input)).toMatchObject({
1546
+ revision: edit.saved.revision,
1547
+ state: "synced",
1548
+ text: edit.input.text,
1549
+ });
1550
+ const raw = f.bonsai.state.backgroundOwner.sql
1551
+ .exec("SELECT body FROM file_edits WHERE revision=?", edit.saved.revision)
1552
+ .toArray()[0];
1553
+ const stored = z.strictObject({ body: z.string() }).parse(raw);
1554
+ expect(JSON.parse(stored.body)).not.toHaveProperty("revision");
1555
+ });
1556
+
1557
+ it("a missing branch fails sync without a push and retains the saved text", async () => {
1558
+ const f = await fixture();
1559
+ const edit = await queuedEdit(f);
1560
+ const missing = {
1561
+ ...f.bonsai,
1562
+ store: { ...f.bonsai.store, refs: () => Promise.resolve([]) },
1563
+ };
1564
+ f.bonsai.store = missing.store;
1565
+ const before = f.counted.total();
1566
+ expect(await edit.execute()).toEqual({ kind: "completed" });
1567
+ expect(f.counted.total() - before).toBe(0);
1568
+ expect(
1569
+ f.bonsai.state.fileEdits.pending(edit.job.lane, edit.input),
1570
+ ).toMatchObject({
1571
+ text: edit.input.text,
1572
+ state: "failed",
1573
+ failure: "branch_missing",
1574
+ });
1575
+ });
1576
+
1577
+ it("current repository access is rechecked before sync with zero Git on refusal", async () => {
1578
+ const f = await fixture();
1579
+ const edit = await queuedEdit(f);
1580
+ f.bonsai.state.backgroundOwner.sql.exec(
1581
+ "DELETE FROM repos WHERE name=?",
1582
+ "pond",
1583
+ );
1584
+ const before = f.counted.total();
1585
+ expect(await edit.execute()).toEqual({
1586
+ kind: "failed",
1587
+ reason: "access_changed",
1588
+ });
1589
+ expect(f.counted.total() - before).toBe(0);
1590
+ expect(
1591
+ f.bonsai.state.fileEdits.pending(edit.job.lane, edit.input),
1592
+ ).toMatchObject({
1593
+ text: edit.input.text,
1594
+ state: "failed",
1595
+ failure: "access_changed",
1596
+ });
1597
+ });
1598
+
1599
+ it("an explicit sync retry requeues a failed edit without losing the member's text", async () => {
1600
+ const f = await fixture();
1601
+ const edit = await queuedEdit(f);
1602
+ const stored = f.bonsai.state.fileEdits.read(edit.job.lane, edit.input);
1603
+ if (stored === null) throw new Error("Missing edit");
1604
+ f.bonsai.state.fileEdits.settle(stored, {
1605
+ kind: "failed",
1606
+ revision: edit.saved.revision,
1607
+ reason: "sync_failed",
1608
+ });
1609
+ expect(
1610
+ await f.call("files.sync", {}, await sealedWrite(member, "files.sync")),
1611
+ ).toMatchObject({ more: true });
1612
+ await replayFileSync(f);
1613
+ expect(
1614
+ await f.call("files.sync", {}, await sealedWrite(member, "files.sync")),
1615
+ ).toMatchObject({
1616
+ settled: [{ result: { kind: "synced" } }],
1617
+ more: false,
1618
+ });
1619
+ expect(f.bonsai.state.fileEdits.read(edit.job.lane, edit.input)?.text).toBe(
1620
+ edit.input.text,
1621
+ );
1622
+ });
1623
+
1624
+ it("Workflow-attested member tools read committed Git without private pending text", async () => {
1625
+ const f = await fixture();
1626
+ const edit = await queuedEdit(f);
1627
+ const caller = {
1628
+ ...member,
1629
+ workflow: {
1630
+ workflowId: "flow",
1631
+ runId: "run",
1632
+ nodeId: "node",
1633
+ armedBy: member.actor,
1634
+ },
1635
+ };
1636
+ const read = await f.call(
1637
+ "files.read",
1638
+ { repo: "pond", ref: "main", path: "README.md" },
1639
+ caller,
1640
+ );
1641
+ expect(read).toMatchObject({ text: "Pond\n" });
1642
+ expect(read).not.toHaveProperty("pending");
1643
+ expect(
1644
+ f.bonsai.state.fileEdits.pending(edit.job.lane, edit.input)?.text,
1645
+ ).toBe(edit.input.text);
1646
+ });
1647
+
1648
+ it("a replayed member save queues one edit and sync writes one commit", async () => {
1649
+ const f = await fixture();
1650
+ const baseline = fileContent.parse(
1651
+ await f.call("files.read", {
1652
+ repo: "pond",
1653
+ ref: "main",
1654
+ path: "README.md",
1655
+ }),
1656
+ );
1657
+ const input = {
1658
+ repo: "pond",
1659
+ branch: "main",
1660
+ path: "README.md",
1661
+ baseBlob: baseline.blob,
1662
+ baseCommit: baseline.commit,
1663
+ text: "Replay saved\n",
1664
+ revision: null,
1665
+ };
1666
+ const caller = await sealedWrite({
1667
+ ...member,
1668
+ idempotencyKey: "save-request",
1669
+ });
1670
+ const before = f.counted.total();
1671
+ const first = await f.call("files.save", input, caller);
1672
+ expect(await f.call("files.save", input, caller)).toEqual(first);
1673
+ expect(f.counted.total() - before).toBe(0);
1674
+ await replayFileSync(f);
1675
+ expect(
1676
+ await f.call("files.sync", {}, await sealedWrite(member, "files.sync")),
1677
+ ).toMatchObject({
1678
+ settled: [{ result: { kind: "synced" } }],
1679
+ more: false,
1680
+ });
1681
+ expect(
1682
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
1683
+ ).toHaveLength(2);
1684
+ });
1685
+
1686
+ it("a claimed file-sync frame checkpoints at most one Git exchange and does not report Synced early", async () => {
1687
+ const f = await fixture();
1688
+ const edit = await queuedEdit(f);
1689
+ const lane = actorLane(member.actor);
1690
+ const key = await backgroundOriginKey("file-sync-native-owner-test");
1691
+ const origin = await sealBackgroundOrigin(key, {
1692
+ instanceId: "bonsai-test",
1693
+ lane,
1694
+ method: "files.save",
1695
+ });
1696
+ const job = {
1697
+ ...edit.job,
1698
+ payload: { ...fileEditJobSchema.parse(edit.job.payload), origin },
1699
+ };
1700
+ let exchanges = 0;
1701
+ const binding = f.bonsai.store.binding;
1702
+ const bonsai = {
1703
+ ...f.bonsai,
1704
+ store: gitStore({
1705
+ ...binding,
1706
+ exchange: async (repo, request) => {
1707
+ exchanges++;
1708
+ return binding.exchange(repo, request);
1709
+ },
1710
+ }),
1711
+ };
1712
+ const controller = new AbortController();
1713
+ const decision = await executeQueuedFileEdit(
1714
+ bonsai,
1715
+ member,
1716
+ job,
1717
+ (next) => {
1718
+ edit.successors.push(next);
1719
+ },
1720
+ {
1721
+ admit: (claimed) =>
1722
+ admitClaimedOrigin(
1723
+ key,
1724
+ {
1725
+ instanceId: "bonsai-test",
1726
+ lane,
1727
+ admitted: ["files.save"],
1728
+ },
1729
+ claimed,
1730
+ ),
1731
+ },
1732
+ controller.signal,
1733
+ );
1734
+ expect(exchanges).toBeLessThanOrEqual(1);
1735
+ expect(decision).toEqual({ kind: "completed" });
1736
+ expect(f.bonsai.state.fileEdits.read(lane, edit.input)?.state).toBe(
1737
+ "pending",
1738
+ );
1739
+ expect(edit.successors).toHaveLength(1);
1740
+ });
1741
+
1742
+ it("fresh claimed file-sync frames confirm Git before projecting Synced", async () => {
1743
+ const f = await fixture();
1744
+ const lane = actorLane(member.actor);
1745
+ const key = await backgroundOriginKey("file-sync-native-owner-test");
1746
+ const background = {
1747
+ admit: (origin: Parameters<typeof admitClaimedOrigin>[2]) =>
1748
+ admitClaimedOrigin(
1749
+ key,
1750
+ { instanceId: "bonsai-test", lane, admitted: ["files.save"] },
1751
+ origin,
1752
+ ),
1753
+ };
1754
+ const binding = f.bonsai.store.binding;
1755
+ let exchanges = 0;
1756
+ const bonsai = {
1757
+ ...f.bonsai,
1758
+ store: gitStore({
1759
+ ...binding,
1760
+ exchange: (repo, request) => {
1761
+ exchanges++;
1762
+ return binding.exchange(repo, request);
1763
+ },
1764
+ }),
1765
+ };
1766
+ const baseline = fileContent.parse(
1767
+ await f.call("files.read", {
1768
+ repo: "pond",
1769
+ ref: "main",
1770
+ path: "README.md",
1771
+ }),
1772
+ );
1773
+ const open = async (signal: AbortSignal) => {
1774
+ const runtime: ConcurrentWatchdogRuntime = await Watchdog.make({
1775
+ owner: bonsai.state.backgroundOwner,
1776
+ concurrentLane: lane,
1777
+ project: (transition) => {
1778
+ projectQueuedFileEdit(bonsai, transition);
1779
+ },
1780
+ isAlive: () => Promise.resolve(false),
1781
+ wake: { recompute: () => Promise.resolve() },
1782
+ execute: (job) =>
1783
+ executeQueuedFileEdit(
1784
+ bonsai,
1785
+ member,
1786
+ job,
1787
+ runtime.transactional.enqueueAcceptedJob,
1788
+ background,
1789
+ signal,
1790
+ ),
1791
+ });
1792
+ return runtime;
1793
+ };
1794
+ const foreground = new AbortController();
1795
+ const runtime = await open(foreground.signal);
1796
+ const saved = await saveFileEdit(
1797
+ bonsai,
1798
+ await sealedWrite(member),
1799
+ {
1800
+ repo: "pond",
1801
+ branch: "main",
1802
+ path: "README.md",
1803
+ baseCommit: baseline.commit,
1804
+ baseBlob: baseline.blob,
1805
+ text: "Fresh frame file\n",
1806
+ revision: null,
1807
+ },
1808
+ runtime,
1809
+ );
1810
+ expect(saved).toMatchObject({ kind: "saved", state: "pending" });
1811
+ foreground.abort();
1812
+ const phases: string[] = [];
1813
+ for (let turn = 0; turn < 8; turn++) {
1814
+ const controller = new AbortController();
1815
+ const replay = await open(controller.signal);
1816
+ const before = exchanges;
1817
+ const tick = await replay.tickConcurrent();
1818
+ expect(exchanges - before).toBeLessThanOrEqual(1);
1819
+ if (tick.status === "no_ready") break;
1820
+ expect(tick.status).toBe("settled");
1821
+ if (tick.status !== "settled") throw new Error("Replay did not settle");
1822
+ const cargo = fileEditJobSchema.parse(tick.job.payload);
1823
+ phases.push(cargo.step?.phase ?? cargo.probe?.phase ?? "refs");
1824
+ const row = bonsai.state.fileEdits.read(lane, {
1825
+ repo: "pond",
1826
+ branch: "main",
1827
+ path: "README.md",
1828
+ });
1829
+ if (cargo.step?.phase !== "confirm") expect(row?.state).toBe("pending");
1830
+ await cleanupSettledFileEdit(bonsai, tick.job);
1831
+ controller.abort();
1832
+ }
1833
+ expect(phases).toEqual([
1834
+ "refs",
1835
+ "commit",
1836
+ "tree",
1837
+ "blob",
1838
+ "inspect-base",
1839
+ "fetch-base",
1840
+ "push",
1841
+ "confirm",
1842
+ ]);
1843
+ expect(
1844
+ bonsai.state.fileEdits.read(lane, {
1845
+ repo: "pond",
1846
+ branch: "main",
1847
+ path: "README.md",
1848
+ }),
1849
+ ).toMatchObject({ state: "synced" });
1850
+ expect(
1851
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
1852
+ ).toHaveLength(2);
1853
+ });
1854
+
1855
+ it.each(["missing", "refused", "closed"])(
1856
+ "claimed file sync refuses %s admission with zero Git effects",
1857
+ async (kind) => {
1858
+ const f = await fixture();
1859
+ const edit = await queuedEdit(f);
1860
+ const controller = new AbortController();
1861
+ if (kind === "closed") controller.abort();
1862
+ const background =
1863
+ kind === "missing"
1864
+ ? undefined
1865
+ : kind === "refused"
1866
+ ? {
1867
+ admit: () =>
1868
+ Promise.resolve({
1869
+ kind: "refused" as const,
1870
+ code: "access_changed" as const,
1871
+ }),
1872
+ }
1873
+ : await claimedAdmission();
1874
+ const before = f.counted.total();
1875
+ expect(
1876
+ await executeQueuedFileEdit(
1877
+ f.bonsai,
1878
+ member,
1879
+ edit.job,
1880
+ () => undefined,
1881
+ background,
1882
+ controller.signal,
1883
+ ),
1884
+ ).toEqual({ kind: "failed", reason: "access_changed" });
1885
+ expect(f.counted.total() - before).toBe(0);
1886
+ expect(
1887
+ f.bonsai.state.fileEdits.read(edit.job.lane, edit.input)?.state,
1888
+ ).toBe("pending");
1889
+ },
1890
+ );
1891
+
1892
+ it("an access refusal drops the member's staged Git material and keeps the saved text", async () => {
1893
+ const f = await fixture();
1894
+ const baseline = fileContent.parse(
1895
+ await f.call("files.read", {
1896
+ repo: "pond",
1897
+ ref: "main",
1898
+ path: "README.md",
1899
+ }),
1900
+ );
1901
+ await f.call(
1902
+ "files.save",
1903
+ {
1904
+ repo: "pond",
1905
+ branch: "main",
1906
+ path: "README.md",
1907
+ baseCommit: baseline.commit,
1908
+ baseBlob: baseline.blob,
1909
+ text: "Private bytes staged for Git\n",
1910
+ revision: null,
1911
+ },
1912
+ await sealedWrite(member),
1913
+ );
1914
+ for (let frame = 0; frame < 4; frame++)
1915
+ expect(await claimFileSync(f, new AbortController().signal)).toMatchObject({
1916
+ status: "settled",
1917
+ job: { outcome: "completed" },
1918
+ });
1919
+ const staged = () =>
1920
+ f.bonsai.state.backgroundOwner.sql
1921
+ .exec("SELECT 1 FROM git_step_material")
1922
+ .toArray().length;
1923
+ expect(staged()).toBeGreaterThan(0);
1924
+ const before = f.counted.total();
1925
+ await refuseBackground(f.bonsai, {
1926
+ lane: actorLane(member.actor),
1927
+ code: "access_changed",
1928
+ });
1929
+ expect(f.counted.total() - before).toBe(0);
1930
+ expect(staged()).toBe(0);
1931
+ expect(
1932
+ f.bonsai.state.fileEdits.pending(actorLane(member.actor), {
1933
+ repo: "pond",
1934
+ branch: "main",
1935
+ path: "README.md",
1936
+ }),
1937
+ ).toMatchObject({
1938
+ text: "Private bytes staged for Git\n",
1939
+ state: "failed",
1940
+ failure: "access_changed",
1941
+ });
1942
+ });
1943
+
1944
+ it("missing durable Git material terminally fails the claimed file job without a retry", async () => {
1945
+ const f = await fixture();
1946
+ const baseline = fileContent.parse(
1947
+ await f.call("files.read", {
1948
+ repo: "pond",
1949
+ ref: "main",
1950
+ path: "README.md",
1951
+ }),
1952
+ );
1953
+ await f.call(
1954
+ "files.save",
1955
+ {
1956
+ repo: "pond",
1957
+ branch: "main",
1958
+ path: "README.md",
1959
+ baseCommit: baseline.commit,
1960
+ baseBlob: baseline.blob,
1961
+ text: "Retained after material loss\n",
1962
+ revision: null,
1963
+ },
1964
+ await sealedWrite(member),
1965
+ );
1966
+ for (let frame = 0; frame < 4; frame++)
1967
+ expect(await claimFileSync(f, new AbortController().signal)).toMatchObject({
1968
+ status: "settled",
1969
+ job: { outcome: "completed" },
1970
+ });
1971
+ f.bonsai.state.backgroundOwner.sql.exec("DELETE FROM git_step_material");
1972
+ const advertisement = await claimFileSync(f, new AbortController().signal);
1973
+ expect(advertisement).toMatchObject({
1974
+ status: "settled",
1975
+ job: { outcome: "completed" },
1976
+ });
1977
+ const failed = await claimFileSync(f, new AbortController().signal);
1978
+ expect(failed).toMatchObject({
1979
+ status: "settled",
1980
+ job: { outcome: "failed", failureReason: "sync_failed" },
1981
+ });
1982
+ expect(
1983
+ f.bonsai.state.fileEdits.pending(actorLane(member.actor), {
1984
+ repo: "pond",
1985
+ branch: "main",
1986
+ path: "README.md",
1987
+ }),
1988
+ ).toMatchObject({
1989
+ text: "Retained after material loss\n",
1990
+ state: "failed",
1991
+ failure: "permanent_material",
1992
+ });
1993
+ const before = f.counted.total();
1994
+ expect(await claimFileSync(f, new AbortController().signal)).toEqual({
1995
+ status: "no_ready",
1996
+ });
1997
+ expect(f.counted.total() - before).toBe(0);
1998
+ expect(
1999
+ await f.call("files.sync", {}, await sealedWrite(member, "files.sync")),
2000
+ ).toMatchObject({ more: false });
2001
+ expect(await claimFileSync(f, new AbortController().signal)).toEqual({
2002
+ status: "no_ready",
2003
+ });
2004
+ expect(f.counted.total() - before).toBe(0);
2005
+ expect(
2006
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
2007
+ ).toHaveLength(1);
2008
+ const failedRead = fileContent.parse(
2009
+ await f.call("files.read", {
2010
+ repo: "pond",
2011
+ ref: "main",
2012
+ path: "README.md",
2013
+ }),
2014
+ );
2015
+ expect(
2016
+ await f.call(
2017
+ "files.save",
2018
+ {
2019
+ repo: "pond",
2020
+ branch: "main",
2021
+ path: "README.md",
2022
+ baseCommit: failedRead.commit,
2023
+ baseBlob: failedRead.blob,
2024
+ revision: failedRead.version,
2025
+ text: "Retained after material loss\n",
2026
+ },
2027
+ await sealedWrite(member),
2028
+ ),
2029
+ ).toMatchObject({ kind: "saved", state: "pending" });
2030
+ await replayFileSync(f);
2031
+ expect(
2032
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), {
2033
+ repo: "pond",
2034
+ branch: "main",
2035
+ path: "README.md",
2036
+ }),
2037
+ ).toMatchObject({ state: "synced" });
2038
+ expect(
2039
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
2040
+ ).toHaveLength(2);
2041
+ });
2042
+
2043
+ it("same-tick public sync callers never recover or execute each other's live claim", async () => {
2044
+ const f = await fixture();
2045
+ const baseline = fileContent.parse(
2046
+ await f.call("files.read", {
2047
+ repo: "pond",
2048
+ ref: "main",
2049
+ path: "README.md",
2050
+ }),
2051
+ );
2052
+ const key = { repo: "pond", branch: "main", path: "README.md" };
2053
+ await f.call(
2054
+ "files.save",
2055
+ {
2056
+ ...key,
2057
+ baseCommit: baseline.commit,
2058
+ baseBlob: baseline.blob,
2059
+ text: "Same tick\n",
2060
+ revision: null,
2061
+ },
2062
+ await sealedWrite(member),
2063
+ );
2064
+ const realAdmission = await claimedAdmission();
2065
+ let admissions = 0;
2066
+ const BACKGROUND = {
2067
+ admit: (origin: Parameters<typeof realAdmission.admit>[0]) => {
2068
+ admissions++;
2069
+ return realAdmission.admit(origin);
2070
+ },
2071
+ };
2072
+ for (let frame = 0; frame < 8; frame++) {
2073
+ const before = f.counted.total();
2074
+ const admittedBefore = admissions;
2075
+ const outcomes = await Promise.allSettled([
2076
+ appBackend.call(
2077
+ "sync",
2078
+ {},
2079
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2080
+ { ...member },
2081
+ ),
2082
+ appBackend.call(
2083
+ "sync",
2084
+ {},
2085
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2086
+ { ...member },
2087
+ ),
2088
+ ]);
2089
+ expect(outcomes.map((outcome) => outcome.status)).toEqual([
2090
+ "fulfilled",
2091
+ "fulfilled",
2092
+ ]);
2093
+ expect(admissions - admittedBefore).toBeLessThanOrEqual(1);
2094
+ expect(f.counted.total() - before).toBeLessThanOrEqual(1);
2095
+ }
2096
+ expect(admissions).toBe(8);
2097
+ expect(
2098
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), key),
2099
+ ).toMatchObject({ state: "synced" });
2100
+ expect(
2101
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
2102
+ ).toHaveLength(2);
2103
+ });
2104
+
2105
+ it.each([
2106
+ "conflict",
2107
+ "digest_mismatch",
2108
+ "atomic_unavailable",
2109
+ "push_refused",
2110
+ ] as const)(
2111
+ "a dependency %s is publicly permanent and a fresh re-save can sync",
2112
+ async (code) => {
2113
+ const f = await fixture();
2114
+ const baseline = fileContent.parse(
2115
+ await f.call("files.read", {
2116
+ repo: "pond",
2117
+ ref: "main",
2118
+ path: "README.md",
2119
+ }),
2120
+ );
2121
+ const key = { repo: "pond", branch: "main", path: "README.md" };
2122
+ const input = {
2123
+ ...key,
2124
+ baseCommit: baseline.commit,
2125
+ baseBlob: baseline.blob,
2126
+ text: "Resaved\n",
2127
+ revision: null,
2128
+ };
2129
+ await f.call("files.save", input, await sealedWrite(member));
2130
+ const previous = f.bonsai.store;
2131
+ const binding = f.counted.git;
2132
+ f.bonsai.store = gitStore({
2133
+ ...binding,
2134
+ exchange: () => Promise.reject(new GitStepMaterialError(code)),
2135
+ });
2136
+ const BACKGROUND = await claimedAdmission();
2137
+ for (let frame = 0; frame < 8; frame++) {
2138
+ await appBackend.call(
2139
+ "sync",
2140
+ {},
2141
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2142
+ { ...member },
2143
+ );
2144
+ if (
2145
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), key)?.state ===
2146
+ "failed"
2147
+ )
2148
+ break;
2149
+ }
2150
+ const failed = fileContent.parse(
2151
+ await f.call("files.read", {
2152
+ repo: "pond",
2153
+ ref: "main",
2154
+ path: "README.md",
2155
+ }),
2156
+ );
2157
+ expect(failed.pending).toMatchObject({
2158
+ state: "failed",
2159
+ failure: "permanent_material",
2160
+ });
2161
+ const before = f.counted.total();
2162
+ expect(
2163
+ await f.call("files.sync", {}, await sealedWrite(member, "files.sync")),
2164
+ ).toMatchObject({ more: false });
2165
+ expect(f.counted.total() - before).toBe(0);
2166
+ f.bonsai.store = previous;
2167
+ expect(
2168
+ await f.call(
2169
+ "files.save",
2170
+ { ...input, revision: failed.version },
2171
+ await sealedWrite(member),
2172
+ ),
2173
+ ).toMatchObject({ kind: "saved", state: "pending" });
2174
+ await replayFileSync(f);
2175
+ expect(
2176
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), key),
2177
+ ).toMatchObject({ state: "synced" });
2178
+ expect(
2179
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
2180
+ ).toHaveLength(2);
2181
+ },
2182
+ );
2183
+
2184
+ it("an unsealed save refuses without leaving a pending row or wake", async () => {
2185
+ const f = await fixture();
2186
+ const baseline = fileContent.parse(
2187
+ await f.call("files.read", {
2188
+ repo: "pond",
2189
+ ref: "main",
2190
+ path: "README.md",
2191
+ }),
2192
+ );
2193
+ const before = f.counted.total();
2194
+ expect(
2195
+ await f.call("files.save", {
2196
+ repo: "pond",
2197
+ branch: "main",
2198
+ path: "README.md",
2199
+ baseCommit: baseline.commit,
2200
+ baseBlob: baseline.blob,
2201
+ text: "Unsealed member text",
2202
+ revision: null,
2203
+ }),
2204
+ ).toEqual({ kind: "refused", reason: "background-unavailable" });
2205
+ expect(await f.call("files.sync", {}, member)).toEqual({
2206
+ settled: [],
2207
+ more: false,
2208
+ });
2209
+ expect(
2210
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), {
2211
+ repo: "pond",
2212
+ branch: "main",
2213
+ path: "README.md",
2214
+ }),
2215
+ ).toBeNull();
2216
+ expect(f.counted.total() - before).toBe(0);
2217
+ });
2218
+
2219
+ it("discard retains only the member revision and rejects a stale null save", async () => {
2220
+ const f = await fixture();
2221
+ const key = { repo: "pond", ref: "main", path: "README.md" };
2222
+ const baseline = fileContent.parse(await f.call("files.read", key));
2223
+ const caller = await sealedWrite(member);
2224
+ const input = {
2225
+ repo: "pond",
2226
+ branch: "main",
2227
+ path: "README.md",
2228
+ baseCommit: baseline.commit,
2229
+ baseBlob: baseline.blob,
2230
+ text: "Discarded private text",
2231
+ revision: null,
2232
+ };
2233
+ expect(await f.call("files.save", input, caller)).toMatchObject({
2234
+ kind: "saved",
2235
+ revision: 1,
2236
+ });
2237
+ expect(
2238
+ await f.call("files.discard", {
2239
+ repo: "pond",
2240
+ branch: "main",
2241
+ path: "README.md",
2242
+ revision: 1,
2243
+ }),
2244
+ ).toEqual({ kind: "discarded" });
2245
+ const floor = fileContent.parse(await f.call("files.read", key));
2246
+ expect(floor).toMatchObject({ version: 1, text: "Pond\n" });
2247
+ expect(floor).not.toHaveProperty("pending");
2248
+ expect(
2249
+ await f.call("files.save", { ...input, text: "Old tab text" }, caller),
2250
+ ).toEqual({ kind: "conflict", revision: 1 });
2251
+ expect(
2252
+ await f.call(
2253
+ "files.save",
2254
+ { ...input, text: "New edit", revision: floor.version },
2255
+ caller,
2256
+ ),
2257
+ ).toMatchObject({ kind: "saved", revision: 2 });
2258
+ });
2259
+
2260
+ it.each(["refs", "confirm", "admit"])(
2261
+ "production sync retries transient %s failures in fresh claims before reporting Synced",
2262
+ async (phase) => {
2263
+ const f = await fixture();
2264
+ const baseline = fileContent.parse(
2265
+ await f.call("files.read", {
2266
+ repo: "pond",
2267
+ ref: "main",
2268
+ path: "README.md",
2269
+ }),
2270
+ );
2271
+ await f.call(
2272
+ "files.save",
2273
+ {
2274
+ repo: "pond",
2275
+ branch: "main",
2276
+ path: "README.md",
2277
+ baseCommit: baseline.commit,
2278
+ baseBlob: baseline.blob,
2279
+ text: "Retry saved text\n",
2280
+ revision: null,
2281
+ },
2282
+ await sealedWrite(member),
2283
+ );
2284
+ const binding = f.bonsai.store.binding;
2285
+ let failures = 0;
2286
+ let pushed = false;
2287
+ f.bonsai.store = gitStore({
2288
+ ...binding,
2289
+ refs: (repo) => {
2290
+ if (phase === "refs" && failures < 2) {
2291
+ failures++;
2292
+ return Promise.reject(new Error("Transient fixture read"));
2293
+ }
2294
+ return binding.refs(repo);
2295
+ },
2296
+ exchange: async (repo, request) => {
2297
+ if (
2298
+ phase === "confirm" &&
2299
+ pushed &&
2300
+ request.phase === "advertise" &&
2301
+ failures < 2
2302
+ ) {
2303
+ failures++;
2304
+ throw new Error("Transient fixture confirmation");
2305
+ }
2306
+ const result = await binding.exchange(repo, request);
2307
+ if (request.service === "receive-pack" && request.phase === "exchange")
2308
+ pushed = true;
2309
+ return result;
2310
+ },
2311
+ });
2312
+ const admitted = await claimedAdmission();
2313
+ const BACKGROUND = {
2314
+ admit: (origin: Parameters<typeof admitted.admit>[0]) => {
2315
+ if (phase === "admit" && failures < 2) {
2316
+ failures++;
2317
+ return Promise.reject(new Error("Transient fixture admission"));
2318
+ }
2319
+ return admitted.admit(origin);
2320
+ },
2321
+ };
2322
+ for (let frame = 0; frame < 16; frame++) {
2323
+ const [settled] = await Promise.allSettled([
2324
+ appBackend.call(
2325
+ "sync",
2326
+ {},
2327
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2328
+ { ...member },
2329
+ ),
2330
+ ]);
2331
+ if (settled.status === "rejected")
2332
+ expect(settled.reason).toMatchObject({ retryable: true });
2333
+ const edit = f.bonsai.state.fileEdits.read(actorLane(member.actor), {
2334
+ repo: "pond",
2335
+ branch: "main",
2336
+ path: "README.md",
2337
+ });
2338
+ if (failures > 0 && failures < 2) expect(edit?.state).toBe("pending");
2339
+ if (edit?.state === "synced") break;
2340
+ expect(edit?.state).toBe("pending");
2341
+ }
2342
+ expect(failures).toBe(2);
2343
+ expect(
2344
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), {
2345
+ repo: "pond",
2346
+ branch: "main",
2347
+ path: "README.md",
2348
+ })?.state,
2349
+ ).toBe("synced");
2350
+ expect(
2351
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
2352
+ ).toHaveLength(2);
2353
+ },
2354
+ );
2355
+
2356
+ it("a replacement after the old push landed waits for confirmation without self-conflict", async () => {
2357
+ const f = await fixture();
2358
+ const baseline = fileContent.parse(
2359
+ await f.call("files.read", {
2360
+ repo: "pond",
2361
+ ref: "main",
2362
+ path: "README.md",
2363
+ }),
2364
+ );
2365
+ const input = {
2366
+ repo: "pond",
2367
+ branch: "main",
2368
+ path: "README.md",
2369
+ baseCommit: baseline.commit,
2370
+ baseBlob: baseline.blob,
2371
+ text: "First saved edit\n",
2372
+ revision: null,
2373
+ };
2374
+ await f.call("files.save", input, await sealedWrite(member));
2375
+ const observation = { pushed: false };
2376
+ const binding = f.bonsai.store.binding;
2377
+ f.bonsai.store = gitStore({
2378
+ ...binding,
2379
+ exchange: async (repo, request) => {
2380
+ const reply = await binding.exchange(repo, request);
2381
+ if (request.service === "receive-pack" && request.phase === "exchange")
2382
+ observation.pushed = true;
2383
+ return reply;
2384
+ },
2385
+ });
2386
+ const BACKGROUND = await claimedAdmission();
2387
+ for (let frame = 0; frame < 12 && !observation.pushed; frame++)
2388
+ await appBackend.call(
2389
+ "sync",
2390
+ {},
2391
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2392
+ { ...member },
2393
+ );
2394
+ expect(observation.pushed).toBe(true);
2395
+ expect(
2396
+ await f.call(
2397
+ "files.save",
2398
+ { ...input, text: "Replacement edit\n", revision: 1 },
2399
+ await sealedWrite(member),
2400
+ ),
2401
+ ).toEqual({ kind: "conflict", revision: 1 });
2402
+ await replayFileSync(f);
2403
+ const confirmed = fileContent.parse(
2404
+ await f.call("files.read", {
2405
+ repo: "pond",
2406
+ ref: "main",
2407
+ path: "README.md",
2408
+ }),
2409
+ );
2410
+ expect(confirmed).toMatchObject({ version: 1, text: "First saved edit\n" });
2411
+ expect(
2412
+ await f.call(
2413
+ "files.save",
2414
+ {
2415
+ ...input,
2416
+ baseCommit: confirmed.commit,
2417
+ baseBlob: confirmed.blob,
2418
+ text: "Replacement edit\n",
2419
+ revision: confirmed.version,
2420
+ },
2421
+ await sealedWrite(member),
2422
+ ),
2423
+ ).toMatchObject({ kind: "saved", revision: 2 });
2424
+ await replayFileSync(f);
2425
+ expect(
2426
+ await f.call("files.read", {
2427
+ repo: "pond",
2428
+ ref: "main",
2429
+ path: "README.md",
2430
+ }),
2431
+ ).toMatchObject({ text: "Replacement edit\n", version: 2 });
2432
+ });
2433
+
2434
+ it("deep file preflight spends at most one Git RPC per production sync frame", async () => {
2435
+ const f = await fixture();
2436
+ const path = [
2437
+ ...Array.from({ length: 40 }, (_, index) => `dir-${String(index)}`),
2438
+ "file.txt",
2439
+ ].join("/");
2440
+ const planted = await commitFiles(f.provider.git, {
2441
+ repository: "pond",
2442
+ branch: "main",
2443
+ expectedHead: f.initial,
2444
+ changes: [{ path, content: encode("Deep baseline\n") }],
2445
+ message: "Plant deep file",
2446
+ author: { name: "seed", email: "seed@test.invalid" },
2447
+ });
2448
+ if (planted.kind !== "committed")
2449
+ throw new Error("Deep fixture did not commit");
2450
+ const commit = await f.provider.git.readCommit("pond", planted.commit);
2451
+ if (commit === null) throw new Error("Missing deep fixture commit");
2452
+ let treeHash = commit.treeHash;
2453
+ let blob: string | undefined;
2454
+ for (const part of path.split("/")) {
2455
+ const entries = await f.provider.git.readTree("pond", treeHash);
2456
+ const entry = entries?.find((value) => value.name === part);
2457
+ if (entry === undefined) throw new Error("Missing deep fixture path");
2458
+ if (entry.type === "tree") treeHash = entry.hash;
2459
+ else blob = entry.hash;
2460
+ }
2461
+ if (blob === undefined) throw new Error("Missing deep fixture file");
2462
+ await f.call(
2463
+ "files.save",
2464
+ {
2465
+ repo: "pond",
2466
+ branch: "main",
2467
+ path,
2468
+ baseCommit: planted.commit,
2469
+ baseBlob: blob,
2470
+ text: "Deep saved edit\n",
2471
+ revision: null,
2472
+ },
2473
+ await sealedWrite(member),
2474
+ );
2475
+ const BACKGROUND = await claimedAdmission();
2476
+ for (let frame = 0; frame < 64; frame++) {
2477
+ const before = f.counted.total();
2478
+ const [settled] = await Promise.allSettled([
2479
+ appBackend.call(
2480
+ "sync",
2481
+ {},
2482
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2483
+ { ...member },
2484
+ ),
2485
+ ]);
2486
+ if (settled.status === "rejected")
2487
+ expect(settled.reason).toMatchObject({ retryable: true });
2488
+ expect(f.counted.total() - before).toBeLessThanOrEqual(1);
2489
+ const edit = f.bonsai.state.fileEdits.read(actorLane(member.actor), {
2490
+ repo: "pond",
2491
+ branch: "main",
2492
+ path,
2493
+ });
2494
+ if (edit?.state === "synced") break;
2495
+ expect(edit?.state).toBe("pending");
2496
+ }
2497
+ expect(
2498
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), {
2499
+ repo: "pond",
2500
+ branch: "main",
2501
+ path,
2502
+ })?.state,
2503
+ ).toBe("synced");
2504
+ });
2505
+
2506
+ it("production sync ends a transient phase after exactly three attempts", async () => {
2507
+ const f = await fixture();
2508
+ const baseline = fileContent.parse(
2509
+ await f.call("files.read", {
2510
+ repo: "pond",
2511
+ ref: "main",
2512
+ path: "README.md",
2513
+ }),
2514
+ );
2515
+ await f.call(
2516
+ "files.save",
2517
+ {
2518
+ repo: "pond",
2519
+ branch: "main",
2520
+ path: "README.md",
2521
+ baseCommit: baseline.commit,
2522
+ baseBlob: baseline.blob,
2523
+ text: "Retained after three failures",
2524
+ revision: null,
2525
+ },
2526
+ await sealedWrite(member),
2527
+ );
2528
+ let calls = 0;
2529
+ f.bonsai.store = gitStore({
2530
+ ...f.bonsai.store.binding,
2531
+ refs: () => {
2532
+ calls++;
2533
+ return Promise.reject(new Error("Transient fixture outage"));
2534
+ },
2535
+ });
2536
+ const BACKGROUND = await claimedAdmission();
2537
+ for (let attempt = 0; attempt < 2; attempt++) {
2538
+ await expect(
2539
+ appBackend.call(
2540
+ "sync",
2541
+ {},
2542
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2543
+ { ...member },
2544
+ ),
2545
+ ).rejects.toMatchObject({ code: "HOST_FAILURE", retryable: true });
2546
+ expect(
2547
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), {
2548
+ repo: "pond",
2549
+ branch: "main",
2550
+ path: "README.md",
2551
+ })?.state,
2552
+ ).toBe("pending");
2553
+ }
2554
+ expect(
2555
+ await appBackend.call(
2556
+ "sync",
2557
+ {},
2558
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2559
+ { ...member },
2560
+ ),
2561
+ ).toEqual({ more: false });
2562
+ expect(calls).toBe(3);
2563
+ expect(
2564
+ f.bonsai.state.fileEdits.pending(actorLane(member.actor), {
2565
+ repo: "pond",
2566
+ branch: "main",
2567
+ path: "README.md",
2568
+ }),
2569
+ ).toMatchObject({
2570
+ state: "failed",
2571
+ failure: "sync_failed",
2572
+ text: "Retained after three failures",
2573
+ });
2574
+ expect(
2575
+ await appBackend.call(
2576
+ "sync",
2577
+ {},
2578
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2579
+ { ...member },
2580
+ ),
2581
+ ).toEqual({ more: false });
2582
+ expect(calls).toBe(3);
2583
+ });
2584
+
2585
+ it("a lost confirmation reply projects the durable Synced receipt instead of failure", async () => {
2586
+ const f = await fixture();
2587
+ const baseline = fileContent.parse(
2588
+ await f.call("files.read", {
2589
+ repo: "pond",
2590
+ ref: "main",
2591
+ path: "README.md",
2592
+ }),
2593
+ );
2594
+ await f.call(
2595
+ "files.save",
2596
+ {
2597
+ repo: "pond",
2598
+ branch: "main",
2599
+ path: "README.md",
2600
+ baseCommit: baseline.commit,
2601
+ baseBlob: baseline.blob,
2602
+ text: "Confirmed before reply loss\n",
2603
+ revision: null,
2604
+ },
2605
+ await sealedWrite(member),
2606
+ );
2607
+ for (let frame = 0; frame < 7; frame++)
2608
+ await claimFileSync(f, new AbortController().signal);
2609
+ const background = await claimedAdmission();
2610
+ const signal = new AbortController().signal;
2611
+ const runtime: ConcurrentWatchdogRuntime = await Watchdog.make({
2612
+ owner: f.bonsai.state.backgroundOwner,
2613
+ concurrentLane: actorLane(member.actor),
2614
+ project: (transition) => {
2615
+ projectQueuedFileEdit(f.bonsai, transition);
2616
+ },
2617
+ isAlive: () => Promise.resolve(false),
2618
+ wake: { recompute: () => Promise.resolve() },
2619
+ execute: async (job) => {
2620
+ expect(fileEditJobSchema.parse(job.payload).step?.phase).toBe("confirm");
2621
+ expect(
2622
+ await executeQueuedFileEdit(
2623
+ f.bonsai,
2624
+ member,
2625
+ job,
2626
+ runtime.transactional.enqueueAcceptedJob,
2627
+ background,
2628
+ signal,
2629
+ ),
2630
+ ).toEqual({ kind: "completed" });
2631
+ throw new Error("Fixture reply lost after durable confirmation");
2632
+ },
2633
+ });
2634
+ const tick = await runtime.tickConcurrent();
2635
+ expect(tick).toMatchObject({ status: "settled", job: { outcome: "failed" } });
2636
+ expect(
2637
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), {
2638
+ repo: "pond",
2639
+ branch: "main",
2640
+ path: "README.md",
2641
+ }),
2642
+ ).toMatchObject({ state: "synced", text: "Confirmed before reply loss\n" });
2643
+ if (tick.status !== "settled") throw new Error("Lost reply did not settle");
2644
+ await cleanupSettledFileEdit(f.bonsai, tick.job);
2645
+ expect(
2646
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
2647
+ ).toHaveLength(2);
2648
+ });
2649
+
2650
+ // E7: the declared backend.sync continuation is the host's replay entry for saved files.
2651
+ it("the declared backend.sync continuation replays a member's saves as one member-authored push", async () => {
2652
+ const continuation = appDeclaration.backend?.sync?.method;
2653
+ if (continuation === undefined) throw new Error("Bonsai declares no sync");
2654
+ expect(continuation).toBe("sync");
2655
+ expect(
2656
+ Schema.decodeUnknownSync(appDeclarationSchema)(appDeclaration),
2657
+ ).toEqual(appDeclaration);
2658
+ const f = await fixture();
2659
+ const baseline = fileContent.parse(
2660
+ await f.call("files.read", {
2661
+ repo: "pond",
2662
+ ref: "main",
2663
+ path: "README.md",
2664
+ }),
2665
+ );
2666
+ const key = { repo: "pond", branch: "main", path: "README.md" };
2667
+ const before = f.counted.total();
2668
+ let revision: number | null = null;
2669
+ for (let save = 1; save <= 5; save++) {
2670
+ const saved = fileSaveResult.parse(
2671
+ await f.call(
2672
+ "files.save",
2673
+ {
2674
+ ...key,
2675
+ baseCommit: baseline.commit,
2676
+ baseBlob: baseline.blob,
2677
+ text: `Edit ${String(save)}\n`,
2678
+ revision,
2679
+ },
2680
+ await sealedWrite(member),
2681
+ ),
2682
+ );
2683
+ if (saved.kind !== "saved") throw new Error("Member save did not queue");
2684
+ revision = saved.revision;
2685
+ }
2686
+ expect(f.counted.total() - before).toBe(0);
2687
+ const BACKGROUND = await claimedAdmission();
2688
+ const deliver = async () =>
2689
+ z
2690
+ .strictObject({ more: z.boolean() })
2691
+ .parse(
2692
+ await appBackend.call(
2693
+ continuation,
2694
+ {},
2695
+ { bonsai: f.bonsai, env: { BACKGROUND } },
2696
+ { ...member },
2697
+ ),
2698
+ );
2699
+ // Each admitted frame spends at most one Git exchange; the host re-posts while more.
2700
+ let frames = 0;
2701
+ while ((await deliver()).more)
2702
+ if (++frames > 32) throw new Error("Continuation did not drain the lane");
2703
+ expect(
2704
+ f.bonsai.state.fileEdits.read(actorLane(member.actor), key),
2705
+ ).toMatchObject({ state: "synced", text: "Edit 5\n" });
2706
+ const log = await f.provider.git.log("pond", { ref: "main", limit: 10 });
2707
+ expect(log).toHaveLength(2);
2708
+ expect(log[0]?.author.name).toBe("owner");
2709
+ // Carrier delivery is at-least-once: a redelivered wake pushes nothing new.
2710
+ expect(await deliver()).toEqual({ more: false });
2711
+ expect(
2712
+ await f.provider.git.log("pond", { ref: "main", limit: 10 }),
2713
+ ).toHaveLength(2);
2714
+ });