@tpsdev-ai/flair 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -377
- package/dist/cli-shim.cjs +1 -1
- package/dist/cli.js +1567 -366
- package/dist/deploy.js +241 -43
- package/dist/fabric-upgrade.js +77 -24
- package/dist/federation/scheduler.js +500 -0
- package/dist/fleet-verify.js +3 -3
- package/dist/install/clients.js +111 -53
- package/dist/lib/mcp-enable.js +2 -2
- 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 +212 -55
- package/dist/rem/snapshot.js +13 -0
- package/dist/replication-convergence.js +505 -0
- package/dist/resources/A2AAdapter.js +1 -1
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +1 -1
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +1 -1
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +1 -1
- package/dist/resources/Credential.js +1 -1
- package/dist/resources/Federation.js +1 -1
- package/dist/resources/Instance.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +1 -1
- package/dist/resources/Memory.js +1 -1
- package/dist/resources/MemoryBootstrap.js +8 -9
- package/dist/resources/MemoryCandidate.js +168 -0
- package/dist/resources/MemoryConsolidate.js +1 -1
- package/dist/resources/MemoryDedupStats.js +1 -1
- package/dist/resources/MemoryFeed.js +1 -1
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryMaintenance.js +1 -1
- package/dist/resources/MemoryReflect.js +2 -2
- package/dist/resources/MemoryReindex.js +1 -1
- package/dist/resources/MemoryUsage.js +1 -1
- package/dist/resources/OAuth.js +1 -1
- package/dist/resources/OAuthClient.js +1 -1
- package/dist/resources/OrgEvent.js +1 -1
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/OrgEventMaintenance.js +1 -1
- package/dist/resources/PairingToken.js +1 -1
- package/dist/resources/Peer.js +1 -1
- package/dist/resources/Presence.js +3 -3
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +1 -1
- package/dist/resources/SemanticSearch.js +18 -46
- package/dist/resources/SkillScan.js +1 -1
- package/dist/resources/Soul.js +1 -1
- package/dist/resources/SoulFeed.js +1 -1
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/XAA.js +1 -1
- package/dist/resources/a2a-url.js +1 -1
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +1 -1
- package/dist/resources/auth-middleware.js +1 -1
- package/dist/resources/embeddings-boot.js +14 -16
- package/dist/resources/embeddings-provider.js +15 -12
- package/dist/resources/federation-cleanup.js +5 -5
- package/dist/resources/federation-nonce-store.js +5 -5
- package/dist/resources/health.js +53 -25
- package/dist/resources/in-process.js +225 -0
- package/dist/resources/instance-identity.js +1 -1
- package/dist/resources/mcp-client-metadata-fields.js +1 -1
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-oauth.js +2 -2
- package/dist/resources/mcp-tools.js +23 -17
- package/dist/resources/memory-read-scope.js +1 -1
- package/dist/resources/memory-reflect-lib.js +3 -3
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +81 -11
- package/dist/resources/migrations/data-dir.js +205 -0
- package/dist/resources/migrations/embedding-stamp.js +3 -3
- package/dist/resources/migrations/graph-heal.js +2 -2
- package/dist/resources/migrations/ledger.js +1 -1
- package/dist/resources/migrations/progress.js +33 -0
- package/dist/resources/migrations/registry.js +14 -5
- package/dist/resources/migrations/runner.js +93 -13
- package/dist/resources/migrations/state.js +13 -2
- package/dist/resources/migrations/synthetic-test-migration.js +1 -1
- package/dist/resources/migrations/visibility-backfill.js +232 -0
- package/dist/resources/models-dir.js +18 -9
- package/dist/resources/record-types.js +28 -0
- package/dist/resources/semantic-retrieval-core.js +6 -5
- package/dist/resources/usage-recording.js +1 -1
- package/dist/src/lib/scheduler-platform.js +128 -0
- package/dist/src/lib/xml-escape.js +54 -0
- package/dist/src/rem/scheduler.js +397 -0
- package/docs/deploying-on-fabric.md +267 -0
- package/docs/deployment.md +6 -0
- package/docs/embedding-in-a-harper-app.md +299 -0
- package/docs/federation.md +61 -4
- package/docs/integrations.md +3 -0
- package/docs/mcp-clients.md +16 -7
- package/docs/quickstart.md +80 -54
- package/docs/releasing.md +72 -38
- package/docs/supply-chain-policy.md +40 -4
- package/docs/troubleshooting.md +24 -0
- package/docs/upgrade.md +100 -5
- package/package.json +4 -14
- package/schemas/memory.graphql +9 -1
- 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
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* slice.
|
|
37
37
|
*/
|
|
38
38
|
import { resolveVersion } from "./version.js";
|
|
39
|
+
import { agentContext, adminContext, collectionResource } from "./in-process.js";
|
|
39
40
|
const H = {};
|
|
40
41
|
const LOADERS = {
|
|
41
42
|
SemanticSearch: async () => (await import("./SemanticSearch.js")).SemanticSearch,
|
|
@@ -78,16 +79,22 @@ export function __setHandlers(overrides) {
|
|
|
78
79
|
* agent, never anonymous, never a header re-verify.
|
|
79
80
|
*/
|
|
80
81
|
function delegationContext(agent) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
// Shape single-sourced from resources/in-process.ts — the same context an
|
|
83
|
+
// embedding Harper app builds to act as one of its agents — plus the
|
|
84
|
+
// `x-tps-agent` header shim the delegated handlers' own header-reading paths
|
|
85
|
+
// expect.
|
|
86
|
+
//
|
|
87
|
+
// The admin branch is spelled out rather than passed as a flag: adminContext()
|
|
88
|
+
// is the greppable name for "this call carries flair-admin authority". Both
|
|
89
|
+
// constructors THROW on an empty agent id, which is the outcome we want here —
|
|
90
|
+
// a token that resolved to no principal must fail the tool call, never fall
|
|
91
|
+
// through to flair's unfiltered `internal` verdict.
|
|
92
|
+
const ctx = agent.isAdmin ? adminContext(agent.agentId) : agentContext(agent.agentId);
|
|
93
|
+
ctx.request.headers = {
|
|
94
|
+
get: (k) => (k.toLowerCase() === "x-tps-agent" ? agent.agentId : undefined),
|
|
90
95
|
};
|
|
96
|
+
ctx.user = undefined;
|
|
97
|
+
return ctx;
|
|
91
98
|
}
|
|
92
99
|
/**
|
|
93
100
|
* Unwrap a handler return value into a plain object/string for the MCP result.
|
|
@@ -128,8 +135,7 @@ async function memorySearch(agent, args) {
|
|
|
128
135
|
}
|
|
129
136
|
async function memoryStore(agent, args) {
|
|
130
137
|
const Cls = await handler("Memory");
|
|
131
|
-
const h =
|
|
132
|
-
h.isCollection = true;
|
|
138
|
+
const h = await collectionResource(Cls, delegationContext(agent));
|
|
133
139
|
// agentId is the RESOLVED agent — Memory.post also re-checks ownership via
|
|
134
140
|
// resolveAgentAuth, so a mismatched body agentId would 403 anyway; we set it
|
|
135
141
|
// to the verified id so the write is correctly owned.
|
|
@@ -204,8 +210,10 @@ async function memoryUpdate(agent, args) {
|
|
|
204
210
|
// version's provenance records which client authored this update.
|
|
205
211
|
if (agent.clientId)
|
|
206
212
|
record.claimedClient = agent.clientId;
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
// A create needs a COLLECTION-bound instance (see resources/in-process.ts);
|
|
214
|
+
// `h` above is the by-id handle the get()/put() branches use.
|
|
215
|
+
const coll = await collectionResource(Cls, delegationContext(agent));
|
|
216
|
+
return unwrap(await coll.post(record));
|
|
209
217
|
}
|
|
210
218
|
const merged = { ...existing, content, updatedAt: new Date().toISOString() };
|
|
211
219
|
delete merged.embedding;
|
|
@@ -281,8 +289,7 @@ async function soulGet(agent, args) {
|
|
|
281
289
|
}
|
|
282
290
|
async function workspaceSet(agent, args) {
|
|
283
291
|
const Cls = await handler("WorkspaceState");
|
|
284
|
-
const h =
|
|
285
|
-
h.isCollection = true;
|
|
292
|
+
const h = await collectionResource(Cls, delegationContext(agent));
|
|
286
293
|
// No agentId in the body — WorkspaceState.post attributes the record to the
|
|
287
294
|
// authenticated identity (from the context), never the body. Same no-forge
|
|
288
295
|
// contract as the flair-mcp stdio tool.
|
|
@@ -304,8 +311,7 @@ async function workspaceSet(agent, args) {
|
|
|
304
311
|
}
|
|
305
312
|
async function orgEvent(agent, args) {
|
|
306
313
|
const Cls = await handler("OrgEvent");
|
|
307
|
-
const h =
|
|
308
|
-
h.isCollection = true;
|
|
314
|
+
const h = await collectionResource(Cls, delegationContext(agent));
|
|
309
315
|
// No authorId in the body — OrgEvent.post attributes to the authenticated
|
|
310
316
|
// identity, never the body (no forging as another agent).
|
|
311
317
|
const body = { kind: args?.kind, summary: args?.summary };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { databases } from "
|
|
1
|
+
import { databases } from "harper";
|
|
2
2
|
import { PRIVATE_VISIBILITY, isPrivateVisibility } from "./memory-visibility.js";
|
|
3
3
|
/**
|
|
4
4
|
* ─── Centralized Memory read-scoping (the original grant-gated read model → within-org-read-open) ─
|
|
@@ -44,7 +44,7 @@ export const GENERATE_TEMPERATURE = 0.2;
|
|
|
44
44
|
//
|
|
45
45
|
// Passed as `responseFormat: { schema: CANDIDATES_SCHEMA }` to models.generate()
|
|
46
46
|
// so backends that honor structured output (Ollama, OpenAI — verified against
|
|
47
|
-
// the pinned
|
|
47
|
+
// the pinned harper 5.1.17's bundled backends) return conformant
|
|
48
48
|
// JSON directly. Not every backend enforces it (Anthropic's Messages API has
|
|
49
49
|
// no equivalent and Harper documents that it silently ignores the option) —
|
|
50
50
|
// this module never trusts the backend to have enforced the schema; every
|
|
@@ -226,7 +226,7 @@ export function parseAndValidateCandidates(raw, gatheredMemoryIds) {
|
|
|
226
226
|
/**
|
|
227
227
|
* Name Harper's models facade sets on the error it throws when no backend is
|
|
228
228
|
* registered for the requested logical name (`ModelBackendNotFoundError`,
|
|
229
|
-
* resources/models/backendRegistry.ts in
|
|
229
|
+
* resources/models/backendRegistry.ts in harper 5.1.17). That
|
|
230
230
|
* class isn't part of the package's public export surface (only its
|
|
231
231
|
* `Models`/type surface is re-exported from the package root), so detecting
|
|
232
232
|
* it here is a documented duck-type on `.name` rather than `instanceof` —
|
|
@@ -237,7 +237,7 @@ export const MODEL_BACKEND_NOT_FOUND_ERROR_NAME = "ModelBackendNotFoundError";
|
|
|
237
237
|
* Calls generate(), validates the result, and on malformed/mismatched output
|
|
238
238
|
* retries exactly once with an explicit `responseFormat: 'json'` (the
|
|
239
239
|
* "json-fallback path" — spec §3A items 2 & 3: build-time check confirmed
|
|
240
|
-
* `GenerateOpts.responseFormat` supports `{ schema }` in
|
|
240
|
+
* `GenerateOpts.responseFormat` supports `{ schema }` in harper
|
|
241
241
|
* 5.1.17's types, but not every backend enforces it, so the first attempt
|
|
242
242
|
* requests schema mode and the fallback attempt requests plain json mode).
|
|
243
243
|
* Both attempts run through the SAME parseAndValidateCandidates — a parse
|
|
@@ -9,18 +9,18 @@
|
|
|
9
9
|
* - src/cli.ts's runFederationSyncOnce() — the federation-sync PUSH filter
|
|
10
10
|
* that must not replicate `private` memories to peer instances.
|
|
11
11
|
*
|
|
12
|
-
* Deliberately has ZERO imports — not even "
|
|
12
|
+
* Deliberately has ZERO imports — not even "harper". That is
|
|
13
13
|
* intentional and load-bearing: src/cli.ts is a standalone CLI entrypoint
|
|
14
14
|
* that runs OUTSIDE any running Harper instance (e.g. `flair federation
|
|
15
15
|
* sync` invoked from a cron/launchd job). resources/memory-read-scope.ts
|
|
16
|
-
* imports `databases` from "
|
|
16
|
+
* imports `databases` from "harper", and that package's
|
|
17
17
|
* top-level init eagerly resolves storage paths and THROWS when there is no
|
|
18
18
|
* live Harper runtime backing it (confirmed empirically — it takes down
|
|
19
19
|
* even `flair --help`). So src/cli.ts must never import
|
|
20
20
|
* resources/memory-read-scope.ts (or anything else that drags that
|
|
21
21
|
* side-effecting import in) directly. This module is the safe seam: a pure
|
|
22
22
|
* function + constant that both sides can import without dragging in
|
|
23
|
-
* "
|
|
23
|
+
* "harper".
|
|
24
24
|
*
|
|
25
25
|
* ── The migration invariant (non-negotiable, mirrors memory-read-scope.ts) ──
|
|
26
26
|
* A record with NO `visibility` field (written before the field existed) is
|
|
@@ -25,20 +25,43 @@
|
|
|
25
25
|
* `runMigrationCycle` itself never throws (see runner.ts's module doc) —
|
|
26
26
|
* the `.catch()` below is pure defense-in-depth so a bug there can never
|
|
27
27
|
* take down the process either.
|
|
28
|
+
*
|
|
29
|
+
* ─── flair#812: this path must never fail silently ────────────────────────
|
|
30
|
+
* `runMigrationCycle` REPORTS why a cycle didn't run (`{ ran: false, reason
|
|
31
|
+
* }`) — it does not log it, because it is a library. This file, the only
|
|
32
|
+
* caller, previously DISCARDED that value, which is what turned a
|
|
33
|
+
* recoverable environment problem into an invisible one: on a provisioned
|
|
34
|
+
* install where `~/.flair/data` isn't creatable, the runner's very first
|
|
35
|
+
* step (`acquireMigrationLock` → `mkdirSync`) threw `EACCES`, the runner
|
|
36
|
+
* caught it and returned `reason: "lock error: ..."`, and nothing anywhere
|
|
37
|
+
* said a word. `.migrations/state.json` was never written, no
|
|
38
|
+
* `[flair-migrations]` marker ever appeared, and EVERY migration — shipped
|
|
39
|
+
* and future — was skipped on that instance forever.
|
|
40
|
+
*
|
|
41
|
+
* So: the data dir is now resolved to a candidate PROVEN writable before
|
|
42
|
+
* the cycle is handed one (resources/migrations/data-dir.ts), and every
|
|
43
|
+
* non-benign outcome — an unresolvable data dir, tables that never became
|
|
44
|
+
* ready, a runner-reported failure, an unexpected throw — is BOTH logged
|
|
45
|
+
* with a `[flair-migrations]` marker AND recorded as a `failed` progress
|
|
46
|
+
* entry per registered migration. That progress entry is what makes the
|
|
47
|
+
* condition visible where an operator will actually meet it:
|
|
48
|
+
* `/HealthDetail` (with a warning), `flair doctor`'s Migrations section
|
|
49
|
+
* (counted as an issue) and `flair quality`'s `instance.migrationsClean`.
|
|
50
|
+
*
|
|
51
|
+
* The one deliberately-quiet outcome is `single-flight` — another thread or
|
|
52
|
+
* process holds the lock and is running the cycle. That is the guard doing
|
|
53
|
+
* its job, not a failure, and Harper boots N worker threads that each load
|
|
54
|
+
* this module; logging it would emit N-1 scary lines on every healthy boot.
|
|
28
55
|
*/
|
|
29
|
-
import { databases } from "
|
|
30
|
-
import { homedir } from "node:os";
|
|
56
|
+
import { databases } from "harper";
|
|
31
57
|
import { existsSync, readFileSync } from "node:fs";
|
|
32
58
|
import { join, dirname } from "node:path";
|
|
33
59
|
import { fileURLToPath } from "node:url";
|
|
34
60
|
import { buildRegistry } from "./migrations/registry.js";
|
|
35
61
|
import { runMigrationCycle } from "./migrations/runner.js";
|
|
36
|
-
import { seedIdleProgress } from "./migrations/progress.js";
|
|
62
|
+
import { markIdleMigrationsFailed, seedIdleProgress, setCyclePhase } from "./migrations/progress.js";
|
|
63
|
+
import { describeUnresolvableDataDir, resolveWritableMigrationDataDir, } from "./migrations/data-dir.js";
|
|
37
64
|
import { getMode } from "./embeddings-provider.js";
|
|
38
|
-
/** Same dataDir resolution as resources/health.ts's disk section. */
|
|
39
|
-
export function resolveMigrationDataDir() {
|
|
40
|
-
return process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
|
|
41
|
-
}
|
|
42
65
|
/** Same "resolve the running package's own version" idiom as resources/health.ts. */
|
|
43
66
|
export function resolveRunningVersion() {
|
|
44
67
|
try {
|
|
@@ -103,6 +126,26 @@ async function waitForEmbeddingsSettled(maxWaitMs = 8_500, intervalMs = 150) {
|
|
|
103
126
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
104
127
|
}
|
|
105
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Records a boot-path failure everywhere an operator might look: the
|
|
131
|
+
* process log (with the `[flair-migrations]` marker the runbook greps for),
|
|
132
|
+
* the cycle status (`lastCycleError`, surfaced by `/HealthDetail` and named
|
|
133
|
+
* by `flair doctor`), and a `failed` entry for each migration that never
|
|
134
|
+
* started — because a boot path that cannot run is not a per-migration
|
|
135
|
+
* problem, it is an instance-wide one, and `flair doctor` / `flair quality`
|
|
136
|
+
* read per-migration state.
|
|
137
|
+
*
|
|
138
|
+
* `failed` (not `halted`) is deliberate for these: nothing was attempted
|
|
139
|
+
* against the corpus, so there is no halted work to resume. And the marking
|
|
140
|
+
* deliberately touches only migrations still `idle` — see
|
|
141
|
+
* `markIdleMigrationsFailed` for why overwriting a terminal state would be
|
|
142
|
+
* a downgrade rather than extra information.
|
|
143
|
+
*/
|
|
144
|
+
function reportBootFailure(registry, reason) {
|
|
145
|
+
console.error(`[flair-migrations] ${reason}`);
|
|
146
|
+
setCyclePhase("done", reason);
|
|
147
|
+
markIdleMigrationsFailed(registry.list().map((m) => m.id), reason);
|
|
148
|
+
}
|
|
106
149
|
let scheduled = false;
|
|
107
150
|
export function scheduleMigrationBoot() {
|
|
108
151
|
if (scheduled)
|
|
@@ -110,30 +153,57 @@ export function scheduleMigrationBoot() {
|
|
|
110
153
|
scheduled = true;
|
|
111
154
|
const registry = buildRegistry();
|
|
112
155
|
seedIdleProgress(registry.list().map((m) => m.id));
|
|
156
|
+
// Proof-of-life, set SYNCHRONOUSLY at module load: from here on,
|
|
157
|
+
// `cyclePhase === "idle"` means this module never loaded at all — the
|
|
158
|
+
// one hypothesis flair#812 could not otherwise rule out from the outside.
|
|
159
|
+
setCyclePhase("scheduled");
|
|
113
160
|
setImmediate(() => {
|
|
114
161
|
void (async () => {
|
|
115
162
|
const ready = await waitForTablesReady();
|
|
116
163
|
if (!ready) {
|
|
117
|
-
|
|
164
|
+
reportBootFailure(registry, "Memory/Relationship tables never became ready within 30s — this boot's migration cycle was skipped (it retries on the next restart)");
|
|
118
165
|
return;
|
|
119
166
|
}
|
|
120
167
|
await waitForEmbeddingsSettled();
|
|
168
|
+
// Resolve a data dir PROVEN writable before handing one to the runner
|
|
169
|
+
// — the runner's first act is to take a file lock there, and a
|
|
170
|
+
// failure at that point is reported back rather than thrown, which is
|
|
171
|
+
// precisely how flair#812 stayed invisible. See data-dir.ts.
|
|
172
|
+
const resolved = resolveWritableMigrationDataDir();
|
|
173
|
+
if (!resolved.dataDir) {
|
|
174
|
+
reportBootFailure(registry, describeUnresolvableDataDir(resolved.tried));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
121
177
|
try {
|
|
122
|
-
await runMigrationCycle({
|
|
178
|
+
const result = await runMigrationCycle({
|
|
123
179
|
registry,
|
|
124
180
|
getTable,
|
|
125
|
-
dataDir:
|
|
181
|
+
dataDir: resolved.dataDir,
|
|
126
182
|
runningVersion: resolveRunningVersion(),
|
|
127
183
|
});
|
|
184
|
+
// `nothing pending` is the healthy no-op; `single-flight` is the
|
|
185
|
+
// lock guard working as designed on a multi-threaded boot. Anything
|
|
186
|
+
// else is a cycle that WANTED to run and couldn't, and must be loud.
|
|
187
|
+
if (!result.ran && result.reason && !isBenignSkip(result.reason)) {
|
|
188
|
+
reportBootFailure(registry, `migration cycle did not run: ${result.reason}`);
|
|
189
|
+
}
|
|
128
190
|
}
|
|
129
191
|
catch (err) {
|
|
130
192
|
// Defense-in-depth only — runMigrationCycle is documented to never
|
|
131
193
|
// throw. A boot-path exception must never surface here regardless.
|
|
132
|
-
|
|
194
|
+
reportBootFailure(registry, `unexpected error from runMigrationCycle: ${err?.message ?? String(err)}`);
|
|
133
195
|
}
|
|
134
196
|
})();
|
|
135
197
|
});
|
|
136
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* The two `{ ran: false }` reasons that are NOT failures: nothing was
|
|
201
|
+
* pending, or another holder is already running the cycle. Matched on the
|
|
202
|
+
* prefixes runner.ts constructs (`"nothing pending"`, `"single-flight: …"`).
|
|
203
|
+
*/
|
|
204
|
+
export function isBenignSkip(reason) {
|
|
205
|
+
return reason === "nothing pending" || reason.startsWith("single-flight:");
|
|
206
|
+
}
|
|
137
207
|
// Test-only reset so a unit/integration test can re-trigger scheduling
|
|
138
208
|
// within the same process (never used in production — a real process only
|
|
139
209
|
// ever boots once).
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* data-dir.ts — resolves the directory the migration subsystem owns
|
|
3
|
+
* (`<dataDir>/.migrations/{state.json,lock,snapshots,exports}`), and proves
|
|
4
|
+
* it is actually usable BEFORE the boot cycle commits to it.
|
|
5
|
+
*
|
|
6
|
+
* ─── Why this file exists (flair#812) ──────────────────────────────────────
|
|
7
|
+
* The previous resolution was a single expression, duplicated in
|
|
8
|
+
* `resources/migration-boot.ts` and `resources/health.ts`:
|
|
9
|
+
*
|
|
10
|
+
* process.env.HDB_ROOT ?? join(homedir(), ".flair", "data")
|
|
11
|
+
*
|
|
12
|
+
* Both halves of that were wrong in a way that only shows up off the
|
|
13
|
+
* developer/default shape:
|
|
14
|
+
*
|
|
15
|
+
* 1. `HDB_ROOT` IS NEVER SET BY ANYTHING. It is not a Harper process env
|
|
16
|
+
* var — Harper's own root-path env var is `ROOTPATH`
|
|
17
|
+
* (`harper`'s `utility/common_utils.ts` reads
|
|
18
|
+
* `process.env['ROOTPATH']`; `HDB_ROOT` appears only as a legacy *config
|
|
19
|
+
* key* alias in `utility/hdbTerms.ts`, and Harper never reads or writes
|
|
20
|
+
* it on `process.env`). flair's own spawner (`src/cli.ts`) exports
|
|
21
|
+
* `ROOTPATH`, never `HDB_ROOT`, and `resources/models-dir.ts` already
|
|
22
|
+
* reads `ROOTPATH` for exactly this reason. So the `??` left branch was
|
|
23
|
+
* dead code and the effective resolution was UNCONDITIONALLY
|
|
24
|
+
* `~/.flair/data`, regardless of where Harper's real root actually is.
|
|
25
|
+
*
|
|
26
|
+
* 2. `~/.flair/data` is right only by coincidence — it is `flair init`'s
|
|
27
|
+
* default data dir, so on a default local install the two happen to
|
|
28
|
+
* coincide. On a PROVISIONED install (a service-managed spoke, a
|
|
29
|
+
* container, a Harper Fabric component deployment) the process's
|
|
30
|
+
* `homedir()` is whatever the service account has: possibly read-only,
|
|
31
|
+
* possibly nonexistent, and in general nothing to do with the instance's
|
|
32
|
+
* data.
|
|
33
|
+
*
|
|
34
|
+
* When `~/.flair/data` is not creatable, EVERY consumer of the old
|
|
35
|
+
* expression failed — and, critically, failed SILENTLY: the very first
|
|
36
|
+
* thing `runMigrationCycle` does is `acquireMigrationLock`, whose
|
|
37
|
+
* `mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 })` throws
|
|
38
|
+
* `EACCES`/`ENOENT`, which the runner catches and turns into
|
|
39
|
+
* `{ ran: false, reason: "lock error: ..." }` — a value `migration-boot.ts`
|
|
40
|
+
* then discarded. No log line, no state file, no health signal, and every
|
|
41
|
+
* migration ever shipped skipped forever on that instance. (Contrast
|
|
42
|
+
* `resources/embeddings-boot.ts`, loaded by the SAME `jsResource` glob:
|
|
43
|
+
* it touches no filesystem path of its own, which is exactly why embeddings
|
|
44
|
+
* kept working on the affected shapes while migrations did not.)
|
|
45
|
+
*
|
|
46
|
+
* ─── The resolution ─────────────────────────────────────────────────────
|
|
47
|
+
* An ordered candidate list, first USABLE one wins:
|
|
48
|
+
*
|
|
49
|
+
* 1. `FLAIR_MIGRATION_DATA_DIR` — explicit operator override. The escape
|
|
50
|
+
* hatch for any deployment shape whose data dir this module cannot
|
|
51
|
+
* infer; naming it in the failure message below is what makes an
|
|
52
|
+
* unresolvable instance actionable rather than merely reported.
|
|
53
|
+
* 2. `HDB_ROOT` — retained purely for compatibility with anything that
|
|
54
|
+
* may have started setting it because flair once read it. Never set by
|
|
55
|
+
* Harper or by flair.
|
|
56
|
+
* 3. `homedir()/.flair/data` — the historical default, and `flair init`'s
|
|
57
|
+
* own data dir. DELIBERATELY still ahead of `ROOTPATH`: on every
|
|
58
|
+
* currently-working install this is where the state file already
|
|
59
|
+
* lives, and reordering would silently relocate it (costing a
|
|
60
|
+
* re-detect pass and orphaning the existing audit record) on instances
|
|
61
|
+
* that have no problem at all. This fix is about instances where the
|
|
62
|
+
* cycle cannot run, not about relocating ones where it can.
|
|
63
|
+
* 4. `ROOTPATH` — Harper's real root path. The rescue candidate: it is
|
|
64
|
+
* writable by definition on a running instance (Harper is writing its
|
|
65
|
+
* own databases there), so a shape whose `homedir()` is unusable still
|
|
66
|
+
* gets a stable, per-instance location instead of nothing.
|
|
67
|
+
* 5. The Harper root INFERRED from this module's own location, but only
|
|
68
|
+
* when the running layout is unambiguously a deployed component (see
|
|
69
|
+
* `deployedComponentRootPath`). `ROOTPATH` is an env var, and a
|
|
70
|
+
* deployment that configures `rootPath` in Harper's config file
|
|
71
|
+
* instead of the environment leaves candidate 4 empty — this covers
|
|
72
|
+
* that case without guessing.
|
|
73
|
+
*
|
|
74
|
+
* "Usable" is probed by DOING THE REAL OPERATION the runner would do —
|
|
75
|
+
* create `<dir>/.migrations` at 0700 and check it is writable — not by a
|
|
76
|
+
* proxy check that could disagree with it. The probe is idempotent and, on
|
|
77
|
+
* a healthy instance, is satisfied by the first candidate without touching
|
|
78
|
+
* the others.
|
|
79
|
+
*
|
|
80
|
+
* If NO candidate is usable, `resolveWritableMigrationDataDir` returns
|
|
81
|
+
* `dataDir: null` WITH the per-candidate reasons, and the boot path turns
|
|
82
|
+
* that into a loud console error plus a `failed` progress entry per
|
|
83
|
+
* registered migration — visible in `/HealthDetail`, `flair doctor` and
|
|
84
|
+
* `flair quality`'s `instance.migrationsClean`. An instance that cannot run
|
|
85
|
+
* migrations now says so; that silence was the actual defect.
|
|
86
|
+
*/
|
|
87
|
+
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
|
|
88
|
+
import { homedir } from "node:os";
|
|
89
|
+
import { basename, dirname, join } from "node:path";
|
|
90
|
+
import { fileURLToPath } from "node:url";
|
|
91
|
+
/** Explicit operator override for the migration data dir (see module doc). */
|
|
92
|
+
export const MIGRATION_DATA_DIR_ENV = "FLAIR_MIGRATION_DATA_DIR";
|
|
93
|
+
/** The subdirectory the migration subsystem owns inside whichever dataDir wins. */
|
|
94
|
+
export const MIGRATIONS_SUBDIR = ".migrations";
|
|
95
|
+
/**
|
|
96
|
+
* Harper's root path, inferred from where THIS module is running, but ONLY
|
|
97
|
+
* when the layout is unambiguously a deployed component:
|
|
98
|
+
*
|
|
99
|
+
* <rootPath>/components/<name>/dist/resources/migrations/data-dir.js
|
|
100
|
+
*
|
|
101
|
+
* i.e. the directory two levels above `dist/` must itself be named
|
|
102
|
+
* `components` — Harper's `componentsRoot` convention, and the layout
|
|
103
|
+
* `deploy_component` produces. That check is what keeps this from firing on
|
|
104
|
+
* a source checkout or an npm install, where the same arithmetic would
|
|
105
|
+
* point at an arbitrary parent directory. Returns null whenever the layout
|
|
106
|
+
* doesn't match, so a non-match contributes no candidate rather than a
|
|
107
|
+
* guess.
|
|
108
|
+
*
|
|
109
|
+
* `moduleUrl` is a parameter purely so a unit test can exercise both the
|
|
110
|
+
* matching and non-matching layouts without relocating the built file.
|
|
111
|
+
*/
|
|
112
|
+
export function deployedComponentRootPath(moduleUrl = import.meta.url) {
|
|
113
|
+
try {
|
|
114
|
+
const here = dirname(fileURLToPath(moduleUrl)); // <component>/dist/resources/migrations
|
|
115
|
+
const componentDir = dirname(dirname(dirname(here))); // <component>
|
|
116
|
+
const componentsRoot = dirname(componentDir); // .../components
|
|
117
|
+
if (basename(componentsRoot) !== "components")
|
|
118
|
+
return null;
|
|
119
|
+
return dirname(componentsRoot);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Ordered candidates for the migration data dir. See the module doc for why
|
|
127
|
+
* this order is what it is — in particular, why `homedir()/.flair/data`
|
|
128
|
+
* stays ahead of `ROOTPATH`.
|
|
129
|
+
*/
|
|
130
|
+
export function migrationDataDirCandidates(env = process.env, moduleUrl = import.meta.url) {
|
|
131
|
+
const out = [];
|
|
132
|
+
const add = (p) => {
|
|
133
|
+
const trimmed = p?.trim();
|
|
134
|
+
if (trimmed && !out.includes(trimmed))
|
|
135
|
+
out.push(trimmed);
|
|
136
|
+
};
|
|
137
|
+
add(env[MIGRATION_DATA_DIR_ENV]);
|
|
138
|
+
add(env.HDB_ROOT);
|
|
139
|
+
add(join(homedir(), ".flair", "data"));
|
|
140
|
+
add(env.ROOTPATH);
|
|
141
|
+
add(deployedComponentRootPath(moduleUrl));
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Probes a candidate by performing the exact operation the runner's lock
|
|
146
|
+
* acquisition performs (`mkdir -p <dir>/.migrations` at 0700), then
|
|
147
|
+
* confirming the result is writable. Idempotent: on an already-working
|
|
148
|
+
* instance this is a no-op stat/mkdir against a directory that already
|
|
149
|
+
* exists.
|
|
150
|
+
*/
|
|
151
|
+
export function probeMigrationDataDir(dir) {
|
|
152
|
+
const owned = join(dir, MIGRATIONS_SUBDIR);
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(owned, { recursive: true, mode: 0o700 });
|
|
155
|
+
accessSync(owned, constants.W_OK | constants.X_OK);
|
|
156
|
+
return { dir, ok: true };
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
return { dir, ok: false, reason: err?.message ?? String(err) };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolves a migration data dir that is proven writable, or reports why not.
|
|
164
|
+
* Used by the boot path (`resources/migration-boot.ts`), which must not
|
|
165
|
+
* commit to a directory the runner will then fail to lock.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveWritableMigrationDataDir(env = process.env) {
|
|
168
|
+
const tried = [];
|
|
169
|
+
for (const candidate of migrationDataDirCandidates(env)) {
|
|
170
|
+
const probe = probeMigrationDataDir(candidate);
|
|
171
|
+
tried.push(probe);
|
|
172
|
+
if (probe.ok)
|
|
173
|
+
return { dataDir: candidate, tried };
|
|
174
|
+
}
|
|
175
|
+
return { dataDir: null, tried };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Operator-facing explanation of a total resolution failure — carries the
|
|
179
|
+
* actor (each path tried), the state (why each was rejected) and the remedy
|
|
180
|
+
* (the override env var), so the message is actionable at 3am rather than
|
|
181
|
+
* merely accurate.
|
|
182
|
+
*/
|
|
183
|
+
export function describeUnresolvableDataDir(tried) {
|
|
184
|
+
const detail = tried.length
|
|
185
|
+
? tried.map((t) => `${t.dir} (${t.reason ?? "unusable"})`).join("; ")
|
|
186
|
+
: "no candidate paths at all";
|
|
187
|
+
return (`no writable migration data directory — tried ${detail}. ` +
|
|
188
|
+
`Migrations cannot run on this instance until one exists: point ${MIGRATION_DATA_DIR_ENV} ` +
|
|
189
|
+
`at a writable directory (or make one of the paths above writable by the account this ` +
|
|
190
|
+
`instance runs as) and restart.`);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* READ-ONLY counterpart for `/HealthDetail`, which must never create
|
|
194
|
+
* directories as a side effect of a GET. Prefers whichever candidate
|
|
195
|
+
* already carries a `.migrations` directory (i.e. the one a boot actually
|
|
196
|
+
* chose), falling back to the first candidate so the field is never empty.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveMigrationDataDirForRead(env = process.env) {
|
|
199
|
+
const candidates = migrationDataDirCandidates(env);
|
|
200
|
+
for (const candidate of candidates) {
|
|
201
|
+
if (existsSync(join(candidate, MIGRATIONS_SUBDIR)))
|
|
202
|
+
return candidate;
|
|
203
|
+
}
|
|
204
|
+
return candidates[0] ?? join(homedir(), ".flair", "data");
|
|
205
|
+
}
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
* embeddings engine to settle before running migrations at all.
|
|
63
63
|
*
|
|
64
64
|
* flair#807 — completion-gate false-negative on 0.10-era / cross-version
|
|
65
|
-
* stores, root-caused against the installed
|
|
66
|
-
* source (node_modules
|
|
65
|
+
* stores, root-caused against the installed `harper@5.1.22`
|
|
66
|
+
* source (node_modules/harper/resources/search.ts): for an
|
|
67
67
|
* INDEXED attribute (`embeddingModel` IS `@indexed` — schemas/memory.graphql),
|
|
68
68
|
* a non-negated `not_equal`/`ne` leaf condition takes `searchByIndex`'s
|
|
69
69
|
* `index && !skipIndex` branch, which reconstructs a SYNTHETIC partial record
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
* resources/migrations/graph-heal.ts). Before enabling any FUTURE bulk
|
|
126
126
|
* re-embed, ship a structural reindex trigger with it.
|
|
127
127
|
*/
|
|
128
|
-
import { databases } from "
|
|
128
|
+
import { databases } from "harper";
|
|
129
129
|
import { getModelId } from "../embeddings-provider.js";
|
|
130
130
|
function defaultMemoryTable() {
|
|
131
131
|
return databases.flair.Memory;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* every pre-this-change install) against the schema and, because
|
|
10
10
|
* `canonicalizeIndexOptions` does NOT inject defaults (`{type:"HNSW"}` and
|
|
11
11
|
* `{type:"HNSW",M:16}` are DIFFERENT canonical keys — verified against the
|
|
12
|
-
* installed
|
|
12
|
+
* installed harper source, resources/databases.js:
|
|
13
13
|
* canonicalizeIndexOptions + the `indexOptionsChanged` reset of
|
|
14
14
|
* `lastIndexedKey` to undefined), clears the graph store and rebuilds it
|
|
15
15
|
* CLEANLY from the already-correct stored vectors. That rebuild heals the
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
* correctness — it re-PUTs the same vector through the incremental path and
|
|
58
58
|
* does not rebuild the graph.
|
|
59
59
|
*/
|
|
60
|
-
import { databases } from "
|
|
60
|
+
import { databases } from "harper";
|
|
61
61
|
import { existsSync, readFileSync } from "node:fs";
|
|
62
62
|
import { dirname, join } from "node:path";
|
|
63
63
|
import { fileURLToPath } from "node:url";
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* resources/MemoryReindex.ts's `_reindex` path already rely on for
|
|
18
18
|
* server-internal writes with no HTTP caller behind them.
|
|
19
19
|
*/
|
|
20
|
-
import { databases } from "
|
|
20
|
+
import { databases } from "harper";
|
|
21
21
|
function defaultOrgEventTable() {
|
|
22
22
|
return databases.flair.OrgEvent;
|
|
23
23
|
}
|
|
@@ -25,6 +25,39 @@ export function seedIdleProgress(ids) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Marks every migration that never got off the starting line as `failed`
|
|
30
|
+
* with `reason` — and ONLY those (flair#812).
|
|
31
|
+
*
|
|
32
|
+
* The boot path calls this when a cycle couldn't run, so that a condition
|
|
33
|
+
* which is really instance-wide (no writable data dir, tables never ready,
|
|
34
|
+
* the lock itself unobtainable) shows up per-migration, which is the shape
|
|
35
|
+
* `flair doctor` and `flair quality`'s `instance.migrationsClean` actually
|
|
36
|
+
* read.
|
|
37
|
+
*
|
|
38
|
+
* Restricting it to `idle` entries is load-bearing, not tidiness: a cycle
|
|
39
|
+
* can fail AFTER some migrations already reached a terminal state — the
|
|
40
|
+
* pre-hash path, for instance, halts each candidate with its own precise
|
|
41
|
+
* reason and THEN reports cycle failure. Overwriting those would replace a
|
|
42
|
+
* specific `halted` (which the runner retries on the next boot, per its
|
|
43
|
+
* documented contract) with a blanket `failed`, and would flip migrations
|
|
44
|
+
* that genuinely completed this cycle to failed. Only an entry still `idle`
|
|
45
|
+
* is one nothing has said anything about yet.
|
|
46
|
+
*
|
|
47
|
+
* Returns the ids actually marked, so a caller can tell "nothing had
|
|
48
|
+
* started" from "the cycle died partway".
|
|
49
|
+
*/
|
|
50
|
+
export function markIdleMigrationsFailed(ids, reason) {
|
|
51
|
+
const marked = [];
|
|
52
|
+
for (const id of ids) {
|
|
53
|
+
const current = progressById.get(id);
|
|
54
|
+
if (current && current.state !== "idle")
|
|
55
|
+
continue;
|
|
56
|
+
progressById.set(id, { id, rowsDone: 0, rowsRemaining: 0, state: "failed", reason });
|
|
57
|
+
marked.push(id);
|
|
58
|
+
}
|
|
59
|
+
return marked;
|
|
60
|
+
}
|
|
28
61
|
export function _resetProgressForTests() {
|
|
29
62
|
progressById.clear();
|
|
30
63
|
cycleStatus = { phase: "idle" };
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* registry.ts — the MigrationRegistry: an ordered list of registered
|
|
3
|
-
* migrations. embedding-stamp
|
|
4
|
-
* CI-only variant registers ONLY when
|
|
5
|
-
* true (see synthetic-test-
|
|
3
|
+
* migrations. embedding-stamp, graph-heal, and visibility-backfill always
|
|
4
|
+
* register; the synthetic CI-only variant registers ONLY when
|
|
5
|
+
* shouldRegisterSyntheticMigration() is true (see synthetic-test-
|
|
6
|
+
* migration.ts's doc for the exact gating rule).
|
|
6
7
|
*/
|
|
7
8
|
import { createEmbeddingStampMigration } from "./embedding-stamp.js";
|
|
8
9
|
import { createGraphHealMigration } from "./graph-heal.js";
|
|
10
|
+
import { createVisibilityBackfillMigration } from "./visibility-backfill.js";
|
|
9
11
|
import { createSyntheticTestMigration, shouldRegisterSyntheticMigration } from "./synthetic-test-migration.js";
|
|
10
12
|
export class MigrationRegistry {
|
|
11
13
|
migrations = [];
|
|
@@ -24,17 +26,24 @@ export class MigrationRegistry {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
/**
|
|
27
|
-
* Builds the production registry. Always includes embedding-stamp
|
|
28
|
-
* graph-heal; conditionally includes the CI-only
|
|
29
|
+
* Builds the production registry. Always includes embedding-stamp,
|
|
30
|
+
* graph-heal, and visibility-backfill; conditionally includes the CI-only
|
|
31
|
+
* synthetic migration.
|
|
29
32
|
*
|
|
30
33
|
* Order note: graph-heal registers AFTER embedding-stamp so that if a boot
|
|
31
34
|
* ever runs both, the (verify-only) recall check ledgers the state AFTER any
|
|
32
35
|
* re-embed work that cycle already applied — never a stale pre-embed reading.
|
|
36
|
+
* visibility-backfill registers last — it touches an unrelated field
|
|
37
|
+
* (`visibility`, never `embedding`/`embeddingModel`) and has no ordering
|
|
38
|
+
* dependency on either of the other two; appended after them simply as
|
|
39
|
+
* newest-added-last, matching how graph-heal was appended after
|
|
40
|
+
* embedding-stamp.
|
|
33
41
|
*/
|
|
34
42
|
export function buildRegistry(env = process.env) {
|
|
35
43
|
const registry = new MigrationRegistry();
|
|
36
44
|
registry.register(createEmbeddingStampMigration());
|
|
37
45
|
registry.register(createGraphHealMigration());
|
|
46
|
+
registry.register(createVisibilityBackfillMigration());
|
|
38
47
|
if (shouldRegisterSyntheticMigration(env)) {
|
|
39
48
|
registry.register(createSyntheticTestMigration());
|
|
40
49
|
}
|