@tpsdev-ai/flair 0.30.0 → 0.31.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 (60) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1434 -288
  3. package/dist/deploy.js +212 -24
  4. package/dist/fabric-upgrade.js +16 -1
  5. package/dist/federation/scheduler.js +500 -0
  6. package/dist/install/clients.js +111 -53
  7. package/dist/lib/mcp-spec.js +128 -0
  8. package/dist/lib/safe-snapshot-extract.js +231 -0
  9. package/dist/lib/scheduler-platform.js +128 -0
  10. package/dist/lib/xml-escape.js +54 -0
  11. package/dist/rem/scheduler.js +35 -87
  12. package/dist/rem/snapshot.js +13 -0
  13. package/dist/replication-convergence.js +505 -0
  14. package/dist/resources/AdminPrincipals.js +10 -1
  15. package/dist/resources/Agent.js +105 -11
  16. package/dist/resources/AgentSeed.js +10 -2
  17. package/dist/resources/MemoryBootstrap.js +7 -8
  18. package/dist/resources/MemoryUsage.js +18 -0
  19. package/dist/resources/Presence.js +8 -1
  20. package/dist/resources/SemanticSearch.js +17 -45
  21. package/dist/resources/abstention.js +1 -1
  22. package/dist/resources/agent-admin.js +149 -0
  23. package/dist/resources/agent-auth.js +98 -5
  24. package/dist/resources/auth-middleware.js +92 -19
  25. package/dist/resources/embeddings-boot.js +10 -12
  26. package/dist/resources/embeddings-provider.js +10 -7
  27. package/dist/resources/health.js +24 -19
  28. package/dist/resources/in-process.js +234 -0
  29. package/dist/resources/mcp-handler.js +14 -4
  30. package/dist/resources/mcp-tools.js +23 -17
  31. package/dist/resources/migration-boot.js +80 -10
  32. package/dist/resources/migrations/data-dir.js +205 -0
  33. package/dist/resources/migrations/progress.js +33 -0
  34. package/dist/resources/migrations/runner.js +29 -2
  35. package/dist/resources/migrations/state.js +13 -2
  36. package/dist/resources/models-dir.js +18 -9
  37. package/dist/resources/presence-internal.js +6 -1
  38. package/dist/resources/record-owner-guard.js +149 -0
  39. package/dist/resources/semantic-retrieval-core.js +5 -4
  40. package/dist/src/lib/scheduler-platform.js +128 -0
  41. package/dist/src/lib/xml-escape.js +54 -0
  42. package/dist/src/rem/scheduler.js +35 -87
  43. package/docs/deploying-on-fabric.md +267 -0
  44. package/docs/deployment.md +5 -0
  45. package/docs/embedding-in-a-harper-app.md +303 -0
  46. package/docs/federation.md +61 -4
  47. package/docs/integrations.md +3 -0
  48. package/docs/mcp-clients.md +16 -7
  49. package/docs/quickstart.md +80 -54
  50. package/docs/releasing.md +72 -38
  51. package/docs/supply-chain-policy.md +36 -0
  52. package/docs/troubleshooting.md +24 -0
  53. package/docs/upgrade.md +99 -4
  54. package/package.json +1 -11
  55. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  56. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  57. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  58. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  59. package/dist/resources/rerank-provider.js +0 -569
  60. package/docs/rerank-provisioning.md +0 -101
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { databases } from "harper";
19
19
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
20
+ import { ADMIN_ROLE, agentRecordIsAdmin } from "./agent-admin.js";
20
21
  /**
21
22
  * Shared Harper user that verified Ed25519 agents resolve to (least-privilege
22
23
  * `flair_agent` role), replacing the old admin super_user elevation. Single
@@ -32,8 +33,14 @@ export const FLAIR_AGENT_USERNAME = "flair-agent";
32
33
  // visible to the other two, and the crypto/decoder logic can't drift.
33
34
  // ─── Admin resolution ─────────────────────────────────────────────────────────
34
35
  // Admin agents come from FLAIR_ADMIN_AGENTS (comma-separated) OR Agent records
35
- // with role === "admin". OR-combined, cached 60s. Distinct from Harper's
36
- // super_user — admin here gates flair-policy decisions (promotions, raw ops).
36
+ // whose `role` is the admin sentinel. OR-combined, cached 60s. Distinct from
37
+ // Harper's super_user — admin here gates flair-policy decisions (promotions,
38
+ // raw ops).
39
+ //
40
+ // The record half of that union is the ONE place the Agent table is consulted
41
+ // for an authorization decision, and it resolves through
42
+ // resources/agent-admin.ts's shared predicate so it cannot drift from the
43
+ // reporters or from the MCP surface (flair#941 — they had drifted).
37
44
  let adminCacheExpiry = 0;
38
45
  let adminCache = new Set();
39
46
  async function getAdminAgents() {
@@ -43,9 +50,9 @@ async function getAdminAgents() {
43
50
  const fromEnv = (process.env.FLAIR_ADMIN_AGENTS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
44
51
  const fromDb = [];
45
52
  try {
46
- const results = await databases.flair.Agent.search([{ attribute: "role", value: "admin", condition: "equals" }]);
53
+ const results = await databases.flair.Agent.search([{ attribute: "role", value: ADMIN_ROLE, condition: "equals" }]);
47
54
  for await (const row of results)
48
- if (row?.id)
55
+ if (row?.id && agentRecordIsAdmin(row))
49
56
  fromDb.push(row.id);
50
57
  }
51
58
  catch { /* Agent table may be empty */ }
@@ -53,6 +60,28 @@ async function getAdminAgents() {
53
60
  adminCacheExpiry = now + 60_000;
54
61
  return adminCache;
55
62
  }
63
+ /**
64
+ * Drop the memoised admin set so the NEXT isAdmin() re-reads the Agent table.
65
+ *
66
+ * The 60s TTL is a load optimisation, but on its own it makes a privilege
67
+ * change take up to a minute to appear — which reads as "the change didn't
68
+ * work", and is exactly the confusion flair#941 is about: an operator grants
69
+ * admin, tests it, sees a denial, and starts changing other things. The write
70
+ * path knows precisely when a principal's admin status changed, so it says so
71
+ * and the grant is effective on the caller's very next request.
72
+ *
73
+ * Deliberately NOT a distributed invalidation: Harper runs N worker threads,
74
+ * each with its own module instance and its own cache, so a promotion applied
75
+ * on thread A still takes up to the TTL to be seen by thread B. Making that
76
+ * exact would mean a shared invalidation channel, which is a much larger change
77
+ * than the confusion warrants — the bound stays 60s, unchanged, and the common
78
+ * single-threaded/dev case becomes immediate. Called by resources/Agent.ts's
79
+ * write paths.
80
+ */
81
+ export function invalidateAdminCache() {
82
+ adminCacheExpiry = 0;
83
+ adminCache = new Set();
84
+ }
56
85
  export async function isAdmin(agentId) {
57
86
  return (await getAdminAgents()).has(agentId);
58
87
  }
@@ -195,10 +224,73 @@ export async function allowAdmin(context) {
195
224
  const a = await resolveAgentAuth(context);
196
225
  return a.kind === "internal" || (a.kind === "agent" && a.isAdmin);
197
226
  }
227
+ /**
228
+ * flair#936 — the `internal` verdict is TRUSTED and UNFILTERED, and it is also
229
+ * what a caller gets for supplying no context at all. Nothing distinguishes
230
+ * "I am flair's own maintenance code" from "I am an application developer who
231
+ * did not know a context was required": both spell it `new Memory()`, both
232
+ * succeed, and both return plausible data. It surfaces months later as memories
233
+ * that were never scoped.
234
+ *
235
+ * This does not change the verdict — every authorization outcome is byte-
236
+ * identical — it ends the SILENCE. A deliberate elevated call says so with
237
+ * `internalContext()` (resources/in-process.ts), whose `__flairInternal` marker
238
+ * is read here and nowhere else; anything else that lands on `internal` gets one
239
+ * warning per process, with the stack, so the condition is observable where it
240
+ * was previously invisible.
241
+ *
242
+ * MEASURED, and the reason this is worth having: flair itself has NO in-process
243
+ * caller that relies on the omission — every maintenance, migration, federation
244
+ * and boot path goes through the raw `databases.flair.*` table accessor, which
245
+ * bypasses this resolver entirely rather than defaulting through it. So a
246
+ * warning from this line is, in practice, always either an embedding
247
+ * application that forgot a context or a flair call site that should be naming
248
+ * its intent. Neither is noise.
249
+ *
250
+ * The marker is read off `context`, NOT off `c`: `c` is rebound to
251
+ * `context.request` on the line below, and internalContext() carries the marker
252
+ * on the outer object.
253
+ */
254
+ let warnedInternalByOmission = false;
255
+ /**
256
+ * Was this elevated call DELIBERATE — i.e. did the caller name the authority
257
+ * with `internalContext()` rather than land on it by leaving a context off?
258
+ *
259
+ * Exported because it is the whole decision, and a decision worth testing is
260
+ * worth testing directly: the warning itself is latched once per process, which
261
+ * makes any test of the side effect order-dependent (bun shares one module
262
+ * registry across the whole run, so whichever file resolves a context-less call
263
+ * first consumes the latch and every later assertion silently passes). Pinning
264
+ * the predicate instead is deterministic.
265
+ *
266
+ * Grants nothing and is never consulted for an authorization decision — the
267
+ * verdict is identical either way.
268
+ */
269
+ export function isDeliberateInternalCall(context) {
270
+ return context?.__flairInternal === true;
271
+ }
272
+ /** The advisory text, exported so its content is pinned without tripping the latch. */
273
+ export const INTERNAL_BY_OMISSION_WARNING = "[flair-auth] a resource was invoked with NO caller context and resolved to the trusted " +
274
+ "`internal` verdict: reads are UNFILTERED across every agent, writes are unattributed, and " +
275
+ "the admin-only gate passes. If this is your application's code, pass agentContext(<id>) from " +
276
+ "@tpsdev-ai/flair's in-process seam so the call is scoped and attributed. If the elevated " +
277
+ "authority is genuinely intended, say so with internalContext() and this warning will stop. " +
278
+ "Fires once per process.";
279
+ function noteInternalVerdict(context) {
280
+ if (warnedInternalByOmission)
281
+ return;
282
+ if (isDeliberateInternalCall(context))
283
+ return; // deliberate, and said so
284
+ warnedInternalByOmission = true;
285
+ const stack = (new Error().stack ?? "").split("\n").slice(2, 7).join("\n");
286
+ console.error(INTERNAL_BY_OMISSION_WARNING + "\n" + stack);
287
+ }
198
288
  export async function resolveAgentAuth(context) {
199
289
  const c = context?.request ?? context;
200
- if (!c)
290
+ if (!c) {
291
+ noteInternalVerdict(context);
201
292
  return { kind: "internal" };
293
+ }
202
294
  if (c.tpsAnonymous === true)
203
295
  return { kind: "anonymous" };
204
296
  if (c.tpsAgent) {
@@ -231,5 +323,6 @@ export async function resolveAgentAuth(context) {
231
323
  return { kind: "agent", agentId: auth.agentId, isAdmin: auth.isAdmin };
232
324
  return { kind: "anonymous" };
233
325
  }
326
+ noteInternalVerdict(context);
234
327
  return { kind: "internal" };
235
328
  }
@@ -4,6 +4,7 @@ import { getEmbedding } from "./embeddings-provider.js";
4
4
  import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
5
5
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
+ import { isForbiddenOwnerMutation, resolveGuardedRecord } from "./record-owner-guard.js";
7
8
  // --- Admin credentials ---
8
9
  // Admin auth is sourced exclusively from Harper's own environment variables
9
10
  // (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
@@ -350,6 +351,37 @@ server.http(async (request, nextLayer) => {
350
351
  // ── Server-side permission guards ──────────────────────────────────────────
351
352
  const method = request.method.toUpperCase();
352
353
  const isMutation = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
354
+ // ── THE record-ownership rule, for every table, on every mutating verb ─────
355
+ //
356
+ // One enforcement point rather than one per resource. See
357
+ // resources/record-owner-guard.ts for why this is not written into each
358
+ // resource's put(): Harper maps verbs to methods one-to-one, so a rule living
359
+ // in put() is enforced on PUT alone, and nearly every resource wrote its rules
360
+ // there. Doing this per-resource would be N chances to get one wrong and would
361
+ // still leave the next resource broken by default.
362
+ //
363
+ // Ownership is read from the STORED record named by the path — never from the
364
+ // request body, which is the caller's claim about who owns the row and was how
365
+ // a body that simply omitted the field passed unchecked.
366
+ //
367
+ // Deliberately scoped to records that ALREADY EXIST: creation is left to each
368
+ // resource's own no-forge attribution. That keeps this incapable of breaking a
369
+ // create, a self-write, or a legitimate cross-agent field like MemoryGrant's
370
+ // granteeId — it can only narrow mutation of another agent's stored row.
371
+ if (isMutation && !request.tpsAgentIsAdmin) {
372
+ const guarded = resolveGuardedRecord(url.pathname);
373
+ if (guarded) {
374
+ try {
375
+ const record = await databases.flair[guarded.table]?.get(guarded.id);
376
+ if (isForbiddenOwnerMutation(record, guarded.ownerField, agentId)) {
377
+ return new Response(JSON.stringify({
378
+ error: `forbidden: cannot modify ${guarded.table} owned by another principal`,
379
+ }), { status: 403, headers: { "Content-Type": "application/json" } });
380
+ }
381
+ }
382
+ catch { /* unreadable row → fall through to the resource's own rules */ }
383
+ }
384
+ }
353
385
  if (isMutation) {
354
386
  // OrgEvent: authorId must match authenticated agent
355
387
  if ((url.pathname === "/OrgEvent" || url.pathname.startsWith("/OrgEvent/")) &&
@@ -419,19 +451,59 @@ server.http(async (request, nextLayer) => {
419
451
  catch { }
420
452
  }
421
453
  }
422
- // Soul PUT: only owner or admin
423
- if (url.pathname.startsWith("/Soul") && (method === "PUT" || method === "POST")) {
454
+ // Soul mutations: only the owner or an admin may write a soul entry.
455
+ //
456
+ // This guard had two independent holes, either of which alone let one agent
457
+ // rewrite another's identity data:
458
+ //
459
+ // 1. THE VERB LIST enumerated PUT and POST only. Its three siblings above
460
+ // (OrgEvent, WorkspaceState, Memory) all include PATCH, and Memory
461
+ // includes DELETE; this one did neither. Harper routes PATCH to a
462
+ // resource method that carries no ownership check of its own
463
+ // (Soul.ts's enforceWriteAuth covers post()/put()), and DELETE reached
464
+ // the table with no per-record check at all. Both were live.
465
+ //
466
+ // 2. IT COMPARED THE BODY, not the target. `body.agentId` is the owner
467
+ // the CALLER claims, and the check only fired when that field was
468
+ // present and mismatched — so a body omitting it, which a partial
469
+ // write naturally does, was compared against nothing and passed
470
+ // whatever record the URL pointed at. The resource-level check behind
471
+ // it is "validate-truthy" attribution, which by design also passes an
472
+ // ABSENT owner field, so nothing downstream caught it either. (A PUT
473
+ // happened to fail anyway, but on `agentId: String!` schema
474
+ // validation — a 400 for the wrong reason, not an authorization
475
+ // decision, and not a defence to rely on.)
476
+ //
477
+ // Closing one hole leaves the other reachable through the remaining verbs,
478
+ // so both are closed here: every mutating verb is covered, and ownership is
479
+ // resolved from the STORED RECORD named by the path. That is the same shape
480
+ // as the Memory ownership guard below, which is the resource in this file
481
+ // that already had it right — worth copying rather than reinventing.
482
+ //
483
+ // The path test stays `startsWith("/Soul")` so sibling routes keep the
484
+ // body check they already had; the record lookup is scoped to the real
485
+ // `/Soul/<id>` collection so it never resolves an unrelated id.
486
+ if (url.pathname.startsWith("/Soul") &&
487
+ (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
424
488
  if (!request.tpsAgentIsAdmin) {
425
- let bodyAgentId = null;
426
- try {
427
- const clone = request.clone();
428
- const body = await clone.json();
429
- bodyAgentId = body?.agentId ?? null;
430
- }
431
- catch { }
432
- if (bodyAgentId && bodyAgentId !== agentId) {
433
- return new Response(JSON.stringify({ error: "forbidden: non-admin cannot modify another agent's soul" }), { status: 403 });
489
+ // (a) A PRESENT, mismatched body agentId is a forged attribution.
490
+ if (method !== "DELETE") {
491
+ let bodyAgentId = null;
492
+ try {
493
+ const clone = request.clone();
494
+ const body = await clone.json();
495
+ bodyAgentId = body?.agentId ?? null;
496
+ }
497
+ catch { }
498
+ if (bodyAgentId && bodyAgentId !== agentId) {
499
+ return new Response(JSON.stringify({ error: "forbidden: non-admin cannot modify another agent's soul" }), { status: 403 });
500
+ }
434
501
  }
502
+ // (b) The owner of the record actually being written — the half that
503
+ // catches a body simply leaving agentId out — is now the shared
504
+ // record-ownership rule at the top of this block, which applies it to
505
+ // every table on every mutating verb. Deliberately NOT repeated here:
506
+ // one condition enforced in two places is how the two drift apart.
435
507
  }
436
508
  }
437
509
  // Memory promotion guard: only admin can approve or set durability=permanent
@@ -453,21 +525,22 @@ server.http(async (request, nextLayer) => {
453
525
  catch { }
454
526
  }
455
527
  }
456
- // Memory PUT/DELETE: ownership check (non-admin can only modify their own memories)
528
+ // Memory DELETE: permanent memories are admin-only to purge.
529
+ //
530
+ // The OWNERSHIP half of this guard — which was the model the shared
531
+ // record-ownership rule above was built from, and the only one in this file
532
+ // that already covered PATCH — now lives there and covers every table. What
533
+ // remains here is the part that is NOT about ownership: durability. An agent
534
+ // owning a permanent memory still may not purge it.
457
535
  if (((url.pathname === "/Memory" || url.pathname.startsWith("/Memory/") || url.pathname === "/memory" || url.pathname.startsWith("/memory/"))) &&
458
- (method === "PUT" || method === "DELETE" || method === "PATCH")) {
536
+ method === "DELETE") {
459
537
  if (!request.tpsAgentIsAdmin) {
460
538
  try {
461
539
  const pathParts = url.pathname.split("/").filter(Boolean);
462
540
  const memId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
463
541
  if (memId) {
464
542
  const record = await databases.flair.Memory.get(memId);
465
- if (record && record.agentId && record.agentId !== agentId) {
466
- return new Response(JSON.stringify({
467
- error: `forbidden: cannot modify memory owned by ${record.agentId}`
468
- }), { status: 403 });
469
- }
470
- if (method === "DELETE" && record?.durability === "permanent") {
543
+ if (record?.durability === "permanent") {
471
544
  return new Response(JSON.stringify({
472
545
  error: "forbidden: only admins can purge permanent memories"
473
546
  }), { status: 403 });
@@ -98,19 +98,17 @@ const MODEL_NAME = "nomic-embed-text";
98
98
  * table for the exact contract this PR bumps to).
99
99
  *
100
100
  * "mean" is nomic-embed-text-v1.5's actual pooling type — NOT assumed from
101
- * the model's reputation, directly confirmed against the shipped GGUF:
102
- * `node_modules/.bin/node-llama-cpp inspect gguf
103
- * ~/.flair/data/models/nomic-embed-text-v1.5.Q4_K_M.gguf` reports
104
- * `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
101
+ * the model's reputation, directly confirmed against the shipped GGUF by
102
+ * reading its metadata: `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
105
103
  * `enum llama_pooling_type` maps 1 -> `LLAMA_POOLING_TYPE_MEAN` (0 = none,
106
- * 1 = mean, 2 = cls, 3 = last, 4 = rank). nomic-embed-text is a
107
- * NomicBertModel architecture, not Qwen3 (last-token) flair registers no
108
- * Qwen3-class embedding model today (the Qwen3-Reranker-0.6B in
109
- * resources/rerank-provider.ts is a SEPARATE code path that calls
110
- * node-llama-cpp directly for generative yes/no scoring, never goes through
111
- * HFE's register()/init(), and has no embedding-pooling context at all — see
112
- * that file's header). If a Qwen3-class (last-token-pooling) embedding model
113
- * is ever registered here, it must declare `pooling: "last"`, not "mean".
104
+ * 1 = mean, 2 = cls, 3 = last, 4 = rank). (That reading was originally taken
105
+ * with `node-llama-cpp inspect gguf`, which flair no longer depends on —
106
+ * flair#893. Any GGUF inspector reports the same metadata field; install the
107
+ * CLI ad hoc if the check needs repeating.) nomic-embed-text is a
108
+ * NomicBertModel architecture, not Qwen3 (last-token), and flair registers no
109
+ * Qwen3-class embedding model today. If a Qwen3-class (last-token-pooling)
110
+ * embedding model is ever registered here, it must declare `pooling: "last"`,
111
+ * not "mean".
114
112
  *
115
113
  * Applies to the bench-only `modelPath` override too (`benchModelPathOverride()`
116
114
  * below): `FLAIR_RECALL_HARNESS_MODEL_PATH` lets an operator point this
@@ -152,7 +152,7 @@ const EMBEDDING_PREFIXES_ENABLED = true; // Phase 2 prefix flip ON — re-baseli
152
152
  * embedding call, so this lets it override the ONE thing that matters
153
153
  * (whether `models.embed` receives `inputType`, and whether `getModelId()`
154
154
  * stamps the suffix) via the env var it already forwards to `startHarper()`
155
- * (the same mechanism `FLAIR_HYBRID_RETRIEVAL`/`FLAIR_RERANK_ENABLED` use).
155
+ * (the same mechanism `FLAIR_HYBRID_RETRIEVAL` uses).
156
156
  *
157
157
  * Bidirectional, not two separate force-on/force-off hatches: THE GATE has
158
158
  * flipped direction once already (off→on, this PR) and may again in the
@@ -236,12 +236,15 @@ async function getModelsApi() {
236
236
  /**
237
237
  * Resolve the directory the embeddings model lives in / downloads into.
238
238
  *
239
- * Implementation moved to `resources/models-dir.ts` (flair#815) so the
240
- * reranker can share the exact same resolution without importing this module
241
- * (several unit-isolated tests `mock.module()` this file wholesale — see
242
- * models-dir.ts's header). Re-exported here so existing consumers keep their
243
- * import site: `resources/embeddings-boot.ts` (flair#694), which builds the
244
- * `modelsDir` it passes to harper-fabric-embeddings' `register()`, and
239
+ * Implementation lives in `resources/models-dir.ts` (extracted in flair#815)
240
+ * so any model consumer can share the exact same resolution without importing
241
+ * this module — several unit-isolated tests `mock.module()` this file
242
+ * wholesale, and an incomplete stub then breaks every file that imports it.
243
+ * See models-dir.ts's header for why that hazard makes the tiny standalone
244
+ * module worth keeping even now that embeddings are its only consumer.
245
+ * Re-exported here so existing consumers keep their import site:
246
+ * `resources/embeddings-boot.ts` (flair#694), which builds the `modelsDir` it
247
+ * passes to harper-fabric-embeddings' `register()`, and
245
248
  * test/unit/embeddings-models-dir.test.ts, which pins the resolution order.
246
249
  */
247
250
  export { resolveModelsDir } from "./models-dir.js";
@@ -3,9 +3,9 @@ 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";
5
5
  import { fileURLToPath } from "node:url";
6
- import { getRerankStatus } from "./rerank-provider.js";
7
6
  import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
8
7
  import { getMigrationStatusSnapshot } from "./migrations/status.js";
8
+ import { resolveMigrationDataDirForRead } from "./migrations/data-dir.js";
9
9
  import { REM_DEDUP_STATS_PATH } from "./dedup-cluster.js";
10
10
  const db = databases;
11
11
  const redactHome = (p) => {
@@ -560,12 +560,21 @@ export class HealthDetail extends Resource {
560
560
  // "pre-flight integrity check in progress" state the K&S verdict calls
561
561
  // for; a halted migration surfaces here (with `reason`) AND as a
562
562
  // warning below so it's visible without a separate lookup.
563
+ //
564
+ // flair#812: the dataDir is resolved by the shared, READ-ONLY resolver
565
+ // (resources/migrations/data-dir.ts) rather than the old
566
+ // `HDB_ROOT ?? homedir()/.flair/data` expression duplicated here — that
567
+ // env var is never set by anything, so the left branch was dead and the
568
+ // path silently disagreed with wherever the boot cycle actually wrote.
569
+ // `lastCycleError` is surfaced so `flair doctor` can name WHY a cycle
570
+ // didn't run, not merely that it didn't.
563
571
  try {
564
- const migrationsDataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
572
+ const migrationsDataDir = resolveMigrationDataDirForRead();
565
573
  const snapshot = getMigrationStatusSnapshot(migrationsDataDir);
566
574
  stats.migrations = {
567
575
  cyclePhase: snapshot.cyclePhase,
568
576
  lastCycleAt: snapshot.lastCycleAt ?? null,
577
+ lastCycleError: snapshot.lastCycleError ?? null,
569
578
  migrations: snapshot.migrations,
570
579
  };
571
580
  for (const m of snapshot.migrations) {
@@ -573,13 +582,25 @@ export class HealthDetail extends Resource {
573
582
  warnings.push({ level: "warn", message: `migration '${m.id}' ${m.state}${m.reason ? `: ${m.reason}` : ""} — see \`flair doctor\`` });
574
583
  }
575
584
  }
585
+ // The boot trigger sets `scheduled` synchronously at module load, so
586
+ // `idle` here means resources/migration-boot.js never loaded — the
587
+ // instance is running a build whose migration trigger is absent or
588
+ // failed to import, and NO migration will ever run on it.
589
+ if (snapshot.cyclePhase === "idle" && snapshot.migrations.length > 0) {
590
+ warnings.push({
591
+ level: "warn",
592
+ message: "migration boot cycle never fired on this instance (cyclePhase=idle) — no migration will run until this is resolved; see `flair doctor`",
593
+ });
594
+ }
576
595
  }
577
596
  catch {
578
597
  stats.migrations = null;
579
598
  }
580
599
  // ── Disk ──
600
+ // flair#812: same shared read-only resolution as the migrations section
601
+ // above, so `disk.dataDir` names the directory migrations actually use.
581
602
  try {
582
- const dataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
603
+ const dataDir = resolveMigrationDataDirForRead();
583
604
  const snapshotDir = join(homedir(), ".flair", "snapshots");
584
605
  const dirSize = async (root, maxDepth = 6) => {
585
606
  if (!(await exists(root)))
@@ -658,22 +679,6 @@ export class HealthDetail extends Resource {
658
679
  catch {
659
680
  stats.bridges = null;
660
681
  }
661
- // ── Reranker ──
662
- // Cross-encoder rerank stage. Reports flag/model/mode + live counters so we
663
- // can see if it's silently degrading (fallbackCount climbing) in prod.
664
- try {
665
- const rr = getRerankStatus();
666
- stats.rerank = rr;
667
- if (rr.enabled && rr.state === "failed") {
668
- warnings.push({ level: "warn", message: `reranker enabled but unavailable: ${rr.error ?? "init failed"} — recall falling back to vector order` });
669
- }
670
- if (rr.enabled && rr.rerankCount > 0 && rr.fallbackCount > rr.rerankCount) {
671
- warnings.push({ level: "warn", message: `reranker falling back more than reranking (${rr.fallbackCount} fallbacks / ${rr.rerankCount} reranks) — check latency budget / topN` });
672
- }
673
- }
674
- catch {
675
- stats.rerank = null;
676
- }
677
682
  // ── Warnings ──
678
683
  stats.warnings = warnings;
679
684
  // ── Process info ──