From 4420ab1773d0f5a090a1f7f7a5b1d9d7ae511f7a Mon Sep 17 00:00:00 2001 From: ChiragArora31 Date: Tue, 31 Mar 2026 16:57:20 +0530 Subject: [PATCH] feat(cli): add plugin resolution checks to ao doctor --- docs/CLI.md | 2 +- .../cli/__tests__/commands/doctor.test.ts | 207 +++++++++++++++++- packages/cli/src/commands/doctor.ts | 194 ++++++++++++++-- 3 files changed, 381 insertions(+), 22 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index c673b9558..52001d0e6 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -36,6 +36,6 @@ ao update # Update local AO install (source install ao config-help # Show full config schema reference ``` -`ao doctor` checks PATH and launcher resolution, required binaries, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity. +`ao doctor` checks PATH and launcher resolution, required binaries, configured plugin resolution, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity. `ao update` fast-forwards the local install on `main`, reinstalls dependencies, clean-rebuilds core packages, refreshes the launcher, and runs smoke tests. Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks. diff --git a/packages/cli/__tests__/commands/doctor.test.ts b/packages/cli/__tests__/commands/doctor.test.ts index b55dc2e6f..244c6aafd 100644 --- a/packages/cli/__tests__/commands/doctor.test.ts +++ b/packages/cli/__tests__/commands/doctor.test.ts @@ -1,9 +1,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Command } from "commander"; -const { mockRunRepoScript, mockFindConfigFile } = vi.hoisted(() => ({ +const { + mockRunRepoScript, + mockFindConfigFile, + mockLoadConfig, + mockCreatePluginRegistry, + mockDetectOpenClawInstallation, + mockValidateToken, + mockRegistry, +} = vi.hoisted(() => ({ mockRunRepoScript: vi.fn(), mockFindConfigFile: vi.fn(), + mockLoadConfig: vi.fn(), + mockCreatePluginRegistry: vi.fn(), + mockDetectOpenClawInstallation: vi.fn(), + mockValidateToken: vi.fn(), + mockRegistry: { + loadFromConfig: vi.fn(), + list: vi.fn(), + get: vi.fn(), + }, })); vi.mock("../../src/lib/script-runner.js", () => ({ @@ -11,29 +28,107 @@ vi.mock("../../src/lib/script-runner.js", () => ({ })); vi.mock("@composio/ao-core", () => ({ + createPluginRegistry: (...args: unknown[]) => mockCreatePluginRegistry(...args), findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), getObservabilityBaseDir: () => "/tmp/.agent-orchestrator/observability", - loadConfig: vi.fn(), + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), })); vi.mock("../../src/lib/openclaw-probe.js", () => ({ - detectOpenClawInstallation: vi.fn(), - validateToken: vi.fn(), + detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args), + validateToken: (...args: unknown[]) => mockValidateToken(...args), })); import { registerDoctor } from "../../src/commands/doctor.js"; +function manifest(slot: string, name: string) { + return { slot, name, description: `${name} plugin`, version: "1.0.0" }; +} + +function makeConfig() { + return { + configPath: "/tmp/agent-orchestrator.yaml", + port: 3000, + readyThresholdMs: 300_000, + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: ["alerts"], + orchestrator: { agent: "codex" }, + worker: { agent: "claude-code" }, + }, + projects: { + "my-app": { + name: "My App", + repo: "org/my-app", + path: "/tmp/my-app", + defaultBranch: "main", + sessionPrefix: "app", + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + tracker: { plugin: "github" }, + scm: { plugin: "github" }, + orchestrator: { agent: "codex" }, + worker: { agent: "claude-code" }, + }, + }, + notifiers: { + alerts: { plugin: "slack" }, + }, + notificationRouting: { + urgent: ["alerts"], + action: ["alerts"], + warning: ["alerts"], + info: ["alerts"], + }, + reactions: {}, + }; +} + describe("doctor command", () => { let program: Command; + let consoleLogSpy: ReturnType; + let processExitSpy: ReturnType; beforeEach(() => { program = new Command(); program.exitOverride(); registerDoctor(program); + + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + mockRunRepoScript.mockReset(); mockRunRepoScript.mockResolvedValue(0); + mockFindConfigFile.mockReset(); mockFindConfigFile.mockReturnValue(null); + + mockLoadConfig.mockReset(); + + mockCreatePluginRegistry.mockReset(); + mockCreatePluginRegistry.mockReturnValue(mockRegistry); + + mockRegistry.loadFromConfig.mockReset(); + mockRegistry.loadFromConfig.mockResolvedValue(undefined); + mockRegistry.list.mockReset(); + mockRegistry.list.mockReturnValue([]); + mockRegistry.get.mockReset(); + mockRegistry.get.mockReturnValue(null); + + mockDetectOpenClawInstallation.mockReset(); + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "running", + gatewayUrl: "http://127.0.0.1:18789", + probe: { httpStatus: 200 }, + }); + mockValidateToken.mockReset(); + mockValidateToken.mockResolvedValue({ valid: true }); }); afterEach(() => { @@ -51,4 +146,108 @@ describe("doctor command", () => { expect(mockRunRepoScript).toHaveBeenCalledWith("ao-doctor.sh", ["--fix"]); }); + + it("checks configured plugin references when config is present", async () => { + const config = makeConfig(); + mockFindConfigFile.mockReturnValue(config.configPath); + mockLoadConfig.mockReturnValue(config); + + mockRegistry.list.mockImplementation((slot: string) => { + switch (slot) { + case "runtime": + return [manifest("runtime", "tmux")]; + case "agent": + return [manifest("agent", "claude-code"), manifest("agent", "codex")]; + case "workspace": + return [manifest("workspace", "worktree")]; + case "tracker": + return [manifest("tracker", "github")]; + case "scm": + return [manifest("scm", "github")]; + case "notifier": + return [manifest("notifier", "slack")]; + default: + return []; + } + }); + + await program.parseAsync(["node", "test", "doctor"]); + + expect(mockCreatePluginRegistry).toHaveBeenCalledTimes(1); + expect(mockRegistry.loadFromConfig).toHaveBeenCalledWith(config, expect.any(Function)); + + const output = consoleLogSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain('defaults.runtime -> runtime plugin "tmux"'); + expect(output).toContain('projects.my-app.scm.plugin -> scm plugin "github"'); + expect(output).toContain('defaults.notifiers: alerts (plugin: slack) -> notifier plugin "slack"'); + }); + + it("fails when a referenced plugin cannot be loaded", async () => { + const config = makeConfig(); + config.projects["my-app"].scm = { plugin: "gitlab" }; + mockFindConfigFile.mockReturnValue(config.configPath); + mockLoadConfig.mockReturnValue(config); + + mockRegistry.list.mockImplementation((slot: string) => { + switch (slot) { + case "runtime": + return [manifest("runtime", "tmux")]; + case "agent": + return [manifest("agent", "claude-code"), manifest("agent", "codex")]; + case "workspace": + return [manifest("workspace", "worktree")]; + case "tracker": + return [manifest("tracker", "github")]; + case "scm": + return [manifest("scm", "github")]; + case "notifier": + return [manifest("notifier", "slack")]; + default: + return []; + } + }); + + await expect(program.parseAsync(["node", "test", "doctor"])).rejects.toThrow("process.exit(1)"); + + const output = consoleLogSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain('projects.my-app.scm.plugin references scm plugin "gitlab"'); + }); + + it("resolves notifier aliases when sending test notifications", async () => { + const config = makeConfig(); + const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) }; + mockFindConfigFile.mockReturnValue(config.configPath); + mockLoadConfig.mockReturnValue(config); + + mockRegistry.list.mockImplementation((slot: string) => { + switch (slot) { + case "runtime": + return [manifest("runtime", "tmux")]; + case "agent": + return [manifest("agent", "claude-code"), manifest("agent", "codex")]; + case "workspace": + return [manifest("workspace", "worktree")]; + case "tracker": + return [manifest("tracker", "github")]; + case "scm": + return [manifest("scm", "github")]; + case "notifier": + return [manifest("notifier", "slack")]; + default: + return []; + } + }); + mockRegistry.get.mockImplementation((slot: string, name: string) => { + if (slot === "notifier" && name === "slack") { + return mockNotifier; + } + return null; + }); + + await program.parseAsync(["node", "test", "doctor", "--test-notify"]); + + expect(mockRegistry.get).toHaveBeenCalledWith("notifier", "slack"); + expect(mockNotifier.notify).toHaveBeenCalledTimes(1); + expect(processExitSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 4c13bb48b..d0e7fcc07 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -3,11 +3,14 @@ import { join } from "node:path"; import type { Command } from "commander"; import chalk from "chalk"; import { + createPluginRegistry, findConfigFile, getObservabilityBaseDir, loadConfig, type Notifier, type OrchestratorConfig, + type PluginRegistry, + type PluginSlot, } from "@composio/ao-core"; import { runRepoScript } from "../lib/script-runner.js"; import { detectOpenClawInstallation, validateToken } from "../lib/openclaw-probe.js"; @@ -39,6 +42,155 @@ function makeFailCounter(): { fail: (msg: string) => void; count: () => number } }; } +type CheckedPluginSlot = Extract< + PluginSlot, + "runtime" | "agent" | "workspace" | "tracker" | "scm" | "notifier" +>; + +interface PluginReference { + slot: CheckedPluginSlot; + pluginName: string; + source: string; +} + +interface NotifierTarget { + label: string; + pluginName: string; +} + +async function loadPluginRegistry(config: OrchestratorConfig): Promise { + const registry = createPluginRegistry(); + await registry.loadFromConfig(config, importPluginModuleFromSource); + return registry; +} + +function addPluginReference( + refs: PluginReference[], + slot: CheckedPluginSlot, + pluginName: string | undefined, + source: string, +): void { + if (!pluginName) return; + refs.push({ slot, pluginName, source }); +} + +function resolveNotifierTarget(config: OrchestratorConfig, ref: string): NotifierTarget { + const configured = config.notifiers?.[ref]; + if (configured?.plugin) { + return { label: ref, pluginName: configured.plugin }; + } + return { label: ref, pluginName: ref }; +} + +function collectPluginReferences(config: OrchestratorConfig): PluginReference[] { + const refs: PluginReference[] = []; + + addPluginReference(refs, "runtime", config.defaults.runtime, "defaults.runtime"); + addPluginReference(refs, "agent", config.defaults.agent, "defaults.agent"); + addPluginReference(refs, "workspace", config.defaults.workspace, "defaults.workspace"); + addPluginReference(refs, "agent", config.defaults.orchestrator?.agent, "defaults.orchestrator.agent"); + addPluginReference(refs, "agent", config.defaults.worker?.agent, "defaults.worker.agent"); + + for (const notifierName of config.defaults.notifiers ?? []) { + const target = resolveNotifierTarget(config, notifierName); + addPluginReference( + refs, + "notifier", + target.pluginName, + `defaults.notifiers: ${target.label} (plugin: ${target.pluginName})`, + ); + } + + for (const [priority, notifierNames] of Object.entries(config.notificationRouting ?? {})) { + for (const notifierName of notifierNames) { + const target = resolveNotifierTarget(config, notifierName); + addPluginReference( + refs, + "notifier", + target.pluginName, + `notificationRouting.${priority}: ${target.label} (plugin: ${target.pluginName})`, + ); + } + } + + for (const [name, notifierConfig] of Object.entries(config.notifiers ?? {})) { + addPluginReference( + refs, + "notifier", + notifierConfig.plugin, + `notifiers.${name} (plugin: ${notifierConfig.plugin})`, + ); + } + + for (const [projectId, project] of Object.entries(config.projects)) { + addPluginReference(refs, "runtime", project.runtime, `projects.${projectId}.runtime`); + addPluginReference(refs, "agent", project.agent, `projects.${projectId}.agent`); + addPluginReference(refs, "workspace", project.workspace, `projects.${projectId}.workspace`); + addPluginReference( + refs, + "agent", + project.orchestrator?.agent, + `projects.${projectId}.orchestrator.agent`, + ); + addPluginReference(refs, "agent", project.worker?.agent, `projects.${projectId}.worker.agent`); + addPluginReference( + refs, + "tracker", + project.tracker?.plugin, + `projects.${projectId}.tracker.plugin`, + ); + addPluginReference(refs, "scm", project.scm?.plugin, `projects.${projectId}.scm.plugin`); + } + + return refs; +} + +async function checkPluginResolution( + config: OrchestratorConfig, + fail: (msg: string) => void, +): Promise { + console.log(""); + console.log("Plugin resolution:"); + + const registry = await loadPluginRegistry(config); + const loadedBySlot = new Map>(); + const slots: CheckedPluginSlot[] = [ + "runtime", + "agent", + "workspace", + "tracker", + "scm", + "notifier", + ]; + + for (const slot of slots) { + loadedBySlot.set( + slot, + new Set(registry.list(slot).map((manifest) => manifest.name)), + ); + } + + const references = collectPluginReferences(config); + if (references.length === 0) { + warn("No plugin references found in config."); + return registry; + } + + for (const ref of references) { + const loaded = loadedBySlot.get(ref.slot); + if (loaded?.has(ref.pluginName)) { + pass(`${ref.source} -> ${ref.slot} plugin "${ref.pluginName}"`); + } else { + fail( + `${ref.source} references ${ref.slot} plugin "${ref.pluginName}", but it could not be loaded. ` + + `Fix: install the plugin or correct the config value.`, + ); + } + } + + return registry; +} + // --------------------------------------------------------------------------- // Notifier connectivity checks (Gap 2) // --------------------------------------------------------------------------- @@ -183,29 +335,35 @@ async function checkNotifierConnectivity( async function sendTestNotifications( config: OrchestratorConfig, + registry: PluginRegistry, fail: (msg: string) => void, ): Promise { - const { createPluginRegistry } = await import("@composio/ao-core"); - const registry = createPluginRegistry(); - await registry.loadFromConfig(config, importPluginModuleFromSource); - const activeNotifierNames = config.defaults?.notifiers ?? []; - const configuredNotifiers = Object.keys(config.notifiers ?? {}); + const configuredNotifiers = Object.entries(config.notifiers ?? {}); + const targets = new Map(); - // Combine both lists (defaults + configured) and deduplicate - const allNames = [...new Set([...activeNotifierNames, ...configuredNotifiers])]; + for (const [name, notifierConfig] of configuredNotifiers) { + targets.set(notifierConfig.plugin, { label: name, pluginName: notifierConfig.plugin }); + } - if (allNames.length === 0) { + for (const name of activeNotifierNames) { + const target = resolveNotifierTarget(config, name); + if (!targets.has(target.pluginName)) { + targets.set(target.pluginName, target); + } + } + + if (targets.size === 0) { warn("No notifiers to test. Fix: configure notifiers in your agent-orchestrator.yaml"); return; } - console.log(`\nSending test notification to ${allNames.length} notifier(s)...\n`); + console.log(`\nSending test notification to ${targets.size} notifier(s)...\n`); - for (const name of allNames) { - const notifier = registry.get("notifier", name); + for (const target of targets.values()) { + const notifier = registry.get("notifier", target.pluginName); if (!notifier) { - warn(`${name}: plugin not loaded (may not be installed)`); + warn(`${target.label}: plugin "${target.pluginName}" not loaded (may not be installed)`); continue; } @@ -222,10 +380,10 @@ async function sendTestNotifications( }; await notifier.notify(testEvent); - pass(`${name}: test notification sent`); + pass(`${target.label}: test notification sent`); } catch (err) { const message = err instanceof Error ? err.message : String(err); - fail(`${name}: ${message}`); + fail(`${target.label}: ${message}`); } } } @@ -261,18 +419,20 @@ export function registerDoctor(program: Command): void { const configPath = findConfigFile(); if (configPath) { let config: ReturnType | undefined; + let registry: PluginRegistry | undefined; try { config = loadConfig(configPath); + registry = await checkPluginResolution(config, fail); await checkNotifierConnectivity(config, fail); } catch (err) { const message = err instanceof Error ? err.message : String(err); - warn(`Notifier connectivity check failed: ${message}`); + fail(`Config-aware doctor checks failed: ${message}`); } // 3. Send test notifications if requested (separate catch for accurate errors) - if (opts.testNotify && config) { + if (opts.testNotify && config && registry) { try { - await sendTestNotifications(config, fail); + await sendTestNotifications(config, registry, fail); } catch (err) { const message = err instanceof Error ? err.message : String(err); fail(`Sending test notifications failed: ${message}`);