fraim-hub 2.0.297 → 2.0.298

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.
@@ -630,12 +630,24 @@ async function bootstrap() {
630
630
  });
631
631
  await launchDesktopShell(options);
632
632
  }
633
- // Issue #1415: self-execution is now gated on `require.main === module` in addition to the
633
+ function isDirectElectronEntry() {
634
+ if (require.main === module)
635
+ return true;
636
+ const currentFile = path_1.default.resolve(__filename).toLowerCase();
637
+ return process.argv.some((arg) => {
638
+ try {
639
+ return path_1.default.resolve(arg).toLowerCase() === currentFile;
640
+ }
641
+ catch {
642
+ return false;
643
+ }
644
+ });
645
+ }
646
+ // Issue #1415: self-execution is gated on a direct Electron entry check in addition to the
634
647
  // existing Electron-main-process check, so `desktop-launcher.ts` requiring this file (bundled or
635
- // materialized) does not double-run bootstrap — the launcher calls the exported `bootstrap`
636
- // explicitly instead. Direct invocation (`npm run hub:desktop`, Electron-launching test suites)
637
- // is unaffected: this file is still `require.main` in that case.
638
- if (require.main === module && process.versions.electron && process.type !== 'renderer') {
648
+ // materialized) does not double-run bootstrap. Electron's loader can own `require.main`, so
649
+ // direct test launches also match the resolved script path in argv.
650
+ if (isDirectElectronEntry() && process.versions.electron && process.type !== 'renderer') {
639
651
  bootstrap().catch(async (error) => {
640
652
  console.error(error instanceof Error ? error.message : error);
641
653
  await stopServerOnce();
@@ -2257,13 +2257,15 @@ class FakeHostRuntime {
2257
2257
  detectEmployees() {
2258
2258
  return this.employees;
2259
2259
  }
2260
- startRun(hostId, _projectPath, message, handlers, _sessionId) {
2260
+ startRun(hostId, _projectPath, message, handlers, _sessionId, launchContext) {
2261
2261
  this.lastStartMessage = message;
2262
+ this.lastLaunchContext = launchContext;
2262
2263
  const reply = this.startReply ?? this.fakeEmployeeReply('start', message);
2263
2264
  return this.fakeProcess(hostId, reply, handlers);
2264
2265
  }
2265
- continueRun(hostId, _projectPath, sessionId, message, handlers) {
2266
+ continueRun(hostId, _projectPath, sessionId, message, handlers, launchContext) {
2266
2267
  this.lastContinueMessage = message;
2268
+ this.lastLaunchContext = launchContext;
2267
2269
  return this.fakeProcess(hostId, this.fakeEmployeeReply('continue', message), handlers);
2268
2270
  }
2269
2271
  startDirectRun(hostId, _message, _projectPath, handlers, _sessionId) {
@@ -50,7 +50,7 @@ function buildCommunicationStyleNote() {
50
50
  function buildBackgroundTaskPolicyNote() {
51
51
  return [
52
52
  '',
53
- '[Background task policy] You are running in Hub headless mode. Never end your turn while background tasks (bash commands started with run_in_background=true, or sub-agents) are still active. Instead: monitor them, poll for results, and provide periodic progress commentary to the manager. Only end your turn when all background tasks have finished, or when you genuinely need a user decision or input to continue. If you exit while a background task is active, the Hub loses the link to that task permanently.',
53
+ '[Background task policy] You are running in Hub headless mode. Never end your turn while background tasks (bash commands started with run_in_background=true, or sub-agents) are still active. Instead: monitor them, poll for results, and provide periodic progress commentary to the manager. Only end your turn when all background tasks have finished, or when you genuinely need a user decision or input to continue. If you exit while a background task is active, the Hub loses the link to that task permanently. Background tasks and Monitor watchers cannot survive this turn ending: the next turn is a new process with no link to the old one (Windows: hard-killed; Unix: orphaned). The two things that reliably work: run the command synchronously in the foreground within this turn, or supervise it with `npx tsx ~/.fraim/scripts/exec-with-timeout.ts --supervise <name> --timeout <n> -- <command>` and poll `--status`/`--wait` (or `--stop` to cancel) without ending your turn.',
54
54
  ].join('\n');
55
55
  }
56
56
  // Issue #732: a plain continue of the SAME active job must not re-load the job.
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HubConnectorStatusStore = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
40
  exports.selfHealBrokenManagedAgents = selfHealBrokenManagedAgents;
41
41
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
42
+ exports.installAgentAndRefreshDetection = installAgentAndRefreshDetection;
42
43
  exports.hubCommandVersion = hubCommandVersion;
43
44
  exports.buildOpenFileInvocation = buildOpenFileInvocation;
44
45
  const express_1 = __importDefault(require("express"));
@@ -1760,6 +1761,30 @@ async function configureFraimForHubAgent(hubId) {
1760
1761
  return { configured: false, error: e instanceof Error ? e.message : String(e) };
1761
1762
  }
1762
1763
  }
1764
+ // Issue #1429: extracted from the install-agent route so the post-install cache-invalidation
1765
+ // policy is directly unit-testable with fake deps (no real npm install, no running HTTP
1766
+ // server) rather than only reachable by exercising the whole route.
1767
+ //
1768
+ // Root cause of #1429: this used to invalidate only when `outcome.outcome === 'standard'`.
1769
+ // When the CLI is installed via FRAIM's own managed-Node fallback (`outcome === 'managed'` -
1770
+ // exactly what happens when a CLI is reachable only through FRAIM's managed install
1771
+ // directory, not the Hub process's inherited system PATH), the cache was never invalidated,
1772
+ // so `detectEmployees()` (which backs `configured-agents`/`reconfigure-surfaces`/the employee
1773
+ // roster) kept serving its pre-install "unavailable" answer for up to
1774
+ // `EMPLOYEE_DETECTION_TTL_MS` (5 minutes) while `check-agent`'s uncached `hubCommandVersion()`
1775
+ // found the freshly-installed managed copy immediately - the exact divergence reported.
1776
+ // `selfHealBrokenManagedAgents()` already establishes the correct pattern (invalidate
1777
+ // unconditionally after any successful install); this now matches it.
1778
+ async function installAgentAndRefreshDetection(option, systemPath, deps) {
1779
+ const outcome = await (0, managed_agent_install_1.installManagedAgent)(option, systemPath, deps);
1780
+ // Issue #1010/#1429: something is now installed that was not before - via a standard
1781
+ // system-PATH install OR FRAIM's own managed-Node fallback - so the cached agent-
1782
+ // availability answer is stale either way. Drop it here rather than waiting out the TTL,
1783
+ // so the newly installed CLI shows as available immediately regardless of which install
1784
+ // path produced it.
1785
+ (0, hosts_1.invalidateEmployeeDetectionCache)();
1786
+ return outcome;
1787
+ }
1763
1788
  // Exported for direct unit testing of the Defect D fix (issue #1284/#1285):
1764
1789
  // asserting this resolves via a real system install and skips a poisoned
1765
1790
  // project-local node_modules/.bin entry, without spinning up a full server.
@@ -2199,6 +2224,7 @@ function buildHubBackgroundTaskContinueMessage(run) {
2199
2224
  `Session id: ${run.sessionId || 'unknown'}`,
2200
2225
  'The background task may still be running as an orphan process. Check whether its results are available on disk and continue your work.',
2201
2226
  'If results are not yet available, wait briefly or check again. Once you have the results, continue with your original plan.',
2227
+ 'Going forward, do not end your turn while this kind of task is active: either run it synchronously in the foreground, or supervise it with `npx tsx ~/.fraim/scripts/exec-with-timeout.ts --supervise <name> --timeout <n> -- <command>` and poll `--status`/`--wait` without ending your turn — ending the turn is what put this task at risk the first time.',
2202
2228
  ].join('\n');
2203
2229
  }
2204
2230
  // #1419 (Change 3): auto-resume when the only unresolved condition is a
@@ -2224,6 +2250,7 @@ function buildHubBackgroundTaskLostContinueMessage(run, killedDescription) {
2224
2250
  `A background task was killed when a turn ended before it reached a terminal state: ${killedDescription || describeKilledBackgroundTasks(run)}.`,
2225
2251
  'Its result is unknown, not passing or failing.',
2226
2252
  '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.',
2253
+ 'When you re-run it, do not repeat the mistake: run it synchronously in the foreground, or supervise it with `npx tsx ~/.fraim/scripts/exec-with-timeout.ts --supervise <name> --timeout <n> -- <command>` and poll `--status`/`--wait` without ending your turn — ending the turn is what killed it the first time.',
2227
2254
  ].join('\n');
2228
2255
  }
2229
2256
  function isHumanActionGate(run) {
@@ -3130,6 +3157,28 @@ class AiHubServer {
3130
3157
  launchContext: { agent: resolved.agent, env },
3131
3158
  };
3132
3159
  }
3160
+ // Issue #1443: stamp the Hub's own run id into the spawned host process's
3161
+ // env so the local FRAIM MCP proxy (a grandchild of this spawn, wired to
3162
+ // the host CLI over stdio) can report run-scoped signals like executionMode
3163
+ // back to the correct run. The proxy has no other way to know it: the FRAIM
3164
+ // workflow/MCP session id it carries is minted independently of any Hub run,
3165
+ // and the host CLI's own session id (`run.sessionId`, set via
3166
+ // applyRunHostSessionSignal) is not known until the host reports it — often
3167
+ // well after the FRAIM MCP tools have already fired. FRAIM_HUB_RUN_ID is set
3168
+ // at spawn time instead, so it is available for the entire life of the
3169
+ // process. Call this at every startRun/continueRun call site (Direct-mode
3170
+ // spawns are exempt — they never load FRAIM, so there is nothing to report).
3171
+ withHubRunIdEnv(launchContext, runId) {
3172
+ return { ...launchContext, env: { ...(launchContext.env || {}), FRAIM_HUB_RUN_ID: runId } };
3173
+ }
3174
+ // Shared by both the runId-keyed and legacy by-session execution-mode routes.
3175
+ applyExecutionModeToRun(runId, body) {
3176
+ const normalizedMode = body.mode === 'trusted' ? 'trusted' : 'coached';
3177
+ const normalizedRuns = typeof body.completedRuns === 'number' ? body.completedRuns : 0;
3178
+ this.runRegistry.update(runId, (current) => {
3179
+ current.executionMode = { mode: normalizedMode, completedRuns: normalizedRuns };
3180
+ });
3181
+ }
3133
3182
  configuredAgentIdForConversation(conversation, employees) {
3134
3183
  const existingConfiguredAgentId = typeof conversation.configuredAgentId === 'string'
3135
3184
  ? conversation.configuredAgentId.trim()
@@ -3515,7 +3564,7 @@ class AiHubServer {
3515
3564
  this.maybeStartDelegatedChildRuns(updated);
3516
3565
  });
3517
3566
  },
3518
- }, startSessionSeedForHost(run.hostId, run.id), freshLaunch.launchContext);
3567
+ }, startSessionSeedForHost(run.hostId, run.id), this.withHubRunIdEnv(freshLaunch.launchContext, run.id));
3519
3568
  this.runRegistry.attachChildIfRunning(run.id, freshChild);
3520
3569
  }
3521
3570
  scheduleRunConversationPersistence(run, activeId) {
@@ -3808,7 +3857,7 @@ class AiHubServer {
3808
3857
  onExit: (exitCode) => {
3809
3858
  this.handleRunExit(currentRun.id, exitCode, options.postPark);
3810
3859
  },
3811
- }, launch.launchContext);
3860
+ }, this.withHubRunIdEnv(launch.launchContext, currentRun.id));
3812
3861
  this.runRegistry.attachChildIfRunning(currentRun.id, child);
3813
3862
  const updated = this.runRegistry.get(currentRun.id);
3814
3863
  if (updated)
@@ -4139,7 +4188,7 @@ class AiHubServer {
4139
4188
  }
4140
4189
  this.runRegistry.dispose(childRun.id);
4141
4190
  },
4142
- }, startSessionSeedForHost(managerRun.hostId, childRun.id), childLaunch.launchContext);
4191
+ }, startSessionSeedForHost(managerRun.hostId, childRun.id), this.withHubRunIdEnv(childLaunch.launchContext, childRun.id));
4143
4192
  this.runRegistry.attachChildIfRunning(childRun.id, child);
4144
4193
  }
4145
4194
  notifyManagerOfDelegatedChild(managerRunId, childRun) {
@@ -4296,7 +4345,7 @@ class AiHubServer {
4296
4345
  if (latestManager)
4297
4346
  this.drainPendingDelegatedReviews(latestManager);
4298
4347
  },
4299
- }, managerLaunch.launchContext);
4348
+ }, this.withHubRunIdEnv(managerLaunch.launchContext, managerRun.id));
4300
4349
  this.runRegistry.attachChildIfRunning(managerRun.id, child);
4301
4350
  }
4302
4351
  computeFirstRun(projectPath, jobCount, personas) {
@@ -5233,8 +5282,8 @@ class AiHubServer {
5233
5282
  },
5234
5283
  };
5235
5284
  const child = strategy === 'resume_same_host'
5236
- ? this.hostRuntime.continueRun(run.hostId, run.projectPath, priorSessionId, hostMessage, handlers, resolved.launchContext)
5237
- : this.hostRuntime.startRun(run.hostId, run.projectPath, hostMessage, handlers, startSessionSeedForHost(run.hostId, run.id), resolved.launchContext);
5285
+ ? this.hostRuntime.continueRun(run.hostId, run.projectPath, priorSessionId, hostMessage, handlers, this.withHubRunIdEnv(resolved.launchContext, run.id))
5286
+ : this.hostRuntime.startRun(run.hostId, run.projectPath, hostMessage, handlers, startSessionSeedForHost(run.hostId, run.id), this.withHubRunIdEnv(resolved.launchContext, run.id));
5238
5287
  this.runRegistry.attachChildIfRunning(run.id, child);
5239
5288
  attemptedRunId = null;
5240
5289
  console.info(`[ai-hub] hub.agent_switch.${strategy}`, {
@@ -5699,7 +5748,7 @@ class AiHubServer {
5699
5748
  loginHint: `Once installed, click "Check if Ready" to verify ${option.label} is on your PATH.`,
5700
5749
  });
5701
5750
  }
5702
- const outcome = await (0, managed_agent_install_1.installManagedAgent)({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath, { runProcess: hubRunProcess, commandVersion: hubCommandVersion });
5751
+ const outcome = await installAgentAndRefreshDetection({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath, { runProcess: hubRunProcess, commandVersion: hubCommandVersion });
5703
5752
  if (outcome.outcome === 'standard' && outcome.npmGlobalBinDirs.length > 0) {
5704
5753
  process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, outcome.npmGlobalBinDirs);
5705
5754
  }
@@ -5707,12 +5756,6 @@ class AiHubServer {
5707
5756
  if (!mcp.configured) {
5708
5757
  console.warn(`[ai-hub] install-agent: FRAIM add-ide did not run for ${option.label} (${outcome.outcome} install): ${mcp.error || 'unknown reason'}`);
5709
5758
  }
5710
- if (outcome.outcome === 'standard') {
5711
- // Issue #1010: something is now installed that was not before, so the cached
5712
- // agent-availability answer is stale. Drop it here rather than waiting out the
5713
- // TTL, so the newly installed CLI shows as available immediately.
5714
- (0, hosts_1.invalidateEmployeeDetectionCache)();
5715
- }
5716
5759
  return res.json({
5717
5760
  ok: true,
5718
5761
  message: `${option.label} installed successfully.`,
@@ -5838,21 +5881,35 @@ class AiHubServer {
5838
5881
  });
5839
5882
  // Issue #1345: accept executionMode forwarded from get_fraim_job response by the local proxy.
5840
5883
  // The proxy parses the Execution Mode Context block and POSTs here; Hub script reads from run poll.
5884
+ //
5885
+ // Issue #1443: the local proxy's only stable identity for "which run is this" is the
5886
+ // Hub's own run id (see withHubRunIdEnv / FRAIM_HUB_RUN_ID) — the FRAIM/MCP session id it
5887
+ // carries is unrelated to any Hub run, and the host CLI's own session id (run.sessionId)
5888
+ // often isn't known yet when the first get_fraim_job call fires. Runs by id, not session.
5889
+ this.app.post('/api/ai-hub/runs/:runId/execution-mode', (req, res) => {
5890
+ if (!this.requireTrustedHubOrigin(req, res))
5891
+ return;
5892
+ const run = this.runRegistry.get(req.params.runId);
5893
+ if (!run)
5894
+ return res.status(404).json({ error: 'Run not found.' });
5895
+ this.applyExecutionModeToRun(run.id, req.body);
5896
+ return res.json({ ok: true });
5897
+ });
5898
+ // Legacy fallback for a local proxy build that predates #1443 and has not yet been
5899
+ // updated to send FRAIM_HUB_RUN_ID. Session-id correlation is unreliable by construction
5900
+ // (see the comment above), so this only succeeds when a run happens to already carry a
5901
+ // matching sessionId; it exists to avoid a hard 404 during a mixed-version rollout, not as
5902
+ // a supported correlation path.
5841
5903
  this.app.post('/api/ai-hub/runs/by-session/:sessionId/execution-mode', (req, res) => {
5842
5904
  if (!this.requireTrustedHubOrigin(req, res))
5843
5905
  return;
5844
5906
  const { sessionId } = req.params;
5845
- const { mode, completedRuns } = req.body;
5846
5907
  if (!sessionId)
5847
5908
  return res.status(400).json({ error: 'sessionId required' });
5848
- const normalizedMode = mode === 'trusted' ? 'trusted' : 'coached';
5849
- const normalizedRuns = typeof completedRuns === 'number' ? completedRuns : 0;
5850
5909
  const run = this.runRegistry.all().find((r) => r.sessionId === sessionId);
5851
5910
  if (!run)
5852
5911
  return res.status(404).json({ error: 'Run not found for session.' });
5853
- this.runRegistry.update(run.id, (current) => {
5854
- current.executionMode = { mode: normalizedMode, completedRuns: normalizedRuns };
5855
- });
5912
+ this.applyExecutionModeToRun(run.id, req.body);
5856
5913
  return res.json({ ok: true });
5857
5914
  });
5858
5915
  this.app.post('/api/ai-hub/runs', (req, res) => {
@@ -6037,7 +6094,7 @@ class AiHubServer {
6037
6094
  this.maybeStartDelegatedChildRuns(updated);
6038
6095
  });
6039
6096
  },
6040
- }, startSessionSeedForHost(hostId, run.id), launchContext);
6097
+ }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
6041
6098
  this.runRegistry.attachChildIfRunning(run.id, child);
6042
6099
  // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
6043
6100
  // uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
@@ -6256,7 +6313,7 @@ class AiHubServer {
6256
6313
  this.maybeStartDelegatedChildRuns(updated);
6257
6314
  });
6258
6315
  },
6259
- }, startSessionSeedForHost(run.hostId, run.id), freshLaunch.launchContext);
6316
+ }, startSessionSeedForHost(run.hostId, run.id), this.withHubRunIdEnv(freshLaunch.launchContext, run.id));
6260
6317
  this.runRegistry.attachChildIfRunning(run.id, freshChild);
6261
6318
  const refreshedFresh = this.runRegistry.get(run.id);
6262
6319
  return res.json(refreshedFresh ? this.enrichRunForResponse(refreshedFresh) : refreshedFresh);
@@ -6341,7 +6398,7 @@ class AiHubServer {
6341
6398
  this.maybeStartDelegatedChildRuns(updated);
6342
6399
  });
6343
6400
  },
6344
- }, continueLaunch.launchContext, deliveryIntent);
6401
+ }, this.withHubRunIdEnv(continueLaunch.launchContext, run.id), deliveryIntent);
6345
6402
  if (!startedCodexReviewApprovalFallback)
6346
6403
  this.runRegistry.attachChildIfRunning(run.id, child);
6347
6404
  const refreshed = this.runRegistry.get(run.id);
@@ -6500,7 +6557,7 @@ class AiHubServer {
6500
6557
  onExit: (exitCode) => {
6501
6558
  this.handleRunExit(run.id, exitCode);
6502
6559
  },
6503
- }, launchContext);
6560
+ }, this.withHubRunIdEnv(launchContext, run.id));
6504
6561
  this.runRegistry.attachChildIfRunning(run.id, child);
6505
6562
  res.status(201).json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
6506
6563
  }
@@ -7269,7 +7326,7 @@ class AiHubServer {
7269
7326
  this.finalizeRunConversationProjection(run.id, updated.conversationId || updated.id);
7270
7327
  this.runRegistry.dispose(run.id);
7271
7328
  },
7272
- }, startSessionSeedForHost(hostId, run.id), launchContext);
7329
+ }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
7273
7330
  // Update the registry entry with the real child process handle.
7274
7331
  this.runRegistry.attachChildIfRunning(run.id, child);
7275
7332
  return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
@@ -7429,7 +7486,7 @@ class AiHubServer {
7429
7486
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
7430
7487
  this.runRegistry.dispose(run.id);
7431
7488
  },
7432
- }, startSessionSeedForHost(hostId, run.id), launchContext);
7489
+ }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
7433
7490
  this.runRegistry.create(run, child);
7434
7491
  return run;
7435
7492
  }
@@ -7585,7 +7642,7 @@ class AiHubServer {
7585
7642
  this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
7586
7643
  },
7587
7644
  onExit: (code) => this.handleRunExit(runId, code, postPark),
7588
- }, launchContext);
7645
+ }, this.withHubRunIdEnv(launchContext, runId));
7589
7646
  this.runRegistry.attachChildIfRunning(runId, child);
7590
7647
  }, delay);
7591
7648
  if (tid.unref)
@@ -172,6 +172,8 @@ class FraimDbService {
172
172
  this.orgAuditCollection = this.db.collection('fraim_org_audit');
173
173
  // Issue #1345 — job execution modes (Coached/Trusted).
174
174
  this.jobExecutionModesCollection = this.db.collection('fraim_job_execution_modes');
175
+ // Issue #1431 — per-user referral codes.
176
+ this.referralCodesCollection = this.db.collection('fraim_referral_codes');
175
177
  }
176
178
  async initializeIndexes() {
177
179
  if (!this.db)
@@ -252,6 +254,9 @@ class FraimDbService {
252
254
  await this.auditLogCollection.createIndex({ sequence: 1 }).catch(() => { });
253
255
  await this.auditLogCollection.createIndex({ userId: 1, ts: -1 }).catch(() => { });
254
256
  await this.auditLogCollection.createIndex({ ts: -1 }).catch(() => { });
257
+ // Issue #1431 — referral codes: one code per user, globally unique codes.
258
+ await this.referralCodesCollection.createIndex({ userId: 1 }, { unique: true }).catch(() => { });
259
+ await this.referralCodesCollection.createIndex({ code: 1 }, { unique: true }).catch(() => { });
255
260
  }
256
261
  async createSession(session) {
257
262
  if (!this.sessionsCollection)
@@ -613,6 +618,65 @@ class FraimDbService {
613
618
  throw new Error('DB not connected');
614
619
  return await this.signupsCollection.findOne({ email });
615
620
  }
621
+ async setSignupAttribution(email, data) {
622
+ if (!this.signupsCollection)
623
+ throw new Error('DB not connected');
624
+ const now = new Date();
625
+ await this.signupsCollection.updateOne({ email }, {
626
+ $setOnInsert: {
627
+ email,
628
+ name: '',
629
+ company: '',
630
+ source: data.source,
631
+ timestamp: now,
632
+ ipAddress: data.ipAddress,
633
+ userAgent: data.userAgent,
634
+ ...(data.referrerName ? { referrerName: data.referrerName } : {}),
635
+ ...(data.referralCodeUsed ? { referralCodeUsed: data.referralCodeUsed } : {}),
636
+ },
637
+ }, { upsert: true });
638
+ }
639
+ generateReferralCodeString() {
640
+ const crypto = require('crypto');
641
+ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
642
+ const bytes = crypto.randomBytes(12);
643
+ return Array.from(bytes).map((b) => ALPHABET[b % ALPHABET.length]).join('');
644
+ }
645
+ async getReferralCode(userId) {
646
+ if (!this.referralCodesCollection)
647
+ throw new Error('DB not connected');
648
+ return await this.referralCodesCollection.findOne({ userId });
649
+ }
650
+ async getOrCreateReferralCode(userId) {
651
+ if (!this.referralCodesCollection)
652
+ throw new Error('DB not connected');
653
+ const existing = await this.referralCodesCollection.findOne({ userId });
654
+ if (existing)
655
+ return existing.code;
656
+ for (let attempt = 0; attempt < 5; attempt++) {
657
+ const code = this.generateReferralCodeString();
658
+ try {
659
+ await this.referralCodesCollection.insertOne({ userId, code, createdAt: new Date() });
660
+ return code;
661
+ }
662
+ catch (err) {
663
+ if (err?.code === 11000) {
664
+ const row = await this.referralCodesCollection.findOne({ userId });
665
+ if (row)
666
+ return row.code;
667
+ continue;
668
+ }
669
+ throw err;
670
+ }
671
+ }
672
+ throw new Error('Failed to generate unique referral code after retries');
673
+ }
674
+ async validateReferralCode(code) {
675
+ if (!this.referralCodesCollection)
676
+ throw new Error('DB not connected');
677
+ const exists = await this.referralCodesCollection.findOne({ code });
678
+ return exists !== null;
679
+ }
616
680
  /** Get existing API key by userId (email for self-serve) */
617
681
  async getApiKeyByUserId(userId, activeOnly = true) {
618
682
  if (!this.db)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.297",
3
+ "version": "2.0.298",
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.297",
214
+ "fraim": "2.0.298",
215
215
  "mongodb": "^7.0.0",
216
216
  "node-cron": "4.2.1",
217
217
  "node-edge-tts": "^1.2.10",
@@ -11056,7 +11056,9 @@ async function tfSelectProjectView(view, projectId) {
11056
11056
  if (tab.id === 'ptab-add') continue;
11057
11057
  const isOverview = tab.id === 'ptab-overview';
11058
11058
  const matchesProj = tab.dataset.projectId === tf.activeProjectId;
11059
- tab.classList.toggle('on', view === 'overview' ? isOverview : matchesProj);
11059
+ const isOn = view === 'overview' ? isOverview : matchesProj;
11060
+ tab.classList.toggle('on', isOn);
11061
+ if (isOn && matchesProj) tab.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' });
11060
11062
  }
11061
11063
  if (view === 'overview') {
11062
11064
  tfRenderOverview();
@@ -11193,6 +11195,8 @@ function tfRenderProjectTabs() {
11193
11195
  btn.addEventListener('click', () => tfSelectProjectView('workspace', proj.id));
11194
11196
  host.appendChild(btn);
11195
11197
  }
11198
+ const activeTab = host.querySelector('.ptab.on');
11199
+ if (activeTab) activeTab.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' });
11196
11200
  }
11197
11201
 
11198
11202
  function tfRenderOverview() {