mcp-twin 1.2.0 → 1.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.
@@ -0,0 +1,908 @@
1
+ /**
2
+ * Twins Routes
3
+ * MCP Twin Cloud
4
+ */
5
+
6
+ import { Router, Request, Response } from 'express';
7
+ import { query, queryOne } from '../db';
8
+ import { authenticateApiKey, getTierLimits } from '../auth';
9
+ import { getTwinManager } from '../../twin-manager';
10
+
11
+ const router = Router();
12
+
13
+ interface Twin {
14
+ id: string;
15
+ user_id: string;
16
+ name: string;
17
+ config_json: any;
18
+ status: string;
19
+ active_server: string;
20
+ port_a: number | null;
21
+ port_b: number | null;
22
+ pid_a: number | null;
23
+ pid_b: number | null;
24
+ created_at: Date;
25
+ updated_at: Date;
26
+ }
27
+
28
+ interface Log {
29
+ id: string;
30
+ user_id: string;
31
+ twin_id: string;
32
+ tool_name: string;
33
+ args: any;
34
+ result: any;
35
+ error: string | null;
36
+ status: string;
37
+ duration_ms: number;
38
+ server: string;
39
+ created_at: Date;
40
+ }
41
+
42
+ /**
43
+ * Middleware: Check quota
44
+ */
45
+ async function checkQuota(req: Request, res: Response, next: Function): Promise<void> {
46
+ try {
47
+ const user = req.user!;
48
+ const limits = getTierLimits(user.tier);
49
+
50
+ // Get current month usage
51
+ const startOfMonth = new Date();
52
+ startOfMonth.setDate(1);
53
+ startOfMonth.setHours(0, 0, 0, 0);
54
+
55
+ const usage = await queryOne<{ total: string }>(
56
+ `SELECT COALESCE(SUM(request_count), 0) as total
57
+ FROM usage_daily
58
+ WHERE user_id = $1 AND date >= $2`,
59
+ [user.id, startOfMonth.toISOString().split('T')[0]]
60
+ );
61
+
62
+ const usedRequests = parseInt(usage?.total || '0');
63
+
64
+ if (limits.requestsPerMonth !== -1 && usedRequests >= limits.requestsPerMonth) {
65
+ res.status(429).json({
66
+ error: {
67
+ code: 'QUOTA_EXCEEDED',
68
+ message: `Monthly request quota exceeded (${usedRequests}/${limits.requestsPerMonth}). Upgrade to Pro for more requests.`,
69
+ usage: {
70
+ used: usedRequests,
71
+ limit: limits.requestsPerMonth,
72
+ },
73
+ },
74
+ });
75
+ return;
76
+ }
77
+
78
+ next();
79
+ } catch (error: any) {
80
+ console.error('[Twins] Quota check error:', error);
81
+ next(); // Don't block on quota check errors
82
+ }
83
+ }
84
+
85
+ /**
86
+ * POST /api/twins
87
+ * Create a new twin
88
+ */
89
+ router.post('/', authenticateApiKey, async (req: Request, res: Response) => {
90
+ try {
91
+ const { name, config } = req.body;
92
+ const user = req.user!;
93
+
94
+ // Validate input
95
+ if (!name || !config) {
96
+ res.status(400).json({
97
+ error: {
98
+ code: 'VALIDATION_ERROR',
99
+ message: 'Name and config are required',
100
+ },
101
+ });
102
+ return;
103
+ }
104
+
105
+ // Check twin limit
106
+ const limits = getTierLimits(user.tier);
107
+ const twinCount = await queryOne<{ count: string }>(
108
+ 'SELECT COUNT(*) as count FROM twins WHERE user_id = $1',
109
+ [user.id]
110
+ );
111
+
112
+ const currentCount = parseInt(twinCount?.count || '0');
113
+ if (limits.twins !== -1 && currentCount >= limits.twins) {
114
+ res.status(403).json({
115
+ error: {
116
+ code: 'TWIN_LIMIT_REACHED',
117
+ message: `Twin limit reached (${currentCount}/${limits.twins}). Upgrade to create more twins.`,
118
+ },
119
+ });
120
+ return;
121
+ }
122
+
123
+ // Assign ports (simple strategy: use twin count * 2 + base)
124
+ const basePort = 8100 + (currentCount * 2);
125
+ const portA = basePort;
126
+ const portB = basePort + 1;
127
+
128
+ // Create twin
129
+ const twins = await query<Twin>(
130
+ `INSERT INTO twins (user_id, name, config_json, port_a, port_b)
131
+ VALUES ($1, $2, $3, $4, $5)
132
+ RETURNING *`,
133
+ [user.id, name, JSON.stringify(config), portA, portB]
134
+ );
135
+
136
+ const twin = twins[0];
137
+
138
+ res.status(201).json({
139
+ message: 'Twin created',
140
+ twin: {
141
+ id: twin.id,
142
+ name: twin.name,
143
+ config: twin.config_json,
144
+ status: twin.status,
145
+ activeServer: twin.active_server,
146
+ ports: {
147
+ a: twin.port_a,
148
+ b: twin.port_b,
149
+ },
150
+ createdAt: twin.created_at,
151
+ },
152
+ });
153
+ } catch (error: any) {
154
+ console.error('[Twins] Create error:', error);
155
+ res.status(500).json({
156
+ error: {
157
+ code: 'INTERNAL_ERROR',
158
+ message: 'Failed to create twin',
159
+ },
160
+ });
161
+ }
162
+ });
163
+
164
+ /**
165
+ * GET /api/twins
166
+ * List user's twins
167
+ */
168
+ router.get('/', authenticateApiKey, async (req: Request, res: Response) => {
169
+ try {
170
+ const twins = await query<Twin>(
171
+ `SELECT * FROM twins WHERE user_id = $1 ORDER BY created_at DESC`,
172
+ [req.user!.id]
173
+ );
174
+
175
+ res.json({
176
+ twins: twins.map((t) => ({
177
+ id: t.id,
178
+ name: t.name,
179
+ config: t.config_json,
180
+ status: t.status,
181
+ activeServer: t.active_server,
182
+ ports: {
183
+ a: t.port_a,
184
+ b: t.port_b,
185
+ },
186
+ createdAt: t.created_at,
187
+ updatedAt: t.updated_at,
188
+ })),
189
+ });
190
+ } catch (error: any) {
191
+ console.error('[Twins] List error:', error);
192
+ res.status(500).json({
193
+ error: {
194
+ code: 'INTERNAL_ERROR',
195
+ message: 'Failed to list twins',
196
+ },
197
+ });
198
+ }
199
+ });
200
+
201
+ /**
202
+ * GET /api/twins/:id
203
+ * Get twin details
204
+ */
205
+ router.get('/:id', authenticateApiKey, async (req: Request, res: Response) => {
206
+ try {
207
+ const twin = await queryOne<Twin>(
208
+ 'SELECT * FROM twins WHERE id = $1 AND user_id = $2',
209
+ [req.params.id, req.user!.id]
210
+ );
211
+
212
+ if (!twin) {
213
+ res.status(404).json({
214
+ error: {
215
+ code: 'NOT_FOUND',
216
+ message: 'Twin not found',
217
+ },
218
+ });
219
+ return;
220
+ }
221
+
222
+ // Get recent logs count
223
+ const logCount = await queryOne<{ count: string }>(
224
+ 'SELECT COUNT(*) as count FROM logs WHERE twin_id = $1',
225
+ [twin.id]
226
+ );
227
+
228
+ res.json({
229
+ twin: {
230
+ id: twin.id,
231
+ name: twin.name,
232
+ config: twin.config_json,
233
+ status: twin.status,
234
+ activeServer: twin.active_server,
235
+ ports: {
236
+ a: twin.port_a,
237
+ b: twin.port_b,
238
+ },
239
+ pids: {
240
+ a: twin.pid_a,
241
+ b: twin.pid_b,
242
+ },
243
+ createdAt: twin.created_at,
244
+ updatedAt: twin.updated_at,
245
+ },
246
+ stats: {
247
+ totalCalls: parseInt(logCount?.count || '0'),
248
+ },
249
+ });
250
+ } catch (error: any) {
251
+ console.error('[Twins] Get error:', error);
252
+ res.status(500).json({
253
+ error: {
254
+ code: 'INTERNAL_ERROR',
255
+ message: 'Failed to get twin',
256
+ },
257
+ });
258
+ }
259
+ });
260
+
261
+ /**
262
+ * PATCH /api/twins/:id
263
+ * Update twin config
264
+ */
265
+ router.patch('/:id', authenticateApiKey, async (req: Request, res: Response) => {
266
+ try {
267
+ const { name, config } = req.body;
268
+
269
+ const updates: string[] = [];
270
+ const values: any[] = [];
271
+ let paramIndex = 1;
272
+
273
+ if (name !== undefined) {
274
+ updates.push(`name = $${paramIndex++}`);
275
+ values.push(name);
276
+ }
277
+
278
+ if (config !== undefined) {
279
+ updates.push(`config_json = $${paramIndex++}`);
280
+ values.push(JSON.stringify(config));
281
+ }
282
+
283
+ if (updates.length === 0) {
284
+ res.status(400).json({
285
+ error: {
286
+ code: 'VALIDATION_ERROR',
287
+ message: 'No fields to update',
288
+ },
289
+ });
290
+ return;
291
+ }
292
+
293
+ updates.push(`updated_at = NOW()`);
294
+ values.push(req.params.id, req.user!.id);
295
+
296
+ const twins = await query<Twin>(
297
+ `UPDATE twins
298
+ SET ${updates.join(', ')}
299
+ WHERE id = $${paramIndex++} AND user_id = $${paramIndex}
300
+ RETURNING *`,
301
+ values
302
+ );
303
+
304
+ if (twins.length === 0) {
305
+ res.status(404).json({
306
+ error: {
307
+ code: 'NOT_FOUND',
308
+ message: 'Twin not found',
309
+ },
310
+ });
311
+ return;
312
+ }
313
+
314
+ const twin = twins[0];
315
+
316
+ res.json({
317
+ message: 'Twin updated',
318
+ twin: {
319
+ id: twin.id,
320
+ name: twin.name,
321
+ config: twin.config_json,
322
+ status: twin.status,
323
+ updatedAt: twin.updated_at,
324
+ },
325
+ });
326
+ } catch (error: any) {
327
+ console.error('[Twins] Update error:', error);
328
+ res.status(500).json({
329
+ error: {
330
+ code: 'INTERNAL_ERROR',
331
+ message: 'Failed to update twin',
332
+ },
333
+ });
334
+ }
335
+ });
336
+
337
+ /**
338
+ * DELETE /api/twins/:id
339
+ * Delete a twin
340
+ */
341
+ router.delete('/:id', authenticateApiKey, async (req: Request, res: Response) => {
342
+ try {
343
+ // Stop twin if running
344
+ const twin = await queryOne<Twin>(
345
+ 'SELECT * FROM twins WHERE id = $1 AND user_id = $2',
346
+ [req.params.id, req.user!.id]
347
+ );
348
+
349
+ if (!twin) {
350
+ res.status(404).json({
351
+ error: {
352
+ code: 'NOT_FOUND',
353
+ message: 'Twin not found',
354
+ },
355
+ });
356
+ return;
357
+ }
358
+
359
+ // Delete (cascade will remove logs)
360
+ await query('DELETE FROM twins WHERE id = $1', [twin.id]);
361
+
362
+ res.json({
363
+ message: 'Twin deleted',
364
+ });
365
+ } catch (error: any) {
366
+ console.error('[Twins] Delete error:', error);
367
+ res.status(500).json({
368
+ error: {
369
+ code: 'INTERNAL_ERROR',
370
+ message: 'Failed to delete twin',
371
+ },
372
+ });
373
+ }
374
+ });
375
+
376
+ /**
377
+ * POST /api/twins/:id/start
378
+ * Start a twin
379
+ */
380
+ router.post('/:id/start', authenticateApiKey, async (req: Request, res: Response) => {
381
+ try {
382
+ const twin = await queryOne<Twin>(
383
+ 'SELECT * FROM twins WHERE id = $1 AND user_id = $2',
384
+ [req.params.id, req.user!.id]
385
+ );
386
+
387
+ if (!twin) {
388
+ res.status(404).json({
389
+ error: {
390
+ code: 'NOT_FOUND',
391
+ message: 'Twin not found',
392
+ },
393
+ });
394
+ return;
395
+ }
396
+
397
+ if (twin.status === 'running') {
398
+ res.status(400).json({
399
+ error: {
400
+ code: 'ALREADY_RUNNING',
401
+ message: 'Twin is already running',
402
+ },
403
+ });
404
+ return;
405
+ }
406
+
407
+ // Start using twin manager
408
+ const manager = getTwinManager();
409
+ const result = await manager.startTwins(twin.name);
410
+
411
+ if (!result.ok) {
412
+ res.status(500).json({
413
+ error: {
414
+ code: 'START_FAILED',
415
+ message: result.error || 'Failed to start twin',
416
+ },
417
+ });
418
+ return;
419
+ }
420
+
421
+ // Update twin status
422
+ await query(
423
+ `UPDATE twins SET status = 'running', pid_a = $1, pid_b = $2, updated_at = NOW()
424
+ WHERE id = $3`,
425
+ [result.pidA, result.pidB, twin.id]
426
+ );
427
+
428
+ res.json({
429
+ message: 'Twin started',
430
+ status: 'running',
431
+ servers: {
432
+ a: { port: twin.port_a, pid: result.pidA, status: result.statusA },
433
+ b: { port: twin.port_b, pid: result.pidB, status: result.statusB },
434
+ },
435
+ });
436
+ } catch (error: any) {
437
+ console.error('[Twins] Start error:', error);
438
+ res.status(500).json({
439
+ error: {
440
+ code: 'INTERNAL_ERROR',
441
+ message: 'Failed to start twin',
442
+ },
443
+ });
444
+ }
445
+ });
446
+
447
+ /**
448
+ * POST /api/twins/:id/stop
449
+ * Stop a twin
450
+ */
451
+ router.post('/:id/stop', authenticateApiKey, async (req: Request, res: Response) => {
452
+ try {
453
+ const twin = await queryOne<Twin>(
454
+ 'SELECT * FROM twins WHERE id = $1 AND user_id = $2',
455
+ [req.params.id, req.user!.id]
456
+ );
457
+
458
+ if (!twin) {
459
+ res.status(404).json({
460
+ error: {
461
+ code: 'NOT_FOUND',
462
+ message: 'Twin not found',
463
+ },
464
+ });
465
+ return;
466
+ }
467
+
468
+ // Stop using twin manager
469
+ const manager = getTwinManager();
470
+ await manager.stopTwins(twin.name);
471
+
472
+ // Update twin status
473
+ await query(
474
+ `UPDATE twins SET status = 'stopped', pid_a = NULL, pid_b = NULL, updated_at = NOW()
475
+ WHERE id = $1`,
476
+ [twin.id]
477
+ );
478
+
479
+ res.json({
480
+ message: 'Twin stopped',
481
+ status: 'stopped',
482
+ });
483
+ } catch (error: any) {
484
+ console.error('[Twins] Stop error:', error);
485
+ res.status(500).json({
486
+ error: {
487
+ code: 'INTERNAL_ERROR',
488
+ message: 'Failed to stop twin',
489
+ },
490
+ });
491
+ }
492
+ });
493
+
494
+ /**
495
+ * POST /api/twins/:id/reload
496
+ * Reload twin standby
497
+ */
498
+ router.post('/:id/reload', authenticateApiKey, async (req: Request, res: Response) => {
499
+ try {
500
+ const twin = await queryOne<Twin>(
501
+ 'SELECT * FROM twins WHERE id = $1 AND user_id = $2',
502
+ [req.params.id, req.user!.id]
503
+ );
504
+
505
+ if (!twin) {
506
+ res.status(404).json({
507
+ error: {
508
+ code: 'NOT_FOUND',
509
+ message: 'Twin not found',
510
+ },
511
+ });
512
+ return;
513
+ }
514
+
515
+ if (twin.status !== 'running') {
516
+ res.status(400).json({
517
+ error: {
518
+ code: 'NOT_RUNNING',
519
+ message: 'Twin must be running to reload',
520
+ },
521
+ });
522
+ return;
523
+ }
524
+
525
+ // Reload using twin manager
526
+ const manager = getTwinManager();
527
+ const result = await manager.reloadStandby(twin.name);
528
+
529
+ if (!result.ok) {
530
+ res.status(500).json({
531
+ error: {
532
+ code: 'RELOAD_FAILED',
533
+ message: result.error || 'Failed to reload twin',
534
+ },
535
+ });
536
+ return;
537
+ }
538
+
539
+ res.json({
540
+ message: 'Twin reloaded',
541
+ reloaded: result.reloaded,
542
+ healthy: result.healthy,
543
+ reloadCount: result.reloadCount,
544
+ });
545
+ } catch (error: any) {
546
+ console.error('[Twins] Reload error:', error);
547
+ res.status(500).json({
548
+ error: {
549
+ code: 'INTERNAL_ERROR',
550
+ message: 'Failed to reload twin',
551
+ },
552
+ });
553
+ }
554
+ });
555
+
556
+ /**
557
+ * POST /api/twins/:id/swap
558
+ * Swap twin active server
559
+ */
560
+ router.post('/:id/swap', authenticateApiKey, async (req: Request, res: Response) => {
561
+ try {
562
+ const twin = await queryOne<Twin>(
563
+ 'SELECT * FROM twins WHERE id = $1 AND user_id = $2',
564
+ [req.params.id, req.user!.id]
565
+ );
566
+
567
+ if (!twin) {
568
+ res.status(404).json({
569
+ error: {
570
+ code: 'NOT_FOUND',
571
+ message: 'Twin not found',
572
+ },
573
+ });
574
+ return;
575
+ }
576
+
577
+ if (twin.status !== 'running') {
578
+ res.status(400).json({
579
+ error: {
580
+ code: 'NOT_RUNNING',
581
+ message: 'Twin must be running to swap',
582
+ },
583
+ });
584
+ return;
585
+ }
586
+
587
+ // Swap using twin manager
588
+ const manager = getTwinManager();
589
+ const result = await manager.swapActive(twin.name);
590
+
591
+ if (!result.ok) {
592
+ res.status(500).json({
593
+ error: {
594
+ code: 'SWAP_FAILED',
595
+ message: result.error || 'Failed to swap twin',
596
+ },
597
+ });
598
+ return;
599
+ }
600
+
601
+ // Update active server
602
+ const newActive = twin.active_server === 'a' ? 'b' : 'a';
603
+ await query(
604
+ `UPDATE twins SET active_server = $1, updated_at = NOW() WHERE id = $2`,
605
+ [newActive, twin.id]
606
+ );
607
+
608
+ res.json({
609
+ message: 'Twin swapped',
610
+ previousActive: result.previousActive,
611
+ newActive: result.newActive,
612
+ });
613
+ } catch (error: any) {
614
+ console.error('[Twins] Swap error:', error);
615
+ res.status(500).json({
616
+ error: {
617
+ code: 'INTERNAL_ERROR',
618
+ message: 'Failed to swap twin',
619
+ },
620
+ });
621
+ }
622
+ });
623
+
624
+ /**
625
+ * POST /api/twins/:id/call
626
+ * Execute a tool call through the twin
627
+ */
628
+ router.post('/:id/call', authenticateApiKey, checkQuota, async (req: Request, res: Response) => {
629
+ const startTime = Date.now();
630
+
631
+ try {
632
+ const { toolName, args } = req.body;
633
+
634
+ if (!toolName) {
635
+ res.status(400).json({
636
+ error: {
637
+ code: 'VALIDATION_ERROR',
638
+ message: 'toolName is required',
639
+ },
640
+ });
641
+ return;
642
+ }
643
+
644
+ const twin = await queryOne<Twin>(
645
+ 'SELECT * FROM twins WHERE id = $1 AND user_id = $2',
646
+ [req.params.id, req.user!.id]
647
+ );
648
+
649
+ if (!twin) {
650
+ res.status(404).json({
651
+ error: {
652
+ code: 'NOT_FOUND',
653
+ message: 'Twin not found',
654
+ },
655
+ });
656
+ return;
657
+ }
658
+
659
+ if (twin.status !== 'running') {
660
+ res.status(400).json({
661
+ error: {
662
+ code: 'NOT_RUNNING',
663
+ message: 'Twin must be running to execute calls',
664
+ },
665
+ });
666
+ return;
667
+ }
668
+
669
+ // Determine active port
670
+ const activePort = twin.active_server === 'a' ? twin.port_a : twin.port_b;
671
+
672
+ // Make HTTP call to MCP server
673
+ const response = await fetch(`http://localhost:${activePort}/mcp`, {
674
+ method: 'POST',
675
+ headers: { 'Content-Type': 'application/json' },
676
+ body: JSON.stringify({
677
+ jsonrpc: '2.0',
678
+ method: 'tools/call',
679
+ params: { name: toolName, arguments: args || {} },
680
+ id: Date.now(),
681
+ }),
682
+ });
683
+
684
+ const result: any = await response.json();
685
+ const durationMs = Date.now() - startTime;
686
+ const isError = !!result.error;
687
+
688
+ // Log the call
689
+ await query(
690
+ `INSERT INTO logs (user_id, twin_id, tool_name, args, result, error, status, duration_ms, server)
691
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
692
+ [
693
+ req.user!.id,
694
+ twin.id,
695
+ toolName,
696
+ JSON.stringify(args || {}),
697
+ isError ? null : JSON.stringify(result.result),
698
+ isError ? JSON.stringify(result.error) : null,
699
+ isError ? 'error' : 'success',
700
+ durationMs,
701
+ twin.active_server,
702
+ ]
703
+ );
704
+
705
+ // Update usage
706
+ const today = new Date().toISOString().split('T')[0];
707
+ await query(
708
+ `INSERT INTO usage_daily (user_id, date, request_count, error_count, total_duration_ms)
709
+ VALUES ($1, $2, 1, $3, $4)
710
+ ON CONFLICT (user_id, date)
711
+ DO UPDATE SET
712
+ request_count = usage_daily.request_count + 1,
713
+ error_count = usage_daily.error_count + $3,
714
+ total_duration_ms = usage_daily.total_duration_ms + $4`,
715
+ [req.user!.id, today, isError ? 1 : 0, durationMs]
716
+ );
717
+
718
+ if (isError) {
719
+ res.status(500).json({
720
+ error: {
721
+ code: 'TOOL_ERROR',
722
+ message: result.error.message || 'Tool call failed',
723
+ details: result.error,
724
+ },
725
+ durationMs,
726
+ server: twin.active_server,
727
+ });
728
+ return;
729
+ }
730
+
731
+ res.json({
732
+ result: result.result,
733
+ durationMs,
734
+ server: twin.active_server,
735
+ requestId: `req_${Date.now()}`,
736
+ });
737
+ } catch (error: any) {
738
+ const durationMs = Date.now() - startTime;
739
+
740
+ // Log the error
741
+ if (req.params.id && req.user) {
742
+ await query(
743
+ `INSERT INTO logs (user_id, twin_id, tool_name, args, error, status, duration_ms, server)
744
+ VALUES ($1, $2, $3, $4, $5, 'error', $6, 'a')`,
745
+ [
746
+ req.user.id,
747
+ req.params.id,
748
+ req.body?.toolName || 'unknown',
749
+ JSON.stringify(req.body?.args || {}),
750
+ error.message,
751
+ durationMs,
752
+ ]
753
+ ).catch(() => {}); // Don't fail on logging errors
754
+ }
755
+
756
+ console.error('[Twins] Call error:', error);
757
+ res.status(500).json({
758
+ error: {
759
+ code: 'INTERNAL_ERROR',
760
+ message: 'Failed to execute tool call',
761
+ details: error.message,
762
+ },
763
+ durationMs,
764
+ });
765
+ }
766
+ });
767
+
768
+ /**
769
+ * GET /api/twins/:id/logs
770
+ * Get twin execution logs
771
+ */
772
+ router.get('/:id/logs', authenticateApiKey, async (req: Request, res: Response) => {
773
+ try {
774
+ const { limit = '50', offset = '0', status } = req.query;
775
+
776
+ const twin = await queryOne<Twin>(
777
+ 'SELECT id FROM twins WHERE id = $1 AND user_id = $2',
778
+ [req.params.id, req.user!.id]
779
+ );
780
+
781
+ if (!twin) {
782
+ res.status(404).json({
783
+ error: {
784
+ code: 'NOT_FOUND',
785
+ message: 'Twin not found',
786
+ },
787
+ });
788
+ return;
789
+ }
790
+
791
+ let whereClause = 'WHERE twin_id = $1';
792
+ const params: any[] = [twin.id];
793
+
794
+ if (status) {
795
+ params.push(status);
796
+ whereClause += ` AND status = $${params.length}`;
797
+ }
798
+
799
+ params.push(parseInt(limit as string));
800
+ params.push(parseInt(offset as string));
801
+
802
+ const logs = await query<Log>(
803
+ `SELECT * FROM logs
804
+ ${whereClause}
805
+ ORDER BY created_at DESC
806
+ LIMIT $${params.length - 1} OFFSET $${params.length}`,
807
+ params
808
+ );
809
+
810
+ res.json({
811
+ logs: logs.map((l) => ({
812
+ id: l.id,
813
+ toolName: l.tool_name,
814
+ args: l.args,
815
+ result: l.result,
816
+ error: l.error,
817
+ status: l.status,
818
+ durationMs: l.duration_ms,
819
+ server: l.server,
820
+ createdAt: l.created_at,
821
+ })),
822
+ });
823
+ } catch (error: any) {
824
+ console.error('[Twins] Get logs error:', error);
825
+ res.status(500).json({
826
+ error: {
827
+ code: 'INTERNAL_ERROR',
828
+ message: 'Failed to get logs',
829
+ },
830
+ });
831
+ }
832
+ });
833
+
834
+ /**
835
+ * GET /api/twins/:id/status
836
+ * Get twin health status
837
+ */
838
+ router.get('/:id/status', authenticateApiKey, async (req: Request, res: Response) => {
839
+ try {
840
+ const twin = await queryOne<Twin>(
841
+ 'SELECT * FROM twins WHERE id = $1 AND user_id = $2',
842
+ [req.params.id, req.user!.id]
843
+ );
844
+
845
+ if (!twin) {
846
+ res.status(404).json({
847
+ error: {
848
+ code: 'NOT_FOUND',
849
+ message: 'Twin not found',
850
+ },
851
+ });
852
+ return;
853
+ }
854
+
855
+ // Check health if running
856
+ let healthA = false;
857
+ let healthB = false;
858
+
859
+ if (twin.status === 'running') {
860
+ try {
861
+ const respA = await fetch(`http://localhost:${twin.port_a}/health`, {
862
+ signal: AbortSignal.timeout(2000)
863
+ });
864
+ healthA = respA.ok;
865
+ } catch {}
866
+
867
+ try {
868
+ const respB = await fetch(`http://localhost:${twin.port_b}/health`, {
869
+ signal: AbortSignal.timeout(2000)
870
+ });
871
+ healthB = respB.ok;
872
+ } catch {}
873
+ }
874
+
875
+ res.json({
876
+ twin: {
877
+ id: twin.id,
878
+ name: twin.name,
879
+ status: twin.status,
880
+ activeServer: twin.active_server,
881
+ },
882
+ servers: {
883
+ a: {
884
+ port: twin.port_a,
885
+ pid: twin.pid_a,
886
+ healthy: healthA,
887
+ isActive: twin.active_server === 'a',
888
+ },
889
+ b: {
890
+ port: twin.port_b,
891
+ pid: twin.pid_b,
892
+ healthy: healthB,
893
+ isActive: twin.active_server === 'b',
894
+ },
895
+ },
896
+ });
897
+ } catch (error: any) {
898
+ console.error('[Twins] Status error:', error);
899
+ res.status(500).json({
900
+ error: {
901
+ code: 'INTERNAL_ERROR',
902
+ message: 'Failed to get status',
903
+ },
904
+ });
905
+ }
906
+ });
907
+
908
+ export default router;