@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 +66 -0
- package/README.md +66 -12
- package/backend/build-workflow.ts +137 -0
- package/backend/index.ts +13 -7
- package/backend/methods.ts +209 -19
- package/package.json +54 -18
- package/scripts/release.mjs +12 -1
- package/scripts/shed-terminal-proof.mjs +10 -8
- package/shared/contract.ts +41 -0
- package/test/apps-build-install.test.ts +399 -26
- package/test/browser/build-workflow.browser.mjs +104 -0
- package/test/browser/repository-browser.fixture.mjs +84 -0
- package/test/browser/repository-settings-fixture.tsx +198 -2
- package/test/browser/repository-settings.browser.mjs +188 -26
- package/test/checks-workflow-template.test.mjs +12 -10
- package/test/favicon.test.mjs +25 -0
- package/test/git-push.native.mjs +30 -0
- package/test/git-push.worker.ts +51 -3
- package/test/merge-template-build.test.mjs +44 -201
- package/test/storyboard-save-https-git.fixture.mjs +35 -29
- package/test/storyboard-save-template.test.mjs +61 -12
- package/vite.config.ts +19 -1
- package/web/app-build-panel.tsx +183 -95
- package/web/bonsai.css +1 -0
- package/web/index.html +0 -1
- package/web/repo-settings.tsx +1 -1
- package/web/public/favicon.svg +0 -1
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**:
|
|
116
|
-
that exact commit on
|
|
117
|
-
|
|
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 =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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> {
|
package/backend/methods.ts
CHANGED
|
@@ -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(
|
|
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?:
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
137
|
-
? "
|
|
138
|
-
: reply.status ===
|
|
139
|
-
? "
|
|
140
|
-
: reply.status ===
|
|
141
|
-
? "
|
|
142
|
-
:
|
|
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
|
|
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
|
-
"
|
|
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:
|
|
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
|
-
|
|
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: [
|
|
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) =>
|