fraim 2.0.351 → 2.0.353
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/src/api/payment/dashboard-link.js +42 -68
- package/dist/src/api/payment/webhook.js +63 -389
- package/dist/src/api/personas/me.js +4 -7
- package/dist/src/cli/commands/sync.js +3 -2
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +86 -9
- package/dist/src/cli/setup/user-level-sync.js +51 -7
- package/dist/src/cli/utils/managed-agent-paths.js +25 -9
- package/dist/src/core/handoff-contracts.js +8 -2
- package/dist/src/core/proposal-set-contract.js +105 -21
- package/dist/src/core/utils/server-startup.js +2 -2
- package/dist/src/fraim/current-license.js +51 -0
- package/dist/src/fraim/db-service.js +253 -30
- package/dist/src/fraim/license-presentation.js +41 -0
- package/dist/src/local-mcp-server/stdio-server.js +188 -73
- package/dist/src/local-mcp-server/usage-collector.js +20 -11
- package/dist/src/middleware/auth.js +44 -51
- package/dist/src/routes/analytics.js +3 -11
- package/dist/src/routes/auth-routes.js +4 -13
- package/dist/src/services/email-service.js +46 -32
- package/dist/src/services/mcp-service.js +17 -1
- package/dist/src/services/persona-entitlement-service.js +89 -31
- package/dist/src/services/subscription-payment.js +171 -0
- package/dist/src/services/subscription-provisioning.js +68 -0
- package/dist/src/services/subscription-reconciliation.js +147 -0
- package/package.json +1 -1
|
@@ -64,7 +64,7 @@ const launcherSource = `#!/usr/bin/env node
|
|
|
64
64
|
const fs = require('fs');
|
|
65
65
|
const os = require('os');
|
|
66
66
|
const path = require('path');
|
|
67
|
-
const { spawnSync } = require('child_process');
|
|
67
|
+
const { spawnSync, spawn } = require('child_process');
|
|
68
68
|
|
|
69
69
|
const LAUNCHER_VERSION = ${LAUNCHER_VERSION};
|
|
70
70
|
const STALENESS_WINDOW_MS = ${STALENESS_WINDOW_MS};
|
|
@@ -75,11 +75,15 @@ const USER_DIR = process.env.FRAIM_USER_DIR || path.join(os.homedir(), '.fraim')
|
|
|
75
75
|
const STATE_DIR = path.join(USER_DIR, 'mcp-launcher');
|
|
76
76
|
const STATE_PATH = path.join(STATE_DIR, 'latest.json');
|
|
77
77
|
const CACHE_ROOT = path.join(USER_DIR, 'npm-cache', 'mcp');
|
|
78
|
+
const REFRESH_LOCK = path.join(STATE_DIR, 'refresh.lock');
|
|
79
|
+
const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
80
|
+
const launchStartedAt = Date.now();
|
|
78
81
|
|
|
79
82
|
const commandName = (name) => process.platform === 'win32' ? name + '.cmd' : name;
|
|
80
83
|
const npmCommand = commandName('npm');
|
|
81
84
|
const npxCommand = commandName('npx');
|
|
82
85
|
const rawArgs = process.argv.slice(2);
|
|
86
|
+
const refreshMode = rawArgs[0] === '--refresh-cache';
|
|
83
87
|
const cliMode = rawArgs[0] === '--fraim-cli';
|
|
84
88
|
const cliArgs = cliMode ? rawArgs.slice(1) : (rawArgs.length > 0 ? rawArgs : ['mcp']);
|
|
85
89
|
|
|
@@ -172,6 +176,15 @@ function resolvePackage() {
|
|
|
172
176
|
return { packageName: 'fraim', binName: 'fraim', version: cached.version, fromCache: true };
|
|
173
177
|
}
|
|
174
178
|
|
|
179
|
+
if (isUsableState(cached)) {
|
|
180
|
+
const cachedDir = path.join(CACHE_ROOT, 'fraim-' + cached.version);
|
|
181
|
+
if (installedEntry(cached, cachedDir)) {
|
|
182
|
+
// The installed package is immediately usable. Refresh in a separate
|
|
183
|
+
// process so an npm outage cannot postpone this MCP connection.
|
|
184
|
+
return { packageName: 'fraim', binName: 'fraim', version: cached.version, fromCache: true, refresh: true };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
175
188
|
const fraimVersion = resolveLatest('fraim');
|
|
176
189
|
if (fraimVersion) {
|
|
177
190
|
const state = { packageName: 'fraim', binName: 'fraim', version: fraimVersion, fromCache: false };
|
|
@@ -219,19 +232,71 @@ function pruneCaches(keepDir, nowMs) {
|
|
|
219
232
|
} catch (error) { /* nothing to prune */ }
|
|
220
233
|
}
|
|
221
234
|
|
|
222
|
-
function runPackage(plan, cacheDir) {
|
|
235
|
+
function runPackage(plan, cacheDir, args = cliArgs) {
|
|
223
236
|
ensureDir(cacheDir);
|
|
224
|
-
const env = { ...process.env, npm_config_cache: cacheDir };
|
|
225
|
-
const result = runCommand(npxCommand, ['--yes', '--package', plan.packageName + '@' + plan.version, plan.binName, ...
|
|
237
|
+
const env = { ...process.env, npm_config_cache: cacheDir, FRAIM_MCP_LAUNCH_STARTED_AT: String(launchStartedAt) };
|
|
238
|
+
const result = runCommand(npxCommand, ['--yes', '--package', plan.packageName + '@' + plan.version, plan.binName, ...args], {
|
|
226
239
|
stdio: 'inherit',
|
|
227
240
|
env
|
|
228
241
|
});
|
|
229
242
|
return typeof result.status === 'number' ? result.status : 1;
|
|
230
243
|
}
|
|
231
244
|
|
|
245
|
+
function installedEntry(plan, cacheDir) {
|
|
246
|
+
try {
|
|
247
|
+
const npxDir = path.join(cacheDir, '_npx');
|
|
248
|
+
for (const hash of fs.readdirSync(npxDir)) {
|
|
249
|
+
try {
|
|
250
|
+
const packageDir = path.join(npxDir, hash, 'node_modules', plan.packageName);
|
|
251
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
|
|
252
|
+
const entry = path.join(packageDir, 'bin', 'fraim.js');
|
|
253
|
+
if (manifest.name === plan.packageName && manifest.version === plan.version && fs.statSync(entry).isFile()) {
|
|
254
|
+
return entry;
|
|
255
|
+
}
|
|
256
|
+
} catch (error) { /* try another installed hash */ }
|
|
257
|
+
}
|
|
258
|
+
} catch (error) { /* no complete installed package */ }
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function startBackgroundRefresh() {
|
|
263
|
+
try {
|
|
264
|
+
ensureDir(STATE_DIR);
|
|
265
|
+
try {
|
|
266
|
+
const age = Date.now() - fs.statSync(REFRESH_LOCK).mtimeMs;
|
|
267
|
+
if (age < REFRESH_LOCK_STALE_MS) return;
|
|
268
|
+
fs.rmSync(REFRESH_LOCK, { force: true });
|
|
269
|
+
} catch (error) { /* no lock */ }
|
|
270
|
+
const lock = fs.openSync(REFRESH_LOCK, 'wx');
|
|
271
|
+
fs.closeSync(lock);
|
|
272
|
+
const child = spawn(process.execPath, [__filename, '--refresh-cache'], {
|
|
273
|
+
detached: true, stdio: 'ignore', windowsHide: true, env: process.env
|
|
274
|
+
});
|
|
275
|
+
child.on('error', () => { try { fs.rmSync(REFRESH_LOCK, { force: true }); } catch (error) { /* best effort */ } });
|
|
276
|
+
child.unref();
|
|
277
|
+
} catch (error) { /* another launcher owns refresh */ }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (refreshMode) {
|
|
281
|
+
try {
|
|
282
|
+
const latest = resolveLatest('fraim');
|
|
283
|
+
if (latest) {
|
|
284
|
+
const next = { packageName: 'fraim', binName: 'fraim', version: latest, fromCache: false };
|
|
285
|
+
const nextCache = path.join(CACHE_ROOT, 'fraim-' + latest);
|
|
286
|
+
if (installedEntry(next, nextCache) || (runPackage(next, nextCache, ['--version']) === 0 && installedEntry(next, nextCache))) {
|
|
287
|
+
writeState(next);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} finally {
|
|
291
|
+
try { fs.rmSync(REFRESH_LOCK, { force: true }); } catch (error) { /* best effort */ }
|
|
292
|
+
}
|
|
293
|
+
process.exit(0);
|
|
294
|
+
}
|
|
295
|
+
|
|
232
296
|
const plan = resolvePackage();
|
|
233
297
|
const versionCacheDir = path.join(CACHE_ROOT, plan.packageName + '-' + plan.version.replace(/[^a-zA-Z0-9._-]/g, '_'));
|
|
234
298
|
pruneCaches(versionCacheDir, Date.now());
|
|
299
|
+
if (plan.refresh) startBackgroundRefresh();
|
|
235
300
|
|
|
236
301
|
/**
|
|
237
302
|
* True when npx materialized the package into this cache. A non-zero exit from a package
|
|
@@ -248,10 +313,19 @@ function packageMaterialized(cacheDir) {
|
|
|
248
313
|
}
|
|
249
314
|
}
|
|
250
315
|
|
|
251
|
-
|
|
316
|
+
const directEntry = installedEntry(plan, versionCacheDir);
|
|
317
|
+
let status;
|
|
318
|
+
if (directEntry) {
|
|
319
|
+
const result = spawnSync(process.execPath, [directEntry, ...cliArgs], {
|
|
320
|
+
stdio: 'inherit', env: { ...process.env, FRAIM_MCP_LAUNCH_STARTED_AT: String(launchStartedAt) }, windowsHide: true
|
|
321
|
+
});
|
|
322
|
+
status = typeof result.status === 'number' ? result.status : 1;
|
|
323
|
+
} else {
|
|
324
|
+
status = runPackage(plan, versionCacheDir);
|
|
325
|
+
}
|
|
252
326
|
|
|
253
327
|
// Retry ONLY when npx could not materialize the package: the corrupt-cache case this exists for.
|
|
254
|
-
if (status !== 0 && process.platform === 'win32' && !packageMaterialized(versionCacheDir)) {
|
|
328
|
+
if (status !== 0 && !directEntry && process.platform === 'win32' && !packageMaterialized(versionCacheDir)) {
|
|
255
329
|
const retryCache = path.join(CACHE_ROOT, 'retry-' + Date.now() + '-' + process.pid);
|
|
256
330
|
console.error('[fraim-mcp-launcher] Package execution failed; retrying once with a fresh npm cache.');
|
|
257
331
|
status = runPackage(plan, retryCache);
|
|
@@ -287,8 +361,9 @@ function getFraimMcpLauncherStatePath() {
|
|
|
287
361
|
* MCP server spawn then uses immediately instead of waiting out the window.
|
|
288
362
|
*
|
|
289
363
|
* The semantics are "the version you just ran is the version recorded", which is why the caller
|
|
290
|
-
* passes its own version rather than this function consulting the registry.
|
|
291
|
-
*
|
|
364
|
+
* passes its own version rather than this function consulting the registry. Automatic catalog
|
|
365
|
+
* sync is not a version selection: its running package may be older than the background updater's
|
|
366
|
+
* selection. It must leave this record alone regardless of which operation finishes first.
|
|
292
367
|
*
|
|
293
368
|
* The record's shape is duplicated from the generated launcher's `writeState`, deliberately and
|
|
294
369
|
* unavoidably: that script executes standalone from `~/.fraim/bin` with only Node built-ins and
|
|
@@ -296,7 +371,9 @@ function getFraimMcpLauncherStatePath() {
|
|
|
296
371
|
* `test-1354-mcp-launcher-freshness-window.ts` asserts that a record written here is accepted by
|
|
297
372
|
* the launcher rather than merely that the fields look alike.
|
|
298
373
|
*/
|
|
299
|
-
function recordFraimMcpLauncherVersion(version) {
|
|
374
|
+
function recordFraimMcpLauncherVersion(version, source = 'explicit-sync') {
|
|
375
|
+
if (source === 'automatic-sync')
|
|
376
|
+
return false;
|
|
300
377
|
if (!/^\d+\.\d+\.\d+/.test(version))
|
|
301
378
|
return false;
|
|
302
379
|
const statePath = getFraimMcpLauncherStatePath();
|
|
@@ -60,6 +60,52 @@ const path_1 = __importDefault(require("path"));
|
|
|
60
60
|
const child_process_1 = require("child_process");
|
|
61
61
|
const chalk_1 = __importDefault(require("chalk"));
|
|
62
62
|
const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
63
|
+
/** Keep optional installation responsive and owned by the process that started it. */
|
|
64
|
+
function installRuntimeDependencies(baseDir, env) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const child = (0, child_process_1.spawn)('npm install --no-audit --no-fund --no-save --no-package-lock --omit=dev', {
|
|
67
|
+
cwd: baseDir,
|
|
68
|
+
env,
|
|
69
|
+
shell: true,
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
// A private POSIX process group lets exit cleanup terminate npm and its
|
|
72
|
+
// descendants without asynchronous process enumeration after parent exit.
|
|
73
|
+
detached: process.platform !== 'win32',
|
|
74
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
75
|
+
});
|
|
76
|
+
let stderr = '';
|
|
77
|
+
child.stderr?.on('data', data => { stderr = (stderr + data.toString()).slice(-65536); });
|
|
78
|
+
const terminateInstaller = () => {
|
|
79
|
+
if (!child.pid || child.exitCode !== null || child.signalCode !== null)
|
|
80
|
+
return;
|
|
81
|
+
try {
|
|
82
|
+
if (process.platform === 'win32') {
|
|
83
|
+
(0, child_process_1.execFileSync)('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
|
84
|
+
windowsHide: true, stdio: 'ignore', timeout: 5000,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch { /* the owned child may have finished while shutdown began */ }
|
|
92
|
+
};
|
|
93
|
+
// STDIO closes with process.exit(), which does not wait for child handles.
|
|
94
|
+
// Terminate only this live install; ordinary CLI callers await completion.
|
|
95
|
+
process.once('exit', terminateInstaller);
|
|
96
|
+
child.once('error', error => {
|
|
97
|
+
process.removeListener('exit', terminateInstaller);
|
|
98
|
+
reject(error);
|
|
99
|
+
});
|
|
100
|
+
child.once('close', code => {
|
|
101
|
+
process.removeListener('exit', terminateInstaller);
|
|
102
|
+
if (code === 0)
|
|
103
|
+
resolve();
|
|
104
|
+
else
|
|
105
|
+
reject(Object.assign(new Error(`npm install exited with code ${code}`), { stderr }));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
63
109
|
/**
|
|
64
110
|
* Curated list of npm runtime dependencies that user-level scripts in
|
|
65
111
|
* ~/.fraim/scripts/ require at runtime. Keep this list narrow — only add deps
|
|
@@ -101,7 +147,7 @@ function ensureUserLevelDirectories(userFraimDir) {
|
|
|
101
147
|
* errors — setup must not break because a downstream script's dep couldn't
|
|
102
148
|
* be installed.
|
|
103
149
|
*/
|
|
104
|
-
function ensureUserLevelDependencies(userFraimDir) {
|
|
150
|
+
async function ensureUserLevelDependencies(userFraimDir) {
|
|
105
151
|
const baseDir = userFraimDir || (0, script_sync_utils_1.getUserFraimDir)();
|
|
106
152
|
const pkgPath = path_1.default.join(baseDir, 'package.json');
|
|
107
153
|
const nodeModulesDir = path_1.default.join(baseDir, 'node_modules');
|
|
@@ -133,11 +179,9 @@ function ensureUserLevelDependencies(userFraimDir) {
|
|
|
133
179
|
}
|
|
134
180
|
console.log(chalk_1.default.blue(`📦 Installing user-level runtime dependencies (${missing.join(', ')})...`));
|
|
135
181
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
env: childEnv,
|
|
140
|
-
});
|
|
182
|
+
// Catalog preparation also runs inside the MCP process. Await installation
|
|
183
|
+
// without blocking its event loop so local discovery can continue serving.
|
|
184
|
+
await installRuntimeDependencies(baseDir, childEnv);
|
|
141
185
|
console.log(chalk_1.default.green(`✅ Installed: ${missing.join(', ')}`));
|
|
142
186
|
}
|
|
143
187
|
catch (error) {
|
|
@@ -158,7 +202,7 @@ async function syncUserLevelArtifacts(userFraimDir) {
|
|
|
158
202
|
console.log(chalk_1.default.blue('📦 Setting up user-level FRAIM directory...'));
|
|
159
203
|
console.log(chalk_1.default.gray(` Target: ${baseDir}`));
|
|
160
204
|
ensureUserLevelDirectories(baseDir);
|
|
161
|
-
ensureUserLevelDependencies(baseDir);
|
|
205
|
+
await ensureUserLevelDependencies(baseDir);
|
|
162
206
|
// Issue #1201 (R4.2): repair global per-IDE FRAIM discovery artifacts
|
|
163
207
|
// (e.g. ~/.codex/skills/fraim/SKILL.md) on every `npx fraim sync --global`
|
|
164
208
|
// run, not only on `fraim add-ide` and first-run onboarding — closes the
|
|
@@ -227,6 +227,7 @@ function buildPathWithManagedAgentBins(basePath) {
|
|
|
227
227
|
// launches already inherit the registry-composed PATH, so this returns null immediately on
|
|
228
228
|
// win32 rather than attempting a login-shell equivalent that doesn't exist.
|
|
229
229
|
const LOGIN_SHELL_PATH_TIMEOUT_MS = 3000;
|
|
230
|
+
const LOGIN_SHELL_PATH_CACHE_TTL_MS = 60_000;
|
|
230
231
|
function defaultResolveLoginShellPath() {
|
|
231
232
|
if (process.platform === 'win32')
|
|
232
233
|
return null;
|
|
@@ -279,18 +280,20 @@ let loginShellPathResolverOverride = null;
|
|
|
279
280
|
*/
|
|
280
281
|
function __setLoginShellPathResolverForTests(resolver) {
|
|
281
282
|
loginShellPathResolverOverride = resolver;
|
|
282
|
-
|
|
283
|
-
cachedNpmGlobalBinDirsForRecovery.clear();
|
|
283
|
+
__clearLoginShellPathCacheForTests();
|
|
284
284
|
}
|
|
285
285
|
let cachedLoginShellPath;
|
|
286
|
+
let cachedLoginShellPathAtMs = 0;
|
|
286
287
|
/**
|
|
287
|
-
* Resolves
|
|
288
|
-
*
|
|
289
|
-
* recomputing per call would multiply one shell spawn by the number of agents probed.
|
|
288
|
+
* Resolves the user's login PATH at most once per minute, including failed reads. A
|
|
289
|
+
* running Hub can discover later installations without spawning a shell per agent probe.
|
|
290
290
|
*/
|
|
291
291
|
function resolveLoginShellPathCached() {
|
|
292
|
-
|
|
292
|
+
const nowMs = Date.now();
|
|
293
|
+
const ageMs = nowMs - cachedLoginShellPathAtMs;
|
|
294
|
+
if (cachedLoginShellPath !== undefined && ageMs >= 0 && ageMs < LOGIN_SHELL_PATH_CACHE_TTL_MS) {
|
|
293
295
|
return cachedLoginShellPath;
|
|
296
|
+
}
|
|
294
297
|
const resolver = loginShellPathResolverOverride ?? defaultResolveLoginShellPath;
|
|
295
298
|
// AC-A5: a resolver failing must degrade to "no recovery", never propagate. The default
|
|
296
299
|
// resolver already guards its own spawnSync internally; this also protects against an
|
|
@@ -301,13 +304,20 @@ function resolveLoginShellPathCached() {
|
|
|
301
304
|
catch {
|
|
302
305
|
cachedLoginShellPath = null;
|
|
303
306
|
}
|
|
307
|
+
cachedLoginShellPathAtMs = nowMs;
|
|
308
|
+
// npm's configured global prefix can change even when the recovered PATH does not.
|
|
309
|
+
// Refresh both tiers together so an old prefix cannot outlive login-PATH recovery.
|
|
310
|
+
cachedNpmGlobalBinDirsForRecovery.clear();
|
|
304
311
|
return cachedLoginShellPath;
|
|
305
312
|
}
|
|
306
313
|
/** Test seam only. Clears the memoized login-shell PATH so the next call recomputes. */
|
|
307
314
|
function __clearLoginShellPathCacheForTests() {
|
|
308
315
|
cachedLoginShellPath = undefined;
|
|
316
|
+
cachedLoginShellPathAtMs = 0;
|
|
317
|
+
cachedNpmGlobalBinDirsForRecovery.clear();
|
|
309
318
|
}
|
|
310
319
|
const cachedNpmGlobalBinDirsForRecovery = new Map();
|
|
320
|
+
const NPM_GLOBAL_RECOVERY_CACHE_MAX_ENTRIES = 64;
|
|
311
321
|
/**
|
|
312
322
|
* Memoized wrapper around resolveNpmGlobalBinDirs(), scoped to buildRecoveredAgentPath()'s
|
|
313
323
|
* own use only — NOT a change to resolveNpmGlobalBinDirs() itself, which fraim doctor and the
|
|
@@ -321,8 +331,8 @@ const cachedNpmGlobalBinDirsForRecovery = new Map();
|
|
|
321
331
|
* and because each call sits inside a Promise executor that runs synchronously up to its own
|
|
322
332
|
* spawnSync, the "concurrent" probes actually serialized behind it — up to 5 agents × 5s of
|
|
323
333
|
* blocking spawnSync is exactly the event-loop-freezing regression issue #1010 eliminated,
|
|
324
|
-
* reintroduced one layer deeper. Memoizing
|
|
325
|
-
*
|
|
334
|
+
* reintroduced one layer deeper. Memoizing between login-PATH refreshes keeps repeated
|
|
335
|
+
* probes in the same round from duplicating that work.
|
|
326
336
|
*
|
|
327
337
|
* Keyed by `basePath` (not a single scalar): `resolveManagedCommand()` also routes through this
|
|
328
338
|
* cache now, and unlike `detectEmployeesAsync()`'s repeated same-ambient-PATH probing, its
|
|
@@ -330,7 +340,8 @@ const cachedNpmGlobalBinDirsForRecovery = new Map();
|
|
|
330
340
|
* isolated tests that swap `FRAIM_USER_DIR`/`PATH` per test case). A single scalar cache would
|
|
331
341
|
* let the FIRST basePath's answer silently leak into every later, differently-scoped lookup —
|
|
332
342
|
* concretely, a real machine's actual npm-global `codex` shadowing a test's own isolated managed
|
|
333
|
-
* shim. Per-basePath keys
|
|
343
|
+
* shim. Per-basePath keys avoid that cross-contamination; a size limit bounds memory when
|
|
344
|
+
* callers supply many distinct PATHs before the next refresh.
|
|
334
345
|
*/
|
|
335
346
|
function resolveNpmGlobalBinDirsCached(basePath) {
|
|
336
347
|
const cached = cachedNpmGlobalBinDirsForRecovery.get(basePath);
|
|
@@ -343,6 +354,11 @@ function resolveNpmGlobalBinDirsCached(basePath) {
|
|
|
343
354
|
catch {
|
|
344
355
|
dirs = [];
|
|
345
356
|
}
|
|
357
|
+
if (cachedNpmGlobalBinDirsForRecovery.size >= NPM_GLOBAL_RECOVERY_CACHE_MAX_ENTRIES) {
|
|
358
|
+
const oldestKey = cachedNpmGlobalBinDirsForRecovery.keys().next().value;
|
|
359
|
+
if (oldestKey !== undefined)
|
|
360
|
+
cachedNpmGlobalBinDirsForRecovery.delete(oldestKey);
|
|
361
|
+
}
|
|
346
362
|
cachedNpmGlobalBinDirsForRecovery.set(basePath, dirs);
|
|
347
363
|
return dirs;
|
|
348
364
|
}
|
|
@@ -680,10 +680,13 @@ const FIELD_SCHEMAS = {
|
|
|
680
680
|
' "runScope": "manager" | "project" | "org",',
|
|
681
681
|
' "proposals": [{',
|
|
682
682
|
' "proposalId": "<id the journal records against>",',
|
|
683
|
-
' "claimKey": "<claim identity>", "
|
|
683
|
+
' "claimKey": "<claim identity>", "revision": <int>,',
|
|
684
|
+
' "type": "Job" | "Phase" | "Skill" | "Context" | "Rule" | "Learning" | "Template" | "Script" | "Config",',
|
|
684
685
|
' "changeType": "new" | "update" | "cold",',
|
|
685
686
|
' "title": "<reader title>", "takeaway": "<reader takeaway>", "example": "<optional>",',
|
|
686
|
-
' "suggestedLevel": "manager" | "project" | "org",
|
|
687
|
+
' "suggestedLevel": "manager" | "project" | "org",',
|
|
688
|
+
' // A Learning row adds its family and occurrence history:',
|
|
689
|
+
' "family": "<learning family>", "recurrences": <int>,',
|
|
687
690
|
' "recurrencesAtLastReview": <int|null>, "domain": "<domain|null>",',
|
|
688
691
|
' "reopening": { "rejectedAt": "<date>", "reason": "<manager words>", "countAtRejection": <int> }',
|
|
689
692
|
' }],',
|
|
@@ -691,6 +694,9 @@ const FIELD_SCHEMAS = {
|
|
|
691
694
|
' "kind": "refused" | "superseded", "reason": "...", "newSourceCount": <int> }]',
|
|
692
695
|
'}',
|
|
693
696
|
'Trace, sourceRefs and scores are audit metadata and must not be sent.',
|
|
697
|
+
'Types come from registry/rules/fraim/artifact-boundaries.md. A non-Learning row',
|
|
698
|
+
'omits family, recurrences, recurrencesAtLastReview, domain and reopening: a',
|
|
699
|
+
'capability change has no occurrence history and no learning family.',
|
|
694
700
|
].join(String.fromCharCode(10)),
|
|
695
701
|
approved: ADDRESS_FEEDBACK_APPROVAL_SCHEMA,
|
|
696
702
|
reviewHandoff: REVIEW_HANDOFF_SCHEMA,
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* from a payload that reached the Hub by some other path.
|
|
33
33
|
*/
|
|
34
34
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
-
exports.MAX_SUPPRESSED_PER_SET = exports.MAX_PROPOSALS_PER_SET = exports.PROPOSAL_CHANGE_TYPES = exports.SUPPRESSION_KINDS = exports.PROPOSAL_RUN_SCOPES = void 0;
|
|
35
|
+
exports.MAX_SUPPRESSED_PER_SET = exports.MAX_PROPOSALS_PER_SET = exports.LEARNING_ARTIFACT_TYPE = exports.PROPOSAL_ARTIFACT_TYPES = exports.PROPOSAL_CHANGE_TYPES = exports.SUPPRESSION_KINDS = exports.PROPOSAL_RUN_SCOPES = void 0;
|
|
36
36
|
exports.rankProposals = rankProposals;
|
|
37
37
|
exports.validateProposalSet = validateProposalSet;
|
|
38
38
|
/** Scopes a run can be asked for. Decides which corpus an approval lands in. */
|
|
@@ -42,6 +42,35 @@ exports.PROPOSAL_RUN_SCOPES = ['manager', 'project', 'org'];
|
|
|
42
42
|
exports.SUPPRESSION_KINDS = ['refused', 'superseded'];
|
|
43
43
|
/** What kind of change a proposal makes to the corpus. */
|
|
44
44
|
exports.PROPOSAL_CHANGE_TYPES = ['new', 'update', 'cold'];
|
|
45
|
+
/**
|
|
46
|
+
* What kind of artifact a row proposes changing (Issue #2079).
|
|
47
|
+
*
|
|
48
|
+
* A learning run proposes more than one kind of thing. A lesson is a claim that
|
|
49
|
+
* has recurred, and the manager decides whether it is worth remembering. A
|
|
50
|
+
* capability change is a permanent change to a job, skill or rule, and the
|
|
51
|
+
* manager decides whether to make it. The run journal has always known the
|
|
52
|
+
* difference: every action it records is typed `kind: 'learning' | 'capability'`.
|
|
53
|
+
* This wire shape is where that fact used to be lost, so a job edit arrived with
|
|
54
|
+
* an occurrence count of 1 beside a lesson at 14 and a heading reading
|
|
55
|
+
* "New lesson".
|
|
56
|
+
*
|
|
57
|
+
* The vocabulary is not invented here. `registry/rules/fraim/artifact-boundaries.md`
|
|
58
|
+
* already defines these nine, each with the one-line responsibility that tells a
|
|
59
|
+
* reader what the type means, and instructs that the definition is kept there
|
|
60
|
+
* rather than copied into each job. `classify-instruction-placement` returns a
|
|
61
|
+
* family from exactly this set. Learning is one of the nine, so every row in a
|
|
62
|
+
* batch names its type from one list instead of lessons being described by
|
|
63
|
+
* `family` while everything else was described by a parallel vocabulary.
|
|
64
|
+
*
|
|
65
|
+
* This list carries the names only. The responsibility wording stays in the rule
|
|
66
|
+
* file, and `scripts/validate-artifact-type-contract.ts` fails the build if the
|
|
67
|
+
* two ever disagree.
|
|
68
|
+
*/
|
|
69
|
+
exports.PROPOSAL_ARTIFACT_TYPES = [
|
|
70
|
+
'Job', 'Phase', 'Skill', 'Context', 'Rule', 'Learning', 'Template', 'Script', 'Config',
|
|
71
|
+
];
|
|
72
|
+
/** The one type whose row carries recurrence evidence. */
|
|
73
|
+
exports.LEARNING_ARTIFACT_TYPE = 'Learning';
|
|
45
74
|
/**
|
|
46
75
|
* Maximum proposals in one set. Derived from the measured envelope above, not
|
|
47
76
|
* chosen: at roughly 700 bytes per proposal this keeps a full set inside twice
|
|
@@ -63,13 +92,28 @@ const isText = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
|
63
92
|
* boundary is cheaper than auditing every render site later.
|
|
64
93
|
*/
|
|
65
94
|
const FORBIDDEN_PROPOSAL_FIELDS = ['trace', 'sourceRefs', 'sourceHashes', 'content', 'score'];
|
|
95
|
+
/**
|
|
96
|
+
* Fields only a Learning row carries (Issue #2079).
|
|
97
|
+
*
|
|
98
|
+
* `recurrences` and `recurrencesAtLastReview` are the accumulated evidence
|
|
99
|
+
* behind a claim. `domain` is its subject matter, which
|
|
100
|
+
* `registry/rules/fraim/artifact-boundaries.md` keeps separate from reach.
|
|
101
|
+
* `family` is which family file it lands in, and it is the slot a capability
|
|
102
|
+
* change was being forced into because there was nowhere else to say what the
|
|
103
|
+
* row was. None of the four means anything for a capability change, and the
|
|
104
|
+
* count in particular invited the strength comparison that made a permanent job
|
|
105
|
+
* edit read as the weakest row in the batch. `reopening` goes with them: it
|
|
106
|
+
* quotes an earlier rejection and renders "it was at N then and is at M now",
|
|
107
|
+
* and M is the count this row does not have.
|
|
108
|
+
*/
|
|
109
|
+
const LEARNING_ONLY_FIELDS = ['family', 'recurrences', 'recurrencesAtLastReview', 'domain', 'reopening'];
|
|
66
110
|
function validateEntry(entry, index, errors) {
|
|
67
111
|
const at = `evidence.proposalSet.proposals[${index}]`;
|
|
68
112
|
if (!isObject(entry)) {
|
|
69
113
|
errors.push(`${at} must be an object`);
|
|
70
114
|
return;
|
|
71
115
|
}
|
|
72
|
-
for (const field of ['proposalId', 'claimKey'
|
|
116
|
+
for (const field of ['proposalId', 'claimKey']) {
|
|
73
117
|
if (!isText(entry[field]))
|
|
74
118
|
errors.push(`${at}.${field} must be a non-empty string`);
|
|
75
119
|
}
|
|
@@ -101,32 +145,57 @@ function validateEntry(entry, index, errors) {
|
|
|
101
145
|
if (!exports.PROPOSAL_CHANGE_TYPES.includes(entry.changeType)) {
|
|
102
146
|
errors.push(`${at}.changeType must be one of ${exports.PROPOSAL_CHANGE_TYPES.join(', ')}`);
|
|
103
147
|
}
|
|
104
|
-
if (!
|
|
105
|
-
errors.push(`${at}.
|
|
148
|
+
if (!exports.PROPOSAL_ARTIFACT_TYPES.includes(entry.type)) {
|
|
149
|
+
errors.push(`${at}.type must be one of ${exports.PROPOSAL_ARTIFACT_TYPES.join(', ')}, as defined in ` +
|
|
150
|
+
'registry/rules/fraim/artifact-boundaries.md; the row names what kind of artifact changes');
|
|
106
151
|
}
|
|
107
152
|
for (const field of FORBIDDEN_PROPOSAL_FIELDS) {
|
|
108
153
|
if (entry[field] !== undefined) {
|
|
109
154
|
errors.push(`${at}.${field} is audit metadata and must not be sent to the review surface`);
|
|
110
155
|
}
|
|
111
156
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
157
|
+
// Issue #2079. `type` decides which row this is, so each kind's fields are
|
|
158
|
+
// checked on its own terms instead of a capability change having to satisfy
|
|
159
|
+
// a Learning's.
|
|
160
|
+
if (entry.type === exports.LEARNING_ARTIFACT_TYPE)
|
|
161
|
+
validateLearningEntry(entry, at, errors);
|
|
162
|
+
else if (exports.PROPOSAL_ARTIFACT_TYPES.includes(entry.type))
|
|
163
|
+
validateCapabilityEntry(entry, at, errors);
|
|
164
|
+
}
|
|
165
|
+
/** A capability change: none of the evidence a claim accumulates. */
|
|
166
|
+
function validateCapabilityEntry(entry, at, errors) {
|
|
167
|
+
for (const field of LEARNING_ONLY_FIELDS) {
|
|
168
|
+
if (entry[field] !== undefined) {
|
|
169
|
+
errors.push(`${at}.${field} belongs to a Learning row; a ${String(entry.type)} change has no ` +
|
|
170
|
+
'occurrence history, learning family or subject-matter domain to weigh');
|
|
127
171
|
}
|
|
128
172
|
}
|
|
129
173
|
}
|
|
174
|
+
/** A claim: its family, occurrence history, and the rejection it may return from. */
|
|
175
|
+
function validateLearningEntry(entry, at, errors) {
|
|
176
|
+
if (!isText(entry.family)) {
|
|
177
|
+
errors.push(`${at}.family must be a non-empty string on a Learning row; it names the family file the entry lands in`);
|
|
178
|
+
}
|
|
179
|
+
if (!Number.isInteger(entry.recurrences) || entry.recurrences < 0) {
|
|
180
|
+
errors.push(`${at}.recurrences must be a non-negative integer`);
|
|
181
|
+
}
|
|
182
|
+
if (entry.reopening === undefined)
|
|
183
|
+
return;
|
|
184
|
+
const r = entry.reopening;
|
|
185
|
+
const ra = `${at}.reopening`;
|
|
186
|
+
if (!isObject(r)) {
|
|
187
|
+
errors.push(`${ra} must be an object`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!isText(r.rejectedAt))
|
|
191
|
+
errors.push(`${ra}.rejectedAt must be a non-empty string`);
|
|
192
|
+
if (!isText(r.reason)) {
|
|
193
|
+
errors.push(`${ra}.reason must be a non-empty string; a reopening quotes the original decision`);
|
|
194
|
+
}
|
|
195
|
+
if (!Number.isInteger(r.countAtRejection) || r.countAtRejection < 0) {
|
|
196
|
+
errors.push(`${ra}.countAtRejection must be a non-negative integer`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
130
199
|
function validateSuppressed(entry, index, errors) {
|
|
131
200
|
const at = `evidence.proposalSet.suppressed[${index}]`;
|
|
132
201
|
if (!isObject(entry)) {
|
|
@@ -171,10 +240,25 @@ function validateSuppressed(entry, index, errors) {
|
|
|
171
240
|
*
|
|
172
241
|
* Applied on the Hub's own read rather than trusted from the agent: the order the
|
|
173
242
|
* manager sees should not depend on how a model happened to sort its output.
|
|
243
|
+
*
|
|
244
|
+
* Issue #2079 adds the partition above that. A capability change has no
|
|
245
|
+
* occurrence count, so ranking it against a Learning by one is the same category
|
|
246
|
+
* error the row made: `b.recurrences - a.recurrences` on a missing count is NaN,
|
|
247
|
+
* which is falsy, so the comparison fell straight through to the id tie-break
|
|
248
|
+
* and a permanent job edit landed wherever its id happened to put it.
|
|
249
|
+
*
|
|
250
|
+
* Capability rows lead as their own group, because a change to how the employee
|
|
251
|
+
* works from now on is decided before a batch of remembered lessons. Inside that
|
|
252
|
+
* group there is nothing to rank by and no claim that there is: every evidence
|
|
253
|
+
* comparison is zero and the order falls to `proposalId`, which is stable rather
|
|
254
|
+
* than meaningful. Learning rows keep the evidence ordering above.
|
|
174
255
|
*/
|
|
175
256
|
function rankProposals(proposals) {
|
|
176
|
-
const
|
|
177
|
-
|
|
257
|
+
const group = (p) => (p.type === exports.LEARNING_ARTIFACT_TYPE ? 1 : 0);
|
|
258
|
+
const count = (p) => (typeof p.recurrences === 'number' ? p.recurrences : 0);
|
|
259
|
+
const growth = (p) => typeof p.recurrencesAtLastReview === 'number' ? count(p) - p.recurrencesAtLastReview : 0;
|
|
260
|
+
return [...proposals].sort((a, b) => group(a) - group(b)
|
|
261
|
+
|| count(b) - count(a)
|
|
178
262
|
|| growth(b) - growth(a)
|
|
179
263
|
|| a.proposalId.localeCompare(b.proposalId));
|
|
180
264
|
}
|
|
@@ -9,11 +9,11 @@ function createPortConflictError(port) {
|
|
|
9
9
|
return error;
|
|
10
10
|
}
|
|
11
11
|
async function startListening(app, port, onListening) {
|
|
12
|
-
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
13
|
const server = app.listen(port);
|
|
14
14
|
server.once('listening', () => {
|
|
15
15
|
onListening();
|
|
16
|
-
resolve();
|
|
16
|
+
resolve(server);
|
|
17
17
|
});
|
|
18
18
|
server.once('error', (error) => {
|
|
19
19
|
if (error?.code === 'EADDRINUSE') {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sameLicenseScope = sameLicenseScope;
|
|
4
|
+
exports.hasUsableLicense = hasUsableLicense;
|
|
5
|
+
exports.effectiveLicenseExpiry = effectiveLicenseExpiry;
|
|
6
|
+
exports.subscriptionAccessPatch = subscriptionAccessPatch;
|
|
7
|
+
exports.selectCurrentLicense = selectCurrentLicense;
|
|
8
|
+
function sameLicenseScope(a, b) {
|
|
9
|
+
return a.userId === b.userId && a.orgId === b.orgId
|
|
10
|
+
&& (a.workspaceId || null) === (b.workspaceId || null);
|
|
11
|
+
}
|
|
12
|
+
function hasUsableLicense(key, now = Date.now()) {
|
|
13
|
+
const expiry = effectiveLicenseExpiry(key);
|
|
14
|
+
return key.isActive !== false && !key.revokedAt
|
|
15
|
+
&& (key.status === 'active' || (!key.status && key.isActive === true))
|
|
16
|
+
&& (expiry === null || expiry.getTime() > now);
|
|
17
|
+
}
|
|
18
|
+
/** No automatic grace. Explicit support grants are separate from payment proof. */
|
|
19
|
+
function effectiveLicenseExpiry(key) {
|
|
20
|
+
if (!key.stripeSubscriptionId)
|
|
21
|
+
return key.expiresAt ? new Date(key.expiresAt) : null;
|
|
22
|
+
if (key.manualAccessOverride?.until === null)
|
|
23
|
+
return null;
|
|
24
|
+
let paid = key.paidThrough ? new Date(key.paidThrough).getTime() : 0;
|
|
25
|
+
if (key.stripeSubscriptionEndedAt)
|
|
26
|
+
paid = Math.min(paid, new Date(key.stripeSubscriptionEndedAt).getTime());
|
|
27
|
+
// A finite pre-migration deadline is retained until reconciliation classifies it.
|
|
28
|
+
const legacy = !key.billingReconciledAt && key.expiresAt ? new Date(key.expiresAt).getTime() : 0;
|
|
29
|
+
const manual = key.manualAccessOverride?.until ? new Date(key.manualAccessOverride.until).getTime() : 0;
|
|
30
|
+
return new Date(Math.max(paid, manual, legacy));
|
|
31
|
+
}
|
|
32
|
+
function subscriptionAccessPatch(key, now = new Date()) {
|
|
33
|
+
const expiresAt = effectiveLicenseExpiry(key);
|
|
34
|
+
const usable = expiresAt === null || expiresAt.getTime() > now.getTime();
|
|
35
|
+
const failed = ['past_due', 'unpaid', 'incomplete', 'paused'].includes(key.subscriptionStatus || '');
|
|
36
|
+
return {
|
|
37
|
+
expiresAt, status: usable ? 'active' : failed ? 'suspended' : 'expired',
|
|
38
|
+
suspendedAt: !usable && failed ? key.suspendedAt || now : null,
|
|
39
|
+
suspensionReason: !usable && failed ? 'payment_failed' : null
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Paid lifecycle takes precedence over trials, including after suspension or cancellation. */
|
|
43
|
+
function selectCurrentLicense(records, activeOnly = false) {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const selected = records.filter(record => record.isActive !== false && !record.revokedAt).sort((a, b) => Number(b.tier === 'paid-subscription') - Number(a.tier === 'paid-subscription')
|
|
46
|
+
|| Number(hasUsableLicense(b, now)) - Number(hasUsableLicense(a, now))
|
|
47
|
+
|| Number(a.accessScope === 'persona') - Number(b.accessScope === 'persona')
|
|
48
|
+
|| (new Date(b.createdAt).getTime() || 0) - (new Date(a.createdAt).getTime() || 0)
|
|
49
|
+
|| a.key.localeCompare(b.key))[0] || null;
|
|
50
|
+
return selected && (!activeOnly || hasUsableLicense(selected, now)) ? selected : null;
|
|
51
|
+
}
|