@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.
Files changed (117) hide show
  1. package/README.md +194 -377
  2. package/dist/cli-shim.cjs +1 -1
  3. package/dist/cli.js +1567 -366
  4. package/dist/deploy.js +241 -43
  5. package/dist/fabric-upgrade.js +77 -24
  6. package/dist/federation/scheduler.js +500 -0
  7. package/dist/fleet-verify.js +3 -3
  8. package/dist/install/clients.js +111 -53
  9. package/dist/lib/mcp-enable.js +2 -2
  10. package/dist/lib/mcp-spec.js +128 -0
  11. package/dist/lib/safe-snapshot-extract.js +231 -0
  12. package/dist/lib/scheduler-platform.js +128 -0
  13. package/dist/lib/xml-escape.js +54 -0
  14. package/dist/rem/scheduler.js +212 -55
  15. package/dist/rem/snapshot.js +13 -0
  16. package/dist/replication-convergence.js +505 -0
  17. package/dist/resources/A2AAdapter.js +1 -1
  18. package/dist/resources/Admin.js +1 -1
  19. package/dist/resources/AdminConnectors.js +1 -1
  20. package/dist/resources/AdminDashboard.js +1 -1
  21. package/dist/resources/AdminIdp.js +1 -1
  22. package/dist/resources/AdminInstance.js +1 -1
  23. package/dist/resources/AdminMemory.js +1 -1
  24. package/dist/resources/AdminPrincipals.js +1 -1
  25. package/dist/resources/Agent.js +1 -1
  26. package/dist/resources/AgentCard.js +1 -1
  27. package/dist/resources/AgentSeed.js +1 -1
  28. package/dist/resources/AttentionQuery.js +1 -1
  29. package/dist/resources/Credential.js +1 -1
  30. package/dist/resources/Federation.js +1 -1
  31. package/dist/resources/Instance.js +1 -1
  32. package/dist/resources/Integration.js +1 -1
  33. package/dist/resources/MCPClientMetadata.js +1 -1
  34. package/dist/resources/Memory.js +1 -1
  35. package/dist/resources/MemoryBootstrap.js +8 -9
  36. package/dist/resources/MemoryCandidate.js +168 -0
  37. package/dist/resources/MemoryConsolidate.js +1 -1
  38. package/dist/resources/MemoryDedupStats.js +1 -1
  39. package/dist/resources/MemoryFeed.js +1 -1
  40. package/dist/resources/MemoryGrant.js +1 -1
  41. package/dist/resources/MemoryMaintenance.js +1 -1
  42. package/dist/resources/MemoryReflect.js +2 -2
  43. package/dist/resources/MemoryReindex.js +1 -1
  44. package/dist/resources/MemoryUsage.js +1 -1
  45. package/dist/resources/OAuth.js +1 -1
  46. package/dist/resources/OAuthClient.js +1 -1
  47. package/dist/resources/OrgEvent.js +1 -1
  48. package/dist/resources/OrgEventCatchup.js +1 -1
  49. package/dist/resources/OrgEventMaintenance.js +1 -1
  50. package/dist/resources/PairingToken.js +1 -1
  51. package/dist/resources/Peer.js +1 -1
  52. package/dist/resources/Presence.js +3 -3
  53. package/dist/resources/RecordUsage.js +1 -1
  54. package/dist/resources/Relationship.js +1 -1
  55. package/dist/resources/SemanticSearch.js +18 -46
  56. package/dist/resources/SkillScan.js +1 -1
  57. package/dist/resources/Soul.js +1 -1
  58. package/dist/resources/SoulFeed.js +1 -1
  59. package/dist/resources/WorkspaceLatest.js +1 -1
  60. package/dist/resources/WorkspaceState.js +1 -1
  61. package/dist/resources/XAA.js +1 -1
  62. package/dist/resources/a2a-url.js +1 -1
  63. package/dist/resources/abstention.js +1 -1
  64. package/dist/resources/agent-auth.js +1 -1
  65. package/dist/resources/agentcard-fields.js +1 -1
  66. package/dist/resources/auth-middleware.js +1 -1
  67. package/dist/resources/embeddings-boot.js +14 -16
  68. package/dist/resources/embeddings-provider.js +15 -12
  69. package/dist/resources/federation-cleanup.js +5 -5
  70. package/dist/resources/federation-nonce-store.js +5 -5
  71. package/dist/resources/health.js +53 -25
  72. package/dist/resources/in-process.js +225 -0
  73. package/dist/resources/instance-identity.js +1 -1
  74. package/dist/resources/mcp-client-metadata-fields.js +1 -1
  75. package/dist/resources/mcp-handler.js +1 -1
  76. package/dist/resources/mcp-oauth.js +2 -2
  77. package/dist/resources/mcp-tools.js +23 -17
  78. package/dist/resources/memory-read-scope.js +1 -1
  79. package/dist/resources/memory-reflect-lib.js +3 -3
  80. package/dist/resources/memory-visibility.js +3 -3
  81. package/dist/resources/migration-boot.js +81 -11
  82. package/dist/resources/migrations/data-dir.js +205 -0
  83. package/dist/resources/migrations/embedding-stamp.js +3 -3
  84. package/dist/resources/migrations/graph-heal.js +2 -2
  85. package/dist/resources/migrations/ledger.js +1 -1
  86. package/dist/resources/migrations/progress.js +33 -0
  87. package/dist/resources/migrations/registry.js +14 -5
  88. package/dist/resources/migrations/runner.js +93 -13
  89. package/dist/resources/migrations/state.js +13 -2
  90. package/dist/resources/migrations/synthetic-test-migration.js +1 -1
  91. package/dist/resources/migrations/visibility-backfill.js +232 -0
  92. package/dist/resources/models-dir.js +18 -9
  93. package/dist/resources/record-types.js +28 -0
  94. package/dist/resources/semantic-retrieval-core.js +6 -5
  95. package/dist/resources/usage-recording.js +1 -1
  96. package/dist/src/lib/scheduler-platform.js +128 -0
  97. package/dist/src/lib/xml-escape.js +54 -0
  98. package/dist/src/rem/scheduler.js +397 -0
  99. package/docs/deploying-on-fabric.md +267 -0
  100. package/docs/deployment.md +6 -0
  101. package/docs/embedding-in-a-harper-app.md +299 -0
  102. package/docs/federation.md +61 -4
  103. package/docs/integrations.md +3 -0
  104. package/docs/mcp-clients.md +16 -7
  105. package/docs/quickstart.md +80 -54
  106. package/docs/releasing.md +72 -38
  107. package/docs/supply-chain-policy.md +40 -4
  108. package/docs/troubleshooting.md +24 -0
  109. package/docs/upgrade.md +100 -5
  110. package/package.json +4 -14
  111. package/schemas/memory.graphql +9 -1
  112. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  113. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  114. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  115. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  116. package/dist/resources/rerank-provider.js +0 -569
  117. 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
- return {
82
- request: {
83
- tpsAgent: agent.agentId,
84
- tpsAgentIsAdmin: agent.isAdmin,
85
- headers: {
86
- get: (k) => (k.toLowerCase() === "x-tps-agent" ? agent.agentId : undefined),
87
- },
88
- },
89
- user: undefined,
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 = new Cls(undefined, delegationContext(agent));
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
- h.isCollection = true;
208
- return unwrap(await h.post(record));
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 = new Cls(undefined, delegationContext(agent));
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 = new Cls(undefined, delegationContext(agent));
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 "@harperfast/harper";
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 @harperfast/harper 5.1.17's bundled backends) return conformant
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 @harperfast/harper 5.1.17). That
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 @harperfast/harper
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 "@harperfast/harper". That is
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 "@harperfast/harper", and that package's
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
- * "@harperfast/harper".
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 "@harperfast/harper";
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
- console.error("[flair-migrations] Memory/Relationship tables never became ready — skipping this boot's migration cycle (will retry next boot)");
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: resolveMigrationDataDir(),
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
- console.error(`[flair-migrations] unexpected error from runMigrationCycle: ${err?.message ?? String(err)}`);
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 `@harperfast/harper@5.1.22`
66
- * source (node_modules/@harperfast/harper/resources/search.ts): for an
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 "@harperfast/harper";
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 @harperfast/harper source, resources/databases.js:
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 "@harperfast/harper";
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 "@harperfast/harper";
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 and graph-heal always register; the synthetic
4
- * CI-only variant registers ONLY when shouldRegisterSyntheticMigration() is
5
- * true (see synthetic-test-migration.ts's doc for the exact gating rule).
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 and
28
- * graph-heal; conditionally includes the CI-only synthetic migration.
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
  }