@fungi.computer/bonsai 0.0.1 → 0.2.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.
@@ -1,10 +1,16 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { Buffer } from "node:buffer";
3
- import { spawn } from "node:child_process";
4
- import { execFile } from "node:child_process";
3
+ import { execFile, spawn } from "node:child_process";
5
4
  import { dirname, join } from "node:path";
6
5
  import { DatabaseSync } from "node:sqlite";
7
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
6
+ import {
7
+ mkdir,
8
+ mkdtemp,
9
+ readFile,
10
+ readdir,
11
+ rm,
12
+ writeFile,
13
+ } from "node:fs/promises";
8
14
  import { tmpdir } from "node:os";
9
15
  import process from "node:process";
10
16
  import { test } from "node:test";
@@ -24,7 +30,7 @@ import {
24
30
  // Shed's, Prints', Inkcap's and Bonsai's sources are pinned at runtime only;
25
31
  // Bonsai's compiler options do not own them.
26
32
  import { buildAppApi } from "@fungi.computer/agar/backend-declaration";
27
- import { mintComputerProcessId } from "@fungi.computer/computer/operations";
33
+ import { mintComputerProcessId } from "@fungi.computer/computer-contract";
28
34
  import { appBackend as bonsaiBackend } from "../backend/methods.ts";
29
35
  import { appBackend as shedBackend } from "../../shed/backend/methods.ts";
30
36
  import { createShedRuns } from "../../shed/backend/runs.ts";
@@ -37,6 +43,50 @@ import {
37
43
 
38
44
  const execFileAsync = promisify(execFile);
39
45
 
46
+ for (const startupFails of [false, true]) {
47
+ test(`HTTPS Git fixture removes certificate files after ${startupFails ? "startup failure" : "server close"}`, async (t) => {
48
+ const work = await mkdtemp(
49
+ join(tmpdir(), "storyboard-save-fixture-check-"),
50
+ );
51
+ t.after(() => rm(work, { recursive: true, force: true }));
52
+ const scratch = join(work, "tmp");
53
+ await mkdir(scratch);
54
+ const fixture = new URL(
55
+ "./storyboard-save-https-git.fixture.mjs",
56
+ import.meta.url,
57
+ );
58
+ const env = {
59
+ ...process.env,
60
+ HOME: work,
61
+ TMPDIR: scratch,
62
+ TMP: scratch,
63
+ TEMP: scratch,
64
+ };
65
+ if (startupFails) env.OPENSSL_CONF = join(work, "missing-openssl.cnf");
66
+ // tmpdir() is process-local. Give the real producer its own environment,
67
+ // rather than changing the test runner's environment or mocking openssl.
68
+ await execFileAsync(
69
+ process.execPath,
70
+ [
71
+ "--input-type=module",
72
+ "--eval",
73
+ `
74
+ import assert from "node:assert/strict";
75
+ import { startHttpsGitServer } from ${JSON.stringify(fixture.href)};
76
+ const startup = startHttpsGitServer(${JSON.stringify(work)});
77
+ ${
78
+ startupFails
79
+ ? "await assert.rejects(startup, { status: 1 });"
80
+ : "const server = await startup; await server.close();"
81
+ }
82
+ `,
83
+ ],
84
+ { env },
85
+ );
86
+ assert.deepEqual(await readdir(scratch), []);
87
+ });
88
+ }
89
+
40
90
  async function readTemplate(name) {
41
91
  const template = z
42
92
  .object({ definition: z.json() })
@@ -192,7 +242,13 @@ test("the Save to git flow runs through Shed's real run handler and Inkcap's rea
192
242
  procs.set(id, record);
193
243
  const child = spawn("bash", ["-c", localCommand], {
194
244
  cwd: cwd ? localize(cwd) : undefined,
195
- env: { ...process.env, GIT_SSL_NO_VERIFY: "true" },
245
+ // The trusted host supplies the Git remote after admission; Shed
246
+ // and its command carry no credential.
247
+ env: {
248
+ ...process.env,
249
+ GIT_SSL_NO_VERIFY: "true",
250
+ FUNGI_GIT_REMOTE: remote,
251
+ },
196
252
  });
197
253
  let stderr = "";
198
254
  child.stderr.on("data", (chunk) => {
@@ -209,13 +265,6 @@ test("the Save to git flow runs through Shed's real run handler and Inkcap's rea
209
265
  processes() {
210
266
  return Promise.resolve({ processes: [...procs.values()] });
211
267
  },
212
- workflowGitPass() {
213
- return Promise.resolve({
214
- passId: "pass_test",
215
- remote,
216
- expiresAt: Date.now() + 300000,
217
- });
218
- },
219
268
  async write({ path, content }) {
220
269
  const target = localize(path);
221
270
  await mkdir(dirname(target), { recursive: true });
package/vite.config.ts CHANGED
@@ -2,12 +2,30 @@ import { fileURLToPath } from "node:url";
2
2
  import tailwindcss from "@tailwindcss/vite";
3
3
  import react from "@vitejs/plugin-react";
4
4
  import { defineConfig } from "vite";
5
+ import { fungiFaviconHref } from "@fungi.computer/sigils/favicon";
5
6
 
6
7
  export default defineConfig({
7
8
  envDir: false,
8
9
  base: "./",
9
10
  root: fileURLToPath(new URL("./web", import.meta.url)),
10
- plugins: [react(), tailwindcss()],
11
+ plugins: [
12
+ react(),
13
+ tailwindcss(),
14
+ {
15
+ name: "fungi-favicon",
16
+ transformIndexHtml: () => [
17
+ {
18
+ tag: "link",
19
+ attrs: {
20
+ rel: "icon",
21
+ type: "image/svg+xml",
22
+ href: fungiFaviconHref,
23
+ },
24
+ injectTo: "head",
25
+ },
26
+ ],
27
+ },
28
+ ],
11
29
  build: {
12
30
  target: "es2022",
13
31
  outDir: fileURLToPath(new URL("./.botanical/web", import.meta.url)),
package/vitest.config.ts CHANGED
@@ -1,5 +1,47 @@
1
1
  import { defineConfig } from "vitest/config";
2
2
 
3
+ /**
4
+ * These files drive real `git` subprocesses (init, http-backend pushes, clones,
5
+ * plumbing reads) through Agar's real-Git test kit. Each Git spawn costs a few
6
+ * milliseconds idle and tens under CI load, so a test that is about 1 s on an
7
+ * idle box crosses the 5 s default when the gate runs at several times
8
+ * oversubscription. They get their own project and timeout; the fast unit
9
+ * tests keep Vitest's default.
10
+ */
11
+ const realGit = [
12
+ "test/background-runtime.test.ts",
13
+ "test/changes.test.ts",
14
+ "test/file-edits.test.ts",
15
+ "test/files-read-copy-calls.test.ts",
16
+ "test/graph-commit-read-copy-calls.test.ts",
17
+ "test/merge-background.test.ts",
18
+ "test/merge-conflict.test.ts",
19
+ "test/merge-repro.test.ts",
20
+ "test/read-copy-composer.test.ts",
21
+ "test/read-copy-lifetimes.test.ts",
22
+ "test/repository-deletion.test.ts",
23
+ ];
24
+
3
25
  export default defineConfig({
4
- test: { include: ["test/**/*.test.ts"], environment: "node" },
26
+ test: {
27
+ projects: [
28
+ {
29
+ test: {
30
+ name: "unit",
31
+ include: ["test/**/*.test.ts"],
32
+ exclude: realGit,
33
+ environment: "node",
34
+ },
35
+ },
36
+ {
37
+ test: {
38
+ name: "real-git",
39
+ include: realGit,
40
+ environment: "node",
41
+ testTimeout: 30_000,
42
+ hookTimeout: 30_000,
43
+ },
44
+ },
45
+ ],
46
+ },
5
47
  });
@@ -1,6 +1,19 @@
1
+ import { replayKey } from "@fungi.computer/cairn";
1
2
  import { useState } from "react";
2
- import { useMutation, useQuery } from "@tanstack/react-query";
3
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import * as S from "effect/Schema";
3
5
  import { z } from "zod";
6
+ import { workflowDefinitionSchema } from "@fungi.computer/braid";
7
+ import { runReply } from "@fungi.computer/workflows-wire";
8
+ import {
9
+ RunTimeline,
10
+ OutputError,
11
+ runStatusSchema,
12
+ runSteps,
13
+ terminal,
14
+ timelineItems,
15
+ } from "@fungi.computer/trellis/run-timeline";
16
+ import template from "@fungi.computer/trellis/templates/bonsai-build-on-merge" with { type: "json" };
4
17
  import { Button } from "@fungi.computer/caps/components/button";
5
18
  import {
6
19
  Card,
@@ -10,57 +23,83 @@ import {
10
23
  } from "@fungi.computer/caps/components/card";
11
24
  import { Input } from "@fungi.computer/caps/components/input";
12
25
  import { pendingSchema } from "@fungi.computer/agar";
13
- import { appBuildView, jsonValue } from "../shared/contract.js";
26
+ import { appBuildView, appRegistration } from "../shared/contract.js";
14
27
  import { callMethod } from "./transport.js";
15
28
 
16
- // The panel sends the Release back to apps.install as JSON.
17
- const buildView = appBuildView.extend({ release: jsonValue.nullable() });
18
- // While the job runs, apps.buildStatus answers Agar Pending.
19
- const buildStatus = z.union([pendingSchema, buildView]);
20
-
21
- /** Start a build of a registered App's source, then install or switch its
22
- * Release once it finishes. Owner only; the host re-checks that on every call. */
23
- export function AppBuildPanel() {
24
- const [appId, setAppId] = useState("");
25
- const [sourceRevision, setSourceRevision] = useState("");
26
- const [installationId, setInstallationId] = useState("");
27
- const [jobId, setJobId] = useState<string | null>(null);
29
+ const buildStatus = z.union([pendingSchema, appBuildView]).nullable();
30
+ const definition = S.decodeUnknownSync(workflowDefinitionSchema)(
31
+ template.definition,
32
+ );
33
+ const appDisplay = { app_bonsai: { displayName: "Bonsai", icon: "bonsai" } };
28
34
 
29
- const build = useMutation({
30
- mutationFn: () =>
31
- callMethod("apps.build", { appId, sourceRevision }, buildView),
32
- onSuccess: (job) => {
33
- setJobId(job.jobId);
34
- },
35
- });
35
+ /** Both this owner action and accepted main pushes start the same Workflow.
36
+ * Only its host-attested install step selects a Release; the panel never does. */
37
+ export function AppBuildPanel({ repo }: { repo: string }) {
38
+ const queryClient = useQueryClient();
39
+ const [appId, setAppId] = useState<string | null>(null);
40
+ const [sourceRevision, setSourceRevision] = useState<string | null>(null);
41
+ const [installationId, setInstallationId] = useState<string | null>(null);
42
+ const [runId, setRunId] = useState<string | null>(null);
43
+ const statusKey = ["apps.buildStatus", repo];
36
44
  const status = useQuery({
37
- queryKey: ["apps.buildStatus", jobId],
45
+ queryKey: statusKey,
46
+ queryFn: () => callMethod("apps.buildStatus", { repo }, buildStatus),
47
+ refetchInterval: 5000,
48
+ });
49
+ const settled =
50
+ status.data == null || "kind" in status.data ? null : status.data;
51
+ const selectedAppId = appId ?? settled?.appId ?? "";
52
+ const selectedRevision = sourceRevision ?? settled?.sourceRevision ?? "";
53
+ const selectedInstallationId = installationId ?? repo;
54
+ const run = useQuery({
55
+ queryKey: ["apps.buildWorkflowRun", runId],
56
+ enabled: runId !== null,
38
57
  queryFn: () => {
39
- if (jobId === null) throw new Error("No build job to check yet");
40
- return callMethod("apps.buildStatus", { jobId }, buildStatus);
58
+ if (runId === null) throw new Error("No Workflow run selected");
59
+ return callMethod("apps.buildWorkflowRun", { runId }, runStatusSchema);
41
60
  },
42
- enabled: jobId !== null,
43
61
  refetchInterval: (query) =>
44
- query.state.data === undefined || "kind" in query.state.data
45
- ? 3_000
46
- : false,
62
+ query.state.data !== undefined &&
63
+ (query.state.data.outcome !== "active" ||
64
+ terminal(query.state.data.status))
65
+ ? false
66
+ : 2000,
47
67
  });
48
- const settled =
49
- status.data === undefined || "kind" in status.data ? null : status.data;
50
- const install = useMutation({
51
- mutationFn: () => {
52
- const release = settled?.release;
53
- if (release === null || release === undefined)
54
- throw new Error("This build has no Release yet");
55
- return callMethod(
56
- "apps.install",
57
- { installationId, release },
58
- z.unknown(),
68
+ const build = useMutation({
69
+ mutationFn: async () => {
70
+ const started = await callMethod(
71
+ "apps.startBuildWorkflow",
72
+ {
73
+ repo,
74
+ sourceRevision: selectedRevision,
75
+ installationId: selectedInstallationId,
76
+ requestId: replayKey(),
77
+ },
78
+ runReply,
59
79
  );
80
+ if (
81
+ !("kind" in started) ||
82
+ (started.kind !== "started" && started.kind !== "replay")
83
+ )
84
+ throw new Error("Build Workflow refused: " + JSON.stringify(started));
85
+ return started;
86
+ },
87
+ onSuccess: (started) => {
88
+ setRunId(started.runId);
60
89
  },
61
90
  });
62
-
63
- const release = settled?.release ?? null;
91
+ const registration = useMutation({
92
+ mutationFn: () => callMethod("apps.register", { repo }, appRegistration),
93
+ onSuccess: (registered) => {
94
+ setAppId(registered.appId);
95
+ setSourceRevision(registered.sourceRevision);
96
+ setInstallationId(repo);
97
+ setRunId(null);
98
+ queryClient.setQueryData(statusKey, null);
99
+ build.reset();
100
+ },
101
+ });
102
+ const busy = build.isPending || registration.isPending;
64
103
  return (
65
104
  <Card className="w-full max-w-2xl" variant="outline">
66
105
  <CardHeader>
@@ -68,27 +107,45 @@ export function AppBuildPanel() {
68
107
  </CardHeader>
69
108
  <CardContent className="flex flex-col gap-4">
70
109
  <p className="text-sm text-[var(--stipe-fg-muted)]">
71
- Build a registered App&rsquo;s source at one exact commit on this
72
- Oyster&rsquo;s Computer, then install the Release, or switch an
73
- already-live installation onto it. Only the owner may do this.
110
+ Register the committed App recipe, then start the Workflow to build,
111
+ verify and install your chosen revision. Its steps report what
112
+ actually happened. Only the owner may do this.
74
113
  </p>
114
+ <div>
115
+ <Button
116
+ type="button"
117
+ disabled={busy}
118
+ onClick={() => {
119
+ registration.mutate();
120
+ }}
121
+ >
122
+ {registration.isPending ? "Registering…" : "Make this an App"}
123
+ </Button>
124
+ </div>
125
+ {registration.isError ? (
126
+ <p role="alert">{registration.error.message}</p>
127
+ ) : null}
75
128
  <form
76
129
  className="flex flex-col gap-4"
77
130
  onSubmit={(event) => {
78
131
  event.preventDefault();
79
- if (appId && sourceRevision && !build.isPending) build.mutate();
132
+ if (
133
+ selectedAppId &&
134
+ selectedRevision &&
135
+ selectedInstallationId &&
136
+ !busy
137
+ )
138
+ build.mutate();
80
139
  }}
81
140
  >
82
141
  <label className="flex flex-col gap-2" htmlFor="app-build-app-id">
83
142
  <span>App id</span>
84
143
  <Input
85
144
  id="app-build-app-id"
86
- value={appId}
87
- onChange={(event) => {
88
- setAppId(event.target.value);
89
- }}
90
- placeholder="app_my-fork"
91
- disabled={build.isPending}
145
+ value={selectedAppId}
146
+ readOnly
147
+ placeholder="app_myfork"
148
+ disabled={busy}
92
149
  />
93
150
  </label>
94
151
  <label
@@ -98,73 +155,104 @@ export function AppBuildPanel() {
98
155
  <span>Source revision</span>
99
156
  <Input
100
157
  id="app-build-source-revision"
101
- value={sourceRevision}
158
+ value={selectedRevision}
102
159
  onChange={(event) => {
103
160
  setSourceRevision(event.target.value);
104
161
  }}
105
- placeholder={`git:${"a".repeat(40)}`}
106
- disabled={build.isPending}
162
+ placeholder={"git:" + "a".repeat(40)}
163
+ disabled={busy}
107
164
  />
108
165
  </label>
109
- {build.isError ? (
110
- <div role="alert" className="text-sm text-error">
111
- <p>{build.error.message}</p>
112
- </div>
113
- ) : null}
114
- <div>
115
- <Button
116
- type="submit"
117
- disabled={!appId || !sourceRevision || build.isPending}
118
- >
119
- {build.isPending ? "Starting build…" : "Build"}
120
- </Button>
121
- </div>
122
- </form>
123
- {jobId !== null ? (
124
- <p className="text-sm" role="status">
125
- Job {jobId}: {settled?.status ?? "running…"}
126
- {/* A succeeded build's detail is its phase timings, not a message. */}
127
- {settled?.status !== "succeeded" && settled?.detail
128
- ? ` — ${settled.detail}`
129
- : ""}
130
- </p>
131
- ) : null}
132
- <form
133
- className="flex flex-col gap-4"
134
- onSubmit={(event) => {
135
- event.preventDefault();
136
- if (installationId && release !== null && !install.isPending)
137
- install.mutate();
138
- }}
139
- >
140
166
  <label className="flex flex-col gap-2" htmlFor="app-install-id">
141
167
  <span>Install at</span>
142
168
  <Input
143
169
  id="app-install-id"
144
- value={installationId}
170
+ value={selectedInstallationId}
145
171
  onChange={(event) => {
146
172
  setInstallationId(event.target.value);
147
173
  }}
148
- placeholder="my-fork"
149
- disabled={install.isPending}
174
+ disabled={busy}
150
175
  />
151
176
  </label>
152
- {install.isError ? (
153
- <div role="alert" className="text-sm text-error">
154
- <p>{install.error.message}</p>
155
- </div>
156
- ) : null}
177
+ {build.isError ? <p role="alert">{build.error.message}</p> : null}
157
178
  <div>
158
179
  <Button
159
180
  type="submit"
160
181
  disabled={
161
- !installationId || release === null || install.isPending
182
+ !selectedAppId ||
183
+ !selectedRevision ||
184
+ !selectedInstallationId ||
185
+ busy
162
186
  }
163
187
  >
164
- {install.isPending ? "Installing…" : "Install this Release"}
188
+ {build.isPending ? "Starting Workflow…" : "Build & install"}
165
189
  </Button>
166
190
  </div>
167
191
  </form>
192
+ {runId !== null ? (
193
+ <section aria-label="Build Workflow" className="flex flex-col gap-3">
194
+ <p role="status">Workflow run {runId}</p>
195
+ {run.isPending ? (
196
+ <p role="status">Loading Workflow steps…</p>
197
+ ) : null}
198
+ {run.isError ? <p role="alert">{run.error.message}</p> : null}
199
+ {run.data === undefined ? null : (
200
+ <>
201
+ <p role="status">
202
+ {run.data.title}: {run.data.status}
203
+ </p>
204
+ {run.data.error === null ? null : (
205
+ <OutputError error={run.data.error} />
206
+ )}
207
+ <RunTimeline
208
+ items={timelineItems(
209
+ runSteps(
210
+ run.data.events,
211
+ run.data.outcome,
212
+ run.data.reviews,
213
+ ),
214
+ definition,
215
+ appDisplay,
216
+ )}
217
+ />
218
+ </>
219
+ )}
220
+ </section>
221
+ ) : (
222
+ <section
223
+ aria-label="Latest repository build"
224
+ className="flex flex-col gap-2"
225
+ >
226
+ {status.data != null && "kind" in status.data ? (
227
+ <p role="status">
228
+ Build or automatic installation is still running.
229
+ </p>
230
+ ) : null}
231
+ {settled === null ? null : (
232
+ <p role="status">
233
+ Job {settled.jobId}: {settled.status}
234
+ {settled.status !== "succeeded" && settled.detail
235
+ ? " — " + settled.detail
236
+ : ""}
237
+ </p>
238
+ )}
239
+ {status.isError ? <p role="alert">{status.error.message}</p> : null}
240
+ {settled?.installation?.kind === "needs_consent" ? (
241
+ <p role="status">
242
+ Needs owner consent. Automatic installation left any previous
243
+ Release unchanged.
244
+ </p>
245
+ ) : null}
246
+ {settled?.installation?.kind === "installed" ? (
247
+ <p role="status">
248
+ Installed at {settled.installation.installationId}.
249
+ </p>
250
+ ) : null}
251
+ {settled?.installation?.kind === "failed" ? (
252
+ <p role="alert">Not installed: {settled.installation.detail}</p>
253
+ ) : null}
254
+ </section>
255
+ )}
168
256
  </CardContent>
169
257
  </Card>
170
258
  );
package/web/bonsai.css CHANGED
@@ -1,3 +1,4 @@
1
+ @source "../../trellis/src/runs";
1
2
  @import "@fungi.computer/caps/tailwind.css";
2
3
  @source "./";
3
4
 
@@ -14,7 +14,6 @@ import {
14
14
  } from "@fungi.computer/caps/components/data-list";
15
15
  import { InlineCode } from "@fungi.computer/caps/components/inline-code";
16
16
  import { Status } from "@fungi.computer/caps/components/status";
17
- import { StatusLed } from "@fungi.computer/caps/components/field-guide";
18
17
  import type { MergeResult, PolicyDecision } from "../shared/contract.js";
19
18
  import {
20
19
  useAgents,
@@ -569,14 +568,19 @@ export function ChangePage({ repo, number }: { repo: string; number: number }) {
569
568
  >
570
569
  <div className="min-w-0">
571
570
  <div className="flex flex-wrap items-center gap-2">
572
- <StatusLed
573
- active={check.status !== "queued"}
571
+ <Status
572
+ size="compact"
574
573
  tone={
575
574
  check.status === "passed"
576
- ? "fruiting"
575
+ ? "success"
577
576
  : check.status === "failed"
578
- ? "spore"
579
- : "mycelium"
577
+ ? "error"
578
+ : check.status === "running"
579
+ ? "info"
580
+ : "neutral"
581
+ }
582
+ motion={
583
+ check.status === "running" ? "bounce" : "none"
580
584
  }
581
585
  label={check.status}
582
586
  />
package/web/index.html CHANGED
@@ -7,7 +7,6 @@
7
7
  name="description"
8
8
  content="Bonsai: a git platform where many agents work at once."
9
9
  />
10
- <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
11
10
  <title>Bonsai</title>
12
11
  </head>
13
12
  <body>
@@ -139,7 +139,7 @@ export function RepoSettings({ repo }: { repo: string }) {
139
139
  )}
140
140
  </CardContent>
141
141
  </Card>
142
- <AppBuildPanel />
142
+ <AppBuildPanel key={repo} repo={repo} />
143
143
  </div>
144
144
  );
145
145
  }
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M9 23V9m0 4c0 4 14 2 14 9" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="9" cy="9" r="3" fill="currentColor"/><circle cx="9" cy="23" r="3" fill="currentColor"/><circle cx="23" cy="22" r="3" fill="currentColor"/></svg>