@tpsdev-ai/flair 0.49.0 → 0.50.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/dist/bridges/runtime/roundtrip.js +91 -2
- package/dist/build-info.json +3 -3
- package/dist/cli.js +383 -110
- package/dist/deploy.js +20 -3
- package/dist/doctor-client.js +59 -31
- package/dist/federation/scheduler.js +24 -3
- package/dist/hook-install.js +45 -13
- package/dist/lib/scheduler-platform.js +132 -10
- package/dist/lib/scratch-owner.js +49 -0
- package/dist/rem/scheduler.js +23 -5
- package/dist/resources/MemoryBootstrap.js +8 -4
- package/dist/resources/health.js +52 -7
- package/dist/resources/mcp-tools.js +1 -0
- package/dist/resources/search-readiness.js +100 -0
- package/dist/resources/semantic-retrieval-core.js +9 -1
- package/dist/resources/sort-comparators.js +45 -0
- package/dist/src/lib/scheduler-platform.js +132 -10
- package/dist/src/rem/scheduler.js +23 -5
- package/docs/auth.md +5 -0
- package/docs/deepseek-harness.md +1 -1
- package/docs/hosted-on-fabric.md +2 -0
- package/docs/integrations.md +53 -1
- package/docs/mcp-clients.md +67 -15
- package/docs/quickstart-fabric.md +1 -1
- package/docs/troubleshooting.md +25 -0
- package/package.json +2 -2
package/dist/rem/scheduler.js
CHANGED
|
@@ -19,7 +19,7 @@ import { homedir } from "node:os";
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { escapeXml } from "../lib/xml-escape.js";
|
|
22
|
-
import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, verifyFirstRun, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
|
|
22
|
+
import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, resolveFlairBin, formatFlairBinWarning, verifyFirstRun, probeUserLingerEnabled, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
|
|
23
23
|
// Re-exported so this module's public surface is unchanged by the extraction
|
|
24
24
|
// into src/lib/scheduler-platform.ts (a second scheduler — `flair federation
|
|
25
25
|
// sync enable` — needs the identical launchctl/systemctl interpretation, and
|
|
@@ -180,8 +180,8 @@ export async function queryActiveStateAsync(plat, timeoutMs = STATUS_CHECK_TIMEO
|
|
|
180
180
|
* known pattern — the caller already prints the raw stderr, so the operator
|
|
181
181
|
* still has something to go on.
|
|
182
182
|
*/
|
|
183
|
-
export function describeLoadFailure(plat, loadResult) {
|
|
184
|
-
return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable");
|
|
183
|
+
export function describeLoadFailure(plat, loadResult, session) {
|
|
184
|
+
return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable", session);
|
|
185
185
|
}
|
|
186
186
|
/**
|
|
187
187
|
* Formats the `flair rem nightly enable` report from an `EnableResult`.
|
|
@@ -199,6 +199,15 @@ export function describeLoadFailure(plat, loadResult) {
|
|
|
199
199
|
* happen once. A missing `loadResult`/`firstRun` (test-only skipLoad shape)
|
|
200
200
|
* therefore withholds the headline too, instead of being treated as success.
|
|
201
201
|
*/
|
|
202
|
+
function appendFlairBinWarning(lines, r) {
|
|
203
|
+
if (r.flairBinCanonical !== false || !r.flairBin)
|
|
204
|
+
return;
|
|
205
|
+
const warning = formatFlairBinWarning(r.flairBin, r.flairBinPublic ?? null, "flair rem nightly enable");
|
|
206
|
+
if (warning.length === 0)
|
|
207
|
+
return;
|
|
208
|
+
lines.push("");
|
|
209
|
+
lines.push(...warning);
|
|
210
|
+
}
|
|
202
211
|
export function formatEnableReport(r, input) {
|
|
203
212
|
const { hour, minute, agentId, flairUrl } = input;
|
|
204
213
|
const scheduleTime = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
@@ -216,11 +225,15 @@ export function formatEnableReport(r, input) {
|
|
|
216
225
|
];
|
|
217
226
|
if (lr.stderr)
|
|
218
227
|
lines.push(` stderr: ${lr.stderr.trim()}`);
|
|
219
|
-
const
|
|
228
|
+
const lingerEnabled = input.lingerEnabled !== undefined
|
|
229
|
+
? input.lingerEnabled
|
|
230
|
+
: (r.platform === "linux" ? (input.probeLinger ?? probeUserLingerEnabled)() : undefined);
|
|
231
|
+
const remedy = describeLoadFailure(r.platform, lr, { lingerEnabled, env: input.env });
|
|
220
232
|
lines.push("");
|
|
221
233
|
lines.push(remedy ? ` ${remedy}` : ` Re-run the activation command above manually to see the full diagnostic.`);
|
|
222
234
|
lines.push("");
|
|
223
235
|
lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair rem nightly status`);
|
|
236
|
+
appendFlairBinWarning(lines, r);
|
|
224
237
|
return { lines, ok: false };
|
|
225
238
|
}
|
|
226
239
|
if (!r.firstRunVerified) {
|
|
@@ -272,6 +285,7 @@ export function formatEnableReport(r, input) {
|
|
|
272
285
|
}
|
|
273
286
|
lines.push("");
|
|
274
287
|
lines.push(` Check anytime with: flair rem nightly status`);
|
|
288
|
+
appendFlairBinWarning(lines, r);
|
|
275
289
|
return { lines, ok: false };
|
|
276
290
|
}
|
|
277
291
|
const lines = [
|
|
@@ -288,6 +302,7 @@ export function formatEnableReport(r, input) {
|
|
|
288
302
|
lines.push(` First run: completed through the service manager, exit 0`);
|
|
289
303
|
lines.push("");
|
|
290
304
|
lines.push(`Disable with \`flair rem nightly disable\`.`);
|
|
305
|
+
appendFlairBinWarning(lines, r);
|
|
291
306
|
return { lines, ok: true };
|
|
292
307
|
}
|
|
293
308
|
/**
|
|
@@ -329,7 +344,8 @@ export function formatStatusReport(s) {
|
|
|
329
344
|
*/
|
|
330
345
|
export function enableScheduler(opts) {
|
|
331
346
|
const plat = detectPlatform(opts.platformOverride);
|
|
332
|
-
const
|
|
347
|
+
const resolvedFlair = resolveFlairBin(opts.flairBin);
|
|
348
|
+
const flairBin = resolvedFlair.path;
|
|
333
349
|
const nodeBin = resolveNodeBin(opts.nodeBin);
|
|
334
350
|
const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
335
351
|
const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
|
|
@@ -383,6 +399,7 @@ export function enableScheduler(opts) {
|
|
|
383
399
|
return {
|
|
384
400
|
platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult,
|
|
385
401
|
firstRunVerified: firstRun?.verified === true, firstRun,
|
|
402
|
+
flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
|
|
386
403
|
};
|
|
387
404
|
}
|
|
388
405
|
// Linux: systemd user units.
|
|
@@ -408,6 +425,7 @@ export function enableScheduler(opts) {
|
|
|
408
425
|
return {
|
|
409
426
|
platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult,
|
|
410
427
|
firstRunVerified: firstRun?.verified === true, firstRun,
|
|
428
|
+
flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
|
|
411
429
|
};
|
|
412
430
|
}
|
|
413
431
|
/**
|
|
@@ -502,16 +502,18 @@ export class BootstrapMemories extends Resource {
|
|
|
502
502
|
skillAssignments.push(record);
|
|
503
503
|
continue;
|
|
504
504
|
}
|
|
505
|
-
// flair#1182 — the raw soul container (key→value), independent of the
|
|
506
|
-
// token-budgeted/priority-truncated `sections.soul` lines built below.
|
|
507
|
-
soulMap[record.key] = record.value;
|
|
508
505
|
const line = `**${record.key}:** ${record.value}`;
|
|
509
506
|
const tokens = estimateTokens(line);
|
|
510
507
|
const priority = SOUL_KEY_PRIORITY[record.key] ?? 50;
|
|
511
|
-
soulEntries.push({ key: record.key, line, tokens, priority });
|
|
508
|
+
soulEntries.push({ key: record.key, value: record.value, line, tokens, priority });
|
|
512
509
|
}
|
|
513
510
|
// Sort by priority (lower = more important)
|
|
514
511
|
soulEntries.sort((a, b) => a.priority - b.priority);
|
|
512
|
+
// flair#1371 — the structured `soul` map follows the admission decision.
|
|
513
|
+
// Filling it during the scan (flair#1182) shipped every key even when
|
|
514
|
+
// this loop dropped the entry, so sections.soul / soulTokens / the
|
|
515
|
+
// context pointer described N while `soul` delivered N+1 (delivered
|
|
516
|
+
// but not counted or charged — the #1206 mirror).
|
|
515
517
|
for (const entry of soulEntries) {
|
|
516
518
|
if (soulTokens + entry.tokens > soulMaxTokens) {
|
|
517
519
|
// Skip large entries that exceed budget — truncate or skip
|
|
@@ -522,6 +524,7 @@ export class BootstrapMemories extends Resource {
|
|
|
522
524
|
if (maxChars > 100) {
|
|
523
525
|
const truncated = `**${entry.key}:** ${entry.line.slice(entry.key.length + 6, entry.key.length + 6 + maxChars)}…(truncated)`;
|
|
524
526
|
sections.soul.push(truncated);
|
|
527
|
+
soulMap[entry.key] = entry.value;
|
|
525
528
|
const cost = estimateTokens(truncated);
|
|
526
529
|
soulTokens += cost;
|
|
527
530
|
tokenBudget -= cost; // #1199 — soul draws from the shared budget
|
|
@@ -529,6 +532,7 @@ export class BootstrapMemories extends Resource {
|
|
|
529
532
|
continue;
|
|
530
533
|
}
|
|
531
534
|
sections.soul.push(entry.line);
|
|
535
|
+
soulMap[entry.key] = entry.value;
|
|
532
536
|
soulTokens += entry.tokens;
|
|
533
537
|
tokenBudget -= entry.tokens; // #1199 — soul draws from the shared budget
|
|
534
538
|
}
|
package/dist/resources/health.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Resource, databases } from "harper";
|
|
1
|
+
import { Resource, databases, server, logger } from "harper";
|
|
2
2
|
import { promises as fsp, existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
@@ -8,6 +8,9 @@ import { resolveBuildInfo } from "./build-info.js";
|
|
|
8
8
|
import { getMigrationStatusSnapshot } from "./migrations/status.js";
|
|
9
9
|
import { resolveMigrationDataDirForRead } from "./migrations/data-dir.js";
|
|
10
10
|
import { REM_DEDUP_STATS_PATH } from "./dedup-cluster.js";
|
|
11
|
+
import { hybridEnabled } from "./bm25.js";
|
|
12
|
+
import { bm25IndexEnabled, bm25IndexStatus } from "./bm25-index-service.js";
|
|
13
|
+
import { buildPublicHealthBody, resolveSearchReadiness } from "./search-readiness.js";
|
|
11
14
|
const db = databases;
|
|
12
15
|
const redactHome = (p) => {
|
|
13
16
|
const home = homedir();
|
|
@@ -52,7 +55,7 @@ function resolveVersion() {
|
|
|
52
55
|
return process.env.npm_package_version ?? "dev";
|
|
53
56
|
}
|
|
54
57
|
/**
|
|
55
|
-
* Health endpoint — truly public
|
|
58
|
+
* Health endpoint — truly public. Identity-free; no auth, no role check.
|
|
56
59
|
*
|
|
57
60
|
* `allowRead() { return true }` opens Harper's role gate for anonymous GETs,
|
|
58
61
|
* which is what makes /Health work for callers outside `authorizeLocal`'s
|
|
@@ -66,8 +69,15 @@ function resolveVersion() {
|
|
|
66
69
|
*
|
|
67
70
|
* Same pattern as `FederationPair.allowCreate(){ return true }` (PR #299):
|
|
68
71
|
* declare the Resource anonymously-accessible at the Harper layer; let the
|
|
69
|
-
* handler itself enforce whatever it needs
|
|
70
|
-
*
|
|
72
|
+
* handler itself enforce whatever it needs.
|
|
73
|
+
*
|
|
74
|
+
* flair#1326: `ok: true` used to mean only "this resource answered." That
|
|
75
|
+
* is a green light that lies when search routes are not mounted yet, or
|
|
76
|
+
* when the hybrid BM25 index is still cold (first search after restart
|
|
77
|
+
* scans the corpus; the lag grows with store size). `searchReady` is
|
|
78
|
+
* always present. When search cannot be served at all, this endpoint
|
|
79
|
+
* returns HTTP 503 and `ok: false`. When the process is live but recall
|
|
80
|
+
* is still cold, it stays 200 and names the lag on `searchReadyReason`.
|
|
71
81
|
*
|
|
72
82
|
* Rich stats (memory counts, agent names, etc.) are behind /HealthDetail
|
|
73
83
|
* which requires authentication. This prevents information leakage on
|
|
@@ -96,13 +106,39 @@ export class Health extends Resource {
|
|
|
96
106
|
// a 40-hex sha when the build ran in a git work tree, an honest null
|
|
97
107
|
// otherwise (tarball builds) — never omitted, never fabricated (Sherlock).
|
|
98
108
|
const build = resolveBuildInfo();
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
const readiness = currentSearchReadiness();
|
|
110
|
+
const body = buildPublicHealthBody(readiness, {
|
|
101
111
|
version: build?.version ?? resolveVersion(),
|
|
102
112
|
buildCommit: build?.commit ?? null,
|
|
103
|
-
};
|
|
113
|
+
});
|
|
114
|
+
if (readiness.status !== 200) {
|
|
115
|
+
return new Response(JSON.stringify(body), {
|
|
116
|
+
status: readiness.status,
|
|
117
|
+
headers: { "content-type": "application/json" },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return body;
|
|
104
121
|
}
|
|
105
122
|
}
|
|
123
|
+
/** Same sources /Health and /HealthDetail consult so they cannot disagree. */
|
|
124
|
+
let _warnedMissingRegistry = false;
|
|
125
|
+
function currentSearchReadiness() {
|
|
126
|
+
// Shipped Harper launch: this Resource is already registered, so
|
|
127
|
+
// server.resources is populated. The null skip is a stated fail-open
|
|
128
|
+
// (Sherlock on #1406) for the injectable/test path — not an accident.
|
|
129
|
+
const resources = server.resources ?? null;
|
|
130
|
+
if (!resources && !_warnedMissingRegistry) {
|
|
131
|
+
_warnedMissingRegistry = true;
|
|
132
|
+
logger.warn?.("Health: server.resources is absent — skipping the search-route mount check (table-only fail-open). Shipped Harper launch always exposes the registry.");
|
|
133
|
+
}
|
|
134
|
+
return resolveSearchReadiness({
|
|
135
|
+
resources,
|
|
136
|
+
memoryTable: db.flair?.Memory,
|
|
137
|
+
bm25: bm25IndexStatus(),
|
|
138
|
+
hybridEnabled: hybridEnabled(),
|
|
139
|
+
bm25IndexEnabled: bm25IndexEnabled(),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
106
142
|
/**
|
|
107
143
|
* Authenticated health detail — returns memory/agent/soul stats + process info.
|
|
108
144
|
* Requires Ed25519 agent auth or admin basic auth.
|
|
@@ -127,6 +163,15 @@ export class HealthDetail extends Resource {
|
|
|
127
163
|
const stats = { ok: true };
|
|
128
164
|
const nowMs = Date.now();
|
|
129
165
|
const warnings = [];
|
|
166
|
+
// flair#1326: same search-ready signal as public /Health. HealthDetail
|
|
167
|
+
// stays HTTP 200 (it is a stats dump, not a traffic gate); the field
|
|
168
|
+
// and a warning name the lag so `flair status` / operators can see it.
|
|
169
|
+
const readiness = currentSearchReadiness();
|
|
170
|
+
stats.searchReady = readiness.searchReady;
|
|
171
|
+
if (readiness.searchReadyReason) {
|
|
172
|
+
stats.searchReadyReason = readiness.searchReadyReason;
|
|
173
|
+
warnings.push({ level: "warn", message: readiness.searchReadyReason });
|
|
174
|
+
}
|
|
130
175
|
const ctx = this.getContext?.();
|
|
131
176
|
// #614 fix: resolve identity via the shared three-way verdict
|
|
132
177
|
// (internal/agent/anonymous — agent-auth.ts) instead of reading
|
|
@@ -811,6 +811,7 @@ export const TOOLS = {
|
|
|
811
811
|
{ count: "memoriesIncluded", containers: ["memories", "predicted"] }, // #1199
|
|
812
812
|
{ count: "teammateFindingsIncluded", containers: ["teammateFindings"] }, // #1199
|
|
813
813
|
{ count: "sections.events", containers: ["events"] }, // #1206
|
|
814
|
+
{ count: "sections.soul", containers: ["soul"] }, // #1371
|
|
814
815
|
],
|
|
815
816
|
// present + typed even when empty — never a bare {} / missing key (#1182).
|
|
816
817
|
selfDescribingEmpty: [
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-readiness.ts — the honest /Health search-ready signal (flair#1326).
|
|
3
|
+
*
|
|
4
|
+
* Harper's process can answer /health (and Flair's /Health resource can
|
|
5
|
+
* answer {ok:true}) while search is still unusable:
|
|
6
|
+
*
|
|
7
|
+
* 1. Boot window — jsResources register incrementally. /Health can be up
|
|
8
|
+
* while /Memory and /SemanticSearch still 404 from Harper's catch-all
|
|
9
|
+
* (documented in packages/adk-flair-js/test/helpers/boot-harper.mjs).
|
|
10
|
+
* 2. Cold BM25 index — hybrid retrieval's persistent index is lazy-built
|
|
11
|
+
* on the first search, not at component start (bm25-index-service.ts).
|
|
12
|
+
* That first-query corpus scan grows with store size. /Health answering
|
|
13
|
+
* is not "recall is warm."
|
|
14
|
+
*
|
|
15
|
+
* This module is Harper-free so the decision is unit-testable against the
|
|
16
|
+
* shipped function. Callers inject the registry / table / index status they
|
|
17
|
+
* already have.
|
|
18
|
+
*
|
|
19
|
+
* Two layers, on purpose:
|
|
20
|
+
* - Routes/table not mounted → not healthy (ok:false, HTTP 503). A
|
|
21
|
+
* traffic-gating probe that only looks at status must not get a green
|
|
22
|
+
* light for a node whose search routes are not serving.
|
|
23
|
+
* - Routes up but index still cold → process is live (ok:true, HTTP 200)
|
|
24
|
+
* and searchReady:false names the lag. We do NOT 503 on a cold index:
|
|
25
|
+
* the index builds on the first search, and a health check must not
|
|
26
|
+
* trigger that scan (bm25-index-service.ts: "Eager building would add a
|
|
27
|
+
* full corpus scan to every boot including … health checks"). 503-until-
|
|
28
|
+
* warm would deadlock — health waits for the index, the index waits for
|
|
29
|
+
* a search that never comes.
|
|
30
|
+
*/
|
|
31
|
+
function routeMounted(resources, name) {
|
|
32
|
+
const entry = resources.get?.(name) ?? resources.getMatch?.(name);
|
|
33
|
+
return Boolean(entry?.Resource);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether search is actually usable, and whether /Health should claim
|
|
37
|
+
* the process is healthy.
|
|
38
|
+
*
|
|
39
|
+
* `resources` is optional. Stated fail-open (Sherlock on #1406): when the
|
|
40
|
+
* registry is missing we skip the route-mount check rather than 503 forever.
|
|
41
|
+
* A table handle can exist while `/Memory` and `/SemanticSearch` still 404,
|
|
42
|
+
* so this is weaker than the primary defense. In the shipped launch path
|
|
43
|
+
* `/Health` is served by this Resource after Harper has registered us, so
|
|
44
|
+
* `server.resources` is populated; the skip is the injectable/test path
|
|
45
|
+
* (and a theoretical export without a registry). Health.ts logs once if
|
|
46
|
+
* the live call site actually takes it.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveSearchReadiness(opts) {
|
|
49
|
+
if (opts.resources) {
|
|
50
|
+
const memoryMounted = routeMounted(opts.resources, "Memory");
|
|
51
|
+
const searchMounted = routeMounted(opts.resources, "SemanticSearch");
|
|
52
|
+
if (!memoryMounted || !searchMounted) {
|
|
53
|
+
const missing = [
|
|
54
|
+
!memoryMounted ? "Memory" : null,
|
|
55
|
+
!searchMounted ? "SemanticSearch" : null,
|
|
56
|
+
].filter(Boolean).join(", ");
|
|
57
|
+
return notServing(`search routes not mounted (${missing})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!opts.memoryTable || typeof opts.memoryTable.search !== "function") {
|
|
61
|
+
return notServing("memory table not queryable");
|
|
62
|
+
}
|
|
63
|
+
// Hybrid + the persistent index are default-on. A cold or in-flight BM25
|
|
64
|
+
// index means the first search will pay a full corpus scan — the #1326
|
|
65
|
+
// lag. Name it; do not fail liveness.
|
|
66
|
+
//
|
|
67
|
+
// `disabled` (feed/build failure) and `FLAIR_BM25_INDEX=false` both fall
|
|
68
|
+
// back to the per-query scan. The kill switch never calls ensureReady, so
|
|
69
|
+
// status stays `empty` for the life of the process — that is serving, not
|
|
70
|
+
// cold. Treating it as lag would make searchReady false forever and refuse
|
|
71
|
+
// a node that is already answering recall.
|
|
72
|
+
const indexInPath = opts.hybridEnabled !== false && opts.bm25IndexEnabled !== false;
|
|
73
|
+
if (indexInPath && opts.bm25) {
|
|
74
|
+
if (opts.bm25.state === "building") {
|
|
75
|
+
return namesLag("bm25 index building — first search is still scanning the corpus");
|
|
76
|
+
}
|
|
77
|
+
if (opts.bm25.state === "empty") {
|
|
78
|
+
return namesLag("bm25 index not built (cold boot; first search scans the corpus)");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { searchReady: true, ok: true, status: 200 };
|
|
82
|
+
}
|
|
83
|
+
function notServing(searchReadyReason) {
|
|
84
|
+
return { searchReady: false, ok: false, status: 503, searchReadyReason };
|
|
85
|
+
}
|
|
86
|
+
function namesLag(searchReadyReason) {
|
|
87
|
+
return { searchReady: false, ok: true, status: 200, searchReadyReason };
|
|
88
|
+
}
|
|
89
|
+
/** Public /Health JSON body. `searchReady` is always present (never omitted). */
|
|
90
|
+
export function buildPublicHealthBody(readiness, identity) {
|
|
91
|
+
const body = {
|
|
92
|
+
ok: readiness.ok,
|
|
93
|
+
version: identity.version,
|
|
94
|
+
buildCommit: identity.buildCommit,
|
|
95
|
+
searchReady: readiness.searchReady,
|
|
96
|
+
};
|
|
97
|
+
if (readiness.searchReadyReason)
|
|
98
|
+
body.searchReadyReason = readiness.searchReadyReason;
|
|
99
|
+
return body;
|
|
100
|
+
}
|
|
@@ -60,6 +60,7 @@ import { compositeScore } from "./scoring.js";
|
|
|
60
60
|
import { buildBM25, fuseRrfNormalized, SEM_LIMIT } from "./bm25.js";
|
|
61
61
|
import { isAllowedBm25Candidate } from "./bm25-filter.js";
|
|
62
62
|
import { indexedBm25Ids } from "./bm25-index-service.js";
|
|
63
|
+
import { byRecencyThenId } from "./sort-comparators.js";
|
|
63
64
|
// Convert HNSW cosine distance (1 - similarity) to similarity score.
|
|
64
65
|
function distanceToSimilarity(distance) {
|
|
65
66
|
return 1 - distance;
|
|
@@ -484,7 +485,14 @@ export async function retrieveCandidates(params) {
|
|
|
484
485
|
// retrieval lives in the fused ORDER (a BM25 rank-1 rescue outranks weak
|
|
485
486
|
// semantic hits), while `_score` carries the honest absolute evidence for
|
|
486
487
|
// each result — the two can disagree, and that is correct.
|
|
487
|
-
|
|
488
|
+
//
|
|
489
|
+
// Ties at this sort are cross-leg RRF identities (flair#1412): no
|
|
490
|
+
// within-leg tie-break can prevent `1/(k+3)+1/(k+5) === 1/(k+5)+1/(k+3)`.
|
|
491
|
+
// Break them by createdAt DESC (null → oldest, never NaN) then id ASC.
|
|
492
|
+
// Determinism is the id ASC total order. createdAt is best-effort recency
|
|
493
|
+
// within an exact `_rank` tie — federated writer clocks can skew, and
|
|
494
|
+
// that cannot reintroduce nondeterminism (see byRecencyThenId).
|
|
495
|
+
filteredResults.sort((a, b) => (b._rank - a._rank) || byRecencyThenId(a, b));
|
|
488
496
|
for (const r of filteredResults)
|
|
489
497
|
delete r._rank;
|
|
490
498
|
return filteredResults;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic sort comparators (flair#1412).
|
|
3
|
+
*
|
|
4
|
+
* Retrieval and selection sorts used to key on one number (score, rank,
|
|
5
|
+
* priority) and then inherit `Array.prototype.sort`'s input order on ties.
|
|
6
|
+
* That input order is Harper scan order, which is free to change across
|
|
7
|
+
* restarts. The family below makes the tie-break the default thing to
|
|
8
|
+
* reach for: each helper has its own primary key, and they share the
|
|
9
|
+
* `compareKey` tail — not one comparator forced onto every site.
|
|
10
|
+
*
|
|
11
|
+
* Do not collapse this into a single `byScoreThenId`. cosine.ts keys on
|
|
12
|
+
* corpus `.index`; MemoryBootstrap (flair#1409, not this PR) keys on the
|
|
13
|
+
* soul `key`. The shared part is the tail.
|
|
14
|
+
*
|
|
15
|
+
* Locale-independent: `compareKey` is code-unit / numeric `<`/`>`, never
|
|
16
|
+
* `localeCompare`. ISO-8601 UTC strings compare chronologically that way.
|
|
17
|
+
*/
|
|
18
|
+
/** Total-order tail. Works for ids, corpus indexes, soul keys. */
|
|
19
|
+
export function compareKey(a, b) {
|
|
20
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
21
|
+
}
|
|
22
|
+
/** Score / rank descending, then `id` ascending. */
|
|
23
|
+
export function byNumberDescThenId(getNumber) {
|
|
24
|
+
return (a, b) => (getNumber(b) - getNumber(a)) || compareKey(a.id, b.id);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* `createdAt` DESC (null → oldest, never NaN) then `id` ASC.
|
|
28
|
+
*
|
|
29
|
+
* Determinism comes from `id` ASC — ids are unique, so this is a total
|
|
30
|
+
* order no matter what `createdAt` does. Do not date-arithmetic a
|
|
31
|
+
* possibly-null field: `new Date(null).getTime()` is 0, but subtracting
|
|
32
|
+
* NaN (unparseable / missing-after-coercion) is undefined behaviour for
|
|
33
|
+
* `Array.prototype.sort`. Empty-string fallback keeps the comparison in
|
|
34
|
+
* string space; an empty value is less than any ISO-8601 UTC timestamp,
|
|
35
|
+
* so it sorts last under DESC.
|
|
36
|
+
*
|
|
37
|
+
* Clock-skew caveat: `createdAt` is writer-stamped. Across federated
|
|
38
|
+
* writers this is best-effort recency within an exact `_rank` tie, not a
|
|
39
|
+
* correctness claim. Skew can misorder rows the ranker already called
|
|
40
|
+
* equivalent; it cannot reintroduce nondeterminism, because `id` ASC
|
|
41
|
+
* still resolves every remaining tie.
|
|
42
|
+
*/
|
|
43
|
+
export function byRecencyThenId(a, b) {
|
|
44
|
+
return compareKey(b.createdAt ?? "", a.createdAt ?? "") || compareKey(a.id, b.id);
|
|
45
|
+
}
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* in `verifyFirstRun()`: success may not be claimed until the thing the
|
|
21
21
|
* operator asked for has been observed to happen once.
|
|
22
22
|
*/
|
|
23
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
24
|
-
import { resolve, dirname, isAbsolute } from "node:path";
|
|
25
|
-
import { platform } from "node:os";
|
|
23
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
|
|
24
|
+
import { resolve, dirname, isAbsolute, basename } from "node:path";
|
|
25
|
+
import { platform, userInfo } from "node:os";
|
|
26
26
|
import { spawnSync } from "node:child_process";
|
|
27
27
|
/**
|
|
28
28
|
* 30s ceiling on launchctl/systemctl invocations so a hung service manager
|
|
@@ -90,20 +90,71 @@ export function interpretActiveResult(plat, code, stdout, stderr) {
|
|
|
90
90
|
return null; // spawn itself failed — inconclusive
|
|
91
91
|
return false; // covers the no-bus case: empty stdout, nonzero/failed exit
|
|
92
92
|
}
|
|
93
|
+
/** True when this session already has the env `systemctl --user` needs. */
|
|
94
|
+
export function sessionHasUserBusEnv(env = process.env) {
|
|
95
|
+
return Boolean(env.XDG_RUNTIME_DIR?.trim() && env.DBUS_SESSION_BUS_ADDRESS?.trim());
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Reads whether lingering is already enabled for the current user.
|
|
99
|
+
* `loginctl show-user … Linger=yes` is the official answer; the stamp file
|
|
100
|
+
* `loginctl enable-linger` creates is the fallback when loginctl is missing
|
|
101
|
+
* or inconclusive. A failed probe is `null`, never linger-off — inventing
|
|
102
|
+
* linger-off would repeat the linger remedy after it already ran (#1107).
|
|
103
|
+
*/
|
|
104
|
+
export function probeUserLingerEnabled(opts = {}) {
|
|
105
|
+
const run = opts.run ?? spawnReport;
|
|
106
|
+
const lingerStampExists = opts.lingerStampExists ?? ((u) => existsSync(`/var/lib/systemd/linger/${u}`));
|
|
107
|
+
let user = "";
|
|
108
|
+
try {
|
|
109
|
+
user = userInfo().username;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
user = process.env.USER || process.env.LOGNAME || "";
|
|
113
|
+
}
|
|
114
|
+
if (!user)
|
|
115
|
+
return null;
|
|
116
|
+
const r = run(["loginctl", "show-user", user, "--property=Linger"], STATUS_CHECK_TIMEOUT_MS);
|
|
117
|
+
const m = /^Linger=(yes|no)\s*$/m.exec(r.stdout ?? "");
|
|
118
|
+
if (m)
|
|
119
|
+
return m[1] === "yes";
|
|
120
|
+
if (lingerStampExists(user))
|
|
121
|
+
return true;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
93
124
|
/**
|
|
94
|
-
* Human remedy text for a failed scheduler-load attempt (flair#850).
|
|
95
|
-
* the
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
125
|
+
* Human remedy text for a failed scheduler-load attempt (flair#850, #1107).
|
|
126
|
+
* Covers the traced "no systemd user session bus" failure, which blocks
|
|
127
|
+
* `systemctl --user` entirely in ssh-without-lingering, container, and CI
|
|
128
|
+
* contexts. Two cases that used to share one remedy:
|
|
129
|
+
* (a) lingering genuinely off — print `loginctl enable-linger`
|
|
130
|
+
* (b) linger already on, this session has no user-bus env — print the
|
|
131
|
+
* `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` export lines
|
|
132
|
+
* Repeating (a) after the operator has applied it is the #1107 lie.
|
|
133
|
+
* Returns null when the failure doesn't match a known pattern — the caller
|
|
134
|
+
* already prints the raw stderr, so the operator still has something to go on.
|
|
100
135
|
*
|
|
101
136
|
* `enableCommand` is the caller's own enable invocation, named in the remedy
|
|
102
137
|
* so the operator is told to re-run the command they actually ran.
|
|
103
138
|
*/
|
|
104
|
-
export function describeLoadFailure(plat, loadResult, enableCommand) {
|
|
139
|
+
export function describeLoadFailure(plat, loadResult, enableCommand, session) {
|
|
105
140
|
const stderr = loadResult.stderr || "";
|
|
106
141
|
if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
|
|
142
|
+
if (session?.lingerEnabled === true) {
|
|
143
|
+
const env = session.env ?? process.env;
|
|
144
|
+
if (!sessionHasUserBusEnv(env)) {
|
|
145
|
+
return ("No systemd user session bus is available in this session. Lingering is already enabled — " +
|
|
146
|
+
"do not re-run `loginctl enable-linger`. The remaining gap is this session's user-bus environment. " +
|
|
147
|
+
"Export:\n" +
|
|
148
|
+
" export XDG_RUNTIME_DIR=/run/user/$(id -u)\n" +
|
|
149
|
+
" export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus\n" +
|
|
150
|
+
` then re-run \`${enableCommand}\`.`);
|
|
151
|
+
}
|
|
152
|
+
return ("No systemd user session bus is available in this session. Lingering is already enabled and " +
|
|
153
|
+
"this session already has XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS — " +
|
|
154
|
+
"do not re-run `loginctl enable-linger` or re-export those variables. " +
|
|
155
|
+
"Check that `$XDG_RUNTIME_DIR/bus` exists (the systemd --user instance may not be running), " +
|
|
156
|
+
`then re-run \`${enableCommand}\`.`);
|
|
157
|
+
}
|
|
107
158
|
return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
|
|
108
159
|
"in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
|
|
109
160
|
`— then re-run \`${enableCommand}\`.`);
|
|
@@ -172,6 +223,77 @@ export function resolveNodeBin(explicit) {
|
|
|
172
223
|
"enable time — refusing to install a shim that would resolve `node` from the service manager's PATH " +
|
|
173
224
|
"at run time. Install node (or put it on PATH for this shell) and re-run enable.");
|
|
174
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Resolves the path enable will bake as FLAIR_BIN, and whether that path is
|
|
228
|
+
* the stable public `flair` entry (flair#1279).
|
|
229
|
+
*
|
|
230
|
+
* Resolution order:
|
|
231
|
+
* 1. `explicit` — caller/test override. Relatives are resolved against cwd.
|
|
232
|
+
* 2. `hooks.argv1` / `process.argv[1]` — whatever launched enable.
|
|
233
|
+
* 3. The public `flair` on PATH, only when (1) and (2) are empty.
|
|
234
|
+
* Nothing absolute resolvable ⇒ throw. A bare `"flair"` is not an exec
|
|
235
|
+
* target under #1231's `exec <node> <script>` form (`node flair` looks in
|
|
236
|
+
* cwd, not PATH).
|
|
237
|
+
*/
|
|
238
|
+
export function resolveFlairBin(explicit, hooks) {
|
|
239
|
+
const publicBin = hooks && "publicBin" in hooks ? (hooks.publicBin ?? null) : lookupPublicFlairBin();
|
|
240
|
+
const captured = explicit ?? hooks?.argv1 ?? process.argv[1];
|
|
241
|
+
let path;
|
|
242
|
+
if (typeof captured === "string" && captured.length > 0) {
|
|
243
|
+
path = isAbsolute(captured) ? captured : resolve(captured);
|
|
244
|
+
}
|
|
245
|
+
else if (publicBin) {
|
|
246
|
+
path = publicBin;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
throw new Error("unable to resolve an absolute path to the flair CLI (process.argv[1] was empty and `command -v flair` " +
|
|
250
|
+
"found nothing). The scheduler shim bakes this path in at enable time — refusing to install a shim " +
|
|
251
|
+
"whose exec target is unknown. Re-run enable via the `flair` command.");
|
|
252
|
+
}
|
|
253
|
+
return { path, publicBin, canonical: isCanonicalFlairBin(path, publicBin) };
|
|
254
|
+
}
|
|
255
|
+
/** True when `baked` is the public `flair` entry, not a working-tree capture. */
|
|
256
|
+
export function isCanonicalFlairBin(baked, publicBin) {
|
|
257
|
+
if (basename(baked) === "flair")
|
|
258
|
+
return true;
|
|
259
|
+
if (publicBin && pathsReferToSameFile(baked, publicBin))
|
|
260
|
+
return true;
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The enable-report lines for a non-canonical FLAIR_BIN. Empty when the
|
|
265
|
+
* baked path is the public entry — callers should not print a warning then.
|
|
266
|
+
*/
|
|
267
|
+
export function formatFlairBinWarning(baked, publicBin, enableCommand) {
|
|
268
|
+
if (isCanonicalFlairBin(baked, publicBin))
|
|
269
|
+
return [];
|
|
270
|
+
const lines = [
|
|
271
|
+
`⚠️ FLAIR_BIN is ${baked} — that is the process that ran enable, not a stable public entry.`,
|
|
272
|
+
` A later blue/green directory swap, or deleting this working tree, will strand the scheduler unit.`,
|
|
273
|
+
];
|
|
274
|
+
if (publicBin) {
|
|
275
|
+
lines.push(` Public \`flair\` on PATH: ${publicBin}. Re-run \`${enableCommand}\` as the \`flair\` command to bake that path instead.`);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
lines.push(` No \`flair\` on PATH. Re-run \`${enableCommand}\` via the installed \`flair\` command (or a stable symlink) so the baked path survives a tree swap.`);
|
|
279
|
+
}
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
|
282
|
+
function lookupPublicFlairBin() {
|
|
283
|
+
const r = spawnReport(["/bin/sh", "-c", "command -v flair"], STATUS_CHECK_TIMEOUT_MS);
|
|
284
|
+
const found = r.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
285
|
+
if (r.code === 0 && found && isAbsolute(found) && existsSync(found))
|
|
286
|
+
return found;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function pathsReferToSameFile(a, b) {
|
|
290
|
+
try {
|
|
291
|
+
return realpathSync(a) === realpathSync(b);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return resolve(a) === resolve(b);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
175
297
|
// ─── first-run verification (flair#1231) ────────────────────────────────────
|
|
176
298
|
// A load/bootstrap command exiting 0 proves the service manager accepted the
|
|
177
299
|
// job — not that the job can run. The only vantage that exercises the real
|