@mseep/anklebreaker-unity-mcp 2.30.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.
@@ -0,0 +1,125 @@
1
+ // Unity Hub CLI wrapper
2
+ import { execFile } from "child_process";
3
+ import { promisify } from "util";
4
+ import { CONFIG } from "./config.js";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Execute a Unity Hub CLI command
10
+ */
11
+ async function runHubCommand(args, timeoutMs = 30000) {
12
+ const hubPath = CONFIG.unityHubPath;
13
+
14
+ // Strategies in order: modern CLI (3.x+), legacy CLI (2.x), shell-based fallback (Windows)
15
+ const strategies = [
16
+ { name: "modern", args: ["--headless", ...args] },
17
+ { name: "legacy", args: ["--", "--headless", ...args] },
18
+ ];
19
+
20
+ const errors = [];
21
+
22
+ for (const strategy of strategies) {
23
+ try {
24
+ const { stdout, stderr } = await execFileAsync(hubPath, strategy.args, {
25
+ timeout: timeoutMs,
26
+ windowsHide: true,
27
+ // Capture output even on non-zero exit codes
28
+ maxBuffer: 10 * 1024 * 1024,
29
+ });
30
+ const out = (stdout || "").trim();
31
+ const err = (stderr || "").trim();
32
+ // Some Hub versions return data on stderr, check both
33
+ if (out || err) {
34
+ return { success: true, stdout: out, stderr: err };
35
+ }
36
+ } catch (error) {
37
+ const msg = error.message || String(error);
38
+ const out = (error.stdout || "").trim();
39
+ const err = (error.stderr || "").trim();
40
+ errors.push({ strategy: strategy.name, message: msg, stdout: out, stderr: err });
41
+ // If Hub returned data despite non-zero exit code, it might still be usable
42
+ if (out && !msg.includes("ENOENT")) {
43
+ return { success: true, stdout: out, stderr: err };
44
+ }
45
+ }
46
+ }
47
+
48
+ // All strategies failed — build helpful error message
49
+ const lastErr = errors[errors.length - 1] || {};
50
+ const isNotFound = errors.some((e) => e.message.includes("ENOENT"));
51
+ const hint = isNotFound
52
+ ? ` Unity Hub not found at "${hubPath}". Set UNITY_HUB_PATH environment variable to the correct path.`
53
+ : " Ensure Unity Hub is installed and supports CLI mode (--headless).";
54
+ return {
55
+ success: false,
56
+ error: (lastErr.message || "Unknown error") + hint,
57
+ stdout: lastErr.stdout || "",
58
+ stderr: lastErr.stderr || "",
59
+ };
60
+ }
61
+
62
+ /**
63
+ * List installed Unity Editor versions
64
+ */
65
+ export async function listInstalledEditors() {
66
+ const result = await runHubCommand(["editors", "--installed"]);
67
+ if (!result.success) return { error: result.error, raw: result.stderr };
68
+
69
+ const editors = [];
70
+ const lines = result.stdout.split("\n").filter((l) => l.trim());
71
+ for (const line of lines) {
72
+ // Parse lines like: "2022.3.0f1 , installed at C:\Program Files\Unity\..."
73
+ const match = line.match(/^([\d.]+\w+)\s*,?\s*installed at\s+(.+)$/i);
74
+ if (match) {
75
+ editors.push({ version: match[1].trim(), path: match[2].trim() });
76
+ }
77
+ }
78
+ return { editors, raw: result.stdout };
79
+ }
80
+
81
+ /**
82
+ * List available Unity Editor releases
83
+ */
84
+ export async function listAvailableReleases() {
85
+ const result = await runHubCommand(["editors", "--releases"]);
86
+ if (!result.success) return { error: result.error };
87
+ return { raw: result.stdout };
88
+ }
89
+
90
+ /**
91
+ * Install a Unity Editor version with optional modules
92
+ */
93
+ export async function installEditor(version, modules = []) {
94
+ const args = ["install", "--version", version];
95
+ for (const mod of modules) {
96
+ args.push("--module", mod);
97
+ }
98
+ const result = await runHubCommand(args, 600000); // 10min timeout for installs
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * Install modules to an existing editor
104
+ */
105
+ export async function installModules(version, modules) {
106
+ const args = ["install-modules", "--version", version];
107
+ for (const mod of modules) {
108
+ args.push("--module", mod);
109
+ }
110
+ const result = await runHubCommand(args, 300000);
111
+ return result;
112
+ }
113
+
114
+ /**
115
+ * Get or set the editor installation path
116
+ */
117
+ export async function getInstallPath() {
118
+ const result = await runHubCommand(["install-path"]);
119
+ return result;
120
+ }
121
+
122
+ export async function setInstallPath(path) {
123
+ const result = await runHubCommand(["install-path", "--set", path]);
124
+ return result;
125
+ }
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AnkleBreaker Unity MCP Multi-Agent Stress Test
4
+ *
5
+ * Simulates N agents each submitting M requests concurrently
6
+ * to validate queue fairness, ticket tracking, and timeout handling.
7
+ *
8
+ * Usage:
9
+ * node tests/multi-agent-stress-test.mjs [--agents N] [--requests M] [--mock]
10
+ *
11
+ * --mock : Start a local mock Unity bridge (no real Unity needed)
12
+ * --agents : Number of concurrent agents (default: 5)
13
+ * --requests: Requests per agent (default: 6)
14
+ */
15
+
16
+ import http from "http";
17
+ import { randomBytes } from "crypto";
18
+
19
+ // ─── CLI args ────────────────────────────────────────────────────────
20
+ const args = process.argv.slice(2);
21
+ const MOCK_MODE = args.includes("--mock");
22
+ const NUM_AGENTS = parseInt(args.find((_, i, a) => a[i - 1] === "--agents") || "5");
23
+ const REQUESTS_PER_AGENT = parseInt(args.find((_, i, a) => a[i - 1] === "--requests") || "6");
24
+ const BRIDGE_PORT = 7890;
25
+ const BRIDGE_HOST = "127.0.0.1";
26
+
27
+ // ─── Colors ──────────────────────────────────────────────────────────
28
+ const C = {
29
+ reset: "\x1b[0m", bold: "\x1b[1m",
30
+ red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m",
31
+ blue: "\x1b[34m", magenta: "\x1b[35m", cyan: "\x1b[36m",
32
+ gray: "\x1b[90m",
33
+ };
34
+ const AGENT_COLORS = [C.cyan, C.magenta, C.yellow, C.green, C.blue, C.red];
35
+
36
+ function log(color, prefix, msg) {
37
+ const ts = new Date().toISOString().slice(11, 23);
38
+ console.log(`${C.gray}[${ts}]${C.reset} ${color}${prefix}${C.reset} ${msg}`);
39
+ }
40
+
41
+ // ─── HTTP helpers ────────────────────────────────────────────────────
42
+ function httpRequest(method, path, body = null) {
43
+ return new Promise((resolve, reject) => {
44
+ const opts = { hostname: BRIDGE_HOST, port: BRIDGE_PORT, path, method, headers: {} };
45
+ if (body) {
46
+ const data = JSON.stringify(body);
47
+ opts.headers["Content-Type"] = "application/json";
48
+ opts.headers["Content-Length"] = Buffer.byteLength(data);
49
+ }
50
+ const req = http.request(opts, (res) => {
51
+ let chunks = [];
52
+ res.on("data", (c) => chunks.push(c));
53
+ res.on("end", () => {
54
+ const raw = Buffer.concat(chunks).toString();
55
+ try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); }
56
+ catch { resolve({ status: res.statusCode, body: raw }); }
57
+ });
58
+ });
59
+ req.on("error", reject);
60
+ if (body) req.write(JSON.stringify(body));
61
+ req.end();
62
+ });
63
+ }
64
+
65
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
66
+
67
+ // ─── Mock Unity Bridge ──────────────────────────────────────────────
68
+ // Simulates queue/submit, queue/status, queue/info endpoints
69
+ // with realistic processing delays (50-300ms per request)
70
+ let mockServer = null;
71
+ const mockTickets = new Map();
72
+ let mockTicketCounter = 0;
73
+
74
+ function startMockBridge() {
75
+ return new Promise((resolve) => {
76
+ mockServer = http.createServer((req, res) => {
77
+ let body = "";
78
+ req.on("data", (c) => (body += c));
79
+ req.on("end", () => {
80
+ const url = new URL(req.url, `http://${BRIDGE_HOST}:${BRIDGE_PORT}`);
81
+ const path = url.pathname;
82
+
83
+ // ── queue/submit ──
84
+ if (path === "/api/queue/submit" && req.method === "POST") {
85
+ const payload = JSON.parse(body);
86
+ const ticketId = `ticket-${++mockTicketCounter}`;
87
+ const processingTime = 50 + Math.random() * 250; // 50-300ms
88
+
89
+ const ticket = {
90
+ ticketId,
91
+ status: "Queued",
92
+ agentId: payload.agentId || "unknown",
93
+ apiPath: payload.apiPath,
94
+ submittedAt: new Date().toISOString(),
95
+ result: null,
96
+ };
97
+ mockTickets.set(ticketId, ticket);
98
+
99
+ // Simulate async processing
100
+ setTimeout(() => {
101
+ ticket.status = "Completed";
102
+ ticket.completedAt = new Date().toISOString();
103
+ ticket.result = {
104
+ success: true,
105
+ data: `Mock result for ${payload.apiPath} (agent: ${payload.agentId})`,
106
+ processingTimeMs: Math.round(processingTime),
107
+ };
108
+ }, processingTime);
109
+
110
+ res.writeHead(202, { "Content-Type": "application/json" });
111
+ res.end(JSON.stringify({ ticketId, status: "Queued", position: mockTickets.size }));
112
+ return;
113
+ }
114
+
115
+ // ── queue/status ──
116
+ if (path === "/api/queue/status") {
117
+ const ticketId = url.searchParams.get("ticketId");
118
+ const ticket = mockTickets.get(ticketId);
119
+ if (!ticket) {
120
+ res.writeHead(404, { "Content-Type": "application/json" });
121
+ res.end(JSON.stringify({ error: "Ticket not found" }));
122
+ return;
123
+ }
124
+ res.writeHead(200, { "Content-Type": "application/json" });
125
+ res.end(JSON.stringify(ticket));
126
+ return;
127
+ }
128
+
129
+ // ── queue/info ──
130
+ if (path === "/api/queue/info") {
131
+ const agents = new Map();
132
+ let pending = 0;
133
+ for (const t of mockTickets.values()) {
134
+ if (t.status === "Queued") {
135
+ pending++;
136
+ agents.set(t.agentId, (agents.get(t.agentId) || 0) + 1);
137
+ }
138
+ }
139
+ res.writeHead(200, { "Content-Type": "application/json" });
140
+ res.end(JSON.stringify({
141
+ totalPending: pending,
142
+ activeAgents: agents.size,
143
+ perAgent: Object.fromEntries(agents),
144
+ completedCacheSize: [...mockTickets.values()].filter((t) => t.status === "Completed").length,
145
+ }));
146
+ return;
147
+ }
148
+
149
+ // ── ping ──
150
+ if (path === "/api/ping") {
151
+ res.writeHead(200, { "Content-Type": "application/json" });
152
+ res.end(JSON.stringify({ status: "ok", mock: true }));
153
+ return;
154
+ }
155
+
156
+ // ── Fallback: simulate legacy endpoints ──
157
+ const delay = 30 + Math.random() * 100;
158
+ setTimeout(() => {
159
+ res.writeHead(200, { "Content-Type": "application/json" });
160
+ res.end(JSON.stringify({ success: true, mock: true, path, data: {} }));
161
+ }, delay);
162
+ });
163
+ });
164
+
165
+ mockServer.listen(BRIDGE_PORT, BRIDGE_HOST, () => {
166
+ log(C.green, "[MOCK]", `Mock Unity bridge running on ${BRIDGE_HOST}:${BRIDGE_PORT}`);
167
+ resolve();
168
+ });
169
+ });
170
+ }
171
+
172
+ // ─── Agent Simulation ────────────────────────────────────────────────
173
+ // Each agent: submit request → get ticket → poll until done
174
+ async function simulateAgent(agentIdx, agentId) {
175
+ const color = AGENT_COLORS[agentIdx % AGENT_COLORS.length];
176
+ const prefix = `[Agent-${agentIdx}]`;
177
+ const results = [];
178
+
179
+ // Varied API paths to test read batching vs write serialization
180
+ const apiPaths = [
181
+ "editor/state", // read
182
+ "scene/hierarchy", // read
183
+ "gameobject/info", // read
184
+ "gameobject/create", // write
185
+ "component/set_property", // write
186
+ "scene/info", // read
187
+ "asset/list", // read
188
+ "script/create", // write
189
+ "material/create", // write
190
+ "editor/ping", // read
191
+ ];
192
+
193
+ log(color, prefix, `Starting — will submit ${REQUESTS_PER_AGENT} requests (id: ${agentId})`);
194
+
195
+ for (let i = 0; i < REQUESTS_PER_AGENT; i++) {
196
+ const apiPath = apiPaths[i % apiPaths.length];
197
+ const startTime = Date.now();
198
+
199
+ try {
200
+ // 1. Submit to queue
201
+ const submitRes = await httpRequest("POST", "/api/queue/submit", {
202
+ apiPath,
203
+ body: JSON.stringify({ test: true, agentIdx, requestIdx: i }),
204
+ agentId,
205
+ });
206
+
207
+ if (submitRes.status !== 202) {
208
+ log(C.red, prefix, `Submit FAILED (HTTP ${submitRes.status}): ${JSON.stringify(submitRes.body)}`);
209
+ results.push({ apiPath, status: "submit_failed", elapsed: Date.now() - startTime });
210
+ continue;
211
+ }
212
+
213
+ const ticketId = submitRes.body.ticketId;
214
+ log(color, prefix, `Submitted ${C.bold}${apiPath}${C.reset} → ticket ${ticketId}`);
215
+
216
+ // 2. Poll for result (exponential backoff)
217
+ let pollInterval = 50;
218
+ let completed = false;
219
+ const pollStart = Date.now();
220
+
221
+ while (Date.now() - pollStart < 30000) { // 30s timeout
222
+ await sleep(pollInterval);
223
+ pollInterval = Math.min(pollInterval * 1.5, 1000);
224
+
225
+ const statusRes = await httpRequest("GET", `/api/queue/status?ticketId=${ticketId}`);
226
+
227
+ if (statusRes.body.status === "Completed") {
228
+ const elapsed = Date.now() - startTime;
229
+ log(color, prefix, ` ✓ ${ticketId} completed in ${C.bold}${elapsed}ms${C.reset}`);
230
+ results.push({ apiPath, ticketId, status: "completed", elapsed });
231
+ completed = true;
232
+ break;
233
+ } else if (statusRes.body.status === "Failed" || statusRes.body.status === "TimedOut") {
234
+ const elapsed = Date.now() - startTime;
235
+ log(C.red, prefix, ` ✗ ${ticketId} ${statusRes.body.status}: ${statusRes.body.errorMessage || "unknown"}`);
236
+ results.push({ apiPath, ticketId, status: statusRes.body.status, elapsed });
237
+ completed = true;
238
+ break;
239
+ }
240
+ }
241
+
242
+ if (!completed) {
243
+ log(C.red, prefix, ` ✗ ${ticketId} POLL TIMEOUT (30s)`);
244
+ results.push({ apiPath, ticketId, status: "poll_timeout", elapsed: Date.now() - startTime });
245
+ }
246
+
247
+ } catch (err) {
248
+ log(C.red, prefix, `Error: ${err.message}`);
249
+ results.push({ apiPath, status: "error", error: err.message, elapsed: Date.now() - startTime });
250
+ }
251
+
252
+ // Small stagger between requests from same agent (10-50ms)
253
+ await sleep(10 + Math.random() * 40);
254
+ }
255
+
256
+ log(color, prefix, `Finished — ${results.filter(r => r.status === "completed").length}/${REQUESTS_PER_AGENT} succeeded`);
257
+ return { agentId, agentIdx, results };
258
+ }
259
+
260
+ // ─── Queue Info Poller ───────────────────────────────────────────────
261
+ // Periodically checks queue state during the test
262
+ async function pollQueueInfo(abortSignal) {
263
+ while (!abortSignal.aborted) {
264
+ try {
265
+ const res = await httpRequest("GET", "/api/queue/info");
266
+ if (res.body.totalPending > 0) {
267
+ const agentInfo = res.body.perAgent
268
+ ? Object.entries(res.body.perAgent).map(([a, c]) => `${a.slice(-8)}:${c}`).join(", ")
269
+ : "n/a";
270
+ log(C.gray, "[QUEUE]", `Pending: ${res.body.totalPending} | Agents: ${res.body.activeAgents} | [${agentInfo}]`);
271
+ }
272
+ } catch { /* bridge might be between requests */ }
273
+ await sleep(500);
274
+ }
275
+ }
276
+
277
+ // ─── Main ────────────────────────────────────────────────────────────
278
+ async function main() {
279
+ console.log(`\n${C.bold}═══════════════════════════════════════════════════════════${C.reset}`);
280
+ console.log(`${C.bold} AB Unity MCP Multi-Agent Stress Test${C.reset}`);
281
+ console.log(`${C.bold}═══════════════════════════════════════════════════════════${C.reset}`);
282
+ console.log(` Agents: ${NUM_AGENTS} | Requests/agent: ${REQUESTS_PER_AGENT} | Total: ${NUM_AGENTS * REQUESTS_PER_AGENT}`);
283
+ console.log(` Mode: ${MOCK_MODE ? "MOCK (simulated bridge)" : "LIVE (real Unity bridge)"}`);
284
+ console.log(`${C.bold}═══════════════════════════════════════════════════════════${C.reset}\n`);
285
+
286
+ // Start mock if needed
287
+ if (MOCK_MODE) {
288
+ await startMockBridge();
289
+ } else {
290
+ // Verify bridge is running
291
+ try {
292
+ await httpRequest("GET", "/api/ping");
293
+ log(C.green, "[INIT]", "Unity bridge is reachable");
294
+ } catch {
295
+ console.error(`${C.red}ERROR: Unity bridge not reachable at ${BRIDGE_HOST}:${BRIDGE_PORT}`);
296
+ console.error(`Run with --mock to use a simulated bridge${C.reset}`);
297
+ process.exit(1);
298
+ }
299
+ }
300
+
301
+ // Generate agent IDs
302
+ const agents = Array.from({ length: NUM_AGENTS }, (_, i) => ({
303
+ idx: i,
304
+ id: `test-agent-${process.pid}-${randomBytes(3).toString("hex")}`,
305
+ }));
306
+
307
+ log(C.green, "[INIT]", `Agent IDs: ${agents.map(a => a.id.slice(-8)).join(", ")}`);
308
+
309
+ // Start queue info polling
310
+ const abortController = new AbortController();
311
+ const queuePollPromise = pollQueueInfo(abortController.signal);
312
+
313
+ // Launch all agents concurrently
314
+ const testStart = Date.now();
315
+ const agentPromises = agents.map((a) => simulateAgent(a.idx, a.id));
316
+ const allResults = await Promise.all(agentPromises);
317
+ const totalElapsed = Date.now() - testStart;
318
+
319
+ // Stop queue polling
320
+ abortController.abort();
321
+
322
+ // ── Summary ──
323
+ console.log(`\n${C.bold}═══════════════════════════════════════════════════════════${C.reset}`);
324
+ console.log(`${C.bold} TEST RESULTS${C.reset}`);
325
+ console.log(`${C.bold}═══════════════════════════════════════════════════════════${C.reset}\n`);
326
+
327
+ let totalCompleted = 0, totalFailed = 0, totalTimeout = 0;
328
+ const allElapsed = [];
329
+
330
+ for (const agentResult of allResults) {
331
+ const color = AGENT_COLORS[agentResult.agentIdx % AGENT_COLORS.length];
332
+ const completed = agentResult.results.filter(r => r.status === "completed");
333
+ const failed = agentResult.results.filter(r => r.status !== "completed");
334
+ totalCompleted += completed.length;
335
+ totalFailed += failed.filter(r => r.status !== "poll_timeout").length;
336
+ totalTimeout += failed.filter(r => r.status === "poll_timeout").length;
337
+
338
+ const times = completed.map(r => r.elapsed);
339
+ allElapsed.push(...times);
340
+ const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : 0;
341
+ const max = times.length ? Math.max(...times) : 0;
342
+ const min = times.length ? Math.min(...times) : 0;
343
+
344
+ console.log(`${color} Agent-${agentResult.agentIdx}${C.reset} (${agentResult.agentId.slice(-8)}): ` +
345
+ `${C.green}${completed.length} ok${C.reset} / ` +
346
+ `${failed.length > 0 ? C.red : C.gray}${failed.length} fail${C.reset} | ` +
347
+ `avg: ${avg}ms min: ${min}ms max: ${max}ms`);
348
+ }
349
+
350
+ console.log(`\n${C.bold} Totals:${C.reset}`);
351
+ console.log(` Completed: ${C.green}${totalCompleted}${C.reset} / ${NUM_AGENTS * REQUESTS_PER_AGENT}`);
352
+ if (totalFailed > 0) console.log(` Failed: ${C.red}${totalFailed}${C.reset}`);
353
+ if (totalTimeout > 0) console.log(` Timed out: ${C.red}${totalTimeout}${C.reset}`);
354
+ console.log(` Wall time: ${C.bold}${totalElapsed}ms${C.reset}`);
355
+
356
+ if (allElapsed.length > 0) {
357
+ allElapsed.sort((a, b) => a - b);
358
+ const p50 = allElapsed[Math.floor(allElapsed.length * 0.5)];
359
+ const p95 = allElapsed[Math.floor(allElapsed.length * 0.95)];
360
+ const p99 = allElapsed[Math.floor(allElapsed.length * 0.99)];
361
+ const avg = Math.round(allElapsed.reduce((a, b) => a + b, 0) / allElapsed.length);
362
+ console.log(`\n${C.bold} Latency (all requests):${C.reset}`);
363
+ console.log(` avg: ${avg}ms | p50: ${p50}ms | p95: ${p95}ms | p99: ${p99}ms`);
364
+ }
365
+
366
+ // Check fairness: did any agent get starved?
367
+ console.log(`\n${C.bold} Fairness Check:${C.reset}`);
368
+ const completionOrder = allResults.flatMap(r =>
369
+ r.results.filter(x => x.status === "completed").map(x => ({ agent: r.agentIdx, elapsed: x.elapsed }))
370
+ ).sort((a, b) => a.elapsed - b.elapsed);
371
+
372
+ // Group completion times into quartiles
373
+ const quartileSize = Math.ceil(completionOrder.length / 4);
374
+ for (let q = 0; q < 4; q++) {
375
+ const slice = completionOrder.slice(q * quartileSize, (q + 1) * quartileSize);
376
+ const agentCounts = {};
377
+ for (const item of slice) {
378
+ agentCounts[item.agent] = (agentCounts[item.agent] || 0) + 1;
379
+ }
380
+ const dist = Object.entries(agentCounts).map(([a, c]) => `A${a}:${c}`).join(" ");
381
+ console.log(` Q${q + 1}: ${dist}`);
382
+ }
383
+
384
+ console.log(`\n${C.bold}═══════════════════════════════════════════════════════════${C.reset}\n`);
385
+
386
+ // Get final queue info
387
+ try {
388
+ const finalInfo = await httpRequest("GET", "/api/queue/info");
389
+ log(C.gray, "[FINAL]", `Queue state: ${JSON.stringify(finalInfo.body)}`);
390
+ } catch { /* ok */ }
391
+
392
+ if (mockServer) mockServer.close();
393
+ process.exit(totalFailed + totalTimeout > 0 ? 1 : 0);
394
+ }
395
+
396
+ main().catch((err) => {
397
+ console.error(`${C.red}Fatal: ${err.message}${C.reset}`);
398
+ if (mockServer) mockServer.close();
399
+ process.exit(1);
400
+ });