Skip to content
Open
19 changes: 19 additions & 0 deletions packages/cli/__tests__/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ vi.mock("node:child_process", async (importOriginal) => {

import { Command } from "commander";
import { registerStart, registerStop } from "../../src/commands/start.js";
import { findWebDir } from "../../src/lib/web-dir.js";

let tmpDir: string;
let program: Command;
Expand Down Expand Up @@ -326,6 +327,24 @@ describe("start command — project resolution", () => {
});
});

describe("start command — dashboard launch", () => {
it("starts Next dev directly so terminal sidecars are supervised in-process", async () => {
const webDir = join(tmpDir, "web");
mkdirSync(webDir, { recursive: true });
writeFileSync(join(webDir, "package.json"), "{}\n");
vi.mocked(findWebDir).mockReturnValue(webDir);
mockConfigRef.current = makeConfig({ "my-app": makeProject() });

await program.parseAsync(["node", "test", "start", "--no-orchestrator"]);

expect(mockSpawn).toHaveBeenCalledWith(
"npx",
["next", "dev", "-p", "3000"],
expect.objectContaining({ cwd: webDir }),
);
});
});

// ---------------------------------------------------------------------------
// URL detection — `ao start <url>` triggers handleUrlStart
// ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/__tests__/scripts/doctor-script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe("scripts/ao-doctor.sh", () => {
const result = spawnSync("bash", [scriptPath], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ""}`,
PATH: `${binDir}:/usr/bin:/bin`,
AO_REPO_ROOT: fakeRepo,
AO_CONFIG_PATH: configPath,
},
Expand Down Expand Up @@ -149,7 +149,7 @@ describe("scripts/ao-doctor.sh", () => {
const result = spawnSync("bash", [scriptPath, "--fix"], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ""}`,
PATH: `${binDir}:/usr/bin:/bin`,
AO_REPO_ROOT: fakeRepo,
AO_CONFIG_PATH: configPath,
AO_DOCTOR_TMP_ROOT: tmpRoot,
Expand Down
62 changes: 60 additions & 2 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { cleanNextCache } from "../lib/dashboard-rebuild.js";
import { preflight } from "../lib/preflight.js";

const DEFAULT_PORT = 3000;
const TERMINAL_READY_TIMEOUT_MS = 20_000;

// =============================================================================
// HELPERS
Expand Down Expand Up @@ -233,13 +234,17 @@ async function startDashboard(
): Promise<ChildProcess> {
const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort);

const child = spawn("pnpm", ["run", "dev"], {
const child = spawn("npx", ["next", "dev", "-p", String(port)], {
cwd: webDir,
stdio: "inherit",
stdio: ["inherit", "inherit", "pipe"],
detached: false,
env,
});

child.stderr?.on("data", (data: Buffer) => {
process.stderr.write(data);
});

child.on("error", (err) => {
console.error(chalk.red("Dashboard failed to start:"), err.message);
// Emit synthetic exit so callers listening on "exit" can clean up
Expand All @@ -249,6 +254,49 @@ async function startDashboard(
return child;
}

async function waitForDashboardPort(port: number, timeoutMs = 30_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const free = await isPortAvailable(port);
if (!free) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
return false;
}

async function ensureTerminalTransportReady(port: number): Promise<boolean> {
if (process.env["VITEST"] === "true") {
return true;
}

const dashboardReady = await waitForDashboardPort(port);
if (!dashboardReady) {
return false;
}

const deadline = Date.now() + TERMINAL_READY_TIMEOUT_MS;
while (Date.now() < deadline) {
const res = await fetch(`http://localhost:${port}/api/terminal-health`).catch(() => null);
if (res?.ok) {
const body = (await res.json()) as {
services?: {
terminalWebsocket?: { healthy?: boolean };
directTerminalWebsocket?: { healthy?: boolean };
};
};
const terminalHealthy = body.services?.terminalWebsocket?.healthy === true;
const directHealthy = body.services?.directTerminalWebsocket?.healthy === true;
if (terminalHealthy && directHealthy) {
return true;
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return false;
}

/**
* Shared startup logic: launch dashboard + orchestrator session, print summary.
* Used by both normal and URL-based start flows.
Expand Down Expand Up @@ -310,6 +358,16 @@ async function runStartup(
);
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));

spinner.start("Checking terminal websocket services");
const terminalsReady = await ensureTerminalTransportReady(port);
if (terminalsReady) {
spinner.succeed("Terminal websocket services healthy");
} else {
spinner.warn(
"Terminal websocket services still recovering (dashboard will report degraded health)",
);
}
}

if (shouldStartLifecycle) {
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/tracker-linear/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@composio/ao-core": "workspace:*"
"@composio/ao-core": "workspace:*",
"@composio/core": "^0.6.4"
},
"devDependencies": {
"@types/node": "^25.2.3",
Expand Down
59 changes: 59 additions & 0 deletions packages/web/src/__tests__/api-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@ import {
} from "@composio/ao-core";
import * as serialize from "@/lib/serialize";
import { getSCM } from "@/lib/services";
import type { TerminalTransportHealth } from "@/lib/types";

const mockTerminalHealth: TerminalTransportHealth = {
status: "healthy",
degraded: false,
message: "Terminal websocket services healthy",
checkedAt: new Date().toISOString(),
services: {
terminalWebsocket: {
key: "terminalWebsocket",
label: "terminal websocket",
port: 14800,
healthPath: "/health",
status: "healthy",
healthy: true,
message: "terminal websocket healthy",
pid: 111,
restartCount: 0,
lastCheckedAt: new Date().toISOString(),
lastHealthyAt: new Date().toISOString(),
lastStartedAt: new Date().toISOString(),
lastErrorAt: null,
lastError: null,
supervisorOwned: true,
},
directTerminalWebsocket: {
key: "directTerminalWebsocket",
label: "direct terminal websocket",
port: 14801,
healthPath: "/health",
status: "healthy",
healthy: true,
message: "direct terminal websocket healthy",
pid: 222,
restartCount: 0,
lastCheckedAt: new Date().toISOString(),
lastHealthyAt: new Date().toISOString(),
lastStartedAt: new Date().toISOString(),
lastErrorAt: null,
lastError: null,
supervisorOwned: true,
},
},
};

// ── Mock Data ─────────────────────────────────────────────────────────
// Provides test sessions covering the key states the dashboard needs.
Expand Down Expand Up @@ -192,9 +236,14 @@ vi.mock("@/lib/services", () => ({
getSCM: vi.fn(() => mockSCM),
}));

vi.mock("@/lib/terminal-transport", () => ({
getTerminalTransportHealth: vi.fn(async () => mockTerminalHealth),
}));

// ── Import routes after mocking ───────────────────────────────────────

import { GET as sessionsGET } from "@/app/api/sessions/route";
import { GET as terminalHealthGET } from "@/app/api/terminal-health/route";
import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route";
import { POST as spawnPOST } from "@/app/api/spawn/route";
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
Expand Down Expand Up @@ -235,6 +284,7 @@ describe("API Routes", () => {
expect(data.sessions.length).toBe(testSessions.length);
expect(data.stats).toBeDefined();
expect(data.stats.totalSessions).toBe(data.sessions.length);
expect(data.terminalHealth).toEqual(mockTerminalHealth);
});

it("stats include expected fields", async () => {
Expand Down Expand Up @@ -392,6 +442,15 @@ describe("API Routes", () => {
});
});

describe("GET /api/terminal-health", () => {
it("returns current terminal transport health", async () => {
const res = await terminalHealthGET(makeRequest("http://localhost:3000/api/terminal-health"));
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual(mockTerminalHealth);
});
});

// ── POST /api/spawn ────────────────────────────────────────────────

describe("POST /api/spawn", () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/web/src/app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
enrichSessionsMetadata,
} from "@/lib/serialize";
import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability";
import { getTerminalTransportHealth } from "@/lib/terminal-transport";

export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const correlationId = getCorrelationId(_request);
const startedAt = Date.now();
try {
const { id } = await params;
const { config, registry, sessionManager } = await getServices();
const terminalHealth = await getTerminalTransportHealth({ heal: false });

const coreSession = await sessionManager.get(id);
if (!coreSession) {
Expand Down Expand Up @@ -52,7 +54,11 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
sessionId: id,
});

return jsonWithCorrelation(dashboardSession, { status: 200 }, correlationId);
return jsonWithCorrelation(
{ ...dashboardSession, terminalHealth },
{ status: 200 },
correlationId,
);
} catch (error) {
const { id } = await params;
const { config, sessionManager } = await getServices().catch(() => ({
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/app/api/sessions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability";
import { resolveGlobalPause } from "@/lib/global-pause";
import { filterProjectSessions } from "@/lib/project-utils";
import { getTerminalTransportHealth } from "@/lib/terminal-transport";

const METADATA_ENRICH_TIMEOUT_MS = 3_000;
const PR_ENRICH_TIMEOUT_MS = 4_000;
Expand Down Expand Up @@ -40,6 +41,7 @@ export async function GET(request: Request) {
const activeOnly = searchParams.get("active") === "true";

const { config, registry, sessionManager } = await getServices();
const terminalHealth = await getTerminalTransportHealth({ heal: false });
const requestedProjectId =
projectFilter && projectFilter !== "all" && config.projects[projectFilter]
? projectFilter
Expand Down Expand Up @@ -106,6 +108,7 @@ export async function GET(request: Request) {
orchestratorId,
orchestrators,
globalPause: resolveGlobalPause(allSessions),
terminalHealth,
},
{ status: 200 },
correlationId,
Expand Down
8 changes: 8 additions & 0 deletions packages/web/src/app/api/terminal-health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability";
import { getTerminalTransportHealth } from "@/lib/terminal-transport";

export async function GET(request: Request) {
const correlationId = getCorrelationId(request);
const health = await getTerminalTransportHealth();
return jsonWithCorrelation(health, { status: 200 }, correlationId);
}
8 changes: 7 additions & 1 deletion packages/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Metadata } from "next";

export const dynamic = "force-dynamic";
import { Dashboard } from "@/components/Dashboard";
import type { DashboardSession } from "@/lib/types";
import type { DashboardSession, TerminalTransportHealth } from "@/lib/types";
import { getServices, getSCM } from "@/lib/services";
import {
sessionToDashboard,
Expand All @@ -15,6 +15,7 @@ import { prCache, prCacheKey } from "@/lib/cache";
import { getPrimaryProjectId, getProjectName, getAllProjects } from "@/lib/project-name";
import { filterProjectSessions, filterWorkerSessions } from "@/lib/project-utils";
import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause";
import { getTerminalTransportHealth } from "@/lib/terminal-transport";

function getSelectedProjectName(projectFilter: string | undefined): string {
if (projectFilter === "all") return "All Projects";
Expand Down Expand Up @@ -42,14 +43,17 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
sessions: DashboardSession[];
globalPause: GlobalPauseState | null;
orchestrators: Array<{ id: string; projectId: string; projectName: string }>;
terminalHealth: TerminalTransportHealth | null;
} = {
sessions: [],
globalPause: null,
orchestrators: [],
terminalHealth: null,
};

try {
const { config, registry, sessionManager } = await getServices();
pageData.terminalHealth = await getTerminalTransportHealth({ heal: false });
const allSessions = await sessionManager.list();

pageData.globalPause = resolveGlobalPause(allSessions);
Expand Down Expand Up @@ -120,6 +124,7 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
pageData.sessions = [];
pageData.globalPause = null;
pageData.orchestrators = [];
pageData.terminalHealth = null;
}

const projectName = getSelectedProjectName(projectFilter);
Expand All @@ -134,6 +139,7 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
projects={projects}
initialGlobalPause={pageData.globalPause}
orchestrators={pageData.orchestrators}
initialTerminalHealth={pageData.terminalHealth}
/>
);
}
Loading
Loading