diff --git a/packages/cli/__tests__/commands/plugin.test.ts b/packages/cli/__tests__/commands/plugin.test.ts index 0410f86a8..ec54c939d 100644 --- a/packages/cli/__tests__/commands/plugin.test.ts +++ b/packages/cli/__tests__/commands/plugin.test.ts @@ -25,7 +25,7 @@ const { })); vi.mock("@composio/ao-core", async (importOriginal) => { - const actual = (await importOriginal()) as Record; + const actual = await importOriginal(); return { ...actual, findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), diff --git a/packages/cli/package.json b/packages/cli/package.json index d45757226..b0609d3c7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index cd61ef62a..8f494c77d 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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[] = []; diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts index 0cd226ab0..82a0095f5 100644 --- a/packages/cli/src/lib/plugin-marketplace.ts +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -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 { - 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; - 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; - const dotEntry = exportsRecord["."]; - if (typeof dotEntry === "string") return dotEntry; - if (dotEntry && typeof dotEntry === "object") { - const importEntry = (dotEntry as Record)["import"]; - if (typeof importEntry === "string") return importEntry; - const defaultEntry = (dotEntry as Record)["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 ( diff --git a/packages/cli/src/lib/plugin-scaffold.ts b/packages/cli/src/lib/plugin-scaffold.ts index 2fadb1f1b..518fc4d7b 100644 --- a/packages/cli/src/lib/plugin-scaffold.ts +++ b/packages/cli/src/lib/plugin-scaffold.ts @@ -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"; diff --git a/packages/cli/src/lib/plugin-store.ts b/packages/cli/src/lib/plugin-store.ts index 6b7684cb1..0f7db20e6 100644 --- a/packages/cli/src/lib/plugin-store.ts +++ b/packages/cli/src/lib/plugin-store.ts @@ -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 { if (isPackageSpecifier(specifier)) { const storeSpecifier = tryResolveInstalledPluginSpecifier(specifier); diff --git a/packages/core/src/__tests__/recovery-actions.test.ts b/packages/core/src/__tests__/recovery-actions.test.ts index 546262dd6..25a66e2a8 100644 --- a/packages/core/src/__tests__/recovery-actions.test.ts +++ b/packages/core/src/__tests__/recovery-actions.test.ts @@ -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 }); } }); diff --git a/packages/core/src/__tests__/recovery-validator.test.ts b/packages/core/src/__tests__/recovery-validator.test.ts index fa585db6d..cab682a4f 100644 --- a/packages/core/src/__tests__/recovery-validator.test.ts +++ b/packages/core/src/__tests__/recovery-validator.test.ts @@ -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); + }); }); diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts new file mode 100644 index 000000000..ae9e1ef45 --- /dev/null +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/__tests__/session-manager/communication.test.ts b/packages/core/src/__tests__/session-manager/communication.test.ts index a9cba56fd..cd1a3f391 100644 --- a/packages/core/src/__tests__/session-manager/communication.test.ts +++ b/packages/core/src/__tests__/session-manager/communication.test.ts @@ -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", () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6b54decd8..b8f4f58b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 72e0783c5..d807d956d 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -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; 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); } } },