@tpsdev-ai/flair 0.20.1 → 0.22.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/README.md +39 -11
- package/SECURITY.md +52 -20
- package/config.yaml +32 -5
- package/dist/cli.js +2235 -260
- package/dist/deploy.js +96 -15
- package/dist/doctor-client.js +312 -0
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/install/clients.js +18 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/restore.js +1 -1
- package/dist/rem/runner.js +82 -12
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +121 -2
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +127 -22
- package/dist/resources/MemoryBootstrap.js +441 -85
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +69 -5
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +268 -17
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +108 -26
- package/dist/resources/SemanticSearch.js +88 -320
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +12 -1
- package/dist/resources/agent-auth.js +61 -3
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +42 -14
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +13 -7
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +456 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +73 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-check.js +167 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/agent.graphql +7 -0
- package/schemas/event.graphql +5 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +82 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- package/ui/observation-center.html +0 -385
package/dist/cli.js
CHANGED
|
@@ -3,16 +3,23 @@ import { Command } from "commander";
|
|
|
3
3
|
import nacl from "tweetnacl";
|
|
4
4
|
import { load as parseYaml } from "js-yaml";
|
|
5
5
|
import * as render from "./render.js";
|
|
6
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { join, resolve, sep, dirname } from "node:path";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
-
import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
|
|
10
|
+
import { createHash, createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
|
|
11
11
|
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
12
12
|
import { keystore } from "./keystore.js";
|
|
13
13
|
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
|
|
14
14
|
import { fabricUpgrade } from "./fabric-upgrade.js";
|
|
15
|
-
import {
|
|
15
|
+
import { checkVersion, formatVersionNudge } from "./version-check.js";
|
|
16
|
+
import { checkServerHandshake, formatHandshakeNudge } from "./version-handshake.js";
|
|
17
|
+
import { probeInstance } from "./probe.js";
|
|
18
|
+
import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
|
|
19
|
+
import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
|
|
20
|
+
import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
|
|
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";
|
|
16
23
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
17
24
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
18
25
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -35,6 +42,39 @@ function signBody(body, secretKey) {
|
|
|
35
42
|
const sig = nacl.sign.detached(message, secretKey);
|
|
36
43
|
return Buffer.from(sig).toString("base64url");
|
|
37
44
|
}
|
|
45
|
+
// Per-record principalId (federation-edge-hardening slice 3a) — INFORMATIONAL
|
|
46
|
+
// only; the receiver (resources/Federation.ts) never treats it as verified
|
|
47
|
+
// identity or uses it in any auth decision. Sourced from the write-time
|
|
48
|
+
// provenance stamp (memory-provenance slice 1, Memory.ts's buildProvenance)
|
|
49
|
+
// when present. `provenance` is persisted as a JSON STRING (not an object),
|
|
50
|
+
// so it must be parsed — a raw `row.provenance?.verified?.agentId` would
|
|
51
|
+
// silently always be undefined. Soul/Agent/Relationship rows never carry a
|
|
52
|
+
// provenance stamp today, so this is a no-op for them.
|
|
53
|
+
function principalIdFromRow(row) {
|
|
54
|
+
if (typeof row?.provenance !== "string" || row.provenance.length === 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(row.provenance)?.verified?.agentId ?? undefined;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Federation push private-visibility filter — inlined for the SAME reason as
|
|
64
|
+
// the crypto helpers above (see comment there; also resources/memory-
|
|
65
|
+
// visibility.ts, the canonical definition; the two must stay in sync).
|
|
66
|
+
//
|
|
67
|
+
// federation-edge-hardening slice 2 (the office-visibility read leak: one rule, one place): the
|
|
68
|
+
// push side of federation sync (runFederationSyncOnce below) must exclude
|
|
69
|
+
// `private` Memory rows from what gets sent to peers, using the EXACT same
|
|
70
|
+
// "not private" semantics as resources/memory-read-scope.ts's resolveReadScope()
|
|
71
|
+
// — a record with NO visibility field (legacy, pre-dates the field) is NOT
|
|
72
|
+
// private and must keep syncing exactly as before. Only `visibility ===
|
|
73
|
+
// "private"` is excluded; null/undefined/"shared"/anything else is included.
|
|
74
|
+
const FEDERATION_PRIVATE_VISIBILITY = "private";
|
|
75
|
+
function isFederationPrivateVisibility(visibility) {
|
|
76
|
+
return visibility === FEDERATION_PRIVATE_VISIBILITY;
|
|
77
|
+
}
|
|
38
78
|
// ─── Secret detection helpers ────────────────────────
|
|
39
79
|
/**
|
|
40
80
|
* Check if a value looks like a real secret/password/token.
|
|
@@ -86,33 +126,76 @@ const DEFAULT_ADMIN_USER = "admin";
|
|
|
86
126
|
const STARTUP_TIMEOUT_MS = 60_000;
|
|
87
127
|
const HEALTH_POLL_INTERVAL_MS = 500;
|
|
88
128
|
/**
|
|
89
|
-
* Read
|
|
129
|
+
* Read a secret (admin password, Fabric password, ...) from a file, refusing
|
|
130
|
+
* if the file is world/group readable.
|
|
90
131
|
*
|
|
91
|
-
*
|
|
92
|
-
* by `openssl rand` — mode 0600 keeps them
|
|
93
|
-
*
|
|
94
|
-
*
|
|
132
|
+
* Secret files (default ~/.flair/admin-pass; also used for --fabric-password-file)
|
|
133
|
+
* are short-lived values generated by `openssl rand` — mode 0600 keeps them
|
|
134
|
+
* out of reach of other local users + most backup tooling. A 0644 file
|
|
135
|
+
* silently leaks the secret to anyone with read access to the user's home
|
|
136
|
+
* (multi-user hosts, NFS, time-machine snapshots, etc.).
|
|
95
137
|
*
|
|
96
|
-
* Throws with an actionable error if the file isn't owner-only, otherwise
|
|
97
|
-
* the trimmed file content (
|
|
98
|
-
* conventionally end in a newline).
|
|
138
|
+
* Throws with an actionable error if the file isn't owner-only, otherwise
|
|
139
|
+
* returns the trimmed file content (values generated by `openssl rand
|
|
140
|
+
* -base64` conventionally end in a newline). `flagName` is only used to
|
|
141
|
+
* personalize the error text (e.g. "--admin-pass-file" vs
|
|
142
|
+
* "--fabric-password-file") — the check itself is identical either way.
|
|
99
143
|
*/
|
|
100
|
-
|
|
144
|
+
function readSecretFileSecure(path, flagName) {
|
|
101
145
|
if (!existsSync(path)) {
|
|
102
|
-
throw new Error(
|
|
146
|
+
throw new Error(`${flagName} path does not exist: ${path}`);
|
|
103
147
|
}
|
|
104
148
|
const st = statSync(path);
|
|
105
149
|
if (st.mode & 0o077) {
|
|
106
150
|
const modeOctal = (st.mode & 0o777).toString(8).padStart(3, "0");
|
|
107
|
-
throw new Error(`Refusing to read
|
|
151
|
+
throw new Error(`Refusing to read ${flagName} at ${path}: permissions ${modeOctal} are too open. ` +
|
|
108
152
|
`Run \`chmod 600 ${path}\` to restrict to owner-only.`);
|
|
109
153
|
}
|
|
110
154
|
const content = readFileSync(path, "utf-8").replace(/\s+$/, "");
|
|
111
155
|
if (!content) {
|
|
112
|
-
throw new Error(
|
|
156
|
+
throw new Error(`${flagName}: file is empty or contains only whitespace: ${path}`);
|
|
113
157
|
}
|
|
114
158
|
return content;
|
|
115
159
|
}
|
|
160
|
+
/** `readSecretFileSecure` specialized for --admin-pass-file (see that function for the shared check). */
|
|
161
|
+
export function readAdminPassFileSecure(path) {
|
|
162
|
+
return readSecretFileSecure(path, "--admin-pass-file");
|
|
163
|
+
}
|
|
164
|
+
function defaultAdminPassPath() {
|
|
165
|
+
return join(homedir(), ".flair", "admin-pass");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve an admin password for LOCAL-only CLI convenience (`agent add`,
|
|
169
|
+
* `principal add`) without requiring `--admin-pass` on every call (#590).
|
|
170
|
+
* Also reused by `api()`'s local-target auth fallback (flair#634) — called
|
|
171
|
+
* there as `resolveLocalAdminPass(undefined, !isLocal)`, so only its file leg
|
|
172
|
+
* ever fires (the env leg is already handled by `api()` itself first).
|
|
173
|
+
*
|
|
174
|
+
* Resolution order: explicit value (the `--admin-pass` flag) → `FLAIR_ADMIN_PASS`
|
|
175
|
+
* env → the secure `~/.flair/admin-pass` file `flair init` already writes with
|
|
176
|
+
* mode 0600 (read via `readAdminPassFileSecure`, which enforces that mode).
|
|
177
|
+
*
|
|
178
|
+
* When `isRemoteTarget` is true, ONLY the explicit value is honored — the env
|
|
179
|
+
* and file legs are skipped entirely. This is the security-critical guard: a
|
|
180
|
+
* `--target`/`--ops-target` deploy must never silently reuse THIS machine's
|
|
181
|
+
* local admin secret against someone else's Harper instance. Remote callers
|
|
182
|
+
* keep requiring an explicit `--admin-pass`.
|
|
183
|
+
*
|
|
184
|
+
* Throws (via readAdminPassFileSecure) if the file exists but has unsafe
|
|
185
|
+
* permissions, so a misconfigured file surfaces as an actionable chmod error
|
|
186
|
+
* instead of a generic "admin pass required" message.
|
|
187
|
+
*/
|
|
188
|
+
function resolveLocalAdminPass(explicit, isRemoteTarget = false, adminPassPath = defaultAdminPassPath()) {
|
|
189
|
+
if (explicit)
|
|
190
|
+
return explicit;
|
|
191
|
+
if (isRemoteTarget)
|
|
192
|
+
return undefined;
|
|
193
|
+
if (process.env.FLAIR_ADMIN_PASS)
|
|
194
|
+
return process.env.FLAIR_ADMIN_PASS;
|
|
195
|
+
if (!existsSync(adminPassPath))
|
|
196
|
+
return undefined;
|
|
197
|
+
return readAdminPassFileSecure(adminPassPath);
|
|
198
|
+
}
|
|
116
199
|
function defaultKeysDir() {
|
|
117
200
|
return join(homedir(), ".flair", "keys");
|
|
118
201
|
}
|
|
@@ -306,24 +389,38 @@ async function api(method, path, body, options) {
|
|
|
306
389
|
const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
307
390
|
const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
|
|
308
391
|
const isLocal = isLocalBase(base);
|
|
309
|
-
// Auth resolution order
|
|
392
|
+
// Auth resolution order (flair#634 — local targets used to send NO auth at
|
|
393
|
+
// all here and ride Harper's authorizeLocal forged super_user; #632 gated
|
|
394
|
+
// FederationInstance/FederationPeers behind allowAdmin, so credential-less
|
|
395
|
+
// local calls to those now 403 instead of silently passing):
|
|
310
396
|
// 1. FLAIR_TOKEN env → Bearer token (backward compat)
|
|
311
|
-
// 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env → Basic admin auth
|
|
312
|
-
//
|
|
397
|
+
// 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env → Basic admin auth. Applies to
|
|
398
|
+
// BOTH local and remote targets — an explicit env var always wins, local
|
|
399
|
+
// included, so a caller that sets it never depends on authorizeLocal.
|
|
313
400
|
// 3. FLAIR_AGENT_ID env + key file → Ed25519 signature (standard)
|
|
314
|
-
// 4.
|
|
401
|
+
// 4. LOCAL TARGETS ONLY: the secure ~/.flair/admin-pass file `flair init`
|
|
402
|
+
// writes (#593) → Basic admin auth, via the same resolveLocalAdminPass
|
|
403
|
+
// convenience `agent add`/`principal add` already use (#590). Guarded to
|
|
404
|
+
// isLocal so a --target/FLAIR_URL request aimed elsewhere never rides
|
|
405
|
+
// this machine's local admin secret.
|
|
406
|
+
// 5. No auth (remote will 401/403; local now also gets a real 403 from
|
|
407
|
+
// #632-gated resources instead of the old forged-admin passthrough)
|
|
315
408
|
//
|
|
316
409
|
// NOTE: this function is for the Harper HTTP/REST API only. The Harper
|
|
317
410
|
// operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
411
|
+
// ALSO honors authorizeLocal: a header-less loopback request to the ops port
|
|
412
|
+
// is auto-authorized as super_user (verified by live probe — flair#610). Those
|
|
413
|
+
// helpers nonetheless send Basic admin auth UNCONDITIONALLY, so they never
|
|
414
|
+
// depend on that ambient elevation and behave identically against a remote or
|
|
415
|
+
// hardened instance. Hardening the ops-API loopback posture itself (bind scope
|
|
416
|
+
// / disabling authorizeLocal there) is tracked separately in flair#654 and is
|
|
417
|
+
// out of scope for this HTTP/REST auth path.
|
|
321
418
|
let authHeader;
|
|
322
419
|
const token = process.env.FLAIR_TOKEN;
|
|
323
420
|
if (token) {
|
|
324
421
|
authHeader = `Bearer ${token}`;
|
|
325
422
|
}
|
|
326
|
-
else if (
|
|
423
|
+
else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
|
|
327
424
|
// Admin Basic auth — used by federation, backup, and other admin CLI commands
|
|
328
425
|
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
329
426
|
authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
@@ -351,6 +448,25 @@ async function api(method, path, body, options) {
|
|
|
351
448
|
}
|
|
352
449
|
}
|
|
353
450
|
}
|
|
451
|
+
// Local-only fallback (flair#634): no explicit env, no usable agent key.
|
|
452
|
+
// FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD are already ruled out by this point
|
|
453
|
+
// (handled above), so resolveLocalAdminPass's env leg is a no-op here and
|
|
454
|
+
// this only ever resolves the ~/.flair/admin-pass file — isRemoteTarget
|
|
455
|
+
// is `!isLocal` so it's skipped entirely for --target/FLAIR_URL requests.
|
|
456
|
+
if (!authHeader) {
|
|
457
|
+
try {
|
|
458
|
+
const filePass = resolveLocalAdminPass(undefined, !isLocal);
|
|
459
|
+
if (filePass) {
|
|
460
|
+
authHeader = `Basic ${Buffer.from(`admin:${filePass}`).toString("base64")}`;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
// File exists but has unsafe permissions — warn (never the secret
|
|
465
|
+
// itself) and fall through to no-auth.
|
|
466
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
467
|
+
console.error(`Warning: ~/.flair/admin-pass unusable: ${message}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
354
470
|
}
|
|
355
471
|
const res = await fetch(`${base}${path}`, {
|
|
356
472
|
method,
|
|
@@ -367,8 +483,19 @@ async function api(method, path, body, options) {
|
|
|
367
483
|
return { ok: true };
|
|
368
484
|
}
|
|
369
485
|
const text = await res.text();
|
|
370
|
-
if (!res.ok)
|
|
486
|
+
if (!res.ok) {
|
|
487
|
+
// 403 with no credentials sent at all is the flair#634 case: a gated
|
|
488
|
+
// resource (e.g. #632's FederationInstance/FederationPeers) rejected a
|
|
489
|
+
// credential-less call. Name the fix instead of surfacing the raw
|
|
490
|
+
// "forbidden" body — never a stack trace.
|
|
491
|
+
if (res.status === 403 && !authHeader) {
|
|
492
|
+
const hint = isLocal
|
|
493
|
+
? "Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass."
|
|
494
|
+
: "Set FLAIR_ADMIN_PASS (remote targets have no local admin-pass fallback).";
|
|
495
|
+
throw new Error(`HTTP 403: no credentials sent. ${hint}`);
|
|
496
|
+
}
|
|
371
497
|
throw new Error(text || `HTTP ${res.status}`);
|
|
498
|
+
}
|
|
372
499
|
if (!text)
|
|
373
500
|
return { ok: true };
|
|
374
501
|
return JSON.parse(text);
|
|
@@ -558,6 +685,90 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
|
558
685
|
}
|
|
559
686
|
}
|
|
560
687
|
}
|
|
688
|
+
// ─── Doctor: client-integration network checks (flair#588) ────────────────────
|
|
689
|
+
//
|
|
690
|
+
// The pure filesystem checks (MCP block parsing, CLAUDE.md, SessionStart hook)
|
|
691
|
+
// live in src/doctor-client.ts. These two are network-dependent and live here
|
|
692
|
+
// because they reuse authFetch/resolveKeyPath, which are private to this file.
|
|
693
|
+
/**
|
|
694
|
+
* Quick, offline-tolerant reachability probe for a Flair instance's HTTP
|
|
695
|
+
* endpoint — GETs /Health with a short timeout. Never hangs, never throws:
|
|
696
|
+
* any failure (timeout, DNS, connection refused, bad URL) is "unreachable".
|
|
697
|
+
* Mirrors the doctor action's own probePort helper (3000ms AbortSignal.timeout
|
|
698
|
+
* style), but takes a full URL since client configs point at arbitrary hosts.
|
|
699
|
+
*/
|
|
700
|
+
export async function probeFlairReachable(url, timeoutMs = 2000) {
|
|
701
|
+
try {
|
|
702
|
+
const res = await fetch(`${url.replace(/\/+$/, "")}/Health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
703
|
+
return res.status > 0;
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Is `agentId` actually registered on the Flair instance at `baseUrl`? Signs
|
|
711
|
+
* GET /Agent/:id with the agent's own key (same pattern as the `flair init`
|
|
712
|
+
* verification at line ~2043 and `flair agent rotate` at line ~2596) —
|
|
713
|
+
* reuses authFetch/resolveKeyPath rather than duplicating the signing logic.
|
|
714
|
+
*
|
|
715
|
+
* 200 -> "registered"
|
|
716
|
+
* 401/403 carrying the server's "unknown_agent" signal -> "not-registered"
|
|
717
|
+
* (see below — this is the actual live behavior for a missing agent, NOT
|
|
718
|
+
* 404)
|
|
719
|
+
* any other status, or a network error/timeout -> "unreachable" (could not
|
|
720
|
+
* verify one way or the other — e.g. a bare 401/403/500 doesn't tell us
|
|
721
|
+
* whether the agent exists, so we don't claim NOT registered on those)
|
|
722
|
+
* no local key found for agentId (checked resolveKeyPath, then keysDir) -> "no-key"
|
|
723
|
+
* (can't sign the request at all — distinct from "unreachable" so the
|
|
724
|
+
* caller can print an accurate reason)
|
|
725
|
+
*
|
|
726
|
+
* Why not 404: an unregistered agent never actually reaches the /Agent/:id
|
|
727
|
+
* resource handler (which is where a 404 would come from) — Flair's own
|
|
728
|
+
* signed-auth middleware (resources/auth-middleware.ts) rejects the request
|
|
729
|
+
* first, once it can't find an Agent record matching the signing identity.
|
|
730
|
+
* On current main that's an explicit `401 {"error":"unknown_agent"}` — Live-
|
|
731
|
+
* verified 2026-07-07 against a local Flair instance with a resolvable-but-
|
|
732
|
+
* unregistered signing key: `401 Unauthorized`, body `{"error":"unknown_agent"}`.
|
|
733
|
+
* Some server versions/paths may instead surface Harper's native
|
|
734
|
+
* AccessViolation as a 403 for the same condition, so both codes are checked
|
|
735
|
+
* — but ONLY when the response also carries the unknown-agent marker; a bare
|
|
736
|
+
* 401/403 without it (e.g. a real AccessViolation for an agent that exists
|
|
737
|
+
* but fails a resource-level authorization check) stays "unreachable", since
|
|
738
|
+
* the server can't always distinguish "agent doesn't exist" from "signing key
|
|
739
|
+
* doesn't match a known agent" and we don't want to falsely claim
|
|
740
|
+
* not-registered on that ambiguity. We only make the not-registered call
|
|
741
|
+
* because we ALREADY have a local signing key that resolved for this
|
|
742
|
+
* agentId (checked above) — so this isn't a client-side key problem, and the
|
|
743
|
+
* server naming the agent unknown is a reliable, actionable signal.
|
|
744
|
+
*/
|
|
745
|
+
export async function checkAgentRegistered(baseUrl, agentId, keysDir) {
|
|
746
|
+
let keyPath = resolveKeyPath(agentId);
|
|
747
|
+
if (!keyPath) {
|
|
748
|
+
const candidate = join(keysDir, `${agentId}.key`);
|
|
749
|
+
if (existsSync(candidate))
|
|
750
|
+
keyPath = candidate;
|
|
751
|
+
}
|
|
752
|
+
if (!keyPath) {
|
|
753
|
+
return { state: "no-key", detail: `no local key for agent '${agentId}' to sign the check` };
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
const res = await authFetch(baseUrl, agentId, keyPath, "GET", `/Agent/${agentId}`);
|
|
757
|
+
if (res.ok)
|
|
758
|
+
return { state: "registered" };
|
|
759
|
+
if (res.status === 404)
|
|
760
|
+
return { state: "not-registered" };
|
|
761
|
+
const text = await res.text().catch(() => "");
|
|
762
|
+
if ((res.status === 401 || res.status === 403) && /unknown_agent/i.test(text)) {
|
|
763
|
+
return { state: "not-registered", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
|
|
764
|
+
}
|
|
765
|
+
return { state: "unreachable", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
|
|
766
|
+
}
|
|
767
|
+
catch (err) {
|
|
768
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
769
|
+
return { state: "unreachable", detail: `instance unreachable: ${message.slice(0, 100)}` };
|
|
770
|
+
}
|
|
771
|
+
}
|
|
561
772
|
// Blocks until the given PID is gone (ESRCH from signal 0), or timeout.
|
|
562
773
|
// Used during restart to confirm the old Harper process actually exited before
|
|
563
774
|
// we start polling /Health — otherwise the still-shutting-down old process can
|
|
@@ -591,10 +802,13 @@ function readHarperPid(dataDir) {
|
|
|
591
802
|
* Seed an agent record via the Harper operations API.
|
|
592
803
|
* Accepts either a port number (localhost) or a full URL string (--target).
|
|
593
804
|
*
|
|
594
|
-
* `adminPass` is
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
805
|
+
* `adminPass` is optional: a local caller may omit it and ride Harper's
|
|
806
|
+
* `authorizeLocal`, which auto-authorizes a header-less loopback request to
|
|
807
|
+
* the ops port as super_user (current behavior, verified by live probe —
|
|
808
|
+
* flair#610). When passed, the helper sends Basic admin auth so it never
|
|
809
|
+
* depends on that ambient elevation and behaves identically against a remote
|
|
810
|
+
* or hardened instance. Hardening the ops-API loopback posture is tracked in
|
|
811
|
+
* flair#654.
|
|
598
812
|
*/
|
|
599
813
|
export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, adminUser, adminPass) {
|
|
600
814
|
const url = typeof opsPortOrUrl === "number"
|
|
@@ -651,10 +865,12 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
|
|
|
651
865
|
// admin:admin-pass), not the REST API (which needs server-side HDB_ADMIN_PASSWORD
|
|
652
866
|
// — unavailable on Fabric). Same pattern as seedAgentViaOpsApi above.
|
|
653
867
|
//
|
|
654
|
-
// `adminPass` is optional
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
// auth
|
|
868
|
+
// `adminPass` is optional (symmetry with seedAgentViaOpsApi): a local caller may
|
|
869
|
+
// omit it and ride authorizeLocal, which the Harper ops API honors today — a
|
|
870
|
+
// header-less loopback request is auto-authorized as super_user (flair#610).
|
|
871
|
+
// When passed, the helper sends Basic admin auth so it never depends on that
|
|
872
|
+
// ambient elevation and behaves identically against a remote or hardened
|
|
873
|
+
// instance. Hardening that posture is tracked in flair#654.
|
|
658
874
|
export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId, publicKey, role, adminUser, adminPass) {
|
|
659
875
|
const url = typeof opsPortOrUrl === "number"
|
|
660
876
|
? `http://127.0.0.1:${opsPortOrUrl}/`
|
|
@@ -689,7 +905,7 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
|
|
|
689
905
|
throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
|
|
690
906
|
}
|
|
691
907
|
}
|
|
692
|
-
// ─── Provision Flair on Harper Fabric
|
|
908
|
+
// ─── Provision Flair on Harper Fabric ──────────────────────────────────────
|
|
693
909
|
//
|
|
694
910
|
// Atomic provisioning for a fresh Harper Fabric cluster: builds a deploy
|
|
695
911
|
// tarball with .env baked in, deploys via ops API, waits for restart, and
|
|
@@ -782,7 +998,7 @@ export async function provisionFabric(target, opsTarget, clusterAdminUser, clust
|
|
|
782
998
|
await waitForFlairRestart(target);
|
|
783
999
|
console.log("Flair is running ✓");
|
|
784
1000
|
// 3. Provision Harper super_user
|
|
785
|
-
// Since
|
|
1001
|
+
// Since custom-admin-username support is merged, the username doesn't have to be "admin".
|
|
786
1002
|
// We can use the cluster-admin user directly if it's already a super_user.
|
|
787
1003
|
// Check if cluster admin is already a super_user first:
|
|
788
1004
|
let clusterAdminIsSuperUser = false;
|
|
@@ -940,9 +1156,6 @@ export async function ensureFlairPairInitiatorRole(opsUrl, adminUser, adminPass)
|
|
|
940
1156
|
// WorkspaceState, OAuthClient — NOT the logical Memory/Event/Workspace/OAuth
|
|
941
1157
|
// shorthand the flair_pair_initiator spec used, which was harmless only
|
|
942
1158
|
// because every grant there is false).
|
|
943
|
-
// 3. Obs* writes: if the presence-emitter writes ObsAgentSnapshot AS the agent
|
|
944
|
-
// it needs insert/update — currently read-only here; confirm the writer's
|
|
945
|
-
// identity (system vs agent) and widen only if it's the agent.
|
|
946
1159
|
// Harper 5.0.21 add_role requires an `attribute_permissions` array on EVERY table
|
|
947
1160
|
// grant (empty = no attribute-level restriction, so the table-level CRUD applies);
|
|
948
1161
|
// omitting it makes add_role reject the whole spec ("Missing 'attribute_permissions'
|
|
@@ -968,14 +1181,20 @@ const FLAIR_AGENT_PERMISSION = {
|
|
|
968
1181
|
Integration: grant(true, true, true, true),
|
|
969
1182
|
Credential: grant(true, true, true, true),
|
|
970
1183
|
Presence: grant(true, true, true, false),
|
|
1184
|
+
// MemoryUsage (flair#683): the usage-feedback dedup ledger. Read (own
|
|
1185
|
+
// contributions, scoped in resources/MemoryUsage.ts) + insert (a fresh
|
|
1186
|
+
// contribution row) only — NO update/delete. This is load-bearing, not
|
|
1187
|
+
// just least-privilege tidiness: the dedup rule ("(agent, memory)
|
|
1188
|
+
// contributes ≤ 1") is enforced by requiring a NEW ledger row before
|
|
1189
|
+
// any usageCount bump; if an agent could delete its own row, it could
|
|
1190
|
+
// re-trigger the /RecordUsage endpoint for the same memory indefinitely
|
|
1191
|
+
// (create → count → delete → count again → repeat), defeating the cap
|
|
1192
|
+
// entirely. See resources/MemoryUsage.ts's module doc.
|
|
1193
|
+
MemoryUsage: grant(true, true, false, false),
|
|
971
1194
|
// Agent: read for discovery, update own card; creation/removal is admin.
|
|
972
1195
|
Agent: grant(true, false, true, false),
|
|
973
1196
|
// Read-only reference data.
|
|
974
1197
|
Instance: grant(true, false, false, false),
|
|
975
|
-
// Observatory read-models — public reads; writes are system-driven (gate 3).
|
|
976
|
-
ObsOffice: grant(true, false, false, false),
|
|
977
|
-
ObsAgentSnapshot: grant(true, false, false, false),
|
|
978
|
-
ObsEventFeed: grant(true, false, false, false),
|
|
979
1198
|
// Federation / OAuth / IdP / internal — system + admin only; agents get none.
|
|
980
1199
|
Peer: grant(false, false, false, false),
|
|
981
1200
|
PairingToken: grant(false, false, false, false),
|
|
@@ -1161,7 +1380,7 @@ export function probeOpenclawPluginVersion(extensionName) {
|
|
|
1161
1380
|
/**
|
|
1162
1381
|
* Whether a package's status line should be printed in the default `flair
|
|
1163
1382
|
* upgrade` listing. Suppresses optional-because-openclaw-is-absent lines
|
|
1164
|
-
*
|
|
1383
|
+
* — pure noise on machines without openclaw — unless `--all`
|
|
1165
1384
|
* (showAll) is set. All other statuses always print.
|
|
1166
1385
|
*/
|
|
1167
1386
|
export function shouldPrintUpgradeLine(status, showAll) {
|
|
@@ -1169,6 +1388,50 @@ export function shouldPrintUpgradeLine(status, showAll) {
|
|
|
1169
1388
|
return false;
|
|
1170
1389
|
return true;
|
|
1171
1390
|
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Pure flag resolution for `flair upgrade`'s restart/verify defaults
|
|
1393
|
+
* (flair#635 decision: restart is now the default; `--no-restart` opts
|
|
1394
|
+
* out). `--restart` is a deprecated no-op accepted for backward compat —
|
|
1395
|
+
* `deprecatedRestartFlagUsed` tells the caller to print a one-time notice
|
|
1396
|
+
* without re-deriving the raw Commander value itself.
|
|
1397
|
+
*
|
|
1398
|
+
* Commander quirk this relies on: registering both `--restart` (plain
|
|
1399
|
+
* boolean) and `--no-restart` (negatable) on the same command means
|
|
1400
|
+
* `opts.restart` is `undefined` when neither flag is passed, `true` when
|
|
1401
|
+
* `--restart` is passed, and `false` when `--no-restart` is passed — so
|
|
1402
|
+
* `!== false` is the correct "should restart" default-true test, and
|
|
1403
|
+
* `=== true` isolates "the user explicitly typed the deprecated flag".
|
|
1404
|
+
*/
|
|
1405
|
+
export function resolveUpgradeRestartVerify(opts) {
|
|
1406
|
+
return {
|
|
1407
|
+
restart: opts.restart !== false,
|
|
1408
|
+
verify: opts.verify !== false,
|
|
1409
|
+
deprecatedRestartFlagUsed: opts.restart === true,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Whether `flair deploy` / `flair upgrade --target` should run the
|
|
1414
|
+
* post-deploy fleet convergence sweep (flair#636). Registering
|
|
1415
|
+
* `--no-fleet-verify` via commander leaves `opts.fleetVerify` undefined when
|
|
1416
|
+
* unset, `false` when the flag is passed — mirrors resolveUpgradeRestartVerify's
|
|
1417
|
+
* `!== false` default-true idiom above.
|
|
1418
|
+
*/
|
|
1419
|
+
export function shouldRunFleetVerify(opts) {
|
|
1420
|
+
return opts.fleetVerify !== false;
|
|
1421
|
+
}
|
|
1422
|
+
export function decideAfterVerify(result, previousVersion) {
|
|
1423
|
+
if (result.ok)
|
|
1424
|
+
return { kind: "ok" };
|
|
1425
|
+
const reason = result.error ?? "post-restart verification failed";
|
|
1426
|
+
if (!previousVersion)
|
|
1427
|
+
return { kind: "cannot-rollback", reason };
|
|
1428
|
+
return { kind: "rollback", reason, toVersion: previousVersion };
|
|
1429
|
+
}
|
|
1430
|
+
export function decideAfterRollbackVerify(result) {
|
|
1431
|
+
if (result.ok)
|
|
1432
|
+
return { kind: "rolled-back" };
|
|
1433
|
+
return { kind: "rollback-failed", reason: result.error ?? "rollback verification failed" };
|
|
1434
|
+
}
|
|
1172
1435
|
/**
|
|
1173
1436
|
* Order a soul key→count map for display: highest count first, ties broken
|
|
1174
1437
|
* alphabetically for stable output. Soul entries are keyed identity facts
|
|
@@ -1350,6 +1613,48 @@ const __pkgVersion = (() => {
|
|
|
1350
1613
|
})();
|
|
1351
1614
|
const program = new Command();
|
|
1352
1615
|
program.name("flair").version(__pkgVersion, "-v, --version");
|
|
1616
|
+
// ─── CLI↔server version handshake (flair#695 §B) ────────────────────────────
|
|
1617
|
+
// Every command invocation gets a cheap, cached (~60s), short-timeout check
|
|
1618
|
+
// of the running server's version against this CLI's own — catches the
|
|
1619
|
+
// bare-npm-upgrade trap where `npm i -g @tpsdev-ai/flair@latest` swaps the
|
|
1620
|
+
// CLI binary but the already-running Harper daemon keeps serving the OLD
|
|
1621
|
+
// code until `flair restart`. `doctor` is excluded here — it already prints
|
|
1622
|
+
// a richer version triple (CLI/installed, running, latest-published) plus
|
|
1623
|
+
// migration state, so a global hook nudge on top of that would be
|
|
1624
|
+
// redundant noise on the one command whose whole job is this exact report.
|
|
1625
|
+
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
1626
|
+
if (actionCommand.name() === "doctor")
|
|
1627
|
+
return;
|
|
1628
|
+
// Interactive-only: this is a pure stderr UX nudge for a human at a
|
|
1629
|
+
// terminal ("bare-npm users must not get stuck"), not a machine-consumed
|
|
1630
|
+
// signal — it never changes exit codes or stdout. Gating on TTY means a
|
|
1631
|
+
// piped/scripted/CI invocation (and every existing test that spawns the
|
|
1632
|
+
// CLI against a mock server) never pays the extra network round trip,
|
|
1633
|
+
// which matters beyond latency: several unit tests spawn this CLI against
|
|
1634
|
+
// a single-shot mock HTTP server asserting on exactly one received
|
|
1635
|
+
// request (e.g. test/unit/presence-set.test.ts) — an unconditional extra
|
|
1636
|
+
// GET /Health here would silently consume that slot and break them.
|
|
1637
|
+
if (!process.stdout.isTTY)
|
|
1638
|
+
return;
|
|
1639
|
+
try {
|
|
1640
|
+
const opts = (actionCommand.opts?.() ?? {});
|
|
1641
|
+
const serverUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
1642
|
+
// Cache key component: prefer the server's own ROOTPATH if this shell
|
|
1643
|
+
// happens to have it set (operating a non-default Harper instance
|
|
1644
|
+
// root), else fall back to Flair's own resolved data directory — same
|
|
1645
|
+
// "which local install is this" identity every other doctor/status
|
|
1646
|
+
// check already keys off, so a stale cache from a since-reinstalled
|
|
1647
|
+
// instance sharing the same port never bleeds into a fresh one.
|
|
1648
|
+
const rootPath = process.env.ROOTPATH ?? defaultDataDir();
|
|
1649
|
+
const result = await checkServerHandshake(__pkgVersion, rootPath, serverUrl);
|
|
1650
|
+
const nudge = formatHandshakeNudge(result);
|
|
1651
|
+
if (nudge)
|
|
1652
|
+
console.error(`⚠️ ${nudge}`);
|
|
1653
|
+
}
|
|
1654
|
+
catch {
|
|
1655
|
+
// NEVER block or fail the underlying command over this check.
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1353
1658
|
// ─── flair init ──────────────────────────────────────────────────────────────
|
|
1354
1659
|
program
|
|
1355
1660
|
.command("init")
|
|
@@ -1367,6 +1672,8 @@ program
|
|
|
1367
1672
|
.option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
|
|
1368
1673
|
.option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
|
|
1369
1674
|
.option("--skip-smoke", "Skip the MCP smoke test")
|
|
1675
|
+
.option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
|
|
1676
|
+
.option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code only)")
|
|
1370
1677
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
1371
1678
|
.option("--remote", "When used with --target, init as hub for remote federation")
|
|
1372
1679
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
@@ -1641,6 +1948,12 @@ program
|
|
|
1641
1948
|
if (major < 18)
|
|
1642
1949
|
throw new Error(`Node.js >= 18 required (found ${process.version})`);
|
|
1643
1950
|
let alreadyRunning = false;
|
|
1951
|
+
// <ROOTPATH>/models — resources/embeddings-provider.ts's resolveModelsDir()
|
|
1952
|
+
// tier 2 default; an operator override already in the environment wins
|
|
1953
|
+
// (tier 1). Scoped above the alreadyRunning branch below (not just inside
|
|
1954
|
+
// the fresh-start path) since the launchd plist step needs it too, even
|
|
1955
|
+
// when Harper was already running and the fresh-spawn branch was skipped.
|
|
1956
|
+
const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
|
|
1644
1957
|
if (!opts.skipStart) {
|
|
1645
1958
|
// Check if already running
|
|
1646
1959
|
try {
|
|
@@ -1663,17 +1976,22 @@ program
|
|
|
1663
1976
|
// database before the env is initialized).
|
|
1664
1977
|
const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
|
|
1665
1978
|
const opsSocket = join(dataDir, "operations-server");
|
|
1979
|
+
// authorizeLocal: false (flair#654) — a credential-less loopback ops-API
|
|
1980
|
+
// request is no longer auto-authorized as super_user. Every ops-API
|
|
1981
|
+
// seed call below (seedAgentViaOpsApi et al.) already passes a real
|
|
1982
|
+
// adminPass via Basic auth, so this does not change local-init behavior.
|
|
1666
1983
|
const harperSetConfig = JSON.stringify({
|
|
1667
1984
|
rootPath: dataDir,
|
|
1668
1985
|
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
1669
1986
|
operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
|
|
1670
1987
|
mqtt: { network: { port: null }, webSocket: false },
|
|
1671
1988
|
localStudio: { enabled: false },
|
|
1672
|
-
authentication: { authorizeLocal:
|
|
1989
|
+
authentication: { authorizeLocal: false, enableSessions: true },
|
|
1673
1990
|
});
|
|
1674
1991
|
const env = {
|
|
1675
1992
|
...process.env,
|
|
1676
1993
|
ROOTPATH: dataDir,
|
|
1994
|
+
FLAIR_MODELS_DIR: modelsDir,
|
|
1677
1995
|
HARPER_SET_CONFIG: harperSetConfig,
|
|
1678
1996
|
DEFAULTS_MODE: "dev",
|
|
1679
1997
|
HDB_ADMIN_USERNAME: adminUser,
|
|
@@ -1684,6 +2002,14 @@ program
|
|
|
1684
2002
|
OPERATIONSAPI_NETWORK_PORT: String(opsPort),
|
|
1685
2003
|
LOCAL_STUDIO: "false",
|
|
1686
2004
|
};
|
|
2005
|
+
// models (flair#504 Phase 1): the embedding backend registers itself
|
|
2006
|
+
// in-process at boot (resources/embeddings-boot.ts, loaded by
|
|
2007
|
+
// config.yaml's `jsResource` glob) — NOT via a config env var. See
|
|
2008
|
+
// that file's header for why (flair#694: HARPER_CONFIG persisted a
|
|
2009
|
+
// `models.embedding.default` block into harper-config.yaml that an
|
|
2010
|
+
// older/downgraded build's boot would tear down to an invalid empty
|
|
2011
|
+
// shell). FLAIR_MODELS_DIR above is still the channel that tells the
|
|
2012
|
+
// registration where to find/download the model.
|
|
1687
2013
|
if (alreadyInstalled) {
|
|
1688
2014
|
console.log("Existing Harper installation found — skipping install.");
|
|
1689
2015
|
console.log("If something is wrong, run: flair doctor");
|
|
@@ -1748,14 +2074,23 @@ program
|
|
|
1748
2074
|
mkdirSync(plistDir, { recursive: true });
|
|
1749
2075
|
const plistPath = join(plistDir, `${label}.plist`);
|
|
1750
2076
|
const opsSocket = join(dataDir, "operations-server");
|
|
2077
|
+
// authorizeLocal: false (flair#654) — same posture as the initial spawn
|
|
2078
|
+
// above; the launchd-managed process must not diverge from it.
|
|
1751
2079
|
const setConfig = JSON.stringify({
|
|
1752
2080
|
rootPath: dataDir,
|
|
1753
2081
|
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
1754
2082
|
operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
|
|
1755
2083
|
mqtt: { network: { port: null }, webSocket: false },
|
|
1756
2084
|
localStudio: { enabled: false },
|
|
1757
|
-
authentication: { authorizeLocal:
|
|
2085
|
+
authentication: { authorizeLocal: false, enableSessions: true },
|
|
1758
2086
|
});
|
|
2087
|
+
// models (flair#504 Phase 1): no env var needed here — the
|
|
2088
|
+
// launchd-managed process loads the SAME dist/resources/*.js as any
|
|
2089
|
+
// other spawn, so resources/embeddings-boot.ts self-registers the
|
|
2090
|
+
// backend on every KeepAlive restart in-process. See that file's
|
|
2091
|
+
// header (flair#694) for why this replaced the old HARPER_CONFIG
|
|
2092
|
+
// plist line.
|
|
2093
|
+
const escapeXml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
1759
2094
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1760
2095
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1761
2096
|
<plist version="1.0">
|
|
@@ -1772,7 +2107,8 @@ program
|
|
|
1772
2107
|
<key>EnvironmentVariables</key>
|
|
1773
2108
|
<dict>
|
|
1774
2109
|
<key>ROOTPATH</key><string>${dataDir}</string>
|
|
1775
|
-
<key>
|
|
2110
|
+
<key>FLAIR_MODELS_DIR</key><string>${modelsDir}</string>
|
|
2111
|
+
<key>HARPER_SET_CONFIG</key><string>${escapeXml(setConfig)}</string>
|
|
1776
2112
|
<key>DEFAULTS_MODE</key><string>dev</string>
|
|
1777
2113
|
<key>HDB_ADMIN_USERNAME</key><string>${adminUser}</string>
|
|
1778
2114
|
<key>HDB_ADMIN_PASSWORD</key><string>${adminPass}</string>
|
|
@@ -1906,8 +2242,6 @@ program
|
|
|
1906
2242
|
console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
|
|
1907
2243
|
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
1908
2244
|
}
|
|
1909
|
-
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
1910
|
-
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
1911
2245
|
// ── MCP client wiring ────────────────────────────────────────────────
|
|
1912
2246
|
// The full one-command front door: detect installed MCP clients and wire
|
|
1913
2247
|
// each to the zero-install `npx -y @tpsdev-ai/flair-mcp` server. Claude
|
|
@@ -1976,6 +2310,30 @@ program
|
|
|
1976
2310
|
console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
|
|
1977
2311
|
wiringResults.push({ client: "claude-code", message: "snippet printed" });
|
|
1978
2312
|
}
|
|
2313
|
+
// ── CLAUDE.md bootstrap line (flair#597) ──────────────────────────
|
|
2314
|
+
// The MCP block alone isn't a working setup — Claude Code also needs
|
|
2315
|
+
// the bootstrap instruction in CLAUDE.md, or it never calls
|
|
2316
|
+
// mcp__flair__bootstrap and memory silently does nothing. Applied
|
|
2317
|
+
// automatically here (same "just do it" shape as the MCP block
|
|
2318
|
+
// above); --skip-claude-md opts out and prints the exact line to
|
|
2319
|
+
// add by hand instead.
|
|
2320
|
+
const claudeMdResult = applyOrReportClaudeMdBootstrap(process.cwd(), homedir(), !!opts.skipClaudeMd);
|
|
2321
|
+
console.log(` ${claudeMdResult.ok ? "✓" : "•"} ${claudeMdResult.message}`);
|
|
2322
|
+
if (claudeMdResult.hint) {
|
|
2323
|
+
for (const line of claudeMdResult.hint.split("\n"))
|
|
2324
|
+
console.log(` ${line}`);
|
|
2325
|
+
}
|
|
2326
|
+
// ── SessionStart hook (flair#597) ─────────────────────────────────
|
|
2327
|
+
// Auto-recall on session start needs this hook wired into
|
|
2328
|
+
// ~/.claude/settings.json — without it, mcp__flair__bootstrap only
|
|
2329
|
+
// ever runs if the agent remembers to call it itself.
|
|
2330
|
+
// --skip-hook opts out and prints the exact JSON to add by hand.
|
|
2331
|
+
const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook);
|
|
2332
|
+
console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
|
|
2333
|
+
if (hookResult.hint) {
|
|
2334
|
+
for (const line of hookResult.hint.split("\n"))
|
|
2335
|
+
console.log(` ${line}`);
|
|
2336
|
+
}
|
|
1979
2337
|
}
|
|
1980
2338
|
else {
|
|
1981
2339
|
let result;
|
|
@@ -2116,7 +2474,6 @@ agent
|
|
|
2116
2474
|
const httpPort = resolveHttpPort(opts);
|
|
2117
2475
|
const opsPort = resolveOpsPort(opts);
|
|
2118
2476
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
2119
|
-
const adminPass = opts.adminPass;
|
|
2120
2477
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
2121
2478
|
const name = opts.name ?? id;
|
|
2122
2479
|
// Where to seed the Agent record. Default is localhost (opsPort). When
|
|
@@ -2124,8 +2481,27 @@ agent
|
|
|
2124
2481
|
// (#514 — agent add could only ever hit localhost ops). Precedence matches
|
|
2125
2482
|
// `flair import`: explicit --ops-target > derive from --target > localhost.
|
|
2126
2483
|
const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
|
|
2484
|
+
const isRemoteTarget = typeof seedOpsTarget === "string";
|
|
2485
|
+
// #590 — local convenience fallback: FLAIR_ADMIN_PASS env, then the secure
|
|
2486
|
+
// ~/.flair/admin-pass file `flair init` already writes (mode 0600). Never
|
|
2487
|
+
// applied for a remote target — see resolveLocalAdminPass.
|
|
2488
|
+
let adminPass;
|
|
2489
|
+
try {
|
|
2490
|
+
adminPass = resolveLocalAdminPass(opts.adminPass, isRemoteTarget);
|
|
2491
|
+
}
|
|
2492
|
+
catch (err) {
|
|
2493
|
+
console.error(`Error: ${err.message}`);
|
|
2494
|
+
process.exit(1);
|
|
2495
|
+
}
|
|
2127
2496
|
if (!adminPass) {
|
|
2128
|
-
|
|
2497
|
+
if (isRemoteTarget) {
|
|
2498
|
+
console.error("Error: --admin-pass is required for agent add when targeting a remote instance " +
|
|
2499
|
+
"(--target/--ops-target) — the local ~/.flair/admin-pass fallback is not used for remote targets.");
|
|
2500
|
+
}
|
|
2501
|
+
else {
|
|
2502
|
+
console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table). " +
|
|
2503
|
+
"Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
|
|
2504
|
+
}
|
|
2129
2505
|
process.exit(1);
|
|
2130
2506
|
}
|
|
2131
2507
|
mkdirSync(keysDir, { recursive: true });
|
|
@@ -2482,6 +2858,108 @@ agent
|
|
|
2482
2858
|
}
|
|
2483
2859
|
console.log(`\n✅ Agent '${id}' removed successfully`);
|
|
2484
2860
|
});
|
|
2861
|
+
// ─── flair mcp ───────────────────────────────────────────────────────────────
|
|
2862
|
+
// Headless agent-auth to a Harper MCP `/mcp` endpoint: RFC 7523
|
|
2863
|
+
// client_credentials + private_key_jwt, using the agent's EXISTING Ed25519
|
|
2864
|
+
// identity key (no new key material, no browser, no human). The plugin side
|
|
2865
|
+
// (HarperFast/oauth, parent issue #159) shipped the full chain in the
|
|
2866
|
+
// published 2.2.0 release: assertion verification (#160/PR #165), CIMD-first
|
|
2867
|
+
// client resolution (#161/#167), and the client_credentials token-endpoint
|
|
2868
|
+
// grant + issuance rate limiting (#170/#171, closing #162/#163). `flair mcp
|
|
2869
|
+
// token` signs the assertion and, by default, requests a real access token
|
|
2870
|
+
// against the token endpoint; see src/mcp-client-assertion.ts.
|
|
2871
|
+
const mcp = program.command("mcp").description("MCP client-credentials agent-auth (RFC 7523 private_key_jwt)");
|
|
2872
|
+
mcp
|
|
2873
|
+
.command("token")
|
|
2874
|
+
.description("Build + sign an RFC 7523 client_assertion and request an MCP client_credentials " +
|
|
2875
|
+
"access token. Caches the minted token (in-process) and reuses it until " +
|
|
2876
|
+
"near-expiry — use --force-refresh to mint unconditionally.")
|
|
2877
|
+
.requiredOption("--agent-id <id>", "Agent id — becomes the client_id (iss/sub claims)")
|
|
2878
|
+
.option("--client-id <url>", "Client ID Metadata Document URL for this agent (defaults to this instance's " +
|
|
2879
|
+
"MCPClientMetadata URL, derived from FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
|
|
2880
|
+
.option("--token-endpoint <url>", "Token-endpoint URL — becomes the `aud` claim (defaults to this instance's " +
|
|
2881
|
+
"own oauth token endpoint, same env vars)")
|
|
2882
|
+
.option("--resource <url>", "RFC 8707 resource indicator for the token request (defaults to this instance's canonical /mcp URI)")
|
|
2883
|
+
.option("--keys-dir <dir>", "Directory to look for <agentId>.key (else FLAIR_KEY_DIR, ~/.flair/keys, ~/.tps/secrets/flair)")
|
|
2884
|
+
.option("--expires-in <seconds>", `Assertion exp - iat window, seconds (default + hard cap: ${MAX_ASSERTION_LIFETIME_SECONDS})`)
|
|
2885
|
+
.option("--dry-run", "Sign the assertion and print what would be sent, but do not call the token endpoint")
|
|
2886
|
+
.option("--force-refresh", "Mint a fresh token even if a cached, not-near-expiry one exists")
|
|
2887
|
+
.option("--json", "Print machine-readable JSON instead of a human summary")
|
|
2888
|
+
.action(async (opts) => {
|
|
2889
|
+
const agentId = opts.agentId;
|
|
2890
|
+
const keyPath = resolveAgentKeyPath(agentId, opts.keysDir);
|
|
2891
|
+
if (!keyPath) {
|
|
2892
|
+
console.error(`Error: no private key found for agent '${agentId}'. Checked --keys-dir, FLAIR_KEY_DIR, ` +
|
|
2893
|
+
`~/.flair/keys, and ~/.tps/secrets/flair.`);
|
|
2894
|
+
process.exit(1);
|
|
2895
|
+
}
|
|
2896
|
+
const clientId = opts.clientId ?? defaultMcpClientId(agentId);
|
|
2897
|
+
if (!clientId) {
|
|
2898
|
+
console.error("Error: --client-id is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive it from " +
|
|
2899
|
+
"this instance's MCPClientMetadata URL).");
|
|
2900
|
+
process.exit(1);
|
|
2901
|
+
}
|
|
2902
|
+
const tokenEndpoint = opts.tokenEndpoint ?? defaultMcpTokenEndpoint();
|
|
2903
|
+
if (!tokenEndpoint) {
|
|
2904
|
+
console.error("Error: --token-endpoint is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive " +
|
|
2905
|
+
"this instance's own oauth token endpoint).");
|
|
2906
|
+
process.exit(1);
|
|
2907
|
+
}
|
|
2908
|
+
const resource = opts.resource ?? defaultMcpResource();
|
|
2909
|
+
const expiresIn = opts.expiresIn ? Number(opts.expiresIn) : undefined;
|
|
2910
|
+
let privateKey;
|
|
2911
|
+
try {
|
|
2912
|
+
privateKey = loadEd25519PrivateKeyFromFile(keyPath);
|
|
2913
|
+
}
|
|
2914
|
+
catch (err) {
|
|
2915
|
+
console.error(`Error: failed to load private key at ${keyPath}: ${err?.message ?? err}`);
|
|
2916
|
+
process.exit(1);
|
|
2917
|
+
}
|
|
2918
|
+
if (opts.dryRun) {
|
|
2919
|
+
const { assertion, claims } = signClientAssertion({
|
|
2920
|
+
clientId,
|
|
2921
|
+
tokenEndpoint,
|
|
2922
|
+
privateKey,
|
|
2923
|
+
expiresInSeconds: expiresIn,
|
|
2924
|
+
});
|
|
2925
|
+
const form = buildTokenRequestForm({ clientId, assertion, resource });
|
|
2926
|
+
if (opts.json) {
|
|
2927
|
+
console.log(JSON.stringify({ assertion, claims, wouldSendForm: form, tokenEndpoint }, null, 2));
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
console.log(`client_assertion (RFC 7523, EdDSA):\n\n${assertion}\n`);
|
|
2931
|
+
console.log(`claims: iss=sub=${claims.iss} aud=${claims.aud} exp-iat=${claims.exp - claims.iat}s jti=${claims.jti}`);
|
|
2932
|
+
console.log(`\n--dry-run: NOT sent. This assertion is the client_assertion value for:\n` +
|
|
2933
|
+
` POST ${tokenEndpoint}\n ${JSON.stringify(form, null, 2).split("\n").join("\n ")}`);
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
try {
|
|
2937
|
+
const token = await getMcpAccessToken({
|
|
2938
|
+
clientId,
|
|
2939
|
+
tokenEndpoint,
|
|
2940
|
+
privateKey,
|
|
2941
|
+
resource,
|
|
2942
|
+
expiresInSeconds: expiresIn,
|
|
2943
|
+
forceRefresh: Boolean(opts.forceRefresh),
|
|
2944
|
+
});
|
|
2945
|
+
if (opts.json) {
|
|
2946
|
+
console.log(JSON.stringify(token, null, 2));
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
console.log(`access_token minted (${token.tokenType}, expires_in=${token.expiresIn}s):\n\n${token.accessToken}`);
|
|
2950
|
+
if (token.scope)
|
|
2951
|
+
console.log(`\nscope: ${token.scope}`);
|
|
2952
|
+
}
|
|
2953
|
+
catch (err) {
|
|
2954
|
+
if (err instanceof McpTokenRequestError) {
|
|
2955
|
+
console.error(`Error: token request failed (HTTP ${err.status}${err.error ? ` ${err.error}` : ""}): ${err.message}`);
|
|
2956
|
+
}
|
|
2957
|
+
else {
|
|
2958
|
+
console.error(`Error: token request failed: ${err?.message ?? err}`);
|
|
2959
|
+
}
|
|
2960
|
+
process.exit(1);
|
|
2961
|
+
}
|
|
2962
|
+
});
|
|
2485
2963
|
// ─── flair principal ─────────────────────────────────────────────────────────
|
|
2486
2964
|
// 1.0 identity management. The Principal model extends Agent — this is the
|
|
2487
2965
|
// preferred CLI surface for managing identities going forward.
|
|
@@ -2501,15 +2979,26 @@ principal
|
|
|
2501
2979
|
.action(async (id, opts) => {
|
|
2502
2980
|
const opsPort = resolveOpsPort(opts);
|
|
2503
2981
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
2504
|
-
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS;
|
|
2505
2982
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
2506
2983
|
const kind = opts.kind ?? "agent";
|
|
2507
2984
|
const name = opts.name ?? id;
|
|
2508
2985
|
const isAdmin = opts.admin ?? false;
|
|
2509
2986
|
const trustTier = opts.trust ?? (isAdmin ? "endorsed" : "unverified");
|
|
2510
2987
|
const runtime = opts.runtime;
|
|
2988
|
+
// #590 — same local-only fallback as `agent add`: FLAIR_ADMIN_PASS env, then
|
|
2989
|
+
// the secure ~/.flair/admin-pass file (mode 0600). `principal add` has no
|
|
2990
|
+
// --target/--ops-target (always localhost), so the fallback always applies.
|
|
2991
|
+
let adminPass;
|
|
2992
|
+
try {
|
|
2993
|
+
adminPass = resolveLocalAdminPass(opts.adminPass);
|
|
2994
|
+
}
|
|
2995
|
+
catch (err) {
|
|
2996
|
+
console.error(`Error: ${err.message}`);
|
|
2997
|
+
process.exit(1);
|
|
2998
|
+
}
|
|
2511
2999
|
if (!adminPass) {
|
|
2512
|
-
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required"
|
|
3000
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required (or ensure ~/.flair/admin-pass exists, " +
|
|
3001
|
+
"created by `flair init`)");
|
|
2513
3002
|
process.exit(1);
|
|
2514
3003
|
}
|
|
2515
3004
|
// Generate Ed25519 keypair (agents always get one; humans get one for instance-attestation)
|
|
@@ -3185,7 +3674,7 @@ federation
|
|
|
3185
3674
|
}
|
|
3186
3675
|
});
|
|
3187
3676
|
// `flair federation reachability` — probe local instance + all paired peers.
|
|
3188
|
-
// Productizes
|
|
3677
|
+
// Productizes flair#695: a single command that tells
|
|
3189
3678
|
// you whether memories CAN flow across the federation right now. Read-only;
|
|
3190
3679
|
// no mutations, no side effects beyond a single tagged status read per peer.
|
|
3191
3680
|
federation
|
|
@@ -3662,15 +4151,54 @@ export async function runFederationSyncOnce(opts) {
|
|
|
3662
4151
|
}
|
|
3663
4152
|
const batch = await res.json();
|
|
3664
4153
|
// For null-updatedAt rows, use createdAt as the effective timestamp.
|
|
3665
|
-
// Skip rows created before the last sync cursor.
|
|
3666
|
-
|
|
4154
|
+
// Skip rows created before the last sync cursor. Only Memory carries
|
|
4155
|
+
// a `visibility` field (Soul/Agent/Relationship don't — see
|
|
4156
|
+
// schemas/memory.graphql vs agent.graphql), so the private-exclusion
|
|
4157
|
+
// filter only applies there; on the other 3 tables `row.visibility`
|
|
4158
|
+
// is always undefined, which isFederationPrivateVisibility() treats
|
|
4159
|
+
// as non-private (included) — a no-op for them.
|
|
4160
|
+
rows = rows.concat(batch
|
|
4161
|
+
.filter((r) => r.updatedAt !== null || r.createdAt > since)
|
|
4162
|
+
.filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility)));
|
|
3667
4163
|
}
|
|
3668
4164
|
if (rows.length === 0)
|
|
3669
4165
|
continue;
|
|
4166
|
+
// Records are signed (below) before they're batched, so the secret key
|
|
4167
|
+
// is needed here rather than only inside sendBatch. Still deferred
|
|
4168
|
+
// until we know THIS table has rows to send — preserves the "don't
|
|
4169
|
+
// load the key on a no-op run" property the original lazy load had.
|
|
4170
|
+
if (!secretKey)
|
|
4171
|
+
secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
3670
4172
|
let batch = [];
|
|
3671
4173
|
let batchBytes = 0;
|
|
3672
4174
|
for (const row of rows) {
|
|
3673
|
-
const
|
|
4175
|
+
const updatedAt = row.updatedAt ?? row.createdAt;
|
|
4176
|
+
const originatorInstanceId = instance.id;
|
|
4177
|
+
// Per-record signature (federation-edge-hardening slice 3a): signed by
|
|
4178
|
+
// THIS instance — the originator — over a versioned canonical form, so
|
|
4179
|
+
// a receiver (including a hub relaying this record onward to other
|
|
4180
|
+
// spokes) can verify authorship independent of who forwarded the
|
|
4181
|
+
// batch. Closes the hub-relay forgery hole — see
|
|
4182
|
+
// resources/Federation.ts FederationSync.post's verification gate.
|
|
4183
|
+
//
|
|
4184
|
+
// CONTRACT — must match Federation.ts's verification payload
|
|
4185
|
+
// byte-for-byte: keys { v, table, id, data, updatedAt,
|
|
4186
|
+
// originatorInstanceId }. canonicalize() sorts keys, so field ORDER
|
|
4187
|
+
// doesn't matter, but the field SET and values do. `v: 1` versions the
|
|
4188
|
+
// canonical form itself: bump it on BOTH sides together if the signed
|
|
4189
|
+
// field set ever changes, so an old signature fails closed instead of
|
|
4190
|
+
// silently mis-verifying under a new form.
|
|
4191
|
+
//
|
|
4192
|
+
// Additive/backward-compatible: pre-3a receivers don't read
|
|
4193
|
+
// `signature`/`principalId` at all and merge exactly as before.
|
|
4194
|
+
const signature = signBody({ v: 1, table, id: row.id, data: row, updatedAt, originatorInstanceId }, secretKey);
|
|
4195
|
+
const sr = { table, id: row.id, data: row, updatedAt, originatorInstanceId, signature };
|
|
4196
|
+
// Informational only (see principalIdFromRow) — never verified by the
|
|
4197
|
+
// receiver as proof of authorship. Omitted entirely when the row
|
|
4198
|
+
// carries no write-time provenance stamp.
|
|
4199
|
+
const principalId = principalIdFromRow(row);
|
|
4200
|
+
if (principalId)
|
|
4201
|
+
sr.principalId = principalId;
|
|
3674
4202
|
const srBytes = JSON.stringify(sr).length;
|
|
3675
4203
|
if (batch.length >= BUDGET_RECORDS || (batch.length > 0 && batchBytes + srBytes > BUDGET_BYTES)) {
|
|
3676
4204
|
const result = await sendBatch(batch);
|
|
@@ -3721,7 +4249,7 @@ export async function runFederationSyncOnce(opts) {
|
|
|
3721
4249
|
console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
|
|
3722
4250
|
}
|
|
3723
4251
|
if (totalBatches === 0) {
|
|
3724
|
-
//
|
|
4252
|
+
// No-change syncs must still ping the hub so it updates the
|
|
3725
4253
|
// spoke's lastSyncAt (liveness). Without this, idle-but-alive spokes
|
|
3726
4254
|
// look indistinguishable from dead ones on the hub dashboard.
|
|
3727
4255
|
try {
|
|
@@ -3818,7 +4346,7 @@ federation
|
|
|
3818
4346
|
await runFederationWatch(opts);
|
|
3819
4347
|
});
|
|
3820
4348
|
// `flair federation prune` — remove stale spoke peers (never the hub).
|
|
3821
|
-
// Productizes
|
|
4349
|
+
// Productizes flair#695 into a real CLI
|
|
3822
4350
|
// subcommand with safety: dry-run is the default, --apply required to delete.
|
|
3823
4351
|
function parseDuration(spec) {
|
|
3824
4352
|
// Accept forms like "30d", "12h", "90m". Returns milliseconds.
|
|
@@ -3928,7 +4456,7 @@ federation
|
|
|
3928
4456
|
});
|
|
3929
4457
|
// `flair federation verify` — end-to-end roundtrip: write a tagged memory
|
|
3930
4458
|
// locally, wait for federation push, probe peers for the tag. Productizes
|
|
3931
|
-
//
|
|
4459
|
+
// flair#695 Cleans up the test memory at the end.
|
|
3932
4460
|
federation
|
|
3933
4461
|
.command("verify")
|
|
3934
4462
|
.description("End-to-end check: write a tagged memory locally and verify it shows up on each peer")
|
|
@@ -4162,13 +4690,61 @@ rem
|
|
|
4162
4690
|
process.exit(1);
|
|
4163
4691
|
}
|
|
4164
4692
|
});
|
|
4693
|
+
// ─── flair rem rapid — pure helpers ──────────────────────────────────────────
|
|
4694
|
+
// Extracted for testability, same pattern as validatePromoteOpts /
|
|
4695
|
+
// decideCandidateAction above: the action callback below spawns api() +
|
|
4696
|
+
// process.exit, which makes it high-effort/low-value to drive directly;
|
|
4697
|
+
// these two functions are the actual decision logic.
|
|
4698
|
+
/** One staged-candidate summary line: `[id] claim, truncated to ~80 chars`. */
|
|
4699
|
+
export function formatCandidateLine(candidate, maxClaimLen = 80) {
|
|
4700
|
+
const claim = candidate.claim ?? "";
|
|
4701
|
+
const truncated = claim.length > maxClaimLen ? `${claim.slice(0, maxClaimLen)}…` : claim;
|
|
4702
|
+
return ` [${candidate.id ?? "?"}] ${truncated}`;
|
|
4703
|
+
}
|
|
4704
|
+
/**
|
|
4705
|
+
* Classifies a thrown /ReflectMemories execute-mode error for CLI display.
|
|
4706
|
+
* `api()` throws `Error(responseBodyText)` for non-2xx responses (see api()
|
|
4707
|
+
* above) — the two execute-mode failure bodies are:
|
|
4708
|
+
* 503 no-backend: { error: "No generative backend configured..." }
|
|
4709
|
+
* 502 distillation_failed: { error: "distillation_failed", detail: "..." }
|
|
4710
|
+
* Any other shape (network errors, the 400/403 actor-resolution errors
|
|
4711
|
+
* prompt mode shares) falls back to "other" — printed as a plain message,
|
|
4712
|
+
* no docs pointer or retry hint attached since neither applies.
|
|
4713
|
+
*/
|
|
4714
|
+
export function describeReflectError(message) {
|
|
4715
|
+
try {
|
|
4716
|
+
const parsed = JSON.parse(message);
|
|
4717
|
+
if (parsed && typeof parsed === "object") {
|
|
4718
|
+
if (parsed.error === "distillation_failed") {
|
|
4719
|
+
return { kind: "distillation-failed", text: String(parsed.detail ?? parsed.error) };
|
|
4720
|
+
}
|
|
4721
|
+
if (typeof parsed.error === "string" && parsed.error.startsWith("No generative backend configured")) {
|
|
4722
|
+
return { kind: "no-backend", text: parsed.error };
|
|
4723
|
+
}
|
|
4724
|
+
if (typeof parsed.error === "string") {
|
|
4725
|
+
return { kind: "other", text: parsed.error };
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
}
|
|
4729
|
+
catch {
|
|
4730
|
+
// Not a JSON error body — network error, etc. Pass the raw message through.
|
|
4731
|
+
}
|
|
4732
|
+
return { kind: "other", text: message };
|
|
4733
|
+
}
|
|
4734
|
+
// ─── flair rem rapid ──────────────────────────────────────────────────────────
|
|
4735
|
+
// Executes by default (specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md § 3C,
|
|
4736
|
+
// issue #707): distills server-side via /ReflectMemories execute:true and
|
|
4737
|
+
// stages MemoryCandidate rows, printing a staged-candidate summary. --prompt-only
|
|
4738
|
+
// preserves the pre-#710 handoff behavior byte-for-byte, for the bring-your-
|
|
4739
|
+
// own-model workflow.
|
|
4165
4740
|
rem
|
|
4166
4741
|
.command("rapid")
|
|
4167
|
-
.description("REM — reflection/learning:
|
|
4742
|
+
.description("REM — reflection/learning: distill recent memories into staged candidates")
|
|
4168
4743
|
.option("--port <port>", "Harper HTTP port")
|
|
4169
4744
|
.option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
|
|
4170
4745
|
.option("--focus <type>", "lessons_learned | patterns | decisions | errors", "lessons_learned")
|
|
4171
4746
|
.option("--since <date>", "ISO timestamp lower bound (default: 24h ago)")
|
|
4747
|
+
.option("--prompt-only", "Return the reflection prompt instead of executing (pre-#710 handoff behavior)")
|
|
4172
4748
|
.action(async (opts) => {
|
|
4173
4749
|
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
4174
4750
|
if (!agentId) {
|
|
@@ -4177,30 +4753,62 @@ rem
|
|
|
4177
4753
|
}
|
|
4178
4754
|
console.log(`\n-- rem rapid --`);
|
|
4179
4755
|
console.log(`Agent: ${agentId} Focus: ${opts.focus}`);
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4756
|
+
const body = {
|
|
4757
|
+
agentId,
|
|
4758
|
+
focus: opts.focus,
|
|
4759
|
+
};
|
|
4760
|
+
if (opts.since)
|
|
4761
|
+
body.since = opts.since;
|
|
4762
|
+
if (opts.promptOnly) {
|
|
4763
|
+
// --prompt-only: EXACT pre-#710 behavior — prompt-return mode, unchanged.
|
|
4764
|
+
try {
|
|
4765
|
+
const result = await api("POST", "/ReflectMemories", body);
|
|
4766
|
+
if (result.error) {
|
|
4767
|
+
console.error(`Reflection error: ${result.error}`);
|
|
4768
|
+
process.exit(1);
|
|
4769
|
+
}
|
|
4770
|
+
console.log(`\nSource memories: ${result.count ?? 0}`);
|
|
4771
|
+
if (result.suggestedTags?.length) {
|
|
4772
|
+
console.log(`Tags: ${result.suggestedTags.join(", ")}`);
|
|
4773
|
+
}
|
|
4774
|
+
console.log("\n--- Reflection Prompt ---");
|
|
4775
|
+
console.log(result.prompt ?? "(no prompt returned)");
|
|
4776
|
+
console.log("--- End Prompt ---\n");
|
|
4777
|
+
console.log("Feed the prompt above to your LLM, then write insights back with:");
|
|
4778
|
+
console.log(" flair memory add --agent <id> --content <insight> --durability persistent --derived-from <source-ids>");
|
|
4779
|
+
}
|
|
4780
|
+
catch (err) {
|
|
4781
|
+
console.error(`Error: ${err.message}`);
|
|
4190
4782
|
process.exit(1);
|
|
4191
4783
|
}
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4786
|
+
// Default: execute mode — distill server-side, stage candidates.
|
|
4787
|
+
try {
|
|
4788
|
+
const result = await api("POST", "/ReflectMemories", { ...body, execute: true });
|
|
4789
|
+
const candidates = Array.isArray(result.candidates) ? result.candidates : [];
|
|
4790
|
+
console.log(`\nModel: ${result.model ?? "?"}`);
|
|
4791
|
+
console.log(`Candidates: ${result.count ?? candidates.length}`);
|
|
4792
|
+
if (candidates.length > 0) {
|
|
4793
|
+
console.log();
|
|
4794
|
+
for (const c of candidates)
|
|
4795
|
+
console.log(formatCandidateLine(c));
|
|
4195
4796
|
}
|
|
4196
|
-
console.log(
|
|
4197
|
-
console.log(result.prompt ?? "(no prompt returned)");
|
|
4198
|
-
console.log("--- End Prompt ---\n");
|
|
4199
|
-
console.log("Feed the prompt above to your LLM, then write insights back with:");
|
|
4200
|
-
console.log(" flair memory add --agent <id> --content <insight> --durability persistent --derived-from <source-ids>");
|
|
4797
|
+
console.log(`\nreview: flair rem candidates / flair rem promote <id>`);
|
|
4201
4798
|
}
|
|
4202
4799
|
catch (err) {
|
|
4203
|
-
|
|
4800
|
+
const desc = describeReflectError(err.message ?? String(err));
|
|
4801
|
+
if (desc.kind === "no-backend") {
|
|
4802
|
+
console.error(`Reflection error: ${desc.text}`);
|
|
4803
|
+
console.error(`See docs/rem.md#configuration for how to point Flair at a models: backend.`);
|
|
4804
|
+
}
|
|
4805
|
+
else if (desc.kind === "distillation-failed") {
|
|
4806
|
+
console.error(`Reflection error: distillation failed — ${desc.text}`);
|
|
4807
|
+
console.error(`Retry, or run with --prompt-only for the manual handoff.`);
|
|
4808
|
+
}
|
|
4809
|
+
else {
|
|
4810
|
+
console.error(`Error: ${desc.text}`);
|
|
4811
|
+
}
|
|
4204
4812
|
process.exit(1);
|
|
4205
4813
|
}
|
|
4206
4814
|
});
|
|
@@ -4699,6 +5307,11 @@ remNightly
|
|
|
4699
5307
|
console.log(`Archived: ${row.archived ?? "—"}`);
|
|
4700
5308
|
console.log(`Expired: ${row.expired ?? "—"}`);
|
|
4701
5309
|
}
|
|
5310
|
+
// row.candidates populates when step 5 (distillation) was attempted
|
|
5311
|
+
// this cycle — see src/rem/runner.ts. Absent when dry-run skipped it.
|
|
5312
|
+
if (row.candidates) {
|
|
5313
|
+
console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
|
|
5314
|
+
}
|
|
4702
5315
|
console.log(`Duration: ${row.durationMs}ms`);
|
|
4703
5316
|
if (row.errors.length > 0) {
|
|
4704
5317
|
console.log(`Errors:`);
|
|
@@ -4964,7 +5577,7 @@ function oauthDetailLines(o) {
|
|
|
4964
5577
|
}
|
|
4965
5578
|
// Common localhost ports a running Flair daemon might be on. Used by
|
|
4966
5579
|
// discoverLocalFlairPort when the configured URL is unreachable, to detect
|
|
4967
|
-
// config-vs-daemon port drift
|
|
5580
|
+
// config-vs-daemon port drift. Order is ad-hoc — first hit wins.
|
|
4968
5581
|
//
|
|
4969
5582
|
// 9926: original default (long-running early installs predate the bump)
|
|
4970
5583
|
// 19926: current default (DEFAULT_PORT)
|
|
@@ -5081,16 +5694,23 @@ const statusCmd = program
|
|
|
5081
5694
|
.action(async (opts) => {
|
|
5082
5695
|
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
|
|
5083
5696
|
// When unreachable on a localhost URL, probe candidate ports to detect
|
|
5084
|
-
// config-vs-daemon port drift
|
|
5697
|
+
// config-vs-daemon port drift. Surface the actually-listening
|
|
5085
5698
|
// port with a fix recipe — better UX than just "unreachable."
|
|
5086
5699
|
let discoveredPort = null;
|
|
5087
5700
|
if (!healthy && isLocalhostUrl(baseUrl)) {
|
|
5088
5701
|
discoveredPort = await discoverLocalFlairPort(baseUrl);
|
|
5089
5702
|
}
|
|
5703
|
+
// Version-behind check (flair#587) — offline-tolerant + cached, so this
|
|
5704
|
+
// never adds meaningful latency or fails `status` when the registry is
|
|
5705
|
+
// unreachable. Independent of Harper health; runs either way.
|
|
5706
|
+
const versionCheckResult = await checkVersion(__pkgVersion);
|
|
5707
|
+
const versionNudge = formatVersionNudge(versionCheckResult);
|
|
5090
5708
|
if (opts.json) {
|
|
5091
5709
|
const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
|
|
5092
5710
|
if (discoveredPort != null)
|
|
5093
5711
|
out.discoveredPort = discoveredPort;
|
|
5712
|
+
if (versionCheckResult.latest)
|
|
5713
|
+
out.latestVersion = versionCheckResult.latest;
|
|
5094
5714
|
console.log(JSON.stringify(out, null, 2));
|
|
5095
5715
|
if (!healthy)
|
|
5096
5716
|
process.exit(1);
|
|
@@ -5110,6 +5730,10 @@ const statusCmd = program
|
|
|
5110
5730
|
else {
|
|
5111
5731
|
console.log(`\n Run: flair start or flair doctor`);
|
|
5112
5732
|
}
|
|
5733
|
+
if (versionNudge) {
|
|
5734
|
+
const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
|
|
5735
|
+
console.log(`\n ${render.wrap(color, "⚠")} ${render.wrap(color, versionNudge.message)}`);
|
|
5736
|
+
}
|
|
5113
5737
|
process.exit(1);
|
|
5114
5738
|
}
|
|
5115
5739
|
const uptimeSec = healthData?.uptimeSeconds;
|
|
@@ -5162,6 +5786,10 @@ const statusCmd = program
|
|
|
5162
5786
|
const metaParts = [pidPart, uptimePart].filter(Boolean).join(render.wrap(render.c.dim, " · "));
|
|
5163
5787
|
console.log(`${versionStr} ${render.wrap(render.c.dim, "—")} ${runStatus}${metaParts ? ` ${metaParts}` : ""}`);
|
|
5164
5788
|
console.log(render.kv("URL", baseUrl));
|
|
5789
|
+
if (versionNudge) {
|
|
5790
|
+
const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
|
|
5791
|
+
console.log(`\n ${render.wrap(color, "⚠")} ${render.wrap(color, versionNudge.message)}`);
|
|
5792
|
+
}
|
|
5165
5793
|
if (scopedWarnings.length > 0) {
|
|
5166
5794
|
console.log(`\n${render.wrap(render.c.bold, "Warnings")} ${render.wrap(render.c.dim, `(${scopedWarnings.length})`)}`);
|
|
5167
5795
|
for (const w of scopedWarnings) {
|
|
@@ -5771,32 +6399,68 @@ statusCmd
|
|
|
5771
6399
|
console.log("\n✅ no warnings");
|
|
5772
6400
|
}
|
|
5773
6401
|
});
|
|
6402
|
+
export function resolveFabricCredentials(opts) {
|
|
6403
|
+
const warnings = [];
|
|
6404
|
+
if (opts.fabricUser && !process.env.FABRIC_USER) {
|
|
6405
|
+
warnings.push("warning: --fabric-user passed inline. Consider FABRIC_USER env — " +
|
|
6406
|
+
"a login name in shell history/ps is recon.");
|
|
6407
|
+
}
|
|
6408
|
+
const fabricUser = opts.fabricUser ?? process.env.FABRIC_USER;
|
|
6409
|
+
let fabricPassword;
|
|
6410
|
+
if (opts.fabricPassword) {
|
|
6411
|
+
if (opts.fabricPasswordFile) {
|
|
6412
|
+
warnings.push("warning: --fabric-password (inline) takes precedence over --fabric-password-file " +
|
|
6413
|
+
"when both are given. Pass --fabric-password-file alone to keep the secret out of shell history.");
|
|
6414
|
+
}
|
|
6415
|
+
if (!process.env.FABRIC_PASSWORD) {
|
|
6416
|
+
warnings.push("warning: --fabric-password leaks to shell history. Prefer FABRIC_PASSWORD env or --fabric-password-file.");
|
|
6417
|
+
}
|
|
6418
|
+
fabricPassword = opts.fabricPassword;
|
|
6419
|
+
}
|
|
6420
|
+
else if (opts.fabricPasswordFile) {
|
|
6421
|
+
fabricPassword = readSecretFileSecure(opts.fabricPasswordFile, "--fabric-password-file");
|
|
6422
|
+
}
|
|
6423
|
+
else {
|
|
6424
|
+
fabricPassword = process.env.FABRIC_PASSWORD;
|
|
6425
|
+
}
|
|
6426
|
+
return { fabricUser, fabricPassword, warnings };
|
|
6427
|
+
}
|
|
5774
6428
|
// ─── flair upgrade --target <fabric> ────────────────────────────────────────
|
|
5775
6429
|
//
|
|
5776
6430
|
// One-command upgrade of a Flair instance DEPLOYED to a Harper Fabric cluster.
|
|
5777
6431
|
// Mirrors `flair deploy`'s credential handling (FABRIC_USER/FABRIC_PASSWORD env
|
|
5778
|
-
// fallbacks, password-via-flag warning
|
|
6432
|
+
// fallbacks, password-via-flag warning, --fabric-password-file — see
|
|
6433
|
+
// resolveFabricCredentials above) and NEVER prints credentials. The
|
|
5779
6434
|
// version-resolution + @harperfast/harper pin + reuse of deploy() lives in
|
|
5780
6435
|
// src/fabric-upgrade.ts; this wrapper only does CLI plumbing + the confirm.
|
|
5781
6436
|
async function runFabricUpgrade(opts) {
|
|
5782
6437
|
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
5783
6438
|
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
5784
6439
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
5785
|
-
|
|
5786
|
-
|
|
6440
|
+
let fabricUser;
|
|
6441
|
+
let fabricPassword;
|
|
6442
|
+
let credWarnings = [];
|
|
6443
|
+
try {
|
|
6444
|
+
({ fabricUser, fabricPassword, warnings: credWarnings } = resolveFabricCredentials(opts));
|
|
6445
|
+
}
|
|
6446
|
+
catch (err) {
|
|
6447
|
+
console.error(red(`Error: ${err.message}`));
|
|
6448
|
+
process.exit(1);
|
|
6449
|
+
}
|
|
5787
6450
|
const check = opts.check ?? false;
|
|
5788
6451
|
// Creds are not required for --check (read-only registry + best-effort GET),
|
|
5789
6452
|
// but ARE required to actually deploy.
|
|
5790
6453
|
if (!check && !(fabricUser && fabricPassword)) {
|
|
5791
6454
|
console.error(red("flair upgrade --target: credentials required to deploy"));
|
|
5792
|
-
console.error(" pass --fabric-user + --fabric-password
|
|
6455
|
+
console.error(" set FABRIC_USER + FABRIC_PASSWORD env (safest), or pass --fabric-user + --fabric-password-file <path>");
|
|
6456
|
+
console.error(" inline --fabric-user/--fabric-password also work but leak to shell history — avoid on shared/multi-user hosts");
|
|
5793
6457
|
console.error(" or use --check to preview the plan without credentials");
|
|
5794
6458
|
process.exit(1);
|
|
5795
6459
|
}
|
|
5796
|
-
//
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
6460
|
+
// Never log the credential VALUES — only the flag names, via the
|
|
6461
|
+
// resolver's own warning strings.
|
|
6462
|
+
for (const w of credWarnings)
|
|
6463
|
+
console.error(dim(w));
|
|
5800
6464
|
const upgradeOpts = {
|
|
5801
6465
|
target: opts.target,
|
|
5802
6466
|
project: opts.project,
|
|
@@ -5834,6 +6498,29 @@ async function runFabricUpgrade(opts) {
|
|
|
5834
6498
|
return;
|
|
5835
6499
|
}
|
|
5836
6500
|
console.log(`\n${green("✓")} Fabric upgrade complete.`);
|
|
6501
|
+
// ── Post-upgrade fleet sweep (flair#636) ────────────────────────────────
|
|
6502
|
+
// "deploy complete" from harper's own CLI means "origin took it" — this
|
|
6503
|
+
// confirms every known federation peer actually converged on the version
|
|
6504
|
+
// we just deployed, instead of trusting a single boolean. Skippable with
|
|
6505
|
+
// --no-fleet-verify. fabricUser/fabricPassword are guaranteed set here —
|
|
6506
|
+
// the !check branch above already required both.
|
|
6507
|
+
if (!shouldRunFleetVerify(opts)) {
|
|
6508
|
+
console.log(dim("(--no-fleet-verify: skipping post-upgrade fleet sweep)"));
|
|
6509
|
+
}
|
|
6510
|
+
else {
|
|
6511
|
+
console.log(`\n${green("→")} Fleet verify`);
|
|
6512
|
+
const sweep = await sweepFleet({
|
|
6513
|
+
target: upgradeOpts.target,
|
|
6514
|
+
fabricUser: fabricUser,
|
|
6515
|
+
fabricPassword: fabricPassword,
|
|
6516
|
+
expectVersion: result.plan.targetVersion,
|
|
6517
|
+
});
|
|
6518
|
+
console.log(renderFleetSweepTable(sweep));
|
|
6519
|
+
if (sweep.exitCode !== FLEET_EXIT_OK) {
|
|
6520
|
+
console.error(red(`\n✗ fleet verify failed (exit ${sweep.exitCode}) — upgrade is NOT fully converged.`));
|
|
6521
|
+
process.exit(sweep.exitCode);
|
|
6522
|
+
}
|
|
6523
|
+
}
|
|
5837
6524
|
}
|
|
5838
6525
|
catch (err) {
|
|
5839
6526
|
console.error(red(`\n✗ fabric upgrade failed: ${err.message}`));
|
|
@@ -5844,39 +6531,385 @@ async function runFabricUpgrade(opts) {
|
|
|
5844
6531
|
process.exit(1);
|
|
5845
6532
|
}
|
|
5846
6533
|
}
|
|
5847
|
-
// ─── flair
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
6534
|
+
// ─── Pre-upgrade data snapshot (flair#637) ─────────────────────────────────
|
|
6535
|
+
// `flair upgrade` used to swap @tpsdev-ai/flair's own package with no backup
|
|
6536
|
+
// of ~/.flair/data — if an upgrade broke something past the package level
|
|
6537
|
+
// (schema/data, not just code), there was no tested way back. This is cheap
|
|
6538
|
+
// insurance: a timestamped tar.gz of the whole data directory taken right
|
|
6539
|
+
// before the package swap, with a keep-last-3 retention policy.
|
|
6540
|
+
//
|
|
6541
|
+
// Native-backup alternative considered and rejected: Harper ships a
|
|
6542
|
+
// `get_backup` operation (@harperfast/harper's dataLayer/getBackup.ts,
|
|
6543
|
+
// wired in server/serverHelpers/serverUtilities.ts, documented in
|
|
6544
|
+
// components/mcp/tools/schemas/operationDescriptions.ts) that streams a
|
|
6545
|
+
// live backup over the running HTTP operations API. It's available in this
|
|
6546
|
+
// OSS tier (no license/tier gate found in operation_authorization.ts — just
|
|
6547
|
+
// `requires_su`), but it backs up ONE database/table at a time
|
|
6548
|
+
// (GetBackupObject requires `schema`/`table`, or defaults to a single "data"
|
|
6549
|
+
// database) — not the whole `~/.flair/data` tree: no config, no
|
|
6550
|
+
// users/roles, no keys, no other schemas. Using it here would mean
|
|
6551
|
+
// enumerating every schema/table and making N authenticated HTTP calls
|
|
6552
|
+
// against a server this same command is about to take down — for a LESS
|
|
6553
|
+
// complete result than a plain recursive file copy, and one that can't run
|
|
6554
|
+
// at all once the server is stopped (it's an operations-API call, not a
|
|
6555
|
+
// standalone filesystem utility). Rejected in favor of the file-level
|
|
6556
|
+
// snapshot below. See docs/upgrade.md for the restore procedure this
|
|
6557
|
+
// produces.
|
|
6558
|
+
const UPGRADE_SNAPSHOT_ROOT = resolve(homedir(), ".flair", "upgrade-snapshots");
|
|
6559
|
+
const UPGRADE_SNAPSHOT_RETAIN = 3;
|
|
6560
|
+
function upgradeSnapshotFileName() {
|
|
6561
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
6562
|
+
return `flair-data-${ts}.tar.gz`;
|
|
6563
|
+
}
|
|
6564
|
+
/**
|
|
6565
|
+
* Snapshot `dataDir` (normally ~/.flair/data) into a timestamped tar.gz
|
|
6566
|
+
* under ~/.flair/upgrade-snapshots/.
|
|
6567
|
+
*
|
|
6568
|
+
* Consistency: the caller is expected to have stopped Flair first (a
|
|
6569
|
+
* running Harper's data dir can be mid-write, and a plain file copy of a
|
|
6570
|
+
* live database directory isn't guaranteed point-in-time consistent —
|
|
6571
|
+
* Harper 5.x's engine is RocksDB, verified from the .sst/WAL/MANIFEST
|
|
6572
|
+
* layout under database/*, and a torn WAL/SST set won't open) — this
|
|
6573
|
+
* function itself doesn't stop anything, it just archives whatever is on
|
|
6574
|
+
* disk right now.
|
|
6575
|
+
*
|
|
6576
|
+
* Preserves file modes exactly — deliberately NOT using tar's `portable`
|
|
6577
|
+
* option (used elsewhere in this file for the deploy tarball and session
|
|
6578
|
+
* snapshots), which flattens every entry's mode to a umask-based "reasonable
|
|
6579
|
+
* default" and would turn 0600 key/admin-pass files into whatever that
|
|
6580
|
+
* default is. Never follows symlinks out of `dataDir`: node-tar already
|
|
6581
|
+
* archives symlinks as symlinks by default (no `follow` option set here),
|
|
6582
|
+
* and the filter below additionally skips any symlink whose resolved target
|
|
6583
|
+
* falls outside `dataDir`, plus any non-regular file (sockets, FIFOs, device
|
|
6584
|
+
* nodes — e.g. a stale `operations-server` domain socket left behind by a
|
|
6585
|
+
* prior run) that tar can't meaningfully archive anyway.
|
|
6586
|
+
*
|
|
6587
|
+
* Throws on any failure — `flair upgrade` treats a snapshot failure as
|
|
6588
|
+
* abort-the-upgrade by default (safe default; --no-snapshot is the opt-out
|
|
6589
|
+
* for hosts that can't spare the time/disk).
|
|
6590
|
+
*
|
|
6591
|
+
* `snapshotRoot` defaults to UPGRADE_SNAPSHOT_ROOT (~/.flair/upgrade-snapshots)
|
|
6592
|
+
* but is an explicit parameter — not read from homedir() internally — so
|
|
6593
|
+
* unit tests can point it at a throwaway temp dir instead of this machine's
|
|
6594
|
+
* real ~/.flair (test/unit/upgrade-data-snapshot.test.ts).
|
|
6595
|
+
*/
|
|
6596
|
+
export async function createDataSnapshot(dataDir, snapshotRoot = UPGRADE_SNAPSHOT_ROOT) {
|
|
6597
|
+
mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 });
|
|
6598
|
+
const snapshotPath = join(snapshotRoot, upgradeSnapshotFileName());
|
|
6599
|
+
// realpath, not just resolve() — on macOS (and some Linux distros) the
|
|
6600
|
+
// system temp dir itself sits behind a symlink (/tmp -> /private/tmp), so
|
|
6601
|
+
// a plain lexical resolve() of `dataDir` would never equal the realpath()
|
|
6602
|
+
// of a symlink target genuinely INSIDE it, misclassifying every in-bounds
|
|
6603
|
+
// symlink as an escape.
|
|
6604
|
+
const resolvedDataDir = realpathSync(resolve(dataDir));
|
|
6605
|
+
const filter = (entryPath) => {
|
|
6606
|
+
// entryPath is relative to `cwd` (dataDir) per tar's create() contract.
|
|
6607
|
+
const abs = resolve(resolvedDataDir, entryPath);
|
|
6608
|
+
let st;
|
|
6609
|
+
try {
|
|
6610
|
+
st = lstatSync(abs);
|
|
6611
|
+
}
|
|
6612
|
+
catch {
|
|
6613
|
+
return false; // vanished between readdir and stat — skip, don't crash the snapshot
|
|
6614
|
+
}
|
|
6615
|
+
if (st.isSocket() || st.isFIFO() || st.isCharacterDevice() || st.isBlockDevice()) {
|
|
6616
|
+
console.error(` (skipping non-regular file in snapshot: ${entryPath})`);
|
|
6617
|
+
return false;
|
|
6618
|
+
}
|
|
6619
|
+
if (st.isSymbolicLink()) {
|
|
6620
|
+
let real;
|
|
6621
|
+
try {
|
|
6622
|
+
real = realpathSync(abs);
|
|
6623
|
+
}
|
|
6624
|
+
catch {
|
|
6625
|
+
console.error(` (skipping broken symlink in snapshot: ${entryPath})`);
|
|
6626
|
+
return false;
|
|
6627
|
+
}
|
|
6628
|
+
const withinDataDir = real === resolvedDataDir || real.startsWith(resolvedDataDir + sep);
|
|
6629
|
+
if (!withinDataDir) {
|
|
6630
|
+
console.error(` (skipping symlink pointing outside the data dir: ${entryPath})`);
|
|
6631
|
+
return false;
|
|
6632
|
+
}
|
|
6633
|
+
}
|
|
6634
|
+
return true;
|
|
6635
|
+
};
|
|
6636
|
+
// preservePaths: true — WITHOUT it, node-tar strips the leading `/` off
|
|
6637
|
+
// any absolute symlink target it archives (found the hard way: an
|
|
6638
|
+
// in-bounds symlink pointing at an absolute path under `dataDir` came
|
|
6639
|
+
// back on extraction as a nonsense RELATIVE path, silently broken). Every
|
|
6640
|
+
// entry path here is already relative (fileList is `["."]`, cwd is
|
|
6641
|
+
// `dataDir`) — this only affects symlink target text, restoring it
|
|
6642
|
+
// verbatim, which is exactly what a same-host restore into the original
|
|
6643
|
+
// ~/.flair/data path needs.
|
|
6644
|
+
await tarCreate({ gzip: true, cwd: resolvedDataDir, file: snapshotPath, filter, preservePaths: true }, ["."]);
|
|
6645
|
+
// Owner-only — the archive can contain 0600 key/admin-pass material.
|
|
6646
|
+
chmodSync(snapshotPath, 0o600);
|
|
6647
|
+
return { path: snapshotPath, bytes: statSync(snapshotPath).size };
|
|
6648
|
+
}
|
|
6649
|
+
/**
|
|
6650
|
+
* Keep only the newest `retain` upgrade snapshots, deleting older ones.
|
|
6651
|
+
* Best-effort: a pruning failure is logged, not thrown — it must never
|
|
6652
|
+
* un-succeed an upgrade whose snapshot already landed safely on disk.
|
|
6653
|
+
* Returns the paths removed.
|
|
6654
|
+
*
|
|
6655
|
+
* `snapshotRoot` is explicit for the same testability reason as
|
|
6656
|
+
* `createDataSnapshot` above.
|
|
6657
|
+
*/
|
|
6658
|
+
export function pruneOldSnapshots(retain = UPGRADE_SNAPSHOT_RETAIN, snapshotRoot = UPGRADE_SNAPSHOT_ROOT) {
|
|
6659
|
+
if (!existsSync(snapshotRoot))
|
|
6660
|
+
return [];
|
|
6661
|
+
const removed = [];
|
|
6662
|
+
try {
|
|
6663
|
+
const files = readdirSync(snapshotRoot)
|
|
6664
|
+
.filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
|
|
6665
|
+
.map((f) => join(snapshotRoot, f))
|
|
6666
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
6667
|
+
for (const stale of files.slice(retain)) {
|
|
6668
|
+
try {
|
|
6669
|
+
rmSync(stale, { force: true });
|
|
6670
|
+
removed.push(stale);
|
|
6671
|
+
}
|
|
6672
|
+
catch (err) {
|
|
6673
|
+
console.error(` (could not prune old snapshot ${stale}: ${err.message})`);
|
|
6674
|
+
}
|
|
6675
|
+
}
|
|
5871
6676
|
}
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
6677
|
+
catch (err) {
|
|
6678
|
+
console.error(` (snapshot retention check failed: ${err.message})`);
|
|
6679
|
+
}
|
|
6680
|
+
return removed;
|
|
6681
|
+
}
|
|
6682
|
+
export function decideUpgradeSnapshotAction(flairIsUpgrading, snapshotRequested, hasDataDir) {
|
|
6683
|
+
if (!flairIsUpgrading)
|
|
6684
|
+
return "not-upgrading";
|
|
6685
|
+
if (!snapshotRequested)
|
|
6686
|
+
return hasDataDir ? "nudge" : "not-upgrading";
|
|
6687
|
+
return hasDataDir ? "snapshot" : "no-data";
|
|
6688
|
+
}
|
|
6689
|
+
/**
|
|
6690
|
+
* The exact non-blocking recommendation nudge printed when `flair upgrade`
|
|
6691
|
+
* runs without --snapshot (the default) and a data dir exists to snapshot.
|
|
6692
|
+
* Exported as a constant — not inlined in two places — so the CLI output and
|
|
6693
|
+
* its unit test assertion can't drift apart. Modeled on Harper's own
|
|
6694
|
+
* upgrade prompt ("if you have not created a backup of your data, we
|
|
6695
|
+
* recommend you cancel and back up before proceeding") but informational,
|
|
6696
|
+
* never blocking: this must stay safe for non-interactive/scripted upgrades.
|
|
6697
|
+
*/
|
|
6698
|
+
export const UPGRADE_SNAPSHOT_NUDGE_LINES = [
|
|
6699
|
+
"No pre-upgrade snapshot will be taken.",
|
|
6700
|
+
"To capture one first: `flair snapshot create` (physical) or `flair backup` (logical export), or re-run with --snapshot.",
|
|
6701
|
+
];
|
|
6702
|
+
// ─── flair snapshot ─────────────────────────────────────────────────────────
|
|
6703
|
+
// Explicit, first-class surface for the physical data-dir snapshot mechanism
|
|
6704
|
+
// above (createDataSnapshot / pruneOldSnapshots / UPGRADE_SNAPSHOT_ROOT).
|
|
6705
|
+
// Added alongside the opt-in rewrite of `flair upgrade`'s snapshot trigger
|
|
6706
|
+
// (2026-07-08) so taking one is a real command, not just a side effect of
|
|
6707
|
+
// upgrading with --snapshot.
|
|
6708
|
+
//
|
|
6709
|
+
// Deliberately NOT named/shaped like `flair backup` / `flair restore`
|
|
6710
|
+
// (further below) — those are a LOGICAL export/import of Agent/Memory/Soul
|
|
6711
|
+
// records as JSON over the HTTP API, portable across hosts and versions.
|
|
6712
|
+
// `flair snapshot` is a PHYSICAL, byte-exact tar.gz of the whole
|
|
6713
|
+
// ~/.flair/data directory (RocksDB files, keys, config, admin-pass — every
|
|
6714
|
+
// byte, same host, same version) taken with Flair stopped for consistency.
|
|
6715
|
+
// Different mechanism, different restore procedure, different failure
|
|
6716
|
+
// modes — hence its own namespace (`snapshot create|list|restore`) instead
|
|
6717
|
+
// of overloading the JSON one. Mirrors the `rem snapshot` / `session
|
|
6718
|
+
// snapshot` subcommand idiom used elsewhere in this file.
|
|
6719
|
+
const snapshotCmd = program
|
|
6720
|
+
.command("snapshot")
|
|
6721
|
+
.description("Physical ~/.flair/data snapshots (byte-exact tar.gz, local-only — see `flair backup`/`flair restore` for the logical JSON export/import)");
|
|
6722
|
+
snapshotCmd
|
|
6723
|
+
.command("create")
|
|
6724
|
+
.description("Take a physical snapshot of the Flair data directory now (briefly stops Flair for a consistent copy — use `flair backup` for a no-downtime logical export)")
|
|
6725
|
+
.option("--data-dir <path>", "Data directory to snapshot (default: ~/.flair/data)")
|
|
6726
|
+
.option("--port <port>", "Harper HTTP port (used to quiesce Flair around the snapshot)")
|
|
6727
|
+
.action(async (opts) => {
|
|
6728
|
+
const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
|
|
6729
|
+
const port = resolveHttpPort(opts);
|
|
6730
|
+
if (!existsSync(dataDir)) {
|
|
6731
|
+
console.error(`Error: data directory does not exist: ${dataDir}`);
|
|
6732
|
+
process.exit(1);
|
|
6733
|
+
}
|
|
6734
|
+
console.log(`Snapshotting ${dataDir}...`);
|
|
6735
|
+
console.log("(Flair will be briefly stopped for a point-in-time-consistent copy, then restarted.)");
|
|
6736
|
+
// Same consistency requirement as the upgrade path's snapshot step: a
|
|
6737
|
+
// live RocksDB directory (WAL/MANIFEST/SST) isn't safe to copy while
|
|
6738
|
+
// Flair is running, so this stops Flair, snapshots, and restarts it —
|
|
6739
|
+
// same stop/start helpers `flair upgrade`'s snapshot step uses, so a
|
|
6740
|
+
// standalone `flair snapshot create` gives the exact same
|
|
6741
|
+
// point-in-time-consistent guarantee, not a weaker one.
|
|
6742
|
+
let stoppedForSnapshot = false;
|
|
6743
|
+
try {
|
|
6744
|
+
await stopFlairProcess(port);
|
|
6745
|
+
stoppedForSnapshot = true;
|
|
6746
|
+
const snapshot = await createDataSnapshot(dataDir);
|
|
6747
|
+
const removed = pruneOldSnapshots();
|
|
6748
|
+
console.log(`✅ Snapshot: ${snapshot.path} (${humanBytes(snapshot.bytes)})`);
|
|
6749
|
+
if (removed.length > 0) {
|
|
6750
|
+
console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
|
|
6751
|
+
}
|
|
6752
|
+
}
|
|
6753
|
+
catch (err) {
|
|
6754
|
+
console.error(`❌ snapshot failed: ${err.message}`);
|
|
6755
|
+
if (stoppedForSnapshot) {
|
|
6756
|
+
try {
|
|
6757
|
+
await startFlairProcess(port);
|
|
6758
|
+
}
|
|
6759
|
+
catch { /* best effort — surface the original snapshot error, not this */ }
|
|
6760
|
+
}
|
|
6761
|
+
process.exit(1);
|
|
6762
|
+
}
|
|
6763
|
+
try {
|
|
6764
|
+
await startFlairProcess(port);
|
|
6765
|
+
}
|
|
6766
|
+
catch (err) {
|
|
6767
|
+
console.error(`❌ the snapshot succeeded but Flair failed to restart: ${err.message}`);
|
|
6768
|
+
console.error(" Check: flair doctor");
|
|
6769
|
+
process.exit(1);
|
|
6770
|
+
}
|
|
6771
|
+
});
|
|
6772
|
+
snapshotCmd
|
|
6773
|
+
.command("list")
|
|
6774
|
+
.description("List physical data snapshots under ~/.flair/upgrade-snapshots/")
|
|
6775
|
+
.option("--json", "Output as JSON")
|
|
6776
|
+
.action((opts) => {
|
|
6777
|
+
if (!existsSync(UPGRADE_SNAPSHOT_ROOT)) {
|
|
6778
|
+
if (opts.json) {
|
|
6779
|
+
console.log("[]");
|
|
6780
|
+
return;
|
|
6781
|
+
}
|
|
6782
|
+
console.log(`(no snapshots — ${UPGRADE_SNAPSHOT_ROOT} does not exist yet)`);
|
|
6783
|
+
console.log("Run `flair snapshot create` to make one, or `flair upgrade --snapshot` to take one automatically before an upgrade.");
|
|
6784
|
+
return;
|
|
6785
|
+
}
|
|
6786
|
+
const rows = readdirSync(UPGRADE_SNAPSHOT_ROOT)
|
|
6787
|
+
.filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
|
|
6788
|
+
.map((f) => {
|
|
6789
|
+
const p = join(UPGRADE_SNAPSHOT_ROOT, f);
|
|
6790
|
+
const s = statSync(p);
|
|
6791
|
+
return { file: f, path: p, size: s.size, mtime: s.mtime.toISOString() };
|
|
6792
|
+
})
|
|
6793
|
+
.sort((a, b) => b.mtime.localeCompare(a.mtime));
|
|
6794
|
+
if (opts.json) {
|
|
6795
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
6796
|
+
return;
|
|
6797
|
+
}
|
|
6798
|
+
if (rows.length === 0) {
|
|
6799
|
+
console.log("(no snapshots)");
|
|
6800
|
+
return;
|
|
6801
|
+
}
|
|
6802
|
+
const fileW = Math.max(20, ...rows.map((r) => r.file.length));
|
|
6803
|
+
console.log(` ${"file".padEnd(fileW)} size age`);
|
|
6804
|
+
for (const r of rows) {
|
|
6805
|
+
console.log(` ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
|
|
6806
|
+
}
|
|
6807
|
+
console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
|
|
6808
|
+
});
|
|
6809
|
+
snapshotCmd
|
|
6810
|
+
.command("restore <path>")
|
|
6811
|
+
.description("Restore a physical snapshot: stops Flair, replaces the data directory, restarts")
|
|
6812
|
+
.option("--data-dir <path>", "Data directory to replace (default: ~/.flair/data)")
|
|
6813
|
+
.option("--port <port>", "Harper HTTP port")
|
|
6814
|
+
.option("--yes", "Skip the confirmation prompt (this destroys the current data directory)")
|
|
6815
|
+
.action(async (snapshotArg, opts) => {
|
|
6816
|
+
const snapshotPath = resolve(snapshotArg);
|
|
6817
|
+
if (!existsSync(snapshotPath)) {
|
|
6818
|
+
console.error(`Error: snapshot does not exist: ${snapshotPath}`);
|
|
6819
|
+
process.exit(1);
|
|
6820
|
+
}
|
|
6821
|
+
const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir();
|
|
6822
|
+
const port = resolveHttpPort(opts);
|
|
6823
|
+
console.log("This will STOP Flair, DELETE the current data directory, and replace it with:");
|
|
6824
|
+
console.log(` snapshot: ${snapshotPath}`);
|
|
6825
|
+
console.log(` target: ${dataDir}`);
|
|
6826
|
+
if (!opts.yes) {
|
|
6827
|
+
if (!process.stdin.isTTY) {
|
|
6828
|
+
console.error("\nError: refusing to destroy the data directory in a non-interactive shell without --yes.");
|
|
6829
|
+
process.exit(1);
|
|
6830
|
+
}
|
|
6831
|
+
const { createInterface } = await import("node:readline");
|
|
6832
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
6833
|
+
const answer = await new Promise((res) => rl.question(`\nDestroy ${dataDir} and restore from this snapshot? [y/N] `, (a) => { rl.close(); res(a); }));
|
|
6834
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
6835
|
+
console.log("Aborted.");
|
|
6836
|
+
return;
|
|
6837
|
+
}
|
|
6838
|
+
}
|
|
6839
|
+
try {
|
|
6840
|
+
await stopFlairProcess(port);
|
|
6841
|
+
}
|
|
6842
|
+
catch (err) {
|
|
6843
|
+
console.error(`❌ failed to stop Flair: ${err.message}`);
|
|
6844
|
+
process.exit(1);
|
|
6845
|
+
}
|
|
6846
|
+
try {
|
|
6847
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
6848
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
6849
|
+
// preservePaths mirrors createDataSnapshot's own preservePaths: true —
|
|
6850
|
+
// restores absolute symlink targets verbatim instead of node-tar's
|
|
6851
|
+
// default of stripping the leading "/" on extraction. No `follow`
|
|
6852
|
+
// option, so symlinks extract as symlinks (never their targets'
|
|
6853
|
+
// contents), and file modes extract exactly as stored — the archive
|
|
6854
|
+
// itself already only contains what createDataSnapshot's filter chose
|
|
6855
|
+
// to include (in-bounds symlinks, regular files/dirs only), so restore
|
|
6856
|
+
// needs no re-filtering of its own.
|
|
6857
|
+
await tarExtract({ file: snapshotPath, cwd: dataDir, preservePaths: true });
|
|
6858
|
+
}
|
|
6859
|
+
catch (err) {
|
|
6860
|
+
console.error(`❌ restore failed: ${err.message}`);
|
|
6861
|
+
console.error(` ${dataDir} may be partially restored or empty — do not start Flair until this is resolved.`);
|
|
6862
|
+
process.exit(1);
|
|
6863
|
+
}
|
|
6864
|
+
try {
|
|
6865
|
+
await startFlairProcess(port);
|
|
6866
|
+
}
|
|
6867
|
+
catch (err) {
|
|
6868
|
+
console.error(`❌ restore succeeded but Flair failed to restart: ${err.message}`);
|
|
6869
|
+
console.error(" Check: flair doctor");
|
|
6870
|
+
process.exit(1);
|
|
6871
|
+
}
|
|
6872
|
+
console.log(`✅ Restored ${dataDir} from ${snapshotPath}`);
|
|
6873
|
+
console.log(" Flair restarted. Verify: flair status && flair doctor");
|
|
6874
|
+
});
|
|
6875
|
+
// ─── flair upgrade ────────────────────────────────────────────────────────────
|
|
6876
|
+
program
|
|
6877
|
+
.command("upgrade")
|
|
6878
|
+
.description("Upgrade Flair — local packages by default, or a deployed Fabric with --target")
|
|
6879
|
+
.option("--check", "Only check for updates / show the plan, don't install or deploy")
|
|
6880
|
+
.option("--restart", "[deprecated] no-op — restart now happens automatically after upgrade; use --no-restart to opt out")
|
|
6881
|
+
.option("--no-restart", "Skip the restart after upgrade (stage new packages now, restart later)")
|
|
6882
|
+
.option("--no-verify", "Skip post-restart health/version/auth verification (default: verify — so a broken upgrade can't report success; see flair#635)")
|
|
6883
|
+
.option("--snapshot", "Take a pre-upgrade ~/.flair/data snapshot before the package swap, keep-last-3 retention (default: off — see `flair snapshot create` to take one by hand, or `flair backup` for a logical export; flair#637)")
|
|
6884
|
+
.option("--all", "Show transitive packages (e.g. flair-client) in the listing — verbose mode for debugging dep versions")
|
|
6885
|
+
// ── Fabric upgrade (--target) ────────────────────────────────────────────
|
|
6886
|
+
// When --target is passed, upgrade the Flair component DEPLOYED to that
|
|
6887
|
+
// Harper Fabric URL instead of the local npm install. Reuses `flair deploy`
|
|
6888
|
+
// under the hood with the @harperfast/harper pin baked in (flair#513).
|
|
6889
|
+
.option("--target <url>", "Upgrade the Flair deployed to this Fabric URL (not the local install)")
|
|
6890
|
+
.option("--fabric-user <user>", "Fabric admin username — for --target (env: FABRIC_USER preferred; inline leaks to shell history)")
|
|
6891
|
+
.option("--fabric-password <pass>", "Fabric admin password — for --target (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
|
|
6892
|
+
.option("--fabric-password-file <path>", "Read the Fabric admin password from a file (chmod 600) — for --target")
|
|
6893
|
+
.option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
|
|
6894
|
+
.option("--harper-version <semver>", "Pin @harperfast/harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
|
|
6895
|
+
.option("--project <name>", "Fabric component name for --target", "flair")
|
|
6896
|
+
.option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
|
|
6897
|
+
.option("--yes", "Skip the confirmation prompt for --target")
|
|
6898
|
+
.option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
|
|
6899
|
+
.action(async (opts) => {
|
|
6900
|
+
// ── Fabric-upgrade branch ───────────────────────────────────────────────
|
|
6901
|
+
if (opts.target) {
|
|
6902
|
+
await runFabricUpgrade(opts);
|
|
6903
|
+
return;
|
|
6904
|
+
}
|
|
6905
|
+
const { execFileSync } = await import("node:child_process");
|
|
6906
|
+
const checkOnly = opts.check ?? false;
|
|
6907
|
+
const showAll = opts.all ?? false;
|
|
6908
|
+
console.log("Checking for updates...\n");
|
|
6909
|
+
const packages = [
|
|
6910
|
+
{
|
|
6911
|
+
name: "@tpsdev-ai/flair",
|
|
6912
|
+
kind: "bin",
|
|
5880
6913
|
probe: () => probeBinVersion(execFileSync, "flair"),
|
|
5881
6914
|
},
|
|
5882
6915
|
{
|
|
@@ -5884,7 +6917,7 @@ program
|
|
|
5884
6917
|
kind: "bin",
|
|
5885
6918
|
// Older flair-mcp installs (e.g. 0.10.0) either aren't on PATH or
|
|
5886
6919
|
// don't support `--version`, so the bin probe returns null even when
|
|
5887
|
-
// the package IS globally installed
|
|
6920
|
+
// the package IS globally installed. Fall back to the lib
|
|
5888
6921
|
// probe, which require.resolves the package.json from a sibling global
|
|
5889
6922
|
// install regardless of PATH or --version support. kind stays "bin" so
|
|
5890
6923
|
// it remains npm-upgradeable (npm install -g), not the openclaw path.
|
|
@@ -5927,7 +6960,7 @@ program
|
|
|
5927
6960
|
}
|
|
5928
6961
|
findings.push({ name, installed, latest, status, kind });
|
|
5929
6962
|
// Suppress the line for openclaw plugins that are optional-because-
|
|
5930
|
-
// openclaw-is-absent
|
|
6963
|
+
// openclaw-is-absent: on machines without openclaw the
|
|
5931
6964
|
// "○ … not installed (openclaw not detected) → … (install via …)"
|
|
5932
6965
|
// line is pure noise. Still print it when openclaw IS installed
|
|
5933
6966
|
// (current/outdated) or under --all.
|
|
@@ -5946,7 +6979,7 @@ program
|
|
|
5946
6979
|
}
|
|
5947
6980
|
catch { /* skip unavailable packages */ }
|
|
5948
6981
|
}
|
|
5949
|
-
// Scope footer
|
|
6982
|
+
// Scope footer: make explicit what `flair upgrade` does and
|
|
5950
6983
|
// doesn't cover, so "were the others checked?" has a one-line answer.
|
|
5951
6984
|
console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
|
|
5952
6985
|
const outdated = findings.filter((f) => f.status === "outdated");
|
|
@@ -5978,11 +7011,108 @@ program
|
|
|
5978
7011
|
}
|
|
5979
7012
|
return;
|
|
5980
7013
|
}
|
|
7014
|
+
// Hoisted here (was previously computed after install/restart) — the
|
|
7015
|
+
// pre-upgrade snapshot below needs to know the target port AND whether a
|
|
7016
|
+
// restart is coming, before any package is touched. Pure function of
|
|
7017
|
+
// `opts` — safe to call this early.
|
|
7018
|
+
const { restart: shouldRestart, verify: shouldVerify, deprecatedRestartFlagUsed } = resolveUpgradeRestartVerify(opts);
|
|
7019
|
+
const upgradePort = resolveHttpPort({});
|
|
7020
|
+
// ── Pre-upgrade data snapshot (flair#637, opt-in as of the 2026-07-08 rewire) ──
|
|
7021
|
+
// Only an @tpsdev-ai/flair package swap touches the code that reads/
|
|
7022
|
+
// writes ~/.flair/data — an flair-mcp-only or openclaw-plugin-only
|
|
7023
|
+
// upgrade never runs different Harper/Flair code against the data, so
|
|
7024
|
+
// there's nothing at risk and nothing to snapshot.
|
|
7025
|
+
//
|
|
7026
|
+
// Decision (Nathan, 2026-07-08): the physical snapshot used to run
|
|
7027
|
+
// automatically on every local upgrade (opt-out via --no-snapshot). That
|
|
7028
|
+
// defaulted every upgrade into tarring the entire data dir (can be
|
|
7029
|
+
// 800MB+, keep-last-3 retention ~2.5GB) for a failure mode the
|
|
7030
|
+
// tested-downgrade guarantee (docs/upgrade.md, test/compat/downgrade-
|
|
7031
|
+
// boot.test.ts) already covers — and it diverged from Harper's own
|
|
7032
|
+
// upgrade CLI, which recommends a backup before proceeding but never
|
|
7033
|
+
// auto-tars the data directory itself. `--snapshot` is now opt-in, off
|
|
7034
|
+
// by default; opting out gets a non-blocking recommendation nudge
|
|
7035
|
+
// instead of a silent skip. The underlying mechanism (createDataSnapshot
|
|
7036
|
+
// / pruneOldSnapshots, the stop-snapshot-restart quiesce dance, and
|
|
7037
|
+
// abort-the-upgrade-on-snapshot-failure) is unchanged — only the trigger
|
|
7038
|
+
// moved from opt-out to opt-in. `flair snapshot create` (below) exposes
|
|
7039
|
+
// the exact same mechanism as a standalone command for anyone who wants
|
|
7040
|
+
// one without wrapping it around an upgrade.
|
|
7041
|
+
const flairIsUpgrading = npmUpgrades.some((u) => u.pkg === "@tpsdev-ai/flair");
|
|
7042
|
+
const snapshotDataDir = defaultDataDir();
|
|
7043
|
+
const snapshotDecision = decideUpgradeSnapshotAction(flairIsUpgrading, !!opts.snapshot, existsSync(snapshotDataDir));
|
|
7044
|
+
let snapshotPath = null;
|
|
7045
|
+
if (snapshotDecision === "nudge") {
|
|
7046
|
+
// Non-blocking nudge only — never prompt/block here, this must stay
|
|
7047
|
+
// safe for non-interactive/scripted upgrades. Modeled on Harper's own
|
|
7048
|
+
// upgrade prompt ("if you have not created a backup ... we recommend
|
|
7049
|
+
// you cancel and back up before proceeding") but informational, not a
|
|
7050
|
+
// gate.
|
|
7051
|
+
console.log("");
|
|
7052
|
+
for (const line of UPGRADE_SNAPSHOT_NUDGE_LINES)
|
|
7053
|
+
console.log(render.wrap(render.c.dim, line));
|
|
7054
|
+
}
|
|
7055
|
+
else if (snapshotDecision === "no-data") {
|
|
7056
|
+
console.log(`\n(no data directory at ${snapshotDataDir} yet — nothing to snapshot)`);
|
|
7057
|
+
}
|
|
7058
|
+
else if (snapshotDecision === "snapshot") {
|
|
7059
|
+
console.log("\nSnapshotting data before upgrade...");
|
|
7060
|
+
// Consistency: a running Harper's data dir can be mid-write, and a
|
|
7061
|
+
// plain file copy of a live database directory isn't guaranteed
|
|
7062
|
+
// point-in-time consistent (Harper 5.x = RocksDB: WAL/SST/MANIFEST
|
|
7063
|
+
// can tear under a live copy). Stopping first — then immediately
|
|
7064
|
+
// restarting the OLD version, before any package changes — gives a
|
|
7065
|
+
// quiesced, safe-to-copy directory with only a brief blip, even for
|
|
7066
|
+
// --no-restart (the snapshot's correctness doesn't depend on
|
|
7067
|
+
// whether the caller wants a restart AFTER the upgrade — those are
|
|
7068
|
+
// orthogonal). See docs/upgrade.md for the native-backup alternative
|
|
7069
|
+
// considered and rejected (Harper's `get_backup` op backs up one
|
|
7070
|
+
// table/schema at a time over the running HTTP API — not the whole
|
|
7071
|
+
// data dir — and rejecting it here means this path never depends on
|
|
7072
|
+
// the server being up).
|
|
7073
|
+
let stoppedForSnapshot = false;
|
|
7074
|
+
try {
|
|
7075
|
+
await stopFlairProcess(upgradePort);
|
|
7076
|
+
stoppedForSnapshot = true;
|
|
7077
|
+
const snapshot = await createDataSnapshot(snapshotDataDir);
|
|
7078
|
+
snapshotPath = snapshot.path;
|
|
7079
|
+
const removed = pruneOldSnapshots();
|
|
7080
|
+
console.log(`✅ Snapshot: ${snapshotPath} (${humanBytes(snapshot.bytes)})`);
|
|
7081
|
+
console.log(` Restore: flair snapshot restore "${snapshotPath}"`);
|
|
7082
|
+
if (removed.length > 0) {
|
|
7083
|
+
console.log(` Pruned ${removed.length} older snapshot${removed.length > 1 ? "s" : ""} (keeping last ${UPGRADE_SNAPSHOT_RETAIN})`);
|
|
7084
|
+
}
|
|
7085
|
+
}
|
|
7086
|
+
catch (err) {
|
|
7087
|
+
console.error(`❌ snapshot failed: ${err.message}`);
|
|
7088
|
+
console.error(" Aborting upgrade — no packages were changed. Omit --snapshot to proceed without one (not recommended).");
|
|
7089
|
+
if (stoppedForSnapshot) {
|
|
7090
|
+
try {
|
|
7091
|
+
await startFlairProcess(upgradePort);
|
|
7092
|
+
}
|
|
7093
|
+
catch { /* best effort — surface the original snapshot error, not this */ }
|
|
7094
|
+
}
|
|
7095
|
+
process.exit(1);
|
|
7096
|
+
}
|
|
7097
|
+
try {
|
|
7098
|
+
await startFlairProcess(upgradePort);
|
|
7099
|
+
}
|
|
7100
|
+
catch (err) {
|
|
7101
|
+
console.error(`❌ failed to restart Flair after the pre-upgrade snapshot: ${err.message}`);
|
|
7102
|
+
console.error(` The snapshot itself succeeded (${snapshotPath}) — no packages were changed. Check: flair doctor`);
|
|
7103
|
+
process.exit(1);
|
|
7104
|
+
}
|
|
7105
|
+
}
|
|
5981
7106
|
// Perform upgrade. `latest` comes from the npm registry's HTTP
|
|
5982
7107
|
// response, so CodeQL (correctly) treats it as untrusted input.
|
|
5983
7108
|
// Use execFileSync with argv — the spec `<name>@<version>` becomes a
|
|
5984
7109
|
// single argument to the upgrade command, no shell to inject into.
|
|
5985
7110
|
console.log(`\nUpgrading ${totalUpgrades} package${totalUpgrades > 1 ? "s" : ""}...\n`);
|
|
7111
|
+
// Tracked separately (rather than inferred from findings alone) because the
|
|
7112
|
+
// post-restart verify/rollback step below needs to know whether @tpsdev-ai/flair's
|
|
7113
|
+
// OWN install actually succeeded — if it failed, the running version is still the
|
|
7114
|
+
// OLD one and verification should expect that, not the target we failed to reach.
|
|
7115
|
+
let flairInstallFailed = false;
|
|
5986
7116
|
for (const { pkg, latest } of npmUpgrades) {
|
|
5987
7117
|
try {
|
|
5988
7118
|
console.log(` Installing ${pkg}@${latest}...`);
|
|
@@ -5991,6 +7121,8 @@ program
|
|
|
5991
7121
|
}
|
|
5992
7122
|
catch (err) {
|
|
5993
7123
|
console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
|
|
7124
|
+
if (pkg === "@tpsdev-ai/flair")
|
|
7125
|
+
flairInstallFailed = true;
|
|
5994
7126
|
}
|
|
5995
7127
|
}
|
|
5996
7128
|
for (const { pkg, latest } of openclawUpgrades) {
|
|
@@ -6013,31 +7145,107 @@ program
|
|
|
6013
7145
|
console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
|
|
6014
7146
|
}
|
|
6015
7147
|
}
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
7148
|
+
// ── Restart + verify + rollback (flair#635) ─────────────────────────────
|
|
7149
|
+
// Decision (2026-07-08): restart is now the default post-upgrade step —
|
|
7150
|
+
// installing new code without restarting leaves the OLD process serving
|
|
7151
|
+
// while the version on disk lies about what's actually running.
|
|
7152
|
+
// --no-restart opts back out for the "stage now, bounce later" case.
|
|
7153
|
+
// --restart is kept as a deprecated no-op for old muscle memory.
|
|
7154
|
+
// Upgrade = install → restart → verify → (rollback on failure), one
|
|
7155
|
+
// transaction — never report success on a broken restart.
|
|
7156
|
+
const flairFinding = findings.find((f) => f.name === "@tpsdev-ai/flair");
|
|
7157
|
+
const previousFlairVersion = flairFinding?.installed ?? null;
|
|
7158
|
+
const expectedFlairVersion = flairFinding?.status === "outdated" && !flairInstallFailed
|
|
7159
|
+
? flairFinding.latest
|
|
7160
|
+
: flairFinding?.installed ?? null;
|
|
7161
|
+
// shouldRestart/shouldVerify/deprecatedRestartFlagUsed were hoisted above
|
|
7162
|
+
// the pre-upgrade snapshot block — it needs to know these before any
|
|
7163
|
+
// package is touched.
|
|
7164
|
+
if (deprecatedRestartFlagUsed) {
|
|
7165
|
+
console.error("warning: --restart is deprecated and is now a no-op — flair upgrade restarts by default. Use --no-restart to skip it.");
|
|
7166
|
+
}
|
|
7167
|
+
if (!shouldRestart) {
|
|
7168
|
+
console.log("\nRun: flair restart to use the new version");
|
|
7169
|
+
return;
|
|
7170
|
+
}
|
|
7171
|
+
console.log("\nRestarting Flair...");
|
|
7172
|
+
const port = upgradePort;
|
|
7173
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
7174
|
+
try {
|
|
7175
|
+
await restartFlair(port);
|
|
7176
|
+
}
|
|
7177
|
+
catch (err) {
|
|
7178
|
+
console.error(`❌ restart failed: ${err.message}`);
|
|
7179
|
+
console.error(" Flair may be partially down. Check: flair doctor");
|
|
7180
|
+
process.exit(1);
|
|
7181
|
+
}
|
|
7182
|
+
console.log("✅ Flair restarted");
|
|
7183
|
+
if (!shouldVerify) {
|
|
7184
|
+
console.log(" (--no-verify: skipping post-restart verification)");
|
|
7185
|
+
return;
|
|
7186
|
+
}
|
|
7187
|
+
console.log("\nVerifying...");
|
|
7188
|
+
// The authenticated leg dogfoods api()'s local-credential resolution
|
|
7189
|
+
// (flair#640: env > agent key > ~/.flair/admin-pass file) — probeInstance
|
|
7190
|
+
// itself never resolves credentials, it just calls whatever's handed to it.
|
|
7191
|
+
const verify = await probeInstance(baseUrl, {
|
|
7192
|
+
expectVersion: expectedFlairVersion ?? undefined,
|
|
7193
|
+
timeoutMs: STARTUP_TIMEOUT_MS,
|
|
7194
|
+
authedGet: (path) => api("GET", path, undefined, { baseUrl }),
|
|
7195
|
+
});
|
|
7196
|
+
const verdict = decideAfterVerify(verify, previousFlairVersion);
|
|
7197
|
+
if (verdict.kind === "ok") {
|
|
7198
|
+
console.log(`✅ verified: healthy, authenticated${verify.version ? `, running ${verify.version}` : ""}`);
|
|
7199
|
+
return;
|
|
7200
|
+
}
|
|
7201
|
+
console.error(`❌ post-restart verification failed: ${verdict.reason}`);
|
|
7202
|
+
if (verdict.kind === "cannot-rollback") {
|
|
7203
|
+
console.error(" Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown.");
|
|
7204
|
+
console.error(" Check the instance now: flair doctor");
|
|
7205
|
+
process.exit(1);
|
|
7206
|
+
}
|
|
7207
|
+
console.log(`\nRolling back @tpsdev-ai/flair to ${verdict.toVersion}...`);
|
|
7208
|
+
try {
|
|
7209
|
+
execFileSync("npm", ["install", "-g", `@tpsdev-ai/flair@${verdict.toVersion}`], { stdio: "pipe" });
|
|
7210
|
+
}
|
|
7211
|
+
catch (err) {
|
|
7212
|
+
console.error(`❌ rollback install failed: ${err.message}`);
|
|
7213
|
+
console.error(` Flair is currently running the FAILED version (${expectedFlairVersion ?? "unknown"}). Manual intervention required.`);
|
|
7214
|
+
process.exit(1);
|
|
7215
|
+
}
|
|
7216
|
+
try {
|
|
7217
|
+
await restartFlair(port);
|
|
7218
|
+
}
|
|
7219
|
+
catch (err) {
|
|
7220
|
+
console.error(`❌ rollback restart failed: ${err.message}`);
|
|
7221
|
+
console.error(" Instance state is UNKNOWN — it may be down entirely. Check: flair doctor");
|
|
7222
|
+
process.exit(1);
|
|
7223
|
+
}
|
|
7224
|
+
const rollbackVerify = await probeInstance(baseUrl, {
|
|
7225
|
+
expectVersion: verdict.toVersion,
|
|
7226
|
+
timeoutMs: STARTUP_TIMEOUT_MS,
|
|
7227
|
+
authedGet: (path) => api("GET", path, undefined, { baseUrl }),
|
|
7228
|
+
});
|
|
7229
|
+
const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
|
|
7230
|
+
if (rollbackVerdict.kind === "rolled-back") {
|
|
7231
|
+
console.error(`❌ upgrade failed verification and was rolled back to @tpsdev-ai/flair@${verdict.toVersion}.`);
|
|
7232
|
+
console.error(` Original failure: ${verdict.reason}`);
|
|
7233
|
+
process.exit(1);
|
|
7234
|
+
}
|
|
7235
|
+
console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
|
|
7236
|
+
console.error(" Instance state is UNKNOWN — do not assume data integrity.");
|
|
7237
|
+
// This double-failure isn't auto-recoverable yet (flair#637) — but if a
|
|
7238
|
+
// pre-upgrade snapshot landed, point at the CONCRETE path instead of
|
|
7239
|
+
// just the issue number, so recovery doesn't start with a GitHub search.
|
|
7240
|
+
if (snapshotPath) {
|
|
7241
|
+
console.error(` A pre-upgrade snapshot is available: ${snapshotPath}`);
|
|
7242
|
+
console.error(` Restore: flair snapshot restore "${snapshotPath}" (or see docs/upgrade.md#downgrade).`);
|
|
6037
7243
|
}
|
|
6038
7244
|
else {
|
|
6039
|
-
console.
|
|
7245
|
+
console.error(" No pre-upgrade snapshot was taken for this run (snapshot is opt-in — pass --snapshot next time, or ~/.flair/data didn't exist yet).");
|
|
7246
|
+
console.error(" Check `flair snapshot list` for a manual one, or restore from a `flair backup` JSON export. See docs/upgrade.md#downgrade.");
|
|
6040
7247
|
}
|
|
7248
|
+
process.exit(1);
|
|
6041
7249
|
});
|
|
6042
7250
|
// ─── flair stop ───────────────────────────────────────────────────────────────
|
|
6043
7251
|
program
|
|
@@ -6132,9 +7340,12 @@ program
|
|
|
6132
7340
|
}
|
|
6133
7341
|
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
|
|
6134
7342
|
const opsPort = resolveOpsPort(opts);
|
|
7343
|
+
const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
|
|
6135
7344
|
const env = {
|
|
6136
7345
|
...process.env,
|
|
6137
7346
|
ROOTPATH: dataDir,
|
|
7347
|
+
// See the matching comment at the install-time spawn site above.
|
|
7348
|
+
FLAIR_MODELS_DIR: modelsDir,
|
|
6138
7349
|
DEFAULTS_MODE: "dev",
|
|
6139
7350
|
HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
|
|
6140
7351
|
HTTP_PORT: String(port),
|
|
@@ -6146,6 +7357,8 @@ program
|
|
|
6146
7357
|
if (adminPass) {
|
|
6147
7358
|
env.HDB_ADMIN_PASSWORD = adminPass;
|
|
6148
7359
|
}
|
|
7360
|
+
// models (flair#504 Phase 1): no env var needed — resources/embeddings-boot.ts
|
|
7361
|
+
// self-registers the backend in-process on every boot (flair#694).
|
|
6149
7362
|
const proc = spawn(process.execPath, [bin, "run", "."], {
|
|
6150
7363
|
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
6151
7364
|
});
|
|
@@ -6160,14 +7373,20 @@ program
|
|
|
6160
7373
|
}
|
|
6161
7374
|
});
|
|
6162
7375
|
// ─── flair restart ────────────────────────────────────────────────────────────
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
|
|
7376
|
+
/**
|
|
7377
|
+
* Stop the local Flair (Harper) process — launchd `stop` on darwin when a
|
|
7378
|
+
* plist is present (falling back on failure), otherwise a manual SIGTERM by
|
|
7379
|
+
* port. Split out of the old monolithic `restartFlair` (flair#637) so the
|
|
7380
|
+
* pre-upgrade snapshot can quiesce the data directory between a stop and a
|
|
7381
|
+
* start without duplicating this logic — `restartFlair` is now just
|
|
7382
|
+
* `stopFlairProcess` followed by `startFlairProcess`.
|
|
7383
|
+
*
|
|
7384
|
+
* Idempotent-ish: stopping an already-stopped instance is a harmless no-op
|
|
7385
|
+
* on both paths (launchctl stop on an unloaded/idle service, or an empty
|
|
7386
|
+
* `lsof` match).
|
|
7387
|
+
*/
|
|
7388
|
+
async function stopFlairProcess(port) {
|
|
7389
|
+
if (process.platform === "darwin") {
|
|
6171
7390
|
const label = "ai.tpsdev.flair";
|
|
6172
7391
|
const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
6173
7392
|
if (existsSync(plistPath)) {
|
|
@@ -6178,9 +7397,10 @@ program
|
|
|
6178
7397
|
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
6179
7398
|
}
|
|
6180
7399
|
catch { }
|
|
6181
|
-
// Capture the current PID *before* stopping so
|
|
6182
|
-
//
|
|
6183
|
-
//
|
|
7400
|
+
// Capture the current PID *before* stopping so callers that
|
|
7401
|
+
// immediately restart can verify exit. Without this, waitForHealth
|
|
7402
|
+
// can race against the still-shutting-down old process and return
|
|
7403
|
+
// success before KeepAlive brings the new one up.
|
|
6184
7404
|
const oldPid = readHarperPid(defaultDataDir());
|
|
6185
7405
|
try {
|
|
6186
7406
|
execSync(`launchctl stop ${label}`, { stdio: "pipe" });
|
|
@@ -6188,69 +7408,122 @@ program
|
|
|
6188
7408
|
catch { }
|
|
6189
7409
|
if (oldPid)
|
|
6190
7410
|
await waitForProcessExit(oldPid, STARTUP_TIMEOUT_MS);
|
|
6191
|
-
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
6192
|
-
console.log("✅ Flair restarted");
|
|
6193
7411
|
return;
|
|
6194
7412
|
}
|
|
6195
7413
|
catch (err) {
|
|
6196
|
-
console.error(`launchd
|
|
7414
|
+
console.error(`launchd stop failed, falling back to port-based stop: ${err.message}`);
|
|
6197
7415
|
}
|
|
6198
7416
|
}
|
|
6199
7417
|
}
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
process.kill(Number(pid.trim()), "SIGTERM");
|
|
6210
|
-
}
|
|
6211
|
-
catch { }
|
|
7418
|
+
// Port-based stop (Linux, or macOS fallback when no launchd plist)
|
|
7419
|
+
console.log("Stopping...");
|
|
7420
|
+
try {
|
|
7421
|
+
const { execSync } = await import("node:child_process");
|
|
7422
|
+
const lsof = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
|
|
7423
|
+
if (lsof) {
|
|
7424
|
+
for (const pid of lsof.split("\n")) {
|
|
7425
|
+
try {
|
|
7426
|
+
process.kill(Number(pid.trim()), "SIGTERM");
|
|
6212
7427
|
}
|
|
6213
|
-
|
|
6214
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
7428
|
+
catch { }
|
|
6215
7429
|
}
|
|
7430
|
+
// Wait briefly for shutdown
|
|
7431
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
6216
7432
|
}
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
6247
|
-
console.log("✅ Flair restarted");
|
|
6248
|
-
}
|
|
6249
|
-
catch {
|
|
6250
|
-
console.error("❌ Flair failed to restart within timeout");
|
|
6251
|
-
process.exit(1);
|
|
7433
|
+
}
|
|
7434
|
+
catch { /* not running */ }
|
|
7435
|
+
}
|
|
7436
|
+
/**
|
|
7437
|
+
* Start the local Flair (Harper) process — launchd `start` on darwin when a
|
|
7438
|
+
* plist is present (falling back on failure), otherwise a direct spawn.
|
|
7439
|
+
* Counterpart to `stopFlairProcess`; see that function's doc comment.
|
|
7440
|
+
*/
|
|
7441
|
+
async function startFlairProcess(port) {
|
|
7442
|
+
if (process.platform === "darwin") {
|
|
7443
|
+
const label = "ai.tpsdev.flair";
|
|
7444
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
7445
|
+
if (existsSync(plistPath)) {
|
|
7446
|
+
try {
|
|
7447
|
+
const { execSync } = await import("node:child_process");
|
|
7448
|
+
try {
|
|
7449
|
+
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
7450
|
+
}
|
|
7451
|
+
catch { }
|
|
7452
|
+
try {
|
|
7453
|
+
execSync(`launchctl start ${label}`, { stdio: "pipe" });
|
|
7454
|
+
}
|
|
7455
|
+
catch { }
|
|
7456
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
7457
|
+
return;
|
|
7458
|
+
}
|
|
7459
|
+
catch (err) {
|
|
7460
|
+
console.error(`launchd start failed, falling back to direct start: ${err.message}`);
|
|
7461
|
+
}
|
|
6252
7462
|
}
|
|
6253
7463
|
}
|
|
7464
|
+
console.log("Starting...");
|
|
7465
|
+
const bin = harperBin();
|
|
7466
|
+
if (!bin) {
|
|
7467
|
+
throw new Error("Harper binary not found. Run 'flair init' first.");
|
|
7468
|
+
}
|
|
7469
|
+
const dataDir = defaultDataDir();
|
|
7470
|
+
// Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
|
|
7471
|
+
// Without this, `flair init --admin-pass X` (which only exports HDB_*
|
|
7472
|
+
// to the initial Harper spawn) followed by `flair restart` would silently
|
|
7473
|
+
// drop admin credentials — any subsequent auth'd call returns 401.
|
|
7474
|
+
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
|
|
7475
|
+
const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
|
|
7476
|
+
const env = {
|
|
7477
|
+
...process.env,
|
|
7478
|
+
ROOTPATH: dataDir,
|
|
7479
|
+
// See the matching comment at the install-time spawn site above.
|
|
7480
|
+
FLAIR_MODELS_DIR: modelsDir,
|
|
7481
|
+
DEFAULTS_MODE: "dev",
|
|
7482
|
+
HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
|
|
7483
|
+
HTTP_PORT: String(port),
|
|
7484
|
+
LOCAL_STUDIO: "false",
|
|
7485
|
+
};
|
|
7486
|
+
if (adminPass) {
|
|
7487
|
+
env.HDB_ADMIN_PASSWORD = adminPass;
|
|
7488
|
+
}
|
|
7489
|
+
// models (flair#504 Phase 1): no env var needed — resources/embeddings-boot.ts
|
|
7490
|
+
// self-registers the backend in-process on every boot (flair#694).
|
|
7491
|
+
const proc = spawn(process.execPath, [bin, "run", "."], {
|
|
7492
|
+
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
7493
|
+
});
|
|
7494
|
+
proc.unref();
|
|
7495
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
7496
|
+
}
|
|
7497
|
+
/**
|
|
7498
|
+
* The ONE restart mechanism for a local Flair install. Shared by `flair
|
|
7499
|
+
* restart` and `flair upgrade`'s post-install restart step (flair#635) so
|
|
7500
|
+
* the two never drift into two different ways to bounce the same process.
|
|
7501
|
+
* Composed of `stopFlairProcess` + `startFlairProcess` (flair#637) — the
|
|
7502
|
+
* pre-upgrade snapshot step calls those two directly with a snapshot taken
|
|
7503
|
+
* in between, instead of going through this wrapper.
|
|
7504
|
+
*
|
|
7505
|
+
* Throws on failure instead of calling process.exit — callers decide how to
|
|
7506
|
+
* react (`flair restart` exits 1; `flair upgrade` treats a failed restart as
|
|
7507
|
+
* an upgrade failure and may attempt a rollback).
|
|
7508
|
+
*/
|
|
7509
|
+
async function restartFlair(port) {
|
|
7510
|
+
await stopFlairProcess(port);
|
|
7511
|
+
await startFlairProcess(port);
|
|
7512
|
+
}
|
|
7513
|
+
program
|
|
7514
|
+
.command("restart")
|
|
7515
|
+
.description("Restart the Flair (Harper) instance")
|
|
7516
|
+
.option("--port <port>", "Harper HTTP port")
|
|
7517
|
+
.action(async (opts) => {
|
|
7518
|
+
const port = resolveHttpPort(opts);
|
|
7519
|
+
try {
|
|
7520
|
+
await restartFlair(port);
|
|
7521
|
+
console.log("✅ Flair restarted");
|
|
7522
|
+
}
|
|
7523
|
+
catch (err) {
|
|
7524
|
+
console.error(`❌ Flair failed to restart: ${err?.message ?? err}`);
|
|
7525
|
+
process.exit(1);
|
|
7526
|
+
}
|
|
6254
7527
|
});
|
|
6255
7528
|
// ─── flair uninstall ──────────────────────────────────────────────────────────
|
|
6256
7529
|
program
|
|
@@ -6345,7 +7618,28 @@ program
|
|
|
6345
7618
|
const dryRun = opts.dryRun ?? false;
|
|
6346
7619
|
const batchSize = Number(opts.batchSize);
|
|
6347
7620
|
const delayMs = Number(opts.delayMs);
|
|
6348
|
-
|
|
7621
|
+
// flair#504 Phase 2: MUST match resources/embeddings-provider.ts's
|
|
7622
|
+
// getModelId() — including THE GATE (EMBEDDING_PREFIXES_ENABLED), not
|
|
7623
|
+
// just the suffix. Duplicated as literals, not imported, because
|
|
7624
|
+
// src/cli.ts and resources/**.ts are separate build targets —
|
|
7625
|
+
// tsconfig.cli.json's rootDir is "src" and only includes src/cli.ts +
|
|
7626
|
+
// src/cli-shim.cts, and the published CLI package ships only dist/ built
|
|
7627
|
+
// from that config (package.json's "files"), so resources/ isn't
|
|
7628
|
+
// reachable from (or bundled into) the CLI binary. THE GATE is now ON
|
|
7629
|
+
// (flipped, re-baselined through the ratchet gate — see
|
|
7630
|
+
// embeddings-provider.ts's file header and PR #689 for the park history
|
|
7631
|
+
// this flip revisits), so `currentModel` here is `<base>+searchprefix` —
|
|
7632
|
+
// matching getModelId()'s gate-on return exactly. If
|
|
7633
|
+
// EMBEDDING_PREFIXES_ENABLED or EMBEDDING_VARIANT ever changes in
|
|
7634
|
+
// embeddings-provider.ts, update this block too — a drift here silently
|
|
7635
|
+
// breaks `--stale-only`: it would compare every row's embeddingModel
|
|
7636
|
+
// against the WRONG current-model string, so rows would read as already
|
|
7637
|
+
// "current" (or as needing re-embed) out of sync with what getModelId()
|
|
7638
|
+
// is actually stamping new writes with.
|
|
7639
|
+
const EMBEDDING_PREFIXES_ENABLED = true; // MUST mirror resources/embeddings-provider.ts's gate
|
|
7640
|
+
const EMBEDDING_VARIANT = "searchprefix";
|
|
7641
|
+
const baseModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
|
|
7642
|
+
const currentModel = EMBEDDING_PREFIXES_ENABLED ? `${baseModel}+${EMBEDDING_VARIANT}` : baseModel;
|
|
6349
7643
|
if (agentId) {
|
|
6350
7644
|
console.log(`Re-embedding memories for agent: ${agentId}`);
|
|
6351
7645
|
}
|
|
@@ -6638,8 +7932,9 @@ program
|
|
|
6638
7932
|
.description("Deploy Flair as a component to a remote Harper Fabric cluster")
|
|
6639
7933
|
.option("--fabric-org <org>", "Fabric org (env: FABRIC_ORG)")
|
|
6640
7934
|
.option("--fabric-cluster <cluster>", "Fabric cluster within the org (env: FABRIC_CLUSTER)")
|
|
6641
|
-
.option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER)")
|
|
6642
|
-
.option("--fabric-password <pass>", "Fabric admin password (env
|
|
7935
|
+
.option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER preferred; inline leaks to shell history)")
|
|
7936
|
+
.option("--fabric-password <pass>", "Fabric admin password (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
|
|
7937
|
+
.option("--fabric-password-file <path>", "Read the Fabric admin password from a file (chmod 600)")
|
|
6643
7938
|
.option("--fabric-token <token>", "OAuth bearer token (env: FABRIC_TOKEN) — reserved for future Fabric bearer support")
|
|
6644
7939
|
.option("--target <url>", "Override the Fabric URL template (https://<cluster>.<org>.harperfabric.com)")
|
|
6645
7940
|
.option("--project <name>", "Component name in Fabric", "flair")
|
|
@@ -6653,15 +7948,29 @@ program
|
|
|
6653
7948
|
.option("--no-verify", "Skip post-deploy served-API verification (default: verify — on by design, so the CLI can't report success on an empty/broken deploy)")
|
|
6654
7949
|
.option("--verify-timeout <ms>", "Milliseconds to wait for the served API to settle after harper's post-deploy restart before verifying (default: 300000)")
|
|
6655
7950
|
.option("--verify-resource <name>", "Resource to verify is serving after deploy (repeatable; default: derived from the deployed package's dist/resources)", (val, prev) => [...prev, val], [])
|
|
7951
|
+
.option("--deploy-retries <n>", "Retry the full harper deploy this many times on a detected flaky peer-replication failure ONLY — a normal deploy failure (auth, bad package, ...) never retries (default: 2; 0 disables)", "2")
|
|
7952
|
+
.option("--ignore-replication-errors", "If peer replication is still failing once retries are exhausted, treat it as a non-fatal warning and succeed with an origin-only deploy (the peer catches up via federation sync or a later deploy)")
|
|
7953
|
+
.option("--no-fleet-verify", "Skip the automatic post-deploy fleet convergence sweep (default: sweep runs — see flair#636)")
|
|
6656
7954
|
.action(async (opts) => {
|
|
6657
7955
|
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
6658
7956
|
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
7957
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
6659
7958
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
7959
|
+
let fabricUser;
|
|
7960
|
+
let fabricPassword;
|
|
7961
|
+
let credWarnings = [];
|
|
7962
|
+
try {
|
|
7963
|
+
({ fabricUser, fabricPassword, warnings: credWarnings } = resolveFabricCredentials(opts));
|
|
7964
|
+
}
|
|
7965
|
+
catch (err) {
|
|
7966
|
+
console.error(red(`Error: ${err.message}`));
|
|
7967
|
+
process.exit(1);
|
|
7968
|
+
}
|
|
6660
7969
|
const deployOpts = {
|
|
6661
7970
|
fabricOrg: opts.fabricOrg ?? process.env.FABRIC_ORG,
|
|
6662
7971
|
fabricCluster: opts.fabricCluster ?? process.env.FABRIC_CLUSTER,
|
|
6663
|
-
fabricUser
|
|
6664
|
-
fabricPassword
|
|
7972
|
+
fabricUser,
|
|
7973
|
+
fabricPassword,
|
|
6665
7974
|
fabricToken: opts.fabricToken ?? process.env.FABRIC_TOKEN,
|
|
6666
7975
|
target: opts.target,
|
|
6667
7976
|
project: opts.project,
|
|
@@ -6675,6 +7984,8 @@ program
|
|
|
6675
7984
|
verify: opts.verify !== false,
|
|
6676
7985
|
verifyResources: opts.verifyResource?.length ? opts.verifyResource : undefined,
|
|
6677
7986
|
verifyTimeoutMs: Number(opts.verifyTimeout ?? 300_000),
|
|
7987
|
+
deployRetries: Number(opts.deployRetries ?? 2),
|
|
7988
|
+
ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
|
|
6678
7989
|
onProgress: (msg) => console.log(dim(` ${msg}`)),
|
|
6679
7990
|
};
|
|
6680
7991
|
const errors = validateDeployOptions(deployOpts);
|
|
@@ -6684,11 +7995,10 @@ program
|
|
|
6684
7995
|
console.error(` - ${e}`);
|
|
6685
7996
|
process.exit(1);
|
|
6686
7997
|
}
|
|
6687
|
-
//
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
}
|
|
7998
|
+
// Never log the credential VALUES — only the flag names, via the
|
|
7999
|
+
// resolver's own warning strings (see resolveFabricCredentials above).
|
|
8000
|
+
for (const w of credWarnings)
|
|
8001
|
+
console.error(dim(w));
|
|
6692
8002
|
const url = buildDeployUrl(deployOpts);
|
|
6693
8003
|
console.log(`${green("→")} Deploying ${deployOpts.project} to ${url}`);
|
|
6694
8004
|
if (deployOpts.dryRun)
|
|
@@ -6700,9 +8010,43 @@ program
|
|
|
6700
8010
|
console.log(dim(` package root: ${result.packageRoot}`));
|
|
6701
8011
|
return;
|
|
6702
8012
|
}
|
|
6703
|
-
|
|
8013
|
+
if (result.replicationWarning) {
|
|
8014
|
+
console.log(`\n${yellow("⚠")} Flair ${result.version} deployed to the ORIGIN NODE ONLY — peer replication did not complete (see warning above). The peer will catch up via federation sync or a later deploy.`);
|
|
8015
|
+
}
|
|
8016
|
+
else {
|
|
8017
|
+
console.log(`\n${green("✓")} Flair ${result.version} deployed${deployOpts.verify ? " and verified serving" : ""}`);
|
|
8018
|
+
}
|
|
6704
8019
|
console.log(`\n URL: ${result.url}`);
|
|
6705
8020
|
console.log(` Project: ${result.project}`);
|
|
8021
|
+
// ── Post-deploy fleet sweep (flair#636) ─────────────────────────────
|
|
8022
|
+
// Harper's own "Successfully deployed" (and the served-API verify
|
|
8023
|
+
// above) only confirm the ORIGIN. This sweeps the origin + every known
|
|
8024
|
+
// federation peer for actual version/health convergence — the gap that
|
|
8025
|
+
// let the 0.21.0 deploy report success while a peer was still throwing
|
|
8026
|
+
// 1006s. Skippable with --no-fleet-verify. Needs Basic-auth creds
|
|
8027
|
+
// (fabricUser+fabricPassword) — a --fabric-token-only deploy has no way
|
|
8028
|
+
// to authenticate the sweep, so it's skipped with a note instead of a
|
|
8029
|
+
// silent no-op.
|
|
8030
|
+
if (!shouldRunFleetVerify(opts)) {
|
|
8031
|
+
console.log(dim("\n(--no-fleet-verify: skipping post-deploy fleet sweep)"));
|
|
8032
|
+
}
|
|
8033
|
+
else if (!deployOpts.fabricUser || !deployOpts.fabricPassword) {
|
|
8034
|
+
console.log(dim("\n(skipping fleet verify — no --fabric-user/--fabric-password to authenticate the sweep; only --fabric-token was provided)"));
|
|
8035
|
+
}
|
|
8036
|
+
else {
|
|
8037
|
+
console.log(`\n${green("→")} Fleet verify`);
|
|
8038
|
+
const sweep = await sweepFleet({
|
|
8039
|
+
target: result.url,
|
|
8040
|
+
fabricUser: deployOpts.fabricUser,
|
|
8041
|
+
fabricPassword: deployOpts.fabricPassword,
|
|
8042
|
+
expectVersion: result.version,
|
|
8043
|
+
});
|
|
8044
|
+
console.log(renderFleetSweepTable(sweep));
|
|
8045
|
+
if (sweep.exitCode !== FLEET_EXIT_OK) {
|
|
8046
|
+
console.error(red(`\n✗ fleet verify failed (exit ${sweep.exitCode}) — deploy is NOT fully converged.`));
|
|
8047
|
+
process.exit(sweep.exitCode);
|
|
8048
|
+
}
|
|
8049
|
+
}
|
|
6706
8050
|
console.log(`\nNext steps:`);
|
|
6707
8051
|
console.log(dim(` 1. Set an admin password in Fabric Studio (Cluster Settings → Admin)`));
|
|
6708
8052
|
console.log(dim(` 2. Seed your first agent:`));
|
|
@@ -6720,9 +8064,73 @@ program
|
|
|
6720
8064
|
if (hint?.includes("did not settle")) {
|
|
6721
8065
|
console.error(dim(" hint: Harper may still be restarting — check Fabric Studio, or retry with a longer --verify-timeout"));
|
|
6722
8066
|
}
|
|
8067
|
+
if (hint?.includes("peer replication failed after")) {
|
|
8068
|
+
console.error(dim(" hint: pass --ignore-replication-errors to accept an origin-only deploy, or re-run once the peer link recovers"));
|
|
8069
|
+
}
|
|
6723
8070
|
process.exit(1);
|
|
6724
8071
|
}
|
|
6725
8072
|
});
|
|
8073
|
+
// ─── flair fleet ──────────────────────────────────────────────────────────────
|
|
8074
|
+
//
|
|
8075
|
+
// Fabric fleet operations (flair#636). `flair deploy` / `flair upgrade
|
|
8076
|
+
// --target` already run this sweep automatically post-deploy (skippable
|
|
8077
|
+
// with --no-fleet-verify) — this is the standalone entry point for running
|
|
8078
|
+
// it independently, e.g. as a periodic health check or before a rolling
|
|
8079
|
+
// restart step (see the flair#636 decision comment: this sweep is the gate
|
|
8080
|
+
// between peers during a rolling restart, not the restart mechanism itself).
|
|
8081
|
+
const fleet = program.command("fleet").description("Fabric fleet operations (post-deploy convergence verification)");
|
|
8082
|
+
fleet
|
|
8083
|
+
.command("verify")
|
|
8084
|
+
.description("Sweep a Fabric origin + its known federation peers for version/health convergence")
|
|
8085
|
+
.requiredOption("--target <url>", "Fabric URL to verify (the origin node)")
|
|
8086
|
+
.option("--fabric-user <user>", "Fabric admin username (env: FABRIC_USER — preferred; inline leaks to ps/shell history)")
|
|
8087
|
+
.option("--fabric-password <pass>", "Fabric admin password (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
|
|
8088
|
+
.option("--fabric-password-file <path>", "Read the Fabric admin password from a mode-0600 file (keeps it out of argv and env)")
|
|
8089
|
+
.option("--expect-version <semver>", "Version every node must report (default: the origin's own reported version — a self-consistency check)")
|
|
8090
|
+
.option("--timeout <ms>", "Per-node /Health poll timeout in ms", "60000")
|
|
8091
|
+
.option("--json", "Emit JSON (also: pipe + FLAIR_OUTPUT=json)")
|
|
8092
|
+
.addHelpText("after", `
|
|
8093
|
+
Exit codes:
|
|
8094
|
+
0 all nodes verified: healthy, authenticated, and version-matched
|
|
8095
|
+
1 origin failed (unreachable, unauthenticated, or wrong version)
|
|
8096
|
+
2 origin OK, but a reachable peer is running a DIFFERENT version (skew)
|
|
8097
|
+
3 origin OK, no skew among reachable peers, but a peer could not be
|
|
8098
|
+
verified at all (unreachable, auth rejected, or no endpoint on file)
|
|
8099
|
+
|
|
8100
|
+
"peer" here means a Flair federation peer (GET /FederationPeers on the
|
|
8101
|
+
origin) — NOT Harper's own cluster-replication nodes, which the OSS
|
|
8102
|
+
@harperfast/harper build this CLI ships does not expose (cluster_status is
|
|
8103
|
+
a harper-pro-only operation). A Fabric replica that was never
|
|
8104
|
+
federation-paired (\`flair federation pair\`) is invisible to this sweep —
|
|
8105
|
+
see src/fleet-verify.ts's file header for the full caveat.`)
|
|
8106
|
+
.action(async (opts) => {
|
|
8107
|
+
// Single source of truth for cred resolution + shell-history warnings,
|
|
8108
|
+
// shared with `flair upgrade --target` and `flair deploy`.
|
|
8109
|
+
const { fabricUser, fabricPassword, warnings } = resolveFabricCredentials(opts);
|
|
8110
|
+
for (const w of warnings)
|
|
8111
|
+
console.error(render.wrap(render.c.dim, w));
|
|
8112
|
+
if (!fabricUser || !fabricPassword) {
|
|
8113
|
+
console.error(render.wrap(render.c.red, "flair fleet verify: credentials required"));
|
|
8114
|
+
console.error(" set FABRIC_USER + FABRIC_PASSWORD env, or --fabric-password-file, or (discouraged) --fabric-user/--fabric-password inline");
|
|
8115
|
+
process.exit(1);
|
|
8116
|
+
}
|
|
8117
|
+
const result = await sweepFleet({
|
|
8118
|
+
target: opts.target,
|
|
8119
|
+
fabricUser,
|
|
8120
|
+
fabricPassword,
|
|
8121
|
+
expectVersion: opts.expectVersion,
|
|
8122
|
+
timeoutMs: Number(opts.timeout ?? 60_000),
|
|
8123
|
+
});
|
|
8124
|
+
const mode = render.resolveOutputMode(opts);
|
|
8125
|
+
if (mode === "json") {
|
|
8126
|
+
console.log(render.asJSON(result));
|
|
8127
|
+
}
|
|
8128
|
+
else {
|
|
8129
|
+
console.log(render.wrap(render.c.bold, `Fleet verify — ${result.target}`));
|
|
8130
|
+
console.log(renderFleetSweepTable(result));
|
|
8131
|
+
}
|
|
8132
|
+
process.exit(result.exitCode);
|
|
8133
|
+
});
|
|
6726
8134
|
// ─── flair doctor ─────────────────────────────────────────────────────────────
|
|
6727
8135
|
program
|
|
6728
8136
|
.command("doctor")
|
|
@@ -6743,6 +8151,24 @@ program
|
|
|
6743
8151
|
let issues = 0;
|
|
6744
8152
|
let harperResponding = false;
|
|
6745
8153
|
console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
|
|
8154
|
+
// 0. Version check (flair#587) — offline-tolerant + cached, independent
|
|
8155
|
+
// of Harper being up. A gap of ≥2 minor versions (or any major) is
|
|
8156
|
+
// treated as loud/red — heuristic for "likely missed a security fix"
|
|
8157
|
+
// since we don't have advisory data, only the version gap. A red gap
|
|
8158
|
+
// counts as an issue (exit 1); a quieter yellow gap (one minor, or
|
|
8159
|
+
// patch-only) is printed but doesn't fail doctor.
|
|
8160
|
+
const versionCheckResult = await checkVersion(__pkgVersion);
|
|
8161
|
+
const versionNudge = formatVersionNudge(versionCheckResult);
|
|
8162
|
+
if (versionNudge) {
|
|
8163
|
+
const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
|
|
8164
|
+
const icon = versionNudge.severity === "red" ? render.wrap(render.c.red, "✗") : render.icons.warn;
|
|
8165
|
+
console.log(` ${icon} ${render.wrap(color, versionNudge.message)}`);
|
|
8166
|
+
if (versionNudge.severity === "red")
|
|
8167
|
+
issues++;
|
|
8168
|
+
}
|
|
8169
|
+
else if (versionCheckResult.latest) {
|
|
8170
|
+
console.log(` ${render.icons.ok} flair ${__pkgVersion} is current`);
|
|
8171
|
+
}
|
|
6746
8172
|
// Helper: try to reach Harper on a given port
|
|
6747
8173
|
async function probePort(p) {
|
|
6748
8174
|
try {
|
|
@@ -6864,6 +8290,53 @@ program
|
|
|
6864
8290
|
issues++;
|
|
6865
8291
|
}
|
|
6866
8292
|
}
|
|
8293
|
+
// 1a. CLI ↔ running-server version handshake (flair#695 §B) — the
|
|
8294
|
+
// version TRIPLE: this CLI's own version (__pkgVersion, checked against
|
|
8295
|
+
// npm-latest in step 0 above), and the RUNNING server's reported
|
|
8296
|
+
// version (GET /Health — public, no auth needed). A mismatch means the
|
|
8297
|
+
// installed package was upgraded but the daemon hasn't restarted onto
|
|
8298
|
+
// it yet — exactly the bare-npm trap the global preAction hook (above,
|
|
8299
|
+
// every other command) nudges about on stderr; doctor prints the full
|
|
8300
|
+
// picture here instead of a one-liner and `--fix` offers the restart.
|
|
8301
|
+
let runningVersion = null;
|
|
8302
|
+
if (harperResponding) {
|
|
8303
|
+
try {
|
|
8304
|
+
const healthRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(3000) });
|
|
8305
|
+
if (healthRes.ok) {
|
|
8306
|
+
const body = (await healthRes.json());
|
|
8307
|
+
runningVersion = typeof body?.version === "string" ? body.version : null;
|
|
8308
|
+
}
|
|
8309
|
+
}
|
|
8310
|
+
catch { /* leave runningVersion null — reported below as "unknown" */ }
|
|
8311
|
+
if (runningVersion && runningVersion !== __pkgVersion) {
|
|
8312
|
+
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)}`);
|
|
8313
|
+
if (autoFix) {
|
|
8314
|
+
if (dryRun) {
|
|
8315
|
+
console.log(` ${render.wrap(render.c.dim, "Would run:")} flair restart`);
|
|
8316
|
+
}
|
|
8317
|
+
else {
|
|
8318
|
+
try {
|
|
8319
|
+
const { execSync } = await import("node:child_process");
|
|
8320
|
+
execSync(`${process.argv[0]} ${process.argv[1]} restart --port ${effectivePort}`, { stdio: "inherit" });
|
|
8321
|
+
console.log(` ${render.icons.ok} Restarted onto ${__pkgVersion}`);
|
|
8322
|
+
}
|
|
8323
|
+
catch {
|
|
8324
|
+
console.log(` ${render.icons.error} Restart failed — try: flair restart`);
|
|
8325
|
+
}
|
|
8326
|
+
}
|
|
8327
|
+
}
|
|
8328
|
+
else {
|
|
8329
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair restart`);
|
|
8330
|
+
}
|
|
8331
|
+
issues++;
|
|
8332
|
+
}
|
|
8333
|
+
else if (runningVersion) {
|
|
8334
|
+
console.log(` ${render.icons.ok} Server running version matches CLI (${runningVersion})`);
|
|
8335
|
+
}
|
|
8336
|
+
else {
|
|
8337
|
+
console.log(` ${render.icons.warn} Could not determine the running server's version`);
|
|
8338
|
+
}
|
|
8339
|
+
}
|
|
6867
8340
|
// 2. Keys directory
|
|
6868
8341
|
const keysDir = defaultKeysDir();
|
|
6869
8342
|
if (existsSync(keysDir)) {
|
|
@@ -6969,6 +8442,290 @@ program
|
|
|
6969
8442
|
issues++;
|
|
6970
8443
|
}
|
|
6971
8444
|
}
|
|
8445
|
+
// 7. Client integration (flair#588) — the first 6 checks diagnose the
|
|
8446
|
+
// SERVER side. This diagnoses whether Flair is actually wired to a real
|
|
8447
|
+
// MCP client (Claude Code, Codex, Gemini, Cursor): the MCP block present
|
|
8448
|
+
// + reachable + the configured agent genuinely registered (every detected
|
|
8449
|
+
// client), plus CLAUDE.md + the SessionStart hook (Claude Code only,
|
|
8450
|
+
// since only Claude Code has those mechanisms). Reuses detectClients()
|
|
8451
|
+
// rather than reimplementing client detection.
|
|
8452
|
+
console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
|
|
8453
|
+
// Prompt y/N before a content-editing fix, but only when interactive —
|
|
8454
|
+
// in a non-TTY context (CI, scripts) --fix itself is the consent signal,
|
|
8455
|
+
// matching how doctor's other --fix branches already behave unprompted.
|
|
8456
|
+
// Mirrors the confirm pattern at `flair fabric upgrade` (~line 6258).
|
|
8457
|
+
async function confirmFix(question) {
|
|
8458
|
+
if (!process.stdin.isTTY)
|
|
8459
|
+
return true;
|
|
8460
|
+
const { createInterface } = await import("node:readline");
|
|
8461
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
8462
|
+
const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
|
|
8463
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
8464
|
+
}
|
|
8465
|
+
const detectedClients = detectClients().filter((c) => c.detected);
|
|
8466
|
+
if (detectedClients.length === 0) {
|
|
8467
|
+
console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
|
|
8468
|
+
}
|
|
8469
|
+
else {
|
|
8470
|
+
let claudeCodeAgentId;
|
|
8471
|
+
let anyKnownAgentId;
|
|
8472
|
+
for (const client of detectedClients) {
|
|
8473
|
+
const block = readClientMcpBlock(client.id, homedir());
|
|
8474
|
+
if (client.id === "claude-code" && block.agentId)
|
|
8475
|
+
claudeCodeAgentId = block.agentId;
|
|
8476
|
+
if (block.agentId)
|
|
8477
|
+
anyKnownAgentId = anyKnownAgentId ?? block.agentId;
|
|
8478
|
+
if (!block.present) {
|
|
8479
|
+
console.log(` ${render.icons.error} ${client.label}: no Flair MCP server configured in ${render.wrap(render.c.dim, block.configPath)}`);
|
|
8480
|
+
if (autoFix) {
|
|
8481
|
+
if (dryRun) {
|
|
8482
|
+
console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
|
|
8483
|
+
}
|
|
8484
|
+
else {
|
|
8485
|
+
const proceed = await confirmFix(` Wire ${client.label} now? [y/N] `);
|
|
8486
|
+
if (!proceed) {
|
|
8487
|
+
console.log(` Skipped.`);
|
|
8488
|
+
}
|
|
8489
|
+
else {
|
|
8490
|
+
const fixAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
|
|
8491
|
+
if (!fixAgentId) {
|
|
8492
|
+
console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent id known — pass --agent <id>`);
|
|
8493
|
+
}
|
|
8494
|
+
else {
|
|
8495
|
+
const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: block.flairUrl || baseUrl };
|
|
8496
|
+
const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
|
|
8497
|
+
client.id === "codex" ? wireCodex(wireEnv) :
|
|
8498
|
+
client.id === "gemini" ? wireGemini(wireEnv) :
|
|
8499
|
+
wireCursor(wireEnv);
|
|
8500
|
+
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
8501
|
+
}
|
|
8502
|
+
}
|
|
8503
|
+
}
|
|
8504
|
+
}
|
|
8505
|
+
else {
|
|
8506
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
|
|
8507
|
+
}
|
|
8508
|
+
issues++;
|
|
8509
|
+
continue;
|
|
8510
|
+
}
|
|
8511
|
+
console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
|
|
8512
|
+
const reachable = await probeFlairReachable(block.flairUrl);
|
|
8513
|
+
if (!reachable) {
|
|
8514
|
+
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} not reachable — cannot verify agent registration`);
|
|
8515
|
+
continue;
|
|
8516
|
+
}
|
|
8517
|
+
console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} reachable`);
|
|
8518
|
+
const reg = await checkAgentRegistered(block.flairUrl, block.agentId, defaultKeysDir());
|
|
8519
|
+
if (reg.state === "registered") {
|
|
8520
|
+
console.log(` ${render.icons.ok} agent '${block.agentId}' registered`);
|
|
8521
|
+
}
|
|
8522
|
+
else if (reg.state === "not-registered") {
|
|
8523
|
+
console.log(` ${render.icons.error} agent '${block.agentId}' is NOT registered on this Flair instance`);
|
|
8524
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${block.agentId}`);
|
|
8525
|
+
issues++;
|
|
8526
|
+
}
|
|
8527
|
+
else {
|
|
8528
|
+
console.log(` ${render.icons.warn} could not verify agent registration ${render.wrap(render.c.dim, `(${reg.detail})`)}`);
|
|
8529
|
+
}
|
|
8530
|
+
}
|
|
8531
|
+
// Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
|
|
8532
|
+
// has these mechanisms, so only run them when claude-code was detected.
|
|
8533
|
+
if (detectedClients.some((c) => c.id === "claude-code")) {
|
|
8534
|
+
const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
|
|
8535
|
+
if (claudeMd.present) {
|
|
8536
|
+
console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
|
|
8537
|
+
}
|
|
8538
|
+
else {
|
|
8539
|
+
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"))})`);
|
|
8540
|
+
if (autoFix) {
|
|
8541
|
+
if (dryRun) {
|
|
8542
|
+
console.log(` ${render.wrap(render.c.dim, "Would append bootstrap instruction to")} ${join(process.cwd(), "CLAUDE.md")}`);
|
|
8543
|
+
}
|
|
8544
|
+
else {
|
|
8545
|
+
const proceed = await confirmFix(` Add the Flair bootstrap line to ./CLAUDE.md? [y/N] `);
|
|
8546
|
+
if (!proceed) {
|
|
8547
|
+
console.log(` Skipped.`);
|
|
8548
|
+
}
|
|
8549
|
+
else {
|
|
8550
|
+
const fixRes = fixClaudeMdBootstrap(process.cwd());
|
|
8551
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
8552
|
+
}
|
|
8553
|
+
}
|
|
8554
|
+
}
|
|
8555
|
+
else {
|
|
8556
|
+
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)")}`);
|
|
8557
|
+
}
|
|
8558
|
+
issues++;
|
|
8559
|
+
}
|
|
8560
|
+
const hook = checkSessionStartHook(homedir());
|
|
8561
|
+
if (hook.present) {
|
|
8562
|
+
console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)}`);
|
|
8563
|
+
}
|
|
8564
|
+
else {
|
|
8565
|
+
console.log(` ${render.icons.error} SessionStart hook: not found in ${render.wrap(render.c.dim, hook.path)}`);
|
|
8566
|
+
if (autoFix) {
|
|
8567
|
+
if (dryRun) {
|
|
8568
|
+
console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
|
|
8569
|
+
}
|
|
8570
|
+
else {
|
|
8571
|
+
const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
|
|
8572
|
+
if (!proceed) {
|
|
8573
|
+
console.log(` Skipped.`);
|
|
8574
|
+
}
|
|
8575
|
+
else {
|
|
8576
|
+
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
8577
|
+
const fixRes = fixSessionStartHook(homedir(), fixAgentId);
|
|
8578
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
8579
|
+
}
|
|
8580
|
+
}
|
|
8581
|
+
}
|
|
8582
|
+
else {
|
|
8583
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
|
|
8584
|
+
}
|
|
8585
|
+
issues++;
|
|
8586
|
+
}
|
|
8587
|
+
}
|
|
8588
|
+
}
|
|
8589
|
+
// 8. Fleet presence (flair#639) — known instances via /Presence heartbeats.
|
|
8590
|
+
//
|
|
8591
|
+
// "Instance" here means each AGENT's heartbeat row — Presence is keyed by
|
|
8592
|
+
// agentId (schemas/schema.graphql), not by Flair server — so several rows
|
|
8593
|
+
// can (and typically will) share one flairVersion/harperVersion whenever
|
|
8594
|
+
// several agents heartbeat through the same Flair. That's still the
|
|
8595
|
+
// useful fleet signal: an outlier version on one row means THAT agent's
|
|
8596
|
+
// serving instance is behind the rest.
|
|
8597
|
+
//
|
|
8598
|
+
// SCOPE, verified against runFederationSyncOnce's own table list a few
|
|
8599
|
+
// hundred lines up (`const tables = ["Memory", "Soul", "Agent",
|
|
8600
|
+
// "Relationship"]`): Presence is NOT one of the tables federation sync
|
|
8601
|
+
// replicates. So this section reports only what THIS instance's own
|
|
8602
|
+
// Presence table has recorded — every agent whose FLAIR_URL points
|
|
8603
|
+
// directly at the Flair `doctor` is talking to. On a hub+spokes
|
|
8604
|
+
// deployment where each spoke runs its own separate Flair database, a
|
|
8605
|
+
// spoke's locally-recorded heartbeats are invisible from the hub's
|
|
8606
|
+
// `doctor` unless those agents also heartbeat straight to the hub. Not
|
|
8607
|
+
// fixed here — flair#639's fix list is version-stamping + a doctor
|
|
8608
|
+
// listing, not widening federation sync scope.
|
|
8609
|
+
if (harperResponding) {
|
|
8610
|
+
console.log(`\n ${render.wrap(render.c.bold, "Fleet presence")}`);
|
|
8611
|
+
try {
|
|
8612
|
+
// flairVersion/harperVersion are gated to verified readers on the
|
|
8613
|
+
// server (resources/Presence.ts, same boundary as currentTask) — sign
|
|
8614
|
+
// the GET when we have an agent + key so the fields aren't silently
|
|
8615
|
+
// nulled out from under us.
|
|
8616
|
+
const fleetAgentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
8617
|
+
const fleetKeyPath = fleetAgentId ? join(defaultKeysDir(), `${fleetAgentId}.key`) : undefined;
|
|
8618
|
+
const canSign = !!(fleetAgentId && fleetKeyPath && existsSync(fleetKeyPath));
|
|
8619
|
+
const headers = canSign
|
|
8620
|
+
? { Authorization: buildEd25519Auth(fleetAgentId, "GET", "/Presence", fleetKeyPath) }
|
|
8621
|
+
: {};
|
|
8622
|
+
const presRes = await fetch(`${baseUrl}/Presence`, { headers, signal: AbortSignal.timeout(5000) });
|
|
8623
|
+
if (!presRes.ok) {
|
|
8624
|
+
console.log(` ${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
|
|
8625
|
+
}
|
|
8626
|
+
else {
|
|
8627
|
+
const roster = (await presRes.json());
|
|
8628
|
+
if (!Array.isArray(roster) || roster.length === 0) {
|
|
8629
|
+
console.log(` ${render.icons.info} No known instances yet — no /Presence heartbeats recorded on this instance`);
|
|
8630
|
+
}
|
|
8631
|
+
else {
|
|
8632
|
+
const rows = sortOldestVersionFirst(markStale(roster));
|
|
8633
|
+
for (const row of rows) {
|
|
8634
|
+
const lastSeen = typeof row.lastHeartbeatAt === "number"
|
|
8635
|
+
? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
|
|
8636
|
+
: "—";
|
|
8637
|
+
const versionLabel = !canSign
|
|
8638
|
+
? render.wrap(render.c.dim, "hidden")
|
|
8639
|
+
: row.flairVersion
|
|
8640
|
+
? `v${row.flairVersion}`
|
|
8641
|
+
: render.wrap(render.c.dim, "no version reported");
|
|
8642
|
+
const staleNote = row.stale && row.newestVersion
|
|
8643
|
+
? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
|
|
8644
|
+
: "";
|
|
8645
|
+
const icon = row.stale ? render.icons.warn : render.icons.ok;
|
|
8646
|
+
const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
|
|
8647
|
+
// Natural-presence: same staleness principle as the version
|
|
8648
|
+
// column — a live activity is shown as current, a decayed one as
|
|
8649
|
+
// "last-known". `activityFresh === false` (server verdict) plus a
|
|
8650
|
+
// known lastActivity → "(was: X)"; a fresh, non-idle activity →
|
|
8651
|
+
// "(X)". Skip entirely when there's nothing informative to say
|
|
8652
|
+
// (no signal, or idle) so the line stays quiet for the common case.
|
|
8653
|
+
const lastActivity = row.lastActivity ?? row.activity;
|
|
8654
|
+
const activityNote = row.activityFresh === false
|
|
8655
|
+
? (lastActivity && lastActivity !== "idle"
|
|
8656
|
+
? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
|
|
8657
|
+
: "")
|
|
8658
|
+
: (row.activity && row.activity !== "idle"
|
|
8659
|
+
? " " + render.wrap(render.c.dim, `(${row.activity})`)
|
|
8660
|
+
: "");
|
|
8661
|
+
console.log(` ${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
|
|
8662
|
+
}
|
|
8663
|
+
if (!canSign) {
|
|
8664
|
+
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.")}`);
|
|
8665
|
+
}
|
|
8666
|
+
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.")}`);
|
|
8667
|
+
}
|
|
8668
|
+
}
|
|
8669
|
+
}
|
|
8670
|
+
catch (err) {
|
|
8671
|
+
console.log(` ${render.icons.warn} Fleet presence check failed: ${err?.message ?? err}`);
|
|
8672
|
+
}
|
|
8673
|
+
}
|
|
8674
|
+
// 9. Migration state (flair#695) — pending/in-progress/blocked + last
|
|
8675
|
+
// ledger-derived outcome per registered migration, read off the same
|
|
8676
|
+
// authenticated /HealthDetail the "Fleet presence" section above
|
|
8677
|
+
// already fetches. `--fix` here means the SAME restart offered in step
|
|
8678
|
+
// 1a above (a halted migration retries automatically on the next boot —
|
|
8679
|
+
// there's no separate "run the migration now" fix; the fix for
|
|
8680
|
+
// "blocked" is whatever the halt reason names, e.g. freeing disk).
|
|
8681
|
+
if (harperResponding) {
|
|
8682
|
+
console.log(`\n ${render.wrap(render.c.bold, "Migrations")}`);
|
|
8683
|
+
try {
|
|
8684
|
+
const migAgentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
8685
|
+
const migKeyPath = migAgentId ? join(defaultKeysDir(), `${migAgentId}.key`) : undefined;
|
|
8686
|
+
const migCanSign = !!(migAgentId && migKeyPath && existsSync(migKeyPath));
|
|
8687
|
+
if (!migCanSign) {
|
|
8688
|
+
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.`);
|
|
8689
|
+
}
|
|
8690
|
+
else {
|
|
8691
|
+
const migHeaders = { Authorization: buildEd25519Auth(migAgentId, "GET", "/HealthDetail", migKeyPath) };
|
|
8692
|
+
const migRes = await fetch(`${baseUrl}/HealthDetail`, { headers: migHeaders, signal: AbortSignal.timeout(5000) });
|
|
8693
|
+
if (!migRes.ok) {
|
|
8694
|
+
console.log(` ${render.icons.warn} Could not fetch migration state (HTTP ${migRes.status})`);
|
|
8695
|
+
}
|
|
8696
|
+
else {
|
|
8697
|
+
const detail = (await migRes.json());
|
|
8698
|
+
const migBlock = detail?.migrations;
|
|
8699
|
+
if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
|
|
8700
|
+
console.log(` ${render.icons.info} No migrations registered on this instance`);
|
|
8701
|
+
}
|
|
8702
|
+
else {
|
|
8703
|
+
if (migBlock.cyclePhase === "pre-hash") {
|
|
8704
|
+
console.log(` ${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
|
|
8705
|
+
}
|
|
8706
|
+
for (const m of migBlock.migrations) {
|
|
8707
|
+
if (m.state === "completed") {
|
|
8708
|
+
console.log(` ${render.icons.ok} ${m.id}: completed`);
|
|
8709
|
+
}
|
|
8710
|
+
else if (m.state === "halted" || m.state === "failed") {
|
|
8711
|
+
console.log(` ${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
|
|
8712
|
+
issues++;
|
|
8713
|
+
}
|
|
8714
|
+
else if (m.state === "running") {
|
|
8715
|
+
console.log(` ${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
|
|
8716
|
+
}
|
|
8717
|
+
else {
|
|
8718
|
+
console.log(` ${render.icons.info} ${m.id}: ${m.state}`);
|
|
8719
|
+
}
|
|
8720
|
+
}
|
|
8721
|
+
}
|
|
8722
|
+
}
|
|
8723
|
+
}
|
|
8724
|
+
}
|
|
8725
|
+
catch (err) {
|
|
8726
|
+
console.log(` ${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
|
|
8727
|
+
}
|
|
8728
|
+
}
|
|
6972
8729
|
// Summary
|
|
6973
8730
|
console.log("");
|
|
6974
8731
|
if (issues === 0) {
|
|
@@ -6982,7 +8739,7 @@ program
|
|
|
6982
8739
|
process.exit(1);
|
|
6983
8740
|
});
|
|
6984
8741
|
// ─── flair session snapshot ──────────────────────────────────────────────────
|
|
6985
|
-
// Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B
|
|
8742
|
+
// Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B. Snapshot a
|
|
6986
8743
|
// session jsonl + label metadata into a tar.gz under ~/.flair/snapshots/<agent>/sessions/.
|
|
6987
8744
|
//
|
|
6988
8745
|
// Three subcommands: create | list | restore. Mirrors FLAIR-NIGHTLY-REM's
|
|
@@ -7134,13 +8891,15 @@ sessionSnapshot
|
|
|
7134
8891
|
});
|
|
7135
8892
|
// ─── Memory and Soul commands ────────────────────────────────────────────────
|
|
7136
8893
|
const memory = program.command("memory").description("Manage agent memories");
|
|
7137
|
-
memory.command("add [content]")
|
|
8894
|
+
memory.command("add [content]")
|
|
8895
|
+
.description("Write a new memory row for an agent (content via positional arg or --content)")
|
|
8896
|
+
.requiredOption("--agent <id>")
|
|
7138
8897
|
.option("--content <text>", "memory content (alias for positional arg)")
|
|
7139
8898
|
.option("--durability <d>", "standard").option("--tags <csv>")
|
|
7140
|
-
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content
|
|
8899
|
+
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
|
|
7141
8900
|
.option("--subject <text>", "one-line title / entity this memory is about")
|
|
7142
8901
|
.option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
|
|
7143
|
-
.option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to
|
|
8902
|
+
.option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to any other agent) or 'shared' (visible to owner + every other agent on this instance — open within the org, not gated by a MemoryGrant). Omit to use the server's durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private (flair#509)")
|
|
7144
8903
|
.action(async (contentArg, opts) => {
|
|
7145
8904
|
const content = contentArg ?? opts.content;
|
|
7146
8905
|
if (!content) {
|
|
@@ -7166,7 +8925,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
|
|
|
7166
8925
|
console.log(JSON.stringify(out, null, 2));
|
|
7167
8926
|
});
|
|
7168
8927
|
// ─── flair memory write-task-summary ────────────────────────────────────────
|
|
7169
|
-
// Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B
|
|
8928
|
+
// Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B. Standalone
|
|
7170
8929
|
// helper that any agent harness (or a manual operator) can invoke at task
|
|
7171
8930
|
// close to capture a structured task summary as a persistent Memory row
|
|
7172
8931
|
// before resetting the session.
|
|
@@ -7183,7 +8942,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
|
|
|
7183
8942
|
memory.command("write-task-summary")
|
|
7184
8943
|
.description("Capture a structured task summary as a persistent Memory row (used by session-reset harness; standalone-callable by operators)")
|
|
7185
8944
|
.requiredOption("--agent <id>", "Agent the summary belongs to")
|
|
7186
|
-
.requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about
|
|
8945
|
+
.requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about")
|
|
7187
8946
|
.requiredOption("--outcome <s>", "Outcome of the task: merged | rejected | abandoned")
|
|
7188
8947
|
.option("--summary <text>", "Multi-sentence dense compression (populates Memory.summary; will be the agent's read-time view)")
|
|
7189
8948
|
.option("--files-touched <csv>", "Comma-separated list of files touched during the task (becomes part of content)")
|
|
@@ -7243,6 +9002,7 @@ memory.command("write-task-summary")
|
|
|
7243
9002
|
console.log(memId);
|
|
7244
9003
|
});
|
|
7245
9004
|
memory.command("search [query]")
|
|
9005
|
+
.description("Semantic search over an agent's memories (query via positional arg or --q)")
|
|
7246
9006
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
7247
9007
|
.option("--q <query>", "search query (alias for positional arg)")
|
|
7248
9008
|
.option("--limit <n>", "Max results", "5")
|
|
@@ -7269,6 +9029,7 @@ memory.command("search [query]")
|
|
|
7269
9029
|
console.log(JSON.stringify(res, null, 2));
|
|
7270
9030
|
});
|
|
7271
9031
|
memory.command("list")
|
|
9032
|
+
.description("List an agent's memories (optionally filtered by --tag or embedding-backfill triage)")
|
|
7272
9033
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
7273
9034
|
.option("--tag <tag>")
|
|
7274
9035
|
.option("--hash-fallback", "Only memories with missing or hash-fallback embeddings (for backfill triage)")
|
|
@@ -7379,7 +9140,7 @@ memory.command("list")
|
|
|
7379
9140
|
});
|
|
7380
9141
|
// ─── flair memory hygiene ────────────────────────────────────────────────────
|
|
7381
9142
|
// Detect + remove junk memory rows that accumulate over time. Surfaced from
|
|
7382
|
-
//
|
|
9143
|
+
// a 2026-05-07 manual cleanup: an instance had 627 records, ~250 of
|
|
7383
9144
|
// them were noise — `*-compact-*` ID fragments from an old pipeline, pangram
|
|
7384
9145
|
// test content ("the quick brown fox..." / "Flair 251 test ..."), and
|
|
7385
9146
|
// near-empty rows (<25 chars). We did the cleanup ad-hoc with raw curl + jq;
|
|
@@ -7394,8 +9155,8 @@ memory.command("list")
|
|
|
7394
9155
|
// Default is all three, dry-run. Flip --apply to actually delete. Always
|
|
7395
9156
|
// requires admin pass to read across agent scopes (uses ops API).
|
|
7396
9157
|
//
|
|
7397
|
-
// Federation note: this only deletes on the local instance.
|
|
7398
|
-
//
|
|
9158
|
+
// Federation note: this only deletes on the local instance. Federation
|
|
9159
|
+
// distributed-delete via tombstones is the systemic answer for
|
|
7399
9160
|
// fan-out — until that lands, run `flair memory hygiene` on each peer.
|
|
7400
9161
|
// Exported for unit testing — keeps the predicate logic separable from
|
|
7401
9162
|
// the CLI plumbing, ops-API fetching, and confirmation flow.
|
|
@@ -7515,7 +9276,7 @@ memory.command("hygiene")
|
|
|
7515
9276
|
console.log(`\n\n✅ Deleted ${deleted} rows.`);
|
|
7516
9277
|
console.log("");
|
|
7517
9278
|
console.log("Note: this is a local-instance delete. Federated peers will keep their copies until");
|
|
7518
|
-
console.log("
|
|
9279
|
+
console.log("tombstone-based distributed delete lands. Until then, run `flair memory");
|
|
7519
9280
|
console.log("hygiene --apply` on each peer to fan out.");
|
|
7520
9281
|
});
|
|
7521
9282
|
// ─── flair search (top-level shortcut) ───────────────────────────────────────
|
|
@@ -7550,7 +9311,7 @@ program
|
|
|
7550
9311
|
.option("--since <iso-or-relative>", "Only memories created after this point (ISO 8601 or '7d'/'24h'/'30m')")
|
|
7551
9312
|
.option("--as-of <iso>", "Temporal validity: only memories valid at this point (ISO 8601)")
|
|
7552
9313
|
.option("--include-superseded", "Include memories that have been superseded")
|
|
7553
|
-
.option("--scoring <mode>", "Scoring mode:
|
|
9314
|
+
.option("--scoring <mode>", "Scoring mode: raw (default) uses cosine similarity/BM25 only; composite re-ranks by durability/recency/retrieval (measurably hurts precision as of flair#623 — opt-in only)", "raw")
|
|
7554
9315
|
.option("--min-score <n>", "Drop results below this score (0..1)", "0")
|
|
7555
9316
|
// Client-side filters (applied after server response)
|
|
7556
9317
|
.option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
|
|
@@ -7576,7 +9337,7 @@ program
|
|
|
7576
9337
|
agentId,
|
|
7577
9338
|
q: query,
|
|
7578
9339
|
limit: Number.parseInt(opts.limit, 10) || 5,
|
|
7579
|
-
scoring: opts.scoring === "
|
|
9340
|
+
scoring: opts.scoring === "composite" ? "composite" : "raw",
|
|
7580
9341
|
};
|
|
7581
9342
|
if (opts.tag)
|
|
7582
9343
|
payload.tag = opts.tag;
|
|
@@ -7777,8 +9538,73 @@ program
|
|
|
7777
9538
|
process.exit(1);
|
|
7778
9539
|
}
|
|
7779
9540
|
});
|
|
9541
|
+
// ─── flair relationship add ──────────────────────────────────────────────────
|
|
9542
|
+
//
|
|
9543
|
+
// Ergonomic agent-directed write surface for the Relationship graph
|
|
9544
|
+
// (relationship-write-path spec): an explicit subject/predicate/object triple
|
|
9545
|
+
// ("record that <subject> <predicate> <object>"), distinct from a free-text
|
|
9546
|
+
// Memory. Mirrors `flair memory add`'s shape (--agent required, signed via
|
|
9547
|
+
// the shared `api()` helper — see api()'s doc above for the Ed25519
|
|
9548
|
+
// resolution order) rather than hand-rolling a signer, per this repo's
|
|
9549
|
+
// existing convention (flair orgevent does hand-roll one because OrgEvent.put()
|
|
9550
|
+
// self-verifies authorId against the signature; Relationship doesn't need that
|
|
9551
|
+
// — the server stamps agentId from the verdict regardless of what's sent).
|
|
9552
|
+
//
|
|
9553
|
+
// PUTs to the CANONICAL id (see canonicalRelationshipId below), not a random
|
|
9554
|
+
// one — re-running this command with the SAME subject/predicate/object
|
|
9555
|
+
// UPSERTS the existing row (confidence/validTo/source refresh) instead of
|
|
9556
|
+
// creating a duplicate. This mirrors flair-client's RelationshipApi.write()
|
|
9557
|
+
// (packages/flair-client/src/client.ts) BYTE FOR BYTE — the CLI can't import
|
|
9558
|
+
// that workspace package into the published @tpsdev-ai/flair bundle (same
|
|
9559
|
+
// reasoning as the existing Memory-id-generation mirroring a few thousand
|
|
9560
|
+
// lines up), so the algorithm is duplicated here rather than shared. A
|
|
9561
|
+
// cross-check test (test/unit/cli-relationship-add.test.ts) pins the two
|
|
9562
|
+
// implementations to identical output so they can't silently drift apart —
|
|
9563
|
+
// a drift here would mean the CLI and the MCP tool/RelationshipApi land the
|
|
9564
|
+
// SAME triple at TWO different ids, defeating the whole dedup guarantee.
|
|
9565
|
+
function canonicalRelationshipId(agentId, subject, predicate, object) {
|
|
9566
|
+
const material = [agentId, subject, predicate, object].join("\u0000").toLowerCase();
|
|
9567
|
+
return createHash("sha256").update(material, "utf8").digest().subarray(0, 16).toString("base64url");
|
|
9568
|
+
}
|
|
9569
|
+
const relationship = program.command("relationship").description("Manage agent relationship triples (knowledge graph)");
|
|
9570
|
+
relationship.command("add")
|
|
9571
|
+
.description("Record that <subject> <predicate> <object> — an explicit entity-to-entity relationship triple. " +
|
|
9572
|
+
"Re-asserting the SAME triple (same subject/predicate/object) UPSERTS the existing row rather than " +
|
|
9573
|
+
"duplicating it. Predicate is free text; recommended vocabulary: manages, works_on, reviews, depends_on, " +
|
|
9574
|
+
"replaces, owns, reports_to, advises. To CONTRADICT a prior relationship: changing the predicate creates " +
|
|
9575
|
+
"a SEPARATE row and does NOT auto-close the old one — re-assert the OLD triple with --valid-to set to now " +
|
|
9576
|
+
"(or delete it) before/after writing the new one.")
|
|
9577
|
+
.requiredOption("--agent <id>")
|
|
9578
|
+
.requiredOption("--subject <text>", "Source entity (e.g. 'nathan')")
|
|
9579
|
+
.requiredOption("--predicate <text>", "Relationship type, free text (e.g. 'manages')")
|
|
9580
|
+
.requiredOption("--object <text>", "Target entity (e.g. 'flair')")
|
|
9581
|
+
.option("--confidence <n>", "0.0-1.0, how certain (default 1.0 = explicitly stated)")
|
|
9582
|
+
.option("--valid-from <iso>", "ISO timestamp this relationship became true (default: now)")
|
|
9583
|
+
.option("--valid-to <iso>", "ISO timestamp this relationship ended (leave unset for an active relationship)")
|
|
9584
|
+
.option("--source <text>", "Where this was learned from (a memory ID, conversation, etc.)")
|
|
9585
|
+
.action(async (opts) => {
|
|
9586
|
+
const id = canonicalRelationshipId(opts.agent, opts.subject, opts.predicate, opts.object);
|
|
9587
|
+
const body = {
|
|
9588
|
+
id,
|
|
9589
|
+
agentId: opts.agent,
|
|
9590
|
+
subject: opts.subject,
|
|
9591
|
+
predicate: opts.predicate,
|
|
9592
|
+
object: opts.object,
|
|
9593
|
+
};
|
|
9594
|
+
if (opts.confidence !== undefined)
|
|
9595
|
+
body.confidence = Number(opts.confidence);
|
|
9596
|
+
if (opts.validFrom)
|
|
9597
|
+
body.validFrom = opts.validFrom;
|
|
9598
|
+
if (opts.validTo)
|
|
9599
|
+
body.validTo = opts.validTo;
|
|
9600
|
+
if (opts.source)
|
|
9601
|
+
body.source = opts.source;
|
|
9602
|
+
const out = await api("PUT", `/Relationship/${id}`, body);
|
|
9603
|
+
console.log(JSON.stringify(out, null, 2));
|
|
9604
|
+
});
|
|
7780
9605
|
const soul = program.command("soul").description("Manage agent soul entries");
|
|
7781
9606
|
soul.command("set")
|
|
9607
|
+
.description("Set (upsert) a soul entry for an agent by key")
|
|
7782
9608
|
.requiredOption("--agent <id>")
|
|
7783
9609
|
.requiredOption("--key <key>")
|
|
7784
9610
|
.requiredOption("--value <value>")
|
|
@@ -7810,6 +9636,7 @@ soul.command("set")
|
|
|
7810
9636
|
console.log(render.kv("durability", render.wrap(render.c.magenta, opts.durability)));
|
|
7811
9637
|
});
|
|
7812
9638
|
soul.command("get")
|
|
9639
|
+
.description("Fetch a single soul entry by id (agent:key)")
|
|
7813
9640
|
.argument("<id>")
|
|
7814
9641
|
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
7815
9642
|
.action(async (id, opts) => {
|
|
@@ -7841,6 +9668,7 @@ soul.command("get")
|
|
|
7841
9668
|
}
|
|
7842
9669
|
});
|
|
7843
9670
|
soul.command("list")
|
|
9671
|
+
.description("List all soul entries for an agent")
|
|
7844
9672
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
7845
9673
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
7846
9674
|
.action(async (opts) => {
|
|
@@ -9187,7 +11015,7 @@ program
|
|
|
9187
11015
|
}
|
|
9188
11016
|
});
|
|
9189
11017
|
// ─── flair presence ─────────────────────────────────────────────────────────
|
|
9190
|
-
const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "idle"];
|
|
11018
|
+
const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "debugging", "idle"];
|
|
9191
11019
|
const MAX_TASK_LENGTH = 120;
|
|
9192
11020
|
const presence = program.command("presence").description("Manage agent presence (The Office Space)");
|
|
9193
11021
|
presence
|
|
@@ -9253,16 +11081,27 @@ presence
|
|
|
9253
11081
|
});
|
|
9254
11082
|
// ─── flair workspace ─────────────────────────────────────────────────────────
|
|
9255
11083
|
//
|
|
9256
|
-
// Coordination write surface (
|
|
9257
|
-
// agent's OWN WorkspaceState via a signed
|
|
9258
|
-
//
|
|
9259
|
-
//
|
|
9260
|
-
//
|
|
11084
|
+
// Coordination write surface (Kris #510). `workspace set` writes the
|
|
11085
|
+
// agent's OWN WorkspaceState via a signed PUT /WorkspaceState/{id}. Identity
|
|
11086
|
+
// is asserted by including agentId in the body — the server never trusts it
|
|
11087
|
+
// blindly, it 403s any mismatch against the Ed25519 signature's agentId
|
|
11088
|
+
// (WorkspaceState.put(), resources/WorkspaceState.ts), so this is a
|
|
11089
|
+
// self-declaration the server verifies 1:1, not attribution-from-body.
|
|
11090
|
+
//
|
|
11091
|
+
// (flair#679, measured against a real spawned Harper): table-backed resources
|
|
11092
|
+
// only accept writes via PUT /<Table>/<id> — a bare POST /WorkspaceState 405s
|
|
11093
|
+
// ("does not have a post method implemented to handle HTTP method POST"),
|
|
11094
|
+
// same restriction documented in resources/Memory.ts and already fixed for
|
|
11095
|
+
// `soul set` (#498). WorkspaceState.ts DOES define a post() method, but
|
|
11096
|
+
// Harper's REST layer never routes a real HTTP POST to it — post() is only
|
|
11097
|
+
// reachable via in-process resource instantiation, never the wire. put(),
|
|
11098
|
+
// unlike post(), does NOT default createdAt/timestamp/agentId — the CLI
|
|
11099
|
+
// supplies them all explicitly below.
|
|
9261
11100
|
const MAX_WORKSPACE_FIELD_LENGTH = 2000;
|
|
9262
11101
|
const workspace = program.command("workspace").description("Manage agent workspace state (The Office Space)");
|
|
9263
11102
|
workspace
|
|
9264
11103
|
.command("set")
|
|
9265
|
-
.description("Set your agent's current workspace state (
|
|
11104
|
+
.description("Set your agent's current workspace state (PUT /WorkspaceState/{id})")
|
|
9266
11105
|
.requiredOption("--ref <ref>", "Workspace ref (branch, worktree, or task ref)")
|
|
9267
11106
|
.option("--label <text>", "Human-readable label for this workspace")
|
|
9268
11107
|
.option("--provider <name>", "Provider/runtime (e.g. claude-code, openclaw)", "cli")
|
|
@@ -9291,15 +11130,23 @@ workspace
|
|
|
9291
11130
|
process.exit(1);
|
|
9292
11131
|
}
|
|
9293
11132
|
const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
|
|
9294
|
-
|
|
9295
|
-
//
|
|
9296
|
-
//
|
|
11133
|
+
// Deterministic id (agentId:ref) — re-running `workspace set` for the same
|
|
11134
|
+
// ref overwrites the same record, which is intentional (one row per
|
|
11135
|
+
// agent+ref, not an append log).
|
|
11136
|
+
const id = `${agentId}:${opts.ref}`;
|
|
11137
|
+
const auth = buildEd25519Auth(agentId, "PUT", `/WorkspaceState/${id}`, keyPath);
|
|
11138
|
+
// agentId IS included in the body now — WorkspaceState.put() (unlike
|
|
11139
|
+
// post()) does not auto-attribute from the signature, it 403s any
|
|
11140
|
+
// mismatch. This is a self-declaration the server verifies against the
|
|
11141
|
+
// signature, not a forgeable claim.
|
|
9297
11142
|
const now = new Date().toISOString();
|
|
9298
11143
|
const body = {
|
|
9299
|
-
id
|
|
11144
|
+
id,
|
|
11145
|
+
agentId,
|
|
9300
11146
|
ref: opts.ref,
|
|
9301
11147
|
provider: opts.provider ?? "cli",
|
|
9302
11148
|
timestamp: now,
|
|
11149
|
+
createdAt: now,
|
|
9303
11150
|
};
|
|
9304
11151
|
if (opts.label)
|
|
9305
11152
|
body.label = opts.label;
|
|
@@ -9309,30 +11156,43 @@ workspace
|
|
|
9309
11156
|
body.phase = opts.phase;
|
|
9310
11157
|
if (opts.summary)
|
|
9311
11158
|
body.summary = opts.summary;
|
|
9312
|
-
const res = await fetch(`${baseUrl}/WorkspaceState`, {
|
|
9313
|
-
method: "
|
|
11159
|
+
const res = await fetch(`${baseUrl}/WorkspaceState/${id}`, {
|
|
11160
|
+
method: "PUT",
|
|
9314
11161
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
9315
11162
|
body: JSON.stringify(body),
|
|
9316
11163
|
});
|
|
9317
11164
|
if (!res.ok) {
|
|
9318
11165
|
const text = await res.text().catch(() => "");
|
|
9319
|
-
console.error(`Error:
|
|
11166
|
+
console.error(`Error: PUT /WorkspaceState/${id} failed (${res.status}): ${text}`);
|
|
9320
11167
|
process.exit(1);
|
|
9321
11168
|
}
|
|
9322
11169
|
console.log(`✓ Workspace state updated for '${agentId}': ref=${opts.ref}${opts.phase ? `, phase=${opts.phase}` : ""}`);
|
|
9323
11170
|
});
|
|
9324
11171
|
// ─── flair orgevent ──────────────────────────────────────────────────────────
|
|
9325
11172
|
//
|
|
9326
|
-
// Coordination write surface (
|
|
9327
|
-
// OrgEvent ATTRIBUTED to the authenticated agent via a signed
|
|
9328
|
-
//
|
|
9329
|
-
//
|
|
9330
|
-
//
|
|
11173
|
+
// Coordination write surface (Kris #510). `orgevent` publishes an
|
|
11174
|
+
// OrgEvent ATTRIBUTED to the authenticated agent via a signed PUT
|
|
11175
|
+
// /OrgEvent/{id}. authorId is asserted in the body but self-verified server
|
|
11176
|
+
// side: OrgEvent.put() (resources/OrgEvent.ts) 403s any authorId that doesn't
|
|
11177
|
+
// match the Ed25519 signature's agentId, so an agent still cannot forge
|
|
11178
|
+
// another agent's events — the difference from post() is that put() checks-
|
|
11179
|
+
// and-rejects a mismatch rather than silently overwriting it.
|
|
11180
|
+
//
|
|
11181
|
+
// (flair#679, measured against a real spawned Harper): table-backed resources
|
|
11182
|
+
// only accept writes via PUT /<Table>/<id> — a bare POST /OrgEvent 405s
|
|
11183
|
+
// ("does not have a post method implemented to handle HTTP method POST"),
|
|
11184
|
+
// same restriction documented in resources/Memory.ts and already fixed for
|
|
11185
|
+
// `soul set` (#498). OrgEvent.ts DOES define a post() method that
|
|
11186
|
+
// auto-generates id/createdAt, but Harper's REST layer never routes a real
|
|
11187
|
+
// HTTP POST to it — post() is only reachable via in-process resource
|
|
11188
|
+
// instantiation, never the wire. put() does NOT default id/createdAt, so the
|
|
11189
|
+
// CLI generates and supplies them itself (id convention mirrors flair-client's
|
|
11190
|
+
// Memory.write(): `${agentId}-${randomUUID()}`).
|
|
9331
11191
|
const MAX_ORGEVENT_SUMMARY_LENGTH = 500;
|
|
9332
11192
|
const MAX_ORGEVENT_DETAIL_LENGTH = 8000;
|
|
9333
11193
|
program
|
|
9334
11194
|
.command("orgevent")
|
|
9335
|
-
.description("Publish an org-wide coordination event attributed to your agent (
|
|
11195
|
+
.description("Publish an org-wide coordination event attributed to your agent (PUT /OrgEvent/{id})")
|
|
9336
11196
|
.requiredOption("--kind <kind>", "Event kind (e.g. coord.claim, coord.release, status)")
|
|
9337
11197
|
.requiredOption("--summary <text>", "Short summary of the event")
|
|
9338
11198
|
.option("--detail <text>", "Longer detail payload")
|
|
@@ -9363,12 +11223,22 @@ program
|
|
|
9363
11223
|
// orgevent reuses --target for recipients, so the remote-URL override is
|
|
9364
11224
|
// --target-url here (env FLAIR_TARGET still honored via resolveBaseUrl).
|
|
9365
11225
|
const baseUrl = resolveBaseUrl({ target: opts.targetUrl, port: opts.port }).replace(/\/$/, "");
|
|
9366
|
-
|
|
9367
|
-
//
|
|
9368
|
-
//
|
|
11226
|
+
// id generation mirrors flair-client's Memory.write() convention
|
|
11227
|
+
// (`${agentId}-${randomUUID()}`) — unique per publish, unlike OrgEvent's
|
|
11228
|
+
// own (HTTP-unreachable) post() default of `${authorId}-${isoTimestamp}`,
|
|
11229
|
+
// which can collide within the same millisecond.
|
|
11230
|
+
const id = `${agentId}-${randomUUID()}`;
|
|
11231
|
+
const auth = buildEd25519Auth(agentId, "PUT", `/OrgEvent/${id}`, keyPath);
|
|
11232
|
+
// authorId IS included in the body now — OrgEvent.put() (unlike post())
|
|
11233
|
+
// does not auto-attribute from the signature, it 403s any mismatch. This
|
|
11234
|
+
// is a self-declaration the server verifies against the signature, not a
|
|
11235
|
+
// forgeable claim.
|
|
9369
11236
|
const body = {
|
|
11237
|
+
id,
|
|
11238
|
+
authorId: agentId,
|
|
9370
11239
|
kind: opts.kind,
|
|
9371
11240
|
summary: opts.summary,
|
|
11241
|
+
createdAt: new Date().toISOString(),
|
|
9372
11242
|
};
|
|
9373
11243
|
if (opts.detail)
|
|
9374
11244
|
body.detail = opts.detail;
|
|
@@ -9376,21 +11246,126 @@ program
|
|
|
9376
11246
|
body.scope = opts.scope;
|
|
9377
11247
|
if (Array.isArray(opts.target) && opts.target.length > 0)
|
|
9378
11248
|
body.targetIds = opts.target;
|
|
9379
|
-
const res = await fetch(`${baseUrl}/OrgEvent`, {
|
|
9380
|
-
method: "
|
|
11249
|
+
const res = await fetch(`${baseUrl}/OrgEvent/${id}`, {
|
|
11250
|
+
method: "PUT",
|
|
9381
11251
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
9382
11252
|
body: JSON.stringify(body),
|
|
9383
11253
|
});
|
|
9384
11254
|
if (!res.ok) {
|
|
9385
11255
|
const text = await res.text().catch(() => "");
|
|
9386
|
-
console.error(`Error:
|
|
11256
|
+
console.error(`Error: PUT /OrgEvent/${id} failed (${res.status}): ${text}`);
|
|
9387
11257
|
process.exit(1);
|
|
9388
11258
|
}
|
|
9389
11259
|
const data = await res.json().catch(() => null);
|
|
9390
11260
|
const targets = Array.isArray(opts.target) && opts.target.length > 0 ? ` → ${opts.target.join(", ")}` : "";
|
|
9391
11261
|
console.log(`✓ OrgEvent published as '${agentId}': kind=${opts.kind}${targets}`);
|
|
9392
|
-
|
|
9393
|
-
|
|
11262
|
+
console.log(` id: ${data?.id ?? id}`);
|
|
11263
|
+
});
|
|
11264
|
+
// ─── flair attention ─────────────────────────────────────────────────────────
|
|
11265
|
+
//
|
|
11266
|
+
// Entity-scoped attention query (flair#677). "What's touching entity E in the
|
|
11267
|
+
// last N days?" — a unified, grouped-by-source view across Memory,
|
|
11268
|
+
// Relationship, WorkspaceState, Presence, and OrgEvent (POST /AttentionQuery,
|
|
11269
|
+
// resources/AttentionQuery.ts). Read-only; signed the same way `flair search`
|
|
11270
|
+
// signs POST /SemanticSearch. Entity must be a vocabulary string (exact
|
|
11271
|
+
// type:value match — resources/entity-vocab.ts); the server 400s anything
|
|
11272
|
+
// malformed.
|
|
11273
|
+
/** Render one attention-result row for its source group — human-readable mode only. */
|
|
11274
|
+
function describeAttentionRow(source, r) {
|
|
11275
|
+
const dim = (s) => render.wrap(render.c.dim, s);
|
|
11276
|
+
const day = (iso) => (typeof iso === "string" ? iso.slice(0, 10) : "");
|
|
11277
|
+
switch (source) {
|
|
11278
|
+
case "memory":
|
|
11279
|
+
return `${r.content ? String(r.content).replace(/\s+/g, " ").slice(0, 100) : "(no content)"} ${dim(`[${r.agentId} · ${day(r.createdAt)}]`)}`;
|
|
11280
|
+
case "relationship":
|
|
11281
|
+
return `${r.subject} —${r.predicate}→ ${r.object} ${dim(`[${r.agentId} · ${day(r.createdAt)}]`)}`;
|
|
11282
|
+
case "workspaceState":
|
|
11283
|
+
return `${r.summary ?? r.ref} ${dim(`[${r.agentId}${r.phase ? ` · ${r.phase}` : ""} · ${String(r.timestamp ?? "").slice(0, 16).replace("T", " ")}]`)}`;
|
|
11284
|
+
case "presence":
|
|
11285
|
+
return `${r.currentTask} ${dim(`[${r.displayName ?? r.agentId}${r.activity ? ` · ${r.activity}` : ""}]`)}`;
|
|
11286
|
+
case "orgEvent":
|
|
11287
|
+
return `${r.summary} ${dim(`[${r.authorId} · ${r.kind} · ${day(r.createdAt)}]`)}`;
|
|
11288
|
+
default:
|
|
11289
|
+
return JSON.stringify(r);
|
|
11290
|
+
}
|
|
11291
|
+
}
|
|
11292
|
+
program
|
|
11293
|
+
.command("attention <entity>")
|
|
11294
|
+
.description("What's touching entity E in the last N days? Grouped view across memory/relationship/workspace/presence/orgevent (POST /AttentionQuery)")
|
|
11295
|
+
.option("--days <n>", "Window size in days (default 7)")
|
|
11296
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
11297
|
+
.option("--key <path>", "Ed25519 private key path")
|
|
11298
|
+
.option("--port <port>", "Harper HTTP port")
|
|
11299
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
11300
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
11301
|
+
.option("--json", "Output raw JSON")
|
|
11302
|
+
.action(async (entity, opts) => {
|
|
11303
|
+
try {
|
|
11304
|
+
const agentId = resolveAgentIdOrEnv(opts);
|
|
11305
|
+
if (!agentId) {
|
|
11306
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
11307
|
+
process.exit(2);
|
|
11308
|
+
}
|
|
11309
|
+
const payload = { entity };
|
|
11310
|
+
if (opts.days !== undefined) {
|
|
11311
|
+
const n = Number.parseInt(opts.days, 10);
|
|
11312
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
11313
|
+
console.error("error: --days must be a positive integer");
|
|
11314
|
+
process.exit(2);
|
|
11315
|
+
}
|
|
11316
|
+
payload.days = n;
|
|
11317
|
+
}
|
|
11318
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
11319
|
+
const headers = { "content-type": "application/json" };
|
|
11320
|
+
const keyPath = opts.key || resolveKeyPath(agentId);
|
|
11321
|
+
if (keyPath) {
|
|
11322
|
+
headers["authorization"] = buildEd25519Auth(agentId, "POST", "/AttentionQuery", keyPath);
|
|
11323
|
+
}
|
|
11324
|
+
const res = await fetch(`${baseUrl}/AttentionQuery`, {
|
|
11325
|
+
method: "POST",
|
|
11326
|
+
headers,
|
|
11327
|
+
body: JSON.stringify(payload),
|
|
11328
|
+
});
|
|
11329
|
+
const text = await res.text();
|
|
11330
|
+
if (!res.ok)
|
|
11331
|
+
throw new Error(text || `HTTP ${res.status}`);
|
|
11332
|
+
const result = text ? JSON.parse(text) : {};
|
|
11333
|
+
const mode = render.resolveOutputMode(opts);
|
|
11334
|
+
if (mode === "json") {
|
|
11335
|
+
console.log(render.asJSON(result));
|
|
11336
|
+
return;
|
|
11337
|
+
}
|
|
11338
|
+
const groups = result.groups ?? {};
|
|
11339
|
+
const counts = result.counts ?? {};
|
|
11340
|
+
console.log(`${render.icons.info} Attention: ${render.wrap(render.c.bold, result.entity ?? entity)} ` +
|
|
11341
|
+
`${render.wrap(render.c.dim, `(last ${result.windowDays ?? payload.days ?? 7}d, since ${result.since ?? "?"})`)}`);
|
|
11342
|
+
console.log(render.wrap(render.c.dim, `total: ${counts.total ?? 0}`));
|
|
11343
|
+
console.log();
|
|
11344
|
+
const sections = [
|
|
11345
|
+
{ key: "memory", label: "Memory" },
|
|
11346
|
+
{ key: "relationship", label: "Relationship" },
|
|
11347
|
+
{ key: "workspaceState", label: "Workspace" },
|
|
11348
|
+
{ key: "presence", label: "Presence" },
|
|
11349
|
+
{ key: "orgEvent", label: "OrgEvent" },
|
|
11350
|
+
];
|
|
11351
|
+
for (const { key, label } of sections) {
|
|
11352
|
+
const rows = Array.isArray(groups[key]) ? groups[key] : [];
|
|
11353
|
+
console.log(`${render.wrap(render.c.bold, label)} ${render.wrap(render.c.dim, `(${rows.length})`)}`);
|
|
11354
|
+
if (rows.length === 0) {
|
|
11355
|
+
console.log(` ${render.wrap(render.c.dim, "—")}`);
|
|
11356
|
+
console.log();
|
|
11357
|
+
continue;
|
|
11358
|
+
}
|
|
11359
|
+
for (const r of rows) {
|
|
11360
|
+
console.log(` ${describeAttentionRow(key, r)}`);
|
|
11361
|
+
}
|
|
11362
|
+
console.log();
|
|
11363
|
+
}
|
|
11364
|
+
}
|
|
11365
|
+
catch (err) {
|
|
11366
|
+
console.error(`${render.icons.error} Attention query failed: ${err.message}`);
|
|
11367
|
+
process.exit(1);
|
|
11368
|
+
}
|
|
9394
11369
|
});
|
|
9395
11370
|
// Parse argv and run the CLI. Exported so the CommonJS preflight shim
|
|
9396
11371
|
// (cli-shim.cts → dist/cli-shim.cjs, the real bin entry) can invoke it after
|
|
@@ -9406,4 +11381,4 @@ if (import.meta.main) {
|
|
|
9406
11381
|
await runCli();
|
|
9407
11382
|
}
|
|
9408
11383
|
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
9409
|
-
export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
|
|
11384
|
+
export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, };
|