@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/src/tasks.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Tasks are the primitive. A pipeline is a recipe that emits them.
|
|
2
|
+
//
|
|
3
|
+
// That is the whole architectural bet here. If stages were their own thing,
|
|
4
|
+
// the QA bot finding an unrelated defect would have nowhere to put it, and a
|
|
5
|
+
// "pipeline" would only ever be a straight line. Because every stage is just
|
|
6
|
+
// a task with dependencies, the board can hold pipeline work and ad-hoc work
|
|
7
|
+
// side by side, work can fan out in parallel, and a late defect is simply a
|
|
8
|
+
// new task that blocks an existing one.
|
|
9
|
+
|
|
10
|
+
export const COLUMNS = ["backlog", "blocked", "waiting", "queued", "active", "review", "done", "cancelled"]
|
|
11
|
+
|
|
12
|
+
let seq = 0
|
|
13
|
+
const newId = () => "tsk_" + Date.now().toString(36) + (++seq).toString(36)
|
|
14
|
+
|
|
15
|
+
export class Tasks {
|
|
16
|
+
constructor(db, { onChange = () => {} } = {}) {
|
|
17
|
+
this.db = db
|
|
18
|
+
this.onChange = onChange
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ownership has three shapes, and they are not the same thing:
|
|
23
|
+
*
|
|
24
|
+
* session a named session does this
|
|
25
|
+
* role whichever bot plays this part picks it up — so a dev task does
|
|
26
|
+
* not die because one particular dev session was closed
|
|
27
|
+
* human you do this, or you answer this; it is never dispatched
|
|
28
|
+
*/
|
|
29
|
+
create({
|
|
30
|
+
title,
|
|
31
|
+
brief = "",
|
|
32
|
+
projectId = null,
|
|
33
|
+
runId = null,
|
|
34
|
+
stage = null,
|
|
35
|
+
persona = null,
|
|
36
|
+
assignee = null,
|
|
37
|
+
role = null,
|
|
38
|
+
owner = null,
|
|
39
|
+
kind = "work",
|
|
40
|
+
options = [],
|
|
41
|
+
askedBy = null,
|
|
42
|
+
dependsOn = [],
|
|
43
|
+
createdBy = "operator",
|
|
44
|
+
acceptance = [],
|
|
45
|
+
order = Date.now(),
|
|
46
|
+
}) {
|
|
47
|
+
if (!title?.trim()) throw new TaskError("bad_request", "A task needs a title.")
|
|
48
|
+
if (owner === "human" || kind === "question") owner = "human"
|
|
49
|
+
else if (assignee) owner = "session"
|
|
50
|
+
else if (role) owner = "role"
|
|
51
|
+
else owner = owner ?? "unassigned"
|
|
52
|
+
const task = {
|
|
53
|
+
id: newId(),
|
|
54
|
+
title: title.trim(),
|
|
55
|
+
brief,
|
|
56
|
+
projectId,
|
|
57
|
+
runId,
|
|
58
|
+
stage,
|
|
59
|
+
persona,
|
|
60
|
+
assignee, // session id, when one session in particular owns it
|
|
61
|
+
role, // or a part, played by whoever is playing it
|
|
62
|
+
owner, // session | role | human | unassigned
|
|
63
|
+
kind, // work | question
|
|
64
|
+
options, // for a question: the answers offered
|
|
65
|
+
askedBy, // session that asked
|
|
66
|
+
answer: null,
|
|
67
|
+
dependsOn,
|
|
68
|
+
acceptance,
|
|
69
|
+
createdBy,
|
|
70
|
+
status: dependsOn.length ? "blocked" : owner === "human" ? "waiting" : assignee || role ? "queued" : "backlog",
|
|
71
|
+
queuedAt: dependsOn.length ? null : Date.now(),
|
|
72
|
+
handoff: null,
|
|
73
|
+
notes: [],
|
|
74
|
+
files: [],
|
|
75
|
+
created: Date.now(),
|
|
76
|
+
updated: Date.now(),
|
|
77
|
+
order,
|
|
78
|
+
}
|
|
79
|
+
this.db.put("tasks", task)
|
|
80
|
+
this.onChange()
|
|
81
|
+
return task
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get(id) {
|
|
85
|
+
return this.db.get("tasks", id)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
all({ projectId, runId, assignee, status, owner, role } = {}) {
|
|
89
|
+
return this.db
|
|
90
|
+
.all("tasks")
|
|
91
|
+
.filter((t) => (!projectId || t.projectId === projectId) && (!runId || t.runId === runId))
|
|
92
|
+
.filter((t) => (!assignee || t.assignee === assignee) && (!status || t.status === status))
|
|
93
|
+
.filter((t) => (!owner || t.owner === owner) && (!role || t.role === role))
|
|
94
|
+
.sort((a, b) => a.order - b.order)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Everything waiting on the operator: questions first, they block someone. */
|
|
98
|
+
forHuman() {
|
|
99
|
+
return this.all({ owner: "human" })
|
|
100
|
+
.filter((t) => !["done", "cancelled"].includes(t.status))
|
|
101
|
+
.sort((a, b) => (a.kind === "question" ? -1 : 1) - (b.kind === "question" ? -1 : 1) || a.created - b.created)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
save(task) {
|
|
105
|
+
task.updated = Date.now()
|
|
106
|
+
this.db.put("tasks", task)
|
|
107
|
+
this.onChange()
|
|
108
|
+
return task
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
update(id, patch) {
|
|
112
|
+
const task = this.get(id)
|
|
113
|
+
if (!task) throw new TaskError("unknown_task", `No task ${id}.`)
|
|
114
|
+
if (patch.status && !COLUMNS.includes(patch.status)) {
|
|
115
|
+
throw new TaskError("bad_status", `status must be one of: ${COLUMNS.join(", ")}`)
|
|
116
|
+
}
|
|
117
|
+
Object.assign(task, patch)
|
|
118
|
+
return this.save(task)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
note(id, text, by = "operator") {
|
|
122
|
+
const task = this.get(id)
|
|
123
|
+
if (!task) return null
|
|
124
|
+
task.notes.push({ at: Date.now(), by, text: String(text).slice(0, 2000) })
|
|
125
|
+
if (task.notes.length > 50) task.notes.shift()
|
|
126
|
+
return this.save(task)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A task is ready when every dependency has landed in `done`. */
|
|
130
|
+
ready(task) {
|
|
131
|
+
return task.dependsOn.every((id) => this.get(id)?.status === "done")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Move anything whose dependencies just cleared into `queued`. Queued is
|
|
136
|
+
* deliberately not `active`: the assignee is told about it when it next goes
|
|
137
|
+
* idle, so a running agent is never interrupted mid-thought.
|
|
138
|
+
*/
|
|
139
|
+
unblock() {
|
|
140
|
+
const freed = []
|
|
141
|
+
for (const task of this.all()) {
|
|
142
|
+
if (task.status !== "blocked") continue
|
|
143
|
+
if (!this.ready(task)) continue
|
|
144
|
+
task.status = task.owner === "human" ? "waiting" : task.assignee || task.role ? "queued" : "backlog"
|
|
145
|
+
task.queuedAt = Date.now()
|
|
146
|
+
this.save(task)
|
|
147
|
+
freed.push(task)
|
|
148
|
+
}
|
|
149
|
+
return freed
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The next thing this session should pick up: work addressed to it by name,
|
|
154
|
+
* or work addressed to the part it plays that nobody has taken yet.
|
|
155
|
+
*/
|
|
156
|
+
nextFor(sessionId, { role = null, projectId = null, alive = null, now = Date.now() } = {}) {
|
|
157
|
+
const mine = this.all({ assignee: sessionId })
|
|
158
|
+
// Work addressed to a part stays in its workstream: another workstream's
|
|
159
|
+
// dev bot must not pick up this one's cards. A card sent back for rework
|
|
160
|
+
// prefers the bot that did it first: others take it only once that bot is
|
|
161
|
+
// gone or has had its chance (preferUntil).
|
|
162
|
+
const mayTake = (t) => !t.prefer || t.prefer === sessionId || now > (t.preferUntil ?? 0) || (alive && !alive(t.prefer))
|
|
163
|
+
const byRole = role
|
|
164
|
+
? this.all({ role }).filter((t) => !t.assignee && t.owner === "role" && (!projectId || !t.projectId || t.projectId === projectId) && mayTake(t))
|
|
165
|
+
: []
|
|
166
|
+
return [...mine, ...byRole]
|
|
167
|
+
.filter((t) => t.status === "queued" && this.ready(t))
|
|
168
|
+
.sort((a, b) => a.order - b.order)[0]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Kanban projection: columns with their cards, newest activity first. */
|
|
172
|
+
board({ projectId, runId } = {}) {
|
|
173
|
+
const tasks = this.all({ projectId, runId })
|
|
174
|
+
return COLUMNS.map((status) => ({
|
|
175
|
+
status,
|
|
176
|
+
tasks: tasks.filter((t) => t.status === status),
|
|
177
|
+
}))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
remove(id) {
|
|
181
|
+
this.db.delete("tasks", id)
|
|
182
|
+
this.onChange()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export class TaskError extends Error {
|
|
187
|
+
constructor(code, message) {
|
|
188
|
+
super(message)
|
|
189
|
+
this.code = code
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/template.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Mustache subset, dependency-free. Supports {{var}} (HTML-safe is pointless
|
|
2
|
+
// here, so nothing is escaped), {{#section}}...{{/section}} for truthy values,
|
|
3
|
+
// lists and objects, {{^inverted}}, dotted paths, and {{.}} inside a list of
|
|
4
|
+
// scalars. Comments are {{! like this }}.
|
|
5
|
+
//
|
|
6
|
+
// This is how a pipeline step decides which earlier handoffs it receives: the
|
|
7
|
+
// template names them, so dropping a stage's output from a later prompt is an
|
|
8
|
+
// edit to one line of YAML rather than a code change.
|
|
9
|
+
|
|
10
|
+
const TAG = /\{\{([#^/!&]?)\s*([^}]*?)\s*\}\}/g
|
|
11
|
+
|
|
12
|
+
export function render(template, context = {}) {
|
|
13
|
+
const { out } = section(String(template ?? ""), 0, [context], true)
|
|
14
|
+
return out
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function section(tpl, from, stack, emit) {
|
|
18
|
+
let out = ""
|
|
19
|
+
let i = from
|
|
20
|
+
TAG.lastIndex = from
|
|
21
|
+
let m
|
|
22
|
+
while ((m = TAG.exec(tpl))) {
|
|
23
|
+
if (emit) out += tpl.slice(i, m.index)
|
|
24
|
+
i = TAG.lastIndex
|
|
25
|
+
const [, sigil, name] = m
|
|
26
|
+
|
|
27
|
+
if (sigil === "!") continue
|
|
28
|
+
if (sigil === "/") return { out, next: i, closed: name }
|
|
29
|
+
|
|
30
|
+
if (sigil === "#" || sigil === "^") {
|
|
31
|
+
const value = lookup(stack, name)
|
|
32
|
+
const truthy = Array.isArray(value) ? value.length > 0 : !!value
|
|
33
|
+
const wanted = sigil === "#" ? truthy : !truthy
|
|
34
|
+
|
|
35
|
+
if (sigil === "#" && Array.isArray(value) && value.length) {
|
|
36
|
+
// Re-render the body once per item, then skip past it.
|
|
37
|
+
let last = null
|
|
38
|
+
for (const item of value) {
|
|
39
|
+
last = section(tpl, i, [...stack, item], true)
|
|
40
|
+
out += last.out
|
|
41
|
+
}
|
|
42
|
+
i = last.next
|
|
43
|
+
} else {
|
|
44
|
+
const body = section(tpl, i, sigil === "#" && isObject(value) ? [...stack, value] : stack, emit && wanted)
|
|
45
|
+
out += body.out
|
|
46
|
+
i = body.next
|
|
47
|
+
}
|
|
48
|
+
TAG.lastIndex = i
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (emit) {
|
|
53
|
+
const v = lookup(stack, name)
|
|
54
|
+
out += v === null || v === undefined ? "" : Array.isArray(v) ? v.join(", ") : String(v)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (emit) out += tpl.slice(i)
|
|
58
|
+
return { out, next: tpl.length }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function lookup(stack, name) {
|
|
62
|
+
if (name === ".") return stack[stack.length - 1]
|
|
63
|
+
const path = name.split(".")
|
|
64
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
65
|
+
let v = stack[i]
|
|
66
|
+
let ok = true
|
|
67
|
+
for (const key of path) {
|
|
68
|
+
if (v && typeof v === "object" && key in v) v = v[key]
|
|
69
|
+
else { ok = false; break }
|
|
70
|
+
}
|
|
71
|
+
if (ok) return v
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isObject = (v) => v && typeof v === "object" && !Array.isArray(v)
|
|
77
|
+
|
|
78
|
+
/** Names a template refers to — used to warn about typos in pipeline YAML. */
|
|
79
|
+
export function variables(template) {
|
|
80
|
+
const names = new Set()
|
|
81
|
+
for (const m of String(template ?? "").matchAll(TAG)) {
|
|
82
|
+
if (m[1] === "!" || m[1] === "/") continue
|
|
83
|
+
names.add(m[2].split(".")[0])
|
|
84
|
+
}
|
|
85
|
+
return [...names]
|
|
86
|
+
}
|
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// A workspace's own tools: every .js / .mjs file in <workspace>/botfarm/mcps/
|
|
2
|
+
// is loaded into BotFarm's MCP server and offered to the bots working in that
|
|
3
|
+
// workspace (as botfarm_<name>), next to the built-in ones.
|
|
4
|
+
//
|
|
5
|
+
// A tool file exports:
|
|
6
|
+
// name letters, digits, _ and - (defaults to the file name)
|
|
7
|
+
// description what the model reads to decide when to call it
|
|
8
|
+
// inputSchema JSON Schema for the arguments (default: no arguments)
|
|
9
|
+
// execute async (args, ctx) => result (also accepted: handler, default)
|
|
10
|
+
// timeoutMs optional, default 120000
|
|
11
|
+
//
|
|
12
|
+
// ctx: { repoRoot, workspace, bot, signal, log(...), exec(cmd, args, opts) }
|
|
13
|
+
//
|
|
14
|
+
// Files are re-imported when they change (fs.watch, plus a rescan as a safety
|
|
15
|
+
// net), so saving a file is enough; bots get the new list on their next turn.
|
|
16
|
+
|
|
17
|
+
import { readdir, readFile, writeFile, stat, mkdir, unlink } from "node:fs/promises"
|
|
18
|
+
import { watch } from "node:fs"
|
|
19
|
+
import { join, basename, extname } from "node:path"
|
|
20
|
+
import { pathToFileURL } from "node:url"
|
|
21
|
+
import { execFile } from "node:child_process"
|
|
22
|
+
|
|
23
|
+
const NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,62}$/
|
|
24
|
+
const FILE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,80}\.m?js$/
|
|
25
|
+
|
|
26
|
+
export class WorkspaceTools {
|
|
27
|
+
constructor({ supervisor, dirFor, reserved = [] }) {
|
|
28
|
+
this.sup = supervisor
|
|
29
|
+
this.dirFor = dirFor // (ws) => absolute tools folder
|
|
30
|
+
this.reserved = new Set(reserved) // built-in tool names a file may not take
|
|
31
|
+
this.sets = new Map() // workspace id -> { dir, tools: Map<name, tool>, files: Map<file, {mtime, name?, error?}>, watcher, version }
|
|
32
|
+
this.listeners = new Set()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onChange(fn) { this.listeners.add(fn) }
|
|
36
|
+
|
|
37
|
+
/** Load (or re-check) one workspace's tools. Returns true when the list changed. */
|
|
38
|
+
async load(ws) {
|
|
39
|
+
const dir = this.dirFor(ws)
|
|
40
|
+
let set = this.sets.get(ws.id)
|
|
41
|
+
if (!set || set.dir !== dir) {
|
|
42
|
+
set?.watcher?.close?.()
|
|
43
|
+
set = { dir, tools: new Map(), files: new Map(), watcher: null, version: 0, ws: ws.id }
|
|
44
|
+
this.sets.set(ws.id, set)
|
|
45
|
+
}
|
|
46
|
+
let names = []
|
|
47
|
+
try { names = (await readdir(dir)).filter((n) => FILE_RE.test(n)) } catch { names = [] }
|
|
48
|
+
let changed = false
|
|
49
|
+
const seen = new Set()
|
|
50
|
+
for (const file of names) {
|
|
51
|
+
seen.add(file)
|
|
52
|
+
const path = join(dir, file)
|
|
53
|
+
const mtime = await stat(path).then((s) => s.mtimeMs, () => 0)
|
|
54
|
+
const prev = set.files.get(file)
|
|
55
|
+
if (prev && prev.mtime === mtime) continue
|
|
56
|
+
changed = true
|
|
57
|
+
if (prev?.name) set.tools.delete(prev.name)
|
|
58
|
+
try {
|
|
59
|
+
const mod = await import(`${pathToFileURL(path).href}?v=${mtime}`)
|
|
60
|
+
const name = String(mod.name ?? mod.title ?? basename(file, extname(file)))
|
|
61
|
+
const exec = mod.execute ?? mod.handler ?? (typeof mod.default === "function" ? mod.default : null)
|
|
62
|
+
if (!NAME_RE.test(name)) throw new Error(`name "${name}" must be letters, digits, _ and - (starting with a letter)`)
|
|
63
|
+
if (this.reserved.has(name)) throw new Error(`"${name}" is a built-in BotFarm tool; pick another name`)
|
|
64
|
+
if (typeof exec !== "function") throw new Error("no execute(args, ctx) function exported")
|
|
65
|
+
const clash = [...set.tools.values()].find((t) => t.name === name && t.file !== file)
|
|
66
|
+
if (clash) throw new Error(`${clash.file} already defines "${name}"`)
|
|
67
|
+
const tool = {
|
|
68
|
+
name,
|
|
69
|
+
description: String(mod.description ?? "").trim() || `(no description in ${file})`,
|
|
70
|
+
inputSchema: mod.inputSchema && typeof mod.inputSchema === "object" ? mod.inputSchema : { type: "object", properties: {} },
|
|
71
|
+
timeoutMs: Number(mod.timeoutMs) > 0 ? Number(mod.timeoutMs) : 120_000,
|
|
72
|
+
execute: exec,
|
|
73
|
+
file,
|
|
74
|
+
}
|
|
75
|
+
set.tools.set(name, tool)
|
|
76
|
+
set.files.set(file, { mtime, name })
|
|
77
|
+
} catch (e) {
|
|
78
|
+
set.files.set(file, { mtime, error: e.message })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const [file, info] of [...set.files]) {
|
|
82
|
+
if (seen.has(file)) continue
|
|
83
|
+
set.files.delete(file)
|
|
84
|
+
if (info.name) set.tools.delete(info.name)
|
|
85
|
+
changed = true
|
|
86
|
+
}
|
|
87
|
+
if (!set.watcher) {
|
|
88
|
+
try {
|
|
89
|
+
let t = null
|
|
90
|
+
set.watcher = watch(dir, () => { clearTimeout(t); t = setTimeout(() => this.refresh(ws), 250) })
|
|
91
|
+
set.watcher.on("error", () => { set.watcher = null })
|
|
92
|
+
} catch { set.watcher = null } // no folder yet: the rescan picks it up
|
|
93
|
+
}
|
|
94
|
+
if (changed) set.version++
|
|
95
|
+
return changed
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async refresh(ws) {
|
|
99
|
+
const changed = await this.load(ws).catch(() => false)
|
|
100
|
+
if (changed) for (const fn of this.listeners) fn(ws.id)
|
|
101
|
+
return changed
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Rescan every workspace (the safety net for missed fs events). */
|
|
105
|
+
async rescan(workspaces) {
|
|
106
|
+
for (const ws of workspaces) await this.refresh(ws)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
list(wsId) {
|
|
110
|
+
const set = this.sets.get(wsId)
|
|
111
|
+
if (!set) return { dir: null, tools: [], errors: [], version: 0 }
|
|
112
|
+
return {
|
|
113
|
+
dir: set.dir,
|
|
114
|
+
version: set.version,
|
|
115
|
+
tools: [...set.tools.values()].map(({ execute, ...t }) => t).sort((a, b) => a.name.localeCompare(b.name)),
|
|
116
|
+
errors: [...set.files].filter(([, f]) => f.error).map(([file, f]) => ({ file, error: f.error })),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get(wsId, name) {
|
|
121
|
+
return this.sets.get(wsId)?.tools.get(name) ?? null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** MCP tool definitions for a workspace. */
|
|
125
|
+
mcpList(wsId) {
|
|
126
|
+
const set = this.sets.get(wsId)
|
|
127
|
+
if (!set) return []
|
|
128
|
+
return [...set.tools.values()].map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Run a tool. Never throws: the outcome is { ok, result?, error?, ms, logs }.
|
|
133
|
+
* `repoRoot` is the checkout the tool works in (the bot's worktree).
|
|
134
|
+
*/
|
|
135
|
+
async run(wsId, name, args = {}, { repoRoot, workspace = null, bot = null, signal = null } = {}) {
|
|
136
|
+
const tool = this.get(wsId, name)
|
|
137
|
+
const started = Date.now()
|
|
138
|
+
const logs = []
|
|
139
|
+
if (!tool) return { ok: false, error: `no tool "${name}" in this workspace`, ms: 0, logs }
|
|
140
|
+
const ctl = new AbortController()
|
|
141
|
+
const onAbort = () => ctl.abort()
|
|
142
|
+
signal?.addEventListener?.("abort", onAbort)
|
|
143
|
+
let timer
|
|
144
|
+
const ctx = {
|
|
145
|
+
repoRoot,
|
|
146
|
+
workspace,
|
|
147
|
+
bot,
|
|
148
|
+
signal: ctl.signal,
|
|
149
|
+
log: (...a) => { if (logs.length < 200) logs.push(a.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" ").slice(0, 2000)) },
|
|
150
|
+
exec: (cmd, argv = [], opts = {}) => new Promise((resolve) => {
|
|
151
|
+
execFile(cmd, argv, { cwd: opts.cwd ?? repoRoot, timeout: opts.timeoutMs ?? tool.timeoutMs, maxBuffer: 16 * 1024 * 1024, signal: ctl.signal, env: { ...process.env, ...(opts.env ?? {}) } }, (err, stdout, stderr) => {
|
|
152
|
+
resolve({ code: err ? (typeof err.code === "number" ? err.code : 1) : 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") || (err && !stderr ? err.message : "") })
|
|
153
|
+
})
|
|
154
|
+
}),
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const result = await Promise.race([
|
|
158
|
+
Promise.resolve().then(() => tool.execute(args ?? {}, ctx)),
|
|
159
|
+
new Promise((_, reject) => { timer = setTimeout(() => { ctl.abort(); reject(new Error(`timed out after ${Math.round(tool.timeoutMs / 1000)}s`)) }, tool.timeoutMs) }),
|
|
160
|
+
])
|
|
161
|
+
return { ok: true, result: result === undefined ? null : result, ms: Date.now() - started, logs }
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return { ok: false, error: e?.message ?? String(e), stack: String(e?.stack ?? "").split("\n").slice(1, 6).join("\n"), ms: Date.now() - started, logs }
|
|
164
|
+
} finally {
|
|
165
|
+
clearTimeout(timer)
|
|
166
|
+
signal?.removeEventListener?.("abort", onAbort)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- files, for the test bench ---------------------------------------------
|
|
171
|
+
|
|
172
|
+
checkFile(file) {
|
|
173
|
+
if (!FILE_RE.test(file ?? "")) throw new Error("a tool file is a name ending in .js or .mjs (letters, digits, _ - .)")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async source(ws, file) {
|
|
177
|
+
this.checkFile(file)
|
|
178
|
+
const path = join(this.dirFor(ws), file)
|
|
179
|
+
return { file, path, text: await readFile(path, "utf8").catch(() => "") }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async save(ws, file, text) {
|
|
183
|
+
this.checkFile(file)
|
|
184
|
+
const dir = this.dirFor(ws)
|
|
185
|
+
await mkdir(dir, { recursive: true })
|
|
186
|
+
await writeFile(join(dir, file), String(text ?? ""))
|
|
187
|
+
await this.refresh(ws)
|
|
188
|
+
return this.list(ws.id)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async create(ws, name) {
|
|
192
|
+
if (!NAME_RE.test(name ?? "")) throw new Error("a tool name is letters, digits, _ and - (starting with a letter)")
|
|
193
|
+
const file = `${name}.js`
|
|
194
|
+
const path = join(this.dirFor(ws), file)
|
|
195
|
+
if (await stat(path).then(() => true, () => false)) throw new Error(`${file} already exists`)
|
|
196
|
+
return { ...(await this.save(ws, file, template(name))), file }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async remove(ws, file) {
|
|
200
|
+
this.checkFile(file)
|
|
201
|
+
await unlink(join(this.dirFor(ws), file))
|
|
202
|
+
await this.refresh(ws)
|
|
203
|
+
return this.list(ws.id)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
close() {
|
|
207
|
+
for (const s of this.sets.values()) s.watcher?.close?.()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Plain JSON for MCP: strings as text, everything else pretty-printed (and structured when it is an object). */
|
|
212
|
+
export function toolResult(out) {
|
|
213
|
+
if (!out.ok) return { content: [{ type: "text", text: `Tool error: ${out.error}${out.logs.length ? `\n\nlog:\n${out.logs.join("\n")}` : ""}` }], isError: true }
|
|
214
|
+
const r = out.result
|
|
215
|
+
if (typeof r === "string") return { content: [{ type: "text", text: r }] }
|
|
216
|
+
const text = JSON.stringify(r, null, 2) ?? "null"
|
|
217
|
+
return r && typeof r === "object" && !Array.isArray(r)
|
|
218
|
+
? { content: [{ type: "text", text }], structuredContent: r }
|
|
219
|
+
: { content: [{ type: "text", text }] }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const template = (name) => `// BotFarm tool: offered to every bot in this workspace as botfarm_${name}.
|
|
223
|
+
// Saving this file reloads it. Try it from Tools in the BotFarm sidebar.
|
|
224
|
+
|
|
225
|
+
export const name = "${name}"
|
|
226
|
+
export const description = "Say what this does and when a bot should use it — this is what the model reads."
|
|
227
|
+
export const inputSchema = {
|
|
228
|
+
type: "object",
|
|
229
|
+
properties: {
|
|
230
|
+
path: { type: "string", description: "A path relative to the worktree." },
|
|
231
|
+
},
|
|
232
|
+
required: ["path"],
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ctx.repoRoot: the calling bot's worktree. ctx.exec(cmd, args): run a program there.
|
|
236
|
+
// ctx.log(...): lines shown in the test bench and returned with errors.
|
|
237
|
+
export async function execute({ path }, ctx) {
|
|
238
|
+
const { stdout } = await ctx.exec("ls", ["-la", path])
|
|
239
|
+
return { path, listing: stdout.split("\\n").filter(Boolean) }
|
|
240
|
+
}
|
|
241
|
+
`
|