@fungi.computer/bonsai 0.0.0-stage → 0.0.1
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 +17 -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 +835 -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/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 +5 -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 +715 -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,724 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Public Oyster client only. Credentials are files; stdout is a secret-free receipt.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
5
|
+
import {
|
|
6
|
+
appendFile,
|
|
7
|
+
cp,
|
|
8
|
+
mkdir,
|
|
9
|
+
readFile,
|
|
10
|
+
stat,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { URL, fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { parseArgs, promisify } from "node:util";
|
|
16
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
17
|
+
import process from "node:process";
|
|
18
|
+
import { log, error } from "node:console";
|
|
19
|
+
import * as z from "zod";
|
|
20
|
+
import {
|
|
21
|
+
repoPage,
|
|
22
|
+
repoSummary,
|
|
23
|
+
repoDetail,
|
|
24
|
+
changePage,
|
|
25
|
+
changeDetail,
|
|
26
|
+
changeRecord,
|
|
27
|
+
checkRequestResult,
|
|
28
|
+
checkReportResult,
|
|
29
|
+
mergeResult,
|
|
30
|
+
graph,
|
|
31
|
+
} from "../shared/contract.ts";
|
|
32
|
+
|
|
33
|
+
import { pushSeedStep } from "./seed-step.mjs";
|
|
34
|
+
|
|
35
|
+
const names = ["morel", "chanterelle", "enoki"];
|
|
36
|
+
const stateSchema = z.object({
|
|
37
|
+
origin: z.url(),
|
|
38
|
+
installation: z.string(),
|
|
39
|
+
repo: z.string(),
|
|
40
|
+
keys: z.record(z.string(), z.string()),
|
|
41
|
+
finished: z.boolean(),
|
|
42
|
+
baseHead: z.string().nullable(),
|
|
43
|
+
lastPushAt: z.number().nonnegative(),
|
|
44
|
+
});
|
|
45
|
+
const keyResponse = z.object({
|
|
46
|
+
key: z.object({ name: z.string() }),
|
|
47
|
+
plaintext: z.string().min(1),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export async function seedDemo({
|
|
51
|
+
origin,
|
|
52
|
+
work,
|
|
53
|
+
ownerKeyFile,
|
|
54
|
+
agentKeysFile,
|
|
55
|
+
phase = "seed",
|
|
56
|
+
installation = "bonsai",
|
|
57
|
+
repo = "koi-pond",
|
|
58
|
+
pushIntervalMinutes = 0,
|
|
59
|
+
}) {
|
|
60
|
+
assert.ok(
|
|
61
|
+
Number.isFinite(pushIntervalMinutes) && pushIntervalMinutes >= 0,
|
|
62
|
+
"Push interval must be nonnegative minutes",
|
|
63
|
+
);
|
|
64
|
+
const url = new URL(origin);
|
|
65
|
+
assert.ok(
|
|
66
|
+
["http:", "https:"].includes(url.protocol) &&
|
|
67
|
+
!url.username &&
|
|
68
|
+
!url.password,
|
|
69
|
+
"Use an HTTP origin without embedded credentials",
|
|
70
|
+
);
|
|
71
|
+
origin = url.origin;
|
|
72
|
+
const agentsOnly = phase === "agents";
|
|
73
|
+
assert.ok(
|
|
74
|
+
agentsOnly ? agentKeysFile && !ownerKeyFile : ownerKeyFile,
|
|
75
|
+
"Agents phase takes only scoped agent credentials",
|
|
76
|
+
);
|
|
77
|
+
const owner = agentsOnly
|
|
78
|
+
? null
|
|
79
|
+
: (await readFile(ownerKeyFile, "utf8")).trim();
|
|
80
|
+
assert.ok(agentsOnly || owner, "Owner credential file is empty");
|
|
81
|
+
await mkdir(work, { recursive: true, mode: 0o700 });
|
|
82
|
+
const stateFile = join(work, "credentials.json");
|
|
83
|
+
let state;
|
|
84
|
+
try {
|
|
85
|
+
state = stateSchema.parse(JSON.parse(await readFile(stateFile, "utf8")));
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
if (cause?.code !== "ENOENT")
|
|
88
|
+
throw new Error("Cannot read seed checkpoint", { cause });
|
|
89
|
+
state = stateSchema.parse({
|
|
90
|
+
origin,
|
|
91
|
+
installation,
|
|
92
|
+
repo,
|
|
93
|
+
keys: {},
|
|
94
|
+
finished: false,
|
|
95
|
+
baseHead: null,
|
|
96
|
+
lastPushAt: 0,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
assert.ok(
|
|
100
|
+
state.origin === origin &&
|
|
101
|
+
state.repo === repo &&
|
|
102
|
+
state.installation === installation,
|
|
103
|
+
"Checkpoint belongs to a different target",
|
|
104
|
+
);
|
|
105
|
+
if (agentsOnly) {
|
|
106
|
+
assert.equal(
|
|
107
|
+
(await stat(agentKeysFile)).mode & 0o777,
|
|
108
|
+
0o600,
|
|
109
|
+
"Scoped keys must be in a 0600 file",
|
|
110
|
+
);
|
|
111
|
+
state.keys = z
|
|
112
|
+
.strictObject({
|
|
113
|
+
morel: z.string().min(1),
|
|
114
|
+
chanterelle: z.string().min(1),
|
|
115
|
+
enoki: z.string().min(1),
|
|
116
|
+
})
|
|
117
|
+
.parse(JSON.parse(await readFile(agentKeysFile, "utf8")));
|
|
118
|
+
}
|
|
119
|
+
const save = () =>
|
|
120
|
+
writeFile(stateFile, JSON.stringify(state), { mode: 0o600 });
|
|
121
|
+
const request = async (path, body, key) => {
|
|
122
|
+
const response = await globalThis.fetch(`${origin}${path}`, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: {
|
|
125
|
+
Authorization: `Bearer ${key}`,
|
|
126
|
+
"Content-Type": "application/json",
|
|
127
|
+
"User-Agent": "oyster-koi-seed/1.0",
|
|
128
|
+
},
|
|
129
|
+
body: JSON.stringify(body),
|
|
130
|
+
signal: globalThis.AbortSignal.timeout(30_000),
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok)
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Public request ${path} failed (${response.status}); no response body logged`,
|
|
135
|
+
);
|
|
136
|
+
return response.json();
|
|
137
|
+
};
|
|
138
|
+
const call = async (method, input, schema, key = owner ?? state.keys.morel) =>
|
|
139
|
+
schema.parse(
|
|
140
|
+
await request(`/apps/${installation}/methods/${method}`, input, key),
|
|
141
|
+
);
|
|
142
|
+
const git = async (directory, args, key) => {
|
|
143
|
+
const environment = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
144
|
+
delete environment.GIT_AUTHOR_DATE;
|
|
145
|
+
delete environment.GIT_COMMITTER_DATE;
|
|
146
|
+
delete environment.GIT_TRACE;
|
|
147
|
+
delete environment.GIT_TRACE_CURL;
|
|
148
|
+
delete environment.GIT_CURL_VERBOSE;
|
|
149
|
+
delete environment.GIT_TRACE_PACKET;
|
|
150
|
+
delete environment.GIT_TRACE_REDACT;
|
|
151
|
+
|
|
152
|
+
if (key) {
|
|
153
|
+
environment.GIT_CONFIG_COUNT = "1";
|
|
154
|
+
environment.GIT_CONFIG_KEY_0 = "http.extraHeader";
|
|
155
|
+
environment.GIT_CONFIG_VALUE_0 = `Authorization: Bearer ${key}`;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const result = await promisify(execFile)("git", args, {
|
|
159
|
+
cwd: directory,
|
|
160
|
+
encoding: "utf8",
|
|
161
|
+
env: environment,
|
|
162
|
+
});
|
|
163
|
+
return result.stdout.trim();
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
const parsed = z.string().safeParse(cause?.stderr);
|
|
166
|
+
let diagnostic = parsed.success ? parsed.data : "No Git stderr";
|
|
167
|
+
for (const secret of [owner, ...Object.values(state.keys)].filter(
|
|
168
|
+
Boolean,
|
|
169
|
+
))
|
|
170
|
+
diagnostic = diagnostic.split(secret).join("[redacted]");
|
|
171
|
+
await writeFile(join(work, "git-error.log"), diagnostic, { mode: 0o600 });
|
|
172
|
+
throw new Error(`Git ${args[0]} failed; see the redacted git-error.log`, {
|
|
173
|
+
cause,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const gitUrl = (name) =>
|
|
178
|
+
`${origin}/git/${encodeURIComponent(installation)}/${encodeURIComponent(name)}.git`;
|
|
179
|
+
const until = async (read, accepts, label) => {
|
|
180
|
+
for (let attempt = 0; attempt < 120; attempt++) {
|
|
181
|
+
const value = await read();
|
|
182
|
+
if (accepts(value)) return value;
|
|
183
|
+
await delay(250);
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
186
|
+
};
|
|
187
|
+
const repositories = await call("repos.list", {}, repoPage);
|
|
188
|
+
if (!repositories.repos.some((item) => item.name === repo)) {
|
|
189
|
+
assert.ok(
|
|
190
|
+
owner,
|
|
191
|
+
"Owner must prepare canonical repository before agent drip",
|
|
192
|
+
);
|
|
193
|
+
await call(
|
|
194
|
+
"repos.create",
|
|
195
|
+
{
|
|
196
|
+
name: repo,
|
|
197
|
+
description:
|
|
198
|
+
"A living canvas pond, built together by Morel, Chanterelle and Enoki.",
|
|
199
|
+
},
|
|
200
|
+
repoSummary,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const base = await call("repos.get", { repo }, repoDetail);
|
|
204
|
+
if (base.empty) {
|
|
205
|
+
assert.ok(owner, "Owner must push canonical base before agent drip");
|
|
206
|
+
const directory = join(work, "base");
|
|
207
|
+
await cp(
|
|
208
|
+
fileURLToPath(new URL("../demo/koi-pond/", import.meta.url)),
|
|
209
|
+
directory,
|
|
210
|
+
{ recursive: true },
|
|
211
|
+
);
|
|
212
|
+
await git(directory, ["init", "-q", "-b", "main"]);
|
|
213
|
+
await git(directory, ["add", "."]);
|
|
214
|
+
await git(directory, [
|
|
215
|
+
"-c",
|
|
216
|
+
"user.name=owner",
|
|
217
|
+
"-c",
|
|
218
|
+
"user.email=owner@bonsai.local",
|
|
219
|
+
"commit",
|
|
220
|
+
"-qm",
|
|
221
|
+
"Start the koi pond canvas game",
|
|
222
|
+
]);
|
|
223
|
+
await git(directory, ["push", "-q", gitUrl(repo), "main"], owner);
|
|
224
|
+
await until(
|
|
225
|
+
() => call("repos.get", { repo }, repoDetail),
|
|
226
|
+
(value) => !value.empty,
|
|
227
|
+
"base push",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (state.baseHead === null) {
|
|
231
|
+
const current = await call("repos.get", { repo }, repoDetail);
|
|
232
|
+
state.baseHead =
|
|
233
|
+
current.refs.find((ref) => ref.name === "refs/heads/main")?.hash ?? null;
|
|
234
|
+
assert.ok(state.baseHead, "Starting main ref is required");
|
|
235
|
+
}
|
|
236
|
+
await save();
|
|
237
|
+
if (phase === "prepare") return { origin, repo, phase: "prepared" };
|
|
238
|
+
const readChanges = () => call("changes.list", { repo }, changePage);
|
|
239
|
+
const recordFirstTwo = async () => {
|
|
240
|
+
if (!agentsOnly) return;
|
|
241
|
+
let lines;
|
|
242
|
+
try {
|
|
243
|
+
lines = await readFile(join(work, "pushes.jsonl"), "utf8");
|
|
244
|
+
} catch (cause) {
|
|
245
|
+
if (cause?.code === "ENOENT") return;
|
|
246
|
+
throw cause;
|
|
247
|
+
}
|
|
248
|
+
const pushes = lines
|
|
249
|
+
.trim()
|
|
250
|
+
.split("\n")
|
|
251
|
+
.filter(Boolean)
|
|
252
|
+
.map((line) =>
|
|
253
|
+
z
|
|
254
|
+
.object({
|
|
255
|
+
actor: z.string(),
|
|
256
|
+
repo: z.string(),
|
|
257
|
+
head: z.string(),
|
|
258
|
+
at: z.iso.datetime(),
|
|
259
|
+
})
|
|
260
|
+
.parse(JSON.parse(line)),
|
|
261
|
+
);
|
|
262
|
+
if (pushes.length < 2) return;
|
|
263
|
+
try {
|
|
264
|
+
await stat(join(work, "first-two-pushes.json"));
|
|
265
|
+
return;
|
|
266
|
+
} catch (cause) {
|
|
267
|
+
if (cause?.code !== "ENOENT") throw cause;
|
|
268
|
+
}
|
|
269
|
+
const page = await readChanges();
|
|
270
|
+
await writeFile(
|
|
271
|
+
join(work, "first-two-pushes.json"),
|
|
272
|
+
JSON.stringify(
|
|
273
|
+
{
|
|
274
|
+
observedAt: new Date().toISOString(),
|
|
275
|
+
origin,
|
|
276
|
+
pushes: pushes.slice(0, 2),
|
|
277
|
+
changes: page.changes.map(
|
|
278
|
+
({ repo, number, sourceRepo, sourceRef, head, state, status }) => ({
|
|
279
|
+
repo,
|
|
280
|
+
number,
|
|
281
|
+
sourceRepo,
|
|
282
|
+
sourceRef,
|
|
283
|
+
head,
|
|
284
|
+
state,
|
|
285
|
+
status,
|
|
286
|
+
}),
|
|
287
|
+
),
|
|
288
|
+
ownerOperations: false,
|
|
289
|
+
browser: false,
|
|
290
|
+
},
|
|
291
|
+
null,
|
|
292
|
+
2,
|
|
293
|
+
),
|
|
294
|
+
{ mode: 0o600 },
|
|
295
|
+
);
|
|
296
|
+
};
|
|
297
|
+
await recordFirstTwo();
|
|
298
|
+
if (!state.finished) {
|
|
299
|
+
const folders = {};
|
|
300
|
+
// Fork every lane from the same actual head before any lane is merged.
|
|
301
|
+
for (const name of names) {
|
|
302
|
+
if (!state.keys[name]) {
|
|
303
|
+
assert.ok(owner, "Owner provisions scoped agent keys");
|
|
304
|
+
const created = keyResponse.parse(
|
|
305
|
+
await request(
|
|
306
|
+
"/api/keys",
|
|
307
|
+
{
|
|
308
|
+
name,
|
|
309
|
+
grants: [{ capability: "apps.use", targets: { kind: "all" } }],
|
|
310
|
+
},
|
|
311
|
+
owner,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
assert.equal(created.key.name, name);
|
|
315
|
+
state.keys[name] = created.plaintext;
|
|
316
|
+
await save();
|
|
317
|
+
}
|
|
318
|
+
const fork = await call(
|
|
319
|
+
"repos.fork",
|
|
320
|
+
{ repo },
|
|
321
|
+
repoSummary,
|
|
322
|
+
state.keys[name],
|
|
323
|
+
);
|
|
324
|
+
const directory = join(work, name);
|
|
325
|
+
try {
|
|
326
|
+
await readFile(join(directory, ".git", "HEAD"));
|
|
327
|
+
} catch (cause) {
|
|
328
|
+
if (cause?.code !== "ENOENT") throw cause;
|
|
329
|
+
await git(
|
|
330
|
+
work,
|
|
331
|
+
["clone", "-q", gitUrl(fork.name), directory],
|
|
332
|
+
state.keys[name],
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
await git(directory, ["config", "user.name", name]);
|
|
336
|
+
await git(directory, ["config", "user.email", `${name}@agents.bonsai`]);
|
|
337
|
+
folders[name] = { directory, fork: fork.name };
|
|
338
|
+
}
|
|
339
|
+
const replace = async (directory, path, before, after) => {
|
|
340
|
+
const target = join(directory, path);
|
|
341
|
+
const content = await readFile(target, "utf8");
|
|
342
|
+
if (content.includes(after)) return;
|
|
343
|
+
assert.ok(
|
|
344
|
+
content.includes(before),
|
|
345
|
+
`Demo source no longer matches ${path}; refusing an invented patch`,
|
|
346
|
+
);
|
|
347
|
+
await writeFile(target, content.replace(before, after));
|
|
348
|
+
};
|
|
349
|
+
const append = async (directory, path, text) => {
|
|
350
|
+
const target = join(directory, path);
|
|
351
|
+
const content = await readFile(target, "utf8");
|
|
352
|
+
if (!content.includes(text)) await writeFile(target, content + text);
|
|
353
|
+
};
|
|
354
|
+
const commit = async (name, message) => {
|
|
355
|
+
const { directory, fork } = folders[name];
|
|
356
|
+
const result = await pushSeedStep({
|
|
357
|
+
git: (args) => git(directory, args, state.keys[name]),
|
|
358
|
+
message,
|
|
359
|
+
remote: gitUrl(fork),
|
|
360
|
+
baseHead: state.baseHead,
|
|
361
|
+
lastPushAt: state.lastPushAt,
|
|
362
|
+
pushIntervalMinutes,
|
|
363
|
+
});
|
|
364
|
+
if (!result) return;
|
|
365
|
+
state.lastPushAt = result.at;
|
|
366
|
+
await save();
|
|
367
|
+
const pushed = {
|
|
368
|
+
actor: name,
|
|
369
|
+
repo: fork,
|
|
370
|
+
head: result.head,
|
|
371
|
+
at: new Date(state.lastPushAt).toISOString(),
|
|
372
|
+
};
|
|
373
|
+
await appendFile(
|
|
374
|
+
join(work, "pushes.jsonl"),
|
|
375
|
+
`${JSON.stringify(pushed)}\n`,
|
|
376
|
+
{ mode: 0o600 },
|
|
377
|
+
);
|
|
378
|
+
log(JSON.stringify({ pushed }));
|
|
379
|
+
await recordFirstTwo();
|
|
380
|
+
};
|
|
381
|
+
const alreadyMerged = (await readChanges()).changes.some(
|
|
382
|
+
(change) =>
|
|
383
|
+
change.sourceRepo === folders.morel.fork && change.state === "merged",
|
|
384
|
+
);
|
|
385
|
+
const morel = folders.morel.directory;
|
|
386
|
+
if (!alreadyMerged) {
|
|
387
|
+
await replace(morel, "src/pond.ts", '"#0b3d4f"', '"#164e63"');
|
|
388
|
+
await commit("morel", "Give the pond a brighter morning palette");
|
|
389
|
+
await replace(
|
|
390
|
+
morel,
|
|
391
|
+
"src/pond.ts",
|
|
392
|
+
"ctx.fillRect(0, 0, pond.width, pond.height);",
|
|
393
|
+
"ctx.fillRect(0, 0, pond.width, pond.height);\n ctx.strokeStyle = '#ffffff18';\n for (const pad of pond.pads) {\n ctx.beginPath();\n ctx.arc(pad.x, pad.y, 24 + (performance.now() / 80) % 32, 0, Math.PI * 2);\n ctx.stroke();\n }",
|
|
394
|
+
);
|
|
395
|
+
await commit("morel", "Draw gentle ripples around the lily pads");
|
|
396
|
+
await append(
|
|
397
|
+
morel,
|
|
398
|
+
"README.md",
|
|
399
|
+
"\n## Morning water\nMorel added expanding ripples around the lily pads and a brighter water palette.\n",
|
|
400
|
+
);
|
|
401
|
+
await commit("morel", "Document the morning water and ripple animation");
|
|
402
|
+
}
|
|
403
|
+
const chanterelle = folders.chanterelle.directory;
|
|
404
|
+
await replace(
|
|
405
|
+
chanterelle,
|
|
406
|
+
"src/fish.ts",
|
|
407
|
+
"speed: 0.6 + Math.random() * 0.8",
|
|
408
|
+
"speed: 0.4 + Math.random() * 0.6",
|
|
409
|
+
);
|
|
410
|
+
await commit("chanterelle", "Slow the koi to a calmer swimming pace");
|
|
411
|
+
await replace(
|
|
412
|
+
chanterelle,
|
|
413
|
+
"src/main.ts",
|
|
414
|
+
"const school = spawnFish(pond, 12);",
|
|
415
|
+
"const school = spawnFish(pond, 12);\naddEventListener('resize', () => {\n canvas.width = pond.width = innerWidth;\n canvas.height = pond.height = innerHeight;\n});",
|
|
416
|
+
);
|
|
417
|
+
await commit(
|
|
418
|
+
"chanterelle",
|
|
419
|
+
"Keep the pond canvas fitted when the window resizes",
|
|
420
|
+
);
|
|
421
|
+
await replace(
|
|
422
|
+
chanterelle,
|
|
423
|
+
"README.md",
|
|
424
|
+
"plain TypeScript with no framework.\n",
|
|
425
|
+
"plain TypeScript with no framework.\n\n## A calmer school\nChanterelle slowed the koi and keeps the canvas fitted to the window.\n",
|
|
426
|
+
);
|
|
427
|
+
await commit("chanterelle", "Document the resizing pond and calmer koi");
|
|
428
|
+
const enoki = folders.enoki.directory;
|
|
429
|
+
await replace(enoki, "src/pond.ts", '"#0b3d4f"', '"#272640"');
|
|
430
|
+
await commit("enoki", "Try a twilight palette for the pond");
|
|
431
|
+
await replace(enoki, "src/fish.ts", '"#f2994a"', '"#ffd166"');
|
|
432
|
+
await commit("enoki", "Make the koi visible against twilight water");
|
|
433
|
+
await append(
|
|
434
|
+
enoki,
|
|
435
|
+
"README.md",
|
|
436
|
+
"\n## Twilight experiment\nEnoki is exploring evening water and golden koi; the water palette needs a decision against Morel’s morning palette.\n",
|
|
437
|
+
);
|
|
438
|
+
await commit(
|
|
439
|
+
"enoki",
|
|
440
|
+
"Explain the twilight experiment and palette decision",
|
|
441
|
+
);
|
|
442
|
+
const pushedHeads = new Map(
|
|
443
|
+
await Promise.all(
|
|
444
|
+
names.map(async (name) => [
|
|
445
|
+
name,
|
|
446
|
+
await git(folders[name].directory, ["rev-parse", "HEAD"]),
|
|
447
|
+
]),
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
await until(
|
|
451
|
+
readChanges,
|
|
452
|
+
(value) =>
|
|
453
|
+
names.every((name) =>
|
|
454
|
+
value.changes.some(
|
|
455
|
+
(change) =>
|
|
456
|
+
change.sourceRepo === folders[name].fork &&
|
|
457
|
+
change.head === pushedHeads.get(name),
|
|
458
|
+
),
|
|
459
|
+
),
|
|
460
|
+
"three Changes at their final pushed heads",
|
|
461
|
+
);
|
|
462
|
+
// Explicit public open is idempotent for a still-open source branch.
|
|
463
|
+
for (const name of names) {
|
|
464
|
+
if (name === "morel" && alreadyMerged) continue;
|
|
465
|
+
await call(
|
|
466
|
+
"changes.open",
|
|
467
|
+
{
|
|
468
|
+
repo: folders[name].fork,
|
|
469
|
+
sourceRef: "main",
|
|
470
|
+
title: {
|
|
471
|
+
morel: "Morning water and lily-pad ripples",
|
|
472
|
+
chanterelle: "Calmer koi and a responsive pond",
|
|
473
|
+
enoki: "Twilight water and golden koi",
|
|
474
|
+
}[name],
|
|
475
|
+
},
|
|
476
|
+
changeRecord,
|
|
477
|
+
state.keys[name],
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
const proposed = (await readChanges()).changes;
|
|
481
|
+
if (agentsOnly) {
|
|
482
|
+
const receipt = {
|
|
483
|
+
origin,
|
|
484
|
+
installation,
|
|
485
|
+
repo,
|
|
486
|
+
phase: "awaiting-owner",
|
|
487
|
+
changes: proposed.map(
|
|
488
|
+
({ number, sourceRepo, sourceRef, head, state, status }) => ({
|
|
489
|
+
number,
|
|
490
|
+
sourceRepo,
|
|
491
|
+
sourceRef,
|
|
492
|
+
head,
|
|
493
|
+
state,
|
|
494
|
+
status,
|
|
495
|
+
}),
|
|
496
|
+
),
|
|
497
|
+
};
|
|
498
|
+
await writeFile(
|
|
499
|
+
join(work, "receipt.json"),
|
|
500
|
+
JSON.stringify(receipt, null, 2),
|
|
501
|
+
);
|
|
502
|
+
return receipt;
|
|
503
|
+
}
|
|
504
|
+
const morelChange = proposed.find(
|
|
505
|
+
(change) => change.sourceRepo === folders.morel.fork,
|
|
506
|
+
);
|
|
507
|
+
assert.ok(morelChange);
|
|
508
|
+
const input = { repo, number: morelChange.number, head: morelChange.head };
|
|
509
|
+
if (morelChange.state !== "merged") {
|
|
510
|
+
const attempt = await call(
|
|
511
|
+
"checks.request",
|
|
512
|
+
{ ...input, names: ["koi TypeScript"] },
|
|
513
|
+
checkRequestResult,
|
|
514
|
+
state.keys.morel,
|
|
515
|
+
);
|
|
516
|
+
assert.equal(attempt.kind, "requested");
|
|
517
|
+
assert.equal(
|
|
518
|
+
await git(morel, ["rev-parse", "HEAD"]),
|
|
519
|
+
input.head,
|
|
520
|
+
"Compiler must check the exact reported head",
|
|
521
|
+
);
|
|
522
|
+
// A real compiler runs on the exact proposed working tree before reporting passed.
|
|
523
|
+
try {
|
|
524
|
+
execFileSync(
|
|
525
|
+
fileURLToPath(new URL("../node_modules/.bin/tsc", import.meta.url)),
|
|
526
|
+
[
|
|
527
|
+
"--noEmit",
|
|
528
|
+
"--strict",
|
|
529
|
+
"--target",
|
|
530
|
+
"ES2022",
|
|
531
|
+
"--module",
|
|
532
|
+
"NodeNext",
|
|
533
|
+
"--moduleResolution",
|
|
534
|
+
"NodeNext",
|
|
535
|
+
"--lib",
|
|
536
|
+
"ES2022,DOM",
|
|
537
|
+
"--skipLibCheck",
|
|
538
|
+
"src/main.ts",
|
|
539
|
+
],
|
|
540
|
+
{ cwd: morel, stdio: ["ignore", "pipe", "pipe"] },
|
|
541
|
+
);
|
|
542
|
+
} catch (cause) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
"Koi TypeScript check failed; no passed check was reported",
|
|
545
|
+
{ cause },
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
const report = await call(
|
|
549
|
+
"checks.report",
|
|
550
|
+
{
|
|
551
|
+
...input,
|
|
552
|
+
name: "koi TypeScript",
|
|
553
|
+
status: "passed",
|
|
554
|
+
attemptId: attempt.attemptId,
|
|
555
|
+
summary:
|
|
556
|
+
"tsc --noEmit --strict passed on the proposed koi canvas source.",
|
|
557
|
+
},
|
|
558
|
+
checkReportResult,
|
|
559
|
+
state.keys.morel,
|
|
560
|
+
);
|
|
561
|
+
assert.equal(report.kind, "recorded");
|
|
562
|
+
const merged = await call("changes.merge", input, mergeResult);
|
|
563
|
+
if (merged.kind === "merging") {
|
|
564
|
+
// Queued; observe it to a terminal state instead of assuming an
|
|
565
|
+
// immediate kind.
|
|
566
|
+
let settled = null;
|
|
567
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
568
|
+
const polled = (await readChanges()).changes.find(
|
|
569
|
+
(change) => change.number === input.number,
|
|
570
|
+
);
|
|
571
|
+
if (polled?.status !== "merging") {
|
|
572
|
+
settled = polled;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
await delay(250);
|
|
576
|
+
}
|
|
577
|
+
assert.ok(settled, "A queued merge must reach a terminal state");
|
|
578
|
+
assert.equal(settled.state, "merged");
|
|
579
|
+
} else {
|
|
580
|
+
assert.equal(merged.kind, "merged");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const conflict = proposed.find(
|
|
584
|
+
(change) => change.sourceRepo === folders.enoki.fork,
|
|
585
|
+
);
|
|
586
|
+
assert.ok(conflict);
|
|
587
|
+
if (conflict.status !== "conflict") {
|
|
588
|
+
const result = await call(
|
|
589
|
+
"changes.merge",
|
|
590
|
+
{ repo, number: conflict.number, head: conflict.head },
|
|
591
|
+
mergeResult,
|
|
592
|
+
);
|
|
593
|
+
if (result.kind === "merging") {
|
|
594
|
+
// Queued; observe it to a terminal state instead of assuming an
|
|
595
|
+
// immediate kind.
|
|
596
|
+
let settled = null;
|
|
597
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
598
|
+
const polled = (await readChanges()).changes.find(
|
|
599
|
+
(change) => change.number === conflict.number,
|
|
600
|
+
);
|
|
601
|
+
if (polled?.status !== "merging") {
|
|
602
|
+
settled = polled;
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
await delay(250);
|
|
606
|
+
}
|
|
607
|
+
assert.ok(settled, "A queued merge must reach a terminal state");
|
|
608
|
+
assert.equal(settled.status, "conflict");
|
|
609
|
+
} else {
|
|
610
|
+
assert.equal(result.kind, "conflict");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
state.finished = true;
|
|
614
|
+
await save();
|
|
615
|
+
}
|
|
616
|
+
const changes = (await readChanges()).changes;
|
|
617
|
+
const selected = names.map((name) =>
|
|
618
|
+
changes.find((change) => change.sourceRepo === `${repo}--${name}`),
|
|
619
|
+
);
|
|
620
|
+
assert.ok(selected.every(Boolean));
|
|
621
|
+
for (const [index, name] of names.entries()) {
|
|
622
|
+
const commits = await git(join(work, name), [
|
|
623
|
+
"rev-list",
|
|
624
|
+
"--count",
|
|
625
|
+
`${state.baseHead}..${selected[index].head}`,
|
|
626
|
+
]);
|
|
627
|
+
assert.equal(
|
|
628
|
+
Number(commits),
|
|
629
|
+
3,
|
|
630
|
+
`${name} must contribute exactly three real commits`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
assert.equal(selected[0].state, "merged");
|
|
634
|
+
assert.equal(selected[1].state, "open");
|
|
635
|
+
assert.equal(selected[2].status, "conflict");
|
|
636
|
+
const merged = await call(
|
|
637
|
+
"changes.get",
|
|
638
|
+
{ repo, number: selected[0].number },
|
|
639
|
+
changeDetail,
|
|
640
|
+
);
|
|
641
|
+
assert.ok(
|
|
642
|
+
merged.review.checks.some(
|
|
643
|
+
(check) => check.head === merged.change.head && check.status === "passed",
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
const network = await call("graph.get", { repo }, graph);
|
|
647
|
+
for (const name of names)
|
|
648
|
+
assert.ok(network.lanes.some((lane) => lane.owner === name));
|
|
649
|
+
const receipt = {
|
|
650
|
+
origin,
|
|
651
|
+
installation,
|
|
652
|
+
repo,
|
|
653
|
+
changes: selected.map((change) => ({
|
|
654
|
+
number: change.number,
|
|
655
|
+
sourceRepo: change.sourceRepo,
|
|
656
|
+
head: change.head,
|
|
657
|
+
state: change.state,
|
|
658
|
+
status: change.status,
|
|
659
|
+
})),
|
|
660
|
+
commits: network.nodes.length,
|
|
661
|
+
};
|
|
662
|
+
await writeFile(join(work, "receipt.json"), JSON.stringify(receipt, null, 2));
|
|
663
|
+
return receipt;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function runSeedCommand(values) {
|
|
667
|
+
try {
|
|
668
|
+
assert.ok(
|
|
669
|
+
values.origin &&
|
|
670
|
+
values.work &&
|
|
671
|
+
(values["owner-key-file"] || values["agent-keys-file"]),
|
|
672
|
+
"Supply --origin, --work and the credential file for the selected phase",
|
|
673
|
+
);
|
|
674
|
+
assert.ok(
|
|
675
|
+
["prepare", "seed", "agents"].includes(values.phase),
|
|
676
|
+
"Phase must be prepare, seed or agents",
|
|
677
|
+
);
|
|
678
|
+
log(
|
|
679
|
+
JSON.stringify(
|
|
680
|
+
await seedDemo({
|
|
681
|
+
origin: values.origin,
|
|
682
|
+
work: values.work,
|
|
683
|
+
ownerKeyFile: values["owner-key-file"],
|
|
684
|
+
agentKeysFile: values["agent-keys-file"],
|
|
685
|
+
phase: values.phase,
|
|
686
|
+
pushIntervalMinutes: Number(values["push-interval-minutes"]),
|
|
687
|
+
}),
|
|
688
|
+
null,
|
|
689
|
+
2,
|
|
690
|
+
),
|
|
691
|
+
);
|
|
692
|
+
return { ok: true };
|
|
693
|
+
} catch (cause) {
|
|
694
|
+
// Only fixed classifications cross the CLI boundary; causes may contain keys.
|
|
695
|
+
return {
|
|
696
|
+
ok: false,
|
|
697
|
+
message:
|
|
698
|
+
cause instanceof z.ZodError
|
|
699
|
+
? "Bonsai demo seed failed: invalid public response or checkpoint."
|
|
700
|
+
: "Bonsai demo seed failed; no credentials or subprocess output logged.",
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (
|
|
706
|
+
process.argv[1] &&
|
|
707
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
708
|
+
) {
|
|
709
|
+
const { values } = parseArgs({
|
|
710
|
+
options: {
|
|
711
|
+
origin: { type: "string" },
|
|
712
|
+
work: { type: "string" },
|
|
713
|
+
"owner-key-file": { type: "string" },
|
|
714
|
+
"agent-keys-file": { type: "string" },
|
|
715
|
+
phase: { type: "string", default: "seed" },
|
|
716
|
+
"push-interval-minutes": { type: "string", default: "0" },
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
const result = await runSeedCommand(values);
|
|
720
|
+
if (!result.ok) {
|
|
721
|
+
error(result.message);
|
|
722
|
+
process.exitCode = 1;
|
|
723
|
+
}
|
|
724
|
+
}
|