// 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
}