@cluesmith/codev 2.0.0-rc.47 → 2.0.0-rc.49
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/bin/af.js +2 -2
- package/bin/consult.js +1 -1
- package/dist/agent-farm/commands/start.d.ts +3 -0
- package/dist/agent-farm/commands/start.d.ts.map +1 -1
- package/dist/agent-farm/commands/start.js +65 -0
- package/dist/agent-farm/commands/start.js.map +1 -1
- package/dist/agent-farm/commands/status.d.ts +2 -0
- package/dist/agent-farm/commands/status.d.ts.map +1 -1
- package/dist/agent-farm/commands/status.js +56 -1
- package/dist/agent-farm/commands/status.js.map +1 -1
- package/dist/agent-farm/commands/stop.d.ts +6 -0
- package/dist/agent-farm/commands/stop.d.ts.map +1 -1
- package/dist/agent-farm/commands/stop.js +36 -1
- package/dist/agent-farm/commands/stop.js.map +1 -1
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +17 -0
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/schema.d.ts +1 -1
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +2 -0
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/lib/tower-client.d.ts +157 -0
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -0
- package/dist/agent-farm/lib/tower-client.js +223 -0
- package/dist/agent-farm/lib/tower-client.js.map +1 -0
- package/dist/agent-farm/servers/tower-server.js +836 -224
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/commands/adopt.js +1 -1
- package/package.json +1 -1
- package/templates/tower.html +2 -2
- package/dist/agent-farm/servers/dashboard-server.d.ts +0 -7
- package/dist/agent-farm/servers/dashboard-server.d.ts.map +0 -1
- package/dist/agent-farm/servers/dashboard-server.js +0 -2181
- package/dist/agent-farm/servers/dashboard-server.js.map +0 -1
|
@@ -12,13 +12,177 @@ import { spawn, execSync } from 'node:child_process';
|
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { Command } from 'commander';
|
|
15
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
15
16
|
import { getGlobalDb } from '../db/index.js';
|
|
16
17
|
import { cleanupStaleEntries } from '../utils/port-registry.js';
|
|
17
18
|
import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
|
|
19
|
+
import { TerminalManager } from '../../terminal/pty-manager.js';
|
|
20
|
+
import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
|
|
18
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
22
|
const __dirname = path.dirname(__filename);
|
|
20
23
|
// Default port for tower dashboard
|
|
21
24
|
const DEFAULT_PORT = 4100;
|
|
25
|
+
// Rate limiting for activation requests (Spec 0090 Phase 1)
|
|
26
|
+
// Simple in-memory rate limiter: 10 activations per minute per client
|
|
27
|
+
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
|
28
|
+
const RATE_LIMIT_MAX = 10;
|
|
29
|
+
const activationRateLimits = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Check if a client has exceeded the rate limit for activations
|
|
32
|
+
* Returns true if rate limit exceeded, false if allowed
|
|
33
|
+
*/
|
|
34
|
+
function isRateLimited(clientIp) {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const entry = activationRateLimits.get(clientIp);
|
|
37
|
+
if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
|
|
38
|
+
// New window
|
|
39
|
+
activationRateLimits.set(clientIp, { count: 1, windowStart: now });
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (entry.count >= RATE_LIMIT_MAX) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
entry.count++;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Clean up old rate limit entries periodically
|
|
50
|
+
*/
|
|
51
|
+
function cleanupRateLimits() {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
for (const [ip, entry] of activationRateLimits.entries()) {
|
|
54
|
+
if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS * 2) {
|
|
55
|
+
activationRateLimits.delete(ip);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Cleanup stale rate limit entries every 5 minutes
|
|
60
|
+
setInterval(cleanupRateLimits, 5 * 60 * 1000);
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// PHASE 2 & 4: Terminal Management (Spec 0090)
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Global TerminalManager instance for tower-managed terminals
|
|
65
|
+
// Uses a temporary directory as projectRoot since terminals can be for any project
|
|
66
|
+
let terminalManager = null;
|
|
67
|
+
const projectTerminals = new Map();
|
|
68
|
+
/**
|
|
69
|
+
* Get or create project terminal registry entry
|
|
70
|
+
*/
|
|
71
|
+
function getProjectTerminalsEntry(projectPath) {
|
|
72
|
+
let entry = projectTerminals.get(projectPath);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
entry = { builders: new Map(), shells: new Map() };
|
|
75
|
+
projectTerminals.set(projectPath, entry);
|
|
76
|
+
}
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate next shell ID for a project
|
|
81
|
+
*/
|
|
82
|
+
function getNextShellId(projectPath) {
|
|
83
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
84
|
+
let maxId = 0;
|
|
85
|
+
for (const id of entry.shells.keys()) {
|
|
86
|
+
const num = parseInt(id.replace('shell-', ''), 10);
|
|
87
|
+
if (!isNaN(num) && num > maxId)
|
|
88
|
+
maxId = num;
|
|
89
|
+
}
|
|
90
|
+
return `shell-${maxId + 1}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get or create the global TerminalManager instance
|
|
94
|
+
*/
|
|
95
|
+
function getTerminalManager() {
|
|
96
|
+
if (!terminalManager) {
|
|
97
|
+
// Use a neutral projectRoot - terminals specify their own cwd
|
|
98
|
+
const projectRoot = process.env.HOME || '/tmp';
|
|
99
|
+
terminalManager = new TerminalManager({
|
|
100
|
+
projectRoot,
|
|
101
|
+
logDir: path.join(homedir(), '.agent-farm', 'logs'),
|
|
102
|
+
maxSessions: 100,
|
|
103
|
+
ringBufferLines: 1000,
|
|
104
|
+
diskLogEnabled: true,
|
|
105
|
+
diskLogMaxBytes: 50 * 1024 * 1024,
|
|
106
|
+
reconnectTimeoutMs: 300_000,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return terminalManager;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Handle WebSocket connection to a terminal session
|
|
113
|
+
* Uses hybrid binary protocol (Spec 0085):
|
|
114
|
+
* - 0x00 prefix: Control frame (JSON)
|
|
115
|
+
* - 0x01 prefix: Data frame (raw PTY bytes)
|
|
116
|
+
*/
|
|
117
|
+
function handleTerminalWebSocket(ws, session, req) {
|
|
118
|
+
const resumeSeq = req.headers['x-session-resume'];
|
|
119
|
+
// Create a client adapter for the PTY session
|
|
120
|
+
// Uses binary protocol for data frames
|
|
121
|
+
const client = {
|
|
122
|
+
send: (data) => {
|
|
123
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
124
|
+
// Encode as binary data frame (0x01 prefix)
|
|
125
|
+
ws.send(encodeData(data));
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
// Attach client to session and get replay data
|
|
130
|
+
let replayLines;
|
|
131
|
+
if (resumeSeq && typeof resumeSeq === 'string') {
|
|
132
|
+
replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
replayLines = session.attach(client);
|
|
136
|
+
}
|
|
137
|
+
// Send replay data as binary data frame
|
|
138
|
+
if (replayLines.length > 0) {
|
|
139
|
+
const replayData = replayLines.join('\n');
|
|
140
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
141
|
+
ws.send(encodeData(replayData));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Handle incoming messages from client (binary protocol)
|
|
145
|
+
ws.on('message', (rawData) => {
|
|
146
|
+
try {
|
|
147
|
+
const frame = decodeFrame(Buffer.from(rawData));
|
|
148
|
+
if (frame.type === 'data') {
|
|
149
|
+
// Write raw input to terminal
|
|
150
|
+
session.write(frame.data.toString('utf-8'));
|
|
151
|
+
}
|
|
152
|
+
else if (frame.type === 'control') {
|
|
153
|
+
// Handle control messages
|
|
154
|
+
const msg = frame.message;
|
|
155
|
+
if (msg.type === 'resize') {
|
|
156
|
+
const cols = msg.payload.cols;
|
|
157
|
+
const rows = msg.payload.rows;
|
|
158
|
+
if (typeof cols === 'number' && typeof rows === 'number') {
|
|
159
|
+
session.resize(cols, rows);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else if (msg.type === 'ping') {
|
|
163
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
164
|
+
ws.send(encodeControl({ type: 'pong', payload: {} }));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// If decode fails, try treating as raw UTF-8 input (for simpler clients)
|
|
171
|
+
try {
|
|
172
|
+
session.write(rawData.toString('utf-8'));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Ignore malformed input
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
ws.on('close', () => {
|
|
180
|
+
session.detach(client);
|
|
181
|
+
});
|
|
182
|
+
ws.on('error', () => {
|
|
183
|
+
session.detach(client);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
22
186
|
// Parse arguments with Commander
|
|
23
187
|
const program = new Command()
|
|
24
188
|
.name('tower-server')
|
|
@@ -63,9 +227,31 @@ process.on('unhandledRejection', (reason) => {
|
|
|
63
227
|
log('ERROR', `Unhandled rejection: ${message}`);
|
|
64
228
|
process.exit(1);
|
|
65
229
|
});
|
|
230
|
+
// Graceful shutdown handler (Phase 2 - Spec 0090)
|
|
231
|
+
async function gracefulShutdown(signal) {
|
|
232
|
+
log('INFO', `Received ${signal}, starting graceful shutdown...`);
|
|
233
|
+
// 1. Stop accepting new connections
|
|
234
|
+
server?.close();
|
|
235
|
+
// 2. Close all WebSocket connections
|
|
236
|
+
if (terminalWss) {
|
|
237
|
+
for (const client of terminalWss.clients) {
|
|
238
|
+
client.close(1001, 'Server shutting down');
|
|
239
|
+
}
|
|
240
|
+
terminalWss.close();
|
|
241
|
+
}
|
|
242
|
+
// 3. Kill all PTY sessions
|
|
243
|
+
if (terminalManager) {
|
|
244
|
+
log('INFO', 'Shutting down terminal manager...');
|
|
245
|
+
terminalManager.shutdown();
|
|
246
|
+
}
|
|
247
|
+
// 4. Stop cloudflared tunnel if running
|
|
248
|
+
stopTunnel();
|
|
249
|
+
log('INFO', 'Graceful shutdown complete');
|
|
250
|
+
process.exit(0);
|
|
251
|
+
}
|
|
66
252
|
// Catch signals for clean shutdown
|
|
67
|
-
process.on('SIGINT', () =>
|
|
68
|
-
process.on('SIGTERM', () =>
|
|
253
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
254
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
69
255
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
70
256
|
log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
|
|
71
257
|
process.exit(1);
|
|
@@ -244,23 +430,21 @@ async function getGateStatusForProject(basePort) {
|
|
|
244
430
|
return { hasGate: false };
|
|
245
431
|
}
|
|
246
432
|
/**
|
|
247
|
-
*
|
|
433
|
+
* Get terminal list for a project from tower's registry.
|
|
434
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
|
|
248
435
|
* Returns architect, builders, and shells with their URLs.
|
|
249
436
|
*/
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const terminals = [];
|
|
262
|
-
// Add architect terminal
|
|
263
|
-
if (state.architect?.terminalId) {
|
|
437
|
+
function getTerminalsForProject(projectPath, proxyUrl) {
|
|
438
|
+
const entry = projectTerminals.get(projectPath);
|
|
439
|
+
const manager = getTerminalManager();
|
|
440
|
+
const terminals = [];
|
|
441
|
+
if (!entry) {
|
|
442
|
+
return { terminals: [], gateStatus: { hasGate: false } };
|
|
443
|
+
}
|
|
444
|
+
// Add architect terminal
|
|
445
|
+
if (entry.architect) {
|
|
446
|
+
const session = manager.getSession(entry.architect);
|
|
447
|
+
if (session) {
|
|
264
448
|
terminals.push({
|
|
265
449
|
type: 'architect',
|
|
266
450
|
id: 'architect',
|
|
@@ -269,47 +453,43 @@ async function getTerminalsForProject(basePort, proxyUrl) {
|
|
|
269
453
|
active: true,
|
|
270
454
|
});
|
|
271
455
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
456
|
+
}
|
|
457
|
+
// Add builder terminals
|
|
458
|
+
for (const [builderId] of entry.builders) {
|
|
459
|
+
const terminalId = entry.builders.get(builderId);
|
|
460
|
+
if (terminalId) {
|
|
461
|
+
const session = manager.getSession(terminalId);
|
|
462
|
+
if (session) {
|
|
276
463
|
terminals.push({
|
|
277
464
|
type: 'builder',
|
|
278
|
-
id:
|
|
279
|
-
label
|
|
280
|
-
url: `${proxyUrl}?tab=builder-${
|
|
465
|
+
id: builderId,
|
|
466
|
+
label: `Builder ${builderId}`,
|
|
467
|
+
url: `${proxyUrl}?tab=builder-${builderId}`,
|
|
281
468
|
active: true,
|
|
282
469
|
});
|
|
283
470
|
}
|
|
284
471
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
472
|
+
}
|
|
473
|
+
// Add shell terminals
|
|
474
|
+
for (const [shellId] of entry.shells) {
|
|
475
|
+
const terminalId = entry.shells.get(shellId);
|
|
476
|
+
if (terminalId) {
|
|
477
|
+
const session = manager.getSession(terminalId);
|
|
478
|
+
if (session) {
|
|
288
479
|
terminals.push({
|
|
289
480
|
type: 'shell',
|
|
290
|
-
id:
|
|
291
|
-
label:
|
|
292
|
-
url: `${proxyUrl}?tab=shell-${
|
|
481
|
+
id: shellId,
|
|
482
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
483
|
+
url: `${proxyUrl}?tab=shell-${shellId}`,
|
|
293
484
|
active: true,
|
|
294
485
|
});
|
|
295
486
|
}
|
|
296
487
|
}
|
|
297
|
-
// Check for pending gates
|
|
298
|
-
const builderWithGate = state.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
|
|
299
|
-
const gateStatus = builderWithGate
|
|
300
|
-
? {
|
|
301
|
-
hasGate: true,
|
|
302
|
-
gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
|
|
303
|
-
builderId: builderWithGate.id,
|
|
304
|
-
timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
|
|
305
|
-
}
|
|
306
|
-
: { hasGate: false };
|
|
307
|
-
return { terminals, gateStatus };
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
// Project dashboard not responding or timeout
|
|
311
488
|
}
|
|
312
|
-
|
|
489
|
+
// Gate status - builders don't have gate tracking yet in tower
|
|
490
|
+
// TODO: Add gate status tracking when porch integration is updated
|
|
491
|
+
const gateStatus = { hasGate: false };
|
|
492
|
+
return { terminals, gateStatus };
|
|
313
493
|
}
|
|
314
494
|
/**
|
|
315
495
|
* Get all instances with their status
|
|
@@ -330,10 +510,9 @@ async function getInstances() {
|
|
|
330
510
|
// Encode project path for proxy URL
|
|
331
511
|
const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
|
|
332
512
|
const proxyUrl = `/project/${encodedPath}/`;
|
|
333
|
-
// Get terminals and gate status
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
: { terminals: [], gateStatus: { hasGate: false } };
|
|
513
|
+
// Get terminals and gate status from tower's registry
|
|
514
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly
|
|
515
|
+
const { terminals, gateStatus } = getTerminalsForProject(allocation.project_path, proxyUrl);
|
|
337
516
|
const ports = [
|
|
338
517
|
{
|
|
339
518
|
type: 'Dashboard',
|
|
@@ -430,8 +609,8 @@ async function getDirectorySuggestions(inputPath) {
|
|
|
430
609
|
}
|
|
431
610
|
/**
|
|
432
611
|
* Launch a new agent-farm instance
|
|
433
|
-
*
|
|
434
|
-
* Auto-adopts non-codev directories
|
|
612
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
|
|
613
|
+
* Auto-adopts non-codev directories and creates architect terminal
|
|
435
614
|
*/
|
|
436
615
|
async function launchInstance(projectPath) {
|
|
437
616
|
// Clean up stale port allocations before launching (handles machine restarts)
|
|
@@ -463,22 +642,10 @@ async function launchInstance(projectPath) {
|
|
|
463
642
|
return { success: false, error: `Failed to adopt codev: ${err.message}` };
|
|
464
643
|
}
|
|
465
644
|
}
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
// SECURITY: Use spawn with cwd option to avoid command injection
|
|
469
|
-
// Do NOT use bash -c with string concatenation
|
|
645
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly
|
|
646
|
+
// No dashboard-server spawning - tower handles everything
|
|
470
647
|
try {
|
|
471
|
-
//
|
|
472
|
-
let afPath = 'af';
|
|
473
|
-
try {
|
|
474
|
-
afPath = execSync('which af', { encoding: 'utf-8' }).trim();
|
|
475
|
-
}
|
|
476
|
-
catch {
|
|
477
|
-
// Fall back to 'af' and hope it's in PATH
|
|
478
|
-
log('WARN', 'Could not resolve af path, using "af"');
|
|
479
|
-
}
|
|
480
|
-
// Don't call "af dash stop" from the tower - it can accidentally kill the tower
|
|
481
|
-
// due to tree-kill or orphan detection. Instead, just clear any stale state file.
|
|
648
|
+
// Clear any stale state file
|
|
482
649
|
const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
|
|
483
650
|
if (fs.existsSync(stateFile)) {
|
|
484
651
|
try {
|
|
@@ -488,68 +655,63 @@ async function launchInstance(projectPath) {
|
|
|
488
655
|
// Ignore - file might not exist or be locked
|
|
489
656
|
}
|
|
490
657
|
}
|
|
491
|
-
//
|
|
492
|
-
// Capture output to detect errors
|
|
493
|
-
const child = spawn(afPath, ['dash', 'start'], {
|
|
494
|
-
detached: true,
|
|
495
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
496
|
-
cwd: projectPath,
|
|
497
|
-
env: process.env,
|
|
498
|
-
});
|
|
499
|
-
// Handle spawn errors (e.g., ENOENT if af not found)
|
|
500
|
-
// Use object wrapper to avoid TypeScript narrowing issues
|
|
501
|
-
const state = { spawnError: null, stdout: '', stderr: '' };
|
|
502
|
-
child.on('error', (err) => {
|
|
503
|
-
state.spawnError = err.message;
|
|
504
|
-
log('ERROR', `Spawn error: ${err.message}`);
|
|
505
|
-
});
|
|
506
|
-
child.stdout?.on('data', (data) => {
|
|
507
|
-
state.stdout += data.toString();
|
|
508
|
-
});
|
|
509
|
-
child.stderr?.on('data', (data) => {
|
|
510
|
-
state.stderr += data.toString();
|
|
511
|
-
});
|
|
512
|
-
// Wait a moment for the process to start (or fail)
|
|
513
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
514
|
-
// Check for spawn error first (e.g., codev not found)
|
|
515
|
-
if (state.spawnError) {
|
|
516
|
-
return {
|
|
517
|
-
success: false,
|
|
518
|
-
error: `Failed to spawn codev: ${state.spawnError}`,
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
// Check if the dashboard port is listening
|
|
522
|
-
// Resolve symlinks (macOS /tmp -> /private/tmp)
|
|
658
|
+
// Ensure project has port allocation
|
|
523
659
|
const resolvedPath = fs.realpathSync(projectPath);
|
|
524
660
|
const db = getGlobalDb();
|
|
525
|
-
|
|
661
|
+
let allocation = db
|
|
526
662
|
.prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
|
|
527
663
|
.get(projectPath, resolvedPath);
|
|
528
|
-
if (allocation) {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
664
|
+
if (!allocation) {
|
|
665
|
+
// Allocate a new port for this project
|
|
666
|
+
// Find the next available port block (starting at 4200, incrementing by 100)
|
|
667
|
+
const existingPorts = db
|
|
668
|
+
.prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
|
|
669
|
+
.all();
|
|
670
|
+
let nextPort = 4200;
|
|
671
|
+
for (const { base_port } of existingPorts) {
|
|
672
|
+
if (base_port >= nextPort) {
|
|
673
|
+
nextPort = base_port + 100;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
db.prepare('INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime("now"))').run(resolvedPath, path.basename(projectPath), nextPort);
|
|
677
|
+
allocation = { base_port: nextPort };
|
|
678
|
+
log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
|
|
679
|
+
}
|
|
680
|
+
// Initialize project terminal entry
|
|
681
|
+
const entry = getProjectTerminalsEntry(resolvedPath);
|
|
682
|
+
// Create architect terminal if not already present
|
|
683
|
+
if (!entry.architect) {
|
|
684
|
+
const manager = getTerminalManager();
|
|
685
|
+
// Read af-config.json to get the architect command
|
|
686
|
+
let architectCmd = 'claude';
|
|
687
|
+
const configPath = path.join(projectPath, 'af-config.json');
|
|
688
|
+
if (fs.existsSync(configPath)) {
|
|
689
|
+
try {
|
|
690
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
691
|
+
if (config.shell?.architect) {
|
|
692
|
+
architectCmd = config.shell.architect;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
// Ignore config read errors, use default
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
const session = await manager.createSession({
|
|
701
|
+
command: architectCmd,
|
|
702
|
+
args: [],
|
|
703
|
+
cwd: projectPath,
|
|
704
|
+
label: 'Architect',
|
|
705
|
+
env: process.env,
|
|
706
|
+
});
|
|
707
|
+
entry.architect = session.id;
|
|
708
|
+
log('INFO', `Created architect terminal for project: ${projectPath}`);
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
log('WARN', `Failed to create architect terminal: ${err.message}`);
|
|
712
|
+
// Don't fail the launch - project is still active, just without architect
|
|
713
|
+
}
|
|
714
|
+
}
|
|
553
715
|
return { success: true, adopted };
|
|
554
716
|
}
|
|
555
717
|
catch (err) {
|
|
@@ -570,26 +732,55 @@ function getProcessOnPort(targetPort) {
|
|
|
570
732
|
}
|
|
571
733
|
}
|
|
572
734
|
/**
|
|
573
|
-
* Stop an agent-farm instance by killing
|
|
735
|
+
* Stop an agent-farm instance by killing all its terminals
|
|
736
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly
|
|
574
737
|
*/
|
|
575
|
-
async function stopInstance(
|
|
738
|
+
async function stopInstance(projectPath) {
|
|
576
739
|
const stopped = [];
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
740
|
+
const manager = getTerminalManager();
|
|
741
|
+
// Resolve symlinks for consistent lookup
|
|
742
|
+
let resolvedPath = projectPath;
|
|
743
|
+
try {
|
|
744
|
+
if (fs.existsSync(projectPath)) {
|
|
745
|
+
resolvedPath = fs.realpathSync(projectPath);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
// Ignore - use original path
|
|
750
|
+
}
|
|
751
|
+
// Get project terminals
|
|
752
|
+
const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
|
|
753
|
+
if (entry) {
|
|
754
|
+
// Kill architect
|
|
755
|
+
if (entry.architect) {
|
|
756
|
+
const session = manager.getSession(entry.architect);
|
|
757
|
+
if (session) {
|
|
758
|
+
manager.killSession(entry.architect);
|
|
759
|
+
stopped.push(session.pid);
|
|
585
760
|
}
|
|
586
|
-
|
|
587
|
-
|
|
761
|
+
}
|
|
762
|
+
// Kill all shells
|
|
763
|
+
for (const terminalId of entry.shells.values()) {
|
|
764
|
+
const session = manager.getSession(terminalId);
|
|
765
|
+
if (session) {
|
|
766
|
+
manager.killSession(terminalId);
|
|
767
|
+
stopped.push(session.pid);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// Kill all builders
|
|
771
|
+
for (const terminalId of entry.builders.values()) {
|
|
772
|
+
const session = manager.getSession(terminalId);
|
|
773
|
+
if (session) {
|
|
774
|
+
manager.killSession(terminalId);
|
|
775
|
+
stopped.push(session.pid);
|
|
588
776
|
}
|
|
589
777
|
}
|
|
778
|
+
// Clear project from registry
|
|
779
|
+
projectTerminals.delete(resolvedPath);
|
|
780
|
+
projectTerminals.delete(projectPath);
|
|
590
781
|
}
|
|
591
782
|
if (stopped.length === 0) {
|
|
592
|
-
return { success: true, error: 'No
|
|
783
|
+
return { success: true, error: 'No terminals found to stop', stopped };
|
|
593
784
|
}
|
|
594
785
|
return { success: true, stopped };
|
|
595
786
|
}
|
|
@@ -610,6 +801,54 @@ function findTemplatePath() {
|
|
|
610
801
|
// escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
|
|
611
802
|
// Find template path
|
|
612
803
|
const templatePath = findTemplatePath();
|
|
804
|
+
// WebSocket server for terminal connections (Phase 2 - Spec 0090)
|
|
805
|
+
let terminalWss = null;
|
|
806
|
+
// React dashboard dist path (for serving directly from tower)
|
|
807
|
+
// React dashboard dist path (for serving directly from tower)
|
|
808
|
+
// Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
|
|
809
|
+
const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
|
|
810
|
+
const hasReactDashboard = fs.existsSync(reactDashboardPath);
|
|
811
|
+
if (hasReactDashboard) {
|
|
812
|
+
log('INFO', `React dashboard found at: ${reactDashboardPath}`);
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
log('WARN', 'React dashboard not found - project dashboards will not work');
|
|
816
|
+
}
|
|
817
|
+
// MIME types for static file serving
|
|
818
|
+
const MIME_TYPES = {
|
|
819
|
+
'.html': 'text/html',
|
|
820
|
+
'.js': 'application/javascript',
|
|
821
|
+
'.css': 'text/css',
|
|
822
|
+
'.json': 'application/json',
|
|
823
|
+
'.png': 'image/png',
|
|
824
|
+
'.jpg': 'image/jpeg',
|
|
825
|
+
'.gif': 'image/gif',
|
|
826
|
+
'.svg': 'image/svg+xml',
|
|
827
|
+
'.ico': 'image/x-icon',
|
|
828
|
+
'.woff': 'font/woff',
|
|
829
|
+
'.woff2': 'font/woff2',
|
|
830
|
+
'.ttf': 'font/ttf',
|
|
831
|
+
'.map': 'application/json',
|
|
832
|
+
};
|
|
833
|
+
/**
|
|
834
|
+
* Serve a static file from the React dashboard dist
|
|
835
|
+
*/
|
|
836
|
+
function serveStaticFile(filePath, res) {
|
|
837
|
+
if (!fs.existsSync(filePath)) {
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
const ext = path.extname(filePath);
|
|
841
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
842
|
+
try {
|
|
843
|
+
const content = fs.readFileSync(filePath);
|
|
844
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
845
|
+
res.end(content);
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
613
852
|
// Create server
|
|
614
853
|
const server = http.createServer(async (req, res) => {
|
|
615
854
|
// Security: Validate Host and Origin headers
|
|
@@ -633,7 +872,222 @@ const server = http.createServer(async (req, res) => {
|
|
|
633
872
|
}
|
|
634
873
|
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
635
874
|
try {
|
|
636
|
-
//
|
|
875
|
+
// =========================================================================
|
|
876
|
+
// NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
|
|
877
|
+
// =========================================================================
|
|
878
|
+
// Health check endpoint (Spec 0090 Phase 1)
|
|
879
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
880
|
+
const instances = await getInstances();
|
|
881
|
+
const activeCount = instances.filter((i) => i.running).length;
|
|
882
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
883
|
+
res.end(JSON.stringify({
|
|
884
|
+
status: 'healthy',
|
|
885
|
+
uptime: process.uptime(),
|
|
886
|
+
activeProjects: activeCount,
|
|
887
|
+
totalProjects: instances.length,
|
|
888
|
+
memoryUsage: process.memoryUsage().heapUsed,
|
|
889
|
+
timestamp: new Date().toISOString(),
|
|
890
|
+
}));
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
// API: List all projects (Spec 0090 Phase 1)
|
|
894
|
+
if (req.method === 'GET' && url.pathname === '/api/projects') {
|
|
895
|
+
const instances = await getInstances();
|
|
896
|
+
const projects = instances.map((i) => ({
|
|
897
|
+
path: i.projectPath,
|
|
898
|
+
name: i.projectName,
|
|
899
|
+
basePort: i.basePort,
|
|
900
|
+
active: i.running,
|
|
901
|
+
proxyUrl: i.proxyUrl,
|
|
902
|
+
terminals: i.terminals.length,
|
|
903
|
+
lastUsed: i.lastUsed,
|
|
904
|
+
}));
|
|
905
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
906
|
+
res.end(JSON.stringify({ projects }));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
// API: Project-specific endpoints (Spec 0090 Phase 1)
|
|
910
|
+
// Routes: /api/projects/:encodedPath/activate, /deactivate, /status
|
|
911
|
+
const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
|
|
912
|
+
if (projectApiMatch) {
|
|
913
|
+
const [, encodedPath, action] = projectApiMatch;
|
|
914
|
+
let projectPath;
|
|
915
|
+
try {
|
|
916
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
917
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
918
|
+
throw new Error('Invalid path');
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
923
|
+
res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// GET /api/projects/:path/status
|
|
927
|
+
if (req.method === 'GET' && action === 'status') {
|
|
928
|
+
const instances = await getInstances();
|
|
929
|
+
const instance = instances.find((i) => i.projectPath === projectPath);
|
|
930
|
+
if (!instance) {
|
|
931
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
932
|
+
res.end(JSON.stringify({ error: 'Project not found' }));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
936
|
+
res.end(JSON.stringify({
|
|
937
|
+
path: instance.projectPath,
|
|
938
|
+
name: instance.projectName,
|
|
939
|
+
active: instance.running,
|
|
940
|
+
basePort: instance.basePort,
|
|
941
|
+
terminals: instance.terminals,
|
|
942
|
+
gateStatus: instance.gateStatus,
|
|
943
|
+
}));
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
// POST /api/projects/:path/activate
|
|
947
|
+
if (req.method === 'POST' && action === 'activate') {
|
|
948
|
+
// Rate limiting: 10 activations per minute per client
|
|
949
|
+
const clientIp = req.socket.remoteAddress || '127.0.0.1';
|
|
950
|
+
if (isRateLimited(clientIp)) {
|
|
951
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
952
|
+
res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const result = await launchInstance(projectPath);
|
|
956
|
+
if (result.success) {
|
|
957
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
958
|
+
res.end(JSON.stringify({ success: true, adopted: result.adopted }));
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
962
|
+
res.end(JSON.stringify({ success: false, error: result.error }));
|
|
963
|
+
}
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// POST /api/projects/:path/deactivate
|
|
967
|
+
if (req.method === 'POST' && action === 'deactivate') {
|
|
968
|
+
// Check if project exists in port allocations
|
|
969
|
+
const allocations = loadPortAllocations();
|
|
970
|
+
const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
|
|
971
|
+
const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
|
|
972
|
+
if (!allocation) {
|
|
973
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
974
|
+
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
// Phase 4: Stop terminals directly via tower
|
|
978
|
+
const result = await stopInstance(projectPath);
|
|
979
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
980
|
+
res.end(JSON.stringify(result));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// =========================================================================
|
|
985
|
+
// TERMINAL API (Phase 2 - Spec 0090)
|
|
986
|
+
// =========================================================================
|
|
987
|
+
// POST /api/terminals - Create a new terminal
|
|
988
|
+
if (req.method === 'POST' && url.pathname === '/api/terminals') {
|
|
989
|
+
try {
|
|
990
|
+
const body = await parseJsonBody(req);
|
|
991
|
+
const manager = getTerminalManager();
|
|
992
|
+
const info = await manager.createSession({
|
|
993
|
+
command: typeof body.command === 'string' ? body.command : undefined,
|
|
994
|
+
args: Array.isArray(body.args) ? body.args : undefined,
|
|
995
|
+
cols: typeof body.cols === 'number' ? body.cols : undefined,
|
|
996
|
+
rows: typeof body.rows === 'number' ? body.rows : undefined,
|
|
997
|
+
cwd: typeof body.cwd === 'string' ? body.cwd : undefined,
|
|
998
|
+
env: typeof body.env === 'object' && body.env !== null ? body.env : undefined,
|
|
999
|
+
label: typeof body.label === 'string' ? body.label : undefined,
|
|
1000
|
+
});
|
|
1001
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1002
|
+
res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}` }));
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
1006
|
+
log('ERROR', `Failed to create terminal: ${message}`);
|
|
1007
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1008
|
+
res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
|
|
1009
|
+
}
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
// GET /api/terminals - List all terminals
|
|
1013
|
+
if (req.method === 'GET' && url.pathname === '/api/terminals') {
|
|
1014
|
+
const manager = getTerminalManager();
|
|
1015
|
+
const terminals = manager.listSessions();
|
|
1016
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1017
|
+
res.end(JSON.stringify({ terminals }));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
// Terminal-specific routes: /api/terminals/:id/*
|
|
1021
|
+
const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
|
|
1022
|
+
if (terminalRouteMatch) {
|
|
1023
|
+
const [, terminalId, subpath] = terminalRouteMatch;
|
|
1024
|
+
const manager = getTerminalManager();
|
|
1025
|
+
// GET /api/terminals/:id - Get terminal info
|
|
1026
|
+
if (req.method === 'GET' && (!subpath || subpath === '')) {
|
|
1027
|
+
const session = manager.getSession(terminalId);
|
|
1028
|
+
if (!session) {
|
|
1029
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1030
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1034
|
+
res.end(JSON.stringify(session.info));
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
// DELETE /api/terminals/:id - Kill terminal
|
|
1038
|
+
if (req.method === 'DELETE' && (!subpath || subpath === '')) {
|
|
1039
|
+
if (!manager.killSession(terminalId)) {
|
|
1040
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1041
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
res.writeHead(204);
|
|
1045
|
+
res.end();
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
// POST /api/terminals/:id/resize - Resize terminal
|
|
1049
|
+
if (req.method === 'POST' && subpath === '/resize') {
|
|
1050
|
+
try {
|
|
1051
|
+
const body = await parseJsonBody(req);
|
|
1052
|
+
if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
|
|
1053
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1054
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const info = manager.resizeSession(terminalId, body.cols, body.rows);
|
|
1058
|
+
if (!info) {
|
|
1059
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1060
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1064
|
+
res.end(JSON.stringify(info));
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1068
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
|
|
1069
|
+
}
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
// GET /api/terminals/:id/output - Get terminal output
|
|
1073
|
+
if (req.method === 'GET' && subpath === '/output') {
|
|
1074
|
+
const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
|
|
1075
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
1076
|
+
const output = manager.getOutput(terminalId, lines, offset);
|
|
1077
|
+
if (!output) {
|
|
1078
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1079
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1083
|
+
res.end(JSON.stringify(output));
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
// =========================================================================
|
|
1088
|
+
// EXISTING API ENDPOINTS
|
|
1089
|
+
// =========================================================================
|
|
1090
|
+
// API: Get status of all instances (legacy - kept for backward compat)
|
|
637
1091
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
638
1092
|
const instances = await getInstances();
|
|
639
1093
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -803,15 +1257,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
803
1257
|
return;
|
|
804
1258
|
}
|
|
805
1259
|
// API: Stop an instance
|
|
1260
|
+
// Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
|
|
806
1261
|
if (req.method === 'POST' && url.pathname === '/api/stop') {
|
|
807
1262
|
const body = await parseJsonBody(req);
|
|
808
|
-
|
|
809
|
-
if
|
|
1263
|
+
let targetPath = body.projectPath;
|
|
1264
|
+
// Backwards compat: if basePort provided, find the project path
|
|
1265
|
+
if (!targetPath && body.basePort) {
|
|
1266
|
+
const allocations = loadPortAllocations();
|
|
1267
|
+
const allocation = allocations.find((a) => a.base_port === body.basePort);
|
|
1268
|
+
targetPath = allocation?.project_path || '';
|
|
1269
|
+
}
|
|
1270
|
+
if (!targetPath) {
|
|
810
1271
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
811
|
-
res.end(JSON.stringify({ success: false, error: 'Missing basePort' }));
|
|
1272
|
+
res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
|
|
812
1273
|
return;
|
|
813
1274
|
}
|
|
814
|
-
const result = await stopInstance(
|
|
1275
|
+
const result = await stopInstance(targetPath);
|
|
815
1276
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
816
1277
|
res.end(JSON.stringify(result));
|
|
817
1278
|
return;
|
|
@@ -834,26 +1295,23 @@ const server = http.createServer(async (req, res) => {
|
|
|
834
1295
|
}
|
|
835
1296
|
return;
|
|
836
1297
|
}
|
|
837
|
-
//
|
|
1298
|
+
// Project routes: /project/:base64urlPath/*
|
|
1299
|
+
// Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
|
|
838
1300
|
// Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
|
|
839
|
-
// All terminals multiplexed on basePort via WebSocket (Spec 0085)
|
|
840
1301
|
if (url.pathname.startsWith('/project/')) {
|
|
841
1302
|
const pathParts = url.pathname.split('/');
|
|
842
|
-
// ['', 'project', base64urlPath,
|
|
1303
|
+
// ['', 'project', base64urlPath, ...rest]
|
|
843
1304
|
const encodedPath = pathParts[2];
|
|
844
|
-
const
|
|
845
|
-
const rest = pathParts.slice(4);
|
|
1305
|
+
const subPath = pathParts.slice(3).join('/');
|
|
846
1306
|
if (!encodedPath) {
|
|
847
1307
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
848
1308
|
res.end('Missing project path');
|
|
849
1309
|
return;
|
|
850
1310
|
}
|
|
851
|
-
// Decode Base64URL (RFC 4648)
|
|
852
|
-
// Wrap in try/catch to handle malformed Base64 input gracefully
|
|
1311
|
+
// Decode Base64URL (RFC 4648)
|
|
853
1312
|
let projectPath;
|
|
854
1313
|
try {
|
|
855
1314
|
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
856
|
-
// Validate decoded path is reasonable (non-empty, looks like absolute path)
|
|
857
1315
|
// Support both POSIX (/) and Windows (C:\) paths
|
|
858
1316
|
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
859
1317
|
throw new Error('Invalid project path');
|
|
@@ -865,35 +1323,198 @@ const server = http.createServer(async (req, res) => {
|
|
|
865
1323
|
return;
|
|
866
1324
|
}
|
|
867
1325
|
const basePort = await getBasePortForProject(projectPath);
|
|
868
|
-
|
|
1326
|
+
// Phase 4 (Spec 0090): Tower handles everything directly
|
|
1327
|
+
const isApiCall = subPath.startsWith('api/') || subPath === 'api';
|
|
1328
|
+
const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
|
|
1329
|
+
// Serve React dashboard static files directly if:
|
|
1330
|
+
// 1. Not an API call
|
|
1331
|
+
// 2. Not a WebSocket path
|
|
1332
|
+
// 3. React dashboard is available
|
|
1333
|
+
// 4. Project doesn't need to be running for static files
|
|
1334
|
+
if (!isApiCall && !isWsPath && hasReactDashboard) {
|
|
1335
|
+
// Determine which static file to serve
|
|
1336
|
+
let staticPath;
|
|
1337
|
+
if (!subPath || subPath === '' || subPath === 'index.html') {
|
|
1338
|
+
staticPath = path.join(reactDashboardPath, 'index.html');
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
// Check if it's a static asset
|
|
1342
|
+
staticPath = path.join(reactDashboardPath, subPath);
|
|
1343
|
+
}
|
|
1344
|
+
// Try to serve the static file
|
|
1345
|
+
if (serveStaticFile(staticPath, res)) {
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
// SPA fallback: serve index.html for client-side routing
|
|
1349
|
+
const indexPath = path.join(reactDashboardPath, 'index.html');
|
|
1350
|
+
if (serveStaticFile(indexPath, res)) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
// Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
|
|
1355
|
+
if (isApiCall) {
|
|
1356
|
+
const apiPath = subPath.replace(/^api\/?/, '');
|
|
1357
|
+
// GET /api/state - Return project state (architect, builders, shells)
|
|
1358
|
+
if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
|
|
1359
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1360
|
+
const manager = getTerminalManager();
|
|
1361
|
+
// Build state response compatible with React dashboard
|
|
1362
|
+
const state = {
|
|
1363
|
+
architect: null,
|
|
1364
|
+
builders: [],
|
|
1365
|
+
utils: [],
|
|
1366
|
+
annotations: [],
|
|
1367
|
+
projectName: path.basename(projectPath),
|
|
1368
|
+
};
|
|
1369
|
+
// Add architect if exists
|
|
1370
|
+
if (entry.architect) {
|
|
1371
|
+
const session = manager.getSession(entry.architect);
|
|
1372
|
+
state.architect = {
|
|
1373
|
+
port: basePort || 0,
|
|
1374
|
+
pid: session?.pid || 0,
|
|
1375
|
+
terminalId: entry.architect,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
// Add shells
|
|
1379
|
+
for (const [shellId, terminalId] of entry.shells) {
|
|
1380
|
+
const session = manager.getSession(terminalId);
|
|
1381
|
+
state.utils.push({
|
|
1382
|
+
id: shellId,
|
|
1383
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
1384
|
+
port: basePort || 0,
|
|
1385
|
+
pid: session?.pid || 0,
|
|
1386
|
+
terminalId,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
// Add builders
|
|
1390
|
+
for (const [builderId, terminalId] of entry.builders) {
|
|
1391
|
+
const session = manager.getSession(terminalId);
|
|
1392
|
+
state.builders.push({
|
|
1393
|
+
id: builderId,
|
|
1394
|
+
name: `Builder ${builderId}`,
|
|
1395
|
+
port: basePort || 0,
|
|
1396
|
+
pid: session?.pid || 0,
|
|
1397
|
+
status: 'running',
|
|
1398
|
+
phase: '',
|
|
1399
|
+
worktree: '',
|
|
1400
|
+
branch: '',
|
|
1401
|
+
type: 'spec',
|
|
1402
|
+
terminalId,
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1406
|
+
res.end(JSON.stringify(state));
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
// POST /api/tabs/shell - Create a new shell terminal
|
|
1410
|
+
if (req.method === 'POST' && apiPath === 'tabs/shell') {
|
|
1411
|
+
try {
|
|
1412
|
+
const manager = getTerminalManager();
|
|
1413
|
+
const shellId = getNextShellId(projectPath);
|
|
1414
|
+
// Create terminal session
|
|
1415
|
+
const session = await manager.createSession({
|
|
1416
|
+
command: process.env.SHELL || '/bin/bash',
|
|
1417
|
+
args: [],
|
|
1418
|
+
cwd: projectPath,
|
|
1419
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
1420
|
+
env: process.env,
|
|
1421
|
+
});
|
|
1422
|
+
// Register terminal with project
|
|
1423
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1424
|
+
entry.shells.set(shellId, session.id);
|
|
1425
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1426
|
+
res.end(JSON.stringify({
|
|
1427
|
+
id: shellId,
|
|
1428
|
+
port: basePort || 0,
|
|
1429
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
1430
|
+
terminalId: session.id,
|
|
1431
|
+
}));
|
|
1432
|
+
}
|
|
1433
|
+
catch (err) {
|
|
1434
|
+
log('ERROR', `Failed to create shell: ${err.message}`);
|
|
1435
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1436
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1437
|
+
}
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
// DELETE /api/tabs/:id - Delete a terminal tab
|
|
1441
|
+
const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
|
|
1442
|
+
if (req.method === 'DELETE' && deleteMatch) {
|
|
1443
|
+
const tabId = deleteMatch[1];
|
|
1444
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1445
|
+
const manager = getTerminalManager();
|
|
1446
|
+
// Find and delete the terminal
|
|
1447
|
+
let terminalId;
|
|
1448
|
+
if (tabId.startsWith('shell-')) {
|
|
1449
|
+
terminalId = entry.shells.get(tabId);
|
|
1450
|
+
if (terminalId) {
|
|
1451
|
+
entry.shells.delete(tabId);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
else if (tabId.startsWith('builder-')) {
|
|
1455
|
+
terminalId = entry.builders.get(tabId);
|
|
1456
|
+
if (terminalId) {
|
|
1457
|
+
entry.builders.delete(tabId);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
else if (tabId === 'architect') {
|
|
1461
|
+
terminalId = entry.architect;
|
|
1462
|
+
if (terminalId) {
|
|
1463
|
+
entry.architect = undefined;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
if (terminalId) {
|
|
1467
|
+
manager.killSession(terminalId);
|
|
1468
|
+
res.writeHead(204);
|
|
1469
|
+
res.end();
|
|
1470
|
+
}
|
|
1471
|
+
else {
|
|
1472
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1473
|
+
res.end(JSON.stringify({ error: 'Tab not found' }));
|
|
1474
|
+
}
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
// POST /api/stop - Stop all terminals for project
|
|
1478
|
+
if (req.method === 'POST' && apiPath === 'stop') {
|
|
1479
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1480
|
+
const manager = getTerminalManager();
|
|
1481
|
+
// Kill all terminals
|
|
1482
|
+
if (entry.architect) {
|
|
1483
|
+
manager.killSession(entry.architect);
|
|
1484
|
+
}
|
|
1485
|
+
for (const terminalId of entry.shells.values()) {
|
|
1486
|
+
manager.killSession(terminalId);
|
|
1487
|
+
}
|
|
1488
|
+
for (const terminalId of entry.builders.values()) {
|
|
1489
|
+
manager.killSession(terminalId);
|
|
1490
|
+
}
|
|
1491
|
+
// Clear registry
|
|
1492
|
+
projectTerminals.delete(projectPath);
|
|
1493
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1494
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
// Unhandled API route
|
|
1498
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1499
|
+
res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
// For WebSocket paths, let the upgrade handler deal with it
|
|
1503
|
+
if (isWsPath) {
|
|
1504
|
+
// WebSocket paths are handled by the upgrade handler
|
|
1505
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1506
|
+
res.end('WebSocket connections should use ws:// protocol');
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
// If we get here for non-API, non-WS paths and React dashboard is not available
|
|
1510
|
+
if (!hasReactDashboard) {
|
|
869
1511
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
870
|
-
res.end('
|
|
1512
|
+
res.end('Dashboard not available');
|
|
871
1513
|
return;
|
|
872
1514
|
}
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
let proxyPath = terminalType ? [terminalType, ...rest].join('/') : rest.join('/');
|
|
877
|
-
// Proxy the request
|
|
878
|
-
const proxyReq = http.request({
|
|
879
|
-
hostname: '127.0.0.1',
|
|
880
|
-
port: targetPort,
|
|
881
|
-
path: '/' + proxyPath + (url.search || ''),
|
|
882
|
-
method: req.method,
|
|
883
|
-
headers: {
|
|
884
|
-
...req.headers,
|
|
885
|
-
host: `localhost:${targetPort}`,
|
|
886
|
-
},
|
|
887
|
-
}, (proxyRes) => {
|
|
888
|
-
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
|
889
|
-
proxyRes.pipe(res);
|
|
890
|
-
});
|
|
891
|
-
proxyReq.on('error', (err) => {
|
|
892
|
-
log('ERROR', `Proxy error: ${err.message}`);
|
|
893
|
-
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
894
|
-
res.end('Proxy error: ' + err.message);
|
|
895
|
-
});
|
|
896
|
-
req.pipe(proxyReq);
|
|
1515
|
+
// Fallback for unmatched paths
|
|
1516
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1517
|
+
res.end('Not found');
|
|
897
1518
|
return;
|
|
898
1519
|
}
|
|
899
1520
|
// 404 for everything else
|
|
@@ -910,20 +1531,37 @@ const server = http.createServer(async (req, res) => {
|
|
|
910
1531
|
server.listen(port, '127.0.0.1', () => {
|
|
911
1532
|
log('INFO', `Tower server listening at http://localhost:${port}`);
|
|
912
1533
|
});
|
|
913
|
-
// WebSocket
|
|
914
|
-
|
|
1534
|
+
// Initialize terminal WebSocket server (Phase 2 - Spec 0090)
|
|
1535
|
+
terminalWss = new WebSocketServer({ noServer: true });
|
|
1536
|
+
// WebSocket upgrade handler for terminal connections and proxying
|
|
915
1537
|
server.on('upgrade', async (req, socket, head) => {
|
|
916
1538
|
const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
|
|
1539
|
+
// Phase 2: Handle /ws/terminal/:id routes directly
|
|
1540
|
+
const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
|
|
1541
|
+
if (terminalMatch) {
|
|
1542
|
+
const terminalId = terminalMatch[1];
|
|
1543
|
+
const manager = getTerminalManager();
|
|
1544
|
+
const session = manager.getSession(terminalId);
|
|
1545
|
+
if (!session) {
|
|
1546
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1547
|
+
socket.destroy();
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
1551
|
+
handleTerminalWebSocket(ws, session, req);
|
|
1552
|
+
});
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
// Phase 4 (Spec 0090): Handle project WebSocket routes directly
|
|
1556
|
+
// Route: /project/:encodedPath/ws/terminal/:terminalId
|
|
917
1557
|
if (!reqUrl.pathname.startsWith('/project/')) {
|
|
918
1558
|
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
919
1559
|
socket.destroy();
|
|
920
1560
|
return;
|
|
921
1561
|
}
|
|
922
1562
|
const pathParts = reqUrl.pathname.split('/');
|
|
923
|
-
// ['', 'project', base64urlPath,
|
|
1563
|
+
// ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
|
|
924
1564
|
const encodedPath = pathParts[2];
|
|
925
|
-
const terminalType = pathParts[3];
|
|
926
|
-
const rest = pathParts.slice(4);
|
|
927
1565
|
if (!encodedPath) {
|
|
928
1566
|
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
929
1567
|
socket.destroy();
|
|
@@ -944,51 +1582,25 @@ server.on('upgrade', async (req, socket, head) => {
|
|
|
944
1582
|
socket.destroy();
|
|
945
1583
|
return;
|
|
946
1584
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1585
|
+
// Check for terminal WebSocket route: /project/:path/ws/terminal/:id
|
|
1586
|
+
const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
|
|
1587
|
+
if (wsMatch) {
|
|
1588
|
+
const terminalId = wsMatch[1];
|
|
1589
|
+
const manager = getTerminalManager();
|
|
1590
|
+
const session = manager.getSession(terminalId);
|
|
1591
|
+
if (!session) {
|
|
1592
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1593
|
+
socket.destroy();
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
1597
|
+
handleTerminalWebSocket(ws, session, req);
|
|
1598
|
+
});
|
|
951
1599
|
return;
|
|
952
1600
|
}
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
let proxyPath = terminalType ? [terminalType, ...rest].join('/') : rest.join('/');
|
|
957
|
-
// Connect to target
|
|
958
|
-
const proxySocket = net.connect(targetPort, '127.0.0.1', () => {
|
|
959
|
-
// Rewrite Origin header for WebSocket compatibility
|
|
960
|
-
const headers = { ...req.headers };
|
|
961
|
-
headers.origin = 'http://localhost';
|
|
962
|
-
headers.host = `localhost:${targetPort}`;
|
|
963
|
-
// Forward the upgrade request
|
|
964
|
-
let headerStr = `${req.method} /${proxyPath}${reqUrl.search || ''} HTTP/1.1\r\n`;
|
|
965
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
966
|
-
if (value) {
|
|
967
|
-
if (Array.isArray(value)) {
|
|
968
|
-
for (const v of value) {
|
|
969
|
-
headerStr += `${key}: ${v}\r\n`;
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
else {
|
|
973
|
-
headerStr += `${key}: ${value}\r\n`;
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
headerStr += '\r\n';
|
|
978
|
-
proxySocket.write(headerStr);
|
|
979
|
-
if (head.length > 0)
|
|
980
|
-
proxySocket.write(head);
|
|
981
|
-
// Pipe bidirectionally
|
|
982
|
-
socket.pipe(proxySocket);
|
|
983
|
-
proxySocket.pipe(socket);
|
|
984
|
-
});
|
|
985
|
-
proxySocket.on('error', (err) => {
|
|
986
|
-
log('ERROR', `WebSocket proxy error: ${err.message}`);
|
|
987
|
-
socket.destroy();
|
|
988
|
-
});
|
|
989
|
-
socket.on('error', () => {
|
|
990
|
-
proxySocket.destroy();
|
|
991
|
-
});
|
|
1601
|
+
// Unhandled WebSocket route
|
|
1602
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1603
|
+
socket.destroy();
|
|
992
1604
|
});
|
|
993
1605
|
// Handle uncaught errors
|
|
994
1606
|
process.on('uncaughtException', (err) => {
|