@fungi.computer/bonsai 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +835 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +5 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +715 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- package/web/working-now.tsx +98 -0
package/backend/merge.ts
ADDED
|
@@ -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
|
+
}
|