@fungi.computer/bonsai 0.1.0 → 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,3 +1,5 @@
1
+ import * as S from "effect/Schema";
2
+ import { repositoryWorkflowSourceSchema } from "@fungi.computer/workflows-wire";
1
3
  import {
2
4
  ReadCopyBudgetExceeded,
3
5
  countingGit,
@@ -8,7 +10,7 @@ import {
8
10
  type AppGitPushFact,
9
11
  } from "@fungi.computer/agar/git";
10
12
  import type { StandardWebhookPayload } from "@fungi.computer/oyster/git";
11
- import { BonsaiApp } from "../backend/index.js";
13
+ import { BonsaiApp, type BonsaiEnv } from "../backend/index.js";
12
14
  import type { GitBinding } from "../backend/git.js";
13
15
  import type { HookEvent } from "../shared/hooks.js";
14
16
 
@@ -128,6 +130,7 @@ function growingGit(large: () => boolean): GitBinding {
128
130
  }
129
131
 
130
132
  export class BonsaiPushProbe extends BonsaiApp {
133
+ private readonly workflowRequests: unknown[];
131
134
  private readonly providerCalls: () => number;
132
135
  private readonly delivery: { failNext: boolean };
133
136
  private readonly emitted: {
@@ -162,8 +165,44 @@ export class BonsaiPushProbe extends BonsaiApp {
162
165
  limit: 10_000,
163
166
  signal: lifetime.signal,
164
167
  });
165
- super(ctx, {
168
+ const workflowRequests: unknown[] = [];
169
+ const unsupported = () =>
170
+ Promise.reject(
171
+ new Error("Unexpected APPS operation in lifecycle handoff proof"),
172
+ );
173
+ const bindings: BonsaiEnv = {
166
174
  GIT: counted.git,
175
+ APPS: {
176
+ register: unsupported,
177
+ fork: unsupported,
178
+ build: unsupported,
179
+ buildStatus: unsupported,
180
+ select: unsupported,
181
+ // Only pond is already registered. Reading this source must not build.
182
+ repository: ({ repo }: { repo: string }) =>
183
+ Promise.resolve(
184
+ repo === "pond"
185
+ ? S.decodeUnknownSync(repositoryWorkflowSourceSchema)({
186
+ appId: "app_pond",
187
+ installationId: "bonsai",
188
+ callerAppId: "app_bonsai",
189
+ callerSourceRevision: "git:" + "a".repeat(40),
190
+ })
191
+ : null,
192
+ ),
193
+ },
194
+ WORKFLOWS: {
195
+ runs: unused,
196
+ graphStatus: unused,
197
+ runGraph: (input) => {
198
+ workflowRequests.push(input);
199
+ return Promise.resolve({
200
+ kind: "started",
201
+ runId: "run_main",
202
+ planDigest: "sha256:" + "d".repeat(64),
203
+ });
204
+ },
205
+ },
167
206
  EVENTS: {
168
207
  emit: (type, payload, eventId) => {
169
208
  if (delivery.failNext) {
@@ -174,7 +213,9 @@ export class BonsaiPushProbe extends BonsaiApp {
174
213
  return Promise.resolve();
175
214
  },
176
215
  },
177
- });
216
+ };
217
+ super(ctx, bindings);
218
+ this.workflowRequests = workflowRequests;
178
219
  this.providerCalls = counted.total;
179
220
  this.emitted = emitted;
180
221
  this.delivery = delivery;
@@ -224,6 +265,9 @@ export class BonsaiPushProbe extends BonsaiApp {
224
265
  };
225
266
  }
226
267
  }
268
+ workflowStarts(): Promise<string> {
269
+ return Promise.resolve(JSON.stringify(this.workflowRequests));
270
+ }
227
271
  calls(): Promise<number> {
228
272
  return Promise.resolve(this.providerCalls());
229
273
  }
@@ -252,6 +296,10 @@ export default {
252
296
  ): Promise<Response> {
253
297
  const url = new URL(request.url);
254
298
  const probe = env.PROBE.getByName(url.searchParams.get("probe") ?? "push");
299
+ if (url.pathname === "/workflow-starts")
300
+ return new Response(await probe.workflowStarts(), {
301
+ headers: { "Content-Type": "application/json" },
302
+ });
255
303
  if (url.pathname === "/grow") return Response.json(await probe.grow());
256
304
  if (url.pathname === "/calls")
257
305
  return Response.json({ calls: await probe.calls() });
@@ -1,234 +1,77 @@
1
+ import { URL } from "node:url";
1
2
  import assert from "node:assert/strict";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { test } from "node:test";
4
- import { URL } from "node:url";
5
- import { Script } from "node:vm";
6
5
  import * as S from "effect/Schema";
7
- import * as z from "zod";
8
6
  import {
9
- catalogEvents,
10
7
  catalogMethods,
11
8
  compile,
12
- triggerRun,
13
9
  validate,
14
10
  workflowDefinitionSchema,
15
11
  } from "@fungi.computer/braid";
16
12
  import { buildAppApi } from "@fungi.computer/agar/backend-declaration";
17
- import { activityEnvelope } from "../shared/hooks.ts";
18
- import { appBackend as bonsaiBackend } from "../backend/methods.ts";
19
- const bonsaiApi = buildAppApi(bonsaiBackend);
13
+ import { appBackend } from "../backend/methods.ts";
20
14
 
21
- // The stock Workflows template that builds and installs an App on merge.
22
- async function buildOnMergeTemplate() {
23
- const template = z
24
- .object({ definition: z.json() })
25
- .parse(
26
- JSON.parse(
27
- await readFile(
28
- new URL(
29
- "../../trellis/templates/bonsai-build-on-merge.json",
30
- import.meta.url,
31
- ),
32
- "utf8",
33
- ),
15
+ async function template() {
16
+ const file = JSON.parse(
17
+ await readFile(
18
+ new URL(
19
+ "../../trellis/templates/bonsai-build-on-merge.json",
20
+ import.meta.url,
34
21
  ),
35
- );
36
- return S.decodeUnknownSync(workflowDefinitionSchema)(template.definition);
22
+ "utf8",
23
+ ),
24
+ );
25
+ return S.decodeUnknownSync(workflowDefinitionSchema)(file.definition);
37
26
  }
38
27
 
39
- async function installedCatalog() {
40
- return {
28
+ test("the lifecycle build template compiles against Bonsai's maintained method declarations", async () => {
29
+ const definition = await template();
30
+ const catalog = {
41
31
  app_bonsai: {
42
- methods: (await catalogMethods(bonsaiApi.methods)).methods,
43
- events: (await catalogEvents(bonsaiApi.events)).events,
32
+ methods: (await catalogMethods(buildAppApi(appBackend).methods)).methods,
44
33
  },
45
34
  };
46
- }
47
-
48
- function findNode(definition, id) {
49
- const node = definition.nodes.find((candidate) => candidate.id === id);
50
- assert.ok(node, `missing node ${id}`);
51
- return node;
52
- }
53
-
54
- // Only the external Code execution boundary is replaced by Node VM here.
55
- const evaluate = async (code, input) =>
56
- z.json().parse(
57
- await new Script(`(async () => { ${code} })()`).runInNewContext({
58
- env: { INPUT: { read: () => Promise.resolve(input) } },
59
- }),
60
- );
61
-
62
- const after = "c".repeat(40);
63
- // The envelope Bonsai's outbox emits when a Change merges into `repo`.
64
- function mergedEnvelope({ repo = "my-app", ref = "main" } = {}) {
65
- return activityEnvelope(
66
- {
67
- id: 41,
68
- at: Date.UTC(2026, 9, 15, 4),
69
- type: "merge",
70
- repo,
71
- actor: "morel",
72
- data: {
73
- change: {
74
- repo,
75
- number: 3,
76
- sourceRepo: `${repo}--morel`,
77
- sourceRef: "edit",
78
- targetRef: ref,
79
- head: "b".repeat(40),
80
- },
81
- from: `${repo}--morel`,
82
- ref,
83
- before: "a".repeat(40),
84
- after,
85
- },
86
- },
87
- {
88
- name: `${repo}--morel`,
89
- canonical: repo,
90
- cloneUrl: `https://bonsai.example/git/${repo}--morel.git`,
91
- },
92
- );
93
- }
94
-
95
- test("pins the build-on-merge template to Bonsai's maintained declarations", async () => {
96
- const definition = await buildOnMergeTemplate();
97
- const catalog = await installedCatalog();
98
35
  assert.deepEqual(validate(definition, catalog), []);
99
36
  const compiled = await compile(definition, catalog, {
100
37
  bonsai: { appId: "app_bonsai", installationId: "bonsai" },
101
38
  });
102
39
  assert.ok(compiled.ok, JSON.stringify(compiled));
103
- // No key, token or Git pass rides in the definition: every step is an App
104
- // method call made as the arming owner.
105
- const text = JSON.stringify(definition);
106
- assert.doesNotMatch(text, /gitAccess|OYSTER_OWNER_KEY|Authorization|token/iu);
107
- // The run starts the build of the merged commit, then waits on that job.
108
- const build = findNode(definition, "build");
109
- assert.ok(build.kind === "app");
110
- assert.equal(build.method, "apps.build");
111
- assert.deepEqual(build.values.sourceRevision, {
112
- from: { node: "merged", port: "sourceRevision" },
113
- });
114
- const status = findNode(definition, "build-status");
115
- assert.ok(status.kind === "app");
116
- assert.equal(status.method, "apps.buildStatus");
117
- assert.deepEqual(status.values.jobId, {
118
- from: { node: "build", port: "jobId" },
119
- });
120
- assert.equal(
121
- catalog.app_bonsai.methods["apps.buildStatus"].pending,
122
- true,
123
- "the Workflow waits on the job through a pending method",
40
+ assert.doesNotMatch(
41
+ JSON.stringify(definition),
42
+ /gitAccess|OYSTER_OWNER_KEY|Authorization|token/iu,
124
43
  );
125
- });
126
-
127
- test("starts on a merge into the App's source main, at the merged commit", async () => {
128
- const definition = await buildOnMergeTemplate();
129
- const trigger = definition.triggers?.find(
130
- (item) => item.on === "event" && item.event === "change.merged",
131
- );
132
- assert.ok(trigger);
133
- const payload = mergedEnvelope();
134
- const outcome = triggerRun(definition, trigger.id, {
135
- eventId: payload.data.eventId,
136
- payload,
137
- });
138
- assert.equal(outcome.kind, "start", JSON.stringify(outcome));
139
- // The owner setting defaults to showing the Workflows review UI.
140
- assert.equal(outcome.input.reviewBeforeInstall, true);
141
- const merged = findNode(definition, "merged");
142
- assert.ok(merged.kind === "code");
143
44
  assert.deepEqual(
144
- await evaluate(merged.source, {
145
- payload: outcome.input.payload,
146
- appId: outcome.input.appId,
147
- }),
148
- { appId: outcome.input.appId, sourceRevision: `git:${after}` },
45
+ definition.nodes.map((node) =>
46
+ node.kind === "app" ? node.method : node.kind,
47
+ ),
48
+ ["apps.build", "apps.verifyBuild", "apps.install"],
149
49
  );
150
- for (const other of [{ repo: "elsewhere" }, { ref: "release" }]) {
151
- const skipped = mergedEnvelope(other);
152
- assert.deepEqual(
153
- triggerRun(definition, trigger.id, {
154
- eventId: skipped.data.eventId,
155
- payload: skipped,
156
- }),
157
- { kind: "ignored" },
158
- JSON.stringify(other),
159
- );
160
- }
50
+ assert.equal(catalog.app_bonsai.methods["apps.verifyBuild"].pending, true);
161
51
  });
162
52
 
163
- test("installs only a passing build of the merged commit", async () => {
164
- const definition = await buildOnMergeTemplate();
165
- const built = findNode(definition, "built");
166
- assert.ok(built.kind === "code");
167
- const sourceRevision = `git:${after}`;
168
- const release = {
169
- appId: "app_myapp",
170
- releaseId: "rel_built",
171
- artifact: { provenance: { sourceRevision } },
172
- };
173
- assert.deepEqual(
174
- await evaluate(built.source, {
175
- status: "succeeded",
176
- detail: null,
177
- release,
178
- sourceRevision,
179
- }),
180
- {
181
- release,
182
- summary: `Built rel_built of app_myapp from ${sourceRevision}`,
183
- },
184
- );
185
- await assert.rejects(
186
- evaluate(built.source, {
187
- status: "failed",
188
- detail: "tsc exited 2",
189
- release: null,
190
- sourceRevision,
191
- }),
192
- /The build did not pass: tsc exited 2/u,
193
- );
194
- await assert.rejects(
195
- evaluate(built.source, {
196
- status: "succeeded",
197
- detail: null,
198
- release,
199
- sourceRevision: `git:${"d".repeat(40)}`,
200
- }),
201
- /not the merged commit/u,
202
- );
203
- });
204
-
205
- test("routes the Release through review only when Review before install is on", async () => {
206
- const definition = await buildOnMergeTemplate();
207
- const gate = findNode(definition, "review-gate");
208
- assert.ok(gate.kind === "condition");
209
- assert.deepEqual(gate.test, {
210
- op: "eq",
211
- left: { from: { node: "$input", port: "reviewBeforeInstall" } },
212
- right: { literal: true },
213
- });
214
- // The review is reached only from the gate's true outlet, automatic
215
- // install only from its false one; install takes whichever ran.
216
- const review = findNode(definition, "review");
217
- assert.ok(review.kind === "review");
218
- assert.deepEqual(review.show[0], { node: "to-review", port: "release" });
219
- assert.deepEqual(findNode(definition, "to-review").fields.review, {
220
- from: { node: "review-gate", port: "true" },
53
+ test("verification gates installation and the template neither configures nor promises review", async () => {
54
+ const definition = await template();
55
+ const verify = definition.nodes.find((node) => node.id === "verify");
56
+ const install = definition.nodes.find((node) => node.id === "install");
57
+ assert.ok(verify?.kind === "app");
58
+ assert.ok(install?.kind === "app");
59
+ assert.deepEqual(verify.values.jobId, {
60
+ from: { node: "build", port: "jobId" },
221
61
  });
222
- assert.deepEqual(findNode(definition, "automatic").fields.automatic, {
223
- from: { node: "review-gate", port: "false" },
62
+ assert.deepEqual(verify.values.sourceRevision, {
63
+ from: { node: "$input", port: "sourceRevision" },
224
64
  });
225
- const install = findNode(definition, "install");
226
- assert.ok(install.kind === "app");
227
- assert.equal(install.method, "apps.install");
228
65
  assert.deepEqual(install.values.release, {
229
- coalesce: [
230
- { from: { node: "review", port: "approved" } },
231
- { from: { node: "automatic", port: "release" } },
232
- ],
66
+ from: { node: "verify", port: "release" },
233
67
  });
68
+ assert.doesNotMatch(
69
+ JSON.stringify(definition),
70
+ /reviewBeforeInstall|review-gate|review before install|"kind":"review"/iu,
71
+ );
72
+ assert.equal(
73
+ definition.triggers,
74
+ undefined,
75
+ "the host lifecycle starts this template through WORKFLOWS, not a guest merge event",
76
+ );
234
77
  });
@@ -2,45 +2,51 @@ import { Buffer } from "node:buffer";
2
2
  import { execFileSync, spawn } from "node:child_process";
3
3
  import { once } from "node:events";
4
4
  import { createServer } from "node:https";
5
- import { mkdtemp, readFile } from "node:fs/promises";
5
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import process from "node:process";
9
9
  import { URL } from "node:url";
10
10
 
11
+ async function createCertificate() {
12
+ const certDir = await mkdtemp(join(tmpdir(), "storyboard-save-tls-"));
13
+ try {
14
+ const keyPath = join(certDir, "key.pem");
15
+ const certPath = join(certDir, "cert.pem");
16
+ execFileSync(
17
+ "openssl",
18
+ [
19
+ "req",
20
+ "-x509",
21
+ "-newkey",
22
+ "rsa:2048",
23
+ "-keyout",
24
+ keyPath,
25
+ "-out",
26
+ certPath,
27
+ "-days",
28
+ "1",
29
+ "-nodes",
30
+ "-subj",
31
+ "/CN=127.0.0.1",
32
+ ],
33
+ { stdio: ["ignore", "pipe", "pipe"] },
34
+ );
35
+ return { key: await readFile(keyPath), cert: await readFile(certPath) };
36
+ } finally {
37
+ await rm(certDir, { recursive: true, force: true });
38
+ }
39
+ }
40
+
11
41
  /**
12
42
  * A real git smart-HTTP server over real TLS (self-signed), proxying to the
13
43
  * maintained `git http-backend` CGI program against a directory of real bare
14
- * repositories. workflowGitPassResultSchema requires an `https:` remote
15
- * (packages/agar/src/computer-contracts.ts); this gives Shed's real `run`
16
- * handler and git's real client something genuine to fetch and push against,
17
- * rather than a stand-in local path.
44
+ * repositories. The host-supplied $FUNGI_GIT_REMOTE is an `https:` remote;
45
+ * this gives Shed's real `run` handler and git's real client something
46
+ * genuine to fetch and push against, rather than a stand-in local path.
18
47
  */
19
48
  export async function startHttpsGitServer(reposRoot) {
20
- const certDir = await mkdtemp(join(tmpdir(), "storyboard-save-tls-"));
21
- const keyPath = join(certDir, "key.pem");
22
- const certPath = join(certDir, "cert.pem");
23
- execFileSync(
24
- "openssl",
25
- [
26
- "req",
27
- "-x509",
28
- "-newkey",
29
- "rsa:2048",
30
- "-keyout",
31
- keyPath,
32
- "-out",
33
- certPath,
34
- "-days",
35
- "1",
36
- "-nodes",
37
- "-subj",
38
- "/CN=127.0.0.1",
39
- ],
40
- { stdio: ["ignore", "pipe", "pipe"] },
41
- );
42
- const key = await readFile(keyPath);
43
- const cert = await readFile(certPath);
49
+ const { key, cert } = await createCertificate();
44
50
  function cgi(request, response) {
45
51
  const url = new URL(request.url, "https://localhost");
46
52
  const child = spawn("git", ["http-backend"], {
@@ -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)),