// INSTALLED != RUNNING — the staleness classifier behind `pgh app stale`.
//
// `pgh app status` reports the INSTALL TREE's version, which says nothing about the code a live process is
// executing: `pgh app update` materializes a new tree at the SAME path while the old process keeps running
// the old code, so reading a version back off disk cannot detect it — the path holds the new tree either
// way. This bit the fleet twice in one day (2026-07-27), once on the very box under investigation, and the
// check was re-derived by hand each time (`ps -o etimes` against the install tree).
//
// What IS decidable: a process that started BEFORE the current tree was installed cannot be running it.
// The classifier is separated from the I/O (same shape as renderVerboseStatus) so every branch is covered
// here, including the one that a live run caught and a naive implementation gets wrong.
import test from 'brittle'
import { classifyStale } from '../lib/commands/app.js'
const iso = (ms) => new Date(ms).toISOString()
const T = Date.parse('2026-07-28T12:00:00.000Z')
test('a process started BEFORE the current tree was installed is stale', (t) => {
const r = classifyStale({
running: true,
installedAt: iso(T),
startedAt: iso(T - 3 * 24 * 3600 * 1000), // days-old process, freshly installed tree
version: '0.3.65',
now: T
})
t.is(r.stale, true, 'flagged stale')
t.ok(/BEFORE the current tree was installed/.test(r.staleReason), 'the reason states the comparison that was made')
t.ok(/0\.3\.65/.test(r.staleReason), 'and names the version it is NOT running')
t.ok(/restart/.test(r.staleReason), 'and says what to do about it')
t.is(r.skewSeconds, 3 * 24 * 3600, 'the skew is reported in full')
})
test('the install/respawn race is NOT stale', (t) => {
// Measured on a healthy tabertron app: the OTA path exits the app (75), the supervisor respawns it, and
// the manifest write races the respawn — processStartedAt landed 1.7s BEFORE installedAt. The first
// version of this check reported that as stale, which would have sent an operator restarting a perfectly
// current app. This is the regression guard for that.
const r = classifyStale({ running: true, installedAt: iso(T), startedAt: iso(T - 2000), version: '0.2.4', now: T })
t.is(r.stale, false, 'a 2s skew is the install/respawn race, not old code')
t.is(r.staleReason, null, 'and is not reported as a problem')
t.is(r.skewSeconds, 2, 'but the skew is still VISIBLE rather than hidden behind the threshold')
})
test('a process started after its tree is current', (t) => {
const r = classifyStale({ running: true, installedAt: iso(T - 3600 * 1000), startedAt: iso(T - 60 * 1000), version: '0.3.83', now: T })
t.is(r.stale, false, 'not stale')
t.is(r.staleReason, null, 'no reason to report')
t.is(r.uptimeSeconds, 60, 'uptime is reported')
})
test('unknowns are never reported as healthy', (t) => {
// The whole failure mode here is a green surface over old code, so "I could not tell" must not render
// as "fine". null is a distinct third state, and cmdStale flags it alongside true.
const noInstall = classifyStale({ running: true, installedAt: null, startedAt: iso(T - 1000), now: T })
t.is(noInstall.stale, null, 'no installedAt -> unknown, NOT false')
t.ok(/installedAt/.test(noInstall.staleReason), 'and says which input was missing')
const noStart = classifyStale({ running: true, installedAt: iso(T), startedAt: null, now: T })
t.is(noStart.stale, null, 'no process start time -> unknown, NOT false')
t.ok(/process start time/.test(noStart.staleReason), 'and says which input was missing')
})
test('an app that is not running is not stale', (t) => {
const r = classifyStale({ running: false, installedAt: iso(T), startedAt: null, now: T })
t.is(r.stale, false, 'nothing is running, so nothing is running old code')
t.is(r.staleReason, 'not running', 'stated plainly rather than left blank')
t.is(r.uptimeSeconds, null, 'and no uptime is invented')
})