fix: report stale and partial data in more tool answers

A sweep for the same pattern found more answers presented as current and
complete: launches and satellite orbits served from a stale proxy cache, a
regional aircraft fallback read as worldwide, license plate cameras trimmed
or partly loaded, and a radio directory of popular stations read as every
station. The launch and satellite sources now report the proxies' stale
markers, and each answer says what it covers.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:36 -07:00
1 parent 6a45a8f6a2
commit e779432925
10 files changed
+185 -25

No files matched your search

+17 -9
View File
@@ -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();
+4 -1
View File
@@ -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 };
},
};
}
+8 -1
View File
@@ -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) },
};
},
+14
View File
@@ -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\)\.$/);
});
+15 -3
View File
@@ -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,
},
};
},
});
+27 -1
View File
@@ -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);
});
+26 -9
View File
@@ -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),
+46
View File
@@ -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(), []);
});
+10 -1
View File
@@ -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,
},
+18
View File
@@ -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/);
});