@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/package.json CHANGED
@@ -1,6 +1,155 @@
1
1
  {
2
2
  "name": "@fungi.computer/bonsai",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "author": "Levi <levi@technomancy.dev>",
5
+ "type": "module",
6
+ "description": "Bonsai: a git platform for many agents working at once, on Workers and Artifacts",
7
+ "dependencies": {
8
+ "@fungi.computer/agar": "0.2.0",
9
+ "@fungi.computer/caps": "0.1.0",
10
+ "@fungi.computer/issues": "0.0.1",
11
+ "@fungi.computer/oyster": "0.2.0",
12
+ "@fungi.computer/puffball": "0.1.0",
13
+ "@fungi.computer/stipe": "0.0.1",
14
+ "@pierre/diffs": "1.3.6",
15
+ "@pierre/trees": "1.0.0-beta.6",
16
+ "@tanstack/react-query": "5.102.8",
17
+ "@visx/curve": "3.12.0",
18
+ "@visx/scale": "3.12.0",
19
+ "@visx/shape": "3.12.0",
20
+ "diff": "9.0.0",
21
+ "lucide-react": "0.460.0",
22
+ "react": "18.3.1",
23
+ "react-dom": "18.3.1",
24
+ "zod": "4.4.3",
25
+ "@fungi.computer/watchdog": "0.0.1",
26
+ "effect": "4.0.2",
27
+ "@fungi.computer/cairn": "0.0.1",
28
+ "@fungi.computer/maosaico": "0.0.1",
29
+ "@visx/hierarchy": "3.12.0",
30
+ "@fungi.computer/gills": "0.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "playwright": "1.62.1",
34
+ "@cloudflare/workers-types": "5.20260828.1",
35
+ "@tailwindcss/vite": "4.2.1",
36
+ "@types/node": "24.13.3",
37
+ "@types/react": "18.3.3",
38
+ "@types/react-dom": "18.3.3",
39
+ "@vitejs/plugin-react": "6.1.1",
40
+ "tailwindcss": "4.2.1",
41
+ "typescript": "5.9.3",
42
+ "vite": "8.2.2",
43
+ "vitest": "4.1.11",
44
+ "wrangler": "4.135.0",
45
+ "tsx": "4.23.12",
46
+ "@fungi.computer/braid": "0.1.0",
47
+ "@fungi.computer/gills": "0.1.0",
48
+ "eslint": "10.9.1",
49
+ "oxlint": "1.78.0",
50
+ "jsdom": "29.0.1",
51
+ "@fungi.computer/computer": "0.1.0"
52
+ },
53
+ "botanical": {
54
+ "role": "app",
55
+ "operations": {
56
+ "lint": "required",
57
+ "typecheck": "required",
58
+ "build": "required",
59
+ "test": "required",
60
+ "docs": {
61
+ "status": "not-applicable",
62
+ "reason": "Bonsai documents itself in its README; it has no package API"
63
+ },
64
+ "pack": {
65
+ "status": "not-applicable",
66
+ "reason": "Bonsai deploys as a Worker, not an npm package"
67
+ },
68
+ "smoke": {
69
+ "status": "not-applicable",
70
+ "reason": "The preview deployment and browser shots exercise Bonsai"
71
+ },
72
+ "stranger": {
73
+ "status": "not-applicable",
74
+ "reason": "Bonsai is not yet published"
75
+ }
76
+ }
77
+ },
78
+ "license": "MIT",
79
+ "repository": {
80
+ "type": "git",
81
+ "url": "git+https://github.com/fungi-computer/bonsai.git"
82
+ },
83
+ "fungi": {
84
+ "backendDefinition": "backend/methods.ts",
85
+ "app": {
86
+ "displayName": "Bonsai",
87
+ "icon": "bonsai",
88
+ "capabilities": [
89
+ "git.repository",
90
+ "apps.open",
91
+ "app.events.emit",
92
+ "computer.execute"
93
+ ],
94
+ "receives": [
95
+ "git.push"
96
+ ],
97
+ "handlers": [],
98
+ "roles": [],
99
+ "backend": {
100
+ "bindings": [
101
+ {
102
+ "name": "GIT",
103
+ "operations": [
104
+ "create",
105
+ "fork",
106
+ "read",
107
+ "write",
108
+ "exchange"
109
+ ]
110
+ },
111
+ {
112
+ "name": "EVENTS",
113
+ "operations": [
114
+ "emit"
115
+ ]
116
+ },
117
+ {
118
+ "name": "APPS",
119
+ "operations": [
120
+ "build",
121
+ "buildStatus",
122
+ "select"
123
+ ]
124
+ }
125
+ ]
126
+ }
127
+ },
128
+ "build": {
129
+ "react": true,
130
+ "packageStylesheets": [
131
+ "@fungi.computer/caps"
132
+ ],
133
+ "stylesheets": [
134
+ "web/bonsai.css"
135
+ ]
136
+ }
137
+ },
138
+ "scripts": {
139
+ "build": "vite build",
140
+ "typecheck": "tsc -p tsconfig.json --noEmit",
141
+ "lint": "eslint backend web shared test --max-warnings=0 && oxlint --config ../../oxlint.config.ts --deny-warnings backend web shared test",
142
+ "test": "pnpm run test:unit && pnpm run test:merge",
143
+ "test:unit": "vitest run && node --import tsx --test test/seed-resume.test.mjs test/insight-layout.test.mjs test/checks-workflow-template.test.mjs test/merge-template-build.test.mjs test/storyboard-save-template.test.mjs",
144
+ "test:merge": "node --import tsx test/git-push.native.mjs",
145
+ "format:check": "node ../../tooling/repository/scripts/format-package-check.mjs",
146
+ "lint:anti-slop": "node ../../tooling/repository/scripts/lint-package-anti-slop.mjs",
147
+ "release": "node scripts/release.mjs",
148
+ "rebuild-copies": "node --import tsx scripts/rebuild-copies.mjs",
149
+ "test:runtime": "node scripts/runtime-proof.mjs",
150
+ "test:shed-terminal": "bash ../../.agents/skills/orchestrate-multi-lane-work/scripts/run-proof.sh node scripts/shed-terminal-proof.mjs",
151
+ "test:runtime:celld": "CELLD_PLATFORM_CELLD=$(../../tooling/celld-platform/runtime/pinned-celld.sh) node --import ../../tooling/celld-platform/runtime/wrangler-on-celld.mjs scripts/runtime-proof.mjs",
152
+ "seed:demo": "node --import tsx scripts/seed-demo.mjs",
153
+ "test:repo-settings": "bash ../../.agents/skills/orchestrate-multi-lane-work/scripts/run-proof.sh node test/browser/repository-settings.browser.mjs"
154
+ }
6
155
  }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # Run one coding agent against a Bonsai repository: fork it, clone the fork
3
+ # through Bonsai's git door, and let the agent work and push in its own fork.
4
+ #
5
+ # BONSAI_URL=https://<oyster> BONSAI_KEY=<agent key> scripts/agent.sh <agent-name> <repo> <task>
6
+ #
7
+ # BONSAI_URL is the Oyster that hosts Bonsai; BONSAI_INSTALLATION names Bonsai's
8
+ # installation there (default "bonsai"). The agent key comes from the Oyster
9
+ # owner (POST /api/keys).
10
+ #
11
+ # AGENT_CMD picks the harness (default: Codex on the cheap Luna model). The
12
+ # agent's key is written into the clone's git config so plain `git push` works;
13
+ # Bonsai only lets that key push to the agent's own fork.
14
+ set -euo pipefail
15
+
16
+ name=${1:?agent name}
17
+ repo=${2:?repository}
18
+ task=${3:?task}
19
+ url=${BONSAI_URL:?BONSAI_URL}
20
+ key=${BONSAI_KEY:?BONSAI_KEY}
21
+ work=${BONSAI_WORK:-$(mktemp -d)}
22
+ app=${BONSAI_INSTALLATION:-bonsai}
23
+
24
+ fork=$(curl -fsS -X POST -H "Authorization: Bearer $key" -d "{\"repo\":\"$repo\"}" -H "Content-Type: application/json" "$url/apps/$app/methods/repos.fork" |
25
+ node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).name))')
26
+ echo "[$name] working in $fork"
27
+
28
+ dir="$work/$fork"
29
+ rm -rf "$dir"
30
+ for attempt in 1 2 3; do
31
+ git -c http.extraHeader="Authorization: Bearer $key" clone -q "$url/git/$app/$fork.git" "$dir" && break
32
+ [ "$attempt" = 3 ] && exit 1
33
+ sleep 2
34
+ done
35
+ git -C "$dir" config http.extraHeader "Authorization: Bearer $key"
36
+ git -C "$dir" config user.name "$name"
37
+ git -C "$dir" config user.email "$name@agents.bonsai"
38
+ git -C "$dir" remote add upstream "$url/git/$app/$repo.git"
39
+
40
+ # Start from the canonical main so the fork carries everything already merged.
41
+ # A conflict is left in the working tree for the agent to resolve.
42
+ if git -C "$dir" pull -q --no-rebase --no-edit upstream main; then
43
+ git -C "$dir" push -q origin HEAD
44
+ else
45
+ echo "[$name] main conflicts with $fork; leaving the merge for the agent"
46
+ fi
47
+
48
+ prompt="You are $name, one of several agents working on this repository at the same time; each agent has its own fork.
49
+ Task: $task
50
+ The canonical repository is the 'upstream' remote; if you are asked to update from main, run 'git pull --no-rebase upstream main', resolve any conflicts so both features keep working, commit and push.
51
+ Rules: work in small steps. After each meaningful step, commit with a clear one-line message and run 'git push origin HEAD' right away so others can see your progress. Make 2 to 4 commits. Do not install packages or run builds. Stop when the task is done."
52
+
53
+ cd "$dir"
54
+ ${AGENT_CMD:-codex exec -m gpt-6-luna --yolo --skip-git-repo-check} "$prompt"
55
+ echo "[$name] done"
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env bash
2
+ # The Bonsai demo, end to end and real: create a repository, seed it, start
3
+ # three coding agents at once (each in its own fork), merge their work as the
4
+ # owner, and when a merge conflicts, send that agent back to resolve it.
5
+ #
6
+ # OYSTER_URL=https://… OYSTER_OWNER_KEY=… KEYS_FILE=agents.env scripts/demo.sh [repo]
7
+ #
8
+ # KEYS_FILE holds BONSAI_KEY_MOREL, BONSAI_KEY_CHANTERELLE and BONSAI_KEY_ENOKI
9
+ # (agent keys from POST /api/keys). Agents run with scripts/agent.sh, so
10
+ # AGENT_CMD picks their harness.
11
+ set -euo pipefail
12
+
13
+ url=${OYSTER_URL:?OYSTER_URL}
14
+ owner=${OYSTER_OWNER_KEY:?OYSTER_OWNER_KEY}
15
+ repo=${1:-koi-pond}
16
+ app=${BONSAI_INSTALLATION:-bonsai}
17
+ here=$(cd "$(dirname "$0")" && pwd)
18
+ work=${BONSAI_WORK:-$(mktemp -d)}
19
+ set -a
20
+ . "${KEYS_FILE:?KEYS_FILE}"
21
+ set +a
22
+
23
+ call() {
24
+ curl -fsS -X POST -H "Authorization: Bearer $owner" -H "Content-Type: application/json" \
25
+ -d "$2" "$url/apps/$app/methods/$1"
26
+ }
27
+
28
+ # One changes.merge call, then bounded read-only observation of that exact
29
+ # pinned Change through the existing contract (changes.get) until it
30
+ # reaches a terminal state FOR THE EXACT HEAD PROPOSED: merged, or a
31
+ # conflict pinned to that head; closed, moved to a different head, or a
32
+ # recorded mergeFailure are reported as failures rather than treated as
33
+ # still-pending (same law apps/bonsai/test/webhooks-issues.native.mjs's
34
+ # waitForMergeOutcome uses). changes.get can itself answer
35
+ # {"kind":"rebuilding"} (apps/bonsai/backend/methods.ts:446); that is not
36
+ # terminal either way, so it is read the same as "keep polling". An owner
37
+ # key is background-eligible (packages/puffball/src/background-replay.ts
38
+ # mayEnqueueBackground), so changes.merge normally answers
39
+ # {"kind":"merging"} and settles later; the merge is never resubmitted,
40
+ # only observed. The whole observation has one finite elapsed budget, and
41
+ # no single fetch's AbortSignal outlives it. Prints "merged <hash>" and
42
+ # exits 0, or "conflict <paths>" and exits 2, a "moved" line on stderr and
43
+ # exits 3 when a newer push replaced the pinned head, or a failure line on
44
+ # stderr and exits 1 (refused, closed, a recorded failure, or a timeout).
45
+ cat >"$work/merge-observe.mjs" <<'MERGE_OBSERVE'
46
+ const [url, owner, app, repo, number, head] = process.argv.slice(2);
47
+ const auth = { Authorization: `Bearer ${owner}`, "Content-Type": "application/json" };
48
+ const deadline = Date.now() + 60_000;
49
+ const budget = (ms) => Math.max(1, Math.min(ms, deadline - Date.now()));
50
+ async function call(method, input) {
51
+ const response = await fetch(`${url}/apps/${app}/methods/${method}`, {
52
+ method: "POST",
53
+ headers: auth,
54
+ body: JSON.stringify(input),
55
+ signal: AbortSignal.timeout(budget(10_000)),
56
+ });
57
+ const text = await response.text();
58
+ return { ok: response.ok, body: text ? JSON.parse(text) : null };
59
+ }
60
+ // Stops only on a terminal, read-only fact about the pinned head: merged,
61
+ // closed, moved off that head, a conflict pinned to that head, or a
62
+ // recorded mergeFailure. "rebuilding" and an unsettled "open"/"merging"
63
+ // Change are not terminal; keep polling until the shared deadline.
64
+ async function observe() {
65
+ for (;;) {
66
+ if (Date.now() >= deadline)
67
+ throw new Error(`Timed out waiting for Change #${number} to settle`);
68
+ const got = await call("changes.get", { repo, number: Number(number) });
69
+ if (!got.ok) throw new Error(`changes.get failed: ${JSON.stringify(got.body)}`);
70
+ const detail = got.body;
71
+ if ("change" in detail) {
72
+ const change = detail.change;
73
+ if (
74
+ change.state === "merged" ||
75
+ change.state === "closed" ||
76
+ change.head !== head ||
77
+ change.conflict?.head === head ||
78
+ change.mergeFailure !== undefined
79
+ )
80
+ return change;
81
+ }
82
+ await new Promise((resolve) => setTimeout(resolve, Math.min(500, budget(500))));
83
+ }
84
+ }
85
+ function classify(change) {
86
+ // A newer push under the lease is "moved", even when the stale lease
87
+ // also recorded a mergeFailure; the caller may retry against the new head.
88
+ if (change.head !== head) return { kind: "moved", head: change.head };
89
+ if (change.mergeFailure !== undefined)
90
+ return { kind: "failed", reason: change.mergeFailure };
91
+ if (change.state === "closed")
92
+ return { kind: "failed", reason: "closed before it could settle" };
93
+ if (change.state === "merged")
94
+ return { kind: "merged", after: change.mergedCommit };
95
+ if (change.conflict?.head === head)
96
+ return { kind: "conflict", paths: change.conflict.paths };
97
+ return { kind: "failed", reason: `unexpected state ${change.state}` };
98
+ }
99
+ const merged = await call("changes.merge", { repo, number: Number(number), head });
100
+ if (!merged.ok) {
101
+ console.error(`merge call failed: ${JSON.stringify(merged.body)}`);
102
+ process.exit(1);
103
+ }
104
+ let outcome = merged.body;
105
+ if (outcome.kind === "merging") outcome = classify(await observe());
106
+ if (outcome.kind === "merged") {
107
+ console.log(`merged ${outcome.after}`);
108
+ process.exit(0);
109
+ }
110
+ if (outcome.kind === "conflict") {
111
+ console.log(`conflict ${JSON.stringify(outcome.paths)}`);
112
+ process.exit(2);
113
+ }
114
+ if (outcome.kind === "moved") {
115
+ console.error(`Change #${number} moved from ${head} to ${outcome.head}`);
116
+ process.exit(3);
117
+ }
118
+ console.error(`merge did not complete: ${outcome.kind} ${outcome.reason ?? JSON.stringify(outcome)}`);
119
+ process.exit(1);
120
+ MERGE_OBSERVE
121
+
122
+ merge_and_observe() {
123
+ node "$work/merge-observe.mjs" "$url" "$owner" "$app" "$repo" "$1" "$2"
124
+ }
125
+
126
+ echo "== create $repo and push the starting project"
127
+ call repos.create "{\"name\":\"$repo\",\"description\":\"A small canvas pond game that agents build together\"}" >/dev/null
128
+ seed="$work/seed"
129
+ rm -rf "$seed" && cp -r "$here/../demo/koi-pond" "$seed"
130
+ git -C "$seed" init -q -b main
131
+ git -C "$seed" add -A
132
+ git -C "$seed" -c user.name=owner -c user.email=owner@bonsai.local commit -qm "Start $repo: a pond, fish and a game loop"
133
+ git -C "$seed" -c http.extraHeader="Authorization: Bearer $owner" push -q "$url/git/$app/$repo.git" main
134
+
135
+ echo "== three agents, three forks, at the same time"
136
+ export BONSAI_URL=$url BONSAI_WORK=$work BONSAI_INSTALLATION=$app
137
+ BONSAI_KEY=$BONSAI_KEY_MOREL "$here/agent.sh" morel "$repo" \
138
+ "Add feeding: clicking the pond drops a food pellet that sinks slowly; fish steer toward the nearest pellet and eat it. Put pellets in src/food.ts and draw them inside frame() in src/main.ts right after the pond is drawn." \
139
+ >"$work/morel.log" 2>&1 &
140
+ BONSAI_KEY=$BONSAI_KEY_CHANTERELLE "$here/agent.sh" chanterelle "$repo" \
141
+ "Make the pond feel alive: soft ripples expand on the water. Add src/ripples.ts and draw the ripples inside frame() in src/main.ts right after the pond is drawn." \
142
+ >"$work/chanterelle.log" 2>&1 &
143
+ BONSAI_KEY=$BONSAI_KEY_ENOKI "$here/agent.sh" enoki "$repo" \
144
+ "Add issues as files for future work: issues/$repo-v1/01-resize-canvas.md, 02-day-night.md and 03-expire-food.md. Each starts with YAML frontmatter: id ($repo-v1-01 and so on), title (quoted), type: enhancement, status: ready-for-agent, priority: p2, created: $(date +%F), assignee: null, areas: [game], blocked_by: [] (03 is blocked_by [$repo-v1-01]); then '# <the same title>' and the sections '## Outcome', '## Context', '## Acceptance criteria' and '## Verification' with short real content." \
145
+ >"$work/enoki.log" 2>&1 &
146
+ wait
147
+ echo " agents finished; logs in $work"
148
+
149
+ find_change() {
150
+ local agent="$1"
151
+ local fork="$repo--$agent"
152
+ local list
153
+ list=$(call changes.list "{\"repo\":\"$repo\"}")
154
+ node -e '
155
+ const page = JSON.parse(process.argv[1]);
156
+ const fork = process.argv[2];
157
+ const found = page.changes.find((c) => c.sourceRepo === fork);
158
+ if (!found) throw new Error(`no Change for fork ${fork}`);
159
+ console.log(`${found.number} ${found.head}`);
160
+ ' "$list" "$fork"
161
+ }
162
+
163
+ # The fork's main as the git server holds it: the agent's last pushed commit.
164
+ fork_tip() {
165
+ git -C "$work/$repo--$1" ls-remote origin refs/heads/main | cut -f1
166
+ }
167
+
168
+ # Bonsai learns about a push asynchronously, so a Change's head can trail the
169
+ # agent's last push for a while. Wait (bounded) until the Change shows the
170
+ # pushed commit; merging an older head would be refused once the newer push
171
+ # lands, which is the correct behaviour.
172
+ await_change() {
173
+ local agent="$1" tip number head
174
+ tip=$(fork_tip "$agent")
175
+ if [[ -z $tip ]]; then
176
+ echo "$agent: could not read the fork's pushed main" >&2
177
+ return 1
178
+ fi
179
+ local deadline=$((SECONDS + 60))
180
+ while :; do
181
+ read -r number head <<<"$(find_change "$agent")"
182
+ if [[ $head == "$tip" ]]; then
183
+ echo "$number $head"
184
+ return 0
185
+ fi
186
+ if ((SECONDS >= deadline)); then
187
+ echo "$agent: Change #$number still shows $head, not the pushed $tip, after 60s" >&2
188
+ return 1
189
+ fi
190
+ sleep 2
191
+ done
192
+ }
193
+
194
+ # Merge the agent's Change at its pushed head. If a newer push moves the head
195
+ # during the merge, re-read the Change and retry once against the new head.
196
+ merge_agent() {
197
+ local agent="$1" found number head status
198
+ found=$(await_change "$agent") || return 1
199
+ read -r number head <<<"$found"
200
+ if merge_and_observe "$number" "$head"; then return 0; else status=$?; fi
201
+ [[ $status -eq 3 ]] || return "$status"
202
+ echo " $agent: Change #$number moved during the merge; retrying once at the new head" >&2
203
+ found=$(await_change "$agent") || return 1
204
+ read -r number head <<<"$found"
205
+ merge_and_observe "$number" "$head"
206
+ }
207
+
208
+ echo "== merge the changes"
209
+ for agent in enoki morel chanterelle; do
210
+ if out=$(merge_agent "$agent"); then
211
+ echo " $agent: $out"
212
+ continue
213
+ else
214
+ status=$?
215
+ fi
216
+ if [[ $status -ne 2 ]]; then
217
+ echo " $agent: merge did not reach a mergeable state (see the error above)" >&2
218
+ exit "$status"
219
+ fi
220
+ echo "== $agent conflicts with main ($out); sending $agent back to resolve it"
221
+ key_var="BONSAI_KEY_$(tr '[:lower:]' '[:upper:]' <<<"$agent")"
222
+ BONSAI_KEY=${!key_var} "$here/agent.sh" "$agent" "$repo" \
223
+ "Bonsai could not merge your fork into main because src/main.ts conflicts with work main already has. The merge from upstream main is in progress in your working tree. Resolve the conflict so every feature keeps working, commit the merge and push." \
224
+ >"$work/$agent-resolve.log" 2>&1
225
+ if out=$(merge_agent "$agent"); then
226
+ echo " $agent: $out (after resolving)"
227
+ else
228
+ status=$?
229
+ echo " $agent: merge did not reach a mergeable state after resolving (see the error above)" >&2
230
+ exit "$status"
231
+ fi
232
+ done
233
+ echo "== done: $url/apps/$app/"
@@ -0,0 +1,34 @@
1
+ import { copyRebuildPage } from "../shared/contract.ts";
2
+
3
+ const origin = process.env.BONSAI_URL;
4
+ const key = process.env.BONSAI_KEY;
5
+ if (!origin || !key)
6
+ throw new Error(
7
+ "Set BONSAI_URL and BONSAI_KEY for the ordinary admitted method route.",
8
+ );
9
+ const installation = process.env.BONSAI_INSTALLATION ?? "bonsai";
10
+ const url = new URL(
11
+ `/apps/${encodeURIComponent(installation)}/methods/rebuildCopies`,
12
+ origin,
13
+ );
14
+ let cursor = null;
15
+ let done = false;
16
+ let pages = 0;
17
+ while (!done) {
18
+ const response = await fetch(url, {
19
+ method: "POST",
20
+ headers: {
21
+ Authorization: `Bearer ${key}`,
22
+ "Content-Type": "application/json",
23
+ },
24
+ body: JSON.stringify({ cursor }),
25
+ });
26
+ if (!response.ok)
27
+ throw new Error(`Read-copy rebuild refused (${String(response.status)})`);
28
+ const page = copyRebuildPage.parse(await response.json());
29
+ ({ cursor, done } = page);
30
+ pages++;
31
+ console.log(
32
+ `Bonsai read copies: ${String(pages)} pages${done ? ", ready" : ", rebuilding"}`,
33
+ );
34
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // Pack Bonsai as an App Release and install it (or replace its Release) on an
3
+ // Oyster. The canonical builder compiles UI and backend from the requested commit.
4
+ //
5
+ // OYSTER_URL=https://… OYSTER_OWNER_KEY=… node scripts/release.mjs
6
+ import { execFileSync } from "node:child_process";
7
+ import { URL, fileURLToPath } from "node:url";
8
+ import process from "node:process";
9
+ import { log } from "node:console";
10
+ import { installRelease, packRelease } from "@fungi.computer/oyster/pack";
11
+ import { standaloneAppSet } from "@fungi.computer/oyster/standalone/app-set";
12
+
13
+ const root = fileURLToPath(new URL("../", import.meta.url));
14
+ const repository = fileURLToPath(new URL("../../../", import.meta.url));
15
+ const url = process.env.OYSTER_URL;
16
+ const key = process.env.OYSTER_OWNER_KEY;
17
+ if (!url || !key) throw new Error("Set OYSTER_URL and OYSTER_OWNER_KEY");
18
+ const installationId = process.env.BONSAI_INSTALLATION ?? "bonsai";
19
+ const revision = execFileSync("git", ["rev-parse", "HEAD"], {
20
+ cwd: root,
21
+ encoding: "utf8",
22
+ }).trim();
23
+ const bonsai = standaloneAppSet.find((app) => app.installationId === "bonsai");
24
+ if (!bonsai) throw new Error("The standalone App set must include Bonsai");
25
+ const stamp = Date.now().toString(36);
26
+ const outDirectory = `${root}.botanical/release`;
27
+
28
+ await packRelease({
29
+ appId: "app_bonsai",
30
+ releaseId: `rel_${revision.slice(0, 12)}${stamp}`,
31
+ sourceDirectory: repository,
32
+ sourceRevision: `git:${revision}`,
33
+ packageStoreDirectory: execFileSync("pnpm", ["store", "path"], {
34
+ cwd: repository,
35
+ encoding: "utf8",
36
+ }).trim(),
37
+ recipe: bonsai.recipe,
38
+ outDirectory,
39
+ });
40
+
41
+ const response = await installRelease({
42
+ directory: outDirectory,
43
+ url,
44
+ key,
45
+ installationId,
46
+ });
47
+ log(
48
+ response.status === 201 ? "installed" : "replaced",
49
+ response.status,
50
+ await response.text(),
51
+ );
52
+ if (!response.ok) process.exitCode = 1;