@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,413 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { URL } from "node:url";
|
|
3
|
+
import { log } from "node:console";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { unstable_dev } from "wrangler";
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import { appBackendActorHeader } from "@fungi.computer/agar/backend";
|
|
10
|
+
import { codeInsights, savedLayout } from "../shared/insights.ts";
|
|
11
|
+
import { buildAppDeclaration } from "@fungi.computer/agar/backend-declaration";
|
|
12
|
+
import { appBackend as bonsaiBackend } from "../backend/methods.ts";
|
|
13
|
+
import bonsaiMetadata from "../package.json" with { type: "json" };
|
|
14
|
+
const appDeclaration = buildAppDeclaration(
|
|
15
|
+
bonsaiBackend,
|
|
16
|
+
bonsaiMetadata.fungi.app,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const fact = {
|
|
20
|
+
repository: "pond--ada",
|
|
21
|
+
writer: { kind: "actor", actor: { kind: "agent", agentId: "agent_ada" } },
|
|
22
|
+
updates: [
|
|
23
|
+
{ ref: "refs/heads/main", before: "0".repeat(40), after: "b".repeat(40) },
|
|
24
|
+
],
|
|
25
|
+
eventId: "push_1",
|
|
26
|
+
};
|
|
27
|
+
const state = await mkdtemp(join(tmpdir(), "bonsai-push-"));
|
|
28
|
+
const start = () =>
|
|
29
|
+
unstable_dev(new URL("./git-push.worker.ts", import.meta.url).pathname, {
|
|
30
|
+
config: new URL("./git-push.config.jsonc", import.meta.url).pathname,
|
|
31
|
+
local: true,
|
|
32
|
+
ip: "127.0.0.1",
|
|
33
|
+
port: 0,
|
|
34
|
+
persist: true,
|
|
35
|
+
persistTo: state,
|
|
36
|
+
logLevel: "none",
|
|
37
|
+
experimental: {
|
|
38
|
+
disableExperimentalWarning: true,
|
|
39
|
+
disableDevRegistry: true,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
let worker;
|
|
43
|
+
try {
|
|
44
|
+
worker = await start();
|
|
45
|
+
let origin = `http://127.0.0.1:${String(worker.port)}`;
|
|
46
|
+
const seeded = await globalThis.fetch(`${origin}/seed`, {
|
|
47
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
48
|
+
});
|
|
49
|
+
assert.equal(seeded.status, 204, await seeded.text());
|
|
50
|
+
const authorize = (question) =>
|
|
51
|
+
globalThis.fetch(`${origin}/authorize`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
body: JSON.stringify(question),
|
|
54
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
55
|
+
});
|
|
56
|
+
const update = {
|
|
57
|
+
ref: "refs/heads/main",
|
|
58
|
+
before: "a".repeat(40),
|
|
59
|
+
after: "b".repeat(40),
|
|
60
|
+
};
|
|
61
|
+
const agentQuestion = {
|
|
62
|
+
repository: "pond--ada",
|
|
63
|
+
writer: { kind: "actor", actor: { kind: "agent", agentId: "ada" } },
|
|
64
|
+
updates: [update],
|
|
65
|
+
};
|
|
66
|
+
const allowed = await authorize(agentQuestion);
|
|
67
|
+
assert.equal(allowed.status, 200);
|
|
68
|
+
assert.deepEqual(await allowed.json(), { allowed: true });
|
|
69
|
+
const denied = await authorize({
|
|
70
|
+
...agentQuestion,
|
|
71
|
+
repository: "pond",
|
|
72
|
+
updates: [update, { ...update, ref: "refs/heads/topic" }],
|
|
73
|
+
});
|
|
74
|
+
assert.equal(denied.status, 200);
|
|
75
|
+
assert.deepEqual(await denied.json(), {
|
|
76
|
+
allowed: false,
|
|
77
|
+
refused: [
|
|
78
|
+
{
|
|
79
|
+
ref: "refs/heads/main",
|
|
80
|
+
message: "Writer cannot push to this repository",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
ref: "refs/heads/topic",
|
|
84
|
+
message: "Writer cannot push to this repository",
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
for (const question of [
|
|
89
|
+
{ repo: "pond", actor: { name: "levi", kind: "human" } },
|
|
90
|
+
{
|
|
91
|
+
...agentQuestion,
|
|
92
|
+
writer: {
|
|
93
|
+
kind: "actor",
|
|
94
|
+
actor: { kind: "human", name: "levi" },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{ ...agentQuestion, updates: Array.from({ length: 257 }, () => update) },
|
|
98
|
+
])
|
|
99
|
+
assert.equal((await authorize(question)).status, 400);
|
|
100
|
+
const response = await globalThis.fetch(origin, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
body: JSON.stringify(fact),
|
|
103
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
104
|
+
});
|
|
105
|
+
assert.equal(response.status, 200, await response.clone().text());
|
|
106
|
+
const result = await response.json();
|
|
107
|
+
assert.deepEqual(appDeclaration.receives, ["git.push"]);
|
|
108
|
+
assert.deepEqual(
|
|
109
|
+
result.emitted.map((event) => event.type),
|
|
110
|
+
["git.push", "change.opened"],
|
|
111
|
+
);
|
|
112
|
+
for (const event of result.emitted) {
|
|
113
|
+
const declaration = appDeclaration.api.events[event.type];
|
|
114
|
+
assert.ok(declaration, `${event.type} must be declared`);
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
z.fromJSONSchema(declaration.payload).parse(event.payload),
|
|
117
|
+
event.payload,
|
|
118
|
+
);
|
|
119
|
+
assert.equal(event.payload.data.eventId, event.eventId);
|
|
120
|
+
assert.equal(event.payload.data.actor, "agent_ada");
|
|
121
|
+
assert.deepEqual(event.payload.data.repository, {
|
|
122
|
+
name: "pond--ada",
|
|
123
|
+
canonical: "pond",
|
|
124
|
+
cloneUrl: "https://git.example.test/repos/pond--ada",
|
|
125
|
+
});
|
|
126
|
+
if (event.type === "git.push") {
|
|
127
|
+
assert.deepEqual(event.payload.data.activity.data, {
|
|
128
|
+
updates: fact.updates,
|
|
129
|
+
commits: [{ hash: "b".repeat(40), message: "Add food" }],
|
|
130
|
+
paths: ["food.txt"],
|
|
131
|
+
});
|
|
132
|
+
} else
|
|
133
|
+
assert.deepEqual(event.payload.data.change, {
|
|
134
|
+
repo: "pond",
|
|
135
|
+
number: 1,
|
|
136
|
+
sourceRepo: "pond--ada",
|
|
137
|
+
sourceRef: "main",
|
|
138
|
+
targetRef: "main",
|
|
139
|
+
head: "b".repeat(40),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
assert.equal(
|
|
143
|
+
result.activity.filter((event) => event.type === "push").length,
|
|
144
|
+
1,
|
|
145
|
+
);
|
|
146
|
+
const tiles = {
|
|
147
|
+
view: "overview",
|
|
148
|
+
placements: [{ id: "network", col: 0, row: 0, w: 2, h: 1 }],
|
|
149
|
+
};
|
|
150
|
+
const method = async (name, input, actor = "levi", probe = "push") => {
|
|
151
|
+
const response = await globalThis.fetch(
|
|
152
|
+
`${origin}/methods?probe=${probe}`,
|
|
153
|
+
{
|
|
154
|
+
method: "PUT",
|
|
155
|
+
headers: { [appBackendActorHeader]: actor },
|
|
156
|
+
body: JSON.stringify({ method: name, input }),
|
|
157
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
assert.equal(response.status, 200, await response.clone().text());
|
|
161
|
+
return response.json();
|
|
162
|
+
};
|
|
163
|
+
assert.deepEqual(
|
|
164
|
+
savedLayout.parse(
|
|
165
|
+
await method("insights.layout.save", { repo: "pond", tiles }),
|
|
166
|
+
),
|
|
167
|
+
{ tiles },
|
|
168
|
+
);
|
|
169
|
+
assert.deepEqual(
|
|
170
|
+
savedLayout.parse(
|
|
171
|
+
await method("insights.layout.get", { repo: "pond" }, "ada"),
|
|
172
|
+
),
|
|
173
|
+
{ tiles: null },
|
|
174
|
+
);
|
|
175
|
+
// Warm projections exercise the real App method and native SQLite blob representation.
|
|
176
|
+
await method("repos.get", { repo: "pond--ada" });
|
|
177
|
+
const callsBefore = await (await globalThis.fetch(`${origin}/calls`)).json();
|
|
178
|
+
await method("repos.get", { repo: "pond--ada" });
|
|
179
|
+
await method("changes.list", { repo: "pond" });
|
|
180
|
+
await method("changes.get", { repo: "pond", number: 1 });
|
|
181
|
+
const callsAfter = await (await globalThis.fetch(`${origin}/calls`)).json();
|
|
182
|
+
assert.equal(
|
|
183
|
+
callsAfter.calls - callsBefore.calls,
|
|
184
|
+
0,
|
|
185
|
+
"warm repository and Change screens never call Git",
|
|
186
|
+
);
|
|
187
|
+
const insights = codeInsights.parse(
|
|
188
|
+
await method("insights.code", { repo: "pond", ref: "main" }),
|
|
189
|
+
);
|
|
190
|
+
assert.deepEqual(insights.files, [
|
|
191
|
+
{ path: "food.txt", lines: 2, changes: 1 },
|
|
192
|
+
]);
|
|
193
|
+
assert.equal(insights.partial, false);
|
|
194
|
+
await worker.stop();
|
|
195
|
+
worker = await start();
|
|
196
|
+
origin = `http://127.0.0.1:${String(worker.port)}`;
|
|
197
|
+
assert.deepEqual(
|
|
198
|
+
savedLayout.parse(await method("insights.layout.get", { repo: "pond" })),
|
|
199
|
+
{ tiles },
|
|
200
|
+
);
|
|
201
|
+
const retry = await globalThis.fetch(origin, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
body: JSON.stringify(fact),
|
|
204
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
205
|
+
});
|
|
206
|
+
assert.equal(retry.status, 200, await retry.clone().text());
|
|
207
|
+
const replayed = await retry.json();
|
|
208
|
+
assert.deepEqual(
|
|
209
|
+
replayed.activity,
|
|
210
|
+
result.activity,
|
|
211
|
+
"retry must not duplicate activity or lose commit attribution",
|
|
212
|
+
);
|
|
213
|
+
assert.deepEqual(
|
|
214
|
+
replayed.emitted,
|
|
215
|
+
[],
|
|
216
|
+
"acknowledged outbox envelopes must stay acknowledged across owner recreation",
|
|
217
|
+
);
|
|
218
|
+
const changed = await globalThis.fetch(origin, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
body: JSON.stringify({
|
|
221
|
+
...fact,
|
|
222
|
+
writer: { kind: "actor", actor: { kind: "agent", agentId: "mallory" } },
|
|
223
|
+
}),
|
|
224
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
225
|
+
});
|
|
226
|
+
assert.equal(
|
|
227
|
+
changed.status,
|
|
228
|
+
500,
|
|
229
|
+
"changed contents under the same canonical event ID must refuse",
|
|
230
|
+
);
|
|
231
|
+
const legacy = await globalThis.fetch(`${origin}/legacy`, {
|
|
232
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
233
|
+
});
|
|
234
|
+
assert.equal(
|
|
235
|
+
legacy.status,
|
|
236
|
+
405,
|
|
237
|
+
"the retired push POST must not remain a second router",
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const seededFailure = await globalThis.fetch(`${origin}/seed?probe=failure`, {
|
|
241
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
242
|
+
});
|
|
243
|
+
assert.equal(seededFailure.status, 204, await seededFailure.text());
|
|
244
|
+
const failed = await globalThis.fetch(`${origin}/?probe=failure&fail`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
body: JSON.stringify(fact),
|
|
247
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
248
|
+
});
|
|
249
|
+
assert.equal(
|
|
250
|
+
failed.status,
|
|
251
|
+
200,
|
|
252
|
+
"best-effort delivery failure must preserve the committed push",
|
|
253
|
+
);
|
|
254
|
+
const committed = await failed.json();
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
committed.emitted,
|
|
257
|
+
[],
|
|
258
|
+
"failed delivery is not acknowledged",
|
|
259
|
+
);
|
|
260
|
+
assert.equal(
|
|
261
|
+
committed.activity.filter((event) => event.type === "push").length,
|
|
262
|
+
1,
|
|
263
|
+
"delivery failure does not roll back the committed activity",
|
|
264
|
+
);
|
|
265
|
+
await worker.stop();
|
|
266
|
+
worker = await start();
|
|
267
|
+
origin = `http://127.0.0.1:${String(worker.port)}`;
|
|
268
|
+
const recovered = await globalThis.fetch(`${origin}/?probe=failure`, {
|
|
269
|
+
method: "POST",
|
|
270
|
+
body: JSON.stringify(fact),
|
|
271
|
+
signal: globalThis.AbortSignal.timeout(10_000),
|
|
272
|
+
});
|
|
273
|
+
assert.equal(recovered.status, 200, await recovered.clone().text());
|
|
274
|
+
const retried = await recovered.json();
|
|
275
|
+
assert.equal(
|
|
276
|
+
retried.activity.filter((event) => event.type === "push").length,
|
|
277
|
+
1,
|
|
278
|
+
);
|
|
279
|
+
assert.deepEqual(
|
|
280
|
+
retried.emitted.map((event) => event.type),
|
|
281
|
+
["git.push", "change.opened"],
|
|
282
|
+
"unacknowledged outbox envelopes survive owner restart and are retried",
|
|
283
|
+
);
|
|
284
|
+
assert.deepEqual(
|
|
285
|
+
retried.emitted.map((event) => event.payload.data.activity.data),
|
|
286
|
+
result.emitted.map((event) => event.payload.data.activity.data),
|
|
287
|
+
);
|
|
288
|
+
// Public native methods share the real push frame and durable pending gate.
|
|
289
|
+
assert.equal(
|
|
290
|
+
(await globalThis.fetch(`${origin}/seed?probe=budget`)).status,
|
|
291
|
+
204,
|
|
292
|
+
);
|
|
293
|
+
const views = [
|
|
294
|
+
["files.list", { repo: "pond", ref: "main" }],
|
|
295
|
+
["files.tree", { repo: "pond", ref: "main", path: "" }],
|
|
296
|
+
["files.read", { repo: "pond", ref: "main", path: "food.txt" }],
|
|
297
|
+
["graph.get", { repo: "pond" }],
|
|
298
|
+
["commits.list", { repo: "pond", ref: "main" }],
|
|
299
|
+
];
|
|
300
|
+
for (const [name, input] of views)
|
|
301
|
+
await method(name, input, "levi", "budget");
|
|
302
|
+
await method(
|
|
303
|
+
"commits.get",
|
|
304
|
+
{ repo: "pond", hash: "b".repeat(40) },
|
|
305
|
+
"levi",
|
|
306
|
+
"budget",
|
|
307
|
+
);
|
|
308
|
+
const counts = async () =>
|
|
309
|
+
(await (await globalThis.fetch(`${origin}/calls?probe=budget`)).json())
|
|
310
|
+
.calls;
|
|
311
|
+
let beforeBudget = await counts();
|
|
312
|
+
for (const [name, input] of views)
|
|
313
|
+
await method(name, input, "levi", "budget");
|
|
314
|
+
assert.equal(
|
|
315
|
+
(await counts()) - beforeBudget,
|
|
316
|
+
0,
|
|
317
|
+
"warm native T2 methods never read Git",
|
|
318
|
+
);
|
|
319
|
+
const grown = await globalThis.fetch(`${origin}/grow?probe=budget`);
|
|
320
|
+
assert.equal(grown.status, 200, await grown.clone().text());
|
|
321
|
+
assert.deepEqual(await grown.json(), { exhausted: true, pending: true });
|
|
322
|
+
assert.ok(
|
|
323
|
+
(await counts()) - beforeBudget <= 40,
|
|
324
|
+
"native push shares one40 operation ceiling",
|
|
325
|
+
);
|
|
326
|
+
await worker.stop();
|
|
327
|
+
worker = await start();
|
|
328
|
+
origin = `http://127.0.0.1:${String(worker.port)}`;
|
|
329
|
+
beforeBudget = await counts();
|
|
330
|
+
for (const [name, input] of views)
|
|
331
|
+
assert.deepEqual(await method(name, input, "levi", "budget"), {
|
|
332
|
+
kind: "rebuilding",
|
|
333
|
+
});
|
|
334
|
+
assert.equal(
|
|
335
|
+
(await counts()) - beforeBudget,
|
|
336
|
+
0,
|
|
337
|
+
"native pending gate survives reconstruction",
|
|
338
|
+
);
|
|
339
|
+
assert.equal(
|
|
340
|
+
(
|
|
341
|
+
await method(
|
|
342
|
+
"commits.get",
|
|
343
|
+
{ repo: "pond", hash: "b".repeat(40) },
|
|
344
|
+
"levi",
|
|
345
|
+
"budget",
|
|
346
|
+
)
|
|
347
|
+
).commit.hash,
|
|
348
|
+
"b".repeat(40),
|
|
349
|
+
);
|
|
350
|
+
let page = { done: false, cursor: null },
|
|
351
|
+
pages = 0;
|
|
352
|
+
while (!page.done && pages < 30) {
|
|
353
|
+
const beforePage = await counts();
|
|
354
|
+
page = await method(
|
|
355
|
+
"rebuildCopies",
|
|
356
|
+
{ cursor: page.cursor },
|
|
357
|
+
"levi",
|
|
358
|
+
"budget",
|
|
359
|
+
);
|
|
360
|
+
assert.ok(
|
|
361
|
+
(await counts()) - beforePage <= 40,
|
|
362
|
+
"native foreground page shares one40 operation ceiling",
|
|
363
|
+
);
|
|
364
|
+
pages++;
|
|
365
|
+
}
|
|
366
|
+
assert.equal(page.done, true, "native foreground backfill finishes");
|
|
367
|
+
beforeBudget = await counts();
|
|
368
|
+
for (const [name, input] of views) {
|
|
369
|
+
const ready = await method(name, input, "levi", "budget");
|
|
370
|
+
assert.notEqual(ready.kind, "rebuilding");
|
|
371
|
+
}
|
|
372
|
+
assert.equal(
|
|
373
|
+
(
|
|
374
|
+
await method(
|
|
375
|
+
"commits.get",
|
|
376
|
+
{ repo: "pond", hash: "e".repeat(40) },
|
|
377
|
+
"levi",
|
|
378
|
+
"budget",
|
|
379
|
+
)
|
|
380
|
+
).commit.hash,
|
|
381
|
+
"e".repeat(40),
|
|
382
|
+
);
|
|
383
|
+
assert.equal(
|
|
384
|
+
(await counts()) - beforeBudget,
|
|
385
|
+
0,
|
|
386
|
+
"backfilled native T2 methods stay warm0",
|
|
387
|
+
);
|
|
388
|
+
// Retry the original failed fact after foreground recovery: native transaction
|
|
389
|
+
// publication must clear the newer pending revision and retain warm views.
|
|
390
|
+
const acceptedGrowth = await globalThis.fetch(`${origin}/grow?probe=budget`);
|
|
391
|
+
assert.equal(acceptedGrowth.status, 200, await acceptedGrowth.clone().text());
|
|
392
|
+
assert.deepEqual(await acceptedGrowth.json(), {
|
|
393
|
+
exhausted: false,
|
|
394
|
+
pending: false,
|
|
395
|
+
});
|
|
396
|
+
beforeBudget = await counts();
|
|
397
|
+
for (const [name, input] of views)
|
|
398
|
+
assert.notEqual(
|
|
399
|
+
(await method(name, input, "levi", "budget")).kind,
|
|
400
|
+
"rebuilding",
|
|
401
|
+
);
|
|
402
|
+
assert.equal(
|
|
403
|
+
(await counts()) - beforeBudget,
|
|
404
|
+
0,
|
|
405
|
+
"accepted native retry publishes refs and all warm views",
|
|
406
|
+
);
|
|
407
|
+
log(
|
|
408
|
+
"PASS: Bonsai warm read-copy Git calls=0, T2 pending/exhaustion/foreground pages PASS, native push events, parsed Git insights, caller-isolated layouts persisted across restart",
|
|
409
|
+
);
|
|
410
|
+
} finally {
|
|
411
|
+
await worker?.stop();
|
|
412
|
+
await rm(state, { recursive: true, force: true });
|
|
413
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ReadCopyBudgetExceeded,
|
|
3
|
+
countingGit,
|
|
4
|
+
} from "@fungi.computer/agar/read-copy";
|
|
5
|
+
import { appBackendActorHeader } from "@fungi.computer/agar/backend";
|
|
6
|
+
import {
|
|
7
|
+
gitPushQuestionSchema,
|
|
8
|
+
type AppGitPushFact,
|
|
9
|
+
} from "@fungi.computer/agar/git";
|
|
10
|
+
import type { StandardWebhookPayload } from "@fungi.computer/oyster/git";
|
|
11
|
+
import { BonsaiApp } from "../backend/index.js";
|
|
12
|
+
import type { GitBinding } from "../backend/git.js";
|
|
13
|
+
import type { HookEvent } from "../shared/hooks.js";
|
|
14
|
+
|
|
15
|
+
const after = "b".repeat(40);
|
|
16
|
+
const tree = "c".repeat(40);
|
|
17
|
+
const unused = () => Promise.reject(new Error("Unexpected Git operation"));
|
|
18
|
+
const git: GitBinding = {
|
|
19
|
+
cloneUrl: (repo) => Promise.resolve(`https://git.example.test/repos/${repo}`),
|
|
20
|
+
create: (name) =>
|
|
21
|
+
Promise.resolve({
|
|
22
|
+
name,
|
|
23
|
+
defaultBranch: "main",
|
|
24
|
+
lastPushAt: null,
|
|
25
|
+
source: null,
|
|
26
|
+
}),
|
|
27
|
+
fork: (source, name) =>
|
|
28
|
+
Promise.resolve({ name, defaultBranch: "main", lastPushAt: null, source }),
|
|
29
|
+
info: (name) =>
|
|
30
|
+
Promise.resolve({
|
|
31
|
+
name,
|
|
32
|
+
defaultBranch: "main",
|
|
33
|
+
lastPushAt: null,
|
|
34
|
+
source: null,
|
|
35
|
+
}),
|
|
36
|
+
delete: unused,
|
|
37
|
+
log: () =>
|
|
38
|
+
Promise.resolve([
|
|
39
|
+
{
|
|
40
|
+
hash: after,
|
|
41
|
+
treeHash: tree,
|
|
42
|
+
message: "Add food",
|
|
43
|
+
parents: [],
|
|
44
|
+
author: { name: "ada", email: "ada@example.test" },
|
|
45
|
+
committer: { name: "ada", email: "ada@example.test" },
|
|
46
|
+
authoredAt: 1,
|
|
47
|
+
committedAt: 1,
|
|
48
|
+
},
|
|
49
|
+
]),
|
|
50
|
+
readCommit: (_repo, hash) =>
|
|
51
|
+
hash === after
|
|
52
|
+
? Promise.resolve({
|
|
53
|
+
hash: after,
|
|
54
|
+
treeHash: tree,
|
|
55
|
+
message: "Add food",
|
|
56
|
+
parents: [],
|
|
57
|
+
author: { name: "ada", email: "ada@example.test" },
|
|
58
|
+
committer: { name: "ada", email: "ada@example.test" },
|
|
59
|
+
authoredAt: 1,
|
|
60
|
+
committedAt: 1,
|
|
61
|
+
})
|
|
62
|
+
: Promise.resolve(null),
|
|
63
|
+
readTree: () =>
|
|
64
|
+
Promise.resolve([
|
|
65
|
+
{ name: "food.txt", mode: "100644", hash: "d".repeat(40), type: "blob" },
|
|
66
|
+
]),
|
|
67
|
+
readBlob: () => Promise.resolve(new TextEncoder().encode("food\nwater\n")),
|
|
68
|
+
refs: () =>
|
|
69
|
+
Promise.resolve([{ name: "refs/heads/main", hash: "a".repeat(40) }]),
|
|
70
|
+
exchange: unused,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** A native fixture with enough distinct directories to exhaust real tree reads. */
|
|
74
|
+
function growingGit(large: () => boolean): GitBinding {
|
|
75
|
+
const grown = "e".repeat(40),
|
|
76
|
+
root = "f".repeat(40);
|
|
77
|
+
const commit = (hash: string) => ({
|
|
78
|
+
hash,
|
|
79
|
+
treeHash: hash === grown ? root : tree,
|
|
80
|
+
message: hash === grown ? "Grow native pond" : "Add food",
|
|
81
|
+
parents: hash === grown ? [after] : [],
|
|
82
|
+
author: { name: "ada", email: "ada@example.test" },
|
|
83
|
+
committer: { name: "ada", email: "ada@example.test" },
|
|
84
|
+
authoredAt: 1,
|
|
85
|
+
committedAt: 1,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
...git,
|
|
89
|
+
refs: (repo) =>
|
|
90
|
+
Promise.resolve([
|
|
91
|
+
{
|
|
92
|
+
name: "refs/heads/main",
|
|
93
|
+
hash: large() && repo === "pond" ? grown : after,
|
|
94
|
+
},
|
|
95
|
+
]),
|
|
96
|
+
log: (repo, options) => {
|
|
97
|
+
const hash = /^[0-9a-f]{40}$/u.test(options.ref)
|
|
98
|
+
? options.ref
|
|
99
|
+
: large() && repo === "pond"
|
|
100
|
+
? grown
|
|
101
|
+
: after;
|
|
102
|
+
return Promise.resolve(
|
|
103
|
+
hash === grown ? [commit(grown), commit(after)] : [commit(after)],
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
readCommit: (_repo, hash) =>
|
|
107
|
+
Promise.resolve(hash === grown || hash === after ? commit(hash) : null),
|
|
108
|
+
readTree: (_repo, hash) => {
|
|
109
|
+
const food = {
|
|
110
|
+
name: "food.txt",
|
|
111
|
+
mode: "100644" as const,
|
|
112
|
+
hash: "d".repeat(40),
|
|
113
|
+
type: "blob" as const,
|
|
114
|
+
};
|
|
115
|
+
if (hash === root)
|
|
116
|
+
return Promise.resolve([
|
|
117
|
+
food,
|
|
118
|
+
...Array.from({ length: 60 }, (_, i) => ({
|
|
119
|
+
name: `dir${String(i)}`,
|
|
120
|
+
mode: "040000" as const,
|
|
121
|
+
type: "tree" as const,
|
|
122
|
+
hash: String(i + 1).padStart(40, "0"),
|
|
123
|
+
})),
|
|
124
|
+
]);
|
|
125
|
+
return Promise.resolve([food]);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class BonsaiPushProbe extends BonsaiApp {
|
|
131
|
+
private readonly providerCalls: () => number;
|
|
132
|
+
private readonly delivery: { failNext: boolean };
|
|
133
|
+
private readonly emitted: {
|
|
134
|
+
type: string;
|
|
135
|
+
payload: StandardWebhookPayload;
|
|
136
|
+
eventId: string;
|
|
137
|
+
}[];
|
|
138
|
+
constructor(ctx: DurableObjectState) {
|
|
139
|
+
const emitted: {
|
|
140
|
+
type: string;
|
|
141
|
+
payload: StandardWebhookPayload;
|
|
142
|
+
eventId: string;
|
|
143
|
+
}[] = [];
|
|
144
|
+
const delivery = { failNext: false };
|
|
145
|
+
ctx.storage.sql.exec(
|
|
146
|
+
"CREATE TABLE IF NOT EXISTS native_git_fixture (id INTEGER PRIMARY KEY, large INTEGER NOT NULL)",
|
|
147
|
+
);
|
|
148
|
+
ctx.storage.sql.exec(
|
|
149
|
+
"INSERT OR IGNORE INTO native_git_fixture VALUES(1,0)",
|
|
150
|
+
);
|
|
151
|
+
const provider =
|
|
152
|
+
ctx.id.name === "budget"
|
|
153
|
+
? growingGit(
|
|
154
|
+
() =>
|
|
155
|
+
ctx.storage.sql
|
|
156
|
+
.exec("SELECT large FROM native_git_fixture WHERE id=1")
|
|
157
|
+
.one().large === 1,
|
|
158
|
+
)
|
|
159
|
+
: git;
|
|
160
|
+
const lifetime = new AbortController();
|
|
161
|
+
const counted = countingGit(provider, {
|
|
162
|
+
limit: 10_000,
|
|
163
|
+
signal: lifetime.signal,
|
|
164
|
+
});
|
|
165
|
+
super(ctx, {
|
|
166
|
+
GIT: counted.git,
|
|
167
|
+
EVENTS: {
|
|
168
|
+
emit: (type, payload, eventId) => {
|
|
169
|
+
if (delivery.failNext) {
|
|
170
|
+
delivery.failNext = false;
|
|
171
|
+
return Promise.reject(new Error("EVENTS temporarily unavailable"));
|
|
172
|
+
}
|
|
173
|
+
emitted.push({ type, payload, eventId });
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
this.providerCalls = counted.total;
|
|
179
|
+
this.emitted = emitted;
|
|
180
|
+
this.delivery = delivery;
|
|
181
|
+
}
|
|
182
|
+
async seed(): Promise<void> {
|
|
183
|
+
for (const [method, input, actor] of [
|
|
184
|
+
["repos.create", { name: "pond" }, "levi"],
|
|
185
|
+
["repos.fork", { repo: "pond" }, "ada"],
|
|
186
|
+
] as const) {
|
|
187
|
+
const response = await this.fetch(
|
|
188
|
+
new Request("https://app.internal/", {
|
|
189
|
+
method: "PUT",
|
|
190
|
+
headers: {
|
|
191
|
+
"Content-Type": "application/json",
|
|
192
|
+
[appBackendActorHeader]: actor,
|
|
193
|
+
},
|
|
194
|
+
body: JSON.stringify({ method, input }),
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
if (!response.ok)
|
|
198
|
+
throw new Error(`Seed failed: ${await response.text()}`);
|
|
199
|
+
}
|
|
200
|
+
this.emitted.length = 0;
|
|
201
|
+
}
|
|
202
|
+
async grow(): Promise<{ exhausted: boolean; pending: boolean }> {
|
|
203
|
+
this.ctx.storage.sql.exec(
|
|
204
|
+
"UPDATE native_git_fixture SET large=1 WHERE id=1",
|
|
205
|
+
);
|
|
206
|
+
try {
|
|
207
|
+
await this.onGitPush({
|
|
208
|
+
repository: "pond",
|
|
209
|
+
eventId: "native-grow",
|
|
210
|
+
writer: { kind: "actor", actor: { kind: "member", memberId: "levi" } },
|
|
211
|
+
updates: [
|
|
212
|
+
{ ref: "refs/heads/main", before: after, after: "e".repeat(40) },
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
exhausted: false,
|
|
217
|
+
pending: this.bonsai.state.repositoryRefStatus("pond").pending,
|
|
218
|
+
};
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (!(error instanceof ReadCopyBudgetExceeded)) throw error;
|
|
221
|
+
return {
|
|
222
|
+
exhausted: true,
|
|
223
|
+
pending: this.bonsai.state.repositoryRefStatus("pond").pending,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
calls(): Promise<number> {
|
|
228
|
+
return Promise.resolve(this.providerCalls());
|
|
229
|
+
}
|
|
230
|
+
failNextEmit(): void {
|
|
231
|
+
this.delivery.failNext = true;
|
|
232
|
+
}
|
|
233
|
+
snapshot(): Promise<{
|
|
234
|
+
emitted: {
|
|
235
|
+
type: string;
|
|
236
|
+
payload: StandardWebhookPayload;
|
|
237
|
+
eventId: string;
|
|
238
|
+
}[];
|
|
239
|
+
activity: HookEvent["data"]["activity"][];
|
|
240
|
+
}> {
|
|
241
|
+
return Promise.resolve({
|
|
242
|
+
emitted: this.emitted,
|
|
243
|
+
activity: this.bonsai.state.events("pond", 20),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export default {
|
|
249
|
+
async fetch(
|
|
250
|
+
request: Request,
|
|
251
|
+
env: { PROBE: DurableObjectNamespace<BonsaiPushProbe> },
|
|
252
|
+
): Promise<Response> {
|
|
253
|
+
const url = new URL(request.url);
|
|
254
|
+
const probe = env.PROBE.getByName(url.searchParams.get("probe") ?? "push");
|
|
255
|
+
if (url.pathname === "/grow") return Response.json(await probe.grow());
|
|
256
|
+
if (url.pathname === "/calls")
|
|
257
|
+
return Response.json({ calls: await probe.calls() });
|
|
258
|
+
if (url.pathname === "/methods") return probe.fetch(request);
|
|
259
|
+
if (url.pathname === "/authorize") {
|
|
260
|
+
const question = gitPushQuestionSchema.safeParse(await request.json());
|
|
261
|
+
if (!question.success) return new Response(null, { status: 400 });
|
|
262
|
+
return Response.json(await probe.authorizeGitPush(question.data));
|
|
263
|
+
}
|
|
264
|
+
if (url.pathname === "/legacy")
|
|
265
|
+
return probe.fetch(
|
|
266
|
+
new Request("https://app.internal/host/git-push", {
|
|
267
|
+
method: "POST",
|
|
268
|
+
body: "{}",
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
if (url.pathname === "/seed") {
|
|
272
|
+
await probe.seed();
|
|
273
|
+
return new Response(null, { status: 204 });
|
|
274
|
+
}
|
|
275
|
+
if (url.searchParams.has("fail")) await probe.failNextEmit();
|
|
276
|
+
const fact = await request.json<AppGitPushFact>();
|
|
277
|
+
await probe.onGitPush(fact);
|
|
278
|
+
return Response.json(await probe.snapshot());
|
|
279
|
+
},
|
|
280
|
+
};
|