@tpsdev-ai/flair 0.22.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +555 -110
- package/dist/doctor-client.js +176 -0
- package/dist/install/clients.js +46 -11
- package/dist/probe.js +15 -2
- package/dist/resources/Memory.js +80 -60
- package/dist/resources/OrgEvent.js +37 -26
- package/dist/resources/Relationship.js +52 -53
- package/dist/resources/Soul.js +16 -8
- package/dist/resources/WorkspaceState.js +58 -54
- package/dist/resources/mcp-handler.js +29 -5
- package/dist/resources/mcp-tools.js +50 -3
- package/dist/resources/provenance.js +60 -8
- package/dist/resources/record-type-kit.js +253 -0
- package/dist/resources/record-types.js +363 -0
- package/package.json +2 -2
- package/schemas/memory.graphql +2 -2
package/dist/cli.js
CHANGED
|
@@ -19,7 +19,7 @@ import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verif
|
|
|
19
19
|
import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
|
|
20
20
|
import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
|
|
21
21
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
22
|
-
import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, } from "./doctor-client.js";
|
|
22
|
+
import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, describeAgentGateFinding, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
|
|
23
23
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
24
24
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
25
25
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -382,6 +382,31 @@ function isLocalBase(base) {
|
|
|
382
382
|
return !base;
|
|
383
383
|
}
|
|
384
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Thrown by api() for an HTTP-level failure (as opposed to a network error,
|
|
387
|
+
* which fetch() itself throws before api() ever sees a status code).
|
|
388
|
+
* Data-carrying, not just a message string, so callers downstream can make
|
|
389
|
+
* decisions without re-parsing text (flair#741):
|
|
390
|
+
* - `status`: the HTTP status code, so probeInstance (src/probe.ts) can
|
|
391
|
+
* classify ProbeResult.authFailureKind (401/403 = credential failure,
|
|
392
|
+
* duck-typed off this property — see probe.ts's authedGet doc).
|
|
393
|
+
* - `noCredentials`: true only for the specific bare-403-no-auth-header
|
|
394
|
+
* case (api()'s own hint-carrying branch below) — distinguishes "we had
|
|
395
|
+
* nothing to send" from "we sent something and it was rejected" so
|
|
396
|
+
* verifyAuthedGet (below) knows exactly when its agent-key fallback
|
|
397
|
+
* applies (fix #2) versus when a real credential was tried and failed
|
|
398
|
+
* (where guessing at other keys would be misleading, not helpful).
|
|
399
|
+
*/
|
|
400
|
+
class ApiHttpError extends Error {
|
|
401
|
+
status;
|
|
402
|
+
noCredentials;
|
|
403
|
+
constructor(status, message, noCredentials = false) {
|
|
404
|
+
super(message);
|
|
405
|
+
this.name = "ApiHttpError";
|
|
406
|
+
this.status = status;
|
|
407
|
+
this.noCredentials = noCredentials;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
385
410
|
async function api(method, path, body, options) {
|
|
386
411
|
// Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
|
|
387
412
|
// When baseUrl is provided (--target), use it directly.
|
|
@@ -479,7 +504,7 @@ async function api(method, path, body, options) {
|
|
|
479
504
|
// Handle 204 No Content (e.g., PUT upsert returns empty body)
|
|
480
505
|
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
|
481
506
|
if (!res.ok)
|
|
482
|
-
throw new
|
|
507
|
+
throw new ApiHttpError(res.status, `HTTP ${res.status}`);
|
|
483
508
|
return { ok: true };
|
|
484
509
|
}
|
|
485
510
|
const text = await res.text();
|
|
@@ -492,9 +517,9 @@ async function api(method, path, body, options) {
|
|
|
492
517
|
const hint = isLocal
|
|
493
518
|
? "Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass."
|
|
494
519
|
: "Set FLAIR_ADMIN_PASS (remote targets have no local admin-pass fallback).";
|
|
495
|
-
throw new
|
|
520
|
+
throw new ApiHttpError(403, `HTTP 403: no credentials sent. ${hint}`, /* noCredentials */ true);
|
|
496
521
|
}
|
|
497
|
-
throw new
|
|
522
|
+
throw new ApiHttpError(res.status, text || `HTTP ${res.status}`);
|
|
498
523
|
}
|
|
499
524
|
if (!text)
|
|
500
525
|
return { ok: true };
|
|
@@ -553,6 +578,90 @@ async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
|
|
|
553
578
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
554
579
|
});
|
|
555
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* The authedGet `flair upgrade` verification (flair#635/#741) hands to
|
|
583
|
+
* probeInstance(): api()'s existing local-credential chain (FLAIR_TOKEN >
|
|
584
|
+
* FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD > FLAIR_AGENT_ID+key > ~/.flair/admin-
|
|
585
|
+
* pass file), with an Ed25519 agent-key fallback layered on top (flair#741
|
|
586
|
+
* fix #2).
|
|
587
|
+
*
|
|
588
|
+
* The gap this closes: api()'s agent-key leg only fires when an agentId is
|
|
589
|
+
* ALREADY known (FLAIR_AGENT_ID env, or an agentId in the request body/query
|
|
590
|
+
* string) — none of which a bare `flair upgrade` ever sets. So on a machine
|
|
591
|
+
* with an agent key under ~/.flair/keys but no admin password anywhere,
|
|
592
|
+
* api() falls straight through to its no-credentials 403, even though
|
|
593
|
+
* `flair doctor`'s verified-read gate would happily use that same key. This
|
|
594
|
+
* wrapper is what actually reuses that path for verification.
|
|
595
|
+
*
|
|
596
|
+
* Auth requirement of the verification target itself: /HealthDetail
|
|
597
|
+
* (probeInstance's default versionPath) is NOT admin-gated — its
|
|
598
|
+
* `allowRead()` is `allowVerified()` (resources/health.ts, resources/agent-
|
|
599
|
+
* auth.ts), which permits ANY registered agent, not just admins. So a
|
|
600
|
+
* signed request from any registered agent's key is sufficient; there's no
|
|
601
|
+
* need to special-case a different, more-public endpoint.
|
|
602
|
+
*
|
|
603
|
+
* Only engages the fallback when api() reports NOTHING was available to
|
|
604
|
+
* send (`ApiHttpError.noCredentials`) — a wrong/rejected credential (bad
|
|
605
|
+
* admin-pass, unregistered FLAIR_AGENT_ID) is a different, more specific
|
|
606
|
+
* problem than "nothing to try"; guessing at unrelated keys on disk in that
|
|
607
|
+
* case would obscure the real error instead of explaining it.
|
|
608
|
+
*
|
|
609
|
+
* Selection rule when multiple keys exist in `keysDir` (mirrors doctor's
|
|
610
|
+
* planAgentIterations sort — doctor-client.ts): try them in sorted
|
|
611
|
+
* (deterministic) filename order, first one that authenticates wins. A
|
|
612
|
+
* liveness/verified-read check only needs ONE working identity, unlike
|
|
613
|
+
* doctor's per-agent registration report which needs to enumerate all of
|
|
614
|
+
* them.
|
|
615
|
+
*/
|
|
616
|
+
export async function verifyAuthedGet(baseUrl, path, keysDir) {
|
|
617
|
+
try {
|
|
618
|
+
return await api("GET", path, undefined, { baseUrl });
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
if (!(err instanceof ApiHttpError) || !err.noCredentials)
|
|
622
|
+
throw err;
|
|
623
|
+
let keyFiles = [];
|
|
624
|
+
try {
|
|
625
|
+
keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key")).sort();
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
// No keys dir at all — nothing to fall back to.
|
|
629
|
+
}
|
|
630
|
+
for (const kf of keyFiles) {
|
|
631
|
+
const agentId = kf.replace(/\.key$/, "");
|
|
632
|
+
const keyPath = join(keysDir, kf);
|
|
633
|
+
try {
|
|
634
|
+
const res = await authFetch(baseUrl, agentId, keyPath, "GET", path);
|
|
635
|
+
if (res.ok)
|
|
636
|
+
return await res.json();
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// This key didn't work (unregistered, bad signature, network blip
|
|
640
|
+
// on this one attempt) — try the next one.
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// No local key authenticated either — surface api()'s original,
|
|
644
|
+
// actionable "no credentials sent" hint rather than a fallback-specific
|
|
645
|
+
// message; it already names both remedies (FLAIR_ADMIN_PASS / `flair init`).
|
|
646
|
+
throw err;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* flair#741 fix #3: does this ProbeResult's failure mean "the server
|
|
651
|
+
* responded but rejected the verifier's credentials" — proof of liveness,
|
|
652
|
+
* NOT a data-integrity risk — as opposed to a genuine down/unreachable/5xx
|
|
653
|
+
* failure where the instance's real state can't be determined? Pure.
|
|
654
|
+
*
|
|
655
|
+
* This is the single predicate behind three call sites in the `upgrade`
|
|
656
|
+
* command: whether the pre-upgrade credential pre-flight aborts (fix #1),
|
|
657
|
+
* and whether the post-restart / post-rollback verification failure
|
|
658
|
+
* messages claim "instance state UNKNOWN — do not assume data integrity"
|
|
659
|
+
* (they must NOT, for this case — that's the flair#741 incident itself) or
|
|
660
|
+
* explain the real, much less scary situation instead.
|
|
661
|
+
*/
|
|
662
|
+
export function isCredentialOnlyFailure(result) {
|
|
663
|
+
return result.healthy === true && result.authFailureKind === "credentials";
|
|
664
|
+
}
|
|
556
665
|
async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
|
|
557
666
|
const url = `http://127.0.0.1:${httpPort}/Health`;
|
|
558
667
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -2281,7 +2390,12 @@ program
|
|
|
2281
2390
|
type: "stdio",
|
|
2282
2391
|
command: "npx",
|
|
2283
2392
|
args: ["-y", "@tpsdev-ai/flair-mcp"],
|
|
2284
|
-
|
|
2393
|
+
// flair#718 authorship-provenance: each client's wired env block
|
|
2394
|
+
// gets its OWN FLAIR_CLIENT label (never the shared mcpEnv
|
|
2395
|
+
// object directly — that would stamp the same label into every
|
|
2396
|
+
// client's config) so writes from THIS client's proxy stamp
|
|
2397
|
+
// provenance.claimed.client = "claude-code".
|
|
2398
|
+
env: { ...mcpEnv, FLAIR_CLIENT: "claude-code" },
|
|
2285
2399
|
};
|
|
2286
2400
|
try {
|
|
2287
2401
|
if (existsSync(claudeJsonPath)) {
|
|
@@ -2337,15 +2451,18 @@ program
|
|
|
2337
2451
|
}
|
|
2338
2452
|
else {
|
|
2339
2453
|
let result;
|
|
2454
|
+
// flair#718 authorship-provenance — see the claude-code branch's
|
|
2455
|
+
// identical comment above: FLAIR_CLIENT is per-client, so it's
|
|
2456
|
+
// added at each call site rather than baked into the shared mcpEnv.
|
|
2340
2457
|
switch (clientId) {
|
|
2341
2458
|
case "codex":
|
|
2342
|
-
result = wireCodex(mcpEnv);
|
|
2459
|
+
result = wireCodex({ ...mcpEnv, FLAIR_CLIENT: "codex" });
|
|
2343
2460
|
break;
|
|
2344
2461
|
case "gemini":
|
|
2345
|
-
result = wireGemini(mcpEnv);
|
|
2462
|
+
result = wireGemini({ ...mcpEnv, FLAIR_CLIENT: "gemini" });
|
|
2346
2463
|
break;
|
|
2347
2464
|
case "cursor":
|
|
2348
|
-
result = wireCursor(mcpEnv);
|
|
2465
|
+
result = wireCursor({ ...mcpEnv, FLAIR_CLIENT: "cursor" });
|
|
2349
2466
|
break;
|
|
2350
2467
|
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
2351
2468
|
}
|
|
@@ -2858,6 +2975,164 @@ agent
|
|
|
2858
2975
|
}
|
|
2859
2976
|
console.log(`\n✅ Agent '${id}' removed successfully`);
|
|
2860
2977
|
});
|
|
2978
|
+
/** Best-effort seed-validity check for a `.key` file: does it parse via any
|
|
2979
|
+
* of the formats loadEd25519PrivateKeyFromFile (src/mcp-client-assertion.ts
|
|
2980
|
+
* — the same loader `flair mcp token` uses) accepts? Never throws — used
|
|
2981
|
+
* only to decide "invalid" vs. "worth a registration check", not to
|
|
2982
|
+
* actually sign anything. */
|
|
2983
|
+
function isValidPrivateKeySeedFile(keyPath) {
|
|
2984
|
+
try {
|
|
2985
|
+
loadEd25519PrivateKeyFromFile(keyPath);
|
|
2986
|
+
return true;
|
|
2987
|
+
}
|
|
2988
|
+
catch {
|
|
2989
|
+
return false;
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
/**
|
|
2993
|
+
* Classify every entry in `keysDir` for `flair keys prune`. Pure READ —
|
|
2994
|
+
* never writes or moves anything; see applyKeyPrune below for the actual
|
|
2995
|
+
* move. Directories (including keysDir's own `.pruned` archive, PRUNED_DIR_NAME)
|
|
2996
|
+
* and files not ending in `.key` are "ignored" without any network call.
|
|
2997
|
+
* `.key` files with an unparseable seed are "invalid" without a network call
|
|
2998
|
+
* either — only a `.key` file that DOES parse triggers a signed
|
|
2999
|
+
* `GET /Agent/:id` against `baseUrl` (checkAgentRegistered above, the exact
|
|
3000
|
+
* same check doctor's registration gate uses).
|
|
3001
|
+
*
|
|
3002
|
+
* If that check EVER reports "unreachable" — the instance couldn't be
|
|
3003
|
+
* confirmed up for that key — the WHOLE run aborts immediately
|
|
3004
|
+
* (`aborted: true`, `entries: []`, short-circuiting the loop): never
|
|
3005
|
+
* classify anything, prunable or not, while registration state can't be
|
|
3006
|
+
* verified. A missing keysDir is treated as "nothing to classify" (fresh
|
|
3007
|
+
* install), not an error — matches `flair doctor`'s own "Keys directory
|
|
3008
|
+
* missing" being a separate, non-fatal finding.
|
|
3009
|
+
*/
|
|
3010
|
+
export async function classifyKeysDir(keysDir, baseUrl) {
|
|
3011
|
+
if (!existsSync(keysDir))
|
|
3012
|
+
return { aborted: false, entries: [] };
|
|
3013
|
+
const dirents = readdirSync(keysDir, { withFileTypes: true });
|
|
3014
|
+
const entries = [];
|
|
3015
|
+
const candidates = [];
|
|
3016
|
+
for (const d of dirents) {
|
|
3017
|
+
if (d.isDirectory()) {
|
|
3018
|
+
entries.push({
|
|
3019
|
+
name: d.name,
|
|
3020
|
+
class: "ignored",
|
|
3021
|
+
reason: d.name === PRUNED_DIR_NAME ? "prune archive directory" : "directory (not a key file)",
|
|
3022
|
+
});
|
|
3023
|
+
continue;
|
|
3024
|
+
}
|
|
3025
|
+
if (!d.name.endsWith(".key")) {
|
|
3026
|
+
entries.push({ name: d.name, class: "ignored", reason: "not a .key file" });
|
|
3027
|
+
continue;
|
|
3028
|
+
}
|
|
3029
|
+
candidates.push({ name: d.name, agentId: d.name.slice(0, -".key".length) });
|
|
3030
|
+
}
|
|
3031
|
+
for (const c of candidates) {
|
|
3032
|
+
const keyPath = join(keysDir, c.name);
|
|
3033
|
+
if (!isValidPrivateKeySeedFile(keyPath)) {
|
|
3034
|
+
const decision = classifyKeyFile(c.agentId, false, null, baseUrl);
|
|
3035
|
+
entries.push({ name: c.name, class: decision.class, reason: decision.reason, agentId: c.agentId });
|
|
3036
|
+
continue;
|
|
3037
|
+
}
|
|
3038
|
+
const reg = await checkAgentRegistered(baseUrl, c.agentId, keysDir);
|
|
3039
|
+
if (reg.state === "unreachable") {
|
|
3040
|
+
return {
|
|
3041
|
+
aborted: true,
|
|
3042
|
+
abortReason: `could not reach ${baseUrl} to verify agent '${c.agentId}' is registered` +
|
|
3043
|
+
`${reg.detail ? ` (${reg.detail})` : ""} — aborting; nothing was classified or moved. ` +
|
|
3044
|
+
`Pass --instance <url> to target a different instance.`,
|
|
3045
|
+
entries: [],
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
const decision = classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
|
|
3049
|
+
entries.push({ name: c.name, class: decision.class, reason: decision.reason, agentId: c.agentId });
|
|
3050
|
+
}
|
|
3051
|
+
return { aborted: false, entries };
|
|
3052
|
+
}
|
|
3053
|
+
/**
|
|
3054
|
+
* Move every "stale" or "invalid" entry from `keysDir` into
|
|
3055
|
+
* `<keysDir>/.pruned/<dateStamp>/`, creating the archive dir as needed —
|
|
3056
|
+
* MOVE, never delete, so a bad classification is always recoverable. Only
|
|
3057
|
+
* ever called with entries classifyKeysDir already decided are prunable; a
|
|
3058
|
+
* "keep" or "ignored" entry passed in here is simply skipped (defense in
|
|
3059
|
+
* depth — a registered agent's key must never move, even if a caller bug
|
|
3060
|
+
* fed it in). Collisions (same filename already archived from an earlier
|
|
3061
|
+
* prune run today) get a numeric suffix (resolveCollisionSafeName) rather
|
|
3062
|
+
* than silently overwriting the earlier archive.
|
|
3063
|
+
*/
|
|
3064
|
+
export function applyKeyPrune(keysDir, entries, dateStamp) {
|
|
3065
|
+
const prunable = entries.filter((e) => e.class === "stale" || e.class === "invalid");
|
|
3066
|
+
if (prunable.length === 0)
|
|
3067
|
+
return [];
|
|
3068
|
+
const destDir = join(keysDir, PRUNED_DIR_NAME, dateStamp);
|
|
3069
|
+
mkdirSync(destDir, { recursive: true });
|
|
3070
|
+
const existing = new Set(readdirSync(destDir));
|
|
3071
|
+
const moved = [];
|
|
3072
|
+
for (const e of prunable) {
|
|
3073
|
+
const destName = resolveCollisionSafeName(existing, e.name);
|
|
3074
|
+
existing.add(destName);
|
|
3075
|
+
const from = join(keysDir, e.name);
|
|
3076
|
+
const to = join(destDir, destName);
|
|
3077
|
+
renameSync(from, to);
|
|
3078
|
+
moved.push({ name: e.name, movedTo: to });
|
|
3079
|
+
}
|
|
3080
|
+
return moved;
|
|
3081
|
+
}
|
|
3082
|
+
const keys = program.command("keys").description("Manage Ed25519 key files in the key directory");
|
|
3083
|
+
keys
|
|
3084
|
+
.command("prune")
|
|
3085
|
+
.description("Move stale/unregistered/invalid keys to <keysDir>/.pruned/<date>/ — dry-run by default")
|
|
3086
|
+
.option("--apply", "Actually move prunable keys (default: dry-run, prints what would move and why)")
|
|
3087
|
+
.option("--keys-dir <dir>", "Directory to scan for key files (else FLAIR_KEY_DIR, ~/.flair/keys)")
|
|
3088
|
+
.option("--instance <url>", "Flair instance to check registration against (else FLAIR_TARGET/FLAIR_URL/config)")
|
|
3089
|
+
.option("--port <port>", "Harper HTTP port (used when --instance/FLAIR_URL/FLAIR_TARGET are not set)")
|
|
3090
|
+
.action(async (opts) => {
|
|
3091
|
+
const keysDir = opts.keysDir ?? process.env.FLAIR_KEY_DIR ?? defaultKeysDir();
|
|
3092
|
+
const baseUrl = resolveBaseUrl({ target: opts.instance, port: opts.port });
|
|
3093
|
+
const apply = !!opts.apply;
|
|
3094
|
+
console.log(`\n${render.wrap(render.c.bold, "🔑 Flair Keys Prune")}${apply ? "" : render.wrap(render.c.dim, " (dry run)")}\n`);
|
|
3095
|
+
console.log(` Keys directory: ${render.wrap(render.c.dim, keysDir)}`);
|
|
3096
|
+
console.log(` Instance: ${render.wrap(render.c.dim, baseUrl)}\n`);
|
|
3097
|
+
const result = await classifyKeysDir(keysDir, baseUrl);
|
|
3098
|
+
if (result.aborted) {
|
|
3099
|
+
console.error(` ${render.icons.error} ${result.abortReason}`);
|
|
3100
|
+
console.log("");
|
|
3101
|
+
process.exit(1);
|
|
3102
|
+
}
|
|
3103
|
+
const stale = result.entries.filter((e) => e.class === "stale");
|
|
3104
|
+
const invalid = result.entries.filter((e) => e.class === "invalid");
|
|
3105
|
+
const kept = result.entries.filter((e) => e.class === "keep");
|
|
3106
|
+
const ignored = result.entries.filter((e) => e.class === "ignored");
|
|
3107
|
+
const prunable = [...stale, ...invalid];
|
|
3108
|
+
if (stale.length + invalid.length + kept.length === 0) {
|
|
3109
|
+
console.log(` ${render.icons.ok} No key files found in ${render.wrap(render.c.dim, keysDir)} — nothing to prune.`);
|
|
3110
|
+
console.log("");
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
for (const e of prunable) {
|
|
3114
|
+
const icon = e.class === "invalid" ? render.icons.error : render.icons.warn;
|
|
3115
|
+
console.log(` ${icon} ${render.wrap(render.c.bold, e.name)} — ${e.class}: ${e.reason}`);
|
|
3116
|
+
}
|
|
3117
|
+
for (const e of kept) {
|
|
3118
|
+
console.log(` ${render.icons.ok} ${e.name} — registered, keeping`);
|
|
3119
|
+
}
|
|
3120
|
+
if (!apply) {
|
|
3121
|
+
console.log("");
|
|
3122
|
+
console.log(` ${render.wrap(render.c.dim, `${prunable.length} prunable (${stale.length} stale, ${invalid.length} invalid), ${kept.length} kept, ${ignored.length} ignored`)}`);
|
|
3123
|
+
if (prunable.length > 0) {
|
|
3124
|
+
console.log(` ${render.wrap(render.c.dim, "Run with --apply to move prunable keys to")} ${join(keysDir, PRUNED_DIR_NAME, pruneDateStamp())}`);
|
|
3125
|
+
}
|
|
3126
|
+
console.log("");
|
|
3127
|
+
return;
|
|
3128
|
+
}
|
|
3129
|
+
const moved = applyKeyPrune(keysDir, result.entries, pruneDateStamp());
|
|
3130
|
+
console.log("");
|
|
3131
|
+
for (const m of moved) {
|
|
3132
|
+
console.log(` ${render.icons.ok} moved ${m.name} -> ${m.movedTo}`);
|
|
3133
|
+
}
|
|
3134
|
+
console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${ignored.length} ignored\n`);
|
|
3135
|
+
});
|
|
2861
3136
|
// ─── flair mcp ───────────────────────────────────────────────────────────────
|
|
2862
3137
|
// Headless agent-auth to a Harper MCP `/mcp` endpoint: RFC 7523
|
|
2863
3138
|
// client_credentials + private_key_jwt, using the agent's EXISTING Ed25519
|
|
@@ -7017,6 +7292,66 @@ program
|
|
|
7017
7292
|
// `opts` — safe to call this early.
|
|
7018
7293
|
const { restart: shouldRestart, verify: shouldVerify, deprecatedRestartFlagUsed } = resolveUpgradeRestartVerify(opts);
|
|
7019
7294
|
const upgradePort = resolveHttpPort({});
|
|
7295
|
+
// Hoisted so the pre-flight check (below) and the post-restart/rollback
|
|
7296
|
+
// verification steps (further down) all target the same URL — upgrade
|
|
7297
|
+
// never restarts Flair onto a different port.
|
|
7298
|
+
const baseUrl = `http://127.0.0.1:${upgradePort}`;
|
|
7299
|
+
// ── Credential pre-flight (flair#741 fix #1) ────────────────────────────
|
|
7300
|
+
// Post-restart verification (below) needs to authenticate against the
|
|
7301
|
+
// running instance. If it can't do that RIGHT NOW, against the CURRENT,
|
|
7302
|
+
// pre-upgrade instance, every upgrade on this machine is structurally
|
|
7303
|
+
// doomed before a single package is touched: post-restart verify fails
|
|
7304
|
+
// for the exact same credential reason, the rollback fires, and the
|
|
7305
|
+
// rollback's own re-verify fails identically — producing "ROLLBACK ALSO
|
|
7306
|
+
// FAILED VERIFICATION / state UNKNOWN" for an instance that was healthy
|
|
7307
|
+
// the entire time. That is exactly the flair#741 incident report (a
|
|
7308
|
+
// real 0.22.0→0.22.1 upgrade, healthy Flair, no ~/.flair/admin-pass, no
|
|
7309
|
+
// FLAIR_ADMIN_PASS). Catch it here, before any mutation, with a message
|
|
7310
|
+
// that says plainly: nothing was touched.
|
|
7311
|
+
//
|
|
7312
|
+
// Runs the SAME verification call (probeInstance + the agent-key-aware
|
|
7313
|
+
// verifyAuthedGet, fix #2) that post-restart verification uses below —
|
|
7314
|
+
// just against the pre-upgrade instance, with no expectVersion (there's
|
|
7315
|
+
// no target version to compare against yet; the question here is purely
|
|
7316
|
+
// "does an authenticated read work at all").
|
|
7317
|
+
//
|
|
7318
|
+
// Gated on --verify (shouldVerify): this check exists ONLY to keep
|
|
7319
|
+
// post-restart verification honest. A user who already opted out of
|
|
7320
|
+
// that verification with --no-verify has no use for a pre-flight that
|
|
7321
|
+
// protects it, and blocking their upgrade on a check they didn't ask
|
|
7322
|
+
// for would be a new, surprising failure mode of its own.
|
|
7323
|
+
//
|
|
7324
|
+
// Deliberately does NOT abort when the pre-flight instance is merely
|
|
7325
|
+
// UNREACHABLE (down/timeout) rather than reachable-but-unauthenticated.
|
|
7326
|
+
// `flair upgrade` may be the user's way of FIXING a down instance (bad
|
|
7327
|
+
// code on disk that a newer version resolves) — today's behavior
|
|
7328
|
+
// (pre-flair#741, no pre-flight at all) already lets that proceed, and
|
|
7329
|
+
// a new hard block here would take away a legitimate recovery path for
|
|
7330
|
+
// a failure mode this issue was never about. Only the specific
|
|
7331
|
+
// "server responded, credentials didn't work" case is structurally
|
|
7332
|
+
// doomed in a way a fresh install/restart can't fix on its own — so
|
|
7333
|
+
// only that case aborts. (If a down instance turns out to ALSO lack
|
|
7334
|
+
// credentials, that surfaces the normal way: post-restart verification
|
|
7335
|
+
// fails and rolls back, same as any other post-restart failure.)
|
|
7336
|
+
if (shouldVerify) {
|
|
7337
|
+
const preflight = await probeInstance(baseUrl, {
|
|
7338
|
+
// A short, bounded budget — this instance is presumed already
|
|
7339
|
+
// running (upgrade's normal case); doctor's probePort convention
|
|
7340
|
+
// (probeFlairReachable's doc comment) uses the same ~3s ballpark
|
|
7341
|
+
// for "is anything there at all" checks.
|
|
7342
|
+
timeoutMs: 3000,
|
|
7343
|
+
pollIntervalMs: 300,
|
|
7344
|
+
authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
|
|
7345
|
+
});
|
|
7346
|
+
if (isCredentialOnlyFailure(preflight)) {
|
|
7347
|
+
console.error(`❌ pre-flight check failed: ${preflight.error}`);
|
|
7348
|
+
console.error(" Nothing has been touched — no packages were installed, no restart happened.");
|
|
7349
|
+
console.error(" The current instance is up and responded; the verifier just has no way to authenticate against it.");
|
|
7350
|
+
console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key — then re-run flair upgrade.");
|
|
7351
|
+
console.error(" (--no-verify skips this check too, but post-restart verification would then fail the exact same way.)");
|
|
7352
|
+
process.exit(1);
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
7020
7355
|
// ── Pre-upgrade data snapshot (flair#637, opt-in as of the 2026-07-08 rewire) ──
|
|
7021
7356
|
// Only an @tpsdev-ai/flair package swap touches the code that reads/
|
|
7022
7357
|
// writes ~/.flair/data — an flair-mcp-only or openclaw-plugin-only
|
|
@@ -7170,7 +7505,7 @@ program
|
|
|
7170
7505
|
}
|
|
7171
7506
|
console.log("\nRestarting Flair...");
|
|
7172
7507
|
const port = upgradePort;
|
|
7173
|
-
|
|
7508
|
+
// baseUrl was hoisted above (pre-flight, fix #1) — same URL, no redeclaration.
|
|
7174
7509
|
try {
|
|
7175
7510
|
await restartFlair(port);
|
|
7176
7511
|
}
|
|
@@ -7185,13 +7520,16 @@ program
|
|
|
7185
7520
|
return;
|
|
7186
7521
|
}
|
|
7187
7522
|
console.log("\nVerifying...");
|
|
7188
|
-
// The authenticated leg
|
|
7189
|
-
// (flair#640: env > agent key
|
|
7190
|
-
//
|
|
7523
|
+
// The authenticated leg reuses verifyAuthedGet (flair#741 fix #2): api()'s
|
|
7524
|
+
// local-credential resolution (flair#640: env > agent key when an agentId
|
|
7525
|
+
// is already known > ~/.flair/admin-pass file), PLUS an Ed25519 agent-key
|
|
7526
|
+
// fallback when none of that resolves anything — see verifyAuthedGet's
|
|
7527
|
+
// doc comment. probeInstance itself never resolves credentials, it just
|
|
7528
|
+
// calls whatever's handed to it.
|
|
7191
7529
|
const verify = await probeInstance(baseUrl, {
|
|
7192
7530
|
expectVersion: expectedFlairVersion ?? undefined,
|
|
7193
7531
|
timeoutMs: STARTUP_TIMEOUT_MS,
|
|
7194
|
-
authedGet: (path) =>
|
|
7532
|
+
authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
|
|
7195
7533
|
});
|
|
7196
7534
|
const verdict = decideAfterVerify(verify, previousFlairVersion);
|
|
7197
7535
|
if (verdict.kind === "ok") {
|
|
@@ -7199,6 +7537,17 @@ program
|
|
|
7199
7537
|
return;
|
|
7200
7538
|
}
|
|
7201
7539
|
console.error(`❌ post-restart verification failed: ${verdict.reason}`);
|
|
7540
|
+
if (isCredentialOnlyFailure(verify)) {
|
|
7541
|
+
// flair#741 fix #3: a responding server that rejects the verifier's
|
|
7542
|
+
// credentials proves the instance is UP — it is not evidence the
|
|
7543
|
+
// upgrade itself broke anything. Say so explicitly instead of leaving
|
|
7544
|
+
// the reader to infer it from the raw error text. Rollback still
|
|
7545
|
+
// proceeds below (a credential check that worked pre-upgrade — see
|
|
7546
|
+
// the pre-flight above — and stopped working mid-run is an edge case
|
|
7547
|
+
// ambiguous enough that "prefer the known-good version" is still the
|
|
7548
|
+
// safer default), but the reason for it is now honest.
|
|
7549
|
+
console.error(" The instance is up and responded — the verifier could not authenticate. This is not a sign the upgrade broke anything.");
|
|
7550
|
+
}
|
|
7202
7551
|
if (verdict.kind === "cannot-rollback") {
|
|
7203
7552
|
console.error(" Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown.");
|
|
7204
7553
|
console.error(" Check the instance now: flair doctor");
|
|
@@ -7224,7 +7573,7 @@ program
|
|
|
7224
7573
|
const rollbackVerify = await probeInstance(baseUrl, {
|
|
7225
7574
|
expectVersion: verdict.toVersion,
|
|
7226
7575
|
timeoutMs: STARTUP_TIMEOUT_MS,
|
|
7227
|
-
authedGet: (path) =>
|
|
7576
|
+
authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
|
|
7228
7577
|
});
|
|
7229
7578
|
const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
|
|
7230
7579
|
if (rollbackVerdict.kind === "rolled-back") {
|
|
@@ -7233,7 +7582,22 @@ program
|
|
|
7233
7582
|
process.exit(1);
|
|
7234
7583
|
}
|
|
7235
7584
|
console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
|
|
7236
|
-
|
|
7585
|
+
// flair#741 fix #3: this is the exact incident report — a 403 from a
|
|
7586
|
+
// responding, healthy server (credentials-only failure) was printed as
|
|
7587
|
+
// "state UNKNOWN — do not assume data integrity" for BOTH the upgrade
|
|
7588
|
+
// verify AND the rollback re-verify, because the same missing-auth-
|
|
7589
|
+
// material condition rejects both. Reserve the UNKNOWN/do-not-assume
|
|
7590
|
+
// text for failures where the instance's real state genuinely can't be
|
|
7591
|
+
// determined (connection refused, timeout, 5xx) — a credential-only
|
|
7592
|
+
// failure here means the rollback likely landed fine and the checker
|
|
7593
|
+
// simply can't prove it.
|
|
7594
|
+
if (isCredentialOnlyFailure(rollbackVerify)) {
|
|
7595
|
+
console.error(" The instance is up and responding — the verifier could not authenticate (credentials, not the rollback, are the problem).");
|
|
7596
|
+
console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key, then check: flair doctor");
|
|
7597
|
+
}
|
|
7598
|
+
else {
|
|
7599
|
+
console.error(" Instance state is UNKNOWN — do not assume data integrity.");
|
|
7600
|
+
}
|
|
7237
7601
|
// This double-failure isn't auto-recoverable yet (flair#637) — but if a
|
|
7238
7602
|
// pre-upgrade snapshot landed, point at the CONCRETE path instead of
|
|
7239
7603
|
// just the issue number, so recovery doesn't start with a GitHub search.
|
|
@@ -8191,6 +8555,7 @@ program
|
|
|
8191
8555
|
let issues = 0;
|
|
8192
8556
|
let fixed = 0; // issues that --fix successfully resolved during this run (flair#721)
|
|
8193
8557
|
let harperResponding = false;
|
|
8558
|
+
let keyAgentIds = []; // populated by step 2 (Keys directory) below; feeds the flair#722 per-agent iteration
|
|
8194
8559
|
console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
|
|
8195
8560
|
// 0. Version check (flair#587) — offline-tolerant + cached, independent
|
|
8196
8561
|
// of Harper being up. A gap of ≥2 minor versions (or any major) is
|
|
@@ -8386,6 +8751,7 @@ program
|
|
|
8386
8751
|
if (existsSync(keysDir)) {
|
|
8387
8752
|
const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
|
|
8388
8753
|
if (keyFiles.length > 0) {
|
|
8754
|
+
keyAgentIds = keyFiles.map((f) => f.replace(/\.key$/, ""));
|
|
8389
8755
|
console.log(` ${render.icons.ok} Keys found: ${render.wrap(render.c.bold, String(keyFiles.length))} agent(s) in ${render.wrap(render.c.dim, keysDir)}`);
|
|
8390
8756
|
}
|
|
8391
8757
|
else {
|
|
@@ -8537,7 +8903,7 @@ program
|
|
|
8537
8903
|
console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent id known — pass --agent <id>`);
|
|
8538
8904
|
}
|
|
8539
8905
|
else {
|
|
8540
|
-
const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: block.flairUrl
|
|
8906
|
+
const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: resolveWireFlairUrl(block.flairUrl, baseUrl) };
|
|
8541
8907
|
const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
|
|
8542
8908
|
client.id === "codex" ? wireCodex(wireEnv) :
|
|
8543
8909
|
client.id === "gemini" ? wireGemini(wireEnv) :
|
|
@@ -8637,6 +9003,54 @@ program
|
|
|
8637
9003
|
}
|
|
8638
9004
|
}
|
|
8639
9005
|
}
|
|
9006
|
+
// 7a. Resolve which agent identities the two verified-read sections below
|
|
9007
|
+
// (Fleet presence, Migrations) iterate (flair#722). Previously both
|
|
9008
|
+
// sections required --agent explicitly; doctor already enumerates every
|
|
9009
|
+
// key in ~/.flair/keys (step 2 above), so by default it now runs the
|
|
9010
|
+
// signed read AS EACH of those agents instead of hiding behind a flag —
|
|
9011
|
+
// a real dogfood run found the #720 halted-migration warning visible via
|
|
9012
|
+
// `flair status --agent local` but invisible in the default `doctor` run
|
|
9013
|
+
// the same user ran minutes later. --agent <id> narrows this to exactly
|
|
9014
|
+
// that one identity (planAgentIterations — same pre-#722 semantics: a
|
|
9015
|
+
// single signed identity, just no longer widened to "every key").
|
|
9016
|
+
//
|
|
9017
|
+
// The registration gate (checkAgentRegistered — same signed GET
|
|
9018
|
+
// /Agent/:id used by the Client integration section above) is resolved
|
|
9019
|
+
// ONCE here per agent and shared by both sections, so a bad/unregistered
|
|
9020
|
+
// key doesn't cost two network round-trips, and its "found" count isn't
|
|
9021
|
+
// double-counted by each section re-discovering the same finding
|
|
9022
|
+
// (flair#721 found/fixed/remaining summary — these are found-only, no
|
|
9023
|
+
// --fix action exists for a bad local key). A gate failure for one agent
|
|
9024
|
+
// never aborts the others — that's the failure isolation flair#722 asks
|
|
9025
|
+
// for; describeAgentGateFinding (src/doctor-client.ts) is pure decision
|
|
9026
|
+
// logic so it's unit-tested without a real Harper.
|
|
9027
|
+
const verifiedReadAgentIds = harperResponding
|
|
9028
|
+
? planAgentIterations(keyAgentIds, opts.agent || process.env.FLAIR_AGENT_ID)
|
|
9029
|
+
: [];
|
|
9030
|
+
const agentGates = [];
|
|
9031
|
+
for (const id of verifiedReadAgentIds) {
|
|
9032
|
+
const reg = await checkAgentRegistered(baseUrl, id, defaultKeysDir());
|
|
9033
|
+
agentGates.push({ id, state: reg.state, detail: reg.detail });
|
|
9034
|
+
const finding = describeAgentGateFinding(id, reg.state, reg.detail);
|
|
9035
|
+
if (finding?.isIssue)
|
|
9036
|
+
issues++;
|
|
9037
|
+
}
|
|
9038
|
+
// Shared renderer for one agent's registration-gate outcome — prints the
|
|
9039
|
+
// "Agent: <id>" subsection header, and if the gate isn't clean, the
|
|
9040
|
+
// finding (never re-counted here; already counted once above) and
|
|
9041
|
+
// returns false so the caller skips its own verified fetch for this
|
|
9042
|
+
// agent and moves on to the next (failure isolation).
|
|
9043
|
+
function renderAgentGateHeader(gate) {
|
|
9044
|
+
console.log(` ${render.wrap(render.c.dim, `Agent: ${gate.id}`)}`);
|
|
9045
|
+
const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail);
|
|
9046
|
+
if (!finding)
|
|
9047
|
+
return true;
|
|
9048
|
+
const icon = finding.icon === "error" ? render.icons.error : render.icons.warn;
|
|
9049
|
+
console.log(` ${icon} ${finding.message}`);
|
|
9050
|
+
if (finding.fixHint)
|
|
9051
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} ${finding.fixHint}`);
|
|
9052
|
+
return false;
|
|
9053
|
+
}
|
|
8640
9054
|
// 8. Fleet presence (flair#639) — known instances via /Presence heartbeats.
|
|
8641
9055
|
//
|
|
8642
9056
|
// "Instance" here means each AGENT's heartbeat row — Presence is keyed by
|
|
@@ -8657,69 +9071,82 @@ program
|
|
|
8657
9071
|
// `doctor` unless those agents also heartbeat straight to the hub. Not
|
|
8658
9072
|
// fixed here — flair#639's fix list is version-stamping + a doctor
|
|
8659
9073
|
// listing, not widening federation sync scope.
|
|
8660
|
-
|
|
8661
|
-
|
|
9074
|
+
//
|
|
9075
|
+
// flair#722: iterated per agent (agentGates above) instead of a single
|
|
9076
|
+
// --agent-gated read. flairVersion/harperVersion are gated to verified
|
|
9077
|
+
// readers on the server (resources/Presence.ts, same boundary as
|
|
9078
|
+
// currentTask), so each agent subsection signs its own GET — a working
|
|
9079
|
+
// key reveals versions for that subsection; roster IDENTITY is public
|
|
9080
|
+
// either way. Zero local keys (and no --agent) falls back to exactly the
|
|
9081
|
+
// pre-#722 single unauthenticated read (hidden versions, "Pass --agent"
|
|
9082
|
+
// hint) — there's no agent to sign as, but remote agents may still have
|
|
9083
|
+
// heartbeated onto this instance and identities are worth showing.
|
|
9084
|
+
async function fetchAndRenderFleetPresence(headers, canSign, indent) {
|
|
8662
9085
|
try {
|
|
8663
|
-
// flairVersion/harperVersion are gated to verified readers on the
|
|
8664
|
-
// server (resources/Presence.ts, same boundary as currentTask) — sign
|
|
8665
|
-
// the GET when we have an agent + key so the fields aren't silently
|
|
8666
|
-
// nulled out from under us.
|
|
8667
|
-
const fleetAgentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
8668
|
-
const fleetKeyPath = fleetAgentId ? join(defaultKeysDir(), `${fleetAgentId}.key`) : undefined;
|
|
8669
|
-
const canSign = !!(fleetAgentId && fleetKeyPath && existsSync(fleetKeyPath));
|
|
8670
|
-
const headers = canSign
|
|
8671
|
-
? { Authorization: buildEd25519Auth(fleetAgentId, "GET", "/Presence", fleetKeyPath) }
|
|
8672
|
-
: {};
|
|
8673
9086
|
const presRes = await fetch(`${baseUrl}/Presence`, { headers, signal: AbortSignal.timeout(5000) });
|
|
8674
9087
|
if (!presRes.ok) {
|
|
8675
|
-
console.log(
|
|
9088
|
+
console.log(`${indent}${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
|
|
9089
|
+
return;
|
|
8676
9090
|
}
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
}
|
|
8682
|
-
else {
|
|
8683
|
-
const rows = sortOldestVersionFirst(markStale(roster));
|
|
8684
|
-
for (const row of rows) {
|
|
8685
|
-
const lastSeen = typeof row.lastHeartbeatAt === "number"
|
|
8686
|
-
? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
|
|
8687
|
-
: "—";
|
|
8688
|
-
const versionLabel = !canSign
|
|
8689
|
-
? render.wrap(render.c.dim, "hidden")
|
|
8690
|
-
: row.flairVersion
|
|
8691
|
-
? `v${row.flairVersion}`
|
|
8692
|
-
: render.wrap(render.c.dim, "no version reported");
|
|
8693
|
-
const staleNote = row.stale && row.newestVersion
|
|
8694
|
-
? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
|
|
8695
|
-
: "";
|
|
8696
|
-
const icon = row.stale ? render.icons.warn : render.icons.ok;
|
|
8697
|
-
const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
|
|
8698
|
-
// Natural-presence: same staleness principle as the version
|
|
8699
|
-
// column — a live activity is shown as current, a decayed one as
|
|
8700
|
-
// "last-known". `activityFresh === false` (server verdict) plus a
|
|
8701
|
-
// known lastActivity → "(was: X)"; a fresh, non-idle activity →
|
|
8702
|
-
// "(X)". Skip entirely when there's nothing informative to say
|
|
8703
|
-
// (no signal, or idle) so the line stays quiet for the common case.
|
|
8704
|
-
const lastActivity = row.lastActivity ?? row.activity;
|
|
8705
|
-
const activityNote = row.activityFresh === false
|
|
8706
|
-
? (lastActivity && lastActivity !== "idle"
|
|
8707
|
-
? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
|
|
8708
|
-
: "")
|
|
8709
|
-
: (row.activity && row.activity !== "idle"
|
|
8710
|
-
? " " + render.wrap(render.c.dim, `(${row.activity})`)
|
|
8711
|
-
: "");
|
|
8712
|
-
console.log(` ${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
|
|
8713
|
-
}
|
|
8714
|
-
if (!canSign) {
|
|
8715
|
-
console.log(` ${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.")}`);
|
|
8716
|
-
}
|
|
8717
|
-
console.log(` ${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.")}`);
|
|
8718
|
-
}
|
|
9091
|
+
const roster = (await presRes.json());
|
|
9092
|
+
if (!Array.isArray(roster) || roster.length === 0) {
|
|
9093
|
+
console.log(`${indent}${render.icons.info} No known instances yet — no /Presence heartbeats recorded on this instance`);
|
|
9094
|
+
return;
|
|
8719
9095
|
}
|
|
9096
|
+
const rows = sortOldestVersionFirst(markStale(roster));
|
|
9097
|
+
for (const row of rows) {
|
|
9098
|
+
const lastSeen = typeof row.lastHeartbeatAt === "number"
|
|
9099
|
+
? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
|
|
9100
|
+
: "—";
|
|
9101
|
+
const versionLabel = !canSign
|
|
9102
|
+
? render.wrap(render.c.dim, "hidden")
|
|
9103
|
+
: row.flairVersion
|
|
9104
|
+
? `v${row.flairVersion}`
|
|
9105
|
+
: render.wrap(render.c.dim, "no version reported");
|
|
9106
|
+
const staleNote = row.stale && row.newestVersion
|
|
9107
|
+
? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
|
|
9108
|
+
: "";
|
|
9109
|
+
const icon = row.stale ? render.icons.warn : render.icons.ok;
|
|
9110
|
+
const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
|
|
9111
|
+
// Natural-presence: same staleness principle as the version
|
|
9112
|
+
// column — a live activity is shown as current, a decayed one as
|
|
9113
|
+
// "last-known". `activityFresh === false` (server verdict) plus a
|
|
9114
|
+
// known lastActivity → "(was: X)"; a fresh, non-idle activity →
|
|
9115
|
+
// "(X)". Skip entirely when there's nothing informative to say
|
|
9116
|
+
// (no signal, or idle) so the line stays quiet for the common case.
|
|
9117
|
+
const lastActivity = row.lastActivity ?? row.activity;
|
|
9118
|
+
const activityNote = row.activityFresh === false
|
|
9119
|
+
? (lastActivity && lastActivity !== "idle"
|
|
9120
|
+
? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
|
|
9121
|
+
: "")
|
|
9122
|
+
: (row.activity && row.activity !== "idle"
|
|
9123
|
+
? " " + render.wrap(render.c.dim, `(${row.activity})`)
|
|
9124
|
+
: "");
|
|
9125
|
+
console.log(`${indent}${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
|
|
9126
|
+
}
|
|
9127
|
+
if (!canSign) {
|
|
9128
|
+
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.")}`);
|
|
9129
|
+
}
|
|
9130
|
+
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.")}`);
|
|
8720
9131
|
}
|
|
8721
9132
|
catch (err) {
|
|
8722
|
-
console.log(
|
|
9133
|
+
console.log(`${indent}${render.icons.warn} Fleet presence check failed: ${err?.message ?? err}`);
|
|
9134
|
+
}
|
|
9135
|
+
}
|
|
9136
|
+
if (harperResponding) {
|
|
9137
|
+
console.log(`\n ${render.wrap(render.c.bold, "Fleet presence")}`);
|
|
9138
|
+
if (agentGates.length === 0) {
|
|
9139
|
+
await fetchAndRenderFleetPresence({}, false, " ");
|
|
9140
|
+
}
|
|
9141
|
+
else {
|
|
9142
|
+
for (const gate of agentGates) {
|
|
9143
|
+
const registered = renderAgentGateHeader(gate);
|
|
9144
|
+
if (!registered)
|
|
9145
|
+
continue;
|
|
9146
|
+
const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
|
|
9147
|
+
const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/Presence", keyPath) };
|
|
9148
|
+
await fetchAndRenderFleetPresence(headers, true, " ");
|
|
9149
|
+
}
|
|
8723
9150
|
}
|
|
8724
9151
|
}
|
|
8725
9152
|
// 9. Migration state (flair#695) — pending/in-progress/blocked + last
|
|
@@ -8729,52 +9156,70 @@ program
|
|
|
8729
9156
|
// 1a above (a halted migration retries automatically on the next boot —
|
|
8730
9157
|
// there's no separate "run the migration now" fix; the fix for
|
|
8731
9158
|
// "blocked" is whatever the halt reason names, e.g. freeing disk).
|
|
8732
|
-
|
|
8733
|
-
|
|
9159
|
+
//
|
|
9160
|
+
// flair#722: iterated per agent (agentGates above), same as Fleet
|
|
9161
|
+
// presence — each subsection's finding is found-only (no per-agent
|
|
9162
|
+
// --fix here beyond the existing restart-on-halt story). Gate FINDINGS
|
|
9163
|
+
// are rendered in full under Fleet presence only (the first
|
|
9164
|
+
// verified-read section); re-printing the identical per-agent finding
|
|
9165
|
+
// here doubled the noise on real multi-key machines (a 27-key dogfood
|
|
9166
|
+
// box printed 15 not-registered findings twice each), so this section
|
|
9167
|
+
// iterates only the gate-passed agents and rolls the rest into one
|
|
9168
|
+
// aggregate skip line. The issue COUNT is unaffected either way — gate
|
|
9169
|
+
// findings are counted exactly once, at gate-resolution time (step 7a).
|
|
9170
|
+
async function fetchAndRenderMigrations(headers, indent) {
|
|
8734
9171
|
try {
|
|
8735
|
-
const
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
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.`);
|
|
9172
|
+
const migRes = await fetch(`${baseUrl}/HealthDetail`, { headers, signal: AbortSignal.timeout(5000) });
|
|
9173
|
+
if (!migRes.ok) {
|
|
9174
|
+
console.log(`${indent}${render.icons.warn} Could not fetch migration state (HTTP ${migRes.status})`);
|
|
9175
|
+
return;
|
|
8740
9176
|
}
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
9177
|
+
const detail = (await migRes.json());
|
|
9178
|
+
const migBlock = detail?.migrations;
|
|
9179
|
+
if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
|
|
9180
|
+
console.log(`${indent}${render.icons.info} No migrations registered on this instance`);
|
|
9181
|
+
return;
|
|
9182
|
+
}
|
|
9183
|
+
if (migBlock.cyclePhase === "pre-hash") {
|
|
9184
|
+
console.log(`${indent}${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
|
|
9185
|
+
}
|
|
9186
|
+
for (const m of migBlock.migrations) {
|
|
9187
|
+
if (m.state === "completed") {
|
|
9188
|
+
console.log(`${indent}${render.icons.ok} ${m.id}: completed`);
|
|
9189
|
+
}
|
|
9190
|
+
else if (m.state === "halted" || m.state === "failed") {
|
|
9191
|
+
console.log(`${indent}${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
|
|
9192
|
+
issues++;
|
|
9193
|
+
}
|
|
9194
|
+
else if (m.state === "running") {
|
|
9195
|
+
console.log(`${indent}${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
|
|
8746
9196
|
}
|
|
8747
9197
|
else {
|
|
8748
|
-
|
|
8749
|
-
const migBlock = detail?.migrations;
|
|
8750
|
-
if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
|
|
8751
|
-
console.log(` ${render.icons.info} No migrations registered on this instance`);
|
|
8752
|
-
}
|
|
8753
|
-
else {
|
|
8754
|
-
if (migBlock.cyclePhase === "pre-hash") {
|
|
8755
|
-
console.log(` ${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
|
|
8756
|
-
}
|
|
8757
|
-
for (const m of migBlock.migrations) {
|
|
8758
|
-
if (m.state === "completed") {
|
|
8759
|
-
console.log(` ${render.icons.ok} ${m.id}: completed`);
|
|
8760
|
-
}
|
|
8761
|
-
else if (m.state === "halted" || m.state === "failed") {
|
|
8762
|
-
console.log(` ${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
|
|
8763
|
-
issues++;
|
|
8764
|
-
}
|
|
8765
|
-
else if (m.state === "running") {
|
|
8766
|
-
console.log(` ${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
|
|
8767
|
-
}
|
|
8768
|
-
else {
|
|
8769
|
-
console.log(` ${render.icons.info} ${m.id}: ${m.state}`);
|
|
8770
|
-
}
|
|
8771
|
-
}
|
|
8772
|
-
}
|
|
9198
|
+
console.log(`${indent}${render.icons.info} ${m.id}: ${m.state}`);
|
|
8773
9199
|
}
|
|
8774
9200
|
}
|
|
8775
9201
|
}
|
|
8776
9202
|
catch (err) {
|
|
8777
|
-
console.log(
|
|
9203
|
+
console.log(`${indent}${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
|
|
9204
|
+
}
|
|
9205
|
+
}
|
|
9206
|
+
if (harperResponding) {
|
|
9207
|
+
console.log(`\n ${render.wrap(render.c.bold, "Migrations")}`);
|
|
9208
|
+
if (agentGates.length === 0) {
|
|
9209
|
+
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.`);
|
|
9210
|
+
}
|
|
9211
|
+
else {
|
|
9212
|
+
const passedGates = agentGates.filter((g) => describeAgentGateFinding(g.id, g.state, g.detail) === null);
|
|
9213
|
+
for (const gate of passedGates) {
|
|
9214
|
+
renderAgentGateHeader(gate);
|
|
9215
|
+
const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
|
|
9216
|
+
const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/HealthDetail", keyPath) };
|
|
9217
|
+
await fetchAndRenderMigrations(headers, " ");
|
|
9218
|
+
}
|
|
9219
|
+
const skipped = agentGates.length - passedGates.length;
|
|
9220
|
+
if (skipped > 0) {
|
|
9221
|
+
console.log(` ${render.icons.info} ${skipped} agent(s) skipped — registration-gate findings reported under Fleet presence above`);
|
|
9222
|
+
}
|
|
8778
9223
|
}
|
|
8779
9224
|
}
|
|
8780
9225
|
// Summary — see summarizeDoctorRun above (flair#721): distinguishes
|