@tpsdev-ai/flair 0.51.0 → 0.51.1

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/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
- // Fallback: find process by port. Listening sockets only, never our own
11054
- // PID see parseListeningPids (flair#800/flair#905): this used to SIGTERM
11055
- // every process holding ANY socket on the port, so `flair stop` could kill
11056
- // itself (leaving Flair running) or kill an unrelated client of it.
11057
- //
11058
- // Attribution guard (flair#915): the port is not an identity. Refuse to
11059
- // SIGTERM a PID that cannot be attributed to this instance.
11060
- try {
11061
- const { execSync } = await import("node:child_process");
11062
- const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
11063
- if (pids.length > 0) {
11064
- const dataDir = defaultDataDir();
11065
- const harperPid = readHarperPid(dataDir);
11066
- if (harperPid !== null && !pids.includes(harperPid)) {
11067
- console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
11068
- + `do not match this Flair instance (PID ${harperPid}). `
11069
- + `Not stopping — cannot attribute the process to this instance. `
11070
- + `Stop the process manually if it is not Flair.`);
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
- else if (harperPid === null) {
11074
- console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
11075
- + `but no PID file in data directory — not a running Flair instance. `
11076
- + `Not stopping — cannot attribute the process to this instance. `
11077
- + `Stop the process manually if it is not Flair.`);
11078
- process.exit(1);
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
- for (const pid of pids) {
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
- else {
11333
+ case "NOT_RUNNING":
11091
11334
  console.log("Flair is not running.");
11092
- }
11093
- }
11094
- catch {
11095
- console.log("Flair is not running (nothing found on port " + port + ").");
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: detectClients().filter((c) => c.detected).map((c) => c.id),
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 an empty
11458
- * `lsof` match).
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 / assertPortInstanceOwnedBy. Callers
11462
- * already treat a failed stop as fatal, which is the point: refusing beats
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 { execSync } = await import("node:child_process");
11538
- // -sTCP:LISTEN plus a self-PID guard, both inside listeningPidsOnPort: a bare
11539
- // `lsof -ti :port` also matches CLIENT sockets referencing the port, including
11540
- // THIS CLI's own keep-alive connections left by the credential pre-flight's
11541
- // probeInstance() HTTP calls (flair#741). Without the filter, the upgrade
11542
- // path SIGTERM'd its own process mid-restart"Stopping..." then death
11543
- // (exit 143) before "Starting..." ever ran, leaving the server down
11544
- // (flair#800, deterministic on the Linux/non-launchd default path).
11545
- // flair#905 moved both halves into that one helper because the same
11546
- // unfiltered pattern had survived in `flair stop`, `flair uninstall` and
11547
- // `flair doctor` — one guarded resolver is what keeps the next site honest.
11548
- // It returns [] when lsof matches nothing, which is this path's "not running".
11549
- const targets = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
11550
- if (targets.length === 0)
11551
- return;
11552
- // Deliberately outside any catch: a refusal must reach the caller, not be
11553
- // swallowed as "not running" and reported as a successful stop.
11554
- assertPortInstanceOwnedBy(port, dataDir, targets);
11555
- for (const target of targets) {
11556
- try {
11557
- process.kill(target, "SIGTERM");
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 bothkilling 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
- catch { /* best-effort — the next start will surface the real problem */ }
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)")