OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / control.js

160 lines · javascript ref:
// control.js — the UDS control surface and the OTA respawn contract.
//
// WHY THIS EXISTS. ap-bridge had NO control surface at all: no UDS, no sidecar registration, no
// `release-ready`. Every deploy was therefore a manual stop / install / start, while atproto-bridge
// and favela-web update themselves. That gap is also why ap-bridge is the app most likely to be
// running something other than what was published — INSTALLED != RUNNING, with no mechanism to close
// the gap.
//
// THE OTA CONTRACT (same as atproto-bridge 0.9.39 and favela-web):
//   1. pgh-sidecar does the FULL install — tree fetch, dep closure, materialize, symlink swap.
//   2. It then calls `release-ready` on this socket.
//   3. We respond ok FIRST, then shut down cleanly and exit 75.
//   4. The supervisor sees 75 and respawns against the new tree.
//
// ⚠ DELIBERATELY NOT pgh-runtime. It fetches ROOT trees WITHOUT dep closures, so an app pulling its
// own update produces a broken install — the MODULE_NOT_FOUND respawn loop that took the membrane
// down on 2026-07-31. The sidecar already does it correctly; duplicating half of it is how you get
// half of it. Our only job is to exit.
//
// ⚠ Respond BEFORE exiting. Exiting first drops the connection, and the sidecar cannot distinguish
// "app accepted and is respawning" from "app crashed".
//
// FAILS SOFT. Serving ActivityPub matters more than being remotely manageable: if hyper-svc is
// missing or the socket cannot bind, the bridge logs it and keeps running. Registration is likewise
// best-effort — a sidecar that is down (or absent entirely, e.g. a standalone `bare index.js` run)
// must not stop the actor federating.

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import process from 'bare-process'

export function sidecarSockPath () {
  return process.env.PGH_SIDECAR_SOCK || path.join(os.homedir(), '.pgh', 'svc.sock')
}

// The sidecar socket may appear AFTER us — pgh boots apps in dependency order but the sidecar's own
// cold start indexes a large store, so it can lose the race. Poll rather than give up.
export async function waitForSidecarSock ({ timeoutMs = 120000, intervalMs = 1000, shouldStop = () => false } = {}) {
  const sock = sidecarSockPath()
  const deadline = Date.now() + timeoutMs
  while (!fs.existsSync(sock)) {
    if (shouldStop()) return false
    if (Date.now() >= deadline) return false
    await new Promise((r) => setTimeout(r, intervalMs))
  }
  return true
}

export async function registerWithSidecar ({ udsPath, name, runtime = 'bare', version = null }) {
  const sidecarSock = sidecarSockPath()
  if (!fs.existsSync(sidecarSock)) return { registered: false, reason: 'sidecar not running' }
  try {
    const { Client } = await import('hyper-svc')
    const c = await Client.connectUds(sidecarSock)
    await c.call('app-register', { name, udsPath, pid: process.pid, runtime, version })
    await c.close()
    return { registered: true, name }
  } catch (err) {
    return { registered: false, reason: err.message }
  }
}

// Register once the sidecar is up, then re-register on an interval: the sidecar keeps its runtime
// registry IN MEMORY, so a sidecar restart silently drops us and every `pgh` command against this
// app starts failing with "not registered". Returns stop().
export function maintainSidecarRegistration ({ udsPath, name, runtime = 'bare', version = null, intervalMs = 30000 } = {}) {
  let timer = null
  let stopped = false
  ;(async () => {
    const ready = await waitForSidecarSock({ shouldStop: () => stopped })
    if (stopped) return
    if (ready) await registerWithSidecar({ udsPath, name, runtime, version })
    timer = setInterval(() => { registerWithSidecar({ udsPath, name, runtime, version }) }, intervalMs)
    if (timer.unref) timer.unref()
  })()
  return () => { stopped = true; if (timer) clearInterval(timer) }
}

/**
 * The control verbs as a plain map, separate from the socket that serves them.
 *
 * ⛔ The favela gateway (hyper-social #80) serves these on ITS socket beside the membrane's verbs, and
 * must not bind ap-bridge's svc.sock or register its name: a release of ap-bridge pushed to a path the
 * gateway served would restart the wrong process. startControl below registers this same map.
 *
 * `release-ready` is not here: whoever owns the process owns its exit.
 */
export function bridgeHandlers ({ name = 'ap-bridge', version = null, status = null, onReproject = null, onNotifications = null, onInvite = null } = {}) {
  const handlers = {
    status: async () => (status ? status() : { ok: true, name, version })
  }
  // Read what the fediverse sent us. An inbound store nothing can query is indistinguishable from
  // the 202-and-discard it replaced, so the read verb ships with the write path, not after it.
  if (onNotifications) {
    handlers.notifications = async (params = {}) => onNotifications({
      limit: Math.min(Number(params.limit) || 50, 200)
    })
  }
  // ⛔ MINTING AN INVITE IS AN OPERATOR ACT, so it lives on the owner-gated unix socket and nowhere
  // else. `registrations.js` has had `invite()`, single-use hashes and an `invite` policy since it
  // was written, and NOTHING has ever called it — so a bridge could be switched to invite-only and
  // then no invite could be created for it. The token type existed; the door did not.
  //
  // ⚠ The token is returned ONCE and stored nowhere: the log holds only its hash, exactly as the
  // page that displays one already says. A control surface that could list tokens would defeat
  // that, so this mints and forgets.
  if (onInvite) handlers.invite = async () => onInvite()
  // reproject — the operator verb for "the projection improved, tell the receivers".
  //
  // Needed because the publisher's drain only runs on onHead(), so an improvement reaches
  // already-delivered posts only when some unrelated FUTURE post advances the head. #26 recorded
  // that trap twice ("event-driven quietly meant never happens"); an event-driven system needs a
  // reconciliation it can be ASKED to run.
  if (onReproject) {
    handlers.reproject = async (params = {}) => onReproject({
      rkey: params.rkey || null,
      force: !!params.force,
      // Default ON: never re-send a post whose media cannot be served. Overridable, deliberately,
      // because an operator staring at a specific case may know better than the probe.
      verify: params.verify !== false,
      limit: Number(params.limit) || 50
    })
  }
  return handlers
}

// Stand up the control socket. Returns { svc, udsPath } or null when unavailable — the caller keeps
// serving either way.
//
// onRelease(version) is invoked AFTER the response is queued, via setImmediate, so the caller owns
// the shutdown sequence and exit code.
export async function startControl ({ udsPath, name, version = null, status = null, onRelease = null, onReproject = null, onNotifications = null, onInvite = null, log = console.log } = {}) {
  try {
    const { Service } = await import('hyper-svc')
    // A stale socket from a killed process blocks bind. We only reach here after the supervisor
    // decided no live instance owns this app, so an existing file is orphaned.
    try { if (fs.existsSync(udsPath)) fs.unlinkSync(udsPath) } catch {}
    const svc = new Service({ name, uds: udsPath })
    for (const [verb, fn] of Object.entries(bridgeHandlers({ name, version, status, onReproject, onNotifications, onInvite }))) {
      svc.handle(verb, async (ctx, params) => fn(params))
    }
    if (onRelease && !svc.hasHandler?.('release-ready')) {
      svc.handle('release-ready', async (ctx, params = {}) => {
        const ver = (params && params.version) || '?'
        log({ msg: 'ota: release-ready — sidecar installed; exiting 75 for supervisor respawn', version: ver })
        setImmediate(() => { onRelease(ver) })
        return { ok: true, willRespawn: true }
      })
    }
    await svc.listen()
    maintainSidecarRegistration({ udsPath, name, version })
    log({ msg: 'control surface up — OTA respawn armed', uds: udsPath })
    return { svc, udsPath }
  } catch (err) {
    log({ msg: 'control surface unavailable (continuing to serve)', error: err && err.message })
    return null
  }
}