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.
- package/dist/src/ai-hub/desktop-main.js +2 -10
- package/dist/src/ai-hub/hosts.js +1 -1
- package/dist/src/ai-hub/manager-turns.js +2 -2
- package/dist/src/ai-hub/preferences.js +3 -2
- package/dist/src/ai-hub/remote-hub-gateway.js +88 -0
- package/dist/src/ai-hub/server.js +119 -184
- package/dist/src/api/ai-hub/manager-team.js +93 -0
- package/dist/src/cli/distribution/marketplace-bundles.js +26 -27
- package/dist/src/fraim/db-service.js +25 -3
- package/dist/src/routes/oauth-routes.js +40 -5
- package/dist/src/services/account-provisioning.js +38 -0
- package/dist/src/services/admin-service.js +10 -36
- package/package.json +1 -1
- package/public/ai-hub/index.html +77 -90
- package/public/ai-hub/review.css +4 -2
- package/public/ai-hub/script.js +526 -562
- package/public/ai-hub/styles.css +151 -35
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
|
1417
|
-
const
|
|
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
|
-
// #
|
|
1839
|
-
//
|
|
1840
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1934
|
-
|
|
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
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
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
|
|
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(
|
|
1960
|
-
|
|
1961
|
-
|
|
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
|
-
//
|
|
2114
|
-
//
|
|
2115
|
-
//
|
|
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
|
-
|
|
2122
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2151
|
-
|
|
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
|
|
2966
|
-
this.app.get('/api/ai-hub/schedules', (
|
|
2967
|
-
|
|
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
|
|
3047
|
-
this.app.get('/api/ai-hub/webhooks', (
|
|
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
|
-
|
|
3050
|
-
|
|
3051
|
-
.
|
|
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:
|
|
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
|
+
}
|