@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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Minimal fake opencode v2 server: enough of /api to exercise botfarm locally.
|
|
2
|
+
import { createServer } from "node:http"
|
|
3
|
+
|
|
4
|
+
const sessions = new Map()
|
|
5
|
+
const messages = new Map()
|
|
6
|
+
const listeners = new Set()
|
|
7
|
+
const registered = [] // runtime MCP registrations, for tests
|
|
8
|
+
const created = [] // session create bodies, for tests
|
|
9
|
+
const prompts = [] // instance-engine prompt bodies, for tests
|
|
10
|
+
const pendingP = [], pendingQ = [], replies = []
|
|
11
|
+
const mcpState = new Map()
|
|
12
|
+
const mcpConnects = []
|
|
13
|
+
let mcpDown = false, mcpMode = false // the /mcp map routes switch on with /__mcp-down
|
|
14
|
+
let n = 0
|
|
15
|
+
|
|
16
|
+
function mkSession({ title, directory }) {
|
|
17
|
+
const id = "ses_" + (++n).toString().padStart(4, "0")
|
|
18
|
+
const s = { id, title, directory, time: { created: Date.now(), updated: Date.now() }, agent: "build", model: { providerID: "anthropic", modelID: "claude-opus-5" } }
|
|
19
|
+
sessions.set(id, s)
|
|
20
|
+
messages.set(id, [])
|
|
21
|
+
return s
|
|
22
|
+
}
|
|
23
|
+
const emit = (payload) => {
|
|
24
|
+
const frame = `data: ${JSON.stringify({ directory: "global", payload })}\n\n`
|
|
25
|
+
for (const r of listeners) r.write(frame)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// seed two sessions, one of which works continuously
|
|
29
|
+
const a = mkSession({ title: "fix auth redirect", directory: "/home/claude/worktrees/app/fix-auth" })
|
|
30
|
+
const b = mkSession({ title: "flaky test triage", directory: "/home/claude/botfarm" })
|
|
31
|
+
const busy = new Set([a.id])
|
|
32
|
+
|
|
33
|
+
let step = 0
|
|
34
|
+
setInterval(() => {
|
|
35
|
+
step++
|
|
36
|
+
for (const id of busy) {
|
|
37
|
+
const msgs = messages.get(id)
|
|
38
|
+
const mid = "msg_" + id + "_" + Math.floor(step / 3)
|
|
39
|
+
let m = msgs.find((x) => x.id === mid)
|
|
40
|
+
if (!m) {
|
|
41
|
+
m = { id, role: "assistant", id: mid, sessionID: id, time: { created: Date.now() }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, parts: [] }
|
|
42
|
+
msgs.unshift(m)
|
|
43
|
+
}
|
|
44
|
+
m.tokens.output += 150 + Math.floor(Math.random() * 400)
|
|
45
|
+
m.tokens.input = 12000 + step * 40
|
|
46
|
+
m.tokens.cache.read = 30000
|
|
47
|
+
if (step % 2 === 0) {
|
|
48
|
+
const pid = "prt_" + step
|
|
49
|
+
m.parts.push({
|
|
50
|
+
id: pid, type: "tool", name: ["bash", "read", "edit", "grep"][step % 4],
|
|
51
|
+
state: { status: "completed", input: { command: "npm test -- --watch=false" }, time: { start: Date.now() - 1200, end: Date.now() } },
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
m.parts.push({ type: "text", text: "Working on the redirect handler, step " + step })
|
|
55
|
+
emit({ type: "message.part.updated", properties: { sessionID: id } })
|
|
56
|
+
}
|
|
57
|
+
}, 500)
|
|
58
|
+
|
|
59
|
+
createServer(async (req, res) => {
|
|
60
|
+
const url = new URL(req.url, "http://x")
|
|
61
|
+
const p = url.pathname
|
|
62
|
+
const json = (o, code = 200) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(o)) }
|
|
63
|
+
|
|
64
|
+
if (p === "/api/info") return json({ version: "mock", hostname: "127.0.0.1" })
|
|
65
|
+
if (p === "/api/event") {
|
|
66
|
+
res.writeHead(200, { "content-type": "text/event-stream" })
|
|
67
|
+
res.write(`data: ${JSON.stringify({ payload: { type: "server.connected" } })}\n\n`)
|
|
68
|
+
listeners.add(res)
|
|
69
|
+
req.on("close", () => listeners.delete(res))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (p === "/api/mcp") return json({ data: [{ name: "botfarm", status: "connected", tools: [{ name: "send" }, { name: "roster" }] }, { name: "playwright", status: "disconnected" }] })
|
|
73
|
+
if (p === "/api/experimental/tool/ids") return json({ data: ["bash", "edit", "read", "grep", "webfetch"] })
|
|
74
|
+
if (/^\/api\/experimental\/mcp\/[^/]+\/(connect|disconnect)$/.test(p)) return json({ ok: true })
|
|
75
|
+
if (/^\/api\/experimental\/mcp\/[^/]+$/.test(p) && req.method === "PUT") {
|
|
76
|
+
const body = JSON.parse(await text(req))
|
|
77
|
+
registered.push({ name: p.split("/").pop(), directory: url.searchParams.get("location[directory]") ?? url.searchParams.get("directory"), config: body.config })
|
|
78
|
+
return json({ ok: true })
|
|
79
|
+
}
|
|
80
|
+
// --- the instance engine (/session…?directory=), as the TUI uses it ------
|
|
81
|
+
if (p === "/global/event") {
|
|
82
|
+
res.writeHead(200, { "content-type": "text/event-stream" })
|
|
83
|
+
res.write(`data: ${JSON.stringify({ payload: { type: "server.connected" } })}\n\n`)
|
|
84
|
+
listeners.add(res)
|
|
85
|
+
req.on("close", () => listeners.delete(res))
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (p === "/session" && req.method === "POST") {
|
|
89
|
+
const body = JSON.parse(await text(req))
|
|
90
|
+
created.push({ ...body, engine: "v1", location: { directory: url.searchParams.get("directory") } })
|
|
91
|
+
const s = mkSession({ title: body.title, directory: url.searchParams.get("directory") })
|
|
92
|
+
return json(s)
|
|
93
|
+
}
|
|
94
|
+
if (p === "/session/status") {
|
|
95
|
+
return json(Object.fromEntries([...sessions.keys()].map((id) => [id, { type: busy.has(id) ? "busy" : "idle" }])))
|
|
96
|
+
}
|
|
97
|
+
const v1 = /^\/session\/([^/]+)\/(prompt_async|message|abort)$/.exec(p)
|
|
98
|
+
if (v1 && sessions.has(v1[1])) {
|
|
99
|
+
const id = v1[1]
|
|
100
|
+
if (v1[2] === "message") return json((messages.get(id) ?? []).slice(0, Number(url.searchParams.get("limit") ?? 50)).slice().reverse().map((m) => ({ info: m, parts: m.parts })))
|
|
101
|
+
if (v1[2] === "abort") { busy.delete(id); return json(true) }
|
|
102
|
+
const body = JSON.parse(await text(req))
|
|
103
|
+
prompts.push({ id, ...body })
|
|
104
|
+
messages.get(id).unshift({ id: "msg_in_" + Math.random().toString(36).slice(2, 7), sessionID: id, role: "user", time: { created: Date.now() }, parts: body.parts })
|
|
105
|
+
busy.add(id)
|
|
106
|
+
res.writeHead(204)
|
|
107
|
+
return res.end()
|
|
108
|
+
}
|
|
109
|
+
if (p === "/__prompts") return json(prompts)
|
|
110
|
+
if (p === "/config/providers") {
|
|
111
|
+
return json({ providers: [{ id: "amazon-bedrock", name: "Amazon Bedrock", models: {
|
|
112
|
+
"anthropic.claude-sonnet-5": { name: "Claude Sonnet 5", variants: { low: {}, medium: {}, high: {} } },
|
|
113
|
+
"anthropic.claude-opus-5": { name: "Claude Opus 5", variants: { low: {}, medium: {}, high: {}, max: {} } },
|
|
114
|
+
} }], default: {} })
|
|
115
|
+
}
|
|
116
|
+
// Pending asks from the instance engine, and a way for tests to raise one.
|
|
117
|
+
if (p === "/__ask" && req.method === "POST") {
|
|
118
|
+
const body = JSON.parse(await text(req))
|
|
119
|
+
const id = (body.kind === "question" ? "que_" : "per_") + Math.random().toString(36).slice(2, 8)
|
|
120
|
+
;(body.kind === "question" ? pendingQ : pendingP).push({ id, ...body.request, sessionID: body.sessionID })
|
|
121
|
+
emit({ type: body.kind === "question" ? "question.asked" : "permission.asked", properties: { sessionID: body.sessionID, id } })
|
|
122
|
+
return json({ id })
|
|
123
|
+
}
|
|
124
|
+
if (p === "/permission") return json(pendingP)
|
|
125
|
+
if (p === "/question") return json(pendingQ)
|
|
126
|
+
const rep = /^\/(permission|question)\/([^/]+)\/(reply|reject)$/.exec(p)
|
|
127
|
+
if (rep) {
|
|
128
|
+
const body = JSON.parse((await text(req)) || "{}")
|
|
129
|
+
replies.push({ kind: rep[1], id: rep[2], action: rep[3], ...body })
|
|
130
|
+
const list = rep[1] === "permission" ? pendingP : pendingQ
|
|
131
|
+
const i = list.findIndex((x) => x.id === rep[2])
|
|
132
|
+
if (i >= 0) list.splice(i, 1)
|
|
133
|
+
return json(true)
|
|
134
|
+
}
|
|
135
|
+
// MCP status per directory. "local-dev" starts failed, as when the docker
|
|
136
|
+
// server was down when opencode opened the directory; /connect fixes it
|
|
137
|
+
// unless /__mcp-down says the server is still down.
|
|
138
|
+
if (p === "/mcp" && req.method === "GET" && mcpMode) {
|
|
139
|
+
const dir = url.searchParams.get("directory")
|
|
140
|
+
return json({ botfarm: { status: "connected" }, "local-dev": mcpState.get(dir) ?? { status: "failed", error: "server unavailable" }, rag: { status: "disabled" } })
|
|
141
|
+
}
|
|
142
|
+
const conn = /^\/mcp\/([^/]+)\/connect$/.exec(p)
|
|
143
|
+
if (conn && req.method === "POST" && mcpMode) {
|
|
144
|
+
mcpConnects.push({ name: decodeURIComponent(conn[1]), directory: url.searchParams.get("directory") })
|
|
145
|
+
if (!mcpDown) mcpState.set(url.searchParams.get("directory"), { status: "connected" })
|
|
146
|
+
return json(true)
|
|
147
|
+
}
|
|
148
|
+
if (p === "/__mcp-down") { mcpMode = true; mcpDown = url.searchParams.get("down") === "1"; if (mcpDown) mcpState.clear(); return json(mcpDown) }
|
|
149
|
+
if (p === "/__mcp-connects") return json(mcpConnects)
|
|
150
|
+
if (p === "/__replies") return json(replies)
|
|
151
|
+
if (p === "/__registered") return json(registered)
|
|
152
|
+
if (p === "/config" && req.method === "GET") {
|
|
153
|
+
// What a real server resolves for a directory: its opencode.json files, merged.
|
|
154
|
+
const { readFileSync } = await import("node:fs")
|
|
155
|
+
const dir = url.searchParams.get("directory")
|
|
156
|
+
const out = { agent: {}, mcp: {} }
|
|
157
|
+
for (const f of ["opencode.json", ".opencode/opencode.json"]) {
|
|
158
|
+
try {
|
|
159
|
+
const c = JSON.parse(readFileSync(dir + "/" + f, "utf8").replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"))
|
|
160
|
+
Object.assign(out.agent, c.agent ?? {}); Object.assign(out.mcp, c.mcp ?? {}); if (c.model) out.model = c.model
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
return json(out)
|
|
164
|
+
}
|
|
165
|
+
if (p === "/__created") return json(created)
|
|
166
|
+
// Oldest first and capped, like a real server with a long history: the
|
|
167
|
+
// newest sessions are the ones that fall off a single page.
|
|
168
|
+
if (p === "/api/session" && req.method === "GET") return json({ data: [...sessions.values()].slice(0, Number(url.searchParams.get("limit") ?? 1000)) })
|
|
169
|
+
if (p === "/api/session" && req.method === "POST") {
|
|
170
|
+
const body = JSON.parse(await text(req))
|
|
171
|
+
created.push(body)
|
|
172
|
+
const s = mkSession({ title: body.title, directory: body.location?.directory })
|
|
173
|
+
emit({ type: "session.created", properties: { info: s } })
|
|
174
|
+
return json({ data: s })
|
|
175
|
+
}
|
|
176
|
+
if (p === "/api/session/active") return json({ data: Object.fromEntries([...busy].map((id) => [id, true])) })
|
|
177
|
+
const m = /^\/api\/session\/([^/]+)(\/.*)?$/.exec(p)
|
|
178
|
+
if (m) {
|
|
179
|
+
const id = m[1]
|
|
180
|
+
const tail = m[2] ?? ""
|
|
181
|
+
if (!sessions.has(id)) return json({ error: "not found" }, 404)
|
|
182
|
+
if (tail === "/message") return json({ data: (messages.get(id) ?? []).slice(0, Number(url.searchParams.get("limit") ?? 20)) })
|
|
183
|
+
if (tail === "/prompt" || tail === "/synthetic") {
|
|
184
|
+
const body = JSON.parse(await text(req))
|
|
185
|
+
if (tail === "/prompt" && !body.prompt) {
|
|
186
|
+
res.writeHead(400, { "content-type": "application/json" })
|
|
187
|
+
return res.end(JSON.stringify({ _tag: "InvalidRequestError", message: 'Missing key\n at ["prompt"]', kind: "Payload" }))
|
|
188
|
+
}
|
|
189
|
+
const promptText = body.prompt?.text ?? body.message?.text ?? body.prompt ?? body.text ?? ""
|
|
190
|
+
if (!promptText) {
|
|
191
|
+
res.writeHead(400, { "content-type": "application/json" })
|
|
192
|
+
return res.end(JSON.stringify({ _tag: "InvalidRequestError", message: "no text in payload", kind: "Payload" }))
|
|
193
|
+
}
|
|
194
|
+
messages.get(id).unshift({
|
|
195
|
+
id: "msg_in_" + Math.random().toString(36).slice(2, 7), sessionID: id,
|
|
196
|
+
role: tail === "/synthetic" ? "synthetic" : "user",
|
|
197
|
+
time: { created: Date.now() },
|
|
198
|
+
parts: [{ type: "text", text: promptText }],
|
|
199
|
+
})
|
|
200
|
+
busy.add(id)
|
|
201
|
+
emit({ type: "message.updated", properties: { sessionID: id } })
|
|
202
|
+
return json({ data: { id: "msg_x" } })
|
|
203
|
+
}
|
|
204
|
+
if (tail === "/interrupt") { busy.delete(id); return json({ interrupted: true }) }
|
|
205
|
+
if (tail === "/permission") return json({ data: [] })
|
|
206
|
+
if (tail === "/compact") { res.writeHead(204); return res.end() }
|
|
207
|
+
if (!tail && req.method === "DELETE") { sessions.delete(id); busy.delete(id); res.writeHead(204); return res.end() }
|
|
208
|
+
if (!tail) return json({ data: sessions.get(id) })
|
|
209
|
+
}
|
|
210
|
+
json({ error: "no route " + p }, 404)
|
|
211
|
+
}).listen(4096, () => console.log("mock opencode on http://127.0.0.1:4096"))
|
|
212
|
+
|
|
213
|
+
async function text(req) {
|
|
214
|
+
const c = []
|
|
215
|
+
for await (const x of req) c.push(x)
|
|
216
|
+
return Buffer.concat(c).toString()
|
|
217
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Fan-out and join: a stage split into one card per item of an earlier
|
|
2
|
+
// handoff, worked by several bots of one kind at once; "after:" for stages
|
|
3
|
+
// that run side by side (TDD); time/token/$ limits that stop and ask.
|
|
4
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
5
|
+
import { promisify } from "node:util"
|
|
6
|
+
import { mkdtemp, writeFile } from "node:fs/promises"
|
|
7
|
+
import { tmpdir } from "node:os"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
10
|
+
import { startServer } from "../src/server.mjs"
|
|
11
|
+
|
|
12
|
+
const run = promisify(execFile)
|
|
13
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
14
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
15
|
+
let failed = 0
|
|
16
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
17
|
+
const repo = await mkdtemp(join(tmpdir(), "par-"))
|
|
18
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: repo })
|
|
19
|
+
await writeFile(join(repo, "a"), "x")
|
|
20
|
+
await run("git", ["add", "."], { cwd: repo })
|
|
21
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: repo })
|
|
22
|
+
const home = await mkdtemp(join(tmpdir(), "parh-"))
|
|
23
|
+
await sleep(600)
|
|
24
|
+
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:4839" })
|
|
25
|
+
await sup.start()
|
|
26
|
+
startServer({ supervisor: sup, port: 4839 })
|
|
27
|
+
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 ?? ""))
|
|
28
|
+
const idleAll = () => { for (const s of sup.store.list()) s.setStatus("idle") }
|
|
29
|
+
const T = (id) => sup.tasks.get(id)
|
|
30
|
+
|
|
31
|
+
sup.pipelines.definitions.set("fan", {
|
|
32
|
+
id: "fan",
|
|
33
|
+
title: "Plan, build in parallel, test alongside",
|
|
34
|
+
stages: [
|
|
35
|
+
{ id: "plan", persona: "product", title: "Plan the pieces", receives: [], prompt: "{{story}}" },
|
|
36
|
+
{ id: "build", persona: "dev", title: "Build", receives: ["plan"], split: "tasks", parallel: 3, limits: { minutes: 30 }, prompt: "Build it." },
|
|
37
|
+
{ id: "tests", persona: "qa", title: "Write the tests first", receives: ["plan"], after: ["plan"], prompt: "Write failing tests for the criteria." },
|
|
38
|
+
{ id: "check", persona: "reviewer", title: "Check", receives: ["build", "tests"], after: ["build", "tests"], prompt: "{{#handoffs.build}}{{summary}}{{/handoffs.build}}" },
|
|
39
|
+
],
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
console.log("a stage that waits on an earlier one, not the one before it")
|
|
43
|
+
const r = await sup.pipelines.start({ pipeline: "fan", repo, branch: "feat/fan", story: "US9: ten little changes", title: "Fan", limits: { minutes: 600 } })
|
|
44
|
+
await sleep(300)
|
|
45
|
+
ok(T(r.taskIds.plan).status === "active" && T(r.taskIds.build).status === "blocked" && T(r.taskIds.tests).status === "blocked", "only the first stage starts")
|
|
46
|
+
const plan = (await texts(r.sessions.product)).find((t) => t.includes("[botfarm] task"))
|
|
47
|
+
ok(plan?.includes("tasks") && plan.includes("REQUIRED") && plan.includes("dev bot"), "the stage feeding a split is asked for a task list")
|
|
48
|
+
idleAll()
|
|
49
|
+
await sup.pipelines.complete(T(r.taskIds.plan), { summary: "Four pieces.", acceptance_criteria: ["all four"], tasks: ["Add the DAO method", { title: "Add the endpoint", detail: "DELETE /things/{id} returns 409 when a FRID exists" }, "Wire the service", "Update the docs"] })
|
|
50
|
+
await sleep(400)
|
|
51
|
+
const build = T(r.taskIds.build)
|
|
52
|
+
ok(T(r.taskIds.tests).status === "active", "QA starts on the tests at the same time as the devs (after: [plan])")
|
|
53
|
+
ok(build.children?.length === 4 && build.status === "active", "the build card fans out into one card per item")
|
|
54
|
+
const kids = build.children.map(T)
|
|
55
|
+
ok(kids[1].title === "Add the endpoint" && kids[1].item.detail.includes("409") && kids[1].item.total === 4, "each with its own title and detail")
|
|
56
|
+
const devs = sup.projects.sessions(r.projectId).filter((s) => s.persona === "dev")
|
|
57
|
+
ok(devs.length === 3, `up to three dev bots work it (${devs.length})`)
|
|
58
|
+
ok(kids.filter((k) => k.status === "active").length === 3 && kids.filter((k) => k.status === "queued").length === 1, "three pieces at once, the fourth waits its turn")
|
|
59
|
+
ok(new Set(kids.filter((k) => k.assignee).map((k) => k.assignee)).size === 3, "each on a different bot")
|
|
60
|
+
const kidPrompt = (await texts(kids[0].assignee)).find((t) => t.includes(kids[0].id))
|
|
61
|
+
ok(kidPrompt?.includes("Your part — 1 of 4: Add the DAO method") && kidPrompt.includes("Do only your piece"), "a piece knows which part it is and that others share the worktree")
|
|
62
|
+
ok(kidPrompt?.includes("You are @"), "a new dev bot gets the dev brief with its first piece")
|
|
63
|
+
ok(sup.pipelines.run(r.id).sessions["dev-2"] && sup.rooms.rooms.get(r.roomId ?? sup.pipelines.run(r.id).roomId).members.size >= 6, "the extra bots join the team and the chat")
|
|
64
|
+
|
|
65
|
+
console.log("\nthe stage after waits for every piece")
|
|
66
|
+
const first = kids.find((k) => k.status === "active")
|
|
67
|
+
idleAll()
|
|
68
|
+
await sup.pipelines.complete(T(first.id), { summary: "DAO method added.", artifacts: ["Dao.java"] })
|
|
69
|
+
await sleep(300)
|
|
70
|
+
ok(T(kids.find((k) => k.status === "queued").id).status === "active", "a finished bot picks up the waiting piece")
|
|
71
|
+
ok(T(r.taskIds.build).status === "active" && T(r.taskIds.check).status === "blocked", "the build card is still open")
|
|
72
|
+
for (const k of kids.map((k) => T(k.id)).filter((k) => k.status === "active")) {
|
|
73
|
+
idleAll()
|
|
74
|
+
await sup.pipelines.complete(k, { summary: `Did ${k.title}.`, artifacts: [`${k.item.index}.java`], open_questions: k.item.index === 4 ? ["Which doc site?"] : [] })
|
|
75
|
+
}
|
|
76
|
+
await sleep(300)
|
|
77
|
+
const joined = T(r.taskIds.build)
|
|
78
|
+
ok(joined.status === "done" && joined.handoff.parts.length === 4, "when the last piece lands, the card is done")
|
|
79
|
+
ok(joined.handoff.summary.includes("DAO method added.") && joined.handoff.summary.includes("[2/4] Add the endpoint") && joined.handoff.artifacts.length === 4, "with every piece's handoff joined")
|
|
80
|
+
ok(joined.handoff.open_questions.includes("Which doc site?"), "open questions from a piece travel on")
|
|
81
|
+
ok(T(r.taskIds.check).status === "blocked", "the reviewer still waits for QA")
|
|
82
|
+
idleAll()
|
|
83
|
+
await sup.pipelines.complete(T(r.taskIds.tests), { summary: "Tests written, failing as expected." })
|
|
84
|
+
await sleep(300)
|
|
85
|
+
ok(T(r.taskIds.check).status === "active", "and starts once both are in")
|
|
86
|
+
const check = (await texts(r.sessions.reviewer)).find((t) => t.includes("[botfarm] task"))
|
|
87
|
+
ok(check?.includes("DAO method added.") && check.includes("Did Update the docs."), "reading the joined handoff")
|
|
88
|
+
|
|
89
|
+
console.log("\na card over its limit stops and asks")
|
|
90
|
+
const r2 = await sup.pipelines.start({ pipeline: "fan", repo, branch: "feat/fan2", story: "US10: two", title: "Fan2" })
|
|
91
|
+
await sleep(300)
|
|
92
|
+
idleAll()
|
|
93
|
+
await sup.pipelines.complete(T(r2.taskIds.plan), { summary: "Two.", tasks: ["one", "two"] })
|
|
94
|
+
await sleep(300)
|
|
95
|
+
const piece = T(T(r2.taskIds.build).children[0])
|
|
96
|
+
ok(sup.projects.sessions(r2.projectId).filter((s) => s.persona === "dev").length === 2, "two pieces bring two dev bots, not three")
|
|
97
|
+
ok(!sup.tasks.all({ projectId: r.projectId }).some((t) => t.parentId === r2.taskIds.build), "(and this workstream's pieces stay here)")
|
|
98
|
+
await sup.pipelines.checkBudgets(Date.now() + 40 * 60_000) // past 30 min even with a Seasoned +10%
|
|
99
|
+
let p2 = T(piece.id)
|
|
100
|
+
ok(p2.status === "review" && p2.budget?.kind === "minutes", "past its 30 minutes it is stopped and waits for you")
|
|
101
|
+
ok(sup.store.events.some((e) => /reached its time/.test(e.text)), "with a note saying why")
|
|
102
|
+
let res = await fetch(`http://127.0.0.1:4839/api/tasks/${piece.id}/budget`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "continue" }) })
|
|
103
|
+
p2 = T(piece.id)
|
|
104
|
+
ok(res.ok && p2.status === "active" && p2.limits.minutes > 31 && !p2.budget, "Continue gives it half as much again and wakes the bot")
|
|
105
|
+
ok((await texts(p2.assignee)).some((t) => t.includes("gave you more room")), "which is told to carry on")
|
|
106
|
+
|
|
107
|
+
console.log("\na workstream over its limit stops every bot")
|
|
108
|
+
await fetch(`http://127.0.0.1:4839/api/projects/${r2.projectId}/budget`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ limits: { tokens: 1 } }) })
|
|
109
|
+
sup.store.get(r2.sessions.dev).totals.output += 50
|
|
110
|
+
await sup.pipelines.checkBudgets()
|
|
111
|
+
const run2 = sup.pipelines.run(r2.id)
|
|
112
|
+
ok(run2.paused?.kind === "tokens", "past its token limit the workstream pauses")
|
|
113
|
+
const snap = JSON.parse(JSON.stringify(sup.snapshot?.() ?? {}))
|
|
114
|
+
res = await fetch(`http://127.0.0.1:4839/api/projects/${r2.projectId}/budget`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "continue", limits: { tokens: 10 } }) })
|
|
115
|
+
ok(!res.ok && sup.pipelines.run(r2.id).paused, "it will not resume into a limit it is still over")
|
|
116
|
+
res = await fetch(`http://127.0.0.1:4839/api/projects/${r2.projectId}/budget`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "continue", limits: { tokens: 1_000_000, usd: 20 } }) })
|
|
117
|
+
ok(res.ok && !sup.pipelines.run(r2.id).paused && sup.pipelines.run(r2.id).limits.usd === 20, "raising it resumes the workstream")
|
|
118
|
+
res = await fetch(`http://127.0.0.1:4839/api/tasks/${T(r2.taskIds.build).children[1]}/budget`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ limits: { usd: 2 } }) })
|
|
119
|
+
ok(res.ok && T(T(r2.taskIds.build).children[1]).limits.usd === 2, "a card's limits can be set by hand")
|
|
120
|
+
|
|
121
|
+
console.log("\nrestarting puts a split card back together")
|
|
122
|
+
await sup.pipelines.restart(r.projectId, { stage: "build", fresh: false })
|
|
123
|
+
await sleep(300)
|
|
124
|
+
const again = T(r.taskIds.build)
|
|
125
|
+
ok(again.children?.length === 4 && again.children.every((id) => !kids.some((k) => k.id === id)), "and fans it out again from the handoff")
|
|
126
|
+
|
|
127
|
+
console.log("\npipelines check the new fields")
|
|
128
|
+
const { checkPipelineDoc } = await import("../src/pipelines.mjs")
|
|
129
|
+
const probs = checkPipelineDoc({ stages: [{ id: "a", persona: "dev" }, { id: "b", persona: "dev", after: ["zz"], parallel: 40, limits: { usd: -1 } }] }, new Map([["dev", {}]]))
|
|
130
|
+
ok(probs.some((p) => p.includes('after "zz"')) && probs.some((p) => p.includes("parallel")) && probs.some((p) => p.includes("limits")), "bad after / parallel / limits are reported")
|
|
131
|
+
|
|
132
|
+
sup.stop()
|
|
133
|
+
mock.kill()
|
|
134
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
135
|
+
process.exit(failed ? 1 : 0)
|
package/test/patches.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The work as patches, applied to the real checkouts.
|
|
2
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
3
|
+
import { promisify } from "node:util"
|
|
4
|
+
import { mkdtemp, writeFile, readFile, 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
|
+
|
|
10
|
+
const run = promisify(execFile)
|
|
11
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
12
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
13
|
+
let failed = 0
|
|
14
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
15
|
+
const G = (cwd, ...args) => run("git", args, { cwd }).then((r) => r.stdout)
|
|
16
|
+
const commit = async (dir, msg = "i") => { await G(dir, "add", "-A"); await G(dir, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", msg) }
|
|
17
|
+
const mkrepo = async (dir, files) => {
|
|
18
|
+
await mkdir(dir, { recursive: true })
|
|
19
|
+
await G(dir, "init", "-q", "-b", "main")
|
|
20
|
+
for (const [f, t] of Object.entries(files)) { await mkdir(join(dir, f, ".."), { recursive: true }); await writeFile(join(dir, f), t) }
|
|
21
|
+
await commit(dir)
|
|
22
|
+
return dir
|
|
23
|
+
}
|
|
24
|
+
const base = await mkdtemp(join(tmpdir(), "patch-"))
|
|
25
|
+
const repo = await mkrepo(join(base, "java-parent-services"), { "pom.xml": "<project/>\n", ".gitignore": "src/\n", "Readme.md": "one\ntwo\nthree\n" })
|
|
26
|
+
const svc = await mkrepo(join(repo, "src", "card-service"), { "Card.java": "class Card {\n int a = 1;\n}\n" })
|
|
27
|
+
const spt = await mkrepo(join(base, "elsewhere", "spt"), { "App.java": "class App {}\n" })
|
|
28
|
+
const home = await mkdtemp(join(tmpdir(), "patchh-"))
|
|
29
|
+
process.env.BOT_FARM_WORKTREE_ROOT = join(home, "wt")
|
|
30
|
+
await sleep(600)
|
|
31
|
+
const PORT = 4851
|
|
32
|
+
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}` })
|
|
33
|
+
await sup.start()
|
|
34
|
+
startServer({ supervisor: sup, port: PORT })
|
|
35
|
+
const api = async (path, opts = {}) => {
|
|
36
|
+
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 })
|
|
37
|
+
return { status: res.status, body: await res.json().catch(() => null) }
|
|
38
|
+
}
|
|
39
|
+
const ws = await sup.workspaces.add({ path: repo, name: "jps" })
|
|
40
|
+
await sup.workspaces.addLinked(ws.id, { path: spt })
|
|
41
|
+
const r = await sup.pipelines.start({ pipeline: "story", workspace: ws.id, story: "US1", title: "Patches", services: ["src/card-service", spt] })
|
|
42
|
+
await sleep(400)
|
|
43
|
+
const wt = sup.projects.get(r.projectId).worktree
|
|
44
|
+
|
|
45
|
+
// The "bots'" work: an edit, a new file, a commit on the branch, work in a service and a linked repo.
|
|
46
|
+
await writeFile(join(wt, "Readme.md"), "one\ntwo changed\nthree\n")
|
|
47
|
+
await writeFile(join(wt, "New.java"), "class New {}\n")
|
|
48
|
+
await writeFile(join(wt, "src", "card-service", "Card.java"), "class Card {\n int a = 2;\n}\n")
|
|
49
|
+
await writeFile(join(wt, "spt", "Extra.java"), "class Extra {}\n")
|
|
50
|
+
await writeFile(join(wt, "spt", "App.java"), "class App { int x; }\n")
|
|
51
|
+
await commit(join(wt, "spt"), "bot committed this one")
|
|
52
|
+
|
|
53
|
+
console.log("what would go where")
|
|
54
|
+
let res = await api(`/api/projects/${r.projectId}/patches`)
|
|
55
|
+
const byRel = Object.fromEntries(res.body.repos.map((x) => [x.rel, x]))
|
|
56
|
+
ok(res.status === 200 && res.body.repos.length === 3, "one patch per repo: the parent, the service and the linked repo")
|
|
57
|
+
ok(byRel[""].files.map((f) => f.path).sort().join() === "New.java,Readme.md", "the parent's patch has the edit and the new file — none of BotFarm's own files")
|
|
58
|
+
ok(byRel["src/card-service"].files.length === 1 && byRel["spt"].files.length === 2 && byRel["spt"].commits === 1, "committed work on the branch counts too")
|
|
59
|
+
ok(res.body.repos.every((x) => x.check?.how === "clean"), "and every one would apply cleanly")
|
|
60
|
+
ok((await G(wt, "status", "--porcelain")).includes("?? New.java"), "the worktree's own index is left alone")
|
|
61
|
+
const dl = await fetch(`http://127.0.0.1:${PORT}/api/projects/${r.projectId}/patches/file?rel=src%2Fcard-service`)
|
|
62
|
+
ok(dl.status === 200 && (await dl.text()).includes("+ int a = 2;"), "each patch can be downloaded")
|
|
63
|
+
|
|
64
|
+
console.log("\napplying")
|
|
65
|
+
res = await api(`/api/projects/${r.projectId}/patches/apply`, { method: "POST", body: {} })
|
|
66
|
+
ok(res.status === 200 && res.body.results.filter((x) => x.how === "clean").length === 3, "all three go in cleanly")
|
|
67
|
+
ok((await readFile(join(repo, "Readme.md"), "utf8")).includes("two changed") && await stat(join(repo, "New.java")).then(() => true, () => false), "the real checkout has the edit and the new file")
|
|
68
|
+
ok((await readFile(join(svc, "Card.java"), "utf8")).includes("a = 2") && (await readFile(join(spt, "App.java"), "utf8")).includes("int x"), "and so do the service and the linked repo")
|
|
69
|
+
ok((await G(repo, "log", "--oneline")).trim().split("\n").length === 1 && (await G(spt, "log", "--oneline")).trim().split("\n").length === 1, "nothing is committed for you")
|
|
70
|
+
ok((await G(repo, "diff", "--cached", "--name-only")).trim() === "", "and nothing is staged: review and commit as you like")
|
|
71
|
+
ok(sup.projects.room(r.projectId, { create: false })?.messages.some((m) => m.text.includes("Applied the work")), "the chat says what happened")
|
|
72
|
+
ok((await api(`/api/projects/${r.projectId}/patches`)).body.repos.every((x) => x.check?.how === "present"), "afterwards the preview says it is already in your repos")
|
|
73
|
+
|
|
74
|
+
console.log("\nundo")
|
|
75
|
+
res = await api(`/api/projects/${r.projectId}/patches/undo`, { method: "POST", body: {} })
|
|
76
|
+
ok(res.status === 200 && res.body.every((x) => x.undone), "Undo takes the clean ones back out")
|
|
77
|
+
ok(!(await readFile(join(repo, "Readme.md"), "utf8")).includes("changed") && (await G(spt, "status", "--porcelain")).trim() === "", "and the real repos are as they were")
|
|
78
|
+
|
|
79
|
+
console.log("\nwhen the real repo moved on")
|
|
80
|
+
await writeFile(join(repo, "Readme.md"), "one\ntwo mine\nthree\n")
|
|
81
|
+
await commit(repo, "you changed the same line")
|
|
82
|
+
res = await api(`/api/projects/${r.projectId}/patches`)
|
|
83
|
+
ok(res.body.repos.find((x) => x.rel === "").check.how !== "clean", "the preview says the parent will not apply cleanly")
|
|
84
|
+
res = await api(`/api/projects/${r.projectId}/patches/apply`, { method: "POST", body: { repos: [""] } })
|
|
85
|
+
const parent = res.body.results[0]
|
|
86
|
+
ok(res.body.results.length === 1 && ["conflicts", "3way", "partial"].includes(parent.how), `it is applied as far as it goes (${parent.how})`)
|
|
87
|
+
ok(parent.how !== "conflicts" || (await readFile(join(repo, "Readme.md"), "utf8")).includes("<<<<<<<"), "with conflict markers to resolve, like a merge")
|
|
88
|
+
ok(await stat(join(repo, "New.java")).then(() => true, () => false), "the parts that fit are in")
|
|
89
|
+
|
|
90
|
+
sup.stop()
|
|
91
|
+
mock.kill()
|
|
92
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
93
|
+
process.exit(failed ? 1 : 0)
|
package/test/repos.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Paths people actually type, and a parent repo with services cloned into a
|
|
2
|
+
// gitignored folder — the layout that broke "start a pipeline".
|
|
3
|
+
import { execFile } from "node:child_process"
|
|
4
|
+
import { promisify } from "node:util"
|
|
5
|
+
import { mkdtemp, writeFile, mkdir, readdir } from "node:fs/promises"
|
|
6
|
+
import { tmpdir, homedir } from "node:os"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import * as git from "../src/git.mjs"
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile)
|
|
11
|
+
let failed = 0
|
|
12
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
13
|
+
|
|
14
|
+
const commit = async (dir, msg = "init") => {
|
|
15
|
+
await run("git", ["add", "-A"], { cwd: dir })
|
|
16
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", msg], { cwd: dir })
|
|
17
|
+
}
|
|
18
|
+
const init = async (dir) => {
|
|
19
|
+
await mkdir(dir, { recursive: true })
|
|
20
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: dir })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log("paths as typed")
|
|
24
|
+
ok(git.expandPath("~") === homedir(), "a bare tilde expands to $HOME")
|
|
25
|
+
ok(git.expandPath("~/workspace/x") === join(homedir(), "workspace/x"), "and so does ~/workspace/x")
|
|
26
|
+
ok(git.expandPath(" ~/a/b/ ") === join(homedir(), "a/b"), "surrounding whitespace is ignored")
|
|
27
|
+
ok(git.expandPath("$HOME/x") === join(homedir(), "x"), "environment variables expand")
|
|
28
|
+
ok(git.expandPath("/tmp/abs") === "/tmp/abs", "absolute paths are left alone")
|
|
29
|
+
|
|
30
|
+
console.log("\nsaying what is actually wrong")
|
|
31
|
+
let info = await git.inspect("~/definitely-not-here-" + Math.random().toString(36).slice(2))
|
|
32
|
+
ok(!info.exists && /no such directory/.test(info.reason), `a missing path says so ("${info.reason}")`)
|
|
33
|
+
const plain = await mkdtemp(join(tmpdir(), "plain-"))
|
|
34
|
+
info = await git.inspect(plain)
|
|
35
|
+
ok(info.exists && !info.isRepo && /not a git repository/.test(info.reason), "a real directory that is not a repo says that instead")
|
|
36
|
+
|
|
37
|
+
console.log("\na parent repo with services in a gitignored folder")
|
|
38
|
+
const parent = await mkdtemp(join(tmpdir(), "java-parent-services-"))
|
|
39
|
+
await init(parent)
|
|
40
|
+
await writeFile(join(parent, "pom.xml"), "<project/>\n")
|
|
41
|
+
await writeFile(join(parent, ".gitignore"), "src/\n")
|
|
42
|
+
await commit(parent)
|
|
43
|
+
const services = ["billing", "identity", "notifications"]
|
|
44
|
+
for (const name of services) {
|
|
45
|
+
const dir = join(parent, "src", name)
|
|
46
|
+
await init(dir)
|
|
47
|
+
await writeFile(join(dir, "README.md"), `# ${name}\n`)
|
|
48
|
+
await commit(dir)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
info = await git.inspect(parent)
|
|
52
|
+
ok(info.isRepo, "the parent is recognised as a repository")
|
|
53
|
+
ok(info.nested.length === 3, `its ${info.nested.length} nested repos are found even though git ignores them`)
|
|
54
|
+
ok(info.nested.every((n) => n.rel.startsWith("src/")), "reported by path relative to the parent")
|
|
55
|
+
ok(info.nested[0].branch === "main", "with the branch each one is on")
|
|
56
|
+
|
|
57
|
+
console.log("\none worktree per repo, same layout")
|
|
58
|
+
const set = await git.addWorktreeSet(parent, "feat/sso", {
|
|
59
|
+
path: join(tmpdir(), "wt-" + Date.now()),
|
|
60
|
+
include: ["src/billing", "src/identity"],
|
|
61
|
+
})
|
|
62
|
+
ok(set.branch === "feat/sso", "the parent gets the branch")
|
|
63
|
+
ok(set.children.length === 2 && set.children.every((c) => !c.error), "so do the selected services")
|
|
64
|
+
const listed = await readdir(join(set.path, "src"))
|
|
65
|
+
ok(listed.includes("billing") && listed.includes("identity"), "laid out where the agent expects them")
|
|
66
|
+
ok(!listed.includes("notifications"), "and services you did not select are left out")
|
|
67
|
+
const billingBranch = (await git.status(join(set.path, "src", "billing")))?.branch
|
|
68
|
+
ok(billingBranch === "feat/sso", "each service worktree is on its own copy of the branch")
|
|
69
|
+
const origin = await git.status(join(parent, "src", "billing"))
|
|
70
|
+
ok(origin.branch === "main", "while the original checkout stays where it was")
|
|
71
|
+
|
|
72
|
+
console.log("\nchanges across all of them")
|
|
73
|
+
await writeFile(join(set.path, "pom.xml"), "<project><module/></project>\n")
|
|
74
|
+
await writeFile(join(set.path, "src", "billing", "Invoice.java"), "class Invoice {}\n")
|
|
75
|
+
await writeFile(join(set.path, "src", "identity", "README.md"), "# identity\nchanged\n")
|
|
76
|
+
const changed = await git.changedFiles(set.path)
|
|
77
|
+
ok(changed.some((f) => f.path === "pom.xml"), "the parent's own change is listed")
|
|
78
|
+
ok(changed.some((f) => f.path === "src/billing/Invoice.java" && f.repo === "src/billing"), "a new file in a service is listed, tagged with its repo")
|
|
79
|
+
ok(changed.some((f) => f.path === "src/identity/README.md" && f.removed >= 0), "as is an edit inside another service")
|
|
80
|
+
|
|
81
|
+
console.log("\ndiffing a file that belongs to a nested repo")
|
|
82
|
+
const diff = await git.diffFile(set.path, "src/identity/README.md")
|
|
83
|
+
ok(/changed/.test(diff), "the diff is fetched from the repo that owns the file")
|
|
84
|
+
|
|
85
|
+
console.log("\ncleaning up removes every worktree it made")
|
|
86
|
+
const removed = await git.removeWorktreeSet(set.path, { force: true })
|
|
87
|
+
ok(removed.length === 2, `both service worktrees were removed (${removed.join(", ")})`)
|
|
88
|
+
const left = await git.nestedRepos(parent)
|
|
89
|
+
ok(left.length === 3, "and the original checkouts are untouched")
|
|
90
|
+
|
|
91
|
+
console.log("\na repo from somewhere else, mounted in the worktree")
|
|
92
|
+
{
|
|
93
|
+
const ws = await mkdtemp(join(tmpdir(), "jps-"))
|
|
94
|
+
await init(ws); await writeFile(join(ws, "pom.xml"), "<project/>\n"); await commit(ws)
|
|
95
|
+
const spt = join(await mkdtemp(join(tmpdir(), "elsewhere-")), "spt")
|
|
96
|
+
await init(spt); await writeFile(join(spt, "App.java"), "class App {}\n"); await commit(spt)
|
|
97
|
+
const srcs = git.serviceSources(ws, [spt, { path: spt, as: "spt-two" }, "src/x"])
|
|
98
|
+
ok(srcs[0].external && srcs[0].rel === "spt" && srcs[1].rel === "spt-two" && !srcs[2].external && srcs[2].source === join(ws, "src/x"), "an absolute path mounts under its folder name (or `as`); a relative one is nested")
|
|
99
|
+
let threw = await git.checkService(ws, join(tmpdir(), "nope-" + Date.now())).then(() => null, (e) => e.message)
|
|
100
|
+
ok(/not a git repository/.test(threw ?? ""), "a folder that is not a repo is refused")
|
|
101
|
+
threw = await git.checkService(ws, spt, { taken: ["spt"] }).then(() => null, (e) => e.message)
|
|
102
|
+
ok(/already has something at spt/.test(threw ?? ""), "so is a mount name already taken")
|
|
103
|
+
const wtPath = join(await mkdtemp(join(tmpdir(), "wt-")), "b")
|
|
104
|
+
const set2 = await git.addWorktreeSet(ws, "botfarm/ext", { path: wtPath, include: [spt] })
|
|
105
|
+
const child = set2.children[0]
|
|
106
|
+
ok(!child.error && child.path === join(wtPath, "spt") && child.branch === "botfarm/ext", "it gets a worktree inside the workstream's, on the same branch")
|
|
107
|
+
const st = await git.status(wtPath)
|
|
108
|
+
ok(st.untracked === 0, "and the parent's git status does not list it")
|
|
109
|
+
await writeFile(join(child.path, "New.java"), "class New {}\n")
|
|
110
|
+
ok((await git.changedFiles(wtPath)).some((f) => f.path === "spt/New.java" && f.repo === "spt"), "changes in it show up with the workstream's")
|
|
111
|
+
const snap = await git.snapshot(wtPath)
|
|
112
|
+
ok(Object.keys(snap.repos).includes("spt"), "and in card snapshots")
|
|
113
|
+
const removed2 = await git.removeWorktreeSet(wtPath, { force: true })
|
|
114
|
+
ok(removed2.includes("spt") && (await git.listWorktrees(spt)).length === 1, "removing the workstream removes its worktree of the other repo too")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
118
|
+
process.exit(failed ? 1 : 0)
|