@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,3 +1,14 @@
1
+ import {
2
+ runReply,
3
+ statusReply,
4
+ runsReplySchema,
5
+ } from "@fungi.computer/workflows-wire";
6
+ import {
7
+ startManualBuildWorkflow,
8
+ readBuildWorkflowRuns,
9
+ type BuildWorkflowBindings,
10
+ } from "./build-workflow.js";
11
+ import { resultRows } from "@fungi.computer/agar/method-presentation";
1
12
  import { openChangesGill, recentCommitsGill } from "./gills.js";
2
13
  import {
3
14
  pickerOptionsInput,
@@ -34,6 +45,8 @@ import {
34
45
  } from "@fungi.computer/agar/backend-definition";
35
46
  import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
36
47
  import * as z from "zod";
48
+ import * as S from "effect/Schema";
49
+ import { releaseSchema } from "@fungi.computer/puffball/effect";
37
50
  import {
38
51
  gitSha1Schema,
39
52
  gitRepositoryNameSchema,
@@ -41,6 +54,10 @@ import {
41
54
  import {
42
55
  activityPage,
43
56
  appBuildView,
57
+ appBuildWorkflowInput,
58
+ appBuildStatusInput,
59
+ appRegistrationInput,
60
+ appRegistration,
44
61
  approvalResult,
45
62
  checkRequestResult,
46
63
  checkReportResult,
@@ -101,6 +118,9 @@ export type AppsBindingReply = Readonly<{
101
118
 
102
119
  /** The host `APPS` binding: fork a source, build it, and install a Release. */
103
120
  export type BonsaiAppsBinding = Readonly<{
121
+ register(
122
+ input: z.infer<typeof appRegistrationInput>,
123
+ ): Promise<AppsBindingReply>;
104
124
  fork(input: {
105
125
  platformAppId: string;
106
126
  name: string;
@@ -108,9 +128,13 @@ export type BonsaiAppsBinding = Readonly<{
108
128
  build(input: {
109
129
  appId: string;
110
130
  sourceRevision: string;
131
+ installationId?: string;
111
132
  }): Promise<AppsBindingReply>;
112
- buildStatus(input: { jobId: string }): Promise<AppsBindingReply>;
133
+ buildStatus(
134
+ input: z.infer<typeof appBuildStatusInput>,
135
+ ): Promise<AppsBindingReply>;
113
136
  select(input: {
137
+ jobId: string;
114
138
  installationId: string;
115
139
  release: unknown;
116
140
  }): Promise<AppsBindingReply>;
@@ -119,10 +143,11 @@ export type BonsaiAppsBinding = Readonly<{
119
143
  /** What a Bonsai method runs against: the facet supplies it. */
120
144
  type BonsaiMethodContext = Readonly<{
121
145
  bonsai: Bonsai;
122
- env?: Readonly<{
123
- BACKGROUND?: BackgroundBinding;
124
- APPS?: BonsaiAppsBinding;
125
- }>;
146
+ env?: BuildWorkflowBindings &
147
+ Readonly<{
148
+ BACKGROUND?: BackgroundBinding;
149
+ APPS?: BonsaiAppsBinding;
150
+ }>;
126
151
  }>;
127
152
 
128
153
  const bindingErrorBody = z.object({ error: z.string() });
@@ -132,13 +157,15 @@ function refuseBindingReply(reply: AppsBindingReply): never {
132
157
  const parsed = bindingErrorBody.safeParse(reply.body);
133
158
  const message = parsed.success ? parsed.data.error : "Request refused";
134
159
  throw new AppMethodRefusal(
135
- reply.status === 404
136
- ? "not_found"
137
- : reply.status === 409
138
- ? "conflict"
139
- : reply.status === 403
140
- ? "forbidden"
141
- : "invalid",
160
+ reply.status >= 500
161
+ ? "unavailable"
162
+ : reply.status === 404
163
+ ? "not_found"
164
+ : reply.status === 409
165
+ ? "conflict"
166
+ : reply.status === 403
167
+ ? "forbidden"
168
+ : "invalid",
142
169
  message,
143
170
  );
144
171
  }
@@ -219,6 +246,72 @@ const definition = defineAppBackend<BonsaiMethodContext>({
219
246
  },
220
247
  },
221
248
  methods: {
249
+ "apps.register": defineAppMethod({
250
+ description:
251
+ "Make this repository an App using its main commit's package.json build recipe. Owner only; does not build or install it.",
252
+ effect: "write",
253
+ requires: ["git.repository"],
254
+ input: appRegistrationInput,
255
+ output: appRegistration,
256
+ handler: async (input, context: BonsaiMethodContext, caller) => {
257
+ if (caller.role !== "owner")
258
+ throw new AppMethodRefusal("forbidden", "Owner only");
259
+ if (!context.env?.APPS)
260
+ throw new AppMethodRefusal(
261
+ "unavailable",
262
+ "App registration is unavailable",
263
+ );
264
+ const reply = await context.env.APPS.register(input);
265
+ if (reply.status >= 400) refuseBindingReply(reply);
266
+ return appRegistration.parse(reply.body);
267
+ },
268
+ }),
269
+ "apps.startBuildWorkflow": defineAppMethod({
270
+ description:
271
+ "Build, verify and install this repository through the published Workflow. Owner only.",
272
+ effect: "write",
273
+ requires: ["git.repository", "workflow.run", "workflow.publish"],
274
+ input: appBuildWorkflowInput,
275
+ output: runReply,
276
+ handler: (input, context: BonsaiMethodContext, caller) => {
277
+ if (caller.role !== "owner")
278
+ throw new AppMethodRefusal("forbidden", "Owner only");
279
+ return startManualBuildWorkflow(context.env ?? {}, input);
280
+ },
281
+ }),
282
+ "apps.buildWorkflowRuns": defineAppMethod({
283
+ description:
284
+ "Discover up to fifty runs of this installation's build Workflow, including accepted main pushes. Read-only; owner only.",
285
+ effect: "read",
286
+ requires: ["workflow.run"],
287
+ input: z.strictObject({}),
288
+ output: runsReplySchema,
289
+ handler: (_input, context: BonsaiMethodContext, caller) => {
290
+ if (caller.role !== "owner")
291
+ throw new AppMethodRefusal("forbidden", "Owner only");
292
+ return readBuildWorkflowRuns(context.env ?? {});
293
+ },
294
+ }),
295
+ "apps.buildWorkflowRun": defineAppMethod({
296
+ description:
297
+ "Read this installation's build Workflow and its recorded step results. Owner only.",
298
+ effect: "read",
299
+ requires: ["workflow.run"],
300
+ input: z.strictObject({ runId: z.string().min(1).max(256) }),
301
+ output: statusReply,
302
+ handler: async (input, context: BonsaiMethodContext, caller) => {
303
+ if (caller.role !== "owner")
304
+ throw new AppMethodRefusal("forbidden", "Owner only");
305
+ if (context.env?.WORKFLOWS === undefined)
306
+ throw new AppMethodRefusal(
307
+ "unavailable",
308
+ "Workflow access is not granted",
309
+ );
310
+ return statusReply.parse(
311
+ await context.env.WORKFLOWS.graphStatus(input),
312
+ );
313
+ },
314
+ }),
222
315
  "apps.build": defineAppMethod({
223
316
  description:
224
317
  "Build an App's registered source at one exact commit on this Oyster's Computer; the job keeps running after this call returns. Owner only.",
@@ -229,28 +322,44 @@ const definition = defineAppBackend<BonsaiMethodContext>({
229
322
  input: z.strictObject({
230
323
  appId: z.string().min(1).max(256),
231
324
  sourceRevision: z.string().min(1).max(256),
325
+ installationId: z.string().min(1).max(256).optional(),
232
326
  }),
233
327
  output: appBuildView,
328
+ present: {
329
+ rows: resultRows,
330
+ verb: "started",
331
+ row: ({ output }) => ({
332
+ id: output.jobId,
333
+ title: "Build " + output.status,
334
+ detail: output.sourceRevision,
335
+ }),
336
+ },
234
337
  handler: async (input, context: BonsaiMethodContext, caller) => {
235
338
  if (caller.role !== "owner")
236
339
  throw new AppMethodRefusal("forbidden", "Owner only");
237
340
  if (!context.env?.APPS)
238
341
  throw new AppMethodRefusal("unavailable", "Build is unavailable");
239
- const reply = await context.env.APPS.build(input);
342
+ const request: Parameters<BonsaiAppsBinding["build"]>[0] = {
343
+ appId: input.appId,
344
+ sourceRevision: input.sourceRevision,
345
+ };
346
+ if (input.installationId !== undefined)
347
+ request.installationId = input.installationId;
348
+ const reply = await context.env.APPS.build(request);
240
349
  if (reply.status >= 400) refuseBindingReply(reply);
241
350
  return appBuildView.parse(reply.body);
242
351
  },
243
352
  }),
244
353
  "apps.buildStatus": defineAppMethod({
245
354
  description:
246
- "Wait for one build job: Pending while it runs, then its settled status and Release. Owner only.",
355
+ "Read one build job or discover this repository's latest job. Pending until build and automatic installation settle. Owner only.",
247
356
  effect: "read",
248
357
  // The host grants its APPS operation only under these approvals.
249
358
  requires: ["git.repository", "computer.execute"],
250
359
  // A Workflow step waits on the job by polling this same call.
251
360
  pending: true,
252
- input: z.strictObject({ jobId: z.string().min(1).max(256) }),
253
- output: appBuildView,
361
+ input: appBuildStatusInput,
362
+ output: appBuildView.nullable(),
254
363
  handler: async (input, context: BonsaiMethodContext, caller) => {
255
364
  if (caller.role !== "owner")
256
365
  throw new AppMethodRefusal("forbidden", "Owner only");
@@ -258,27 +367,104 @@ const definition = defineAppBackend<BonsaiMethodContext>({
258
367
  throw new AppMethodRefusal("unavailable", "Build is unavailable");
259
368
  const reply = await context.env.APPS.buildStatus(input);
260
369
  if (reply.status >= 400) refuseBindingReply(reply);
261
- const build = appBuildView.parse(reply.body);
262
- return build.status === "running" ? pending(5_000) : build;
370
+ const build = appBuildView.nullable().parse(reply.body);
371
+ if (build === null) return null;
372
+ return build.status === "running" ||
373
+ (build.status === "succeeded" &&
374
+ build.installation?.kind === "pending")
375
+ ? pending(5_000)
376
+ : build;
377
+ },
378
+ }),
379
+ "apps.verifyBuild": defineAppMethod({
380
+ description:
381
+ "Wait for the build's boot gate and verify that its Release is the exact requested commit. A failed or mismatched build never installs.",
382
+ effect: "read",
383
+ requires: ["git.repository", "computer.execute"],
384
+ pending: true,
385
+ input: z.strictObject({
386
+ jobId: z.string().min(1).max(256),
387
+ sourceRevision: z.string().regex(/^git:[0-9a-f]{40}$/),
388
+ }),
389
+ output: z.strictObject({
390
+ release: z.unknown(),
391
+ summary: z.string().max(4000),
392
+ }),
393
+ present: {
394
+ rows: resultRows,
395
+ verb: "verified",
396
+ row: ({ output }) => ({
397
+ id: "apps.verifyBuild",
398
+ title: "Release verified",
399
+ detail: output.summary,
400
+ }),
401
+ },
402
+ handler: async (input, context: BonsaiMethodContext, caller) => {
403
+ if (caller.role !== "owner")
404
+ throw new AppMethodRefusal("forbidden", "Owner only");
405
+ if (!context.env?.APPS)
406
+ throw new AppMethodRefusal("unavailable", "Build is unavailable");
407
+ const reply = await context.env.APPS.buildStatus({
408
+ jobId: input.jobId,
409
+ });
410
+ if (reply.status >= 400) refuseBindingReply(reply);
411
+ const build = appBuildView.nullable().parse(reply.body);
412
+ if (build === null)
413
+ throw new AppMethodRefusal("not_found", "Build not found");
414
+ if (build.status === "running") return pending(5_000);
415
+ if (build.status !== "succeeded" || build.release === null)
416
+ throw new AppMethodRefusal(
417
+ "invalid",
418
+ "The build did not pass" +
419
+ (build.detail === null ? "" : ": " + build.detail.slice(-2000)),
420
+ );
421
+ const release = S.decodeUnknownSync(releaseSchema)(build.release);
422
+ if (release.artifact.provenance.sourceRevision !== input.sourceRevision)
423
+ throw new AppMethodRefusal(
424
+ "invalid",
425
+ "The build's Release is not the requested commit",
426
+ );
427
+ return {
428
+ release,
429
+ summary:
430
+ "Built " +
431
+ release.releaseId +
432
+ " of " +
433
+ release.appId +
434
+ " from " +
435
+ input.sourceRevision,
436
+ };
263
437
  },
264
438
  }),
265
439
  "apps.install": defineAppMethod({
266
440
  description:
267
441
  "Install a built Release at an installation id, or switch an already-live one onto it. Owner only.",
268
442
  effect: "write",
443
+ pending: true,
269
444
  // The host grants its APPS operation only under these approvals.
270
445
  requires: ["git.repository", "computer.execute"],
271
446
  input: z.strictObject({
447
+ jobId: z.string().min(1).max(256),
272
448
  installationId: z.string().min(1).max(256),
273
449
  release: z.unknown(),
274
450
  }),
275
451
  output: z.unknown(),
452
+ present: {
453
+ rows: resultRows,
454
+ verb: "installed",
455
+ row: ({ input }) => ({
456
+ id: "apps.install",
457
+ title: "Release installed",
458
+ detail: input.installationId,
459
+ }),
460
+ },
276
461
  handler: async (input, context: BonsaiMethodContext, caller) => {
277
462
  if (caller.role !== "owner")
278
463
  throw new AppMethodRefusal("forbidden", "Owner only");
279
464
  if (!context.env?.APPS)
280
465
  throw new AppMethodRefusal("unavailable", "Install is unavailable");
281
466
  const reply = await context.env.APPS.select(input);
467
+ if (reply.status === 202) return pending(5000);
282
468
  if (reply.status >= 400) refuseBindingReply(reply);
283
469
  return reply.body;
284
470
  },
@@ -339,6 +525,18 @@ const definition = defineAppBackend<BonsaiMethodContext>({
339
525
  requires: [],
340
526
  input: z.strictObject({}),
341
527
  output: repoPage,
528
+ present: {
529
+ rows: resultRows,
530
+ verb: "listed",
531
+ row: ({ output }) => ({
532
+ id: "repos.list",
533
+ title: `Bonsai listed ${String(output.repos.length)} ${output.repos.length === 1 ? "repository" : "repositories"}`,
534
+ detail: output.repos
535
+ .slice(0, 5)
536
+ .map((repo) => repo.name)
537
+ .join(" · "),
538
+ }),
539
+ },
342
540
  handler: (_input, context) => ({
343
541
  repos: service.listRepos(context.bonsai),
344
542
  }),
@@ -684,7 +882,12 @@ const definition = defineAppBackend<BonsaiMethodContext>({
684
882
  effect: "write",
685
883
  // The continuation covers every write's requirements (apps.build's
686
884
  // computer.execute included); it only replays queued Git writes.
687
- requires: ["git.repository", "computer.execute"],
885
+ requires: [
886
+ "git.repository",
887
+ "computer.execute",
888
+ "workflow.run",
889
+ "workflow.publish",
890
+ ],
688
891
  input: z.strictObject({}),
689
892
  output: z.strictObject({ more: z.boolean() }),
690
893
  handler: (_input, context, caller) =>
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "@fungi.computer/bonsai",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "type": "module",
6
6
  "description": "Bonsai: a git platform for many agents working at once, on Workers and Artifacts",
7
7
  "dependencies": {
8
- "@fungi.computer/agar": "0.1.0",
9
- "@fungi.computer/caps": "0.0.2",
10
- "@fungi.computer/issues": "0.0.1",
11
- "@fungi.computer/oyster": "0.1.0",
12
- "@fungi.computer/puffball": "0.0.2",
13
- "@fungi.computer/stipe": "0.0.1",
8
+ "@fungi.computer/agar": "0.3.0",
9
+ "@fungi.computer/caps": "0.2.0",
10
+ "@fungi.computer/issues": "0.1.0",
11
+ "@fungi.computer/oyster": "0.3.0",
12
+ "@fungi.computer/puffball": "0.2.0",
13
+ "@fungi.computer/sigils": "0.2.0",
14
+ "@fungi.computer/stipe": "0.1.0",
14
15
  "@pierre/diffs": "1.3.6",
15
16
  "@pierre/trees": "1.0.0-beta.6",
16
17
  "@tanstack/react-query": "5.102.8",
@@ -22,12 +23,16 @@
22
23
  "react": "18.3.1",
23
24
  "react-dom": "18.3.1",
24
25
  "zod": "4.4.3",
25
- "@fungi.computer/watchdog": "0.0.1",
26
+ "@fungi.computer/watchdog": "0.1.0",
26
27
  "effect": "4.0.2",
27
- "@fungi.computer/cairn": "0.0.1",
28
- "@fungi.computer/maosaico": "0.0.1",
28
+ "@fungi.computer/cairn": "0.1.0",
29
+ "@fungi.computer/maosaico": "0.1.0",
29
30
  "@visx/hierarchy": "3.12.0",
30
- "@fungi.computer/gills": "0.0.1"
31
+ "@fungi.computer/gills": "0.2.0",
32
+ "@fungi.computer/nacre": "0.0.1",
33
+ "@fungi.computer/trellis": "0.2.0",
34
+ "@fungi.computer/braid": "0.2.0",
35
+ "@fungi.computer/workflows-wire": "0.2.0"
31
36
  },
32
37
  "devDependencies": {
33
38
  "playwright": "1.62.1",
@@ -43,12 +48,11 @@
43
48
  "vitest": "4.1.11",
44
49
  "wrangler": "4.135.0",
45
50
  "tsx": "4.23.12",
46
- "@fungi.computer/braid": "0.0.1",
47
- "@fungi.computer/gills": "0.0.1",
51
+ "@fungi.computer/gills": "0.2.0",
48
52
  "eslint": "10.9.1",
49
53
  "oxlint": "1.78.0",
50
54
  "jsdom": "29.0.1",
51
- "@fungi.computer/computer": "0.0.1"
55
+ "@fungi.computer/computer-contract": "0.0.1"
52
56
  },
53
57
  "botanical": {
54
58
  "role": "app",
@@ -89,7 +93,9 @@
89
93
  "git.repository",
90
94
  "apps.open",
91
95
  "app.events.emit",
92
- "computer.execute"
96
+ "computer.execute",
97
+ "workflow.run",
98
+ "workflow.publish"
93
99
  ],
94
100
  "receives": [
95
101
  "git.push"
@@ -117,9 +123,21 @@
117
123
  {
118
124
  "name": "APPS",
119
125
  "operations": [
126
+ "register",
127
+ "fork",
120
128
  "build",
121
129
  "buildStatus",
122
- "select"
130
+ "select",
131
+ "repository"
132
+ ]
133
+ },
134
+ {
135
+ "name": "WORKFLOWS",
136
+ "operations": [
137
+ "catalog",
138
+ "runGraph",
139
+ "graphStatus",
140
+ "runs"
123
141
  ]
124
142
  }
125
143
  ]
@@ -132,7 +150,25 @@
132
150
  ],
133
151
  "stylesheets": [
134
152
  "web/bonsai.css"
135
- ]
153
+ ],
154
+ "entry": "web/index.html",
155
+ "maxOutputBytes": 67108864,
156
+ "backend": {
157
+ "entry": "backend/index.ts",
158
+ "exportName": "BonsaiApp",
159
+ "compatibilityDate": "2026-09-04",
160
+ "runtime": {
161
+ "kind": "durable-facet",
162
+ "stateSchema": 1
163
+ }
164
+ },
165
+ "assetBase": "./",
166
+ "assets": {
167
+ "maxManifestBytes": 1048576,
168
+ "maxObjects": 1024,
169
+ "maxObjectBytes": 16777216,
170
+ "maxTotalBytes": 67108864
171
+ }
136
172
  }
137
173
  },
138
174
  "scripts": {
@@ -140,7 +176,7 @@
140
176
  "typecheck": "tsc -p tsconfig.json --noEmit",
141
177
  "lint": "eslint backend web shared test --max-warnings=0 && oxlint --config ../../oxlint.config.ts --deny-warnings backend web shared test",
142
178
  "test": "pnpm run test:unit && pnpm run test:merge",
143
- "test:unit": "vitest run && node --import tsx --test test/seed-resume.test.mjs test/insight-layout.test.mjs test/checks-workflow-template.test.mjs test/merge-template-build.test.mjs test/storyboard-save-template.test.mjs",
179
+ "test:unit": "vitest run && node --import tsx --test test/seed-resume.test.mjs test/insight-layout.test.mjs test/checks-workflow-template.test.mjs test/merge-template-build.test.mjs test/storyboard-save-template.test.mjs test/favicon.test.mjs",
144
180
  "test:merge": "node --import tsx test/git-push.native.mjs",
145
181
  "format:check": "node ../../tooling/repository/scripts/format-package-check.mjs",
146
182
  "lint:anti-slop": "node ../../tooling/repository/scripts/lint-package-anti-slop.mjs",
@@ -9,6 +9,10 @@ import process from "node:process";
9
9
  import { log } from "node:console";
10
10
  import { installRelease, packRelease } from "@fungi.computer/oyster/pack";
11
11
  import { standaloneAppSet } from "@fungi.computer/oyster/standalone/app-set";
12
+ import {
13
+ appBuildRecipe,
14
+ readBuildManifest,
15
+ } from "@fungi.computer/oyster/build";
12
16
 
13
17
  const root = fileURLToPath(new URL("../", import.meta.url));
14
18
  const repository = fileURLToPath(new URL("../../../", import.meta.url));
@@ -22,6 +26,13 @@ const revision = execFileSync("git", ["rev-parse", "HEAD"], {
22
26
  }).trim();
23
27
  const bonsai = standaloneAppSet.find((app) => app.installationId === "bonsai");
24
28
  if (!bonsai) throw new Error("The standalone App set must include Bonsai");
29
+ const manifest = readBuildManifest(
30
+ execFileSync(
31
+ "git",
32
+ ["show", revision + ":" + bonsai.directory + "/package.json"],
33
+ { cwd: repository, encoding: "utf8" },
34
+ ),
35
+ );
25
36
  const stamp = Date.now().toString(36);
26
37
  const outDirectory = `${root}.botanical/release`;
27
38
 
@@ -34,7 +45,7 @@ await packRelease({
34
45
  cwd: repository,
35
46
  encoding: "utf8",
36
47
  }).trim(),
37
- recipe: bonsai.recipe,
48
+ recipe: appBuildRecipe(manifest, bonsai.directory),
38
49
  outDirectory,
39
50
  });
40
51
 
@@ -20,7 +20,7 @@ import * as z from "zod";
20
20
  import { installRelease } from "@fungi.computer/oyster/pack";
21
21
  import { parseSourceRevision } from "@fungi.computer/puffball";
22
22
  import { releaseSchema } from "@fungi.computer/puffball/effect";
23
- import { workbenchDirectorySchema } from "@fungi.computer/oyster/shell/workbench-directory";
23
+ import { workbenchDirectorySchema } from "@fungi.computer/nacre/workbench-directory";
24
24
 
25
25
  const [packedDirectory, expectedRevision, tourtoisDirectory, tourtoisRevision] =
26
26
  z
@@ -227,11 +227,13 @@ try {
227
227
  "The unchanged Shed backend runs a native command",
228
228
  );
229
229
  const result = await command.json();
230
- assert.equal(result.status, "succeeded");
231
- assert.equal(result.output.status, "completed");
232
- assert.equal(result.output.stdout, "standalone-shed-terminal");
233
- assert.equal(result.output.stderr, "");
234
- assert.equal(result.output.exitCode, 0);
230
+ assert.deepEqual(result, {
231
+ stdout: "standalone-shed-terminal",
232
+ stderr: "",
233
+ exitCode: 0,
234
+ stdoutTruncated: false,
235
+ stderrTruncated: false,
236
+ });
235
237
 
236
238
  const directoryResponse = await globalThis.fetch(
237
239
  `${origin}/api/workbench/directory`,
@@ -377,16 +379,17 @@ try {
377
379
  "The VM Computer fixture's sole Computer is named workspace",
378
380
  );
379
381
  const nonce = randomBytes(8).toString("hex");
380
- // Shed's light prompt is the default surface; `full` escalates one
381
- // typed line to the real PTY. Once escalated, ShellPanel unmounts
382
- // LightShellPanel — only a PTY exit (never triggered in this proof)
383
- // remounts it — so every light-mode check below runs BEFORE the
384
- // first `full`, and every later command is typed straight into the
385
- // now-visible terminal instead.
386
- const prompt = shed.getByRole("textbox", {
387
- name: "Shell command",
388
- exact: true,
389
- });
382
+ // Shed is one Ghostty terminal whose light prompt is drawn on its
383
+ // canvas; `full` escalates one typed line to the real PTY in place, and
384
+ // every later command is typed into the same canvas. Typing goes to
385
+ // Ghostty's hidden key-capture textarea, which a click on the canvas
386
+ // focuses.
387
+ const canvas = shed.locator(".terminal-host canvas");
388
+ const typeLine = async (line) => {
389
+ await canvas.click();
390
+ await page.keyboard.type(line);
391
+ await page.keyboard.press("Enter");
392
+ };
390
393
  const receivedBytes = () =>
391
394
  Buffer.concat(received.filter((frame) => Buffer.isBuffer(frame)));
392
395
  const sentBytesLength = () =>
@@ -404,14 +407,15 @@ try {
404
407
  runCommandCalls.push(request.postDataJSON());
405
408
  });
406
409
  const beforeLightInput = sentBytesLength();
407
- await prompt.click();
408
- await prompt.fill("pwd");
409
- await prompt.press("Enter");
410
+ await typeLine("pwd");
410
411
  await expect
411
- .poll(() => runCommandCalls.some((call) => call.command === "pwd"), {
412
- message: "A light command calls Shed's runCommand method",
413
- timeout: 30_000,
414
- })
412
+ .poll(
413
+ () => runCommandCalls.some((call) => call.command.endsWith("; pwd")),
414
+ {
415
+ message: "A light command calls Shed's runCommand method",
416
+ timeout: 30_000,
417
+ },
418
+ )
415
419
  .toBe(true);
416
420
  assert.equal(
417
421
  sentBytesLength(),
@@ -420,9 +424,7 @@ try {
420
424
  );
421
425
 
422
426
  const marker = `oyster-${nonce}-command`;
423
- await prompt.click();
424
- await prompt.fill(`full printf 'oyster-%s-command\\n' '${nonce}'`);
425
- await prompt.press("Enter");
427
+ await typeLine(`full printf 'oyster-%s-command\\n' '${nonce}'`);
426
428
  await expect
427
429
  .poll(() => receivedBytes().includes(marker), {
428
430
  message: "A real shell command returns through Shed's terminal",
@@ -568,8 +570,8 @@ try {
568
570
  // Tourtois window chrome, not Shed's own light/full prompt, so they
569
571
  // work the same escalated or not.
570
572
  // Port 8099, not 8080: 8080 is computerDaemonPort, computerd's own
571
- // reserved control-plane port (packages/computer/src/
572
- // operation-contract.ts); starting a demo process there throws
573
+ // reserved control-plane port (packages/computer-contract/src/
574
+ // index.ts); starting a demo process there throws
573
575
  // ComputerPortReservedError, confirmed against the real VM.
574
576
  //
575
577
  // Not python3 -m http.server: packages/computer/Dockerfile only