@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,228 @@
|
|
|
1
|
+
// A workspace is a folder: its agents and pipelines live in two YAML files at
|
|
2
|
+
// its root, and every pipeline run is a new workstream with its own branch,
|
|
3
|
+
// team, chat and board.
|
|
4
|
+
import { spawn as spawnProc, execFile } from "node:child_process"
|
|
5
|
+
import { promisify } from "node:util"
|
|
6
|
+
import { mkdtemp, writeFile, readFile, mkdir } 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
|
+
import { listDirs } from "../src/workspaces.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
|
+
const root = await mkdtemp(join(tmpdir(), "wsp-"))
|
|
20
|
+
const repo = join(root, "java-parent-services")
|
|
21
|
+
await mkdir(repo)
|
|
22
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: repo })
|
|
23
|
+
await writeFile(join(repo, "pom.xml"), "<x/>")
|
|
24
|
+
await run("git", ["add", "."], { cwd: repo })
|
|
25
|
+
await run("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"], { cwd: repo })
|
|
26
|
+
process.env.BOT_FARM_WORKTREE_ROOT = join(root, "worktrees")
|
|
27
|
+
// The checkout's own opencode setup, deliberately untracked — and, like a
|
|
28
|
+
// real one, kept elsewhere and linked in.
|
|
29
|
+
const shared = join(root, "shared-opencode")
|
|
30
|
+
await mkdir(join(shared, ".opencode", "agent"), { recursive: true })
|
|
31
|
+
await writeFile(join(shared, "opencode.json"), `{
|
|
32
|
+
// comments are allowed
|
|
33
|
+
"model": "amazon-bedrock/anthropic.claude-sonnet-5",
|
|
34
|
+
"default_agent": "locked-down",
|
|
35
|
+
"agent": {
|
|
36
|
+
"build": { "model": "amazon-bedrock/anthropic.claude-sonnet-5", "variant": "medium" },
|
|
37
|
+
"locked-down": { "tools": { "*": false } },
|
|
38
|
+
},
|
|
39
|
+
"mcp": { "jira": { "type": "remote", "url": "https://jira.example/mcp" }, "off-one": { "type": "remote", "url": "x", "enabled": false }, "local-dev": { "type": "remote", "url": "http://localhost:3333/sse/dev" } },
|
|
40
|
+
}`)
|
|
41
|
+
await writeFile(join(shared, ".opencode", "agent", "java.md"), "You know our Java conventions.")
|
|
42
|
+
await writeFile(join(shared, ".opencode", "opencode.json"), JSON.stringify({ mcp: { sonar: { type: "local", command: ["sonar"] } } }))
|
|
43
|
+
const { symlink: ln } = await import("node:fs/promises")
|
|
44
|
+
await ln(join(shared, "opencode.json"), join(repo, "opencode.json"))
|
|
45
|
+
await ln(join(shared, ".opencode"), join(repo, ".opencode"))
|
|
46
|
+
const home = await mkdtemp(join(tmpdir(), "wsph-"))
|
|
47
|
+
await sleep(600)
|
|
48
|
+
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:4827", defaultWorkspace: repo })
|
|
49
|
+
await sup.start()
|
|
50
|
+
startServer({ supervisor: sup, port: 4827 })
|
|
51
|
+
const api = async (p, opts = {}) => {
|
|
52
|
+
const res = await fetch("http://127.0.0.1:4827" + p, { method: opts.method ?? "GET", headers: opts.body ? { "content-type": "application/json" } : undefined, body: opts.body ? JSON.stringify(opts.body) : undefined })
|
|
53
|
+
return { status: res.status, body: await res.json() }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log("the default folder is the first workspace")
|
|
57
|
+
const [ws] = sup.workspaces.list()
|
|
58
|
+
ok(ws?.path === repo && ws.id === "java-parent-services", "java-parent-services is opened on first start")
|
|
59
|
+
const agentsText = await readFile(join(repo, "botfarm", "product.agent.botfarm.yml"), "utf8")
|
|
60
|
+
const pipesText = await readFile(join(repo, "botfarm", "story.pipeline.botfarm.yml"), "utf8")
|
|
61
|
+
ok(agentsText.includes("id: product") && agentsText.includes("prompt:"), "botfarm/ gets one file per built-in agent")
|
|
62
|
+
ok(pipesText.includes("id: story") && pipesText.includes("stages:"), "and per built-in pipeline")
|
|
63
|
+
let lib = (await api(`/api/workspaces/${ws.id}/library`)).body
|
|
64
|
+
ok(lib.agents.length === 4 && lib.pipelines[0].stages.length === 5 && !lib.problems.length, "and they read back cleanly")
|
|
65
|
+
|
|
66
|
+
console.log("\nediting definitions edits the files")
|
|
67
|
+
const story = lib.pipelines[0]
|
|
68
|
+
await api(`/api/workspaces/${ws.id}/pipelines/do-user-story`, { method: "PUT", body: { doc: { ...story, id: "do-user-story", title: "DoUserStory", stages: story.stages.slice(0, 2) } } })
|
|
69
|
+
lib = (await api(`/api/workspaces/${ws.id}/library`)).body
|
|
70
|
+
ok(lib.pipelines.some((p) => p.id === "do-user-story" && p.stages.length === 2), "a new pipeline can be saved next to the old one")
|
|
71
|
+
ok(!lib.problems.length, "and the file still parses after a rewrite")
|
|
72
|
+
const edited = { ...lib.pipelines.find((p) => p.id === "do-user-story") }
|
|
73
|
+
edited.stages[1].receives = ["analyse"]
|
|
74
|
+
edited.stages[1].prompt = "Build it.\n\n{{#handoffs.analyse}}{{summary}}{{/handoffs.analyse}}\n"
|
|
75
|
+
await api(`/api/workspaces/${ws.id}/pipelines/do-user-story`, { method: "PUT", body: { doc: edited } })
|
|
76
|
+
await api(`/api/workspaces/${ws.id}/agents/security`, { method: "PUT", body: { doc: { id: "security", title: "Security Bot", role: "security", prompt: "You review for injection.\n", tools: ["read", "grep"] } } })
|
|
77
|
+
lib = (await api(`/api/workspaces/${ws.id}/library`)).body
|
|
78
|
+
ok(lib.agents.some((a) => a.id === "security" && a.tools.join() === "read,grep"), "agents too")
|
|
79
|
+
ok(!lib.problems.length, "nothing broke after three rewrites")
|
|
80
|
+
const raw = (await api(`/api/workspaces/${ws.id}/file/pipelines?entry=do-user-story`)).body.text
|
|
81
|
+
ok(raw.includes("title: DoUserStory"), "the YAML is readable as a file")
|
|
82
|
+
ok((await api(`/api/workspaces/${ws.id}/file/pipelines?entry=do-user-story`, { method: "PUT", body: { text: "stages: [\n" } })).status >= 400, "a file that does not parse is refused")
|
|
83
|
+
await writeFile(join(repo, "botfarm", "do-user-story.pipeline.botfarm.yml"), raw.replace("title: DoUserStory", "title: DoUserStory v2"))
|
|
84
|
+
lib = (await api(`/api/workspaces/${ws.id}/library`)).body
|
|
85
|
+
ok(lib.pipelines.some((p) => p.title === "DoUserStory v2"), "a hand edit is picked up without a reload")
|
|
86
|
+
|
|
87
|
+
console.log("\nevery run is a new workstream")
|
|
88
|
+
const a = (await api(`/api/workspaces/${ws.id}/workstreams`, { method: "POST", body: { pipeline: "do-user-story", story: "DE238: DELETE eligibilities should 409 when a FRID exists", title: "DE238 FRID guard" } })).body
|
|
89
|
+
const b = (await api(`/api/workspaces/${ws.id}/workstreams`, { method: "POST", body: { pipeline: "do-user-story", story: "US1412: show eligibility end dates", title: "US1412 end dates" } })).body
|
|
90
|
+
ok(a.projectId && b.projectId && a.projectId !== b.projectId, "two runs of one pipeline are two workstreams")
|
|
91
|
+
const pa = sup.projects.get(a.projectId)
|
|
92
|
+
const pb = sup.projects.get(b.projectId)
|
|
93
|
+
ok(pa.branch === "botfarm/de238-frid-guard" && pb.branch === "botfarm/us1412-end-dates", `each on its own branch (${pa.branch}, ${pb.branch})`)
|
|
94
|
+
ok(pa.roomId && pb.roomId && pa.roomId !== pb.roomId, "with its own chat")
|
|
95
|
+
ok(sup.projects.sessions(pa.id).length === 2 && sup.projects.sessions(pb.id).length === 2, "and its own team")
|
|
96
|
+
ok(new Set([...sup.projects.sessions(pa.id), ...sup.projects.sessions(pb.id)].map((s) => s.directory)).size === 2, "working in two separate worktrees")
|
|
97
|
+
ok(sup.tasks.all({ projectId: pa.id }).length === 2 && sup.tasks.all({ projectId: pb.id }).length === 2, "and its own board")
|
|
98
|
+
ok(pa.workspaceId === ws.id && pa.pipeline === "do-user-story" && pa.story.includes("FRID"), "remembering its workspace, pipeline and story")
|
|
99
|
+
const clash = await api(`/api/workspaces/${ws.id}/workstreams`, { method: "POST", body: { pipeline: "do-user-story", story: "again", branch: pa.branch } })
|
|
100
|
+
ok(clash.status === 500 && /already on branch/.test(clash.body.error), "a second workstream on the same branch is refused")
|
|
101
|
+
const again = (await api(`/api/workspaces/${ws.id}/workstreams`, { method: "POST", body: { pipeline: "do-user-story", story: "DE238 again", title: "DE238 FRID guard", branch: "botfarm/de238-take-2" } })).body
|
|
102
|
+
ok(again.projectId && again.projectId !== a.projectId, "the same name twice still makes a new workstream")
|
|
103
|
+
|
|
104
|
+
console.log("\nbots get the checkout's opencode setup and the botfarm tools")
|
|
105
|
+
const wt = sup.projects.sessions(pa.id)[0].directory
|
|
106
|
+
const rootCfg = await readFile(join(wt, "opencode.json"), "utf8")
|
|
107
|
+
ok(rootCfg.includes("jira"), "the untracked opencode.json (with its MCP servers) reaches the worktree")
|
|
108
|
+
ok((await readFile(join(wt, ".opencode", "agent", "java.md"), "utf8")).includes("Java"), "so is .opencode/ (agents, commands)")
|
|
109
|
+
const dotCfg = JSON.parse(await readFile(join(wt, ".opencode", "opencode.json"), "utf8"))
|
|
110
|
+
ok(dotCfg.mcp?.sonar && dotCfg.mcp?.botfarm?.url?.includes("/mcp/"), "the botfarm tools are added next to the checkout's own MCP servers")
|
|
111
|
+
const { lstat: lst } = await import("node:fs/promises")
|
|
112
|
+
ok(!(await lst(join(wt, ".opencode"))).isSymbolicLink() && (await lst(join(wt, ".opencode", "agent"))).isSymbolicLink(), "a linked .opencode becomes a real folder whose contents link back")
|
|
113
|
+
ok(!(await readFile(join(shared, ".opencode", "opencode.json"), "utf8")).includes("botfarm\"\: {"), "so the shared config is never written to")
|
|
114
|
+
ok(dotCfg.mcp?.["local-dev"]?.headers?.["X-Repo-Root"] === wt && new URL(dotCfg.mcp["local-dev"].url).searchParams.get("root") === wt && dotCfg.mcp["local-dev"].url.startsWith("http://localhost:3333/sse/dev?"), "local MCP servers are told the worktree is their checkout")
|
|
115
|
+
ok(!dotCfg.mcp?.jira?.headers, "remote ones elsewhere are left alone")
|
|
116
|
+
const created = await (await fetch("http://127.0.0.1:4096/__created")).json()
|
|
117
|
+
const mine = created.filter((b) => b.location?.directory === wt)
|
|
118
|
+
ok(mine.length && mine.every((b) => b.engine === "v1" && b.model?.providerID === "amazon-bedrock" && b.model?.modelID === "anthropic.claude-sonnet-5" && !b.model?.variant && /^botfarm-/.test(b.agent)), "bots run on opencode's instance engine (the TUI's), on the workspace's model, each as its own botfarm-* agent")
|
|
119
|
+
const prompts = await (await fetch("http://127.0.0.1:4096/__prompts")).json()
|
|
120
|
+
const firstTurn = prompts.find((x) => mine.length && sup.projects.sessions(pa.id).some((s) => s.id === x.id))
|
|
121
|
+
ok(firstTurn && /^botfarm-/.test(firstTurn.agent) && firstTurn.model?.modelID === "anthropic.claude-sonnet-5", "and every turn is sent with its agent and model")
|
|
122
|
+
const wtCfg = JSON.parse(await readFile(join(wt, ".opencode", "opencode.json"), "utf8"))
|
|
123
|
+
ok(wtCfg.agent?.["botfarm-product"]?.model === "amazon-bedrock/anthropic.claude-sonnet-5" && wtCfg.agent["botfarm-product"].tools?.["*"] === false && wtCfg.agent["botfarm-product"].tools?.read === true && wtCfg.agent["botfarm-product"].tools?.["botfarm*"] === true, "the product agent is read-only but keeps the botfarm tools")
|
|
124
|
+
ok(wtCfg.agent?.["botfarm-dev"]?.model === "amazon-bedrock/anthropic.claude-sonnet-5" && !wtCfg.agent["botfarm-dev"].variant && !wtCfg.agent["botfarm-dev"].tools, "built on the workspace's build agent (not its locked-down default), without a reasoning level the model may not offer")
|
|
125
|
+
const reg = await (await fetch("http://127.0.0.1:4096/__registered")).json()
|
|
126
|
+
ok(sup.projects.sessions(pa.id).every((s) => /^botfarm-/.test(s.snapshot().agentName)), "and each bot remembers what it runs on")
|
|
127
|
+
const team = sup.projects.sessions(pa.id)
|
|
128
|
+
ok(team.every((s) => s.mcpToken && dotCfg.mcp.botfarm.url.endsWith(s.mcpToken)), "before the session starts, with the token the whole worktree shares")
|
|
129
|
+
ok(team.every((s) => s.personaTitle), "each bot knows its kind (" + team.map((s) => s.personaTitle).join(", ") + ")")
|
|
130
|
+
await sup.refreshMcp(team)
|
|
131
|
+
ok(team[0].snapshot().mcp?.total >= 1, "and its MCP servers are counted")
|
|
132
|
+
ok(team.some((s) => s.snapshot().invocations === 1), "the bot that was handed a card has one run")
|
|
133
|
+
|
|
134
|
+
console.log("\nwhat opencode waits on reaches you")
|
|
135
|
+
const asker = sup.projects.sessions(pa.id)[0]
|
|
136
|
+
const post = (u, b) => fetch("http://127.0.0.1:4096" + u, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }).then((r) => r.json())
|
|
137
|
+
await post("/__ask", { sessionID: asker.id, kind: "permission", request: { permission: "bash", patterns: ["mvn test"], metadata: { command: "mvn -pl eligibility test" }, always: ["mvn *"] } })
|
|
138
|
+
await post("/__ask", { sessionID: asker.id, kind: "question", request: { questions: [{ question: "Which module?", header: "Module", options: [{ label: "eligibility", description: "" }, { label: "customer", description: "" }] }] } })
|
|
139
|
+
sup.dirty.add(asker.id)
|
|
140
|
+
await sup.flushDirty()
|
|
141
|
+
let snap = sup.store.snapshot().sessions.find((x) => x.id === asker.id)
|
|
142
|
+
ok(snap.status === "waiting" && snap.permissions[0]?.action === "bash" && snap.permissions[0].metadata.command.includes("mvn"), "a pending permission shows on the bot, with the command")
|
|
143
|
+
ok(snap.questions[0]?.questions[0]?.options.length === 2, "and so does a question from opencode's own question tool")
|
|
144
|
+
await api(`/api/sessions/${asker.id}/permission`, { method: "POST", body: { requestId: snap.permissions[0].id, decision: "once" } })
|
|
145
|
+
await api(`/api/sessions/${asker.id}/question`, { method: "POST", body: { requestId: snap.questions[0].id, answers: [["eligibility"]] } })
|
|
146
|
+
const replies = await (await fetch("http://127.0.0.1:4096/__replies")).json()
|
|
147
|
+
ok(replies.some((r) => r.kind === "permission" && r.reply === "once") && replies.some((r) => r.kind === "question" && r.answers?.[0]?.[0] === "eligibility"), "answering goes back to opencode")
|
|
148
|
+
sup.dirty.add(asker.id)
|
|
149
|
+
await sup.flushDirty()
|
|
150
|
+
snap = sup.store.snapshot().sessions.find((x) => x.id === asker.id)
|
|
151
|
+
ok(!snap.permissions.length && !snap.questions.length && snap.status !== "waiting", "and the bot carries on")
|
|
152
|
+
|
|
153
|
+
console.log("\nprices")
|
|
154
|
+
const s0 = team[0]
|
|
155
|
+
Object.assign(s0.totals, { input: 1_000_000, output: 100_000, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }); for (const t of sup.timers) clearInterval(t)
|
|
156
|
+
s0.model = "amazon-bedrock/us.anthropic.claude-opus-9-20300101-v1:0"
|
|
157
|
+
ok(sup.store.totals().unknownModels.includes("claude-opus-9"), "an unknown model is reported by name")
|
|
158
|
+
await api("/api/pricing", { method: "PUT", body: { model: s0.model, input: 10, output: 50 } })
|
|
159
|
+
ok(Math.abs(sup.store.snapshot().sessions.find((x) => x.id === s0.id).cost.usd - 15) < 1e-6, "setting its price prices that bot (1M in × $10 + 100k out × $50)")
|
|
160
|
+
ok(JSON.parse(await readFile(join(home, "config.json"), "utf8")).pricing["claude-opus-9"].output === 50, "and it is kept in config.json")
|
|
161
|
+
|
|
162
|
+
console.log("\nthe run keeps the definition it started with")
|
|
163
|
+
const runA = sup.pipelines.run(a.id)
|
|
164
|
+
await api(`/api/workspaces/${ws.id}/pipelines/do-user-story`, { method: "DELETE" })
|
|
165
|
+
ok(sup.pipelines.promptFor(sup.tasks.get(runA.taskIds.build)).includes("Build it."), "deleting the pipeline does not break a workstream using it")
|
|
166
|
+
|
|
167
|
+
console.log("\nbots and cards can be added by hand")
|
|
168
|
+
const bot = (await api(`/api/projects/${pa.id}/bots`, { method: "POST", body: { agent: "security" } })).body
|
|
169
|
+
ok(bot.id && sup.store.get(bot.id).project === pa.id, "a bot joins the workstream")
|
|
170
|
+
ok(sup.rooms.rooms.get(pa.roomId).members.has(bot.id), "and its chat")
|
|
171
|
+
ok(sup.store.get(bot.id).directory === sup.projects.sessions(pa.id)[0].directory, "in the same worktree")
|
|
172
|
+
|
|
173
|
+
console.log("\narchiving")
|
|
174
|
+
await api(`/api/projects/${pb.id}/archive`, { method: "POST", body: {} })
|
|
175
|
+
ok(sup.projects.get(pb.id).status === "archived", "a workstream can be put away")
|
|
176
|
+
ok(sup.tasks.all({ projectId: pb.id }).every((t) => ["done", "cancelled"].includes(t.status)), "its open cards are cancelled")
|
|
177
|
+
|
|
178
|
+
console.log("\nunedited built-ins are moved to the current ones")
|
|
179
|
+
const legacyRepo = join(root, "legacy")
|
|
180
|
+
await mkdir(legacyRepo)
|
|
181
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: legacyRepo })
|
|
182
|
+
const { LEGACY } = await import("../src/legacy-defaults.mjs")
|
|
183
|
+
const YAML = await import("../src/yaml.mjs")
|
|
184
|
+
const oldProduct = YAML.parse(LEGACY.agents.product[0])
|
|
185
|
+
const oldAnalyse = YAML.parse(LEGACY.stages["story/analyse"][0]).stages[0]
|
|
186
|
+
const qaEdited = { id: "qa", title: "QA", role: "qa", prompt: "My own QA prompt.\n" }
|
|
187
|
+
await writeFile(join(legacyRepo, "botfarm-agents.yml"), YAML.stringify({ agents: [oldProduct, qaEdited] }))
|
|
188
|
+
await writeFile(join(legacyRepo, "botfarm-pipeline.yml"), YAML.stringify({ pipelines: [{ id: "story", title: "Mine", stages: [oldAnalyse] }] }))
|
|
189
|
+
const lw = await sup.workspaces.add({ path: legacyRepo })
|
|
190
|
+
const ll = await sup.workspaces.summary(lw.id)
|
|
191
|
+
const p2 = ll.agents.find((a) => a.id === "product")
|
|
192
|
+
ok(p2.prompt.includes("Work quickly") && p2.tools?.includes("read") && !p2.tools.includes("bash"), "the old product agent becomes the quick, read-only one")
|
|
193
|
+
ok(ll.agents.find((a) => a.id === "qa").prompt === "My own QA prompt.\n", "an agent you wrote is left alone")
|
|
194
|
+
ok(ll.pipelines[0].title === "Mine" && ll.pipelines[0].stages[0].title === "Polish the acceptance criteria", "the old analyse prompt is updated, your pipeline name kept")
|
|
195
|
+
|
|
196
|
+
console.log("\neach workstream keeps a manifest of its bots")
|
|
197
|
+
const YAMLm = await import("../src/yaml.mjs")
|
|
198
|
+
const manifestFile = join(repo, ".botfarm", "workstreams", `${pa.id}.yml`)
|
|
199
|
+
let man = YAMLm.parse(await readFile(manifestFile, "utf8"))
|
|
200
|
+
const teamIds = sup.projects.sessions(pa.id).map((s) => s.id).sort()
|
|
201
|
+
ok(man.bots.map((b) => b.session).sort().join() === teamIds.join(), `${pa.id}.yml lists every bot's opencode session`)
|
|
202
|
+
ok(man.bots.every((b) => b.persona && b.handle) && man.branch === pa.branch && man.story.includes("FRID"), "with who it is, the branch and the story")
|
|
203
|
+
ok((await readFile(join(repo, ".git", "info", "exclude"), "utf8")).includes(".botfarm/"), "and .botfarm/ is kept out of git")
|
|
204
|
+
await sup.release(bot.id)
|
|
205
|
+
await sup.saveManifest(pa.id)
|
|
206
|
+
man = YAMLm.parse(await readFile(manifestFile, "utf8"))
|
|
207
|
+
ok(man.previous?.some((b) => b.session === bot.id && b.replaced) && !man.bots.some((b) => b.session === bot.id), "a bot that leaves moves to previous, still findable")
|
|
208
|
+
|
|
209
|
+
console.log("\nlosing botfarm's own state does not lose the team")
|
|
210
|
+
const home2 = await mkdtemp(join(tmpdir(), "wsph2-"))
|
|
211
|
+
const sup2 = new Supervisor({ url: "http://127.0.0.1:4096", statePath: join(home2, "m.json"), registryPath: join(home2, "r.json"), mcpBase: "http://127.0.0.1:4828", defaultWorkspace: repo })
|
|
212
|
+
await sup2.start()
|
|
213
|
+
const back = sup2.projects.get(pa.id)
|
|
214
|
+
ok(back && back.recovered && back.branch === pa.branch, "the workstream is recreated from its manifest")
|
|
215
|
+
const backTeam = sup2.projects.sessions(pa.id)
|
|
216
|
+
ok(backTeam.map((s) => s.id).sort().join() === man.bots.map((b) => b.session).sort().join(), "with the same bots, found by session id")
|
|
217
|
+
ok(backTeam.every((s) => s.persona), "who know what they are")
|
|
218
|
+
sup2.stop()
|
|
219
|
+
|
|
220
|
+
console.log("\nthe folder browser")
|
|
221
|
+
const listing = await listDirs(root)
|
|
222
|
+
ok(listing.dirs.some((d) => d.name === "java-parent-services" && d.isRepo), "lists folders and marks repositories")
|
|
223
|
+
ok(listing.parent && !listing.dirs.some((d) => d.name.startsWith(".")), "with a way up and no hidden folders")
|
|
224
|
+
|
|
225
|
+
sup.stop()
|
|
226
|
+
mock.kill()
|
|
227
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
228
|
+
process.exit(failed ? 1 : 0)
|
|
File without changes
|