@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,848 @@
1
+ import { resultRows } from "@fungi.computer/agar/method-presentation";
2
+ import { openChangesGill, recentCommitsGill } from "./gills.js";
3
+ import {
4
+ pickerOptionsInput,
5
+ pickerOptionsPage,
6
+ } from "../shared/picker-options.js";
7
+ import { getRepoDetail } from "./repository-copy.js";
8
+ import { ReadCopyBudgetExceeded } from "@fungi.computer/agar/read-copy";
9
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
10
+ import {
11
+ backgroundRuntime,
12
+ registerBackgroundProjector,
13
+ syncBackground,
14
+ } from "./background.js";
15
+ import {
16
+ assertOrdinaryFileMember,
17
+ saveFileEdit,
18
+ syncFileEdits,
19
+ projectQueuedFileEdit,
20
+ } from "./file-edits.js";
21
+ import {
22
+ actorLane,
23
+ mayEnqueueBackground,
24
+ type BackgroundBinding,
25
+ } from "@fungi.computer/puffball/background-replay";
26
+ /**
27
+ * Bonsai's App methods: the one API for Agents, the host and Bonsai's own UI.
28
+ * Runtime-neutral: no Cloudflare imports here (see agar's backend rules).
29
+ */
30
+ import {
31
+ defineAppBackend,
32
+ defineAppMethod,
33
+ pending,
34
+ type AppBackendMethod,
35
+ } from "@fungi.computer/agar/backend-definition";
36
+ import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
37
+ import * as z from "zod";
38
+ import {
39
+ gitSha1Schema,
40
+ gitRepositoryNameSchema,
41
+ } from "@fungi.computer/puffball/git-representation";
42
+ import {
43
+ activityPage,
44
+ appBuildView,
45
+ approvalResult,
46
+ checkRequestResult,
47
+ checkReportResult,
48
+ mergeResult,
49
+ changeRecord,
50
+ readCopyPageInput,
51
+ policyDecision,
52
+ agentPage,
53
+ changeDetail,
54
+ changePage,
55
+ commitDetail,
56
+ graph,
57
+ issueFiles,
58
+ repoDetail,
59
+ repoPage,
60
+ repoSummary,
61
+ } from "../shared/contract.js";
62
+ import {
63
+ readCopyRebuilding,
64
+ copyRebuildPage,
65
+ commitPage,
66
+ fileContent,
67
+ fileSaveInput,
68
+ fileSaveResult,
69
+ fileSyncResult,
70
+ fileDiscardInput,
71
+ fileDiscardResult,
72
+ fileList,
73
+ treeListing,
74
+ } from "@fungi.computer/agar/roles/files";
75
+ import { hookEventFor } from "../shared/hooks.js";
76
+ import { repositoryDeletion } from "../shared/repository-deletion.js";
77
+ import {
78
+ codeInsights,
79
+ insightLayout,
80
+ savedLayout,
81
+ } from "../shared/insights.js";
82
+ import * as changes from "./changes.js";
83
+ import * as reviews from "./reviews.js";
84
+ import { mergeChange, queueMergeChange } from "./merge.js";
85
+ import { drainOutbox } from "./state.js";
86
+ import * as service from "./service.js";
87
+ import { callerActor } from "./policy.js";
88
+ import type { Bonsai } from "./service.js";
89
+
90
+ /** The closed result `APPS.fork` answers with. */
91
+ export type AppSourceForkBindingResult =
92
+ | Readonly<{ kind: "forked"; appId: string; repository: string }>
93
+ | Readonly<{
94
+ kind: "unauthorized" | "conflict" | "provider_unavailable";
95
+ }>;
96
+
97
+ /** A host binding call's wire reply: the owner route's status and body, never a key. */
98
+ export type AppsBindingReply = Readonly<{
99
+ status: number;
100
+ body: z.core.util.JSONType;
101
+ }>;
102
+
103
+ /** The host `APPS` binding: fork a source, build it, and install a Release. */
104
+ export type BonsaiAppsBinding = Readonly<{
105
+ fork(input: {
106
+ platformAppId: string;
107
+ name: string;
108
+ }): Promise<AppSourceForkBindingResult>;
109
+ build(input: {
110
+ appId: string;
111
+ sourceRevision: string;
112
+ }): Promise<AppsBindingReply>;
113
+ buildStatus(input: { jobId: string }): Promise<AppsBindingReply>;
114
+ select(input: {
115
+ installationId: string;
116
+ release: unknown;
117
+ }): Promise<AppsBindingReply>;
118
+ }>;
119
+
120
+ /** What a Bonsai method runs against: the facet supplies it. */
121
+ type BonsaiMethodContext = Readonly<{
122
+ bonsai: Bonsai;
123
+ env?: Readonly<{
124
+ BACKGROUND?: BackgroundBinding;
125
+ APPS?: BonsaiAppsBinding;
126
+ }>;
127
+ }>;
128
+
129
+ const bindingErrorBody = z.object({ error: z.string() });
130
+
131
+ /** Translate a refused owner-route reply into the method's typed refusal. */
132
+ function refuseBindingReply(reply: AppsBindingReply): never {
133
+ const parsed = bindingErrorBody.safeParse(reply.body);
134
+ const message = parsed.success ? parsed.data.error : "Request refused";
135
+ throw new AppMethodRefusal(
136
+ reply.status === 404
137
+ ? "not_found"
138
+ : reply.status === 409
139
+ ? "conflict"
140
+ : reply.status === 403
141
+ ? "forbidden"
142
+ : "invalid",
143
+ message,
144
+ );
145
+ }
146
+
147
+ /** Install the file projection for fresh replay contexts as well as enqueue frames. */
148
+ function fileBackgroundRuntime(bonsai: Bonsai, caller: AppMethodCallContext) {
149
+ assertOrdinaryFileMember(caller);
150
+ registerBackgroundProjector(bonsai, "file-edit", (transition) => {
151
+ projectQueuedFileEdit(bonsai, transition);
152
+ });
153
+ return backgroundRuntime(bonsai, caller);
154
+ }
155
+
156
+ async function readCopyResponse<T>(
157
+ read: () => Promise<T>,
158
+ ): Promise<T | { kind: "rebuilding" }> {
159
+ try {
160
+ return await read();
161
+ } catch (error) {
162
+ if (
163
+ error instanceof ReadCopyBudgetExceeded ||
164
+ (error instanceof AppMethodRefusal && error.code === "unavailable")
165
+ )
166
+ return { kind: "rebuilding" };
167
+ throw error;
168
+ }
169
+ }
170
+ const repo = z.string().regex(/^[a-zA-Z0-9._-]{1,128}$/u);
171
+ const ref = z.string().min(1).max(256).default("main");
172
+ const path = z.string().max(4096).default("");
173
+
174
+ const definition = defineAppBackend<BonsaiMethodContext>({
175
+ events: {
176
+ "git.push": {
177
+ description: "A repository accepted a push.",
178
+ payload: hookEventFor("git.push"),
179
+ },
180
+ "git.fork": {
181
+ description: "A repository was forked.",
182
+ payload: hookEventFor("git.fork"),
183
+ },
184
+ "change.opened": {
185
+ description:
186
+ "A fork received its first push since it was created or merged.",
187
+ payload: hookEventFor("change.opened"),
188
+ },
189
+ "change.updated": {
190
+ description: "The proposed source head changed.",
191
+ payload: hookEventFor("change.updated"),
192
+ },
193
+ "change.approved": {
194
+ description: "A person approved the proposed head.",
195
+ payload: hookEventFor("change.approved"),
196
+ },
197
+ "change.checks_requested": {
198
+ description: "Checks were requested at the proposed head.",
199
+ payload: hookEventFor("change.checks_requested"),
200
+ },
201
+ "change.checked": {
202
+ description: "A check finished at the proposed head.",
203
+ payload: hookEventFor("change.checked"),
204
+ },
205
+ "change.closed": {
206
+ description: "A Change was closed.",
207
+ payload: hookEventFor("change.closed"),
208
+ },
209
+ "change.merged": {
210
+ description: "A fork was merged into its canonical repository.",
211
+ payload: hookEventFor("change.merged"),
212
+ },
213
+ "change.conflicted": {
214
+ description: "A merge found conflicting paths.",
215
+ payload: hookEventFor("change.conflicted"),
216
+ },
217
+ "repo.created": {
218
+ description: "A canonical repository was created.",
219
+ payload: hookEventFor("repo.created"),
220
+ },
221
+ },
222
+ methods: {
223
+ "apps.build": defineAppMethod({
224
+ description:
225
+ "Build an App's registered source at one exact commit on this Oyster's Computer; the job keeps running after this call returns. Owner only.",
226
+ effect: "write",
227
+ // The host scopes APPS.build to a method that declares the operation's
228
+ // own requirements; the owner check below still gates every call.
229
+ requires: ["git.repository", "computer.execute"],
230
+ input: z.strictObject({
231
+ appId: z.string().min(1).max(256),
232
+ sourceRevision: z.string().min(1).max(256),
233
+ }),
234
+ output: appBuildView,
235
+ handler: async (input, context: BonsaiMethodContext, caller) => {
236
+ if (caller.role !== "owner")
237
+ throw new AppMethodRefusal("forbidden", "Owner only");
238
+ if (!context.env?.APPS)
239
+ throw new AppMethodRefusal("unavailable", "Build is unavailable");
240
+ const reply = await context.env.APPS.build(input);
241
+ if (reply.status >= 400) refuseBindingReply(reply);
242
+ return appBuildView.parse(reply.body);
243
+ },
244
+ }),
245
+ "apps.buildStatus": defineAppMethod({
246
+ description:
247
+ "Wait for one build job: Pending while it runs, then its settled status and Release. Owner only.",
248
+ effect: "read",
249
+ // The host grants its APPS operation only under these approvals.
250
+ requires: ["git.repository", "computer.execute"],
251
+ // A Workflow step waits on the job by polling this same call.
252
+ pending: true,
253
+ input: z.strictObject({ jobId: z.string().min(1).max(256) }),
254
+ output: appBuildView,
255
+ handler: async (input, context: BonsaiMethodContext, caller) => {
256
+ if (caller.role !== "owner")
257
+ throw new AppMethodRefusal("forbidden", "Owner only");
258
+ if (!context.env?.APPS)
259
+ throw new AppMethodRefusal("unavailable", "Build is unavailable");
260
+ const reply = await context.env.APPS.buildStatus(input);
261
+ if (reply.status >= 400) refuseBindingReply(reply);
262
+ const build = appBuildView.parse(reply.body);
263
+ return build.status === "running" ? pending(5_000) : build;
264
+ },
265
+ }),
266
+ "apps.install": defineAppMethod({
267
+ description:
268
+ "Install a built Release at an installation id, or switch an already-live one onto it. Owner only.",
269
+ effect: "write",
270
+ // The host grants its APPS operation only under these approvals.
271
+ requires: ["git.repository", "computer.execute"],
272
+ input: z.strictObject({
273
+ installationId: z.string().min(1).max(256),
274
+ release: z.unknown(),
275
+ }),
276
+ output: z.unknown(),
277
+ handler: async (input, context: BonsaiMethodContext, caller) => {
278
+ if (caller.role !== "owner")
279
+ throw new AppMethodRefusal("forbidden", "Owner only");
280
+ if (!context.env?.APPS)
281
+ throw new AppMethodRefusal("unavailable", "Install is unavailable");
282
+ const reply = await context.env.APPS.select(input);
283
+ if (reply.status >= 400) refuseBindingReply(reply);
284
+ return reply.body;
285
+ },
286
+ }),
287
+ "insights.code": defineAppMethod({
288
+ description:
289
+ "Sample current text files and their first-parent line history (up to 40 commits, 80 files).",
290
+ effect: "read",
291
+ requires: ["git.repository"],
292
+ input: z.strictObject({ repo, ref }),
293
+ output: codeInsights,
294
+ handler: (input, context) =>
295
+ service.getCodeInsights(context.bonsai, input),
296
+ }),
297
+ "insights.layout.get": defineAppMethod({
298
+ description: "Read the caller's repository insight layout.",
299
+ effect: "read",
300
+ requires: [],
301
+ input: z.strictObject({ repo }),
302
+ output: savedLayout,
303
+ handler: (input, context, caller) => ({
304
+ tiles: context.bonsai.state.layout(
305
+ JSON.stringify(caller.actor),
306
+ input.repo,
307
+ ),
308
+ }),
309
+ }),
310
+ "insights.layout.save": defineAppMethod({
311
+ description: "Save the caller's repository insight layout.",
312
+ effect: "write",
313
+ requires: [],
314
+ input: z.strictObject({ repo, tiles: insightLayout }),
315
+ output: savedLayout,
316
+ handler: (input, context, caller) => {
317
+ context.bonsai.state.saveLayout(
318
+ JSON.stringify(caller.actor),
319
+ input.repo,
320
+ input.tiles,
321
+ );
322
+ return { tiles: input.tiles };
323
+ },
324
+ }),
325
+ rebuildCopies: defineAppMethod({
326
+ description:
327
+ "Rebuild one page of local repository, Change, Graph, Commit and Files copies, within this frame's provider budget.",
328
+ effect: "read",
329
+ // Each page reads Git; the host scopes GIT to methods that declare it.
330
+ requires: ["git.repository"],
331
+ input: z.strictObject({ cursor: z.string().nullable().default(null) }),
332
+ output: copyRebuildPage,
333
+ handler: (input, context) =>
334
+ service.rebuildCopies(context.bonsai, input.cursor),
335
+ }),
336
+ "repos.list": defineAppMethod({
337
+ description:
338
+ "List canonical repositories with their forks and recent push activity.",
339
+ effect: "read",
340
+ requires: [],
341
+ input: z.strictObject({}),
342
+ output: repoPage,
343
+ present: {
344
+ rows: resultRows,
345
+ verb: "listed",
346
+ row: ({ output }) => ({
347
+ id: "repos.list",
348
+ title: `Bonsai listed ${String(output.repos.length)} ${output.repos.length === 1 ? "repository" : "repositories"}`,
349
+ detail: output.repos
350
+ .slice(0, 5)
351
+ .map((repo) => repo.name)
352
+ .join(" · "),
353
+ }),
354
+ },
355
+ handler: (_input, context) => ({
356
+ repos: service.listRepos(context.bonsai),
357
+ }),
358
+ }),
359
+ "repos.options": defineAppMethod({
360
+ description: "Choose a repository from the saved canonical catalog.",
361
+ effect: "read",
362
+ requires: [],
363
+ input: pickerOptionsInput,
364
+ output: pickerOptionsPage,
365
+ handler: (input, context) =>
366
+ service.repositoryOptions(context.bonsai, input),
367
+ }),
368
+ "branches.options": defineAppMethod({
369
+ description: "Choose a branch from a repository's ready saved refs.",
370
+ effect: "read",
371
+ requires: [],
372
+ input: pickerOptionsInput,
373
+ output: pickerOptionsPage,
374
+ handler: (input, context) => service.branchOptions(context.bonsai, input),
375
+ }),
376
+ "repos.get": defineAppMethod({
377
+ description:
378
+ "Read one repository: default branch, refs, clone URL and forks.",
379
+ effect: "read",
380
+ requires: ["git.repository"],
381
+ input: z.strictObject({ repo }),
382
+ output: z.union([repoDetail, readCopyRebuilding]),
383
+ handler: (input, context) =>
384
+ readCopyResponse(() => getRepoDetail(context.bonsai, input.repo)),
385
+ }),
386
+ "repos.create": defineAppMethod({
387
+ description: "Create a canonical repository. People only.",
388
+ effect: "write",
389
+ requires: ["git.repository"],
390
+ input: z.strictObject({
391
+ name: z.string().regex(service.repoNamePattern),
392
+ description: z.string().max(280).default(""),
393
+ }),
394
+ output: repoSummary,
395
+ handler: (input, context, caller) =>
396
+ service.createRepo(context.bonsai, callerActor(caller), input),
397
+ }),
398
+ "repos.delete": defineAppMethod({
399
+ description:
400
+ "Delete a repository and its provider content. Owner only; retry-safe.",
401
+ effect: "write",
402
+ requires: ["git.repository"],
403
+ input: z.strictObject({ repo: gitRepositoryNameSchema }),
404
+ output: repositoryDeletion,
405
+ handler: (input, context, caller) =>
406
+ service.deleteRepo(context.bonsai, caller, input.repo),
407
+ }),
408
+ "repos.fork": defineAppMethod({
409
+ description:
410
+ "Give the caller its own fork of a canonical repository (returns the existing one if present). Push work there, then ask a person to merge.",
411
+ effect: "write",
412
+ requires: ["git.repository"],
413
+ input: z.strictObject({ repo }),
414
+ output: repoSummary,
415
+ handler: (input, context, caller) =>
416
+ service.forkFor(context.bonsai, callerActor(caller), input.repo),
417
+ }),
418
+ "apps.fork": defineAppMethod({
419
+ description:
420
+ "Fork an App's published source into its own Team-owned App: a new Bonsai repository the owner edits and merges, registered as that App's build source. Owner only.",
421
+ effect: "write",
422
+ // It records the fork as a Bonsai repository in this installation's Git.
423
+ requires: ["git.repository"],
424
+ input: z.strictObject({
425
+ appId: z.string().min(1).max(256),
426
+ name: z.string().min(1).max(63),
427
+ }),
428
+ output: z.strictObject({
429
+ appId: z.string(),
430
+ repository: z.string(),
431
+ }),
432
+ handler: async (input, context, caller) => {
433
+ if (caller.role !== "owner")
434
+ throw new AppMethodRefusal("forbidden", "Owner only");
435
+ if (!context.env?.APPS)
436
+ throw new AppMethodRefusal(
437
+ "unavailable",
438
+ "App source forking is unavailable",
439
+ );
440
+ service.assertRepoName(input.name);
441
+ const revision = service.repoRevision(context.bonsai, input.name);
442
+ const result = await context.env.APPS.fork({
443
+ platformAppId: input.appId,
444
+ name: input.name,
445
+ });
446
+ const parsed = z
447
+ .union([
448
+ z.strictObject({
449
+ kind: z.literal("forked"),
450
+ appId: z.string(),
451
+ repository: z.string(),
452
+ }),
453
+ z.strictObject({
454
+ kind: z.enum([
455
+ "unauthorized",
456
+ "conflict",
457
+ "provider_unavailable",
458
+ ]),
459
+ }),
460
+ ])
461
+ .parse(result);
462
+ if (parsed.kind === "forked") {
463
+ await service.recordAppFork(
464
+ context.bonsai,
465
+ callerActor(caller),
466
+ {
467
+ name: parsed.repository,
468
+ description: `Source of ${parsed.appId}, forked from ${input.appId}`,
469
+ },
470
+ revision,
471
+ );
472
+ return { appId: parsed.appId, repository: parsed.repository };
473
+ }
474
+ throw new AppMethodRefusal(
475
+ parsed.kind === "unauthorized"
476
+ ? "forbidden"
477
+ : parsed.kind === "conflict"
478
+ ? "conflict"
479
+ : "unavailable",
480
+ `App source fork ${parsed.kind}`,
481
+ );
482
+ },
483
+ }),
484
+ "files.tree": defineAppMethod({
485
+ description: "List one directory of a repository at a ref.",
486
+ effect: "read",
487
+ requires: ["git.repository"],
488
+ input: z.strictObject({ repo, ref, path }),
489
+ output: z.union([treeListing, readCopyRebuilding]),
490
+ handler: (input, context) =>
491
+ readCopyResponse(() => service.listTree(context.bonsai, input)),
492
+ }),
493
+ "files.list": defineAppMethod({
494
+ description:
495
+ "List every path in a repository at a ref (directories end with /).",
496
+ effect: "read",
497
+ requires: ["git.repository"],
498
+ input: z.strictObject({ repo, ref }),
499
+ output: z.union([fileList, readCopyRebuilding]),
500
+ handler: (input, context) =>
501
+ readCopyResponse(() => service.listFiles(context.bonsai, input)),
502
+ }),
503
+ "files.read": defineAppMethod({
504
+ description:
505
+ "Read one file at a ref; text is null for binary or very large files.",
506
+ effect: "read",
507
+ requires: ["git.repository"],
508
+ input: z.strictObject({ repo, ref, path: z.string().min(1).max(4096) }),
509
+ output: z.union([fileContent, readCopyRebuilding]),
510
+ handler: (input, context, caller) =>
511
+ readCopyResponse(async () => {
512
+ const file = await service.readFile(context.bonsai, input);
513
+ if (caller.actor.kind !== "member" || caller.workflow !== undefined)
514
+ return file;
515
+ const lane = actorLane(caller.actor);
516
+ const key = {
517
+ repo: input.repo,
518
+ branch: input.ref.replace(/^refs\/heads\//u, ""),
519
+ path: input.path,
520
+ };
521
+ const version = context.bonsai.state.fileEdits.revision(lane, key);
522
+ const pending = context.bonsai.state.fileEdits.pending(lane, key);
523
+ return pending === undefined
524
+ ? { ...file, version }
525
+ : { ...file, version, pending };
526
+ }),
527
+ }),
528
+ "files.save": defineAppMethod({
529
+ description:
530
+ "Save bounded text as a private member row with revision CAS; eligible edits sync in the background.",
531
+ effect: "write",
532
+ requires: ["git.repository"],
533
+ input: fileSaveInput,
534
+ output: fileSaveResult,
535
+ handler: async (input, context, caller) =>
536
+ saveFileEdit(
537
+ context.bonsai,
538
+ caller,
539
+ input,
540
+ mayEnqueueBackground(caller)
541
+ ? await fileBackgroundRuntime(context.bonsai, caller)
542
+ : null,
543
+ ),
544
+ }),
545
+ "files.sync": defineAppMethod({
546
+ description:
547
+ "Retry eligible failed edits and report this member's saved-file status; background sync owns Git work.",
548
+ effect: "write",
549
+ requires: ["git.repository"],
550
+ input: z.strictObject({}),
551
+ output: fileSyncResult,
552
+ handler: async (_input, context, caller) =>
553
+ syncFileEdits(
554
+ context.bonsai,
555
+ caller,
556
+ await fileBackgroundRuntime(context.bonsai, caller),
557
+ ),
558
+ }),
559
+ "files.discard": defineAppMethod({
560
+ description:
561
+ "Discard only this caller's saved file revision and reload Git's text.",
562
+ effect: "write",
563
+ requires: ["git.repository"],
564
+ input: fileDiscardInput,
565
+ output: fileDiscardResult,
566
+ handler: (input, context, caller) => {
567
+ assertOrdinaryFileMember(caller);
568
+ return context.bonsai.state.fileEdits.discard(
569
+ actorLane(caller.actor),
570
+ input,
571
+ );
572
+ },
573
+ }),
574
+ "issues.read": defineAppMethod({
575
+ description: "Read the Markdown issue files under issues/ at a ref.",
576
+ effect: "read",
577
+ requires: ["git.repository"],
578
+ input: z.strictObject({ repo, ref }),
579
+ output: issueFiles,
580
+ handler: (input, context) =>
581
+ service.readIssueFiles(context.bonsai, input),
582
+ }),
583
+ "commits.list": defineAppMethod({
584
+ description:
585
+ "List recent commits on a ref with the agent that pushed each.",
586
+ effect: "read",
587
+ requires: ["git.repository"],
588
+ input: z.strictObject({ repo, ref, ...readCopyPageInput.shape }),
589
+ output: z.union([commitPage, readCopyRebuilding]),
590
+ handler: (input, context) =>
591
+ readCopyResponse(() => service.listCommits(context.bonsai, input)),
592
+ }),
593
+ "commits.get": defineAppMethod({
594
+ description: "Read one commit and its diff against its first parent.",
595
+ effect: "read",
596
+ requires: ["git.repository"],
597
+ input: z.strictObject({
598
+ repo,
599
+ hash: z.string().regex(/^[0-9a-f]{40}$/u),
600
+ }),
601
+ output: z.union([commitDetail, readCopyRebuilding]),
602
+ handler: (input, context) =>
603
+ readCopyResponse(() => service.getCommitDetail(context.bonsai, input)),
604
+ }),
605
+ "graph.get": defineAppMethod({
606
+ description:
607
+ "The network: every branch and agent fork as lanes with their commits.",
608
+ effect: "read",
609
+ requires: ["git.repository"],
610
+ input: z.strictObject({ repo }),
611
+ output: z.union([graph, readCopyRebuilding]),
612
+ handler: (input, context) =>
613
+ readCopyResponse(() => service.buildGraph(context.bonsai, input.repo)),
614
+ }),
615
+ "changes.list": defineAppMethod({
616
+ description:
617
+ "Stored proposals for a canonical repository, with pinned review status.",
618
+ effect: "read",
619
+ requires: ["git.repository"],
620
+ input: z.strictObject({
621
+ repo,
622
+ ...readCopyPageInput.shape,
623
+ state: z.enum(["open", "merging", "merged", "closed"]).optional(),
624
+ }),
625
+ output: z.union([changePage, readCopyRebuilding]),
626
+ handler: (input, context, caller) =>
627
+ readCopyResponse(() =>
628
+ changes.changes(
629
+ context.bonsai,
630
+ callerActor(caller),
631
+ input.repo,
632
+ input.state,
633
+ { limit: input.limit, after: input.after },
634
+ ),
635
+ ),
636
+ }),
637
+ "changes.get": defineAppMethod({
638
+ description:
639
+ "Read a numbered Change, pinned diff and review facts; push fixes on checks-failed.",
640
+ effect: "read",
641
+ requires: ["git.repository"],
642
+ input: z.strictObject({ repo, number: z.number().int().positive() }),
643
+ output: z.union([changeDetail, readCopyRebuilding]),
644
+ handler: (input, context, caller) =>
645
+ readCopyResponse(() =>
646
+ changes.change(
647
+ context.bonsai,
648
+ callerActor(caller),
649
+ input.repo,
650
+ input.number,
651
+ ),
652
+ ),
653
+ }),
654
+ "changes.open": defineAppMethod({
655
+ description:
656
+ "Open a proposal from your source branch; pushes of new fork work also open Changes.",
657
+ effect: "write",
658
+ requires: ["git.repository"],
659
+ input: z.strictObject({
660
+ repo,
661
+ sourceRef: ref,
662
+ targetRef: z.string().min(1).max(256).optional(),
663
+ title: z.string().min(1).max(200).optional(),
664
+ body: z.string().max(20_000).optional(),
665
+ }),
666
+ output: changeRecord,
667
+ handler: (input, context, caller) =>
668
+ changes.openChange(
669
+ context.bonsai,
670
+ callerActor(caller),
671
+ input,
672
+ caller.idempotencyKey,
673
+ ),
674
+ }),
675
+ "changes.approve": defineAppMethod({
676
+ description:
677
+ "Approve the exact proposed head if you did not open or author it, or as your Team's only member. Agents report reviews as checks.",
678
+ effect: "write",
679
+ requires: ["git.repository"],
680
+ input: z.strictObject({
681
+ repo,
682
+ number: z.number().int().positive(),
683
+ head: gitSha1Schema,
684
+ }),
685
+ output: approvalResult,
686
+ handler: (input, context, caller) =>
687
+ reviews.approveChange(
688
+ context.bonsai,
689
+ callerActor(caller),
690
+ input,
691
+ caller.idempotencyKey,
692
+ ),
693
+ }),
694
+ sync: defineAppMethod({
695
+ description:
696
+ "Continue this member's pending writes after fresh host admission.",
697
+ effect: "write",
698
+ // The continuation covers every write's requirements (apps.build's
699
+ // computer.execute included); it only replays queued Git writes.
700
+ requires: ["git.repository", "computer.execute"],
701
+ input: z.strictObject({}),
702
+ output: z.strictObject({ more: z.boolean() }),
703
+ handler: (_input, context, caller) =>
704
+ syncBackground(context.bonsai, caller, context.env?.BACKGROUND),
705
+ }),
706
+ "changes.merge": defineAppMethod({
707
+ description:
708
+ "A person maintainer merges the exact reviewed head; eligible Merge counts as approval. Failing or pending checks block it.",
709
+ effect: "write",
710
+ requires: ["git.repository"],
711
+ input: z.strictObject({
712
+ repo,
713
+ number: z.number().int().positive(),
714
+ head: gitSha1Schema,
715
+ }),
716
+ output: mergeResult,
717
+ handler: async (input, context, caller) =>
718
+ mayEnqueueBackground(caller)
719
+ ? queueMergeChange(
720
+ context.bonsai,
721
+ caller,
722
+ input,
723
+ await backgroundRuntime(context.bonsai, caller),
724
+ )
725
+ : mergeChange(
726
+ context.bonsai,
727
+ callerActor(caller),
728
+ input,
729
+ caller.idempotencyKey,
730
+ ),
731
+ }),
732
+ "checks.request": defineAppMethod({
733
+ description:
734
+ "Request checks at this head; the emitted event ID is the real attempt identity for every report from that trigger.",
735
+ effect: "write",
736
+ requires: ["git.repository"],
737
+ input: z.strictObject({
738
+ repo,
739
+ number: z.number().int().positive(),
740
+ head: gitSha1Schema,
741
+ names: z.array(z.string().min(1).max(100)).optional(),
742
+ }),
743
+ output: checkRequestResult,
744
+ handler: (input, context, caller) =>
745
+ reviews.requestChecks(
746
+ context.bonsai,
747
+ callerActor(caller),
748
+ input,
749
+ caller.idempotencyKey,
750
+ ),
751
+ }),
752
+ "checks.report": defineAppMethod({
753
+ description:
754
+ "Report a check for the triggering Change event ID. Use its actual eventId as attemptId; runUrl is optional and supplied only for a real run link. Old attempts and conflicting terminal reports settle without overwriting.",
755
+ effect: "write",
756
+ requires: ["git.repository"],
757
+ input: z.strictObject({
758
+ repo,
759
+ number: z.number().int().positive(),
760
+ head: gitSha1Schema,
761
+ name: z.string().min(1).max(100),
762
+ status: z.enum(["queued", "running", "passed", "failed"]),
763
+ attemptId: z.string().min(1).max(512),
764
+ runUrl: z.url().optional(),
765
+ summary: z.string().max(4000).optional(),
766
+ }),
767
+ output: checkReportResult,
768
+ handler: (input, context, caller) =>
769
+ reviews.reportCheck(
770
+ context.bonsai,
771
+ callerActor(caller),
772
+ input,
773
+ caller.idempotencyKey,
774
+ ),
775
+ }),
776
+ "changes.close": defineAppMethod({
777
+ description: "Close your proposal or a proposal you maintain.",
778
+ effect: "write",
779
+ requires: ["git.repository"],
780
+ input: z.strictObject({ repo, number: z.number().int().positive() }),
781
+ output: policyDecision,
782
+ handler: (input, context, caller) =>
783
+ changes.closeChange(
784
+ context.bonsai,
785
+ callerActor(caller),
786
+ input.repo,
787
+ input.number,
788
+ caller.idempotencyKey,
789
+ ),
790
+ }),
791
+ "activity.list": defineAppMethod({
792
+ description:
793
+ "Recent activity, newest first; pass after to receive only newer events.",
794
+ effect: "read",
795
+ requires: [],
796
+ input: z.strictObject({
797
+ repo: repo.nullable().default(null),
798
+ limit: z.number().int().min(1).max(200).default(60),
799
+ after: z.number().int().min(0).default(0),
800
+ }),
801
+ output: activityPage,
802
+ handler: (input, context) => ({
803
+ events: service.listActivity(context.bonsai, input),
804
+ }),
805
+ }),
806
+ "actors.list": defineAppMethod({
807
+ description: "Everyone Bonsai has seen act, with their colour slot.",
808
+ effect: "read",
809
+ requires: [],
810
+ input: z.strictObject({}),
811
+ output: agentPage,
812
+ handler: (_input, context) => ({
813
+ agents: context.bonsai.state.actors(),
814
+ }),
815
+ }),
816
+ },
817
+ });
818
+
819
+ /** Deliver one pending page after a write; reads never acquire the event sink. */
820
+ function withOutbox(
821
+ method: AppBackendMethod<BonsaiMethodContext>,
822
+ ): AppBackendMethod<BonsaiMethodContext> {
823
+ if (method.policy.effect !== "write") return method;
824
+ return {
825
+ ...method,
826
+ policy: {
827
+ ...method.policy,
828
+ requires: [...method.policy.requires, "app.events.emit"],
829
+ },
830
+ call: async (input, context, caller) => {
831
+ const result = await method.call(input, context, caller);
832
+ await drainOutbox(context.bonsai);
833
+ return result;
834
+ },
835
+ };
836
+ }
837
+
838
+ export const appBackend = defineAppBackend<BonsaiMethodContext>({
839
+ gills: [openChangesGill, recentCommitsGill],
840
+ sync: { method: "sync" },
841
+ events: definition.events,
842
+ methods: Object.fromEntries(
843
+ Object.entries(definition.methods).map(([name, method]) => [
844
+ name,
845
+ withOutbox(method),
846
+ ]),
847
+ ),
848
+ });