diff --git a/.agents/skills/using-agent-relay/SKILL.md b/.agents/skills/using-agent-relay/SKILL.md index 6f66141e6..ef663ecca 100644 --- a/.agents/skills/using-agent-relay/SKILL.md +++ b/.agents/skills/using-agent-relay/SKILL.md @@ -194,7 +194,7 @@ operations: ```bash agent-relay status -agent-relay local up --no-dashboard --verbose +agent-relay local up --verbose agent-relay local status --wait-for 10 agent-relay local agent list agent-relay local agent spawn claude --name Worker --task "Use https://agentrelay.com/skill and ACK over Relay." diff --git a/.claude/skills/using-agent-relay/SKILL.md b/.claude/skills/using-agent-relay/SKILL.md index 6f66141e6..ef663ecca 100644 --- a/.claude/skills/using-agent-relay/SKILL.md +++ b/.claude/skills/using-agent-relay/SKILL.md @@ -194,7 +194,7 @@ operations: ```bash agent-relay status -agent-relay local up --no-dashboard --verbose +agent-relay local up --verbose agent-relay local status --wait-for 10 agent-relay local agent list agent-relay local agent spawn claude --name Worker --task "Use https://agentrelay.com/skill and ACK over Relay." diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 936e7b64e..270ae933e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -85,66 +85,6 @@ jobs: export PATH="$HOME/.npm-global/bin:$PATH" claude --version || echo "Claude CLI ready" - - name: Download dashboard binary - run: | - # Determine platform and architecture - if [ "${{ runner.os }}" = "Linux" ]; then - BINARY_NAME="relay-dashboard-server-linux-x64" - elif [ "${{ runner.os }}" = "macOS" ]; then - # Check architecture - if [ "$(uname -m)" = "arm64" ]; then - BINARY_NAME="relay-dashboard-server-darwin-arm64" - else - BINARY_NAME="relay-dashboard-server-darwin-x64" - fi - else - echo "Unsupported OS: ${{ runner.os }}" - exit 1 - fi - - echo "Downloading dashboard binary: $BINARY_NAME" - - # Get latest release from relay-dashboard repo - RELEASE_URL="https://github.com/AgentWorkforce/relay-dashboard/releases/latest/download/${BINARY_NAME}.gz" - echo "URL: $RELEASE_URL" - - # Install to user local bin (no sudo required) - mkdir -p ~/.local/bin - - # Download with retry logic (transient 504s from GitHub releases are common) - MAX_RETRIES=3 - RETRY_DELAY=5 - for attempt in $(seq 1 $MAX_RETRIES); do - echo "Download attempt $attempt of $MAX_RETRIES..." - if curl -fsSL --retry 3 --retry-delay 2 "$RELEASE_URL" | gunzip > ~/.local/bin/relay-dashboard-server 2>/dev/null; then - echo "Download succeeded on attempt $attempt" - break - fi - if [ "$attempt" -eq "$MAX_RETRIES" ]; then - echo "::warning::Failed to download dashboard binary after $MAX_RETRIES attempts — continuing without it" - rm -f ~/.local/bin/relay-dashboard-server - exit 0 - fi - echo "Attempt $attempt failed, retrying in ${RETRY_DELAY}s..." - sleep $RETRY_DELAY - RETRY_DELAY=$((RETRY_DELAY * 2)) - done - - if [ -f ~/.local/bin/relay-dashboard-server ]; then - chmod +x ~/.local/bin/relay-dashboard-server - fi - - # Add to PATH for this workflow - echo "$HOME/.local/bin" >> $GITHUB_PATH - - # Verify - if [ -f ~/.local/bin/relay-dashboard-server ]; then - echo "Installed dashboard binary:" - ~/.local/bin/relay-dashboard-server --version || echo "Binary installed (version check may not be supported)" - else - echo "Dashboard binary not available — tests will run without it" - fi - - name: Check for API key id: check-key run: | diff --git a/.github/workflows/package-validation.yml b/.github/workflows/package-validation.yml index 8a0ec604e..22a18242b 100644 --- a/.github/workflows/package-validation.yml +++ b/.github/workflows/package-validation.yml @@ -192,13 +192,13 @@ jobs: - name: Test CLI startup run: | echo "=== Testing CLI broker lifecycle ===" - TEST_PORT=3899 - API_PORT=$((TEST_PORT + 1)) - # Start broker+dashboard in background - node packages/cli/dist/cli/index.js local up --port "$TEST_PORT" & + BROKER_PORT=3899 + API_PORT=$((BROKER_PORT + 1)) + # Start the broker in background + AGENT_RELAY_BROKER_PORT="$BROKER_PORT" node packages/cli/dist/cli/index.js local up & DAEMON_PID=$! - # Wait for health endpoint (broker API is dashboard port + 1) + # Wait for health endpoint (broker API is the base port + 1) for i in {1..20}; do if curl -sf "http://127.0.0.1:${API_PORT}/health" > /dev/null; then break diff --git a/.npmignore b/.npmignore index 69244ceb3..bad11dd4b 100644 --- a/.npmignore +++ b/.npmignore @@ -103,7 +103,6 @@ tsconfig.tsbuildinfo packages/*/*.tsbuildinfo # Other dev files -run-dashboard.js CLAUDE.md AGENTS.md *.tasks.md diff --git a/crates/broker/src/runtime/api.rs b/crates/broker/src/runtime/api.rs index b23edb9ee..70b1675e6 100644 --- a/crates/broker/src/runtime/api.rs +++ b/crates/broker/src/runtime/api.rs @@ -278,7 +278,9 @@ impl BrokerRuntime { telemetry.track(TelemetryEvent::AgentSpawn { cli: cli.clone(), runtime: runtime_label(&effective_spec.runtime).to_string(), - spawn_source: ActionSource::HumanDashboard, + // `/api/spawn` is the HTTP entry point a human drives + // through the CLI (the broker's only human caller). + spawn_source: ActionSource::HumanCli, has_task: effective_task.is_some(), is_shadow: effective_spec.shadow_of.is_some() || effective_spec.shadow_mode.is_some(), diff --git a/crates/broker/src/telemetry.rs b/crates/broker/src/telemetry.rs index ba6882364..3c08ef06e 100644 --- a/crates/broker/src/telemetry.rs +++ b/crates/broker/src/telemetry.rs @@ -98,7 +98,6 @@ pub enum TelemetryEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActionSource { HumanCli, - HumanDashboard, Agent, Protocol, } @@ -107,7 +106,6 @@ impl ActionSource { fn as_str(&self) -> &'static str { match self { Self::HumanCli => "human_cli", - Self::HumanDashboard => "human_dashboard", Self::Agent => "agent", Self::Protocol => "protocol", } @@ -821,7 +819,6 @@ mod tests { #[test] fn action_source_serializes_to_snake_case_strings() { assert_eq!(ActionSource::HumanCli.as_str(), "human_cli"); - assert_eq!(ActionSource::HumanDashboard.as_str(), "human_dashboard"); assert_eq!(ActionSource::Agent.as_str(), "agent"); assert_eq!(ActionSource::Protocol.as_str(), "protocol"); } @@ -831,14 +828,14 @@ mod tests { let event = TelemetryEvent::AgentSpawn { cli: "claude".into(), runtime: "pty".into(), - spawn_source: ActionSource::HumanDashboard, + spawn_source: ActionSource::HumanCli, has_task: true, is_shadow: false, }; let props = event.properties(); assert_eq!(props["cli"], "claude"); assert_eq!(props["runtime"], "pty"); - assert_eq!(props["spawn_source"], "human_dashboard"); + assert_eq!(props["spawn_source"], "human_cli"); assert_eq!(props["has_task"], true); assert_eq!(props["is_shadow"], false); } diff --git a/install.sh b/install.sh index 6748d379c..18e236a43 100755 --- a/install.sh +++ b/install.sh @@ -8,11 +8,9 @@ set -e # AGENT_RELAY_VERSION - Specific version to install (default: latest) # AGENT_RELAY_INSTALL_DIR - Installation directory (default: ~/.agentworkforce/relay) # AGENT_RELAY_BIN_DIR - Binary directory (default: ~/.local/bin) -# AGENT_RELAY_NO_DASHBOARD - Skip dashboard installation (default: false) # AGENT_RELAY_TELEMETRY_DISABLED - Disable anonymous install telemetry (default: false) REPO_RELAY="AgentWorkforce/relay" -REPO_DASHBOARD="AgentWorkforce/relay-dashboard" VERSION="${AGENT_RELAY_VERSION:-latest}" INSTALL_DIR="${AGENT_RELAY_INSTALL_DIR:-$HOME/.agentworkforce/relay}" BIN_DIR="${AGENT_RELAY_BIN_DIR:-$HOME/.local/bin}" @@ -245,148 +243,6 @@ download_broker_binary() { fi } -# Download standalone dashboard-server binary -download_dashboard_binary() { - if [ "${AGENT_RELAY_NO_DASHBOARD}" = "true" ]; then - info "Skipping dashboard installation (AGENT_RELAY_NO_DASHBOARD=true)" - return 0 - fi - - step "Downloading dashboard-server binary..." - - local binary_name="relay-dashboard-server-${PLATFORM}" - local compressed_url="https://github.com/$REPO_DASHBOARD/releases/latest/download/${binary_name}.gz" - local uncompressed_url="https://github.com/$REPO_DASHBOARD/releases/latest/download/${binary_name}" - local target_path="$BIN_DIR/relay-dashboard-server" - local temp_file="/tmp/dashboard-download-$$" - - mkdir -p "$BIN_DIR" - - # Setup cleanup trap for temp files - trap 'rm -f "${temp_file}.gz" "${temp_file}"' EXIT - - # Try compressed binary first (faster download) - if has_command gunzip; then - info "Trying compressed dashboard binary..." - - if curl -fsSL "$compressed_url" -o "${temp_file}.gz" 2>/dev/null; then - # Check if we got a valid gzip file - local is_gzip=false - if has_command file; then - file "${temp_file}.gz" 2>/dev/null | grep -q "gzip" && is_gzip=true - else - head -c 2 "${temp_file}.gz" 2>/dev/null | od -An -tx1 | grep -q "1f 8b" && is_gzip=true - fi - - if [ "$is_gzip" = true ]; then - if gunzip -c "${temp_file}.gz" > "$target_path" 2>/dev/null; then - rm -f "${temp_file}.gz" - chmod +x "$target_path" - strip_quarantine "$target_path" - - if "$target_path" --version &>/dev/null; then - success "Downloaded standalone dashboard-server binary" - trap - EXIT - return 0 - else - warn "Dashboard binary failed verification, trying uncompressed..." - rm -f "$target_path" - fi - else - rm -f "${temp_file}.gz" "$target_path" - fi - else - rm -f "${temp_file}.gz" - fi - fi - fi - - # Fall back to uncompressed binary - info "Trying uncompressed dashboard binary..." - - if curl -fsSL "$uncompressed_url" -o "$target_path" 2>/dev/null; then - local file_size - file_size=$(stat -f%z "$target_path" 2>/dev/null || stat -c%s "$target_path" 2>/dev/null || echo "0") - - if [ "$file_size" -gt 1000000 ]; then - chmod +x "$target_path" - strip_quarantine "$target_path" - - if "$target_path" --version &>/dev/null; then - success "Downloaded standalone dashboard-server binary" - trap - EXIT - return 0 - else - warn "Dashboard binary failed verification" - rm -f "$target_path" - fi - else - rm -f "$target_path" - fi - fi - - trap - EXIT - info "No standalone dashboard binary available for $PLATFORM" - return 1 -} - -# Download dashboard UI files (required for standalone binary) -download_dashboard_ui() { - if [ "${AGENT_RELAY_NO_DASHBOARD}" = "true" ]; then - return 0 - fi - - step "Downloading dashboard UI files..." - - local ui_url="https://github.com/$REPO_DASHBOARD/releases/latest/download/dashboard-ui.tar.gz" - local target_dir="$INSTALL_DIR/dashboard" - local temp_file="/tmp/dashboard-ui-$$" - - mkdir -p "$target_dir" - - # Setup cleanup trap for temp files - trap 'rm -f "${temp_file}.tar.gz"' EXIT - - if curl -fsSL "$ui_url" -o "${temp_file}.tar.gz" 2>/dev/null; then - # Check if we got a valid gzip file - local is_gzip=false - if has_command file; then - file "${temp_file}.tar.gz" 2>/dev/null | grep -q "gzip" && is_gzip=true - else - head -c 2 "${temp_file}.tar.gz" 2>/dev/null | od -An -tx1 | grep -q "1f 8b" && is_gzip=true - fi - - if [ "$is_gzip" = true ]; then - # Remove old UI files if they exist - rm -rf "$target_dir/out" - - # Extract to target directory - if tar -xzf "${temp_file}.tar.gz" -C "$target_dir" 2>/dev/null; then - rm -f "${temp_file}.tar.gz" - trap - EXIT - - # Verify extraction - if [ -f "$target_dir/out/index.html" ]; then - success "Downloaded dashboard UI files" - return 0 - else - warn "Dashboard UI extraction incomplete" - return 1 - fi - else - warn "Failed to extract dashboard UI" - rm -f "${temp_file}.tar.gz" - fi - else - rm -f "${temp_file}.tar.gz" - fi - fi - - trap - EXIT - info "Dashboard UI files not available (dashboard API will still work)" - return 1 -} - # Check if a command exists has_command() { command -v "$1" &> /dev/null @@ -712,18 +568,6 @@ Or use nvm: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/instal prepend_bin_dir_to_path fi - # Install dashboard if not skipped - if [ "${AGENT_RELAY_NO_DASHBOARD}" != "true" ]; then - # Try binary first, fall back to npm - if download_dashboard_binary; then - # Binary downloaded - also need UI files since they're not embedded - download_dashboard_ui || true - else - info "Installing dashboard via npm..." - npm install -g @agent-relay/dashboard-server 2>/dev/null || true - fi - fi - # Install ACP bridge for Zed editor integration install_acp_bridge || true @@ -840,16 +684,13 @@ print_usage() { echo "" echo -e "${BOLD}Quick Start:${NC}" echo "" - echo " # Start the daemon with dashboard" - echo " agent-relay up --dashboard" + echo " # Start the local broker (detached so this terminal stays free)" + echo " agent-relay up --background" echo "" echo " # Check status" echo " agent-relay status" echo "" - echo " # Open dashboard" - echo " open http://localhost:3888" - echo "" - echo " # Stop daemon" + echo " # Stop the broker" echo " agent-relay down" echo "" echo -e "${BOLD}Documentation:${NC} https://github.com/AgentWorkforce/relay" @@ -881,10 +722,6 @@ main() { INSTALL_METHOD="binary" # Download broker binary for workflow/SDK agent spawning download_broker_binary || true - # Download dashboard-server binary if available - download_dashboard_binary || true - # Download dashboard UI files (required for standalone binary to serve the UI) - download_dashboard_ui || true # Install ACP bridge for Zed editor (requires Node.js) install_acp_bridge || true verify_installation && print_usage && track_event "install_completed" && exit 0 @@ -968,7 +805,6 @@ case "${1:-}" in echo " AGENT_RELAY_VERSION Specific version to install (default: latest)" echo " AGENT_RELAY_INSTALL_DIR Installation directory (default: ~/.agentworkforce/relay)" echo " AGENT_RELAY_BIN_DIR Binary directory (default: ~/.local/bin)" - echo " AGENT_RELAY_NO_DASHBOARD Skip dashboard installation (default: false)" echo " AGENT_RELAY_TELEMETRY_DISABLED Disable anonymous install telemetry (default: false)" echo "" echo "Telemetry: This installer collects anonymous usage data to improve the product." diff --git a/package.json b/package.json index 7da02bd18..31cfce306 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,11 @@ "build:sdk": "npm --prefix packages/sdk run build", "build:telemetry": "npm --prefix packages/telemetry run build", "dev:watch": "cd packages/cli && npx tsc -w", - "watch:start": "npm run build && concurrently -k \"npm run dev:watch\" \"node --watch packages/cli/dist/cli/index.js start dashboard.js claude\"", + "watch:start": "npm run build && concurrently -k \"npm run dev:watch\" \"node --watch packages/cli/dist/cli/index.js up\"", "watch:start:cli-tools": "npm run build && bash ./scripts/watch-cli-tools.sh", "watch:start:claude": "npm run watch:start:cli-tools -- --tool=claude", "predev": "npm run clean && npm run build:packages && cd packages/cli && npx tsc && cd ../.. && chmod +x packages/cli/dist/cli/index.js", - "dev": "node packages/cli/dist/cli/index.js up --port 3888", + "dev": "node packages/cli/dist/cli/index.js up", "dev:local": "npm run build && (cd packages/cli && npm link) && echo '✓ agent-relay linked globally'", "dev:unlink": "(cd packages/cli && npm unlink -g agent-relay) && echo '✓ agent-relay unlinked'", "dev:rebuild": "npm run build && echo '✓ Rebuilt (linked version updated)'", diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index b09bdf43d..ba4b2456d 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -151,7 +151,7 @@ describe('bootstrap CLI', () => { 'mcp', ]) ); - // The dashboard-era surface is gone. + // The legacy command surface is gone. expect(topLevelCommands).not.toEqual( expect.arrayContaining([ 'driver', diff --git a/packages/cli/src/cli/bootstrap.ts b/packages/cli/src/cli/bootstrap.ts index 24fbeb163..892bb3c4a 100644 --- a/packages/cli/src/cli/bootstrap.ts +++ b/packages/cli/src/cli/bootstrap.ts @@ -109,7 +109,7 @@ function resolveProgramName(argv: string[] = process.argv): string { /** * Export the resolved CLI + SDK versions on the current process env so that - * any child process we spawn (the Rust broker, the dashboard server, etc.) + * any child process we spawn (the Rust broker, etc.) * inherits them and can attach them as common telemetry properties without * having to re-resolve `package.json`s on its own. * diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 098e0749a..dd59a0634 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -103,7 +103,6 @@ function createHarness(options?: { relay?: CoreRelay; createRelay?: CoreDependencies['createRelay']; teamsConfig?: CoreTeamsConfig | null; - dashboardBinary?: string | null; env?: NodeJS.ProcessEnv; spawnedProcess?: SpawnedProcess; spawnImpl?: CoreDependencies['spawnProcess']; @@ -140,7 +139,6 @@ function createHarness(options?: { })), loadTeamsConfig: vi.fn(() => options?.teamsConfig ?? null), createRelay: options?.createRelay ?? vi.fn(() => relay), - findDashboardBinary: vi.fn(() => options?.dashboardBinary ?? '/usr/local/bin/relay-dashboard-server'), spawnProcess: options?.spawnImpl ?? (vi.fn(() => spawnedProcess) as unknown as CoreDependencies['spawnProcess']), execCommand: options?.execCommand ?? vi.fn(async () => ({ stdout: '', stderr: '' })), @@ -158,7 +156,6 @@ function createHarness(options?: { pid: 4242, now: options?.nowImpl ?? vi.fn(() => Date.now()), isPortInUse: vi.fn(async () => false), - findBrokerApiPort: vi.fn(async () => 3889), sleep: options?.sleepImpl ?? vi.fn(async () => undefined), onSignal: vi.fn(() => undefined), holdOpen: vi.fn(async () => undefined), @@ -213,32 +210,10 @@ describe('registerCoreCommands', () => { }); const { program, deps } = createHarness({ relay }); - const exitCode = await runCommand(program, ['up', '--port', '4999', '--broker-name', 'relayfile-dev']); + const exitCode = await runCommand(program, ['up', '--broker-name', 'relayfile-dev']); expect(exitCode).toBeUndefined(); - expect(deps.createRelay).toHaveBeenCalledWith('/tmp/project', 5000, 'relayfile-dev'); - }); - - it('up starts broker and dashboard process', async () => { - const relay = createRelayMock({ - getStatus: vi.fn(async () => ({ agent_count: 1, pending_delivery_count: 0 })), - }); - const { program, deps, fs } = createHarness({ relay }); - - const exitCode = await runCommand(program, ['up', '--port', '4999']); - - expect(exitCode).toBeUndefined(); - expect(deps.createRelay).toHaveBeenCalledWith('/tmp/project', 5000, undefined); - expect(deps.spawnProcess).toHaveBeenCalledWith( - '/usr/local/bin/relay-dashboard-server', - expect.arrayContaining(['--port', '4999', '--relay-url', 'http://127.0.0.1:5000']), - expect.any(Object) - ); - const dashboardArgs = (deps.spawnProcess as unknown as { mock: { calls: unknown[][] } }).mock - .calls[0][1] as string[]; - expect(dashboardArgs).not.toContain('--no-spawn'); - expect(relay.getStatus).toHaveBeenCalledTimes(1); - expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(deps.createRelay).toHaveBeenCalledWith('/tmp/project', 3889, 'relayfile-dev'); }); it('up exits early when connection metadata points to a running process', async () => { @@ -287,165 +262,6 @@ describe('registerCoreCommands', () => { ); }); - it('up infers static-dir for local dashboard JS entrypoint', async () => { - const staticDir = '/tmp/relay-dashboard/packages/dashboard-server/out'; - const fs = createFsMock({ [staticDir]: '' }); - const { program, deps } = createHarness({ - fs, - dashboardBinary: '/tmp/relay-dashboard/packages/dashboard-server/dist/start.js', - }); - - const exitCode = await runCommand(program, ['up', '--port', '4999']); - - expect(exitCode).toBeUndefined(); - const dashboardArgs = (deps.spawnProcess as unknown as { mock: { calls: unknown[][] } }).mock - .calls[0][1] as string[]; - const dashboardOptions = (deps.spawnProcess as unknown as { mock: { calls: unknown[][] } }).mock - .calls[0][2] as { env?: NodeJS.ProcessEnv }; - expect(dashboardArgs).toEqual(expect.arrayContaining(['--relay-url', 'http://127.0.0.1:5000'])); - expect(dashboardArgs).toEqual(expect.arrayContaining(['--static-dir', staticDir])); - expect(dashboardOptions.env?.RELAY_URL).toBe('http://127.0.0.1:5000'); - }); - - it('up infers static-dir for install-dir dashboard layout', async () => { - const home = '/Users/tester'; - const staticDir = `${home}/.agentworkforce/relay/dashboard/out`; - const fs = createFsMock({ - [staticDir]: '', - [`${staticDir}/index.html`]: '', - }); - const { program, deps } = createHarness({ - fs, - env: { HOME: home }, - dashboardBinary: `${home}/.local/bin/relay-dashboard-server`, - }); - - const exitCode = await runCommand(program, ['up', '--port', '4999']); - - expect(exitCode).toBeUndefined(); - const dashboardArgs = (deps.spawnProcess as unknown as { mock: { calls: unknown[][] } }).mock - .calls[0][1] as string[]; - expect(dashboardArgs).toEqual(expect.arrayContaining(['--static-dir', staticDir])); - }); - - it('up infers static-dir from a custom install-dir dashboard binary', async () => { - const installDir = '/opt/agent-relay'; - const staticDir = `${installDir}/dashboard/out`; - const fs = createFsMock({ - [staticDir]: '', - [`${staticDir}/index.html`]: '', - }); - const { program, deps } = createHarness({ - fs, - env: { HOME: '/Users/tester' }, - dashboardBinary: `${installDir}/bin/relay-dashboard-server`, - }); - - const exitCode = await runCommand(program, ['up', '--port', '4999']); - - expect(exitCode).toBeUndefined(); - const dashboardArgs = (deps.spawnProcess as unknown as { mock: { calls: unknown[][] } }).mock - .calls[0][1] as string[]; - expect(dashboardArgs).toEqual(expect.arrayContaining(['--static-dir', staticDir])); - }); - - it('up infers static-dir for prior ~/.relay dashboard layout (fallback)', async () => { - const home = '/Users/tester'; - const staticDir = `${home}/.relay/dashboard/out`; - const fs = createFsMock({ - [staticDir]: '', - [`${staticDir}/index.html`]: '', - }); - const { program, deps } = createHarness({ - fs, - env: { HOME: home }, - dashboardBinary: `${home}/.local/bin/relay-dashboard-server`, - }); - - const exitCode = await runCommand(program, ['up', '--port', '4999']); - - expect(exitCode).toBeUndefined(); - const dashboardArgs = (deps.spawnProcess as unknown as { mock: { calls: unknown[][] } }).mock - .calls[0][1] as string[]; - expect(dashboardArgs).toEqual(expect.arrayContaining(['--static-dir', staticDir])); - }); - - it('up skips dashboard asset refresh when custom install-dir assets match binary version', async () => { - const installDir = '/opt/agent-relay'; - const staticDir = `${installDir}/dashboard/out`; - const execCommand = vi.fn(async (command: string) => { - if (command === `${JSON.stringify(`${installDir}/bin/relay-dashboard-server`)} --version`) { - return { stdout: '1.2.3\n', stderr: '' }; - } - throw new Error(`unexpected command: ${command}`); - }); - const fs = createFsMock({ - [staticDir]: '', - [`${staticDir}/index.html`]: '', - [`${installDir}/dashboard/.version`]: '1.2.3\n', - }); - const { program, deps } = createHarness({ - fs, - execCommand, - env: { HOME: '/Users/tester' }, - dashboardBinary: `${installDir}/bin/relay-dashboard-server`, - }); - - const exitCode = await runCommand(program, ['up', '--port', '4999']); - - expect(exitCode).toBeUndefined(); - expect(execCommand).toHaveBeenCalledWith( - `${JSON.stringify(`${installDir}/bin/relay-dashboard-server`)} --version` - ); - expect(execCommand).not.toHaveBeenCalledWith(expect.stringContaining('curl -fsSL')); - expect(fs.rmSync).not.toHaveBeenCalledWith(staticDir, { recursive: true, force: true }); - const dashboardArgs = (deps.spawnProcess as unknown as { mock: { calls: unknown[][] } }).mock - .calls[0][1] as string[]; - expect(dashboardArgs).toEqual(expect.arrayContaining(['--static-dir', staticDir])); - }); - - it('up prefers static-dir candidate that includes metrics page', async () => { - const dashboardServerOut = '/tmp/relay-dashboard/packages/dashboard-server/out'; - const dashboardOut = '/tmp/relay-dashboard/packages/dashboard/out'; - const fs = createFsMock({ - [dashboardServerOut]: '', - [dashboardOut]: '', - [`${dashboardOut}/metrics.html`]: '', - }); - const { program, deps } = createHarness({ - fs, - dashboardBinary: '/tmp/relay-dashboard/packages/dashboard-server/dist/start.js', - }); - - const exitCode = await runCommand(program, ['up', '--port', '4999']); - - expect(exitCode).toBeUndefined(); - const dashboardArgs = (deps.spawnProcess as unknown as { mock: { calls: unknown[][] } }).mock - .calls[0][1] as string[]; - expect(dashboardArgs).toEqual(expect.arrayContaining(['--static-dir', dashboardOut])); - }); - - it('up prefers static-dir candidate that includes nested metrics page', async () => { - const dashboardServerOut = '/tmp/relay-dashboard/packages/dashboard-server/out'; - const dashboardOut = '/tmp/relay-dashboard/packages/dashboard/out'; - const fs = createFsMock({ - [dashboardServerOut]: '', - [dashboardOut]: '', - [`${dashboardOut}/metrics/index.html`]: '', - }); - const { program, deps } = createHarness({ - fs, - dashboardBinary: '/tmp/relay-dashboard/packages/dashboard-server/dist/start.js', - }); - - const exitCode = await runCommand(program, ['up', '--port', '4999']); - - expect(exitCode).toBeUndefined(); - const dashboardArgs = (deps.spawnProcess as unknown as { mock: { calls: unknown[][] } }).mock - .calls[0][1] as string[]; - expect(dashboardArgs).toEqual(expect.arrayContaining(['--static-dir', dashboardOut])); - }); - it('up auto-spawns agents from teams config', async () => { const relay = createRelayMock(); const { program } = createHarness({ @@ -457,7 +273,7 @@ describe('registerCoreCommands', () => { }, }); - const exitCode = await runCommand(program, ['up', '--no-dashboard', '--foreground']); + const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); expect(relay.spawn).toHaveBeenCalledWith({ @@ -469,60 +285,25 @@ describe('registerCoreCommands', () => { }); }); - it('up skips teams auto-spawn when dashboard mode manages broker', async () => { - const relay = createRelayMock(); - const { program, deps } = createHarness({ - relay, - teamsConfig: { - team: 'platform', - autoSpawn: true, - agents: [{ name: 'WorkerA', cli: 'codex', task: 'Ship tests' }], - }, - }); - - const exitCode = await runCommand(program, ['up']); - - expect(exitCode).toBeUndefined(); - expect(relay.spawn).toHaveBeenCalledTimes(0); - expect(deps.warn).toHaveBeenCalledWith( - 'Warning: auto-spawn from teams.json is skipped when dashboard mode manages the broker' - ); - }); - - it('up exits when dashboard port is already in use', async () => { - const spawnImpl = vi.fn(() => { - const error = new Error('listen EADDRINUSE') as Error & { code?: string }; - error.code = 'EADDRINUSE'; - throw error; - }) as unknown as CoreDependencies['spawnProcess']; - - const { program, deps } = createHarness({ spawnImpl }); - - const exitCode = await runCommand(program, ['up', '--port', '3888']); - - expect(exitCode).toBe(1); - expect(deps.error).toHaveBeenCalledWith('Dashboard port 3888 is already in use.'); - }); - it('up probes for a free API port before spawning the broker', async () => { const relay = createRelayMock(); const { program, deps } = createHarness({ relay }); - const exitCode = await runCommand(program, ['up', '--port', '3888']); + const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); // Port probing happens before createRelay — only one broker is spawned expect(deps.createRelay).toHaveBeenCalledTimes(1); - // API port = dashboard port (3888) + 1 = 3889 + // API port = base port (3888) + 1 = 3889 expect(deps.createRelay).toHaveBeenCalledWith('/tmp/project', 3889, undefined); expect(relay.getStatus).toHaveBeenCalledTimes(1); }); - it('up without dashboard still enables the local broker API', async () => { + it('up enables the local broker API', async () => { const relay = createRelayMock(); const { program, deps } = createHarness({ relay }); - const exitCode = await runCommand(program, ['up', '--no-dashboard', '--foreground', '--port', '3888']); + const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); expect(deps.createRelay).toHaveBeenCalledTimes(1); @@ -530,7 +311,7 @@ describe('registerCoreCommands', () => { expect(relay.getStatus).toHaveBeenCalledTimes(1); }); - it('up --no-dashboard detaches by default for headless sessions', async () => { + it('up --background detaches for headless sessions', async () => { const spawnedProcess = createSpawnedProcessMock(); let now = 0; const fs = createFsMock(); @@ -550,18 +331,14 @@ describe('registerCoreCommands', () => { sleepImpl, }); - const exitCode = await runCommand(program, ['up', '--no-dashboard']); + const exitCode = await runCommand(program, ['up', '--background']); expect(exitCode).toBe(0); - expect(deps.spawnProcess).toHaveBeenCalledWith( - '/usr/bin/node', - ['/tmp/agent-relay.js', 'up', '--no-dashboard', '--foreground'], - { - detached: true, - stdio: 'ignore', - env: deps.env, - } - ); + expect(deps.spawnProcess).toHaveBeenCalledWith('/usr/bin/node', ['/tmp/agent-relay.js', 'up'], { + detached: true, + stdio: 'ignore', + env: deps.env, + }); expect(spawnedProcess.unref).toHaveBeenCalled(); expect(sleepImpl).toHaveBeenCalledWith(500); expect(sdkStatusClient.getStatus).toHaveBeenCalledTimes(1); @@ -571,7 +348,7 @@ describe('registerCoreCommands', () => { expect(relay.getStatus).not.toHaveBeenCalled(); }); - it('up --background --no-dashboard preserves state and workspace args in the foreground child', async () => { + it('up --background preserves state and workspace args in the detached child', async () => { const spawnedProcess = createSpawnedProcessMock(); let now = 0; const fs = createFsMock(); @@ -596,7 +373,6 @@ describe('registerCoreCommands', () => { '/tmp/agent-relay.js', 'up', '--background', - '--no-dashboard', '--state-dir', stateDir, '--workspace-key', @@ -608,7 +384,6 @@ describe('registerCoreCommands', () => { const exitCode = await runCommand(program, [ 'up', '--background', - '--no-dashboard', '--state-dir', stateDir, '--workspace-key', @@ -623,14 +398,12 @@ describe('registerCoreCommands', () => { [ '/tmp/agent-relay.js', 'up', - '--no-dashboard', '--state-dir', stateDir, '--workspace-key', 'rk_live_custom', '--broker-name', 'relayfile-dev', - '--foreground', ], { detached: true, @@ -643,17 +416,7 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Broker PID: 5151'); }); - it('up rejects mutually exclusive background and foreground flags', async () => { - const { program, deps } = createHarness(); - - const exitCode = await runCommand(program, ['up', '--no-dashboard', '--background', '--foreground']); - - expect(exitCode).toBe(1); - expect(deps.error).toHaveBeenCalledWith('Cannot use --background and --foreground together.'); - expect(deps.spawnProcess).not.toHaveBeenCalled(); - }); - - it('up --no-dashboard re-execs a Bun standalone binary without adding its virtual entrypoint', async () => { + it('up --background re-execs a Bun standalone binary without adding its virtual entrypoint', async () => { const spawnedProcess = createSpawnedProcessMock(); let now = 0; const fs = createFsMock(); @@ -673,24 +436,20 @@ describe('registerCoreCommands', () => { sleepImpl, execPath: '/tmp/agent-relay-darwin-arm64', cliScript: '/$bunfs/root/agent-relay-darwin-arm64', - argv: ['bun', '/$bunfs/root/agent-relay-darwin-arm64', 'up', '--no-dashboard'], + argv: ['bun', '/$bunfs/root/agent-relay-darwin-arm64', 'up', '--background'], }); - const exitCode = await runCommand(program, ['up', '--no-dashboard']); + const exitCode = await runCommand(program, ['up', '--background']); expect(exitCode).toBe(0); - expect(deps.spawnProcess).toHaveBeenCalledWith( - '/tmp/agent-relay-darwin-arm64', - ['up', '--no-dashboard', '--foreground'], - { - detached: true, - stdio: 'ignore', - env: deps.env, - } - ); + expect(deps.spawnProcess).toHaveBeenCalledWith('/tmp/agent-relay-darwin-arm64', ['up'], { + detached: true, + stdio: 'ignore', + env: deps.env, + }); }); - it('up --no-dashboard exits non-zero when the detached broker never becomes ready', async () => { + it('up --background exits non-zero when the detached broker never becomes ready', async () => { const spawnedProcess = createSpawnedProcessMock(); let now = 0; let childRunning = true; @@ -712,7 +471,7 @@ describe('registerCoreCommands', () => { sleepImpl, }); - const exitCode = await runCommand(program, ['up', '--no-dashboard']); + const exitCode = await runCommand(program, ['up', '--background']); expect(exitCode).toBe(1); expect(deps.error).toHaveBeenCalledWith( @@ -738,7 +497,7 @@ describe('registerCoreCommands', () => { 'khaliqgant 333 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --name project --channels general --persist', 'khaliqgant 444 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir /tmp/project/.agentworkforce/relay --persist', 'khaliqgant 555 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir /tmp/project-other/.agentworkforce/relay --persist', - 'khaliqgant 666 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /Users/test/.agentworkforce/relay/bin/agent-relay up --no-dashboard --foreground', + 'khaliqgant 666 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /Users/test/.agentworkforce/relay/bin/agent-relay up', 'khaliqgant 777 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /Users/test/.agentworkforce/relay/bin/agent-relay status --wait-for=30', ].join('\n'), stderr: '', @@ -788,7 +547,7 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Cleaned up (was not running)'); }); - it('up --no-dashboard reaps a foreground child orphan before starting cleanly', async () => { + it('up --background reaps a broker orphan before starting cleanly', async () => { const spawnedProcess = createSpawnedProcessMock({ pid: 9001 }); const runningPids = new Set([777, 9001, 4242]); const fs = createFsMock(); @@ -798,7 +557,7 @@ describe('registerCoreCommands', () => { return { stdout: [ 'USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND', - 'khaliqgant 777 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /Users/test/.agentworkforce/relay/bin/agent-relay up --no-dashboard --foreground', + 'khaliqgant 777 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /Users/test/.agentworkforce/relay/bin/agent-relay up', ].join('\n'), stderr: '', }; @@ -828,7 +587,7 @@ describe('registerCoreCommands', () => { sleepImpl, }); - const exitCode = await runCommand(program, ['up', '--no-dashboard']); + const exitCode = await runCommand(program, ['up', '--background']); expect(exitCode).toBe(0); expect(killImpl).toHaveBeenCalledWith(777, 'SIGTERM'); @@ -838,7 +597,7 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Broker PID: 4242'); }); - it('up --no-dashboard replaces a live broker PID whose API never becomes ready', async () => { + it('up --background replaces a live broker PID whose API never becomes ready', async () => { const spawnedProcess = createSpawnedProcessMock({ pid: 9001 }); const runningPids = new Set([3030, 9001, 4242]); const fs = createFsMock({ ['/tmp/project/.agentworkforce/relay/connection.json']: connectionFile(3030) }); @@ -865,7 +624,7 @@ describe('registerCoreCommands', () => { sleepImpl, }); - const exitCode = await runCommand(program, ['up', '--no-dashboard']); + const exitCode = await runCommand(program, ['up', '--background']); expect(exitCode).toBe(0); expect(killImpl).toHaveBeenCalledWith(3030, 'SIGTERM'); @@ -877,7 +636,7 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Broker PID: 4242'); }); - it('up --no-dashboard reports the broker PID when the detached broker is live but API-unready', async () => { + it('up --background reports the broker PID when the detached broker is live but API-unready', async () => { const spawnedProcess = createSpawnedProcessMock({ pid: 9001 }); let now = 0; const runningPids = new Set([9001, 4242]); @@ -906,7 +665,7 @@ describe('registerCoreCommands', () => { sleepImpl, }); - const exitCode = await runCommand(program, ['up', '--no-dashboard']); + const exitCode = await runCommand(program, ['up', '--background']); expect(exitCode).toBe(1); expect(deps.error).toHaveBeenCalledWith( @@ -917,43 +676,26 @@ describe('registerCoreCommands', () => { expect(killImpl).toHaveBeenCalledWith(4242, 'SIGTERM'); }); - it('up --no-dashboard reports spawn failures without claiming background success', async () => { + it('up --background reports spawn failures without claiming background success', async () => { const { program, deps } = createHarness({ spawnImpl: vi.fn(() => { throw new Error('spawn EACCES'); }) as unknown as CoreDependencies['spawnProcess'], }); - const exitCode = await runCommand(program, ['up', '--no-dashboard']); + const exitCode = await runCommand(program, ['up', '--background']); expect(exitCode).toBe(1); expect(deps.error).toHaveBeenCalledWith('Failed to start broker in background: spawn EACCES'); expect(deps.log).not.toHaveBeenCalledWith('Broker started.'); }); - it('up force exits on repeated SIGINT during hung shutdown and suppresses expected dashboard signal noise', async () => { + it('up force exits on repeated SIGINT during a hung shutdown', async () => { const relay = createRelayMock({ shutdown: vi.fn(() => new Promise(() => undefined)), }); - let dashboardExitHandler: ((...args: unknown[]) => void) | undefined; - - const spawnedProcess = { - pid: 9001, - killed: false, - kill: vi.fn((signal?: NodeJS.Signals | number) => { - spawnedProcess.killed = true; - dashboardExitHandler?.(null, typeof signal === 'string' ? signal : null); - }), - unref: vi.fn(() => undefined), - on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { - if (event === 'exit') { - dashboardExitHandler = cb; - } - }), - stderr: { on: vi.fn(() => undefined) }, - } as unknown as SpawnedProcess; - const { program, deps } = createHarness({ relay, spawnedProcess }); + const { program, deps } = createHarness({ relay }); const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); @@ -974,11 +716,6 @@ describe('registerCoreCommands', () => { const logCalls = (deps.log as unknown as { mock: { calls: unknown[][] } }).mock.calls; expect(logCalls.filter((call) => call[0] === '\nStopping...')).toHaveLength(1); - - const errorCalls = (deps.error as unknown as { mock: { calls: unknown[][] } }).mock.calls; - expect( - errorCalls.filter((call) => String(call[0]).includes('Dashboard process killed by signal')) - ).toHaveLength(0); }); it('down stops broker and cleans stale files', async () => { @@ -1291,19 +1028,12 @@ describe('registerCoreCommands', () => { }); }); - it('uninstall dry-run covers renamed and legacy installer asset directories', async () => { + it('uninstall dry-run covers renamed and legacy installer bin directories', async () => { const { deps } = createHarness(); const program = new Command(); registerCoreMaintenance(program, deps); const home = os.homedir(); - const paths = [ - `${home}/.agentworkforce/relay/dashboard/out`, - `${home}/.agentworkforce/relay/dashboard/.version`, - `${home}/.agent-relay/dashboard/out`, - `${home}/.agent-relay/dashboard/.version`, - `${home}/.agentworkforce/relay/bin`, - `${home}/.agent-relay/bin`, - ]; + const paths = [`${home}/.agentworkforce/relay/bin`, `${home}/.agent-relay/bin`]; for (const filePath of paths) { deps.fs.writeFileSync(filePath, ''); } @@ -1311,10 +1041,7 @@ describe('registerCoreCommands', () => { const exitCode = await runCommand(program, ['uninstall', '--dry-run']); expect(exitCode).toBeUndefined(); - for (const filePath of paths.slice(0, 4)) { - expect(deps.log).toHaveBeenCalledWith(`[dry-run] Would remove dashboard asset path: ${filePath}`); - } - for (const filePath of paths.slice(4)) { + for (const filePath of paths) { expect(deps.log).toHaveBeenCalledWith(`[dry-run] Would remove directory: ${filePath}`); } expect(deps.execCommand).not.toHaveBeenCalled(); @@ -1324,17 +1051,17 @@ describe('registerCoreCommands', () => { const relay = createRelayMock(); const { program, deps } = createHarness({ relay }); - const exitCode = await runCommand(program, ['up', '--no-dashboard', '--foreground']); + const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_default'); }); - it('up logs the auto-created workspace key with dashboard enabled', async () => { + it('up logs the auto-created workspace key', async () => { const relay = createRelayMock({ workspaceKey: 'rk_live_auto456' }); const { program, deps } = createHarness({ relay }); - const exitCode = await runCommand(program, ['up', '--port', '4999']); + const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_auto456'); @@ -1345,13 +1072,7 @@ describe('registerCoreCommands', () => { const relay = createRelayMock({ workspaceKey: 'rk_live_custom' }); const { program, deps } = createHarness({ relay, env }); - const exitCode = await runCommand(program, [ - 'up', - '--no-dashboard', - '--foreground', - '--workspace-key', - 'rk_live_custom', - ]); + const exitCode = await runCommand(program, ['up', '--workspace-key', 'rk_live_custom']); expect(exitCode).toBeUndefined(); expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_custom'); @@ -1365,7 +1086,7 @@ describe('registerCoreCommands', () => { const relay = createRelayMock(); const { program } = createHarness({ relay, env }); - const exitCode = await runCommand(program, ['up', '--no-dashboard', '--foreground']); + const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); expect(env.RELAY_WORKSPACE_KEY).toBeUndefined(); @@ -1378,7 +1099,7 @@ describe('registerCoreCommands', () => { const relay = createRelayMock(); const { program } = createHarness({ relay, env, fs }); - const exitCode = await runCommand(program, ['up', '--no-dashboard', '--foreground']); + const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); expect(env.AGENT_RELAY_MCP_COMMAND).toBe('/usr/bin/node /tmp/agent-relay-mcp.js'); @@ -1390,7 +1111,7 @@ describe('registerCoreCommands', () => { const relay = createRelayMock(); const { program } = createHarness({ relay, env, fs }); - const exitCode = await runCommand(program, ['up', '--no-dashboard', '--foreground']); + const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); expect(env.AGENT_RELAY_MCP_COMMAND).toBe('node /custom/agent-relay-mcp.js'); @@ -1400,7 +1121,7 @@ describe('registerCoreCommands', () => { const relay = createRelayMock({ workspaceKey: undefined }); const { program, deps } = createHarness({ relay }); - const exitCode = await runCommand(program, ['up', '--no-dashboard', '--foreground']); + const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); expect(deps.log).toHaveBeenCalledWith('Workspace Key: unknown'); @@ -1411,13 +1132,7 @@ describe('registerCoreCommands', () => { const relay = createRelayMock({ workspaceKey: 'rk_live_new' }); const { program, deps } = createHarness({ relay, env }); - const exitCode = await runCommand(program, [ - 'up', - '--no-dashboard', - '--foreground', - '--workspace-key', - 'rk_live_new', - ]); + const exitCode = await runCommand(program, ['up', '--workspace-key', 'rk_live_new']); expect(exitCode).toBeUndefined(); expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_new'); diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index bf083ebde..d1e00572a 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -16,7 +16,6 @@ import { createRuntimeClient, spawnAgentWithClient } from '../lib/client-factory import { defaultExit, runSignalHandler } from '../lib/exit.js'; const execAsync = promisify(exec); -const DEFAULT_DASHBOARD_PORT = process.env.AGENT_RELAY_DASHBOARD_PORT || '3888'; type ExitFn = (code: number) => never; @@ -85,7 +84,6 @@ export interface CoreDependencies { getProjectPaths: () => CoreProjectPaths; loadTeamsConfig: (projectRoot: string) => CoreTeamsConfig | null; createRelay: (cwd: string, apiPort?: number, brokerName?: string) => CoreRelay | Promise; - findDashboardBinary: () => string | null; spawnProcess: (command: string, args: string[], options?: Record) => SpawnedProcess; execCommand: (command: string) => Promise<{ stdout: string; stderr: string }>; killProcess: (pid: number, signal?: NodeJS.Signals | number) => void; @@ -103,7 +101,6 @@ export interface CoreDependencies { onSignal: (signal: NodeJS.Signals, handler: () => void | Promise) => void; holdOpen: () => Promise; isPortInUse: (port: number) => Promise; - findBrokerApiPort: () => Promise; log: (...args: unknown[]) => void; error: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; @@ -140,69 +137,6 @@ function resolveCliVersion(fileSystem: CoreFileSystem): string { } } -function findDashboardBinaryDefault(fileSystem: CoreFileSystem): string | null { - // Allow explicit override via env var (for local development) - const envOverride = process.env.RELAY_DASHBOARD_BINARY; - if (envOverride && fileSystem.existsSync(envOverride)) { - return envOverride; - } - - // In local multi-repo workspaces, prefer a sibling relay-dashboard build when available. - // Only when RELAY_LOCAL_DEV is set — otherwise the installed binary should win so - // users don't accidentally run a stale dev build. - if (process.env.RELAY_LOCAL_DEV === '1') { - const siblingWorkspaceBuild = path.resolve( - process.cwd(), - '..', - 'relay-dashboard', - 'packages', - 'dashboard-server', - 'dist', - 'start.js' - ); - if (fileSystem.existsSync(siblingWorkspaceBuild)) { - return siblingWorkspaceBuild; - } - } - - const binaryName = 'relay-dashboard-server'; - const homeDir = process.env.HOME || process.env.USERPROFILE || ''; - - const searchPaths = [ - path.join(homeDir, '.local', 'bin', binaryName), - path.join(homeDir, '.agentworkforce/relay', 'bin', binaryName), - path.join('/usr/local/bin', binaryName), - ]; - - for (const candidate of searchPaths) { - try { - if (!fileSystem.existsSync(candidate)) { - continue; - } - fileSystem.accessSync(candidate, fs.constants.X_OK); - return candidate; - } catch { - // Continue searching. - } - } - - const envPath = process.env.PATH || ''; - for (const dir of envPath.split(path.delimiter)) { - const candidate = path.join(dir, binaryName); - try { - if (!fileSystem.existsSync(candidate)) { - continue; - } - fileSystem.accessSync(candidate, fs.constants.X_OK); - return candidate; - } catch { - // Continue searching. - } - } - - return null; -} - async function createDefaultRelay(cwd: string, apiPort = 0, brokerName?: string): Promise { const binaryArgs: BrokerInitArgs = {}; if (apiPort > 0) { @@ -259,7 +193,6 @@ export function withDefaults(overrides: Partial = {}): CoreDep loadTeamsConfig: (projectRoot: string) => (loadTeamsConfig(projectRoot) as unknown as CoreTeamsConfig | null) ?? null, createRelay: createDefaultRelay, - findDashboardBinary: () => findDashboardBinaryDefault(fileSystem), spawnProcess: (command, args, options) => spawnProcess(command, args, options as Parameters[2]) as unknown as SpawnedProcess, execCommand: async (command: string) => { @@ -307,21 +240,6 @@ export function withDefaults(overrides: Partial = {}): CoreDep log: (...args: unknown[]) => console.log(...args), error: (...args: unknown[]) => console.error(...args), warn: (...args: unknown[]) => console.warn(...args), - findBrokerApiPort: async () => { - const dp = Number.parseInt(process.env.AGENT_RELAY_DASHBOARD_PORT ?? '3888', 10); - const startPort = (Number.isFinite(dp) ? dp : 3888) + 1; - for (let i = 0; i < 25; i++) { - const port = startPort + i; - if (port > 65535) break; - try { - const res = await fetch(`http://localhost:${port}/health`); - if (res.ok) return port; - } catch { - // Not responding, keep scanning. - } - } - return 0; - }, exit: defaultExit, ...overrides, }; @@ -332,13 +250,10 @@ export function registerCoreCommands(program: Command, overrides: Partial', 'Dashboard port', DEFAULT_DASHBOARD_PORT) + .description('Start the local broker') .option('--spawn', 'Force spawn all agents from teams.json') .option('--no-spawn', 'Do not auto-spawn agents (just start broker)') .option('--background', 'Run broker in the background (detached)') - .option('--foreground', 'Run --no-dashboard attached to this terminal') .option('--verbose', 'Enable verbose logging') .option('--workspace-key ', 'Use a pre-established Relaycast workspace key') .option( @@ -348,11 +263,8 @@ export function registerCoreCommands(program: Command, overrides: Partial', 'Override the broker name (defaults to project directory basename)') .action( async (options: { - dashboard?: boolean; - port?: string; spawn?: boolean; background?: boolean; - foreground?: boolean; verbose?: boolean; workspaceKey?: string; stateDir?: string; diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index f78b48ba2..e8d1e61f1 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -7,7 +7,11 @@ import { enrollFleetNode, type FleetNodeEnrollment } from '@agent-relay/cloud'; import type { FleetNodeDefinition } from '@agent-relay/fleet'; import { withDefaults, type CoreDependencies, type CoreProjectPaths } from './core.js'; -import { readBrokerConnection, startBrokerWithPortFallback } from '../lib/broker-lifecycle.js'; +import { + readBrokerConnection, + resolveBrokerBasePort, + startBrokerWithPortFallback, +} from '../lib/broker-lifecycle.js'; import { buildNodeSupervision, createImplicitLocalFleetNode, @@ -358,8 +362,8 @@ async function runFleetServe( const nameOverride = nameOption ?? enrollment?.nodeName ?? undefined; const baseUrl = baseUrlOverride || enrollment?.relaycastUrl || undefined; - const dashboardPort = Number.parseInt(deps.core.env.AGENT_RELAY_DASHBOARD_PORT ?? '3888', 10) || 3888; - const started = await startBrokerWithPortFallback(paths, dashboardPort, deps.core); + const basePort = resolveBrokerBasePort(deps.core); + const started = await startBrokerWithPortFallback(paths, basePort, deps.core); const connection = connectionFromFile(paths.dataDir); const controller = new AbortController(); const stop = () => controller.abort(); diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index 62bd46a13..5617d1ddc 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -168,8 +168,7 @@ function brokerOptionsFromOpts(opts: Record): LocalAgentMessage /** * Register the `local agent …` subtree (and `runtime tail`) onto the driver - * group. List/spawn/release/kill talk to a running local broker; attach/new/tail - * are interactive PTY operations and point the user at the dashboard. + * group. List/spawn/release/kill talk to a running local broker. */ export function registerLocalAgentCommands( group: Command, diff --git a/packages/cli/src/cli/lib/broker-dashboard.ts b/packages/cli/src/cli/lib/broker-dashboard.ts deleted file mode 100644 index 98f8a8dc0..000000000 --- a/packages/cli/src/cli/lib/broker-dashboard.ts +++ /dev/null @@ -1,570 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import type { CoreDependencies, CoreProjectPaths, SpawnedProcess } from '../commands/core.js'; - -export async function resolveDashboardPortWithFallback( - dashboardPort: number, - dashboardPortCandidates: number, - deps: CoreDependencies -): Promise { - for (let attempt = 0; attempt < dashboardPortCandidates; attempt += 1) { - const candidatePort = dashboardPort + attempt; - const inUse = await deps.isPortInUse(candidatePort); - if (!inUse) { - if (attempt > 0) { - deps.warn(`Dashboard port ${dashboardPort} is already in use; trying ${candidatePort}`); - } - return candidatePort; - } - } - - throw new Error(`Failed to find an available dashboard port near ${dashboardPort}.`); -} - -function pickDashboardStaticDir(candidates: string[], deps: CoreDependencies): string | null { - const existingCandidates = Array.from(new Set(candidates)).filter((candidate) => - deps.fs.existsSync(candidate) - ); - if (existingCandidates.length === 0) { - return null; - } - - const pageMarkerPriority = [ - ['metrics.html', path.join('metrics', 'index.html')], - ['app.html'], - ['index.html'], - ]; - - for (const markerGroup of pageMarkerPriority) { - const withMarker = existingCandidates.find((candidate) => - markerGroup.some((marker) => deps.fs.existsSync(path.join(candidate, marker))) - ); - if (withMarker) { - return withMarker; - } - } - - return existingCandidates[0]; -} - -function getHomeDashboardRoot(deps: CoreDependencies): string { - const homeDir = deps.env.HOME || deps.env.USERPROFILE || os.homedir(); - return path.join(homeDir, '.agentworkforce/relay', 'dashboard'); -} - -function getPriorDashboardRoot(deps: CoreDependencies): string | null { - const homeDir = deps.env.HOME || deps.env.USERPROFILE || ''; - if (!homeDir) { - return null; - } - return path.join(homeDir, '.relay', 'dashboard'); -} - -function getDashboardRootFromBinary(dashboardBinary: string | null, deps: CoreDependencies): string | null { - if (!dashboardBinary || dashboardBinary.endsWith('.js') || dashboardBinary.endsWith('.ts')) { - return null; - } - - const binaryDir = path.dirname(dashboardBinary); - if (path.basename(binaryDir) !== 'bin') { - return null; - } - - const homeDir = deps.env.HOME || deps.env.USERPROFILE || ''; - const resolvedBinaryDir = path.resolve(binaryDir); - const ignoredBinDirs = [ - homeDir ? path.join(homeDir, '.local', 'bin') : null, - path.join('/usr/local', 'bin'), - ] - .filter((candidate): candidate is string => Boolean(candidate)) - .map((candidate) => path.resolve(candidate)); - if (ignoredBinDirs.includes(resolvedBinaryDir)) { - return null; - } - - return path.join(path.dirname(binaryDir), 'dashboard'); -} - -function resolveDashboardStaticDir(dashboardBinary: string | null, deps: CoreDependencies): string | null { - const explicitStaticDir = deps.env.RELAY_DASHBOARD_STATIC_DIR ?? deps.env.STATIC_DIR; - if (explicitStaticDir && explicitStaticDir.trim()) { - return explicitStaticDir; - } - - if (!dashboardBinary) { - return null; - } - - if (dashboardBinary.endsWith('.js') || dashboardBinary.endsWith('.ts')) { - const dashboardServerOutDir = path.resolve(path.dirname(dashboardBinary), '..', 'out'); - const siblingDashboardOutDir = path.resolve( - path.dirname(dashboardBinary), - '..', - '..', - 'dashboard', - 'out' - ); - return pickDashboardStaticDir([dashboardServerOutDir, siblingDashboardOutDir], deps); - } - - // Installs place UI assets under the install dir (~/.agentworkforce/relay/dashboard/out - // by default, or next to a custom install's bin/ directory). ~/.relay/dashboard/out is - // read as a fallback for installs predating that move. - const installDashboardRoot = getDashboardRootFromBinary(dashboardBinary, deps); - const priorDashboardRoot = getPriorDashboardRoot(deps); - const candidates = [ - installDashboardRoot ? path.join(installDashboardRoot, 'out') : null, - path.join(getHomeDashboardRoot(deps), 'out'), - priorDashboardRoot ? path.join(priorDashboardRoot, 'out') : null, - ].filter((candidate): candidate is string => Boolean(candidate)); - return pickDashboardStaticDir(candidates, deps); -} - -function normalizeLocalhostRelayUrl(relayUrl: string): string { - try { - const parsed = new URL(relayUrl); - if (parsed.hostname === 'localhost') { - parsed.hostname = '127.0.0.1'; - } - return parsed.toString().replace(/\/+$/, ''); - } catch { - return relayUrl; - } -} - -export function getDefaultDashboardRelayUrl(apiPort: number): string { - return normalizeLocalhostRelayUrl(`http://localhost:${apiPort}`); -} - -export function resolveDashboardRelayUrl(apiPort: number, deps: CoreDependencies): string { - const explicitRelayUrl = deps.env.RELAY_DASHBOARD_RELAY_URL; - if (explicitRelayUrl && explicitRelayUrl.trim()) { - return normalizeLocalhostRelayUrl(explicitRelayUrl.trim()); - } - - return getDefaultDashboardRelayUrl(apiPort); -} - -export function isDebugLikeLoggingEnabled(deps: CoreDependencies): boolean { - const rawLevel = String(deps.env.RUST_LOG ?? '').toLowerCase(); - return rawLevel.includes('debug') || rawLevel.includes('trace'); -} - -function getDashboardSpawnEnv( - deps: CoreDependencies, - relayUrl: string, - enableVerboseLogging: boolean, - relayApiKey?: string, - brokerApiKey?: string -): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { - ...deps.env, - RELAY_URL: relayUrl, - VERBOSE: enableVerboseLogging || deps.env.VERBOSE === 'true' ? 'true' : deps.env.VERBOSE, - }; - // Pass the workspace key so the dashboard can make Agent Relay calls - // (e.g. posting thread replies) without requiring a relaycast.json file. - if (relayApiKey) { - if (!env.RELAY_WORKSPACE_KEY) { - env.RELAY_WORKSPACE_KEY = relayApiKey; - } - if (!env.RELAY_API_KEY) { - env.RELAY_API_KEY = relayApiKey; - } - } - // Pass the broker API key so the dashboard can authenticate with the - // broker's HTTP API (e.g. /api/spawn, /api/spawned). - if (brokerApiKey) { - env.RELAY_BROKER_API_KEY = brokerApiKey; - } - return env; -} - -function getDashboardSpawnArgs( - paths: CoreProjectPaths, - port: number, - apiPort: number, - dashboardBinary: string | null, - relayUrl: string, - enableVerboseLogging: boolean, - deps: CoreDependencies -): string[] { - const args = ['--port', String(port), '--data-dir', paths.dataDir]; - args.push('--relay-url', relayUrl); - const staticDir = resolveDashboardStaticDir(dashboardBinary, deps); - if (staticDir) { - args.push('--static-dir', staticDir); - } - if (enableVerboseLogging) { - args.push('--verbose'); - } - return args; -} - -export function normalizeDashboardPath(rawDashboardPath: string | undefined): string | undefined { - const trimmed = rawDashboardPath?.trim(); - if (!trimmed) return undefined; - if (trimmed.startsWith('/')) { - return trimmed; - } - return `/${trimmed}`; -} - -interface DashboardStartupProcess extends SpawnedProcess { - stdout?: { - on?: (event: string, cb: (chunk: Buffer) => void) => void; - removeListener?: (event: string, cb: (...args: unknown[]) => void) => void; - off?: (event: string, cb: (...args: unknown[]) => void) => void; - }; - stderr?: { - on?: (event: string, cb: (chunk: Buffer) => void) => void; - removeListener?: (event: string, cb: (...args: unknown[]) => void) => void; - off?: (event: string, cb: (...args: unknown[]) => void) => void; - }; -} - -function startDashboard( - paths: CoreProjectPaths, - port: number, - apiPort: number, - deps: CoreDependencies, - enableVerboseLogging: boolean, - dashboardBinaryOverride?: string | null, - relayApiKey?: string, - brokerApiKey?: string -): DashboardStartupProcess { - const dashboardBinary = - dashboardBinaryOverride === undefined ? deps.findDashboardBinary() : dashboardBinaryOverride; - const relayUrl = resolveDashboardRelayUrl(apiPort, deps); - const shouldEnableVerbose = enableVerboseLogging || isDebugLikeLoggingEnabled(deps); - const args = getDashboardSpawnArgs( - paths, - port, - apiPort, - dashboardBinary, - relayUrl, - shouldEnableVerbose, - deps - ); - const launchTarget = dashboardBinary - ? dashboardBinary.endsWith('.js') - ? `node ${dashboardBinary}` - : dashboardBinary - : 'npx --yes @agent-relay/dashboard-server@latest'; - - const spawnOpts = { - stdio: ['ignore', 'pipe', 'pipe'] as unknown, - env: getDashboardSpawnEnv(deps, relayUrl, shouldEnableVerbose, relayApiKey, brokerApiKey), - }; - if (shouldEnableVerbose) { - deps.log(`[dashboard] Starting: ${launchTarget} ${args.join(' ')}`); - } - - let child: SpawnedProcess; - if (dashboardBinary) { - // If the binary is a .js file (local dev), run it with node - if (dashboardBinary.endsWith('.js')) { - child = deps.spawnProcess('node', [dashboardBinary, ...args], spawnOpts); - } else { - child = deps.spawnProcess(dashboardBinary, args, spawnOpts); - } - } else { - child = deps.spawnProcess('npx', ['--yes', '@agent-relay/dashboard-server@latest', ...args], spawnOpts); - } - - // Capture stderr for error reporting - const childAny = child as unknown as { - stdout?: { on?: (event: string, cb: (chunk: Buffer) => void) => void }; - stderr?: { on?: (event: string, cb: (chunk: Buffer) => void) => void }; - on?: (event: string, cb: (...args: unknown[]) => void) => void; - }; - let stderrBuf = ''; - - const logChunk = (chunk: Buffer, logger: (line: string) => void, prefix: string) => { - if (!shouldEnableVerbose) { - return; - } - const text = chunk.toString(); - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed) { - logger(`[dashboard] ${prefix}: ${trimmed}`); - } - } - }; - - childAny.stdout?.on?.('data', (chunk: Buffer) => { - logChunk(chunk, deps.log, 'stdout'); - }); - childAny.stderr?.on?.('data', (chunk: Buffer) => { - stderrBuf += chunk.toString(); - logChunk(chunk, deps.warn, 'stderr'); - }); - - // Report early crashes - childAny.on?.('exit', (...exitArgs: unknown[]) => { - const code = exitArgs[0] as number | null; - const signal = exitArgs[1] as string | null; - if (code !== null && code !== 0) { - deps.error(`Dashboard process exited with code ${code}`); - if (stderrBuf.trim()) { - deps.error(stderrBuf.trim().split('\n').slice(-5).join('\n')); - } - } else if (signal && signal !== 'SIGINT' && signal !== 'SIGTERM') { - deps.error(`Dashboard process killed by signal ${signal}`); - } - }); - - return child; -} - -async function resolveStartedDashboardPort( - process: DashboardStartupProcess, - preferredPort: number, - deps: CoreDependencies -): Promise { - return new Promise((resolve) => { - let resolved = false; - const processAny = process as DashboardStartupProcess & { - on?: (event: string, cb: (...args: unknown[]) => void) => void; - off?: (event: string, cb: (...args: unknown[]) => void) => void; - removeListener?: (event: string, cb: (...args: unknown[]) => void) => void; - }; - const detach = () => { - process.stdout?.off?.('data', extractPort); - process.stdout?.removeListener?.('data', extractPort); - process.stderr?.off?.('data', extractPort); - process.stderr?.removeListener?.('data', extractPort); - processAny.off?.('exit', handleExit); - processAny.removeListener?.('exit', handleExit); - clearTimeout(timer); - }; - const timer = setTimeout(() => { - if (resolved) return; - resolved = true; - detach(); - deps.warn(`Dashboard did not report its bound port quickly; assuming requested port ${preferredPort}`); - resolve(preferredPort); - }, 3000); - - const finalize = (port: number) => { - if (resolved) return; - resolved = true; - detach(); - resolve(port); - }; - const handleExit = (...exitArgs: unknown[]) => { - const code = exitArgs[0] as number | null; - const signal = exitArgs[1] as string | null; - if (resolved) { - return; - } - resolved = true; - detach(); - if (code !== null && code !== 0) { - deps.warn(`Dashboard exited before reporting its port (code: ${code}).`); - } else if (signal && signal !== 'SIGINT' && signal !== 'SIGTERM') { - deps.warn(`Dashboard exited before reporting its port (signal: ${signal}).`); - } else { - deps.warn('Dashboard exited before reporting its bound port.'); - } - resolve(null); - }; - - const extractPort = (...chunkArgs: unknown[]) => { - const firstChunk = chunkArgs[0]; - if (!firstChunk) { - return; - } - - const chunk = Buffer.isBuffer(firstChunk) - ? firstChunk - : typeof firstChunk === 'string' - ? Buffer.from(firstChunk) - : Buffer.from(JSON.stringify(firstChunk)); - - const match = chunk.toString().match(/Server running at http:\/\/localhost:(\d+)/i); - if (!match?.[1]) { - return; - } - const parsed = Number.parseInt(match[1], 10); - if (!Number.isNaN(parsed)) { - finalize(parsed); - } - }; - - process.stdout?.on?.('data', extractPort); - process.stderr?.on?.('data', extractPort); - processAny.on?.('exit', handleExit); - }); -} - -/** - * Check if the cached dashboard UI assets match the installed dashboard-server - * binary version. If they are stale (or missing a version marker), re-download - * the latest assets from the relay-dashboard GitHub release. - */ -async function refreshDashboardAssetsIfStale( - dashboardBinary: string | null, - deps: CoreDependencies -): Promise { - if (!dashboardBinary || dashboardBinary.endsWith('.js') || dashboardBinary.endsWith('.ts')) { - // Dev mode or npx — skip - return; - } - - // Get installed binary version (async to avoid blocking event loop) - let binaryVersion: string; - try { - const versionResult = await deps.execCommand(`${JSON.stringify(dashboardBinary)} --version`); - binaryVersion = versionResult.stdout.trim(); - } catch { - return; // Can't determine version — skip - } - - if (!binaryVersion) { - return; - } - - const targetDir = getDashboardRootFromBinary(dashboardBinary, deps) ?? getHomeDashboardRoot(deps); - const assetsDir = path.join(targetDir, 'out'); - const versionFile = path.join(targetDir, '.version'); - - // Check if assets match the binary version - try { - const cachedVersion = deps.fs.readFileSync(versionFile, 'utf-8').trim(); - if (cachedVersion === binaryVersion) { - return; // Up to date - } - } catch { - // No version file — need to download if assets exist but are unversioned, - // or if assets don't exist at all - if (deps.fs.existsSync(assetsDir)) { - // Assets exist but no version marker — they're from an old install - } else { - // No assets at all — need to download - } - } - - deps.log(`Updating dashboard UI assets (${binaryVersion})...`); - - const uiUrl = - 'https://github.com/AgentWorkforce/relay-dashboard/releases/latest/download/dashboard-ui.tar.gz'; - let tempDir: string | undefined; - let tempFile: string | undefined; - - try { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `dashboard-ui-${deps.pid}-`)); - tempFile = path.join(tempDir, 'dashboard-ui.tar.gz'); - // Download (async to avoid blocking event loop during network I/O) - await deps.execCommand( - `curl -fsSL --max-time 30 ${JSON.stringify(uiUrl)} -o ${JSON.stringify(tempFile)}` - ); - - // Verify it's a valid gzip - const header = Buffer.alloc(2); - const fd = fs.openSync(tempFile, 'r'); - fs.readSync(fd, header, 0, 2, 0); - fs.closeSync(fd); - if (header[0] !== 0x1f || header[1] !== 0x8b) { - if (tempFile) deps.fs.unlinkSync(tempFile); - return; // Not a valid gzip file - } - - // Remove old assets and extract (async to avoid blocking event loop) - deps.fs.rmSync(assetsDir, { recursive: true, force: true }); - deps.fs.mkdirSync(targetDir, { recursive: true }); - await deps.execCommand(`tar -xzf ${JSON.stringify(tempFile)} -C ${JSON.stringify(targetDir)}`); - if (tempFile) deps.fs.unlinkSync(tempFile); - - // Write version marker only after confirming extraction succeeded - if (deps.fs.existsSync(path.join(assetsDir, 'index.html'))) { - deps.fs.writeFileSync(versionFile, binaryVersion); - deps.log(`Dashboard UI assets updated to ${binaryVersion}`); - } else { - deps.warn('Dashboard UI extraction may be incomplete — skipping version marker'); - } - } catch { - // Best-effort — don't block startup - try { - if (tempFile) deps.fs.unlinkSync(tempFile); - } catch { - /* ignore */ - } - } finally { - try { - if (tempDir) deps.fs.rmSync(tempDir, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } -} - -export async function startDashboardWithFallback( - paths: CoreProjectPaths, - dashboardPort: number, - apiPort: number, - deps: CoreDependencies, - enableVerboseLogging: boolean, - relayApiKey?: string, - brokerApiKey?: string -): Promise<{ process: SpawnedProcess; port: number | null }> { - const preferredBinary = deps.findDashboardBinary(); - await refreshDashboardAssetsIfStale(preferredBinary, deps); - let process = startDashboard( - paths, - dashboardPort, - apiPort, - deps, - enableVerboseLogging, - preferredBinary, - relayApiKey, - brokerApiKey - ); - let port = await resolveStartedDashboardPort(process as DashboardStartupProcess, dashboardPort, deps); - - if (port === null && preferredBinary) { - deps.warn('Retrying dashboard startup using npx @agent-relay/dashboard-server@latest'); - process = startDashboard( - paths, - dashboardPort, - apiPort, - deps, - enableVerboseLogging, - null, - relayApiKey, - brokerApiKey - ); - port = await resolveStartedDashboardPort(process as DashboardStartupProcess, dashboardPort, deps); - } - - return { process, port }; -} - -export async function waitForDashboard( - port: number, - process: SpawnedProcess, - deps: Pick, - isShuttingDown: () => boolean -): Promise { - for (let i = 0; i < 20; i++) { - await new Promise((r) => setTimeout(r, 500)); - if (process.killed) { - if (!isShuttingDown()) { - deps.warn(`Warning: Dashboard process exited before becoming ready on port ${port}`); - } - return; - } - try { - const resp = await fetch(`http://localhost:${port}/health`); - if (resp.ok) return; // Dashboard is up - } catch { - // Not ready yet - } - } - if (!isShuttingDown()) { - deps.warn(`Warning: Dashboard not responding on port ${port} after 10s`); - } -} diff --git a/packages/cli/src/cli/lib/broker-lifecycle.test.ts b/packages/cli/src/cli/lib/broker-lifecycle.test.ts index 41482e073..c07f026d7 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.test.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.test.ts @@ -67,28 +67,23 @@ describe('classifyBrokerStartError', () => { }); describe('classifyBrokerStartStage', () => { - it('marks dashboard port conflicts when the dashboard was requested', () => { - const err = Object.assign(new Error('listen EADDRINUSE'), { code: 'EADDRINUSE' }); - expect(classifyBrokerStartStage(err, 'listen EADDRINUSE', true)).toBe('dashboard_port'); - }); - it('marks already-running brokers from the message text', () => { const message = 'another broker instance is already running in this directory (/tmp/x)'; - expect(classifyBrokerStartStage(new Error(message), message, false)).toBe('already_running'); + expect(classifyBrokerStartStage(new Error(message), message)).toBe('already_running'); }); it('classifies fetch failures as connect-stage errors', () => { const cause = Object.assign(new Error('ECONNREFUSED'), { code: 'ECONNREFUSED' }); const err = new TypeError('fetch failed', { cause }); - expect(classifyBrokerStartStage(err, 'fetch failed', false)).toBe('connect'); + expect(classifyBrokerStartStage(err, 'fetch failed')).toBe('connect'); }); it('classifies broker-exited-before-ready as a spawn failure', () => { const message = 'Broker process exited with code 1 before becoming ready (pid=123; …)'; - expect(classifyBrokerStartStage(new Error(message), message, false)).toBe('spawn'); + expect(classifyBrokerStartStage(new Error(message), message)).toBe('spawn'); }); it('falls back to startup for everything else', () => { - expect(classifyBrokerStartStage(new Error('???'), '???', false)).toBe('startup'); + expect(classifyBrokerStartStage(new Error('???'), '???')).toBe('startup'); }); }); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 4f15a4cc0..7697a9079 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -10,15 +10,6 @@ import type { SpawnedProcess, } from '../commands/core.js'; import { track } from '../telemetry/index.js'; -import { - getDefaultDashboardRelayUrl, - isDebugLikeLoggingEnabled, - normalizeDashboardPath, - resolveDashboardPortWithFallback, - resolveDashboardRelayUrl, - startDashboardWithFallback, - waitForDashboard, -} from './broker-dashboard.js'; import { buildBundledAgentRelayMcpCommand } from './agent-relay-mcp-command.js'; import { errorClassName } from './telemetry-helpers.js'; import { @@ -29,14 +20,9 @@ import { } from './fleet-sidecar.js'; type UpOptions = { - dashboard?: boolean; - port?: string; spawn?: boolean; background?: boolean; - foreground?: boolean; verbose?: boolean; - dashboardPath?: string; - reuseExistingBroker?: boolean; workspaceKey?: string; stateDir?: string; brokerName?: string; @@ -50,8 +36,8 @@ type DownOptions = { }; const MAX_API_PORT_ATTEMPTS = 25; -const MAX_DASHBOARD_PORT_ATTEMPTS = 25; const MAX_PORT = 65535; +const DEFAULT_BROKER_BASE_PORT = 3888; /** The broker writes this file with URL, port, API key, and PID. */ const CONNECTION_FILENAME = 'connection.json'; @@ -195,8 +181,7 @@ export function classifyBrokerStartError(err: unknown): string { } /** Exported for testing. */ -export function classifyBrokerStartStage(err: unknown, message: string, wantsDashboard: boolean): string { - if (errorCode(err) === 'EADDRINUSE' && wantsDashboard) return 'dashboard_port'; +export function classifyBrokerStartStage(_err: unknown, message: string): string { if (isBrokerAlreadyRunningError(message)) return 'already_running'; if (/fetch failed/i.test(message)) return 'connect'; if (/Broker did not report API port/i.test(message)) return 'spawn'; @@ -227,16 +212,26 @@ async function resolveApiPortWithFallback( throw new Error(`Failed to find an available API port near ${startApiPort}.`); } +/** + * The broker base port. `AGENT_RELAY_BROKER_PORT` overrides the default so + * multiple brokers can run side by side (e.g. in tests); the broker HTTP API + * binds near `basePort + 1` with fallback scanning. + */ +export function resolveBrokerBasePort(deps: Pick): number { + const raw = Number.parseInt(deps.env.AGENT_RELAY_BROKER_PORT ?? '', 10); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_BROKER_BASE_PORT; +} + export async function startBrokerWithPortFallback( paths: CoreProjectPaths, - dashboardPort: number, + basePort: number, deps: CoreDependencies, brokerName?: string ): Promise<{ relay: CoreRelay; apiPort: number }> { // Resolve a free API port BEFORE spawning the broker. This avoids // spawning (and flocking) multiple --persist brokers during retry, // which caused stale-flock "already running" errors. - const startApiPort = dashboardPort + 1; + const startApiPort = basePort + 1; const apiPort = await resolveApiPortWithFallback(startApiPort, MAX_API_PORT_ATTEMPTS, deps); const candidate = await deps.createRelay(paths.projectRoot, apiPort, brokerName); @@ -260,19 +255,27 @@ function startImplicitLocalFleetSidecar( deps.warn('Fleet local node skipped: broker connection file was not available.'); return undefined; } - const node = createImplicitLocalFleetNode({ - paths, - teamsConfig, - name: options.brokerName ?? (path.basename(paths.projectRoot) || 'local-node'), - }); - return startFleetSidecar({ - definition: node, - connection: { url: conn.url, apiKey: conn.api_key }, - workspaceKey: relay.workspaceKey, - statusPath: fleetStatusPath(paths), - reconnect: true, - warn: (message) => deps.warn(message), - }); + // The implicit local fleet node is best-effort: it lets this broker advertise + // itself as a fleet node, but the broker is already up and usable without it. + // Never let a sidecar setup failure abort `up`. + try { + const node = createImplicitLocalFleetNode({ + paths, + teamsConfig, + name: options.brokerName ?? (path.basename(paths.projectRoot) || 'local-node'), + }); + return startFleetSidecar({ + definition: node, + connection: { url: conn.url, apiKey: conn.api_key }, + workspaceKey: relay.workspaceKey, + statusPath: fleetStatusPath(paths), + reconnect: true, + warn: (message) => deps.warn(message), + }); + } catch (err) { + deps.warn(`Fleet local node skipped: ${toErrorMessage(err)}`); + return undefined; + } } function isBrokerAlreadyRunningError(message: string): boolean { @@ -354,18 +357,20 @@ function isBrokerExecutableCommand(command: string): boolean { return basename === 'agent-relay-broker' || basename.startsWith('agent-relay-broker-'); } -function isForegroundBrokerCliCommand(command: string): boolean { +function isAttachedBrokerCliCommand(command: string): boolean { if (command.includes('agent-relay-mcp')) { return false; } - if (!/(?:^|\s)up(?:\s|$)/.test(command) || !/(?:^|\s)--foreground(?:\s|=|$)/.test(command)) { + // The attached `up` process holds the broker. Skip the transient + // `up --background` launcher, which exits as soon as the child is ready. + if (!/(?:^|\s)up(?:\s|$)/.test(command) || /(?:^|\s)--background(?:\s|=|$)/.test(command)) { return false; } return /(?:^|\s)(?:\S*agent-relay(?:\.js)?|\S*agent-relay-[^\s]+)(?:\s|$)/.test(command); } function isBrokerProcessCommand(command: string): boolean { - return isBrokerExecutableCommand(command) || isForegroundBrokerCliCommand(command); + return isBrokerExecutableCommand(command) || isAttachedBrokerCliCommand(command); } function escapeRegExp(value: string): string { @@ -580,12 +585,7 @@ function cleanupBrokerFiles(paths: CoreProjectPaths, deps: CoreDependencies): vo } function childUpArgsForDetachedStart(options: UpOptions, deps: CoreDependencies): string[] { - const args = cliUserArgs(deps).filter( - (arg) => !['--background', '--foreground'].some((name) => matchesCliOption(arg, name)) - ); - if (options.dashboard === false && !args.includes('--no-dashboard')) { - args.push('--no-dashboard'); - } + const args = cliUserArgs(deps).filter((arg) => !matchesCliOption(arg, '--background')); if (options.stateDir && !hasCliOption(args, '--state-dir')) { args.push('--state-dir', path.resolve(options.stateDir)); } @@ -598,9 +598,6 @@ function childUpArgsForDetachedStart(options: UpOptions, deps: CoreDependencies) if (options.verbose === true && !args.includes('--verbose')) { args.push('--verbose'); } - if (options.dashboard === false && !args.includes('--foreground')) { - args.push('--foreground'); - } return args; } @@ -700,63 +697,15 @@ async function waitForBrokerReadiness( return latest; } -async function discoverExistingBrokerApiPort( - preferredApiPort: number, - maxAttempts: number, - deps: Pick -): Promise { - const attempts = Math.max(1, maxAttempts); - for (let attempt = 0; attempt < attempts; attempt += 1) { - const candidatePort = preferredApiPort + attempt; - if (candidatePort > MAX_PORT) { - return preferredApiPort; - } - try { - const response = await fetch(`http://localhost:${candidatePort}/health`); - if (response.ok) { - if (attempt > 0) { - deps.warn(`Detected existing broker API on port ${candidatePort}.`); - } - return candidatePort; - } - } catch { - // Keep scanning. - } - } - return preferredApiPort; -} - -async function shutdownUpResources( - relay: CoreRelay, - dashboardProcess: SpawnedProcess | undefined, - dataDir: string, - deps: CoreDependencies, - ownsBroker: boolean -): Promise { - if (dashboardProcess && !dashboardProcess.killed) { - try { - dashboardProcess.kill('SIGTERM'); - } catch { - // Best-effort cleanup. - } - } - +async function shutdownUpResources(relay: CoreRelay, dataDir: string, deps: CoreDependencies): Promise { await relay.shutdown().catch(() => undefined); - if (ownsBroker) { - safeUnlink(path.join(dataDir, CONNECTION_FILENAME), deps); - } + safeUnlink(path.join(dataDir, CONNECTION_FILENAME), deps); } // eslint-disable-next-line complexity export async function runUpCommand(options: UpOptions, deps: CoreDependencies): Promise { ensureBundledAgentRelayMcpCommand(deps); - if (options.background && options.foreground) { - deps.error('Cannot use --background and --foreground together.'); - deps.exit(1); - return; - } - const paths = deps.getProjectPaths(); // --state-dir overrides where the broker writes state / connection files if (options.stateDir) { @@ -765,7 +714,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): deps.env.AGENT_RELAY_STATE_DIR = resolved; } - if (options.background || (options.dashboard === false && !options.foreground)) { + if (options.background) { const preflight = await recoverHalfStartedBroker(paths, deps); if (preflight === 'running') { const pid = readBrokerPid(paths.dataDir, deps); @@ -840,27 +789,12 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): return; } - const wantsDashboard = options.dashboard !== false; - const requestedDashboardPort = Number.parseInt(options.port ?? '3888', 10) || 3888; - const shouldReuseExistingBroker = options.reuseExistingBroker === true; - const dashboardPort = wantsDashboard - ? await resolveDashboardPortWithFallback(requestedDashboardPort, MAX_DASHBOARD_PORT_ATTEMPTS, deps) - : requestedDashboardPort; - if (wantsDashboard && dashboardPort !== requestedDashboardPort) { - deps.warn( - `Requested dashboard port ${requestedDashboardPort} is already in use; active dashboard will run on ${dashboardPort}.` - ); - } - + const basePort = resolveBrokerBasePort(deps); deps.fs.mkdirSync(paths.dataDir, { recursive: true }); - let existingPid = readBrokerPid(paths.dataDir, deps); - let ownsBroker = true; + const existingPid = readBrokerPid(paths.dataDir, deps); let relay: CoreRelay | null = null; - let apiPort = dashboardPort + 1; - let dashboardProcess: SpawnedProcess | undefined; let fleetSidecar: RunningFleetSidecar | undefined; - const dashboardVerbose = Boolean(options.verbose) || isDebugLikeLoggingEnabled(deps); let shuttingDown = false; let sigintCount = 0; let shutdownPromise: Promise | undefined; @@ -872,7 +806,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): } else { shutdownPromise = (async () => { await fleetSidecar?.stop(); - await shutdownUpResources(relay, dashboardProcess, paths.dataDir, deps, ownsBroker); + await shutdownUpResources(relay, paths.dataDir, deps); })(); } } @@ -881,116 +815,12 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): try { if (existingPid !== null) { if (isProcessRunning(existingPid, deps)) { - if (!shouldReuseExistingBroker || !wantsDashboard) { - deps.error(`Broker already running for this project (pid: ${existingPid}).`); - deps.error('Run `agent-relay status` to inspect it, then `agent-relay down` to stop it.'); - deps.exit(1); - return; - } - - apiPort = await discoverExistingBrokerApiPort(Math.max(1, apiPort), MAX_API_PORT_ATTEMPTS, deps); - const reusableRelay = await deps.createRelay(paths.projectRoot, apiPort); - try { - await reusableRelay.getStatus(); - } catch { - await reusableRelay.shutdown().catch(() => undefined); - deps.warn( - `Broker already running for this project (pid: ${existingPid}), but API port ${apiPort} is not responding.` - ); - deps.warn('Treating this as stale broker state and starting a fresh broker.'); - safeUnlink(path.join(paths.dataDir, CONNECTION_FILENAME), deps); - existingPid = null; - } - - if (existingPid === null) { - // fallthrough and start a fresh broker - } else { - relay = reusableRelay; - ownsBroker = false; - const dashboardRelayUrl = resolveDashboardRelayUrl(apiPort, deps); - const expectedRelayUrl = getDefaultDashboardRelayUrl(apiPort); - if ( - deps.env.RELAY_DASHBOARD_RELAY_URL && - deps.env.RELAY_DASHBOARD_RELAY_URL.trim() !== '' && - deps.env.RELAY_DASHBOARD_RELAY_URL.trim() !== expectedRelayUrl - ) { - deps.warn( - `RELAY_DASHBOARD_RELAY_URL is set to ${deps.env.RELAY_DASHBOARD_RELAY_URL.trim()}, ` + - `but this session computed ${expectedRelayUrl}.` - ); - } - deps.log(`Relay API: ${dashboardRelayUrl}`); - if (dashboardVerbose) { - deps.log(`[dashboard] relay target resolved from config: ${dashboardRelayUrl}`); - } - deps.log(`Project: ${paths.projectRoot}`); - deps.log('Mode: broker (stdio)'); - deps.log(`Workspace Key: ${relay.workspaceKey ?? 'unknown'}`); - deps.log('Broker already running for this project; reusing existing broker.'); - - if (wantsDashboard) { - const brokerConn = readBrokerConnectionFromFs(deps.fs, paths.dataDir); - const dashboardStart = await startDashboardWithFallback( - paths, - dashboardPort, - apiPort, - deps, - dashboardVerbose, - relay?.workspaceKey, - brokerConn?.api_key - ); - dashboardProcess = dashboardStart.process; - const startedDashboardPort = dashboardStart.port; - if (startedDashboardPort === null) { - deps.warn('Dashboard failed to start. Check dashboard error logs above.'); - } else { - if (startedDashboardPort !== dashboardPort) { - deps.warn( - `Dashboard port ${dashboardPort} was already in use, so dashboard started on ${startedDashboardPort}` - ); - } - const dashboardPath = normalizeDashboardPath(options.dashboardPath); - const dashboardUrl = dashboardPath - ? `http://localhost:${startedDashboardPort}${dashboardPath}` - : `http://localhost:${startedDashboardPort}`; - deps.log(`Dashboard: ${dashboardUrl}`); - - waitForDashboard(startedDashboardPort, dashboardProcess, deps, () => shuttingDown).catch( - () => {} - ); - } - } - - fleetSidecar = startImplicitLocalFleetSidecar(paths, relay, options, deps); - - deps.onSignal('SIGINT', async () => { - sigintCount += 1; - if (shuttingDown) { - if (sigintCount >= 2) { - deps.warn('Force exiting...'); - deps.exit(130); - } - return; - } - deps.log('\nStopping...'); - await shutdownOnce(); - deps.exit(0); - }); - deps.onSignal('SIGTERM', async () => { - if (shuttingDown) { - return; - } - await shutdownOnce(); - deps.exit(0); - }); - - await deps.holdOpen(); - return; - } + deps.error(`Broker already running for this project (pid: ${existingPid}).`); + deps.error('Run `agent-relay status` to inspect it, then `agent-relay down` to stop it.'); + deps.exit(1); + return; } - safeUnlink(path.join(paths.dataDir, CONNECTION_FILENAME), deps); - existingPid = null; } // If a workspace key was explicitly provided, inject it into the environment @@ -1004,81 +834,29 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): // files (e.g. user deleted .agentworkforce/relay/ while broker was running). await killOrphanedBrokerProcesses(paths.projectRoot, deps); - const started = await startBrokerWithPortFallback(paths, dashboardPort, deps, options.brokerName); + const started = await startBrokerWithPortFallback(paths, basePort, deps, options.brokerName); relay = started.relay; - apiPort = started.apiPort; - const dashboardRelayUrl = resolveDashboardRelayUrl(apiPort, deps); - const expectedRelayUrl = getDefaultDashboardRelayUrl(apiPort); - if ( - deps.env.RELAY_DASHBOARD_RELAY_URL && - deps.env.RELAY_DASHBOARD_RELAY_URL.trim() !== '' && - deps.env.RELAY_DASHBOARD_RELAY_URL.trim() !== expectedRelayUrl - ) { - deps.warn( - `RELAY_DASHBOARD_RELAY_URL is set to ${deps.env.RELAY_DASHBOARD_RELAY_URL.trim()}, ` + - `but this session computed ${expectedRelayUrl}.` - ); - } - deps.log(`Relay API: ${dashboardRelayUrl}`); - if (dashboardVerbose) { - deps.log(`[dashboard] relay target resolved from config: ${dashboardRelayUrl}`); - } + deps.log(`Relay API: http://localhost:${started.apiPort}`); deps.log(`Project: ${paths.projectRoot}`); deps.log('Mode: broker (stdio)'); deps.log(`Workspace Key: ${relay.workspaceKey ?? 'unknown'}`); deps.log('Broker started.'); - if (wantsDashboard) { - const brokerConn = readBrokerConnectionFromFs(deps.fs, paths.dataDir); - const dashboardStart = await startDashboardWithFallback( - paths, - dashboardPort, - apiPort, - deps, - dashboardVerbose, - relay?.workspaceKey, - brokerConn?.api_key - ); - dashboardProcess = dashboardStart.process; - const startedDashboardPort = dashboardStart.port; - if (startedDashboardPort === null) { - deps.warn('Dashboard failed to start. Check dashboard error logs above.'); - } else { - if (startedDashboardPort !== dashboardPort) { - deps.warn( - `Dashboard port ${dashboardPort} was already in use, so dashboard started on ${startedDashboardPort}` - ); - } - const dashboardPath = normalizeDashboardPath(options.dashboardPath); - const dashboardUrl = dashboardPath - ? `http://localhost:${startedDashboardPort}${dashboardPath}` - : `http://localhost:${startedDashboardPort}`; - deps.log(`Dashboard: ${dashboardUrl}`); - - // Verify the dashboard is actually reachable (non-blocking) - waitForDashboard(startedDashboardPort, dashboardProcess, deps, () => shuttingDown).catch(() => {}); - } - } - const teamsConfig = deps.loadTeamsConfig(paths.projectRoot); fleetSidecar = startImplicitLocalFleetSidecar(paths, relay, options, deps, teamsConfig); const shouldSpawn = options.spawn === true ? true : options.spawn === false ? false : Boolean(teamsConfig?.autoSpawn); if (shouldSpawn && teamsConfig && teamsConfig.agents.length > 0) { - if (wantsDashboard) { - deps.warn('Warning: auto-spawn from teams.json is skipped when dashboard mode manages the broker'); - } else { - for (const agent of teamsConfig.agents) { - await relay.spawn({ - name: agent.name, - cli: agent.cli, - channels: ['general'], - task: agent.task ?? '', - team: teamsConfig.team, - }); - } + for (const agent of teamsConfig.agents) { + await relay.spawn({ + name: agent.name, + cli: agent.cli, + channels: ['general'], + task: agent.task ?? '', + team: teamsConfig.team, + }); } } else if (options.spawn === true && !teamsConfig) { deps.warn('Warning: --spawn specified but no teams.json found'); @@ -1109,14 +887,12 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): } catch (err: unknown) { await shutdownOnce(); const message = toErrorMessage(err); - const stage = classifyBrokerStartStage(err, message, wantsDashboard); + const stage = classifyBrokerStartStage(err, message); track('broker_start_failed', { stage, error_class: classifyBrokerStartError(err), }); - if (errorCode(err) === 'EADDRINUSE' && wantsDashboard) { - deps.error(`Dashboard port ${dashboardPort} is already in use.`); - } else if (isBrokerAlreadyRunningError(message)) { + if (isBrokerAlreadyRunningError(message)) { reportAlreadyRunningError(message, paths.dataDir, deps); } else { deps.error(`Failed to start broker: ${describeError(err)}`); diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index 046e6bde5..b38d1ece3 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -278,43 +278,13 @@ export async function runUninstallCommand( removeZedConfig(serverName, deps.fs, isDryRun, deps.log); } - // --- Dashboard static assets removal --- - const homeDir = os.homedir(); - const dashboardAssetPaths = [ - path.join(homeDir, '.relay', 'dashboard', 'out'), - path.join(homeDir, '.relay', 'dashboard', '.version'), - ...INSTALL_DIR_NAMES.flatMap((installDir) => [ - path.join(homeDir, installDir, 'dashboard', 'out'), - path.join(homeDir, installDir, 'dashboard', '.version'), - ]), - ]; - for (const assetPath of dashboardAssetPaths) { - if (deps.fs.existsSync(assetPath)) { - if (isDryRun) { - deps.log(`[dry-run] Would remove dashboard asset path: ${assetPath}`); - } else { - try { - deps.fs.rmSync(assetPath, { recursive: true, force: true }); - deps.log(`Removed dashboard asset path: ${assetPath}`); - } catch { - // Best-effort fallback for file-only implementations. - try { - deps.fs.unlinkSync(assetPath); - deps.log(`Removed dashboard asset path: ${assetPath}`); - } catch { - // Best-effort. - } - } - } - } - } - // --- Binary removal (standalone binaries + npm packages) --- + const homeDir = os.homedir(); const standaloneBinDir = path.join(homeDir, '.local', 'bin'); const installBinDirs = INSTALL_DIR_NAMES.map((installDir) => path.join(homeDir, installDir, 'bin')); // Remove standalone binaries from ~/.local/bin - for (const binaryName of ['agent-relay', 'relay-dashboard-server', 'relay-acp']) { + for (const binaryName of ['agent-relay', 'relay-acp']) { const binPath = path.join(standaloneBinDir, binaryName); if (deps.fs.existsSync(binPath)) { if (isDryRun) { @@ -330,21 +300,6 @@ export async function runUninstallCommand( } } - // Remove relay-dashboard-server from /usr/local/bin (another search path used by findDashboardBinary) - const usrLocalBinDashboard = path.join('/usr/local/bin', 'relay-dashboard-server'); - if (deps.fs.existsSync(usrLocalBinDashboard)) { - if (isDryRun) { - deps.log(`[dry-run] Would remove binary: ${usrLocalBinDashboard}`); - } else { - try { - deps.fs.unlinkSync(usrLocalBinDashboard); - deps.log(`Removed ${usrLocalBinDashboard}`); - } catch { - // Best-effort. - } - } - } - // Remove installer bin dirs without deleting the parent data directories. for (const installBinDir of installBinDirs) { if (!deps.fs.existsSync(installBinDir)) { @@ -364,7 +319,7 @@ export async function runUninstallCommand( // Remove npm-installed packages if (!isDryRun) { - for (const pkg of ['agent-relay', '@agent-relay/dashboard-server']) { + for (const pkg of ['agent-relay']) { try { await deps.execCommand(`npm uninstall -g ${pkg}`); deps.log(`Uninstalled npm package: ${pkg}`); @@ -373,7 +328,7 @@ export async function runUninstallCommand( } } } else { - deps.log('[dry-run] Would run: npm uninstall -g agent-relay @agent-relay/dashboard-server'); + deps.log('[dry-run] Would run: npm uninstall -g agent-relay'); } // --- Snippet cleanup (CLAUDE.md, GEMINI.md, AGENTS.md) --- diff --git a/packages/cli/src/cli/telemetry/events.ts b/packages/cli/src/cli/telemetry/events.ts index 3344f2806..2fdb1bf79 100644 --- a/packages/cli/src/cli/telemetry/events.ts +++ b/packages/cli/src/cli/telemetry/events.ts @@ -19,13 +19,13 @@ */ /** Source of spawn/release action */ -export type ActionSource = 'human_cli' | 'human_dashboard' | 'agent' | 'protocol'; +export type ActionSource = 'human_cli' | 'agent' | 'protocol'; /** Component that emitted the telemetry event. */ -export type TelemetryApp = 'cli' | 'broker' | 'sdk' | 'relaycast-server' | 'dashboard' | 'unknown'; +export type TelemetryApp = 'cli' | 'broker' | 'sdk' | 'relaycast-server' | 'unknown'; /** User-facing product surface responsible for the event. */ -export type TelemetrySurface = 'cli' | 'broker' | 'sdk' | 'cloud' | 'mcp' | 'dashboard' | 'unknown'; +export type TelemetrySurface = 'cli' | 'broker' | 'sdk' | 'cloud' | 'mcp' | 'unknown'; /** * Reason for agent release. diff --git a/packages/config/src/cloud-config.ts b/packages/config/src/cloud-config.ts index b94bd44ca..b1391a73c 100644 --- a/packages/config/src/cloud-config.ts +++ b/packages/config/src/cloud-config.ts @@ -6,13 +6,9 @@ export interface CloudConfig { // Server port: number; publicUrl: string; - appUrl: string; // Dashboard app URL (e.g., app.agentrelay.com) + appUrl: string; // Hosted web app URL (e.g., app.agentrelay.com) sessionSecret: string; - // Local dashboard URL for channel API proxying (optional) - // When set, channel requests are proxied to this URL instead of the workspace - localDashboardUrl?: string; - // Database databaseUrl: string; redisUrl: string; @@ -105,9 +101,6 @@ export function loadConfig(): CloudConfig { appUrl: process.env.APP_URL || process.env.PUBLIC_URL || 'http://localhost:4567', sessionSecret: requireEnv('SESSION_SECRET'), - // Local dashboard for channel API (auto-detected if not set) - localDashboardUrl: optionalEnv('LOCAL_DASHBOARD_URL'), - databaseUrl: requireEnv('DATABASE_URL'), redisUrl: process.env.REDIS_URL || 'redis://localhost:6379', diff --git a/packages/config/src/schemas.ts b/packages/config/src/schemas.ts index 532f0ab89..622695092 100644 --- a/packages/config/src/schemas.ts +++ b/packages/config/src/schemas.ts @@ -127,7 +127,6 @@ export const CloudConfigSchema = z.object({ publicUrl: z.string(), appUrl: z.string(), sessionSecret: z.string(), - localDashboardUrl: z.string().optional(), databaseUrl: z.string(), redisUrl: z.string(), github: z.object({ diff --git a/packages/harness-driver/src/spawn-config.ts b/packages/harness-driver/src/spawn-config.ts index 831c0fe0c..b67a3cd11 100644 --- a/packages/harness-driver/src/spawn-config.ts +++ b/packages/harness-driver/src/spawn-config.ts @@ -4,7 +4,7 @@ import type { EventBus } from './event-bus.js'; import type { HarnessDriverEvents } from './lifecycle-hooks.js'; export interface BrokerInitArgs { - /** Optional HTTP API port for dashboard proxy (0 = disabled). */ + /** Optional HTTP API port for the broker (0 = disabled). */ apiPort?: number; /** Bind address for the HTTP API. Defaults to 127.0.0.1 in the broker. */ apiBind?: string; diff --git a/packages/policy/src/agent-policy.ts b/packages/policy/src/agent-policy.ts index d4a37e694..bb5984d12 100644 --- a/packages/policy/src/agent-policy.ts +++ b/packages/policy/src/agent-policy.ts @@ -20,7 +20,7 @@ import os from 'node:os'; * * Policy files are loaded from (in order of precedence): * 1. User-level: ~/.config/agent-relay/policies/*.yaml (NOT in source control) - * 2. Cloud: Workspace config from dashboard (stored in database) + * 2. Cloud: Workspace config from the cloud web app (stored in database) * * PRPM packages install to the user-level location to avoid polluting repos. * Install via: prpm install @org/strict-agent-rules --global diff --git a/packages/utils/cli-registry.yaml b/packages/utils/cli-registry.yaml index 9385e8ffd..0d6b6e0a6 100644 --- a/packages/utils/cli-registry.yaml +++ b/packages/utils/cli-registry.yaml @@ -1,6 +1,6 @@ # CLI Registry # Single source of truth for CLI tools, versions, and models. -# Used by: TS SDK, Python SDK, relay-cloud, relay-dashboard +# Used by: TS SDK, Python SDK, relay-cloud # # To update: Edit this file, then run `npm run codegen:models` diff --git a/scripts/ci-standalone-smoke.sh b/scripts/ci-standalone-smoke.sh index c5e9e8d51..7e8da2b3d 100755 --- a/scripts/ci-standalone-smoke.sh +++ b/scripts/ci-standalone-smoke.sh @@ -118,9 +118,9 @@ echo "=== Smoke: standalone down --force ===" DOWN_OUTPUT="$(run_cli local down --force 2>&1 || true)" assert_exact_count "$DOWN_OUTPUT" '^Cleaned up \(was not running\)$' 1 'down cleanup line' -echo "=== Smoke: standalone up --no-dashboard ===" +echo "=== Smoke: standalone up ===" UP_LOG="$TMP_ROOT/up.log" -run_cli local up --no-dashboard >"$UP_LOG" 2>&1 & +run_cli local up >"$UP_LOG" 2>&1 & UP_PID=$! sleep 8 diff --git a/scripts/demos/README.md b/scripts/demos/README.md index a6394f4b0..bc418c171 100644 --- a/scripts/demos/README.md +++ b/scripts/demos/README.md @@ -44,7 +44,7 @@ claude --version ### Step 1: Start Relay Daemon ```bash -agent-relay up --dashboard +agent-relay up ``` Open http://localhost:3888 to watch the conversation. @@ -75,5 +75,4 @@ agent-relay -n Analytics claude ## What You'll See -- **Dashboard**: Agents connect, messages flow in real-time - **Terminals**: Agents negotiate, propose allocations, vote on outcomes diff --git a/scripts/demos/server-capacity.sh b/scripts/demos/server-capacity.sh index 59b00d6fc..53eb76edf 100755 --- a/scripts/demos/server-capacity.sh +++ b/scripts/demos/server-capacity.sh @@ -57,7 +57,7 @@ echo "Created: $DEMO_DIR/server-capacity.md" echo "" echo "=== HOW TO RUN ===" echo "" -echo "1. Start daemon: agent-relay up --dashboard" +echo "1. Start daemon: agent-relay up" echo "" echo "2. Start 3 agents in separate terminals:" echo " agent-relay -n WebAPI claude" diff --git a/scripts/demos/sprint-planning.sh b/scripts/demos/sprint-planning.sh index a249a8e55..360e3587b 100755 --- a/scripts/demos/sprint-planning.sh +++ b/scripts/demos/sprint-planning.sh @@ -61,7 +61,7 @@ echo "Created: $DEMO_DIR/sprint-planning.md" echo "" echo "=== HOW TO RUN ===" echo "" -echo "1. Start daemon: agent-relay up --dashboard" +echo "1. Start daemon: agent-relay up" echo "" echo "2. Start 3 agents in separate terminals:" echo " agent-relay -n ProductLead claude" diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh index b755ef710..f8ecc52bb 100755 --- a/scripts/e2e-test.sh +++ b/scripts/e2e-test.sh @@ -18,7 +18,7 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # Configuration AGENT_NAME="e2e-test-agent" -DASHBOARD_PORT=3889 # Use different port to avoid conflicts with running instances +BROKER_PORT=3889 # Broker API binds BROKER_PORT+1; kept distinct to avoid conflicts SPAWN_TIMEOUT=120 DAEMON_ONLY=false @@ -30,11 +30,11 @@ while [[ $# -gt 0 ]]; do shift ;; --port) - DASHBOARD_PORT="$2" + BROKER_PORT="$2" shift 2 ;; --port=*) - DASHBOARD_PORT="${1#*=}" + BROKER_PORT="${1#*=}" shift ;; *) @@ -118,7 +118,7 @@ fi log_info "Configuration:" log_info " Agent name: $AGENT_NAME" -log_info " Dashboard port: $DASHBOARD_PORT" +log_info " Broker port: $BROKER_PORT" log_info " Daemon only: $DAEMON_ONLY" log_info " CLI command: $CLI_CMD" @@ -131,9 +131,6 @@ cleanup() { log_info "Ensuring daemon is stopped..." run_with_timeout 10 "$CLI_CMD" local down --force --timeout 5000 2>/dev/null || true - # Force kill any remaining processes if timeout occurred - pkill -9 -f "relay-dashboard-server.*--port.*$DASHBOARD_PORT" 2>/dev/null || true - log_info "Cleanup complete." } trap cleanup EXIT @@ -154,16 +151,16 @@ log_phase "Phase 1: Broker Startup" # Kill any existing daemon (with timeout to prevent hanging) run_with_timeout 10 "$CLI_CMD" local down --force --timeout 5000 2>/dev/null || true -# Kill any process using our target port (ensures dashboard can bind) +# Kill any process using our target port (ensures the broker can bind) if command -v lsof &> /dev/null; then - lsof -ti:$DASHBOARD_PORT | xargs kill -9 2>/dev/null || true + lsof -ti:$BROKER_PORT | xargs kill -9 2>/dev/null || true fi sleep 1 -# Start broker+dashboard in background, redirect output to log file +# Start broker in background, redirect output to log file DAEMON_LOG="$PROJECT_DIR/.agentworkforce/relay/e2e-daemon.log" mkdir -p "$(dirname "$DAEMON_LOG")" -"$CLI_CMD" local up --port "$DASHBOARD_PORT" > "$DAEMON_LOG" 2>&1 & +AGENT_RELAY_BROKER_PORT="$BROKER_PORT" "$CLI_CMD" local up > "$DAEMON_LOG" 2>&1 & DAEMON_PID=$! log_info "Daemon started (PID: $DAEMON_PID)" log_info "Daemon log: $DAEMON_LOG" diff --git a/scripts/run-dashboard.js b/scripts/run-dashboard.js deleted file mode 100644 index 065ce4510..000000000 --- a/scripts/run-dashboard.js +++ /dev/null @@ -1,3 +0,0 @@ -import { startDashboard } from '../dist/dashboard/server.js'; - -startDashboard(3888, '/tmp/agent-relay-team', '/tmp/agent-relay.sqlite'); diff --git a/scripts/test-interactive-terminal.sh b/scripts/test-interactive-terminal.sh index 68f792dd0..24bd2551d 100755 --- a/scripts/test-interactive-terminal.sh +++ b/scripts/test-interactive-terminal.sh @@ -123,7 +123,7 @@ setup_test_environment() { echo "" echo " Codex: $CLOUD_URL/api/test/auto-login?redirect=/providers/setup/codex?workspace=$WORKSPACE_ID" echo "" - log "Or access the dashboard:" + log "Or access the cloud web app:" echo "" echo " $CLOUD_URL/api/test/auto-login?redirect=/app" echo "" diff --git a/scripts/test-spawn-refactor.sh b/scripts/test-spawn-refactor.sh deleted file mode 100755 index 24d3e3666..000000000 --- a/scripts/test-spawn-refactor.sh +++ /dev/null @@ -1,510 +0,0 @@ -#!/bin/bash -# -# Test Script: SDK Spawn Refactor (GitHub #374) -# -# Tests all changes from the sdk-spawn-refactor branch: -# 1. Unit tests (relay + dashboard) -# 2. SDK integration tests (spawn, release, messaging) -# 3. E2E daemon lifecycle (daemon-only mode, no API key needed) -# 4. Dashboard fleet endpoint verification -# 5. Manual test checklist for live agent spawning -# -# Usage: -# ./scripts/test-spawn-refactor.sh # Run automated tests -# ./scripts/test-spawn-refactor.sh --full # Run all tests incl. live agent spawn -# ./scripts/test-spawn-refactor.sh --checklist # Print manual test checklist only -# - -set -uo pipefail -# Note: NOT using set -e because we handle failures via the FAILURES counter - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -DASHBOARD_DIR="$PROJECT_DIR/../relay-dashboard" -CLI_CMD="$PROJECT_DIR/packages/cli/dist/cli/index.js" -DASHBOARD_PORT=3891 # Use unique port to avoid conflicts -FULL_TEST=false -CHECKLIST_ONLY=false - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --full) FULL_TEST=true; shift ;; - --checklist) CHECKLIST_ONLY=true; shift ;; - *) shift ;; - esac -done - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -BOLD='\033[1m' -NC='\033[0m' - -pass() { echo -e " ${GREEN}PASS${NC} $1"; } -fail() { echo -e " ${RED}FAIL${NC} $1"; FAILURES=$((FAILURES + 1)); } -skip() { echo -e " ${YELLOW}SKIP${NC} $1"; } -phase() { echo -e "\n${CYAN}${BOLD}=== $1 ===${NC}\n"; } -info() { echo -e " ${BOLD}INFO${NC} $1"; } - -FAILURES=0 -TOTAL=0 - -check() { - TOTAL=$((TOTAL + 1)) - if eval "$1" > /dev/null 2>&1; then - pass "$2" - else - fail "$2" - fi - return 0 # Never fail the script itself -} - -# ------------------------------------------------------- -# Manual test checklist -# ------------------------------------------------------- -print_checklist() { - echo "" - echo -e "${CYAN}${BOLD}=================================================${NC}" - echo -e "${CYAN}${BOLD} SDK Spawn Refactor - Manual Test Checklist${NC}" - echo -e "${CYAN}${BOLD}=================================================${NC}" - echo "" - echo -e "${BOLD}Prerequisites:${NC}" - echo " 1. Stop existing daemon: agent-relay down --force" - echo " 2. Start LOCAL daemon: node packages/cli/dist/cli/index.js up --dashboard --port $DASHBOARD_PORT" - echo " 3. Ensure ANTHROPIC_API_KEY is set" - echo "" - echo -e "${BOLD}Test 1: Spawn via Local CLI (daemon socket path)${NC}" - echo " node packages/cli/dist/cli/index.js spawn TestWorker claude 'Say hello then wait' --port $DASHBOARD_PORT" - echo " Expected: Agent spawns via daemon socket (check daemon log for 'SPAWN' envelope)" - echo " Verify: node packages/cli/dist/cli/index.js agents --port $DASHBOARD_PORT" - echo " Cleanup: node packages/cli/dist/cli/index.js release TestWorker --port $DASHBOARD_PORT" - echo "" - echo -e "${BOLD}Test 2: Spawn via Dashboard UI${NC}" - echo " Open http://localhost:$DASHBOARD_PORT in browser" - echo " Click 'Spawn Agent' button" - echo " Fill in: name=UIWorker, cli=claude, task='Hello from dashboard'" - echo " Expected: Dashboard routes spawn through SDK -> daemon" - echo " Verify: Agent appears in fleet view" - echo " Cleanup: Release from UI" - echo "" - echo -e "${BOLD}Test 3: Fleet Endpoints (spawnReader fix)${NC}" - echo " curl http://localhost:$DASHBOARD_PORT/api/fleet/servers | jq ." - echo " curl http://localhost:$DASHBOARD_PORT/api/fleet/stats | jq ." - echo " Expected: localAgents should reflect actual spawned agents (not empty)" - echo "" - echo -e "${BOLD}Test 4: Fallback Chain (daemon-first, no policy bypass)${NC}" - echo " 1. Spawn an agent: node packages/cli/dist/cli/index.js spawn FallbackTest claude 'wait'" - echo " 2. Try spawning same name again: node packages/cli/dist/cli/index.js spawn FallbackTest claude 'wait'" - echo " Expected: Second spawn gets daemon rejection, does NOT fall through to HTTP" - echo " Cleanup: node packages/cli/dist/cli/index.js release FallbackTest" - echo "" - echo -e "${BOLD}Test 5: spawnerName Passthrough${NC}" - echo " Spawn agent with custom spawnerName from dashboard route:" - echo " curl -X POST http://localhost:$DASHBOARD_PORT/api/spawn \\" - echo " -H 'Content-Type: application/json' \\" - echo " -d '{\"name\":\"SpawnerTest\",\"cli\":\"claude\",\"task\":\"wait\",\"spawnerName\":\"MyOrchestrator\"}'" - echo " Expected: SpawnPayload contains spawnerName='MyOrchestrator'" - echo " Verify: Agent shows MyOrchestrator as spawner in agent list" - echo " Cleanup: curl -X POST http://localhost:$DASHBOARD_PORT/api/release -H 'Content-Type: application/json' -d '{\"name\":\"SpawnerTest\"}'" - echo "" - echo -e "${BOLD}Test 6: SDK Integration Tests (requires daemon running)${NC}" - echo " cd $PROJECT_DIR" - echo " node tests/integration/run-all-tests.js --type=sdk" - echo " Expected: All SDK tests pass (spawn, release, messaging)" - echo "" - echo -e "${BOLD}Test 7: E2E Full Lifecycle${NC}" - echo " # Stop the test daemon first, then:" - echo " ./scripts/e2e-test.sh --port $DASHBOARD_PORT" - echo " Expected: Full lifecycle pass (up -> spawn -> release -> down)" - echo "" - echo -e "${BOLD}Key Changes to Verify:${NC}" - echo " [ ] Daemon handles SEND_INPUT, LIST_WORKERS messages" - echo " [ ] SDK client.spawn() accepts spawnerName option" - echo " [ ] Dashboard fleet endpoints use spawnReader (not spawner)" - echo " [ ] Fallback chain only falls through on transport errors" - echo " [ ] LIST_WORKERS_RESULT includes error field on failure" - echo " [ ] Documentation updated (protocol.md, daemon.md, SDK README)" - echo "" -} - -if [ "$CHECKLIST_ONLY" = true ]; then - print_checklist - exit 0 -fi - -# ------------------------------------------------------- -# Phase 0: Verify builds -# ------------------------------------------------------- -phase "Phase 0: Build Verification" - -cd "$PROJECT_DIR" - -check "test -f packages/cli/dist/cli/index.js" "Relay CLI built" -check "test -f packages/sdk/dist/client.js" "SDK package built" -check "test -f packages/daemon/dist/server.js" "Daemon package built" -check "test -f packages/protocol/dist/types.js" "Protocol package built" -check "test -f packages/wrapper/dist/relay-pty-orchestrator.js" "Wrapper package built" - -if [ -d "$DASHBOARD_DIR" ]; then - check "test -f $DASHBOARD_DIR/packages/dashboard-server/dist/server.js" "Dashboard server built" -else - skip "Dashboard repo not found at $DASHBOARD_DIR" -fi - -# ------------------------------------------------------- -# Phase 1: Unit Tests -# ------------------------------------------------------- -phase "Phase 1: Relay Unit Tests" - -info "Running vitest (this takes ~30s)..." -RELAY_TEST_OUTPUT=$(npm test 2>&1) -RELAY_TEST_RESULT=$(echo "$RELAY_TEST_OUTPUT" | grep "Tests" | tail -1) -if echo "$RELAY_TEST_OUTPUT" | grep -q "passed"; then - pass "Relay unit tests: $RELAY_TEST_RESULT" - TOTAL=$((TOTAL + 1)) -else - fail "Relay unit tests failed" - echo "$RELAY_TEST_OUTPUT" | tail -20 -fi - -phase "Phase 1b: Dashboard Unit Tests" - -if [ -d "$DASHBOARD_DIR" ]; then - cd "$DASHBOARD_DIR" - info "Running dashboard vitest..." - DASH_TEST_OUTPUT=$(npm test 2>&1) - DASH_TEST_RESULT=$(echo "$DASH_TEST_OUTPUT" | grep "Tests" | tail -1) - if echo "$DASH_TEST_OUTPUT" | grep -q "passed"; then - pass "Dashboard unit tests: $DASH_TEST_RESULT" - TOTAL=$((TOTAL + 1)) - else - fail "Dashboard unit tests failed" - echo "$DASH_TEST_OUTPUT" | tail -20 - fi - cd "$PROJECT_DIR" -else - skip "Dashboard repo not found" -fi - -# ------------------------------------------------------- -# Phase 2: Protocol Type Verification -# ------------------------------------------------------- -phase "Phase 2: Protocol Type Verification" - -info "Checking new protocol types exist in built output..." - -# Check SEND_INPUT type exists -check "grep -q 'SEND_INPUT' packages/protocol/dist/types.js 2>/dev/null || grep -q 'SEND_INPUT' packages/protocol/dist/types.d.ts 2>/dev/null" \ - "SEND_INPUT message type in protocol" - -# Check LIST_WORKERS type exists -check "grep -q 'LIST_WORKERS' packages/protocol/dist/types.js 2>/dev/null || grep -q 'LIST_WORKERS' packages/protocol/dist/types.d.ts 2>/dev/null" \ - "LIST_WORKERS message type in protocol" - -# Check ListWorkersResultPayload has error field -check "grep -q 'error' packages/protocol/dist/types.d.ts 2>/dev/null && grep -q 'ListWorkersResultPayload' packages/protocol/dist/types.d.ts 2>/dev/null" \ - "ListWorkersResultPayload includes error field" - -# Check SDK has spawnerName in spawn options -check "grep -q 'spawnerName' packages/sdk/dist/client.d.ts 2>/dev/null" \ - "SDK spawn() accepts spawnerName option" - -# Check SDK has sendWorkerInput method -check "grep -q 'sendWorkerInput' packages/sdk/dist/client.d.ts 2>/dev/null" \ - "SDK has sendWorkerInput() method" - -# Check SDK has listWorkers method -check "grep -q 'listWorkers' packages/sdk/dist/client.d.ts 2>/dev/null" \ - "SDK has listWorkers() method" - -# ------------------------------------------------------- -# Phase 3: Spawn Manager Verification -# ------------------------------------------------------- -phase "Phase 3: Daemon Spawn Manager Verification" - -info "Checking spawn-manager handles new message types..." - -check "grep -q 'SEND_INPUT' packages/daemon/dist/spawn-manager.js" \ - "SpawnManager handles SEND_INPUT" - -check "grep -q 'LIST_WORKERS' packages/daemon/dist/spawn-manager.js" \ - "SpawnManager handles LIST_WORKERS" - -check "grep -q 'SEND_INPUT_RESULT' packages/daemon/dist/spawn-manager.js" \ - "SpawnManager sends SEND_INPUT_RESULT" - -check "grep -q 'LIST_WORKERS_RESULT' packages/daemon/dist/spawn-manager.js" \ - "SpawnManager sends LIST_WORKERS_RESULT" - -# ------------------------------------------------------- -# Phase 4: Wrapper Orchestrator Fallback Verification -# ------------------------------------------------------- -phase "Phase 4: Wrapper Fallback Chain Verification" - -info "Checking orchestrator fallback logic..." - -# Verify the fix: daemon responses always return (no fall-through to HTTP) -check "grep -q 'Always return if daemon responded' packages/wrapper/dist/relay-pty-orchestrator.js 2>/dev/null || \ - grep -q 'transport error' packages/wrapper/dist/relay-pty-orchestrator.js 2>/dev/null" \ - "Fallback chain: daemon rejection stops cascade" - -# ------------------------------------------------------- -# Phase 5: Dashboard Integration Checks (static analysis) -# ------------------------------------------------------- -phase "Phase 5: Dashboard Integration Checks" - -if [ -d "$DASHBOARD_DIR" ]; then - DASH_SERVER="$DASHBOARD_DIR/packages/dashboard-server" - - # Check fleet endpoints use spawnReader - check "grep -q 'spawnReader' $DASH_SERVER/dist/server.js 2>/dev/null || \ - grep -q 'spawnReader' $DASH_SERVER/src/server.ts 2>/dev/null" \ - "Fleet endpoints use spawnReader (not spawner)" - - # Check spawn route passes spawnerName - check "grep -q 'spawnerName' $DASH_SERVER/src/server.ts" \ - "Spawn route passes spawnerName to SDK" - - # Make sure fleet doesn't use spawner directly for getActiveWorkers - if grep -n 'spawner?.getActiveWorkers\|spawner\.getActiveWorkers' "$DASH_SERVER/src/server.ts" 2>/dev/null | grep -v 'spawnReader' | grep -v '//' | head -1 | grep -q '.'; then - fail "Fleet endpoint still uses spawner?.getActiveWorkers() directly" - else - pass "Fleet endpoints don't bypass spawnReader" - TOTAL=$((TOTAL + 1)) - fi -else - skip "Dashboard repo not found" -fi - -# ------------------------------------------------------- -# Phase 6: E2E Daemon Lifecycle (daemon-only, no API key needed) -# ------------------------------------------------------- -phase "Phase 6: E2E Daemon Lifecycle (daemon-only mode)" - -# Check if existing daemon is running on the same socket -EXISTING_DAEMON=$(pgrep -f "agent-relay up" 2>/dev/null || true) -if [ -n "$EXISTING_DAEMON" ]; then - info "Existing daemon found (PID: $EXISTING_DAEMON). Stopping it first..." - "$CLI_CMD" down --force --timeout 5000 2>/dev/null || true - # Also try the global CLI - agent-relay down --force --timeout 5000 2>/dev/null || true - sleep 2 - # Force kill if still running - pgrep -f "agent-relay up" | xargs kill -9 2>/dev/null || true - pgrep -f "relay-dashboard-server" | xargs kill -9 2>/dev/null || true - sleep 1 -fi - -info "Starting local daemon on port $DASHBOARD_PORT..." - -# Kill any existing process on our test port -lsof -ti:$DASHBOARD_PORT | xargs kill -9 2>/dev/null || true -sleep 1 - -# Clean stale socket -rm -f "$PROJECT_DIR/.agentworkforce/relay/relay.sock" 2>/dev/null || true - -# Start daemon with local build -DAEMON_LOG="$PROJECT_DIR/.agentworkforce/relay/test-spawn-refactor.log" -mkdir -p "$(dirname "$DAEMON_LOG")" -"$CLI_CMD" up --dashboard --port "$DASHBOARD_PORT" > "$DAEMON_LOG" 2>&1 & -DAEMON_PID=$! - -cleanup_daemon() { - kill $DAEMON_PID 2>/dev/null || true - lsof -ti:$DASHBOARD_PORT | xargs kill -9 2>/dev/null || true -} -trap cleanup_daemon EXIT - -# Wait for daemon -for i in $(seq 1 20); do - if curl -s "http://127.0.0.1:${DASHBOARD_PORT}/health" > /dev/null 2>&1; then - break - fi - if [ $i -eq 20 ]; then - fail "Local daemon failed to start within 20s" - echo " Daemon log:" - tail -20 "$DAEMON_LOG" 2>/dev/null || true - echo "" - echo -e "${RED}${BOLD}Cannot continue without daemon. Aborting.${NC}" - exit 1 - fi - sleep 1 -done -pass "Local daemon started (PID: $DAEMON_PID, port: $DASHBOARD_PORT)" -TOTAL=$((TOTAL + 1)) - -# Wait a bit more for dashboard to fully initialize -sleep 3 - -# Test health endpoint -check "curl -sf http://127.0.0.1:${DASHBOARD_PORT}/health" \ - "Health endpoint responds" - -# Test agents endpoint (may be at /api/agents or via CLI) -AGENTS_RESP=$(curl -s "http://127.0.0.1:${DASHBOARD_PORT}/api/agents" 2>/dev/null || echo "") -if [ -n "$AGENTS_RESP" ]; then - pass "Agents API responds" - TOTAL=$((TOTAL + 1)) -else - # Some dashboard versions don't have /api/agents, test via CLI instead - if "$CLI_CMD" agents --port "$DASHBOARD_PORT" > /dev/null 2>&1; then - pass "Agents available via CLI (API may differ by dashboard version)" - TOTAL=$((TOTAL + 1)) - else - fail "Agents endpoint not available" - fi -fi - -# Test fleet/servers endpoint -FLEET=$(curl -s "http://127.0.0.1:${DASHBOARD_PORT}/api/fleet/servers" 2>/dev/null || echo "") -if [ -n "$FLEET" ]; then - pass "Fleet servers endpoint responds" - TOTAL=$((TOTAL + 1)) -else - skip "Fleet servers endpoint not available (dashboard may be published version)" -fi - -# Test fleet/stats endpoint -STATS=$(curl -s "http://127.0.0.1:${DASHBOARD_PORT}/api/fleet/stats" 2>/dev/null || echo "") -if [ -n "$STATS" ]; then - pass "Fleet stats endpoint responds" - TOTAL=$((TOTAL + 1)) -else - skip "Fleet stats endpoint not available (dashboard may be published version)" -fi - -# Test daemon socket is functional (CLI uses socket, which requires the daemon's PID file) -if [ -S "$PROJECT_DIR/.agentworkforce/relay/relay.sock" ]; then - pass "Daemon socket exists" - TOTAL=$((TOTAL + 1)) -else - fail "Daemon socket not found" -fi - -# Verify daemon PID file -if [ -f "$PROJECT_DIR/.agentworkforce/relay/relay.sock.pid" ]; then - pass "Daemon PID file exists" - TOTAL=$((TOTAL + 1)) -else - # CLI commands may not work without PID file but daemon itself is functional - skip "Daemon PID file not found (CLI status/agents may not work)" -fi - -# ------------------------------------------------------- -# Phase 7: Live Spawn Test (only with --full) -# ------------------------------------------------------- -if [ "$FULL_TEST" = true ]; then - phase "Phase 7: Live Agent Spawn/Release Test" - - if [ -z "${ANTHROPIC_API_KEY:-}" ]; then - skip "ANTHROPIC_API_KEY not set - skipping live spawn test" - else - AGENT_NAME="spawn-refactor-test-$$" - info "Spawning test agent '$AGENT_NAME'..." - - SPAWN_OUTPUT=$("$CLI_CMD" spawn "$AGENT_NAME" claude \ - "You are a test agent. Send a message to yourself saying 'SPAWN_TEST_OK' then wait." \ - --port "$DASHBOARD_PORT" 2>&1) || true - - if echo "$SPAWN_OUTPUT" | grep -qi "success\|spawned\|started"; then - pass "Spawn command succeeded for '$AGENT_NAME'" - TOTAL=$((TOTAL + 1)) - - # Wait for agent to register - info "Waiting for agent registration (max 60s)..." - REGISTERED=false - for i in $(seq 1 60); do - AGENTS=$("$CLI_CMD" agents --json --port "$DASHBOARD_PORT" 2>/dev/null | grep '^\[' || echo "[]") - if echo "$AGENTS" | jq -e --arg name "$AGENT_NAME" '.[] | select(.name == $name)' > /dev/null 2>&1; then - REGISTERED=true - pass "Agent '$AGENT_NAME' registered after ${i}s" - TOTAL=$((TOTAL + 1)) - break - fi - sleep 1 - done - - if [ "$REGISTERED" = false ]; then - fail "Agent '$AGENT_NAME' did not register within 60s" - fi - - # Verify fleet shows the agent - FLEET_AGENTS=$(curl -sf "http://127.0.0.1:${DASHBOARD_PORT}/api/fleet/servers" 2>/dev/null || echo "") - if echo "$FLEET_AGENTS" | grep -q "$AGENT_NAME"; then - pass "Fleet endpoint shows spawned agent" - TOTAL=$((TOTAL + 1)) - else - skip "Fleet endpoint does not show agent (may need time)" - fi - - # Release - info "Releasing agent '$AGENT_NAME'..." - "$CLI_CMD" release "$AGENT_NAME" --port "$DASHBOARD_PORT" 2>/dev/null || true - sleep 3 - - AGENTS_AFTER=$("$CLI_CMD" agents --json --port "$DASHBOARD_PORT" 2>/dev/null | grep '^\[' || echo "[]") - if ! echo "$AGENTS_AFTER" | jq -e --arg name "$AGENT_NAME" '.[] | select(.name == $name)' > /dev/null 2>&1; then - pass "Agent '$AGENT_NAME' released successfully" - TOTAL=$((TOTAL + 1)) - else - fail "Agent '$AGENT_NAME' still present after release" - fi - else - fail "Spawn command failed: $SPAWN_OUTPUT" - fi - fi -else - phase "Phase 7: Live Agent Spawn (skipped, use --full)" - skip "Use --full flag to test live agent spawn/release" -fi - -# ------------------------------------------------------- -# Cleanup -# ------------------------------------------------------- -phase "Cleanup" - -info "Stopping test daemon..." -kill $DAEMON_PID 2>/dev/null || true -sleep 1 -lsof -ti:$DASHBOARD_PORT | xargs kill -9 2>/dev/null || true -pass "Test daemon stopped" - -info "Restarting your daemon on default port (3888)..." -agent-relay up --dashboard > /dev/null 2>&1 & -sleep 3 -if curl -s "http://127.0.0.1:3888/health" > /dev/null 2>&1; then - info "Your daemon is back up on port 3888" -else - info "Daemon restart may still be in progress. Run: agent-relay up --dashboard" -fi - -# ------------------------------------------------------- -# Summary -# ------------------------------------------------------- -echo "" -echo -e "${CYAN}${BOLD}=================================================${NC}" -if [ $FAILURES -eq 0 ]; then - echo -e "${GREEN}${BOLD} ALL $TOTAL CHECKS PASSED${NC}" -else - echo -e "${RED}${BOLD} $FAILURES of $TOTAL CHECKS FAILED${NC}" -fi -echo -e "${CYAN}${BOLD}=================================================${NC}" -echo "" - -if [ $FAILURES -eq 0 ] && [ "$FULL_TEST" = false ]; then - echo -e "${YELLOW}Tip:${NC} Run with ${BOLD}--full${NC} to include live agent spawn/release test" - echo -e "${YELLOW}Tip:${NC} Run with ${BOLD}--checklist${NC} to see manual verification steps" -fi - -# Print checklist reminder -if [ "$FULL_TEST" = false ]; then - echo "" - echo -e "${BOLD}For manual testing, run:${NC}" - echo " ./scripts/test-spawn-refactor.sh --checklist" -fi - -exit $FAILURES diff --git a/scripts/watch-cli-tools.sh b/scripts/watch-cli-tools.sh index 149f16a97..a4d17ea9b 100755 --- a/scripts/watch-cli-tools.sh +++ b/scripts/watch-cli-tools.sh @@ -5,46 +5,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CLI_REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -CLI_TOOL="claude" -if [ -n "${npm_config_tool:-}" ]; then - CLI_TOOL="$npm_config_tool" -fi - -DASHBOARD_PORT=3888 -if [ -n "${npm_config_port:-}" ]; then - DASHBOARD_PORT="$npm_config_port" -fi - -PROJECT_DIR="/Users/khaliqgant/Projects/agent-workforce/test-broker-new" -if [ -n "${npm_config_project:-}" ]; then - PROJECT_DIR="$npm_config_project" -fi - -if [ ! -d "$PROJECT_DIR" ]; then - echo "Project directory not found: $PROJECT_DIR" - exit 1 -fi +PROJECT_DIR="${npm_config_project:-$PWD}" while [ "$#" -gt 0 ]; do case "$1" in - --tool=*) - CLI_TOOL="${1#*=}" - ;; - --tool) - shift - if [ "$#" -gt 0 ]; then - CLI_TOOL="$1" - fi - ;; - --port=*) - DASHBOARD_PORT="${1#*=}" - ;; - --port) - shift - if [ "$#" -gt 0 ]; then - DASHBOARD_PORT="$1" - fi - ;; --project=*) PROJECT_DIR="${1#*=}" ;; @@ -55,28 +19,21 @@ while [ "$#" -gt 0 ]; do fi ;; *) - if [ -z "$1" ]; then - : - elif [ "$1" = "--" ]; then - : - else - CLI_TOOL="$1" - fi + # Other flags (e.g. --tool) are accepted for compatibility but ignored. + : ;; esac shift done -if [ -z "${CLI_TOOL}" ]; then - CLI_TOOL="claude" +if [ ! -d "$PROJECT_DIR" ]; then + echo "Project directory not found: $PROJECT_DIR" + exit 1 fi export RUST_LOG=debug -export RELAY_DASHBOARD_STATIC_DIR=/Users/khaliqgant/Projects/agent-workforce/relay-dashboard/packages/dashboard/out export AGENT_RELAY_MCP_COMMAND="node dist/cli/agent-relay-mcp.js" -export AGENT_RELAY_BIN=/Users/khaliqgant/Projects/agent-workforce/relay-cli-uses-broker/target/debug/agent-relay-broker -export RELAY_DASHBOARD_BINARY=/Users/khaliqgant/Projects/agent-workforce/relay-dashboard/packages/dashboard-server/dist/start.js concurrently -k \ "(cd \"$CLI_REPO_DIR\" && npm run dev:watch)" \ - "cd \"$PROJECT_DIR\" && node --watch /Users/khaliqgant/Projects/agent-workforce/relay-cli-uses-broker/packages/cli/dist/cli/index.js start dashboard.js ${CLI_TOOL} --port ${DASHBOARD_PORT}" + "cd \"$PROJECT_DIR\" && node --watch \"$CLI_REPO_DIR/packages/cli/dist/cli/index.js\" up" diff --git a/tests/e2e/fleet/README.md b/tests/e2e/fleet/README.md index c199ef821..7e022d31f 100644 --- a/tests/e2e/fleet/README.md +++ b/tests/e2e/fleet/README.md @@ -69,7 +69,7 @@ full matrix; the matrix itself is ~30s, the wall-clock is build-dominated. Each `fleet serve` runs in a hermetic env: all ambient `RELAY_*` / `AGENT_RELAY_*` vars are stripped (so the broker never rejoins the operator's real workspace), with -its own `HOME`, project dir, state dir, and dashboard port. The broker reads its +its own `HOME`, project dir, state dir, and broker port. The broker reads its node id from `/agent-relay/machine-id`, which the harness pre-seeds to match the enrolled node id (otherwise `node.register` is rejected `node_id_mismatch`). `FleetNode.stop()` kills the broker child too (by its diff --git a/tests/e2e/fleet/fleet-e2e.test.ts b/tests/e2e/fleet/fleet-e2e.test.ts index afcabc2a7..4e7721cca 100644 --- a/tests/e2e/fleet/fleet-e2e.test.ts +++ b/tests/e2e/fleet/fleet-e2e.test.ts @@ -106,7 +106,7 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - dashboardPort: await getFreePort(), + brokerPort: await getFreePort(), }); nodeB = new FleetNode({ name: 'node-b', @@ -117,7 +117,7 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - dashboardPort: await getFreePort(), + brokerPort: await getFreePort(), }); nodeA.start(); nodeB.start(); @@ -170,7 +170,7 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { engineBaseUrl: engine.baseUrl, brokerBinary: pre.brokerBinary!, tmpRoot, - dashboardPort: await getFreePort(), + brokerPort: await getFreePort(), }); badNode.start(); try { diff --git a/tests/e2e/fleet/harness.ts b/tests/e2e/fleet/harness.ts index 5b783cbcf..fde208263 100644 --- a/tests/e2e/fleet/harness.ts +++ b/tests/e2e/fleet/harness.ts @@ -277,7 +277,7 @@ export class FleetNode { engineBaseUrl: string; brokerBinary: string; tmpRoot: string; - dashboardPort: number; + brokerPort: number; } ) { this.projectDir = path.join(opts.tmpRoot, `node-${opts.name}`); @@ -331,7 +331,7 @@ export class FleetNode { RELAY_API_KEY: o.workspaceKey, AGENT_RELAY_PROJECT: this.projectDir, AGENT_RELAY_STATE_DIR: stateDir, - AGENT_RELAY_DASHBOARD_PORT: String(o.dashboardPort), + AGENT_RELAY_BROKER_PORT: String(o.brokerPort), }), stdio: ['ignore', 'pipe', 'pipe'], } diff --git a/web/content/agent-relay/SKILL.md b/web/content/agent-relay/SKILL.md index 275bbd829..bdd5e9685 100644 --- a/web/content/agent-relay/SKILL.md +++ b/web/content/agent-relay/SKILL.md @@ -69,7 +69,7 @@ and `join_channel`. 1. Start Relay from the project root: ```bash - agent-relay local up --no-dashboard --verbose + agent-relay local up --verbose agent-relay local status --wait-for 10 ``` diff --git a/web/content/docs/cli-broker-lifecycle.mdx b/web/content/docs/cli-broker-lifecycle.mdx index 190ceda9d..8ee0ffb51 100644 --- a/web/content/docs/cli-broker-lifecycle.mdx +++ b/web/content/docs/cli-broker-lifecycle.mdx @@ -1,9 +1,9 @@ --- title: 'Broker lifecycle' -description: 'Start, inspect, and stop the optional local broker runtime used for managed CLI agents and the local dashboard.' +description: 'Start, inspect, and stop the optional local broker runtime used for managed CLI agents.' --- -The version 8 core product is workspace messaging. The local broker is optional: use it when this machine should run managed CLI agents, expose a local dashboard, or attach to PTY/headless sessions. +The version 8 core product is workspace messaging. The local broker is optional: use it when this machine should run managed CLI agents or attach to PTY/headless sessions. Broker commands live under `agent-relay local`. @@ -17,12 +17,9 @@ Useful flags: | Flag | Description | | --- | --- | -| `--no-dashboard` | Start the broker without the web dashboard. | -| `--port ` | Dashboard port. The broker API uses the next available port. | | `--spawn` | Force auto-spawn from local team config. | | `--no-spawn` | Start only the broker. | -| `--background` | Detach and leave the broker running. | -| `--foreground` | Keep a no-dashboard broker attached to this terminal. | +| `--background` | Detach and leave the broker running. The default is to stay attached to this terminal. | | `--workspace-key ` | Join a pre-existing Relay workspace. | | `--state-dir ` | Write runtime state outside `.agentworkforce/relay/`. | | `--broker-name ` | Override the broker identity. | diff --git a/web/content/docs/cli-overview.mdx b/web/content/docs/cli-overview.mdx index c077a5bce..47cc32577 100644 --- a/web/content/docs/cli-overview.mdx +++ b/web/content/docs/cli-overview.mdx @@ -114,7 +114,7 @@ agent-relay local agent release reviewer agent-relay local down ``` -The local runtime is optional. It manages a broker process, dashboard, PTY/headless agents, attach modes, workflow logs, and release for CLI agents running on this machine. Local workflow runs execute Relayflows workflows (`.yaml`, `.yml`, `.ts`, `.tsx`, `.js`, `.py`, or `.sh`) in the current checkout and keep metadata under `.agentworkforce/relay/local-runs`. +The local runtime is optional. It manages a broker process, PTY/headless agents, attach modes, workflow logs, and release for CLI agents running on this machine. Local workflow runs execute Relayflows workflows (`.yaml`, `.yml`, `.ts`, `.tsx`, `.js`, `.py`, or `.sh`) in the current checkout and keep metadata under `.agentworkforce/relay/local-runs`. ## Composite Status And Maintenance diff --git a/web/content/docs/reference-cli.mdx b/web/content/docs/reference-cli.mdx index e4a9d91dc..eaf682d58 100644 --- a/web/content/docs/reference-cli.mdx +++ b/web/content/docs/reference-cli.mdx @@ -128,7 +128,7 @@ The SDK-backed groups are `agent`, `channel`, `message`, `integration`, and `cap | Command | Description | | --- | --- | -| `agent-relay local up [flags]` | Start the local broker and optional dashboard. | +| `agent-relay local up [flags]` | Start the local broker. | | `agent-relay local status [--state-dir ] [--wait-for ]` | Check local broker daemon state. | | `agent-relay local metrics [--agent ]` | Show local broker and agent resource usage. | | `agent-relay local run [--file-type ]` | Start a local Relayflows workflow file in the background. |