fraim-hub 2.0.291 → 2.0.293

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.
@@ -78,6 +78,13 @@ function normalizeConversationKey(key) {
78
78
  function normalizeProjectPath(projectPath) {
79
79
  return normalizeConversationKey(projectPath);
80
80
  }
81
+ function scopeForBucket(projectPath) {
82
+ if (projectPath === exports.MANAGER_SCOPE_KEY)
83
+ return 'manager';
84
+ if (projectPath === exports.COMPANY_SCOPE_KEY)
85
+ return 'company';
86
+ return undefined;
87
+ }
81
88
  // Issue #708: fold legacy manager/company-invoked runs into the reserved scope buckets on read.
82
89
  function migrateScopeBuckets(store) {
83
90
  const projects = {};
@@ -297,9 +304,11 @@ function normalizeConversation(projectPath, raw) {
297
304
  return null;
298
305
  if (value.status !== 'running' && value.status !== 'completed' && value.status !== 'failed')
299
306
  return null;
307
+ const repairedScope = value.scope ?? scopeForBucket(projectPath);
300
308
  return {
301
309
  ...value,
302
310
  id: value.id,
311
+ ...(repairedScope !== undefined && { scope: repairedScope }),
303
312
  projectPath: normalizeProjectPath(typeof value.projectPath === 'string' ? value.projectPath : projectPath),
304
313
  title: value.title,
305
314
  jobId: value.jobId,
@@ -65,6 +65,20 @@ function parseArgs(argv) {
65
65
  }
66
66
  return { projectPath, preferredPort, runtimeId };
67
67
  }
68
+ // Issue #1415 (follow-up): the orphan sweep inside reconcileRunningHub hits real system ports
69
+ // directly (not scoped to FRAIM_USER_DIR), so a test proving it fires must not point it at the
70
+ // real 43091-43200 range - that could kill whatever Hub is genuinely running on the machine the
71
+ // test happens to execute on. Not used in any real launch path; mirrors the existing
72
+ // FRAIM_HUB_SKIP_MATERIALIZE / FRAIM_INSTALLER_LIFECYCLE_TEST kill-switch pattern.
73
+ function resolveOrphanScanPortRangeOverride() {
74
+ const raw = process.env.FRAIM_HUB_ORPHAN_SCAN_PORT_RANGE;
75
+ if (!raw)
76
+ return hub_instance_reconciliation_1.DEFAULT_HUB_ORPHAN_SCAN_PORT_RANGE;
77
+ const [start, end] = raw.split('-').map(Number);
78
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end)
79
+ return hub_instance_reconciliation_1.DEFAULT_HUB_ORPHAN_SCAN_PORT_RANGE;
80
+ return { start, end };
81
+ }
68
82
  function applyUserDataOverride() {
69
83
  const userDataDir = process.env.FRAIM_AI_HUB_USER_DATA_DIR;
70
84
  if (!userDataDir)
@@ -570,7 +584,7 @@ async function bootstrap() {
570
584
  // regardless of FRAIM_USER_DIR isolation, so a test double (FAKE_HOST=1) skips it for the same
571
585
  // reason it skips the lock — it must never disturb a real Hub already running on this machine.
572
586
  if (!skipSingleInstance) {
573
- await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: true, keepRunning: false }, options.runtimeId);
587
+ await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: true, keepRunning: false }, options.runtimeId, resolveOrphanScanPortRangeOverride());
574
588
  }
575
589
  // macOS reads the dock icon from the app bundle once packaged, but an
576
590
  // unpackaged `npm run hub:desktop` / `npx fraim-hub` run shows Electron's
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_HUB_ORPHAN_SCAN_PORT_RANGE = void 0;
6
7
  exports.killPid = killPid;
7
8
  exports.isPortFree = isPortFree;
8
9
  exports.waitForPortFree = waitForPortFree;
@@ -109,10 +110,13 @@ function fetchHubPid(port) {
109
110
  req.on('timeout', () => { req.destroy(); resolve(null); });
110
111
  });
111
112
  }
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++) {
113
+ exports.DEFAULT_HUB_ORPHAN_SCAN_PORT_RANGE = { start: 43091, end: 43200 };
114
+ // `portRange` is injectable (default: the real Hub port range) so a test can point this at an
115
+ // isolated range instead - the scan hits real system ports directly, independent of any
116
+ // FRAIM_USER_DIR isolation, so exercising it against the real range in a test would risk killing
117
+ // whatever Hub is genuinely running on this machine at the time.
118
+ async function scanAndKillOrphanHubs(excludePid, portRange = exports.DEFAULT_HUB_ORPHAN_SCAN_PORT_RANGE) {
119
+ for (let port = portRange.start; port <= portRange.end; port++) {
116
120
  const version = await fetchRunningHubVersion(port);
117
121
  if (!version)
118
122
  continue; // port not a Hub
@@ -127,8 +131,8 @@ async function scanAndKillOrphanHubs(excludePid) {
127
131
  // The one entry point both launch paths call before starting their own server: reads
128
132
  // hub-runtime.json for `runtimeId`, confirms the recorded pid is actually alive and answering
129
133
  // (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') {
134
+ // its port to free. The orphan sweep (#921) then runs independently of that decision - see below.
135
+ async function reconcileRunningHub(flags, runtimeId = 'hub', orphanScanPortRange = exports.DEFAULT_HUB_ORPHAN_SCAN_PORT_RANGE) {
132
136
  const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
133
137
  const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
134
138
  const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
@@ -140,17 +144,27 @@ async function reconcileRunningHub(flags, runtimeId = 'hub') {
140
144
  restart: flags.restart,
141
145
  noRestart: flags.keepRunning,
142
146
  });
147
+ let replacedPid = null;
143
148
  if (decision.action === 'replace' && effective) {
144
149
  console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) - ${decision.reason}`);
145
150
  killPid(effective.pid);
146
151
  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
+ replacedPid = effective.pid;
152
153
  }
153
154
  else if (decision.action === 'focus-existing' && effective) {
154
155
  console.log(`A Hub (v${effective.version}) is already running - focusing it. Use --restart to replace it.`);
156
+ return;
157
+ }
158
+ // Issue #1415 (follow-up): confirmed live - a Hub started via a different launch path (so it
159
+ // was never registered in hub-runtime.json at all, e.g. an older `npx fraim-hub` build) is only
160
+ // ever caught here. The bug this closes: the *registered* Hub can perfectly well have already
161
+ // quit on its own (a clean shutdown, or - observed live - electron-updater's own native update
162
+ // silently restarting it) by the time this reconciliation runs. That collapses the decision to
163
+ // 'launch-fresh', and the sweep used to live *inside* the 'replace' branch above, so a real,
164
+ // still-running orphan on the standard port range was never even looked for. The sweep must run
165
+ // whenever there is no live-and-focusable Hub for this runtimeId, not only when one was found
166
+ // and replaced - "no other launch path registered a Hub" is not evidence "no Hub is running."
167
+ if (runtimeId === 'hub') {
168
+ await scanAndKillOrphanHubs(replacedPid, orphanScanPortRange);
155
169
  }
156
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.291",
3
+ "version": "2.0.293",
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.291",
214
+ "fraim": "2.0.293",
215
215
  "mongodb": "^7.0.0",
216
216
  "node-cron": "4.2.1",
217
217
  "node-edge-tts": "^1.2.10",
@@ -1405,6 +1405,12 @@ function persistConversations(options) {
1405
1405
  if (opts.disk !== false) scheduleConversationDiskPersist();
1406
1406
  }
1407
1407
 
1408
+ function renderActiveScopedArea() {
1409
+ if (typeof tf === 'undefined') return;
1410
+ if (tf.area === 'manager' && typeof tfRenderManager === 'function') tfRenderManager();
1411
+ else if (tf.area === 'company' && typeof tfRenderCompany === 'function') tfRenderCompany();
1412
+ }
1413
+
1408
1414
  async function hydrateConversationsFromServer() {
1409
1415
  // Issue #708: load the project-independent scope buckets (manager/company) first, so
1410
1416
  // manager runs are present regardless of whether a project is selected.
@@ -1425,7 +1431,7 @@ async function hydrateConversationsFromServer() {
1425
1431
  console.warn('Could not hydrate manager/company conversations:', error);
1426
1432
  }
1427
1433
  if (!state.projectPath) {
1428
- if (typeof tfRenderManager === 'function' && typeof tf !== 'undefined' && tf.area === 'manager') tfRenderManager();
1434
+ renderActiveScopedArea();
1429
1435
  return;
1430
1436
  }
1431
1437
  const projectPath = state.projectPath;
@@ -1454,6 +1460,7 @@ async function hydrateConversationsFromServer() {
1454
1460
  renderRail();
1455
1461
  renderActive();
1456
1462
  await hydrateConversationBody(projectPath, state.activeId);
1463
+ renderActiveScopedArea();
1457
1464
  syncConversationRefreshPolling();
1458
1465
  } catch (error) {
1459
1466
  if (state.conversationBodiesPending === projectPath) state.conversationBodiesPending = null;
@@ -6280,8 +6287,9 @@ function renderNextJobRecommendations(conv) {
6280
6287
  const instructions = issueNumber
6281
6288
  ? `Continue from issue #${issueNumber}: ${summary}`
6282
6289
  : `Continue from ${conv.jobTitle || 'the previous job'}: ${summary}`;
6283
- const area = conv.scope === 'manager' ? 'manager'
6284
- : conv.scope === 'company' ? 'company'
6290
+ const scope = convScope(conv);
6291
+ const area = scope === 'manager' ? 'manager'
6292
+ : scope === 'company' ? 'company'
6285
6293
  : 'projects';
6286
6294
  const employeeId = conversationAgentName(conv) || (state.selectedEmployeeId) || 'claude';
6287
6295
  // Issue #989 R1-R6: open step-2 with job pre-selected and instructions pre-filled
@@ -8053,7 +8061,7 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
8053
8061
  projectPath: state.projectPath,
8054
8062
  // Issue #892: send the run scope so project-independent runs (Company/Manager
8055
8063
  // onboarding) can start with no project selected.
8056
- scope: conv.scope,
8064
+ scope: convScope(conv),
8057
8065
  hostId: baseHostIdForAgent(employeeId),
8058
8066
  configuredAgentId: employeeId,
8059
8067
  jobId: job.id,
@@ -8105,13 +8113,14 @@ async function restartConvWithAgent(conv, newAgentId, text) {
8105
8113
  renderRail();
8106
8114
  renderActive();
8107
8115
  try {
8116
+ const scope = convScope(conv);
8108
8117
  const result = await requestJson(`/api/ai-hub/conversations/${encodeURIComponent(conv.id)}/switch-agent`, {
8109
8118
  method: 'POST',
8110
8119
  headers: { 'Content-Type': 'application/json' },
8111
8120
  body: JSON.stringify({
8112
8121
  projectPath: state.projectPath,
8113
8122
  configuredAgentId: newAgentId,
8114
- scope: conv.scope === 'manager' || conv.scope === 'company' ? conv.scope : undefined,
8123
+ scope: scope === 'manager' || scope === 'company' ? scope : undefined,
8115
8124
  instructions: text,
8116
8125
  }),
8117
8126
  });
@@ -11599,6 +11608,12 @@ function kbMakeCard(conv) {
11599
11608
  // Kanban/Cards overview is read-only. Clicking anywhere on the card navigates to the workspace.
11600
11609
  const navigate = () => {
11601
11610
  if (!conv.id) return;
11611
+ const scope = convScope(conv);
11612
+ if (scope === 'manager' || scope === 'company') {
11613
+ tfShowArea(scope);
11614
+ switchToConversation(conv.id);
11615
+ return;
11616
+ }
11602
11617
  const _proj = conv.projectPath ? tfProjectForPath(conv.projectPath) : null;
11603
11618
  tfSelectProjectView('workspace', _proj ? _proj.id : tf.activeProjectId);
11604
11619
  switchToConversation(conv.id);