fraim-hub 2.0.236 → 2.0.237

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.
@@ -1848,12 +1848,18 @@ class AiHubServer {
1848
1848
  console.warn('[ai-hub] manager-team lookup failed:', err?.message || err);
1849
1849
  return [];
1850
1850
  });
1851
- const personaProjectionPromise = this.computePersonas(resolvedApiKey, managerTeamPromise);
1852
- const fallbackPersonaProjection = this.fallbackPersonaProjection(resolvedApiKey);
1851
+ const personaProjectionPromise = this.computePersonas(resolvedApiKey, managerTeamPromise, normalizedProjectPath);
1852
+ const fallbackPersonaProjection = this.fallbackPersonaProjection(resolvedApiKey, normalizedProjectPath);
1853
1853
  const personaProjection = await this.withFirstPaintBudget(personaProjectionPromise, fallbackPersonaProjection, 'persona projection');
1854
- const managerTeam = personaProjection === fallbackPersonaProjection
1855
- ? []
1856
- : await this.withFirstPaintBudget(managerTeamPromise, [], 'manager team');
1854
+ // Issue #1005: identity not persona contents — is what distinguishes a first-paint
1855
+ // placeholder from an answer. withFirstPaintBudget resolves to this exact object only
1856
+ // on timeout; computePersonas returns a fresh `{ ...fallbackProjection, userKey }` even
1857
+ // when the authority definitively locks everything (401 / feature-off). So a real
1858
+ // sign-out is still `resolved`, and only a budget miss is `unresolved`.
1859
+ const personasResolved = personaProjection !== fallbackPersonaProjection;
1860
+ const managerTeam = personasResolved
1861
+ ? await this.withFirstPaintBudget(managerTeamPromise, [], 'manager team')
1862
+ : [];
1857
1863
  const { personas, subscriptionActive, workspaceId, userKey } = personaProjection;
1858
1864
  void personaProjectionPromise.catch(() => undefined);
1859
1865
  void managerTeamPromise.catch(() => undefined);
@@ -1888,6 +1894,9 @@ class AiHubServer {
1888
1894
  employees,
1889
1895
  configuredAgents,
1890
1896
  personas,
1897
+ // Issue #1005: lets the client tell a resolved projection from the first-paint
1898
+ // placeholder, so a late unresolved bootstrap cannot wipe a resolved roster.
1899
+ personasResolved,
1891
1900
  subscriptionActive,
1892
1901
  activeRun,
1893
1902
  projects,
@@ -2597,9 +2606,15 @@ class AiHubServer {
2597
2606
  const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)();
2598
2607
  return { message, display };
2599
2608
  }
2600
- async computePersonas(apiKey, managerTeamPromise) {
2609
+ async computePersonas(apiKey, managerTeamPromise,
2610
+ // Issue #1005: the project whose custom employees belong in this projection. Custom
2611
+ // employees are per-project (`fraim/personalized-employee/employees/`), so reading
2612
+ // them from `this.projectPath` (the directory the Hub launched from) leaked one
2613
+ // project's employees into every other project's roster. Defaults to the launch
2614
+ // project only so callers that genuinely have no project context keep working.
2615
+ projectPath = this.projectPath) {
2601
2616
  const allBundles = listHubPersonaBundles();
2602
- const fallbackProjection = this.fallbackPersonaProjection(apiKey);
2617
+ const fallbackProjection = this.fallbackPersonaProjection(apiKey, projectPath);
2603
2618
  // Issue #701: persona state comes from the hosted server (GET /api/personas/me)
2604
2619
  // via the user's API key — never a local MongoDB connection. Issue #925: resolve
2605
2620
  // it in a transport-aware way so a transient outage does not collapse to the same
@@ -2616,7 +2631,7 @@ class AiHubServer {
2616
2631
  // (feature-off, legacy bypass, per-entitlement gating) lives solely in
2617
2632
  // persona-entitlement-service.resolvePersonaAccessStatuses — the Hub does not
2618
2633
  // re-derive it.
2619
- const customPersonas = (0, custom_employees_1.readCustomEmployees)(this.projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2634
+ const customPersonas = (0, custom_employees_1.readCustomEmployees)(projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2620
2635
  if (!state) {
2621
2636
  return { ...fallbackProjection, userKey };
2622
2637
  }
@@ -2655,7 +2670,9 @@ class AiHubServer {
2655
2670
  return { ...fallbackProjection, userKey };
2656
2671
  }
2657
2672
  }
2658
- fallbackPersonaProjection(apiKey) {
2673
+ fallbackPersonaProjection(apiKey,
2674
+ // Issue #1005: see computePersonas — custom employees are per-project.
2675
+ projectPath = this.projectPath) {
2659
2676
  const fallbackPersonas = listHubPersonaBundles().map((bundle) => ({
2660
2677
  key: bundle.personaKey,
2661
2678
  displayName: bundle.catalogMetadata.displayName,
@@ -2668,7 +2685,7 @@ class AiHubServer {
2668
2685
  seatsInUse: 0,
2669
2686
  origin: 'catalog',
2670
2687
  }));
2671
- const customPersonas = (0, custom_employees_1.readCustomEmployees)(this.projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2688
+ const customPersonas = (0, custom_employees_1.readCustomEmployees)(projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2672
2689
  return {
2673
2690
  personas: [...fallbackPersonas, ...customPersonas],
2674
2691
  subscriptionActive: false,
@@ -2836,7 +2853,9 @@ class AiHubServer {
2836
2853
  console.warn('[ai-hub] manager-team lookup failed:', err?.message || err);
2837
2854
  return [];
2838
2855
  });
2839
- const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey, managerTeamPromise);
2856
+ // Issue #1005: pass the requested project so custom employees are scoped to it,
2857
+ // not to the directory the Hub launched from.
2858
+ const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey, managerTeamPromise, projectPath);
2840
2859
  const managerTeam = await managerTeamPromise;
2841
2860
  const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath, { includeRegistry: true })
2842
2861
  .filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.236",
3
+ "version": "2.0.237",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js"
@@ -158,7 +158,7 @@
158
158
  "electron": "^41.2.2",
159
159
  "electron-updater": "^6.8.9",
160
160
  "express": "^5.2.1",
161
- "fraim": "2.0.236",
161
+ "fraim": "2.0.237",
162
162
  "mongodb": "^7.0.0",
163
163
  "node-cron": "4.2.1",
164
164
  "node-edge-tts": "^1.2.10",
@@ -210,7 +210,42 @@ function cacheBootstrapPayload(bootstrap, docUrl) {
210
210
  bootstrapCache.set(bootstrapCacheKey(bootstrap.project.path, docUrl), bootstrap);
211
211
  }
212
212
 
213
+ // Issue #1005: `GET /api/ai-hub/bootstrap` time-boxes the hosted persona lookup to a
214
+ // 250ms first-paint budget (#975). On a miss it returns `personasResolved: false` plus an
215
+ // all-locked placeholder projection. That payload is NOT an authority statement, so
216
+ // adopting it wholesale is what erased a resolved roster: the ~4s background bootstrap
217
+ // refresh landed after the ~0.2s /api/ai-hub/personas refresh and `state.bootstrap =
218
+ // bootstrap` overwrote every hired persona, collapsing the employee rail to nothing.
219
+ //
220
+ // Contract, mirrored by tests/ui/test-1005-persona-preserved-across-bootstrap.spec.ts:
221
+ // personasResolved !== false -> adopt verbatim. A definitive not-entitled answer is a
222
+ // real answer, so a genuine sign-out still clears.
223
+ // personasResolved === false -> keep the last RESOLVED projection's catalog half.
224
+ //
225
+ // Only the catalog half is preserved. Custom employees are a per-project local disk read
226
+ // (`fraim/personalized-employee/employees/`) that never depends on the hosted authority,
227
+ // so the incoming payload's custom personas are always correct for the incoming project —
228
+ // carrying the previous project's across would re-create the cross-project leak.
229
+ const PERSONA_AUTHORITY_FIELDS = ['subscriptionActive', 'workspaceId', 'userEmail', 'managerTeam'];
230
+
231
+ function mergePersonaProjection(incoming, previous) {
232
+ if (!incoming || incoming.personasResolved !== false) return incoming;
233
+ // Nothing trustworthy to fall back on (cold start, or the previous payload was itself a
234
+ // placeholder). Adopt it and let tfRefreshAfterBootstrap schedule the real lookup.
235
+ if (!previous || previous.personasResolved === false) return incoming;
236
+ const previousCatalog = (previous.personas || []).filter((p) => p && p.origin !== 'custom');
237
+ if (!previousCatalog.length) return incoming;
238
+ const incomingCustom = (incoming.personas || []).filter((p) => p && p.origin === 'custom');
239
+ // Catalog-then-custom matches the server's own union order in computePersonas.
240
+ const merged = { ...incoming, personas: [...previousCatalog, ...incomingCustom], personasResolved: true };
241
+ for (const field of PERSONA_AUTHORITY_FIELDS) {
242
+ if (previous[field] !== undefined) merged[field] = previous[field];
243
+ }
244
+ return merged;
245
+ }
246
+
213
247
  function applyBootstrap(bootstrap, docUrl) {
248
+ bootstrap = mergePersonaProjection(bootstrap, state.bootstrap);
214
249
  state.bootstrap = bootstrap;
215
250
  state.projectPath = bootstrap.project.path;
216
251
  state.remoteBaseUrl = bootstrap.remoteBaseUrl || null;
@@ -319,6 +354,11 @@ async function tfRefreshPersonasFromServerInBackground() {
319
354
  state.bootstrap = {
320
355
  ...state.bootstrap,
321
356
  personas: Array.isArray(payload.personas) ? payload.personas : state.bootstrap.personas,
357
+ // Issue #1005: GET /api/ai-hub/personas awaits the authority with no first-paint
358
+ // budget, so a successful response IS the resolved projection. Marking it lets
359
+ // applyBootstrap preserve it against a later unresolved bootstrap, and stops
360
+ // tfRefreshAfterBootstrap from re-requesting a lookup that already succeeded.
361
+ personasResolved: true,
322
362
  subscriptionActive: Boolean(payload.subscriptionActive),
323
363
  workspaceId: payload.workspaceId ?? null,
324
364
  userEmail: payload.userEmail ?? null,
@@ -12984,6 +13024,13 @@ function tfRefreshAfterBootstrap() {
12984
13024
  else if (tf.area === 'brain') tfRenderBrain();
12985
13025
  if (tf.projectView === 'overview') tfRenderOverview();
12986
13026
  else tfRenderTree();
13027
+ // Issue #1005: the bootstrap we just applied missed the first-paint persona budget and
13028
+ // mergePersonaProjection had no resolved projection to fall back on (cold start, or the
13029
+ // shell's initial refresh failed). Schedule the authoritative lookup so the roster fills
13030
+ // in instead of waiting for some later bootstrap to happen to win the race.
13031
+ if (state.bootstrap && state.bootstrap.personasResolved === false) {
13032
+ void tfRefreshPersonasFromServerInBackground();
13033
+ }
12987
13034
  }
12988
13035
 
12989
13036
  // Kick off the shell once the initial bootstrap has settled. The IIFE init at