@fungi.computer/bonsai 0.0.0-stage → 0.0.1

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +835 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/read-copy-calls.test.ts +691 -0
  90. package/test/read-copy-composer.test.ts +388 -0
  91. package/test/read-copy-lifetimes.test.ts +214 -0
  92. package/test/read-copy-rpc.config.jsonc +12 -0
  93. package/test/read-copy-rpc.native.mjs +47 -0
  94. package/test/read-copy-rpc.worker.ts +92 -0
  95. package/test/recent-activity.test.ts +62 -0
  96. package/test/repository-deletion.test.ts +500 -0
  97. package/test/seed-resume.test.mjs +91 -0
  98. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  99. package/test/storyboard-save-template.test.mjs +545 -0
  100. package/test/ui-errors.test.ts +976 -0
  101. package/test/workflow-store.test.ts +115 -0
  102. package/tsconfig.json +36 -0
  103. package/turbo.jsonc +25 -0
  104. package/vite.config.ts +19 -0
  105. package/vitest.config.ts +5 -0
  106. package/web/api.ts +224 -0
  107. package/web/app-build-panel.tsx +171 -0
  108. package/web/board.tsx +259 -0
  109. package/web/bonsai.css +642 -0
  110. package/web/change-page.tsx +715 -0
  111. package/web/change-rows.tsx +135 -0
  112. package/web/code-tab.tsx +267 -0
  113. package/web/commit-tree.tsx +152 -0
  114. package/web/format.ts +54 -0
  115. package/web/home-page.tsx +274 -0
  116. package/web/index.html +17 -0
  117. package/web/insight-data.ts +76 -0
  118. package/web/insight-tiles.tsx +534 -0
  119. package/web/insights.tsx +250 -0
  120. package/web/issues-panel.tsx +305 -0
  121. package/web/main.tsx +72 -0
  122. package/web/network-graph.tsx +647 -0
  123. package/web/overview.tsx +339 -0
  124. package/web/pages.tsx +469 -0
  125. package/web/parts.tsx +381 -0
  126. package/web/public/favicon.svg +1 -0
  127. package/web/repo-navigation.tsx +256 -0
  128. package/web/repo-settings.tsx +145 -0
  129. package/web/router.tsx +151 -0
  130. package/web/transport.ts +91 -0
  131. package/web/views.tsx +151 -0
  132. package/web/working-now.tsx +98 -0
@@ -0,0 +1,115 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { expect, it } from "vitest";
3
+ import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
4
+ import {
5
+ createGraphWorkflowRuns,
6
+ createWorkflowCeilingCapture,
7
+ } from "@fungi.computer/braid/runtime";
8
+
9
+ it("refuses execution facts for an unknown run", () => {
10
+ const database = new DatabaseSync(":memory:");
11
+ try {
12
+ const store = createGraphWorkflowRuns(fromNodeSqlite(database));
13
+ expect(() => {
14
+ store.recordEvent({
15
+ runId: "missing",
16
+ nodeId: "node",
17
+ iteration: "0",
18
+ state: "running",
19
+ attempt: 0,
20
+ });
21
+ }).toThrow("not active");
22
+ expect(() => store.events("missing")).toThrow("Unknown graph run");
23
+ } finally {
24
+ database.close();
25
+ }
26
+ });
27
+
28
+ it("retains distinct Pending polls across reopen and excludes cancelled-run facts", async () => {
29
+ const { compile } = await import("@fungi.computer/braid");
30
+ const result = await compile(
31
+ {
32
+ format: "fungi.workflow/v1",
33
+ title: "Store law",
34
+ apps: {},
35
+ input: { type: "object", properties: {}, additionalProperties: false },
36
+ nodes: [
37
+ {
38
+ id: "done",
39
+ kind: "transform",
40
+ fields: { value: { literal: "done" } },
41
+ },
42
+ ],
43
+ edges: [],
44
+ output: { value: { node: "done", port: "value" } },
45
+ },
46
+ {},
47
+ {},
48
+ );
49
+ if (!result.ok) throw new Error(JSON.stringify(result.issues));
50
+ const database = new DatabaseSync(":memory:");
51
+ try {
52
+ const owner = fromNodeSqlite(database);
53
+ const store = createGraphWorkflowRuns(owner);
54
+ const unused = (): never => {
55
+ throw new Error("Transform store fixture has no App or Git node");
56
+ };
57
+ const ceiling = await createWorkflowCeilingCapture({
58
+ node: unused,
59
+ git: unused,
60
+ })(
61
+ {
62
+ plan: result.plan,
63
+ computerId: null,
64
+ initiatedBy: { kind: "member", memberId: "owner" },
65
+ source: "manual",
66
+ },
67
+ { kind: "member", memberId: "owner" },
68
+ { membershipVersion: 1 },
69
+ );
70
+ if (ceiling === null) throw new Error("Transform ceiling refused");
71
+ const run = {
72
+ runId: "run_one",
73
+ teamId: "library",
74
+ requestId: "request",
75
+ actorId: "owner",
76
+ computerId: null,
77
+ plan: result.plan,
78
+ input: {},
79
+ ceiling,
80
+ source: "manual" as const,
81
+ initiatedBy: { kind: "member" as const, memberId: "owner" },
82
+ };
83
+ expect(store.startCompiled(run)).toBe("started");
84
+ expect(store.startCompiled(run)).toBe("replay");
85
+ expect(store.startCompiled({ ...run, requestId: "changed" })).toBe(
86
+ "conflict",
87
+ );
88
+ const base = {
89
+ runId: run.runId,
90
+ nodeId: "done",
91
+ iteration: "0",
92
+ attempt: 0,
93
+ };
94
+ store.recordEvent({ ...base, state: "running", poll: 0 });
95
+ store.recordEvent({ ...base, state: "waiting", poll: 0 });
96
+ const reopened = createGraphWorkflowRuns(owner);
97
+ reopened.recordEvent({ ...base, state: "running", poll: 1 });
98
+ reopened.recordEvent({
99
+ ...base,
100
+ graphId: "body",
101
+ state: "running",
102
+ poll: 1,
103
+ });
104
+ expect(reopened.events(run.runId)).toHaveLength(4);
105
+ reopened.markTerminal(run.runId, "cancelled");
106
+ expect(() => {
107
+ reopened.recordEvent({ ...base, state: "complete", poll: 1 });
108
+ }).toThrow("not active");
109
+ reopened.markTerminal(run.runId, "terminal");
110
+ expect(reopened.outcome(run.runId)).toBe("cancelled");
111
+ expect(reopened.events(run.runId)).toHaveLength(4);
112
+ } finally {
113
+ database.close();
114
+ }
115
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "compilerOptions": {
3
+ "jsx": "react-jsx",
4
+ "noEmit": true,
5
+ "allowJs": false,
6
+ "allowUnreachableCode": false,
7
+ "allowUnusedLabels": false,
8
+ "exactOptionalPropertyTypes": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "isolatedModules": true,
11
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
12
+ "module": "NodeNext",
13
+ "moduleDetection": "force",
14
+ "moduleResolution": "NodeNext",
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noImplicitOverride": true,
17
+ "noImplicitReturns": true,
18
+ "noUncheckedIndexedAccess": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "resolveJsonModule": true,
22
+ "skipLibCheck": true,
23
+ "strict": true,
24
+ "target": "ES2022",
25
+ "types": ["@cloudflare/workers-types", "vitest/globals", "vite/client"],
26
+ "verbatimModuleSyntax": true
27
+ },
28
+ "include": [
29
+ "backend",
30
+ "web",
31
+ "shared",
32
+ "test",
33
+ "vite.config.ts",
34
+ "vitest.config.ts"
35
+ ]
36
+ }
package/turbo.jsonc ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://turborepo.com/schema.json",
3
+ "extends": ["//"],
4
+ "tasks": {
5
+ // The checks template test reads Shed's backend declaration and the
6
+ // Trellis App's templates; the storyboard template test builds Prints'
7
+ // and Inkcap's method catalogs from their sources; the merge test loads
8
+ // the celld runtime adapters.
9
+ "test:unit": {
10
+ "dependsOn": [
11
+ "build",
12
+ "@fungi.computer/shed#build",
13
+ "@fungi.computer/inkcap#build",
14
+ "@fungi.computer/prints#build",
15
+ ],
16
+ "inputs": [
17
+ "$TURBO_DEFAULT$",
18
+ "$TURBO_ROOT$/tooling/repository/scripts/{test-file-pool,proof-budget}.{mjs,d.mts}",
19
+ "$TURBO_ROOT$/.agents/skills/orchestrate-multi-lane-work/scripts/proof-time-bound.sh",
20
+ "$TURBO_ROOT$/tooling/celld-platform/runtime/**",
21
+ "$TURBO_ROOT$/apps/trellis/templates/**",
22
+ ],
23
+ },
24
+ },
25
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import tailwindcss from "@tailwindcss/vite";
3
+ import react from "@vitejs/plugin-react";
4
+ import { defineConfig } from "vite";
5
+
6
+ export default defineConfig({
7
+ envDir: false,
8
+ base: "./",
9
+ root: fileURLToPath(new URL("./web", import.meta.url)),
10
+ plugins: [react(), tailwindcss()],
11
+ build: {
12
+ target: "es2022",
13
+ outDir: fileURLToPath(new URL("./.botanical/web", import.meta.url)),
14
+ emptyOutDir: true,
15
+ },
16
+ server: {
17
+ proxy: { "/api": "http://127.0.0.1:8797", "/git": "http://127.0.0.1:8797" },
18
+ },
19
+ });
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: { include: ["test/**/*.test.ts"], environment: "node" },
5
+ });
package/web/api.ts ADDED
@@ -0,0 +1,224 @@
1
+ /** Bonsai's data hooks: every call is an App method, parsed at this boundary. */
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import {
5
+ activityPage,
6
+ approvalResult,
7
+ policyDecision,
8
+ agentPage,
9
+ changeDetail,
10
+ changePage,
11
+ commitDetail,
12
+ graph,
13
+ issueFiles,
14
+ mergeResult,
15
+ repoDetail,
16
+ repoPage,
17
+ type Activity,
18
+ } from "../shared/contract.js";
19
+ import { fileContent, fileList } from "@fungi.computer/agar/roles/files";
20
+ import { ApiError, callMethod, filesEditor } from "./transport.js";
21
+
22
+ export { ApiError };
23
+
24
+ /** The Team's files-role App, re-asked as the shell's role choice may change. */
25
+ export const useFilesEditor = () =>
26
+ useQuery({ queryKey: ["role", "files"], queryFn: filesEditor });
27
+
28
+ export const useRepos = () =>
29
+ useQuery({
30
+ queryKey: ["repos"],
31
+ queryFn: () => callMethod("repos.list", {}, repoPage),
32
+ });
33
+
34
+ export const useAgents = () =>
35
+ useQuery({
36
+ queryKey: ["agents"],
37
+ queryFn: () => callMethod("actors.list", {}, agentPage),
38
+ });
39
+
40
+ export const useRepo = (name: string) =>
41
+ useQuery({
42
+ queryKey: ["repo", name],
43
+ queryFn: () => callMethod("repos.get", { repo: name }, repoDetail),
44
+ });
45
+
46
+ export const useFile = (name: string, ref: string, path: string | null) =>
47
+ useQuery({
48
+ queryKey: ["blob", name, ref, path],
49
+ queryFn: () =>
50
+ callMethod(
51
+ "files.read",
52
+ { repo: name, ref, path: path ?? "" },
53
+ fileContent,
54
+ ),
55
+ enabled: path !== null,
56
+ });
57
+
58
+ export const useFiles = (name: string, ref: string, enabled = true) =>
59
+ useQuery({
60
+ queryKey: ["files", name, ref],
61
+ queryFn: () => callMethod("files.list", { repo: name, ref }, fileList),
62
+ enabled,
63
+ });
64
+
65
+ export const useIssueFiles = (name: string, ref: string) =>
66
+ useQuery({
67
+ queryKey: ["issues", name, ref],
68
+ queryFn: () => callMethod("issues.read", { repo: name, ref }, issueFiles),
69
+ });
70
+
71
+ export const useChanges = (name: string) =>
72
+ useQuery({
73
+ queryKey: ["changes", name],
74
+ queryFn: () => callMethod("changes.list", { repo: name }, changePage),
75
+ });
76
+
77
+ export const useChange = (repo: string, number: number) =>
78
+ useQuery({
79
+ queryKey: ["change", repo, number],
80
+ queryFn: () => callMethod("changes.get", { repo, number }, changeDetail),
81
+ });
82
+
83
+ export function mergeChange(repo: string, number: number, head: string) {
84
+ return callMethod("changes.merge", { repo, number, head }, mergeResult);
85
+ }
86
+
87
+ export function approveChange(repo: string, number: number, head: string) {
88
+ return callMethod("changes.approve", { repo, number, head }, approvalResult);
89
+ }
90
+
91
+ export function closeChange(repo: string, number: number) {
92
+ return callMethod("changes.close", { repo, number }, policyDecision);
93
+ }
94
+
95
+ export const useCommit = (name: string, hash: string) =>
96
+ useQuery({
97
+ queryKey: ["commit", name, hash],
98
+ queryFn: () =>
99
+ callMethod("commits.get", { repo: name, hash }, commitDetail),
100
+ staleTime: Number.POSITIVE_INFINITY,
101
+ });
102
+
103
+ export const useGraph = (name: string, enabled = true) =>
104
+ useQuery({
105
+ queryKey: ["graph", name],
106
+ queryFn: () => callMethod("graph.get", { repo: name }, graph),
107
+ enabled,
108
+ });
109
+
110
+ export const useEvents = (repo: string | null) =>
111
+ useQuery({
112
+ queryKey: ["events", repo],
113
+ queryFn: () =>
114
+ callMethod("activity.list", { repo, limit: 60 }, activityPage),
115
+ });
116
+
117
+ export type LiveState = Readonly<{
118
+ connected: boolean;
119
+ error: string | null;
120
+ /** Event ids that arrived while this page was open, for entrance motion. */
121
+ fresh: ReadonlySet<number>;
122
+ }>;
123
+
124
+ const POLL_MS = 2500;
125
+ const IDLE_POLL_MS = 5000;
126
+
127
+ function touches(event: Activity, key: readonly unknown[]): boolean {
128
+ const [kind, repo, number] = key;
129
+ const family = event.repo.split("--")[0] ?? event.repo;
130
+ if (kind === "events")
131
+ return repo === null || repo === family || repo === event.repo;
132
+ if (kind === "repos") return true;
133
+ if (kind === "agents")
134
+ return (
135
+ event.type === "push" ||
136
+ event.type === "fork" ||
137
+ event.type === "repo.created"
138
+ );
139
+ if ("change" in event.data) {
140
+ const change = event.data.change;
141
+ if (kind === "change")
142
+ return repo === change.repo && number === change.number;
143
+ if (kind === "changes") return repo === change.repo;
144
+ }
145
+ if (event.type === "push" || event.type === "merge") {
146
+ if (kind === "change") return repo === event.repo;
147
+ if (kind === "graph" || kind === "changes") return repo === family;
148
+ if (
149
+ kind === "repo" ||
150
+ kind === "files" ||
151
+ kind === "blob" ||
152
+ kind === "issues"
153
+ )
154
+ return repo === event.repo;
155
+ }
156
+ if (event.type === "fork") {
157
+ if (kind === "graph" || kind === "repo")
158
+ return repo === family || repo === event.data.fork;
159
+ }
160
+ return false;
161
+ }
162
+
163
+ /** Follow new activity and refresh whatever it touches. */
164
+ export function useLive(): LiveState {
165
+ const client = useQueryClient();
166
+ const [connected, setConnected] = useState(false);
167
+ const [error, setError] = useState<string | null>(null);
168
+ const [fresh, setFresh] = useState<ReadonlySet<number>>(new Set());
169
+ const cursor = useRef<number | null>(null);
170
+ useEffect(() => {
171
+ let stopped = false;
172
+ let timer: ReturnType<typeof setTimeout> | undefined;
173
+ let inFlight = false;
174
+ const canPoll = () => !stopped && !document.hidden;
175
+ const tick = async () => {
176
+ if (!canPoll() || inFlight) return;
177
+ inFlight = true;
178
+ let delay = POLL_MS;
179
+ try {
180
+ const page = await callMethod(
181
+ "activity.list",
182
+ { repo: null, limit: 50, after: cursor.current ?? 0 },
183
+ activityPage,
184
+ );
185
+ if (stopped) return;
186
+ setConnected(true);
187
+ setError(null);
188
+ const newest = page.events[0]?.id;
189
+ if (newest === undefined) delay = IDLE_POLL_MS;
190
+ if (cursor.current === null) cursor.current = newest ?? 0;
191
+ else if (newest !== undefined) {
192
+ cursor.current = newest;
193
+ const arrived: Activity[] = page.events;
194
+ setFresh(
195
+ (current) =>
196
+ new Set([...current, ...arrived.map((event) => event.id)]),
197
+ );
198
+ void client.invalidateQueries({
199
+ predicate: (query) =>
200
+ arrived.some((event) => touches(event, query.queryKey)),
201
+ });
202
+ }
203
+ } catch {
204
+ if (stopped) return;
205
+ setConnected(false);
206
+ setError("Live updates are unavailable. Retrying…");
207
+ }
208
+ inFlight = false;
209
+ if (canPoll()) timer = setTimeout(() => void tick(), delay);
210
+ };
211
+ const onVisibilityChange = () => {
212
+ if (timer !== undefined) clearTimeout(timer);
213
+ if (!document.hidden) void tick();
214
+ };
215
+ document.addEventListener("visibilitychange", onVisibilityChange);
216
+ void tick();
217
+ return () => {
218
+ stopped = true;
219
+ document.removeEventListener("visibilitychange", onVisibilityChange);
220
+ if (timer !== undefined) clearTimeout(timer);
221
+ };
222
+ }, [client]);
223
+ return { connected, error, fresh };
224
+ }
@@ -0,0 +1,171 @@
1
+ import { useState } from "react";
2
+ import { useMutation, useQuery } from "@tanstack/react-query";
3
+ import { z } from "zod";
4
+ import { Button } from "@fungi.computer/caps/components/button";
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from "@fungi.computer/caps/components/card";
11
+ import { Input } from "@fungi.computer/caps/components/input";
12
+ import { pendingSchema } from "@fungi.computer/agar";
13
+ import { appBuildView, jsonValue } from "../shared/contract.js";
14
+ import { callMethod } from "./transport.js";
15
+
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);
28
+
29
+ const build = useMutation({
30
+ mutationFn: () =>
31
+ callMethod("apps.build", { appId, sourceRevision }, buildView),
32
+ onSuccess: (job) => {
33
+ setJobId(job.jobId);
34
+ },
35
+ });
36
+ const status = useQuery({
37
+ queryKey: ["apps.buildStatus", jobId],
38
+ queryFn: () => {
39
+ if (jobId === null) throw new Error("No build job to check yet");
40
+ return callMethod("apps.buildStatus", { jobId }, buildStatus);
41
+ },
42
+ enabled: jobId !== null,
43
+ refetchInterval: (query) =>
44
+ query.state.data === undefined || "kind" in query.state.data
45
+ ? 3_000
46
+ : false,
47
+ });
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(),
59
+ );
60
+ },
61
+ });
62
+
63
+ const release = settled?.release ?? null;
64
+ return (
65
+ <Card className="w-full max-w-2xl" variant="outline">
66
+ <CardHeader>
67
+ <CardTitle level={2}>Build &amp; install</CardTitle>
68
+ </CardHeader>
69
+ <CardContent className="flex flex-col gap-4">
70
+ <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.
74
+ </p>
75
+ <form
76
+ className="flex flex-col gap-4"
77
+ onSubmit={(event) => {
78
+ event.preventDefault();
79
+ if (appId && sourceRevision && !build.isPending) build.mutate();
80
+ }}
81
+ >
82
+ <label className="flex flex-col gap-2" htmlFor="app-build-app-id">
83
+ <span>App id</span>
84
+ <Input
85
+ 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}
92
+ />
93
+ </label>
94
+ <label
95
+ className="flex flex-col gap-2"
96
+ htmlFor="app-build-source-revision"
97
+ >
98
+ <span>Source revision</span>
99
+ <Input
100
+ id="app-build-source-revision"
101
+ value={sourceRevision}
102
+ onChange={(event) => {
103
+ setSourceRevision(event.target.value);
104
+ }}
105
+ placeholder={`git:${"a".repeat(40)}`}
106
+ disabled={build.isPending}
107
+ />
108
+ </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
+ <label className="flex flex-col gap-2" htmlFor="app-install-id">
141
+ <span>Install at</span>
142
+ <Input
143
+ id="app-install-id"
144
+ value={installationId}
145
+ onChange={(event) => {
146
+ setInstallationId(event.target.value);
147
+ }}
148
+ placeholder="my-fork"
149
+ disabled={install.isPending}
150
+ />
151
+ </label>
152
+ {install.isError ? (
153
+ <div role="alert" className="text-sm text-error">
154
+ <p>{install.error.message}</p>
155
+ </div>
156
+ ) : null}
157
+ <div>
158
+ <Button
159
+ type="submit"
160
+ disabled={
161
+ !installationId || release === null || install.isPending
162
+ }
163
+ >
164
+ {install.isPending ? "Installing…" : "Install this Release"}
165
+ </Button>
166
+ </div>
167
+ </form>
168
+ </CardContent>
169
+ </Card>
170
+ );
171
+ }