OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / diag.js

261 lines · javascript ref:
// diag — ONE call that answers "what is this node actually doing?" for every hosted DID.
//
// WHY THIS EXISTS. Inspecting a running hpp meant ssh + a heredoc + writing a throwaway .mjs with
// absolute-path imports into ~/.local/share/store/apps/hyper-proto-peer/node_modules/... because there was
// no CLI way to ask a running daemon anything. Most of a working day went into that on 2026-07-27, and a
// heredoc died because a paste indented the closing EOF. The daemon already answers `get-state`,
// `members-diag` and `pointer-diag` over its owner-gated UDS; nothing collected them. This does, so remote
// inspection is:
//
//     ssh host 'hyper-proto-peer diag'
//
// The three reads answer three different questions and the interesting failures are only visible when you
// put them side by side:
//   get-state    — head/rev/records/writable: is this node advancing at all?
//   members-diag — roster + isCreator + forcedRole: is it playing the role it should be? (`amCreator` is
//                  now the honest `_isCreator`; it used to read `!!<prototype method>` and report true for
//                  every node, which was cited as evidence in the loser-club fork.)
//   pointer-diag — do this node's local BYTES match the converged pointers? The wj62 divergence was
//                  invisible to every other surface: two co-owners, same 139 pointers, different bytes.
//
// Read-only: every op it calls is a read. Nothing here writes, fetches or repairs.
import fs from 'bare-fs'
import os from 'bare-os'
import { Client } from 'hyper-svc'
import { loadConfig } from './config.js'
import { sockFor } from './control-socket.js'
import { loadOrCreateSwarmKeyPair } from './swarm-keypair.js'
import { collectRotationHeld } from './rotation-guard.js'
import b4a from 'b4a'

// Ask one op, and record WHY it failed rather than dropping the whole DID. A node with a wedged
// pointer store must still report its state and roster — those are usually what says why it is wedged.
async function ask (call, op, params, out, errors) {
  try { out[op] = await call(op, params) } catch (e) { errors[op] = String((e && e.message) || e) }
}

const REPORT_OPS = [
  ['get-state', 'state', {}],
  ['members-diag', 'members', {}],
  ['pointer-diag', 'pointers', null], // params filled per-call (limit)
  // conflict-diag — the fourth question, and the one no surface could answer: has a core FORKED? A
  // conflict freezes that core and stops it replicating while head, records and bytes all stay correct,
  // so every other section here reports a healthy node. That is not hypothetical: #17 declared
  // loser-club converged on exactly those three agreeing while both machines were logging conflicts on
  // core 4a6f1b30. Absent on a daemon older than 0.3.92, which lands in `errors` rather than as a zero.
  ['conflict-diag', 'conflicts', {}]
]

async function collectFrom (call, { limit }) {
  const raw = {}
  const errors = {}
  for (const [op, , defaults] of REPORT_OPS) {
    await ask(call, op, defaults === null ? { limit } : defaults, raw, errors)
  }
  const out = {}
  for (const [op, field] of REPORT_OPS) if (raw[op] !== undefined) out[field] = raw[op]
  if (Object.keys(errors).length) out.errors = errors
  return out
}

// The one-line answer per DID: what an operator scans first, before reading the full sections.
export function summarize (did, r) {
  const s = r.state || {}
  const m = r.members || {}
  const p = r.pointers || {}
  const cf = r.conflicts || {}
  return {
    did,
    // Field names track lib/swarm-device.js state()/membersDiag() EXACTLY — head.rev (not `rev`),
    // recordCount (not `records`, which is the key LIST). Verified against the live daemon.
    // ⚠ THIS NODE'S OWN signed head, not the DID's. A node that does not sign (a resync's
    // forced-non-creator, e.g. tabertron for wj62) NEVER advances this, so a frozen rev here is normal
    // and is NOT evidence that writes are unpublished — the signer's rev is the DID's head. Compare
    // `records`/`indexed` across nodes to judge convergence, and `signs` to see who publishes.
    rev: s.head ? s.head.rev : null,
    records: s.recordCount !== undefined ? s.recordCount : null,
    writable: s.writable !== undefined ? s.writable : null,
    // `signs` is the presence election — the gate sign() actually consults. holdsLease is DELIBERATELY
    // not surfaced here: it is a legacy field that reads False on healthy signing nodes, and having it
    // sit beside `rev` in the summary cost a real debugging session (2026-07-29) chasing a signing wedge
    // that did not exist. A diagnostic that misleads is worse than one that is absent.
    signs: s.holdsSigner !== undefined ? s.holdsSigner : null,
    indexers: s.indexers !== undefined ? s.indexers : null,
    // "<indexedLength>/<baseLength>" — the autobase progress pair every incident write-up quotes
    // ("base=0/0 flat for 160s"; "122287/122287 fully indexed"). A node that is stuck rather than slow
    // says so right here, which is otherwise two more calls to find out.
    indexed: (m.indexedLength != null || m.baseLength != null) ? `${m.indexedLength}/${m.baseLength}` : null,
    // Who accounts for an indexed/base gap. `writers` is the count; `writersPending` totals the blocks
    // authored but not yet in the view, so a non-zero `indexed != base` can be attributed instead of
    // guessed at — the exact question that was unanswerable for loser-club's 46-block gap. The per-writer
    // detail (authored/seen/pending/indexer/removed/local) is in the full members section.
    writers: Array.isArray(m.writers) ? m.writers.length : null,
    writersPending: Array.isArray(m.writers) ? m.writers.reduce((n, w) => n + (w.pending || 0), 0) : null,
    // The role this node ACTUALLY resolved (not the roster's opinion of it) plus any explicit override.
    amCreator: m.amCreator !== undefined ? m.amCreator : null,
    forcedRole: m.forcedRole !== undefined ? m.forcedRole : null,
    roster: m.rosterLen !== undefined ? m.rosterLen : null,
    // Admission depends on these being IN THE LINEARIZED VIEW, and "__devices__ empty" is a tracked
    // open question on wj62 — presence is the answer, content is in the full section.
    membersInView: m.membersInView !== undefined ? !!m.membersInView : null,
    devicesInView: m.devicesInView !== undefined ? !!m.devicesInView : null,
    // Byte health. stale > 0 is the wj62 signature: this node will SIGN superseded bytes as truth.
    // Conflicts SINCE BOOT. Non-zero means a core forked and silently stopped replicating — the one
    // condition that is invisible in every field above it, because a conflicted node keeps serving the
    // right answer from the surviving line. null = the daemon predates conflict-diag; 0 = none THIS
    // process, which is not the same as none ever (the freeze is not persisted, so a restart re-arms it).
    conflicts: cf.seenSinceBoot !== undefined ? cf.seenSinceBoot : null,
    conflictEvents: cf.total !== undefined ? cf.total : null,
    pointerChecked: p.checked !== undefined ? p.checked : null,
    stale: p.stale !== undefined ? p.stale : null,
    missing: p.missing !== undefined ? p.missing : null,
    noOid: p.unresolvable !== undefined ? p.unresolvable : null,
    // CAN WE STILL CHANGE THIS IDENTITY? Every field above answers "is this node advancing?"; none of
    // them can answer this one, which is why wj62 went six weeks before anyone noticed (#47). TRI-STATE:
    // true = a local secret derives one of the DID's rotation keys; false = none does, and the DID is
    // frozen FOREVER (serving still works, which is exactly why it hides); null = could not determine
    // (no network, or an encrypted vault) — deliberately not false, because a check that could not run
    // is not a NO.
    rotationKeyHeld: r.rotation ? r.rotation.held : null,
    // true when the DID has exactly ONE rotation key. `rotationKeyHeld: true` with this also true is one
    // disk failure away from wj62's outcome, so the two are reported separately rather than merged.
    singleRotationKey: r.rotation ? r.rotation.singleKeyRisk : null,
    reachable: !r.unreachable,
    failed: r.errors ? Object.keys(r.errors) : []
  }
}

// One JSON blob for every hosted DID, read over each DID's owner-gated control socket.
export async function collectLocalDiag ({ cfg, storage, dids, limit = 50 } = {}) {
  const out = []
  for (const did of dids) {
    const sock = sockFor(cfg, storage, did)
    const entry = (cfg.dids || []).find((d) => d.did === did) || {}
    const row = { did, primary: !!entry.primary, socket: sock }
    if (!fs.existsSync(sock)) {
      // The single most common answer, and worth stating plainly: no socket == this daemon is not
      // hosting this DID right now. It is NOT evidence about the DID's data.
      row.unreachable = 'no control socket at ' + sock + ' — daemon not running, or not hosting this DID'
      out.push(row)
      continue
    }
    let client = null
    try {
      client = await Client.connectUds(sock)
      Object.assign(row, await collectFrom((op, params) => client.call(op, params), { limit }))
    } catch (e) {
      row.unreachable = String((e && e.message) || e)
    } finally { try { if (client) await client.close() } catch {} }
    out.push(row)
  }
  // Rotation authority is a LOCAL fact (a secret on this disk vs the DID's published rotationKeys), not
  // something the daemon can be asked for — so it is collected here rather than via an op. Bounded and
  // non-fatal: a diagnostic must never be the thing that hangs.
  try {
    const rot = await collectRotationHeld({ cfg, dids, storage })
    for (const row of out) row.rotation = rot.get(row.did) || null
  } catch (e) {
    for (const row of out) row.rotation = { held: null, reason: 'rotation check failed: ' + ((e && e.message) || e) }
  }
  return out
}

// The same three reads against a REMOTE node over the swarm — no ssh at all. get-state/members-diag are
// public on the device control surface; pointer-diag is writer-gated there (it hashes every record file,
// so it must not be an open CPU trigger for any peer that knows a DID), and an unauthorized caller gets
// that recorded as an error rather than silence.
export async function collectRemoteDiag ({ dids, limit = 50, timeoutMs = 20000, keyPair = null } = {}) {
  const [{ default: Hyperswarm }, { default: ProtomuxRPC }, { default: c }, { discoveryTopic }] = await Promise.all([
    import('hyperswarm'), import('protomux-rpc'), import('compact-encoding'), import('atproto-identity/lib/discovery.js')
  ])
  const { DEVICE_PROTOCOL } = await import('./serve-device.js')
  const out = []
  for (const did of dids) {
    // A STABLE client identity, not a fresh one per invocation: pointer-diag is writer-gated on the
    // device surface, so a remote operator can only ever reach it if the key they present is the same one
    // the target allowlisted. An ephemeral keypair makes that permanently unreachable by construction.
    const swarm = new Hyperswarm(keyPair ? { keyPair } : {})
    const row = { did, remote: true }
    try {
      const rpc = await new Promise((resolve, reject) => {
        const timer = setTimeout(() => reject(new Error(`no device answered for ${did} within ${timeoutMs}ms`)), timeoutMs)
        swarm.on('connection', (conn) => {
          const r = new ProtomuxRPC(conn, { protocol: DEVICE_PROTOCOL, valueEncoding: c.json })
          r.on('error', () => {})
          clearTimeout(timer)
          resolve(r) // first device of the DID to connect answers; any of them can serve these reads
        })
        swarm.join(discoveryTopic(did), { server: false, client: true })
        swarm.flush().catch(() => {})
      })
      Object.assign(row, await collectFrom((op, params) => rpc.request(op, params), { limit }))
    } catch (e) {
      row.unreachable = String((e && e.message) || e)
    } finally { try { await swarm.destroy() } catch {} }
    out.push(row)
  }
  return out
}

function readVersion () {
  try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version } catch { return null }
}

// args = CLI args AFTER the subcommand: [<did>|--all] [--remote] [--storage <p>] [--limit <n>] [--brief]
export async function diagCommand (args = []) {
  const cfg = loadConfig()
  const flag = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt }
  const storage = flag('--storage', cfg.storage)
  const limit = Number(flag('--limit', 50)) || 0
  const brief = args.includes('--brief')
  const remote = args.includes('--remote')
  const one = args.find((a) => a.startsWith('did:'))
  const hosted = (cfg.dids || []).map((d) => d.did)
  const targets = one ? [one] : hosted

  if (!targets.length) {
    console.error('usage: hyper-proto-peer diag [<did>] [--remote] [--storage <path>] [--limit <n>] [--brief]')
    console.error('  Reports get-state + members-diag + pointer-diag (+ the creator role) for every hosted')
    console.error('  DID as ONE JSON blob. Read-only. Local by default over each DID\'s owner-gated control')
    console.error('  socket; --remote asks over the swarm instead (no ssh).')
    console.error('  No hosted DIDs in ' + cfg.configPath + ' — pass a <did> explicitly, or use --remote.')
    return 1
  }

  // The remote client's own stable key, kept beside (not shared with) the daemon's swarm identity —
  // reported below so an operator knows exactly what to add to a target's PEER_WRITERS to unlock the
  // writer-gated reads. Its own file: two Hyperswarms on one keypair would put two DHT identities behind
  // the same key.
  let clientKeyPair = null
  if (remote) {
    try { clientKeyPair = loadOrCreateSwarmKeyPair(storage, { name: 'diag-keypair.json' }) } catch {}
  }

  const dids = remote
    ? await collectRemoteDiag({ dids: targets, limit, keyPair: clientKeyPair })
    : await collectLocalDiag({ cfg, storage, dids: targets, limit })

  const report = {
    tool: 'hyper-proto-peer diag',
    // The version of the tree this CLI RAN FROM — not proof of what the daemon is running. `pgh app
    // status` reports the install tree too, and INSTALLED != RUNNING has bitten this fleet repeatedly:
    // check process age against the install time before trusting a version to explain behaviour.
    cliVersion: readVersion(),
    host: (() => { try { return os.hostname() } catch { return null } })(),
    at: new Date().toISOString(),
    source: remote ? 'swarm' : 'uds',
    storage: remote ? null : storage,
    // Add this key to a target host's PEER_WRITERS to unlock the writer-gated reads (pointer-diag)
    // over the swarm. Stable across invocations, so allowlisting it once is enough.
    clientKey: clientKeyPair ? b4a.toString(clientKeyPair.publicKey, 'hex') : null,
    summary: dids.map((r) => summarize(r.did, r)),
    dids: brief ? undefined : dids
  }
  console.log(JSON.stringify(report, null, 2))

  // Exit non-zero when nothing could be reached, so a scripted sweep can branch on it. A reachable node
  // reporting problems still exits 0 — the report IS the answer, and a stale-pointer node is not a
  // failed invocation.
  return dids.every((r) => r.unreachable) ? 1 : 0
}