diff --git a/.aiox-core/core/registry/registry-loader.js b/.aiox-core/core/registry/registry-loader.js index 474239fd08..0b41e96cc0 100644 --- a/.aiox-core/core/registry/registry-loader.js +++ b/.aiox-core/core/registry/registry-loader.js @@ -14,6 +14,36 @@ const path = require('path'); // Cache configuration const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const REGISTRY_RELATIVE_PATH = path.join('.aiox-core', 'core', 'registry', 'service-registry.json'); +const PACKAGE_REGISTRY_PATH = path.join(__dirname, 'service-registry.json'); + +function uniquePaths(paths) { + return Array.from(new Set(paths)); +} + +function getDefaultRegistryPaths() { + return uniquePaths([ + path.join(process.cwd(), REGISTRY_RELATIVE_PATH), + PACKAGE_REGISTRY_PATH, + ]); +} + +async function readRegistryFile(paths) { + const errors = []; + + for (const registryPath of paths) { + try { + return { + content: await fs.readFile(registryPath, 'utf8'), + registryPath, + }; + } catch (error) { + errors.push(`${registryPath}: ${error.message}`); + } + } + + throw new Error(`Failed to load registry: ${errors.join('; ')}`); +} /** * Service Registry class with caching @@ -24,6 +54,7 @@ class ServiceRegistry { this.cache = null; this.cacheTimestamp = 0; this.cacheTTL = options.cacheTTL || CACHE_TTL_MS; + this.resolvedRegistryPath = null; // Indexed lookups (built on load) this._byId = new Map(); @@ -45,16 +76,15 @@ class ServiceRegistry { return this.cache; } - // Determine registry path - const registryPath = this.registryPath || - path.join(process.cwd(), '.aiox-core/core/registry/service-registry.json'); + const registryPaths = this.registryPath ? [this.registryPath] : getDefaultRegistryPaths(); const startTime = Date.now(); try { - const content = await fs.readFile(registryPath, 'utf8'); + const { content, registryPath } = await readRegistryFile(registryPaths); this.cache = JSON.parse(content); this.cacheTimestamp = now; + this.resolvedRegistryPath = registryPath; // Build indexes this._buildIndexes(); @@ -66,6 +96,9 @@ class ServiceRegistry { return this.cache; } catch (error) { + if (error.message.startsWith('Failed to load registry:')) { + throw error; + } throw new Error(`Failed to load registry: ${error.message}`); } } @@ -276,6 +309,7 @@ class ServiceRegistry { clearCache() { this.cache = null; this.cacheTimestamp = 0; + this.resolvedRegistryPath = null; this._byId.clear(); this._byCategory.clear(); this._byTag.clear(); @@ -290,6 +324,7 @@ class ServiceRegistry { return { cached: this.cache !== null, cacheAge: this.cache ? Date.now() - this.cacheTimestamp : null, + registryPath: this.resolvedRegistryPath, workerCount: this._byId.size, categoryCount: this._byCategory.size, tagCount: this._byTag.size, @@ -326,5 +361,6 @@ async function loadRegistry(registryPath = null) { module.exports = { ServiceRegistry, getRegistry, + getDefaultRegistryPaths, loadRegistry, }; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index d8fad22563..a1220558e7 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-08T05:14:31.637Z" +generated_at: "2026-05-08T05:30:43.651Z" generator: scripts/generate-install-manifest.js file_count: 1111 files: @@ -1041,9 +1041,9 @@ files: type: core size: 4786 - path: core/registry/registry-loader.js - hash: sha256:40310943d29e4ecffe8dab1f7eb7388c73d959b7176d5277b066b97ef556a85a + hash: sha256:0cf9fa2ca39f7c4ca20043f3c4d7742e40dec7e94f81b706cf9318ebee199975 type: core - size: 7988 + size: 9006 - path: core/registry/registry-schema.json hash: sha256:f7947c4c4eff858857934f2f17a8f7778f0be25a93e27dac3509f29099115573 type: core diff --git a/tests/core/registry-loader.test.js b/tests/core/registry-loader.test.js index 2b2d52c1c9..d3bceb54ea 100644 --- a/tests/core/registry-loader.test.js +++ b/tests/core/registry-loader.test.js @@ -20,7 +20,11 @@ jest.mock('fs', () => ({ })); const fs = require('fs').promises; -const { ServiceRegistry, getRegistry } = require('../../.aiox-core/core/registry/registry-loader'); +const { + ServiceRegistry, + getDefaultRegistryPaths, + getRegistry, +} = require('../../.aiox-core/core/registry/registry-loader'); // Set timeout for all tests jest.setTimeout(30000); @@ -94,6 +98,7 @@ describe('ServiceRegistry', () => { expect(reg).toBeDefined(); expect(reg.registryPath).toBeNull(); + expect(reg.resolvedRegistryPath).toBeNull(); expect(reg.cache).toBeNull(); expect(reg.cacheTimestamp).toBe(0); }); @@ -156,6 +161,41 @@ describe('ServiceRegistry', () => { await expect(registry.load()).rejects.toThrow('Failed to load registry'); }); + + it('should fall back to the package registry when cwd registry is missing', async () => { + const cwdSpy = jest + .spyOn(process, 'cwd') + .mockReturnValue(path.join(path.sep, 'tmp', 'project-without-registry')); + const defaultPaths = getDefaultRegistryPaths(); + const fallbackRegistry = new ServiceRegistry(); + + fs.readFile.mockImplementation(async (registryPath) => { + if (registryPath === defaultPaths[0]) { + throw new Error('File not found'); + } + return JSON.stringify(mockData); + }); + + try { + const data = await fallbackRegistry.load(); + + expect(data).toEqual(mockData); + expect(fs.readFile).toHaveBeenNthCalledWith(1, defaultPaths[0], 'utf8'); + expect(fs.readFile).toHaveBeenNthCalledWith(2, defaultPaths[1], 'utf8'); + expect(fallbackRegistry.resolvedRegistryPath).toBe(defaultPaths[1]); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('should not fall back when an explicit registry path is provided', async () => { + fs.readFile.mockRejectedValue(new Error('File not found')); + + await expect(registry.load()).rejects.toThrow('Failed to load registry'); + + expect(fs.readFile).toHaveBeenCalledTimes(1); + expect(fs.readFile).toHaveBeenCalledWith('/mock/path.json', 'utf8'); + }); }); describe('getById', () => { @@ -354,6 +394,7 @@ describe('ServiceRegistry', () => { expect(registry.cache).toBeNull(); expect(registry.cacheTimestamp).toBe(0); + expect(registry.resolvedRegistryPath).toBeNull(); expect(registry._byId.size).toBe(0); expect(registry._byCategory.size).toBe(0); }); diff --git a/tests/unit/cli.test.js b/tests/unit/cli.test.js index eef47bd684..bc67a95dbf 100644 --- a/tests/unit/cli.test.js +++ b/tests/unit/cli.test.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); +const os = require('os'); const { spawn } = require('child_process'); describe('CLI Entry Point', () => { @@ -99,6 +100,41 @@ describe('CLI Entry Point', () => { }); }); + it('should load packaged worker registry outside a project cwd', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aiox-cli-registry-')); + try { + const { code, output, errors } = await new Promise((resolve) => { + const child = spawn('node', [cliPath, 'workers', 'list', '--count'], { + cwd: tempDir, + env: process.env, + }); + let output = ''; + let errors = ''; + + child.stdout.on('data', (data) => { + output += data.toString(); + }); + + child.stderr.on('data', (data) => { + errors += data.toString(); + }); + + child.on('close', (code) => { + resolve({ code, output, errors }); + }); + }); + + expect({ code, errors }).toEqual({ + code: 0, + errors: expect.not.stringMatching(/\b(?:Error|ERROR)\b/), + }); + expect(output).toContain('Total:'); + expect(output).toContain('workers'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }, 15000); + it('should error on unknown command', (done) => { const child = spawn('node', [cliPath, 'unknown-command']); let errors = '';