@tpsdev-ai/flair 0.34.0 → 0.36.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/config.yaml +17 -0
- package/dist/cli.js +136 -49
- package/dist/doctor-client.js +13 -1
- package/dist/engine-version.js +239 -0
- package/dist/resources/AdminInstance.js +7 -7
- package/dist/resources/Credential.js +55 -16
- package/dist/resources/Memory.js +8 -20
- package/dist/resources/MemoryFeed.js +40 -5
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/WorkspaceState.js +7 -16
- package/dist/resources/agent-auth.js +41 -2
- package/dist/resources/auth-middleware.js +65 -36
- package/dist/resources/mcp-oauth.js +47 -0
- package/dist/resources/record-type-kit.js +49 -0
- package/docs/upgrade.md +28 -9
- package/package.json +1 -1
package/config.yaml
CHANGED
|
@@ -81,3 +81,20 @@ authentication:
|
|
|
81
81
|
# own risk.
|
|
82
82
|
authorizeLocal: false
|
|
83
83
|
enableSessions: true
|
|
84
|
+
|
|
85
|
+
# @harperfast/oauth — authorization server component declaration.
|
|
86
|
+
# Commented out by default: the plugin is only needed when FLAIR_MCP_OAUTH=on.
|
|
87
|
+
# When you enable the flag without uncommenting this block, the boot guard
|
|
88
|
+
# (resources/mcp-oauth.ts) logs an error with the exact YAML to add — uncomment
|
|
89
|
+
# the block below and add mcp.* config. The issuer is derived at runtime from
|
|
90
|
+
# FLAIR_MCP_ISSUER / FLAIR_PUBLIC_URL; do not hardcode one here.
|
|
91
|
+
#
|
|
92
|
+
# "@harperfast/oauth":
|
|
93
|
+
# providers:
|
|
94
|
+
# default:
|
|
95
|
+
# authorizationEndpoint: "/OAuthAuthorize"
|
|
96
|
+
# tokenEndpoint: "/OAuthToken"
|
|
97
|
+
# revocationEndpoint: "/OAuthRevoke"
|
|
98
|
+
# registrationEndpoint: "/OAuthRegister"
|
|
99
|
+
# jwksUri: "/.well-known/jwks.json"
|
|
100
|
+
# discoveryEndpoint: "/.well-known/oauth-authorization-server"
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { deploy as deployToFabric, validateOptions as validateDeployOptions, bui
|
|
|
14
14
|
import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, assertNoSecretKeysAdded, describePublicUrlFinding, planComponentEnv, readEnvValue, } from "./component-env.js";
|
|
15
15
|
import { fabricUpgrade } from "./fabric-upgrade.js";
|
|
16
16
|
import { checkVersion, formatVersionNudge, primeVersionCheckCache, FLAIR_PKG_NAME } from "./version-check.js";
|
|
17
|
+
import { readInstalledHarperVersion, fetchDeclaredHarperVersion, writeEngineVersionStamp, checkEngineVersionBackwards, UPGRADE_SNAPSHOT_ROOT, } from "./engine-version.js";
|
|
17
18
|
import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache } from "./version-handshake.js";
|
|
18
19
|
import { probeInstance } from "./probe.js";
|
|
19
20
|
import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
|
|
@@ -8823,7 +8824,8 @@ async function runFabricUpgrade(opts) {
|
|
|
8823
8824
|
// standalone filesystem utility). Rejected in favor of the file-level
|
|
8824
8825
|
// snapshot below. See docs/upgrade.md for the restore procedure this
|
|
8825
8826
|
// produces.
|
|
8826
|
-
|
|
8827
|
+
// UPGRADE_SNAPSHOT_ROOT is defined in engine-version.ts (the module that owns the path)
|
|
8828
|
+
// and imported from there for all callers.
|
|
8827
8829
|
const UPGRADE_SNAPSHOT_RETAIN = 3;
|
|
8828
8830
|
function upgradeSnapshotFileName() {
|
|
8829
8831
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
@@ -8947,9 +8949,12 @@ export function pruneOldSnapshots(retain = UPGRADE_SNAPSHOT_RETAIN, snapshotRoot
|
|
|
8947
8949
|
}
|
|
8948
8950
|
return removed;
|
|
8949
8951
|
}
|
|
8950
|
-
export function decideUpgradeSnapshotAction(flairIsUpgrading, snapshotRequested, hasDataDir) {
|
|
8952
|
+
export function decideUpgradeSnapshotAction(flairIsUpgrading, snapshotRequested, hasDataDir, engineVersionChanging, engineSnapshotOptOut) {
|
|
8951
8953
|
if (!flairIsUpgrading)
|
|
8952
8954
|
return "not-upgrading";
|
|
8955
|
+
// Engine version change forces a snapshot unless explicitly opted out.
|
|
8956
|
+
if (engineVersionChanging && hasDataDir && !engineSnapshotOptOut)
|
|
8957
|
+
return "engine-version-change";
|
|
8953
8958
|
if (!snapshotRequested)
|
|
8954
8959
|
return hasDataDir ? "nudge" : "not-upgrading";
|
|
8955
8960
|
return hasDataDir ? "snapshot" : "no-data";
|
|
@@ -8967,6 +8972,75 @@ export const UPGRADE_SNAPSHOT_NUDGE_LINES = [
|
|
|
8967
8972
|
"No pre-upgrade snapshot will be taken.",
|
|
8968
8973
|
"To capture one first: `flair snapshot create` (physical) or `flair backup` (logical export), or re-run with --snapshot.",
|
|
8969
8974
|
];
|
|
8975
|
+
/**
|
|
8976
|
+
* Run the stop → snapshot → prune → restart dance for a pre-upgrade snapshot.
|
|
8977
|
+
* Extracted from the upgrade action so the --snapshot and engine-version-change
|
|
8978
|
+
* branches share the same mechanism (flair#1047).
|
|
8979
|
+
*
|
|
8980
|
+
* On snapshot failure: aborts the upgrade (process.exit(1)), restarting Flair
|
|
8981
|
+
* first if it was stopped. On restart-after-snapshot failure: also exits.
|
|
8982
|
+
*/
|
|
8983
|
+
async function runUpgradeSnapshot(port, dataDir) {
|
|
8984
|
+
// Consistency: a running Harper's data dir can be mid-write, and a
|
|
8985
|
+
// plain file copy of a live database directory isn't guaranteed
|
|
8986
|
+
// point-in-time consistent (Harper 5.x = RocksDB: WAL/SST/MANIFEST
|
|
8987
|
+
// can tear under a live copy). Stopping first — then immediately
|
|
8988
|
+
// restarting the OLD version, before any package changes — gives a
|
|
8989
|
+
// quiesced, safe-to-copy directory with only a brief blip, even for
|
|
8990
|
+
// --no-restart (the snapshot's correctness doesn't depend on
|
|
8991
|
+
// whether the caller wants a restart AFTER the upgrade — those are
|
|
8992
|
+
// orthogonal). See docs/upgrade.md for the native-backup alternative
|
|
8993
|
+
// considered and rejected (Harper's `get_backup` op backs up one
|
|
8994
|
+
// table/schema at a time over the running HTTP API — not the whole
|
|
8995
|
+
// data dir — and rejecting it here means this path never depends on
|
|
8996
|
+
// the server being up).
|
|
8997
|
+
let stoppedForSnapshot = false;
|
|
8998
|
+
let snapshotPath = null;
|
|
8999
|
+
try {
|
|
9000
|
+
await stopFlairProcess(port, dataDir);
|
|
9001
|
+
stoppedForSnapshot = true;
|
|
9002
|
+
const snapshot = await createDataSnapshot(dataDir);
|
|
9003
|
+
snapshotPath = snapshot.path;
|
|
9004
|
+
const removed = pruneOldSnapshots();
|
|
9005
|
+
console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
|
|
9006
|
+
console.log(` Restore: flair snapshot restore "${snapshotPath}"`);
|
|
9007
|
+
if (removed.length > 0) {
|
|
9008
|
+
console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
|
|
9009
|
+
}
|
|
9010
|
+
}
|
|
9011
|
+
catch (err) {
|
|
9012
|
+
console.error(`❌ snapshot failed: ${err.message}`);
|
|
9013
|
+
console.error(" Aborting upgrade — no packages were changed.");
|
|
9014
|
+
if (stoppedForSnapshot) {
|
|
9015
|
+
try {
|
|
9016
|
+
await startFlairProcess(port, dataDir);
|
|
9017
|
+
}
|
|
9018
|
+
catch { /* best effort — surface the original snapshot error, not this */ }
|
|
9019
|
+
}
|
|
9020
|
+
process.exit(1);
|
|
9021
|
+
}
|
|
9022
|
+
try {
|
|
9023
|
+
await startFlairProcess(port, dataDir);
|
|
9024
|
+
}
|
|
9025
|
+
catch (err) {
|
|
9026
|
+
console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
|
|
9027
|
+
console.error(` The snapshot itself succeeded (${snapshotPath}) — no packages were changed. Check: flair doctor`);
|
|
9028
|
+
process.exit(1);
|
|
9029
|
+
}
|
|
9030
|
+
}
|
|
9031
|
+
/**
|
|
9032
|
+
* Stamp the data directory with the currently-installed Harper engine version
|
|
9033
|
+
* (flair#1047). Called after every successful boot — start, restart, upgrade.
|
|
9034
|
+
* Best-effort: a failure to stamp is not a boot failure.
|
|
9035
|
+
*/
|
|
9036
|
+
function stampEngineVersionIfRunning(dataDir) {
|
|
9037
|
+
try {
|
|
9038
|
+
const version = readInstalledHarperVersion(flairPackageDir());
|
|
9039
|
+
if (version)
|
|
9040
|
+
writeEngineVersionStamp(dataDir, version);
|
|
9041
|
+
}
|
|
9042
|
+
catch { /* best-effort — stamp failure must not prevent boot */ }
|
|
9043
|
+
}
|
|
8970
9044
|
// ─── flair snapshot ─────────────────────────────────────────────────────────
|
|
8971
9045
|
// Explicit, first-class surface for the physical data-dir snapshot mechanism
|
|
8972
9046
|
// above (createDataSnapshot / pruneOldSnapshots / UPGRADE_SNAPSHOT_ROOT).
|
|
@@ -9204,6 +9278,7 @@ program
|
|
|
9204
9278
|
.option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
|
|
9205
9279
|
.option("--no-verify", "Skip post-restart health/version/auth verification (default: verify — so a broken upgrade can't report success; see flair#635)")
|
|
9206
9280
|
.option("--snapshot", "Take a pre-upgrade ~/.flair/data snapshot before the package swap, keep-last-3 retention (default: off — see `flair snapshot create` to take one by hand, or `flair backup` for a logical export; flair#637)")
|
|
9281
|
+
.option("--no-engine-snapshot", "Skip the pre-upgrade snapshot even when the Harper engine version is changing (flair#1047). The snapshot is automatic on engine-version changes because the tested-downgrade guarantee does not hold across engine boundaries. Opting out prints what is being given up.")
|
|
9207
9282
|
.option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
|
|
9208
9283
|
// ── Fabric upgrade (--target) ────────────────────────────────────────────
|
|
9209
9284
|
// When --target is passed, upgrade the Flair component DEPLOYED to that
|
|
@@ -9457,8 +9532,40 @@ program
|
|
|
9457
9532
|
// moved from opt-out to opt-in. `flair snapshot create` (below) exposes
|
|
9458
9533
|
// the exact same mechanism as a standalone command for anyone who wants
|
|
9459
9534
|
// one without wrapping it around an upgrade.
|
|
9535
|
+
//
|
|
9536
|
+
// flair#1047: the tested-downgrade guarantee does not hold across engine
|
|
9537
|
+
// version boundaries — a Harper bump is the only realistic source of a
|
|
9538
|
+
// cross-version boot break. When the engine version is changing, the
|
|
9539
|
+
// snapshot is unconditional. Opting out requires --no-engine-snapshot
|
|
9540
|
+
// and prints what is being given up.
|
|
9460
9541
|
const flairIsUpgrading = npmUpgrades.some((u) => u.pkg === "@tpsdev-ai/flair");
|
|
9461
|
-
const
|
|
9542
|
+
const hasDataDir = existsSync(upgradeDataDir);
|
|
9543
|
+
const flairFinding = findings.find((f) => f.name === "@tpsdev-ai/flair");
|
|
9544
|
+
// Determine whether the engine (Harper) version is changing.
|
|
9545
|
+
let engineVersionChanging = false;
|
|
9546
|
+
let currentEngineVersion = null;
|
|
9547
|
+
let targetEngineVersion = null;
|
|
9548
|
+
if (flairIsUpgrading && hasDataDir) {
|
|
9549
|
+
currentEngineVersion = readInstalledHarperVersion(flairPackageDir());
|
|
9550
|
+
const targetFlairVersion = flairFinding?.latest;
|
|
9551
|
+
if (targetFlairVersion && currentEngineVersion) {
|
|
9552
|
+
targetEngineVersion = await fetchDeclaredHarperVersion(targetFlairVersion);
|
|
9553
|
+
if (targetEngineVersion === null) {
|
|
9554
|
+
// Registry lookup failed — cannot determine the target Harper
|
|
9555
|
+
// version. Assume it might change (safe default) and print why.
|
|
9556
|
+
engineVersionChanging = true;
|
|
9557
|
+
console.log(render.wrap(render.c.dim, `Could not determine the target Harper version from the npm registry — forcing a pre-upgrade snapshot as a precaution.`));
|
|
9558
|
+
}
|
|
9559
|
+
else {
|
|
9560
|
+
engineVersionChanging = targetEngineVersion !== currentEngineVersion;
|
|
9561
|
+
}
|
|
9562
|
+
}
|
|
9563
|
+
else {
|
|
9564
|
+
// Cannot determine — assume it might change (safe default).
|
|
9565
|
+
engineVersionChanging = true;
|
|
9566
|
+
}
|
|
9567
|
+
}
|
|
9568
|
+
const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, hasDataDir, engineVersionChanging, !!opts.noEngineSnapshot);
|
|
9462
9569
|
let snapshotPath = null;
|
|
9463
9570
|
if (snapshotDecision === "nudge") {
|
|
9464
9571
|
// Non-blocking nudge only — never prompt/block here, this must stay
|
|
@@ -9473,53 +9580,20 @@ program
|
|
|
9473
9580
|
else if (snapshotDecision === "no-data") {
|
|
9474
9581
|
console.log(`\n(no data directory at ${upgradeDataDir} yet — nothing to snapshot)`);
|
|
9475
9582
|
}
|
|
9583
|
+
else if (snapshotDecision === "engine-version-change") {
|
|
9584
|
+
// Engine version is changing — snapshot is unconditional (flair#1047).
|
|
9585
|
+
// The operator can opt out with --no-engine-snapshot, which prints what
|
|
9586
|
+
// is being given up (handled in the nudge branch above).
|
|
9587
|
+
const fromLabel = currentEngineVersion ?? "unknown";
|
|
9588
|
+
const toLabel = targetEngineVersion ?? "unknown";
|
|
9589
|
+
console.log(`\nHarper engine version changing (${fromLabel} → ${toLabel}) — snapshotting data before upgrade...`);
|
|
9590
|
+
console.log(render.wrap(render.c.dim, "The tested-downgrade guarantee does not hold across engine version boundaries."));
|
|
9591
|
+
console.log(render.wrap(render.c.dim, "Pass --no-engine-snapshot to skip this (not recommended)."));
|
|
9592
|
+
await runUpgradeSnapshot(upgradePort, upgradeDataDir);
|
|
9593
|
+
}
|
|
9476
9594
|
else if (snapshotDecision === "snapshot") {
|
|
9477
9595
|
console.log("\nSnapshotting data before upgrade...");
|
|
9478
|
-
|
|
9479
|
-
// plain file copy of a live database directory isn't guaranteed
|
|
9480
|
-
// point-in-time consistent (Harper 5.x = RocksDB: WAL/SST/MANIFEST
|
|
9481
|
-
// can tear under a live copy). Stopping first — then immediately
|
|
9482
|
-
// restarting the OLD version, before any package changes — gives a
|
|
9483
|
-
// quiesced, safe-to-copy directory with only a brief blip, even for
|
|
9484
|
-
// --no-restart (the snapshot's correctness doesn't depend on
|
|
9485
|
-
// whether the caller wants a restart AFTER the upgrade — those are
|
|
9486
|
-
// orthogonal). See docs/upgrade.md for the native-backup alternative
|
|
9487
|
-
// considered and rejected (Harper's `get_backup` op backs up one
|
|
9488
|
-
// table/schema at a time over the running HTTP API — not the whole
|
|
9489
|
-
// data dir — and rejecting it here means this path never depends on
|
|
9490
|
-
// the server being up).
|
|
9491
|
-
let stoppedForSnapshot = false;
|
|
9492
|
-
try {
|
|
9493
|
-
await stopFlairProcess(upgradePort, upgradeDataDir);
|
|
9494
|
-
stoppedForSnapshot = true;
|
|
9495
|
-
const snapshot = await createDataSnapshot(upgradeDataDir);
|
|
9496
|
-
snapshotPath = snapshot.path;
|
|
9497
|
-
const removed = pruneOldSnapshots();
|
|
9498
|
-
console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
|
|
9499
|
-
console.log(` Restore: flair snapshot restore "${snapshotPath}"`);
|
|
9500
|
-
if (removed.length > 0) {
|
|
9501
|
-
console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
|
|
9502
|
-
}
|
|
9503
|
-
}
|
|
9504
|
-
catch (err) {
|
|
9505
|
-
console.error(`❌ snapshot failed: ${err.message}`);
|
|
9506
|
-
console.error(" Aborting upgrade — no packages were changed. Omit --snapshot to proceed without one (not recommended).");
|
|
9507
|
-
if (stoppedForSnapshot) {
|
|
9508
|
-
try {
|
|
9509
|
-
await startFlairProcess(upgradePort, upgradeDataDir);
|
|
9510
|
-
}
|
|
9511
|
-
catch { /* best effort — surface the original snapshot error, not this */ }
|
|
9512
|
-
}
|
|
9513
|
-
process.exit(1);
|
|
9514
|
-
}
|
|
9515
|
-
try {
|
|
9516
|
-
await startFlairProcess(upgradePort, upgradeDataDir);
|
|
9517
|
-
}
|
|
9518
|
-
catch (err) {
|
|
9519
|
-
console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
|
|
9520
|
-
console.error(` The snapshot itself succeeded (${snapshotPath}) — no packages were changed. Check: flair doctor`);
|
|
9521
|
-
process.exit(1);
|
|
9522
|
-
}
|
|
9596
|
+
await runUpgradeSnapshot(upgradePort, upgradeDataDir);
|
|
9523
9597
|
}
|
|
9524
9598
|
// Perform upgrade. `latest` comes from the npm registry's HTTP
|
|
9525
9599
|
// response, so CodeQL (correctly) treats it as untrusted input.
|
|
@@ -9571,7 +9645,6 @@ program
|
|
|
9571
9645
|
// --restart is kept as a deprecated no-op for old muscle memory.
|
|
9572
9646
|
// Upgrade = install → restart → verify → (rollback on failure), one
|
|
9573
9647
|
// transaction — never report success on a broken restart.
|
|
9574
|
-
const flairFinding = findings.find((f) => f.name === "@tpsdev-ai/flair");
|
|
9575
9648
|
const previousFlairVersion = flairFinding?.installed ?? null;
|
|
9576
9649
|
const expectedFlairVersion = flairFinding?.status === "outdated" && !flairInstallFailed
|
|
9577
9650
|
? flairFinding.latest
|
|
@@ -9880,6 +9953,16 @@ program
|
|
|
9880
9953
|
console.error("❌ No Flair data directory found. Run 'flair init' first.");
|
|
9881
9954
|
process.exit(1);
|
|
9882
9955
|
}
|
|
9956
|
+
// flair#1047: refuse to boot if the store was written by a newer engine.
|
|
9957
|
+
const runningHarperVersion = readInstalledHarperVersion(flairPackageDir());
|
|
9958
|
+
if (runningHarperVersion) {
|
|
9959
|
+
const backwardsError = checkEngineVersionBackwards(dataDir, runningHarperVersion);
|
|
9960
|
+
if (backwardsError) {
|
|
9961
|
+
console.error(`❌ Cannot start Flair — the data directory was written by a newer Harper engine.\n`);
|
|
9962
|
+
console.error(backwardsError);
|
|
9963
|
+
process.exit(1);
|
|
9964
|
+
}
|
|
9965
|
+
}
|
|
9883
9966
|
const platform = process.platform;
|
|
9884
9967
|
if (platform === "darwin") {
|
|
9885
9968
|
// resolveLaunchdLabel (flair#693) finds whichever label this data
|
|
@@ -9902,6 +9985,7 @@ program
|
|
|
9902
9985
|
console.log(`Migrated launchd service off the legacy label (${LEGACY_LAUNCHD_LABEL}) → ${label} ✓`);
|
|
9903
9986
|
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
9904
9987
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
|
|
9988
|
+
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
9905
9989
|
console.log("✅ Flair started (launchd)");
|
|
9906
9990
|
return;
|
|
9907
9991
|
}
|
|
@@ -9942,6 +10026,7 @@ program
|
|
|
9942
10026
|
try {
|
|
9943
10027
|
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
9944
10028
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
|
|
10029
|
+
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
9945
10030
|
console.log(`✅ Flair started on port ${port}`);
|
|
9946
10031
|
}
|
|
9947
10032
|
catch {
|
|
@@ -10275,6 +10360,7 @@ async function startFlairProcess(port, dataDir) {
|
|
|
10275
10360
|
ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
|
|
10276
10361
|
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
10277
10362
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
|
|
10363
|
+
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
10278
10364
|
return;
|
|
10279
10365
|
}
|
|
10280
10366
|
catch (err) {
|
|
@@ -10322,6 +10408,7 @@ async function startFlairProcess(port, dataDir) {
|
|
|
10322
10408
|
proc.unref();
|
|
10323
10409
|
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
10324
10410
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
|
|
10411
|
+
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
10325
10412
|
}
|
|
10326
10413
|
/**
|
|
10327
10414
|
* The ONE restart mechanism for a local Flair install. Shared by `flair
|
package/dist/doctor-client.js
CHANGED
|
@@ -867,7 +867,19 @@ export function resolveCollisionSafeName(existingNames, filename) {
|
|
|
867
867
|
*/
|
|
868
868
|
export function classifyKeyFile(agentId, seedValid, registration, baseUrl) {
|
|
869
869
|
if (!seedValid) {
|
|
870
|
-
|
|
870
|
+
// NOT "invalid", and therefore NOT prunable. "I could not parse this" and
|
|
871
|
+
// "this is a stale agent key" are different findings, and only the second
|
|
872
|
+
// is safe to act on. `~/.flair/keys/<id>.key` is a namespace shared by two
|
|
873
|
+
// writers: plaintext Ed25519 seeds, and AES-256-GCM keystore blobs written
|
|
874
|
+
// by FileKeyStore (flair#1026). A keystore blob is unparseable AS A SEED
|
|
875
|
+
// while being a LIVE federation key — classifying it "invalid" moved a key
|
|
876
|
+
// that was in use. An unidentified file is reported for a human and left
|
|
877
|
+
// exactly where it is.
|
|
878
|
+
return {
|
|
879
|
+
class: "unidentified",
|
|
880
|
+
reason: "not a parseable Ed25519 private key seed — may be a keystore blob or another format; " +
|
|
881
|
+
"left in place, inspect it before removing anything (flair#1026)",
|
|
882
|
+
};
|
|
871
883
|
}
|
|
872
884
|
if (registration?.state === "registered") {
|
|
873
885
|
return { class: "keep", reason: `agent '${agentId}' is registered on ${baseUrl} — never pruned` };
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// engine-version.ts — Harper engine version tracking (flair#1047)
|
|
2
|
+
//
|
|
3
|
+
// Two concerns, one module:
|
|
4
|
+
//
|
|
5
|
+
// 1. STORE STAMP — flair records the engine version that last wrote the data
|
|
6
|
+
// directory. At boot, if the store was written by a NEWER engine than the
|
|
7
|
+
// one running, flair refuses to start and says so: which version wrote the
|
|
8
|
+
// store, which is running now, and what to do about it.
|
|
9
|
+
//
|
|
10
|
+
// 2. VERSION READ — read the Harper version installed alongside this flair
|
|
11
|
+
// package, and (when a target flair version is known) the Harper version
|
|
12
|
+
// that target declares. Used by the upgrade path to decide whether the
|
|
13
|
+
// engine version is changing and a pre-upgrade snapshot is therefore
|
|
14
|
+
// mandatory.
|
|
15
|
+
//
|
|
16
|
+
// The stamp is a single-line file in the data directory. It must survive the
|
|
17
|
+
// data directory being moved and must not require a Harper query to read — if
|
|
18
|
+
// the engine cannot boot, we still need to read it.
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
/** Filename of the engine-version stamp inside the data directory. */
|
|
23
|
+
export const ENGINE_VERSION_STAMP = "engine-version.txt";
|
|
24
|
+
/** Root directory for pre-upgrade snapshots (~/.flair/upgrade-snapshots). */
|
|
25
|
+
export const UPGRADE_SNAPSHOT_ROOT = resolve(homedir(), ".flair", "upgrade-snapshots");
|
|
26
|
+
/** Read the Harper version installed alongside this flair package. */
|
|
27
|
+
export function readInstalledHarperVersion(packageRoot) {
|
|
28
|
+
for (const name of ["harper", "@harperfast/harper"]) {
|
|
29
|
+
const pkgPath = join(packageRoot, "node_modules", ...name.split("/"), "package.json");
|
|
30
|
+
if (!existsSync(pkgPath))
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
34
|
+
if (pkg.version)
|
|
35
|
+
return pkg.version;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Fetch the Harper version that a given @tpsdev-ai/flair version declares as
|
|
45
|
+
* a dependency. Returns null when the lookup fails (network, unparseable, etc.)
|
|
46
|
+
* — callers treat null as "cannot determine, assume it might change."
|
|
47
|
+
*/
|
|
48
|
+
export async function fetchDeclaredHarperVersion(flairVersion) {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`https://registry.npmjs.org/@tpsdev-ai/flair/${flairVersion}`, { signal: AbortSignal.timeout(5000) });
|
|
51
|
+
if (!res.ok)
|
|
52
|
+
return null;
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
return data.dependencies?.harper ?? data.dependencies?.["@harperfast/harper"] ?? null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ─── Store stamp ─────────────────────────────────────────────────────────────
|
|
61
|
+
/** Write the engine version stamp into the data directory. */
|
|
62
|
+
export function writeEngineVersionStamp(dataDir, version) {
|
|
63
|
+
writeFileSync(join(dataDir, ENGINE_VERSION_STAMP), `${version}\n`, "utf-8");
|
|
64
|
+
}
|
|
65
|
+
/** Read the engine version stamp from the data directory, or null if absent. */
|
|
66
|
+
export function readEngineVersionStamp(dataDir) {
|
|
67
|
+
const stampPath = join(dataDir, ENGINE_VERSION_STAMP);
|
|
68
|
+
if (!existsSync(stampPath))
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
return readFileSync(stampPath, "utf-8").trim() || null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Check whether the running engine is OLDER than the engine that last wrote
|
|
79
|
+
* the store. Returns null when the check passes (no stamp, or stamp ≤ running),
|
|
80
|
+
* or an error message when the store is newer.
|
|
81
|
+
*
|
|
82
|
+
* The error must be actionable: actor, state, remedy.
|
|
83
|
+
*/
|
|
84
|
+
export function checkEngineVersionBackwards(dataDir, runningVersion) {
|
|
85
|
+
const stamp = readEngineVersionStamp(dataDir);
|
|
86
|
+
if (!stamp)
|
|
87
|
+
return null; // no stamp — nothing to compare (pre-stamp install)
|
|
88
|
+
const parsed = compareVersions(stamp, runningVersion);
|
|
89
|
+
if (parsed === null) {
|
|
90
|
+
// Genuinely unparseable — cannot determine ordering. Refuse with a
|
|
91
|
+
// message that does NOT claim one is newer than the other.
|
|
92
|
+
return [
|
|
93
|
+
`This Flair install is running Harper ${runningVersion}, but the data directory at`,
|
|
94
|
+
` ${dataDir}`,
|
|
95
|
+
`was last written by Harper ${stamp}.`,
|
|
96
|
+
``,
|
|
97
|
+
`The engine version stamp could not be compared to the running version.`,
|
|
98
|
+
`An older Harper cannot safely read a store written by a newer one.`,
|
|
99
|
+
``,
|
|
100
|
+
...buildRecoveryLines(),
|
|
101
|
+
].join("\n");
|
|
102
|
+
}
|
|
103
|
+
if (parsed > 0) {
|
|
104
|
+
// stamp > running — backwards boot, refuse.
|
|
105
|
+
return [
|
|
106
|
+
`This Flair install is running Harper ${runningVersion}, but the data directory at`,
|
|
107
|
+
` ${dataDir}`,
|
|
108
|
+
`was last written by Harper ${stamp} — a newer engine version.`,
|
|
109
|
+
``,
|
|
110
|
+
`An older Harper cannot safely read a store written by a newer one.`,
|
|
111
|
+
`The data may appear intact but can be silently unreadable.`,
|
|
112
|
+
``,
|
|
113
|
+
...buildRecoveryLines(),
|
|
114
|
+
].join("\n");
|
|
115
|
+
}
|
|
116
|
+
return null; // running >= stamp — allowed
|
|
117
|
+
}
|
|
118
|
+
// ─── Version comparison (flair#1047) ─────────────────────────────────────────
|
|
119
|
+
/**
|
|
120
|
+
* Compare two semver-like version strings.
|
|
121
|
+
* Returns negative when a < b, positive when a > b, zero when equal,
|
|
122
|
+
* or null when either version is genuinely unparseable (not N.N.N at all).
|
|
123
|
+
*
|
|
124
|
+
* Pre-release ordering follows semver: a version WITH a pre-release tag is
|
|
125
|
+
* LOWER than the same core without one (5.2.0-rc1 < 5.2.0). When both have
|
|
126
|
+
* pre-releases, identifiers are compared dot by dot — numeric parts
|
|
127
|
+
* numerically, the rest as strings.
|
|
128
|
+
*/
|
|
129
|
+
function compareVersions(a, b) {
|
|
130
|
+
const pa = parseVersion(a);
|
|
131
|
+
const pb = parseVersion(b);
|
|
132
|
+
if (!pa || !pb)
|
|
133
|
+
return null;
|
|
134
|
+
// Compare major.minor.patch (and any additional numeric components) numerically.
|
|
135
|
+
const coreLen = Math.max(pa.core.length, pb.core.length);
|
|
136
|
+
for (let i = 0; i < coreLen; i++) {
|
|
137
|
+
const ac = pa.core[i] ?? 0;
|
|
138
|
+
const bc = pb.core[i] ?? 0;
|
|
139
|
+
if (ac !== bc)
|
|
140
|
+
return ac - bc;
|
|
141
|
+
}
|
|
142
|
+
// Cores are equal — compare pre-release tags.
|
|
143
|
+
if (pa.pre === null && pb.pre === null)
|
|
144
|
+
return 0;
|
|
145
|
+
if (pa.pre === null)
|
|
146
|
+
return 1; // a has no pre-release → a > b
|
|
147
|
+
if (pb.pre === null)
|
|
148
|
+
return -1; // b has no pre-release → a < b
|
|
149
|
+
// Both have pre-releases — compare identifiers dot by dot.
|
|
150
|
+
const len = Math.max(pa.pre.length, pb.pre.length);
|
|
151
|
+
for (let i = 0; i < len; i++) {
|
|
152
|
+
const ai = pa.pre[i];
|
|
153
|
+
const bi = pb.pre[i];
|
|
154
|
+
if (ai === undefined)
|
|
155
|
+
return -1; // fewer identifiers → lower
|
|
156
|
+
if (bi === undefined)
|
|
157
|
+
return 1;
|
|
158
|
+
const an = Number(ai);
|
|
159
|
+
const bn = Number(bi);
|
|
160
|
+
const aIsNum = !isNaN(an);
|
|
161
|
+
const bIsNum = !isNaN(bn);
|
|
162
|
+
if (aIsNum && bIsNum) {
|
|
163
|
+
if (an !== bn)
|
|
164
|
+
return an - bn;
|
|
165
|
+
}
|
|
166
|
+
else if (aIsNum) {
|
|
167
|
+
return -1; // numeric < string
|
|
168
|
+
}
|
|
169
|
+
else if (bIsNum) {
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
if (ai !== bi)
|
|
174
|
+
return ai < bi ? -1 : 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
function parseVersion(v) {
|
|
180
|
+
// Split off pre-release: everything after the first hyphen.
|
|
181
|
+
const hyphenIdx = v.indexOf("-");
|
|
182
|
+
const coreStr = hyphenIdx === -1 ? v : v.slice(0, hyphenIdx);
|
|
183
|
+
const preStr = hyphenIdx === -1 ? null : v.slice(hyphenIdx + 1);
|
|
184
|
+
const coreParts = coreStr.split(".");
|
|
185
|
+
if (coreParts.length < 3)
|
|
186
|
+
return null; // not at least N.N.N
|
|
187
|
+
const core = coreParts.map(Number);
|
|
188
|
+
if (core.some(isNaN))
|
|
189
|
+
return null; // non-numeric core component
|
|
190
|
+
const pre = preStr ? preStr.split(".") : null;
|
|
191
|
+
return { core, pre };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Build the recovery lines for a backwards-boot refusal message.
|
|
195
|
+
* Inspects the snapshot directory so the operator gets a runnable command
|
|
196
|
+
* (or a clear "nothing to restore" message) instead of a literal placeholder.
|
|
197
|
+
*/
|
|
198
|
+
export function buildRecoveryLines(snapshotDir) {
|
|
199
|
+
const effectiveSnapshotDir = snapshotDir ?? UPGRADE_SNAPSHOT_ROOT;
|
|
200
|
+
const snapshots = readSnapshotFiles(effectiveSnapshotDir);
|
|
201
|
+
if (snapshots.length === 0) {
|
|
202
|
+
return [
|
|
203
|
+
`To recover:`,
|
|
204
|
+
` No pre-upgrade snapshot was found.`,
|
|
205
|
+
` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`,
|
|
206
|
+
` 2. Or restore from a flair backup export (if you have one).`,
|
|
207
|
+
``,
|
|
208
|
+
`This check only helps from the release that ships it onward — it cannot`,
|
|
209
|
+
`rescue a downgrade to a build that predates the stamp.`,
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
const newest = snapshots[0];
|
|
213
|
+
const lines = [`To recover:`];
|
|
214
|
+
if (snapshots.length > 1) {
|
|
215
|
+
lines.push(` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`, ` 2. Or restore from the newest pre-upgrade snapshot:`, ` flair snapshot restore ${newest.path}`, ``, ` (To see all snapshots: flair snapshot list)`);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
lines.push(` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`, ` 2. Or restore from the pre-upgrade snapshot:`, ` flair snapshot restore ${newest.path}`);
|
|
219
|
+
}
|
|
220
|
+
lines.push(``, `This check only helps from the release that ships it onward — it cannot`, `rescue a downgrade to a build that predates the stamp.`);
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
223
|
+
/** Read snapshot files (.tar.gz), newest first. Lexical sort = chronological, because the
|
|
224
|
+
filename carries an ISO 8601 timestamp (see upgradeSnapshotFileName). If that format ever
|
|
225
|
+
changes, this sort must be revisited. */
|
|
226
|
+
function readSnapshotFiles(dir) {
|
|
227
|
+
if (!existsSync(dir))
|
|
228
|
+
return [];
|
|
229
|
+
try {
|
|
230
|
+
return readdirSync(dir)
|
|
231
|
+
.filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
|
|
232
|
+
.sort() // alphabetical = chronological (flair-data-<timestamp>.tar.gz)
|
|
233
|
+
.reverse() // newest first
|
|
234
|
+
.map((f) => ({ name: f, path: join(dir, f) }));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -113,7 +113,7 @@ export class AdminInstance extends Resource {
|
|
|
113
113
|
// actionable and still teaches them the surface exists.
|
|
114
114
|
const mcp = mcpRouteState();
|
|
115
115
|
const mcpCell = mcp.mounted
|
|
116
|
-
? `<code>${publicUrl}/mcp</code>`
|
|
116
|
+
? `<code>${esc(publicUrl)}/mcp</code>`
|
|
117
117
|
: `<span class="badge badge-gray">${esc(mcp.status)}</span>` +
|
|
118
118
|
`<div style="margin-top:4px;color:#666;font-size:0.9em">${esc(mcp.reason)}</div>`;
|
|
119
119
|
// Try to read instance public key
|
|
@@ -141,7 +141,7 @@ export class AdminInstance extends Resource {
|
|
|
141
141
|
</div>
|
|
142
142
|
<div class="card">
|
|
143
143
|
<h3>Public URL</h3>
|
|
144
|
-
<div style="font-family:monospace;font-size:0.9em;word-break:break-all">${publicUrl}</div>
|
|
144
|
+
<div style="font-family:monospace;font-size:0.9em;word-break:break-all">${esc(publicUrl)}</div>
|
|
145
145
|
</div>
|
|
146
146
|
</div>
|
|
147
147
|
|
|
@@ -156,12 +156,12 @@ export class AdminInstance extends Resource {
|
|
|
156
156
|
<div class="card">
|
|
157
157
|
<h3>Endpoints</h3>
|
|
158
158
|
<table style="box-shadow:none">
|
|
159
|
-
<tr><td>API</td><td><code>${publicUrl}/</code></td></tr>
|
|
159
|
+
<tr><td>API</td><td><code>${esc(publicUrl)}/</code></td></tr>
|
|
160
160
|
<tr><td>MCP</td><td>${mcpCell}</td></tr>
|
|
161
|
-
<tr><td>OAuth Discovery</td><td><code>${publicUrl}/OAuthMetadata</code></td></tr>
|
|
162
|
-
<tr><td>OAuth Authorize</td><td><code>${publicUrl}/OAuthAuthorize</code></td></tr>
|
|
163
|
-
<tr><td>OAuth Token</td><td><code>${publicUrl}/OAuthToken</code></td></tr>
|
|
164
|
-
<tr><td>Admin</td><td><code>${publicUrl}/AdminDashboard</code></td></tr>
|
|
161
|
+
<tr><td>OAuth Discovery</td><td><code>${esc(publicUrl)}/OAuthMetadata</code></td></tr>
|
|
162
|
+
<tr><td>OAuth Authorize</td><td><code>${esc(publicUrl)}/OAuthAuthorize</code></td></tr>
|
|
163
|
+
<tr><td>OAuth Token</td><td><code>${esc(publicUrl)}/OAuthToken</code></td></tr>
|
|
164
|
+
<tr><td>Admin</td><td><code>${esc(publicUrl)}/AdminDashboard</code></td></tr>
|
|
165
165
|
</table>
|
|
166
166
|
</div>
|
|
167
167
|
`;
|