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
- if (FraimDbService.connectionPromise) {
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
- return;
174
+ this.connected = true;
175
+ console.info(`[FRAIM] connect_ms=${Date.now() - started} cold=${cold}`);
128
176
  }
129
- FraimDbService.connectionPromise = (async () => {
130
- try {
131
- if (!FraimDbService.client) {
132
- FraimDbService.client = new mongodb_1.MongoClient(this.uri);
133
- await FraimDbService.client.connect();
134
- FraimDbService.db = FraimDbService.client.db(this.dbName);
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
- this.client = FraimDbService.client;
137
- this.db = FraimDbService.db;
138
- console.log(`[FRAIM] Connected to MongoDB: ${this.uri.replace(/:([^:@]+)@/, ':****@')}, Database: ${this.dbName}`);
139
- // Initialize collections and indexes
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
- event: { $first: '$$ROOT' },
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: { $first: '$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
- return results.map(result => ({
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: { $first: '$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
- return await collection.aggregate(pipeline).toArray();
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
- // All stage queries + the gate findOne start concurrently in a single Promise.all.
1968
- const [stageResults, gateScore] = await Promise.all([
1969
- Promise.all(quality_evidence_1.ALL_STAGE_CATEGORIES.map(stageCategory => collection
1970
- .find({ userId, stageCategory, ...(sanitizedRepo ? { repoIdentifier: sanitizedRepo } : {}), ...createdAtFilter })
1971
- .sort({ createdAt: 1 })
1972
- .limit(100)
1973
- .toArray()
1974
- .then(scores => ({ stageCategory, scores })))),
1975
- collection.findOne({ userId, jobName: 'triage-customer-needs', ...(sanitizedRepo ? { repoIdentifier: sanitizedRepo } : {}), ...createdAtFilter }, { sort: { createdAt: -1 } }),
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 scores = await this.db.collection('fraim_quality_scores')
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