Skip to content
54 changes: 26 additions & 28 deletions packages/cli/__tests__/commands/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,45 +89,43 @@ describe("findRunningDashboardPid", () => {
});
});

describe("findProcessWebDir", () => {
it("extracts cwd from lsof output", async () => {
const webDir = join(tmpDir, "web");
mkdirSync(webDir, { recursive: true });
writeFileSync(join(webDir, "package.json"), "{}");
describe("isInstalledUnderNodeModules", () => {
it("returns true for a Unix node_modules path segment", async () => {
const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");

// Simulate lsof -p <pid> -Fn output
mockExecSilent.mockResolvedValue(
`p12345\nfcwd\nn${webDir}\nftxt\nn/usr/bin/node`,
);
expect(isInstalledUnderNodeModules("/usr/local/lib/node_modules/@composio/ao-web")).toBe(true);
});

const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js");
it("returns true for a Windows node_modules path segment", async () => {
const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");

const result = await findProcessWebDir("12345");
expect(result).toBe(webDir);
expect(isInstalledUnderNodeModules("C:\\Users\\me\\node_modules\\@composio\\ao-web")).toBe(true);
});

it("returns null when cwd has no package.json", async () => {
const webDir = join(tmpDir, "web");
mkdirSync(webDir, { recursive: true });
// No package.json
it("returns false for source paths containing node_modules as plain text", async () => {
const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");

mockExecSilent.mockResolvedValue(
`p12345\nfcwd\nn${webDir}\nftxt\nn/usr/bin/node`,
);
expect(
isInstalledUnderNodeModules("/home/user/node_modules_backup/agent-orchestrator/packages/web"),
).toBe(false);
});
});

const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js");
describe("assertDashboardRebuildSupported", () => {
it("passes for a source checkout", async () => {
const { assertDashboardRebuildSupported } = await import("../../src/lib/dashboard-rebuild.js");

const result = await findProcessWebDir("12345");
expect(result).toBeNull();
expect(() =>
assertDashboardRebuildSupported("/home/user/agent-orchestrator/packages/web"),
).not.toThrow();
});

it("returns null when lsof fails", async () => {
mockExecSilent.mockResolvedValue(null);

const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js");
it("throws for an npm-installed package path", async () => {
const { assertDashboardRebuildSupported } = await import("../../src/lib/dashboard-rebuild.js");

const result = await findProcessWebDir("12345");
expect(result).toBeNull();
expect(() =>
assertDashboardRebuildSupported("/usr/local/lib/node_modules/@composio/ao-web"),
).toThrow("Dashboard rebuild is only available from a source checkout");
});
});

Expand Down
3 changes: 1 addition & 2 deletions packages/cli/__tests__/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,8 @@ vi.mock("../../src/lib/web-dir.js", () => ({
}));

vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
cleanNextCache: vi.fn(),
findRunningDashboardPid: vi.fn().mockResolvedValue(null),
findProcessWebDir: vi.fn().mockResolvedValue(null),
rebuildDashboardProductionArtifacts: vi.fn().mockResolvedValue(undefined),
waitForPortFree: vi.fn(),
}));

Expand Down
13 changes: 13 additions & 0 deletions packages/cli/__tests__/lib/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@ describe("preflight.checkBuilt", () => {
// /web/node_modules/@composio/ao-core — miss
// /node_modules/@composio/ao-core — hit
// /node_modules/@composio/ao-core/dist/index.js — exists
// /web/.next/BUILD_ID and /web/dist-server/start-all.js — exist
mockExistsSync
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true);
await expect(preflight.checkBuilt("/web")).resolves.toBeUndefined();
});
Expand Down Expand Up @@ -88,6 +91,16 @@ describe("preflight.checkBuilt", () => {
"Packages not built",
);
});

it("throws when web production artifacts are missing", async () => {
mockExistsSync
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await expect(preflight.checkBuilt("/web")).rejects.toThrow(
"Packages not built",
);
});
});

describe("preflight.checkTmux", () => {
Expand Down
46 changes: 24 additions & 22 deletions packages/cli/src/commands/dashboard.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig } from "@composio/ao-core";
import { findWebDir, buildDashboardEnv, waitForPortAndOpen } from "../lib/web-dir.js";
import { cleanNextCache, findRunningDashboardPid, findProcessWebDir, waitForPortFree } from "../lib/dashboard-rebuild.js";
import {
assertDashboardRebuildSupported,
findRunningDashboardPid,
isInstalledUnderNodeModules,
rebuildDashboardProductionArtifacts,
waitForPortFree,
} from "../lib/dashboard-rebuild.js";
import { preflight } from "../lib/preflight.js";

export function registerDashboard(program: Command): void {
program
Expand All @@ -26,13 +32,13 @@ export function registerDashboard(program: Command): void {
const localWebDir = findWebDir(); // throws with install-specific guidance if not found

if (opts.rebuild) {
assertDashboardRebuildSupported(localWebDir);

// Check if a dashboard is already running on this port.
const runningPid = await findRunningDashboardPid(port);
const runningWebDir = runningPid ? await findProcessWebDir(runningPid) : null;
const targetWebDir = runningWebDir ?? localWebDir;

if (runningPid) {
// Kill the running server, clean .next, then start fresh below.
// Stop the running server before rebuilding or restarting below.
console.log(
chalk.dim(`Stopping dashboard (PID ${runningPid}) on port ${port}...`),
);
Expand All @@ -45,8 +51,10 @@ export function registerDashboard(program: Command): void {
await waitForPortFree(port, 5000);
}

await cleanNextCache(targetWebDir);
await rebuildDashboardProductionArtifacts(localWebDir);
// Fall through to start the dashboard on this port.
} else {
await preflight.checkBuilt(localWebDir);
}

const webDir = localWebDir;
Expand All @@ -60,21 +68,12 @@ export function registerDashboard(program: Command): void {
config.directTerminalPort,
);

// In dev mode (monorepo), use `pnpm run dev` which starts Next.js AND
// the terminal WebSocket servers via concurrently. Without the WS servers,
// the live terminal in the dashboard won't work.
const isDevMode = existsSync(resolve(webDir, "server"));
const child = isDevMode
? spawn("pnpm", ["run", "dev"], {
cwd: webDir,
stdio: ["inherit", "inherit", "pipe"],
env,
})
: spawn("npx", ["next", "dev", "-p", String(port)], {
cwd: webDir,
stdio: ["inherit", "inherit", "pipe"],
env,
});
const startScript = resolve(webDir, "dist-server", "start-all.js");
const child = spawn("node", [startScript], {
cwd: webDir,
stdio: ["inherit", "inherit", "pipe"],
env,
});

const stderrChunks: string[] = [];

Expand Down Expand Up @@ -108,10 +107,13 @@ export function registerDashboard(program: Command): void {
if (code !== 0 && code !== null && !opts.rebuild) {
const stderr = stderrChunks.join("");
if (looksLikeStaleBuild(stderr)) {
const recoveryCommand = isInstalledUnderNodeModules(webDir)
? "ao update"
: "ao dashboard --rebuild";
console.error(
chalk.yellow(
"\nThis looks like a stale build cache issue. Try:\n\n" +
` ${chalk.cyan("ao dashboard --rebuild")}\n`,
` ${chalk.cyan(recoveryCommand)}\n`,
),
);
}
Expand Down
45 changes: 14 additions & 31 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
findFreePort,
MAX_PORT_SCAN,
} from "../lib/web-dir.js";
import { cleanNextCache } from "../lib/dashboard-rebuild.js";
import { rebuildDashboardProductionArtifacts } from "../lib/dashboard-rebuild.js";
import { preflight } from "../lib/preflight.js";
import { register, unregister, isAlreadyRunning, getRunning, waitForExit } from "../lib/running-state.js";
import { isHumanCaller } from "../lib/caller-context.js";
Expand Down Expand Up @@ -756,37 +756,20 @@ async function startDashboard(
): Promise<ChildProcess> {
const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort);

// Detect dev vs production: the `server/` source directory only exists in the
// monorepo. Published npm packages only have `dist-server/`.
const isDevMode = existsSync(resolve(webDir, "server"));

let child: ChildProcess;
if (isDevMode) {
// Monorepo development: use pnpm run dev (tsx, HMR, etc.)
child = spawn("pnpm", ["run", "dev"], {
cwd: webDir,
stdio: "inherit",
detached: false,
env,
});
} else {
// Production (installed from npm): use pre-built start-all script
child = spawn("node", [resolve(webDir, "dist-server", "start-all.js")], {
cwd: webDir,
stdio: "inherit",
detached: false,
env,
});
}
const startScript = resolve(webDir, "dist-server", "start-all.js");
const child: ChildProcess = spawn("node", [startScript], {
cwd: webDir,
stdio: "inherit",
detached: false,
env,
});

child.on("error", (err) => {
const cmd = isDevMode ? "pnpm" : "node";
const args = isDevMode ? ["run", "dev"] : [resolve(webDir, "dist-server", "start-all.js")];
const formatted = formatCommandError(err, {
cmd,
args,
cmd: "node",
args: [startScript],
action: "start the AO dashboard",
installHints: genericInstallHints(cmd),
installHints: genericInstallHints("node"),
});
console.error(chalk.red("Dashboard failed to start:"), formatted.message);
// Emit synthetic exit so callers listening on "exit" can clean up
Expand Down Expand Up @@ -948,10 +931,10 @@ async function runStartup(
port = newPort;
}
const webDir = findWebDir(); // throws with install-specific guidance if not found
await preflight.checkBuilt(webDir);

if (opts?.rebuild) {
await cleanNextCache(webDir);
await rebuildDashboardProductionArtifacts(webDir);
} else {
await preflight.checkBuilt(webDir);
}

spinner.start("Starting dashboard");
Expand Down
69 changes: 43 additions & 26 deletions packages/cli/src/lib/dashboard-rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import { resolve } from "node:path";
import { existsSync, rmSync } from "node:fs";
import ora from "ora";
import { execSilent } from "./shell.js";
import { exec, execSilent } from "./shell.js";

// Match node_modules as a path segment, not just a substring.
export function isInstalledUnderNodeModules(path: string): boolean {
return path.includes("/node_modules/") || path.includes("\\node_modules\\");
}

/**
* Find the PID of a process listening on the given port.
Expand All @@ -21,28 +26,6 @@ export async function findRunningDashboardPid(port: number): Promise<string | nu
return pid;
}

/**
* Find the working directory of a process by PID.
* Returns null if the cwd can't be determined.
*/
export async function findProcessWebDir(pid: string): Promise<string | null> {
const lsofDetail = await execSilent("lsof", ["-p", pid, "-Ffn"]);
if (!lsofDetail) return null;

// lsof -Fn outputs lines like "n/path/to/cwd" — the cwd entry follows "fcwd"
const lines = lsofDetail.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i] === "fcwd" && i + 1 < lines.length && lines[i + 1]?.startsWith("n/")) {
const cwd = lines[i + 1].slice(1);
if (existsSync(resolve(cwd, "package.json"))) {
return cwd;
}
}
}

return null;
}

/**
* Wait for a port to be free (no process listening).
* Throws if the port is still busy after the timeout.
Expand All @@ -58,9 +41,7 @@ export async function waitForPortFree(port: number, timeoutMs: number): Promise<
}

/**
* Clean just the .next cache directory. Use when a dev server is running —
* it will recompile on next request. Does NOT run pnpm build (which would
* create a production .next that the dev server can't use).
* Remove the .next directory before a rebuild.
*/
export async function cleanNextCache(webDir: string): Promise<void> {
const nextDir = resolve(webDir, ".next");
Expand All @@ -72,3 +53,39 @@ export async function cleanNextCache(webDir: string): Promise<void> {
}
}

/**
* Rebuild dashboard production artifacts from a source checkout.
* Global npm installs ship prebuilt artifacts and cannot rebuild in place.
*/
export function assertDashboardRebuildSupported(webDir: string): void {
if (isInstalledUnderNodeModules(webDir)) {
throw new Error(
"Dashboard rebuild is only available from a source checkout. " +
"Run `ao update`, or reinstall with `npm install -g @composio/ao@latest`.",
);
}
}

/**
* Rebuild dashboard production artifacts from a source checkout.
* Global npm installs ship prebuilt artifacts and cannot rebuild in place.
*/
export async function rebuildDashboardProductionArtifacts(webDir: string): Promise<void> {
assertDashboardRebuildSupported(webDir);

await cleanNextCache(webDir);

const workspaceRoot = resolve(webDir, "../..");
const spinner = ora("Rebuilding dashboard production artifacts").start();

try {
await exec("pnpm", ["build"], { cwd: workspaceRoot });
spinner.succeed("Rebuilt dashboard production artifacts");
} catch (error) {
spinner.fail("Dashboard rebuild failed");
throw new Error(
"Failed to rebuild dashboard production artifacts. Run `pnpm build` and try again.",
{ cause: error },
);
}
}
Loading