@opennodes/registry 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennodes/registry",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "OpenNodes (ONP) reference registry: node registration with proof-of-resource qualification (Stage A/B/C), transparent ranking, enforceable estimates, catalog importers (OpenRouter, Hugging Face, models.dev, Ollama), MCP surface, and the web app (catalog, chat, advisor, Node Builder)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/feed.js ADDED
@@ -0,0 +1,42 @@
1
+ // Feed shaping: a node that keeps dropping offline and coming back would otherwise fill
2
+ // the public feed with suspended/reinstated pairs. Consecutive liveness transitions of
3
+ // one node collapse into a single availability event; everything else passes through.
4
+
5
+ const isLiveness = (e) => /^(liveness:|reinstated:)/.test(e.reason ?? '');
6
+
7
+ /** events: newest first. Returns newest first, with liveness runs (2+ events) collapsed. */
8
+ export function collapseFlaps(events) {
9
+ const out = [];
10
+ const open = new Map(); // node_id -> collapsed entry still accepting older liveness events
11
+ for (const e of events) {
12
+ if (!isLiveness(e)) {
13
+ open.delete(e.node_id);
14
+ out.push(e);
15
+ continue;
16
+ }
17
+ const run = open.get(e.node_id);
18
+ if (run) {
19
+ run.events.push(e);
20
+ continue;
21
+ }
22
+ const entry = { ...e, events: [e] };
23
+ open.set(e.node_id, entry);
24
+ out.push(entry);
25
+ }
26
+ return out.map((entry) => {
27
+ if (!entry.events) return entry;
28
+ const { events: run, ...newest } = entry;
29
+ if (run.length === 1) return newest;
30
+ const oldest = run[run.length - 1];
31
+ const outages = run.filter((e) => e.to === 'suspended').length;
32
+ const now = newest.to === 'suspended' ? 'suspended now' : `${newest.to} again`;
33
+ return {
34
+ ...newest,
35
+ from: oldest.from,
36
+ kind: 'availability',
37
+ collapsed: run.length,
38
+ since: oldest.at,
39
+ reason: `availability: ${outages} outage${outages === 1 ? '' : 's'} since ${oldest.at.slice(0, 10)}, ${now}`,
40
+ };
41
+ });
42
+ }
package/src/server.js CHANGED
@@ -19,6 +19,7 @@ import { searchOfferings, searchOfferingsPaged, collectOfferings, RANK_COMPONENT
19
19
  import { mcpRoutes } from './mcp.js';
20
20
  import { hostControlsNamespace, isPrivateHost, makeRateLimiter } from './namespace.js';
21
21
  import { exportRoutes } from './export.js';
22
+ import { collapseFlaps } from './feed.js';
22
23
  import {
23
24
  importModelsDev, importHuggingFace, importOpenRouter,
24
25
  importOllamaLibrary, parseOllamaLibrary,
@@ -321,13 +322,16 @@ export async function startRegistry({
321
322
 
322
323
  // Freshness feed: recent listing events (registrations, tier changes, suspensions),
323
324
  // newest first, each linked to its provenance reason.
325
+ // Liveness flaps (suspended/reinstated runs) collapse into one availability event;
326
+ // ?raw=1 returns every transition.
324
327
  ['GET', '/v0/feed', async (req, res, { query }) => {
325
328
  const limit = Math.min(Number(query.get('limit') ?? 50), 200);
326
- const events = (await store.recentTransitions(limit)).map((t) => ({
329
+ const raw = query.get('raw') === '1';
330
+ const events = (await store.recentTransitions(raw ? limit : Math.min(limit * 10, 2000))).map((t) => ({
327
331
  at: t.at, node_id: t.node_id, from: t.from_state, to: t.to_state,
328
332
  reason: t.reason, source: t.source ?? 'registration',
329
333
  }));
330
- sendJson(res, 200, { events });
334
+ sendJson(res, 200, { events: raw ? events : collapseFlaps(events).slice(0, limit) });
331
335
  }],
332
336
 
333
337
  ['GET', '/v0/nodes/:id', async (req, res, { params }) => {
@@ -399,6 +403,27 @@ export async function startRegistry({
399
403
  sendJson(res, 200, recommend(all, features, body.policy ?? {}));
400
404
  }],
401
405
 
406
+ // Delist (admin): operator opt-out of an imported listing, or retiring a node. Search,
407
+ // the directory and Stage C skip delisted nodes, and re-imports never revive them.
408
+ // Body {reason}. A registered node returns by registering again; relist re-opens imports.
409
+ ['POST', '/v0/admin/nodes/:id/delist', async (req, res, { params }) => {
410
+ if (!adminOk(req, res)) return;
411
+ const node = await store.getNode(params.id);
412
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
413
+ const { reason } = await readJson(req);
414
+ if (node.state !== 'delisted') await store.setState(node.id, 'delisted', `delisted: ${reason || 'by registry admin'}`);
415
+ sendJson(res, 200, { id: node.id, state: 'delisted' });
416
+ }],
417
+ ['POST', '/v0/admin/nodes/:id/relist', async (req, res, { params }) => {
418
+ if (!adminOk(req, res)) return;
419
+ const node = await store.getNode(params.id);
420
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
421
+ if (node.state !== 'delisted') return problem(res, 409, 'not-delisted', `${node.id} is ${node.state}`);
422
+ if (node.source !== 'import') return problem(res, 409, 'register-again', 'a registered node returns by registering again (POST /v0/nodes)');
423
+ await store.setState(node.id, 'indexed', 'relisted by registry admin');
424
+ sendJson(res, 200, { id: node.id, state: 'indexed' });
425
+ }],
426
+
402
427
  // Seed importer (admin): body {catalog} in models.dev shape, or {url} to fetch it.
403
428
  ['POST', '/v0/import/models-dev', async (req, res) => {
404
429
  if (!adminOk(req, res)) return;
@@ -317,7 +317,7 @@
317
317
  <!-- ================= NEW (feed) ================= -->
318
318
  <div id="view-feed" hidden>
319
319
  <h1>New</h1>
320
- <p class="sub">Dated listing events, newest first — registrations, tier changes, suspensions — each with its provenance. Also available as <a id="feed-json" href="/v0/feed">JSON</a> for agents.</p>
320
+ <p class="sub">Dated listing events, newest first — registrations, tier changes, suspensions — each with its provenance. A node that keeps dropping offline and coming back shows as one availability line. Also available as <a id="feed-json" href="/v0/feed">JSON</a> for agents (<a href="/v0/feed?raw=1">every transition</a>).</p>
321
321
  <div class="panel pad" id="feed-list"></div>
322
322
  </div>
323
323
 
@@ -1729,7 +1729,7 @@ async function loadFeed() {
1729
1729
  $('#feed-list').innerHTML = events.length ? events.map((e) => `<div class="feeditem">
1730
1730
  <span class="prov">${esc((e.at || '').replace('T', ' ').slice(0, 16))}</span>
1731
1731
  <span><a href="#/node/${encodeURIComponent(e.node_id)}" class="mono" style="font-size:0.78rem">${esc(e.node_id)}</a>
1732
- → ${tierChip(e.to)} ${e.reason ? '<span style="color:var(--muted)">— ' + esc(e.reason) + '</span>' : ''}</span>
1732
+ → ${tierChip(e.to)} ${e.reason ? '<span style="color:var(--muted)">— ' + esc(e.reason) + (e.collapsed ? ' (' + e.collapsed + ' events)' : '') + '</span>' : ''}</span>
1733
1733
  </div>`).join('') : '<p class="sub">No events yet.</p>';
1734
1734
  } catch (e) { $('#feed-list').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
1735
1735
  }