@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.
- package/CHANGELOG.md +43 -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 +848 -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/present.test.ts +27 -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 +47 -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 +719 -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
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
dropMaterial,
|
|
6
|
+
sqliteGitStepMaterial,
|
|
7
|
+
} from "@fungi.computer/agar/git-steps";
|
|
8
|
+
import { mergeJobSchema } from "../backend/merge.js";
|
|
9
|
+
import { commitFiles } from "@fungi.computer/agar/git-commit";
|
|
10
|
+
import { countingGit } from "@fungi.computer/agar/read-copy";
|
|
11
|
+
import {
|
|
12
|
+
backgroundContext,
|
|
13
|
+
backgroundOriginKey,
|
|
14
|
+
sealBackgroundOrigin,
|
|
15
|
+
admitClaimedOrigin,
|
|
16
|
+
type BackgroundBinding,
|
|
17
|
+
} from "@fungi.computer/puffball/background-replay";
|
|
18
|
+
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
|
|
19
|
+
import { realGit } from "@fungi.computer/agar/test-kit/git";
|
|
20
|
+
import {
|
|
21
|
+
refuseBackground,
|
|
22
|
+
backgroundRuntime,
|
|
23
|
+
registerBackgroundProjector,
|
|
24
|
+
} from "../backend/background.js";
|
|
25
|
+
import { appBackend } from "../backend/methods.js";
|
|
26
|
+
import { gitStore } from "../backend/git.js";
|
|
27
|
+
import { getRepoDetail } from "../backend/repository-copy.js";
|
|
28
|
+
import { ensureBonsaiTables, BonsaiState } from "../backend/state.js";
|
|
29
|
+
import {
|
|
30
|
+
changeRecord,
|
|
31
|
+
changeDetail,
|
|
32
|
+
checkRequestResult,
|
|
33
|
+
} from "../shared/contract.js";
|
|
34
|
+
import { fileContent, fileSaveResult } from "@fungi.computer/agar/roles/files";
|
|
35
|
+
|
|
36
|
+
const testKey = backgroundOriginKey("bonsai-background-merge-test-key");
|
|
37
|
+
const instanceId = "bonsai-test-instance";
|
|
38
|
+
async function enqueueCaller(
|
|
39
|
+
context: ReturnType<typeof caller> & { idempotencyKey: string },
|
|
40
|
+
method = "changes.merge",
|
|
41
|
+
) {
|
|
42
|
+
const origin = await sealBackgroundOrigin(await testKey, {
|
|
43
|
+
instanceId,
|
|
44
|
+
lane: `member:${context.actor.memberId}`,
|
|
45
|
+
method,
|
|
46
|
+
});
|
|
47
|
+
return backgroundContext(
|
|
48
|
+
context,
|
|
49
|
+
[],
|
|
50
|
+
{ name: method, effect: "write" },
|
|
51
|
+
origin,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
function replayBinding(
|
|
55
|
+
admitted: readonly string[] = ["changes.merge"],
|
|
56
|
+
): BackgroundBinding {
|
|
57
|
+
return {
|
|
58
|
+
admit: async (origin) =>
|
|
59
|
+
admitClaimedOrigin(
|
|
60
|
+
await testKey,
|
|
61
|
+
{
|
|
62
|
+
instanceId,
|
|
63
|
+
lane: "member:Levi",
|
|
64
|
+
admitted,
|
|
65
|
+
},
|
|
66
|
+
origin,
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const author = { name: "Morel", email: "morel@test.invalid" };
|
|
72
|
+
const caller = (name: string) => ({
|
|
73
|
+
actor: { kind: "member" as const, memberId: name },
|
|
74
|
+
computerId: null,
|
|
75
|
+
role: "owner" as const,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
async function fixture() {
|
|
79
|
+
const provider = realGit();
|
|
80
|
+
const database = new DatabaseSync(":memory:");
|
|
81
|
+
const owner = fromNodeSqlite(database);
|
|
82
|
+
ensureBonsaiTables(owner.sql);
|
|
83
|
+
const lifetime = new AbortController();
|
|
84
|
+
const exchanges: { service: string; phase: string }[] = [];
|
|
85
|
+
const counted = countingGit(
|
|
86
|
+
{
|
|
87
|
+
...provider.git,
|
|
88
|
+
exchange: async (repo, request) => {
|
|
89
|
+
exchanges.push({ service: request.service, phase: request.phase });
|
|
90
|
+
return provider.git.exchange(repo, request);
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
limit: 10_000,
|
|
95
|
+
signal: lifetime.signal,
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
const bonsai = {
|
|
99
|
+
state: new BonsaiState(owner.sql, owner.transactionSync),
|
|
100
|
+
store: gitStore(counted.git),
|
|
101
|
+
events: { emit: () => Promise.resolve() },
|
|
102
|
+
};
|
|
103
|
+
await provider.git.create("pond");
|
|
104
|
+
bonsai.state.registerRepo({
|
|
105
|
+
name: "pond",
|
|
106
|
+
description: null,
|
|
107
|
+
parent: null,
|
|
108
|
+
owner: null,
|
|
109
|
+
});
|
|
110
|
+
const base = await commitFiles(provider.git, {
|
|
111
|
+
repository: "pond",
|
|
112
|
+
branch: "main",
|
|
113
|
+
expectedHead: null,
|
|
114
|
+
author,
|
|
115
|
+
message: "Start",
|
|
116
|
+
changes: [
|
|
117
|
+
{ path: "notes.txt", content: new TextEncoder().encode("Fish\n") },
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
if (base.kind !== "committed") throw new Error("Fixture base did not commit");
|
|
121
|
+
const source = await commitFiles(provider.git, {
|
|
122
|
+
repository: "pond",
|
|
123
|
+
branch: "feature",
|
|
124
|
+
expectedHead: null,
|
|
125
|
+
startFrom: "main",
|
|
126
|
+
author,
|
|
127
|
+
message: "Add waves",
|
|
128
|
+
changes: [
|
|
129
|
+
{ path: "waves.txt", content: new TextEncoder().encode("Waves\n") },
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
if (source.kind !== "committed")
|
|
133
|
+
throw new Error("Fixture source did not commit");
|
|
134
|
+
await getRepoDetail(bonsai, "pond");
|
|
135
|
+
const open = async () =>
|
|
136
|
+
changeRecord.parse(
|
|
137
|
+
await appBackend.call(
|
|
138
|
+
"changes.open",
|
|
139
|
+
{ repo: "pond", sourceRef: "feature" },
|
|
140
|
+
{ bonsai },
|
|
141
|
+
caller("Morel"),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
const detail = async (number: number) =>
|
|
145
|
+
changeDetail.parse(
|
|
146
|
+
await appBackend.call(
|
|
147
|
+
"changes.get",
|
|
148
|
+
{ repo: "pond", number },
|
|
149
|
+
{ bonsai },
|
|
150
|
+
caller("Levi"),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
return {
|
|
154
|
+
bonsai,
|
|
155
|
+
provider,
|
|
156
|
+
counted,
|
|
157
|
+
exchanges,
|
|
158
|
+
base: base.commit,
|
|
159
|
+
source: source.commit,
|
|
160
|
+
open,
|
|
161
|
+
detail,
|
|
162
|
+
reopen() {
|
|
163
|
+
return {
|
|
164
|
+
...bonsai,
|
|
165
|
+
state: new BonsaiState(owner.sql, owner.transactionSync),
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
close() {
|
|
169
|
+
lifetime.abort();
|
|
170
|
+
database.close();
|
|
171
|
+
provider.close();
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it("accepts a member merge as Merging without changing Git in the click", async () => {
|
|
177
|
+
const f = await fixture();
|
|
178
|
+
try {
|
|
179
|
+
const row = await f.open();
|
|
180
|
+
await f.detail(row.number);
|
|
181
|
+
const before = f.counted.counts();
|
|
182
|
+
const result = await appBackend.call(
|
|
183
|
+
"changes.merge",
|
|
184
|
+
{ repo: "pond", number: row.number, head: f.source },
|
|
185
|
+
{ bonsai: f.bonsai },
|
|
186
|
+
await enqueueCaller({
|
|
187
|
+
...caller("Levi"),
|
|
188
|
+
idempotencyKey: "background-merge-click",
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
expect(result).toMatchObject({ kind: "merging" });
|
|
192
|
+
const pending = await f.detail(row.number);
|
|
193
|
+
expect(pending.change).toMatchObject({
|
|
194
|
+
state: "merging",
|
|
195
|
+
lease: { by: "Levi", head: f.source },
|
|
196
|
+
});
|
|
197
|
+
expect((f.counted.counts().exchange ?? 0) - (before.exchange ?? 0)).toBe(0);
|
|
198
|
+
expect(await f.provider.git.refs("pond")).toContainEqual({
|
|
199
|
+
name: "refs/heads/main",
|
|
200
|
+
hash: f.base,
|
|
201
|
+
});
|
|
202
|
+
} finally {
|
|
203
|
+
f.close();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("rechecks failed rows on replay, refuses the merge and releases its lease without Git", async () => {
|
|
208
|
+
const f = await fixture();
|
|
209
|
+
try {
|
|
210
|
+
const row = await f.open();
|
|
211
|
+
await f.detail(row.number);
|
|
212
|
+
await appBackend.call(
|
|
213
|
+
"changes.merge",
|
|
214
|
+
{ repo: "pond", number: row.number, head: f.source },
|
|
215
|
+
{ bonsai: f.bonsai },
|
|
216
|
+
await enqueueCaller({
|
|
217
|
+
...caller("Levi"),
|
|
218
|
+
idempotencyKey: "policy-change-click",
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
const checker = {
|
|
222
|
+
actor: { kind: "agent" as const, agentId: "ci" },
|
|
223
|
+
computerId: null,
|
|
224
|
+
};
|
|
225
|
+
const requested = checkRequestResult.parse(
|
|
226
|
+
await appBackend.call(
|
|
227
|
+
"checks.request",
|
|
228
|
+
{
|
|
229
|
+
repo: "pond",
|
|
230
|
+
number: row.number,
|
|
231
|
+
head: f.source,
|
|
232
|
+
names: ["typecheck"],
|
|
233
|
+
},
|
|
234
|
+
{ bonsai: f.bonsai },
|
|
235
|
+
checker,
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
if (requested.kind !== "requested")
|
|
239
|
+
throw new Error("Fixture check not requested");
|
|
240
|
+
await appBackend.call(
|
|
241
|
+
"checks.report",
|
|
242
|
+
{
|
|
243
|
+
repo: "pond",
|
|
244
|
+
number: row.number,
|
|
245
|
+
head: f.source,
|
|
246
|
+
attemptId: requested.attemptId,
|
|
247
|
+
name: "typecheck",
|
|
248
|
+
status: "failed",
|
|
249
|
+
},
|
|
250
|
+
{ bonsai: f.bonsai },
|
|
251
|
+
checker,
|
|
252
|
+
);
|
|
253
|
+
const before = f.counted.total();
|
|
254
|
+
expect(
|
|
255
|
+
await appBackend.call(
|
|
256
|
+
"sync",
|
|
257
|
+
{},
|
|
258
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding() } },
|
|
259
|
+
{ ...caller("Levi"), idempotencyKey: "replay-policy-1" },
|
|
260
|
+
),
|
|
261
|
+
).toEqual({ more: false });
|
|
262
|
+
expect(f.counted.total() - before).toBe(0);
|
|
263
|
+
expect((await f.detail(row.number)).change).toMatchObject({
|
|
264
|
+
state: "open",
|
|
265
|
+
lease: null,
|
|
266
|
+
mergeFailure: "access_changed",
|
|
267
|
+
});
|
|
268
|
+
expect(await f.provider.git.refs("pond")).toContainEqual({
|
|
269
|
+
name: "refs/heads/main",
|
|
270
|
+
hash: f.base,
|
|
271
|
+
});
|
|
272
|
+
} finally {
|
|
273
|
+
f.close();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("reconstructs one merge step per replay without reporting an intermediate merge as complete", async () => {
|
|
278
|
+
const f = await fixture();
|
|
279
|
+
try {
|
|
280
|
+
const row = await f.open();
|
|
281
|
+
await f.detail(row.number);
|
|
282
|
+
await appBackend.call(
|
|
283
|
+
"changes.merge",
|
|
284
|
+
{ repo: "pond", number: row.number, head: f.source },
|
|
285
|
+
{ bonsai: f.bonsai },
|
|
286
|
+
await enqueueCaller({
|
|
287
|
+
...caller("Levi"),
|
|
288
|
+
idempotencyKey: "step-merge-click",
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
let more = true;
|
|
292
|
+
let frames = 0;
|
|
293
|
+
while (more && frames < 12) {
|
|
294
|
+
const bonsai = f.reopen();
|
|
295
|
+
const before = f.counted.counts().exchange ?? 0;
|
|
296
|
+
const result = await appBackend.call(
|
|
297
|
+
"sync",
|
|
298
|
+
{},
|
|
299
|
+
{ bonsai, env: { BACKGROUND: replayBinding() } },
|
|
300
|
+
{ ...caller("Levi"), idempotencyKey: `step-replay-${String(frames)}` },
|
|
301
|
+
);
|
|
302
|
+
expect((f.counted.counts().exchange ?? 0) - before).toBeLessThanOrEqual(
|
|
303
|
+
1,
|
|
304
|
+
);
|
|
305
|
+
more = z.strictObject({ more: z.boolean() }).parse(result).more;
|
|
306
|
+
if (!more)
|
|
307
|
+
expect(f.exchanges.at(-1)).not.toEqual({
|
|
308
|
+
service: "receive-pack",
|
|
309
|
+
phase: "exchange",
|
|
310
|
+
});
|
|
311
|
+
const current = bonsai.state.requireChange("pond", row.number);
|
|
312
|
+
if (more) {
|
|
313
|
+
const readStart = f.counted.total();
|
|
314
|
+
expect((await f.detail(row.number)).change.state).toBe("merging");
|
|
315
|
+
expect(f.counted.total() - readStart).toBe(0);
|
|
316
|
+
}
|
|
317
|
+
if (more)
|
|
318
|
+
expect(current).toMatchObject({
|
|
319
|
+
state: "merging",
|
|
320
|
+
lease: { head: f.source },
|
|
321
|
+
mergedCommit: null,
|
|
322
|
+
});
|
|
323
|
+
else
|
|
324
|
+
expect(current).toMatchObject({
|
|
325
|
+
state: "merged",
|
|
326
|
+
lease: null,
|
|
327
|
+
mergedBy: "Levi",
|
|
328
|
+
});
|
|
329
|
+
frames++;
|
|
330
|
+
}
|
|
331
|
+
expect(more).toBe(false);
|
|
332
|
+
expect(frames).toBeGreaterThan(1);
|
|
333
|
+
expect(await f.provider.git.refs("pond")).toContainEqual({
|
|
334
|
+
name: "refs/heads/main",
|
|
335
|
+
hash: f.bonsai.state.requireChange("pond", row.number).mergedCommit,
|
|
336
|
+
});
|
|
337
|
+
} finally {
|
|
338
|
+
f.close();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("settles an access-changed merge lane without Git and preserves another member's pending work", async () => {
|
|
343
|
+
const f = await fixture();
|
|
344
|
+
try {
|
|
345
|
+
const row = await f.open();
|
|
346
|
+
await f.detail(row.number);
|
|
347
|
+
await appBackend.call(
|
|
348
|
+
"changes.merge",
|
|
349
|
+
{ repo: "pond", number: row.number, head: f.source },
|
|
350
|
+
{ bonsai: f.bonsai },
|
|
351
|
+
await enqueueCaller({
|
|
352
|
+
...caller("Levi"),
|
|
353
|
+
idempotencyKey: "refused-merge-click",
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
await appBackend.call(
|
|
357
|
+
"files.save",
|
|
358
|
+
{
|
|
359
|
+
repo: "pond",
|
|
360
|
+
branch: "main",
|
|
361
|
+
path: "private.txt",
|
|
362
|
+
text: "Retained",
|
|
363
|
+
baseCommit: f.base,
|
|
364
|
+
baseBlob: null,
|
|
365
|
+
revision: null,
|
|
366
|
+
},
|
|
367
|
+
{ bonsai: f.bonsai },
|
|
368
|
+
await enqueueCaller(
|
|
369
|
+
{ ...caller("Riley"), idempotencyKey: "other-private-save" },
|
|
370
|
+
"files.save",
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
const bonsai = f.reopen();
|
|
374
|
+
const before = f.counted.total();
|
|
375
|
+
await refuseBackground(bonsai, {
|
|
376
|
+
lane: "member:Levi",
|
|
377
|
+
code: "access_changed",
|
|
378
|
+
});
|
|
379
|
+
await refuseBackground(bonsai, {
|
|
380
|
+
lane: "member:Levi",
|
|
381
|
+
code: "access_changed",
|
|
382
|
+
});
|
|
383
|
+
expect(f.counted.total() - before).toBe(0);
|
|
384
|
+
expect(bonsai.state.requireChange("pond", row.number)).toMatchObject({
|
|
385
|
+
state: "open",
|
|
386
|
+
lease: null,
|
|
387
|
+
mergeFailure: "access_changed",
|
|
388
|
+
});
|
|
389
|
+
expect(bonsai.state.fileEdits.hasPending("member:Riley")).toBe(true);
|
|
390
|
+
} finally {
|
|
391
|
+
f.close();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("recovers a checkpointed step after settlement failure without repeating its Git exchange", async () => {
|
|
396
|
+
const f = await fixture();
|
|
397
|
+
try {
|
|
398
|
+
const row = await f.open();
|
|
399
|
+
await f.detail(row.number);
|
|
400
|
+
await appBackend.call(
|
|
401
|
+
"changes.merge",
|
|
402
|
+
{ repo: "pond", number: row.number, head: f.source },
|
|
403
|
+
{ bonsai: f.bonsai },
|
|
404
|
+
await enqueueCaller({
|
|
405
|
+
...caller("Levi"),
|
|
406
|
+
idempotencyKey: "recovery-merge-click",
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
const runtime = await backgroundRuntime(
|
|
410
|
+
f.bonsai,
|
|
411
|
+
caller("Levi"),
|
|
412
|
+
new AbortController().signal,
|
|
413
|
+
replayBinding(),
|
|
414
|
+
);
|
|
415
|
+
registerBackgroundProjector(f.bonsai, "merge", (transition) => {
|
|
416
|
+
if (transition.settled?.outcome === "completed")
|
|
417
|
+
throw new Error("Storage settlement interrupted");
|
|
418
|
+
});
|
|
419
|
+
const before = f.counted.counts().exchange ?? 0;
|
|
420
|
+
await expect(runtime.tickConcurrent()).rejects.toThrow();
|
|
421
|
+
expect((f.counted.counts().exchange ?? 0) - before).toBe(1);
|
|
422
|
+
const recoveryStart = f.counted.counts().exchange ?? 0;
|
|
423
|
+
expect(
|
|
424
|
+
await appBackend.call(
|
|
425
|
+
"sync",
|
|
426
|
+
{},
|
|
427
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding() } },
|
|
428
|
+
{ ...caller("Levi"), idempotencyKey: "recover-checkpoint" },
|
|
429
|
+
),
|
|
430
|
+
).toEqual({ more: true });
|
|
431
|
+
expect((f.counted.counts().exchange ?? 0) - recoveryStart).toBe(0);
|
|
432
|
+
expect(f.bonsai.state.requireChange("pond", row.number)).toMatchObject({
|
|
433
|
+
state: "merging",
|
|
434
|
+
lease: { head: f.source },
|
|
435
|
+
});
|
|
436
|
+
let more = true;
|
|
437
|
+
for (let frame = 0; more && frame < 12; frame++) {
|
|
438
|
+
const start = f.counted.counts().exchange ?? 0;
|
|
439
|
+
const result = await appBackend.call(
|
|
440
|
+
"sync",
|
|
441
|
+
{},
|
|
442
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding() } },
|
|
443
|
+
{ ...caller("Levi"), idempotencyKey: `recovery-step-${String(frame)}` },
|
|
444
|
+
);
|
|
445
|
+
expect((f.counted.counts().exchange ?? 0) - start).toBeLessThanOrEqual(1);
|
|
446
|
+
more = z.strictObject({ more: z.boolean() }).parse(result).more;
|
|
447
|
+
}
|
|
448
|
+
expect(more).toBe(false);
|
|
449
|
+
expect(f.bonsai.state.requireChange("pond", row.number)).toMatchObject({
|
|
450
|
+
state: "merged",
|
|
451
|
+
lease: null,
|
|
452
|
+
});
|
|
453
|
+
} finally {
|
|
454
|
+
f.close();
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("leaves a queued merge unclaimed on foreground sync and refuses a freshly withdrawn origin", async () => {
|
|
459
|
+
const f = await fixture();
|
|
460
|
+
try {
|
|
461
|
+
const row = await f.open();
|
|
462
|
+
await f.detail(row.number);
|
|
463
|
+
await appBackend.call(
|
|
464
|
+
"changes.merge",
|
|
465
|
+
{ repo: "pond", number: row.number, head: f.source },
|
|
466
|
+
{ bonsai: f.bonsai },
|
|
467
|
+
await enqueueCaller({
|
|
468
|
+
...caller("Levi"),
|
|
469
|
+
idempotencyKey: "origin-withdrawal",
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
const before = f.counted.total();
|
|
473
|
+
expect(
|
|
474
|
+
await appBackend.call("sync", {}, { bonsai: f.reopen() }, caller("Levi")),
|
|
475
|
+
).toEqual({ more: false });
|
|
476
|
+
expect(f.bonsai.state.requireChange("pond", row.number).state).toBe(
|
|
477
|
+
"merging",
|
|
478
|
+
);
|
|
479
|
+
expect(
|
|
480
|
+
await appBackend.call(
|
|
481
|
+
"sync",
|
|
482
|
+
{},
|
|
483
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding([]) } },
|
|
484
|
+
caller("Levi"),
|
|
485
|
+
),
|
|
486
|
+
).toEqual({ more: false });
|
|
487
|
+
expect(f.counted.total() - before).toBe(0);
|
|
488
|
+
expect(f.bonsai.state.requireChange("pond", row.number)).toMatchObject({
|
|
489
|
+
state: "open",
|
|
490
|
+
lease: null,
|
|
491
|
+
mergeFailure: "access_changed",
|
|
492
|
+
});
|
|
493
|
+
} finally {
|
|
494
|
+
f.close();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("settles missing immutable step material as a permanent merge failure without retrying Git", async () => {
|
|
499
|
+
const f = await fixture();
|
|
500
|
+
try {
|
|
501
|
+
const row = await f.open();
|
|
502
|
+
await f.detail(row.number);
|
|
503
|
+
await appBackend.call(
|
|
504
|
+
"changes.merge",
|
|
505
|
+
{ repo: "pond", number: row.number, head: f.source },
|
|
506
|
+
{ bonsai: f.bonsai },
|
|
507
|
+
await enqueueCaller({
|
|
508
|
+
...caller("Levi"),
|
|
509
|
+
idempotencyKey: "material-unavailable",
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
const lease = f.bonsai.state.requireChange("pond", row.number).lease;
|
|
513
|
+
if (lease === null) throw new Error("Missing merge lease");
|
|
514
|
+
for (let frame = 0; frame < 3; frame++)
|
|
515
|
+
await appBackend.call(
|
|
516
|
+
"sync",
|
|
517
|
+
{},
|
|
518
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding() } },
|
|
519
|
+
caller("Levi"),
|
|
520
|
+
);
|
|
521
|
+
const runtime = await backgroundRuntime(
|
|
522
|
+
f.reopen(),
|
|
523
|
+
caller("Levi"),
|
|
524
|
+
new AbortController().signal,
|
|
525
|
+
replayBinding(),
|
|
526
|
+
);
|
|
527
|
+
const head = await runtime.readQueueHead(`merge:${lease.id}`);
|
|
528
|
+
if (head.state === "idle") throw new Error("Missing step successor");
|
|
529
|
+
const stored = await runtime.read(head.jobId);
|
|
530
|
+
if (stored.status !== "found") throw new Error("Missing step job");
|
|
531
|
+
const payload = mergeJobSchema.parse(stored.job.payload);
|
|
532
|
+
if (payload.checkpoint === null)
|
|
533
|
+
throw new Error("Missing persisted checkpoint");
|
|
534
|
+
expect(payload.checkpoint.phase).toBe("push");
|
|
535
|
+
await dropMaterial(
|
|
536
|
+
payload.checkpoint,
|
|
537
|
+
sqliteGitStepMaterial(f.bonsai.state.backgroundOwner.sql),
|
|
538
|
+
);
|
|
539
|
+
const before = f.counted.total();
|
|
540
|
+
expect(
|
|
541
|
+
await appBackend.call(
|
|
542
|
+
"sync",
|
|
543
|
+
{},
|
|
544
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding() } },
|
|
545
|
+
caller("Levi"),
|
|
546
|
+
),
|
|
547
|
+
).toEqual({ more: false });
|
|
548
|
+
expect(f.counted.total() - before).toBe(0);
|
|
549
|
+
const terminal = await runtime.read(head.jobId);
|
|
550
|
+
expect(terminal).toMatchObject({
|
|
551
|
+
status: "found",
|
|
552
|
+
job: {
|
|
553
|
+
state: "settled",
|
|
554
|
+
outcome: "failed",
|
|
555
|
+
failureReason: "sync_failed",
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
expect(f.bonsai.state.requireChange("pond", row.number)).toMatchObject({
|
|
559
|
+
state: "open",
|
|
560
|
+
lease: null,
|
|
561
|
+
mergeFailure: "sync_failed",
|
|
562
|
+
});
|
|
563
|
+
expect(
|
|
564
|
+
await appBackend.call(
|
|
565
|
+
"sync",
|
|
566
|
+
{},
|
|
567
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: replayBinding() } },
|
|
568
|
+
caller("Levi"),
|
|
569
|
+
),
|
|
570
|
+
).toEqual({ more: false });
|
|
571
|
+
expect(f.counted.total() - before).toBe(0);
|
|
572
|
+
} finally {
|
|
573
|
+
f.close();
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("composes queued file and merge stages through one fresh replay claim per frame", async () => {
|
|
578
|
+
const f = await fixture();
|
|
579
|
+
try {
|
|
580
|
+
const draft = await commitFiles(f.provider.git, {
|
|
581
|
+
repository: "pond",
|
|
582
|
+
branch: "draft",
|
|
583
|
+
expectedHead: null,
|
|
584
|
+
startFrom: "main",
|
|
585
|
+
author,
|
|
586
|
+
message: "Start draft",
|
|
587
|
+
changes: [
|
|
588
|
+
{ path: "draft.txt", content: new TextEncoder().encode("Draft\n") },
|
|
589
|
+
],
|
|
590
|
+
});
|
|
591
|
+
if (draft.kind !== "committed")
|
|
592
|
+
throw new Error("Draft fixture did not commit");
|
|
593
|
+
f.bonsai.state.repositoryCopies.stale("pond");
|
|
594
|
+
await getRepoDetail(f.bonsai, "pond");
|
|
595
|
+
const row = await f.open();
|
|
596
|
+
await f.detail(row.number);
|
|
597
|
+
const baseline = fileContent.parse(
|
|
598
|
+
await appBackend.call(
|
|
599
|
+
"files.read",
|
|
600
|
+
{ repo: "pond", ref: "draft", path: "notes.txt" },
|
|
601
|
+
{ bonsai: f.bonsai },
|
|
602
|
+
caller("Levi"),
|
|
603
|
+
),
|
|
604
|
+
);
|
|
605
|
+
const saved = fileSaveResult.parse(
|
|
606
|
+
await appBackend.call(
|
|
607
|
+
"files.save",
|
|
608
|
+
{
|
|
609
|
+
repo: "pond",
|
|
610
|
+
branch: "draft",
|
|
611
|
+
path: "notes.txt",
|
|
612
|
+
text: "Private draft update\n",
|
|
613
|
+
baseCommit: baseline.commit,
|
|
614
|
+
baseBlob: baseline.blob,
|
|
615
|
+
revision: null,
|
|
616
|
+
},
|
|
617
|
+
{ bonsai: f.bonsai },
|
|
618
|
+
await enqueueCaller(
|
|
619
|
+
{ ...caller("Levi"), idempotencyKey: "mixed-file-click" },
|
|
620
|
+
"files.save",
|
|
621
|
+
),
|
|
622
|
+
),
|
|
623
|
+
);
|
|
624
|
+
expect(saved).toMatchObject({ kind: "saved", state: "pending" });
|
|
625
|
+
await appBackend.call(
|
|
626
|
+
"changes.merge",
|
|
627
|
+
{ repo: "pond", number: row.number, head: f.source },
|
|
628
|
+
{ bonsai: f.bonsai },
|
|
629
|
+
await enqueueCaller({
|
|
630
|
+
...caller("Levi"),
|
|
631
|
+
idempotencyKey: "mixed-merge-click",
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
let more = true;
|
|
635
|
+
let frames = 0;
|
|
636
|
+
while (more && frames < 20) {
|
|
637
|
+
const admitted = replayBinding(["files.save", "changes.merge"]);
|
|
638
|
+
let claims = 0;
|
|
639
|
+
const background = {
|
|
640
|
+
admit: (origin: Parameters<typeof admitted.admit>[0]) => {
|
|
641
|
+
claims++;
|
|
642
|
+
return admitted.admit(origin);
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
const before = f.counted.counts().exchange ?? 0;
|
|
646
|
+
const result = await appBackend.call(
|
|
647
|
+
"sync",
|
|
648
|
+
{},
|
|
649
|
+
{ bonsai: f.reopen(), env: { BACKGROUND: background } },
|
|
650
|
+
{ ...caller("Levi"), idempotencyKey: `mixed-frame-${String(frames)}` },
|
|
651
|
+
);
|
|
652
|
+
expect(claims).toBeLessThanOrEqual(1);
|
|
653
|
+
expect((f.counted.counts().exchange ?? 0) - before).toBeLessThanOrEqual(
|
|
654
|
+
1,
|
|
655
|
+
);
|
|
656
|
+
more = z.strictObject({ more: z.boolean() }).parse(result).more;
|
|
657
|
+
frames++;
|
|
658
|
+
}
|
|
659
|
+
expect(more).toBe(false);
|
|
660
|
+
expect(frames).toBeGreaterThan(5);
|
|
661
|
+
expect(f.bonsai.state.requireChange("pond", row.number)).toMatchObject({
|
|
662
|
+
state: "merged",
|
|
663
|
+
lease: null,
|
|
664
|
+
});
|
|
665
|
+
expect(
|
|
666
|
+
fileContent.parse(
|
|
667
|
+
await appBackend.call(
|
|
668
|
+
"files.read",
|
|
669
|
+
{ repo: "pond", ref: "draft", path: "notes.txt" },
|
|
670
|
+
{ bonsai: f.reopen() },
|
|
671
|
+
caller("Levi"),
|
|
672
|
+
),
|
|
673
|
+
),
|
|
674
|
+
).toMatchObject({ text: "Private draft update\n" });
|
|
675
|
+
} finally {
|
|
676
|
+
f.close();
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("keeps an active claim alive across two public sync frames while admission is pending", async () => {
|
|
681
|
+
const f = await fixture();
|
|
682
|
+
const entered = Promise.withResolvers<undefined>();
|
|
683
|
+
const release = Promise.withResolvers<undefined>();
|
|
684
|
+
let first:
|
|
685
|
+
| Promise<{ ok: true; value: unknown } | { ok: false; error: unknown }>
|
|
686
|
+
| undefined;
|
|
687
|
+
try {
|
|
688
|
+
const row = await f.open();
|
|
689
|
+
await f.detail(row.number);
|
|
690
|
+
await appBackend.call(
|
|
691
|
+
"changes.merge",
|
|
692
|
+
{ repo: "pond", number: row.number, head: f.source },
|
|
693
|
+
{ bonsai: f.bonsai },
|
|
694
|
+
await enqueueCaller({
|
|
695
|
+
...caller("Levi"),
|
|
696
|
+
idempotencyKey: "live-claim-click",
|
|
697
|
+
}),
|
|
698
|
+
);
|
|
699
|
+
const realAdmission = replayBinding();
|
|
700
|
+
const background = {
|
|
701
|
+
admit: async (origin: Parameters<typeof realAdmission.admit>[0]) => {
|
|
702
|
+
entered.resolve(undefined);
|
|
703
|
+
await release.promise;
|
|
704
|
+
return realAdmission.admit(origin);
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
first = appBackend
|
|
708
|
+
.call(
|
|
709
|
+
"sync",
|
|
710
|
+
{},
|
|
711
|
+
{ bonsai: f.bonsai, env: { BACKGROUND: background } },
|
|
712
|
+
{ ...caller("Levi"), idempotencyKey: "live-frame-A" },
|
|
713
|
+
)
|
|
714
|
+
.then(
|
|
715
|
+
(value) => ({ ok: true as const, value }),
|
|
716
|
+
(error: unknown) => ({ ok: false as const, error }),
|
|
717
|
+
);
|
|
718
|
+
await entered.promise;
|
|
719
|
+
let secondAdmissions = 0;
|
|
720
|
+
const secondBackground = {
|
|
721
|
+
admit: (origin: Parameters<typeof realAdmission.admit>[0]) => {
|
|
722
|
+
secondAdmissions++;
|
|
723
|
+
return realAdmission.admit(origin);
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
const before = f.counted.total();
|
|
727
|
+
expect(
|
|
728
|
+
await appBackend.call(
|
|
729
|
+
"sync",
|
|
730
|
+
{},
|
|
731
|
+
{ bonsai: f.bonsai, env: { BACKGROUND: secondBackground } },
|
|
732
|
+
{ ...caller("Levi"), idempotencyKey: "live-frame-B" },
|
|
733
|
+
),
|
|
734
|
+
).toEqual({ more: true });
|
|
735
|
+
expect(secondAdmissions).toBe(0);
|
|
736
|
+
expect(f.counted.total() - before).toBe(0);
|
|
737
|
+
release.resolve(undefined);
|
|
738
|
+
expect(await first).toEqual({ ok: true, value: { more: true } });
|
|
739
|
+
const stepStart = f.counted.counts().exchange ?? 0;
|
|
740
|
+
expect(
|
|
741
|
+
await appBackend.call(
|
|
742
|
+
"sync",
|
|
743
|
+
{},
|
|
744
|
+
{ bonsai: f.bonsai, env: { BACKGROUND: replayBinding() } },
|
|
745
|
+
{ ...caller("Levi"), idempotencyKey: "after-live-frame" },
|
|
746
|
+
),
|
|
747
|
+
).toEqual({ more: true });
|
|
748
|
+
expect((f.counted.counts().exchange ?? 0) - stepStart).toBeLessThanOrEqual(
|
|
749
|
+
1,
|
|
750
|
+
);
|
|
751
|
+
} finally {
|
|
752
|
+
release.resolve(undefined);
|
|
753
|
+
if (first !== undefined) await first;
|
|
754
|
+
f.close();
|
|
755
|
+
}
|
|
756
|
+
});
|