From 5d0bea08d3f6e5446b81dd7f430622db77dc9943 Mon Sep 17 00:00:00 2001 From: Worph Date: Sun, 4 Oct 2026 17:19:29 +0000 Subject: [PATCH] meshdisco.ts: beacon v2 scan (probe + classify) mirrored from meta-sort; LeaderClient.scan passthrough --- .../meta-dup-core/src/discovery/meshdisco.ts | 97 +++++++++++++++++++ packages/meta-dup-core/src/kv/LeaderClient.ts | 7 +- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/meta-dup-core/src/discovery/meshdisco.ts b/packages/meta-dup-core/src/discovery/meshdisco.ts index 7c1a414..b624931 100644 --- a/packages/meta-dup-core/src/discovery/meshdisco.ts +++ b/packages/meta-dup-core/src/discovery/meshdisco.ts @@ -102,6 +102,85 @@ export interface MeshNodeConfig { payload?: () => { status?: string; resources: BeaconResource[] }; } +/** How a discovered resource relates to this consumer (Scan card). */ +export type ScanState = 'configured' | 'addable' | 'bound-elsewhere'; + +export interface ScanCandidate { + instance: string; + name: string; + version?: string; + resourceId: string; + caps: string[]; + /** endpoints.http — what the consumer would register. */ + url: string; + binds?: string; + state: ScanState; + /** The list entry that already holds this URL (state `configured`). */ + configuredAs?: string; + /** A list-safe name to register it under. */ + suggestedName: string; +} + +/** Same JSON as meta-feeder-sdk `beacon::ScanReport`. */ +export interface ScanReport { + cap: string; + self: string; + durationMs: number; + summary: { nodes: number; capable: number; configured: number; boundElsewhere: number; addable: number }; + candidates: ScanCandidate[]; +} + +/** Lower-case, `[a-z0-9._-]` only — accepted by every consumer's name rules. */ +export function suggestedName(instance: string): string { + const s = instance.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-'); + return s || 'plugin'; +} + +const normUrl = (u: string) => u.trim().replace(/\/+$/, ''); + +/** + * Classify every resource matching `pattern` against the consumer's + * `(name, url)` list. Mirrors meta-feeder-sdk `beacon::classify`. + */ +export function classifyScan( + pattern: string, + self: string, + neighbors: MeshNeighbor[], + configured: Array<[string, string]>, +): ScanReport { + const summary = { nodes: neighbors.length, capable: 0, configured: 0, boundElsewhere: 0, addable: 0 }; + const candidates: ScanCandidate[] = []; + for (const n of neighbors) { + for (const r of n.resources) { + if (!resourceMatches(r, pattern)) continue; + const http = resourceEndpoint(r, 'http'); + if (!http) continue; // nothing a consumer could register + const url = normUrl(http); + summary.capable++; + const hit = configured.find(([, u]) => normUrl(u) === url); + let state: ScanState; + if (hit) { state = 'configured'; summary.configured++; } + else if (r.binds && r.binds !== self) { state = 'bound-elsewhere'; summary.boundElsewhere++; } + else { state = 'addable'; summary.addable++; } + candidates.push({ + instance: n.instance, + name: n.name, + ...(n.version ? { version: n.version } : {}), + resourceId: r.id, + caps: r.caps ?? [], + url, + ...(r.binds ? { binds: r.binds } : {}), + state, + ...(hit ? { configuredAs: hit[0] } : {}), + suggestedName: suggestedName(n.instance), + }); + } + } + const rank: Record = { addable: 0, configured: 1, 'bound-elsewhere': 2 }; + candidates.sort((a, b) => rank[a.state] - rank[b.state] || a.instance.localeCompare(b.instance)); + return { cap: pattern, self, durationMs: 0, summary, candidates }; +} + /** * Does capability `cap` satisfy `pattern`? `*` matches everything; an `@N` on * the pattern must equal the cap's contract (none ignores it); `x/*` matches @@ -333,6 +412,19 @@ export class MeshNode { return [...best.values()].sort((a, b) => a.name.localeCompare(b.name)); } + /** + * The Scan card: probe everyone, wait `waitMs` for the replies, then + * classify what matches `pattern` against `configured` ([name, url]). + */ + async scan(pattern: string, configured: Array<[string, string]>, waitMs = 1500): Promise { + const started = Date.now(); + this.probe(); + await new Promise((r) => setTimeout(r, waitMs)); + const report = classifyScan(pattern, this.cfg.instance, this.getNeighbors(), configured); + report.durationMs = Date.now() - started; + return report; + } + /** This node as a row, so a UI can render itself without waiting. */ self(): MeshNeighbor { const { status, resources } = this.payload(); @@ -557,6 +649,11 @@ export class MetaCoreLocator { return this.node.self(); } + /** The Scan card — see {@link MeshNode.scan}. */ + scan(pattern: string, configured: Array<[string, string]>, waitMs = 1500): Promise { + return this.node.scan(pattern, configured, waitMs); + } + probe(want: string[] = []): void { this.node.probe(want); } diff --git a/packages/meta-dup-core/src/kv/LeaderClient.ts b/packages/meta-dup-core/src/kv/LeaderClient.ts index 20e9980..f7a027f 100644 --- a/packages/meta-dup-core/src/kv/LeaderClient.ts +++ b/packages/meta-dup-core/src/kv/LeaderClient.ts @@ -21,7 +21,7 @@ */ import { Logger } from 'tslog'; -import { MetaCoreLocator, type MeshNeighbor } from '../discovery/meshdisco.js'; +import { MetaCoreLocator, type MeshNeighbor, type ScanReport } from '../discovery/meshdisco.js'; import type { LeaderLockInfo } from './IKVClient.js'; const logger = new Logger({ name: 'LeaderClient' }); @@ -268,6 +268,11 @@ export class LeaderClient { return this.locator.self(); } + /** Beacon v2 Scan card: probe, wait, classify `pattern` against `configured`. */ + scan(pattern: string, configured: Array<[string, string]>, waitMs = 1500): Promise { + return this.locator.scan(pattern, configured, waitMs); + } + close(): void { this.onChangeCallbacks = []; void this.locator.stop();