@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # @fungi.computer/bonsai
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Changes since 0.1.0:
8
+
9
+ - fix(oyster): keep Stage out of the starter release
10
+ - fix(shed): compact the model-facing command reply
11
+ - refactor(shed): send Workflow Git commands without any credential
12
+ - feat: show the Fungi sigil in the Bonsai and Tourtois tab
13
+ - fix(bonsai,oyster)!: share the repository App source wire through
14
+ workflows-wire
15
+ - fix(oyster,bonsai): export the types public signatures use
16
+ - refactor(bonsai): share authoring browser fixtures
17
+ - test(bonsai): follow Workflow-only Settings authoring
18
+ - fix(authoring): preserve Workflow refusals across lifecycle and native RPC
19
+ - Revert "fix(bonsai): remove impossible local App release command"
20
+ - fix(bonsai): remove impossible local App release command
21
+ - chore(b3a): checkpoint native workflow consumers and shared renderer types
22
+ - fix(b3a): align template and native proof consumers
23
+ - chore(b3a): checkpoint manual workflow integration
24
+ - refactor: move repository installation into Workflow nodes
25
+ - feat(apps): discover repository builds and install local Releases
26
+ - feat(apps): retain typed build installation outcomes in Settings
27
+ - test(apps): prove honest build refusals through host and Settings
28
+ - feat(bonsai): add repository App registration method and settings action
29
+ - fix(bonsai): clean up HTTPS Git fixture certificates
30
+ - feat(oyster): derive App build recipes from package metadata
31
+ - refactor(oyster)!: move the shell client into @fungi.computer/nacre
32
+ - refactor(computer)!: move the operation contract into computer-contract
33
+ - feat(oyster): add stage to the starter apps
34
+ - feat(oyster): public release 0.2.0
35
+
36
+ ### Patch Changes
37
+
38
+ - Updated dependencies
39
+ - Updated dependencies
40
+ - Updated dependencies
41
+ - Updated dependencies
42
+ - Updated dependencies
43
+ - Updated dependencies
44
+ - Updated dependencies
45
+ - Updated dependencies
46
+ - Updated dependencies
47
+ - Updated dependencies
48
+ - Updated dependencies
49
+ - Updated dependencies
50
+ - Updated dependencies
51
+ - Updated dependencies
52
+ - Updated dependencies
53
+ - @fungi.computer/agar@0.3.0
54
+ - @fungi.computer/braid@0.2.0
55
+ - @fungi.computer/cairn@0.1.0
56
+ - @fungi.computer/caps@0.2.0
57
+ - @fungi.computer/gills@0.2.0
58
+ - @fungi.computer/issues@0.1.0
59
+ - @fungi.computer/maosaico@0.1.0
60
+ - @fungi.computer/nacre@0.0.1
61
+ - @fungi.computer/oyster@0.3.0
62
+ - @fungi.computer/puffball@0.2.0
63
+ - @fungi.computer/sigils@0.2.0
64
+ - @fungi.computer/stipe@0.1.0
65
+ - @fungi.computer/trellis@0.2.0
66
+ - @fungi.computer/watchdog@0.1.0
67
+ - @fungi.computer/workflows-wire@0.2.0
68
+
3
69
  ## 0.1.0
4
70
 
5
71
  ### Minor Changes
package/README.md CHANGED
@@ -112,9 +112,10 @@ retried after 10 s, 1 min, 5 min, 30 min and 2 h;
112
112
 
113
113
  Bonsai also edits the other Apps on your Oyster. `apps.fork` copies an installed
114
114
  App's source into a new Bonsai repository you own. Push your changes to it, then
115
- open the repository's **Settings** and use **Build & install**: Oyster builds
116
- that exact commit on your Computer and installs the Release, or switches a live
117
- installation onto it while keeping its data. To fork Bonsai itself:
115
+ open the repository's **Settings** and use **Build & install**: the retained
116
+ Workflow builds that exact commit on a hosted Computer, verifies its Release,
117
+ and installs it or switches a live installation while keeping its data. To fork
118
+ Bonsai itself:
118
119
 
119
120
  ```sh
120
121
  curl -X POST -H "Authorization: Bearer $OYSTER_OWNER_KEY" -H 'content-type: application/json' \
@@ -129,6 +130,59 @@ your forks with `--repositories <base URL>` and `--ref <branch or tag>`. The
129
130
  installer reads all seven starter Apps from that base, so fork them under one
130
131
  account.
131
132
 
133
+ ## Make a repository an App
134
+
135
+ On a standalone Oyster, open the repository's **Settings → Make this an App**.
136
+ The owner-only `apps.register({ repo })` call reads the committed main root's
137
+ `package.json` through Oyster's shared manifest reader. The App owns its
138
+ `fungi.appId` identity and `fungi.build` recipe; a registered fork keeps its own
139
+ identity. Registration neither builds nor installs and never selects or starts a
140
+ Computer. Caller-supplied identities, recipes and source installations are not
141
+ registration inputs.
142
+
143
+ **Build & install** fills the readonly App ID and editable revision/name from
144
+ registration or the latest recorded job. Accepted main pushes and merges use the
145
+ host's accepted-ref journal, not a trusted App merge payload, to build the exact
146
+ commit on a hosted backend. A failed build leaves the running Release unchanged.
147
+ Automatic installation reuses approval only: new permissions show **Needs owner
148
+ consent** without switching Releases or offering a misleading install button.
149
+
150
+ The explicit owner button calls `apps.startBuildWorkflow` with the repository,
151
+ revision, installation name and a replay key. It starts the same published
152
+ build/verify/install definition as accepted main pushes; the panel does not call
153
+ `apps.build` or `apps.install` separately. `apps.buildWorkflowRun({ runId })`
154
+ reads its recorded steps for the shared Trellis cards. To discover automatic
155
+ runs without a browser-held ID, the owner can call `apps.buildWorkflowRuns({})`:
156
+ it reads at most fifty stock build Workflow runs from this installation's
157
+ `WORKFLOWS.runs` door. Neither read starts work or maintains a trigger ledger.
158
+
159
+ Settings discovers job status by `apps.buildStatus({ repo })` after reload; no
160
+ remembered browser job ID is required. No recorded job returns `null`, not an
161
+ invented success or failure. Workflows still poll their specific job with
162
+ `apps.buildStatus({ jobId })`; that call is Pending until build and automatic
163
+ installation settle. These are two current selectors for different callers. A
164
+ local-only Computer is not a build backend and returns typed `no_backend`. This
165
+ flow does not deploy a host or implement a separate consent ledger.
166
+
167
+ The retained Trellis **Build on merge** template is requested through the
168
+ Workflows capability by Bonsai's host-attested accepted-main lifecycle, with the
169
+ registered App identity and the exact accepted head. Its verification step is
170
+ **apps.verifyBuild({ jobId, sourceRevision })**: it waits only for the build,
171
+ not for installation, then returns the canonically decoded Release only if its
172
+ provenance matches. Build, verification and install each declare a result card.
173
+ The template has no review configuration or review step; installation requires
174
+ the Workflow's authority, not an App-origin installation exception.
175
+
176
+ **Breaking declaration contract:** Bonsai declares **WORKFLOWS.catalog**,
177
+ **WORKFLOWS.runGraph**, **WORKFLOWS.graphStatus**, **WORKFLOWS.runs**, Workflow
178
+ run/publication capabilities and **APPS.repository**. The latter is a read-only
179
+ lookup for the owner action or **onGitPush**, scoped to the live calling
180
+ installation; it neither registers a source nor selects a Computer. The retained
181
+ runGraph compiler requires catalog permission and reads the Apps bound by the
182
+ request; Bonsai declares that read operation rather than bypassing the host's
183
+ check. Updated declarations must go through the ordinary owner-consent path. Old
184
+ App-origin installation remains refused.
185
+
132
186
  ## Agent tools
133
187
 
134
188
  Every method is `POST $OYSTER_URL/apps/bonsai/methods/<name>` with a JSON body
@@ -136,15 +190,15 @@ and a key, and the same methods reach agents through Oyster's MCP endpoint; see
136
190
  [Git, keys and MCP](https://github.com/fungi-computer/oyster#git-keys-and-mcp).
137
191
  Bonsai's own UI uses the same API.
138
192
 
139
- | Area | Methods |
140
- | ------------ | ---------------------------------------------------------------------------------------------------------- |
141
- | Repositories | `repos.list`, `repos.get`, `repos.create`, `repos.fork`, `repos.delete`, `branches.options` |
142
- | Files | `files.tree`, `files.list`, `files.read`, `files.save`, `files.sync`, `files.discard`, `issues.read` |
143
- | History | `commits.list`, `commits.get`, `graph.get`, `activity.list`, `actors.list` |
144
- | Changes | `changes.list`, `changes.get`, `changes.open`, `changes.approve`, `changes.merge`, `changes.close`, `sync` |
145
- | Checks | `checks.request`, `checks.report` |
146
- | Insights | `insights.code`, `insights.layout.get`, `insights.layout.save` |
147
- | Apps (owner) | `apps.fork`, `apps.build`, `apps.buildStatus`, `apps.install` |
193
+ | Area | Methods |
194
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
195
+ | Repositories | `repos.list`, `repos.get`, `repos.create`, `repos.fork`, `repos.delete`, `branches.options` |
196
+ | Files | `files.tree`, `files.list`, `files.read`, `files.save`, `files.sync`, `files.discard`, `issues.read` |
197
+ | History | `commits.list`, `commits.get`, `graph.get`, `activity.list`, `actors.list` |
198
+ | Changes | `changes.list`, `changes.get`, `changes.open`, `changes.approve`, `changes.merge`, `changes.close`, `sync` |
199
+ | Checks | `checks.request`, `checks.report` |
200
+ | Insights | `insights.code`, `insights.layout.get`, `insights.layout.save` |
201
+ | Apps (owner) | `apps.register`, `apps.fork`, `apps.startBuildWorkflow`, `apps.buildWorkflowRuns`, `apps.buildWorkflowRun`, `apps.build`, `apps.buildStatus`, `apps.verifyBuild`, `apps.install` |
148
202
 
149
203
  ## How it is built
150
204
 
@@ -0,0 +1,137 @@
1
+ import * as S from "effect/Schema";
2
+ import { parseSha256Digest } from "@fungi.computer/puffball";
3
+ import {
4
+ appGitPushFactSchema,
5
+ type AppGitPushFact,
6
+ } from "@fungi.computer/agar/git";
7
+ import { contentDigest, workflowDefinitionSchema } from "@fungi.computer/braid";
8
+ import type { GraphStartRequest } from "@fungi.computer/braid/runtime";
9
+ import {
10
+ repositoryWorkflowSourceSchema,
11
+ runReply,
12
+ statusReply,
13
+ runsReplySchema,
14
+ } from "@fungi.computer/workflows-wire";
15
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
16
+ import type { AppBuildWorkflowInput } from "../shared/contract.js";
17
+ import template from "@fungi.computer/trellis/templates/bonsai-build-on-merge" with { type: "json" };
18
+
19
+ export type RepositoryWorkflowSource =
20
+ typeof repositoryWorkflowSourceSchema.Type;
21
+ export type BuildWorkflowRunReply = ReturnType<typeof runReply.parse>;
22
+
23
+ export type BuildWorkflowBindings = Readonly<{
24
+ APPS?: Readonly<{
25
+ repository(input: {
26
+ repo: string;
27
+ }): Promise<RepositoryWorkflowSource | null>;
28
+ }>;
29
+ WORKFLOWS?: Readonly<{
30
+ runs(input: {
31
+ workflowId?: string;
32
+ }): Promise<ReturnType<typeof runsReplySchema.parse>>;
33
+ runGraph(input: GraphStartRequest): Promise<BuildWorkflowRunReply>;
34
+ graphStatus(input: {
35
+ runId: string;
36
+ }): Promise<ReturnType<typeof statusReply.parse>>;
37
+ }>;
38
+ }>;
39
+
40
+ /** One request path for accepted main pushes and an explicit owner action.
41
+ * The host owns lifecycle identity; manual request IDs are ordinary call replay
42
+ * keys. Neither entrypoint constructs a call frame or installs as this App. */
43
+ async function requestBuildWorkflow(
44
+ env: BuildWorkflowBindings,
45
+ input: AppBuildWorkflowInput,
46
+ ) {
47
+ if (env.APPS === undefined)
48
+ throw new AppMethodRefusal(
49
+ "unavailable",
50
+ "App source lookup is unavailable",
51
+ );
52
+ const source = S.decodeUnknownSync(S.NullOr(repositoryWorkflowSourceSchema))(
53
+ await env.APPS.repository({ repo: input.repo }),
54
+ );
55
+ if (source === null) return null;
56
+ if (env.WORKFLOWS === undefined)
57
+ throw new AppMethodRefusal("unavailable", "Workflow access is not granted");
58
+ const stock = S.decodeUnknownSync(workflowDefinitionSchema)(
59
+ template.definition,
60
+ );
61
+ const definition = {
62
+ ...stock,
63
+ apps: { bonsai: { appId: source.callerAppId } },
64
+ };
65
+ const response = runReply.parse(
66
+ await env.WORKFLOWS.runGraph({
67
+ requestId: input.requestId,
68
+ definition,
69
+ bindings: { bonsai: source.installationId },
70
+ input: {
71
+ appId: source.appId,
72
+ sourceRevision: input.sourceRevision,
73
+ installationId: input.installationId,
74
+ },
75
+ published: {
76
+ workflowId: template.id,
77
+ commit: source.callerSourceRevision.slice(4),
78
+ digest: parseSha256Digest(await contentDigest(definition)),
79
+ },
80
+ }),
81
+ );
82
+ if (
83
+ !("kind" in response) ||
84
+ (response.kind !== "started" && response.kind !== "replay")
85
+ )
86
+ throw new AppMethodRefusal(
87
+ "kind" in response && response.kind === "invalid"
88
+ ? "invalid"
89
+ : "unavailable",
90
+ "Build Workflow refused: " + JSON.stringify(response).slice(0, 2000),
91
+ );
92
+ return response;
93
+ }
94
+
95
+ /** The host journal supplies the accepted main head, never an App merge event. */
96
+ export async function startBuildWorkflow(
97
+ env: BuildWorkflowBindings,
98
+ raw: AppGitPushFact,
99
+ ): Promise<void> {
100
+ const fact = appGitPushFactSchema.parse(raw);
101
+ const update = fact.updates.find(
102
+ (row) =>
103
+ row.ref === "refs/heads/main" &&
104
+ row.after !== "0".repeat(40) &&
105
+ row.before !== row.after,
106
+ );
107
+ if (update === undefined) return;
108
+ await requestBuildWorkflow(env, {
109
+ repo: fact.repository,
110
+ sourceRevision: "git:" + update.after,
111
+ installationId: fact.repository,
112
+ requestId: fact.eventId,
113
+ });
114
+ }
115
+
116
+ /** Owner calls use the same retained definition, verification and node authority. */
117
+ export async function startManualBuildWorkflow(
118
+ env: BuildWorkflowBindings,
119
+ input: AppBuildWorkflowInput,
120
+ ) {
121
+ const started = await requestBuildWorkflow(env, input);
122
+ if (started === null)
123
+ throw new AppMethodRefusal(
124
+ "not_found",
125
+ "This repository is not registered as an App",
126
+ );
127
+ return started;
128
+ }
129
+
130
+ /** Discover host-owned runs after a push, without materializing a trigger ledger. */
131
+ export async function readBuildWorkflowRuns(env: BuildWorkflowBindings) {
132
+ if (env.WORKFLOWS === undefined)
133
+ throw new AppMethodRefusal("unavailable", "Workflow access is not granted");
134
+ return runsReplySchema.parse(
135
+ await env.WORKFLOWS.runs({ workflowId: template.id }),
136
+ );
137
+ }
package/backend/index.ts CHANGED
@@ -32,15 +32,20 @@ import type {
32
32
  BackgroundBinding,
33
33
  } from "@fungi.computer/puffball/background-replay";
34
34
  import { refuseBackground } from "./background.js";
35
+ import {
36
+ startBuildWorkflow,
37
+ type BuildWorkflowBindings,
38
+ } from "./build-workflow.js";
35
39
 
36
40
  /** The bindings the host grants Bonsai (see the Oyster binding proposal). */
37
- export type BonsaiEnv = Readonly<{
38
- GIT: GitBinding;
39
- EVENTS: EventSink;
40
- BACKGROUND?: BackgroundBinding;
41
- /** Fork, build and install Apps; present only on a host that supplies it. */
42
- APPS?: BonsaiAppsBinding;
43
- }>;
41
+ export type BonsaiEnv = BuildWorkflowBindings &
42
+ Readonly<{
43
+ GIT: GitBinding;
44
+ EVENTS: EventSink;
45
+ BACKGROUND?: BackgroundBinding;
46
+ /** Fork, build and install Apps; present only on a host that supplies it. */
47
+ APPS?: BonsaiAppsBinding;
48
+ }>;
44
49
 
45
50
  const uiRequest = z.strictObject({
46
51
  method: z.string().min(1).max(80),
@@ -88,6 +93,7 @@ class BonsaiFacet extends DurableObject<BonsaiEnv> {
88
93
 
89
94
  async onGitPush(fact: AppGitPushFact): Promise<void> {
90
95
  await observePush(this.bonsai, fact);
96
+ await startBuildWorkflow(this.env, fact);
91
97
  }
92
98
 
93
99
  authorizeGitPush(raw: GitPushQuestion): Promise<GitPushVerdict> {
@@ -1,3 +1,13 @@
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";
1
11
  import { resultRows } from "@fungi.computer/agar/method-presentation";
2
12
  import { openChangesGill, recentCommitsGill } from "./gills.js";
3
13
  import {
@@ -35,6 +45,8 @@ import {
35
45
  } from "@fungi.computer/agar/backend-definition";
36
46
  import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
37
47
  import * as z from "zod";
48
+ import * as S from "effect/Schema";
49
+ import { releaseSchema } from "@fungi.computer/puffball/effect";
38
50
  import {
39
51
  gitSha1Schema,
40
52
  gitRepositoryNameSchema,
@@ -42,6 +54,10 @@ import {
42
54
  import {
43
55
  activityPage,
44
56
  appBuildView,
57
+ appBuildWorkflowInput,
58
+ appBuildStatusInput,
59
+ appRegistrationInput,
60
+ appRegistration,
45
61
  approvalResult,
46
62
  checkRequestResult,
47
63
  checkReportResult,
@@ -102,6 +118,9 @@ export type AppsBindingReply = Readonly<{
102
118
 
103
119
  /** The host `APPS` binding: fork a source, build it, and install a Release. */
104
120
  export type BonsaiAppsBinding = Readonly<{
121
+ register(
122
+ input: z.infer<typeof appRegistrationInput>,
123
+ ): Promise<AppsBindingReply>;
105
124
  fork(input: {
106
125
  platformAppId: string;
107
126
  name: string;
@@ -109,9 +128,13 @@ export type BonsaiAppsBinding = Readonly<{
109
128
  build(input: {
110
129
  appId: string;
111
130
  sourceRevision: string;
131
+ installationId?: string;
112
132
  }): Promise<AppsBindingReply>;
113
- buildStatus(input: { jobId: string }): Promise<AppsBindingReply>;
133
+ buildStatus(
134
+ input: z.infer<typeof appBuildStatusInput>,
135
+ ): Promise<AppsBindingReply>;
114
136
  select(input: {
137
+ jobId: string;
115
138
  installationId: string;
116
139
  release: unknown;
117
140
  }): Promise<AppsBindingReply>;
@@ -120,10 +143,11 @@ export type BonsaiAppsBinding = Readonly<{
120
143
  /** What a Bonsai method runs against: the facet supplies it. */
121
144
  type BonsaiMethodContext = Readonly<{
122
145
  bonsai: Bonsai;
123
- env?: Readonly<{
124
- BACKGROUND?: BackgroundBinding;
125
- APPS?: BonsaiAppsBinding;
126
- }>;
146
+ env?: BuildWorkflowBindings &
147
+ Readonly<{
148
+ BACKGROUND?: BackgroundBinding;
149
+ APPS?: BonsaiAppsBinding;
150
+ }>;
127
151
  }>;
128
152
 
129
153
  const bindingErrorBody = z.object({ error: z.string() });
@@ -133,13 +157,15 @@ function refuseBindingReply(reply: AppsBindingReply): never {
133
157
  const parsed = bindingErrorBody.safeParse(reply.body);
134
158
  const message = parsed.success ? parsed.data.error : "Request refused";
135
159
  throw new AppMethodRefusal(
136
- reply.status === 404
137
- ? "not_found"
138
- : reply.status === 409
139
- ? "conflict"
140
- : reply.status === 403
141
- ? "forbidden"
142
- : "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",
143
169
  message,
144
170
  );
145
171
  }
@@ -220,6 +246,72 @@ const definition = defineAppBackend<BonsaiMethodContext>({
220
246
  },
221
247
  },
222
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
+ }),
223
315
  "apps.build": defineAppMethod({
224
316
  description:
225
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.",
@@ -230,28 +322,44 @@ const definition = defineAppBackend<BonsaiMethodContext>({
230
322
  input: z.strictObject({
231
323
  appId: z.string().min(1).max(256),
232
324
  sourceRevision: z.string().min(1).max(256),
325
+ installationId: z.string().min(1).max(256).optional(),
233
326
  }),
234
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
+ },
235
337
  handler: async (input, context: BonsaiMethodContext, caller) => {
236
338
  if (caller.role !== "owner")
237
339
  throw new AppMethodRefusal("forbidden", "Owner only");
238
340
  if (!context.env?.APPS)
239
341
  throw new AppMethodRefusal("unavailable", "Build is unavailable");
240
- 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);
241
349
  if (reply.status >= 400) refuseBindingReply(reply);
242
350
  return appBuildView.parse(reply.body);
243
351
  },
244
352
  }),
245
353
  "apps.buildStatus": defineAppMethod({
246
354
  description:
247
- "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.",
248
356
  effect: "read",
249
357
  // The host grants its APPS operation only under these approvals.
250
358
  requires: ["git.repository", "computer.execute"],
251
359
  // A Workflow step waits on the job by polling this same call.
252
360
  pending: true,
253
- input: z.strictObject({ jobId: z.string().min(1).max(256) }),
254
- output: appBuildView,
361
+ input: appBuildStatusInput,
362
+ output: appBuildView.nullable(),
255
363
  handler: async (input, context: BonsaiMethodContext, caller) => {
256
364
  if (caller.role !== "owner")
257
365
  throw new AppMethodRefusal("forbidden", "Owner only");
@@ -259,27 +367,104 @@ const definition = defineAppBackend<BonsaiMethodContext>({
259
367
  throw new AppMethodRefusal("unavailable", "Build is unavailable");
260
368
  const reply = await context.env.APPS.buildStatus(input);
261
369
  if (reply.status >= 400) refuseBindingReply(reply);
262
- const build = appBuildView.parse(reply.body);
263
- 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
+ };
264
437
  },
265
438
  }),
266
439
  "apps.install": defineAppMethod({
267
440
  description:
268
441
  "Install a built Release at an installation id, or switch an already-live one onto it. Owner only.",
269
442
  effect: "write",
443
+ pending: true,
270
444
  // The host grants its APPS operation only under these approvals.
271
445
  requires: ["git.repository", "computer.execute"],
272
446
  input: z.strictObject({
447
+ jobId: z.string().min(1).max(256),
273
448
  installationId: z.string().min(1).max(256),
274
449
  release: z.unknown(),
275
450
  }),
276
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
+ },
277
461
  handler: async (input, context: BonsaiMethodContext, caller) => {
278
462
  if (caller.role !== "owner")
279
463
  throw new AppMethodRefusal("forbidden", "Owner only");
280
464
  if (!context.env?.APPS)
281
465
  throw new AppMethodRefusal("unavailable", "Install is unavailable");
282
466
  const reply = await context.env.APPS.select(input);
467
+ if (reply.status === 202) return pending(5000);
283
468
  if (reply.status >= 400) refuseBindingReply(reply);
284
469
  return reply.body;
285
470
  },
@@ -697,7 +882,12 @@ const definition = defineAppBackend<BonsaiMethodContext>({
697
882
  effect: "write",
698
883
  // The continuation covers every write's requirements (apps.build's
699
884
  // computer.execute included); it only replays queued Git writes.
700
- requires: ["git.repository", "computer.execute"],
885
+ requires: [
886
+ "git.repository",
887
+ "computer.execute",
888
+ "workflow.run",
889
+ "workflow.publish",
890
+ ],
701
891
  input: z.strictObject({}),
702
892
  output: z.strictObject({ more: z.boolean() }),
703
893
  handler: (_input, context, caller) =>