OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / handlers / pgh-tree.js

203 lines · javascript ref:
/**
 * pgh-tree RPC handlers — daemon-mediated wrappers around the pgh
 * package's content-addressed publish/install/registry primitives.
 *
 * Phase 1 of the pgh/pear-git-v3 convergence (see
 * ../../pgh/NEXT-STEPS.md). When a pear-git-v3 daemon is running on a
 * machine, every tree publish/install/registry operation routes
 * through these handlers instead of each user's CLI doing its own
 * thing. Net effect: ONE store + ONE registry per machine, shared
 * across all pgh CLI invocations on that host.
 *
 * These handlers deliberately do NOT re-implement pgh's logic — they
 * import + call it directly. The daemon's process owns the same
 * ~/.local/share/store/ + ~/.config/pgh/release-registry/ the
 * standalone pgh would own. Auth: mutating handlers require a writer
 * (trust map), reads are open. Mirror mode refuses mutations.
 *
 * Not yet: channel-promote / channel-unpin / registry subscription.
 * Those land once pgh itself grows channel-aware commands.
 */

import { run as runPublish } from 'pgh/commands/publish'
import { run as runApp } from 'pgh/commands/app'
import { TreeStore } from 'pgh/store'
import { Client } from 'hyper-svc'

import { requireWriter } from '../auth/check.js'
import { sidecarSockPath } from '../sidecar-wait.js'

/**
 * Mark each registry entry with whether THIS machine holds its tree.
 *
 * The ReleaseRegistry is append-only with no retention; the tree store is
 * content-addressed and garbage-collected. They diverge by construction, so a
 * registry row routinely names a tree that was swept long ago. Measured on
 * tabertron 2026-08-29 for hyper-tps: 382 advertised tree hashes, 10 present,
 * 372 absent -- every one of those 372 rendering an install command that
 * resolves to nothing here.
 *
 * `heldLocally: false` means NOT HELD HERE, never "gone". The registry is
 * replicated and another peer may well hold the tree; only the local answer is
 * cheap enough to compute per page render. Callers must word it that way.
 *
 * Pure and defensive: input objects are not mutated, a missing hash is never
 * claimed as held, and a probe that throws (permissions, absent store) degrades
 * to not-held rather than asserting something it did not verify.
 *
 * @param {Array<object>} entries
 * @param {(treeHash: string) => boolean} hasTree
 * @returns {Array<object>} copies, each with heldLocally
 */
export function markHeldLocally (entries, hasTree) {
  if (!Array.isArray(entries)) return []
  return entries.map((e) => {
    let held = false
    if (e && e.treeHash) {
      try { held = !!hasTree(e.treeHash) } catch { held = false }
    }
    return { ...e, heldLocally: held }
  })
}

export function pghTreeHandlers (svc) {
  const registry = () => svc.identityRegistry

  return [
    {
      name: 'tree-publish',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        const { appDir, acceptConflict, dryRun } = params
        if (!appDir || typeof appDir !== 'string') {
          throw new Error('tree-publish requires { appDir: string }')
        }
        const args = [appDir]
        if (acceptConflict) args.push('--accept-conflict', acceptConflict)
        if (dryRun) args.push('--dry-run')
        return runPublish(null, args)
      }
    },

    {
      name: 'tree-install',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        const { registryKeyHex, appName, version, installName } = params
        if (!registryKeyHex || registryKeyHex.length !== 64) {
          throw new Error('tree-install requires { registryKeyHex: 64-char hex }')
        }
        if (!appName) throw new Error('tree-install requires { appName }')
        const url = 'pgh://' + registryKeyHex + '/' + appName + (version ? '@' + version : '')
        const args = ['install', url]
        if (installName) args.push('--name', installName)
        return runApp(null, args)
      }
    },

    {
      // Ask the SIDECAR for the registry. Do NOT open the store here.
      //
      // This used to `new Corestore(~/.config/pgh/release-registry)` directly.
      // pgh-sidecar owns that store and holds its RocksDB lock for its whole
      // lifetime, so a second opener always failed with "File descriptor could
      // not be locked" — verified against the live daemon 2026-08-07. The
      // handler could therefore never succeed in the deployed configuration,
      // which is why nothing was ever built on it.
      //
      // Uses `releases-summary`, not `releases-list`: every registry entry
      // embeds its full dep closure (~120 KB for one pear-git-v3 entry), so
      // listing via the fat RPC does not return. Requires pgh-sidecar >= 0.2.51.
      name: 'tree-registry-list',
      fn: async (ctx, params = {}) => {
        // Opted into ANONYMOUS_METHODS so the SSR Releases tab can render for a
        // logged-out visitor. The registry itself is public by construction — a
        // replicated Autobase whose key ships in every published package.json as
        // `pgh.upgrade` — so gating reads adds friction, not secrecy.
        //
        // BUT an unfiltered call would enumerate every app on this machine,
        // including ones whose repo is private, bypassing the repo read gate. So
        // an anonymous caller must name a repo, and that name goes through
        // repoAccess exactly like the release-download route (router.js:513).
        // A trusted caller (UDS / writer) keeps the unfiltered listing.
        const anon = !ctx || !ctx.identity
        if (anon) {
          if (!params.name) {
            return { registryKey: null, count: 0, entries: [], unavailable: 'name required for anonymous callers' }
          }
          if (svc.repoAccess) {
            let d = null
            try {
              d = await svc.repoAccess.async(params.name, { transport: 'http', identity: null })
            } catch { d = null }
            if (d && d.wouldDeny && d.enforced) {
              return { registryKey: null, count: 0, entries: [] }
            }
          }
        }
        const sock = sidecarSockPath()
        let c = null
        try {
          c = await Client.connectUds(sock)
          const info = await c.call('registry-info', {})
          const entries = await c.call('releases-summary',
            params.name ? { name: params.name } : {})
          // Annotate with local resolvability. This reads the TREE store, not
          // the sidecar's registry corestore -- the lock the comment above warns
          // about is RocksDB on ~/.config/pgh/release-registry. The tree store
          // is a plain hardlink directory, so hasTree() is an existsSync plus a
          // small meta read: no lock, no init, no side effects.
          let probe = () => false
          try {
            const ts = new TreeStore()
            probe = (h) => ts.hasTree(h)
          } catch { /* no store on this machine -- everything reads as not held */ }
          return {
            registryKey: info && info.keyHex ? info.keyHex : null,
            count: entries.length,
            entries: markHeldLocally(entries, probe)
          }
        } catch (err) {
          // A forge whose sidecar is down should render an empty, explained
          // section rather than 500 the page it is embedded in.
          return {
            registryKey: null,
            count: 0,
            entries: [],
            unavailable: err.message || String(err)
          }
        } finally {
          if (c) { try { await c.close() } catch {} }
        }
      }
    },

    {
      name: 'tree-store-stats',
      fn: async () => {
        const ts = new TreeStore()
        ts.init()
        return ts.stats()
      }
    },

    {
      name: 'tree-seed-status',
      fn: async () => {
        // Reports whether the daemon's DeployServer is active + serving
        // tree content. The seed is wired at process startup; this is
        // pure status reporting.
        const deploy = svc._deploy
        if (!deploy) return { active: false, reason: 'no DeployServer running' }
        return {
          active: true,
          topic: deploy.topicHex,
          connections: deploy.connections,
          fileCount: deploy.manifest ? deploy.manifest.files.length : 0,
          totalSize: deploy.manifest ? deploy.manifest.totalSize : 0
        }
      }
    }
  ]
}