@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.
- package/README.md +194 -377
- package/dist/cli.js +1434 -288
- 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/AdminPrincipals.js +10 -1
- package/dist/resources/Agent.js +105 -11
- package/dist/resources/AgentSeed.js +10 -2
- package/dist/resources/MemoryBootstrap.js +7 -8
- package/dist/resources/MemoryUsage.js +18 -0
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/SemanticSearch.js +17 -45
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/agent-admin.js +149 -0
- package/dist/resources/agent-auth.js +98 -5
- package/dist/resources/auth-middleware.js +92 -19
- 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 +234 -0
- package/dist/resources/mcp-handler.js +14 -4
- 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/presence-internal.js +6 -1
- package/dist/resources/record-owner-guard.js +149 -0
- 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 +303 -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 +99 -4
- 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
|
@@ -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
|
-
//
|
|
36
|
-
// super_user — admin here gates flair-policy decisions (promotions,
|
|
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:
|
|
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
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
* `
|
|
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).
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
|
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
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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";
|
package/dist/resources/health.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 ──
|