@tpsdev-ai/flair 0.5.6 → 0.6.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 +3 -0
- package/dist/bridges/builtins/agentic-stack.js +58 -0
- package/dist/bridges/builtins/index.js +36 -0
- package/dist/bridges/discover.js +195 -0
- package/dist/bridges/runtime/context.js +59 -0
- package/dist/bridges/runtime/execute.js +92 -0
- package/dist/bridges/runtime/export-runner.js +105 -0
- package/dist/bridges/runtime/formats.js +136 -0
- package/dist/bridges/runtime/import-runner.js +107 -0
- package/dist/bridges/runtime/load-descriptor.js +46 -0
- package/dist/bridges/runtime/mapper.js +139 -0
- package/dist/bridges/runtime/predicate.js +122 -0
- package/dist/bridges/runtime/writers.js +130 -0
- package/dist/bridges/runtime/yaml-loader.js +148 -0
- package/dist/bridges/scaffold.js +224 -0
- package/dist/bridges/types.js +30 -0
- package/dist/cli.js +970 -216
- package/dist/deploy.js +161 -0
- package/dist/resources/ObservationCenter.js +19 -0
- package/dist/resources/health.js +443 -14
- package/package.json +6 -6
- package/ui/observation-center.html +91 -0
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
// Files that must be present in a Flair package for deployment.
|
|
7
|
+
// Mirrors the `files` array in package.json — keep in sync.
|
|
8
|
+
export const REQUIRED_PACKAGE_FILES = [
|
|
9
|
+
"dist",
|
|
10
|
+
"schemas",
|
|
11
|
+
"ui",
|
|
12
|
+
"config.yaml",
|
|
13
|
+
];
|
|
14
|
+
export function validateOptions(opts) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
if (!opts.target) {
|
|
17
|
+
if (!opts.fabricOrg)
|
|
18
|
+
errors.push("--fabric-org required (or FABRIC_ORG env)");
|
|
19
|
+
if (!opts.fabricCluster)
|
|
20
|
+
errors.push("--fabric-cluster required (or FABRIC_CLUSTER env)");
|
|
21
|
+
}
|
|
22
|
+
const hasBasic = !!(opts.fabricUser && opts.fabricPassword);
|
|
23
|
+
const hasBearer = !!opts.fabricToken;
|
|
24
|
+
if (!hasBasic && !hasBearer) {
|
|
25
|
+
errors.push("credentials required: pass --fabric-user + --fabric-password " +
|
|
26
|
+
"(or FABRIC_USER / FABRIC_PASSWORD env), or --fabric-token " +
|
|
27
|
+
"(FABRIC_TOKEN env)");
|
|
28
|
+
}
|
|
29
|
+
return errors;
|
|
30
|
+
}
|
|
31
|
+
export function buildTargetUrl(opts) {
|
|
32
|
+
if (opts.target)
|
|
33
|
+
return opts.target;
|
|
34
|
+
return `https://${opts.fabricCluster}.${opts.fabricOrg}.harperfabric.com`;
|
|
35
|
+
}
|
|
36
|
+
export function resolvePackageRoot(override) {
|
|
37
|
+
if (override) {
|
|
38
|
+
const abs = resolve(override);
|
|
39
|
+
if (!existsSync(join(abs, "package.json"))) {
|
|
40
|
+
throw new Error(`No package.json at ${abs}`);
|
|
41
|
+
}
|
|
42
|
+
return abs;
|
|
43
|
+
}
|
|
44
|
+
// Walk up from this module's location — works when installed locally
|
|
45
|
+
// and when npx extracts the tarball to a tmpdir.
|
|
46
|
+
try {
|
|
47
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
let dir = here;
|
|
49
|
+
for (let i = 0; i < 8; i++) {
|
|
50
|
+
const pkgPath = join(dir, "package.json");
|
|
51
|
+
if (existsSync(pkgPath)) {
|
|
52
|
+
const json = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
53
|
+
if (json.name === "@tpsdev-ai/flair")
|
|
54
|
+
return dir;
|
|
55
|
+
}
|
|
56
|
+
const parent = dirname(dir);
|
|
57
|
+
if (parent === dir)
|
|
58
|
+
break;
|
|
59
|
+
dir = parent;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* fall through */
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const req = createRequire(import.meta.url);
|
|
67
|
+
return dirname(req.resolve("@tpsdev-ai/flair/package.json"));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error("Could not locate @tpsdev-ai/flair package root. Try --package-root.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function validatePackageLayout(packageRoot) {
|
|
74
|
+
const missing = [];
|
|
75
|
+
for (const f of REQUIRED_PACKAGE_FILES) {
|
|
76
|
+
if (!existsSync(join(packageRoot, f)))
|
|
77
|
+
missing.push(f);
|
|
78
|
+
}
|
|
79
|
+
if (missing.length) {
|
|
80
|
+
throw new Error(`Flair package at ${packageRoot} is missing required entries: ` +
|
|
81
|
+
missing.join(", "));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function resolveHarperBin(packageRoot) {
|
|
85
|
+
const local = join(packageRoot, "node_modules/@harperfast/harper/dist/bin/harper.js");
|
|
86
|
+
if (existsSync(local))
|
|
87
|
+
return local;
|
|
88
|
+
try {
|
|
89
|
+
const req = createRequire(join(packageRoot, "package.json"));
|
|
90
|
+
const mainPath = req.resolve("@harperfast/harper");
|
|
91
|
+
let dir = dirname(mainPath);
|
|
92
|
+
for (let i = 0; i < 6; i++) {
|
|
93
|
+
const candidate = join(dir, "dist/bin/harper.js");
|
|
94
|
+
if (existsSync(candidate))
|
|
95
|
+
return candidate;
|
|
96
|
+
const parent = dirname(dir);
|
|
97
|
+
if (parent === dir)
|
|
98
|
+
break;
|
|
99
|
+
dir = parent;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* fall through */
|
|
104
|
+
}
|
|
105
|
+
throw new Error("Could not locate Harper CLI binary (@harperfast/harper). " +
|
|
106
|
+
"Flair deploy requires Harper to be installed alongside Flair.");
|
|
107
|
+
}
|
|
108
|
+
function spawnHarper(bin, args, cwd, env) {
|
|
109
|
+
return new Promise((resolveP, rejectP) => {
|
|
110
|
+
const p = spawn(process.execPath, [bin, ...args], {
|
|
111
|
+
cwd,
|
|
112
|
+
stdio: "inherit",
|
|
113
|
+
env,
|
|
114
|
+
});
|
|
115
|
+
p.on("error", rejectP);
|
|
116
|
+
p.on("exit", (code) => {
|
|
117
|
+
if (code === 0)
|
|
118
|
+
resolveP();
|
|
119
|
+
else
|
|
120
|
+
rejectP(new Error(`harper deploy exited with code ${code}`));
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
export async function deploy(opts) {
|
|
125
|
+
const errors = validateOptions(opts);
|
|
126
|
+
if (errors.length) {
|
|
127
|
+
throw new Error(errors.join("\n"));
|
|
128
|
+
}
|
|
129
|
+
const packageRoot = resolvePackageRoot(opts.packageRoot);
|
|
130
|
+
validatePackageLayout(packageRoot);
|
|
131
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
132
|
+
const version = opts.version ?? pkg.version;
|
|
133
|
+
const project = opts.project ?? "flair";
|
|
134
|
+
const url = buildTargetUrl(opts);
|
|
135
|
+
if (opts.dryRun) {
|
|
136
|
+
return { url, project, version, packageRoot, dryRun: true };
|
|
137
|
+
}
|
|
138
|
+
if (opts.fabricToken && !(opts.fabricUser && opts.fabricPassword)) {
|
|
139
|
+
throw new Error("Bearer token auth (--fabric-token) is not yet supported — " +
|
|
140
|
+
"Harper's deploy_component CLI path only accepts Basic auth today. " +
|
|
141
|
+
"Pass --fabric-user + --fabric-password instead.");
|
|
142
|
+
}
|
|
143
|
+
const harperBin = resolveHarperBin(packageRoot);
|
|
144
|
+
const args = [
|
|
145
|
+
"deploy",
|
|
146
|
+
`target=${url}`,
|
|
147
|
+
`project=${project}`,
|
|
148
|
+
`restart=${opts.restart !== false}`,
|
|
149
|
+
`replicated=${opts.replicated !== false}`,
|
|
150
|
+
];
|
|
151
|
+
// Credentials go via env, not argv, so they don't appear in `ps` output
|
|
152
|
+
// for the lifetime of the Harper child process. Harper's cliOperations
|
|
153
|
+
// reads CLI_TARGET_USERNAME / CLI_TARGET_PASSWORD as env fallbacks.
|
|
154
|
+
const childEnv = {
|
|
155
|
+
...process.env,
|
|
156
|
+
CLI_TARGET_USERNAME: opts.fabricUser,
|
|
157
|
+
CLI_TARGET_PASSWORD: opts.fabricPassword,
|
|
158
|
+
};
|
|
159
|
+
await spawnHarper(harperBin, args, packageRoot, childEnv);
|
|
160
|
+
return { url, project, version, packageRoot, dryRun: false };
|
|
161
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Resource } from "@harperfast/harper";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const HTML_PATH = resolve(HERE, "..", "..", "ui", "observation-center.html");
|
|
7
|
+
const HTML = readFileSync(HTML_PATH, "utf8");
|
|
8
|
+
/**
|
|
9
|
+
* GET /ObservationCenter — serves the read-only dashboard shell.
|
|
10
|
+
* The HTML handles its own Basic-auth prompt and polls authenticated JSON endpoints.
|
|
11
|
+
*/
|
|
12
|
+
export class ObservationCenter extends Resource {
|
|
13
|
+
async get() {
|
|
14
|
+
return new Response(HTML, {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/resources/health.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
const db = databases;
|
|
6
|
+
const redactHome = (p) => {
|
|
7
|
+
const home = homedir();
|
|
8
|
+
return p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
9
|
+
};
|
|
10
|
+
const exists = async (path) => {
|
|
11
|
+
try {
|
|
12
|
+
await fsp.stat(path);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
3
19
|
/**
|
|
4
20
|
* Health endpoint — unauthenticated, returns only { ok: true }.
|
|
5
21
|
*
|
|
@@ -15,26 +31,87 @@ export class Health extends Resource {
|
|
|
15
31
|
/**
|
|
16
32
|
* Authenticated health detail — returns memory/agent/soul stats + process info.
|
|
17
33
|
* Requires Ed25519 agent auth or admin basic auth.
|
|
34
|
+
*
|
|
35
|
+
* Every optional-subsystem lookup is wrapped so a missing table or absent
|
|
36
|
+
* schema downgrades to "not configured" rather than failing the whole call.
|
|
18
37
|
*/
|
|
19
38
|
export class HealthDetail extends Resource {
|
|
20
39
|
async get() {
|
|
21
40
|
const stats = { ok: true };
|
|
41
|
+
const nowMs = Date.now();
|
|
42
|
+
const warnings = [];
|
|
43
|
+
const ctx = this.getContext?.();
|
|
44
|
+
const request = ctx?.request ?? ctx;
|
|
45
|
+
const callerAgent = request?.tpsAgent;
|
|
46
|
+
const isAdmin = request?.tpsAgentIsAdmin === true || !callerAgent;
|
|
47
|
+
stats.caller = { agentId: callerAgent ?? null, isAdmin };
|
|
48
|
+
let memoriesList = [];
|
|
22
49
|
// ── Memory stats ──
|
|
23
50
|
try {
|
|
24
|
-
const list = [];
|
|
25
51
|
for await (const m of db.flair.Memory.search({})) {
|
|
26
|
-
|
|
52
|
+
memoriesList.push(m);
|
|
53
|
+
}
|
|
54
|
+
// Per-model counts: "hash-512d" is the hash-fallback marker; any other
|
|
55
|
+
// value (or missing value) is a real embedding. Multiple distinct
|
|
56
|
+
// non-hash models means mixed vector spaces — cross-space searches
|
|
57
|
+
// return garbage unless reconciled via `flair reembed`.
|
|
58
|
+
const modelCounts = {};
|
|
59
|
+
for (const m of memoriesList) {
|
|
60
|
+
const model = m.embeddingModel || "hash-512d";
|
|
61
|
+
modelCounts[model] = (modelCounts[model] ?? 0) + 1;
|
|
62
|
+
}
|
|
63
|
+
const hashFallback = modelCounts["hash-512d"] ?? 0;
|
|
64
|
+
const withEmbeddings = memoriesList.length - hashFallback;
|
|
65
|
+
const realModels = Object.keys(modelCounts).filter((k) => k !== "hash-512d");
|
|
66
|
+
const byDurability = { permanent: 0, persistent: 0, standard: 0, ephemeral: 0 };
|
|
67
|
+
let archived = 0;
|
|
68
|
+
let expired = 0;
|
|
69
|
+
for (const m of memoriesList) {
|
|
70
|
+
const d = (m.durability ?? "standard");
|
|
71
|
+
if (d in byDurability)
|
|
72
|
+
byDurability[d]++;
|
|
73
|
+
if (m.archived)
|
|
74
|
+
archived++;
|
|
75
|
+
if (!m.archived && m.validTo && new Date(m.validTo).getTime() < nowMs)
|
|
76
|
+
expired++;
|
|
27
77
|
}
|
|
28
78
|
stats.memories = {
|
|
29
|
-
total:
|
|
30
|
-
withEmbeddings
|
|
31
|
-
hashFallback
|
|
79
|
+
total: memoriesList.length,
|
|
80
|
+
withEmbeddings,
|
|
81
|
+
hashFallback,
|
|
82
|
+
modelCounts,
|
|
83
|
+
byDurability,
|
|
84
|
+
archived,
|
|
85
|
+
expired,
|
|
32
86
|
};
|
|
33
|
-
if (
|
|
34
|
-
const sorted =
|
|
87
|
+
if (memoriesList.length > 0) {
|
|
88
|
+
const sorted = memoriesList
|
|
89
|
+
.filter((m) => m.createdAt)
|
|
90
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
35
91
|
if (sorted[0])
|
|
36
92
|
stats.lastWrite = sorted[0].createdAt;
|
|
37
93
|
}
|
|
94
|
+
if (expired > 0)
|
|
95
|
+
warnings.push({ level: "warn", message: `${expired} memories have expired validTo but aren't archived` });
|
|
96
|
+
// Hash-fallback coverage — tiered by percentage. Thresholds are
|
|
97
|
+
// first-pass defaults; Kern's review on ops-n4n may tune them.
|
|
98
|
+
if (memoriesList.length > 0) {
|
|
99
|
+
const pct = Math.round((hashFallback / memoriesList.length) * 100);
|
|
100
|
+
if (pct >= 10) {
|
|
101
|
+
warnings.push({
|
|
102
|
+
level: "warn",
|
|
103
|
+
message: `${hashFallback}/${memoriesList.length} (${pct}%) memories are hash-fallback — run: flair reembed --stale-only --dry-run`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Mixed embedding models — searches across vector spaces return garbage.
|
|
108
|
+
if (realModels.length > 1) {
|
|
109
|
+
const list = realModels.map((k) => `${k}:${modelCounts[k]}`).join(", ");
|
|
110
|
+
warnings.push({
|
|
111
|
+
level: "warn",
|
|
112
|
+
message: `multiple embedding models in use (${list}) — cross-model search unreliable; run: flair reembed against one model`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
38
115
|
}
|
|
39
116
|
catch {
|
|
40
117
|
stats.memories = null;
|
|
@@ -42,28 +119,380 @@ export class HealthDetail extends Resource {
|
|
|
42
119
|
// ── Agent stats ──
|
|
43
120
|
try {
|
|
44
121
|
const agents = [];
|
|
45
|
-
for await (const a of db.flair.Agent.search({}))
|
|
122
|
+
for await (const a of db.flair.Agent.search({}))
|
|
46
123
|
agents.push(a);
|
|
124
|
+
const blank = (id) => ({
|
|
125
|
+
id, memoryCount: 0, hashFallback: 0, writes24h: 0, lastWriteAt: null,
|
|
126
|
+
});
|
|
127
|
+
const perAgentMap = new Map();
|
|
128
|
+
for (const a of agents) {
|
|
129
|
+
if (a.id)
|
|
130
|
+
perAgentMap.set(a.id, blank(a.id));
|
|
131
|
+
}
|
|
132
|
+
const cutoff24h = nowMs - 24 * 3600 * 1000;
|
|
133
|
+
for (const m of memoriesList) {
|
|
134
|
+
if (!m.agentId)
|
|
135
|
+
continue;
|
|
136
|
+
const row = perAgentMap.get(m.agentId) ?? blank(m.agentId);
|
|
137
|
+
row.memoryCount++;
|
|
138
|
+
if (!m.embeddingModel || m.embeddingModel === "hash-512d")
|
|
139
|
+
row.hashFallback++;
|
|
140
|
+
if (m.createdAt) {
|
|
141
|
+
const ts = new Date(m.createdAt).getTime();
|
|
142
|
+
if (ts >= cutoff24h)
|
|
143
|
+
row.writes24h++;
|
|
144
|
+
if (!row.lastWriteAt || ts > new Date(row.lastWriteAt).getTime()) {
|
|
145
|
+
row.lastWriteAt = m.createdAt;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
perAgentMap.set(m.agentId, row);
|
|
47
149
|
}
|
|
150
|
+
const perAgentFull = Array.from(perAgentMap.values()).sort((a, b) => b.memoryCount - a.memoryCount);
|
|
151
|
+
const perAgent = isAdmin
|
|
152
|
+
? perAgentFull
|
|
153
|
+
: perAgentFull.filter((r) => r.id === callerAgent);
|
|
48
154
|
stats.agents = {
|
|
49
155
|
count: agents.length,
|
|
50
|
-
names: agents.map((a) => a.id).filter(Boolean),
|
|
156
|
+
names: isAdmin ? agents.map((a) => a.id).filter(Boolean) : undefined,
|
|
157
|
+
perAgent,
|
|
51
158
|
};
|
|
52
159
|
}
|
|
53
160
|
catch {
|
|
54
161
|
stats.agents = null;
|
|
55
162
|
}
|
|
56
|
-
// ──
|
|
163
|
+
// ── Relationships ──
|
|
57
164
|
try {
|
|
58
|
-
|
|
59
|
-
for await (const
|
|
60
|
-
|
|
165
|
+
const rels = [];
|
|
166
|
+
for await (const r of db.flair.Relationship.search({}))
|
|
167
|
+
rels.push(r);
|
|
168
|
+
if (rels.length === 0) {
|
|
169
|
+
stats.relationships = null;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
const active = rels.filter((r) => !r.validTo || new Date(r.validTo).getTime() > nowMs).length;
|
|
173
|
+
stats.relationships = { total: rels.length, active };
|
|
61
174
|
}
|
|
62
|
-
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
stats.relationships = null;
|
|
178
|
+
}
|
|
179
|
+
// ── Soul ──
|
|
180
|
+
try {
|
|
181
|
+
const souls = [];
|
|
182
|
+
for await (const s of db.flair.Soul.search({}))
|
|
183
|
+
souls.push(s);
|
|
184
|
+
stats.soulEntries = souls.length;
|
|
185
|
+
const byPriority = { critical: 0, high: 0, standard: 0, low: 0 };
|
|
186
|
+
for (const s of souls) {
|
|
187
|
+
const p = (s.priority ?? "standard");
|
|
188
|
+
if (p in byPriority)
|
|
189
|
+
byPriority[p]++;
|
|
190
|
+
}
|
|
191
|
+
stats.soul = { total: souls.length, byPriority };
|
|
63
192
|
}
|
|
64
193
|
catch {
|
|
65
194
|
stats.soulEntries = null;
|
|
195
|
+
stats.soul = null;
|
|
196
|
+
}
|
|
197
|
+
// ── Federation ──
|
|
198
|
+
try {
|
|
199
|
+
const instances = [];
|
|
200
|
+
try {
|
|
201
|
+
for await (const i of db.flair.Instance.search({}))
|
|
202
|
+
instances.push(i);
|
|
203
|
+
}
|
|
204
|
+
catch { /* absent */ }
|
|
205
|
+
const peers = [];
|
|
206
|
+
try {
|
|
207
|
+
for await (const p of db.flair.Peer.search({}))
|
|
208
|
+
peers.push(p);
|
|
209
|
+
}
|
|
210
|
+
catch { /* absent */ }
|
|
211
|
+
const tokens = [];
|
|
212
|
+
try {
|
|
213
|
+
for await (const t of db.flair.PairingToken.search({}))
|
|
214
|
+
tokens.push(t);
|
|
215
|
+
}
|
|
216
|
+
catch { /* absent */ }
|
|
217
|
+
if (instances.length === 0 && peers.length === 0) {
|
|
218
|
+
stats.federation = null;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const inst = instances[0];
|
|
222
|
+
const peersBlock = {
|
|
223
|
+
total: peers.length,
|
|
224
|
+
connected: peers.filter((p) => p.status === "connected").length,
|
|
225
|
+
disconnected: peers.filter((p) => p.status === "disconnected").length,
|
|
226
|
+
revoked: peers.filter((p) => p.status === "revoked").length,
|
|
227
|
+
};
|
|
228
|
+
const pendingTokens = tokens.filter((t) => !t.consumedBy && t.expiresAt && new Date(t.expiresAt).getTime() > nowMs).length;
|
|
229
|
+
stats.federation = {
|
|
230
|
+
instance: inst ? { id: inst.id, role: inst.role, status: inst.status } : null,
|
|
231
|
+
peers: peersBlock,
|
|
232
|
+
pendingTokens,
|
|
233
|
+
peerList: isAdmin
|
|
234
|
+
? peers.map((p) => ({
|
|
235
|
+
id: p.id,
|
|
236
|
+
role: p.role,
|
|
237
|
+
status: p.status,
|
|
238
|
+
lastSyncAt: p.lastSyncAt ?? null,
|
|
239
|
+
}))
|
|
240
|
+
: undefined,
|
|
241
|
+
};
|
|
242
|
+
if (peers.length > 0 && peersBlock.connected === 0) {
|
|
243
|
+
const oldest = peers
|
|
244
|
+
.map((p) => (p.lastSyncAt ? new Date(p.lastSyncAt).getTime() : 0))
|
|
245
|
+
.reduce((a, b) => (a === 0 ? b : b === 0 ? a : Math.min(a, b)), 0);
|
|
246
|
+
if (oldest > 0 && nowMs - oldest > 24 * 3600 * 1000) {
|
|
247
|
+
warnings.push({ level: "warn", message: "federation peers all disconnected >24h" });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (pendingTokens > 0) {
|
|
251
|
+
warnings.push({ level: "info", message: `${pendingTokens} pairing token(s) unconsumed` });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
stats.federation = null;
|
|
257
|
+
}
|
|
258
|
+
// ── OAuth / IdP ──
|
|
259
|
+
try {
|
|
260
|
+
const clients = [];
|
|
261
|
+
try {
|
|
262
|
+
for await (const c of db.flair.OAuthClient.search({}))
|
|
263
|
+
clients.push(c);
|
|
264
|
+
}
|
|
265
|
+
catch { /* absent */ }
|
|
266
|
+
const idps = [];
|
|
267
|
+
try {
|
|
268
|
+
for await (const i of db.flair.IdpConfig.search({}))
|
|
269
|
+
idps.push(i);
|
|
270
|
+
}
|
|
271
|
+
catch { /* absent */ }
|
|
272
|
+
let activeTokens = 0;
|
|
273
|
+
let tokensAvailable = true;
|
|
274
|
+
try {
|
|
275
|
+
const toks = [];
|
|
276
|
+
for await (const t of db.flair.OAuthToken.search({}))
|
|
277
|
+
toks.push(t);
|
|
278
|
+
activeTokens = toks.filter((t) => {
|
|
279
|
+
if (t.revokedAt)
|
|
280
|
+
return false;
|
|
281
|
+
if (t.expiresAt && new Date(t.expiresAt).getTime() < nowMs)
|
|
282
|
+
return false;
|
|
283
|
+
return true;
|
|
284
|
+
}).length;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
tokensAvailable = false;
|
|
288
|
+
}
|
|
289
|
+
if (clients.length === 0 && idps.length === 0) {
|
|
290
|
+
stats.oauth = null;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
stats.oauth = {
|
|
294
|
+
clients: clients.length,
|
|
295
|
+
idpConfigs: idps.length,
|
|
296
|
+
activeTokens: tokensAvailable ? activeTokens : 0,
|
|
297
|
+
clientList: isAdmin
|
|
298
|
+
? clients.map((c) => ({
|
|
299
|
+
id: c.id,
|
|
300
|
+
name: c.name,
|
|
301
|
+
registeredBy: c.registeredBy ?? null,
|
|
302
|
+
createdAt: c.createdAt ?? null,
|
|
303
|
+
}))
|
|
304
|
+
: undefined,
|
|
305
|
+
idpList: isAdmin
|
|
306
|
+
? idps.map((i) => ({ id: i.id, name: i.name, issuer: i.issuer }))
|
|
307
|
+
: undefined,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
stats.oauth = null;
|
|
313
|
+
}
|
|
314
|
+
// ── REM ──
|
|
315
|
+
try {
|
|
316
|
+
const logsDir = join(homedir(), ".flair", "logs");
|
|
317
|
+
const remLog = join(logsDir, "rem.jsonl");
|
|
318
|
+
const nightlyLog = join(logsDir, "rem-nightly.jsonl");
|
|
319
|
+
const tailJsonl = async (path, maxBytes = 256 * 1024) => {
|
|
320
|
+
let fh = null;
|
|
321
|
+
try {
|
|
322
|
+
const st = await fsp.stat(path).catch(() => null);
|
|
323
|
+
if (!st)
|
|
324
|
+
return [];
|
|
325
|
+
const start = Math.max(0, st.size - maxBytes);
|
|
326
|
+
const len = st.size - start;
|
|
327
|
+
if (len === 0)
|
|
328
|
+
return [];
|
|
329
|
+
fh = await fsp.open(path, "r");
|
|
330
|
+
const buf = Buffer.alloc(len);
|
|
331
|
+
await fh.read(buf, 0, len, start);
|
|
332
|
+
return buf
|
|
333
|
+
.toString("utf-8")
|
|
334
|
+
.split("\n")
|
|
335
|
+
.filter((l) => l.trim())
|
|
336
|
+
.map((l) => { try {
|
|
337
|
+
return JSON.parse(l);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return null;
|
|
341
|
+
} })
|
|
342
|
+
.filter(Boolean);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return [];
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
if (fh)
|
|
349
|
+
await fh.close().catch(() => { });
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const remRecords = await tailJsonl(remLog);
|
|
353
|
+
const findLast = (kind) => {
|
|
354
|
+
for (let i = remRecords.length - 1; i >= 0; i--) {
|
|
355
|
+
const r = remRecords[i];
|
|
356
|
+
if (r && (r.kind === kind || r.type === kind))
|
|
357
|
+
return r.at ?? r.ts ?? r.timestamp ?? null;
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
};
|
|
361
|
+
const lastLightAt = findLast("light");
|
|
362
|
+
const lastRapidAt = findLast("rapid");
|
|
363
|
+
const lastRestorativeAt = findLast("restorative");
|
|
364
|
+
let nightlyEnabled = null;
|
|
365
|
+
const plat = platform();
|
|
366
|
+
if (plat === "darwin") {
|
|
367
|
+
nightlyEnabled = await exists(join(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist"));
|
|
368
|
+
}
|
|
369
|
+
else if (plat === "linux") {
|
|
370
|
+
nightlyEnabled = await exists(join(homedir(), ".config", "systemd", "user", "flair-rem-nightly.timer"));
|
|
371
|
+
}
|
|
372
|
+
const nightlyRecords = await tailJsonl(nightlyLog);
|
|
373
|
+
const lastNightlyRec = nightlyRecords[nightlyRecords.length - 1];
|
|
374
|
+
const lastNightlyAt = lastNightlyRec ? (lastNightlyRec.at ?? lastNightlyRec.ts ?? lastNightlyRec.timestamp ?? null) : null;
|
|
375
|
+
let pendingCandidates = null;
|
|
376
|
+
try {
|
|
377
|
+
let count = 0;
|
|
378
|
+
for await (const c of db.flair.MemoryCandidate.search({})) {
|
|
379
|
+
if (c.status === "pending")
|
|
380
|
+
count++;
|
|
381
|
+
}
|
|
382
|
+
pendingCandidates = count;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
pendingCandidates = null;
|
|
386
|
+
}
|
|
387
|
+
const allNull = !lastLightAt &&
|
|
388
|
+
!lastRapidAt &&
|
|
389
|
+
!lastRestorativeAt &&
|
|
390
|
+
nightlyEnabled === null &&
|
|
391
|
+
!lastNightlyAt &&
|
|
392
|
+
pendingCandidates === null;
|
|
393
|
+
if (allNull) {
|
|
394
|
+
stats.rem = null;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
stats.rem = {
|
|
398
|
+
lastLightAt,
|
|
399
|
+
lastRapidAt,
|
|
400
|
+
lastRestorativeAt,
|
|
401
|
+
nightlyEnabled,
|
|
402
|
+
lastNightlyAt,
|
|
403
|
+
pendingCandidates,
|
|
404
|
+
};
|
|
405
|
+
if (nightlyEnabled && lastNightlyAt && nowMs - new Date(lastNightlyAt).getTime() > 48 * 3600 * 1000) {
|
|
406
|
+
warnings.push({ level: "warn", message: "nightly REM hasn't run in >48h" });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
stats.rem = null;
|
|
412
|
+
}
|
|
413
|
+
// ── Disk ──
|
|
414
|
+
try {
|
|
415
|
+
const dataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
|
|
416
|
+
const snapshotDir = join(homedir(), ".flair", "snapshots");
|
|
417
|
+
const dirSize = async (root, maxDepth = 6) => {
|
|
418
|
+
if (!(await exists(root)))
|
|
419
|
+
return null;
|
|
420
|
+
let total = 0;
|
|
421
|
+
const walk = async (p, depth) => {
|
|
422
|
+
if (depth > maxDepth)
|
|
423
|
+
return;
|
|
424
|
+
let entries;
|
|
425
|
+
try {
|
|
426
|
+
entries = await fsp.readdir(p, { withFileTypes: true });
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const subdirs = [];
|
|
432
|
+
const files = [];
|
|
433
|
+
for (const e of entries) {
|
|
434
|
+
const full = join(p, e.name);
|
|
435
|
+
if (e.isDirectory())
|
|
436
|
+
subdirs.push(full);
|
|
437
|
+
else if (e.isFile())
|
|
438
|
+
files.push(full);
|
|
439
|
+
}
|
|
440
|
+
const sizes = await Promise.all(files.map((f) => fsp.stat(f).then((s) => s.size).catch(() => 0)));
|
|
441
|
+
total += sizes.reduce((a, b) => a + b, 0);
|
|
442
|
+
await Promise.all(subdirs.map((d) => walk(d, depth + 1)));
|
|
443
|
+
};
|
|
444
|
+
await walk(root, 0);
|
|
445
|
+
return total;
|
|
446
|
+
};
|
|
447
|
+
const [dataBytes, snapshotBytes] = await Promise.all([dirSize(dataDir), dirSize(snapshotDir)]);
|
|
448
|
+
if (dataBytes === null && snapshotBytes === null) {
|
|
449
|
+
stats.disk = null;
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
stats.disk = {
|
|
453
|
+
dataDir: isAdmin ? dataDir : redactHome(dataDir),
|
|
454
|
+
dataBytes: dataBytes ?? 0,
|
|
455
|
+
snapshotDir: isAdmin ? snapshotDir : redactHome(snapshotDir),
|
|
456
|
+
snapshotBytes: snapshotBytes ?? 0,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
stats.disk = null;
|
|
462
|
+
}
|
|
463
|
+
// ── Bridges ──
|
|
464
|
+
try {
|
|
465
|
+
const cwd = process.cwd();
|
|
466
|
+
const candidates = [join(cwd, "node_modules"), join(homedir(), ".flair", "node_modules")];
|
|
467
|
+
const installed = new Set();
|
|
468
|
+
await Promise.all(candidates.map(async (base) => {
|
|
469
|
+
if (!(await exists(base)))
|
|
470
|
+
return;
|
|
471
|
+
try {
|
|
472
|
+
const names = await fsp.readdir(base);
|
|
473
|
+
for (const name of names) {
|
|
474
|
+
if (name.startsWith("flair-bridge-"))
|
|
475
|
+
installed.add(name);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch { /* skip */ }
|
|
479
|
+
}));
|
|
480
|
+
if (installed.size === 0) {
|
|
481
|
+
stats.bridges = null;
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
stats.bridges = {
|
|
485
|
+
installed: Array.from(installed).sort(),
|
|
486
|
+
lastImport: null,
|
|
487
|
+
lastExport: null,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
stats.bridges = null;
|
|
66
493
|
}
|
|
494
|
+
// ── Warnings ──
|
|
495
|
+
stats.warnings = warnings;
|
|
67
496
|
// ── Process info ──
|
|
68
497
|
stats.pid = process.pid;
|
|
69
498
|
stats.uptimeSeconds = Math.floor(process.uptime());
|