fraim-framework 2.0.184 → 2.0.186

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.
@@ -56,6 +56,7 @@ const hosts_1 = require("./hosts");
56
56
  const manager_turns_1 = require("./manager-turns");
57
57
  const preferences_1 = require("./preferences");
58
58
  const conversation_store_1 = require("./conversation-store");
59
+ const remote_hub_gateway_1 = require("./remote-hub-gateway");
59
60
  const managed_browser_1 = require("./managed-browser");
60
61
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
61
62
  let personaHiringModule;
@@ -137,29 +138,6 @@ function buildHubManagerHiringCatalog() {
137
138
  roles: {},
138
139
  };
139
140
  }
140
- async function getHubWorkspacePersonaState(dbService, userId, apiKey) {
141
- try {
142
- // eslint-disable-next-line @typescript-eslint/no-var-requires
143
- const service = require('../services/persona-entitlement-service');
144
- return await service.getWorkspacePersonaState(dbService, userId, apiKey);
145
- }
146
- catch {
147
- return null;
148
- }
149
- }
150
- function createDefaultDbService() {
151
- try {
152
- // eslint-disable-next-line @typescript-eslint/no-var-requires
153
- const { FraimDbService } = require('../fraim/db-service');
154
- return new FraimDbService();
155
- }
156
- catch {
157
- return undefined;
158
- }
159
- }
160
- function supportsManagerSeatAssignments(dbService) {
161
- return typeof dbService.countHubManagerAssignments === 'function';
162
- }
163
141
  class AiHubRunRegistry {
164
142
  constructor() {
165
143
  this.runs = new Map();
@@ -249,6 +227,9 @@ function safeHttpUrl(value) {
249
227
  }
250
228
  // ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
251
229
  const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
230
+ function startSessionSeedForHost(hostId, runId) {
231
+ return hostId === 'gemini' ? undefined : runId;
232
+ }
252
233
  class DeploymentStore {
253
234
  constructor(filePath) {
254
235
  this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
@@ -398,19 +379,22 @@ function normalizeReviewHandoff(raw) {
398
379
  const url = safeHttpUrl(rawTarget?.url);
399
380
  if (!url)
400
381
  return null;
382
+ if (artifacts.length > 0)
383
+ return null;
401
384
  return {
402
385
  reviewRequired: true,
403
386
  reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
404
- artifacts,
387
+ artifacts: [],
405
388
  summary: typeof value.summary === 'string' ? value.summary.trim() : '',
406
389
  feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'pull_request_comments',
407
390
  };
408
391
  }
409
- if (targetType === 'artifact_set' && artifacts.length > 0) {
392
+ const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
393
+ if (targetType === 'artifact_set' && fileArtifacts.length > 0 && fileArtifacts.length === artifacts.length) {
410
394
  return {
411
395
  reviewRequired: true,
412
396
  reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}` },
413
- artifacts,
397
+ artifacts: fileArtifacts,
414
398
  summary: typeof value.summary === 'string' ? value.summary.trim() : '',
415
399
  feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'inline',
416
400
  };
@@ -573,31 +557,6 @@ function extractDelegationLedgerFromText(text) {
573
557
  }
574
558
  return null;
575
559
  }
576
- const ARTIFACT_EXCLUDE_RE = /(^|[\\/])(retrospectives|evidence|learnings|mocks|raw|archive)[\\/]/i;
577
- function extractReviewArtifactsFromText(text, projectPath) {
578
- if (!text)
579
- return [];
580
- const artifacts = [];
581
- const seen = new Set();
582
- const matches = String(text)
583
- .split(/[\s`"'()<>{}\[\],;:]+/)
584
- .filter((token) => /^(docs|public|src|tests)[\\/]/.test(token));
585
- for (const candidate of matches) {
586
- const relativePath = candidate.replace(/[.)]+$/g, '').replace(/\\/g, '/');
587
- if (!/\.[A-Za-z0-9]+$/.test(relativePath))
588
- continue;
589
- if (ARTIFACT_EXCLUDE_RE.test(relativePath))
590
- continue;
591
- const parts = relativePath.split('/').filter(Boolean);
592
- const name = parts[parts.length - 1] || relativePath;
593
- const artifactPath = path_1.default.isAbsolute(relativePath) ? relativePath : path_1.default.join(projectPath, relativePath);
594
- if (seen.has(artifactPath))
595
- continue;
596
- seen.add(artifactPath);
597
- artifacts.push({ type: path_1.default.extname(name).replace(/^\./, '') || 'file', label: name, path: artifactPath });
598
- }
599
- return artifacts;
600
- }
601
560
  function applyReviewProjection(run, text) {
602
561
  const delegation = extractDelegationLedgerFromText(text);
603
562
  if (delegation) {
@@ -610,41 +569,9 @@ function applyReviewProjection(run, text) {
610
569
  const handoff = extractReviewHandoffFromText(text);
611
570
  if (handoff) {
612
571
  run.reviewHandoff = handoff;
613
- run.artifacts = handoff.artifacts;
572
+ run.artifacts = handoff.reviewTarget?.type === 'artifact_set' ? handoff.artifacts : [];
614
573
  return;
615
574
  }
616
- const artifacts = extractReviewArtifactsFromText(text, run.projectPath);
617
- if (!artifacts.length)
618
- return;
619
- run.artifacts = run.artifacts || [];
620
- for (const artifact of artifacts) {
621
- if (!run.artifacts.some((entry) => (entry.path && artifact.path && entry.path === artifact.path) || (entry.url && artifact.url && entry.url === artifact.url))) {
622
- run.artifacts.push(artifact);
623
- }
624
- }
625
- }
626
- function mergeReviewArtifacts(existing, discovered) {
627
- const merged = [...(existing || [])];
628
- for (const artifact of discovered) {
629
- if (!merged.some((entry) => (entry.path && artifact.path && entry.path === artifact.path) || (entry.url && artifact.url && entry.url === artifact.url))) {
630
- merged.push(artifact);
631
- }
632
- }
633
- return merged;
634
- }
635
- function extractRunReviewArtifacts(run) {
636
- const discovered = [];
637
- for (const message of run.messages || []) {
638
- if (message.role !== 'employee')
639
- continue;
640
- discovered.push(...extractReviewArtifactsFromText(message.text, run.projectPath));
641
- }
642
- for (const event of run.events || []) {
643
- if (event.channel !== 'stdout')
644
- continue;
645
- discovered.push(...extractReviewArtifactsFromText(event.text, run.projectPath));
646
- }
647
- return mergeReviewArtifacts(run.artifacts, discovered);
648
575
  }
649
576
  function emptyTotals() {
650
577
  return {
@@ -663,6 +590,42 @@ function emptyTotals() {
663
590
  },
664
591
  };
665
592
  }
593
+ const PHASE_STATUSES = new Set(['starting', 'complete', 'incomplete', 'failure']);
594
+ function normalizePersistedPhaseHistory(value) {
595
+ if (!Array.isArray(value))
596
+ return [];
597
+ return value
598
+ .map((entry) => {
599
+ if (!entry || typeof entry !== 'object')
600
+ return null;
601
+ const raw = entry;
602
+ if (typeof raw.phaseId !== 'string' || !raw.phaseId)
603
+ return null;
604
+ const latestStatus = typeof raw.latestStatus === 'string' && PHASE_STATUSES.has(raw.latestStatus)
605
+ ? raw.latestStatus
606
+ : null;
607
+ return {
608
+ phaseId: raw.phaseId,
609
+ enteredAt: typeof raw.enteredAt === 'string' ? raw.enteredAt : new Date().toISOString(),
610
+ latestStatus,
611
+ latestText: typeof raw.latestText === 'string' ? raw.latestText : null,
612
+ };
613
+ })
614
+ .filter((entry) => Boolean(entry));
615
+ }
616
+ function readPersistedRunProjection(conversation) {
617
+ const rawRun = conversation?.run;
618
+ if (!rawRun || typeof rawRun !== 'object')
619
+ return null;
620
+ const value = rawRun;
621
+ return {
622
+ currentPhase: typeof value.currentPhase === 'string' ? value.currentPhase : null,
623
+ phaseHistory: normalizePersistedPhaseHistory(value.phaseHistory),
624
+ stages: Array.isArray(value.stages) ? value.stages : [],
625
+ totals: value.totals && typeof value.totals === 'object' ? value.totals : null,
626
+ runDiscriminant: typeof value.runDiscriminant === 'string' ? value.runDiscriminant : null,
627
+ };
628
+ }
666
629
  // Issue #347 — apply per-turn usage from the host stream into the
667
630
  // run's tokenTotals. Idempotent on the same turn (we replace, not add)
668
631
  // because Codex's `turn.completed.usage` is cumulative, not delta.
@@ -750,8 +713,13 @@ function applySeekMentoringSignal(run, signal) {
750
713
  const callJobId = signal.jobId || signal.jobName;
751
714
  if (callJobId && targetJobId && callJobId !== targetJobId)
752
715
  return;
753
- if (signal.reviewHandoff)
754
- run.reviewHandoff = signal.reviewHandoff;
716
+ if (signal.reviewHandoff) {
717
+ const normalizedReviewHandoff = normalizeReviewHandoff(signal.reviewHandoff);
718
+ run.reviewHandoff = normalizedReviewHandoff || signal.reviewHandoff;
719
+ run.artifacts = normalizedReviewHandoff?.reviewTarget?.type === 'artifact_set'
720
+ ? normalizedReviewHandoff.artifacts
721
+ : [];
722
+ }
755
723
  if (signal.delegationLedger &&
756
724
  run.jobId === 'fully-delegate' &&
757
725
  signal.phaseStatus === 'complete' &&
@@ -837,20 +805,17 @@ function deriveStages(run, projectPath) {
837
805
  }
838
806
  return declaredPath.map((phase, index) => {
839
807
  let state;
840
- if (currentIndex < 0) {
841
- state = 'upcoming';
842
- }
843
- else if (index < currentIndex) {
844
- state = 'done';
845
- }
846
- else if (index === currentIndex) {
808
+ const entry = historyMap.get(phase.id);
809
+ if (index === currentIndex) {
847
810
  // If the agent has already reported this phase as 'complete', advance
848
811
  // its visual state to 'done' so the tracker doesn't look frozen while
849
812
  // waiting for the agent to start the next phase (e.g. after
850
813
  // implement-submission completes but before address-feedback starts).
851
- const entry = historyMap.get(phase.id);
852
814
  state = entry?.latestStatus === 'complete' ? 'done' : 'current';
853
815
  }
816
+ else if (entry?.latestStatus === 'complete' || (currentIndex >= 0 && index < currentIndex && entry)) {
817
+ state = 'done';
818
+ }
854
819
  else {
855
820
  state = 'upcoming';
856
821
  }
@@ -1154,14 +1119,14 @@ class AiHubServer {
1154
1119
  explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
1155
1120
  });
1156
1121
  this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
1157
- if (options.dbService !== undefined) {
1158
- this.dbService = options.dbService;
1159
- this.ownsDbService = false;
1160
- }
1161
- else {
1162
- this.dbService = createDefaultDbService();
1163
- this.ownsDbService = this.dbService !== undefined;
1164
- }
1122
+ // Issue #701: the AI Hub is a loopback companion that never touches MongoDB directly —
1123
+ // on every machine (dev, CI, prod) it resolves persona/manager-team state from the hosted
1124
+ // server through the same code path. The hosted server is the sole owner of DB access.
1125
+ // A dbService is used ONLY when explicitly injected (e.g. the hosted server embedding the
1126
+ // Hub, or a test); it is never auto-created, so there is no dev/prod divergence.
1127
+ this.dbService = options.dbService;
1128
+ this.ownsDbService = false;
1129
+ this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
1165
1130
  this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
1166
1131
  this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
1167
1132
  this.app.use(express_1.default.json({ limit: '10mb' }));
@@ -1429,8 +1394,9 @@ class AiHubServer {
1429
1394
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
1430
1395
  }));
1431
1396
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
1432
- const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey || preferences.apiKey);
1433
- const managerTeam = await this.computeManagerTeam(workspaceId, userKey);
1397
+ const resolvedApiKey = apiKey || preferences.apiKey;
1398
+ const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
1399
+ const managerTeam = await this.computeManagerTeam(resolvedApiKey);
1434
1400
  const projects = this.knownProjects(normalizedProjectPath);
1435
1401
  preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
1436
1402
  this.preferencesStore.save(preferences);
@@ -1469,6 +1435,7 @@ class AiHubServer {
1469
1435
  }
1470
1436
  conversationRecordFromRun(run) {
1471
1437
  const lastUpdatedAt = run.updatedAt || new Date().toISOString();
1438
+ const stages = deriveStages(run, run.projectPath);
1472
1439
  return {
1473
1440
  id: run.conversationId || run.id,
1474
1441
  projectPath: path_1.default.resolve(run.projectPath),
@@ -1503,6 +1470,13 @@ class AiHubServer {
1503
1470
  compareRunId: run.compareRunId || null,
1504
1471
  // Issue #578: preserve trigger source so the UI can render the chip.
1505
1472
  sourceTrigger: run.sourceTrigger,
1473
+ run: {
1474
+ stages,
1475
+ currentPhase: run.currentPhase || null,
1476
+ phaseHistory: run.phaseHistory || [],
1477
+ totals: run.totals || null,
1478
+ runDiscriminant: run.runDiscriminant || null,
1479
+ },
1506
1480
  };
1507
1481
  }
1508
1482
  persistRunConversation(run, activeId) {
@@ -1614,8 +1588,10 @@ class AiHubServer {
1614
1588
  }
1615
1589
  if (event.agentIdentity)
1616
1590
  applyAgentIdentitySignal(current, event.agentIdentity);
1591
+ if (event.fraimJob)
1592
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
1617
1593
  if (event.seekMentoring)
1618
- applySeekMentoringSignal(current, event.seekMentoring);
1594
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
1619
1595
  if (event.usage)
1620
1596
  applyUsageSignal(current, event.usage);
1621
1597
  });
@@ -1637,7 +1613,7 @@ class AiHubServer {
1637
1613
  }
1638
1614
  this.runRegistry.dispose(childRun.id);
1639
1615
  },
1640
- }, childRun.sessionId);
1616
+ }, startSessionSeedForHost(managerRun.hostId, childRun.id));
1641
1617
  this.runRegistry.attachChildIfRunning(childRun.id, child);
1642
1618
  }
1643
1619
  notifyManagerOfDelegatedChild(managerRunId, childRun) {
@@ -1761,8 +1737,10 @@ class AiHubServer {
1761
1737
  }
1762
1738
  if (event.agentIdentity)
1763
1739
  applyAgentIdentitySignal(current, event.agentIdentity);
1740
+ if (event.fraimJob)
1741
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
1764
1742
  if (event.seekMentoring)
1765
- applySeekMentoringSignal(current, event.seekMentoring);
1743
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
1766
1744
  if (event.usage)
1767
1745
  applyUsageSignal(current, event.usage);
1768
1746
  });
@@ -1834,14 +1812,48 @@ class AiHubServer {
1834
1812
  };
1835
1813
  }
1836
1814
  resolveHubJob(projectPath, jobId) {
1815
+ if (!jobId || jobId === '__freeform__')
1816
+ return null;
1837
1817
  const employeeJob = (0, catalog_1.discoverEmployeeJobs)(projectPath).find((job) => job.id === jobId);
1838
- if (employeeJob)
1839
- return { id: employeeJob.id, stubPath: employeeJob.stubPath };
1818
+ if (employeeJob) {
1819
+ return {
1820
+ id: employeeJob.id,
1821
+ title: employeeJob.title,
1822
+ stubPath: employeeJob.stubPath,
1823
+ personaKey: employeeJob.requiredPersonaKey ?? getProtectedPersonaForHubJob(employeeJob.id),
1824
+ };
1825
+ }
1840
1826
  const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
1841
- if (managerTemplate)
1842
- return { id: managerTemplate.id, stubPath: managerTemplate.stubPath };
1827
+ if (managerTemplate) {
1828
+ return {
1829
+ id: managerTemplate.id,
1830
+ title: managerTemplate.title,
1831
+ stubPath: managerTemplate.stubPath,
1832
+ personaKey: getProtectedPersonaForHubJob(managerTemplate.id),
1833
+ };
1834
+ }
1843
1835
  return null;
1844
1836
  }
1837
+ applySeekMentoringSignalToRun(run, signal) {
1838
+ this.maybePromoteFreeformRunToJob(run, signal.jobId || signal.jobName);
1839
+ applySeekMentoringSignal(run, signal);
1840
+ }
1841
+ applyFraimJobSignalToRun(run, signal) {
1842
+ this.maybePromoteFreeformRunToJob(run, signal.jobId);
1843
+ }
1844
+ maybePromoteFreeformRunToJob(run, signalJobId) {
1845
+ const normalizedJobId = (signalJobId || '').trim().toLowerCase();
1846
+ if (run.jobId !== '__freeform__' || !normalizedJobId)
1847
+ return;
1848
+ const metadata = this.resolveHubJob(run.projectPath, normalizedJobId);
1849
+ if (!metadata)
1850
+ return;
1851
+ run.jobId = metadata.id;
1852
+ run.jobTitle = metadata.title;
1853
+ run.personaKey = metadata.personaKey;
1854
+ run.updatedAt = new Date().toISOString();
1855
+ run.events.push((0, hosts_1.createHubEvent)('system', `Recognized FRAIM job ${metadata.id} from structured telemetry.`));
1856
+ }
1845
1857
  // Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
1846
1858
  // content) export routes so a conversational deliverable with no on-disk file
1847
1859
  // can still be downloaded for Word annotation.
@@ -1906,18 +1918,18 @@ class AiHubServer {
1906
1918
  seatCount: 0,
1907
1919
  seatsInUse: 0,
1908
1920
  }));
1909
- if (!this.dbService) {
1910
- return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1911
- }
1912
1921
  try {
1913
- const state = await getHubWorkspacePersonaState(this.dbService, getHubUserEmail(), apiKey ?? '');
1922
+ // Issue #701: persona state comes from the hosted server (GET /api/personas/me)
1923
+ // via the user's API key — never a local MongoDB connection. A null result means
1924
+ // no/expired key or the feature is off; render the locked fallback (not-signed-in).
1925
+ const state = await this.remoteGateway.getPersonaState(apiKey);
1914
1926
  if (!state) {
1915
1927
  return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1916
1928
  }
1917
1929
  // Legacy bypass: persona gating is not active for this workspace, so all personas are accessible.
1918
1930
  // Mirrors the evaluatePersonaAccess legacy bypass in persona-entitlement-service.ts.
1931
+ // Seats are not assignable on legacy workspaces (the hosted server is always DB-backed).
1919
1932
  if (!state.subscriptionActive) {
1920
- const legacySeatCount = supportsManagerSeatAssignments(this.dbService) ? 0 : 1;
1921
1933
  const allPersonas = allBundles.map((bundle) => ({
1922
1934
  key: bundle.personaKey,
1923
1935
  displayName: bundle.catalogMetadata.displayName,
@@ -1926,7 +1938,7 @@ class AiHubServer {
1926
1938
  pricingLabel: '',
1927
1939
  status: 'hired',
1928
1940
  hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
1929
- seatCount: legacySeatCount,
1941
+ seatCount: 0,
1930
1942
  seatsInUse: 0,
1931
1943
  }));
1932
1944
  return { personas: allPersonas, subscriptionActive: false, workspaceId: state.workspaceId, userKey: state.userId ?? null };
@@ -1942,13 +1954,13 @@ class AiHubServer {
1942
1954
  seatCountByKey[e.personaKey] = (seatCountByKey[e.personaKey] ?? 0) + (e.jobCreditsRemaining ?? 1);
1943
1955
  }
1944
1956
  }
1945
- // seatsInUse is the count of manager-assignment rows for this workspace/persona.
1946
- const workspaceId = state.workspaceId;
1957
+ // seatsInUse for display: derived from the hosted manager team. Authoritative
1958
+ // out-of-stock enforcement lives on the hosted assign endpoint (server-side),
1959
+ // so this display count does not need to be workspace-wide.
1947
1960
  const seatsInUseByKey = {};
1948
- if (this.dbService) {
1949
- await Promise.all(Object.keys(seatCountByKey).map(async (pKey) => {
1950
- seatsInUseByKey[pKey] = await this.dbService.countHubManagerAssignments(workspaceId, pKey);
1951
- }));
1961
+ const team = await this.remoteGateway.listManagerTeam(apiKey);
1962
+ for (const entry of team) {
1963
+ seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
1952
1964
  }
1953
1965
  const personas = allBundles.map((bundle) => ({
1954
1966
  key: bundle.personaKey,
@@ -1961,23 +1973,16 @@ class AiHubServer {
1961
1973
  seatCount: seatCountByKey[bundle.personaKey] ?? 0,
1962
1974
  seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
1963
1975
  }));
1964
- return { personas, subscriptionActive: state.subscriptionActive, workspaceId, userKey: state.userId ?? null };
1976
+ return { personas, subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey: state.userId ?? null };
1965
1977
  }
1966
1978
  catch (err) {
1967
- console.error('[ai-hub] persona entitlement lookup failed:', err);
1979
+ console.error('[ai-hub] persona lookup failed:', err);
1968
1980
  return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1969
1981
  }
1970
1982
  }
1971
- async computeManagerTeam(workspaceId, userKey) {
1972
- if (!workspaceId || !userKey || !this.dbService)
1973
- return [];
1974
- try {
1975
- const rows = await this.dbService.getHubManagerAssignments(workspaceId, userKey);
1976
- return rows.map((r) => ({ personaKey: r.personaKey, assignedAt: r.assignedAt.toISOString() }));
1977
- }
1978
- catch {
1979
- return [];
1980
- }
1983
+ async computeManagerTeam(apiKey) {
1984
+ // Issue #701: manager team comes from the hosted server, not local Mongo.
1985
+ return this.remoteGateway.listManagerTeam(apiKey);
1981
1986
  }
1982
1987
  registerRoutes() {
1983
1988
  // Issue #512: Serve the account and analytics pages from public/.
@@ -2121,57 +2126,25 @@ class AiHubServer {
2121
2126
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
2122
2127
  }
2123
2128
  });
2124
- // Issue #540: POST /api/ai-hub/manager-team/assign
2125
- // Assigns a persona to the authenticated manager's team (uses X-Fraim-Api-Key header).
2126
- // Returns 404 {error:'no_company_seat'} when the workspace has purchased no seats for the persona.
2127
- // Returns 409 {error:'out_of_stock'} when all company seats are already assigned to other managers.
2129
+ // Issue #540/#701: POST /api/ai-hub/manager-team/assign
2130
+ // Proxies to the hosted server (which owns the DB + seat enforcement). The Hub
2131
+ // accepts the local X-Fraim-Api-Key header and forwards it as the hosted x-api-key.
2132
+ // Hosted returns 404 no_company_seat / 409 out_of_stock; those are passed through.
2128
2133
  this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
2129
2134
  const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2130
2135
  const { personaKey } = (req.body ?? {});
2131
2136
  if (!personaKey)
2132
2137
  return res.status(400).json({ error: 'personaKey required' });
2133
- if (!this.dbService) {
2134
- return res.status(404).json({ error: 'no_company_seat', personaKey });
2135
- }
2136
- try {
2137
- const { workspaceId, userKey, personas } = await this.computePersonas(apiKey);
2138
- if (!workspaceId || !userKey) {
2139
- return res.status(404).json({ error: 'no_company_seat', personaKey });
2140
- }
2141
- const persona = personas.find((p) => p.key === personaKey);
2142
- if (!persona || persona.seatCount === 0) {
2143
- return res.status(404).json({ error: 'no_company_seat', personaKey });
2144
- }
2145
- if (persona.seatsInUse >= persona.seatCount) {
2146
- return res.status(409).json({ error: 'out_of_stock', personaKey, seatCount: persona.seatCount });
2147
- }
2148
- await this.dbService.addHubManagerAssignment(workspaceId, userKey, personaKey);
2149
- const team = await this.computeManagerTeam(workspaceId, userKey);
2150
- return res.json({ managerTeam: team });
2151
- }
2152
- catch (err) {
2153
- console.error('[ai-hub] assign manager seat failed:', err);
2154
- return res.status(500).json({ error: 'internal_error' });
2155
- }
2138
+ const result = await this.remoteGateway.assignManagerTeam(apiKey, personaKey);
2139
+ return res.status(result.status).json(result.body);
2156
2140
  });
2157
- // Issue #540: DELETE /api/ai-hub/manager-team/assign/:personaKey
2158
- // Removes a persona from the authenticated manager's team.
2141
+ // Issue #540/#701: DELETE /api/ai-hub/manager-team/assign/:personaKey
2142
+ // Proxies the seat release to the hosted server.
2159
2143
  this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
2160
2144
  const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2161
2145
  const { personaKey } = req.params;
2162
- if (!this.dbService)
2163
- return res.status(204).end();
2164
- try {
2165
- const { workspaceId, userKey } = await this.computePersonas(apiKey);
2166
- if (workspaceId && userKey) {
2167
- await this.dbService.removeHubManagerAssignment(workspaceId, userKey, personaKey);
2168
- }
2169
- return res.status(204).end();
2170
- }
2171
- catch (err) {
2172
- console.error('[ai-hub] remove manager seat failed:', err);
2173
- return res.status(500).json({ error: 'internal_error' });
2174
- }
2146
+ await this.remoteGateway.removeManagerTeam(apiKey, personaKey);
2147
+ return res.status(204).end();
2175
2148
  });
2176
2149
  this.app.get('/api/ai-hub/conversations', (req, res) => {
2177
2150
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
@@ -2464,11 +2437,15 @@ class AiHubServer {
2464
2437
  throw new Error('Choose a FRAIM job before starting a run.');
2465
2438
  }
2466
2439
  const startTimestamp = new Date().toISOString();
2440
+ const jobMetadata = this.resolveHubJob(projectPath, jobId);
2441
+ const fallbackJobTitle = typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim()
2442
+ ? req.body.jobTitle.trim()
2443
+ : jobId;
2467
2444
  const run = {
2468
2445
  id: (0, crypto_1.randomUUID)(),
2469
2446
  conversationId: typeof req.body.conversationId === 'string' && req.body.conversationId.trim() ? req.body.conversationId.trim() : undefined,
2470
2447
  conversationTitle: typeof req.body.conversationTitle === 'string' && req.body.conversationTitle.trim() ? req.body.conversationTitle.trim() : undefined,
2471
- jobTitle: typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim() ? req.body.jobTitle.trim() : jobId,
2448
+ jobTitle: jobMetadata?.title || fallbackJobTitle,
2472
2449
  jobId,
2473
2450
  hostId,
2474
2451
  projectPath,
@@ -2482,7 +2459,7 @@ class AiHubServer {
2482
2459
  phaseHistory: [],
2483
2460
  totals: emptyTotals(),
2484
2461
  lastStatusChangeAt: startTimestamp,
2485
- personaKey: getProtectedPersonaForHubJob(jobId),
2462
+ personaKey: jobMetadata?.personaKey ?? getProtectedPersonaForHubJob(jobId),
2486
2463
  // Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
2487
2464
  ...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
2488
2465
  // #0: trigger source — defaults to 'manager' when not provided by the caller.
@@ -2535,8 +2512,10 @@ class AiHubServer {
2535
2512
  }
2536
2513
  if (event.agentIdentity)
2537
2514
  applyAgentIdentitySignal(current, event.agentIdentity);
2515
+ if (event.fraimJob)
2516
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
2538
2517
  if (event.seekMentoring)
2539
- applySeekMentoringSignal(current, event.seekMentoring);
2518
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
2540
2519
  if (event.usage)
2541
2520
  applyUsageSignal(current, event.usage);
2542
2521
  });
@@ -2569,7 +2548,7 @@ class AiHubServer {
2569
2548
  if (latest)
2570
2549
  this.drainPendingDelegatedReviews(latest);
2571
2550
  },
2572
- }, run.sessionId);
2551
+ }, startSessionSeedForHost(hostId, run.id));
2573
2552
  this.runRegistry.attachChildIfRunning(run.id, child);
2574
2553
  // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
2575
2554
  // uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
@@ -2598,7 +2577,7 @@ class AiHubServer {
2598
2577
  });
2599
2578
  this.runRegistry.dispose(directId);
2600
2579
  },
2601
- }, directRun.sessionId);
2580
+ }, startSessionSeedForHost(hostId, directRun.id));
2602
2581
  this.runRegistry.attachChildIfRunning(directRun.id, directChild);
2603
2582
  }
2604
2583
  const existingPreferences = this.preferencesStore.load(projectPath);
@@ -2724,8 +2703,10 @@ class AiHubServer {
2724
2703
  }
2725
2704
  if (event.agentIdentity)
2726
2705
  applyAgentIdentitySignal(current, event.agentIdentity);
2706
+ if (event.fraimJob)
2707
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
2727
2708
  if (event.seekMentoring)
2728
- applySeekMentoringSignal(current, event.seekMentoring);
2709
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
2729
2710
  if (event.usage)
2730
2711
  applyUsageSignal(current, event.usage);
2731
2712
  });
@@ -2786,16 +2767,27 @@ class AiHubServer {
2786
2767
  if (!employee?.available) {
2787
2768
  throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
2788
2769
  }
2770
+ const conversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
2771
+ ? body.conversationId.trim()
2772
+ : undefined;
2773
+ const persistedConversation = conversationId
2774
+ ? this.conversationStore.loadProject(projectPath).conversations.find((entry) => entry.id === conversationId)
2775
+ : undefined;
2776
+ const persistedRun = readPersistedRunProjection(persistedConversation);
2789
2777
  const now = new Date().toISOString();
2790
2778
  const run = {
2791
2779
  id: (0, crypto_1.randomUUID)(),
2792
- conversationId: typeof body.conversationId === 'string' && body.conversationId.trim() ? body.conversationId.trim() : undefined,
2780
+ conversationId,
2793
2781
  conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
2794
2782
  jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
2795
2783
  jobId, hostId, projectPath, status: 'running', sessionId,
2796
2784
  createdAt: now, updatedAt: now, messages: [],
2797
2785
  events: [(0, hosts_1.createHubEvent)('system', `Resuming ${hostId} session ${sessionId} in ${projectPath}`)],
2798
- currentPhase: null, phaseHistory: [], totals: emptyTotals(), lastStatusChangeAt: now,
2786
+ currentPhase: persistedRun?.currentPhase || null,
2787
+ phaseHistory: persistedRun?.phaseHistory || [],
2788
+ totals: persistedRun?.totals || emptyTotals(),
2789
+ lastStatusChangeAt: now,
2790
+ runDiscriminant: persistedRun?.runDiscriminant || undefined,
2799
2791
  personaKey: getProtectedPersonaForHubJob(jobId),
2800
2792
  };
2801
2793
  // Continue-turn message (FRAIM invocation for the job + instructions) plus
@@ -2819,8 +2811,10 @@ class AiHubServer {
2819
2811
  }
2820
2812
  if (event.agentIdentity)
2821
2813
  applyAgentIdentitySignal(current, event.agentIdentity);
2814
+ if (event.fraimJob)
2815
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
2822
2816
  if (event.seekMentoring)
2823
- applySeekMentoringSignal(current, event.seekMentoring);
2817
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
2824
2818
  if (event.usage)
2825
2819
  applyUsageSignal(current, event.usage);
2826
2820
  });
@@ -3248,8 +3242,10 @@ class AiHubServer {
3248
3242
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
3249
3243
  if (event.agentIdentity)
3250
3244
  applyAgentIdentitySignal(current, event.agentIdentity);
3245
+ if (event.fraimJob)
3246
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
3251
3247
  if (event.seekMentoring)
3252
- applySeekMentoringSignal(current, event.seekMentoring);
3248
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
3253
3249
  if (event.usage)
3254
3250
  applyUsageSignal(current, event.usage);
3255
3251
  });
@@ -3262,7 +3258,7 @@ class AiHubServer {
3262
3258
  });
3263
3259
  this.runRegistry.dispose(run.id);
3264
3260
  },
3265
- }, run.sessionId);
3261
+ }, startSessionSeedForHost(hostId, run.id));
3266
3262
  // Update the registry entry with the real child process handle.
3267
3263
  this.runRegistry.attachChildIfRunning(run.id, child);
3268
3264
  return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
@@ -3368,8 +3364,10 @@ class AiHubServer {
3368
3364
  }
3369
3365
  if (event.agentIdentity)
3370
3366
  applyAgentIdentitySignal(current, event.agentIdentity);
3367
+ if (event.fraimJob)
3368
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
3371
3369
  if (event.seekMentoring)
3372
- applySeekMentoringSignal(current, event.seekMentoring);
3370
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
3373
3371
  if (event.usage)
3374
3372
  applyUsageSignal(current, event.usage);
3375
3373
  });
@@ -3389,7 +3387,7 @@ class AiHubServer {
3389
3387
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
3390
3388
  this.runRegistry.dispose(run.id);
3391
3389
  },
3392
- });
3390
+ }, startSessionSeedForHost(deployment.hostId, run.id));
3393
3391
  this.runRegistry.create(run, child);
3394
3392
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
3395
3393
  return run;
@@ -3435,7 +3433,7 @@ class AiHubServer {
3435
3433
  liveTotals.waitingDurationMs = Math.max(0, liveTotals.waitingDurationMs - overflow);
3436
3434
  }
3437
3435
  }
3438
- return { ...run, stages, totals: liveTotals, artifacts: extractRunReviewArtifacts(run) };
3436
+ return { ...run, stages, totals: liveTotals, artifacts: run.artifacts || [] };
3439
3437
  }
3440
3438
  }
3441
3439
  exports.AiHubServer = AiHubServer;