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
101 changes: 101 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,51 @@ import {
} from "@composio/ao-core";
import * as serialize from "@/lib/serialize";
import { getSCM } from "@/lib/services";
import { getTerminalTransportHealth } from "@/lib/terminal-transport";
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 +237,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 +285,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 +443,56 @@ 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);
});

it("rate-limits self-heal side effects across frequent polls", async () => {
const degradedHealth: TerminalTransportHealth = {
...mockTerminalHealth,
status: "degraded",
degraded: true,
message: "Terminal transport degraded: direct terminal websocket",
services: {
...mockTerminalHealth.services,
directTerminalWebsocket: {
...mockTerminalHealth.services.directTerminalWebsocket,
healthy: false,
status: "restarting",
},
},
};

const healthMock = vi.mocked(getTerminalTransportHealth);
healthMock.mockReset();
healthMock.mockResolvedValueOnce(degradedHealth);
healthMock.mockResolvedValueOnce(degradedHealth);
healthMock.mockResolvedValueOnce(degradedHealth);

const nowSpy = vi.spyOn(Date, "now");
const base = 10_000_000_000_000;
nowSpy.mockReturnValueOnce(base);
const first = await terminalHealthGET(
makeRequest("http://localhost:3000/api/terminal-health"),
);
expect(first.status).toBe(200);

nowSpy.mockReturnValueOnce(base + 10);
const second = await terminalHealthGET(
makeRequest("http://localhost:3000/api/terminal-health"),
);
expect(second.status).toBe(200);

expect(healthMock).toHaveBeenNthCalledWith(1, { heal: false });
expect(healthMock).toHaveBeenNthCalledWith(2, { heal: true });
expect(healthMock).toHaveBeenNthCalledWith(3, { heal: false });
});
});

// ── 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
25 changes: 25 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,25 @@
import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability";
import { getTerminalTransportHealth } from "@/lib/terminal-transport";

const HEAL_COOLDOWN_MS = 30_000;
let lastHealAt = 0;

function shouldHeal(): boolean {
const now = Date.now();
if (now - lastHealAt < HEAL_COOLDOWN_MS) {
return false;
}
lastHealAt = now;
return true;
}

export async function GET(request: Request) {
const correlationId = getCorrelationId(request);
const observed = await getTerminalTransportHealth({ heal: false });
if (!observed.degraded || !shouldHeal()) {
return jsonWithCorrelation(observed, { status: 200 }, correlationId);
}

const health = await getTerminalTransportHealth({ heal: true });
return jsonWithCorrelation(health, { status: 200 }, correlationId);
}
Loading
Loading