@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/ws.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A small RFC 6455 server, text frames only, no dependencies.
|
|
2
|
+
//
|
|
3
|
+
// The dashboard already received pushes over SSE rather than polling, but a
|
|
4
|
+
// websocket is cheaper per message (2–10 bytes of framing against SSE's
|
|
5
|
+
// "data: " plus base64-free UTF-8 reparse), survives proxies that buffer
|
|
6
|
+
// event-streams, and gives the client a way to talk back — used here for
|
|
7
|
+
// nothing more than pings and a "send me everything again" resync.
|
|
8
|
+
//
|
|
9
|
+
// Actions stay on plain HTTP. They are one-shot, they want status codes and
|
|
10
|
+
// retries, and multiplexing them over a socket would mean inventing request
|
|
11
|
+
// ids and error plumbing that fetch already has.
|
|
12
|
+
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
14
|
+
|
|
15
|
+
const MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
16
|
+
|
|
17
|
+
export function isWebSocketUpgrade(req) {
|
|
18
|
+
return (
|
|
19
|
+
req.headers.upgrade?.toLowerCase() === "websocket" &&
|
|
20
|
+
/\bupgrade\b/i.test(req.headers.connection ?? "") &&
|
|
21
|
+
!!req.headers["sec-websocket-key"]
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function accept(req, socket, { onMessage = () => {}, onClose = () => {} } = {}) {
|
|
26
|
+
const key = req.headers["sec-websocket-key"]
|
|
27
|
+
const digest = createHash("sha1").update(key + MAGIC).digest("base64")
|
|
28
|
+
socket.write(
|
|
29
|
+
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
30
|
+
"Upgrade: websocket\r\n" +
|
|
31
|
+
"Connection: Upgrade\r\n" +
|
|
32
|
+
`Sec-WebSocket-Accept: ${digest}\r\n\r\n`,
|
|
33
|
+
)
|
|
34
|
+
socket.setNoDelay(true)
|
|
35
|
+
|
|
36
|
+
const conn = new Connection(socket)
|
|
37
|
+
let buffer = Buffer.alloc(0)
|
|
38
|
+
|
|
39
|
+
socket.on("data", (chunk) => {
|
|
40
|
+
buffer = Buffer.concat([buffer, chunk])
|
|
41
|
+
while (true) {
|
|
42
|
+
const frame = decode(buffer)
|
|
43
|
+
if (!frame) break
|
|
44
|
+
buffer = buffer.subarray(frame.size)
|
|
45
|
+
if (frame.opcode === 0x8) return conn.close()
|
|
46
|
+
if (frame.opcode === 0x9) { socket.write(encode(frame.payload, 0xa)); continue }
|
|
47
|
+
if (frame.opcode === 0xa) { conn.alive = true; continue }
|
|
48
|
+
if (frame.opcode === 0x1) onMessage(frame.payload.toString("utf8"), conn)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
socket.on("error", () => conn.close())
|
|
52
|
+
socket.on("close", () => { conn.open = false; onClose(conn) })
|
|
53
|
+
return conn
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class Connection {
|
|
57
|
+
constructor(socket) {
|
|
58
|
+
this.socket = socket
|
|
59
|
+
this.open = true
|
|
60
|
+
this.alive = true
|
|
61
|
+
this.id = randomBytes(6).toString("hex")
|
|
62
|
+
// A half-open socket looks identical to a quiet one until you ping it.
|
|
63
|
+
this.heartbeat = setInterval(() => {
|
|
64
|
+
if (!this.open) return
|
|
65
|
+
if (!this.alive) return this.close()
|
|
66
|
+
this.alive = false
|
|
67
|
+
try { this.socket.write(encode(Buffer.alloc(0), 0x9)) } catch { this.close() }
|
|
68
|
+
}, 20000)
|
|
69
|
+
}
|
|
70
|
+
send(text) {
|
|
71
|
+
if (!this.open) return false
|
|
72
|
+
try {
|
|
73
|
+
this.socket.write(encode(Buffer.from(text, "utf8"), 0x1))
|
|
74
|
+
return true
|
|
75
|
+
} catch {
|
|
76
|
+
this.close()
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
close() {
|
|
81
|
+
if (!this.open) return
|
|
82
|
+
this.open = false
|
|
83
|
+
clearInterval(this.heartbeat)
|
|
84
|
+
try { this.socket.end(encode(Buffer.alloc(0), 0x8)) } catch {}
|
|
85
|
+
try { this.socket.destroy() } catch {}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function encode(payload, opcode = 0x1) {
|
|
90
|
+
const len = payload.length
|
|
91
|
+
let header
|
|
92
|
+
if (len < 126) {
|
|
93
|
+
header = Buffer.alloc(2)
|
|
94
|
+
header[1] = len
|
|
95
|
+
} else if (len < 65536) {
|
|
96
|
+
header = Buffer.alloc(4)
|
|
97
|
+
header[1] = 126
|
|
98
|
+
header.writeUInt16BE(len, 2)
|
|
99
|
+
} else {
|
|
100
|
+
header = Buffer.alloc(10)
|
|
101
|
+
header[1] = 127
|
|
102
|
+
header.writeBigUInt64BE(BigInt(len), 2)
|
|
103
|
+
}
|
|
104
|
+
header[0] = 0x80 | opcode // FIN + opcode; server frames are never masked
|
|
105
|
+
return Buffer.concat([header, payload])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Returns null until a whole frame is buffered. */
|
|
109
|
+
function decode(buf) {
|
|
110
|
+
if (buf.length < 2) return null
|
|
111
|
+
const opcode = buf[0] & 0x0f
|
|
112
|
+
const masked = (buf[1] & 0x80) !== 0
|
|
113
|
+
let len = buf[1] & 0x7f
|
|
114
|
+
let offset = 2
|
|
115
|
+
if (len === 126) {
|
|
116
|
+
if (buf.length < 4) return null
|
|
117
|
+
len = buf.readUInt16BE(2)
|
|
118
|
+
offset = 4
|
|
119
|
+
} else if (len === 127) {
|
|
120
|
+
if (buf.length < 10) return null
|
|
121
|
+
len = Number(buf.readBigUInt64BE(2))
|
|
122
|
+
offset = 10
|
|
123
|
+
}
|
|
124
|
+
const maskKey = masked ? buf.subarray(offset, offset + 4) : null
|
|
125
|
+
if (masked) offset += 4
|
|
126
|
+
if (buf.length < offset + len) return null
|
|
127
|
+
const payload = Buffer.from(buf.subarray(offset, offset + len))
|
|
128
|
+
if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4]
|
|
129
|
+
return { opcode, payload, size: offset + len }
|
|
130
|
+
}
|
package/src/yaml.mjs
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// A deliberately small YAML subset — enough for personas and pipelines, and
|
|
2
|
+
// nothing more. Supported: comments, nested maps by indentation, "- " lists
|
|
3
|
+
// (of scalars or maps), quoted and bare scalars, numbers, booleans, null, and
|
|
4
|
+
// the "|" / ">" block scalars that prompts need.
|
|
5
|
+
//
|
|
6
|
+
// Not supported: anchors, aliases, multiple documents, flow maps, tags. If a
|
|
7
|
+
// file uses them, parsing throws with a line number rather than silently
|
|
8
|
+
// producing something odd — these files are edited by hand, so a loud failure
|
|
9
|
+
// beats a quiet misreading.
|
|
10
|
+
|
|
11
|
+
export function parse(text) {
|
|
12
|
+
const lines = String(text).split(/\r?\n/)
|
|
13
|
+
const root = {}
|
|
14
|
+
const stack = [{ indent: -1, value: root }]
|
|
15
|
+
let i = 0
|
|
16
|
+
|
|
17
|
+
const fail = (msg) => {
|
|
18
|
+
throw new Error(`${msg} (line ${i + 1}: ${lines[i]?.trim()})`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
while (i < lines.length) {
|
|
22
|
+
const raw = lines[i]
|
|
23
|
+
const line = stripComment(raw)
|
|
24
|
+
if (!line.trim()) { i++; continue }
|
|
25
|
+
if (/^\s*(---|\.\.\.)\s*$/.test(line)) { i++; continue }
|
|
26
|
+
if (/[&*!]\S/.test(line.trim()) && /^\s*\w+:\s*[&*]/.test(line)) fail("anchors and aliases are not supported")
|
|
27
|
+
|
|
28
|
+
const indent = line.match(/^ */)[0].length
|
|
29
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop()
|
|
30
|
+
const parent = stack[stack.length - 1].value
|
|
31
|
+
const body = line.slice(indent)
|
|
32
|
+
|
|
33
|
+
// list item
|
|
34
|
+
if (body.startsWith("- ") || body === "-") {
|
|
35
|
+
if (!Array.isArray(parent)) fail("list item outside a list")
|
|
36
|
+
const rest = body.slice(2)
|
|
37
|
+
if (!rest.trim()) {
|
|
38
|
+
const obj = {}
|
|
39
|
+
parent.push(obj)
|
|
40
|
+
stack.push({ indent, value: obj })
|
|
41
|
+
i++
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
const kv = splitKey(rest)
|
|
45
|
+
if (kv) {
|
|
46
|
+
const obj = {}
|
|
47
|
+
parent.push(obj)
|
|
48
|
+
stack.push({ indent, value: obj })
|
|
49
|
+
// re-handle the inline "key: value" at a deeper virtual indent
|
|
50
|
+
const [key, value] = kv
|
|
51
|
+
const block = readBlock(value, indent + 2)
|
|
52
|
+
if (block !== undefined) obj[key] = block
|
|
53
|
+
else if (value === "") {
|
|
54
|
+
const child = nextIsList(indent + 2) ? [] : {}
|
|
55
|
+
obj[key] = child
|
|
56
|
+
stack.push({ indent: indent + 1, value: child })
|
|
57
|
+
} else obj[key] = scalar(value)
|
|
58
|
+
i++
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
parent.push(scalar(rest))
|
|
62
|
+
i++
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// key: value
|
|
67
|
+
const kv = splitKey(body)
|
|
68
|
+
if (!kv) fail("expected 'key: value' or '- item'")
|
|
69
|
+
const [key, value] = kv
|
|
70
|
+
if (Array.isArray(parent)) fail("mapping inside a list needs '- ' on its first key")
|
|
71
|
+
|
|
72
|
+
const block = readBlock(value, indent)
|
|
73
|
+
if (block !== undefined) {
|
|
74
|
+
parent[key] = block
|
|
75
|
+
continue // readBlock advanced i
|
|
76
|
+
}
|
|
77
|
+
if (value === "") {
|
|
78
|
+
const child = nextIsList(indent) ? [] : {}
|
|
79
|
+
parent[key] = child
|
|
80
|
+
stack.push({ indent, value: child })
|
|
81
|
+
i++
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
parent[key] = scalar(value)
|
|
85
|
+
i++
|
|
86
|
+
}
|
|
87
|
+
return root
|
|
88
|
+
|
|
89
|
+
/** Look ahead past blanks for the next meaningful line's shape. */
|
|
90
|
+
function nextIsList(indent) {
|
|
91
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
92
|
+
const l = stripComment(lines[j])
|
|
93
|
+
if (!l.trim()) continue
|
|
94
|
+
const ind = l.match(/^ */)[0].length
|
|
95
|
+
if (ind <= indent) return false
|
|
96
|
+
return l.slice(ind).startsWith("- ") || l.slice(ind) === "-"
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** "|" and ">" block scalars; returns undefined when `value` isn't one. */
|
|
102
|
+
function readBlock(value, indent) {
|
|
103
|
+
const m = /^([|>])([-+]?)$/.exec(value.trim())
|
|
104
|
+
if (!m) return undefined
|
|
105
|
+
const fold = m[1] === ">"
|
|
106
|
+
const chomp = m[2]
|
|
107
|
+
const out = []
|
|
108
|
+
let j = i + 1
|
|
109
|
+
let blockIndent = null
|
|
110
|
+
for (; j < lines.length; j++) {
|
|
111
|
+
const l = lines[j]
|
|
112
|
+
if (!l.trim()) { out.push(""); continue }
|
|
113
|
+
const ind = l.match(/^ */)[0].length
|
|
114
|
+
if (ind <= indent) break
|
|
115
|
+
if (blockIndent === null) blockIndent = ind
|
|
116
|
+
out.push(l.slice(blockIndent))
|
|
117
|
+
}
|
|
118
|
+
i = j
|
|
119
|
+
while (out.length && out[out.length - 1] === "") out.pop()
|
|
120
|
+
let text = fold ? foldLines(out) : out.join("\n")
|
|
121
|
+
if (chomp !== "-") text += "\n"
|
|
122
|
+
if (chomp === "-") text = text.replace(/\n+$/, "")
|
|
123
|
+
return text
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function foldLines(lines) {
|
|
128
|
+
const out = []
|
|
129
|
+
for (const l of lines) {
|
|
130
|
+
if (l === "") out.push("\n")
|
|
131
|
+
else if (out.length && !out[out.length - 1].endsWith("\n")) out[out.length - 1] += " " + l
|
|
132
|
+
else out.push(l)
|
|
133
|
+
}
|
|
134
|
+
return out.join("").replace(/\n(?!\n)/g, "\n")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stripComment(line) {
|
|
138
|
+
let out = ""
|
|
139
|
+
let quote = null
|
|
140
|
+
for (let k = 0; k < line.length; k++) {
|
|
141
|
+
const c = line[k]
|
|
142
|
+
if (quote) {
|
|
143
|
+
out += c
|
|
144
|
+
if (c === quote && line[k - 1] !== "\\") quote = null
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (c === '"' || c === "'") { quote = c; out += c; continue }
|
|
148
|
+
if (c === "#" && (k === 0 || /\s/.test(line[k - 1]))) break
|
|
149
|
+
out += c
|
|
150
|
+
}
|
|
151
|
+
return out.replace(/\s+$/, "")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function splitKey(body) {
|
|
155
|
+
let quote = null
|
|
156
|
+
for (let k = 0; k < body.length; k++) {
|
|
157
|
+
const c = body[k]
|
|
158
|
+
if (quote) { if (c === quote) quote = null; continue }
|
|
159
|
+
if (c === '"' || c === "'") { quote = c; continue }
|
|
160
|
+
if (c === ":" && (k + 1 === body.length || /\s/.test(body[k + 1]))) {
|
|
161
|
+
return [unquote(body.slice(0, k).trim()), body.slice(k + 1).trim()]
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function unquote(s) {
|
|
168
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
169
|
+
return s.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"')
|
|
170
|
+
}
|
|
171
|
+
return s
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function scalar(v) {
|
|
175
|
+
const s = v.trim()
|
|
176
|
+
if (s === "" || s === "~" || s === "null") return null
|
|
177
|
+
if (s === "true" || s === "yes") return true
|
|
178
|
+
if (s === "false" || s === "no") return false
|
|
179
|
+
if (/^-?\d+$/.test(s)) return Number(s)
|
|
180
|
+
if (/^-?\d*\.\d+$/.test(s)) return Number(s)
|
|
181
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
182
|
+
const inner = s.slice(1, -1).trim()
|
|
183
|
+
return inner ? splitFlow(inner).map((x) => scalar(x)) : []
|
|
184
|
+
}
|
|
185
|
+
// One-line maps: { usd: 5, per: day }. Not "{{story}}" — that is a template.
|
|
186
|
+
if (s.startsWith("{") && s.endsWith("}") && s[1] !== "{") {
|
|
187
|
+
const inner = s.slice(1, -1).trim()
|
|
188
|
+
if (!inner) return {}
|
|
189
|
+
const pairs = splitFlow(inner).map((x) => splitKey(x.trim()) ?? (/^[\w-]+:$/.test(x.trim()) ? [x.trim().slice(0, -1), ""] : null))
|
|
190
|
+
if (pairs.every(Boolean)) return Object.fromEntries(pairs.map(([k, v]) => [k, scalar(v)]))
|
|
191
|
+
}
|
|
192
|
+
return unquote(s)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Split on top-level commas, leaving nested [], {} and quotes alone. */
|
|
196
|
+
function splitFlow(s) {
|
|
197
|
+
const out = []
|
|
198
|
+
let depth = 0, quote = null, start = 0
|
|
199
|
+
for (let k = 0; k < s.length; k++) {
|
|
200
|
+
const c = s[k]
|
|
201
|
+
if (quote) { if (c === quote && s[k - 1] !== "\\") quote = null; continue }
|
|
202
|
+
if (c === '"' || c === "'") quote = c
|
|
203
|
+
else if (c === "[" || c === "{") depth++
|
|
204
|
+
else if (c === "]" || c === "}") depth--
|
|
205
|
+
else if (c === "," && depth === 0) { out.push(s.slice(start, k)); start = k + 1 }
|
|
206
|
+
}
|
|
207
|
+
out.push(s.slice(start))
|
|
208
|
+
return out.map((x) => x.trim()).filter((x) => x !== "")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
export function stringify(value, indent = 0) {
|
|
214
|
+
const pad = " ".repeat(indent)
|
|
215
|
+
if (value === null || value === undefined) return "null"
|
|
216
|
+
if (Array.isArray(value)) {
|
|
217
|
+
if (!value.length) return "[]"
|
|
218
|
+
return value
|
|
219
|
+
.map((v) =>
|
|
220
|
+
isPlainObject(v)
|
|
221
|
+
? listItem(v, indent, pad)
|
|
222
|
+
: `${pad}- ${scalarOut(v)}`,
|
|
223
|
+
)
|
|
224
|
+
.join("\n")
|
|
225
|
+
}
|
|
226
|
+
if (isPlainObject(value)) {
|
|
227
|
+
return Object.entries(value)
|
|
228
|
+
.map(([k, v]) => {
|
|
229
|
+
if (isPlainObject(v)) {
|
|
230
|
+
return Object.keys(v).length ? `${pad}${k}:\n${stringify(v, indent + 2)}` : `${pad}${k}: {}`
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(v)) {
|
|
233
|
+
return v.length ? `${pad}${k}:\n${stringify(v, indent + 2)}` : `${pad}${k}: []`
|
|
234
|
+
}
|
|
235
|
+
if (typeof v === "string" && v.includes("\n")) {
|
|
236
|
+
const body = v.replace(/\n$/, "").split("\n").map((l) => " ".repeat(indent + 2) + l).join("\n")
|
|
237
|
+
return `${pad}${k}: |\n${body}`
|
|
238
|
+
}
|
|
239
|
+
return `${pad}${k}: ${scalarOut(v)}`
|
|
240
|
+
})
|
|
241
|
+
.join("\n")
|
|
242
|
+
}
|
|
243
|
+
return `${pad}${scalarOut(value)}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// "- key: value" with the rest of the map under it. The first key must be a
|
|
247
|
+
// scalar, so move one to the front; a map with none uses a bare "-" line.
|
|
248
|
+
function listItem(v, indent, pad) {
|
|
249
|
+
const keys = Object.keys(v)
|
|
250
|
+
const scalar = keys.find((k) => !isPlainObject(v[k]) && !Array.isArray(v[k]) && !(typeof v[k] === "string" && v[k].includes("\n")))
|
|
251
|
+
if (scalar === undefined) return `${pad}-\n${stringify(v, indent + 2)}`
|
|
252
|
+
const ordered = { [scalar]: v[scalar], ...v }
|
|
253
|
+
return `${pad}- ${stringify(ordered, indent + 2).slice(indent + 2)}`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const isPlainObject = (v) => v && typeof v === "object" && !Array.isArray(v)
|
|
257
|
+
|
|
258
|
+
function scalarOut(v) {
|
|
259
|
+
if (v === null || v === undefined) return "null"
|
|
260
|
+
if (typeof v === "boolean" || typeof v === "number") return String(v)
|
|
261
|
+
const s = String(v)
|
|
262
|
+
if (s === "" || /^[\s#&*!|>%@`]/.test(s) || /:\s/.test(s) || /^(true|false|null|yes|no|~|-?\d+(\.\d+)?)$/.test(s)) {
|
|
263
|
+
return JSON.stringify(s)
|
|
264
|
+
}
|
|
265
|
+
return s
|
|
266
|
+
}
|
package/test/board.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The board is a registry, not a listing: ownership, groups, cost, tools.
|
|
2
|
+
import { spawn as spawnProc } from "node:child_process"
|
|
3
|
+
import { Supervisor } from "../src/supervisor.mjs"
|
|
4
|
+
import { startServer } from "../src/server.mjs"
|
|
5
|
+
import { estimate, normalise, rateFor } from "../src/pricing.mjs"
|
|
6
|
+
import { handleFor, avatarFor, GLYPH } from "../src/identity.mjs"
|
|
7
|
+
|
|
8
|
+
const mock = spawnProc(process.execPath, [new URL("./mock-opencode.mjs", import.meta.url).pathname], { stdio: "ignore" })
|
|
9
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
10
|
+
let failed = 0
|
|
11
|
+
const ok = (c, m) => (c ? console.log(" ok " + m) : (failed++, console.error(" FAIL " + m)))
|
|
12
|
+
|
|
13
|
+
console.log("identities")
|
|
14
|
+
ok(GLYPH.size >= 64, `the creature roster is ${GLYPH.size} strong`)
|
|
15
|
+
ok(new Set(GLYPH.values()).size === GLYPH.size, "every creature has its own glyph — no two share a face")
|
|
16
|
+
const taken = new Set()
|
|
17
|
+
const creatures = new Set()
|
|
18
|
+
for (let i = 0; i < 300; i++) {
|
|
19
|
+
const h = handleFor("ses_test_" + i, taken)
|
|
20
|
+
taken.add(h)
|
|
21
|
+
creatures.add(h.split("-").slice(1).join("-"))
|
|
22
|
+
}
|
|
23
|
+
ok(taken.size === 300, "300 sessions get 300 distinct handles")
|
|
24
|
+
ok(creatures.size > 40, `spread across ${creatures.size} different creatures`)
|
|
25
|
+
const svg = avatarFor("ses_test_1", { handle: [...taken][0] })
|
|
26
|
+
ok(/viewBox="0 0 32 32"/.test(svg) && /<text/.test(svg), "and each renders as a glyph avatar")
|
|
27
|
+
ok(avatarFor("x", { handle: "brisk-otter" }) === avatarFor("x", { handle: "brisk-otter" }), "deterministic across calls")
|
|
28
|
+
|
|
29
|
+
console.log("\npricing")
|
|
30
|
+
ok(normalise("us.anthropic.claude-sonnet-4-20250514-v1:0") === "claude-sonnet-4", "a Bedrock id normalises to its family")
|
|
31
|
+
ok(normalise("anthropic/claude-opus-4-1-20250805") === "claude-opus-4-1", "a provider-prefixed id normalises too")
|
|
32
|
+
ok(rateFor("us.anthropic.claude-sonnet-4-20250514-v1:0")?.input === 3, "and finds a rate")
|
|
33
|
+
const bedrock = estimate({
|
|
34
|
+
model: "us.anthropic.claude-sonnet-4-20250514-v1:0",
|
|
35
|
+
totals: { input: 100_000, output: 20_000, cacheRead: 1_000_000, cacheWrite: 50_000 },
|
|
36
|
+
reported: 0,
|
|
37
|
+
})
|
|
38
|
+
ok(bedrock.usd > 0 && bedrock.estimated, `Bedrock sessions get a price anyway ($${bedrock.usd.toFixed(3)})`)
|
|
39
|
+
ok(Math.abs(bedrock.usd - (0.3 + 0.3 + 0.3 + 0.1875)) < 0.01, "cache reads are a tenth and writes a quarter more")
|
|
40
|
+
ok(estimate({ model: "x", totals: {}, reported: 4.2 }).estimated === false, "a reported cost is used as-is")
|
|
41
|
+
ok(estimate({ model: "some-new-model", totals: { input: 5 } }).unknownModel === "some-new-model", "an unpriced model says so rather than guessing")
|
|
42
|
+
ok(estimate({ model: "gpt-4o", totals: { input: 1e6 }, overrides: { "gpt-4o": { input: 99, output: 1 } } }).usd === 99, "config overrides win")
|
|
43
|
+
|
|
44
|
+
await sleep(600)
|
|
45
|
+
const sup = new Supervisor({
|
|
46
|
+
url: "http://127.0.0.1:4096",
|
|
47
|
+
statePath: "/tmp/botfarm-reg-state-" + Date.now() + ".json",
|
|
48
|
+
registryPath: "/tmp/botfarm-reg-" + Date.now() + ".json",
|
|
49
|
+
mcpBase: "http://127.0.0.1:4795",
|
|
50
|
+
})
|
|
51
|
+
await sup.start()
|
|
52
|
+
const { url } = startServer({ supervisor: sup, port: 4795 })
|
|
53
|
+
const get = async (p) => (await fetch(url + p)).json()
|
|
54
|
+
const post = async (p, body) =>
|
|
55
|
+
(await fetch(url + p, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body ?? {}) })).json()
|
|
56
|
+
|
|
57
|
+
console.log("\nan empty board")
|
|
58
|
+
let state = await get("/api/state")
|
|
59
|
+
ok(state.sessions.length === 0, "sessions opencode knows about do not appear on their own")
|
|
60
|
+
ok(state.unmanaged === 2, `they are offered instead (${state.unmanaged} available)`)
|
|
61
|
+
let loose = await get("/api/unmanaged")
|
|
62
|
+
ok(loose.sessions.length === 2 && loose.sessions[0].title, "the adoption list carries enough to choose by")
|
|
63
|
+
ok((await get("/api/unmanaged?q=flaky")).sessions.length === 1, "the list is searchable")
|
|
64
|
+
|
|
65
|
+
console.log("\nadopting")
|
|
66
|
+
await post("/api/adopt", { ids: [loose.sessions[0].id], project: "auth-work" })
|
|
67
|
+
await sleep(1400)
|
|
68
|
+
state = await get("/api/state")
|
|
69
|
+
ok(state.sessions.length === 1, "only the adopted session is on the board")
|
|
70
|
+
ok(state.unmanaged === 1, "the rest stay out of the way")
|
|
71
|
+
ok(state.projects.some((p) => p.id === "auth-work"), "the project is created and counted")
|
|
72
|
+
ok(!!state.sessions[0].handle, `it gets an identity (@${state.sessions[0].handle})`)
|
|
73
|
+
|
|
74
|
+
console.log("\ncost on a card")
|
|
75
|
+
const adopted = state.sessions[0]
|
|
76
|
+
ok(adopted.cost && typeof adopted.cost.usd === "number", "every card carries a cost")
|
|
77
|
+
const detail = await get(`/api/sessions/${adopted.id}`)
|
|
78
|
+
ok(detail.cost.estimated === true, "which is marked as an estimate when the provider gave us none")
|
|
79
|
+
|
|
80
|
+
console.log("\nlast message on the card")
|
|
81
|
+
ok(typeof adopted.lastText === "string" && adopted.lastText.length > 0, `the card shows what you would be continuing ("${adopted.lastText.slice(0, 40)}…")`)
|
|
82
|
+
|
|
83
|
+
console.log("\ntools")
|
|
84
|
+
ok(detail.tools.servers.length === 2, `MCP servers are listed (${detail.tools.servers.map((s) => s.name).join(", ")})`)
|
|
85
|
+
ok(detail.tools.servers.find((s) => s.name === "playwright")?.connected === false, "with their connection state")
|
|
86
|
+
ok(detail.tools.builtin.length > 0 && detail.tools.builtin.every((t) => t.enabled), "built-in tools start enabled")
|
|
87
|
+
await post(`/api/sessions/${adopted.id}/tools`, { name: "bash", enabled: false })
|
|
88
|
+
const after = await get(`/api/sessions/${adopted.id}`)
|
|
89
|
+
ok(after.tools.builtin.find((t) => t.name === "bash")?.enabled === false, "a tool can be switched off")
|
|
90
|
+
ok((await post(`/api/sessions/${adopted.id}/mcp`, { name: "playwright", connected: true })).ok, "an MCP server can be connected")
|
|
91
|
+
|
|
92
|
+
console.log("\ngroups and release")
|
|
93
|
+
await post(`/api/sessions/${adopted.id}/group`, { project: "default" })
|
|
94
|
+
state = await get("/api/state")
|
|
95
|
+
ok(state.sessions[0].project === "default", "a session can be moved between projects")
|
|
96
|
+
await post(`/api/sessions/${adopted.id}/release`, {})
|
|
97
|
+
state = await get("/api/state")
|
|
98
|
+
ok(state.sessions.length === 0 && state.unmanaged === 2, "releasing puts it back in the pool without deleting it")
|
|
99
|
+
|
|
100
|
+
console.log("\nrestart")
|
|
101
|
+
await sup.registry.load()
|
|
102
|
+
const reopened = sup.projects.get("auth-work")
|
|
103
|
+
ok(!!reopened && reopened.name === "auth-work", "the project itself outlives the sessions in it")
|
|
104
|
+
|
|
105
|
+
await sup.stop()
|
|
106
|
+
mock.kill()
|
|
107
|
+
console.log(failed ? `\n${failed} CHECKS FAILED` : "\nall checks passed")
|
|
108
|
+
process.exit(failed ? 1 : 0)
|