fraim-hub 2.0.310 → 2.0.311
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/desktop-main.js +101 -5
- package/dist/src/ai-hub/devtools-window.js +17 -0
- package/dist/src/ai-hub/hub-app-materializer.js +33 -3
- package/dist/src/ai-hub/hub-main-diagnostics.js +278 -0
- package/dist/src/ai-hub/server.js +30 -2
- package/package.json +2 -2
- package/public/ai-hub/script.js +238 -95
- package/public/ai-hub/styles.css +26 -0
|
@@ -24,6 +24,8 @@ const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launche
|
|
|
24
24
|
const desktop_auto_updater_1 = require("./desktop-auto-updater");
|
|
25
25
|
const hub_app_materializer_1 = require("./hub-app-materializer");
|
|
26
26
|
const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
|
|
27
|
+
const hub_main_diagnostics_1 = require("./hub-main-diagnostics");
|
|
28
|
+
const devtools_window_1 = require("./devtools-window");
|
|
27
29
|
// Keep installed, running, and user-pinned Windows shortcuts grouped under the
|
|
28
30
|
// stable identity declared in packages/fraim-hub/package.json.
|
|
29
31
|
electron_1.app.setAppUserModelId('ai.fraim.hub');
|
|
@@ -163,7 +165,7 @@ async function resolveLatestHubAppVersion() {
|
|
|
163
165
|
bundledVersion: runningVersion,
|
|
164
166
|
bundledEntryPath: __filename,
|
|
165
167
|
});
|
|
166
|
-
return { upgradable: resolved.version !== runningVersion, latest: resolved.version };
|
|
168
|
+
return { upgradable: resolved.version !== runningVersion, latest: resolved.version, error: resolved.materializationError };
|
|
167
169
|
}
|
|
168
170
|
// Issue #1415: a second-instance activation (the user re-clicking the icon while the Hub is
|
|
169
171
|
// already running) is the common case once login-item autostart + hide-to-tray keep the Hub
|
|
@@ -189,9 +191,12 @@ async function checkForHubAppUpdateOnSecondInstance() {
|
|
|
189
191
|
// response reaches the browser (which uses it to start polling for the new instance) before this
|
|
190
192
|
// process starts tearing down.
|
|
191
193
|
async function restartToLatestNow() {
|
|
192
|
-
const { upgradable, latest } = await resolveLatestHubAppVersion();
|
|
193
|
-
if (!upgradable)
|
|
194
|
-
|
|
194
|
+
const { upgradable, latest, error } = await resolveLatestHubAppVersion();
|
|
195
|
+
if (!upgradable) {
|
|
196
|
+
if (error)
|
|
197
|
+
console.warn(`[fraim] manual restart requested but could not complete: ${error}`);
|
|
198
|
+
return { restarting: false, latest, error };
|
|
199
|
+
}
|
|
195
200
|
console.log(`[fraim] manual restart requested; relaunching to FRAIM Hub ${latest}`);
|
|
196
201
|
setTimeout(() => {
|
|
197
202
|
electron_1.app.relaunch();
|
|
@@ -274,6 +279,14 @@ function shouldConfigureOfficeSideload() {
|
|
|
274
279
|
function displayName(runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
|
|
275
280
|
return process.env.FRAIM_HUB_DISPLAY_NAME || (runtimeId === 'hub2' ? 'FRAIM Hub 2' : 'FRAIM Hub');
|
|
276
281
|
}
|
|
282
|
+
async function openMainWindowDevTools(hubUrl) {
|
|
283
|
+
await (0, devtools_window_1.openHubWindowDevTools)({
|
|
284
|
+
hubUrl,
|
|
285
|
+
getWindow: () => mainWindow,
|
|
286
|
+
createWindow,
|
|
287
|
+
recordOpenRequested: () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.devtools.open_requested'),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
277
290
|
function buildTrayMenu(hubUrl, runtimeId) {
|
|
278
291
|
const name = displayName(runtimeId);
|
|
279
292
|
return electron_1.Menu.buildFromTemplate([
|
|
@@ -289,6 +302,14 @@ function buildTrayMenu(hubUrl, runtimeId) {
|
|
|
289
302
|
}
|
|
290
303
|
},
|
|
291
304
|
},
|
|
305
|
+
{
|
|
306
|
+
label: 'Open DevTools',
|
|
307
|
+
click: () => {
|
|
308
|
+
void openMainWindowDevTools(hubUrl).catch((error) => {
|
|
309
|
+
console.warn('[fraim] could not open Hub DevTools:', error);
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
},
|
|
292
313
|
{
|
|
293
314
|
// #755: surface the running build version so staleness is diagnosable at a glance.
|
|
294
315
|
label: `About ${name}`,
|
|
@@ -345,6 +366,36 @@ function createTray(hubUrl, runtimeId) {
|
|
|
345
366
|
// ---------------------------------------------------------------------------
|
|
346
367
|
// BrowserWindow
|
|
347
368
|
// ---------------------------------------------------------------------------
|
|
369
|
+
function buildDesktopDebugSnapshot(runtimeId) {
|
|
370
|
+
const win = mainWindow;
|
|
371
|
+
const windowPresent = Boolean(win);
|
|
372
|
+
const isDestroyed = win ? win.isDestroyed() : null;
|
|
373
|
+
const canReadWindow = Boolean(win && !isDestroyed);
|
|
374
|
+
const safeRead = (reader) => {
|
|
375
|
+
if (!canReadWindow)
|
|
376
|
+
return null;
|
|
377
|
+
try {
|
|
378
|
+
return reader();
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
return {
|
|
385
|
+
runtimeId,
|
|
386
|
+
windowPresent,
|
|
387
|
+
isDestroyed,
|
|
388
|
+
isFocused: safeRead(() => win.isFocused()),
|
|
389
|
+
isVisible: safeRead(() => win.isVisible()),
|
|
390
|
+
isMinimized: safeRead(() => win.isMinimized()),
|
|
391
|
+
bounds: safeRead(() => win.getBounds()),
|
|
392
|
+
title: safeRead(() => win.getTitle()),
|
|
393
|
+
url: safeRead(() => win.webContents.getURL()),
|
|
394
|
+
webContentsDestroyed: win ? safeRead(() => win.webContents.isDestroyed()) : null,
|
|
395
|
+
devToolsOpened: safeRead(() => win.webContents.isDevToolsOpened()),
|
|
396
|
+
gpuFeatureStatus: electron_1.app.getGPUFeatureStatus(),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
348
399
|
async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
|
|
349
400
|
const { width, height } = preferredWindowSize();
|
|
350
401
|
const isMac = process.platform === 'darwin';
|
|
@@ -374,6 +425,32 @@ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID ||
|
|
|
374
425
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
|
375
426
|
});
|
|
376
427
|
electron_1.Menu.setApplicationMenu(null);
|
|
428
|
+
mainWindow.on('focus', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.focus'));
|
|
429
|
+
mainWindow.on('blur', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.blur'));
|
|
430
|
+
mainWindow.on('show', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.show'));
|
|
431
|
+
mainWindow.on('hide', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.hide'));
|
|
432
|
+
mainWindow.on('close', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.close', { isQuitting }));
|
|
433
|
+
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
|
434
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.render_process_gone', details);
|
|
435
|
+
});
|
|
436
|
+
mainWindow.webContents.on('unresponsive', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.unresponsive'));
|
|
437
|
+
mainWindow.webContents.on('responsive', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.responsive'));
|
|
438
|
+
mainWindow.webContents.on('did-finish-load', () => {
|
|
439
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.did_finish_load', {
|
|
440
|
+
url: mainWindow?.webContents.getURL() || '',
|
|
441
|
+
title: mainWindow?.getTitle() || '',
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
|
445
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.did_fail_load', {
|
|
446
|
+
errorCode,
|
|
447
|
+
errorDescription,
|
|
448
|
+
validatedURL,
|
|
449
|
+
isMainFrame,
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
mainWindow.webContents.on('devtools-opened', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.devtools_opened'));
|
|
453
|
+
mainWindow.webContents.on('devtools-closed', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.devtools_closed'));
|
|
377
454
|
try {
|
|
378
455
|
await mainWindow.webContents.session.clearCache();
|
|
379
456
|
}
|
|
@@ -448,7 +525,10 @@ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID ||
|
|
|
448
525
|
mainWindow?.hide();
|
|
449
526
|
}
|
|
450
527
|
});
|
|
451
|
-
mainWindow.on('closed', () => {
|
|
528
|
+
mainWindow.on('closed', () => {
|
|
529
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.closed');
|
|
530
|
+
mainWindow = null;
|
|
531
|
+
});
|
|
452
532
|
await mainWindow.loadURL(url);
|
|
453
533
|
mainWindow.webContents.setZoomFactor(1);
|
|
454
534
|
}
|
|
@@ -499,6 +579,9 @@ async function launchDesktopShell(options) {
|
|
|
499
579
|
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
|
|
500
580
|
},
|
|
501
581
|
restartToLatest: restartToLatestNow,
|
|
582
|
+
hubMainDiagnostics: {
|
|
583
|
+
snapshot: () => (0, hub_main_diagnostics_1.getHubMainDiagnosticsSnapshot)(buildDesktopDebugSnapshot(runtimeId)),
|
|
584
|
+
},
|
|
502
585
|
});
|
|
503
586
|
await server.start(httpPort);
|
|
504
587
|
const resolvedProjectPath = server.getProjectPath();
|
|
@@ -566,6 +649,16 @@ async function launchDesktopShell(options) {
|
|
|
566
649
|
async function bootstrap() {
|
|
567
650
|
const options = parseArgs(process.argv.slice(2));
|
|
568
651
|
applyUserDataOverride();
|
|
652
|
+
(0, hub_main_diagnostics_1.installHubMainConsoleTee)();
|
|
653
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.startup', {
|
|
654
|
+
runtimeId: options.runtimeId,
|
|
655
|
+
preferredPort: options.preferredPort,
|
|
656
|
+
hasProjectPath: Boolean(options.projectPath),
|
|
657
|
+
});
|
|
658
|
+
electron_1.app.on('child-process-gone', (_event, details) => {
|
|
659
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('app.child_process_gone', details);
|
|
660
|
+
});
|
|
661
|
+
electron_1.app.on('gpu-info-update', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('app.gpu_info_update'));
|
|
569
662
|
// Single-instance lock — if another instance is already running, focus it
|
|
570
663
|
// and exit rather than spawning a second server + window.
|
|
571
664
|
// Skip when FRAIM_AI_HUB_FAKE_HOST=1 (test mode) so Playwright can launch
|
|
@@ -577,6 +670,7 @@ async function bootstrap() {
|
|
|
577
670
|
return;
|
|
578
671
|
}
|
|
579
672
|
electron_1.app.on('second-instance', () => {
|
|
673
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.second_instance');
|
|
580
674
|
void electron_1.app.whenReady().then(checkForRelaunchAttemptUpdate);
|
|
581
675
|
if (mainWindow) {
|
|
582
676
|
mainWindow.show();
|
|
@@ -624,6 +718,7 @@ async function bootstrap() {
|
|
|
624
718
|
// macOS: clicking dock icon re-shows the window. Unlike Windows/Linux, this never spawns a
|
|
625
719
|
// second process (so 'second-instance' never fires here) — it is macOS's equivalent
|
|
626
720
|
// relaunch-attempt event, so it gets the same npm-latest re-check (#1415).
|
|
721
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.activate');
|
|
627
722
|
checkForRelaunchAttemptUpdate();
|
|
628
723
|
if (mainWindow) {
|
|
629
724
|
mainWindow.show();
|
|
@@ -632,6 +727,7 @@ async function bootstrap() {
|
|
|
632
727
|
});
|
|
633
728
|
electron_1.app.on('before-quit', () => {
|
|
634
729
|
isQuitting = true;
|
|
730
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.shutdown');
|
|
635
731
|
// #755: clear the runtime file so a later `fraim hub` doesn't treat a
|
|
636
732
|
// cleanly-exited instance as live.
|
|
637
733
|
try {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.openHubWindowDevTools = openHubWindowDevTools;
|
|
4
|
+
async function openHubWindowDevTools(options) {
|
|
5
|
+
options.recordOpenRequested?.();
|
|
6
|
+
let window = options.getWindow();
|
|
7
|
+
if (!window || window.isDestroyed()) {
|
|
8
|
+
await options.createWindow(options.hubUrl);
|
|
9
|
+
window = options.getWindow();
|
|
10
|
+
}
|
|
11
|
+
if (!window || window.isDestroyed())
|
|
12
|
+
return 'unavailable';
|
|
13
|
+
window.show();
|
|
14
|
+
window.focus();
|
|
15
|
+
window.webContents.openDevTools({ mode: 'detach' });
|
|
16
|
+
return 'opened';
|
|
17
|
+
}
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.HUB_APP_ENTRY_RELATIVE = exports.HUB_APP_PACKAGE_NAME = void 0;
|
|
7
|
+
exports.redactCredentialLikeSubstrings = redactCredentialLikeSubstrings;
|
|
7
8
|
exports.resolvePackagedCliScriptPath = resolvePackagedCliScriptPath;
|
|
8
9
|
exports.planHubAppMaterialization = planHubAppMaterialization;
|
|
9
10
|
exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
|
|
@@ -33,6 +34,23 @@ const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
|
|
|
33
34
|
// offline matrix is unit-testable without touching npm, disk, or Electron — the same separation
|
|
34
35
|
// `hub-launch-decision.ts` (#755) and `desktop-auto-updater.ts` (#1387) already established for
|
|
35
36
|
// this exact class of problem.
|
|
37
|
+
// Issue #1627 (security review): the raw materialization failure text below is now surfaced to a
|
|
38
|
+
// new, wider audience than before this issue — a persisted log file and the manual restart-to-
|
|
39
|
+
// latest HTTP response/UI — where previously it was silently discarded. `npm install` output does
|
|
40
|
+
// not normally echo credentials, but a misconfigured corporate registry (a literal token in a
|
|
41
|
+
// `.npmrc` URL, verbose proxy auth errors) is a real enough edge case that this new, wider surface
|
|
42
|
+
// should not forward it as-is. Defense-in-depth only: redact credential-shaped substrings before
|
|
43
|
+
// this text reaches either destination.
|
|
44
|
+
const CREDENTIAL_LIKE_PATTERNS = [
|
|
45
|
+
/:\/\/[^\s/@]+:[^\s/@]+@/g, // scheme://user:pass@ basic-auth-in-URL
|
|
46
|
+
/\b(?:_authToken|authToken|Authorization|api[_-]?key|password)\s*[:=]\s*\S+/gi,
|
|
47
|
+
/\bnpm_[A-Za-z0-9]{20,}\b/g, // npm access-token shape
|
|
48
|
+
/\bBearer\s+[A-Za-z0-9._-]{10,}\b/gi,
|
|
49
|
+
];
|
|
50
|
+
/** Strip anything that looks like a credential from text before it is logged or surfaced. */
|
|
51
|
+
function redactCredentialLikeSubstrings(text) {
|
|
52
|
+
return CREDENTIAL_LIKE_PATTERNS.reduce((acc, pattern) => acc.replace(pattern, '[redacted]'), text);
|
|
53
|
+
}
|
|
36
54
|
exports.HUB_APP_PACKAGE_NAME = 'fraim-hub';
|
|
37
55
|
/** Relative path, from a materialized (or bundled) package root, to the Electron main entry. */
|
|
38
56
|
exports.HUB_APP_ENTRY_RELATIVE = path_1.default.join('dist', 'src', 'ai-hub', 'desktop-main.js');
|
|
@@ -68,12 +86,14 @@ function planHubAppMaterialization(input) {
|
|
|
68
86
|
action: 'use-cached',
|
|
69
87
|
version: newestCached,
|
|
70
88
|
reason: 'npm registry unreachable; reusing the newest complete cached version',
|
|
89
|
+
degraded: false,
|
|
71
90
|
};
|
|
72
91
|
}
|
|
73
92
|
return {
|
|
74
93
|
action: 'use-bundled',
|
|
75
94
|
version: bundledVersion,
|
|
76
95
|
reason: 'npm registry unreachable and no cached version available; falling back to the version bundled with this installer',
|
|
96
|
+
degraded: true,
|
|
77
97
|
};
|
|
78
98
|
}
|
|
79
99
|
if (latestPublishedVersion === bundledVersion) {
|
|
@@ -81,6 +101,7 @@ function planHubAppMaterialization(input) {
|
|
|
81
101
|
action: 'use-bundled',
|
|
82
102
|
version: bundledVersion,
|
|
83
103
|
reason: 'the installed shell already matches the latest published version',
|
|
104
|
+
degraded: false,
|
|
84
105
|
};
|
|
85
106
|
}
|
|
86
107
|
const alreadyMaterialized = cachedVersions.some((entry) => entry.complete && entry.version === latestPublishedVersion);
|
|
@@ -89,12 +110,14 @@ function planHubAppMaterialization(input) {
|
|
|
89
110
|
action: 'use-cached',
|
|
90
111
|
version: latestPublishedVersion,
|
|
91
112
|
reason: 'latest published version is already materialized on disk',
|
|
113
|
+
degraded: false,
|
|
92
114
|
};
|
|
93
115
|
}
|
|
94
116
|
return {
|
|
95
117
|
action: 'materialize',
|
|
96
118
|
version: latestPublishedVersion,
|
|
97
119
|
reason: `latest published version ${latestPublishedVersion} is not yet materialized`,
|
|
120
|
+
degraded: false,
|
|
98
121
|
};
|
|
99
122
|
}
|
|
100
123
|
// ---------------------------------------------------------------------------
|
|
@@ -265,7 +288,11 @@ async function resolveHubAppEntry(options) {
|
|
|
265
288
|
cachedVersions,
|
|
266
289
|
});
|
|
267
290
|
if (plan.action === 'use-bundled') {
|
|
268
|
-
|
|
291
|
+
const materializationError = plan.degraded ? plan.reason : undefined;
|
|
292
|
+
if (materializationError) {
|
|
293
|
+
console.error(`[fraim] FRAIM Hub update check could not complete: ${materializationError}`);
|
|
294
|
+
}
|
|
295
|
+
return { entryPath: options.bundledEntryPath, source: 'bundled', version: plan.version, materializationError };
|
|
269
296
|
}
|
|
270
297
|
if (plan.action === 'use-cached') {
|
|
271
298
|
return {
|
|
@@ -279,7 +306,10 @@ async function resolveHubAppEntry(options) {
|
|
|
279
306
|
const entryPath = await ensureMaterializedHubApp(plan.version, { fraimDir, installPackage: options.installPackage });
|
|
280
307
|
return { entryPath, source: 'cached', version: plan.version };
|
|
281
308
|
}
|
|
282
|
-
catch {
|
|
283
|
-
|
|
309
|
+
catch (error) {
|
|
310
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
311
|
+
const materializationError = redactCredentialLikeSubstrings(rawMessage);
|
|
312
|
+
console.error(`[fraim] failed to materialize FRAIM Hub ${plan.version}; falling back to bundled ${options.bundledVersion}: ${materializationError}`);
|
|
313
|
+
return { entryPath: options.bundledEntryPath, source: 'bundled', version: options.bundledVersion, materializationError };
|
|
284
314
|
}
|
|
285
315
|
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.HubMainDiagnostics = void 0;
|
|
7
|
+
exports.getHubMainDiagnostics = getHubMainDiagnostics;
|
|
8
|
+
exports.resetHubMainDiagnosticsForTest = resetHubMainDiagnosticsForTest;
|
|
9
|
+
exports.recordHubMainDiagnostic = recordHubMainDiagnostic;
|
|
10
|
+
exports.getHubMainDiagnosticsSnapshot = getHubMainDiagnosticsSnapshot;
|
|
11
|
+
exports.installHubMainConsoleTee = installHubMainConsoleTee;
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
const node_util_1 = __importDefault(require("node:util"));
|
|
16
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
17
|
+
const version_utils_1 = require("../cli/utils/version-utils");
|
|
18
|
+
const DEFAULT_MAX_EVENTS = 100;
|
|
19
|
+
const DEFAULT_MAX_CURRENT_BYTES = 1024 * 1024;
|
|
20
|
+
const DEFAULT_MAX_RETAINED_CHUNKS = 20;
|
|
21
|
+
const DEFAULT_MAX_RETAINED_BYTES = 50 * 1024 * 1024;
|
|
22
|
+
const MAX_STRING_LENGTH = 1000;
|
|
23
|
+
const SENSITIVE_KEY_PATTERN = /token|secret|password|api[-_]?key|credential|authorization/i;
|
|
24
|
+
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
25
|
+
const SENSITIVE_TEXT_PATTERNS = [
|
|
26
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
27
|
+
/\bsk-[A-Za-z0-9]{48,}\b/g,
|
|
28
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/g,
|
|
29
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
30
|
+
/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g,
|
|
31
|
+
/\bsk_live_[A-Za-z0-9]{24,}\b/g,
|
|
32
|
+
/(?:authorization|api[-_]?key|token|secret|password|credential)(["'\s:=]+)([^"',\s]{8,})/gi,
|
|
33
|
+
];
|
|
34
|
+
function resolveDefaultLogDir() {
|
|
35
|
+
try {
|
|
36
|
+
return node_path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'logs');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return node_path_1.default.join(node_os_1.default.homedir(), '.fraim', 'logs');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function redactSensitiveText(value) {
|
|
43
|
+
let redacted = value.replace(EMAIL_PATTERN, '[redacted-email]');
|
|
44
|
+
for (const pattern of SENSITIVE_TEXT_PATTERNS) {
|
|
45
|
+
redacted = redacted.replace(pattern, (match, separator) => {
|
|
46
|
+
if (typeof separator === 'string' && match.includes(separator)) {
|
|
47
|
+
return match.replace(new RegExp(`${separator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`), `${separator}[redacted]`);
|
|
48
|
+
}
|
|
49
|
+
return '[redacted-secret]';
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return redacted;
|
|
53
|
+
}
|
|
54
|
+
function boundedString(value, max = MAX_STRING_LENGTH) {
|
|
55
|
+
const redacted = redactSensitiveText(value);
|
|
56
|
+
return redacted.length > max ? `${redacted.slice(0, max)}...[truncated ${redacted.length - max} chars]` : redacted;
|
|
57
|
+
}
|
|
58
|
+
function safeFileSegment(value) {
|
|
59
|
+
const sanitized = value.replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '');
|
|
60
|
+
return sanitized || 'hub';
|
|
61
|
+
}
|
|
62
|
+
function sanitizeDetail(value, depth = 0) {
|
|
63
|
+
if (value == null || typeof value === 'boolean' || typeof value === 'number')
|
|
64
|
+
return value;
|
|
65
|
+
if (typeof value === 'string')
|
|
66
|
+
return boundedString(value);
|
|
67
|
+
if (value instanceof Error)
|
|
68
|
+
return { name: value.name, message: boundedString(value.message) };
|
|
69
|
+
if (depth >= 4)
|
|
70
|
+
return '[truncated-depth]';
|
|
71
|
+
if (Array.isArray(value))
|
|
72
|
+
return value.slice(0, 20).map((item) => sanitizeDetail(item, depth + 1));
|
|
73
|
+
if (typeof value === 'object') {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [key, item] of Object.entries(value).slice(0, 40)) {
|
|
76
|
+
out[key] = SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : sanitizeDetail(item, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
return boundedString(String(value));
|
|
81
|
+
}
|
|
82
|
+
function toDetails(details) {
|
|
83
|
+
if (!details)
|
|
84
|
+
return undefined;
|
|
85
|
+
const sanitized = sanitizeDetail(details);
|
|
86
|
+
return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)
|
|
87
|
+
? sanitized
|
|
88
|
+
: { value: sanitized };
|
|
89
|
+
}
|
|
90
|
+
function safeStat(filePath) {
|
|
91
|
+
try {
|
|
92
|
+
return node_fs_1.default.statSync(filePath);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function buildVersions() {
|
|
99
|
+
return {
|
|
100
|
+
node: process.versions.node,
|
|
101
|
+
...(process.versions.electron ? { electron: process.versions.electron } : {}),
|
|
102
|
+
...(process.versions.chrome ? { chrome: process.versions.chrome } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
class HubMainDiagnostics {
|
|
106
|
+
constructor(options = {}) {
|
|
107
|
+
this.events = [];
|
|
108
|
+
this.rotationSequence = 0;
|
|
109
|
+
this.lastWriteError = null;
|
|
110
|
+
this.lastRenderProcessGone = null;
|
|
111
|
+
this.lastChildProcessGone = null;
|
|
112
|
+
this.lastUnresponsiveAt = null;
|
|
113
|
+
this.lastResponsiveAt = null;
|
|
114
|
+
this.runtimeId = options.runtimeId || process.env.FRAIM_HUB_RUNTIME_ID || 'hub';
|
|
115
|
+
this.now = options.now || (() => new Date());
|
|
116
|
+
this.maxEvents = options.maxEvents || DEFAULT_MAX_EVENTS;
|
|
117
|
+
this.maxCurrentBytes = options.maxCurrentBytes || DEFAULT_MAX_CURRENT_BYTES;
|
|
118
|
+
this.maxRetainedChunks = options.maxRetainedChunks || DEFAULT_MAX_RETAINED_CHUNKS;
|
|
119
|
+
this.maxRetainedBytes = options.maxRetainedBytes || DEFAULT_MAX_RETAINED_BYTES;
|
|
120
|
+
this.logDir = node_path_1.default.resolve(options.logDir || resolveDefaultLogDir());
|
|
121
|
+
this.logPath = node_path_1.default.join(this.logDir, 'hub-main.log');
|
|
122
|
+
}
|
|
123
|
+
record(type, details) {
|
|
124
|
+
const event = {
|
|
125
|
+
type,
|
|
126
|
+
timestamp: this.now().toISOString(),
|
|
127
|
+
runtimeId: this.runtimeId,
|
|
128
|
+
pid: process.pid,
|
|
129
|
+
appVersion: (0, version_utils_1.getFraimVersion)(),
|
|
130
|
+
...(details ? { details: toDetails(details) } : {}),
|
|
131
|
+
};
|
|
132
|
+
this.events.push(event);
|
|
133
|
+
if (this.events.length > this.maxEvents)
|
|
134
|
+
this.events.splice(0, this.events.length - this.maxEvents);
|
|
135
|
+
if (type === 'webcontents.render_process_gone')
|
|
136
|
+
this.lastRenderProcessGone = event;
|
|
137
|
+
if (type === 'app.child_process_gone')
|
|
138
|
+
this.lastChildProcessGone = event;
|
|
139
|
+
if (type === 'webcontents.unresponsive')
|
|
140
|
+
this.lastUnresponsiveAt = event.timestamp;
|
|
141
|
+
if (type === 'webcontents.responsive')
|
|
142
|
+
this.lastResponsiveAt = event.timestamp;
|
|
143
|
+
this.persist(event);
|
|
144
|
+
return event;
|
|
145
|
+
}
|
|
146
|
+
snapshot(window = null) {
|
|
147
|
+
const current = safeStat(this.logPath);
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
process: {
|
|
151
|
+
pid: process.pid,
|
|
152
|
+
platform: process.platform,
|
|
153
|
+
versions: buildVersions(),
|
|
154
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
155
|
+
},
|
|
156
|
+
log: {
|
|
157
|
+
path: this.logPath,
|
|
158
|
+
directory: this.logDir,
|
|
159
|
+
exists: Boolean(current),
|
|
160
|
+
bytes: current?.size || 0,
|
|
161
|
+
maxCurrentBytes: this.maxCurrentBytes,
|
|
162
|
+
retainedChunks: this.retainedChunks(),
|
|
163
|
+
lastWriteError: this.lastWriteError,
|
|
164
|
+
},
|
|
165
|
+
window,
|
|
166
|
+
lastEvents: [...this.events],
|
|
167
|
+
lastRenderProcessGone: this.lastRenderProcessGone,
|
|
168
|
+
lastChildProcessGone: this.lastChildProcessGone,
|
|
169
|
+
lastUnresponsiveAt: this.lastUnresponsiveAt,
|
|
170
|
+
lastResponsiveAt: this.lastResponsiveAt,
|
|
171
|
+
capturedAt: this.now().toISOString(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
persist(event) {
|
|
175
|
+
try {
|
|
176
|
+
node_fs_1.default.mkdirSync(this.logDir, { recursive: true });
|
|
177
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
178
|
+
const current = safeStat(this.logPath);
|
|
179
|
+
if (current && current.size > 0 && current.size + Buffer.byteLength(line, 'utf8') > this.maxCurrentBytes) {
|
|
180
|
+
this.rotateCurrentLog();
|
|
181
|
+
}
|
|
182
|
+
node_fs_1.default.appendFileSync(this.logPath, line, 'utf8');
|
|
183
|
+
this.lastWriteError = null;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
this.lastWriteError = error instanceof Error ? boundedString(error.message, 500) : boundedString(String(error), 500);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
rotateCurrentLog() {
|
|
190
|
+
if (!node_fs_1.default.existsSync(this.logPath))
|
|
191
|
+
return;
|
|
192
|
+
const timestamp = this.now().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
193
|
+
const chunkPath = node_path_1.default.join(this.logDir, `hub-main-${timestamp}-${safeFileSegment(this.runtimeId)}-${String(this.rotationSequence).padStart(4, '0')}.log`);
|
|
194
|
+
this.rotationSequence += 1;
|
|
195
|
+
node_fs_1.default.renameSync(this.logPath, chunkPath);
|
|
196
|
+
this.pruneRetainedChunks();
|
|
197
|
+
}
|
|
198
|
+
retainedChunks() {
|
|
199
|
+
try {
|
|
200
|
+
return node_fs_1.default.readdirSync(this.logDir)
|
|
201
|
+
.filter((name) => /^hub-main-.+\.log$/.test(name))
|
|
202
|
+
.map((name) => {
|
|
203
|
+
const filePath = node_path_1.default.join(this.logDir, name);
|
|
204
|
+
const stat = node_fs_1.default.statSync(filePath);
|
|
205
|
+
return { path: filePath, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
|
|
206
|
+
})
|
|
207
|
+
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
pruneRetainedChunks() {
|
|
214
|
+
const chunks = this.retainedChunks();
|
|
215
|
+
let retainedBytes = 0;
|
|
216
|
+
chunks.forEach((chunk, index) => {
|
|
217
|
+
retainedBytes += chunk.bytes;
|
|
218
|
+
if (index < this.maxRetainedChunks && retainedBytes <= this.maxRetainedBytes)
|
|
219
|
+
return;
|
|
220
|
+
try {
|
|
221
|
+
node_fs_1.default.unlinkSync(chunk.path);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Best effort pruning; endpoint still reports remaining chunks.
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
exports.HubMainDiagnostics = HubMainDiagnostics;
|
|
230
|
+
let singleton = null;
|
|
231
|
+
let consoleTeeInstalled = false;
|
|
232
|
+
let inConsoleTee = false;
|
|
233
|
+
function getHubMainDiagnostics() {
|
|
234
|
+
if (!singleton)
|
|
235
|
+
singleton = new HubMainDiagnostics();
|
|
236
|
+
return singleton;
|
|
237
|
+
}
|
|
238
|
+
function resetHubMainDiagnosticsForTest(options = {}) {
|
|
239
|
+
singleton = new HubMainDiagnostics(options);
|
|
240
|
+
return singleton;
|
|
241
|
+
}
|
|
242
|
+
function recordHubMainDiagnostic(type, details) {
|
|
243
|
+
return getHubMainDiagnostics().record(type, details);
|
|
244
|
+
}
|
|
245
|
+
function getHubMainDiagnosticsSnapshot(window = null) {
|
|
246
|
+
return getHubMainDiagnostics().snapshot(window);
|
|
247
|
+
}
|
|
248
|
+
function installHubMainConsoleTee() {
|
|
249
|
+
if (consoleTeeInstalled)
|
|
250
|
+
return;
|
|
251
|
+
consoleTeeInstalled = true;
|
|
252
|
+
const originals = {
|
|
253
|
+
log: console.log.bind(console),
|
|
254
|
+
warn: console.warn.bind(console),
|
|
255
|
+
error: console.error.bind(console),
|
|
256
|
+
};
|
|
257
|
+
const wrap = (level, type) => {
|
|
258
|
+
return (...args) => {
|
|
259
|
+
originals[level](...args);
|
|
260
|
+
if (inConsoleTee)
|
|
261
|
+
return;
|
|
262
|
+
inConsoleTee = true;
|
|
263
|
+
try {
|
|
264
|
+
getHubMainDiagnostics().record(type, { message: node_util_1.default.format(...args) });
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
originals.warn('[fraim] hub main diagnostic console tee failed:', error);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
inConsoleTee = false;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
console.log = wrap('log', 'log.info');
|
|
275
|
+
console.warn = wrap('warn', 'log.warn');
|
|
276
|
+
console.error = wrap('error', 'log.error');
|
|
277
|
+
getHubMainDiagnostics().record('hub.log_tee_installed');
|
|
278
|
+
}
|
|
@@ -69,6 +69,7 @@ const manager_turns_1 = require("./manager-turns");
|
|
|
69
69
|
const preferences_1 = require("./preferences");
|
|
70
70
|
const conversation_store_1 = require("./conversation-store");
|
|
71
71
|
const raw_event_log_store_1 = require("./raw-event-log-store");
|
|
72
|
+
const hub_main_diagnostics_1 = require("./hub-main-diagnostics");
|
|
72
73
|
const run_working_directory_1 = require("./run-working-directory");
|
|
73
74
|
const conversation_search_1 = require("./conversation-search");
|
|
74
75
|
const conversation_search_index_1 = require("./conversation-search-index");
|
|
@@ -3005,6 +3006,7 @@ class AiHubServer {
|
|
|
3005
3006
|
userDataDir: process.env.FRAIM_BROWSER_USER_DATA_DIR || undefined,
|
|
3006
3007
|
explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
|
|
3007
3008
|
});
|
|
3009
|
+
this.hubMainDiagnostics = options.hubMainDiagnostics || { snapshot: () => (0, hub_main_diagnostics_1.getHubMainDiagnosticsSnapshot)(null) };
|
|
3008
3010
|
this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
|
|
3009
3011
|
// Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
|
|
3010
3012
|
// never touches a database. Persona and manager-team state resolve from the hosted server
|
|
@@ -6122,6 +6124,17 @@ class AiHubServer {
|
|
|
6122
6124
|
this.app.get('/api/ai-hub/pid', (_req, res) => {
|
|
6123
6125
|
return res.json({ pid: process.pid });
|
|
6124
6126
|
});
|
|
6127
|
+
this.app.get('/api/ai-hub/debug/window-state', (_req, res) => {
|
|
6128
|
+
try {
|
|
6129
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('debug.window_state_requested');
|
|
6130
|
+
return res.json(this.hubMainDiagnostics.snapshot());
|
|
6131
|
+
}
|
|
6132
|
+
catch (error) {
|
|
6133
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6134
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('debug.window_state_failed', { error: message });
|
|
6135
|
+
return res.status(500).json({ error: message });
|
|
6136
|
+
}
|
|
6137
|
+
});
|
|
6125
6138
|
// Issue #1415/#1379: the update badge's click handler POSTs here instead of just telling the
|
|
6126
6139
|
// user to quit and relaunch by hand. `restartToLatest` (the Electron shell's
|
|
6127
6140
|
// restartToLatestNow) responds before it actually tears the process down, so the client can
|
|
@@ -6485,10 +6498,25 @@ class AiHubServer {
|
|
|
6485
6498
|
return res.status(404).json({ error: 'Configured agent not found or cannot be deleted.' });
|
|
6486
6499
|
return res.json({ ok: true });
|
|
6487
6500
|
});
|
|
6488
|
-
this.app.post('/api/ai-hub/configured-agents/:id/check', (req, res) => {
|
|
6501
|
+
this.app.post('/api/ai-hub/configured-agents/:id/check', async (req, res) => {
|
|
6489
6502
|
if (!this.requireTrustedHubOrigin(req, res))
|
|
6490
6503
|
return;
|
|
6491
|
-
|
|
6504
|
+
// Issue #1618: this is the manager's explicit "check now" action, so it must reflect
|
|
6505
|
+
// reality immediately rather than serving the up-to-5-minute-stale detection cache -
|
|
6506
|
+
// the same reasoning installAgentAndRefreshDetection() already applies after install
|
|
6507
|
+
// (see the comment above that function, server.ts:~2430).
|
|
6508
|
+
(0, hosts_1.invalidateEmployeeDetectionCache)();
|
|
6509
|
+
// Security review (implement-security-review, issue #1618): prefer the non-blocking
|
|
6510
|
+
// parallel probe here for the same reason bootstrapResponse() does (server.ts:~3939) -
|
|
6511
|
+
// invalidating the cache on every click means this route now pays the full re-probe
|
|
6512
|
+
// cost every time instead of at most once per TTL window, so the synchronous form
|
|
6513
|
+
// would let a manager repeatedly clicking Check block the single-threaded Hub event
|
|
6514
|
+
// loop for ~1-3s per click, reintroducing the exact issue-#1010 unresponsiveness this
|
|
6515
|
+
// route previously avoided only by accident (via the cache). Falls back to the sync
|
|
6516
|
+
// form for HostRuntime stubs that do not implement the async variant.
|
|
6517
|
+
const employees = this.hostRuntime.detectEmployeesAsync
|
|
6518
|
+
? await this.hostRuntime.detectEmployeesAsync()
|
|
6519
|
+
: this.hostRuntime.detectEmployees();
|
|
6492
6520
|
const agent = this.configuredAgentsForCurrentMachine(employees).find((entry) => entry.id === req.params.id);
|
|
6493
6521
|
if (!agent)
|
|
6494
6522
|
return res.status(404).json({ error: 'Configured agent not found.' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.311",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"author": "Sid Mathur <sid.mathur@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/mathursrus/FRAIM#readme",
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
"electron-updater": "^6.8.9",
|
|
212
212
|
"express": "^5.2.1",
|
|
213
213
|
"extract-zip": "^2.0.1",
|
|
214
|
-
"fraim": "2.0.
|
|
214
|
+
"fraim": "2.0.311",
|
|
215
215
|
"mongodb": "^7.0.0",
|
|
216
216
|
"node-cron": "4.2.1",
|
|
217
217
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -7642,11 +7642,20 @@ function renderCpAgentPicker() {
|
|
|
7642
7642
|
if (!picker) return;
|
|
7643
7643
|
picker.innerHTML = '';
|
|
7644
7644
|
const list = hubConfiguredAgents();
|
|
7645
|
+
const hasAvailable = list.some((e) => e.available !== false && e.enabled !== false);
|
|
7646
|
+
const footer = picker.closest('.cp-employee-footer');
|
|
7647
|
+
if (footer) footer.hidden = !hasAvailable;
|
|
7645
7648
|
const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false && e.enabled !== false);
|
|
7646
7649
|
if (!curOk) {
|
|
7647
7650
|
const firstAvail = list.find((e) => e.available !== false && e.enabled !== false);
|
|
7648
7651
|
state.cpEmployee = firstAvail ? firstAvail.id : null;
|
|
7649
7652
|
}
|
|
7653
|
+
if (!hasAvailable) {
|
|
7654
|
+
renderCpAgentInstallPanel();
|
|
7655
|
+
const startBtn = document.getElementById('cp-start-btn');
|
|
7656
|
+
if (startBtn) startBtn.disabled = true;
|
|
7657
|
+
return;
|
|
7658
|
+
}
|
|
7650
7659
|
for (const e of list) {
|
|
7651
7660
|
const pill = document.createElement('button');
|
|
7652
7661
|
pill.type = 'button';
|
|
@@ -7808,6 +7817,12 @@ function rerunLastJob() {
|
|
|
7808
7817
|
function showRerunToast(msg) {
|
|
7809
7818
|
const t = document.createElement('div');
|
|
7810
7819
|
t.className = 'cp-rerun-toast';
|
|
7820
|
+
// Issue #1627: matches this app's own convention for transient status text (#be-status,
|
|
7821
|
+
// #status-line, etc.) so a screen reader announces the message instead of it being silently
|
|
7822
|
+
// visual-only - relevant beyond the original re-run case now that this toast also carries the
|
|
7823
|
+
// real reason a restart-to-latest attempt failed.
|
|
7824
|
+
t.setAttribute('role', 'status');
|
|
7825
|
+
t.setAttribute('aria-live', 'polite');
|
|
7811
7826
|
t.textContent = msg;
|
|
7812
7827
|
document.body.appendChild(t);
|
|
7813
7828
|
setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 5000);
|
|
@@ -8079,11 +8094,122 @@ function renderCpAgentInstallPanel() {
|
|
|
8079
8094
|
panel.innerHTML = '';
|
|
8080
8095
|
return;
|
|
8081
8096
|
}
|
|
8082
|
-
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8097
|
+
// Keep setup guidance centralized in Manager -> AI Agents.
|
|
8098
|
+
panel.hidden = false;
|
|
8099
|
+
panel.innerHTML = '';
|
|
8100
|
+
|
|
8101
|
+
const heading = document.createElement('div');
|
|
8102
|
+
heading.className = 'install-panel-heading';
|
|
8103
|
+
heading.textContent = 'No AI agent is set up yet';
|
|
8104
|
+
panel.appendChild(heading);
|
|
8105
|
+
|
|
8106
|
+
const intro = document.createElement('p');
|
|
8107
|
+
intro.className = 'install-panel-copy';
|
|
8108
|
+
intro.textContent = 'Install, sign in, and verify a CLI agent in Manager -> AI Agents, then come back here to start.';
|
|
8109
|
+
panel.appendChild(intro);
|
|
8110
|
+
|
|
8111
|
+
const link = document.createElement('button');
|
|
8112
|
+
link.type = 'button';
|
|
8113
|
+
link.className = 'secondary small';
|
|
8114
|
+
link.textContent = 'Go to Manager -> AI Agents';
|
|
8115
|
+
link.setAttribute('data-testid', 'cp-cli-setup-goto-manager');
|
|
8116
|
+
link.addEventListener('click', () => {
|
|
8117
|
+
closePalette();
|
|
8118
|
+
if (typeof tfShowArea === 'function') tfShowArea('manager');
|
|
8119
|
+
const acc = document.getElementById('manager-agents-acc');
|
|
8120
|
+
if (acc) {
|
|
8121
|
+
acc.open = true;
|
|
8122
|
+
acc.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
8123
|
+
}
|
|
8086
8124
|
});
|
|
8125
|
+
panel.appendChild(link);
|
|
8126
|
+
}
|
|
8127
|
+
|
|
8128
|
+
// Issue #1618 (R3): the single shared install/sign-in row builder. Used both by the job
|
|
8129
|
+
// composer's standalone panels (renderAgentInstallPanelInto's loop below) and by the
|
|
8130
|
+
// Manager AI Agents panel's per-card mount (renderConfiguredAgentsPanel), so the two
|
|
8131
|
+
// surfaces share one state machine and cannot drift out of sync. `options.testPrefix`
|
|
8132
|
+
// namespaces testids per mount point (e.g. 'hub-agent' vs 'manager-agent').
|
|
8133
|
+
// PR feedback (#1620): `options.showLabel` (default true) and `options.resetLabel`
|
|
8134
|
+
// (default 'Choose another agent') let a mount point adapt wording/labeling without a
|
|
8135
|
+
// second implementation. `options.buttonClass`/`options.secondaryButtonClass` (default
|
|
8136
|
+
// 'secondary small'/'ghost small', the job composer's own standalone-panel button style)
|
|
8137
|
+
// let a mount point match its own surrounding card/button language instead. The job
|
|
8138
|
+
// composer's 3 mounts pass none of these and are unchanged.
|
|
8139
|
+
function buildAgentInstallRow(emp, options) {
|
|
8140
|
+
const buttonClass = options.buttonClass || 'secondary small';
|
|
8141
|
+
const secondaryButtonClass = options.secondaryButtonClass || 'ghost small';
|
|
8142
|
+
|
|
8143
|
+
const row = document.createElement('div');
|
|
8144
|
+
row.className = 'install-row';
|
|
8145
|
+
row.id = `install-row-${emp.id}`;
|
|
8146
|
+
|
|
8147
|
+
if (options.showLabel !== false) {
|
|
8148
|
+
const label = document.createElement('span');
|
|
8149
|
+
label.className = 'install-label';
|
|
8150
|
+
label.textContent = emp.label;
|
|
8151
|
+
row.appendChild(label);
|
|
8152
|
+
}
|
|
8153
|
+
|
|
8154
|
+
const status = document.createElement('span');
|
|
8155
|
+
status.className = 'install-status';
|
|
8156
|
+
status.id = `install-status-${emp.id}`;
|
|
8157
|
+
status.textContent = agentInstallState[emp.id]?.statusText || '';
|
|
8158
|
+
row.appendChild(status);
|
|
8159
|
+
|
|
8160
|
+
const btn = document.createElement('button');
|
|
8161
|
+
btn.className = buttonClass;
|
|
8162
|
+
btn.id = `install-btn-${emp.id}`;
|
|
8163
|
+
btn.dataset.hubId = emp.id;
|
|
8164
|
+
|
|
8165
|
+
const st = agentInstallState[emp.id] || {};
|
|
8166
|
+
if (!st.phase) {
|
|
8167
|
+
btn.textContent = `Download / Install ${emp.label}`;
|
|
8168
|
+
btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
|
|
8169
|
+
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
8170
|
+
} else if (st.phase === 'installing') {
|
|
8171
|
+
btn.textContent = 'Installing...';
|
|
8172
|
+
btn.disabled = true;
|
|
8173
|
+
} else if (st.phase === 'needs-login') {
|
|
8174
|
+
btn.textContent = 'Sign In';
|
|
8175
|
+
btn.addEventListener('click', () => triggerAgentLogin(emp.id));
|
|
8176
|
+
} else if (st.phase === 'login-triggered') {
|
|
8177
|
+
const checkBtn = document.createElement('button');
|
|
8178
|
+
checkBtn.className = buttonClass;
|
|
8179
|
+
checkBtn.textContent = 'Check if Ready';
|
|
8180
|
+
checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
|
|
8181
|
+
checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
|
|
8182
|
+
row.appendChild(checkBtn);
|
|
8183
|
+
|
|
8184
|
+
const skipBtn = document.createElement('button');
|
|
8185
|
+
skipBtn.className = secondaryButtonClass;
|
|
8186
|
+
// PR feedback (#1620): "Choose another agent" describes the job composer's picker
|
|
8187
|
+
// context (abandon this one, pick a different employee/tool). The Manager panel has
|
|
8188
|
+
// no picker to return to — clicking this just resets THIS card's install phase — so
|
|
8189
|
+
// that mount passes a clearer label via options.resetLabel.
|
|
8190
|
+
skipBtn.textContent = options.resetLabel || 'Choose another agent';
|
|
8191
|
+
skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
|
|
8192
|
+
skipBtn.style.marginLeft = '6px';
|
|
8193
|
+
skipBtn.addEventListener('click', () => {
|
|
8194
|
+
delete agentInstallState[emp.id];
|
|
8195
|
+
renderAgentInstallPanel();
|
|
8196
|
+
renderHubAgentSetupPanel();
|
|
8197
|
+
renderCpAgentInstallPanel();
|
|
8198
|
+
renderConfiguredAgentsPanel();
|
|
8199
|
+
});
|
|
8200
|
+
row.appendChild(skipBtn);
|
|
8201
|
+
return row;
|
|
8202
|
+
} else if (st.phase === 'ready') {
|
|
8203
|
+
btn.textContent = 'Ready';
|
|
8204
|
+
btn.disabled = true;
|
|
8205
|
+
btn.style.color = 'var(--accent)';
|
|
8206
|
+
} else if (st.phase === 'error') {
|
|
8207
|
+
btn.textContent = 'Retry';
|
|
8208
|
+
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
8209
|
+
}
|
|
8210
|
+
|
|
8211
|
+
row.appendChild(btn);
|
|
8212
|
+
return row;
|
|
8087
8213
|
}
|
|
8088
8214
|
|
|
8089
8215
|
function renderAgentInstallPanelInto(panel, options) {
|
|
@@ -8113,70 +8239,7 @@ function renderAgentInstallPanelInto(panel, options) {
|
|
|
8113
8239
|
}
|
|
8114
8240
|
|
|
8115
8241
|
for (const emp of unavailable) {
|
|
8116
|
-
|
|
8117
|
-
row.className = 'install-row';
|
|
8118
|
-
row.id = `install-row-${emp.id}`;
|
|
8119
|
-
|
|
8120
|
-
const label = document.createElement('span');
|
|
8121
|
-
label.className = 'install-label';
|
|
8122
|
-
label.textContent = emp.label;
|
|
8123
|
-
row.appendChild(label);
|
|
8124
|
-
|
|
8125
|
-
const status = document.createElement('span');
|
|
8126
|
-
status.className = 'install-status';
|
|
8127
|
-
status.id = `install-status-${emp.id}`;
|
|
8128
|
-
status.textContent = agentInstallState[emp.id]?.statusText || '';
|
|
8129
|
-
row.appendChild(status);
|
|
8130
|
-
|
|
8131
|
-
const btn = document.createElement('button');
|
|
8132
|
-
btn.className = 'secondary small';
|
|
8133
|
-
btn.id = `install-btn-${emp.id}`;
|
|
8134
|
-
btn.dataset.hubId = emp.id;
|
|
8135
|
-
|
|
8136
|
-
const st = agentInstallState[emp.id] || {};
|
|
8137
|
-
if (!st.phase) {
|
|
8138
|
-
btn.textContent = `Download / Install ${emp.label}`;
|
|
8139
|
-
btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
|
|
8140
|
-
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
8141
|
-
} else if (st.phase === 'installing') {
|
|
8142
|
-
btn.textContent = 'Installing...';
|
|
8143
|
-
btn.disabled = true;
|
|
8144
|
-
} else if (st.phase === 'needs-login') {
|
|
8145
|
-
btn.textContent = 'Sign In';
|
|
8146
|
-
btn.addEventListener('click', () => triggerAgentLogin(emp.id));
|
|
8147
|
-
} else if (st.phase === 'login-triggered') {
|
|
8148
|
-
const checkBtn = document.createElement('button');
|
|
8149
|
-
checkBtn.className = 'secondary small';
|
|
8150
|
-
checkBtn.textContent = 'Check if Ready';
|
|
8151
|
-
checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
|
|
8152
|
-
checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
|
|
8153
|
-
row.appendChild(checkBtn);
|
|
8154
|
-
|
|
8155
|
-
const skipBtn = document.createElement('button');
|
|
8156
|
-
skipBtn.className = 'ghost small';
|
|
8157
|
-
skipBtn.textContent = 'Choose another agent';
|
|
8158
|
-
skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
|
|
8159
|
-
skipBtn.style.marginLeft = '6px';
|
|
8160
|
-
skipBtn.addEventListener('click', () => {
|
|
8161
|
-
delete agentInstallState[emp.id];
|
|
8162
|
-
renderAgentInstallPanel();
|
|
8163
|
-
renderHubAgentSetupPanel();
|
|
8164
|
-
renderCpAgentInstallPanel();
|
|
8165
|
-
});
|
|
8166
|
-
row.appendChild(skipBtn);
|
|
8167
|
-
panel.appendChild(row);
|
|
8168
|
-
continue;
|
|
8169
|
-
} else if (st.phase === 'ready') {
|
|
8170
|
-
btn.textContent = 'Ready';
|
|
8171
|
-
btn.disabled = true;
|
|
8172
|
-
btn.style.color = 'var(--accent)';
|
|
8173
|
-
} else if (st.phase === 'error') {
|
|
8174
|
-
btn.textContent = 'Retry';
|
|
8175
|
-
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
8176
|
-
}
|
|
8177
|
-
|
|
8178
|
-
row.appendChild(btn);
|
|
8179
|
-
panel.appendChild(row);
|
|
8242
|
+
panel.appendChild(buildAgentInstallRow(emp, options));
|
|
8180
8243
|
}
|
|
8181
8244
|
}
|
|
8182
8245
|
|
|
@@ -8186,6 +8249,10 @@ function setInstallState(hubId, phase, statusText) {
|
|
|
8186
8249
|
renderHubAgentSetupPanel();
|
|
8187
8250
|
renderCpAgentInstallPanel();
|
|
8188
8251
|
renderCpAgentPicker();
|
|
8252
|
+
// Issue #1618: the Manager AI Agents panel's per-card install row shares this same
|
|
8253
|
+
// agentInstallState, so it must refresh at the same choke point as the job composer's
|
|
8254
|
+
// three panels or it drifts out of sync with them.
|
|
8255
|
+
renderConfiguredAgentsPanel();
|
|
8189
8256
|
}
|
|
8190
8257
|
|
|
8191
8258
|
// Issue #1256 (slice c, AC-C4): backstop above the server's own AGENT_INSTALL_TIMEOUT_MS
|
|
@@ -8257,6 +8324,9 @@ async function refreshEmployees() {
|
|
|
8257
8324
|
renderAgentInstallPanel();
|
|
8258
8325
|
renderHubAgentSetupPanel();
|
|
8259
8326
|
renderCpAgentInstallPanel();
|
|
8327
|
+
// Issue #1618: the Manager AI Agents panel's per-card install row depends on the same
|
|
8328
|
+
// refreshed employee roster (e.g. after "Check if Ready" confirms a host is now available).
|
|
8329
|
+
renderConfiguredAgentsPanel();
|
|
8260
8330
|
} catch { /* best-effort */ }
|
|
8261
8331
|
}
|
|
8262
8332
|
|
|
@@ -9166,6 +9236,26 @@ function renderConfiguredAgentsPanel() {
|
|
|
9166
9236
|
const renderedAvailable = latestCheck ? latestCheck.available !== false : agent.available !== false;
|
|
9167
9237
|
const renderedEnabled = agent.enabled !== false;
|
|
9168
9238
|
const renderedReasons = latestCheck?.reasons || agent.reasons || [];
|
|
9239
|
+
|
|
9240
|
+
// Issue #1618 (R2/R3), moved up (PR feedback #1620): computed before the actions
|
|
9241
|
+
// row so the redundant top-of-card "Check" button can be omitted whenever the
|
|
9242
|
+
// install row itself is about to render — the install row's own state machine
|
|
9243
|
+
// (Download/Install -> Sign In -> Check if Ready) already provides a live-check
|
|
9244
|
+
// path once installed, and "Check" against a not-yet-installed host communicates
|
|
9245
|
+
// nothing the status pill/status pill text does not already say.
|
|
9246
|
+
//
|
|
9247
|
+
// Gated strictly on the underlying host CLI's own readiness (`emp.available ===
|
|
9248
|
+
// false`), never on the configured-agent's `enabled` flag or any other not-ready
|
|
9249
|
+
// reason (PR feedback #1620: an earlier, broader formula also showed the row for a
|
|
9250
|
+
// profile that was merely disabled, or one with a good host but a broken/missing
|
|
9251
|
+
// setup script -- neither of which installing/signing-in the CLI would fix; Edit
|
|
9252
|
+
// is the correct path for both). `renderedAvailable` already prefers a fresh
|
|
9253
|
+
// `latestCheck` result over the possibly-stale roster snapshot, so clicking "Check
|
|
9254
|
+
// if Ready" inside the row hides the row (and restores the top Check button)
|
|
9255
|
+
// immediately once ready.
|
|
9256
|
+
const emp = hubEmployees().find((e) => e.id === agent.baseHostId);
|
|
9257
|
+
const showInstallRow = emp ? emp.available === false : false;
|
|
9258
|
+
|
|
9169
9259
|
const card = document.createElement('div');
|
|
9170
9260
|
card.className = 'configured-agent-card';
|
|
9171
9261
|
card.dataset.testid = 'configured-agent-card';
|
|
@@ -9181,21 +9271,23 @@ function renderConfiguredAgentsPanel() {
|
|
|
9181
9271
|
|
|
9182
9272
|
const actions = document.createElement('div');
|
|
9183
9273
|
actions.className = 'configured-agent-card-actions';
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9274
|
+
if (!showInstallRow) {
|
|
9275
|
+
const check = document.createElement('button');
|
|
9276
|
+
check.type = 'button';
|
|
9277
|
+
check.className = 'configured-agent-icon-action';
|
|
9278
|
+
check.textContent = 'Check';
|
|
9279
|
+
check.addEventListener('click', async () => {
|
|
9280
|
+
try {
|
|
9281
|
+
const result = await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}/check`, { method: 'POST' });
|
|
9282
|
+
state.configuredAgentCheckResults[agent.id] = result;
|
|
9283
|
+
showStatus(result.available ? `${agent.label} is ready.` : `${agent.label}: ${(result.reasons || []).join(' ') || 'Check setup.'}`, !result.available);
|
|
9284
|
+
renderConfiguredAgentsPanel();
|
|
9285
|
+
} catch (err) {
|
|
9286
|
+
showStatus(err.message || 'Agent check failed.', true);
|
|
9287
|
+
}
|
|
9288
|
+
});
|
|
9289
|
+
actions.appendChild(check);
|
|
9290
|
+
}
|
|
9199
9291
|
|
|
9200
9292
|
const edit = document.createElement('button');
|
|
9201
9293
|
edit.type = 'button';
|
|
@@ -9243,6 +9335,38 @@ function renderConfiguredAgentsPanel() {
|
|
|
9243
9335
|
card.appendChild(head);
|
|
9244
9336
|
card.appendChild(meta);
|
|
9245
9337
|
card.appendChild(detail);
|
|
9338
|
+
|
|
9339
|
+
// Issue #1618 (R2/R3/R4): a per-card install/sign-in affordance for an agent
|
|
9340
|
+
// that isn't ready, reusing the job composer's shared row builder rather than a
|
|
9341
|
+
// second parallel implementation. `showInstallRow` is computed above (it also
|
|
9342
|
+
// gates the top "Check" button). The buttons are styled with the card's own
|
|
9343
|
+
// `.configured-agent-icon-action` class (PR feedback #1620: the job composer's
|
|
9344
|
+
// `.secondary small`/`.ghost small` classes read as a visually different, duller
|
|
9345
|
+
// button style next to Check/Edit/Delete) instead of the job composer's own
|
|
9346
|
+
// standalone-panel button classes.
|
|
9347
|
+
if (showInstallRow) {
|
|
9348
|
+
const installWrap = document.createElement('div');
|
|
9349
|
+
installWrap.className = 'configured-agent-install-row';
|
|
9350
|
+
installWrap.appendChild(buildAgentInstallRow(
|
|
9351
|
+
emp || { id: agent.baseHostId, label: agent.label, available: false },
|
|
9352
|
+
// PR feedback (#1620): the card's own title already names this configured
|
|
9353
|
+
// agent, so the row's own `emp.label` line (the underlying host's label,
|
|
9354
|
+
// which can legitimately differ from a custom configured-agent label) is
|
|
9355
|
+
// suppressed here to avoid reading as a second, unrelated agent name.
|
|
9356
|
+
// "Choose another agent" is replaced with "Start over" since there is no
|
|
9357
|
+
// agent picker on this panel to return to -- the button only resets this
|
|
9358
|
+
// card's own install phase.
|
|
9359
|
+
{
|
|
9360
|
+
testPrefix: 'manager-agent',
|
|
9361
|
+
showLabel: false,
|
|
9362
|
+
resetLabel: 'Start over',
|
|
9363
|
+
buttonClass: 'configured-agent-icon-action',
|
|
9364
|
+
secondaryButtonClass: 'configured-agent-icon-action',
|
|
9365
|
+
},
|
|
9366
|
+
));
|
|
9367
|
+
card.appendChild(installWrap);
|
|
9368
|
+
}
|
|
9369
|
+
|
|
9246
9370
|
panel.appendChild(card);
|
|
9247
9371
|
}
|
|
9248
9372
|
}
|
|
@@ -16994,23 +17118,42 @@ function tfRestartHubToLatest(event) {
|
|
|
16994
17118
|
badgeEl.textContent = '⬆️ Restarting…';
|
|
16995
17119
|
}
|
|
16996
17120
|
fetch('/api/ai-hub/restart-to-latest', { method: 'POST' })
|
|
16997
|
-
.then((r) =>
|
|
16998
|
-
.then((
|
|
16999
|
-
if (
|
|
17000
|
-
|
|
17001
|
-
|
|
17002
|
-
|
|
17003
|
-
badgeEl.textContent = '⬆️ Updates available';
|
|
17121
|
+
.then((r) => r.json().catch(() => ({})).then((body) => ({ ok: r.ok, body })))
|
|
17122
|
+
.then(({ ok, body }) => {
|
|
17123
|
+
if (!ok) throw new Error((body && body.error) || 'Could not update FRAIM Hub.');
|
|
17124
|
+
if (body && body.restarting) {
|
|
17125
|
+
tfPollForHubRestart(body.latest || null, 0);
|
|
17126
|
+
return;
|
|
17004
17127
|
}
|
|
17128
|
+
// Issue #1627: restarting:false used to be treated as "nothing to do" unconditionally, so a
|
|
17129
|
+
// genuine materialization failure looked identical to "already current" and the badge just
|
|
17130
|
+
// silently reverted with no explanation. An `error` here means the restart was attempted and
|
|
17131
|
+
// failed - show the real reason instead of pretending the click did nothing.
|
|
17132
|
+
tfShowHubRestartFailure(badgeEl, body && body.error);
|
|
17005
17133
|
})
|
|
17006
|
-
.catch(() => {
|
|
17007
|
-
|
|
17008
|
-
badgeEl.disabled = false;
|
|
17009
|
-
badgeEl.textContent = '⬆️ Updates available';
|
|
17010
|
-
}
|
|
17134
|
+
.catch((err) => {
|
|
17135
|
+
tfShowHubRestartFailure(badgeEl, err && err.message);
|
|
17011
17136
|
});
|
|
17012
17137
|
}
|
|
17013
17138
|
|
|
17139
|
+
// Issue #1627: how long the badge shows "Restart failed" before settling back to its steady
|
|
17140
|
+
// "an update is still available, click to try again" state.
|
|
17141
|
+
const HUB_RESTART_FAILURE_DISPLAY_MS = 8000;
|
|
17142
|
+
|
|
17143
|
+
function tfShowHubRestartFailure(badgeEl, reason) {
|
|
17144
|
+
const message = reason
|
|
17145
|
+
? `Could not update FRAIM Hub: ${reason}. Check your network or npm access, then click to try again.`
|
|
17146
|
+
: 'Could not update FRAIM Hub. Check your network or npm access, then click to try again.';
|
|
17147
|
+
showRerunToast(message);
|
|
17148
|
+
if (!badgeEl) return;
|
|
17149
|
+
badgeEl.disabled = false;
|
|
17150
|
+
badgeEl.textContent = '⬆️ Restart failed';
|
|
17151
|
+
badgeEl.title = message;
|
|
17152
|
+
setTimeout(() => {
|
|
17153
|
+
if (badgeEl.textContent === '⬆️ Restart failed') badgeEl.textContent = '⬆️ Updates available';
|
|
17154
|
+
}, HUB_RESTART_FAILURE_DISPLAY_MS);
|
|
17155
|
+
}
|
|
17156
|
+
|
|
17014
17157
|
const HUB_RESTART_POLL_MS = 1000;
|
|
17015
17158
|
const HUB_RESTART_POLL_MAX_ATTEMPTS = 45; // ~45s: generous for a fresh materialize-from-npm relaunch
|
|
17016
17159
|
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -6198,6 +6198,32 @@ img.eh-av { object-fit: cover; background: var(--surface); }
|
|
|
6198
6198
|
overflow-wrap: anywhere;
|
|
6199
6199
|
}
|
|
6200
6200
|
|
|
6201
|
+
/* Issue #1618 (R4): the per-card install/sign-in row is a continuation of the existing
|
|
6202
|
+
card, not a second panel — a dashed top border separates it from Check/Edit/Delete
|
|
6203
|
+
above, matching the mock (docs/feature-specs/mocks/1618-view.html). The shared
|
|
6204
|
+
.install-row/.install-label/.install-status/button.secondary.small/.ghost.small rules
|
|
6205
|
+
above are reused, scoped-overridden only where the card's ~750px width behaves
|
|
6206
|
+
differently than the job composer's ~340px standalone panel (PR #1620 feedback):
|
|
6207
|
+
`.install-status`'s `flex:1` is designed to fill a narrow panel; at card width an
|
|
6208
|
+
empty status span balloons into a large, unstyled-looking blank gap before the
|
|
6209
|
+
action button. Capping it to content width and right-aligning the button instead
|
|
6210
|
+
(matching the Check/Edit/Delete row's own right-aligned actions) keeps the same
|
|
6211
|
+
information, without a wide accidental gap. Only `.configured-agent-install-row`
|
|
6212
|
+
is affected; the 3 job-composer mounts keep their original layout unchanged. */
|
|
6213
|
+
.configured-agent-install-row {
|
|
6214
|
+
margin-top: 8px;
|
|
6215
|
+
padding-top: 8px;
|
|
6216
|
+
border-top: 1px dashed var(--line);
|
|
6217
|
+
}
|
|
6218
|
+
|
|
6219
|
+
.configured-agent-install-row .install-status {
|
|
6220
|
+
flex: 0 1 auto;
|
|
6221
|
+
}
|
|
6222
|
+
|
|
6223
|
+
.configured-agent-install-row .install-row button:first-of-type {
|
|
6224
|
+
margin-left: auto;
|
|
6225
|
+
}
|
|
6226
|
+
|
|
6201
6227
|
.configured-agents-empty {
|
|
6202
6228
|
margin: 0;
|
|
6203
6229
|
color: var(--muted);
|