@fungi.computer/bonsai 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +848 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/present.test.ts +27 -0
  90. package/test/read-copy-calls.test.ts +691 -0
  91. package/test/read-copy-composer.test.ts +388 -0
  92. package/test/read-copy-lifetimes.test.ts +214 -0
  93. package/test/read-copy-rpc.config.jsonc +12 -0
  94. package/test/read-copy-rpc.native.mjs +47 -0
  95. package/test/read-copy-rpc.worker.ts +92 -0
  96. package/test/recent-activity.test.ts +62 -0
  97. package/test/repository-deletion.test.ts +500 -0
  98. package/test/seed-resume.test.mjs +91 -0
  99. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  100. package/test/storyboard-save-template.test.mjs +545 -0
  101. package/test/ui-errors.test.ts +976 -0
  102. package/test/workflow-store.test.ts +115 -0
  103. package/tsconfig.json +36 -0
  104. package/turbo.jsonc +25 -0
  105. package/vite.config.ts +19 -0
  106. package/vitest.config.ts +47 -0
  107. package/web/api.ts +224 -0
  108. package/web/app-build-panel.tsx +171 -0
  109. package/web/board.tsx +259 -0
  110. package/web/bonsai.css +642 -0
  111. package/web/change-page.tsx +719 -0
  112. package/web/change-rows.tsx +135 -0
  113. package/web/code-tab.tsx +267 -0
  114. package/web/commit-tree.tsx +152 -0
  115. package/web/format.ts +54 -0
  116. package/web/home-page.tsx +274 -0
  117. package/web/index.html +17 -0
  118. package/web/insight-data.ts +76 -0
  119. package/web/insight-tiles.tsx +534 -0
  120. package/web/insights.tsx +250 -0
  121. package/web/issues-panel.tsx +305 -0
  122. package/web/main.tsx +72 -0
  123. package/web/network-graph.tsx +647 -0
  124. package/web/overview.tsx +339 -0
  125. package/web/pages.tsx +469 -0
  126. package/web/parts.tsx +381 -0
  127. package/web/public/favicon.svg +1 -0
  128. package/web/repo-navigation.tsx +256 -0
  129. package/web/repo-settings.tsx +145 -0
  130. package/web/router.tsx +151 -0
  131. package/web/transport.ts +91 -0
  132. package/web/views.tsx +151 -0
  133. package/web/working-now.tsx +98 -0
@@ -0,0 +1,274 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { DataListHeading } from "@fungi.computer/caps/components/data-list";
3
+ import { Status } from "@fungi.computer/caps/components/status";
4
+ import { EmptyState } from "@fungi.computer/caps/blocks/empty-state";
5
+ import {
6
+ useAgents,
7
+ useRepos,
8
+ useGraph,
9
+ useChanges,
10
+ useEvents,
11
+ type LiveState,
12
+ } from "./api.js";
13
+ import { BonsaiBoard, type BoardTile } from "./board.js";
14
+ import { NetworkGraph } from "./network-graph.js";
15
+ import {
16
+ ActivityFeed,
17
+ AgentName,
18
+ agentColors,
19
+ Loading,
20
+ Failure,
21
+ Sparkline,
22
+ type AgentColors,
23
+ } from "./parts.js";
24
+ import { WorkingNow, workingNow } from "./working-now.js";
25
+ import { href, useRouter } from "./router.js";
26
+ import type { Graph, RepoSummary } from "../shared/contract.js";
27
+ import { relativeTime } from "./format.js";
28
+
29
+ function HomeNetwork({
30
+ graph,
31
+ repo,
32
+ colors,
33
+ live,
34
+ }: {
35
+ graph: Graph;
36
+ repo: string;
37
+ colors: AgentColors;
38
+ live: LiveState;
39
+ }) {
40
+ const changes = useChanges(repo);
41
+ const events = useEvents(repo);
42
+ const { go } = useRouter();
43
+ const fresh = new Set(
44
+ events.data?.events.flatMap((event) =>
45
+ event.type === "push" && live.fresh.has(event.id)
46
+ ? event.data.commits.map((commit) => commit.hash)
47
+ : [],
48
+ ),
49
+ );
50
+ return (
51
+ <NetworkGraph
52
+ graph={graph}
53
+ colors={colors}
54
+ fresh={fresh}
55
+ onSelect={(node) => {
56
+ go(
57
+ href({
58
+ page: "repo",
59
+ repo,
60
+ tab: "commits",
61
+ commit: node.hash,
62
+ ref: null,
63
+ file: null,
64
+ path: "",
65
+ }),
66
+ );
67
+ }}
68
+ onLaneSelect={(lane) => {
69
+ const change = changes.data?.changes.find(
70
+ (item) =>
71
+ item.sourceRepo === lane.repo &&
72
+ item.sourceRef === lane.ref &&
73
+ item.state === "open",
74
+ );
75
+ go(
76
+ change
77
+ ? href({ page: "change", repo: change.repo, number: change.number })
78
+ : href({
79
+ page: "repo",
80
+ repo: lane.repo,
81
+ tab: "overview",
82
+ ref: lane.ref,
83
+ file: null,
84
+ path: "",
85
+ }),
86
+ );
87
+ }}
88
+ />
89
+ );
90
+ }
91
+ function RepoCard({
92
+ repo,
93
+ colors,
94
+ }: {
95
+ repo: RepoSummary;
96
+ colors: AgentColors;
97
+ }) {
98
+ const changes = useChanges(repo.name);
99
+ return (
100
+ <div className="space-y-3">
101
+ {repo.description ? <p>{repo.description}</p> : null}
102
+ <p className="text-xs text-[var(--stipe-fg-muted)]">
103
+ {repo.lastPushAt !== null
104
+ ? `Pushed ${relativeTime(repo.lastPushAt)} · `
105
+ : ""}
106
+ {repo.forks.length} forks
107
+ {changes.data
108
+ ? ` · ${String(changes.data.changes.filter((change) => change.state === "open" || change.state === "merging").length)} open changes`
109
+ : ""}
110
+ </p>
111
+ <div className="flex flex-wrap gap-2">
112
+ {repo.forks.map((fork) => (
113
+ <AgentName
114
+ key={fork}
115
+ name={fork.split("--")[1] ?? fork}
116
+ colors={colors}
117
+ />
118
+ ))}
119
+ </div>
120
+ <Sparkline values={repo.activity} />
121
+ </div>
122
+ );
123
+ }
124
+ export function HomePage({ live }: { live: LiveState }) {
125
+ const [wide, setWide] = useState(false);
126
+ useEffect(() => {
127
+ const media = matchMedia("(min-width: 1024px)");
128
+ const update = () => {
129
+ setWide(media.matches);
130
+ };
131
+ update();
132
+ media.addEventListener("change", update);
133
+ return () => {
134
+ media.removeEventListener("change", update);
135
+ };
136
+ }, []);
137
+ const repos = useRepos();
138
+ const events = useEvents(null);
139
+ const actors = useAgents();
140
+ const colors = useMemo(() => agentColors(actors.data?.agents), [actors.data]);
141
+ const featured = [...(repos.data?.repos ?? [])]
142
+ .filter((repo) => repo.parent === null)
143
+ .sort((a, b) => (b.lastPushAt ?? 0) - (a.lastPushAt ?? 0))[0];
144
+ const network = useGraph(
145
+ featured?.name ?? "",
146
+ featured !== undefined && featured.lastPushAt !== null,
147
+ );
148
+ const tiles: BoardTile[] = [];
149
+ if (
150
+ featured?.lastPushAt !== null &&
151
+ featured !== undefined &&
152
+ (network.isPending || network.isError || network.data.nodes.length > 0)
153
+ )
154
+ tiles.push({
155
+ id: "network",
156
+ title: `Network · ${featured.name}`,
157
+ autoHeight: true,
158
+ body: network.isPending ? (
159
+ <Loading label="Loading network" />
160
+ ) : network.isError ? (
161
+ <Failure
162
+ error={network.error}
163
+ retry={() => {
164
+ void network.refetch();
165
+ }}
166
+ />
167
+ ) : (
168
+ <HomeNetwork
169
+ graph={network.data}
170
+ repo={featured.name}
171
+ colors={colors}
172
+ live={live}
173
+ />
174
+ ),
175
+ });
176
+ if (events.isError)
177
+ tiles.push({
178
+ id: "live",
179
+ title: "Live",
180
+ autoHeight: !wide || !tiles.some((tile) => tile.id === "network"),
181
+ body: (
182
+ <Failure
183
+ error={events.error}
184
+ retry={() => {
185
+ void events.refetch();
186
+ }}
187
+ />
188
+ ),
189
+ });
190
+ else if (events.data?.events.length)
191
+ tiles.push({
192
+ id: "live",
193
+ title: "Live",
194
+ autoHeight: !wide || !tiles.some((tile) => tile.id === "network"),
195
+ body: (
196
+ <>
197
+ {workingNow(events.data.events).length ? (
198
+ <>
199
+ <DataListHeading>Working now</DataListHeading>
200
+ <WorkingNow events={events.data.events} colors={colors} />
201
+ </>
202
+ ) : null}
203
+ <ActivityFeed
204
+ events={events.data.events.slice(0, 5)}
205
+ colors={colors}
206
+ fresh={live.fresh}
207
+ showRepo
208
+ />
209
+ </>
210
+ ),
211
+ });
212
+ for (const repo of repos.data?.repos ?? [])
213
+ tiles.push({
214
+ id: `repo:${repo.name}`,
215
+ title: repo.name,
216
+ to: href({
217
+ page: "repo",
218
+ repo: repo.name,
219
+ tab: "overview",
220
+ ref: null,
221
+ file: null,
222
+ path: "",
223
+ }),
224
+ autoHeight: true,
225
+ status:
226
+ repo.lastPushAt !== null && repo.lastPushAt > Date.now() - 900000 ? (
227
+ <Status label="Active" tone="success" size="compact" />
228
+ ) : undefined,
229
+ body: <RepoCard repo={repo} colors={colors} />,
230
+ });
231
+ const hasHero = tiles.some(
232
+ (tile) => tile.id === "network" || tile.id === "live",
233
+ );
234
+ let card = 0;
235
+ const placements = tiles.map((tile) => {
236
+ if (tile.id === "network")
237
+ return { id: tile.id, col: 0, row: 0, w: 3, h: 1 };
238
+ if (tile.id === "live") return { id: tile.id, col: 3, row: 0, w: 1, h: 1 };
239
+ const index = card++;
240
+ return {
241
+ id: tile.id,
242
+ col: index % 4,
243
+ row: (hasHero ? 1 : 0) + Math.floor(index / 4),
244
+ w: 1,
245
+ h: 1,
246
+ };
247
+ });
248
+ return (
249
+ <div className="space-y-3">
250
+ {repos.isPending ? (
251
+ <Loading label="Loading repositories" />
252
+ ) : repos.isError ? (
253
+ <Failure
254
+ error={repos.error}
255
+ retry={() => {
256
+ void repos.refetch();
257
+ }}
258
+ />
259
+ ) : repos.data.repos.length === 0 ? (
260
+ <EmptyState
261
+ variant="panel"
262
+ title="No repositories yet"
263
+ description="Repositories you and your Agents create appear here, with every fork, push and change live."
264
+ />
265
+ ) : null}
266
+ <BonsaiBoard
267
+ rowHeight={0}
268
+ placements={placements}
269
+ tiles={tiles}
270
+ phoneOrder={tiles.map((tile) => tile.id)}
271
+ />
272
+ </div>
273
+ );
274
+ }
package/web/index.html ADDED
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta
7
+ name="description"
8
+ content="Bonsai: a git platform where many agents work at once."
9
+ />
10
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
11
+ <title>Bonsai</title>
12
+ </head>
13
+ <body>
14
+ <div id="bonsai-root"></div>
15
+ <script type="module" src="./main.tsx"></script>
16
+ </body>
17
+ </html>
@@ -0,0 +1,76 @@
1
+ import type { Agent, Graph } from "../shared/contract.js";
2
+
3
+ type ContributorKind = "agent" | "human" | "unknown";
4
+ function contributorKind(
5
+ actor: string | null,
6
+ actors: readonly Agent[],
7
+ ): ContributorKind {
8
+ return actors.find((item) => item.id === actor)?.kind ?? "unknown";
9
+ }
10
+
11
+ export function actorSplit(graph: Graph, actors: readonly Agent[]) {
12
+ const counts = () => ({ agent: 0, human: 0, unknown: 0 });
13
+ const commits = counts();
14
+ const merges = counts();
15
+ for (const node of graph.nodes) {
16
+ const kind = contributorKind(node.agent, actors);
17
+ commits[kind]++;
18
+ if (node.parents.length > 1) merges[kind]++;
19
+ }
20
+ return { commits, merges };
21
+ }
22
+
23
+ const DAY = 86400;
24
+ export function contributionDays(graph: Graph, now = Date.now()) {
25
+ const today = Math.floor(now / 1000 / DAY);
26
+ const days = Array.from({ length: 182 }, (_, index) => ({
27
+ day: today - 181 + index,
28
+ count: 0,
29
+ }));
30
+ for (const node of graph.nodes) {
31
+ const index = Math.floor(node.committedAt / DAY) - (today - 181);
32
+ const item = days[index];
33
+ if (item) item.count++;
34
+ }
35
+ return days;
36
+ }
37
+
38
+ export function contributionWeeks(
39
+ graph: Graph,
40
+ actors: readonly Agent[],
41
+ now = Date.now(),
42
+ ) {
43
+ const end = Math.floor(now / 1000 / (7 * DAY));
44
+ const contributors = new Map<
45
+ string,
46
+ {
47
+ id: string;
48
+ name: string;
49
+ kind: ContributorKind;
50
+ color: string | undefined;
51
+ total: number;
52
+ }
53
+ >();
54
+ const weeks = Array.from({ length: 12 }, (_, index) => ({
55
+ index,
56
+ counts: new Map<string, number>(),
57
+ }));
58
+ for (const node of graph.nodes) {
59
+ const week = weeks[Math.floor(node.committedAt / (7 * DAY)) - end + 11];
60
+ if (!week) continue;
61
+ const id =
62
+ node.agent === null ? `unknown:${node.author}` : `actor:${node.agent}`;
63
+ const actor = actors.find((item) => item.id === node.agent);
64
+ const contributor = contributors.get(id) ?? {
65
+ id,
66
+ name: actor?.name ?? node.agent ?? node.author,
67
+ kind: contributorKind(node.agent, actors),
68
+ color: actor?.color,
69
+ total: 0,
70
+ };
71
+ contributor.total++;
72
+ contributors.set(id, contributor);
73
+ week.counts.set(id, (week.counts.get(id) ?? 0) + 1);
74
+ }
75
+ return { contributors: [...contributors.values()], weeks };
76
+ }