@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.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +848 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/present.test.ts +27 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +47 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +719 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- 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
|
-
#
|
|
1
|
+
# Bonsai (`@fungi.computer/bonsai`)
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+

|
|
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
|
+

|
|
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
|
+
}
|