@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
@@ -0,0 +1,505 @@
1
+ /**
2
+ * replication-convergence.ts — flair#878
3
+ *
4
+ * Harper Fabric's component replication to peers converges ASYNCHRONOUSLY. A
5
+ * peer-replication error raised by `harper deploy` is therefore a snapshot of
6
+ * one instant, not a terminal verdict: the origin has the component, one peer
7
+ * did not have it *at the moment the deploy call returned*, and Harper keeps
8
+ * healing after the CLI has stopped watching.
9
+ *
10
+ * The incident this module closes (flair#878): a two-node cluster upgrade
11
+ * reported
12
+ *
13
+ * Component 'flair' was deployed on the origin node but failed to
14
+ * replicate to 1 of 1 peer node(s): <peer> (Error: Connection closed 1006)
15
+ *
16
+ * and the CLI treated it as failure. Afterwards both nodes carried
17
+ * byte-identical component files and all data was intact — the upgrade had
18
+ * succeeded. Worse, the CLI's retry re-ran the full deploy while Harper was
19
+ * still healing, and *that* attempt died with an npm `ENOTEMPTY` on a native
20
+ * module, turning a transient warning into a hard, misleading failure.
21
+ *
22
+ * ── The signal this module uses ─────────────────────────────────────────────
23
+ * `cluster_status` — the operation that would answer "are my cluster peers in
24
+ * sync" — is harper-pro-only and unavailable in the OSS build this CLI ships
25
+ * (see src/fleet-verify.ts's header for the same constraint). What IS
26
+ * available in the OSS build is `get_components`, an `admin_read` operation
27
+ * that walks the components root and returns, per file, `{ name, size, mtime }`
28
+ * (directories recurse through `entries`; `node_modules` is excluded
29
+ * server-side). Addressing it at each node's OWN hostname yields a per-node
30
+ * fingerprint of the deployed component tree, and comparing peer-to-origin
31
+ * answers exactly the question the deploy error left open.
32
+ *
33
+ * `mtime` is the load-bearing half of that fingerprint, not decoration: a peer
34
+ * still holding the PREVIOUS version can easily match on size (a same-length
35
+ * version string, an unchanged asset) but cannot match on the extraction
36
+ * timestamp the origin just stamped. Size alone would be a much weaker check.
37
+ *
38
+ * ── Why a node-identity guard exists (the false-success this would otherwise
39
+ * have) ────────────────────────────────────────────────────────────────────
40
+ * A Fabric cluster endpoint is GTM-steered: `https://<cluster>.<org>.
41
+ * harperfabric.com` resolves to ONE of the member nodes, and which one is not
42
+ * guaranteed stable between the deploy call and this poll. If the cluster
43
+ * endpoint happened to steer to the very peer that failed, a naive
44
+ * origin-vs-peer comparison would be comparing a node against ITSELF and would
45
+ * report `converged` for a cluster that never converged — precisely the
46
+ * failure mode that is worse than the bug being fixed, because it turns a
47
+ * false alarm into a false all-clear.
48
+ *
49
+ * The guard is a DNS resolution of both hostnames: the peer is only compared
50
+ * against the deploy target when the two resolve to disjoint address sets.
51
+ * Overlapping (or unresolvable) addresses yield `unknown`, never `converged`.
52
+ * This is the reporter's own observation encoded as a check rather than as
53
+ * prose a future reader has to remember — the two nodes "resolve to different
54
+ * IPs" is the property that makes the comparison meaningful at all.
55
+ *
56
+ * ── The invariant every caller may rely on ──────────────────────────────────
57
+ * `converged: true` is returned ONLY when every named peer was positively
58
+ * observed, over a distinct network identity, to hold a component tree
59
+ * identical to the origin's. Every other outcome — unparseable error, node
60
+ * name that is not an addressable host, DNS failure, shared address, HTTP
61
+ * failure, absent component, differing files — is `converged: false`. There is
62
+ * no path that infers convergence from the absence of evidence.
63
+ */
64
+ import { lookup } from "node:dns/promises";
65
+ // ─── Tunables ────────────────────────────────────────────────────────────────
66
+ /**
67
+ * How long to wait for asynchronous peer replication to converge before giving
68
+ * up on confirming it. Generous on purpose: the whole point is that Harper is
69
+ * still working after the deploy call returned, and the cost of waiting is a
70
+ * slower failure report, while the cost of NOT waiting is the flair#878
71
+ * false-failure plus a destructive retry.
72
+ */
73
+ export const DEFAULT_CONVERGENCE_TIMEOUT_MS = 180_000;
74
+ /** How often to re-fingerprint every node while waiting. */
75
+ export const CONVERGENCE_POLL_INTERVAL_MS = 10_000;
76
+ /**
77
+ * Quiescence (see awaitOriginQuiescent): how many consecutive identical
78
+ * fingerprints of the ORIGIN's component tree count as "nothing is writing to
79
+ * it any more". Two is the minimum that can distinguish "stable" from "read
80
+ * once"; the interval between them is what gives it meaning.
81
+ */
82
+ export const QUIESCENT_STABLE_READS = 2;
83
+ export const DEFAULT_QUIESCENT_TIMEOUT_MS = 120_000;
84
+ export const QUIESCENT_POLL_INTERVAL_MS = 5_000;
85
+ /**
86
+ * Harper builds the message as (components/operations.js):
87
+ *
88
+ * `Component '<name>' was deployed on the origin node but failed to
89
+ * replicate to <failed> of <total> peer node(s): <node> (<error>)[, ...].
90
+ * See deployment <id> (get_deployment) for details, or pass
91
+ * ignore_replication_errors: true ...`
92
+ *
93
+ * Both counts and the deployment-id tail are optional here — the aim is to
94
+ * recover the peer NAMES, since those are the only handle the CLI has on the
95
+ * cluster's actual topology (`cluster_status` being unavailable). Anything
96
+ * unrecognised yields an empty peer list, which callers must treat as "cannot
97
+ * check", never as "nothing failed".
98
+ *
99
+ * Literal regexes only (no interpolation) — satisfies semgrep
100
+ * detect-non-literal-regexp.
101
+ */
102
+ const REPLICATION_DETAIL_RE = /failed to replicate to (\d+)(?: of (\d+))? peer node\(s\):\s*([\s\S]*?)(?:\.\s*See deployment\s+(\S+?)\s*\(get_deployment\)|\.\s*$|$)/i;
103
+ /** One `name (error)` entry out of the comma-separated detail list. */
104
+ const PEER_ENTRY_RE = /([^,()]+?)\s*\(([^)]*)\)/g;
105
+ export function parseReplicationFailure(output) {
106
+ const empty = {
107
+ peers: [],
108
+ failedCount: null,
109
+ totalCount: null,
110
+ deploymentId: null,
111
+ };
112
+ if (!output)
113
+ return empty;
114
+ const m = REPLICATION_DETAIL_RE.exec(output);
115
+ if (!m)
116
+ return empty;
117
+ const failedCount = Number.isFinite(Number(m[1])) ? Number(m[1]) : null;
118
+ const totalCount = m[2] != null && Number.isFinite(Number(m[2])) ? Number(m[2]) : null;
119
+ const deploymentId = m[4] ?? null;
120
+ const peers = [];
121
+ const detail = (m[3] ?? "").split("\n")[0];
122
+ for (const entry of detail.matchAll(PEER_ENTRY_RE)) {
123
+ const node = entry[1].trim();
124
+ if (!node)
125
+ continue;
126
+ peers.push({ node, error: entry[2].trim() });
127
+ }
128
+ return { peers, failedCount, totalCount, deploymentId };
129
+ }
130
+ /**
131
+ * Harper serialises `mtime` as a Date, which crosses JSON as an ISO string.
132
+ * Normalising through Date makes the comparison immune to a peer reporting a
133
+ * different-but-equivalent encoding; an unparseable value is compared verbatim
134
+ * rather than silently dropped (dropping it would weaken the check).
135
+ */
136
+ function normalizeMtime(value) {
137
+ if (value == null)
138
+ return "-";
139
+ const asDate = new Date(value);
140
+ const ms = asDate.getTime();
141
+ return Number.isNaN(ms) ? `raw:${String(value)}` : asDate.toISOString();
142
+ }
143
+ function collectFiles(entry, prefix, out) {
144
+ if (!entry || typeof entry.name !== "string")
145
+ return;
146
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
147
+ if (Array.isArray(entry.entries)) {
148
+ for (const child of entry.entries)
149
+ collectFiles(child, path, out);
150
+ return;
151
+ }
152
+ const size = typeof entry.size === "number" ? String(entry.size) : "?";
153
+ out.push(`${path} ${size} ${normalizeMtime(entry.mtime)}`);
154
+ }
155
+ /**
156
+ * Reduce a `get_components` response to a stable, comparable fingerprint of ONE
157
+ * component's file tree.
158
+ *
159
+ * Returns null when the response doesn't contain that component, or contains it
160
+ * with no files at all. Null is deliberately NOT a fingerprint: two nodes that
161
+ * both lack the component would otherwise "match" and be reported converged.
162
+ */
163
+ export function fingerprintComponent(body, project) {
164
+ const roots = body?.entries;
165
+ if (!Array.isArray(roots))
166
+ return null;
167
+ const component = roots.find((e) => e && typeof e === "object" && e.name === project);
168
+ if (!component)
169
+ return null;
170
+ const children = Array.isArray(component.entries) ? component.entries : [];
171
+ const lines = [];
172
+ for (const child of children)
173
+ collectFiles(child, "", lines);
174
+ if (lines.length === 0)
175
+ return null;
176
+ lines.sort();
177
+ return lines.join("\n");
178
+ }
179
+ /** How many file entries differ between two fingerprints (for operator-facing detail). */
180
+ export function fingerprintDiffCount(a, b) {
181
+ const left = new Set(a.split("\n"));
182
+ const right = new Set(b.split("\n"));
183
+ let diff = 0;
184
+ for (const line of left)
185
+ if (!right.has(line))
186
+ diff++;
187
+ for (const line of right)
188
+ if (!left.has(line))
189
+ diff++;
190
+ return diff;
191
+ }
192
+ // ─── Addressing a node ──────────────────────────────────────────────────────
193
+ /**
194
+ * A Harper node name is a hostname (server/nodeName.ts derives it from
195
+ * `node.hostname`, the replication URL, or the TLS certificate CN), so it can
196
+ * normally be addressed directly with the deploy target's scheme and port.
197
+ *
198
+ * But harper's error detail is FREE TEXT — the per-peer entry falls back to the
199
+ * literal `'unknown'`, and a malformed cluster can put an arbitrary message
200
+ * there. Anything that isn't a plain `host[:port]` is rejected rather than
201
+ * guessed at: an unaddressable node yields "cannot check", which is safe, while
202
+ * a guessed address could reach the wrong machine, which is not.
203
+ */
204
+ const HOSTPORT_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?(?::\d{1,5})?$/;
205
+ export function resolvePeerUrl(node, referenceUrl) {
206
+ const trimmed = (node ?? "").trim();
207
+ if (!trimmed || trimmed.toLowerCase() === "unknown")
208
+ return null;
209
+ if (/^https?:\/\//i.test(trimmed)) {
210
+ try {
211
+ return new URL(trimmed).origin;
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ if (!HOSTPORT_RE.test(trimmed))
218
+ return null;
219
+ let reference;
220
+ try {
221
+ reference = new URL(referenceUrl);
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ const lastColon = trimmed.lastIndexOf(":");
227
+ const host = lastColon > 0 ? trimmed.slice(0, lastColon) : trimmed;
228
+ const port = lastColon > 0 ? trimmed.slice(lastColon + 1) : "";
229
+ const url = new URL(reference.origin);
230
+ url.hostname = host;
231
+ if (port)
232
+ url.port = port;
233
+ // Setting an invalid hostname on a URL is a silent no-op in WHATWG URL, which
234
+ // would leave us pointed at the ORIGIN while believing we addressed the peer.
235
+ if (url.hostname !== host.toLowerCase())
236
+ return null;
237
+ return url.origin;
238
+ }
239
+ function hostnameOf(url) {
240
+ try {
241
+ return new URL(url).hostname;
242
+ }
243
+ catch {
244
+ return null;
245
+ }
246
+ }
247
+ /**
248
+ * Real `get_components` caller. Fabric serves the operations API on the same
249
+ * origin the deploy targets, with the same admin Basic auth the deploy used.
250
+ *
251
+ * NEVER logs or returns the credential, and never folds a response BODY into a
252
+ * thrown error — only the status code. An operations-API error body can echo
253
+ * request context, and this runs with cluster-admin credentials.
254
+ */
255
+ export function buildOpsGetComponents(fabricUser, fabricPassword, timeoutMs = 15_000) {
256
+ const headers = { "Content-Type": "application/json" };
257
+ if (fabricUser && fabricPassword) {
258
+ headers.Authorization = `Basic ${Buffer.from(`${fabricUser}:${fabricPassword}`).toString("base64")}`;
259
+ }
260
+ return async (baseUrl) => {
261
+ const res = await fetch(baseUrl.replace(/\/+$/, "") + "/", {
262
+ method: "POST",
263
+ headers,
264
+ body: JSON.stringify({ operation: "get_components" }),
265
+ signal: AbortSignal.timeout(timeoutMs),
266
+ });
267
+ if (!res.ok)
268
+ throw new Error(`get_components returned HTTP ${res.status}`);
269
+ return (await res.json());
270
+ };
271
+ }
272
+ async function defaultResolveHostAddresses(hostname) {
273
+ const records = await lookup(hostname, { all: true });
274
+ return records.map((r) => r.address);
275
+ }
276
+ export function defaultConvergenceDeps(fabricUser, fabricPassword, onProgress) {
277
+ return {
278
+ getComponents: buildOpsGetComponents(fabricUser, fabricPassword),
279
+ resolveHostAddresses: defaultResolveHostAddresses,
280
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
281
+ now: () => Date.now(),
282
+ onProgress,
283
+ };
284
+ }
285
+ function summarize(peers, converged, conclusive) {
286
+ if (peers.length === 0) {
287
+ return "harper's replication error named no peer nodes — convergence could not be checked";
288
+ }
289
+ if (converged) {
290
+ const names = peers.map((p) => p.node).join(", ");
291
+ return `all ${peers.length} peer node(s) hold the same component tree as the origin (${names})`;
292
+ }
293
+ const parts = peers.map((p) => `${p.node}: ${p.state} — ${p.detail}`);
294
+ return conclusive
295
+ ? `peer replication did NOT converge — ${parts.join("; ")}`
296
+ : `convergence could NOT be determined — ${parts.join("; ")}`;
297
+ }
298
+ /**
299
+ * Poll every named peer until its component tree matches the origin's, or the
300
+ * deadline passes.
301
+ *
302
+ * Never throws for cluster-side reasons: a peer that cannot be reached, named,
303
+ * or resolved is reported as `unknown` and the caller decides. The only
304
+ * unhandled throw would be a bug in an injected dep.
305
+ */
306
+ export async function awaitReplicationConvergence(opts, deps) {
307
+ const started = deps.now();
308
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_CONVERGENCE_TIMEOUT_MS;
309
+ const pollIntervalMs = opts.pollIntervalMs ?? CONVERGENCE_POLL_INTERVAL_MS;
310
+ const deadline = started + timeoutMs;
311
+ const results = opts.peers.map((p) => {
312
+ const url = resolvePeerUrl(p.node, opts.targetUrl);
313
+ return {
314
+ node: p.node,
315
+ url,
316
+ state: "unknown",
317
+ detail: url
318
+ ? "not checked yet"
319
+ : `harper reported "${p.node}", which is not an addressable host — this CLI will not guess an address for it, so convergence cannot be checked from here`,
320
+ };
321
+ });
322
+ const finish = () => {
323
+ const converged = results.length > 0 && results.every((r) => r.state === "converged");
324
+ const conclusive = results.length > 0 && results.every((r) => r.state !== "unknown");
325
+ return {
326
+ converged,
327
+ conclusive,
328
+ peers: results,
329
+ elapsedMs: deps.now() - started,
330
+ detail: summarize(results, converged, conclusive),
331
+ };
332
+ };
333
+ const addressable = results.filter((r) => r.url !== null);
334
+ if (addressable.length === 0)
335
+ return finish();
336
+ const targetHost = hostnameOf(opts.targetUrl);
337
+ let targetAddresses = [];
338
+ if (targetHost) {
339
+ try {
340
+ targetAddresses = await deps.resolveHostAddresses(targetHost);
341
+ }
342
+ catch {
343
+ targetAddresses = [];
344
+ }
345
+ }
346
+ for (;;) {
347
+ let originFingerprint = null;
348
+ let originError = null;
349
+ try {
350
+ originFingerprint = fingerprintComponent(await deps.getComponents(opts.targetUrl), opts.project);
351
+ if (!originFingerprint) {
352
+ originError = `the deploy target did not report a '${opts.project}' component tree to compare peers against`;
353
+ }
354
+ }
355
+ catch (err) {
356
+ originError = `could not read the deploy target's component tree: ${err?.message ?? String(err)}`;
357
+ }
358
+ for (const peer of results) {
359
+ if (peer.state === "converged" || peer.url === null)
360
+ continue;
361
+ if (!originFingerprint) {
362
+ peer.state = "unknown";
363
+ peer.detail = originError ?? "no origin fingerprint to compare against";
364
+ continue;
365
+ }
366
+ const peerHost = hostnameOf(peer.url);
367
+ let peerAddresses = [];
368
+ if (peerHost) {
369
+ try {
370
+ peerAddresses = await deps.resolveHostAddresses(peerHost);
371
+ }
372
+ catch {
373
+ peerAddresses = [];
374
+ }
375
+ }
376
+ if (peerAddresses.length === 0) {
377
+ peer.state = "unknown";
378
+ peer.detail = `${peerHost ?? peer.url} did not resolve — cannot reach this node to check whether it converged`;
379
+ continue;
380
+ }
381
+ if (targetAddresses.length === 0) {
382
+ peer.state = "unknown";
383
+ peer.detail =
384
+ `the deploy target's own hostname did not resolve, so this peer cannot be proven to be a different ` +
385
+ `node from it — refusing to compare a node against itself`;
386
+ continue;
387
+ }
388
+ if (peerAddresses.some((a) => targetAddresses.includes(a))) {
389
+ peer.state = "unknown";
390
+ peer.detail =
391
+ `${peerHost} currently resolves to the same address as the deploy target — a Fabric cluster endpoint ` +
392
+ `is steered to one member node, so this comparison would be the peer against itself. Refusing to ` +
393
+ `report convergence from it`;
394
+ continue;
395
+ }
396
+ let peerFingerprint = null;
397
+ try {
398
+ peerFingerprint = fingerprintComponent(await deps.getComponents(peer.url), opts.project);
399
+ }
400
+ catch (err) {
401
+ peer.state = "unknown";
402
+ peer.detail = `could not read this node's component tree: ${err?.message ?? String(err)}`;
403
+ continue;
404
+ }
405
+ if (!peerFingerprint) {
406
+ peer.state = "diverged";
407
+ peer.detail = `this node reports no '${opts.project}' component at all`;
408
+ continue;
409
+ }
410
+ if (peerFingerprint === originFingerprint) {
411
+ peer.state = "converged";
412
+ peer.detail = "component files match the origin byte-for-byte (name, size and mtime)";
413
+ continue;
414
+ }
415
+ peer.state = "diverged";
416
+ peer.detail = `${fingerprintDiffCount(originFingerprint, peerFingerprint)} component file entr(ies) differ from the origin`;
417
+ }
418
+ if (results.every((r) => r.state === "converged"))
419
+ break;
420
+ if (deps.now() >= deadline)
421
+ break;
422
+ deps.onProgress?.(`waiting for peer replication to converge (${results.filter((r) => r.state === "converged").length}/${results.length} peer(s) match the origin so far)...`);
423
+ await deps.sleep(pollIntervalMs);
424
+ }
425
+ return finish();
426
+ }
427
+ /**
428
+ * Wait until the ORIGIN's component tree stops changing.
429
+ *
430
+ * This is the retry's clean-first precondition. Retrying on a fixed 5s/10s
431
+ * backoff re-issues a full deploy while the previous attempt's SERVER-SIDE work
432
+ * — tarball extraction and `npm install` into `<components-root>/<project>` —
433
+ * may still be running, and two writers in one component directory is the
434
+ * best available explanation for the reported
435
+ * `ENOTEMPTY: directory not empty, rmdir '.../node_modules/<pkg>/dist'`, the
436
+ * error that took that upgrade from "transient warning" to "failed".
437
+ *
438
+ * Stated honestly: the CLI cannot observe WHY the remote install failed — that
439
+ * happened inside Harper on another host. What it can do is remove the overlap,
440
+ * which is a necessary condition for any concurrent-writer explanation and
441
+ * costs nothing when the explanation is something else.
442
+ *
443
+ * What the CLI CANNOT do is make Harper's remote install clean-first directly.
444
+ * Harper consults a component's `install_command` only when `node_modules` is
445
+ * ABSENT — `components/Application.ts` returns early ("already has
446
+ * node_modules; skipping install") before the custom command is ever read — so
447
+ * it cannot be used to clear an existing tree, and `deploy_component` exposes no
448
+ * clean/force-reinstall option. Not starting attempt N+1 on top of attempt N is
449
+ * therefore the only clean-first guarantee available from this side.
450
+ *
451
+ * Returns `quiescent: false` when it could not establish stability. Callers
452
+ * must treat that as "do not retry", never as "safe to retry".
453
+ */
454
+ export async function awaitOriginQuiescent(opts, deps) {
455
+ const started = deps.now();
456
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_QUIESCENT_TIMEOUT_MS;
457
+ const pollIntervalMs = opts.pollIntervalMs ?? QUIESCENT_POLL_INTERVAL_MS;
458
+ const stableReads = opts.stableReads ?? QUIESCENT_STABLE_READS;
459
+ const deadline = started + timeoutMs;
460
+ let previous = null;
461
+ let streak = 0;
462
+ let lastError = null;
463
+ for (;;) {
464
+ let fingerprint = null;
465
+ try {
466
+ fingerprint = fingerprintComponent(await deps.getComponents(opts.targetUrl), opts.project);
467
+ if (!fingerprint)
468
+ lastError = `the deploy target reports no '${opts.project}' component tree`;
469
+ }
470
+ catch (err) {
471
+ lastError = err?.message ?? String(err);
472
+ }
473
+ if (fingerprint) {
474
+ if (previous !== null && fingerprint === previous) {
475
+ streak++;
476
+ }
477
+ else {
478
+ streak = 1;
479
+ }
480
+ previous = fingerprint;
481
+ lastError = null;
482
+ if (streak >= stableReads) {
483
+ return {
484
+ quiescent: true,
485
+ detail: `origin component tree unchanged across ${streak} consecutive reads`,
486
+ elapsedMs: deps.now() - started,
487
+ };
488
+ }
489
+ }
490
+ else {
491
+ streak = 0;
492
+ previous = null;
493
+ }
494
+ if (deps.now() >= deadline) {
495
+ return {
496
+ quiescent: false,
497
+ detail: `origin component tree did not settle within ${timeoutMs}ms` +
498
+ (lastError ? ` (last read: ${lastError})` : " — it is still being written to"),
499
+ elapsedMs: deps.now() - started,
500
+ };
501
+ }
502
+ deps.onProgress?.("waiting for the origin's component tree to settle before retrying...");
503
+ await deps.sleep(pollIntervalMs);
504
+ }
505
+ }
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { access, readFile, readdir } from "node:fs/promises";
3
3
  import { constants } from "node:fs";
4
4
  import { basename, extname, join } from "node:path";
@@ -1,4 +1,4 @@
1
- import { Resource } from "@harperfast/harper";
1
+ import { Resource } from "harper";
2
2
  import { allowAdmin } from "./agent-auth.js";
3
3
  /**
4
4
  * GET /Admin — friendly redirect to /AdminDashboard.
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource } from "@harperfast/harper";
1
+ import { Resource } from "harper";
2
2
  import { layout, htmlResponse } from "./admin-layout.js";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { databases } from "@harperfast/harper";
1
+ import { databases } from "harper";
2
2
  import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
3
3
  import { localInstanceId } from "./instance-identity.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { selectPublicDescription, selectPublicSkills } from "./agentcard-fields.js";
3
3
  export class AgentCard extends Resource {
4
4
  // Public discovery metadata (per A2A spec, agent cards are intentionally
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Auth: admin only.
18
18
  */
19
- import { Resource, databases } from "@harperfast/harper";
19
+ import { Resource, databases } from "harper";
20
20
  import { isAdmin, allowAdmin } from "./agent-auth.js";
21
21
  const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
22
22
  name: displayName,
@@ -75,7 +75,7 @@
75
75
  * validated single-entity + bounded-window condition (WorkspaceState), OR
76
76
  * goes through the source's own content-gated resource (Presence).
77
77
  */
78
- import { Resource, databases } from "@harperfast/harper";
78
+ import { Resource, databases } from "harper";
79
79
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
80
80
  import { isValidEntity } from "./entity-vocab.js";
81
81
  import { resolveReadScope } from "./memory-read-scope.js";
@@ -1,4 +1,4 @@
1
- import { databases } from "@harperfast/harper";
1
+ import { databases } from "harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { Resource, databases, server } from "@harperfast/harper";
1
+ import { Resource, databases, server } from "harper";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import nacl from "tweetnacl";
4
4
  import { allowAdmin } from "./agent-auth.js";
@@ -1,4 +1,4 @@
1
- import { databases } from "@harperfast/harper";
1
+ import { databases } from "harper";
2
2
  import { allowVerified, allowAdmin } from "./agent-auth.js";
3
3
  /**
4
4
  * Instance is read-only reference data for agents (flair_agent grant: read only).
@@ -1,4 +1,4 @@
1
- import { databases } from "@harperfast/harper";
1
+ import { databases } from "harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
4
4
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
@@ -1,4 +1,4 @@
1
- import { Resource, databases } from "@harperfast/harper";
1
+ import { Resource, databases } from "harper";
2
2
  import { mcpIssuer } from "./mcp-oauth-flag.js";
3
3
  import { agentPublicKeyToJwk, buildCimdDocument } from "./mcp-client-metadata-fields.js";
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { databases } from "@harperfast/harper";
1
+ import { databases } from "harper";
2
2
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
3
3
  import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
4
4
  import { localInstanceId } from "./instance-identity.js";