@adiholick/nvstudio-mcp 2.2.2 → 2.3.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/README.md +43 -47
- package/agent-plugin/mcp_config.json +8 -0
- package/agent-plugin/plugin.json +6 -0
- package/agent-plugin/rules/AGENTS.md +87 -0
- package/agent-plugin/skills/nvstudio-assets-terrain-guide/SKILL.md +27 -0
- package/agent-plugin/skills/nvstudio-gui-standards/SKILL.md +419 -0
- package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +69 -0
- package/agent-plugin/skills/nvstudio-roblox-sync-guide/SKILL.md +16 -0
- package/dist/build-plugin.js +55 -0
- package/dist/index.js +988 -146
- package/dist/luau-validator.js +99 -34
- package/dist/roblox-bridge.js +390 -72
- package/dist/roblox-docs.js +264 -0
- package/dist/task-queue.js +57 -6
- package/dist/ws-gateway.js +579 -0
- package/package.json +7 -3
- package/public/index.html +634 -109
- package/studio-plugin/Main.server.lua +484 -53
- package/studio-plugin/batch_create_instances.lua +128 -0
- package/studio-plugin/batch_set_properties.lua +69 -0
- package/studio-plugin/get_properties.lua +25 -2
- package/studio-plugin/insert_asset.lua +85 -12
- package/studio-plugin/nvstudio_mcp.rbxmx +1393 -68
- package/studio-plugin/preview_asset.lua +99 -0
- package/studio-plugin/scan_security.lua +81 -0
- package/studio-plugin/security_scanner.lua +346 -0
- package/studio-plugin/set_properties.lua +10 -0
- package/studio-plugin/ui_manager.lua +1 -1
package/dist/roblox-bridge.js
CHANGED
|
@@ -4,7 +4,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.bridgeReadyPromise = exports.isBridgeHosting = void 0;
|
|
7
|
+
exports.removePendingStreamTask = removePendingStreamTask;
|
|
8
|
+
exports.checkPendingTasksOnStudioDisconnect = checkPendingTasksOnStudioDisconnect;
|
|
7
9
|
exports.isStudioConnected = isStudioConnected;
|
|
10
|
+
exports.getConnectedStudios = getConnectedStudios;
|
|
11
|
+
exports.getConnectedStudioCount = getConnectedStudioCount;
|
|
8
12
|
exports.setMcpConnected = setMcpConnected;
|
|
9
13
|
exports.startBridgeServer = startBridgeServer;
|
|
10
14
|
const express_1 = __importDefault(require("express"));
|
|
@@ -13,6 +17,8 @@ const socket_io_1 = require("socket.io");
|
|
|
13
17
|
const path_1 = __importDefault(require("path"));
|
|
14
18
|
const child_process_1 = require("child_process");
|
|
15
19
|
const task_queue_1 = require("./task-queue");
|
|
20
|
+
const ws_gateway_1 = require("./ws-gateway");
|
|
21
|
+
const roblox_docs_1 = require("./roblox-docs");
|
|
16
22
|
const { version: SERVER_VERSION } = require('../package.json');
|
|
17
23
|
exports.isBridgeHosting = false;
|
|
18
24
|
let resolveBridgeReady;
|
|
@@ -34,14 +40,43 @@ const sessionStart = new Date().toISOString();
|
|
|
34
40
|
// Per-studio pending events queue: events yang belum diambil oleh Studio
|
|
35
41
|
const studioEventQueues = new Map();
|
|
36
42
|
const studioLastSeen = new Map();
|
|
43
|
+
const studioPlaces = new Map();
|
|
37
44
|
const activeStudioPollers = new Map();
|
|
45
|
+
// Antrean global untuk task request yang menunggu poller long-polling (/api/stream)
|
|
46
|
+
const pendingStreamTasks = [];
|
|
47
|
+
function removePendingStreamTask(taskId) {
|
|
48
|
+
const idx = pendingStreamTasks.findIndex(t => t.requestId === taskId || t.taskId === taskId);
|
|
49
|
+
if (idx !== -1) {
|
|
50
|
+
pendingStreamTasks.splice(idx, 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
let disconnectCheckTimer = null;
|
|
54
|
+
function checkPendingTasksOnStudioDisconnect() {
|
|
55
|
+
if (disconnectCheckTimer)
|
|
56
|
+
clearTimeout(disconnectCheckTimer);
|
|
57
|
+
disconnectCheckTimer = setTimeout(() => {
|
|
58
|
+
disconnectCheckTimer = null;
|
|
59
|
+
if (!isStudioConnected() && (0, task_queue_1.hasPendingTasks)()) {
|
|
60
|
+
pendingStreamTasks.length = 0;
|
|
61
|
+
(0, task_queue_1.rejectAllPendingTasks)('Roblox Studio terputus sebelum merespons tugas.');
|
|
62
|
+
}
|
|
63
|
+
}, 2000);
|
|
64
|
+
}
|
|
38
65
|
setInterval(() => {
|
|
39
66
|
const now = Date.now();
|
|
40
67
|
let changed = false;
|
|
41
68
|
for (const [sid, lastSeen] of studioLastSeen.entries()) {
|
|
42
|
-
|
|
69
|
+
const isPollerActive = activeStudioPollers.has(sid);
|
|
70
|
+
if (!isPollerActive && now - lastSeen > 10000) {
|
|
43
71
|
studioEventQueues.delete(sid);
|
|
44
72
|
studioLastSeen.delete(sid);
|
|
73
|
+
studioPlaces.delete(sid);
|
|
74
|
+
changed = true;
|
|
75
|
+
}
|
|
76
|
+
else if (now - lastSeen > 25000) {
|
|
77
|
+
studioEventQueues.delete(sid);
|
|
78
|
+
studioLastSeen.delete(sid);
|
|
79
|
+
studioPlaces.delete(sid);
|
|
45
80
|
const poller = activeStudioPollers.get(sid);
|
|
46
81
|
if (poller) {
|
|
47
82
|
clearTimeout(poller.timer);
|
|
@@ -56,10 +91,84 @@ setInterval(() => {
|
|
|
56
91
|
}
|
|
57
92
|
if (changed) {
|
|
58
93
|
broadcastStatus();
|
|
94
|
+
if (!isStudioConnected() && (0, task_queue_1.hasPendingTasks)()) {
|
|
95
|
+
checkPendingTasksOnStudioDisconnect();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}, 2000);
|
|
99
|
+
function isStudioConnected(filter) {
|
|
100
|
+
if (filter?.studioId) {
|
|
101
|
+
if ((0, ws_gateway_1.hasActiveWsPeer)({ studioId: filter.studioId }))
|
|
102
|
+
return true;
|
|
103
|
+
if (activeStudioPollers.has(filter.studioId))
|
|
104
|
+
return true;
|
|
105
|
+
const seen = studioLastSeen.get(filter.studioId);
|
|
106
|
+
if (seen && Date.now() - seen <= 5000)
|
|
107
|
+
return true;
|
|
108
|
+
return false;
|
|
59
109
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
110
|
+
if (filter?.placeId !== undefined && filter?.placeId !== null) {
|
|
111
|
+
if ((0, ws_gateway_1.hasActiveWsPeer)({ placeId: filter.placeId }))
|
|
112
|
+
return true;
|
|
113
|
+
for (const [sid, pid] of studioPlaces.entries()) {
|
|
114
|
+
if (pid === filter.placeId) {
|
|
115
|
+
if (activeStudioPollers.has(sid))
|
|
116
|
+
return true;
|
|
117
|
+
const seen = studioLastSeen.get(sid);
|
|
118
|
+
if (seen && Date.now() - seen <= 5000)
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if ((0, ws_gateway_1.hasActiveWsPeer)())
|
|
125
|
+
return true;
|
|
126
|
+
if (activeStudioPollers.size > 0)
|
|
127
|
+
return true;
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
for (const lastSeen of studioLastSeen.values()) {
|
|
130
|
+
if (now - lastSeen <= 3000)
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
function getConnectedStudios() {
|
|
136
|
+
const list = [];
|
|
137
|
+
const wsDetails = (0, ws_gateway_1.getWsPeersDetails)();
|
|
138
|
+
const wsStudioIds = new Set();
|
|
139
|
+
for (const p of wsDetails) {
|
|
140
|
+
wsStudioIds.add(p.studioId);
|
|
141
|
+
list.push({
|
|
142
|
+
studioId: p.studioId,
|
|
143
|
+
placeId: p.placeId,
|
|
144
|
+
transport: 'websocket',
|
|
145
|
+
lastSeen: p.lastSeen,
|
|
146
|
+
isAlive: p.isAlive && !p.unresponsive,
|
|
147
|
+
isBusy: p.isBusy,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
for (const [sid, lastSeen] of studioLastSeen.entries()) {
|
|
152
|
+
if (!wsStudioIds.has(sid) && (activeStudioPollers.has(sid) || now - lastSeen <= 5000)) {
|
|
153
|
+
list.push({
|
|
154
|
+
studioId: sid,
|
|
155
|
+
placeId: studioPlaces.get(sid),
|
|
156
|
+
transport: 'long-polling',
|
|
157
|
+
lastSeen,
|
|
158
|
+
isAlive: true,
|
|
159
|
+
isBusy: false,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return list;
|
|
164
|
+
}
|
|
165
|
+
function getConnectedStudioCount() {
|
|
166
|
+
const ids = new Set();
|
|
167
|
+
for (const sid of studioEventQueues.keys())
|
|
168
|
+
ids.add(sid);
|
|
169
|
+
for (const sid of (0, ws_gateway_1.getWsStudioIds)())
|
|
170
|
+
ids.add(sid);
|
|
171
|
+
return Math.max(ids.size, isStudioConnected() ? 1 : 0);
|
|
63
172
|
}
|
|
64
173
|
function flushEventsToStudio(sid) {
|
|
65
174
|
const poller = activeStudioPollers.get(sid);
|
|
@@ -71,9 +180,10 @@ function flushEventsToStudio(sid) {
|
|
|
71
180
|
studioEventQueues.set(sid, []); // kosongkan antrian
|
|
72
181
|
clearTimeout(poller.timer);
|
|
73
182
|
activeStudioPollers.delete(sid);
|
|
183
|
+
const nonStatusEvents = queue.filter(ev => ev.kind !== 'status');
|
|
74
184
|
const events = [
|
|
75
185
|
{ kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
|
|
76
|
-
...
|
|
186
|
+
...nonStatusEvents
|
|
77
187
|
];
|
|
78
188
|
try {
|
|
79
189
|
poller.res.setHeader('Content-Type', 'text/plain');
|
|
@@ -98,7 +208,7 @@ function enqueueForStudio(studioId, event) {
|
|
|
98
208
|
flushEventsToStudio(studioId);
|
|
99
209
|
}
|
|
100
210
|
else {
|
|
101
|
-
// Broadcast ke semua studio yang terdaftar
|
|
211
|
+
// Broadcast non-task (status, heartbeat) ke semua studio yang terdaftar
|
|
102
212
|
for (const [sid, q] of studioEventQueues.entries()) {
|
|
103
213
|
q.push(event);
|
|
104
214
|
if (q.length > 100)
|
|
@@ -140,26 +250,91 @@ function sendEvent(peer, kind, payload) {
|
|
|
140
250
|
* Push status koneksi ke semua peer (apakah mcpConnected, jumlah peer aktif dll).
|
|
141
251
|
*/
|
|
142
252
|
function broadcastStatus() {
|
|
253
|
+
const studioCount = getConnectedStudioCount();
|
|
143
254
|
const statusEvent = {
|
|
144
255
|
mcpConnected,
|
|
145
|
-
studioCount
|
|
256
|
+
studioCount,
|
|
146
257
|
serverVersion: SERVER_VERSION,
|
|
147
258
|
};
|
|
148
259
|
broadcastEvent('status', statusEvent);
|
|
260
|
+
(0, ws_gateway_1.broadcastWsEvent)('status', statusEvent);
|
|
149
261
|
}
|
|
150
262
|
/**
|
|
151
|
-
* Push task ke
|
|
152
|
-
*
|
|
263
|
+
* Push task ke Studio peer (dengan routing studioId/placeId).
|
|
264
|
+
* Mengembalikan true jika berhasil dikirim langsung ke poller aktif, false jika diantrekan (Bug F).
|
|
153
265
|
*/
|
|
154
266
|
function broadcastTask(task) {
|
|
267
|
+
const totalBudget = task.remainingMs ?? 30000;
|
|
268
|
+
const elapsed = task.createdAt ? (Date.now() - task.createdAt) : 0;
|
|
269
|
+
const remainingMs = Math.max(1000, totalBudget - elapsed);
|
|
270
|
+
// Bug A & E: Stamp attempt counter dan channel pengiriman
|
|
271
|
+
const attempt = (task.attempt || 0) + 1;
|
|
272
|
+
task.attempt = attempt;
|
|
273
|
+
task.dispatchedAt = Date.now();
|
|
274
|
+
task.deliveredVia = 'stream';
|
|
155
275
|
const taskEvent = {
|
|
276
|
+
kind: 'request',
|
|
156
277
|
requestId: task.id,
|
|
278
|
+
taskId: task.id,
|
|
279
|
+
attempt,
|
|
157
280
|
command: task.command,
|
|
158
281
|
target: task.target,
|
|
159
282
|
data: task.data ?? null,
|
|
160
|
-
|
|
283
|
+
studioId: task.studioId,
|
|
284
|
+
placeId: task.placeId,
|
|
285
|
+
remainingMs,
|
|
161
286
|
};
|
|
162
|
-
|
|
287
|
+
// 1. Coba kirim langsung ke poller aktif yang cocok (hanging GET)
|
|
288
|
+
let matchingSid = null;
|
|
289
|
+
if (task.studioId && activeStudioPollers.has(task.studioId)) {
|
|
290
|
+
matchingSid = task.studioId;
|
|
291
|
+
}
|
|
292
|
+
else if (task.placeId !== undefined && task.placeId !== null) {
|
|
293
|
+
for (const sid of activeStudioPollers.keys()) {
|
|
294
|
+
if (studioPlaces.get(sid) === task.placeId) {
|
|
295
|
+
matchingSid = sid;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else if (!task.studioId && task.placeId === undefined && activeStudioPollers.size > 0) {
|
|
301
|
+
let bestSid = null;
|
|
302
|
+
let latestTime = -1;
|
|
303
|
+
for (const sid of activeStudioPollers.keys()) {
|
|
304
|
+
const seen = studioLastSeen.get(sid) ?? 0;
|
|
305
|
+
if (seen > latestTime) {
|
|
306
|
+
latestTime = seen;
|
|
307
|
+
bestSid = sid;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
matchingSid = bestSid || activeStudioPollers.keys().next().value;
|
|
311
|
+
}
|
|
312
|
+
if (matchingSid && activeStudioPollers.has(matchingSid)) {
|
|
313
|
+
const poller = activeStudioPollers.get(matchingSid);
|
|
314
|
+
clearTimeout(poller.timer);
|
|
315
|
+
activeStudioPollers.delete(matchingSid);
|
|
316
|
+
studioLastSeen.set(matchingSid, Date.now());
|
|
317
|
+
const events = [
|
|
318
|
+
{ kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
|
|
319
|
+
taskEvent
|
|
320
|
+
];
|
|
321
|
+
try {
|
|
322
|
+
poller.res.setHeader('Content-Type', 'text/plain');
|
|
323
|
+
poller.res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
324
|
+
const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
|
|
325
|
+
poller.res.send(body);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// Jika pengiriman langsung gagal, masukkan ke antrean pending
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// 2. Jika tidak ada poller yang sedang hanging (jeda antar-request), masukkan ke antrean
|
|
333
|
+
pendingStreamTasks.push(taskEvent);
|
|
334
|
+
if (pendingStreamTasks.length > 50) {
|
|
335
|
+
pendingStreamTasks.shift();
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
163
338
|
}
|
|
164
339
|
// ── Logging ───────────────────────────────────────────────────────────────────
|
|
165
340
|
let logCounter = 0;
|
|
@@ -173,8 +348,20 @@ function addLog(io, type, message) {
|
|
|
173
348
|
activityLogs.unshift(log);
|
|
174
349
|
if (activityLogs.length > 200)
|
|
175
350
|
activityLogs.pop();
|
|
176
|
-
io.
|
|
177
|
-
|
|
351
|
+
if (io.engine && io.engine.clientsCount > 0) {
|
|
352
|
+
io.emit('log', log);
|
|
353
|
+
io.emit('stats', {
|
|
354
|
+
totalTasks,
|
|
355
|
+
successCount,
|
|
356
|
+
errorCount,
|
|
357
|
+
sessionStart,
|
|
358
|
+
studioConnected: isStudioConnected(),
|
|
359
|
+
studioCount: getConnectedStudioCount(),
|
|
360
|
+
wsStudios: (0, ws_gateway_1.getWsPeerCount)(),
|
|
361
|
+
connectedStudios: getConnectedStudios(),
|
|
362
|
+
version: SERVER_VERSION
|
|
363
|
+
});
|
|
364
|
+
}
|
|
178
365
|
}
|
|
179
366
|
/**
|
|
180
367
|
* Membunuh proses zombie yang masih menyandera port tertentu.
|
|
@@ -222,6 +409,62 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
222
409
|
const app = (0, express_1.default)();
|
|
223
410
|
const httpServer = (0, http_1.createServer)(app);
|
|
224
411
|
const io = new socket_io_1.Server(httpServer, { cors: { origin: '*' } });
|
|
412
|
+
function handleTaskResponse(taskId, status, result, error) {
|
|
413
|
+
if (status === 'success') {
|
|
414
|
+
successCount++;
|
|
415
|
+
addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
errorCount++;
|
|
419
|
+
addLog(io, 'error', `Gagal: ${error}`);
|
|
420
|
+
}
|
|
421
|
+
removePendingStreamTask(taskId);
|
|
422
|
+
(0, ws_gateway_1.cancelTaskDeadline)(taskId);
|
|
423
|
+
(0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
|
|
424
|
+
}
|
|
425
|
+
// Pasang WebSocket Gateway untuk duplex communication instan (/ws)
|
|
426
|
+
(0, ws_gateway_1.attachWsGateway)(httpServer, {
|
|
427
|
+
onTaskResponse: (taskId, status, result, error) => {
|
|
428
|
+
handleTaskResponse(taskId, status, result, error);
|
|
429
|
+
},
|
|
430
|
+
onLog: (type, message) => {
|
|
431
|
+
addLog(io, type, message);
|
|
432
|
+
},
|
|
433
|
+
onStudioStatusChange: () => {
|
|
434
|
+
const connected = isStudioConnected();
|
|
435
|
+
const studioCount = getConnectedStudioCount();
|
|
436
|
+
io.emit('studio-status', {
|
|
437
|
+
connected,
|
|
438
|
+
studioCount,
|
|
439
|
+
connectedStudios: getConnectedStudios(),
|
|
440
|
+
version: SERVER_VERSION
|
|
441
|
+
});
|
|
442
|
+
broadcastStatus();
|
|
443
|
+
if (!connected && (0, task_queue_1.hasPendingTasks)()) {
|
|
444
|
+
checkPendingTasksOnStudioDisconnect();
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
getServerVersion: () => SERVER_VERSION,
|
|
448
|
+
isMcpConnected: () => mcpConnected,
|
|
449
|
+
onRedispatchTask: (task, failedStudioId) => {
|
|
450
|
+
// Coba re-dispatch ke peer WS lain jika ada yang cocok
|
|
451
|
+
const nextPeer = (0, ws_gateway_1.selectWsPeer)({
|
|
452
|
+
placeId: task.placeId,
|
|
453
|
+
});
|
|
454
|
+
if (nextPeer && nextPeer.studioId !== failedStudioId) {
|
|
455
|
+
const ok = (0, ws_gateway_1.sendTaskViaWs)(task, nextPeer.studioId);
|
|
456
|
+
if (ok)
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
// Fallback: hanya ke long-polling jika ada poller yang sedang hanging aktif (Bug F)
|
|
460
|
+
if (activeStudioPollers.size > 0) {
|
|
461
|
+
const delivered = broadcastTask(task);
|
|
462
|
+
if (delivered)
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
return false;
|
|
466
|
+
},
|
|
467
|
+
});
|
|
225
468
|
app.use(express_1.default.json({ limit: '50mb' }));
|
|
226
469
|
app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
|
|
227
470
|
let dashboardOpened = false;
|
|
@@ -229,30 +472,64 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
229
472
|
io.on('connection', (socket) => {
|
|
230
473
|
socket.emit('init', {
|
|
231
474
|
logs: activityLogs,
|
|
232
|
-
stats: {
|
|
475
|
+
stats: {
|
|
476
|
+
totalTasks,
|
|
477
|
+
successCount,
|
|
478
|
+
errorCount,
|
|
479
|
+
sessionStart,
|
|
480
|
+
studioConnected: isStudioConnected(),
|
|
481
|
+
studioCount: getConnectedStudioCount(),
|
|
482
|
+
wsStudios: (0, ws_gateway_1.getWsPeerCount)(),
|
|
483
|
+
port,
|
|
484
|
+
version: SERVER_VERSION,
|
|
485
|
+
connectedStudios: getConnectedStudios(),
|
|
486
|
+
},
|
|
233
487
|
});
|
|
234
488
|
});
|
|
489
|
+
// ── /api/docs (Endpoint pencarian dokumentasi engine untuk web dashboard) ──
|
|
490
|
+
app.get('/api/docs', async (req, res) => {
|
|
491
|
+
try {
|
|
492
|
+
const query = String(req.query.query || '').trim();
|
|
493
|
+
const member = req.query.member ? String(req.query.member).trim() : undefined;
|
|
494
|
+
const category = req.query.category ? String(req.query.category).trim() : 'auto';
|
|
495
|
+
if (!query) {
|
|
496
|
+
return res.status(400).json({ error: "Parameter 'query' wajib diisi." });
|
|
497
|
+
}
|
|
498
|
+
const result = await (0, roblox_docs_1.getRobloxDocs)(query, member, category);
|
|
499
|
+
res.json({ success: true, result });
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
res.status(500).json({ error: err.message });
|
|
503
|
+
}
|
|
504
|
+
});
|
|
235
505
|
// ── /api/stats ─────────────────────────────────────────────────────────────
|
|
236
506
|
app.get('/api/stats', (req, res) => {
|
|
237
|
-
const activeStudios =
|
|
507
|
+
const activeStudios = getConnectedStudioCount();
|
|
508
|
+
const wsStudios = (0, ws_gateway_1.getWsPeerCount)();
|
|
238
509
|
res.json({
|
|
239
510
|
totalTasks, successCount, errorCount, sessionStart,
|
|
240
|
-
studioConnected:
|
|
511
|
+
studioConnected: isStudioConnected(),
|
|
241
512
|
studioCount: activeStudios,
|
|
513
|
+
wsConnectedCount: wsStudios,
|
|
514
|
+
transport: wsStudios > 0 ? 'websocket' : (activeStudioPollers.size > 0 ? 'long-polling' : 'none'),
|
|
242
515
|
mcpConnected,
|
|
243
516
|
port,
|
|
517
|
+
connectedStudios: getConnectedStudios(),
|
|
244
518
|
});
|
|
245
519
|
});
|
|
246
520
|
// ── /api/ping ─────────────────────────────────────────────────────────────
|
|
247
521
|
app.get('/api/ping', (req, res) => {
|
|
248
522
|
const uptime = Math.floor((Date.now() - new Date(sessionStart).getTime()) / 1000);
|
|
249
|
-
const activeStudios =
|
|
523
|
+
const activeStudios = getConnectedStudioCount();
|
|
524
|
+
const wsStudios = (0, ws_gateway_1.getWsPeerCount)();
|
|
250
525
|
res.json({
|
|
251
526
|
status: 'ok',
|
|
252
527
|
version: SERVER_VERSION,
|
|
253
528
|
uptimeSeconds: uptime,
|
|
254
529
|
totalTasksProcessed: totalTasks,
|
|
255
|
-
activeStudios: activeStudios
|
|
530
|
+
activeStudios: activeStudios,
|
|
531
|
+
wsStudios: wsStudios,
|
|
532
|
+
dualMode: true,
|
|
256
533
|
});
|
|
257
534
|
});
|
|
258
535
|
// ── /api/stream (True Long-Polling endpoint untuk Roblox GetAsync) ─────
|
|
@@ -262,12 +539,23 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
262
539
|
// atau timeout 15 detik (heartbeat) untuk menjaga koneksi tetap segar.
|
|
263
540
|
app.get('/api/stream', (req, res) => {
|
|
264
541
|
const studioId = req.query.studioId || `anon-${Date.now()}`;
|
|
542
|
+
const placeIdStr = req.query.placeId;
|
|
543
|
+
const placeId = placeIdStr !== undefined && placeIdStr !== '' ? parseInt(placeIdStr, 10) : undefined;
|
|
544
|
+
if (placeId !== undefined && !isNaN(placeId)) {
|
|
545
|
+
studioPlaces.set(studioId, placeId);
|
|
546
|
+
}
|
|
265
547
|
studioLastSeen.set(studioId, Date.now());
|
|
266
548
|
// Daftarkan studio jika belum ada
|
|
267
549
|
if (!studioEventQueues.has(studioId)) {
|
|
268
550
|
studioEventQueues.set(studioId, []);
|
|
269
|
-
io.emit('studio-status', {
|
|
270
|
-
|
|
551
|
+
io.emit('studio-status', {
|
|
552
|
+
connected: true,
|
|
553
|
+
studioCount: Math.max(studioEventQueues.size, 1),
|
|
554
|
+
studioId,
|
|
555
|
+
connectedStudios: getConnectedStudios(),
|
|
556
|
+
version: SERVER_VERSION
|
|
557
|
+
});
|
|
558
|
+
addLog(io, 'system', `Studio terdaftar. (ID: ${studioId.substring(0, 8)}…, Place: ${placeId ?? 'Local'}, Total: ${studioEventQueues.size})`);
|
|
271
559
|
if (!isDaemon && !dashboardOpened) {
|
|
272
560
|
dashboardOpened = true;
|
|
273
561
|
const url = `http://localhost:${port}`;
|
|
@@ -288,29 +576,45 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
288
576
|
catch { }
|
|
289
577
|
activeStudioPollers.delete(studioId);
|
|
290
578
|
}
|
|
291
|
-
//
|
|
579
|
+
// 1. Prioritas Utama: Cek apakah ada task AI yang cocok sedang menunggu poller Studio ini (Bug #3 Routing)
|
|
580
|
+
const matchIdx = pendingStreamTasks.findIndex((t) => {
|
|
581
|
+
if (t.studioId && t.studioId !== studioId)
|
|
582
|
+
return false;
|
|
583
|
+
if (t.placeId !== undefined && t.placeId !== null && placeId !== undefined && t.placeId !== placeId)
|
|
584
|
+
return false;
|
|
585
|
+
return true;
|
|
586
|
+
});
|
|
587
|
+
if (matchIdx !== -1) {
|
|
588
|
+
const taskEvent = pendingStreamTasks.splice(matchIdx, 1)[0];
|
|
589
|
+
const events = [
|
|
590
|
+
{ kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
|
|
591
|
+
taskEvent
|
|
592
|
+
];
|
|
593
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
594
|
+
res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
595
|
+
const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
|
|
596
|
+
return res.send(body);
|
|
597
|
+
}
|
|
598
|
+
// 2. Kumpulkan semua events non-task yang belum dikirim ke studio ini
|
|
292
599
|
const queue = studioEventQueues.get(studioId) ?? [];
|
|
293
600
|
if (queue.length > 0) {
|
|
294
601
|
studioEventQueues.set(studioId, []); // kosongkan antrian
|
|
295
602
|
const events = [
|
|
296
|
-
{ kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION },
|
|
603
|
+
{ kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION },
|
|
604
|
+
...queue.filter(ev => ev.kind !== 'status')
|
|
297
605
|
];
|
|
298
|
-
for (const ev of queue) {
|
|
299
|
-
if (ev.kind !== 'status')
|
|
300
|
-
events.push(ev);
|
|
301
|
-
}
|
|
302
606
|
res.setHeader('Content-Type', 'text/plain');
|
|
303
607
|
res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
304
608
|
const body = events.map(ev => `data: ${JSON.stringify(ev)}`).join('\n');
|
|
305
609
|
return res.send(body);
|
|
306
610
|
}
|
|
307
|
-
// Jika antrean kosong, TAHAN koneksi (Long-Polling) maksimal 15 detik
|
|
611
|
+
// 3. Jika antrean kosong, TAHAN koneksi (Long-Polling) maksimal 15 detik
|
|
308
612
|
const timer = setTimeout(() => {
|
|
309
613
|
activeStudioPollers.delete(studioId);
|
|
310
614
|
try {
|
|
311
615
|
const events = [
|
|
312
616
|
{ kind: 'heartbeat', timestamp: Date.now() },
|
|
313
|
-
{ kind: 'status', mcpConnected, studioCount: studioEventQueues.size, serverVersion: SERVER_VERSION }
|
|
617
|
+
{ kind: 'status', mcpConnected, studioCount: Math.max(studioEventQueues.size, 1), serverVersion: SERVER_VERSION }
|
|
314
618
|
];
|
|
315
619
|
res.setHeader('Content-Type', 'text/plain');
|
|
316
620
|
res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
@@ -320,12 +624,21 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
320
624
|
catch { }
|
|
321
625
|
}, 15000);
|
|
322
626
|
activeStudioPollers.set(studioId, { res, timer });
|
|
323
|
-
|
|
627
|
+
res.on('close', () => {
|
|
324
628
|
const current = activeStudioPollers.get(studioId);
|
|
325
629
|
if (current && current.res === res) {
|
|
326
630
|
clearTimeout(current.timer);
|
|
327
631
|
activeStudioPollers.delete(studioId);
|
|
328
632
|
}
|
|
633
|
+
if (!res.writableEnded) {
|
|
634
|
+
// Client menutup koneksi HTTP sebelum respons dikirim (Studio ditutup/crash)
|
|
635
|
+
studioLastSeen.delete(studioId);
|
|
636
|
+
studioEventQueues.delete(studioId);
|
|
637
|
+
broadcastStatus();
|
|
638
|
+
if ((0, task_queue_1.hasPendingTasks)()) {
|
|
639
|
+
checkPendingTasksOnStudioDisconnect();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
329
642
|
});
|
|
330
643
|
});
|
|
331
644
|
// ── /api/events (SSE persisten untuk browser/klien modern) ───────────────
|
|
@@ -365,98 +678,103 @@ function startBridgeServer(port, isDaemon = false) {
|
|
|
365
678
|
}
|
|
366
679
|
catch { /* ignore */ }
|
|
367
680
|
}
|
|
368
|
-
|
|
369
|
-
|
|
681
|
+
res.on('close', cleanup);
|
|
682
|
+
res.on('error', cleanup);
|
|
370
683
|
});
|
|
371
|
-
// ── /api/response/:taskId (Studio POSTs hasil eksekusi)
|
|
684
|
+
// ── /api/response/:taskId (Studio POSTs hasil eksekusi) (Bug H) ─────────
|
|
372
685
|
app.post('/api/response/:taskId', (req, res) => {
|
|
373
|
-
const
|
|
686
|
+
const taskId = req.params.taskId || req.body?.taskId || req.body?.id;
|
|
374
687
|
const { status, result, error } = req.body;
|
|
375
|
-
|
|
376
|
-
successCount++;
|
|
377
|
-
addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
|
|
378
|
-
}
|
|
379
|
-
else {
|
|
380
|
-
errorCount++;
|
|
381
|
-
addLog(io, 'error', `Gagal: ${error}`);
|
|
382
|
-
}
|
|
383
|
-
(0, task_queue_1.resolvePendingTask)(taskId, status, result, error);
|
|
688
|
+
handleTaskResponse(taskId, status, result, error);
|
|
384
689
|
res.json({ ok: true });
|
|
385
690
|
});
|
|
386
|
-
// ── /api/response (legacy
|
|
691
|
+
// ── /api/response (legacy & fallback) (Bug H) ───────────────────────────
|
|
387
692
|
app.post('/api/response', (req, res) => {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
addLog(io, 'response', `Perintah selesai: ${String(result ?? '').substring(0, 120)}`);
|
|
392
|
-
}
|
|
393
|
-
else {
|
|
394
|
-
errorCount++;
|
|
395
|
-
addLog(io, 'error', `Gagal: ${error}`);
|
|
396
|
-
}
|
|
397
|
-
(0, task_queue_1.resolvePendingTask)(id, status, result, error);
|
|
693
|
+
const taskId = req.body?.taskId || req.body?.id || req.query?.taskId;
|
|
694
|
+
const { status, result, error } = req.body;
|
|
695
|
+
handleTaskResponse(taskId, status, result, error);
|
|
398
696
|
res.json({ ok: true });
|
|
399
697
|
});
|
|
400
|
-
// ── Notifikasi task baru ke Dashboard
|
|
401
|
-
// Pengiriman task ke Studio dilakukan via /api/stream (snapshot polling).
|
|
402
|
-
// Di sini kita hanya log ke dashboard browser.
|
|
698
|
+
// ── Notifikasi task baru ke Dashboard & Dispatch ke Studio ────────────────
|
|
403
699
|
task_queue_1.taskEmitter.on('new_task', (task) => {
|
|
404
700
|
totalTasks++;
|
|
405
701
|
addLog(io, 'task', `Task dikirim ke Studio: '${task.command}'${task.target ? ` → ${task.target}` : ''}`);
|
|
702
|
+
// Prioritas 1: Push via WebSocket instan duplex jika Studio aktif terhubung via WS
|
|
703
|
+
const sentWs = (0, ws_gateway_1.sendTaskViaWs)(task);
|
|
704
|
+
if (sentWs) {
|
|
705
|
+
task.deliveredVia = 'ws';
|
|
706
|
+
return; // Berhasil terkirim instan via WebSocket! Tidak perlu enqueue ke long-polling.
|
|
707
|
+
}
|
|
708
|
+
// Prioritas 2 (Fallback): Enqueue ke Long-Polling /api/stream
|
|
406
709
|
broadcastTask(task);
|
|
407
710
|
});
|
|
408
711
|
// ── /api/tasks/enqueue (dari proses MCP eksternal / bridge forwarding) ────
|
|
409
712
|
app.post('/api/tasks/enqueue', async (req, res) => {
|
|
410
|
-
const { command, target, data } = req.body;
|
|
411
|
-
// Fast-check: pastikan
|
|
412
|
-
if (!isStudioConnected()) {
|
|
413
|
-
for (let i = 0; i <
|
|
713
|
+
const { command, target, data, placeId, studioId, remainingMs } = req.body;
|
|
714
|
+
// Fast-check: pastikan Studio yang cocok aktif polling / websocket (Bug #3)
|
|
715
|
+
if (!isStudioConnected({ placeId, studioId })) {
|
|
716
|
+
for (let i = 0; i < 10; i++) {
|
|
414
717
|
await new Promise(r => setTimeout(r, 50));
|
|
415
|
-
if (isStudioConnected())
|
|
718
|
+
if (isStudioConnected({ placeId, studioId }))
|
|
416
719
|
break;
|
|
417
720
|
}
|
|
418
|
-
if (!isStudioConnected()) {
|
|
721
|
+
if (!isStudioConnected({ placeId, studioId })) {
|
|
419
722
|
return res.json({
|
|
420
723
|
status: 'error',
|
|
421
|
-
error:
|
|
724
|
+
error: placeId !== undefined
|
|
725
|
+
? `Roblox Studio dengan Place ID ${placeId} tidak terdeteksi aktif.`
|
|
726
|
+
: 'Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.'
|
|
422
727
|
});
|
|
423
728
|
}
|
|
424
729
|
}
|
|
425
730
|
try {
|
|
426
|
-
const result = await (0, task_queue_1.addTaskToQueue)(command, target, data);
|
|
731
|
+
const result = await (0, task_queue_1.addTaskToQueue)(command, target, data, { placeId, studioId, remainingMs });
|
|
427
732
|
res.json({ status: 'success', result });
|
|
428
733
|
}
|
|
429
734
|
catch (err) {
|
|
430
|
-
res.json({ status: 'error', error: err
|
|
735
|
+
res.json({ status: 'error', error: err?.message || String(err) });
|
|
431
736
|
}
|
|
432
737
|
});
|
|
433
738
|
// ── /api/tasks (legacy long-poll — dipertahankan untuk backward-compat) ───
|
|
434
739
|
// Studio lama yang belum update plugin masih bisa bekerja via polling.
|
|
740
|
+
// Menggunakan claimTask secara atomik untuk mencegah perebutan task (Bug D & E).
|
|
435
741
|
const waitingPollers = [];
|
|
436
742
|
task_queue_1.taskEmitter.on('new_task', (task) => {
|
|
743
|
+
if (task.deliveredVia)
|
|
744
|
+
return; // Abaikan jika task sudah terkirim via WS atau Stream
|
|
437
745
|
if (waitingPollers.length > 0) {
|
|
746
|
+
const claimed = (0, task_queue_1.claimTask)(task.id, 'legacy');
|
|
747
|
+
if (!claimed)
|
|
748
|
+
return;
|
|
438
749
|
const pollRes = waitingPollers.shift();
|
|
439
750
|
const index = task_queue_1.taskQueue.findIndex(t => t.id === task.id);
|
|
440
751
|
if (index !== -1)
|
|
441
752
|
task_queue_1.taskQueue.splice(index, 1);
|
|
753
|
+
removePendingStreamTask(task.id);
|
|
442
754
|
for (const queue of studioEventQueues.values()) {
|
|
443
755
|
const qIndex = queue.findIndex((e) => e.requestId === task.id);
|
|
444
756
|
if (qIndex !== -1)
|
|
445
757
|
queue.splice(qIndex, 1);
|
|
446
758
|
}
|
|
447
759
|
addLog(io, 'task', `[Legacy] Mengirim perintah '${task.command}' ke Studio.`);
|
|
448
|
-
pollRes?.json(
|
|
760
|
+
pollRes?.json(claimed);
|
|
449
761
|
}
|
|
450
762
|
});
|
|
451
763
|
app.get('/api/tasks', (req, res) => {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
764
|
+
// Cari task pertama yang belum diklaim oleh transport mana pun
|
|
765
|
+
const unclaimedIdx = task_queue_1.taskQueue.findIndex(t => !t.deliveredVia);
|
|
766
|
+
if (unclaimedIdx !== -1) {
|
|
767
|
+
const candidate = task_queue_1.taskQueue[unclaimedIdx];
|
|
768
|
+
const claimed = (0, task_queue_1.claimTask)(candidate.id, 'legacy');
|
|
769
|
+
if (claimed) {
|
|
770
|
+
task_queue_1.taskQueue.splice(unclaimedIdx, 1);
|
|
771
|
+
removePendingStreamTask(claimed.id);
|
|
772
|
+
addLog(io, 'task', `[Legacy] Mengirim perintah tertunda '${claimed.command}' ke Studio.`);
|
|
773
|
+
return res.json(claimed);
|
|
774
|
+
}
|
|
457
775
|
}
|
|
458
776
|
waitingPollers.push(res);
|
|
459
|
-
|
|
777
|
+
res.on('close', () => {
|
|
460
778
|
const index = waitingPollers.indexOf(res);
|
|
461
779
|
if (index !== -1)
|
|
462
780
|
waitingPollers.splice(index, 1);
|