diff --git a/src/layers/launches/source.js b/src/layers/launches/source.js index 39a49e36c..2779e7d81 100644 --- a/src/layers/launches/source.js +++ b/src/layers/launches/source.js @@ -2,16 +2,24 @@ export function createLaunchSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + /** The launch payload, and whether the proxy served it from a stale cache. */ + async function getLaunchSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/launches', { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(payload) && !Array.isArray(payload?.results)) + throw new Error('Malformed launch snapshot'); + return { + payload, + stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR', + }; + } return { - async getLaunches({ signal } = {}) { - signal?.throwIfAborted(); - const response = await fetchImpl('/api/launches', { signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const payload = await response.json(); - signal?.throwIfAborted(); - if (!Array.isArray(payload) && !Array.isArray(payload?.results)) - throw new Error('Malformed launch snapshot'); - return payload; + getLaunchSnapshot, + async getLaunches(options) { + return (await getLaunchSnapshot(options)).payload; }, async getActiveTle({ signal } = {}) { signal?.throwIfAborted(); diff --git a/src/layers/satellites/source.js b/src/layers/satellites/source.js index 8bcab7b02..e900d34b0 100644 --- a/src/layers/satellites/source.js +++ b/src/layers/satellites/source.js @@ -19,7 +19,10 @@ export function createSatelliteSource({ const response = await fetchImpl(`/api/celestrak/${group}`, { signal }); const text = response.ok ? await response.text() : ''; signal?.throwIfAborted(); - return { ok: response.ok, status: response.status, text }; + // The proxy serves its last copy, marked STALE-ERROR, when CelesTrak + // is down. + const stale = response.headers?.get?.('x-tle-cache') === 'STALE-ERROR'; + return { ok: response.ok, status: response.status, text, stale }; }, }; } diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0bf520d0d..df904297c 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -118,10 +118,17 @@ export const aircraftInArea = defineTool({ .map((record) => aircraftRow(record, center)) .sort((a, b) => a.distance_km - b.distance_km); const kind = args.military ? 'military aircraft' : 'aircraft'; + // A regional fallback feed covers only part of the world around the + // area's center; say so rather than imply a worldwide answer. + const regional = /regional/i.test(snapshot.coverage || ''); + const notes = [ + ...(regional ? [`regional feed: ${snapshot.coverage}`] : []), + ...(snapshot.freshness === 'stale' ? ['data may be stale'] : []), + ]; return { summary: `${rows.length} ${kind} in ${area.label}` + - (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, }; }, diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 7594029ce..4db854345 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -337,3 +337,17 @@ test('aircraft searches keep the feeds that answered and name the others', async error.code === 'unavailable' && error.message === 'OpenSky HTTP 503', ); }); + +test('a regional fallback feed is named in the answer', async () => { + const regional = feed([record('abc123', 37.6, -122.3)], { + source: 'adsb.lol', + coverage: '250nm regional fallback', + }); + const result = await composeCatalog({ + tools: coreTools, + services: { aircraft: regional }, + }).call('aircraft_in_area', { + area: { lat: 37.6, lon: -122.3, radius_km: 20 }, + }); + assert.match(result.summary, /\(regional feed: 250nm regional fallback\)\.$/); +}); diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index a0a551196..b4d3bf38c 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -116,7 +116,8 @@ export const findRadioStations = defineTool({ name: 'find_radio_stations', title: 'Radio stations', description: - 'Internet radio stations from the Radio Browser directory, by area and/or ' + + 'Internet radio stations from a directory of popular Radio Browser ' + + 'stations, not every station, by area and/or ' + 'a search term matched against name, tags, language and country. Each ' + "result includes the broadcaster's public stream URL.", inputSchema: { @@ -186,9 +187,20 @@ export const findRadioStations = defineTool({ if (center) rows.sort((a, b) => a.distance_km - b.distance_km); const where = area ? ` in ${area.label}` : ''; const what = args.query ? ` matching "${args.query}"` : ''; + // The directory is a selection of popular stations, not every station. + const notes = [ + `from a directory of ${directory.stations.length} popular stations`, + ...(directory.stale ? ['the directory may be stale'] : []), + ...(directory.degraded ? ['the directory is incomplete right now'] : []), + ]; return { - summary: `${countNoun(rows.length, 'radio station')}${what}${where}.`, - data: capRows(rows, args.limit), + summary: `${countNoun(rows.length, 'radio station')}${what}${where} (${notes.join('; ')}).`, + data: { + ...capRows(rows, args.limit), + directory_size: directory.stations.length, + stale: directory.stale === true, + degraded: directory.degraded === true, + }, }; }, }); diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index a1916c111..a5c7a4a56 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -185,7 +185,12 @@ test('radio stations match area and every search term', async () => { const jazz = await catalog.call('find_radio_stations', { query: 'jazz norwegian', }); - assert.equal(jazz.summary, '1 radio station matching "jazz norwegian".'); + assert.equal( + jazz.summary, + '1 radio station matching "jazz norwegian" (from a directory of 3 popular stations).', + ); + assert.equal(jazz.data.directory_size, 3); + assert.equal(jazz.data.stale, false); assert.equal(jazz.data.rows[0].distance_km, undefined); const classicalTexas = await catalog.call('find_radio_stations', { query: 'classical texas', @@ -220,3 +225,24 @@ test('malformed image results are a programming error', async () => { /malformed images/, ); }); + +test('a stale or degraded radio directory is reported', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + radio: { + getDirectory: async () => ({ + stations: [], + stale: true, + degraded: true, + }), + }, + }, + }); + const result = await catalog.call('find_radio_stations', { query: 'jazz' }); + assert.match( + result.summary, + /the directory may be stale; the directory is incomplete right now\)\.$/, + ); + assert.equal(result.data.degraded, true); +}); diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index d02796429..ad65f80e8 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -25,7 +25,12 @@ export const getRecentLaunches = defineTool({ }, requires: ['launches'], async run(args, { services, signal }) { - const payload = await services.launches.getLaunches({ signal }); + const { payload, stale } = services.launches.getLaunchSnapshot + ? await services.launches.getLaunchSnapshot({ signal }) + : { + payload: await services.launches.getLaunches({ signal }), + stale: false, + }; const launches = Array.isArray(payload) ? payload : payload.results; if (!Array.isArray(launches)) throw new ToolError('malformed', 'The launch feed returned no launches'); @@ -49,8 +54,10 @@ export const getRecentLaunches = defineTool({ .sort((a, b) => String(b.time).localeCompare(String(a.time))); const result = capRows(rows, args.limit); return { - summary: `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}.`, - data: result, + summary: + `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}` + + (stale ? ' (data may be stale).' : '.'), + data: { ...result, stale }, }; }, }); @@ -103,7 +110,7 @@ async function readCatalog(services, group, signal) { 'unavailable', `The ${group} satellite catalog is unavailable (HTTP ${result.status})`, ); - return parseTleText(result.text).flatMap((entry) => { + const entries = parseTleText(result.text).flatMap((entry) => { try { return [ { @@ -116,6 +123,9 @@ async function readCatalog(services, group, signal) { return []; } }); + // The proxy serves its last copy when CelesTrak is down; old orbital + // elements make predictions drift. + return { entries, stale: result.stale === true }; } function matchSatellite(entries, wanted) { @@ -164,7 +174,8 @@ export const nextSatellitePass = defineTool({ async run(args, { services, signal }) { const point = await resolvePoint(args.location, { services, signal }); const group = args.group ?? 'stations'; - const entries = await readCatalog(services, group, signal); + const { entries, stale } = await readCatalog(services, group, signal); + const staleNote = stale ? ' (orbit data may be stale)' : ''; const satellite = matchSatellite(entries, args.satellite); if (!satellite) throw new ToolError( @@ -184,8 +195,9 @@ export const nextSatellitePass = defineTool({ const name = satellite.name; if (!pass) return { - summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours.`, + summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours${staleNote}.`, data: { + stale, location: point, satellite: name, norad: satellite.norad, @@ -196,8 +208,9 @@ export const nextSatellitePass = defineTool({ summary: `${name} next rises over ${point.label} at ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` + `peaking at ${Math.round(pass.maxElevDeg)}°` + - `${pass.visible ? '; visible to the naked eye' : ''}.`, + `${pass.visible ? '; visible to the naked eye' : ''}${staleNote}.`, data: { + stale, location: point, satellite: name, norad: satellite.norad, @@ -238,7 +251,8 @@ export const satellitesOverhead = defineTool({ const group = args.group ?? 'stations'; const minimum = args.min_elevation_deg ?? 10; const at = nowMs(services); - const rows = (await readCatalog(services, group, signal)) + const { entries, stale } = await readCatalog(services, group, signal); + const rows = entries .flatMap((entry) => { const look = lookAnglesAt(entry.satrec, at, point.lat, point.lon); return look && look.elevDeg >= minimum @@ -255,9 +269,12 @@ export const satellitesOverhead = defineTool({ }) .sort((a, b) => b.elevation_deg - a.elevation_deg); return { - summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}.`, + summary: + `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}` + + (stale ? ' (orbit data may be stale).' : '.'), data: { ...capRows(rows, args.limit), + stale, location: point, group, at: isoTime(at), diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 8f77fb041..376cf54d7 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -150,3 +150,49 @@ test('satellites overhead are those above the elevation, highest first', async ( /^0 satellites from the stations group are at least 90° above/, ); }); + +test('stale orbit and launch data are reported', async () => { + const stale = { + readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }), + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { + satellites: stale, + clock: CLOCK, + launches: { + getLaunchSnapshot: async () => ({ payload: [], stale: true }), + }, + }, + }); + const overhead = await catalog.call('satellites_overhead', { + location: { lat: 0, lon: 0 }, + }); + assert.equal(overhead.data.stale, true); + assert.match(overhead.summary, /\(orbit data may be stale\)\.$/); + const launches = await catalog.call('get_recent_launches', {}); + assert.equal(launches.data.stale, true); + assert.match(launches.summary, /\(data may be stale\)\.$/); +}); + +test('satellite and launch sources read the proxies stale markers', async () => { + const { createSatelliteSource } = + await import('../../layers/satellites/source.js'); + const { createLaunchSource } = + await import('../../layers/launches/source.js'); + const satellites = createSatelliteSource({ + fetchImpl: async () => + new Response(TLE, { headers: { 'x-tle-cache': 'STALE-ERROR' } }), + }); + assert.equal((await satellites.readGroup('stations')).stale, true); + const launches = createLaunchSource({ + fetchImpl: async () => + Response.json([], { headers: { 'X-GEV-Cache': 'STALE-ERROR' } }), + }); + assert.deepEqual(await launches.getLaunchSnapshot(), { + payload: [], + stale: true, + }); + const { getLaunches } = launches; + assert.deepEqual(await getLaunches(), []); +}); diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js index d6fbd346a..6571036d5 100644 --- a/src/tools/queries/surveillance.js +++ b/src/tools/queries/surveillance.js @@ -77,12 +77,21 @@ export const findAlprCameras = defineTool({ distance_km: round(distanceKm(center, point), 2), })) .sort((a, b) => a.distance_km - b.distance_km); + const notes = [ + ...(result.saturated + ? ['partial: some cameras were not loaded; use a smaller area'] + : []), + ...(result.stale ? ['data may be stale'] : []), + ]; return { summary: `${countNoun(rows.length, 'license plate reader camera')} mapped in ${area.label}` + - (result.stale ? ' (data may be stale).' : '.'), + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { ...capRows(rows, args.limit), + // Some tiles failed, or a dense area was trimmed to the source limit. + complete: !result.saturated, + stale: result.stale === true, source: services.alpr.label ?? null, attribution: services.alpr.attribution ?? null, }, diff --git a/src/tools/queries/surveillance.test.mjs b/src/tools/queries/surveillance.test.mjs index 8b4a151bc..5c76a04f2 100644 --- a/src/tools/queries/surveillance.test.mjs +++ b/src/tools/queries/surveillance.test.mjs @@ -95,3 +95,21 @@ test('coverage, size and zoom limits are reported', async () => { /smaller, city-sized area/, ); }); + +test('a saturated camera fetch is reported as partial', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + alpr: alpr({ + records: [camera('alpr:1', 30.3, -97.7)], + stale: false, + saturated: true, + }), + }, + }); + const result = await catalog.call('find_alpr_cameras', { + area: { lat: 30.3, lon: -97.7, radius_km: 5 }, + }); + assert.equal(result.data.complete, false); + assert.match(result.summary, /partial: some cameras were not loaded/); +});