@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,999 @@
1
+ /** Lease admission, source-pinned Git CAS, settlement and recovery. */
2
+ import * as z from "zod";
3
+ import {
4
+ begin,
5
+ runStep,
6
+ sqliteGitStepMaterial,
7
+ dropMaterial,
8
+ gitStepSchema,
9
+ GitStepMaterialError,
10
+ } from "@fungi.computer/agar/git-steps";
11
+ import { gitSha1Schema } from "@fungi.computer/puffball/git-representation";
12
+ import type { GitMergeOutcome } from "@fungi.computer/agar/git-commit";
13
+ import { mergeBranch } from "@fungi.computer/agar/git-commit";
14
+ import { replayKey } from "@fungi.computer/cairn";
15
+ import {
16
+ changeRecord,
17
+ changeIdentityOf,
18
+ mergeResult,
19
+ type ChangeRecord,
20
+ type MergeResult,
21
+ } from "../shared/contract.js";
22
+ import { activityEnvelope, type HookEvent } from "../shared/hooks.js";
23
+ import { changeCopyKey } from "./state.js";
24
+ import { refreshChangeCopy } from "./change-copy.js";
25
+ import { readCopyFrame } from "./git.js";
26
+ import { ReadCopyBudgetExceeded } from "@fungi.computer/agar/read-copy";
27
+ import { approvalKind, callerActor, decide } from "./policy.js";
28
+ import { authorsAtHead, reachableCommits } from "./history.js";
29
+ import type { Bonsai, BonsaiActor } from "./service.js";
30
+
31
+ import type {
32
+ ConcurrentWatchdogRuntime,
33
+ WatchdogRunningJob,
34
+ WatchdogSettledJob,
35
+ WatchdogExecutionDecision,
36
+ WatchdogQueueTransition,
37
+ WatchdogTransactionalProjection,
38
+ } from "@fungi.computer/watchdog";
39
+ import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
40
+ import {
41
+ actorLane,
42
+ mayEnqueueBackground,
43
+ backgroundSyncRefusalSchema,
44
+ backgroundOriginSchema,
45
+ type BackgroundBinding,
46
+ } from "@fungi.computer/puffball/background-replay";
47
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
48
+
49
+ const ZERO = "0".repeat(40);
50
+ const leaseAdmission = z.union([
51
+ mergeResult,
52
+ z.strictObject({ kind: z.literal("lease"), change: changeRecord }),
53
+ ]);
54
+ const LEASE_MS = 120_000;
55
+
56
+ /** Durable intent; the replay resolves authority and review facts again. */
57
+ export const mergeJobSchema = z.strictObject({
58
+ kind: z.literal("merge"),
59
+ repo: changeRecord.shape.repo,
60
+ number: changeRecord.shape.number,
61
+ head: changeRecord.shape.head,
62
+ leaseId: z.string().min(1).max(128),
63
+ lane: backgroundSyncRefusalSchema.shape.lane,
64
+ step: z.number().int().nonnegative(),
65
+ /** How many full merge attempts this lease has made; bounds stale-CAS retry. */
66
+ attempt: z.number().int().min(1).max(3),
67
+ origin: backgroundOriginSchema,
68
+ checkpoint: gitStepSchema.nullable(),
69
+ reviewedCommits: z.array(gitSha1Schema),
70
+ });
71
+
72
+ function savedReviewHashes(
73
+ bonsai: Bonsai,
74
+ row: ChangeRecord,
75
+ ): readonly string[] {
76
+ const copy = bonsai.state.changeCopies.read(changeCopyKey(row));
77
+ if (
78
+ bonsai.state.repositoryRefStatus(row.repo).pending ||
79
+ bonsai.state.repositoryRefStatus(row.sourceRepo).pending ||
80
+ copy.kind !== "ready" ||
81
+ copy.head !== row.head ||
82
+ copy.value.target !== row.target
83
+ )
84
+ throw new AppMethodRefusal("unavailable", "The review copy is rebuilding");
85
+ return copy.value.aheadHashes;
86
+ }
87
+
88
+ function savedAuthors(bonsai: Bonsai, row: ChangeRecord): readonly string[] {
89
+ return [
90
+ ...new Set(
91
+ [...bonsai.state.origins(savedReviewHashes(bonsai, row)).values()].map(
92
+ (origin) => origin.agent,
93
+ ),
94
+ ),
95
+ ];
96
+ }
97
+
98
+ /** Accept a member's merge intent and its job in the same row transaction. */
99
+ export function queueMergeChange(
100
+ bonsai: Bonsai,
101
+ caller: AppMethodCallContext,
102
+ input: { repo: string; number: number; head: string },
103
+ runtime: ConcurrentWatchdogRuntime,
104
+ ): MergeResult {
105
+ if (!mayEnqueueBackground(caller))
106
+ return { kind: "refused", reason: "not-authorized" };
107
+ if (caller.actor.kind !== "member")
108
+ return { kind: "refused", reason: "agents-cannot-merge" };
109
+ const actor = callerActor(caller);
110
+ const requestKey = caller.idempotencyKey ?? replayKey();
111
+ const key = `merge:${requestKey}`;
112
+ const digest = JSON.stringify({ actor, input });
113
+ const cached = bonsai.state.receipt(key, digest, mergeResult);
114
+ if (cached !== null) return cached.result;
115
+ const initial = bonsai.state.requireChange(input.repo, input.number);
116
+ const authors = savedAuthors(bonsai, initial);
117
+ const source = bonsai.state.repositoryCopies.read(initial.sourceRepo);
118
+ if (source.kind !== "ready")
119
+ throw new AppMethodRefusal(
120
+ "unavailable",
121
+ "The source repository copy is rebuilding",
122
+ );
123
+ return bonsai.state.transition(key, digest, mergeResult, () => {
124
+ const row = bonsai.state.requireChange(input.repo, input.number);
125
+ if (row.head !== input.head)
126
+ return { result: { kind: "moved", head: row.head }, events: [] };
127
+ const facts = bonsai.state.reviewFacts(actor, row, input.head, authors);
128
+ const decision = decide("merge", facts);
129
+ if (!decision.allowed)
130
+ return {
131
+ result: { kind: "refused", reason: decision.reason },
132
+ events: [],
133
+ };
134
+ bonsai.state.seeActor(actor.name, actor.kind);
135
+ const at = Date.now();
136
+ const leaseId = replayKey();
137
+ const implicit =
138
+ decide("approve", facts).allowed &&
139
+ !facts.approvals.some(
140
+ (approval) =>
141
+ approval.actor === actor.name && approval.head === row.head,
142
+ );
143
+ if (implicit)
144
+ bonsai.state.approve(row.repo, row.number, {
145
+ actor: actor.name,
146
+ kind: approvalKind(facts),
147
+ head: row.head,
148
+ at,
149
+ });
150
+ const next = bonsai.state.reviseChange(
151
+ {
152
+ ...row,
153
+ state: "merging",
154
+ mergeFailure: undefined,
155
+ updatedAt: at,
156
+ lease: { id: leaseId, by: actor.name, head: row.head, at },
157
+ },
158
+ row.revision,
159
+ );
160
+ const lane = actorLane(caller.actor);
161
+ runtime.transactional.enqueueAcceptedJob({
162
+ state: "queued",
163
+ jobId: `merge:${leaseId}`,
164
+ queue: `merge:${leaseId}`,
165
+ lane,
166
+ priority: 0,
167
+ recovery: { maxRecoveries: 1 },
168
+ payload: mergeJobSchema.parse({
169
+ kind: "merge",
170
+ ...input,
171
+ leaseId,
172
+ lane,
173
+ step: 0,
174
+ attempt: 1,
175
+ origin: caller.backgroundOrigin,
176
+ checkpoint: null,
177
+ reviewedCommits: savedReviewHashes(bonsai, row),
178
+ }),
179
+ });
180
+ return {
181
+ result: { kind: "merging" },
182
+ events: implicit
183
+ ? [
184
+ bonsai.state.changeEvent(
185
+ next,
186
+ actor.name,
187
+ "change.approved",
188
+ source.value.cloneUrl,
189
+ ),
190
+ ]
191
+ : [],
192
+ };
193
+ });
194
+ }
195
+
196
+ /** Repair screen copies after an accepted merge or lease settlement, in this frame. */
197
+ async function repairCopies(bonsai: Bonsai, row: ChangeRecord): Promise<void> {
198
+ const repo = bonsai.state.repositoryCopies.read(row.repo);
199
+ if (repo.kind === "ready") {
200
+ const name = `refs/heads/${row.targetRef}`;
201
+ const refs = repo.value.refs.filter((ref) => ref.name !== name);
202
+ if (row.target !== ZERO) refs.push({ name, hash: row.target });
203
+ bonsai.state.repositoryCopies.write(
204
+ row.repo,
205
+ row.targetRef === repo.value.defaultBranch
206
+ ? row.target === ZERO
207
+ ? null
208
+ : row.target
209
+ : repo.head,
210
+ { ...repo.value, refs, empty: refs.length === 0 },
211
+ );
212
+ }
213
+ try {
214
+ await readCopyFrame(bonsai.store, (store) =>
215
+ refreshChangeCopy({ ...bonsai, store }, row),
216
+ );
217
+ } catch (error) {
218
+ if (!(error instanceof ReadCopyBudgetExceeded)) throw error;
219
+ bonsai.state.changeCopies.stale(changeCopyKey(row));
220
+ }
221
+ }
222
+
223
+ /** Record the actual accepted write once until the host journal owns this fact. */
224
+ function mergedEvents(
225
+ bonsai: Bonsai,
226
+ proposed: ChangeRecord,
227
+ by: string,
228
+ before: string,
229
+ after: string,
230
+ sourceUrl: string,
231
+ targetUrl: string,
232
+ wrote: boolean,
233
+ recordChange: boolean,
234
+ ): HookEvent[] {
235
+ const at = Date.now();
236
+ const events: HookEvent[] = [];
237
+ if (wrote) {
238
+ const push = bonsai.state.record({
239
+ at,
240
+ type: "push",
241
+ repo: proposed.repo,
242
+ actor: by,
243
+ data: {
244
+ updates: [{ ref: `refs/heads/${proposed.targetRef}`, before, after }],
245
+ commits: [
246
+ {
247
+ hash: after,
248
+ message: `Merge #${String(proposed.number)}: ${proposed.title}`,
249
+ },
250
+ ],
251
+ paths: [],
252
+ },
253
+ });
254
+ events.push(
255
+ activityEnvelope(push, {
256
+ name: proposed.repo,
257
+ canonical: proposed.repo,
258
+ cloneUrl: targetUrl,
259
+ }),
260
+ );
261
+ }
262
+ if (recordChange) {
263
+ const merged = bonsai.state.record({
264
+ at,
265
+ type: "merge",
266
+ repo: proposed.repo,
267
+ actor: by,
268
+ data: {
269
+ change: changeIdentityOf(proposed),
270
+ from: proposed.sourceRepo,
271
+ ref: proposed.targetRef,
272
+ before,
273
+ after,
274
+ },
275
+ });
276
+ events.push(
277
+ activityEnvelope(merged, {
278
+ name: proposed.sourceRepo,
279
+ canonical: proposed.repo,
280
+ cloneUrl: sourceUrl,
281
+ }),
282
+ );
283
+ }
284
+ return events;
285
+ }
286
+
287
+ export async function recoverExpiredLease(
288
+ bonsai: Bonsai,
289
+ initial: ChangeRecord,
290
+ ): Promise<ChangeRecord> {
291
+ const lease = initial.lease;
292
+ if (lease === null || Date.now() - lease.at < LEASE_MS) return initial;
293
+ const [refs, sourceUrl, targetUrl] = await Promise.all([
294
+ bonsai.store.refs(initial.repo),
295
+ bonsai.store.cloneUrl(initial.sourceRepo),
296
+ bonsai.store.cloneUrl(initial.repo),
297
+ ]);
298
+ const target =
299
+ refs.find((ref) => ref.name === `refs/heads/${initial.targetRef}`)?.hash ??
300
+ ZERO;
301
+ const reachable =
302
+ target === ZERO
303
+ ? new Set<string>()
304
+ : await reachableCommits(bonsai.store, initial.repo, target);
305
+ const result = bonsai.state.transition(
306
+ `lease-recovery:${lease.id}:${String(initial.revision)}:${target}`,
307
+ JSON.stringify({ lease, target, revision: initial.revision }),
308
+ changeRecord,
309
+ () => {
310
+ const row = bonsai.state.requireChange(initial.repo, initial.number);
311
+ if (
312
+ row.lease?.id !== lease.id ||
313
+ (row.target !== initial.target && row.target !== target)
314
+ )
315
+ return { result: row, events: [] };
316
+ const accepted = reachable.has(lease.head);
317
+ const sameHead = row.head === lease.head;
318
+ let next: ChangeRecord = {
319
+ ...row,
320
+ state: row.state === "closed" ? "closed" : "open",
321
+ lease: null,
322
+ target,
323
+ conflict: null,
324
+ updatedAt: Date.now(),
325
+ };
326
+ // A lease this old settled by expiry, not by a settlement this
327
+ // function recorded, so the fallback below needs its own typed
328
+ // outcome here too: reopening with neither a conflict nor a
329
+ // failure would be exactly the silent drop these races elsewhere
330
+ // were already fixed not to do.
331
+ if (accepted && sameHead)
332
+ next = {
333
+ ...next,
334
+ state: "merged",
335
+ mergedCommit: target,
336
+ mergedBy: lease.by,
337
+ };
338
+ else if (accepted)
339
+ next = {
340
+ ...next,
341
+ mergeFailure: "head_moved",
342
+ mergedCommit: target,
343
+ mergedBy: lease.by,
344
+ };
345
+ else if (!sameHead) next = { ...next, mergeFailure: "head_moved" };
346
+ else next = { ...next, mergeFailure: "sync_failed" };
347
+ const result = bonsai.state.reviseChange(next, row.revision);
348
+ const proposed = { ...row, head: lease.head };
349
+ return {
350
+ result,
351
+ events: accepted
352
+ ? mergedEvents(
353
+ bonsai,
354
+ proposed,
355
+ lease.by,
356
+ initial.target,
357
+ target,
358
+ sourceUrl,
359
+ targetUrl,
360
+ false,
361
+ true,
362
+ )
363
+ : [],
364
+ };
365
+ },
366
+ );
367
+ await repairCopies(bonsai, result);
368
+ return result;
369
+ }
370
+
371
+ export async function mergeChange(
372
+ bonsai: Bonsai,
373
+ actor: BonsaiActor,
374
+ input: { repo: string; number: number; head: string },
375
+ requestKey: string = replayKey(),
376
+ ): Promise<MergeResult> {
377
+ const resultKey = `merge:${requestKey}`;
378
+ const digest = JSON.stringify({ actor, input });
379
+ const cached = bonsai.state.receipt(resultKey, digest, mergeResult);
380
+ if (cached !== null) {
381
+ return cached.result;
382
+ }
383
+ const initial = bonsai.state.requireChange(input.repo, input.number);
384
+ if (actor.kind !== "human") {
385
+ const result = bonsai.state.transition(
386
+ resultKey,
387
+ digest,
388
+ mergeResult,
389
+ () => {
390
+ const row = bonsai.state.requireChange(input.repo, input.number);
391
+ const decision = decide(
392
+ "merge",
393
+ bonsai.state.reviewFacts(actor, row, input.head, []),
394
+ );
395
+ if (decision.allowed)
396
+ throw new Error("Agent merge policy unexpectedly allowed");
397
+ return {
398
+ result: mergeResult.parse({
399
+ kind: "refused",
400
+ reason: decision.reason,
401
+ }),
402
+ events: [],
403
+ };
404
+ },
405
+ );
406
+ return result;
407
+ }
408
+ const recovered = await recoverExpiredLease(bonsai, initial);
409
+ const [authors, sourceUrl, targetUrl] = await Promise.all([
410
+ authorsAtHead(bonsai, recovered),
411
+ bonsai.store.cloneUrl(recovered.sourceRepo),
412
+ bonsai.store.cloneUrl(recovered.repo),
413
+ ]);
414
+ const leaseId = replayKey();
415
+ const admission = bonsai.state.transition(
416
+ `merge-start:${requestKey}`,
417
+ digest,
418
+ leaseAdmission,
419
+ () => {
420
+ const row = bonsai.state.requireChange(input.repo, input.number);
421
+ if (row.head !== input.head)
422
+ return {
423
+ result: leaseAdmission.parse({ kind: "moved", head: row.head }),
424
+ events: [],
425
+ };
426
+ const facts = bonsai.state.reviewFacts(actor, row, input.head, authors);
427
+ const decision = decide("merge", facts);
428
+ if (!decision.allowed)
429
+ return {
430
+ result: leaseAdmission.parse({
431
+ kind: "refused",
432
+ reason: decision.reason,
433
+ }),
434
+ events: [],
435
+ };
436
+ const at = Date.now();
437
+ bonsai.state.seeActor(actor.name, actor.kind);
438
+ const implicit =
439
+ decide("approve", facts).allowed &&
440
+ !facts.approvals.some(
441
+ (approval) =>
442
+ approval.head === row.head && approval.actor === actor.name,
443
+ );
444
+ if (implicit)
445
+ bonsai.state.approve(row.repo, row.number, {
446
+ actor: actor.name,
447
+ kind: approvalKind(facts),
448
+ head: row.head,
449
+ at,
450
+ });
451
+ const next = bonsai.state.reviseChange(
452
+ {
453
+ ...row,
454
+ state: "merging",
455
+ updatedAt: at,
456
+ lease: { id: leaseId, by: actor.name, head: row.head, at },
457
+ },
458
+ row.revision,
459
+ );
460
+ return {
461
+ result: leaseAdmission.parse({ kind: "lease", change: next }),
462
+ events: implicit
463
+ ? [
464
+ bonsai.state.changeEvent(
465
+ next,
466
+ actor.name,
467
+ "change.approved",
468
+ sourceUrl,
469
+ ),
470
+ ]
471
+ : [],
472
+ };
473
+ },
474
+ );
475
+ if (admission.kind !== "lease")
476
+ return bonsai.state.transition(resultKey, digest, mergeResult, () => ({
477
+ result: admission,
478
+ events: [],
479
+ }));
480
+ const proposed = admission.change;
481
+ const lease = proposed.lease;
482
+ if (lease === null) throw new Error("Merge admission lacks its stored lease");
483
+ if (lease.id !== leaseId) {
484
+ const physical = bonsai.state.receipt(
485
+ `merge-settle:${lease.id}`,
486
+ digest,
487
+ mergeResult,
488
+ );
489
+ if (physical !== null)
490
+ return bonsai.state.transition(resultKey, digest, mergeResult, () => ({
491
+ result: physical.result,
492
+ events: [],
493
+ }));
494
+ const current = bonsai.state.requireChange(input.repo, input.number);
495
+ if (current.lease?.id === lease.id)
496
+ return { kind: "refused", reason: "merging" };
497
+ const result =
498
+ current.state === "merged" && current.mergedCommit !== null
499
+ ? mergeResult.parse({
500
+ kind: "merged",
501
+ before: proposed.target,
502
+ after: current.mergedCommit,
503
+ })
504
+ : mergeResult.parse({ kind: "moved", head: current.head });
505
+ return bonsai.state.transition(resultKey, digest, mergeResult, () => ({
506
+ result,
507
+ events: [],
508
+ }));
509
+ }
510
+ return performMerge(
511
+ bonsai,
512
+ actor,
513
+ proposed,
514
+ input,
515
+ requestKey,
516
+ digest,
517
+ sourceUrl,
518
+ targetUrl,
519
+ );
520
+ }
521
+
522
+ /**
523
+ * The merge git-step operation a background job begins, shared by its first
524
+ * attempt and every stale-CAS retry. `timestamp` is the caller's already
525
+ * fixed value (the lease's creation second), never `Date.now()`, so a crash
526
+ * replay of the same attempt recomputes the identical commit instead of a
527
+ * new one that could conflict with already-retained step material.
528
+ */
529
+ function mergeStepOperation(
530
+ row: ChangeRecord,
531
+ actor: BonsaiActor,
532
+ leaseId: string,
533
+ head: string,
534
+ timestamp: number,
535
+ ) {
536
+ return {
537
+ kind: "merge" as const,
538
+ requestKey: `merge:${leaseId}`,
539
+ timestamp,
540
+ request: {
541
+ target: row.repo,
542
+ source: row.sourceRepo,
543
+ branch: row.targetRef,
544
+ sourceBranch: row.sourceRef,
545
+ expectedSource: head,
546
+ message: `Merge #${String(row.number)}: ${row.title}`,
547
+ author: { name: actor.name, email: `${actor.name}@bonsai.local` },
548
+ },
549
+ };
550
+ }
551
+
552
+ async function performMerge(
553
+ bonsai: Bonsai,
554
+ actor: BonsaiActor,
555
+ proposed: ChangeRecord,
556
+ input: { repo: string; number: number; head: string },
557
+ requestKey: string,
558
+ digest: string,
559
+ sourceUrl: string,
560
+ targetUrl: string,
561
+ ): Promise<MergeResult> {
562
+ const lease = proposed.lease;
563
+ if (lease === null) throw new Error("Merge execution lacks its stored lease");
564
+ const request = {
565
+ target: proposed.repo,
566
+ source: proposed.sourceRepo,
567
+ branch: proposed.targetRef,
568
+ sourceBranch: proposed.sourceRef,
569
+ expectedSource: lease.head,
570
+ message: `Merge #${String(proposed.number)}: ${proposed.title}`,
571
+ author: { name: actor.name, email: `${actor.name}@bonsai.local` },
572
+ };
573
+ let outcome = await mergeBranch(bonsai.store, request);
574
+ for (let attempt = 1; outcome.kind === "stale" && attempt < 3; attempt++)
575
+ outcome = await mergeBranch(bonsai.store, request);
576
+ const result = settleMerge(
577
+ bonsai,
578
+ actor,
579
+ proposed,
580
+ input,
581
+ requestKey,
582
+ digest,
583
+ sourceUrl,
584
+ targetUrl,
585
+ outcome,
586
+ // The foreground caller gets this MergeResult back directly and shows
587
+ // it synchronously; no stored failure reason is needed.
588
+ false,
589
+ );
590
+ await repairCopies(
591
+ bonsai,
592
+ bonsai.state.requireChange(input.repo, input.number),
593
+ );
594
+ return result;
595
+ }
596
+
597
+ /**
598
+ * Records one Git merge outcome against a Change's stored row, inside one
599
+ * settlement transition. Exported so a focused test can drive the specific
600
+ * ownership and head/target races this function itself resolves, without
601
+ * reconstructing the Watchdog's own per-frame timing to land them.
602
+ */
603
+ export function settleMerge(
604
+ bonsai: Bonsai,
605
+ actor: BonsaiActor,
606
+ proposed: ChangeRecord,
607
+ input: { repo: string; number: number; head: string },
608
+ requestKey: string,
609
+ digest: string,
610
+ sourceUrl: string,
611
+ targetUrl: string,
612
+ outcome: GitMergeOutcome,
613
+ /**
614
+ * Background callers have no synchronous recipient for a bare "moved"
615
+ * MergeResult, so a non-conflict, non-merged settlement must record its
616
+ * own mergeFailure here, inside this same settlement transition. Doing it
617
+ * here (not after this function returns) keeps it atomic with the lease
618
+ * release: a crash replay re-admits the job before that separate write
619
+ * could ever run, and the Change would silently reopen with no reason.
620
+ */
621
+ recordUnsettledAsFailure: boolean,
622
+ ): MergeResult {
623
+ const lease = proposed.lease;
624
+ if (lease === null) throw new Error("Merge settlement lacks its lease");
625
+ const resultKey = `merge:${requestKey}`;
626
+ const physical = bonsai.state.transition(
627
+ `merge-settle:${lease.id}`,
628
+ digest,
629
+ mergeResult,
630
+ () => {
631
+ const row = bonsai.state.requireChange(input.repo, input.number);
632
+ const ownsLease = row.lease?.id === lease.id;
633
+ if (outcome.kind === "merged" || outcome.kind === "up-to-date") {
634
+ const before =
635
+ outcome.kind === "merged" ? outcome.before : outcome.head;
636
+ const after = outcome.kind === "merged" ? outcome.after : outcome.head;
637
+ if (ownsLease) {
638
+ let next: ChangeRecord = {
639
+ ...row,
640
+ state: row.head === lease.head ? "merged" : "open",
641
+ target: after,
642
+ lease: null,
643
+ conflict: null,
644
+ updatedAt: Date.now(),
645
+ };
646
+ // The source moved under this lease: the work that landed was
647
+ // for an already-superseded head, so this Change cannot
648
+ // honestly show as merged for its current head. It still
649
+ // landed, though -- name what actually merged, rather than
650
+ // call it a sync failure, so the Change can ask for a review
651
+ // of the new head instead.
652
+ if (row.head === lease.head)
653
+ next = { ...next, mergedCommit: after, mergedBy: actor.name };
654
+ else if (recordUnsettledAsFailure)
655
+ next = {
656
+ ...next,
657
+ mergeFailure: "head_moved",
658
+ mergedCommit: after,
659
+ mergedBy: actor.name,
660
+ };
661
+ bonsai.state.reviseChange(next, row.revision);
662
+ }
663
+ const events = mergedEvents(
664
+ bonsai,
665
+ proposed,
666
+ actor.name,
667
+ before,
668
+ after,
669
+ sourceUrl,
670
+ targetUrl,
671
+ outcome.kind === "merged",
672
+ ownsLease,
673
+ );
674
+ return {
675
+ result: mergeResult.parse({ kind: "merged", before, after }),
676
+ events,
677
+ };
678
+ }
679
+ if (ownsLease) {
680
+ let next: ChangeRecord = {
681
+ ...row,
682
+ state: row.state === "closed" ? "closed" : "open",
683
+ lease: null,
684
+ updatedAt: Date.now(),
685
+ };
686
+ // A conflict against the still-current head is always recorded,
687
+ // even when some other write has raced the row's own cached
688
+ // `target` to a third value: that bookkeeping field is only safe
689
+ // to overwrite when it still matches what this attempt expected
690
+ // or found, but the conflict itself (what the attempt actually
691
+ // discovered) is true regardless.
692
+ if (outcome.kind === "conflict" && row.head === lease.head) {
693
+ next = {
694
+ ...next,
695
+ conflict: {
696
+ head: lease.head,
697
+ target: outcome.head,
698
+ paths: [...outcome.paths],
699
+ },
700
+ };
701
+ if (row.target === proposed.target || row.target === outcome.head)
702
+ next = { ...next, target: outcome.head };
703
+ }
704
+ // Anything else leaves this Change with nothing to show unless
705
+ // it records its own typed failure. A moved source -- whether
706
+ // Git's own fetch caught it, or this lease's head no longer
707
+ // matches the row's -- is the author's own work overtaking this
708
+ // attempt, never a sync problem; only an exhausted stale target
709
+ // CAS against a still-current head is that.
710
+ else if (recordUnsettledAsFailure)
711
+ next = {
712
+ ...next,
713
+ mergeFailure:
714
+ outcome.kind === "source-moved" || row.head !== lease.head
715
+ ? "head_moved"
716
+ : "sync_failed",
717
+ };
718
+ bonsai.state.reviseChange(next, row.revision);
719
+ }
720
+ if (outcome.kind === "conflict") {
721
+ const stored = bonsai.state.record({
722
+ at: Date.now(),
723
+ type: "merge.conflict",
724
+ repo: proposed.repo,
725
+ actor: actor.name,
726
+ data: {
727
+ change: changeIdentityOf(proposed),
728
+ from: proposed.sourceRepo,
729
+ ref: proposed.targetRef,
730
+ paths: [...outcome.paths],
731
+ },
732
+ });
733
+ return {
734
+ result: mergeResult.parse({
735
+ kind: "conflict",
736
+ paths: [...outcome.paths],
737
+ head: lease.head,
738
+ }),
739
+ events: [
740
+ activityEnvelope(stored, {
741
+ name: proposed.sourceRepo,
742
+ canonical: proposed.repo,
743
+ cloneUrl: sourceUrl,
744
+ }),
745
+ ],
746
+ };
747
+ }
748
+ return {
749
+ result: mergeResult.parse({
750
+ kind: "moved",
751
+ head: outcome.kind === "source-moved" ? outcome.head : row.head,
752
+ }),
753
+ events: [],
754
+ };
755
+ },
756
+ );
757
+ const result = bonsai.state.transition(
758
+ resultKey,
759
+ digest,
760
+ mergeResult,
761
+ () => ({ result: physical, events: [] }),
762
+ );
763
+ return result;
764
+ }
765
+
766
+ /** Re-admitted calls own only their member lane and must satisfy current row policy. */
767
+ export async function executeQueuedMerge(
768
+ bonsai: Bonsai,
769
+ caller: AppMethodCallContext,
770
+ job: WatchdogRunningJob,
771
+ enqueue: WatchdogTransactionalProjection["enqueueAcceptedJob"],
772
+ signal: AbortSignal,
773
+ background: BackgroundBinding,
774
+ ): Promise<WatchdogExecutionDecision> {
775
+ const payload = mergeJobSchema.parse(job.payload);
776
+ const row = bonsai.state.change(payload.repo, payload.number);
777
+ if (row?.lease?.id !== payload.leaseId) return { kind: "completed" };
778
+ if (
779
+ caller.actor.kind !== "member" ||
780
+ caller.workflow !== undefined ||
781
+ payload.lane !== actorLane(caller.actor) ||
782
+ job.lane !== payload.lane ||
783
+ row.lease.by !== caller.actor.memberId
784
+ )
785
+ return { kind: "failed", reason: "access_changed" };
786
+ const actor = callerActor(caller);
787
+ // Only this job's existing lease may project merging as open for its own policy check.
788
+ const facts = bonsai.state.reviewFacts(
789
+ actor,
790
+ { ...row, state: row.state === "merging" ? "open" : row.state },
791
+ payload.head,
792
+ [
793
+ ...new Set(
794
+ [...bonsai.state.origins(payload.reviewedCommits).values()].map(
795
+ (origin) => origin.agent,
796
+ ),
797
+ ),
798
+ ],
799
+ );
800
+ if (!decide("merge", facts).allowed)
801
+ return { kind: "failed", reason: "access_changed" };
802
+ const stageKey = `merge-step:${payload.leaseId}:${String(payload.step)}`;
803
+ const stageDigest = JSON.stringify(payload);
804
+ // A disappeared frame may repeat a completed claim, but not its exchange.
805
+ if (bonsai.state.receipt(stageKey, stageDigest, gitStepSchema) !== null)
806
+ return { kind: "completed" };
807
+ const admission = await background.admit(payload.origin);
808
+ if (admission.kind === "refused")
809
+ return { kind: "failed", reason: "access_changed" };
810
+ const owner = bonsai.state.backgroundOwner;
811
+ const material = sqliteGitStepMaterial(owner.sql);
812
+ const initialKey = `merge-start:${payload.leaseId}`;
813
+ const initialDigest = JSON.stringify({
814
+ lease: payload.leaseId,
815
+ head: payload.head,
816
+ });
817
+ let checkpoint = payload.checkpoint;
818
+ if (checkpoint === null) {
819
+ const saved = bonsai.state.receipt(
820
+ initialKey,
821
+ initialDigest,
822
+ gitStepSchema,
823
+ );
824
+ checkpoint =
825
+ saved?.result ??
826
+ (await begin(
827
+ mergeStepOperation(
828
+ row,
829
+ actor,
830
+ payload.leaseId,
831
+ payload.head,
832
+ Math.floor(row.lease.at / 1000),
833
+ ),
834
+ material,
835
+ ));
836
+ const initial = checkpoint;
837
+ bonsai.state.transition(initialKey, initialDigest, gitStepSchema, () => ({
838
+ result: initial,
839
+ events: [],
840
+ }));
841
+ }
842
+ let result;
843
+ try {
844
+ result = await runStep(checkpoint, { git: bonsai.store, material }, signal);
845
+ } catch (error) {
846
+ if (error instanceof GitStepMaterialError)
847
+ return { kind: "failed", reason: "sync_failed" };
848
+ throw error;
849
+ }
850
+ if (!("done" in result)) {
851
+ const next = "next" in result ? result.next : checkpoint;
852
+ bonsai.state.transition(stageKey, stageDigest, gitStepSchema, () => {
853
+ enqueue({
854
+ state: "queued",
855
+ jobId: `merge:${payload.leaseId}:${String(payload.step + 1)}`,
856
+ queue: `merge:${payload.leaseId}`,
857
+ lane: payload.lane,
858
+ priority: 0,
859
+ recovery: { maxRecoveries: 1 },
860
+ payload: mergeJobSchema.parse({
861
+ ...payload,
862
+ step: payload.step + 1,
863
+ checkpoint: next,
864
+ }),
865
+ });
866
+ return { result: next, events: [] };
867
+ });
868
+ return { kind: "completed" };
869
+ }
870
+ const outcome = result.done;
871
+ if (outcome.kind === "too_large")
872
+ return { kind: "failed", reason: "sync_failed" };
873
+ if (
874
+ outcome.kind !== "merged" &&
875
+ outcome.kind !== "up-to-date" &&
876
+ outcome.kind !== "stale" &&
877
+ outcome.kind !== "source-moved" &&
878
+ outcome.kind !== "conflict"
879
+ )
880
+ throw new Error("Unexpected merge step outcome");
881
+ // A stale target CAS retries bounded, re-reading the target fresh, exactly
882
+ // like the foreground's own retry loop in performMerge. The abandoned
883
+ // attempt's step material is left for the Watchdog's own terminal
884
+ // cleanup, which drops the whole shared owner prefix once this lease
885
+ // finally settles; dropping it early here, ahead of this step's own
886
+ // durable transition, would violate the material's own contract.
887
+ if (outcome.kind === "stale" && payload.attempt < 3) {
888
+ const retried = await begin(
889
+ mergeStepOperation(
890
+ row,
891
+ actor,
892
+ payload.leaseId,
893
+ payload.head,
894
+ Math.floor(row.lease.at / 1000),
895
+ ),
896
+ material,
897
+ );
898
+ bonsai.state.transition(stageKey, stageDigest, gitStepSchema, () => {
899
+ enqueue({
900
+ state: "queued",
901
+ jobId: `merge:${payload.leaseId}:${String(payload.step + 1)}`,
902
+ queue: `merge:${payload.leaseId}`,
903
+ lane: payload.lane,
904
+ priority: 0,
905
+ recovery: { maxRecoveries: 1 },
906
+ payload: mergeJobSchema.parse({
907
+ ...payload,
908
+ step: payload.step + 1,
909
+ attempt: payload.attempt + 1,
910
+ checkpoint: retried,
911
+ }),
912
+ });
913
+ return { result: retried, events: [] };
914
+ });
915
+ return { kind: "completed" };
916
+ }
917
+ // Settlement records the Git outcome it already has; it never waits on a
918
+ // screen read copy, which main's own movement makes rebuild routinely.
919
+ const [sourceUrl, targetUrl] = await Promise.all([
920
+ bonsai.store.cloneUrl(row.sourceRepo),
921
+ bonsai.store.cloneUrl(row.repo),
922
+ ]);
923
+ const settled = settleMerge(
924
+ bonsai,
925
+ actor,
926
+ row,
927
+ { repo: row.repo, number: row.number, head: payload.head },
928
+ `background:${payload.leaseId}`,
929
+ JSON.stringify({
930
+ actor,
931
+ input: { repo: row.repo, number: row.number, head: payload.head },
932
+ }),
933
+ sourceUrl,
934
+ targetUrl,
935
+ outcome,
936
+ // No synchronous caller exists for a queued job; a non-conflict
937
+ // settlement must record its own mergeFailure, inside this same
938
+ // settlement transition, instead of silently reopening the Change.
939
+ true,
940
+ );
941
+ bonsai.state.changeCopies.stale(changeCopyKey(row));
942
+ return settled.kind === "merged"
943
+ ? { kind: "completed" }
944
+ : { kind: "failed", reason: "sync_failed" };
945
+ }
946
+
947
+ /** Async material deletion follows the Watchdog's durable terminal settlement. */
948
+ export async function cleanupQueuedMerge(
949
+ bonsai: Bonsai,
950
+ job: WatchdogSettledJob,
951
+ ): Promise<void> {
952
+ const parsed = mergeJobSchema.safeParse(job.payload);
953
+ if (!parsed.success) return;
954
+ const payload = parsed.data;
955
+ // Intermediate step completion owns a successor and still needs its material.
956
+ if (
957
+ job.outcome === "completed" &&
958
+ bonsai.state.receipt(
959
+ `merge-step:${payload.leaseId}:${String(payload.step)}`,
960
+ JSON.stringify(payload),
961
+ gitStepSchema,
962
+ ) !== null
963
+ )
964
+ return;
965
+ const checkpoint =
966
+ payload.checkpoint ??
967
+ bonsai.state.receipt(
968
+ `merge-start:${payload.leaseId}`,
969
+ JSON.stringify({ lease: payload.leaseId, head: payload.head }),
970
+ gitStepSchema,
971
+ )?.result;
972
+ if (checkpoint !== undefined)
973
+ await dropMaterial(
974
+ checkpoint,
975
+ sqliteGitStepMaterial(bonsai.state.backgroundOwner.sql),
976
+ );
977
+ }
978
+
979
+ /** Watchdog failures and exhausted recovery release only the failed job's lease. */
980
+ export function projectQueuedMerge(
981
+ bonsai: Bonsai,
982
+ transition: WatchdogQueueTransition,
983
+ ): void {
984
+ if (
985
+ transition.settled === undefined ||
986
+ transition.settled.outcome === "completed"
987
+ )
988
+ return;
989
+ const parsed = mergeJobSchema.safeParse(transition.settled.payload);
990
+ if (!parsed.success) return;
991
+ bonsai.state.failMergeLease(
992
+ parsed.data.repo,
993
+ parsed.data.number,
994
+ parsed.data.leaseId,
995
+ transition.settled.failureReason === "access_changed"
996
+ ? "access_changed"
997
+ : "sync_failed",
998
+ );
999
+ }