@fungi.computer/bonsai 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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, jsonValue } from "../shared/contract.js";
26
+ import { appBuildView, appRegistration } from "../shared/contract.js";
14
27
  import { callMethod } from "./transport.js";
15
28
 
16
- // The panel sends the Release back to apps.install as JSON.
17
- const buildView = appBuildView.extend({ release: jsonValue.nullable() });
18
- // While the job runs, apps.buildStatus answers Agar Pending.
19
- const buildStatus = z.union([pendingSchema, buildView]);
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
- const build = useMutation({
30
- mutationFn: () =>
31
- callMethod("apps.build", { appId, sourceRevision }, buildView),
32
- onSuccess: (job) => {
33
- setJobId(job.jobId);
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: ["apps.buildStatus", jobId],
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 (jobId === null) throw new Error("No build job to check yet");
40
- return callMethod("apps.buildStatus", { jobId }, buildStatus);
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 === undefined || "kind" in query.state.data
45
- ? 3_000
46
- : false,
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 settled =
49
- status.data === undefined || "kind" in status.data ? null : status.data;
50
- const install = useMutation({
51
- mutationFn: () => {
52
- const release = settled?.release;
53
- if (release === null || release === undefined)
54
- throw new Error("This build has no Release yet");
55
- return callMethod(
56
- "apps.install",
57
- { installationId, release },
58
- z.unknown(),
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
- const release = settled?.release ?? null;
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
- Build a registered App&rsquo;s source at one exact commit on this
72
- Oyster&rsquo;s Computer, then install the Release, or switch an
73
- already-live installation onto it. Only the owner may do this.
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 (appId && sourceRevision && !build.isPending) build.mutate();
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={appId}
87
- onChange={(event) => {
88
- setAppId(event.target.value);
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={sourceRevision}
158
+ value={selectedRevision}
102
159
  onChange={(event) => {
103
160
  setSourceRevision(event.target.value);
104
161
  }}
105
- placeholder={`git:${"a".repeat(40)}`}
106
- disabled={build.isPending}
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={installationId}
170
+ value={selectedInstallationId}
145
171
  onChange={(event) => {
146
172
  setInstallationId(event.target.value);
147
173
  }}
148
- placeholder="my-fork"
149
- disabled={install.isPending}
174
+ disabled={busy}
150
175
  />
151
176
  </label>
152
- {install.isError ? (
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
- !installationId || release === null || install.isPending
182
+ !selectedAppId ||
183
+ !selectedRevision ||
184
+ !selectedInstallationId ||
185
+ busy
162
186
  }
163
187
  >
164
- {install.isPending ? "Installing…" : "Install this Release"}
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
@@ -1,3 +1,4 @@
1
+ @source "../../trellis/src/runs";
1
2
  @import "@fungi.computer/caps/tailwind.css";
2
3
  @source "./";
3
4
 
package/web/index.html CHANGED
@@ -7,7 +7,6 @@
7
7
  name="description"
8
8
  content="Bonsai: a git platform where many agents work at once."
9
9
  />
10
- <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
11
10
  <title>Bonsai</title>
12
11
  </head>
13
12
  <body>
@@ -139,7 +139,7 @@ export function RepoSettings({ repo }: { repo: string }) {
139
139
  )}
140
140
  </CardContent>
141
141
  </Card>
142
- <AppBuildPanel />
142
+ <AppBuildPanel key={repo} repo={repo} />
143
143
  </div>
144
144
  );
145
145
  }
@@ -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>