@tpsdev-ai/flair 0.20.0 → 0.20.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/dist/cli.js CHANGED
@@ -6648,6 +6648,11 @@ program
6648
6648
  .option("--no-restart", "Do not restart the component after deploy (default: restart=true)")
6649
6649
  .option("--dry-run", "Resolve package, validate args, skip the deploy call")
6650
6650
  .option("--package-root <dir>", "Override package root (mainly for testing)")
6651
+ .option("--deployment-timeout <ms>", "Milliseconds harper waits for cluster-wide peer replication (env: FABRIC_DEPLOYMENT_TIMEOUT; default: 600000 — harper's own 120s default is too short for Fabric)")
6652
+ .option("--install-timeout <ms>", "Milliseconds harper waits for package install (env: FABRIC_INSTALL_TIMEOUT; default: 600000)")
6653
+ .option("--no-verify", "Skip post-deploy served-API verification (default: verify — on by design, so the CLI can't report success on an empty/broken deploy)")
6654
+ .option("--verify-timeout <ms>", "Milliseconds to wait for the served API to settle after harper's post-deploy restart before verifying (default: 300000)")
6655
+ .option("--verify-resource <name>", "Resource to verify is serving after deploy (repeatable; default: derived from the deployed package's dist/resources)", (val, prev) => [...prev, val], [])
6651
6656
  .action(async (opts) => {
6652
6657
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
6653
6658
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
@@ -6665,6 +6670,12 @@ program
6665
6670
  restart: opts.restart !== false,
6666
6671
  dryRun: opts.dryRun ?? false,
6667
6672
  packageRoot: opts.packageRoot,
6673
+ deploymentTimeoutMs: Number(opts.deploymentTimeout ?? process.env.FABRIC_DEPLOYMENT_TIMEOUT ?? 600_000),
6674
+ installTimeoutMs: Number(opts.installTimeout ?? process.env.FABRIC_INSTALL_TIMEOUT ?? 600_000),
6675
+ verify: opts.verify !== false,
6676
+ verifyResources: opts.verifyResource?.length ? opts.verifyResource : undefined,
6677
+ verifyTimeoutMs: Number(opts.verifyTimeout ?? 300_000),
6678
+ onProgress: (msg) => console.log(dim(` ${msg}`)),
6668
6679
  };
6669
6680
  const errors = validateDeployOptions(deployOpts);
6670
6681
  if (errors.length) {
@@ -6689,7 +6700,7 @@ program
6689
6700
  console.log(dim(` package root: ${result.packageRoot}`));
6690
6701
  return;
6691
6702
  }
6692
- console.log(`\n${green("✓")} Flair ${result.version} deployed`);
6703
+ console.log(`\n${green("✓")} Flair ${result.version} deployed${deployOpts.verify ? " and verified serving" : ""}`);
6693
6704
  console.log(`\n URL: ${result.url}`);
6694
6705
  console.log(` Project: ${result.project}`);
6695
6706
  console.log(`\nNext steps:`);
@@ -6703,6 +6714,12 @@ program
6703
6714
  if (hint?.includes("401") || hint?.includes("unauthoriz")) {
6704
6715
  console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
6705
6716
  }
6717
+ if (hint?.includes("component is not serving")) {
6718
+ console.error(dim(" hint: harper reported success but the served API disagrees — check the Fabric Studio component logs for the real deploy error, then retry"));
6719
+ }
6720
+ if (hint?.includes("did not settle")) {
6721
+ console.error(dim(" hint: Harper may still be restarting — check Fabric Studio, or retry with a longer --verify-timeout"));
6722
+ }
6706
6723
  process.exit(1);
6707
6724
  }
6708
6725
  });
package/dist/deploy.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { existsSync, readFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createRequire } from "node:module";
6
6
  // Files that must be present in a Flair package for deployment.
@@ -11,6 +11,23 @@ export const REQUIRED_PACKAGE_FILES = [
11
11
  "ui",
12
12
  "config.yaml",
13
13
  ];
14
+ // harper's own deploy CLI defaults to a 120s peer-replication timeout that's
15
+ // too short for Fabric — the CLI aborts mid-replicate with no override,
16
+ // which is exactly the incident this module now guards against. 10 minutes
17
+ // gives cluster-wide replication + install room to actually finish.
18
+ export const DEFAULT_DEPLOYMENT_TIMEOUT_MS = 600_000;
19
+ export const DEFAULT_INSTALL_TIMEOUT_MS = 600_000;
20
+ // Post-deploy verification: how long we'll wait for the served API to come
21
+ // back up after harper's restart, how often we poll while waiting, and how
22
+ // many consecutive reachable responses count as "settled" (a single
23
+ // reachable probe right after restart can be a fluke mid-flap).
24
+ export const DEFAULT_VERIFY_TIMEOUT_MS = 300_000;
25
+ export const VERIFY_POLL_INTERVAL_MS = 15_000;
26
+ export const VERIFY_SETTLE_STREAK = 3;
27
+ // Fallback when dist/resources can't be scanned (e.g. an unusual package
28
+ // layout via --package-root). Memory is Flair's original, always-present
29
+ // resource — a reasonable single thing to check when derivation fails.
30
+ export const FALLBACK_VERIFY_RESOURCE = "Memory";
14
31
  export function validateOptions(opts) {
15
32
  const errors = [];
16
33
  if (!opts.target) {
@@ -81,6 +98,65 @@ export function validatePackageLayout(packageRoot) {
81
98
  missing.join(", "));
82
99
  }
83
100
  }
101
+ // Derive the list of served, table-backed REST resources from the compiled
102
+ // package — no hardcoded resource list. Flair's jsResource files (dist/resources/*.js)
103
+ // contain both real Resource classes (routable, GET-able) and plain helper
104
+ // modules (embeddings, auth, scoring, etc). We only ship dist/ in the
105
+ // published package (resources/*.ts source is not in package.json's `files`),
106
+ // so this scans the COMPILED output, matching the convention every current
107
+ // table-backed resource follows:
108
+ //
109
+ // export class <Name> extends databases.<db>.<Name> { ... }
110
+ //
111
+ // i.e. the exported class name equals the filename equals the underlying
112
+ // table name (Memory.js -> `export class Memory extends databases.flair.Memory`,
113
+ // same for Agent, Soul, MemoryGrant, Credential, OrgEvent, etc). Helper
114
+ // modules are lowercase-first (agent-auth.js, embeddings-provider.js, ...)
115
+ // and never match, so they're skipped without needing a denylist. Files
116
+ // that export a resource extending a *generic* `Resource` base (AgentCard,
117
+ // WorkspaceLatest, action-style endpoints) are deliberately excluded here —
118
+ // they're action/command endpoints, not GET-able collections, and asserting
119
+ // non-404 on them would be the wrong check.
120
+ // Literal regex (no interpolation — satisfies semgrep detect-non-literal-regexp):
121
+ // matches `export class <Name> extends databases.<db>.<Name>` where the `\1`
122
+ // backreference forces the class name and the table name to be identical.
123
+ // Capture group 1 is the resource name; the caller matches it against the filename.
124
+ const EXPORTED_TABLE_CLASS_RE = /export class (\w+) extends databases\.[A-Za-z_$][\w$]*\.\1\b/g;
125
+ export function deriveVerifyResources(packageRoot) {
126
+ const resourcesDir = join(packageRoot, "dist", "resources");
127
+ let entries;
128
+ try {
129
+ entries = readdirSync(resourcesDir);
130
+ }
131
+ catch {
132
+ return [FALLBACK_VERIFY_RESOURCE];
133
+ }
134
+ const names = [];
135
+ for (const entry of entries) {
136
+ if (!entry.endsWith(".js"))
137
+ continue;
138
+ const base = entry.slice(0, -3);
139
+ if (!/^[A-Z]/.test(base))
140
+ continue; // helper modules are camelCase/lowercase
141
+ let src;
142
+ try {
143
+ src = readFileSync(join(resourcesDir, entry), "utf8");
144
+ }
145
+ catch {
146
+ continue;
147
+ }
148
+ // The file serves a table resource iff it exports a class whose name matches
149
+ // its filename (base) and extends databases.<db>.<sameName>.
150
+ for (const m of src.matchAll(EXPORTED_TABLE_CLASS_RE)) {
151
+ if (m[1] === base) {
152
+ names.push(base);
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ names.sort();
158
+ return names.length ? names : [FALLBACK_VERIFY_RESOURCE];
159
+ }
84
160
  function resolveHarperBin(packageRoot) {
85
161
  const local = join(packageRoot, "node_modules/@harperfast/harper/dist/bin/harper.js");
86
162
  if (existsSync(local))
@@ -105,6 +181,22 @@ function resolveHarperBin(packageRoot) {
105
181
  throw new Error("Could not locate Harper CLI binary (@harperfast/harper). " +
106
182
  "Flair deploy requires Harper to be installed alongside Flair.");
107
183
  }
184
+ // Pure arg-array builder — separated from spawnHarper so the timeout
185
+ // passthrough (and the rest of the arg shape) is unit-testable without
186
+ // mocking child_process / actually spawning harper.
187
+ export function buildHarperDeployArgs(opts, url, project) {
188
+ const deploymentTimeoutMs = opts.deploymentTimeoutMs ?? DEFAULT_DEPLOYMENT_TIMEOUT_MS;
189
+ const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
190
+ return [
191
+ "deploy",
192
+ `target=${url}`,
193
+ `project=${project}`,
194
+ `restart=${opts.restart !== false}`,
195
+ `replicated=${opts.replicated !== false}`,
196
+ `deployment_timeout=${deploymentTimeoutMs}`,
197
+ `install_timeout=${installTimeoutMs}`,
198
+ ];
199
+ }
108
200
  function spawnHarper(bin, args, cwd, env) {
109
201
  return new Promise((resolveP, rejectP) => {
110
202
  const p = spawn(process.execPath, [bin, ...args], {
@@ -121,6 +213,85 @@ function spawnHarper(bin, args, cwd, env) {
121
213
  });
122
214
  });
123
215
  }
216
+ function sleep(ms) {
217
+ return new Promise((r) => setTimeout(r, ms));
218
+ }
219
+ // A single reachability probe against the served (REST) base URL. Harper
220
+ // restarts the process after every deploy, so the endpoint FLAPS for a bit —
221
+ // connection refused / reset / DNS blips are all EXPECTED right after
222
+ // restart, not a failure signal by themselves. Any HTTP response at all
223
+ // (regardless of status code — a 404 on `/` is normal) proves the process
224
+ // is back up and terminating TLS/HTTP again; that's all "reachable" means
225
+ // here. Resource-level 404s are a separate, later check.
226
+ async function probeReachable(baseUrl, fetchImpl) {
227
+ try {
228
+ await fetchImpl(baseUrl, { method: "GET", signal: AbortSignal.timeout(10_000) });
229
+ return true;
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ }
235
+ async function pollUntilSettled(baseUrl, timeoutMs, pollIntervalMs, settleStreak, fetchImpl, onProgress) {
236
+ const deadline = Date.now() + timeoutMs;
237
+ let streak = 0;
238
+ let attempt = 0;
239
+ for (;;) {
240
+ attempt++;
241
+ const reachable = await probeReachable(baseUrl, fetchImpl);
242
+ streak = reachable ? streak + 1 : 0;
243
+ if (!reachable) {
244
+ onProgress?.(`waiting for ${baseUrl} to come back up after restart (attempt ${attempt})...`);
245
+ }
246
+ if (streak >= settleStreak)
247
+ return;
248
+ if (Date.now() >= deadline) {
249
+ throw new Error(`deploy verification: ${baseUrl} did not settle within ${timeoutMs}ms after restart ` +
250
+ `(Harper never came back up, or is unusually slow to restart post-deploy)`);
251
+ }
252
+ await sleep(pollIntervalMs);
253
+ }
254
+ }
255
+ async function verifyResourcesServing(baseUrl, resources, fetchImpl) {
256
+ const base = baseUrl.replace(/\/+$/, "");
257
+ const notServing = [];
258
+ for (const name of resources) {
259
+ const path = `${base}/${name}`;
260
+ let status;
261
+ try {
262
+ const res = await fetchImpl(path, { method: "GET", signal: AbortSignal.timeout(10_000) });
263
+ status = res.status;
264
+ }
265
+ catch (err) {
266
+ throw new Error(`deploy verification: request to ${path} failed even after the endpoint settled: ${err?.message ?? err}`);
267
+ }
268
+ // 404 = the resource genuinely isn't being served (this is the incident:
269
+ // harper reported "Successfully deployed" while the component was empty).
270
+ // 401 = auth-gated, which means the resource IS being served correctly.
271
+ // 200 = served + accessible. Both count as pass.
272
+ if (status === 404)
273
+ notServing.push(name);
274
+ }
275
+ if (notServing.length) {
276
+ const list = notServing.map((n) => `/${n}`).join(", ");
277
+ throw new Error(`deploy reported success but ${list} return${notServing.length === 1 ? "s" : ""} 404 — ` +
278
+ `component is not serving; likely deployed the wrong package root`);
279
+ }
280
+ }
281
+ // The tool must not be able to lie. harper's deploy CLI can print
282
+ // "Successfully deployed" for an empty component — the only way to know the
283
+ // deploy actually worked is to curl the served API and check it isn't 404.
284
+ // This polls the served base URL (443, NOT the :9925 ops API deploy talks
285
+ // to) until it settles after harper's post-deploy restart, then asserts the
286
+ // derived resource(s) respond non-404.
287
+ export async function verifyDeployServing(o) {
288
+ const { baseUrl, resources, timeoutMs = DEFAULT_VERIFY_TIMEOUT_MS, pollIntervalMs = VERIFY_POLL_INTERVAL_MS, settleStreak = VERIFY_SETTLE_STREAK, fetchImpl = fetch, onProgress, } = o;
289
+ onProgress?.(`verifying ${baseUrl} is actually serving (not just reported deployed)...`);
290
+ await pollUntilSettled(baseUrl, timeoutMs, pollIntervalMs, settleStreak, fetchImpl, onProgress);
291
+ onProgress?.(`settled — checking ${resources.map((r) => `/${r}`).join(", ")}...`);
292
+ await verifyResourcesServing(baseUrl, resources, fetchImpl);
293
+ onProgress?.(`served API verified non-404 for ${resources.length} resource(s)`);
294
+ }
124
295
  export async function deploy(opts) {
125
296
  const errors = validateOptions(opts);
126
297
  if (errors.length) {
@@ -141,13 +312,7 @@ export async function deploy(opts) {
141
312
  "Pass --fabric-user + --fabric-password instead.");
142
313
  }
143
314
  const harperBin = resolveHarperBin(packageRoot);
144
- const args = [
145
- "deploy",
146
- `target=${url}`,
147
- `project=${project}`,
148
- `restart=${opts.restart !== false}`,
149
- `replicated=${opts.replicated !== false}`,
150
- ];
315
+ const args = buildHarperDeployArgs(opts, url, project);
151
316
  // Credentials go via env, not argv, so they don't appear in `ps` output
152
317
  // for the lifetime of the Harper child process. Harper's cliOperations
153
318
  // reads CLI_TARGET_USERNAME / CLI_TARGET_PASSWORD as env fallbacks.
@@ -157,5 +322,20 @@ export async function deploy(opts) {
157
322
  CLI_TARGET_PASSWORD: opts.fabricPassword,
158
323
  };
159
324
  await spawnHarper(harperBin, args, packageRoot, childEnv);
325
+ // harper can print "Successfully deployed" for a component that isn't
326
+ // actually serving anything (the incident this closes: an empty deploy,
327
+ // reported success, /Memory 404ing in prod). Verify by curling the served
328
+ // API — on by default, escape hatch via --no-verify.
329
+ if (opts.verify !== false) {
330
+ const resources = opts.verifyResources?.length
331
+ ? opts.verifyResources
332
+ : deriveVerifyResources(packageRoot);
333
+ await verifyDeployServing({
334
+ baseUrl: url,
335
+ resources,
336
+ timeoutMs: opts.verifyTimeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS,
337
+ onProgress: opts.onProgress,
338
+ });
339
+ }
160
340
  return { url, project, version, packageRoot, dryRun: false };
161
341
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",