import { inject, injectable } from "@codemation/core"; import type { CredentialTypeDefinition, McpServerDeclaration } from "@codemation/core"; import { ApplicationTokens } from "../applicationTokens"; import type { LoggerFactory } from "../application/logging/Logger"; import type { AppConfig } from "../presentation/config/AppConfig"; import { PairedFetch } from "../pairing/PairedFetch"; import { PairingConfigToken } from "../pairing/PairingConfigToken"; import type { PairingConfig } from "../pairing/pairing.types"; interface CatalogFetcherConfig { readonly pollIntervalMs: number; readonly staleFailuresThreshold: number; readonly staleHoursThreshold: number; } type EndpointState = { consecutiveFailures: number; lastSuccessAt: Date | null; }; @injectable() export class ControlPlaneCatalogFetcher { private readonly config: CatalogFetcherConfig; private timerHandle: ReturnType | null = null; private stopped = false; private inFlight: Promise | null = null; private _mcpServers: readonly McpServerDeclaration[] | null = null; private _credentialTypeOverrides: readonly CredentialTypeDefinition[] | null = null; private readonly mcpServersState: EndpointState = { consecutiveFailures: 0, lastSuccessAt: null }; private readonly credentialTypesState: EndpointState = { consecutiveFailures: 0, lastSuccessAt: null }; onRefresh: (() => void) | null = null; constructor( @inject(PairedFetch) private readonly pairedFetch: PairedFetch, @inject(PairingConfigToken, { isOptional: true }) private readonly pairingConfig: PairingConfig | null, @inject(ApplicationTokens.LoggerFactory) private readonly loggers: LoggerFactory, @inject(ApplicationTokens.AppConfig) appConfig: AppConfig, ) { const env = appConfig.env; const pollSec = Number(env["CODEMATION_CATALOG_POLL_INTERVAL_SECONDS"] ?? 300); const staleFailures = Number(env["CODEMATION_CATALOG_STALE_FAILURES"] ?? 5); const staleHours = Number(env["CODEMATION_CATALOG_STALE_HOURS"] ?? 24); this.config = { pollIntervalMs: Number.isFinite(pollSec) ? pollSec * 1_000 : 300_000, staleFailuresThreshold: Number.isFinite(staleFailures) ? staleFailures : 5, staleHoursThreshold: Number.isFinite(staleHours) ? staleHours : 24, }; } get mcpServers(): readonly McpServerDeclaration[] | null { return this._mcpServers; } get credentialTypeOverrides(): readonly CredentialTypeDefinition[] | null { return this._credentialTypeOverrides; } async start(): Promise { if (!this.pairingConfig) { return; } try { await this.refresh(); } catch {} this.scheduleNext(); } async stop(): Promise { this.stopped = true; if (this.timerHandle !== null) { clearTimeout(this.timerHandle); this.timerHandle = null; } if (this.inFlight) { await this.inFlight; } } async refresh(): Promise { const run = this.fetchAll(); this.inFlight = run; try { await run; } finally { if (this.inFlight === run) { this.inFlight = null; } } } private scheduleNext(): void { if (this.stopped || this.config.pollIntervalMs <= 0) { return; } this.timerHandle = setTimeout(() => { if (this.stopped) { return; } void this.refresh().finally(() => this.scheduleNext()); }, this.config.pollIntervalMs); } private async fetchAll(): Promise { if (!this.pairingConfig) { return; } const logger = this.loggers.create("ControlPlaneCatalogFetcher"); const base = this.pairingConfig.controlPlaneUrl; const [mcpResult, credTypesResult] = await Promise.allSettled([ this.pairedFetch.get(`${base}/internal/catalog/mcp-servers`), this.pairedFetch.get(`${base}/internal/catalog/credential-types`), ]); await this.handleEndpointResult( mcpResult, this.mcpServersState, "mcp-servers", (data) => { this._mcpServers = data as McpServerDeclaration[]; }, logger, ); await this.handleEndpointResult( credTypesResult, this.credentialTypesState, "credential-types", (data) => { this._credentialTypeOverrides = data as CredentialTypeDefinition[]; }, logger, ); this.onRefresh?.(); } private async handleEndpointResult( result: PromiseSettledResult, state: EndpointState, endpointName: string, onSuccess: (data: unknown[]) => void, logger: ReturnType, ): Promise { if (result.status === "fulfilled") { const res = result.value; if (res.ok) { try { const data = (await res.json()) as unknown[]; onSuccess(data); state.consecutiveFailures = 0; state.lastSuccessAt = new Date(); logger.info(`ControlPlaneCatalogFetcher: fetched ${endpointName} (count=${data.length})`); return; } catch (err) { this.logEndpointFailure(state, endpointName, err, logger); return; } } this.logEndpointFailure(state, endpointName, new Error(`HTTP ${res.status} ${res.statusText}`), logger); } else { this.logEndpointFailure(state, endpointName, result.reason, logger); } } private logEndpointFailure( state: EndpointState, endpointName: string, err: unknown, logger: ReturnType, ): void { state.consecutiveFailures++; const staleHours = state.lastSuccessAt ? (Date.now() - state.lastSuccessAt.getTime()) / 3_600_000 : null; const errMsg = err instanceof Error ? err.message : String(err); const isStale = state.consecutiveFailures >= this.config.staleFailuresThreshold || (staleHours !== null && staleHours >= this.config.staleHoursThreshold); if (isStale) { const staleLabel = staleHours !== null ? `${staleHours.toFixed(1)}h` : "never-succeeded"; logger.error( `ControlPlaneCatalogFetcher: ${endpointName} is stale — control plane unreachable (failures=${state.consecutiveFailures}, stale=${staleLabel}): ${errMsg}`, err instanceof Error ? err : undefined, ); } else { logger.warn( `ControlPlaneCatalogFetcher: ${endpointName} fetch failed, retaining prior cached value (failures=${state.consecutiveFailures}): ${errMsg}`, err instanceof Error ? err : undefined, ); } } }