@fungi.computer/bonsai 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +848 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/present.test.ts +27 -0
  90. package/test/read-copy-calls.test.ts +691 -0
  91. package/test/read-copy-composer.test.ts +388 -0
  92. package/test/read-copy-lifetimes.test.ts +214 -0
  93. package/test/read-copy-rpc.config.jsonc +12 -0
  94. package/test/read-copy-rpc.native.mjs +47 -0
  95. package/test/read-copy-rpc.worker.ts +92 -0
  96. package/test/recent-activity.test.ts +62 -0
  97. package/test/repository-deletion.test.ts +500 -0
  98. package/test/seed-resume.test.mjs +91 -0
  99. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  100. package/test/storyboard-save-template.test.mjs +545 -0
  101. package/test/ui-errors.test.ts +976 -0
  102. package/test/workflow-store.test.ts +115 -0
  103. package/tsconfig.json +36 -0
  104. package/turbo.jsonc +25 -0
  105. package/vite.config.ts +19 -0
  106. package/vitest.config.ts +47 -0
  107. package/web/api.ts +224 -0
  108. package/web/app-build-panel.tsx +171 -0
  109. package/web/board.tsx +259 -0
  110. package/web/bonsai.css +642 -0
  111. package/web/change-page.tsx +719 -0
  112. package/web/change-rows.tsx +135 -0
  113. package/web/code-tab.tsx +267 -0
  114. package/web/commit-tree.tsx +152 -0
  115. package/web/format.ts +54 -0
  116. package/web/home-page.tsx +274 -0
  117. package/web/index.html +17 -0
  118. package/web/insight-data.ts +76 -0
  119. package/web/insight-tiles.tsx +534 -0
  120. package/web/insights.tsx +250 -0
  121. package/web/issues-panel.tsx +305 -0
  122. package/web/main.tsx +72 -0
  123. package/web/network-graph.tsx +647 -0
  124. package/web/overview.tsx +339 -0
  125. package/web/pages.tsx +469 -0
  126. package/web/parts.tsx +381 -0
  127. package/web/public/favicon.svg +1 -0
  128. package/web/repo-navigation.tsx +256 -0
  129. package/web/repo-settings.tsx +145 -0
  130. package/web/router.tsx +151 -0
  131. package/web/transport.ts +91 -0
  132. package/web/views.tsx +151 -0
  133. package/web/working-now.tsx +98 -0
@@ -0,0 +1,321 @@
1
+ /** Graph and Commit screen projections in the facet's bounded read copies. */
2
+ import * as z from "zod";
3
+ import {
4
+ readCopyPageInput,
5
+ type ReadCopyPageInput,
6
+ } from "../shared/contract.js";
7
+ import { readCopyPageOffset, sliceReadCopyPage } from "./read-copy-page.js";
8
+ import { ReadCopyBudgetExceeded } from "@fungi.computer/agar/read-copy";
9
+ import {
10
+ commitDetail,
11
+ graph,
12
+ type CommitDetail,
13
+ type Graph,
14
+ type GraphLane,
15
+ type GraphNode,
16
+ } from "../shared/contract.js";
17
+ import { commitPage, type CommitPage } from "@fungi.computer/agar/roles/files";
18
+ import { treeDiff } from "./diff.js";
19
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
20
+ import { projectionStore } from "./history.js";
21
+ import { readRepositoryRefs } from "./repository-copy.js";
22
+ import type { Bonsai } from "./service.js";
23
+
24
+ const oid = /^[0-9a-f]{40}$/u;
25
+ const pageKey = (bonsai: Bonsai, repo: string, ref: string) =>
26
+ JSON.stringify([repo, bonsai.state.repositoryGeneration(repo), ref]);
27
+ const graphKey = (bonsai: Bonsai, repo: string) =>
28
+ JSON.stringify([repo, bonsai.state.repositoryGeneration(repo)]);
29
+ const graphCopies = (bonsai: Bonsai) =>
30
+ bonsai.state.copies.open("bonsai-graph", graph);
31
+ const pages = (bonsai: Bonsai) =>
32
+ bonsai.state.copies.open("bonsai-commit-page", commitPage);
33
+ const details = (bonsai: Bonsai) =>
34
+ bonsai.state.copies.open("bonsai-commit-detail", commitDetail);
35
+
36
+ function known(bonsai: Bonsai, repo: string) {
37
+ const summary = bonsai.state.repo(repo);
38
+ if (summary === null)
39
+ throw new AppMethodRefusal("not_found", `No repository ${repo}`);
40
+ return summary;
41
+ }
42
+ function localRefs(bonsai: Bonsai, repo: string) {
43
+ known(bonsai, repo);
44
+ return readRepositoryRefs(bonsai, repo).refs;
45
+ }
46
+ function refHead(bonsai: Bonsai, repo: string, ref: string) {
47
+ if (oid.test(ref)) return ref;
48
+ const found = localRefs(bonsai, repo).find(
49
+ (item) =>
50
+ item.name === ref ||
51
+ item.name === `refs/heads/${ref}` ||
52
+ item.name === `refs/tags/${ref}`,
53
+ );
54
+ return found?.hash ?? null;
55
+ }
56
+ function localLanes(bonsai: Bonsai, repo: string): GraphLane[] {
57
+ const summary = known(bonsai, repo);
58
+ if (summary.parent !== null)
59
+ throw new AppMethodRefusal("invalid", "Graph the canonical repository");
60
+ return [repo, ...summary.forks].flatMap((member) => {
61
+ const owner = known(bonsai, member).owner;
62
+ return localRefs(bonsai, member)
63
+ .filter((ref) => ref.name.startsWith("refs/heads/"))
64
+ .sort((a, b) =>
65
+ a.name === "refs/heads/main"
66
+ ? -1
67
+ : b.name === "refs/heads/main"
68
+ ? 1
69
+ : a.name.localeCompare(b.name),
70
+ )
71
+ .map((ref) => ({
72
+ id: `${member}:${ref.name.slice(11)}`,
73
+ repo: member,
74
+ ref: ref.name.slice(11),
75
+ owner: member === repo ? null : owner,
76
+ head: ref.hash,
77
+ canonical: member === repo,
78
+ }));
79
+ });
80
+ }
81
+ function origins(bonsai: Bonsai, value: Graph): Graph {
82
+ const recorded = bonsai.state.origins(value.nodes.map((node) => node.hash));
83
+ return {
84
+ lanes: value.lanes,
85
+ nodes: value.nodes.map((node) => ({
86
+ ...node,
87
+ agent: recorded.get(node.hash)?.agent ?? null,
88
+ origin: recorded.get(node.hash)?.repo ?? null,
89
+ })),
90
+ };
91
+ }
92
+
93
+ /** Caller supplies the existing bounded frame; this module never resets its budget. */
94
+ export async function readGraph(bonsai: Bonsai, repo: string): Promise<Graph> {
95
+ const lanes = localLanes(bonsai, repo);
96
+ const copy = graphCopies(bonsai);
97
+ const key = graphKey(bonsai, repo);
98
+ const saved = copy.read(key);
99
+ if (
100
+ saved.kind === "ready" &&
101
+ JSON.stringify(saved.value.lanes) === JSON.stringify(lanes)
102
+ )
103
+ return origins(bonsai, saved.value);
104
+ // A changed family must never retain its old ready projection if rebuilding fails.
105
+ copy.stale(key);
106
+ const store = projectionStore(bonsai);
107
+ const nodes = new Map<string, GraphNode>();
108
+ for (const lane of lanes) {
109
+ for (const commit of await store.log(lane.repo, lane.head, 60)) {
110
+ const existing = nodes.get(commit.hash);
111
+ if (existing) {
112
+ existing.lanes.push(lane.id);
113
+ continue;
114
+ }
115
+ nodes.set(commit.hash, {
116
+ hash: commit.hash,
117
+ parents: commit.parents,
118
+ message: commit.message.split("\n")[0] ?? "",
119
+ author: commit.author.name,
120
+ committedAt: commit.committedAt,
121
+ agent: null,
122
+ origin: null,
123
+ lanes: [lane.id],
124
+ });
125
+ }
126
+ }
127
+ const value = {
128
+ lanes,
129
+ nodes: [...nodes.values()].sort((a, b) => b.committedAt - a.committedAt),
130
+ };
131
+ copy.write(key, null, value);
132
+ return origins(bonsai, value);
133
+ }
134
+
135
+ /** Mutable branch/tag pages chain on local refs; OID pages retain immutable history. */
136
+ export async function readCommits(
137
+ bonsai: Bonsai,
138
+ input: { repo: string; ref: string } & ReadCopyPageInput,
139
+ ): Promise<CommitPage> {
140
+ known(bonsai, input.repo);
141
+ const options = readCopyPageInput.parse({
142
+ limit: input.limit,
143
+ after: input.after,
144
+ });
145
+ const head = refHead(bonsai, input.repo, input.ref);
146
+ const scope = JSON.stringify(["commits", input.repo, input.ref]);
147
+ const offset = readCopyPageOffset(options.after, scope, head);
148
+ const copy = pages(bonsai),
149
+ key = pageKey(bonsai, input.repo, input.ref);
150
+ const saved = copy.read(key);
151
+ if (
152
+ options.after !== undefined &&
153
+ (saved.kind !== "ready" || saved.head !== head)
154
+ )
155
+ throw new AppMethodRefusal(
156
+ "conflict",
157
+ "Saved Commit page is unavailable; restart from the first page.",
158
+ );
159
+ let value: CommitPage;
160
+ if (saved.kind === "ready" && saved.head === head) value = saved.value;
161
+ else {
162
+ copy.stale(key);
163
+ const commits =
164
+ head === null
165
+ ? []
166
+ : await projectionStore(bonsai).log(input.repo, head, 100);
167
+ value = {
168
+ ref: input.ref,
169
+ commits: commits.map((commit) => ({ commit, agent: null })),
170
+ };
171
+ if (!oid.test(input.ref) && refHead(bonsai, input.repo, input.ref) !== head)
172
+ throw new AppMethodRefusal(
173
+ "unavailable",
174
+ "Commit refs changed during rebuild.",
175
+ );
176
+ copy.write(key, head, value);
177
+ }
178
+ const selected = sliceReadCopyPage(
179
+ value.commits,
180
+ options.limit,
181
+ offset,
182
+ scope,
183
+ head,
184
+ );
185
+ const recorded = bonsai.state.origins(
186
+ selected.items.map((item) => item.commit.hash),
187
+ );
188
+ const result: CommitPage = {
189
+ ref: value.ref,
190
+ commits: selected.items.map((item) => ({
191
+ ...item,
192
+ agent: recorded.get(item.commit.hash)?.agent ?? null,
193
+ })),
194
+ };
195
+ if (selected.next !== null) result.next = selected.next;
196
+ return result;
197
+ }
198
+
199
+ /** Immutable OID detail and its first-parent diff survive ref changes and restarts. */
200
+ export async function readCommitDetail(
201
+ bonsai: Bonsai,
202
+ input: { repo: string; hash: string },
203
+ ): Promise<CommitDetail> {
204
+ known(bonsai, input.repo);
205
+ if (!oid.test(input.hash))
206
+ throw new AppMethodRefusal("invalid", "Expected a commit hash");
207
+ const copy = details(bonsai),
208
+ key = pageKey(bonsai, input.repo, input.hash);
209
+ const saved = copy.read(key);
210
+ let value: CommitDetail;
211
+ if (saved.kind === "ready") value = saved.value;
212
+ else {
213
+ const store = projectionStore(bonsai);
214
+ const commit = await store.commit(input.repo, input.hash);
215
+ if (commit === null)
216
+ throw new AppMethodRefusal("not_found", "No such commit");
217
+ const parent =
218
+ commit.parents[0] === undefined
219
+ ? null
220
+ : await store.commit(input.repo, commit.parents[0]);
221
+ value = {
222
+ commit,
223
+ agent: null,
224
+ diff: await treeDiff(
225
+ store,
226
+ input.repo,
227
+ parent?.treeHash ?? null,
228
+ commit.treeHash,
229
+ ),
230
+ };
231
+ copy.write(key, input.hash, value);
232
+ }
233
+ return {
234
+ ...value,
235
+ agent: bonsai.state.origins([input.hash]).get(input.hash)?.agent ?? null,
236
+ };
237
+ }
238
+
239
+ /** Composer calls synchronously after receipt check and BEFORE any provider await. */
240
+ export function invalidateGraphCommitCopies(
241
+ bonsai: Bonsai,
242
+ repo: string,
243
+ ): void {
244
+ known(bonsai, repo);
245
+ const canonical = bonsai.state.canonicalOf(repo);
246
+ graphCopies(bonsai).stale(graphKey(bonsai, canonical));
247
+ const saved = bonsai.state.repositoryCopies.read(repo);
248
+ if (saved.kind === "ready")
249
+ for (const ref of saved.value.refs) {
250
+ const copy = pages(bonsai);
251
+ copy.stale(pageKey(bonsai, repo, ref.name));
252
+ copy.stale(
253
+ pageKey(bonsai, repo, ref.name.replace(/^refs\/(heads|tags)\//u, "")),
254
+ );
255
+ }
256
+ }
257
+
258
+ const rebuildItem = z.discriminatedUnion("kind", [
259
+ z.object({ kind: z.literal("graph"), repo: z.string() }),
260
+ z.object({ kind: z.literal("commits"), repo: z.string(), ref: z.string() }),
261
+ z.object({
262
+ kind: z.literal("commit"),
263
+ repo: z.string(),
264
+ hash: z.string().regex(oid),
265
+ }),
266
+ ]);
267
+ /** Opaque cursor keys are App-owned values, not a second shared rebuild protocol. */
268
+ export function graphCommitCopyKeys(bonsai: Bonsai, repo: string): string[] {
269
+ const canonical = bonsai.state.canonicalOf(repo);
270
+ const family = known(bonsai, canonical);
271
+ return [
272
+ JSON.stringify({ kind: "graph", repo: canonical }),
273
+ ...[canonical, ...family.forks].flatMap((member) =>
274
+ localRefs(bonsai, member).flatMap((ref) => [
275
+ JSON.stringify({
276
+ kind: "commits",
277
+ repo: member,
278
+ ref: ref.name.replace(/^refs\/(heads|tags)\//u, ""),
279
+ }),
280
+ JSON.stringify({ kind: "commit", repo: member, hash: ref.hash }),
281
+ ]),
282
+ ),
283
+ ];
284
+ }
285
+ /** One selected foreground key in the caller's frame; unfinished keys retain their cursor. */
286
+ export async function rebuildGraphCommitCopy(
287
+ bonsai: Bonsai,
288
+ key: string,
289
+ ): Promise<boolean> {
290
+ const item = rebuildItem.parse(JSON.parse(key));
291
+ try {
292
+ switch (item.kind) {
293
+ case "graph":
294
+ await readGraph(bonsai, item.repo);
295
+ break;
296
+ case "commits":
297
+ await readCommits(bonsai, item);
298
+ break;
299
+ case "commit":
300
+ await readCommitDetail(bonsai, item);
301
+ break;
302
+ }
303
+ return true;
304
+ } catch (error) {
305
+ if (
306
+ error instanceof ReadCopyBudgetExceeded ||
307
+ (error instanceof AppMethodRefusal && error.code === "unavailable")
308
+ )
309
+ return false;
310
+ throw error;
311
+ }
312
+ }
313
+ /** Push composer runs this after Changed-Change refresh, before Files, on the same store. */
314
+ export async function refreshGraphCommitCopies(
315
+ bonsai: Bonsai,
316
+ repo: string,
317
+ ): Promise<boolean> {
318
+ for (const key of graphCommitCopyKeys(bonsai, repo))
319
+ if (!(await rebuildGraphCommitCopy(bonsai, key))) return false;
320
+ return true;
321
+ }
@@ -0,0 +1,178 @@
1
+ /** Bounded Git ancestry and host-recorded commit origins for proposals. */
2
+ import type { ChangeRecord } from "../shared/contract.js";
3
+ import type { Commit } from "@fungi.computer/agar/roles/files";
4
+ import type { GitStore } from "./git.js";
5
+ import type { Bonsai } from "./service.js";
6
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
7
+ import * as z from "zod";
8
+ import {
9
+ commit as commitSchema,
10
+ treeEntry,
11
+ } from "@fungi.computer/agar/roles/files";
12
+
13
+ const HISTORY = 300;
14
+ const MAX_SIDE_BRANCHES = 24;
15
+
16
+ /** Immutable Git objects survive frame boundaries while a bounded rebuild advances. */
17
+ export function projectionStore(bonsai: Bonsai): GitStore {
18
+ const store = bonsai.store;
19
+ const commits = bonsai.state.copies.open("bonsai-commit", commitSchema);
20
+ const trees = bonsai.state.copies.open("bonsai-tree", z.array(treeEntry));
21
+ const logs = bonsai.state.copies.open(
22
+ "bonsai-history",
23
+ z.array(commitSchema),
24
+ );
25
+ return {
26
+ ...store,
27
+ async commit(repo, hash) {
28
+ const key = JSON.stringify([
29
+ repo,
30
+ bonsai.state.repositoryGeneration(repo),
31
+ hash,
32
+ ]);
33
+ const saved = commits.read(key);
34
+ if (saved.kind === "ready") return saved.value;
35
+ const value = await store.commit(repo, hash);
36
+ if (value !== null) commits.write(key, hash, value);
37
+ return value;
38
+ },
39
+ async tree(repo, hash) {
40
+ const key = JSON.stringify([
41
+ repo,
42
+ bonsai.state.repositoryGeneration(repo),
43
+ hash,
44
+ ]);
45
+ const saved = trees.read(key);
46
+ if (saved.kind === "ready") return saved.value;
47
+ const value = await store.tree(repo, hash);
48
+ if (value !== null) trees.write(key, hash, [...value]);
49
+ return value;
50
+ },
51
+ async blob(repo, hash) {
52
+ const saved = bonsai.state.copies.readBlob(hash);
53
+ if (saved !== null) return saved;
54
+ const value = await store.blob(repo, hash);
55
+ if (value !== null && value.byteLength <= 512_000)
56
+ bonsai.state.copies.putBlob(hash, value);
57
+ return value;
58
+ },
59
+ async log(repo, ref, limit, offset = 0) {
60
+ if (!/^[0-9a-f]{40}$/u.test(ref))
61
+ return store.log(repo, ref, limit, offset);
62
+ const key = JSON.stringify([
63
+ repo,
64
+ bonsai.state.repositoryGeneration(repo),
65
+ ref,
66
+ limit,
67
+ offset,
68
+ ]);
69
+ const saved = logs.read(key);
70
+ if (saved.kind === "ready") return saved.value;
71
+ const value = await store.log(repo, ref, limit, offset);
72
+ logs.write(key, ref, [...value]);
73
+ return value;
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Commits reachable from `ref`: the first-parent history plus the histories
80
+ * merged into it, bounded so a listing stays a handful of reads.
81
+ */
82
+ async function ancestry(
83
+ store: GitStore,
84
+ repo: string,
85
+ ref: string,
86
+ ): Promise<{ firstParent: readonly Commit[]; all: ReadonlySet<string> }> {
87
+ const firstParent = await store.log(repo, ref, HISTORY);
88
+ const all = new Set(firstParent.map((commit) => commit.hash));
89
+ const pending = firstParent.flatMap((commit) => commit.parents.slice(1));
90
+ let reads = 0;
91
+ while (pending.length > 0 && reads < MAX_SIDE_BRANCHES) {
92
+ const next = pending.shift();
93
+ if (next === undefined || all.has(next)) continue;
94
+ reads += 1;
95
+ for (const commit of await store.log(repo, next, HISTORY)) {
96
+ if (all.has(commit.hash)) break;
97
+ all.add(commit.hash);
98
+ pending.push(...commit.parents.slice(1));
99
+ }
100
+ }
101
+ return { firstParent, all };
102
+ }
103
+
104
+ export type ChangeHistory = Readonly<{
105
+ /** Fork commits the canonical repository does not contain, newest first. */
106
+ ahead: readonly Commit[];
107
+ aheadHashes: readonly string[];
108
+ base: string | null;
109
+ behind: number;
110
+ head: string;
111
+ }>;
112
+
113
+ export async function compareChange(
114
+ store: GitStore,
115
+ canonical: string,
116
+ fork: string,
117
+ head: string,
118
+ target: string,
119
+ ): Promise<ChangeHistory | null> {
120
+ const [main, branch] = await Promise.all([
121
+ ancestry(store, canonical, target),
122
+ ancestry(store, fork, head),
123
+ ]);
124
+ const tip = branch.firstParent[0];
125
+ if (tip === undefined) return null;
126
+ const ahead: Commit[] = [];
127
+ let base: string | null = null;
128
+ for (const commit of branch.firstParent) {
129
+ if (main.all.has(commit.hash)) {
130
+ base = commit.hash;
131
+ break;
132
+ }
133
+ ahead.push(commit);
134
+ }
135
+ let behind = 0;
136
+ for (const commit of main.firstParent) {
137
+ if (branch.all.has(commit.hash)) break;
138
+ behind += 1;
139
+ }
140
+ return {
141
+ ahead,
142
+ aheadHashes: [...branch.all].filter((hash) => !main.all.has(hash)),
143
+ base,
144
+ behind,
145
+ head: tip.hash,
146
+ };
147
+ }
148
+
149
+ /** Bounded target reachability, prepared once per accepted target update. */
150
+ export async function reachableCommits(
151
+ store: GitStore,
152
+ repo: string,
153
+ target: string,
154
+ ): Promise<ReadonlySet<string>> {
155
+ return (await ancestry(store, repo, target)).all;
156
+ }
157
+
158
+ export async function authorsAtHead(
159
+ bonsai: Bonsai,
160
+ row: ChangeRecord,
161
+ ): Promise<readonly string[]> {
162
+ const history = await compareChange(
163
+ bonsai.store,
164
+ row.repo,
165
+ row.sourceRepo,
166
+ row.head,
167
+ row.target,
168
+ );
169
+ if (history === null)
170
+ throw new AppMethodRefusal("not_found", "The proposed head is missing");
171
+ return [
172
+ ...new Set(
173
+ [...bonsai.state.origins(history.aheadHashes).values()].map(
174
+ (origin) => origin.agent,
175
+ ),
176
+ ),
177
+ ];
178
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The Bonsai App facet: a Durable Object whose generated RPC methods are
3
+ * Bonsai's App methods. `fetch` serves the App's own UI (a JSON PUT naming a
4
+ * method, as agar's appBackend.request sends it). Native onGitPush receives
5
+ * accepted push facts from the host.
6
+ */
7
+ import {
8
+ AppMethodRefusal,
9
+ appBackendActorHeader,
10
+ appBackendComputerHeader,
11
+ appMethodRefusalStatus,
12
+ } from "@fungi.computer/agar/backend";
13
+ import {
14
+ gitPushQuestionSchema,
15
+ type GitPushQuestion,
16
+ type GitPushVerdict,
17
+ type AppGitPushFact,
18
+ } from "@fungi.computer/agar/git";
19
+ import { DurableObject } from "cloudflare:workers";
20
+ import * as z from "zod";
21
+ import { gitStore, type GitBinding } from "./git.js";
22
+ import { appBackend, type BonsaiAppsBinding } from "./methods.js";
23
+ import {
24
+ observePush,
25
+ type EventSink,
26
+ type Bonsai as BonsaiRuntime,
27
+ } from "./service.js";
28
+ import { ensureBonsaiTables, BonsaiState } from "./state.js";
29
+ import { mayPush } from "./policy.js";
30
+ import type {
31
+ BackgroundSyncRefusal,
32
+ BackgroundBinding,
33
+ } from "@fungi.computer/puffball/background-replay";
34
+ import { refuseBackground } from "./background.js";
35
+
36
+ /** The bindings the host grants Bonsai (see the Oyster binding proposal). */
37
+ export type BonsaiEnv = Readonly<{
38
+ GIT: GitBinding;
39
+ EVENTS: EventSink;
40
+ BACKGROUND?: BackgroundBinding;
41
+ /** Fork, build and install Apps; present only on a host that supplies it. */
42
+ APPS?: BonsaiAppsBinding;
43
+ }>;
44
+
45
+ const uiRequest = z.strictObject({
46
+ method: z.string().min(1).max(80),
47
+ input: z.json(),
48
+ });
49
+
50
+ function declared(method: string): boolean {
51
+ return Object.hasOwn(appBackend.methods, method);
52
+ }
53
+
54
+ function failure(error: Error): Response {
55
+ if (error instanceof AppMethodRefusal)
56
+ return Response.json(
57
+ { error: error.code, message: error.message },
58
+ { status: appMethodRefusalStatus[error.code] },
59
+ );
60
+ if (error instanceof z.ZodError)
61
+ return Response.json({ error: "Invalid request" }, { status: 400 });
62
+ console.error("bonsai backend error", error);
63
+ return Response.json(
64
+ { error: "Bonsai could not finish that request" },
65
+ { status: 500 },
66
+ );
67
+ }
68
+
69
+ class BonsaiFacet extends DurableObject<BonsaiEnv> {
70
+ declare public readonly env: BonsaiEnv;
71
+ readonly bonsai: BonsaiRuntime;
72
+
73
+ constructor(ctx: DurableObjectState, env: BonsaiEnv) {
74
+ super(ctx, env);
75
+ ensureBonsaiTables(ctx.storage.sql);
76
+ this.bonsai = {
77
+ store: gitStore(env.GIT),
78
+ state: new BonsaiState(ctx.storage.sql, (body) =>
79
+ ctx.storage.transactionSync(body),
80
+ ),
81
+ events: env.EVENTS,
82
+ };
83
+ }
84
+
85
+ syncRefused(refusal: BackgroundSyncRefusal): Promise<void> {
86
+ return refuseBackground(this.bonsai, refusal);
87
+ }
88
+
89
+ async onGitPush(fact: AppGitPushFact): Promise<void> {
90
+ await observePush(this.bonsai, fact);
91
+ }
92
+
93
+ authorizeGitPush(raw: GitPushQuestion): Promise<GitPushVerdict> {
94
+ return Promise.resolve().then(() => {
95
+ const question = gitPushQuestionSchema.parse(raw);
96
+ const actor = question.writer.actor;
97
+ const allowed = mayPush(
98
+ actor.kind === "member"
99
+ ? { name: actor.memberId, kind: "human" }
100
+ : {
101
+ name: actor.kind === "team-key" ? actor.keyId : actor.agentId,
102
+ kind: "agent",
103
+ },
104
+ this.bonsai.state.repo(question.repository),
105
+ );
106
+ return allowed
107
+ ? { allowed: true }
108
+ : {
109
+ allowed: false,
110
+ refused: question.updates.map((update) => ({
111
+ ref: update.ref,
112
+ message: "Writer cannot push to this repository",
113
+ })),
114
+ };
115
+ });
116
+ }
117
+
118
+ override async fetch(request: Request): Promise<Response> {
119
+ try {
120
+ if (request.method !== "PUT")
121
+ return new Response(null, { status: 405, headers: { Allow: "PUT" } });
122
+ const body = uiRequest.parse(await request.json());
123
+ if (!declared(body.method))
124
+ return Response.json({ error: "No such method" }, { status: 404 });
125
+ const memberId = request.headers.get(appBackendActorHeader);
126
+ if (memberId === null)
127
+ return Response.json(
128
+ { error: "App actor unavailable" },
129
+ { status: 403 },
130
+ );
131
+ const result = await appBackend.call(body.method, body.input, this, {
132
+ computerId: request.headers.get(appBackendComputerHeader),
133
+ actor: { kind: "member", memberId },
134
+ });
135
+ return Response.json(result);
136
+ } catch (error) {
137
+ return failure(
138
+ error instanceof Error ? error : new Error("Non-error thrown"),
139
+ );
140
+ }
141
+ }
142
+ }
143
+
144
+ export const BonsaiApp = appBackend.implement(BonsaiFacet);