OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / b3bfec8

0.1.243: mark registry entries whose tree this forge does not hold
The "Published to pgh" list advertised installs that cannot work. The registry and the tree store have OPPOSITE lifecycle rules: - the tree store is content-addressed and garbage-collected; a tree survives only while referenced, and PublishedIndex keeps maxVersions = 1, so old versions are swept by `pgh store gc`. - the ReleaseRegistry is an append-only signed autobase with NO retention. They diverge by construction. Measured on tabertron 2026-08-29 for hyper-tps: 382 advertised tree hashes, 10 present in the local store, 372 absent -- and every one of those 372 rows printed a confident `pgh app install pgh://<key>/hyper-tps@<version>` resolving to nothing here. tree-registry-list now annotates each entry with heldLocally, and the page dims the row, badges it "not stored here", replaces the install command with "unavailable from this forge", and summarises the count above the table. WORDING IS DELIBERATE: heldLocally:false means NOT HELD HERE, never "gone". The registry is replicated and another peer may hold the tree; only the local answer is cheap enough per render. Nothing is hidden or dropped -- the registry really does contain these entries. Marking, not pruning: the registry is signed and replicated, so removing entries is a far heavier action and wants its own keep-N with its own review. Reads the TREE store, not the sidecar's registry corestore. The "do NOT open the store here" warning above the handler is about the RocksDB lock 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. A probe that throws degrades to not-held rather than asserting something unverified. Pre-existing and unrelated: test/release-overlay-repair.test.js fails 3/6, confirmed by reverting lib/overlay.js to pre-0.3.13 -- identical failure. 10/11 suites pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b3bfec8eab73bbfe593bc12e9114b56ae24226b3
by plankatron <fleeky@gmail.com> · 2026-08-29T19:57:28.000Z

Changed files (4) +182 -9

lib/handlers/pgh-tree.js +44 -1
……@@ -27,6 +27,39 @@ import { Client } from 'hyper-svc'
2727import { requireWriter } from '../auth/check.js'
2828import { sidecarSockPath } from '../sidecar-wait.js'
2929
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 */
52export 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
3063export function pghTreeHandlers (svc) {
3164 const registry = () => svc.identityRegistry
3265
……@@ -109,10 +142,20 @@ export function pghTreeHandlers (svc) {
109142 const info = await c.call('registry-info', {})
110143 const entries = await c.call('releases-summary',
111144 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 */ }
112155 return {
113156 registryKey: info && info.keyHex ? info.keyHex : null,
114157 count: entries.length,
115 entries
158 entries: markHeldLocally(entries, probe)
116159 }
117160 } catch (err) {
118161 // A forge whose sidecar is down should render an empty, explained
lib/web/pages/repo-releases.js +26 -7
……@@ -16,6 +16,10 @@ import { renderRepoTabs, REPO_TAB_STYLES } from '../components/repo-tabs.js'
1616
1717const RELEASES_STYLES = `
1818<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
1923 .channel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin: 0.6rem 0 1rem; }
2024 .channel-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 0.9rem 1rem; }
2125 .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) {
8690 .map(e => ({ ...e, _ms: toMs(e.publishedAt) }))
8791 .sort((a, b) => b._ms - a._ms)
8892
93 const absentCount = treeEntries.filter(e => e.heldLocally === false).length
94
8995 const { versions, channels } = await svc._releases.list(repoName)
9096 const pinnedVersions = new Set(Object.values(channels || {}).map(c => c.version))
9197
……@@ -115,6 +121,13 @@ ${renderRepoTabs(repoName, 'releases', {}, prefix)}
115121 fetch. Separate from the build artifacts below; the two are not the same namespace
116122 and a repo may have either, both, or neither.
117123 </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 : ''}
118131 ${treeRows.unavailable
119132 ? `<div class="empty">Registry unavailable — ${escapeHtml(treeRows.unavailable)}</div>`
120133 : treeEntries.length === 0
……@@ -122,17 +135,23 @@ ${renderRepoTabs(repoName, 'releases', {}, prefix)}
122135 : `<table class="version-table">
123136 <thead><tr><th>Version</th><th>Published</th><th>Deps</th><th>Tree</th><th>Install</th></tr></thead>
124137 <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>
127144 <td>${e._ms ? timeAgo(e._ms) : '<span class="version-stale">unknown</span>'}</td>
128145 <td class="artifact-list">${e.depCount == null ? '' : escapeHtml(String(e.depCount))}</td>
129146 <td class="artifact-list" title="${escapeHtml(String(e.treeHash || ''))}">${escapeHtml(String(e.treeHash || '').slice(0, 12))}</td>
130147 <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 : '')
136155 }</td>
137156 </tr>`).join('')}
138157 </tbody>
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.242",
3 "version": "0.1.243",
44 "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
test/registry-held-locally.test.js +111 -0
……@@ -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
21import test from 'brittle'
22
23import { markHeldLocally } from '../lib/handlers/pgh-tree.js'
24
25test('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
41test('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
46test('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
57test('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
63test('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
71import { renderRepoReleases } from '../lib/web/pages/repo-releases.js'
72
73function 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
85test('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
103test('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})