@tpsdev-ai/flair 0.53.0 → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1791 -15648
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1654 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +914 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1592 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/fabric-npm-install.js +87 -0
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +5 -4
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +43 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +15 -7
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
- package/package.json +9 -4
- package/schemas/agent.graphql +15 -0
|
@@ -0,0 +1,1654 @@
|
|
|
1
|
+
import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, describePublicUrlFinding, readEnvValue } from "../component-env.js";
|
|
2
|
+
import { checkClaudeMdBootstrap, checkContinuityCaptureHooks, describeAgentGateFinding, effectiveFlairUrl, embeddingsSkipRemedy, fixClaudeMdBootstrap, fixCommandAgentHint, fixContinuityCaptureHooks, fixSessionStartHook, inspectSessionStartHook, partitionKeyIds, planAgentIterations, readClientMcpBlock, resolveFixAgentId, resolveWireFlairUrl, upgradeSessionStartHookCommand } from "../doctor-client.js";
|
|
3
|
+
import { markStale, sortOldestVersionFirst } from "../fleet-presence.js";
|
|
4
|
+
import { hookSettingsPath, repinSessionStartHook, resolveHookAgentId } from "../hook-install.js";
|
|
5
|
+
import { detectClients, wireAntigravity, wireClaudeCode, wireCodex, wireCursor, wireGemini } from "../install/clients.js";
|
|
6
|
+
import { checkGlobalBinOnPath, resolveNpmGlobalPrefix } from "../install/global-bin-path.js";
|
|
7
|
+
import { buildEd25519Auth, defaultKeysDir, resolveAdminUser, resolveKeyPath, resolveLocalAdminPass } from "../lib/auth-resolve.js";
|
|
8
|
+
import { flairConfigYamlCandidates, readPortFromYamlFile, resolveFlairConfigYaml } from "../lib/doctor-config-path.js";
|
|
9
|
+
import { collectFederationEnv, describeFederationDriverFinding, federationPeersConfigured, loadYamlDoc } from "../lib/doctor-federation-driver.js";
|
|
10
|
+
import { DOCTOR_CHECK_IDS, catalogIssueDelta, renderCatalogDoctorLines, runDoctorChecks } from "../lib/doctor-run.js";
|
|
11
|
+
import { opsApiBindFinding } from "../lib/ops-api-bind.js";
|
|
12
|
+
import { flairCliVersion, unpinnedSpecWarning } from "../lib/mcp-spec.js";
|
|
13
|
+
import { staleSessionStartHookPins } from "../lib/owned-pins.js";
|
|
14
|
+
import * as render from "../render.js";
|
|
15
|
+
import { checkVersion, formatVersionNudge, probeInstanceVersion } from "../version-check.js";
|
|
16
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
let cli;
|
|
20
|
+
/** Bind the cli-locals this module depends on. */
|
|
21
|
+
export function bindCli(fns) {
|
|
22
|
+
cli = fns;
|
|
23
|
+
}
|
|
24
|
+
function api(...args) {
|
|
25
|
+
return cli.api(...args);
|
|
26
|
+
}
|
|
27
|
+
function checkAgentRegistered(...args) {
|
|
28
|
+
return cli.checkAgentRegistered(...args);
|
|
29
|
+
}
|
|
30
|
+
function classifyOpsSocketPosture(...args) {
|
|
31
|
+
return cli.classifyOpsSocketPosture(...args);
|
|
32
|
+
}
|
|
33
|
+
function configPath(...args) {
|
|
34
|
+
return cli.configPath(...args);
|
|
35
|
+
}
|
|
36
|
+
function defaultDataDir(...args) {
|
|
37
|
+
return cli.defaultDataDir(...args);
|
|
38
|
+
}
|
|
39
|
+
function flairPackageDir(...args) {
|
|
40
|
+
return cli.flairPackageDir(...args);
|
|
41
|
+
}
|
|
42
|
+
function listeningPidsOnPort(...args) {
|
|
43
|
+
return cli.listeningPidsOnPort(...args);
|
|
44
|
+
}
|
|
45
|
+
function persistDefaultInstallCoordinates(...args) {
|
|
46
|
+
return cli.persistDefaultInstallCoordinates(...args);
|
|
47
|
+
}
|
|
48
|
+
function planLaunchdRepairFor(...args) {
|
|
49
|
+
return cli.planLaunchdRepairFor(...args);
|
|
50
|
+
}
|
|
51
|
+
function probeFlairReachable(...args) {
|
|
52
|
+
return cli.probeFlairReachable(...args);
|
|
53
|
+
}
|
|
54
|
+
function readHarperConfig(...args) {
|
|
55
|
+
return cli.readHarperConfig(...args);
|
|
56
|
+
}
|
|
57
|
+
function readPortFromConfig(...args) {
|
|
58
|
+
return cli.readPortFromConfig(...args);
|
|
59
|
+
}
|
|
60
|
+
function relativeTime(...args) {
|
|
61
|
+
return cli.relativeTime(...args);
|
|
62
|
+
}
|
|
63
|
+
function repairLaunchdManagement(...args) {
|
|
64
|
+
return cli.repairLaunchdManagement(...args);
|
|
65
|
+
}
|
|
66
|
+
function resolveHttpPort(...args) {
|
|
67
|
+
return cli.resolveHttpPort(...args);
|
|
68
|
+
}
|
|
69
|
+
function resolveOpsPort(...args) {
|
|
70
|
+
return cli.resolveOpsPort(...args);
|
|
71
|
+
}
|
|
72
|
+
function verifyAuditLog(...args) {
|
|
73
|
+
return cli.verifyAuditLog(...args);
|
|
74
|
+
}
|
|
75
|
+
function verifySemanticSearch(...args) {
|
|
76
|
+
return cli.verifySemanticSearch(...args);
|
|
77
|
+
}
|
|
78
|
+
export function summarizeDoctorRun(found, fixed, autoFix) {
|
|
79
|
+
const plural = (n) => `issue${n === 1 ? "" : "s"}`;
|
|
80
|
+
if (found === 0) {
|
|
81
|
+
return { line: ` ${render.icons.ok} ${render.wrap(render.c.green, "No issues found")}`, exitCode: 0 };
|
|
82
|
+
}
|
|
83
|
+
if (!autoFix) {
|
|
84
|
+
return {
|
|
85
|
+
line: ` ${render.icons.error} ${render.wrap(render.c.red, `${found} ${plural(found)} found`)} ${render.wrap(render.c.dim, "— see fixes above")}`,
|
|
86
|
+
exitCode: 1,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (fixed >= found) {
|
|
90
|
+
return {
|
|
91
|
+
line: ` ${render.icons.ok} ${render.wrap(render.c.green, `${found} ${plural(found)} found, ${fixed} fixed ✓`)}`,
|
|
92
|
+
exitCode: 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const remaining = found - fixed;
|
|
96
|
+
return {
|
|
97
|
+
line: ` ${render.icons.error} ${render.wrap(render.c.red, `${found} ${plural(found)} found, ${fixed} fixed, ${remaining} remaining`)}`,
|
|
98
|
+
exitCode: 1,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// ─── flair doctor ─────────────────────────────────────────────────────────────
|
|
102
|
+
export function register(program) {
|
|
103
|
+
const __pkgVersion = cli.__pkgVersion;
|
|
104
|
+
// ─── flair doctor — pure summary/exit helper ─────────────────────────────────
|
|
105
|
+
// Extracted for testability (flair#721), same pattern as formatCandidateLine /
|
|
106
|
+
// describeReflectError in src/commands/rem.ts: the action callback spawns process.exit and a
|
|
107
|
+
// long sequence of console.log side effects, which makes it high-effort/
|
|
108
|
+
// low-value to drive directly — this is the actual decision logic. Before
|
|
109
|
+
// #721, doctor tracked only a single `issues` counter: every detected
|
|
110
|
+
// problem incremented it, and the final summary/exit-code read that counter
|
|
111
|
+
// alone, with no separate record of which of those issues `--fix` actually
|
|
112
|
+
// resolved during the same run. So a `--fix` run that interactively fixed
|
|
113
|
+
// every issue it found still printed "N issues found — see fixes above" and
|
|
114
|
+
// exited 1 — indistinguishable from a run that fixed nothing. This helper
|
|
115
|
+
// takes the accumulated found/fixed counts plus whether `--fix` was passed
|
|
116
|
+
// at all, and decides the summary line + exit code:
|
|
117
|
+
// - 0 found → "No issues found", exit 0 (unchanged)
|
|
118
|
+
// - found, no --fix → "N issues found — see fixes above", exit 1 (unchanged)
|
|
119
|
+
// - found, --fix, all fixed → "N issues found, N fixed ✓", exit 0
|
|
120
|
+
// - found, --fix, some remaining → "N issues found, M fixed, K remaining", exit 1
|
|
121
|
+
program
|
|
122
|
+
.command("doctor")
|
|
123
|
+
.description("Diagnose common Flair problems and suggest fixes")
|
|
124
|
+
.option("--port <port>", "Harper HTTP port")
|
|
125
|
+
.option("--agent <id>", "Agent ID to use for the semantic-search round-trip (or FLAIR_AGENT_ID env)")
|
|
126
|
+
.option("--fix", "Automatically fix issues where possible")
|
|
127
|
+
.option("--dry-run", "Show what --fix would do without making changes")
|
|
128
|
+
.action(async (opts) => {
|
|
129
|
+
const port = resolveHttpPort(opts);
|
|
130
|
+
const autoFix = opts.fix ?? false;
|
|
131
|
+
const dryRun = opts.dryRun ?? false;
|
|
132
|
+
if (dryRun && !autoFix) {
|
|
133
|
+
console.log(" ℹ️ --dry-run only has effect with --fix\n");
|
|
134
|
+
}
|
|
135
|
+
let effectivePort = port;
|
|
136
|
+
let baseUrl = `http://127.0.0.1:${port}`;
|
|
137
|
+
let issues = 0;
|
|
138
|
+
let fixed = 0; // issues that --fix successfully resolved during this run (flair#721)
|
|
139
|
+
let harperResponding = false;
|
|
140
|
+
let keyAgentIds = []; // populated by step 2 (Keys directory) below; feeds the flair#722 per-agent iteration
|
|
141
|
+
let nodeKeyIds = []; // node-scoped federation keys; feeds the #1514 driver gate
|
|
142
|
+
console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
|
|
143
|
+
// 0. Version check (flair#587) — offline-tolerant + cached, independent
|
|
144
|
+
// of Harper being up. A gap of ≥2 minor versions (or any major) is
|
|
145
|
+
// treated as loud/red — heuristic for "likely missed a security fix"
|
|
146
|
+
// since we don't have advisory data, only the version gap. A red gap
|
|
147
|
+
// counts as an issue (exit 1); a quieter yellow gap (one minor, or
|
|
148
|
+
// patch-only) is printed but doesn't fail doctor.
|
|
149
|
+
// ── flair#1072: the currency claim must be about the INSTANCE ─────────────
|
|
150
|
+
//
|
|
151
|
+
// This check used to run `checkVersion(__pkgVersion)` — the version of the
|
|
152
|
+
// CLI you happen to have installed — and print "flair <x> is current". When
|
|
153
|
+
// FLAIR_URL or --url points at a deployed instance, every other line doctor
|
|
154
|
+
// prints is genuinely remote, so that sentence reads as a statement about
|
|
155
|
+
// the thing you are talking to. It was a statement about your laptop.
|
|
156
|
+
//
|
|
157
|
+
// Reported against an instance five minors behind, where doctor said
|
|
158
|
+
// "current". Telling you that is doctor's entire job.
|
|
159
|
+
//
|
|
160
|
+
// UNKNOWN MUST NOT FALL BACK TO THE LOCAL NUMBER. An older instance may not
|
|
161
|
+
// expose its version at all, and the tempting fix is to use the one already
|
|
162
|
+
// in hand — which is precisely how this bug reads today. If the instance
|
|
163
|
+
// version cannot be determined, say so and count it as an issue rather than
|
|
164
|
+
// answering from the wrong machine.
|
|
165
|
+
const instanceVersion = await probeInstanceVersion(baseUrl);
|
|
166
|
+
const versionSubject = instanceVersion ?? null;
|
|
167
|
+
if (versionSubject === null) {
|
|
168
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `could not determine the version running at ${baseUrl} — not reporting currency. ` +
|
|
169
|
+
`(The local CLI is ${__pkgVersion}; that is NOT the instance.)`)}`);
|
|
170
|
+
issues++;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const versionCheckResult = await checkVersion(versionSubject);
|
|
174
|
+
const versionNudge = formatVersionNudge(versionCheckResult);
|
|
175
|
+
if (versionNudge) {
|
|
176
|
+
const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
|
|
177
|
+
const icon = versionNudge.severity === "red" ? render.wrap(render.c.red, "✗") : render.icons.warn;
|
|
178
|
+
console.log(` ${icon} ${render.wrap(color, versionNudge.message)}`);
|
|
179
|
+
if (versionNudge.severity === "red")
|
|
180
|
+
issues++;
|
|
181
|
+
}
|
|
182
|
+
else if (versionCheckResult.latest) {
|
|
183
|
+
console.log(` ${render.icons.ok} instance at ${baseUrl} runs flair ${versionSubject} — current`);
|
|
184
|
+
}
|
|
185
|
+
if (versionSubject !== __pkgVersion) {
|
|
186
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `local CLI is ${__pkgVersion}, instance is ${versionSubject} — they differ. ` +
|
|
187
|
+
`Commands run through the CLI; the instance serves the data.`)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// 0.5 npm global bin dir on PATH (flair#1134) — a user-prefix
|
|
191
|
+
// `npm i -g` succeeds and then `flair` is command-not-found because
|
|
192
|
+
// <prefix>/bin never made it into PATH. postinstall warns at install
|
|
193
|
+
// time, but lifecycle scripts are suppressed on several real paths
|
|
194
|
+
// (--ignore-scripts, bun without trustedDependencies, tar-swap
|
|
195
|
+
// deploys), so doctor re-runs the same check — cheap, local, and
|
|
196
|
+
// independent of Harper being up. When npm itself is absent or slow
|
|
197
|
+
// the check SKIPS silently: flair may be installed by other means,
|
|
198
|
+
// and "npm missing" has no actionable fix this check could print.
|
|
199
|
+
const npmGlobalPrefix = await resolveNpmGlobalPrefix();
|
|
200
|
+
if (npmGlobalPrefix) {
|
|
201
|
+
const binCheck = checkGlobalBinOnPath({
|
|
202
|
+
prefix: npmGlobalPrefix,
|
|
203
|
+
pathEnv: process.env.PATH,
|
|
204
|
+
shell: process.env.SHELL,
|
|
205
|
+
});
|
|
206
|
+
if ("message" in binCheck) {
|
|
207
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `npm global bin dir ${binCheck.binDir} is NOT on PATH — global npm installs (flair included) won't be found by name`)}`);
|
|
208
|
+
for (const line of binCheck.message.split("\n")) {
|
|
209
|
+
console.log(` ${render.wrap(render.c.dim, line)}`);
|
|
210
|
+
}
|
|
211
|
+
issues++;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
console.log(` ${render.icons.ok} npm global bin dir ${render.wrap(render.c.dim, binCheck.binDir)} is on PATH`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Helper: try to reach Harper on a given port.
|
|
218
|
+
// Must return true ONLY when Harper's /Health endpoint returns 200 OK.
|
|
219
|
+
// A generic HTTP status > 0 (flair#862) would accept 404 from a Node
|
|
220
|
+
// inspector on 9229 or any other service — "present but wrong" beats
|
|
221
|
+
// "absent but correct".
|
|
222
|
+
async function probePort(p) {
|
|
223
|
+
try {
|
|
224
|
+
const res = await fetch(`http://127.0.0.1:${p}/Health`, { signal: AbortSignal.timeout(3000) });
|
|
225
|
+
return res.ok; // 200-299 only — /Health returns { ok: true } on 200
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Helper: discover what port a Harper PID is listening on.
|
|
232
|
+
// Scans ALL listening ports for this PID and returns the first one that
|
|
233
|
+
// responds to /Health with 200 OK. This avoids picking a debug port (9229)
|
|
234
|
+
// or any non-Flair listener that happens to share the process (flair#862).
|
|
235
|
+
async function discoverPortFromPid(pid) {
|
|
236
|
+
// Defense-in-depth: caller already validates, but re-check here
|
|
237
|
+
if (!/^\d+$/.test(pid))
|
|
238
|
+
return null;
|
|
239
|
+
try {
|
|
240
|
+
const { execSync } = await import("node:child_process");
|
|
241
|
+
const out = execSync(`lsof -aPi -p ${pid} -sTCP:LISTEN -Fn 2>/dev/null || true`, { encoding: "utf-8" });
|
|
242
|
+
// Extract all ports from lsof -Fn output (lines like "n127.0.0.1:PORT")
|
|
243
|
+
const ports = [...out.matchAll(/n(?:\S+):(\d+)/g)].map(m => Number(m[1]));
|
|
244
|
+
if (ports.length === 0)
|
|
245
|
+
return null;
|
|
246
|
+
// Try each port until one responds to /Health with 200 OK
|
|
247
|
+
for (const port of ports) {
|
|
248
|
+
if (await probePort(port))
|
|
249
|
+
return port;
|
|
250
|
+
}
|
|
251
|
+
return null; // No port responded to /Health
|
|
252
|
+
}
|
|
253
|
+
catch { /* ignore */ }
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
// 1. Port check — is something listening?
|
|
257
|
+
// First, check PID file so we can cross-reference
|
|
258
|
+
const dataDir0 = defaultDataDir();
|
|
259
|
+
const pidFile0 = join(dataDir0, "hdb.pid");
|
|
260
|
+
let pidAlive = false;
|
|
261
|
+
let pidValue = "";
|
|
262
|
+
if (existsSync(pidFile0)) {
|
|
263
|
+
const rawPid = (await import("node:fs")).readFileSync(pidFile0, "utf-8").trim();
|
|
264
|
+
// Strict integer validation — PID must be purely numeric to prevent injection
|
|
265
|
+
if (/^\d+$/.test(rawPid)) {
|
|
266
|
+
pidValue = rawPid;
|
|
267
|
+
try {
|
|
268
|
+
process.kill(Number(pidValue), 0);
|
|
269
|
+
pidAlive = true;
|
|
270
|
+
}
|
|
271
|
+
catch { /* dead */ }
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
console.log(` ${render.icons.warn} PID file contains non-numeric value: ${render.wrap(render.c.dim, pidFile0)} — skipping`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (await probePort(port)) {
|
|
278
|
+
console.log(` ${render.icons.ok} Harper responding on port ${render.wrap(render.c.bold, String(port))}`);
|
|
279
|
+
harperResponding = true;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
// Port didn't respond — but if PID is alive, try to find the real port
|
|
283
|
+
let discoveredPort = null;
|
|
284
|
+
if (pidAlive) {
|
|
285
|
+
discoveredPort = await discoverPortFromPid(pidValue);
|
|
286
|
+
if (discoveredPort && discoveredPort !== port && await probePort(discoveredPort)) {
|
|
287
|
+
console.log(` ${render.icons.warn} Harper not on expected port ${port}, but responding on port ${render.wrap(render.c.bold, String(discoveredPort))} ${render.wrap(render.c.dim, `(PID ${pidValue})`)}`);
|
|
288
|
+
console.log(` ${render.wrap(render.c.dim, `Your config says port ${port} but Harper is actually running on ${discoveredPort}`)}`);
|
|
289
|
+
if (autoFix) {
|
|
290
|
+
if (dryRun) {
|
|
291
|
+
console.log(` ${render.wrap(render.c.dim, "Would update config to port")} ${discoveredPort}`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
// dataDir0 is defaultDataDir() — `flair doctor` has no
|
|
295
|
+
// --data-dir, so the default install is what it means, and
|
|
296
|
+
// saying so keeps that true when it grows one (flair#914).
|
|
297
|
+
persistDefaultInstallCoordinates(dataDir0, discoveredPort);
|
|
298
|
+
console.log(` ${render.icons.ok} Updated config to port ${discoveredPort}`);
|
|
299
|
+
fixed++;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(updates config to match running port)")}`);
|
|
304
|
+
}
|
|
305
|
+
effectivePort = discoveredPort;
|
|
306
|
+
baseUrl = `http://127.0.0.1:${discoveredPort}`;
|
|
307
|
+
harperResponding = true;
|
|
308
|
+
issues++;
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
console.log(` ${render.icons.error} Harper process alive (PID ${pidValue}) but not responding on any detected port`);
|
|
312
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
|
|
313
|
+
issues++;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
// No live PID — Harper genuinely isn't running
|
|
318
|
+
// Check if something else grabbed the port
|
|
319
|
+
try {
|
|
320
|
+
const { execSync } = await import("node:child_process");
|
|
321
|
+
// Listening sockets only, never our own PID — doctor has already
|
|
322
|
+
// probed this port over HTTP, so a bare lsof reports doctor's own
|
|
323
|
+
// process as the squatter and tells the operator to kill it
|
|
324
|
+
// (flair#905; see parseListeningPids).
|
|
325
|
+
const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
|
|
326
|
+
if (pids.length > 0) {
|
|
327
|
+
const lsof = pids.join(" ");
|
|
328
|
+
console.log(` ${render.icons.error} Nothing responding on port ${port} ${render.wrap(render.c.dim, `(port occupied by PID ${lsof})`)}`);
|
|
329
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} kill ${lsof} && flair restart`);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
console.log(` ${render.icons.error} Harper is not running`);
|
|
333
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
console.log(` ${render.icons.error} Harper is not running`);
|
|
338
|
+
if (autoFix) {
|
|
339
|
+
if (dryRun) {
|
|
340
|
+
console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
console.log(` ${render.wrap(render.c.dim, "Attempting restart...")}`);
|
|
344
|
+
try {
|
|
345
|
+
const { execSync } = await import("node:child_process");
|
|
346
|
+
execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${port}`, { stdio: "inherit" });
|
|
347
|
+
console.log(` ${render.icons.ok} Restart attempted`);
|
|
348
|
+
fixed++;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
console.log(` ${render.icons.error} Restart failed — try: flair init --agent-id <your-agent>`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
issues++;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// 1a. CLI ↔ running-server version handshake (flair#695 §B) — the
|
|
363
|
+
// version TRIPLE: this CLI's own version (__pkgVersion, checked against
|
|
364
|
+
// npm-latest in step 0 above), and the RUNNING server's reported
|
|
365
|
+
// version (GET /Health — public, no auth needed). A mismatch means the
|
|
366
|
+
// installed package was upgraded but the daemon hasn't restarted onto
|
|
367
|
+
// it yet — exactly the bare-npm trap the global preAction hook (above,
|
|
368
|
+
// every other command) nudges about on stderr; doctor prints the full
|
|
369
|
+
// picture here instead of a one-liner and `--fix` offers the restart.
|
|
370
|
+
let runningVersion = null;
|
|
371
|
+
if (harperResponding) {
|
|
372
|
+
try {
|
|
373
|
+
const healthRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(3000) });
|
|
374
|
+
if (healthRes.ok) {
|
|
375
|
+
const body = (await healthRes.json());
|
|
376
|
+
runningVersion = typeof body?.version === "string" ? body.version : null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch { /* leave runningVersion null — reported below as "unknown" */ }
|
|
380
|
+
if (runningVersion && runningVersion !== __pkgVersion) {
|
|
381
|
+
console.log(` ${render.icons.error} Version mismatch: CLI/installed ${render.wrap(render.c.bold, __pkgVersion)} but server is running ${render.wrap(render.c.bold, runningVersion)}`);
|
|
382
|
+
if (autoFix) {
|
|
383
|
+
if (dryRun) {
|
|
384
|
+
console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
try {
|
|
388
|
+
const { execSync } = await import("node:child_process");
|
|
389
|
+
execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${effectivePort}`, { stdio: "inherit" });
|
|
390
|
+
console.log(` ${render.icons.ok} Restarted onto ${__pkgVersion}`);
|
|
391
|
+
fixed++;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
console.log(` ${render.icons.error} Restart failed — try: flair restart`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
|
|
400
|
+
}
|
|
401
|
+
issues++;
|
|
402
|
+
}
|
|
403
|
+
else if (runningVersion) {
|
|
404
|
+
console.log(` ${render.icons.ok} Server running version matches CLI (${runningVersion})`);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
console.log(` ${render.icons.warn} Could not determine the running server's version`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// 2. Keys directory
|
|
411
|
+
const keysDir = defaultKeysDir();
|
|
412
|
+
if (existsSync(keysDir)) {
|
|
413
|
+
const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
|
|
414
|
+
// ~/.flair/keys is shared by agent Ed25519 signing keys and node-scoped
|
|
415
|
+
// federation keys (flair#1193). Only agent keys are signing identities;
|
|
416
|
+
// node keys are AES-GCM keystore blobs that must never be parsed as, or
|
|
417
|
+
// inferred as, an agent. Partition them out here so every downstream
|
|
418
|
+
// consumer of keyAgentIds (registration checks, --fix inference,
|
|
419
|
+
// fixCommandAgentHint) is node-free by construction.
|
|
420
|
+
const partitioned = partitionKeyIds(keyFiles.map((f) => f.replace(/\.key$/, "")), keysDir);
|
|
421
|
+
keyAgentIds = partitioned.agentKeyIds;
|
|
422
|
+
nodeKeyIds = partitioned.nodeKeyIds;
|
|
423
|
+
if (keyAgentIds.length > 0) {
|
|
424
|
+
console.log(` ${render.icons.ok} Keys found: ${render.wrap(render.c.bold, String(keyAgentIds.length))} agent(s) in ${render.wrap(render.c.dim, keysDir)}`);
|
|
425
|
+
if (partitioned.nodeKeyIds.length > 0) {
|
|
426
|
+
console.log(` ${render.icons.info} ${render.wrap(render.c.dim, `${partitioned.nodeKeyIds.length} node-scoped federation key(s) present — not agent signing keys; skipping`)}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else if (partitioned.nodeKeyIds.length > 0) {
|
|
430
|
+
// Node keys but no agent key: functionally there is no agent identity
|
|
431
|
+
// here. Report it plainly (not the old DECODER false alarm) and point
|
|
432
|
+
// at the real remedy. Kept a warn — not an issues++ — so a genuine
|
|
433
|
+
// federation-only host doesn't newly fail doctor's exit code.
|
|
434
|
+
console.log(` ${render.icons.warn} No agent signing key found — only ${render.wrap(render.c.bold, String(nodeKeyIds.length))} node-scoped federation key(s) in ${render.wrap(render.c.dim, keysDir)}`);
|
|
435
|
+
console.log(` ${render.wrap(render.c.dim, "These are Fabric node keys, not agent identities. Fix:")} flair init --agent-id <your-agent>`);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
console.log(` ${render.icons.error} Keys directory exists but no .key files found`);
|
|
439
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
|
|
440
|
+
issues++;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
console.log(` ${render.icons.error} Keys directory missing: ${render.wrap(render.c.dim, keysDir)}`);
|
|
445
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
|
|
446
|
+
issues++;
|
|
447
|
+
}
|
|
448
|
+
// 3. Config file (flair#1514) — same resolution Harper uses: cwd, then
|
|
449
|
+
// the component/package dir, then ~/.flair. Looking only at
|
|
450
|
+
// ~/.flair/config.yaml printed "using defaults" on wrapper-launched
|
|
451
|
+
// component dirs whose real config is ~/agents/flair/config.yaml.
|
|
452
|
+
const configLookup = {
|
|
453
|
+
cwd: process.cwd(),
|
|
454
|
+
homeDir: homedir(),
|
|
455
|
+
componentDir: flairPackageDir(),
|
|
456
|
+
};
|
|
457
|
+
const cfgPath = resolveFlairConfigYaml(configLookup);
|
|
458
|
+
if (cfgPath) {
|
|
459
|
+
const savedPort = readPortFromYamlFile(cfgPath) ?? readPortFromConfig();
|
|
460
|
+
console.log(` ${render.icons.ok} Config: ${render.wrap(render.c.dim, cfgPath)} ${render.wrap(render.c.dim, `(port: ${savedPort ?? "default"})`)}`);
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
const tried = flairConfigYamlCandidates(configLookup);
|
|
464
|
+
console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, tried[0] ?? configPath())} — using defaults`);
|
|
465
|
+
if (tried.length > 1) {
|
|
466
|
+
console.log(` ${render.wrap(render.c.dim, `also tried: ${tried.slice(1).join(", ")}`)}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// 3b. Ops API bind (flair#670) — report-only finding, never auto-fixed.
|
|
470
|
+
// Rebinding the ops API requires a Harper restart to take effect, so
|
|
471
|
+
// `doctor --fix` deliberately does not touch it here; the fix is
|
|
472
|
+
// `flair init` (re-run, then `flair restart` to apply it) or a manual
|
|
473
|
+
// harper-config.yaml edit + restart. flair#827: re-running `flair init`
|
|
474
|
+
// used to regenerate ~/.flair/admin-pass unconditionally, desyncing it
|
|
475
|
+
// from Harper's already-persisted credential and breaking admin auth on
|
|
476
|
+
// the very re-run this remedy prescribed — resolveInitAdminPasswordSource
|
|
477
|
+
// (see its doc comment) now reuses the existing password instead, so this
|
|
478
|
+
// remedy is safe to follow on a working install.
|
|
479
|
+
try {
|
|
480
|
+
const finding = opsApiBindFinding(readHarperConfig(defaultDataDir()));
|
|
481
|
+
if (finding?.allInterfaces) {
|
|
482
|
+
console.log(` ${render.icons.error} ${finding.message}`);
|
|
483
|
+
console.log(` ${render.wrap(render.c.dim, finding.remedy)}`);
|
|
484
|
+
issues++;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch { /* best-effort — don't fail doctor over a malformed harper-config.yaml */ }
|
|
488
|
+
// 3c. Ops-socket permission posture (flair#763) — report-only, never
|
|
489
|
+
// auto-fixed. Re-tightening a live socket needs a restart, so the remedy is
|
|
490
|
+
// `flair init`/restart (which re-applies the posture), not a `doctor --fix`.
|
|
491
|
+
// Only assessed when the socket exists (Harper has booted at least once).
|
|
492
|
+
try {
|
|
493
|
+
const socketPath = join(defaultDataDir(), "operations-server");
|
|
494
|
+
if (existsSync(socketPath)) {
|
|
495
|
+
const dirMode = statSync(dirname(socketPath)).mode;
|
|
496
|
+
const socketMode = statSync(socketPath).mode;
|
|
497
|
+
const groupOptIn = !!(process.env.FLAIR_SOCKET_GROUP && process.env.FLAIR_SOCKET_GROUP.trim().length > 0);
|
|
498
|
+
const verdict = classifyOpsSocketPosture(dirMode, socketMode, groupOptIn);
|
|
499
|
+
if (verdict.flagged) {
|
|
500
|
+
console.log(` ${render.icons.error} Ops socket permissions: ${verdict.reason}`);
|
|
501
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init ${render.wrap(render.c.dim, "(re-applies the 0700 dir / 0600 socket posture on next start; set FLAIR_SOCKET_GROUP for deliberate multi-user access)")}`);
|
|
502
|
+
issues++;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
catch { /* best-effort — a stat failure shouldn't fail doctor */ }
|
|
507
|
+
// 3d. The URL this instance tells the world to use (flair#1005, flair#1000).
|
|
508
|
+
//
|
|
509
|
+
// Asks the instance for its OWN discovery document rather than inferring
|
|
510
|
+
// anything from config: /OAuthMetadata's `issuer` is the exact field that was
|
|
511
|
+
// wrong in flair#1000, and it is the only thing that proves what a client
|
|
512
|
+
// will actually be handed. describePublicUrlFinding (src/component-env.ts) is
|
|
513
|
+
// pure decision logic, unit-tested, and documents in its own header why the
|
|
514
|
+
// detectable condition is DRIFT rather than "unset on a public instance" —
|
|
515
|
+
// doctor reaches this instance over loopback and cannot observe whether it is
|
|
516
|
+
// also reachable at a public address.
|
|
517
|
+
if (harperResponding) {
|
|
518
|
+
let advertisedIssuer = null;
|
|
519
|
+
try {
|
|
520
|
+
const res = await fetch(`${baseUrl}/OAuthMetadata`, { signal: AbortSignal.timeout(5000) });
|
|
521
|
+
if (res.ok) {
|
|
522
|
+
const doc = (await res.json());
|
|
523
|
+
if (typeof doc?.issuer === "string" && doc.issuer !== "")
|
|
524
|
+
advertisedIssuer = doc.issuer;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch { /* unreachable/unparseable → null → the finding is skipped, not passed */ }
|
|
528
|
+
// The component directory for a local install is the flair package itself:
|
|
529
|
+
// `flair start` spawns `harper run .` with cwd = flairPackageDir().
|
|
530
|
+
// That path is often inside node_modules on an npm install-g; doctor still
|
|
531
|
+
// READs it for drift detection, but describePublicUrlFinding never names
|
|
532
|
+
// it as the fix (flair#1313 — wiped on every upgrade).
|
|
533
|
+
const componentEnvPath = join(flairPackageDir(), COMPONENT_ENV_FILENAME);
|
|
534
|
+
let componentEnvValue = null;
|
|
535
|
+
try {
|
|
536
|
+
if (existsSync(componentEnvPath)) {
|
|
537
|
+
componentEnvValue = readEnvValue(readFileSync(componentEnvPath, "utf-8"), PUBLIC_URL_KEY);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
catch { /* unreadable → treat as absent */ }
|
|
541
|
+
const finding = describePublicUrlFinding({
|
|
542
|
+
advertisedIssuer,
|
|
543
|
+
componentEnvValue,
|
|
544
|
+
processEnvValue: process.env.FLAIR_PUBLIC_URL ?? null,
|
|
545
|
+
componentEnvPath,
|
|
546
|
+
});
|
|
547
|
+
if (finding) {
|
|
548
|
+
const icon = finding.icon === "ok" ? render.icons.ok
|
|
549
|
+
: finding.icon === "warn" ? render.icons.warn
|
|
550
|
+
: render.icons.error;
|
|
551
|
+
console.log(` ${icon} ${finding.message}`);
|
|
552
|
+
if (finding.fixHint)
|
|
553
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${finding.fixHint}`);
|
|
554
|
+
if (finding.isIssue)
|
|
555
|
+
issues++;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
|
|
559
|
+
//
|
|
560
|
+
// The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
|
|
561
|
+
// not loaded: SemanticSearch falls back to keyword-only scan, and an
|
|
562
|
+
// unauthenticated probe 401s → "cannot verify" → no issue counted. A clean-VM
|
|
563
|
+
// dogfood found semantic search DEAD out of the box (sudo/root-owned install
|
|
564
|
+
// can't write the models symlink → EACCES) while `flair doctor` reported
|
|
565
|
+
// "no issues found". This now stores a memory with a distinctive phrase and
|
|
566
|
+
// searches for a PARAPHRASE (no shared keywords). If the top result isn't
|
|
567
|
+
// recovered by MEANING, recall-by-meaning is broken and doctor FAILS LOUDLY.
|
|
568
|
+
if (harperResponding) {
|
|
569
|
+
const semanticStatus = await verifySemanticSearch(baseUrl, opts.agent, defaultKeysDir());
|
|
570
|
+
switch (semanticStatus.state) {
|
|
571
|
+
case "ok":
|
|
572
|
+
console.log(` ${render.icons.ok} Embeddings: semantic search operational ${render.wrap(render.c.dim, `(paraphrase recall verified, score ${semanticStatus.score.toFixed(2)})`)}`);
|
|
573
|
+
break;
|
|
574
|
+
case "degraded":
|
|
575
|
+
// LOUD failure — never report all-clear when recall-by-meaning is dead.
|
|
576
|
+
console.log(` ${render.icons.error} Semantic search DEGRADED ${render.wrap(render.c.dim, `— ${semanticStatus.detail}`)}`);
|
|
577
|
+
console.log(` ${render.wrap(render.c.red, "Embeddings are not loaded; recall-by-meaning will NOT work.")}`);
|
|
578
|
+
console.log(` ${render.wrap(render.c.dim, "Common cause: the embeddings component lacks write access (sudo/root global installs).")}`);
|
|
579
|
+
console.log(` ${render.wrap(render.c.dim, "See:")} docs/troubleshooting.md ${render.wrap(render.c.dim, "→ \"Semantic search DEGRADED\"")}`);
|
|
580
|
+
issues++;
|
|
581
|
+
break;
|
|
582
|
+
case "failed":
|
|
583
|
+
// flair#1501: a rejected signature is LOUD. It is either a real auth
|
|
584
|
+
// defect (the key is unregistered or stale) or a doctor defect, and
|
|
585
|
+
// both need a person — never soften it to "not verified". The detail
|
|
586
|
+
// names the identity and key path the probe signed with.
|
|
587
|
+
console.log(` ${render.icons.error} Embeddings: probe rejected ${render.wrap(render.c.dim, `— ${semanticStatus.detail}`)}`);
|
|
588
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: register this key on the instance (`flair agent add <id>`) or pass --agent <a registered agent id>.")}`);
|
|
589
|
+
issues++;
|
|
590
|
+
break;
|
|
591
|
+
case "skipped": {
|
|
592
|
+
// Could not run the round-trip. Don't claim all-clear — surface that
|
|
593
|
+
// the check was skipped, but don't count it as a hard issue since
|
|
594
|
+
// the user may simply not have an agent yet.
|
|
595
|
+
//
|
|
596
|
+
// flair#1023: the remedy is chosen from the classified reason
|
|
597
|
+
// (embeddingsSkipRemedy, src/doctor-client.ts) instead of being
|
|
598
|
+
// printed unconditionally. A key that will not decode gets no
|
|
599
|
+
// "pass --agent" advice, because following it changes nothing.
|
|
600
|
+
console.log(` ${render.icons.warn} Embeddings: not verified ${render.wrap(render.c.dim, `(${semanticStatus.detail})`)}`);
|
|
601
|
+
const remedy = embeddingsSkipRemedy(semanticStatus.reason);
|
|
602
|
+
if (remedy)
|
|
603
|
+
console.log(` ${render.wrap(render.c.dim, remedy)}`);
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// 4b. Audit-log positive control (flair#970) — REAL write→read_audit_log
|
|
609
|
+
// round-trip, only if Harper is responding. `describe_table` reporting
|
|
610
|
+
// `audit: true` proves nothing: a node that joined or resynced via
|
|
611
|
+
// cluster base copy holds zero audit history while reporting audit
|
|
612
|
+
// enabled and answering read_audit_log with clean empty (harper#2212).
|
|
613
|
+
// So doctor writes probe rows and asserts their audit entries come back —
|
|
614
|
+
// never trusts the flag. Same ok/degraded/skipped discipline as the
|
|
615
|
+
// embeddings check above: skipped is rendered UNVERIFIED, never as a pass.
|
|
616
|
+
if (harperResponding) {
|
|
617
|
+
// read_audit_log only exists on the ops API (its own port), which the
|
|
618
|
+
// agent's Ed25519 header cannot authenticate — resolve the local admin
|
|
619
|
+
// credential (env or ~/.flair/admin-pass; never prompts). A file with
|
|
620
|
+
// unsafe permissions throws — that is "could not probe", not "broken".
|
|
621
|
+
let auditAdminPass;
|
|
622
|
+
let auditCredIssue = null;
|
|
623
|
+
try {
|
|
624
|
+
auditAdminPass = resolveLocalAdminPass(undefined);
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
auditCredIssue = err instanceof Error ? err.message : String(err);
|
|
628
|
+
}
|
|
629
|
+
const auditStatus = auditCredIssue
|
|
630
|
+
? { state: "skipped", reason: "no-admin-credentials", detail: auditCredIssue }
|
|
631
|
+
: await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, resolveAdminUser(undefined), auditAdminPass);
|
|
632
|
+
switch (auditStatus.state) {
|
|
633
|
+
case "ok":
|
|
634
|
+
// Present-tense claim ONLY (see AuditVerifyResult): the probe
|
|
635
|
+
// proves the log records writes NOW — never that history is
|
|
636
|
+
// complete. Overclaiming here would rebuild the false trust
|
|
637
|
+
// anchor this check exists to kill, one layer up.
|
|
638
|
+
console.log(` ${render.icons.ok} Audit log: recording (verified now) ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
|
|
639
|
+
break;
|
|
640
|
+
case "degraded":
|
|
641
|
+
if (auditStatus.cause === "disabled") {
|
|
642
|
+
console.log(` ${render.icons.error} Audit log DISABLED ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
|
|
643
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
console.log(` ${render.icons.error} Audit log NOT RECORDING ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
|
|
647
|
+
console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
|
|
648
|
+
console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
|
|
649
|
+
console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
|
|
650
|
+
}
|
|
651
|
+
issues++;
|
|
652
|
+
break;
|
|
653
|
+
case "failed":
|
|
654
|
+
// Same loud discipline as the embeddings probe above (flair#1501).
|
|
655
|
+
console.log(` ${render.icons.error} Audit log: probe rejected ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
|
|
656
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: register this key on the instance (`flair agent add <id>`) or pass --agent <a registered agent id>.")}`);
|
|
657
|
+
issues++;
|
|
658
|
+
break;
|
|
659
|
+
case "skipped":
|
|
660
|
+
// An unrun check must not look like a pass — UNVERIFIED, visually
|
|
661
|
+
// distinct from ok, but not a hard issue (mirrors the embeddings
|
|
662
|
+
// skip: the operator may simply have no agent or no local admin
|
|
663
|
+
// credential on this box).
|
|
664
|
+
console.log(` ${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditStatus.detail})`);
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// 5. Stale PID file (skip if already reported in port check)
|
|
669
|
+
const dataDir = defaultDataDir();
|
|
670
|
+
const pidFile = join(dataDir, "hdb.pid");
|
|
671
|
+
if (existsSync(pidFile)) {
|
|
672
|
+
const pidContent = (await import("node:fs")).readFileSync(pidFile, "utf-8").trim();
|
|
673
|
+
try {
|
|
674
|
+
process.kill(Number(pidContent), 0);
|
|
675
|
+
if (harperResponding) {
|
|
676
|
+
console.log(` ${render.icons.ok} PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is alive)`)}`);
|
|
677
|
+
}
|
|
678
|
+
// If not responding, we already reported the issue in step 1
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
console.log(` ${render.icons.error} Stale PID file: ${render.wrap(render.c.dim, pidFile)} ${render.wrap(render.c.dim, `(process ${pidContent} is dead)`)}`);
|
|
682
|
+
if (autoFix) {
|
|
683
|
+
if (dryRun) {
|
|
684
|
+
console.log(` ${render.wrap(render.c.dim, "Would remove:")} ${pidFile}`);
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
(await import("node:fs")).unlinkSync(pidFile);
|
|
688
|
+
console.log(` ${render.icons.ok} Removed stale PID file`);
|
|
689
|
+
fixed++;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} rm ${pidFile} && flair restart`);
|
|
694
|
+
}
|
|
695
|
+
issues++;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// 6. Data directory
|
|
699
|
+
if (existsSync(dataDir)) {
|
|
700
|
+
console.log(` ${render.icons.ok} Data directory: ${render.wrap(render.c.dim, dataDir)}`);
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
// Check ~/harper/ (common alternative)
|
|
704
|
+
const altDir = join(homedir(), "harper");
|
|
705
|
+
if (existsSync(altDir)) {
|
|
706
|
+
console.log(` ${render.icons.warn} Data at ${render.wrap(render.c.dim, "~/harper/")} (not ${render.wrap(render.c.dim, "~/.flair/data")}) — old install location`);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
console.log(` ${render.icons.error} No data directory found`);
|
|
710
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init --agent-id <your-agent>`);
|
|
711
|
+
issues++;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// 7. Client integration (flair#588) — the first 6 checks diagnose the
|
|
715
|
+
// SERVER side. This diagnoses whether Flair is actually wired to a real
|
|
716
|
+
// client: for MCP clients (Claude Code, Codex, Gemini, Cursor,
|
|
717
|
+
// Antigravity) the MCP block present + reachable + the configured agent
|
|
718
|
+
// genuinely registered; for pi (a NATIVE EXTENSION host — flair#1342) the
|
|
719
|
+
// pi-flair reference in pi's own settings, including the flair#1346
|
|
720
|
+
// npm:-under-"extensions" trap; plus CLAUDE.md (Claude Code) and the
|
|
721
|
+
// SessionStart hook (Claude Code + Codex — flair#1148). Reuses
|
|
722
|
+
// detectClients() rather than reimplementing client detection.
|
|
723
|
+
console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
|
|
724
|
+
// Prompt y/N before a content-editing fix, but only when interactive —
|
|
725
|
+
// in a non-TTY context (CI, scripts) --fix itself is the consent signal,
|
|
726
|
+
// matching how doctor's other --fix branches already behave unprompted.
|
|
727
|
+
// Mirrors the confirm pattern at `flair fabric upgrade` (~line 6258).
|
|
728
|
+
async function confirmFix(question) {
|
|
729
|
+
if (!process.stdin.isTTY)
|
|
730
|
+
return true;
|
|
731
|
+
const { createInterface } = await import("node:readline");
|
|
732
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
733
|
+
const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
|
|
734
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
735
|
+
}
|
|
736
|
+
const detectedClients = detectClients().filter((c) => c.detected);
|
|
737
|
+
// flair#1439 — install-health (MCP, FLAIR_URL, CLAUDE.md, SessionStart
|
|
738
|
+
// hook, verified-read plan, keys classification, launchd) is the same
|
|
739
|
+
// catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
|
|
740
|
+
// both. Extra doctor UX (pi, --fix, execution probe, continuity,
|
|
741
|
+
// agent registration) stays below and does not redefine those checks.
|
|
742
|
+
//
|
|
743
|
+
// flair#1573 slice b — launchd management is diagnosed + repaired by its
|
|
744
|
+
// own section below (planLaunchdRepairFor / repairLaunchdManagement), not
|
|
745
|
+
// by the install-health catalog. The catalog's launchd check stays for
|
|
746
|
+
// `upgrade` (flair#1022), but doctor would otherwise double-count the same
|
|
747
|
+
// drift (catalog "detached" fail + repair "regenerate"/"adopt"/"refuse").
|
|
748
|
+
const doctorCatalogIds = DOCTOR_CHECK_IDS.filter((id) => id !== "launchd-management");
|
|
749
|
+
const doctorCtx = {
|
|
750
|
+
homeDir: homedir(),
|
|
751
|
+
cwd: process.cwd(),
|
|
752
|
+
detectedClientIds: detectedClients.map((c) => c.id),
|
|
753
|
+
keysDir,
|
|
754
|
+
keyAgentIds,
|
|
755
|
+
agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
|
|
756
|
+
};
|
|
757
|
+
const catalogBefore = runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds });
|
|
758
|
+
if (detectedClients.length === 0) {
|
|
759
|
+
console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
let claudeCodeAgentId;
|
|
763
|
+
let codexAgentId;
|
|
764
|
+
let anyKnownAgentId;
|
|
765
|
+
// `doctor --fix` writes client configs through the same wire functions
|
|
766
|
+
// init does, so it owes the user the same warning when the spec it would
|
|
767
|
+
// write cannot be pinned (flair#907).
|
|
768
|
+
if (autoFix) {
|
|
769
|
+
const pinWarning = unpinnedSpecWarning();
|
|
770
|
+
if (pinWarning) {
|
|
771
|
+
for (const line of pinWarning.split("\n"))
|
|
772
|
+
console.log(` ${render.icons.warn} ${line}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
for (const client of detectedClients) {
|
|
776
|
+
// flair#989 — pi is a dead namespace: the pi (kind:
|
|
777
|
+
// "native-extension") check is removed from doctor entirely. pi was
|
|
778
|
+
// the last non-MCP client here, and a detected-but-unwired pi was
|
|
779
|
+
// counted as an install failure for a namespace nobody opts into any
|
|
780
|
+
// more. Doctor now diagnoses only MCP clients the user wired (below).
|
|
781
|
+
if (client.kind !== "mcp")
|
|
782
|
+
continue;
|
|
783
|
+
const block = readClientMcpBlock(client.id, homedir());
|
|
784
|
+
if (client.id === "claude-code" && block.agentId)
|
|
785
|
+
claudeCodeAgentId = block.agentId;
|
|
786
|
+
if (client.id === "codex" && block.agentId)
|
|
787
|
+
codexAgentId = block.agentId;
|
|
788
|
+
if (block.agentId)
|
|
789
|
+
anyKnownAgentId = anyKnownAgentId ?? block.agentId;
|
|
790
|
+
if (!block.present) {
|
|
791
|
+
// flair#989: this client is DETECTED (binary/config on the box) but
|
|
792
|
+
// was never wired to Flair — the user did not opt into it. That is
|
|
793
|
+
// not an install FAILURE, so it renders as info, never a ✗, and is
|
|
794
|
+
// not counted (the catalog's opt-in mcp-block check owns the count).
|
|
795
|
+
// `--fix` still offers to wire it, on the user's y/N consent.
|
|
796
|
+
console.log(` ${render.icons.info} ${client.label}: detected but not wired to Flair — optional (no Flair MCP server in ${render.wrap(render.c.dim, block.configPath)})`);
|
|
797
|
+
if (autoFix) {
|
|
798
|
+
if (dryRun) {
|
|
799
|
+
console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
const proceed = await confirmFix(` Wire ${client.label} now? [y/N] `);
|
|
803
|
+
if (!proceed) {
|
|
804
|
+
console.log(` Skipped.`);
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
// flair#802b: fall back to the sole locally-keyed agent when
|
|
808
|
+
// nothing else identifies one — the only case doctor can
|
|
809
|
+
// infer without being told (see inferSoleAgentId's doc
|
|
810
|
+
// comment in doctor-client.ts for why 0/2+ keys don't guess).
|
|
811
|
+
// flair#1193: resolveFixAgentId additionally refuses a
|
|
812
|
+
// node-scoped federation id from ANY source (inference, env,
|
|
813
|
+
// or a wired block a prior buggy run may have poisoned) — a
|
|
814
|
+
// node id can't sign, so wiring it would authenticate the
|
|
815
|
+
// connector as a phantom unregistered node.
|
|
816
|
+
const fixAgentId = resolveFixAgentId({
|
|
817
|
+
optsAgent: opts.agent,
|
|
818
|
+
envAgentId: process.env.FLAIR_AGENT_ID,
|
|
819
|
+
anyKnownAgentId,
|
|
820
|
+
keyAgentIds,
|
|
821
|
+
keysDir: defaultKeysDir(),
|
|
822
|
+
});
|
|
823
|
+
if (!fixAgentId) {
|
|
824
|
+
if (keyAgentIds.length > 1) {
|
|
825
|
+
console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: multiple agents found (${[...keyAgentIds].sort().join(", ")}) — pass --agent <id> to choose which one`);
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent identity found in keys/ — run \`flair init --agent <name>\` or \`flair agent add <name>\` before wiring a connector`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: resolveWireFlairUrl(block.flairUrl, baseUrl) };
|
|
833
|
+
const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
|
|
834
|
+
client.id === "codex" ? wireCodex(wireEnv) :
|
|
835
|
+
client.id === "gemini" ? wireGemini(wireEnv) :
|
|
836
|
+
client.id === "antigravity" ? wireAntigravity(wireEnv) :
|
|
837
|
+
wireCursor(wireEnv);
|
|
838
|
+
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
839
|
+
if (wireResult.ok) {
|
|
840
|
+
if (client.id === "claude-code")
|
|
841
|
+
claudeCodeAgentId = fixAgentId;
|
|
842
|
+
if (client.id === "codex")
|
|
843
|
+
codexAgentId = fixAgentId;
|
|
844
|
+
anyKnownAgentId = anyKnownAgentId ?? fixAgentId;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
// flair#802b: only splice in a concrete --agent if the id isn't
|
|
852
|
+
// already resolvable some other way — an explicit --agent /
|
|
853
|
+
// FLAIR_AGENT_ID / an already-wired client's agent id means bare
|
|
854
|
+
// `--fix` already works, so don't clutter the suggestion.
|
|
855
|
+
const knownAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
|
|
856
|
+
const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
|
|
857
|
+
console.log(` ${render.wrap(render.c.dim, "To wire it (optional):")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label})`)}`);
|
|
858
|
+
}
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
|
|
862
|
+
// flair#1287: a block with FLAIR_AGENT_ID but no FLAIR_URL is a
|
|
863
|
+
// WORKING setup — flair-client falls back to its built-in default —
|
|
864
|
+
// and must never be reported as unconfigured. Say which URL applies
|
|
865
|
+
// and keep verifying against it, exactly as for an explicit one.
|
|
866
|
+
const eff = effectiveFlairUrl(block);
|
|
867
|
+
const urlLabel = eff.defaulted ? `${eff.url} (client default)` : eff.url;
|
|
868
|
+
if (eff.defaulted) {
|
|
869
|
+
console.log(` ${render.icons.info} FLAIR_URL not set — flair-mcp defaults to ${render.wrap(render.c.dim, eff.url)}`);
|
|
870
|
+
}
|
|
871
|
+
const reachable = await probeFlairReachable(eff.url);
|
|
872
|
+
if (!reachable) {
|
|
873
|
+
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, urlLabel)} not reachable — cannot verify agent registration`);
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim, urlLabel)} reachable`);
|
|
877
|
+
const reg = await checkAgentRegistered(eff.url, block.agentId, defaultKeysDir());
|
|
878
|
+
if (reg.state === "registered") {
|
|
879
|
+
console.log(` ${render.icons.ok} agent '${block.agentId}' registered`);
|
|
880
|
+
}
|
|
881
|
+
else if (reg.state === "not-registered") {
|
|
882
|
+
console.log(` ${render.icons.error} agent '${block.agentId}' is NOT registered on this Flair instance`);
|
|
883
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${block.agentId}`);
|
|
884
|
+
issues++;
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
// flair#1023: `reachable` was just established two lines above, so
|
|
888
|
+
// reuse the same self-inconsistency guard the agent gates use
|
|
889
|
+
// rather than echoing a detail that may claim the opposite.
|
|
890
|
+
const finding = describeAgentGateFinding(block.agentId, reg.state, reg.detail, { instanceReachable: reachable });
|
|
891
|
+
console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// flair#989: the harness-specific checks below (CLAUDE.md, SessionStart
|
|
895
|
+
// hook, continuity, Codex hook) run only for a harness the user actually
|
|
896
|
+
// WIRED — its MCP block is present. A harness merely DETECTED on the box
|
|
897
|
+
// but never opted into owes none of these; flagging them was the false-
|
|
898
|
+
// positive this fix removes. Read the block fresh so a `--fix` that just
|
|
899
|
+
// wired the client during the loop above is reflected here.
|
|
900
|
+
const claudeCodeDetected = detectedClients.some((c) => c.id === "claude-code");
|
|
901
|
+
const claudeCodeConfigured = claudeCodeDetected && readClientMcpBlock("claude-code", homedir()).present;
|
|
902
|
+
const codexConfigured = detectedClients.some((c) => c.id === "codex") && readClientMcpBlock("codex", homedir()).present;
|
|
903
|
+
// Claude-Code-specific: CLAUDE.md + SessionStart hook + continuity.
|
|
904
|
+
// Codex has a SessionStart hook too (checked below); CLAUDE.md and
|
|
905
|
+
// continuity stay Claude Code only.
|
|
906
|
+
//
|
|
907
|
+
// flair#989: CLAUDE.md and the SessionStart hook are wiring-dependent —
|
|
908
|
+
// they apply, and can only fail, once Claude Code is WIRED — so they are
|
|
909
|
+
// gated on `claudeCodeConfigured`. Continuity (below) is a separate
|
|
910
|
+
// opt-in that renders "not enabled" as info and never a failure, so it
|
|
911
|
+
// stays gated on mere detection (flair#1324/#1257).
|
|
912
|
+
if (claudeCodeConfigured) {
|
|
913
|
+
const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
|
|
914
|
+
if (claudeMd.present) {
|
|
915
|
+
console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
console.log(` ${render.icons.error} CLAUDE.md: bootstrap instruction not found (checked ${render.wrap(render.c.dim, join(process.cwd(), "CLAUDE.md"))} and ${render.wrap(render.c.dim, join(homedir(), ".claude", "CLAUDE.md"))})`);
|
|
919
|
+
if (autoFix) {
|
|
920
|
+
if (dryRun) {
|
|
921
|
+
console.log(` ${render.wrap(render.c.dim, "Would append bootstrap instruction to")} ${join(process.cwd(), "CLAUDE.md")}`);
|
|
922
|
+
}
|
|
923
|
+
else {
|
|
924
|
+
const proceed = await confirmFix(` Add the Flair bootstrap line to ./CLAUDE.md? [y/N] `);
|
|
925
|
+
if (!proceed) {
|
|
926
|
+
console.log(` Skipped.`);
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
const fixRes = fixClaudeMdBootstrap(process.cwd());
|
|
930
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the mcp__flair__bootstrap line to ./CLAUDE.md)")}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
// flair#1007: presence was never the problem — the failing entry was
|
|
939
|
+
// perfectly well-formed. inspectSessionStartHook() additionally RUNS
|
|
940
|
+
// the registered command (bounded, side-effect-free via
|
|
941
|
+
// FLAIR_HOOK_PROBE) so doctor can tell "wired" from "wired and still
|
|
942
|
+
// works", and reports the shell-level silencing separately so an
|
|
943
|
+
// already-installed loud hook can be upgraded rather than only
|
|
944
|
+
// diagnosed.
|
|
945
|
+
const hook = inspectSessionStartHook(homedir());
|
|
946
|
+
if (hook.present) {
|
|
947
|
+
// flair#1485: pin ≠ installed CLI version is a failure, never a
|
|
948
|
+
// ✓ "still runs". Check freshness first so a stale pin cannot
|
|
949
|
+
// hide behind the execution probe. Catalog owns the issue count.
|
|
950
|
+
const claudeStale = staleSessionStartHookPins(homedir()).find((r) => r.target.id === "claude-code");
|
|
951
|
+
if (claudeStale) {
|
|
952
|
+
console.log(` ${render.icons.error} SessionStart hook: pinned to flair-mcp@${claudeStale.pin} (installed CLI is ${flairCliVersion()}) — the hook still launches the OLD adapter on every session`);
|
|
953
|
+
if (autoFix) {
|
|
954
|
+
if (dryRun) {
|
|
955
|
+
console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
const repin = repinSessionStartHook(homedir(), "claude-code");
|
|
959
|
+
console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install ${render.wrap(render.c.dim, "(re-pins the hook to the installed CLI version)")}`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
else if (hook.execution === "broken") {
|
|
967
|
+
// Two very different states that share one probe outcome:
|
|
968
|
+
//
|
|
969
|
+
// 1. Silenced (current) command that didn't run — the npx cache
|
|
970
|
+
// is cold, the machine is offline, or the adapter hasn't been
|
|
971
|
+
// fetched yet. On a fresh install this is NORMAL: the hook is
|
|
972
|
+
// wired but no Claude Code session has exercised it yet.
|
|
973
|
+
// Report as informational, not a warning, and never suggest
|
|
974
|
+
// reinstall — the setup is correct, the environment just
|
|
975
|
+
// hasn't warmed yet.
|
|
976
|
+
//
|
|
977
|
+
// 2. Unsilenced (legacy) command that didn't run — the hook has
|
|
978
|
+
// been in place long enough that a cold cache is not the
|
|
979
|
+
// explanation. This IS a genuine failure: warn and name the
|
|
980
|
+
// actual state with a fitting remedy.
|
|
981
|
+
if (hook.silenced) {
|
|
982
|
+
console.log(` ${render.icons.ok} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)} — not yet exercised`);
|
|
983
|
+
console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
|
|
984
|
+
console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
|
|
985
|
+
console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Claude Code session will warm the npx cache.")}`);
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
|
|
989
|
+
console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
|
|
990
|
+
console.log(` ${render.wrap(render.c.dim, "The hook command could not be executed. Check that npx can resolve")}`);
|
|
991
|
+
console.log(` ${render.wrap(render.c.dim, "@tpsdev-ai/flair-mcp — a cold npx cache or network issue")}`);
|
|
992
|
+
console.log(` ${render.wrap(render.c.dim, "issue can prevent the adapter from running on its first invocation.")}`);
|
|
993
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites the hook to the current silent-failure form)")}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
else if (hook.execution === "unknown") {
|
|
997
|
+
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"})`)}`);
|
|
998
|
+
}
|
|
999
|
+
else if (!hook.ours) {
|
|
1000
|
+
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)")}`);
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
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")}`);
|
|
1004
|
+
}
|
|
1005
|
+
// Independent of whether it runs today: would it stay quiet if it
|
|
1006
|
+
// stopped? Only offered as a repair when the command is the exact
|
|
1007
|
+
// string Flair itself wrote — a hand-edited or pinned hook is the
|
|
1008
|
+
// user's, and doctor reports on it rather than rewriting it.
|
|
1009
|
+
if (!hook.silenced && hook.ours) {
|
|
1010
|
+
console.log(` ${render.icons.warn} SessionStart hook: a failure would print an error on every session (this command predates the silent-failure fix)`);
|
|
1011
|
+
if (hook.upgradable) {
|
|
1012
|
+
if (autoFix) {
|
|
1013
|
+
if (dryRun) {
|
|
1014
|
+
console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
|
|
1015
|
+
}
|
|
1016
|
+
else {
|
|
1017
|
+
const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
|
|
1018
|
+
if (!proceed) {
|
|
1019
|
+
console.log(` Skipped.`);
|
|
1020
|
+
}
|
|
1021
|
+
else {
|
|
1022
|
+
const upgrade = upgradeSessionStartHookCommand(homedir());
|
|
1023
|
+
console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
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)")}`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
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`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
console.log(` ${render.icons.error} SessionStart hook: not found in ${render.wrap(render.c.dim, hook.path)}`);
|
|
1038
|
+
if (autoFix) {
|
|
1039
|
+
if (dryRun) {
|
|
1040
|
+
console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
|
|
1044
|
+
if (!proceed) {
|
|
1045
|
+
console.log(` Skipped.`);
|
|
1046
|
+
}
|
|
1047
|
+
else {
|
|
1048
|
+
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1049
|
+
const fixRes = fixSessionStartHook(homedir(), fixAgentId);
|
|
1050
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
} // end CLAUDE.md + SessionStart hook (claudeCodeConfigured)
|
|
1059
|
+
// Continuity capture is a standalone Claude Code opt-in — shown whenever
|
|
1060
|
+
// Claude Code is DETECTED, independent of MCP wiring (flair#1324/#1257).
|
|
1061
|
+
if (claudeCodeDetected) {
|
|
1062
|
+
// flair#1257 slice 2 — continuity capture pair (the check-5 twin of
|
|
1063
|
+
// the SessionStart check above: installed / absent / stale-form).
|
|
1064
|
+
// Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
|
|
1065
|
+
// opt-in — so "absent" renders as informational "not enabled": NEVER
|
|
1066
|
+
// a pass (an unrun check must not look green), never counted as an
|
|
1067
|
+
// issue, and NEVER wired by --fix (flair#1324: doctor's fixable set
|
|
1068
|
+
// is broken state; initiating an opt-in the user hasn't made is not a
|
|
1069
|
+
// fix — a y/N prompt auto-answers yes in every non-TTY run, so it was
|
|
1070
|
+
// no consent gate at all; enablement is `flair hook install
|
|
1071
|
+
// --continuity` only). A partial or stale pair IS evidence of a prior
|
|
1072
|
+
// opt-in, so repairing it to the complete current form remains a
|
|
1073
|
+
// legitimate --fix.
|
|
1074
|
+
const continuity = checkContinuityCaptureHooks(homedir());
|
|
1075
|
+
if (continuity.state === "installed") {
|
|
1076
|
+
console.log(` ${render.icons.ok} Continuity capture hooks: PostToolUse + Stop wired in ${render.wrap(render.c.dim, continuity.path)}`);
|
|
1077
|
+
}
|
|
1078
|
+
else if (continuity.state === "absent") {
|
|
1079
|
+
console.log(` ${render.icons.info} Continuity capture hooks: not enabled ${render.wrap(render.c.dim, "(opt-in — auto-journal working state into the ephemeral memory tier; enable: flair hook install --continuity)")}`);
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
const continuityDetail = continuity.state === "partial"
|
|
1083
|
+
? (!continuity.postToolUse.present ? "the PostToolUse entry is missing" : "the Stop entry is missing")
|
|
1084
|
+
: "an entry is not the current form (unsilenced, hand-altered, or a drifted PostToolUse matcher)";
|
|
1085
|
+
console.log(` ${render.icons.warn} Continuity capture hooks: ${continuity.state} — ${continuityDetail}`);
|
|
1086
|
+
if (autoFix) {
|
|
1087
|
+
if (dryRun) {
|
|
1088
|
+
console.log(` ${render.wrap(render.c.dim, "Would rewrite the continuity capture hooks in")} ${continuity.path}`);
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
const proceed = await confirmFix(` Rewrite the continuity capture hooks in ${continuity.path} to the current form? [y/N] `);
|
|
1092
|
+
if (!proceed) {
|
|
1093
|
+
console.log(` Skipped.`);
|
|
1094
|
+
}
|
|
1095
|
+
else {
|
|
1096
|
+
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1097
|
+
// Preserve the FLAIR_URL an existing entry already carries —
|
|
1098
|
+
// a repair must never silently re-point the hooks at a
|
|
1099
|
+
// different instance.
|
|
1100
|
+
const existingCommand = continuity.postToolUse.command || continuity.stop.command || "";
|
|
1101
|
+
const existingUrl = existingCommand.match(/FLAIR_URL=(\S+)/)?.[1];
|
|
1102
|
+
const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId, existingUrl);
|
|
1103
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
1104
|
+
if (fixRes.ok && fixRes.changed)
|
|
1105
|
+
fixed++;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites both entries to the current form — same agent, same instance)")}`);
|
|
1111
|
+
}
|
|
1112
|
+
issues++;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
// Codex SessionStart hook (flair#1148) — same flair-session-start
|
|
1116
|
+
// command Claude Code uses, written to ~/.codex/hooks.json. Continuity
|
|
1117
|
+
// and CLAUDE.md stay Claude-Code-only; Codex's session-start mechanism
|
|
1118
|
+
// is the hook file.
|
|
1119
|
+
if (codexConfigured) {
|
|
1120
|
+
const hook = inspectSessionStartHook(homedir(), { settingsPath: hookSettingsPath(homedir(), "codex") });
|
|
1121
|
+
if (hook.present) {
|
|
1122
|
+
const codexStale = staleSessionStartHookPins(homedir()).find((r) => r.target.id === "codex");
|
|
1123
|
+
if (codexStale) {
|
|
1124
|
+
console.log(` ${render.icons.error} SessionStart hook (codex): pinned to flair-mcp@${codexStale.pin} (installed CLI is ${flairCliVersion()}) — the hook still launches the OLD adapter on every session`);
|
|
1125
|
+
if (autoFix) {
|
|
1126
|
+
if (dryRun) {
|
|
1127
|
+
console.log(` ${render.wrap(render.c.dim, "Would re-pin the SessionStart hook in")} ${hook.path}`);
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
const repin = repinSessionStartHook(homedir(), "codex");
|
|
1131
|
+
console.log(` ${repin.ok ? render.icons.ok : render.icons.warn} ${repin.message}`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
else {
|
|
1135
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(re-pins the hook to the installed CLI version)")}`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
else if (hook.execution === "broken") {
|
|
1139
|
+
if (hook.silenced) {
|
|
1140
|
+
console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} — not yet exercised`);
|
|
1141
|
+
console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
|
|
1142
|
+
console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
|
|
1143
|
+
console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Codex session will warm the npx cache.")}`);
|
|
1144
|
+
console.log(` ${render.wrap(render.c.dim, "Codex requires /hooks to trust a newly written command before it runs.")}`);
|
|
1145
|
+
}
|
|
1146
|
+
else {
|
|
1147
|
+
console.log(` ${render.icons.warn} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
|
|
1148
|
+
console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
|
|
1149
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(rewrites the hook to the current silent-failure form)")}`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
else if (hook.execution === "unknown") {
|
|
1153
|
+
console.log(` ${render.icons.warn} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)}, but could not be verified ${render.wrap(render.c.dim, `(${hook.detail ?? "no detail"})`)}`);
|
|
1154
|
+
}
|
|
1155
|
+
else if (!hook.ours) {
|
|
1156
|
+
console.log(` ${render.icons.ok} SessionStart hook (codex): wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "(custom command — not verified, not modified)")}`);
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
console.log(` ${render.icons.ok} SessionStart hook (codex): flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
|
|
1160
|
+
}
|
|
1161
|
+
if (!hook.silenced && hook.ours) {
|
|
1162
|
+
console.log(` ${render.icons.warn} SessionStart hook (codex): a failure would print an error on every session (this command predates the silent-failure fix)`);
|
|
1163
|
+
if (hook.upgradable) {
|
|
1164
|
+
if (autoFix) {
|
|
1165
|
+
if (dryRun) {
|
|
1166
|
+
console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
|
|
1167
|
+
}
|
|
1168
|
+
else {
|
|
1169
|
+
const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
|
|
1170
|
+
if (!proceed) {
|
|
1171
|
+
console.log(` Skipped.`);
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
|
|
1175
|
+
console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
else {
|
|
1180
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex ${render.wrap(render.c.dim, "(rewrites the hook command in place — same agent, same instance)")}`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
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 --harness codex`);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
console.log(` ${render.icons.error} SessionStart hook (codex): not found in ${render.wrap(render.c.dim, hook.path)}`);
|
|
1190
|
+
if (autoFix) {
|
|
1191
|
+
if (dryRun) {
|
|
1192
|
+
console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
|
|
1193
|
+
}
|
|
1194
|
+
else {
|
|
1195
|
+
const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
|
|
1196
|
+
if (!proceed) {
|
|
1197
|
+
console.log(` Skipped.`);
|
|
1198
|
+
}
|
|
1199
|
+
else {
|
|
1200
|
+
const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
|
|
1201
|
+
const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
|
|
1202
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
else {
|
|
1207
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// Catalog is the install-health verdict — count fail/unrun here, not
|
|
1213
|
+
// via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
|
|
1214
|
+
// --fix that cleared a catalog member shows up in the found→fixed delta.
|
|
1215
|
+
const catalogAfter = autoFix ? runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds }) : catalogBefore;
|
|
1216
|
+
const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
|
|
1217
|
+
issues += catalogDelta.found;
|
|
1218
|
+
if (autoFix)
|
|
1219
|
+
fixed += catalogDelta.fixed;
|
|
1220
|
+
console.log(`\n ${render.wrap(render.c.bold, "Install health")}`);
|
|
1221
|
+
for (const row of renderCatalogDoctorLines(catalogAfter)) {
|
|
1222
|
+
console.log(` ${render.icons[row.icon]} ${row.line}`);
|
|
1223
|
+
}
|
|
1224
|
+
// 7b. Launchd management repair (flair#1573 slice b) — `doctor --fix`
|
|
1225
|
+
// repairs a MISSING, CORRUPT, or DETACHED launchd plist. This is a
|
|
1226
|
+
// distinct concern from the install-health catalog above (which
|
|
1227
|
+
// `upgrade` also asserts), so it owns its own reporting + counting
|
|
1228
|
+
// rather than double-counting the catalog's launchd check. The
|
|
1229
|
+
// DECISION is pure (planLaunchdRepairFor -> planLaunchdRepair); the
|
|
1230
|
+
// EXECUTION (adopt: clean-stop -> regenerate pass-file plist -> load ->
|
|
1231
|
+
// verify) is repairLaunchdManagement, which is the only place that
|
|
1232
|
+
// touches the real filesystem and launchctl.
|
|
1233
|
+
console.log(`\n ${render.wrap(render.c.bold, "Launchd management")}`);
|
|
1234
|
+
if (autoFix && !dryRun) {
|
|
1235
|
+
// Execute the repair directly; it re-derives the plan internally and
|
|
1236
|
+
// verifies via assessLaunchdManagement (fail-loud, never a silent pass).
|
|
1237
|
+
const repairResult = await repairLaunchdManagement(defaultDataDir(), effectivePort);
|
|
1238
|
+
switch (repairResult.kind) {
|
|
1239
|
+
case "no-op":
|
|
1240
|
+
console.log(` ${render.icons.ok} ${repairResult.detail}`);
|
|
1241
|
+
break;
|
|
1242
|
+
case "refused":
|
|
1243
|
+
issues++;
|
|
1244
|
+
console.log(` ${render.icons.error} ${repairResult.detail}`);
|
|
1245
|
+
break;
|
|
1246
|
+
case "repaired":
|
|
1247
|
+
fixed++;
|
|
1248
|
+
console.log(` ${render.icons.ok} ${repairResult.detail}`);
|
|
1249
|
+
break;
|
|
1250
|
+
case "failed":
|
|
1251
|
+
issues++;
|
|
1252
|
+
console.log(` ${render.icons.error} ${repairResult.detail}`);
|
|
1253
|
+
if (repairResult.remedy)
|
|
1254
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${repairResult.remedy.join(" && ")}`);
|
|
1255
|
+
break;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
// Report only (no --fix, or --fix --dry-run): compute the plan, touch
|
|
1260
|
+
// nothing. A regenerate plan is drift; a refuse plan is a named refusal.
|
|
1261
|
+
const repairPlan = planLaunchdRepairFor(defaultDataDir(), effectivePort);
|
|
1262
|
+
switch (repairPlan.plan.kind) {
|
|
1263
|
+
case "no-op":
|
|
1264
|
+
console.log(` ${render.icons.ok} ${repairPlan.plan.detail}`);
|
|
1265
|
+
break;
|
|
1266
|
+
case "refuse":
|
|
1267
|
+
issues++;
|
|
1268
|
+
console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
|
|
1269
|
+
break;
|
|
1270
|
+
case "regenerate":
|
|
1271
|
+
issues++;
|
|
1272
|
+
console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
|
|
1273
|
+
if (dryRun) {
|
|
1274
|
+
console.log(` ${render.wrap(render.c.dim, "Would regenerate")} the launchd plist (pass-file mode) and load it`);
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(regenerates the plist in pass-file mode, loads it, and verifies)")}`);
|
|
1278
|
+
}
|
|
1279
|
+
break;
|
|
1280
|
+
case "adopt":
|
|
1281
|
+
issues++;
|
|
1282
|
+
console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
|
|
1283
|
+
if (dryRun) {
|
|
1284
|
+
console.log(` ${render.wrap(render.c.dim, "Would adopt")} the direct-spawned instance into launchd (clean-stop, regenerate, load — bounces the live instance)`);
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(clean-stops the direct process, regenerates the plist, loads it, and verifies — bounces the live instance)")}`);
|
|
1288
|
+
}
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
// 7a. Resolve which agent identities the two verified-read sections below
|
|
1293
|
+
// (Fleet presence, Migrations) iterate (flair#722). Previously both
|
|
1294
|
+
// sections required --agent explicitly; doctor already enumerates every
|
|
1295
|
+
// key in ~/.flair/keys (step 2 above), so by default it now runs the
|
|
1296
|
+
// signed read AS EACH of those agents instead of hiding behind a flag —
|
|
1297
|
+
// a real dogfood run found the #720 halted-migration warning visible via
|
|
1298
|
+
// `flair status --agent local` but invisible in the default `doctor` run
|
|
1299
|
+
// the same user ran minutes later. --agent <id> narrows this to exactly
|
|
1300
|
+
// that one identity (planAgentIterations — same pre-#722 semantics: a
|
|
1301
|
+
// single signed identity, just no longer widened to "every key").
|
|
1302
|
+
//
|
|
1303
|
+
// The registration gate (checkAgentRegistered — same signed GET
|
|
1304
|
+
// /Agent/:id used by the Client integration section above) is resolved
|
|
1305
|
+
// ONCE here per agent and shared by both sections, so a bad/unregistered
|
|
1306
|
+
// key doesn't cost two network round-trips, and its "found" count isn't
|
|
1307
|
+
// double-counted by each section re-discovering the same finding
|
|
1308
|
+
// (flair#721 found/fixed/remaining summary — these are found-only, no
|
|
1309
|
+
// --fix action exists for a bad local key). A gate failure for one agent
|
|
1310
|
+
// never aborts the others — that's the failure isolation flair#722 asks
|
|
1311
|
+
// for; describeAgentGateFinding (src/doctor-client.ts) is pure decision
|
|
1312
|
+
// logic so it's unit-tested without a real Harper.
|
|
1313
|
+
const verifiedReadAgentIds = harperResponding
|
|
1314
|
+
? planAgentIterations(keyAgentIds, opts.agent || process.env.FLAIR_AGENT_ID)
|
|
1315
|
+
: [];
|
|
1316
|
+
const agentGates = [];
|
|
1317
|
+
for (const id of verifiedReadAgentIds) {
|
|
1318
|
+
const reg = await checkAgentRegistered(baseUrl, id, defaultKeysDir());
|
|
1319
|
+
agentGates.push({ id, state: reg.state, detail: reg.detail });
|
|
1320
|
+
// harperResponding is necessarily true here (verifiedReadAgentIds is
|
|
1321
|
+
// empty otherwise), so an "unreachable" verdict from this loop is
|
|
1322
|
+
// always a self-contradiction — flair#1023. Hand the guard the fact.
|
|
1323
|
+
const finding = describeAgentGateFinding(id, reg.state, reg.detail, { instanceReachable: harperResponding });
|
|
1324
|
+
if (finding?.isIssue)
|
|
1325
|
+
issues++;
|
|
1326
|
+
}
|
|
1327
|
+
// Shared renderer for one agent's registration-gate outcome — prints the
|
|
1328
|
+
// "Agent: <id>" subsection header, and if the gate isn't clean, the
|
|
1329
|
+
// finding (never re-counted here; already counted once above) and
|
|
1330
|
+
// returns false so the caller skips its own verified fetch for this
|
|
1331
|
+
// agent and moves on to the next (failure isolation).
|
|
1332
|
+
function renderAgentGateHeader(gate) {
|
|
1333
|
+
console.log(` ${render.wrap(render.c.dim, `Agent: ${gate.id}`)}`);
|
|
1334
|
+
const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail, { instanceReachable: harperResponding });
|
|
1335
|
+
if (!finding)
|
|
1336
|
+
return true;
|
|
1337
|
+
const icon = finding.icon === "error" ? render.icons.error : render.icons.warn;
|
|
1338
|
+
console.log(` ${icon} ${finding.message}`);
|
|
1339
|
+
if (finding.fixHint)
|
|
1340
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${finding.fixHint}`);
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
// 8. Fleet presence (flair#639) — known instances via /Presence heartbeats.
|
|
1344
|
+
//
|
|
1345
|
+
// "Instance" here means each AGENT's heartbeat row — Presence is keyed by
|
|
1346
|
+
// agentId (schemas/schema.graphql), not by Flair server — so several rows
|
|
1347
|
+
// can (and typically will) share one flairVersion/harperVersion whenever
|
|
1348
|
+
// several agents heartbeat through the same Flair. That's still the
|
|
1349
|
+
// useful fleet signal: an outlier version on one row means THAT agent's
|
|
1350
|
+
// serving instance is behind the rest.
|
|
1351
|
+
//
|
|
1352
|
+
// SCOPE, verified against runFederationSyncOnce's own table list in
|
|
1353
|
+
// src/commands/federation.ts (`const tables = ["Memory", "Soul", "Agent",
|
|
1354
|
+
// "Relationship"]`): Presence is NOT one of the tables federation sync
|
|
1355
|
+
// replicates. So this section reports only what THIS instance's own
|
|
1356
|
+
// Presence table has recorded — every agent whose FLAIR_URL points
|
|
1357
|
+
// directly at the Flair `doctor` is talking to. On a hub+spokes
|
|
1358
|
+
// deployment where each spoke runs its own separate Flair database, a
|
|
1359
|
+
// spoke's locally-recorded heartbeats are invisible from the hub's
|
|
1360
|
+
// `doctor` unless those agents also heartbeat straight to the hub. Not
|
|
1361
|
+
// fixed here — flair#639's fix list is version-stamping + a doctor
|
|
1362
|
+
// listing, not widening federation sync scope.
|
|
1363
|
+
//
|
|
1364
|
+
// flair#722: iterated per agent (agentGates above) instead of a single
|
|
1365
|
+
// --agent-gated read. flairVersion/harperVersion are gated to verified
|
|
1366
|
+
// readers on the server (resources/Presence.ts, same boundary as
|
|
1367
|
+
// currentTask), so each agent subsection signs its own GET — a working
|
|
1368
|
+
// key reveals versions for that subsection; roster IDENTITY is public
|
|
1369
|
+
// either way. Zero local keys (and no --agent) falls back to exactly the
|
|
1370
|
+
// pre-#722 single unauthenticated read (hidden versions, "Pass --agent"
|
|
1371
|
+
// hint) — there's no agent to sign as, but remote agents may still have
|
|
1372
|
+
// heartbeated onto this instance and identities are worth showing.
|
|
1373
|
+
async function fetchAndRenderFleetPresence(headers, canSign, indent) {
|
|
1374
|
+
try {
|
|
1375
|
+
const presRes = await fetch(`${baseUrl}/Presence`, { headers, signal: AbortSignal.timeout(5000) });
|
|
1376
|
+
if (!presRes.ok) {
|
|
1377
|
+
console.log(`${indent}${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
const roster = (await presRes.json());
|
|
1381
|
+
if (!Array.isArray(roster) || roster.length === 0) {
|
|
1382
|
+
console.log(`${indent}${render.icons.info} No known instances yet — no /Presence heartbeats recorded on this instance`);
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
const rows = sortOldestVersionFirst(markStale(roster));
|
|
1386
|
+
for (const row of rows) {
|
|
1387
|
+
const lastSeen = typeof row.lastHeartbeatAt === "number"
|
|
1388
|
+
? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
|
|
1389
|
+
: "—";
|
|
1390
|
+
const versionLabel = !canSign
|
|
1391
|
+
? render.wrap(render.c.dim, "hidden")
|
|
1392
|
+
: row.flairVersion
|
|
1393
|
+
? `v${row.flairVersion}`
|
|
1394
|
+
: render.wrap(render.c.dim, "no version reported");
|
|
1395
|
+
const staleNote = row.stale && row.newestVersion
|
|
1396
|
+
? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
|
|
1397
|
+
: "";
|
|
1398
|
+
const icon = row.stale ? render.icons.warn : render.icons.ok;
|
|
1399
|
+
const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
|
|
1400
|
+
// Natural-presence: same staleness principle as the version
|
|
1401
|
+
// column — a live activity is shown as current, a decayed one as
|
|
1402
|
+
// "last-known". `activityFresh === false` (server verdict) plus a
|
|
1403
|
+
// known lastActivity → "(was: X)"; a fresh, non-idle activity →
|
|
1404
|
+
// "(X)". Skip entirely when there's nothing informative to say
|
|
1405
|
+
// (no signal, or idle) so the line stays quiet for the common case.
|
|
1406
|
+
const lastActivity = row.lastActivity ?? row.activity;
|
|
1407
|
+
const activityNote = row.activityFresh === false
|
|
1408
|
+
? (lastActivity && lastActivity !== "idle"
|
|
1409
|
+
? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
|
|
1410
|
+
: "")
|
|
1411
|
+
: (row.activity && row.activity !== "idle"
|
|
1412
|
+
? " " + render.wrap(render.c.dim, `(${row.activity})`)
|
|
1413
|
+
: "");
|
|
1414
|
+
console.log(`${indent}${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
|
|
1415
|
+
}
|
|
1416
|
+
if (!canSign) {
|
|
1417
|
+
console.log(`${indent} ${render.wrap(render.c.dim, "Pass --agent <id> (with a matching key in ~/.flair/keys) to reveal versions — flairVersion/harperVersion require a verified signature, same as currentTask.")}`);
|
|
1418
|
+
}
|
|
1419
|
+
console.log(`${indent} ${render.wrap(render.c.dim, "Staleness above is fleet-relative (newest version seen among these instances) — comparing against the latest PUBLISHED flair is the version check at the top of this report, not this section.")}`);
|
|
1420
|
+
}
|
|
1421
|
+
catch (err) {
|
|
1422
|
+
console.log(`${indent}${render.icons.warn} Fleet presence check failed: ${err?.message ?? err}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (harperResponding) {
|
|
1426
|
+
console.log(`\n ${render.wrap(render.c.bold, "Fleet presence")}`);
|
|
1427
|
+
if (agentGates.length === 0) {
|
|
1428
|
+
await fetchAndRenderFleetPresence({}, false, " ");
|
|
1429
|
+
}
|
|
1430
|
+
else {
|
|
1431
|
+
for (const gate of agentGates) {
|
|
1432
|
+
const registered = renderAgentGateHeader(gate);
|
|
1433
|
+
if (!registered)
|
|
1434
|
+
continue;
|
|
1435
|
+
const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
|
|
1436
|
+
const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/Presence", keyPath) };
|
|
1437
|
+
await fetchAndRenderFleetPresence(headers, true, " ");
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
// 9. Migration state (flair#695) — pending/in-progress/blocked + last
|
|
1442
|
+
// ledger-derived outcome per registered migration, read off the same
|
|
1443
|
+
// authenticated /HealthDetail the "Fleet presence" section above
|
|
1444
|
+
// already fetches. `--fix` here means the SAME restart offered in step
|
|
1445
|
+
// 1a above (a halted migration retries automatically on the next boot —
|
|
1446
|
+
// there's no separate "run the migration now" fix; the fix for
|
|
1447
|
+
// "blocked" is whatever the halt reason names, e.g. freeing disk).
|
|
1448
|
+
//
|
|
1449
|
+
// flair#722: iterated per agent (agentGates above), same as Fleet
|
|
1450
|
+
// presence — each subsection's finding is found-only (no per-agent
|
|
1451
|
+
// --fix here beyond the existing restart-on-halt story). Gate FINDINGS
|
|
1452
|
+
// are rendered in full under Fleet presence only (the first
|
|
1453
|
+
// verified-read section); re-printing the identical per-agent finding
|
|
1454
|
+
// here doubled the noise on real multi-key machines (a 27-key dogfood
|
|
1455
|
+
// box printed 15 not-registered findings twice each), so this section
|
|
1456
|
+
// iterates only the gate-passed agents and rolls the rest into one
|
|
1457
|
+
// aggregate skip line. The issue COUNT is unaffected either way — gate
|
|
1458
|
+
// findings are counted exactly once, at gate-resolution time (step 7a).
|
|
1459
|
+
async function fetchAndRenderMigrations(headers, indent) {
|
|
1460
|
+
try {
|
|
1461
|
+
const migRes = await fetch(`${baseUrl}/HealthDetail`, { headers, signal: AbortSignal.timeout(5000) });
|
|
1462
|
+
if (!migRes.ok) {
|
|
1463
|
+
console.log(`${indent}${render.icons.warn} Could not fetch migration state (HTTP ${migRes.status})`);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
const detail = (await migRes.json());
|
|
1467
|
+
const migBlock = detail?.migrations;
|
|
1468
|
+
if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
|
|
1469
|
+
console.log(`${indent}${render.icons.info} No migrations registered on this instance`);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (migBlock.cyclePhase === "pre-hash") {
|
|
1473
|
+
console.log(`${indent}${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
|
|
1474
|
+
}
|
|
1475
|
+
// flair#812: the boot trigger sets `scheduled` synchronously at
|
|
1476
|
+
// module load, so `idle` means resources/migration-boot.js never
|
|
1477
|
+
// loaded in the serving process — NO migration will ever run on
|
|
1478
|
+
// this instance, which is precisely the failure that went unnoticed
|
|
1479
|
+
// because a skipped cycle looked identical to a clean one.
|
|
1480
|
+
if (migBlock.cyclePhase === "idle") {
|
|
1481
|
+
console.log(`${indent}${render.icons.error} Migration boot cycle never fired on this instance — no migration will run until this is resolved. Check the instance log for [flair-migrations] and confirm the running build ships dist/resources/migration-boot.js.`);
|
|
1482
|
+
issues++;
|
|
1483
|
+
}
|
|
1484
|
+
// A cycle that reached a terminal phase carrying an error explains
|
|
1485
|
+
// itself here rather than only in the process log — the reason
|
|
1486
|
+
// string names the paths tried and the remedy.
|
|
1487
|
+
if (migBlock.lastCycleError) {
|
|
1488
|
+
console.log(`${indent}${render.icons.error} Last migration cycle did not complete: ${migBlock.lastCycleError}`);
|
|
1489
|
+
issues++;
|
|
1490
|
+
}
|
|
1491
|
+
for (const m of migBlock.migrations) {
|
|
1492
|
+
if (m.state === "completed") {
|
|
1493
|
+
// flair#812: a `reason` on a COMPLETED migration means the
|
|
1494
|
+
// runner short-circuited it from the (hand-editable) state file
|
|
1495
|
+
// rather than verifying the corpus this boot. Print it, so an
|
|
1496
|
+
// unverified claim is never rendered as a verified one.
|
|
1497
|
+
const note = m.reason ? ` ${render.wrap(render.c.dim, `(${m.reason})`)}` : "";
|
|
1498
|
+
console.log(`${indent}${render.icons.ok} ${m.id}: completed${note}`);
|
|
1499
|
+
}
|
|
1500
|
+
else if (m.state === "halted" || m.state === "failed") {
|
|
1501
|
+
console.log(`${indent}${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
|
|
1502
|
+
issues++;
|
|
1503
|
+
}
|
|
1504
|
+
else if (m.state === "running") {
|
|
1505
|
+
console.log(`${indent}${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
|
|
1506
|
+
}
|
|
1507
|
+
else {
|
|
1508
|
+
console.log(`${indent}${render.icons.info} ${m.id}: ${m.state}`);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
catch (err) {
|
|
1513
|
+
console.log(`${indent}${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
if (harperResponding) {
|
|
1517
|
+
console.log(`\n ${render.wrap(render.c.bold, "Migrations")}`);
|
|
1518
|
+
if (agentGates.length === 0) {
|
|
1519
|
+
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.`);
|
|
1520
|
+
}
|
|
1521
|
+
else {
|
|
1522
|
+
const passedGates = agentGates.filter((g) => describeAgentGateFinding(g.id, g.state, g.detail, { instanceReachable: harperResponding }) === null);
|
|
1523
|
+
for (const gate of passedGates) {
|
|
1524
|
+
renderAgentGateHeader(gate);
|
|
1525
|
+
const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
|
|
1526
|
+
const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/HealthDetail", keyPath) };
|
|
1527
|
+
await fetchAndRenderMigrations(headers, " ");
|
|
1528
|
+
}
|
|
1529
|
+
const skipped = agentGates.length - passedGates.length;
|
|
1530
|
+
if (skipped > 0) {
|
|
1531
|
+
console.log(` ${render.icons.info} ${skipped} agent(s) skipped — registration-gate findings reported under Fleet presence above`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
// 10. Scheduled drivers (flair#1278) — launchd/systemd liveness for the
|
|
1536
|
+
// background schedulers (federation sync, REM nightly), read from the
|
|
1537
|
+
// LOCAL service manager (no Harper dependency, so no harperResponding
|
|
1538
|
+
// gate). Neither #1231 fleet incident (launchd spawn error 209 from a
|
|
1539
|
+
// missing log dir, exit 126 from a stripped exec bit) was visible in
|
|
1540
|
+
// doctor: driver health only surfaced in `flair federation sync status`
|
|
1541
|
+
// / `flair rem nightly status` — commands an operator has to think to
|
|
1542
|
+
// run, while doctor is the tool they actually run when something feels
|
|
1543
|
+
// off. Reuses each scheduler's own status read (installed + genuinely
|
|
1544
|
+
// loaded, flair#850) plus the #1282 last-exit plumbing
|
|
1545
|
+
// (queryLastExitStatus); the verdict is describeScheduledDriverFinding
|
|
1546
|
+
// (src/lib/scheduler-platform.ts) — pure decision logic, unit-tested
|
|
1547
|
+
// without spawning launchctl/systemctl. Not-enabled renders as
|
|
1548
|
+
// informational: an unenabled scheduler is a choice — never the pass
|
|
1549
|
+
// marker, never the fail marker, never an issue.
|
|
1550
|
+
//
|
|
1551
|
+
// flair#1514: the federation driver is additionally gated on peers
|
|
1552
|
+
// being configured. Zero peers → N/A (never ✗). Peers configured +
|
|
1553
|
+
// driver missing/broken still ✗. Config.yaml is the component-dir
|
|
1554
|
+
// file resolved above, not only ~/.flair/config.yaml.
|
|
1555
|
+
console.log(`\n ${render.wrap(render.c.bold, "Scheduled drivers")}`);
|
|
1556
|
+
try {
|
|
1557
|
+
const { queryLastExitStatus, describeScheduledDriverFinding } = await import("../lib/scheduler-platform.js");
|
|
1558
|
+
const fedSched = await import("../federation/scheduler.js");
|
|
1559
|
+
const remSched = await import("../rem/scheduler.js");
|
|
1560
|
+
const guiDomain = `gui/${process.getuid?.() ?? ""}`;
|
|
1561
|
+
let livePeerCount = null;
|
|
1562
|
+
if (harperResponding) {
|
|
1563
|
+
try {
|
|
1564
|
+
const r = await api("GET", "/FederationPeers", undefined, { baseUrl });
|
|
1565
|
+
const peers = Array.isArray(r?.peers) ? r.peers : [];
|
|
1566
|
+
livePeerCount = peers.filter((p) => p?.status !== "revoked").length;
|
|
1567
|
+
}
|
|
1568
|
+
catch {
|
|
1569
|
+
livePeerCount = null;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
const configDoc = cfgPath ? loadYamlDoc(cfgPath) : null;
|
|
1573
|
+
const fedEnv = collectFederationEnv({
|
|
1574
|
+
processEnv: process.env,
|
|
1575
|
+
envFilePaths: [
|
|
1576
|
+
join(process.cwd(), COMPONENT_ENV_FILENAME),
|
|
1577
|
+
join(flairPackageDir(), COMPONENT_ENV_FILENAME),
|
|
1578
|
+
...(cfgPath ? [join(dirname(cfgPath), COMPONENT_ENV_FILENAME)] : []),
|
|
1579
|
+
],
|
|
1580
|
+
});
|
|
1581
|
+
const peersConfigured = federationPeersConfigured({
|
|
1582
|
+
livePeerCount,
|
|
1583
|
+
configDoc,
|
|
1584
|
+
env: fedEnv,
|
|
1585
|
+
nodeKeyIds,
|
|
1586
|
+
});
|
|
1587
|
+
const drivers = [
|
|
1588
|
+
{
|
|
1589
|
+
kind: "federation",
|
|
1590
|
+
status: fedSched.schedulerStatus(),
|
|
1591
|
+
label: "Federation sync driver",
|
|
1592
|
+
enableCommand: "flair federation sync enable",
|
|
1593
|
+
statusCommand: "flair federation sync status",
|
|
1594
|
+
darwinTarget: `${guiDomain}/${fedSched.LAUNCHD_LABEL}`,
|
|
1595
|
+
linuxServiceUnit: fedSched.SYSTEMD_SERVICE_UNIT,
|
|
1596
|
+
stderrLogPath: join(homedir(), ".flair", "logs", "federation-sync.stderr.log"),
|
|
1597
|
+
},
|
|
1598
|
+
{
|
|
1599
|
+
kind: "rem",
|
|
1600
|
+
status: remSched.schedulerStatus(),
|
|
1601
|
+
label: "REM nightly driver",
|
|
1602
|
+
enableCommand: "flair rem nightly enable",
|
|
1603
|
+
statusCommand: "flair rem nightly status",
|
|
1604
|
+
darwinTarget: `${guiDomain}/${remSched.LAUNCHD_LABEL}`,
|
|
1605
|
+
linuxServiceUnit: remSched.SYSTEMD_SERVICE_UNIT,
|
|
1606
|
+
stderrLogPath: join(homedir(), ".flair", "logs", "rem-nightly.stderr.log"),
|
|
1607
|
+
},
|
|
1608
|
+
];
|
|
1609
|
+
for (const d of drivers) {
|
|
1610
|
+
// Read the last run only when the service manager actually has the
|
|
1611
|
+
// job — "not installed" and "not loaded" carry their own findings,
|
|
1612
|
+
// and layering a last-exit read on top would blur which actor failed.
|
|
1613
|
+
const lastExit = d.status.installed && d.status.active === true
|
|
1614
|
+
? queryLastExitStatus({ plat: d.status.platform, darwinTarget: d.darwinTarget, linuxServiceUnit: d.linuxServiceUnit })
|
|
1615
|
+
: null;
|
|
1616
|
+
const facts = {
|
|
1617
|
+
label: d.label,
|
|
1618
|
+
enableCommand: d.enableCommand,
|
|
1619
|
+
statusCommand: d.statusCommand,
|
|
1620
|
+
installed: d.status.installed,
|
|
1621
|
+
active: d.status.active,
|
|
1622
|
+
lastExit,
|
|
1623
|
+
stderrLogPath: d.stderrLogPath,
|
|
1624
|
+
};
|
|
1625
|
+
const finding = d.kind === "federation"
|
|
1626
|
+
? describeFederationDriverFinding({ peersConfigured, driver: facts })
|
|
1627
|
+
: describeScheduledDriverFinding(facts);
|
|
1628
|
+
console.log(` ${render.icons[finding.icon]} ${finding.message}`);
|
|
1629
|
+
finding.detail.forEach((line, i) => {
|
|
1630
|
+
// Embed-verify degraded style: the actor+state line loud (red),
|
|
1631
|
+
// the remedy dim.
|
|
1632
|
+
const color = finding.state === "degraded" && i === 0 ? render.c.red : render.c.dim;
|
|
1633
|
+
console.log(` ${render.wrap(color, line)}`);
|
|
1634
|
+
});
|
|
1635
|
+
if (finding.isIssue)
|
|
1636
|
+
issues++;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
catch (err) {
|
|
1640
|
+
// An unsupported platform (neither darwin nor linux) or a broken unit
|
|
1641
|
+
// read must not take down doctor — report the section as unchecked
|
|
1642
|
+
// (UNVERIFIED, not a pass), same as the other probes' skip discipline.
|
|
1643
|
+
console.log(` ${render.icons.warn} Scheduled drivers: could not check ${render.wrap(render.c.dim, `(${err?.message ?? err})`)}`);
|
|
1644
|
+
}
|
|
1645
|
+
// Summary — see summarizeDoctorRun above (flair#721): distinguishes
|
|
1646
|
+
// issues --fix actually resolved this run from ones still outstanding.
|
|
1647
|
+
console.log("");
|
|
1648
|
+
const summary = summarizeDoctorRun(issues, fixed, autoFix);
|
|
1649
|
+
console.log(summary.line);
|
|
1650
|
+
console.log("");
|
|
1651
|
+
if (summary.exitCode !== 0)
|
|
1652
|
+
process.exit(summary.exitCode);
|
|
1653
|
+
});
|
|
1654
|
+
}
|