fraim-framework 2.0.183 → 2.0.185

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();
@@ -398,19 +376,22 @@ function normalizeReviewHandoff(raw) {
398
376
  const url = safeHttpUrl(rawTarget?.url);
399
377
  if (!url)
400
378
  return null;
379
+ if (artifacts.length > 0)
380
+ return null;
401
381
  return {
402
382
  reviewRequired: true,
403
383
  reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
404
- artifacts,
384
+ artifacts: [],
405
385
  summary: typeof value.summary === 'string' ? value.summary.trim() : '',
406
386
  feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'pull_request_comments',
407
387
  };
408
388
  }
409
- if (targetType === 'artifact_set' && artifacts.length > 0) {
389
+ const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
390
+ if (targetType === 'artifact_set' && fileArtifacts.length > 0 && fileArtifacts.length === artifacts.length) {
410
391
  return {
411
392
  reviewRequired: true,
412
393
  reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}` },
413
- artifacts,
394
+ artifacts: fileArtifacts,
414
395
  summary: typeof value.summary === 'string' ? value.summary.trim() : '',
415
396
  feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'inline',
416
397
  };
@@ -573,31 +554,6 @@ function extractDelegationLedgerFromText(text) {
573
554
  }
574
555
  return null;
575
556
  }
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
557
  function applyReviewProjection(run, text) {
602
558
  const delegation = extractDelegationLedgerFromText(text);
603
559
  if (delegation) {
@@ -610,41 +566,9 @@ function applyReviewProjection(run, text) {
610
566
  const handoff = extractReviewHandoffFromText(text);
611
567
  if (handoff) {
612
568
  run.reviewHandoff = handoff;
613
- run.artifacts = handoff.artifacts;
569
+ run.artifacts = handoff.reviewTarget?.type === 'artifact_set' ? handoff.artifacts : [];
614
570
  return;
615
571
  }
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
572
  }
649
573
  function emptyTotals() {
650
574
  return {
@@ -750,8 +674,13 @@ function applySeekMentoringSignal(run, signal) {
750
674
  const callJobId = signal.jobId || signal.jobName;
751
675
  if (callJobId && targetJobId && callJobId !== targetJobId)
752
676
  return;
753
- if (signal.reviewHandoff)
754
- run.reviewHandoff = signal.reviewHandoff;
677
+ if (signal.reviewHandoff) {
678
+ const normalizedReviewHandoff = normalizeReviewHandoff(signal.reviewHandoff);
679
+ run.reviewHandoff = normalizedReviewHandoff || signal.reviewHandoff;
680
+ run.artifacts = normalizedReviewHandoff?.reviewTarget?.type === 'artifact_set'
681
+ ? normalizedReviewHandoff.artifacts
682
+ : [];
683
+ }
755
684
  if (signal.delegationLedger &&
756
685
  run.jobId === 'fully-delegate' &&
757
686
  signal.phaseStatus === 'complete' &&
@@ -1009,6 +938,22 @@ function ensureDirectoryPath(projectPath) {
1009
938
  }
1010
939
  return resolved;
1011
940
  }
941
+ function normalizedDirectoryPath(projectPath) {
942
+ const resolved = path_1.default.resolve(projectPath);
943
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
944
+ }
945
+ function sameDirectoryPath(left, right) {
946
+ return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
947
+ }
948
+ function deploymentProjectFilter(rawProjectPath, fallbackProjectPath) {
949
+ return ensureDirectoryPath(typeof rawProjectPath === 'string' && rawProjectPath.trim() ? rawProjectPath : fallbackProjectPath);
950
+ }
951
+ function deploymentBelongsToProject(deployment, projectPath, fallbackProjectPath) {
952
+ const deploymentProjectPath = typeof deployment.projectPath === 'string' && deployment.projectPath.trim()
953
+ ? deployment.projectPath
954
+ : fallbackProjectPath;
955
+ return sameDirectoryPath(deploymentProjectPath, projectPath);
956
+ }
1012
957
  // ---------------------------------------------------------------------------
1013
958
  // Issue #512 (S3) — Hub bootstrap projection helpers.
1014
959
  // ---------------------------------------------------------------------------
@@ -1138,14 +1083,14 @@ class AiHubServer {
1138
1083
  explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
1139
1084
  });
1140
1085
  this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
1141
- if (options.dbService !== undefined) {
1142
- this.dbService = options.dbService;
1143
- this.ownsDbService = false;
1144
- }
1145
- else {
1146
- this.dbService = createDefaultDbService();
1147
- this.ownsDbService = this.dbService !== undefined;
1148
- }
1086
+ // Issue #701: the AI Hub is a loopback companion that never touches MongoDB directly —
1087
+ // on every machine (dev, CI, prod) it resolves persona/manager-team state from the hosted
1088
+ // server through the same code path. The hosted server is the sole owner of DB access.
1089
+ // A dbService is used ONLY when explicitly injected (e.g. the hosted server embedding the
1090
+ // Hub, or a test); it is never auto-created, so there is no dev/prod divergence.
1091
+ this.dbService = options.dbService;
1092
+ this.ownsDbService = false;
1093
+ this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
1149
1094
  this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
1150
1095
  this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
1151
1096
  this.app.use(express_1.default.json({ limit: '10mb' }));
@@ -1413,8 +1358,9 @@ class AiHubServer {
1413
1358
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
1414
1359
  }));
1415
1360
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
1416
- const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey || preferences.apiKey);
1417
- const managerTeam = await this.computeManagerTeam(workspaceId, userKey);
1361
+ const resolvedApiKey = apiKey || preferences.apiKey;
1362
+ const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
1363
+ const managerTeam = await this.computeManagerTeam(resolvedApiKey);
1418
1364
  const projects = this.knownProjects(normalizedProjectPath);
1419
1365
  preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
1420
1366
  this.preferencesStore.save(preferences);
@@ -1835,12 +1781,9 @@ class AiHubServer {
1835
1781
  if (!resolvedJobId) {
1836
1782
  throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
1837
1783
  }
1838
- // #521: `display` is what the manager's conversation bubble shows only their own
1839
- // words. The FRAIM invocation (`$fraim <job>`), the stub path, and the shared-
1840
- // browser / communication-style notes are system context for the AGENT, kept out
1841
- // of the bubble so the thread isn't cluttered with machinery. `message` (with all
1842
- // of it) is what the agent actually receives.
1843
- const display = (explicit?.remainder || instructions || '').trim();
1784
+ // #696: `display` is the manager conversation bubble. Keep the full generated
1785
+ // turn in `message`, but show only the host-specific FRAIM invocation.
1786
+ const display = (0, manager_turns_1.fraimInvocationFor)(hostId, resolvedJobId) || (explicit?.remainder || instructions || '').trim();
1844
1787
  // #521: the shared-browser guidance is injected HERE, at the Hub layer — never
1845
1788
  // baked into the registry job/skill. It only appears when a shared browser is
1846
1789
  // available (env published at boot).
@@ -1869,13 +1812,12 @@ class AiHubServer {
1869
1812
  // as the target of the FRAIM invocation. The server picks the correct
1870
1813
  // invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
1871
1814
  // passes raw invocation syntax.
1872
- const effectiveJobId = coachingJobId || run.jobId;
1873
- // #521: `display` = the manager's own words for the conversation bubble. When the
1874
- // manager typed nothing (a quick-coach button), fall back to the bare invocation
1875
- // so the bubble still shows what was triggered — but never the style note.
1876
1815
  const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
1816
+ const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
1817
+ // #696: the thread shows the invocation that the Hub sent, while the employee
1818
+ // still receives the full manager turn plus the style note.
1877
1819
  const userText = (explicit?.remainder || instructions || '').trim();
1878
- const display = userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
1820
+ const display = (0, manager_turns_1.fraimInvocationFor)(run.hostId, effectiveJobId) || userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
1879
1821
  return {
1880
1822
  message: (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)(),
1881
1823
  display,
@@ -1894,18 +1836,18 @@ class AiHubServer {
1894
1836
  seatCount: 0,
1895
1837
  seatsInUse: 0,
1896
1838
  }));
1897
- if (!this.dbService) {
1898
- return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1899
- }
1900
1839
  try {
1901
- const state = await getHubWorkspacePersonaState(this.dbService, getHubUserEmail(), apiKey ?? '');
1840
+ // Issue #701: persona state comes from the hosted server (GET /api/personas/me)
1841
+ // via the user's API key — never a local MongoDB connection. A null result means
1842
+ // no/expired key or the feature is off; render the locked fallback (not-signed-in).
1843
+ const state = await this.remoteGateway.getPersonaState(apiKey);
1902
1844
  if (!state) {
1903
1845
  return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1904
1846
  }
1905
1847
  // Legacy bypass: persona gating is not active for this workspace, so all personas are accessible.
1906
1848
  // Mirrors the evaluatePersonaAccess legacy bypass in persona-entitlement-service.ts.
1849
+ // Seats are not assignable on legacy workspaces (the hosted server is always DB-backed).
1907
1850
  if (!state.subscriptionActive) {
1908
- const legacySeatCount = supportsManagerSeatAssignments(this.dbService) ? 0 : 1;
1909
1851
  const allPersonas = allBundles.map((bundle) => ({
1910
1852
  key: bundle.personaKey,
1911
1853
  displayName: bundle.catalogMetadata.displayName,
@@ -1914,7 +1856,7 @@ class AiHubServer {
1914
1856
  pricingLabel: '',
1915
1857
  status: 'hired',
1916
1858
  hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
1917
- seatCount: legacySeatCount,
1859
+ seatCount: 0,
1918
1860
  seatsInUse: 0,
1919
1861
  }));
1920
1862
  return { personas: allPersonas, subscriptionActive: false, workspaceId: state.workspaceId, userKey: state.userId ?? null };
@@ -1930,13 +1872,13 @@ class AiHubServer {
1930
1872
  seatCountByKey[e.personaKey] = (seatCountByKey[e.personaKey] ?? 0) + (e.jobCreditsRemaining ?? 1);
1931
1873
  }
1932
1874
  }
1933
- // seatsInUse is the count of manager-assignment rows for this workspace/persona.
1934
- const workspaceId = state.workspaceId;
1875
+ // seatsInUse for display: derived from the hosted manager team. Authoritative
1876
+ // out-of-stock enforcement lives on the hosted assign endpoint (server-side),
1877
+ // so this display count does not need to be workspace-wide.
1935
1878
  const seatsInUseByKey = {};
1936
- if (this.dbService) {
1937
- await Promise.all(Object.keys(seatCountByKey).map(async (pKey) => {
1938
- seatsInUseByKey[pKey] = await this.dbService.countHubManagerAssignments(workspaceId, pKey);
1939
- }));
1879
+ const team = await this.remoteGateway.listManagerTeam(apiKey);
1880
+ for (const entry of team) {
1881
+ seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
1940
1882
  }
1941
1883
  const personas = allBundles.map((bundle) => ({
1942
1884
  key: bundle.personaKey,
@@ -1949,23 +1891,16 @@ class AiHubServer {
1949
1891
  seatCount: seatCountByKey[bundle.personaKey] ?? 0,
1950
1892
  seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
1951
1893
  }));
1952
- return { personas, subscriptionActive: state.subscriptionActive, workspaceId, userKey: state.userId ?? null };
1894
+ return { personas, subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey: state.userId ?? null };
1953
1895
  }
1954
1896
  catch (err) {
1955
- console.error('[ai-hub] persona entitlement lookup failed:', err);
1897
+ console.error('[ai-hub] persona lookup failed:', err);
1956
1898
  return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1957
1899
  }
1958
1900
  }
1959
- async computeManagerTeam(workspaceId, userKey) {
1960
- if (!workspaceId || !userKey || !this.dbService)
1961
- return [];
1962
- try {
1963
- const rows = await this.dbService.getHubManagerAssignments(workspaceId, userKey);
1964
- return rows.map((r) => ({ personaKey: r.personaKey, assignedAt: r.assignedAt.toISOString() }));
1965
- }
1966
- catch {
1967
- return [];
1968
- }
1901
+ async computeManagerTeam(apiKey) {
1902
+ // Issue #701: manager team comes from the hosted server, not local Mongo.
1903
+ return this.remoteGateway.listManagerTeam(apiKey);
1969
1904
  }
1970
1905
  registerRoutes() {
1971
1906
  // Issue #512: Serve the account and analytics pages from public/.
@@ -2109,57 +2044,25 @@ class AiHubServer {
2109
2044
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
2110
2045
  }
2111
2046
  });
2112
- // Issue #540: POST /api/ai-hub/manager-team/assign
2113
- // Assigns a persona to the authenticated manager's team (uses X-Fraim-Api-Key header).
2114
- // Returns 404 {error:'no_company_seat'} when the workspace has purchased no seats for the persona.
2115
- // Returns 409 {error:'out_of_stock'} when all company seats are already assigned to other managers.
2047
+ // Issue #540/#701: POST /api/ai-hub/manager-team/assign
2048
+ // Proxies to the hosted server (which owns the DB + seat enforcement). The Hub
2049
+ // accepts the local X-Fraim-Api-Key header and forwards it as the hosted x-api-key.
2050
+ // Hosted returns 404 no_company_seat / 409 out_of_stock; those are passed through.
2116
2051
  this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
2117
2052
  const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2118
2053
  const { personaKey } = (req.body ?? {});
2119
2054
  if (!personaKey)
2120
2055
  return res.status(400).json({ error: 'personaKey required' });
2121
- if (!this.dbService) {
2122
- return res.status(404).json({ error: 'no_company_seat', personaKey });
2123
- }
2124
- try {
2125
- const { workspaceId, userKey, personas } = await this.computePersonas(apiKey);
2126
- if (!workspaceId || !userKey) {
2127
- return res.status(404).json({ error: 'no_company_seat', personaKey });
2128
- }
2129
- const persona = personas.find((p) => p.key === personaKey);
2130
- if (!persona || persona.seatCount === 0) {
2131
- return res.status(404).json({ error: 'no_company_seat', personaKey });
2132
- }
2133
- if (persona.seatsInUse >= persona.seatCount) {
2134
- return res.status(409).json({ error: 'out_of_stock', personaKey, seatCount: persona.seatCount });
2135
- }
2136
- await this.dbService.addHubManagerAssignment(workspaceId, userKey, personaKey);
2137
- const team = await this.computeManagerTeam(workspaceId, userKey);
2138
- return res.json({ managerTeam: team });
2139
- }
2140
- catch (err) {
2141
- console.error('[ai-hub] assign manager seat failed:', err);
2142
- return res.status(500).json({ error: 'internal_error' });
2143
- }
2056
+ const result = await this.remoteGateway.assignManagerTeam(apiKey, personaKey);
2057
+ return res.status(result.status).json(result.body);
2144
2058
  });
2145
- // Issue #540: DELETE /api/ai-hub/manager-team/assign/:personaKey
2146
- // Removes a persona from the authenticated manager's team.
2059
+ // Issue #540/#701: DELETE /api/ai-hub/manager-team/assign/:personaKey
2060
+ // Proxies the seat release to the hosted server.
2147
2061
  this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
2148
2062
  const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2149
2063
  const { personaKey } = req.params;
2150
- if (!this.dbService)
2151
- return res.status(204).end();
2152
- try {
2153
- const { workspaceId, userKey } = await this.computePersonas(apiKey);
2154
- if (workspaceId && userKey) {
2155
- await this.dbService.removeHubManagerAssignment(workspaceId, userKey, personaKey);
2156
- }
2157
- return res.status(204).end();
2158
- }
2159
- catch (err) {
2160
- console.error('[ai-hub] remove manager seat failed:', err);
2161
- return res.status(500).json({ error: 'internal_error' });
2162
- }
2064
+ await this.remoteGateway.removeManagerTeam(apiKey, personaKey);
2065
+ return res.status(204).end();
2163
2066
  });
2164
2067
  this.app.get('/api/ai-hub/conversations', (req, res) => {
2165
2068
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
@@ -2962,9 +2865,15 @@ class AiHubServer {
2962
2865
  this.scheduleDeployment(deployment);
2963
2866
  return res.status(201).json(deployment);
2964
2867
  });
2965
- // GET /api/ai-hub/schedules — list all scheduled deployments.
2966
- this.app.get('/api/ai-hub/schedules', (_req, res) => {
2967
- return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled'));
2868
+ // GET /api/ai-hub/schedules — list scheduled deployments for one project.
2869
+ this.app.get('/api/ai-hub/schedules', (req, res) => {
2870
+ try {
2871
+ const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
2872
+ return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled' && deploymentBelongsToProject(d, projectPath, this.projectPath)));
2873
+ }
2874
+ catch (err) {
2875
+ return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
2876
+ }
2968
2877
  });
2969
2878
  // DELETE /api/ai-hub/schedules/:id — remove a scheduled deployment.
2970
2879
  this.app.delete('/api/ai-hub/schedules/:id', (req, res) => {
@@ -2982,7 +2891,7 @@ class AiHubServer {
2982
2891
  // PUT /api/ai-hub/schedules/:id — update an existing scheduled deployment.
2983
2892
  this.app.put('/api/ai-hub/schedules/:id', (req, res) => {
2984
2893
  const { id } = req.params;
2985
- const { label, jobId, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
2894
+ const { label, jobId, projectPath, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
2986
2895
  if (cronExpr !== undefined) {
2987
2896
  // eslint-disable-next-line @typescript-eslint/no-var-requires
2988
2897
  const cronLib = require('node-cron');
@@ -2990,6 +2899,14 @@ class AiHubServer {
2990
2899
  return res.status(400).json({ error: 'Invalid cron expression.' });
2991
2900
  }
2992
2901
  }
2902
+ let resolvedProjectPath;
2903
+ try {
2904
+ if (projectPath !== undefined)
2905
+ resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
2906
+ }
2907
+ catch (err) {
2908
+ return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
2909
+ }
2993
2910
  const validEmployees = VALID_EMPLOYEE_IDS;
2994
2911
  let updated = null;
2995
2912
  const ok = this.deploymentStore.update(id, (dep) => {
@@ -2997,6 +2914,8 @@ class AiHubServer {
2997
2914
  dep.label = label;
2998
2915
  if (jobId !== undefined)
2999
2916
  dep.jobId = jobId;
2917
+ if (resolvedProjectPath !== undefined)
2918
+ dep.projectPath = resolvedProjectPath;
3000
2919
  if (cronExpr !== undefined)
3001
2920
  dep.cronExpr = cronExpr;
3002
2921
  if (hostId !== undefined && validEmployees.includes(hostId))
@@ -3043,12 +2962,18 @@ class AiHubServer {
3043
2962
  this.deploymentStore.create(deployment);
3044
2963
  return res.status(201).json({ ...deployment, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${deployment.id}/inbound` });
3045
2964
  });
3046
- // GET /api/ai-hub/webhooks — list all webhook deployments.
3047
- this.app.get('/api/ai-hub/webhooks', (_req, res) => {
2965
+ // GET /api/ai-hub/webhooks — list webhook deployments for one project.
2966
+ this.app.get('/api/ai-hub/webhooks', (req, res) => {
3048
2967
  const hubBase = this.hubBase;
3049
- return res.json(this.deploymentStore.load()
3050
- .filter((d) => d.type === 'webhook')
3051
- .map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
2968
+ try {
2969
+ const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
2970
+ return res.json(this.deploymentStore.load()
2971
+ .filter((d) => d.type === 'webhook' && deploymentBelongsToProject(d, projectPath, this.projectPath))
2972
+ .map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
2973
+ }
2974
+ catch (err) {
2975
+ return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
2976
+ }
3052
2977
  });
3053
2978
  // DELETE /api/ai-hub/webhooks/:id — remove a webhook deployment.
3054
2979
  this.app.delete('/api/ai-hub/webhooks/:id', (req, res) => {
@@ -3060,7 +2985,15 @@ class AiHubServer {
3060
2985
  // PUT /api/ai-hub/webhooks/:id — update an existing webhook deployment.
3061
2986
  this.app.put('/api/ai-hub/webhooks/:id', (req, res) => {
3062
2987
  const { id } = req.params;
3063
- const { label, jobId, hostId, instructions, allowConcurrent } = req.body ?? {};
2988
+ const { label, jobId, projectPath, hostId, instructions, allowConcurrent } = req.body ?? {};
2989
+ let resolvedProjectPath;
2990
+ try {
2991
+ if (projectPath !== undefined)
2992
+ resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
2993
+ }
2994
+ catch (err) {
2995
+ return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
2996
+ }
3064
2997
  const validEmployees = VALID_EMPLOYEE_IDS;
3065
2998
  let updated = null;
3066
2999
  const ok = this.deploymentStore.update(id, (dep) => {
@@ -3068,6 +3001,8 @@ class AiHubServer {
3068
3001
  dep.label = label;
3069
3002
  if (jobId !== undefined)
3070
3003
  dep.jobId = jobId;
3004
+ if (resolvedProjectPath !== undefined)
3005
+ dep.projectPath = resolvedProjectPath;
3071
3006
  if (hostId !== undefined && validEmployees.includes(hostId))
3072
3007
  dep.hostId = hostId;
3073
3008
  if (instructions !== undefined)
@@ -3391,7 +3326,7 @@ class AiHubServer {
3391
3326
  liveTotals.waitingDurationMs = Math.max(0, liveTotals.waitingDurationMs - overflow);
3392
3327
  }
3393
3328
  }
3394
- return { ...run, stages, totals: liveTotals, artifacts: extractRunReviewArtifacts(run) };
3329
+ return { ...run, stages, totals: liveTotals, artifacts: run.artifacts || [] };
3395
3330
  }
3396
3331
  }
3397
3332
  exports.AiHubServer = AiHubServer;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getManagerTeamFor = getManagerTeamFor;
4
+ exports.assignManagerSeatFor = assignManagerSeatFor;
5
+ exports.removeManagerSeatFor = removeManagerSeatFor;
6
+ exports.listManagerTeam = listManagerTeam;
7
+ exports.assignManagerTeam = assignManagerTeam;
8
+ exports.removeManagerTeam = removeManagerTeam;
9
+ const persona_entitlement_service_1 = require("../../services/persona-entitlement-service");
10
+ async function loadTeam(dbService, workspaceId, userKey) {
11
+ const rows = await dbService.getHubManagerAssignments(workspaceId, userKey);
12
+ return rows.map((r) => ({ personaKey: r.personaKey, assignedAt: r.assignedAt.toISOString() }));
13
+ }
14
+ /** Seats purchased for a persona in this workspace. Mirrors computePersonas' seatCountByKey:
15
+ * legacy (unsubscribed) workspaces have no assignable seats. */
16
+ function seatCountForPersona(state, personaKey) {
17
+ if (!state.subscriptionActive)
18
+ return 0;
19
+ return state.entitlements
20
+ .filter((e) => e.personaKey === personaKey && e.status === 'active')
21
+ .reduce((sum, e) => sum + (e.jobCreditsRemaining ?? 1), 0);
22
+ }
23
+ // ── Core operations (shared by hosted handlers and the Hub's local-DB gateway) ──────
24
+ async function getManagerTeamFor(dbService, userId, apiKey) {
25
+ const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(dbService, userId, apiKey);
26
+ return loadTeam(dbService, state.workspaceId, state.userId);
27
+ }
28
+ async function assignManagerSeatFor(dbService, userId, apiKey, personaKey) {
29
+ const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(dbService, userId, apiKey);
30
+ const seatCount = seatCountForPersona(state, personaKey);
31
+ if (seatCount === 0)
32
+ return { status: 404, body: { error: 'no_company_seat', personaKey } };
33
+ const seatsInUse = await dbService.countHubManagerAssignments(state.workspaceId, personaKey);
34
+ if (seatsInUse >= seatCount)
35
+ return { status: 409, body: { error: 'out_of_stock', personaKey, seatCount } };
36
+ await dbService.addHubManagerAssignment(state.workspaceId, state.userId, personaKey);
37
+ return { status: 200, body: { managerTeam: await loadTeam(dbService, state.workspaceId, state.userId) } };
38
+ }
39
+ async function removeManagerSeatFor(dbService, userId, apiKey, personaKey) {
40
+ const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(dbService, userId, apiKey);
41
+ await dbService.removeHubManagerAssignment(state.workspaceId, state.userId, personaKey);
42
+ }
43
+ // ── Hosted HTTP handlers (thin wrappers over the core, authed via req.apiKeyData) ───
44
+ async function listManagerTeam(req, res, dbService) {
45
+ const apiKeyData = req.apiKeyData;
46
+ if (!apiKeyData?.userId) {
47
+ res.status(401).json({ error: 'Authentication required' });
48
+ return;
49
+ }
50
+ try {
51
+ res.json({ team: await getManagerTeamFor(dbService, apiKeyData.userId, apiKeyData.key) });
52
+ }
53
+ catch (error) {
54
+ console.error('[api] listManagerTeam failed:', error);
55
+ res.status(500).json({ error: 'internal_error', details: error?.message || String(error) });
56
+ }
57
+ }
58
+ async function assignManagerTeam(req, res, dbService) {
59
+ const apiKeyData = req.apiKeyData;
60
+ if (!apiKeyData?.userId) {
61
+ res.status(401).json({ error: 'Authentication required' });
62
+ return;
63
+ }
64
+ const { personaKey } = (req.body ?? {});
65
+ if (!personaKey) {
66
+ res.status(400).json({ error: 'personaKey required' });
67
+ return;
68
+ }
69
+ try {
70
+ const result = await assignManagerSeatFor(dbService, apiKeyData.userId, apiKeyData.key, personaKey);
71
+ res.status(result.status).json(result.body);
72
+ }
73
+ catch (error) {
74
+ console.error('[api] assignManagerTeam failed:', error);
75
+ res.status(500).json({ error: 'internal_error', details: error?.message || String(error) });
76
+ }
77
+ }
78
+ async function removeManagerTeam(req, res, dbService) {
79
+ const apiKeyData = req.apiKeyData;
80
+ if (!apiKeyData?.userId) {
81
+ res.status(401).json({ error: 'Authentication required' });
82
+ return;
83
+ }
84
+ const personaKey = String(req.params.personaKey ?? '');
85
+ try {
86
+ await removeManagerSeatFor(dbService, apiKeyData.userId, apiKeyData.key, personaKey);
87
+ res.status(204).end();
88
+ }
89
+ catch (error) {
90
+ console.error('[api] removeManagerTeam failed:', error);
91
+ res.status(500).json({ error: 'internal_error', details: error?.message || String(error) });
92
+ }
93
+ }