OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / release-manager.js

704 lines · javascript ref:
/**
 * ReleaseManager — per-repo versions + channels layered on top of a
 * hyper-deploy release directory.
 *
 * Directory layout:
 *
 *   <releaseDir>/
 *     <repo>/
 *       versions/
 *         <version>/
 *           <artifact-files>
 *           manifest.json       { version, builtAt, fromCommit, artifacts[], sizeBytes }
 *       channels/
 *         <channel>.json        { version, pinnedAt }
 *
 * Versions are immutable once published. Channels are mutable pointers
 * (small JSON, not symlinks — portable + inspectable). Fetching
 * <repo>/channels/<channel>.json tells a peer which version is current;
 * fetching <repo>/versions/<v>/* is content-stable.
 *
 * Mutating methods auto-trigger hyper-deploy.rescan() so peers see
 * changes without an extra round-trip.
 */

import { mkdir, writeFile, readFile, readdir, stat, rm } from '#node:fs/promises'
import { join, dirname } from '#node:path'
import { hashChunk } from 'hyper-overlay/chunker'
import {
  OP_PUBLISHED, OP_PROMOTED, OP_UNPINNED, OP_DELETED
} from './services/release-event-log.js'

const MANIFEST_FILE = 'manifest.json'
// Written into the version dir by the vdrive step (vdrive-publish.js
// copyLocations) so the drive's contentHash -> (file, offset, length) map
// replicates with the release. Named here because ReleaseManager owns what a
// version directory may contain, and _writeIndex has to know to index it.
export const VDRIVE_LOCATIONS_FILE = 'locations.json'
// Files a version carries that are NOT listed in manifest.artifacts. Anything
// omitted here is invisible to _writeIndex and therefore never replicates.
const RESERVED_VERSION_FILES = [MANIFEST_FILE, VDRIVE_LOCATIONS_FILE]
// Overlay namespace for release artifacts (the attachment spool shares this overlay).
export const RELEASE_OVERLAY_PREFIX = 'release:'
// Well-known discovery path at the release-dir root. See _writeIndex().
const INDEX_FILE = '_index.json'

export class ReleaseManager {
  /**
   * @param {object} opts
   * @param {string} opts.releaseDir - absolute path to the top-level release root
   * @param {object} [opts.deploy]   - hyper-deploy DeployServer, for auto-rescan
   * @param {object} [opts.eventLog] - ReleaseEventLog. When present AND writable
   *   (i.e. this daemon is the origin), every mutation is announced to peers so
   *   mirrors reconcile on the event instead of on a polling interval.
   */
  constructor (opts = {}) {
    if (!opts.releaseDir) throw new Error('ReleaseManager requires { releaseDir }')
    this._releaseDir = opts.releaseDir
    this._deploy = opts.deploy || null
    // Optional: the V2 overlay. When present, _writeIndex registers every
    // artifact by content hash so peers can fetch the bytes the index names.
    this._overlay = opts.overlay || null
    this._eventLog = opts.eventLog || null
  }

  /** Release root on disk — the version dirs under it are the vdrive byte store. */
  get releaseDir () { return this._releaseDir }

  // ── Path helpers ───────────────────────────────────────────
  _repoDir (repo)            { return join(this._releaseDir, repo) }
  _versionsDir (repo)        { return join(this._releaseDir, repo, 'versions') }
  _versionDir (repo, ver)    { return join(this._releaseDir, repo, 'versions', ver) }
  _channelsDir (repo)        { return join(this._releaseDir, repo, 'channels') }
  _channelPath (repo, chan)  { return join(this._releaseDir, repo, 'channels', chan + '.json') }

  // ── Publish: write a new version + optionally pin a channel ──
  /**
   * @param {object} opts
   * @param {string} opts.repo
   * @param {string} opts.version
   * @param {Array<{path: string, data: Buffer}>} opts.artifacts
   * @param {string} [opts.channel]
   * @param {object} [opts.meta] - extra fields merged into manifest.json
   */
  async publish (opts) {
    const { repo, version, artifacts, channel, meta } = opts
    if (!repo || !version) throw new Error('publish requires { repo, version }')
    if (!Array.isArray(artifacts) || artifacts.length === 0) {
      throw new Error('publish requires artifacts: [{ path, data }]')
    }

    validateName(repo, 'repo')
    validateName(version, 'version')

    const verDir = this._versionDir(repo, version)
    // Guard: don't silently overwrite an existing version's files. The
    // caller must delete first if they really want to replace.
    try {
      await stat(verDir)
      throw new Error(`version ${version} already exists for ${repo} — delete it first to republish`)
    } catch (err) {
      if (err.code !== 'ENOENT') throw err
    }

    await mkdir(verDir, { recursive: true })

    const written = []
    for (const a of artifacts) {
      if (!a || !a.path || !Buffer.isBuffer(a.data) && !(a.data instanceof Uint8Array) && typeof a.data !== 'string') {
        throw new Error('each artifact requires { path, data: Buffer|string }')
      }
      validatePath(a.path)
      const abs = join(verDir, a.path)
      await mkdir(dirname(abs), { recursive: true })
      const buf = Buffer.isBuffer(a.data) ? a.data : Buffer.from(a.data)
      await writeFile(abs, buf)
      written.push({ path: a.path, size: buf.length })
    }

    const manifest = {
      version,
      builtAt: Date.now(),
      fromCommit: (meta && meta.fromCommit) || null,
      artifacts: written,
      sizeBytes: written.reduce((s, a) => s + a.size, 0),
      ...(meta || {})
    }
    await writeFile(join(verDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2))

    if (channel) await this._writeChannel(repo, channel, version)
    await this._rescan({ op: OP_PUBLISHED, repo, version, channel: channel || '' })

    return { repo, version, channel: channel || null, manifest }
  }

  // ── Stream-friendly publish for binary uploads ────────────
  /**
   * Lower-level alternative to publish() for large artifacts uploaded
   * via HTTP streaming. Callers use startVersion → writeArtifact × N →
   * finalizeVersion so bytes never have to be buffered server-side.
   */
  async startVersion (repo, version) {
    validateName(repo, 'repo')
    validateName(version, 'version')
    const verDir = this._versionDir(repo, version)
    try {
      await stat(verDir)
      throw new Error(`version ${version} already exists for ${repo}`)
    } catch (err) {
      if (err.code !== 'ENOENT') throw err
    }
    await mkdir(verDir, { recursive: true })
    return verDir
  }

  async writeArtifact (repo, version, path, data) {
    validateName(repo, 'repo')
    validateName(version, 'version')
    validatePath(path)
    const abs = join(this._versionDir(repo, version), path)
    await mkdir(dirname(abs), { recursive: true })
    const buf = Buffer.isBuffer(data) ? data : Buffer.from(data)
    await writeFile(abs, buf)
    return { path, size: buf.length }
  }

  async finalizeVersion (repo, version, { channel, meta } = {}) {
    validateName(repo, 'repo')
    validateName(version, 'version')
    const verDir = this._versionDir(repo, version)

    // Walk the version dir to build the manifest's artifacts list
    const walk = async (dir, prefix = '') => {
      const out = []
      const entries = await readdir(dir, { withFileTypes: true })
      for (const e of entries) {
        if (e.name === MANIFEST_FILE) continue
        const rel = prefix ? prefix + '/' + e.name : e.name
        if (e.isDirectory()) {
          out.push(...(await walk(join(dir, e.name), rel)))
        } else if (e.isFile()) {
          const s = await stat(join(dir, e.name))
          out.push({ path: rel, size: s.size })
        }
      }
      return out
    }
    const written = await walk(verDir)

    const manifest = {
      version,
      builtAt: Date.now(),
      fromCommit: (meta && meta.fromCommit) || null,
      artifacts: written,
      sizeBytes: written.reduce((s, a) => s + a.size, 0),
      ...(meta || {})
    }
    await writeFile(join(verDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2))

    if (channel) await this._writeChannel(repo, channel, version)
    await this._rescan({ op: OP_PUBLISHED, repo, version, channel: channel || '' })

    return { repo, version, channel: channel || null, manifest }
  }

  // ── Channel ops ───────────────────────────────────────────
  async _writeChannel (repo, channel, version) {
    validateName(channel, 'channel')
    await mkdir(this._channelsDir(repo), { recursive: true })
    await writeFile(this._channelPath(repo, channel), JSON.stringify({
      version,
      pinnedAt: Date.now()
    }, null, 2))
  }

  /**
   * Re-index a published version whose files changed out of band, and wake the
   * mirrors.
   *
   * Publishing is not the last thing that writes into a version directory. The
   * vdrive step runs afterwards: it rewrites manifest.json to carry the
   * pear:// link and drops locations.json beside it. Both happen AFTER publish()
   * hashed the version into _index.json, so without this the index advertises a
   * content hash for bytes that no longer exist — a mirror fetches every
   * artifact, fails on the manifest, and the version reads `incomplete` on the
   * far side for ever. Measured in prod: manifest 665 B indexed, 880 B on disk,
   * nine seconds apart (#18).
   *
   * Idempotent, and safe to call when nothing changed: _writeIndex keeps a
   * hash cache keyed on (path, size, mtime), so unchanged artifacts cost a
   * stat rather than a re-hash. The event is a wake-up the mirror reacts to by
   * re-reading the index, so a duplicate is harmless.
   */
  async reannounce (repo, version, channel) {
    if (!repo || !version) throw new Error('reannounce requires { repo, version }')
    await this._rescan({ op: OP_PUBLISHED, repo, version, channel: channel || '' })
    return { repo, version, channel: channel || '' }
  }

  async promote (repo, version, toChannel) {
    if (!repo || !version || !toChannel) {
      throw new Error('promote requires { repo, version, toChannel }')
    }
    // Version must exist before we pin a channel to it
    try {
      await stat(this._versionDir(repo, version))
    } catch {
      throw new Error(`version ${version} not found for repo ${repo}`)
    }
    await this._writeChannel(repo, toChannel, version)
    await this._rescan({ op: OP_PROMOTED, repo, version, channel: toChannel })
    return { repo, version, channel: toChannel }
  }

  async unpinChannel (repo, channel) {
    if (!repo || !channel) throw new Error('unpinChannel requires { repo, channel }')
    try { await rm(this._channelPath(repo, channel)) } catch (err) {
      if (err.code !== 'ENOENT') throw err
    }
    await this._rescan({ op: OP_UNPINNED, repo, channel })
    return { repo, channel, unpinned: true }
  }

  // ── Read ops ──────────────────────────────────────────────
  async list (repo) {
    if (repo) return this._listOne(repo)
    const entries = await readdir(this._releaseDir, { withFileTypes: true }).catch(() => [])
    const all = []
    for (const e of entries) {
      if (e.isDirectory()) all.push(await this._listOne(e.name))
    }
    return all
  }

  async _listOne (repo) {
    const versions = []
    try {
      const verNames = await readdir(this._versionsDir(repo))
      for (const v of verNames) {
        try {
          const raw = await readFile(join(this._versionDir(repo, v), MANIFEST_FILE), 'utf8')
          versions.push(JSON.parse(raw))
        } catch {
          versions.push({ version: v, incomplete: true })
        }
      }
    } catch {}
    versions.sort((a, b) => (b.builtAt || 0) - (a.builtAt || 0))

    const channels = {}
    try {
      const chanFiles = await readdir(this._channelsDir(repo))
      for (const f of chanFiles) {
        if (!f.endsWith('.json')) continue
        const name = f.slice(0, -5)
        try {
          channels[name] = JSON.parse(await readFile(this._channelPath(repo, name), 'utf8'))
        } catch {}
      }
    } catch {}

    return { repo, versions, channels }
  }

  async getManifest (repo, version) {
    try {
      const raw = await readFile(join(this._versionDir(repo, version), MANIFEST_FILE), 'utf8')
      return JSON.parse(raw)
    } catch {
      return null
    }
  }

  // ── Delete / GC ────────────────────────────────────────────
  /** Unlink a version's bytes. No reindex, no event -- callers own those. */
  async _removeVersionDir (repo, version) {
    await rm(this._versionDir(repo, version), { recursive: true, force: true })
  }

  async deleteVersion (repo, version) {
    if (!repo || !version) throw new Error('deleteVersion requires { repo, version }')
    await this._removeVersionDir(repo, version)
    await this._rescan({ op: OP_DELETED, repo, version })
    return { repo, version, deleted: true }
  }

  /**
   * Garbage-collect stale versions.
   *
   * Default policy: keep any version pinned by any channel, plus the N
   * newest unpinned versions. Any older unpinned versions are candidates.
   *
   * @param {object} [opts]
   * @param {string} [opts.repo]      - restrict GC to one repo
   * @param {number} [opts.keepN=3]   - newest unpinned to keep per repo
   * @param {boolean} [opts.dryRun]   - report candidates without deleting
   */
  async gc (opts = {}) {
    const { repo, keepN = 3, dryRun = false } = opts
    const info = repo ? [await this._listOne(repo)] : await this.list()

    const candidates = []
    for (const r of info) {
      const pinned = new Set(Object.values(r.channels || {}).map(c => c.version))
      const unpinned = r.versions.filter(v => !pinned.has(v.version))
      // versions already sorted newest-first by _listOne
      for (const v of unpinned.slice(keepN)) {
        candidates.push({ repo: r.repo, version: v.version, reason: 'stale-unpinned' })
      }
    }

    const deleted = []
    if (!dryRun) {
      // Unlink EVERY candidate first, then reindex ONCE, then announce.
      //
      // Calling deleteVersion per candidate reindexed per candidate, and
      // _writeIndex walks every artifact in the release root and re-registers
      // each with the overlay -- so collecting N versions rebuilt the whole
      // index N times, synchronously, on the daemon's event loop. Collecting
      // favela-android on stortron (1117 indexed files) held ~45% of a core for
      // minutes and flapped the public forge 000/200 at roughly 90s per version
      // until the operator killed it (2026-08-29). The ~150 MB of unlinks was
      // never the cost; the repeated reindex was.
      for (const c of candidates) {
        try {
          await this._removeVersionDir(c.repo, c.version)
          deleted.push(c)
        } catch {}
      }
      if (deleted.length) {
        // ORDER IS LOAD-BEARING: reindex BEFORE any event. A mirror reacts to a
        // tombstone within milliseconds, so announcing while the index still
        // advertised the dead version would send it after bytes nobody holds --
        // the "no holder" failure in services/deleted-releases.js. Pinned by
        // release-gc-batched-rescan.test.js and release-index-serve-order.test.js.
        await this._reindex()
        for (const c of deleted) {
          await this._emit({ op: OP_DELETED, repo: c.repo, version: c.version })
        }
      }
    }

    return { candidates, deleted, dryRun }
  }

  // ── Internal ──────────────────────────────────────────────

  /**
   * Regenerate `<releaseDir>/_index.json` — the ONE well-known path a puller
   * can ask for without already knowing what exists.
   *
   * Why this file has to exist: the v2 overlay protocol (the canonical client
   * surface, what `pgh app install` uses) is content/path addressed and has no
   * directory listing — every request needs a path or hash you already know.
   * v1's `sendFileList` was orphaned when the unsolicited broadcast was removed
   * in hyper-deploy 0.4.1, so `DeployClient.pull()` waits on a message that is
   * never sent. This index restores discovery without reviving an unbounded
   * broadcast: it is bounded by RELEASE count, not by the 100k-file shared
   * dependency cache that broke v1 in the first place.
   *
   * Hashes are required, not optional: two backends routinely hold the same
   * (repo, version) with different bytes, and at least one observed case
   * (hardops.zip) had IDENTICAL SIZE with a different hash. A size-only diff
   * would silently treat those as equal.
   *
   * The previous index doubles as the hash cache — an entry whose path, size
   * and mtime are unchanged keeps its recorded hash, so a 244 MB artifact is
   * hashed once rather than on every publish.
   */
  async _writeIndex () {
    const prev = new Map()
    try {
      const raw = JSON.parse(await readFile(join(this._releaseDir, INDEX_FILE), 'utf8'))
      for (const f of raw.files || []) prev.set(f.path, f)
    } catch {}

    const files = []
    const repos = await this.list()
    for (const r of repos) {
      for (const v of r.versions || []) {
        if (!v.version || v.incomplete) continue
        // manifest.json travels WITH the artifacts. Without it a puller lands
        // the bytes but the version reads as `incomplete` on the far side, so
        // it never enters that peer's own index and never gets re-served —
        // replication would be a star with the publisher at the centre.
        // It is a reserved artifact path, hence listed explicitly here.
        //
        // locations.json is reserved for the same reason and was NOT listed,
        // which is why it never replicated despite being copied into the
        // version dir expressly so that it would (#18). A mirror then held every
        // byte and the vdrive key yet could not resolve a single block. Missing
        // files are skipped by the stat() below, so a release with no vdrive is
        // unaffected.
        const versionFiles = [...(v.artifacts || []).map(a => a.path), ...RESERVED_VERSION_FILES]
        for (const apath of versionFiles) {
          const rel = r.repo + '/versions/' + v.version + '/' + apath
          const abs = join(this._releaseDir, rel)
          let st
          try { st = await stat(abs) } catch { continue }
          const cached = prev.get(rel)
          const mtimeMs = Math.floor(st.mtimeMs)
          let hash = null
          if (cached && cached.size === st.size && cached.mtimeMs === mtimeMs && cached.hash) {
            hash = cached.hash
          } else {
            try { hash = hashChunk(await readFile(abs)) } catch { continue }
          }
          files.push({ path: rel, size: st.size, mtimeMs, hash, repo: r.repo, version: v.version })
        }
      }
    }

    // Channel pointers travel too — a puller that has the bytes but not the
    // pointer cannot answer "what is current?".
    const channels = []
    for (const r of repos) {
      for (const [name, c] of Object.entries(r.channels || {})) {
        channels.push({ repo: r.repo, channel: name, version: c.version, pinnedAt: c.pinnedAt || null })
      }
    }

    // Make every artifact fetchable BY CONTENT HASH over the V2 overlay. The
    // overlay is auto-attached to every hyper-svc swarm connection, so this is
    // what lets a peer actually pull the bytes the index advertises — without
    // it the index names files nobody can fetch. Namespaced so release
    // artifacts cannot collide with the attachment spool sharing this overlay.
    //
    // BEFORE the index is written, and that ordering is load-bearing. `_rescan`
    // appends the release-event that wakes every mirror immediately after this
    // returns, and a mirror reacts in milliseconds — so anything the index names
    // but the overlay has not been told about yet is answered "no holder". The
    // window is real work, not a scheduling hiccup: registerFile chunks and
    // hashes the file, which for a 49 MB AppImage is far longer than a mirror
    // needs to read the index and start asking.
    //
    // Measured on stortron 2026-08-17 (ifsrenderer-linux linux-port-14855c7):
    // the artifact registered FIRST arrived, and the three after it — including
    // a 508-byte manifest.json — all failed with "no holder", stranding the
    // version and leaving the channel pinned to the previous release. Size was
    // irrelevant; position in this loop was everything.
    //
    // Only NEW-or-CHANGED entries are registered. _writeIndex runs on every
    // mutation, and a mutation happens on every push-triggered build; a forge
    // with 558 versions would otherwise issue ~560 overlay index writes per
    // publish for content that has not moved. `prev` is the previous index, so
    // an unchanged hash means the overlay already knows about it.
    if (this._overlay && typeof this._overlay.registerFile === 'function') {
      let registered = 0
      const failed = []
      for (const f of files) {
        const before = prev.get(f.path)
        if (before && before.hash === f.hash) continue
        const overlayPath = RELEASE_OVERLAY_PREFIX + f.path
        const diskPath = join(this._releaseDir, f.path)
        try {
          await this._overlay.registerFile(overlayPath, diskPath,
            { contentHash: f.hash, size: f.size })
          registered++
        } catch (err) {
          // FALL BACK RATHER THAN LOSE THE REGISTRATION. The expensive half of
          // registerFile is the eager chunk-map build, which reads the whole
          // file — 471 MB for a Windows game bundle — and is explicitly
          // optional: without it the file is still servable from the path maps
          // and the serve path chunks lazily on first request. Losing the whole
          // registration because the optional half failed is what leaves the
          // index advertising a file the overlay can never serve.
          try {
            await this._overlay.registerFile(overlayPath, diskPath,
              { contentHash: f.hash, size: f.size, prepare: false })
            registered++
            console.warn('[releases] registered ' + f.path + ' WITHOUT a chunk map (' +
              (err && err.message ? err.message : err) + ') — first fetch will chunk lazily')
          } catch (err2) {
            failed.push({ path: f.path, error: err2 && err2.message ? err2.message : String(err2) })
          }
        }
      }
      this.lastRegistered = registered
      this.lastRegisterFailures = failed
      // NOT SWALLOWED. This used to be a bare `catch {}`, and registerFile is the
      // ONLY thing that writes the overlay's index — the one that decides what
      // this daemon can actually serve. A failure here leaves _index.json (which
      // is written unconditionally, below) advertising a file the overlay does
      // not know about, so every mirror asks for it forever and is told "no
      // holder", with nothing on the publishing side to point at.
      if (failed.length) {
        console.error('[releases] ' + failed.length + ' artifact(s) FAILED to register with the ' +
          'overlay — the index will advertise bytes this daemon cannot serve:')
        for (const f of failed) console.error('  ' + f.path + ': ' + f.error)
      }
    }

    const index = { schema: 1, generatedAt: Date.now(), files, channels }
    await writeFile(join(this._releaseDir, INDEX_FILE), JSON.stringify(index, null, 2))

    return index
  }

  /**
   * Re-point the overlay's in-memory serve maps at our release files.
   *
   * MUST run at boot or this daemon cannot serve a single release artifact it
   * did not publish since the last restart.
   *
   * hyper-overlay's `_filePaths` / `_contentHashPaths` maps are in-memory and
   * populated only by registerFile; FileIndex deliberately persists the
   * contentHash but NOT a disk path, because layout belongs to the consumer.
   * So after a restart the bytes are still on disk and the chunk map is still in
   * the index, but nothing maps hash → file: the content offer is silently
   * dropped and the requester just times out. `_writeIndex` cannot repair it
   * either — it registers only NEW-or-CHANGED entries, and an artifact whose
   * hash has not moved is skipped forever.
   *
   * Measured 2026-08-08: tabertron held favela-android's 136 MiB APK on disk and
   * advertised its exact hash in _index.json, while stortron got "no holder" for
   * that hash and for a 436-byte manifest beside it — after three restarts. The
   * same failure mode cost an atproto co-owner 75+ hours (hyper-overlay
   * seedFilePathsFromIndex docs) and pgh-sidecar a bespoke pre-seed.
   *
   * Cheap: one index scan, no file reads, no re-hashing.
   *
   * @returns {Promise<number>} entries seeded
   */
  async seedOverlayServeMap () {
    if (!this._overlay || typeof this._overlay.seedFilePathsFromIndex !== 'function') return 0
    // Collect what the OVERLAY knows while seeding, so the repair below can diff
    // it against what the RELEASE INDEX advertises. Two different indexes:
    // _index.json says what exists, the overlay's FileIndex says what can be
    // served, and only registerFile writes the second one.
    const known = new Set()
    let unhashed = 0
    const seeded = await this._overlay.seedFilePathsFromIndex((overlayPath, entry) => {
      if (!overlayPath || !overlayPath.startsWith(RELEASE_OVERLAY_PREFIX)) return null
      // ONLY an entry that actually carries a contentHash counts as known.
      //
      // The overlay persists `contentHash: null` for a file indexed while still
      // hashing, and such an entry seeds the PATH map but is unservable BY OID.
      // Adding it to `known` on path alone made the repair below skip it, so the
      // repair written for exactly this failure could not see it -- and since the
      // hash never changes, nothing ever retried. That is the "no holder for a
      // file that is on disk with a matching hash" bug: 2026-08-08 (136 MiB APK),
      // 2026-08-25 (471 MB zip), 2026-08-29 (168 MB APK).
      if (entry && entry.contentHash) known.add(overlayPath)
      else if (entry) unhashed++
      return join(this._releaseDir, overlayPath.slice(RELEASE_OVERLAY_PREFIX.length))
    })
    if (unhashed) {
      console.log('[releases] overlay index holds ' + unhashed +
        ' entry(s) with no contentHash - unservable by oid; repairing from the release index')
    }
    await this._repairOverlayRegistrations(known)
    return seeded
  }

  /**
   * Register anything the release index advertises that the overlay does not
   * know how to serve.
   *
   * WHY THIS IS NEEDED AT ALL. _writeIndex registers each artifact and THEN
   * writes _index.json — but it wrote the index whether or not the registrations
   * succeeded, and the failure was swallowed. So a single failed registerFile
   * left the two indexes permanently disagreeing: mirrors read _index.json, ask
   * for a hash by content, and are told "no holder" forever. Nothing re-tried,
   * because _writeIndex only registers NEW-or-CHANGED entries and the hash never
   * changes.
   *
   * Measured 2026-08-25 (hyper-tps v0.919.0): tabertron held a 471 MB
   * hypertps-win64.zip on disk, advertised its exact hash in _index.json, and
   * stortron retried "no holder for …dist/hypertps-win64.zip (07af6b4a31a0)"
   * indefinitely — while build.log and manifest.json from the SAME publish
   * replicated fine. Same peer, same connection, same pass: per-content, not
   * per-peer.
   *
   * CHEAP BY CONSTRUCTION: prepare:false, so no file is read and nothing is
   * re-hashed — the content hash is already in the index. It restores the
   * ability to serve; the chunk map is rebuilt lazily on first request.
   */
  async _repairOverlayRegistrations (known) {
    if (!this._overlay || typeof this._overlay.registerFile !== 'function') return 0
    let index
    try {
      index = JSON.parse(await readFile(join(this._releaseDir, INDEX_FILE), 'utf8'))
    } catch {
      return 0 // no index yet — nothing published on this daemon
    }
    if (!index || !Array.isArray(index.files)) return 0

    let repaired = 0
    const failed = []
    for (const f of index.files) {
      if (!f || !f.path || !f.hash) continue
      const overlayPath = RELEASE_OVERLAY_PREFIX + f.path
      if (known.has(overlayPath)) continue
      try {
        // Returns null (not a throw) when the file is gone — a version collected
        // by gc is expected here, not an error.
        const r = await this._overlay.registerFile(overlayPath, join(this._releaseDir, f.path),
          { contentHash: f.hash, size: f.size, prepare: false })
        if (r) repaired++
      } catch (err) {
        failed.push(f.path + ': ' + (err && err.message ? err.message : err))
      }
    }
    if (repaired) {
      console.log('[releases] overlay repair: registered ' + repaired +
        ' artifact(s) the index advertised but the overlay could not serve')
    }
    if (failed.length) {
      console.error('[releases] overlay repair FAILED for ' + failed.length + ' artifact(s):')
      for (const f of failed) console.error('  ' + f)
    }
    return repaired
  }

  /** Rebuild _index.json (which re-registers artifacts) and rescan the deploy dir. */
  async _reindex () {
    try { await this._writeIndex() } catch {}
    if (this._deploy && typeof this._deploy.rescan === 'function') {
      try { await this._deploy.rescan() } catch {}
    }
  }

  /** Announce one mutation. Non-fatal: a publish must never fail on a notify. */
  async _emit (event) {
    if (!event || !this._eventLog || !this._eventLog.writable) return
    try {
      await this._eventLog.append(event)
    } catch (err) {
      // eslint-disable-next-line no-console
      console.error('[release-manager] event append failed:', err && err.message)
    }
  }

  /**
   * One mutation: reindex, then announce it.
   *
   * Index BEFORE the deploy rescan (the deploy server indexes the directory
   * during rescan, so an _index.json written afterwards would not be servable
   * until the next unrelated mutation), and the event LAST -- so a mirror that
   * reacts instantly reads an index already describing the new state.
   *
   * gc() deliberately does NOT use this: it unlinks every candidate, calls
   * _reindex() once, and then _emit()s each tombstone, which preserves the same
   * ordering for a fraction of the cost. See the comment in gc().
   */
  async _rescan (event = null) {
    await this._reindex()
    await this._emit(event)
  }
}

// Reject path-traversal + anything that'd escape the release dir
function validateName (name, kind) {
  if (typeof name !== 'string' || !name) throw new Error(`${kind} must be a non-empty string`)
  if (name.includes('/') || name.includes('\\') || name.includes('..') || name.startsWith('.')) {
    throw new Error(`invalid ${kind}: ${name}`)
  }
}

function validatePath (p) {
  if (typeof p !== 'string' || !p) throw new Error('artifact path must be a non-empty string')
  if (p.startsWith('/') || p.includes('..')) throw new Error('artifact path must be relative + within version dir: ' + p)
  if (p === MANIFEST_FILE) throw new Error(`artifact path cannot be "${MANIFEST_FILE}" — that's reserved`)
}