diff --git a/docs/CLI.md b/docs/CLI.md index 52001d0e6..171295652 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -10,6 +10,7 @@ ao start # Clone repo, auto-configure, and start ao start ~/other-repo # Add a new project and start ao stop # Stop everything (dashboard, orchestrator, lifecycle worker) ao status # Overview of all sessions +ao status --watch # Live-updating terminal status view ao dashboard # Open web dashboard in browser ``` diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index 6f40eb038..5a47c6259 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -27,6 +27,7 @@ const { mockGetReviewDecision, mockGetPendingComments, mockSessionManager, + mockGetPluginRegistry, sessionsDirRef, } = vi.hoisted(() => ({ mockTmux: vi.fn(), @@ -48,6 +49,7 @@ const { send: vi.fn(), claimPR: vi.fn(), }, + mockGetPluginRegistry: vi.fn(), sessionsDirRef: { current: "" }, })); @@ -205,7 +207,7 @@ function makeSession(overrides: Partial & { id: string; projectId: stri vi.mock("../../src/lib/create-session-manager.js", () => ({ getSessionManager: async (): Promise => mockSessionManager as SessionManager, - getPluginRegistry: async () => ({ get: vi.fn(), list: vi.fn(), register: vi.fn() }), + getPluginRegistry: (...args: unknown[]) => mockGetPluginRegistry(...args), })); let tmpDir: string; @@ -216,6 +218,9 @@ import { registerStatus } from "../../src/commands/status.js"; let program: Command; let consoleSpy: ReturnType; +let setIntervalSpy: ReturnType | undefined; +let clearIntervalSpy: ReturnType | undefined; +let processOnceSpy: ReturnType | undefined; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "ao-status-test-")); @@ -281,6 +286,9 @@ beforeEach(() => { mockSessionManager.get.mockReset(); mockSessionManager.spawn.mockReset(); mockSessionManager.send.mockReset(); + mockGetPluginRegistry.mockReset(); + // Default registry: no tracker + mockGetPluginRegistry.mockResolvedValue({ get: vi.fn().mockReturnValue(null), list: vi.fn(), register: vi.fn() }); // Default: list reads from sessionsDir mockSessionManager.list.mockImplementation(async () => { @@ -289,6 +297,12 @@ beforeEach(() => { }); afterEach(() => { + setIntervalSpy?.mockRestore(); + setIntervalSpy = undefined; + clearIntervalSpy?.mockRestore(); + clearIntervalSpy = undefined; + processOnceSpy?.mockRestore(); + processOnceSpy = undefined; rmSync(tmpDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -567,6 +581,81 @@ describe("status command", () => { expect(parsed[0].pendingThreads).toBe(0); }); + it("rejects --watch with --json", async () => { + await expect(program.parseAsync(["node", "test", "status", "--watch", "--json"])).rejects.toThrow( + "process.exit(1)", + ); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => c[0]) + .join("\n"); + expect(errors).toContain("--watch cannot be used with --json"); + }); + + it("rejects non-positive watch intervals", async () => { + await expect(program.parseAsync(["node", "test", "status", "--watch", "--interval", "0"])).rejects.toThrow( + "process.exit(1)", + ); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => c[0]) + .join("\n"); + expect(errors).toContain("--interval must be a positive integer"); + }); + + it("ignores --interval entirely when --watch is not set", async () => { + mockTmux.mockResolvedValue(null); + mockSessionManager.list.mockResolvedValue([]); + + // Invalid value (0) should NOT cause an error without --watch + await expect( + program.parseAsync(["node", "test", "status", "--interval", "0"]), + ).resolves.not.toThrow(); + + // Valid value should also be silently ignored without --watch + await expect( + program.parseAsync(["node", "test", "status", "--interval", "10"]), + ).resolves.not.toThrow(); + }); + + it("schedules watch refreshes with the requested interval", async () => { + mockTmux.mockResolvedValue(null); + setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as never); + + await program.parseAsync(["node", "test", "status", "--watch", "--interval", "3"]); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 3000); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("Refreshing every 3s. Press Ctrl+C to exit."); + }); + + it("cleans up the watch timer on shutdown signals", async () => { + mockTmux.mockResolvedValue(null); + + const watchTimer = { id: "watch-timer" } as unknown as ReturnType; + setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => watchTimer); + clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => undefined); + + const signalHandlers = new Map void>(); + processOnceSpy = vi.spyOn(process, "once").mockImplementation((event, listener) => { + if (event === "SIGINT" || event === "SIGTERM") { + signalHandlers.set(event, listener as () => void); + } + return process; + }); + + await program.parseAsync(["node", "test", "status", "--watch"]); + + expect(signalHandlers.has("SIGINT")).toBe(true); + expect(signalHandlers.has("SIGTERM")).toBe(true); + + expect(() => signalHandlers.get("SIGINT")?.()).toThrow("process.exit(0)"); + expect(clearIntervalSpy).toHaveBeenCalledWith(watchTimer); + }); + it("falls back to PR number from metadata URL when SCM fails", async () => { writeFileSync( join(sessionsDir, "app-1"), @@ -857,4 +946,220 @@ describe("status command", () => { project: "my-app", }); }); + + // ── lines 262-266: loadConfig() throws → fallback to tmux discovery ─────── + it("falls back to tmux session discovery when loadConfig throws", async () => { + // The vi.mock for @composio/ao-core uses `() => mockConfigRef.current`. + // Setting current to a throwing getter makes loadConfig throw. + // Simpler: use a Proxy-based trick — but easiest is a getter that throws. + const originalCurrent = mockConfigRef.current; + Object.defineProperty(mockConfigRef, "current", { + get() { + throw new Error("no config file"); + }, + configurable: true, + }); + + // No tmux sessions — fallback should print the banner with "No config found" + mockTmux.mockImplementation(async (...args: string[]) => { + if (args[0] === "list-sessions") return null; + return null; + }); + mockIntrospect.mockResolvedValue(null); + + try { + await program.parseAsync(["node", "test", "status"]); + } finally { + // Restore mockConfigRef.current to a plain data property + Object.defineProperty(mockConfigRef, "current", { + value: originalCurrent, + writable: true, + configurable: true, + }); + } + + const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("No config found"); + expect(output).toContain("Falling back to session discovery"); + }); + + // ── lines 269-271: unknown --project flag ─────────────────────────────── + it("exits with error when --project refers to an unknown project", async () => { + mockTmux.mockResolvedValue(null); + mockSessionManager.list.mockResolvedValue([]); + + await expect( + program.parseAsync(["node", "test", "status", "--project", "no-such-project"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => c[0]) + .join("\n"); + expect(errors).toContain("Unknown project: no-such-project"); + }); + + // ── lines 388, 390-396, 402-405: tracker unverified-issues warning ──────── + it("shows unverified issues warning when tracker returns merged-unverified issues", async () => { + const mockListIssues = vi.fn().mockResolvedValue([{ id: "ISS-1" }, { id: "ISS-2" }]); + const mockTracker = { listIssues: mockListIssues }; + + mockConfigRef.current = { + ...(mockConfigRef.current as Record), + projects: { + "my-app": { + name: "My App", + repo: "org/my-app", + path: join(tmpDir, "main-repo"), + defaultBranch: "main", + sessionPrefix: "app", + scm: { plugin: "github" }, + tracker: { plugin: "linear" }, + }, + }, + } as Record; + + // Use the hoisted mockGetPluginRegistry fn to surface our tracker + mockGetPluginRegistry.mockResolvedValueOnce({ + get: vi.fn().mockReturnValue(mockTracker), + list: vi.fn(), + register: vi.fn(), + }); + + mockSessionManager.list.mockResolvedValue([]); + mockTmux.mockResolvedValue(null); + + await program.parseAsync(["node", "test", "status"]); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("awaiting verification"); + expect(mockListIssues).toHaveBeenCalledWith( + { state: "open", labels: ["merged-unverified"], limit: 20 }, + expect.objectContaining({ tracker: { plugin: "linear" } }), + ); + }); + + // ── line 398: tracker listIssues() rejects → swallowed silently ─────────── + it("handles tracker listIssues failure gracefully without crashing", async () => { + const mockListIssues = vi.fn().mockRejectedValue(new Error("tracker down")); + const mockTracker = { listIssues: mockListIssues }; + + mockConfigRef.current = { + ...(mockConfigRef.current as Record), + projects: { + "my-app": { + name: "My App", + repo: "org/my-app", + path: join(tmpDir, "main-repo"), + defaultBranch: "main", + sessionPrefix: "app", + scm: { plugin: "github" }, + tracker: { plugin: "linear" }, + }, + }, + } as Record; + + mockGetPluginRegistry.mockResolvedValueOnce({ + get: vi.fn().mockReturnValue(mockTracker), + list: vi.fn(), + register: vi.fn(), + }); + + mockSessionManager.list.mockResolvedValue([]); + mockTmux.mockResolvedValue(null); + + // Must not throw + await expect(program.parseAsync(["node", "test", "status"])).resolves.not.toThrow(); + }); + + // ── lines 65-69 (isTTY branch) + 255-256 (maybeClearScreen on refresh) ─── + it("writes clear-screen escape when stdout is a TTY during watch refresh", async () => { + mockTmux.mockResolvedValue(null); + mockSessionManager.list.mockResolvedValue([]); + + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + let capturedCallback: (() => void) | undefined; + setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => { + capturedCallback = fn as () => void; + return 77 as never; + }); + clearIntervalSpy = vi + .spyOn(globalThis, "clearInterval") + .mockImplementation(() => undefined); + processOnceSpy = vi.spyOn(process, "once").mockImplementation((_e, _l) => process); + + await program.parseAsync(["node", "test", "status", "--watch", "--interval", "5"]); + + expect(capturedCallback).toBeDefined(); + // Fire interval callback — this calls renderStatus(true) which calls maybeClearScreen() + capturedCallback!(); + // Allow promises to settle + await new Promise((r) => setTimeout(r, 20)); + + expect(writeSpy).toHaveBeenCalledWith("\x1Bc"); + + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + writeSpy.mockRestore(); + }); + + // ── lines 424-425: watch guard skips render when already in progress ────── + it("skips a watch refresh when the previous render is still in progress", async () => { + let renderCount = 0; + let unblockSlowRender!: () => void; + const slowRenderFinished = new Promise((res) => { + unblockSlowRender = res; + }); + + mockSessionManager.list.mockImplementation(async () => { + renderCount++; + if (renderCount === 2) { + // First watch-refresh (second overall list call) — block deliberately + await slowRenderFinished; + } + return []; + }); + + mockTmux.mockResolvedValue(null); + + let capturedCallback: (() => void) | undefined; + setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => { + capturedCallback = fn as () => void; + return 55 as never; + }); + clearIntervalSpy = vi + .spyOn(globalThis, "clearInterval") + .mockImplementation(() => undefined); + processOnceSpy = vi.spyOn(process, "once").mockImplementation((_e, _l) => process); + + const originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + + await program.parseAsync(["node", "test", "status", "--watch"]); + + // First interval tick — starts a slow render + capturedCallback!(); + await new Promise((r) => setTimeout(r, 0)); + + const countAfterFirst = renderCount; + + // Second tick while first is still pending — `rendering` guard should block it + capturedCallback!(); + await new Promise((r) => setTimeout(r, 0)); + expect(renderCount).toBe(countAfterFirst); // no additional list() call + + // Unblock slow render + unblockSlowRender(); + await new Promise((r) => setTimeout(r, 20)); + + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + }); }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 6cb6254db..625e06370 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -44,6 +44,30 @@ interface SessionInfo { activity: ActivityState | null; } +interface StatusOptions { + project?: string; + json?: boolean; + watch?: boolean; + interval?: string; +} + +const DEFAULT_WATCH_INTERVAL_SECONDS = 5; + +function parseWatchIntervalSeconds(value?: string): number { + if (!value) return DEFAULT_WATCH_INTERVAL_SECONDS; + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error("--interval must be a positive integer number of seconds."); + } + return parsed; +} + +function maybeClearScreen(): void { + if (process.stdout.isTTY) { + process.stdout.write("\x1Bc"); + } +} + async function gatherSessionInfo( session: Session, agent: Agent, @@ -210,154 +234,217 @@ export function registerStatus(program: Command): void { .description("Show all sessions with branch, activity, PR, and CI status") .option("-p, --project ", "Filter by project ID") .option("--json", "Output as JSON") - .action(async (opts: { project?: string; json?: boolean }) => { - let config: ReturnType; - try { - config = loadConfig(); - } catch { - console.log(chalk.yellow("No config found. Run `ao init` first.")); - console.log(chalk.dim("Falling back to session discovery...\n")); - await showFallbackStatus(); - return; - } - - if (opts.project && !config.projects[opts.project]) { - console.error(chalk.red(`Unknown project: ${opts.project}`)); + .option("-w, --watch", "Refresh the status view continuously") + .option("--interval ", "Refresh interval in seconds (default: 5)") + .action(async (opts: StatusOptions) => { + if (opts.watch && opts.json) { + console.error(chalk.red("--watch cannot be used with --json.")); process.exit(1); } - // Use session manager to list sessions (metadata-based, not tmux-based) - const sm = await getSessionManager(config); - const registry = await getPluginRegistry(config); - const sessions = await sm.list(opts.project); - - if (!opts.json) { - console.log(banner("AGENT ORCHESTRATOR STATUS")); - console.log(); + let watchIntervalSeconds = DEFAULT_WATCH_INTERVAL_SECONDS; + if (opts.watch) { + try { + watchIntervalSeconds = parseWatchIntervalSeconds(opts.interval); + } catch (err) { + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } } - // Group sessions by project - const byProject = new Map(); - for (const s of sessions) { - const list = byProject.get(s.projectId) ?? []; - list.push(s); - byProject.set(s.projectId, list); - } + const renderStatus = async (refreshing = false): Promise => { + if (refreshing) { + maybeClearScreen(); + } - // Show projects that have no sessions too (if not filtered) - const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); - const jsonOutput: SessionInfo[] = []; - let totalWorkers = 0; - let totalOrchestrators = 0; + let config: ReturnType; + try { + config = loadConfig(); + } catch { + console.log(chalk.yellow("No config found. Run `ao init` first.")); + console.log(chalk.dim("Falling back to session discovery...\n")); + await showFallbackStatus(); + return; + } - for (const projectId of projectIds) { - const projectConfig = config.projects[projectId]; - if (!projectConfig) continue; + if (opts.project && !config.projects[opts.project]) { + console.error(chalk.red(`Unknown project: ${opts.project}`)); + process.exit(1); + } - const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) => - a.id.localeCompare(b.id), - ); - - // Resolve agent and SCM for this project - const agentName = projectConfig.agent ?? config.defaults.agent; - const agent = getAgentByNameFromRegistry(registry, agentName); - const scm = getSCMFromRegistry(registry, config, projectId); + // Use session manager to list sessions (metadata-based, not tmux-based) + const sm = await getSessionManager(config); + const registry = await getPluginRegistry(config); + const sessions = await sm.list(opts.project); if (!opts.json) { - console.log(header(projectConfig.name || projectId)); - } - - if (projectSessions.length === 0) { - if (!opts.json) { - console.log(chalk.dim(" (no active sessions)")); + console.log(banner("AGENT ORCHESTRATOR STATUS")); + if (opts.watch) { + console.log( + chalk.dim( + `Refreshing every ${watchIntervalSeconds}s. Press Ctrl+C to exit.`, + ), + ); + console.log(); + } else { console.log(); } - continue; } - // Gather all session info in parallel - const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config)); - const sessionInfos = await Promise.all(infoPromises); + // Group sessions by project + const byProject = new Map(); + for (const s of sessions) { + const list = byProject.get(s.projectId) ?? []; + list.push(s); + byProject.set(s.projectId, list); + } - const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator"); - const workers = sessionInfos.filter((info) => info.role === "worker"); + // Show projects that have no sessions too (if not filtered) + const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); + const jsonOutput: SessionInfo[] = []; + let totalWorkers = 0; + let totalOrchestrators = 0; - totalWorkers += workers.length; - totalOrchestrators += orchestrators.length; + for (const projectId of projectIds) { + const projectConfig = config.projects[projectId]; + if (!projectConfig) continue; - for (const info of sessionInfos) { - if (opts.json) { - jsonOutput.push(info); + const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) => + a.id.localeCompare(b.id), + ); + + // Resolve agent and SCM for this project via the shared registry + const agentName = projectConfig.agent ?? config.defaults.agent; + const agent = getAgentByNameFromRegistry(registry, agentName); + const scm = getSCMFromRegistry(registry, config, projectId); + + if (!opts.json) { + console.log(header(projectConfig.name || projectId)); } - } - if (opts.json) { - continue; - } - - if (orchestrators.length > 0) { - for (const info of orchestrators) { - printOrchestratorRow(info); + if (projectSessions.length === 0) { + if (!opts.json) { + console.log(chalk.dim(" (no active sessions)")); + console.log(); + } + continue; } - } - if (workers.length === 0) { - console.log(chalk.dim(" (no active sessions)")); - console.log(); - continue; - } + // Gather all session info in parallel + const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config)); + const sessionInfos = await Promise.all(infoPromises); - printTableHeader(); - for (const info of workers) { - printSessionRow(info); - } - console.log(); - } + const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator"); + const workers = sessionInfos.filter((info) => info.role === "worker"); - if (opts.json) { - console.log(JSON.stringify(jsonOutput, null, 2)); - } else { - console.log( - chalk.dim( - ` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` + - (totalOrchestrators > 0 - ? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}` - : ""), - ), - ); + totalWorkers += workers.length; + totalOrchestrators += orchestrators.length; - // Check for issues awaiting verification across all projects - try { - let unverifiedTotal = 0; - for (const projectId of projectIds) { - const project: ProjectConfig | undefined = config.projects[projectId]; - if (!project?.tracker) continue; - const tracker = registry.get("tracker", project.tracker.plugin); - if (!tracker?.listIssues) continue; - try { - const issues = await tracker.listIssues( - { state: "open", labels: ["merged-unverified"], limit: 20 }, - project, - ); - unverifiedTotal += issues.length; - } catch { - // Tracker query failed — not critical + for (const info of sessionInfos) { + if (opts.json) { + jsonOutput.push(info); } } - if (unverifiedTotal > 0) { - console.log( - chalk.yellow( - ` ⚠ ${unverifiedTotal} issue${unverifiedTotal !== 1 ? "s" : ""} awaiting verification (use \`ao verify --list\` to see them)`, - ), - ); + if (opts.json) { + continue; } - } catch { - // Plugin registry or tracker unavailable — skip silently + + if (orchestrators.length > 0) { + for (const info of orchestrators) { + printOrchestratorRow(info); + } + } + + if (workers.length === 0) { + console.log(chalk.dim(" (no active sessions)")); + console.log(); + continue; + } + + printTableHeader(); + for (const info of workers) { + printSessionRow(info); + } + console.log(); } - console.log(); + if (opts.json) { + console.log(JSON.stringify(jsonOutput, null, 2)); + } else { + console.log( + chalk.dim( + ` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` + + (totalOrchestrators > 0 + ? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}` + : ""), + ), + ); + + // Check for issues awaiting verification across all projects + try { + let unverifiedTotal = 0; + for (const projectId of projectIds) { + const project: ProjectConfig | undefined = config.projects[projectId]; + if (!project?.tracker) continue; + const tracker = registry.get("tracker", project.tracker.plugin); + if (!tracker?.listIssues) continue; + try { + const issues = await tracker.listIssues( + { state: "open", labels: ["merged-unverified"], limit: 20 }, + project, + ); + unverifiedTotal += issues.length; + } catch { + // Tracker query failed — not critical + } + } + + if (unverifiedTotal > 0) { + console.log( + chalk.yellow( + ` ⚠ ${unverifiedTotal} issue${unverifiedTotal !== 1 ? "s" : ""} awaiting verification (use \`ao verify --list\` to see them)`, + ), + ); + } + } catch { + // Plugin registry or tracker unavailable — skip silently + } + + console.log(); + } + }; + + await renderStatus(); + + if (!opts.watch) { + return; } + + let rendering = false; + const watchTimer = setInterval(() => { + if (rendering) return; + rendering = true; + void renderStatus(true) + .catch((err) => { + console.error( + chalk.red( + `Watch refresh failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + }) + .finally(() => { + rendering = false; + }); + }, watchIntervalSeconds * 1000); + + const shutdown = (): void => { + clearInterval(watchTimer); + process.exit(0); + }; + + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); }); }