@tpsdev-ai/flair 0.30.0 → 0.31.0
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/README.md +194 -377
- package/dist/cli.js +1355 -281
- package/dist/deploy.js +212 -24
- package/dist/fabric-upgrade.js +16 -1
- package/dist/federation/scheduler.js +500 -0
- package/dist/install/clients.js +111 -53
- package/dist/lib/mcp-spec.js +128 -0
- package/dist/lib/safe-snapshot-extract.js +231 -0
- package/dist/lib/scheduler-platform.js +128 -0
- package/dist/lib/xml-escape.js +54 -0
- package/dist/rem/scheduler.js +35 -87
- package/dist/rem/snapshot.js +13 -0
- package/dist/replication-convergence.js +505 -0
- package/dist/resources/MemoryBootstrap.js +7 -8
- package/dist/resources/SemanticSearch.js +17 -45
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/embeddings-boot.js +10 -12
- package/dist/resources/embeddings-provider.js +10 -7
- package/dist/resources/health.js +24 -19
- package/dist/resources/in-process.js +225 -0
- package/dist/resources/mcp-tools.js +23 -17
- package/dist/resources/migration-boot.js +80 -10
- package/dist/resources/migrations/data-dir.js +205 -0
- package/dist/resources/migrations/progress.js +33 -0
- package/dist/resources/migrations/runner.js +29 -2
- package/dist/resources/migrations/state.js +13 -2
- package/dist/resources/models-dir.js +18 -9
- package/dist/resources/semantic-retrieval-core.js +5 -4
- package/dist/src/lib/scheduler-platform.js +128 -0
- package/dist/src/lib/xml-escape.js +54 -0
- package/dist/src/rem/scheduler.js +35 -87
- package/docs/deploying-on-fabric.md +267 -0
- package/docs/deployment.md +5 -0
- package/docs/embedding-in-a-harper-app.md +299 -0
- package/docs/federation.md +61 -4
- package/docs/integrations.md +3 -0
- package/docs/mcp-clients.md +16 -7
- package/docs/quickstart.md +80 -54
- package/docs/releasing.md +72 -38
- package/docs/supply-chain-policy.md +36 -0
- package/docs/troubleshooting.md +24 -0
- package/docs/upgrade.md +98 -3
- package/package.json +1 -11
- package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
- package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
- package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
- package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
- package/dist/resources/rerank-provider.js +0 -569
- package/docs/rerank-provisioning.md +0 -101
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* replication-convergence.ts — flair#878
|
|
3
|
+
*
|
|
4
|
+
* Harper Fabric's component replication to peers converges ASYNCHRONOUSLY. A
|
|
5
|
+
* peer-replication error raised by `harper deploy` is therefore a snapshot of
|
|
6
|
+
* one instant, not a terminal verdict: the origin has the component, one peer
|
|
7
|
+
* did not have it *at the moment the deploy call returned*, and Harper keeps
|
|
8
|
+
* healing after the CLI has stopped watching.
|
|
9
|
+
*
|
|
10
|
+
* The incident this module closes (flair#878): a two-node cluster upgrade
|
|
11
|
+
* reported
|
|
12
|
+
*
|
|
13
|
+
* Component 'flair' was deployed on the origin node but failed to
|
|
14
|
+
* replicate to 1 of 1 peer node(s): <peer> (Error: Connection closed 1006)
|
|
15
|
+
*
|
|
16
|
+
* and the CLI treated it as failure. Afterwards both nodes carried
|
|
17
|
+
* byte-identical component files and all data was intact — the upgrade had
|
|
18
|
+
* succeeded. Worse, the CLI's retry re-ran the full deploy while Harper was
|
|
19
|
+
* still healing, and *that* attempt died with an npm `ENOTEMPTY` on a native
|
|
20
|
+
* module, turning a transient warning into a hard, misleading failure.
|
|
21
|
+
*
|
|
22
|
+
* ── The signal this module uses ─────────────────────────────────────────────
|
|
23
|
+
* `cluster_status` — the operation that would answer "are my cluster peers in
|
|
24
|
+
* sync" — is harper-pro-only and unavailable in the OSS build this CLI ships
|
|
25
|
+
* (see src/fleet-verify.ts's header for the same constraint). What IS
|
|
26
|
+
* available in the OSS build is `get_components`, an `admin_read` operation
|
|
27
|
+
* that walks the components root and returns, per file, `{ name, size, mtime }`
|
|
28
|
+
* (directories recurse through `entries`; `node_modules` is excluded
|
|
29
|
+
* server-side). Addressing it at each node's OWN hostname yields a per-node
|
|
30
|
+
* fingerprint of the deployed component tree, and comparing peer-to-origin
|
|
31
|
+
* answers exactly the question the deploy error left open.
|
|
32
|
+
*
|
|
33
|
+
* `mtime` is the load-bearing half of that fingerprint, not decoration: a peer
|
|
34
|
+
* still holding the PREVIOUS version can easily match on size (a same-length
|
|
35
|
+
* version string, an unchanged asset) but cannot match on the extraction
|
|
36
|
+
* timestamp the origin just stamped. Size alone would be a much weaker check.
|
|
37
|
+
*
|
|
38
|
+
* ── Why a node-identity guard exists (the false-success this would otherwise
|
|
39
|
+
* have) ────────────────────────────────────────────────────────────────────
|
|
40
|
+
* A Fabric cluster endpoint is GTM-steered: `https://<cluster>.<org>.
|
|
41
|
+
* harperfabric.com` resolves to ONE of the member nodes, and which one is not
|
|
42
|
+
* guaranteed stable between the deploy call and this poll. If the cluster
|
|
43
|
+
* endpoint happened to steer to the very peer that failed, a naive
|
|
44
|
+
* origin-vs-peer comparison would be comparing a node against ITSELF and would
|
|
45
|
+
* report `converged` for a cluster that never converged — precisely the
|
|
46
|
+
* failure mode that is worse than the bug being fixed, because it turns a
|
|
47
|
+
* false alarm into a false all-clear.
|
|
48
|
+
*
|
|
49
|
+
* The guard is a DNS resolution of both hostnames: the peer is only compared
|
|
50
|
+
* against the deploy target when the two resolve to disjoint address sets.
|
|
51
|
+
* Overlapping (or unresolvable) addresses yield `unknown`, never `converged`.
|
|
52
|
+
* This is the reporter's own observation encoded as a check rather than as
|
|
53
|
+
* prose a future reader has to remember — the two nodes "resolve to different
|
|
54
|
+
* IPs" is the property that makes the comparison meaningful at all.
|
|
55
|
+
*
|
|
56
|
+
* ── The invariant every caller may rely on ──────────────────────────────────
|
|
57
|
+
* `converged: true` is returned ONLY when every named peer was positively
|
|
58
|
+
* observed, over a distinct network identity, to hold a component tree
|
|
59
|
+
* identical to the origin's. Every other outcome — unparseable error, node
|
|
60
|
+
* name that is not an addressable host, DNS failure, shared address, HTTP
|
|
61
|
+
* failure, absent component, differing files — is `converged: false`. There is
|
|
62
|
+
* no path that infers convergence from the absence of evidence.
|
|
63
|
+
*/
|
|
64
|
+
import { lookup } from "node:dns/promises";
|
|
65
|
+
// ─── Tunables ────────────────────────────────────────────────────────────────
|
|
66
|
+
/**
|
|
67
|
+
* How long to wait for asynchronous peer replication to converge before giving
|
|
68
|
+
* up on confirming it. Generous on purpose: the whole point is that Harper is
|
|
69
|
+
* still working after the deploy call returned, and the cost of waiting is a
|
|
70
|
+
* slower failure report, while the cost of NOT waiting is the flair#878
|
|
71
|
+
* false-failure plus a destructive retry.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_CONVERGENCE_TIMEOUT_MS = 180_000;
|
|
74
|
+
/** How often to re-fingerprint every node while waiting. */
|
|
75
|
+
export const CONVERGENCE_POLL_INTERVAL_MS = 10_000;
|
|
76
|
+
/**
|
|
77
|
+
* Quiescence (see awaitOriginQuiescent): how many consecutive identical
|
|
78
|
+
* fingerprints of the ORIGIN's component tree count as "nothing is writing to
|
|
79
|
+
* it any more". Two is the minimum that can distinguish "stable" from "read
|
|
80
|
+
* once"; the interval between them is what gives it meaning.
|
|
81
|
+
*/
|
|
82
|
+
export const QUIESCENT_STABLE_READS = 2;
|
|
83
|
+
export const DEFAULT_QUIESCENT_TIMEOUT_MS = 120_000;
|
|
84
|
+
export const QUIESCENT_POLL_INTERVAL_MS = 5_000;
|
|
85
|
+
/**
|
|
86
|
+
* Harper builds the message as (components/operations.js):
|
|
87
|
+
*
|
|
88
|
+
* `Component '<name>' was deployed on the origin node but failed to
|
|
89
|
+
* replicate to <failed> of <total> peer node(s): <node> (<error>)[, ...].
|
|
90
|
+
* See deployment <id> (get_deployment) for details, or pass
|
|
91
|
+
* ignore_replication_errors: true ...`
|
|
92
|
+
*
|
|
93
|
+
* Both counts and the deployment-id tail are optional here — the aim is to
|
|
94
|
+
* recover the peer NAMES, since those are the only handle the CLI has on the
|
|
95
|
+
* cluster's actual topology (`cluster_status` being unavailable). Anything
|
|
96
|
+
* unrecognised yields an empty peer list, which callers must treat as "cannot
|
|
97
|
+
* check", never as "nothing failed".
|
|
98
|
+
*
|
|
99
|
+
* Literal regexes only (no interpolation) — satisfies semgrep
|
|
100
|
+
* detect-non-literal-regexp.
|
|
101
|
+
*/
|
|
102
|
+
const REPLICATION_DETAIL_RE = /failed to replicate to (\d+)(?: of (\d+))? peer node\(s\):\s*([\s\S]*?)(?:\.\s*See deployment\s+(\S+?)\s*\(get_deployment\)|\.\s*$|$)/i;
|
|
103
|
+
/** One `name (error)` entry out of the comma-separated detail list. */
|
|
104
|
+
const PEER_ENTRY_RE = /([^,()]+?)\s*\(([^)]*)\)/g;
|
|
105
|
+
export function parseReplicationFailure(output) {
|
|
106
|
+
const empty = {
|
|
107
|
+
peers: [],
|
|
108
|
+
failedCount: null,
|
|
109
|
+
totalCount: null,
|
|
110
|
+
deploymentId: null,
|
|
111
|
+
};
|
|
112
|
+
if (!output)
|
|
113
|
+
return empty;
|
|
114
|
+
const m = REPLICATION_DETAIL_RE.exec(output);
|
|
115
|
+
if (!m)
|
|
116
|
+
return empty;
|
|
117
|
+
const failedCount = Number.isFinite(Number(m[1])) ? Number(m[1]) : null;
|
|
118
|
+
const totalCount = m[2] != null && Number.isFinite(Number(m[2])) ? Number(m[2]) : null;
|
|
119
|
+
const deploymentId = m[4] ?? null;
|
|
120
|
+
const peers = [];
|
|
121
|
+
const detail = (m[3] ?? "").split("\n")[0];
|
|
122
|
+
for (const entry of detail.matchAll(PEER_ENTRY_RE)) {
|
|
123
|
+
const node = entry[1].trim();
|
|
124
|
+
if (!node)
|
|
125
|
+
continue;
|
|
126
|
+
peers.push({ node, error: entry[2].trim() });
|
|
127
|
+
}
|
|
128
|
+
return { peers, failedCount, totalCount, deploymentId };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Harper serialises `mtime` as a Date, which crosses JSON as an ISO string.
|
|
132
|
+
* Normalising through Date makes the comparison immune to a peer reporting a
|
|
133
|
+
* different-but-equivalent encoding; an unparseable value is compared verbatim
|
|
134
|
+
* rather than silently dropped (dropping it would weaken the check).
|
|
135
|
+
*/
|
|
136
|
+
function normalizeMtime(value) {
|
|
137
|
+
if (value == null)
|
|
138
|
+
return "-";
|
|
139
|
+
const asDate = new Date(value);
|
|
140
|
+
const ms = asDate.getTime();
|
|
141
|
+
return Number.isNaN(ms) ? `raw:${String(value)}` : asDate.toISOString();
|
|
142
|
+
}
|
|
143
|
+
function collectFiles(entry, prefix, out) {
|
|
144
|
+
if (!entry || typeof entry.name !== "string")
|
|
145
|
+
return;
|
|
146
|
+
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
147
|
+
if (Array.isArray(entry.entries)) {
|
|
148
|
+
for (const child of entry.entries)
|
|
149
|
+
collectFiles(child, path, out);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const size = typeof entry.size === "number" ? String(entry.size) : "?";
|
|
153
|
+
out.push(`${path} ${size} ${normalizeMtime(entry.mtime)}`);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Reduce a `get_components` response to a stable, comparable fingerprint of ONE
|
|
157
|
+
* component's file tree.
|
|
158
|
+
*
|
|
159
|
+
* Returns null when the response doesn't contain that component, or contains it
|
|
160
|
+
* with no files at all. Null is deliberately NOT a fingerprint: two nodes that
|
|
161
|
+
* both lack the component would otherwise "match" and be reported converged.
|
|
162
|
+
*/
|
|
163
|
+
export function fingerprintComponent(body, project) {
|
|
164
|
+
const roots = body?.entries;
|
|
165
|
+
if (!Array.isArray(roots))
|
|
166
|
+
return null;
|
|
167
|
+
const component = roots.find((e) => e && typeof e === "object" && e.name === project);
|
|
168
|
+
if (!component)
|
|
169
|
+
return null;
|
|
170
|
+
const children = Array.isArray(component.entries) ? component.entries : [];
|
|
171
|
+
const lines = [];
|
|
172
|
+
for (const child of children)
|
|
173
|
+
collectFiles(child, "", lines);
|
|
174
|
+
if (lines.length === 0)
|
|
175
|
+
return null;
|
|
176
|
+
lines.sort();
|
|
177
|
+
return lines.join("\n");
|
|
178
|
+
}
|
|
179
|
+
/** How many file entries differ between two fingerprints (for operator-facing detail). */
|
|
180
|
+
export function fingerprintDiffCount(a, b) {
|
|
181
|
+
const left = new Set(a.split("\n"));
|
|
182
|
+
const right = new Set(b.split("\n"));
|
|
183
|
+
let diff = 0;
|
|
184
|
+
for (const line of left)
|
|
185
|
+
if (!right.has(line))
|
|
186
|
+
diff++;
|
|
187
|
+
for (const line of right)
|
|
188
|
+
if (!left.has(line))
|
|
189
|
+
diff++;
|
|
190
|
+
return diff;
|
|
191
|
+
}
|
|
192
|
+
// ─── Addressing a node ──────────────────────────────────────────────────────
|
|
193
|
+
/**
|
|
194
|
+
* A Harper node name is a hostname (server/nodeName.ts derives it from
|
|
195
|
+
* `node.hostname`, the replication URL, or the TLS certificate CN), so it can
|
|
196
|
+
* normally be addressed directly with the deploy target's scheme and port.
|
|
197
|
+
*
|
|
198
|
+
* But harper's error detail is FREE TEXT — the per-peer entry falls back to the
|
|
199
|
+
* literal `'unknown'`, and a malformed cluster can put an arbitrary message
|
|
200
|
+
* there. Anything that isn't a plain `host[:port]` is rejected rather than
|
|
201
|
+
* guessed at: an unaddressable node yields "cannot check", which is safe, while
|
|
202
|
+
* a guessed address could reach the wrong machine, which is not.
|
|
203
|
+
*/
|
|
204
|
+
const HOSTPORT_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?(?::\d{1,5})?$/;
|
|
205
|
+
export function resolvePeerUrl(node, referenceUrl) {
|
|
206
|
+
const trimmed = (node ?? "").trim();
|
|
207
|
+
if (!trimmed || trimmed.toLowerCase() === "unknown")
|
|
208
|
+
return null;
|
|
209
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
210
|
+
try {
|
|
211
|
+
return new URL(trimmed).origin;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!HOSTPORT_RE.test(trimmed))
|
|
218
|
+
return null;
|
|
219
|
+
let reference;
|
|
220
|
+
try {
|
|
221
|
+
reference = new URL(referenceUrl);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const lastColon = trimmed.lastIndexOf(":");
|
|
227
|
+
const host = lastColon > 0 ? trimmed.slice(0, lastColon) : trimmed;
|
|
228
|
+
const port = lastColon > 0 ? trimmed.slice(lastColon + 1) : "";
|
|
229
|
+
const url = new URL(reference.origin);
|
|
230
|
+
url.hostname = host;
|
|
231
|
+
if (port)
|
|
232
|
+
url.port = port;
|
|
233
|
+
// Setting an invalid hostname on a URL is a silent no-op in WHATWG URL, which
|
|
234
|
+
// would leave us pointed at the ORIGIN while believing we addressed the peer.
|
|
235
|
+
if (url.hostname !== host.toLowerCase())
|
|
236
|
+
return null;
|
|
237
|
+
return url.origin;
|
|
238
|
+
}
|
|
239
|
+
function hostnameOf(url) {
|
|
240
|
+
try {
|
|
241
|
+
return new URL(url).hostname;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Real `get_components` caller. Fabric serves the operations API on the same
|
|
249
|
+
* origin the deploy targets, with the same admin Basic auth the deploy used.
|
|
250
|
+
*
|
|
251
|
+
* NEVER logs or returns the credential, and never folds a response BODY into a
|
|
252
|
+
* thrown error — only the status code. An operations-API error body can echo
|
|
253
|
+
* request context, and this runs with cluster-admin credentials.
|
|
254
|
+
*/
|
|
255
|
+
export function buildOpsGetComponents(fabricUser, fabricPassword, timeoutMs = 15_000) {
|
|
256
|
+
const headers = { "Content-Type": "application/json" };
|
|
257
|
+
if (fabricUser && fabricPassword) {
|
|
258
|
+
headers.Authorization = `Basic ${Buffer.from(`${fabricUser}:${fabricPassword}`).toString("base64")}`;
|
|
259
|
+
}
|
|
260
|
+
return async (baseUrl) => {
|
|
261
|
+
const res = await fetch(baseUrl.replace(/\/+$/, "") + "/", {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers,
|
|
264
|
+
body: JSON.stringify({ operation: "get_components" }),
|
|
265
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
266
|
+
});
|
|
267
|
+
if (!res.ok)
|
|
268
|
+
throw new Error(`get_components returned HTTP ${res.status}`);
|
|
269
|
+
return (await res.json());
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async function defaultResolveHostAddresses(hostname) {
|
|
273
|
+
const records = await lookup(hostname, { all: true });
|
|
274
|
+
return records.map((r) => r.address);
|
|
275
|
+
}
|
|
276
|
+
export function defaultConvergenceDeps(fabricUser, fabricPassword, onProgress) {
|
|
277
|
+
return {
|
|
278
|
+
getComponents: buildOpsGetComponents(fabricUser, fabricPassword),
|
|
279
|
+
resolveHostAddresses: defaultResolveHostAddresses,
|
|
280
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
281
|
+
now: () => Date.now(),
|
|
282
|
+
onProgress,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function summarize(peers, converged, conclusive) {
|
|
286
|
+
if (peers.length === 0) {
|
|
287
|
+
return "harper's replication error named no peer nodes — convergence could not be checked";
|
|
288
|
+
}
|
|
289
|
+
if (converged) {
|
|
290
|
+
const names = peers.map((p) => p.node).join(", ");
|
|
291
|
+
return `all ${peers.length} peer node(s) hold the same component tree as the origin (${names})`;
|
|
292
|
+
}
|
|
293
|
+
const parts = peers.map((p) => `${p.node}: ${p.state} — ${p.detail}`);
|
|
294
|
+
return conclusive
|
|
295
|
+
? `peer replication did NOT converge — ${parts.join("; ")}`
|
|
296
|
+
: `convergence could NOT be determined — ${parts.join("; ")}`;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Poll every named peer until its component tree matches the origin's, or the
|
|
300
|
+
* deadline passes.
|
|
301
|
+
*
|
|
302
|
+
* Never throws for cluster-side reasons: a peer that cannot be reached, named,
|
|
303
|
+
* or resolved is reported as `unknown` and the caller decides. The only
|
|
304
|
+
* unhandled throw would be a bug in an injected dep.
|
|
305
|
+
*/
|
|
306
|
+
export async function awaitReplicationConvergence(opts, deps) {
|
|
307
|
+
const started = deps.now();
|
|
308
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_CONVERGENCE_TIMEOUT_MS;
|
|
309
|
+
const pollIntervalMs = opts.pollIntervalMs ?? CONVERGENCE_POLL_INTERVAL_MS;
|
|
310
|
+
const deadline = started + timeoutMs;
|
|
311
|
+
const results = opts.peers.map((p) => {
|
|
312
|
+
const url = resolvePeerUrl(p.node, opts.targetUrl);
|
|
313
|
+
return {
|
|
314
|
+
node: p.node,
|
|
315
|
+
url,
|
|
316
|
+
state: "unknown",
|
|
317
|
+
detail: url
|
|
318
|
+
? "not checked yet"
|
|
319
|
+
: `harper reported "${p.node}", which is not an addressable host — this CLI will not guess an address for it, so convergence cannot be checked from here`,
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
const finish = () => {
|
|
323
|
+
const converged = results.length > 0 && results.every((r) => r.state === "converged");
|
|
324
|
+
const conclusive = results.length > 0 && results.every((r) => r.state !== "unknown");
|
|
325
|
+
return {
|
|
326
|
+
converged,
|
|
327
|
+
conclusive,
|
|
328
|
+
peers: results,
|
|
329
|
+
elapsedMs: deps.now() - started,
|
|
330
|
+
detail: summarize(results, converged, conclusive),
|
|
331
|
+
};
|
|
332
|
+
};
|
|
333
|
+
const addressable = results.filter((r) => r.url !== null);
|
|
334
|
+
if (addressable.length === 0)
|
|
335
|
+
return finish();
|
|
336
|
+
const targetHost = hostnameOf(opts.targetUrl);
|
|
337
|
+
let targetAddresses = [];
|
|
338
|
+
if (targetHost) {
|
|
339
|
+
try {
|
|
340
|
+
targetAddresses = await deps.resolveHostAddresses(targetHost);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
targetAddresses = [];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (;;) {
|
|
347
|
+
let originFingerprint = null;
|
|
348
|
+
let originError = null;
|
|
349
|
+
try {
|
|
350
|
+
originFingerprint = fingerprintComponent(await deps.getComponents(opts.targetUrl), opts.project);
|
|
351
|
+
if (!originFingerprint) {
|
|
352
|
+
originError = `the deploy target did not report a '${opts.project}' component tree to compare peers against`;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
originError = `could not read the deploy target's component tree: ${err?.message ?? String(err)}`;
|
|
357
|
+
}
|
|
358
|
+
for (const peer of results) {
|
|
359
|
+
if (peer.state === "converged" || peer.url === null)
|
|
360
|
+
continue;
|
|
361
|
+
if (!originFingerprint) {
|
|
362
|
+
peer.state = "unknown";
|
|
363
|
+
peer.detail = originError ?? "no origin fingerprint to compare against";
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const peerHost = hostnameOf(peer.url);
|
|
367
|
+
let peerAddresses = [];
|
|
368
|
+
if (peerHost) {
|
|
369
|
+
try {
|
|
370
|
+
peerAddresses = await deps.resolveHostAddresses(peerHost);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
peerAddresses = [];
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (peerAddresses.length === 0) {
|
|
377
|
+
peer.state = "unknown";
|
|
378
|
+
peer.detail = `${peerHost ?? peer.url} did not resolve — cannot reach this node to check whether it converged`;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (targetAddresses.length === 0) {
|
|
382
|
+
peer.state = "unknown";
|
|
383
|
+
peer.detail =
|
|
384
|
+
`the deploy target's own hostname did not resolve, so this peer cannot be proven to be a different ` +
|
|
385
|
+
`node from it — refusing to compare a node against itself`;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (peerAddresses.some((a) => targetAddresses.includes(a))) {
|
|
389
|
+
peer.state = "unknown";
|
|
390
|
+
peer.detail =
|
|
391
|
+
`${peerHost} currently resolves to the same address as the deploy target — a Fabric cluster endpoint ` +
|
|
392
|
+
`is steered to one member node, so this comparison would be the peer against itself. Refusing to ` +
|
|
393
|
+
`report convergence from it`;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
let peerFingerprint = null;
|
|
397
|
+
try {
|
|
398
|
+
peerFingerprint = fingerprintComponent(await deps.getComponents(peer.url), opts.project);
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
peer.state = "unknown";
|
|
402
|
+
peer.detail = `could not read this node's component tree: ${err?.message ?? String(err)}`;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (!peerFingerprint) {
|
|
406
|
+
peer.state = "diverged";
|
|
407
|
+
peer.detail = `this node reports no '${opts.project}' component at all`;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (peerFingerprint === originFingerprint) {
|
|
411
|
+
peer.state = "converged";
|
|
412
|
+
peer.detail = "component files match the origin byte-for-byte (name, size and mtime)";
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
peer.state = "diverged";
|
|
416
|
+
peer.detail = `${fingerprintDiffCount(originFingerprint, peerFingerprint)} component file entr(ies) differ from the origin`;
|
|
417
|
+
}
|
|
418
|
+
if (results.every((r) => r.state === "converged"))
|
|
419
|
+
break;
|
|
420
|
+
if (deps.now() >= deadline)
|
|
421
|
+
break;
|
|
422
|
+
deps.onProgress?.(`waiting for peer replication to converge (${results.filter((r) => r.state === "converged").length}/${results.length} peer(s) match the origin so far)...`);
|
|
423
|
+
await deps.sleep(pollIntervalMs);
|
|
424
|
+
}
|
|
425
|
+
return finish();
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Wait until the ORIGIN's component tree stops changing.
|
|
429
|
+
*
|
|
430
|
+
* This is the retry's clean-first precondition. Retrying on a fixed 5s/10s
|
|
431
|
+
* backoff re-issues a full deploy while the previous attempt's SERVER-SIDE work
|
|
432
|
+
* — tarball extraction and `npm install` into `<components-root>/<project>` —
|
|
433
|
+
* may still be running, and two writers in one component directory is the
|
|
434
|
+
* best available explanation for the reported
|
|
435
|
+
* `ENOTEMPTY: directory not empty, rmdir '.../node_modules/<pkg>/dist'`, the
|
|
436
|
+
* error that took that upgrade from "transient warning" to "failed".
|
|
437
|
+
*
|
|
438
|
+
* Stated honestly: the CLI cannot observe WHY the remote install failed — that
|
|
439
|
+
* happened inside Harper on another host. What it can do is remove the overlap,
|
|
440
|
+
* which is a necessary condition for any concurrent-writer explanation and
|
|
441
|
+
* costs nothing when the explanation is something else.
|
|
442
|
+
*
|
|
443
|
+
* What the CLI CANNOT do is make Harper's remote install clean-first directly.
|
|
444
|
+
* Harper consults a component's `install_command` only when `node_modules` is
|
|
445
|
+
* ABSENT — `components/Application.ts` returns early ("already has
|
|
446
|
+
* node_modules; skipping install") before the custom command is ever read — so
|
|
447
|
+
* it cannot be used to clear an existing tree, and `deploy_component` exposes no
|
|
448
|
+
* clean/force-reinstall option. Not starting attempt N+1 on top of attempt N is
|
|
449
|
+
* therefore the only clean-first guarantee available from this side.
|
|
450
|
+
*
|
|
451
|
+
* Returns `quiescent: false` when it could not establish stability. Callers
|
|
452
|
+
* must treat that as "do not retry", never as "safe to retry".
|
|
453
|
+
*/
|
|
454
|
+
export async function awaitOriginQuiescent(opts, deps) {
|
|
455
|
+
const started = deps.now();
|
|
456
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_QUIESCENT_TIMEOUT_MS;
|
|
457
|
+
const pollIntervalMs = opts.pollIntervalMs ?? QUIESCENT_POLL_INTERVAL_MS;
|
|
458
|
+
const stableReads = opts.stableReads ?? QUIESCENT_STABLE_READS;
|
|
459
|
+
const deadline = started + timeoutMs;
|
|
460
|
+
let previous = null;
|
|
461
|
+
let streak = 0;
|
|
462
|
+
let lastError = null;
|
|
463
|
+
for (;;) {
|
|
464
|
+
let fingerprint = null;
|
|
465
|
+
try {
|
|
466
|
+
fingerprint = fingerprintComponent(await deps.getComponents(opts.targetUrl), opts.project);
|
|
467
|
+
if (!fingerprint)
|
|
468
|
+
lastError = `the deploy target reports no '${opts.project}' component tree`;
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
lastError = err?.message ?? String(err);
|
|
472
|
+
}
|
|
473
|
+
if (fingerprint) {
|
|
474
|
+
if (previous !== null && fingerprint === previous) {
|
|
475
|
+
streak++;
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
streak = 1;
|
|
479
|
+
}
|
|
480
|
+
previous = fingerprint;
|
|
481
|
+
lastError = null;
|
|
482
|
+
if (streak >= stableReads) {
|
|
483
|
+
return {
|
|
484
|
+
quiescent: true,
|
|
485
|
+
detail: `origin component tree unchanged across ${streak} consecutive reads`,
|
|
486
|
+
elapsedMs: deps.now() - started,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
streak = 0;
|
|
492
|
+
previous = null;
|
|
493
|
+
}
|
|
494
|
+
if (deps.now() >= deadline) {
|
|
495
|
+
return {
|
|
496
|
+
quiescent: false,
|
|
497
|
+
detail: `origin component tree did not settle within ${timeoutMs}ms` +
|
|
498
|
+
(lastError ? ` (last read: ${lastError})` : " — it is still being written to"),
|
|
499
|
+
elapsedMs: deps.now() - started,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
deps.onProgress?.("waiting for the origin's component tree to settle before retrying...");
|
|
503
|
+
await deps.sleep(pollIntervalMs);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
@@ -11,7 +11,7 @@ import { buildCollisionEntries, buildEntityMatchCondition, freshPresenceByAgent,
|
|
|
11
11
|
// The bounded HNSW candidate-pool retrieval for the task-relevant/teammate/
|
|
12
12
|
// collision surfaces (flair-bootstrap-scale-fix) — the SAME pure core
|
|
13
13
|
// SemanticSearch.ts's post() wraps, called bare here so an internal
|
|
14
|
-
// bootstrap call never trips SemanticSearch's rate-limit
|
|
14
|
+
// bootstrap call never trips SemanticSearch's rate-limit or
|
|
15
15
|
// retrievalCount hit-tracking side effects (see resources/
|
|
16
16
|
// semantic-retrieval-core.ts's module doc for the full boundary).
|
|
17
17
|
import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
|
|
@@ -619,12 +619,11 @@ export class BootstrapMemories extends Resource {
|
|
|
619
619
|
queryEmbedding,
|
|
620
620
|
conditions: [scope.condition],
|
|
621
621
|
limit: candidatePoolK,
|
|
622
|
-
// HNSW-leg pushdown ONLY (K&S verdict): no BM25 fusion
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
// follow-ons, gated on their own harness runs.
|
|
622
|
+
// HNSW-leg pushdown ONLY (K&S verdict): no BM25 fusion for
|
|
623
|
+
// bootstrap — a different cost profile, since BM25 over the org
|
|
624
|
+
// corpus for a one-shot session-load could be MORE expensive than
|
|
625
|
+
// HNSW-only unless cached across sessions. Turning it on is an
|
|
626
|
+
// explicit opt-in follow-on, gated on its own harness run.
|
|
628
627
|
hybrid: false,
|
|
629
628
|
// Per-set (this K-bounded pool only, never cross-applied to the
|
|
630
629
|
// permanent/recent/predicted sets above) — see this function's
|
|
@@ -679,7 +678,7 @@ export class BootstrapMemories extends Resource {
|
|
|
679
678
|
// `candidates` are already `_score`-sorted best-first, so filtering
|
|
680
679
|
// preserves that order). `retrieveCandidates()`'s cosine similarity
|
|
681
680
|
// replaces the raw JS dot product as the ranking signal (HNSW-only,
|
|
682
|
-
// no BM25
|
|
681
|
+
// no BM25) — the K&S-ratified, closest-to-a-wash choice; the
|
|
683
682
|
// recall harness gates any regression from this ranking-signal
|
|
684
683
|
// change (magnitude-sensitive dot product → normalized cosine).
|
|
685
684
|
const scored = candidates
|
|
@@ -4,7 +4,6 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
|
4
4
|
import { patchRecord } from "./table-helpers.js";
|
|
5
5
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
6
6
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
7
|
-
import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
|
|
8
7
|
// The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
|
|
9
8
|
// (imported from ./bm25 — Harper-free so it's unit-testable). Default is ON as
|
|
10
9
|
// of 2026-07-08 (see ./bm25.ts's hybridEnabled() doc); set
|
|
@@ -15,7 +14,7 @@ import { hybridEnabled } from "./bm25.js";
|
|
|
15
14
|
// supersede/isAllowed) now lives in the pure, side-effect-free
|
|
16
15
|
// retrieveCandidates() core (flair-bootstrap-scale-fix, Kern-approved
|
|
17
16
|
// extraction) — MemoryBootstrap.ts calls the SAME core bare, without
|
|
18
|
-
// tripping this file's rate-limit/
|
|
17
|
+
// tripping this file's rate-limit/hit-tracking side effects. See
|
|
19
18
|
// resources/semantic-retrieval-core.ts's module doc for the full boundary.
|
|
20
19
|
import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
|
|
21
20
|
import { attachTrust } from "./trust-block.js";
|
|
@@ -181,29 +180,18 @@ export class SemanticSearch extends Resource {
|
|
|
181
180
|
}
|
|
182
181
|
}
|
|
183
182
|
const hybrid = hybridEnabled();
|
|
184
|
-
// When the reranker is on, widen the legacy HNSW fetch so it has a deeper
|
|
185
|
-
// pool to re-score (retrieve topN → rerank → slice to limit). Decoupled
|
|
186
|
-
// from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
|
|
187
|
-
// headroom. Scoped to the legacy (non-hybrid) vector path below — the
|
|
188
|
-
// hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
|
|
189
|
-
// (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union; the
|
|
190
|
-
// reranker still applies to its output further down regardless of which
|
|
191
|
-
// path produced `filteredResults`.
|
|
192
|
-
const rerankOn = isRerankEnabled();
|
|
193
|
-
const rerankTopN = getRerankTopN();
|
|
194
183
|
// The overfetch policy (how many raw candidates to pull from the
|
|
195
184
|
// HNSW/BM25 legs relative to what the caller ultimately wants) is THIS
|
|
196
185
|
// wrapper's decision — retrieveCandidates() never multiplies its `limit`
|
|
197
186
|
// param internally (see resources/semantic-retrieval-core.ts's doc), so
|
|
198
187
|
// every caller (this one, and MemoryBootstrap's own K formula) computes
|
|
199
|
-
// its own fetch depth.
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
: (rerankOn ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN) : limit * CANDIDATE_MULTIPLIER);
|
|
188
|
+
// its own fetch depth. Both the hybrid path (CANDIDATE_MULTIPLIER on the
|
|
189
|
+
// semantic leg + SEM_LIMIT on the BM25 leg, fused by RRF) and the legacy
|
|
190
|
+
// vector-only path overfetch by the same multiplier, which is what gives
|
|
191
|
+
// composite re-scoring headroom to reorder before the final slice.
|
|
192
|
+
const candidateLimit = limit * CANDIDATE_MULTIPLIER;
|
|
205
193
|
const ctx = this.getContext?.();
|
|
206
|
-
|
|
194
|
+
const filteredResults = await retrieveCandidates({
|
|
207
195
|
queryEmbedding: qEmb,
|
|
208
196
|
q,
|
|
209
197
|
conditions,
|
|
@@ -233,10 +221,10 @@ export class SemanticSearch extends Resource {
|
|
|
233
221
|
withSemSimilarity: abstain || includeTrust,
|
|
234
222
|
});
|
|
235
223
|
// ─── flair#744 slice 2 — first-class abstention ("no memory covers this")
|
|
236
|
-
// Opt-in only. Evaluated on the RETRIEVED candidate pool, BEFORE the
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
224
|
+
// Opt-in only. Evaluated on the RETRIEVED candidate pool, BEFORE the final
|
|
225
|
+
// slice / hit-tracking, so an abstaining recall never bumps retrievalCount
|
|
226
|
+
// for memories it declines to surface. The decision reads ONLY the best
|
|
227
|
+
// absolute semantic similarity
|
|
240
228
|
// (never any principal/authority signal — abstention.ts is pure and
|
|
241
229
|
// authority-free), against the single GLOBAL threshold. Default OFF ⇒ this
|
|
242
230
|
// whole block is skipped and the response is byte-identical to pre-slice-2.
|
|
@@ -253,28 +241,12 @@ export class SemanticSearch extends Resource {
|
|
|
253
241
|
};
|
|
254
242
|
}
|
|
255
243
|
}
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// Still gated on `qEmb` (an embedding was actually generated); the pure
|
|
263
|
-
// keyword-only fallback (no qEmb at all) is untouched either way. The
|
|
264
|
-
// reranker overwrites `_score` with the rerank score (so margin
|
|
265
|
-
// measurement reads it) and preserves the semantic score as `_semScore`;
|
|
266
|
-
// `_rawScore` is left as-is so recall-bench's scoring:"raw" path stays
|
|
267
|
-
// reproducible. On init failure, timeout, or any throw, rerankCandidates
|
|
268
|
-
// returns the input UNCHANGED and we fall through to retrieveCandidates'
|
|
269
|
-
// own vector-order sort — recall is never blocked. retrieveCandidates
|
|
270
|
-
// already returns its output sorted best-first, so the non-rerank branch
|
|
271
|
-
// needs no additional sort here.
|
|
272
|
-
if (rerankOn && qEmb && q && filteredResults.length >= getRerankMinCandidates()) {
|
|
273
|
-
filteredResults = await rerankCandidates(String(q), filteredResults, {
|
|
274
|
-
topN: rerankTopN,
|
|
275
|
-
budgetMs: getRerankBudgetMs(),
|
|
276
|
-
});
|
|
277
|
-
}
|
|
244
|
+
// retrieveCandidates() already returns its output sorted best-first
|
|
245
|
+
// (whichever leg produced it — legacy HNSW-only, the BM25+union-RRF hybrid
|
|
246
|
+
// path, or the keyword-only fallback all converge into the same shape), so
|
|
247
|
+
// the final slice needs no additional sort. A cross-encoder rerank stage
|
|
248
|
+
// used to sit here and reorder the pool before this slice; it was removed
|
|
249
|
+
// in flair#893 after measuring Δp@3 = 0.000 at 4.1× query latency.
|
|
278
250
|
const topResults = filteredResults.slice(0, limit);
|
|
279
251
|
// Async hit tracking — don't block the response
|
|
280
252
|
const now = new Date().toISOString();
|
|
@@ -100,7 +100,7 @@ export const STRONG_BAND = 0.55;
|
|
|
100
100
|
* Reads ONLY the `_semSimilarity` number the retrieval core
|
|
101
101
|
* (resources/semantic-retrieval-core.ts) attaches to each semantic-leg result
|
|
102
102
|
* WHEN abstention is requested — an absolute cosine similarity in [0,1],
|
|
103
|
-
* independent of the RRF normalization
|
|
103
|
+
* independent of the RRF normalization that makes the ranking `_score`
|
|
104
104
|
* a *relative* signal (the top RRF-fused result is normalized to 1.0 regardless
|
|
105
105
|
* of how weak the actual match is, so `_score` is unusable as a confidence
|
|
106
106
|
* floor — this is why abstention reads the absolute similarity instead).
|