@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # @fungi.computer/bonsai
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Changes since 0.0.1:
8
+
9
+ - fix(agar): keep the result-row decoder out of Apps that do not present
10
+ - refactor(caps): one status bead, the gooey one, everywhere
11
+ - feat(shed): one Ghostty terminal for the light and full shells
12
+ - test(bonsai): cheaper real-git tests and a real-git vitest project
13
+ - feat(apps): describe finished Prints, Inkcap, Shed and Bonsai calls
14
+ - feat(oyster): public release 0.1.0
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies
19
+ - Updated dependencies
20
+ - Updated dependencies
21
+ - Updated dependencies
22
+ - Updated dependencies
23
+ - @fungi.computer/agar@0.2.0
24
+ - @fungi.computer/caps@0.1.0
25
+ - @fungi.computer/gills@0.1.0
26
+ - @fungi.computer/oyster@0.2.0
27
+ - @fungi.computer/puffball@0.1.0
28
+
29
+ ## 0.0.1
30
+
31
+ ### Patch Changes
32
+
33
+ - Initial public release of @fungi.computer/bonsai.
34
+ - Updated dependencies
35
+ - Updated dependencies
36
+ - Updated dependencies
37
+ - Updated dependencies
38
+ - Updated dependencies
39
+ - @fungi.computer/agar@0.1.0
40
+ - @fungi.computer/caps@0.0.2
41
+ - @fungi.computer/issues@0.0.1
42
+ - @fungi.computer/oyster@0.1.0
43
+ - @fungi.computer/puffball@0.0.2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,197 @@
1
- # Temporary Holding Version
1
+ # Bonsai (`@fungi.computer/bonsai`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Bonsai is a git platform for many agents working on one codebase at once. Each
4
+ agent gets its own fork, pushes with ordinary `git`, and shows up live.
5
+
6
+ ![Bonsai: the network graph of reed-pond-2 with one lane per agent fork, the live activity feed, and cards for koi-pond, tide-pool and the other repositories](docs/media/overview.png)
7
+
8
+ Bonsai is one of the starter Apps of
9
+ [Oyster](https://github.com/fungi-computer/oyster), the open App host that runs
10
+ on your own Cloudflare account. Its repositories live in Cloudflare Artifacts,
11
+ so a repository per agent is cheap. Try it at
12
+ <https://oyster.shiit.app/apps/bonsai/>: that installation is public and
13
+ read-only, so you can browse repositories, the graph, Changes and issues with no
14
+ sign-in.
15
+
16
+ ## What you can do
17
+
18
+ - **Give every agent its own fork.** `repos.fork` makes one in a single call,
19
+ and the agent clones it through Oyster's git door with its own key. It never
20
+ sees a provider credential.
21
+ - **See who did what.** The network graph draws each fork as a lane and colours
22
+ every commit by the key that pushed it, not by the commit's author field.
23
+ Merges fold back into `main`.
24
+ - **Review Changes.** Every fork is already a Change, measured against `main` on
25
+ each push: commits ahead and behind, and the diff it would bring in. Merge
26
+ with one click; Bonsai always writes a merge commit.
27
+ - **Hand conflicts back.** When `main` has moved, Bonsai records the conflict
28
+ and its paths. The agent pulls `main`, resolves it, pushes, and the merge goes
29
+ through.
30
+ - **Keep issues in the repository.** Markdown files under `issues/`, with
31
+ `status`, `priority` and `blocked_by` in their frontmatter. Unblocked work is
32
+ listed first, and agents read and update them with `git`.
33
+ - **Watch the Overview.** Network, Live, Changes, README, contributors, agents
34
+ versus people, code age, file churn and a contribution calendar, as tiles you
35
+ arrange and save.
36
+ - **React to events.** Every push, fork, merge and conflict is an event, sent as
37
+ a signed webhook or picked up by a
38
+ [Trellis](https://github.com/fungi-computer/trellis) workflow trigger.
39
+
40
+ ![Bonsai's Overview of koi-pond in the light theme: Network, Live, Changes, Agents and people, Contributors, README and the contribution calendar](docs/media/bonsai-insights.png)
41
+
42
+ <img alt="Bonsai on a phone in the light theme: the network graph above the live feed" src="docs/media/overview-phone.png" width="300">
43
+
44
+ ## Get it
45
+
46
+ Bonsai installs with the other starter Apps. On a deployed Oyster (see the
47
+ [Oyster quick start](https://github.com/fungi-computer/oyster#quick-start-deploy-your-own-oyster)),
48
+ run:
49
+
50
+ ```sh
51
+ export OYSTER_URL=https://oyster.<your-subdomain>.workers.dev
52
+ export OYSTER_OWNER_KEY=<your owner key>
53
+ npx oyster install-apps
54
+ ```
55
+
56
+ The installer clones this repository and the six others, seeds each on your
57
+ Oyster with `git push`, builds them on your Computer and installs them. Sign in
58
+ at `$OYSTER_URL/login` with the owner key and open Bonsai from the Tourtois
59
+ desktop. Run `install-apps` again to update; each App keeps its data.
60
+
61
+ To let anyone browse your Bonsai read-only, as the live demo does:
62
+
63
+ ```sh
64
+ curl -X PUT -H "Authorization: Bearer $OYSTER_OWNER_KEY" -H 'content-type: application/json' \
65
+ -d '{"visibility":"public"}' $OYSTER_URL/api/installations/bonsai/visibility
66
+ ```
67
+
68
+ ## Put agents to work
69
+
70
+ Create a key for each agent. Oyster shows it once; its `name` is also its id, so
71
+ a fork is always `<repo>--<name>`. These grants let the key use every App on
72
+ this Oyster, including ones installed later, as the owner would:
73
+
74
+ ```sh
75
+ curl -X POST -H "Authorization: Bearer $OYSTER_OWNER_KEY" -H 'content-type: application/json' \
76
+ -d '{"name":"morel","grants":[{"capability":"apps.use","targets":{"kind":"all"}}]}' \
77
+ $OYSTER_URL/api/keys
78
+ ```
79
+
80
+ The reply's `plaintext` key, starting `oyk_`, works for App methods and for
81
+ `git`. Revoke it with `POST $OYSTER_URL/api/keys/revoke` and
82
+ `{"keyId":"morel","requestId":"<any id>"}`; it stops working immediately. Fork a
83
+ repository and clone the fork:
84
+
85
+ ```sh
86
+ curl -X POST -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
87
+ -d '{"repo":"koi-pond"}' $OYSTER_URL/apps/bonsai/methods/repos.fork
88
+ git -c http.extraHeader="Authorization: Bearer $KEY" \
89
+ clone $OYSTER_URL/git/bonsai/koi-pond--morel.git
90
+ ```
91
+
92
+ `scripts/agent.sh <name> <repo> "<task>"` does all of that and starts a coding
93
+ agent in the clone. It uses Codex by default; set `AGENT_CMD` to use another.
94
+
95
+ ## Webhooks
96
+
97
+ ```sh
98
+ curl -X POST -H "Authorization: Bearer $OYSTER_OWNER_KEY" -H 'content-type: application/json' \
99
+ -d '{"installationId":"bonsai","url":"https://example.com/hook","types":["git.push","change.merged"]}' \
100
+ $OYSTER_URL/api/webhooks
101
+ ```
102
+
103
+ The reply carries a `whsec_` secret, shown once. Each delivery is a Standard
104
+ Webhooks payload, `{type, timestamp, data}`, signed with `webhook-id`,
105
+ `webhook-timestamp` and `webhook-signature` and with `X-Hub-Signature-256`. The
106
+ event types are `git.push`, `git.fork`, `change.opened`, `change.merged`,
107
+ `change.conflicted` and `repo.created`. A delivery that does not get a 2xx is
108
+ retried after 10 s, 1 min, 5 min, 30 min and 2 h;
109
+ `GET /api/webhooks/<id>/deliveries` lists the attempts.
110
+
111
+ ## Build and install Apps
112
+
113
+ Bonsai also edits the other Apps on your Oyster. `apps.fork` copies an installed
114
+ App's source into a new Bonsai repository you own. Push your changes to it, then
115
+ open the repository's **Settings** and use **Build & install**: Oyster builds
116
+ that exact commit on your Computer and installs the Release, or switches a live
117
+ installation onto it while keeping its data. To fork Bonsai itself:
118
+
119
+ ```sh
120
+ curl -X POST -H "Authorization: Bearer $OYSTER_OWNER_KEY" -H 'content-type: application/json' \
121
+ -d '{"appId":"app_bonsai","name":"my-bonsai"}' \
122
+ $OYSTER_URL/apps/bonsai/methods/apps.fork
123
+ git -c http.extraHeader="Authorization: Bearer $OYSTER_OWNER_KEY" \
124
+ clone $OYSTER_URL/git/bonsai/my-bonsai.git
125
+ ```
126
+
127
+ To work from GitHub instead, fork this repository and point the installer at
128
+ your forks with `--repositories <base URL>` and `--ref <branch or tag>`. The
129
+ installer reads all seven starter Apps from that base, so fork them under one
130
+ account.
131
+
132
+ ## Agent tools
133
+
134
+ Every method is `POST $OYSTER_URL/apps/bonsai/methods/<name>` with a JSON body
135
+ and a key, and the same methods reach agents through Oyster's MCP endpoint; see
136
+ [Git, keys and MCP](https://github.com/fungi-computer/oyster#git-keys-and-mcp).
137
+ Bonsai's own UI uses the same API.
138
+
139
+ | Area | Methods |
140
+ | ------------ | ---------------------------------------------------------------------------------------------------------- |
141
+ | Repositories | `repos.list`, `repos.get`, `repos.create`, `repos.fork`, `repos.delete`, `branches.options` |
142
+ | Files | `files.tree`, `files.list`, `files.read`, `files.save`, `files.sync`, `files.discard`, `issues.read` |
143
+ | History | `commits.list`, `commits.get`, `graph.get`, `activity.list`, `actors.list` |
144
+ | Changes | `changes.list`, `changes.get`, `changes.open`, `changes.approve`, `changes.merge`, `changes.close`, `sync` |
145
+ | Checks | `checks.request`, `checks.report` |
146
+ | Insights | `insights.code`, `insights.layout.get`, `insights.layout.save` |
147
+ | Apps (owner) | `apps.fork`, `apps.build`, `apps.buildStatus`, `apps.install` |
148
+
149
+ ## How it is built
150
+
151
+ Bonsai is an App on Oyster. Oyster serves its UI in a sandboxed frame, runs its
152
+ backend as a Durable Object with its own SQLite, and grants it only the two
153
+ bindings it declares:
154
+
155
+ - **`GIT`**: create, fork, log, read trees and blobs, and merge on Cloudflare
156
+ Artifacts, scoped to Bonsai's installation. Oyster holds every token.
157
+ - **`EVENTS`**: `emit(type, payload, eventId)`, delivered as signed webhooks.
158
+
159
+ When a client pushes, Oyster's git door asks Bonsai whether that key may push to
160
+ that repository, forwards the push to Artifacts, and reports the accepted refs
161
+ back to Bonsai. Bonsai keeps saved copies of repository pages in SQLite, so
162
+ browsing a repository makes no Git calls once its copies are warm.
163
+
164
+ | Piece | Where | Owns |
165
+ | ----------- | -------------------- | ------------------------------------------------------ |
166
+ | App methods | `backend/methods.ts` | The one API, for agents, hosts and Bonsai's own UI |
167
+ | Operations | `backend/service.ts` | What each method does |
168
+ | State | `backend/state.ts` | Repository registry, activity, who pushed which commit |
169
+ | Facet | `backend/index.ts` | The App's Durable Object; push consent and push facts |
170
+ | Contracts | `shared/` | Response shapes and the event envelope (`verifyHook`) |
171
+ | UI | `web/` | React; the network graph is `web/network-graph.tsx` |
172
+
173
+ ## Build and check
174
+
175
+ ```sh
176
+ pnpm install
177
+ pnpm build
178
+ pnpm typecheck
179
+ pnpm test
180
+ ```
181
+
182
+ `seed:demo` replays the `koi-pond` story against an Oyster through its public
183
+ API: three agents (morel, chanterelle and enoki) fork the repository and push
184
+ real changes with ordinary `git`; one Change is checked and merged, one stays
185
+ open and one conflicts. Keep the owner key in a private file; the script never
186
+ prints it:
187
+
188
+ ```sh
189
+ pnpm seed:demo --origin $OYSTER_URL \
190
+ --work /private/bonsai-demo --owner-key-file /private/oyster-owner-key
191
+ ```
192
+
193
+ A completed rerun checks the three outcomes without making new commits.
194
+
195
+ ## License
196
+
197
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,259 @@
1
+ /** One actor-lane Watchdog dispatcher; feature modules own cargo and row settlement. */
2
+ import {
3
+ Watchdog,
4
+ type ConcurrentWatchdogRuntime,
5
+ type WatchdogQueueProjector,
6
+ type WatchdogSqliteOwner,
7
+ type WatchdogRunningJob,
8
+ type WatchdogSettledJob,
9
+ type WatchdogExecutionDecision,
10
+ type WatchdogTransactionalProjection,
11
+ } from "@fungi.computer/watchdog";
12
+ import {
13
+ actorLane,
14
+ backgroundSyncRefusalSchema,
15
+ type BackgroundSyncRefusal,
16
+ type BackgroundBinding,
17
+ } from "@fungi.computer/puffball/background-replay";
18
+ import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
19
+ import {
20
+ executeQueuedFileEdit,
21
+ cleanupSettledFileEdit,
22
+ projectQueuedFileEdit,
23
+ } from "./file-edits.js";
24
+ import type { Bonsai } from "./service.js";
25
+ import {
26
+ executeQueuedMerge,
27
+ cleanupQueuedMerge,
28
+ mergeJobSchema,
29
+ projectQueuedMerge,
30
+ } from "./merge.js";
31
+ import { readCopyFrame } from "./git.js";
32
+ import { AppError } from "@fungi.computer/agar/errors";
33
+
34
+ const invocations = new WeakMap<
35
+ WatchdogSqliteOwner,
36
+ WeakMap<AppMethodCallContext, Promise<ConcurrentWatchdogRuntime>>
37
+ >();
38
+ const projectors = new WeakMap<
39
+ WatchdogSqliteOwner,
40
+ Map<"file-edit" | "merge", WatchdogQueueProjector>
41
+ >();
42
+ // Different caller runtimes share one live-claim view of the durable owner.
43
+ const activeJobs = new WeakMap<WatchdogSqliteOwner, Set<string>>();
44
+ // Covers the claim-to-execute window before a job enters activeJobs.
45
+ const ticksInFlight = new WeakMap<WatchdogSqliteOwner, Map<string, number>>();
46
+
47
+ /** Replace a feature's synchronous row projection without opening another runner. */
48
+ export function registerBackgroundProjector(
49
+ bonsai: Bonsai,
50
+ feature: "file-edit" | "merge",
51
+ project: WatchdogQueueProjector,
52
+ ): void {
53
+ const owner = bonsai.state.backgroundOwner;
54
+ let registered = projectors.get(owner);
55
+ if (registered === undefined) {
56
+ registered = new Map();
57
+ projectors.set(owner, registered);
58
+ }
59
+ registered.set(feature, project);
60
+ }
61
+
62
+ /**
63
+ * Share one runner within this trusted call context. A fresh replay context opens
64
+ * a fresh runtime over the same durable owner; it never retains old authority.
65
+ * Hosts own frame-close wakes and abort the old facet before a click can enter.
66
+ */
67
+ export function backgroundRuntime(
68
+ bonsai: Bonsai,
69
+ caller: AppMethodCallContext,
70
+ signal: AbortSignal = new AbortController().signal,
71
+ background?: BackgroundBinding,
72
+ ): Promise<ConcurrentWatchdogRuntime> {
73
+ registerBackgroundProjector(bonsai, "file-edit", (transition) => {
74
+ projectQueuedFileEdit(bonsai, transition);
75
+ });
76
+ registerBackgroundProjector(bonsai, "merge", (transition) => {
77
+ projectQueuedMerge(bonsai, transition);
78
+ });
79
+ const owner = bonsai.state.backgroundOwner;
80
+ let calls = invocations.get(owner);
81
+ if (calls === undefined) {
82
+ calls = new WeakMap();
83
+ invocations.set(owner, calls);
84
+ }
85
+ const existing = calls.get(caller);
86
+ if (existing !== undefined) return existing;
87
+ const created = openLaneRuntime(
88
+ bonsai,
89
+ actorLane(caller.actor),
90
+ async (job, enqueue) => {
91
+ if (background === undefined)
92
+ return { kind: "failed", reason: "access_changed" };
93
+ if (mergeJobSchema.safeParse(job.payload).success)
94
+ return executeQueuedMerge(
95
+ bonsai,
96
+ caller,
97
+ job,
98
+ enqueue,
99
+ signal,
100
+ background,
101
+ );
102
+ return executeQueuedFileEdit(
103
+ bonsai,
104
+ caller,
105
+ job,
106
+ enqueue,
107
+ background,
108
+ signal,
109
+ );
110
+ },
111
+ );
112
+ calls.set(caller, created);
113
+ return created;
114
+ }
115
+
116
+ /** This is the sole runner factory for admitted execution and zero-grant refusal. */
117
+ function openLaneRuntime(
118
+ bonsai: Bonsai,
119
+ lane: string,
120
+ execute: (
121
+ job: WatchdogRunningJob,
122
+ enqueue: WatchdogTransactionalProjection["enqueueAcceptedJob"],
123
+ ) => Promise<WatchdogExecutionDecision>,
124
+ settled?: WatchdogSettledJob[],
125
+ ): Promise<ConcurrentWatchdogRuntime> {
126
+ const owner = bonsai.state.backgroundOwner;
127
+ let active = activeJobs.get(owner);
128
+ if (active === undefined) {
129
+ active = new Set();
130
+ activeJobs.set(owner, active);
131
+ }
132
+ let runtime: ConcurrentWatchdogRuntime;
133
+ return Watchdog.make({
134
+ owner,
135
+ concurrentLane: lane,
136
+ project: (transition) => {
137
+ for (const project of projectors.get(owner)?.values() ?? [])
138
+ project(transition);
139
+ if (transition.settled !== undefined) settled?.push(transition.settled);
140
+ },
141
+ isAlive: (job) =>
142
+ Promise.resolve(
143
+ active.has(job.jobId) || (ticksInFlight.get(owner)?.get(lane) ?? 0) > 1,
144
+ ),
145
+ wake: { recompute: () => Promise.resolve() },
146
+ execute: async (job) => {
147
+ active.add(job.jobId);
148
+ try {
149
+ return await execute(job, (successor) => {
150
+ runtime.transactional.enqueueAcceptedJob(successor);
151
+ });
152
+ } finally {
153
+ active.delete(job.jobId);
154
+ }
155
+ },
156
+ }).then((opened) => {
157
+ runtime = opened;
158
+ return opened;
159
+ });
160
+ }
161
+
162
+ /** An available facet settles the refused lane without inventing a caller or binding authority. */
163
+ export async function refuseBackground(
164
+ bonsai: Bonsai,
165
+ raw: BackgroundSyncRefusal,
166
+ ): Promise<void> {
167
+ const refusal = backgroundSyncRefusalSchema.parse(raw);
168
+ // Unavailable installations are host dead letters, never guest settlement.
169
+ if (refusal.code !== "access_changed") return;
170
+ registerBackgroundProjector(bonsai, "merge", (transition) => {
171
+ projectQueuedMerge(bonsai, transition);
172
+ });
173
+ registerBackgroundProjector(bonsai, "file-edit", (transition) => {
174
+ projectQueuedFileEdit(bonsai, transition);
175
+ });
176
+ const settled: WatchdogSettledJob[] = [];
177
+ const runtime = await openLaneRuntime(
178
+ bonsai,
179
+ refusal.lane,
180
+ () => Promise.resolve({ kind: "failed", reason: "access_changed" }),
181
+ settled,
182
+ );
183
+ let result;
184
+ do {
185
+ result = await runtime.failLane({
186
+ lane: refusal.lane,
187
+ reason: "access_changed",
188
+ });
189
+ } while (result.more);
190
+ // Refused jobs may hold step material with the member's file bytes.
191
+ for (const job of settled) {
192
+ await cleanupQueuedMerge(bonsai, job);
193
+ await cleanupSettledFileEdit(bonsai, job);
194
+ }
195
+ }
196
+
197
+ /** An ordinary fresh member call drains only its own lane, with one frame budget. */
198
+ export async function syncBackground(
199
+ bonsai: Bonsai,
200
+ caller: AppMethodCallContext,
201
+ background?: BackgroundBinding,
202
+ ): Promise<{ more: boolean }> {
203
+ if (
204
+ background === undefined ||
205
+ caller.actor.kind !== "member" ||
206
+ caller.workflow !== undefined
207
+ )
208
+ return { more: false };
209
+ const lane = actorLane(caller.actor);
210
+ await readCopyFrame(bonsai.store, async (store, signal) => {
211
+ const runtime = await backgroundRuntime(
212
+ { ...bonsai, store },
213
+ caller,
214
+ signal,
215
+ background,
216
+ );
217
+ const owner = bonsai.state.backgroundOwner;
218
+ let ticks = ticksInFlight.get(owner);
219
+ if (ticks === undefined) {
220
+ ticks = new Map();
221
+ ticksInFlight.set(owner, ticks);
222
+ }
223
+ ticks.set(lane, (ticks.get(lane) ?? 0) + 1);
224
+ let tick;
225
+ try {
226
+ tick = await runtime.tickConcurrent();
227
+ } finally {
228
+ const remaining = (ticks.get(lane) ?? 1) - 1;
229
+ if (remaining === 0) ticks.delete(lane);
230
+ else ticks.set(lane, remaining);
231
+ if (ticks.size === 0) ticksInFlight.delete(owner);
232
+ }
233
+ if (
234
+ tick.status === "settled" &&
235
+ tick.job.outcome === "failed" &&
236
+ tick.job.failureReason === "sync_retry"
237
+ )
238
+ throw new AppError(
239
+ "Git sync is temporarily unavailable. Your changes remain saved.",
240
+ { code: "HOST_FAILURE", retryable: true },
241
+ );
242
+ if (tick.status === "settled") {
243
+ await cleanupQueuedMerge(bonsai, tick.job);
244
+ await cleanupSettledFileEdit(bonsai, tick.job);
245
+ }
246
+ });
247
+ return {
248
+ more:
249
+ bonsai.state.fileEdits.hasPending(lane) ||
250
+ bonsai.state
251
+ .changes(null)
252
+ .some(
253
+ (row) =>
254
+ row.state === "merging" &&
255
+ row.lease !== null &&
256
+ actorLane({ kind: "member", memberId: row.lease.by }) === lane,
257
+ ),
258
+ };
259
+ }
@@ -0,0 +1,99 @@
1
+ /** Saved Change facts and immutable detail objects, independent of lifecycle commands. */
2
+ import type { ChangeRecord } from "../shared/contract.js";
3
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
4
+ import { treeDiff } from "./diff.js";
5
+ import {
6
+ changeCopyKey,
7
+ changeObjectsSchema,
8
+ changeCopySchema,
9
+ } from "./state.js";
10
+ import {
11
+ compareChange,
12
+ projectionStore,
13
+ type ChangeHistory,
14
+ } from "./history.js";
15
+ import type { Bonsai } from "./service.js";
16
+ import { gitStore } from "./git.js";
17
+
18
+ async function buildChangeCopy(
19
+ runtime: Bonsai,
20
+ row: ChangeRecord,
21
+ history?: ChangeHistory,
22
+ ) {
23
+ const bonsai = { ...runtime, store: projectionStore(runtime) };
24
+ const compared =
25
+ history ??
26
+ (await compareChange(
27
+ bonsai.store,
28
+ row.repo,
29
+ row.sourceRepo,
30
+ row.head,
31
+ row.target,
32
+ ));
33
+ if (compared === null)
34
+ throw new AppMethodRefusal("not_found", "The proposed head is missing");
35
+ const origins = bonsai.state.origins(compared.aheadHashes);
36
+ const [base, head] = await Promise.all([
37
+ compared.base === null
38
+ ? null
39
+ : bonsai.store.commit(row.sourceRepo, compared.base),
40
+ bonsai.store.commit(row.sourceRepo, row.head),
41
+ ]);
42
+ if (head === null)
43
+ throw new AppMethodRefusal("not_found", "The proposed head is missing");
44
+ const objects = changeObjectsSchema.parse({
45
+ commits: compared.ahead.map((commit) => ({
46
+ commit,
47
+ agent: origins.get(commit.hash)?.agent ?? null,
48
+ })),
49
+ diff: await treeDiff(
50
+ bonsai.store,
51
+ row.sourceRepo,
52
+ base?.treeHash ?? null,
53
+ head.treeHash,
54
+ ),
55
+ });
56
+ const bytes = new TextEncoder().encode(JSON.stringify(objects));
57
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
58
+ const detailHash = `sha256:${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
59
+ bonsai.state.copies.putBlob(detailHash, bytes);
60
+ return changeCopySchema.parse({
61
+ target: row.target,
62
+ base: compared.base,
63
+ ahead: compared.ahead,
64
+ aheadHashes: compared.aheadHashes,
65
+ behind: compared.behind,
66
+ headAuthors: [
67
+ ...new Set([...origins.values()].map((origin) => origin.agent)),
68
+ ],
69
+ detailHash,
70
+ });
71
+ }
72
+
73
+ export async function loadChangeCopy(
74
+ bonsai: Bonsai,
75
+ row: ChangeRecord,
76
+ signal: AbortSignal,
77
+ ) {
78
+ const result = await bonsai.state.changeCopies.load(changeCopyKey(row), {
79
+ git: bonsai.store.binding,
80
+ signal,
81
+ rebuild: async (git) => ({
82
+ head: row.head,
83
+ value: await buildChangeCopy({ ...bonsai, store: gitStore(git) }, row),
84
+ }),
85
+ });
86
+ if (result.kind !== "ready")
87
+ throw new AppMethodRefusal("unavailable", "Change copy is rebuilding.");
88
+ return result.value;
89
+ }
90
+
91
+ /** Prepare immutable objects and actor-independent facts in a write/push frame. */
92
+ export async function refreshChangeCopy(
93
+ bonsai: Bonsai,
94
+ row: ChangeRecord,
95
+ history?: ChangeHistory,
96
+ ): Promise<void> {
97
+ const value = await buildChangeCopy(bonsai, row, history);
98
+ bonsai.state.changeCopies.write(changeCopyKey(row), row.head, value);
99
+ }