Merge pull request #844 from ChiragArora31/feat/doctor-plugin-resolution
This commit is contained in:
commit
e486f1927e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue