fraim-hub 2.0.268 → 2.0.270
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 +14 -0
- package/dist/src/ai-hub/cli.js +88 -32
- package/dist/src/ai-hub/conversation-store-lock.js +2 -11
- package/dist/src/ai-hub/desktop-main.js +21 -9
- package/dist/src/ai-hub/electron-dist.js +265 -0
- package/dist/src/ai-hub/hosts.js +7 -2
- package/dist/src/ai-hub/process-liveness.js +25 -0
- package/dist/src/ai-hub/raw-event-log-store.js +110 -0
- package/dist/src/ai-hub/remote-hub-gateway.js +6 -5
- package/dist/src/ai-hub/server.js +253 -81
- package/dist/src/ai-hub/ui-runtime.js +5 -34
- package/package.json +5 -3
- package/public/ai-hub/script.js +70 -42
- package/public/ai-hub/styles.css +207 -180
|
@@ -7,6 +7,7 @@ exports.summarizeProject = summarizeProject;
|
|
|
7
7
|
exports.discoverEmployeeJobs = discoverEmployeeJobs;
|
|
8
8
|
exports.discoverManagerTemplates = discoverManagerTemplates;
|
|
9
9
|
exports.loadJobPhases = loadJobPhases;
|
|
10
|
+
exports.resolveJobPhaseTransition = resolveJobPhaseTransition;
|
|
10
11
|
exports.loadAllJobPhaseIds = loadAllJobPhaseIds;
|
|
11
12
|
exports.labelForPhaseId = labelForPhaseId;
|
|
12
13
|
exports.getAiHubCategories = getAiHubCategories;
|
|
@@ -470,6 +471,19 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
|
|
|
470
471
|
// this job before appending it to the tracker, so cross-job pollution
|
|
471
472
|
// (agent calling seekMentoring for a different job mid-run) cannot
|
|
472
473
|
// surface stages from elsewhere.
|
|
474
|
+
function resolveJobPhaseTransition(jobId, projectPath, phaseId, outcome, discriminant = 'feature') {
|
|
475
|
+
const stubPath = findJobStubPath(projectPath, jobId);
|
|
476
|
+
if (!stubPath)
|
|
477
|
+
return null;
|
|
478
|
+
const fm = readJobFrontmatter(stubPath);
|
|
479
|
+
if (!fm || !fm.phases)
|
|
480
|
+
return null;
|
|
481
|
+
const phaseDef = fm.phases[phaseId];
|
|
482
|
+
if (!phaseDef)
|
|
483
|
+
return null;
|
|
484
|
+
const edge = outcome === 'complete' ? phaseDef.onSuccess : phaseDef.onFailure;
|
|
485
|
+
return nextPhase(edge, discriminant);
|
|
486
|
+
}
|
|
473
487
|
function loadAllJobPhaseIds(jobId, projectPath) {
|
|
474
488
|
const stubPath = findJobStubPath(projectPath, jobId);
|
|
475
489
|
if (!stubPath)
|
package/dist/src/ai-hub/cli.js
CHANGED
|
@@ -36,6 +36,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.DESKTOP_HUB_READY_TIMEOUT_ENV = void 0;
|
|
40
|
+
exports.resolveDesktopReadyTimeoutMs = resolveDesktopReadyTimeoutMs;
|
|
39
41
|
exports.waitForDesktopHubReady = waitForDesktopHubReady;
|
|
40
42
|
exports.runHub = runHub;
|
|
41
43
|
// Hub-owned launcher used by the fraim-hub package. Keep this outside
|
|
@@ -51,15 +53,8 @@ const hub_launch_decision_1 = require("./hub-launch-decision");
|
|
|
51
53
|
const hub_runtime_file_1 = require("./hub-runtime-file");
|
|
52
54
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
53
55
|
const version_utils_1 = require("../cli/utils/version-utils");
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
57
|
-
return require('electron');
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
56
|
+
const electron_dist_1 = require("./electron-dist");
|
|
57
|
+
const process_liveness_1 = require("./process-liveness");
|
|
63
58
|
function resolveDesktopEntry() {
|
|
64
59
|
const candidates = [
|
|
65
60
|
path_1.default.resolve(__dirname, 'desktop-main.js'),
|
|
@@ -76,10 +71,20 @@ function resolveDesktopEntry() {
|
|
|
76
71
|
}
|
|
77
72
|
return null;
|
|
78
73
|
}
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
// #1112: the Electron binary is resolved (and, on a first launch for a version, extracted) before
|
|
75
|
+
// the spawn, not inside waitForDesktopHubReady. A one-time extraction is minutes of work on a slow
|
|
76
|
+
// disk; charging it to the readiness budget is exactly the mistake #1110 fixed, so it happens here
|
|
77
|
+
// with progress on stdout while nothing is being waited on.
|
|
78
|
+
async function openDesktopWindow(projectPath, preferredPort, runtimeId) {
|
|
81
79
|
const desktopEntry = resolveDesktopEntry();
|
|
82
|
-
if (!
|
|
80
|
+
if (!desktopEntry) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const electron = await (0, electron_dist_1.resolveHubElectronBinary)({
|
|
84
|
+
onProgress: (message) => console.log(message),
|
|
85
|
+
onError: (error) => console.log(`Could not prepare the shared Electron runtime (${error.message}). Opening the FRAIM Hub in your browser instead.`),
|
|
86
|
+
});
|
|
87
|
+
if (!electron) {
|
|
83
88
|
return null;
|
|
84
89
|
}
|
|
85
90
|
const args = projectPath
|
|
@@ -88,7 +93,7 @@ function openDesktopWindow(projectPath, preferredPort, runtimeId) {
|
|
|
88
93
|
if (runtimeId && runtimeId !== 'hub') {
|
|
89
94
|
args.push('--hub-runtime-id', runtimeId);
|
|
90
95
|
}
|
|
91
|
-
const child = (0, child_process_1.spawn)(
|
|
96
|
+
const child = (0, child_process_1.spawn)(electron.binaryPath, args, {
|
|
92
97
|
detached: true,
|
|
93
98
|
stdio: 'ignore',
|
|
94
99
|
});
|
|
@@ -108,15 +113,6 @@ function openBrowser(url) {
|
|
|
108
113
|
const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
109
114
|
child.unref();
|
|
110
115
|
}
|
|
111
|
-
function isProcessAlive(pid) {
|
|
112
|
-
try {
|
|
113
|
-
process.kill(pid, 0);
|
|
114
|
-
return true;
|
|
115
|
-
}
|
|
116
|
-
catch (e) {
|
|
117
|
-
return !!(e && e.code === 'EPERM');
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
116
|
function killPid(pid) {
|
|
121
117
|
try {
|
|
122
118
|
if (process.platform === 'win32') {
|
|
@@ -207,9 +203,55 @@ function fetchRunningHubVersion(port) {
|
|
|
207
203
|
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
208
204
|
});
|
|
209
205
|
}
|
|
206
|
+
// #1110: the desktop shell's cold-start cost is work this launcher can neither see nor bound.
|
|
207
|
+
// Before the Hub can answer /api/ai-hub/version it pays for Electron process boot, top-level
|
|
208
|
+
// evaluation of the src/ai-hub/server.ts module graph, cert load plus a blocking certutil
|
|
209
|
+
// trust call, and finally the listens. The same path measured 3.3s on an idle machine and
|
|
210
|
+
// 20.5s on a real `npx fraim-hub@latest --restart` immediately after a release, when npm has
|
|
211
|
+
// just written a fresh Electron dist. A fixed budget inside that spread turns a healthy launch
|
|
212
|
+
// into a reported failure, which is what issue #1110 reports.
|
|
213
|
+
//
|
|
214
|
+
// The only positive evidence of a failed launch is the child dying, and that is handled
|
|
215
|
+
// separately below. A live child that has not answered yet is still starting, so the wait is
|
|
216
|
+
// long, says so out loud while it waits, and stays tunable for slower machines.
|
|
217
|
+
exports.DESKTOP_HUB_READY_TIMEOUT_ENV = 'FRAIM_HUB_READY_TIMEOUT_MS';
|
|
218
|
+
const DEFAULT_DESKTOP_HUB_READY_TIMEOUT_MS = 120000;
|
|
219
|
+
const DEFAULT_DESKTOP_HUB_READY_PROGRESS_MS = 15000;
|
|
220
|
+
function resolveDesktopReadyTimeoutMs() {
|
|
221
|
+
const configured = Number(process.env[exports.DESKTOP_HUB_READY_TIMEOUT_ENV]);
|
|
222
|
+
if (Number.isFinite(configured) && configured > 0)
|
|
223
|
+
return configured;
|
|
224
|
+
return DEFAULT_DESKTOP_HUB_READY_TIMEOUT_MS;
|
|
225
|
+
}
|
|
226
|
+
// Whole seconds read best at the real cadence (15s, 30s, 120s), but rounding a sub-second
|
|
227
|
+
// duration to whole seconds prints "after 0s" or repeats "1s elapsed" on consecutive notices.
|
|
228
|
+
// Keep one decimal below this threshold so the number is never wrong at any budget.
|
|
229
|
+
const READY_SECONDS_DECIMAL_BELOW_MS = 10000;
|
|
230
|
+
function formatReadySeconds(durationMs) {
|
|
231
|
+
return durationMs >= READY_SECONDS_DECIMAL_BELOW_MS
|
|
232
|
+
? `${Math.round(durationMs / 1000)}s`
|
|
233
|
+
: `${Math.round(durationMs / 100) / 10}s`;
|
|
234
|
+
}
|
|
235
|
+
function reportDesktopReadyProgress(progress) {
|
|
236
|
+
console.log(`Still waiting for the FRAIM Hub desktop shell on port ${progress.port} `
|
|
237
|
+
+ `(${formatReadySeconds(progress.elapsedMs)} elapsed, giving up at ${formatReadySeconds(progress.timeoutMs)}). `
|
|
238
|
+
+ 'A first launch after an upgrade unpacks Electron, so this can take a while.');
|
|
239
|
+
}
|
|
240
|
+
// Where the Hub could be answering: the shell records its chosen port in the runtime file once
|
|
241
|
+
// it is listening, which is the only way the launcher learns about a port other than the one it
|
|
242
|
+
// asked for. Re-read every poll, because that file is written mid-wait.
|
|
243
|
+
function readinessProbePorts(fraimDir, runtimeId, preferredPort) {
|
|
244
|
+
const runtime = (0, hub_runtime_file_1.readHubRuntimeFile)(fraimDir, runtimeId);
|
|
245
|
+
return runtime?.port && runtime.port !== preferredPort
|
|
246
|
+
? [runtime.port, preferredPort]
|
|
247
|
+
: [preferredPort];
|
|
248
|
+
}
|
|
210
249
|
async function waitForDesktopHubReady(child, preferredPort, options = {}) {
|
|
211
|
-
const timeoutMs = options.timeoutMs ??
|
|
250
|
+
const timeoutMs = options.timeoutMs ?? resolveDesktopReadyTimeoutMs();
|
|
212
251
|
const pollMs = options.pollMs ?? 250;
|
|
252
|
+
const progressAfterMs = options.progressAfterMs ?? DEFAULT_DESKTOP_HUB_READY_PROGRESS_MS;
|
|
253
|
+
const progressIntervalMs = options.progressIntervalMs ?? progressAfterMs;
|
|
254
|
+
const onProgress = options.onProgress ?? reportDesktopReadyProgress;
|
|
213
255
|
const runtimeId = options.runtimeId || 'hub';
|
|
214
256
|
const fraimDir = options.fraimDir || (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
215
257
|
const start = Date.now();
|
|
@@ -220,7 +262,10 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
|
|
|
220
262
|
child.once('error', (error) => {
|
|
221
263
|
childState.error = error;
|
|
222
264
|
});
|
|
223
|
-
|
|
265
|
+
// A dead child is the only positive evidence that the launch failed, so it always wins over
|
|
266
|
+
// the elapsed budget. Checked at the top of every poll and once more after the loop, so a
|
|
267
|
+
// child that dies during the final sleep is still reported as an exit and not as a timeout.
|
|
268
|
+
const failIfChildDied = () => {
|
|
224
269
|
if (childState.error) {
|
|
225
270
|
throw new Error(`FRAIM Hub desktop shell failed to launch: ${childState.error.message}`);
|
|
226
271
|
}
|
|
@@ -228,24 +273,35 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
|
|
|
228
273
|
const detail = childState.exit.signal ? `signal ${childState.exit.signal}` : `exit code ${childState.exit.code ?? 'unknown'}`;
|
|
229
274
|
throw new Error(`FRAIM Hub desktop shell exited before the Hub became ready (${detail}).`);
|
|
230
275
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
for (const port of
|
|
276
|
+
};
|
|
277
|
+
let nextProgressAtMs = progressAfterMs;
|
|
278
|
+
while (Date.now() - start < timeoutMs) {
|
|
279
|
+
failIfChildDied();
|
|
280
|
+
for (const port of readinessProbePorts(fraimDir, runtimeId, preferredPort)) {
|
|
236
281
|
const version = await fetchRunningHubVersion(port);
|
|
237
282
|
if (version) {
|
|
238
283
|
return { port, version };
|
|
239
284
|
}
|
|
240
285
|
}
|
|
286
|
+
const elapsedMs = Date.now() - start;
|
|
287
|
+
if (elapsedMs >= nextProgressAtMs) {
|
|
288
|
+
onProgress({ elapsedMs, timeoutMs, port: preferredPort });
|
|
289
|
+
nextProgressAtMs = elapsedMs + progressIntervalMs;
|
|
290
|
+
}
|
|
241
291
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
242
292
|
}
|
|
243
|
-
|
|
293
|
+
failIfChildDied();
|
|
294
|
+
const stillRunning = typeof child.pid === 'number'
|
|
295
|
+
? ` The desktop shell (pid ${child.pid}) is still running and may finish starting on its own.`
|
|
296
|
+
: '';
|
|
297
|
+
throw new Error(`Timed out waiting for FRAIM Hub desktop shell to become ready on port ${preferredPort} after ${formatReadySeconds(timeoutMs)}.`
|
|
298
|
+
+ `${stillRunning}`
|
|
299
|
+
+ ` Set ${exports.DESKTOP_HUB_READY_TIMEOUT_ENV} to a larger value if this machine needs longer.`);
|
|
244
300
|
}
|
|
245
301
|
async function reconcileRunningHub(flags, runtimeId = 'hub') {
|
|
246
302
|
const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
|
|
247
303
|
const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
|
|
248
|
-
const live = !!(running && confirmedVersion &&
|
|
304
|
+
const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
|
|
249
305
|
const effective = live && running ? { ...running, version: confirmedVersion } : null;
|
|
250
306
|
const decision = (0, hub_launch_decision_1.decideHubLaunch)({
|
|
251
307
|
running: effective,
|
|
@@ -278,7 +334,7 @@ async function runHub(options) {
|
|
|
278
334
|
if (wantDesktop) {
|
|
279
335
|
await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
|
|
280
336
|
}
|
|
281
|
-
const desktopChild = wantDesktop ? openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
|
|
337
|
+
const desktopChild = wantDesktop ? await openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
|
|
282
338
|
if (!desktopChild) {
|
|
283
339
|
const port = await findAvailablePort(preferredPort);
|
|
284
340
|
const server = new AiHubServer(projectPath ? { projectPath } : {});
|
|
@@ -7,22 +7,13 @@ exports.isLockStale = isLockStale;
|
|
|
7
7
|
exports.withBucketLock = withBucketLock;
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const process_liveness_1 = require("./process-liveness");
|
|
10
11
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
11
12
|
const DEFAULT_STALE_MS = 10000;
|
|
12
13
|
// Synchronous sleep without a busy spin (the store's write path is synchronous).
|
|
13
14
|
function sleepMs(ms) {
|
|
14
15
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
15
16
|
}
|
|
16
|
-
function pidAlive(pid) {
|
|
17
|
-
try {
|
|
18
|
-
process.kill(pid, 0);
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
catch (error) {
|
|
22
|
-
// ESRCH: no such process. EPERM: exists but not ours (still alive).
|
|
23
|
-
return error.code === 'EPERM';
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
17
|
// A lock is stale (safe to steal) if its file is unreadable/absent, its timestamp is older than
|
|
27
18
|
// staleMs, or it names a dead pid on this same host.
|
|
28
19
|
function isLockStale(lockPath, staleMs = DEFAULT_STALE_MS) {
|
|
@@ -35,7 +26,7 @@ function isLockStale(lockPath, staleMs = DEFAULT_STALE_MS) {
|
|
|
35
26
|
}
|
|
36
27
|
if (typeof info.ts === 'number' && Date.now() - info.ts > staleMs)
|
|
37
28
|
return true;
|
|
38
|
-
if (typeof info.pid === 'number' && info.host === os_1.default.hostname() && !
|
|
29
|
+
if (typeof info.pid === 'number' && info.host === os_1.default.hostname() && !(0, process_liveness_1.isPidAlive)(info.pid))
|
|
39
30
|
return true;
|
|
40
31
|
return false;
|
|
41
32
|
}
|
|
@@ -5,7 +5,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.launchDesktopShell = launchDesktopShell;
|
|
7
7
|
const electron_1 = require("electron");
|
|
8
|
-
const electron_updater_1 = require("electron-updater");
|
|
9
8
|
const path_1 = __importDefault(require("path"));
|
|
10
9
|
const fs_1 = __importDefault(require("fs"));
|
|
11
10
|
const server_1 = require("./server");
|
|
@@ -91,8 +90,14 @@ function ensureLoginItem() {
|
|
|
91
90
|
function configureAutoUpdater() {
|
|
92
91
|
if (!electron_1.app.isPackaged)
|
|
93
92
|
return;
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
// #1110: electron-updater compiles ~114 files (js-yaml, builder-util-runtime, ...) that a
|
|
94
|
+
// non-packaged `npx fraim-hub` launch never uses, and this whole function returns early
|
|
95
|
+
// there. Requiring it lazily keeps those file reads off the cold-start path, which is what
|
|
96
|
+
// dominates time-to-ready on a freshly unpacked install.
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
98
|
+
const { autoUpdater } = require('electron-updater');
|
|
99
|
+
autoUpdater.autoDownload = true;
|
|
100
|
+
autoUpdater.checkForUpdatesAndNotify().catch((err) => {
|
|
96
101
|
console.warn('[fraim] auto-update check failed:', err);
|
|
97
102
|
});
|
|
98
103
|
}
|
|
@@ -329,13 +334,10 @@ async function launchDesktopShell(options) {
|
|
|
329
334
|
const httpsPort = await (0, server_1.findAvailablePortExcluding)(43092, new Set([httpPort]));
|
|
330
335
|
// Generate (or load cached) self-signed cert for HTTPS.
|
|
331
336
|
// Fast on subsequent launches (file read); ~200ms on first launch (key gen).
|
|
337
|
+
// The cert bundle itself is needed here because server.start() binds the HTTPS listener with
|
|
338
|
+
// it. Trusting it in the OS store is a separate, Word-Online-only concern and happens after
|
|
339
|
+
// the Hub is serving (#1110) - see below.
|
|
332
340
|
const certBundle = await (0, cert_store_1.loadOrCreateCert)();
|
|
333
|
-
try {
|
|
334
|
-
(0, cert_store_1.trustCert)((0, cert_store_1.certPaths)().certPath);
|
|
335
|
-
}
|
|
336
|
-
catch (err) {
|
|
337
|
-
console.warn('[fraim] could not trust localhost certificate:', err);
|
|
338
|
-
}
|
|
339
341
|
server = new server_1.AiHubServer({
|
|
340
342
|
...(options.projectPath ? { projectPath: options.projectPath } : {}),
|
|
341
343
|
// Issue #701: no local DB. Persona/manager-team state resolves through the hosted
|
|
@@ -366,6 +368,16 @@ async function launchDesktopShell(options) {
|
|
|
366
368
|
catch (err) {
|
|
367
369
|
console.warn('[fraim] could not write hub runtime file:', err);
|
|
368
370
|
}
|
|
371
|
+
// #1110: trusting the loopback cert in the OS store is a blocking `certutil` subprocess and
|
|
372
|
+
// is only needed so Word *Online* will render the task pane over HTTPS. The Hub does not need
|
|
373
|
+
// it to serve, so it runs after the server is listening rather than in front of it. Grouped
|
|
374
|
+
// with the Office sideload because both are Word-only first-run housekeeping.
|
|
375
|
+
try {
|
|
376
|
+
(0, cert_store_1.trustCert)((0, cert_store_1.certPaths)().certPath);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
console.warn('[fraim] could not trust localhost certificate:', err);
|
|
380
|
+
}
|
|
369
381
|
ensureWordSideload(resolvedProjectPath, httpsPort);
|
|
370
382
|
const hubUrl = `http://127.0.0.1:${httpPort}/ai-hub/`;
|
|
371
383
|
createTray(hubUrl, runtimeId);
|
|
@@ -0,0 +1,265 @@
|
|
|
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.HUB_ELECTRON_VERSION = void 0;
|
|
7
|
+
exports.electronExecutableRelativePath = electronExecutableRelativePath;
|
|
8
|
+
exports.resolveSharedElectronDistDir = resolveSharedElectronDistDir;
|
|
9
|
+
exports.resolveSharedElectronBinaryPath = resolveSharedElectronBinaryPath;
|
|
10
|
+
exports.isElectronDistComplete = isElectronDistComplete;
|
|
11
|
+
exports.ensureSharedElectronDist = ensureSharedElectronDist;
|
|
12
|
+
exports.resolveHubElectronBinary = resolveHubElectronBinary;
|
|
13
|
+
// #1112: one shared, versioned Electron dist instead of a private 345 MB copy per npx install.
|
|
14
|
+
//
|
|
15
|
+
// `electron` used to be a hard dependency of packages/fraim-hub. npm runs its postinstall in every
|
|
16
|
+
// tree it installs into, and that postinstall extracts the ~345 MB platform zip. Because
|
|
17
|
+
// `npx fraim-hub@latest` creates a fresh `_npx/<hash>/` tree for every published version, the same
|
|
18
|
+
// dist was unpacked again on every upgrade: 27 copies and 9.1 GB on one developer machine, and a
|
|
19
|
+
// first-execution cost on freshly written binaries that was the largest remaining term in the Hub
|
|
20
|
+
// cold start measured in #1110.
|
|
21
|
+
//
|
|
22
|
+
// The extraction cannot be skipped at launch time. electron/install.js short-circuits only on
|
|
23
|
+
// ELECTRON_SKIP_BINARY_DOWNLOAD, read from the environment of the npm process, and by the time
|
|
24
|
+
// src/ai-hub/cli.ts runs npm has already unpacked. So `electron` is no longer declared at all, and
|
|
25
|
+
// the Hub resolves the binary itself from ~/.fraim/bin/electron/<version>/ — extracted once per
|
|
26
|
+
// Electron version per machine, shared by every installed and future fraim-hub version.
|
|
27
|
+
//
|
|
28
|
+
// The zip still comes from the same shared cache npm used (~/AppData/Local/electron/Cache on
|
|
29
|
+
// Windows, ~/Library/Caches/electron on macOS, ~/.cache/electron on Linux), because this reuses
|
|
30
|
+
// @electron/get and extract-zip — the two modules electron/install.js itself uses. That keeps
|
|
31
|
+
// checksum validation, mirror and proxy environment variables, and symlink- and mode-preserving
|
|
32
|
+
// extraction behaving exactly as they do today.
|
|
33
|
+
const fs_1 = __importDefault(require("fs"));
|
|
34
|
+
const path_1 = __importDefault(require("path"));
|
|
35
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
36
|
+
const process_liveness_1 = require("./process-liveness");
|
|
37
|
+
/**
|
|
38
|
+
* The exact Electron release the Hub runs on, and the runtime source of truth for it.
|
|
39
|
+
*
|
|
40
|
+
* @electron/get needs an exact version: a range cannot name a download. Two other declarations are
|
|
41
|
+
* pinned to this value and `scripts/validate-package-split.ts` fails the build if either drifts:
|
|
42
|
+
* - `packages/fraim-hub` `build.electronVersion`, which is how electron-builder learns the
|
|
43
|
+
* version now that `electron` is absent from that manifest.
|
|
44
|
+
* - the repo root's `dependencies.electron` range, which still installs Electron for local dev,
|
|
45
|
+
* `npm run hub:desktop`, and the Electron-launching test suites.
|
|
46
|
+
*/
|
|
47
|
+
exports.HUB_ELECTRON_VERSION = '41.2.2';
|
|
48
|
+
/** Relative path of the Electron executable inside a dist, matching getPlatformPath() in electron/install.js. */
|
|
49
|
+
function electronExecutableRelativePath(platform = process.platform) {
|
|
50
|
+
switch (platform) {
|
|
51
|
+
case 'darwin':
|
|
52
|
+
return path_1.default.join('Electron.app', 'Contents', 'MacOS', 'Electron');
|
|
53
|
+
case 'win32':
|
|
54
|
+
return 'electron.exe';
|
|
55
|
+
default:
|
|
56
|
+
return 'electron';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// An Electron release is `x.y.z` or `x.y.z-<prerelease>`, and nothing else. Anchoring on that shape
|
|
60
|
+
// means a version string can never carry path syntax into the directory name below, which matters
|
|
61
|
+
// because `ensureSharedElectronDist` calls a recursive `fs.rmSync` on the resulting path: a version
|
|
62
|
+
// of `../../..` would delete an unrelated tree. Not reachable today - every caller resolves to the
|
|
63
|
+
// compile-time `HUB_ELECTRON_VERSION` - so this is a guard at the write boundary, kept next to the
|
|
64
|
+
// path construction it protects rather than left to every future caller to remember.
|
|
65
|
+
const EXACT_ELECTRON_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
66
|
+
function assertPathSafeVersion(version) {
|
|
67
|
+
if (!EXACT_ELECTRON_VERSION.test(version)) {
|
|
68
|
+
throw new Error(`Refusing to use "${version}" as an Electron version: expected an exact release such as 41.2.2.`);
|
|
69
|
+
}
|
|
70
|
+
return version;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `~/.fraim/bin/electron/<version>/`.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately a sibling of, not a child of, `resolveHubInstallDir()`
|
|
76
|
+
* (`~/.fraim/bin/fraim-hub-electron/`). That directory belongs to the installed Hub application:
|
|
77
|
+
* `runHubInstall` writes `fraim-hub.exe` and the downloaded release asset into it, and
|
|
78
|
+
* `installDownloadedAsset` does an `fs.rmSync` inside it on macOS. A shared runtime with a
|
|
79
|
+
* different lifecycle should not live inside a tree another code path deletes from.
|
|
80
|
+
*/
|
|
81
|
+
function resolveSharedElectronDistDir(version, fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)()) {
|
|
82
|
+
return path_1.default.join(fraimDir, 'bin', 'electron', assertPathSafeVersion(version));
|
|
83
|
+
}
|
|
84
|
+
/** Absolute path of the Electron executable inside the shared dist for a version. */
|
|
85
|
+
function resolveSharedElectronBinaryPath(version, fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)(), platform = process.platform) {
|
|
86
|
+
return path_1.default.join(resolveSharedElectronDistDir(version, fraimDir), electronExecutableRelativePath(platform));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether a directory holds a complete dist for exactly this version.
|
|
90
|
+
*
|
|
91
|
+
* Same two conditions electron's own `isInstalled()` checks: the `version` file names this release,
|
|
92
|
+
* and the platform executable is present. A dist that fails either is stale or half-written, and
|
|
93
|
+
* spawning from it would fail at launch instead of here.
|
|
94
|
+
*/
|
|
95
|
+
function isElectronDistComplete(distDir, version, platform = process.platform) {
|
|
96
|
+
try {
|
|
97
|
+
const recorded = fs_1.default.readFileSync(path_1.default.join(distDir, 'version'), 'utf8').trim().replace(/^v/, '');
|
|
98
|
+
if (recorded !== version)
|
|
99
|
+
return false;
|
|
100
|
+
return fs_1.default.existsSync(path_1.default.join(distDir, electronExecutableRelativePath(platform)));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the platform zip through @electron/get, so a zip npm already cached for this version is a
|
|
108
|
+
* cache hit and no bytes cross the network. Checksums stay enabled: @electron/get validates the
|
|
109
|
+
* artifact against SHASUMS256.txt, fetching that 2 KB file when no local checksums.json is
|
|
110
|
+
* available. That is the only integrity check standing between a 345 MB download and executing it.
|
|
111
|
+
*/
|
|
112
|
+
const downloadElectronZip = async ({ version, platform, arch }) => {
|
|
113
|
+
// Required lazily: @electron/get pulls in got, fs-extra, and sumchecker, and none of that belongs
|
|
114
|
+
// in the module graph of a launch that resolves an already-extracted dist.
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
116
|
+
const { downloadArtifact } = require('@electron/get');
|
|
117
|
+
return downloadArtifact({ version, artifactName: 'electron', platform, arch });
|
|
118
|
+
};
|
|
119
|
+
const extractElectronZip = async (zipPath, destDir) => {
|
|
120
|
+
// extract-zip is what electron/install.js uses, and it preserves the mode bits and the symlinks
|
|
121
|
+
// inside Electron.app/Contents/Frameworks that a naive zip reader drops.
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
123
|
+
const extract = require('extract-zip');
|
|
124
|
+
await extract(zipPath, { dir: destDir });
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Remove staging trees whose owning process is gone.
|
|
128
|
+
*
|
|
129
|
+
* The `finally` that cleans up a staging tree does not run when the process is killed, and the
|
|
130
|
+
* window it covers is a visible "Preparing the shared Electron runtime" message that invites a
|
|
131
|
+
* Ctrl+C. Without this sweep, one impatient interrupt strands ~345 MB under `~/.fraim` forever,
|
|
132
|
+
* which is the same disk leak this change exists to stop. A staging tree whose pid is still alive
|
|
133
|
+
* belongs to a concurrent launch and is left alone.
|
|
134
|
+
*/
|
|
135
|
+
function sweepDeadStagingDirs(distDir) {
|
|
136
|
+
const parent = path_1.default.dirname(distDir);
|
|
137
|
+
const prefix = `${path_1.default.basename(distDir)}.staging-`;
|
|
138
|
+
let entries;
|
|
139
|
+
try {
|
|
140
|
+
entries = fs_1.default.readdirSync(parent, { withFileTypes: true });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (!entry.isDirectory() || !entry.name.startsWith(prefix))
|
|
147
|
+
continue;
|
|
148
|
+
const pid = Number(entry.name.slice(prefix.length));
|
|
149
|
+
if (Number.isInteger(pid) && pid > 0 && (0, process_liveness_1.isPidAlive)(pid))
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
fs_1.default.rmSync(path_1.default.join(parent, entry.name), { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* best effort: a tree we cannot delete is not a reason to fail a launch */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Return the Electron executable in the shared dist for this version, extracting it first if it is
|
|
161
|
+
* not already there. Returns immediately when a complete dist exists, which is the case on every
|
|
162
|
+
* launch after the first for a given Electron version.
|
|
163
|
+
*
|
|
164
|
+
* Extraction goes to a staging sibling and is promoted with a rename, matching the
|
|
165
|
+
* `${dir}.staging-${pid}` pattern in `org-pack-sync.ts` and `manager-pack-sync.ts`. A failure
|
|
166
|
+
* therefore leaves no partial tree at the versioned path, so the next launch cannot mistake one for
|
|
167
|
+
* a usable dist.
|
|
168
|
+
*/
|
|
169
|
+
async function ensureSharedElectronDist(options = {}) {
|
|
170
|
+
const version = options.version ?? exports.HUB_ELECTRON_VERSION;
|
|
171
|
+
const fraimDir = options.fraimDir ?? (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
172
|
+
const platform = options.platform ?? process.platform;
|
|
173
|
+
const arch = options.arch ?? process.arch;
|
|
174
|
+
const distDir = resolveSharedElectronDistDir(version, fraimDir);
|
|
175
|
+
const binaryPath = resolveSharedElectronBinaryPath(version, fraimDir, platform);
|
|
176
|
+
if (isElectronDistComplete(distDir, version, platform)) {
|
|
177
|
+
return binaryPath;
|
|
178
|
+
}
|
|
179
|
+
const downloadZip = options.downloadZip ?? downloadElectronZip;
|
|
180
|
+
const extractZip = options.extractZip ?? extractElectronZip;
|
|
181
|
+
const onProgress = options.onProgress;
|
|
182
|
+
onProgress?.(`Preparing the shared Electron ${version} runtime in ${distDir}. `
|
|
183
|
+
+ 'This happens once per Electron version on this machine; later Hub launches reuse it.');
|
|
184
|
+
const zipPath = await downloadZip({ version, platform, arch });
|
|
185
|
+
await extractAndPromote({ zipPath, distDir, version, platform, extractZip });
|
|
186
|
+
onProgress?.(`Shared Electron ${version} runtime ready.`);
|
|
187
|
+
return binaryPath;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Unpack a zip into `distDir`, atomically.
|
|
191
|
+
*
|
|
192
|
+
* Extraction goes to a `${distDir}.staging-${pid}` sibling and is promoted with a rename, matching
|
|
193
|
+
* the pattern in `org-pack-sync.ts` and `manager-pack-sync.ts`. A failure therefore leaves no
|
|
194
|
+
* partial tree at the versioned path, so the next launch cannot mistake one for a usable dist.
|
|
195
|
+
*/
|
|
196
|
+
async function extractAndPromote(args) {
|
|
197
|
+
const { zipPath, distDir, version, platform, extractZip } = args;
|
|
198
|
+
const stagingDir = `${distDir}.staging-${process.pid}`;
|
|
199
|
+
fs_1.default.mkdirSync(path_1.default.dirname(distDir), { recursive: true });
|
|
200
|
+
sweepDeadStagingDirs(distDir);
|
|
201
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
202
|
+
try {
|
|
203
|
+
await extractZip(zipPath, stagingDir);
|
|
204
|
+
if (!isElectronDistComplete(stagingDir, version, platform)) {
|
|
205
|
+
throw new Error(`Extracted Electron ${version} is missing its version file or ${electronExecutableRelativePath(platform)}; `
|
|
206
|
+
+ `refusing to install an incomplete dist from ${zipPath}.`);
|
|
207
|
+
}
|
|
208
|
+
// A concurrent launch may have finished extracting the same version while this one worked.
|
|
209
|
+
// Promoting over a complete dist would delete the tree that launch is spawning from, so the
|
|
210
|
+
// winner keeps it and this one discards its own copy.
|
|
211
|
+
if (!isElectronDistComplete(distDir, version, platform)) {
|
|
212
|
+
fs_1.default.rmSync(distDir, { recursive: true, force: true });
|
|
213
|
+
try {
|
|
214
|
+
fs_1.default.renameSync(stagingDir, distDir);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
// The same race, one step later: the target can appear between that check and this rename.
|
|
218
|
+
// A complete dist at the target is the outcome this call wanted, so it is success.
|
|
219
|
+
if (!isElectronDistComplete(distDir, version, platform))
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Resolve the `electron` npm package's own binary, or null when it is absent or points nowhere. */
|
|
229
|
+
function resolveInstalledElectronBinary(requireElectron) {
|
|
230
|
+
try {
|
|
231
|
+
const binaryPath = requireElectron();
|
|
232
|
+
return typeof binaryPath === 'string' && fs_1.default.existsSync(binaryPath) ? binaryPath : null;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The Electron binary this Hub should launch, or null when there is none.
|
|
240
|
+
*
|
|
241
|
+
* An installed `electron` package wins when one is present. That is this repo, and any consumer who
|
|
242
|
+
* depends on Electron themselves: its dist is already on disk, so preferring it avoids a needless
|
|
243
|
+
* download and keeps `npm run hub:desktop` and the Electron-launching suites on the exact path they
|
|
244
|
+
* use today. The published `fraim-hub` package declares no `electron`, so it takes the shared dist.
|
|
245
|
+
*
|
|
246
|
+
* Null is a supported outcome, not a failure: `openDesktopWindow()` returns null in that case and
|
|
247
|
+
* `runHub` starts the in-process server and opens a browser. An offline first launch must reach that
|
|
248
|
+
* fallback rather than throwing, so a download failure is reported through `onError` and swallowed.
|
|
249
|
+
*/
|
|
250
|
+
async function resolveHubElectronBinary(options = {}) {
|
|
251
|
+
const requireElectron = options.requireElectron
|
|
252
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
253
|
+
?? (() => require('electron'));
|
|
254
|
+
const installed = resolveInstalledElectronBinary(requireElectron);
|
|
255
|
+
if (installed) {
|
|
256
|
+
return { binaryPath: installed, source: 'installed-package' };
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
return { binaryPath: await ensureSharedElectronDist(options), source: 'shared-dist' };
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -33,7 +33,6 @@ const os_1 = __importDefault(require("os"));
|
|
|
33
33
|
const path_1 = __importDefault(require("path"));
|
|
34
34
|
const manager_turns_1 = require("./manager-turns");
|
|
35
35
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
36
|
-
const mcp_config_generator_1 = require("../cli/setup/mcp-config-generator");
|
|
37
36
|
const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
|
|
38
37
|
const configured_agents_1 = require("./configured-agents");
|
|
39
38
|
const pack_home_1 = require("../cli/utils/pack-home");
|
|
@@ -1155,7 +1154,13 @@ function prepareCodexBrowserHome(cdp, env = process.env) {
|
|
|
1155
1154
|
const realConfig = path_1.default.join(real, 'config.toml');
|
|
1156
1155
|
const existing = fs_1.default.existsSync(realConfig) ? fs_1.default.readFileSync(realConfig, 'utf8') : '';
|
|
1157
1156
|
const pwBlock = `[mcp_servers.playwright]\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "${cdp}"]\n`;
|
|
1158
|
-
|
|
1157
|
+
// #1110: this is the only use of mcp-config-generator here, and it runs when a run launches,
|
|
1158
|
+
// never at Hub startup. Importing it at module scope pulled the whole
|
|
1159
|
+
// mcp-server-builder -> mcp-server-registry -> provider-registry -> provider-client -> axios
|
|
1160
|
+
// chain (~30 files) into the graph the Hub must compile before it can listen.
|
|
1161
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1162
|
+
const { mergeTomlMCPServers } = require('../cli/setup/mcp-config-generator');
|
|
1163
|
+
const merged = mergeTomlMCPServers(existing, pwBlock, ['playwright']).content;
|
|
1159
1164
|
fs_1.default.writeFileSync(path_1.default.join(home, 'config.toml'), merged, 'utf8');
|
|
1160
1165
|
// Auth + the session index (so resume can find existing rollouts by thread id).
|
|
1161
1166
|
for (const f of ['auth.json', 'session_index.jsonl', 'history.jsonl']) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Is a pid on this host still running? A leaf with no dependencies, so any Hub module can use it
|
|
3
|
+
// without pulling in a graph or creating an import cycle.
|
|
4
|
+
//
|
|
5
|
+
// #1112 consolidated three byte-identical copies of this: `isProcessAlive` in `cli.ts` (which
|
|
6
|
+
// decides whether a recorded Hub instance is live), `pidAlive` in `conversation-store-lock.ts`
|
|
7
|
+
// (which decides whether a lock is stealable), and a third that the shared-Electron-dist sweep
|
|
8
|
+
// would otherwise have added.
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.isPidAlive = isPidAlive;
|
|
11
|
+
/**
|
|
12
|
+
* `process.kill(pid, 0)` sends no signal; it only checks that the process exists and is signalable.
|
|
13
|
+
* `ESRCH` means no such process. `EPERM` means it exists but belongs to another user, which still
|
|
14
|
+
* counts as alive — treating it as dead is what would let a caller steal a live lock or delete a
|
|
15
|
+
* live process's working tree.
|
|
16
|
+
*/
|
|
17
|
+
function isPidAlive(pid) {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return error.code === 'EPERM';
|
|
24
|
+
}
|
|
25
|
+
}
|