@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/handoff.mjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Open questions travel with a handoff instead of stopping the line, unless
|
|
2
|
+
// the stage says to wait; the operator can let a held stage go on with answers.
|
|
3
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
4
|
+
import { promisify } from "node:util"
|
|
5
|
+
import { mkdtemp, writeFile } 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
|
+
|
|
11
|
+
const run = promisify(execFile)
|
|
12
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
14
|
+
let failed = 0
|
|
15
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
16
|
+
const repo = await mkdtemp(join(tmpdir(), "ho-"))
|
|
17
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: repo })
|
|
18
|
+
await writeFile(join(repo, "a"), "x")
|
|
19
|
+
await run("git", ["add", "."], { cwd: repo })
|
|
20
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: repo })
|
|
21
|
+
const home = await mkdtemp(join(tmpdir(), "hoh-"))
|
|
22
|
+
await sleep(600)
|
|
23
|
+
const sup = new Supervisor({ url: "http://127.0.0.1:4096", statePath: join(home, "m.json"), registryPath: join(home, "r.json"), mcpBase: "http://127.0.0.1:4837" })
|
|
24
|
+
await sup.start()
|
|
25
|
+
startServer({ supervisor: sup, port: 4837 })
|
|
26
|
+
const texts = async (id) => (await sup.client.messages(id, { limit: 200 })).filter((m) => (m.info ?? m).role !== "assistant").flatMap((m) => (m.parts ?? []).map((p) => p.text ?? ""))
|
|
27
|
+
const idle = async (id) => { await sup.client.interrupt(id); sup.store.get(id).setStatus("idle") }
|
|
28
|
+
|
|
29
|
+
console.log("open questions do not stop the line")
|
|
30
|
+
const r = await sup.pipelines.start({ pipeline: "story", repo, branch: "feat/a", story: "DE238: DELETE should 409 when a FRID exists", title: "A" })
|
|
31
|
+
await sleep(300)
|
|
32
|
+
await idle(r.sessions.dev)
|
|
33
|
+
await sup.pipelines.complete(sup.tasks.get(r.taskIds.analyse), { summary: "Criteria written.", acceptance_criteria: ["409 on FRID"], open_questions: ["Which FRID statuses count?", "Is soft-delete in scope?"] })
|
|
34
|
+
await sleep(300)
|
|
35
|
+
ok(sup.tasks.get(r.taskIds.analyse).status === "done", "the stage is done")
|
|
36
|
+
ok(sup.tasks.get(r.taskIds.build).status === "active", "and the next bot picks up its card straight away")
|
|
37
|
+
const dev = (await texts(r.sessions.dev)).find((t) => t.includes("[botfarm] task"))
|
|
38
|
+
ok(dev?.includes("Which FRID statuses count?") && dev.includes("Settle them"), "carrying the open questions with it")
|
|
39
|
+
ok(dev?.includes("Finished analyse: Criteria written."), "the handoff notice rides along with the card")
|
|
40
|
+
ok(!(await texts(r.sessions.dev)).some((t) => t.startsWith("[botfarm]") && t.includes("Finished analyse") && !t.includes("[botfarm] task")), "instead of waking the bot on its own")
|
|
41
|
+
ok(!(await texts(r.sessions.qa)).some((t) => t.includes("Finished analyse")), "and later stages are not woken by it")
|
|
42
|
+
|
|
43
|
+
console.log("\na stage can choose to wait")
|
|
44
|
+
const r2 = await sup.pipelines.start({ pipeline: "story", repo, branch: "feat/b", story: "US1: another", title: "B" })
|
|
45
|
+
const run2 = sup.pipelines.run(r2.id)
|
|
46
|
+
run2.def.stages[0].hold_on_questions = true
|
|
47
|
+
sup.db.put("runs", run2)
|
|
48
|
+
await sleep(300)
|
|
49
|
+
await idle(r2.sessions.dev)
|
|
50
|
+
await sup.pipelines.complete(sup.tasks.get(r2.taskIds.analyse), { summary: "Written.", acceptance_criteria: ["x"], open_questions: ["Which API version?"] })
|
|
51
|
+
await sleep(300)
|
|
52
|
+
ok(sup.tasks.get(r2.taskIds.analyse).status === "review", "a holding stage waits in review")
|
|
53
|
+
ok(sup.tasks.get(r2.taskIds.build).status === "blocked", "and the next stage has not started")
|
|
54
|
+
const res = await fetch(`http://127.0.0.1:4837/api/tasks/${r2.taskIds.analyse}/continue`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ note: "Use v1 only." }) })
|
|
55
|
+
ok(res.ok, "you can let it continue")
|
|
56
|
+
await sleep(300)
|
|
57
|
+
ok(sup.tasks.get(r2.taskIds.build).status === "active", "which starts the next bot")
|
|
58
|
+
const dev2 = (await texts(r2.sessions.dev)).find((t) => t.includes("[botfarm] task"))
|
|
59
|
+
ok(dev2?.includes("The operator's answer: Use v1 only."), "with your answer in its card")
|
|
60
|
+
ok(sup.rooms.rooms.get(run2.roomId).messages.some((m) => m.from === "operator" && m.text.includes("Use v1 only.")), "and in the chat")
|
|
61
|
+
|
|
62
|
+
console.log("\nchatter does not wake bots whose stage has not started")
|
|
63
|
+
const room = sup.rooms.rooms.get(sup.pipelines.run(r.id).roomId)
|
|
64
|
+
for (let i = 0; i < 8; i++) await sup.rooms.post(room, i % 2 ? null : sup.store.get(r.sessions.dev), `message ${i}`)
|
|
65
|
+
await sup.rooms.flushAll(sup.store.get(r.sessions.reviewer))
|
|
66
|
+
ok((await texts(r.sessions.reviewer)).length === 0, "not even when the backlog grows")
|
|
67
|
+
|
|
68
|
+
console.log("\nquestions for you come with choices")
|
|
69
|
+
const tokenFor = (id) => sup.mcpUrlFor(sup.store.get(id)).split("/").pop()
|
|
70
|
+
const call = async (id, name, args) => {
|
|
71
|
+
sup.mcpCalls.set(id, Date.now())
|
|
72
|
+
const res = await fetch(`http://127.0.0.1:4837/mcp/${tokenFor(id)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) })
|
|
73
|
+
return (await res.json()).result
|
|
74
|
+
}
|
|
75
|
+
const asked = await call(r.sessions.dev, "ask_human", { questions: [
|
|
76
|
+
{ question: "Which FRID statuses block the delete?", options: [{ label: "Active only", description: "Expired FRIDs are ignored" }, "Active and pending"], header: "FRID" },
|
|
77
|
+
{ question: "Which environments?", options: ["dev", "qa", "prod"], multiple: true },
|
|
78
|
+
{ question: "Anything else I should know?" },
|
|
79
|
+
] })
|
|
80
|
+
ok(!asked.isError, "a bot can ask several questions at once")
|
|
81
|
+
const q = sup.tasks.get(asked.structuredContent.task_id)
|
|
82
|
+
ok(q.questions.length === 3 && q.questions[0].options[0].description === "Expired FRIDs are ignored" && q.questions[1].multiple, "each with its own choices, single or multiple")
|
|
83
|
+
ok(q.questions[2].options.length === 0, "or none, for an open question")
|
|
84
|
+
await fetch(`http://127.0.0.1:4837/api/tasks/${q.id}/answer`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ answers: [{ picked: ["Active only"], text: "" }, { picked: ["dev", "qa"], text: "prod later" }, { picked: [], text: "" }] }) })
|
|
85
|
+
await sleep(200)
|
|
86
|
+
const got = (await texts(r.sessions.dev)).find((t) => t.includes("your operator answered"))
|
|
87
|
+
ok(got?.includes("→ Active only") && got.includes("→ dev, qa — prod later"), "your picks and your words go back together")
|
|
88
|
+
ok(got?.includes("(no answer — use your judgement)"), "and a question you skipped says so")
|
|
89
|
+
const one = await call(r.sessions.dev, "ask_human", { question: "Ship behind a flag?", options: ["yes", "no"] })
|
|
90
|
+
ok(sup.tasks.get(one.structuredContent.task_id).questions[0].options.length === 2, "the one-question form still works")
|
|
91
|
+
|
|
92
|
+
console.log("\nan agent's tools are enforced per turn")
|
|
93
|
+
const prod = sup.store.get(r.sessions.product)
|
|
94
|
+
prod.allowedTools = ["read", "grep", "glob", "list"]
|
|
95
|
+
const sw = await sup.toolSwitches(prod)
|
|
96
|
+
ok(sw && sw.bash === false && sw.edit === false && !("read" in sw) && !("grep" in sw), "everything not on its list is switched off")
|
|
97
|
+
ok(await sup.toolSwitches(sup.store.get(r.sessions.qa)) === undefined, "an agent with no list keeps every tool")
|
|
98
|
+
|
|
99
|
+
console.log("\nyour sign-off stage is a question too")
|
|
100
|
+
for (const st of ["build", "verify", "check"]) await sup.pipelines.release(sup.tasks.get(r.taskIds[st]), "")
|
|
101
|
+
const signoff = sup.tasks.get(r.taskIds.signoff)
|
|
102
|
+
ok(signoff.status === "waiting" && signoff.kind === "question", "it reaches you once the chain before it is done")
|
|
103
|
+
await fetch(`http://127.0.0.1:4837/api/tasks/${signoff.id}/answer`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ answers: [{ picked: ["Looks good — ship it"], text: "" }] }) })
|
|
104
|
+
await sleep(200)
|
|
105
|
+
ok(sup.tasks.get(signoff.id).status === "done" && sup.pipelines.run(r.id).status === "done", "answering it finishes the run")
|
|
106
|
+
|
|
107
|
+
console.log("\nbots with no open cards stay quiet")
|
|
108
|
+
const [devS, qaS, revS] = [r.sessions.dev, r.sessions.qa, r.sessions.reviewer].map((id) => sup.store.get(id))
|
|
109
|
+
for (const b of [devS, qaS, revS]) b.setStatus("idle")
|
|
110
|
+
ok(sup.pipelines.resting(qaS) && !sup.pipelines.resting(sup.store.get(r2.sessions.dev)), "a bot is resting once all its cards are done")
|
|
111
|
+
const count = async (id) => (await texts(id)).length
|
|
112
|
+
let n = await count(qaS.id)
|
|
113
|
+
await sup.rooms.post(room, devS, `All done here, thanks @${qaS.handle} and @${revS.handle}!`)
|
|
114
|
+
await sup.rooms.post(room, revS, "Great work everyone.")
|
|
115
|
+
await sup.rooms.flushAll(qaS)
|
|
116
|
+
ok(await count(qaS.id) === n, "thanks and sign-offs do not wake it, even at idle")
|
|
117
|
+
await sup.rooms.post(room, devS, `@${qaS.handle} did the 409 test run against qa?`)
|
|
118
|
+
ok(await count(qaS.id) === n + 1, "a direct question does")
|
|
119
|
+
ok((await texts(qaS.id)).some((t) => t.includes("Great work everyone.") && t.includes("did the 409")), "with what it missed")
|
|
120
|
+
n = await count(revS.id)
|
|
121
|
+
await sup.rooms.post(room, null, "One more thing: add a changelog line.")
|
|
122
|
+
ok(await count(revS.id) === n + 1, "and so does the operator")
|
|
123
|
+
ok((await texts(revS.id)).some((t) => t.includes("add a changelog") && t.includes("Do not post to acknowledge")), "and the frame says not to post just to agree")
|
|
124
|
+
|
|
125
|
+
console.log("\na partly done workstream can be restarted")
|
|
126
|
+
const r3 = await sup.pipelines.start({ pipeline: "story", repo, branch: "feat/c", story: "DE9: restart me", title: "C" })
|
|
127
|
+
await sleep(300)
|
|
128
|
+
await idle(r3.sessions.dev)
|
|
129
|
+
await sup.pipelines.complete(sup.tasks.get(r3.taskIds.analyse), { summary: "Criteria v1.", acceptance_criteria: ["a"] })
|
|
130
|
+
await sleep(200)
|
|
131
|
+
ok(sup.tasks.get(r3.taskIds.build).status === "active", "(build is under way)")
|
|
132
|
+
const project3 = sup.pipelines.run(r3.id).projectId
|
|
133
|
+
const before = { ...sup.pipelines.run(r3.id).sessions }
|
|
134
|
+
// Same bots, from the current stage
|
|
135
|
+
let out = await sup.pipelines.restart(project3, { fresh: false })
|
|
136
|
+
ok(out.from === "build" && out.fresh === 0, "by default from the first unfinished stage")
|
|
137
|
+
ok(sup.tasks.get(r3.taskIds.analyse).status === "done" && sup.tasks.get(r3.taskIds.analyse).handoff.summary === "Criteria v1.", "earlier stages keep their handoff")
|
|
138
|
+
ok(sup.tasks.get(r3.taskIds.build).status === "active" && sup.tasks.get(r3.taskIds.verify).status === "blocked", "the stage is handed out again and the rest wait")
|
|
139
|
+
// Fresh bots, from the start
|
|
140
|
+
out = await sup.pipelines.restart(project3, { stage: "analyse", fresh: true })
|
|
141
|
+
await sleep(300)
|
|
142
|
+
const after = sup.pipelines.run(r3.id).sessions
|
|
143
|
+
ok(out.fresh === 4 && Object.keys(before).every((k) => after[k] && after[k] !== before[k]), "fresh bots replace every old one")
|
|
144
|
+
ok(Object.values(before).every((id) => !sup.store.get(id)), "the old sessions leave the board")
|
|
145
|
+
ok(Object.values(after).every((id) => sup.store.get(id)?.directory === sup.store.get(after.product).directory), "the new ones work in the same worktree")
|
|
146
|
+
ok(sup.tasks.get(r3.taskIds.analyse).status === "active" && sup.tasks.get(r3.taskIds.analyse).assignee === after.product, "the first stage goes to the new product bot")
|
|
147
|
+
ok(!sup.tasks.get(r3.taskIds.analyse).handoff && sup.tasks.get(r3.taskIds.build).status === "blocked", "with the old handoff cleared")
|
|
148
|
+
const room3 = sup.rooms.rooms.get(sup.pipelines.run(r3.id).roomId)
|
|
149
|
+
ok(Object.values(after).every((id) => room3.members.has(id)) && Object.values(before).every((id) => !room3.members.has(id)), "the chat has the new team")
|
|
150
|
+
ok((await texts(after.product)).some((t) => t.includes("You are the product analyst")), "and the new bot is briefed again")
|
|
151
|
+
|
|
152
|
+
console.log("\na long opencode history does not hide the team")
|
|
153
|
+
for (let i = 0; i < 210; i++) await fetch("http://127.0.0.1:4096/api/session", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ title: "old " + i, location: { directory: "/tmp/old" } }) })
|
|
154
|
+
const r4 = await sup.pipelines.start({ pipeline: "story", repo, branch: "feat/d", story: "DE10: after a long history", title: "D" })
|
|
155
|
+
await sup.discover()
|
|
156
|
+
ok(Object.values(r4.sessions).every((id) => sup.store.get(id)), "bots beyond the first page of sessions stay on the board")
|
|
157
|
+
ok(Object.values(after).every((id) => sup.store.get(id)), "including a freshly restarted team")
|
|
158
|
+
|
|
159
|
+
console.log("\na workstream can be deleted when you are done")
|
|
160
|
+
const r5 = await sup.pipelines.start({ pipeline: "story", repo, branch: "botfarm/delete-me", story: "DE11: throwaway", title: "E" })
|
|
161
|
+
await sleep(300)
|
|
162
|
+
const p5 = sup.pipelines.run(r5.id).projectId
|
|
163
|
+
const wt5 = sup.projects.get(p5).worktree
|
|
164
|
+
const room5 = sup.projects.get(p5).roomId
|
|
165
|
+
await writeFile(join(wt5, "scratch.txt"), "unsaved")
|
|
166
|
+
let pv = await (await fetch(`http://127.0.0.1:4837/api/projects/${p5}/delete`)).json()
|
|
167
|
+
ok(pv.changed === 1 && pv.files.includes("scratch.txt") && pv.branchOwned && pv.bots === 4, "the dialog is told what would go, uncommitted files first")
|
|
168
|
+
let del = await fetch(`http://127.0.0.1:4837/api/projects/${p5}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })
|
|
169
|
+
ok(del.status === 409 && sup.projects.get(p5), "uncommitted work stops it unless you say so")
|
|
170
|
+
del = await fetch(`http://127.0.0.1:4837/api/projects/${p5}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ force: true, deleteBranch: true }) })
|
|
171
|
+
ok(del.ok, "and then it goes")
|
|
172
|
+
ok(!sup.projects.get(p5) && !sup.pipelines.run(r5.id) && !sup.rooms.rooms.get(room5), "the workstream, its run and its chat are gone")
|
|
173
|
+
ok(sup.tasks.all({ projectId: p5 }).length === 0 && Object.values(r5.sessions).every((id) => !sup.store.get(id) && !sup.registry.get(id)), "and so are its cards and bots")
|
|
174
|
+
ok(!(await run("git", ["worktree", "list"], { cwd: repo })).stdout.includes(wt5), "the worktree is removed")
|
|
175
|
+
ok(!(await run("git", ["branch", "--list", "botfarm/delete-me"], { cwd: repo })).stdout.trim(), "and the botfarm branch, because you asked")
|
|
176
|
+
const p4 = sup.pipelines.run(r4.id).projectId
|
|
177
|
+
pv = await (await fetch(`http://127.0.0.1:4837/api/projects/${p4}/delete`)).json()
|
|
178
|
+
ok(pv.branch === "feat/d" && !pv.branchOwned, "a branch you named yourself is never offered")
|
|
179
|
+
await fetch(`http://127.0.0.1:4837/api/projects/${p4}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deleteBranch: true }) })
|
|
180
|
+
ok(!sup.projects.get(p4) && (await run("git", ["branch", "--list", "feat/d"], { cwd: repo })).stdout.trim(), "so it survives the delete")
|
|
181
|
+
|
|
182
|
+
console.log("\nan MCP server that was down when opencode opened the worktree is retried")
|
|
183
|
+
const botM = sup.store.get(Object.values(sup.pipelines.run(r3.id).sessions)[0])
|
|
184
|
+
await fetch("http://127.0.0.1:4096/__mcp-down?down=1")
|
|
185
|
+
await sup.refreshMcp([botM], { force: true })
|
|
186
|
+
ok(botM.mcp.servers.find((m) => m.name === "local-dev")?.status === "failed" && botM.mcp.servers.find((m) => m.name === "local-dev").error, "while it is still down the bot shows it failed, with why")
|
|
187
|
+
ok((await (await fetch("http://127.0.0.1:4096/__mcp-connects")).json()).some((c) => c.name === "local-dev" && c.directory === botM.directory), "and BotFarm asked opencode to connect it again")
|
|
188
|
+
ok(!(await (await fetch("http://127.0.0.1:4096/__mcp-connects")).json()).some((c) => c.name === "rag" || c.name === "botfarm" || c.name === "botfarm"), "but leaves disabled servers alone")
|
|
189
|
+
await fetch("http://127.0.0.1:4096/__mcp-down?down=0")
|
|
190
|
+
const rc = await fetch(`http://127.0.0.1:4837/api/sessions/${botM.id}/mcp/reconnect`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })
|
|
191
|
+
ok(rc.ok && (await rc.json()).servers.find((m) => m.name === "local-dev").connected, "once it is up, Reconnect brings it back")
|
|
192
|
+
|
|
193
|
+
console.log("\nworkspace config changes reach running workstreams")
|
|
194
|
+
const repo6 = await mkdtemp(join(tmpdir(), "cfg-"))
|
|
195
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: repo6 })
|
|
196
|
+
const { mkdir: mkd, readFile: rf } = await import("node:fs/promises")
|
|
197
|
+
await mkd(join(repo6, ".opencode"))
|
|
198
|
+
const wsCfg = (mcp) => writeFile(join(repo6, ".opencode", "opencode.json"), JSON.stringify({ agent: { plan: { permission: { edit: "deny", "local-broad*": "allow" } } }, mcp }))
|
|
199
|
+
await wsCfg({ "local-broad": { type: "remote", url: "http://localhost:3333/sse/broad", enabled: true }, rally: { type: "remote", url: "https://mcp.rallydev.com/mcp", enabled: true } })
|
|
200
|
+
await writeFile(join(repo6, "a"), "x")
|
|
201
|
+
await run("git", ["add", "."], { cwd: repo6 })
|
|
202
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: repo6 })
|
|
203
|
+
const r6 = await sup.pipelines.start({ pipeline: "story", repo: repo6, branch: "feat/cfg", story: "US3: config", title: "F" })
|
|
204
|
+
await sleep(300)
|
|
205
|
+
const p6 = sup.pipelines.run(r6.id).projectId
|
|
206
|
+
const wt6 = sup.projects.get(p6).worktree
|
|
207
|
+
const cfg6 = async () => JSON.parse(await rf(join(wt6, ".opencode", "opencode.json"), "utf8"))
|
|
208
|
+
let c6 = await cfg6()
|
|
209
|
+
ok(c6.mcp["local-broad"]?.url.includes("root=") && c6.mcp.botfarm, "the worktree starts with the workspace's MCP servers, pinned to it")
|
|
210
|
+
const prodPerm = c6.agent["botfarm-product"].permission ?? {}
|
|
211
|
+
ok(prodPerm["local-broad_*"] === "deny" && prodPerm["rally_*"] === "deny" && !("local-broad*" in prodPerm), "a bot limited to a few tools is denied MCP servers it does not list, even ones its base agent allows")
|
|
212
|
+
await wsCfg({ rally: { type: "remote", url: "https://mcp.rallydev.com/mcp", enabled: true } })
|
|
213
|
+
const disposedBefore = (await (await fetch("http://127.0.0.1:4096/__disposed")).json().catch(() => [])).length
|
|
214
|
+
const rr = await fetch(`http://127.0.0.1:4837/api/projects/${p6}/refresh-config`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })
|
|
215
|
+
const rrOut = await rr.json()
|
|
216
|
+
c6 = await cfg6()
|
|
217
|
+
ok(rr.ok && !c6.mcp["local-broad"] && c6.mcp.rally && c6.mcp.botfarm, "Refresh tools drops a server removed from the workspace and keeps botfarm")
|
|
218
|
+
ok(!("local-broad_*" in (c6.agent["botfarm-product"].permission ?? {})), "and the bots' agents are rebuilt from the new setup")
|
|
219
|
+
ok(rrOut.reloaded || rrOut.pending, "then opencode re-opens the worktree, now or once the bots are idle")
|
|
220
|
+
|
|
221
|
+
console.log("\nthe chat can colour a bot")
|
|
222
|
+
const snap = sup.store.get(r.sessions.dev).snapshot()
|
|
223
|
+
ok(/^#[0-9a-f]{6}$/.test(snap.color?.fg) && /^#[0-9a-f]{6}$/.test(snap.color?.bg), "every session carries its avatar colours")
|
|
224
|
+
|
|
225
|
+
sup.stop()
|
|
226
|
+
mock.kill()
|
|
227
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
228
|
+
process.exit(failed ? 1 : 0)
|
package/test/linked.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Repos from anywhere in a workstream: linked to the workspace up front, or added after the fact.
|
|
2
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
3
|
+
import { promisify } from "node:util"
|
|
4
|
+
import { mkdtemp, writeFile, mkdir, stat } from "node:fs/promises"
|
|
5
|
+
import { tmpdir } from "node:os"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
8
|
+
import { startServer } from "../src/server.mjs"
|
|
9
|
+
import * as git from "../src/git.mjs"
|
|
10
|
+
|
|
11
|
+
const run = promisify(execFile)
|
|
12
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
14
|
+
let failed = 0
|
|
15
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
16
|
+
const mkrepo = async (dir, file) => {
|
|
17
|
+
await mkdir(dir, { recursive: true })
|
|
18
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: dir })
|
|
19
|
+
await writeFile(join(dir, file), "x\n")
|
|
20
|
+
await run("git", ["add", "."], { cwd: dir })
|
|
21
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: dir })
|
|
22
|
+
return dir
|
|
23
|
+
}
|
|
24
|
+
const exists = (p) => stat(p).then(() => true, () => false)
|
|
25
|
+
const base = await mkdtemp(join(tmpdir(), "linked-"))
|
|
26
|
+
const repo = await mkrepo(join(base, "java-parent-services"), "pom.xml")
|
|
27
|
+
const spt = await mkrepo(join(base, "elsewhere", "spt"), "App.java")
|
|
28
|
+
const ui = await mkrepo(join(base, "elsewhere", "web-ui"), "package.json")
|
|
29
|
+
const home = await mkdtemp(join(tmpdir(), "linkedh-"))
|
|
30
|
+
process.env.BOT_FARM_WORKTREE_ROOT = join(home, "wt")
|
|
31
|
+
await sleep(600)
|
|
32
|
+
const PORT = 4849
|
|
33
|
+
const sup = new Supervisor({ url: "http://127.0.0.1:4096", statePath: join(home, "m.json"), registryPath: join(home, "r.json"), mcpBase: `http://127.0.0.1:${PORT}` })
|
|
34
|
+
await sup.start()
|
|
35
|
+
startServer({ supervisor: sup, port: PORT })
|
|
36
|
+
const api = async (path, opts = {}) => {
|
|
37
|
+
const res = await fetch(`http://127.0.0.1:${PORT}${path}`, { method: opts.method ?? "GET", headers: opts.body ? { "content-type": "application/json" } : undefined, body: opts.body ? JSON.stringify(opts.body) : undefined })
|
|
38
|
+
return { status: res.status, body: await res.json().catch(() => null) }
|
|
39
|
+
}
|
|
40
|
+
const prompts = async (id) => (await (await fetch("http://127.0.0.1:4096/__prompts")).json()).filter((p) => p.id === id)
|
|
41
|
+
const ws = await sup.workspaces.add({ path: repo, name: "jps" })
|
|
42
|
+
|
|
43
|
+
console.log("linking a repo from elsewhere to the workspace")
|
|
44
|
+
let res = await api(`/api/workspaces/${ws.id}/repos`, { method: "POST", body: { path: spt } })
|
|
45
|
+
ok(res.status === 200 && res.body.linked[0].path === spt && res.body.linked[0].as === "spt" && res.body.services.includes(spt), "a repo outside the workspace is linked, mounted as spt/, and ticked for new workstreams")
|
|
46
|
+
res = await api(`/api/workspaces/${ws.id}/repos`, { method: "POST", body: { path: spt } })
|
|
47
|
+
ok(res.status === 400 && /already linked/.test(res.body.error), "twice is refused")
|
|
48
|
+
res = await api(`/api/workspaces/${ws.id}/repos`, { method: "POST", body: { path: join(base, "elsewhere") } })
|
|
49
|
+
ok(res.status === 400 && /not a git repository/.test(res.body.error), "a folder that is not a repo is refused")
|
|
50
|
+
const lib = (await api(`/api/workspaces/${ws.id}/library`)).body
|
|
51
|
+
ok(lib.linked?.length === 1, "the library lists it for the new-workstream dialog")
|
|
52
|
+
|
|
53
|
+
console.log("\na workstream takes it along")
|
|
54
|
+
const r = await sup.pipelines.start({ pipeline: "story", workspace: ws.id, story: "US1: something across repos", title: "Across" })
|
|
55
|
+
await sleep(400)
|
|
56
|
+
const project = sup.projects.get(r.projectId)
|
|
57
|
+
ok(project.worktree && await exists(join(project.worktree, "spt", "App.java")), "the workstream's worktree has spt/ in it")
|
|
58
|
+
ok((await git.status(join(project.worktree, "spt"))).branch === project.branch, "on the workstream's branch")
|
|
59
|
+
const first = sup.store.get(Object.values(r.sessions)[0])
|
|
60
|
+
const text = (await prompts(first.id)).at(-1)?.parts?.[0]?.text ?? ""
|
|
61
|
+
ok(text.includes("Repos in this worktree") && text.includes("./spt/"), "and the bots are told which repos are where")
|
|
62
|
+
|
|
63
|
+
console.log("\nadding one after the fact")
|
|
64
|
+
res = await api(`/api/projects/${r.projectId}/repos`, { method: "POST", body: { path: ui, as: "ui" } })
|
|
65
|
+
ok(res.status === 200 && res.body.rel === "ui" && await exists(join(project.worktree, "ui", "package.json")), "a running workstream gets a worktree of another repo, mounted as ui/")
|
|
66
|
+
ok((await git.status(project.worktree)).untracked === 0, "the parent repo does not see it as untracked")
|
|
67
|
+
const room = sup.projects.room(r.projectId, { create: false })
|
|
68
|
+
ok(room?.messages.some((m) => m.text.includes("ui/ is now in this worktree")), "the chat says so")
|
|
69
|
+
ok(sup.pipelines.run(r.id).notes?.some((n) => n.topic === "Repos in this worktree" && n.text.includes("./ui/")), "and so do the team notes")
|
|
70
|
+
res = await api(`/api/projects/${r.projectId}/repos`, { method: "POST", body: { path: ui } })
|
|
71
|
+
ok(res.status === 400 && /already in this workstream/.test(res.body.error), "the same repo twice is refused")
|
|
72
|
+
ok((await sup.workstreamRepos(sup.projects.get(r.projectId))).includes(ui), "it counts as one of the workstream's repos (for branches and delete)")
|
|
73
|
+
|
|
74
|
+
console.log("\ndeleting the workstream")
|
|
75
|
+
await sup.deleteWorkstream(r.projectId, { deleteBranch: true, force: true })
|
|
76
|
+
ok((await git.listWorktrees(spt)).length === 1 && (await git.listWorktrees(ui)).length === 1, "the worktrees in the other repos go too")
|
|
77
|
+
const branches = (await run("git", ["branch", "--list", "botfarm/*"], { cwd: ui })).stdout.trim()
|
|
78
|
+
ok(!branches, "and their botfarm/ branches")
|
|
79
|
+
|
|
80
|
+
sup.stop()
|
|
81
|
+
mock.kill()
|
|
82
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
83
|
+
process.exit(failed ? 1 : 0)
|
package/test/loop.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Plan → Code → Verify → Fail → Code → Verify → Pass: sending work back.
|
|
2
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
3
|
+
import { promisify } from "node:util"
|
|
4
|
+
import { mkdtemp, writeFile } from "node:fs/promises"
|
|
5
|
+
import { tmpdir } from "node:os"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
8
|
+
import { startServer } from "../src/server.mjs"
|
|
9
|
+
import { checkPipelineDoc } from "../src/pipelines.mjs"
|
|
10
|
+
|
|
11
|
+
const run = promisify(execFile)
|
|
12
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
14
|
+
let failed = 0
|
|
15
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
16
|
+
const repo = await mkdtemp(join(tmpdir(), "loop-"))
|
|
17
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: repo })
|
|
18
|
+
await writeFile(join(repo, "a"), "x")
|
|
19
|
+
await run("git", ["add", "."], { cwd: repo })
|
|
20
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: repo })
|
|
21
|
+
const home = await mkdtemp(join(tmpdir(), "looph-"))
|
|
22
|
+
process.env.BOT_FARM_WORKTREE_ROOT = join(home, "wt")
|
|
23
|
+
await sleep(600)
|
|
24
|
+
const PORT = 4847
|
|
25
|
+
const sup = new Supervisor({ url: "http://127.0.0.1:4096", statePath: join(home, "m.json"), registryPath: join(home, "r.json"), mcpBase: `http://127.0.0.1:${PORT}` })
|
|
26
|
+
await sup.start()
|
|
27
|
+
startServer({ supervisor: sup, port: PORT })
|
|
28
|
+
const api = async (path, opts = {}) => {
|
|
29
|
+
const res = await fetch(`http://127.0.0.1:${PORT}${path}`, { method: opts.method ?? "GET", headers: opts.body ? { "content-type": "application/json" } : undefined, body: opts.body ? JSON.stringify(opts.body) : undefined })
|
|
30
|
+
return { status: res.status, body: await res.json().catch(() => null) }
|
|
31
|
+
}
|
|
32
|
+
const prompts = async (id) => (await (await fetch("http://127.0.0.1:4096/__prompts")).json()).filter((p) => p.id === id)
|
|
33
|
+
const lastText = async (id) => (await prompts(id)).at(-1)?.parts?.[0]?.text ?? ""
|
|
34
|
+
const T = (id) => sup.tasks.get(id)
|
|
35
|
+
const idleAll = () => { for (const s of sup.store.list()) s.setStatus("idle") }
|
|
36
|
+
const token = (s) => sup.mcpUrlFor(s).split("/").pop()
|
|
37
|
+
const rpc = async (s, name, args) => { sup.mcpCalls.set(s.id, Date.now()); return (await (await fetch(`http://127.0.0.1:${PORT}/mcp/${token(s)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) })).json()).result }
|
|
38
|
+
|
|
39
|
+
const ws = await sup.workspaces.add({ path: repo, name: "loop" })
|
|
40
|
+
const lib = await sup.pipelines.lib(ws.id)
|
|
41
|
+
lib.definitions.set("tdd", { id: "tdd", title: "TDD loop", stages: [
|
|
42
|
+
{ id: "plan", persona: "product", receives: [], prompt: "{{story}}" },
|
|
43
|
+
{ id: "code", persona: "dev", receives: ["plan"], prompt: "Build it." },
|
|
44
|
+
{ id: "verify", persona: "qa", receives: ["plan", "code"], max_rounds: 2, prompt: "Verify it." },
|
|
45
|
+
] })
|
|
46
|
+
lib.definitions.set("fan", { id: "fan", title: "Fan", stages: [
|
|
47
|
+
{ id: "plan", persona: "product", receives: [], prompt: "{{story}}" },
|
|
48
|
+
{ id: "build", persona: "dev", receives: ["plan"], split: "tasks", parallel: 2, prompt: "Build your piece." },
|
|
49
|
+
{ id: "verify", persona: "qa", receives: ["build"], prompt: "Verify it." },
|
|
50
|
+
] })
|
|
51
|
+
for (const [k, v] of sup.pipelines.personas) if (!lib.personas.has(k)) lib.personas.set(k, v)
|
|
52
|
+
|
|
53
|
+
console.log("the pipeline file")
|
|
54
|
+
const bad = checkPipelineDoc({ id: "x", stages: [{ id: "a", persona: "dev" }, { id: "b", persona: "qa", send_back: ["zzz"], max_rounds: 99 }] }, lib.personas)
|
|
55
|
+
ok(bad.some((p) => p.includes('"zzz"')) && bad.some((p) => p.includes("max_rounds")), "send_back and max_rounds are checked")
|
|
56
|
+
|
|
57
|
+
console.log("\nCode → Verify → Fail → Code → Verify")
|
|
58
|
+
const r = await sup.pipelines.start({ pipeline: "tdd", workspace: ws.id, story: "DE238: DELETE should 409 when a FRID exists", title: "Loop" })
|
|
59
|
+
await sleep(300); idleAll()
|
|
60
|
+
await sup.pipelines.complete(T(r.taskIds.plan), { summary: "Criteria set.", acceptance_criteria: ["409 when a FRID is active"] })
|
|
61
|
+
await sleep(300); idleAll()
|
|
62
|
+
const code = () => T(r.taskIds.code), verify = () => T(r.taskIds.verify)
|
|
63
|
+
const dev = sup.store.get(code().assignee)
|
|
64
|
+
await sup.pipelines.complete(code(), { summary: "Added the FRID check in CardService.", artifacts: ["CardService.java"] })
|
|
65
|
+
await sleep(300); idleAll()
|
|
66
|
+
const qa = sup.store.get(verify().assignee)
|
|
67
|
+
ok(verify().status === "active" && (await lastText(qa.id)).includes("botfarm_send_back") && (await lastText(qa.id)).includes("code (@"), "the checker is told it can send work back, and to whom")
|
|
68
|
+
let out = await rpc(qa, "send_back", { task_id: verify().id, to: "code", reason: "DELETE still returns 500", failures: ["FridDeleteIT.deleteWithActiveFrid: expected 409 but was 500 (CardController.java:88)"] })
|
|
69
|
+
await sleep(300)
|
|
70
|
+
ok(!out.isError && out.structuredContent.fix === "1 of 2", "QA sends the code back (fix 1 of 2)")
|
|
71
|
+
ok(code().round === 2 && code().history?.[0]?.summary.includes("FRID check") && code().status === "active" && code().assignee === dev.id, "the code card goes back to the dev who wrote it, with the last round kept")
|
|
72
|
+
ok(verify().status === "blocked" && verify().recheck?.to === "code" && verify().dependsOn.includes(code().id), "QA's card waits for the fix")
|
|
73
|
+
let t = await lastText(dev.id)
|
|
74
|
+
ok(t.includes("Round 2") && t.includes("expected 409 but was 500") && t.includes("Added the FRID check"), "the dev's prompt says what failed and what it handed off last time")
|
|
75
|
+
const room = sup.rooms.rooms.get(r.roomId)
|
|
76
|
+
ok(room.messages.some((m) => m.meta?.kind === "sendback" && m.text.includes("fix 1 of 2")), "the chat shows it was sent back")
|
|
77
|
+
idleAll()
|
|
78
|
+
await sup.pipelines.complete(code(), { summary: "Mapped FridActiveException to 409 in the controller advice." })
|
|
79
|
+
await sleep(300); idleAll()
|
|
80
|
+
ok(code().xp?.gained === 3 && code().rework === null, "a fix round earns a little XP, not a full card")
|
|
81
|
+
t = await lastText(qa.id)
|
|
82
|
+
ok(verify().status === "active" && verify().assignee === qa.id && t.includes("Fix 1") && t.includes("controller advice") && t.includes("Verify it again"), "the same QA bot gets its card back with the fix to verify")
|
|
83
|
+
|
|
84
|
+
console.log("\nstopping the loop")
|
|
85
|
+
out = await rpc(qa, "send_back", { task_id: verify().id, to: "code", reason: "DELETE still returns 500", failures: ["FridDeleteIT.deleteWithActiveFrid: expected 409 but was 500 (CardController.java:88)"] })
|
|
86
|
+
ok(out.structuredContent.paused && verify().status === "review" && verify().budget?.kind === "rounds" && /same failure/.test(verify().budget.why), "the same failure twice stops and asks the operator")
|
|
87
|
+
ok(code().status === "done", "and nothing was sent")
|
|
88
|
+
idleAll()
|
|
89
|
+
let res = await api(`/api/tasks/${verify().id}/budget`, { method: "POST", body: { action: "continue" } })
|
|
90
|
+
await sleep(300)
|
|
91
|
+
ok(res.status === 200 && code().round === 3 && code().status === "active", "One more round sends it after all")
|
|
92
|
+
idleAll()
|
|
93
|
+
await sup.pipelines.complete(code(), { summary: "Fixed the transaction boundary too." })
|
|
94
|
+
await sleep(300); idleAll(); await sup.pipelines.dispatch()
|
|
95
|
+
out = await rpc(qa, "send_back", { task_id: verify().id, to: "code", reason: "Now the 409 body is empty", failures: ["FridDeleteIT.errorBody: expected JSON body"] })
|
|
96
|
+
ok(out.structuredContent.paused && /sent back 2 times \(max 2\)/.test(verify().budget.why), "past max_rounds it stops too")
|
|
97
|
+
res = await api(`/api/tasks/${verify().id}/budget`, { method: "POST", body: { action: "accept" } })
|
|
98
|
+
await sleep(200)
|
|
99
|
+
ok(verify().status === "active" && (await lastText(qa.id)).includes("no more rounds"), "Accept as is tells the checker to finish and list what still fails")
|
|
100
|
+
out = await rpc(qa, "send_back", { task_id: verify().id, to: "plan", reason: "x".repeat(30) })
|
|
101
|
+
ok(!out.isError && out.structuredContent.fix, "any earlier stage can be the target by default")
|
|
102
|
+
idleAll()
|
|
103
|
+
await sup.pipelines.complete(T(r.taskIds.plan), { summary: "Clarified the body." })
|
|
104
|
+
await sleep(300); idleAll()
|
|
105
|
+
out = await rpc(qa, "send_back", { task_id: verify().id, to: "verify", reason: "x".repeat(30) })
|
|
106
|
+
ok(out.isError, "a card cannot send itself back")
|
|
107
|
+
await sup.pipelines.complete(verify(), { summary: "Passes except the empty body.", open_questions: [] })
|
|
108
|
+
await sleep(300)
|
|
109
|
+
ok(sup.pipelines.run(r.id).status === "done" && verify().xp?.reasons.some((x) => x.includes("sent back")), "then it completes, and the checker gets a little for catching things")
|
|
110
|
+
|
|
111
|
+
console.log("\nfrom the board")
|
|
112
|
+
{
|
|
113
|
+
const r2 = await sup.pipelines.start({ pipeline: "tdd", workspace: ws.id, story: "US9: board", title: "Board loop" })
|
|
114
|
+
await sleep(300); idleAll()
|
|
115
|
+
await sup.pipelines.complete(T(r2.taskIds.plan), { summary: "ok" })
|
|
116
|
+
await sleep(200); idleAll()
|
|
117
|
+
await sup.pipelines.complete(T(r2.taskIds.code), { summary: "done" })
|
|
118
|
+
await sleep(200)
|
|
119
|
+
ok(T(r2.taskIds.verify).status === "active", "QA is verifying")
|
|
120
|
+
res = await api(`/api/tasks/${r2.taskIds.code}/send-back`, { method: "POST", body: { reason: "Use the existing ErrorBody class, not a new one" } })
|
|
121
|
+
await sleep(300)
|
|
122
|
+
ok(res.status === 200 && T(r2.taskIds.code).round === 2 && T(r2.taskIds.code).rework.by === "operator", "the operator sends a done card back")
|
|
123
|
+
ok(T(r2.taskIds.verify).status === "blocked" && T(r2.taskIds.verify).recheck, "and the card that was checking it waits for the fix")
|
|
124
|
+
await sup.pipelines.restart(r2.projectId, { stage: "code", fresh: false })
|
|
125
|
+
await sleep(200)
|
|
126
|
+
ok(!T(r2.taskIds.code).round && !T(r2.taskIds.verify).recheck && T(r2.taskIds.verify).dependsOn.length === 1, "restarting forgets the rounds")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log("\nsplit stages: the piece that failed, preferring its bot")
|
|
130
|
+
{
|
|
131
|
+
const r3 = await sup.pipelines.start({ pipeline: "fan", workspace: ws.id, story: "US10: two pieces", title: "Fan loop" })
|
|
132
|
+
await sleep(300); idleAll()
|
|
133
|
+
await sup.pipelines.complete(T(r3.taskIds.plan), { summary: "two", tasks: [{ title: "API docs" }, { title: "Service check" }] })
|
|
134
|
+
await sleep(500); idleAll()
|
|
135
|
+
const [p1, p2] = T(r3.taskIds.build).children.map(T)
|
|
136
|
+
const devA = p1.assignee, devB = p2.assignee
|
|
137
|
+
ok(devA && devB && devA !== devB, "two dev bots build the two pieces")
|
|
138
|
+
await sup.pipelines.complete(p1, { summary: "docs", artifacts: ["docs/api.md"] })
|
|
139
|
+
await sup.pipelines.complete(T(p2.id), { summary: "service", artifacts: ["src/main/java/CardService.java"] })
|
|
140
|
+
await sleep(300); idleAll()
|
|
141
|
+
const qa3 = sup.store.get(T(r3.taskIds.verify).assignee)
|
|
142
|
+
sup.store.get(devB).setStatus("busy")
|
|
143
|
+
out = await rpc(qa3, "send_back", { task_id: r3.taskIds.verify, to: "build", reason: "409 missing", files: ["CardService.java"] })
|
|
144
|
+
await sleep(300)
|
|
145
|
+
ok(!out.isError && T(p2.id).status === "queued" && T(p2.id).prefer === devB && T(p1.id).status === "done", "only the piece that touched the file goes back, for the bot that built it")
|
|
146
|
+
ok(T(r3.taskIds.build).status === "active", "the split card is open again until it lands")
|
|
147
|
+
await sup.pipelines.dispatch()
|
|
148
|
+
ok(T(p2.id).status === "queued", "the other dev, though free, leaves it while its own bot has its chance")
|
|
149
|
+
sup.store.get(devA).setStatus("idle")
|
|
150
|
+
const waiting = T(p2.id)
|
|
151
|
+
waiting.preferUntil = Date.now() - 1
|
|
152
|
+
sup.tasks.save(waiting)
|
|
153
|
+
await sup.pipelines.dispatch()
|
|
154
|
+
ok(T(p2.id).status === "active" && T(p2.id).assignee === devA, "after that, a free bot of the same kind takes it")
|
|
155
|
+
idleAll()
|
|
156
|
+
await sup.pipelines.complete(T(p2.id), { summary: "fixed" })
|
|
157
|
+
await sleep(300); idleAll()
|
|
158
|
+
ok(T(r3.taskIds.build).status === "done" && T(r3.taskIds.verify).status === "active", "the split card lands again and QA verifies")
|
|
159
|
+
out = await rpc(qa3, "send_back", { task_id: r3.taskIds.verify, to: "build", reason: "The audit log line is missing for deletes" })
|
|
160
|
+
await sleep(300)
|
|
161
|
+
const fix = T(r3.taskIds.build).children.map(T).at(-1)
|
|
162
|
+
ok(!out.isError && fix.title.startsWith("Fix:") && fix.item.index === 3 && fix.owner === "role", "a failure no piece owns becomes a new fix piece for any dev")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
sup.stop()
|
|
166
|
+
mock.kill()
|
|
167
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
168
|
+
process.exit(failed ? 1 : 0)
|