@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/web/app-build-panel.tsx
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
import { replayKey } from "@fungi.computer/cairn";
|
|
1
2
|
import { useState } from "react";
|
|
2
|
-
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import * as S from "effect/Schema";
|
|
3
5
|
import { z } from "zod";
|
|
6
|
+
import { workflowDefinitionSchema } from "@fungi.computer/braid";
|
|
7
|
+
import { runReply } from "@fungi.computer/workflows-wire";
|
|
8
|
+
import {
|
|
9
|
+
RunTimeline,
|
|
10
|
+
OutputError,
|
|
11
|
+
runStatusSchema,
|
|
12
|
+
runSteps,
|
|
13
|
+
terminal,
|
|
14
|
+
timelineItems,
|
|
15
|
+
} from "@fungi.computer/trellis/run-timeline";
|
|
16
|
+
import template from "@fungi.computer/trellis/templates/bonsai-build-on-merge" with { type: "json" };
|
|
4
17
|
import { Button } from "@fungi.computer/caps/components/button";
|
|
5
18
|
import {
|
|
6
19
|
Card,
|
|
@@ -10,57 +23,83 @@ import {
|
|
|
10
23
|
} from "@fungi.computer/caps/components/card";
|
|
11
24
|
import { Input } from "@fungi.computer/caps/components/input";
|
|
12
25
|
import { pendingSchema } from "@fungi.computer/agar";
|
|
13
|
-
import { appBuildView,
|
|
26
|
+
import { appBuildView, appRegistration } from "../shared/contract.js";
|
|
14
27
|
import { callMethod } from "./transport.js";
|
|
15
28
|
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/** Start a build of a registered App's source, then install or switch its
|
|
22
|
-
* Release once it finishes. Owner only; the host re-checks that on every call. */
|
|
23
|
-
export function AppBuildPanel() {
|
|
24
|
-
const [appId, setAppId] = useState("");
|
|
25
|
-
const [sourceRevision, setSourceRevision] = useState("");
|
|
26
|
-
const [installationId, setInstallationId] = useState("");
|
|
27
|
-
const [jobId, setJobId] = useState<string | null>(null);
|
|
29
|
+
const buildStatus = z.union([pendingSchema, appBuildView]).nullable();
|
|
30
|
+
const definition = S.decodeUnknownSync(workflowDefinitionSchema)(
|
|
31
|
+
template.definition,
|
|
32
|
+
);
|
|
33
|
+
const appDisplay = { app_bonsai: { displayName: "Bonsai", icon: "bonsai" } };
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
/** Both this owner action and accepted main pushes start the same Workflow.
|
|
36
|
+
* Only its host-attested install step selects a Release; the panel never does. */
|
|
37
|
+
export function AppBuildPanel({ repo }: { repo: string }) {
|
|
38
|
+
const queryClient = useQueryClient();
|
|
39
|
+
const [appId, setAppId] = useState<string | null>(null);
|
|
40
|
+
const [sourceRevision, setSourceRevision] = useState<string | null>(null);
|
|
41
|
+
const [installationId, setInstallationId] = useState<string | null>(null);
|
|
42
|
+
const [runId, setRunId] = useState<string | null>(null);
|
|
43
|
+
const statusKey = ["apps.buildStatus", repo];
|
|
36
44
|
const status = useQuery({
|
|
37
|
-
queryKey:
|
|
45
|
+
queryKey: statusKey,
|
|
46
|
+
queryFn: () => callMethod("apps.buildStatus", { repo }, buildStatus),
|
|
47
|
+
refetchInterval: 5000,
|
|
48
|
+
});
|
|
49
|
+
const settled =
|
|
50
|
+
status.data == null || "kind" in status.data ? null : status.data;
|
|
51
|
+
const selectedAppId = appId ?? settled?.appId ?? "";
|
|
52
|
+
const selectedRevision = sourceRevision ?? settled?.sourceRevision ?? "";
|
|
53
|
+
const selectedInstallationId = installationId ?? repo;
|
|
54
|
+
const run = useQuery({
|
|
55
|
+
queryKey: ["apps.buildWorkflowRun", runId],
|
|
56
|
+
enabled: runId !== null,
|
|
38
57
|
queryFn: () => {
|
|
39
|
-
if (
|
|
40
|
-
return callMethod("apps.
|
|
58
|
+
if (runId === null) throw new Error("No Workflow run selected");
|
|
59
|
+
return callMethod("apps.buildWorkflowRun", { runId }, runStatusSchema);
|
|
41
60
|
},
|
|
42
|
-
enabled: jobId !== null,
|
|
43
61
|
refetchInterval: (query) =>
|
|
44
|
-
query.state.data
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
query.state.data !== undefined &&
|
|
63
|
+
(query.state.data.outcome !== "active" ||
|
|
64
|
+
terminal(query.state.data.status))
|
|
65
|
+
? false
|
|
66
|
+
: 2000,
|
|
47
67
|
});
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
const build = useMutation({
|
|
69
|
+
mutationFn: async () => {
|
|
70
|
+
const started = await callMethod(
|
|
71
|
+
"apps.startBuildWorkflow",
|
|
72
|
+
{
|
|
73
|
+
repo,
|
|
74
|
+
sourceRevision: selectedRevision,
|
|
75
|
+
installationId: selectedInstallationId,
|
|
76
|
+
requestId: replayKey(),
|
|
77
|
+
},
|
|
78
|
+
runReply,
|
|
59
79
|
);
|
|
80
|
+
if (
|
|
81
|
+
!("kind" in started) ||
|
|
82
|
+
(started.kind !== "started" && started.kind !== "replay")
|
|
83
|
+
)
|
|
84
|
+
throw new Error("Build Workflow refused: " + JSON.stringify(started));
|
|
85
|
+
return started;
|
|
86
|
+
},
|
|
87
|
+
onSuccess: (started) => {
|
|
88
|
+
setRunId(started.runId);
|
|
60
89
|
},
|
|
61
90
|
});
|
|
62
|
-
|
|
63
|
-
|
|
91
|
+
const registration = useMutation({
|
|
92
|
+
mutationFn: () => callMethod("apps.register", { repo }, appRegistration),
|
|
93
|
+
onSuccess: (registered) => {
|
|
94
|
+
setAppId(registered.appId);
|
|
95
|
+
setSourceRevision(registered.sourceRevision);
|
|
96
|
+
setInstallationId(repo);
|
|
97
|
+
setRunId(null);
|
|
98
|
+
queryClient.setQueryData(statusKey, null);
|
|
99
|
+
build.reset();
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const busy = build.isPending || registration.isPending;
|
|
64
103
|
return (
|
|
65
104
|
<Card className="w-full max-w-2xl" variant="outline">
|
|
66
105
|
<CardHeader>
|
|
@@ -68,27 +107,45 @@ export function AppBuildPanel() {
|
|
|
68
107
|
</CardHeader>
|
|
69
108
|
<CardContent className="flex flex-col gap-4">
|
|
70
109
|
<p className="text-sm text-[var(--stipe-fg-muted)]">
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
110
|
+
Register the committed App recipe, then start the Workflow to build,
|
|
111
|
+
verify and install your chosen revision. Its steps report what
|
|
112
|
+
actually happened. Only the owner may do this.
|
|
74
113
|
</p>
|
|
114
|
+
<div>
|
|
115
|
+
<Button
|
|
116
|
+
type="button"
|
|
117
|
+
disabled={busy}
|
|
118
|
+
onClick={() => {
|
|
119
|
+
registration.mutate();
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{registration.isPending ? "Registering…" : "Make this an App"}
|
|
123
|
+
</Button>
|
|
124
|
+
</div>
|
|
125
|
+
{registration.isError ? (
|
|
126
|
+
<p role="alert">{registration.error.message}</p>
|
|
127
|
+
) : null}
|
|
75
128
|
<form
|
|
76
129
|
className="flex flex-col gap-4"
|
|
77
130
|
onSubmit={(event) => {
|
|
78
131
|
event.preventDefault();
|
|
79
|
-
if (
|
|
132
|
+
if (
|
|
133
|
+
selectedAppId &&
|
|
134
|
+
selectedRevision &&
|
|
135
|
+
selectedInstallationId &&
|
|
136
|
+
!busy
|
|
137
|
+
)
|
|
138
|
+
build.mutate();
|
|
80
139
|
}}
|
|
81
140
|
>
|
|
82
141
|
<label className="flex flex-col gap-2" htmlFor="app-build-app-id">
|
|
83
142
|
<span>App id</span>
|
|
84
143
|
<Input
|
|
85
144
|
id="app-build-app-id"
|
|
86
|
-
value={
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
placeholder="app_my-fork"
|
|
91
|
-
disabled={build.isPending}
|
|
145
|
+
value={selectedAppId}
|
|
146
|
+
readOnly
|
|
147
|
+
placeholder="app_myfork"
|
|
148
|
+
disabled={busy}
|
|
92
149
|
/>
|
|
93
150
|
</label>
|
|
94
151
|
<label
|
|
@@ -98,73 +155,104 @@ export function AppBuildPanel() {
|
|
|
98
155
|
<span>Source revision</span>
|
|
99
156
|
<Input
|
|
100
157
|
id="app-build-source-revision"
|
|
101
|
-
value={
|
|
158
|
+
value={selectedRevision}
|
|
102
159
|
onChange={(event) => {
|
|
103
160
|
setSourceRevision(event.target.value);
|
|
104
161
|
}}
|
|
105
|
-
placeholder={
|
|
106
|
-
disabled={
|
|
162
|
+
placeholder={"git:" + "a".repeat(40)}
|
|
163
|
+
disabled={busy}
|
|
107
164
|
/>
|
|
108
165
|
</label>
|
|
109
|
-
{build.isError ? (
|
|
110
|
-
<div role="alert" className="text-sm text-error">
|
|
111
|
-
<p>{build.error.message}</p>
|
|
112
|
-
</div>
|
|
113
|
-
) : null}
|
|
114
|
-
<div>
|
|
115
|
-
<Button
|
|
116
|
-
type="submit"
|
|
117
|
-
disabled={!appId || !sourceRevision || build.isPending}
|
|
118
|
-
>
|
|
119
|
-
{build.isPending ? "Starting build…" : "Build"}
|
|
120
|
-
</Button>
|
|
121
|
-
</div>
|
|
122
|
-
</form>
|
|
123
|
-
{jobId !== null ? (
|
|
124
|
-
<p className="text-sm" role="status">
|
|
125
|
-
Job {jobId}: {settled?.status ?? "running…"}
|
|
126
|
-
{/* A succeeded build's detail is its phase timings, not a message. */}
|
|
127
|
-
{settled?.status !== "succeeded" && settled?.detail
|
|
128
|
-
? ` — ${settled.detail}`
|
|
129
|
-
: ""}
|
|
130
|
-
</p>
|
|
131
|
-
) : null}
|
|
132
|
-
<form
|
|
133
|
-
className="flex flex-col gap-4"
|
|
134
|
-
onSubmit={(event) => {
|
|
135
|
-
event.preventDefault();
|
|
136
|
-
if (installationId && release !== null && !install.isPending)
|
|
137
|
-
install.mutate();
|
|
138
|
-
}}
|
|
139
|
-
>
|
|
140
166
|
<label className="flex flex-col gap-2" htmlFor="app-install-id">
|
|
141
167
|
<span>Install at</span>
|
|
142
168
|
<Input
|
|
143
169
|
id="app-install-id"
|
|
144
|
-
value={
|
|
170
|
+
value={selectedInstallationId}
|
|
145
171
|
onChange={(event) => {
|
|
146
172
|
setInstallationId(event.target.value);
|
|
147
173
|
}}
|
|
148
|
-
|
|
149
|
-
disabled={install.isPending}
|
|
174
|
+
disabled={busy}
|
|
150
175
|
/>
|
|
151
176
|
</label>
|
|
152
|
-
{
|
|
153
|
-
<div role="alert" className="text-sm text-error">
|
|
154
|
-
<p>{install.error.message}</p>
|
|
155
|
-
</div>
|
|
156
|
-
) : null}
|
|
177
|
+
{build.isError ? <p role="alert">{build.error.message}</p> : null}
|
|
157
178
|
<div>
|
|
158
179
|
<Button
|
|
159
180
|
type="submit"
|
|
160
181
|
disabled={
|
|
161
|
-
!
|
|
182
|
+
!selectedAppId ||
|
|
183
|
+
!selectedRevision ||
|
|
184
|
+
!selectedInstallationId ||
|
|
185
|
+
busy
|
|
162
186
|
}
|
|
163
187
|
>
|
|
164
|
-
{
|
|
188
|
+
{build.isPending ? "Starting Workflow…" : "Build & install"}
|
|
165
189
|
</Button>
|
|
166
190
|
</div>
|
|
167
191
|
</form>
|
|
192
|
+
{runId !== null ? (
|
|
193
|
+
<section aria-label="Build Workflow" className="flex flex-col gap-3">
|
|
194
|
+
<p role="status">Workflow run {runId}</p>
|
|
195
|
+
{run.isPending ? (
|
|
196
|
+
<p role="status">Loading Workflow steps…</p>
|
|
197
|
+
) : null}
|
|
198
|
+
{run.isError ? <p role="alert">{run.error.message}</p> : null}
|
|
199
|
+
{run.data === undefined ? null : (
|
|
200
|
+
<>
|
|
201
|
+
<p role="status">
|
|
202
|
+
{run.data.title}: {run.data.status}
|
|
203
|
+
</p>
|
|
204
|
+
{run.data.error === null ? null : (
|
|
205
|
+
<OutputError error={run.data.error} />
|
|
206
|
+
)}
|
|
207
|
+
<RunTimeline
|
|
208
|
+
items={timelineItems(
|
|
209
|
+
runSteps(
|
|
210
|
+
run.data.events,
|
|
211
|
+
run.data.outcome,
|
|
212
|
+
run.data.reviews,
|
|
213
|
+
),
|
|
214
|
+
definition,
|
|
215
|
+
appDisplay,
|
|
216
|
+
)}
|
|
217
|
+
/>
|
|
218
|
+
</>
|
|
219
|
+
)}
|
|
220
|
+
</section>
|
|
221
|
+
) : (
|
|
222
|
+
<section
|
|
223
|
+
aria-label="Latest repository build"
|
|
224
|
+
className="flex flex-col gap-2"
|
|
225
|
+
>
|
|
226
|
+
{status.data != null && "kind" in status.data ? (
|
|
227
|
+
<p role="status">
|
|
228
|
+
Build or automatic installation is still running.
|
|
229
|
+
</p>
|
|
230
|
+
) : null}
|
|
231
|
+
{settled === null ? null : (
|
|
232
|
+
<p role="status">
|
|
233
|
+
Job {settled.jobId}: {settled.status}
|
|
234
|
+
{settled.status !== "succeeded" && settled.detail
|
|
235
|
+
? " — " + settled.detail
|
|
236
|
+
: ""}
|
|
237
|
+
</p>
|
|
238
|
+
)}
|
|
239
|
+
{status.isError ? <p role="alert">{status.error.message}</p> : null}
|
|
240
|
+
{settled?.installation?.kind === "needs_consent" ? (
|
|
241
|
+
<p role="status">
|
|
242
|
+
Needs owner consent. Automatic installation left any previous
|
|
243
|
+
Release unchanged.
|
|
244
|
+
</p>
|
|
245
|
+
) : null}
|
|
246
|
+
{settled?.installation?.kind === "installed" ? (
|
|
247
|
+
<p role="status">
|
|
248
|
+
Installed at {settled.installation.installationId}.
|
|
249
|
+
</p>
|
|
250
|
+
) : null}
|
|
251
|
+
{settled?.installation?.kind === "failed" ? (
|
|
252
|
+
<p role="alert">Not installed: {settled.installation.detail}</p>
|
|
253
|
+
) : null}
|
|
254
|
+
</section>
|
|
255
|
+
)}
|
|
168
256
|
</CardContent>
|
|
169
257
|
</Card>
|
|
170
258
|
);
|
package/web/bonsai.css
CHANGED
package/web/index.html
CHANGED
package/web/repo-settings.tsx
CHANGED
package/web/public/favicon.svg
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M9 23V9m0 4c0 4 14 2 14 9" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="9" cy="9" r="3" fill="currentColor"/><circle cx="9" cy="23" r="3" fill="currentColor"/><circle cx="23" cy="22" r="3" fill="currentColor"/></svg>
|