@motionstudies/data 0.1.0-alpha.3 → 0.1.0-alpha.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -2
- package/adsb-heatmap.d.mts +56 -0
- package/adsb-heatmap.mjs +223 -0
- package/air-endpoints.d.mts +7 -0
- package/air-endpoints.mjs +148 -0
- package/feed-expectations.d.mts +3 -0
- package/feed-expectations.mjs +33 -0
- package/feed-incidents.d.mts +44 -0
- package/feed-incidents.mjs +148 -0
- package/feed-observability.d.mts +68 -0
- package/feed-observability.mjs +154 -0
- package/feed-observer.d.mts +30 -0
- package/feed-observer.mjs +192 -0
- package/network-chunk-cli.d.mts +1 -0
- package/network-chunk-cli.mjs +65 -0
- package/observation-windows.d.mts +38 -0
- package/observation-windows.mjs +76 -0
- package/operational-files.mjs +21 -0
- package/package.json +85 -2
- package/rail-geometry.d.mts +7 -0
- package/rail-geometry.mjs +108 -0
- package/rail-journeys.d.mts +14 -0
- package/rail-journeys.mjs +118 -0
- package/rail-public-calls.d.mts +4 -0
- package/rail-public-calls.mjs +48 -0
- package/rail-routing.d.mts +16 -0
- package/rail-routing.mjs +119 -0
- package/rail-wtt.d.mts +12 -0
- package/rail-wtt.mjs +135 -0
- package/rail-xml.mjs +12 -0
- package/recorder-evidence.d.mts +14 -0
- package/recorder-evidence.mjs +136 -0
- package/recorder-observability.d.mts +3 -0
- package/recorder-observability.mjs +106 -0
- package/release-files.d.mts +14 -0
- package/release-files.mjs +52 -0
- package/release.d.mts +10 -0
- package/release.mjs +62 -0
- package/source-store.d.mts +15 -0
- package/source-store.mjs +160 -0
- package/uk-service-day.d.mts +7 -0
- package/uk-service-day.mjs +49 -0
- package/webtris.d.mts +14 -0
- package/webtris.mjs +67 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readFile, readdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { gunzipSync } from 'node:zlib'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { isAirContinent } from '@motionstudies/core/air-continents'
|
|
6
|
+
import { parseCsvLine } from './gtfs.mjs'
|
|
7
|
+
import { decodeAdsbHeatmap } from './adsb-heatmap.mjs'
|
|
8
|
+
|
|
9
|
+
export function parseAirports(csv, { continentOverrides = [] } = {}) {
|
|
10
|
+
const [header, ...lines] = csv.trim().split(/\r?\n/)
|
|
11
|
+
const keys = parseCsvLine(header)
|
|
12
|
+
const rows = lines.map(line => Object.fromEntries(parseCsvLine(line).map((value, index) => [keys[index], value])))
|
|
13
|
+
if (!Array.isArray(continentOverrides)) throw new Error('Continent overrides must be an array')
|
|
14
|
+
const identifiers = new Set(rows.map(row => row.ident)), overrides = new Map()
|
|
15
|
+
for (const entry of continentOverrides) {
|
|
16
|
+
if (!entry || !identifiers.has(entry.airportIdent) || !isAirContinent(entry.continent)
|
|
17
|
+
|| typeof entry.reason !== 'string' || !entry.reason.trim() || overrides.has(entry.airportIdent)) {
|
|
18
|
+
throw new Error('Each continent override needs a unique reference airportIdent, valid continent and reason')
|
|
19
|
+
}
|
|
20
|
+
overrides.set(entry.airportIdent, entry.continent)
|
|
21
|
+
}
|
|
22
|
+
const geography = row => {
|
|
23
|
+
const continent = overrides.get(row.ident) ?? row.continent?.trim().toUpperCase()
|
|
24
|
+
if (!continent) return {}
|
|
25
|
+
if (!isAirContinent(continent)) throw new Error(`Invalid continent for airport ${row.ident}: ${continent}`)
|
|
26
|
+
return { continent, continentSource: overrides.has(row.ident) ? 'override' : 'ourairports' }
|
|
27
|
+
}
|
|
28
|
+
return rows
|
|
29
|
+
.filter(row => ['large_airport', 'medium_airport'].includes(row.type) && row.ident && row.elevation_ft)
|
|
30
|
+
.map(row => ({ icao: row.ident, iata: row.iata_code, name: row.name, city: row.municipality,
|
|
31
|
+
longitude: Number(row.longitude_deg), latitude: Number(row.latitude_deg), elevation: Number(row.elevation_ft), ...geography(row) }))
|
|
32
|
+
.filter(row => [row.longitude, row.latitude, row.elevation].every(Number.isFinite))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function distance(sample, airport) {
|
|
36
|
+
const longitude = ((sample[1] - airport.longitude + 540) % 360) - 180
|
|
37
|
+
return Math.hypot(longitude * 111.32 * Math.cos((sample[2] + airport.latitude) * Math.PI / 360), (sample[2] - airport.latitude) * 111.32)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function atAirport(sample, airport) {
|
|
41
|
+
return sample[3] >= airport.elevation - 500 && sample[3] <= airport.elevation + 1500 && sample[4] <= 250 && distance(sample, airport) <= 6
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Conservative endpoint association; cruise-only traces and ambiguous neighbouring airports stay unknown. */
|
|
45
|
+
export function inferAirEndpoints(samples, airports) {
|
|
46
|
+
if (samples.length < 4) return {}
|
|
47
|
+
const maximumAltitude = Math.max(...samples.map(sample => sample[3]))
|
|
48
|
+
const endpoint = (arrival) => {
|
|
49
|
+
const ordered = arrival ? [...samples].reverse() : samples
|
|
50
|
+
const candidates = airports.filter(airport => atAirport(ordered[0], airport) && maximumAltitude >= airport.elevation + 3000)
|
|
51
|
+
if (candidates.length !== 1) return undefined
|
|
52
|
+
const airport = candidates[0]
|
|
53
|
+
let boundary = ordered[0]
|
|
54
|
+
for (const sample of ordered.slice(1)) {
|
|
55
|
+
if (!atAirport(sample, airport)) break
|
|
56
|
+
boundary = sample
|
|
57
|
+
}
|
|
58
|
+
return { icao: airport.icao, iata: airport.iata, name: airport.name, city: airport.city, time: boundary[0], evidence: 'observed-endpoint',
|
|
59
|
+
...(isAirContinent(airport.continent) ? { continent: airport.continent, continentSource: airport.continentSource ?? 'ourairports' } : {}) }
|
|
60
|
+
}
|
|
61
|
+
const origin = endpoint(false), destination = endpoint(true)
|
|
62
|
+
if (origin && destination && (origin.icao === destination.icao || origin.time >= destination.time)) return {}
|
|
63
|
+
return { ...(origin ? { origin } : {}), ...(destination ? { destination } : {}) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function segmentsFor(samples) {
|
|
67
|
+
const segments = []
|
|
68
|
+
let current = []
|
|
69
|
+
for (const sample of samples.sort((a, b) => a[0] - b[0])) {
|
|
70
|
+
const previous = current.at(-1)
|
|
71
|
+
if (previous && previous[0] === sample[0]) continue
|
|
72
|
+
if (previous && (sample[0] - previous[0] > 1800 || sample[5] !== previous[5])) {
|
|
73
|
+
segments.push(current)
|
|
74
|
+
current = []
|
|
75
|
+
}
|
|
76
|
+
current.push(sample)
|
|
77
|
+
}
|
|
78
|
+
if (current.length) segments.push(current)
|
|
79
|
+
return segments
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Enrich an existing study and its chunks using cached, same-date global heatmaps. No network requests. */
|
|
83
|
+
export async function enrichAirEndpoints({ manifestPath, snapshotPaths = [], heatmapDirectory, airportCsvPath, utcOffsetHours }) {
|
|
84
|
+
const readJson = async path => JSON.parse(await readFile(path, 'utf8'))
|
|
85
|
+
const manifest = await readJson(manifestPath)
|
|
86
|
+
const snapshots = await Promise.all(snapshotPaths.map(readJson))
|
|
87
|
+
const date = manifest.metadata.serviceDate
|
|
88
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(utcOffsetHours)) throw new Error('A service date and UTC offset are required')
|
|
89
|
+
if (snapshots.some(snapshot => snapshot.metadata.serviceDate !== date)) throw new Error('Air snapshots must share the manifest service date')
|
|
90
|
+
const tracks = [...manifest.aircraft, ...snapshots.flatMap(snapshot => snapshot.tracks)]
|
|
91
|
+
const wanted = new Set(tracks.map(track => parseInt(track.icaoAddress ?? track.id.split('-')[0], 16)))
|
|
92
|
+
const records = new Map()
|
|
93
|
+
const files = (await readdir(heatmapDirectory)).filter(file => file.endsWith('.bin.ttf')).sort()
|
|
94
|
+
if (!files.length) throw new Error('No cached heatmap slices found')
|
|
95
|
+
const sources = []
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const raw = await readFile(join(heatmapDirectory, file))
|
|
98
|
+
const used = decodeAdsbHeatmap(gunzipSync(raw), {
|
|
99
|
+
serviceDate: date, utcOffsetHours, windowStart: 0, windowEnd: 86400, addresses: wanted,
|
|
100
|
+
}, (id, sample) => {
|
|
101
|
+
const samples = records.get(id) ?? []
|
|
102
|
+
sample[5] ??= samples.at(-1)?.[5] ?? ''
|
|
103
|
+
samples.push(sample)
|
|
104
|
+
records.set(id, samples)
|
|
105
|
+
})
|
|
106
|
+
if (used) sources.push({ file, sha256: createHash('sha256').update(raw).digest('hex') })
|
|
107
|
+
}
|
|
108
|
+
const csv = await readFile(airportCsvPath, 'utf8')
|
|
109
|
+
if (!sources.length) throw new Error('No same-date observations matched the study aircraft')
|
|
110
|
+
const airports = parseAirports(csv)
|
|
111
|
+
if (!airports.length) throw new Error('No usable airports in the reference CSV')
|
|
112
|
+
const segments = new Map([...records].map(([id, samples]) => [id, segmentsFor(samples).map(samples => ({
|
|
113
|
+
start: samples[0][0], end: samples.at(-1)[0], callsign: samples[0][5], ...inferAirEndpoints(samples, airports),
|
|
114
|
+
}))]))
|
|
115
|
+
const enrich = (track) => {
|
|
116
|
+
const candidates = (segments.get(parseInt(track.icaoAddress ?? track.id.split('-')[0], 16)) ?? [])
|
|
117
|
+
.filter(segment => segment.callsign && segment.callsign === track.callsign && segment.start <= track.start + 30 && segment.end >= track.end - 30)
|
|
118
|
+
const { origin: _origin, destination: _destination, ...rest } = track
|
|
119
|
+
if (candidates.length !== 1) return rest
|
|
120
|
+
const { origin, destination } = candidates[0]
|
|
121
|
+
return { ...rest, ...(origin ? { origin } : {}), ...(destination ? { destination } : {}) }
|
|
122
|
+
}
|
|
123
|
+
const metadata = {
|
|
124
|
+
serviceDate: date, method: 'observed-endpoint', note: 'Origin/destination inferred from full-trace endpoints within 6 km and 1500 ft above an airport; observed times, not schedules or confirmed flight plans.',
|
|
125
|
+
airportSource: 'https://ourairports.com/data/', airportLicense: 'Public Domain', airportSha256: createHash('sha256').update(csv).digest('hex'),
|
|
126
|
+
traceSource: manifest.metadata.sourceUrl, traceLicense: 'ODbL 1.0', utcOffsetHours, sourceFiles: sources,
|
|
127
|
+
}
|
|
128
|
+
const aircraft = manifest.aircraft.map(enrich)
|
|
129
|
+
const byId = new Map(aircraft.map(track => [track.id, track]))
|
|
130
|
+
const writes = []
|
|
131
|
+
for (const descriptor of manifest.chunks) {
|
|
132
|
+
const path = join(dirname(manifestPath), descriptor.path)
|
|
133
|
+
const chunk = await readJson(path)
|
|
134
|
+
chunk.tracks = chunk.tracks.map(track => {
|
|
135
|
+
const { origin: _origin, destination: _destination, ...rest } = track
|
|
136
|
+
const full = byId.get(track.id)
|
|
137
|
+
return { ...rest, ...(full?.origin ? { origin: full.origin } : {}), ...(full?.destination ? { destination: full.destination } : {}) }
|
|
138
|
+
})
|
|
139
|
+
const bytes = Buffer.from(`${JSON.stringify(chunk)}\n`)
|
|
140
|
+
if ('bytes' in descriptor) descriptor.bytes = bytes.length
|
|
141
|
+
if ('sha256' in descriptor) descriptor.sha256 = createHash('sha256').update(bytes).digest('hex')
|
|
142
|
+
writes.push([path, chunk])
|
|
143
|
+
}
|
|
144
|
+
writes.push([manifestPath, { ...manifest, metadata: { ...manifest.metadata, routeEnrichment: metadata }, aircraft }])
|
|
145
|
+
snapshots.forEach((snapshot, index) => writes.push([snapshotPaths[index], { ...snapshot, metadata: { ...snapshot.metadata, routeEnrichment: metadata }, tracks: snapshot.tracks.map(enrich) }]))
|
|
146
|
+
for (const [path, value] of writes) await writeFile(path, `${JSON.stringify(value)}\n`)
|
|
147
|
+
return { tracks: aircraft.length, origins: aircraft.filter(track => track.origin).length, destinations: aircraft.filter(track => track.destination).length }
|
|
148
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export interface DailyExpectation { serviceDate: string; deadlineAt: string; nextDeadlineAt: string }
|
|
2
|
+
/** Repeated local minutes use their later occurrence; missing minutes use the next valid minute. */
|
|
3
|
+
export function dailyFeedExpectation(schedule: { timeZone: string; deadline: string }, now?: number): DailyExpectation
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readOperationalTime } from './feed-observability.mjs'
|
|
2
|
+
|
|
3
|
+
const shift = (date, days) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86400000).toISOString().slice(0, 10)
|
|
4
|
+
function formatter(timeZone) {
|
|
5
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
6
|
+
}
|
|
7
|
+
function wall(format, ms) {
|
|
8
|
+
const p = Object.fromEntries(format.formatToParts(ms).map(p => [p.type, p.value]))
|
|
9
|
+
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`
|
|
10
|
+
}
|
|
11
|
+
/** Latest occurrence of a repeated local minute; first valid minute after a DST gap. */
|
|
12
|
+
function deadlineAt(date, deadline, format) {
|
|
13
|
+
const target = `${date}T${deadline}`, nominal = Date.parse(`${target}:00Z`)
|
|
14
|
+
let exact = null, next = null
|
|
15
|
+
for (let ms = nominal - 18 * 3600000; ms <= nominal + 18 * 3600000; ms += 60000) {
|
|
16
|
+
const local = wall(format, ms)
|
|
17
|
+
if (local === target) exact = ms
|
|
18
|
+
if (local.slice(0, 10) === date && local > target && (!next || local < next.local)) next = { local, ms }
|
|
19
|
+
}
|
|
20
|
+
const value = exact ?? next?.ms
|
|
21
|
+
if (value === undefined) throw new Error('Local deadline does not exist on this date')
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
/** Latest completed service date whose following-day deadline has passed. */
|
|
25
|
+
export function dailyFeedExpectation({ timeZone, deadline }, now = Date.now()) {
|
|
26
|
+
if (typeof timeZone !== 'string' || timeZone.length > 80 || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(deadline)) throw new Error('Invalid daily expectation')
|
|
27
|
+
readOperationalTime(new Date(now).toISOString())
|
|
28
|
+
const format = formatter(timeZone), today = wall(format, now).slice(0, 10)
|
|
29
|
+
const todayDeadline = deadlineAt(today, deadline, format), dueToday = now >= todayDeadline
|
|
30
|
+
const dueDate = dueToday ? today : shift(today, -1)
|
|
31
|
+
return { serviceDate: shift(dueDate, -1), deadlineAt: new Date(dueToday ? todayDeadline : deadlineAt(dueDate, deadline, format)).toISOString(),
|
|
32
|
+
nextDeadlineAt: new Date(dueToday ? deadlineAt(shift(today, 1), deadline, format) : todayDeadline).toISOString() }
|
|
33
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { FeedHealth, OperationalState } from './feed-observability.mjs'
|
|
2
|
+
export interface IncidentPolicy { openAfterSeconds: number; recoverAfterSeconds: number; retentionDays: number; maxEvents: number }
|
|
3
|
+
export declare const INCIDENT_POLICY: Readonly<IncidentPolicy>
|
|
4
|
+
export interface IncidentEvent {
|
|
5
|
+
id: string
|
|
6
|
+
incidentId: string
|
|
7
|
+
key: string
|
|
8
|
+
feedId: string | null
|
|
9
|
+
stageId: string
|
|
10
|
+
firstDetectedAt: string
|
|
11
|
+
at: string
|
|
12
|
+
transition: 'opened' | 'updated' | 'recovered'
|
|
13
|
+
state: OperationalState
|
|
14
|
+
reasons: string[]
|
|
15
|
+
}
|
|
16
|
+
export interface ActiveIncident {
|
|
17
|
+
incidentId: string
|
|
18
|
+
key: string
|
|
19
|
+
feedId: string | null
|
|
20
|
+
stageId: string
|
|
21
|
+
firstDetectedAt: string
|
|
22
|
+
openedAt: string
|
|
23
|
+
lastObservedAt: string
|
|
24
|
+
state: OperationalState
|
|
25
|
+
reasons: string[]
|
|
26
|
+
recoveryStartedAt?: string
|
|
27
|
+
recoveryProducerAt?: string
|
|
28
|
+
}
|
|
29
|
+
export interface IncidentState {
|
|
30
|
+
schemaVersion: 1
|
|
31
|
+
kind: 'feed-incident-state'
|
|
32
|
+
registryId: string
|
|
33
|
+
producerId: string
|
|
34
|
+
policy: IncidentPolicy
|
|
35
|
+
lastObservedAt: string
|
|
36
|
+
lastReportId: string
|
|
37
|
+
report: FeedHealth
|
|
38
|
+
active: Record<string, ActiveIncident>
|
|
39
|
+
pending: Record<string, { since: string }>
|
|
40
|
+
events: IncidentEvent[]
|
|
41
|
+
prunedEvents: number
|
|
42
|
+
}
|
|
43
|
+
export function readIncidentStore(root: string): Promise<IncidentState>
|
|
44
|
+
export function updateIncidentStore(root: string, registry: unknown, report: unknown, options?: { policy?: Partial<IncidentPolicy>; now?: number }): Promise<{ state: IncidentState; changed: boolean; events: IncidentEvent[] }>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, lstat, open, rename, rm } from 'node:fs/promises'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { hostname } from 'node:os'
|
|
5
|
+
import { readFeedRegistry, readFeedHealth, readOperationalTime } from './feed-observability.mjs'
|
|
6
|
+
import { readOperationalFile } from './operational-files.mjs'
|
|
7
|
+
|
|
8
|
+
const MAX_BYTES = 8 * 1024 * 1024
|
|
9
|
+
export const INCIDENT_POLICY = Object.freeze({ openAfterSeconds: 120, recoverAfterSeconds: 60, retentionDays: 180, maxEvents: 10000 })
|
|
10
|
+
const canonical = value => JSON.stringify(value, (_, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map(k => [k, v[k]])) : v)
|
|
11
|
+
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
12
|
+
const notInstrumented = new Set(['upload-evidence-unavailable', 'stage-evidence-unavailable', 'capture-threshold-unconfigured', 'normalization-threshold-unconfigured', 'analytics-threshold-unconfigured'])
|
|
13
|
+
function policy(value) {
|
|
14
|
+
const p = { ...INCIDENT_POLICY, ...value }
|
|
15
|
+
if (Object.keys(p).some(k => !Object.hasOwn(INCIDENT_POLICY, k)) || Object.values(p).some(n => !Number.isSafeInteger(n) || n < 0) || p.openAfterSeconds > 86400 || p.recoverAfterSeconds > 86400 || p.retentionDays < 1 || p.retentionDays > 3650 || p.maxEvents < 1 || p.maxEvents > 20000) throw new Error('Invalid incident policy')
|
|
16
|
+
return p
|
|
17
|
+
}
|
|
18
|
+
function validateState(state) {
|
|
19
|
+
if (!state || state.schemaVersion !== 1 || state.kind !== 'feed-incident-state' || typeof state.registryId !== 'string' || typeof state.producerId !== 'string') throw new Error('Invalid incident state')
|
|
20
|
+
policy(state.policy)
|
|
21
|
+
readOperationalTime(state.lastObservedAt)
|
|
22
|
+
if (!/^[a-f0-9]{64}$/.test(state.lastReportId) || !Array.isArray(state.events) || state.events.length > state.policy.maxEvents) throw new Error('Invalid incident cursor/history')
|
|
23
|
+
for (const field of ['active', 'pending']) if (!state[field] || typeof state[field] !== 'object' || Array.isArray(state[field]) || Object.keys(state[field]).length > 4096) throw new Error('Invalid incident index')
|
|
24
|
+
if (!Number.isSafeInteger(state.prunedEvents) || state.prunedEvents < 0) throw new Error('Invalid retention counter')
|
|
25
|
+
for (const event of state.events) {
|
|
26
|
+
if (!/^[a-f0-9]{64}$/.test(event.id) || !/^[a-f0-9]{64}$/.test(event.incidentId) || !['opened', 'updated', 'recovered'].includes(event.transition)) throw new Error('Invalid incident event')
|
|
27
|
+
readOperationalTime(event.at)
|
|
28
|
+
}
|
|
29
|
+
if (new Set(state.events.map(e => e.id)).size !== state.events.length) throw new Error('Duplicate incident event')
|
|
30
|
+
return state
|
|
31
|
+
}
|
|
32
|
+
/** Reads the last atomically committed state; corruption never initializes a new history. */
|
|
33
|
+
export async function readIncidentStore(root) {
|
|
34
|
+
const wrapper = JSON.parse(await readOperationalFile(join(root, 'state.json'), MAX_BYTES))
|
|
35
|
+
if (wrapper.sha256 !== hash(canonical(wrapper.state))) throw new Error('Incident state checksum mismatch')
|
|
36
|
+
return validateState(wrapper.state)
|
|
37
|
+
}
|
|
38
|
+
function conditions(report, registry) {
|
|
39
|
+
const entries = [], add = (feedId, stageId, disposition, state, reasons) => {
|
|
40
|
+
entries.push({ key: JSON.stringify([report.producerId, feedId, stageId]), feedId, stageId, disposition, state, reasons: [...reasons].sort() })
|
|
41
|
+
}
|
|
42
|
+
const available = report.telemetry.state === 'current'
|
|
43
|
+
add(null, 'telemetry', available ? 'healthy' : 'problem', available ? 'healthy' : 'unknown', available ? [] : [`telemetry-${report.telemetry.state}`])
|
|
44
|
+
add(null, 'capacity', !available ? 'hold' : report.capacity.state === 'healthy' ? 'healthy' : 'problem', report.capacity.state, report.capacity.reasons)
|
|
45
|
+
for (const feed of report.feeds) {
|
|
46
|
+
const definition = registry.feeds.find(f => f.id === feed.feedId)
|
|
47
|
+
const actionable = stage => stage.state === 'degraded' || stage.state === 'unknown' && !stage.reasons.some(r => notInstrumented.has(r))
|
|
48
|
+
const blocked = id => definition.stages.find(s => s.id === id).dependsOn.some(parent => actionable(feed.stages.find(s => s.stageId === parent)) || blocked(parent))
|
|
49
|
+
for (const stage of feed.stages) {
|
|
50
|
+
let disposition = !available || feed.configuredState !== 'active' ? 'hold' : stage.state === 'healthy' ? 'healthy' : actionable(stage) ? 'problem' : 'hold'
|
|
51
|
+
if (disposition === 'problem' && (blocked(stage.stageId) || stage.reasons.includes('host-storage-blocked'))) disposition = 'hold'
|
|
52
|
+
add(feed.feedId, stage.stageId, disposition, stage.state, stage.reasons)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return entries
|
|
56
|
+
}
|
|
57
|
+
function advance(previous, report, registry, p) {
|
|
58
|
+
const at = report.observedAt, now = Date.parse(at), reportId = hash(canonical(report))
|
|
59
|
+
if (previous) {
|
|
60
|
+
if (previous.registryId !== registry.id || previous.producerId !== report.producerId || canonical(previous.policy) !== canonical(p)) throw new Error('Store identity/policy mismatch')
|
|
61
|
+
const order = now - Date.parse(previous.lastObservedAt)
|
|
62
|
+
if (order < 0 || order === 0 && previous.lastReportId !== reportId) throw new Error('Out-of-order or conflicting observation')
|
|
63
|
+
if (order === 0) return { state: previous, changed: false, events: [] }
|
|
64
|
+
}
|
|
65
|
+
const state = previous ? structuredClone(previous) : { schemaVersion: 1, kind: 'feed-incident-state', registryId: registry.id, producerId: report.producerId, policy: p, active: {}, pending: {}, events: [], prunedEvents: 0 }
|
|
66
|
+
const transitions = []
|
|
67
|
+
const emit = (incident, transition, health, reasons) => {
|
|
68
|
+
const event = { incidentId: incident.incidentId, key: incident.key, feedId: incident.feedId, stageId: incident.stageId, firstDetectedAt: incident.firstDetectedAt, at, transition, state: health, reasons }
|
|
69
|
+
transitions.push({ id: hash(canonical(event)), ...event })
|
|
70
|
+
}
|
|
71
|
+
const observedKeys = new Set()
|
|
72
|
+
for (const condition of conditions(report, registry)) {
|
|
73
|
+
const { key, disposition, state: health, reasons, feedId, stageId } = condition
|
|
74
|
+
observedKeys.add(key)
|
|
75
|
+
let incident = state.active[key]
|
|
76
|
+
if (disposition === 'problem') {
|
|
77
|
+
if (!incident) {
|
|
78
|
+
const candidate = state.pending[key] ?? { since: at }
|
|
79
|
+
state.pending[key] = candidate
|
|
80
|
+
if (now - Date.parse(candidate.since) < p.openAfterSeconds * 1000) continue
|
|
81
|
+
incident = { key, feedId, stageId, incidentId: hash(canonical([key, candidate.since])), firstDetectedAt: candidate.since, openedAt: at, lastObservedAt: at, state: health, reasons }
|
|
82
|
+
state.active[key] = incident; delete state.pending[key]
|
|
83
|
+
emit(incident, 'opened', health, reasons)
|
|
84
|
+
} else {
|
|
85
|
+
if (incident.state !== health || canonical(incident.reasons) !== canonical(reasons)) emit(incident, 'updated', health, reasons)
|
|
86
|
+
incident.state = health; incident.reasons = reasons; incident.lastObservedAt = at
|
|
87
|
+
}
|
|
88
|
+
delete incident.recoveryStartedAt; delete incident.recoveryProducerAt
|
|
89
|
+
} else {
|
|
90
|
+
delete state.pending[key]
|
|
91
|
+
if (!incident) continue
|
|
92
|
+
if (disposition === 'hold') { delete incident.recoveryStartedAt; delete incident.recoveryProducerAt; continue }
|
|
93
|
+
incident.recoveryStartedAt ??= at
|
|
94
|
+
incident.recoveryProducerAt ??= report.producerGeneratedAt
|
|
95
|
+
// Fresh observer timestamps alone cannot make a replayed producer report confirm recovery.
|
|
96
|
+
const fresh = p.recoverAfterSeconds === 0 || Date.parse(report.producerGeneratedAt) > Date.parse(incident.recoveryProducerAt)
|
|
97
|
+
if (fresh && now - Date.parse(incident.recoveryStartedAt) >= p.recoverAfterSeconds * 1000) {
|
|
98
|
+
emit(incident, 'recovered', 'healthy', reasons)
|
|
99
|
+
delete state.active[key]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Removed/paused scopes cannot silently recover existing incidents.
|
|
104
|
+
for (const key of Object.keys(state.pending)) if (!observedKeys.has(key)) delete state.pending[key]
|
|
105
|
+
for (const [key, incident] of Object.entries(state.active)) if (!observedKeys.has(key)) { delete incident.recoveryStartedAt; delete incident.recoveryProducerAt }
|
|
106
|
+
state.events.push(...transitions)
|
|
107
|
+
const cutoff = now - p.retentionDays * 86400000
|
|
108
|
+
const expired = new Set(state.events.filter(e => e.transition === 'recovered' && Date.parse(e.at) < cutoff).map(e => e.incidentId))
|
|
109
|
+
for (const incident of Object.values(state.active)) expired.delete(incident.incidentId)
|
|
110
|
+
const before = state.events.length
|
|
111
|
+
state.events = state.events.filter(e => !expired.has(e.incidentId))
|
|
112
|
+
state.prunedEvents += before - state.events.length
|
|
113
|
+
state.lastObservedAt = at; state.lastReportId = reportId; state.report = report
|
|
114
|
+
if (state.events.length > p.maxEvents) throw new Error('Incident history event budget exhausted; archive before continuing')
|
|
115
|
+
return { state: validateState(state), changed: true, events: transitions }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Single-writer transaction: checksum + fsync + atomic rename. A leftover lock fails closed. */
|
|
119
|
+
export async function updateIncidentStore(root, registryValue, reportValue, { policy: requestedPolicy, now = Date.now() } = {}) {
|
|
120
|
+
const registry = readFeedRegistry(registryValue), report = readFeedHealth(reportValue, registry), p = policy(requestedPolicy)
|
|
121
|
+
if (!Number.isFinite(now) || now - Date.parse(report.observedAt) > registry.heartbeatMaxAgeSeconds * 1000 || Date.parse(report.observedAt) - now > registry.clockSkewSeconds * 1000) throw new Error('Incident observation is not current')
|
|
122
|
+
root = resolve(root)
|
|
123
|
+
// Serialize directory creation as well as updates: a second initializer must not
|
|
124
|
+
// take the writer lock before the directory creator has committed its first state.
|
|
125
|
+
const lockPath = `${root}.lock`, lock = await open(lockPath, 'wx', 0o600)
|
|
126
|
+
const temporary = join(root, `.state-${randomUUID()}`)
|
|
127
|
+
try {
|
|
128
|
+
await lock.writeFile(JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date(now).toISOString() }))
|
|
129
|
+
await lock.sync()
|
|
130
|
+
let created = false
|
|
131
|
+
try { await mkdir(root, { mode: 0o700 }); created = true } catch (error) { if (error.code !== 'EEXIST') throw error }
|
|
132
|
+
if (!(await lstat(root)).isDirectory()) throw new Error('Incident store must be a real directory')
|
|
133
|
+
const previous = created ? null : await readIncidentStore(root)
|
|
134
|
+
const update = advance(previous, report, registry, p)
|
|
135
|
+
if (!update.changed) return update
|
|
136
|
+
const content = canonical({ sha256: hash(canonical(update.state)), state: update.state }) + '\n'
|
|
137
|
+
if (Buffer.byteLength(content) > MAX_BYTES) throw new Error('Incident history byte budget exhausted; archive before continuing')
|
|
138
|
+
const output = await open(temporary, 'wx', 0o600)
|
|
139
|
+
try { await output.writeFile(content); await output.sync() } finally { await output.close() }
|
|
140
|
+
await rename(temporary, join(root, 'state.json'))
|
|
141
|
+
const directory = await open(root, 'r')
|
|
142
|
+
try { await directory.sync() } finally { await directory.close() }
|
|
143
|
+
return update
|
|
144
|
+
} finally {
|
|
145
|
+
await lock.close()
|
|
146
|
+
try { await rm(temporary, { force: true }) } finally { await rm(lockPath) }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type OperationalState = 'healthy' | 'degraded' | 'unknown' | 'waiting'
|
|
2
|
+
export type ConfiguredState = 'active' | 'paused' | 'setup-required'
|
|
3
|
+
export type FeedSchedule = { kind: 'interval'; seconds: number } | { kind: 'daily'; timeZone: string; deadline: string } | { kind: 'manual' | 'push' }
|
|
4
|
+
export interface FeedStage {
|
|
5
|
+
id: string
|
|
6
|
+
dependsOn: string[]
|
|
7
|
+
maxAgeSeconds: number | null
|
|
8
|
+
sourceMaxAgeSeconds?: number
|
|
9
|
+
}
|
|
10
|
+
export interface FeedDefinition {
|
|
11
|
+
id: string
|
|
12
|
+
label: string
|
|
13
|
+
producerId: string
|
|
14
|
+
producerFeed: string
|
|
15
|
+
sourceId: string
|
|
16
|
+
owner: string
|
|
17
|
+
runbook: string
|
|
18
|
+
state: ConfiguredState
|
|
19
|
+
schedule: FeedSchedule
|
|
20
|
+
stages: FeedStage[]
|
|
21
|
+
}
|
|
22
|
+
export interface FeedRegistry {
|
|
23
|
+
schemaVersion: 1
|
|
24
|
+
kind: 'feed-registry'
|
|
25
|
+
id: string
|
|
26
|
+
heartbeatMaxAgeSeconds: number
|
|
27
|
+
clockSkewSeconds: number
|
|
28
|
+
feeds: FeedDefinition[]
|
|
29
|
+
}
|
|
30
|
+
export interface FeedEvent {
|
|
31
|
+
schemaVersion: 1
|
|
32
|
+
kind: 'feed-event'
|
|
33
|
+
eventId: string
|
|
34
|
+
feedId: string
|
|
35
|
+
stageId: string
|
|
36
|
+
runId: string
|
|
37
|
+
parentRunId?: string
|
|
38
|
+
producerId: string
|
|
39
|
+
revision: string
|
|
40
|
+
occurredAt: string
|
|
41
|
+
recordedAt: string
|
|
42
|
+
type: 'started' | 'succeeded' | 'failed' | 'heartbeat'
|
|
43
|
+
reasons: string[]
|
|
44
|
+
metrics: Record<string, number>
|
|
45
|
+
}
|
|
46
|
+
export interface StageHealth {
|
|
47
|
+
stageId: string
|
|
48
|
+
state: OperationalState
|
|
49
|
+
reasons: string[]
|
|
50
|
+
timestamps: Partial<Record<'receivedAt' | 'sourceGeneratedAt' | 'latestPeriodEnd' | 'lastSuccessAt' | 'lastAttemptAt', string>>
|
|
51
|
+
metrics: Record<string, number>
|
|
52
|
+
}
|
|
53
|
+
export interface FeedHealth {
|
|
54
|
+
schemaVersion: 1
|
|
55
|
+
kind: 'feed-health'
|
|
56
|
+
registryId: string
|
|
57
|
+
producerId: string
|
|
58
|
+
observedAt: string
|
|
59
|
+
producerGeneratedAt: string | null
|
|
60
|
+
telemetry: { state: 'current' | 'stale' | 'missing' | 'invalid'; ageSeconds: number | null }
|
|
61
|
+
feeds: { feedId: string; configuredState: ConfiguredState; state: OperationalState | Exclude<ConfiguredState, 'active'>; stages: StageHealth[] }[]
|
|
62
|
+
capacity: { state: Exclude<OperationalState, 'waiting'>; reasons: string[]; metrics: Record<string, number> }
|
|
63
|
+
}
|
|
64
|
+
export function readOperationalTime(value: unknown): string
|
|
65
|
+
export function readFeedRegistry(value: unknown): FeedRegistry
|
|
66
|
+
export function readFeedEvent(value: unknown, registry: unknown): FeedEvent
|
|
67
|
+
export function readFeedHealth(value: unknown, registry: unknown): FeedHealth
|
|
68
|
+
export function summarizeFeedStages(stages: readonly Pick<StageHealth, 'state'>[]): OperationalState
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Versioned, JSON-only contracts. No I/O, clock, Node imports or provider calls. */
|
|
2
|
+
const own = (o, k) => Object.hasOwn(o, k)
|
|
3
|
+
function object(value, keys, label) {
|
|
4
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid ${label}`)
|
|
5
|
+
if (Object.keys(value).some(k => !keys.includes(k))) throw new Error(`Unknown ${label} field`)
|
|
6
|
+
return value
|
|
7
|
+
}
|
|
8
|
+
function text(value, label, limit = 160) {
|
|
9
|
+
if (typeof value !== 'string' || !value.trim() || value.length > limit || [...value].some(c => c.charCodeAt(0) < 32)) throw new Error(`Invalid ${label}`)
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
function id(value) {
|
|
13
|
+
if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._:-]{0,127}$/.test(value)) throw new Error('Invalid identifier')
|
|
14
|
+
return value
|
|
15
|
+
}
|
|
16
|
+
function choice(value, values) {
|
|
17
|
+
if (!values.includes(value)) throw new Error('Invalid enum value')
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
function number(value, max = Number.MAX_SAFE_INTEGER) {
|
|
21
|
+
if (!Number.isFinite(value) || value < 0 || value > max) throw new Error('Invalid nonnegative number')
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
function seconds(value) {
|
|
25
|
+
if (!Number.isInteger(value) || value < 1 || value > 366 * 86400) throw new Error('Invalid duration')
|
|
26
|
+
return value
|
|
27
|
+
}
|
|
28
|
+
function array(value, max, min = 0) {
|
|
29
|
+
if (!Array.isArray(value) || value.length < min || value.length > max) throw new Error('Invalid bounded array')
|
|
30
|
+
return value
|
|
31
|
+
}
|
|
32
|
+
function unique(values) {
|
|
33
|
+
if (new Set(values).size !== values.length) throw new Error('Duplicate identifier')
|
|
34
|
+
return values
|
|
35
|
+
}
|
|
36
|
+
export function readOperationalTime(value) {
|
|
37
|
+
if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value)) throw new Error('Invalid UTC timestamp')
|
|
38
|
+
const ms = Date.parse(value)
|
|
39
|
+
if (!Number.isFinite(ms) || new Date(ms).toISOString() !== (value.length === 20 ? value.replace('Z', '.000Z') : value)) throw new Error('Invalid UTC timestamp')
|
|
40
|
+
return new Date(ms).toISOString()
|
|
41
|
+
}
|
|
42
|
+
function header(value, kind, keys) {
|
|
43
|
+
object(value, ['schemaVersion', 'kind', ...keys], kind)
|
|
44
|
+
if (value.schemaVersion !== 1 || value.kind !== kind) throw new Error('Unsupported operational schema')
|
|
45
|
+
}
|
|
46
|
+
function schedule(value) {
|
|
47
|
+
object(value, ['kind', 'seconds', 'timeZone', 'deadline'], 'schedule')
|
|
48
|
+
if (value.kind === 'interval') {
|
|
49
|
+
object(value, ['kind', 'seconds'], 'interval schedule')
|
|
50
|
+
return { kind: value.kind, seconds: seconds(value.seconds) }
|
|
51
|
+
}
|
|
52
|
+
if (value.kind === 'daily') {
|
|
53
|
+
object(value, ['kind', 'timeZone', 'deadline'], 'daily schedule')
|
|
54
|
+
text(value.timeZone, 'timezone', 80)
|
|
55
|
+
new Intl.DateTimeFormat('en', { timeZone: value.timeZone })
|
|
56
|
+
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value.deadline)) throw new Error('Invalid daily deadline')
|
|
57
|
+
return { kind: value.kind, timeZone: value.timeZone, deadline: value.deadline }
|
|
58
|
+
}
|
|
59
|
+
object(value, ['kind'], 'manual schedule')
|
|
60
|
+
return { kind: choice(value.kind, ['manual', 'push']) }
|
|
61
|
+
}
|
|
62
|
+
export function readFeedRegistry(value) {
|
|
63
|
+
header(value, 'feed-registry', ['id', 'heartbeatMaxAgeSeconds', 'clockSkewSeconds', 'feeds'])
|
|
64
|
+
const feeds = array(value.feeds, 128, 1).map(f => {
|
|
65
|
+
object(f, ['id', 'label', 'producerId', 'producerFeed', 'sourceId', 'owner', 'runbook', 'state', 'schedule', 'stages'], 'feed')
|
|
66
|
+
const stages = array(f.stages, 16, 1).map(s => {
|
|
67
|
+
object(s, ['id', 'dependsOn', 'maxAgeSeconds', 'sourceMaxAgeSeconds'], 'stage')
|
|
68
|
+
return { id: id(s.id), dependsOn: unique(array(s.dependsOn, 16).map(id)),
|
|
69
|
+
maxAgeSeconds: s.maxAgeSeconds === null ? null : seconds(s.maxAgeSeconds),
|
|
70
|
+
...(own(s, 'sourceMaxAgeSeconds') ? { sourceMaxAgeSeconds: seconds(s.sourceMaxAgeSeconds) } : {}) }
|
|
71
|
+
})
|
|
72
|
+
unique(stages.map(s => s.id))
|
|
73
|
+
const visiting = new Set(), visited = new Set()
|
|
74
|
+
const visit = stageId => {
|
|
75
|
+
if (visiting.has(stageId)) throw new Error('Cyclic stage dependency')
|
|
76
|
+
if (visited.has(stageId)) return
|
|
77
|
+
const stage = stages.find(s => s.id === stageId)
|
|
78
|
+
if (!stage) throw new Error('Unknown stage dependency')
|
|
79
|
+
visiting.add(stageId); stage.dependsOn.forEach(visit); visiting.delete(stageId); visited.add(stageId)
|
|
80
|
+
}
|
|
81
|
+
stages.forEach(s => visit(s.id))
|
|
82
|
+
return { id: id(f.id), label: text(f.label, 'label'), producerId: id(f.producerId), producerFeed: id(f.producerFeed), sourceId: id(f.sourceId),
|
|
83
|
+
owner: text(f.owner, 'owner'), runbook: text(f.runbook, 'runbook', 300), state: choice(f.state, ['active', 'paused', 'setup-required']), schedule: schedule(f.schedule), stages }
|
|
84
|
+
})
|
|
85
|
+
unique(feeds.map(f => f.id)); unique(feeds.map(f => `${f.producerId}/${f.producerFeed}`))
|
|
86
|
+
return { schemaVersion: 1, kind: 'feed-registry', id: id(value.id), heartbeatMaxAgeSeconds: seconds(value.heartbeatMaxAgeSeconds),
|
|
87
|
+
clockSkewSeconds: number(value.clockSkewSeconds, 300), feeds }
|
|
88
|
+
}
|
|
89
|
+
function metrics(value) {
|
|
90
|
+
object(value, Object.keys(value ?? {}), 'metrics')
|
|
91
|
+
if (Object.keys(value).length > 32) throw new Error('Too many metrics')
|
|
92
|
+
return Object.fromEntries(Object.entries(value).map(([key, v]) => [id(key), number(v)]))
|
|
93
|
+
}
|
|
94
|
+
function reasons(value) { return unique(array(value, 16).map(id)) }
|
|
95
|
+
function registered(registry, feedId, stageId) {
|
|
96
|
+
const feed = registry.feeds.find(f => f.id === feedId)
|
|
97
|
+
if (!feed?.stages.some(s => s.id === stageId)) throw new Error('Unregistered feed or stage')
|
|
98
|
+
}
|
|
99
|
+
export function readFeedEvent(value, registryValue) {
|
|
100
|
+
header(value, 'feed-event', ['eventId', 'feedId', 'stageId', 'runId', 'parentRunId', 'producerId', 'revision', 'occurredAt', 'recordedAt', 'type', 'reasons', 'metrics'])
|
|
101
|
+
const registry = readFeedRegistry(registryValue)
|
|
102
|
+
registered(registry, value.feedId, value.stageId)
|
|
103
|
+
if (registry.feeds.find(f => f.id === value.feedId).producerId !== value.producerId) throw new Error('Producer does not own feed')
|
|
104
|
+
return { schemaVersion: 1, kind: 'feed-event', eventId: id(value.eventId), feedId: id(value.feedId), stageId: id(value.stageId), runId: id(value.runId),
|
|
105
|
+
...(own(value, 'parentRunId') ? { parentRunId: id(value.parentRunId) } : {}), producerId: id(value.producerId), revision: id(value.revision),
|
|
106
|
+
occurredAt: readOperationalTime(value.occurredAt), recordedAt: readOperationalTime(value.recordedAt),
|
|
107
|
+
type: choice(value.type, ['started', 'succeeded', 'failed', 'heartbeat']), reasons: reasons(value.reasons), metrics: metrics(value.metrics) }
|
|
108
|
+
}
|
|
109
|
+
export function summarizeFeedStages(stages) {
|
|
110
|
+
return ['degraded', 'unknown', 'waiting'].find(state => stages.some(s => s.state === state)) ?? 'healthy'
|
|
111
|
+
}
|
|
112
|
+
export function readFeedHealth(value, registryValue) {
|
|
113
|
+
header(value, 'feed-health', ['registryId', 'producerId', 'observedAt', 'producerGeneratedAt', 'telemetry', 'feeds', 'capacity'])
|
|
114
|
+
const registry = readFeedRegistry(registryValue)
|
|
115
|
+
if (value.registryId !== registry.id) throw new Error('Registry does not match health report')
|
|
116
|
+
const producerId = id(value.producerId), expected = registry.feeds.filter(f => f.producerId === producerId)
|
|
117
|
+
if (!expected.length) throw new Error('Unregistered producer')
|
|
118
|
+
const observedAt = readOperationalTime(value.observedAt)
|
|
119
|
+
const producerGeneratedAt = value.producerGeneratedAt === null ? null : readOperationalTime(value.producerGeneratedAt)
|
|
120
|
+
object(value.telemetry, ['state', 'ageSeconds'], 'telemetry')
|
|
121
|
+
const telemetry = { state: choice(value.telemetry.state, ['current', 'stale', 'missing', 'invalid']), ageSeconds: value.telemetry.ageSeconds === null ? null : number(value.telemetry.ageSeconds) }
|
|
122
|
+
if (producerGeneratedAt === null) {
|
|
123
|
+
if (!['missing', 'invalid'].includes(telemetry.state) || telemetry.ageSeconds !== null) throw new Error('Missing producer clock cannot establish freshness')
|
|
124
|
+
} else {
|
|
125
|
+
const elapsed = (Date.parse(observedAt) - Date.parse(producerGeneratedAt)) / 1000
|
|
126
|
+
const state = elapsed < -registry.clockSkewSeconds ? 'invalid' : elapsed > registry.heartbeatMaxAgeSeconds ? 'stale' : 'current'
|
|
127
|
+
if (telemetry.state !== state || telemetry.ageSeconds === null || Math.abs(telemetry.ageSeconds - Math.max(0, elapsed)) > 0.000001) throw new Error('Telemetry does not match report clocks')
|
|
128
|
+
}
|
|
129
|
+
const feeds = array(value.feeds, 128, 1).map(f => {
|
|
130
|
+
object(f, ['feedId', 'configuredState', 'state', 'stages'], 'feed health')
|
|
131
|
+
const entry = expected.find(e => e.id === f.feedId)
|
|
132
|
+
if (!entry || f.configuredState !== entry.state) throw new Error('Feed configuration does not match registry')
|
|
133
|
+
const stages = array(f.stages, 16, 1).map(s => {
|
|
134
|
+
object(s, ['stageId', 'state', 'reasons', 'timestamps', 'metrics'], 'stage health')
|
|
135
|
+
registered(registry, f.feedId, s.stageId)
|
|
136
|
+
object(s.timestamps, ['receivedAt', 'sourceGeneratedAt', 'latestPeriodEnd', 'lastSuccessAt', 'lastAttemptAt'], 'timestamps')
|
|
137
|
+
return { stageId: id(s.stageId), state: choice(s.state, ['healthy', 'degraded', 'unknown', 'waiting']), reasons: reasons(s.reasons),
|
|
138
|
+
timestamps: Object.fromEntries(Object.entries(s.timestamps).map(([k, v]) => [k, readOperationalTime(v)])), metrics: metrics(s.metrics) }
|
|
139
|
+
})
|
|
140
|
+
unique(stages.map(s => s.stageId))
|
|
141
|
+
if (stages.length !== entry.stages.length) throw new Error('Incomplete stage report')
|
|
142
|
+
const state = entry.state === 'active' ? summarizeFeedStages(stages) : entry.state
|
|
143
|
+
if (f.state !== state) throw new Error('Inconsistent feed summary')
|
|
144
|
+
if (telemetry.state !== 'current' && stages.some(s => s.state !== 'unknown')) throw new Error('Unavailable telemetry cannot assert stage health')
|
|
145
|
+
return { feedId: f.feedId, configuredState: entry.state, state, stages }
|
|
146
|
+
})
|
|
147
|
+
unique(feeds.map(f => f.feedId))
|
|
148
|
+
if (feeds.length !== expected.length) throw new Error('Incomplete producer report')
|
|
149
|
+
object(value.capacity, ['state', 'reasons', 'metrics'], 'capacity')
|
|
150
|
+
const capacity = { state: choice(value.capacity.state, ['healthy', 'degraded', 'unknown']), reasons: reasons(value.capacity.reasons), metrics: metrics(value.capacity.metrics) }
|
|
151
|
+
if (telemetry.state !== 'current' && capacity.state !== 'unknown') throw new Error('Unavailable telemetry cannot assert capacity health')
|
|
152
|
+
return { schemaVersion: 1, kind: 'feed-health', registryId: registry.id, producerId, observedAt,
|
|
153
|
+
producerGeneratedAt, telemetry, feeds, capacity }
|
|
154
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SERVICE_TIMEZONE } from './uk-service-day.mjs'
|
|
2
|
+
import type { FeedHealth, StageHealth } from './feed-observability.mjs'
|
|
3
|
+
import type { DailyExpectation } from './feed-expectations.mjs'
|
|
4
|
+
export type ObserverCredential = string | { bearer?: string; accessClientId?: string; accessClientSecret?: string }
|
|
5
|
+
export interface ObserverConfig {
|
|
6
|
+
schemaVersion: 1
|
|
7
|
+
kind: 'feed-observer-config'
|
|
8
|
+
producerId: string
|
|
9
|
+
healthUrl: string
|
|
10
|
+
consumers: { feedId: string; manifestUrl: string; operator: string; deadline: string; timeZone: typeof SERVICE_TIMEZONE }[]
|
|
11
|
+
}
|
|
12
|
+
export interface ObserverCheck {
|
|
13
|
+
schemaVersion: 1
|
|
14
|
+
kind: 'feed-observer-check'
|
|
15
|
+
startedAt: string
|
|
16
|
+
completedAt: string
|
|
17
|
+
sourceError: string | null
|
|
18
|
+
requests: number
|
|
19
|
+
bytes: number
|
|
20
|
+
report: FeedHealth
|
|
21
|
+
consumers: (StageHealth & { feedId: string; expectation: DailyExpectation })[]
|
|
22
|
+
}
|
|
23
|
+
export declare const OBSERVER_LIMITS: Readonly<{ requests: number; totalBytes: number; timeoutMs: number; totalMs: number; healthBytes: number; manifestBytes: number; releaseBytes: number }>
|
|
24
|
+
export function observeFeeds(registry: unknown, config: unknown, options?: {
|
|
25
|
+
fetchImpl?: typeof fetch
|
|
26
|
+
clock?: () => number
|
|
27
|
+
tokens?: { health?: ObserverCredential; consumers?: Record<string, ObserverCredential> }
|
|
28
|
+
limits?: typeof OBSERVER_LIMITS
|
|
29
|
+
}): Promise<ObserverCheck>
|
|
30
|
+
export function assessObserverCheck(registry: unknown, checked: ObserverCheck | null | undefined, options?: { now?: number; maxAgeSeconds?: number }): { state: 'healthy' | 'degraded' | 'unknown'; reasons: string[] }
|