@tpsdev-ai/flair 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -377
- package/dist/cli-shim.cjs +1 -1
- package/dist/cli.js +1567 -366
- package/dist/deploy.js +241 -43
- package/dist/fabric-upgrade.js +77 -24
- package/dist/federation/scheduler.js +500 -0
- package/dist/fleet-verify.js +3 -3
- package/dist/install/clients.js +111 -53
- package/dist/lib/mcp-enable.js +2 -2
- package/dist/lib/mcp-spec.js +128 -0
- package/dist/lib/safe-snapshot-extract.js +231 -0
- package/dist/lib/scheduler-platform.js +128 -0
- package/dist/lib/xml-escape.js +54 -0
- package/dist/rem/scheduler.js +212 -55
- package/dist/rem/snapshot.js +13 -0
- package/dist/replication-convergence.js +505 -0
- package/dist/resources/A2AAdapter.js +1 -1
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +1 -1
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +1 -1
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +1 -1
- package/dist/resources/Credential.js +1 -1
- package/dist/resources/Federation.js +1 -1
- package/dist/resources/Instance.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +1 -1
- package/dist/resources/Memory.js +1 -1
- package/dist/resources/MemoryBootstrap.js +8 -9
- package/dist/resources/MemoryCandidate.js +168 -0
- package/dist/resources/MemoryConsolidate.js +1 -1
- package/dist/resources/MemoryDedupStats.js +1 -1
- package/dist/resources/MemoryFeed.js +1 -1
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryMaintenance.js +1 -1
- package/dist/resources/MemoryReflect.js +2 -2
- package/dist/resources/MemoryReindex.js +1 -1
- package/dist/resources/MemoryUsage.js +1 -1
- package/dist/resources/OAuth.js +1 -1
- package/dist/resources/OAuthClient.js +1 -1
- package/dist/resources/OrgEvent.js +1 -1
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/OrgEventMaintenance.js +1 -1
- package/dist/resources/PairingToken.js +1 -1
- package/dist/resources/Peer.js +1 -1
- package/dist/resources/Presence.js +3 -3
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +1 -1
- package/dist/resources/SemanticSearch.js +18 -46
- package/dist/resources/SkillScan.js +1 -1
- package/dist/resources/Soul.js +1 -1
- package/dist/resources/SoulFeed.js +1 -1
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/XAA.js +1 -1
- package/dist/resources/a2a-url.js +1 -1
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +1 -1
- package/dist/resources/auth-middleware.js +1 -1
- package/dist/resources/embeddings-boot.js +14 -16
- package/dist/resources/embeddings-provider.js +15 -12
- package/dist/resources/federation-cleanup.js +5 -5
- package/dist/resources/federation-nonce-store.js +5 -5
- package/dist/resources/health.js +53 -25
- package/dist/resources/in-process.js +225 -0
- package/dist/resources/instance-identity.js +1 -1
- package/dist/resources/mcp-client-metadata-fields.js +1 -1
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-oauth.js +2 -2
- package/dist/resources/mcp-tools.js +23 -17
- package/dist/resources/memory-read-scope.js +1 -1
- package/dist/resources/memory-reflect-lib.js +3 -3
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +81 -11
- package/dist/resources/migrations/data-dir.js +205 -0
- package/dist/resources/migrations/embedding-stamp.js +3 -3
- package/dist/resources/migrations/graph-heal.js +2 -2
- package/dist/resources/migrations/ledger.js +1 -1
- package/dist/resources/migrations/progress.js +33 -0
- package/dist/resources/migrations/registry.js +14 -5
- package/dist/resources/migrations/runner.js +93 -13
- package/dist/resources/migrations/state.js +13 -2
- package/dist/resources/migrations/synthetic-test-migration.js +1 -1
- package/dist/resources/migrations/visibility-backfill.js +232 -0
- package/dist/resources/models-dir.js +18 -9
- package/dist/resources/record-types.js +28 -0
- package/dist/resources/semantic-retrieval-core.js +6 -5
- package/dist/resources/usage-recording.js +1 -1
- package/dist/src/lib/scheduler-platform.js +128 -0
- package/dist/src/lib/xml-escape.js +54 -0
- package/dist/src/rem/scheduler.js +397 -0
- package/docs/deploying-on-fabric.md +267 -0
- package/docs/deployment.md +6 -0
- package/docs/embedding-in-a-harper-app.md +299 -0
- package/docs/federation.md +61 -4
- package/docs/integrations.md +3 -0
- package/docs/mcp-clients.md +16 -7
- package/docs/quickstart.md +80 -54
- package/docs/releasing.md +72 -38
- package/docs/supply-chain-policy.md +40 -4
- package/docs/troubleshooting.md +24 -0
- package/docs/upgrade.md +100 -5
- package/package.json +4 -14
- package/schemas/memory.graphql +9 -1
- package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
- package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
- package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
- package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
- package/dist/resources/rerank-provider.js +0 -569
- package/docs/rerank-provisioning.md +0 -101
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-scheduler primitives shared by every Flair-managed background job.
|
|
3
|
+
*
|
|
4
|
+
* Two modules install user-session scheduler entries today:
|
|
5
|
+
* - src/rem/scheduler.ts (`flair rem nightly enable`)
|
|
6
|
+
* - src/federation/scheduler.ts (`flair federation sync enable`)
|
|
7
|
+
*
|
|
8
|
+
* They differ in what they schedule and how often; they do NOT differ in how
|
|
9
|
+
* you ask launchd/systemd whether a job is really loaded, how you read the
|
|
10
|
+
* answer, or how you render a template into a unit file. This module exists so
|
|
11
|
+
* there is exactly ONE implementation of those — same reason src/lib/xml-escape.ts
|
|
12
|
+
* exists (#918): the second copy of a subtle rule is where it goes wrong.
|
|
13
|
+
*
|
|
14
|
+
* `interpretActiveResult()` in particular encodes a production lesson
|
|
15
|
+
* (flair#850) that took a real outage to learn. It must not be re-derived.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
18
|
+
import { resolve, dirname } from "node:path";
|
|
19
|
+
import { platform } from "node:os";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
/**
|
|
22
|
+
* 30s ceiling on launchctl/systemctl invocations so a hung service manager
|
|
23
|
+
* can't block the CLI indefinitely. Sherlock #415 follow-up.
|
|
24
|
+
*/
|
|
25
|
+
export const SPAWN_TIMEOUT_MS = 30_000;
|
|
26
|
+
/**
|
|
27
|
+
* Status-check spawns (launchctl print / systemctl is-active) return
|
|
28
|
+
* near-instantly when the service manager is reachable, and fail fast (no
|
|
29
|
+
* hang) when it isn't (e.g. "Failed to connect to bus"). A short ceiling
|
|
30
|
+
* keeps status commands and the Health endpoint responsive even when the
|
|
31
|
+
* query is inconclusive.
|
|
32
|
+
*/
|
|
33
|
+
export const STATUS_CHECK_TIMEOUT_MS = 5_000;
|
|
34
|
+
export function detectPlatform(label, override) {
|
|
35
|
+
if (override)
|
|
36
|
+
return override;
|
|
37
|
+
const p = platform();
|
|
38
|
+
if (p === "darwin")
|
|
39
|
+
return "darwin";
|
|
40
|
+
if (p === "linux")
|
|
41
|
+
return "linux";
|
|
42
|
+
throw new Error(`unsupported platform for ${label}: ${p} (only darwin and linux)`);
|
|
43
|
+
}
|
|
44
|
+
export function spawnReport(cmd, timeoutMs = SPAWN_TIMEOUT_MS) {
|
|
45
|
+
const r = spawnSync(cmd[0], cmd.slice(1), {
|
|
46
|
+
encoding: "buffer",
|
|
47
|
+
timeout: timeoutMs,
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
code: r.status,
|
|
51
|
+
stdout: r.stdout?.toString("utf-8") ?? "",
|
|
52
|
+
stderr: r.stderr?.toString("utf-8") ?? "",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Interprets the result of a `launchctl print` / `systemctl --user is-active`
|
|
57
|
+
* probe. Shared by the sync (CLI) and async (server) callers of both
|
|
58
|
+
* schedulers.
|
|
59
|
+
*
|
|
60
|
+
* - true — the service manager confirms the job is loaded/active.
|
|
61
|
+
* - false — confirmed NOT active. This includes the "no session bus" case
|
|
62
|
+
* (flair#850): when `systemctl --user` can't reach a bus, it fails
|
|
63
|
+
* before printing a status word ("Failed to connect to bus: No medium
|
|
64
|
+
* found") — but nothing CAN be running without a bus, so `false` is the
|
|
65
|
+
* honest answer, not "unknown".
|
|
66
|
+
* - null — genuinely inconclusive (the command itself couldn't run at
|
|
67
|
+
* all — e.g. the binary is missing — with no output to interpret).
|
|
68
|
+
*/
|
|
69
|
+
export function interpretActiveResult(plat, code, stdout, stderr) {
|
|
70
|
+
const noOutput = !stdout.trim() && !stderr.trim();
|
|
71
|
+
if (plat === "darwin") {
|
|
72
|
+
if (code === 0)
|
|
73
|
+
return true;
|
|
74
|
+
if (code === null && noOutput)
|
|
75
|
+
return null; // spawn itself failed — inconclusive
|
|
76
|
+
return false; // launchctl ran and reported not-loaded
|
|
77
|
+
}
|
|
78
|
+
const out = stdout.trim();
|
|
79
|
+
if (out === "active" || out === "activating")
|
|
80
|
+
return true;
|
|
81
|
+
if (out === "inactive" || out === "failed" || out === "unknown")
|
|
82
|
+
return false;
|
|
83
|
+
if (code === null && noOutput)
|
|
84
|
+
return null; // spawn itself failed — inconclusive
|
|
85
|
+
return false; // covers the no-bus case: empty stdout, nonzero/failed exit
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Human remedy text for a failed scheduler-load attempt (flair#850). Covers
|
|
89
|
+
* the one root cause traced so far: a missing systemd user session bus,
|
|
90
|
+
* which blocks `systemctl --user` entirely in ssh-without-lingering,
|
|
91
|
+
* container, and CI contexts. Returns null when the failure doesn't match a
|
|
92
|
+
* known pattern — the caller already prints the raw stderr, so the operator
|
|
93
|
+
* still has something to go on.
|
|
94
|
+
*
|
|
95
|
+
* `enableCommand` is the caller's own enable invocation, named in the remedy
|
|
96
|
+
* so the operator is told to re-run the command they actually ran.
|
|
97
|
+
*/
|
|
98
|
+
export function describeLoadFailure(plat, loadResult, enableCommand) {
|
|
99
|
+
const stderr = loadResult.stderr || "";
|
|
100
|
+
if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
|
|
101
|
+
return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
|
|
102
|
+
"in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
|
|
103
|
+
`— then re-run \`${enableCommand}\`.`);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/** Single-pass `{{KEY}}` substitution, with a per-value escape hook. */
|
|
108
|
+
export function renderTemplateWith(text, subs, escape) {
|
|
109
|
+
return text.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => {
|
|
110
|
+
const value = subs[key];
|
|
111
|
+
if (value === undefined)
|
|
112
|
+
throw new Error(`unknown template placeholder: ${key}`);
|
|
113
|
+
return escape(String(value));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
export function readTemplate(rootDir, relativePath) {
|
|
117
|
+
const full = resolve(rootDir, relativePath);
|
|
118
|
+
if (!existsSync(full)) {
|
|
119
|
+
throw new Error(`template not found: ${full}`);
|
|
120
|
+
}
|
|
121
|
+
return readFileSync(full, "utf-8");
|
|
122
|
+
}
|
|
123
|
+
export function writeFileWithDir(path, contents, mode = 0o600) {
|
|
124
|
+
const dir = dirname(path);
|
|
125
|
+
if (!existsSync(dir))
|
|
126
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
127
|
+
writeFileSync(path, contents, { mode });
|
|
128
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XML entity escaping for the launchd plists this CLI generates.
|
|
3
|
+
*
|
|
4
|
+
* A launchd plist is XML, so ANY value interpolated into one has to be
|
|
5
|
+
* escaped or the document is malformed — and `launchctl load` rejects a
|
|
6
|
+
* malformed plist outright, so the service silently never registers.
|
|
7
|
+
*
|
|
8
|
+
* Two independent writers generate plists, and both go through here:
|
|
9
|
+
* - buildLaunchdPlist() in src/cli.ts (the Harper service, `flair init`)
|
|
10
|
+
* - renderPlistTemplate() in src/rem/scheduler.ts (`flair rem nightly enable`)
|
|
11
|
+
*
|
|
12
|
+
* This module exists so there is exactly ONE implementation to get right.
|
|
13
|
+
* Anything that interpolates into plist XML imports it rather than
|
|
14
|
+
* hand-rolling a `.replace()` chain at the call site — a local chain is how
|
|
15
|
+
* this class of bug got in (and how it stayed partial: the original one
|
|
16
|
+
* covered three of the five entities).
|
|
17
|
+
*/
|
|
18
|
+
/** The five XML predefined entities, in the order they must be replaced. */
|
|
19
|
+
const XML_ESCAPES = [
|
|
20
|
+
// `&` MUST be first: replacing it after `<` would turn the `&` of an
|
|
21
|
+
// already-emitted `<` into `&lt;` and corrupt the value.
|
|
22
|
+
[/&/g, "&"],
|
|
23
|
+
[/</g, "<"],
|
|
24
|
+
[/>/g, ">"],
|
|
25
|
+
[/"/g, """],
|
|
26
|
+
[/'/g, "'"],
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Escape the five XML predefined entities for use inside an XML text node.
|
|
30
|
+
*
|
|
31
|
+
* Safe to apply to any string, including one with no special characters.
|
|
32
|
+
* The result round-trips exactly through unescapeXml().
|
|
33
|
+
*/
|
|
34
|
+
export function escapeXml(s) {
|
|
35
|
+
let out = s;
|
|
36
|
+
for (const [pattern, replacement] of XML_ESCAPES)
|
|
37
|
+
out = out.replace(pattern, replacement);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Inverse of escapeXml(), for reading a value back out of a document we wrote.
|
|
42
|
+
*
|
|
43
|
+
* `&` MUST be decoded LAST, mirroring escapeXml()'s ordering: decoding it
|
|
44
|
+
* first would turn `&lt;` (the escaping of a literal "<") into `<`
|
|
45
|
+
* instead of back into `<`.
|
|
46
|
+
*/
|
|
47
|
+
export function unescapeXml(s) {
|
|
48
|
+
return s
|
|
49
|
+
.replace(/</g, "<")
|
|
50
|
+
.replace(/>/g, ">")
|
|
51
|
+
.replace(/"/g, '"')
|
|
52
|
+
.replace(/'/g, "'")
|
|
53
|
+
.replace(/&/g, "&");
|
|
54
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REM nightly scheduler — platform-native scheduler install/uninstall.
|
|
3
|
+
*
|
|
4
|
+
* Per FLAIR-NIGHTLY-REM § 3 (`flair rem nightly enable|disable`). Renders
|
|
5
|
+
* launchd plist (macOS) or systemd timer+service (Linux) from templates,
|
|
6
|
+
* deploys a shim script to `~/.flair/bin/flair-rem-nightly`, and loads the
|
|
7
|
+
* job into the user-session scheduler.
|
|
8
|
+
*
|
|
9
|
+
* Templates use `{{KEY}}` placeholders — single-pass substitution. The full
|
|
10
|
+
* placeholder set is enumerated in `interface SchedulerSubstitutions` so
|
|
11
|
+
* adding a new key requires touching both this module and the template.
|
|
12
|
+
*
|
|
13
|
+
* No daemon code lives here — the scheduler invokes the shim, the shim
|
|
14
|
+
* invokes `flair rem nightly run-once`, the runner module does the work.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, chmodSync, rmSync } from "node:fs";
|
|
17
|
+
import { resolve, dirname } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
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";
|
|
23
|
+
// Re-exported so this module's public surface is unchanged by the extraction
|
|
24
|
+
// into src/lib/scheduler-platform.ts (a second scheduler — `flair federation
|
|
25
|
+
// sync enable` — needs the identical launchctl/systemctl interpretation, and
|
|
26
|
+
// flair#850's lesson must have exactly one implementation).
|
|
27
|
+
export { interpretActiveResult };
|
|
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");
|
|
30
|
+
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");
|
|
33
|
+
function detectPlatform(override) {
|
|
34
|
+
return detectPlatformFor("REM nightly scheduler", override);
|
|
35
|
+
}
|
|
36
|
+
function defaultTemplateRoot() {
|
|
37
|
+
// Templates live alongside dist/ in the published package and alongside
|
|
38
|
+
// src/rem/ in the source tree. Walk up from this file until we find
|
|
39
|
+
// a directory containing templates/.
|
|
40
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const candidates = [
|
|
42
|
+
resolve(here, "..", "..", "templates"),
|
|
43
|
+
resolve(here, "..", "..", "..", "templates"),
|
|
44
|
+
resolve(here, "..", "templates"),
|
|
45
|
+
];
|
|
46
|
+
for (const c of candidates) {
|
|
47
|
+
if (existsSync(c))
|
|
48
|
+
return c;
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`unable to locate templates directory (looked in: ${candidates.join(", ")})`);
|
|
51
|
+
}
|
|
52
|
+
export function renderTemplate(text, subs) {
|
|
53
|
+
return renderTemplateWith(text, { ...subs }, (v) => v);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* renderTemplate() for the launchd plist template specifically: substituted
|
|
57
|
+
* values are XML-escaped.
|
|
58
|
+
*
|
|
59
|
+
* A launchd plist is XML, so a substitution carrying `&`, `<`, `>`, `"` or
|
|
60
|
+
* `'` makes it malformed and `launchctl bootstrap` rejects it — the timer
|
|
61
|
+
* silently never registers. FLAIR_URL is the realistic carrier (a URL with
|
|
62
|
+
* more than one query parameter contains `&`), but HOME and SHIM_PATH are
|
|
63
|
+
* arbitrary paths and equally capable of it. HOUR/MINUTE and AGENT_ID are
|
|
64
|
+
* validated upstream in buildSubstitutions(), but they are escaped too
|
|
65
|
+
* rather than exempted — the point of a chokepoint is that no value gets to
|
|
66
|
+
* argue it is special.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately NOT folded into renderTemplate(): the same substitutions are
|
|
69
|
+
* rendered into the systemd unit files and the shell shim, where XML
|
|
70
|
+
* escaping would be corruption rather than a fix.
|
|
71
|
+
*/
|
|
72
|
+
export function renderPlistTemplate(text, subs) {
|
|
73
|
+
return renderTemplateWith(text, { ...subs }, escapeXml);
|
|
74
|
+
}
|
|
75
|
+
export function readTemplate(rootDir, relativePath) {
|
|
76
|
+
return readTemplateFrom(rootDir, relativePath);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Validates the hour:minute schedule. Throws on invalid input rather than
|
|
80
|
+
* silently coercing — surface bad input at the install boundary.
|
|
81
|
+
*/
|
|
82
|
+
function validateSchedule(hour, minute) {
|
|
83
|
+
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
|
84
|
+
throw new Error(`hour must be an integer 0-23, got ${hour}`);
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
87
|
+
throw new Error(`minute must be an integer 0-59, got ${minute}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function buildSubstitutions(opts, shimPath, flairBin) {
|
|
91
|
+
validateSchedule(opts.hour, opts.minute);
|
|
92
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(opts.agentId)) {
|
|
93
|
+
throw new Error(`invalid agent id: ${opts.agentId}`);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
FLAIR_BIN: flairBin,
|
|
97
|
+
SHIM_PATH: shimPath,
|
|
98
|
+
HOME: homedir(),
|
|
99
|
+
AGENT_ID: opts.agentId,
|
|
100
|
+
FLAIR_URL: opts.flairUrl,
|
|
101
|
+
HOUR: String(opts.hour),
|
|
102
|
+
HOUR_PAD: String(opts.hour).padStart(2, "0"),
|
|
103
|
+
MINUTE: String(opts.minute),
|
|
104
|
+
MINUTE_PAD: String(opts.minute).padStart(2, "0"),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Command to genuinely query the platform scheduler's active/loaded state
|
|
109
|
+
* for a given install (as opposed to the shape of `loadCommand`, which
|
|
110
|
+
* *installs* it). Shared by the sync and async active-state checks below so
|
|
111
|
+
* the two can't drift.
|
|
112
|
+
*/
|
|
113
|
+
function activeCheckCommand(plat) {
|
|
114
|
+
if (plat === "darwin") {
|
|
115
|
+
return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/dev.flair.rem.nightly`];
|
|
116
|
+
}
|
|
117
|
+
return ["systemctl", "--user", "is-active", "flair-rem-nightly.timer"];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Synchronous active-state check for CLI use (`flair rem nightly status`).
|
|
121
|
+
* Blocking is fine here — this is a one-shot process and the caller wants
|
|
122
|
+
* the answer before printing anything.
|
|
123
|
+
*/
|
|
124
|
+
function queryActiveState(plat) {
|
|
125
|
+
const [cmd, ...args] = activeCheckCommand(plat);
|
|
126
|
+
const r = spawnReport([cmd, ...args], STATUS_CHECK_TIMEOUT_MS);
|
|
127
|
+
return interpretActiveResult(plat, r.code, r.stdout, r.stderr);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Async, non-blocking equivalent for server contexts (the Health endpoint)
|
|
131
|
+
* where a synchronous subprocess would stall the request-handling thread.
|
|
132
|
+
* Same semantics as `queryActiveState()`.
|
|
133
|
+
*/
|
|
134
|
+
export async function queryActiveStateAsync(plat, timeoutMs = STATUS_CHECK_TIMEOUT_MS) {
|
|
135
|
+
const [cmd, ...args] = activeCheckCommand(plat);
|
|
136
|
+
return new Promise((resolvePromise) => {
|
|
137
|
+
let settled = false;
|
|
138
|
+
let child;
|
|
139
|
+
const finish = (result) => {
|
|
140
|
+
if (settled)
|
|
141
|
+
return;
|
|
142
|
+
settled = true;
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
resolvePromise(result);
|
|
145
|
+
};
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
try {
|
|
148
|
+
child?.kill("SIGKILL");
|
|
149
|
+
}
|
|
150
|
+
catch { /* best-effort */ }
|
|
151
|
+
finish(null);
|
|
152
|
+
}, timeoutMs);
|
|
153
|
+
try {
|
|
154
|
+
child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
finish(null);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let stdout = "";
|
|
161
|
+
let stderr = "";
|
|
162
|
+
child.stdout?.on("data", (d) => { stdout += d.toString("utf-8"); });
|
|
163
|
+
child.stderr?.on("data", (d) => { stderr += d.toString("utf-8"); });
|
|
164
|
+
child.on("error", () => finish(null));
|
|
165
|
+
child.on("close", (code) => finish(interpretActiveResult(plat, code, stdout, stderr)));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Human remedy text for a failed scheduler-load attempt (flair#850). Covers
|
|
170
|
+
* the one root cause traced so far: a missing systemd user session bus,
|
|
171
|
+
* which blocks `systemctl --user` entirely in ssh-without-lingering,
|
|
172
|
+
* container, and CI contexts. Returns null when the failure doesn't match a
|
|
173
|
+
* known pattern — the caller already prints the raw stderr, so the operator
|
|
174
|
+
* still has something to go on.
|
|
175
|
+
*/
|
|
176
|
+
export function describeLoadFailure(plat, loadResult) {
|
|
177
|
+
return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable");
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Formats the `flair rem nightly enable` report from an `EnableResult`.
|
|
181
|
+
* Pulled out of the CLI action so the success-vs-failure decision (flair#850:
|
|
182
|
+
* do not print a success headline before activation is known to have
|
|
183
|
+
* succeeded) is unit-testable without spawning a real launchctl/systemctl or
|
|
184
|
+
* parsing CLI argv.
|
|
185
|
+
*
|
|
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`.
|
|
190
|
+
*/
|
|
191
|
+
export function formatEnableReport(r, input) {
|
|
192
|
+
const { hour, minute, agentId, flairUrl } = input;
|
|
193
|
+
const scheduleTime = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
194
|
+
const activationFailed = !!r.loadResult && r.loadResult.code !== 0;
|
|
195
|
+
if (activationFailed) {
|
|
196
|
+
const lr = r.loadResult;
|
|
197
|
+
const lines = [
|
|
198
|
+
`⚠️ REM nightly scheduler files written but NOT activated (${r.platform})`,
|
|
199
|
+
` Schedule: ${scheduleTime} local time — NOT scheduled (see below)`,
|
|
200
|
+
` Scheduler: ${r.schedulerPath}`,
|
|
201
|
+
` Shim: ${r.shimPath}`,
|
|
202
|
+
` Agent: ${agentId}`,
|
|
203
|
+
` Flair URL: ${flairUrl}`,
|
|
204
|
+
` Activation: ${r.loadCommand.join(" ")} → code ${lr.code}`,
|
|
205
|
+
];
|
|
206
|
+
if (lr.stderr)
|
|
207
|
+
lines.push(` stderr: ${lr.stderr.trim()}`);
|
|
208
|
+
const remedy = describeLoadFailure(r.platform, lr);
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push(remedy ? ` ${remedy}` : ` Re-run the activation command above manually to see the full diagnostic.`);
|
|
211
|
+
lines.push("");
|
|
212
|
+
lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair rem nightly status`);
|
|
213
|
+
return { lines, ok: false };
|
|
214
|
+
}
|
|
215
|
+
const lines = [
|
|
216
|
+
`✅ REM nightly scheduler enabled (${r.platform})`,
|
|
217
|
+
` Schedule: ${scheduleTime} local time`,
|
|
218
|
+
` Scheduler: ${r.schedulerPath}`,
|
|
219
|
+
` Shim: ${r.shimPath}`,
|
|
220
|
+
` Agent: ${agentId}`,
|
|
221
|
+
` Flair URL: ${flairUrl}`,
|
|
222
|
+
];
|
|
223
|
+
if (r.loadResult) {
|
|
224
|
+
lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
|
|
225
|
+
}
|
|
226
|
+
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\`.`);
|
|
229
|
+
return { lines, ok: true };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Formats the `flair rem nightly status` report from a `SchedulerStatus`.
|
|
233
|
+
* Extracted for the same testability reason as `formatEnableReport()`
|
|
234
|
+
* (flair#850): status must report genuine active state, not file presence.
|
|
235
|
+
*/
|
|
236
|
+
export function formatStatusReport(s) {
|
|
237
|
+
const activeTxt = s.active === true ? "yes" : s.active === false ? "no" : "unknown";
|
|
238
|
+
const lines = [
|
|
239
|
+
`REM nightly scheduler (${s.platform}):`,
|
|
240
|
+
` Active: ${activeTxt}`,
|
|
241
|
+
` Installed: ${s.installed ? "yes" : "no"}`,
|
|
242
|
+
` Scheduler: ${s.schedulerPath}`,
|
|
243
|
+
` Shim: ${s.shimPath}${s.shimExists ? "" : " (missing)"}`,
|
|
244
|
+
];
|
|
245
|
+
if (!s.installed) {
|
|
246
|
+
lines.push("");
|
|
247
|
+
lines.push(`Enable with: flair rem nightly enable --agent <id> [--at HH:MM]`);
|
|
248
|
+
}
|
|
249
|
+
else if (s.active === false) {
|
|
250
|
+
lines.push("");
|
|
251
|
+
lines.push(`Files are written but nothing is scheduled — the job is not loaded/active.`);
|
|
252
|
+
lines.push(`Re-run: flair rem nightly enable --agent <id> [--at HH:MM]`);
|
|
253
|
+
}
|
|
254
|
+
// Status is informational — it does not itself signal process failure,
|
|
255
|
+
// consistent with the pre-existing "not installed" case never exiting
|
|
256
|
+
// nonzero. `ok` here only reflects whether the headline claims success.
|
|
257
|
+
return { lines, ok: s.active !== false };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Installs the platform-native scheduler entry and the shim script.
|
|
261
|
+
*
|
|
262
|
+
* macOS: writes ~/Library/LaunchAgents/dev.flair.rem.nightly.plist + bootstraps it via launchctl.
|
|
263
|
+
* Linux: writes ~/.config/systemd/user/flair-rem-nightly.{timer,service} + enables the timer.
|
|
264
|
+
*
|
|
265
|
+
* In both cases, also deploys ~/.flair/bin/flair-rem-nightly as the shim
|
|
266
|
+
* the scheduler invokes.
|
|
267
|
+
*/
|
|
268
|
+
export function enableScheduler(opts) {
|
|
269
|
+
const plat = detectPlatform(opts.platformOverride);
|
|
270
|
+
const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
|
|
271
|
+
const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
272
|
+
const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
|
|
273
|
+
const subs = buildSubstitutions(opts, shimPath, flairBin);
|
|
274
|
+
// 1. Deploy the shim (always — both platforms invoke it).
|
|
275
|
+
const shimContents = renderTemplate(readTemplate(templateRoot, "bin/flair-rem-nightly.sh.tmpl"), subs);
|
|
276
|
+
writeFileWithDir(shimPath, shimContents, 0o700);
|
|
277
|
+
chmodSync(shimPath, 0o700);
|
|
278
|
+
// 2. Write the scheduler entry.
|
|
279
|
+
if (plat === "darwin") {
|
|
280
|
+
const plistPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
|
|
281
|
+
const plistContents = renderPlistTemplate(readTemplate(templateRoot, "launchd/dev.flair.rem.nightly.plist.tmpl"), subs);
|
|
282
|
+
writeFileWithDir(plistPath, plistContents, 0o600);
|
|
283
|
+
const loadCommand = ["launchctl", "bootstrap", `gui/${process.getuid?.() ?? ""}`, plistPath];
|
|
284
|
+
let loadResult;
|
|
285
|
+
if (!opts.skipLoad) {
|
|
286
|
+
// Bootout first in case a prior install left the job loaded.
|
|
287
|
+
spawnReport(["launchctl", "bootout", `gui/${process.getuid?.() ?? ""}`, plistPath]);
|
|
288
|
+
loadResult = spawnReport(loadCommand);
|
|
289
|
+
}
|
|
290
|
+
return { platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult };
|
|
291
|
+
}
|
|
292
|
+
// Linux: systemd user units.
|
|
293
|
+
const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
|
|
294
|
+
const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
|
|
295
|
+
const serviceContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.service.tmpl"), subs);
|
|
296
|
+
const timerContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.timer.tmpl"), subs);
|
|
297
|
+
writeFileWithDir(servicePath, serviceContents, 0o600);
|
|
298
|
+
writeFileWithDir(timerPath, timerContents, 0o600);
|
|
299
|
+
const loadCommand = ["systemctl", "--user", "enable", "--now", "flair-rem-nightly.timer"];
|
|
300
|
+
let loadResult;
|
|
301
|
+
if (!opts.skipLoad) {
|
|
302
|
+
spawnReport(["systemctl", "--user", "daemon-reload"]);
|
|
303
|
+
loadResult = spawnReport(loadCommand);
|
|
304
|
+
}
|
|
305
|
+
return { platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Removes the scheduler entry. Audit log + snapshots are preserved.
|
|
309
|
+
*/
|
|
310
|
+
export function disableScheduler(opts = {}) {
|
|
311
|
+
const plat = detectPlatform(opts.platformOverride);
|
|
312
|
+
const removed = [];
|
|
313
|
+
if (plat === "darwin") {
|
|
314
|
+
const plistPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
|
|
315
|
+
const unloadCommand = ["launchctl", "bootout", `gui/${process.getuid?.() ?? ""}`, plistPath];
|
|
316
|
+
let unloadResult;
|
|
317
|
+
if (existsSync(plistPath)) {
|
|
318
|
+
if (!opts.skipUnload) {
|
|
319
|
+
unloadResult = spawnReport(unloadCommand);
|
|
320
|
+
}
|
|
321
|
+
rmSync(plistPath, { force: true });
|
|
322
|
+
removed.push(plistPath);
|
|
323
|
+
}
|
|
324
|
+
if (opts.removeShim) {
|
|
325
|
+
const shim = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
326
|
+
if (existsSync(shim)) {
|
|
327
|
+
rmSync(shim, { force: true });
|
|
328
|
+
removed.push(shim);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { platform: plat, removed, unloadCommand, unloadResult };
|
|
332
|
+
}
|
|
333
|
+
const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
|
|
334
|
+
const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
|
|
335
|
+
const unloadCommand = ["systemctl", "--user", "disable", "--now", "flair-rem-nightly.timer"];
|
|
336
|
+
let unloadResult;
|
|
337
|
+
if (existsSync(timerPath) || existsSync(servicePath)) {
|
|
338
|
+
if (!opts.skipUnload) {
|
|
339
|
+
unloadResult = spawnReport(unloadCommand);
|
|
340
|
+
spawnReport(["systemctl", "--user", "daemon-reload"]);
|
|
341
|
+
}
|
|
342
|
+
if (existsSync(timerPath)) {
|
|
343
|
+
rmSync(timerPath, { force: true });
|
|
344
|
+
removed.push(timerPath);
|
|
345
|
+
}
|
|
346
|
+
if (existsSync(servicePath)) {
|
|
347
|
+
rmSync(servicePath, { force: true });
|
|
348
|
+
removed.push(servicePath);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (opts.removeShim) {
|
|
352
|
+
const shim = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
353
|
+
if (existsSync(shim)) {
|
|
354
|
+
rmSync(shim, { force: true });
|
|
355
|
+
removed.push(shim);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { platform: plat, removed, unloadCommand, unloadResult };
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Reports whether the scheduler is installed AND whether it is genuinely
|
|
362
|
+
* active. File presence alone proves the templates were written — it does
|
|
363
|
+
* NOT prove `launchctl`/`systemctl` successfully loaded the job (flair#850).
|
|
364
|
+
*/
|
|
365
|
+
export function schedulerStatus(opts = {}) {
|
|
366
|
+
const plat = detectPlatform(opts.platformOverride);
|
|
367
|
+
const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
368
|
+
let schedulerPath;
|
|
369
|
+
let installed;
|
|
370
|
+
if (plat === "darwin") {
|
|
371
|
+
schedulerPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
|
|
372
|
+
installed = existsSync(schedulerPath);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
schedulerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
|
|
376
|
+
const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
|
|
377
|
+
installed = existsSync(schedulerPath) && existsSync(servicePath);
|
|
378
|
+
}
|
|
379
|
+
let active;
|
|
380
|
+
if (!installed) {
|
|
381
|
+
active = false; // nothing written — definitely nothing active
|
|
382
|
+
}
|
|
383
|
+
else if (opts.skipActiveCheck) {
|
|
384
|
+
active = null; // caller opted out — unknown, not a claim either way
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
active = queryActiveState(plat);
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
platform: plat,
|
|
391
|
+
installed,
|
|
392
|
+
active,
|
|
393
|
+
schedulerPath,
|
|
394
|
+
shimPath,
|
|
395
|
+
shimExists: existsSync(shimPath),
|
|
396
|
+
};
|
|
397
|
+
}
|