@tpsdev-ai/flair 0.51.0 → 0.51.2
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/dist/build-info.json +3 -3
- package/dist/cli.js +443 -133
- package/dist/lib/daemon-liveness.js +207 -0
- package/dist/lib/upgrade-migrations.js +181 -0
- package/dist/resources/MemoryArchive.js +94 -0
- package/dist/resources/MemoryBootstrap.js +3 -1
- package/dist/resources/SemanticSearch.js +7 -2
- package/dist/resources/mcp-tools.js +106 -1
- package/dist/resources/record-types.js +3 -1
- package/package.json +8 -4
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import nacl from "tweetnacl";
|
|
4
4
|
import { load as parseYaml } from "js-yaml";
|
|
5
5
|
import * as render from "./render.js";
|
|
6
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, openSync, closeSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, constants as fsConstants, } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { join, resolve, sep, dirname } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -34,11 +34,13 @@ import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
|
|
|
34
34
|
import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
|
|
35
35
|
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
|
|
36
36
|
import { applyUpgradeHookConsent, catalogIssueDelta, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
|
|
37
|
+
import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartTimeToEpochMs, parsePsLstart, parseSidecarJson, } from "./lib/daemon-liveness.js";
|
|
37
38
|
// Value-only static import so `--interval`'s advertised default cannot drift
|
|
38
39
|
// from the one the scheduler actually validates against. The module itself is
|
|
39
40
|
// still loaded lazily at call time (the `await import()`s below) for the
|
|
40
41
|
// functions — this pulls in nothing but node builtins.
|
|
41
42
|
import { DEFAULT_INTERVAL_SECONDS as FEDERATION_SYNC_DEFAULT_INTERVAL } from "./federation/scheduler.js";
|
|
43
|
+
import { applyUpgradeMigrations } from "./lib/upgrade-migrations.js";
|
|
42
44
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
43
45
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
44
46
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -3468,6 +3470,11 @@ program
|
|
|
3468
3470
|
console.log(`Starting Harper on port ${httpPort}...`);
|
|
3469
3471
|
const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
|
|
3470
3472
|
proc.unref();
|
|
3473
|
+
// flair#1454: write the identity sidecar immediately after spawn so
|
|
3474
|
+
// `flair stop` and `flair status` can classify this daemon's state
|
|
3475
|
+
// without lsof. Same call as startFlairProcess() uses.
|
|
3476
|
+
if (proc.pid)
|
|
3477
|
+
writeDaemonSidecar(dataDir, proc.pid, httpPort);
|
|
3471
3478
|
}
|
|
3472
3479
|
console.log("Waiting for Harper health check...");
|
|
3473
3480
|
await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
|
|
@@ -10985,6 +10992,8 @@ program
|
|
|
10985
10992
|
management,
|
|
10986
10993
|
port,
|
|
10987
10994
|
installHooksFlag: !!opts.installHooks,
|
|
10995
|
+
fromVersion: previousFlairVersion,
|
|
10996
|
+
toVersion: expectedFlairVersion,
|
|
10988
10997
|
});
|
|
10989
10998
|
printVerifiedSummary(renderVerifiedSummary(verify.version, run));
|
|
10990
10999
|
return;
|
|
@@ -11004,6 +11013,8 @@ program
|
|
|
11004
11013
|
management,
|
|
11005
11014
|
port,
|
|
11006
11015
|
installHooksFlag: !!opts.installHooks,
|
|
11016
|
+
fromVersion: previousFlairVersion,
|
|
11017
|
+
toVersion: expectedFlairVersion,
|
|
11007
11018
|
});
|
|
11008
11019
|
const versionNote = expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : "";
|
|
11009
11020
|
if (run.healthy) {
|
|
@@ -11025,6 +11036,240 @@ program
|
|
|
11025
11036
|
}
|
|
11026
11037
|
await rollbackTo(verdict.toVersion, verdict.reason);
|
|
11027
11038
|
});
|
|
11039
|
+
/**
|
|
11040
|
+
* Read a file with O_NOFOLLOW so a symlink planted at the pidfile/sidecar path
|
|
11041
|
+
* cannot redirect the read (flair#1454 decision 6). `readFileSync` has no such
|
|
11042
|
+
* flag, so this opens the fd first and reads from it.
|
|
11043
|
+
*/
|
|
11044
|
+
function readFileNoFollow(path) {
|
|
11045
|
+
let fd;
|
|
11046
|
+
try {
|
|
11047
|
+
fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
11048
|
+
}
|
|
11049
|
+
catch (err) {
|
|
11050
|
+
if (err?.code === "ENOENT")
|
|
11051
|
+
return { kind: "absent" };
|
|
11052
|
+
if (err?.code === "ELOOP")
|
|
11053
|
+
return { kind: "unreadable", reason: `${path} is a symbolic link` };
|
|
11054
|
+
return { kind: "unreadable", reason: `cannot open ${path}: ${err?.code ?? err?.message}` };
|
|
11055
|
+
}
|
|
11056
|
+
try {
|
|
11057
|
+
return { kind: "present", content: readFileSync(fd, "utf-8") };
|
|
11058
|
+
}
|
|
11059
|
+
catch (err) {
|
|
11060
|
+
return { kind: "unreadable", reason: `cannot read ${path}: ${err?.code ?? err?.message}` };
|
|
11061
|
+
}
|
|
11062
|
+
finally {
|
|
11063
|
+
try {
|
|
11064
|
+
closeSync(fd);
|
|
11065
|
+
}
|
|
11066
|
+
catch { /* already closed */ }
|
|
11067
|
+
}
|
|
11068
|
+
}
|
|
11069
|
+
/**
|
|
11070
|
+
* Refuse to trust a data dir that is a symlink or world-writable (flair#1454
|
|
11071
|
+
* decision 6). Returns a reason, or null when the dir is safe (or absent — a
|
|
11072
|
+
* missing dir is "no data", not "unsafe"; the pidfile read reports absent).
|
|
11073
|
+
*/
|
|
11074
|
+
function checkDataDirSafe(dataDir) {
|
|
11075
|
+
let lst;
|
|
11076
|
+
try {
|
|
11077
|
+
lst = lstatSync(dataDir);
|
|
11078
|
+
}
|
|
11079
|
+
catch {
|
|
11080
|
+
return null;
|
|
11081
|
+
}
|
|
11082
|
+
if (lst.isSymbolicLink()) {
|
|
11083
|
+
return `data directory ${dataDir} is a symbolic link — refusing to trust its pidfile`;
|
|
11084
|
+
}
|
|
11085
|
+
let st;
|
|
11086
|
+
try {
|
|
11087
|
+
st = statSync(dataDir);
|
|
11088
|
+
}
|
|
11089
|
+
catch {
|
|
11090
|
+
return null;
|
|
11091
|
+
}
|
|
11092
|
+
if (st.mode & 0o002) {
|
|
11093
|
+
return `data directory ${dataDir} is world-writable — refusing to trust its pidfile`;
|
|
11094
|
+
}
|
|
11095
|
+
return null;
|
|
11096
|
+
}
|
|
11097
|
+
/** Read `hdb.pid` (O_NOFOLLOW) into a `PidfileRead`. */
|
|
11098
|
+
function readPidfile(dataDir) {
|
|
11099
|
+
const r = readFileNoFollow(join(dataDir, "hdb.pid"));
|
|
11100
|
+
if (r.kind !== "present")
|
|
11101
|
+
return r;
|
|
11102
|
+
const n = Number(r.content.trim());
|
|
11103
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
11104
|
+
return { kind: "unreadable", reason: `${join(dataDir, "hdb.pid")} does not contain a valid pid` };
|
|
11105
|
+
}
|
|
11106
|
+
return { kind: "present", pid: n };
|
|
11107
|
+
}
|
|
11108
|
+
/** Read `flair-daemon.json` (O_NOFOLLOW) into a `SidecarRead`. */
|
|
11109
|
+
function readSidecar(dataDir) {
|
|
11110
|
+
const r = readFileNoFollow(join(dataDir, "flair-daemon.json"));
|
|
11111
|
+
if (r.kind !== "present")
|
|
11112
|
+
return r;
|
|
11113
|
+
const parsed = parseSidecarJson(r.content);
|
|
11114
|
+
if (parsed === null) {
|
|
11115
|
+
return { kind: "unreadable", reason: `${join(dataDir, "flair-daemon.json")} is malformed` };
|
|
11116
|
+
}
|
|
11117
|
+
return { kind: "present", ...parsed };
|
|
11118
|
+
}
|
|
11119
|
+
/** `kill(pid, 0)` as a three-way: alive / gone (ESRCH) / eperm (another user's). */
|
|
11120
|
+
function probePidLiveness(pid) {
|
|
11121
|
+
try {
|
|
11122
|
+
process.kill(pid, 0);
|
|
11123
|
+
return { kind: "alive" };
|
|
11124
|
+
}
|
|
11125
|
+
catch (err) {
|
|
11126
|
+
if (err?.code === "ESRCH")
|
|
11127
|
+
return { kind: "gone" };
|
|
11128
|
+
if (err?.code === "EPERM")
|
|
11129
|
+
return { kind: "eperm" };
|
|
11130
|
+
return { kind: "gone" };
|
|
11131
|
+
}
|
|
11132
|
+
}
|
|
11133
|
+
/**
|
|
11134
|
+
* The live process's start time in epoch ms, or null when it cannot be read.
|
|
11135
|
+
* Linux reads `/proc/<pid>/stat` field 22 (starttime in clock ticks) plus
|
|
11136
|
+
* `/proc/uptime`; macOS shells out to `ps -o lstart=`. A null answer degrades
|
|
11137
|
+
* to "identity unverified" — it never decides a verdict toward the destructive
|
|
11138
|
+
* branch (flair#1454 decision 4).
|
|
11139
|
+
*/
|
|
11140
|
+
function readProcessStartTimeMs(pid) {
|
|
11141
|
+
if (process.platform === "linux") {
|
|
11142
|
+
try {
|
|
11143
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
11144
|
+
const starttime = parseProcStatStartTime(stat);
|
|
11145
|
+
if (starttime === null)
|
|
11146
|
+
return null;
|
|
11147
|
+
const uptimeRaw = readFileSync("/proc/uptime", "utf-8").trim().split(/\s+/)[0];
|
|
11148
|
+
const uptime = Number(uptimeRaw);
|
|
11149
|
+
if (!Number.isFinite(uptime))
|
|
11150
|
+
return null;
|
|
11151
|
+
return procStartTimeToEpochMs(starttime, uptime, Date.now());
|
|
11152
|
+
}
|
|
11153
|
+
catch {
|
|
11154
|
+
return null;
|
|
11155
|
+
}
|
|
11156
|
+
}
|
|
11157
|
+
if (process.platform === "darwin") {
|
|
11158
|
+
try {
|
|
11159
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
11160
|
+
encoding: "utf-8",
|
|
11161
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
11162
|
+
timeout: 2000,
|
|
11163
|
+
});
|
|
11164
|
+
return parsePsLstart(out);
|
|
11165
|
+
}
|
|
11166
|
+
catch {
|
|
11167
|
+
return null;
|
|
11168
|
+
}
|
|
11169
|
+
}
|
|
11170
|
+
return null;
|
|
11171
|
+
}
|
|
11172
|
+
/** The health probe, three-way: ok / refused (ECONNREFUSED) / unreachable. */
|
|
11173
|
+
async function probeHealth(port) {
|
|
11174
|
+
try {
|
|
11175
|
+
await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(2000) });
|
|
11176
|
+
return { kind: "ok" };
|
|
11177
|
+
}
|
|
11178
|
+
catch (err) {
|
|
11179
|
+
// Node's undici fetch reports ECONNREFUSED on `err.cause.code`; Bun reports
|
|
11180
|
+
// `ConnectionRefused` on `err.code`. Both mean "nothing is listening".
|
|
11181
|
+
const code = err?.cause?.code ?? err?.code;
|
|
11182
|
+
if (code === "ECONNREFUSED" || code === "ConnectionRefused") {
|
|
11183
|
+
return { kind: "refused" };
|
|
11184
|
+
}
|
|
11185
|
+
return { kind: "unreachable" };
|
|
11186
|
+
}
|
|
11187
|
+
}
|
|
11188
|
+
/** Gather every piece of evidence the classifier needs, in one place. */
|
|
11189
|
+
async function gatherDaemonEvidence(port, dataDir) {
|
|
11190
|
+
const dataDirUnsafe = checkDataDirSafe(dataDir);
|
|
11191
|
+
const pidfile = readPidfile(dataDir);
|
|
11192
|
+
const pidLiveness = pidfile.kind === "present" ? probePidLiveness(pidfile.pid) : null;
|
|
11193
|
+
let sidecar = readSidecar(dataDir);
|
|
11194
|
+
// Probe health first so the self-heal gate below can use it without a
|
|
11195
|
+
// second round-trip. Also consumed at the end for the classifier.
|
|
11196
|
+
const health = await probeHealth(port);
|
|
11197
|
+
// flair#1454 self-heal: a daemon started by a pre-sidecar version of flair
|
|
11198
|
+
// (upgrade-across-#1454) has a live pid in hdb.pid but no flair-daemon.json.
|
|
11199
|
+
// Without this path, classifyDaemonState returns DISAGREEMENT and `flair stop`
|
|
11200
|
+
// refuses — breaking the upgrade flow for every existing user.
|
|
11201
|
+
//
|
|
11202
|
+
// SECURITY: the real guard is /Health, NOT the ±2s start-time check.
|
|
11203
|
+
// The ±2s check is circular in the self-heal path: we write the sidecar
|
|
11204
|
+
// with the live process's OWN start time, then verifyIdentity reads the
|
|
11205
|
+
// same process — it matches by construction for ANY live pid, including a
|
|
11206
|
+
// recycled pid belonging to an unrelated process. Requiring /Health OK is
|
|
11207
|
+
// the correct proof: only the flair daemon responds 200 at
|
|
11208
|
+
// http://127.0.0.1:<port>/Health. An unrelated recycled pid does not.
|
|
11209
|
+
//
|
|
11210
|
+
// Therefore: self-heal is gated on health.kind === "ok". A live pid that
|
|
11211
|
+
// does NOT serve /Health — a recycled pid, a wedged pre-#1454 daemon that
|
|
11212
|
+
// can no longer respond — is left as DISAGREEMENT (refuse to signal).
|
|
11213
|
+
// Never WEDGED, never SIGTERM, on an unverified pid.
|
|
11214
|
+
//
|
|
11215
|
+
// The write uses the same O_NOFOLLOW / 0600 / atomic-rename posture as every
|
|
11216
|
+
// other sidecar write. We skip self-heal when the dataDir is unsafe
|
|
11217
|
+
// (symlink / world-writable) — the check has already happened above.
|
|
11218
|
+
if (sidecar.kind === "absent" &&
|
|
11219
|
+
dataDirUnsafe === null &&
|
|
11220
|
+
pidfile.kind === "present" &&
|
|
11221
|
+
pidLiveness?.kind === "alive" &&
|
|
11222
|
+
health.kind === "ok" // ← the real proof: only flair serves this
|
|
11223
|
+
) {
|
|
11224
|
+
const pid = pidfile.pid;
|
|
11225
|
+
const startTimeMs = readProcessStartTimeMs(pid);
|
|
11226
|
+
if (startTimeMs !== null) {
|
|
11227
|
+
try {
|
|
11228
|
+
writeDaemonSidecar(dataDir, pid, port, startTimeMs);
|
|
11229
|
+
// Re-read: now that the sidecar exists, classify through the normal path.
|
|
11230
|
+
sidecar = readSidecar(dataDir);
|
|
11231
|
+
}
|
|
11232
|
+
catch {
|
|
11233
|
+
// Self-heal is best-effort. If the write fails (e.g. read-only dataDir),
|
|
11234
|
+
// we proceed with sidecar === absent and fall through to DISAGREEMENT
|
|
11235
|
+
// — the same outcome as before the self-heal path, so no regression.
|
|
11236
|
+
}
|
|
11237
|
+
}
|
|
11238
|
+
}
|
|
11239
|
+
const identity = verifyIdentity({
|
|
11240
|
+
pidfilePid: pidfile.kind === "present" ? pidfile.pid : null,
|
|
11241
|
+
sidecar,
|
|
11242
|
+
readStartTime: readProcessStartTimeMs,
|
|
11243
|
+
});
|
|
11244
|
+
return { dataDirUnsafe, pidfile, pidLiveness, identity, health };
|
|
11245
|
+
}
|
|
11246
|
+
/**
|
|
11247
|
+
* Write the identity sidecar atomically (temp + rename) at spawn time or
|
|
11248
|
+
* during self-heal (flair#1454 decision 3). `pid` is the spawned process's
|
|
11249
|
+
* pid — the same number Harper writes to `hdb.pid`, since Harper runs
|
|
11250
|
+
* in-process. `startTimeMs` defaults to `Date.now()` for a fresh spawn.
|
|
11251
|
+
*
|
|
11252
|
+
* Self-heal callers pass the live process's actual start time (from
|
|
11253
|
+
* readProcessStartTimeMs) so the sidecar records an accurate epoch, not a
|
|
11254
|
+
* wall-clock approximation. Note: in the self-heal path the ±2s start-time
|
|
11255
|
+
* check in verifyIdentity is NOT what prevents recycled-pid adoption —
|
|
11256
|
+
* that guard is the /Health probe that the self-heal caller already required
|
|
11257
|
+
* before reaching this point. The start time is recorded faithfully for
|
|
11258
|
+
* forward compatibility and audit, not as a security gate here.
|
|
11259
|
+
*/
|
|
11260
|
+
function writeDaemonSidecar(dataDir, pid, port, startTimeMs = Date.now()) {
|
|
11261
|
+
const sidecar = { pid, startTimeMs, port, flairVersion: __pkgVersion };
|
|
11262
|
+
const tmpPath = join(dataDir, `.flair-daemon.json.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
11263
|
+
// Write with mode 0600 so the tmp file is never world-readable (flair#1454
|
|
11264
|
+
// decision 6 — same posture as admin-pass and key material).
|
|
11265
|
+
writeFileSync(tmpPath, JSON.stringify(sidecar, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
11266
|
+
const finalPath = join(dataDir, "flair-daemon.json");
|
|
11267
|
+
renameSync(tmpPath, finalPath);
|
|
11268
|
+
// Re-assert 0600 after the rename: rename preserves the tmp permissions but
|
|
11269
|
+
// a pre-existing file at the destination retains its original mode on some
|
|
11270
|
+
// kernels. An explicit chmod is the only guarantee (flair#1454 decision 6).
|
|
11271
|
+
chmodSync(finalPath, 0o600);
|
|
11272
|
+
}
|
|
11028
11273
|
// ─── flair stop ───────────────────────────────────────────────────────────────
|
|
11029
11274
|
program
|
|
11030
11275
|
.command("stop")
|
|
@@ -11050,49 +11295,55 @@ program
|
|
|
11050
11295
|
}
|
|
11051
11296
|
}
|
|
11052
11297
|
}
|
|
11053
|
-
//
|
|
11054
|
-
//
|
|
11055
|
-
//
|
|
11056
|
-
//
|
|
11057
|
-
//
|
|
11058
|
-
//
|
|
11059
|
-
|
|
11060
|
-
|
|
11061
|
-
|
|
11062
|
-
|
|
11063
|
-
|
|
11064
|
-
|
|
11065
|
-
|
|
11066
|
-
|
|
11067
|
-
|
|
11068
|
-
|
|
11069
|
-
|
|
11070
|
-
|
|
11071
|
-
process.exit(1);
|
|
11298
|
+
// Non-launchd: the five-state liveness machine (flair#1454). The old
|
|
11299
|
+
// decision tree (launchd -> lsof -> "not running") is REPLACED, not
|
|
11300
|
+
// patched: `lsof` absence used to render as a definite "not running", and
|
|
11301
|
+
// the pidfile was only consulted to attribute port-derived PIDs. Now the
|
|
11302
|
+
// pidfile + identity sidecar are the primary evidence, and the health
|
|
11303
|
+
// probe is a cross-check — never the verdict.
|
|
11304
|
+
const dataDir = defaultDataDir();
|
|
11305
|
+
const evidence = await gatherDaemonEvidence(port, dataDir);
|
|
11306
|
+
const state = classifyDaemonState(evidence, { port, dataDir });
|
|
11307
|
+
switch (state.state) {
|
|
11308
|
+
case "RUNNING":
|
|
11309
|
+
case "WEDGED": {
|
|
11310
|
+
// Identity is already proven for both of these — killing a wedged
|
|
11311
|
+
// daemon is recovery, not a recycled-PID gamble.
|
|
11312
|
+
const pid = state.pid;
|
|
11313
|
+
const label = state.state === "WEDGED" ? "wedged daemon" : "daemon";
|
|
11314
|
+
try {
|
|
11315
|
+
process.kill(pid, "SIGTERM");
|
|
11072
11316
|
}
|
|
11073
|
-
|
|
11074
|
-
|
|
11075
|
-
|
|
11076
|
-
|
|
11077
|
-
|
|
11078
|
-
|
|
11317
|
+
catch (err) {
|
|
11318
|
+
if (err?.code !== "ESRCH") {
|
|
11319
|
+
console.error(`❌ failed to signal pid ${pid}: ${err?.code ?? err?.message}`);
|
|
11320
|
+
process.exit(1);
|
|
11321
|
+
}
|
|
11322
|
+
}
|
|
11323
|
+
await waitForProcessExit(pid, STARTUP_TIMEOUT_MS);
|
|
11324
|
+
const after = await probeHealth(port);
|
|
11325
|
+
if (after.kind === "refused") {
|
|
11326
|
+
console.log(`✅ Flair stopped (${label}, pid ${pid})`);
|
|
11079
11327
|
}
|
|
11080
11328
|
else {
|
|
11081
|
-
|
|
11082
|
-
try {
|
|
11083
|
-
process.kill(pid, "SIGTERM");
|
|
11084
|
-
}
|
|
11085
|
-
catch { /* already gone */ }
|
|
11086
|
-
}
|
|
11087
|
-
console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
|
|
11329
|
+
console.log(`✅ Flair stopped (${label}, pid ${pid}; port ${port} may still be releasing)`);
|
|
11088
11330
|
}
|
|
11331
|
+
return;
|
|
11089
11332
|
}
|
|
11090
|
-
|
|
11333
|
+
case "NOT_RUNNING":
|
|
11091
11334
|
console.log("Flair is not running.");
|
|
11092
|
-
|
|
11093
|
-
|
|
11094
|
-
|
|
11095
|
-
|
|
11335
|
+
return;
|
|
11336
|
+
case "DISAGREEMENT":
|
|
11337
|
+
console.error(`⚠️ ${state.detail}`);
|
|
11338
|
+
console.error(` Not stopping — the evidence conflicts.`);
|
|
11339
|
+
console.error(` pidfile: ${join(dataDir, "hdb.pid")}`);
|
|
11340
|
+
console.error(` port: ${port}`);
|
|
11341
|
+
console.error(` To inspect: flair doctor`);
|
|
11342
|
+
process.exit(1);
|
|
11343
|
+
case "UNKNOWN":
|
|
11344
|
+
console.error(`⚠️ ${state.detail}`);
|
|
11345
|
+
console.error(` Not stopping — could not determine whether Flair is running.`);
|
|
11346
|
+
process.exit(1);
|
|
11096
11347
|
}
|
|
11097
11348
|
});
|
|
11098
11349
|
// ─── flair start ──────────────────────────────────────────────────────────────
|
|
@@ -11102,16 +11353,35 @@ program
|
|
|
11102
11353
|
.option("--port <port>", "Harper HTTP port")
|
|
11103
11354
|
.action(async (opts) => {
|
|
11104
11355
|
const port = resolveHttpPort(opts);
|
|
11105
|
-
// Check if already running
|
|
11106
|
-
try {
|
|
11107
|
-
const res = await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(2000) });
|
|
11108
|
-
if (res.status > 0) {
|
|
11109
|
-
console.log(`Flair is already running on port ${port}.`);
|
|
11110
|
-
return;
|
|
11111
|
-
}
|
|
11112
|
-
}
|
|
11113
|
-
catch { /* not running — good */ }
|
|
11114
11356
|
const dataDir = defaultDataDir();
|
|
11357
|
+
// Already-running check via the five-state liveness machine (flair#1454).
|
|
11358
|
+
// The old check was a bare `fetch /Health` that treated "got a response"
|
|
11359
|
+
// as "already running" and exited 0 — half of #1454. Now the machine
|
|
11360
|
+
// classifies, and every non-NOT_RUNNING state refuses with a non-zero exit.
|
|
11361
|
+
const evidence = await gatherDaemonEvidence(port, dataDir);
|
|
11362
|
+
const state = classifyDaemonState(evidence, { port, dataDir });
|
|
11363
|
+
switch (state.state) {
|
|
11364
|
+
case "NOT_RUNNING":
|
|
11365
|
+
break; // proceed to boot
|
|
11366
|
+
case "RUNNING":
|
|
11367
|
+
console.error(`Flair is already running on port ${port} (pid ${state.pid}).`);
|
|
11368
|
+
process.exit(1);
|
|
11369
|
+
case "WEDGED":
|
|
11370
|
+
console.error(`⚠️ A wedged Flair daemon (pid ${state.pid}) is holding port ${port}.`);
|
|
11371
|
+
console.error(` Run 'flair stop' first — never start over a live pid.`);
|
|
11372
|
+
process.exit(1);
|
|
11373
|
+
case "DISAGREEMENT":
|
|
11374
|
+
console.error(`⚠️ ${state.detail}`);
|
|
11375
|
+
console.error(` Refusing to start — the evidence conflicts.`);
|
|
11376
|
+
console.error(` pidfile: ${join(dataDir, "hdb.pid")}`);
|
|
11377
|
+
console.error(` port: ${port}`);
|
|
11378
|
+
console.error(` To inspect: flair doctor`);
|
|
11379
|
+
process.exit(1);
|
|
11380
|
+
case "UNKNOWN":
|
|
11381
|
+
console.error(`⚠️ ${state.detail}`);
|
|
11382
|
+
console.error(` Refusing to start — could not determine whether Flair is running.`);
|
|
11383
|
+
process.exit(1);
|
|
11384
|
+
}
|
|
11115
11385
|
if (!existsSync(dataDir)) {
|
|
11116
11386
|
console.error("❌ No Flair data directory found. Run 'flair init' first.");
|
|
11117
11387
|
process.exit(1);
|
|
@@ -11189,6 +11459,12 @@ program
|
|
|
11189
11459
|
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
11190
11460
|
});
|
|
11191
11461
|
proc.unref();
|
|
11462
|
+
// Write the identity sidecar immediately after spawn (flair#1454 decision
|
|
11463
|
+
// 3) — BEFORE waitForHealth, so startTimeMs stays within the ±2s tolerance
|
|
11464
|
+
// of the process's real start time. `proc.pid` is the pid Harper writes to
|
|
11465
|
+
// hdb.pid, since Harper runs in-process.
|
|
11466
|
+
if (proc.pid)
|
|
11467
|
+
writeDaemonSidecar(dataDir, proc.pid, port);
|
|
11192
11468
|
try {
|
|
11193
11469
|
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
11194
11470
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
|
|
@@ -11251,51 +11527,6 @@ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
|
|
|
11251
11527
|
`Re-run with --data-dir ${resolve(declared)} to act on that one, or run ` +
|
|
11252
11528
|
`'flair init --data-dir ${resolve(dataDir)}' to register a service for this one.`);
|
|
11253
11529
|
}
|
|
11254
|
-
/**
|
|
11255
|
-
* Refuse a port-based SIGTERM that cannot be attributed to `dataDir`
|
|
11256
|
-
* (flair#902).
|
|
11257
|
-
*
|
|
11258
|
-
* The port fallback below identifies its target by port number and nothing
|
|
11259
|
-
* else, so `--data-dir <scratch>` with a port that scratch instance does not
|
|
11260
|
-
* serve signals whichever instance DOES serve it. That is the whole of this
|
|
11261
|
-
* bug on Linux, where there is no launchd path at all.
|
|
11262
|
-
*
|
|
11263
|
-
* Scoped deliberately to a non-default data dir. For the default install the
|
|
11264
|
-
* port genuinely is that instance's port by every convention in this CLI
|
|
11265
|
-
* (`writeConfig`/`readPortFromConfig`), and the only evidence available here
|
|
11266
|
-
* — `<dataDir>/hdb.pid` vs the listening PIDs — is not something we can
|
|
11267
|
-
* require without risking a false refusal on a working install whose PID file
|
|
11268
|
-
* is missing or whose listener is a worker. So the default path keeps today's
|
|
11269
|
-
* behavior exactly, and the residual gap is stated rather than papered over:
|
|
11270
|
-
* a default-dir port stop is still unattributed. The new refusal can only
|
|
11271
|
-
* fire for a caller that explicitly named another data dir — the case that is
|
|
11272
|
-
* wrong today whenever the port does not match.
|
|
11273
|
-
*/
|
|
11274
|
-
function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
|
|
11275
|
-
// (flair#915) Apply the attribution check for ALL data directories, not
|
|
11276
|
-
// just non-default ones. The default-dir bypass was the residual gap that
|
|
11277
|
-
// #910 left behind — it allowed an unattributed SIGTERM on the default
|
|
11278
|
-
// install's port. The old concern (false refusal when hdb.pid is missing)
|
|
11279
|
-
// is actually the RIGHT behavior: no PID file means we cannot attribute the
|
|
11280
|
-
// listener, so we refuse. That is safer than killing the wrong process.
|
|
11281
|
-
const expected = readHarperPid(dataDir);
|
|
11282
|
-
// No PID file — Harper is not (or was not) running in this directory.
|
|
11283
|
-
// The port is stale or held by something else; refuse to SIGTERM it.
|
|
11284
|
-
if (expected === null) {
|
|
11285
|
-
throw new Error(`refusing to stop the process listening on port ${port}: no hdb.pid under `
|
|
11286
|
-
+ `${resolve(dataDir)}, so that is not a running instance. `
|
|
11287
|
-
+ `Stopping by port alone would signal a process we cannot attribute. `
|
|
11288
|
-
+ `If it is not Flair, stop it manually.`);
|
|
11289
|
-
}
|
|
11290
|
-
// PID file exists — the PID on the port must be Harper.
|
|
11291
|
-
if (listeningPids.includes(expected))
|
|
11292
|
-
return;
|
|
11293
|
-
throw new Error(`refusing to stop the process listening on port ${port}: its recorded PID ${expected} `
|
|
11294
|
-
+ `is not the process listening on ${port}. `
|
|
11295
|
-
+ `Stopping by port alone would signal a different instance. `
|
|
11296
|
-
+ `Pass --port with the port ${resolve(dataDir)} actually serves, `
|
|
11297
|
-
+ `or stop the process manually.`);
|
|
11298
|
-
}
|
|
11299
11530
|
// ─── "is it still under launchd?" (flair#1022) ─────────────────────────────
|
|
11300
11531
|
//
|
|
11301
11532
|
// The pure logic lives in src/lib/launchd-management.ts; these two adapters
|
|
@@ -11361,14 +11592,48 @@ async function confirmYes(question) {
|
|
|
11361
11592
|
* yes is the only consent. The consent→write composition lives in
|
|
11362
11593
|
* applyUpgradeHookConsent so tests can drive the path that actually
|
|
11363
11594
|
* writes (or does not write) the hook file.
|
|
11595
|
+
*
|
|
11596
|
+
* Before the catalog run, `applyUpgradeMigrations` fires any version-keyed
|
|
11597
|
+
* migrations that are pending for the fromVersion→toVersion pair. These do
|
|
11598
|
+
* NOT require `--install-hooks` because the user already consented to the
|
|
11599
|
+
* affected integration when they ran `flair init` — the migration just
|
|
11600
|
+
* applies a new artifact that the old init could not have written.
|
|
11364
11601
|
*/
|
|
11365
11602
|
async function doctorRunAfterUpgrade(args) {
|
|
11366
11603
|
const homeDir = homedir();
|
|
11367
11604
|
const keysDir = defaultKeysDir();
|
|
11605
|
+
const detectedClientIds = detectClients().filter((c) => c.detected).map((c) => c.id);
|
|
11606
|
+
// ── Version-keyed upgrade migrations (flair#1439) ─────────────────────────
|
|
11607
|
+
// Apply any pending migrations BEFORE the doctor catalog run so that the
|
|
11608
|
+
// catalog sees the post-migration state (e.g. the hook is present, so the
|
|
11609
|
+
// session-start-hook check passes and the upgrade prints ✅ verified: healthy).
|
|
11610
|
+
const migCtx = {
|
|
11611
|
+
homeDir,
|
|
11612
|
+
port: args.port,
|
|
11613
|
+
detectedClientIds,
|
|
11614
|
+
};
|
|
11615
|
+
const migrations = applyUpgradeMigrations(args.fromVersion, args.toVersion, migCtx);
|
|
11616
|
+
for (const { results } of migrations.applied) {
|
|
11617
|
+
for (const r of results) {
|
|
11618
|
+
if (r.wrote) {
|
|
11619
|
+
console.log(` ✓ ${r.message}`);
|
|
11620
|
+
}
|
|
11621
|
+
else if (!r.ok) {
|
|
11622
|
+
console.error(` • ${r.message}`);
|
|
11623
|
+
}
|
|
11624
|
+
// Silently skip no-op (ok, !wrote) — nothing changed, nothing to say.
|
|
11625
|
+
}
|
|
11626
|
+
}
|
|
11627
|
+
if (!migrations.allOk) {
|
|
11628
|
+
// A migration reported a non-fatal issue (each is logged above). Say so
|
|
11629
|
+
// plainly rather than let the upcoming ✅ verified summary imply the upgrade
|
|
11630
|
+
// finished cleanly — the doctor catalog below reflects the real state.
|
|
11631
|
+
console.error(" • one or more upgrade migrations did not complete cleanly — the doctor check below shows the current state.");
|
|
11632
|
+
}
|
|
11368
11633
|
const ctx = {
|
|
11369
11634
|
homeDir,
|
|
11370
11635
|
cwd: process.cwd(),
|
|
11371
|
-
detectedClientIds
|
|
11636
|
+
detectedClientIds,
|
|
11372
11637
|
launchd: args.management,
|
|
11373
11638
|
keysDir,
|
|
11374
11639
|
keyAgentIds: collectKeyAgentIds(keysDir),
|
|
@@ -11454,13 +11719,13 @@ function observeLaunchdManagement(dataDir, port) {
|
|
|
11454
11719
|
* data dir does, via `resolveLaunchdLabel`.
|
|
11455
11720
|
*
|
|
11456
11721
|
* Idempotent-ish: stopping an already-stopped instance is a harmless no-op
|
|
11457
|
-
* on both paths (launchctl stop on an unloaded/idle service, or
|
|
11458
|
-
*
|
|
11722
|
+
* on both paths (launchctl stop on an unloaded/idle service, or a
|
|
11723
|
+
* NOT_RUNNING classification from the liveness machine).
|
|
11459
11724
|
*
|
|
11460
11725
|
* Throws when the resolved target provably belongs to a different instance
|
|
11461
|
-
* — see assertLaunchdServiceOwnedBy /
|
|
11462
|
-
* already treat a failed stop as fatal, which
|
|
11463
|
-
* quiescing the wrong install.
|
|
11726
|
+
* — see assertLaunchdServiceOwnedBy, or a DISAGREEMENT/UNKNOWN verdict from
|
|
11727
|
+
* the liveness machine. Callers already treat a failed stop as fatal, which
|
|
11728
|
+
* is the point: refusing beats quiescing the wrong install.
|
|
11464
11729
|
*/
|
|
11465
11730
|
async function stopFlairProcess(port, dataDir) {
|
|
11466
11731
|
if (process.platform === "darwin") {
|
|
@@ -11532,43 +11797,42 @@ async function stopFlairProcess(port, dataDir) {
|
|
|
11532
11797
|
}
|
|
11533
11798
|
}
|
|
11534
11799
|
}
|
|
11535
|
-
// Port-based stop (Linux, or macOS fallback when no launchd plist)
|
|
11800
|
+
// Port-based stop (Linux, or macOS fallback when no launchd plist) — the
|
|
11801
|
+
// five-state liveness machine (flair#1454). The old lsof-based tree is
|
|
11802
|
+
// REPLACED, not patched: `lsof` absence used to render as "not running", and
|
|
11803
|
+
// the pidfile was only consulted to attribute port-derived PIDs. Now the
|
|
11804
|
+
// pidfile + identity sidecar are the primary evidence, and the health probe
|
|
11805
|
+
// is a cross-check — never the verdict.
|
|
11536
11806
|
console.log("Stopping...");
|
|
11537
|
-
const
|
|
11538
|
-
|
|
11539
|
-
|
|
11540
|
-
|
|
11541
|
-
|
|
11542
|
-
|
|
11543
|
-
|
|
11544
|
-
|
|
11545
|
-
|
|
11546
|
-
|
|
11547
|
-
|
|
11548
|
-
|
|
11549
|
-
|
|
11550
|
-
|
|
11551
|
-
|
|
11552
|
-
|
|
11553
|
-
|
|
11554
|
-
|
|
11555
|
-
|
|
11556
|
-
|
|
11557
|
-
|
|
11558
|
-
}
|
|
11559
|
-
catch { }
|
|
11560
|
-
}
|
|
11561
|
-
// flair#905 / lrf5: wait for every signalled process to actually exit.
|
|
11562
|
-
// A blind 2-second sleep is not a guarantee — Harper may be flushing
|
|
11563
|
-
// RocksDB WAL/MANIFEST, and the next start will fail with a locked data
|
|
11564
|
-
// directory if the old process hasn't released it yet. The launchd path
|
|
11565
|
-
// above already does this via waitForProcessExit; the port-based path
|
|
11566
|
-
// must match that guarantee.
|
|
11567
|
-
for (const target of targets) {
|
|
11568
|
-
try {
|
|
11569
|
-
await waitForProcessExit(target, STARTUP_TIMEOUT_MS);
|
|
11807
|
+
const evidence = await gatherDaemonEvidence(port, dataDir);
|
|
11808
|
+
const state = classifyDaemonState(evidence, { port, dataDir });
|
|
11809
|
+
switch (state.state) {
|
|
11810
|
+
case "RUNNING":
|
|
11811
|
+
case "WEDGED": {
|
|
11812
|
+
// Identity is already proven for both — killing a wedged daemon is
|
|
11813
|
+
// recovery, not a recycled-PID gamble.
|
|
11814
|
+
const pid = state.pid;
|
|
11815
|
+
try {
|
|
11816
|
+
process.kill(pid, "SIGTERM");
|
|
11817
|
+
}
|
|
11818
|
+
catch { /* already gone */ }
|
|
11819
|
+
// flair#905 / lrf5: wait for the signalled process to actually exit. A
|
|
11820
|
+
// blind sleep is not a guarantee — Harper may be flushing RocksDB
|
|
11821
|
+
// WAL/MANIFEST, and the next start fails with a locked data directory if
|
|
11822
|
+
// the old process hasn't released it yet.
|
|
11823
|
+
try {
|
|
11824
|
+
await waitForProcessExit(pid, STARTUP_TIMEOUT_MS);
|
|
11825
|
+
}
|
|
11826
|
+
catch { /* best-effort — the next start will surface the real problem */ }
|
|
11827
|
+
return;
|
|
11570
11828
|
}
|
|
11571
|
-
|
|
11829
|
+
case "NOT_RUNNING":
|
|
11830
|
+
return; // idempotent no-op
|
|
11831
|
+
case "DISAGREEMENT":
|
|
11832
|
+
case "UNKNOWN":
|
|
11833
|
+
// Deliberately outside any catch: a refusal must reach the caller, not
|
|
11834
|
+
// be swallowed as "not running" and reported as a successful stop.
|
|
11835
|
+
throw new Error(`refusing to stop: ${state.detail}`);
|
|
11572
11836
|
}
|
|
11573
11837
|
}
|
|
11574
11838
|
/**
|
|
@@ -11697,6 +11961,10 @@ async function startFlairProcess(port, dataDir) {
|
|
|
11697
11961
|
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
11698
11962
|
});
|
|
11699
11963
|
proc.unref();
|
|
11964
|
+
// Identity sidecar immediately after spawn (flair#1454 decision 3), before
|
|
11965
|
+
// waitForHealth so startTimeMs stays within the ±2s tolerance.
|
|
11966
|
+
if (proc.pid)
|
|
11967
|
+
writeDaemonSidecar(dataDir, proc.pid, port);
|
|
11700
11968
|
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
11701
11969
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
|
|
11702
11970
|
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
@@ -15514,6 +15782,7 @@ memory.command("search [query]")
|
|
|
15514
15782
|
.option("--q <query>", "search query (alias for positional arg)")
|
|
15515
15783
|
.option("--limit <n>", "Max results", "5")
|
|
15516
15784
|
.option("--tag <tag>")
|
|
15785
|
+
.option("--include-archived", "Include basemented (archived) memories in results (default: excluded)")
|
|
15517
15786
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
15518
15787
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15519
15788
|
.option("--port <port>", "Harper HTTP port")
|
|
@@ -15531,10 +15800,51 @@ memory.command("search [query]")
|
|
|
15531
15800
|
const body = { agentId, q, limit: parseInt(opts.limit, 10) || 5 };
|
|
15532
15801
|
if (opts.tag)
|
|
15533
15802
|
body.tag = opts.tag;
|
|
15803
|
+
if (opts.includeArchived)
|
|
15804
|
+
body.includeArchived = true;
|
|
15534
15805
|
const baseUrl = resolveBaseUrl(opts);
|
|
15535
15806
|
const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
15536
15807
|
console.log(JSON.stringify(res, null, 2));
|
|
15537
15808
|
});
|
|
15809
|
+
// ─── flair memory basement / restore ────────────────────────────────────────
|
|
15810
|
+
// flair#1472 Deliverable A — the user-facing archive action. `basement` sends a
|
|
15811
|
+
// memory to the basement (archived=true + stamps archivedAt); `restore`
|
|
15812
|
+
// un-basements it (clears archived/archivedAt/archivedBy). Both are GLOBAL and
|
|
15813
|
+
// deliberate: restore un-retires the memory for EVERY session, not a
|
|
15814
|
+
// session-local view (per-session reuse is drawers, Deliverable B, which does
|
|
15815
|
+
// not exist yet). Scoped to the caller's own memories (own-lane write).
|
|
15816
|
+
memory.command("basement <id>")
|
|
15817
|
+
.description("Send a memory to the basement (archive it). Removes it from bootstrap + default search; still retrievable via `memory search --include-archived`. GLOBAL and deliberate — scoped to your own memories.")
|
|
15818
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
15819
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
15820
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15821
|
+
.option("--port <port>", "Harper HTTP port")
|
|
15822
|
+
.action(async (id, opts) => {
|
|
15823
|
+
const agentId = resolveSigningAgentId(opts, "memory basement");
|
|
15824
|
+
if (!agentId) {
|
|
15825
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15826
|
+
process.exit(2);
|
|
15827
|
+
}
|
|
15828
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
15829
|
+
const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId });
|
|
15830
|
+
console.log(JSON.stringify(res, null, 2));
|
|
15831
|
+
});
|
|
15832
|
+
memory.command("restore <id>")
|
|
15833
|
+
.description("Restore a basemented (archived) memory. Clears archived/archivedAt/archivedBy. GLOBAL and deliberate — this un-retires the memory for EVERY session, not a session-local view (per-session reuse is drawers, which do not exist yet). Scoped to your own memories.")
|
|
15834
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
15835
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
15836
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15837
|
+
.option("--port <port>", "Harper HTTP port")
|
|
15838
|
+
.action(async (id, opts) => {
|
|
15839
|
+
const agentId = resolveSigningAgentId(opts, "memory restore");
|
|
15840
|
+
if (!agentId) {
|
|
15841
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15842
|
+
process.exit(2);
|
|
15843
|
+
}
|
|
15844
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
15845
|
+
const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId });
|
|
15846
|
+
console.log(JSON.stringify(res, null, 2));
|
|
15847
|
+
});
|
|
15538
15848
|
memory.command("list")
|
|
15539
15849
|
.description("List an agent's memories (optionally filtered by --tag or embedding-backfill triage)")
|
|
15540
15850
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|