diff --git a/packages/meta-sort-core/src/api/UnifiedAPIServer.ts b/packages/meta-sort-core/src/api/UnifiedAPIServer.ts index c30837a..b7686bd 100644 --- a/packages/meta-sort-core/src/api/UnifiedAPIServer.ts +++ b/packages/meta-sort-core/src/api/UnifiedAPIServer.ts @@ -237,7 +237,7 @@ export class UnifiedAPIServer { this.setupStatsRoutes(); } - // Service discovery — proxies to meta-core's /api/services using the + // Service discovery — meta-discovery v1 neighbour list, served from the // LeaderClient to resolve meta-core's current URL dynamically. this.setupServiceDiscoveryRoutes(); @@ -592,33 +592,28 @@ export class UnifiedAPIServer { } /** - * Service discovery — proxies to meta-core's /api/services, resolving the - * current leader's URL via LeaderClient (no hardcoded hostname). + * Service discovery — meta-discovery v1. Served from this service's own UDP + * neighbour map; no meta-core round trip, so the nav survives a core outage. */ private setupServiceDiscoveryRoutes(): void { - this.app.get('/api/services', async (_request, reply) => { - try { - const leaderClient = this.kvManager?.getLeaderClient(); - if (!leaderClient) { - return reply.status(503).send({ error: 'KVManager not initialized' }); - } - const apiUrl = await leaderClient.getApiUrl(); - if (!apiUrl) { - return reply.status(503).send({ error: 'Leader not available yet' }); - } - const response = await fetch(`${apiUrl}/api/services`, { - signal: AbortSignal.timeout(5000), - }); - if (!response.ok) { - return reply.status(response.status).send({ - error: `Upstream ${apiUrl} returned ${response.status}`, - }); - } - const body = await response.json(); - return reply.send(body); - } catch (err) { - return reply.status(502).send({ error: getErrorMessage(err) }); + // meta-discovery v1: neighbours heard over UDP, served from this service's + // own map. Unlike /api/services below it needs no meta-core, so the nav + // still renders when the core is down. `services` is kept as an alias + // while any older dashboard build is still in circulation. + this.app.get('/api/neighbors', async (_request, reply) => { + const leaderClient = this.kvManager?.getLeaderClient(); + if (!leaderClient) { + return reply.send({ current: 'meta-sort', enabled: false, count: 0, neighbors: [] }); } + const neighbors = leaderClient.getNeighbors(); + return reply.send({ + current: 'meta-sort', + enabled: true, + count: neighbors.length, + neighbors, + services: neighbors, + self: leaderClient.self(), + }); }); } diff --git a/packages/meta-sort-core/src/kv/IKVClient.ts b/packages/meta-sort-core/src/kv/IKVClient.ts index 2fb90ee..16b5ed8 100644 --- a/packages/meta-sort-core/src/kv/IKVClient.ts +++ b/packages/meta-sort-core/src/kv/IKVClient.ts @@ -139,7 +139,7 @@ export interface KVClientConfig { } /** - * Leader lock file content format (kv-leader.info) + * meta-core's identity and URL set (from its UDP announce, or /urls) * Written by meta-core leader, read by other services */ export interface LeaderLockInfo { diff --git a/packages/meta-sort-core/src/kv/KVManager.ts b/packages/meta-sort-core/src/kv/KVManager.ts index 815584b..7248b9e 100644 --- a/packages/meta-sort-core/src/kv/KVManager.ts +++ b/packages/meta-sort-core/src/kv/KVManager.ts @@ -2,13 +2,12 @@ * KV Manager - Unified manager for KV storage and service discovery * * This is the main entry point for the KV subsystem. It: - * 1. Uses LeaderClient to read leader info from meta-core + * 1. Uses LeaderClient to locate meta-core over UDP (meta-discovery v1) * 2. Creates and manages the KV client (Redis) - * 3. Handles service discovery registration * 4. Manages reconnection on leader changes * - * Note: Leader election is now handled by meta-core (Go sidecar). - * This service only reads the leader info and connects to Redis. + * Note: there is no leader election. meta-core announces itself on the + * discovery group; this service listens and connects to its HTTP API. * * Usage: * ```typescript @@ -112,7 +111,12 @@ export class KVManager { this.leaderClient = new LeaderClient({ metaCoreUrl: this.config.metaCoreUrl, serviceName: this.config.serviceName, - baseUrl: this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`, + // Browser-facing URL for the nav menu, in the order the spec requires: + // PUBLIC_URL (reachable when there is no Caddy perimeter in front — a + // debug-direct port) -> BASE_URL (the Caddy URL) -> container host. + // Announcing a Caddy URL on a stack with no Caddy running is what puts + // dead links in every neighbour's menu. + baseUrl: process.env.PUBLIC_URL || this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`, }); // Watch for leader changes @@ -313,10 +317,6 @@ export class KVManager { /** * Get service registration instance */ - /** @deprecated File-based registration is gone; always null. */ - getServiceRegistration(): null { - return null; - } /** * Check if the KV store is healthy diff --git a/packages/meta-sort-core/src/kv/ServiceRegistration.ts b/packages/meta-sort-core/src/kv/ServiceRegistration.ts deleted file mode 100644 index 2251308..0000000 --- a/packages/meta-sort-core/src/kv/ServiceRegistration.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Service Registration using shared filesystem - * - * Each service registers itself in /meta-core/services/{service-name}-{hostname}.json - * with its base URL, status, and heartbeat timestamp. - * - * Service discovery is centralized in meta-core - this class only handles registration. - * For service discovery, use the /api/services endpoint (proxied to meta-core via nginx). - * - * Each service must call start() to register itself and begin heartbeat updates. - * meta-core runs a cleanup process that removes services with stale heartbeats. - */ - -import { promises as fs } from 'fs'; -import { hostname } from 'os'; -import type { ServiceInfo } from './IKVClient.js'; -import { isNodeError } from '../types/ExtendedInterfaces.js'; - -interface ServiceRegistrationConfig { - /** Path to META_CORE_VOLUME (e.g., /meta-core) */ - metaCorePath: string; - - /** Service name */ - serviceName: string; - - /** Service version (optional, not used in simplified format) */ - version?: string; - - /** Base URL for the service */ - apiUrl: string; - - /** Heartbeat interval in ms (default: 30000) */ - heartbeatInterval?: number; - - /** Stale threshold in ms (default: 60000) */ - staleThreshold?: number; -} - -export class ServiceRegistration { - private config: ServiceRegistrationConfig; - private servicesDir: string; - private serviceFilePath: string; - private heartbeatTimer: NodeJS.Timeout | null = null; - private isShuttingDown = false; - private currentHostname: string; - - constructor(config: ServiceRegistrationConfig) { - this.config = { - heartbeatInterval: 30000, - staleThreshold: 60000, - ...config - }; - - this.currentHostname = hostname(); - this.servicesDir = `${this.config.metaCorePath}/services`; - // Use hostname-based file naming - this.serviceFilePath = `${this.servicesDir}/${this.config.serviceName}-${this.currentHostname}.json`; - } - - /** - * Ensure services directory exists - */ - private async ensureServicesDir(): Promise { - await fs.mkdir(this.servicesDir, { recursive: true }); - } - - /** - * Build service info for this service - */ - private buildServiceInfo(status: ServiceInfo['status']): ServiceInfo { - return { - name: this.config.serviceName, - hostname: this.currentHostname, - baseUrl: this.config.apiUrl, - status, - lastHeartbeat: new Date().toISOString() - }; - } - - /** - * Register this service by writing service info to the shared filesystem. - * meta-core's cleanup process monitors these files and removes stale entries. - */ - async register(): Promise { - await this.ensureServicesDir(); - - const info = this.buildServiceInfo('running'); - await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2)); - - console.log(`[ServiceRegistration] Registered ${this.config.serviceName}-${this.currentHostname}`); - } - - /** - * Update service status - */ - async updateStatus(status: ServiceInfo['status']): Promise { - try { - const content = await fs.readFile(this.serviceFilePath, 'utf-8'); - const info = JSON.parse(content) as ServiceInfo; - - info.status = status; - info.lastHeartbeat = new Date().toISOString(); - - await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2)); - } catch (error) { - console.error('[ServiceRegistration] Failed to update status:', error); - } - } - - /** - * Send heartbeat (update lastHeartbeat timestamp) - */ - async heartbeat(): Promise { - try { - const content = await fs.readFile(this.serviceFilePath, 'utf-8'); - const info = JSON.parse(content) as ServiceInfo; - - info.lastHeartbeat = new Date().toISOString(); - - await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2)); - } catch (error) { - // If file was deleted, re-register - if (isNodeError(error) && error.code === 'ENOENT') { - await this.register(); - } else { - console.error('[ServiceRegistration] Heartbeat failed:', error); - } - } - } - - /** - * Start heartbeat loop - */ - startHeartbeat(): void { - this.heartbeatTimer = setInterval(async () => { - if (this.isShuttingDown) return; - await this.heartbeat(); - }, this.config.heartbeatInterval); - } - - /** - * Stop heartbeat loop - */ - stopHeartbeat(): void { - if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = null; - } - } - - /** - * Unregister this service (on shutdown) - */ - async unregister(): Promise { - this.isShuttingDown = true; - this.stopHeartbeat(); - - try { - // Remove the service file - await fs.unlink(this.serviceFilePath); - console.log(`[ServiceRegistration] Unregistered ${this.config.serviceName}-${this.currentHostname}`); - } catch (error) { - if (!isNodeError(error) || error.code !== 'ENOENT') { - console.error('[ServiceRegistration] Failed to unregister:', error); - } - } - } - - // ======================================================================== - // Lifecycle - // ======================================================================== - - /** - * Full startup sequence: register service and start heartbeat loop. - */ - async start(): Promise { - await this.register(); - this.startHeartbeat(); - } - - /** - * Full shutdown sequence - */ - async stop(): Promise { - await this.unregister(); - } - - // ======================================================================== - // Getters - // ======================================================================== - - getServiceFilePath(): string { - return this.serviceFilePath; - } - - getServicesDir(): string { - return this.servicesDir; - } -} diff --git a/packages/meta-sort-core/src/kv/index.ts b/packages/meta-sort-core/src/kv/index.ts index 0852123..aa14af5 100644 --- a/packages/meta-sort-core/src/kv/index.ts +++ b/packages/meta-sort-core/src/kv/index.ts @@ -5,7 +5,8 @@ * - IKVClient interface for KV storage abstraction * - RedisKVClient for Redis-based storage * - LeaderClient for reading leader info from meta-core - * - ServiceRegistration for service registration (discovery is centralized in meta-core) + * Services announce and discover over UDP (meta-discovery v1); there is no + * separate registration step, so no ServiceRegistration export. * - KVManager for unified management of all components * - MetadataUtils for flattening/reconstructing nested metadata */ @@ -13,6 +14,5 @@ export * from './IKVClient.js'; export * from './RedisClient.js'; export * from './LeaderClient.js'; -export * from './ServiceRegistration.js'; export * from './KVManager.js'; export * from './MetadataUtils.js'; diff --git a/packages/meta-sort-ui/src/components/ServiceNav.tsx b/packages/meta-sort-ui/src/components/ServiceNav.tsx index 049941d..7f7fd8a 100644 --- a/packages/meta-sort-ui/src/components/ServiceNav.tsx +++ b/packages/meta-sort-ui/src/components/ServiceNav.tsx @@ -1,5 +1,8 @@ import { useEffect } from 'react'; -import './meta-service-menu.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore -- plain ES module, no .d.ts; imported for its side effect +// of defining the custom element. +import '../meta-service-menu.js'; /** * ServiceNav — the neighbouring meta-* services, as a burger menu.