@dirwin517/bot-farm 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/.idea/bot-farm.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/package.json +13 -0
- package/src/api.mjs +567 -0
- package/src/cli.mjs +241 -0
- package/src/craft.mjs +172 -0
- package/src/db.mjs +93 -0
- package/src/farmhands.mjs +111 -0
- package/src/git.mjs +578 -0
- package/src/identity.mjs +199 -0
- package/src/legacy-defaults.mjs +44 -0
- package/src/mcp.mjs +681 -0
- package/src/mesh.mjs +222 -0
- package/src/packets.mjs +125 -0
- package/src/patches.mjs +141 -0
- package/src/pipelines.mjs +2145 -0
- package/src/pricing.mjs +91 -0
- package/src/projects.mjs +148 -0
- package/src/public/classic.html +1810 -0
- package/src/public/index.html +3284 -0
- package/src/questions.mjs +69 -0
- package/src/quota.mjs +76 -0
- package/src/registry.mjs +88 -0
- package/src/replay.mjs +151 -0
- package/src/rooms.mjs +320 -0
- package/src/server.mjs +765 -0
- package/src/store.mjs +546 -0
- package/src/supervisor.mjs +1803 -0
- package/src/tasks.mjs +191 -0
- package/src/template.mjs +86 -0
- package/src/tools.mjs +241 -0
- package/src/workspaces.mjs +605 -0
- package/src/ws.mjs +130 -0
- package/src/yaml.mjs +266 -0
- package/test/board.mjs +108 -0
- package/test/craft.mjs +281 -0
- package/test/extend.mjs +145 -0
- package/test/flow.mjs +165 -0
- package/test/handoff.mjs +228 -0
- package/test/linked.mjs +83 -0
- package/test/loop.mjs +168 -0
- package/test/mock-opencode.mjs +217 -0
- package/test/parallel.mjs +135 -0
- package/test/patches.mjs +93 -0
- package/test/repos.mjs +118 -0
- package/test/roles.mjs +157 -0
- package/test/shot.mjs +65 -0
- package/test/smoke.mjs +70 -0
- package/test/transport.mjs +131 -0
- package/test/workspaces.mjs +228 -0
- package/test/workspaces.mjs.tmp +0 -0
package/test/roles.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Who a bot is (its name), who owns a task (a session, a part, or you), and
|
|
2
|
+
// whether a pipeline can be edited without leaving the app.
|
|
3
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
4
|
+
import { promisify } from "node:util"
|
|
5
|
+
import { mkdtemp, writeFile, readFile } from "node:fs/promises"
|
|
6
|
+
import { tmpdir } from "node:os"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
9
|
+
import { startServer } from "../src/server.mjs"
|
|
10
|
+
import { handleFor, adjectivesFor } from "../src/identity.mjs"
|
|
11
|
+
import * as YAML from "../src/yaml.mjs"
|
|
12
|
+
|
|
13
|
+
const run = promisify(execFile)
|
|
14
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
16
|
+
let failed = 0
|
|
17
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
18
|
+
|
|
19
|
+
console.log("names carry the job")
|
|
20
|
+
ok(adjectivesFor("qa").includes("prying"), "a tester draws from a suspicious vocabulary")
|
|
21
|
+
ok(adjectivesFor("dev").includes("polishing"), "a builder from a making one")
|
|
22
|
+
ok(adjectivesFor("product").includes("framing"), "product from a shaping one")
|
|
23
|
+
ok(adjectivesFor(null).includes("brisk"), "anything else falls back to the generic list")
|
|
24
|
+
ok(adjectivesFor("qa", ["grumpy"])[0] === "grumpy", "a persona can bring its own words")
|
|
25
|
+
const taken = new Set()
|
|
26
|
+
const qa = handleFor("ses_q", taken, { role: "qa" })
|
|
27
|
+
const dev = handleFor("ses_d", taken, { role: "dev" })
|
|
28
|
+
ok(adjectivesFor("qa").includes(qa.split("-")[0]), `a QA session is named like one (@${qa})`)
|
|
29
|
+
ok(adjectivesFor("dev").includes(dev.split("-")[0]), `and a dev session like one (@${dev})`)
|
|
30
|
+
ok(handleFor("ses_q", new Set(), { role: "qa" }) === qa, "and the name is stable")
|
|
31
|
+
|
|
32
|
+
const repo = await mkdtemp(join(tmpdir(), "roles-"))
|
|
33
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: repo })
|
|
34
|
+
await writeFile(join(repo, "README.md"), "# demo\n")
|
|
35
|
+
await run("git", ["add", "."], { cwd: repo })
|
|
36
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: repo })
|
|
37
|
+
const home = await mkdtemp(join(tmpdir(), "roleshome-"))
|
|
38
|
+
|
|
39
|
+
await sleep(600)
|
|
40
|
+
const sup = new Supervisor({
|
|
41
|
+
url: "http://127.0.0.1:4096",
|
|
42
|
+
statePath: join(home, "m.json"),
|
|
43
|
+
registryPath: join(home, "r.json"),
|
|
44
|
+
mcpBase: "http://127.0.0.1:4807",
|
|
45
|
+
})
|
|
46
|
+
await sup.start()
|
|
47
|
+
startServer({ supervisor: sup, port: 4807 })
|
|
48
|
+
const api = async (p, opts = {}) =>
|
|
49
|
+
(await fetch("http://127.0.0.1:4807" + p, {
|
|
50
|
+
method: opts.method ?? "GET",
|
|
51
|
+
headers: opts.body ? { "content-type": "application/json" } : undefined,
|
|
52
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
53
|
+
})).json()
|
|
54
|
+
|
|
55
|
+
const r = await sup.pipelines.start({ pipeline: "story", repo, branch: "feat/x", story: "Something is wrong with login.", title: "Login" })
|
|
56
|
+
await sleep(500)
|
|
57
|
+
|
|
58
|
+
console.log("\nthe crew is named for what it does")
|
|
59
|
+
const named = Object.entries(r.sessions).map(([persona, id]) => [persona, sup.store.get(id).handle])
|
|
60
|
+
for (const [persona, handle] of named) {
|
|
61
|
+
ok(adjectivesFor(persona).includes(handle.split("-")[0]), `${persona} → @${handle}`)
|
|
62
|
+
}
|
|
63
|
+
ok(new Set(named.map(([, h]) => h)).size === named.length, "and no two share a name")
|
|
64
|
+
|
|
65
|
+
console.log("\ntasks belong to a part, not only to one session")
|
|
66
|
+
const buildTask = sup.tasks.get(r.taskIds.build)
|
|
67
|
+
ok(buildTask.role === "dev", "a stage task records the part that owns it")
|
|
68
|
+
ok(buildTask.owner === "session", "as well as the session currently playing it")
|
|
69
|
+
const loose = sup.tasks.create({ title: "Fix the flake", brief: "x", role: "qa", owner: "role", projectId: r.projectId })
|
|
70
|
+
ok(loose.status === "queued" && !loose.assignee, "work can be addressed to a part with nobody named")
|
|
71
|
+
const qaSession = sup.store.get(r.sessions.qa)
|
|
72
|
+
qaSession.setStatus("idle")
|
|
73
|
+
await sup.pipelines.dispatch()
|
|
74
|
+
const claimed = sup.tasks.get(loose.id)
|
|
75
|
+
ok(claimed.assignee === qaSession.id, "whoever is playing that part picks it up")
|
|
76
|
+
ok(claimed.status === "active", "and it becomes theirs, so a second bot cannot take it too")
|
|
77
|
+
|
|
78
|
+
console.log("\nthe human is an assignee too")
|
|
79
|
+
const rpc = (session) => {
|
|
80
|
+
const url = sup.mcpUrlFor(session)
|
|
81
|
+
let id = 0
|
|
82
|
+
return async (name, args = {}) => {
|
|
83
|
+
// Every bot in a run shares one worktree, and opencode keeps one MCP URL
|
|
84
|
+
// per worktree, so the URL alone cannot say who is calling. In real use
|
|
85
|
+
// the event stream announces the botfarm_ tool starting; stand in for that.
|
|
86
|
+
sup.mcpCalls.set(session.id, Date.now())
|
|
87
|
+
const res = await fetch(url, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json" },
|
|
90
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }),
|
|
91
|
+
})
|
|
92
|
+
const out = (await res.json()).result
|
|
93
|
+
return { isError: !!out.isError, data: out.structuredContent, text: out.content[0].text }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const dev2 = sup.store.get(r.sessions.dev)
|
|
97
|
+
const callDev = rpc(dev2)
|
|
98
|
+
let out = await callDev("ask_human", {
|
|
99
|
+
question: "Should the return path survive a second SSO hop, or is one enough?",
|
|
100
|
+
options: ["one hop is enough", "must survive any number"],
|
|
101
|
+
})
|
|
102
|
+
ok(!out.isError && out.data.asked, "a bot can ask its operator directly")
|
|
103
|
+
const question = sup.tasks.get(out.data.task_id)
|
|
104
|
+
ok(question.owner === "human" && question.kind === "question", "which lands as a human-owned question")
|
|
105
|
+
ok(question.status === "waiting", "waiting, not queued — nothing dispatches it")
|
|
106
|
+
ok(question.options.length === 2, "with the choices it offered")
|
|
107
|
+
ok(sup.tasks.forHuman().some((t) => t.id === question.id), "and it surfaces in what needs you")
|
|
108
|
+
|
|
109
|
+
out = await callDev("tasks")
|
|
110
|
+
ok(out.data.yours.some((t) => t.id === question.id), "the asker can see it is still open")
|
|
111
|
+
|
|
112
|
+
await api(`/api/tasks/${question.id}/answer`, { method: "POST", body: { answer: "One hop is enough." } })
|
|
113
|
+
await sleep(300)
|
|
114
|
+
const answered = sup.tasks.get(question.id)
|
|
115
|
+
ok(answered.status === "done" && answered.answer === "One hop is enough.", "answering closes it")
|
|
116
|
+
const devMsgs = (await sup.client.messages(dev2.id, { limit: 20 })).flatMap((m) => (m.parts ?? []).map((p) => p.text ?? ""))
|
|
117
|
+
ok(devMsgs.some((t) => t.includes("your operator answered")), "and the answer goes back to the bot that asked")
|
|
118
|
+
ok(devMsgs.some((t) => t.includes("came from the human running this session")), "marked as coming from you, not from a peer")
|
|
119
|
+
|
|
120
|
+
console.log("\na pipeline can have a stage that is yours")
|
|
121
|
+
const story = sup.pipelines.definitions.get("story")
|
|
122
|
+
ok(story.stages.some((st) => st.human), "the shipped pipeline ends with a human sign-off")
|
|
123
|
+
const signoff = sup.tasks.all({ runId: r.id }).find((t) => t.stage === "signoff")
|
|
124
|
+
ok(signoff?.owner === "human", "which becomes a task for you when its turn comes")
|
|
125
|
+
ok(!signoff.assignee, "never dispatched to a bot")
|
|
126
|
+
|
|
127
|
+
console.log("\nediting definitions")
|
|
128
|
+
const def = await api("/api/pipelines/story")
|
|
129
|
+
ok(def.doc.stages.length === 5 && def.yaml.includes("stages:"), "a definition can be read back as structure and as YAML")
|
|
130
|
+
const edited = structuredClone(def.doc)
|
|
131
|
+
edited.stages.splice(1, 0, { id: "spike", persona: "dev", title: "Spike it first", receives: ["analyse"], prompt: "Try it roughly: {{story}}" })
|
|
132
|
+
let saved = await api("/api/pipelines/story", { method: "PUT", body: { doc: edited } })
|
|
133
|
+
ok(saved.problems.length === 0, "a stage can be inserted")
|
|
134
|
+
ok(sup.pipelines.definitions.get("story").stages.length === 6, "and takes effect without a restart")
|
|
135
|
+
const onDisk = YAML.parse(await readFile(join(home, "pipelines", "story.yaml"), "utf8"))
|
|
136
|
+
ok(onDisk.stages[1].id === "spike", "written back to the YAML file, still yours to diff and commit")
|
|
137
|
+
|
|
138
|
+
const broken = structuredClone(edited)
|
|
139
|
+
broken.stages[2].receives = ["nope"]
|
|
140
|
+
saved = await api("/api/pipelines/story", { method: "PUT", body: { doc: broken } })
|
|
141
|
+
ok(saved.problems.some((p) => p.includes("nope")), "a stage receiving something that does not exist is reported, not swallowed")
|
|
142
|
+
|
|
143
|
+
const persona = await api("/api/personas/qa")
|
|
144
|
+
const pdoc = { ...persona.doc, model: "anthropic/claude-haiku-4-5", tools: ["read", "grep", "bash"], adjectives: ["grumpy", "dubious"] }
|
|
145
|
+
saved = await api("/api/personas/qa", { method: "PUT", body: { doc: pdoc } })
|
|
146
|
+
ok(saved.problems.length === 0, "a persona's model, tools and vocabulary can be changed")
|
|
147
|
+
ok(sup.pipelines.personas.get("qa").model === "anthropic/claude-haiku-4-5", "and is live immediately")
|
|
148
|
+
const fresh = handleFor("ses_new_qa", new Set(), {
|
|
149
|
+
role: "qa",
|
|
150
|
+
adjectives: sup.pipelines.personas.get("qa").adjectives,
|
|
151
|
+
})
|
|
152
|
+
ok(fresh.startsWith("grumpy") || fresh.startsWith("dubious"), `so the next tester is named from it (@${fresh})`)
|
|
153
|
+
|
|
154
|
+
await sup.stop()
|
|
155
|
+
mock.kill()
|
|
156
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
157
|
+
process.exit(failed ? 1 : 0)
|
package/test/shot.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
4
|
+
import { startServer } from "../src/server.mjs"
|
|
5
|
+
|
|
6
|
+
const require = createRequire("/home/claude/.npm-global/lib/node_modules/@mermaid-js/mermaid-cli/")
|
|
7
|
+
const puppeteer = require("puppeteer")
|
|
8
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
9
|
+
|
|
10
|
+
const mock = spawn(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
11
|
+
await sleep(700)
|
|
12
|
+
const sup = new Supervisor({ url: "http://127.0.0.1:4096", statePath: "/tmp/botfarm-shot-" + Date.now() + ".json", registryPath: "/tmp/botfarm-reg-" + Math.random().toString(36).slice(2) + ".json" })
|
|
13
|
+
await sup.start()
|
|
14
|
+
const { url } = startServer({ supervisor: sup, port: 4799 })
|
|
15
|
+
// the board only shows sessions botfarm manages, so adopt the mock's two first
|
|
16
|
+
await sup.adopt(sup.unmanaged.map((x) => x.id))
|
|
17
|
+
await sleep(2500)
|
|
18
|
+
// light the mesh up so the screenshot shows handles, mail and policy controls
|
|
19
|
+
const [a, b] = sup.store.list()
|
|
20
|
+
await sup.setPolicy(a.id, { talk: "open", spawn: true })
|
|
21
|
+
await sup.setPolicy(b.id, { talk: "open" })
|
|
22
|
+
await sup.mesh.send(a, b.handle, "The auth middleware I touched also gates /health — check before you change it.")
|
|
23
|
+
for (const s2 of [a, b]) await sup.setPolicy(s2.id, { rooms: "create" })
|
|
24
|
+
const room = sup.rooms.create({ name: "auth refactor", topic: "Splitting session auth out of the monolith", members: [a, b] })
|
|
25
|
+
await sup.rooms.post(room, a, "Moved the token parser to packages/auth. Nothing else touched yet.")
|
|
26
|
+
await sup.rooms.post(room, null, `Ship that first, leave the cookie bug to @${b.handle}`)
|
|
27
|
+
await sleep(1200)
|
|
28
|
+
|
|
29
|
+
const browser = await puppeteer.launch({ args: ["--no-sandbox"], executablePath: process.env.CHROME_PATH || undefined })
|
|
30
|
+
const page = await browser.newPage()
|
|
31
|
+
await page.setViewport({ width: 1440, height: 980, deviceScaleFactor: 2 })
|
|
32
|
+
const errors = []
|
|
33
|
+
page.on("pageerror", (e) => errors.push(String(e)))
|
|
34
|
+
page.on("console", (m) => m.type() === "error" && errors.push(m.text()))
|
|
35
|
+
await page.goto(url, { waitUntil: "domcontentloaded" })
|
|
36
|
+
await sleep(2500)
|
|
37
|
+
await page.screenshot({ path: "/tmp/dash.png" })
|
|
38
|
+
|
|
39
|
+
const cards = await page.$$eval(".pod", (els) =>
|
|
40
|
+
els.map((e) => ({
|
|
41
|
+
status: e.dataset.status,
|
|
42
|
+
name: e.querySelector(".name").textContent,
|
|
43
|
+
tpm: e.querySelector(".tpm").textContent,
|
|
44
|
+
paths: e.querySelectorAll("svg.spark path").length,
|
|
45
|
+
})),
|
|
46
|
+
)
|
|
47
|
+
console.log("cards:", JSON.stringify(cards))
|
|
48
|
+
console.log("errors:", errors.slice(0, 5))
|
|
49
|
+
|
|
50
|
+
await page.click(".navitem[data-k=\"room\"]")
|
|
51
|
+
await sleep(1000)
|
|
52
|
+
await page.screenshot({ path: "/tmp/room.png" })
|
|
53
|
+
console.log("room view:", await page.$$eval(".chat .turn .m", (e) => e.map((x) => x.textContent)))
|
|
54
|
+
await page.click("#rm-close")
|
|
55
|
+
await sleep(400)
|
|
56
|
+
await page.click(".pod .open")
|
|
57
|
+
await sleep(1200)
|
|
58
|
+
await page.screenshot({ path: "/tmp/inspector.png" })
|
|
59
|
+
console.log("side sections:", await page.$$eval(".modal .side h3", (e) => e.map((x) => x.textContent)))
|
|
60
|
+
console.log("errors2:", errors.slice(0, 5))
|
|
61
|
+
|
|
62
|
+
await browser.close()
|
|
63
|
+
await sup.stop()
|
|
64
|
+
mock.kill()
|
|
65
|
+
process.exit(0)
|
package/test/smoke.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// End-to-end smoke test: mock opencode -> supervisor -> dashboard API.
|
|
2
|
+
import { spawn } from "node:child_process"
|
|
3
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
4
|
+
import { startServer } from "../src/server.mjs"
|
|
5
|
+
|
|
6
|
+
const mock = spawn(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
7
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
8
|
+
const fail = (m) => { console.error("FAIL:", m); process.exitCode = 1 }
|
|
9
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : fail(m))
|
|
10
|
+
|
|
11
|
+
await sleep(600)
|
|
12
|
+
const sup = new Supervisor({ url: "http://127.0.0.1:4096", statePath: "/tmp/botfarm-test-" + Date.now() + ".json", registryPath: "/tmp/botfarm-reg-" + Math.random().toString(36).slice(2) + ".json" })
|
|
13
|
+
await sup.start()
|
|
14
|
+
const { url } = startServer({ supervisor: sup, port: 4788 })
|
|
15
|
+
const get = async (p, o) => (await fetch(url + p, o)).json()
|
|
16
|
+
|
|
17
|
+
// the board only shows sessions botfarm manages, so adopt the mock's two first
|
|
18
|
+
await sup.adopt(sup.unmanaged.map((x) => x.id))
|
|
19
|
+
await sleep(2600)
|
|
20
|
+
let state = await get("/api/state")
|
|
21
|
+
console.log("\nstate after 7s")
|
|
22
|
+
ok(state.sessions.length === 2, "two sessions discovered")
|
|
23
|
+
ok(state.totals.busy === 1, "one session reported busy")
|
|
24
|
+
const busy = state.sessions.find((s) => s.status === "busy")
|
|
25
|
+
ok(busy.tokPerMin > 0, `token rate is live (${busy.tokPerMin}/min)`)
|
|
26
|
+
ok(busy.totals.tools > 0, `tool calls counted (${busy.totals.tools})`)
|
|
27
|
+
ok(busy.spark.tok.length === 60 && busy.spark.tok.at(-1) > 0, "sparkline has 60 buckets with a live tail")
|
|
28
|
+
ok(busy.contextTokens > 0, `context tokens tracked (${busy.contextTokens})`)
|
|
29
|
+
ok(!!busy.lastText, "latest assistant text captured")
|
|
30
|
+
|
|
31
|
+
const idle = state.sessions.find((s) => s.status === "idle")
|
|
32
|
+
console.log("\nsend a prompt to the idle session")
|
|
33
|
+
await fetch(`${url}/api/sessions/${idle.id}/send`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "content-type": "application/json" },
|
|
36
|
+
body: JSON.stringify({ text: "continue" }),
|
|
37
|
+
})
|
|
38
|
+
await sleep(1800)
|
|
39
|
+
state = await get("/api/state")
|
|
40
|
+
const woken = state.sessions.find((s) => s.id === idle.id)
|
|
41
|
+
ok(woken.status === "busy", "idle session went busy after continue")
|
|
42
|
+
ok(woken.tokPerMin > 0, `woken session burning tokens (${woken.tokPerMin}/min)`)
|
|
43
|
+
|
|
44
|
+
console.log("\ndetail view")
|
|
45
|
+
const d = await get(`/api/sessions/${busy.id}`)
|
|
46
|
+
ok(d.toolLog.length > 0, `tool log populated (${d.toolLog.length} entries, first: ${d.toolLog[0]?.name})`)
|
|
47
|
+
ok(d.transcript.length > 0, `transcript rendered (${d.transcript.length} messages)`)
|
|
48
|
+
ok(typeof d.totals.output === "number" && d.totals.output > 0, `output tokens accumulated (${d.totals.output})`)
|
|
49
|
+
|
|
50
|
+
console.log("\nabort")
|
|
51
|
+
await fetch(`${url}/api/sessions/${busy.id}/abort`, { method: "POST" })
|
|
52
|
+
await sleep(1800)
|
|
53
|
+
state = await get("/api/state")
|
|
54
|
+
ok(state.sessions.find((s) => s.id === busy.id).status === "idle", "aborted session reports idle")
|
|
55
|
+
|
|
56
|
+
console.log("\nstatic + stream")
|
|
57
|
+
const html = await fetch(url + "/")
|
|
58
|
+
ok(html.status === 200 && (await html.text()).includes("botfarm"), "dashboard html served")
|
|
59
|
+
const es = await fetch(url + "/api/stream", { headers: { accept: "text/event-stream" } })
|
|
60
|
+
const first = await es.body[Symbol.asyncIterator]().next()
|
|
61
|
+
ok(Buffer.from(first.value).toString().startsWith("data: "), "SSE stream pushes state frames")
|
|
62
|
+
|
|
63
|
+
console.log("\nmetrics persistence")
|
|
64
|
+
await sup.store.save()
|
|
65
|
+
ok((await import("node:fs")).existsSync(sup.store.statePath), "metrics snapshot written")
|
|
66
|
+
|
|
67
|
+
await sup.stop()
|
|
68
|
+
mock.kill()
|
|
69
|
+
console.log(process.exitCode ? "\nSOME CHECKS FAILED" : "\nall checks passed")
|
|
70
|
+
process.exit(process.exitCode ?? 0)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The transport: a real websocket handshake, pushes without polling, and a
|
|
2
|
+
// count of how often botfarm actually talks to opencode when nothing happens.
|
|
3
|
+
import { spawn } from "node:child_process"
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
5
|
+
import { connect as netConnect } from "node:net"
|
|
6
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
7
|
+
import { startServer } from "../src/server.mjs"
|
|
8
|
+
|
|
9
|
+
const mock = spawn(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
10
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
11
|
+
let failed = 0
|
|
12
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
13
|
+
|
|
14
|
+
await sleep(600)
|
|
15
|
+
const sup = new Supervisor({
|
|
16
|
+
url: "http://127.0.0.1:4096",
|
|
17
|
+
statePath: "/tmp/botfarm-ws-" + Date.now() + ".json",
|
|
18
|
+
registryPath: "/tmp/botfarm-wsreg-" + Date.now() + ".json",
|
|
19
|
+
mcpBase: "http://127.0.0.1:4801",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// Count every request botfarm makes to opencode, so "does it poll" is a number.
|
|
23
|
+
let calls = []
|
|
24
|
+
const realFetch = globalThis.fetch
|
|
25
|
+
globalThis.fetch = (url, opts) => {
|
|
26
|
+
const u = String(url)
|
|
27
|
+
if (u.includes("127.0.0.1:4096")) calls.push(u.replace("http://127.0.0.1:4096", ""))
|
|
28
|
+
return realFetch(url, opts)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await sup.start()
|
|
32
|
+
startServer({ supervisor: sup, port: 4801 })
|
|
33
|
+
await sup.adopt(sup.unmanaged.map((x) => x.id))
|
|
34
|
+
await sleep(1200)
|
|
35
|
+
|
|
36
|
+
console.log("websocket handshake")
|
|
37
|
+
const key = randomBytes(16).toString("base64")
|
|
38
|
+
const socket = netConnect(4801, "127.0.0.1")
|
|
39
|
+
const frames = []
|
|
40
|
+
let buf = Buffer.alloc(0)
|
|
41
|
+
let handshake = ""
|
|
42
|
+
await new Promise((resolve) => {
|
|
43
|
+
socket.on("connect", () => {
|
|
44
|
+
socket.write(
|
|
45
|
+
`GET /api/socket HTTP/1.1\r\nHost: 127.0.0.1:4801\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` +
|
|
46
|
+
`Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
socket.on("data", (chunk) => {
|
|
50
|
+
if (!handshake) {
|
|
51
|
+
const split = chunk.indexOf("\r\n\r\n")
|
|
52
|
+
handshake = chunk.subarray(0, split).toString()
|
|
53
|
+
chunk = chunk.subarray(split + 4)
|
|
54
|
+
resolve()
|
|
55
|
+
}
|
|
56
|
+
buf = Buffer.concat([buf, chunk])
|
|
57
|
+
while (true) {
|
|
58
|
+
const f = readFrame(buf)
|
|
59
|
+
if (!f) break
|
|
60
|
+
buf = buf.subarray(f.size)
|
|
61
|
+
if (f.opcode === 0x1) frames.push(JSON.parse(f.payload.toString()))
|
|
62
|
+
if (f.opcode === 0x9) socket.write(maskedFrame(Buffer.alloc(0), 0xa)) // answer pings
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
ok(/101 Switching Protocols/.test(handshake), "server answers with 101")
|
|
67
|
+
const expected = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64")
|
|
68
|
+
ok(handshake.includes(expected), "and the correct Sec-WebSocket-Accept")
|
|
69
|
+
|
|
70
|
+
await sleep(400)
|
|
71
|
+
ok(frames.length >= 1, "a snapshot arrives on connect without asking")
|
|
72
|
+
ok(Array.isArray(frames[0].sessions) && frames[0].totals, "and it is the same state the dashboard renders")
|
|
73
|
+
|
|
74
|
+
console.log("\npush, not poll")
|
|
75
|
+
const before = frames.length
|
|
76
|
+
await sup.send(sup.store.list()[0].id, "hello from the test")
|
|
77
|
+
await sleep(700)
|
|
78
|
+
ok(frames.length > before, `a change pushes a new frame (${frames.length - before})`)
|
|
79
|
+
|
|
80
|
+
socket.write(maskedFrame(Buffer.from("resync"), 0x1))
|
|
81
|
+
await sleep(400)
|
|
82
|
+
ok(frames.length > before + 1, "and the client can ask for a resync over the same socket")
|
|
83
|
+
|
|
84
|
+
console.log("\nthe client speaks first: masked frames are decoded")
|
|
85
|
+
ok(frames.at(-1).at > 0, "the resynced frame is well-formed")
|
|
86
|
+
|
|
87
|
+
console.log("\nshape negotiation does not cache the message")
|
|
88
|
+
const target = sup.store.list()[0]
|
|
89
|
+
await sup.send(target.id, "first message")
|
|
90
|
+
await sup.send(target.id, "second message")
|
|
91
|
+
await sleep(400)
|
|
92
|
+
const sent = (await sup.client.messages(target.id, { limit: 10 }))
|
|
93
|
+
.flatMap((m) => (m.parts ?? []).map((p) => p.text ?? ""))
|
|
94
|
+
ok(sent.includes("second message"), "the second message carries its own text")
|
|
95
|
+
ok(sent.filter((t) => t === "first message").length === 1, "and the first is not resent")
|
|
96
|
+
|
|
97
|
+
console.log("\ntraffic to opencode while nothing is happening")
|
|
98
|
+
// stop the mock's busy session so the board is genuinely quiet
|
|
99
|
+
for (const s of sup.store.list()) await sup.abort(s.id).catch(() => {})
|
|
100
|
+
await sleep(2500)
|
|
101
|
+
calls = []
|
|
102
|
+
await sleep(6000)
|
|
103
|
+
const perSecond = calls.length / 6
|
|
104
|
+
console.log(` ${calls.length} requests in 6s (${perSecond.toFixed(1)}/s):`, [...new Set(calls.map((c) => c.split("?")[0]))].join(", "))
|
|
105
|
+
ok(perSecond < 1.5, `a quiet board is nearly silent (${perSecond.toFixed(1)} requests/second)`)
|
|
106
|
+
ok(!calls.some((c) => c.includes("/message")), "and no message polling at all while idle")
|
|
107
|
+
|
|
108
|
+
socket.destroy()
|
|
109
|
+
await sup.stop()
|
|
110
|
+
mock.kill()
|
|
111
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
112
|
+
process.exit(failed ? 1 : 0)
|
|
113
|
+
|
|
114
|
+
// --- a minimal client-side codec, so the test does not trust the server's ---
|
|
115
|
+
function maskedFrame(payload, opcode) {
|
|
116
|
+
const mask = randomBytes(4)
|
|
117
|
+
const masked = Buffer.from(payload)
|
|
118
|
+
for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4]
|
|
119
|
+
const header = Buffer.from([0x80 | opcode, 0x80 | masked.length])
|
|
120
|
+
return Buffer.concat([header, mask, masked])
|
|
121
|
+
}
|
|
122
|
+
function readFrame(b) {
|
|
123
|
+
if (b.length < 2) return null
|
|
124
|
+
const opcode = b[0] & 0x0f
|
|
125
|
+
let len = b[1] & 0x7f
|
|
126
|
+
let off = 2
|
|
127
|
+
if (len === 126) { if (b.length < 4) return null; len = b.readUInt16BE(2); off = 4 }
|
|
128
|
+
else if (len === 127) { if (b.length < 10) return null; len = Number(b.readBigUInt64BE(2)); off = 10 }
|
|
129
|
+
if (b.length < off + len) return null
|
|
130
|
+
return { opcode, payload: b.subarray(off, off + len), size: off + len }
|
|
131
|
+
}
|