fraim-framework 2.0.184 → 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/remote-hub-gateway.js +88 -0
- package/dist/src/ai-hub/server.js +54 -163
- package/dist/src/api/ai-hub/manager-team.js +93 -0
- 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 +487 -536
- package/public/ai-hub/styles.css +151 -35
|
@@ -21,15 +21,6 @@ let isQuitting = false; // distinguishes window-close (→ tray) from app-quit
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
// Helpers
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
|
-
function tryCreateDbService() {
|
|
25
|
-
try {
|
|
26
|
-
const loaded = require('../fraim/db-service');
|
|
27
|
-
return new loaded.FraimDbService();
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
24
|
function preferredWindowSize() {
|
|
34
25
|
const { workAreaSize } = electron_1.screen.getPrimaryDisplay();
|
|
35
26
|
return {
|
|
@@ -255,7 +246,8 @@ async function launchDesktopShell(options) {
|
|
|
255
246
|
const certBundle = await (0, cert_store_1.loadOrCreateCert)();
|
|
256
247
|
server = new server_1.AiHubServer({
|
|
257
248
|
projectPath: options.projectPath,
|
|
258
|
-
|
|
249
|
+
// Issue #701: no local DB. Persona/manager-team state resolves through the hosted
|
|
250
|
+
// server via the Hub's default remote gateway (authenticated by the user's API key).
|
|
259
251
|
httpsPort,
|
|
260
252
|
certBundle,
|
|
261
253
|
folderPicker: async () => {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.HttpHubRemoteGateway = void 0;
|
|
7
|
+
exports.resolveFraimRemoteUrl = resolveFraimRemoteUrl;
|
|
8
|
+
const axios_1 = __importDefault(require("axios"));
|
|
9
|
+
function resolveFraimRemoteUrl(explicit) {
|
|
10
|
+
return (explicit || process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me').replace(/\/+$/, '');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Default HTTP-backed gateway. Mirrors the ProviderClient axios pattern:
|
|
14
|
+
* base URL from FRAIM_REMOTE_URL, `x-api-key` auth header, 10s timeout.
|
|
15
|
+
*/
|
|
16
|
+
class HttpHubRemoteGateway {
|
|
17
|
+
constructor(serverUrl) {
|
|
18
|
+
this.baseURL = resolveFraimRemoteUrl(serverUrl);
|
|
19
|
+
}
|
|
20
|
+
client(apiKey) {
|
|
21
|
+
return axios_1.default.create({
|
|
22
|
+
baseURL: this.baseURL,
|
|
23
|
+
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
|
|
24
|
+
timeout: 10000,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async getPersonaState(apiKey) {
|
|
28
|
+
if (!apiKey)
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
const res = await this.client(apiKey).get('/api/personas/me');
|
|
32
|
+
return res.data;
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
// 401 (no/expired key) or 404 (feature disabled) => treat as "no state" so the Hub
|
|
36
|
+
// renders a clean locked/not-signed-in view instead of crashing.
|
|
37
|
+
const status = err?.response?.status;
|
|
38
|
+
if (status === 401 || status === 404)
|
|
39
|
+
return null;
|
|
40
|
+
console.warn('[ai-hub] getPersonaState failed:', err?.message || err);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async listManagerTeam(apiKey) {
|
|
45
|
+
if (!apiKey)
|
|
46
|
+
return [];
|
|
47
|
+
try {
|
|
48
|
+
const res = await this.client(apiKey).get('/api/ai-hub/manager-team');
|
|
49
|
+
const team = (res.data?.team ?? []);
|
|
50
|
+
return Array.isArray(team) ? team : [];
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const status = err?.response?.status;
|
|
54
|
+
if (status === 401 || status === 404)
|
|
55
|
+
return [];
|
|
56
|
+
console.warn('[ai-hub] listManagerTeam failed:', err?.message || err);
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async assignManagerTeam(apiKey, personaKey) {
|
|
61
|
+
if (!apiKey)
|
|
62
|
+
return { status: 401, body: { error: 'authentication_required' } };
|
|
63
|
+
try {
|
|
64
|
+
const res = await this.client(apiKey).post('/api/ai-hub/manager-team/assign', { personaKey });
|
|
65
|
+
return { status: res.status, body: res.data };
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err?.response)
|
|
69
|
+
return { status: err.response.status, body: err.response.data };
|
|
70
|
+
console.warn('[ai-hub] assignManagerTeam failed:', err?.message || err);
|
|
71
|
+
return { status: 500, body: { error: 'internal_error' } };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async removeManagerTeam(apiKey, personaKey) {
|
|
75
|
+
if (!apiKey)
|
|
76
|
+
return;
|
|
77
|
+
try {
|
|
78
|
+
await this.client(apiKey).delete(`/api/ai-hub/manager-team/assign/${encodeURIComponent(personaKey)}`);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
const status = err?.response?.status;
|
|
82
|
+
if (status === 401 || status === 404)
|
|
83
|
+
return;
|
|
84
|
+
console.warn('[ai-hub] removeManagerTeam failed:', err?.message || err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.HttpHubRemoteGateway = HttpHubRemoteGateway;
|
|
@@ -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' &&
|
|
@@ -1154,14 +1083,14 @@ class AiHubServer {
|
|
|
1154
1083
|
explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
|
|
1155
1084
|
});
|
|
1156
1085
|
this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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();
|
|
1165
1094
|
this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
|
|
1166
1095
|
this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
|
|
1167
1096
|
this.app.use(express_1.default.json({ limit: '10mb' }));
|
|
@@ -1429,8 +1358,9 @@ class AiHubServer {
|
|
|
1429
1358
|
requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
|
|
1430
1359
|
}));
|
|
1431
1360
|
const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
|
|
1432
|
-
const
|
|
1433
|
-
const
|
|
1361
|
+
const resolvedApiKey = apiKey || preferences.apiKey;
|
|
1362
|
+
const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
|
|
1363
|
+
const managerTeam = await this.computeManagerTeam(resolvedApiKey);
|
|
1434
1364
|
const projects = this.knownProjects(normalizedProjectPath);
|
|
1435
1365
|
preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
|
|
1436
1366
|
this.preferencesStore.save(preferences);
|
|
@@ -1906,18 +1836,18 @@ class AiHubServer {
|
|
|
1906
1836
|
seatCount: 0,
|
|
1907
1837
|
seatsInUse: 0,
|
|
1908
1838
|
}));
|
|
1909
|
-
if (!this.dbService) {
|
|
1910
|
-
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
1911
|
-
}
|
|
1912
1839
|
try {
|
|
1913
|
-
|
|
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);
|
|
1914
1844
|
if (!state) {
|
|
1915
1845
|
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
1916
1846
|
}
|
|
1917
1847
|
// Legacy bypass: persona gating is not active for this workspace, so all personas are accessible.
|
|
1918
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).
|
|
1919
1850
|
if (!state.subscriptionActive) {
|
|
1920
|
-
const legacySeatCount = supportsManagerSeatAssignments(this.dbService) ? 0 : 1;
|
|
1921
1851
|
const allPersonas = allBundles.map((bundle) => ({
|
|
1922
1852
|
key: bundle.personaKey,
|
|
1923
1853
|
displayName: bundle.catalogMetadata.displayName,
|
|
@@ -1926,7 +1856,7 @@ class AiHubServer {
|
|
|
1926
1856
|
pricingLabel: '',
|
|
1927
1857
|
status: 'hired',
|
|
1928
1858
|
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
1929
|
-
seatCount:
|
|
1859
|
+
seatCount: 0,
|
|
1930
1860
|
seatsInUse: 0,
|
|
1931
1861
|
}));
|
|
1932
1862
|
return { personas: allPersonas, subscriptionActive: false, workspaceId: state.workspaceId, userKey: state.userId ?? null };
|
|
@@ -1942,13 +1872,13 @@ class AiHubServer {
|
|
|
1942
1872
|
seatCountByKey[e.personaKey] = (seatCountByKey[e.personaKey] ?? 0) + (e.jobCreditsRemaining ?? 1);
|
|
1943
1873
|
}
|
|
1944
1874
|
}
|
|
1945
|
-
// seatsInUse
|
|
1946
|
-
|
|
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.
|
|
1947
1878
|
const seatsInUseByKey = {};
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
}));
|
|
1879
|
+
const team = await this.remoteGateway.listManagerTeam(apiKey);
|
|
1880
|
+
for (const entry of team) {
|
|
1881
|
+
seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
|
|
1952
1882
|
}
|
|
1953
1883
|
const personas = allBundles.map((bundle) => ({
|
|
1954
1884
|
key: bundle.personaKey,
|
|
@@ -1961,23 +1891,16 @@ class AiHubServer {
|
|
|
1961
1891
|
seatCount: seatCountByKey[bundle.personaKey] ?? 0,
|
|
1962
1892
|
seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
|
|
1963
1893
|
}));
|
|
1964
|
-
return { personas, subscriptionActive: state.subscriptionActive, workspaceId, userKey: state.userId ?? null };
|
|
1894
|
+
return { personas, subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey: state.userId ?? null };
|
|
1965
1895
|
}
|
|
1966
1896
|
catch (err) {
|
|
1967
|
-
console.error('[ai-hub] persona
|
|
1897
|
+
console.error('[ai-hub] persona lookup failed:', err);
|
|
1968
1898
|
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
1969
1899
|
}
|
|
1970
1900
|
}
|
|
1971
|
-
async computeManagerTeam(
|
|
1972
|
-
|
|
1973
|
-
|
|
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
|
-
}
|
|
1901
|
+
async computeManagerTeam(apiKey) {
|
|
1902
|
+
// Issue #701: manager team comes from the hosted server, not local Mongo.
|
|
1903
|
+
return this.remoteGateway.listManagerTeam(apiKey);
|
|
1981
1904
|
}
|
|
1982
1905
|
registerRoutes() {
|
|
1983
1906
|
// Issue #512: Serve the account and analytics pages from public/.
|
|
@@ -2121,57 +2044,25 @@ class AiHubServer {
|
|
|
2121
2044
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
|
|
2122
2045
|
}
|
|
2123
2046
|
});
|
|
2124
|
-
// Issue #540: POST /api/ai-hub/manager-team/assign
|
|
2125
|
-
//
|
|
2126
|
-
//
|
|
2127
|
-
//
|
|
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.
|
|
2128
2051
|
this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
|
|
2129
2052
|
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
2130
2053
|
const { personaKey } = (req.body ?? {});
|
|
2131
2054
|
if (!personaKey)
|
|
2132
2055
|
return res.status(400).json({ error: 'personaKey required' });
|
|
2133
|
-
|
|
2134
|
-
|
|
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
|
-
}
|
|
2056
|
+
const result = await this.remoteGateway.assignManagerTeam(apiKey, personaKey);
|
|
2057
|
+
return res.status(result.status).json(result.body);
|
|
2156
2058
|
});
|
|
2157
|
-
// Issue #540: DELETE /api/ai-hub/manager-team/assign/:personaKey
|
|
2158
|
-
//
|
|
2059
|
+
// Issue #540/#701: DELETE /api/ai-hub/manager-team/assign/:personaKey
|
|
2060
|
+
// Proxies the seat release to the hosted server.
|
|
2159
2061
|
this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
|
|
2160
2062
|
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
2161
2063
|
const { personaKey } = req.params;
|
|
2162
|
-
|
|
2163
|
-
|
|
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
|
-
}
|
|
2064
|
+
await this.remoteGateway.removeManagerTeam(apiKey, personaKey);
|
|
2065
|
+
return res.status(204).end();
|
|
2175
2066
|
});
|
|
2176
2067
|
this.app.get('/api/ai-hub/conversations', (req, res) => {
|
|
2177
2068
|
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
@@ -3435,7 +3326,7 @@ class AiHubServer {
|
|
|
3435
3326
|
liveTotals.waitingDurationMs = Math.max(0, liveTotals.waitingDurationMs - overflow);
|
|
3436
3327
|
}
|
|
3437
3328
|
}
|
|
3438
|
-
return { ...run, stages, totals: liveTotals, artifacts:
|
|
3329
|
+
return { ...run, stages, totals: liveTotals, artifacts: run.artifacts || [] };
|
|
3439
3330
|
}
|
|
3440
3331
|
}
|
|
3441
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.185",
|
|
4
4
|
"description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|