From 91d7dd30c5342651ab5e7fbb0c18f451e2553fad Mon Sep 17 00:00:00 2001 From: test Date: Thu, 6 Aug 2026 02:33:44 +0000 Subject: [PATCH 1/3] An upstream-less ai-gateway source idles instead of throwing (#649) A picker run selecting `hermes` and nothing else composed `@hypaware/ai-gateway` with `{ upstreams: [] }` and then failed at source start with "ai-gateway: at least one upstream must be configured before start", so a reachable first-run choice produced a broken install. The gateway plugin does two separable jobs: at activation it contributes the `ai_gateway_messages` dataset and the shared `ai_gateway.projected_exchange` materializer, and at source start it runs the proxy. `@hypaware/hermes` wants the first alone. It is "never modified, configured, or proxied" (LLP 0119) so it contributes no `gateway_upstream`, yet the materializer is a hard `requires.plugins` dependency (LLP 0120) so its picker row must still compose the gateway plugin. So the source now treats an empty compiled routing table as idle: no listener bound, `state.listen` left unset (`localEndpoint()` keeps throwing rather than handing an attach a URL nothing is listening on), `status()` reporting `listening: false` with a reason. A `reload()` that brings an upstream binds normally, so idling is recoverable. Co-Authored-By: Claude --- .../ai-gateway/src/config.js | 9 +- .../ai-gateway/src/source.js | 50 +++++++++-- test/plugins/ai-gateway-source.test.js | 83 ++++++++++++++++++- 3 files changed, 130 insertions(+), 12 deletions(-) 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..32a01bfa 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/source.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/source.js @@ -49,6 +49,9 @@ 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) return { @@ -58,8 +61,10 @@ export function createStartSource(state) { state: 'ready', rowsWritten: liveState.rowsWritten, details: { - host: proxy.host, - port: proxy.port, + // 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 }), upstreams: readConfiguredUpstreamNames(ctx), registered_presets: Array.from(state.presets.keys()), projectors: state.projectors.map((p) => p.name), @@ -80,6 +85,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 +95,13 @@ 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) }, async stop() { - await proxy.stop() + await proxy?.stop() state.listen = undefined }, } @@ -108,13 +114,45 @@ 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 + 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 +226,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 diff --git a/test/plugins/ai-gateway-source.test.js b/test/plugins/ai-gateway-source.test.js index 5be3b838..1ec074a5 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,6 +153,79 @@ test('restart-drops-state: a fresh GatewayState never carries a previous run\'s } }) +// --------------------------------------------------------------------------- +// 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() + } +}) + /** @param {Record} config */ function fakeCtx(config) { return /** @type {any} */ ({ From 693deb1d8b422ef301311ab9bd2260bf732ccbc4 Mon Sep 17 00:00:00 2001 From: test Date: Thu, 6 Aug 2026 03:24:46 +0000 Subject: [PATCH 2/3] An idle gateway that was configured with upstreams is visible in status (#650 review) Letting an upstream-less `ai-gateway` source idle rather than fail its start is right for the config that wants it (hermes composes the plugin for its materializer alone, LLP 0120), but the same path swallowed a real misconfiguration. A gateway-only install whose TOML says `url =` where `base_url` was meant has every entry dropped by `compileUpstreams` without complaint, and `diagnoseV1Config`'s `gateway_missing_*_upstream` check does not fire for that shape. On master that install read `[failed]` / `degraded`; on this branch it read `[started]` / `healthy` while the user's client got ECONNREFUSED, with the reason living only in `status.message`, which the daemon snapshot never captures. `hyp status` now emits a non-degrading `gateway_idle_no_upstreams` warning when the gateway reports `listening: false` and a non-empty `details.upstreams`. That field is the raw configured names, pre-compile, so it discriminates exactly: a hermes-only install publishes `[]` and stays quiet and healthy, a dropped or misspelled upstream publishes `['anthropic']` and warns, naming the entry and the field that drops it. Also: `aigw.idle_no_upstreams` logs at `warn` with the configured count and names when the config listed upstreams and compiled to none, staying at `info` for the genuine hermes-only case; and `status()` reads the context `reload()` handed it rather than the boot-time closure, so `details.upstreams` describes the config in force (pre-existing, but the new diagnostic reads that field). Tests: the diagnostic fires for the dropped-upstream shape and not for the hermes-only one, and never degrades `overall`; a reload that removes every upstream tears a live listener down and idles without error, which ends capture for already-attached clients and is now pinned deliberately. Co-Authored-By: Claude --- .../ai-gateway/src/source.js | 61 ++++++++- src/core/daemon/status.js | 82 ++++++++++- src/core/daemon/types.d.ts | 1 + test/core/status-gateway-idle.test.js | 127 ++++++++++++++++++ test/plugins/ai-gateway-source.test.js | 97 ++++++++++++- 5 files changed, 348 insertions(+), 20 deletions(-) create mode 100644 test/core/status-gateway-idle.test.js diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/source.js b/hypaware-core/plugins-workspace/ai-gateway/src/source.js index 32a01bfa..8d750276 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/source.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/source.js @@ -54,6 +54,15 @@ export function createStartSource(state) { // {@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() { /** @type {SourceStatus} */ @@ -65,7 +74,12 @@ export function createStartSource(state) { // (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 }), - upstreams: readConfiguredUpstreamNames(ctx), + // 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: readConfiguredUpstreamNames(activeCtx), 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 @@ -98,6 +112,7 @@ export function createStartSource(state) { await proxy?.stop() state.listen = undefined proxy = await launchListener(nextCtx, state, liveState) + activeCtx = nextCtx }, async stop() { @@ -147,10 +162,28 @@ async function launchListener(ctx, state, liveState) { const upstreams = mergeUpstreams(config.upstreams, state) if (upstreams.length === 0) { liveState.listenFallbackFrom = undefined - ctx.log.info('aigw.idle_no_upstreams', { - [Attr.PLUGIN]: PLUGIN_NAME, - registered_presets: state.presets.size, - }) + // 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 }) @@ -334,8 +367,22 @@ export function mergeUpstreams(configUpstreams, state) { * @returns {string[]} */ function readConfiguredUpstreamNames(ctx) { + return readConfiguredUpstreams(ctx).names +} + +/** + * 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 can be loud about a + * config that listed upstreams and compiled to none even when the names are + * unusable. + * + * @param {PluginActivationContext} ctx + * @returns {{ count: number, names: string[] }} + */ +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) { @@ -344,7 +391,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..55e04274 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,55 @@ 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) +} + +/** + * Names of the upstreams a *deliberately idle* gateway was nonetheless + * configured with, 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` (a `url =` where `base_url` was meant is dropped + * without complaint, and `diagnoseV1Config`'s `gateway_missing_*_upstream` + * check does not fire for that shape) also idles, reporting `started` and + * `healthy` while the user's client gets ECONNREFUSED. + * + * `details.upstreams` is what separates them: the gateway publishes the *raw* + * configured names, pre-compile, so hermes-only yields `[]` and a dropped + * upstream yields the name that vanished. + * + * @param {SourceSnapshot[] | undefined} sources + * @returns {string[] | undefined} + */ +function gatewayIdleWithConfiguredUpstreams(sources) { + const details = gatewaySourceRawDetails(sources) + if (!details || details.listening !== false) return undefined + const upstreams = details.upstreams + if (!Array.isArray(upstreams)) return undefined + const names = upstreams.filter((u) => typeof u === 'string' && u.length > 0) + return names.length > 0 ? /** @type {string[]} */ (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 +642,30 @@ 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 named 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 list = idleGatewayUpstreams.join(', ') + diagnostics.push({ + severity: 'warning', + kind: 'gateway_idle_no_upstreams', + message: `the gateway is running but listening on nothing: ${idleGatewayUpstreams.length === 1 ? 'upstream' : 'upstreams'} ${list} ${idleGatewayUpstreams.length === 1 ? 'is' : 'are'} configured but none compiled to a route (each needs a 'base_url') - clients will get connection refused`, + repair: [ + `hyp config validate # every upstream needs a name and a base_url`, + `hyp daemon restart`, + ], + }) + } /** @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..a9fd989b --- /dev/null +++ b/test/core/status-gateway-idle.test.js @@ -0,0 +1,127 @@ +// @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` (a `url =` where `base_url` was meant) leave the source +// `started`, the daemon `healthy`, and the user's client with ECONNREFUSED. +// `details.upstreams` carries the *raw* configured names, so it tells the two +// apart. 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') +}) + +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: [], 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') +}) + +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 1ec074a5..c833c882 100644 --- a/test/plugins/ai-gateway-source.test.js +++ b/test/plugins/ai-gateway-source.test.js @@ -226,8 +226,93 @@ test('an idle gateway binds once a reload brings an upstream', async () => { } }) -/** @param {Record} config */ -function fakeCtx(config) { +// 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') + } 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: { @@ -237,10 +322,10 @@ function fakeCtx(config) { async appendRows() {}, }, log: { - debug() {}, - info() {}, - warn() {}, - error() {}, + debug: record('debug'), + info: record('info'), + warn: record('warn'), + error: record('error'), }, }) } From c39f71c2c4f4a73df947bb7f2e522355b197041f Mon Sep 17 00:00:00 2001 From: test Date: Thu, 6 Aug 2026 04:13:53 +0000 Subject: [PATCH 3/3] An idle gateway whose upstreams have no usable name is visible too (#650 review 2) The `gateway_idle_no_upstreams` warning gated on `details.upstreams`, the raw configured names, so it could not see the config it most needed to see. `compileUpstreams` drops an entry for a missing `name` exactly as silently as for a missing `base_url`, and a nameless entry contributes no name at all, so `provider = "anthropic", base_url = "..."` published `upstreams: []` and read as hermes-only: `healthy`, no diagnostic, ECONNREFUSED at the client. The repo's own `diagnoseV1Config` affirms that config, because it matches an upstream by its `provider` field. The gateway source already computed the count (its idle log is a `warn` on the strength of it); it just never published it. It now does, as `details.upstreams_configured`, and core gates on the count instead of the names. The names still ride along and still make the message concrete when they exist; a status file from a build that predates the count falls back to counting the names, so an older daemon's dropped `base_url` stays visible. Hermes-only (0 configured) stays quiet and healthy, and the diagnostic remains non-degrading. The repair lines pointed at `hyp config validate`, which prints `config ok` and exits 0 for exactly this config: `@hypaware/ai-gateway` registers no config section, so nothing checks upstream shape. Replaced with the edit that fixes it, naming the config file and both required keys, followed by the restart that makes it take effect. Co-Authored-By: Claude --- .../ai-gateway/src/source.js | 33 ++--- src/core/daemon/status.js | 64 +++++++--- test/core/status-gateway-idle.test.js | 115 +++++++++++++++++- test/plugins/ai-gateway-source.test.js | 38 ++++++ 4 files changed, 210 insertions(+), 40 deletions(-) diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/source.js b/hypaware-core/plugins-workspace/ai-gateway/src/source.js index 8d750276..7610fab0 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/source.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/source.js @@ -65,6 +65,7 @@ export function createStartSource(state) { return { async status() { + const configured = readConfiguredUpstreams(activeCtx) /** @type {SourceStatus} */ const status = { state: 'ready', @@ -79,7 +80,16 @@ export function createStartSource(state) { // 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: readConfiguredUpstreamNames(activeCtx), + 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 @@ -358,24 +368,15 @@ export function mergeUpstreams(configUpstreams, state) { return Array.from(merged.values()) } -/** - * 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. - * - * @param {PluginActivationContext} ctx - * @returns {string[]} - */ -function readConfiguredUpstreamNames(ctx) { - return readConfiguredUpstreams(ctx).names -} - /** * 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 can be loud about a - * config that listed upstreams and compiled to none even when the names are - * unusable. + * 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 {{ count: number, names: string[] }} diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index 55e04274..faa2ec26 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -146,33 +146,49 @@ function gatewaySourceRawDetails(sources) { } /** - * Names of the upstreams a *deliberately idle* gateway was nonetheless - * configured with, or `undefined` when the gateway is bound, absent, or idle - * for the reason it is allowed to be idle. + * 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` (a `url =` where `base_url` was meant is dropped + * 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) also idles, reporting `started` and - * `healthy` while the user's client gets ECONNREFUSED. + * 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. * - * `details.upstreams` is what separates them: the gateway publishes the *raw* - * configured names, pre-compile, so hermes-only yields `[]` and a dropped - * upstream yields the name that vanished. + * 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 {string[] | undefined} + * @returns {{ count: number, names: string[] } | undefined} */ function gatewayIdleWithConfiguredUpstreams(sources) { const details = gatewaySourceRawDetails(sources) if (!details || details.listening !== false) return undefined const upstreams = details.upstreams - if (!Array.isArray(upstreams)) return undefined - const names = upstreams.filter((u) => typeof u === 'string' && u.length > 0) - return names.length > 0 ? /** @type {string[]} */ (names) : undefined + 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 } /** @@ -646,7 +662,7 @@ export async function collectHypAwareStatus(opts = {}) { ? gatewayIdleWithConfiguredUpstreams(daemonStatusFile?.sources) : undefined if (idleGatewayUpstreams) { - // The gateway bound nothing while the config named upstreams it wanted + // 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 @@ -655,14 +671,26 @@ export async function collectHypAwareStatus(opts = {}) { // *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 list = idleGatewayUpstreams.join(', ') + 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: ${idleGatewayUpstreams.length === 1 ? 'upstream' : 'upstreams'} ${list} ${idleGatewayUpstreams.length === 1 ? 'is' : 'are'} configured but none compiled to a route (each needs a 'base_url') - clients will get connection refused`, + 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: [ - `hyp config validate # every upstream needs a name and a base_url`, - `hyp daemon restart`, + `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`, ], }) } diff --git a/test/core/status-gateway-idle.test.js b/test/core/status-gateway-idle.test.js index a9fd989b..e912f38d 100644 --- a/test/core/status-gateway-idle.test.js +++ b/test/core/status-gateway-idle.test.js @@ -17,11 +17,13 @@ import { defaultConfigPath } from '../../src/core/config/schema.js' // 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` (a `url =` where `base_url` was meant) leave the source -// `started`, the daemon `healthy`, and the user's client with ECONNREFUSED. -// `details.upstreams` carries the *raw* configured names, so it tells the two -// apart. The diagnostic is non-degrading: a correct hermes-only install must -// stay healthy and quiet. +// `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() { @@ -76,19 +78,120 @@ test('an idle gateway that was configured with upstreams warns', async () => { 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: [], registered_presets: [] }) + 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'] }) diff --git a/test/plugins/ai-gateway-source.test.js b/test/plugins/ai-gateway-source.test.js index c833c882..e905d235 100644 --- a/test/plugins/ai-gateway-source.test.js +++ b/test/plugins/ai-gateway-source.test.js @@ -275,6 +275,44 @@ test('status() reports the reloaded config upstreams, not the boot-time ones', a 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() }