Merge pull request #844 from ChiragArora31/feat/doctor-plugin-resolution

This commit is contained in:
Dhruv Sharma 2026-04-01 10:35:59 +05:30 committed by GitHub
commit e486f1927e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 381 additions and 22 deletions

View File

@ -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.

View File

@ -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<typeof vi.spyOn>;
let processExitSpy: ReturnType<typeof vi.spyOn>;
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();
});
});

View File

@ -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<PluginRegistry> {
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<PluginRegistry> {
console.log("");
console.log("Plugin resolution:");
const registry = await loadPluginRegistry(config);
const loadedBySlot = new Map<CheckedPluginSlot, Set<string>>();
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<void> {
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<string, NotifierTarget>();
// 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>("notifier", name);
for (const target of targets.values()) {
const notifier = registry.get<Notifier>("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<typeof loadConfig> | 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}`);