@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 +18 -1
- package/dist/deploy.js +188 -8
- package/package.json +1 -1
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.
|
|
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",
|