@tpsdev-ai/flair 0.21.0 → 0.22.1

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.
Files changed (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1811 -221
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +455 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +167 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. package/ui/observation-center.html +0 -385
package/dist/deploy.js CHANGED
@@ -8,7 +8,6 @@ import { createRequire } from "node:module";
8
8
  export const REQUIRED_PACKAGE_FILES = [
9
9
  "dist",
10
10
  "schemas",
11
- "ui",
12
11
  "config.yaml",
13
12
  ];
14
13
  // harper's own deploy CLI defaults to a 120s peer-replication timeout that's
@@ -39,9 +38,9 @@ export function validateOptions(opts) {
39
38
  const hasBasic = !!(opts.fabricUser && opts.fabricPassword);
40
39
  const hasBearer = !!opts.fabricToken;
41
40
  if (!hasBasic && !hasBearer) {
42
- errors.push("credentials required: pass --fabric-user + --fabric-password " +
43
- "(or FABRIC_USER / FABRIC_PASSWORD env), or --fabric-token " +
44
- "(FABRIC_TOKEN env)");
41
+ errors.push("credentials required: set FABRIC_USER + FABRIC_PASSWORD env (safest), or pass " +
42
+ "--fabric-user + --fabric-password-file <path>, or --fabric-token (FABRIC_TOKEN env); " +
43
+ "inline --fabric-user/--fabric-password also work but leak to shell history");
45
44
  }
46
45
  return errors;
47
46
  }
@@ -0,0 +1,98 @@
1
+ // ─── Doctor: fleet presence / instance staleness (flair#639) ────────────────────
2
+ //
3
+ // `flair doctor` diagnosed a single instance only. Auto-presence (flair#608)
4
+ // gives every agent a heartbeat, and each heartbeat now carries the SERVING
5
+ // instance's own flair + harper version (resources/Presence.ts,
6
+ // buildPresenceRecord()) — so the /Presence roster doubles as an org-level
7
+ // fleet-version report, IF something reads it that way. This module is that
8
+ // reading: pure classification over an already-fetched roster, no network,
9
+ // no crypto, so it's fast and fully unit-testable in isolation — same split
10
+ // as doctor-client.ts (pure logic here; the network fetch + Ed25519 signing
11
+ // stay in src/cli.ts alongside authFetch/buildEd25519Auth, which they reuse).
12
+ //
13
+ // Deliberately NOT npm-latest aware: comparing against the newest version
14
+ // PUBLISHED is version-nudge's job (src/version-check.ts, flair#587/#594).
15
+ // This is comparing instances against EACH OTHER — "is anyone behind the
16
+ // rest of the fleet" — which stays meaningful even fully offline, and is the
17
+ // question flair#639 actually asks: an org-level answer without probing
18
+ // every host by hand.
19
+ //
20
+ // Semver comparison reuses fabric-upgrade.ts's parseSemverCore/semverGte
21
+ // (major.minor.patch only, pre-release/build suffixes ignored) — the one
22
+ // semver implementation in this codebase; duplicating it here would risk the
23
+ // two drifting on an edge case.
24
+ import { parseSemverCore, semverGte } from "./fabric-upgrade.js";
25
+ /** True only for a non-empty, parseable semver string — the one predicate every
26
+ * function below needs, so it lives in exactly one place. */
27
+ function hasParsableVersion(v) {
28
+ return typeof v === "string" && parseSemverCore(v) !== null;
29
+ }
30
+ /**
31
+ * Newest flairVersion seen across the roster. Entries with no (or an
32
+ * unparseable) version are excluded from the computation — an unversioned
33
+ * record can't be compared, so it can't set the bar. Returns null when NO
34
+ * entry in the roster reports a parseable version (nothing to compare
35
+ * against — e.g. a fleet that's entirely pre-flair#639, or a roster with a
36
+ * single unversioned instance).
37
+ */
38
+ export function newestVersionSeen(rows) {
39
+ let newest = null;
40
+ for (const row of rows) {
41
+ if (!hasParsableVersion(row.flairVersion))
42
+ continue;
43
+ if (newest === null || semverGte(row.flairVersion, newest))
44
+ newest = row.flairVersion;
45
+ }
46
+ return newest;
47
+ }
48
+ /**
49
+ * Flag each roster row as stale relative to the newest flairVersion seen
50
+ * ACROSS THE WHOLE ROSTER (never against npm-latest — see module doc). A row
51
+ * is stale when:
52
+ * - the roster has SOME newest version to compare against (newest !== null)
53
+ * AND
54
+ * - this row has no parseable version of its own (an older, pre-flair#639
55
+ * instance — the loudest possible skew signal), OR its version is older
56
+ * than the newest seen.
57
+ *
58
+ * A single-instance roster, or a roster where every instance reports the
59
+ * IDENTICAL version, has nothing to compare against → newest equals every
60
+ * row's own version (or is null) → nothing is flagged stale. A roster where
61
+ * NO instance has ever reported a version (newest === null) similarly flags
62
+ * nothing — there's no fleet-relative signal yet, only an org-wide gap this
63
+ * function can't see (that's what the "pass --agent to reveal versions" /
64
+ * "no versions reported yet" doctor messaging is for, not staleness).
65
+ */
66
+ export function markStale(rows) {
67
+ const newest = newestVersionSeen(rows);
68
+ return rows.map((row) => {
69
+ const stale = newest !== null && (!hasParsableVersion(row.flairVersion) || !semverGte(row.flairVersion, newest));
70
+ return { ...row, stale, newestVersion: newest };
71
+ });
72
+ }
73
+ /**
74
+ * Sort oldest-version-first so problems top the listing (flair#639's stated
75
+ * requirement). Rows with no parseable version sort FIRST — an unversioned
76
+ * instance is the biggest unknown in the fleet, not a middling one, and
77
+ * markStale() always flags it stale whenever the roster has any signal at
78
+ * all, so surfacing it first keeps the "problems on top" contract even when
79
+ * mixed with real-but-old versions. Ties (equal versions) preserve roster
80
+ * order (stable sort).
81
+ */
82
+ export function sortOldestVersionFirst(rows) {
83
+ return [...rows].sort((a, b) => {
84
+ const pa = hasParsableVersion(a.flairVersion) ? parseSemverCore(a.flairVersion) : null;
85
+ const pb = hasParsableVersion(b.flairVersion) ? parseSemverCore(b.flairVersion) : null;
86
+ if (!pa && !pb)
87
+ return 0;
88
+ if (!pa)
89
+ return -1;
90
+ if (!pb)
91
+ return 1;
92
+ for (let i = 0; i < 3; i++) {
93
+ if (pa[i] !== pb[i])
94
+ return pa[i] - pb[i];
95
+ }
96
+ return 0;
97
+ });
98
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * fleet-verify.ts — `flair fleet verify` (flair#636)
3
+ *
4
+ * Fabric deploys tolerate replication errors by design (origin-first, see
5
+ * src/deploy.ts's REPLICATION_FAILURE_RE / --ignore-replication-errors) —
6
+ * but nothing upstream of this module confirms a replicated node actually
7
+ * converged. Harper's own "Successfully deployed" means "origin took it."
8
+ * This sweeps the origin + every known peer and reports a per-node table
9
+ * instead of a single boolean, reusing src/probe.ts's `probeInstance`
10
+ * verbatim (flair#635/#641 — same shape, Fabric admin Basic auth swapped in
11
+ * for the injected `authedGet`, exactly per that module's own docstring).
12
+ *
13
+ * ── What "peer" means here — read before trusting a green sweep ──────────
14
+ * Harper Fabric's OWN cluster-replication peers (the physical nodes a
15
+ * component gets replicated to by `harper deploy`) are NOT enumerable
16
+ * through any operation in the OSS @harperfast/harper build this repo
17
+ * depends on. `cluster_status` — the operation that would answer "what
18
+ * nodes are in this cluster and are they in sync" — is explicitly called
19
+ * out as harper-pro-only in Harper's own source (node_modules/@harperfast/
20
+ * harper/components/mcp/tools/schemas/operationDescriptions.ts: "Out-of-core
21
+ * operations (e.g. harper-pro's cluster_status) cannot have entries here").
22
+ * There is no "list this cluster's replication nodes" call available to this
23
+ * CLI, on the origin or anywhere else — direct AND via-origin cluster
24
+ * topology discovery are both unavailable in this Harper tier.
25
+ *
26
+ * The only node registry this module CAN reach is Flair's OWN federation
27
+ * peer table (resources/Federation.ts's Peer records, via GET
28
+ * /FederationPeers) — a distinct, higher-level concept (cross-instance
29
+ * memory-sync pairing, hub-and-spoke) that happens to double as a fleet
30
+ * registry ONLY where an operator has ALSO paired each Fabric replica as a
31
+ * federation peer of the origin. A replica that was never paired is
32
+ * invisible to this sweep — `0 peers known` means "0 peers ON FILE", never
33
+ * "0 peers exist." renderFleetSweepTable() prints this caveat inline
34
+ * whenever the peer list comes back empty, so it can't be missed by reading
35
+ * a green summary line.
36
+ *
37
+ * Every peer that DOES carry a direct `endpoint` gets a REAL probe (health +
38
+ * authenticated version check, same Fabric admin Basic-auth credentials as
39
+ * the origin — a Fabric cluster shares one admin realm). A peer with no
40
+ * usable endpoint is reported "unverifiable" — never silently dropped, never
41
+ * printed green. See classifyNode()'s "method" field: "direct" (we actually
42
+ * hit it) vs "none" (we could not attempt a check at all, and say why).
43
+ */
44
+ import { probeInstance } from "./probe.js";
45
+ import * as render from "./render.js";
46
+ // ─── Exit codes (documented in `flair fleet verify --help`) ─────────────────
47
+ /** All nodes verified: healthy, authenticated, and version-matched. */
48
+ export const FLEET_EXIT_OK = 0;
49
+ /** Origin failed — unreachable, unauthenticated, or running the wrong version. Worst case: the cluster's entrypoint itself is broken. */
50
+ export const FLEET_EXIT_ORIGIN_FAILED = 1;
51
+ /** Origin is fine, but at least one reachable peer is running a DIFFERENT version — a mixed-version fleet (the #638 scenario). */
52
+ export const FLEET_EXIT_PEER_SKEW = 2;
53
+ /** Origin is fine, no version skew among reachable peers, but at least one peer could not be verified at all (unreachable, auth rejected, or no endpoint on file). */
54
+ export const FLEET_EXIT_PEER_UNREACHABLE = 3;
55
+ export const FLEET_EXIT_DESCRIPTIONS = {
56
+ [FLEET_EXIT_OK]: "all nodes verified: healthy, authenticated, version-matched",
57
+ [FLEET_EXIT_ORIGIN_FAILED]: "origin failed (unreachable, unauthenticated, or wrong version)",
58
+ [FLEET_EXIT_PEER_SKEW]: "origin OK, but a reachable peer is running a DIFFERENT version (skew)",
59
+ [FLEET_EXIT_PEER_UNREACHABLE]: "origin OK, no skew among reachable peers, but a peer could not be verified at all (unreachable, auth rejected, or no endpoint on file)",
60
+ };
61
+ /**
62
+ * Priority order when multiple problems exist at once: an origin failure
63
+ * always wins (nothing downstream matters if the entrypoint is broken), then
64
+ * skew (a real mixed-version fleet), then unreachable/unverifiable (we
65
+ * genuinely don't know that node's state).
66
+ */
67
+ export function decideFleetExitCode(origin, peers) {
68
+ if (origin.status !== "ok")
69
+ return FLEET_EXIT_ORIGIN_FAILED;
70
+ if (peers.some((p) => p.status === "skew"))
71
+ return FLEET_EXIT_PEER_SKEW;
72
+ if (peers.some((p) => p.status !== "ok"))
73
+ return FLEET_EXIT_PEER_UNREACHABLE;
74
+ return FLEET_EXIT_OK;
75
+ }
76
+ // ─── Pure decision logic: ProbeResult → FleetNodeResult ─────────────────────
77
+ /**
78
+ * Turn a probe attempt (or the absence of one) into a table row. Pure —
79
+ * no I/O. `expectVersion` is the baseline this node should be running;
80
+ * pass null when there is nothing to compare against (skew can't be
81
+ * evaluated, but health/auth still can).
82
+ */
83
+ export function classifyNode(role, id, url, method, probe, expectVersion, unverifiableReason) {
84
+ if (method === "none" || probe === null) {
85
+ return {
86
+ id, role, url, method: "none",
87
+ healthy: null, authenticated: null, version: null, versionMatch: null,
88
+ status: "unverifiable",
89
+ detail: unverifiableReason ?? "no way to verify this node",
90
+ };
91
+ }
92
+ if (!probe.healthy) {
93
+ return {
94
+ id, role, url, method,
95
+ healthy: false, authenticated: null, version: null, versionMatch: null,
96
+ status: "unreachable",
97
+ detail: probe.error ?? `${url ?? id} did not answer /Health`,
98
+ };
99
+ }
100
+ if (probe.authenticated === false) {
101
+ return {
102
+ id, role, url, method,
103
+ healthy: true, authenticated: false, version: null, versionMatch: null,
104
+ status: "auth-failed",
105
+ detail: probe.error ?? "authenticated request rejected",
106
+ };
107
+ }
108
+ if (expectVersion !== null && probe.versionMatch === false) {
109
+ return {
110
+ id, role, url, method,
111
+ healthy: true, authenticated: probe.authenticated, version: probe.version, versionMatch: false,
112
+ status: "skew",
113
+ detail: probe.error ?? `version mismatch: expected ${expectVersion}, reports ${probe.version ?? "unknown"}`,
114
+ };
115
+ }
116
+ return {
117
+ id, role, url, method,
118
+ healthy: true, authenticated: probe.authenticated, version: probe.version, versionMatch: probe.versionMatch,
119
+ status: "ok",
120
+ detail: probe.version ? `healthy, running ${probe.version}` : "healthy",
121
+ };
122
+ }
123
+ // ─── Peer endpoint validation ────────────────────────────────────────────────
124
+ /**
125
+ * A federation Peer record may have no `endpoint` at all (tunnel-paired —
126
+ * see src/cli.ts's existing `flair federation ping` handling of the same
127
+ * shape) or an endpoint that isn't a plain http(s) URL. Reject non-http(s)
128
+ * schemes rather than attempting to fetch them (same protocol allowlist as
129
+ * the existing federation-ping probe — Sherlock review on #314).
130
+ */
131
+ export function validatePeerEndpoint(endpoint) {
132
+ if (!endpoint) {
133
+ return { error: "no endpoint on file (never federation-paired with a direct endpoint, or tunnel-paired)" };
134
+ }
135
+ let parsed;
136
+ try {
137
+ parsed = new URL(endpoint);
138
+ }
139
+ catch {
140
+ return { error: `invalid endpoint URL on file: ${endpoint}` };
141
+ }
142
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
143
+ return { error: `unsupported endpoint protocol on file: ${parsed.protocol}` };
144
+ }
145
+ return { url: endpoint };
146
+ }
147
+ // ─── Fabric admin Basic-auth authedGet (injected into probeInstance) ────────
148
+ /**
149
+ * Builds the authedGet probeInstance needs, using Fabric admin Basic auth —
150
+ * the exact credential shape src/deploy.ts and src/fabric-upgrade.ts already
151
+ * use for the whole cluster (FABRIC_USER/FABRIC_PASSWORD). Assumes a single
152
+ * Fabric cluster shares one admin realm across its nodes; a peer that
153
+ * rejects these credentials (a genuinely separate Harper instance with its
154
+ * own admin) surfaces as "auth-failed" — itself a real, informative finding,
155
+ * not a bug in this probe.
156
+ *
157
+ * NEVER logs the credential — only the Authorization header carries it, and
158
+ * probeInstance/callers only ever see/report the HTTP outcome.
159
+ */
160
+ export function buildFabricAuthedGet(baseUrl, fabricUser, fabricPassword) {
161
+ const base = baseUrl.replace(/\/+$/, "");
162
+ const auth = Buffer.from(`${fabricUser}:${fabricPassword}`).toString("base64");
163
+ return async (path) => {
164
+ const res = await fetch(`${base}${path}`, {
165
+ headers: { Authorization: `Basic ${auth}` },
166
+ signal: AbortSignal.timeout(15_000),
167
+ });
168
+ const text = await res.text();
169
+ if (!res.ok)
170
+ throw new Error(text || `HTTP ${res.status}`);
171
+ return text ? JSON.parse(text) : {};
172
+ };
173
+ }
174
+ async function defaultFetchPeers(authedGet) {
175
+ const body = await authedGet("/FederationPeers");
176
+ return Array.isArray(body?.peers) ? body.peers : [];
177
+ }
178
+ function defaultDeps() {
179
+ return {
180
+ probe: probeInstance,
181
+ fetchPeers: defaultFetchPeers,
182
+ buildAuthedGet: buildFabricAuthedGet,
183
+ };
184
+ }
185
+ // ─── Orchestrator ───────────────────────────────────────────────────────────
186
+ export async function sweepFleet(opts, injected) {
187
+ const deps = { ...defaultDeps(), ...injected };
188
+ const log = deps.log ?? (() => { });
189
+ const originAuthedGet = deps.buildAuthedGet(opts.target, opts.fabricUser, opts.fabricPassword);
190
+ log(`Probing origin ${opts.target} ...`);
191
+ const originProbe = await deps.probe(opts.target, {
192
+ expectVersion: opts.expectVersion,
193
+ timeoutMs: opts.timeoutMs,
194
+ authedGet: originAuthedGet,
195
+ });
196
+ const explicitExpect = opts.expectVersion ?? null;
197
+ const expectVersion = explicitExpect ?? originProbe.version ?? null;
198
+ const expectVersionSource = explicitExpect !== null ? "explicit" : expectVersion !== null ? "origin" : "none";
199
+ // Origin is classified against the EXPLICIT expectation only — when none was
200
+ // given, the origin itself defines the baseline and can't be "skewed"
201
+ // relative to its own reading.
202
+ const origin = classifyNode("origin", "origin", opts.target, "direct", originProbe, explicitExpect);
203
+ let peerRecords = [];
204
+ let peerEnumerationError = null;
205
+ try {
206
+ log("Enumerating federation peers ...");
207
+ peerRecords = await deps.fetchPeers(originAuthedGet);
208
+ }
209
+ catch (err) {
210
+ peerEnumerationError = err?.message ?? String(err);
211
+ }
212
+ // Revoked peers are intentionally decommissioned — reporting them
213
+ // unreachable would be noise, not signal.
214
+ const knownPeers = peerRecords.filter((p) => p.status !== "revoked");
215
+ const peers = [];
216
+ for (const p of knownPeers) {
217
+ const check = validatePeerEndpoint(p.endpoint);
218
+ if ("error" in check) {
219
+ peers.push(classifyNode("peer", p.id, p.endpoint ?? null, "none", null, expectVersion, check.error));
220
+ continue;
221
+ }
222
+ log(`Probing peer ${p.id} (${check.url}) ...`);
223
+ const peerAuthedGet = deps.buildAuthedGet(check.url, opts.fabricUser, opts.fabricPassword);
224
+ const peerProbe = await deps.probe(check.url, {
225
+ expectVersion: expectVersion ?? undefined,
226
+ timeoutMs: opts.timeoutMs,
227
+ authedGet: peerAuthedGet,
228
+ });
229
+ peers.push(classifyNode("peer", p.id, check.url, "direct", peerProbe, expectVersion));
230
+ }
231
+ if (peerEnumerationError) {
232
+ // A failure to even LIST peers means fleet coverage is unknown — never
233
+ // let that read as "0 peers, all clear."
234
+ peers.push({
235
+ id: "(peer enumeration)",
236
+ role: "peer",
237
+ url: null,
238
+ method: "none",
239
+ healthy: null, authenticated: null, version: null, versionMatch: null,
240
+ status: "unverifiable",
241
+ detail: `GET /FederationPeers on the origin failed: ${peerEnumerationError} — fleet coverage unknown, cannot confirm every peer was even considered`,
242
+ });
243
+ }
244
+ const exitCode = decideFleetExitCode(origin, peers);
245
+ return { target: opts.target, expectVersion, expectVersionSource, origin, peers, peerEnumerationError, exitCode };
246
+ }
247
+ // ─── Rendering ───────────────────────────────────────────────────────────────
248
+ const STATUS_COLOR = {
249
+ ok: render.c.green,
250
+ skew: render.c.red,
251
+ unreachable: render.c.red,
252
+ "auth-failed": render.c.yellow,
253
+ unverifiable: render.c.yellow,
254
+ };
255
+ /**
256
+ * Per-node table + the caveats an operator needs to trust it. An "ok" row
257
+ * always means a REAL probe answered; anything else names exactly how it's
258
+ * wrong (see FleetNodeResult.detail) instead of a bare pass/fail.
259
+ */
260
+ export function renderFleetSweepTable(result) {
261
+ const rows = [result.origin, ...result.peers];
262
+ const cols = [
263
+ { label: "node", key: "id" },
264
+ { label: "role", key: "role" },
265
+ { label: "method", key: "method", format: (v) => (v === "direct" ? "direct" : render.wrap(render.c.dim, "none")) },
266
+ {
267
+ label: "status",
268
+ key: "status",
269
+ format: (v) => render.wrap(STATUS_COLOR[v] ?? render.c.dim, String(v)),
270
+ },
271
+ { label: "version", key: "version", format: (v) => (v ? String(v) : render.wrap(render.c.dim, "—")) },
272
+ { label: "detail", key: "detail" },
273
+ ];
274
+ const lines = [render.table(cols, rows)];
275
+ if (result.peers.length === 0 || (result.peers.length === 1 && result.peerEnumerationError)) {
276
+ lines.push("");
277
+ lines.push(render.wrap(render.c.dim, "0 federation peers known to the origin. This does NOT mean the Fabric cluster has no other " +
278
+ "replication nodes — Harper's own cluster peers aren't visible to this tool (cluster_status is a " +
279
+ "harper-pro-only operation, unavailable in the OSS build this CLI ships). It means no other node is " +
280
+ "paired as a Flair federation peer of this origin. If this cluster has replicas, pair them " +
281
+ "(`flair federation pair`) so fleet verify can actually see them."));
282
+ }
283
+ const expectLine = result.expectVersionSource === "explicit"
284
+ ? `compared against --expect-version ${result.expectVersion}`
285
+ : result.expectVersionSource === "origin"
286
+ ? `compared against the origin's own reported version (${result.expectVersion}) — no --expect-version given`
287
+ : "no version baseline available (origin unreachable/unauthenticated and no --expect-version given) — skew could not be checked";
288
+ lines.push("");
289
+ lines.push(render.wrap(render.c.dim, expectLine));
290
+ return lines.join("\n");
291
+ }