@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
package/dist/deploy.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createRequire } from "node:module";
6
+ import { awaitOriginQuiescent, awaitReplicationConvergence, defaultConvergenceDeps, parseReplicationFailure, } from "./replication-convergence.js";
6
7
  // Files that must be present in a Flair package for deployment.
7
8
  // Mirrors the `files` array in package.json — keep in sync.
8
9
  export const REQUIRED_PACKAGE_FILES = [
@@ -156,28 +157,38 @@ export function deriveVerifyResources(packageRoot) {
156
157
  names.sort();
157
158
  return names.length ? names : [FALLBACK_VERIFY_RESOURCE];
158
159
  }
160
+ // flair#870: flair declares the BARE `harper` package name, so that is probed
161
+ // first. `@harperfast/harper` is a permanent lockstep publish of the same
162
+ // source under the legacy scoped name; it stays in the probe list because
163
+ // `packageRoot` can be a staged install of an OLDER published flair (see
164
+ // src/fabric-upgrade.ts) that declared the scoped name.
165
+ const HARPER_PKG_NAMES = ["harper", "@harperfast/harper"];
159
166
  function resolveHarperBin(packageRoot) {
160
- const local = join(packageRoot, "node_modules/@harperfast/harper/dist/bin/harper.js");
161
- if (existsSync(local))
162
- return local;
163
- try {
164
- const req = createRequire(join(packageRoot, "package.json"));
165
- const mainPath = req.resolve("@harperfast/harper");
166
- let dir = dirname(mainPath);
167
- for (let i = 0; i < 6; i++) {
168
- const candidate = join(dir, "dist/bin/harper.js");
169
- if (existsSync(candidate))
170
- return candidate;
171
- const parent = dirname(dir);
172
- if (parent === dir)
173
- break;
174
- dir = parent;
175
- }
167
+ for (const pkg of HARPER_PKG_NAMES) {
168
+ const local = join(packageRoot, "node_modules", pkg, "dist/bin/harper.js");
169
+ if (existsSync(local))
170
+ return local;
176
171
  }
177
- catch {
178
- /* fall through */
172
+ for (const pkg of HARPER_PKG_NAMES) {
173
+ try {
174
+ const req = createRequire(join(packageRoot, "package.json"));
175
+ const mainPath = req.resolve(pkg);
176
+ let dir = dirname(mainPath);
177
+ for (let i = 0; i < 6; i++) {
178
+ const candidate = join(dir, "dist/bin/harper.js");
179
+ if (existsSync(candidate))
180
+ return candidate;
181
+ const parent = dirname(dir);
182
+ if (parent === dir)
183
+ break;
184
+ dir = parent;
185
+ }
186
+ }
187
+ catch {
188
+ /* try the next package name */
189
+ }
179
190
  }
180
- throw new Error("Could not locate Harper CLI binary (@harperfast/harper). " +
191
+ throw new Error(`Could not locate Harper CLI binary (tried ${HARPER_PKG_NAMES.join(", ")}). ` +
181
192
  "Flair deploy requires Harper to be installed alongside Flair.");
182
193
  }
183
194
  // Pure arg-array builder — separated from spawnHarper so the timeout
@@ -205,7 +216,34 @@ export function buildHarperDeployArgs(opts, url, project) {
205
216
  }
206
217
  // Flaky-peer-replication resilience defaults. See DeployOptions
207
218
  // for the full incident writeup this closes.
208
- export const DEFAULT_DEPLOY_RETRIES = 2;
219
+ //
220
+ // ── Why this default is 0 (flair#878) ───────────────────────────────────────
221
+ // It was 2 (three attempts). The reasoning behind that — "a bare manual re-run
222
+ // cleared it, so let the tool self-heal" — was right about the symptom and
223
+ // wrong about the mechanism. What actually clears a peer-replication error is
224
+ // Harper finishing its own asynchronous replication; the re-run merely happened
225
+ // to take long enough for that to complete. Retrying is not what fixed it, and
226
+ // the retry is not free:
227
+ //
228
+ // 1. It races work that was going to succeed anyway. Harper keeps replicating
229
+ // after the deploy call returns, so the retry re-issues a full deploy into
230
+ // a cluster mid-heal.
231
+ // 2. It is not idempotent. A retry re-runs the whole deploy including
232
+ // Harper's own `npm install` into the component directory on every node.
233
+ // Overlapping that with the previous attempt's still-running install is
234
+ // how the reported upgrade died with
235
+ // `ENOTEMPTY: directory not empty, rmdir '.../node_modules/<pkg>/dist'`
236
+ // on a native module — a hard failure the original error never was.
237
+ // 3. It is now redundant. The convergence poll added in flair#878 covers
238
+ // exactly the window a retry was buying, WITHOUT touching the cluster:
239
+ // it waits and looks instead of waiting and re-deploying.
240
+ //
241
+ // So the remedy was strictly worse than the problem it retried, and the thing
242
+ // it was compensating for is now observed directly. Retrying is kept as an
243
+ // explicit opt-in (`--deploy-retries <n>`) for the genuine case — replication
244
+ // observed NOT to converge — where it is additionally gated on the origin
245
+ // having gone quiescent first (see awaitOriginQuiescent).
246
+ export const DEFAULT_DEPLOY_RETRIES = 0;
209
247
  export const DEPLOY_RETRY_BACKOFF_MS = [5_000, 10_000];
210
248
  // The signature of a Fabric PEER-REPLICATION failure specifically — harper
211
249
  // deploys fine to the origin node, but pushing the component to a peer
@@ -218,6 +256,78 @@ export const DEPLOY_RETRY_BACKOFF_MS = [5_000, 10_000];
218
256
  // none of which mention peer replication and must never be retried).
219
257
  // Literal regex, no interpolation.
220
258
  export const REPLICATION_FAILURE_RE = /failed to replicate to \d+ (of \d+ )?peer|connection closed\s+1006|ignore_replication_errors/i;
259
+ const HARPER_OUTPUT_SUMMARY_MAX = 300;
260
+ /**
261
+ * Pull the most useful single line out of harper's combined output. Prefers the
262
+ * last line that looks like an error, falling back to the last non-empty line —
263
+ * harper prints its fatal reason last on both paths.
264
+ */
265
+ export function summarizeHarperOutput(output) {
266
+ const lines = (output ?? "")
267
+ .split("\n")
268
+ .map((l) => l.replace(/\s+$/, "").replace(/^\s*[!>]\s?/, "").trim())
269
+ .filter((l) => l.length > 0);
270
+ if (lines.length === 0)
271
+ return "(no output)";
272
+ const errorish = [...lines].reverse().find((l) => /error|failed|cannot|denied|exit code/i.test(l));
273
+ const chosen = errorish ?? lines[lines.length - 1];
274
+ return chosen.length > HARPER_OUTPUT_SUMMARY_MAX
275
+ ? chosen.slice(0, HARPER_OUTPUT_SUMMARY_MAX - 1) + "…"
276
+ : chosen;
277
+ }
278
+ /**
279
+ * Build the error message for a failed deploy from the FULL attempt history.
280
+ *
281
+ * ── The rule this encodes (flair#878, the core defect) ──────────────────────
282
+ * The reported failure is always the FIRST attempt's failure. A later attempt
283
+ * can only exist because this CLI chose to retry, so a later, different failure
284
+ * describes the state the CLI's own remedy created — not the state the operator
285
+ * needs to act on. In the reported incident attempt 1 was a transient
286
+ * peer-replication warning and attempt 2 died with npm `ENOTEMPTY` on a native
287
+ * module; surfacing the ENOTEMPTY sent the diagnosis into the component's
288
+ * node_modules when the actual event was "a peer link blipped and then healed".
289
+ *
290
+ * The later failure is still REPORTED — suppressing it would hide that the
291
+ * cluster may have been left in a worse state — but it is explicitly labelled
292
+ * as a consequence of retrying, with the remedy (stop retrying) named inline.
293
+ * Errors have to enable a response, and "ENOTEMPTY on node-llama-cpp/dist"
294
+ * enables the wrong one.
295
+ */
296
+ export function describeDeployFailure(failures, convergence) {
297
+ if (failures.length === 0)
298
+ return "harper deploy failed with no recorded attempt";
299
+ const primary = failures[0];
300
+ const escalations = failures
301
+ .slice(1)
302
+ .filter((f) => f.kind !== primary.kind || f.summary !== primary.summary);
303
+ const lines = [];
304
+ if (primary.kind === "replication") {
305
+ lines.push(`harper deploy exited with code ${primary.code}: the component deployed to the origin node but ` +
306
+ `peer replication was reported failed, and this CLI could not confirm the peers converged.`);
307
+ }
308
+ else {
309
+ lines.push(`harper deploy exited with code ${primary.code}: ${primary.summary}`);
310
+ }
311
+ if (convergence) {
312
+ lines.push(` convergence check: ${convergence.detail}`);
313
+ }
314
+ if (failures.length > 1) {
315
+ lines.push(` attempt 1 of ${primary.totalAttempts} is the failure reported above (${primary.summary}).`);
316
+ }
317
+ for (const f of escalations) {
318
+ const consequence = primary.kind === "replication" && f.kind === "other"
319
+ ? ` This is a CONSEQUENCE of retrying, not the original problem: re-running a deploy over a component ` +
320
+ `directory the previous attempt is still installing into is not idempotent (npm ENOTEMPTY on a native ` +
321
+ `module is the known shape). Diagnose the replication failure above, not this. Run with ` +
322
+ `--deploy-retries 0 to remove this class of failure entirely.`
323
+ : "";
324
+ lines.push(` attempt ${f.attempt} of ${f.totalAttempts} then failed with: ${f.summary}.${consequence}`);
325
+ }
326
+ if (primary.kind === "replication") {
327
+ lines.push(` Pass --ignore-replication-errors to accept an origin-only deploy, or re-run once the peer link recovers.`);
328
+ }
329
+ return lines.join("\n");
330
+ }
221
331
  // Tee-style capture: streams harper's stdout/stderr to the user in real time
222
332
  // (unchanged UX — a multi-minute deploy needs live progress, not a black
223
333
  // box) while ALSO buffering the combined text so the caller can
@@ -272,42 +382,122 @@ function sleep(ms) {
272
382
  // what worked by hand, and harper's CLI has no "retry replication only"
273
383
  // entry point. Any other failure (bad package, auth, missing files, ...)
274
384
  // is never retried — it fails fast, same as before this change.
275
- async function runHarperDeploy(bin, args, cwd, env, opts) {
385
+ async function runHarperDeploy(bin, args, cwd, env, opts, targetUrl, project) {
276
386
  const maxRetries = opts.deployRetries ?? DEFAULT_DEPLOY_RETRIES;
277
387
  const backoff = opts.deployRetryBackoffMs ?? DEPLOY_RETRY_BACKOFF_MS;
278
388
  const totalAttempts = Math.max(1, maxRetries + 1);
389
+ const convergenceDeps = opts.convergenceDeps ??
390
+ defaultConvergenceDeps(opts.fabricUser, opts.fabricPassword, opts.onProgress);
391
+ // The attempt history is what makes the anti-escalation rule possible —
392
+ // describeDeployFailure() always reports failures[0], never the newest.
393
+ const failures = [];
394
+ let lastConvergence = null;
279
395
  for (let attempt = 1; attempt <= totalAttempts; attempt++) {
280
396
  const { code, output } = await spawnHarperCaptured(bin, args, cwd, env);
281
397
  if (code === 0)
282
- return { replicationWarning: false };
398
+ return { replicationWarning: false, convergedAfterReplicationError: false };
283
399
  const isReplicationFailure = REPLICATION_FAILURE_RE.test(output);
284
400
  const isLastAttempt = attempt === totalAttempts;
285
- if (isReplicationFailure && !isLastAttempt) {
286
- const waitMs = backoff[Math.min(attempt - 1, backoff.length - 1)];
287
- // Self-healing must be visible, never silent — console.warn directly
288
- // (not gated behind onProgress, which some callers like
289
- // fabric-upgrade.ts don't wire up) so this is loud regardless of caller.
290
- console.warn(`⚠ flair deploy: replication flake on attempt ${attempt}/${totalAttempts} ` +
291
- `(harper deploy exited ${code}, peer-replication signature matched) — ` +
292
- `retrying in ${Math.round(waitMs / 1000)}s...`);
293
- opts.onProgress?.(`replication flake on attempt ${attempt}/${totalAttempts} retrying in ${Math.round(waitMs / 1000)}s...`);
294
- await sleep(waitMs);
295
- continue;
401
+ failures.push({
402
+ attempt,
403
+ totalAttempts,
404
+ code,
405
+ kind: isReplicationFailure ? "replication" : "other",
406
+ summary: summarizeHarperOutput(output),
407
+ });
408
+ // A non-replication failure has never been retried and still isn't. It
409
+ // fails fastbut through describeDeployFailure(), so that when it lands
410
+ // as attempt 2+ it is reported as the consequence of a retry rather than
411
+ // as the headline. That reordering IS the flair#878 fix.
412
+ if (!isReplicationFailure) {
413
+ throw new Error(describeDeployFailure(failures, lastConvergence));
414
+ }
415
+ // ── flair#878 step 1: check convergence BEFORE deciding anything ────────
416
+ // Harper is still replicating after the call returned. Look before
417
+ // declaring, and before retrying — a converged upgrade must report success.
418
+ if (opts.convergenceCheck !== false) {
419
+ const parsed = parseReplicationFailure(output);
420
+ opts.onProgress?.(parsed.peers.length
421
+ ? `peer replication reported failed for ${parsed.peers.length} node(s) — checking whether it converged anyway...`
422
+ : `peer replication reported failed — harper named no peer nodes, so convergence cannot be checked`);
423
+ lastConvergence = await awaitReplicationConvergence({
424
+ targetUrl,
425
+ project,
426
+ peers: parsed.peers,
427
+ timeoutMs: opts.convergenceTimeoutMs,
428
+ pollIntervalMs: opts.convergencePollIntervalMs,
429
+ }, convergenceDeps);
430
+ if (lastConvergence.converged) {
431
+ console.warn(`⚠ flair deploy: harper reported a peer-replication failure on attempt ${attempt}/${totalAttempts}, ` +
432
+ `but replication CONVERGED on its own — ${lastConvergence.detail}. Harper replicates components ` +
433
+ `asynchronously, so that error was a snapshot, not a verdict. Treating this deploy as SUCCESSFUL.`);
434
+ opts.onProgress?.(`peer replication converged after ${lastConvergence.elapsedMs}ms — deploy succeeded`);
435
+ return { replicationWarning: false, convergedAfterReplicationError: true };
436
+ }
296
437
  }
297
- if (isReplicationFailure && opts.ignoreReplicationErrors) {
438
+ // ── flair#878 steps 2 + 4: retry only when it is justified AND safe ─────
439
+ // Justified: we positively OBSERVED non-convergence. An unknown is not a
440
+ // licence to re-deploy — the retry is the destructive operation here.
441
+ // Safe: the origin's component tree has stopped changing, so attempt N+1
442
+ // cannot collide with attempt N's still-running server-side install (the
443
+ // ENOTEMPTY shape).
444
+ if (!isLastAttempt) {
445
+ let refusal = null;
446
+ let retryReason = "";
447
+ if (opts.convergenceCheck === false) {
448
+ // --no-convergence-check is an explicit "do what you did before
449
+ // flair#878": no operations-API polling at all, retry on the
450
+ // replication signature alone. Honour it rather than silently
451
+ // downgrading it to "never retry" — an operator who set BOTH this and
452
+ // --deploy-retries has asked for the old behaviour, hazard included.
453
+ // The anti-escalation rule still applies, so a retry that fails
454
+ // differently still cannot hijack what gets reported.
455
+ retryReason = "convergence checking is disabled (--no-convergence-check)";
456
+ }
457
+ else if (!lastConvergence || !lastConvergence.conclusive) {
458
+ refusal =
459
+ `peer replication could not be confirmed either way ` +
460
+ `(${lastConvergence?.detail ?? "no convergence result"}). Re-deploying without knowing whether the ` +
461
+ `cluster converged risks colliding with an in-flight replication or install; reporting the original ` +
462
+ `failure instead.`;
463
+ }
464
+ else {
465
+ const quiescence = await awaitOriginQuiescent({ targetUrl, project }, convergenceDeps);
466
+ if (!quiescence.quiescent) {
467
+ refusal =
468
+ `${quiescence.detail}. Re-deploying while the origin's component directory is still being written ` +
469
+ `is the overlap that turns a replication warning into a hard install failure (npm ENOTEMPTY over an ` +
470
+ `existing native-module tree).`;
471
+ }
472
+ else {
473
+ retryReason = `origin is quiescent (${quiescence.detail})`;
474
+ }
475
+ }
476
+ if (refusal) {
477
+ console.warn(`⚠ flair deploy: NOT retrying — ${refusal}`);
478
+ opts.onProgress?.(`not retrying — ${refusal}`);
479
+ }
480
+ else {
481
+ const waitMs = backoff[Math.min(attempt - 1, backoff.length - 1)];
482
+ // Self-healing must be visible, never silent — console.warn directly
483
+ // (not gated behind onProgress, which some callers like
484
+ // fabric-upgrade.ts don't wire up) so this is loud regardless of caller.
485
+ console.warn(`⚠ flair deploy: replication did not converge on attempt ${attempt}/${totalAttempts} ` +
486
+ `(harper deploy exited ${code}); ${retryReason} — retrying in ${Math.round(waitMs / 1000)}s...`);
487
+ opts.onProgress?.(`replication did not converge on attempt ${attempt}/${totalAttempts} — retrying in ${Math.round(waitMs / 1000)}s...`);
488
+ await sleep(waitMs);
489
+ continue;
490
+ }
491
+ }
492
+ if (opts.ignoreReplicationErrors) {
298
493
  console.warn(`⚠ flair deploy: peer replication still failing after ${attempt} attempt(s), ` +
299
494
  `but --ignore-replication-errors is set — treating this as a WARNED SUCCESS ` +
300
495
  `(deployed to the origin node only; the peer will need to catch up via normal ` +
301
496
  `federation sync or a later deploy).`);
302
497
  opts.onProgress?.(`WARNING: proceeding origin-only — peer replication did not complete after ${attempt} attempt(s)`);
303
- return { replicationWarning: true };
498
+ return { replicationWarning: true, convergedAfterReplicationError: false };
304
499
  }
305
- if (isReplicationFailure) {
306
- throw new Error(`harper deploy exited with code ${code}: peer replication failed after ${attempt} attempt(s) ` +
307
- `(retries exhausted). Pass --ignore-replication-errors to accept an origin-only deploy, ` +
308
- `or re-run once the peer link recovers.`);
309
- }
310
- throw new Error(`harper deploy exited with code ${code}`);
500
+ throw new Error(describeDeployFailure(failures, lastConvergence));
311
501
  }
312
502
  // Unreachable — the loop always returns or throws — but keeps TS happy.
313
503
  throw new Error("harper deploy: exhausted retry loop without resolving");
@@ -417,7 +607,7 @@ export async function deploy(opts) {
417
607
  CLI_TARGET_USERNAME: opts.fabricUser,
418
608
  CLI_TARGET_PASSWORD: opts.fabricPassword,
419
609
  };
420
- const { replicationWarning } = await runHarperDeploy(harperBin, args, packageRoot, childEnv, opts);
610
+ const { replicationWarning, convergedAfterReplicationError } = await runHarperDeploy(harperBin, args, packageRoot, childEnv, opts, url, project);
421
611
  // harper can print "Successfully deployed" for a component that isn't
422
612
  // actually serving anything (the incident this closes: an empty deploy,
423
613
  // reported success, /Memory 404ing in prod). Verify by curling the served
@@ -433,5 +623,13 @@ export async function deploy(opts) {
433
623
  onProgress: opts.onProgress,
434
624
  });
435
625
  }
436
- return { url, project, version, packageRoot, dryRun: false, replicationWarning };
626
+ return {
627
+ url,
628
+ project,
629
+ version,
630
+ packageRoot,
631
+ dryRun: false,
632
+ replicationWarning,
633
+ convergedAfterReplicationError,
634
+ };
437
635
  }
@@ -9,9 +9,9 @@
9
9
  * Upgrading a deployed Fabric Flair used to require, by hand:
10
10
  * 1. mkdir a fresh temp dir
11
11
  * 2. write a package.json that depends on @tpsdev-ai/flair@<version> AND
12
- * carries an `overrides` block pinning @harperfast/harper to a fixed
12
+ * carries an `overrides` block pinning harper to a fixed
13
13
  * version — because the PUBLISHED flair declares an OLD Harper
14
- * (@harperfast/harper@5.0.21 as of flair@0.14.0) whose component
14
+ * (harper@5.0.21 as of flair@0.14.0) whose component
15
15
  * packager (`packageComponent`) emits an EMPTY tarball when the package
16
16
  * root lives under node_modules (the real npm-install scenario).
17
17
  * See flair#513 — fixed in Harper >= 5.1.13.
@@ -31,7 +31,7 @@ import { join } from "node:path";
31
31
  import { tmpdir } from "node:os";
32
32
  import { createRequire } from "node:module";
33
33
  /**
34
- * Minimum @harperfast/harper version whose component packager works when the
34
+ * Minimum harper version whose component packager works when the
35
35
  * package root is under node_modules (flair#513 — the empty-tarball fix landed
36
36
  * in 5.1.13). Anything below this MUST be overridden before a Fabric deploy.
37
37
  */
@@ -43,7 +43,18 @@ export const MIN_HARPER_VERSION = "5.1.13";
43
43
  */
44
44
  export const DEFAULT_HARPER_PIN = "5.1.14";
45
45
  const FLAIR_PKG = "@tpsdev-ai/flair";
46
- const HARPER_PKG = "@harperfast/harper";
46
+ const HARPER_PKG = "harper";
47
+ /**
48
+ * flair#870 renamed flair's Harper dependency from the scoped
49
+ * `@harperfast/harper` to the BARE `harper` name (permanent lockstep publishes
50
+ * of the same source; bare is upstream's primary public name).
51
+ *
52
+ * This module reads and rewrites the dependency graph of a PUBLISHED flair
53
+ * version, which may predate that rename — so every lookup here has to accept
54
+ * either name. The bare name is always preferred when both are present.
55
+ */
56
+ const LEGACY_HARPER_PKG = "@harperfast/harper";
57
+ const HARPER_PKG_NAMES = [HARPER_PKG, LEGACY_HARPER_PKG];
47
58
  /**
48
59
  * Parse "5.1.14" / "5.1.14-rc.1" / "v0.14.0" into [major, minor, patch],
49
60
  * ignoring any pre-release / build suffix for comparison. Returns null when the
@@ -76,7 +87,7 @@ export function semverGte(a, b) {
76
87
  return true; // equal
77
88
  }
78
89
  /**
79
- * Decide what (if any) @harperfast/harper override to bake into the temp
90
+ * Decide what (if any) harper override to bake into the temp
80
91
  * deployable's package.json.
81
92
  *
82
93
  * - If flair already declares Harper >= MIN_HARPER_VERSION → no override
@@ -88,7 +99,7 @@ export function semverGte(a, b) {
88
99
  * passed something too old.
89
100
  *
90
101
  * Pure — no I/O. The LOAD-BEARING bit is `overridden`: when true, the temp
91
- * package.json gets `overrides: { "@harperfast/harper": pin }`.
102
+ * package.json gets `overrides: { "harper": pin }`.
92
103
  */
93
104
  export function resolveHarperPin(declared, preferredPin) {
94
105
  if (declared && semverGte(declared, MIN_HARPER_VERSION)) {
@@ -120,6 +131,13 @@ export function resolveHarperPin(declared, preferredPin) {
120
131
  * @tpsdev-ai/flair@<version> and, when the Harper pin is an override, carries
121
132
  * the `overrides` block that forces npm to install the fix Harper under the
122
133
  * flair package's node_modules.
134
+ *
135
+ * The override is written under BOTH Harper package names (flair#870). Only
136
+ * flair versions OLDER than the rename ever need an override — MIN_HARPER_VERSION
137
+ * is 5.1.13 and every post-rename flair declares ≥ 5.1.22 — and those declare
138
+ * the scoped name, so dropping the legacy key would make the override a silent
139
+ * no-op exactly when it matters. Both are plain VERSION overrides, not `npm:`
140
+ * aliases; an override key that matches nothing in the tree is inert.
123
141
  */
124
142
  export function buildDeployablePackageJson(flairVersion, pin) {
125
143
  const pkg = {
@@ -131,7 +149,7 @@ export function buildDeployablePackageJson(flairVersion, pin) {
131
149
  },
132
150
  };
133
151
  if (pin.overridden) {
134
- pkg.overrides = { [HARPER_PKG]: pin.pin };
152
+ pkg.overrides = Object.fromEntries(HARPER_PKG_NAMES.map((n) => [n, pin.pin]));
135
153
  }
136
154
  return pkg;
137
155
  }
@@ -156,7 +174,14 @@ async function defaultFetchDeclaredHarperVersion(flairVersion) {
156
174
  if (!res.ok)
157
175
  return null;
158
176
  const data = (await res.json());
159
- return data.dependencies?.[HARPER_PKG] ?? null;
177
+ // Either package name (flair#870) — pre-rename flair versions declare the
178
+ // scoped one, and those are precisely the versions that may need an override.
179
+ for (const name of HARPER_PKG_NAMES) {
180
+ const declared = data.dependencies?.[name];
181
+ if (declared)
182
+ return declared;
183
+ }
184
+ return null;
160
185
  }
161
186
  function defaultNpmInstall(dir) {
162
187
  // No package spec on argv — npm reads dependencies + overrides from the
@@ -205,13 +230,22 @@ async function defaultFetchDeployedVersion(opts) {
205
230
  return null;
206
231
  }
207
232
  }
208
- /** Resolve the @harperfast/harper bin that npm installed under the staged flair. */
233
+ /**
234
+ * Resolve the harper bin that npm installed under the staged flair.
235
+ *
236
+ * Probes both Harper package names (flair#870): the staged flair may be a
237
+ * pre-rename published version whose Harper landed under `@harperfast/harper`.
238
+ */
209
239
  export function resolveStagedHarperVersion(stagingDir) {
210
- const harperPkgJson = join(stagingDir, "node_modules", FLAIR_PKG, "node_modules", HARPER_PKG, "package.json");
211
- // npm dedupes: with an override the fixed Harper may hoist to the staging
212
- // root rather than nest under flair. Check both.
213
- const hoisted = join(stagingDir, "node_modules", HARPER_PKG, "package.json");
214
- for (const candidate of [harperPkgJson, hoisted]) {
240
+ const candidates = [];
241
+ for (const name of HARPER_PKG_NAMES) {
242
+ // Nested under the staged flair
243
+ candidates.push(join(stagingDir, "node_modules", FLAIR_PKG, "node_modules", name, "package.json"));
244
+ // …or hoisted: npm dedupes, and with an override the fixed Harper may sit
245
+ // at the staging root rather than nest under flair. Check both.
246
+ candidates.push(join(stagingDir, "node_modules", name, "package.json"));
247
+ }
248
+ for (const candidate of candidates) {
215
249
  if (existsSync(candidate)) {
216
250
  try {
217
251
  const pkg = JSON.parse(readFileSync(candidate, "utf8"));
@@ -224,16 +258,20 @@ export function resolveStagedHarperVersion(stagingDir) {
224
258
  }
225
259
  }
226
260
  // Last resort: resolve through the staged flair's module graph.
227
- try {
228
- const flairPkg = join(stagingDir, "node_modules", FLAIR_PKG, "package.json");
229
- const req = createRequire(flairPkg);
230
- const resolved = req.resolve(`${HARPER_PKG}/package.json`);
231
- const pkg = JSON.parse(readFileSync(resolved, "utf8"));
232
- return typeof pkg.version === "string" ? pkg.version : null;
233
- }
234
- catch {
235
- return null;
261
+ const flairPkg = join(stagingDir, "node_modules", FLAIR_PKG, "package.json");
262
+ for (const name of HARPER_PKG_NAMES) {
263
+ try {
264
+ const req = createRequire(flairPkg);
265
+ const resolved = req.resolve(`${name}/package.json`);
266
+ const pkg = JSON.parse(readFileSync(resolved, "utf8"));
267
+ if (typeof pkg.version === "string")
268
+ return pkg.version;
269
+ }
270
+ catch {
271
+ /* try the next package name */
272
+ }
236
273
  }
274
+ return null;
237
275
  }
238
276
  /** Locate the staged @tpsdev-ai/flair package root (the deploy() packageRoot). */
239
277
  export function resolveStagedFlairRoot(stagingDir) {
@@ -335,6 +373,14 @@ export async function fabricUpgrade(opts, injected) {
335
373
  restart: opts.restart,
336
374
  replicated: opts.replicated,
337
375
  packageRoot,
376
+ // flair#878 — see FabricUpgradeOptions: these used to stop at this
377
+ // boundary, which is why the upgrade path could not act on harper's own
378
+ // "pass ignore_replication_errors: true" advice.
379
+ ignoreReplicationErrors: opts.ignoreReplicationErrors,
380
+ deployRetries: opts.deployRetries,
381
+ convergenceCheck: opts.convergenceCheck,
382
+ convergenceTimeoutMs: opts.convergenceTimeoutMs,
383
+ onProgress: (m) => log(` ${m}`),
338
384
  });
339
385
  // Step 4: verify the deployed version (best-effort).
340
386
  let verifiedVersion = null;
@@ -358,7 +404,14 @@ export async function fabricUpgrade(opts, injected) {
358
404
  else {
359
405
  log(`✓ Deployed ${result.version} (Fabric did not report a version to verify against)`);
360
406
  }
361
- return { plan, deployed: true, verifiedVersion, stagingDir };
407
+ return {
408
+ plan,
409
+ deployed: true,
410
+ verifiedVersion,
411
+ stagingDir,
412
+ replicationWarning: result.replicationWarning,
413
+ convergedAfterReplicationError: result.convergedAfterReplicationError,
414
+ };
362
415
  }
363
416
  finally {
364
417
  // SAFETY: always clean up the temp dir.