@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.
- package/README.md +194 -377
- package/dist/cli.js +1355 -281
- package/dist/deploy.js +212 -24
- package/dist/fabric-upgrade.js +16 -1
- package/dist/federation/scheduler.js +500 -0
- package/dist/install/clients.js +111 -53
- 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 +35 -87
- package/dist/rem/snapshot.js +13 -0
- package/dist/replication-convergence.js +505 -0
- package/dist/resources/MemoryBootstrap.js +7 -8
- package/dist/resources/SemanticSearch.js +17 -45
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/embeddings-boot.js +10 -12
- package/dist/resources/embeddings-provider.js +10 -7
- package/dist/resources/health.js +24 -19
- package/dist/resources/in-process.js +225 -0
- package/dist/resources/mcp-tools.js +23 -17
- package/dist/resources/migration-boot.js +80 -10
- package/dist/resources/migrations/data-dir.js +205 -0
- package/dist/resources/migrations/progress.js +33 -0
- package/dist/resources/migrations/runner.js +29 -2
- package/dist/resources/migrations/state.js +13 -2
- package/dist/resources/models-dir.js +18 -9
- package/dist/resources/semantic-retrieval-core.js +5 -4
- package/dist/src/lib/scheduler-platform.js +128 -0
- package/dist/src/lib/xml-escape.js +54 -0
- package/dist/src/rem/scheduler.js +35 -87
- package/docs/deploying-on-fabric.md +267 -0
- package/docs/deployment.md +5 -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 +36 -0
- package/docs/troubleshooting.md +24 -0
- package/docs/upgrade.md +98 -3
- package/package.json +1 -11
- 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
|
@@ -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
|
+
}
|