@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.
@@ -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
- if (now - lastSeen > 35000) {
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
- }, 5000);
61
- function isStudioConnected() {
62
- return studioEventQueues.size > 0 || activeStudioPollers.size > 0;
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
- ...queue
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: studioEventQueues.size,
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 semua Studio peer (broadcast ke semua Studio yang terbuka).
152
- * Hanya satu yang akan memprosesnya (first-come first-served via requestId).
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
- remainingMs: 29000,
282
+ studioId: task.studioId,
283
+ placeId: task.placeId,
284
+ remainingMs,
161
285
  };
162
- enqueueForStudio(null, { kind: 'request', ...taskEvent });
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.emit('log', log);
177
- io.emit('stats', { totalTasks, successCount, errorCount, sessionStart });
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: { totalTasks, successCount, errorCount, sessionStart, studioConnected: studioEventQueues.size > 0, port },
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 = studioEventQueues.size;
473
+ const activeStudios = getConnectedStudioCount();
474
+ const wsStudios = (0, ws_gateway_1.getWsPeerCount)();
238
475
  res.json({
239
476
  totalTasks, successCount, errorCount, sessionStart,
240
- studioConnected: activeStudios > 0,
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 = studioEventQueues.size;
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
- // Kumpulkan semua events yang belum dikirim ke studio ini
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
- req.on('close', () => {
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
- req.on('close', cleanup);
369
- req.on('error', cleanup);
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 { taskId } = req.params;
646
+ const taskId = req.params.taskId || req.body?.taskId || req.body?.id;
374
647
  const { status, result, error } = req.body;
375
- if (status === 'success') {
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 — lama, tetap didukung) ────────────────────────
651
+ // ── /api/response (legacy & fallback) (Bug H) ───────────────────────────
387
652
  app.post('/api/response', (req, res) => {
388
- const { id, status, result, error } = req.body;
389
- if (status === 'success') {
390
- successCount++;
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 setidaknya 1 Studio aktif polling
412
- if (!isStudioConnected()) {
413
- for (let i = 0; i < 6; 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: 'Roblox Studio tidak terdeteksi aktif. Pastikan Roblox Studio dibuka, place telah dimuat, dan plugin nvstudio-mcp dalam status terhubung.'
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.message });
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(task);
720
+ pollRes?.json(claimed);
449
721
  }
450
722
  });
451
723
  app.get('/api/tasks', (req, res) => {
452
- if (task_queue_1.taskQueue.length > 0) {
453
- const task = task_queue_1.taskQueue.shift();
454
- addLog(io, 'task', `[Legacy] Mengirim perintah tertunda '${task?.command}' ke Studio.`);
455
- res.json(task);
456
- return;
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
- req.on('close', () => {
737
+ res.on('close', () => {
460
738
  const index = waitingPollers.indexOf(res);
461
739
  if (index !== -1)
462
740
  waitingPollers.splice(index, 1);