@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
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeGillOptionsResponse,
|
|
3
|
+
type GillOptionsResponse,
|
|
4
|
+
} from "@fungi.computer/gills";
|
|
5
|
+
import type { PickerOptionsInput } from "../shared/picker-options.js";
|
|
6
|
+
import { readRepositoryRefs } from "./repository-copy.js";
|
|
7
|
+
import {
|
|
8
|
+
copyListSnapshot,
|
|
9
|
+
readCopyPageOffset,
|
|
10
|
+
sliceReadCopyPage,
|
|
11
|
+
} from "./read-copy-page.js";
|
|
12
|
+
/**
|
|
13
|
+
* Bonsai's operations, independent of how they are called: App methods (for
|
|
14
|
+
* Agents and the host), the App's own UI, and push facts from the host all
|
|
15
|
+
* land here. Git goes through the GIT binding, events leave through the
|
|
16
|
+
* host's emit, and state lives in the App's SQLite.
|
|
17
|
+
*/
|
|
18
|
+
import * as z from "zod";
|
|
19
|
+
import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
|
|
20
|
+
import { replayKey } from "@fungi.computer/cairn";
|
|
21
|
+
import {
|
|
22
|
+
appGitPushFactSchema,
|
|
23
|
+
type AppGitPushFact,
|
|
24
|
+
} from "@fungi.computer/agar/git";
|
|
25
|
+
import { ReadCopyBudgetExceeded } from "@fungi.computer/agar/read-copy";
|
|
26
|
+
import type { EventSink } from "@fungi.computer/oyster/git";
|
|
27
|
+
import type {
|
|
28
|
+
Activity,
|
|
29
|
+
CommitDetail,
|
|
30
|
+
Graph,
|
|
31
|
+
IssueFiles,
|
|
32
|
+
RepoSummary,
|
|
33
|
+
ReadCopyPageInput,
|
|
34
|
+
} from "../shared/contract.js";
|
|
35
|
+
import type {
|
|
36
|
+
Commit,
|
|
37
|
+
CommitPage,
|
|
38
|
+
FileContent,
|
|
39
|
+
FileList,
|
|
40
|
+
TreeListing,
|
|
41
|
+
} from "@fungi.computer/agar/roles/files";
|
|
42
|
+
import { activityEnvelope, type HookEvent } from "../shared/hooks.js";
|
|
43
|
+
import { drainOutbox } from "./state.js";
|
|
44
|
+
import { AppMethodRefusal } from "@fungi.computer/agar/backend";
|
|
45
|
+
import { readCodeInsights } from "./insights.js";
|
|
46
|
+
import { reconcilePush } from "./changes.js";
|
|
47
|
+
import { refreshChangeCopy } from "./change-copy.js";
|
|
48
|
+
import { refreshRepositoryCopy } from "./repository-copy.js";
|
|
49
|
+
import { reachableCommits, projectionStore } from "./history.js";
|
|
50
|
+
import { changedPaths, decodeText, listPaths } from "./diff.js";
|
|
51
|
+
import { readCopyFrame, type GitStore } from "./git.js";
|
|
52
|
+
import {
|
|
53
|
+
listFilesCopy,
|
|
54
|
+
listTreeCopy,
|
|
55
|
+
readFileCopy,
|
|
56
|
+
invalidateFilesCopies,
|
|
57
|
+
filesCopyRequests,
|
|
58
|
+
refreshFilesCopy,
|
|
59
|
+
} from "./files-copy.js";
|
|
60
|
+
import {
|
|
61
|
+
readGraph,
|
|
62
|
+
readCommits,
|
|
63
|
+
readCommitDetail,
|
|
64
|
+
invalidateGraphCommitCopies,
|
|
65
|
+
graphCommitCopyKeys,
|
|
66
|
+
refreshGraphCommitCopies,
|
|
67
|
+
rebuildGraphCommitCopy,
|
|
68
|
+
} from "./graph-commit-copies.js";
|
|
69
|
+
import type { ActorKind, NewActivity, BonsaiState } from "./state.js";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Who is acting, as the host attributes the call. `soleMember` is the host's
|
|
73
|
+
* attestation that this person is currently their Team's only member; Bonsai
|
|
74
|
+
* never infers it from the people it has seen.
|
|
75
|
+
*/
|
|
76
|
+
export type BonsaiActor = Readonly<{
|
|
77
|
+
name: string;
|
|
78
|
+
kind: ActorKind;
|
|
79
|
+
soleMember?: true;
|
|
80
|
+
}>;
|
|
81
|
+
|
|
82
|
+
/** The host's EVENTS binding (Oyster's EventSink). */
|
|
83
|
+
export type { EventSink };
|
|
84
|
+
|
|
85
|
+
export type Bonsai = Readonly<{
|
|
86
|
+
store: GitStore;
|
|
87
|
+
state: BonsaiState;
|
|
88
|
+
events: EventSink;
|
|
89
|
+
}>;
|
|
90
|
+
|
|
91
|
+
const MAX_ISSUE_FILES = 200;
|
|
92
|
+
const MAX_ISSUE_BYTES = 64_000;
|
|
93
|
+
const HEX40 = /^[0-9a-f]{40}$/u;
|
|
94
|
+
const ZERO = "0".repeat(40);
|
|
95
|
+
export const repoNamePattern = /^[a-z0-9][a-z0-9._-]{0,62}$/u;
|
|
96
|
+
|
|
97
|
+
const recordedResult = z.literal(true);
|
|
98
|
+
|
|
99
|
+
/** Record activity and durable envelopes in the same local transaction. */
|
|
100
|
+
async function publish(
|
|
101
|
+
bonsai: Bonsai,
|
|
102
|
+
event: NewActivity,
|
|
103
|
+
mutate?: () => void,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const cloneUrl = await bonsai.store.cloneUrl(event.repo);
|
|
106
|
+
bonsai.state.transition(
|
|
107
|
+
`activity:${replayKey()}`,
|
|
108
|
+
JSON.stringify(event),
|
|
109
|
+
recordedResult,
|
|
110
|
+
() => {
|
|
111
|
+
mutate?.();
|
|
112
|
+
return {
|
|
113
|
+
result: true,
|
|
114
|
+
events: activityEvents(bonsai, bonsai.state.record(event), cloneUrl),
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function activityEvents(
|
|
121
|
+
bonsai: Bonsai,
|
|
122
|
+
stored: Activity,
|
|
123
|
+
cloneUrl: string,
|
|
124
|
+
): HookEvent[] {
|
|
125
|
+
return [
|
|
126
|
+
activityEnvelope(stored, {
|
|
127
|
+
name: stored.repo,
|
|
128
|
+
canonical: bonsai.state.canonicalOf(stored.repo),
|
|
129
|
+
cloneUrl,
|
|
130
|
+
}),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function known(bonsai: Bonsai, name: string): RepoSummary {
|
|
135
|
+
const summary = bonsai.state.repo(name);
|
|
136
|
+
if (summary === null)
|
|
137
|
+
throw new AppMethodRefusal("not_found", `No repository ${name}`);
|
|
138
|
+
return summary;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function resolve(
|
|
142
|
+
store: GitStore,
|
|
143
|
+
repo: string,
|
|
144
|
+
ref: string,
|
|
145
|
+
): Promise<Commit> {
|
|
146
|
+
const commit = HEX40.test(ref)
|
|
147
|
+
? await store.commit(repo, ref)
|
|
148
|
+
: ((await store.log(repo, ref, 1))[0] ?? null);
|
|
149
|
+
if (commit === null)
|
|
150
|
+
throw new AppMethodRefusal("not_found", `Unknown ref ${ref}`);
|
|
151
|
+
return commit;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type TreeTarget = Readonly<{ hash: string; type: "tree" | "blob" }>;
|
|
155
|
+
|
|
156
|
+
async function treeAt(
|
|
157
|
+
store: GitStore,
|
|
158
|
+
repo: string,
|
|
159
|
+
commit: Commit,
|
|
160
|
+
path: string,
|
|
161
|
+
): Promise<TreeTarget | null> {
|
|
162
|
+
let current: TreeTarget = { hash: commit.treeHash, type: "tree" };
|
|
163
|
+
for (const segment of path.split("/").filter(Boolean)) {
|
|
164
|
+
if (current.type !== "tree") return null;
|
|
165
|
+
const entries = await store.tree(repo, current.hash);
|
|
166
|
+
const entry = entries?.find((item) => item.name === segment);
|
|
167
|
+
if (!entry) return null;
|
|
168
|
+
if (entry.type === "tree") current = { hash: entry.hash, type: "tree" };
|
|
169
|
+
else if (entry.type === "blob")
|
|
170
|
+
current = { hash: entry.hash, type: "blob" };
|
|
171
|
+
else return null;
|
|
172
|
+
}
|
|
173
|
+
return current;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function listRepos(bonsai: Bonsai): RepoSummary[] {
|
|
177
|
+
return bonsai.state.repos();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Repository placement choices come solely from the saved local catalog. */
|
|
181
|
+
export async function repositoryOptions(
|
|
182
|
+
bonsai: Bonsai,
|
|
183
|
+
input: PickerOptionsInput,
|
|
184
|
+
): Promise<GillOptionsResponse> {
|
|
185
|
+
const rows = bonsai.state.repositoryOptions();
|
|
186
|
+
const snapshot = await copyListSnapshot(
|
|
187
|
+
rows.map((row) => [row.name, row.description]),
|
|
188
|
+
);
|
|
189
|
+
const scope = JSON.stringify(["repos.options"]);
|
|
190
|
+
const selected = sliceReadCopyPage(
|
|
191
|
+
rows,
|
|
192
|
+
input.limit,
|
|
193
|
+
readCopyPageOffset(input.after, scope, snapshot),
|
|
194
|
+
scope,
|
|
195
|
+
snapshot,
|
|
196
|
+
);
|
|
197
|
+
const result: GillOptionsResponse = {
|
|
198
|
+
options: selected.items.map((row) => {
|
|
199
|
+
const option: GillOptionsResponse["options"][number] = {
|
|
200
|
+
value: row.name,
|
|
201
|
+
label: row.name,
|
|
202
|
+
};
|
|
203
|
+
return row.description === null
|
|
204
|
+
? option
|
|
205
|
+
: { ...option, detail: row.description };
|
|
206
|
+
}),
|
|
207
|
+
};
|
|
208
|
+
return decodeGillOptionsResponse(
|
|
209
|
+
selected.next === null ? result : { ...result, next: selected.next },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Branch choices never rebuild or bypass the durable saved-ref readiness gate. */
|
|
214
|
+
export async function branchOptions(
|
|
215
|
+
bonsai: Bonsai,
|
|
216
|
+
input: PickerOptionsInput,
|
|
217
|
+
): Promise<GillOptionsResponse> {
|
|
218
|
+
const repo = input.params.repo;
|
|
219
|
+
if (!repo)
|
|
220
|
+
throw new AppMethodRefusal(
|
|
221
|
+
"invalid",
|
|
222
|
+
"Choose a repository before choosing a branch.",
|
|
223
|
+
);
|
|
224
|
+
const choices = () =>
|
|
225
|
+
readRepositoryRefs(bonsai, repo)
|
|
226
|
+
.refs.filter(
|
|
227
|
+
(ref) =>
|
|
228
|
+
ref.name.startsWith("refs/heads/") &&
|
|
229
|
+
ref.name.length - "refs/heads/".length <= 256,
|
|
230
|
+
)
|
|
231
|
+
.sort((a, b) => (a.name === b.name ? 0 : a.name < b.name ? -1 : 1));
|
|
232
|
+
const refs = choices();
|
|
233
|
+
const identity = (rows: typeof refs) =>
|
|
234
|
+
rows.map((row) => [row.name, row.hash]);
|
|
235
|
+
const identityJson = JSON.stringify(identity(refs));
|
|
236
|
+
const snapshot = await copyListSnapshot(identity(refs));
|
|
237
|
+
const scope = JSON.stringify(["branches.options", repo]);
|
|
238
|
+
const selected = sliceReadCopyPage(
|
|
239
|
+
refs,
|
|
240
|
+
input.limit,
|
|
241
|
+
readCopyPageOffset(input.after, scope, snapshot),
|
|
242
|
+
scope,
|
|
243
|
+
snapshot,
|
|
244
|
+
);
|
|
245
|
+
if (JSON.stringify(identity(choices())) !== identityJson)
|
|
246
|
+
throw new AppMethodRefusal(
|
|
247
|
+
"conflict",
|
|
248
|
+
"Branch options changed; restart from the first page.",
|
|
249
|
+
);
|
|
250
|
+
const result: GillOptionsResponse = {
|
|
251
|
+
options: selected.items.map((ref) => {
|
|
252
|
+
const name = ref.name.slice("refs/heads/".length);
|
|
253
|
+
return { value: name, label: name };
|
|
254
|
+
}),
|
|
255
|
+
};
|
|
256
|
+
return decodeGillOptionsResponse(
|
|
257
|
+
selected.next === null ? result : { ...result, next: selected.next },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function createRepo(
|
|
262
|
+
bonsai: Bonsai,
|
|
263
|
+
actor: BonsaiActor,
|
|
264
|
+
input: { name: string; description: string },
|
|
265
|
+
): Promise<RepoSummary> {
|
|
266
|
+
if (actor.kind !== "human")
|
|
267
|
+
throw new AppMethodRefusal("forbidden", "Only people create repositories");
|
|
268
|
+
assertRepoName(input.name);
|
|
269
|
+
bonsai.state.assertRepoAvailable(input.name);
|
|
270
|
+
const revision = bonsai.state.repositoryRefStatus(input.name).revision;
|
|
271
|
+
const info = await bonsai.store.create(input.name, input.description);
|
|
272
|
+
return recordRepo(bonsai, actor, input, info.defaultBranch, revision);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A repository name Bonsai can hold: lowercase, digits, dots, dashes; no `--`. */
|
|
276
|
+
export function assertRepoName(name: string): void {
|
|
277
|
+
if (!repoNamePattern.test(name) || name.includes("--"))
|
|
278
|
+
throw new AppMethodRefusal(
|
|
279
|
+
"invalid",
|
|
280
|
+
"Use lowercase letters, digits, dots, dashes; no --",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Record a forked App's source repository, which the host already created
|
|
285
|
+
* in this installation's Git, as an ordinary Bonsai repository its owner
|
|
286
|
+
* edits and merges (each merge emits `change.merged`). */
|
|
287
|
+
export async function recordAppFork(
|
|
288
|
+
bonsai: Bonsai,
|
|
289
|
+
actor: BonsaiActor,
|
|
290
|
+
input: { name: string; description: string },
|
|
291
|
+
revision: number,
|
|
292
|
+
): Promise<RepoSummary> {
|
|
293
|
+
const info = await bonsai.store.info(input.name);
|
|
294
|
+
if (info === null)
|
|
295
|
+
throw new AppMethodRefusal("unavailable", "The forked source is missing");
|
|
296
|
+
return recordRepo(bonsai, actor, input, info.defaultBranch, revision);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The repository-availability revision a later record must still match. */
|
|
300
|
+
export function repoRevision(bonsai: Bonsai, name: string): number {
|
|
301
|
+
bonsai.state.assertRepoAvailable(name);
|
|
302
|
+
return bonsai.state.repositoryRefStatus(name).revision;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function recordRepo(
|
|
306
|
+
bonsai: Bonsai,
|
|
307
|
+
actor: BonsaiActor,
|
|
308
|
+
input: { name: string; description: string },
|
|
309
|
+
defaultBranch: string,
|
|
310
|
+
revision: number,
|
|
311
|
+
): Promise<RepoSummary> {
|
|
312
|
+
const refs = await bonsai.store.refs(input.name);
|
|
313
|
+
const cloneUrl = await bonsai.store.cloneUrl(input.name);
|
|
314
|
+
await publish(
|
|
315
|
+
bonsai,
|
|
316
|
+
{
|
|
317
|
+
at: Date.now(),
|
|
318
|
+
type: "repo.created",
|
|
319
|
+
repo: input.name,
|
|
320
|
+
actor: actor.name,
|
|
321
|
+
data: {},
|
|
322
|
+
},
|
|
323
|
+
() => {
|
|
324
|
+
bonsai.state.assertRepoAvailable(input.name, revision);
|
|
325
|
+
bonsai.state.registerRepo({
|
|
326
|
+
name: input.name,
|
|
327
|
+
description: input.description || null,
|
|
328
|
+
parent: null,
|
|
329
|
+
owner: null,
|
|
330
|
+
});
|
|
331
|
+
bonsai.state.seeActor(actor.name, actor.kind);
|
|
332
|
+
bonsai.state.publishRepositoryRefs(
|
|
333
|
+
input.name,
|
|
334
|
+
revision,
|
|
335
|
+
refs.find((ref) => ref.name === `refs/heads/${defaultBranch}`)?.hash ??
|
|
336
|
+
null,
|
|
337
|
+
{
|
|
338
|
+
defaultBranch,
|
|
339
|
+
refs: refs.filter((ref) => ref.name !== "HEAD"),
|
|
340
|
+
cloneUrl,
|
|
341
|
+
empty: refs.length === 0,
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
return known(bonsai, input.name);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const deletions = new WeakMap<
|
|
350
|
+
BonsaiState,
|
|
351
|
+
Map<string, Promise<{ deleted: true }>>
|
|
352
|
+
>();
|
|
353
|
+
|
|
354
|
+
/** Withdraw local metadata, then retry provider cleanup under the same owner. */
|
|
355
|
+
export async function deleteRepo(
|
|
356
|
+
bonsai: Bonsai,
|
|
357
|
+
caller: AppMethodCallContext,
|
|
358
|
+
name: string,
|
|
359
|
+
): Promise<{ deleted: true }> {
|
|
360
|
+
if (caller.actor.kind !== "member" || caller.role !== "owner")
|
|
361
|
+
throw new AppMethodRefusal(
|
|
362
|
+
"forbidden",
|
|
363
|
+
"Only the owner can delete repositories",
|
|
364
|
+
);
|
|
365
|
+
const active =
|
|
366
|
+
deletions.get(bonsai.state) ??
|
|
367
|
+
new Map<string, Promise<{ deleted: true }>>();
|
|
368
|
+
deletions.set(bonsai.state, active);
|
|
369
|
+
const existing = active.get(name);
|
|
370
|
+
if (existing) return existing;
|
|
371
|
+
const cleanup = async (): Promise<{ deleted: true }> => {
|
|
372
|
+
try {
|
|
373
|
+
const current = bonsai.state.repo(name);
|
|
374
|
+
if ((current?.forks.length ?? 0) > 0)
|
|
375
|
+
throw new AppMethodRefusal(
|
|
376
|
+
"conflict",
|
|
377
|
+
"Delete the repository's forks first",
|
|
378
|
+
);
|
|
379
|
+
if (current !== null) invalidateGraphCommitCopies(bonsai, name);
|
|
380
|
+
invalidateFilesCopies(bonsai, name);
|
|
381
|
+
bonsai.state.removeRepo(name);
|
|
382
|
+
await bonsai.store.binding.delete(name);
|
|
383
|
+
bonsai.state.completeRepoDeletion(name);
|
|
384
|
+
return { deleted: true };
|
|
385
|
+
} finally {
|
|
386
|
+
active.delete(name);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
const pending = Promise.resolve().then(cleanup);
|
|
390
|
+
active.set(name, pending);
|
|
391
|
+
return pending;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Give an actor its own fork of a canonical repository (idempotent). */
|
|
395
|
+
export async function forkFor(
|
|
396
|
+
bonsai: Bonsai,
|
|
397
|
+
actor: BonsaiActor,
|
|
398
|
+
name: string,
|
|
399
|
+
): Promise<RepoSummary> {
|
|
400
|
+
const source = known(bonsai, name);
|
|
401
|
+
if (source.parent !== null)
|
|
402
|
+
throw new AppMethodRefusal("invalid", "Fork the canonical repository");
|
|
403
|
+
const forkName = `${source.name}--${actor.name}`;
|
|
404
|
+
bonsai.state.assertRepoAvailable(forkName);
|
|
405
|
+
const revision = bonsai.state.repositoryRefStatus(forkName).revision;
|
|
406
|
+
const sourceRevision = bonsai.state.repositoryRefStatus(source.name).revision;
|
|
407
|
+
const existing = bonsai.state.repo(forkName);
|
|
408
|
+
if (existing !== null) return existing;
|
|
409
|
+
// The store returns an existing destination (an earlier attempt may have
|
|
410
|
+
// created it before failing); adopt it only when it is a fork of source.
|
|
411
|
+
const info = await bonsai.store.fork(
|
|
412
|
+
source.name,
|
|
413
|
+
forkName,
|
|
414
|
+
`${actor.name}'s fork of ${source.name}`,
|
|
415
|
+
);
|
|
416
|
+
if (info.source !== source.name)
|
|
417
|
+
throw new AppMethodRefusal(
|
|
418
|
+
"conflict",
|
|
419
|
+
`A repository named ${forkName} exists and is not a fork of ${source.name}`,
|
|
420
|
+
);
|
|
421
|
+
const refs = await bonsai.store.refs(forkName);
|
|
422
|
+
const cloneUrl = await bonsai.store.cloneUrl(forkName);
|
|
423
|
+
await publish(
|
|
424
|
+
bonsai,
|
|
425
|
+
{
|
|
426
|
+
at: Date.now(),
|
|
427
|
+
type: "fork",
|
|
428
|
+
repo: source.name,
|
|
429
|
+
actor: actor.name,
|
|
430
|
+
data: { fork: forkName },
|
|
431
|
+
},
|
|
432
|
+
() => {
|
|
433
|
+
bonsai.state.assertRepoAvailable(forkName, revision);
|
|
434
|
+
bonsai.state.assertRepoAvailable(source.name, sourceRevision);
|
|
435
|
+
known(bonsai, source.name);
|
|
436
|
+
bonsai.state.registerRepo({
|
|
437
|
+
name: forkName,
|
|
438
|
+
description: null,
|
|
439
|
+
parent: source.name,
|
|
440
|
+
owner: actor.name,
|
|
441
|
+
});
|
|
442
|
+
bonsai.state.seeActor(actor.name, actor.kind);
|
|
443
|
+
bonsai.state.publishRepositoryRefs(
|
|
444
|
+
forkName,
|
|
445
|
+
revision,
|
|
446
|
+
refs.find((ref) => ref.name === `refs/heads/${info.defaultBranch}`)
|
|
447
|
+
?.hash ?? null,
|
|
448
|
+
{
|
|
449
|
+
defaultBranch: info.defaultBranch,
|
|
450
|
+
refs: refs.filter((ref) => ref.name !== "HEAD"),
|
|
451
|
+
cloneUrl,
|
|
452
|
+
empty: refs.length === 0,
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
return known(bonsai, forkName);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function listTree(
|
|
461
|
+
bonsai: Bonsai,
|
|
462
|
+
input: { repo: string; ref: string; path: string },
|
|
463
|
+
): Promise<TreeListing> {
|
|
464
|
+
return readCopyFrame(bonsai.store, (store) =>
|
|
465
|
+
listTreeCopy({ ...bonsai, store }, input),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function listFiles(
|
|
470
|
+
bonsai: Bonsai,
|
|
471
|
+
input: { repo: string; ref: string },
|
|
472
|
+
): Promise<FileList> {
|
|
473
|
+
return readCopyFrame(bonsai.store, (store) =>
|
|
474
|
+
listFilesCopy({ ...bonsai, store }, input),
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export async function readFile(
|
|
479
|
+
bonsai: Bonsai,
|
|
480
|
+
input: { repo: string; ref: string; path: string },
|
|
481
|
+
): Promise<FileContent> {
|
|
482
|
+
return readCopyFrame(bonsai.store, (store) =>
|
|
483
|
+
readFileCopy({ ...bonsai, store }, input),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export async function readIssueFiles(
|
|
488
|
+
bonsai: Bonsai,
|
|
489
|
+
input: { repo: string; ref: string },
|
|
490
|
+
): Promise<IssueFiles> {
|
|
491
|
+
const repo = known(bonsai, input.repo).name;
|
|
492
|
+
const store = bonsai.store;
|
|
493
|
+
const commit = await resolve(store, repo, input.ref);
|
|
494
|
+
const root = await treeAt(store, repo, commit, "issues");
|
|
495
|
+
const files: Record<string, string> = {};
|
|
496
|
+
let truncated = false;
|
|
497
|
+
if (root?.type === "tree") {
|
|
498
|
+
const listing = await listPaths(store, repo, root.hash);
|
|
499
|
+
const markdown = listing.paths.filter((path) => path.endsWith(".md"));
|
|
500
|
+
truncated = listing.truncated || markdown.length > MAX_ISSUE_FILES;
|
|
501
|
+
for (const path of markdown.slice(0, MAX_ISSUE_FILES)) {
|
|
502
|
+
const target = await treeAt(store, repo, commit, `issues/${path}`);
|
|
503
|
+
const bytes =
|
|
504
|
+
target?.type === "blob" ? await store.blob(repo, target.hash) : null;
|
|
505
|
+
const text =
|
|
506
|
+
bytes === null || bytes.length > MAX_ISSUE_BYTES
|
|
507
|
+
? null
|
|
508
|
+
: decodeText(bytes);
|
|
509
|
+
if (text?.kind === "text") files[path] = text.text;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return { ref: input.ref, commit, files, truncated };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function listCommits(
|
|
516
|
+
bonsai: Bonsai,
|
|
517
|
+
input: { repo: string; ref: string } & ReadCopyPageInput,
|
|
518
|
+
): Promise<CommitPage> {
|
|
519
|
+
return readCopyFrame(bonsai.store, (store) =>
|
|
520
|
+
readCommits({ ...bonsai, store }, input),
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function getCommitDetail(
|
|
525
|
+
bonsai: Bonsai,
|
|
526
|
+
input: { repo: string; hash: string },
|
|
527
|
+
): Promise<CommitDetail> {
|
|
528
|
+
return readCopyFrame(bonsai.store, (store) =>
|
|
529
|
+
readCommitDetail({ ...bonsai, store }, input),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export async function buildGraph(bonsai: Bonsai, name: string): Promise<Graph> {
|
|
534
|
+
return readCopyFrame(bonsai.store, (store) =>
|
|
535
|
+
readGraph({ ...bonsai, store }, name),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function listActivity(
|
|
540
|
+
bonsai: Bonsai,
|
|
541
|
+
input: { repo: string | null; limit: number; after: number },
|
|
542
|
+
): Activity[] {
|
|
543
|
+
return bonsai.state.events(input.repo, input.limit, input.after);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Stable host principal attribution; display names never determine ownership. */
|
|
547
|
+
function pushActor(writer: AppGitPushFact["writer"]): BonsaiActor {
|
|
548
|
+
if (writer.kind === "installation" && writer.onBehalfOf === null)
|
|
549
|
+
return { name: writer.installationId, kind: "agent" };
|
|
550
|
+
const actor = writer.kind === "actor" ? writer.actor : writer.onBehalfOf;
|
|
551
|
+
if (actor === null)
|
|
552
|
+
throw new AppMethodRefusal("invalid", "Push caller unavailable");
|
|
553
|
+
switch (actor.kind) {
|
|
554
|
+
case "member":
|
|
555
|
+
return { name: actor.memberId, kind: "human" };
|
|
556
|
+
case "agent":
|
|
557
|
+
return { name: actor.agentId, kind: "agent" };
|
|
558
|
+
case "team-key":
|
|
559
|
+
return { name: actor.keyId, kind: "agent" };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* A push the host accepted for one of Bonsai's repositories. Records which
|
|
565
|
+
* commits are new work (commits Bonsai already saw elsewhere, such as a fork
|
|
566
|
+
* catching up with main, are not), the paths touched, and emits git.push.
|
|
567
|
+
*/
|
|
568
|
+
export async function observePush(
|
|
569
|
+
bonsai: Bonsai,
|
|
570
|
+
fact: AppGitPushFact,
|
|
571
|
+
): Promise<void> {
|
|
572
|
+
await readCopyFrame(bonsai.store, (store) => {
|
|
573
|
+
const runtime = { ...bonsai, store };
|
|
574
|
+
return observePushFrame(
|
|
575
|
+
{ ...runtime, store: projectionStore(runtime) },
|
|
576
|
+
fact,
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
await drainOutbox(bonsai);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** A new branch's paths are the ones it changes from the repository's
|
|
583
|
+
* default head, not its whole tree; the default branch itself, or a first
|
|
584
|
+
* push, has no base. The head is read once per push, however many branches
|
|
585
|
+
* it creates. */
|
|
586
|
+
function defaultBase(bonsai: Bonsai, repo: string) {
|
|
587
|
+
let base:
|
|
588
|
+
| Promise<Readonly<{ branch: string; commit: Commit | null }> | null>
|
|
589
|
+
| undefined;
|
|
590
|
+
const read = async () => {
|
|
591
|
+
const info = await bonsai.store.info(repo);
|
|
592
|
+
if (info === null) return null;
|
|
593
|
+
const head = (await bonsai.store.refs(repo)).find(
|
|
594
|
+
(ref) => ref.name === `refs/heads/${info.defaultBranch}`,
|
|
595
|
+
)?.hash;
|
|
596
|
+
return {
|
|
597
|
+
branch: `refs/heads/${info.defaultBranch}`,
|
|
598
|
+
commit: head === undefined ? null : await bonsai.store.commit(repo, head),
|
|
599
|
+
};
|
|
600
|
+
};
|
|
601
|
+
return async (ref: string) => {
|
|
602
|
+
base ??= read();
|
|
603
|
+
const resolved = await base;
|
|
604
|
+
return resolved === null || resolved.branch === ref
|
|
605
|
+
? null
|
|
606
|
+
: resolved.commit;
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function observePushFrame(
|
|
611
|
+
bonsai: Bonsai,
|
|
612
|
+
raw: AppGitPushFact,
|
|
613
|
+
): Promise<void> {
|
|
614
|
+
const fact = appGitPushFactSchema.parse(raw);
|
|
615
|
+
const key = `push:${fact.eventId}`;
|
|
616
|
+
const digest = JSON.stringify(fact);
|
|
617
|
+
if (bonsai.state.receipt(key, digest, recordedResult) !== null) return;
|
|
618
|
+
const repo = known(bonsai, fact.repository).name;
|
|
619
|
+
const refsRevision = bonsai.state.invalidateRepositoryRefs(repo);
|
|
620
|
+
invalidateGraphCommitCopies(bonsai, repo);
|
|
621
|
+
invalidateFilesCopies(bonsai, repo);
|
|
622
|
+
const actor = pushActor(fact.writer);
|
|
623
|
+
const commits: { hash: string; message: string }[] = [];
|
|
624
|
+
const commitsByRef = new Map<string, string[]>();
|
|
625
|
+
const paths = new Set<string>();
|
|
626
|
+
const branchBase = defaultBase(bonsai, repo);
|
|
627
|
+
for (const update of fact.updates) {
|
|
628
|
+
if (update.after === ZERO) continue;
|
|
629
|
+
const history = await bonsai.store.log(repo, update.after, 50);
|
|
630
|
+
const branchCommits: string[] = [];
|
|
631
|
+
commitsByRef.set(update.ref, branchCommits);
|
|
632
|
+
for (const commit of history) {
|
|
633
|
+
if (commit.hash === update.before) break;
|
|
634
|
+
branchCommits.push(commit.hash);
|
|
635
|
+
commits.push({
|
|
636
|
+
hash: commit.hash,
|
|
637
|
+
message: commit.message.split("\n")[0] ?? "",
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
const before =
|
|
641
|
+
update.before === ZERO
|
|
642
|
+
? await branchBase(update.ref)
|
|
643
|
+
: await bonsai.store.commit(repo, update.before);
|
|
644
|
+
const after = history[0];
|
|
645
|
+
if (after)
|
|
646
|
+
for (const changed of await changedPaths(
|
|
647
|
+
bonsai.store,
|
|
648
|
+
repo,
|
|
649
|
+
before?.treeHash ?? null,
|
|
650
|
+
after.treeHash,
|
|
651
|
+
))
|
|
652
|
+
paths.add(changed.path);
|
|
653
|
+
}
|
|
654
|
+
const cloneUrl = await bonsai.store.cloneUrl(repo);
|
|
655
|
+
const canonical = bonsai.state.canonicalOf(repo);
|
|
656
|
+
const summary = known(bonsai, canonical);
|
|
657
|
+
const cloneUrls = new Map(
|
|
658
|
+
await Promise.all(
|
|
659
|
+
[canonical, ...summary.forks].map(
|
|
660
|
+
async (name) => [name, await bonsai.store.cloneUrl(name)] as const,
|
|
661
|
+
),
|
|
662
|
+
),
|
|
663
|
+
);
|
|
664
|
+
const targetHistory = new Map<string, ReadonlySet<string>>();
|
|
665
|
+
if (repo === canonical)
|
|
666
|
+
for (const update of fact.updates) {
|
|
667
|
+
if (update.ref.startsWith("refs/heads/") && update.after !== ZERO)
|
|
668
|
+
targetHistory.set(
|
|
669
|
+
update.ref,
|
|
670
|
+
await reachableCommits(bonsai.store, repo, update.after),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
const info = await bonsai.store.info(canonical);
|
|
674
|
+
if (info === null)
|
|
675
|
+
throw new AppMethodRefusal("not_found", "The target repository is missing");
|
|
676
|
+
const defaultTarget =
|
|
677
|
+
(await bonsai.store.refs(canonical)).find(
|
|
678
|
+
(ref) => ref.name === `refs/heads/${info.defaultBranch}`,
|
|
679
|
+
)?.hash ?? ZERO;
|
|
680
|
+
const repoInfo = repo === canonical ? info : await bonsai.store.info(repo);
|
|
681
|
+
if (repoInfo === null)
|
|
682
|
+
throw new AppMethodRefusal("not_found", "Repository storage is missing");
|
|
683
|
+
const repoRefs = await bonsai.store.refs(repo);
|
|
684
|
+
const beforeCopies = new Set(
|
|
685
|
+
bonsai.state
|
|
686
|
+
.changes(canonical)
|
|
687
|
+
.map((row) =>
|
|
688
|
+
JSON.stringify([row.repo, row.sourceRepo, row.head, row.target]),
|
|
689
|
+
),
|
|
690
|
+
);
|
|
691
|
+
bonsai.state.transition(key, digest, recordedResult, () => {
|
|
692
|
+
if (bonsai.state.repositoryRefStatus(repo).revision !== refsRevision)
|
|
693
|
+
throw new AppMethodRefusal(
|
|
694
|
+
"unavailable",
|
|
695
|
+
"Repository refs changed during push preparation.",
|
|
696
|
+
);
|
|
697
|
+
const metadata = {
|
|
698
|
+
defaultBranch: repoInfo.defaultBranch,
|
|
699
|
+
refs: repoRefs.filter((ref) => ref.name !== "HEAD"),
|
|
700
|
+
cloneUrl,
|
|
701
|
+
empty: repoRefs.length === 0,
|
|
702
|
+
};
|
|
703
|
+
const defaultUpdate = fact.updates.find(
|
|
704
|
+
(update) => update.ref === `refs/heads/${repoInfo.defaultBranch}`,
|
|
705
|
+
);
|
|
706
|
+
if (defaultUpdate !== undefined)
|
|
707
|
+
bonsai.state.repositoryCopies.apply(repo, {
|
|
708
|
+
...defaultUpdate,
|
|
709
|
+
value: metadata,
|
|
710
|
+
});
|
|
711
|
+
else
|
|
712
|
+
bonsai.state.repositoryCopies.write(
|
|
713
|
+
repo,
|
|
714
|
+
repoRefs.find(
|
|
715
|
+
(ref) => ref.name === `refs/heads/${repoInfo.defaultBranch}`,
|
|
716
|
+
)?.hash ?? null,
|
|
717
|
+
metadata,
|
|
718
|
+
);
|
|
719
|
+
const ready = bonsai.state.repositoryCopies.read(repo);
|
|
720
|
+
if (ready.kind === "ready")
|
|
721
|
+
bonsai.state.publishRepositoryRefs(
|
|
722
|
+
repo,
|
|
723
|
+
refsRevision,
|
|
724
|
+
ready.head,
|
|
725
|
+
metadata,
|
|
726
|
+
);
|
|
727
|
+
const seen = bonsai.state.origins(commits.map((commit) => commit.hash));
|
|
728
|
+
bonsai.state.seeActor(actor.name, actor.kind);
|
|
729
|
+
const newCommits = [
|
|
730
|
+
...new Map(
|
|
731
|
+
commits
|
|
732
|
+
.filter((commit) => !seen.has(commit.hash))
|
|
733
|
+
.map((commit) => [commit.hash, commit]),
|
|
734
|
+
).values(),
|
|
735
|
+
];
|
|
736
|
+
const events = activityEvents(
|
|
737
|
+
bonsai,
|
|
738
|
+
bonsai.state.record({
|
|
739
|
+
at: Date.now(),
|
|
740
|
+
type: "push",
|
|
741
|
+
repo,
|
|
742
|
+
actor: actor.name,
|
|
743
|
+
data: {
|
|
744
|
+
updates: [...fact.updates],
|
|
745
|
+
commits: newCommits,
|
|
746
|
+
paths: [...paths].slice(0, 200),
|
|
747
|
+
},
|
|
748
|
+
}),
|
|
749
|
+
cloneUrl,
|
|
750
|
+
);
|
|
751
|
+
events.push(
|
|
752
|
+
...reconcilePush(bonsai, {
|
|
753
|
+
repo,
|
|
754
|
+
canonical,
|
|
755
|
+
actor,
|
|
756
|
+
updates: fact.updates,
|
|
757
|
+
newCommits,
|
|
758
|
+
commitsByRef,
|
|
759
|
+
cloneUrls,
|
|
760
|
+
targetHistory,
|
|
761
|
+
defaultBranch: info.defaultBranch,
|
|
762
|
+
defaultTarget,
|
|
763
|
+
}),
|
|
764
|
+
);
|
|
765
|
+
return { result: true, events };
|
|
766
|
+
});
|
|
767
|
+
for (const row of bonsai.state.changes(canonical)) {
|
|
768
|
+
if (
|
|
769
|
+
!beforeCopies.has(
|
|
770
|
+
JSON.stringify([row.repo, row.sourceRepo, row.head, row.target]),
|
|
771
|
+
)
|
|
772
|
+
) {
|
|
773
|
+
try {
|
|
774
|
+
await refreshChangeCopy(bonsai, row);
|
|
775
|
+
} catch (error) {
|
|
776
|
+
if (!(error instanceof ReadCopyBudgetExceeded)) throw error;
|
|
777
|
+
bonsai.state.changeCopies.stale(
|
|
778
|
+
JSON.stringify([row.repo, row.sourceRepo, row.head, row.target]),
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (!bonsai.state.repositoryRefStatus(repo).pending) {
|
|
784
|
+
try {
|
|
785
|
+
await refreshGraphCommitCopies(bonsai, repo);
|
|
786
|
+
for (const request of filesCopyRequests(bonsai, repo)) {
|
|
787
|
+
try {
|
|
788
|
+
await refreshFilesCopy(bonsai, request);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
if (!(
|
|
791
|
+
error instanceof AppMethodRefusal && error.code === "not_found"
|
|
792
|
+
))
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
} catch (error) {
|
|
797
|
+
if (
|
|
798
|
+
!(error instanceof ReadCopyBudgetExceeded) &&
|
|
799
|
+
!(error instanceof AppMethodRefusal && error.code === "unavailable")
|
|
800
|
+
)
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export function getCodeInsights(
|
|
807
|
+
bonsai: Bonsai,
|
|
808
|
+
input: { repo: string; ref: string },
|
|
809
|
+
) {
|
|
810
|
+
known(bonsai, input.repo);
|
|
811
|
+
return readCodeInsights(bonsai.store, input.repo, input.ref);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** One selected foreground key shares a single frame and resumes after exhaustion. */
|
|
815
|
+
export async function rebuildCopies(bonsai: Bonsai, cursor: string | null) {
|
|
816
|
+
const repos = bonsai.state
|
|
817
|
+
.repos()
|
|
818
|
+
.flatMap((repo) => [repo.name, ...repo.forks]);
|
|
819
|
+
const pending = repos.find(
|
|
820
|
+
(repo) =>
|
|
821
|
+
bonsai.state.repositoryRefStatus(repo).pending ||
|
|
822
|
+
bonsai.state.repositoryCopies.read(repo).kind !== "ready",
|
|
823
|
+
);
|
|
824
|
+
const items = [
|
|
825
|
+
...repos.map((repo) => ({
|
|
826
|
+
key: `0:repo:${repo}`,
|
|
827
|
+
run: async (runtime: Bonsai, signal: AbortSignal) => {
|
|
828
|
+
const detail = await refreshRepositoryCopy(runtime, repo, signal);
|
|
829
|
+
for (const ref of detail.refs)
|
|
830
|
+
if (ref.name.startsWith("refs/heads/"))
|
|
831
|
+
await reachableCommits(runtime.store, repo, ref.hash);
|
|
832
|
+
return true;
|
|
833
|
+
},
|
|
834
|
+
})),
|
|
835
|
+
...bonsai.state.changes(null).map((row) => ({
|
|
836
|
+
key: `1:change:${row.repo}:${String(row.number).padStart(10, "0")}`,
|
|
837
|
+
run: async (runtime: Bonsai) => {
|
|
838
|
+
await refreshChangeCopy(runtime, row);
|
|
839
|
+
return true;
|
|
840
|
+
},
|
|
841
|
+
})),
|
|
842
|
+
...(pending === undefined
|
|
843
|
+
? bonsai.state.repos().flatMap((repo) =>
|
|
844
|
+
graphCommitCopyKeys(bonsai, repo.name).map((key) => ({
|
|
845
|
+
key: `2:graph:${key}`,
|
|
846
|
+
run: (runtime: Bonsai) => rebuildGraphCommitCopy(runtime, key),
|
|
847
|
+
})),
|
|
848
|
+
)
|
|
849
|
+
: []),
|
|
850
|
+
...repos.flatMap((repo) =>
|
|
851
|
+
filesCopyRequests(bonsai, repo).map((request) => ({
|
|
852
|
+
key: `3:files:${JSON.stringify(request)}`,
|
|
853
|
+
run: async (runtime: Bonsai) => {
|
|
854
|
+
try {
|
|
855
|
+
await refreshFilesCopy(runtime, request);
|
|
856
|
+
} catch (error) {
|
|
857
|
+
if (!(
|
|
858
|
+
error instanceof AppMethodRefusal && error.code === "not_found"
|
|
859
|
+
))
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
return true;
|
|
863
|
+
},
|
|
864
|
+
})),
|
|
865
|
+
),
|
|
866
|
+
].sort((a, b) => a.key.localeCompare(b.key));
|
|
867
|
+
const start = pending === undefined ? cursor : `0:repo:${pending}`;
|
|
868
|
+
const index =
|
|
869
|
+
start === null
|
|
870
|
+
? 0
|
|
871
|
+
: items.findIndex((item) => item.key.localeCompare(start) >= 0);
|
|
872
|
+
const item = items[index];
|
|
873
|
+
if (item === undefined) return { done: true, cursor: null };
|
|
874
|
+
try {
|
|
875
|
+
const complete = await readCopyFrame(bonsai.store, (store, signal) => {
|
|
876
|
+
const runtime = { ...bonsai, store };
|
|
877
|
+
return item.run({ ...runtime, store: projectionStore(runtime) }, signal);
|
|
878
|
+
});
|
|
879
|
+
if (!complete) return { done: false, cursor: item.key };
|
|
880
|
+
} catch (error) {
|
|
881
|
+
if (
|
|
882
|
+
error instanceof ReadCopyBudgetExceeded ||
|
|
883
|
+
(error instanceof AppMethodRefusal && error.code === "unavailable")
|
|
884
|
+
)
|
|
885
|
+
return { done: false, cursor: item.key };
|
|
886
|
+
throw error;
|
|
887
|
+
}
|
|
888
|
+
// Re-enumerate after metadata pages so newly discovered refs enter the later phase.
|
|
889
|
+
if (pending !== undefined) return { done: false, cursor: item.key };
|
|
890
|
+
const next = items[index + 1];
|
|
891
|
+
return { done: next === undefined, cursor: next?.key ?? null };
|
|
892
|
+
}
|