@tpsdev-ai/flair 0.30.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 (49) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1355 -281
  3. package/dist/deploy.js +212 -24
  4. package/dist/fabric-upgrade.js +16 -1
  5. package/dist/federation/scheduler.js +500 -0
  6. package/dist/install/clients.js +111 -53
  7. package/dist/lib/mcp-spec.js +128 -0
  8. package/dist/lib/safe-snapshot-extract.js +231 -0
  9. package/dist/lib/scheduler-platform.js +128 -0
  10. package/dist/lib/xml-escape.js +54 -0
  11. package/dist/rem/scheduler.js +35 -87
  12. package/dist/rem/snapshot.js +13 -0
  13. package/dist/replication-convergence.js +505 -0
  14. package/dist/resources/MemoryBootstrap.js +7 -8
  15. package/dist/resources/SemanticSearch.js +17 -45
  16. package/dist/resources/abstention.js +1 -1
  17. package/dist/resources/embeddings-boot.js +10 -12
  18. package/dist/resources/embeddings-provider.js +10 -7
  19. package/dist/resources/health.js +24 -19
  20. package/dist/resources/in-process.js +225 -0
  21. package/dist/resources/mcp-tools.js +23 -17
  22. package/dist/resources/migration-boot.js +80 -10
  23. package/dist/resources/migrations/data-dir.js +205 -0
  24. package/dist/resources/migrations/progress.js +33 -0
  25. package/dist/resources/migrations/runner.js +29 -2
  26. package/dist/resources/migrations/state.js +13 -2
  27. package/dist/resources/models-dir.js +18 -9
  28. package/dist/resources/semantic-retrieval-core.js +5 -4
  29. package/dist/src/lib/scheduler-platform.js +128 -0
  30. package/dist/src/lib/xml-escape.js +54 -0
  31. package/dist/src/rem/scheduler.js +35 -87
  32. package/docs/deploying-on-fabric.md +267 -0
  33. package/docs/deployment.md +5 -0
  34. package/docs/embedding-in-a-harper-app.md +299 -0
  35. package/docs/federation.md +61 -4
  36. package/docs/integrations.md +3 -0
  37. package/docs/mcp-clients.md +16 -7
  38. package/docs/quickstart.md +80 -54
  39. package/docs/releasing.md +72 -38
  40. package/docs/supply-chain-policy.md +36 -0
  41. package/docs/troubleshooting.md +24 -0
  42. package/docs/upgrade.md +98 -3
  43. package/package.json +1 -11
  44. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  45. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  46. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  47. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  48. package/dist/resources/rerank-provider.js +0 -569
  49. package/docs/rerank-provisioning.md +0 -101
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Federation sync driver — platform-native scheduler install/uninstall.
3
+ *
4
+ * Backs `flair federation sync enable|disable|status`. Mirrors
5
+ * src/rem/scheduler.ts (`flair rem nightly enable`) in structure, verbs and
6
+ * platform handling; the shared launchd/systemd primitives live in
7
+ * src/lib/scheduler-platform.ts so flair#850's active-state lesson has one
8
+ * implementation.
9
+ *
10
+ * ── Why this exists ────────────────────────────────────────────────────────
11
+ * Federation had no automatic driver. `flair federation sync` is one-shot and
12
+ * `flair federation watch` is a foreground loop that dies with its terminal,
13
+ * so a freshly paired spoke syncs exactly once and then never again — which
14
+ * presents as a broken pairing rather than as a missing scheduler.
15
+ *
16
+ * ── Why a periodic one-shot, not a supervised watcher ──────────────────────
17
+ * Both shapes were built by hand on our own hub (one launchd job wrapping the
18
+ * watch loop with KeepAlive/SuccessfulExit=false, one running one-shot sync on
19
+ * StartInterval=900) and neither was ever shipped. This module ships exactly
20
+ * one of them: the periodic one-shot. The argument is not aesthetic:
21
+ *
22
+ * 1. The watch loop carries NO state across iterations. It is literally
23
+ * `while (!stopped) { await runFederationSyncOnce(opts); sleep(interval) }`
24
+ * — every cycle re-reads the peer list, re-loads the instance key and
25
+ * opens fresh HTTP connections. A long-lived process is worth its cost
26
+ * when it holds warm state or a persistent connection. This one holds
27
+ * neither, so supervision buys only the ~300ms of process startup a
28
+ * one-shot pays per cycle — at a 300s interval, a 0.1% duty cycle.
29
+ *
30
+ * 2. A supervised watcher's worst failure is invisible. KeepAlive restarts a
31
+ * process that EXITS; it does nothing for one that HANGS. A hung watcher
32
+ * stops syncing forever while `launchctl list` still shows it happily
33
+ * running — the exact "looks fine, is dead" shape supervision was
34
+ * supposed to remove. Every network call inside runFederationSyncOnce is
35
+ * bounded by an AbortSignal.timeout (10s ops, 15s query, 45s per batch),
36
+ * so a one-shot always terminates and the scheduler always gets to start
37
+ * the next one.
38
+ *
39
+ * 3. Crash safety is free. The sync cursor (peer.lastSyncAt) only advances
40
+ * after a successful push, and the hub merges by id, so an interrupted
41
+ * run re-sends rather than losing records. There is no partial-failure
42
+ * state for a supervisor to reason about.
43
+ *
44
+ * The cost is latency, and latency is the knob: --interval, default 300s.
45
+ * (Not 30s like `federation watch`: a scheduler-spawned process per cycle
46
+ * makes very short intervals mostly process startup. Not 900s like the
47
+ * hand-built job either — five minutes is a defensible upper bound on how
48
+ * stale a spoke should look on the hub dashboard.)
49
+ *
50
+ * `federation watch` is NOT removed — it is still the right tool for an
51
+ * interactive "watch it sync while I debug this" session. It is simply no
52
+ * longer the answer to "how do I keep this synced".
53
+ *
54
+ * ── Secrets ────────────────────────────────────────────────────────────────
55
+ * The scheduler unit NEVER contains a password. It carries
56
+ * FLAIR_ADMIN_PASS_FILE — a PATH — and the shim passes that path to
57
+ * `flair federation sync --admin-pass-file`, which reads it through
58
+ * readAdminPassFileSecure() and refuses a file that is not owner-only.
59
+ */
60
+ import { existsSync, chmodSync, rmSync, readFileSync } from "node:fs";
61
+ import { resolve, dirname } from "node:path";
62
+ import { homedir } from "node:os";
63
+ import { fileURLToPath } from "node:url";
64
+ import { escapeXml } from "../lib/xml-escape.js";
65
+ import { detectPlatform as detectPlatformFor, spawnReport, readTemplate, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
66
+ export const LAUNCHD_LABEL = "dev.flair.federation.sync";
67
+ export const SYSTEMD_TIMER_UNIT = "flair-federation-sync.timer";
68
+ export const SYSTEMD_SERVICE_UNIT = "flair-federation-sync.service";
69
+ export const SHIM_PATH_DEFAULT = resolve(homedir(), ".flair", "bin", "flair-federation-sync");
70
+ export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
71
+ export const SYSTEMD_USER_DIR = resolve(homedir(), ".config", "systemd", "user");
72
+ export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR, SYSTEMD_TIMER_UNIT);
73
+ export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, SYSTEMD_SERVICE_UNIT);
74
+ /** Default seconds between one-shot syncs. See the module header for why 300. */
75
+ export const DEFAULT_INTERVAL_SECONDS = 300;
76
+ /**
77
+ * Floor on --interval. Below a minute the per-cycle process startup starts to
78
+ * dominate the actual work, and systemd's default timer accuracy is a minute
79
+ * anyway — a smaller number would be a promise the scheduler cannot keep.
80
+ * Sub-minute latency is what `flair federation watch` is for.
81
+ */
82
+ export const MIN_INTERVAL_SECONDS = 60;
83
+ /** Ceiling on --interval: a day. Past this, use `flair rem nightly`'s shape. */
84
+ export const MAX_INTERVAL_SECONDS = 86_400;
85
+ function detectPlatform(override) {
86
+ return detectPlatformFor("federation sync scheduler", override);
87
+ }
88
+ function defaultTemplateRoot() {
89
+ // Templates live alongside dist/ in the published package and alongside
90
+ // src/federation/ in the source tree. Walk up from this file until we find
91
+ // a directory containing templates/.
92
+ const here = dirname(fileURLToPath(import.meta.url));
93
+ const candidates = [
94
+ resolve(here, "..", "..", "templates"),
95
+ resolve(here, "..", "..", "..", "templates"),
96
+ resolve(here, "..", "templates"),
97
+ ];
98
+ for (const c of candidates) {
99
+ if (existsSync(c))
100
+ return c;
101
+ }
102
+ throw new Error(`unable to locate templates directory (looked in: ${candidates.join(", ")})`);
103
+ }
104
+ export function renderTemplate(text, subs) {
105
+ return renderTemplateWith(text, { ...subs }, (v) => v);
106
+ }
107
+ /**
108
+ * renderTemplate() for the launchd plist specifically: substituted values are
109
+ * XML-escaped (#918). A plist is XML, so a value carrying `&`, `<`, `>`, `"`
110
+ * or `'` makes it malformed and `launchctl bootstrap` rejects it — the job
111
+ * silently never registers. HOME, SHIM_PATH, ADMIN_PASS_FILE and FLAIR_TARGET
112
+ * are all arbitrary operator-supplied strings and every one of them can carry
113
+ * an ampersand.
114
+ *
115
+ * Deliberately NOT folded into renderTemplate(): the same substitutions are
116
+ * rendered into the systemd units and the shell shim, where XML escaping
117
+ * would be corruption rather than a fix.
118
+ */
119
+ export function renderPlistTemplate(text, subs) {
120
+ return renderTemplateWith(text, { ...subs }, escapeXml);
121
+ }
122
+ /**
123
+ * Validates the interval. Throws rather than silently coercing — surface bad
124
+ * input at the install boundary, where the operator is still watching.
125
+ */
126
+ export function validateInterval(intervalSeconds) {
127
+ if (!Number.isInteger(intervalSeconds)) {
128
+ throw new Error(`interval must be a whole number of seconds, got ${intervalSeconds}`);
129
+ }
130
+ if (intervalSeconds < MIN_INTERVAL_SECONDS || intervalSeconds > MAX_INTERVAL_SECONDS) {
131
+ throw new Error(`interval must be ${MIN_INTERVAL_SECONDS}-${MAX_INTERVAL_SECONDS} seconds, got ${intervalSeconds}. ` +
132
+ `For sub-minute latency use \`flair federation watch --interval <s>\` in a foreground session instead.`);
133
+ }
134
+ }
135
+ function buildSubstitutions(opts, shimPath, flairBin) {
136
+ validateInterval(opts.intervalSeconds);
137
+ const adminPassFile = opts.adminPassFile ?? "";
138
+ if (adminPassFile && !existsSync(adminPassFile)) {
139
+ throw new Error(`--admin-pass-file path does not exist: ${adminPassFile}. ` +
140
+ `The scheduler stores the PATH, not the password, so the file must exist before enabling.`);
141
+ }
142
+ return {
143
+ FLAIR_BIN: flairBin,
144
+ SHIM_PATH: shimPath,
145
+ HOME: opts.homeOverride ?? homedir(),
146
+ INTERVAL_SECONDS: String(opts.intervalSeconds),
147
+ ADMIN_PASS_FILE: adminPassFile,
148
+ FLAIR_TARGET: opts.target ?? "",
149
+ };
150
+ }
151
+ function launchdDomain() {
152
+ return `gui/${process.getuid?.() ?? ""}`;
153
+ }
154
+ /**
155
+ * Installs the platform-native scheduler entry and the shim script.
156
+ *
157
+ * macOS: writes ~/Library/LaunchAgents/dev.flair.federation.sync.plist and
158
+ * bootstraps it (StartInterval + RunAtLoad).
159
+ * Linux: writes ~/.config/systemd/user/flair-federation-sync.{timer,service}
160
+ * and enables the timer (OnActiveSec + OnUnitActiveSec).
161
+ *
162
+ * Idempotent: re-running overwrites the unit in place and re-bootstraps, so
163
+ * `enable --interval 600` after `enable` is how you change the interval.
164
+ */
165
+ export function enableScheduler(opts) {
166
+ const plat = detectPlatform(opts.platformOverride);
167
+ const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
168
+ const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
169
+ const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
170
+ const subs = buildSubstitutions(opts, shimPath, flairBin);
171
+ // 1. Deploy the shim (always — both platforms invoke it).
172
+ const shimContents = renderTemplate(readTemplate(templateRoot, "bin/flair-federation-sync.sh.tmpl"), subs);
173
+ writeFileWithDir(shimPath, shimContents, 0o700);
174
+ chmodSync(shimPath, 0o700);
175
+ // 2. Write the scheduler entry.
176
+ if (plat === "darwin") {
177
+ const plistPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
178
+ const plistContents = renderPlistTemplate(readTemplate(templateRoot, `launchd/${LAUNCHD_LABEL}.plist.tmpl`), subs);
179
+ writeFileWithDir(plistPath, plistContents, 0o600);
180
+ const loadCommand = ["launchctl", "bootstrap", launchdDomain(), plistPath];
181
+ let loadResult;
182
+ if (!opts.skipLoad) {
183
+ // Bootout first in case a prior install left the job loaded — this is
184
+ // what makes re-running enable (e.g. to change --interval) idempotent
185
+ // rather than a "service already loaded" failure.
186
+ spawnReport(["launchctl", "bootout", launchdDomain(), plistPath]);
187
+ loadResult = spawnReport(loadCommand);
188
+ }
189
+ return { platform: plat, shimPath, schedulerPath: plistPath, intervalSeconds: opts.intervalSeconds, loadCommand, loadResult };
190
+ }
191
+ // Linux: systemd user units.
192
+ const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
193
+ const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
194
+ const serviceContents = renderTemplate(readTemplate(templateRoot, `systemd/${SYSTEMD_SERVICE_UNIT}.tmpl`), subs);
195
+ const timerContents = renderTemplate(readTemplate(templateRoot, `systemd/${SYSTEMD_TIMER_UNIT}.tmpl`), subs);
196
+ writeFileWithDir(servicePath, serviceContents, 0o600);
197
+ writeFileWithDir(timerPath, timerContents, 0o600);
198
+ const loadCommand = ["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT];
199
+ let loadResult;
200
+ if (!opts.skipLoad) {
201
+ spawnReport(["systemctl", "--user", "daemon-reload"]);
202
+ // Restart so a changed --interval takes effect on re-enable; `enable
203
+ // --now` alone leaves an already-running timer on its old schedule.
204
+ loadResult = spawnReport(loadCommand);
205
+ if (loadResult.code === 0)
206
+ spawnReport(["systemctl", "--user", "restart", SYSTEMD_TIMER_UNIT]);
207
+ }
208
+ return { platform: plat, shimPath, schedulerPath: timerPath, intervalSeconds: opts.intervalSeconds, loadCommand, loadResult };
209
+ }
210
+ /** Removes the scheduler entry. Peer records and sync history are untouched. */
211
+ export function disableScheduler(opts = {}) {
212
+ const plat = detectPlatform(opts.platformOverride);
213
+ const removed = [];
214
+ if (plat === "darwin") {
215
+ const plistPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
216
+ const unloadCommand = ["launchctl", "bootout", launchdDomain(), plistPath];
217
+ let unloadResult;
218
+ if (existsSync(plistPath)) {
219
+ if (!opts.skipUnload) {
220
+ unloadResult = spawnReport(unloadCommand);
221
+ }
222
+ rmSync(plistPath, { force: true });
223
+ removed.push(plistPath);
224
+ }
225
+ if (opts.removeShim) {
226
+ const shim = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
227
+ if (existsSync(shim)) {
228
+ rmSync(shim, { force: true });
229
+ removed.push(shim);
230
+ }
231
+ }
232
+ return { platform: plat, removed, unloadCommand, unloadResult };
233
+ }
234
+ const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
235
+ const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
236
+ const unloadCommand = ["systemctl", "--user", "disable", "--now", SYSTEMD_TIMER_UNIT];
237
+ let unloadResult;
238
+ if (existsSync(timerPath) || existsSync(servicePath)) {
239
+ if (!opts.skipUnload) {
240
+ unloadResult = spawnReport(unloadCommand);
241
+ spawnReport(["systemctl", "--user", "daemon-reload"]);
242
+ }
243
+ if (existsSync(timerPath)) {
244
+ rmSync(timerPath, { force: true });
245
+ removed.push(timerPath);
246
+ }
247
+ if (existsSync(servicePath)) {
248
+ rmSync(servicePath, { force: true });
249
+ removed.push(servicePath);
250
+ }
251
+ }
252
+ if (opts.removeShim) {
253
+ const shim = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
254
+ if (existsSync(shim)) {
255
+ rmSync(shim, { force: true });
256
+ removed.push(shim);
257
+ }
258
+ }
259
+ return { platform: plat, removed, unloadCommand, unloadResult };
260
+ }
261
+ function activeCheckCommand(plat) {
262
+ if (plat === "darwin") {
263
+ return ["launchctl", "print", `${launchdDomain()}/${LAUNCHD_LABEL}`];
264
+ }
265
+ return ["systemctl", "--user", "is-active", SYSTEMD_TIMER_UNIT];
266
+ }
267
+ function queryActiveState(plat) {
268
+ const [cmd, ...args] = activeCheckCommand(plat);
269
+ const r = spawnReport([cmd, ...args], STATUS_CHECK_TIMEOUT_MS);
270
+ return interpretActiveResult(plat, r.code, r.stdout, r.stderr);
271
+ }
272
+ /**
273
+ * Reads the configured interval back out of an installed unit file.
274
+ *
275
+ * Exported for testing, and deliberately parses the file rather than trusting
276
+ * a remembered value: an operator who hand-edits the plist has changed the
277
+ * real schedule, and status should say what is actually installed.
278
+ */
279
+ export function parseInstalledInterval(plat, unitText) {
280
+ const m = plat === "darwin"
281
+ ? /<key>\s*StartInterval\s*<\/key>\s*<integer>\s*(\d+)\s*<\/integer>/.exec(unitText)
282
+ : /^\s*OnUnitActiveSec\s*=\s*(\d+)s\s*$/m.exec(unitText);
283
+ if (!m)
284
+ return null;
285
+ const n = Number(m[1]);
286
+ return Number.isFinite(n) && n > 0 ? n : null;
287
+ }
288
+ export function schedulerStatus(opts = {}) {
289
+ const plat = detectPlatform(opts.platformOverride);
290
+ const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
291
+ let schedulerPath;
292
+ let installed;
293
+ if (plat === "darwin") {
294
+ schedulerPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
295
+ installed = existsSync(schedulerPath);
296
+ }
297
+ else {
298
+ schedulerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
299
+ const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
300
+ installed = existsSync(schedulerPath) && existsSync(servicePath);
301
+ }
302
+ let intervalSeconds = null;
303
+ if (installed) {
304
+ try {
305
+ intervalSeconds = parseInstalledInterval(plat, readFileSync(schedulerPath, "utf-8"));
306
+ }
307
+ catch {
308
+ intervalSeconds = null;
309
+ }
310
+ }
311
+ let active;
312
+ if (!installed) {
313
+ active = false; // nothing written — definitely nothing active
314
+ }
315
+ else if (opts.skipActiveCheck) {
316
+ active = null; // caller opted out — unknown, not a claim either way
317
+ }
318
+ else {
319
+ active = queryActiveState(plat);
320
+ }
321
+ return {
322
+ platform: plat,
323
+ installed,
324
+ active,
325
+ intervalSeconds,
326
+ schedulerPath,
327
+ shimPath,
328
+ shimExists: existsSync(shimPath),
329
+ };
330
+ }
331
+ /** Freshness window when no managed interval is known (nothing installed). */
332
+ export const DEFAULT_FRESHNESS_MS = 3_600_000;
333
+ /**
334
+ * How long peer silence has to last before it counts as "not syncing".
335
+ *
336
+ * Three consecutive missed cycles — one miss is a blip, three is a pattern —
337
+ * with a five-minute floor so a tight interval doesn't produce a hair trigger
338
+ * that fires on a single slow run.
339
+ */
340
+ export function freshnessWindowMs(intervalSeconds) {
341
+ if (intervalSeconds == null)
342
+ return DEFAULT_FRESHNESS_MS;
343
+ return Math.max(intervalSeconds * 3 * 1000, 300_000);
344
+ }
345
+ function humanAge(ms) {
346
+ if (ms < 60_000)
347
+ return "<1m";
348
+ if (ms < 3_600_000)
349
+ return `${Math.floor(ms / 60_000)}m`;
350
+ if (ms < 86_400_000)
351
+ return `${Math.floor(ms / 3_600_000)}h`;
352
+ return `${Math.floor(ms / 86_400_000)}d`;
353
+ }
354
+ const LOG_HINT = "~/.flair/logs/federation-sync.stderr.log";
355
+ export function assessDriver(input) {
356
+ const windowMs = freshnessWindowMs(input.intervalSeconds);
357
+ const t = input.lastSyncAt ? Date.parse(input.lastSyncAt) : NaN;
358
+ const haveContact = Number.isFinite(t);
359
+ const contactAgeMs = haveContact ? input.now - t : Number.POSITIVE_INFINITY;
360
+ const contactFresh = haveContact && contactAgeMs <= windowMs;
361
+ const driverActive = input.installed && input.active === true;
362
+ const agoText = haveContact ? `${humanAge(contactAgeMs)} ago` : "never";
363
+ const everyText = input.intervalSeconds ? `every ${input.intervalSeconds}s` : "on its installed schedule";
364
+ const base = { driverActive, contactFresh, freshnessWindowMs: windowMs };
365
+ // Inconclusive service-manager query — say so rather than guessing. Note
366
+ // this is checked BEFORE driverActive: `active === null` can never satisfy
367
+ // `active === true`, so without this branch an inconclusive query would be
368
+ // silently reported as "no driver".
369
+ if (input.installed && input.active === null) {
370
+ return {
371
+ ...base,
372
+ verdict: "unknown",
373
+ headline: "Sync driver: installed, but its state could not be read",
374
+ detail: `The scheduler unit is on disk, but querying the service manager was inconclusive, ` +
375
+ `so whether it is actually loaded is unknown. Last peer contact: ${agoText}.`,
376
+ remedy: "flair federation sync status",
377
+ };
378
+ }
379
+ if (driverActive) {
380
+ if (contactFresh) {
381
+ return {
382
+ ...base,
383
+ verdict: "driving",
384
+ headline: `Sync driver: active (${everyText})`,
385
+ detail: `Last peer contact ${agoText}.`,
386
+ remedy: null,
387
+ };
388
+ }
389
+ return {
390
+ ...base,
391
+ verdict: "driver-stalled",
392
+ headline: `Sync driver: active (${everyText}) — but no peer contact in ${humanAge(windowMs)}`,
393
+ detail: `Sync IS scheduled and the service manager has it loaded, so this is not a missing driver — ` +
394
+ `the runs themselves are failing to reach a peer (unreachable endpoint, expired credential, ` +
395
+ `or a revoked pairing). Last peer contact: ${agoText}.`,
396
+ remedy: `flair federation reachability # then check ${LOG_HINT}`,
397
+ };
398
+ }
399
+ // No managed driver from here down.
400
+ if (contactFresh) {
401
+ return {
402
+ ...base,
403
+ verdict: "external-driver",
404
+ headline: "Sync driver: none managed by Flair — but syncs are landing",
405
+ detail: `No Flair-managed scheduler is loaded, yet a peer was contacted ${agoText}. Something else is ` +
406
+ `driving sync — a cron entry, a hand-written unit, or a \`flair federation watch\` session. ` +
407
+ `Nothing is broken; enable the managed driver only if you want Flair to own it.`,
408
+ remedy: null,
409
+ };
410
+ }
411
+ if (input.installed) {
412
+ return {
413
+ ...base,
414
+ verdict: "driver-inactive",
415
+ headline: "Sync driver: INSTALLED BUT NOT LOADED — nothing is running federation sync",
416
+ detail: `The scheduler unit is on disk but the service manager does not have it loaded, so it never ` +
417
+ `fires. Last peer contact: ${agoText}.`,
418
+ remedy: "flair federation sync enable",
419
+ };
420
+ }
421
+ return {
422
+ ...base,
423
+ verdict: "no-driver",
424
+ headline: "Sync driver: NONE — nothing is running federation sync",
425
+ detail: `\`flair federation sync\` is one-shot and \`flair federation watch\` only runs while its terminal ` +
426
+ `is open, so a paired spoke syncs once and then stops. Last peer contact: ${agoText}.`,
427
+ remedy: "flair federation sync enable",
428
+ };
429
+ }
430
+ /**
431
+ * Formats the `flair federation sync enable` report. Owns the
432
+ * success-vs-failure decision (flair#850: never print a success headline
433
+ * before activation is known to have succeeded), extracted from the CLI
434
+ * action so it is unit-testable without spawning launchctl/systemctl.
435
+ */
436
+ export function formatEnableReport(r, input) {
437
+ const activationFailed = !!r.loadResult && r.loadResult.code !== 0;
438
+ const credLine = input.adminPassFile
439
+ ? ` Credential: ${input.adminPassFile} ${"(path only — the password is never written into the unit)"}`
440
+ : ` Credential: none configured — sync will use the CLI's default auth resolution`;
441
+ if (activationFailed) {
442
+ const lr = r.loadResult;
443
+ const lines = [
444
+ `⚠️ Federation sync driver files written but NOT activated (${r.platform})`,
445
+ ` Interval: every ${r.intervalSeconds}s — NOT scheduled (see below)`,
446
+ ` Scheduler: ${r.schedulerPath}`,
447
+ ` Shim: ${r.shimPath}`,
448
+ credLine,
449
+ ];
450
+ if (input.target)
451
+ lines.push(` Target: ${input.target}`);
452
+ lines.push(` Activation: ${r.loadCommand.join(" ")} → code ${lr.code}`);
453
+ if (lr.stderr)
454
+ lines.push(` stderr: ${lr.stderr.trim()}`);
455
+ const remedy = describeLoadFailureFor(r.platform, lr, "flair federation sync enable");
456
+ lines.push("");
457
+ lines.push(remedy ? ` ${remedy}` : ` Re-run the activation command above manually to see the full diagnostic.`);
458
+ lines.push("");
459
+ lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair federation sync status`);
460
+ return { lines, ok: false };
461
+ }
462
+ const lines = [
463
+ `✅ Federation sync driver enabled (${r.platform})`,
464
+ ` Interval: every ${r.intervalSeconds}s`,
465
+ ` Scheduler: ${r.schedulerPath}`,
466
+ ` Shim: ${r.shimPath}`,
467
+ credLine,
468
+ ];
469
+ if (input.target)
470
+ lines.push(` Target: ${input.target}`);
471
+ if (r.loadResult)
472
+ lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
473
+ lines.push("");
474
+ lines.push(`The first sync runs immediately. Confirm with \`flair federation status\`,`);
475
+ lines.push(`which now reports whether anything is actually driving sync.`);
476
+ lines.push(`Disable with \`flair federation sync disable\`.`);
477
+ return { lines, ok: true };
478
+ }
479
+ /** Formats the `flair federation sync status` report. */
480
+ export function formatStatusReport(s, a) {
481
+ const activeTxt = s.active === true ? "yes" : s.active === false ? "no" : "unknown";
482
+ const lines = [
483
+ `Federation sync driver (${s.platform}):`,
484
+ ` Active: ${activeTxt}`,
485
+ ` Installed: ${s.installed ? "yes" : "no"}`,
486
+ ` Interval: ${s.intervalSeconds ? `every ${s.intervalSeconds}s` : "—"}`,
487
+ ` Scheduler: ${s.schedulerPath}`,
488
+ ` Shim: ${s.shimPath}${s.shimExists ? "" : " (missing)"}`,
489
+ "",
490
+ ` ${a.headline}`,
491
+ ` ${a.detail}`,
492
+ ];
493
+ if (a.remedy) {
494
+ lines.push("");
495
+ lines.push(` Run: ${a.remedy}`);
496
+ }
497
+ // Status is informational — it does not itself signal process failure.
498
+ // `ok` reflects only whether the headline claims a working driver.
499
+ return { lines, ok: a.verdict !== "driver-stalled" && a.verdict !== "driver-inactive" && a.verdict !== "no-driver" };
500
+ }