fraim 2.0.361 → 2.0.362-next.2
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.
|
@@ -39,6 +39,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
39
39
|
const current_license_1 = require("./current-license");
|
|
40
40
|
const git_utils_1 = require("../core/utils/git-utils");
|
|
41
41
|
const quality_evidence_1 = require("../core/quality-evidence");
|
|
42
|
+
const license_presentation_1 = require("./license-presentation");
|
|
42
43
|
function sanitizeSessionRepoContext(repo) {
|
|
43
44
|
if (!repo || typeof repo !== 'object') {
|
|
44
45
|
return repo;
|
|
@@ -108,6 +109,7 @@ function removeLabels(existing, labelsToRemove) {
|
|
|
108
109
|
}
|
|
109
110
|
class FraimDbService {
|
|
110
111
|
constructor() {
|
|
112
|
+
this.connected = false;
|
|
111
113
|
this.usageEventsCollectionName = 'fraim_usage_events';
|
|
112
114
|
const url = process.env.MONGODB_URI || process.env.MONGO_DATABASE_URL;
|
|
113
115
|
if (!url) {
|
|
@@ -116,39 +118,86 @@ class FraimDbService {
|
|
|
116
118
|
this.uri = url;
|
|
117
119
|
this.dbName = (0, git_utils_1.determineDatabaseName)();
|
|
118
120
|
}
|
|
121
|
+
/** Driver topology recovery plus one health probe; never runs schema work. */
|
|
122
|
+
async recoverConnection() {
|
|
123
|
+
if (!this.recovering) {
|
|
124
|
+
this.recovering = (async () => {
|
|
125
|
+
if (!this.client || !this.db)
|
|
126
|
+
return this.connect();
|
|
127
|
+
await this.client.connect();
|
|
128
|
+
await this.db.command({ ping: 1 });
|
|
129
|
+
})();
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await this.recovering;
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
this.recovering = undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
119
138
|
async connect() {
|
|
139
|
+
if (this.connected)
|
|
140
|
+
return;
|
|
141
|
+
if (this.connecting)
|
|
142
|
+
return this.connecting;
|
|
143
|
+
this.connecting = this.acquireConnection();
|
|
144
|
+
try {
|
|
145
|
+
await this.connecting;
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
this.connecting = undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async acquireConnection() {
|
|
152
|
+
const started = Date.now();
|
|
153
|
+
const cold = !FraimDbService.connectionPromise;
|
|
120
154
|
FraimDbService.refCount++;
|
|
121
|
-
|
|
155
|
+
try {
|
|
156
|
+
if (!FraimDbService.connectionPromise) {
|
|
157
|
+
FraimDbService.connectionPromise = (async () => {
|
|
158
|
+
const client = new mongodb_1.MongoClient(this.uri);
|
|
159
|
+
try {
|
|
160
|
+
await client.connect();
|
|
161
|
+
FraimDbService.client = client;
|
|
162
|
+
FraimDbService.db = client.db(this.dbName);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
await client.close();
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
}
|
|
122
170
|
await FraimDbService.connectionPromise;
|
|
123
171
|
this.client = FraimDbService.client;
|
|
124
172
|
this.db = FraimDbService.db;
|
|
125
|
-
// Ensure per-instance collection handles are initialized when reusing shared connection.
|
|
126
173
|
await this.initializeCollections();
|
|
127
|
-
|
|
174
|
+
this.connected = true;
|
|
175
|
+
console.info(`[FRAIM] connect_ms=${Date.now() - started} cold=${cold}`);
|
|
128
176
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
177
|
+
catch (error) {
|
|
178
|
+
FraimDbService.refCount = Math.max(0, FraimDbService.refCount - 1);
|
|
179
|
+
FraimDbService.connectionPromise = undefined;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Called explicitly by the server before it accepts requests. */
|
|
184
|
+
async initializeIndexesAtStartup() {
|
|
185
|
+
if (!this.db)
|
|
186
|
+
throw new Error('Connect before initializing indexes');
|
|
187
|
+
if (!FraimDbService.indexInitialization) {
|
|
188
|
+
FraimDbService.indexInitialization = (async () => {
|
|
189
|
+
const started = Date.now();
|
|
190
|
+
const results = await Promise.allSettled([
|
|
191
|
+
this.initializeIndexes(), this.initializeUsageEventsCollection(),
|
|
192
|
+
]);
|
|
193
|
+
for (const result of results) {
|
|
194
|
+
if (result.status === 'rejected')
|
|
195
|
+
console.error('[FRAIM] Startup index initialization failed:', result.reason);
|
|
135
196
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
await this.initializeCollections();
|
|
141
|
-
await this.initializeIndexes();
|
|
142
|
-
await this.initializeUsageEventsCollection();
|
|
143
|
-
console.log(`✅ Connected to Fraim DB: ${this.dbName}`);
|
|
144
|
-
}
|
|
145
|
-
catch (error) {
|
|
146
|
-
FraimDbService.connectionPromise = undefined;
|
|
147
|
-
console.error('❌ Failed to connect to MongoDB:', error);
|
|
148
|
-
throw error;
|
|
149
|
-
}
|
|
150
|
-
})();
|
|
151
|
-
await FraimDbService.connectionPromise;
|
|
197
|
+
console.info(`[FRAIM] index_init_ms=${Date.now() - started}`);
|
|
198
|
+
})();
|
|
199
|
+
}
|
|
200
|
+
await FraimDbService.indexInitialization;
|
|
152
201
|
}
|
|
153
202
|
async initializeCollections() {
|
|
154
203
|
if (!this.db)
|
|
@@ -1332,10 +1381,24 @@ class FraimDbService {
|
|
|
1332
1381
|
});
|
|
1333
1382
|
return latest?.createdAt ?? null;
|
|
1334
1383
|
}
|
|
1384
|
+
/** Explicit legacy/detail read only; never part of the window-scoped overview. */
|
|
1385
|
+
async getLatestUsageEventsByUser(userIds, types) {
|
|
1386
|
+
if (!this.db)
|
|
1387
|
+
throw new Error('DB not connected');
|
|
1388
|
+
if (!userIds.length)
|
|
1389
|
+
return new Map();
|
|
1390
|
+
const rows = await this.db.collection(this.usageEventsCollectionName).aggregate([
|
|
1391
|
+
{ $match: { userId: { $in: userIds }, ...(types?.length ? { type: { $in: types } } : {}) } },
|
|
1392
|
+
{ $group: { _id: '$userId', lastEventAt: { $max: '$createdAt' } } },
|
|
1393
|
+
]).toArray();
|
|
1394
|
+
const latest = new Map(userIds.map(id => [id, null]));
|
|
1395
|
+
for (const row of rows)
|
|
1396
|
+
latest.set(row._id, row.lastEventAt);
|
|
1397
|
+
return latest;
|
|
1398
|
+
}
|
|
1335
1399
|
/** Keep the earliest observed start per execution before grouping into time buckets. */
|
|
1336
1400
|
distinctJobStartStages() {
|
|
1337
1401
|
return [
|
|
1338
|
-
{ $sort: { createdAt: 1 } },
|
|
1339
1402
|
{ $group: {
|
|
1340
1403
|
_id: { $cond: [
|
|
1341
1404
|
{ $and: [{ $eq: ['$type', 'job'] }, { $ne: [{ $ifNull: ['$jobId', ''] }, ''] }] },
|
|
@@ -1343,11 +1406,105 @@ class FraimDbService {
|
|
|
1343
1406
|
// Preserve legacy event counts when no execution ID is available.
|
|
1344
1407
|
'$_id',
|
|
1345
1408
|
] },
|
|
1346
|
-
|
|
1409
|
+
// Array comparison is lexicographic: ID breaks timestamp ties without
|
|
1410
|
+
// a compound sort (unsupported by Cosmos without another index).
|
|
1411
|
+
event: { $min: { $concatArrays: [['$createdAt', '$_id', '$name', '$type', '$userId',
|
|
1412
|
+
{ $ifNull: ['$category', null] }, { $ifNull: ['$success', false] },
|
|
1413
|
+
{ $ifNull: ['$tokenSnapshot', null] }]] } },
|
|
1414
|
+
} },
|
|
1415
|
+
{ $project: {
|
|
1416
|
+
_id: { $arrayElemAt: ['$event', 1] },
|
|
1417
|
+
createdAt: { $arrayElemAt: ['$event', 0] },
|
|
1418
|
+
name: { $arrayElemAt: ['$event', 2] },
|
|
1419
|
+
type: { $arrayElemAt: ['$event', 3] },
|
|
1420
|
+
userId: { $arrayElemAt: ['$event', 4] },
|
|
1421
|
+
category: { $arrayElemAt: ['$event', 5] },
|
|
1422
|
+
success: { $arrayElemAt: ['$event', 6] },
|
|
1423
|
+
tokenSnapshot: { $arrayElemAt: ['$event', 7] },
|
|
1347
1424
|
} },
|
|
1348
|
-
{ $replaceRoot: { newRoot: '$event' } },
|
|
1349
1425
|
];
|
|
1350
1426
|
}
|
|
1427
|
+
/** Window-scoped overview: four queries regardless of the number of members. */
|
|
1428
|
+
async getTeamMemberSummaryData(userIds, timeWindow, repoIdentifier) {
|
|
1429
|
+
if (!this.db)
|
|
1430
|
+
throw new Error('DB not connected');
|
|
1431
|
+
const ids = [...new Set(userIds)];
|
|
1432
|
+
if (!ids.length)
|
|
1433
|
+
return [];
|
|
1434
|
+
const repo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1435
|
+
const scope = { userId: { $in: ids }, createdAt: this.buildCreatedAtMatch(timeWindow),
|
|
1436
|
+
...(repo ? { repoIdentifier: repo } : {}) };
|
|
1437
|
+
const events = this.db.collection(this.usageEventsCollectionName);
|
|
1438
|
+
// Execute sequentially to avoid an individual overview multiplying DB concurrency.
|
|
1439
|
+
const totals = await events.aggregate([
|
|
1440
|
+
{ $match: { ...scope, type: 'job' } },
|
|
1441
|
+
{ $group: { _id: '$userId', totalEvents: { $sum: 1 }, lastEventAt: { $max: '$createdAt' } } },
|
|
1442
|
+
]).toArray();
|
|
1443
|
+
const components = await events.aggregate([
|
|
1444
|
+
{ $match: { ...scope, type: 'job', 'args.action': { $ne: 'complete' }, 'args.isReload': { $ne: true } } },
|
|
1445
|
+
...this.distinctJobStartStages(),
|
|
1446
|
+
{ $group: { _id: { userId: '$userId', name: '$name' },
|
|
1447
|
+
category: { $min: '$category' }, count: { $sum: 1 } } },
|
|
1448
|
+
]).toArray();
|
|
1449
|
+
const licenses = await this.db.collection('fraim_api_keys')
|
|
1450
|
+
.find({ userId: { $in: ids.map(id => id.trim().toLowerCase()) } }).toArray();
|
|
1451
|
+
const quality = await this.db.collection('fraim_quality_scores').aggregate([
|
|
1452
|
+
{ $match: scope },
|
|
1453
|
+
{ $group: {
|
|
1454
|
+
_id: { userId: '$userId', stage: { $cond: [
|
|
1455
|
+
{ $eq: ['$jobName', 'triage-customer-needs'] }, 'customer-development', '$stageCategory',
|
|
1456
|
+
] } },
|
|
1457
|
+
latest: { $max: { $cond: [
|
|
1458
|
+
{ $in: [{ $type: '$scores.composite' }, ['double', 'int', 'long', 'decimal']] },
|
|
1459
|
+
['$createdAt', '$_id', '$scores.composite'], null,
|
|
1460
|
+
] } },
|
|
1461
|
+
gate: { $max: { $cond: [
|
|
1462
|
+
{ $eq: ['$jobName', 'triage-customer-needs'] },
|
|
1463
|
+
['$createdAt', '$_id', { $ifNull: ['$scores.gateDecision', null] }, { $ifNull: ['$scores.gaps', []] }], null,
|
|
1464
|
+
] } },
|
|
1465
|
+
} },
|
|
1466
|
+
]).toArray();
|
|
1467
|
+
const totalsByUser = new Map(totals.map(row => [row._id, row]));
|
|
1468
|
+
const componentsByUser = new Map();
|
|
1469
|
+
for (const row of components) {
|
|
1470
|
+
const list = componentsByUser.get(row._id.userId) ?? [];
|
|
1471
|
+
list.push({ name: row._id.name, category: row.category, count: row.count });
|
|
1472
|
+
componentsByUser.set(row._id.userId, list);
|
|
1473
|
+
}
|
|
1474
|
+
const licensesByUser = new Map();
|
|
1475
|
+
for (const key of licenses) {
|
|
1476
|
+
const list = licensesByUser.get(key.userId) ?? [];
|
|
1477
|
+
list.push(key);
|
|
1478
|
+
licensesByUser.set(key.userId, list);
|
|
1479
|
+
}
|
|
1480
|
+
const qualityByUser = new Map();
|
|
1481
|
+
for (const row of quality) {
|
|
1482
|
+
const stages = qualityByUser.get(row._id.userId) ?? new Map();
|
|
1483
|
+
stages.set(row._id.stage, row);
|
|
1484
|
+
qualityByUser.set(row._id.userId, stages);
|
|
1485
|
+
}
|
|
1486
|
+
return ids.map(memberId => {
|
|
1487
|
+
const keys = licensesByUser.get(memberId.trim().toLowerCase()) ?? [];
|
|
1488
|
+
const selected = (0, current_license_1.selectCurrentLicense)(keys, false);
|
|
1489
|
+
// Match the existing single-member revoked-account presentation.
|
|
1490
|
+
const fallback = !selected && keys.length
|
|
1491
|
+
? { ...[...keys].sort((a, b) => a.key.localeCompare(b.key))[0], status: 'expired' } : null;
|
|
1492
|
+
const total = totalsByUser.get(memberId);
|
|
1493
|
+
const lastEventAt = total?.lastEventAt ?? null;
|
|
1494
|
+
return {
|
|
1495
|
+
memberId, totalEvents: total?.totalEvents ?? 0, lastEventAt, lastActiveAt: lastEventAt,
|
|
1496
|
+
hasFraimSetup: keys.length > 0, ...(0, license_presentation_1.serializeLicenseLifecycle)(selected ?? fallback),
|
|
1497
|
+
components: componentsByUser.get(memberId) ?? [],
|
|
1498
|
+
qualityStages: quality_evidence_1.ALL_STAGE_CATEGORIES.map(stageCategory => {
|
|
1499
|
+
const row = qualityByUser.get(memberId)?.get(stageCategory);
|
|
1500
|
+
return { stageCategory, stageName: quality_evidence_1.STAGE_DISPLAY_NAMES[stageCategory] ?? stageCategory,
|
|
1501
|
+
latestComposite: row?.latest?.[2] ?? null,
|
|
1502
|
+
latestGate: row?.latest && row?.gate?.[2]
|
|
1503
|
+
? { decision: row.gate[2], gaps: row.gate[3] } : null };
|
|
1504
|
+
}),
|
|
1505
|
+
};
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1351
1508
|
/**
|
|
1352
1509
|
* Get top components by usage count
|
|
1353
1510
|
*/
|
|
@@ -1380,17 +1537,19 @@ class FraimDbService {
|
|
|
1380
1537
|
{
|
|
1381
1538
|
$group: {
|
|
1382
1539
|
_id: { name: '$name', type: '$type' },
|
|
1383
|
-
category: { $
|
|
1540
|
+
category: { $min: '$category' },
|
|
1384
1541
|
count: { $sum: 1 },
|
|
1385
1542
|
successfulEvents: { $sum: { $cond: ['$success', 1, 0] } },
|
|
1386
1543
|
lastUsed: { $max: '$createdAt' }
|
|
1387
1544
|
}
|
|
1388
1545
|
},
|
|
1389
|
-
{ $sort: { count: -1 } },
|
|
1390
|
-
{ $limit: limit }
|
|
1391
1546
|
];
|
|
1392
1547
|
const results = await collection.aggregate(pipeline).toArray();
|
|
1393
|
-
|
|
1548
|
+
// Sorting tied counts in memory avoids a Cosmos compound ORDER BY while
|
|
1549
|
+
// keeping the limit boundary stable across repeated refreshes.
|
|
1550
|
+
results.sort((a, b) => b.count - a.count || String(a._id.name).localeCompare(String(b._id.name))
|
|
1551
|
+
|| String(a._id.type).localeCompare(String(b._id.type)));
|
|
1552
|
+
return results.slice(0, limit).map(result => ({
|
|
1394
1553
|
name: result._id.name,
|
|
1395
1554
|
type: result._id.type,
|
|
1396
1555
|
category: result.category,
|
|
@@ -1544,7 +1703,7 @@ class FraimDbService {
|
|
|
1544
1703
|
date: { $dateToString: { format, date: '$createdAt' } },
|
|
1545
1704
|
name: '$name'
|
|
1546
1705
|
},
|
|
1547
|
-
category: { $
|
|
1706
|
+
category: { $min: '$category' },
|
|
1548
1707
|
count: { $sum: 1 },
|
|
1549
1708
|
successRate: { $avg: { $cond: ['$success', 100, 0] } },
|
|
1550
1709
|
minDate: { $min: '$createdAt' },
|
|
@@ -1578,7 +1737,9 @@ class FraimDbService {
|
|
|
1578
1737
|
},
|
|
1579
1738
|
{ $sort: { date: 1 } }
|
|
1580
1739
|
];
|
|
1581
|
-
|
|
1740
|
+
const results = await collection.aggregate(pipeline).toArray();
|
|
1741
|
+
return results.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
1742
|
+
|| String(a.name).localeCompare(String(b.name)));
|
|
1582
1743
|
}
|
|
1583
1744
|
async getComponentTrend(componentName, componentType, timeWindow, userId, dateUnit, repoIdentifier) {
|
|
1584
1745
|
if (!this.db)
|
|
@@ -1724,6 +1885,9 @@ class FraimDbService {
|
|
|
1724
1885
|
return {};
|
|
1725
1886
|
}
|
|
1726
1887
|
async close() {
|
|
1888
|
+
if (!this.connected)
|
|
1889
|
+
return;
|
|
1890
|
+
this.connected = false;
|
|
1727
1891
|
FraimDbService.refCount = Math.max(0, FraimDbService.refCount - 1);
|
|
1728
1892
|
if (FraimDbService.refCount === 0 && FraimDbService.client) {
|
|
1729
1893
|
await FraimDbService.client.close();
|
|
@@ -1731,6 +1895,8 @@ class FraimDbService {
|
|
|
1731
1895
|
FraimDbService.db = undefined;
|
|
1732
1896
|
FraimDbService.connectionPromise = undefined;
|
|
1733
1897
|
}
|
|
1898
|
+
this.client = undefined;
|
|
1899
|
+
this.db = undefined;
|
|
1734
1900
|
}
|
|
1735
1901
|
getClient() {
|
|
1736
1902
|
return FraimDbService.client;
|
|
@@ -1964,16 +2130,17 @@ class FraimDbService {
|
|
|
1964
2130
|
const collection = this.db.collection('fraim_quality_scores');
|
|
1965
2131
|
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1966
2132
|
const createdAtFilter = this.buildQualityCreatedAtFilter(periodDays, startDate, endDate);
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2133
|
+
const records = await collection.find({ userId,
|
|
2134
|
+
...(sanitizedRepo ? { repoIdentifier: sanitizedRepo } : {}), ...createdAtFilter,
|
|
2135
|
+
}).toArray();
|
|
2136
|
+
// Match the overview's deterministic latest selection, including histories
|
|
2137
|
+
// longer than 100 entries. Sort locally to avoid a Cosmos compound sort.
|
|
2138
|
+
records.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
2139
|
+
|| String(b._id).localeCompare(String(a._id)));
|
|
2140
|
+
const gateScore = records.find(record => record.jobName === 'triage-customer-needs');
|
|
2141
|
+
const stageResults = quality_evidence_1.ALL_STAGE_CATEGORIES.map(stageCategory => ({ stageCategory,
|
|
2142
|
+
scores: this.recentQualityAssessments(records.filter(record => record.stageCategory === stageCategory)),
|
|
2143
|
+
}));
|
|
1977
2144
|
const stages = stageResults.map(({ stageCategory, scores }) => {
|
|
1978
2145
|
// Filter to records that have an actual composite score — some records
|
|
1979
2146
|
// (e.g., triage-customer-needs gate decisions) intentionally omit composite
|
|
@@ -2028,6 +2195,13 @@ class FraimDbService {
|
|
|
2028
2195
|
});
|
|
2029
2196
|
return stages;
|
|
2030
2197
|
}
|
|
2198
|
+
/** Both tiles and detail show the latest 100 scored assessments, chronologically. */
|
|
2199
|
+
recentQualityAssessments(records) {
|
|
2200
|
+
return records.filter(record => typeof record.scores?.composite === 'number')
|
|
2201
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
2202
|
+
|| String(b._id).localeCompare(String(a._id)))
|
|
2203
|
+
.slice(0, 100).reverse();
|
|
2204
|
+
}
|
|
2031
2205
|
/**
|
|
2032
2206
|
* Get full assessment history for a single stage (tile click detail view).
|
|
2033
2207
|
*/
|
|
@@ -2036,11 +2210,10 @@ class FraimDbService {
|
|
|
2036
2210
|
throw new Error('DB not connected');
|
|
2037
2211
|
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
2038
2212
|
const createdAtFilter = this.buildQualityCreatedAtFilter(periodDays, startDate, endDate);
|
|
2039
|
-
const
|
|
2213
|
+
const records = await this.db.collection('fraim_quality_scores')
|
|
2040
2214
|
.find({ userId, stageCategory, ...(sanitizedRepo ? { repoIdentifier: sanitizedRepo } : {}), ...createdAtFilter })
|
|
2041
|
-
.sort({ createdAt: 1 })
|
|
2042
|
-
.limit(100)
|
|
2043
2215
|
.toArray();
|
|
2216
|
+
const scores = this.recentQualityAssessments(records);
|
|
2044
2217
|
const composites = scores.map(s => s.scores.composite ?? 0);
|
|
2045
2218
|
const avgComposite = composites.length > 0
|
|
2046
2219
|
? Math.round(composites.reduce((a, b) => a + b, 0) / composites.length * 10) / 10
|