fraim-hub 2.0.349 → 2.0.351
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/ai-hub/catalog.js +7 -1
- package/dist/src/ai-hub/desktop-installer-setup.js +0 -9
- package/dist/src/ai-hub/hosts.js +43 -16
- package/dist/src/ai-hub/server.js +87 -21
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +18 -1
- package/dist/src/cli/setup/user-level-sync.js +2 -0
- package/dist/src/core/utils/project-fraim-paths.js +14 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +16 -119
- package/package.json +2 -2
- package/public/ai-hub/script.js +212 -252
- package/public/ai-hub/styles.css +35 -0
|
@@ -213,6 +213,7 @@ function summarizeProject(projectPath) {
|
|
|
213
213
|
path: '',
|
|
214
214
|
exists: false,
|
|
215
215
|
hasFraim: false,
|
|
216
|
+
isOnboarded: false,
|
|
216
217
|
message: 'Choose a project path to load FRAIM jobs.',
|
|
217
218
|
};
|
|
218
219
|
}
|
|
@@ -221,17 +222,20 @@ function summarizeProject(projectPath) {
|
|
|
221
222
|
path: projectPath,
|
|
222
223
|
exists: false,
|
|
223
224
|
hasFraim: false,
|
|
225
|
+
isOnboarded: (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath),
|
|
224
226
|
message: 'Project path does not exist on this machine.',
|
|
225
227
|
};
|
|
226
228
|
}
|
|
227
229
|
const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
|
|
228
230
|
const hasFraim = fs_1.default.existsSync(fraimDir);
|
|
231
|
+
const isOnboarded = (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath);
|
|
229
232
|
if (!hasFraim) {
|
|
230
233
|
return {
|
|
231
234
|
path: projectPath,
|
|
232
235
|
exists: true,
|
|
233
236
|
hasFraim: false,
|
|
234
|
-
|
|
237
|
+
isOnboarded,
|
|
238
|
+
needsOnboarding: isOnboarded === false,
|
|
235
239
|
message: 'This folder has not been set up with FRAIM yet. Onboarding will get your AI employee started.',
|
|
236
240
|
};
|
|
237
241
|
}
|
|
@@ -239,6 +243,8 @@ function summarizeProject(projectPath) {
|
|
|
239
243
|
path: projectPath,
|
|
240
244
|
exists: true,
|
|
241
245
|
hasFraim: true,
|
|
246
|
+
isOnboarded,
|
|
247
|
+
needsOnboarding: isOnboarded === false,
|
|
242
248
|
};
|
|
243
249
|
}
|
|
244
250
|
function resolveLayerRoot(projectPath, layer) {
|
|
@@ -16,7 +16,6 @@ const auto_mcp_setup_1 = require("../cli/setup/auto-mcp-setup");
|
|
|
16
16
|
const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
|
|
17
17
|
const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
|
|
18
18
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
19
|
-
const hub_app_materializer_1 = require("./hub-app-materializer");
|
|
20
19
|
function isDesktopRuntimeComplete() {
|
|
21
20
|
if (![(0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)(), (0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)(), (0, fraim_mcp_latest_launcher_1.getFraimNpxShimPath)(), (0, fraim_mcp_latest_launcher_1.getFraimMcpLatestLauncherPath)()]
|
|
22
21
|
.every(file => fs_1.default.existsSync(file)))
|
|
@@ -68,19 +67,11 @@ async function repairInstallerIdeMcpEntries(options = {}) {
|
|
|
68
67
|
process.env.FRAIM_NON_INTERACTIVE = previousNonInteractive;
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
|
-
async function warmInstallerCliCache() {
|
|
72
|
-
const cliShim = (0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)();
|
|
73
|
-
const result = await (0, hub_app_materializer_1.runNpmInstall)(cliShim, ['--version']);
|
|
74
|
-
if (result.status !== 0) {
|
|
75
|
-
throw new Error(`FRAIM CLI bootstrap exited with code ${result.status ?? 'unknown'}.`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
70
|
async function runDesktopRuntimeSetup(logger = console) {
|
|
79
71
|
await (0, managed_node_runtime_1.ensureManagedNpm)({
|
|
80
72
|
onProgress: (message) => logger.log(`[fraim-installer-setup] ${message}`),
|
|
81
73
|
});
|
|
82
74
|
(0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
|
|
83
|
-
await warmInstallerCliCache();
|
|
84
75
|
}
|
|
85
76
|
async function runDesktopInstallerSetup(logger = console) {
|
|
86
77
|
await runDesktopRuntimeSetup(logger);
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -2398,36 +2398,63 @@ function detectCodexCompactTaskFailure(line) {
|
|
|
2398
2398
|
}
|
|
2399
2399
|
return null;
|
|
2400
2400
|
}
|
|
2401
|
+
const CODEX_PERMANENT_REFRESH_FAILURES = new Set([
|
|
2402
|
+
'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.',
|
|
2403
|
+
'Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.',
|
|
2404
|
+
'Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.',
|
|
2405
|
+
'Your access token could not be refreshed. Please log out and sign in again.',
|
|
2406
|
+
'Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.',
|
|
2407
|
+
]);
|
|
2408
|
+
// Exec has no typed auth code. Scope transport 401s to Codex's own OpenAI
|
|
2409
|
+
// Responses endpoints; tools, MCP servers and custom relays can also return 401.
|
|
2410
|
+
function isCodexProviderAuthFailure(message) {
|
|
2411
|
+
if (typeof message !== 'string')
|
|
2412
|
+
return false;
|
|
2413
|
+
if (message === 'workspace routing discovery unauthorized (401)')
|
|
2414
|
+
return true;
|
|
2415
|
+
const transport = /^unexpected status 401 Unauthorized: [\s\S]*?, url: ([^\s,]+)(?:, [\s\S]*)?$/.exec(message);
|
|
2416
|
+
if (!transport)
|
|
2417
|
+
return false;
|
|
2418
|
+
try {
|
|
2419
|
+
const endpoint = new URL(transport[1]);
|
|
2420
|
+
if (!['https:', 'wss:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.port || endpoint.hash)
|
|
2421
|
+
return false;
|
|
2422
|
+
const responsePath = endpoint.hostname === 'api.openai.com' ? '/v1/responses'
|
|
2423
|
+
: endpoint.hostname === 'chatgpt.com' ? '/backend-api/codex/responses' : null;
|
|
2424
|
+
return responsePath !== null && (endpoint.pathname === responsePath || endpoint.pathname === `${responsePath}/compact`);
|
|
2425
|
+
}
|
|
2426
|
+
catch {
|
|
2427
|
+
return false;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2401
2430
|
function wireHostProcess(hostId, child, handlers, spawnCwd) {
|
|
2402
|
-
//
|
|
2403
|
-
//
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
let codexAuthDiagnostic = false;
|
|
2408
|
-
let codexTerminalAuthFailure = false;
|
|
2409
|
-
let codexTerminalWorkspaceAuthFailure = false;
|
|
2431
|
+
// Refresh failures need matching auth-manager stderr. Provider 401s need a
|
|
2432
|
+
// terminal turn.failed. Both require normal failed exit; never scan prose.
|
|
2433
|
+
const codexAuthDiagnostics = new Set();
|
|
2434
|
+
let codexTerminalRefreshFailure = null;
|
|
2435
|
+
let codexTerminalProviderFailure = null;
|
|
2410
2436
|
const observeCodexAuth = (line, channel) => {
|
|
2411
2437
|
if (hostId !== 'codex')
|
|
2412
2438
|
return;
|
|
2413
2439
|
if (channel === 'stderr') {
|
|
2414
2440
|
const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
|
2415
2441
|
const diagnostic = clean.match(/^(?:\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+)?ERROR\s+codex_login::auth::manager:\s+Failed to refresh token: (.+)$/);
|
|
2416
|
-
if (diagnostic
|
|
2417
|
-
|
|
2442
|
+
if (diagnostic && CODEX_PERMANENT_REFRESH_FAILURES.has(diagnostic[1]))
|
|
2443
|
+
codexAuthDiagnostics.add(diagnostic[1]);
|
|
2418
2444
|
return;
|
|
2419
2445
|
}
|
|
2420
2446
|
try {
|
|
2421
2447
|
const event = JSON.parse(line);
|
|
2422
2448
|
if (event.type === 'turn.failed') {
|
|
2423
|
-
|
|
2449
|
+
codexTerminalProviderFailure = isCodexProviderAuthFailure(event.error?.message) ? event.error.message : null;
|
|
2424
2450
|
}
|
|
2425
2451
|
if (event.type === 'turn.failed' || event.type === 'error') {
|
|
2426
|
-
|
|
2452
|
+
const message = event.type === 'turn.failed' ? event.error?.message : event.message;
|
|
2453
|
+
codexTerminalRefreshFailure = CODEX_PERMANENT_REFRESH_FAILURES.has(message) ? message : null;
|
|
2427
2454
|
}
|
|
2428
2455
|
else if (event.type === 'turn.started' || event.type === 'turn.completed') {
|
|
2429
|
-
|
|
2430
|
-
|
|
2456
|
+
codexTerminalRefreshFailure = null;
|
|
2457
|
+
codexTerminalProviderFailure = null;
|
|
2431
2458
|
}
|
|
2432
2459
|
}
|
|
2433
2460
|
catch { /* Plain stdout cannot establish authentication failure. */ }
|
|
@@ -2481,8 +2508,8 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
|
|
|
2481
2508
|
// `exitCode ?? 0` guard. Pass -1 for any signal-terminated child so the
|
|
2482
2509
|
// non-zero branch fires and recovery is attempted.
|
|
2483
2510
|
child.on('close', (code, signal) => {
|
|
2484
|
-
|
|
2485
|
-
|
|
2511
|
+
const failure = codexTerminalProviderFailure || (codexTerminalRefreshFailure && codexAuthDiagnostics.has(codexTerminalRefreshFailure) ? codexTerminalRefreshFailure : null);
|
|
2512
|
+
if (!signal && code !== null && code !== 0 && failure) {
|
|
2486
2513
|
handlers.onEvent({ hostAuthenticationRequired: true, hostError: failure, raw: failure }, 'system');
|
|
2487
2514
|
}
|
|
2488
2515
|
handlers.onExit(signal ? -1 : code);
|
|
@@ -383,20 +383,21 @@ function directoryExists(projectPath) {
|
|
|
383
383
|
// project list. This deliberately never falls back to process.cwd() — the launch
|
|
384
384
|
// directory must not masquerade as a project.
|
|
385
385
|
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
// Downloads
|
|
389
|
-
//
|
|
390
|
-
// it was simply not on the list, and such a list is unbounded by construction.
|
|
386
|
+
// A recorded folder must be a discoverable FRAIM project or a deliberately added
|
|
387
|
+
// project list entry. The latter remains selectable before onboarding (#2039).
|
|
388
|
+
// Existing temporary/Home/Desktop/Downloads safeguards still apply; a stale bare
|
|
389
|
+
// recorded path is not enough to turn an arbitrary directory into a project.
|
|
391
390
|
function resolveHubProjectPath(preferencesStore, options = {}) {
|
|
392
|
-
const
|
|
391
|
+
const preferences = preferencesStore.load('');
|
|
392
|
+
const recorded = preferences.projectPath;
|
|
393
393
|
if (typeof recorded === 'string' && recorded.trim().length > 0) {
|
|
394
394
|
const resolved = path_1.default.resolve(recorded);
|
|
395
395
|
const allowedTemporary = options.allowTemporaryRelatedTo
|
|
396
396
|
? isRelatedTemporaryProjectPath(resolved, options.allowTemporaryRelatedTo)
|
|
397
397
|
: false;
|
|
398
398
|
if (!isLikelyTemporaryProjectPath(resolved) || allowedTemporary) {
|
|
399
|
-
|
|
399
|
+
const explicitlyAdded = preferences.projects?.some((project) => sameDirectoryPath(project.folderPath, resolved));
|
|
400
|
+
if (directoryExists(resolved) && ((0, project_fraim_paths_1.workspaceFraimExists)(resolved) || explicitlyAdded))
|
|
400
401
|
return { projectPath: resolved };
|
|
401
402
|
// A missing folder can be a moved project. Report it separately from a
|
|
402
403
|
// non-FRAIM folder so the Hub can show the rehome path instead of leaving
|
|
@@ -419,10 +420,15 @@ function resolveHubProjectPath(preferencesStore, options = {}) {
|
|
|
419
420
|
* handed to `preferencesStore.save()`.
|
|
420
421
|
*/
|
|
421
422
|
function decorateProjectsWithFraimStatus(projects) {
|
|
422
|
-
return projects.map((project) =>
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
423
|
+
return projects.map((project) => {
|
|
424
|
+
const isOnboarded = project.folderPath ? (0, project_fraim_paths_1.workspaceOnboardingReady)(path_1.default.resolve(project.folderPath)) : false;
|
|
425
|
+
return {
|
|
426
|
+
...project,
|
|
427
|
+
hasFraim: !!project.folderPath && (0, project_fraim_paths_1.workspaceFraimExists)(path_1.default.resolve(project.folderPath)),
|
|
428
|
+
isOnboarded,
|
|
429
|
+
needsOnboarding: isOnboarded === false,
|
|
430
|
+
};
|
|
431
|
+
});
|
|
426
432
|
}
|
|
427
433
|
class AiHubRunRegistry {
|
|
428
434
|
constructor() {
|
|
@@ -2411,6 +2417,18 @@ function normalizedDirectoryPath(projectPath) {
|
|
|
2411
2417
|
function sameDirectoryPath(left, right) {
|
|
2412
2418
|
return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
|
|
2413
2419
|
}
|
|
2420
|
+
function onboardingConversationIsActive(conversation) {
|
|
2421
|
+
if (conversation.status === 'running')
|
|
2422
|
+
return true;
|
|
2423
|
+
if (conversation.pauseReason === 'done' || conversation.reviewApproved === true)
|
|
2424
|
+
return false;
|
|
2425
|
+
const handoff = normalizeReviewHandoff(conversation.reviewHandoff);
|
|
2426
|
+
if (handoff)
|
|
2427
|
+
return handoff.reviewRequired;
|
|
2428
|
+
if (conversation.pauseReason === 'awaiting_review')
|
|
2429
|
+
return false;
|
|
2430
|
+
return conversation.status === 'completed' && (conversation.artifacts || []).some((artifact) => Boolean(artifact.path || artifact.url || (('where' in artifact) && artifact.where && artifact.name)));
|
|
2431
|
+
}
|
|
2414
2432
|
function isPathInsideDirectory(candidatePath, parentPath) {
|
|
2415
2433
|
const relative = path_1.default.relative(path_1.default.resolve(parentPath), path_1.default.resolve(candidatePath));
|
|
2416
2434
|
return relative === '' || (!!relative && !relative.startsWith('..') && !path_1.default.isAbsolute(relative));
|
|
@@ -2480,8 +2498,9 @@ function resolveApiKey() {
|
|
|
2480
2498
|
}
|
|
2481
2499
|
// Read persisted Get-started step states from ~/.fraim/install-state.json
|
|
2482
2500
|
// (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
|
|
2483
|
-
// missing key falls back to derivation. Persisted `true` is sticky
|
|
2484
|
-
//
|
|
2501
|
+
// missing key falls back to derivation. Persisted `true` is sticky for install,
|
|
2502
|
+
// company, and hire (R13.4). computeFirstRun ignores the legacy project flag:
|
|
2503
|
+
// project readiness is derived live from the selected project's config presence.
|
|
2485
2504
|
function readPersistedFirstRun() {
|
|
2486
2505
|
const out = {};
|
|
2487
2506
|
const apply = (source) => {
|
|
@@ -3000,6 +3019,7 @@ class AiHubServer {
|
|
|
3000
3019
|
constructor(options = {}) {
|
|
3001
3020
|
this.app = (0, express_1.default)();
|
|
3002
3021
|
this.runRegistry = new AiHubRunRegistry();
|
|
3022
|
+
this.projectSelectionGeneration = 0;
|
|
3003
3023
|
this.activeAvailabilityWarnings = new Set();
|
|
3004
3024
|
// Issue #925: the last identity successfully resolved from the hosted authority,
|
|
3005
3025
|
// keyed by the exact apiKey it was resolved for. When the authority is transiently
|
|
@@ -3584,10 +3604,26 @@ class AiHubServer {
|
|
|
3584
3604
|
this.conversationStore.moveProjectBucket(oldProjectPath, newProjectPath);
|
|
3585
3605
|
}
|
|
3586
3606
|
}
|
|
3587
|
-
async bootstrapResponse(projectPath) {
|
|
3607
|
+
async bootstrapResponse(projectPath, explicitSelection = false) {
|
|
3588
3608
|
// #866 R2: guard against path.resolve('') === cwd; '' means "no active project".
|
|
3589
3609
|
const activeProjectPath = projectPath || this.projectPath;
|
|
3590
3610
|
const normalizedProjectPath = activeProjectPath ? path_1.default.resolve(activeProjectPath) : '';
|
|
3611
|
+
if (explicitSelection) {
|
|
3612
|
+
this.projectSelectionGeneration += 1;
|
|
3613
|
+
// Record the chosen directory before slow employee/persona reads. A
|
|
3614
|
+
// pathless refresh during that work must already resolve this selection.
|
|
3615
|
+
if (directoryExists(normalizedProjectPath)) {
|
|
3616
|
+
const selectedPreferences = this.preferencesStore.load('');
|
|
3617
|
+
this.preferencesStore.save({
|
|
3618
|
+
...selectedPreferences,
|
|
3619
|
+
projectPath: normalizedProjectPath,
|
|
3620
|
+
projects: (0, preferences_1.normalizeAiHubProjectList)(selectedPreferences.projects || [], normalizedProjectPath, {
|
|
3621
|
+
removedProjectPaths: selectedPreferences.removedProjectPaths || [],
|
|
3622
|
+
}),
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
const selectionGeneration = this.projectSelectionGeneration;
|
|
3591
3627
|
// Issue #1010: prefer the non-blocking parallel probe on the first-paint path. On a
|
|
3592
3628
|
// cache miss the synchronous form would spawnSync per agent (~2.7s) and freeze the
|
|
3593
3629
|
// event loop, making every concurrent request wait. Falls back to the sync form for
|
|
@@ -3616,7 +3652,8 @@ class AiHubServer {
|
|
|
3616
3652
|
const firstAvailable = employees.find((e) => e.available);
|
|
3617
3653
|
if (firstAvailable) {
|
|
3618
3654
|
preferences = { ...preferences, employeeId: firstAvailable.id };
|
|
3619
|
-
this.
|
|
3655
|
+
if (selectionGeneration === this.projectSelectionGeneration)
|
|
3656
|
+
this.preferencesStore.save(preferences);
|
|
3620
3657
|
}
|
|
3621
3658
|
}
|
|
3622
3659
|
const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
|
|
@@ -3682,7 +3719,8 @@ class AiHubServer {
|
|
|
3682
3719
|
// shell is visible.
|
|
3683
3720
|
const projects = this.knownProjects(normalizedProjectPath, [], { includeConversationProjects: false });
|
|
3684
3721
|
preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
|
|
3685
|
-
this.
|
|
3722
|
+
if (selectionGeneration === this.projectSelectionGeneration)
|
|
3723
|
+
this.preferencesStore.save(preferences);
|
|
3686
3724
|
// Issue #1253: `hasFraim` is decorated AFTER the save above, never before. The
|
|
3687
3725
|
// line that persists `preferences` takes whatever list it is handed, so a
|
|
3688
3726
|
// derived field attached upstream of it silently becomes persisted state. See
|
|
@@ -5174,8 +5212,7 @@ class AiHubServer {
|
|
|
5174
5212
|
const companyDone = tc.orgContext.present || tc.managerContext.present || tc.orgRules.present || tc.managerRules.present;
|
|
5175
5213
|
// hire = any persona hired (entitlement active).
|
|
5176
5214
|
const hireDone = personas.some((p) => p.status === 'hired');
|
|
5177
|
-
|
|
5178
|
-
const projectDone = tc.projectContext.present || tc.projectBrief.present || tc.projectRules.present || tc.projectQa.present || jobCount > 0;
|
|
5215
|
+
const projectDone = (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath) === true;
|
|
5179
5216
|
const derived = {
|
|
5180
5217
|
install: true, // the Hub is running, so the toolchain install step is satisfied.
|
|
5181
5218
|
company: companyDone,
|
|
@@ -5187,7 +5224,7 @@ class AiHubServer {
|
|
|
5187
5224
|
install: persisted.install ?? derived.install,
|
|
5188
5225
|
company: persisted.company || derived.company,
|
|
5189
5226
|
hire: persisted.hire || derived.hire,
|
|
5190
|
-
project:
|
|
5227
|
+
project: derived.project,
|
|
5191
5228
|
};
|
|
5192
5229
|
}
|
|
5193
5230
|
// Issue #512 (S3, R3) — presence + display paths of the three-layer context
|
|
@@ -5722,7 +5759,7 @@ class AiHubServer {
|
|
|
5722
5759
|
}
|
|
5723
5760
|
// #719 / local Hub package: a bare reload must land on the user-level saved
|
|
5724
5761
|
// Hub project, not whatever directory happened to launch the process.
|
|
5725
|
-
res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath()));
|
|
5762
|
+
res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath(), !!projectPath));
|
|
5726
5763
|
});
|
|
5727
5764
|
this.app.get('/api/ai-hub/personas', async (req, res) => {
|
|
5728
5765
|
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
@@ -6540,7 +6577,7 @@ class AiHubServer {
|
|
|
6540
6577
|
.filter((id) => id.length > 0));
|
|
6541
6578
|
const retainedProjects = this.knownProjects(projectPath).filter((project) => !(project.id && incomingProjectIds.has(project.id)));
|
|
6542
6579
|
const projects = this.preferencesStore.saveProjects(projectPath, [...retainedProjects, ...body.projects], { reviveRemovedPaths: Array.isArray(body.reviveRemovedPaths) ? body.reviveRemovedPaths : [] });
|
|
6543
|
-
return res.json({ projectPath, projects, source: 'disk' });
|
|
6580
|
+
return res.json({ projectPath, projects: decorateProjectsWithFraimStatus(projects), source: 'disk' });
|
|
6544
6581
|
}
|
|
6545
6582
|
catch (error) {
|
|
6546
6583
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
|
|
@@ -7160,6 +7197,35 @@ class AiHubServer {
|
|
|
7160
7197
|
if (!jobId) {
|
|
7161
7198
|
throw new Error('Choose a FRAIM job before starting a run.');
|
|
7162
7199
|
}
|
|
7200
|
+
// A project has one active onboarding workflow, including review gates.
|
|
7201
|
+
// Disk records cover review after restart; optimistic records without a
|
|
7202
|
+
// run id cannot block their own first launch.
|
|
7203
|
+
if (jobId === 'project-onboarding' && scope === 'project') {
|
|
7204
|
+
const conversations = this.conversationStore.loadProject(projectPath).conversations;
|
|
7205
|
+
const liveRun = this.runRegistry.all().find((run) => {
|
|
7206
|
+
if (run.jobId !== jobId || run.scope === 'manager' || run.scope === 'company'
|
|
7207
|
+
|| !sameDirectoryPath(run.projectPath, projectPath))
|
|
7208
|
+
return false;
|
|
7209
|
+
const stored = conversations.find((conversation) => conversation.id === (run.conversationId || run.id));
|
|
7210
|
+
return onboardingConversationIsActive({
|
|
7211
|
+
...run,
|
|
7212
|
+
reviewApproved: stored?.reviewApproved,
|
|
7213
|
+
pauseReason: stored?.pauseReason === 'done' ? 'done' : run.pauseReason,
|
|
7214
|
+
});
|
|
7215
|
+
});
|
|
7216
|
+
const existing = conversations.find((conversation) => !!conversation.runId
|
|
7217
|
+
&& conversation.jobId === jobId && conversation.scope !== 'manager' && conversation.scope !== 'company'
|
|
7218
|
+
&& !!conversation.projectPath && sameDirectoryPath(conversation.projectPath, projectPath)
|
|
7219
|
+
&& onboardingConversationIsActive(conversation));
|
|
7220
|
+
if (liveRun || existing) {
|
|
7221
|
+
return res.status(409).json({
|
|
7222
|
+
code: 'project_onboarding_active',
|
|
7223
|
+
error: 'Project onboarding is already in progress.',
|
|
7224
|
+
existingConversationId: liveRun ? (liveRun.conversationId || liveRun.id) : existing.id,
|
|
7225
|
+
existingRunId: liveRun?.id || existing?.runId,
|
|
7226
|
+
});
|
|
7227
|
+
}
|
|
7228
|
+
}
|
|
7163
7229
|
// #1394: Dedup guard — return an already-running identical run rather than spawning a second
|
|
7164
7230
|
// process. Key includes managerDisplay (messages[0].text) so runs for different issues are
|
|
7165
7231
|
// never blocked even when they share the same jobId + hostId + projectPath.
|
|
@@ -232,9 +232,26 @@ function runPackage(plan, cacheDir) {
|
|
|
232
232
|
const plan = resolvePackage();
|
|
233
233
|
const versionCacheDir = path.join(CACHE_ROOT, plan.packageName + '-' + plan.version.replace(/[^a-zA-Z0-9._-]/g, '_'));
|
|
234
234
|
pruneCaches(versionCacheDir, Date.now());
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* True when npx materialized the package into this cache. A non-zero exit from a package
|
|
238
|
+
* that DID install is the CLI's own result, not a cache problem, and must be returned verbatim.
|
|
239
|
+
*/
|
|
240
|
+
function packageMaterialized(cacheDir) {
|
|
241
|
+
try {
|
|
242
|
+
const npxDir = path.join(cacheDir, '_npx');
|
|
243
|
+
return fs.readdirSync(npxDir).some(function (entry) {
|
|
244
|
+
return fs.existsSync(path.join(npxDir, entry, 'node_modules', plan.packageName));
|
|
245
|
+
});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
235
251
|
let status = runPackage(plan, versionCacheDir);
|
|
236
252
|
|
|
237
|
-
|
|
253
|
+
// 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)) {
|
|
238
255
|
const retryCache = path.join(CACHE_ROOT, 'retry-' + Date.now() + '-' + process.pid);
|
|
239
256
|
console.error('[fraim-mcp-launcher] Package execution failed; retrying once with a fresh npm cache.');
|
|
240
257
|
status = runPackage(plan, retryCache);
|
|
@@ -70,6 +70,8 @@ const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
|
70
70
|
const USER_LEVEL_RUNTIME_DEPS = {
|
|
71
71
|
'node-edge-tts': '*', // used by scripts/author-audio.js
|
|
72
72
|
'adm-zip': '^0.6.0', // used by scripts/communication/*.js (DOCX review layer) — matches root/packages/* manifests
|
|
73
|
+
'fast-glob': '^3.3.3', // used by scripts/exec-with-timeout.ts (Issue #1839)
|
|
74
|
+
'tree-kill': '^1.2.2', // used by scripts/exec-with-timeout.ts (Issue #1839)
|
|
73
75
|
};
|
|
74
76
|
/**
|
|
75
77
|
* Ensure the user-level FRAIM directory structure exists.
|
|
@@ -7,6 +7,7 @@ exports.WORKSPACE_SYNCED_CONTENT_DIRS = exports.WORKSPACE_FRAIM_DIRNAME = void 0
|
|
|
7
7
|
exports.getWorkspaceFraimDir = getWorkspaceFraimDir;
|
|
8
8
|
exports.workspaceFraimExists = workspaceFraimExists;
|
|
9
9
|
exports.getWorkspaceConfigPath = getWorkspaceConfigPath;
|
|
10
|
+
exports.workspaceOnboardingReady = workspaceOnboardingReady;
|
|
10
11
|
exports.getWorkspaceFraimPath = getWorkspaceFraimPath;
|
|
11
12
|
exports.assertWorkspaceFraimRoot = assertWorkspaceFraimRoot;
|
|
12
13
|
exports.assertWorkspaceFraimPath = assertWorkspaceFraimPath;
|
|
@@ -75,6 +76,19 @@ function workspaceFraimExists(projectRoot = process.cwd()) {
|
|
|
75
76
|
function getWorkspaceConfigPath(projectRoot = process.cwd()) {
|
|
76
77
|
return (0, path_1.join)(getWorkspaceFraimDir(projectRoot), 'config.json');
|
|
77
78
|
}
|
|
79
|
+
/** Config presence is readiness; unreadable filesystem state is not absence. */
|
|
80
|
+
function workspaceOnboardingReady(projectRoot) {
|
|
81
|
+
if (!projectRoot)
|
|
82
|
+
return false;
|
|
83
|
+
try {
|
|
84
|
+
(0, fs_1.statSync)(getWorkspaceConfigPath(projectRoot));
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const code = error.code;
|
|
89
|
+
return code === 'ENOENT' || code === 'ENOTDIR' ? false : null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
78
92
|
function getWorkspaceFraimPath(projectRoot = process.cwd(), ...parts) {
|
|
79
93
|
return (0, path_1.join)(getWorkspaceFraimDir(projectRoot), ...parts);
|
|
80
94
|
}
|