@tpsdev-ai/flair 0.49.0 → 0.51.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 +15 -4
- package/dist/bridges/runtime/roundtrip.js +91 -2
- package/dist/build-info.json +3 -3
- package/dist/cli.js +565 -171
- package/dist/deploy.js +20 -3
- package/dist/doctor-client.js +62 -34
- package/dist/federation/scheduler.js +24 -3
- package/dist/hook-install.js +45 -13
- package/dist/install/clients.js +28 -17
- package/dist/lib/doctor-run.js +481 -0
- package/dist/lib/launchd-management.js +7 -26
- package/dist/lib/scheduler-platform.js +132 -10
- package/dist/lib/scratch-owner.js +49 -0
- package/dist/rem/scheduler.js +23 -5
- package/dist/resources/Federation.js +42 -20
- package/dist/resources/MemoryBootstrap.js +8 -4
- package/dist/resources/RecordUsage.js +13 -6
- package/dist/resources/SemanticSearch.js +8 -1
- package/dist/resources/federation-classify.js +90 -0
- package/dist/resources/health.js +51 -7
- package/dist/resources/mcp-tools.js +10 -6
- package/dist/resources/search-readiness.js +123 -0
- package/dist/resources/semantic-retrieval-core.js +48 -21
- package/dist/resources/sort-comparators.js +45 -0
- package/dist/resources/usage-ids.js +63 -0
- package/dist/src/lib/scheduler-platform.js +132 -10
- package/dist/src/rem/scheduler.js +23 -5
- package/docs/auth.md +5 -0
- package/docs/deepseek-harness.md +1 -1
- package/docs/federation.md +11 -0
- package/docs/hosted-on-fabric.md +2 -0
- package/docs/integrations.md +53 -1
- package/docs/mcp-clients.md +67 -15
- package/docs/quickstart-fabric.md +1 -1
- package/docs/supply-chain-policy.md +1 -1
- package/docs/troubleshooting.md +25 -0
- package/docs/upgrade.md +17 -1
- package/package.json +3 -3
- package/schemas/federation.graphql +1 -1
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* in `verifyFirstRun()`: success may not be claimed until the thing the
|
|
21
21
|
* operator asked for has been observed to happen once.
|
|
22
22
|
*/
|
|
23
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
24
|
-
import { resolve, dirname, isAbsolute } from "node:path";
|
|
25
|
-
import { platform } from "node:os";
|
|
23
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
|
|
24
|
+
import { resolve, dirname, isAbsolute, basename } from "node:path";
|
|
25
|
+
import { platform, userInfo } from "node:os";
|
|
26
26
|
import { spawnSync } from "node:child_process";
|
|
27
27
|
/**
|
|
28
28
|
* 30s ceiling on launchctl/systemctl invocations so a hung service manager
|
|
@@ -90,20 +90,71 @@ export function interpretActiveResult(plat, code, stdout, stderr) {
|
|
|
90
90
|
return null; // spawn itself failed — inconclusive
|
|
91
91
|
return false; // covers the no-bus case: empty stdout, nonzero/failed exit
|
|
92
92
|
}
|
|
93
|
+
/** True when this session already has the env `systemctl --user` needs. */
|
|
94
|
+
export function sessionHasUserBusEnv(env = process.env) {
|
|
95
|
+
return Boolean(env.XDG_RUNTIME_DIR?.trim() && env.DBUS_SESSION_BUS_ADDRESS?.trim());
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Reads whether lingering is already enabled for the current user.
|
|
99
|
+
* `loginctl show-user … Linger=yes` is the official answer; the stamp file
|
|
100
|
+
* `loginctl enable-linger` creates is the fallback when loginctl is missing
|
|
101
|
+
* or inconclusive. A failed probe is `null`, never linger-off — inventing
|
|
102
|
+
* linger-off would repeat the linger remedy after it already ran (#1107).
|
|
103
|
+
*/
|
|
104
|
+
export function probeUserLingerEnabled(opts = {}) {
|
|
105
|
+
const run = opts.run ?? spawnReport;
|
|
106
|
+
const lingerStampExists = opts.lingerStampExists ?? ((u) => existsSync(`/var/lib/systemd/linger/${u}`));
|
|
107
|
+
let user = "";
|
|
108
|
+
try {
|
|
109
|
+
user = userInfo().username;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
user = process.env.USER || process.env.LOGNAME || "";
|
|
113
|
+
}
|
|
114
|
+
if (!user)
|
|
115
|
+
return null;
|
|
116
|
+
const r = run(["loginctl", "show-user", user, "--property=Linger"], STATUS_CHECK_TIMEOUT_MS);
|
|
117
|
+
const m = /^Linger=(yes|no)\s*$/m.exec(r.stdout ?? "");
|
|
118
|
+
if (m)
|
|
119
|
+
return m[1] === "yes";
|
|
120
|
+
if (lingerStampExists(user))
|
|
121
|
+
return true;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
93
124
|
/**
|
|
94
|
-
* Human remedy text for a failed scheduler-load attempt (flair#850).
|
|
95
|
-
* the
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
125
|
+
* Human remedy text for a failed scheduler-load attempt (flair#850, #1107).
|
|
126
|
+
* Covers the traced "no systemd user session bus" failure, which blocks
|
|
127
|
+
* `systemctl --user` entirely in ssh-without-lingering, container, and CI
|
|
128
|
+
* contexts. Two cases that used to share one remedy:
|
|
129
|
+
* (a) lingering genuinely off — print `loginctl enable-linger`
|
|
130
|
+
* (b) linger already on, this session has no user-bus env — print the
|
|
131
|
+
* `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` export lines
|
|
132
|
+
* Repeating (a) after the operator has applied it is the #1107 lie.
|
|
133
|
+
* Returns null when the failure doesn't match a known pattern — the caller
|
|
134
|
+
* already prints the raw stderr, so the operator still has something to go on.
|
|
100
135
|
*
|
|
101
136
|
* `enableCommand` is the caller's own enable invocation, named in the remedy
|
|
102
137
|
* so the operator is told to re-run the command they actually ran.
|
|
103
138
|
*/
|
|
104
|
-
export function describeLoadFailure(plat, loadResult, enableCommand) {
|
|
139
|
+
export function describeLoadFailure(plat, loadResult, enableCommand, session) {
|
|
105
140
|
const stderr = loadResult.stderr || "";
|
|
106
141
|
if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
|
|
142
|
+
if (session?.lingerEnabled === true) {
|
|
143
|
+
const env = session.env ?? process.env;
|
|
144
|
+
if (!sessionHasUserBusEnv(env)) {
|
|
145
|
+
return ("No systemd user session bus is available in this session. Lingering is already enabled — " +
|
|
146
|
+
"do not re-run `loginctl enable-linger`. The remaining gap is this session's user-bus environment. " +
|
|
147
|
+
"Export:\n" +
|
|
148
|
+
" export XDG_RUNTIME_DIR=/run/user/$(id -u)\n" +
|
|
149
|
+
" export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus\n" +
|
|
150
|
+
` then re-run \`${enableCommand}\`.`);
|
|
151
|
+
}
|
|
152
|
+
return ("No systemd user session bus is available in this session. Lingering is already enabled and " +
|
|
153
|
+
"this session already has XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS — " +
|
|
154
|
+
"do not re-run `loginctl enable-linger` or re-export those variables. " +
|
|
155
|
+
"Check that `$XDG_RUNTIME_DIR/bus` exists (the systemd --user instance may not be running), " +
|
|
156
|
+
`then re-run \`${enableCommand}\`.`);
|
|
157
|
+
}
|
|
107
158
|
return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
|
|
108
159
|
"in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
|
|
109
160
|
`— then re-run \`${enableCommand}\`.`);
|
|
@@ -172,6 +223,77 @@ export function resolveNodeBin(explicit) {
|
|
|
172
223
|
"enable time — refusing to install a shim that would resolve `node` from the service manager's PATH " +
|
|
173
224
|
"at run time. Install node (or put it on PATH for this shell) and re-run enable.");
|
|
174
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Resolves the path enable will bake as FLAIR_BIN, and whether that path is
|
|
228
|
+
* the stable public `flair` entry (flair#1279).
|
|
229
|
+
*
|
|
230
|
+
* Resolution order:
|
|
231
|
+
* 1. `explicit` — caller/test override. Relatives are resolved against cwd.
|
|
232
|
+
* 2. `hooks.argv1` / `process.argv[1]` — whatever launched enable.
|
|
233
|
+
* 3. The public `flair` on PATH, only when (1) and (2) are empty.
|
|
234
|
+
* Nothing absolute resolvable ⇒ throw. A bare `"flair"` is not an exec
|
|
235
|
+
* target under #1231's `exec <node> <script>` form (`node flair` looks in
|
|
236
|
+
* cwd, not PATH).
|
|
237
|
+
*/
|
|
238
|
+
export function resolveFlairBin(explicit, hooks) {
|
|
239
|
+
const publicBin = hooks && "publicBin" in hooks ? (hooks.publicBin ?? null) : lookupPublicFlairBin();
|
|
240
|
+
const captured = explicit ?? hooks?.argv1 ?? process.argv[1];
|
|
241
|
+
let path;
|
|
242
|
+
if (typeof captured === "string" && captured.length > 0) {
|
|
243
|
+
path = isAbsolute(captured) ? captured : resolve(captured);
|
|
244
|
+
}
|
|
245
|
+
else if (publicBin) {
|
|
246
|
+
path = publicBin;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
throw new Error("unable to resolve an absolute path to the flair CLI (process.argv[1] was empty and `command -v flair` " +
|
|
250
|
+
"found nothing). The scheduler shim bakes this path in at enable time — refusing to install a shim " +
|
|
251
|
+
"whose exec target is unknown. Re-run enable via the `flair` command.");
|
|
252
|
+
}
|
|
253
|
+
return { path, publicBin, canonical: isCanonicalFlairBin(path, publicBin) };
|
|
254
|
+
}
|
|
255
|
+
/** True when `baked` is the public `flair` entry, not a working-tree capture. */
|
|
256
|
+
export function isCanonicalFlairBin(baked, publicBin) {
|
|
257
|
+
if (basename(baked) === "flair")
|
|
258
|
+
return true;
|
|
259
|
+
if (publicBin && pathsReferToSameFile(baked, publicBin))
|
|
260
|
+
return true;
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The enable-report lines for a non-canonical FLAIR_BIN. Empty when the
|
|
265
|
+
* baked path is the public entry — callers should not print a warning then.
|
|
266
|
+
*/
|
|
267
|
+
export function formatFlairBinWarning(baked, publicBin, enableCommand) {
|
|
268
|
+
if (isCanonicalFlairBin(baked, publicBin))
|
|
269
|
+
return [];
|
|
270
|
+
const lines = [
|
|
271
|
+
`⚠️ FLAIR_BIN is ${baked} — that is the process that ran enable, not a stable public entry.`,
|
|
272
|
+
` A later blue/green directory swap, or deleting this working tree, will strand the scheduler unit.`,
|
|
273
|
+
];
|
|
274
|
+
if (publicBin) {
|
|
275
|
+
lines.push(` Public \`flair\` on PATH: ${publicBin}. Re-run \`${enableCommand}\` as the \`flair\` command to bake that path instead.`);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
lines.push(` No \`flair\` on PATH. Re-run \`${enableCommand}\` via the installed \`flair\` command (or a stable symlink) so the baked path survives a tree swap.`);
|
|
279
|
+
}
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
|
282
|
+
function lookupPublicFlairBin() {
|
|
283
|
+
const r = spawnReport(["/bin/sh", "-c", "command -v flair"], STATUS_CHECK_TIMEOUT_MS);
|
|
284
|
+
const found = r.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
285
|
+
if (r.code === 0 && found && isAbsolute(found) && existsSync(found))
|
|
286
|
+
return found;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function pathsReferToSameFile(a, b) {
|
|
290
|
+
try {
|
|
291
|
+
return realpathSync(a) === realpathSync(b);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return resolve(a) === resolve(b);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
175
297
|
// ─── first-run verification (flair#1231) ────────────────────────────────────
|
|
176
298
|
// A load/bootstrap command exiting 0 proves the service manager accepted the
|
|
177
299
|
// job — not that the job can run. The only vantage that exercises the real
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner stamp for harness scratch directories (flair#1032).
|
|
3
|
+
*
|
|
4
|
+
* Directory mtime is not a liveness signal: on Linux, appending to files
|
|
5
|
+
* inside subdirectories does not update the parent. The stamp records the
|
|
6
|
+
* creating process; a sweep may delete a tree only when that process is gone
|
|
7
|
+
* (and, for Harper trees, when `hdb.pid` is gone too).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
export const SCRATCH_OWNER_FILE = ".flair-scratch-owner";
|
|
12
|
+
export function writeScratchOwnerStamp(dir, pid = process.pid) {
|
|
13
|
+
writeFileSync(join(dir, SCRATCH_OWNER_FILE), `${pid}\n`, { encoding: "utf-8" });
|
|
14
|
+
}
|
|
15
|
+
export function isPidAlive(pid) {
|
|
16
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function readPidFile(path) {
|
|
27
|
+
try {
|
|
28
|
+
const pid = Number(readFileSync(path, "utf-8").trim());
|
|
29
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function readScratchOwnerPid(dir) {
|
|
36
|
+
return readPidFile(join(dir, SCRATCH_OWNER_FILE));
|
|
37
|
+
}
|
|
38
|
+
export function hasScratchOwnerStamp(dir) {
|
|
39
|
+
return existsSync(join(dir, SCRATCH_OWNER_FILE));
|
|
40
|
+
}
|
|
41
|
+
/** True when the creating process is still alive. Unreadable stamp → not live. */
|
|
42
|
+
export function scratchOwnerIsLive(dir) {
|
|
43
|
+
const pid = readScratchOwnerPid(dir);
|
|
44
|
+
return pid !== null && isPidAlive(pid);
|
|
45
|
+
}
|
|
46
|
+
export function hdbPidIsLive(dir) {
|
|
47
|
+
const pid = readPidFile(join(dir, "hdb.pid"));
|
|
48
|
+
return pid !== null && isPidAlive(pid);
|
|
49
|
+
}
|
package/dist/rem/scheduler.js
CHANGED
|
@@ -19,7 +19,7 @@ import { homedir } from "node:os";
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { escapeXml } from "../lib/xml-escape.js";
|
|
22
|
-
import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, verifyFirstRun, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
|
|
22
|
+
import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, resolveFlairBin, formatFlairBinWarning, verifyFirstRun, probeUserLingerEnabled, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
|
|
23
23
|
// Re-exported so this module's public surface is unchanged by the extraction
|
|
24
24
|
// into src/lib/scheduler-platform.ts (a second scheduler — `flair federation
|
|
25
25
|
// sync enable` — needs the identical launchctl/systemctl interpretation, and
|
|
@@ -180,8 +180,8 @@ export async function queryActiveStateAsync(plat, timeoutMs = STATUS_CHECK_TIMEO
|
|
|
180
180
|
* known pattern — the caller already prints the raw stderr, so the operator
|
|
181
181
|
* still has something to go on.
|
|
182
182
|
*/
|
|
183
|
-
export function describeLoadFailure(plat, loadResult) {
|
|
184
|
-
return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable");
|
|
183
|
+
export function describeLoadFailure(plat, loadResult, session) {
|
|
184
|
+
return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable", session);
|
|
185
185
|
}
|
|
186
186
|
/**
|
|
187
187
|
* Formats the `flair rem nightly enable` report from an `EnableResult`.
|
|
@@ -199,6 +199,15 @@ export function describeLoadFailure(plat, loadResult) {
|
|
|
199
199
|
* happen once. A missing `loadResult`/`firstRun` (test-only skipLoad shape)
|
|
200
200
|
* therefore withholds the headline too, instead of being treated as success.
|
|
201
201
|
*/
|
|
202
|
+
function appendFlairBinWarning(lines, r) {
|
|
203
|
+
if (r.flairBinCanonical !== false || !r.flairBin)
|
|
204
|
+
return;
|
|
205
|
+
const warning = formatFlairBinWarning(r.flairBin, r.flairBinPublic ?? null, "flair rem nightly enable");
|
|
206
|
+
if (warning.length === 0)
|
|
207
|
+
return;
|
|
208
|
+
lines.push("");
|
|
209
|
+
lines.push(...warning);
|
|
210
|
+
}
|
|
202
211
|
export function formatEnableReport(r, input) {
|
|
203
212
|
const { hour, minute, agentId, flairUrl } = input;
|
|
204
213
|
const scheduleTime = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
@@ -216,11 +225,15 @@ export function formatEnableReport(r, input) {
|
|
|
216
225
|
];
|
|
217
226
|
if (lr.stderr)
|
|
218
227
|
lines.push(` stderr: ${lr.stderr.trim()}`);
|
|
219
|
-
const
|
|
228
|
+
const lingerEnabled = input.lingerEnabled !== undefined
|
|
229
|
+
? input.lingerEnabled
|
|
230
|
+
: (r.platform === "linux" ? (input.probeLinger ?? probeUserLingerEnabled)() : undefined);
|
|
231
|
+
const remedy = describeLoadFailure(r.platform, lr, { lingerEnabled, env: input.env });
|
|
220
232
|
lines.push("");
|
|
221
233
|
lines.push(remedy ? ` ${remedy}` : ` Re-run the activation command above manually to see the full diagnostic.`);
|
|
222
234
|
lines.push("");
|
|
223
235
|
lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair rem nightly status`);
|
|
236
|
+
appendFlairBinWarning(lines, r);
|
|
224
237
|
return { lines, ok: false };
|
|
225
238
|
}
|
|
226
239
|
if (!r.firstRunVerified) {
|
|
@@ -272,6 +285,7 @@ export function formatEnableReport(r, input) {
|
|
|
272
285
|
}
|
|
273
286
|
lines.push("");
|
|
274
287
|
lines.push(` Check anytime with: flair rem nightly status`);
|
|
288
|
+
appendFlairBinWarning(lines, r);
|
|
275
289
|
return { lines, ok: false };
|
|
276
290
|
}
|
|
277
291
|
const lines = [
|
|
@@ -288,6 +302,7 @@ export function formatEnableReport(r, input) {
|
|
|
288
302
|
lines.push(` First run: completed through the service manager, exit 0`);
|
|
289
303
|
lines.push("");
|
|
290
304
|
lines.push(`Disable with \`flair rem nightly disable\`.`);
|
|
305
|
+
appendFlairBinWarning(lines, r);
|
|
291
306
|
return { lines, ok: true };
|
|
292
307
|
}
|
|
293
308
|
/**
|
|
@@ -329,7 +344,8 @@ export function formatStatusReport(s) {
|
|
|
329
344
|
*/
|
|
330
345
|
export function enableScheduler(opts) {
|
|
331
346
|
const plat = detectPlatform(opts.platformOverride);
|
|
332
|
-
const
|
|
347
|
+
const resolvedFlair = resolveFlairBin(opts.flairBin);
|
|
348
|
+
const flairBin = resolvedFlair.path;
|
|
333
349
|
const nodeBin = resolveNodeBin(opts.nodeBin);
|
|
334
350
|
const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
335
351
|
const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
|
|
@@ -383,6 +399,7 @@ export function enableScheduler(opts) {
|
|
|
383
399
|
return {
|
|
384
400
|
platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult,
|
|
385
401
|
firstRunVerified: firstRun?.verified === true, firstRun,
|
|
402
|
+
flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
|
|
386
403
|
};
|
|
387
404
|
}
|
|
388
405
|
// Linux: systemd user units.
|
|
@@ -408,6 +425,7 @@ export function enableScheduler(opts) {
|
|
|
408
425
|
return {
|
|
409
426
|
platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult,
|
|
410
427
|
firstRunVerified: firstRun?.verified === true, firstRun,
|
|
428
|
+
flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
|
|
411
429
|
};
|
|
412
430
|
}
|
|
413
431
|
/**
|
|
@@ -5,8 +5,8 @@ import { allowAdmin } from "./agent-auth.js";
|
|
|
5
5
|
import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce, } from "./federation-crypto.js";
|
|
6
6
|
import { initFederationCleanup } from "./federation-cleanup.js";
|
|
7
7
|
import { createPersistentNonceStore, initNonceStoreCleanup } from "./federation-nonce-store.js";
|
|
8
|
-
import { classifyRecord } from "./federation-classify.js";
|
|
9
|
-
export { classifyRecord } from "./federation-classify.js";
|
|
8
|
+
import { classifyRecord, reconstructRecordVerifyBody, checkPrincipalEntitlement, } from "./federation-classify.js";
|
|
9
|
+
export { classifyRecord, reconstructRecordVerifyBody, checkPrincipalEntitlement, recordSignatureVersion, PRINCIPAL_OWNING_TABLES, FEDERATION_TABLE_POLICY, FEDERATION_SYNC_TABLES, } from "./federation-classify.js";
|
|
10
10
|
// Module-level nonce store for federation anti-replay.
|
|
11
11
|
// Shared across FederationPair + FederationSync — nonces are globally unique
|
|
12
12
|
// (generated by signBodyFresh per request with 128-bit random nonces).
|
|
@@ -37,6 +37,17 @@ export { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodyS
|
|
|
37
37
|
function requireRecordSignatures() {
|
|
38
38
|
return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_SIGNATURES ?? "").toLowerCase() === "true";
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Phase 3 of flair#1416 — skip leftover v:1 records on principal-owning
|
|
42
|
+
* tables that lack principalId. Default OFF. Flip only once every paired
|
|
43
|
+
* peer is emitting v:2 (check SyncLog for unsigned / v:1 Memory). Same
|
|
44
|
+
* operator-decision pattern as requireRecordSignatures() — never
|
|
45
|
+
* auto-flipped. v:2 Memory is already mandatory-principal regardless of
|
|
46
|
+
* this flag (see checkPrincipalEntitlement).
|
|
47
|
+
*/
|
|
48
|
+
function requireRecordPrincipal() {
|
|
49
|
+
return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_PRINCIPAL ?? "").toLowerCase() === "true";
|
|
50
|
+
}
|
|
40
51
|
// ─── Conflict resolution ─────────────────────────────────────────────────────
|
|
41
52
|
/**
|
|
42
53
|
* Field-level Last-Write-Wins merge.
|
|
@@ -359,7 +370,10 @@ export class FederationSync extends Resource {
|
|
|
359
370
|
skipped++;
|
|
360
371
|
skippedReasons[reason] = (skippedReasons[reason] ?? 0) + 1;
|
|
361
372
|
}
|
|
362
|
-
// Table name → Harper database table mapping
|
|
373
|
+
// Table name → Harper database table mapping.
|
|
374
|
+
// Typed against FEDERATION_TABLE_POLICY so adding a federated table
|
|
375
|
+
// without deciding principalOwning is a type error, not a silent
|
|
376
|
+
// default (flair#1416 — refuse by whitelist, never by field presence).
|
|
363
377
|
const tableMap = {
|
|
364
378
|
Memory: databases.flair.Memory,
|
|
365
379
|
Soul: databases.flair.Soul,
|
|
@@ -369,7 +383,9 @@ export class FederationSync extends Resource {
|
|
|
369
383
|
const knownTables = new Set(Object.keys(tableMap));
|
|
370
384
|
for (const record of records) {
|
|
371
385
|
try {
|
|
372
|
-
const table =
|
|
386
|
+
const table = (record.table in tableMap)
|
|
387
|
+
? tableMap[record.table]
|
|
388
|
+
: undefined;
|
|
373
389
|
const local = table ? await table.get(record.id) : null;
|
|
374
390
|
const decision = classifyRecord(record, peer.role, instanceId, local, knownTables);
|
|
375
391
|
if (decision.action === "skip") {
|
|
@@ -403,22 +419,15 @@ export class FederationSync extends Resource {
|
|
|
403
419
|
recordSkip("unknown_originator_key");
|
|
404
420
|
continue;
|
|
405
421
|
}
|
|
406
|
-
// CONTRACT —
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
table: record.table,
|
|
416
|
-
id: record.id,
|
|
417
|
-
data: record.data,
|
|
418
|
-
updatedAt: record.updatedAt,
|
|
419
|
-
originatorInstanceId: originator,
|
|
420
|
-
signature: record.signature,
|
|
421
|
-
}, originatorPublicKey);
|
|
422
|
+
// CONTRACT — reconstruct from the record (v defaults to 1 when
|
|
423
|
+
// absent — v is NOT on the wire today). Must match
|
|
424
|
+
// reconstructRecordVerifyBody / src/cli.ts's signing payload.
|
|
425
|
+
// A hardcoded { v: 1, table, id, data, updatedAt,
|
|
426
|
+
// originatorInstanceId } field set can only ever verify one
|
|
427
|
+
// shape; building from the record is what makes v:2 (principalId
|
|
428
|
+
// in the signed body) verifiable without breaking existing
|
|
429
|
+
// records. See flair#1416.
|
|
430
|
+
const signatureValid = verifyBodySignature(reconstructRecordVerifyBody(record, originator), originatorPublicKey);
|
|
422
431
|
if (!signatureValid) {
|
|
423
432
|
recordSkip("invalid_signature");
|
|
424
433
|
continue;
|
|
@@ -431,6 +440,19 @@ export class FederationSync extends Resource {
|
|
|
431
440
|
recordSkip("missing_signature");
|
|
432
441
|
continue;
|
|
433
442
|
}
|
|
443
|
+
// ── Per-record principal entitlement (flair#1416 / slice 3a) ──
|
|
444
|
+
// After signature verification, before table.put. Scoped by the
|
|
445
|
+
// explicit PRINCIPAL_OWNING_TABLES set (Memory), never by whether
|
|
446
|
+
// principalId happens to be present — absent Memory principalId
|
|
447
|
+
// is a skip, not an accept. Soul/Agent/Relationship are not in
|
|
448
|
+
// the set and are not consulted. No Agent.get.
|
|
449
|
+
const principalSkip = checkPrincipalEntitlement(record, {
|
|
450
|
+
enforceV1Principal: requireRecordPrincipal(),
|
|
451
|
+
});
|
|
452
|
+
if (principalSkip) {
|
|
453
|
+
recordSkip(principalSkip);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
434
456
|
const mergedData = mergeRecord(local, record);
|
|
435
457
|
mergedData._originatorInstanceId = decision.originator;
|
|
436
458
|
mergedData._syncedFrom = instanceId;
|
|
@@ -502,16 +502,18 @@ export class BootstrapMemories extends Resource {
|
|
|
502
502
|
skillAssignments.push(record);
|
|
503
503
|
continue;
|
|
504
504
|
}
|
|
505
|
-
// flair#1182 — the raw soul container (key→value), independent of the
|
|
506
|
-
// token-budgeted/priority-truncated `sections.soul` lines built below.
|
|
507
|
-
soulMap[record.key] = record.value;
|
|
508
505
|
const line = `**${record.key}:** ${record.value}`;
|
|
509
506
|
const tokens = estimateTokens(line);
|
|
510
507
|
const priority = SOUL_KEY_PRIORITY[record.key] ?? 50;
|
|
511
|
-
soulEntries.push({ key: record.key, line, tokens, priority });
|
|
508
|
+
soulEntries.push({ key: record.key, value: record.value, line, tokens, priority });
|
|
512
509
|
}
|
|
513
510
|
// Sort by priority (lower = more important)
|
|
514
511
|
soulEntries.sort((a, b) => a.priority - b.priority);
|
|
512
|
+
// flair#1371 — the structured `soul` map follows the admission decision.
|
|
513
|
+
// Filling it during the scan (flair#1182) shipped every key even when
|
|
514
|
+
// this loop dropped the entry, so sections.soul / soulTokens / the
|
|
515
|
+
// context pointer described N while `soul` delivered N+1 (delivered
|
|
516
|
+
// but not counted or charged — the #1206 mirror).
|
|
515
517
|
for (const entry of soulEntries) {
|
|
516
518
|
if (soulTokens + entry.tokens > soulMaxTokens) {
|
|
517
519
|
// Skip large entries that exceed budget — truncate or skip
|
|
@@ -522,6 +524,7 @@ export class BootstrapMemories extends Resource {
|
|
|
522
524
|
if (maxChars > 100) {
|
|
523
525
|
const truncated = `**${entry.key}:** ${entry.line.slice(entry.key.length + 6, entry.key.length + 6 + maxChars)}…(truncated)`;
|
|
524
526
|
sections.soul.push(truncated);
|
|
527
|
+
soulMap[entry.key] = entry.value;
|
|
525
528
|
const cost = estimateTokens(truncated);
|
|
526
529
|
soulTokens += cost;
|
|
527
530
|
tokenBudget -= cost; // #1199 — soul draws from the shared budget
|
|
@@ -529,6 +532,7 @@ export class BootstrapMemories extends Resource {
|
|
|
529
532
|
continue;
|
|
530
533
|
}
|
|
531
534
|
sections.soul.push(entry.line);
|
|
535
|
+
soulMap[entry.key] = entry.value;
|
|
532
536
|
soulTokens += entry.tokens;
|
|
533
537
|
tokenBudget -= entry.tokens; // #1199 — soul draws from the shared budget
|
|
534
538
|
}
|
|
@@ -97,6 +97,7 @@ import { Resource } from "harper";
|
|
|
97
97
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
98
98
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
99
99
|
import { recordUsageContribution, MAX_USAGE_IDS_PER_CALL } from "./usage-recording.js";
|
|
100
|
+
import { resolveRecordUsageIds } from "./usage-ids.js";
|
|
100
101
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
101
102
|
const BAD_REQUEST = (msg) => new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
102
103
|
// flair#744 slice A: sourced from the shared module (./usage-recording.ts)
|
|
@@ -159,14 +160,20 @@ export class RecordUsage extends Resource {
|
|
|
159
160
|
const rl = checkRateLimit(agentId, "usage");
|
|
160
161
|
if (!rl.allowed)
|
|
161
162
|
return rateLimitResponse(rl.retryAfterMs, "usage");
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
// flair#1410: MERGE memoryId + memoryIds (union, then dedupe). The
|
|
164
|
+
// previous `data?.memoryIds ?? [data?.memoryId]` preferred the plural
|
|
165
|
+
// and silently dropped the singular — quiet data loss. Unioning HERE
|
|
166
|
+
// means a client that POSTs both fields straight through (without
|
|
167
|
+
// flattening first) still credits both. Native `/mcp` also unions
|
|
168
|
+
// before calling this; the endpoint is the guarantee, not the client.
|
|
169
|
+
const resolved = resolveRecordUsageIds(data, MAX_IDS_PER_CALL);
|
|
170
|
+
if (!resolved.ok) {
|
|
171
|
+
if (resolved.error === "cap") {
|
|
172
|
+
return BAD_REQUEST(`memoryIds exceeds the per-call limit of ${MAX_IDS_PER_CALL}`);
|
|
173
|
+
}
|
|
164
174
|
return BAD_REQUEST("memoryIds must be a non-empty array of memory id strings");
|
|
165
175
|
}
|
|
166
|
-
|
|
167
|
-
return BAD_REQUEST(`memoryIds exceeds the per-call limit of ${MAX_IDS_PER_CALL}`);
|
|
168
|
-
}
|
|
169
|
-
const memoryIds = [...new Set(rawIds)]; // dedupe within THIS call too
|
|
176
|
+
const memoryIds = resolved.ids;
|
|
170
177
|
const attribution = sanitizeAttribution(data?.attribution);
|
|
171
178
|
const now = new Date().toISOString();
|
|
172
179
|
for (const memoryId of memoryIds) {
|
|
@@ -61,7 +61,7 @@ export class SemanticSearch extends Resource {
|
|
|
61
61
|
// recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
|
|
62
62
|
// before reconsidering this default if the compositeScore formula or
|
|
63
63
|
// corpus changes.
|
|
64
|
-
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false } = data || {};
|
|
64
|
+
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false, includeLegs = false } = data || {};
|
|
65
65
|
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
66
66
|
// `this.request` is NOT populated on Harper v5 Resources — prior reads here
|
|
67
67
|
// silently returned undefined and the defense-in-depth scope check below
|
|
@@ -215,6 +215,7 @@ export class SemanticSearch extends Resource {
|
|
|
215
215
|
// composite re-scoring headroom to reorder before the final slice.
|
|
216
216
|
const candidateLimit = limit * CANDIDATE_MULTIPLIER;
|
|
217
217
|
const ctx = this.getContext?.();
|
|
218
|
+
let legs;
|
|
218
219
|
const filteredResults = await retrieveCandidates({
|
|
219
220
|
queryEmbedding: qEmb,
|
|
220
221
|
q,
|
|
@@ -230,6 +231,7 @@ export class SemanticSearch extends Resource {
|
|
|
230
231
|
isAllowed: scope?.isAllowed,
|
|
231
232
|
hybrid,
|
|
232
233
|
ctx,
|
|
234
|
+
onLegs: includeLegs ? (l) => { legs = l; } : undefined,
|
|
233
235
|
// flair#744 slice 1: the trust block needs `provenance`, which the
|
|
234
236
|
// default projection omits. Widen the select ONLY when the caller opts
|
|
235
237
|
// in — passing undefined otherwise keeps the default (no `provenance`)
|
|
@@ -330,6 +332,11 @@ export class SemanticSearch extends Resource {
|
|
|
330
332
|
if (!qEmb && q && getMode() === "none") {
|
|
331
333
|
response._warning = "semantic search unavailable — results are keyword-only";
|
|
332
334
|
}
|
|
335
|
+
// flair#1358: opt-in per-leg candidate ids for the bench instrument.
|
|
336
|
+
// Default OFF ⇒ response is byte-identical (no `legs` key). The ranked
|
|
337
|
+
// `results` slice is unchanged either way — this is observation only.
|
|
338
|
+
if (includeLegs && legs)
|
|
339
|
+
response.legs = legs;
|
|
333
340
|
return response;
|
|
334
341
|
}
|
|
335
342
|
}
|
|
@@ -5,6 +5,96 @@
|
|
|
5
5
|
* spinning up Harper's database module. The same SkipReason names are used
|
|
6
6
|
* in SyncLog.skippedReasons so operators can grep for them.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Static policy for every table FederationSync will merge.
|
|
10
|
+
*
|
|
11
|
+
* Lives next to SkipReason / SyncRecord so the principal-owning decision is
|
|
12
|
+
* one visible list, not a condition scattered through the apply path.
|
|
13
|
+
* `Federation.ts` types its `tableMap` as `Record<FederationSyncTable, …>`,
|
|
14
|
+
* so adding a federated table without deciding `principalOwning` here is a
|
|
15
|
+
* type error rather than a silent default.
|
|
16
|
+
*
|
|
17
|
+
* Scope the principalId requirement by TABLE, never by field presence.
|
|
18
|
+
* Memory carries agentId / a provenance stamp; Soul, Agent, and
|
|
19
|
+
* Relationship do not, and will legitimately have no principalId.
|
|
20
|
+
*/
|
|
21
|
+
export const FEDERATION_TABLE_POLICY = {
|
|
22
|
+
Memory: { principalOwning: true },
|
|
23
|
+
Soul: { principalOwning: false },
|
|
24
|
+
Agent: { principalOwning: false },
|
|
25
|
+
Relationship: { principalOwning: false },
|
|
26
|
+
};
|
|
27
|
+
export const FEDERATION_SYNC_TABLES = Object.keys(FEDERATION_TABLE_POLICY);
|
|
28
|
+
export const PRINCIPAL_OWNING_TABLES = new Set(FEDERATION_SYNC_TABLES.filter((t) => FEDERATION_TABLE_POLICY[t].principalOwning));
|
|
29
|
+
/** Wire `v` when present; assume 1 when absent (today's records omit it). */
|
|
30
|
+
export function recordSignatureVersion(record) {
|
|
31
|
+
return record.v ?? 1;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Rebuild the object FederationSync verifies against the originator's key.
|
|
35
|
+
*
|
|
36
|
+
* Load-bearing details, both of which fail every existing record if missed:
|
|
37
|
+
*
|
|
38
|
+
* 1. `v` is not on the wire today. The push side signs a body containing
|
|
39
|
+
* `v: 1` but sends a SyncRecord without it. Verification works today
|
|
40
|
+
* only because the receiver pinned the same literal. Default it.
|
|
41
|
+
* Apply `v` AFTER the spread so an absent/undefined `record.v` cannot
|
|
42
|
+
* overwrite the default.
|
|
43
|
+
*
|
|
44
|
+
* 2. `principalId` IS on the wire today for some records, but it is
|
|
45
|
+
* attached AFTER signing (informational). Spreading it into a v:1
|
|
46
|
+
* verify body changes the field set and fails those records. v:1
|
|
47
|
+
* therefore strips it; v:2 signs it, so it stays.
|
|
48
|
+
*
|
|
49
|
+
* `originatorInstanceId` is the classifyRecord originator (same override
|
|
50
|
+
* the pre-3a hardcoded reconstruction used), not a blind spread of the
|
|
51
|
+
* wire field.
|
|
52
|
+
*/
|
|
53
|
+
export function reconstructRecordVerifyBody(record, originator) {
|
|
54
|
+
const v = recordSignatureVersion(record);
|
|
55
|
+
const { signature, v: _wireV, principalId, ...payload } = record;
|
|
56
|
+
const verifyPayload = v >= 2 && principalId !== undefined ? { ...payload, principalId } : payload;
|
|
57
|
+
return {
|
|
58
|
+
...verifyPayload,
|
|
59
|
+
v,
|
|
60
|
+
originatorInstanceId: originator,
|
|
61
|
+
signature,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Per-record principal entitlement — apply-site check, DB-free.
|
|
66
|
+
*
|
|
67
|
+
* Table in PRINCIPAL_OWNING_TABLES → principalId is mandatory on v:2
|
|
68
|
+
* (absent is a skip, mismatched is a skip). Table not in the set →
|
|
69
|
+
* principalId is not consulted at all.
|
|
70
|
+
*
|
|
71
|
+
* Does not load Agent, does not read originatorInstanceId off an Agent
|
|
72
|
+
* row. The record's own stamp is the binding.
|
|
73
|
+
*
|
|
74
|
+
* `enforceV1Principal` is Phase 3 (FLAIR_FEDERATION_REQUIRE_RECORD_PRINCIPAL):
|
|
75
|
+
* skip leftover v:1 records on principal-owning tables that lack
|
|
76
|
+
* principalId. Off by default — v:1 Memory keeps merging until an
|
|
77
|
+
* operator flips the flag after the fleet is on v:2.
|
|
78
|
+
*/
|
|
79
|
+
export function checkPrincipalEntitlement(record, opts = {}) {
|
|
80
|
+
if (!PRINCIPAL_OWNING_TABLES.has(record.table)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const v = recordSignatureVersion(record);
|
|
84
|
+
if (v >= 2) {
|
|
85
|
+
if (typeof record.principalId !== "string" ||
|
|
86
|
+
record.principalId.length === 0 ||
|
|
87
|
+
record.principalId !== record.data?.agentId) {
|
|
88
|
+
return "principal_mismatch";
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (opts.enforceV1Principal &&
|
|
93
|
+
(typeof record.principalId !== "string" || record.principalId.length === 0)) {
|
|
94
|
+
return "principal_mismatch";
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
8
98
|
export function classifyRecord(record, peerRole, receiverInstanceId, local, knownTables, now = new Date()) {
|
|
9
99
|
if (!knownTables.has(record.table)) {
|
|
10
100
|
return { action: "skip", reason: "unknown_table" };
|