OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / app-state.js

329 lines · javascript ref:
/**
 * App lifecycle state: pid files, state files, log files, user overrides,
 * env merging. Shared by `pgh app start|stop|status|log` and the
 * supervisor child.
 *
 * ── Path layout ─────────────────────────────────────────────────────
 *
 * Install-time data (rewritten by every `pgh app install`):
 *   <STORE_ROOT>/apps/<name>/        ← installPath, rmSync'd on reinstall
 *   <STORE_ROOT>/apps/<name>/.pgh-install.json
 *
 * Run-time state (must survive reinstall):
 *   <RUN_ROOT>/<name>/app.pid
 *   <RUN_ROOT>/<name>/app.state.json
 *   <RUN_ROOT>/<name>/app.log
 *
 * RUN_ROOT defaults to `$XDG_RUNTIME_DIR/pgh` when set, else
 * `~/.config/pgh/run`. STORE_ROOT comes from `$PGH_STORE_ROOT` or the
 * platform default in store.js.
 *
 * Pre-0.1.23 versions stored pid/state/log INSIDE installPath, which
 * meant `pgh app install`'s rmSync wiped them — leaving `pgh app status`
 * reporting `running: false` while the supervisor + daemon were still
 * alive. The relocation fixes that. Legacy pid/state files are read as
 * a fallback for one boot cycle so already-running apps keep working
 * across the upgrade.
 */

import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import { homedir } from '#node:os'

import { DEFAULT_ROOT as STORE_ROOT_DEFAULT } from './store.js'

const USER_CONFIG = path.join(homedir(), '.config', 'pgh', 'apps.json')

const LOG_ROTATE_BYTES = 10 * 1024 * 1024
const LOG_ROTATE_KEEP = 5

// ── Paths (env-aware at call-time so env overrides take effect) ─────

function storeRoot () {
  return process.env.PGH_STORE_ROOT || STORE_ROOT_DEFAULT
}

function appsDir () {
  return path.join(storeRoot(), 'apps')
}

function runRoot () {
  // Run-time state lives outside the install path so `pgh app install`'s
  // rmSync(installPath) doesn't wipe pid/state/log of a running app.
  if (process.env.XDG_RUNTIME_DIR) return path.join(process.env.XDG_RUNTIME_DIR, 'pgh')
  return path.join(homedir(), '.config', 'pgh', 'run')
}

function runAppDir (name) {
  return path.join(runRoot(), name)
}

export function appDir (name) {
  return path.join(appsDir(), name)
}

export function pidFile (name) {
  return path.join(runAppDir(name), 'app.pid')
}

export function logFile (name) {
  return path.join(runAppDir(name), 'app.log')
}

export function stateFile (name) {
  return path.join(runAppDir(name), 'app.state.json')
}

export function installManifestFile (name) {
  // Manifest STAYS inside installPath — it's install-time metadata
  // (entrypoint, runtime, defaults.env), rewritten by every install.
  return path.join(appDir(name), '.pgh-install.json')
}

// Legacy (pre-0.1.23) paths — used as read-fallback during the
// migration window so apps started under the old scheme don't appear
// dead immediately after the upgrade. The next `pgh app start` writes
// to the new location and the legacy file is left behind (harmless;
// gets rmSync'd on next reinstall).
function legacyPidFile (name) { return path.join(appDir(name), 'app.pid') }
function legacyStateFile (name) { return path.join(appDir(name), 'app.state.json') }
function legacyLogFile (name) { return path.join(appDir(name), 'app.log') }

// ── Install manifest ────────────────────────────────────────────────

export function readInstallManifest (name) {
  const p = installManifestFile(name)
  try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
}

export function writeInstallManifest (name, manifest) {
  const p = installManifestFile(name)
  fs.mkdirSync(path.dirname(p), { recursive: true })
  fs.writeFileSync(p, JSON.stringify(manifest, null, 2))
}

export function readPackageJsonPgh (name) {
  try {
    const pkg = JSON.parse(fs.readFileSync(path.join(appDir(name), 'package.json'), 'utf8'))
    return pkg.pgh || {}
  } catch { return {} }
}

// ── User config ─────────────────────────────────────────────────────

export function readUserConfig () {
  try { return JSON.parse(fs.readFileSync(USER_CONFIG, 'utf8')) } catch { return {} }
}

export function writeUserConfig (cfg) {
  fs.mkdirSync(path.dirname(USER_CONFIG), { recursive: true })
  fs.writeFileSync(USER_CONFIG, JSON.stringify(cfg, null, 2))
}

// ── Pid file ────────────────────────────────────────────────────────

function readPidAt (p) {
  try {
    const raw = fs.readFileSync(p, 'utf8').trim()
    const pid = parseInt(raw, 10)
    return Number.isFinite(pid) && pid > 0 ? pid : null
  } catch { return null }
}

export function readPid (name) {
  return readPidAt(pidFile(name)) || readPidAt(legacyPidFile(name))
}

export function writePid (name, pid) {
  const p = pidFile(name)
  fs.mkdirSync(path.dirname(p), { recursive: true })
  fs.writeFileSync(p, String(pid))
}

export function clearPid (name) {
  try { fs.unlinkSync(pidFile(name)) } catch {}
  // Don't touch the legacy file — installPath is rmSync'd on reinstall
  // anyway, and during the migration window leaving the legacy file
  // is harmless (readPid prefers the new location).
}

export function isPidAlive (pid) {
  if (!pid) return false
  try { process.kill(pid, 0); return true } catch { return false }
}

// When a process actually STARTED, from the kernel rather than from our own bookkeeping.
//
// `state.startedAt` records when pgh spawned a supervisor, which is not the same thing: a process
// started outside pgh, or one whose state file was rewritten, reports a time that has nothing to do with
// the code it is executing. The /proc/<pid> directory's ctime is the process start time (verified against
// both process.uptime() and `ps -o lstart` — agreement within milliseconds), so it is the ground truth
// for "is this process older than the tree it is supposed to be running".
//
// Linux-only by construction; returns null anywhere /proc is absent, and every caller treats null as
// "unknown" rather than "fine".
export function processStartedAt (pid) {
  if (!pid) return null
  try { return new Date(fs.statSync('/proc/' + pid).ctimeMs).toISOString() } catch { return null }
}

// Discover a running app supervisor by its PGH_SUP_NAME env, independent
// of the pid file. The supervisor is spawned with PGH_SUP_NAME=<name> in
// its environment (see `cmdStart`), so when the pid file is missing or
// stale we can still find the live process group to stop — otherwise a
// stale pid leaves the real daemon+supervisor running (holding the store
// lock / port) and the next start collides. Linux-only (reads /proc);
// returns null elsewhere or if not found, so callers fall back to
// pid-file behaviour. readdir/readFile are injectable for tests.
// Returns ALL matching supervisor pids (usually one; more than one means
// an orphan herd that this stop should also reap).
export function findRunningSupervisors (name, opts = {}) {
  const procDir = opts.procDir || '/proc'
  const readdir = opts.readdir || ((d) => fs.readdirSync(d))
  const readFile = opts.readFile || ((p) => fs.readFileSync(p, 'utf8'))
  const found = []
  let entries
  try { entries = readdir(procDir) } catch { return found }
  for (const ent of entries) {
    if (!/^\d+$/.test(ent)) continue
    let environ
    try { environ = readFile(procDir + '/' + ent + '/environ') } catch { continue }
    // environ is NUL-separated KEY=VALUE; exact-match to avoid prefix
    // collisions (e.g. pear-git-v3 vs pear-git-v3-experimental).
    if (!environ.split('\0').includes('PGH_SUP_NAME=' + name)) continue
    let cmdline
    try { cmdline = readFile(procDir + '/' + ent + '/cmdline') } catch { continue }
    if (cmdline.indexOf('supervisor.js') === -1) continue
    found.push(Number(ent))
  }
  return found
}

// ── State file ──────────────────────────────────────────────────────

function readStateAt (p) {
  try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
}

export function readState (name) {
  return readStateAt(stateFile(name)) || readStateAt(legacyStateFile(name)) || {}
}

export function writeState (name, patch) {
  const cur = readState(name)
  const next = { ...cur, ...patch }
  const p = stateFile(name)
  fs.mkdirSync(path.dirname(p), { recursive: true })
  fs.writeFileSync(p, JSON.stringify(next, null, 2))
  return next
}

// ── Log file ────────────────────────────────────────────────────────
//
// New supervisor invocations open the log under runAppDir. If a legacy
// log exists at the old in-install location and the new one doesn't
// yet, prefer the legacy one for `pgh app log` reads so pre-upgrade
// log lines are still visible.

/**
 * Rotate app.log if it's over the size limit. Keeps app.log, app.log.1,
 * up to app.log.<LOG_ROTATE_KEEP>. Called before spawning the supervisor
 * (at which point the log is not being written to).
 */
export function rotateLogIfNeeded (name) {
  const lp = logFile(name)
  let size = 0
  try { size = fs.statSync(lp).size } catch { return }
  if (size < LOG_ROTATE_BYTES) return
  for (let i = LOG_ROTATE_KEEP; i >= 1; i--) {
    const src = i === 1 ? lp : lp + '.' + (i - 1)
    const dst = lp + '.' + i
    try { fs.renameSync(src, dst) } catch {}
  }
}

export function openLogForAppend (name) {
  const lp = logFile(name)
  fs.mkdirSync(path.dirname(lp), { recursive: true })
  return fs.openSync(lp, 'a')
}

/**
 * Resolve the log path for `pgh app log` — prefer the new location;
 * fall back to legacy if the new one doesn't exist yet (migration
 * window: previous supervisor wrote to legacy, current daemon may not
 * have generated new logs yet).
 */
export function resolveLogPath (name) {
  const newP = logFile(name)
  if (fs.existsSync(newP)) return newP
  const legacy = legacyLogFile(name)
  if (fs.existsSync(legacy)) return legacy
  return newP // for "no log yet" callers, point at the new location
}

// ── Env / args / autostart ──────────────────────────────────────────

/**
 * Compute the effective env for the app: process env < install defaults <
 * user overrides. Later layers win. ${VAR} references in values are
 * expanded against the merged map.
 */
export function effectiveEnv (name, manifest) {
  const defaults = (manifest && manifest.defaults && manifest.defaults.env) || {}
  const user = (readUserConfig()[name] || {}).env || {}
  const merged = { ...process.env, ...defaults, ...user }
  for (const k of Object.keys(merged)) {
    if (typeof merged[k] === 'string') merged[k] = expandEnv(merged[k], merged)
  }
  return merged
}

export function effectiveArgs (name, manifest) {
  // Precedence: user-overrides > manifest.args (install-time CLI) >
  // manifest.defaults.args (the canonical install-time setting written
  // by `pgh app install` / `pgh app adopt`). Mirror effectiveEnv's
  // defaults-as-baseline behaviour: an empty manifest.args means
  // "fall back to defaults", not "I want no args".
  const defaults = (manifest && manifest.defaults && manifest.defaults.args) || []
  const installArgs = (manifest && manifest.args) || []
  const userArgs = (readUserConfig()[name] || {}).args
  let chosen
  if (Array.isArray(userArgs)) chosen = userArgs
  else if (installArgs.length > 0) chosen = installArgs
  else chosen = defaults
  // Expand ${VAR} so e.g. --storage=${HOME}/pgv3-experimental-storage
  // resolves at start time (mirrors effectiveEnv's expandEnv pass).
  const env = { ...process.env, ...((manifest && manifest.defaults && manifest.defaults.env) || {}) }
  return chosen.map(a => typeof a === 'string' ? expandEnv(a, env) : a)
}

export function effectiveAutostart (name, manifest) {
  const user = readUserConfig()[name]
  if (user && typeof user.autostart === 'boolean') return user.autostart
  if (manifest && typeof manifest.autostart === 'boolean') return manifest.autostart
  return false
}

export function expandEnv (s, env) {
  if (typeof s !== 'string') return s
  return s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k) => (env[k] != null ? env[k] : '${' + k + '}'))
}

// Install-machinery siblings of an app dir, never apps themselves: the update
// scratch (.upd), the swap's leftover (.old) and a STAGED update waiting for
// the app to stop (.next — it has a manifest; see materializers/atomic.js).
const INSTALL_SIBLING = /\.(upd|old|next)$/

export function listAppNames () {
  try {
    return fs.readdirSync(appsDir()).filter(n => {
      if (INSTALL_SIBLING.test(n)) return false
      try { return fs.existsSync(installManifestFile(n)) } catch { return false }
    })
  } catch { return [] }
}

export { USER_CONFIG }