@tpsdev-ai/flair 0.45.0 → 0.47.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.
Files changed (39) hide show
  1. package/config.yaml +35 -2
  2. package/dist/build-info.json +6 -0
  3. package/dist/cli.js +847 -168
  4. package/dist/doctor-client.js +358 -11
  5. package/dist/federation/scheduler.js +114 -9
  6. package/dist/hook-install.js +150 -1
  7. package/dist/install/global-bin-path.js +234 -0
  8. package/dist/lib/entity-vocab-cli.js +113 -0
  9. package/dist/lib/mcp-enable.js +71 -21
  10. package/dist/lib/scheduler-platform.js +363 -1
  11. package/dist/postinstall.cjs +88 -0
  12. package/dist/rem/runner.js +177 -10
  13. package/dist/rem/scheduler.js +126 -20
  14. package/dist/resources/AttentionQuery.js +5 -3
  15. package/dist/resources/AutoPromoteCandidates.js +18 -12
  16. package/dist/resources/Federation.js +49 -5
  17. package/dist/resources/Memory.js +36 -2
  18. package/dist/resources/MemoryBootstrap.js +118 -7
  19. package/dist/resources/MemoryMaintenance.js +8 -2
  20. package/dist/resources/MemoryReflect.js +70 -5
  21. package/dist/resources/auto-promote-lib.js +46 -0
  22. package/dist/resources/build-info.js +50 -0
  23. package/dist/resources/entity-vocab.js +25 -1
  24. package/dist/resources/health.js +25 -5
  25. package/dist/resources/mcp-oauth-flag.js +20 -0
  26. package/dist/resources/mcp-oauth.js +6 -1
  27. package/dist/resources/mcp-tools.js +53 -3
  28. package/dist/resources/memory-reflect-lib.js +201 -4
  29. package/dist/src/lib/scheduler-platform.js +363 -1
  30. package/dist/src/rem/scheduler.js +126 -20
  31. package/docs/deepseek-harness.md +110 -0
  32. package/docs/entity-vocabulary.md +15 -0
  33. package/docs/integrations.md +1 -0
  34. package/docs/mcp-clients.md +4 -0
  35. package/docs/notes/mcp-oauth-model2.md +52 -3
  36. package/package.json +5 -4
  37. package/schemas/memory.graphql +12 -0
  38. package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
  39. package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
@@ -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,359 @@ 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
+ /**
238
+ * Reads how the job's most recent run ended, from the only vantage that
239
+ * knows: the service manager itself.
240
+ *
241
+ * darwin: `launchctl print` carries `last exit code = N` once a run has
242
+ * completed (parseLaunchdPrintExit). linux: `systemctl --user show` on the
243
+ * service unit — with one trap encoded here rather than in every caller: a
244
+ * unit that has NEVER completed a run still reports `ExecMainStatus=0,
245
+ * Result=success` (systemd property defaults), so the exit properties are
246
+ * only believed when `ExecMainExitTimestampMonotonic` proves a run actually
247
+ * finished. Without that check, "never ran" renders as "last run succeeded"
248
+ * — the exact skipped-check-looks-like-a-pass shape this feature exists to
249
+ * kill.
250
+ */
251
+ export function queryLastExitStatus(opts) {
252
+ const run = opts.run ?? ((cmd, timeoutMs) => spawnReport(cmd, timeoutMs));
253
+ if (opts.plat === "darwin") {
254
+ const target = opts.darwinTarget;
255
+ if (!target)
256
+ throw new Error("queryLastExitStatus: darwinTarget is required on darwin");
257
+ const printCmd = ["launchctl", "print", target];
258
+ const r = run(printCmd, STATUS_CHECK_TIMEOUT_MS);
259
+ if (spawnedNothing(r)) {
260
+ return { state: "unavailable", exitCode: null, detail: `launchctl could not be run (${printCmd.join(" ")})` };
261
+ }
262
+ if (r.code !== 0) {
263
+ return { state: "unavailable", exitCode: null, detail: `${printCmd.join(" ")} → code ${r.code} (job not loaded — no run record to read)` };
264
+ }
265
+ const { running, lastExitCode } = parseLaunchdPrintExit(r.stdout);
266
+ if (running) {
267
+ return { state: "running", exitCode: null, detail: `${printCmd.join(" ")} → a run is in flight` };
268
+ }
269
+ if (lastExitCode === null) {
270
+ return { state: "never-ran", exitCode: null, detail: `${printCmd.join(" ")} → no completed run recorded` };
271
+ }
272
+ return { state: "recorded", exitCode: lastExitCode, detail: `${printCmd.join(" ")} → last exit code = ${lastExitCode}` };
273
+ }
274
+ const unit = opts.linuxServiceUnit;
275
+ if (!unit)
276
+ throw new Error("queryLastExitStatus: linuxServiceUnit is required on linux");
277
+ const showCmd = ["systemctl", "--user", "show", unit, "--property=ExecMainStatus,Result,ExecMainExitTimestampMonotonic"];
278
+ const r = run(showCmd, STATUS_CHECK_TIMEOUT_MS);
279
+ if (spawnedNothing(r)) {
280
+ return { state: "unavailable", exitCode: null, detail: `systemctl could not be run (${showCmd.join(" ")})` };
281
+ }
282
+ if (/failed to connect to bus/i.test(r.stderr)) {
283
+ return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → ${r.stderr.trim()}` };
284
+ }
285
+ if (r.code !== 0) {
286
+ return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → code ${r.code}${r.stderr.trim() ? `: ${r.stderr.trim()}` : ""}` };
287
+ }
288
+ // Believe the exit properties only when a run has actually finished — see
289
+ // the doc comment above for why this must be checked FIRST.
290
+ const ts = /^ExecMainExitTimestampMonotonic=(\d+)\s*$/m.exec(r.stdout);
291
+ if (ts && Number(ts[1]) === 0) {
292
+ return { state: "never-ran", exitCode: null, detail: `${showCmd.join(" ")} → no completed run recorded` };
293
+ }
294
+ const parsed = parseSystemdShowExit(r.stdout);
295
+ if (parsed.execMainStatus === null) {
296
+ return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → no ExecMainStatus in the reply` };
297
+ }
298
+ const resultTxt = parsed.result ? `, Result=${parsed.result}` : "";
299
+ return {
300
+ state: "recorded",
301
+ exitCode: parsed.execMainStatus,
302
+ detail: `${showCmd.join(" ")} → ExecMainStatus=${parsed.execMainStatus}${resultTxt}`,
303
+ };
304
+ }
305
+ /**
306
+ * Pure decision logic for `flair doctor`'s "Scheduled drivers" section
307
+ * (flair#1278) — extracted so it is unit-testable without spawning
308
+ * launchctl/systemctl, same idiom as formatEnableReport/assessDriver in the
309
+ * scheduler modules and summarizeDoctorRun in the CLI.
310
+ *
311
+ * The three load-bearing rules:
312
+ * - not-enabled is a CHOICE, not a defect: informational marker, never the
313
+ * pass marker, never the fail marker, never an issue (a skipped check
314
+ * must not look like a pass — flair#970's rule applied to schedulers).
315
+ * - a last-run failure IS a defect, reported loud with actor+state+remedy
316
+ * (embed-verify style): the service manager is firing the job, the runs
317
+ * themselves are dying, so the schedule looks alive while nothing is
318
+ * delivered — the #1231 incident shape.
319
+ * - "could not read" is UNVERIFIED, never a pass and never a hard failure
320
+ * — the same discipline as doctor's audit-log and embeddings probes.
321
+ */
322
+ export function describeScheduledDriverFinding(f) {
323
+ if (!f.installed) {
324
+ return {
325
+ state: "not-enabled",
326
+ icon: "info",
327
+ isIssue: false,
328
+ message: `${f.label}: not enabled`,
329
+ detail: [`Opt-in — enable: ${f.enableCommand}`],
330
+ };
331
+ }
332
+ if (f.active === false) {
333
+ return {
334
+ state: "degraded",
335
+ icon: "error",
336
+ isIssue: true,
337
+ message: `${f.label}: INSTALLED BUT NOT LOADED — nothing will run it`,
338
+ detail: [
339
+ `The unit files are on disk, but the service manager does not have the job loaded, so it never fires.`,
340
+ `Fix: ${f.enableCommand} # then check: ${f.statusCommand}`,
341
+ ],
342
+ };
343
+ }
344
+ if (f.active === null) {
345
+ return {
346
+ state: "unverified",
347
+ icon: "warn",
348
+ isIssue: false,
349
+ message: `${f.label}: UNVERIFIED — installed, but whether it is loaded could not be read`,
350
+ detail: [`Querying the service manager was inconclusive. Check: ${f.statusCommand}`],
351
+ };
352
+ }
353
+ // Loaded from here down.
354
+ const le = f.lastExit;
355
+ if (!le || le.state === "unavailable") {
356
+ return {
357
+ state: "unverified",
358
+ icon: "warn",
359
+ isIssue: false,
360
+ message: `${f.label}: loaded, but its last-run status could not be read`,
361
+ detail: [...(le ? [le.detail] : []), `Check: ${f.statusCommand}`],
362
+ };
363
+ }
364
+ if (le.state === "recorded" && le.exitCode !== 0) {
365
+ return {
366
+ state: "degraded",
367
+ icon: "error",
368
+ isIssue: true,
369
+ message: `${f.label} DEGRADED — loaded, but its last run failed (${describeExitCode(le.exitCode)})`,
370
+ detail: [
371
+ `The service manager has the job loaded and is firing it; the runs themselves are failing, so the schedule looks alive while nothing is delivered.`,
372
+ `Check ${f.stderrLogPath}, then: ${f.statusCommand}`,
373
+ ],
374
+ };
375
+ }
376
+ if (le.state === "running") {
377
+ return { state: "healthy", icon: "ok", isIssue: false, message: `${f.label}: loaded (a run is in flight now)`, detail: [] };
378
+ }
379
+ if (le.state === "never-ran") {
380
+ return {
381
+ state: "healthy",
382
+ icon: "ok",
383
+ isIssue: false,
384
+ message: `${f.label}: loaded (no completed run on record yet)`,
385
+ detail: [`Installed and loaded; the service manager has not recorded a completed run since it last (re)loaded the job.`],
386
+ };
387
+ }
388
+ return { state: "healthy", icon: "ok", isIssue: false, message: `${f.label}: loaded (last run: exit 0)`, detail: [] };
389
+ }
390
+ function spawnedNothing(r) {
391
+ return r.code === null && !r.stdout.trim() && !r.stderr.trim();
392
+ }
393
+ /**
394
+ * Triggers the job's first run through the service manager and reads back how
395
+ * it ended (flair#1231). Call ONLY after the load/bootstrap command exited 0 —
396
+ * a load failure is its own failure mode with its own remedy, and layering a
397
+ * kickstart on top of it would blur which actor failed.
398
+ *
399
+ * darwin: `launchctl kickstart -k` returns immediately (it does NOT block for
400
+ * exit), so the recorded exit status is POLLED out of `launchctl print` until
401
+ * a completed run is visible or the budget lapses. linux: `systemctl --user
402
+ * start` on a oneshot blocks until the run exits, so a single
403
+ * `systemctl --user show` read afterwards suffices.
404
+ *
405
+ * "Can't tell" is its own state: a missing/unreachable service manager yields
406
+ * outcome "manager-unavailable", distinct from "run-failed" — the remedy
407
+ * points at the service manager, not at the job.
408
+ */
409
+ export function verifyFirstRun(opts) {
410
+ const run = opts.hooks?.run ?? ((cmd, timeoutMs) => spawnReport(cmd, timeoutMs));
411
+ const sleep = opts.hooks?.sleep ?? sleepSync;
412
+ const now = opts.hooks?.now ?? Date.now;
413
+ const pollIntervalMs = opts.pollIntervalMs ?? FIRST_RUN_POLL_INTERVAL_MS;
414
+ const budgetMs = opts.budgetMs ?? FIRST_RUN_BUDGET_MS;
415
+ const finish = (outcome, exitCode, detail) => {
416
+ const log = outcome === "success"
417
+ ? { exists: false, empty: false, tail: "" } // no diagnostics needed on success
418
+ : readLogTail(opts.stderrLogPath);
419
+ return {
420
+ verified: outcome === "success",
421
+ outcome,
422
+ exitCode,
423
+ detail,
424
+ logPath: opts.stderrLogPath,
425
+ stderrTail: log.tail,
426
+ logEmpty: log.exists && log.empty,
427
+ budgetMs,
428
+ };
429
+ };
430
+ if (opts.plat === "darwin") {
431
+ const target = opts.darwinTarget;
432
+ if (!target)
433
+ throw new Error("verifyFirstRun: darwinTarget is required on darwin");
434
+ const kickCmd = ["launchctl", "kickstart", "-k", target];
435
+ const kick = run(kickCmd, SPAWN_TIMEOUT_MS);
436
+ if (spawnedNothing(kick)) {
437
+ return finish("manager-unavailable", null, `launchctl could not be run (${kickCmd.join(" ")})`);
438
+ }
439
+ if (kick.code !== 0) {
440
+ return finish("start-failed", null, `${kickCmd.join(" ")} → code ${kick.code}${kick.stderr.trim() ? `: ${kick.stderr.trim()}` : ""}`);
441
+ }
442
+ const deadline = now() + budgetMs;
443
+ // Poll: kickstart returned immediately, so watch `launchctl print` until a
444
+ // COMPLETED run (not running + a recorded exit code) is visible.
445
+ for (;;) {
446
+ const printCmd = ["launchctl", "print", target];
447
+ const r = run(printCmd, STATUS_CHECK_TIMEOUT_MS);
448
+ if (spawnedNothing(r)) {
449
+ return finish("manager-unavailable", null, `launchctl could not be run (${printCmd.join(" ")})`);
450
+ }
451
+ if (r.code === 0) {
452
+ const { running, lastExitCode } = parseLaunchdPrintExit(r.stdout);
453
+ if (!running && lastExitCode !== null) {
454
+ return lastExitCode === 0
455
+ ? finish("success", 0, `${printCmd.join(" ")} → last exit code = 0`)
456
+ : finish("run-failed", lastExitCode, `${printCmd.join(" ")} → last exit code = ${lastExitCode}`);
457
+ }
458
+ }
459
+ if (now() >= deadline) {
460
+ return finish("timeout", null, `no completed run visible in ${printCmd.join(" ")} within ${Math.round(budgetMs / 1000)}s`);
461
+ }
462
+ sleep(pollIntervalMs);
463
+ }
464
+ }
465
+ // linux
466
+ const unit = opts.linuxServiceUnit;
467
+ if (!unit)
468
+ throw new Error("verifyFirstRun: linuxServiceUnit is required on linux");
469
+ const startCmd = ["systemctl", "--user", "start", unit];
470
+ const start = run(startCmd, budgetMs);
471
+ if (spawnedNothing(start)) {
472
+ return finish("manager-unavailable", null, `systemctl could not be run (${startCmd.join(" ")})`);
473
+ }
474
+ if (/failed to connect to bus/i.test(start.stderr)) {
475
+ return finish("manager-unavailable", null, `${startCmd.join(" ")} → ${start.stderr.trim()}`);
476
+ }
477
+ if (start.code === null) {
478
+ return finish("timeout", null, `${startCmd.join(" ")} did not return within ${Math.round(budgetMs / 1000)}s`);
479
+ }
480
+ const showCmd = ["systemctl", "--user", "show", unit, "--property=ExecMainStatus,Result"];
481
+ const show = run(showCmd, STATUS_CHECK_TIMEOUT_MS);
482
+ const parsed = parseSystemdShowExit(show.stdout);
483
+ if (start.code === 0) {
484
+ // A blocking start of a oneshot exits 0 only when the run succeeded; the
485
+ // show read supplies the recorded status for the report.
486
+ return finish("success", parsed.execMainStatus ?? 0, `${startCmd.join(" ")} → ok`);
487
+ }
488
+ const resultTxt = parsed.result ? `, Result=${parsed.result}` : "";
489
+ return finish("run-failed", parsed.execMainStatus, `${startCmd.join(" ")} → code ${start.code}${resultTxt}${start.stderr.trim() ? `: ${start.stderr.trim()}` : ""}`);
490
+ }
@@ -13,23 +13,29 @@
13
13
  * No daemon code lives here — the scheduler invokes the shim, the shim
14
14
  * invokes `flair rem nightly run-once`, the runner module does the work.
15
15
  */
16
- import { existsSync, chmodSync, rmSync } from "node:fs";
16
+ import { existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
17
17
  import { resolve, dirname } from "node:path";
18
18
  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, 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, verifyFirstRun, 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
26
26
  // flair#850's lesson must have exactly one implementation).
27
27
  export { interpretActiveResult };
28
28
  export const SHIM_PATH_DEFAULT = resolve(homedir(), ".flair", "bin", "flair-rem-nightly");
29
- export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist");
29
+ // Unit names, exported (flair#1278) so `flair doctor`'s scheduled-drivers
30
+ // section addresses the same job this module installs — same single-source
31
+ // rule as the federation scheduler's LAUNCHD_LABEL/SYSTEMD_*_UNIT constants.
32
+ export const LAUNCHD_LABEL = "dev.flair.rem.nightly";
33
+ export const SYSTEMD_TIMER_UNIT = "flair-rem-nightly.timer";
34
+ export const SYSTEMD_SERVICE_UNIT = "flair-rem-nightly.service";
35
+ export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
30
36
  export const SYSTEMD_USER_DIR = resolve(homedir(), ".config", "systemd", "user");
31
- export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.timer");
32
- export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.service");
37
+ export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR, SYSTEMD_TIMER_UNIT);
38
+ export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, SYSTEMD_SERVICE_UNIT);
33
39
  function detectPlatform(override) {
34
40
  return detectPlatformFor("REM nightly scheduler", override);
35
41
  }
@@ -87,15 +93,16 @@ function validateSchedule(hour, minute) {
87
93
  throw new Error(`minute must be an integer 0-59, got ${minute}`);
88
94
  }
89
95
  }
90
- function buildSubstitutions(opts, shimPath, flairBin) {
96
+ function buildSubstitutions(opts, shimPath, flairBin, nodeBin) {
91
97
  validateSchedule(opts.hour, opts.minute);
92
98
  if (!/^[a-zA-Z0-9_-]+$/.test(opts.agentId)) {
93
99
  throw new Error(`invalid agent id: ${opts.agentId}`);
94
100
  }
95
101
  return {
96
102
  FLAIR_BIN: flairBin,
103
+ NODE_BIN: nodeBin,
97
104
  SHIM_PATH: shimPath,
98
- HOME: homedir(),
105
+ HOME: opts.homeOverride ?? homedir(),
99
106
  AGENT_ID: opts.agentId,
100
107
  FLAIR_URL: opts.flairUrl,
101
108
  HOUR: String(opts.hour),
@@ -112,9 +119,9 @@ function buildSubstitutions(opts, shimPath, flairBin) {
112
119
  */
113
120
  function activeCheckCommand(plat) {
114
121
  if (plat === "darwin") {
115
- return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/dev.flair.rem.nightly`];
122
+ return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`];
116
123
  }
117
- return ["systemctl", "--user", "is-active", "flair-rem-nightly.timer"];
124
+ return ["systemctl", "--user", "is-active", SYSTEMD_TIMER_UNIT];
118
125
  }
119
126
  /**
120
127
  * Synchronous active-state check for CLI use (`flair rem nightly status`).
@@ -183,10 +190,14 @@ export function describeLoadFailure(plat, loadResult) {
183
190
  * succeeded) is unit-testable without spawning a real launchctl/systemctl or
184
191
  * parsing CLI argv.
185
192
  *
186
- * `r.loadResult` is only set when the load command actually ran (the CLI
187
- * never sets `skipLoad`). A missing `loadResult` (test-only path) is treated
188
- * as success matches the CLI's real-world behavior, which always runs the
189
- * load command and therefore always gets a `loadResult`.
193
+ * flair#1231 deepened the #850 rule by one layer: activation exiting 0 proves
194
+ * the service manager ACCEPTED the job, not that the job can run — a stripped
195
+ * exec bit and a missing log directory both passed activation and killed the
196
+ * first real run invisibly. So the headline is now additionally gated on
197
+ * `firstRunVerified`: success may not be claimed until the thing the operator
198
+ * asked for — a REM run through the service manager — has been observed to
199
+ * happen once. A missing `loadResult`/`firstRun` (test-only skipLoad shape)
200
+ * therefore withholds the headline too, instead of being treated as success.
190
201
  */
191
202
  export function formatEnableReport(r, input) {
192
203
  const { hour, minute, agentId, flairUrl } = input;
@@ -212,6 +223,57 @@ export function formatEnableReport(r, input) {
212
223
  lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair rem nightly status`);
213
224
  return { lines, ok: false };
214
225
  }
226
+ if (!r.firstRunVerified) {
227
+ const fr = r.firstRun;
228
+ const headline = fr?.outcome === "run-failed"
229
+ ? `⚠️ REM nightly scheduler installed but the first run FAILED (${describeExitCode(fr.exitCode)})`
230
+ : fr?.outcome === "timeout"
231
+ ? `⚠️ REM nightly scheduler installed but the first run did not complete within ${Math.round(fr.budgetMs / 1000)}s — cannot confirm it works`
232
+ : fr?.outcome === "manager-unavailable"
233
+ ? `⚠️ REM nightly scheduler installed but the service manager is unreachable — cannot verify the first run`
234
+ : fr?.outcome === "start-failed"
235
+ ? `⚠️ REM nightly scheduler installed but the first run could not be started`
236
+ : `⚠️ REM nightly scheduler installed but the first run was never verified`;
237
+ const lines = [
238
+ headline,
239
+ ` Schedule: ${scheduleTime} local time`,
240
+ ` Scheduler: ${r.schedulerPath}`,
241
+ ` Shim: ${r.shimPath}`,
242
+ ` Agent: ${agentId}`,
243
+ ` Flair URL: ${flairUrl}`,
244
+ ];
245
+ if (r.loadResult)
246
+ lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
247
+ if (fr) {
248
+ lines.push(` First run: ${fr.detail}`);
249
+ if (fr.stderrTail) {
250
+ lines.push(` Log tail (${fr.logPath}):`);
251
+ for (const l of fr.stderrTail.split("\n"))
252
+ lines.push(` ${l}`);
253
+ }
254
+ else if (fr.logEmpty) {
255
+ lines.push(` Log file ${fr.logPath} exists but is EMPTY — the run died before writing anything.`);
256
+ }
257
+ else {
258
+ lines.push(` No log file at ${fr.logPath}.`);
259
+ }
260
+ }
261
+ lines.push("");
262
+ if (fr?.outcome === "timeout") {
263
+ lines.push(` The run may legitimately still be going (a REM cycle can be slow). Check the log above and`);
264
+ lines.push(` \`flair rem nightly status\`; no cycle has been CONFIRMED to work yet.`);
265
+ }
266
+ else if (fr?.outcome === "manager-unavailable") {
267
+ lines.push(` The scheduler files are installed, but launchctl/systemctl could not be consulted, so whether`);
268
+ lines.push(` the nightly cycle runs is UNKNOWN. Fix the service manager for this session, then re-run \`flair rem nightly enable\`.`);
269
+ }
270
+ else {
271
+ lines.push(` No REM cycle has run. Fix the cause above, then re-run \`flair rem nightly enable\`.`);
272
+ }
273
+ lines.push("");
274
+ lines.push(` Check anytime with: flair rem nightly status`);
275
+ return { lines, ok: false };
276
+ }
215
277
  const lines = [
216
278
  `✅ REM nightly scheduler enabled (${r.platform})`,
217
279
  ` Schedule: ${scheduleTime} local time`,
@@ -223,9 +285,9 @@ export function formatEnableReport(r, input) {
223
285
  if (r.loadResult) {
224
286
  lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
225
287
  }
288
+ lines.push(` First run: completed through the service manager, exit 0`);
226
289
  lines.push("");
227
- lines.push(`Tip: run \`flair rem nightly run-once --dry-run\` to verify the cycle works`);
228
- lines.push(` before the first scheduled fire. Disable with \`flair rem nightly disable\`.`);
290
+ lines.push(`Disable with \`flair rem nightly disable\`.`);
229
291
  return { lines, ok: true };
230
292
  }
231
293
  /**
@@ -268,9 +330,29 @@ export function formatStatusReport(s) {
268
330
  export function enableScheduler(opts) {
269
331
  const plat = detectPlatform(opts.platformOverride);
270
332
  const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
333
+ const nodeBin = resolveNodeBin(opts.nodeBin);
271
334
  const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
272
335
  const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
273
- const subs = buildSubstitutions(opts, shimPath, flairBin);
336
+ const subs = buildSubstitutions(opts, shimPath, flairBin, nodeBin);
337
+ // 0. Create the log directory the unit files point stdout/stderr at.
338
+ // Nothing else ever creates it — launchd kills a job whose StandardOutPath
339
+ // directory is missing (spawn error 209) and systemd fails the unit (#1231).
340
+ //
341
+ // Mode 0700 is load-bearing, NOT cosmetic: REM's nightly log carries
342
+ // distillation CANDIDATE CONTENT — actual memory text, not just counts.
343
+ // Relaxing it to 0755 (e.g. "for shared debugging") would expose memory
344
+ // content to every local user.
345
+ const logsDir = resolve(subs.HOME, ".flair", "logs");
346
+ try {
347
+ mkdirSync(logsDir, { recursive: true, mode: 0o700 });
348
+ }
349
+ catch (err) {
350
+ throw new Error(`could not create the scheduler log directory ${logsDir}: ${err?.message ?? err}. ` +
351
+ `The service manager writes the job's stdout/stderr there; without it the first run dies ` +
352
+ `before producing any output. Fix whatever blocks creating that directory, then re-run ` +
353
+ `\`flair rem nightly enable\`.`);
354
+ }
355
+ const stderrLogPath = resolve(logsDir, "rem-nightly.stderr.log");
274
356
  // 1. Deploy the shim (always — both platforms invoke it).
275
357
  const shimContents = renderTemplate(readTemplate(templateRoot, "bin/flair-rem-nightly.sh.tmpl"), subs);
276
358
  writeFileWithDir(shimPath, shimContents, 0o700);
@@ -282,12 +364,26 @@ export function enableScheduler(opts) {
282
364
  writeFileWithDir(plistPath, plistContents, 0o600);
283
365
  const loadCommand = ["launchctl", "bootstrap", `gui/${process.getuid?.() ?? ""}`, plistPath];
284
366
  let loadResult;
367
+ let firstRun;
285
368
  if (!opts.skipLoad) {
286
369
  // Bootout first in case a prior install left the job loaded.
287
370
  spawnReport(["launchctl", "bootout", `gui/${process.getuid?.() ?? ""}`, plistPath]);
288
371
  loadResult = spawnReport(loadCommand);
372
+ if (loadResult.code === 0) {
373
+ // Ordering gate (#1231): verify the first run ONLY after the load
374
+ // exited 0. A load failure is its own failure mode with its own
375
+ // remedy — kickstarting on top of it would blur which actor failed.
376
+ firstRun = verifyFirstRun({
377
+ plat,
378
+ darwinTarget: `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`,
379
+ stderrLogPath,
380
+ });
381
+ }
289
382
  }
290
- return { platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult };
383
+ return {
384
+ platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult,
385
+ firstRunVerified: firstRun?.verified === true, firstRun,
386
+ };
291
387
  }
292
388
  // Linux: systemd user units.
293
389
  const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
@@ -296,13 +392,23 @@ export function enableScheduler(opts) {
296
392
  const timerContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.timer.tmpl"), subs);
297
393
  writeFileWithDir(servicePath, serviceContents, 0o600);
298
394
  writeFileWithDir(timerPath, timerContents, 0o600);
299
- const loadCommand = ["systemctl", "--user", "enable", "--now", "flair-rem-nightly.timer"];
395
+ const loadCommand = ["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT];
300
396
  let loadResult;
397
+ let firstRun;
301
398
  if (!opts.skipLoad) {
302
399
  spawnReport(["systemctl", "--user", "daemon-reload"]);
303
400
  loadResult = spawnReport(loadCommand);
401
+ if (loadResult.code === 0) {
402
+ // Ordering gate (#1231): only after the load exited 0. Starts the
403
+ // SERVICE unit directly (oneshot ⇒ blocks until the run exits) rather
404
+ // than waiting for the nightly timer to fire.
405
+ firstRun = verifyFirstRun({ plat, linuxServiceUnit: SYSTEMD_SERVICE_UNIT, stderrLogPath });
406
+ }
304
407
  }
305
- return { platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult };
408
+ return {
409
+ platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult,
410
+ firstRunVerified: firstRun?.verified === true, firstRun,
411
+ };
306
412
  }
307
413
  /**
308
414
  * Removes the scheduler entry. Audit log + snapshots are preserved.
@@ -332,7 +438,7 @@ export function disableScheduler(opts = {}) {
332
438
  }
333
439
  const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
334
440
  const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
335
- const unloadCommand = ["systemctl", "--user", "disable", "--now", "flair-rem-nightly.timer"];
441
+ const unloadCommand = ["systemctl", "--user", "disable", "--now", SYSTEMD_TIMER_UNIT];
336
442
  let unloadResult;
337
443
  if (existsSync(timerPath) || existsSync(servicePath)) {
338
444
  if (!opts.skipUnload) {