@tpsdev-ai/flair 0.48.0 → 0.50.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 +2 -0
- package/dist/bridges/runtime/roundtrip.js +91 -2
- package/dist/build-info.json +3 -3
- package/dist/cli.js +903 -226
- package/dist/component-env.js +52 -4
- package/dist/deploy.js +20 -3
- package/dist/doctor-client.js +105 -32
- package/dist/federation/scheduler.js +24 -3
- package/dist/hook-install.js +96 -16
- package/dist/install/clients.js +318 -9
- package/dist/lib/auth-resolve.js +34 -3
- package/dist/lib/mcp-enable.js +134 -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/AgentSeed.js +2 -0
- package/dist/resources/Memory.js +24 -5
- package/dist/resources/MemoryBootstrap.js +8 -4
- package/dist/resources/MemoryFeed.js +3 -0
- package/dist/resources/MemoryMaintenance.js +11 -2
- package/dist/resources/bm25-index-service.js +257 -0
- package/dist/resources/bm25-index.js +631 -0
- package/dist/resources/bm25.js +31 -1
- package/dist/resources/embeddings-boot.js +45 -3
- package/dist/resources/health.js +52 -7
- package/dist/resources/mcp-tools.js +1 -0
- package/dist/resources/memory-read-scope.js +2 -0
- package/dist/resources/search-readiness.js +100 -0
- package/dist/resources/semantic-retrieval-core.js +102 -23
- package/dist/resources/sort-comparators.js +45 -0
- package/dist/src/lib/scheduler-platform.js +132 -10
- package/dist/src/rem/scheduler.js +23 -5
- package/dist/version-check.js +59 -13
- package/docs/auth.md +5 -0
- package/docs/claude-code.md +10 -3
- package/docs/deepseek-harness.md +1 -1
- package/docs/deployment.md +11 -1
- package/docs/hosted-on-fabric.md +2 -0
- package/docs/integrations.md +78 -5
- package/docs/mcp-clients.md +85 -15
- package/docs/notes/mcp-oauth-model2.md +31 -13
- package/docs/quickstart-fabric.md +1 -1
- package/docs/quickstart.md +9 -9
- package/docs/standalone-local.md +3 -0
- package/docs/troubleshooting.md +25 -0
- package/package.json +4 -3
package/dist/lib/mcp-enable.js
CHANGED
|
@@ -555,12 +555,57 @@ function opsBaseUrl(opsPortOrUrl) {
|
|
|
555
555
|
function basicAuthHeader(adminUser, adminPass) {
|
|
556
556
|
return `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
557
557
|
}
|
|
558
|
+
/** The resolver's own predicate, verbatim (resources/mcp-handler.ts
|
|
559
|
+
* `resolveAgentFromSub`): a credential is resolvable unless it is explicitly
|
|
560
|
+
* revoked. The linking layer MUST use the same test — a credential the linker
|
|
561
|
+
* considers inactive but the resolver would still serve is exactly the
|
|
562
|
+
* invisible-duplicate hole #1317 is about. */
|
|
563
|
+
function isResolvableCredential(cred) {
|
|
564
|
+
return cred?.status !== "revoked";
|
|
565
|
+
}
|
|
558
566
|
/**
|
|
559
567
|
* Map the operator's IdP subject to their principal via `Credential(kind:
|
|
560
568
|
* "idp")` — the SAME credential surface resources/mcp-handler.ts's
|
|
561
|
-
* `resolveAgentFromSub` reads at request time.
|
|
562
|
-
*
|
|
563
|
-
*
|
|
569
|
+
* `resolveAgentFromSub` reads at request time.
|
|
570
|
+
*
|
|
571
|
+
* ## The uniqueness constraint (flair#1317, K&S ruling 2026-08-21)
|
|
572
|
+
*
|
|
573
|
+
* **At most one ACTIVE `Credential(kind:"idp", idpSubject:<sub>)` exists at a
|
|
574
|
+
* time, regardless of `idpProvider`.** This function is where that invariant is
|
|
575
|
+
* enforced, because it is the only supported writer of the mapping.
|
|
576
|
+
*
|
|
577
|
+
* It used to dedup on `(kind, idpProvider, idpSubject)` while the resolver read
|
|
578
|
+
* `(kind, idpSubject)`. A re-link under a different provider name therefore
|
|
579
|
+
* matched nothing, INSERTED a second active credential, and left
|
|
580
|
+
* `resolveAgentFromSub` picking whichever row its search iterator served first
|
|
581
|
+
* — identity resolution by iteration order, on a security-relevant mapping.
|
|
582
|
+
*
|
|
583
|
+
* The resolver's key is the correct one and does not change: an IdP subject is
|
|
584
|
+
* an identity, and "who is this subject?" has exactly one answer. `idpProvider`
|
|
585
|
+
* stays on the row as audit/diagnostic metadata, but it does not participate in
|
|
586
|
+
* uniqueness. So:
|
|
587
|
+
*
|
|
588
|
+
* - same provider, existing active credential → RE-POINT it (`credentialReused`);
|
|
589
|
+
* - any OTHER active credential for the subject → SUPERSEDE it: terminal
|
|
590
|
+
* `status: "revoked"`, never a soft flag a later path could flip back
|
|
591
|
+
* (revoked rows are never reused here — a re-link after a revoke mints a
|
|
592
|
+
* fresh credential);
|
|
593
|
+
* - the re-point/insert and every revocation go out as ONE ops-API `upsert`
|
|
594
|
+
* batch, so there is no observable window with two active credentials or
|
|
595
|
+
* zero. If the batch fails, nothing is claimed and the call throws;
|
|
596
|
+
* - after the write the invariant is RE-READ and asserted. A store that
|
|
597
|
+
* somehow holds ≠1 active credential for the subject is a hard error, not a
|
|
598
|
+
* silent nondeterministic mapping.
|
|
599
|
+
*
|
|
600
|
+
* The principal Agent is created only if missing.
|
|
601
|
+
*
|
|
602
|
+
* RESIDUAL RISK, by design (Sherlock, #1317): whoever can call this for a
|
|
603
|
+
* subject can revoke that subject's prior credential. If two genuinely
|
|
604
|
+
* different people ever shared a subject string across providers, one's link
|
|
605
|
+
* kills the other's mapping. IdP subjects are opaque per-IdP identifiers so the
|
|
606
|
+
* collision is remote, and the alternative — duplicate active credentials with
|
|
607
|
+
* order-dependent resolution — is strictly worse. `credentialSuperseded` exists
|
|
608
|
+
* so the operator is told, not so the event is hidden.
|
|
564
609
|
*/
|
|
565
610
|
export async function provisionIdpIdentityMapping(params, deps = {}) {
|
|
566
611
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
@@ -628,26 +673,43 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
|
|
|
628
673
|
}
|
|
629
674
|
principalCreated = true;
|
|
630
675
|
}
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
676
|
+
// ── flair#1317: look SUBJECT-WIDE, not (provider, subject) ─────────────────
|
|
677
|
+
// The resolver's key is (kind, idpSubject); anything narrower here leaves
|
|
678
|
+
// credentials that dedup cannot see but resolution can.
|
|
679
|
+
const findCredentialsForSubject = async () => {
|
|
680
|
+
const res = await fetchImpl(opsUrl, {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: { "Content-Type": "application/json", Authorization: authHeader },
|
|
683
|
+
body: JSON.stringify({
|
|
684
|
+
operation: "search_by_conditions",
|
|
685
|
+
database: "flair",
|
|
686
|
+
table: "Credential",
|
|
687
|
+
operator: "and",
|
|
688
|
+
conditions: [
|
|
689
|
+
{ search_attribute: "kind", search_type: "equals", search_value: "idp" },
|
|
690
|
+
{ search_attribute: "idpSubject", search_type: "equals", search_value: params.idpSubject },
|
|
691
|
+
],
|
|
692
|
+
get_attributes: ["id", "principalId", "idpProvider", "idpSubject", "status", "label", "createdAt"],
|
|
693
|
+
}),
|
|
694
|
+
});
|
|
695
|
+
const body = res.ok ? await res.json().catch(() => []) : [];
|
|
696
|
+
return Array.isArray(body) ? body : [];
|
|
697
|
+
};
|
|
698
|
+
const subjectCreds = await findCredentialsForSubject();
|
|
699
|
+
const activeCreds = subjectCreds.filter(isResolvableCredential);
|
|
700
|
+
// Survivor: an ACTIVE same-provider credential is re-pointed (the idempotent
|
|
701
|
+
// re-run and the documented same-provider link). A revoked one is never
|
|
702
|
+
// resurrected — a re-link after a revoke mints a fresh credential.
|
|
703
|
+
const reused = activeCreds.find((c) => c?.idpProvider === params.idpProvider && c?.id);
|
|
704
|
+
const credentialId = reused?.id ?? `cred_idp_${params.idpProvider}_${randomBytes(6).toString("hex")}`;
|
|
705
|
+
// Everything else active for this subject is superseded. Under the old
|
|
706
|
+
// (provider, subject) key these rows were simply invisible; they are what made
|
|
707
|
+
// resolution order-dependent.
|
|
708
|
+
const superseded = activeCreds.filter((c) => c?.id && c.id !== credentialId);
|
|
709
|
+
// ONE batched write: the survivor first, then the revocations. A single
|
|
710
|
+
// ops-API operation is the strongest atomicity this surface can express, and
|
|
711
|
+
// ordering the survivor first means even a partially-applied batch can never
|
|
712
|
+
// leave the subject with ZERO resolvable credentials (the fail-open denial).
|
|
651
713
|
const upsertRes = await fetchImpl(opsUrl, {
|
|
652
714
|
method: "POST",
|
|
653
715
|
headers: { "Content-Type": "application/json", Authorization: authHeader },
|
|
@@ -664,9 +726,25 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
|
|
|
664
726
|
status: "active",
|
|
665
727
|
idpProvider: params.idpProvider,
|
|
666
728
|
idpSubject: params.idpSubject,
|
|
667
|
-
createdAt:
|
|
729
|
+
createdAt: reused ? undefined : now,
|
|
668
730
|
lastUsedAt: now,
|
|
669
731
|
},
|
|
732
|
+
// Retained, not deleted: the revocation stays legible in storage and in
|
|
733
|
+
// Harper's table audit log (which records the full record image of
|
|
734
|
+
// every write). Identifying fields are echoed back so the row survives
|
|
735
|
+
// as a well-formed, revoked credential whichever merge semantics the
|
|
736
|
+
// ops API applies.
|
|
737
|
+
...superseded.map((c) => ({
|
|
738
|
+
id: c.id,
|
|
739
|
+
principalId: c.principalId,
|
|
740
|
+
kind: "idp",
|
|
741
|
+
label: c.label,
|
|
742
|
+
status: "revoked",
|
|
743
|
+
idpProvider: c.idpProvider,
|
|
744
|
+
idpSubject: params.idpSubject,
|
|
745
|
+
createdAt: c.createdAt,
|
|
746
|
+
updatedAt: now,
|
|
747
|
+
})),
|
|
670
748
|
],
|
|
671
749
|
}),
|
|
672
750
|
});
|
|
@@ -674,7 +752,26 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
|
|
|
674
752
|
const text = await upsertRes.text().catch(() => "");
|
|
675
753
|
throw new Error(`Identity mapping: failed to write Credential(kind:idp) mapping (HTTP ${upsertRes.status}): ${text}`);
|
|
676
754
|
}
|
|
677
|
-
|
|
755
|
+
// ── The invariant, RE-READ ────────────────────────────────────────────────
|
|
756
|
+
// Asserting what we intended to write proves nothing. This asks the store.
|
|
757
|
+
// ≠1 active credential means the resolver's answer for this subject is
|
|
758
|
+
// order-dependent, so this fails LOUDLY rather than returning a mapping the
|
|
759
|
+
// operator would reasonably believe is deterministic.
|
|
760
|
+
const afterCreds = (await findCredentialsForSubject()).filter(isResolvableCredential);
|
|
761
|
+
if (afterCreds.length !== 1 || afterCreds[0]?.id !== credentialId) {
|
|
762
|
+
const seen = afterCreds.map((c) => `${c?.id} → ${c?.principalId} (provider '${c?.idpProvider}')`).join("; ") || "none";
|
|
763
|
+
throw new Error(`Identity mapping: the uniqueness invariant does not hold after the write — subject '${params.idpSubject}' ` +
|
|
764
|
+
`has ${afterCreds.length} active Credential(kind:idp) row(s) [${seen}], expected exactly 1 (${credentialId}). ` +
|
|
765
|
+
`Runtime resolution for this subject would be iteration-order-dependent (flair#1317). ` +
|
|
766
|
+
`Inspect the Credential table for kind:"idp" idpSubject:"${params.idpSubject}" and revoke the rows that should not resolve.`);
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
principalCreated,
|
|
770
|
+
credentialId,
|
|
771
|
+
credentialReused: Boolean(reused),
|
|
772
|
+
credentialSuperseded: superseded.length > 0,
|
|
773
|
+
supersededCredentialIds: superseded.map((c) => String(c.id)),
|
|
774
|
+
};
|
|
678
775
|
}
|
|
679
776
|
// ─── Restart only ────────────────────────────────────────────────────────────
|
|
680
777
|
/** `restart` only — used by `disableMcp` (flag off + restart, no config
|
|
@@ -1069,10 +1166,21 @@ export async function enableMcp(params, deps = {}) {
|
|
|
1069
1166
|
// and the one silent failure mode this surface has is discovering that
|
|
1070
1167
|
// via an empty bootstrap. So the step that creates the mapping states it
|
|
1071
1168
|
// plainly, names the link remedy, and points at the runtime diagnostic.
|
|
1169
|
+
// flair#1317 — a cross-provider re-link REVOKES the subject's prior
|
|
1170
|
+
// credential (one active credential per subject is the invariant). That is
|
|
1171
|
+
// a credential dying, so it is stated as such, by id: an operator must
|
|
1172
|
+
// never discover it later from something that stopped working.
|
|
1173
|
+
const supersedeNote = mapping.credentialSuperseded
|
|
1174
|
+
? ` SUPERSEDED: ${mapping.supersededCredentialIds.length} prior Credential(kind:idp) row(s) for this subject ` +
|
|
1175
|
+
`were REVOKED, not de-duplicated — ${mapping.supersededCredentialIds.join(", ")}. ` +
|
|
1176
|
+
`They no longer resolve, and anything relying on them stops working. ` +
|
|
1177
|
+
`Exactly one active credential per (kind, idpSubject) is the invariant that keeps resolution deterministic.`
|
|
1178
|
+
: "";
|
|
1072
1179
|
push(true, `connector identity: sub '${params.idpSubject}' (provider '${idpProvider}') resolves to Agent '${principal}' — ` +
|
|
1073
1180
|
`every /mcp call reads and writes AS '${principal}'. ` +
|
|
1074
1181
|
`principal ${mapping.principalCreated ? "created" : "already existed"}; ` +
|
|
1075
|
-
`Credential(kind:idp) ${mapping.credentialReused ? "re-pointed" : "created"} (${mapping.credentialId})
|
|
1182
|
+
`Credential(kind:idp) ${mapping.credentialReused ? "re-pointed" : "created"} (${mapping.credentialId}).` +
|
|
1183
|
+
`${supersedeNote} ` +
|
|
1076
1184
|
`If your CLI signs as a DIFFERENT agent id, the connector sees that agent's DISTINCT memory scope (by design) — ` +
|
|
1077
1185
|
`re-run with --principal <your-agent-id> to link them. ` +
|
|
1078
1186
|
`Diagnostic: the bootstrap tool's agentId/scope fields always say who the server resolved you to.`);
|
|
@@ -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
|
/**
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { Resource, databases } from "harper";
|
|
20
20
|
import { isAdmin, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
|
|
21
21
|
import { reconcileAdminFields } from "./agent-admin.js";
|
|
22
|
+
import { noteMemoryUpsert } from "./bm25-index-service.js";
|
|
22
23
|
const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
|
|
23
24
|
name: displayName,
|
|
24
25
|
role,
|
|
@@ -124,6 +125,7 @@ export class AgentSeed extends Resource {
|
|
|
124
125
|
archived: false,
|
|
125
126
|
};
|
|
126
127
|
await databases.flair.Memory.put(record);
|
|
128
|
+
noteMemoryUpsert(record);
|
|
127
129
|
memories.push(record);
|
|
128
130
|
}
|
|
129
131
|
} // end !hasOnboardingMemory
|
package/dist/resources/Memory.js
CHANGED
|
@@ -13,6 +13,7 @@ import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeSco
|
|
|
13
13
|
import { RECORD_TYPES } from "./record-types.js";
|
|
14
14
|
import { attachTrust } from "./trust-block.js";
|
|
15
15
|
import { recordCitations } from "./usage-recording.js";
|
|
16
|
+
import { noteMemoryUpsert, noteMemoryDelete } from "./bm25-index-service.js";
|
|
16
17
|
/**
|
|
17
18
|
* flair#744 slice 1 — read the opt-in `includeTrust` flag for a by-id get.
|
|
18
19
|
* Two entry shapes: an in-process caller (resources/mcp-tools.ts's memory_get)
|
|
@@ -306,7 +307,11 @@ async function closeSupersededRecord(ctx, oldId, patch) {
|
|
|
306
307
|
if (!existing) {
|
|
307
308
|
throw new Error(`supersede-close: record ${oldId} not found`);
|
|
308
309
|
}
|
|
309
|
-
|
|
310
|
+
const closed = { ...existing, ...patch };
|
|
311
|
+
await withDetachedTxn(ctx, () => databases.flair.Memory.put(closed));
|
|
312
|
+
// flair#1357 — a supersede-close sets `validTo`, which the retrieval filters
|
|
313
|
+
// read, so the lexical index has to see it as eagerly as a content write.
|
|
314
|
+
noteMemoryUpsert(closed);
|
|
310
315
|
}
|
|
311
316
|
/** Does an agent hold a "write" grant from `ownerId`? Same MemoryGrant lookup
|
|
312
317
|
* pattern as Memory.search()/SemanticSearch.ts (read/search scopes) — reused
|
|
@@ -766,6 +771,11 @@ export class Memory extends databases.flair.Memory {
|
|
|
766
771
|
await stampOriginatorInstanceId(content);
|
|
767
772
|
// ── Write the new record FIRST ──────────────────────────────────────────
|
|
768
773
|
const result = await super.post(content);
|
|
774
|
+
// flair#1357 — read-your-write for the lexical leg. The table change feed
|
|
775
|
+
// (resources/bm25-index-service.ts) is the CORRECTNESS mechanism; this
|
|
776
|
+
// synchronous hook is what makes a store immediately searchable rather
|
|
777
|
+
// than searchable-after-the-feed-turns.
|
|
778
|
+
noteMemoryUpsert(content);
|
|
769
779
|
// ── THEN close the superseded record ────────────────────────────────────
|
|
770
780
|
// Write-new-BEFORE-close-old: the previous order (close-old via a fire-
|
|
771
781
|
// and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
|
|
@@ -807,7 +817,9 @@ export class Memory extends databases.flair.Memory {
|
|
|
807
817
|
});
|
|
808
818
|
}
|
|
809
819
|
delete content._reindex;
|
|
810
|
-
|
|
820
|
+
const reindexed = await super.put(content);
|
|
821
|
+
noteMemoryUpsert(content);
|
|
822
|
+
return reindexed;
|
|
811
823
|
}
|
|
812
824
|
// Create/update ownership (same rule as post): a non-admin agent may only
|
|
813
825
|
// write memories it owns, via resolveAgentAuth (gate annotation), not
|
|
@@ -1043,6 +1055,8 @@ export class Memory extends databases.flair.Memory {
|
|
|
1043
1055
|
await stampOriginatorInstanceId(content);
|
|
1044
1056
|
// ── Write the new/updated record FIRST ──────────────────────────────────
|
|
1045
1057
|
const result = await super.put(content);
|
|
1058
|
+
// flair#1357 — read-your-write for the lexical leg (see post()).
|
|
1059
|
+
noteMemoryUpsert(content);
|
|
1046
1060
|
// ── THEN close the superseded record (see post()) ───────────────────────
|
|
1047
1061
|
await closeSupersededIfNeeded(ctx, content, "put");
|
|
1048
1062
|
// flair#744 slice A: citation-on-write — POST-COMMIT, fully
|
|
@@ -1067,8 +1081,11 @@ export class Memory extends databases.flair.Memory {
|
|
|
1067
1081
|
// before the read-gate fix — the read-scoping override must not leak
|
|
1068
1082
|
// into delete()'s internal record lookup.
|
|
1069
1083
|
const record = await super.get(id);
|
|
1070
|
-
if (!record)
|
|
1071
|
-
|
|
1084
|
+
if (!record) {
|
|
1085
|
+
const gone = await super.delete(id);
|
|
1086
|
+
noteMemoryDelete(id);
|
|
1087
|
+
return gone;
|
|
1088
|
+
}
|
|
1072
1089
|
if (record.durability === "permanent") {
|
|
1073
1090
|
// Middleware already guards this for non-admins, but belt-and-suspenders
|
|
1074
1091
|
const ctx = this.getContext?.();
|
|
@@ -1081,6 +1098,8 @@ export class Memory extends databases.flair.Memory {
|
|
|
1081
1098
|
});
|
|
1082
1099
|
}
|
|
1083
1100
|
}
|
|
1084
|
-
|
|
1101
|
+
const deleted = await super.delete(id);
|
|
1102
|
+
noteMemoryDelete(id);
|
|
1103
|
+
return deleted;
|
|
1085
1104
|
}
|
|
1086
1105
|
}
|
|
@@ -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
|
}
|