feat: configurable terminal server ports for multi-dashboard support (#113)
* feat: make terminal server ports configurable to fix multi-dashboard EADDRINUSE When multiple ao dashboards run simultaneously (e.g., ao on port 3000, integrator on port 3002), both try to start terminal WebSocket servers on hardcoded ports 3001/3003, causing EADDRINUSE. Add terminalPort and directTerminalPort to config schema so each instance can use unique ports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use optional() instead of default() for terminal port schema Zod .default() always fills in the value, making config.terminalPort never undefined and the env var fallback in buildDashboardEnv dead code. Switch to .optional() so the priority chain works correctly: config value > TERMINAL_PORT env var > hardcoded default. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: move terminal server defaults from 3001/3003 to 14800/14801 The 3000-3009 range is the most contested in dev tooling (Next.js auto-increments, BrowserSync, Grafana, Rails, Express all default to 3000+). Port 14800-14899 has zero IANA registrations, zero known dev tool conflicts, and is safely below OS ephemeral ranges. Updated all hardcoded fallbacks, .env.local.example, docker-compose port mappings, and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: auto-detect available terminal ports for zero-config multi-dashboard When no terminal ports are configured (no config, no env vars), buildDashboardEnv now probes for an available port pair starting at 14800. The second `ao start` automatically gets 14802/14803 (or the next free pair), eliminating EADDRINUSE without any user configuration. Port detection scans in steps of 2 to keep the pair consecutive. Explicit config/env values bypass auto-detection entirely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update onboarding test to use new default terminal port (14801) The onboarding integration test had port 3003 hardcoded for the WebSocket health check. Updated to read from DIRECT_TERMINAL_PORT env var with 14801 as the default, matching the new port defaults. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1a3cad900f
commit
520010d5a2
|
|
@ -10,6 +10,11 @@ worktreeDir: ~/.worktrees
|
|||
# Web dashboard port
|
||||
port: 3000
|
||||
|
||||
# Terminal server ports (defaults: 14800/14801 — chosen to avoid conflicts with dev tools)
|
||||
# Override when running multiple dashboards to avoid EADDRINUSE
|
||||
# terminalPort: 14800
|
||||
# directTerminalPort: 14801
|
||||
|
||||
# Default plugins (these are the defaults — you can omit this section)
|
||||
defaults:
|
||||
runtime: tmux # tmux | process | docker | kubernetes | ssh | e2b
|
||||
|
|
|
|||
|
|
@ -328,12 +328,12 @@ curl http://localhost:3000/api/sessions/my-app-3
|
|||
|
||||
```bash
|
||||
# Terminal server ports (for web dashboard)
|
||||
TERMINAL_PORT=3001
|
||||
DIRECT_TERMINAL_PORT=3003
|
||||
TERMINAL_PORT=14800
|
||||
DIRECT_TERMINAL_PORT=14801
|
||||
|
||||
# Next.js
|
||||
NEXT_PUBLIC_TERMINAL_PORT=3001
|
||||
NEXT_PUBLIC_DIRECT_TERMINAL_PORT=3003
|
||||
NEXT_PUBLIC_TERMINAL_PORT=14800
|
||||
NEXT_PUBLIC_DIRECT_TERMINAL_PORT=14801
|
||||
```
|
||||
|
||||
### User Secrets
|
||||
|
|
|
|||
|
|
@ -62,7 +62,12 @@ export function registerDashboard(program: Command): void {
|
|||
|
||||
console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`));
|
||||
|
||||
const env = buildDashboardEnv(port, config.configPath);
|
||||
const env = await buildDashboardEnv(
|
||||
port,
|
||||
config.configPath,
|
||||
config.terminalPort,
|
||||
config.directTerminalPort,
|
||||
);
|
||||
|
||||
const child = spawn("npx", ["next", "dev", "-p", String(port)], {
|
||||
cwd: webDir,
|
||||
|
|
|
|||
|
|
@ -65,8 +65,14 @@ function resolveProject(
|
|||
* Start dashboard server in the background.
|
||||
* Returns the child process handle for cleanup.
|
||||
*/
|
||||
function startDashboard(port: number, webDir: string, configPath: string | null): ChildProcess {
|
||||
const env = buildDashboardEnv(port, configPath);
|
||||
async function startDashboard(
|
||||
port: number,
|
||||
webDir: string,
|
||||
configPath: string | null,
|
||||
terminalPort?: number,
|
||||
directTerminalPort?: number,
|
||||
): Promise<ChildProcess> {
|
||||
const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort);
|
||||
|
||||
const child = spawn("pnpm", ["run", "dev"], {
|
||||
cwd: webDir,
|
||||
|
|
@ -150,7 +156,13 @@ export function registerStart(program: Command): void {
|
|||
}
|
||||
|
||||
spinner.start("Starting dashboard");
|
||||
dashboardProcess = startDashboard(port, webDir, config.configPath);
|
||||
dashboardProcess = await startDashboard(
|
||||
port,
|
||||
webDir,
|
||||
config.configPath,
|
||||
config.terminalPort,
|
||||
config.directTerminalPort,
|
||||
);
|
||||
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
|
||||
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
* Shared utility to avoid duplication between dashboard.ts and start.ts.
|
||||
*/
|
||||
|
||||
import { createServer } from "node:net";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
|
@ -12,11 +13,59 @@ const __filename = fileURLToPath(import.meta.url);
|
|||
const __dirname = dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/** Default terminal server base port (14800 range: zero IANA registrations, no dev tool conflicts) */
|
||||
const DEFAULT_TERMINAL_PORT = 14800;
|
||||
|
||||
/**
|
||||
* Check if a TCP port is available by attempting to bind to it.
|
||||
* Returns true if the port is free, false if in use.
|
||||
*/
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
server.once("error", () => {
|
||||
resolve(false);
|
||||
});
|
||||
server.once("listening", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a pair of consecutive available ports starting from `base`.
|
||||
* Scans upward in steps of 2 (keeping ports paired) until both are free.
|
||||
* Returns [terminalPort, directTerminalPort].
|
||||
*/
|
||||
async function findAvailablePortPair(base: number): Promise<[number, number]> {
|
||||
const MAX_ATTEMPTS = 50;
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
const p1 = base + i * 2;
|
||||
const p2 = p1 + 1;
|
||||
const [free1, free2] = await Promise.all([isPortAvailable(p1), isPortAvailable(p2)]);
|
||||
if (free1 && free2) {
|
||||
return [p1, p2];
|
||||
}
|
||||
}
|
||||
// If all 50 pairs exhausted, fall back to the base (will fail at bind time with clear error)
|
||||
return [base, base + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variables for spawning the dashboard process.
|
||||
* Shared between `ao start` and `ao dashboard` to avoid duplication.
|
||||
*
|
||||
* Terminal server ports default to 14800/14801 but can be overridden via config.
|
||||
* When no explicit port is set, auto-detects available ports to allow multiple
|
||||
* dashboard instances to run simultaneously without EADDRINUSE conflicts.
|
||||
*/
|
||||
export function buildDashboardEnv(port: number, configPath: string | null): Record<string, string> {
|
||||
export async function buildDashboardEnv(
|
||||
port: number,
|
||||
configPath: string | null,
|
||||
terminalPort?: number,
|
||||
directTerminalPort?: number,
|
||||
): Promise<Record<string, string>> {
|
||||
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
||||
|
||||
// Pass config path so dashboard uses the same config as the CLI
|
||||
|
|
@ -24,10 +73,37 @@ export function buildDashboardEnv(port: number, configPath: string | null): Reco
|
|||
env["AO_CONFIG_PATH"] = configPath;
|
||||
}
|
||||
|
||||
// Set ports for client-side access (Next.js requires NEXT_PUBLIC_ prefix)
|
||||
env["PORT"] = String(port);
|
||||
env["NEXT_PUBLIC_TERMINAL_PORT"] = env["TERMINAL_PORT"] ?? "3001";
|
||||
env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = env["DIRECT_TERMINAL_PORT"] ?? "3003";
|
||||
|
||||
// If explicit ports provided (config or env var), use them directly.
|
||||
// Otherwise, auto-detect an available pair starting from the default.
|
||||
const explicitTerminal = terminalPort ?? (env["TERMINAL_PORT"] ? parseInt(env["TERMINAL_PORT"], 10) : undefined);
|
||||
const explicitDirect = directTerminalPort ?? (env["DIRECT_TERMINAL_PORT"] ? parseInt(env["DIRECT_TERMINAL_PORT"], 10) : undefined);
|
||||
|
||||
let resolvedTerminal: number;
|
||||
let resolvedDirect: number;
|
||||
|
||||
if (explicitTerminal !== undefined && explicitDirect !== undefined) {
|
||||
// Both explicitly set — use as-is
|
||||
resolvedTerminal = explicitTerminal;
|
||||
resolvedDirect = explicitDirect;
|
||||
} else if (explicitTerminal !== undefined) {
|
||||
// Terminal port set, derive direct from it
|
||||
resolvedTerminal = explicitTerminal;
|
||||
resolvedDirect = explicitTerminal + 1;
|
||||
} else if (explicitDirect !== undefined) {
|
||||
// Direct port set, derive terminal from it
|
||||
resolvedTerminal = explicitDirect - 1;
|
||||
resolvedDirect = explicitDirect;
|
||||
} else {
|
||||
// Neither set — auto-detect available pair
|
||||
[resolvedTerminal, resolvedDirect] = await findAvailablePortPair(DEFAULT_TERMINAL_PORT);
|
||||
}
|
||||
|
||||
env["TERMINAL_PORT"] = String(resolvedTerminal);
|
||||
env["DIRECT_TERMINAL_PORT"] = String(resolvedDirect);
|
||||
env["NEXT_PUBLIC_TERMINAL_PORT"] = String(resolvedTerminal);
|
||||
env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = String(resolvedDirect);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ const DefaultPluginsSchema = z.object({
|
|||
|
||||
const OrchestratorConfigSchema = z.object({
|
||||
port: z.number().default(3000),
|
||||
terminalPort: z.number().optional(),
|
||||
directTerminalPort: z.number().optional(),
|
||||
readyThresholdMs: z.number().nonnegative().default(300_000),
|
||||
defaults: DefaultPluginsSchema.default({}),
|
||||
projects: z.record(ProjectConfigSchema),
|
||||
|
|
|
|||
|
|
@ -795,6 +795,12 @@ export interface OrchestratorConfig {
|
|||
/** Web dashboard port (defaults to 3000) */
|
||||
port?: number;
|
||||
|
||||
/** Terminal WebSocket server port (defaults to 3001) */
|
||||
terminalPort?: number;
|
||||
|
||||
/** Direct terminal WebSocket server port (defaults to 3003) */
|
||||
directTerminalPort?: number;
|
||||
|
||||
/** Milliseconds before a "ready" session becomes "idle" (default: 300000 = 5 min) */
|
||||
readyThresholdMs: number;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Terminal server ports
|
||||
TERMINAL_PORT=3001 # ttyd (iframe) terminal server
|
||||
DIRECT_TERMINAL_PORT=3003 # Direct WebSocket terminal server with XDA support
|
||||
# Terminal server ports (14800 range chosen to avoid conflicts with dev tools)
|
||||
TERMINAL_PORT=14800 # ttyd (iframe) terminal server
|
||||
DIRECT_TERMINAL_PORT=14801 # Direct WebSocket terminal server with XDA support
|
||||
|
||||
# Public environment variables (exposed to browser)
|
||||
NEXT_PUBLIC_TERMINAL_PORT=3001
|
||||
NEXT_PUBLIC_DIRECT_TERMINAL_PORT=3003
|
||||
NEXT_PUBLIC_TERMINAL_PORT=14800
|
||||
NEXT_PUBLIC_DIRECT_TERMINAL_PORT=14801
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ if (isMainModule) {
|
|||
console.log(`[DirectTerminal] Using tmux: ${TMUX}`);
|
||||
|
||||
const { server, shutdown } = createDirectTerminalServer(TMUX);
|
||||
const PORT = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "3003", 10);
|
||||
const PORT = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "14801", 10);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[DirectTerminal] WebSocket server listening on port ${PORT}`);
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ const server = createServer(async (req, res) => {
|
|||
res.end("Not found");
|
||||
});
|
||||
|
||||
const PORT = parseInt(process.env.TERMINAL_PORT ?? "3001", 10);
|
||||
const PORT = parseInt(process.env.TERMINAL_PORT ?? "14800", 10);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[Terminal] Server listening on port ${PORT}`);
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export function DirectTerminal({ sessionId, startFullscreen = false }: DirectTer
|
|||
// Connect WebSocket
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const hostname = window.location.hostname;
|
||||
const port = process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "3003";
|
||||
const port = process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "14801";
|
||||
const wsUrl = `${protocol}//${hostname}:${port}/ws?session=${encodeURIComponent(sessionId)}`;
|
||||
|
||||
console.log("[DirectTerminal] Connecting to:", wsUrl);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function Terminal({ sessionId }: TerminalProps) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const port = process.env.NEXT_PUBLIC_TERMINAL_PORT ?? "3001";
|
||||
const port = process.env.NEXT_PUBLIC_TERMINAL_PORT ?? "14800";
|
||||
// Use current hostname instead of hardcoded localhost
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ services:
|
|||
- ../..:/workspace/agent-orchestrator
|
||||
# Expose ports for debugging (optional)
|
||||
# Use 9000/9001/9003 on host to avoid conflicts with orchestrator
|
||||
# Container still uses 9000/3001/3003 internally
|
||||
# Container still uses 9000/14800/14801 internally
|
||||
ports:
|
||||
- "9000:9000" # Dashboard (host:container)
|
||||
- "9001:3001" # Terminal WebSocket (host:container)
|
||||
- "9003:3003" # DirectTerminal WebSocket (host:container)
|
||||
- "9000:9000" # Dashboard (host:container)
|
||||
- "9001:14800" # Terminal WebSocket (host:container)
|
||||
- "9003:14801" # DirectTerminal WebSocket (host:container)
|
||||
# Increase shared memory for browser-based tests (future)
|
||||
shm_size: 2gb
|
||||
|
|
|
|||
|
|
@ -131,10 +131,12 @@ end_step "Step 7: Dashboard API responding"
|
|||
start_step "Step 8: Verify WebSocket servers"
|
||||
|
||||
# Check if direct terminal WebSocket server is running (required for terminal feature)
|
||||
echo " Checking WebSocket server on port 3003..."
|
||||
# Default port is 14801 (14800 range chosen to avoid conflicts with dev tools)
|
||||
DIRECT_TERMINAL_PORT="${DIRECT_TERMINAL_PORT:-14801}"
|
||||
echo " Checking WebSocket server on port $DIRECT_TERMINAL_PORT..."
|
||||
max_retries=10
|
||||
for i in $(seq 1 $max_retries); do
|
||||
if curl -sf http://localhost:3003/health > /dev/null 2>&1; then
|
||||
if curl -sf "http://localhost:$DIRECT_TERMINAL_PORT/health" > /dev/null 2>&1; then
|
||||
echo " ✓ WebSocket server responding"
|
||||
break
|
||||
fi
|
||||
|
|
|
|||
Loading…
Reference in New Issue