Merge main into feat/openclaw-dx - resolve conflicts
Conflicts resolved in 3 files: - plugin-marketplace.ts: take main's createRequire approach, keep isAbsolute fix - plugin-scaffold.ts: take main's newline escaping (superset of backslash fix) - plugin.test.ts: take main's importOriginal pattern Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
124f7d30ee
|
|
@ -25,7 +25,7 @@ const {
|
|||
}));
|
||||
|
||||
vi.mock("@composio/ao-core", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && cp -r src/assets dist/",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { loadConfig } from "@composio/ao-core";
|
||||
|
|
@ -58,11 +60,21 @@ export function registerDashboard(program: Command): void {
|
|||
config.directTerminalPort,
|
||||
);
|
||||
|
||||
const child = spawn("npx", ["next", "dev", "-p", String(port)], {
|
||||
cwd: webDir,
|
||||
stdio: ["inherit", "inherit", "pipe"],
|
||||
env,
|
||||
});
|
||||
// In dev mode (monorepo), use `pnpm run dev` which starts Next.js AND
|
||||
// the terminal WebSocket servers via concurrently. Without the WS servers,
|
||||
// the live terminal in the dashboard won't work.
|
||||
const isDevMode = existsSync(resolve(webDir, "server"));
|
||||
const child = isDevMode
|
||||
? spawn("pnpm", ["run", "dev"], {
|
||||
cwd: webDir,
|
||||
stdio: ["inherit", "inherit", "pipe"],
|
||||
env,
|
||||
})
|
||||
: spawn("npx", ["next", "dev", "-p", String(port)], {
|
||||
cwd: webDir,
|
||||
stdio: ["inherit", "inherit", "pipe"],
|
||||
env,
|
||||
});
|
||||
|
||||
const stderrChunks: string[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import type { InstalledPluginConfig, PluginModule, PluginSlot } from "@composio/ao-core";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveLocalPluginEntrypoint, type InstalledPluginConfig, type PluginSlot } from "@composio/ao-core";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// Resolve registry from package root (works from both src/lib/ and dist/lib/)
|
||||
const registryPath = resolve(__dirname, "../../src/assets/plugin-registry.json");
|
||||
const registryData = JSON.parse(readFileSync(registryPath, "utf-8")) as unknown[];
|
||||
const registryData = createRequire(import.meta.url)("../assets/plugin-registry.json") as unknown[];
|
||||
|
||||
export interface MarketplacePluginEntry {
|
||||
id: string;
|
||||
|
|
@ -21,12 +18,11 @@ export interface MarketplacePluginEntry {
|
|||
}
|
||||
|
||||
export const BUNDLED_MARKETPLACE_PLUGIN_CATALOG = registryData as MarketplacePluginEntry[];
|
||||
export const MARKETPLACE_PLUGIN_CATALOG = BUNDLED_MARKETPLACE_PLUGIN_CATALOG;
|
||||
export const DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL =
|
||||
"https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/packages/cli/src/assets/plugin-registry.json";
|
||||
|
||||
const LOCAL_PLUGIN_ENTRY_CANDIDATES = ["dist/index.js", "index.js"] as const;
|
||||
const MARKETPLACE_CACHE_FILE = "plugin-registry.json";
|
||||
const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
function isPluginSlot(value: unknown): value is PluginSlot {
|
||||
return (
|
||||
|
|
@ -117,7 +113,7 @@ export function loadMarketplaceCatalog(): MarketplacePluginEntry[] {
|
|||
export async function refreshMarketplaceCatalog(
|
||||
url = process.env["AO_PLUGIN_REGISTRY_URL"] ?? DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL,
|
||||
): Promise<MarketplacePluginEntry[]> {
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch marketplace registry from ${url} (HTTP ${response.status}).`);
|
||||
}
|
||||
|
|
@ -129,78 +125,7 @@ export async function refreshMarketplaceCatalog(
|
|||
return mergedEntries;
|
||||
}
|
||||
|
||||
function isPluginModule(value: unknown): value is PluginModule {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<PluginModule>;
|
||||
return Boolean(candidate.manifest && typeof candidate.create === "function");
|
||||
}
|
||||
|
||||
export function normalizeImportedPluginModule(value: unknown): PluginModule | null {
|
||||
if (isPluginModule(value)) return value;
|
||||
if (value && typeof value === "object" && "default" in value) {
|
||||
const defaultExport = (value as { default?: unknown }).default;
|
||||
if (isPluginModule(defaultExport)) return defaultExport;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePackageExportsEntry(exportsField: unknown): string | null {
|
||||
if (typeof exportsField === "string") return exportsField;
|
||||
if (!exportsField || typeof exportsField !== "object") return null;
|
||||
|
||||
const exportsRecord = exportsField as Record<string, unknown>;
|
||||
const dotEntry = exportsRecord["."];
|
||||
if (typeof dotEntry === "string") return dotEntry;
|
||||
if (dotEntry && typeof dotEntry === "object") {
|
||||
const importEntry = (dotEntry as Record<string, unknown>)["import"];
|
||||
if (typeof importEntry === "string") return importEntry;
|
||||
const defaultEntry = (dotEntry as Record<string, unknown>)["default"];
|
||||
if (typeof defaultEntry === "string") return defaultEntry;
|
||||
}
|
||||
|
||||
const importEntry = exportsRecord["import"];
|
||||
if (typeof importEntry === "string") return importEntry;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLocalPluginEntrypoint(pluginPath: string): string | null {
|
||||
if (!existsSync(pluginPath)) return null;
|
||||
|
||||
const stat = statSync(pluginPath);
|
||||
if (stat.isFile()) return pluginPath;
|
||||
if (!stat.isDirectory()) return null;
|
||||
|
||||
const packageJsonPath = join(pluginPath, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const raw = readFileSync(packageJsonPath, "utf-8");
|
||||
const packageJson = JSON.parse(raw) as { exports?: unknown; main?: unknown; module?: unknown };
|
||||
const exportsEntry = resolvePackageExportsEntry(packageJson.exports);
|
||||
if (exportsEntry) {
|
||||
const resolvedExportsEntry = resolve(pluginPath, exportsEntry);
|
||||
if (existsSync(resolvedExportsEntry)) return resolvedExportsEntry;
|
||||
}
|
||||
if (typeof packageJson.module === "string") {
|
||||
const moduleEntry = resolve(pluginPath, packageJson.module);
|
||||
if (existsSync(moduleEntry)) return moduleEntry;
|
||||
}
|
||||
if (typeof packageJson.main === "string") {
|
||||
const mainEntry = resolve(pluginPath, packageJson.main);
|
||||
if (existsSync(mainEntry)) return mainEntry;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to common entrypoints below.
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of LOCAL_PLUGIN_ENTRY_CANDIDATES) {
|
||||
const entry = join(pluginPath, candidate);
|
||||
if (existsSync(entry)) return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
export { normalizeImportedPluginModule } from "@composio/ao-core";
|
||||
|
||||
export function isLocalPluginReference(reference: string): boolean {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -119,8 +119,8 @@ function buildTsConfig(): string {
|
|||
|
||||
function buildIndexTs(input: PluginScaffoldInput): string {
|
||||
const manifestName = normalizePluginName(input.displayName);
|
||||
const displayName = input.displayName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const description = input.description.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const displayName = input.displayName.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
const description = input.description.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
|
||||
return `import type { PluginModule } from "@composio/ao-core";
|
||||
|
||||
|
|
|
|||
|
|
@ -117,16 +117,6 @@ export function tryResolveInstalledPluginSpecifier(packageName: string): string
|
|||
}
|
||||
}
|
||||
|
||||
export function resolveInstalledPluginSpecifier(packageName: string): string {
|
||||
const specifier = tryResolveInstalledPluginSpecifier(packageName);
|
||||
if (!specifier) {
|
||||
throw new Error(
|
||||
`Package ${packageName} is not installed in the AO plugin store (${getPluginStoreRoot()}).`,
|
||||
);
|
||||
}
|
||||
return specifier;
|
||||
}
|
||||
|
||||
export async function importPluginModuleFromSource(specifier: string): Promise<unknown> {
|
||||
if (isPackageSpecifier(specifier)) {
|
||||
const storeSpecifier = tryResolveInstalledPluginSpecifier(specifier);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readMetadataRaw } from "../metadata.js";
|
||||
import { getSessionsDir } from "../paths.js";
|
||||
import { escalateSession, recoverSession } from "../recovery/actions.js";
|
||||
import { getSessionsDir, getProjectBaseDir } from "../paths.js";
|
||||
import { cleanupSession, escalateSession, recoverSession } from "../recovery/actions.js";
|
||||
import { runRecovery } from "../recovery/manager.js";
|
||||
import { getRecoveryLogPath, scanAllSessions } from "../recovery/scanner.js";
|
||||
import {
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
type RecoveryAssessment,
|
||||
type RecoveryContext,
|
||||
} from "../recovery/types.js";
|
||||
import type { OrchestratorConfig, PluginRegistry } from "../types.js";
|
||||
import type { OrchestratorConfig, PluginRegistry, Runtime, Workspace } from "../types.js";
|
||||
|
||||
function makeConfig(rootDir: string): OrchestratorConfig {
|
||||
return {
|
||||
|
|
@ -101,6 +101,12 @@ describe("recoverSession", () => {
|
|||
|
||||
afterEach(() => {
|
||||
if (rootDir) {
|
||||
const configPath = join(rootDir, "agent-orchestrator.yaml");
|
||||
const projectPath = join(rootDir, "project");
|
||||
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -215,6 +221,12 @@ describe("escalateSession", () => {
|
|||
|
||||
afterEach(() => {
|
||||
if (rootDir) {
|
||||
const configPath = join(rootDir, "agent-orchestrator.yaml");
|
||||
const projectPath = join(rootDir, "project");
|
||||
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -242,11 +254,135 @@ describe("escalateSession", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("cleanupSession", () => {
|
||||
let rootDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (rootDir) {
|
||||
const configPath = join(rootDir, "agent-orchestrator.yaml");
|
||||
const projectPath = join(rootDir, "project");
|
||||
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("continues cleanup and calls deleteMetadata even when workspace.destroy throws", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
mkdirSync(join(rootDir, "project"), { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const config = makeConfig(rootDir);
|
||||
const workspacePath = join(rootDir, "worktree");
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn().mockRejectedValue(new Error("Workspace destroy failed")),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const assessment = makeAssessment({
|
||||
action: "cleanup",
|
||||
classification: "dead",
|
||||
runtimeAlive: false,
|
||||
workspaceExists: true,
|
||||
workspacePath,
|
||||
rawMetadata: {
|
||||
...makeAssessment().rawMetadata,
|
||||
worktree: workspacePath,
|
||||
},
|
||||
});
|
||||
const context = makeContext(rootDir);
|
||||
|
||||
const result = await cleanupSession(assessment, config, registry, context);
|
||||
const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path);
|
||||
|
||||
expect(mockWorkspace.destroy).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(existsSync(join(sessionsDir, "app-1"))).toBe(false);
|
||||
});
|
||||
|
||||
it("continues cleanup and calls workspace.destroy and deleteMetadata even when runtime.destroy throws", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
mkdirSync(join(rootDir, "project"), { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const config = makeConfig(rootDir);
|
||||
const workspacePath = join(rootDir, "worktree");
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn().mockRejectedValue(new Error("Runtime destroy failed")),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn(),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const assessment = makeAssessment({
|
||||
action: "cleanup",
|
||||
classification: "partial",
|
||||
runtimeAlive: true,
|
||||
workspaceExists: true,
|
||||
workspacePath,
|
||||
rawMetadata: {
|
||||
...makeAssessment().rawMetadata,
|
||||
worktree: workspacePath,
|
||||
},
|
||||
});
|
||||
const context = makeContext(rootDir);
|
||||
|
||||
const result = await cleanupSession(assessment, config, registry, context);
|
||||
const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path);
|
||||
|
||||
expect(mockRuntime.destroy).toHaveBeenCalled();
|
||||
expect(mockWorkspace.destroy).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(existsSync(join(sessionsDir, "app-1"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery manager and scanner", () => {
|
||||
let rootDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (rootDir) {
|
||||
const configPath = join(rootDir, "agent-orchestrator.yaml");
|
||||
const projectPath = join(rootDir, "project");
|
||||
const projectBaseDir = getProjectBaseDir(configPath, projectPath);
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -121,4 +121,94 @@ describe("recovery validator", () => {
|
|||
expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled();
|
||||
expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets runtimeAlive to false when runtime.isAlive throws an error", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn().mockRejectedValue(new Error("Runtime check failed")),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockAgent: Agent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock-agent",
|
||||
getLaunchCommand: vi.fn(),
|
||||
getEnvironment: vi.fn(),
|
||||
detectActivity: vi.fn(),
|
||||
getActivityState: vi.fn(),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(false),
|
||||
getSessionInfo: vi.fn(),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") return mockAgent;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-1",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: projectPath,
|
||||
status: "working",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(mockRuntime.isAlive).toHaveBeenCalled();
|
||||
expect(assessment.runtimeAlive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { writeMetadata } from "../metadata.js";
|
||||
import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js";
|
||||
import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "./test-utils.js";
|
||||
|
||||
// Mock child_process module with custom promisify
|
||||
vi.mock("node:child_process", () => {
|
||||
const execFileMock = vi.fn() as any;
|
||||
// Implement custom promisify to return { stdout, stderr } objects
|
||||
execFileMock[Symbol.for("nodejs.util.promisify.custom")] = (...args: any[]) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFileMock(...args, (error: any, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
reject(Object.assign(error, { stdout, stderr }));
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
return {
|
||||
execFile: execFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
let ctx: TestContext;
|
||||
let sessionsDir: string;
|
||||
let mockRegistry: PluginRegistry;
|
||||
let config: OrchestratorConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = setupTestContext();
|
||||
({ sessionsDir, mockRegistry, config } = ctx);
|
||||
|
||||
// Create an opencode agent mock
|
||||
const opencodeAgent: Agent = {
|
||||
name: "opencode",
|
||||
processName: "opencode",
|
||||
getLaunchCommand: vi.fn().mockReturnValue("opencode start"),
|
||||
getEnvironment: vi.fn().mockReturnValue({}),
|
||||
detectActivity: vi.fn().mockReturnValue("active"),
|
||||
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
getSessionInfo: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
// Update registry to include opencode agent
|
||||
const originalGet = mockRegistry.get;
|
||||
mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => {
|
||||
if (slot === "agent" && name === "opencode") {
|
||||
return opencodeAgent;
|
||||
}
|
||||
return (originalGet as any)(slot, name);
|
||||
});
|
||||
|
||||
// Set project to use opencode agent
|
||||
config.projects["my-app"]!.agent = "opencode";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
teardownTestContext(ctx);
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("deleteSession retry loop", () => {
|
||||
it("verifies retry count - calls execFileAsync 3 times when all attempts fail", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
|
||||
// Setup: Create a session with opencode agent
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_123",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
let deleteCallCount = 0;
|
||||
const mockError = new Error("OpenCode delete failed");
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
deleteCallCount++;
|
||||
cb(mockError, "", "");
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
// Execute kill with purgeOpenCode option
|
||||
await sm.kill("app-1", { purgeOpenCode: true });
|
||||
|
||||
// Verify delete was called 3 times (one for each retry)
|
||||
expect(deleteCallCount).toBe(3);
|
||||
});
|
||||
|
||||
it("verifies retry delays - confirms delays are 0ms, 200ms, 600ms", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
vi.useFakeTimers();
|
||||
|
||||
writeMetadata(sessionsDir, "app-2", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_456",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-2")),
|
||||
});
|
||||
|
||||
const callTimes: number[] = [];
|
||||
const mockError = new Error("OpenCode delete failed");
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
callTimes.push(Date.now());
|
||||
cb(mockError, "", "");
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const killPromise = sm.kill("app-2", { purgeOpenCode: true });
|
||||
|
||||
// Run all timers to completion
|
||||
await vi.runAllTimersAsync();
|
||||
await killPromise;
|
||||
|
||||
// Verify we have 3 calls
|
||||
expect(callTimes).toHaveLength(3);
|
||||
|
||||
// Calculate delays between calls
|
||||
const delay1 = callTimes[1]! - callTimes[0]!; // Should be 200ms
|
||||
const delay2 = callTimes[2]! - callTimes[1]!; // Should be 600ms
|
||||
|
||||
expect(delay1).toBe(200);
|
||||
expect(delay2).toBe(600);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("verifies all retries are attempted when deletion fails", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
|
||||
writeMetadata(sessionsDir, "app-3", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_789",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-3")),
|
||||
});
|
||||
|
||||
const lastError = new Error("Final error after retries");
|
||||
let deleteCallCount = 0;
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
deleteCallCount++;
|
||||
const error = deleteCallCount === 3 ? lastError : new Error(`Error ${deleteCallCount}`);
|
||||
cb(error, "", "");
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
// The kill function catches and ignores deleteOpenCodeSession() failures,
|
||||
// so this test verifies that all retry attempts are made despite errors
|
||||
await sm.kill("app-3", { purgeOpenCode: true });
|
||||
|
||||
// Verify all 3 delete attempts were made
|
||||
expect(deleteCallCount).toBe(3);
|
||||
});
|
||||
|
||||
it("verifies early success exit - stops after first success without unnecessary retries", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
|
||||
writeMetadata(sessionsDir, "app-4", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_abc",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-4")),
|
||||
});
|
||||
|
||||
let deleteCallCount = 0;
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
deleteCallCount++;
|
||||
if (deleteCallCount === 1) {
|
||||
// First attempt fails
|
||||
cb(new Error("First attempt failed"), "", "");
|
||||
} else {
|
||||
// Second attempt succeeds
|
||||
cb(null, "", "");
|
||||
}
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.kill("app-4", { purgeOpenCode: true });
|
||||
|
||||
// Verify delete was called exactly 2 times (failed once, succeeded on second)
|
||||
expect(deleteCallCount).toBe(2);
|
||||
});
|
||||
|
||||
it("verifies session-not-found handling - exits gracefully without retrying", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
|
||||
writeMetadata(sessionsDir, "app-5", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses_test_def",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-5")),
|
||||
});
|
||||
|
||||
const notFoundError = new Error("Session not found: ses_test_def") as Error & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
};
|
||||
notFoundError.stderr = "Error: session not found: ses_test_def";
|
||||
|
||||
let deleteCallCount = 0;
|
||||
|
||||
vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => {
|
||||
const cb = typeof options === "function" ? options : callback;
|
||||
if (!cb) return null as any;
|
||||
|
||||
const argsArray = Array.isArray(args) ? args : [];
|
||||
if (argsArray[1] === "delete") {
|
||||
deleteCallCount++;
|
||||
cb(notFoundError, "", "");
|
||||
} else if (argsArray[1] === "list") {
|
||||
cb(null, "[]", "");
|
||||
}
|
||||
return null as any;
|
||||
}) as any);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.kill("app-5", { purgeOpenCode: true });
|
||||
|
||||
// Verify delete was called only once - no retries for "not found" errors
|
||||
expect(deleteCallCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -215,6 +215,9 @@ describe("send", () => {
|
|||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
vi.mocked(mockRuntime.isAlive).mockResolvedValue(true);
|
||||
vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after");
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.send("app-1", "hello");
|
||||
|
||||
|
|
@ -247,6 +250,9 @@ describe("send", () => {
|
|||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
vi.mocked(mockRuntime.isAlive).mockResolvedValue(true);
|
||||
vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after");
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.send("app-1", "hello");
|
||||
|
||||
|
|
@ -351,7 +357,7 @@ describe("send", () => {
|
|||
makeHandle("rt-1"),
|
||||
"do not confirm on visibility",
|
||||
);
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("remap", () => {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,13 @@ export {
|
|||
} from "./config.js";
|
||||
|
||||
// Plugin registry
|
||||
export { createPluginRegistry } from "./plugin-registry.js";
|
||||
export {
|
||||
createPluginRegistry,
|
||||
isPluginModule,
|
||||
normalizeImportedPluginModule,
|
||||
resolveLocalPluginEntrypoint,
|
||||
resolvePackageExportsEntry,
|
||||
} from "./plugin-registry.js";
|
||||
|
||||
// Metadata — flat-file session metadata read/write
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -84,13 +84,13 @@ function extractPluginConfig(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function isPluginModule(value: unknown): value is PluginModule {
|
||||
export function isPluginModule(value: unknown): value is PluginModule {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<PluginModule>;
|
||||
return Boolean(candidate.manifest && typeof candidate.create === "function");
|
||||
}
|
||||
|
||||
function normalizeImportedPluginModule(value: unknown): PluginModule | null {
|
||||
export function normalizeImportedPluginModule(value: unknown): PluginModule | null {
|
||||
if (isPluginModule(value)) return value;
|
||||
|
||||
if (value && typeof value === "object" && "default" in value) {
|
||||
|
|
@ -107,7 +107,7 @@ function resolveConfigRelativePath(targetPath: string, configPath?: string): str
|
|||
return resolve(baseDir, targetPath);
|
||||
}
|
||||
|
||||
function resolvePackageExportsEntry(exportsField: unknown): string | null {
|
||||
export function resolvePackageExportsEntry(exportsField: unknown): string | null {
|
||||
if (typeof exportsField === "string") return exportsField;
|
||||
if (!exportsField || typeof exportsField !== "object") return null;
|
||||
|
||||
|
|
@ -131,18 +131,19 @@ function resolvePackageExportsEntry(exportsField: unknown): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function resolveLocalPluginEntrypoint(pluginPath: string): string | null {
|
||||
export function resolveLocalPluginEntrypoint(pluginPath: string): string | null {
|
||||
if (!existsSync(pluginPath)) return null;
|
||||
|
||||
const stat = statSync(pluginPath);
|
||||
if (stat.isFile()) {
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(pluginPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stat.isFile()) return pluginPath;
|
||||
if (!stat.isDirectory()) return null;
|
||||
|
||||
const packageJsonPath = join(pluginPath, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
|
|
@ -183,6 +184,7 @@ function resolveLocalPluginEntrypoint(pluginPath: string): string | null {
|
|||
|
||||
function inferPackageSpecifier(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
if (value.startsWith(".") || value.startsWith("/")) return null;
|
||||
return value.startsWith("@") || value.includes("/") ? value : null;
|
||||
}
|
||||
|
||||
|
|
@ -190,13 +192,19 @@ function resolvePluginSpecifier(
|
|||
plugin: InstalledPluginConfig,
|
||||
config: OrchestratorConfig,
|
||||
): string | null {
|
||||
if (plugin.path) {
|
||||
const absolutePath = resolveConfigRelativePath(plugin.path, config.configPath);
|
||||
const entrypoint = resolveLocalPluginEntrypoint(absolutePath);
|
||||
return entrypoint ? pathToFileURL(entrypoint).href : null;
|
||||
switch (plugin.source) {
|
||||
case "local": {
|
||||
if (!plugin.path) return null;
|
||||
const absolutePath = resolveConfigRelativePath(plugin.path, config.configPath);
|
||||
const entrypoint = resolveLocalPluginEntrypoint(absolutePath);
|
||||
return entrypoint ? pathToFileURL(entrypoint).href : null;
|
||||
}
|
||||
case "registry":
|
||||
case "npm":
|
||||
return plugin.package ?? inferPackageSpecifier(plugin.name);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return plugin.package ?? inferPackageSpecifier(plugin.name);
|
||||
}
|
||||
|
||||
export function createPluginRegistry(): PluginRegistry {
|
||||
|
|
@ -258,7 +266,10 @@ export function createPluginRegistry(): PluginRegistry {
|
|||
if (plugin.enabled === false) continue;
|
||||
|
||||
const specifier = resolvePluginSpecifier(plugin, config);
|
||||
if (!specifier) continue;
|
||||
if (!specifier) {
|
||||
console.warn(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = normalizeImportedPluginModule(await doImport(specifier));
|
||||
|
|
@ -266,8 +277,8 @@ export function createPluginRegistry(): PluginRegistry {
|
|||
|
||||
const pluginConfig = extractPluginConfig(mod.manifest.slot, mod.manifest.name, config);
|
||||
this.register(mod, pluginConfig);
|
||||
} catch {
|
||||
// External plugin unavailable or invalid — continue loading the rest.
|
||||
} catch (error) {
|
||||
console.warn(`[plugin-registry] Failed to load plugin "${specifier}":`, error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue