@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,892 @@
1
+ import {
2
+ decodeGillOptionsResponse,
3
+ type GillOptionsResponse,
4
+ } from "@fungi.computer/gills";
5
+ import type { PickerOptionsInput } from "../shared/picker-options.js";
6
+ import { readRepositoryRefs } from "./repository-copy.js";
7
+ import {
8
+ copyListSnapshot,
9
+ readCopyPageOffset,
10
+ sliceReadCopyPage,
11
+ } from "./read-copy-page.js";
12
+ /**
13
+ * Bonsai's operations, independent of how they are called: App methods (for
14
+ * Agents and the host), the App's own UI, and push facts from the host all
15
+ * land here. Git goes through the GIT binding, events leave through the
16
+ * host's emit, and state lives in the App's SQLite.
17
+ */
18
+ import * as z from "zod";
19
+ import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
20
+ import { replayKey } from "@fungi.computer/cairn";
21
+ import {
22
+ appGitPushFactSchema,
23
+ type AppGitPushFact,
24
+ } from "@fungi.computer/agar/git";
25
+ import { ReadCopyBudgetExceeded } from "@fungi.computer/agar/read-copy";
26
+ import type { EventSink } from "@fungi.computer/oyster/git";
27
+ import type {
28
+ Activity,
29
+ CommitDetail,
30
+ Graph,
31
+ IssueFiles,
32
+ RepoSummary,
33
+ ReadCopyPageInput,
34
+ } from "../shared/contract.js";
35
+ import type {
36
+ Commit,
37
+ CommitPage,
38
+ FileContent,
39
+ FileList,
40
+ TreeListing,
41
+ } from "@fungi.computer/agar/roles/files";
42
+ import { activityEnvelope, type HookEvent } from "../shared/hooks.js";
43
+ import { drainOutbox } from "./state.js";
44
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
45
+ import { readCodeInsights } from "./insights.js";
46
+ import { reconcilePush } from "./changes.js";
47
+ import { refreshChangeCopy } from "./change-copy.js";
48
+ import { refreshRepositoryCopy } from "./repository-copy.js";
49
+ import { reachableCommits, projectionStore } from "./history.js";
50
+ import { changedPaths, decodeText, listPaths } from "./diff.js";
51
+ import { readCopyFrame, type GitStore } from "./git.js";
52
+ import {
53
+ listFilesCopy,
54
+ listTreeCopy,
55
+ readFileCopy,
56
+ invalidateFilesCopies,
57
+ filesCopyRequests,
58
+ refreshFilesCopy,
59
+ } from "./files-copy.js";
60
+ import {
61
+ readGraph,
62
+ readCommits,
63
+ readCommitDetail,
64
+ invalidateGraphCommitCopies,
65
+ graphCommitCopyKeys,
66
+ refreshGraphCommitCopies,
67
+ rebuildGraphCommitCopy,
68
+ } from "./graph-commit-copies.js";
69
+ import type { ActorKind, NewActivity, BonsaiState } from "./state.js";
70
+
71
+ /**
72
+ * Who is acting, as the host attributes the call. `soleMember` is the host's
73
+ * attestation that this person is currently their Team's only member; Bonsai
74
+ * never infers it from the people it has seen.
75
+ */
76
+ export type BonsaiActor = Readonly<{
77
+ name: string;
78
+ kind: ActorKind;
79
+ soleMember?: true;
80
+ }>;
81
+
82
+ /** The host's EVENTS binding (Oyster's EventSink). */
83
+ export type { EventSink };
84
+
85
+ export type Bonsai = Readonly<{
86
+ store: GitStore;
87
+ state: BonsaiState;
88
+ events: EventSink;
89
+ }>;
90
+
91
+ const MAX_ISSUE_FILES = 200;
92
+ const MAX_ISSUE_BYTES = 64_000;
93
+ const HEX40 = /^[0-9a-f]{40}$/u;
94
+ const ZERO = "0".repeat(40);
95
+ export const repoNamePattern = /^[a-z0-9][a-z0-9._-]{0,62}$/u;
96
+
97
+ const recordedResult = z.literal(true);
98
+
99
+ /** Record activity and durable envelopes in the same local transaction. */
100
+ async function publish(
101
+ bonsai: Bonsai,
102
+ event: NewActivity,
103
+ mutate?: () => void,
104
+ ): Promise<void> {
105
+ const cloneUrl = await bonsai.store.cloneUrl(event.repo);
106
+ bonsai.state.transition(
107
+ `activity:${replayKey()}`,
108
+ JSON.stringify(event),
109
+ recordedResult,
110
+ () => {
111
+ mutate?.();
112
+ return {
113
+ result: true,
114
+ events: activityEvents(bonsai, bonsai.state.record(event), cloneUrl),
115
+ };
116
+ },
117
+ );
118
+ }
119
+
120
+ function activityEvents(
121
+ bonsai: Bonsai,
122
+ stored: Activity,
123
+ cloneUrl: string,
124
+ ): HookEvent[] {
125
+ return [
126
+ activityEnvelope(stored, {
127
+ name: stored.repo,
128
+ canonical: bonsai.state.canonicalOf(stored.repo),
129
+ cloneUrl,
130
+ }),
131
+ ];
132
+ }
133
+
134
+ function known(bonsai: Bonsai, name: string): RepoSummary {
135
+ const summary = bonsai.state.repo(name);
136
+ if (summary === null)
137
+ throw new AppMethodRefusal("not_found", `No repository ${name}`);
138
+ return summary;
139
+ }
140
+
141
+ async function resolve(
142
+ store: GitStore,
143
+ repo: string,
144
+ ref: string,
145
+ ): Promise<Commit> {
146
+ const commit = HEX40.test(ref)
147
+ ? await store.commit(repo, ref)
148
+ : ((await store.log(repo, ref, 1))[0] ?? null);
149
+ if (commit === null)
150
+ throw new AppMethodRefusal("not_found", `Unknown ref ${ref}`);
151
+ return commit;
152
+ }
153
+
154
+ type TreeTarget = Readonly<{ hash: string; type: "tree" | "blob" }>;
155
+
156
+ async function treeAt(
157
+ store: GitStore,
158
+ repo: string,
159
+ commit: Commit,
160
+ path: string,
161
+ ): Promise<TreeTarget | null> {
162
+ let current: TreeTarget = { hash: commit.treeHash, type: "tree" };
163
+ for (const segment of path.split("/").filter(Boolean)) {
164
+ if (current.type !== "tree") return null;
165
+ const entries = await store.tree(repo, current.hash);
166
+ const entry = entries?.find((item) => item.name === segment);
167
+ if (!entry) return null;
168
+ if (entry.type === "tree") current = { hash: entry.hash, type: "tree" };
169
+ else if (entry.type === "blob")
170
+ current = { hash: entry.hash, type: "blob" };
171
+ else return null;
172
+ }
173
+ return current;
174
+ }
175
+
176
+ export function listRepos(bonsai: Bonsai): RepoSummary[] {
177
+ return bonsai.state.repos();
178
+ }
179
+
180
+ /** Repository placement choices come solely from the saved local catalog. */
181
+ export async function repositoryOptions(
182
+ bonsai: Bonsai,
183
+ input: PickerOptionsInput,
184
+ ): Promise<GillOptionsResponse> {
185
+ const rows = bonsai.state.repositoryOptions();
186
+ const snapshot = await copyListSnapshot(
187
+ rows.map((row) => [row.name, row.description]),
188
+ );
189
+ const scope = JSON.stringify(["repos.options"]);
190
+ const selected = sliceReadCopyPage(
191
+ rows,
192
+ input.limit,
193
+ readCopyPageOffset(input.after, scope, snapshot),
194
+ scope,
195
+ snapshot,
196
+ );
197
+ const result: GillOptionsResponse = {
198
+ options: selected.items.map((row) => {
199
+ const option: GillOptionsResponse["options"][number] = {
200
+ value: row.name,
201
+ label: row.name,
202
+ };
203
+ return row.description === null
204
+ ? option
205
+ : { ...option, detail: row.description };
206
+ }),
207
+ };
208
+ return decodeGillOptionsResponse(
209
+ selected.next === null ? result : { ...result, next: selected.next },
210
+ );
211
+ }
212
+
213
+ /** Branch choices never rebuild or bypass the durable saved-ref readiness gate. */
214
+ export async function branchOptions(
215
+ bonsai: Bonsai,
216
+ input: PickerOptionsInput,
217
+ ): Promise<GillOptionsResponse> {
218
+ const repo = input.params.repo;
219
+ if (!repo)
220
+ throw new AppMethodRefusal(
221
+ "invalid",
222
+ "Choose a repository before choosing a branch.",
223
+ );
224
+ const choices = () =>
225
+ readRepositoryRefs(bonsai, repo)
226
+ .refs.filter(
227
+ (ref) =>
228
+ ref.name.startsWith("refs/heads/") &&
229
+ ref.name.length - "refs/heads/".length <= 256,
230
+ )
231
+ .sort((a, b) => (a.name === b.name ? 0 : a.name < b.name ? -1 : 1));
232
+ const refs = choices();
233
+ const identity = (rows: typeof refs) =>
234
+ rows.map((row) => [row.name, row.hash]);
235
+ const identityJson = JSON.stringify(identity(refs));
236
+ const snapshot = await copyListSnapshot(identity(refs));
237
+ const scope = JSON.stringify(["branches.options", repo]);
238
+ const selected = sliceReadCopyPage(
239
+ refs,
240
+ input.limit,
241
+ readCopyPageOffset(input.after, scope, snapshot),
242
+ scope,
243
+ snapshot,
244
+ );
245
+ if (JSON.stringify(identity(choices())) !== identityJson)
246
+ throw new AppMethodRefusal(
247
+ "conflict",
248
+ "Branch options changed; restart from the first page.",
249
+ );
250
+ const result: GillOptionsResponse = {
251
+ options: selected.items.map((ref) => {
252
+ const name = ref.name.slice("refs/heads/".length);
253
+ return { value: name, label: name };
254
+ }),
255
+ };
256
+ return decodeGillOptionsResponse(
257
+ selected.next === null ? result : { ...result, next: selected.next },
258
+ );
259
+ }
260
+
261
+ export async function createRepo(
262
+ bonsai: Bonsai,
263
+ actor: BonsaiActor,
264
+ input: { name: string; description: string },
265
+ ): Promise<RepoSummary> {
266
+ if (actor.kind !== "human")
267
+ throw new AppMethodRefusal("forbidden", "Only people create repositories");
268
+ assertRepoName(input.name);
269
+ bonsai.state.assertRepoAvailable(input.name);
270
+ const revision = bonsai.state.repositoryRefStatus(input.name).revision;
271
+ const info = await bonsai.store.create(input.name, input.description);
272
+ return recordRepo(bonsai, actor, input, info.defaultBranch, revision);
273
+ }
274
+
275
+ /** A repository name Bonsai can hold: lowercase, digits, dots, dashes; no `--`. */
276
+ export function assertRepoName(name: string): void {
277
+ if (!repoNamePattern.test(name) || name.includes("--"))
278
+ throw new AppMethodRefusal(
279
+ "invalid",
280
+ "Use lowercase letters, digits, dots, dashes; no --",
281
+ );
282
+ }
283
+
284
+ /** Record a forked App's source repository, which the host already created
285
+ * in this installation's Git, as an ordinary Bonsai repository its owner
286
+ * edits and merges (each merge emits `change.merged`). */
287
+ export async function recordAppFork(
288
+ bonsai: Bonsai,
289
+ actor: BonsaiActor,
290
+ input: { name: string; description: string },
291
+ revision: number,
292
+ ): Promise<RepoSummary> {
293
+ const info = await bonsai.store.info(input.name);
294
+ if (info === null)
295
+ throw new AppMethodRefusal("unavailable", "The forked source is missing");
296
+ return recordRepo(bonsai, actor, input, info.defaultBranch, revision);
297
+ }
298
+
299
+ /** The repository-availability revision a later record must still match. */
300
+ export function repoRevision(bonsai: Bonsai, name: string): number {
301
+ bonsai.state.assertRepoAvailable(name);
302
+ return bonsai.state.repositoryRefStatus(name).revision;
303
+ }
304
+
305
+ async function recordRepo(
306
+ bonsai: Bonsai,
307
+ actor: BonsaiActor,
308
+ input: { name: string; description: string },
309
+ defaultBranch: string,
310
+ revision: number,
311
+ ): Promise<RepoSummary> {
312
+ const refs = await bonsai.store.refs(input.name);
313
+ const cloneUrl = await bonsai.store.cloneUrl(input.name);
314
+ await publish(
315
+ bonsai,
316
+ {
317
+ at: Date.now(),
318
+ type: "repo.created",
319
+ repo: input.name,
320
+ actor: actor.name,
321
+ data: {},
322
+ },
323
+ () => {
324
+ bonsai.state.assertRepoAvailable(input.name, revision);
325
+ bonsai.state.registerRepo({
326
+ name: input.name,
327
+ description: input.description || null,
328
+ parent: null,
329
+ owner: null,
330
+ });
331
+ bonsai.state.seeActor(actor.name, actor.kind);
332
+ bonsai.state.publishRepositoryRefs(
333
+ input.name,
334
+ revision,
335
+ refs.find((ref) => ref.name === `refs/heads/${defaultBranch}`)?.hash ??
336
+ null,
337
+ {
338
+ defaultBranch,
339
+ refs: refs.filter((ref) => ref.name !== "HEAD"),
340
+ cloneUrl,
341
+ empty: refs.length === 0,
342
+ },
343
+ );
344
+ },
345
+ );
346
+ return known(bonsai, input.name);
347
+ }
348
+
349
+ const deletions = new WeakMap<
350
+ BonsaiState,
351
+ Map<string, Promise<{ deleted: true }>>
352
+ >();
353
+
354
+ /** Withdraw local metadata, then retry provider cleanup under the same owner. */
355
+ export async function deleteRepo(
356
+ bonsai: Bonsai,
357
+ caller: AppMethodCallContext,
358
+ name: string,
359
+ ): Promise<{ deleted: true }> {
360
+ if (caller.actor.kind !== "member" || caller.role !== "owner")
361
+ throw new AppMethodRefusal(
362
+ "forbidden",
363
+ "Only the owner can delete repositories",
364
+ );
365
+ const active =
366
+ deletions.get(bonsai.state) ??
367
+ new Map<string, Promise<{ deleted: true }>>();
368
+ deletions.set(bonsai.state, active);
369
+ const existing = active.get(name);
370
+ if (existing) return existing;
371
+ const cleanup = async (): Promise<{ deleted: true }> => {
372
+ try {
373
+ const current = bonsai.state.repo(name);
374
+ if ((current?.forks.length ?? 0) > 0)
375
+ throw new AppMethodRefusal(
376
+ "conflict",
377
+ "Delete the repository's forks first",
378
+ );
379
+ if (current !== null) invalidateGraphCommitCopies(bonsai, name);
380
+ invalidateFilesCopies(bonsai, name);
381
+ bonsai.state.removeRepo(name);
382
+ await bonsai.store.binding.delete(name);
383
+ bonsai.state.completeRepoDeletion(name);
384
+ return { deleted: true };
385
+ } finally {
386
+ active.delete(name);
387
+ }
388
+ };
389
+ const pending = Promise.resolve().then(cleanup);
390
+ active.set(name, pending);
391
+ return pending;
392
+ }
393
+
394
+ /** Give an actor its own fork of a canonical repository (idempotent). */
395
+ export async function forkFor(
396
+ bonsai: Bonsai,
397
+ actor: BonsaiActor,
398
+ name: string,
399
+ ): Promise<RepoSummary> {
400
+ const source = known(bonsai, name);
401
+ if (source.parent !== null)
402
+ throw new AppMethodRefusal("invalid", "Fork the canonical repository");
403
+ const forkName = `${source.name}--${actor.name}`;
404
+ bonsai.state.assertRepoAvailable(forkName);
405
+ const revision = bonsai.state.repositoryRefStatus(forkName).revision;
406
+ const sourceRevision = bonsai.state.repositoryRefStatus(source.name).revision;
407
+ const existing = bonsai.state.repo(forkName);
408
+ if (existing !== null) return existing;
409
+ // The store returns an existing destination (an earlier attempt may have
410
+ // created it before failing); adopt it only when it is a fork of source.
411
+ const info = await bonsai.store.fork(
412
+ source.name,
413
+ forkName,
414
+ `${actor.name}'s fork of ${source.name}`,
415
+ );
416
+ if (info.source !== source.name)
417
+ throw new AppMethodRefusal(
418
+ "conflict",
419
+ `A repository named ${forkName} exists and is not a fork of ${source.name}`,
420
+ );
421
+ const refs = await bonsai.store.refs(forkName);
422
+ const cloneUrl = await bonsai.store.cloneUrl(forkName);
423
+ await publish(
424
+ bonsai,
425
+ {
426
+ at: Date.now(),
427
+ type: "fork",
428
+ repo: source.name,
429
+ actor: actor.name,
430
+ data: { fork: forkName },
431
+ },
432
+ () => {
433
+ bonsai.state.assertRepoAvailable(forkName, revision);
434
+ bonsai.state.assertRepoAvailable(source.name, sourceRevision);
435
+ known(bonsai, source.name);
436
+ bonsai.state.registerRepo({
437
+ name: forkName,
438
+ description: null,
439
+ parent: source.name,
440
+ owner: actor.name,
441
+ });
442
+ bonsai.state.seeActor(actor.name, actor.kind);
443
+ bonsai.state.publishRepositoryRefs(
444
+ forkName,
445
+ revision,
446
+ refs.find((ref) => ref.name === `refs/heads/${info.defaultBranch}`)
447
+ ?.hash ?? null,
448
+ {
449
+ defaultBranch: info.defaultBranch,
450
+ refs: refs.filter((ref) => ref.name !== "HEAD"),
451
+ cloneUrl,
452
+ empty: refs.length === 0,
453
+ },
454
+ );
455
+ },
456
+ );
457
+ return known(bonsai, forkName);
458
+ }
459
+
460
+ export async function listTree(
461
+ bonsai: Bonsai,
462
+ input: { repo: string; ref: string; path: string },
463
+ ): Promise<TreeListing> {
464
+ return readCopyFrame(bonsai.store, (store) =>
465
+ listTreeCopy({ ...bonsai, store }, input),
466
+ );
467
+ }
468
+
469
+ export async function listFiles(
470
+ bonsai: Bonsai,
471
+ input: { repo: string; ref: string },
472
+ ): Promise<FileList> {
473
+ return readCopyFrame(bonsai.store, (store) =>
474
+ listFilesCopy({ ...bonsai, store }, input),
475
+ );
476
+ }
477
+
478
+ export async function readFile(
479
+ bonsai: Bonsai,
480
+ input: { repo: string; ref: string; path: string },
481
+ ): Promise<FileContent> {
482
+ return readCopyFrame(bonsai.store, (store) =>
483
+ readFileCopy({ ...bonsai, store }, input),
484
+ );
485
+ }
486
+
487
+ export async function readIssueFiles(
488
+ bonsai: Bonsai,
489
+ input: { repo: string; ref: string },
490
+ ): Promise<IssueFiles> {
491
+ const repo = known(bonsai, input.repo).name;
492
+ const store = bonsai.store;
493
+ const commit = await resolve(store, repo, input.ref);
494
+ const root = await treeAt(store, repo, commit, "issues");
495
+ const files: Record<string, string> = {};
496
+ let truncated = false;
497
+ if (root?.type === "tree") {
498
+ const listing = await listPaths(store, repo, root.hash);
499
+ const markdown = listing.paths.filter((path) => path.endsWith(".md"));
500
+ truncated = listing.truncated || markdown.length > MAX_ISSUE_FILES;
501
+ for (const path of markdown.slice(0, MAX_ISSUE_FILES)) {
502
+ const target = await treeAt(store, repo, commit, `issues/${path}`);
503
+ const bytes =
504
+ target?.type === "blob" ? await store.blob(repo, target.hash) : null;
505
+ const text =
506
+ bytes === null || bytes.length > MAX_ISSUE_BYTES
507
+ ? null
508
+ : decodeText(bytes);
509
+ if (text?.kind === "text") files[path] = text.text;
510
+ }
511
+ }
512
+ return { ref: input.ref, commit, files, truncated };
513
+ }
514
+
515
+ export async function listCommits(
516
+ bonsai: Bonsai,
517
+ input: { repo: string; ref: string } & ReadCopyPageInput,
518
+ ): Promise<CommitPage> {
519
+ return readCopyFrame(bonsai.store, (store) =>
520
+ readCommits({ ...bonsai, store }, input),
521
+ );
522
+ }
523
+
524
+ export async function getCommitDetail(
525
+ bonsai: Bonsai,
526
+ input: { repo: string; hash: string },
527
+ ): Promise<CommitDetail> {
528
+ return readCopyFrame(bonsai.store, (store) =>
529
+ readCommitDetail({ ...bonsai, store }, input),
530
+ );
531
+ }
532
+
533
+ export async function buildGraph(bonsai: Bonsai, name: string): Promise<Graph> {
534
+ return readCopyFrame(bonsai.store, (store) =>
535
+ readGraph({ ...bonsai, store }, name),
536
+ );
537
+ }
538
+
539
+ export function listActivity(
540
+ bonsai: Bonsai,
541
+ input: { repo: string | null; limit: number; after: number },
542
+ ): Activity[] {
543
+ return bonsai.state.events(input.repo, input.limit, input.after);
544
+ }
545
+
546
+ /** Stable host principal attribution; display names never determine ownership. */
547
+ function pushActor(writer: AppGitPushFact["writer"]): BonsaiActor {
548
+ if (writer.kind === "installation" && writer.onBehalfOf === null)
549
+ return { name: writer.installationId, kind: "agent" };
550
+ const actor = writer.kind === "actor" ? writer.actor : writer.onBehalfOf;
551
+ if (actor === null)
552
+ throw new AppMethodRefusal("invalid", "Push caller unavailable");
553
+ switch (actor.kind) {
554
+ case "member":
555
+ return { name: actor.memberId, kind: "human" };
556
+ case "agent":
557
+ return { name: actor.agentId, kind: "agent" };
558
+ case "team-key":
559
+ return { name: actor.keyId, kind: "agent" };
560
+ }
561
+ }
562
+
563
+ /**
564
+ * A push the host accepted for one of Bonsai's repositories. Records which
565
+ * commits are new work (commits Bonsai already saw elsewhere, such as a fork
566
+ * catching up with main, are not), the paths touched, and emits git.push.
567
+ */
568
+ export async function observePush(
569
+ bonsai: Bonsai,
570
+ fact: AppGitPushFact,
571
+ ): Promise<void> {
572
+ await readCopyFrame(bonsai.store, (store) => {
573
+ const runtime = { ...bonsai, store };
574
+ return observePushFrame(
575
+ { ...runtime, store: projectionStore(runtime) },
576
+ fact,
577
+ );
578
+ });
579
+ await drainOutbox(bonsai);
580
+ }
581
+
582
+ /** A new branch's paths are the ones it changes from the repository's
583
+ * default head, not its whole tree; the default branch itself, or a first
584
+ * push, has no base. The head is read once per push, however many branches
585
+ * it creates. */
586
+ function defaultBase(bonsai: Bonsai, repo: string) {
587
+ let base:
588
+ | Promise<Readonly<{ branch: string; commit: Commit | null }> | null>
589
+ | undefined;
590
+ const read = async () => {
591
+ const info = await bonsai.store.info(repo);
592
+ if (info === null) return null;
593
+ const head = (await bonsai.store.refs(repo)).find(
594
+ (ref) => ref.name === `refs/heads/${info.defaultBranch}`,
595
+ )?.hash;
596
+ return {
597
+ branch: `refs/heads/${info.defaultBranch}`,
598
+ commit: head === undefined ? null : await bonsai.store.commit(repo, head),
599
+ };
600
+ };
601
+ return async (ref: string) => {
602
+ base ??= read();
603
+ const resolved = await base;
604
+ return resolved === null || resolved.branch === ref
605
+ ? null
606
+ : resolved.commit;
607
+ };
608
+ }
609
+
610
+ async function observePushFrame(
611
+ bonsai: Bonsai,
612
+ raw: AppGitPushFact,
613
+ ): Promise<void> {
614
+ const fact = appGitPushFactSchema.parse(raw);
615
+ const key = `push:${fact.eventId}`;
616
+ const digest = JSON.stringify(fact);
617
+ if (bonsai.state.receipt(key, digest, recordedResult) !== null) return;
618
+ const repo = known(bonsai, fact.repository).name;
619
+ const refsRevision = bonsai.state.invalidateRepositoryRefs(repo);
620
+ invalidateGraphCommitCopies(bonsai, repo);
621
+ invalidateFilesCopies(bonsai, repo);
622
+ const actor = pushActor(fact.writer);
623
+ const commits: { hash: string; message: string }[] = [];
624
+ const commitsByRef = new Map<string, string[]>();
625
+ const paths = new Set<string>();
626
+ const branchBase = defaultBase(bonsai, repo);
627
+ for (const update of fact.updates) {
628
+ if (update.after === ZERO) continue;
629
+ const history = await bonsai.store.log(repo, update.after, 50);
630
+ const branchCommits: string[] = [];
631
+ commitsByRef.set(update.ref, branchCommits);
632
+ for (const commit of history) {
633
+ if (commit.hash === update.before) break;
634
+ branchCommits.push(commit.hash);
635
+ commits.push({
636
+ hash: commit.hash,
637
+ message: commit.message.split("\n")[0] ?? "",
638
+ });
639
+ }
640
+ const before =
641
+ update.before === ZERO
642
+ ? await branchBase(update.ref)
643
+ : await bonsai.store.commit(repo, update.before);
644
+ const after = history[0];
645
+ if (after)
646
+ for (const changed of await changedPaths(
647
+ bonsai.store,
648
+ repo,
649
+ before?.treeHash ?? null,
650
+ after.treeHash,
651
+ ))
652
+ paths.add(changed.path);
653
+ }
654
+ const cloneUrl = await bonsai.store.cloneUrl(repo);
655
+ const canonical = bonsai.state.canonicalOf(repo);
656
+ const summary = known(bonsai, canonical);
657
+ const cloneUrls = new Map(
658
+ await Promise.all(
659
+ [canonical, ...summary.forks].map(
660
+ async (name) => [name, await bonsai.store.cloneUrl(name)] as const,
661
+ ),
662
+ ),
663
+ );
664
+ const targetHistory = new Map<string, ReadonlySet<string>>();
665
+ if (repo === canonical)
666
+ for (const update of fact.updates) {
667
+ if (update.ref.startsWith("refs/heads/") && update.after !== ZERO)
668
+ targetHistory.set(
669
+ update.ref,
670
+ await reachableCommits(bonsai.store, repo, update.after),
671
+ );
672
+ }
673
+ const info = await bonsai.store.info(canonical);
674
+ if (info === null)
675
+ throw new AppMethodRefusal("not_found", "The target repository is missing");
676
+ const defaultTarget =
677
+ (await bonsai.store.refs(canonical)).find(
678
+ (ref) => ref.name === `refs/heads/${info.defaultBranch}`,
679
+ )?.hash ?? ZERO;
680
+ const repoInfo = repo === canonical ? info : await bonsai.store.info(repo);
681
+ if (repoInfo === null)
682
+ throw new AppMethodRefusal("not_found", "Repository storage is missing");
683
+ const repoRefs = await bonsai.store.refs(repo);
684
+ const beforeCopies = new Set(
685
+ bonsai.state
686
+ .changes(canonical)
687
+ .map((row) =>
688
+ JSON.stringify([row.repo, row.sourceRepo, row.head, row.target]),
689
+ ),
690
+ );
691
+ bonsai.state.transition(key, digest, recordedResult, () => {
692
+ if (bonsai.state.repositoryRefStatus(repo).revision !== refsRevision)
693
+ throw new AppMethodRefusal(
694
+ "unavailable",
695
+ "Repository refs changed during push preparation.",
696
+ );
697
+ const metadata = {
698
+ defaultBranch: repoInfo.defaultBranch,
699
+ refs: repoRefs.filter((ref) => ref.name !== "HEAD"),
700
+ cloneUrl,
701
+ empty: repoRefs.length === 0,
702
+ };
703
+ const defaultUpdate = fact.updates.find(
704
+ (update) => update.ref === `refs/heads/${repoInfo.defaultBranch}`,
705
+ );
706
+ if (defaultUpdate !== undefined)
707
+ bonsai.state.repositoryCopies.apply(repo, {
708
+ ...defaultUpdate,
709
+ value: metadata,
710
+ });
711
+ else
712
+ bonsai.state.repositoryCopies.write(
713
+ repo,
714
+ repoRefs.find(
715
+ (ref) => ref.name === `refs/heads/${repoInfo.defaultBranch}`,
716
+ )?.hash ?? null,
717
+ metadata,
718
+ );
719
+ const ready = bonsai.state.repositoryCopies.read(repo);
720
+ if (ready.kind === "ready")
721
+ bonsai.state.publishRepositoryRefs(
722
+ repo,
723
+ refsRevision,
724
+ ready.head,
725
+ metadata,
726
+ );
727
+ const seen = bonsai.state.origins(commits.map((commit) => commit.hash));
728
+ bonsai.state.seeActor(actor.name, actor.kind);
729
+ const newCommits = [
730
+ ...new Map(
731
+ commits
732
+ .filter((commit) => !seen.has(commit.hash))
733
+ .map((commit) => [commit.hash, commit]),
734
+ ).values(),
735
+ ];
736
+ const events = activityEvents(
737
+ bonsai,
738
+ bonsai.state.record({
739
+ at: Date.now(),
740
+ type: "push",
741
+ repo,
742
+ actor: actor.name,
743
+ data: {
744
+ updates: [...fact.updates],
745
+ commits: newCommits,
746
+ paths: [...paths].slice(0, 200),
747
+ },
748
+ }),
749
+ cloneUrl,
750
+ );
751
+ events.push(
752
+ ...reconcilePush(bonsai, {
753
+ repo,
754
+ canonical,
755
+ actor,
756
+ updates: fact.updates,
757
+ newCommits,
758
+ commitsByRef,
759
+ cloneUrls,
760
+ targetHistory,
761
+ defaultBranch: info.defaultBranch,
762
+ defaultTarget,
763
+ }),
764
+ );
765
+ return { result: true, events };
766
+ });
767
+ for (const row of bonsai.state.changes(canonical)) {
768
+ if (
769
+ !beforeCopies.has(
770
+ JSON.stringify([row.repo, row.sourceRepo, row.head, row.target]),
771
+ )
772
+ ) {
773
+ try {
774
+ await refreshChangeCopy(bonsai, row);
775
+ } catch (error) {
776
+ if (!(error instanceof ReadCopyBudgetExceeded)) throw error;
777
+ bonsai.state.changeCopies.stale(
778
+ JSON.stringify([row.repo, row.sourceRepo, row.head, row.target]),
779
+ );
780
+ }
781
+ }
782
+ }
783
+ if (!bonsai.state.repositoryRefStatus(repo).pending) {
784
+ try {
785
+ await refreshGraphCommitCopies(bonsai, repo);
786
+ for (const request of filesCopyRequests(bonsai, repo)) {
787
+ try {
788
+ await refreshFilesCopy(bonsai, request);
789
+ } catch (error) {
790
+ if (!(
791
+ error instanceof AppMethodRefusal && error.code === "not_found"
792
+ ))
793
+ throw error;
794
+ }
795
+ }
796
+ } catch (error) {
797
+ if (
798
+ !(error instanceof ReadCopyBudgetExceeded) &&
799
+ !(error instanceof AppMethodRefusal && error.code === "unavailable")
800
+ )
801
+ throw error;
802
+ }
803
+ }
804
+ }
805
+
806
+ export function getCodeInsights(
807
+ bonsai: Bonsai,
808
+ input: { repo: string; ref: string },
809
+ ) {
810
+ known(bonsai, input.repo);
811
+ return readCodeInsights(bonsai.store, input.repo, input.ref);
812
+ }
813
+
814
+ /** One selected foreground key shares a single frame and resumes after exhaustion. */
815
+ export async function rebuildCopies(bonsai: Bonsai, cursor: string | null) {
816
+ const repos = bonsai.state
817
+ .repos()
818
+ .flatMap((repo) => [repo.name, ...repo.forks]);
819
+ const pending = repos.find(
820
+ (repo) =>
821
+ bonsai.state.repositoryRefStatus(repo).pending ||
822
+ bonsai.state.repositoryCopies.read(repo).kind !== "ready",
823
+ );
824
+ const items = [
825
+ ...repos.map((repo) => ({
826
+ key: `0:repo:${repo}`,
827
+ run: async (runtime: Bonsai, signal: AbortSignal) => {
828
+ const detail = await refreshRepositoryCopy(runtime, repo, signal);
829
+ for (const ref of detail.refs)
830
+ if (ref.name.startsWith("refs/heads/"))
831
+ await reachableCommits(runtime.store, repo, ref.hash);
832
+ return true;
833
+ },
834
+ })),
835
+ ...bonsai.state.changes(null).map((row) => ({
836
+ key: `1:change:${row.repo}:${String(row.number).padStart(10, "0")}`,
837
+ run: async (runtime: Bonsai) => {
838
+ await refreshChangeCopy(runtime, row);
839
+ return true;
840
+ },
841
+ })),
842
+ ...(pending === undefined
843
+ ? bonsai.state.repos().flatMap((repo) =>
844
+ graphCommitCopyKeys(bonsai, repo.name).map((key) => ({
845
+ key: `2:graph:${key}`,
846
+ run: (runtime: Bonsai) => rebuildGraphCommitCopy(runtime, key),
847
+ })),
848
+ )
849
+ : []),
850
+ ...repos.flatMap((repo) =>
851
+ filesCopyRequests(bonsai, repo).map((request) => ({
852
+ key: `3:files:${JSON.stringify(request)}`,
853
+ run: async (runtime: Bonsai) => {
854
+ try {
855
+ await refreshFilesCopy(runtime, request);
856
+ } catch (error) {
857
+ if (!(
858
+ error instanceof AppMethodRefusal && error.code === "not_found"
859
+ ))
860
+ throw error;
861
+ }
862
+ return true;
863
+ },
864
+ })),
865
+ ),
866
+ ].sort((a, b) => a.key.localeCompare(b.key));
867
+ const start = pending === undefined ? cursor : `0:repo:${pending}`;
868
+ const index =
869
+ start === null
870
+ ? 0
871
+ : items.findIndex((item) => item.key.localeCompare(start) >= 0);
872
+ const item = items[index];
873
+ if (item === undefined) return { done: true, cursor: null };
874
+ try {
875
+ const complete = await readCopyFrame(bonsai.store, (store, signal) => {
876
+ const runtime = { ...bonsai, store };
877
+ return item.run({ ...runtime, store: projectionStore(runtime) }, signal);
878
+ });
879
+ if (!complete) return { done: false, cursor: item.key };
880
+ } catch (error) {
881
+ if (
882
+ error instanceof ReadCopyBudgetExceeded ||
883
+ (error instanceof AppMethodRefusal && error.code === "unavailable")
884
+ )
885
+ return { done: false, cursor: item.key };
886
+ throw error;
887
+ }
888
+ // Re-enumerate after metadata pages so newly discovered refs enter the later phase.
889
+ if (pending !== undefined) return { done: false, cursor: item.key };
890
+ const next = items[index + 1];
891
+ return { done: next === undefined, cursor: next?.key ?? null };
892
+ }