@tpsdev-ai/flair 0.44.13 → 0.46.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/config.yaml +35 -2
- package/dist/cli.js +410 -21
- package/dist/doctor-client.js +266 -0
- package/dist/federation/scheduler.js +114 -9
- package/dist/hook-install.js +150 -1
- package/dist/lib/mcp-enable.js +59 -19
- package/dist/lib/safe-snapshot-extract.js +20 -2
- package/dist/lib/scheduler-platform.js +210 -1
- package/dist/rem/scheduler.js +113 -13
- package/dist/rem/snapshot.js +14 -13
- package/dist/resources/Memory.js +32 -1
- package/dist/resources/MemoryBootstrap.js +88 -51
- package/dist/resources/MemoryFeed.js +56 -1
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/abstention.js +12 -9
- package/dist/resources/auth-middleware.js +11 -3
- package/dist/resources/bm25.js +7 -3
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/resources/memory-visibility.js +48 -0
- package/dist/resources/semantic-retrieval-core.js +99 -19
- package/dist/src/lib/scheduler-platform.js +210 -1
- package/dist/src/rem/scheduler.js +113 -13
- package/docs/notes/mcp-oauth-model2.md +10 -3
- package/docs/quickstart-fabric.md +42 -3
- package/package.json +1 -1
- package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
|
@@ -79,3 +79,51 @@ export function assertValidVisibility(visibility) {
|
|
|
79
79
|
`(got: ${JSON.stringify(visibility)}). Omit it to use the durability-keyed default: ` +
|
|
80
80
|
`permanent/persistent -> shared, standard/ephemeral -> private.`);
|
|
81
81
|
}
|
|
82
|
+
/** Deliberately a LOCAL literal, not an import from memory-durability.ts: this
|
|
83
|
+
* module's zero-imports property is load-bearing (see the header — src/cli.ts
|
|
84
|
+
* must be able to import it with no transitive baggage), and the tripwire
|
|
85
|
+
* tests in test/unit/visibility-write-validation.test.ts pin the value to the
|
|
86
|
+
* durability enum so the two cannot drift apart silently. */
|
|
87
|
+
export const EPHEMERAL_DURABILITY = "ephemeral";
|
|
88
|
+
/**
|
|
89
|
+
* ─── flair#1257 hard precondition: ephemeral memories are private-only ───────
|
|
90
|
+
*
|
|
91
|
+
* `ephemeral` is the continuity-journal tier: auto-captured working state,
|
|
92
|
+
* self-pruning, never meant to leave its owner. `defaultVisibilityForDurability`
|
|
93
|
+
* keys it to `private`, but a DEFAULT is not a CONSTRAINT — before this guard,
|
|
94
|
+
* an explicit `visibility:"shared"` on an ephemeral write was accepted, which
|
|
95
|
+
* would have made journal entries org-readable AND federation-pushed. Kern's
|
|
96
|
+
* #1257 ruling requires the server to REFUSE the combination so the boundary
|
|
97
|
+
* holds for every caller (REST, in-process, any adapter), not just the hooks
|
|
98
|
+
* that promise to send `private` explicitly.
|
|
99
|
+
*
|
|
100
|
+
* The rule is deliberately "ephemeral may only carry `private` or nothing",
|
|
101
|
+
* NOT "refuse ephemeral+shared": on the read side any value other than the
|
|
102
|
+
* literal "private" resolves to non-private (the migration invariant above),
|
|
103
|
+
* so an unknown value on an ephemeral row would leak exactly like "shared".
|
|
104
|
+
* assertValidVisibility refuses unknowns first at both call sites, but this
|
|
105
|
+
* guard must stay fail-closed on its own — unknown means refused, not allowed.
|
|
106
|
+
*
|
|
107
|
+
* Absent (`undefined`/`null`) is accepted: it resolves through the
|
|
108
|
+
* durability-keyed default, which for ephemeral is `private` — the documented,
|
|
109
|
+
* intentional path (and the one the continuity hooks use, belt-and-suspenders
|
|
110
|
+
* with an explicit `private`).
|
|
111
|
+
*
|
|
112
|
+
* Returns an error message, or null when the combination is acceptable.
|
|
113
|
+
* `durability` is the EFFECTIVE durability of the row being written — for
|
|
114
|
+
* Memory.put() updates, where the payload may omit durability, the caller
|
|
115
|
+
* passes the pre-existing row's durability as the fallback so a PUT that flips
|
|
116
|
+
* a stored ephemeral row to shared refuses too.
|
|
117
|
+
*/
|
|
118
|
+
export function assertVisibilityAllowedForDurability(durability, visibility) {
|
|
119
|
+
if (durability !== EPHEMERAL_DURABILITY)
|
|
120
|
+
return null;
|
|
121
|
+
if (visibility === undefined || visibility === null || visibility === PRIVATE_VISIBILITY) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return (`ephemeral memories are private-only (continuity journal tier, flair#1257): ` +
|
|
125
|
+
`durability "${EPHEMERAL_DURABILITY}" cannot be written with visibility ${JSON.stringify(visibility)}. ` +
|
|
126
|
+
`Omit visibility (the durability-keyed default is "${PRIVATE_VISIBILITY}") or set it to ` +
|
|
127
|
+
`"${PRIVATE_VISIBILITY}"; for a memory other agents should read, use durability ` +
|
|
128
|
+
`"standard", "persistent", or "permanent".`);
|
|
129
|
+
}
|
|
@@ -29,14 +29,29 @@
|
|
|
29
29
|
// withDetachedTxn's transaction-chain workaround — both SemanticSearch and
|
|
30
30
|
// MemoryBootstrap are Harper Resources with their own `ctx`).
|
|
31
31
|
//
|
|
32
|
-
// Returns results AFTER all filters, sorted best-first by
|
|
33
|
-
// ONLY by the `limit` the caller chose to push down (the core never
|
|
32
|
+
// Returns results AFTER all filters, sorted best-first by RETRIEVAL RANK —
|
|
33
|
+
// bounded ONLY by the `limit` the caller chose to push down (the core never
|
|
34
34
|
// multiplies `limit` internally; any overfetch policy — SemanticSearch's
|
|
35
35
|
// CANDIDATE_MULTIPLIER, MemoryBootstrap's K formula — is the CALLER's
|
|
36
36
|
// decision, made
|
|
37
37
|
// before calling in). Never exposes which internal leg (BM25+RRF hybrid vs.
|
|
38
38
|
// legacy HNSW-only vs. keyword-only fallback) produced a given result — the
|
|
39
39
|
// output shape is identical regardless of `hybrid`.
|
|
40
|
+
//
|
|
41
|
+
// ── SCORE CONTRACT (flair#985) ───────────────────────────────────────────────
|
|
42
|
+
// `_score` under `scoring:"raw"` is ALWAYS an ABSOLUTE similarity (cosine of
|
|
43
|
+
// the query and the record's stored embedding, plus the legacy +0.05 substring
|
|
44
|
+
// keyword bump) — on every path, hybrid included. It is NEVER a
|
|
45
|
+
// rank-normalized value. Ordering and score are deliberately decoupled on the
|
|
46
|
+
// hybrid path: results are ORDERED by the fused RRF rank (that ordering is the
|
|
47
|
+
// hybrid recall win), but each result's `_score` reports its true evidence, so
|
|
48
|
+
// order and `_score` can disagree. The pre-#985 hybrid path reported the
|
|
49
|
+
// normalized RRF value AS `_score`, which pinned the top result of ANY query
|
|
50
|
+
// at 1.0 — and every consumer thresholding `_score` as a similarity (the
|
|
51
|
+
// pre-0.18 flair-client dedup gate at 0.95, `minScore`, `flair doctor`'s
|
|
52
|
+
// embed-verify probe) failed open at maximal confidence. For the dedup gate
|
|
53
|
+
// that meant EVERY memory_store from a stale client silently dropped its
|
|
54
|
+
// content into the arbitrary top-1 match — the #985 data-loss report.
|
|
40
55
|
import { databases } from "harper";
|
|
41
56
|
import { withDetachedTxn } from "./table-helpers.js";
|
|
42
57
|
import { wrapUntrusted } from "./content-safety.js";
|
|
@@ -85,10 +100,12 @@ export async function retrieveCandidates(params) {
|
|
|
85
100
|
// ── (a) Semantic candidate records (best-first) ──────────────────────
|
|
86
101
|
const semRecords = [];
|
|
87
102
|
const semIds = [];
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
103
|
+
// Absolute cosine similarity per semantic candidate (from the HNSW
|
|
104
|
+
// `$distance`), captured HERE before `$distance` is stripped downstream.
|
|
105
|
+
// Captured UNCONDITIONALLY (flair#985): this is the value `_score` reports
|
|
106
|
+
// under `scoring:"raw"` — see the fused loop below — and, when
|
|
107
|
+
// `withSemSimilarity` (flair#744 slice 2), also the confidence signal the
|
|
108
|
+
// abstention decision reads via the opt-in `_semSimilarity` field.
|
|
92
109
|
const semSimById = new Map();
|
|
93
110
|
if (qEmb) {
|
|
94
111
|
const semQuery = {
|
|
@@ -119,9 +136,20 @@ export async function retrieveCandidates(params) {
|
|
|
119
136
|
continue;
|
|
120
137
|
if (!passesAllowed(record))
|
|
121
138
|
continue;
|
|
122
|
-
if (
|
|
139
|
+
if (record.$distance !== undefined) {
|
|
123
140
|
semSimById.set(record.id, distanceToSimilarity(record.$distance));
|
|
124
141
|
}
|
|
142
|
+
else {
|
|
143
|
+
// Harper's cosine-sort query omits `$distance` for a SINGLETON
|
|
144
|
+
// post-filter result set (see the legacy path below and
|
|
145
|
+
// resources/SemanticSearch.ts's original writeup). Point-lookup the
|
|
146
|
+
// record and compute cosine ourselves from its real stored
|
|
147
|
+
// embedding — a missing/empty stored embedding yields 0 (safe "no
|
|
148
|
+
// semantic evidence"), never a false-high score.
|
|
149
|
+
const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(record.id));
|
|
150
|
+
const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
|
|
151
|
+
semSimById.set(record.id, cosineSimilarity(qEmb, storedEmbedding));
|
|
152
|
+
}
|
|
125
153
|
semRecords.push(record);
|
|
126
154
|
semIds.push(record.id);
|
|
127
155
|
}
|
|
@@ -175,40 +203,78 @@ export async function retrieveCandidates(params) {
|
|
|
175
203
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
176
204
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
177
205
|
_source: source,
|
|
206
|
+
_rank: finalScore,
|
|
178
207
|
});
|
|
179
208
|
}
|
|
180
209
|
}
|
|
181
210
|
else {
|
|
182
|
-
// ── Candidate-union RRF → normalized [0,1]
|
|
211
|
+
// ── Candidate-union RRF → normalized [0,1] RANKING value ────────────
|
|
212
|
+
// flair#985: the fused RRF value ORDERS results but is never REPORTED
|
|
213
|
+
// as a score. RRF normalization pins the top candidate at exactly 1.0
|
|
214
|
+
// regardless of how weak the real match is — reporting it as `_score`
|
|
215
|
+
// (the pre-#985 behavior) silently changed the meaning of `_score` from
|
|
216
|
+
// "absolute similarity, 0.95 ≈ near-duplicate" to "relative rank". Every
|
|
217
|
+
// consumer that thresholds `_score` as a similarity then fails OPEN at
|
|
218
|
+
// maximal confidence: the pre-0.18 flair-client dedup gate (`score >=
|
|
219
|
+
// 0.95` → suppress the write) suppressed EVERY memory_store into the
|
|
220
|
+
// arbitrary top-1 — however unrelated — which is the #985 field report
|
|
221
|
+
// (4/5 writes silently lost cross-topic). `minScore`, `flair doctor`'s
|
|
222
|
+
// embed-verify probe, and compositeScore's relevance floors all carry
|
|
223
|
+
// the same absolute-scale expectation. So: rank by fusion (`_rank`,
|
|
224
|
+
// internal, stripped before return — the hybrid recall win lives in the
|
|
225
|
+
// fused ORDER), report absolute evidence (`_score` = true cosine + the
|
|
226
|
+
// legacy keyword bump, same scale as the legacy HNSW-only path below).
|
|
183
227
|
const fused = fuseRrfNormalized(semIds, bm25Ids);
|
|
184
228
|
for (const [id, rrfRaw] of fused) {
|
|
185
229
|
const record = allowedById.get(id);
|
|
186
230
|
if (!record)
|
|
187
231
|
continue; // should not happen — union ⊆ allowed
|
|
188
|
-
|
|
189
|
-
|
|
232
|
+
// Absolute semantic similarity for this candidate. Sem-leg candidates
|
|
233
|
+
// already carry it (captured above, incl. the singleton-`$distance`
|
|
234
|
+
// fallback). A BM25-only candidate never went through the HNSW leg —
|
|
235
|
+
// point-lookup its stored embedding and compute the true cosine, so a
|
|
236
|
+
// genuinely-relevant lexical rescue reports its real similarity
|
|
237
|
+
// instead of a fabricated one (missing/legacy embedding ⇒ 0, safe).
|
|
238
|
+
let semSim = semSimById.get(id);
|
|
239
|
+
if (semSim === undefined && qEmb) {
|
|
240
|
+
const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(id));
|
|
241
|
+
const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
|
|
242
|
+
semSim = cosineSimilarity(qEmb, storedEmbedding);
|
|
243
|
+
semSimById.set(id, semSim);
|
|
244
|
+
}
|
|
245
|
+
let keywordHit = false;
|
|
246
|
+
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
247
|
+
keywordHit = true;
|
|
248
|
+
}
|
|
249
|
+
const rawScore = (semSim ?? 0) + (keywordHit ? 0.05 : 0);
|
|
250
|
+
let finalScore = scoring === "raw" ? rawScore : compositeScore(rrfRaw, record);
|
|
190
251
|
if (temporalBoost > 1.0)
|
|
191
252
|
finalScore *= temporalBoost;
|
|
192
253
|
const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
|
|
193
254
|
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
194
|
-
// flair#744 slice 2: absolute cosine confidence for the abstention
|
|
195
|
-
// decision — only for records that had a semantic (HNSW) candidate; a
|
|
196
|
-
// BM25-lexical-only match carries no cosine and contributes none.
|
|
197
|
-
const semSim = withSemSimilarity ? semSimById.get(id) : undefined;
|
|
198
255
|
results.push({
|
|
199
256
|
...record,
|
|
200
257
|
content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
|
|
201
258
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
202
259
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
203
260
|
_source: source,
|
|
204
|
-
|
|
261
|
+
// Ordering key: fused rank for raw mode; composite value for
|
|
262
|
+
// composite mode (composite ordering is unchanged by #985 — its
|
|
263
|
+
// rrfRaw input and result order are exactly the pre-#985 behavior).
|
|
264
|
+
_rank: scoring === "raw" ? rrfRaw : finalScore,
|
|
265
|
+
// flair#744 slice 2: the opt-in absolute-confidence field for the
|
|
266
|
+
// abstention decision. Attach remains OPT-IN so non-abstain
|
|
267
|
+
// responses stay byte-identical (the capture above is now
|
|
268
|
+
// unconditional, but the response field is not).
|
|
269
|
+
...(withSemSimilarity && semSim !== undefined ? { _semSimilarity: semSim } : {}),
|
|
205
270
|
});
|
|
206
271
|
}
|
|
207
272
|
}
|
|
208
273
|
}
|
|
209
274
|
else if (qEmb) {
|
|
210
|
-
// ─── HNSW vector search path (legacy, hybrid flag OFF —
|
|
211
|
-
//
|
|
275
|
+
// ─── HNSW vector search path (legacy, hybrid flag OFF — the
|
|
276
|
+
// FLAIR_HYBRID_RETRIEVAL kill-switch path for BOTH production callers
|
|
277
|
+
// since flair#1246) ─────────────────────────────────────────────────────
|
|
212
278
|
const query = {
|
|
213
279
|
sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
|
|
214
280
|
select: hnswSelect,
|
|
@@ -269,6 +335,7 @@ export async function retrieveCandidates(params) {
|
|
|
269
335
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
270
336
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
271
337
|
_source: source,
|
|
338
|
+
_rank: finalScore,
|
|
272
339
|
...(withSemSimilarity ? { _semSimilarity: semanticScore } : {}),
|
|
273
340
|
});
|
|
274
341
|
}
|
|
@@ -314,6 +381,7 @@ export async function retrieveCandidates(params) {
|
|
|
314
381
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
315
382
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
316
383
|
_source: source,
|
|
384
|
+
_rank: finalScore,
|
|
317
385
|
});
|
|
318
386
|
}
|
|
319
387
|
}
|
|
@@ -331,10 +399,22 @@ export async function retrieveCandidates(params) {
|
|
|
331
399
|
}
|
|
332
400
|
filteredResults = results.filter((r) => !supersededIds.has(r.id));
|
|
333
401
|
}
|
|
334
|
-
// Apply minimum score filter
|
|
402
|
+
// Apply minimum score filter — against `_score`, which is ALWAYS on the
|
|
403
|
+
// absolute-similarity scale after flair#985 (the hybrid path used to report
|
|
404
|
+
// the rank-normalized RRF value here, so `minScore: 0.95` matched the
|
|
405
|
+
// always-1.0 top-1 of ANY query instead of meaning "similarity ≥ 0.95").
|
|
335
406
|
if (minScore > 0) {
|
|
336
407
|
filteredResults = filteredResults.filter((r) => r._score >= minScore);
|
|
337
408
|
}
|
|
338
|
-
|
|
409
|
+
// Order by the internal ranking key (fused RRF rank on the hybrid raw path;
|
|
410
|
+
// identical to `_score` everywhere else), then strip it — `_rank` is an
|
|
411
|
+
// ordering key, never part of the response shape. Note the hybrid raw
|
|
412
|
+
// ordering is deliberately NOT by `_score`: the recall win of hybrid
|
|
413
|
+
// retrieval lives in the fused ORDER (a BM25 rank-1 rescue outranks weak
|
|
414
|
+
// semantic hits), while `_score` carries the honest absolute evidence for
|
|
415
|
+
// each result — the two can disagree, and that is correct.
|
|
416
|
+
filteredResults.sort((a, b) => b._rank - a._rank);
|
|
417
|
+
for (const r of filteredResults)
|
|
418
|
+
delete r._rank;
|
|
339
419
|
return filteredResults;
|
|
340
420
|
}
|
|
@@ -13,9 +13,15 @@
|
|
|
13
13
|
*
|
|
14
14
|
* `interpretActiveResult()` in particular encodes a production lesson
|
|
15
15
|
* (flair#850) that took a real outage to learn. It must not be re-derived.
|
|
16
|
+
* flair#1231 extended it one layer deeper: a load command exiting 0 proves the
|
|
17
|
+
* service manager ACCEPTED the job, not that the job can RUN — two fleet
|
|
18
|
+
* incidents (a stripped exec bit, a missing log directory) both passed the
|
|
19
|
+
* load check and died on the first real run, invisibly. The rule now encoded
|
|
20
|
+
* in `verifyFirstRun()`: success may not be claimed until the thing the
|
|
21
|
+
* operator asked for has been observed to happen once.
|
|
16
22
|
*/
|
|
17
23
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
18
|
-
import { resolve, dirname } from "node:path";
|
|
24
|
+
import { resolve, dirname, isAbsolute } from "node:path";
|
|
19
25
|
import { platform } from "node:os";
|
|
20
26
|
import { spawnSync } from "node:child_process";
|
|
21
27
|
/**
|
|
@@ -126,3 +132,206 @@ export function writeFileWithDir(path, contents, mode = 0o600) {
|
|
|
126
132
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
127
133
|
writeFileSync(path, contents, { mode });
|
|
128
134
|
}
|
|
135
|
+
// ─── node binary resolution (flair#1231) ────────────────────────────────────
|
|
136
|
+
/**
|
|
137
|
+
* Resolves the ABSOLUTE path to the node binary at enable time, so the shim
|
|
138
|
+
* can `exec "<node>" "<script>"` with ZERO PATH lookups at run time.
|
|
139
|
+
*
|
|
140
|
+
* Why this exists: the shims switched from `exec "{{FLAIR_BIN}}"` (which
|
|
141
|
+
* required an exec bit that tarball extraction strips — the #1231 regression)
|
|
142
|
+
* to running the CLI under node, which needs read permission only. But a bare
|
|
143
|
+
* `exec node …` would introduce a run-time PATH lookup the old absolute-path
|
|
144
|
+
* form never had: whatever PATH the service manager's environment carries
|
|
145
|
+
* would pick the `node` that runs with the operator's credentials. So the
|
|
146
|
+
* node path is resolved HERE, once, from the enabling process's own
|
|
147
|
+
* environment, and baked into the shim — symmetric with how FLAIR_BIN is
|
|
148
|
+
* already handled.
|
|
149
|
+
*
|
|
150
|
+
* Resolution order:
|
|
151
|
+
* 1. `explicit` — caller/test override.
|
|
152
|
+
* 2. `process.execPath` when the enabling runtime IS node (the published
|
|
153
|
+
* CLI's case): absolute, known-good, already trusted to run this code.
|
|
154
|
+
* 3. `command -v node` in the enabling shell environment (dev/test under
|
|
155
|
+
* bun): the one deliberate PATH consultation, made at enable time by the
|
|
156
|
+
* operator's own session, never later by the service manager.
|
|
157
|
+
* Nothing resolvable ⇒ throw — enable must fail loudly rather than bake a
|
|
158
|
+
* run-time lookup into the shim.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveNodeBin(explicit) {
|
|
161
|
+
if (explicit)
|
|
162
|
+
return explicit;
|
|
163
|
+
if (!process.versions.bun && process.execPath && isAbsolute(process.execPath)) {
|
|
164
|
+
return process.execPath;
|
|
165
|
+
}
|
|
166
|
+
const r = spawnReport(["/bin/sh", "-c", "command -v node"], STATUS_CHECK_TIMEOUT_MS);
|
|
167
|
+
const found = r.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
168
|
+
if (r.code === 0 && found && isAbsolute(found) && existsSync(found))
|
|
169
|
+
return found;
|
|
170
|
+
throw new Error("unable to resolve an absolute path to a node binary (not running under node, and `command -v node` " +
|
|
171
|
+
"found nothing). The scheduler shim runs `<node> <flair-script>` with the node path baked in at " +
|
|
172
|
+
"enable time — refusing to install a shim that would resolve `node` from the service manager's PATH " +
|
|
173
|
+
"at run time. Install node (or put it on PATH for this shell) and re-run enable.");
|
|
174
|
+
}
|
|
175
|
+
// ─── first-run verification (flair#1231) ────────────────────────────────────
|
|
176
|
+
// A load/bootstrap command exiting 0 proves the service manager accepted the
|
|
177
|
+
// job — not that the job can run. The only vantage that exercises the real
|
|
178
|
+
// failure modes (launchd spawn error 209 from a missing log dir, exit 126
|
|
179
|
+
// from a stripped exec bit) is the service manager itself, so the first run
|
|
180
|
+
// is triggered and observed THROUGH it, never via a bare spawn of the shim.
|
|
181
|
+
/** Poll cadence for darwin `launchctl print` first-run polling. */
|
|
182
|
+
export const FIRST_RUN_POLL_INTERVAL_MS = 150;
|
|
183
|
+
/** Total budget for first-run verification on both platforms. */
|
|
184
|
+
export const FIRST_RUN_BUDGET_MS = 12_000;
|
|
185
|
+
/** Synchronous sleep without spawning anything. */
|
|
186
|
+
function sleepSync(ms) {
|
|
187
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Parses `launchctl print <domain>/<label>` for run state. `last exit code`
|
|
191
|
+
* is absent (or "(never exited)") until a run has completed, and `pid =` /
|
|
192
|
+
* `state = running` are present only while one is in flight.
|
|
193
|
+
*/
|
|
194
|
+
export function parseLaunchdPrintExit(output) {
|
|
195
|
+
const running = /^\s*state\s*=\s*(?:running|spawn)/m.test(output) || /^\s*pid\s*=\s*\d+/m.test(output);
|
|
196
|
+
const m = /last exit (?:code|status)\s*=\s*(-?\d+)/.exec(output);
|
|
197
|
+
return { running, lastExitCode: m ? Number(m[1]) : null };
|
|
198
|
+
}
|
|
199
|
+
/** Parses `systemctl --user show <unit> --property=ExecMainStatus,Result`. */
|
|
200
|
+
export function parseSystemdShowExit(output) {
|
|
201
|
+
const m = /^ExecMainStatus=(-?\d+)\s*$/m.exec(output);
|
|
202
|
+
const r = /^Result=(\S+)\s*$/m.exec(output);
|
|
203
|
+
return { execMainStatus: m ? Number(m[1]) : null, result: r ? r[1] : null };
|
|
204
|
+
}
|
|
205
|
+
/** Reads the last lines of a log file for failure diagnostics. Never throws. */
|
|
206
|
+
export function readLogTail(path, maxLines = 12, maxChars = 1500) {
|
|
207
|
+
let text;
|
|
208
|
+
try {
|
|
209
|
+
text = readFileSync(path, "utf-8");
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return { exists: false, empty: false, tail: "" };
|
|
213
|
+
}
|
|
214
|
+
const trimmed = text.trimEnd();
|
|
215
|
+
if (!trimmed)
|
|
216
|
+
return { exists: true, empty: true, tail: "" };
|
|
217
|
+
let tail = trimmed.split("\n").slice(-maxLines).join("\n");
|
|
218
|
+
if (tail.length > maxChars)
|
|
219
|
+
tail = tail.slice(-maxChars);
|
|
220
|
+
return { exists: true, empty: false, tail };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Names the failure class for a recorded exit status, so the report can lead
|
|
224
|
+
* with actor+state instead of a bare number.
|
|
225
|
+
*/
|
|
226
|
+
export function describeExitCode(code) {
|
|
227
|
+
if (code === null)
|
|
228
|
+
return "no exit status recorded";
|
|
229
|
+
if (code === 126)
|
|
230
|
+
return "exit 126 — found but not runnable (permission denied / exec format)";
|
|
231
|
+
if (code === 127)
|
|
232
|
+
return "exit 127 — command not found";
|
|
233
|
+
if (code === 209)
|
|
234
|
+
return "exit 209 — launchd could not spawn the job (a missing/unwritable log directory produces this)";
|
|
235
|
+
return `exit ${code}`;
|
|
236
|
+
}
|
|
237
|
+
function spawnedNothing(r) {
|
|
238
|
+
return r.code === null && !r.stdout.trim() && !r.stderr.trim();
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Triggers the job's first run through the service manager and reads back how
|
|
242
|
+
* it ended (flair#1231). Call ONLY after the load/bootstrap command exited 0 —
|
|
243
|
+
* a load failure is its own failure mode with its own remedy, and layering a
|
|
244
|
+
* kickstart on top of it would blur which actor failed.
|
|
245
|
+
*
|
|
246
|
+
* darwin: `launchctl kickstart -k` returns immediately (it does NOT block for
|
|
247
|
+
* exit), so the recorded exit status is POLLED out of `launchctl print` until
|
|
248
|
+
* a completed run is visible or the budget lapses. linux: `systemctl --user
|
|
249
|
+
* start` on a oneshot blocks until the run exits, so a single
|
|
250
|
+
* `systemctl --user show` read afterwards suffices.
|
|
251
|
+
*
|
|
252
|
+
* "Can't tell" is its own state: a missing/unreachable service manager yields
|
|
253
|
+
* outcome "manager-unavailable", distinct from "run-failed" — the remedy
|
|
254
|
+
* points at the service manager, not at the job.
|
|
255
|
+
*/
|
|
256
|
+
export function verifyFirstRun(opts) {
|
|
257
|
+
const run = opts.hooks?.run ?? ((cmd, timeoutMs) => spawnReport(cmd, timeoutMs));
|
|
258
|
+
const sleep = opts.hooks?.sleep ?? sleepSync;
|
|
259
|
+
const now = opts.hooks?.now ?? Date.now;
|
|
260
|
+
const pollIntervalMs = opts.pollIntervalMs ?? FIRST_RUN_POLL_INTERVAL_MS;
|
|
261
|
+
const budgetMs = opts.budgetMs ?? FIRST_RUN_BUDGET_MS;
|
|
262
|
+
const finish = (outcome, exitCode, detail) => {
|
|
263
|
+
const log = outcome === "success"
|
|
264
|
+
? { exists: false, empty: false, tail: "" } // no diagnostics needed on success
|
|
265
|
+
: readLogTail(opts.stderrLogPath);
|
|
266
|
+
return {
|
|
267
|
+
verified: outcome === "success",
|
|
268
|
+
outcome,
|
|
269
|
+
exitCode,
|
|
270
|
+
detail,
|
|
271
|
+
logPath: opts.stderrLogPath,
|
|
272
|
+
stderrTail: log.tail,
|
|
273
|
+
logEmpty: log.exists && log.empty,
|
|
274
|
+
budgetMs,
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
if (opts.plat === "darwin") {
|
|
278
|
+
const target = opts.darwinTarget;
|
|
279
|
+
if (!target)
|
|
280
|
+
throw new Error("verifyFirstRun: darwinTarget is required on darwin");
|
|
281
|
+
const kickCmd = ["launchctl", "kickstart", "-k", target];
|
|
282
|
+
const kick = run(kickCmd, SPAWN_TIMEOUT_MS);
|
|
283
|
+
if (spawnedNothing(kick)) {
|
|
284
|
+
return finish("manager-unavailable", null, `launchctl could not be run (${kickCmd.join(" ")})`);
|
|
285
|
+
}
|
|
286
|
+
if (kick.code !== 0) {
|
|
287
|
+
return finish("start-failed", null, `${kickCmd.join(" ")} → code ${kick.code}${kick.stderr.trim() ? `: ${kick.stderr.trim()}` : ""}`);
|
|
288
|
+
}
|
|
289
|
+
const deadline = now() + budgetMs;
|
|
290
|
+
// Poll: kickstart returned immediately, so watch `launchctl print` until a
|
|
291
|
+
// COMPLETED run (not running + a recorded exit code) is visible.
|
|
292
|
+
for (;;) {
|
|
293
|
+
const printCmd = ["launchctl", "print", target];
|
|
294
|
+
const r = run(printCmd, STATUS_CHECK_TIMEOUT_MS);
|
|
295
|
+
if (spawnedNothing(r)) {
|
|
296
|
+
return finish("manager-unavailable", null, `launchctl could not be run (${printCmd.join(" ")})`);
|
|
297
|
+
}
|
|
298
|
+
if (r.code === 0) {
|
|
299
|
+
const { running, lastExitCode } = parseLaunchdPrintExit(r.stdout);
|
|
300
|
+
if (!running && lastExitCode !== null) {
|
|
301
|
+
return lastExitCode === 0
|
|
302
|
+
? finish("success", 0, `${printCmd.join(" ")} → last exit code = 0`)
|
|
303
|
+
: finish("run-failed", lastExitCode, `${printCmd.join(" ")} → last exit code = ${lastExitCode}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (now() >= deadline) {
|
|
307
|
+
return finish("timeout", null, `no completed run visible in ${printCmd.join(" ")} within ${Math.round(budgetMs / 1000)}s`);
|
|
308
|
+
}
|
|
309
|
+
sleep(pollIntervalMs);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// linux
|
|
313
|
+
const unit = opts.linuxServiceUnit;
|
|
314
|
+
if (!unit)
|
|
315
|
+
throw new Error("verifyFirstRun: linuxServiceUnit is required on linux");
|
|
316
|
+
const startCmd = ["systemctl", "--user", "start", unit];
|
|
317
|
+
const start = run(startCmd, budgetMs);
|
|
318
|
+
if (spawnedNothing(start)) {
|
|
319
|
+
return finish("manager-unavailable", null, `systemctl could not be run (${startCmd.join(" ")})`);
|
|
320
|
+
}
|
|
321
|
+
if (/failed to connect to bus/i.test(start.stderr)) {
|
|
322
|
+
return finish("manager-unavailable", null, `${startCmd.join(" ")} → ${start.stderr.trim()}`);
|
|
323
|
+
}
|
|
324
|
+
if (start.code === null) {
|
|
325
|
+
return finish("timeout", null, `${startCmd.join(" ")} did not return within ${Math.round(budgetMs / 1000)}s`);
|
|
326
|
+
}
|
|
327
|
+
const showCmd = ["systemctl", "--user", "show", unit, "--property=ExecMainStatus,Result"];
|
|
328
|
+
const show = run(showCmd, STATUS_CHECK_TIMEOUT_MS);
|
|
329
|
+
const parsed = parseSystemdShowExit(show.stdout);
|
|
330
|
+
if (start.code === 0) {
|
|
331
|
+
// A blocking start of a oneshot exits 0 only when the run succeeded; the
|
|
332
|
+
// show read supplies the recorded status for the report.
|
|
333
|
+
return finish("success", parsed.execMainStatus ?? 0, `${startCmd.join(" ")} → ok`);
|
|
334
|
+
}
|
|
335
|
+
const resultTxt = parsed.result ? `, Result=${parsed.result}` : "";
|
|
336
|
+
return finish("run-failed", parsed.execMainStatus, `${startCmd.join(" ")} → code ${start.code}${resultTxt}${start.stderr.trim() ? `: ${start.stderr.trim()}` : ""}`);
|
|
337
|
+
}
|