@fungi.computer/bonsai 0.0.0-stage → 0.1.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 +43 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +848 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/present.test.ts +27 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +47 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +719 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- 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
|
+
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* These files drive real `git` subprocesses (init, http-backend pushes, clones,
|
|
5
|
+
* plumbing reads) through Agar's real-Git test kit. Each Git spawn costs a few
|
|
6
|
+
* milliseconds idle and tens under CI load, so a test that is about 1 s on an
|
|
7
|
+
* idle box crosses the 5 s default when the gate runs at several times
|
|
8
|
+
* oversubscription. They get their own project and timeout; the fast unit
|
|
9
|
+
* tests keep Vitest's default.
|
|
10
|
+
*/
|
|
11
|
+
const realGit = [
|
|
12
|
+
"test/background-runtime.test.ts",
|
|
13
|
+
"test/changes.test.ts",
|
|
14
|
+
"test/file-edits.test.ts",
|
|
15
|
+
"test/files-read-copy-calls.test.ts",
|
|
16
|
+
"test/graph-commit-read-copy-calls.test.ts",
|
|
17
|
+
"test/merge-background.test.ts",
|
|
18
|
+
"test/merge-conflict.test.ts",
|
|
19
|
+
"test/merge-repro.test.ts",
|
|
20
|
+
"test/read-copy-composer.test.ts",
|
|
21
|
+
"test/read-copy-lifetimes.test.ts",
|
|
22
|
+
"test/repository-deletion.test.ts",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
test: {
|
|
27
|
+
projects: [
|
|
28
|
+
{
|
|
29
|
+
test: {
|
|
30
|
+
name: "unit",
|
|
31
|
+
include: ["test/**/*.test.ts"],
|
|
32
|
+
exclude: realGit,
|
|
33
|
+
environment: "node",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
test: {
|
|
38
|
+
name: "real-git",
|
|
39
|
+
include: realGit,
|
|
40
|
+
environment: "node",
|
|
41
|
+
testTimeout: 30_000,
|
|
42
|
+
hookTimeout: 30_000,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
});
|
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 & 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’s source at one exact commit on this
|
|
72
|
+
Oyster’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
|
+
}
|