@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
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 fast — but 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
|
-
|
|
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
|
-
|
|
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 {
|
|
626
|
+
return {
|
|
627
|
+
url,
|
|
628
|
+
project,
|
|
629
|
+
version,
|
|
630
|
+
packageRoot,
|
|
631
|
+
dryRun: false,
|
|
632
|
+
replicationWarning,
|
|
633
|
+
convergedAfterReplicationError,
|
|
634
|
+
};
|
|
437
635
|
}
|
package/dist/fabric-upgrade.js
CHANGED
|
@@ -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
|
|
12
|
+
* carries an `overrides` block pinning harper to a fixed
|
|
13
13
|
* version — because the PUBLISHED flair declares an OLD Harper
|
|
14
|
-
* (
|
|
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
|
|
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 = "
|
|
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)
|
|
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: { "
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 {
|
|
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.
|