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