| … | … | @@ -27,6 +27,39 @@ import { Client } from 'hyper-svc' |
| 27 | 27 | import { requireWriter } from '../auth/check.js' |
| 28 | 28 | import { sidecarSockPath } from '../sidecar-wait.js' |
| 29 | 29 | |
| 30 | /** | |
| 31 | * Mark each registry entry with whether THIS machine holds its tree. | |
| 32 | * | |
| 33 | * The ReleaseRegistry is append-only with no retention; the tree store is | |
| 34 | * content-addressed and garbage-collected. They diverge by construction, so a | |
| 35 | * registry row routinely names a tree that was swept long ago. Measured on | |
| 36 | * tabertron 2026-08-29 for hyper-tps: 382 advertised tree hashes, 10 present, | |
| 37 | * 372 absent -- every one of those 372 rendering an install command that | |
| 38 | * resolves to nothing here. | |
| 39 | * | |
| 40 | * `heldLocally: false` means NOT HELD HERE, never "gone". The registry is | |
| 41 | * replicated and another peer may well hold the tree; only the local answer is | |
| 42 | * cheap enough to compute per page render. Callers must word it that way. | |
| 43 | * | |
| 44 | * Pure and defensive: input objects are not mutated, a missing hash is never | |
| 45 | * claimed as held, and a probe that throws (permissions, absent store) degrades | |
| 46 | * to not-held rather than asserting something it did not verify. | |
| 47 | * | |
| 48 | * @param {Array<object>} entries | |
| 49 | * @param {(treeHash: string) => boolean} hasTree | |
| 50 | * @returns {Array<object>} copies, each with heldLocally | |
| 51 | */ | |
| 52 | export function markHeldLocally (entries, hasTree) { | |
| 53 | if (!Array.isArray(entries)) return [] | |
| 54 | return entries.map((e) => { | |
| 55 | let held = false | |
| 56 | if (e && e.treeHash) { | |
| 57 | try { held = !!hasTree(e.treeHash) } catch { held = false } | |
| 58 | } | |
| 59 | return { ...e, heldLocally: held } | |
| 60 | }) | |
| 61 | } | |
| 62 | ||
| 30 | 63 | export function pghTreeHandlers (svc) { |
| 31 | 64 | const registry = () => svc.identityRegistry |
| 32 | 65 | |
| … | … | @@ -109,10 +142,20 @@ export function pghTreeHandlers (svc) { |
| 109 | 142 | const info = await c.call('registry-info', {}) |
| 110 | 143 | const entries = await c.call('releases-summary', |
| 111 | 144 | params.name ? { name: params.name } : {}) |
| 145 | // Annotate with local resolvability. This reads the TREE store, not | |
| 146 | // the sidecar's registry corestore -- the lock the comment above warns | |
| 147 | // about is RocksDB on ~/.config/pgh/release-registry. The tree store | |
| 148 | // is a plain hardlink directory, so hasTree() is an existsSync plus a | |
| 149 | // small meta read: no lock, no init, no side effects. | |
| 150 | let probe = () => false | |
| 151 | try { | |
| 152 | const ts = new TreeStore() | |
| 153 | probe = (h) => ts.hasTree(h) | |
| 154 | } catch { /* no store on this machine -- everything reads as not held */ } | |
| 112 | 155 | return { |
| 113 | 156 | registryKey: info && info.keyHex ? info.keyHex : null, |
| 114 | 157 | count: entries.length, |
| 115 | entries | |
| 158 | entries: markHeldLocally(entries, probe) | |
| 116 | 159 | } |
| 117 | 160 | } catch (err) { |
| 118 | 161 | // A forge whose sidecar is down should render an empty, explained |
| … | … | @@ -16,6 +16,10 @@ import { renderRepoTabs, REPO_TAB_STYLES } from '../components/repo-tabs.js' |
| 16 | 16 | |
| 17 | 17 | const RELEASES_STYLES = ` |
| 18 | 18 | <style> |
| 19 | /* A registry row whose tree this forge does not hold. Dimmed, not hidden: | |
| 20 | the registry genuinely contains it, and another peer may hold the bytes. */ | |
| 21 | .version-table tr.tree-absent td { opacity: 0.55; } | |
| 22 | ||
| 19 | 23 | .channel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin: 0.6rem 0 1rem; } |
| 20 | 24 | .channel-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 0.9rem 1rem; } |
| 21 | 25 | .channel-card h3 { margin: 0 0 0.3rem; font-size: 0.75rem; text-transform: uppercase; color: #8b949e; letter-spacing: 0.5px; } |
| … | … | @@ -86,6 +90,8 @@ export async function renderRepoReleases (svc, ctx, repoName) { |
| 86 | 90 | .map(e => ({ ...e, _ms: toMs(e.publishedAt) })) |
| 87 | 91 | .sort((a, b) => b._ms - a._ms) |
| 88 | 92 | |
| 93 | const absentCount = treeEntries.filter(e => e.heldLocally === false).length | |
| 94 | ||
| 89 | 95 | const { versions, channels } = await svc._releases.list(repoName) |
| 90 | 96 | const pinnedVersions = new Set(Object.values(channels || {}).map(c => c.version)) |
| 91 | 97 | |
| … | … | @@ -115,6 +121,13 @@ ${renderRepoTabs(repoName, 'releases', {}, prefix)} |
| 115 | 121 | fetch. Separate from the build artifacts below; the two are not the same namespace |
| 116 | 122 | and a repo may have either, both, or neither. |
| 117 | 123 | </div> |
| 124 | ${absentCount > 0 | |
| 125 | ? `<div class="meta" style="margin-bottom:0.6rem"> | |
| 126 | <strong>${absentCount}</strong> of ${treeEntries.length} not stored on this forge. | |
| 127 | The registry is append-only; the tree store is garbage-collected, so older | |
| 128 | entries outlive their bytes. Another peer may still hold them. | |
| 129 | </div>` | |
| 130 | : ''} | |
| 118 | 131 | ${treeRows.unavailable |
| 119 | 132 | ? `<div class="empty">Registry unavailable — ${escapeHtml(treeRows.unavailable)}</div>` |
| 120 | 133 | : treeEntries.length === 0 |
| … | … | @@ -122,17 +135,23 @@ ${renderRepoTabs(repoName, 'releases', {}, prefix)} |
| 122 | 135 | : `<table class="version-table"> |
| 123 | 136 | <thead><tr><th>Version</th><th>Published</th><th>Deps</th><th>Tree</th><th>Install</th></tr></thead> |
| 124 | 137 | <tbody> |
| 125 | ${treeEntries.map(e => `<tr> | |
| 126 | <td><strong>${escapeHtml(String(e.version || ''))}</strong>${e.runtime ? ` <span class="meta">${escapeHtml(e.runtime)}</span>` : ''}</td> | |
| 138 | ${treeEntries.map(e => `<tr${e.heldLocally === false ? ' class="tree-absent"' : ''}> | |
| 139 | <td><strong>${escapeHtml(String(e.version || ''))}</strong>${e.runtime ? ` <span class="meta">${escapeHtml(e.runtime)}</span>` : ''}${ | |
| 140 | e.heldLocally === false | |
| 141 | ? ' <span class="badge badge-closed" title="This forge does not hold this tree. The registry is append-only while the tree store is garbage-collected, so older entries routinely outlive their bytes. Another peer may still hold it.">not stored here</span>' | |
| 142 | : '' | |
| 143 | }</td> | |
| 127 | 144 | <td>${e._ms ? timeAgo(e._ms) : '<span class="version-stale">unknown</span>'}</td> |
| 128 | 145 | <td class="artifact-list">${e.depCount == null ? '' : escapeHtml(String(e.depCount))}</td> |
| 129 | 146 | <td class="artifact-list" title="${escapeHtml(String(e.treeHash || ''))}">${escapeHtml(String(e.treeHash || '').slice(0, 12))}</td> |
| 130 | 147 | <td class="artifact-list">${ |
| 131 | e.upgrade | |
| 132 | ? `<code>pgh app install ${escapeHtml(e.upgrade)}@${escapeHtml(String(e.version || ''))}</code>` | |
| 133 | : (treeRows.registryKey | |
| 134 | ? `<code>pgh app install pgh://${escapeHtml(treeRows.registryKey)}/${escapeHtml(String(e.name || repoName))}@${escapeHtml(String(e.version || ''))}</code>` | |
| 135 | : '') | |
| 148 | e.heldLocally === false | |
| 149 | ? '<span class="meta">unavailable from this forge</span>' | |
| 150 | : e.upgrade | |
| 151 | ? `<code>pgh app install ${escapeHtml(e.upgrade)}@${escapeHtml(String(e.version || ''))}</code>` | |
| 152 | : (treeRows.registryKey | |
| 153 | ? `<code>pgh app install pgh://${escapeHtml(treeRows.registryKey)}/${escapeHtml(String(e.name || repoName))}@${escapeHtml(String(e.version || ''))}</code>` | |
| 154 | : '') | |
| 136 | 155 | }</td> |
| 137 | 156 | </tr>`).join('')} |
| 138 | 157 | </tbody> |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.242", | |
| 3 | "version": "0.1.243", | |
| 4 | 4 | "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -0,0 +1,111 @@ |
| 1 | // The "Published to pgh" list must not advertise installs that cannot work. | |
| 2 | // | |
| 3 | // The registry and the tree store have OPPOSITE lifecycle rules: | |
| 4 | // | |
| 5 | // - the tree store is content-addressed and garbage-collected; a tree | |
| 6 | // survives only while something references it, and PublishedIndex keeps | |
| 7 | // maxVersions = 1, so old versions are swept by `pgh store gc`. | |
| 8 | // - the ReleaseRegistry is an append-only signed autobase with NO retention. | |
| 9 | // | |
| 10 | // They therefore diverge by construction. Measured on tabertron 2026-08-29 for | |
| 11 | // hyper-tps: the registry advertised 382 distinct tree hashes, of which 10 were | |
| 12 | // present in the local store and 372 were not. Every one of those 372 rows | |
| 13 | // printed a confident `pgh app install pgh://<key>/hyper-tps@<version>` that | |
| 14 | // resolves to nothing here. | |
| 15 | // | |
| 16 | // Marking is deliberately chosen over pruning: the registry is signed and | |
| 17 | // replicated, so removing entries is a far heavier action, and "not held HERE" | |
| 18 | // is not the same as "gone" -- another peer may still hold the tree. The label | |
| 19 | // has to say the former, never the latter. | |
| 20 | ||
| 21 | import test from 'brittle' | |
| 22 | ||
| 23 | import { markHeldLocally } from '../lib/handlers/pgh-tree.js' | |
| 24 | ||
| 25 | test('entries are marked by whether their tree is held locally', (t) => { | |
| 26 | const entries = [ | |
| 27 | { name: 'app', version: '1.0.0', treeHash: 'aa'.repeat(32) }, | |
| 28 | { name: 'app', version: '0.9.0', treeHash: 'bb'.repeat(32) }, | |
| 29 | { name: 'app', version: '0.8.0', treeHash: 'cc'.repeat(32) } | |
| 30 | ] | |
| 31 | const held = new Set([ 'aa'.repeat(32) ]) | |
| 32 | ||
| 33 | const out = markHeldLocally(entries, (h) => held.has(h)) | |
| 34 | ||
| 35 | t.is(out.length, 3, 'nothing is dropped -- the registry is reported as it is') | |
| 36 | t.ok(out[0].heldLocally, 'present tree marked held') | |
| 37 | t.absent(out[1].heldLocally, 'absent tree marked NOT held') | |
| 38 | t.absent(out[2].heldLocally) | |
| 39 | }) | |
| 40 | ||
| 41 | test('an entry with no tree hash is not claimed to be held', (t) => { | |
| 42 | const out = markHeldLocally([{ name: 'app', version: '1.0.0' }], () => true) | |
| 43 | t.absent(out[0].heldLocally, 'no hash means we cannot assert it is resolvable') | |
| 44 | }) | |
| 45 | ||
| 46 | test('a throwing probe degrades to "unknown", never to a false claim', (t) => { | |
| 47 | // A stat can fail for reasons that say nothing about the tree (permissions, | |
| 48 | // a store that has not been created yet). Reporting "held" there would put | |
| 49 | // an install command on screen that does not work. | |
| 50 | const out = markHeldLocally( | |
| 51 | [{ name: 'app', version: '1.0.0', treeHash: 'dd'.repeat(32) }], | |
| 52 | () => { throw new Error('EACCES') } | |
| 53 | ) | |
| 54 | t.absent(out[0].heldLocally, 'probe failure is not treated as held') | |
| 55 | }) | |
| 56 | ||
| 57 | test('marking is pure -- the input entries are not mutated', (t) => { | |
| 58 | const entries = [{ name: 'app', version: '1.0.0', treeHash: 'ee'.repeat(32) }] | |
| 59 | markHeldLocally(entries, () => true) | |
| 60 | t.absent('heldLocally' in entries[0], 'caller keeps its own objects intact') | |
| 61 | }) | |
| 62 | ||
| 63 | test('an empty or missing list is handled', (t) => { | |
| 64 | t.alike(markHeldLocally([], () => true), []) | |
| 65 | t.alike(markHeldLocally(null, () => true), []) | |
| 66 | t.alike(markHeldLocally(undefined, () => true), []) | |
| 67 | }) | |
| 68 | ||
| 69 | // ── Page rendering ─────────────────────────────────────────── | |
| 70 | ||
| 71 | import { renderRepoReleases } from '../lib/web/pages/repo-releases.js' | |
| 72 | ||
| 73 | function svcWith (entries) { | |
| 74 | return { | |
| 75 | _releases: { async list () { return { versions: [], channels: {} } } }, | |
| 76 | async dispatch (name) { | |
| 77 | if (name === 'tree-registry-list') { | |
| 78 | return { registryKey: 'k'.repeat(64), count: entries.length, entries } | |
| 79 | } | |
| 80 | return {} | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | test('the page marks absent trees and refuses to print an install for them', async (t) => { | |
| 86 | const html = await renderRepoReleases(svcWith([ | |
| 87 | { name: 'demo', version: '2.0.0', treeHash: 'aa'.repeat(32), publishedAt: 2, heldLocally: true }, | |
| 88 | { name: 'demo', version: '1.0.0', treeHash: 'bb'.repeat(32), publishedAt: 1, heldLocally: false } | |
| 89 | ]), 'demo', {}) | |
| 90 | ||
| 91 | t.ok(html.includes('not stored here'), 'the absent row is labelled') | |
| 92 | t.ok(html.includes('tree-absent'), 'and dimmed via its own class') | |
| 93 | t.ok(html.includes('1</strong> of 2 not stored on this forge'), 'summary counts it') | |
| 94 | ||
| 95 | // The held version keeps a usable command; the absent one must not have one. | |
| 96 | t.ok(html.includes('pgh app install pgh://' + 'k'.repeat(64) + '/demo@2.0.0'), | |
| 97 | 'held version still offers an install') | |
| 98 | t.absent(html.includes('/demo@1.0.0'), | |
| 99 | 'absent version does NOT advertise an install that cannot resolve') | |
| 100 | t.ok(html.includes('unavailable from this forge'), 'it says so instead') | |
| 101 | }) | |
| 102 | ||
| 103 | test('a fully-held list shows no summary and no markers', async (t) => { | |
| 104 | const html = await renderRepoReleases(svcWith([ | |
| 105 | { name: 'demo', version: '2.0.0', treeHash: 'aa'.repeat(32), publishedAt: 2, heldLocally: true } | |
| 106 | ]), 'demo', {}) | |
| 107 | ||
| 108 | t.absent(html.includes('not stored here'), 'no marker') | |
| 109 | t.absent(html.includes('not stored on this forge'), 'no summary noise when everything resolves') | |
| 110 | t.ok(html.includes('pgh app install'), 'install still offered') | |
| 111 | }) |