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