Merge pull request #836 from ChiragArora31/feat/status-watch-mode

feat(cli): add watch mode to ao status
This commit is contained in:
Dhruv Sharma 2026-04-01 12:28:32 +05:30 committed by GitHub
commit 2febded969
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 511 additions and 118 deletions

View File

@ -10,6 +10,7 @@ ao start <url> # 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
```

View File

@ -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<Session> & { id: string; projectId: stri
vi.mock("../../src/lib/create-session-manager.js", () => ({
getSessionManager: async (): Promise<SessionManager> => 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<typeof vi.spyOn>;
let setIntervalSpy: ReturnType<typeof vi.spyOn> | undefined;
let clearIntervalSpy: ReturnType<typeof vi.spyOn> | undefined;
let processOnceSpy: ReturnType<typeof vi.spyOn> | 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<typeof setInterval>;
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => watchTimer);
clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => undefined);
const signalHandlers = new Map<string, () => 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<string, unknown>),
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<string, unknown>;
// 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<string, unknown>),
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<string, unknown>;
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<void>((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,
});
});
});

View File

@ -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 <id>", "Filter by project ID")
.option("--json", "Output as JSON")
.action(async (opts: { project?: string; json?: boolean }) => {
let config: ReturnType<typeof loadConfig>;
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 <seconds>", "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<string, Session[]>();
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<void> => {
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<typeof loadConfig>;
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<string, Session[]>();
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>("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>("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);
});
}