fraim-hub 2.0.290 → 2.0.291
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/cli.js
CHANGED
|
@@ -46,15 +46,10 @@ const git_utils_1 = require("../core/utils/git-utils");
|
|
|
46
46
|
const path_1 = __importDefault(require("path"));
|
|
47
47
|
const child_process_1 = require("child_process");
|
|
48
48
|
const fs_1 = __importDefault(require("fs"));
|
|
49
|
-
const net_1 = __importDefault(require("net"));
|
|
50
|
-
const http_1 = __importDefault(require("http"));
|
|
51
|
-
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
52
|
-
const hub_launch_decision_1 = require("./hub-launch-decision");
|
|
53
49
|
const hub_runtime_file_1 = require("./hub-runtime-file");
|
|
54
50
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
55
|
-
const version_utils_1 = require("../cli/utils/version-utils");
|
|
56
51
|
const electron_dist_1 = require("./electron-dist");
|
|
57
|
-
const
|
|
52
|
+
const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
|
|
58
53
|
function resolveDesktopEntry() {
|
|
59
54
|
const candidates = [
|
|
60
55
|
path_1.default.resolve(__dirname, 'desktop-main.js'),
|
|
@@ -113,96 +108,6 @@ function openBrowser(url) {
|
|
|
113
108
|
const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
114
109
|
child.unref();
|
|
115
110
|
}
|
|
116
|
-
function killPid(pid) {
|
|
117
|
-
try {
|
|
118
|
-
if (process.platform === 'win32') {
|
|
119
|
-
// /T kills the entire process tree; /F forces termination without prompting.
|
|
120
|
-
(0, child_process_1.execFileSync)('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
// tree-kill sends SIGTERM to the process group so child Electron helper processes
|
|
124
|
-
// are also terminated, not just the root node process (#921).
|
|
125
|
-
(0, tree_kill_1.default)(pid, 'SIGTERM');
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
/* already gone */
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
// #921: after killing the registered pid, probe ports 43091-43200 for any Hub HTTP
|
|
133
|
-
// response. Any Hub found on those ports that is NOT the already-killed pid is an
|
|
134
|
-
// orphan (e.g. an older version started via bare `npx` that never wrote hub-runtime.json).
|
|
135
|
-
// GET /api/ai-hub/pid identifies the pid; we then kill it and wait for its port to free.
|
|
136
|
-
async function fetchHubPid(port) {
|
|
137
|
-
return new Promise((resolve) => {
|
|
138
|
-
const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/pid', timeout: 1000 }, (res) => {
|
|
139
|
-
if (res.statusCode !== 200) {
|
|
140
|
-
res.resume();
|
|
141
|
-
return resolve(null);
|
|
142
|
-
}
|
|
143
|
-
let body = '';
|
|
144
|
-
res.on('data', (c) => { body += c; });
|
|
145
|
-
res.on('end', () => { try {
|
|
146
|
-
resolve(JSON.parse(body).pid ?? null);
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
resolve(null);
|
|
150
|
-
} });
|
|
151
|
-
});
|
|
152
|
-
req.on('error', () => resolve(null));
|
|
153
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
async function scanAndKillOrphanHubs(excludePid) {
|
|
157
|
-
const HUB_PORT_SCAN_START = 43091;
|
|
158
|
-
const HUB_PORT_SCAN_END = 43200;
|
|
159
|
-
for (let port = HUB_PORT_SCAN_START; port <= HUB_PORT_SCAN_END; port++) {
|
|
160
|
-
const version = await fetchRunningHubVersion(port);
|
|
161
|
-
if (!version)
|
|
162
|
-
continue; // port not a Hub
|
|
163
|
-
const pid = await fetchHubPid(port);
|
|
164
|
-
if (pid === null || pid === excludePid)
|
|
165
|
-
continue; // same process we already killed
|
|
166
|
-
console.log(`Killing orphan Hub (pid ${pid}) on port ${port}`);
|
|
167
|
-
killPid(pid);
|
|
168
|
-
await waitForPortFree(port);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
function isPortFree(port, timeoutMs = 500) {
|
|
172
|
-
return new Promise((resolve) => {
|
|
173
|
-
const sock = net_1.default.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(false); });
|
|
174
|
-
sock.on('error', () => resolve(true));
|
|
175
|
-
sock.setTimeout(timeoutMs, () => { sock.destroy(); resolve(true); });
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
async function waitForPortFree(port, totalMs = 5000) {
|
|
179
|
-
const start = Date.now();
|
|
180
|
-
while (Date.now() - start < totalMs) {
|
|
181
|
-
if (await isPortFree(port))
|
|
182
|
-
return;
|
|
183
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
function fetchRunningHubVersion(port) {
|
|
187
|
-
return new Promise((resolve) => {
|
|
188
|
-
const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/version', timeout: 1000 }, (res) => {
|
|
189
|
-
if (res.statusCode !== 200) {
|
|
190
|
-
res.resume();
|
|
191
|
-
return resolve(null);
|
|
192
|
-
}
|
|
193
|
-
let body = '';
|
|
194
|
-
res.on('data', (c) => { body += c; });
|
|
195
|
-
res.on('end', () => { try {
|
|
196
|
-
resolve(JSON.parse(body).version ?? null);
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
resolve(null);
|
|
200
|
-
} });
|
|
201
|
-
});
|
|
202
|
-
req.on('error', () => resolve(null));
|
|
203
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
111
|
// #1110: the desktop shell's cold-start cost is work this launcher can neither see nor bound.
|
|
207
112
|
// Before the Hub can answer /api/ai-hub/version it pays for Electron process boot, top-level
|
|
208
113
|
// evaluation of the src/ai-hub/server.ts module graph, cert load plus a blocking certutil
|
|
@@ -278,7 +183,7 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
|
|
|
278
183
|
while (Date.now() - start < timeoutMs) {
|
|
279
184
|
failIfChildDied();
|
|
280
185
|
for (const port of readinessProbePorts(fraimDir, runtimeId, preferredPort)) {
|
|
281
|
-
const version = await fetchRunningHubVersion(port);
|
|
186
|
+
const version = await (0, hub_instance_reconciliation_1.fetchRunningHubVersion)(port);
|
|
282
187
|
if (version) {
|
|
283
188
|
return { port, version };
|
|
284
189
|
}
|
|
@@ -298,32 +203,6 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
|
|
|
298
203
|
+ `${stillRunning}`
|
|
299
204
|
+ ` Set ${exports.DESKTOP_HUB_READY_TIMEOUT_ENV} to a larger value if this machine needs longer.`);
|
|
300
205
|
}
|
|
301
|
-
async function reconcileRunningHub(flags, runtimeId = 'hub') {
|
|
302
|
-
const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
|
|
303
|
-
const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
|
|
304
|
-
const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
|
|
305
|
-
const effective = live && running ? { ...running, version: confirmedVersion } : null;
|
|
306
|
-
const decision = (0, hub_launch_decision_1.decideHubLaunch)({
|
|
307
|
-
running: effective,
|
|
308
|
-
pidAlive: live,
|
|
309
|
-
cliVersion: (0, version_utils_1.getFraimVersion)(),
|
|
310
|
-
restart: flags.restart,
|
|
311
|
-
noRestart: flags.keepRunning,
|
|
312
|
-
});
|
|
313
|
-
if (decision.action === 'replace' && effective) {
|
|
314
|
-
console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) - ${decision.reason}`);
|
|
315
|
-
killPid(effective.pid);
|
|
316
|
-
await waitForPortFree(effective.port);
|
|
317
|
-
// #921: after killing the registered pid, scan for orphan Hub processes that were
|
|
318
|
-
// never registered in hub-runtime.json (e.g. older versions started via bare npx).
|
|
319
|
-
if (runtimeId === 'hub') {
|
|
320
|
-
await scanAndKillOrphanHubs(effective.pid);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
else if (decision.action === 'focus-existing' && effective) {
|
|
324
|
-
console.log(`A Hub (v${effective.version}) is already running - focusing it. Use --restart to replace it.`);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
206
|
async function runHub(options) {
|
|
328
207
|
const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('./server')));
|
|
329
208
|
const preferredPort = options.port || (0, git_utils_1.getPort)() + 100;
|
|
@@ -332,7 +211,7 @@ async function runHub(options) {
|
|
|
332
211
|
if (options.open) {
|
|
333
212
|
const wantDesktop = !options.browser;
|
|
334
213
|
if (wantDesktop) {
|
|
335
|
-
await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
|
|
214
|
+
await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
|
|
336
215
|
}
|
|
337
216
|
const desktopChild = wantDesktop ? await openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
|
|
338
217
|
if (!desktopChild) {
|
|
@@ -22,6 +22,7 @@ const session_service_1 = require("../first-run/session-service");
|
|
|
22
22
|
const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
|
|
23
23
|
const desktop_auto_updater_1 = require("./desktop-auto-updater");
|
|
24
24
|
const hub_app_materializer_1 = require("./hub-app-materializer");
|
|
25
|
+
const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
|
|
25
26
|
// Keep installed, running, and user-pinned Windows shortcuts grouped under the
|
|
26
27
|
// stable identity declared in packages/fraim-hub/package.json.
|
|
27
28
|
electron_1.app.setAppUserModelId('ai.fraim.hub');
|
|
@@ -135,28 +136,54 @@ async function configureAutoUpdater() {
|
|
|
135
136
|
});
|
|
136
137
|
return result.action === 'installing';
|
|
137
138
|
}
|
|
139
|
+
// Issue #1415: shared by every path that needs to know "is a newer FRAIM Hub published than the
|
|
140
|
+
// one currently running" — the automatic second-instance/activate recheck below, and the
|
|
141
|
+
// manually-triggered restart from the web UI's update badge (see restartToLatestNow). Compares
|
|
142
|
+
// `getFraimVersion()` (not `app.getVersion()`), because it resolves from wherever the
|
|
143
|
+
// currently-running code actually lives — the bundled app.asar or a previously materialized copy
|
|
144
|
+
// — while `app.getVersion()` always names the former.
|
|
145
|
+
async function resolveLatestHubAppVersion() {
|
|
146
|
+
const runningVersion = (0, version_utils_1.getFraimVersion)();
|
|
147
|
+
const resolved = await (0, hub_app_materializer_1.resolveHubAppEntry)({
|
|
148
|
+
bundledVersion: runningVersion,
|
|
149
|
+
bundledEntryPath: __filename,
|
|
150
|
+
});
|
|
151
|
+
return { upgradable: resolved.version !== runningVersion, latest: resolved.version };
|
|
152
|
+
}
|
|
138
153
|
// Issue #1415: a second-instance activation (the user re-clicking the icon while the Hub is
|
|
139
154
|
// already running) is the common case once login-item autostart + hide-to-tray keep the Hub
|
|
140
155
|
// resident for days, and it is the one launch-shaped event where the desktop-launcher.ts
|
|
141
156
|
// materializer never gets a chance to run — bootstrap() has already won the single-instance lock
|
|
142
157
|
// by the time it fires. Without this check, an already-running Hub could only ever pick up a new
|
|
143
158
|
// `fraim-hub` release through the slower electron-updater feed below, which is the exact lagging
|
|
144
|
-
// signal #1415 was filed about.
|
|
145
|
-
// it resolves from wherever the currently-running code actually lives — the bundled app.asar or a
|
|
146
|
-
// previously materialized copy — while `app.getVersion()` always names the former.
|
|
159
|
+
// signal #1415 was filed about.
|
|
147
160
|
async function checkForHubAppUpdateOnSecondInstance() {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
bundledVersion: runningVersion,
|
|
151
|
-
bundledEntryPath: __filename,
|
|
152
|
-
});
|
|
153
|
-
if (resolved.version === runningVersion)
|
|
161
|
+
const { upgradable, latest } = await resolveLatestHubAppVersion();
|
|
162
|
+
if (!upgradable)
|
|
154
163
|
return false;
|
|
155
|
-
console.log(`[fraim] newer FRAIM Hub ${
|
|
164
|
+
console.log(`[fraim] newer FRAIM Hub ${latest} available (was running ${(0, version_utils_1.getFraimVersion)()}); restarting to apply it`);
|
|
156
165
|
electron_1.app.relaunch();
|
|
157
166
|
electron_1.app.quit();
|
|
158
167
|
return true;
|
|
159
168
|
}
|
|
169
|
+
// Issue #1415: the web UI's persistent update badge (#1379) used to just tell the user to quit
|
|
170
|
+
// and relaunch by hand. This is the same "resolve latest, relaunch" as the automatic checks above,
|
|
171
|
+
// just triggered by an explicit click instead of a relaunch-shaped Electron event — wired into
|
|
172
|
+
// AiHubServer as the `restartToLatest` option (see launchDesktopShell) so POST
|
|
173
|
+
// /api/ai-hub/restart-to-latest can call it. The relaunch/quit is deferred a beat so the HTTP
|
|
174
|
+
// response reaches the browser (which uses it to start polling for the new instance) before this
|
|
175
|
+
// process starts tearing down.
|
|
176
|
+
async function restartToLatestNow() {
|
|
177
|
+
const { upgradable, latest } = await resolveLatestHubAppVersion();
|
|
178
|
+
if (!upgradable)
|
|
179
|
+
return { restarting: false, latest };
|
|
180
|
+
console.log(`[fraim] manual restart requested; relaunching to FRAIM Hub ${latest}`);
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
electron_1.app.relaunch();
|
|
183
|
+
electron_1.app.quit();
|
|
184
|
+
}, 300);
|
|
185
|
+
return { restarting: true, latest };
|
|
186
|
+
}
|
|
160
187
|
// Issue #1415: shared by both re-launch-shaped events on an already-running Hub — Windows/Linux's
|
|
161
188
|
// `second-instance` (a new process lost the single-instance lock) and macOS's `activate` (the
|
|
162
189
|
// Dock icon click that never spawns a new process at all, so `second-instance` never fires there).
|
|
@@ -434,6 +461,7 @@ async function launchDesktopShell(options) {
|
|
|
434
461
|
});
|
|
435
462
|
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
|
|
436
463
|
},
|
|
464
|
+
restartToLatest: restartToLatestNow,
|
|
437
465
|
});
|
|
438
466
|
await server.start(httpPort);
|
|
439
467
|
const resolvedProjectPath = server.getProjectPath();
|
|
@@ -525,6 +553,25 @@ async function bootstrap() {
|
|
|
525
553
|
await electron_1.app.whenReady();
|
|
526
554
|
electron_1.app.setName(displayName(options.runtimeId));
|
|
527
555
|
configurePackagedFraimRuntime();
|
|
556
|
+
// Issue #1415 (follow-up): app.requestSingleInstanceLock() above only sees another instance of
|
|
557
|
+
// *this same identity* — it cannot see a Hub already running via a different launch path (e.g.
|
|
558
|
+
// `npx fraim-hub`'s raw electron.exe, confirmed live to end up under a different userData scope
|
|
559
|
+
// than this packaged app). hub-runtime.json is the identity-agnostic record both launch paths
|
|
560
|
+
// already write (#755); reconcile against it — and sweep for unregistered orphans (#921) —
|
|
561
|
+
// before binding our own port, so a direct double-click launch takes over whatever Hub is
|
|
562
|
+
// already running instead of silently opening a second, independent one on a fallback port.
|
|
563
|
+
// `restart: true` because a materialized/bundled launch is by construction already resolved to
|
|
564
|
+
// the current latest — there is nothing to compare, only to replace. Runs after whenReady, like
|
|
565
|
+
// every other network-touching step in this function (checkForRelaunchAttemptUpdate,
|
|
566
|
+
// configureAutoUpdater) — reconcileRunningHub makes real HTTP probes and process kills, and this
|
|
567
|
+
// keeps it on the same footing as those instead of racing Electron's own startup.
|
|
568
|
+
// Gated on the same FRAIM_AI_HUB_FAKE_HOST flag as the single-instance lock above, not on
|
|
569
|
+
// FRAIM_INSTALLER_LIFECYCLE_TEST: the orphan sweep probes real system ports 43091-43200
|
|
570
|
+
// regardless of FRAIM_USER_DIR isolation, so a test double (FAKE_HOST=1) skips it for the same
|
|
571
|
+
// reason it skips the lock — it must never disturb a real Hub already running on this machine.
|
|
572
|
+
if (!skipSingleInstance) {
|
|
573
|
+
await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: true, keepRunning: false }, options.runtimeId);
|
|
574
|
+
}
|
|
528
575
|
// macOS reads the dock icon from the app bundle once packaged, but an
|
|
529
576
|
// unpackaged `npm run hub:desktop` / `npx fraim-hub` run shows Electron's
|
|
530
577
|
// own icon there unless we set it explicitly (#1329).
|
|
@@ -0,0 +1,156 @@
|
|
|
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.killPid = killPid;
|
|
7
|
+
exports.isPortFree = isPortFree;
|
|
8
|
+
exports.waitForPortFree = waitForPortFree;
|
|
9
|
+
exports.fetchRunningHubVersion = fetchRunningHubVersion;
|
|
10
|
+
exports.fetchHubPid = fetchHubPid;
|
|
11
|
+
exports.scanAndKillOrphanHubs = scanAndKillOrphanHubs;
|
|
12
|
+
exports.reconcileRunningHub = reconcileRunningHub;
|
|
13
|
+
// Issue #755/#921: kill-and-take-over an already-running Hub, identified through the shared,
|
|
14
|
+
// identity-agnostic ~/.fraim/hub-runtime.json + a port scan - never through OS-level process
|
|
15
|
+
// identity (Electron's app.requestSingleInstanceLock() included). That distinction matters: a
|
|
16
|
+
// Hub can be running via `npx fraim-hub`'s raw electron.exe (a different userData scope than the
|
|
17
|
+
// packaged, branded app) or a bare-npx build that never wrote hub-runtime.json at all, and this
|
|
18
|
+
// module is what makes "only one Hub, and it's the one that just launched" true regardless of
|
|
19
|
+
// which of those started the one already running.
|
|
20
|
+
//
|
|
21
|
+
// Originally only exercised by the CLI (`fraim hub`, see cli.ts) before it spawns its own desktop
|
|
22
|
+
// shell. Issue #1415 follow-up: the packaged desktop app's own bootstrap (desktop-main.ts) never
|
|
23
|
+
// called any of this when launched directly (double-click / Windows shortcut, no CLI in front of
|
|
24
|
+
// it) - confirmed live: a Hub already running via `npx fraim-hub` kept its default ports while a
|
|
25
|
+
// freshly installed packaged build silently fell back to the next free ports and came up as an
|
|
26
|
+
// independent second Hub instead of replacing it. Both launch paths now share this module so
|
|
27
|
+
// there is exactly one implementation of "is a Hub already running, and if so, replace it."
|
|
28
|
+
const child_process_1 = require("child_process");
|
|
29
|
+
const net_1 = __importDefault(require("net"));
|
|
30
|
+
const http_1 = __importDefault(require("http"));
|
|
31
|
+
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
32
|
+
const hub_launch_decision_1 = require("./hub-launch-decision");
|
|
33
|
+
const hub_runtime_file_1 = require("./hub-runtime-file");
|
|
34
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
35
|
+
const version_utils_1 = require("../cli/utils/version-utils");
|
|
36
|
+
const process_liveness_1 = require("./process-liveness");
|
|
37
|
+
function killPid(pid) {
|
|
38
|
+
try {
|
|
39
|
+
if (process.platform === 'win32') {
|
|
40
|
+
// /T kills the entire process tree; /F forces termination without prompting.
|
|
41
|
+
(0, child_process_1.execFileSync)('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
// tree-kill sends SIGTERM to the process group so child Electron helper processes
|
|
45
|
+
// are also terminated, not just the root node process (#921).
|
|
46
|
+
(0, tree_kill_1.default)(pid, 'SIGTERM');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* already gone */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function isPortFree(port, timeoutMs = 500) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const sock = net_1.default.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(false); });
|
|
56
|
+
sock.on('error', () => resolve(true));
|
|
57
|
+
sock.setTimeout(timeoutMs, () => { sock.destroy(); resolve(true); });
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async function waitForPortFree(port, totalMs = 5000) {
|
|
61
|
+
const start = Date.now();
|
|
62
|
+
while (Date.now() - start < totalMs) {
|
|
63
|
+
if (await isPortFree(port))
|
|
64
|
+
return;
|
|
65
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function fetchRunningHubVersion(port) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/version', timeout: 1000 }, (res) => {
|
|
71
|
+
if (res.statusCode !== 200) {
|
|
72
|
+
res.resume();
|
|
73
|
+
return resolve(null);
|
|
74
|
+
}
|
|
75
|
+
let body = '';
|
|
76
|
+
res.on('data', (c) => { body += c; });
|
|
77
|
+
res.on('end', () => { try {
|
|
78
|
+
resolve(JSON.parse(body).version ?? null);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
resolve(null);
|
|
82
|
+
} });
|
|
83
|
+
});
|
|
84
|
+
req.on('error', () => resolve(null));
|
|
85
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// #921: after killing the registered pid, probe ports 43091-43200 for any Hub HTTP
|
|
89
|
+
// response. Any Hub found on those ports that is NOT the already-killed pid is an
|
|
90
|
+
// orphan (e.g. an older version started via bare `npx` that never wrote hub-runtime.json).
|
|
91
|
+
// GET /api/ai-hub/pid identifies the pid; we then kill it and wait for its port to free.
|
|
92
|
+
function fetchHubPid(port) {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/pid', timeout: 1000 }, (res) => {
|
|
95
|
+
if (res.statusCode !== 200) {
|
|
96
|
+
res.resume();
|
|
97
|
+
return resolve(null);
|
|
98
|
+
}
|
|
99
|
+
let body = '';
|
|
100
|
+
res.on('data', (c) => { body += c; });
|
|
101
|
+
res.on('end', () => { try {
|
|
102
|
+
resolve(JSON.parse(body).pid ?? null);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
resolve(null);
|
|
106
|
+
} });
|
|
107
|
+
});
|
|
108
|
+
req.on('error', () => resolve(null));
|
|
109
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async function scanAndKillOrphanHubs(excludePid) {
|
|
113
|
+
const HUB_PORT_SCAN_START = 43091;
|
|
114
|
+
const HUB_PORT_SCAN_END = 43200;
|
|
115
|
+
for (let port = HUB_PORT_SCAN_START; port <= HUB_PORT_SCAN_END; port++) {
|
|
116
|
+
const version = await fetchRunningHubVersion(port);
|
|
117
|
+
if (!version)
|
|
118
|
+
continue; // port not a Hub
|
|
119
|
+
const pid = await fetchHubPid(port);
|
|
120
|
+
if (pid === null || pid === excludePid)
|
|
121
|
+
continue; // same process we already killed
|
|
122
|
+
console.log(`Killing orphan Hub (pid ${pid}) on port ${port}`);
|
|
123
|
+
killPid(pid);
|
|
124
|
+
await waitForPortFree(port);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// The one entry point both launch paths call before starting their own server: reads
|
|
128
|
+
// hub-runtime.json for `runtimeId`, confirms the recorded pid is actually alive and answering
|
|
129
|
+
// (not just present in a stale file), and - when the decision is `replace` - kills it, waits for
|
|
130
|
+
// its port to free, then sweeps for any unregistered orphan on the standard Hub port range.
|
|
131
|
+
async function reconcileRunningHub(flags, runtimeId = 'hub') {
|
|
132
|
+
const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
|
|
133
|
+
const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
|
|
134
|
+
const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
|
|
135
|
+
const effective = live && running ? { ...running, version: confirmedVersion } : null;
|
|
136
|
+
const decision = (0, hub_launch_decision_1.decideHubLaunch)({
|
|
137
|
+
running: effective,
|
|
138
|
+
pidAlive: live,
|
|
139
|
+
cliVersion: (0, version_utils_1.getFraimVersion)(),
|
|
140
|
+
restart: flags.restart,
|
|
141
|
+
noRestart: flags.keepRunning,
|
|
142
|
+
});
|
|
143
|
+
if (decision.action === 'replace' && effective) {
|
|
144
|
+
console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) - ${decision.reason}`);
|
|
145
|
+
killPid(effective.pid);
|
|
146
|
+
await waitForPortFree(effective.port);
|
|
147
|
+
// #921: after killing the registered pid, scan for orphan Hub processes that were
|
|
148
|
+
// never registered in hub-runtime.json (e.g. older versions started via bare npx).
|
|
149
|
+
if (runtimeId === 'hub') {
|
|
150
|
+
await scanAndKillOrphanHubs(effective.pid);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else if (decision.action === 'focus-existing' && effective) {
|
|
154
|
+
console.log(`A Hub (v${effective.version}) is already running - focusing it. Use --restart to replace it.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -2201,6 +2201,31 @@ function buildHubBackgroundTaskContinueMessage(run) {
|
|
|
2201
2201
|
'If results are not yet available, wait briefly or check again. Once you have the results, continue with your original plan.',
|
|
2202
2202
|
].join('\n');
|
|
2203
2203
|
}
|
|
2204
|
+
// #1419 (Change 3): auto-resume when the only unresolved condition is a
|
|
2205
|
+
// KILLED background task (see hasKilledBackgroundTask) — distinct from
|
|
2206
|
+
// buildHubBackgroundTaskContinueMessage above, which resumes a still-ACTIVE
|
|
2207
|
+
// resumable task. recoveryAttempts is NOT incremented — same budget-preserving
|
|
2208
|
+
// semantics as #1275's background_task resume.
|
|
2209
|
+
function createHubBackgroundTaskLostContinueEvent(run) {
|
|
2210
|
+
return (0, hosts_1.createHubEvent)('system', `Hub background-task-lost continuation for run ${run.id} session ${run.sessionId || 'unknown'} — a background task was killed before its work finished.${hostErrorSuffix(run)}`);
|
|
2211
|
+
}
|
|
2212
|
+
// `killedDescription` must be captured at classification time (classifyExit's
|
|
2213
|
+
// systemNote) rather than recomputed here: handleRunExit's resume path calls
|
|
2214
|
+
// clearBackgroundTaskLifecycle before this message is built (the setTimeout'd
|
|
2215
|
+
// continue), which wipes run.hostLifecycle.backgroundTasks. Recomputing from
|
|
2216
|
+
// the live run at that point would always fall back to the generic
|
|
2217
|
+
// 'a background task' description.
|
|
2218
|
+
function buildHubBackgroundTaskLostContinueMessage(run, killedDescription) {
|
|
2219
|
+
return [
|
|
2220
|
+
'[FRAIM Hub system recovery]',
|
|
2221
|
+
'This is not a manager-authored instruction. Do not say the manager asked you to continue.',
|
|
2222
|
+
`Run id: ${run.id}`,
|
|
2223
|
+
`Session id: ${run.sessionId || 'unknown'}`,
|
|
2224
|
+
`A background task was killed when a turn ended before it reached a terminal state: ${killedDescription || describeKilledBackgroundTasks(run)}.`,
|
|
2225
|
+
'Its result is unknown, not passing or failing.',
|
|
2226
|
+
'Check for partial output first; if none exists, re-run the work and say plainly that this is a re-run because the prior attempt\'s result was never observed.',
|
|
2227
|
+
].join('\n');
|
|
2228
|
+
}
|
|
2204
2229
|
function isHumanActionGate(run) {
|
|
2205
2230
|
if (run.stoppedByUser)
|
|
2206
2231
|
return true;
|
|
@@ -2248,19 +2273,31 @@ function hasActiveResumableBackgroundTask(run) {
|
|
|
2248
2273
|
return Object.values(tasks).some((task) => task.status === 'active' && isResumableBackgroundTaskType(task.taskType));
|
|
2249
2274
|
}
|
|
2250
2275
|
// Issue #1234: true when the host reported a background task as active
|
|
2251
|
-
// (`background_tasks_changed`) and it
|
|
2252
|
-
//
|
|
2253
|
-
//
|
|
2276
|
+
// (`background_tasks_changed`) and it never resolved before the host's child
|
|
2277
|
+
// process exited — a still-running, non-resumable task is a worse signal
|
|
2278
|
+
// than a cleanly killed one (it is "unexpectedly still running", not
|
|
2279
|
+
// "definitely gone") and must keep hard-erroring rather than auto-resuming.
|
|
2254
2280
|
// #1275: tightened — local_bash active tasks are handled by hasActiveResumableBackgroundTask above.
|
|
2255
|
-
// #
|
|
2256
|
-
//
|
|
2257
|
-
//
|
|
2258
|
-
|
|
2281
|
+
// #1419: split out of the old hasUnresolvedBackgroundTask so a killed task can be
|
|
2282
|
+
// classified separately (see hasKilledBackgroundTask) — Change 3's fix is to the
|
|
2283
|
+
// classification's *action* for the killed case, not to detection, which already
|
|
2284
|
+
// fired correctly for both cases.
|
|
2285
|
+
function hasActiveNonResumableBackgroundTask(run) {
|
|
2259
2286
|
const tasks = run.hostLifecycle?.backgroundTasks;
|
|
2260
2287
|
if (!tasks)
|
|
2261
2288
|
return false;
|
|
2262
|
-
return Object.values(tasks).some((task) =>
|
|
2263
|
-
|
|
2289
|
+
return Object.values(tasks).some((task) => task.status === 'active' && !isResumableBackgroundTaskType(task.taskType));
|
|
2290
|
+
}
|
|
2291
|
+
// #1419: true when the host reported a background task that resolved as
|
|
2292
|
+
// `killed` rather than `completed` — evidence the agent's (or its subagent's)
|
|
2293
|
+
// promised follow-up did not happen, but the work is definitively gone rather
|
|
2294
|
+
// than still running. Independent of taskType: a killed task is killed
|
|
2295
|
+
// regardless of what type the host labelled it.
|
|
2296
|
+
function hasKilledBackgroundTask(run) {
|
|
2297
|
+
const tasks = run.hostLifecycle?.backgroundTasks;
|
|
2298
|
+
if (!tasks)
|
|
2299
|
+
return false;
|
|
2300
|
+
return Object.values(tasks).some((task) => task.status === 'killed');
|
|
2264
2301
|
}
|
|
2265
2302
|
function describeUnresolvedBackgroundTasks(run) {
|
|
2266
2303
|
const descriptions = Object.values(run.hostLifecycle?.backgroundTasks || {})
|
|
@@ -2270,6 +2307,15 @@ function describeUnresolvedBackgroundTasks(run) {
|
|
|
2270
2307
|
return `The host ended this turn while ${list} was still active, and it was killed or never confirmed complete. `
|
|
2271
2308
|
+ `The agent's promised follow-up did not happen; review the conversation before continuing.`;
|
|
2272
2309
|
}
|
|
2310
|
+
// #1419: names the specific killed task(s) so the auto-resume continuation
|
|
2311
|
+
// message (buildHubBackgroundTaskLostContinueMessage) can be concrete about
|
|
2312
|
+
// what was lost, same spirit as describeUnresolvedBackgroundTasks above.
|
|
2313
|
+
function describeKilledBackgroundTasks(run) {
|
|
2314
|
+
const descriptions = Object.values(run.hostLifecycle?.backgroundTasks || {})
|
|
2315
|
+
.filter((task) => task.status === 'killed')
|
|
2316
|
+
.map((task) => task.description || 'a background task');
|
|
2317
|
+
return descriptions.length > 0 ? descriptions.join('; ') : 'a background task';
|
|
2318
|
+
}
|
|
2273
2319
|
function classifyExit(run, exitCode) {
|
|
2274
2320
|
if (run.stoppedByUser) {
|
|
2275
2321
|
return { action: 'park', pauseReason: 'stopped' };
|
|
@@ -2321,26 +2367,50 @@ function classifyExit(run, exitCode) {
|
|
|
2321
2367
|
if (hasActiveResumableBackgroundTask(run)) {
|
|
2322
2368
|
return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task' };
|
|
2323
2369
|
}
|
|
2324
|
-
|
|
2370
|
+
// #1419 (Change 3): an unexpectedly-still-running non-resumable task is a
|
|
2371
|
+
// worse signal than a cleanly killed one — keep hard-erroring for it.
|
|
2372
|
+
if (hasActiveNonResumableBackgroundTask(run)) {
|
|
2325
2373
|
return { action: 'error', pauseReason: 'error', systemNote: describeUnresolvedBackgroundTasks(run) };
|
|
2326
2374
|
}
|
|
2375
|
+
// When the ONLY unresolved condition is a killed task, the work is
|
|
2376
|
+
// definitely gone rather than still running: auto-resume once with an
|
|
2377
|
+
// evidence-honest continuation instead of forcing the manager to say
|
|
2378
|
+
// "continue" with no evidence of what was lost.
|
|
2379
|
+
if (hasKilledBackgroundTask(run)) {
|
|
2380
|
+
// systemNote computed now, before clearBackgroundTaskLifecycle wipes
|
|
2381
|
+
// the task map in handleRunExit — the continuation message needs the
|
|
2382
|
+
// real description, not the post-clear fallback.
|
|
2383
|
+
return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task_lost', systemNote: describeKilledBackgroundTasks(run) };
|
|
2384
|
+
}
|
|
2327
2385
|
return { action: 'park', pauseReason: 'awaiting_user' };
|
|
2328
2386
|
}
|
|
2329
2387
|
if (compactingActive) {
|
|
2330
2388
|
return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
|
|
2331
2389
|
}
|
|
2332
|
-
if (
|
|
2390
|
+
if (hasActiveNonResumableBackgroundTask(run)) {
|
|
2333
2391
|
return { action: 'error', pauseReason: 'error', systemNote: describeUnresolvedBackgroundTasks(run) };
|
|
2334
2392
|
}
|
|
2393
|
+
if (hasKilledBackgroundTask(run)) {
|
|
2394
|
+
// systemNote computed now, before clearBackgroundTaskLifecycle wipes
|
|
2395
|
+
// the task map in handleRunExit — the continuation message needs the
|
|
2396
|
+
// real description, not the post-clear fallback.
|
|
2397
|
+
return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task_lost', systemNote: describeKilledBackgroundTasks(run) };
|
|
2398
|
+
}
|
|
2335
2399
|
// Phase mid-flight (started but not completed/incomplete): conservative park.
|
|
2336
2400
|
return { action: 'park', pauseReason: 'awaiting_user' };
|
|
2337
2401
|
}
|
|
2338
2402
|
if (compactingActive) {
|
|
2339
2403
|
return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
|
|
2340
2404
|
}
|
|
2341
|
-
if (
|
|
2405
|
+
if (hasActiveNonResumableBackgroundTask(run)) {
|
|
2342
2406
|
return { action: 'error', pauseReason: 'error', systemNote: describeUnresolvedBackgroundTasks(run) };
|
|
2343
2407
|
}
|
|
2408
|
+
if (hasKilledBackgroundTask(run)) {
|
|
2409
|
+
// systemNote computed now, before clearBackgroundTaskLifecycle wipes the
|
|
2410
|
+
// task map in handleRunExit — the continuation message needs the real
|
|
2411
|
+
// description, not the post-clear fallback.
|
|
2412
|
+
return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task_lost', systemNote: describeKilledBackgroundTasks(run) };
|
|
2413
|
+
}
|
|
2344
2414
|
// Signal-less non-FRAIM run: conservative park.
|
|
2345
2415
|
return { action: 'park', pauseReason: 'awaiting_user' };
|
|
2346
2416
|
}
|
|
@@ -2478,6 +2548,7 @@ class AiHubServer {
|
|
|
2478
2548
|
this.configuredAgentStore = options.configuredAgentStore || new configured_agents_1.AiHubConfiguredAgentStore();
|
|
2479
2549
|
this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
|
|
2480
2550
|
this.folderPicker = options.folderPicker ?? pickProjectPath;
|
|
2551
|
+
this.restartToLatest = options.restartToLatest ?? (async () => ({ restarting: false }));
|
|
2481
2552
|
this.httpsPort = options.httpsPort;
|
|
2482
2553
|
this.certBundle = options.certBundle;
|
|
2483
2554
|
this.hubUiCacheDir = options.hubUiCacheDir || process.env.FRAIM_HUB_UI_CACHE_DIR || (0, ui_cache_1.defaultHubUiCacheDir)();
|
|
@@ -5381,6 +5452,19 @@ class AiHubServer {
|
|
|
5381
5452
|
this.app.get('/api/ai-hub/pid', (_req, res) => {
|
|
5382
5453
|
return res.json({ pid: process.pid });
|
|
5383
5454
|
});
|
|
5455
|
+
// Issue #1415/#1379: the update badge's click handler POSTs here instead of just telling the
|
|
5456
|
+
// user to quit and relaunch by hand. `restartToLatest` (the Electron shell's
|
|
5457
|
+
// restartToLatestNow) responds before it actually tears the process down, so the client can
|
|
5458
|
+
// rely on this response arriving and then poll /api/ai-hub/version for the new instance.
|
|
5459
|
+
this.app.post('/api/ai-hub/restart-to-latest', async (_req, res) => {
|
|
5460
|
+
try {
|
|
5461
|
+
const result = await this.restartToLatest();
|
|
5462
|
+
return res.json(result);
|
|
5463
|
+
}
|
|
5464
|
+
catch (error) {
|
|
5465
|
+
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not restart to the latest version.' });
|
|
5466
|
+
}
|
|
5467
|
+
});
|
|
5384
5468
|
this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
|
|
5385
5469
|
try {
|
|
5386
5470
|
const projectPath = await this.folderPicker();
|
|
@@ -7407,9 +7491,11 @@ class AiHubServer {
|
|
|
7407
7491
|
// entries from the dead Claude Code process don't re-trigger hasActiveResumableBackgroundTask
|
|
7408
7492
|
// on the next exit, causing an infinite loop.
|
|
7409
7493
|
this.runRegistry.update(runId, (current) => {
|
|
7410
|
-
if (classification.recoveryKind === 'background_task') {
|
|
7494
|
+
if (classification.recoveryKind === 'background_task' || classification.recoveryKind === 'background_task_lost') {
|
|
7411
7495
|
clearBackgroundTaskLifecycle(current);
|
|
7412
|
-
current.events.push(
|
|
7496
|
+
current.events.push(classification.recoveryKind === 'background_task'
|
|
7497
|
+
? createHubBackgroundTaskContinueEvent(current)
|
|
7498
|
+
: createHubBackgroundTaskLostContinueEvent(current));
|
|
7413
7499
|
}
|
|
7414
7500
|
else {
|
|
7415
7501
|
current.recoveryAttempts = (current.recoveryAttempts ?? 0) + 1;
|
|
@@ -7444,7 +7530,9 @@ class AiHubServer {
|
|
|
7444
7530
|
? buildHubCompactionRecoveryContinueMessage(current, exitCode, attempt)
|
|
7445
7531
|
: classification.recoveryKind === 'background_task'
|
|
7446
7532
|
? buildHubBackgroundTaskContinueMessage(current)
|
|
7447
|
-
:
|
|
7533
|
+
: classification.recoveryKind === 'background_task_lost'
|
|
7534
|
+
? buildHubBackgroundTaskLostContinueMessage(current, classification.systemNote)
|
|
7535
|
+
: buildHubRecoveryContinueMessage(current, exitCode, attempt);
|
|
7448
7536
|
// Issue #1150: recovery must relaunch as the SAME configured agent. Its setup
|
|
7449
7537
|
// script supplies the env that selects the profile the host session lives in
|
|
7450
7538
|
// (CODEX_HOME for Codex), and resuming without it sends `codex exec resume`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.291",
|
|
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.291",
|
|
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
|
@@ -16078,11 +16078,12 @@ function tfWireShell() {
|
|
|
16078
16078
|
}
|
|
16079
16079
|
const avatar = document.getElementById('avatar-btn');
|
|
16080
16080
|
if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
|
|
16081
|
-
// #
|
|
16082
|
-
// the
|
|
16083
|
-
// second
|
|
16081
|
+
// Issue #1415: clicking the top-bar badge now restarts the Hub straight into the latest
|
|
16082
|
+
// published version (the same "resolve latest, relaunch" the desktop shell already does on
|
|
16083
|
+
// every second-instance/activate attempt) instead of opening the account menu to read
|
|
16084
|
+
// instructions for quitting and relaunching by hand.
|
|
16084
16085
|
const updateBadge = document.getElementById('hub-update-badge');
|
|
16085
|
-
if (updateBadge) updateBadge.addEventListener('click',
|
|
16086
|
+
if (updateBadge) updateBadge.addEventListener('click', tfRestartHubToLatest);
|
|
16086
16087
|
tfWireThemeToggle();
|
|
16087
16088
|
const accountItem = document.getElementById('am-account');
|
|
16088
16089
|
if (accountItem) accountItem.addEventListener('click', (e) => {
|
|
@@ -16334,6 +16335,60 @@ function tfPopulateVersionInfo() {
|
|
|
16334
16335
|
.catch(() => { /* offline / best-effort: leave both surfaces hidden */ });
|
|
16335
16336
|
}
|
|
16336
16337
|
|
|
16338
|
+
// Issue #1415: the update badge used to only tell the user to quit and relaunch by hand
|
|
16339
|
+
// (#1379). This resolves latest npm-published FRAIM Hub and relaunches into it directly - the
|
|
16340
|
+
// desktop shell responds to the POST before it actually tears itself down (see
|
|
16341
|
+
// restartToLatestNow in desktop-main.ts), so the ack below is reliable; the reload then waits
|
|
16342
|
+
// for the *new* instance specifically (matching `latest`), not just any response, so it never
|
|
16343
|
+
// reloads against the old process mid-shutdown.
|
|
16344
|
+
function tfRestartHubToLatest(event) {
|
|
16345
|
+
if (event) event.preventDefault();
|
|
16346
|
+
const badgeEl = document.getElementById('hub-update-badge');
|
|
16347
|
+
if (badgeEl) {
|
|
16348
|
+
badgeEl.disabled = true;
|
|
16349
|
+
badgeEl.textContent = '⬆️ Restarting…';
|
|
16350
|
+
}
|
|
16351
|
+
fetch('/api/ai-hub/restart-to-latest', { method: 'POST' })
|
|
16352
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
|
|
16353
|
+
.then((info) => {
|
|
16354
|
+
if (info && info.restarting) {
|
|
16355
|
+
tfPollForHubRestart(info.latest || null, 0);
|
|
16356
|
+
} else if (badgeEl) {
|
|
16357
|
+
badgeEl.disabled = false;
|
|
16358
|
+
badgeEl.textContent = '⬆️ Updates available';
|
|
16359
|
+
}
|
|
16360
|
+
})
|
|
16361
|
+
.catch(() => {
|
|
16362
|
+
if (badgeEl) {
|
|
16363
|
+
badgeEl.disabled = false;
|
|
16364
|
+
badgeEl.textContent = '⬆️ Updates available';
|
|
16365
|
+
}
|
|
16366
|
+
});
|
|
16367
|
+
}
|
|
16368
|
+
|
|
16369
|
+
const HUB_RESTART_POLL_MS = 1000;
|
|
16370
|
+
const HUB_RESTART_POLL_MAX_ATTEMPTS = 45; // ~45s: generous for a fresh materialize-from-npm relaunch
|
|
16371
|
+
|
|
16372
|
+
function tfPollForHubRestart(targetVersion, attempt) {
|
|
16373
|
+
if (attempt >= HUB_RESTART_POLL_MAX_ATTEMPTS) {
|
|
16374
|
+
window.location.reload();
|
|
16375
|
+
return;
|
|
16376
|
+
}
|
|
16377
|
+
setTimeout(() => {
|
|
16378
|
+
fetch('/api/ai-hub/version')
|
|
16379
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
|
|
16380
|
+
.then((info) => {
|
|
16381
|
+
// No target to match (best-effort) or the new instance is the one now answering.
|
|
16382
|
+
if (!targetVersion || (info && info.version === targetVersion)) {
|
|
16383
|
+
window.location.reload();
|
|
16384
|
+
} else {
|
|
16385
|
+
tfPollForHubRestart(targetVersion, attempt + 1);
|
|
16386
|
+
}
|
|
16387
|
+
})
|
|
16388
|
+
.catch(() => tfPollForHubRestart(targetVersion, attempt + 1));
|
|
16389
|
+
}, HUB_RESTART_POLL_MS);
|
|
16390
|
+
}
|
|
16391
|
+
|
|
16337
16392
|
function tfRequestedConnectedSurface() {
|
|
16338
16393
|
try {
|
|
16339
16394
|
const params = new URLSearchParams(window.location.search);
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -4494,6 +4494,10 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
4494
4494
|
.hub-update-badge:hover { opacity: .85; box-shadow: 0 2px 8px rgba(0,0,0,.22); }
|
|
4495
4495
|
.hub-update-badge:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
4496
4496
|
.hub-update-badge[hidden] { display: none; }
|
|
4497
|
+
/* Issue #1415: while the restart-to-latest request is in flight / the new instance is coming up,
|
|
4498
|
+
the badge shows a live "Restarting…" state rather than the static hover affordance. */
|
|
4499
|
+
.hub-update-badge:disabled { cursor: default; opacity: .7; }
|
|
4500
|
+
.hub-update-badge:disabled:hover { box-shadow: 0 1px 4px rgba(0,0,0,.18); }
|
|
4497
4501
|
|
|
4498
4502
|
/* Suppress the old Hub's header and rail when inside the workspace-conv */
|
|
4499
4503
|
.workspace-conv .header { display: none !important; }
|