@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.
@@ -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 `_score` bounded
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
- // flair#744 slice 2: absolute cosine similarity per semantic candidate
89
- // (from the HNSW `$distance`), captured HERE before `$distance` is stripped
90
- // downstream the confidence signal the abstention decision reads (only
91
- // when `withSemSimilarity`; empty/unused otherwise).
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 (withSemSimilarity && record.$distance !== undefined) {
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] rawScore ────────────────
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
- const rawScore = rrfRaw; // already normalized to [0,1]
189
- let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
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
- ...(semSim !== undefined ? { _semSimilarity: semSim } : {}),
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 — or a caller
211
- // like MemoryBootstrap forcing HNSW-leg-only regardless of the flag) ────
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
- filteredResults.sort((a, b) => b._score - a._score);
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
+ }