@tpsdev-ai/flair 0.33.0 → 0.35.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 +570 -83
- package/dist/component-env.js +286 -0
- package/dist/deploy.js +190 -3
- package/dist/doctor-client.js +370 -8
- package/dist/engine-version.js +239 -0
- package/dist/hook-install.js +39 -9
- package/dist/lib/auth-resolve.js +85 -2
- package/dist/lib/launchd-management.js +328 -0
- package/dist/lib/mcp-enable.js +19 -0
- package/dist/resources/AdminInstance.js +26 -8
- package/dist/resources/OAuth.js +41 -25
- package/dist/resources/auth-middleware.js +26 -0
- package/dist/resources/dcr-gate.js +194 -0
- package/dist/resources/mcp-handler.js +91 -4
- package/dist/resources/mcp-oauth.js +136 -7
- package/dist/resources/oauth-discovery.js +242 -0
- package/dist/resources/oauth-wellknown.js +111 -0
- package/dist/resources/rate-limit.js +400 -0
- package/docs/auth.md +122 -5
- package/docs/deploying-on-fabric.md +35 -2
- package/docs/deployment.md +1 -1
- package/docs/hosted-on-fabric.md +1 -1
- package/docs/mcp-clients.md +24 -2
- package/docs/troubleshooting.md +36 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,13 +6,15 @@ import * as render from "./render.js";
|
|
|
6
6
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { join, resolve, sep, dirname } from "node:path";
|
|
9
|
-
import { spawn, execFileSync } from "node:child_process";
|
|
9
|
+
import { spawn, execFileSync, spawnSync, execSync } from "node:child_process";
|
|
10
10
|
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
11
11
|
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
12
12
|
import { keystore } from "./keystore.js";
|
|
13
|
-
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
|
|
13
|
+
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl, resolveDeployPublicUrl } from "./deploy.js";
|
|
14
|
+
import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, assertNoSecretKeysAdded, describePublicUrlFinding, planComponentEnv, readEnvValue, } from "./component-env.js";
|
|
14
15
|
import { fabricUpgrade } from "./fabric-upgrade.js";
|
|
15
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";
|
|
16
18
|
import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache } from "./version-handshake.js";
|
|
17
19
|
import { probeInstance } from "./probe.js";
|
|
18
20
|
import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
|
|
@@ -21,11 +23,12 @@ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemi
|
|
|
21
23
|
import { flairCliVersion, mcpServerSpec, unpinnedSpecWarning } from "./lib/mcp-spec.js";
|
|
22
24
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
23
25
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
24
|
-
import { readClientMcpBlock, checkClaudeMdBootstrap,
|
|
26
|
+
import { readClientMcpBlock, checkClaudeMdBootstrap, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, inferSoleAgentId, fixCommandAgentHint, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
|
|
25
27
|
import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
26
|
-
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
28
|
+
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
27
29
|
import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
|
|
28
30
|
import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
|
|
31
|
+
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, renderVerifiedSummary, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
|
|
29
32
|
// Value-only static import so `--interval`'s advertised default cannot drift
|
|
30
33
|
// from the one the scheduler actually validates against. The module itself is
|
|
31
34
|
// still loaded lazily at call time (the `await import()`s below) for the
|
|
@@ -1427,7 +1430,7 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
|
1427
1430
|
catch { /* keysDir missing */ }
|
|
1428
1431
|
}
|
|
1429
1432
|
if (!agentId) {
|
|
1430
|
-
return { state: "skipped", detail: "no agent id or key found" };
|
|
1433
|
+
return { state: "skipped", reason: "no-agent", detail: "no agent id or key found" };
|
|
1431
1434
|
}
|
|
1432
1435
|
// Find the signing key. Prefer the standard locations (resolveKeyPath), but
|
|
1433
1436
|
// fall back to the keysDir we were handed — `flair init` keys live there and
|
|
@@ -1439,7 +1442,7 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
|
1439
1442
|
keyPath = candidate;
|
|
1440
1443
|
}
|
|
1441
1444
|
if (!keyPath) {
|
|
1442
|
-
return { state: "skipped", detail: `no private key for agent '${agentId}'` };
|
|
1445
|
+
return { state: "skipped", reason: "no-key", detail: `no private key for agent '${agentId}'` };
|
|
1443
1446
|
}
|
|
1444
1447
|
// Distinctive content vs. a PARAPHRASE query with deliberately ZERO shared
|
|
1445
1448
|
// content words. If the search recovers the memory it can ONLY be by meaning.
|
|
@@ -1459,7 +1462,7 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
|
1459
1462
|
});
|
|
1460
1463
|
if (!writeRes.ok && writeRes.status !== 204) {
|
|
1461
1464
|
const text = await writeRes.text().catch(() => "");
|
|
1462
|
-
return { state: "skipped", detail: `could not write probe memory: HTTP ${writeRes.status} ${text.slice(0, 80)}` };
|
|
1465
|
+
return { state: "skipped", reason: "probe-failed", detail: `could not write probe memory: HTTP ${writeRes.status} ${text.slice(0, 80)}` };
|
|
1463
1466
|
}
|
|
1464
1467
|
stored = true;
|
|
1465
1468
|
// Allow the HNSW index to catch up before searching.
|
|
@@ -1472,7 +1475,7 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
|
1472
1475
|
});
|
|
1473
1476
|
if (!searchRes.ok) {
|
|
1474
1477
|
const text = await searchRes.text().catch(() => "");
|
|
1475
|
-
return { state: "skipped", detail: `SemanticSearch failed: HTTP ${searchRes.status} ${text.slice(0, 80)}` };
|
|
1478
|
+
return { state: "skipped", reason: "probe-failed", detail: `SemanticSearch failed: HTTP ${searchRes.status} ${text.slice(0, 80)}` };
|
|
1476
1479
|
}
|
|
1477
1480
|
const data = await searchRes.json();
|
|
1478
1481
|
// The server sets _warning ONLY when getMode() === "none" — i.e. the
|
|
@@ -1498,8 +1501,15 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
|
1498
1501
|
return { state: "ok", score };
|
|
1499
1502
|
}
|
|
1500
1503
|
catch (err) {
|
|
1504
|
+
// flair#1023: distinguish "your key will not load" from "the probe
|
|
1505
|
+
// request failed". The former is raised before any request leaves the
|
|
1506
|
+
// process, so it is never evidence about the instance — and "pass
|
|
1507
|
+
// --agent" cannot fix it.
|
|
1508
|
+
if (err instanceof KeyLoadError) {
|
|
1509
|
+
return { state: "skipped", reason: "key-load", detail: err.message };
|
|
1510
|
+
}
|
|
1501
1511
|
const message = err instanceof Error ? err.message : String(err);
|
|
1502
|
-
return { state: "skipped", detail: `probe error: ${message.slice(0, 100)}` };
|
|
1512
|
+
return { state: "skipped", reason: "probe-failed", detail: `probe error: ${message.slice(0, 100)}` };
|
|
1503
1513
|
}
|
|
1504
1514
|
finally {
|
|
1505
1515
|
// Best-effort cleanup of the ephemeral probe memory.
|
|
@@ -1545,6 +1555,11 @@ export async function probeFlairReachable(url, timeoutMs = 2000) {
|
|
|
1545
1555
|
* any other status, or a network error/timeout -> "unreachable" (could not
|
|
1546
1556
|
* verify one way or the other — e.g. a bare 401/403/500 doesn't tell us
|
|
1547
1557
|
* whether the agent exists, so we don't claim NOT registered on those)
|
|
1558
|
+
* the key file exists but will not load -> "key-unreadable" (flair#1023 —
|
|
1559
|
+
* signing happens strictly before the request, so authFetch can only
|
|
1560
|
+
* raise KeyLoadError while the instance is still untouched. This USED to
|
|
1561
|
+
* land in the catch below and be reported as "instance unreachable",
|
|
1562
|
+
* which doctor printed directly beneath its own "Harper responding" tick)
|
|
1548
1563
|
* no local key found for agentId (checked resolveKeyPath, then keysDir) -> "no-key"
|
|
1549
1564
|
* (can't sign the request at all — distinct from "unreachable" so the
|
|
1550
1565
|
* caller can print an accurate reason)
|
|
@@ -1591,6 +1606,13 @@ export async function checkAgentRegistered(baseUrl, agentId, keysDir) {
|
|
|
1591
1606
|
return { state: "unreachable", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
|
|
1592
1607
|
}
|
|
1593
1608
|
catch (err) {
|
|
1609
|
+
// flair#1023: a key that will not load is NOT a reachability fact. It is
|
|
1610
|
+
// raised before the request is sent, so reporting it as "unreachable"
|
|
1611
|
+
// sends the operator to firewalls and ports for a problem that is on
|
|
1612
|
+
// their own disk.
|
|
1613
|
+
if (err instanceof KeyLoadError) {
|
|
1614
|
+
return { state: "key-unreadable", detail: err.message };
|
|
1615
|
+
}
|
|
1594
1616
|
const message = err instanceof Error ? err.message : String(err);
|
|
1595
1617
|
return { state: "unreachable", detail: `instance unreachable: ${message.slice(0, 100)}` };
|
|
1596
1618
|
}
|
|
@@ -1599,6 +1621,21 @@ export async function checkAgentRegistered(baseUrl, agentId, keysDir) {
|
|
|
1599
1621
|
// Used during restart to confirm the old Harper process actually exited before
|
|
1600
1622
|
// we start polling /Health — otherwise the still-shutting-down old process can
|
|
1601
1623
|
// answer and we'd declare restart success while a gap is still ahead.
|
|
1624
|
+
/**
|
|
1625
|
+
* Is `pid` a process that exists right now? Signal 0 performs the permission
|
|
1626
|
+
* and existence checks without delivering anything (flair#1022) — a `hdb.pid`
|
|
1627
|
+
* left behind by a process that is gone is not evidence about a running
|
|
1628
|
+
* instance, and treating it as such produces confident wrong answers.
|
|
1629
|
+
*/
|
|
1630
|
+
function isProcessAlive(pid) {
|
|
1631
|
+
try {
|
|
1632
|
+
process.kill(pid, 0);
|
|
1633
|
+
return true;
|
|
1634
|
+
}
|
|
1635
|
+
catch {
|
|
1636
|
+
return false;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1602
1639
|
async function waitForProcessExit(pid, timeoutMs) {
|
|
1603
1640
|
const deadline = Date.now() + timeoutMs;
|
|
1604
1641
|
while (Date.now() < deadline) {
|
|
@@ -1751,7 +1788,35 @@ export async function callOpsApi(opsUrl, body, user, pass) {
|
|
|
1751
1788
|
}
|
|
1752
1789
|
return res.json();
|
|
1753
1790
|
}
|
|
1754
|
-
|
|
1791
|
+
/**
|
|
1792
|
+
* Build the component tarball `flair init --remote` uploads via the ops API.
|
|
1793
|
+
*
|
|
1794
|
+
* ── What was wrong here (flair#1005 item 2) ─────────────────────────────────
|
|
1795
|
+
* This function wrote a `.env` into its temp directory and then packed an
|
|
1796
|
+
* EXPLICIT entries list that did not contain it, so the file was discarded with
|
|
1797
|
+
* the temp directory on every call. It had been that way since the writer landed:
|
|
1798
|
+
* a writer whose output nothing consumed. `.env` is now in the list, which is the
|
|
1799
|
+
* whole of the fix — and `init-remote-ops.test.ts` asserts the entry is present
|
|
1800
|
+
* in a real tarball, because "the file was written" was never evidence of
|
|
1801
|
+
* anything.
|
|
1802
|
+
*
|
|
1803
|
+
* ── Why `publicUrl` replaced the password parameter (flair#1011) ────────────
|
|
1804
|
+
* The discarded file assigned `HDB_ADMIN_PASSWORD` and `FLAIR_ADMIN_PASSWORD`.
|
|
1805
|
+
* Shipping it as-is would have made a latent hazard live, for two independent
|
|
1806
|
+
* reasons: Harper composes its own configuration before a component's `.env`
|
|
1807
|
+
* loads, so `HDB_ADMIN_PASSWORD` set this way is a credential Harper is
|
|
1808
|
+
* structurally unable to honour while flair reads it — two sources, one name,
|
|
1809
|
+
* nothing comparing them; and the payload is ingested into Harper's
|
|
1810
|
+
* `hdb_deployment` record, which is replicated to every node and retained for
|
|
1811
|
+
* rollback, so anything in it is persisted cluster-wide.
|
|
1812
|
+
*
|
|
1813
|
+
* The parameter is REMOVED rather than validated. A caller cannot pass a password
|
|
1814
|
+
* to a function that has nowhere to put one, and no future edit can reintroduce
|
|
1815
|
+
* one without also reintroducing the parameter. The admin credential still
|
|
1816
|
+
* reaches the instance the way it always actually did — `add_user`/`alter_user`
|
|
1817
|
+
* over the ops API in `provisionFabric`.
|
|
1818
|
+
*/
|
|
1819
|
+
export async function buildDeployTarball(projectRoot, publicUrl) {
|
|
1755
1820
|
const tmpDir = mkdtempSync(join(tmpdir(), "flair-deploy-"));
|
|
1756
1821
|
try {
|
|
1757
1822
|
// Copy deployment files into temp directory
|
|
@@ -1765,13 +1830,22 @@ export async function buildDeployTarball(projectRoot, flairAdminPass) {
|
|
|
1765
1830
|
cpSync(src, dst, { recursive: true });
|
|
1766
1831
|
}
|
|
1767
1832
|
}
|
|
1768
|
-
//
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1833
|
+
// The component's environment. Harper reads this file only because
|
|
1834
|
+
// config.yaml declares its `loadEnv` plugin (flair#1010) — without that
|
|
1835
|
+
// declaration the file is present and inert, which is what made flair#1000
|
|
1836
|
+
// hard to see. An existing `.env` in the project root is merged, never
|
|
1837
|
+
// replaced; planComponentEnv keeps an operator's own value for the key.
|
|
1838
|
+
const existingEnvPath = join(projectRoot, COMPONENT_ENV_FILENAME);
|
|
1839
|
+
const existingEnv = existsSync(existingEnvPath) ? readFileSync(existingEnvPath, "utf8") : null;
|
|
1840
|
+
const plan = planComponentEnv(existingEnv, publicUrl);
|
|
1841
|
+
for (const notice of plan.notices)
|
|
1842
|
+
console.warn(`⚠ flair init --remote: ${notice}`);
|
|
1843
|
+
const envText = plan.text ?? existingEnv;
|
|
1844
|
+
if (envText !== null) {
|
|
1845
|
+
assertNoSecretKeysAdded(existingEnv, envText);
|
|
1846
|
+
writeFileSync(join(tmpDir, COMPONENT_ENV_FILENAME), envText, { mode: 0o600 });
|
|
1847
|
+
entries.push(COMPONENT_ENV_FILENAME);
|
|
1848
|
+
}
|
|
1775
1849
|
// Build compressed tarball
|
|
1776
1850
|
const tarballPath = join(tmpDir, "deploy.tar.gz");
|
|
1777
1851
|
await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, entries);
|
|
@@ -1809,9 +1883,12 @@ export async function waitForFlairRestart(targetUrl, maxWaitMs = 30_000) {
|
|
|
1809
1883
|
}
|
|
1810
1884
|
export async function provisionFabric(target, opsTarget, clusterAdminUser, clusterAdminPass, flairAdminPass) {
|
|
1811
1885
|
const projectRoot = process.cwd();
|
|
1812
|
-
// 1. Build and deploy component tarball
|
|
1886
|
+
// 1. Build and deploy component tarball. `target` is the served URL this
|
|
1887
|
+
// function verifies against in step 2 — the same value the component must
|
|
1888
|
+
// advertise in OAuth/A2A discovery, so it is what FLAIR_PUBLIC_URL is set from
|
|
1889
|
+
// (flair#1005). A loopback target supplies nothing: see resolveDeployPublicUrl.
|
|
1813
1890
|
console.log("Building deploy tarball...");
|
|
1814
|
-
const { tarballB64 } = await buildDeployTarball(projectRoot,
|
|
1891
|
+
const { tarballB64 } = await buildDeployTarball(projectRoot, resolveDeployPublicUrl(target));
|
|
1815
1892
|
console.log("Deploying via ops API...");
|
|
1816
1893
|
await callOpsApi(opsTarget, {
|
|
1817
1894
|
operation: "deploy_component",
|
|
@@ -3961,7 +4038,13 @@ export async function classifyKeysDir(keysDir, baseUrl) {
|
|
|
3961
4038
|
entries: [],
|
|
3962
4039
|
};
|
|
3963
4040
|
}
|
|
3964
|
-
|
|
4041
|
+
// flair#1023 added "key-unreadable". It cannot occur here — this key's
|
|
4042
|
+
// seed already parsed via isValidPrivateKeySeedFile above — but is
|
|
4043
|
+
// handled explicitly rather than folded into the else: a key that will
|
|
4044
|
+
// not load means exactly what prune already calls "invalid".
|
|
4045
|
+
const decision = reg.state === "key-unreadable"
|
|
4046
|
+
? classifyKeyFile(c.agentId, false, null, baseUrl)
|
|
4047
|
+
: classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
|
|
3965
4048
|
entries.push({ name: c.name, class: decision.class, reason: decision.reason, agentId: c.agentId });
|
|
3966
4049
|
}
|
|
3967
4050
|
return { aborted: false, entries };
|
|
@@ -4162,6 +4245,14 @@ hook
|
|
|
4162
4245
|
console.log(` ${status.correctShape ? render.icons.ok : render.icons.warn} wired${status.correctShape ? "" : " (unexpected shape — was it hand-edited?)"}`);
|
|
4163
4246
|
console.log(` ${render.wrap(render.c.dim, "Agent:")} ${status.agentId ?? render.wrap(render.c.dim, "(unknown — could not parse command)")}`);
|
|
4164
4247
|
console.log(` ${render.wrap(render.c.dim, "Flair URL:")} ${status.flairUrl ?? render.wrap(render.c.dim, "(unknown — could not parse command)")}`);
|
|
4248
|
+
// flair#1007 — whether a command that stopped resolving would fail quietly
|
|
4249
|
+
// or print an error on every session start.
|
|
4250
|
+
if (status.silenced) {
|
|
4251
|
+
console.log(` ${render.wrap(render.c.dim, "On failure:")} silent (exit 0, no output)`);
|
|
4252
|
+
}
|
|
4253
|
+
else {
|
|
4254
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`flair hook install\` to adopt the silent form`);
|
|
4255
|
+
}
|
|
4165
4256
|
console.log("");
|
|
4166
4257
|
});
|
|
4167
4258
|
// ─── flair mcp ───────────────────────────────────────────────────────────────
|
|
@@ -8733,7 +8824,8 @@ async function runFabricUpgrade(opts) {
|
|
|
8733
8824
|
// standalone filesystem utility). Rejected in favor of the file-level
|
|
8734
8825
|
// snapshot below. See docs/upgrade.md for the restore procedure this
|
|
8735
8826
|
// produces.
|
|
8736
|
-
|
|
8827
|
+
// UPGRADE_SNAPSHOT_ROOT is defined in engine-version.ts (the module that owns the path)
|
|
8828
|
+
// and imported from there for all callers.
|
|
8737
8829
|
const UPGRADE_SNAPSHOT_RETAIN = 3;
|
|
8738
8830
|
function upgradeSnapshotFileName() {
|
|
8739
8831
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
@@ -8857,9 +8949,12 @@ export function pruneOldSnapshots(retain = UPGRADE_SNAPSHOT_RETAIN, snapshotRoot
|
|
|
8857
8949
|
}
|
|
8858
8950
|
return removed;
|
|
8859
8951
|
}
|
|
8860
|
-
export function decideUpgradeSnapshotAction(flairIsUpgrading, snapshotRequested, hasDataDir) {
|
|
8952
|
+
export function decideUpgradeSnapshotAction(flairIsUpgrading, snapshotRequested, hasDataDir, engineVersionChanging, engineSnapshotOptOut) {
|
|
8861
8953
|
if (!flairIsUpgrading)
|
|
8862
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";
|
|
8863
8958
|
if (!snapshotRequested)
|
|
8864
8959
|
return hasDataDir ? "nudge" : "not-upgrading";
|
|
8865
8960
|
return hasDataDir ? "snapshot" : "no-data";
|
|
@@ -8877,6 +8972,75 @@ export const UPGRADE_SNAPSHOT_NUDGE_LINES = [
|
|
|
8877
8972
|
"No pre-upgrade snapshot will be taken.",
|
|
8878
8973
|
"To capture one first: `flair snapshot create` (physical) or `flair backup` (logical export), or re-run with --snapshot.",
|
|
8879
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
|
+
}
|
|
8880
9044
|
// ─── flair snapshot ─────────────────────────────────────────────────────────
|
|
8881
9045
|
// Explicit, first-class surface for the physical data-dir snapshot mechanism
|
|
8882
9046
|
// above (createDataSnapshot / pruneOldSnapshots / UPGRADE_SNAPSHOT_ROOT).
|
|
@@ -9114,6 +9278,7 @@ program
|
|
|
9114
9278
|
.option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
|
|
9115
9279
|
.option("--no-verify", "Skip post-restart health/version/auth verification (default: verify — so a broken upgrade can't report success; see flair#635)")
|
|
9116
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.")
|
|
9117
9282
|
.option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
|
|
9118
9283
|
// ── Fabric upgrade (--target) ────────────────────────────────────────────
|
|
9119
9284
|
// When --target is passed, upgrade the Flair component DEPLOYED to that
|
|
@@ -9367,8 +9532,40 @@ program
|
|
|
9367
9532
|
// moved from opt-out to opt-in. `flair snapshot create` (below) exposes
|
|
9368
9533
|
// the exact same mechanism as a standalone command for anyone who wants
|
|
9369
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.
|
|
9370
9541
|
const flairIsUpgrading = npmUpgrades.some((u) => u.pkg === "@tpsdev-ai/flair");
|
|
9371
|
-
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);
|
|
9372
9569
|
let snapshotPath = null;
|
|
9373
9570
|
if (snapshotDecision === "nudge") {
|
|
9374
9571
|
// Non-blocking nudge only — never prompt/block here, this must stay
|
|
@@ -9383,53 +9580,20 @@ program
|
|
|
9383
9580
|
else if (snapshotDecision === "no-data") {
|
|
9384
9581
|
console.log(`\n(no data directory at ${upgradeDataDir} yet — nothing to snapshot)`);
|
|
9385
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
|
+
}
|
|
9386
9594
|
else if (snapshotDecision === "snapshot") {
|
|
9387
9595
|
console.log("\nSnapshotting data before upgrade...");
|
|
9388
|
-
|
|
9389
|
-
// plain file copy of a live database directory isn't guaranteed
|
|
9390
|
-
// point-in-time consistent (Harper 5.x = RocksDB: WAL/SST/MANIFEST
|
|
9391
|
-
// can tear under a live copy). Stopping first — then immediately
|
|
9392
|
-
// restarting the OLD version, before any package changes — gives a
|
|
9393
|
-
// quiesced, safe-to-copy directory with only a brief blip, even for
|
|
9394
|
-
// --no-restart (the snapshot's correctness doesn't depend on
|
|
9395
|
-
// whether the caller wants a restart AFTER the upgrade — those are
|
|
9396
|
-
// orthogonal). See docs/upgrade.md for the native-backup alternative
|
|
9397
|
-
// considered and rejected (Harper's `get_backup` op backs up one
|
|
9398
|
-
// table/schema at a time over the running HTTP API — not the whole
|
|
9399
|
-
// data dir — and rejecting it here means this path never depends on
|
|
9400
|
-
// the server being up).
|
|
9401
|
-
let stoppedForSnapshot = false;
|
|
9402
|
-
try {
|
|
9403
|
-
await stopFlairProcess(upgradePort, upgradeDataDir);
|
|
9404
|
-
stoppedForSnapshot = true;
|
|
9405
|
-
const snapshot = await createDataSnapshot(upgradeDataDir);
|
|
9406
|
-
snapshotPath = snapshot.path;
|
|
9407
|
-
const removed = pruneOldSnapshots();
|
|
9408
|
-
console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
|
|
9409
|
-
console.log(` Restore: flair snapshot restore "${snapshotPath}"`);
|
|
9410
|
-
if (removed.length > 0) {
|
|
9411
|
-
console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
|
|
9412
|
-
}
|
|
9413
|
-
}
|
|
9414
|
-
catch (err) {
|
|
9415
|
-
console.error(`❌ snapshot failed: ${err.message}`);
|
|
9416
|
-
console.error(" Aborting upgrade — no packages were changed. Omit --snapshot to proceed without one (not recommended).");
|
|
9417
|
-
if (stoppedForSnapshot) {
|
|
9418
|
-
try {
|
|
9419
|
-
await startFlairProcess(upgradePort, upgradeDataDir);
|
|
9420
|
-
}
|
|
9421
|
-
catch { /* best effort — surface the original snapshot error, not this */ }
|
|
9422
|
-
}
|
|
9423
|
-
process.exit(1);
|
|
9424
|
-
}
|
|
9425
|
-
try {
|
|
9426
|
-
await startFlairProcess(upgradePort, upgradeDataDir);
|
|
9427
|
-
}
|
|
9428
|
-
catch (err) {
|
|
9429
|
-
console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
|
|
9430
|
-
console.error(` The snapshot itself succeeded (${snapshotPath}) — no packages were changed. Check: flair doctor`);
|
|
9431
|
-
process.exit(1);
|
|
9432
|
-
}
|
|
9596
|
+
await runUpgradeSnapshot(upgradePort, upgradeDataDir);
|
|
9433
9597
|
}
|
|
9434
9598
|
// Perform upgrade. `latest` comes from the npm registry's HTTP
|
|
9435
9599
|
// response, so CodeQL (correctly) treats it as untrusted input.
|
|
@@ -9481,7 +9645,6 @@ program
|
|
|
9481
9645
|
// --restart is kept as a deprecated no-op for old muscle memory.
|
|
9482
9646
|
// Upgrade = install → restart → verify → (rollback on failure), one
|
|
9483
9647
|
// transaction — never report success on a broken restart.
|
|
9484
|
-
const flairFinding = findings.find((f) => f.name === "@tpsdev-ai/flair");
|
|
9485
9648
|
const previousFlairVersion = flairFinding?.installed ?? null;
|
|
9486
9649
|
const expectedFlairVersion = flairFinding?.status === "outdated" && !flairInstallFailed
|
|
9487
9650
|
? flairFinding.latest
|
|
@@ -9611,8 +9774,28 @@ program
|
|
|
9611
9774
|
// The delegated `flair restart` printed its own success line; don't say it twice.
|
|
9612
9775
|
if (!restartWasDelegated)
|
|
9613
9776
|
console.log("✅ Flair restarted");
|
|
9777
|
+
// flair#1022 — the headline defect. The restart above is allowed to fall
|
|
9778
|
+
// back off launchd to a plain detached spawn, and SHOULD be: a running
|
|
9779
|
+
// instance beats a down one. What was missing is that the fallback changes
|
|
9780
|
+
// whether anything brings this instance back after a reboot, and the
|
|
9781
|
+
// verification below made no claim about it. `healthy, authenticated,
|
|
9782
|
+
// running <new version>` was every word true of an instance that had just
|
|
9783
|
+
// been orphaned.
|
|
9784
|
+
//
|
|
9785
|
+
// Observed here rather than reported by the restart, because
|
|
9786
|
+
// `restartAfterUpgrade` may have delegated to the newly installed CLI in a
|
|
9787
|
+
// CHILD PROCESS (flair#905) — no in-process flag crosses that boundary.
|
|
9788
|
+
// Asking launchd is the one form of this check that is correct on both
|
|
9789
|
+
// paths.
|
|
9790
|
+
const management = observeLaunchdManagement(upgradeDataDir, port);
|
|
9791
|
+
const detached = isDetached(management);
|
|
9614
9792
|
if (!shouldVerify) {
|
|
9615
9793
|
console.log(" (--no-verify: skipping post-restart verification)");
|
|
9794
|
+
if (detached) {
|
|
9795
|
+
for (const line of renderDetachedWarning(management, "Flair is running, but NOT under launchd.")) {
|
|
9796
|
+
console.error(line);
|
|
9797
|
+
}
|
|
9798
|
+
}
|
|
9616
9799
|
return;
|
|
9617
9800
|
}
|
|
9618
9801
|
console.log("\nVerifying...");
|
|
@@ -9629,7 +9812,20 @@ program
|
|
|
9629
9812
|
});
|
|
9630
9813
|
const verdict = decideAfterVerify(verify, previousFlairVersion);
|
|
9631
9814
|
if (verdict.kind === "ok") {
|
|
9632
|
-
|
|
9815
|
+
// flair#1022: the verified facts are unchanged and still stated — the
|
|
9816
|
+
// upgrade did land. What changes is the MARKER and the claim around it.
|
|
9817
|
+
// A run that ended up outside its process manager has not fully
|
|
9818
|
+
// succeeded, so it does not get a ✅, and the line names the property
|
|
9819
|
+
// that is wrong rather than only the ones that are right. The choice
|
|
9820
|
+
// lives in renderVerifiedSummary so it is testable without performing an
|
|
9821
|
+
// upgrade — no CI lane runs this darwin path.
|
|
9822
|
+
const summary = renderVerifiedSummary(verify.version, management);
|
|
9823
|
+
for (const line of summary.lines) {
|
|
9824
|
+
if (summary.degraded)
|
|
9825
|
+
console.error(line);
|
|
9826
|
+
else
|
|
9827
|
+
console.log(line);
|
|
9828
|
+
}
|
|
9633
9829
|
return;
|
|
9634
9830
|
}
|
|
9635
9831
|
// flair#741 follow-through: a healthy instance the verifier just couldn't
|
|
@@ -9641,10 +9837,21 @@ program
|
|
|
9641
9837
|
// "print an honest note but roll back anyway" branch that used to sit below
|
|
9642
9838
|
// is gone — that credentials case can no longer reach the rollback path.)
|
|
9643
9839
|
if (verdict.kind === "healthy-unverified") {
|
|
9644
|
-
|
|
9840
|
+
// flair#1022: same rule as the "ok" branch above — the ✅ is withheld
|
|
9841
|
+
// when the run left the instance outside launchd, and the reason is
|
|
9842
|
+
// named. This branch already qualifies the version claim; the process
|
|
9843
|
+
// manager is a second, independent qualification.
|
|
9844
|
+
console.log(detached
|
|
9845
|
+
? `⚠️ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}, but NOT under launchd.`
|
|
9846
|
+
: `✅ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}.`);
|
|
9645
9847
|
console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
|
|
9646
9848
|
console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
|
|
9647
9849
|
console.log(" To enable full post-upgrade verification: set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key.");
|
|
9850
|
+
if (detached) {
|
|
9851
|
+
for (const line of renderDetachedWarning(management, "The instance is NOT running under launchd.")) {
|
|
9852
|
+
console.error(line);
|
|
9853
|
+
}
|
|
9854
|
+
}
|
|
9648
9855
|
return;
|
|
9649
9856
|
}
|
|
9650
9857
|
console.error(`❌ post-restart verification failed: ${verdict.reason}`);
|
|
@@ -9746,6 +9953,16 @@ program
|
|
|
9746
9953
|
console.error("❌ No Flair data directory found. Run 'flair init' first.");
|
|
9747
9954
|
process.exit(1);
|
|
9748
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
|
+
}
|
|
9749
9966
|
const platform = process.platform;
|
|
9750
9967
|
if (platform === "darwin") {
|
|
9751
9968
|
// resolveLaunchdLabel (flair#693) finds whichever label this data
|
|
@@ -9755,12 +9972,20 @@ program
|
|
|
9755
9972
|
const { plistPath } = resolveLaunchdLabel(dataDir);
|
|
9756
9973
|
if (existsSync(plistPath)) {
|
|
9757
9974
|
try {
|
|
9975
|
+
// flair#1022, same pre-flight as startFlairProcess: launchctl exits 0
|
|
9976
|
+
// for a job it cannot exec, so a stale plist is only ever observable
|
|
9977
|
+
// as a startup timeout unless the paths are checked first.
|
|
9978
|
+
const stalePlist = diagnoseLaunchdPlistPaths(plistPath);
|
|
9979
|
+
if (stalePlist) {
|
|
9980
|
+
throw new Error(`${stalePlist.message} Fix it with: ${stalePlist.remedy.join(" && ")}`);
|
|
9981
|
+
}
|
|
9758
9982
|
const { execSync } = await import("node:child_process");
|
|
9759
9983
|
const { label, migrated } = ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
|
|
9760
9984
|
if (migrated)
|
|
9761
9985
|
console.log(`Migrated launchd service off the legacy label (${LEGACY_LAUNCHD_LABEL}) → ${label} ✓`);
|
|
9762
9986
|
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
9763
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
|
|
9764
9989
|
console.log("✅ Flair started (launchd)");
|
|
9765
9990
|
return;
|
|
9766
9991
|
}
|
|
@@ -9801,6 +10026,7 @@ program
|
|
|
9801
10026
|
try {
|
|
9802
10027
|
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
9803
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
|
|
9804
10030
|
console.log(`✅ Flair started on port ${port}`);
|
|
9805
10031
|
}
|
|
9806
10032
|
catch {
|
|
@@ -9904,6 +10130,71 @@ function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
|
|
|
9904
10130
|
+ `Pass --port with the port ${resolve(dataDir)} actually serves, `
|
|
9905
10131
|
+ `or stop the process manually.`);
|
|
9906
10132
|
}
|
|
10133
|
+
// ─── "is it still under launchd?" (flair#1022) ─────────────────────────────
|
|
10134
|
+
//
|
|
10135
|
+
// The pure logic lives in src/lib/launchd-management.ts; these two adapters
|
|
10136
|
+
// are the only places that talk to real launchd or the real filesystem, so a
|
|
10137
|
+
// test can exercise every branch above without either.
|
|
10138
|
+
/** `launchctl list <label>`, capped so an unreachable launchd cannot hang the CLI. */
|
|
10139
|
+
const realLaunchctlLister = (label) => {
|
|
10140
|
+
const res = spawnSync("launchctl", ["list", label], {
|
|
10141
|
+
encoding: "utf-8",
|
|
10142
|
+
timeout: LAUNCHCTL_QUERY_TIMEOUT_MS,
|
|
10143
|
+
});
|
|
10144
|
+
return { code: res.status, stdout: res.stdout ?? "" };
|
|
10145
|
+
};
|
|
10146
|
+
/**
|
|
10147
|
+
* Which process is actually serving `dataDir` — Harper's own `hdb.pid` first,
|
|
10148
|
+
* then the listener on `port`.
|
|
10149
|
+
*
|
|
10150
|
+
* `hdb.pid` is written by the Harper process itself on every boot regardless
|
|
10151
|
+
* of who spawned it, which is exactly the property this needs: it is the same
|
|
10152
|
+
* number on the launchd path and on the direct-spawn fallback, so comparing it
|
|
10153
|
+
* against launchd's reported PID is a real comparison rather than a proxy.
|
|
10154
|
+
* The port listener is the backstop for an install whose PID file is missing;
|
|
10155
|
+
* `null` (neither available) is handled by the caller as "no evidence", never
|
|
10156
|
+
* as "detached".
|
|
10157
|
+
*/
|
|
10158
|
+
function resolveInstanceServingPid(dataDir, port) {
|
|
10159
|
+
let listeningPids = [];
|
|
10160
|
+
try {
|
|
10161
|
+
listeningPids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
|
|
10162
|
+
}
|
|
10163
|
+
catch { /* lsof unavailable — the PID file may still answer */ }
|
|
10164
|
+
return pickInstancePid({
|
|
10165
|
+
pidFilePid: readHarperPid(dataDir),
|
|
10166
|
+
isAlive: isProcessAlive,
|
|
10167
|
+
listeningPids,
|
|
10168
|
+
});
|
|
10169
|
+
}
|
|
10170
|
+
/**
|
|
10171
|
+
* Observe whether `dataDir`'s instance is running under launchd right now.
|
|
10172
|
+
*
|
|
10173
|
+
* Called AFTER a restart completes, by both `flair restart` and `flair
|
|
10174
|
+
* upgrade` — see the module header for why this is an observation rather than
|
|
10175
|
+
* a flag carried out of `startFlairProcess` (the upgrade's restart may happen
|
|
10176
|
+
* in a child process, so no in-process flag survives).
|
|
10177
|
+
*/
|
|
10178
|
+
function observeLaunchdManagement(dataDir, port) {
|
|
10179
|
+
// Answered without touching the filesystem or lsof off darwin — this runs on
|
|
10180
|
+
// the success path of every restart and upgrade, including Linux's, where
|
|
10181
|
+
// there is no launchd to have fallen back from.
|
|
10182
|
+
if (process.platform !== "darwin") {
|
|
10183
|
+
return { state: "not-applicable", detail: `${process.platform} does not use launchd` };
|
|
10184
|
+
}
|
|
10185
|
+
const { label, plistPath } = resolveLaunchdLabel(dataDir);
|
|
10186
|
+
if (!existsSync(plistPath)) {
|
|
10187
|
+
return { state: "no-service", detail: `no launchd service is registered for this instance (${plistPath})` };
|
|
10188
|
+
}
|
|
10189
|
+
return assessLaunchdManagement({
|
|
10190
|
+
platform: process.platform,
|
|
10191
|
+
label,
|
|
10192
|
+
plistPath,
|
|
10193
|
+
instancePid: resolveInstanceServingPid(dataDir, port),
|
|
10194
|
+
plistExists: existsSync,
|
|
10195
|
+
list: realLaunchctlLister,
|
|
10196
|
+
});
|
|
10197
|
+
}
|
|
9907
10198
|
/**
|
|
9908
10199
|
* Stop the local Flair (Harper) process — launchd `stop` on darwin when a
|
|
9909
10200
|
* plist is present (falling back on failure), otherwise a manual SIGTERM by
|
|
@@ -9947,12 +10238,48 @@ async function stopFlairProcess(port, dataDir) {
|
|
|
9947
10238
|
// can race against the still-shutting-down old process and return
|
|
9948
10239
|
// success before the new one comes up.
|
|
9949
10240
|
const oldPid = readHarperPid(dataDir);
|
|
10241
|
+
// flair#1022: ask launchd whether the process we are about to wait on
|
|
10242
|
+
// is even its job's, BEFORE unloading. When the instance is already
|
|
10243
|
+
// running outside launchd — the state a previous fallback leaves
|
|
10244
|
+
// behind, and the state a stale plist guarantees — the unload has
|
|
10245
|
+
// nothing to signal, so waiting on `oldPid` burns the FULL startup
|
|
10246
|
+
// budget and then reports the meaningless
|
|
10247
|
+
// "Process <pid> did not exit within 60000ms". That was the first of
|
|
10248
|
+
// the reported incident's two 60-second hangs. The unload still runs
|
|
10249
|
+
// (a loaded-but-broken job must not be left able to respawn); only the
|
|
10250
|
+
// wait is skipped, and the fallback is entered immediately with a
|
|
10251
|
+
// reason that names the real condition.
|
|
10252
|
+
//
|
|
10253
|
+
// Gated on a LIVE recorded PID, and that gate is load-bearing: with no
|
|
10254
|
+
// running process there is nothing to wait for and nothing to
|
|
10255
|
+
// reattribute, and `stopFlairProcess` is documented as a harmless
|
|
10256
|
+
// no-op when the instance is already stopped. Without the gate, an
|
|
10257
|
+
// already-stopped instance takes the port fallback, which refuses when
|
|
10258
|
+
// it cannot attribute a listener (flair#915) — turning an idempotent
|
|
10259
|
+
// stop into a failed restart. Caught by the flair#902/#914 suites.
|
|
10260
|
+
//
|
|
10261
|
+
// Asked BEFORE the unload, because after it launchd no longer knows
|
|
10262
|
+
// the label at all and every answer would be "detached".
|
|
10263
|
+
const managed = oldPid !== null && isProcessAlive(oldPid)
|
|
10264
|
+
? assessLaunchdManagement({
|
|
10265
|
+
platform: process.platform,
|
|
10266
|
+
label,
|
|
10267
|
+
plistPath,
|
|
10268
|
+
instancePid: oldPid,
|
|
10269
|
+
plistExists: existsSync,
|
|
10270
|
+
list: realLaunchctlLister,
|
|
10271
|
+
})
|
|
10272
|
+
: null;
|
|
9950
10273
|
// unload stops the job AND prevents KeepAlive from respawning it.
|
|
9951
10274
|
// launchctl stop alone is insufficient for a KeepAlive job (flair#874).
|
|
9952
10275
|
try {
|
|
9953
10276
|
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
9954
10277
|
}
|
|
9955
10278
|
catch { }
|
|
10279
|
+
if (managed && isDetached(managed)) {
|
|
10280
|
+
throw new Error(`launchd is not running this instance — ${managed.detail}`
|
|
10281
|
+
+ `${managed.remedy?.length ? ` Fix it with: ${managed.remedy.join(" && ")}` : ""}`);
|
|
10282
|
+
}
|
|
9956
10283
|
if (oldPid)
|
|
9957
10284
|
await waitForProcessExit(oldPid, STARTUP_TIMEOUT_MS);
|
|
9958
10285
|
return;
|
|
@@ -10014,10 +10341,26 @@ async function startFlairProcess(port, dataDir) {
|
|
|
10014
10341
|
// success.
|
|
10015
10342
|
assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "start");
|
|
10016
10343
|
try {
|
|
10344
|
+
// flair#1022: launchd will not tell us it cannot exec the job.
|
|
10345
|
+
// `launchctl load` and `launchctl start` BOTH exit 0 for a plist whose
|
|
10346
|
+
// ProgramArguments[0] does not exist (measured, see the module header),
|
|
10347
|
+
// so the only way this loop learns anything is by waiting the full
|
|
10348
|
+
// startup budget for a port that will never open — the reported
|
|
10349
|
+
// incident's second 60-second hang, ending in "did not respond within
|
|
10350
|
+
// 60000ms (120 attempts)", an error about a port that says nothing
|
|
10351
|
+
// about the cause. The paths in the plist are absolute and checkable
|
|
10352
|
+
// with an existsSync, so check them first and turn a two-minute silence
|
|
10353
|
+
// into an immediate, named diagnosis. Still falls back — a running
|
|
10354
|
+
// instance beats a down one — just without the wait or the mystery.
|
|
10355
|
+
const stalePlist = diagnoseLaunchdPlistPaths(plistPath);
|
|
10356
|
+
if (stalePlist) {
|
|
10357
|
+
throw new Error(`${stalePlist.message} Fix it with: ${stalePlist.remedy.join(" && ")}`);
|
|
10358
|
+
}
|
|
10017
10359
|
const { execSync } = await import("node:child_process");
|
|
10018
10360
|
ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
|
|
10019
10361
|
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
10020
10362
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
|
|
10363
|
+
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
10021
10364
|
return;
|
|
10022
10365
|
}
|
|
10023
10366
|
catch (err) {
|
|
@@ -10065,6 +10408,7 @@ async function startFlairProcess(port, dataDir) {
|
|
|
10065
10408
|
proc.unref();
|
|
10066
10409
|
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
10067
10410
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
|
|
10411
|
+
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
10068
10412
|
}
|
|
10069
10413
|
/**
|
|
10070
10414
|
* The ONE restart mechanism for a local Flair install. Shared by `flair
|
|
@@ -10190,6 +10534,18 @@ program
|
|
|
10190
10534
|
// and saying so here is what keeps that true when someone adds one.
|
|
10191
10535
|
try {
|
|
10192
10536
|
await restartFlair(port, defaultDataDir());
|
|
10537
|
+
// flair#1022: a restart that fell back off launchd left the instance
|
|
10538
|
+
// running but unmanaged, and "✅ Flair restarted" was true of both
|
|
10539
|
+
// outcomes. Ask launchd what it is actually running now — an
|
|
10540
|
+
// observation, not a flag out of the restart, so it is right even when
|
|
10541
|
+
// the detachment predates this command.
|
|
10542
|
+
const managed = observeLaunchdManagement(defaultDataDir(), port);
|
|
10543
|
+
if (isDetached(managed)) {
|
|
10544
|
+
for (const line of renderDetachedWarning(managed, "Flair restarted, but it is NOT running under launchd.")) {
|
|
10545
|
+
console.error(line);
|
|
10546
|
+
}
|
|
10547
|
+
return;
|
|
10548
|
+
}
|
|
10193
10549
|
console.log("✅ Flair restarted");
|
|
10194
10550
|
}
|
|
10195
10551
|
catch (err) {
|
|
@@ -11229,6 +11585,54 @@ program
|
|
|
11229
11585
|
}
|
|
11230
11586
|
}
|
|
11231
11587
|
catch { /* best-effort — a stat failure shouldn't fail doctor */ }
|
|
11588
|
+
// 3d. The URL this instance tells the world to use (flair#1005, flair#1000).
|
|
11589
|
+
//
|
|
11590
|
+
// Asks the instance for its OWN discovery document rather than inferring
|
|
11591
|
+
// anything from config: /OAuthMetadata's `issuer` is the exact field that was
|
|
11592
|
+
// wrong in flair#1000, and it is the only thing that proves what a client
|
|
11593
|
+
// will actually be handed. describePublicUrlFinding (src/component-env.ts) is
|
|
11594
|
+
// pure decision logic, unit-tested, and documents in its own header why the
|
|
11595
|
+
// detectable condition is DRIFT rather than "unset on a public instance" —
|
|
11596
|
+
// doctor reaches this instance over loopback and cannot observe whether it is
|
|
11597
|
+
// also reachable at a public address.
|
|
11598
|
+
if (harperResponding) {
|
|
11599
|
+
let advertisedIssuer = null;
|
|
11600
|
+
try {
|
|
11601
|
+
const res = await fetch(`${baseUrl}/OAuthMetadata`, { signal: AbortSignal.timeout(5000) });
|
|
11602
|
+
if (res.ok) {
|
|
11603
|
+
const doc = (await res.json());
|
|
11604
|
+
if (typeof doc?.issuer === "string" && doc.issuer !== "")
|
|
11605
|
+
advertisedIssuer = doc.issuer;
|
|
11606
|
+
}
|
|
11607
|
+
}
|
|
11608
|
+
catch { /* unreachable/unparseable → null → the finding is skipped, not passed */ }
|
|
11609
|
+
// The component directory for a local install is the flair package itself:
|
|
11610
|
+
// `flair start` spawns `harper run .` with cwd = flairPackageDir().
|
|
11611
|
+
const componentEnvPath = join(flairPackageDir(), COMPONENT_ENV_FILENAME);
|
|
11612
|
+
let componentEnvValue = null;
|
|
11613
|
+
try {
|
|
11614
|
+
if (existsSync(componentEnvPath)) {
|
|
11615
|
+
componentEnvValue = readEnvValue(readFileSync(componentEnvPath, "utf-8"), PUBLIC_URL_KEY);
|
|
11616
|
+
}
|
|
11617
|
+
}
|
|
11618
|
+
catch { /* unreadable → treat as absent */ }
|
|
11619
|
+
const finding = describePublicUrlFinding({
|
|
11620
|
+
advertisedIssuer,
|
|
11621
|
+
componentEnvValue,
|
|
11622
|
+
processEnvValue: process.env.FLAIR_PUBLIC_URL ?? null,
|
|
11623
|
+
componentEnvPath,
|
|
11624
|
+
});
|
|
11625
|
+
if (finding) {
|
|
11626
|
+
const icon = finding.icon === "ok" ? render.icons.ok
|
|
11627
|
+
: finding.icon === "warn" ? render.icons.warn
|
|
11628
|
+
: render.icons.error;
|
|
11629
|
+
console.log(` ${icon} ${finding.message}`);
|
|
11630
|
+
if (finding.fixHint)
|
|
11631
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${finding.fixHint}`);
|
|
11632
|
+
if (finding.isIssue)
|
|
11633
|
+
issues++;
|
|
11634
|
+
}
|
|
11635
|
+
}
|
|
11232
11636
|
// 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
|
|
11233
11637
|
//
|
|
11234
11638
|
// The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
|
|
@@ -11253,13 +11657,21 @@ program
|
|
|
11253
11657
|
console.log(` ${render.wrap(render.c.dim, "See:")} docs/troubleshooting.md ${render.wrap(render.c.dim, "→ \"Semantic search DEGRADED\"")}`);
|
|
11254
11658
|
issues++;
|
|
11255
11659
|
break;
|
|
11256
|
-
case "skipped":
|
|
11257
|
-
// Could not run the round-trip
|
|
11258
|
-
//
|
|
11259
|
-
//
|
|
11660
|
+
case "skipped": {
|
|
11661
|
+
// Could not run the round-trip. Don't claim all-clear — surface that
|
|
11662
|
+
// the check was skipped, but don't count it as a hard issue since
|
|
11663
|
+
// the user may simply not have an agent yet.
|
|
11664
|
+
//
|
|
11665
|
+
// flair#1023: the remedy is chosen from the classified reason
|
|
11666
|
+
// (embeddingsSkipRemedy, src/doctor-client.ts) instead of being
|
|
11667
|
+
// printed unconditionally. A key that will not decode gets no
|
|
11668
|
+
// "pass --agent" advice, because following it changes nothing.
|
|
11260
11669
|
console.log(` ${render.icons.warn} Embeddings: not verified ${render.wrap(render.c.dim, `(${semanticStatus.detail})`)}`);
|
|
11261
|
-
|
|
11670
|
+
const remedy = embeddingsSkipRemedy(semanticStatus.reason);
|
|
11671
|
+
if (remedy)
|
|
11672
|
+
console.log(` ${render.wrap(render.c.dim, remedy)}`);
|
|
11262
11673
|
break;
|
|
11674
|
+
}
|
|
11263
11675
|
}
|
|
11264
11676
|
}
|
|
11265
11677
|
// 5. Stale PID file (skip if already reported in port check)
|
|
@@ -11418,7 +11830,11 @@ program
|
|
|
11418
11830
|
issues++;
|
|
11419
11831
|
}
|
|
11420
11832
|
else {
|
|
11421
|
-
|
|
11833
|
+
// flair#1023: `reachable` was just established two lines above, so
|
|
11834
|
+
// reuse the same self-inconsistency guard the agent gates use
|
|
11835
|
+
// rather than echoing a detail that may claim the opposite.
|
|
11836
|
+
const finding = describeAgentGateFinding(block.agentId, reg.state, reg.detail, { instanceReachable: reachable });
|
|
11837
|
+
console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
|
|
11422
11838
|
}
|
|
11423
11839
|
}
|
|
11424
11840
|
// Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
|
|
@@ -11452,9 +11868,75 @@ program
|
|
|
11452
11868
|
}
|
|
11453
11869
|
issues++;
|
|
11454
11870
|
}
|
|
11455
|
-
|
|
11871
|
+
// flair#1007: presence was never the problem — the failing entry was
|
|
11872
|
+
// perfectly well-formed. inspectSessionStartHook() additionally RUNS
|
|
11873
|
+
// the registered command (bounded, side-effect-free via
|
|
11874
|
+
// FLAIR_HOOK_PROBE) so doctor can tell "wired" from "wired and still
|
|
11875
|
+
// works", and reports the shell-level silencing separately so an
|
|
11876
|
+
// already-installed loud hook can be upgraded rather than only
|
|
11877
|
+
// diagnosed.
|
|
11878
|
+
const hook = inspectSessionStartHook(homedir());
|
|
11456
11879
|
if (hook.present) {
|
|
11457
|
-
|
|
11880
|
+
if (hook.execution === "broken") {
|
|
11881
|
+
// Reported in full, with the remedy — but NOT counted as an issue,
|
|
11882
|
+
// so it never flips doctor's exit code on its own. This is a
|
|
11883
|
+
// verification of the environment at the moment doctor runs (a
|
|
11884
|
+
// cold `npx` cache, an offline machine, a slow registry), exactly
|
|
11885
|
+
// like the FLAIR_URL reachability and agent-registration
|
|
11886
|
+
// verifications above, which are warnings for the same reason. A
|
|
11887
|
+
// fresh, correct install on a machine that simply has not fetched
|
|
11888
|
+
// the adapter yet must not be told it is broken in the exit code.
|
|
11889
|
+
// The unsilenced finding below IS counted: that one is a fact
|
|
11890
|
+
// about the file, true regardless of the environment.
|
|
11891
|
+
console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
|
|
11892
|
+
console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
|
|
11893
|
+
console.log(` ${render.wrap(render.c.dim, "If this persists, the Node runtime probably changed and the globally")}`);
|
|
11894
|
+
console.log(` ${render.wrap(render.c.dim, "installed @tpsdev-ai/flair-mcp no longer resolves for it.")}`);
|
|
11895
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} npm install -g @tpsdev-ai/flair-mcp ${render.wrap(render.c.dim, "(reinstall for the runtime you use now)")}`);
|
|
11896
|
+
console.log(` ${render.wrap(render.c.dim, "Or, if you no longer want ambient memory:")} flair hook uninstall`);
|
|
11897
|
+
}
|
|
11898
|
+
else if (hook.execution === "unknown") {
|
|
11899
|
+
console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but could not be verified ${render.wrap(render.c.dim, `(${hook.detail ?? "no detail"})`)}`);
|
|
11900
|
+
}
|
|
11901
|
+
else if (!hook.ours) {
|
|
11902
|
+
console.log(` ${render.icons.ok} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "(custom command — not verified, not modified)")}`);
|
|
11903
|
+
}
|
|
11904
|
+
else {
|
|
11905
|
+
console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
|
|
11906
|
+
}
|
|
11907
|
+
// Independent of whether it runs today: would it stay quiet if it
|
|
11908
|
+
// stopped? Only offered as a repair when the command is the exact
|
|
11909
|
+
// string Flair itself wrote — a hand-edited or pinned hook is the
|
|
11910
|
+
// user's, and doctor reports on it rather than rewriting it.
|
|
11911
|
+
if (!hook.silenced && hook.ours) {
|
|
11912
|
+
console.log(` ${render.icons.warn} SessionStart hook: a failure would print an error on every session (this command predates the silent-failure fix)`);
|
|
11913
|
+
if (hook.upgradable) {
|
|
11914
|
+
if (autoFix) {
|
|
11915
|
+
if (dryRun) {
|
|
11916
|
+
console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
|
|
11917
|
+
}
|
|
11918
|
+
else {
|
|
11919
|
+
const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
|
|
11920
|
+
if (!proceed) {
|
|
11921
|
+
console.log(` Skipped.`);
|
|
11922
|
+
}
|
|
11923
|
+
else {
|
|
11924
|
+
const upgrade = upgradeSessionStartHookCommand(homedir());
|
|
11925
|
+
console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
|
|
11926
|
+
if (upgrade.ok && upgrade.changed)
|
|
11927
|
+
fixed++;
|
|
11928
|
+
}
|
|
11929
|
+
}
|
|
11930
|
+
}
|
|
11931
|
+
else {
|
|
11932
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites the hook command in place — same agent, same instance)")}`);
|
|
11933
|
+
}
|
|
11934
|
+
}
|
|
11935
|
+
else {
|
|
11936
|
+
console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install`);
|
|
11937
|
+
}
|
|
11938
|
+
issues++;
|
|
11939
|
+
}
|
|
11458
11940
|
}
|
|
11459
11941
|
else {
|
|
11460
11942
|
console.log(` ${render.icons.error} SessionStart hook: not found in ${render.wrap(render.c.dim, hook.path)}`);
|
|
@@ -11511,7 +11993,10 @@ program
|
|
|
11511
11993
|
for (const id of verifiedReadAgentIds) {
|
|
11512
11994
|
const reg = await checkAgentRegistered(baseUrl, id, defaultKeysDir());
|
|
11513
11995
|
agentGates.push({ id, state: reg.state, detail: reg.detail });
|
|
11514
|
-
|
|
11996
|
+
// harperResponding is necessarily true here (verifiedReadAgentIds is
|
|
11997
|
+
// empty otherwise), so an "unreachable" verdict from this loop is
|
|
11998
|
+
// always a self-contradiction — flair#1023. Hand the guard the fact.
|
|
11999
|
+
const finding = describeAgentGateFinding(id, reg.state, reg.detail, { instanceReachable: harperResponding });
|
|
11515
12000
|
if (finding?.isIssue)
|
|
11516
12001
|
issues++;
|
|
11517
12002
|
}
|
|
@@ -11522,7 +12007,7 @@ program
|
|
|
11522
12007
|
// agent and moves on to the next (failure isolation).
|
|
11523
12008
|
function renderAgentGateHeader(gate) {
|
|
11524
12009
|
console.log(` ${render.wrap(render.c.dim, `Agent: ${gate.id}`)}`);
|
|
11525
|
-
const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail);
|
|
12010
|
+
const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail, { instanceReachable: harperResponding });
|
|
11526
12011
|
if (!finding)
|
|
11527
12012
|
return true;
|
|
11528
12013
|
const icon = finding.icon === "error" ? render.icons.error : render.icons.warn;
|
|
@@ -11710,7 +12195,7 @@ program
|
|
|
11710
12195
|
console.log(` ${render.icons.info} Pass --agent <id> (with a matching key in ~/.flair/keys) to see migration state — requires a verified read, same as Fleet presence above.`);
|
|
11711
12196
|
}
|
|
11712
12197
|
else {
|
|
11713
|
-
const passedGates = agentGates.filter((g) => describeAgentGateFinding(g.id, g.state, g.detail) === null);
|
|
12198
|
+
const passedGates = agentGates.filter((g) => describeAgentGateFinding(g.id, g.state, g.detail, { instanceReachable: harperResponding }) === null);
|
|
11714
12199
|
for (const gate of passedGates) {
|
|
11715
12200
|
renderAgentGateHeader(gate);
|
|
11716
12201
|
const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
|
|
@@ -15369,4 +15854,6 @@ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBi
|
|
|
15369
15854
|
// Harper's own config — the per-instance port record (flair#914)
|
|
15370
15855
|
harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
|
|
15371
15856
|
// launchd label (flair#693)
|
|
15372
|
-
LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded,
|
|
15857
|
+
LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded,
|
|
15858
|
+
// launchd management observation (flair#1022)
|
|
15859
|
+
observeLaunchdManagement, resolveInstanceServingPid, };
|