diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/config.js b/hypaware-core/plugins-workspace/ai-gateway/src/config.js index 2c8e20b0..54ba10f0 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/config.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/config.js @@ -13,9 +13,12 @@ export const FALLBACK_LISTEN = '127.0.0.1:0' /** * Validate and normalize the ai-gateway config slice. Returns the - * compiled shape used by the source/listener. Validation is strict: - * missing or malformed `upstreams` is rejected loudly because the - * gateway has nothing useful to do without at least one upstream. + * compiled shape used by the source/listener. Missing or malformed + * `upstreams` compiles to an empty list rather than an error: adapter + * plugins contribute the rest of the routing table as presets after this + * runs, and a config that wants the gateway plugin only for its dataset and + * materializer (`@hypaware/hermes`) legitimately names no upstream at all. + * The source decides what an empty table means, not this function. * * @param {unknown} raw * @returns {AiGatewayConfig} diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/source.js b/hypaware-core/plugins-workspace/ai-gateway/src/source.js index 77bc6cf1..7610fab0 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/source.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/source.js @@ -49,18 +49,47 @@ export function createStartSource(state) { entrypoints: createEntrypointActivity(), } + // `undefined` when the compiled routing table is empty: the source idles + // instead of binding a listener that could route nothing. See + // {@link launchListener}. let proxy = await launchListener(ctx, state, liveState) + // The config `status()` reports on, not the one this source booted with. + // `reload()` hands the daemon's new context to the listener but the + // closure above keeps the boot-time `ctx` forever, so reading it would + // publish a stale `details.upstreams` after every reload. Core's + // `gateway_idle_no_upstreams` diagnostic reads exactly that field to + // tell "hermes-only, correctly idle" from "an upstream was dropped", so + // it has to describe the config in force now. + let activeCtx = ctx + return { async status() { + const configured = readConfiguredUpstreams(activeCtx) /** @type {SourceStatus} */ const status = { state: 'ready', rowsWritten: liveState.rowsWritten, details: { - host: proxy.host, - port: proxy.port, - upstreams: readConfiguredUpstreamNames(ctx), + // Omitted while idle, which is already how `gatewaySourceDetails` + // (core `daemon/status.js`) reads "no reachable gateway here" off + // the status file for a bind that never happened. + ...(proxy ? { host: proxy.host, port: proxy.port } : { listening: false }), + // Raw configured names, pre-compile, deliberately: an entry the + // compiler dropped (a `url =` where `base_url` was meant) still + // appears here, which is what lets core see the difference + // between a gateway with nothing to proxy and a gateway whose + // upstream fell out of the routing table. + upstreams: configured.names, + // The names cannot carry the whole signal, because `name` is one + // of the two keys whose absence drops an entry: an upstream + // written with a `provider` and a `base_url` but no `name` leaves + // `upstreams: []`, indistinguishable from hermes-only. The count + // is the wider question ("did this config ask for any upstream at + // all?") and it is the one core's `gateway_idle_no_upstreams` + // diagnostic gates on; the names only decide how the warning + // reads. + upstreams_configured: configured.count, registered_presets: Array.from(state.presets.keys()), projectors: state.projectors.map((p) => p.name), // @ref LLP 0066#ephemeral: surface the live opt-out count so an @@ -80,6 +109,7 @@ export function createStartSource(state) { recent_entrypoints: liveState.entrypoints.snapshot(), }, } + if (!proxy) status.message = 'idle: no upstreams configured, nothing to proxy' if (liveState.lastError) status.lastError = liveState.lastError return status }, @@ -89,13 +119,14 @@ export function createStartSource(state) { // new config. Connections in flight finish through the // recorder's drain (called inside stop()) so their rows are not // lost across the reload. - await proxy.stop() + await proxy?.stop() state.listen = undefined proxy = await launchListener(nextCtx, state, liveState) + activeCtx = nextCtx }, async stop() { - await proxy.stop() + await proxy?.stop() state.listen = undefined }, } @@ -108,13 +139,63 @@ export function createStartSource(state) { * `AiGatewayCapability.localEndpoint()` returns the bound URL; clears * it on stop/reload. * + * Returns `undefined` when the compiled routing table is empty, leaving the + * source idle with no listener at all. + * + * The gateway plugin does two separable jobs, and a config can legitimately + * want only one. At activation it contributes the `ai_gateway_messages` + * dataset and the shared `ai_gateway.projected_exchange` materializer; at + * source start it runs the proxy. `@hypaware/hermes` wants the first alone: + * it reads Hermes's own `state.db` and is "never modified, configured, or + * proxied" (LLP 0119), yet the materializer is a hard `requires.plugins` + * dependency (LLP 0120), so its picker row composes the gateway plugin while + * contributing no upstream. A hermes-only picker run therefore produces + * `upstreams: []` with no adapter presets either, and failing the source + * start there would take a correct install down over a dataset-only + * dependency. Idling instead leaves `state.listen` unset, so + * `localEndpoint()` keeps throwing rather than handing an attach a URL + * nothing is listening on. + * + * @ref LLP 0120#consequences [constrained-by]: hermes composes the gateway plugin for the materializer alone, so an upstream-less gateway is a valid config rather than a misconfiguration + * * @param {PluginActivationContext} ctx * @param {GatewayState} state * @param {{ rowsWritten: number, exchangeBytes: number, lastError: string | undefined, listenFallbackFrom: string | undefined, entrypoints: ReturnType }} liveState - * @returns {Promise} + * @returns {Promise} */ async function launchListener(ctx, state, liveState) { const config = compileConfig(ctx.config) + // Hoisted out of `bind` below (which runs twice on the EADDRINUSE fallback + // path) because the answer decides whether we bind at all. Pure over + // `config.upstreams` and `state.presets`, neither of which moves between + // the two binds. + const upstreams = mergeUpstreams(config.upstreams, state) + if (upstreams.length === 0) { + liveState.listenFallbackFrom = undefined + // Two configs reach an empty routing table and they are not the same + // event. A hermes-only install asked for no upstream at all: idle is the + // outcome it wanted, and `info` is the right volume. A config that listed + // upstreams and still compiled to none lost every one of them to + // `compileUpstreams` (a missing or misspelled `base_url` is dropped + // silently), so the operator is going to get ECONNREFUSED from a gateway + // that reports itself started. Name the entries that vanished, at `warn`. + const configured = readConfiguredUpstreams(ctx) + if (configured.count > 0) { + ctx.log.warn('aigw.idle_no_upstreams', { + [Attr.PLUGIN]: PLUGIN_NAME, + registered_presets: state.presets.size, + configured_upstreams: configured.count, + configured_upstream_names: configured.names, + reason: 'every configured upstream was dropped at compile: check base_url on each entry', + }) + } else { + ctx.log.info('aigw.idle_no_upstreams', { + [Attr.PLUGIN]: PLUGIN_NAME, + registered_presets: state.presets.size, + }) + } + return undefined + } const recorder = createRecorder({ redactHeaders: config.redactHeaders }) const projector = createAiGatewayMessageProjector({ gatewayId: config.gatewayId, @@ -188,7 +269,7 @@ async function launchListener(ctx, state, liveState) { /** @param {string} listen */ const bind = (listen) => startProxy({ listen, - upstreams: mergeUpstreams(config.upstreams, state), + upstreams, startExchange: (init) => recorder.startExchange(init), onExchangeFinished, // Serve `/_hypaware/*` control requests locally over the gateway's @@ -288,16 +369,21 @@ export function mergeUpstreams(configUpstreams, state) { } /** - * Read the names of configured upstreams from the activation config. - * Defensive: if config has been mutated to a degenerate shape, returns - * an empty list so status() never throws. + * Both halves of "what did the config ask for?": how many upstream entries it + * listed at all, and the names among them. The count is the wider signal (an + * entry with no `name` still counts), so the idle log and `hyp status` can be + * loud about a config that listed upstreams and compiled to none even when the + * names are unusable. + * + * Defensive: if config has been mutated to a degenerate shape, returns a zero + * count and an empty list so `status()` never throws. * * @param {PluginActivationContext} ctx - * @returns {string[]} + * @returns {{ count: number, names: string[] }} */ -function readConfiguredUpstreamNames(ctx) { +function readConfiguredUpstreams(ctx) { const raw = /** @type {Record} */ (ctx.config ?? {}).upstreams - if (!Array.isArray(raw)) return [] + if (!Array.isArray(raw)) return { count: 0, names: [] } /** @type {string[]} */ const out = [] for (const entry of raw) { @@ -306,7 +392,7 @@ function readConfiguredUpstreamNames(ctx) { if (typeof name === 'string' && name.length > 0) out.push(name) } } - return out + return { count: raw.length, names: out } } /** diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index c7630ee3..faa2ec26 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -112,13 +112,8 @@ const GATEWAY_PLUGIN_NAME = '@hypaware/ai-gateway' * @ref LLP 0086#endpoint-discovery [implements]: the daemon's live bound port is read from status.json sources[].details, not guessed */ export function gatewaySourceDetails(sources) { - const list = Array.isArray(sources) ? sources : [] - const source = - list.find((s) => s && s.plugin === GATEWAY_PLUGIN_NAME) ?? - list.find((s) => s && s.name === 'ai-gateway') - const rawDetails = source && typeof source.details === 'object' ? source.details : undefined - if (!rawDetails) return undefined - const details = /** @type {Record} */ (rawDetails) + const details = gatewaySourceRawDetails(sources) + if (!details) return undefined const port = details.port if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0) return undefined const host = typeof details.host === 'string' && details.host.length > 0 ? details.host : '127.0.0.1' @@ -131,6 +126,71 @@ export function gatewaySourceDetails(sources) { return { host, port, listenFallback, ...(listenFallbackFrom ? { listenFallbackFrom } : {}) } } +/** + * The gateway source's `status()` details as the daemon captured them, before + * any "is it bound?" filtering. `gatewaySourceDetails` above answers "where do + * I send traffic?" and so returns nothing for a gateway that never bound; the + * idle checks below need the details of exactly that case. + * + * @param {SourceSnapshot[] | undefined} sources + * @returns {Record | undefined} + */ +function gatewaySourceRawDetails(sources) { + const list = Array.isArray(sources) ? sources : [] + const source = + list.find((s) => s && s.plugin === GATEWAY_PLUGIN_NAME) ?? + list.find((s) => s && s.name === 'ai-gateway') + const rawDetails = source && typeof source.details === 'object' ? source.details : undefined + if (!rawDetails) return undefined + return /** @type {Record} */ (rawDetails) +} + +/** + * How many upstreams a *deliberately idle* gateway was nonetheless configured + * with, and which of them it can name, or `undefined` when the gateway is + * bound, absent, or idle for the reason it is allowed to be idle. + * + * An upstream-less gateway is a legitimate config (LLP 0120: hermes composes + * the plugin for its materializer alone and contributes no upstream), so the + * source idles instead of failing to start. That trade turns one class of + * misconfiguration silent: a config that *did* list upstreams and lost them + * all to `compileUpstreams` (an entry missing either required key is dropped + * without complaint, and `diagnoseV1Config`'s `gateway_missing_*_upstream` + * check does not fire for that shape, since it matches on `provider` too) also + * idles, reporting `started` and `healthy` while the user's client gets + * ECONNREFUSED. + * + * The *count* is what separates them, not the names. `compileUpstreams` drops + * an entry for a missing `name` exactly as silently as for a missing + * `base_url`, and a nameless entry contributes nothing to `details.upstreams`, + * so a config of `provider = "anthropic", base_url = "..."` looks identical to + * hermes-only through the names alone. `details.upstreams_configured` counts + * the entries the config listed whatever shape they were in, so hermes-only + * yields 0 and any dropped upstream yields at least 1. The names still ride + * along, pre-compile, because they make the warning concrete when they exist. + * + * A status file written before `upstreams_configured` existed carries names + * only; those still count for themselves, so an older daemon's dropped + * `base_url` stays visible. + * + * @param {SourceSnapshot[] | undefined} sources + * @returns {{ count: number, names: string[] } | undefined} + */ +function gatewayIdleWithConfiguredUpstreams(sources) { + const details = gatewaySourceRawDetails(sources) + if (!details || details.listening !== false) return undefined + const upstreams = details.upstreams + const names = Array.isArray(upstreams) + ? /** @type {string[]} */ (upstreams.filter((u) => typeof u === 'string' && u.length > 0)) + : [] + const rawCount = details.upstreams_configured + const count = + typeof rawCount === 'number' && Number.isInteger(rawCount) && rawCount >= 0 + ? rawCount + : names.length + return count > 0 ? { count, names } : undefined +} + /** * How many recent client surfaces `hyp status` will report. The gateway keeps * its own, deliberately equal, cap on the writing side; this one exists @@ -598,6 +658,42 @@ export async function collectHypAwareStatus(opts = {}) { repair: [`free ${from} and restart the daemon - attached clients re-point automatically`], }) } + const idleGatewayUpstreams = daemon.running + ? gatewayIdleWithConfiguredUpstreams(daemonStatusFile?.sources) + : undefined + if (idleGatewayUpstreams) { + // The gateway bound nothing while the config listed upstreams it wanted + // proxied: every one was dropped at compile, so there is no listener and + // no error either. Before the source was allowed to idle this was a source + // start failure and `hyp status` said `[failed]`; the same install must not + // now read `[started]` / `healthy` with the reason living only in a log + // line. Non-degrading like `gateway_port_fallback`: an install that + // *wanted* no upstream (hermes-only) reports no configured upstreams here + // and never reaches this branch, so it stays healthy and silent. + // @ref LLP 0114#fallback-is-visible [implements]: an exception to "the gateway is listening" is readable from status.json steadily, not only from a boot-time log line + const { count, names } = idleGatewayUpstreams + // Count first, names in parentheses when there are any: `name` is itself + // one of the two keys that drops an entry, so the config that most needs + // this warning is exactly the one that can supply no name to print. + const named = names.length > 0 ? ` (${names.join(', ')})` : '' + diagnostics.push({ + severity: 'warning', + kind: 'gateway_idle_no_upstreams', + message: `the gateway is running but listening on nothing: ${count} ${count === 1 ? 'upstream' : 'upstreams'}${named} ${count === 1 ? 'is' : 'are'} configured but none compiled to a route (each needs both a 'name' and a 'base_url') - clients will get connection refused`, + // Not `hyp config validate`: it prints `config ok` for this config and + // exits 0. `@hypaware/ai-gateway` registers no config section, so + // nothing validates upstream shape, and `diagnoseV1Config` matches an + // upstream by its `provider` field, so a nameless anthropic entry + // satisfies the one check that does look. A repair line that sends the + // user to a command which affirms the broken config is worse than no + // repair line, so point at the file and the two required keys instead. + // @ref LLP 0139#repair-must-be-runnable [constrained-by]: a repair has to be a step that changes something, so the inert validate command gives way to the edit that fixes it + repair: [ + `add the missing 'name' / 'base_url' to each upstream in ${configPath} ('hyp config validate' does not check upstream shape)`, + `hyp daemon restart # the daemon reads the file only at boot`, + ], + }) + } /** @type {ClientAttachReport[]} */ const clients = [] const clientDescriptors = catalog?.clientDescriptors ?? new Map() diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index 806b75da..d0da2983 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -99,6 +99,7 @@ export type StatusDiagnosticKind = | 'client_attach_stale' | 'client_attached_not_configured' | 'gateway_port_fallback' + | 'gateway_idle_no_upstreams' | 'recent_errors' | 'remote_config_rolled_back' | 'local_only_list_unreadable' diff --git a/test/core/status-gateway-idle.test.js b/test/core/status-gateway-idle.test.js new file mode 100644 index 00000000..e912f38d --- /dev/null +++ b/test/core/status-gateway-idle.test.js @@ -0,0 +1,230 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { collectHypAwareStatus, writeStatusFile } from '../../src/core/daemon/status.js' +import { writePidFile } from '../../src/core/daemon/pid.js' +import { defaultConfigPath } from '../../src/core/config/schema.js' + +/** @import { CollectStatusOptions } from '../../src/core/daemon/types.js' */ + +// The `gateway_idle_no_upstreams` diagnostic. Letting an upstream-less gateway +// idle rather than fail its start (#649, LLP 0120) is right for the config +// that wants it - hermes composes the gateway plugin for its materializer and +// contributes no upstream - but the same idle path swallows a real +// misconfiguration: upstreams that were configured and then dropped whole by +// `compileUpstreams` (an entry missing either `name` or `base_url`) leave the +// source `started`, the daemon `healthy`, and the user's client with +// ECONNREFUSED. `details.upstreams_configured` counts what the config asked +// for whatever shape it was in, so it tells the two apart even when there is +// no name left to print; `details.upstreams` carries the raw names alongside +// it, to make the warning concrete when they exist. The diagnostic is +// non-degrading: a correct hermes-only install must stay healthy and quiet. +// @ref LLP 0114#fallback-is-visible [tests]: an idle gateway that was meant to be listening is readable from hyp status, not only from a log line + +async function makeHome() { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-status-idle-')) + const stateRoot = path.join(hypHome, 'hypaware') + await fs.mkdir(path.join(stateRoot, 'run'), { recursive: true }) + await fs.writeFile(defaultConfigPath(hypHome), JSON.stringify({ version: 2, plugins: [] }) + '\n') + return { hypHome, stateRoot } +} + +/** + * Simulate a live daemon: a pid file naming this (alive) test process, and a + * status snapshot whose gateway source carries the given details. + * + * @param {string} stateRoot + * @param {Record} details + */ +function writeRunningDaemon(stateRoot, details) { + writePidFile(stateRoot, /** @type {any} */ ({ pid: process.pid, runId: 'test-run', mode: 'foreground' })) + writeStatusFile(stateRoot, /** @type {any} */ ({ + state: 'running', + sources: [{ name: 'ai-gateway', plugin: '@hypaware/ai-gateway', state: 'started', details }], + sinks: [], + })) +} + +/** + * @param {string} hypHome + * @returns {CollectStatusOptions} + */ +function collectOpts(hypHome) { + // Stub out the launch-agent probe so the machine's real daemon install + // cannot leak into the report; daemon liveness then comes from the pid + // file written above. + return { + env: { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: '' }, + platform: 'darwin', + isLaunchAgentInstalled: () => false, + } +} + +test('an idle gateway that was configured with upstreams warns', async () => { + const { hypHome, stateRoot } = await makeHome() + // The shape a `url = "..."` typo produces: the name survives into + // `details.upstreams`, the entry never survives `compileUpstreams`, so + // nothing is bound and no port is advertised. + writeRunningDaemon(stateRoot, { listening: false, upstreams: ['anthropic'], registered_presets: [] }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams') + assert.ok(diag, 'gateway_idle_no_upstreams diagnostic is emitted') + assert.equal(diag.severity, 'warning') + assert.match(diag.message, /anthropic/, 'the message names the upstream that went missing') + assert.match(diag.message, /base_url/, 'and points at the field that drops an entry') + // The repair has to be one that changes something. `hyp config validate` + // prints `config ok` for exactly this config: `@hypaware/ai-gateway` + // registers no config section, so nothing checks upstream shape, and the + // v1 diagnoser matches an anthropic upstream by its `provider` field. + assert.ok( + !diag.repair.some((r) => /^\s*hyp config validate/.test(r)), + 'no repair step tells the user to run a command that calls this config fine', + ) + assert.ok( + diag.repair.some((r) => r.includes(path.join(hypHome, 'hypaware-config.json'))), + 'it names the file to edit', + ) +}) + +// `name` drops an entry exactly as silently as `base_url` does, and an entry +// with no usable name contributes nothing to `details.upstreams`, so the names +// alone cannot see this config at all. `upstreams_configured` counts the +// entries the config listed, whatever shape they were in, which is the only +// signal that separates "one upstream asked for, none survived" from +// "hermes-only". `hyp config validate` affirms this config, because its +// `gateway_missing_*_upstream` check matches on the `provider` field the entry +// still has. +test('an idle gateway whose configured upstream has no name warns', async () => { + const { hypHome, stateRoot } = await makeHome() + // `provider = "anthropic", base_url = "..."` and no `name`: one upstream was + // configured, `compileUpstreams` dropped it, and no name reaches status. + writeRunningDaemon(stateRoot, { + listening: false, + upstreams: [], + upstreams_configured: 1, + registered_presets: [], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams') + assert.ok(diag, 'gateway_idle_no_upstreams diagnostic is emitted with no names to print') + assert.equal(diag.severity, 'warning') + assert.match(diag.message, /1 upstream/, 'the message falls back to the count it does have') + assert.match(diag.message, /name/, "and names the field that made it nameless") + assert.equal(report.overall, 'healthy', 'still non-degrading') +}) + +test('an idle gateway whose configured upstream has an empty name warns', async () => { + const { hypHome, stateRoot } = await makeHome() + // `name = ""` reaches core the same way a missing `name` does: counted, not + // named. + writeRunningDaemon(stateRoot, { + listening: false, + upstreams: [], + upstreams_configured: 2, + registered_presets: [], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams') + assert.ok(diag) + assert.match(diag.message, /2 upstreams are configured/, 'plural reads correctly') +}) + +test('an idle gateway with no configured upstreams stays quiet and healthy', async () => { + const { hypHome, stateRoot } = await makeHome() + // The hermes-only shape: the config asked for no upstream, so idling is the + // outcome it wanted and there is nothing to report. + writeRunningDaemon(stateRoot, { + listening: false, + upstreams: [], + upstreams_configured: 0, + registered_presets: [], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.equal(report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams'), undefined) + assert.equal(report.overall, 'healthy', 'a deliberately idle gateway is a working install') +}) + +// No `upstreams` key at all is the other hermes-only shape (the source omits +// nothing, but a status file written by another build might), and a `upstreams` +// that is not a list at all is a config someone mangled. Neither is evidence +// that an upstream was lost, so both stay quiet rather than guessing. +test('an idle gateway with no upstreams key at all stays quiet and healthy', async () => { + const { hypHome, stateRoot } = await makeHome() + writeRunningDaemon(stateRoot, { listening: false, registered_presets: [] }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.equal(report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams'), undefined) + assert.equal(report.overall, 'healthy') +}) + +test('a degenerate upstreams detail does not crash or warn', async () => { + const { hypHome, stateRoot } = await makeHome() + writeRunningDaemon(stateRoot, { + listening: false, + upstreams: 'anthropic', + upstreams_configured: 'lots', + registered_presets: [], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.equal(report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams'), undefined) + assert.equal(report.overall, 'healthy') +}) + +// A status file written by a build from before `upstreams_configured` existed +// still carries the names, and a dropped `base_url` is still visible in them. +test('a status file with names but no count still warns', async () => { + const { hypHome, stateRoot } = await makeHome() + writeRunningDaemon(stateRoot, { listening: false, upstreams: ['anthropic'], registered_presets: [] }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const diag = report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams') + assert.ok(diag, 'the names carry the signal on their own') + assert.match(diag.message, /anthropic/) +}) + +test('a listening gateway never warns, however many upstreams it has', async () => { + const { hypHome, stateRoot } = await makeHome() + writeRunningDaemon(stateRoot, { host: '127.0.0.1', port: 18521, upstreams: ['anthropic'] }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.equal(report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams'), undefined) +}) + +test('the idle warning does not degrade overall health', async () => { + const { hypHome, stateRoot } = await makeHome() + writeRunningDaemon(stateRoot, { listening: false, upstreams: ['anthropic'] }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.ok(report.diagnostics.some((d) => d.kind === 'gateway_idle_no_upstreams')) + // Same call as `gateway_port_fallback`: loud in the diagnostics list, but it + // does not flip `overall`, which is reserved for what makes an install + // unusable rather than misrouted. + assert.equal(report.overall, 'healthy') +}) + +test('a stopped daemon does not warn off a stale status snapshot', async () => { + const { hypHome, stateRoot } = await makeHome() + writeStatusFile(stateRoot, /** @type {any} */ ({ + state: 'stopped', + sources: [{ + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + state: 'stopped', + details: { listening: false, upstreams: ['anthropic'] }, + }], + sinks: [], + })) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.equal(report.diagnostics.find((d) => d.kind === 'gateway_idle_no_upstreams'), undefined) +}) diff --git a/test/plugins/ai-gateway-source.test.js b/test/plugins/ai-gateway-source.test.js index 5be3b838..e905d235 100644 --- a/test/plugins/ai-gateway-source.test.js +++ b/test/plugins/ai-gateway-source.test.js @@ -6,10 +6,14 @@ import test from 'node:test' import { createGatewayState } from '../../hypaware-core/plugins-workspace/ai-gateway/src/api.js' import { createStartSource } from '../../hypaware-core/plugins-workspace/ai-gateway/src/source.js' +import { composePickerConfig } from '../../src/core/cli/walkthrough.js' +import { buildPluginCatalog } from '../../src/core/plugin_catalog.js' +import { discoverBundledPlugins } from '../../src/core/runtime/bundled.js' -// startProxy requires at least one configured upstream even when a test only -// exercises the control path (never proxies through it), so the R3 tests -// below carry this unreachable-but-well-formed one. +// A source with an empty routing table binds no listener at all, so a test +// that needs a live port must give it something to route even when it only +// exercises the control path (and never proxies through it). Hence this +// unreachable-but-well-formed upstream on the R3 tests below. const ARBITRARY_UPSTREAM = { name: 'unused', base_url: 'http://127.0.0.1:1', path_prefix: '/' } test('source starts with only adapter-registered upstream presets', async () => { @@ -149,8 +153,204 @@ test('restart-drops-state: a fresh GatewayState never carries a previous run\'s } }) -/** @param {Record} config */ -function fakeCtx(config) { +// --------------------------------------------------------------------------- +// An upstream-less gateway is a valid config, not a misconfiguration. +// `@hypaware/hermes` reads Hermes's own state.db and is "never modified, +// configured, or proxied" (LLP 0119), but the shared +// `ai_gateway.projected_exchange` materializer is a hard `requires.plugins` +// dependency (LLP 0120), so its picker row composes the gateway plugin while +// contributing no `gateway_upstream`. Picked alone that wrote a gateway slice +// with `upstreams: []` whose source start threw, i.e. a reachable first-run +// choice that produced a broken install rather than a working one (#649). +// --------------------------------------------------------------------------- + +/** @param {string[]} sources */ +async function composePicked(sources) { + const bundled = await discoverBundledPlugins() + const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) + return composePickerConfig({ + sources: /** @type {any} */ (sources), + descriptors: catalog.pickerDescriptors, + exportChoice: 'local-parquet', + retentionDays: 30, + hypHome: '/home/tester/.hyp', + }) +} + +// @ref LLP 0120#consequences [tests]: hermes composes the gateway plugin for the materializer alone, so the config a hermes-only picker run writes must yield a source that starts +test('the gateway source a hermes-only picker run composes starts, idle', async () => { + const config = await composePicked(['hermes']) + const gateway = config.plugins?.find((p) => p.name === '@hypaware/ai-gateway') + assert.ok(gateway, 'hermes composes the gateway plugin: its materializer is a hard dependency') + assert.deepEqual(gateway.config?.upstreams, [], 'and hermes contributes no upstream of its own') + + // Before the fix this rejected with + // "ai-gateway: at least one upstream must be configured before start". + const source = await createStartSource(createGatewayState())(fakeCtx(/** @type {any} */ (gateway.config))) + try { + assert.ok(source.status, 'source exposes status()') + const status = await source.status() + assert.equal(status.state, 'ready', 'an idle gateway is not an error state') + assert.ok(status.details, 'status carries details') + assert.equal(status.details.listening, false, 'no listener was bound') + assert.equal(status.details.port, undefined, 'and no port is advertised for one') + assert.match(String(status.message ?? ''), /no upstreams/, 'status says why it is idle') + } finally { + await source.stop() + } +}) + +// Idling must be recoverable, not a dead end: the daemon reloads the source +// in place when config changes, so adding an upstream has to bind a listener +// without a restart. +test('an idle gateway binds once a reload brings an upstream', async () => { + // Idle source first, echo upstream second: if starting it ever regresses to + // throwing, this fails without leaking a listening server into the run. + const source = await createStartSource(createGatewayState())(fakeCtx({ listen: '127.0.0.1:0', upstreams: [] })) + const upstream = await startEchoUpstream('reloaded-ok') + try { + assert.ok(source.reload && source.status, 'source exposes reload() and status()') + await source.reload(fakeCtx({ + listen: '127.0.0.1:0', + upstreams: [{ name: 'echo', base_url: upstream.url, path_prefix: '/' }], + })) + const status = await source.status() + assert.ok(status.details, 'status carries details') + assert.equal(status.details.listening, undefined, 'the reloaded source is no longer idle') + const body = await fetchText(`http://${status.details.host}:${status.details.port}/anything`) + assert.equal(body.status, 200) + assert.equal(body.text, 'reloaded-ok') + } finally { + await source.stop() + await upstream.close() + } +}) + +// The reverse direction, pinned deliberately: a reload that removes every +// upstream tears a live listener down and idles, which silently ends capture +// for clients already attached to that port. It is the same trade #649 made +// on the way in (an upstream-less gateway is a config, not a failure), and it +// is why core warns when the config named upstreams and none survived. +test('a reload that removes every upstream tears the listener down and idles', async () => { + const upstream = await startEchoUpstream('still-here') + const source = await createStartSource(createGatewayState())(fakeCtx({ + listen: '127.0.0.1:0', + upstreams: [{ name: 'echo', base_url: upstream.url, path_prefix: '/' }], + })) + try { + assert.ok(source.reload && source.status, 'source exposes reload() and status()') + const bound = await source.status() + assert.ok(bound.details?.port, 'the source bound a port before the reload') + const port = bound.details.port + + // No throw: dropping to zero upstreams is the same valid state a + // hermes-only install boots into. + assert.equal(await source.reload(fakeCtx({ listen: '127.0.0.1:0', upstreams: [] })), undefined) + + const idle = await source.status() + assert.equal(idle.state, 'ready', 'idling after a reload is not an error state') + assert.equal(idle.details?.listening, false, 'the listener is gone') + assert.equal(idle.details?.port, undefined, 'and no port is advertised for it') + assert.match(String(idle.message ?? ''), /no upstreams/) + // The teardown is real, not just unadvertised: an attached client pointed + // at the old port now gets a connection error, with nothing proxied. + await assert.rejects(fetchText(`http://127.0.0.1:${port}/anything`)) + } finally { + await source.stop() + await upstream.close() + } +}) + +// `details.upstreams` is what core's `gateway_idle_no_upstreams` diagnostic +// reads to tell "configured with nothing" from "configured and dropped", so it +// has to describe the config in force, not the one the source booted with. +test('status() reports the reloaded config upstreams, not the boot-time ones', async () => { + const source = await createStartSource(createGatewayState())(fakeCtx({ listen: '127.0.0.1:0', upstreams: [] })) + try { + assert.ok(source.reload && source.status, 'source exposes reload() and status()') + assert.deepEqual((await source.status()).details?.upstreams, []) + // `url` where `base_url` was meant: `compileUpstreams` drops the entry, so + // the source stays idle, but the name the user wrote must still show up. + await source.reload(fakeCtx({ listen: '127.0.0.1:0', upstreams: [{ name: 'anthropic', url: 'https://x' }] })) + const status = await source.status() + assert.equal(status.details?.listening, false, 'a dropped upstream leaves the source idle') + assert.deepEqual(status.details?.upstreams, ['anthropic'], 'and status names what the config asked for') + assert.equal(status.details?.upstreams_configured, 1, 'and counts it') + } finally { + await source.stop() + } +}) + +// `name` is the other key `compileUpstreams` drops an entry over, and an entry +// with no name puts nothing in `details.upstreams` at all. Without the count +// beside it, this config is indistinguishable from hermes-only and core cannot +// warn about it. +test('status() counts a configured upstream it cannot name', async () => { + const state = createGatewayState() + const source = await createStartSource(state)(fakeCtx({ + listen: '127.0.0.1:0', + // No `name`: the v1 config diagnoser is satisfied by `provider`, the + // compiler drops it, and the source idles. + upstreams: [{ provider: 'anthropic', base_url: 'https://api.anthropic.com' }], + })) + try { + assert.ok(source.status, 'source exposes status()') + const status = await source.status() + assert.equal(status.details?.listening, false, 'nothing compiled, so nothing is bound') + assert.deepEqual(status.details?.upstreams, [], 'there is no name to publish') + assert.equal(status.details?.upstreams_configured, 1, 'but the config did ask for one upstream') + } finally { + await source.stop() + } +}) + +// The hermes-only shape must stay distinguishable from the above: a config +// that asked for no upstream counts zero, which is what keeps `hyp status` +// quiet and healthy for it. +test('status() counts zero upstreams for a config that named none', async () => { + const source = await createStartSource(createGatewayState())(fakeCtx({ listen: '127.0.0.1:0', upstreams: [] })) + try { + assert.ok(source.status, 'source exposes status()') + const status = await source.status() + assert.equal(status.details?.upstreams_configured, 0) + } finally { + await source.stop() + } +}) + +// Two configs reach the same empty routing table and they are not the same +// event, so they must not log at the same volume: one is what hermes asked +// for, the other lost every upstream it named. +test('the idle log is a warning only when configured upstreams were dropped', async () => { + /** @type {{ level: string, event: string, attrs: any }[]} */ + const logged = [] + const hermesOnly = await createStartSource(createGatewayState())(fakeCtx({ upstreams: [] }, logged)) + await hermesOnly.stop() + const idleLog = logged.find((l) => l.event === 'aigw.idle_no_upstreams') + assert.ok(idleLog, 'the idle boot is logged') + assert.equal(idleLog.level, 'info', 'a config that wanted no upstream is not a problem') + + logged.length = 0 + const dropped = await createStartSource(createGatewayState())(fakeCtx({ + upstreams: [{ name: 'anthropic', url: 'https://api.anthropic.com' }], + }, logged)) + await dropped.stop() + const warned = logged.find((l) => l.event === 'aigw.idle_no_upstreams') + assert.ok(warned, 'the idle boot is logged') + assert.equal(warned.level, 'warn', 'losing every configured upstream is a problem') + assert.equal(warned.attrs.configured_upstreams, 1) + assert.deepEqual(warned.attrs.configured_upstream_names, ['anthropic']) +}) + +/** + * @param {Record} config + * @param {{ level: string, event: string, attrs: any }[]} [logged] + */ +function fakeCtx(config, logged) { + /** @param {string} level */ + const record = (level) => (/** @type {string} */ event, /** @type {any} */ attrs) => { + logged?.push({ level, event, attrs }) + } return /** @type {any} */ ({ config, storage: { @@ -160,10 +360,10 @@ function fakeCtx(config) { async appendRows() {}, }, log: { - debug() {}, - info() {}, - warn() {}, - error() {}, + debug: record('debug'), + info: record('info'), + warn: record('warn'), + error: record('error'), }, }) }