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