@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.
Files changed (132) hide show
  1. package/CHANGELOG.md +17 -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 +835 -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/read-copy-calls.test.ts +691 -0
  90. package/test/read-copy-composer.test.ts +388 -0
  91. package/test/read-copy-lifetimes.test.ts +214 -0
  92. package/test/read-copy-rpc.config.jsonc +12 -0
  93. package/test/read-copy-rpc.native.mjs +47 -0
  94. package/test/read-copy-rpc.worker.ts +92 -0
  95. package/test/recent-activity.test.ts +62 -0
  96. package/test/repository-deletion.test.ts +500 -0
  97. package/test/seed-resume.test.mjs +91 -0
  98. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  99. package/test/storyboard-save-template.test.mjs +545 -0
  100. package/test/ui-errors.test.ts +976 -0
  101. package/test/workflow-store.test.ts +115 -0
  102. package/tsconfig.json +36 -0
  103. package/turbo.jsonc +25 -0
  104. package/vite.config.ts +19 -0
  105. package/vitest.config.ts +5 -0
  106. package/web/api.ts +224 -0
  107. package/web/app-build-panel.tsx +171 -0
  108. package/web/board.tsx +259 -0
  109. package/web/bonsai.css +642 -0
  110. package/web/change-page.tsx +715 -0
  111. package/web/change-rows.tsx +135 -0
  112. package/web/code-tab.tsx +267 -0
  113. package/web/commit-tree.tsx +152 -0
  114. package/web/format.ts +54 -0
  115. package/web/home-page.tsx +274 -0
  116. package/web/index.html +17 -0
  117. package/web/insight-data.ts +76 -0
  118. package/web/insight-tiles.tsx +534 -0
  119. package/web/insights.tsx +250 -0
  120. package/web/issues-panel.tsx +305 -0
  121. package/web/main.tsx +72 -0
  122. package/web/network-graph.tsx +647 -0
  123. package/web/overview.tsx +339 -0
  124. package/web/pages.tsx +469 -0
  125. package/web/parts.tsx +381 -0
  126. package/web/public/favicon.svg +1 -0
  127. package/web/repo-navigation.tsx +256 -0
  128. package/web/repo-settings.tsx +145 -0
  129. package/web/router.tsx +151 -0
  130. package/web/transport.ts +91 -0
  131. package/web/views.tsx +151 -0
  132. package/web/working-now.tsx +98 -0
@@ -0,0 +1,337 @@
1
+ /**
2
+ * The demo's multi-agent shape on real Git: three agent forks of one
3
+ * repository, merged in order by the owner while main moves under the later
4
+ * ones. A fork that edits the same lines as an earlier merge must settle a
5
+ * typed conflict (never a throw), mergeability must then say so, and the
6
+ * author's ordinary resolution (merge main into the fork, push) must land.
7
+ */
8
+ import * as z from "zod";
9
+ import { execFileSync } from "node:child_process";
10
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { DatabaseSync } from "node:sqlite";
15
+ import { expect, it } from "vitest";
16
+ import { commitFiles } from "@fungi.computer/agar/git-commit";
17
+ import { countingGit } from "@fungi.computer/agar/read-copy";
18
+ import type { GitBinding } from "@fungi.computer/agar/git";
19
+ import {
20
+ backgroundContext,
21
+ backgroundOriginKey,
22
+ sealBackgroundOrigin,
23
+ admitClaimedOrigin,
24
+ type BackgroundBinding,
25
+ } from "@fungi.computer/puffball/background-replay";
26
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
27
+ import { realGit } from "@fungi.computer/agar/test-kit/git";
28
+ import { appBackend } from "../backend/methods.js";
29
+ import { gitStore } from "../backend/git.js";
30
+ import { getRepoDetail } from "../backend/repository-copy.js";
31
+ import { observePush } from "../backend/service.js";
32
+ import { ensureBonsaiTables, BonsaiState } from "../backend/state.js";
33
+ import { changeDetail, mergeResult } from "../shared/contract.js";
34
+
35
+ const testKey = backgroundOriginKey("bonsai-merge-conflict-test-key");
36
+ const instanceId = "bonsai-test-instance";
37
+ const author = { name: "Owner", email: "owner@test.invalid" };
38
+ const owner = (name: string) => ({
39
+ actor: { kind: "member" as const, memberId: name },
40
+ computerId: null,
41
+ role: "owner" as const,
42
+ });
43
+ const agent = (name: string) => ({
44
+ actor: { kind: "agent" as const, agentId: name },
45
+ computerId: null,
46
+ role: "member" as const,
47
+ });
48
+ const replay: BackgroundBinding = {
49
+ admit: async (origin) =>
50
+ admitClaimedOrigin(
51
+ await testKey,
52
+ { instanceId, lane: "member:Levi", admitted: ["changes.merge"] },
53
+ origin,
54
+ ),
55
+ };
56
+ const changePage = z.object({
57
+ changes: z.array(z.object({ number: z.number(), sourceRepo: z.string() })),
58
+ });
59
+ const text = (value: string) => new TextEncoder().encode(value);
60
+
61
+ async function pond() {
62
+ const provider = realGit();
63
+ const bare = async (name: string) =>
64
+ fileURLToPath(await provider.git.cloneUrl(name));
65
+ // Agar's provider fork: a full copy of the source under the new name.
66
+ const git: GitBinding = {
67
+ ...provider.git,
68
+ fork: async (source, name) => {
69
+ const from = await bare(source);
70
+ const to = from.replace(/[^/]+\.git$/u, `${name}.git`);
71
+ execFileSync("git", ["clone", "-q", "--bare", from, to]);
72
+ execFileSync("git", [
73
+ `--git-dir=${to}`,
74
+ "config",
75
+ "http.receivepack",
76
+ "true",
77
+ ]);
78
+ return { name, defaultBranch: "main", lastPushAt: null, source };
79
+ },
80
+ };
81
+ const lifetime = new AbortController();
82
+ const counted = countingGit(git, {
83
+ limit: 10_000,
84
+ signal: lifetime.signal,
85
+ });
86
+ const database = new DatabaseSync(":memory:");
87
+ const sqlite = fromNodeSqlite(database);
88
+ ensureBonsaiTables(sqlite.sql);
89
+ const bonsai = {
90
+ state: new BonsaiState(sqlite.sql, sqlite.transactionSync),
91
+ store: gitStore(counted.git),
92
+ events: { emit: () => Promise.resolve() },
93
+ };
94
+ const fresh = () => ({
95
+ ...bonsai,
96
+ state: new BonsaiState(sqlite.sql, sqlite.transactionSync),
97
+ });
98
+ await provider.git.create("pond");
99
+ bonsai.state.registerRepo({
100
+ name: "pond",
101
+ description: null,
102
+ parent: null,
103
+ owner: null,
104
+ });
105
+ const started = await commitFiles(provider.git, {
106
+ repository: "pond",
107
+ branch: "main",
108
+ expectedHead: null,
109
+ author,
110
+ message: "Start",
111
+ changes: [{ path: "main.ts", content: text("pond()\n") }],
112
+ });
113
+ if (started.kind !== "committed")
114
+ throw new Error("Fixture base did not commit");
115
+ const base = started.commit;
116
+ await getRepoDetail(bonsai, "pond");
117
+ let mainHead = base;
118
+
119
+ async function observeForkPush(
120
+ repository: string,
121
+ name: string,
122
+ before: string,
123
+ after: string,
124
+ ) {
125
+ await observePush(bonsai, {
126
+ eventId: `push-${repository}-${after}`,
127
+ repository,
128
+ writer: { kind: "actor", actor: { kind: "agent", agentId: name } },
129
+ updates: [{ ref: "refs/heads/main", before, after }],
130
+ });
131
+ }
132
+
133
+ /** One agent forks, pushes one commit, and the push opens its Change. */
134
+ async function forkAndPush(name: string, path: string, content: string) {
135
+ const fork = z
136
+ .object({ name: z.string() })
137
+ .parse(
138
+ await appBackend.call(
139
+ "repos.fork",
140
+ { repo: "pond" },
141
+ { bonsai },
142
+ agent(name),
143
+ ),
144
+ ).name;
145
+ const pushed = await commitFiles(provider.git, {
146
+ repository: fork,
147
+ branch: "main",
148
+ expectedHead: base,
149
+ author,
150
+ message: `${name} work`,
151
+ changes: [{ path, content: text(content) }],
152
+ });
153
+ if (pushed.kind !== "committed") throw new Error("Fork push failed");
154
+ await observeForkPush(fork, name, base, pushed.commit);
155
+ const found = changePage
156
+ .parse(
157
+ await appBackend.call(
158
+ "changes.list",
159
+ { repo: "pond" },
160
+ { bonsai },
161
+ owner("Levi"),
162
+ ),
163
+ )
164
+ .changes.find((change) => change.sourceRepo === fork);
165
+ if (found === undefined) throw new Error(`No Change for ${fork}`);
166
+ return { fork, number: found.number, head: pushed.commit };
167
+ }
168
+
169
+ const detail = async (number: number) =>
170
+ changeDetail.parse(
171
+ await appBackend.call(
172
+ "changes.get",
173
+ { repo: "pond", number },
174
+ { bonsai },
175
+ owner("Levi"),
176
+ ),
177
+ ).change;
178
+
179
+ /** The owner's queued merge, drained the way host wakes drain it. */
180
+ async function merge(
181
+ number: number,
182
+ head: string,
183
+ key: string,
184
+ options: { staleTargetCopy?: boolean } = {},
185
+ ) {
186
+ const origin = await sealBackgroundOrigin(await testKey, {
187
+ instanceId,
188
+ lane: "member:Levi",
189
+ method: "changes.merge",
190
+ });
191
+ const accepted = mergeResult.parse(
192
+ await appBackend.call(
193
+ "changes.merge",
194
+ { repo: "pond", number, head },
195
+ { bonsai },
196
+ backgroundContext(
197
+ { ...owner("Levi"), idempotencyKey: key },
198
+ [],
199
+ { name: "changes.merge", effect: "write" },
200
+ origin,
201
+ ),
202
+ ),
203
+ );
204
+ // A screen copy rebuilding while the job runs must not cost its outcome.
205
+ if (options.staleTargetCopy === true)
206
+ bonsai.state.repositoryCopies.stale("pond");
207
+ for (let frame = 0; frame < 24; frame++) {
208
+ if (bonsai.state.requireChange("pond", number).state !== "merging") break;
209
+ await appBackend.call(
210
+ "sync",
211
+ {},
212
+ { bonsai: fresh(), env: { BACKGROUND: replay } },
213
+ { ...owner("Levi"), idempotencyKey: `${key}-${String(frame)}` },
214
+ );
215
+ }
216
+ const row = bonsai.state.requireChange("pond", number);
217
+ // The canonical push of a landed merge reaches Bonsai like any push.
218
+ if (row.state === "merged" && row.mergedCommit !== null) {
219
+ await observePush(bonsai, {
220
+ eventId: `main-${row.mergedCommit}`,
221
+ repository: "pond",
222
+ writer: { kind: "actor", actor: { kind: "member", memberId: "Levi" } },
223
+ updates: [
224
+ { ref: "refs/heads/main", before: mainHead, after: row.mergedCommit },
225
+ ],
226
+ });
227
+ mainHead = row.mergedCommit;
228
+ }
229
+ // The next repository read rebuilds the copy this test made stale.
230
+ if (options.staleTargetCopy === true) await getRepoDetail(bonsai, "pond");
231
+ return { accepted, row };
232
+ }
233
+
234
+ /** The demo's resolve pass: merge upstream main, keep both sides, push. */
235
+ async function resolve(
236
+ change: { fork: string; head: string },
237
+ name: string,
238
+ content: string,
239
+ ) {
240
+ const work = mkdtempSync(join(tmpdir(), "bonsai-resolve-"));
241
+ const git = (...args: string[]) =>
242
+ execFileSync(
243
+ "git",
244
+ ["-c", "user.name=agent", "-c", "user.email=a@test.invalid", ...args],
245
+ { cwd: work, encoding: "utf8", stdio: "pipe" },
246
+ ).trim();
247
+ try {
248
+ git("clone", "-q", await bare(change.fork), ".");
249
+ const upstream = await bare("pond");
250
+ // The same conflict Bonsai reported: Git stops the upstream merge.
251
+ expect(() =>
252
+ git("pull", "-q", "--no-rebase", "--no-edit", upstream, "main"),
253
+ ).toThrow();
254
+ writeFileSync(join(work, "main.ts"), content);
255
+ git("add", "main.ts");
256
+ git("commit", "-q", "--no-edit");
257
+ git("push", "-q", "origin", "main");
258
+ const after = git("rev-parse", "HEAD");
259
+ await observeForkPush(change.fork, name, change.head, after);
260
+ return after;
261
+ } finally {
262
+ rmSync(work, { recursive: true, force: true });
263
+ }
264
+ }
265
+ return {
266
+ forkAndPush,
267
+ detail,
268
+ merge,
269
+ resolve,
270
+ close() {
271
+ lifetime.abort();
272
+ database.close();
273
+ provider.close();
274
+ },
275
+ };
276
+ }
277
+
278
+ it("settles a typed conflict with its paths for a fork behind main, merges behind-but-clean forks, and lands the author's resolution", async () => {
279
+ const f = await pond();
280
+ try {
281
+ const enoki = await f.forkAndPush("enoki", "issues.md", "Resize\n");
282
+ const morel = await f.forkAndPush("morel", "main.ts", "pond()\nfood()\n");
283
+ const chanterelle = await f.forkAndPush(
284
+ "chanterelle",
285
+ "main.ts",
286
+ "pond()\nripples()\n",
287
+ );
288
+
289
+ expect(
290
+ (
291
+ await f.merge(enoki.number, enoki.head, "enoki", {
292
+ staleTargetCopy: true,
293
+ })
294
+ ).row.state,
295
+ ).toBe("merged");
296
+ // Behind main by enoki's merge, on a different path: still merges.
297
+ expect((await f.detail(morel.number)).behind).toBe(1);
298
+ expect((await f.merge(morel.number, morel.head, "morel")).row.state).toBe(
299
+ "merged",
300
+ );
301
+
302
+ expect((await f.detail(chanterelle.number)).behind).toBe(2);
303
+ const conflicted = await f.merge(
304
+ chanterelle.number,
305
+ chanterelle.head,
306
+ "chanterelle",
307
+ );
308
+ expect(conflicted.accepted).toEqual({ kind: "merging" });
309
+ expect(conflicted.row).toMatchObject({
310
+ state: "open",
311
+ lease: null,
312
+ conflict: { head: chanterelle.head, paths: ["main.ts"] },
313
+ });
314
+ expect(conflicted.row.mergeFailure).toBeUndefined();
315
+ const blocked = await f.detail(chanterelle.number);
316
+ expect(blocked.status).toBe("conflict");
317
+ expect(blocked.mergeable).toEqual({ allowed: false, reason: "conflict" });
318
+ // Asking again at the same head and target refuses typed, without Git.
319
+ expect(
320
+ (await f.merge(chanterelle.number, chanterelle.head, "again")).accepted,
321
+ ).toEqual({ kind: "refused", reason: "conflict" });
322
+
323
+ const resolved = await f.resolve(
324
+ chanterelle,
325
+ "chanterelle",
326
+ "pond()\nfood()\nripples()\n",
327
+ );
328
+ const ready = await f.detail(chanterelle.number);
329
+ expect(ready).toMatchObject({ head: resolved, behind: 0 });
330
+ expect(ready.mergeable).toEqual({ allowed: true });
331
+ expect(
332
+ (await f.merge(chanterelle.number, resolved, "resolved")).row.state,
333
+ ).toBe("merged");
334
+ } finally {
335
+ f.close();
336
+ }
337
+ });
@@ -0,0 +1,46 @@
1
+ import { expect, it } from "vitest";
2
+ import { nativeGitFrameMethods } from "@fungi.computer/agar/git-rpc";
3
+ import {
4
+ buildAppApi,
5
+ buildAppDeclaration,
6
+ } from "@fungi.computer/agar/backend-declaration";
7
+ import { appBackend } from "../backend/methods.js";
8
+ import bonsaiMetadata from "../package.json" with { type: "json" };
9
+ const appApi = buildAppApi(appBackend);
10
+ const appDeclaration = buildAppDeclaration(
11
+ appBackend,
12
+ bonsaiMetadata.fungi.app,
13
+ );
14
+
15
+ it("declares every native receive-pack operation used by an owner merge", () => {
16
+ const required = nativeGitFrameMethods.exchange.requiredOperations([
17
+ "pond",
18
+ { service: "receive-pack", phase: "advertise", headers: {}, body: null },
19
+ ]);
20
+ const git = appDeclaration.backend?.bindings.find(
21
+ (binding) => binding.name === "GIT",
22
+ );
23
+ expect(required).toEqual(["exchange", "write"]);
24
+ expect(git?.operations).toEqual(expect.arrayContaining([...required]));
25
+ });
26
+
27
+ it("requests the installation Git capability for Hub owner merges", () => {
28
+ const method = appApi.methods["changes.merge"];
29
+ if (method === undefined) throw new Error("Missing merge method");
30
+ expect(method.effect).toBe("write");
31
+ expect(method.requires).toEqual(["git.repository", "app.events.emit"]);
32
+ });
33
+
34
+ it.each([
35
+ "insights.layout.get",
36
+ "insights.layout.save",
37
+ "repos.list",
38
+ "activity.list",
39
+ "actors.list",
40
+ ])("keeps %s free of Git authority", (name) => {
41
+ const method = appApi.methods[name];
42
+ if (method === undefined) throw new Error(`Missing ${name} method`);
43
+ expect(method.requires).toEqual(
44
+ name === "insights.layout.save" ? ["app.events.emit"] : [],
45
+ );
46
+ });