From 853c60583fa66e5b1bdeeffde3cf2fb47c4af63f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:51:31 -0700 Subject: [PATCH] fix: report stale transit feeds and drop expired positions get_transit_vehicles returned positions from a feed the proxy served from its error cache, and hour-old fixes, as current. It now applies the transit layer's rules: a feed is stale when the proxy says so or the operator has not answered recently, and fixes older than the vehicle limit are dropped and counted. --- scripts/package-boundaries.json | 6 ++- src/tools/queries/mobility.js | 60 +++++++++++++++++++++++++++-- src/tools/queries/mobility.test.mjs | 36 ++++++++++++++++- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 03a648a71..9c768aa65 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3123,7 +3123,11 @@ "src/tools/queries/events.js", "src/data/bhoteKoshiFloodPath.js", "src/tools/functions.js", - "src/tools/surfaces.js" + "src/tools/surfaces.js", + "src/layers/transit/policy.js", + "src/data/transitFeeds.js", + "src/layers/transit/movement.js", + "src/data/contactPlayback.js" ], "external": ["satellite.js"] }, diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 0c9e3a7fe..055889a7c 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -4,6 +4,10 @@ import { parseStationInformation, parseStationStatus, } from '../../sources/gbfsStations.js'; +import { + FEED_STALE_AFTER_MS, + isStaleVehicleFix, +} from '../../layers/transit/policy.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -157,6 +161,7 @@ export const getTransitVehicles = defineTool({ data: { ...capRows([], args.limit), feeds: [] }, }; const center = areaCenter(area); + const now = services.clock?.now() ?? Date.now(); const results = await Promise.allSettled( feeds.map(async (feed) => { const response = await services.transit.requestSnapshot(feed.id, { @@ -164,7 +169,31 @@ export const getTransitVehicles = defineTool({ }); if (!response.ok) throw new Error(`Transit HTTP ${response.status}`); const snapshot = await response.json(); - return (snapshot.vehicles || []).map((vehicle) => ({ feed, vehicle })); + // The app's freshness rules: the operator's fetch time ages the + // snapshot, and fixes older than the vehicle limit are not shown. + const fetchedAt = Number.isFinite(snapshot.fetchedAt) + ? Math.min(snapshot.fetchedAt, now) + : now; + const contacted = Number.parseInt( + response.headers?.get?.('x-transit-contact') || '', + 10, + ); + const answeredAt = Math.max( + fetchedAt, + Number.isFinite(contacted) ? Math.min(contacted, now) : fetchedAt, + ); + const stale = + response.headers?.get?.('x-gev-cache') === 'STALE-ERROR' || + now - answeredAt > FEED_STALE_AFTER_MS; + const vehicles = snapshot.vehicles || []; + const current = vehicles.filter( + (vehicle) => !isStaleVehicleFix(vehicle, now, fetchedAt), + ); + return { + stale, + expired: vehicles.length - current.length, + vehicles: current.map((vehicle) => ({ feed, vehicle })), + }; }), ); signal?.throwIfAborted(); @@ -175,7 +204,9 @@ export const getTransitVehicles = defineTool({ ); const wanted = args.route?.trim().toLowerCase(); const rows = results - .flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) + .flatMap((result) => + result.status === 'fulfilled' ? result.value.vehicles : [], + ) .filter(({ vehicle }) => areaContains(area, vehicle)) .filter( ({ vehicle }) => @@ -205,15 +236,36 @@ export const getTransitVehicles = defineTool({ const failed = results.filter( (result) => result.status === 'rejected', ).length; + const answered = results + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value); + const staleFeeds = answered.filter((feed) => feed.stale).length; + const expired = answered.reduce((total, feed) => total + feed.expired, 0); + const notes = [ + ...(failed ? [`${countNoun(failed, 'feed')} unavailable`] : []), + ...(staleFeeds + ? [ + `${countNoun(staleFeeds, 'feed')} stale; positions may be out of date`, + ] + : []), + ]; const what = args.route ? ` on route ${args.route}` : ''; return { summary: `${countNoun(rows.length, 'transit vehicle')}${what} in ${area.label}` + - (failed ? ` (${countNoun(failed, 'feed')} unavailable).` : '.'), + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { ...capRows(rows, args.limit), - feeds: feeds.map((feed) => ({ + stale: staleFeeds > 0, + expired_positions_dropped: expired, + feeds: feeds.map((feed, index) => ({ name: feed.name, + status: + results[index].status === 'rejected' + ? 'unavailable' + : results[index].value.stale + ? 'stale' + : 'current', operator: feed.operator ?? null, attribution: feed.attribution ?? null, license: feed.license ?? null, diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index cbf48b99d..11510f01a 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -177,6 +177,8 @@ const vehicles = [ }, { id: 'v3', lat: 30.5, lon: -97.6, routeId: '1' }, ]; +// One minute after v1's fix. +const clock = { now: () => Date.UTC(2026, 0, 1, 0, 1) }; const transit = ( snapshot = { ok: true, status: 200, json: async () => ({ vehicles }) }, ) => ({ @@ -197,7 +199,7 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as const source = transit(); const catalog = composeCatalog({ tools: coreTools, - services: { transit: source }, + services: { transit: source, clock }, }); const all = await catalog.call('get_transit_vehicles', { area: downtown }); assert.equal( @@ -223,6 +225,7 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as assert.deepEqual(all.data.feeds, [ { name: 'CapMetro', + status: 'current', operator: 'Capital Metro', attribution: 'Capital Metro', license: 'Open data', @@ -370,3 +373,34 @@ test('traffic reports a missing key, empty areas and areas that are too wide', a /too large for traffic detail/, ); }); + +test('transit marks stale feeds and drops expired positions', async () => { + const headers = new Headers({ 'x-gev-cache': 'STALE-ERROR' }); + const snapshot = (extra) => ({ + ok: true, + status: 200, + headers, + json: async () => ({ + vehicles: [ + vehicles[0], + { ...vehicles[0], id: 'old', timestamp: 1767225600 - 3600 }, + ], + ...extra, + }), + }); + const result = await composeCatalog({ + tools: coreTools, + services: { transit: transit(snapshot()), clock }, + }).call('get_transit_vehicles', { area: downtown }); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['v1'], + ); + assert.equal(result.data.expired_positions_dropped, 1); + assert.equal(result.data.stale, true); + assert.equal(result.data.feeds[0].status, 'stale'); + assert.equal( + result.summary, + '1 transit vehicle in 2 km around 30.267, -97.743 (1 feed stale; positions may be out of date).', + ); +});