@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,914 @@
1
+ /**
2
+ * Bonsai's own state in the App's SQLite: the repository registry (which repo
3
+ * is a fork of which, and whose), activity, which actor first pushed each
4
+ * commit, and the actors Bonsai has seen (with their palette slot). Git content
5
+ * remains the write log. The facet also owns bounded ready screen copies and
6
+ * immutable read objects through Agar's read-copy store.
7
+ */
8
+ import * as z from "zod";
9
+ import { FileEdits } from "./file-edits.js";
10
+ import { createReadCopies } from "@fungi.computer/agar/read-copy";
11
+ import { mayPush, type PolicyFacts } from "./policy.js";
12
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
13
+ import type { EventSink } from "@fungi.computer/oyster/git";
14
+ import { insightLayout, type InsightLayout } from "../shared/insights.js";
15
+ import type {
16
+ WatchdogSqliteOwner,
17
+ WatchdogSqliteValue,
18
+ } from "@fungi.computer/watchdog";
19
+ import {
20
+ activityEnvelope,
21
+ hookEvent,
22
+ type HookEvent,
23
+ } from "../shared/hooks.js";
24
+ import {
25
+ activity as activitySchema,
26
+ changeRecord,
27
+ repoDetail,
28
+ changeDetail,
29
+ changeIdentityOf,
30
+ approval,
31
+ check,
32
+ type ChangeRecord,
33
+ type Approval,
34
+ type Check,
35
+ type Activity,
36
+ type Agent,
37
+ type RepoSummary,
38
+ } from "../shared/contract.js";
39
+ import { commit as commitSchema } from "@fungi.computer/agar/roles/files";
40
+
41
+ export type WithoutId<T> = T extends unknown ? Omit<T, "id"> : never;
42
+ /** Activity before it is stored and numbered. */
43
+ export type NewActivity = WithoutId<Activity>;
44
+
45
+ /** Which actor first pushed a commit, and to which repository. */
46
+ export type CommitOrigin = Readonly<{
47
+ hash: string;
48
+ agent: string;
49
+ repo: string;
50
+ }>;
51
+
52
+ export type ActorKind = "agent" | "human";
53
+
54
+ const PALETTE_SIZE = 8;
55
+ const HOUR = 3_600_000;
56
+ const actorRow = z.object({
57
+ name: z.string(),
58
+ color: z.string(),
59
+ kind: z.enum(["human", "agent"]),
60
+ first_seen: z.number(),
61
+ });
62
+ const repoRow = z.object({
63
+ name: z.string(),
64
+ description: z.string().nullable(),
65
+ parent: z.string().nullable(),
66
+ owner: z.string().nullable(),
67
+ created_at: z.number(),
68
+ });
69
+ const eventRow = z.object({
70
+ id: z.number(),
71
+ at: z.number(),
72
+ type: z.string(),
73
+ repo: z.string(),
74
+ actor: z.string(),
75
+ data: z.string(),
76
+ });
77
+ const receiptRow = z.object({ digest: z.string(), result: z.string() });
78
+ export const changeCopySchema = z.strictObject({
79
+ target: changeRecord.shape.target,
80
+ base: changeRecord.shape.head.nullable(),
81
+ ahead: z.array(commitSchema),
82
+ aheadHashes: z.array(changeRecord.shape.head),
83
+ behind: z.number().int().nonnegative(),
84
+ headAuthors: z.array(z.string()),
85
+ detailHash: z.string().nullable(),
86
+ });
87
+ export const changeObjectsSchema = changeDetail.pick({
88
+ commits: true,
89
+ diff: true,
90
+ });
91
+ export function changeCopyKey(row: ChangeRecord): string {
92
+ return JSON.stringify([row.repo, row.sourceRepo, row.head, row.target]);
93
+ }
94
+
95
+ function toActor(row: z.output<typeof actorRow>): Agent {
96
+ return {
97
+ id: row.name,
98
+ name: row.name,
99
+ color: row.color,
100
+ kind: row.kind === "human" ? "human" : "agent",
101
+ createdAt: row.first_seen,
102
+ };
103
+ }
104
+
105
+ function toActivity(row: z.output<typeof eventRow>): Activity {
106
+ const data: unknown = JSON.parse(row.data);
107
+ return activitySchema.parse({
108
+ id: row.id,
109
+ at: row.at,
110
+ type: row.type,
111
+ repo: row.repo,
112
+ actor: row.actor,
113
+ data,
114
+ });
115
+ }
116
+
117
+ const changeColumns = `repo, number, source_repo AS sourceRepo, source_ref AS sourceRef, target_ref AS targetRef,
118
+ title, body, opened_by AS openedBy, opened_kind AS openedKind, created_at AS createdAt,
119
+ updated_at AS updatedAt, state, head_sha AS head, target_sha AS target, revision,
120
+ conflict, lease, merged_commit AS mergedCommit, merged_by AS mergedBy, closed_by AS closedBy`;
121
+ const storedChange = changeRecord
122
+ .extend({ conflict: z.string().nullable(), lease: z.string().nullable() })
123
+ .transform((row) => {
124
+ const conflict: unknown =
125
+ row.conflict === null ? null : JSON.parse(row.conflict);
126
+ const lease: unknown = row.lease === null ? null : JSON.parse(row.lease);
127
+ return changeRecord.parse({ ...row, conflict, lease });
128
+ });
129
+
130
+ /** Create the tables; the App's state schema version 1. */
131
+ export function ensureBonsaiTables(sql: WatchdogSqliteOwner["sql"]): void {
132
+ sql.exec(`
133
+ CREATE TABLE IF NOT EXISTS insight_layouts (
134
+ actor TEXT NOT NULL, repo TEXT NOT NULL, tiles TEXT NOT NULL,
135
+ PRIMARY KEY (actor, repo)) STRICT;
136
+ CREATE TABLE IF NOT EXISTS changes (
137
+ repo TEXT NOT NULL, number INTEGER NOT NULL, source_repo TEXT NOT NULL,
138
+ source_ref TEXT NOT NULL, target_ref TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL,
139
+ opened_by TEXT NOT NULL, opened_kind TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
140
+ state TEXT NOT NULL CHECK(state IN ('open','merging','merged','closed')),
141
+ head_sha TEXT NOT NULL, target_sha TEXT NOT NULL, revision INTEGER NOT NULL,
142
+ conflict TEXT, lease TEXT, merged_commit TEXT, merged_by TEXT, closed_by TEXT,
143
+ PRIMARY KEY(repo, number)) STRICT;
144
+ CREATE UNIQUE INDEX IF NOT EXISTS changes_open_source ON changes(source_repo, source_ref, target_ref) WHERE state IN ('open','merging');
145
+ CREATE TABLE IF NOT EXISTS approvals (
146
+ repo TEXT NOT NULL, number INTEGER NOT NULL, head_sha TEXT NOT NULL, actor TEXT NOT NULL,
147
+ actor_kind TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY(repo,number,head_sha,actor)) STRICT;
148
+ CREATE TABLE IF NOT EXISTS checks (
149
+ repo TEXT NOT NULL, number INTEGER NOT NULL, head_sha TEXT NOT NULL, name TEXT NOT NULL,
150
+ status TEXT NOT NULL CHECK(status IN ('queued','running','passed','failed')),
151
+ reporter TEXT NOT NULL, attempt_id TEXT NOT NULL, run_url TEXT, summary TEXT, updated_at INTEGER NOT NULL,
152
+ PRIMARY KEY(repo,number,head_sha,name)) STRICT;
153
+ CREATE TABLE IF NOT EXISTS actors (
154
+ name TEXT PRIMARY KEY, color TEXT NOT NULL, kind TEXT NOT NULL,
155
+ first_seen INTEGER NOT NULL) STRICT;
156
+ CREATE TABLE IF NOT EXISTS repository_ref_readiness (
157
+ repo TEXT PRIMARY KEY, revision INTEGER NOT NULL, pending INTEGER NOT NULL) STRICT;
158
+ CREATE TABLE IF NOT EXISTS repos (
159
+ name TEXT PRIMARY KEY, description TEXT, parent TEXT, owner TEXT,
160
+ created_at INTEGER NOT NULL) STRICT;
161
+ CREATE TABLE IF NOT EXISTS repo_deletions (
162
+ name TEXT PRIMARY KEY) STRICT;
163
+ CREATE TABLE IF NOT EXISTS repo_lifetimes (
164
+ name TEXT PRIMARY KEY, generation INTEGER NOT NULL) STRICT;
165
+ CREATE TABLE IF NOT EXISTS events (
166
+ id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, type TEXT NOT NULL,
167
+ repo TEXT NOT NULL, actor TEXT NOT NULL, data TEXT NOT NULL) STRICT;
168
+ CREATE INDEX IF NOT EXISTS events_repo ON events(repo, id);
169
+ CREATE TABLE IF NOT EXISTS merge_failures (repo TEXT NOT NULL, number INTEGER NOT NULL, reason TEXT NOT NULL, PRIMARY KEY(repo,number)) STRICT;
170
+ CREATE TABLE IF NOT EXISTS receipts (
171
+ key TEXT PRIMARY KEY, digest TEXT NOT NULL, result TEXT NOT NULL) STRICT;
172
+ CREATE TABLE IF NOT EXISTS outbox (
173
+ event_id TEXT PRIMARY KEY, envelope TEXT NOT NULL, emitted INTEGER NOT NULL DEFAULT 0) STRICT;
174
+ CREATE INDEX IF NOT EXISTS outbox_pending ON outbox(emitted);
175
+ CREATE TABLE IF NOT EXISTS commits (
176
+ hash TEXT NOT NULL, repo TEXT NOT NULL, agent TEXT NOT NULL, at INTEGER NOT NULL,
177
+ PRIMARY KEY (hash, repo)) STRICT;
178
+ CREATE INDEX IF NOT EXISTS commits_hash ON commits(hash);
179
+ `);
180
+ }
181
+
182
+ export class BonsaiState {
183
+ /** Shared SQLite owner for Bonsai's actor-lane background jobs and file rows. */
184
+ readonly backgroundOwner: WatchdogSqliteOwner;
185
+ readonly fileEdits;
186
+ readonly copies;
187
+ readonly repositoryCopies;
188
+ readonly changeCopies;
189
+ constructor(
190
+ private readonly sql: WatchdogSqliteOwner["sql"],
191
+ private readonly commit: <T>(body: () => T) => T,
192
+ ) {
193
+ this.backgroundOwner = { sql, transactionSync: commit };
194
+ this.fileEdits = new FileEdits(this.backgroundOwner);
195
+ this.copies = createReadCopies(sql, {
196
+ version: 1,
197
+ maxRows: 10_000,
198
+ maxValueBytes: 512_000,
199
+ maxBlobBytes: 512_000,
200
+ maxBlobTotalBytes: 32 * 1024 * 1024,
201
+ });
202
+ this.repositoryCopies = this.copies.open(
203
+ "bonsai-repository",
204
+ repoDetail.omit({ repo: true }),
205
+ );
206
+ this.changeCopies = this.copies.open("bonsai-change", changeCopySchema);
207
+ }
208
+
209
+ repositoryRefStatus(repo: string) {
210
+ const row = this.rows(
211
+ z.object({
212
+ revision: z.number().int().nonnegative(),
213
+ pending: z.number().int(),
214
+ }),
215
+ "SELECT revision, pending FROM repository_ref_readiness WHERE repo = ?",
216
+ repo,
217
+ )[0];
218
+ return { revision: row?.revision ?? 0, pending: row?.pending === 1 };
219
+ }
220
+
221
+ /** A push blocks mutable readers before any provider work can fail. */
222
+ invalidateRepositoryRefs(repo: string): number {
223
+ this.sql.exec(
224
+ "INSERT INTO repository_ref_readiness(repo, revision, pending) VALUES(?,1,1) ON CONFLICT(repo) DO UPDATE SET revision=revision+1,pending=1",
225
+ repo,
226
+ );
227
+ return this.repositoryRefStatus(repo).revision;
228
+ }
229
+
230
+ /** Publish refs and clear only the invalidation this refresh observed. */
231
+ publishRepositoryRefs(
232
+ repo: string,
233
+ revision: number,
234
+ head: string | null,
235
+ value: Omit<z.output<typeof repoDetail>, "repo">,
236
+ ) {
237
+ return this.commit(() => {
238
+ if (this.repositoryRefStatus(repo).revision !== revision) return false;
239
+ this.repositoryCopies.write(repo, head, value);
240
+ this.sql.exec(
241
+ "UPDATE repository_ref_readiness SET pending=0 WHERE repo=? AND revision=?",
242
+ repo,
243
+ revision,
244
+ );
245
+ return true;
246
+ });
247
+ }
248
+
249
+ private rows<R>(
250
+ schema: z.ZodType<R>,
251
+ statement: string,
252
+ ...bindings: WatchdogSqliteValue[]
253
+ ): R[] {
254
+ return this.sql
255
+ .exec(statement, ...bindings)
256
+ .toArray()
257
+ .map((row) => schema.parse(row));
258
+ }
259
+
260
+ layout(actor: string, repo: string): InsightLayout | null {
261
+ const row = this.rows(
262
+ z.object({ tiles: z.string() }),
263
+ "SELECT tiles FROM insight_layouts WHERE actor = ? AND repo = ?",
264
+ actor,
265
+ repo,
266
+ )[0];
267
+ if (!row) return null;
268
+ const decoded = insightLayout.safeParse(JSON.parse(row.tiles));
269
+ return decoded.success ? decoded.data : null;
270
+ }
271
+
272
+ saveLayout(actor: string, repo: string, tiles: InsightLayout): void {
273
+ this.sql.exec(
274
+ "INSERT INTO insight_layouts (actor, repo, tiles) VALUES (?, ?, ?) ON CONFLICT(actor, repo) DO UPDATE SET tiles = excluded.tiles",
275
+ actor,
276
+ repo,
277
+ JSON.stringify(insightLayout.parse(tiles)),
278
+ );
279
+ }
280
+
281
+ /** Remember an actor; the first sighting assigns its palette slot. */
282
+ seeActor(name: string, kind: ActorKind): void {
283
+ const count = z
284
+ .object({ n: z.number() })
285
+ .parse(
286
+ this.rows(
287
+ z.object({ n: z.number() }),
288
+ "SELECT COUNT(*) AS n FROM actors",
289
+ )[0],
290
+ ).n;
291
+ this.sql.exec(
292
+ "INSERT OR IGNORE INTO actors (name, color, kind, first_seen) VALUES (?, ?, ?, ?)",
293
+ name,
294
+ `c${String(count % PALETTE_SIZE)}`,
295
+ kind,
296
+ Date.now(),
297
+ );
298
+ }
299
+
300
+ actors(): Agent[] {
301
+ return this.rows(
302
+ actorRow,
303
+ "SELECT name, color, kind, first_seen FROM actors ORDER BY first_seen",
304
+ ).map(toActor);
305
+ }
306
+
307
+ registerRepo(input: {
308
+ name: string;
309
+ description: string | null;
310
+ parent: string | null;
311
+ owner: string | null;
312
+ }): void {
313
+ this.commit(() => {
314
+ const present =
315
+ this.rows(
316
+ z.object({ name: z.string() }),
317
+ "SELECT name FROM repos WHERE name = ?",
318
+ input.name,
319
+ ).length > 0;
320
+ if (!present)
321
+ this.sql.exec(
322
+ "INSERT INTO repo_lifetimes(name, generation) VALUES (?,1) ON CONFLICT(name) DO UPDATE SET generation=generation+1",
323
+ input.name,
324
+ );
325
+ this.sql.exec(
326
+ "INSERT OR IGNORE INTO repos (name, description, parent, owner, created_at) VALUES (?, ?, ?, ?, ?)",
327
+ input.name,
328
+ input.description,
329
+ input.parent,
330
+ input.owner,
331
+ Date.now(),
332
+ );
333
+ });
334
+ }
335
+
336
+ /** Stable across pushes; advances only when an absent name is registered again. */
337
+ repositoryGeneration(name: string): number {
338
+ return (
339
+ this.rows(
340
+ z.object({ generation: z.number().int().nonnegative() }),
341
+ "SELECT generation FROM repo_lifetimes WHERE name = ?",
342
+ name,
343
+ )[0]?.generation ?? 0
344
+ );
345
+ }
346
+
347
+ /** Pending provider cleanup reserves the name; old publications cannot reuse it. */
348
+ assertRepoAvailable(name: string, revision?: number): void {
349
+ const pending =
350
+ this.rows(
351
+ z.object({ name: z.string() }),
352
+ "SELECT name FROM repo_deletions WHERE name = ?",
353
+ name,
354
+ ).length > 0;
355
+ if (
356
+ pending ||
357
+ (revision !== undefined &&
358
+ this.repositoryRefStatus(name).revision !== revision)
359
+ )
360
+ throw new AppMethodRefusal(
361
+ "conflict",
362
+ "Repository changed or deletion is pending; retry after cleanup",
363
+ );
364
+ }
365
+
366
+ /** Release the reserved name only after the provider confirms absence. */
367
+ completeRepoDeletion(name: string): void {
368
+ this.sql.exec("DELETE FROM repo_deletions WHERE name = ?", name);
369
+ }
370
+
371
+ /** Withdraw a repository atomically; keep its ref revision to fence old rebuilds. */
372
+ removeRepo(name: string): void {
373
+ this.commit(() => {
374
+ if (
375
+ this.rows(
376
+ z.object({ name: z.string() }),
377
+ "SELECT name FROM repo_deletions WHERE name = ?",
378
+ name,
379
+ ).length > 0
380
+ )
381
+ return;
382
+ if ((this.repo(name)?.forks.length ?? 0) > 0)
383
+ throw new AppMethodRefusal(
384
+ "conflict",
385
+ "Delete the repository's forks first",
386
+ );
387
+ this.sql.exec("INSERT INTO repo_deletions(name) VALUES (?)", name);
388
+ this.invalidateRepositoryRefs(name);
389
+ this.repositoryCopies.stale(name);
390
+ this.sql.exec(
391
+ "DELETE FROM outbox WHERE json_extract(envelope, '$.data.repository.name') = ? OR json_extract(envelope, '$.data.repository.canonical') = ?",
392
+ name,
393
+ name,
394
+ );
395
+ this.sql.exec(
396
+ "DELETE FROM approvals WHERE (repo, number) IN (SELECT repo, number FROM changes WHERE repo = ? OR source_repo = ?)",
397
+ name,
398
+ name,
399
+ );
400
+ this.sql.exec(
401
+ "DELETE FROM checks WHERE (repo, number) IN (SELECT repo, number FROM changes WHERE repo = ? OR source_repo = ?)",
402
+ name,
403
+ name,
404
+ );
405
+ this.sql.exec(
406
+ "DELETE FROM changes WHERE repo = ? OR source_repo = ?",
407
+ name,
408
+ name,
409
+ );
410
+ this.sql.exec(
411
+ "DELETE FROM events WHERE repo = ? OR json_extract(data, '$.fork') = ? OR json_extract(data, '$.change.sourceRepo') = ?",
412
+ name,
413
+ name,
414
+ name,
415
+ );
416
+ this.sql.exec("DELETE FROM commits WHERE repo = ?", name);
417
+ this.sql.exec("DELETE FROM insight_layouts WHERE repo = ?", name);
418
+ this.sql.exec("DELETE FROM repos WHERE name = ?", name);
419
+ });
420
+ }
421
+
422
+ private summaries(where: string, ...args: string[]): RepoSummary[] {
423
+ const rows = this.rows(
424
+ repoRow,
425
+ `SELECT name, description, parent, owner, created_at FROM repos ${where} ORDER BY created_at`,
426
+ ...args,
427
+ );
428
+ const since = Date.now() - 24 * HOUR;
429
+ return rows.map((row) => {
430
+ const forks = this.rows(
431
+ z.object({ name: z.string() }),
432
+ "SELECT name FROM repos WHERE parent = ? ORDER BY created_at",
433
+ row.name,
434
+ ).map((fork) => fork.name);
435
+ const family = [row.name, ...forks];
436
+ const marks = family.map(() => "?").join(",");
437
+ const pushes = this.rows(
438
+ z.object({ at: z.number() }),
439
+ `SELECT at FROM events WHERE type = 'push' AND repo IN (${marks}) AND at >= ?`,
440
+ ...family,
441
+ since,
442
+ );
443
+ const activity = Array.from({ length: 24 }, () => 0);
444
+ for (const push of pushes) {
445
+ const bucket = Math.min(23, Math.floor((push.at - since) / HOUR));
446
+ activity[bucket] = (activity[bucket] ?? 0) + 1;
447
+ }
448
+ const last =
449
+ this.rows(
450
+ z.object({ at: z.number().nullable() }),
451
+ `SELECT MAX(at) AS at FROM events WHERE type = 'push' AND repo IN (${marks})`,
452
+ ...family,
453
+ )[0]?.at ?? null;
454
+ return {
455
+ name: row.name,
456
+ description: row.description,
457
+ parent: row.parent,
458
+ owner: row.owner,
459
+ createdAt: row.created_at,
460
+ lastPushAt: last,
461
+ forks,
462
+ openChanges:
463
+ this.rows(
464
+ z.object({ n: z.number() }),
465
+ "SELECT COUNT(*) AS n FROM changes WHERE (repo = ? OR source_repo = ?) AND state IN ('open', 'merging')",
466
+ row.name,
467
+ row.name,
468
+ )[0]?.n ?? 0,
469
+ activity,
470
+ };
471
+ });
472
+ }
473
+
474
+ repos(): RepoSummary[] {
475
+ return this.summaries("WHERE parent IS NULL");
476
+ }
477
+
478
+ /** Saved canonical picker facts without summary, family or provider fan-out. */
479
+ repositoryOptions(): Pick<RepoSummary, "name" | "description">[] {
480
+ return this.rows(
481
+ repoRow.pick({ name: true, description: true }),
482
+ "SELECT name, description FROM repos WHERE parent IS NULL ORDER BY name",
483
+ );
484
+ }
485
+
486
+ repo(name: string): RepoSummary | null {
487
+ return this.summaries("WHERE name = ?", name)[0] ?? null;
488
+ }
489
+
490
+ /** The canonical repository a repository belongs to. */
491
+ canonicalOf(repo: string): string {
492
+ const row = this.rows(
493
+ z.object({ parent: z.string().nullable() }),
494
+ "SELECT parent FROM repos WHERE name = ?",
495
+ repo,
496
+ )[0];
497
+ return row?.parent ?? repo;
498
+ }
499
+
500
+ change(repo: string, number: number): ChangeRecord | null {
501
+ const row =
502
+ this.rows(
503
+ storedChange,
504
+ `SELECT ${changeColumns} FROM changes WHERE repo = ? AND number = ?`,
505
+ repo,
506
+ number,
507
+ )[0] ?? null;
508
+ return row === null ? null : this.mergeStatus(row);
509
+ }
510
+
511
+ private mergeStatus(row: ChangeRecord): ChangeRecord {
512
+ const failure = this.rows(
513
+ z.object({ reason: changeRecord.shape.mergeFailure.unwrap() }),
514
+ "SELECT reason FROM merge_failures WHERE repo=? AND number=?",
515
+ row.repo,
516
+ row.number,
517
+ )[0];
518
+ return failure === undefined
519
+ ? row
520
+ : { ...row, mergeFailure: failure.reason };
521
+ }
522
+
523
+ /** Terminal job projection can release only the lease belonging to that job. */
524
+ failMergeLease(
525
+ repo: string,
526
+ number: number,
527
+ leaseId: string,
528
+ reason: NonNullable<ChangeRecord["mergeFailure"]>,
529
+ ): boolean {
530
+ return this.commit(() => {
531
+ const row = this.change(repo, number);
532
+ if (row?.lease?.id !== leaseId) return false;
533
+ this.reviseChange(
534
+ {
535
+ ...row,
536
+ state: row.state === "closed" ? "closed" : "open",
537
+ lease: null,
538
+ mergeFailure: reason,
539
+ updatedAt: Date.now(),
540
+ },
541
+ row.revision,
542
+ );
543
+ return true;
544
+ });
545
+ }
546
+
547
+ changes(repo: string | null): ChangeRecord[] {
548
+ return this.rows(
549
+ storedChange,
550
+ `SELECT ${changeColumns} FROM changes WHERE (? IS NULL OR repo = ?) ORDER BY updated_at DESC, number DESC`,
551
+ repo,
552
+ repo,
553
+ ).map((row) => this.mergeStatus(row));
554
+ }
555
+
556
+ insertChange(input: Omit<ChangeRecord, "number" | "revision">): ChangeRecord {
557
+ const latest = this.rows(
558
+ z.object({ number: z.number() }),
559
+ "SELECT COALESCE(MAX(number),0) AS number FROM changes WHERE repo = ?",
560
+ input.repo,
561
+ )[0];
562
+ if (latest === undefined)
563
+ throw new Error("Missing Change number allocation");
564
+ const row = changeRecord.parse({
565
+ ...input,
566
+ number: latest.number + 1,
567
+ revision: 0,
568
+ });
569
+ this.sql.exec(
570
+ `INSERT INTO changes (repo, number, source_repo, source_ref, target_ref, title, body, opened_by, opened_kind, created_at, updated_at, state, head_sha, target_sha, revision, conflict, lease, merged_commit, merged_by, closed_by) VALUES (${Array.from({ length: 20 }, () => "?").join(",")})`,
571
+ row.repo,
572
+ row.number,
573
+ row.sourceRepo,
574
+ row.sourceRef,
575
+ row.targetRef,
576
+ row.title,
577
+ row.body,
578
+ row.openedBy,
579
+ row.openedKind,
580
+ row.createdAt,
581
+ row.updatedAt,
582
+ row.state,
583
+ row.head,
584
+ row.target,
585
+ row.revision,
586
+ row.conflict === null ? null : JSON.stringify(row.conflict),
587
+ row.lease === null ? null : JSON.stringify(row.lease),
588
+ row.mergedCommit,
589
+ row.mergedBy,
590
+ row.closedBy,
591
+ );
592
+ this.sql.exec(
593
+ "DELETE FROM merge_failures WHERE repo=? AND number=?",
594
+ row.repo,
595
+ row.number,
596
+ );
597
+ return row;
598
+ }
599
+
600
+ reviseChange(next: ChangeRecord, expectedRevision: number): ChangeRecord {
601
+ const row = changeRecord.parse({ ...next, revision: expectedRevision + 1 });
602
+ const changed = this.rows(
603
+ z.object({ number: z.number() }),
604
+ `UPDATE changes SET head_sha = ?, target_sha = ?, state = ?, conflict = ?, lease = ?, merged_commit = ?, merged_by = ?, closed_by = ?, updated_at = ?, revision = ? WHERE repo = ? AND number = ? AND revision = ? RETURNING number`,
605
+ row.head,
606
+ row.target,
607
+ row.state,
608
+ row.conflict === null ? null : JSON.stringify(row.conflict),
609
+ row.lease === null ? null : JSON.stringify(row.lease),
610
+ row.mergedCommit,
611
+ row.mergedBy,
612
+ row.closedBy,
613
+ row.updatedAt,
614
+ row.revision,
615
+ row.repo,
616
+ row.number,
617
+ expectedRevision,
618
+ );
619
+ if (changed.length !== 1) throw new Error("Change revision moved");
620
+ if (row.mergeFailure === undefined)
621
+ this.sql.exec(
622
+ "DELETE FROM merge_failures WHERE repo=? AND number=?",
623
+ row.repo,
624
+ row.number,
625
+ );
626
+ else
627
+ this.sql.exec(
628
+ "INSERT INTO merge_failures(repo,number,reason) VALUES(?,?,?) ON CONFLICT(repo,number) DO UPDATE SET reason=excluded.reason",
629
+ row.repo,
630
+ row.number,
631
+ row.mergeFailure,
632
+ );
633
+ return row;
634
+ }
635
+
636
+ approvals(repo: string, number: number): Approval[] {
637
+ return this.rows(
638
+ approval,
639
+ "SELECT actor, actor_kind AS kind, head_sha AS head, at FROM approvals WHERE repo = ? AND number = ? ORDER BY at",
640
+ repo,
641
+ number,
642
+ );
643
+ }
644
+
645
+ approve(repo: string, number: number, raw: Approval): void {
646
+ const row = approval.parse(raw);
647
+ this.sql.exec(
648
+ "INSERT OR IGNORE INTO approvals (repo,number,head_sha,actor,actor_kind,at) VALUES (?,?,?,?,?,?)",
649
+ repo,
650
+ number,
651
+ row.head,
652
+ row.actor,
653
+ row.kind,
654
+ row.at,
655
+ );
656
+ }
657
+
658
+ checks(repo: string, number: number): Check[] {
659
+ return this.rows(
660
+ check,
661
+ "SELECT head_sha AS head, name, status, reporter, attempt_id AS attemptId, run_url AS runUrl, summary, updated_at AS updatedAt FROM checks WHERE repo = ? AND number = ? ORDER BY name",
662
+ repo,
663
+ number,
664
+ );
665
+ }
666
+
667
+ reportCheck(repo: string, number: number, raw: Check): void {
668
+ const row = check.parse(raw);
669
+ this.sql.exec(
670
+ "INSERT INTO checks (repo,number,head_sha,name,status,reporter,attempt_id,run_url,summary,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) ON CONFLICT(repo,number,head_sha,name) DO UPDATE SET status=excluded.status,reporter=excluded.reporter,attempt_id=excluded.attempt_id,run_url=excluded.run_url,summary=excluded.summary,updated_at=excluded.updated_at",
671
+ repo,
672
+ number,
673
+ row.head,
674
+ row.name,
675
+ row.status,
676
+ row.reporter,
677
+ row.attemptId,
678
+ row.runUrl,
679
+ row.summary,
680
+ row.updatedAt,
681
+ );
682
+ }
683
+
684
+ requireChange(repo: string, number: number): ChangeRecord {
685
+ const row = this.change(repo, number);
686
+ if (row === null)
687
+ throw new AppMethodRefusal(
688
+ "not_found",
689
+ `No Change ${repo}#${String(number)}`,
690
+ );
691
+ return row;
692
+ }
693
+
694
+ changeEvent(
695
+ row: ChangeRecord,
696
+ actor: string,
697
+ type:
698
+ | "change.opened"
699
+ | "change.updated"
700
+ | "change.approved"
701
+ | "change.checks_requested"
702
+ | "change.checked"
703
+ | "change.closed",
704
+ cloneUrl: string,
705
+ details: Readonly<{
706
+ before?: string;
707
+ after?: string;
708
+ name?: string;
709
+ status?: "queued" | "running" | "passed" | "failed";
710
+ names?: string[];
711
+ }> = {},
712
+ ): HookEvent {
713
+ const stored = this.record(
714
+ activitySchema.parse({
715
+ id: 0,
716
+ at: row.updatedAt,
717
+ type,
718
+ repo: row.repo,
719
+ actor,
720
+ data: { change: changeIdentityOf(row), ...details },
721
+ }),
722
+ );
723
+ return activityEnvelope(stored, {
724
+ name: row.sourceRepo,
725
+ canonical: row.repo,
726
+ cloneUrl,
727
+ });
728
+ }
729
+
730
+ reviewFacts(
731
+ actor: PolicyFacts["actor"],
732
+ row: ChangeRecord,
733
+ head: string,
734
+ headAuthors: readonly string[],
735
+ ): PolicyFacts {
736
+ return {
737
+ actor,
738
+ change: row,
739
+ head,
740
+ headAuthors,
741
+ approvals: this.approvals(row.repo, row.number),
742
+ checks: this.checks(row.repo, row.number),
743
+ isMaintainer: actor.kind === "human",
744
+ mayPush: mayPush(actor, this.repo(row.sourceRepo)),
745
+ sourceOwner: this.repo(row.sourceRepo)?.owner ?? null,
746
+ soleMember: actor.kind === "human" && actor.soleMember === true,
747
+ };
748
+ }
749
+
750
+ /** Read a committed result and refuse a replay key with different input. */
751
+ receipt<R>(
752
+ key: string,
753
+ digest: string,
754
+ schema: z.ZodType<R>,
755
+ ): Readonly<{ result: R }> | null {
756
+ const row = this.rows(
757
+ receiptRow,
758
+ "SELECT digest, result FROM receipts WHERE key = ?",
759
+ key,
760
+ )[0];
761
+ if (row === undefined) return null;
762
+ if (row.digest !== digest)
763
+ throw new Error("Receipt key reused with different contents");
764
+ return { result: schema.parse(JSON.parse(row.result)) };
765
+ }
766
+
767
+ /** Commit domain changes, the replay result and every event atomically. */
768
+ transition<R>(
769
+ key: string,
770
+ digest: string,
771
+ schema: z.ZodType<R>,
772
+ build: () => Readonly<{ result: R; events: readonly HookEvent[] }>,
773
+ ): R {
774
+ return this.commit(() => {
775
+ const recorded = this.receipt(key, digest, schema);
776
+ if (recorded !== null) return recorded.result;
777
+ const built = build();
778
+ const result = schema.parse(built.result);
779
+ this.sql.exec(
780
+ "INSERT INTO receipts (key, digest, result) VALUES (?, ?, ?)",
781
+ key,
782
+ digest,
783
+ JSON.stringify(result),
784
+ );
785
+ for (const raw of built.events) {
786
+ const event = hookEvent.parse(raw);
787
+ this.sql.exec(
788
+ "INSERT INTO outbox (event_id, envelope) VALUES (?, ?)",
789
+ event.data.eventId,
790
+ JSON.stringify(event),
791
+ );
792
+ }
793
+ return result;
794
+ });
795
+ }
796
+
797
+ /** Resolve an opaque attempt identity against an actual committed envelope. */
798
+ event(eventId: string): HookEvent | null {
799
+ const row = this.rows(
800
+ z.object({ envelope: z.string() }),
801
+ "SELECT envelope FROM outbox WHERE event_id = ?",
802
+ eventId,
803
+ )[0];
804
+ return row === undefined ? null : hookEvent.parse(JSON.parse(row.envelope));
805
+ }
806
+
807
+ /** A delivery page, not a bound on how many envelopes a transition accepts. */
808
+ pendingEvents(): HookEvent[] {
809
+ return this.rows(
810
+ z.object({ envelope: z.string() }),
811
+ "SELECT envelope FROM outbox WHERE emitted = 0 ORDER BY rowid LIMIT 32",
812
+ ).map((row) => hookEvent.parse(JSON.parse(row.envelope)));
813
+ }
814
+
815
+ acknowledge(eventId: string): void {
816
+ this.sql.exec("UPDATE outbox SET emitted = 1 WHERE event_id = ?", eventId);
817
+ }
818
+
819
+ record(event: NewActivity): Activity {
820
+ const row = this.rows(
821
+ z.object({ id: z.number() }),
822
+ "INSERT INTO events (at, type, repo, actor, data) VALUES (?, ?, ?, ?, ?) RETURNING id",
823
+ event.at,
824
+ event.type,
825
+ event.repo,
826
+ event.actor,
827
+ JSON.stringify(event.data),
828
+ )[0];
829
+ const stored = activitySchema.parse({
830
+ ...event,
831
+ id: z.object({ id: z.number() }).parse(row).id,
832
+ });
833
+ if (stored.type === "push")
834
+ for (const item of stored.data.commits)
835
+ this.sql.exec(
836
+ "INSERT OR IGNORE INTO commits (hash, repo, agent, at) VALUES (?, ?, ?, ?)",
837
+ item.hash,
838
+ stored.repo,
839
+ stored.actor,
840
+ event.at,
841
+ );
842
+ return stored;
843
+ }
844
+
845
+ /** Activity for a repository family (the repo and its forks), newest first. */
846
+ events(repo: string | null, limit: number, after = 0): Activity[] {
847
+ const bounded = Math.max(1, Math.min(200, limit));
848
+ if (repo === null)
849
+ return this.rows(
850
+ eventRow,
851
+ "SELECT * FROM events WHERE id > ? ORDER BY id DESC LIMIT ?",
852
+ after,
853
+ bounded,
854
+ ).map(toActivity);
855
+ return this.rows(
856
+ eventRow,
857
+ "SELECT * FROM events WHERE id > ? AND (repo = ? OR repo IN (SELECT name FROM repos WHERE parent = ?)) ORDER BY id DESC LIMIT ?",
858
+ after,
859
+ repo,
860
+ repo,
861
+ bounded,
862
+ ).map(toActivity);
863
+ }
864
+
865
+ /** Where each commit was first pushed, for the commits Bonsai has seen. */
866
+ origins(hashes: readonly string[]): ReadonlyMap<string, CommitOrigin> {
867
+ const result = new Map<string, CommitOrigin>();
868
+ for (let start = 0; start < hashes.length; start += 90) {
869
+ const chunk = hashes.slice(start, start + 90);
870
+ const marks = chunk.map(() => "?").join(",");
871
+ for (const row of this.rows(
872
+ z.object({ hash: z.string(), agent: z.string(), repo: z.string() }),
873
+ `SELECT hash, agent, repo FROM commits WHERE hash IN (${marks}) ORDER BY at, rowid`,
874
+ ...chunk,
875
+ ))
876
+ if (!result.has(row.hash))
877
+ result.set(row.hash, {
878
+ hash: row.hash,
879
+ agent: row.agent,
880
+ repo: row.repo,
881
+ });
882
+ }
883
+ return result;
884
+ }
885
+ }
886
+
887
+ /** Deliver one page with stable IDs; failed delivery stays pending for the next write. */
888
+ export async function drainOutbox(
889
+ bonsai: Readonly<{ state: BonsaiState; events: EventSink }>,
890
+ ): Promise<
891
+ | { kind: "delivered"; delivered: number }
892
+ | { kind: "deferred"; delivered: number; cause: unknown }
893
+ > {
894
+ let delivered = 0;
895
+ try {
896
+ for (const event of bonsai.state.pendingEvents()) {
897
+ await bonsai.events.emit(
898
+ event.type,
899
+ {
900
+ type: event.type,
901
+ timestamp: event.timestamp,
902
+ data: z.json().parse(event.data),
903
+ },
904
+ event.data.eventId,
905
+ );
906
+ bonsai.state.acknowledge(event.data.eventId);
907
+ delivered += 1;
908
+ }
909
+ return { kind: "delivered", delivered };
910
+ } catch (cause) {
911
+ // Domain changes are already committed; preserve the failure for the delivery caller.
912
+ return { kind: "deferred", delivered, cause };
913
+ }
914
+ }