@tpsdev-ai/flair 0.53.0 → 0.54.1
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 +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1791 -15648
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1654 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +914 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1592 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/fabric-npm-install.js +87 -0
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +5 -4
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +43 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +15 -7
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
- package/package.json +9 -4
- package/schemas/agent.graphql +15 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { DEFAULT_ADMIN_USER } from "../lib/auth-resolve.js";
|
|
2
|
+
import { classifyDaemonState } from "../lib/daemon-liveness.js";
|
|
3
|
+
import { diagnoseLaunchdPlistPaths, isDetached, renderDetachedWarning } from "../lib/launchd-management.js";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
let cli;
|
|
8
|
+
/** Bind the cli-locals this module depends on. */
|
|
9
|
+
export function bindCli(fns) {
|
|
10
|
+
cli = fns;
|
|
11
|
+
}
|
|
12
|
+
function buildDirectSpawnEnv(...args) {
|
|
13
|
+
return cli.buildDirectSpawnEnv(...args);
|
|
14
|
+
}
|
|
15
|
+
function defaultDataDir(...args) {
|
|
16
|
+
return cli.defaultDataDir(...args);
|
|
17
|
+
}
|
|
18
|
+
function ensureLaunchdServiceLoaded(...args) {
|
|
19
|
+
return cli.ensureLaunchdServiceLoaded(...args);
|
|
20
|
+
}
|
|
21
|
+
function flairPackageDir(...args) {
|
|
22
|
+
return cli.flairPackageDir(...args);
|
|
23
|
+
}
|
|
24
|
+
function gatherDaemonEvidence(...args) {
|
|
25
|
+
return cli.gatherDaemonEvidence(...args);
|
|
26
|
+
}
|
|
27
|
+
function guardEngineNotBackwards(...args) {
|
|
28
|
+
return cli.guardEngineNotBackwards(...args);
|
|
29
|
+
}
|
|
30
|
+
function harperBinNotFoundMessage(...args) {
|
|
31
|
+
return cli.harperBinNotFoundMessage(...args);
|
|
32
|
+
}
|
|
33
|
+
function harperSearchRoots(...args) {
|
|
34
|
+
return cli.harperSearchRoots(...args);
|
|
35
|
+
}
|
|
36
|
+
function observeLaunchdManagement(...args) {
|
|
37
|
+
return cli.observeLaunchdManagement(...args);
|
|
38
|
+
}
|
|
39
|
+
function probeHealth(...args) {
|
|
40
|
+
return cli.probeHealth(...args);
|
|
41
|
+
}
|
|
42
|
+
function readyOpsSocketPosture(...args) {
|
|
43
|
+
return cli.readyOpsSocketPosture(...args);
|
|
44
|
+
}
|
|
45
|
+
function resolveHarperBin(...args) {
|
|
46
|
+
return cli.resolveHarperBin(...args);
|
|
47
|
+
}
|
|
48
|
+
function resolveHttpPort(...args) {
|
|
49
|
+
return cli.resolveHttpPort(...args);
|
|
50
|
+
}
|
|
51
|
+
function resolveLaunchdLabel(...args) {
|
|
52
|
+
return cli.resolveLaunchdLabel(...args);
|
|
53
|
+
}
|
|
54
|
+
function resolveOpsBindHost(...args) {
|
|
55
|
+
return cli.resolveOpsBindHost(...args);
|
|
56
|
+
}
|
|
57
|
+
function resolveOpsPort(...args) {
|
|
58
|
+
return cli.resolveOpsPort(...args);
|
|
59
|
+
}
|
|
60
|
+
function restartFlair(...args) {
|
|
61
|
+
return cli.restartFlair(...args);
|
|
62
|
+
}
|
|
63
|
+
function stampEngineVersionIfRunning(...args) {
|
|
64
|
+
return cli.stampEngineVersionIfRunning(...args);
|
|
65
|
+
}
|
|
66
|
+
function waitForHealth(...args) {
|
|
67
|
+
return cli.waitForHealth(...args);
|
|
68
|
+
}
|
|
69
|
+
function waitForProcessExit(...args) {
|
|
70
|
+
return cli.waitForProcessExit(...args);
|
|
71
|
+
}
|
|
72
|
+
function writeDaemonSidecar(...args) {
|
|
73
|
+
return cli.writeDaemonSidecar(...args);
|
|
74
|
+
}
|
|
75
|
+
export function register(program) {
|
|
76
|
+
const LEGACY_LAUNCHD_LABEL = cli.LEGACY_LAUNCHD_LABEL;
|
|
77
|
+
const STARTUP_TIMEOUT_MS = cli.STARTUP_TIMEOUT_MS;
|
|
78
|
+
// ─── flair stop ───────────────────────────────────────────────────────────────
|
|
79
|
+
program
|
|
80
|
+
.command("stop")
|
|
81
|
+
.description("Stop the running Flair (Harper) instance")
|
|
82
|
+
.option("--port <port>", "Harper HTTP port")
|
|
83
|
+
.action(async (opts) => {
|
|
84
|
+
const port = resolveHttpPort(opts);
|
|
85
|
+
const platform = process.platform;
|
|
86
|
+
if (platform === "darwin") {
|
|
87
|
+
// macOS: try launchd first. resolveLaunchdLabel (flair#693) finds
|
|
88
|
+
// whichever label this data dir is actually registered under —
|
|
89
|
+
// the new instance-scoped one, or a pre-flair#693 legacy install.
|
|
90
|
+
const { plistPath } = resolveLaunchdLabel(defaultDataDir());
|
|
91
|
+
if (existsSync(plistPath)) {
|
|
92
|
+
try {
|
|
93
|
+
const { execSync } = await import("node:child_process");
|
|
94
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
95
|
+
console.log("✅ Flair stopped (launchd service unloaded)");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// launchd unload failed, try PID fallback
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Non-launchd: the five-state liveness machine (flair#1454). The old
|
|
104
|
+
// decision tree (launchd -> lsof -> "not running") is REPLACED, not
|
|
105
|
+
// patched: `lsof` absence used to render as a definite "not running", and
|
|
106
|
+
// the pidfile was only consulted to attribute port-derived PIDs. Now the
|
|
107
|
+
// pidfile + identity sidecar are the primary evidence, and the health
|
|
108
|
+
// probe is a cross-check — never the verdict.
|
|
109
|
+
const dataDir = defaultDataDir();
|
|
110
|
+
const evidence = await gatherDaemonEvidence(port, dataDir);
|
|
111
|
+
const state = classifyDaemonState(evidence, { port, dataDir });
|
|
112
|
+
switch (state.state) {
|
|
113
|
+
case "RUNNING":
|
|
114
|
+
case "WEDGED": {
|
|
115
|
+
// Identity is already proven for both of these — killing a wedged
|
|
116
|
+
// daemon is recovery, not a recycled-PID gamble.
|
|
117
|
+
const pid = state.pid;
|
|
118
|
+
const label = state.state === "WEDGED" ? "wedged daemon" : "daemon";
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, "SIGTERM");
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (err?.code !== "ESRCH") {
|
|
124
|
+
console.error(`❌ failed to signal pid ${pid}: ${err?.code ?? err?.message}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await waitForProcessExit(pid, STARTUP_TIMEOUT_MS);
|
|
129
|
+
const after = await probeHealth(port);
|
|
130
|
+
if (after.kind === "refused") {
|
|
131
|
+
console.log(`✅ Flair stopped (${label}, pid ${pid})`);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
console.log(`✅ Flair stopped (${label}, pid ${pid}; port ${port} may still be releasing)`);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
case "NOT_RUNNING":
|
|
139
|
+
console.log("Flair is not running.");
|
|
140
|
+
return;
|
|
141
|
+
case "DISAGREEMENT":
|
|
142
|
+
console.error(`⚠️ ${state.detail}`);
|
|
143
|
+
console.error(` Not stopping — the evidence conflicts.`);
|
|
144
|
+
console.error(` pidfile: ${join(dataDir, "hdb.pid")}`);
|
|
145
|
+
console.error(` port: ${port}`);
|
|
146
|
+
console.error(` To inspect: flair doctor`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
case "UNKNOWN":
|
|
149
|
+
console.error(`⚠️ ${state.detail}`);
|
|
150
|
+
console.error(` Not stopping — could not determine whether Flair is running.`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
// ─── flair start ──────────────────────────────────────────────────────────────
|
|
155
|
+
program
|
|
156
|
+
.command("start")
|
|
157
|
+
.description("Start Flair (Harper) — requires a prior 'flair init'")
|
|
158
|
+
.option("--port <port>", "Harper HTTP port")
|
|
159
|
+
.action(async (opts) => {
|
|
160
|
+
const port = resolveHttpPort(opts);
|
|
161
|
+
const dataDir = defaultDataDir();
|
|
162
|
+
// Already-running check via the five-state liveness machine (flair#1454).
|
|
163
|
+
// The old check was a bare `fetch /Health` that treated "got a response"
|
|
164
|
+
// as "already running" and exited 0 — half of #1454. Now the machine
|
|
165
|
+
// classifies, and every non-NOT_RUNNING state refuses with a non-zero exit.
|
|
166
|
+
const evidence = await gatherDaemonEvidence(port, dataDir);
|
|
167
|
+
const state = classifyDaemonState(evidence, { port, dataDir });
|
|
168
|
+
switch (state.state) {
|
|
169
|
+
case "NOT_RUNNING":
|
|
170
|
+
break; // proceed to boot
|
|
171
|
+
case "RUNNING":
|
|
172
|
+
console.error(`Flair is already running on port ${port} (pid ${state.pid}).`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
case "WEDGED":
|
|
175
|
+
console.error(`⚠️ A wedged Flair daemon (pid ${state.pid}) is holding port ${port}.`);
|
|
176
|
+
console.error(` Run 'flair stop' first — never start over a live pid.`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
case "DISAGREEMENT":
|
|
179
|
+
console.error(`⚠️ ${state.detail}`);
|
|
180
|
+
console.error(` Refusing to start — the evidence conflicts.`);
|
|
181
|
+
console.error(` pidfile: ${join(dataDir, "hdb.pid")}`);
|
|
182
|
+
console.error(` port: ${port}`);
|
|
183
|
+
console.error(` To inspect: flair doctor`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
case "UNKNOWN":
|
|
186
|
+
console.error(`⚠️ ${state.detail}`);
|
|
187
|
+
console.error(` Refusing to start — could not determine whether Flair is running.`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
if (!existsSync(dataDir)) {
|
|
191
|
+
console.error("❌ No Flair data directory found. Run 'flair init' first.");
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
// flair#1047: refuse to boot if the store was written by a newer engine.
|
|
195
|
+
// Same guard startFlairProcess runs, so restart/upgrade/snapshot cannot
|
|
196
|
+
// reach a boot this command would refuse (flair#1093).
|
|
197
|
+
try {
|
|
198
|
+
guardEngineNotBackwards(dataDir);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (!err?.engineBackwards)
|
|
202
|
+
throw err;
|
|
203
|
+
console.error(`❌ Cannot start Flair — the data directory was written by a newer Harper engine.\n`);
|
|
204
|
+
console.error(err.message);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
const platform = process.platform;
|
|
208
|
+
if (platform === "darwin") {
|
|
209
|
+
// resolveLaunchdLabel (flair#693) finds whichever label this data
|
|
210
|
+
// dir is currently registered under (new instance-scoped, or a
|
|
211
|
+
// pre-flair#693 legacy install) so the existsSync gate below is
|
|
212
|
+
// accurate before we attempt anything.
|
|
213
|
+
const { plistPath } = resolveLaunchdLabel(dataDir);
|
|
214
|
+
if (existsSync(plistPath)) {
|
|
215
|
+
try {
|
|
216
|
+
// flair#1022, same pre-flight as startFlairProcess: launchctl exits 0
|
|
217
|
+
// for a job it cannot exec, so a stale plist is only ever observable
|
|
218
|
+
// as a startup timeout unless the paths are checked first.
|
|
219
|
+
const stalePlist = diagnoseLaunchdPlistPaths(plistPath);
|
|
220
|
+
if (stalePlist) {
|
|
221
|
+
throw new Error(`${stalePlist.message} Fix it with: ${stalePlist.remedy.join(" && ")}`);
|
|
222
|
+
}
|
|
223
|
+
const { execSync } = await import("node:child_process");
|
|
224
|
+
const { label, migrated } = ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
|
|
225
|
+
if (migrated)
|
|
226
|
+
console.log(`Migrated launchd service off the legacy label (${LEGACY_LAUNCHD_LABEL}) → ${label} ✓`);
|
|
227
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
228
|
+
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
|
|
229
|
+
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
230
|
+
console.log("✅ Flair started (launchd)");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
console.error(`launchd start failed, falling back to direct start: ${err.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Direct start (Linux, or macOS fallback when no launchd plist)
|
|
239
|
+
const harper = resolveHarperBin(harperSearchRoots());
|
|
240
|
+
if (!harper.path) {
|
|
241
|
+
console.error(`❌ ${harperBinNotFoundMessage(harper.searched)}`);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
const bin = harper.path;
|
|
245
|
+
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
|
|
246
|
+
// flair#670/#863: this fallback path (no launchd plist) sets no
|
|
247
|
+
// HARPER_SET_CONFIG, so the ops bind has to be re-asserted explicitly on
|
|
248
|
+
// every spawn — see buildDirectSpawnEnv. The escape hatch on this path is
|
|
249
|
+
// FLAIR_OPS_BIND or the `opsBind` that `flair init --ops-bind` persisted to
|
|
250
|
+
// ~/.flair/config.yaml; there is no --ops-bind flag on `start`.
|
|
251
|
+
const env = {
|
|
252
|
+
...process.env,
|
|
253
|
+
...buildDirectSpawnEnv({
|
|
254
|
+
dataDir,
|
|
255
|
+
modelsDir: process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models"),
|
|
256
|
+
httpPort: port,
|
|
257
|
+
opsPort: resolveOpsPort(opts),
|
|
258
|
+
opsBindHost: resolveOpsBindHost({}),
|
|
259
|
+
adminUser: DEFAULT_ADMIN_USER,
|
|
260
|
+
adminPass,
|
|
261
|
+
}),
|
|
262
|
+
};
|
|
263
|
+
const proc = spawn(process.execPath, [bin, "run", "."], {
|
|
264
|
+
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
265
|
+
});
|
|
266
|
+
proc.unref();
|
|
267
|
+
// Write the identity sidecar immediately after spawn (flair#1454 decision
|
|
268
|
+
// 3) — BEFORE waitForHealth, so startTimeMs stays within the ±2s tolerance
|
|
269
|
+
// of the process's real start time. `proc.pid` is the pid Harper writes to
|
|
270
|
+
// hdb.pid, since Harper runs in-process.
|
|
271
|
+
if (proc.pid)
|
|
272
|
+
writeDaemonSidecar(dataDir, proc.pid, port);
|
|
273
|
+
try {
|
|
274
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
275
|
+
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
|
|
276
|
+
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
277
|
+
console.log(`✅ Flair started on port ${port}`);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
console.error("❌ Flair failed to start within timeout. Check logs in " + join(dataDir, "harper.log"));
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
// ─── flair restart ────────────────────────────────────────────────────────────
|
|
285
|
+
program
|
|
286
|
+
.command("restart")
|
|
287
|
+
.description("Restart the Flair (Harper) instance")
|
|
288
|
+
.option("--port <port>", "Harper HTTP port")
|
|
289
|
+
.action(async (opts) => {
|
|
290
|
+
const port = resolveHttpPort(opts);
|
|
291
|
+
// Explicit, not defaulted inside restartFlair (flair#902): `flair
|
|
292
|
+
// restart` has no --data-dir, so the default install IS what it means —
|
|
293
|
+
// and saying so here is what keeps that true when someone adds one.
|
|
294
|
+
try {
|
|
295
|
+
await restartFlair(port, defaultDataDir());
|
|
296
|
+
// flair#1022: a restart that fell back off launchd left the instance
|
|
297
|
+
// running but unmanaged, and "✅ Flair restarted" was true of both
|
|
298
|
+
// outcomes. Ask launchd what it is actually running now — an
|
|
299
|
+
// observation, not a flag out of the restart, so it is right even when
|
|
300
|
+
// the detachment predates this command.
|
|
301
|
+
const managed = observeLaunchdManagement(defaultDataDir(), port);
|
|
302
|
+
if (isDetached(managed)) {
|
|
303
|
+
for (const line of renderDetachedWarning(managed, "Flair restarted, but it is NOT running under launchd.")) {
|
|
304
|
+
console.error(line);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
console.log("✅ Flair restarted");
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
console.error(`❌ Flair failed to restart: ${err?.message ?? err}`);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, rmSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
5
|
+
import { validateSnapshotArchive } from "../lib/safe-snapshot-extract.js";
|
|
6
|
+
let cli;
|
|
7
|
+
/** Bind shared CLI helpers. cli.ts calls this immediately before register(program). */
|
|
8
|
+
export function bindCli(fns) {
|
|
9
|
+
cli = fns;
|
|
10
|
+
}
|
|
11
|
+
const humanBytes = (n) => cli.humanBytes(n);
|
|
12
|
+
const relativeTime = (iso) => cli.relativeTime(iso);
|
|
13
|
+
/** Register the `flair session` command group (flair#1631). */
|
|
14
|
+
export function register(program) {
|
|
15
|
+
// ─── flair session snapshot ──────────────────────────────────────────────────
|
|
16
|
+
// Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B. Snapshot a
|
|
17
|
+
// session jsonl + label metadata into a tar.gz under ~/.flair/snapshots/<agent>/sessions/.
|
|
18
|
+
//
|
|
19
|
+
// Three subcommands: create | list | restore. Mirrors FLAIR-NIGHTLY-REM's
|
|
20
|
+
// snapshot pattern (tar.gz, 600 perms, 30-day retention enforced separately).
|
|
21
|
+
//
|
|
22
|
+
// Standalone-callable today; harness slices 3+4 will wire it into the
|
|
23
|
+
// session-reset pipeline.
|
|
24
|
+
const SNAPSHOT_ROOT = resolve(homedir(), ".flair", "snapshots");
|
|
25
|
+
function sessionSnapshotDir(agent) {
|
|
26
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(agent))
|
|
27
|
+
throw new Error(`invalid agent id: ${agent}`);
|
|
28
|
+
return resolve(SNAPSHOT_ROOT, agent, "sessions");
|
|
29
|
+
}
|
|
30
|
+
const session = program.command("session").description("Agent session lifecycle (snapshot/restore for FLAIR-AGENT-CONTEXT-TIERS-B)");
|
|
31
|
+
const sessionSnapshot = session.command("snapshot").description("Manage session snapshots (tar.gz of session jsonl + metadata)");
|
|
32
|
+
sessionSnapshot
|
|
33
|
+
.command("create")
|
|
34
|
+
.description("Create a session snapshot tar.gz")
|
|
35
|
+
.requiredOption("--agent <id>", "Agent the session belongs to")
|
|
36
|
+
.requiredOption("--session-file <path>", "Path to the session jsonl to snapshot (e.g. /tmp/openclaw/openclaw-2026-05-03.log)")
|
|
37
|
+
.option("--label <text>", "Label for the snapshot file (e.g. ops-ID); default: ISO timestamp")
|
|
38
|
+
.action(async (opts) => {
|
|
39
|
+
const sessionFile = resolve(opts.sessionFile);
|
|
40
|
+
if (!existsSync(sessionFile)) {
|
|
41
|
+
console.error(`Error: --session-file does not exist: ${sessionFile}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const dir = sessionSnapshotDir(opts.agent);
|
|
45
|
+
if (!existsSync(dir))
|
|
46
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
47
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
48
|
+
const safeLabel = opts.label ? String(opts.label).replace(/[^a-zA-Z0-9._-]/g, "_") : ts;
|
|
49
|
+
const tarballName = opts.label ? `${safeLabel}-${ts}.tar.gz` : `${ts}.tar.gz`;
|
|
50
|
+
const tarballPath = resolve(dir, tarballName);
|
|
51
|
+
// Write a metadata.json into a tmp dir alongside the session file for the
|
|
52
|
+
// tarball, so the snapshot is self-describing.
|
|
53
|
+
const meta = {
|
|
54
|
+
agent: opts.agent,
|
|
55
|
+
label: opts.label ?? null,
|
|
56
|
+
sessionFile,
|
|
57
|
+
sessionFileSize: statSync(sessionFile).size,
|
|
58
|
+
createdAt: new Date().toISOString(),
|
|
59
|
+
flairVersion: cli.pkgVersion,
|
|
60
|
+
};
|
|
61
|
+
const tmpDir = resolve(dir, `.tmp-${process.pid}-${Date.now()}`);
|
|
62
|
+
mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
|
|
63
|
+
try {
|
|
64
|
+
const sessionBaseName = sessionFile.split("/").pop() ?? "session.jsonl";
|
|
65
|
+
writeFileSync(resolve(tmpDir, sessionBaseName), readFileSync(sessionFile));
|
|
66
|
+
writeFileSync(resolve(tmpDir, "metadata.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
67
|
+
await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, [sessionBaseName, "metadata.json"]);
|
|
68
|
+
// Tarball perms: 600 (owner-only) — matches FLAIR-NIGHTLY-REM
|
|
69
|
+
chmodSync(tarballPath, 0o600);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
const size = statSync(tarballPath).size;
|
|
75
|
+
console.log(tarballPath);
|
|
76
|
+
console.error(` agent: ${opts.agent}`);
|
|
77
|
+
console.error(` label: ${opts.label ?? "(none)"}`);
|
|
78
|
+
console.error(` size: ${humanBytes(size)}`);
|
|
79
|
+
});
|
|
80
|
+
sessionSnapshot
|
|
81
|
+
.command("list")
|
|
82
|
+
.description("List session snapshots for an agent (or all agents)")
|
|
83
|
+
.option("--agent <id>", "Filter to a single agent")
|
|
84
|
+
.option("--json", "Output as JSON")
|
|
85
|
+
.action(async (opts) => {
|
|
86
|
+
if (!existsSync(SNAPSHOT_ROOT)) {
|
|
87
|
+
if (opts.json) {
|
|
88
|
+
console.log("[]");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
console.log("(no snapshots — ~/.flair/snapshots/ does not exist yet)");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const rows = [];
|
|
95
|
+
const agents = opts.agent ? [opts.agent] : readdirSync(SNAPSHOT_ROOT).filter((d) => {
|
|
96
|
+
try {
|
|
97
|
+
return statSync(resolve(SNAPSHOT_ROOT, d)).isDirectory();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
for (const a of agents) {
|
|
104
|
+
const dir = resolve(SNAPSHOT_ROOT, a, "sessions");
|
|
105
|
+
if (!existsSync(dir))
|
|
106
|
+
continue;
|
|
107
|
+
for (const f of readdirSync(dir)) {
|
|
108
|
+
if (!f.endsWith(".tar.gz"))
|
|
109
|
+
continue;
|
|
110
|
+
const p = resolve(dir, f);
|
|
111
|
+
const s = statSync(p);
|
|
112
|
+
rows.push({ agent: a, file: f, path: p, size: s.size, mtime: s.mtime.toISOString() });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
rows.sort((a, b) => b.mtime.localeCompare(a.mtime));
|
|
116
|
+
if (opts.json) {
|
|
117
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (rows.length === 0) {
|
|
121
|
+
console.log("(no snapshots)");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const agentW = Math.max(5, ...rows.map((r) => r.agent.length));
|
|
125
|
+
const fileW = Math.max(20, ...rows.map((r) => r.file.length));
|
|
126
|
+
console.log(` ${"agent".padEnd(agentW)} ${"file".padEnd(fileW)} size age`);
|
|
127
|
+
for (const r of rows) {
|
|
128
|
+
console.log(` ${r.agent.padEnd(agentW)} ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
|
|
129
|
+
}
|
|
130
|
+
console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
|
|
131
|
+
});
|
|
132
|
+
sessionSnapshot
|
|
133
|
+
.command("restore")
|
|
134
|
+
.description("Extract a session snapshot to a target directory")
|
|
135
|
+
.requiredOption("--snapshot <path>", "Path to the .tar.gz snapshot")
|
|
136
|
+
.option("--target <dir>", "Directory to extract into (default: <snapshot>.restored next to the snapshot)")
|
|
137
|
+
.option("--dry-run", "List the snapshot's contents without extracting")
|
|
138
|
+
.action(async (opts) => {
|
|
139
|
+
const snapshotPath = resolve(opts.snapshot);
|
|
140
|
+
if (!existsSync(snapshotPath)) {
|
|
141
|
+
console.error(`Error: snapshot does not exist: ${snapshotPath}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
if (opts.dryRun) {
|
|
145
|
+
console.log("(dry-run) snapshot contents:");
|
|
146
|
+
const entries = [];
|
|
147
|
+
await tarList({ file: snapshotPath, onReadEntry: (entry) => entries.push(` ${entry.path} (${humanBytes(entry.size ?? 0)})`) });
|
|
148
|
+
for (const e of entries)
|
|
149
|
+
console.log(e);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const targetDir = opts.target
|
|
153
|
+
? resolve(opts.target)
|
|
154
|
+
: `${snapshotPath}.restored`;
|
|
155
|
+
if (existsSync(targetDir)) {
|
|
156
|
+
console.error(`Error: target directory already exists: ${targetDir}`);
|
|
157
|
+
console.error(` Pass --target <new-path> or remove the existing dir.`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
// flair#903 — fail CLOSED on a tampered archive. node-tar's defaults below
|
|
161
|
+
// do contain malicious entries (leading "/" stripped, ".." entries
|
|
162
|
+
// dropped, no writing through a symlink — verified on the pinned tar
|
|
163
|
+
// against all four vectors), but they discard those entries SILENTLY: the
|
|
164
|
+
// restore printed the target dir as a plain success minus the parts it
|
|
165
|
+
// never mentioned. Validation runs BEFORE the target directory is even
|
|
166
|
+
// created — a tampered snapshot aborts the whole restore, names the
|
|
167
|
+
// offending entry, and writes nothing (same posture as the data-dir
|
|
168
|
+
// restore's extractSnapshotSafely). The default extract flags stay as
|
|
169
|
+
// containment defense-in-depth — deliberately NOT preservePaths; if that
|
|
170
|
+
// flag is ever added here, this call MUST move to extractSnapshotSafely.
|
|
171
|
+
// See src/lib/safe-snapshot-extract.ts.
|
|
172
|
+
try {
|
|
173
|
+
await validateSnapshotArchive({ file: snapshotPath, targetDir });
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
console.error(`Error: ${err.message}`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
180
|
+
await tarExtract({ file: snapshotPath, cwd: targetDir });
|
|
181
|
+
console.log(targetDir);
|
|
182
|
+
console.error(` extracted to: ${targetDir}`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as render from "../render.js";
|
|
2
|
+
let cli;
|
|
3
|
+
/** Bind shared CLI helpers. cli.ts calls this immediately before register(program). */
|
|
4
|
+
export function bindCli(fns) {
|
|
5
|
+
cli = fns;
|
|
6
|
+
}
|
|
7
|
+
function api(...args) {
|
|
8
|
+
return cli.api(...args);
|
|
9
|
+
}
|
|
10
|
+
function resolveSigningAgentId(opts, command) {
|
|
11
|
+
return cli.resolveSigningAgentId(opts, command);
|
|
12
|
+
}
|
|
13
|
+
function applyAdminPassFile(opts) {
|
|
14
|
+
cli.applyAdminPassFile(opts);
|
|
15
|
+
}
|
|
16
|
+
function addSharedCredentialOptions(cmd) {
|
|
17
|
+
return cli.addSharedCredentialOptions(cmd);
|
|
18
|
+
}
|
|
19
|
+
export function register(program) {
|
|
20
|
+
const soul = program.command("soul").description("Manage agent soul entries");
|
|
21
|
+
addSharedCredentialOptions(soul.command("set"))
|
|
22
|
+
.description("Set (upsert) a soul entry for an agent by key")
|
|
23
|
+
.requiredOption("--agent <id>")
|
|
24
|
+
.requiredOption("--key <key>")
|
|
25
|
+
.requiredOption("--value <value>")
|
|
26
|
+
.option("--durability <d>", "permanent|persistent|standard|ephemeral (default permanent — soul entries are identity, not working memory)")
|
|
27
|
+
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
28
|
+
.action(async (opts) => {
|
|
29
|
+
applyAdminPassFile(opts);
|
|
30
|
+
// PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
|
|
31
|
+
// The Soul table resource has no POST handler, so a collection POST /Soul
|
|
32
|
+
// 405s; the record must be written by its primary key. (#498)
|
|
33
|
+
//
|
|
34
|
+
// flair#1183: resolve the SIGNING identity through the canonical seam and
|
|
35
|
+
// thread it to api(). --agent is required, so the flag always wins the
|
|
36
|
+
// precedence — but before this, api() re-derived the signer as
|
|
37
|
+
// FLAIR_AGENT_ID-first, so `soul set --agent X` with FLAIR_AGENT_ID=Y set
|
|
38
|
+
// wrote a record owned by X while signing as Y (the soul family's stale rung).
|
|
39
|
+
const { agentId, source } = resolveSigningAgentId(opts, "soul set");
|
|
40
|
+
const id = `${opts.agent}:${opts.key}`;
|
|
41
|
+
const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
|
|
42
|
+
id,
|
|
43
|
+
agentId: opts.agent,
|
|
44
|
+
key: opts.key,
|
|
45
|
+
value: opts.value,
|
|
46
|
+
durability: opts.durability,
|
|
47
|
+
createdAt: new Date().toISOString(),
|
|
48
|
+
}, { agentId, agentIdSource: source, explicitAdminPass: opts.adminPass, adminUser: opts.adminUser });
|
|
49
|
+
const mode = render.resolveOutputMode(opts);
|
|
50
|
+
if (mode === "json") {
|
|
51
|
+
console.log(render.asJSON(out));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.log(`${render.icons.ok} ${render.wrap(render.c.green, "soul entry set")}`);
|
|
55
|
+
console.log(render.kv("agent", opts.agent));
|
|
56
|
+
console.log(render.kv("key", render.wrap(render.c.bold, opts.key)));
|
|
57
|
+
console.log(render.kv("value", String(opts.value)));
|
|
58
|
+
if (opts.durability)
|
|
59
|
+
console.log(render.kv("durability", render.wrap(render.c.magenta, opts.durability)));
|
|
60
|
+
});
|
|
61
|
+
soul.command("get")
|
|
62
|
+
.description("Fetch a single soul entry by id (agent:key)")
|
|
63
|
+
.argument("<id>")
|
|
64
|
+
.option("--agent <id>", "Agent ID to sign the read as (or set FLAIR_AGENT_ID); falls back to the config-profile agent")
|
|
65
|
+
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
66
|
+
.action(async (id, opts) => {
|
|
67
|
+
// flair#1183: /Soul reads are verified (any registered agent). Resolve the
|
|
68
|
+
// signer through the canonical seam so soul get honors the SAME precedence
|
|
69
|
+
// as every other family; a null result lets api() fall to admin-pass/floor.
|
|
70
|
+
const { agentId, source } = resolveSigningAgentId(opts, "soul get");
|
|
71
|
+
const out = await api("GET", `/Soul/${id}`, undefined, { agentId, agentIdSource: source });
|
|
72
|
+
const mode = render.resolveOutputMode(opts);
|
|
73
|
+
if (mode === "json") {
|
|
74
|
+
console.log(render.asJSON(out));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!out || (typeof out === "object" && !out.id)) {
|
|
78
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no entry")}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
console.log(render.wrap(render.c.bold, out.id ?? id));
|
|
82
|
+
if (out.agentId)
|
|
83
|
+
console.log(render.kv("agent", out.agentId));
|
|
84
|
+
if (out.key)
|
|
85
|
+
console.log(render.kv("key", out.key));
|
|
86
|
+
if (out.value !== undefined)
|
|
87
|
+
console.log(render.kv("value", String(out.value)));
|
|
88
|
+
if (out.durability)
|
|
89
|
+
console.log(render.kv("durability", render.wrap(render.c.magenta, String(out.durability))));
|
|
90
|
+
if (out.priority)
|
|
91
|
+
console.log(render.kv("priority", String(out.priority)));
|
|
92
|
+
if (out.createdAt)
|
|
93
|
+
console.log(render.kv("created", `${render.relativeTime(out.createdAt)} ${render.wrap(render.c.dim, `(${out.createdAt})`)}`));
|
|
94
|
+
if (out.updatedAt && out.updatedAt !== out.createdAt) {
|
|
95
|
+
console.log(render.kv("updated", `${render.relativeTime(out.updatedAt)} ${render.wrap(render.c.dim, `(${out.updatedAt})`)}`));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
soul.command("list")
|
|
99
|
+
.description("List all soul entries for an agent")
|
|
100
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
101
|
+
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
102
|
+
.action(async (opts) => {
|
|
103
|
+
const { agentId, source } = resolveSigningAgentId(opts, "soul list");
|
|
104
|
+
if (!agentId) {
|
|
105
|
+
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId, agentIdSource: source });
|
|
109
|
+
const mode = render.resolveOutputMode(opts);
|
|
110
|
+
if (mode === "json") {
|
|
111
|
+
console.log(render.asJSON(out));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const all = Array.isArray(out) ? out : (out?.results ?? out?.items ?? []);
|
|
115
|
+
if (all.length === 0) {
|
|
116
|
+
console.log(`${render.icons.info} ${render.wrap(render.c.dim, `no soul entries for agent ${agentId}`)}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.log(`${render.wrap(render.c.bold, String(all.length))} soul entries for agent ${render.wrap(render.c.bold, agentId)}\n`);
|
|
120
|
+
const priorityColor = (p) => {
|
|
121
|
+
if (p === "critical")
|
|
122
|
+
return render.c.red;
|
|
123
|
+
if (p === "high")
|
|
124
|
+
return render.c.yellow;
|
|
125
|
+
if (p === "low")
|
|
126
|
+
return render.c.gray;
|
|
127
|
+
return render.c.cyan;
|
|
128
|
+
};
|
|
129
|
+
const cols = [
|
|
130
|
+
{ label: "key", key: "key", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
|
|
131
|
+
{
|
|
132
|
+
label: "priority",
|
|
133
|
+
key: "priority",
|
|
134
|
+
format: (v) => {
|
|
135
|
+
const p = String(v ?? "standard");
|
|
136
|
+
return render.wrap(priorityColor(p), p);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
label: "durability",
|
|
141
|
+
key: "durability",
|
|
142
|
+
format: (v) => {
|
|
143
|
+
const d = String(v ?? "—");
|
|
144
|
+
return d === "permanent" ? render.wrap(render.c.magenta, d) : render.wrap(render.c.dim, d);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
label: "value",
|
|
149
|
+
key: "value",
|
|
150
|
+
format: (v) => String(v ?? "").replace(/\s+/g, " ").slice(0, 80),
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
console.log(render.table(cols, all));
|
|
154
|
+
});
|
|
155
|
+
}
|